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/client/gameclient.ts
// Port of BrowserQuest client/js/gameclient.js (MPL-2.0), over a Transport.
import { Messages, getKindAsString, getKindFromString, isChest, isItem, isMob, isPlayer } from '../shared/gametypes.js';
import type { Transport } from '../net/transport.js';
import { Character, type Entity } from './entity.js';
import { Chest, Item, Player, createEntity } from './entities.js';

type Handler = (data: unknown[]) => void;
type Fn<A extends unknown[]> = ((...args: A) => void) | null;

export class GameClient {
  isListening = true;
  isTimeout = false;
  private readonly handlers = new Map<number, Handler>();

  connectedCallback: Fn<[]> = null;
  disconnectedCallback: Fn<[string]> = null;
  welcomeCallback: Fn<[number, string, number, number, number]> = null;
  spawnCharacterCallback: Fn<[Character, number, number, number, number | undefined]> = null;
  spawnItemCallback: Fn<[Item, number, number]> = null;
  spawnChestCallback: Fn<[Chest, number, number]> = null;
  despawnCallback: Fn<[number]> = null;
  moveCallback: Fn<[number, number, number]> = null;
  lootMoveCallback: Fn<[number, number]> = null;
  attackCallback: Fn<[number, number]> = null;
  healthCallback: Fn<[number, boolean]> = null;
  chatCallback: Fn<[number, string]> = null;
  equipCallback: Fn<[number, number]> = null;
  dropCallback: Fn<[Item, number]> = null;
  teleportCallback: Fn<[number, number, number]> = null;
  dmgCallback: Fn<[number, number]> = null;
  populationCallback: Fn<[number, number]> = null;
  killCallback: Fn<[number]> = null;
  listCallback: Fn<[number[]]> = null;
  destroyCallback: Fn<[number]> = null;
  hpCallback: Fn<[number]> = null;
  blinkCallback: Fn<[number]> = null;
  playerInfoCallback: Fn<[number, string]> = null;

  constructor(private readonly transport: Transport) {
    const h = (type: number, fn: Handler): void => void this.handlers.set(type, fn);
    h(Messages.WELCOME, (d) => this.welcomeCallback?.(d[1] as number, d[2] as string, d[3] as number, d[4] as number, d[5] as number));
    h(Messages.MOVE, (d) => this.moveCallback?.(d[1] as number, d[2] as number, d[3] as number));
    h(Messages.LOOTMOVE, (d) => this.lootMoveCallback?.(d[1] as number, d[2] as number));
    h(Messages.ATTACK, (d) => this.attackCallback?.(d[1] as number, d[2] as number));
    h(Messages.SPAWN, (d) => this.receiveSpawn(d));
    h(Messages.DESPAWN, (d) => this.despawnCallback?.(d[1] as number));
    h(Messages.HEALTH, (d) => this.healthCallback?.(d[1] as number, Boolean(d[2])));
    h(Messages.CHAT, (d) => this.chatCallback?.(d[1] as number, String(d[2])));
    h(Messages.EQUIP, (d) => this.equipCallback?.(d[1] as number, d[2] as number));
    h(Messages.DROP, (d) => this.receiveDrop(d));
    h(Messages.TELEPORT, (d) => this.teleportCallback?.(d[1] as number, d[2] as number, d[3] as number));
    h(Messages.DAMAGE, (d) => this.dmgCallback?.(d[1] as number, d[2] as number));
    h(Messages.POPULATION, (d) => this.populationCallback?.(d[1] as number, d[2] as number));
    h(Messages.LIST, (d) => this.listCallback?.(d.slice(1) as number[]));
    h(Messages.DESTROY, (d) => this.destroyCallback?.(d[1] as number));
    h(Messages.KILL, (d) => this.killCallback?.(d[1] as number));
    h(Messages.HP, (d) => this.hpCallback?.(d[1] as number));
    h(Messages.BLINK, (d) => this.blinkCallback?.(d[1] as number));
    h(Messages.PLAYERINFO, (d) => this.playerInfoCallback?.(d[1] as number, String(d[2] ?? '')));
  }

  enable(): void {
    this.isListening = true;
  }

  disable(): void {
    this.isListening = false;
  }

  connect(): void {
    this.transport.connect({
      onReady: () => this.connectedCallback?.(),
      onMessage: (data) => this.receiveMessage(data),
      onClose: (reason) => {
        if (reason === 'timeout') this.isTimeout = true;
        this.disconnectedCallback?.(
          this.isTimeout
            ? 'You have been disconnected for being inactive for too long'
            : `The connection to the world has been lost (${reason})`,
        );
      },
    });
  }

  close(): void {
    this.transport.close();
  }

  private sendMessage(json: unknown[]): void {
    this.transport.send(json);
  }

  private receiveMessage(data: unknown): void {
    if (!this.isListening || !Array.isArray(data)) return;
    if (Array.isArray(data[0])) {
      // Multiple actions received
      for (const action of data as unknown[][]) this.receiveAction(action);
    } else {
      this.receiveAction(data);
    }
  }

  private receiveAction(data: unknown[]): void {
    // A death mid-batch disables the client; drop the rest like the original socket did.
    if (!this.isListening) return;
    this.handlers.get(data[0] as number)?.(data);
  }

  private receiveSpawn(data: unknown[]): void {
    const id = data[1] as number;
    const kind = data[2] as number;
    const x = data[3] as number;
    const y = data[4] as number;

    if (isItem(kind)) {
      this.spawnItemCallback?.(createEntity(kind, id) as Item, x, y);
    } else if (isChest(kind)) {
      this.spawnChestCallback?.(createEntity(kind, id) as Chest, x, y);
    } else {
      let name: string | undefined;
      let orientation = 0;
      let target: number | undefined;
      let weapon = 0;
      let armor = 0;

      if (isPlayer(kind)) {
        name = data[5] as string;
        orientation = data[6] as number;
        armor = data[7] as number;
        weapon = data[8] as number;
        if (data.length > 9) target = data[9] as number;
      } else if (isMob(kind)) {
        orientation = data[5] as number;
        if (data.length > 6) target = data[6] as number;
      }

      let character: Entity;
      try {
        character = createEntity(kind, id, name);
      } catch {
        return;
      }
      if (character instanceof Player) {
        character.weaponName = getKindAsString(weapon) ?? 'sword1';
        character.spriteName = getKindAsString(armor) ?? 'clotharmor';
      }
      if (character instanceof Character) {
        this.spawnCharacterCallback?.(character, x, y, orientation, target);
      }
    }
  }

  private receiveDrop(data: unknown[]): void {
    const item = createEntity(data[3] as number, data[2] as number) as Item;
    item.wasDropped = true;
    item.playersInvolved = (data[4] as number[]) ?? [];
    this.dropCallback?.(item, data[1] as number);
  }

  sendHello(player: Player): void {
    const hello: unknown[] = [
      Messages.HELLO,
      player.name,
      getKindFromString(player.getSpriteName()),
      getKindFromString(player.getWeaponName() ?? 'sword1'),
    ];
    if (player.nostrPubkey) hello.push(player.nostrPubkey);
    this.sendMessage(hello);
  }

  sendMove(x: number, y: number): void {
    this.sendMessage([Messages.MOVE, x, y]);
  }

  sendLootMove(item: Entity, x: number, y: number): void {
    this.sendMessage([Messages.LOOTMOVE, x, y, item.id]);
  }

  sendAggro(mob: Entity): void {
    this.sendMessage([Messages.AGGRO, mob.id]);
  }

  sendAttack(mob: Entity): void {
    this.sendMessage([Messages.ATTACK, mob.id]);
  }

  sendHit(mob: Entity): void {
    this.sendMessage([Messages.HIT, mob.id]);
  }

  sendHurt(mob: Entity): void {
    this.sendMessage([Messages.HURT, mob.id]);
  }

  sendChat(text: string): void {
    this.sendMessage([Messages.CHAT, text]);
  }

  sendLoot(item: Entity): void {
    this.sendMessage([Messages.LOOT, item.id]);
  }

  sendTeleport(x: number, y: number): void {
    this.sendMessage([Messages.TELEPORT, x, y]);
  }

  sendWho(ids: number[]): void {
    this.sendMessage([Messages.WHO, ...ids]);
  }

  sendZone(): void {
    this.sendMessage([Messages.ZONE]);
  }

  sendOpen(chest: Entity): void {
    this.sendMessage([Messages.OPEN, chest.id]);
  }

  sendCheck(id: number): void {
    this.sendMessage([Messages.CHECK, id]);
  }
}