Back to Browser Side Quest
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.

src/sim/areas.ts
// 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;