import { tickCombat, createWorld, spawnMonsters, spawnMonsterPacks, damageMonster, rebindPlayer } from './combat.ts' import type { CombatWorld, CombatOptions, MonsterPack, MonsterStats, SafeZone } from './combat.ts' import { Inventory, rollDrop, goldItem, totalStat } from './items.ts' import type { Item, ItemBase, Affix } from './items.ts' import { castSkill, tickProjectiles } from './skills.ts' import type { SkillDef, Projectile } from './skills.ts' import { QuestLog, npcDialog } from './quests.ts' import type { NpcDef, QuestDef } from './quests.ts' import { Rng } from './rng.ts' import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from './save.ts' import { MonsterStreamingManager } from './monster-streaming.ts' import type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts' export interface EngineInput { readonly movement: { readonly x: number; readonly y: number } readonly attacking: boolean readonly pickingUp: boolean readonly talking: boolean readonly digits: readonly number[] readonly saving: boolean readonly loading: boolean } export interface WorldMapProvider { readonly widthPx: number readonly heightPx: number overlap(x: number, y: number): number } /** * Where snapshots are kept. * * The engine owns *when* a save happens — on the rising edge of * {@link EngineInput.saving}, at a tick boundary, after the simulation has * settled — but not *where* it goes. Browsers have `localStorage`, tests have a * `Map`, and a future server would have a file; none of that may leak into the * simulation, which has to stay reproducible from a seed and an input log. */ export interface SnapshotStore { /** * Read the stored snapshot. * * @returns the serialized snapshot, or null when nothing has been saved. */ read(): string | null /** * Overwrite the stored snapshot. * * @param data - a serialized snapshot. */ write(data: string): void } export interface GameEngineOptions { spawn: { x: number; y: number } stats: readonly MonsterStats[] xpTable: readonly number[] itemBases: readonly ItemBase[] prefixAffixes: readonly Affix[] suffixAffixes: readonly Affix[] skills: readonly SkillDef[] npcDefs: readonly NpcDef[] questDefs: readonly QuestDef[] combatOptions: CombatOptions lootSeed?: number talkRadius: number pickupRadius: number inventoryCols: number inventoryRows: number /** * How many monsters to spawn at construction. * * Defaults to 8, which is the sandbox number this engine grew up with. Real * levels derive it from `Levels.txt` (`NumMon` for how many *kinds*, `MonDen` * for density), so callers that have a level row pass their own. */ monsterCount?: number /** * Spawn spread around {@link GameEngineOptions.spawn}, in scene pixels. * * Defaults to 260. Real levels scatter across the whole map rather than a * disc around the entrance, so this is a sandbox affordance, not a rule. */ monsterSpread?: number /** * Pre-planned packs, which take precedence over * {@link GameEngineOptions.stats} and {@link GameEngineOptions.monsterCount}. * * A level read from `Levels.txt` does not produce a count and a type list; it * produces groups — two to three Fallen here, a Quill Rat pair there — and * flattening that back into "spawn 20 monsters from these 3 types" loses the * grouping, which is most of what makes a level feel populated. Scenes that * have planned their level pass packs; fixtures that just want some monsters * to hit keep passing a count. */ monsterPacks?: readonly MonsterPack[] /** * Optional safe zones (e.g. around spawn point, town bridges, and waypoints) * where ordinary monster packs must not spawn. */ safeZones?: readonly SafeZone[] /** * Whether to place NPCs on a synthetic ring around the spawn. * * Defaults to true, for fixtures that have NPC definitions but no positions. * Scenes that know where their NPCs actually stand — a DS1 carries their * coordinates — pass false and fill {@link GameEngine.npcEntities} themselves, * so the ring is never built and then thrown away. */ npcRingFallback?: boolean /** * Backing store for {@link EngineInput.saving} and * {@link EngineInput.loading}. * * Omit it and both inputs are inert — which is what every fixture wants, and * what {@link GameEngine.saveSnapshot} is for when a caller drives saving * itself. */ snapshotStore?: SnapshotStore /** * Optional pre-constructed MonsterStreamingManager for lazy room population * and active/sleeping monster streaming. */ streamingManager?: MonsterStreamingManager | undefined /** * Pre-planned rooms for monster streaming. When configured without an explicit * streamingManager, a MonsterStreamingManager is initialized automatically. */ roomPacks?: readonly StreamingRoomDef[] | undefined /** * Alias for roomPacks. */ streamingRooms?: readonly StreamingRoomDef[] | undefined /** * Tuning options for MonsterStreamingManager when created from roomPacks. */ streamingOptions?: MonsterStreamingOptions | undefined } export interface GameEngineMetrics { casts: number castRefusals: number castHits: number dropsRolled: number pickups: number inventoryRefusals: number lastDrops: string[] playerHits: number questXp: number questGold: number saves: number loads: number saveError: string | null } export class GameEngine { world: CombatWorld loot: Rng bag: Inventory ground: { x: number; y: number; item: Item }[] = [] questLog: QuestLog projectiles: Projectile[] = [] castRng: Rng skillCooldown = 0 selectedSkill = 0 npcEntities: NpcEntity[] = [] dialog: string[] = [] streamingManager?: MonsterStreamingManager /** * Whether the save input was held on the previous tick. * * Save and load fire on a rising edge. The input is a key state sampled every * tick, so without this a key held for half a second would save twelve times * — and a held load key would re-apply the snapshot every tick, pinning the * player in place. */ private savingHeld = false /** Whether the load input was held on the previous tick. */ private loadingHeld = false metrics: GameEngineMetrics = { casts: 0, castRefusals: 0, castHits: 0, dropsRolled: 0, pickups: 0, inventoryRefusals: 0, lastDrops: [], playerHits: 0, questXp: 0, questGold: 0, saves: 0, loads: 0, saveError: null, } constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) { const rooms = opts.roomPacks ?? opts.streamingRooms if (opts.streamingManager !== undefined) { this.streamingManager = opts.streamingManager this.world = opts.streamingManager.world } else { this.world = createWorld(opts.spawn.x, opts.spawn.y) if (rooms !== undefined && rooms.length > 0) { this.streamingManager = new MonsterStreamingManager( this.world, this.terrain, rooms, opts.streamingOptions, ) } } const spread = opts.monsterSpread ?? 260 const count = opts.monsterCount ?? 8 if (this.streamingManager !== undefined) { // Monsters are lazily populated by streamingManager; avoid frame-0 eager population. } else if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) { spawnMonsterPacks(this.world, opts.monsterPacks, opts.spawn, spread, this.terrain, opts.safeZones) } else if (count > 0) { spawnMonsters(this.world, opts.stats, count, opts.spawn, spread, this.terrain) } this.loot = new Rng(opts.lootSeed ?? 0x5eed) this.bag = new Inventory(opts.inventoryCols, opts.inventoryRows) this.questLog = new QuestLog(opts.questDefs) this.castRng = new Rng((opts.lootSeed ?? 0x5eed) ^ 0x51c1) if (opts.npcRingFallback ?? true) { for (let attempt = 0; attempt < 40 && this.npcEntities.length < this.opts.npcDefs.length; attempt += 1) { const angle = attempt * 1.7 + 0.6 const x = opts.spawn.x + Math.cos(angle) * 56 const y = opts.spawn.y + Math.sin(angle) * 56 if (this.terrain.overlap(x, y) > 0) continue const def = this.opts.npcDefs[this.npcEntities.length] if (def === undefined) break // These are the synthetic ring placements used when a scene carries no // prebaked NPC art, so they never have a sprite of their own to draw. this.npcEntities.push({ def, x, y, hasSprite: false }) } } } tick(input: EngineInput): void { const movement = input.movement const player = this.world.player const beforeX = player.x const beforeY = player.y this.streamingManager?.update(player.x, player.y) if (this.world.tick % 25 === 0) player.mana = Math.min(player.maxMana, player.mana + 1) const wanted = this.opts.skills[this.selectedSkill] let castHappened = false const casting = input.attacking && this.selectedSkill > 0 && wanted !== undefined if (casting && wanted !== undefined) { const result = castSkill( wanted, { x: player.x, y: player.y, facing: player.facing }, 1, this.castRng, this.skillCooldown, player.mana, ) if (result.kind === 'projectile') { this.projectiles.push(result.projectile) player.mana = Math.max(0, player.mana - wanted.manaCost) this.skillCooldown = wanted.cooldownTicks this.metrics.casts += 1 castHappened = true } else if (result.kind === 'instant') { player.mana = Math.max(0, player.mana - wanted.manaCost) this.skillCooldown = wanted.cooldownTicks this.metrics.casts += 1 castHappened = true this.world.monsters.forEach((monster, index) => { if (monster.state === 'dead') return if (Math.hypot(monster.x - player.x, monster.y - player.y) > wanted.radius + 20) return damageMonster(this.world, index, result.damage) }) } else if (result.kind === 'mana') { this.metrics.castRefusals += 1 } } tickCombat(this.world, { movement, attack: castHappened ? false : input.attacking }, this.opts.combatOptions, this.terrain, this.opts.xpTable) if (this.world.events.some(event => event.kind === 'monsterHit')) this.metrics.playerHits += 1 const digits = input.digits if (digits.length > 0) this.selectedSkill = Math.min(Math.max(digits[0]! - 1, 0), this.opts.skills.length - 1) if (this.skillCooldown > 0) this.skillCooldown -= 1 if (this.projectiles.length > 0) { const targets = this.world.monsters.map((monster, index) => ({ index, x: monster.x, y: monster.y, radius: 20, alive: monster.state !== 'dead', })) const outcome = tickProjectiles(this.projectiles, targets, this.terrain) this.projectiles.length = 0 this.projectiles.push(...outcome.alive) for (const hit of outcome.hits) { damageMonster(this.world, hit.targetIndex, hit.damage) this.metrics.castHits += 1 } } if (input.talking) { let nearest: { def: NpcDef; x: number; y: number } | null = null let best = this.opts.talkRadius for (const npc of this.npcEntities) { const distance = Math.hypot(npc.x - player.x, npc.y - player.y) if (distance <= best) { best = distance; nearest = npc } } if (nearest === null) { this.dialog = [] } else { if (nearest.def.questId !== null) this.questLog.accept(nearest.def.questId) this.dialog = npcDialog(nearest.def, this.questLog) } } for (const event of this.world.events) { if (event.kind !== 'kill') continue for (const reward of this.questLog.recordKill(event.subjectId ?? '')) { player.xp += reward.xp this.bag.add(goldItem(reward.gold)) this.metrics.questXp += reward.xp this.metrics.questGold += reward.gold } const drop = rollDrop(this.opts.itemBases, this.opts.prefixAffixes, this.opts.suffixAffixes, this.loot, { level: player.level + 2, dropChance: 0.8, goldChance: 0.3, goldRange: [5, 40], }) this.metrics.dropsRolled += 1 if (drop.kind === 'gold') { this.bag.add(goldItem(drop.amount)) } else if (drop.kind === 'item') { this.ground.push({ x: event.x, y: event.y, item: drop.item }) this.metrics.lastDrops.push(drop.item.name) if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift() } } if (input.pickingUp && this.ground.length > 0) { let bestIndex = -1 let bestDistance = this.opts.pickupRadius this.ground.forEach((entry, index) => { const distance = Math.hypot(entry.x - player.x, entry.y - player.y) if (distance <= bestDistance) { bestDistance = distance; bestIndex = index } }) const entry = bestIndex === -1 ? undefined : this.ground[bestIndex] if (entry !== undefined) { if (this.bag.add(entry.item) === null) { this.metrics.inventoryRefusals += 1 } else { this.ground.splice(bestIndex, 1) this.metrics.pickups += 1 } } } this.handleSaveLoad(input) } /** * Apply the save and load inputs, once per press. * * Runs last so a snapshot always describes a settled tick: movement, combat, * drops and pickups have all been applied, which is what a player who pressed * save this frame expects to get back. Loading last likewise means the * restored state is what the *next* tick starts from, rather than being * partially overwritten by the rest of this one. * * @param input - this tick's input. */ private handleSaveLoad(input: EngineInput): void { const store = this.opts.snapshotStore if (store !== undefined) { if (input.saving && !this.savingHeld) { try { store.write(this.saveSnapshot()) this.metrics.saves += 1 this.metrics.saveError = null } catch (error) { // A full quota or a private-browsing window must not kill the loop. this.metrics.saveError = error instanceof Error ? error.message : String(error) } } if (input.loading && !this.loadingHeld) { try { const data = store.read() if (data !== null) { this.loadSnapshot(data) this.metrics.loads += 1 this.metrics.saveError = null } } catch (error) { // A snapshot from an older schema is a refusal, not a crash. this.metrics.saveError = error instanceof Error ? error.message : String(error) } } } this.savingHeld = input.saving this.loadingHeld = input.loading } saveSnapshot(): string { return serializeSnapshot(captureSnapshot({ world: this.world, rng: this.loot, inventory: this.bag, quests: this.questLog, ground: this.ground, })) } loadSnapshot(data: string): void { const loaded = parseSnapshot(data) const pieces = restoreSnapshot(loaded, { inventory: (width, height, placed) => Inventory.restore(width, height, placed), quests: states => QuestLog.restore(this.opts.questDefs, states), }) this.world = rebindPlayer({ ...this.world, tick: pieces.world.tick, kills: pieces.world.kills, player: { ...pieces.world.player }, monsters: pieces.world.monsters.map(monster => ({ ...monster })), players: [], events: [], }) this.loot = new Rng(pieces.rngState) this.bag = pieces.inventory this.questLog = pieces.quests this.ground = pieces.ground this.projectiles = [] if (this.streamingManager !== undefined) { this.streamingManager.world = this.world } } } export interface NpcEntity { def: NpcDef x: number y: number /** * Whether this NPC's own art is already drawn by the scene's sprite layer. * * The renderer falls back to a coloured placeholder box for NPCs without art. * Once real art exists the box would sit on top of the sprite, so the two * paths are mutually exclusive and the renderer needs to know which applies. */ hasSprite: boolean } export interface EngineViewState { monstersSpawned?: number monstersAlive?: number kills?: number playerHp?: number playerMaxHp?: number playerMana?: number playerMaxMana?: number playerLevel?: number playerXp?: number playerHits?: number groundItems?: number inventoryItems?: number cellsUsed?: number cellsTotal?: number gold?: number defense?: number dropsRolled?: number pickups?: number inventoryRefusals?: number lastDrops?: string[] skills?: string[] selectedSkill?: number casts?: number castRefusals?: number castHits?: number projectilesAlive?: number npcs?: number npcsNear?: NpcEntity[] monstersNear?: unknown[] quests?: unknown[] dialog?: string[] questXp?: number questGold?: number worldTick?: number saves?: number loads?: number saveError?: string | null tick?: number x?: number y?: number bagItems?: number } export function syncEngineState(engine: GameEngine, state: EngineViewState): void { state.monstersSpawned = engine.world.monsters.length state.monstersAlive = engine.world.monsters.filter(m => m.state !== 'dead').length state.kills = engine.world.kills state.playerHp = engine.world.player.hp state.playerMaxHp = engine.world.player.maxHp state.playerMana = engine.world.player.mana state.playerMaxMana = engine.world.player.maxMana state.playerLevel = engine.world.player.level state.playerXp = engine.world.player.xp state.playerHits = engine.metrics.playerHits state.groundItems = engine.ground.length state.inventoryItems = engine.bag.contents.length state.cellsUsed = engine.bag.usedCells state.cellsTotal = engine.bag.width * engine.bag.height state.gold = engine.bag.gold state.defense = totalStat(engine.bag.contents, 'defense') state.dropsRolled = engine.metrics.dropsRolled state.pickups = engine.metrics.pickups state.inventoryRefusals = engine.metrics.inventoryRefusals state.lastDrops = [...engine.metrics.lastDrops] state.skills = engine.opts.skills.map(s => s.name) state.selectedSkill = engine.selectedSkill state.casts = engine.metrics.casts state.castRefusals = engine.metrics.castRefusals state.castHits = engine.metrics.castHits state.projectilesAlive = engine.projectiles.length state.npcs = engine.npcEntities.length state.npcsNear = engine.npcEntities.filter(n => Math.hypot(n.x - engine.world.player.x, n.y - engine.world.player.y) <= 800) state.monstersNear = engine.world.monsters.filter(m => Math.hypot(m.x - engine.world.player.x, m.y - engine.world.player.y) <= 800) state.quests = engine.questLog.active.map(q => q.def.name) state.dialog = engine.dialog state.questXp = engine.metrics.questXp state.questGold = engine.metrics.questGold state.worldTick = engine.world.tick state.saves = engine.metrics.saves state.loads = engine.metrics.loads state.saveError = engine.metrics.saveError state.tick = engine.world.tick state.x = engine.world.player.x state.y = engine.world.player.y state.bagItems = engine.bag.contents.length } export { MonsterStreamingManager } from './monster-streaming.ts' export type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts'