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> = {}; /** 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; } /** 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 { 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, ); // 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, now); } else { this.renderer.clearNearbyHeroes(); } const meetOv = this._heroMeetOverlay; if (meetOv) { this.renderer.drawMeetPartner(meetOv.positionX, meetOv.positionY, meetOv.name, meetOv.level, now); } 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(); } }