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