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/map.ts
// Port of BrowserQuest server/js/map.js and checkpoint.js (MPL-2.0).
import { randomInt, tileIndexToGridPosition } from '../shared/utils.js';

export interface ServerMapData {
  width: number;
  height: number;
  collisions: number[];
  doors: { x: number; y: number; tx: number; ty: number }[];
  checkpoints: { id: number; x: number; y: number; w: number; h: number; s?: number }[];
  roamingAreas: { id: number; x: number; y: number; width: number; height: number; type: string; nb: number }[];
  chestAreas: { x: number; y: number; w: number; h: number; i: number[]; tx: number; ty: number }[];
  staticChests: { x: number; y: number; i: number[] }[];
  staticEntities: Record<string, string>;
}

export interface Position {
  x: number;
  y: number;
}

export class Checkpoint {
  constructor(
    readonly id: number,
    readonly x: number,
    readonly y: number,
    readonly width: number,
    readonly height: number,
  ) {}

  getRandomPosition(): Position {
    return {
      x: this.x + randomInt(0, this.width - 1),
      y: this.y + randomInt(0, this.height - 1),
    };
  }
}

const pos = (x: number, y: number): Position => ({ x, y });

export class WorldMap {
  readonly width: number;
  readonly height: number;
  readonly mobAreas: ServerMapData['roamingAreas'];
  readonly chestAreas: ServerMapData['chestAreas'];
  readonly staticChests: ServerMapData['staticChests'];
  readonly staticEntities: ServerMapData['staticEntities'];
  readonly zoneWidth = 28;
  readonly zoneHeight = 12;
  readonly groupWidth: number;
  readonly groupHeight: number;
  readonly checkpoints = new Map<number, Checkpoint>();
  readonly startingAreas: Checkpoint[] = [];
  private readonly grid: Uint8Array;
  private readonly connectedGroups = new Map<string, Position[]>();

  constructor(map: ServerMapData) {
    this.width = map.width;
    this.height = map.height;
    this.mobAreas = map.roamingAreas;
    this.chestAreas = map.chestAreas;
    this.staticChests = map.staticChests;
    this.staticEntities = map.staticEntities;
    this.groupWidth = Math.floor(this.width / this.zoneWidth);
    this.groupHeight = Math.floor(this.height / this.zoneHeight);

    // generateCollisionGrid: row-major tile index starting at 0.
    this.grid = new Uint8Array(this.width * this.height);
    for (const tileIndex of map.collisions) {
      if (tileIndex >= 0 && tileIndex < this.grid.length) this.grid[tileIndex] = 1;
    }

    this.initConnectedGroups(map.doors);
    for (const cp of map.checkpoints) {
      const checkpoint = new Checkpoint(cp.id, cp.x, cp.y, cp.w, cp.h);
      this.checkpoints.set(checkpoint.id, checkpoint);
      if (cp.s === 1) this.startingAreas.push(checkpoint);
    }
  }

  tileIndexToGridPosition(tileNum: number): Position {
    return tileIndexToGridPosition(tileNum, this.width);
  }

  /** Same quirk as the original: row 0 and column 0 count as out of bounds. */
  isOutOfBounds(x: number, y: number): boolean {
    return x <= 0 || x >= this.width || y <= 0 || y >= this.height;
  }

  isColliding(x: number, y: number): boolean {
    if (this.isOutOfBounds(x, y)) return false;
    return this.grid[y * this.width + x] === 1;
  }

  groupIdToGroupPosition(id: string): Position {
    const [x, y] = id.split('-');
    return pos(parseInt(x, 10), parseInt(y, 10));
  }

  forEachGroup(callback: (id: string) => void): void {
    for (let x = 0; x < this.groupWidth; x += 1) {
      for (let y = 0; y < this.groupHeight; y += 1) callback(`${x}-${y}`);
    }
  }

  getGroupIdFromPosition(x: number, y: number): string {
    const gx = Math.floor((x - 1) / this.zoneWidth);
    const gy = Math.floor((y - 1) / this.zoneHeight);
    return `${gx}-${gy}`;
  }

  getAdjacentGroupPositions(id: string): Position[] {
    const { x, y } = this.groupIdToGroupPosition(id);
    const list = [
      pos(x - 1, y - 1), pos(x, y - 1), pos(x + 1, y - 1),
      pos(x - 1, y), pos(x, y), pos(x + 1, y),
      pos(x - 1, y + 1), pos(x, y + 1), pos(x + 1, y + 1),
    ];
    // Groups connected via doors. The original compared only x here (a typo
    // in equalPositions); comparing both axes keeps door-linked groups adjacent.
    for (const p of this.connectedGroups.get(id) ?? []) {
      if (!list.some((g) => g.x === p.x && g.y === p.y)) list.push(p);
    }
    return list.filter(
      (p) => !(p.x < 0 || p.y < 0 || p.x >= this.groupWidth || p.y >= this.groupHeight),
    );
  }

  forEachAdjacentGroup(groupId: string | null, callback: (id: string) => void): void {
    if (!groupId) return;
    for (const p of this.getAdjacentGroupPositions(groupId)) callback(`${p.x}-${p.y}`);
  }

  private initConnectedGroups(doors: ServerMapData['doors']): void {
    for (const door of doors) {
      const groupId = this.getGroupIdFromPosition(door.x, door.y);
      const connected = this.groupIdToGroupPosition(this.getGroupIdFromPosition(door.tx, door.ty));
      const list = this.connectedGroups.get(groupId);
      if (list) list.push(connected);
      else this.connectedGroups.set(groupId, [connected]);
    }
  }

  getCheckpoint(id: number): Checkpoint | undefined {
    return this.checkpoints.get(id);
  }

  getRandomStartingPosition(): Position {
    const area = this.startingAreas[randomInt(0, this.startingAreas.length - 1)];
    return area.getRandomPosition();
  }
}