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, CANONICAL_113C_MISSILES, ISO_GROUND_ASPECT_RATIO, createActiveOverlay, getSkillCastOverlay, isAuraSkill, getAuraOverlayName, getAuraOverlayNames } from './skills.ts' import type { SkillDef, Projectile, ActiveOverlay, CreateActiveOverlayOptions } from './skills.ts' import { QuestLog, npcDialog } from './quests.ts' import type { NpcDef, QuestDef } from './quests.ts' import { Rng } from './rng.ts' import type { DropTables } from './drop-pipeline.ts' import { executeDropPipeline } from './drop-pipeline.ts' import type { MonsterKind, Difficulty } from './monsters.ts' import { getMonsterTreasureClass } from './monsters.ts' import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from './save.ts' import { MonsterStreamingManager } from './monster-streaming.ts' import type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts' import { GroundItemManager, triggerFlippyBounce, findIsometricDropPosition } from './ground-items.ts' import type { GroundItemEntity, GroundItemSource } from './ground-items.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 isMissileBlocked?(x: number, y: number, fromX?: number, fromY?: number): boolean } /** * 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[] | undefined prefixAffixes?: readonly Affix[] | undefined suffixAffixes?: readonly Affix[] | undefined dropTables?: DropTables | undefined difficulty?: Difficulty | undefined monsterKinds?: Map | undefined 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 /** * Whether the level is a town (e.g. Rogue Encampment, Lut Gholein, Kurast Docks, Pandemonium Fortress, Harrogath). * In town, friendly auras only affect self; enemy debuffs, pulses, and corpse redemption are suspended. */ isTown?: boolean | undefined /** * Current level ID (e.g. 1 for Rogue Encampment). */ levelId?: number | undefined /** * Optional allies (mercenaries, party members, summons) for aura propagation in wilderness. */ allies?: readonly any[] | 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 } /** Active explosion visual effect (e.g. fireexplode from firebolt). */ export interface ActiveExplosion { readonly id?: string | undefined readonly missileType: string readonly x: number readonly y: number frame: number readonly maxFrames: number readonly radius?: number | undefined } export class GameEngine { world: CombatWorld loot: Rng bag: Inventory ground: { x: number; y: number; item: Item }[] = [] groundItems: GroundItemManager = new GroundItemManager() questLog: QuestLog projectiles: Projectile[] = [] explosions: ActiveExplosion[] = [] overlays: ActiveOverlay[] = [] castRng: Rng skillCooldown = 0 selectedSkill = 0 npcEntities: NpcEntity[] = [] dialog: string[] = [] isTown = false levelId = 1 allies: any[] = [] activeAuraSkillId: number | null = null activeAuraOverlay: ActiveOverlay | null = null activeAuraOverlays: ActiveOverlay[] = [] activeAuraLevel = 1 /** * Lazy room population driver, when the level supplied a room plan. * * Explicitly assignable to `undefined`: walking into a level the baker gave * no packs has to clear the previous level's manager, or its rooms would keep * waking monsters that belong to a map nobody is standing on. */ streamingManager?: MonsterStreamingManager | undefined /** * 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, } tickCount = 0 nextDelayByTarget: Map = new Map() constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) { this.isTown = opts.isTown ?? false this.levelId = opts.levelId ?? 1 this.allies = opts.allies ? [...opts.allies] : [] 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 }) } } } /** * Dynamically assign or update drop tables (e.g. when loaded asynchronously in the background). */ setDropTables(dropTables: DropTables): void { this.opts.dropTables = dropTables this.opts.monsterKinds = dropTables.monsterKinds } /** * Activates or deactivates an authentic Diablo II v1.13c aura on the player. * If skillId is an aura skill (e.g. Conviction 123, Holy Freeze 114, Defiance 104), * sets the active aura, spawns the ground looping overlay on the player, * and synchronizes monster foot overlays according to 1.13c rules. */ updateActiveAura(skillId: number | null, slvl = 1): void { const normalizedSkillId = skillId !== null && isAuraSkill(skillId) ? skillId : null if (this.activeAuraSkillId === normalizedSkillId && this.activeAuraLevel === slvl) { return } // Clean up previous aura overlay and any enemy conviction overlays if (this.activeAuraSkillId !== null) { for (const ov of this.overlays) { if (ov.isAura) { ov.expired = true } } this.overlays = this.overlays.filter(ov => !ov.expired) this.activeAuraOverlay = null this.activeAuraOverlays = [] } this.activeAuraSkillId = normalizedSkillId this.activeAuraLevel = slvl if (normalizedSkillId !== null) { const ovNames = getAuraOverlayNames(normalizedSkillId) const player = this.world.player const spawned: ActiveOverlay[] = [] for (const ovName of ovNames) { const auraOverlay = createActiveOverlay( ovName, { x: player.x, y: player.y }, 'player', undefined, { loop: true, isAura: true, auraSkillId: normalizedSkillId, }, ) this.overlays.push(auraOverlay) spawned.push(auraOverlay) } this.activeAuraOverlays = spawned this.activeAuraOverlay = spawned[0] ?? null } } /** * Spawns an active cast overlay effect on an entity (defaulting to the player). * Replaces any existing non-aura cast overlay for the same caster to prevent oversaturated additive stacking. */ spawnOverlay( overlayName: string, caster?: { x: number; y: number }, casterId = 'player', options?: CreateActiveOverlayOptions, ): ActiveOverlay { const pos = caster ?? { x: this.world.player.x, y: this.world.player.y } // Cleanly replace any ongoing non-aura cast overlay for this caster to prevent oversaturated additive stacking for (const ov of this.overlays) { if (ov.casterId === casterId && !ov.isAura) { ov.expired = true } } this.overlays = this.overlays.filter(ov => !ov.expired) const overlay = createActiveOverlay(overlayName, pos, casterId, undefined, options) this.overlays.push(overlay) return overlay } 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 const overlayName = wanted.castOverlay ?? getSkillCastOverlay(wanted.id) if (overlayName) { this.spawnOverlay(overlayName, { x: player.x, y: player.y }, 'player') } } else if (result.kind === 'instant') { player.mana = Math.max(0, player.mana - wanted.manaCost) this.skillCooldown = wanted.cooldownTicks this.metrics.casts += 1 castHappened = true const overlayName = wanted.castOverlay ?? getSkillCastOverlay(wanted.id) if (overlayName) { this.spawnOverlay(overlayName, { x: player.x, y: player.y }, 'player') } 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 this.tickCount = this.world.tick for (const [targetId, expireTick] of this.nextDelayByTarget) { if (expireTick <= this.tickCount) { this.nextDelayByTarget.delete(targetId) } } 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, { overlap: (x, y) => this.terrain.overlap(x, y), isMissileBlocked: this.terrain.isMissileBlocked ? (x, y, fromX, fromY) => this.terrain.isMissileBlocked!(x, y, fromX, fromY) : undefined, nextDelayByTarget: this.nextDelayByTarget, currentTick: this.tickCount, }) this.projectiles.length = 0 // outcome.alive already contains spawned projectiles from tickProjectiles this.projectiles.push(...outcome.alive) for (const hit of outcome.hits) { const targetMonster = this.world.monsters.find(m => m.index === hit.targetIndex) if (hit.isUndeadOnly) { const name = targetMonster?.stats?.name?.toLowerCase() ?? '' const id = targetMonster?.stats?.id?.toLowerCase() ?? '' const isUndead = (targetMonster as any)?.isUndead || name.includes('skeleton') || name.includes('zombie') || name.includes('ghoul') || name.includes('ghost') || name.includes('wraith') || id.includes('skeleton') || id.includes('zombie') || id.includes('ghoul') || id.includes('ghost') || id.includes('wraith') if (!isUndead) { continue } } damageMonster(this.world, hit.targetIndex, hit.damage) this.metrics.castHits += 1 if (targetMonster) { if (hit.statusEffect === 'freeze') { targetMonster.frozenTicks = hit.statusDuration ?? 75 } else if (hit.statusEffect === 'chill') { targetMonster.chillTicks = hit.statusDuration ?? 100 } else if (hit.statusEffect === 'poison') { targetMonster.poisonTicks = hit.statusDuration ?? 100 } } // AoE splash damage if (hit.aoeRadius && hit.aoeRadius > 0) { for (const m of this.world.monsters) { if (m.index === hit.targetIndex || m.state === 'dead' || m.hp <= 0) continue if (hit.isUndeadOnly) { const name = m?.stats?.name?.toLowerCase() ?? '' const id = m?.stats?.id?.toLowerCase() ?? '' const isUndead = (m as any)?.isUndead || name.includes('skeleton') || name.includes('zombie') || name.includes('ghoul') || name.includes('ghost') || name.includes('wraith') || id.includes('skeleton') || id.includes('zombie') || id.includes('ghoul') || id.includes('ghost') || id.includes('wraith') if (!isUndead) continue } // Ground distance in 2:1 isometric projection: // dx^2 + (dy / ISO_GROUND_ASPECT_RATIO)^2 <= aoeRadius^2 const dx = m.x - hit.x const dy = (m.y - hit.y) / ISO_GROUND_ASPECT_RATIO if (Math.hypot(dx, dy) <= hit.aoeRadius) { damageMonster(this.world, m.index, hit.damage) if (hit.statusEffect === 'freeze') { m.frozenTicks = hit.statusDuration ?? 75 } else if (hit.statusEffect === 'chill') { m.chillTicks = hit.statusDuration ?? 100 } else if (hit.statusEffect === 'poison') { m.poisonTicks = hit.statusDuration ?? 100 } } } } const missileType = hit.missileType ?? (hit.skillId === '36' || hit.skillId === 'firebolt' ? 'firebolt' : undefined) const mslData = missileType ? CANONICAL_113C_MISSILES[missileType] : undefined const explType = mslData?.explosionMissile || (missileType === 'firebolt' ? 'fireexplode' : '') if (explType) { const explData = CANONICAL_113C_MISSILES[explType] const maxFrames = explData?.animLen || 12 this.explosions.push({ missileType: explType, x: hit.x, y: hit.y, frame: 0, maxFrames, radius: hit.aoeRadius ?? 24, }) this.world.events.push({ kind: 'missileExplode', missileType: explType, skillId: hit.skillId, x: hit.x, y: hit.y, amount: hit.damage, }) } } if (outcome.wallHitEvents) { for (const wallHit of outcome.wallHitEvents) { if (wallHit.aoeRadius && wallHit.aoeRadius > 0) { for (const m of this.world.monsters) { if (m.state === 'dead' || m.hp <= 0) continue if (Math.hypot(m.x - wallHit.x, m.y - wallHit.y) <= wallHit.aoeRadius) { damageMonster(this.world, m.index, 10) } } } const missileType = wallHit.missileType ?? (wallHit.skillId === '36' || wallHit.skillId === 'firebolt' ? 'firebolt' : undefined) const mslData = missileType ? CANONICAL_113C_MISSILES[missileType] : undefined const explType = mslData?.explosionMissile || (missileType === 'firebolt' ? 'fireexplode' : '') if (explType) { const explData = CANONICAL_113C_MISSILES[explType] const maxFrames = explData?.animLen || 12 this.explosions.push({ missileType: explType, x: wallHit.x, y: wallHit.y, frame: 0, maxFrames, radius: wallHit.aoeRadius ?? 24, }) this.world.events.push({ kind: 'missileExplode', missileType: explType, skillId: wallHit.skillId, x: wallHit.x, y: wallHit.y, }) } } } } if (this.explosions.length > 0) { for (const exp of this.explosions) { exp.frame += 1 } this.explosions = this.explosions.filter(exp => exp.frame < exp.maxFrames) } if (this.overlays.length > 0) { for (const overlay of this.overlays) { if (overlay.casterId === 'player') { overlay.x = player.x overlay.y = player.y } else if (overlay.targetMonsterIndex !== undefined) { const mon = this.world.monsters[overlay.targetMonsterIndex] if (!mon || mon.state === 'dead' || mon.hp <= 0) { overlay.expired = true continue } overlay.x = mon.x overlay.y = mon.y } overlay.frame += overlay.animRate / 25 if (overlay.frame >= overlay.maxFrames) { if (overlay.loop) { overlay.frame = overlay.frame % overlay.maxFrames } else { overlay.expired = true } } } this.overlays = this.overlays.filter(overlay => !overlay.expired) } // Periodic Thunder Storm simulation if (player.thunderstormTicks && player.thunderstormTicks > 0) { player.thunderstormTicks -= 1 player.thunderstormNextStrike = (player.thunderstormNextStrike ?? 0) - 1 if (player.thunderstormNextStrike <= 0) { player.thunderstormNextStrike = player.thunderstormInterval ?? 41 // Find nearest live monster within 17 subtiles (340 px) let nearestMonster: any = null let minDist = 340 for (const monster of this.world.monsters) { if (monster.state === 'dead' || monster.hp <= 0) continue const dist = Math.hypot(monster.x - player.x, monster.y - player.y) if (dist <= minDist) { minDist = dist nearestMonster = monster } } if (nearestMonster) { const dmg = player.thunderstormDamage ?? 50 damageMonster(this.world, nearestMonster.index, dmg) this.spawnOverlay('light_cast_2', { x: nearestMonster.x, y: nearestMonster.y }, `monster-${nearestMonster.index}`) this.world.events.push({ kind: 'monsterHit', x: nearestMonster.x, y: nearestMonster.y, amount: dmg, }) } } } // Energy Shield duration tick & overlay management if (player.energyshieldTicks && player.energyshieldTicks > 0) { player.energyshieldTicks -= 1 if (player.energyshieldTicks <= 0) { for (const ov of this.overlays) { if (ov.overlayName === 'energyshield' && ov.casterId === 'player') { ov.expired = true } } } } // Cold Armor duration tick & overlay management if (player.coldArmorTicks && player.coldArmorTicks > 0) { player.coldArmorTicks -= 1 if (player.coldArmorTicks <= 0) { player.coldArmorType = undefined for (const ov of this.overlays) { if ( (ov.overlayName === 'frozenarmor' || ov.overlayName === 'shiverarmor' || ov.overlayName === 'chillingarmor') && ov.casterId === 'player' ) { ov.expired = true } } } } // Periodic & continuous aura simulation (D2 v1.13c parity) if (this.activeAuraSkillId !== null) { const auraId = this.activeAuraSkillId const slvl = this.activeAuraLevel const inTown = this.isTown || [1, 40, 75, 103, 109].includes(this.levelId) // Conviction (123): // - Caster has ground ring visual only, zero resistance/defense reduction on self! // - In wilderness: hostile enemies in range receive -Def% & -Res% and display convictionback ring under feet. // - In town: enemy debuffs and enemy overlays are completely suspended. if (auraId === 123) { const auraRadius = Math.round(((40 + slvl * 8) * 2 / 3) * 16) if (!inTown) { const activeIndices = new Set() for (const monster of this.world.monsters) { if (monster.state === 'dead' || monster.hp <= 0) continue const dist = Math.hypot(monster.x - player.x, monster.y - player.y) if (dist <= auraRadius) { activeIndices.add(monster.index) const hasOv = this.overlays.some( ov => !ov.expired && ov.isAura && ov.auraSkillId === 123 && ov.targetMonsterIndex === monster.index, ) if (!hasOv) { this.overlays.push( createActiveOverlay( 'convictionback', { x: monster.x, y: monster.y }, `monster_${monster.index}`, undefined, { loop: true, isAura: true, auraSkillId: 123, targetMonsterIndex: monster.index, }, ), ) } } } // Remove conviction overlay from monsters that died or walked out of range for (const ov of this.overlays) { if (ov.isAura && ov.auraSkillId === 123 && ov.targetMonsterIndex !== undefined) { if (!activeIndices.has(ov.targetMonsterIndex)) { ov.expired = true } } } } else { // In town: expire all monster conviction overlays for (const ov of this.overlays) { if (ov.isAura && ov.auraSkillId === 123 && ov.targetMonsterIndex !== undefined) { ov.expired = true } } } } // Holy Freeze (114): // - Caster displays holyfreeze cyan ring. // - In wilderness: periodic cold pulse damage & chill (slow + blue tint) to enemies in range. // - D2 Ground Truth Invariant: Holy Freeze NEVER displays an aura ring under enemy feet! // - In town: pulse damage & slow are completely suspended. if (auraId === 114 && !inTown) { if (this.world.tick % 50 === 0) { const auraRadius = Math.round(((40 + slvl * 8) * 2 / 3) * 16) const coldDmg = Math.max(1, Math.round(5 + slvl * 2.5)) for (const monster of this.world.monsters) { if (monster.state === 'dead' || monster.hp <= 0) continue const dist = Math.hypot(monster.x - player.x, monster.y - player.y) if (dist <= auraRadius) { damageMonster(this.world, monster.index, coldDmg) monster.chillTicks = 100 // 4 seconds slow & blue tint } } } } // Holy Fire (102) & Holy Shock (118) in wilderness: if ((auraId === 102 || auraId === 118) && !inTown) { if (this.world.tick % 50 === 0) { const auraRadius = Math.round(((40 + slvl * 8) * 2 / 3) * 16) const pulseDmg = auraId === 102 ? Math.max(1, Math.round(3 + slvl * 2)) : Math.max(1, Math.round(4 + slvl * 3)) for (const monster of this.world.monsters) { if (monster.state === 'dead' || monster.hp <= 0) continue const dist = Math.hypot(monster.x - player.x, monster.y - player.y) if (dist <= auraRadius) { damageMonster(this.world, monster.index, pulseDmg) } } } } // Prayer (99): // - Periodic heal every 50 ticks (2 seconds) // - In town: heals only self // - In wilderness: heals self and allies if (auraId === 99) { if (this.world.tick % 50 === 0) { const heal = Math.max(1, Math.round(2 + slvl * 1.5)) player.hp = Math.min(player.maxHp, player.hp + heal) if (!inTown && this.allies.length > 0) { for (const ally of this.allies) { if (ally && typeof ally.hp === 'number' && typeof ally.maxHp === 'number') { ally.hp = Math.min(ally.maxHp, ally.hp + heal) } } } } } // Meditation (120): // - Boosts mana recovery if (auraId === 120) { if (this.world.tick % 10 === 0) { player.mana = Math.min(player.maxMana, player.mana + 1) } } // Redemption (124): // - In wilderness: redeems nearby corpses every 50 ticks to heal HP & Mana // - In town: corpse consumption is completely suspended if (auraId === 124 && !inTown) { if (this.world.tick % 50 === 0) { const auraRadius = Math.round(((40 + slvl * 8) * 2 / 3) * 16) for (const monster of this.world.monsters) { if (monster.state === 'dead' && monster.corpseTicks > 0) { const dist = Math.hypot(monster.x - player.x, monster.y - player.y) if (dist <= auraRadius) { monster.corpseTicks = 0 // consume corpse const restoreHp = Math.round(player.maxHp * 0.15) const restoreMana = Math.round(player.maxMana * 0.15) player.hp = Math.min(player.maxHp, player.hp + restoreHp) player.mana = Math.min(player.maxMana, player.mana + restoreMana) break // up to 1 corpse per pulse tick } } } } } } 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 } if (this.opts.dropTables) { const difficulty = this.opts.difficulty ?? 'normal' const monsterRank = event.monsterRank ?? 'normal' const monsterType = event.monsterType ?? (monsterRank === 'unique' ? 3 : monsterRank === 'champion' ? 2 : 1) const monsterLevel = event.monsterLevel ?? (player.level + 2) let tcName = '' if (event.superUniqueId && this.opts.dropTables.superUniques.has(event.superUniqueId)) { tcName = this.opts.dropTables.superUniques.get(event.superUniqueId)!.treasureClass } if (!tcName) { const monsterKind = this.opts.dropTables.monsterKinds.get(event.subjectId ?? '') ?? this.opts.monsterKinds?.get(event.subjectId ?? '') if (monsterKind) { tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType) } } if (!tcName) { tcName = 'Act 1 H2H A' } const droppedItems = executeDropPipeline(this.opts.dropTables, { tcName, nLevel: monsterLevel, monsterType, difficulty, gamePlayers: 1, partyPlayers: 1, monsterRng: this.loot, }) this.metrics.dropsRolled += 1 for (const drop of droppedItems) { if (drop.base.id === 'gold' || drop.code?.trim() === 'gld') { this.bag.add(drop) } else { const isBlocked = (bx: number, by: number) => (this.terrain ? this.terrain.overlap(bx, by) !== 0 : false) const dropPos = findIsometricDropPosition(event.x, event.y, this.groundItems.all, isBlocked) this.ground.push({ x: dropPos.x, y: dropPos.y, item: drop }) this.groundItems.add(drop, 0, 0, dropPos.x, dropPos.y, { bounce: true }) this.metrics.lastDrops.push(drop.name) if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift() } } } else if (this.opts.itemBases && this.opts.itemBases.length > 0) { 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') { const isBlocked = (bx: number, by: number) => (this.terrain ? this.terrain.overlap(bx, by) !== 0 : false) const dropPos = findIsometricDropPosition(event.x, event.y, this.groundItems.all, isBlocked) this.ground.push({ x: dropPos.x, y: dropPos.y, item: drop.item }) this.groundItems.add(drop.item, 0, 0, dropPos.x, dropPos.y, { bounce: true }) 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) const target = this.groundItems.all.find(g => g.item === entry.item) ?? this.groundItems.findAtPoint(entry.x, entry.y, 48) if (target) this.groundItems.remove(target.id) this.metrics.pickups += 1 } } } this.groundItems.update(this.world.tick * 40, 40) 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 = [] this.explosions = [] this.overlays = [] if (this.streamingManager !== undefined) { this.streamingManager.world = this.world } } /** * Drop an item onto the ground at target coordinates. */ dropItem(item: GroundItemSource, x?: number, y?: number, cellX?: number, cellY?: number): GroundItemEntity { const rawX = x ?? this.world.player.x const rawY = y ?? this.world.player.y const isBlocked = (bx: number, by: number) => (this.terrain ? this.terrain.overlap(bx, by) !== 0 : false) const dropPos = findIsometricDropPosition(rawX, rawY, this.groundItems.all, isBlocked) const worldX = dropPos.x const worldY = dropPos.y const cx = cellX ?? 0 const cy = cellY ?? 0 const entity = this.groundItems.add(item, cx, cy, worldX, worldY, { bounce: true }) this.ground.push({ x: worldX, y: worldY, item: item as any }) return entity } /** * Drop gold onto the ground at target coordinates. */ dropGold(amount: number, x?: number, y?: number, cellX?: number, cellY?: number): GroundItemEntity | null { if (amount <= 0) return null const rawX = x ?? this.world.player.x const rawY = y ?? this.world.player.y const isBlocked = (bx: number, by: number) => (this.terrain ? this.terrain.overlap(bx, by) !== 0 : false) const dropPos = findIsometricDropPosition(rawX, rawY, this.groundItems.all, isBlocked) const worldX = dropPos.x const worldY = dropPos.y const cx = cellX ?? 0 const cy = cellY ?? 0 const entity = this.groundItems.add( { isGold: true, code: 'gld', name: `${amount} Gold`, nameZh: `${amount} 金币` }, cx, cy, worldX, worldY, { amount, bounce: true }, ) this.ground.push({ x: worldX, y: worldY, item: { name: `${amount} 金币`, stack: amount, value: amount, base: { id: 'gold', name: 'Gold' } as any } as any, }) return entity } /** * Pick up gold from the ground by its ground item id. */ pickupGold(id: string): { readonly success: boolean; readonly amount: number } { const entity = this.groundItems.get(id) if (!entity || !entity.isGold) { return { success: false, amount: 0 } } const amount = entity.amount || 1 this.groundItems.remove(id) const gIdx = this.ground.findIndex(g => Math.hypot(g.x - entity.x, g.y - entity.y) <= 32 || (g.item as any)?.isGold) if (gIdx !== -1) { this.ground.splice(gIdx, 1) } this.metrics.pickups += 1 return { success: true, amount } } /** * Pick up an item from the ground by ground item id into the inventory. */ pickupItem(id: string): { readonly success: boolean readonly reason?: 'full' | 'not_found' readonly item?: GroundItemEntity } { const entity = this.groundItems.get(id) if (!entity) { return { success: false, reason: 'not_found' } } if (entity.isGold) { const res = this.pickupGold(id) return { success: res.success, item: entity } } // Attempt to place in bag const placed = this.bag.add(entity.item as any) if (placed === null) { this.metrics.inventoryRefusals += 1 triggerFlippyBounce(entity, typeof performance !== 'undefined' ? performance.now() : Date.now()) return { success: false, reason: 'full', item: entity } } this.groundItems.remove(id) const gIdx = this.ground.findIndex(g => Math.hypot(g.x - entity.x, g.y - entity.y) <= 32 || g.item === entity.item) if (gIdx !== -1) { this.ground.splice(gIdx, 1) } this.metrics.pickups += 1 return { success: true, item: entity } } } export interface NpcSpriteFrame { readonly width: number readonly height: number readonly offsetX: number readonly offsetY: number } 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 /** * Optional frame bounds and offsets from the baked DC6 sprite. * Used for exact screen projection of name labels over sprite quads. */ frame?: NpcSpriteFrame | null | undefined } 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'