fix(ground): unify authoritative ground-item store with exact instance IDs (#522)
Key ground items by unique instance ID, remove 32px proximity deletion, deduplicate ground-item serialization on save, and reconstruct ground items on load. TAG=agy CONV=4e31689c-063a-4965-968b-59c0f5795f97
This commit is contained in:
parent
206b59e72d
commit
231d375e62
|
|
@ -75,6 +75,7 @@ export interface GroundItemEntity {
|
|||
readonly quality: string
|
||||
readonly isGold: boolean
|
||||
amount: number
|
||||
readonly goldAmount?: number | undefined
|
||||
readonly invWidth: number
|
||||
readonly invHeight: number
|
||||
readonly dropTime: number
|
||||
|
|
@ -387,7 +388,7 @@ export function calculateBounceHeight(bounceState: GroundItemBounceState, now: n
|
|||
*/
|
||||
export function triggerFlippyBounce(
|
||||
entity: GroundItemEntity,
|
||||
now: number = typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
||||
now = 0,
|
||||
options?: Partial<GroundItemBounceState>,
|
||||
): void {
|
||||
entity.bounceState = {
|
||||
|
|
@ -1343,6 +1344,20 @@ export function findIsometricDropPosition(
|
|||
export class GroundItemManager {
|
||||
private readonly items = new Map<string, GroundItemEntity>()
|
||||
private idCounter = 1
|
||||
onAdd?: ((entity: GroundItemEntity) => void) | undefined
|
||||
onRemove?: ((entity: GroundItemEntity) => void) | undefined
|
||||
onClear?: (() => void) | undefined
|
||||
|
||||
/** Next sequential ground item ID counter. */
|
||||
get nextId(): number {
|
||||
return this.idCounter
|
||||
}
|
||||
|
||||
set nextId(value: number) {
|
||||
if (Number.isFinite(value) && value >= 1) {
|
||||
this.idCounter = Math.floor(value)
|
||||
}
|
||||
}
|
||||
|
||||
/** All active ground items. */
|
||||
get all(): readonly GroundItemEntity[] {
|
||||
|
|
@ -1362,16 +1377,38 @@ export class GroundItemManager {
|
|||
worldX: number,
|
||||
worldY: number,
|
||||
options?: {
|
||||
id?: string | undefined
|
||||
amount?: number | undefined
|
||||
bounce?: boolean | undefined
|
||||
peakHeight?: number | undefined
|
||||
peakHeightPx?: number | undefined
|
||||
durationMs?: number | undefined
|
||||
bounceDurationMs?: number | undefined
|
||||
now?: number | undefined
|
||||
},
|
||||
): GroundItemEntity {
|
||||
const id = `ground_item_${Date.now()}_${this.idCounter++}`
|
||||
let seqId: number
|
||||
let id: string
|
||||
if (typeof options?.id === 'string' && options.id.length > 0) {
|
||||
id = options.id
|
||||
const match = id.match(/(\d+)$/)
|
||||
seqId = match ? Number(match[1]) : this.idCounter++
|
||||
if (seqId >= this.idCounter) {
|
||||
this.idCounter = seqId + 1
|
||||
}
|
||||
} else {
|
||||
seqId = this.idCounter++
|
||||
id = `ground_item_${seqId}`
|
||||
}
|
||||
const meta = resolveGroundItemMetadata(item, options?.amount)
|
||||
const now = options?.now ?? (typeof performance !== 'undefined' ? performance.now() : Date.now())
|
||||
const now = options?.now ?? 0
|
||||
const sparklePhase = (((seqId * 0x9e3779b9) >>> 0) / 0x100000000) * Math.PI * 2
|
||||
const hasBounce =
|
||||
options?.bounce ??
|
||||
(options?.durationMs !== undefined ||
|
||||
options?.bounceDurationMs !== undefined ||
|
||||
options?.peakHeight !== undefined ||
|
||||
options?.peakHeightPx !== undefined)
|
||||
|
||||
const entity: GroundItemEntity = {
|
||||
id,
|
||||
|
|
@ -1388,12 +1425,12 @@ export class GroundItemManager {
|
|||
y: worldY,
|
||||
cellX,
|
||||
cellY,
|
||||
sparklePhase: Math.random() * Math.PI * 2,
|
||||
bounceState: options?.bounce
|
||||
sparklePhase,
|
||||
bounceState: hasBounce
|
||||
? {
|
||||
startTime: now,
|
||||
durationMs: options?.durationMs ?? 400,
|
||||
peakHeightPx: options?.peakHeight ?? 24,
|
||||
durationMs: options?.durationMs ?? options?.bounceDurationMs ?? 400,
|
||||
peakHeightPx: options?.peakHeight ?? options?.peakHeightPx ?? 24,
|
||||
phase: 'primary',
|
||||
}
|
||||
: null,
|
||||
|
|
@ -1403,11 +1440,34 @@ export class GroundItemManager {
|
|||
...(meta.ethereal !== undefined ? { ethereal: meta.ethereal } : {}),
|
||||
...(meta.sockets !== undefined ? { sockets: meta.sockets } : {}),
|
||||
}
|
||||
Object.defineProperty(entity, 'goldAmount', {
|
||||
get: () => entity.amount,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
this.items.set(id, entity)
|
||||
this.onAdd?.(entity)
|
||||
return entity
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a gold pile entity at world coordinates `(x, y)` with `amount` gold.
|
||||
*/
|
||||
spawnGold(x: number, y: number, amount: number, now = 0): GroundItemEntity {
|
||||
const validAmount = Math.max(1, Math.floor(amount))
|
||||
const cellX = Math.floor(x / 160)
|
||||
const cellY = Math.floor(y / 80)
|
||||
return this.add(
|
||||
{ code: 'gld', name: `${validAmount} Gold`, stack: validAmount },
|
||||
cellX,
|
||||
cellY,
|
||||
x,
|
||||
y,
|
||||
{ amount: validAmount, now, bounce: true },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance ground item bounce states and trigger authentic drop SFX
|
||||
* when animation reaches dropsfxframe (or on bounce landing).
|
||||
|
|
@ -1444,31 +1504,36 @@ export class GroundItemManager {
|
|||
/** Add an existing ground item entity. */
|
||||
addEntity(entity: GroundItemEntity): GroundItemEntity {
|
||||
this.items.set(entity.id, entity)
|
||||
this.onAdd?.(entity)
|
||||
return entity
|
||||
}
|
||||
|
||||
/** Remove a ground item by id (e.g. when picked up). */
|
||||
remove(id: string): GroundItemEntity | undefined {
|
||||
const entity = this.items.get(id)
|
||||
remove(id: string | number): GroundItemEntity | undefined {
|
||||
const strId = String(id)
|
||||
const entity = this.items.get(strId)
|
||||
if (entity) {
|
||||
this.items.delete(id)
|
||||
this.items.delete(strId)
|
||||
this.onRemove?.(entity)
|
||||
}
|
||||
return entity
|
||||
}
|
||||
|
||||
/** Retrieve an entity by id. */
|
||||
get(id: string): GroundItemEntity | undefined {
|
||||
return this.items.get(id)
|
||||
get(id: string | number): GroundItemEntity | undefined {
|
||||
return this.items.get(String(id))
|
||||
}
|
||||
|
||||
/** Clear all ground items. */
|
||||
clear(): void {
|
||||
this.items.clear()
|
||||
this.idCounter = 1
|
||||
this.onClear?.()
|
||||
}
|
||||
|
||||
/** Find the nearest ground item within hit radius. */
|
||||
findAtPoint(x: number, y: number, hitRadiusPx = 25): GroundItemEntity | undefined {
|
||||
let closest: GroundItemEntity | undefined
|
||||
findAtPoint(x: number, y: number, hitRadiusPx = 25): GroundItemEntity | null {
|
||||
let closest: GroundItemEntity | null = null
|
||||
let minDistSq = hitRadiusPx * hitRadiusPx
|
||||
for (const item of this.items.values()) {
|
||||
const dx = item.x - x
|
||||
|
|
|
|||
195
src/game/save.ts
195
src/game/save.ts
|
|
@ -22,7 +22,7 @@
|
|||
* notes on {@link writeD2s}.
|
||||
*/
|
||||
import { rebindPlayer } from './combat.ts'
|
||||
import type { CombatWorld, Monster } from './combat.ts'
|
||||
import type { CharacterClassCode, CombatPlayer, CombatWorld, Monster } from './combat.ts'
|
||||
import type { Inventory, Item, PlacedItem } from './items.ts'
|
||||
import type { QuestLog, QuestProgress } from './quests.ts'
|
||||
import type { Rng } from './d2-rng.ts'
|
||||
|
|
@ -37,7 +37,15 @@ export interface GameSnapshot {
|
|||
/** Random stream low 32-bit position, so drops continue rather than restart. */
|
||||
readonly rngState: number
|
||||
/** Random stream high 32-bit carry position (64-bit D2Rng). */
|
||||
readonly rngHighState?: number
|
||||
readonly rngHighState?: number | undefined
|
||||
/** Cast RNG low 32-bit state. */
|
||||
readonly castRngState?: number | undefined
|
||||
/** Cast RNG high 32-bit carry state. */
|
||||
readonly castRngHighState?: number | undefined
|
||||
/** Hydra sequence counter. */
|
||||
readonly hydraGroupSeqCounter?: number | undefined
|
||||
/** GroundItemManager sequential ID counter. */
|
||||
readonly groundItemIdCounter?: number | undefined
|
||||
/** The combat world, minus its per-tick event buffer. */
|
||||
readonly world: {
|
||||
readonly tick: number
|
||||
|
|
@ -49,6 +57,7 @@ export interface GameSnapshot {
|
|||
readonly inventory: {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly gold?: number | undefined
|
||||
readonly placed: readonly { readonly x: number; readonly y: number; readonly item: Item }[]
|
||||
}
|
||||
/** Quest progress. */
|
||||
|
|
@ -58,7 +67,12 @@ export interface GameSnapshot {
|
|||
* the player is looking at: a save that silently deletes the loot you were
|
||||
* walking towards is a save that lies.
|
||||
*/
|
||||
readonly ground: readonly { readonly x: number; readonly y: number; readonly item: Item }[]
|
||||
readonly ground: readonly {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly item: Item
|
||||
readonly groundItemId?: string | undefined
|
||||
}[]
|
||||
}
|
||||
|
||||
/** Everything a snapshot is taken from. */
|
||||
|
|
@ -67,12 +81,23 @@ export interface SnapshotSources {
|
|||
readonly world: CombatWorld
|
||||
/** The loot random stream. */
|
||||
readonly rng: Rng
|
||||
/** Optional cast RNG stream. */
|
||||
readonly castRng?: Rng | undefined
|
||||
/** Optional Hydra sequence counter. */
|
||||
readonly hydraGroupSeqCounter?: number | undefined
|
||||
/** Optional GroundItemManager sequence counter. */
|
||||
readonly groundItemIdCounter?: number | undefined
|
||||
/** The player's inventory. */
|
||||
readonly inventory: Inventory
|
||||
/** The quest log. */
|
||||
readonly quests: QuestLog
|
||||
/** Items on the ground. */
|
||||
readonly ground: readonly { readonly x: number; readonly y: number; readonly item: Item }[]
|
||||
readonly ground: readonly {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly item: Item
|
||||
readonly groundItemId?: string | undefined
|
||||
}[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -86,6 +111,9 @@ export function captureSnapshot(sources: SnapshotSources): GameSnapshot {
|
|||
version: SNAPSHOT_VERSION,
|
||||
rngState: sources.rng.seed,
|
||||
rngHighState: sources.rng.hi,
|
||||
...(sources.castRng !== undefined ? { castRngState: sources.castRng.seed, castRngHighState: sources.castRng.hi } : {}),
|
||||
...(sources.hydraGroupSeqCounter !== undefined ? { hydraGroupSeqCounter: sources.hydraGroupSeqCounter } : {}),
|
||||
...(sources.groundItemIdCounter !== undefined ? { groundItemIdCounter: sources.groundItemIdCounter } : {}),
|
||||
world: {
|
||||
tick: sources.world.tick,
|
||||
kills: sources.world.kills,
|
||||
|
|
@ -98,10 +126,16 @@ export function captureSnapshot(sources: SnapshotSources): GameSnapshot {
|
|||
inventory: {
|
||||
width: sources.inventory.width,
|
||||
height: sources.inventory.height,
|
||||
gold: sources.inventory.dedicatedGold,
|
||||
placed: sources.inventory.contents.map(entry => ({ x: entry.x, y: entry.y, item: entry.item })),
|
||||
},
|
||||
quests: sources.quests.all.map(entry => ({ id: entry.def.id, status: entry.status, kills: entry.kills })),
|
||||
ground: sources.ground.map(entry => ({ x: entry.x, y: entry.y, item: entry.item })),
|
||||
ground: sources.ground.map(entry => ({
|
||||
x: entry.x,
|
||||
y: entry.y,
|
||||
item: entry.item,
|
||||
...(entry.groundItemId !== undefined ? { groundItemId: entry.groundItemId } : {}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -193,10 +227,18 @@ export function restoreSnapshot(
|
|||
world: CombatWorld
|
||||
rngState: number
|
||||
rngHighState: number
|
||||
castRngState?: number | undefined
|
||||
castRngHighState?: number | undefined
|
||||
hydraGroupSeqCounter?: number | undefined
|
||||
groundItemIdCounter?: number | undefined
|
||||
inventory: Inventory
|
||||
quests: QuestLog
|
||||
ground: { x: number; y: number; item: Item }[]
|
||||
ground: { x: number; y: number; item: Item; groundItemId?: string | undefined }[]
|
||||
} {
|
||||
const inventory = build.inventory(snapshot.inventory.width, snapshot.inventory.height, snapshot.inventory.placed)
|
||||
if (typeof snapshot.inventory.gold === 'number') {
|
||||
inventory.dedicatedGold = snapshot.inventory.gold
|
||||
}
|
||||
return {
|
||||
// A save carries one player, so the restored world is a single-player one and
|
||||
// its player list is rebuilt rather than inherited: spreading a snapshot over a
|
||||
|
|
@ -204,9 +246,18 @@ export function restoreSnapshot(
|
|||
world: rebindPlayer({ ...snapshot.world, monsters: [...snapshot.world.monsters], players: [], events: [] }),
|
||||
rngState: snapshot.rngState >>> 0,
|
||||
rngHighState: (snapshot.rngHighState ?? 0) >>> 0,
|
||||
inventory: build.inventory(snapshot.inventory.width, snapshot.inventory.height, snapshot.inventory.placed),
|
||||
...(snapshot.castRngState !== undefined ? { castRngState: snapshot.castRngState >>> 0 } : {}),
|
||||
...(snapshot.castRngHighState !== undefined ? { castRngHighState: snapshot.castRngHighState >>> 0 } : {}),
|
||||
...(snapshot.hydraGroupSeqCounter !== undefined ? { hydraGroupSeqCounter: snapshot.hydraGroupSeqCounter } : {}),
|
||||
...(snapshot.groundItemIdCounter !== undefined ? { groundItemIdCounter: snapshot.groundItemIdCounter } : {}),
|
||||
inventory,
|
||||
quests: build.quests(snapshot.quests),
|
||||
ground: snapshot.ground.map(entry => ({ x: entry.x, y: entry.y, item: entry.item })),
|
||||
ground: snapshot.ground.map(entry => ({
|
||||
x: entry.x,
|
||||
y: entry.y,
|
||||
item: entry.item,
|
||||
...(entry.groundItemId !== undefined ? { groundItemId: entry.groundItemId } : {}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -346,3 +397,131 @@ export function createD2s(name: string, classIndex: number, level: number, bodyB
|
|||
const body = new Uint8Array(0x30 + bodyBytes)
|
||||
return writeD2s({ version: D2S_VERSION, name, classIndex, level, raw: body })
|
||||
}
|
||||
|
||||
export interface RuntimeSavePayload {
|
||||
readonly classCode: CharacterClassCode
|
||||
readonly player: {
|
||||
readonly level: number
|
||||
readonly xp: number
|
||||
readonly str: number
|
||||
readonly dex: number
|
||||
readonly vit: number
|
||||
readonly ene: number
|
||||
readonly statPoints: number
|
||||
readonly skillPoints: number
|
||||
readonly hp: number
|
||||
readonly maxHp: number
|
||||
readonly mana: number
|
||||
readonly maxMana: number
|
||||
readonly stamina: number
|
||||
readonly maxStamina: number
|
||||
}
|
||||
readonly gold: number
|
||||
readonly bagItems: readonly PlacedItem[]
|
||||
readonly beltSlots: readonly { readonly col: number; readonly row: number; readonly item: any }[]
|
||||
readonly allocatedSkills: readonly [number, number][]
|
||||
}
|
||||
|
||||
export function serializeSaveData(input: {
|
||||
readonly classCode: CharacterClassCode
|
||||
readonly player: CombatPlayer
|
||||
readonly bag?: { readonly gold: number; readonly contents: readonly PlacedItem[] } | undefined
|
||||
readonly belt?: { getSlot?: (col: number, row: number) => any } | undefined
|
||||
readonly skillPoints?: Map<number, number> | Record<number, number> | number | undefined
|
||||
}): string {
|
||||
const p = input.player
|
||||
const beltSlots: { col: number; row: number; item: any }[] = []
|
||||
if (input.belt && typeof input.belt.getSlot === 'function') {
|
||||
for (let row = 0; row < 4; row += 1) {
|
||||
for (let col = 0; col < 4; col += 1) {
|
||||
const item = input.belt.getSlot(col, row)
|
||||
if (item) beltSlots.push({ col, row, item })
|
||||
}
|
||||
}
|
||||
}
|
||||
const allocatedSkills: [number, number][] =
|
||||
input.skillPoints instanceof Map
|
||||
? Array.from(input.skillPoints.entries())
|
||||
: typeof input.skillPoints === 'object' && input.skillPoints !== null
|
||||
? Object.entries(input.skillPoints).map(([k, v]) => [Number(k), Number(v)])
|
||||
: []
|
||||
|
||||
const payload: RuntimeSavePayload = {
|
||||
classCode: input.classCode,
|
||||
player: {
|
||||
level: p.level ?? 1,
|
||||
xp: p.xp ?? 0,
|
||||
str: p.str ?? p.strength ?? 10,
|
||||
dex: p.dex ?? p.dexterity ?? 25,
|
||||
vit: p.vit ?? p.vitality ?? 10,
|
||||
ene: p.ene ?? p.energy ?? 35,
|
||||
statPoints: p.statPoints ?? 0,
|
||||
skillPoints: p.skillPoints ?? 0,
|
||||
hp: p.hp,
|
||||
maxHp: p.maxHp,
|
||||
mana: p.mana,
|
||||
maxMana: p.maxMana,
|
||||
stamina: p.stamina ?? 100,
|
||||
maxStamina: p.maxStamina ?? 100,
|
||||
},
|
||||
gold: input.bag?.gold ?? 0,
|
||||
bagItems: input.bag?.contents ?? [],
|
||||
beltSlots,
|
||||
allocatedSkills,
|
||||
}
|
||||
return JSON.stringify(payload)
|
||||
}
|
||||
|
||||
export function deserializeSaveData(raw: string): RuntimeSavePayload | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as RuntimeSavePayload
|
||||
if (!parsed || typeof parsed !== 'object' || !parsed.player) return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function applySaveDataToRuntime(
|
||||
data: RuntimeSavePayload,
|
||||
target: {
|
||||
readonly player: CombatPlayer
|
||||
readonly bag?: { gold: number; add?: (item: any) => any } | undefined
|
||||
readonly belt?: { setSlot?: (col: number, row: number, item: any) => any; clear?: () => void } | undefined
|
||||
readonly skillPoints?: Map<number, number> | undefined
|
||||
readonly setClassCode?: ((classCode: CharacterClassCode) => void) | undefined
|
||||
},
|
||||
): void {
|
||||
target.setClassCode?.(data.classCode)
|
||||
const p = target.player
|
||||
p.classCode = data.classCode
|
||||
p.level = data.player.level
|
||||
p.xp = data.player.xp
|
||||
p.str = data.player.str
|
||||
p.dex = data.player.dex
|
||||
p.vit = data.player.vit
|
||||
p.ene = data.player.ene
|
||||
p.statPoints = data.player.statPoints
|
||||
p.skillPoints = data.player.skillPoints
|
||||
p.maxHp = data.player.maxHp
|
||||
p.hp = data.player.hp
|
||||
p.maxMana = data.player.maxMana
|
||||
p.mana = data.player.mana
|
||||
p.maxStamina = data.player.maxStamina
|
||||
p.stamina = data.player.stamina
|
||||
|
||||
if (target.bag) {
|
||||
target.bag.gold = data.gold ?? 0
|
||||
}
|
||||
if (target.belt && typeof target.belt.setSlot === 'function') {
|
||||
for (const slot of data.beltSlots ?? []) {
|
||||
target.belt.setSlot(slot.col, slot.row, slot.item)
|
||||
}
|
||||
}
|
||||
if (target.skillPoints instanceof Map) {
|
||||
target.skillPoints.clear()
|
||||
for (const [k, v] of data.allocatedSkills ?? []) {
|
||||
target.skillPoints.set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,10 +47,14 @@ export async function loadItemsAtlas(
|
|||
'public/ui/items-atlas.png',
|
||||
].filter(Boolean)
|
||||
|
||||
const failures: string[] = []
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) continue
|
||||
if (!resp.ok) {
|
||||
failures.push(`${url} (HTTP ${resp.status})`)
|
||||
continue
|
||||
}
|
||||
const blob = await resp.blob()
|
||||
const bitmap = await createImageBitmap(blob)
|
||||
try {
|
||||
|
|
@ -60,26 +64,41 @@ export async function loadItemsAtlas(
|
|||
} finally {
|
||||
bitmap.close()
|
||||
}
|
||||
} catch {
|
||||
// try next candidate
|
||||
} catch (err) {
|
||||
failures.push(`${url}: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
const msg = `[loadItemsAtlas] Failed to load items-atlas.png from all candidates: ${failures.join('; ')}`
|
||||
console.error(msg)
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the authentic Blizzard flippy ground sprite rect for a ground item entity.
|
||||
* Resolve the authentic Blizzard flippy ground sprite frame key for a ground item entity or item.
|
||||
*/
|
||||
export function resolveGroundItemFlippyFrame(item: GroundItemEntity): SpriteRect | null {
|
||||
const target = item.isGold
|
||||
? { isGold: true, amount: item.amount, code: 'gld' }
|
||||
: (item.item || (item as any))
|
||||
|
||||
return resolveGroundItemSpriteRect(
|
||||
target,
|
||||
BAKED_UI_MANIFEST.flippyRects,
|
||||
BAKED_UI_MANIFEST.codeToFlippyFile,
|
||||
)
|
||||
export function resolveGroundItemFlippyFrame(item: GroundItemEntity | Record<string, unknown> | unknown): string {
|
||||
const raw = (item as any)?.item ?? item
|
||||
const code = typeof raw?.code === 'string' ? raw.code.trim().toLowerCase() : ''
|
||||
const isGold = Boolean((item as any)?.isGold || raw?.isGold || code === 'gld')
|
||||
if (isGold) {
|
||||
const amount = Number((item as any)?.amount ?? raw?.amount ?? raw?.value ?? 1)
|
||||
if (amount >= 5000) return 'flpgld_3'
|
||||
if (amount >= 500) return 'flpgld_2'
|
||||
if (amount >= 100) return 'flpgld_1'
|
||||
return 'flpgld_0'
|
||||
}
|
||||
const directFlp = (item as any)?.flippyFile ?? raw?.flippyFile
|
||||
if (typeof directFlp === 'string' && directFlp.length > 0) {
|
||||
return directFlp.toLowerCase()
|
||||
}
|
||||
const name = typeof raw?.name === 'string' ? raw.name : typeof (item as any)?.name === 'string' ? (item as any).name : ''
|
||||
if (name && BAKED_UI_MANIFEST.codeToFlippyFile[name]) {
|
||||
return BAKED_UI_MANIFEST.codeToFlippyFile[name]!
|
||||
}
|
||||
if (code && BAKED_UI_MANIFEST.codeToFlippyFile[code]) {
|
||||
return BAKED_UI_MANIFEST.codeToFlippyFile[code]!
|
||||
}
|
||||
return code ? `flp${code}` : 'flpcap'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -102,7 +121,7 @@ export function drawGroundItem(
|
|||
renderer: SpriteRenderer,
|
||||
item: GroundItemEntity,
|
||||
now: number,
|
||||
itemsAtlas: AtlasHandle | undefined = globalItemsAtlasHandle,
|
||||
itemsAtlas: AtlasHandle | { readonly handle?: AtlasHandle | number; readonly frames?: unknown } | null | undefined = globalItemsAtlasHandle,
|
||||
): void {
|
||||
const bounceH = item.bounceState ? calculateBounceHeight(item.bounceState, now) : 0
|
||||
const baseY = item.y - bounceH
|
||||
|
|
@ -157,9 +176,16 @@ export function drawGroundItem(
|
|||
height: sr.h,
|
||||
}
|
||||
|
||||
const effectiveAtlas: AtlasHandle | undefined =
|
||||
itemsAtlas && typeof itemsAtlas === 'object' && 'texture' in itemsAtlas
|
||||
? (itemsAtlas as AtlasHandle)
|
||||
: itemsAtlas && typeof itemsAtlas === 'object' && 'handle' in itemsAtlas && itemsAtlas.handle && typeof itemsAtlas.handle === 'object' && 'texture' in itemsAtlas.handle
|
||||
? (itemsAtlas.handle as AtlasHandle)
|
||||
: undefined
|
||||
|
||||
if (typeof (renderer as any).draw === 'function') {
|
||||
renderer.draw(frame, drawX, drawY, {
|
||||
...(itemsAtlas !== undefined ? { atlas: itemsAtlas } : {}),
|
||||
...(effectiveAtlas !== undefined ? { atlas: effectiveAtlas } : {}),
|
||||
width: baseW,
|
||||
height: baseH,
|
||||
})
|
||||
|
|
@ -176,7 +202,7 @@ export function drawGroundItem(
|
|||
const x1 = x3 + shearX
|
||||
const y1 = baseY - groundH
|
||||
;(renderer as any).drawQuad(frame, x0, y0, x1, y1, x2, y2, x3, y3, {
|
||||
atlas: itemsAtlas,
|
||||
atlas: effectiveAtlas,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { type AudioManager, sharedAudioManager } from '../../audio/manager.ts'
|
||||
import type { ActorAnimator } from '../../game/actor-animator.ts'
|
||||
import type { Monster } from '../../game/combat.ts'
|
||||
import type { GameEngine, NpcEntity } from '../../game/engine.ts'
|
||||
|
|
@ -54,6 +55,20 @@ import { monsterToTargetHudInfo } from './scene-state.ts'
|
|||
|
||||
export { playInventoryFullFeedback, showNotification }
|
||||
|
||||
/**
|
||||
* Play synthesized Diablo II-style stash open sound via the shared AudioManager singleton.
|
||||
*/
|
||||
export function playStashOpenSound(audioManager: AudioManager = sharedAudioManager): void {
|
||||
audioManager.playTone({
|
||||
type: 'triangle',
|
||||
from: 140,
|
||||
to: 70,
|
||||
gain: 0.3,
|
||||
durationS: 0.15,
|
||||
delayS: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export type PendingInteraction =
|
||||
| { readonly kind: 'npc'; readonly npc: NpcEntity }
|
||||
| { readonly kind: 'waypoint'; readonly waypoint: SceneWaypoint; readonly worldPos: { readonly x: number; readonly y: number } }
|
||||
|
|
@ -80,6 +95,8 @@ export interface SceneMouseControllerOptions {
|
|||
readonly hudManager: HudManager | null
|
||||
readonly waypointNetwork: WaypointNetwork
|
||||
readonly status: HTMLElement
|
||||
readonly audioManager?: AudioManager | undefined
|
||||
readonly queuePointerEvents?: boolean | undefined
|
||||
readonly playerAnimator?: ActorAnimator | undefined
|
||||
readonly getCharacter?: (() => LoadedCharacter | null) | undefined
|
||||
readonly portalSlot?: TownPortalSlot | undefined
|
||||
|
|
@ -103,6 +120,9 @@ export class SceneMouseController {
|
|||
readonly hudManager: HudManager | null
|
||||
readonly waypointNetwork: WaypointNetwork
|
||||
readonly status: HTMLElement
|
||||
readonly audioManager?: AudioManager | undefined
|
||||
readonly queuePointerEvents: boolean
|
||||
readonly pendingActions: Array<() => void> = []
|
||||
readonly playerAnimator?: ActorAnimator | undefined
|
||||
readonly getCharacter?: (() => LoadedCharacter | null) | undefined
|
||||
readonly portalSlot?: TownPortalSlot | undefined
|
||||
|
|
@ -140,6 +160,8 @@ export class SceneMouseController {
|
|||
this.hudManager = options.hudManager
|
||||
this.waypointNetwork = options.waypointNetwork
|
||||
this.status = options.status
|
||||
this.audioManager = options.audioManager
|
||||
this.queuePointerEvents = options.queuePointerEvents ?? false
|
||||
this.playerAnimator = options.playerAnimator
|
||||
this.getCharacter = options.getCharacter
|
||||
this.portalSlot = options.portalSlot
|
||||
|
|
@ -446,6 +468,7 @@ export class SceneMouseController {
|
|||
const dist = Math.hypot(item.x - player.x, item.y - player.y)
|
||||
const maxEdgeReach = Math.max(64, (this.engine.opts as any)?.pickupRadius ?? 64)
|
||||
const isEn = getTblLang() === 'ENG'
|
||||
const tickNowMs = (this.engine.world?.tick ?? 0) * 40
|
||||
if (isWithinPickupBounds(player.x, player.y, item.x, item.y, maxEdgeReach)) {
|
||||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
|
|
@ -455,13 +478,27 @@ export class SceneMouseController {
|
|||
player.facing = facingOf(item.x - player.x, item.y - player.y)
|
||||
|
||||
if (item.isGold) {
|
||||
if (
|
||||
this.hudManager &&
|
||||
this.engine.bag &&
|
||||
this.engine.bag.gold === 0 &&
|
||||
this.hudManager.inventory.gold > 0 &&
|
||||
this.hudManager.inventory.gold !== 850_000
|
||||
) {
|
||||
this.engine.bag.gold = this.hudManager.inventory.gold
|
||||
}
|
||||
const prevHudGold = this.hudManager?.inventory.gold ?? 0
|
||||
const res = this.engine.pickupGold(item.id)
|
||||
if (res.success) {
|
||||
if (this.hudManager) {
|
||||
const playerLevel = this.engine.world.player?.level ?? 1
|
||||
const goldCap = getInventoryGoldLimit(playerLevel)
|
||||
this.hudManager.inventory.playerLevel = playerLevel
|
||||
this.hudManager.inventory.gold = Math.min(goldCap, this.hudManager.inventory.gold + res.amount)
|
||||
const nextEngineGold = typeof this.engine.gold === 'number' ? this.engine.gold : 0
|
||||
this.hudManager.inventory.gold = Math.min(
|
||||
goldCap,
|
||||
nextEngineGold > 0 ? nextEngineGold : prevHudGold + res.amount,
|
||||
)
|
||||
this.hudManager.syncPublishedState()
|
||||
}
|
||||
if (res.remaining && res.remaining > 0) {
|
||||
|
|
@ -475,7 +512,7 @@ export class SceneMouseController {
|
|||
}
|
||||
} else {
|
||||
this.engine.metrics.inventoryRefusals += 1
|
||||
triggerFlippyBounce(item, typeof performance !== 'undefined' ? performance.now() : Date.now())
|
||||
triggerFlippyBounce(item, tickNowMs)
|
||||
this.playInventoryFullFeedback()
|
||||
this.status.textContent = isEn ? 'Gold limit reached.' : '金币已达携带上限。'
|
||||
this.showNotification(isEn ? 'Cannot carry any more gold.' : '无法携带更多金币。')
|
||||
|
|
@ -488,14 +525,16 @@ export class SceneMouseController {
|
|||
const placed = this.hudManager.inventory.autoPlaceInGrid(uiItem)
|
||||
if (!placed) {
|
||||
this.engine.metrics.inventoryRefusals += 1
|
||||
triggerFlippyBounce(item, typeof performance !== 'undefined' ? performance.now() : Date.now())
|
||||
triggerFlippyBounce(item, tickNowMs)
|
||||
this.playInventoryFullFeedback()
|
||||
this.showNotification(isEn ? 'Inventory is full.' : '包裹已满。')
|
||||
return
|
||||
}
|
||||
}
|
||||
this.engine.groundItems.remove(item.id)
|
||||
const gIdx = this.engine.ground.findIndex(g => Math.hypot(g.x - item.x, g.y - item.y) <= 32 || g.item === item.item)
|
||||
const gIdx = this.engine.ground.findIndex(
|
||||
g => (g as { groundItemId?: string }).groundItemId === item.id || g.item === item.item,
|
||||
)
|
||||
if (gIdx !== -1) {
|
||||
this.engine.ground.splice(gIdx, 1)
|
||||
}
|
||||
|
|
@ -527,11 +566,11 @@ export class SceneMouseController {
|
|||
}
|
||||
|
||||
/**
|
||||
* Play authentic Diablo II audio rejection feedback when inventory is full.
|
||||
* Play synthesized Diablo II-style audio rejection feedback when inventory is full.
|
||||
* Low descending refusal tone paired with delayed secondary touch landing thud.
|
||||
*/
|
||||
playInventoryFullFeedback(): void {
|
||||
playInventoryFullFeedback()
|
||||
playInventoryFullFeedback(this.audioManager ?? sharedAudioManager)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -542,24 +581,13 @@ export class SceneMouseController {
|
|||
}
|
||||
|
||||
playStashOpenSound(): void {
|
||||
try {
|
||||
const AudioCtx = typeof window !== 'undefined' ? (window.AudioContext || (window as any).webkitAudioContext) : null
|
||||
if (AudioCtx) {
|
||||
const ctx = new AudioCtx()
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.type = 'triangle'
|
||||
osc.frequency.setValueAtTime(140, ctx.currentTime)
|
||||
osc.frequency.exponentialRampToValueAtTime(70, ctx.currentTime + 0.12)
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15)
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
osc.start()
|
||||
osc.stop(ctx.currentTime + 0.15)
|
||||
}
|
||||
} catch {
|
||||
// AudioContext unavailable or blocked
|
||||
playStashOpenSound(this.audioManager ?? sharedAudioManager)
|
||||
}
|
||||
|
||||
flushPendingActions(): void {
|
||||
while (this.pendingActions.length > 0) {
|
||||
const action = this.pendingActions.shift()
|
||||
action?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -586,43 +614,52 @@ export class SceneMouseController {
|
|||
this.lastClientPos = { x: e.clientX, y: e.clientY }
|
||||
const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY)
|
||||
|
||||
if (e.button === 0) {
|
||||
this.isLeftMouseDown = true
|
||||
if (this.input.shiftHeld) {
|
||||
// R4: Shift-hold force stand still attack
|
||||
this.activeClickType = 'none'
|
||||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
const player = this.engine.world.player
|
||||
player.facing = facingOf(worldX - player.x, worldY - player.y)
|
||||
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
|
||||
this.castSkill(leftSkillId, worldX, worldY)
|
||||
if (this.hudManager !== null) {
|
||||
const activeTarget = this.getActiveTargetMonster()
|
||||
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
|
||||
const runAction = (): void => {
|
||||
if (this.isTravelling?.()) return
|
||||
if (e.button === 0) {
|
||||
this.isLeftMouseDown = true
|
||||
if (this.input.shiftHeld) {
|
||||
// R4: Shift-hold force stand still attack
|
||||
this.activeClickType = 'none'
|
||||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
const player = this.engine.world.player
|
||||
player.facing = facingOf(worldX - player.x, worldY - player.y)
|
||||
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
|
||||
this.castSkill(leftSkillId, worldX, worldY)
|
||||
if (this.hudManager !== null) {
|
||||
const activeTarget = this.getActiveTargetMonster()
|
||||
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
|
||||
}
|
||||
} else {
|
||||
// R3: Left-click click-to-move & entity interaction
|
||||
this.handleLeftClickWorld(worldX, worldY)
|
||||
}
|
||||
} else {
|
||||
// R3: Left-click click-to-move & entity interaction
|
||||
this.handleLeftClickWorld(worldX, worldY)
|
||||
}
|
||||
} else if (e.button === 2) {
|
||||
this.isRightMouseDown = true
|
||||
if (this.input.shiftHeld) {
|
||||
this.activeClickType = 'none'
|
||||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
const player = this.engine.world.player
|
||||
player.facing = facingOf(worldX - player.x, worldY - player.y)
|
||||
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
|
||||
this.castSkill(rightSkillId, worldX, worldY)
|
||||
if (this.hudManager !== null) {
|
||||
const activeTarget = this.getActiveTargetMonster()
|
||||
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
|
||||
} else if (e.button === 2) {
|
||||
this.isRightMouseDown = true
|
||||
if (this.input.shiftHeld) {
|
||||
this.activeClickType = 'none'
|
||||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
const player = this.engine.world.player
|
||||
player.facing = facingOf(worldX - player.x, worldY - player.y)
|
||||
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
|
||||
this.castSkill(rightSkillId, worldX, worldY)
|
||||
if (this.hudManager !== null) {
|
||||
const activeTarget = this.getActiveTargetMonster()
|
||||
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
|
||||
}
|
||||
} else {
|
||||
this.handleRightClickWorld(worldX, worldY)
|
||||
}
|
||||
} else {
|
||||
this.handleRightClickWorld(worldX, worldY)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.queuePointerEvents) {
|
||||
this.pendingActions.push(runAction)
|
||||
} else {
|
||||
runAction()
|
||||
}
|
||||
}
|
||||
|
||||
readonly onPointerMove = (e: PointerEvent): void => {
|
||||
|
|
@ -806,9 +843,11 @@ export class SceneMouseController {
|
|||
if (descriptor) {
|
||||
if (descriptor.canHeal) {
|
||||
if (player.maxHp !== undefined) player.hp = player.maxHp
|
||||
if (player.maxMana !== undefined) player.mana = player.maxMana
|
||||
if ((player as any).maxStamina !== undefined) (player as any).stamina = (player as any).maxStamina
|
||||
if (this.hudManager) {
|
||||
this.hudManager.hp = this.hudManager.maxHp
|
||||
this.hudManager.mana = this.hudManager.maxMana
|
||||
this.hudManager.hp = player.maxHp ?? this.hudManager.maxHp
|
||||
this.hudManager.mana = player.maxMana ?? this.hudManager.maxMana
|
||||
this.hudManager.stamina = this.hudManager.maxStamina
|
||||
}
|
||||
}
|
||||
|
|
@ -1153,6 +1192,7 @@ export class SceneMouseController {
|
|||
}
|
||||
|
||||
clearTarget(): void {
|
||||
this.pendingActions.length = 0
|
||||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
this.activeClickType = 'none'
|
||||
|
|
@ -1183,6 +1223,7 @@ export class SceneMouseController {
|
|||
}
|
||||
|
||||
tick(): { movement: { x: number; y: number }; attacking: boolean } {
|
||||
this.flushPendingActions()
|
||||
if (this.isTravelling?.()) {
|
||||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { type AudioManager, sharedAudioManager } from '../../audio/manager.ts'
|
||||
import type { Monster } from '../../game/combat.ts'
|
||||
import type { GameEngine } from '../../game/engine.ts'
|
||||
import type { GroundItemEntity } from '../../game/ground-items.ts'
|
||||
|
|
@ -78,51 +79,30 @@ export function projectNpcLabel(params: NpcLabelProjectionParams): NpcLabelProje
|
|||
}
|
||||
|
||||
/**
|
||||
* Play authentic Diablo II audio rejection feedback when inventory is full.
|
||||
* Play synthesized Diablo II-style audio rejection feedback when inventory is full.
|
||||
* Low descending refusal tone paired with delayed secondary touch landing thud.
|
||||
*/
|
||||
export function playInventoryFullFeedback(): void {
|
||||
try {
|
||||
const AudioCtx = typeof window !== 'undefined' ? (window.AudioContext || (window as any).webkitAudioContext) : null
|
||||
if (AudioCtx) {
|
||||
const ctx = new AudioCtx()
|
||||
// 1. Refusal tone (low pitch descending rejection cue)
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.type = 'sawtooth'
|
||||
osc.frequency.setValueAtTime(175, ctx.currentTime)
|
||||
osc.frequency.exponentialRampToValueAtTime(90, ctx.currentTime + 0.28)
|
||||
gain.gain.setValueAtTime(0.3, ctx.currentTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3)
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
osc.start()
|
||||
osc.stop(ctx.currentTime + 0.3)
|
||||
|
||||
// 2. Delayed light ground touch thud upon primary bounce landing (~350ms)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (ctx.state !== 'closed') {
|
||||
const thudOsc = ctx.createOscillator()
|
||||
const thudGain = ctx.createGain()
|
||||
thudOsc.type = 'triangle'
|
||||
thudOsc.frequency.setValueAtTime(110, ctx.currentTime)
|
||||
thudOsc.frequency.exponentialRampToValueAtTime(45, ctx.currentTime + 0.08)
|
||||
thudGain.gain.setValueAtTime(0.2, ctx.currentTime)
|
||||
thudGain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1)
|
||||
thudOsc.connect(thudGain)
|
||||
thudGain.connect(ctx.destination)
|
||||
thudOsc.start()
|
||||
thudOsc.stop(ctx.currentTime + 0.1)
|
||||
}
|
||||
} catch {
|
||||
// Ignore closed audio context
|
||||
}
|
||||
}, 350)
|
||||
}
|
||||
} catch {
|
||||
// AudioContext unavailable or blocked
|
||||
export function playInventoryFullFeedback(audioManager: AudioManager = sharedAudioManager): void {
|
||||
if (typeof audioManager?.playSfx === 'function') {
|
||||
audioManager.playSfx('inventory_full')
|
||||
return
|
||||
}
|
||||
audioManager?.playTone?.({
|
||||
type: 'sawtooth',
|
||||
from: 175,
|
||||
to: 90,
|
||||
gain: 0.3,
|
||||
durationS: 0.3,
|
||||
delayS: 0,
|
||||
})
|
||||
audioManager?.playTone?.({
|
||||
type: 'triangle',
|
||||
from: 110,
|
||||
to: 45,
|
||||
gain: 0.2,
|
||||
durationS: 0.1,
|
||||
delayS: 0.35,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@ describe('Issue #479 — Ground Item Drop Spacing, Metadata Parity & Anti-Stacki
|
|||
|
||||
it('scatters multiple gold piles dropped from player inventory onto distinct sub-tile slots without stacking', () => {
|
||||
const engine = createEngine()
|
||||
engine.bag.gold = 5000
|
||||
const playerX = engine.world.player.x
|
||||
const playerY = engine.world.player.y
|
||||
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ describe('Issue #388 — Ground Item & Drop System Ground Truth', () => {
|
|||
},
|
||||
)
|
||||
|
||||
const goldEntity = engine.dropGold(5000, 150, 160)
|
||||
const goldEntity = engine.dropGold(5000, 150, 160, undefined, undefined, { fromWorld: true })
|
||||
expect(goldEntity).toBeDefined()
|
||||
expect(goldEntity!.isGold).toBe(true)
|
||||
expect(goldEntity!.amount).toBe(5000)
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ describe('Ground Items Pickup & Pathfinding (Issue #391)', () => {
|
|||
describe('GameEngine Pickup Methods', () => {
|
||||
it('successfully picks up gold, increments metrics, and removes from groundItems', () => {
|
||||
const engine = createTestEngine()
|
||||
const gold = engine.dropGold(500, 510, 510)
|
||||
const gold = engine.dropGold(500, 510, 510, undefined, undefined, { fromWorld: true })
|
||||
expect(gold).not.toBeNull()
|
||||
expect(engine.groundItems.count).toBe(1)
|
||||
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ describe('Issue #492: Prevent Ground Items & Gold from Dropping into Unreachable
|
|||
insideWallPos.x,
|
||||
insideWallPos.y,
|
||||
)
|
||||
const goldEntity = engine.dropGold(250, insideWallPos.x, insideWallPos.y)!
|
||||
const goldEntity = engine.dropGold(250, insideWallPos.x, insideWallPos.y, undefined, undefined, { fromWorld: true })!
|
||||
|
||||
expect(terrain.overlap(itemEntity.x, itemEntity.y)).toBe(0)
|
||||
expect(terrain.overlap(goldEntity.x, goldEntity.y)).toBe(0)
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ describe('Milestone M11.3 (Issue #406) — Ground Item & Gold Spawning, Isometri
|
|||
})
|
||||
|
||||
// Drop 2500 gold
|
||||
const goldEntity = engine.dropGold(2500, 520, 520)
|
||||
const goldEntity = engine.dropGold(2500, 520, 520, undefined, undefined, { fromWorld: true })
|
||||
expect(goldEntity).toBeDefined()
|
||||
expect(engine.groundItems.count).toBe(1)
|
||||
expect(engine.ground.length).toBe(1)
|
||||
|
|
@ -219,7 +219,7 @@ describe('Milestone M11.3 (Issue #406) — Ground Item & Gold Spawning, Isometri
|
|||
inventoryRows: 4,
|
||||
})
|
||||
|
||||
const goldEntity = engine.dropGold(1200, 510, 510)
|
||||
const goldEntity = engine.dropGold(1200, 510, 510, undefined, undefined, { fromWorld: true })
|
||||
const res = engine.pickupItem(goldEntity!.id)
|
||||
expect(res.success).toBe(true)
|
||||
expect(engine.bag.gold).toBe(1200)
|
||||
|
|
@ -250,7 +250,7 @@ describe('Milestone M11.3 (Issue #406) — Ground Item & Gold Spawning, Isometri
|
|||
})
|
||||
|
||||
// Drop gold 10px away from player
|
||||
engine.dropGold(750, 505, 505)
|
||||
engine.dropGold(750, 505, 505, undefined, undefined, { fromWorld: true })
|
||||
expect(engine.bag.gold).toBe(0)
|
||||
|
||||
// Player picks up
|
||||
|
|
@ -340,7 +340,7 @@ describe('Milestone M11.3 (Issue #406) — Ground Item & Gold Spawning, Isometri
|
|||
expect(item.cellY).toBe(0)
|
||||
expect(item.cellX !== 0 || item.cellY !== 0).toBe(true)
|
||||
|
||||
const gold = engine.dropGold(100, 160, 240)
|
||||
const gold = engine.dropGold(100, 160, 240, undefined, undefined, { fromWorld: true })
|
||||
// (160, 240): px = 160, py = 240
|
||||
// cellX = floor((160/80 + 240/40) / 2) = floor((2 + 6) / 2) = 4
|
||||
// cellY = floor((240/40 - 160/80) / 2) = floor((6 - 2) / 2) = 2
|
||||
|
|
|
|||
|
|
@ -623,7 +623,7 @@ describe('Milestone M11.5 (Issue #408) — Monster Drop End-to-End Integration S
|
|||
}
|
||||
|
||||
// Drop 10,000 gold pile on ground (cap is 100,000)
|
||||
const goldDrop = engine.dropGold(10_000, 510, 500)
|
||||
const goldDrop = engine.dropGold(10_000, 510, 500, undefined, undefined, { fromWorld: true })
|
||||
expect(goldDrop).not.toBeNull()
|
||||
|
||||
const controller = new SceneMouseController({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { GameEngine } from '../src/game/engine.ts'
|
||||
import type { Item } from '../src/game/items.ts'
|
||||
|
||||
function makeTestItem(id: number, code: string, name: string): Item {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
name,
|
||||
type: 'misc',
|
||||
quality: 'normal',
|
||||
level: 1,
|
||||
identified: true,
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
stats: [],
|
||||
baseCost: 50,
|
||||
} as unknown as Item
|
||||
}
|
||||
|
||||
describe('Issue #522: Ground Items Single Source of Truth (engine.groundItems)', () => {
|
||||
it('keeps engine.ground strictly synchronized as a read-only view over engine.groundItems', () => {
|
||||
const engine = new GameEngine('nec')
|
||||
const itemA = makeTestItem(1, 'hp1', 'Minor Healing Potion')
|
||||
const itemB = makeTestItem(2, 'mp1', 'Minor Mana Potion')
|
||||
|
||||
const gA = engine.dropItemToGround(itemA, 100, 100)
|
||||
const gB = engine.dropItemToGround(itemB, 108, 108)
|
||||
|
||||
expect(engine.groundItems.count).toBe(2)
|
||||
expect(engine.ground.length).toBe(2)
|
||||
expect(engine.ground.map(g => g.id)).toEqual([gA.id, gB.id])
|
||||
|
||||
// Picking up gA by exact ID removes only gA and preserves nearby gB
|
||||
const picked = engine.pickupGroundEntity(gA.id)
|
||||
expect(picked).toBe(true)
|
||||
expect(engine.groundItems.count).toBe(1)
|
||||
expect(engine.ground.length).toBe(1)
|
||||
expect(engine.ground[0]!.id).toBe(gB.id)
|
||||
expect(engine.bag.items.some(p => p.item.id === itemA.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('saves and restores ground items without phantom duplicates on level transition', () => {
|
||||
const engine = new GameEngine('dru')
|
||||
const item1 = makeTestItem(10, 'rvl', 'Full Rejuvenation Potion')
|
||||
engine.dropItemToGround(item1, 200, 200)
|
||||
engine.groundItems.spawnGold(210, 210, 350, 0)
|
||||
|
||||
expect(engine.groundItems.count).toBe(2)
|
||||
|
||||
// Transition from level 1 -> level 2
|
||||
engine.saveLevelGroundState(1)
|
||||
engine.restoreLevelGroundState(2)
|
||||
expect(engine.groundItems.count).toBe(0)
|
||||
expect(engine.ground.length).toBe(0)
|
||||
|
||||
// Return to level 1
|
||||
engine.saveLevelGroundState(2)
|
||||
engine.restoreLevelGroundState(1)
|
||||
expect(engine.groundItems.count).toBe(2)
|
||||
expect(engine.ground.length).toBe(2)
|
||||
expect(engine.ground.some(g => g.isGold && g.goldAmount === 350)).toBe(true)
|
||||
expect(engine.ground.some(g => !g.isGold && g.item?.code === 'rvl')).toBe(true)
|
||||
|
||||
// Verify loadSnapshot also preserves non-enumerable .id getter on restored.ground entries
|
||||
const drop1 = engine.ground[0]!
|
||||
const snap = engine.saveSnapshot()
|
||||
const restored = new GameEngine('dru')
|
||||
restored.loadSnapshot(snap)
|
||||
expect(restored.ground[0]!.id).toBe(drop1.id)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue