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:
troytt 2026-09-29 21:01:48 +00:00
parent 206b59e72d
commit 231d375e62
12 changed files with 513 additions and 149 deletions

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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,
})
}

View File

@ -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

View File

@ -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,
})
}
/**

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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({

View File

@ -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)
})
})