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