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/{entity,character,mob,npc,item,chest}.js (MPL-2.0).
import * as Properties from '../shared/properties.js';
import { Entities, Messages } from '../shared/gametypes.js';
import { distanceTo, random, randomOrientation } from '../shared/utils.js';
import type { Position } from './map.js';
import type { Scheduler } from './scheduler.js';
import type { Area } from './areas.js';
export type EntityType = 'player' | 'mob' | 'npc' | 'item';
export type Message = (string | number | number[] | null)[];
export class Entity {
id: number;
group: string | null = null;
recentlyLeftGroups: string[] = [];
constructor(
id: number | string,
readonly type: EntityType,
public kind: number,
public x: number,
public y: number,
) {
this.id = typeof id === 'number' ? id : parseInt(id, 10);
}
destroy(): void {}
protected baseState(): Message {
return [this.id, this.kind, this.x, this.y];
}
getState(): Message {
return this.baseState();
}
spawn(): Message {
return [Messages.SPAWN, ...this.getState()];
}
despawn(): Message {
return [Messages.DESPAWN, this.id];
}
setPosition(x: number, y: number): void {
this.x = x;
this.y = y;
}
/** Quick & dirty random position next to another entity, as in the original. */
getPositionNextTo(entity: Position): Position {
const r = random(4);
const p = { x: entity.x, y: entity.y };
if (r === 0) p.y -= 1;
if (r === 1) p.y += 1;
if (r === 2) p.x -= 1;
if (r === 3) p.x += 1;
return p;
}
}
export class Character extends Entity {
orientation = randomOrientation();
attackers = new Map<number, Character>();
target: number | null = null;
hitPoints = 0;
maxHitPoints = 0;
getState(): Message {
const state = [...this.baseState(), this.orientation];
if (this.target) state.push(this.target);
return state;
}
resetHitPoints(maxHitPoints: number): void {
this.maxHitPoints = maxHitPoints;
this.hitPoints = maxHitPoints;
}
regenHealthBy(value: number): void {
if (this.hitPoints < this.maxHitPoints) {
this.hitPoints = Math.min(this.hitPoints + value, this.maxHitPoints);
}
}
hasFullHealth(): boolean {
return this.hitPoints === this.maxHitPoints;
}
setTarget(entity: Entity): void {
this.target = entity.id;
}
clearTarget(): void {
this.target = null;
}
hasTarget(): boolean {
return this.target !== null;
}
attack(): Message {
return [Messages.ATTACK, this.id, this.target];
}
health(): Message {
return [Messages.HEALTH, this.hitPoints];
}
regen(): Message {
return [Messages.HEALTH, this.hitPoints, 1];
}
addAttacker(entity: Character): void {
this.attackers.set(entity.id, entity);
}
removeAttacker(entity: Character): void {
this.attackers.delete(entity.id);
}
forEachAttacker(callback: (c: Character) => void): void {
for (const attacker of [...this.attackers.values()]) callback(attacker);
}
}
export class Npc extends Entity {
constructor(id: number | string, kind: number, x: number, y: number) {
super(id, 'npc', kind, x, y);
}
}
export class Item extends Entity {
isStatic = false;
isFromChest = false;
private blinkTimeout: number | null = null;
private despawnTimeout: number | null = null;
private respawnCallback: (() => void) | null = null;
constructor(
id: number | string,
kind: number,
x: number,
y: number,
protected readonly scheduler: Scheduler,
) {
super(id, 'item', kind, x, y);
}
handleDespawn(params: {
beforeBlinkDelay: number;
blinkCallback: () => void;
blinkingDuration: number;
despawnCallback: () => void;
}): void {
this.blinkTimeout = this.scheduler.later(() => {
params.blinkCallback();
this.despawnTimeout = this.scheduler.later(params.despawnCallback, params.blinkingDuration);
}, params.beforeBlinkDelay);
}
destroy(): void {
if (this.blinkTimeout !== null) this.scheduler.cancel(this.blinkTimeout);
if (this.despawnTimeout !== null) this.scheduler.cancel(this.despawnTimeout);
this.blinkTimeout = null;
this.despawnTimeout = null;
if (this.isStatic) this.scheduleRespawn(30000);
}
scheduleRespawn(delay: number): void {
this.scheduler.later(() => this.respawnCallback?.(), delay);
}
onRespawn(callback: () => void): void {
this.respawnCallback = callback;
}
}
export class Chest extends Item {
items: number[] = [];
constructor(id: number | string, x: number, y: number, scheduler: Scheduler) {
super(id, Entities.CHEST, x, y, scheduler);
}
setItems(items: number[]): void {
this.items = items;
}
getRandomItem(): number | null {
return this.items.length > 0 ? this.items[random(this.items.length)] : null;
}
}
export interface HateEntry {
id: number;
hate: number;
}
export class Mob extends Character {
spawningX: number;
spawningY: number;
armorLevel: number;
weaponLevel: number;
hatelist: HateEntry[] = [];
isDead = false;
area: Area | null = null;
private returnTimeout: number | null = null;
private respawnCallback: (() => void) | null = null;
private moveCallback: ((mob: Mob) => void) | null = null;
constructor(
id: number | string,
kind: number,
x: number,
y: number,
private readonly scheduler: Scheduler,
) {
super(id, 'mob', kind, x, y);
this.updateHitPoints();
this.spawningX = x;
this.spawningY = y;
this.armorLevel = Properties.getArmorLevel(kind);
this.weaponLevel = Properties.getWeaponLevel(kind);
}
destroy(): void {
this.isDead = true;
this.hatelist = [];
this.clearTarget();
this.updateHitPoints();
this.resetPosition();
this.handleRespawn();
}
receiveDamage(points: number): void {
this.hitPoints -= points;
}
hates(playerId: number): boolean {
return this.hatelist.some((obj) => obj.id === playerId);
}
increaseHateFor(playerId: number, points: number): void {
const entry = this.hatelist.find((obj) => obj.id === playerId);
if (entry) entry.hate += points;
else this.hatelist.push({ id: playerId, hate: points });
if (this.returnTimeout !== null) {
// Prevent the mob from returning to its spawning position since it has aggroed a new player
this.scheduler.cancel(this.returnTimeout);
this.returnTimeout = null;
}
}
getHatedPlayerId(hateRank?: number): number | undefined {
// _.sortBy is stable and ascending.
const sorted = [...this.hatelist].sort((a, b) => a.hate - b.hate);
const size = this.hatelist.length;
const i = hateRank && hateRank <= size ? size - hateRank : size - 1;
return sorted[i]?.id;
}
forgetPlayer(playerId: number, duration?: number): void {
this.hatelist = this.hatelist.filter((obj) => obj.id !== playerId);
if (this.hatelist.length === 0) this.returnToSpawningPosition(duration);
}
forgetEveryone(): void {
this.hatelist = [];
this.returnToSpawningPosition(1);
}
drop(item: Item): Message {
return [Messages.DROP, this.id, item.id, item.kind, this.hatelist.map((h) => h.id)];
}
handleRespawn(): void {
const delay = 30000;
if (this.area && this.area.kind === 'mob') {
// Respawn inside the area if part of a MobArea
this.area.respawnMob(this, delay);
} else {
if (this.area && this.area.kind === 'chest') this.area.removeFromArea(this);
this.scheduler.later(() => this.respawnCallback?.(), delay);
}
}
onRespawn(callback: () => void): void {
this.respawnCallback = callback;
}
resetPosition(): void {
this.setPosition(this.spawningX, this.spawningY);
}
returnToSpawningPosition(waitDuration?: number): void {
const delay = waitDuration || 4000;
this.clearTarget();
this.returnTimeout = this.scheduler.later(() => {
this.returnTimeout = null;
this.resetPosition();
this.move(this.x, this.y);
}, delay);
}
onMove(callback: (mob: Mob) => void): void {
this.moveCallback = callback;
}
move(x: number, y: number): void {
this.setPosition(x, y);
this.moveCallback?.(this);
}
updateHitPoints(): void {
this.resetHitPoints(Properties.getHitPoints(this.kind));
}
distanceToSpawningPoint(x: number, y: number): number {
return distanceTo(x, y, this.spawningX, this.spawningY);
}
}
