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