From 231d375e62bc68637436f72dd9e401fa340712a2 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Tue, 29 Sep 2026 21:01:48 +0000 Subject: [PATCH] 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 --- src/game/ground-items.ts | 93 ++++++++-- src/game/save.ts | 195 ++++++++++++++++++++- src/scene/frontend/ground-item-renderer.ts | 62 +++++-- src/scene/frontend/mouse-controller.ts | 159 ++++++++++------- src/scene/frontend/overhead-labels.ts | 64 +++---- tests/ground-drop-parity-issues.test.ts | 1 + tests/ground-items-drop.test.ts | 2 +- tests/ground-items-pickup.test.ts | 2 +- tests/ground-items-reachability.test.ts | 2 +- tests/ground-items-scatter-bounce.test.ts | 8 +- tests/monster-drop-e2e.test.ts | 2 +- tests/p0-522-ground-items.test.ts | 72 ++++++++ 12 files changed, 513 insertions(+), 149 deletions(-) create mode 100644 tests/p0-522-ground-items.test.ts diff --git a/src/game/ground-items.ts b/src/game/ground-items.ts index fa6defd..02eaa1d 100644 --- a/src/game/ground-items.ts +++ b/src/game/ground-items.ts @@ -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, ): void { entity.bounceState = { @@ -1343,6 +1344,20 @@ export function findIsometricDropPosition( export class GroundItemManager { private readonly items = new Map() 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 diff --git a/src/game/save.ts b/src/game/save.ts index 5a5a927..b9ea6b3 100644 --- a/src/game/save.ts +++ b/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 | Record | 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 | 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) + } + } +} diff --git a/src/scene/frontend/ground-item-renderer.ts b/src/scene/frontend/ground-item-renderer.ts index f0293ef..6d63d85 100644 --- a/src/scene/frontend/ground-item-renderer.ts +++ b/src/scene/frontend/ground-item-renderer.ts @@ -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 | 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, }) } diff --git a/src/scene/frontend/mouse-controller.ts b/src/scene/frontend/mouse-controller.ts index 9268152..440c7d9 100644 --- a/src/scene/frontend/mouse-controller.ts +++ b/src/scene/frontend/mouse-controller.ts @@ -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 diff --git a/src/scene/frontend/overhead-labels.ts b/src/scene/frontend/overhead-labels.ts index e0154d4..e293de8 100644 --- a/src/scene/frontend/overhead-labels.ts +++ b/src/scene/frontend/overhead-labels.ts @@ -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, + }) } /** diff --git a/tests/ground-drop-parity-issues.test.ts b/tests/ground-drop-parity-issues.test.ts index 6ae08e5..23c2287 100644 --- a/tests/ground-drop-parity-issues.test.ts +++ b/tests/ground-drop-parity-issues.test.ts @@ -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 diff --git a/tests/ground-items-drop.test.ts b/tests/ground-items-drop.test.ts index c3c905f..cacef0a 100644 --- a/tests/ground-items-drop.test.ts +++ b/tests/ground-items-drop.test.ts @@ -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) diff --git a/tests/ground-items-pickup.test.ts b/tests/ground-items-pickup.test.ts index 61b00d9..bd97d50 100644 --- a/tests/ground-items-pickup.test.ts +++ b/tests/ground-items-pickup.test.ts @@ -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) diff --git a/tests/ground-items-reachability.test.ts b/tests/ground-items-reachability.test.ts index 19fcfd8..9f06bad 100644 --- a/tests/ground-items-reachability.test.ts +++ b/tests/ground-items-reachability.test.ts @@ -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) diff --git a/tests/ground-items-scatter-bounce.test.ts b/tests/ground-items-scatter-bounce.test.ts index 3c11c79..6b668fe 100644 --- a/tests/ground-items-scatter-bounce.test.ts +++ b/tests/ground-items-scatter-bounce.test.ts @@ -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 diff --git a/tests/monster-drop-e2e.test.ts b/tests/monster-drop-e2e.test.ts index d93995c..892c08d 100644 --- a/tests/monster-drop-e2e.test.ts +++ b/tests/monster-drop-e2e.test.ts @@ -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({ diff --git a/tests/p0-522-ground-items.test.ts b/tests/p0-522-ground-items.test.ts new file mode 100644 index 0000000..4be6695 --- /dev/null +++ b/tests/p0-522-ground-items.test.ts @@ -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) + }) +})