import { FIXED_DT_MS, MAX_ACCUMULATED_MS, } from '../shared/constants'; import type { GameState, EnemyState, HeroState, FloatingDamageData, LootDrop, TownData, NearbyHeroData, NPCData, } 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) */ // ---- Thought Bubble Pool ---- const HERO_THOUGHTS = [ "I wonder what's ahead...", 'Need better gear...', 'Getting stronger!', 'This forest is huge', 'I smell adventure', 'Gold... need more gold', 'What was that sound?', 'Time to rest a bit', 'My sword needs sharpening', 'Almost leveled up!', ]; // ---- Callbacks ---- export type DamageCallback = (damage: FloatingDamageData) => void; /** 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; private _accumulator = 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, }; // ---- 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; /** 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[] = []; /** Callbacks */ private _onStateChange: ((state: GameState) => void) | null = null; private _onDamage: DamageCallback | 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) */ onDamage(cb: DamageCallback): void { this._onDamage = cb; } // ---- 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; } /** Update the list of nearby heroes for shared-world rendering. */ setNearbyHeroes(heroes: NearbyHeroData[]): void { this._nearbyHeroes = heroes; } // ---- 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, }; // 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 { 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; } // Clear rest/thought when moving if ( this._gameState.phase === GamePhase.Resting || this._gameState.phase === GamePhase.InTown ) { this._gameState = { ...this._gameState, phase: GamePhase.Walking }; this._thoughtText = null; } 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 if (state === 'walking' && this._gameState.phase !== GamePhase.Fighting) { this._gameState = { ...this._gameState, phase: GamePhase.Walking }; this._thoughtText = null; } } /** * 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(); this._gameState = { ...this._gameState, phase: GamePhase.Walking, routeWaypoints: waypoints.length >= 2 ? waypoints.map((p) => ({ x: p.x, y: p.y })) : null, }; this._thoughtText = null; 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. */ applyHeroState(hero: HeroState): void { 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; } } this._gameState = { ...this._gameState, hero, }; 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, }; this._gameState = { ...this._gameState, phase: GamePhase.Fighting, enemy, loot: null, }; this._lootTimerMs = 0; this._thoughtText = null; this._notifyStateChange(); } /** * Called when server sends attack. * Updates HP values and emits floating damage numbers. */ applyAttack( source: 'hero' | 'enemy' | 'potion', damage: number, isCrit: boolean, heroHp: number, enemyHp: number, ): void { if (this._gameState.hero) { this._gameState.hero.hp = heroHp; } if (this._gameState.enemy) { this._gameState.enemy.hp = enemyHp; } // Emit floating damage at appropriate screen position const viewport = getViewport(); if (source === 'hero') { // Damage on enemy (right side of screen) this._emitDamage( damage, viewport.width / 2 + 60, viewport.height / 2 - 30, isCrit, ); } else if (source === 'enemy') { // Damage on hero (left side of screen) this._emitDamage( damage, viewport.width / 2 - 60, viewport.height / 2 - 30, false, ); } // potion source: no floating damage this._notifyStateChange(); } /** * Called when server sends combat_end. * Transitions back to walking phase. */ applyCombatEnd(): void { this._gameState = { ...this._gameState, phase: GamePhase.Walking, enemy: null, }; 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, }; this._notifyStateChange(); } /** * Called when server sends town_enter. */ applyTownEnter(): void { this._gameState = { ...this._gameState, phase: GamePhase.InTown, }; this._showThought(); this._notifyStateChange(); } /** Server simulated approach to a town NPC (quest / shop / healer). */ applyTownNPCVisit(npcName: string, npcType: string): void { const label = npcType === 'merchant' ? 'Shop' : npcType === 'healer' ? 'Healer' : 'Quest'; this._thoughtText = `${label}: ${npcName}`; 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._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 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._accumulator = 0; 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; this._accumulator += frameTime; // Fixed timestep updates (camera, loot timer only -- no game logic) while (this._accumulator >= FIXED_DT_MS) { this._update(FIXED_DT_MS); this._accumulator -= FIXED_DT_MS; } // Render (alpha available for future interpolation use) void this._accumulator; this._render(); this._rafId = requestAnimationFrame(this._tick); }; /** Fixed-step update -- camera follow and loot timer only */ 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 { 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 animPhase = isWalking ? 'walk' : isFighting ? 'fight' : 'idle'; 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(); } } // 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 if (this._nearbyHeroes.length > 0) { this.renderer.drawNearbyHeroes(this._nearbyHeroes, now); } else { this.renderer.clearNearbyHeroes(); } // 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.enemyType, now, ); } // Sort entities for isometric depth this.renderer.sortEntities(); } // ---- Private: Helpers ---- private _notifyStateChange(): void { this._onStateChange?.(this._gameState); } private _emitDamage( value: number, x: number, y: number, isCrit: boolean, ): void { if (!this._onDamage) return; this._onDamage({ id: Date.now() + Math.random(), value, x, y, isCrit, createdAt: performance.now(), }); } private _showThought(): void { this._thoughtText = HERO_THOUGHTS[Math.floor(Math.random() * HERO_THOUGHTS.length)] ?? null; this._thoughtStartMs = performance.now(); } }