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/worldserver.js (MPL-2.0).
// Runs inside one browser tab: the shard host for multiplayer, or the local tab in solo play.
import { Messages, getKindAsString, getKindFromString, isItem, isMob, isNpc, Entities } from '../shared/gametypes.js';
import { Properties } from '../shared/properties.js';
import { random } from '../shared/utils.js';
import { ChestArea, MobArea } from './areas.js';
import { Character, Chest, Entity, Item, Mob, Npc, type Message } from './entities.js';
import { WorldMap, type ServerMapData } from './map.js';
import { Player, type Connection } from './player.js';
import { Scheduler } from './scheduler.js';
interface Group {
entities: Map<number, Entity>;
players: number[];
incoming: Entity[];
}
export class World {
readonly ups = 50;
readonly scheduler = new Scheduler();
readonly map: WorldMap;
readonly entities = new Map<number, Entity>();
readonly players = new Map<number, Player>();
readonly mobs = new Map<number, Mob>();
readonly items = new Map<number, Item>();
readonly npcs = new Map<number, Npc>();
readonly mobAreas: MobArea[] = [];
readonly chestAreas: ChestArea[] = [];
readonly groups = new Map<string, Group>();
private readonly outgoingQueues = new Map<number, Message[]>();
private itemCount = 0;
playerCount = 0;
private zoneGroupsReady = false;
private connectionCounter = 0;
onPopulationChange: ((count: number) => void) | null = null;
constructor(mapData: ServerMapData) {
this.map = new WorldMap(mapData);
}
run(): void {
this.initZoneGroups();
// Populate all mob "roaming" areas
for (const a of this.map.mobAreas) {
const area = new MobArea(a.id, a.nb, a.type, a.x, a.y, a.width, a.height, this);
area.spawnMobs();
this.mobAreas.push(area);
}
// Create all chest areas
for (const a of this.map.chestAreas) {
const area = new ChestArea(0, a.x, a.y, a.w, a.h, a.tx, a.ty, a.i, this);
this.chestAreas.push(area);
area.onEmpty(() => this.handleEmptyChestArea(area));
}
// Spawn static chests
for (const chest of this.map.staticChests) {
this.addStaticItem(this.createChest(chest.x, chest.y, chest.i));
}
this.spawnStaticEntities();
// Set maximum number of entities contained in each chest area
for (const area of this.chestAreas) area.setNumberOfEntities(area.entities.length);
const regenCount = this.ups * 2;
let updateCount = 0;
this.scheduler.every(() => {
this.processGroups();
this.processQueues();
if (updateCount < regenCount) {
updateCount += 1;
} else {
this.onRegenTick();
updateCount = 0;
}
}, 1000 / this.ups);
}
dispose(): void {
this.scheduler.dispose();
}
/** Allocate a connection id with the original '5' + random(99) + counter shape. */
nextConnectionId(): number {
return parseInt(`5${random(99)}${this.connectionCounter++}`, 10);
}
/**
* A client connected: create its session. The client then sends HELLO.
* `resume` (host migration / shard switch) places the first spawn at the
* player's last position instead of a starting area, when that tile is valid.
*/
connect(connection: Connection, resume?: { x: number; y: number; checkpoint?: number }): Player {
const player = new Player(connection, this);
let resumeAt = resume && this.isValidPosition(resume.x, resume.y) ? { x: resume.x, y: resume.y } : null;
if (resume?.checkpoint !== undefined) player.lastCheckpoint = this.map.getCheckpoint(resume.checkpoint) ?? null;
player.onRequestPosition(() => {
if (resumeAt) {
const pos = resumeAt;
resumeAt = null;
return pos;
}
return player.lastCheckpoint
? player.lastCheckpoint.getRandomPosition()
: this.map.getRandomStartingPosition();
});
return player;
}
enterCallback(player: Player): void {
if (!player.hasEnteredGame) this.incrementPlayerCount();
// Number of players in this world
this.pushToPlayer(player, [Messages.POPULATION, this.playerCount, this.playerCount]);
this.pushRelevantEntityListTo(player);
// The original updated a player's zone group only on the client's ZONE message,
// sent when its fixed 30x14 camera flipped screens, which lined up with the 28x12
// groups. The port's camera is fitted to the frame, so membership follows the
// player's position on every move instead (ZONE still works and is harmless).
const updateZone = (): void => {
if (this.handleEntityGroupMembership(player)) {
this.pushToPreviousGroups(player, [Messages.DESTROY, player.id]);
this.pushRelevantEntityListTo(player);
}
};
const moveCallback = (): void => {
updateZone();
player.forEachAttacker((attacker) => {
const mob = attacker as Mob;
// Fix from the community fork: drop a stale link to a mob that lost its target.
if (mob.target === null) {
player.removeAttacker(mob);
return;
}
const target = this.getEntityById(mob.target);
if (target) {
const pos = this.findPositionNextTo(mob, target);
if (mob.distanceToSpawningPoint(pos.x, pos.y) > 50) {
mob.clearTarget();
mob.forgetEveryone();
player.removeAttacker(mob);
} else {
this.moveEntity(mob, pos.x, pos.y);
}
}
});
};
player.moveCallback = moveCallback;
player.lootMoveCallback = moveCallback;
player.zoneCallback = updateZone;
player.broadcastCallback = (message, ignoreSelf) => {
this.pushToAdjacentGroups(player.group, message, ignoreSelf ? player.id : null);
};
player.broadcastZoneCallback = (message, ignoreSelf) => {
if (player.group) this.pushToGroup(player.group, message, ignoreSelf ? player.id : null);
};
player.exitCallback = () => {
this.removePlayer(player);
this.decrementPlayerCount();
};
}
private onRegenTick(): void {
this.forEachCharacter((character) => {
if (!character.hasFullHealth()) {
character.regenHealthBy(Math.floor(character.maxHitPoints / 25));
if (character instanceof Player) this.pushToPlayer(character, character.regen());
}
});
}
pushRelevantEntityListTo(player: Player): void {
const group = player.group ? this.groups.get(player.group) : undefined;
if (group) {
const ids = [...group.entities.keys()].filter((id) => id !== player.id);
this.pushToPlayer(player, [Messages.LIST, ...ids]);
}
}
pushSpawnsToPlayer(player: Player, ids: number[]): void {
for (const id of ids) {
const entity = this.getEntityById(id);
if (entity) for (const message of this.spawnMessages(entity)) this.pushToPlayer(player, message);
}
}
/** SPAWN plus, for players with a Nostr key, the port's PLAYERINFO extension. */
private spawnMessages(entity: Entity): Message[] {
const messages = [entity.spawn()];
if (entity instanceof Player && entity.nostrPubkey) {
messages.push([Messages.PLAYERINFO, entity.id, entity.nostrPubkey]);
}
return messages;
}
pushToPlayer(player: Player | undefined, message: Message): void {
if (player && this.outgoingQueues.has(player.id)) {
this.outgoingQueues.get(player.id)!.push(message);
}
}
pushToGroup(groupId: string, message: Message, ignoredPlayer: number | null = null): void {
const group = this.groups.get(groupId);
if (!group) return;
for (const playerId of group.players) {
if (playerId !== ignoredPlayer) this.pushToPlayer(this.players.get(playerId), message);
}
}
pushToAdjacentGroups(groupId: string | null, message: Message, ignoredPlayer: number | null = null): void {
this.map.forEachAdjacentGroup(groupId, (id) => this.pushToGroup(id, message, ignoredPlayer));
}
pushToPreviousGroups(player: Player, message: Message): void {
// Push this message to all groups which are not going to be updated anymore,
// since the player left them.
for (const id of player.recentlyLeftGroups) this.pushToGroup(id, message);
player.recentlyLeftGroups = [];
}
pushBroadcast(message: Message, ignoredPlayer: number | null = null): void {
for (const [id, queue] of this.outgoingQueues) {
if (id !== ignoredPlayer) queue.push(message);
}
}
private processQueues(): void {
for (const [id, queue] of this.outgoingQueues) {
if (queue.length > 0) {
this.players.get(id)?.connection.send(queue);
this.outgoingQueues.set(id, []);
}
}
}
addEntity(entity: Entity): void {
this.entities.set(entity.id, entity);
this.handleEntityGroupMembership(entity);
}
removeEntity(entity: Entity): void {
this.entities.delete(entity.id);
this.mobs.delete(entity.id);
this.items.delete(entity.id);
if (entity instanceof Mob) {
this.clearMobAggroLink(entity);
this.clearMobHateLinks(entity);
}
entity.destroy();
this.removeFromGroups(entity);
}
addPlayer(player: Player): void {
this.addEntity(player);
this.players.set(player.id, player);
this.outgoingQueues.set(player.id, []);
}
removePlayer(player: Player): void {
player.broadcast(player.despawn());
this.removeEntity(player);
this.players.delete(player.id);
this.outgoingQueues.delete(player.id);
}
addMob(mob: Mob): void {
this.addEntity(mob);
this.mobs.set(mob.id, mob);
}
addNpc(kind: number, x: number, y: number): Npc {
const npc = new Npc(`8${x}${y}`, kind, x, y);
this.addEntity(npc);
this.npcs.set(npc.id, npc);
return npc;
}
addItem(item: Item): Item {
this.addEntity(item);
this.items.set(item.id, item);
return item;
}
createItem(kind: number, x: number, y: number): Item {
const id = `9${this.itemCount++}`;
return kind === Entities.CHEST
? new Chest(id, x, y, this.scheduler)
: new Item(id, kind, x, y, this.scheduler);
}
createChest(x: number, y: number, items: number[]): Chest {
const chest = this.createItem(Entities.CHEST, x, y) as Chest;
chest.setItems(items);
return chest;
}
addStaticItem(item: Item): Item {
item.isStatic = true;
item.onRespawn(() => this.addStaticItem(item));
return this.addItem(item);
}
addItemFromChest(kind: number, x: number, y: number): Item {
const item = this.createItem(kind, x, y);
item.isFromChest = true;
return this.addItem(item);
}
/** The mob will no longer be registered as an attacker of its current target. */
clearMobAggroLink(mob: Mob): void {
if (mob.target !== null) {
const player = this.getEntityById(mob.target);
if (player instanceof Character) player.removeAttacker(mob);
}
}
clearMobHateLinks(mob: Mob): void {
for (const obj of mob.hatelist) {
const player = this.getEntityById(obj.id);
if (player instanceof Player) player.removeHater(mob);
}
}
forEachPlayer(callback: (p: Player) => void): void {
for (const player of [...this.players.values()]) callback(player);
}
forEachMob(callback: (m: Mob) => void): void {
for (const mob of [...this.mobs.values()]) callback(mob);
}
forEachCharacter(callback: (c: Character) => void): void {
this.forEachPlayer(callback);
this.forEachMob(callback);
}
handleMobHate(mobId: number, playerId: number, hatePoints: number): void {
const mob = this.getEntityById(mobId);
const player = this.getEntityById(playerId);
if (player instanceof Player && mob instanceof Mob) {
mob.increaseHateFor(playerId, hatePoints);
player.addHater(mob);
// only choose a target if still alive
if (mob.hitPoints > 0) this.chooseMobTarget(mob);
}
}
chooseMobTarget(mob: Mob, hateRank?: number): void {
const hatedId = mob.getHatedPlayerId(hateRank);
const player = hatedId !== undefined ? this.getEntityById(hatedId) : undefined;
// If the mob is not already attacking the player, create an attack link between them.
if (player instanceof Player && !player.attackers.has(mob.id)) {
this.clearMobAggroLink(mob);
player.addAttacker(mob);
mob.setTarget(player);
this.broadcastAttacker(mob);
}
}
getEntityById(id: number): Entity | undefined {
return this.entities.get(id);
}
broadcastAttacker(character: Character): void {
this.pushToAdjacentGroups(character.group, character.attack(), character.id);
// Called when an entity is attacked by another entity
const target = character.target !== null ? this.getEntityById(character.target) : undefined;
if (target && character instanceof Mob) {
const pos = this.findPositionNextTo(character, target);
this.moveEntity(character, pos.x, pos.y);
}
}
handleHurtEntity(entity: Character, attacker?: Player, damage?: number): void {
if (entity instanceof Player) {
// A player is only aware of his own hitpoints
this.pushToPlayer(entity, entity.health());
}
if (entity instanceof Mob) {
// Let the mob's attacker (player) know how much damage was inflicted
this.pushToPlayer(attacker, [Messages.DAMAGE, entity.id, damage ?? 0]);
}
// If the entity is about to die
if (entity.hitPoints <= 0) {
if (entity instanceof Mob) {
const item = this.getDroppedItem(entity);
this.pushToPlayer(attacker, [Messages.KILL, entity.kind]);
// Despawn must be enqueued before the item drop
this.pushToAdjacentGroups(entity.group, entity.despawn());
if (item) {
this.pushToAdjacentGroups(entity.group, entity.drop(item));
this.handleItemDespawn(item);
}
}
if (entity instanceof Player) {
this.handlePlayerVanish(entity);
this.pushToAdjacentGroups(entity.group, entity.despawn());
}
this.removeEntity(entity);
}
}
private spawnStaticEntities(): void {
let count = 0;
for (const [tid, kindName] of Object.entries(this.map.staticEntities)) {
const kind = getKindFromString(kindName);
if (kind === undefined) continue;
const pos = this.map.tileIndexToGridPosition(Number(tid));
if (isNpc(kind)) this.addNpc(kind, pos.x + 1, pos.y);
if (isMob(kind)) {
const mob = new Mob(`7${kind}${count++}`, kind, pos.x + 1, pos.y, this.scheduler);
mob.onRespawn(() => {
mob.isDead = false;
this.addMob(mob);
if (mob.area instanceof ChestArea) mob.area.addToArea(mob);
});
mob.onMove(this.onMobMoveCallback.bind(this));
this.addMob(mob);
this.tryAddingMobToChestArea(mob);
}
if (isItem(kind)) this.addStaticItem(this.createItem(kind, pos.x + 1, pos.y));
}
}
isValidPosition(x: unknown, y: unknown): x is number {
return (
typeof x === 'number' &&
typeof y === 'number' &&
Number.isFinite(x) &&
Number.isFinite(y) &&
!this.map.isOutOfBounds(x, y) &&
!this.map.isColliding(x, y)
);
}
handlePlayerVanish(player: Player): void {
const previousAttackers: Mob[] = [];
// When a player dies or teleports, all of his attackers go and attack their second most hated player.
player.forEachAttacker((attacker) => {
const mob = attacker as Mob;
previousAttackers.push(mob);
this.chooseMobTarget(mob, 2);
});
for (const mob of previousAttackers) {
player.removeAttacker(mob);
mob.clearTarget();
mob.forgetPlayer(player.id, 1000);
}
this.handleEntityGroupMembership(player);
}
private setPlayerCount(count: number): void {
this.playerCount = count;
this.onPopulationChange?.(count);
}
private incrementPlayerCount(): void {
this.setPlayerCount(this.playerCount + 1);
}
private decrementPlayerCount(): void {
if (this.playerCount > 0) this.setPlayerCount(this.playerCount - 1);
}
getDroppedItem(mob: Mob): Item | null {
const drops = Properties[getKindAsString(mob.kind) ?? '']?.drops ?? {};
const v = random(100);
let p = 0;
for (const [itemName, percentage] of Object.entries(drops)) {
p += percentage;
if (v <= p) {
return this.addItem(this.createItem(getKindFromString(itemName)!, mob.x, mob.y));
}
}
return null;
}
onMobMoveCallback(mob: Mob): void {
this.pushToAdjacentGroups(mob.group, [Messages.MOVE, mob.id, mob.x, mob.y]);
this.handleEntityGroupMembership(mob);
}
findPositionNextTo(entity: Entity, target: Entity): { x: number; y: number } {
// The original loops until valid; bound it so a fully walled-in target can't hang the tab.
for (let i = 0; i < 64; i += 1) {
const pos = entity.getPositionNextTo(target);
if (this.isValidPosition(pos.x, pos.y)) return pos;
}
return { x: entity.x, y: entity.y };
}
private initZoneGroups(): void {
this.map.forEachGroup((id) => {
this.groups.set(id, { entities: new Map(), players: [], incoming: [] });
});
this.zoneGroupsReady = true;
}
removeFromGroups(entity: Entity): string[] {
const oldGroups: string[] = [];
if (entity.group) {
const group = this.groups.get(entity.group);
if (group && entity instanceof Player) {
group.players = group.players.filter((id) => id !== entity.id);
}
this.map.forEachAdjacentGroup(entity.group, (id) => {
const g = this.groups.get(id);
if (g?.entities.has(entity.id)) {
g.entities.delete(entity.id);
oldGroups.push(id);
}
});
entity.group = null;
}
return oldGroups;
}
/**
* Registers an entity as "incoming" into several groups, meaning that it just entered them.
* All players inside these groups will receive a Spawn message when processGroups is called.
*/
private addAsIncomingToGroup(entity: Entity, groupId: string): void {
const isChest = entity instanceof Chest;
const isItemEntity = entity instanceof Item;
const isDroppedItem = isItemEntity && !entity.isStatic && !entity.isFromChest;
this.map.forEachAdjacentGroup(groupId, (id) => {
const group = this.groups.get(id);
// Items dropped off of mobs are handled differently via DROP messages. See handleHurtEntity.
if (group && !group.entities.has(entity.id) && (!isItemEntity || isChest || !isDroppedItem)) {
group.incoming.push(entity);
}
});
}
private addToGroup(entity: Entity, groupId: string): string[] {
const newGroups: string[] = [];
if (this.groups.has(groupId)) {
this.map.forEachAdjacentGroup(groupId, (id) => {
this.groups.get(id)!.entities.set(entity.id, entity);
newGroups.push(id);
});
entity.group = groupId;
if (entity instanceof Player) this.groups.get(groupId)!.players.push(entity.id);
}
return newGroups;
}
handleEntityGroupMembership(entity: Entity): boolean {
const groupId = this.map.getGroupIdFromPosition(entity.x, entity.y);
if (!entity.group || entity.group !== groupId) {
this.addAsIncomingToGroup(entity, groupId);
const oldGroups = this.removeFromGroups(entity);
const newGroups = this.addToGroup(entity, groupId);
if (oldGroups.length > 0) {
entity.recentlyLeftGroups = oldGroups.filter((g) => !newGroups.includes(g));
}
return true;
}
return false;
}
private processGroups(): void {
if (!this.zoneGroupsReady) return;
for (const [id, group] of this.groups) {
if (group.incoming.length > 0) {
for (const entity of group.incoming) {
for (const message of this.spawnMessages(entity)) {
if (entity instanceof Player) this.pushToGroup(id, message, entity.id);
else this.pushToGroup(id, message);
}
}
group.incoming = [];
}
}
}
moveEntity(entity: Entity, x: number, y: number): void {
entity.setPosition(x, y);
this.handleEntityGroupMembership(entity);
}
handleItemDespawn(item: Item): void {
item.handleDespawn({
beforeBlinkDelay: 10000,
blinkCallback: () => this.pushToAdjacentGroups(item.group, [Messages.BLINK, item.id]),
blinkingDuration: 4000,
despawnCallback: () => {
this.pushToAdjacentGroups(item.group, [Messages.DESTROY, item.id]);
this.removeEntity(item);
},
});
}
private handleEmptyChestArea(area: ChestArea): void {
const chest = this.addItem(this.createChest(area.chestX, area.chestY, area.items));
this.handleItemDespawn(chest);
}
handleOpenedChest(chest: Chest): void {
this.pushToAdjacentGroups(chest.group, chest.despawn());
this.removeEntity(chest);
const kind = chest.getRandomItem();
if (kind) {
const item = this.addItemFromChest(kind, chest.x, chest.y);
this.handleItemDespawn(item);
}
}
private tryAddingMobToChestArea(mob: Mob): void {
for (const area of this.chestAreas) {
if (area.contains(mob)) area.addToArea(mob);
}
}
updatePopulation(totalPlayers?: number): void {
this.pushBroadcast([Messages.POPULATION, this.playerCount, totalPlayers ?? this.playerCount]);
}
}
