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.

828 lines
21 KiB
TypeScript

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<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._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();
}
}