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/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();
}
}
