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/game.ts
// Port of BrowserQuest client/js/game.js (MPL-2.0). Game logic, achievement
// rules and the client side of the protocol are kept as in the original; the
// mobile dirty-rect path is dropped (every device renders the desktop path).
import {
  Entities,
  Orientations,
  forEachArmorKind,
  forEachMobOrNpcKind,
  getKindAsString,
  isHealingItem,
  type Orientation,
} from '../shared/gametypes.js';
import type { Transport } from '../net/transport.js';
import { AudioManager } from './audio.js';
import { clientMapData, imageUrl } from './assets.js';
import { Character, type Entity, type Path } from './entity.js';
import { Chest, Item, LootException, Mob, Npc, Player, spriteNameForKind } from './entities.js';
import { GameClient } from './gameclient.js';
import { GameMap } from './map.js';
import { BubbleManager, InfoManager } from './overlays.js';
import { Pathfinder } from './pathfinder.js';
import { Renderer } from './renderer.js';
import { Sprite, type DrawableSprite } from './sprite.js';
import type { GameStorage } from './storage.js';
import { AnimatedTile, Animation, Transition } from './timing.js';
import { Updater } from './updater.js';

/** Height in CSS px of the speech-bubble tail (thingy.png), which hangs below the box. */
const BUBBLE_TAIL_HEIGHT = 10;

export interface Achievement {
  id: number;
  name: string;
  desc: string;
  hidden: boolean;
  isCompleted: () => boolean;
}

/** Hooks the UI layer (app.ts) attaches to, mirroring the original on* callbacks. */
export interface GameEvents {
  gameStart(): void;
  disconnect(message: string): void;
  playerDeath(): void;
  playerHealthChange(hp: number, maxHp: number): void;
  playerHurt(): void;
  equipmentChange(): void;
  playerInvincible(): void;
  nbPlayersChange(worldPlayers: number, totalPlayers: number): void;
  achievementUnlock(id: number, name: string, desc: string): void;
  notification(message: string): void;
  initAchievementList(achievements: Record<string, Achievement>): void;
  initUnlockedAchievements(ids: number[]): void;
}

const SPRITE_NAMES = [
  'hand', 'sword', 'loot', 'target', 'talk', 'sparks', 'shadow16', 'rat', 'skeleton', 'skeleton2', 'spectre',
  'boss', 'deathknight', 'ogre', 'crab', 'snake', 'eye', 'bat', 'goblin', 'wizard', 'guard', 'king',
  'villagegirl', 'villager', 'coder', 'agent', 'rick', 'scientist', 'priest', 'sorcerer', 'beachnpc',
  'forestnpc', 'desertnpc', 'lavanpc', 'clotharmor', 'leatherarmor', 'mailarmor', 'platearmor', 'redarmor',
  'goldenarmor', 'firefox', 'death', 'sword1', 'axe', 'chest', 'sword2', 'redsword', 'bluesword',
  'goldensword', 'item-sword2', 'item-axe', 'item-redsword', 'item-bluesword', 'item-goldensword',
  'item-leatherarmor', 'item-mailarmor', 'item-platearmor', 'item-redarmor', 'item-goldenarmor',
  'item-flask', 'item-cake', 'item-burger', 'morningstar', 'item-morningstar', 'item-firepotion',
];

export class Game {
  ready = false;
  started = false;
  hasNeverStarted = true;
  isStopped = false;
  readonly renderer: Renderer;
  readonly updater: Updater;
  readonly map: GameMap;
  readonly pathfinder: Pathfinder;
  readonly bubbleManager: BubbleManager;
  readonly infoManager: InfoManager;
  readonly audioManager: AudioManager;
  client: GameClient | null = null;
  player: Player | null;
  playerId = -1;
  username = '';
  nostrPubkey = '';
  entities = new Map<number, Entity>();
  deathpositions = new Map<number, { x: number; y: number }>();
  entityGrid: Map<number, Entity>[][] = [];
  pathingGrid: number[][] = [];
  renderingGrid: Map<number, Entity>[][] = [];
  itemGrid: Map<number, Item>[][] = [];
  currentCursor: DrawableSprite | null = null;
  mouse = { x: 0, y: 0 };
  zoningQueue: { x: number; y: number }[] = [];
  previousClickPosition: { x?: number; y?: number } = {};
  selectedX = 0;
  selectedY = 0;
  selectedCellVisible = false;
  targetColor = 'rgba(255, 255, 255, 0.5)';
  targetCellVisible = true;
  hoveringTarget = false;
  hoveringMob = false;
  hoveringItem = false;
  hoveringNpc = false;
  hoveringChest = false;
  hoveringCollidingTile = false;
  hoveringPlateauTile = false;
  lastHovered: Entity | null = null;
  currentZoning: Transition | null = null;
  zoningOrientation: Orientation | 0 = 0;
  cursors: Record<string, DrawableSprite | undefined> = {};
  sprites: Record<string, Sprite> = {};
  shadows: Record<string, DrawableSprite | undefined> = {};
  animatedTiles: AnimatedTile[] = [];
  targetAnimation: Animation | null = null;
  sparksAnimation: Animation | null = null;
  achievements: Record<string, Achievement> = {};
  currentTime = 0;
  obsoleteEntities: Entity[] | null = null;
  private tileset = new Image();
  private rafHandle = 0;

  constructor(
    private readonly events: GameEvents,
    readonly storage: GameStorage,
    canvas: HTMLCanvasElement,
    background: HTMLCanvasElement,
    foreground: HTMLCanvasElement,
    bubbleContainer: HTMLElement,
  ) {
    this.bubbleManager = new BubbleManager(bubbleContainer);
    this.renderer = new Renderer(this, canvas, background, foreground);
    this.infoManager = new InfoManager(() => this.currentTime);
    this.map = new GameMap(clientMapData);
    this.pathfinder = new Pathfinder(this.map.width, this.map.height);
    this.updater = new Updater(this);
    this.audioManager = new AudioManager(() => this.player);
    this.player = new Player(-1, '');
  }

  get camera() {
    return this.renderer.camera;
  }

  /** Decode every sprite and the tileset before the first frame. */
  async load(): Promise<void> {
    for (const name of SPRITE_NAMES) this.sprites[name] = new Sprite(name);
    this.tileset.src = imageUrl('tilesheet');
    await Promise.all([
      ...Object.values(this.sprites).map((s) => s.ready()),
      this.tileset.decode().catch(
        () =>
          new Promise<void>((resolve) => {
            if (this.tileset.complete) resolve();
            else this.tileset.onload = () => resolve();
          }),
      ),
    ]);
    this.renderer.setTileset(this.tileset);

    for (const area of this.map.musicAreas) {
      this.audioManager.addArea(area.x, area.y, area.w, area.h, area.id);
    }
    this.initAchievements();
    this.initCursors();
    this.initAnimations();
    this.initShadows();
    this.initHurtSprites();
    this.initSilhouettes();
    this.initEntityGrid();
    this.initItemGrid();
    this.initPathingGrid();
    this.initRenderingGrid();
    this.ready = true;
    // The world map is visible behind the intro parchment (the original showed a
    // wood backdrop instead), so run the loop now to draw its animated and high tiles.
    this.initAnimatedTiles();
    this.start();
  }

  private initPlayer(): void {
    const player = this.player!;
    if (this.storage.hasAlreadyPlayed()) {
      const { armor, weapon } = this.storage.data.player;
      if (armor && this.sprites[armor]) player.setSpriteName(armor);
      if (weapon && this.sprites[weapon]) player.setWeaponName(weapon);
    }
    player.nostrPubkey = this.nostrPubkey;
    player.setSprite(this.sprites[player.getSpriteName()]);
    player.idle();
  }

  private initShadows(): void {
    this.shadows.small = this.sprites.shadow16;
  }

  private initCursors(): void {
    for (const name of ['hand', 'sword', 'loot', 'target', 'talk']) this.cursors[name] = this.sprites[name];
  }

  private initAnimations(): void {
    this.targetAnimation = new Animation('idle_down', 4, 0, 16, 16);
    this.targetAnimation.setSpeed(50);
    this.sparksAnimation = new Animation('idle_down', 6, 0, 16, 16);
    this.sparksAnimation.setSpeed(120);
  }

  private initHurtSprites(): void {
    forEachArmorKind((_kind, kindName) => this.sprites[kindName]?.createHurtSprite());
  }

  private initSilhouettes(): void {
    const done = new Set<string>();
    forEachMobOrNpcKind((kind) => {
      const name = spriteNameForKind(kind);
      if (!done.has(name)) {
        this.sprites[name]?.createSilhouette();
        done.add(name);
      }
    });
    this.sprites.chest.createSilhouette();
    this.sprites['item-cake'].createSilhouette();
  }

  private initAchievements(): void {
    const s = this.storage;
    const def = (id: number, name: string, desc: string, extra: Partial<Achievement> = {}): Achievement => ({
      id,
      name,
      desc,
      hidden: false,
      isCompleted: () => true,
      ...extra,
    });
    this.achievements = {
      A_TRUE_WARRIOR: def(1, 'A True Warrior', 'Find a new weapon'),
      INTO_THE_WILD: def(2, 'Into the Wild', 'Venture outside the village'),
      ANGRY_RATS: def(3, 'Angry Rats', 'Kill 10 rats', { isCompleted: () => s.getRatCount() >= 10 }),
      SMALL_TALK: def(4, 'Small Talk', 'Talk to a non-player character'),
      FAT_LOOT: def(5, 'Fat Loot', 'Get a new armor set'),
      UNDERGROUND: def(6, 'Underground', 'Explore at least one cave'),
      AT_WORLDS_END: def(7, "At World's End", 'Reach the south shore'),
      COWARD: def(8, 'Coward', 'Successfully escape an enemy'),
      TOMB_RAIDER: def(9, 'Tomb Raider', 'Find the graveyard'),
      SKULL_COLLECTOR: def(10, 'Skull Collector', 'Kill 10 skeletons', {
        isCompleted: () => s.getSkeletonCount() >= 10,
      }),
      NINJA_LOOT: def(11, 'Ninja Loot', "Get hold of an item you didn't fight for"),
      NO_MANS_LAND: def(12, "No Man's Land", 'Travel through the desert'),
      HUNTER: def(13, 'Hunter', 'Kill 50 enemies', { isCompleted: () => s.getTotalKills() >= 50 }),
      STILL_ALIVE: def(14, 'Still Alive', 'Revive your character five times', {
        isCompleted: () => s.getTotalRevives() >= 5,
      }),
      MEATSHIELD: def(15, 'Meatshield', 'Take 5,000 points of damage', {
        isCompleted: () => s.getTotalDamageTaken() >= 5000,
      }),
      HOT_SPOT: def(16, 'Hot Spot', 'Enter the volcanic mountains'),
      HERO: def(17, 'Hero', 'Defeat the final boss'),
      FOXY: def(18, 'Fired Up', 'Find the flame costume', { hidden: true }),
      FOR_SCIENCE: def(19, 'For Science', 'Enter into a portal', { hidden: true }),
      RICKROLLD: def(20, 'Encore', 'Take some singing lessons', { hidden: true }),
    };

    this.events.initAchievementList(this.achievements);
    if (this.storage.hasAlreadyPlayed()) {
      this.events.initUnlockedAchievements(this.storage.data.achievements.unlocked);
    }
  }

  getAchievementById(id: number): Achievement | null {
    return Object.values(this.achievements).find((a) => a.id === id) ?? null;
  }

  private setCursor(name: string): void {
    const cursor = this.cursors[name];
    if (cursor) this.currentCursor = cursor;
  }

  private updateCursorLogic(): void {
    this.targetColor =
      this.hoveringCollidingTile && this.started ? 'rgba(255, 50, 50, 0.5)' : 'rgba(255, 255, 255, 0.5)';

    if (this.hoveringMob && this.started) {
      this.setCursor('sword');
      this.hoveringTarget = false;
      this.targetCellVisible = false;
    } else if (this.hoveringNpc && this.started) {
      this.setCursor('talk');
      this.hoveringTarget = false;
      this.targetCellVisible = false;
    } else if ((this.hoveringItem || this.hoveringChest) && this.started) {
      this.setCursor('loot');
      this.hoveringTarget = false;
      this.targetCellVisible = true;
    } else {
      this.setCursor('hand');
      this.hoveringTarget = false;
      this.targetCellVisible = true;
    }
  }

  addEntity(entity: Entity): void {
    if (!this.entities.has(entity.id)) {
      this.entities.set(entity.id, entity);
      this.registerEntityPosition(entity);
      if (!(entity instanceof Item && entity.wasDropped)) entity.fadeIn(this.currentTime);
    }
  }

  removeEntity(entity: Entity): void {
    if (this.entities.has(entity.id)) {
      this.unregisterEntityPosition(entity);
      this.entities.delete(entity.id);
    }
  }

  private addItem(item: Item, x: number, y: number): void {
    item.setSprite(this.sprites[item.getSpriteName()]);
    item.setGridPosition(x, y);
    item.setAnimation('idle', 150);
    this.addEntity(item);
  }

  private removeItem(item: Item | null): void {
    if (item) {
      this.removeFromItemGrid(item, item.gridX, item.gridY);
      this.removeFromRenderingGrid(item, item.gridX, item.gridY);
      this.entities.delete(item.id);
    }
  }

  private initPathingGrid(): void {
    this.pathingGrid = this.map.grid.map((row) => row.slice());
  }

  private initEntityGrid(): void {
    this.entityGrid = this.map.grid.map((row) => row.map(() => new Map()));
  }

  private initRenderingGrid(): void {
    this.renderingGrid = this.map.grid.map((row) => row.map(() => new Map()));
  }

  private initItemGrid(): void {
    this.itemGrid = this.map.grid.map((row) => row.map(() => new Map()));
  }

  initAnimatedTiles(): void {
    const m = this.map;
    this.animatedTiles = [];
    this.forEachVisibleTile((id, index) => {
      if (m.isAnimatedTile(id)) {
        const tile = new AnimatedTile(id, m.getTileAnimationLength(id), m.getTileAnimationDelay(id), index);
        const pos = m.tileIndexToGridPosition(tile.index);
        tile.x = pos.x;
        tile.y = pos.y;
        this.animatedTiles.push(tile);
      }
    }, 1);
  }

  private addToRenderingGrid(entity: Entity, x: number, y: number): void {
    if (!this.map.isOutOfBounds(x, y)) this.renderingGrid[y][x].set(entity.id, entity);
  }

  removeFromRenderingGrid(entity: Entity, x: number, y: number): void {
    this.renderingGrid[y]?.[x]?.delete(entity.id);
  }

  private removeFromEntityGrid(entity: Entity, x: number, y: number): void {
    this.entityGrid[y]?.[x]?.delete(entity.id);
  }

  private removeFromItemGrid(item: Item, x: number, y: number): void {
    this.itemGrid[y]?.[x]?.delete(item.id);
  }

  private removeFromPathingGrid(x: number, y: number): void {
    if (this.pathingGrid[y] && x >= 0 && x < this.map.width) this.pathingGrid[y][x] = 0;
  }

  /**
   * Registers the entity at two adjacent positions on the grid at the same time.
   * This situation is temporary and should only occur when the entity is moving.
   */
  private registerEntityDualPosition(entity: Character): void {
    this.entityGrid[entity.gridY]?.[entity.gridX]?.set(entity.id, entity);
    this.addToRenderingGrid(entity, entity.gridX, entity.gridY);
    if (entity.nextGridX >= 0 && entity.nextGridY >= 0) {
      this.entityGrid[entity.nextGridY]?.[entity.nextGridX]?.set(entity.id, entity);
      if (!(entity instanceof Player)) this.pathingGrid[entity.nextGridY][entity.nextGridX] = 1;
    }
  }

  unregisterEntityPosition(entity: Entity): void {
    this.removeFromEntityGrid(entity, entity.gridX, entity.gridY);
    this.removeFromPathingGrid(entity.gridX, entity.gridY);
    this.removeFromRenderingGrid(entity, entity.gridX, entity.gridY);
    if (entity instanceof Character && entity.nextGridX >= 0 && entity.nextGridY >= 0) {
      this.removeFromEntityGrid(entity, entity.nextGridX, entity.nextGridY);
      this.removeFromPathingGrid(entity.nextGridX, entity.nextGridY);
    }
  }

  registerEntityPosition(entity: Entity): void {
    const x = entity.gridX;
    const y = entity.gridY;
    if (this.map.isOutOfBounds(x, y)) return;
    if (entity instanceof Character || entity instanceof Chest) {
      this.entityGrid[y][x].set(entity.id, entity);
      if (!(entity instanceof Player)) this.pathingGrid[y][x] = 1;
    }
    if (entity instanceof Item) this.itemGrid[y][x].set(entity.id, entity);
    this.addToRenderingGrid(entity, x, y);
  }

  /** Start the loop and connect. Resolves once WELCOME has been received. */
  run(transport: Transport, username: string): void {
    this.username = username;
    this.player = new Player(-1, username);
    this.initPlayer();
    this.setCursor('hand');
    this.connect(transport);
  }

  private tick = (): void => {
    this.currentTime = performance.now();
    if (this.started) {
      this.updateCursorLogic();
      this.updater.update();
      this.renderer.renderFrame();
      this.publishProbe();
    } else {
      // Backdrop (intro, death, world switch): animate water and lava, keep tree tops drawn.
      this.updater.updateAnimatedTiles();
      this.renderer.renderFrame();
    }
    if (!this.isStopped) this.rafHandle = requestAnimationFrame(this.tick);
  };

  private lastProbe = '';

  /**
   * Read-only test probe: the rendered local position and the other warriors
   * currently drawn, as data attributes on the game canvas. Used by
   * tests/multiplayer.mjs to observe what the player actually sees.
   */
  private publishProbe(): void {
    const p = this.player;
    const others: string[] = [];
    this.forEachPlayer((other) => {
      if (other !== p) others.push(`${other.name}@${other.gridX},${other.gridY}`);
    });
    const probe = `${p ? `${p.x},${p.y}` : ''}|${others.sort().join(';')}`;
    if (probe === this.lastProbe) return;
    this.lastProbe = probe;
    const canvas = this.renderer.forecanvas;
    canvas.dataset.localX = p ? String(p.x) : '';
    canvas.dataset.localY = p ? String(p.y) : '';
    canvas.dataset.peers = others.sort().join(';');
  }

  private start(): void {
    this.hasNeverStarted = false;
    if (!this.isStopped && this.rafHandle) return;
    this.isStopped = false;
    this.rafHandle = requestAnimationFrame(this.tick);
  }

  stop(): void {
    this.isStopped = true;
    cancelAnimationFrame(this.rafHandle);
    this.rafHandle = 0;
  }

  entityIdExists(id: number): boolean {
    return this.entities.has(id);
  }

  getEntityById(id: number): Entity | undefined {
    return this.entities.get(id);
  }

  /** Drop the current world link (e.g. switching shard) and wipe client-side world state. */
  disconnectFromWorld(): void {
    const client = this.client;
    this.client = null;
    if (client) {
      client.disconnectedCallback = null;
      client.disable();
      client.close();
    }
    this.started = false;
    for (const entity of this.entities.values()) {
      if (entity instanceof Player) entity.dispose();
      else entity.clean();
    }
    this.entities.clear();
    this.deathpositions.clear();
    this.bubbleManager.clean();
    this.initEntityGrid();
    this.initItemGrid();
    this.initPathingGrid();
    this.initRenderingGrid();
  }

  /** Re-enter a (new) world keeping the current character. */
  reconnect(transport: Transport): void {
    const prev = this.player;
    this.player = new Player(-1, this.username);
    this.initPlayer();
    if (prev && !prev.isDead) {
      // Keep the equipment the player had, even if it was not saved yet.
      this.player.setSpriteName(prev.invincible && prev.currentArmorSprite ? prev.currentArmorSprite.id : prev.getSpriteName());
      this.player.setWeaponName(prev.getWeaponName() ?? 'sword1');
      this.player.setSprite(this.sprites[this.player.getSpriteName()]);
      this.player.idle();
    }
    prev?.dispose();
    this.connect(transport);
  }

  private connect(transport: Transport): void {
    const client = new GameClient(transport);
    this.client = client;

    client.connectedCallback = () => {
      this.player!.name = this.username;
      this.started = true;
      this.sendHello();
    };

    client.listCallback = (list) => {
      const known = new Set(list.filter((id) => this.entities.has(id)));
      const newIds = list.filter((id) => !known.has(id));
      const playerId = this.player?.id;
      this.obsoleteEntities = [...this.entities.values()].filter(
        (entity) => !known.has(entity.id) && entity.id !== playerId,
      );
      // Destroy entities outside of the player's zone group
      this.removeObsoleteEntities();
      // Ask the server for spawn information about unknown entities
      if (newIds.length > 0) client.sendWho(newIds);
    };

    client.welcomeCallback = (id, name, x, y, hp) => {
      const player = this.player!;
      player.id = id;
      this.playerId = id;
      // Always accept name received from the server which will sanitize and shorten names.
      player.name = name;
      player.setGridPosition(x, y);
      player.setMaxHitPoints(hp);

      this.updateBars();
      this.resetCamera();
      this.updatePlateauMode();
      this.audioManager.updateMusic();

      this.addEntity(player);

      window.setTimeout(() => this.tryUnlockingAchievement('STILL_ALIVE'), 1500);

      if (!this.storage.hasAlreadyPlayed()) {
        this.storage.initPlayer(player.name);
        this.storage.savePlayer(this.renderer.getPlayerImage(), player.getSpriteName(), player.getWeaponName() ?? 'sword1');
        this.showNotification('Welcome to Browser Side Quest!');
      } else {
        this.showNotification('Welcome back to Browser Side Quest!');
        this.storage.setPlayerName(name);
      }

      this.bindPlayer(player, client);
      this.bindWorldEvents(client);

      this.events.gameStart();
      if (this.hasNeverStarted) this.start();
    };

    client.connect();
  }

  private bindPlayer(player: Player, client: GameClient): void {
    player.onStartPathing((path) => {
      const i = path.length - 1;
      const x = path[i][0];
      const y = path[i][1];

      if (player.isMovingToLoot()) {
        player.isLootMoving = false;
      } else if (!player.isAttacking()) {
        client.sendMove(x, y);
      }

      // Target cursor position
      this.selectedX = x;
      this.selectedY = y;
      this.selectedCellVisible = true;
    });

    player.onCheckAggro(() => {
      this.forEachMob((mob) => {
        if (mob.isAggressive && !mob.isAttacking() && player.isNear(mob, mob.aggroRange)) {
          player.aggro(mob);
        }
      });
    });

    player.onAggro((mob) => {
      if (!mob.isWaitingToAttack(player) && !player.isAttackedBy(mob)) {
        client.sendAggro(mob);
        mob.waitToAttack(player);
      }
    });

    player.onBeforeStep(() => {
      this.unregisterEntityPosition(player);
    });

    player.onStep(() => {
      if (player.hasNextStep()) this.registerEntityDualPosition(player);

      if (this.isZoningTile(player.gridX, player.gridY)) {
        this.enqueueZoningFrom(player.gridX, player.gridY);
      }

      player.forEachAttacker((attacker) => {
        if (attacker.isAdjacent(attacker.target)) attacker.lookAtTarget();
        else attacker.follow(player);
      });

      // Community fork (44da4d4): pick up items when walking over them.
      const stepItem = this.getItemAt(player.gridX, player.gridY);
      if (stepItem) this.tryLootingItem(stepItem);

      const { gridX: gx, gridY: gy } = player;
      if ((gx <= 85 && gy <= 179 && gy > 178) || (gx <= 85 && gy <= 266 && gy > 265)) {
        this.tryUnlockingAchievement('INTO_THE_WILD');
      }
      if (gx <= 85 && gy <= 293 && gy > 292) this.tryUnlockingAchievement('AT_WORLDS_END');
      if (gx <= 85 && gy <= 100 && gy > 99) this.tryUnlockingAchievement('NO_MANS_LAND');
      if (gx <= 85 && gy <= 51 && gy > 50) this.tryUnlockingAchievement('HOT_SPOT');
      if (gx <= 27 && gy <= 123 && gy > 112) this.tryUnlockingAchievement('TOMB_RAIDER');

      this.updatePlayerCheckpoint();
      if (!player.isDead) this.audioManager.updateMusic();
    });

    player.onStopPathing((x, y) => {
      if (player.hasTarget()) player.lookAtTarget();
      this.selectedCellVisible = false;

      const item = this.getItemAt(x, y);
      if (item) this.tryLootingItem(item);

      if (!player.hasTarget() && this.map.isDoor(x, y)) {
        const dest = this.map.getDoorDestination(x, y)!;
        player.setGridPosition(dest.x, dest.y);
        player.nextGridX = dest.x;
        player.nextGridY = dest.y;
        player.turnTo(dest.orientation);
        client.sendTeleport(dest.x, dest.y);

        if (dest.portal) {
          this.assignBubbleTo(player);
        } else {
          this.camera.focusEntity(player);
          this.resetZone();
        }

        if (Object.keys(player.attackers).length > 0) {
          window.setTimeout(() => this.tryUnlockingAchievement('COWARD'), 500);
        }
        player.forEachAttacker((attacker) => {
          attacker.disengage();
          attacker.idle();
        });

        this.updatePlateauMode();
        this.checkUndergroundAchievement();
        if (dest.portal) this.audioManager.playSound('teleport');
        if (!player.isDead) this.audioManager.updateMusic();
      }

      if (player.target instanceof Npc) {
        this.makeNpcTalk(player.target);
      } else if (player.target instanceof Chest) {
        client.sendOpen(player.target);
        this.audioManager.playSound('chest');
      }

      player.forEachAttacker((attacker) => {
        if (!attacker.isAdjacentNonDiagonal(player)) attacker.follow(player);
      });

      this.unregisterEntityPosition(player);
      this.registerEntityPosition(player);
    });

    player.onRequestPath((x, y) => {
      const ignored: Entity[] = [player]; // Always ignore self
      if (player.hasTarget()) ignored.push(player.target!);
      return this.findPath(player, x, y, ignored);
    });

    player.onDeath(() => {
      player.stopBlinking();
      player.setSprite(this.sprites.death);
      player.animate('death', 120, 1, () => {
        this.removeEntity(player);
        this.removeFromRenderingGrid(player, player.gridX, player.gridY);
        if (this.player === player) this.player = null;
        client.disable();
        window.setTimeout(() => this.events.playerDeath(), 1000);
      });

      player.forEachAttacker((attacker) => {
        attacker.disengage();
        attacker.idle();
      });

      this.audioManager.fadeOutCurrentMusic();
      this.audioManager.playSound('death');
    });

    player.onHasMoved((p) => this.assignBubbleTo(p));

    player.onArmorLoot((armorName) => player.switchArmor(this.sprites[armorName]));

    player.onSwitchItem(() => {
      this.storage.savePlayer(this.renderer.getPlayerImage(), player.getArmorName(), player.getWeaponName() ?? 'sword1');
      this.events.equipmentChange();
    });

    player.onInvincible(() => {
      this.events.playerInvincible();
      player.switchArmor(this.sprites.firefox);
    });
  }

  private bindWorldEvents(client: GameClient): void {
    client.spawnItemCallback = (item, x, y) => this.addItem(item, x, y);

    client.spawnChestCallback = (chest, x, y) => {
      chest.setSprite(this.sprites[chest.getSpriteName()]);
      chest.setGridPosition(x, y);
      chest.setAnimation('idle_down', 150);
      this.addEntity(chest);

      chest.onOpen(() => {
        chest.stopBlinking();
        chest.setSprite(this.sprites.death);
        chest.setAnimation('death', 120, 1, () => {
          this.removeEntity(chest);
          this.removeFromRenderingGrid(chest, chest.gridX, chest.gridY);
          this.previousClickPosition = {};
        });
      });
    };

    client.spawnCharacterCallback = (entity, x, y, orientation, targetId) => {
      if (this.entityIdExists(entity.id) || entity.id === this.playerId) return;
      const sprite = this.sprites[entity.getSpriteName()];
      if (!sprite) return;
      entity.setSprite(sprite);
      entity.setGridPosition(x, y);
      entity.setOrientation(orientation as Orientation);
      entity.idle();
      this.addEntity(entity);

      entity.onBeforeStep(() => this.unregisterEntityPosition(entity));

      entity.onStep(() => {
        if (!entity.isDying) {
          this.registerEntityDualPosition(entity);
          // Community fork (8e953e0): keep chasing a target that moves away.
          const player = this.player;
          if (player && player.target === entity) {
            if (player.isAdjacent(entity)) player.lookAtTarget();
            else player.follow(entity);
          }
          entity.forEachAttacker((attacker) => {
            if (attacker.isAdjacent(attacker.target)) attacker.lookAtTarget();
            else attacker.follow(entity);
          });
        }
      });

      entity.onStopPathing(() => {
        if (entity.isDying) return;
        if (entity.hasTarget() && entity.isAdjacent(entity.target)) entity.lookAtTarget();

        if (entity instanceof Player && entity.destination) {
          const { gridX, gridY } = entity.destination;
          if (this.map.isDoor(gridX, gridY)) {
            const dest = this.map.getDoorDestination(gridX, gridY)!;
            entity.setGridPosition(dest.x, dest.y);
          }
        }

        entity.forEachAttacker((attacker) => {
          if (!attacker.isAdjacentNonDiagonal(entity) && attacker.id !== this.playerId) attacker.follow(entity);
        });

        this.unregisterEntityPosition(entity);
        this.registerEntityPosition(entity);
      });

      entity.onRequestPath((px, py) => {
        const ignored: Entity[] = [entity]; // Always ignore self
        const ignoreTarget = (target: Entity): void => {
          ignored.push(target);
          // also ignore other attackers of the target entity
          if (target instanceof Character) target.forEachAttacker((attacker) => ignored.push(attacker));
        };
        if (entity.hasTarget()) ignoreTarget(entity.target!);
        else if (entity.previousTarget) ignoreTarget(entity.previousTarget);
        return this.findPath(entity, px, py, ignored);
      });

      entity.onDeath(() => {
        if (entity instanceof Mob) {
          // Keep track of where mobs die in order to spawn their dropped items at the right position later.
          this.deathpositions.set(entity.id, { x: entity.gridX, y: entity.gridY });
        }

        entity.isDying = true;
        entity.setSprite(this.sprites[entity.kind === Entities.RAT ? 'rat' : 'death']);
        entity.animate('death', 120, 1, () => {
          this.removeEntity(entity);
          this.removeFromRenderingGrid(entity, entity.gridX, entity.gridY);
        });

        entity.forEachAttacker((attacker) => attacker.disengage());

        if (this.player?.target && this.player.target.id === entity.id) this.player.disengage();

        // Upon death, this entity is removed from both grids, allowing the player
        // to click very fast in order to loot the dropped item and not be blocked.
        this.removeFromEntityGrid(entity, entity.gridX, entity.gridY);
        this.removeFromPathingGrid(entity.gridX, entity.gridY);

        if (this.camera.isVisible(entity)) {
          this.audioManager.playSound(`kill${Math.floor(Math.random() * 2 + 1)}`);
        }
        this.updateCursor();
      });

      entity.onHasMoved((e) => this.assignBubbleTo(e));

      if (entity instanceof Mob && targetId) {
        const player = this.getEntityById(targetId);
        if (player instanceof Character) this.createAttackLink(entity, player);
      }
    };

    client.despawnCallback = (entityId) => {
      const entity = this.getEntityById(entityId);
      if (!entity) return;
      if (entity.gridX === this.previousClickPosition.x && entity.gridY === this.previousClickPosition.y) {
        this.previousClickPosition = {};
      }
      if (entity instanceof Item) {
        this.removeItem(entity);
      } else if (entity instanceof Character) {
        entity.forEachAttacker((attacker) => {
          if (attacker.canReachTarget()) attacker.hit();
        });
        entity.die();
      } else if (entity instanceof Chest) {
        entity.open();
      }
      entity.clean();
    };

    client.playerInfoCallback = (id, pubkey) => {
      const player = this.getEntityById(id);
      if (player instanceof Player && /^[0-9a-f]{64}$/.test(pubkey)) player.nostrPubkey = pubkey;
    };

    client.blinkCallback = (id) => {
      this.getEntityById(id)?.blink(150);
    };

    client.moveCallback = (id, x, y) => {
      if (id === this.playerId) return;
      const entity = this.getEntityById(id);
      if (entity instanceof Character) {
        if (this.player?.isAttackedBy(entity)) this.tryUnlockingAchievement('COWARD');
        entity.disengage();
        entity.idle();
        this.makeCharacterGoTo(entity, x, y);
      }
    };

    client.destroyCallback = (id) => {
      const entity = this.getEntityById(id);
      if (entity instanceof Item) this.removeItem(entity);
      else if (entity) this.removeEntity(entity);
    };

    client.lootMoveCallback = (playerId, itemId) => {
      if (playerId === this.playerId) return;
      const player = this.getEntityById(playerId);
      const item = this.getEntityById(itemId);
      if (player instanceof Character && item) this.makeCharacterGoTo(player, item.gridX, item.gridY);
    };

    client.attackCallback = (attackerId, targetId) => {
      const attacker = this.getEntityById(attackerId);
      const target = this.getEntityById(targetId);
      if (attacker instanceof Character && target instanceof Character && attacker.id !== this.playerId) {
        if (
          target instanceof Player &&
          target.id !== this.playerId &&
          target.target &&
          target.target.id === attacker.id &&
          attacker.getDistanceToEntity(target) < 3
        ) {
          // delay to prevent other players attacking mobs from ending up on the same tile as they walk towards each other.
          window.setTimeout(() => this.createAttackLink(attacker, target), 200);
        } else {
          this.createAttackLink(attacker, target);
        }
      }
    };

    client.dmgCallback = (mobId, points) => {
      const mob = this.getEntityById(mobId);
      if (mob && points) this.infoManager.addDamageInfo(points, mob.x, mob.y - 15, 'inflicted');
    };

    client.killCallback = (kind) => {
      let mobName = getKindAsString(kind) ?? 'enemy';
      if (mobName === 'skeleton2') mobName = 'greater skeleton';
      if (mobName === 'eye') mobName = 'evil eye';
      if (mobName === 'deathknight') mobName = 'death knight';

      if (mobName === 'boss') {
        this.showNotification('You killed the skeleton king');
      } else if ('aeiou'.includes(mobName[0])) {
        this.showNotification(`You killed an ${mobName}`);
      } else {
        this.showNotification(`You killed a ${mobName}`);
      }

      this.storage.incrementTotalKills();
      this.tryUnlockingAchievement('HUNTER');

      if (kind === Entities.RAT) {
        this.storage.incrementRatCount();
        this.tryUnlockingAchievement('ANGRY_RATS');
      }
      if (kind === Entities.SKELETON || kind === Entities.SKELETON2) {
        this.storage.incrementSkeletonCount();
        this.tryUnlockingAchievement('SKULL_COLLECTOR');
      }
      if (kind === Entities.BOSS) this.tryUnlockingAchievement('HERO');
    };

    client.healthCallback = (points, isRegen) => {
      const player = this.player;
      if (player && !player.isDead && !player.invincible) {
        const isHurt = points <= player.hitPoints;
        const diff = points - player.hitPoints;
        player.hitPoints = points;

        if (player.hitPoints <= 0) player.die();
        if (isHurt) {
          player.hurt();
          this.infoManager.addDamageInfo(diff, player.x, player.y - 15, 'received');
          this.audioManager.playSound('hurt');
          this.storage.addDamage(-diff);
          this.tryUnlockingAchievement('MEATSHIELD');
          this.events.playerHurt();
        } else if (!isRegen) {
          this.infoManager.addDamageInfo(`+${diff}`, player.x, player.y - 15, 'healed');
        }
        this.updateBars();
      }
    };

    client.hpCallback = (hp) => {
      if (!this.player) return;
      this.player.maxHitPoints = hp;
      this.player.hitPoints = hp;
      this.updateBars();
    };

    client.equipCallback = (playerId, itemKind) => {
      const player = this.getEntityById(playerId);
      const itemName = getKindAsString(itemKind) ?? '';
      if (player instanceof Player) {
        if (itemKind >= Entities.FIREFOX && itemKind <= Entities.GOLDENARMOR) {
          const sprite = this.sprites[itemName];
          if (sprite) player.setSprite(sprite);
        } else if (itemKind >= Entities.SWORD1) {
          player.setWeaponName(itemName);
        }
      }
    };

    client.teleportCallback = (id, x, y) => {
      if (id === this.playerId) return;
      const entity = this.getEntityById(id);
      if (entity instanceof Character) {
        const currentOrientation = entity.orientation;
        this.makeCharacterTeleportTo(entity, x, y);
        entity.setOrientation(currentOrientation);
        entity.forEachAttacker((attacker) => {
          attacker.disengage();
          attacker.idle();
          attacker.stop();
        });
      }
    };

    client.dropCallback = (item, mobId) => {
      const pos = this.getDeadMobPosition(mobId);
      if (pos) {
        this.addItem(item, pos.x, pos.y);
        this.updateCursor();
      }
    };

    client.chatCallback = (entityId, message) => {
      const entity = this.getEntityById(entityId);
      this.createBubble(entityId, message);
      if (entity) this.assignBubbleTo(entity);
      this.audioManager.playSound('chat');
    };

    client.populationCallback = (worldPlayers, totalPlayers) => {
      this.events.nbPlayersChange(worldPlayers, totalPlayers);
    };

    client.disconnectedCallback = (message) => {
      if (this.player && !this.player.isDead) this.player.die();
      this.events.disconnect(message);
    };
  }

  /** Links two entities in an attacker<-->target relationship. */
  createAttackLink(attacker: Character, target: Character): void {
    if (attacker.hasTarget()) attacker.removeTarget();
    attacker.engage(target);
    if (attacker.id !== this.playerId) target.addAttacker(attacker);
  }

  private sendHello(): void {
    if (this.player) this.client?.sendHello(this.player);
  }

  /** Converts the current mouse position on the canvas to world grid coordinates. */
  getMouseGridPosition(): { x: number; y: number } {
    const c = this.camera;
    const size = this.renderer.tilesize * this.renderer.scale;
    return {
      x: Math.floor(this.mouse.x / size) + c.gridX,
      y: Math.floor(this.mouse.y / size) + c.gridY,
    };
  }

  makeCharacterGoTo(character: Character, x: number, y: number): void {
    if (!this.map.isOutOfBounds(x, y)) character.go(x, y);
  }

  private makeCharacterTeleportTo(character: Character, x: number, y: number): void {
    if (!this.map.isOutOfBounds(x, y)) {
      this.unregisterEntityPosition(character);
      character.setGridPosition(x, y);
      this.registerEntityPosition(character);
      this.assignBubbleTo(character);
    }
  }

  makePlayerGoTo(x: number, y: number): void {
    if (this.player) this.makeCharacterGoTo(this.player, x, y);
  }

  private makePlayerGoToItem(item: Item): void {
    if (!this.player) return;
    this.player.isLootMoving = true;
    this.makePlayerGoTo(item.gridX, item.gridY);
    this.client?.sendLootMove(item, item.gridX, item.gridY);
  }

  private makePlayerTalkTo(npc: Npc): void {
    this.player?.setTarget(npc);
    this.player?.follow(npc);
  }

  private makePlayerOpenChest(chest: Chest): void {
    this.player?.setTarget(chest);
    this.player?.follow(chest);
  }

  private makePlayerAttack(mob: Mob): void {
    if (!this.player) return;
    this.createAttackLink(this.player, mob);
    this.client?.sendAttack(mob);
  }

  private makeNpcTalk(npc: Npc): void {
    const msg = npc.talk();
    this.previousClickPosition = {};
    if (msg) {
      this.createBubble(npc.id, msg);
      this.assignBubbleTo(npc);
      this.audioManager.playSound('npc');
    } else {
      this.destroyBubble(npc.id);
      this.audioManager.playSound('npc-end');
    }
    this.tryUnlockingAchievement('SMALL_TALK');
    if (npc.kind === Entities.RICK) this.tryUnlockingAchievement('RICKROLLD');
  }

  forEachEntity(callback: (entity: Entity) => void): void {
    for (const entity of [...this.entities.values()]) callback(entity);
  }

  forEachMob(callback: (mob: Mob) => void): void {
    for (const entity of this.entities.values()) if (entity instanceof Mob) callback(entity);
  }

  /** Visible entities sorted by depth: lower 'y' value means higher depth. */
  forEachVisibleEntityByDepth(callback: (entity: Entity) => void): void {
    const m = this.map;
    this.camera.forEachVisiblePosition((x, y) => {
      if (!m.isOutOfBounds(x, y)) {
        for (const entity of this.renderingGrid[y][x].values()) callback(entity);
      }
    }, 2);
  }

  private forEachVisibleTileIndex(callback: (tileIndex: number) => void, extra: number): void {
    const m = this.map;
    this.camera.forEachVisiblePosition((x, y) => {
      if (!m.isOutOfBounds(x, y)) callback(m.gridPositionToTileIndex(x, y) - 1);
    }, extra);
  }

  forEachVisibleTile(callback: (id: number, tileIndex: number) => void, extra: number): void {
    const m = this.map;
    this.forEachVisibleTileIndex((tileIndex) => {
      const data = m.data[tileIndex];
      if (Array.isArray(data)) {
        for (const id of data) callback(id - 1, tileIndex);
      } else if (typeof data === 'number' && !Number.isNaN(data - 1)) {
        callback(data - 1, tileIndex);
      }
    }, extra);
  }

  forEachAnimatedTile(callback: (tile: AnimatedTile) => void): void {
    for (const tile of this.animatedTiles) callback(tile);
  }

  /** Returns the entity located at the given position on the world grid. */
  getEntityAt(x: number, y: number): Entity | null {
    if (this.map.isOutOfBounds(x, y) || !this.entityGrid[y]?.[x]) return null;
    const entities = this.entityGrid[y][x];
    if (entities.size > 0) return entities.values().next().value ?? null;
    return this.getItemAt(x, y);
  }

  getMobAt(x: number, y: number): Mob | null {
    const entity = this.getEntityAt(x, y);
    return entity instanceof Mob ? entity : null;
  }

  getNpcAt(x: number, y: number): Npc | null {
    const entity = this.getEntityAt(x, y);
    return entity instanceof Npc ? entity : null;
  }

  getChestAt(x: number, y: number): Chest | null {
    const entity = this.getEntityAt(x, y);
    return entity instanceof Chest ? entity : null;
  }

  getItemAt(x: number, y: number): Item | null {
    if (this.map.isOutOfBounds(x, y) || !this.itemGrid[y]?.[x]) return null;
    const items = this.itemGrid[y][x];
    if (items.size === 0) return null;
    // If there are potions/burgers stacked with equipment items on the same tile, always get expendable items first.
    let item: Item | null = null;
    for (const i of items.values()) {
      if (i.kind === Entities.FLASK || i.kind === Entities.BURGER || i.kind === Entities.FIREPOTION || i.kind === Entities.CAKE) {
        item = i;
      }
    }
    return item ?? items.values().next().value ?? null;
  }

  isMobAt(x: number, y: number): boolean {
    return this.getMobAt(x, y) !== null;
  }

  isItemAt(x: number, y: number): boolean {
    return this.getItemAt(x, y) !== null;
  }

  isNpcAt(x: number, y: number): boolean {
    return this.getNpcAt(x, y) !== null;
  }

  isChestAt(x: number, y: number): boolean {
    return this.getChestAt(x, y) !== null;
  }

  /** Finds a path to a grid position. The path will pass through any entity present in the ignore list. */
  private findPath(character: Character, x: number, y: number, ignoreList: Entity[]): Path {
    if (this.map.isColliding(x, y)) return [];
    for (const entity of ignoreList) this.pathfinder.ignoreEntity(entity as unknown as Character);
    const path = this.pathfinder.findPath(this.pathingGrid, character, x, y, false);
    this.pathfinder.clearIgnoreList();
    return path;
  }

  togglePathingGrid(): void {}

  toggleDebugInfo(): void {
    this.renderer.isDebugInfoVisible = !this.renderer.isDebugInfoVisible;
  }

  private updateHoverAt(x: number, y: number): void {
    const player = this.player;
    if (!player) return;
    this.hoveringCollidingTile = this.map.isColliding(x, y);
    this.hoveringPlateauTile = player.isOnPlateau ? !this.map.isPlateau(x, y) : this.map.isPlateau(x, y);
    this.hoveringMob = this.isMobAt(x, y);
    this.hoveringItem = this.isItemAt(x, y);
    this.hoveringNpc = this.isNpcAt(x, y);
    this.hoveringChest = this.isChestAt(x, y);
  }

  movecursor(): void {
    const { x, y } = this.getMouseGridPosition();
    if (!this.player || this.renderer.touch) return;
    this.updateHoverAt(x, y);

    if (this.hoveringMob || this.hoveringNpc || this.hoveringChest) {
      const entity = this.getEntityAt(x, y);
      if (entity && !entity.isHighlighted && this.renderer.supportsSilhouettes) {
        this.lastHovered?.setHighlight(false);
        this.lastHovered = entity;
        entity.setHighlight(true);
      }
    } else if (this.lastHovered) {
      this.lastHovered.setHighlight(false);
      this.lastHovered = null;
    }
  }

  /** Processes game logic when the user clicks or taps during the game. */
  click(): void {
    const pos = this.getMouseGridPosition();
    this.clickGrid(pos.x, pos.y, false);
  }

  /**
   * Same rules as the original click(), addressed by grid position so keyboard,
   * gamepad and touch input share one path. `force` skips the repeated-click guard.
   */
  clickGrid(x: number, y: number, force: boolean): void {
    if (!force && x === this.previousClickPosition.x && y === this.previousClickPosition.y) return;
    this.previousClickPosition = { x, y };

    const player = this.player;
    this.updateHoverAt(x, y);
    if (
      this.started &&
      player &&
      !this.isZoning() &&
      !this.isZoningTile(player.nextGridX, player.nextGridY) &&
      !player.isDead &&
      !this.hoveringCollidingTile &&
      !this.hoveringPlateauTile
    ) {
      const entity = this.getEntityAt(x, y);
      if (entity instanceof Mob) {
        this.makePlayerAttack(entity);
      } else if (entity instanceof Item) {
        this.makePlayerGoToItem(entity);
      } else if (entity instanceof Npc) {
        if (!player.isAdjacentNonDiagonal(entity)) this.makePlayerTalkTo(entity);
        else this.makeNpcTalk(entity);
      } else if (entity instanceof Chest) {
        this.makePlayerOpenChest(entity);
      } else {
        this.makePlayerGoTo(x, y);
      }
    }
  }

  /**
   * Keyboard / gamepad / touch pad: walk one tile in a direction, interacting with
   * whatever is there. While already walking, extend the path from the tile being
   * entered so a held direction moves continuously (Character.continueTo).
   */
  stepPlayer(dx: number, dy: number): void {
    const player = this.player;
    if (!player || player.isDead || this.isZoning()) return;
    const moving = player.isMoving() && player.nextGridX >= 0;
    const fromX = moving ? player.nextGridX : player.gridX;
    const fromY = moving ? player.nextGridY : player.gridY;
    const x = fromX + dx;
    const y = fromY + dy;
    if (moving && player.newDestination?.x === x && player.newDestination?.y === y) return;
    if (moving && player.destination?.gridX === x && player.destination?.gridY === y) return;
    if (!moving && player.isAttacking() && player.target?.gridX === x && player.target?.gridY === y) return;
    this.clickGrid(x, y, true);
  }

  /** Esc: original behavior, stop every mob that is chasing the player. */
  stopAttackers(): void {
    this.player?.forEachAttacker((attacker) => attacker.stop());
  }

  /** Keyboard / gamepad action button: attack, talk to, open or pick up the nearest thing. */
  interact(): void {
    const player = this.player;
    if (!player || player.isDead) return;
    const facing: Record<number, [number, number]> = {
      [Orientations.UP]: [0, -1],
      [Orientations.DOWN]: [0, 1],
      [Orientations.LEFT]: [-1, 0],
      [Orientations.RIGHT]: [1, 0],
    };
    const [fx, fy] = facing[player.orientation];
    const candidates: [number, number][] = [
      [player.gridX + fx, player.gridY + fy],
      [player.gridX, player.gridY - 1],
      [player.gridX, player.gridY + 1],
      [player.gridX - 1, player.gridY],
      [player.gridX + 1, player.gridY],
    ];
    for (const [x, y] of candidates) {
      const entity = this.getEntityAt(x, y);
      if (entity instanceof Mob || entity instanceof Npc || entity instanceof Chest || entity instanceof Item) {
        this.clickGrid(x, y, true);
        return;
      }
    }
    // Nothing adjacent: engage the closest visible mob within a short reach.
    let best: Mob | null = null;
    let bestDist = 4;
    this.forEachMob((mob) => {
      if (mob.isDead || mob.isDying) return;
      const d = player.getDistanceToEntity(mob);
      if (d < bestDist && this.camera.isVisible(mob)) {
        best = mob;
        bestDist = d;
      }
    });
    const target = best as Mob | null;
    if (target) this.clickGrid(target.gridX, target.gridY, true);
  }

  private isMobOnSameTile(mob: Mob, x?: number, y?: number): boolean {
    const X = x || mob.gridX;
    const Y = y || mob.gridY;
    for (const entity of this.entityGrid[Y]?.[X]?.values() ?? []) {
      if (entity instanceof Mob && entity.id !== mob.id) return true;
    }
    return false;
  }

  private getFreeAdjacentNonDiagonalPosition(entity: Entity): { x: number; y: number; o: Orientation } | null {
    let result: { x: number; y: number; o: Orientation } | null = null;
    entity.forEachAdjacentNonDiagonalPosition((x, y, o) => {
      if (!result && !this.map.isColliding(x, y) && !this.isMobAt(x, y)) result = { x, y, o };
    });
    return result;
  }

  private tryMovingToADifferentTile(attacker: Character): boolean {
    const target = attacker.target;
    if (!(target instanceof Player)) return false;

    if (!target.isMoving() && attacker.getDistanceToEntity(target) === 0) {
      let pos: { x: number; y: number; o: Orientation } | null = null;
      switch (target.orientation) {
        case Orientations.UP:
          pos = { x: target.gridX, y: target.gridY - 1, o: target.orientation };
          break;
        case Orientations.DOWN:
          pos = { x: target.gridX, y: target.gridY + 1, o: target.orientation };
          break;
        case Orientations.LEFT:
          pos = { x: target.gridX - 1, y: target.gridY, o: target.orientation };
          break;
        case Orientations.RIGHT:
          pos = { x: target.gridX + 1, y: target.gridY, o: target.orientation };
          break;
      }
      if (pos) {
        attacker.previousTarget = target;
        attacker.disengage();
        attacker.idle();
        this.makeCharacterGoTo(attacker, pos.x, pos.y);
        target.adjacentTiles[pos.o] = true;
        return true;
      }
    }

    if (!target.isMoving() && attacker.isAdjacentNonDiagonal(target) && attacker instanceof Mob && this.isMobOnSameTile(attacker)) {
      const pos = this.getFreeAdjacentNonDiagonalPosition(target);
      // avoid stacking mobs on the same tile next to a player
      if (pos && !target.adjacentTiles[pos.o]) {
        if (this.player?.target && attacker.id === this.player.target.id) return false; // never unstack the player's target
        attacker.previousTarget = target;
        attacker.disengage();
        attacker.idle();
        this.makeCharacterGoTo(attacker, pos.x, pos.y);
        target.adjacentTiles[pos.o] = true;
        return true;
      }
    }
    return false;
  }

  onCharacterUpdate(character: Character): void {
    const time = this.currentTime;

    // If mob has finished moving to a different tile in order to avoid stacking, attack again from the new position.
    if (character.previousTarget && !character.isMoving() && character instanceof Mob) {
      const t = character.previousTarget;
      if (this.getEntityById(t.id)) {
        character.previousTarget = null;
        this.createAttackLink(character, t);
        return;
      }
    }

    if (character.isAttacking() && !character.previousTarget) {
      // Don't let multiple mobs stack on the same tile when attacking a player.
      const isMoving = this.tryMovingToADifferentTile(character);

      if (character.canAttack(time)) {
        if (!isMoving) {
          // don't hit target if moving to a different tile.
          if (character.hasTarget() && character.getOrientationTo(character.target!) !== character.orientation) {
            character.lookAtTarget();
          }
          character.hit();

          if (character.id === this.playerId && character.target) this.client?.sendHit(character.target);

          if (character instanceof Player && this.camera.isVisible(character)) {
            this.audioManager.playSound(`hit${Math.floor(Math.random() * 2 + 1)}`);
          }

          if (character.hasTarget() && character.target!.id === this.playerId && this.player && !this.player.invincible) {
            this.client?.sendHurt(character);
          }
        }
      } else if (
        character.hasTarget() &&
        character.isDiagonallyAdjacent(character.target!) &&
        character.target instanceof Player &&
        !character.target.isMoving()
      ) {
        character.follow(character.target);
      }
    }
  }

  isZoningTile(x: number, y: number): boolean {
    const c = this.camera;
    x -= c.gridX;
    y -= c.gridY;
    return x === 0 || y === 0 || x === c.gridW - 1 || y === c.gridH - 1;
  }

  private getZoningOrientation(x: number, y: number): Orientation | 0 {
    const c = this.camera;
    x -= c.gridX;
    y -= c.gridY;
    if (x === 0) return Orientations.LEFT;
    if (y === 0) return Orientations.UP;
    if (x === c.gridW - 1) return Orientations.RIGHT;
    if (y === c.gridH - 1) return Orientations.DOWN;
    return 0;
  }

  private startZoningFrom(x: number, y: number): void {
    this.zoningOrientation = this.getZoningOrientation(x, y);
    this.currentZoning = new Transition();
    this.bubbleManager.clean();
    this.client?.sendZone();
  }

  private enqueueZoningFrom(x: number, y: number): void {
    this.zoningQueue.push({ x, y });
    if (this.zoningQueue.length === 1) this.startZoningFrom(x, y);
  }

  endZoning(): void {
    this.currentZoning = null;
    this.resetZone();
    this.zoningQueue.shift();
    if (this.zoningQueue.length > 0) {
      const pos = this.zoningQueue[0];
      this.startZoningFrom(pos.x, pos.y);
    }
  }

  /** Loot an item under the player (shared by click-to-loot and walk-over pickup). */
  private tryLootingItem(item: Item): void {
    try {
      this.player!.loot(item);
      this.client?.sendLoot(item); // Notify the server that this item has been looted
      this.removeItem(item);
      this.showNotification(item.getLootMessage());

      if (item.type === 'armor') this.tryUnlockingAchievement('FAT_LOOT');
      if (item.type === 'weapon') this.tryUnlockingAchievement('A_TRUE_WARRIOR');
      if (item.kind === Entities.CAKE) this.tryUnlockingAchievement('FOR_SCIENCE');
      if (item.kind === Entities.FIREPOTION) {
        this.tryUnlockingAchievement('FOXY');
        this.audioManager.playSound('firefox');
      }
      this.audioManager.playSound(isHealingItem(item.kind) ? 'heal' : 'loot');

      if (item.wasDropped && !item.playersInvolved.includes(this.playerId)) {
        this.tryUnlockingAchievement('NINJA_LOOT');
      }
    } catch (e) {
      if (e instanceof LootException) {
        this.showNotification(e.message);
        this.audioManager.playSound('noloot');
      } else {
        throw e;
      }
    }
  }

  isZoning(): boolean {
    return this.currentZoning !== null;
  }

  private resetZone(): void {
    this.bubbleManager.clean();
    this.initAnimatedTiles();
    this.renderer.renderStaticCanvases();
  }

  private resetCamera(): void {
    if (this.player) this.camera.focusEntity(this.player);
    this.resetZone();
  }

  say(message: string): void {
    this.client?.sendChat(message);
  }

  private createBubble(id: number, message: string): void {
    this.bubbleManager.create(id, message, this.currentTime);
  }

  private destroyBubble(id: number): void {
    this.bubbleManager.destroyBubble(id);
  }

  assignBubbleTo(character: Entity): void {
    const bubble = this.bubbleManager.getBubbleById(character.id);
    if (!bubble) return;
    const r = this.renderer;
    const s = r.scale / r.dpr; // CSS px per world px
    const t = 16 * s;
    const x = (character.x - this.camera.x) * s;
    const w = bubble.element.offsetWidth;
    const offset = w / 2 - t / 2;
    // The original fixed the bubble's top two tiles above the speaker, which only
    // lined up with its own bubble height. Anchor the tail's tip instead: a few
    // pixels above the top of the speaker's sprite (about where the original put it).
    const spriteTop = character.sprite ? character.sprite.offsetY : -8;
    const tipY = (character.y + spriteTop - 4 - this.camera.y) * s;
    const y = tipY - BUBBLE_TAIL_HEIGHT - bubble.element.offsetHeight;
    bubble.element.style.left = `${x - offset}px`;
    bubble.element.style.top = `${y}px`;
  }

  restart(): void {
    this.entities.clear();
    this.initEntityGrid();
    this.initPathingGrid();
    this.initRenderingGrid();
    this.initItemGrid();

    this.player?.dispose();
    this.player = new Player(-1, this.username);
    this.initPlayer();

    this.started = true;
    this.client?.enable();
    this.sendHello();
    this.storage.incrementRevives();
  }

  /** Re-fit the canvases to the frame without restarting the game. */
  resize(cssWidth: number, cssHeight: number): void {
    this.renderer.resize(cssWidth, cssHeight);
    if (this.player && this.started) {
      this.camera.focusEntity(this.player);
      this.zoningQueue = [];
      this.currentZoning = null;
    }
    this.initAnimatedTiles();
    this.renderer.renderStaticCanvases();
    this.forEachEntity((e) => this.assignBubbleTo(e));
  }

  updateBars(): void {
    if (this.player) this.events.playerHealthChange(this.player.hitPoints, this.player.maxHitPoints);
  }

  private getDeadMobPosition(mobId: number): { x: number; y: number } | undefined {
    const position = this.deathpositions.get(mobId);
    this.deathpositions.delete(mobId);
    return position;
  }

  tryUnlockingAchievement(name: string): void {
    const achievement = this.achievements[name];
    if (achievement && achievement.isCompleted() && this.storage.unlockAchievement(achievement.id)) {
      this.events.achievementUnlock(achievement.id, achievement.name, achievement.desc);
      this.audioManager.playSound('achievement');
    }
  }

  showNotification(message: string): void {
    this.events.notification(message);
  }

  private removeObsoleteEntities(): void {
    const obsolete = this.obsoleteEntities ?? [];
    for (const entity of obsolete) {
      if (entity.id !== this.player?.id) this.removeEntity(entity);
    }
    this.obsoleteEntities = null;
  }

  /** Fake a mouse move event in order to update the cursor. */
  updateCursor(): void {
    this.movecursor();
    this.updateCursorLogic();
  }

  private updatePlateauMode(): void {
    if (this.player) this.player.isOnPlateau = this.map.isPlateau(this.player.gridX, this.player.gridY);
  }

  private updatePlayerCheckpoint(): void {
    const player = this.player;
    if (!player) return;
    const checkpoint = this.map.getCurrentCheckpoint(player);
    if (checkpoint) {
      const last = player.lastCheckpoint;
      if (!last || last.id !== checkpoint.id) {
        player.lastCheckpoint = checkpoint;
        this.client?.sendCheck(Number(checkpoint.id));
      }
    }
  }

  private checkUndergroundAchievement(): void {
    if (!this.player) return;
    const music = this.audioManager.getSurroundingMusic(this.player);
    if (music?.name === 'cave') this.tryUnlockingAchievement('UNDERGROUND');
  }

  /** Every remote warrior currently known, for the player list. */
  forEachPlayer(callback: (player: Player) => void): void {
    for (const entity of this.entities.values()) if (entity instanceof Player) callback(entity);
  }
}