1266 lines
48 KiB
TypeScript
1266 lines
48 KiB
TypeScript
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<string, MonsterKind> | 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<string | number, number> = 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 {
|
|
if (!this.world.pendingKills || this.world.pendingKills.length === 0) {
|
|
this.world.events = []
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
const combatOptions = { ...this.opts.combatOptions, preserveEvents: true }
|
|
tickCombat(this.world, { movement, attack: castHappened ? false : input.attacking }, 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Enchant buff duration tick & overlay management
|
|
if (player.enchantTicks && player.enchantTicks > 0) {
|
|
player.enchantTicks -= 1
|
|
if (player.enchantTicks <= 0) {
|
|
player.enchantTicks = 0
|
|
player.enchantDamage = undefined
|
|
player.enchantAttackRatingPercent = undefined
|
|
for (const ov of this.overlays) {
|
|
if (ov.overlayName === 'enchant' && 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Blaze buff duration tick & footstep fire patch emission
|
|
if (player.blazeTicks && player.blazeTicks > 0) {
|
|
player.blazeTicks -= 1
|
|
const distFromLast = Math.hypot(
|
|
player.x - (player.lastBlazeX ?? player.x),
|
|
player.y - (player.lastBlazeY ?? player.y),
|
|
)
|
|
if (distFromLast >= 24 || player.lastBlazeX === undefined) {
|
|
player.lastBlazeX = player.x
|
|
player.lastBlazeY = player.y
|
|
this.projectiles.push({
|
|
skillId: '46',
|
|
x: player.x,
|
|
y: player.y,
|
|
vx: 0,
|
|
vy: 0,
|
|
damage: player.blazeDamage ?? 10,
|
|
ttl: player.blazePatchDuration ?? 115,
|
|
fromPlayer: true,
|
|
missileType: 'blaze',
|
|
pierce: true,
|
|
aoeRadius: 24,
|
|
})
|
|
}
|
|
if (player.blazeTicks <= 0) {
|
|
player.lastBlazeX = undefined
|
|
player.lastBlazeY = undefined
|
|
}
|
|
}
|
|
|
|
// 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<number>()
|
|
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)
|
|
}
|
|
}
|
|
|
|
if (this.world.pendingKills && this.world.pendingKills.length > 0) {
|
|
for (const kill of this.world.pendingKills) {
|
|
if (!this.world.events.includes(kill)) {
|
|
this.world.events.push(kill)
|
|
}
|
|
}
|
|
this.world.pendingKills = []
|
|
}
|
|
|
|
for (const event of this.world.events) {
|
|
if (event.kind !== 'kill') continue
|
|
const monster = event.monsterIndex !== undefined
|
|
? this.world.monsters.find(m => m.index === event.monsterIndex)
|
|
: this.world.monsters.find(m => m.stats.id === event.subjectId && Math.hypot(m.x - event.x, m.y - event.y) < 10)
|
|
if (monster?.dropRolled) continue
|
|
if (monster) monster.dropRolled = true
|
|
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'
|