You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1124 lines
32 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { MAX_ACCUMULATED_MS } from '../shared/constants';
import type {
GameState,
EnemyState,
HeroState,
LootDrop,
TownData,
NearbyHeroData,
NPCData,
BuildingData,
ActiveDebuff,
DebuffType,
} from './types';
import { GamePhase } from './types';
import { GameRenderer, worldToScreen } from './renderer';
import { buildWorldTerrainContext } from './procedural';
import { Camera } from './camera';
import { getViewport } from '../shared/telegram';
/**
* Server-authoritative game engine.
*
* This engine does NOT simulate walking, combat, encounters, or any
* game logic. All state comes from the server via WebSocket messages.
*
* Responsibilities:
* - Hold current game state (hero, enemy, phase)
* - Interpolate hero position between server updates (2 Hz -> 60 fps)
* - Run the requestAnimationFrame render loop
* - Camera follow + shake
* - Emit state change and damage callbacks for React UI
* - Thought bubble display (server-driven rest state)
*/
/** Drift threshold in world tiles before snapping to server position. */
const POSITION_DRIFT_SNAP_THRESHOLD = 2.0;
/** Interpolation window matching the server's 2 Hz send rate. */
const MOVE_UPDATE_INTERVAL_S = 0.5;
export class GameEngine {
renderer: GameRenderer;
camera: Camera;
private _running = false;
private _rafId: number | null = null;
private _lastTime = 0;
/** Current game state (exposed to React via onStateChange) */
private _gameState: GameState = {
phase: GamePhase.Walking,
hero: null,
enemy: null,
loot: null,
lastVictoryLoot: null,
tick: 0,
serverTimeMs: 0,
routeWaypoints: null,
enemyOnScreenRight: undefined,
};
// ---- Server-driven position interpolation ----
private _heroDisplayX = 0;
private _heroDisplayY = 0;
private _prevPositionX = 0;
private _prevPositionY = 0;
private _targetPositionX = 0;
private _targetPositionY = 0;
_moveTargetX = 0;
_moveTargetY = 0;
_heroSpeed = 0;
private _lastMoveUpdateTime = 0;
/** Road waypoints from route_assigned, for optional path rendering. */
_routeWaypoints: Array<{ x: number; y: number }> = [];
/** Loot popup auto-clear timer */
private _lootTimerMs = 0;
/** Thought bubble text shown during rest pauses (null = hidden). */
private _thoughtText: string | null = null;
private _thoughtStartMs = 0;
/** True if current bubble text came from client random pool (not applyAdventureLogLine). */
private _thoughtIsRandomPool = false;
/** Returns localized idle thought; set from App using current locale (e.g. randomRoadsideThoughtLine). */
private _restThoughtPicker: (() => string) | null = null;
/** Towns for map rendering */
private _towns: TownData[] = [];
/** All NPCs from towns for rendering */
private _allNPCs: NPCData[] = [];
/** Nearby heroes from the shared world (polled periodically) */
private _nearbyHeroes: NearbyHeroData[] = [];
/** Meet partner world snapshot (active session or linger ghost). */
private _heroMeetOverlay: NearbyHeroData | null = null;
private _heroMeetLingerEndMs = 0;
private _meetBubbleSelf: {
text: string;
startMs: number;
player: boolean;
/** Typed by local player (not auto/scripted); distinct balloon background on canvas */
ownPlayerMessage: boolean;
} | null = null;
private _meetBubbleOther: {
text: string;
startMs: number;
player: boolean;
ownPlayerMessage: boolean;
} | null = null;
/** Debuff full-duration ms from last hero snapshot (`debuffCatalog`); used when WS omits durationMs. */
private _debuffDurationMsByType: Partial<Record<DebuffType, number>> = {};
/** Callbacks */
private _onStateChange: ((state: GameState) => void) | null = null;
private _handleResize: (() => void) | null = null;
constructor() {
this.renderer = new GameRenderer();
this.camera = new Camera();
}
get running(): boolean {
return this._running;
}
get gameState(): GameState {
return this._gameState;
}
/** Current thought text if hero is resting (null otherwise). */
get thoughtText(): string | null {
return this._thoughtText;
}
/** performance.now() when the thought started, for fade animation. */
get thoughtStartMs(): number {
return this._thoughtStartMs;
}
// ---- Callback Registration ----
/** Register a callback for game state changes (for React UI) */
onStateChange(cb: (state: GameState) => void): void {
this._onStateChange = cb;
}
/** Register a callback for damage events (floating numbers) */
// ---- Data Setters (static data from REST init) ----
/** Set the hero display name on the renderer label. */
setHeroName(name: string): void {
this.renderer.setHeroName(name);
}
/** Set towns for map rendering. */
setTowns(towns: TownData[]): void {
this._towns = towns;
this._syncWorldTerrainContext();
}
/** Set NPC data for rendering. */
setNPCs(npcs: NPCData[]): void {
this._allNPCs = npcs;
}
/**
* Picks thought bubble text when resting / in town before server sends adventure_log_line.
* Call with a function that reads current UI locale (e.g. from a ref).
*/
setRestThoughtPicker(picker: (() => string) | null): void {
this._restThoughtPicker = picker;
}
/** After language change: refresh only client-random rest thoughts, not server narration. */
refreshRandomRestThoughtForLocale(): void {
if (!this._thoughtIsRandomPool || !this._thoughtText) return;
const phase = this._gameState.phase;
if (phase !== GamePhase.Resting && phase !== GamePhase.InTown) return;
this._showThought();
this._notifyStateChange();
}
/** Interpolated hero position in world space (for UI proximity checks). */
getHeroDisplayWorldPosition(): { x: number; y: number } {
return { x: this._heroDisplayX, y: this._heroDisplayY };
}
/** Update the list of nearby heroes for shared-world rendering. */
setNearbyHeroes(heroes: NearbyHeroData[]): void {
this._nearbyHeroes = heroes;
}
/** Apply a live movement update for a nearby hero (WS subscription). */
applyNearbyHeroMove(heroId: number, x: number, y: number): void {
const idx = this._nearbyHeroes.findIndex((h) => h.id === heroId);
if (idx < 0) return;
const hero = this._nearbyHeroes[idx]!;
if (hero.positionX === x && hero.positionY === y) return;
this._nearbyHeroes[idx] = { ...hero, positionX: x, positionY: y };
}
/** Start hero meet UI overlay (partner stands near you). */
setHeroMeetOverlay(partner: NearbyHeroData): void {
this._heroMeetOverlay = { ...partner };
this._heroMeetLingerEndMs = 0;
this._meetBubbleSelf = null;
this._meetBubbleOther = null;
}
/** End meet but keep partner visible for lingerMs (perf ms). */
endHeroMeetOverlayLinger(lingerMs: number): void {
const now = performance.now();
this._heroMeetLingerEndMs = now + Math.max(0, lingerMs);
}
clearHeroMeetOverlay(): void {
this._heroMeetOverlay = null;
this._heroMeetLingerEndMs = 0;
this._meetBubbleSelf = null;
this._meetBubbleOther = null;
}
applyHeroMeetChatLine(
fromHeroId: number,
kind: 'player' | 'scripted',
displayText: string,
): void {
const myId = this._gameState.hero?.id;
if (!myId || !displayText.trim()) return;
const entry = {
text: displayText,
startMs: performance.now(),
player: kind === 'player',
ownPlayerMessage: kind === 'player' && fromHeroId === myId,
};
if (fromHeroId === myId) {
this._meetBubbleSelf = entry;
} else {
this._meetBubbleOther = entry;
}
}
private _mergedNearbyHeroes(now: number): NearbyHeroData[] {
const base = [...this._nearbyHeroes];
if (this._heroMeetLingerEndMs > 0 && now > this._heroMeetLingerEndMs) {
this._heroMeetOverlay = null;
this._heroMeetLingerEndMs = 0;
}
const o = this._heroMeetOverlay;
if (!o) return base;
// Meet / linger: partner is drawn as a second full hero sprite (drawMeetPartner), not a nearby diamond.
return [];
}
private _heroMeetBubbleTTLms = 9000;
/** True only during hero_meet dialogue (server phase "meet"), not approach/return walks. */
private _heroMeetDialoguePhase(): boolean {
const h = this._gameState.hero;
return (
h?.excursionKind === 'hero_meet' &&
h.excursionPhase?.toLowerCase() === 'meet'
);
}
private _pruneMeetBubbles(now: number): void {
const ttl = this._heroMeetBubbleTTLms;
if (this._meetBubbleSelf && now - this._meetBubbleSelf.startMs > ttl) {
this._meetBubbleSelf = null;
}
if (this._meetBubbleOther && now - this._meetBubbleOther.startMs > ttl) {
this._meetBubbleOther = null;
}
}
// ---- Server State Application ----
/**
* Initialize the engine with hero data from the REST init endpoint.
* This sets the initial state before WebSocket messages start arriving.
*/
initFromServer(hero: HeroState, serverHeroState?: string): void {
const serverDead = serverHeroState?.toLowerCase() === 'dead';
const serverResting = serverHeroState?.toLowerCase() === 'resting';
const serverInTown = serverHeroState?.toLowerCase() === 'in_town';
let phase = GamePhase.Walking;
if (hero.hp <= 0 || serverDead) phase = GamePhase.Dead;
else if (serverResting) phase = GamePhase.Resting;
else if (serverInTown) phase = GamePhase.InTown;
this._gameState = {
phase,
hero,
enemy: null,
loot: null,
lastVictoryLoot: null,
tick: 0,
serverTimeMs: 0,
routeWaypoints: null,
enemyOnScreenRight: undefined,
};
// Initialize display position
this._heroDisplayX = hero.position.x || 0;
this._heroDisplayY = hero.position.y || 0;
this._prevPositionX = this._heroDisplayX;
this._prevPositionY = this._heroDisplayY;
this._targetPositionX = this._heroDisplayX;
this._targetPositionY = this._heroDisplayY;
this._lastMoveUpdateTime = performance.now();
this._lootTimerMs = 0;
const heroScreen = worldToScreen(this._heroDisplayX, this._heroDisplayY);
this.camera.setTarget(heroScreen.x, heroScreen.y);
this.camera.snapToTarget();
if (phase === GamePhase.Resting || phase === GamePhase.InTown) {
this._showThought();
}
this._notifyStateChange();
}
/**
* Called when server sends hero_move (2 Hz).
* Smoothly interpolates between positions in the render loop.
*/
applyHeroMove(
x: number,
y: number,
targetX: number,
targetY: number,
speed: number,
): void {
if (this._heroMeetDialoguePhase()) {
this._heroDisplayX = x;
this._heroDisplayY = y;
this._prevPositionX = x;
this._prevPositionY = y;
this._targetPositionX = x;
this._targetPositionY = y;
this._moveTargetX = targetX;
this._moveTargetY = targetY;
this._heroSpeed = speed;
this._lastMoveUpdateTime = performance.now();
if (this._gameState.hero) {
this._gameState.hero.position.x = x;
this._gameState.hero.position.y = y;
}
return;
}
this._prevPositionX = this._heroDisplayX;
this._prevPositionY = this._heroDisplayY;
this._targetPositionX = x;
this._targetPositionY = y;
this._moveTargetX = targetX;
this._moveTargetY = targetY;
this._heroSpeed = speed;
this._lastMoveUpdateTime = performance.now();
// Update hero state position to server-known position
if (this._gameState.hero) {
this._gameState.hero.position.x = x;
this._gameState.hero.position.y = y;
}
// Server sends hero_move while resting (road + display offset). Do not treat as "left rest".
const serverResting =
this._gameState.hero?.serverActivityState?.toLowerCase() === 'resting';
if (
!serverResting &&
(this._gameState.phase === GamePhase.Resting ||
this._gameState.phase === GamePhase.InTown)
) {
this._gameState = { ...this._gameState, phase: GamePhase.Walking };
this._thoughtText = null;
this._thoughtIsRandomPool = false;
} else if (this._gameState.phase !== GamePhase.Walking) {
// Don't override fighting/dead phase from move messages
}
}
/**
* Called when server sends position_sync (every 10s).
* Snaps to server position if drift exceeds threshold.
*/
applyPositionSync(x: number, y: number, state: string): void {
const dx = this._heroDisplayX - x;
const dy = this._heroDisplayY - y;
const drift = Math.sqrt(dx * dx + dy * dy);
if (drift > POSITION_DRIFT_SNAP_THRESHOLD) {
this._heroDisplayX = x;
this._heroDisplayY = y;
this._prevPositionX = x;
this._prevPositionY = y;
this._targetPositionX = x;
this._targetPositionY = y;
}
if (this._gameState.hero) {
this._gameState.hero.position.x = x;
this._gameState.hero.position.y = y;
}
// Sync phase from server state string (never clear Death or active combat)
if (
state === 'walking' &&
this._gameState.phase !== GamePhase.Fighting &&
this._gameState.phase !== GamePhase.Dead
) {
this._gameState = { ...this._gameState, phase: GamePhase.Walking };
this._thoughtText = null;
this._thoughtIsRandomPool = false;
}
}
/**
* Called when server sends route_assigned.
* Stores waypoints for optional path rendering.
*/
applyRouteAssigned(
waypoints: Array<{ x: number; y: number }>,
speed: number,
): void {
this._routeWaypoints = waypoints;
this._heroSpeed = speed;
this._syncWorldTerrainContext();
const hp = this._gameState.hero?.hp ?? 1;
const phase = hp <= 0 ? GamePhase.Dead : GamePhase.Walking;
this._gameState = {
...this._gameState,
phase,
routeWaypoints: waypoints.length >= 2 ? waypoints.map((p) => ({ x: p.x, y: p.y })) : null,
};
this._thoughtText = null;
this._thoughtIsRandomPool = false;
this._notifyStateChange();
}
/** Rebuild procedural terrain context from towns + current route (ring + active polyline). */
private _syncWorldTerrainContext(): void {
if (this._towns.length === 0) {
this.renderer.setWorldTerrainContext(null);
return;
}
const influences = this._towns.map((t) => ({
id: t.id,
cx: t.centerX,
cy: t.centerY,
radius: t.radius,
biome: t.biome,
levelMin: t.levelMin,
}));
const route =
this._routeWaypoints.length >= 2 ? this._routeWaypoints : null;
this.renderer.setWorldTerrainContext(
buildWorldTerrainContext(influences, route),
);
}
/**
* Apply a full hero state snapshot from the server.
* Sent on WS connect, after level-up, revive, equipment change.
*/
/** Fallback duration for debuff UI when `debuff_applied` lacks numeric durationMs. */
getDebuffDurationMs(type: DebuffType): number | undefined {
return this._debuffDurationMsByType[type];
}
applyHeroState(hero: HeroState): void {
if (hero.debuffCatalogDurations) {
this._debuffDurationMsByType = {
...this._debuffDurationMsByType,
...hero.debuffCatalogDurations,
};
}
const prevPos = this._gameState.hero?.position;
// Preserve display position if hero hasn't moved significantly
if (prevPos) {
const dx = (hero.position.x || 0) - prevPos.x;
const dy = (hero.position.y || 0) - prevPos.y;
if (dx * dx + dy * dy < 0.01) {
hero.position = prevPos;
}
}
const activity = hero.serverActivityState?.toLowerCase();
const isDead = hero.hp <= 0 || activity === 'dead';
this._gameState = {
...this._gameState,
hero,
};
if (isDead) {
this._gameState = {
...this._gameState,
phase: GamePhase.Dead,
enemy: null,
enemyOnScreenRight: undefined,
};
this._thoughtText = null;
this._thoughtIsRandomPool = false;
} else if (this._gameState.phase !== GamePhase.Fighting) {
if (activity === 'resting') {
this._gameState = { ...this._gameState, phase: GamePhase.Resting };
if (!this._thoughtText) this._showThought();
} else if (activity === 'in_town') {
this._gameState = { ...this._gameState, phase: GamePhase.InTown };
if (!this._thoughtText) this._showThought();
} else if (activity === 'walking') {
this._gameState = { ...this._gameState, phase: GamePhase.Walking };
this._thoughtText = null;
this._thoughtIsRandomPool = false;
}
}
// Display position: follow hero_move interpolation and applyPositionSync only.
// Do not snap here on hero_state vs last move — server position includes
// excursion/rest offsets and ordering with hero_move caused visible teleports.
// Exception: active hero_meet stand is fixed; snap so we don't rely on hero_move ticks.
if (
hero.excursionKind === 'hero_meet' &&
hero.excursionPhase?.toLowerCase() === 'meet' &&
hero.position
) {
const x = hero.position.x;
const y = hero.position.y;
this._heroDisplayX = x;
this._heroDisplayY = y;
this._prevPositionX = x;
this._prevPositionY = y;
this._targetPositionX = x;
this._targetPositionY = y;
this._lastMoveUpdateTime = performance.now();
}
this._notifyStateChange();
}
/**
* Called when server sends combat_start.
*/
applyCombatStart(enemy: EnemyState): void {
// Position enemy near hero
enemy.position = {
x: this._heroDisplayX + 1.5,
y: this._heroDisplayY - 0.5,
};
const enemyOnScreenRight = enemy.position.x >= this._heroDisplayX;
this._gameState = {
...this._gameState,
phase: GamePhase.Fighting,
enemy,
loot: null,
enemyOnScreenRight,
};
this._lootTimerMs = 0;
this._thoughtText = null;
this._thoughtIsRandomPool = false;
this._notifyStateChange();
}
/**
* Called when server sends attack.
* Updates HP values.
*/
applyAttack(
source: 'hero' | 'enemy' | 'potion' | 'dot' | 'summon',
damage: number,
isCrit: boolean,
heroHp: number,
enemyHp: number,
outcome?: 'hit' | 'dodge' | 'block' | 'stun',
): void {
void source;
void damage;
void isCrit;
void outcome;
if (this._gameState.hero) {
this._gameState.hero.hp = heroHp;
}
if (this._gameState.enemy) {
this._gameState.enemy.hp = enemyHp;
}
this._notifyStateChange();
}
/**
* Called when server sends enemy_regen.
* Updates enemy HP.
*/
applyEnemyRegen(amount: number, enemyHp: number): void {
void amount;
if (!this._gameState.enemy) return;
this._gameState.enemy.hp = enemyHp;
this._notifyStateChange();
}
/**
* Called when server sends combat_end.
* Transitions back to walking phase.
*/
applyCombatEnd(): void {
const hp = this._gameState.hero?.hp ?? 0;
const phase = hp <= 0 ? GamePhase.Dead : GamePhase.Walking;
this._gameState = {
...this._gameState,
phase,
enemy: null,
enemyOnScreenRight: undefined,
};
this._notifyStateChange();
}
/**
* Apply loot from combat_end for the popup UI.
*/
applyLoot(loot: LootDrop | null): void {
this._lootTimerMs = 0;
this._gameState = {
...this._gameState,
loot,
lastVictoryLoot: loot != null ? loot : this._gameState.lastVictoryLoot,
};
this._notifyStateChange();
}
/**
* Called when server sends hero_died.
*/
applyHeroDied(): void {
this._gameState = {
...this._gameState,
phase: GamePhase.Dead,
};
this._notifyStateChange();
}
/**
* Called when server sends hero_revived.
*/
applyHeroRevived(hp: number): void {
if (this._gameState.hero) {
this._gameState.hero.hp = hp;
}
this._gameState = {
...this._gameState,
phase: GamePhase.Walking,
enemy: null,
enemyOnScreenRight: undefined,
};
this._notifyStateChange();
}
/**
* Called when server sends town_enter.
* If buildings are provided, merge them into the matching town for rendering.
*/
applyTownEnter(townId?: number, buildings?: BuildingData[]): void {
if (townId && buildings && buildings.length > 0) {
const idx = this._towns.findIndex((t) => t.id === townId);
if (idx >= 0) {
this._towns[idx] = { ...this._towns[idx]!, buildings };
}
}
this._gameState = {
...this._gameState,
phase: GamePhase.InTown,
};
this._showThought();
this._notifyStateChange();
}
/** Server simulated approach to a town NPC (quest / shop / healer). Narration uses adventure_log_line. */
applyTownNPCVisit(_npcName: string, _npcType: string): void {
void _npcName;
void _npcType;
}
/** Same text as adventure log line — shown in the thought bubble above the hero (town NPC visits). */
applyAdventureLogLine(message: string): void {
if (!message) return;
this._thoughtText = message;
this._thoughtIsRandomPool = false;
this._thoughtStartMs = performance.now();
this._notifyStateChange();
}
/**
* Called when server sends town_exit.
*/
applyTownExit(): void {
this._gameState = {
...this._gameState,
phase: GamePhase.Walking,
};
this._thoughtText = null;
this._thoughtIsRandomPool = false;
this._notifyStateChange();
}
// ---- Patch helpers for UI-driven optimistic updates ----
/** Merge server-side active buffs into the current hero. */
patchHeroBuffs(activeBuffs: HeroState['activeBuffs']): void {
const hero = this._gameState.hero;
if (!hero) return;
this._gameState = {
...this._gameState,
hero: { ...hero, activeBuffs },
};
this._notifyStateChange();
}
/** Merge buff quota fields. */
patchHeroBuffQuota(
fields: Pick<
HeroState,
'buffFreeChargesRemaining' | 'buffQuotaPeriodEnd' | 'buffCharges'
>,
): void {
const hero = this._gameState.hero;
if (!hero) return;
this._gameState = {
...this._gameState,
hero: { ...hero, ...fields },
};
this._notifyStateChange();
}
/** Patch server-derived combat stats after buff application. */
patchHeroCombat(
combat: Pick<
HeroState,
'damage' | 'defense' | 'attackSpeed' | 'activeBuffs'
>,
): void {
const hero = this._gameState.hero;
if (!hero) return;
this._gameState = {
...this._gameState,
hero: {
...hero,
damage: combat.damage,
defense: combat.defense,
attackSpeed: combat.attackSpeed,
activeBuffs: combat.activeBuffs,
},
};
this._notifyStateChange();
}
/** Patch hero HP (e.g. after potion use). */
patchHeroHp(hp: number): void {
const hero = this._gameState.hero;
if (!hero) return;
this._gameState = {
...this._gameState,
hero: { ...hero, hp: Math.min(hp, hero.maxHp) },
};
this._notifyStateChange();
}
/** Replace debuffs from server snapshot. */
patchHeroDebuffs(debuffs: HeroState['debuffs']): void {
const hero = this._gameState.hero;
if (!hero) return;
this._gameState = {
...this._gameState,
hero: { ...hero, debuffs },
};
this._notifyStateChange();
}
/** Apply or refresh a single debuff from WS. */
applyDebuffApplied(type: DebuffType, durationMs: number, expiresAtMs?: number): void {
const hero = this._gameState.hero;
if (!hero) return;
const nowMs = Date.now();
const expMs = Number.isFinite(expiresAtMs)
? (expiresAtMs as number)
: nowMs + Math.max(0, durationMs);
const remainingMs = Math.max(0, expMs - nowMs);
const next: ActiveDebuff = {
type,
remainingMs,
durationMs: Math.max(0, durationMs),
expiresAtMs: expMs,
};
const debuffs = hero.debuffs
.filter((d) => d.type !== type)
.filter((d) => {
const exp = d.expiresAtMs ?? nowMs + d.remainingMs;
return exp > nowMs && d.remainingMs > 0;
});
debuffs.push(next);
this._gameState = {
...this._gameState,
hero: { ...hero, debuffs },
};
this._notifyStateChange();
}
/** Apply a full server state override (used for backward compat). */
applyServerState(state: GameState): void {
this._gameState = {
...state,
lastVictoryLoot:
state.lastVictoryLoot ?? this._gameState.lastVictoryLoot,
};
this._notifyStateChange();
}
// ---- Lifecycle ----
/** Initialize the engine and attach to the DOM */
async init(canvasContainer: HTMLElement): Promise<void> {
await this.renderer.init(canvasContainer);
this._handleResize = (): void => {
this.renderer.resize();
};
window.addEventListener('resize', this._handleResize);
window.addEventListener('orientationchange', this._handleResize);
}
/** Start the game loop */
start(): void {
if (this._running) return;
this._running = true;
this._lastTime = performance.now();
this._tick(performance.now());
}
/** Stop the game loop */
stop(): void {
this._running = false;
if (this._rafId !== null) {
cancelAnimationFrame(this._rafId);
this._rafId = null;
}
}
/** Clean up everything */
destroy(): void {
this.stop();
if (this._handleResize) {
window.removeEventListener('resize', this._handleResize);
window.removeEventListener('orientationchange', this._handleResize);
this._handleResize = null;
}
this.renderer.destroy();
}
// ---- Private: Loop ----
/** Main loop tick - called by requestAnimationFrame */
private _tick = (now: number): void => {
if (!this._running) return;
const frameTime = Math.min(now - this._lastTime, MAX_ACCUMULATED_MS);
this._lastTime = now;
// Interpolation + camera must run every frame. A 100ms fixed step (server tick rate)
// only updated ~10×/s and made the view stutter on 60 Hz displays.
if (frameTime > 0) {
this._update(frameTime);
}
this._render();
this._rafId = requestAnimationFrame(this._tick);
};
/** Per-frame update -- hero interpolation, camera follow, loot timer */
private _update(dtMs: number): void {
// Interpolate hero display position toward target
this._interpolatePosition();
// Camera follows interpolated hero position
if (this._gameState.hero) {
const heroScreen = worldToScreen(
this._heroDisplayX,
this._heroDisplayY,
);
this.camera.setTarget(heroScreen.x, heroScreen.y);
}
this.camera.update(dtMs);
// Auto-clear loot popup
if (this._gameState.loot) {
this._lootTimerMs += dtMs;
if (this._lootTimerMs >= 2000) {
this._gameState = { ...this._gameState, loot: null };
this._lootTimerMs = 0;
}
}
}
/**
* Interpolate hero display position between server updates.
*
* Server sends hero_move at 2 Hz (every 500ms). We linearly
* interpolate between the previous known position and the target
* position over that interval.
*/
private _interpolatePosition(): void {
if (this._heroMeetDialoguePhase()) {
this._heroDisplayX = this._targetPositionX;
this._heroDisplayY = this._targetPositionY;
this._prevPositionX = this._targetPositionX;
this._prevPositionY = this._targetPositionY;
return;
}
const elapsed =
(performance.now() - this._lastMoveUpdateTime) / 1000;
const t = Math.min(elapsed / MOVE_UPDATE_INTERVAL_S, 1.0);
this._heroDisplayX =
this._prevPositionX +
(this._targetPositionX - this._prevPositionX) * t;
this._heroDisplayY =
this._prevPositionY +
(this._targetPositionY - this._prevPositionY) * t;
}
/** Render frame */
private _render(): void {
if (!this.renderer.initialized) return;
const viewport = getViewport();
// Apply camera to world container
this.camera.applyTo(
this.renderer.worldContainer,
viewport.width,
viewport.height,
);
// Draw the ground tiles
this.renderer.drawGround(this.camera, viewport.width, viewport.height);
// Draw towns on the map
if (this._towns.length > 0) {
this.renderer.drawTowns(
this._towns,
this.camera,
viewport.width,
viewport.height,
);
}
// Draw entities
const state = this._gameState;
const now = performance.now();
if (state.hero) {
const isWalking = state.phase === GamePhase.Walking;
const isFighting = state.phase === GamePhase.Fighting;
const inHeroMeetDialogue =
state.hero.excursionKind === 'hero_meet' &&
state.hero.excursionPhase?.toLowerCase() === 'meet';
const animPhase =
inHeroMeetDialogue && isWalking
? 'idle'
: isWalking
? 'walk'
: isFighting
? 'fight'
: 'idle';
const rk = state.hero.restKind?.toLowerCase() ?? '';
const excPhase = state.hero.excursionPhase?.toLowerCase() ?? '';
// Camp only during the stationary wild phase; hide as soon as rest ends and return leg starts.
const showRestCamp =
state.phase === GamePhase.Resting &&
excPhase === 'wild' &&
(rk === 'roadside' || rk === 'adventure_inline');
if (showRestCamp) {
this.renderer.drawRestCamp(this._heroDisplayX, this._heroDisplayY, now);
} else {
this.renderer.clearRestCamp();
}
this.renderer.drawHero(
this._heroDisplayX,
this._heroDisplayY,
animPhase,
now,
state.hero.modelVariant ?? 0,
);
// Thought bubble during rest/town pauses
if (this._thoughtText) {
this.renderer.drawThoughtBubble(
this._heroDisplayX,
this._heroDisplayY,
this._thoughtText,
now,
this._thoughtStartMs,
);
} else {
this.renderer.clearThoughtBubble();
}
} else {
this.renderer.clearRestCamp();
}
// Draw NPCs from towns
if (this._allNPCs.length > 0) {
this.renderer.drawNPCs(
this._allNPCs,
this.camera,
viewport.width,
viewport.height,
now,
);
} else {
this.renderer.clearNPCs();
}
// Draw nearby heroes from the shared world (meet partner uses full sprite below, not diamonds)
const nearbyMerged = this._mergedNearbyHeroes(now);
if (nearbyMerged.length > 0) {
this.renderer.drawNearbyHeroes(nearbyMerged);
} else {
this.renderer.clearNearbyHeroes();
}
const meetOv = this._heroMeetOverlay;
if (meetOv) {
this.renderer.drawMeetPartner(
meetOv.positionX,
meetOv.positionY,
meetOv.name,
meetOv.level,
now,
meetOv.modelVariant ?? 0,
);
} else {
this.renderer.clearMeetPartner();
}
this._pruneMeetBubbles(now);
const bubbleItems: Array<{
wx: number;
wy: number;
text: string;
startMs: number;
ownPlayerMessage: boolean;
}> = [];
if (this._meetBubbleSelf) {
bubbleItems.push({
wx: this._heroDisplayX,
wy: this._heroDisplayY,
text: this._meetBubbleSelf.text,
startMs: this._meetBubbleSelf.startMs,
ownPlayerMessage: this._meetBubbleSelf.ownPlayerMessage,
});
}
if (this._meetBubbleOther && meetOv) {
bubbleItems.push({
wx: meetOv.positionX,
wy: meetOv.positionY,
text: this._meetBubbleOther.text,
startMs: this._meetBubbleOther.startMs,
ownPlayerMessage: this._meetBubbleOther.ownPlayerMessage,
});
}
if (bubbleItems.length > 0) {
this.renderer.drawHeroMeetBubbles(bubbleItems, now);
} else {
this.renderer.clearHeroMeetBubbles();
}
// Draw enemy during combat or death
const showEnemy =
state.enemy &&
(state.phase === GamePhase.Fighting ||
(state.phase === GamePhase.Dead && state.enemy));
if (showEnemy && state.enemy) {
this.renderer.drawEnemy(
state.enemy.position.x,
state.enemy.position.y,
state.enemy.hp,
state.enemy.maxHp,
state.enemy.enemySlug,
state.enemy.enemyArchetype,
now,
);
} else {
this.renderer.clearEnemyCombat();
}
// Sort entities for isometric depth
this.renderer.sortEntities();
}
// ---- Private: Helpers ----
private _notifyStateChange(): void {
this._onStateChange?.(this._gameState);
}
private _showThought(): void {
const line = this._restThoughtPicker?.().trim() ?? '';
this._thoughtText = line || null;
this._thoughtIsRandomPool = Boolean(line);
this._thoughtStartMs = performance.now();
}
}