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/{app,main}.js (MPL-2.0): intro parchment, HUD,
// chat, achievements and death screens, rebuilt on the DOM without jQuery and
// laid out for any frame size.
import { getKindAsString } from '../shared/gametypes.js';
import { imageUrl } from './assets.js';
import { Game, type Achievement, type GameEvents } from './game.js';
import { Player } from './entities.js';
import type { GameStorage } from './storage.js';
import type { NostrIdentity, ProfileCache } from '../social/profiles.js';
import type { Transport } from '../net/transport.js';
const $ = <T extends HTMLElement = HTMLElement>(selector: string): T => {
const el = document.querySelector<T>(selector);
if (!el) throw new Error(`Missing element ${selector}`);
return el;
};
/** Where the app gets worlds from. Implemented by main.ts (solo or shard network). */
export interface WorldProvider {
/** Join (or re-join) a world. Resolves with a transport ready to connect. */
join(): Promise<Transport>;
/** Human label for the current world, e.g. "World 2 (host)". */
label(): string;
/** Move to another shard; the app reconnects through join(). Optional. */
switchWorld?(delta: number): boolean;
/** Notifies the app when the world must be re-joined (host migration, shard full). */
onRejoinRequired?(callback: (reason: string) => void): void;
/** Notifies of transport-level status text (connecting, solo fallback…). */
onStatus?(callback: (status: string) => void): void;
/** Where to put the player back when re-entering a world (migration, shard switch). */
setResumePoint?(resume: { x: number; y: number; checkpoint?: number } | null): void;
}
export interface AppOptions {
storage: GameStorage;
profiles: ProfileCache;
worlds: WorldProvider;
identity: { pubkey: string; name: string };
}
type ParchmentState = 'createcharacter' | 'loadcharacter' | 'confirmation' | 'about' | 'death' | 'disconnected';
export class App {
readonly game: Game;
private readonly root = $('#app');
private readonly parchment = $('#parchment');
private readonly nameInput = $<HTMLInputElement>('#nameinput');
private readonly chatInput = $<HTMLInputElement>('#chatinput');
private readonly foreground = $<HTMLCanvasElement>('#foreground');
private currentPage = 1;
private pageCount = 1;
private blinkInterval: number | null = null;
private messageTimer: number | null = null;
private notifTimer: number | null = null;
private starting = false;
/** The name last filled in from the Nostr profile (replaced when the identity changes). */
private autoName = '';
/** Where the in-game name came from; a typed name is never replaced by a late identity. */
private nameSource: 'typed' | 'saved' | 'nostr' = 'typed';
private overlayState: ParchmentState | null = null;
private worldPlayers = 0;
private identity: NostrIdentity;
private readonly resizeObserver: ResizeObserver;
constructor(private readonly options: AppOptions) {
this.identity = options.identity;
const events: GameEvents = {
gameStart: () => this.initEquipmentIcons(),
disconnect: (message) => this.showDisconnected(message),
playerDeath: () => this.showOverlay('death'),
playerHealthChange: (hp, maxHp) => this.updateHealthBar(hp, maxHp),
playerHurt: () => this.blinkHealthBar(),
equipmentChange: () => this.initEquipmentIcons(),
playerInvincible: () => $('#hitpoints').classList.toggle('invincible'),
nbPlayersChange: (world) => this.setPopulation(world),
achievementUnlock: (id, name) => this.unlockAchievement(id, name),
notification: (message) => this.showMessage(message),
initAchievementList: (achievements) => this.initAchievementList(achievements),
initUnlockedAchievements: (ids) => this.initUnlockedAchievements(ids),
};
this.game = new Game(
events,
options.storage,
$<HTMLCanvasElement>('#entities'),
$<HTMLCanvasElement>('#background'),
this.foreground,
$('#bubbles'),
);
this.game.nostrPubkey = this.identity.pubkey;
this.root.classList.toggle('touch', matchMedia('(pointer: coarse)').matches);
this.bindIntro();
this.bindHud();
this.bindPointer();
this.resizeObserver = new ResizeObserver(() => this.layout());
this.resizeObserver.observe(this.root);
this.layout();
options.worlds.onRejoinRequired?.((reason) => void this.rejoin(reason));
options.worlds.onStatus?.((status) => this.setWorldName(status));
}
async load(): Promise<void> {
await this.game.load();
const data = this.options.storage.data;
if (data.hasAlreadyPlayed && data.player.name) {
if (data.player.image) $<HTMLImageElement>('#playerimage').src = data.player.image;
this.setParchment('loadcharacter');
} else {
this.setParchment('createcharacter');
}
this.applyIdentity(this.identity);
this.layout();
}
/**
* Apply the Nostr identity. A signed-in player plays under their Nostr name.
*
* On the menus it fills the new-character field and replaces the saved name
* on the returning-player screen. A name the player typed is kept when the
* identity merely arrives late at boot, but a login or account switch
* (`changed`) replaces it. In game, a login, account switch or newly loaded
* profile renames the character by re-entering the world in place (the
* protocol has no rename); a dead player takes the new name on respawn.
* Signing out keeps the current name.
*/
applyIdentity(id: NostrIdentity, changed = false): void {
const previous = this.identity;
this.identity = id;
this.game.nostrPubkey = id.pubkey;
const name = id.pubkey ? id.name : '';
const typed = this.nameInput.value.trim();
if (!typed || typed === this.autoName || (changed && name)) {
this.nameInput.value = name;
this.toggleButton();
}
this.autoName = name;
const saved = this.options.storage.data.player.name;
$('#playername').textContent = name || saved;
const hint = $('#identity-hint');
if (!id.pubkey) hint.textContent = '';
else hint.textContent = name ? `Playing as your Nostr profile, ${name}` : 'Signed in with Nostr';
// In game: follow a login/account switch, or upgrade a name that came from
// Nostr or the save, but never override a name the player typed themselves.
if (!this.root.classList.contains('intro') && id.pubkey && name && (changed || this.nameSource !== 'typed')) {
const renamed = name !== this.game.username;
const relinked = id.pubkey !== previous.pubkey;
if (renamed || relinked) {
this.nameSource = 'nostr';
this.renameInGame(name, renamed);
}
}
}
/** Re-enter the world under the Nostr name (and pubkey) now in effect. */
private renameInGame(name: string, renamed: boolean): void {
this.game.username = name;
if (renamed) this.options.storage.setPlayerName(name);
const player = this.game.player;
// Dead or disconnected: the next HELLO (respawn / rejoin) carries the new name.
if (!this.game.started || !player || player.isDead || this.overlayState === 'death' || this.overlayState === 'disconnected') return;
void this.rejoin('identity').then(() => {
if (renamed) this.showMessage(`You are now known as ${name}`);
});
}
// ---------- Layout ----------
private layout(): void {
const w = this.root.clientWidth;
const h = this.root.clientHeight;
const style = this.root.style;
const touch = this.root.classList.contains('touch');
const tooSmall = w < 160 || h < 120;
this.root.classList.toggle('too-small', tooSmall);
const hud = w >= 900 ? 2 : w >= 560 ? 1.5 : touch && w >= 340 ? 1.5 : 1;
style.setProperty('--h', String(hud));
const u = Math.min(2.5, (w - 16) / 422, (h - 16) / 240);
const compact = u < 0.72;
this.root.classList.toggle('compact', compact);
style.setProperty('--u', compact ? '1' : u.toFixed(3));
// The HUD bar sits inside the frame directly under the map, so the map gets
// the stage height minus the bar.
const stage = $('#stage');
const barHeight = $('#bar').offsetHeight;
const border = 2 + 2 * hud;
const availW = Math.max(16, stage.clientWidth - 2 * border);
const availH = Math.max(16, stage.clientHeight - 2 * border - barHeight);
this.game.resize(availW, availH);
const vp = $('#viewport');
vp.style.width = this.foreground.style.width;
vp.style.height = this.foreground.style.height;
$('#frame').style.width = this.foreground.style.width;
this.game.renderer.touch = touch;
}
// ---------- Intro / parchment ----------
private setParchment(state: ParchmentState): void {
this.parchment.className = state;
}
private bindIntro(): void {
const toggle = (): void => this.toggleButton();
this.nameInput.addEventListener('input', toggle);
$('#createform').addEventListener('submit', (e) => {
e.preventDefault();
this.tryStartingGame();
});
for (const play of document.querySelectorAll<HTMLButtonElement>('.play')) {
play.addEventListener('click', () => this.tryStartingGame());
}
$('#create-new button').addEventListener('click', () => this.setParchment('confirmation'));
$('.delete').addEventListener('click', () => {
this.options.storage.clear();
this.initUnlockedAchievements([]);
this.setParchment('createcharacter');
this.applyIdentity(this.identity);
});
$('#cancel button').addEventListener('click', () => this.setParchment('loadcharacter'));
$('#respawn').addEventListener('click', () => this.respawn());
$('#rejoin').addEventListener('click', () => void this.rejoin('manual'));
// Click anywhere closes the about parchment (as the original "click anywhere to close").
$('#intro').addEventListener('click', (e) => {
if (this.parchment.className === 'about' && !(e.target as HTMLElement).closest('button')) {
this.closeAbout();
}
});
}
toggleButton(): void {
const enabled = this.nameInput.value.trim().length > 0;
$('#createcharacter .play').classList.toggle('disabled', !enabled);
$('#character').classList.toggle('disabled', !enabled);
}
private currentName(): string {
const state = this.parchment.className;
if (state === 'loadcharacter') return ($('#playername').textContent ?? '').trim();
return this.nameInput.value.trim();
}
private async tryStartingGame(): Promise<void> {
if (this.starting || this.game.started || !this.root.classList.contains('intro')) return;
const name = this.currentName();
if (!name) return;
const fromNostr = !!this.identity.pubkey && name === this.autoName;
this.nameSource = fromNostr ? 'nostr' : this.parchment.className === 'loadcharacter' ? 'saved' : 'typed';
this.starting = true;
this.nameInput.blur();
try {
const transport = await this.options.worlds.join();
this.root.classList.remove('intro');
this.root.classList.add('game');
this.setWorldName(this.options.worlds.label());
this.game.run(transport, name);
if (!this.options.storage.hasAlreadyPlayed()) {
window.setTimeout(() => this.toggleInstructions(true), 1200);
}
this.foreground.focus();
} catch (error) {
$('#identity-hint').textContent = `Could not enter a world: ${error instanceof Error ? error.message : String(error)}`;
} finally {
this.starting = false;
}
}
private showOverlay(state: ParchmentState): void {
this.overlayState = state;
this.hideWindows();
this.hideChat();
this.root.classList.add('overlay');
this.setParchment(state);
}
private hideOverlay(): void {
this.overlayState = null;
this.root.classList.remove('overlay');
}
private respawn(): void {
if (this.overlayState !== 'death') return;
this.game.audioManager.playSound('revive');
this.hideOverlay();
this.game.restart();
this.foreground.focus();
}
private showDisconnected(message: string): void {
$('#disconnect-message').textContent = message;
this.showOverlay('disconnected');
}
private async rejoin(reason: string): Promise<void> {
if (this.root.classList.contains('intro')) return;
const p = this.game.player;
this.options.worlds.setResumePoint?.(
p && !p.isDead && p.id >= 0
? { x: p.gridX, y: p.gridY, checkpoint: p.lastCheckpoint ? Number(p.lastCheckpoint.id) : undefined }
: null,
);
this.game.disconnectFromWorld();
this.hideOverlay();
if (reason !== 'manual') this.showMessage('Reconnecting to the world…');
try {
const transport = await this.options.worlds.join();
this.setWorldName(this.options.worlds.label());
this.game.reconnect(transport);
} catch (error) {
this.showDisconnected(`Could not rejoin: ${error instanceof Error ? error.message : String(error)}`);
}
}
private toggleAbout(): void {
if (this.overlayState === 'about') this.closeAbout();
else if (!this.overlayState) {
this.showOverlay('about');
$('#helpbutton').classList.add('active');
}
}
private closeAbout(): void {
if (this.root.classList.contains('intro')) {
this.setParchment(this.options.storage.hasAlreadyPlayed() ? 'loadcharacter' : 'createcharacter');
return;
}
this.hideOverlay();
$('#helpbutton').classList.remove('active');
}
/** Enter / gamepad A on menus. */
confirm(): boolean {
if (this.root.classList.contains('intro')) {
const state = this.parchment.className;
if (state === 'createcharacter' || state === 'loadcharacter') {
void this.tryStartingGame();
return true;
}
return false;
}
if (this.overlayState === 'death') {
this.respawn();
return true;
}
if (this.overlayState === 'disconnected') {
void this.rejoin('manual');
return true;
}
if (this.overlayState === 'about') {
this.closeAbout();
return true;
}
return false;
}
// ---------- HUD ----------
private bindHud(): void {
$('#chatbutton').addEventListener('click', () => this.toggleChat());
$('#helpbutton').addEventListener('click', () => this.toggleAbout());
$('#achievementsbutton').addEventListener('click', () => this.toggleAchievements());
$('#mutebutton').addEventListener('click', () => this.toggleMute());
$('#playercount').addEventListener('click', () => this.togglePopulation());
$('#population').addEventListener('click', (e) => {
if (!(e.target as HTMLElement).closest('button')) this.togglePopulation();
});
$('#prev-world').addEventListener('click', () => this.switchWorld(-1));
$('#next-world').addEventListener('click', () => this.switchWorld(1));
$('#instructions').addEventListener('click', () => this.hideWindows());
for (const close of document.querySelectorAll<HTMLButtonElement>('.close')) {
close.addEventListener('click', (e) => {
e.stopPropagation();
this.hideWindows();
});
}
$('#previous').addEventListener('click', () => this.setPage(this.currentPage - 1));
$('#next').addEventListener('click', () => this.setPage(this.currentPage + 1));
this.chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
// Don't let this Enter reach the window listener below and reopen the chat.
e.stopPropagation();
const text = this.chatInput.value.trim();
if (text && this.game.player) this.game.say(text);
this.chatInput.value = '';
this.hideChat();
this.foreground.focus();
} else if (e.key === 'Escape') {
e.preventDefault();
this.hideChat();
this.foreground.focus();
}
});
// Enter opens chat while playing (the input's own handler closes it).
window.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || this.isTyping() || !this.game.started || this.overlayState) return;
e.preventDefault();
this.showChat();
});
}
private switchWorld(delta: number): void {
if (this.options.worlds.switchWorld?.(delta)) {
this.togglePopulation(false);
void this.rejoin('switch');
}
}
isTyping(): boolean {
const active = document.activeElement;
return active === this.chatInput || active === this.nameInput;
}
toggleChat(): void {
if ($('#chatbox').classList.contains('active')) this.hideChat();
else this.showChat();
}
private showChat(): void {
if (!this.game.started || this.overlayState) return;
$('#chatbox').classList.add('active');
$('#chatbutton').classList.add('active');
this.chatInput.focus();
}
private hideChat(): void {
$('#chatbox').classList.remove('active');
$('#chatbutton').classList.remove('active');
this.chatInput.blur();
}
toggleMute(): void {
this.game.audioManager.toggle();
const on = this.game.audioManager.enabled;
$('#mutebutton').classList.toggle('active', on);
$('#mutebutton').setAttribute('aria-pressed', String(on));
}
setMuted(muted: boolean): void {
if (this.game.audioManager.enabled === muted) this.toggleMute();
}
private updateHealthBar(hp: number, maxHp: number): void {
const bar = $('#hitpoints');
const pct = maxHp > 0 ? Math.max(0, hp) / maxHp : 0;
bar.style.width = `calc(90px * var(--h) * ${pct.toFixed(4)})`;
const meter = $('#healthbar');
meter.setAttribute('aria-valuemax', String(maxHp));
meter.setAttribute('aria-valuenow', String(Math.max(0, hp)));
}
private blinkHealthBar(): void {
const hitpoints = $('#hitpoints');
hitpoints.classList.add('white');
window.setTimeout(() => hitpoints.classList.remove('white'), 500);
}
private initEquipmentIcons(): void {
const player = this.game.player;
if (!player) return;
const weapon = player.getWeaponName() ?? 'sword1';
const armor = player.getSpriteName();
$('#weapon').style.backgroundImage = `url("${imageUrl(`item-${weapon}`)}")`;
if (armor !== 'firefox') $('#armor').style.backgroundImage = `url("${imageUrl(`item-${armor}`)}")`;
}
private showMessage(message: string): void {
const wrapper = $('#notifications div');
const m1 = $('#message1');
const m2 = $('#message2');
// Slide the previous message up and out, show the new one.
if (this.notifTimer !== null) window.clearTimeout(this.notifTimer);
wrapper.classList.remove('top');
m1.textContent = m2.textContent;
m2.textContent = message;
void wrapper.offsetWidth;
wrapper.classList.add('top');
if (this.messageTimer !== null) window.clearTimeout(this.messageTimer);
this.messageTimer = window.setTimeout(() => {
wrapper.classList.remove('top');
m1.textContent = '';
m2.textContent = '';
}, 5000);
}
private setPopulation(world: number): void {
this.worldPlayers = world;
$('#playercount .count').textContent = String(world);
$('#playercount .label').textContent = world === 1 ? 'player' : 'players';
$('#instance-population .n').textContent = String(world);
$('#instance-population .w').textContent = world === 1 ? 'player' : 'players';
if ($('#population').classList.contains('visible')) void this.renderPlayerList();
}
private setWorldName(name: string): void {
$('#world-name').textContent = name;
}
private togglePopulation(force?: boolean): void {
const visible = $('#population').classList.toggle('visible', force);
if (visible) void this.renderPlayerList();
}
private async renderPlayerList(): Promise<void> {
const list = $('#player-list');
const players: Player[] = [];
this.game.forEachPlayer((p) => players.push(p));
const me = this.game.player;
if (me && !players.includes(me)) players.unshift(me);
const rows = players.map((p) => {
const li = document.createElement('li');
if (p === me) li.className = 'me';
const avatar = document.createElement('span');
avatar.className = 'avatar';
const who = document.createElement('span');
who.className = 'who';
who.textContent = p.name;
const npub = document.createElement('span');
npub.className = 'npub';
li.append(avatar, who, npub);
const pubkey = p === me ? this.identity.pubkey : p.nostrPubkey;
if (pubkey) {
const cached = this.options.profiles.peek(pubkey);
const apply = (view: { name: string; npub: string; avatarUrl: string | null }): void => {
npub.textContent = ` ${view.name && view.name !== p.name ? `${view.name} · ` : ''}${view.npub}`;
if (view.avatarUrl) {
const img = document.createElement('img');
img.alt = '';
img.src = view.avatarUrl;
avatar.replaceWith(img);
}
};
if (cached) apply(cached);
else void this.options.profiles.get(pubkey).then(apply);
li.title = p === me ? 'You (Nostr)' : 'Nostr key announced by this player';
}
return li;
});
list.replaceChildren(...rows);
if (players.length === 0) {
const li = document.createElement('li');
li.textContent = `${this.worldPlayers} in world`;
list.append(li);
}
}
// ---------- Windows ----------
toggleInstructions(force?: boolean): void {
if (force !== false) $('#achievements').classList.remove('active');
$('#instructions').classList.toggle('active', force);
}
toggleAchievements(): void {
if (this.root.classList.contains('intro')) return;
$('#instructions').classList.remove('active');
const active = $('#achievements').classList.toggle('active');
if (active) this.setPage(1);
if (this.blinkInterval !== null) window.clearInterval(this.blinkInterval);
this.blinkInterval = null;
$('#achievementsbutton').classList.remove('blink');
$('#achievementsbutton').classList.toggle('active', active);
}
/** Returns true if something was closed. */
hideWindows(): boolean {
let closed = false;
for (const id of ['#achievements', '#instructions']) {
if ($(id).classList.contains('active')) {
$(id).classList.remove('active');
closed = true;
}
}
$('#achievementsbutton').classList.remove('active');
if ($('#population').classList.contains('visible')) {
this.togglePopulation(false);
closed = true;
}
if (this.overlayState === 'about') {
this.closeAbout();
closed = true;
}
if ($('#chatbox').classList.contains('active')) {
this.hideChat();
closed = true;
}
return closed;
}
private setPage(page: number): void {
this.currentPage = Math.max(1, Math.min(this.pageCount, page));
$('#lists').style.transform = `translateX(${-(this.currentPage - 1) * 100}%)`;
const ach = $('#achievements');
ach.classList.toggle('first', this.currentPage === 1);
ach.classList.toggle('last', this.currentPage === this.pageCount);
}
private coinStyle(el: HTMLElement, id: number): void {
const [cx, cy] = id <= 17 ? [(id - 1) * 24, 222] : [216 + (id - 18) * 24, 274];
el.style.setProperty('--cx', String(cx));
el.style.setProperty('--cy', String(cy));
}
private initAchievementList(achievements: Record<string, Achievement>): void {
const lists = $('#lists');
lists.replaceChildren();
let page: HTMLUListElement | null = null;
let count = 0;
for (const achievement of Object.values(achievements)) {
count++;
if ((count - 1) % 4 === 0) {
page = document.createElement('ul');
lists.append(page);
}
const li = document.createElement('li');
li.className = `achievement${achievement.id}`;
li.dataset.id = String(achievement.id);
const coin = document.createElement('div');
coin.className = 'coin';
this.coinStyle(coin, achievement.id);
const name = document.createElement('span');
name.className = 'achievement-name';
const desc = document.createElement('span');
desc.className = 'achievement-description';
name.textContent = achievement.hidden ? '???' : achievement.name;
desc.textContent = achievement.hidden ? '???' : achievement.desc;
li.append(coin, name, desc);
page!.append(li);
}
this.pageCount = Math.max(1, lists.children.length);
$('#total-achievements').textContent = String(count);
this.setPage(1);
}
private displayUnlockedAchievement(id: number): void {
const li = document.querySelector<HTMLElement>(`#lists li.achievement${id}`);
if (!li) return;
const achievement = this.game.getAchievementById(id);
if (achievement?.hidden) {
li.querySelector('.achievement-name')!.textContent = achievement.name;
li.querySelector('.achievement-description')!.textContent = achievement.desc;
}
li.classList.add('unlocked');
}
private initUnlockedAchievements(ids: number[]): void {
for (const li of document.querySelectorAll<HTMLElement>('#lists li')) {
li.classList.remove('unlocked');
const achievement = this.game.getAchievementById(Number(li.dataset.id));
if (achievement?.hidden) {
li.querySelector('.achievement-name')!.textContent = '???';
li.querySelector('.achievement-description')!.textContent = '???';
}
}
for (const id of ids) this.displayUnlockedAchievement(id);
$('#unlocked-achievements').textContent = String(ids.length);
}
private unlockAchievement(id: number, name: string): void {
const notif = $('#achievement-notification');
const coin = notif.querySelector<HTMLElement>('.coin')!;
this.coinStyle(coin, id);
notif.querySelector('.name')!.textContent = name;
notif.classList.add('active');
if (this.options.storage.getAchievementCount() === 1) {
const button = $('#achievementsbutton');
this.blinkInterval = window.setInterval(() => button.classList.toggle('blink'), 500);
}
window.setTimeout(() => {
notif.classList.remove('active');
$('#achievementsbutton').classList.remove('blink');
}, 5000);
this.displayUnlockedAchievement(id);
$('#unlocked-achievements').textContent = String(this.options.storage.getAchievementCount());
}
// ---------- Pointer ----------
private setMouseCoordinates(event: { clientX: number; clientY: number }, touch: boolean): void {
const rect = this.foreground.getBoundingClientRect();
const r = this.game.renderer;
const sx = this.foreground.width / Math.max(1, rect.width);
const sy = this.foreground.height / Math.max(1, rect.height);
// Desktop: the original nudged the hit point so the hand cursor's fingertip clicks.
const nudge = touch ? 0 : 0;
let x = (event.clientX - rect.left) * sx - nudge;
let y = (event.clientY - rect.top) * sy - nudge;
x = Math.max(0, Math.min(r.getWidth() - 1, x));
y = Math.max(0, Math.min(r.getHeight() - 1, y));
this.game.mouse.x = x;
this.game.mouse.y = y;
}
private bindPointer(): void {
this.foreground.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
const touch = e.pointerType !== 'mouse';
if (touch !== this.root.classList.contains('touch')) {
this.root.classList.toggle('touch', touch);
this.game.renderer.touch = touch;
}
this.setMouseCoordinates(e, touch);
if (this.hideWindows()) return;
if (this.game.started && this.game.player) this.game.click();
});
this.foreground.addEventListener('pointermove', (e) => {
if (e.pointerType !== 'mouse') return;
this.setMouseCoordinates(e, false);
if (this.game.started) this.game.movecursor();
});
this.foreground.addEventListener('contextmenu', (e) => e.preventDefault());
}
/** Name of the armor kind, used by tests and the HUD. */
static kindName(kind: number): string {
return getKindAsString(kind) ?? '';
}
dispose(): void {
this.resizeObserver.disconnect();
this.game.stop();
this.game.disconnectFromWorld();
this.game.audioManager.dispose();
}
}
