SOURCE / PINNED RELEASE
Made of little things.
Browser Side Quest
- Release
- b9d42f5768e4…
- Author-recorded commit
- c267690145fa…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/browser-side-quest
Archive hash verified: 6d4b90b8a5f328f7…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Port of BrowserQuest server/js/{area,mobarea,chestarea}.js (MPL-2.0).
import { getKindFromString } from '../shared/gametypes.js';
import { random } from '../shared/utils.js';
import { Mob, type Entity } from './entities.js';
import type { World } from './world.js';
abstract class BaseArea {
entities: Mob[] = [];
hasCompletelyRespawned = true;
nbEntities = 0;
private emptyCallback: (() => void) | null = null;
constructor(
readonly id: number,
readonly x: number,
readonly y: number,
readonly width: number,
readonly height: number,
protected readonly world: World,
) {}
protected getRandomPositionInsideArea(): { x: number; y: number } {
for (;;) {
const pos = { x: this.x + random(this.width + 1), y: this.y + random(this.height + 1) };
if (this.world.isValidPosition(pos.x, pos.y)) return pos;
}
}
removeFromArea(entity: Entity): void {
const i = this.entities.findIndex((e) => e.id === entity.id);
// _.indexOf returns -1 when missing; splice(-1, 1) then drops the last entry.
this.entities.splice(i, 1);
if (this.isEmpty() && this.hasCompletelyRespawned && this.emptyCallback) {
this.hasCompletelyRespawned = false;
this.emptyCallback();
}
}
addToArea(entity: Mob | null): void {
if (entity) {
this.entities.push(entity);
entity.area = this as unknown as Area;
this.world.addMob(entity);
}
if (this.isFull()) this.hasCompletelyRespawned = true;
}
setNumberOfEntities(nb: number): void {
this.nbEntities = nb;
}
isEmpty(): boolean {
return !this.entities.some((entity) => !entity.isDead);
}
isFull(): boolean {
return !this.isEmpty() && this.nbEntities === this.entities.length;
}
onEmpty(callback: () => void): void {
this.emptyCallback = callback;
}
}
export class MobArea extends BaseArea {
readonly kind = 'mob' as const;
constructor(
id: number,
readonly nb: number,
readonly mobKind: string,
x: number,
y: number,
width: number,
height: number,
world: World,
) {
super(id, x, y, width, height, world);
this.setNumberOfEntities(nb);
}
spawnMobs(): void {
for (let i = 0; i < this.nb; i += 1) this.addToArea(this.createMobInsideArea());
}
private createMobInsideArea(): Mob {
const k = getKindFromString(this.mobKind)!;
const pos = this.getRandomPositionInsideArea();
const mob = new Mob(`1${this.id}${k}${this.entities.length}`, k, pos.x, pos.y, this.world.scheduler);
mob.onMove(this.world.onMobMoveCallback.bind(this.world));
return mob;
}
respawnMob(mob: Mob, delay: number): void {
this.removeFromArea(mob);
this.world.scheduler.later(() => {
const pos = this.getRandomPositionInsideArea();
mob.x = pos.x;
mob.y = pos.y;
mob.isDead = false;
this.addToArea(mob);
this.world.addMob(mob);
}, delay);
}
}
export class ChestArea extends BaseArea {
readonly kind = 'chest' as const;
constructor(
id: number,
x: number,
y: number,
width: number,
height: number,
readonly chestX: number,
readonly chestY: number,
readonly items: number[],
world: World,
) {
super(id, x, y, width, height, world);
}
contains(entity: Entity | null): boolean {
if (!entity) return false;
return (
entity.x >= this.x &&
entity.y >= this.y &&
entity.x < this.x + this.width &&
entity.y < this.y + this.height
);
}
}
export type Area = MobArea | ChestArea;
