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 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]);
}
}
