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/entities.ts
// 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);
  }
}