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 quality: string
|
||||||
readonly isGold: boolean
|
readonly isGold: boolean
|
||||||
amount: number
|
amount: number
|
||||||
|
readonly goldAmount?: number | undefined
|
||||||
readonly invWidth: number
|
readonly invWidth: number
|
||||||
readonly invHeight: number
|
readonly invHeight: number
|
||||||
readonly dropTime: number
|
readonly dropTime: number
|
||||||
|
|
@ -387,7 +388,7 @@ export function calculateBounceHeight(bounceState: GroundItemBounceState, now: n
|
||||||
*/
|
*/
|
||||||
export function triggerFlippyBounce(
|
export function triggerFlippyBounce(
|
||||||
entity: GroundItemEntity,
|
entity: GroundItemEntity,
|
||||||
now: number = typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
now = 0,
|
||||||
options?: Partial<GroundItemBounceState>,
|
options?: Partial<GroundItemBounceState>,
|
||||||
): void {
|
): void {
|
||||||
entity.bounceState = {
|
entity.bounceState = {
|
||||||
|
|
@ -1343,6 +1344,20 @@ export function findIsometricDropPosition(
|
||||||
export class GroundItemManager {
|
export class GroundItemManager {
|
||||||
private readonly items = new Map<string, GroundItemEntity>()
|
private readonly items = new Map<string, GroundItemEntity>()
|
||||||
private idCounter = 1
|
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. */
|
/** All active ground items. */
|
||||||
get all(): readonly GroundItemEntity[] {
|
get all(): readonly GroundItemEntity[] {
|
||||||
|
|
@ -1362,16 +1377,38 @@ export class GroundItemManager {
|
||||||
worldX: number,
|
worldX: number,
|
||||||
worldY: number,
|
worldY: number,
|
||||||
options?: {
|
options?: {
|
||||||
|
id?: string | undefined
|
||||||
amount?: number | undefined
|
amount?: number | undefined
|
||||||
bounce?: boolean | undefined
|
bounce?: boolean | undefined
|
||||||
peakHeight?: number | undefined
|
peakHeight?: number | undefined
|
||||||
|
peakHeightPx?: number | undefined
|
||||||
durationMs?: number | undefined
|
durationMs?: number | undefined
|
||||||
|
bounceDurationMs?: number | undefined
|
||||||
now?: number | undefined
|
now?: number | undefined
|
||||||
},
|
},
|
||||||
): GroundItemEntity {
|
): 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 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 = {
|
const entity: GroundItemEntity = {
|
||||||
id,
|
id,
|
||||||
|
|
@ -1388,12 +1425,12 @@ export class GroundItemManager {
|
||||||
y: worldY,
|
y: worldY,
|
||||||
cellX,
|
cellX,
|
||||||
cellY,
|
cellY,
|
||||||
sparklePhase: Math.random() * Math.PI * 2,
|
sparklePhase,
|
||||||
bounceState: options?.bounce
|
bounceState: hasBounce
|
||||||
? {
|
? {
|
||||||
startTime: now,
|
startTime: now,
|
||||||
durationMs: options?.durationMs ?? 400,
|
durationMs: options?.durationMs ?? options?.bounceDurationMs ?? 400,
|
||||||
peakHeightPx: options?.peakHeight ?? 24,
|
peakHeightPx: options?.peakHeight ?? options?.peakHeightPx ?? 24,
|
||||||
phase: 'primary',
|
phase: 'primary',
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -1403,11 +1440,34 @@ export class GroundItemManager {
|
||||||
...(meta.ethereal !== undefined ? { ethereal: meta.ethereal } : {}),
|
...(meta.ethereal !== undefined ? { ethereal: meta.ethereal } : {}),
|
||||||
...(meta.sockets !== undefined ? { sockets: meta.sockets } : {}),
|
...(meta.sockets !== undefined ? { sockets: meta.sockets } : {}),
|
||||||
}
|
}
|
||||||
|
Object.defineProperty(entity, 'goldAmount', {
|
||||||
|
get: () => entity.amount,
|
||||||
|
enumerable: false,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
|
||||||
this.items.set(id, entity)
|
this.items.set(id, entity)
|
||||||
|
this.onAdd?.(entity)
|
||||||
return 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
|
* Advance ground item bounce states and trigger authentic drop SFX
|
||||||
* when animation reaches dropsfxframe (or on bounce landing).
|
* when animation reaches dropsfxframe (or on bounce landing).
|
||||||
|
|
@ -1444,31 +1504,36 @@ export class GroundItemManager {
|
||||||
/** Add an existing ground item entity. */
|
/** Add an existing ground item entity. */
|
||||||
addEntity(entity: GroundItemEntity): GroundItemEntity {
|
addEntity(entity: GroundItemEntity): GroundItemEntity {
|
||||||
this.items.set(entity.id, entity)
|
this.items.set(entity.id, entity)
|
||||||
|
this.onAdd?.(entity)
|
||||||
return entity
|
return entity
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a ground item by id (e.g. when picked up). */
|
/** Remove a ground item by id (e.g. when picked up). */
|
||||||
remove(id: string): GroundItemEntity | undefined {
|
remove(id: string | number): GroundItemEntity | undefined {
|
||||||
const entity = this.items.get(id)
|
const strId = String(id)
|
||||||
|
const entity = this.items.get(strId)
|
||||||
if (entity) {
|
if (entity) {
|
||||||
this.items.delete(id)
|
this.items.delete(strId)
|
||||||
|
this.onRemove?.(entity)
|
||||||
}
|
}
|
||||||
return entity
|
return entity
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Retrieve an entity by id. */
|
/** Retrieve an entity by id. */
|
||||||
get(id: string): GroundItemEntity | undefined {
|
get(id: string | number): GroundItemEntity | undefined {
|
||||||
return this.items.get(id)
|
return this.items.get(String(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear all ground items. */
|
/** Clear all ground items. */
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.items.clear()
|
this.items.clear()
|
||||||
|
this.idCounter = 1
|
||||||
|
this.onClear?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Find the nearest ground item within hit radius. */
|
/** Find the nearest ground item within hit radius. */
|
||||||
findAtPoint(x: number, y: number, hitRadiusPx = 25): GroundItemEntity | undefined {
|
findAtPoint(x: number, y: number, hitRadiusPx = 25): GroundItemEntity | null {
|
||||||
let closest: GroundItemEntity | undefined
|
let closest: GroundItemEntity | null = null
|
||||||
let minDistSq = hitRadiusPx * hitRadiusPx
|
let minDistSq = hitRadiusPx * hitRadiusPx
|
||||||
for (const item of this.items.values()) {
|
for (const item of this.items.values()) {
|
||||||
const dx = item.x - x
|
const dx = item.x - x
|
||||||
|
|
|
||||||
195
src/game/save.ts
195
src/game/save.ts
|
|
@ -22,7 +22,7 @@
|
||||||
* notes on {@link writeD2s}.
|
* notes on {@link writeD2s}.
|
||||||
*/
|
*/
|
||||||
import { rebindPlayer } from './combat.ts'
|
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 { Inventory, Item, PlacedItem } from './items.ts'
|
||||||
import type { QuestLog, QuestProgress } from './quests.ts'
|
import type { QuestLog, QuestProgress } from './quests.ts'
|
||||||
import type { Rng } from './d2-rng.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. */
|
/** Random stream low 32-bit position, so drops continue rather than restart. */
|
||||||
readonly rngState: number
|
readonly rngState: number
|
||||||
/** Random stream high 32-bit carry position (64-bit D2Rng). */
|
/** 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. */
|
/** The combat world, minus its per-tick event buffer. */
|
||||||
readonly world: {
|
readonly world: {
|
||||||
readonly tick: number
|
readonly tick: number
|
||||||
|
|
@ -49,6 +57,7 @@ export interface GameSnapshot {
|
||||||
readonly inventory: {
|
readonly inventory: {
|
||||||
readonly width: number
|
readonly width: number
|
||||||
readonly height: number
|
readonly height: number
|
||||||
|
readonly gold?: number | undefined
|
||||||
readonly placed: readonly { readonly x: number; readonly y: number; readonly item: Item }[]
|
readonly placed: readonly { readonly x: number; readonly y: number; readonly item: Item }[]
|
||||||
}
|
}
|
||||||
/** Quest progress. */
|
/** Quest progress. */
|
||||||
|
|
@ -58,7 +67,12 @@ export interface GameSnapshot {
|
||||||
* the player is looking at: a save that silently deletes the loot you were
|
* the player is looking at: a save that silently deletes the loot you were
|
||||||
* walking towards is a save that lies.
|
* 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. */
|
/** Everything a snapshot is taken from. */
|
||||||
|
|
@ -67,12 +81,23 @@ export interface SnapshotSources {
|
||||||
readonly world: CombatWorld
|
readonly world: CombatWorld
|
||||||
/** The loot random stream. */
|
/** The loot random stream. */
|
||||||
readonly rng: Rng
|
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. */
|
/** The player's inventory. */
|
||||||
readonly inventory: Inventory
|
readonly inventory: Inventory
|
||||||
/** The quest log. */
|
/** The quest log. */
|
||||||
readonly quests: QuestLog
|
readonly quests: QuestLog
|
||||||
/** Items on the ground. */
|
/** 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,
|
version: SNAPSHOT_VERSION,
|
||||||
rngState: sources.rng.seed,
|
rngState: sources.rng.seed,
|
||||||
rngHighState: sources.rng.hi,
|
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: {
|
world: {
|
||||||
tick: sources.world.tick,
|
tick: sources.world.tick,
|
||||||
kills: sources.world.kills,
|
kills: sources.world.kills,
|
||||||
|
|
@ -98,10 +126,16 @@ export function captureSnapshot(sources: SnapshotSources): GameSnapshot {
|
||||||
inventory: {
|
inventory: {
|
||||||
width: sources.inventory.width,
|
width: sources.inventory.width,
|
||||||
height: sources.inventory.height,
|
height: sources.inventory.height,
|
||||||
|
gold: sources.inventory.dedicatedGold,
|
||||||
placed: sources.inventory.contents.map(entry => ({ x: entry.x, y: entry.y, item: entry.item })),
|
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 })),
|
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
|
world: CombatWorld
|
||||||
rngState: number
|
rngState: number
|
||||||
rngHighState: number
|
rngHighState: number
|
||||||
|
castRngState?: number | undefined
|
||||||
|
castRngHighState?: number | undefined
|
||||||
|
hydraGroupSeqCounter?: number | undefined
|
||||||
|
groundItemIdCounter?: number | undefined
|
||||||
inventory: Inventory
|
inventory: Inventory
|
||||||
quests: QuestLog
|
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 {
|
return {
|
||||||
// A save carries one player, so the restored world is a single-player one and
|
// 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
|
// 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: [] }),
|
world: rebindPlayer({ ...snapshot.world, monsters: [...snapshot.world.monsters], players: [], events: [] }),
|
||||||
rngState: snapshot.rngState >>> 0,
|
rngState: snapshot.rngState >>> 0,
|
||||||
rngHighState: (snapshot.rngHighState ?? 0) >>> 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),
|
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)
|
const body = new Uint8Array(0x30 + bodyBytes)
|
||||||
return writeD2s({ version: D2S_VERSION, name, classIndex, level, raw: body })
|
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',
|
'public/ui/items-atlas.png',
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
|
const failures: string[] = []
|
||||||
for (const url of candidates) {
|
for (const url of candidates) {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url)
|
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 blob = await resp.blob()
|
||||||
const bitmap = await createImageBitmap(blob)
|
const bitmap = await createImageBitmap(blob)
|
||||||
try {
|
try {
|
||||||
|
|
@ -60,26 +64,41 @@ export async function loadItemsAtlas(
|
||||||
} finally {
|
} finally {
|
||||||
bitmap.close()
|
bitmap.close()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// try next candidate
|
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 {
|
export function resolveGroundItemFlippyFrame(item: GroundItemEntity | Record<string, unknown> | unknown): string {
|
||||||
const target = item.isGold
|
const raw = (item as any)?.item ?? item
|
||||||
? { isGold: true, amount: item.amount, code: 'gld' }
|
const code = typeof raw?.code === 'string' ? raw.code.trim().toLowerCase() : ''
|
||||||
: (item.item || (item as any))
|
const isGold = Boolean((item as any)?.isGold || raw?.isGold || code === 'gld')
|
||||||
|
if (isGold) {
|
||||||
return resolveGroundItemSpriteRect(
|
const amount = Number((item as any)?.amount ?? raw?.amount ?? raw?.value ?? 1)
|
||||||
target,
|
if (amount >= 5000) return 'flpgld_3'
|
||||||
BAKED_UI_MANIFEST.flippyRects,
|
if (amount >= 500) return 'flpgld_2'
|
||||||
BAKED_UI_MANIFEST.codeToFlippyFile,
|
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,
|
renderer: SpriteRenderer,
|
||||||
item: GroundItemEntity,
|
item: GroundItemEntity,
|
||||||
now: number,
|
now: number,
|
||||||
itemsAtlas: AtlasHandle | undefined = globalItemsAtlasHandle,
|
itemsAtlas: AtlasHandle | { readonly handle?: AtlasHandle | number; readonly frames?: unknown } | null | undefined = globalItemsAtlasHandle,
|
||||||
): void {
|
): void {
|
||||||
const bounceH = item.bounceState ? calculateBounceHeight(item.bounceState, now) : 0
|
const bounceH = item.bounceState ? calculateBounceHeight(item.bounceState, now) : 0
|
||||||
const baseY = item.y - bounceH
|
const baseY = item.y - bounceH
|
||||||
|
|
@ -157,9 +176,16 @@ export function drawGroundItem(
|
||||||
height: sr.h,
|
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') {
|
if (typeof (renderer as any).draw === 'function') {
|
||||||
renderer.draw(frame, drawX, drawY, {
|
renderer.draw(frame, drawX, drawY, {
|
||||||
...(itemsAtlas !== undefined ? { atlas: itemsAtlas } : {}),
|
...(effectiveAtlas !== undefined ? { atlas: effectiveAtlas } : {}),
|
||||||
width: baseW,
|
width: baseW,
|
||||||
height: baseH,
|
height: baseH,
|
||||||
})
|
})
|
||||||
|
|
@ -176,7 +202,7 @@ export function drawGroundItem(
|
||||||
const x1 = x3 + shearX
|
const x1 = x3 + shearX
|
||||||
const y1 = baseY - groundH
|
const y1 = baseY - groundH
|
||||||
;(renderer as any).drawQuad(frame, x0, y0, x1, y1, x2, y2, x3, y3, {
|
;(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 { ActorAnimator } from '../../game/actor-animator.ts'
|
||||||
import type { Monster } from '../../game/combat.ts'
|
import type { Monster } from '../../game/combat.ts'
|
||||||
import type { GameEngine, NpcEntity } from '../../game/engine.ts'
|
import type { GameEngine, NpcEntity } from '../../game/engine.ts'
|
||||||
|
|
@ -54,6 +55,20 @@ import { monsterToTargetHudInfo } from './scene-state.ts'
|
||||||
|
|
||||||
export { playInventoryFullFeedback, showNotification }
|
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 =
|
export type PendingInteraction =
|
||||||
| { readonly kind: 'npc'; readonly npc: NpcEntity }
|
| { readonly kind: 'npc'; readonly npc: NpcEntity }
|
||||||
| { readonly kind: 'waypoint'; readonly waypoint: SceneWaypoint; readonly worldPos: { readonly x: number; readonly y: number } }
|
| { 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 hudManager: HudManager | null
|
||||||
readonly waypointNetwork: WaypointNetwork
|
readonly waypointNetwork: WaypointNetwork
|
||||||
readonly status: HTMLElement
|
readonly status: HTMLElement
|
||||||
|
readonly audioManager?: AudioManager | undefined
|
||||||
|
readonly queuePointerEvents?: boolean | undefined
|
||||||
readonly playerAnimator?: ActorAnimator | undefined
|
readonly playerAnimator?: ActorAnimator | undefined
|
||||||
readonly getCharacter?: (() => LoadedCharacter | null) | undefined
|
readonly getCharacter?: (() => LoadedCharacter | null) | undefined
|
||||||
readonly portalSlot?: TownPortalSlot | undefined
|
readonly portalSlot?: TownPortalSlot | undefined
|
||||||
|
|
@ -103,6 +120,9 @@ export class SceneMouseController {
|
||||||
readonly hudManager: HudManager | null
|
readonly hudManager: HudManager | null
|
||||||
readonly waypointNetwork: WaypointNetwork
|
readonly waypointNetwork: WaypointNetwork
|
||||||
readonly status: HTMLElement
|
readonly status: HTMLElement
|
||||||
|
readonly audioManager?: AudioManager | undefined
|
||||||
|
readonly queuePointerEvents: boolean
|
||||||
|
readonly pendingActions: Array<() => void> = []
|
||||||
readonly playerAnimator?: ActorAnimator | undefined
|
readonly playerAnimator?: ActorAnimator | undefined
|
||||||
readonly getCharacter?: (() => LoadedCharacter | null) | undefined
|
readonly getCharacter?: (() => LoadedCharacter | null) | undefined
|
||||||
readonly portalSlot?: TownPortalSlot | undefined
|
readonly portalSlot?: TownPortalSlot | undefined
|
||||||
|
|
@ -140,6 +160,8 @@ export class SceneMouseController {
|
||||||
this.hudManager = options.hudManager
|
this.hudManager = options.hudManager
|
||||||
this.waypointNetwork = options.waypointNetwork
|
this.waypointNetwork = options.waypointNetwork
|
||||||
this.status = options.status
|
this.status = options.status
|
||||||
|
this.audioManager = options.audioManager
|
||||||
|
this.queuePointerEvents = options.queuePointerEvents ?? false
|
||||||
this.playerAnimator = options.playerAnimator
|
this.playerAnimator = options.playerAnimator
|
||||||
this.getCharacter = options.getCharacter
|
this.getCharacter = options.getCharacter
|
||||||
this.portalSlot = options.portalSlot
|
this.portalSlot = options.portalSlot
|
||||||
|
|
@ -446,6 +468,7 @@ export class SceneMouseController {
|
||||||
const dist = Math.hypot(item.x - player.x, item.y - player.y)
|
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 maxEdgeReach = Math.max(64, (this.engine.opts as any)?.pickupRadius ?? 64)
|
||||||
const isEn = getTblLang() === 'ENG'
|
const isEn = getTblLang() === 'ENG'
|
||||||
|
const tickNowMs = (this.engine.world?.tick ?? 0) * 40
|
||||||
if (isWithinPickupBounds(player.x, player.y, item.x, item.y, maxEdgeReach)) {
|
if (isWithinPickupBounds(player.x, player.y, item.x, item.y, maxEdgeReach)) {
|
||||||
this.navTarget = null
|
this.navTarget = null
|
||||||
this.pendingInteraction = null
|
this.pendingInteraction = null
|
||||||
|
|
@ -455,13 +478,27 @@ export class SceneMouseController {
|
||||||
player.facing = facingOf(item.x - player.x, item.y - player.y)
|
player.facing = facingOf(item.x - player.x, item.y - player.y)
|
||||||
|
|
||||||
if (item.isGold) {
|
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)
|
const res = this.engine.pickupGold(item.id)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
if (this.hudManager) {
|
if (this.hudManager) {
|
||||||
const playerLevel = this.engine.world.player?.level ?? 1
|
const playerLevel = this.engine.world.player?.level ?? 1
|
||||||
const goldCap = getInventoryGoldLimit(playerLevel)
|
const goldCap = getInventoryGoldLimit(playerLevel)
|
||||||
this.hudManager.inventory.playerLevel = 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()
|
this.hudManager.syncPublishedState()
|
||||||
}
|
}
|
||||||
if (res.remaining && res.remaining > 0) {
|
if (res.remaining && res.remaining > 0) {
|
||||||
|
|
@ -475,7 +512,7 @@ export class SceneMouseController {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.engine.metrics.inventoryRefusals += 1
|
this.engine.metrics.inventoryRefusals += 1
|
||||||
triggerFlippyBounce(item, typeof performance !== 'undefined' ? performance.now() : Date.now())
|
triggerFlippyBounce(item, tickNowMs)
|
||||||
this.playInventoryFullFeedback()
|
this.playInventoryFullFeedback()
|
||||||
this.status.textContent = isEn ? 'Gold limit reached.' : '金币已达携带上限。'
|
this.status.textContent = isEn ? 'Gold limit reached.' : '金币已达携带上限。'
|
||||||
this.showNotification(isEn ? 'Cannot carry any more gold.' : '无法携带更多金币。')
|
this.showNotification(isEn ? 'Cannot carry any more gold.' : '无法携带更多金币。')
|
||||||
|
|
@ -488,14 +525,16 @@ export class SceneMouseController {
|
||||||
const placed = this.hudManager.inventory.autoPlaceInGrid(uiItem)
|
const placed = this.hudManager.inventory.autoPlaceInGrid(uiItem)
|
||||||
if (!placed) {
|
if (!placed) {
|
||||||
this.engine.metrics.inventoryRefusals += 1
|
this.engine.metrics.inventoryRefusals += 1
|
||||||
triggerFlippyBounce(item, typeof performance !== 'undefined' ? performance.now() : Date.now())
|
triggerFlippyBounce(item, tickNowMs)
|
||||||
this.playInventoryFullFeedback()
|
this.playInventoryFullFeedback()
|
||||||
this.showNotification(isEn ? 'Inventory is full.' : '包裹已满。')
|
this.showNotification(isEn ? 'Inventory is full.' : '包裹已满。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.engine.groundItems.remove(item.id)
|
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) {
|
if (gIdx !== -1) {
|
||||||
this.engine.ground.splice(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.
|
* Low descending refusal tone paired with delayed secondary touch landing thud.
|
||||||
*/
|
*/
|
||||||
playInventoryFullFeedback(): void {
|
playInventoryFullFeedback(): void {
|
||||||
playInventoryFullFeedback()
|
playInventoryFullFeedback(this.audioManager ?? sharedAudioManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -542,24 +581,13 @@ export class SceneMouseController {
|
||||||
}
|
}
|
||||||
|
|
||||||
playStashOpenSound(): void {
|
playStashOpenSound(): void {
|
||||||
try {
|
playStashOpenSound(this.audioManager ?? sharedAudioManager)
|
||||||
const AudioCtx = typeof window !== 'undefined' ? (window.AudioContext || (window as any).webkitAudioContext) : null
|
}
|
||||||
if (AudioCtx) {
|
|
||||||
const ctx = new AudioCtx()
|
flushPendingActions(): void {
|
||||||
const osc = ctx.createOscillator()
|
while (this.pendingActions.length > 0) {
|
||||||
const gain = ctx.createGain()
|
const action = this.pendingActions.shift()
|
||||||
osc.type = 'triangle'
|
action?.()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -586,43 +614,52 @@ export class SceneMouseController {
|
||||||
this.lastClientPos = { x: e.clientX, y: e.clientY }
|
this.lastClientPos = { x: e.clientX, y: e.clientY }
|
||||||
const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY)
|
const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY)
|
||||||
|
|
||||||
if (e.button === 0) {
|
const runAction = (): void => {
|
||||||
this.isLeftMouseDown = true
|
if (this.isTravelling?.()) return
|
||||||
if (this.input.shiftHeld) {
|
if (e.button === 0) {
|
||||||
// R4: Shift-hold force stand still attack
|
this.isLeftMouseDown = true
|
||||||
this.activeClickType = 'none'
|
if (this.input.shiftHeld) {
|
||||||
this.navTarget = null
|
// R4: Shift-hold force stand still attack
|
||||||
this.pendingInteraction = null
|
this.activeClickType = 'none'
|
||||||
const player = this.engine.world.player
|
this.navTarget = null
|
||||||
player.facing = facingOf(worldX - player.x, worldY - player.y)
|
this.pendingInteraction = null
|
||||||
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
|
const player = this.engine.world.player
|
||||||
this.castSkill(leftSkillId, worldX, worldY)
|
player.facing = facingOf(worldX - player.x, worldY - player.y)
|
||||||
if (this.hudManager !== null) {
|
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
|
||||||
const activeTarget = this.getActiveTargetMonster()
|
this.castSkill(leftSkillId, worldX, worldY)
|
||||||
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
|
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 {
|
} else if (e.button === 2) {
|
||||||
// R3: Left-click click-to-move & entity interaction
|
this.isRightMouseDown = true
|
||||||
this.handleLeftClickWorld(worldX, worldY)
|
if (this.input.shiftHeld) {
|
||||||
}
|
this.activeClickType = 'none'
|
||||||
} else if (e.button === 2) {
|
this.navTarget = null
|
||||||
this.isRightMouseDown = true
|
this.pendingInteraction = null
|
||||||
if (this.input.shiftHeld) {
|
const player = this.engine.world.player
|
||||||
this.activeClickType = 'none'
|
player.facing = facingOf(worldX - player.x, worldY - player.y)
|
||||||
this.navTarget = null
|
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
|
||||||
this.pendingInteraction = null
|
this.castSkill(rightSkillId, worldX, worldY)
|
||||||
const player = this.engine.world.player
|
if (this.hudManager !== null) {
|
||||||
player.facing = facingOf(worldX - player.x, worldY - player.y)
|
const activeTarget = this.getActiveTargetMonster()
|
||||||
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
|
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
|
||||||
this.castSkill(rightSkillId, worldX, worldY)
|
}
|
||||||
if (this.hudManager !== null) {
|
} else {
|
||||||
const activeTarget = this.getActiveTargetMonster()
|
this.handleRightClickWorld(worldX, worldY)
|
||||||
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
this.handleRightClickWorld(worldX, worldY)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.queuePointerEvents) {
|
||||||
|
this.pendingActions.push(runAction)
|
||||||
|
} else {
|
||||||
|
runAction()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
readonly onPointerMove = (e: PointerEvent): void => {
|
readonly onPointerMove = (e: PointerEvent): void => {
|
||||||
|
|
@ -806,9 +843,11 @@ export class SceneMouseController {
|
||||||
if (descriptor) {
|
if (descriptor) {
|
||||||
if (descriptor.canHeal) {
|
if (descriptor.canHeal) {
|
||||||
if (player.maxHp !== undefined) player.hp = player.maxHp
|
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) {
|
if (this.hudManager) {
|
||||||
this.hudManager.hp = this.hudManager.maxHp
|
this.hudManager.hp = player.maxHp ?? this.hudManager.maxHp
|
||||||
this.hudManager.mana = this.hudManager.maxMana
|
this.hudManager.mana = player.maxMana ?? this.hudManager.maxMana
|
||||||
this.hudManager.stamina = this.hudManager.maxStamina
|
this.hudManager.stamina = this.hudManager.maxStamina
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1153,6 +1192,7 @@ export class SceneMouseController {
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTarget(): void {
|
clearTarget(): void {
|
||||||
|
this.pendingActions.length = 0
|
||||||
this.navTarget = null
|
this.navTarget = null
|
||||||
this.pendingInteraction = null
|
this.pendingInteraction = null
|
||||||
this.activeClickType = 'none'
|
this.activeClickType = 'none'
|
||||||
|
|
@ -1183,6 +1223,7 @@ export class SceneMouseController {
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(): { movement: { x: number; y: number }; attacking: boolean } {
|
tick(): { movement: { x: number; y: number }; attacking: boolean } {
|
||||||
|
this.flushPendingActions()
|
||||||
if (this.isTravelling?.()) {
|
if (this.isTravelling?.()) {
|
||||||
this.navTarget = null
|
this.navTarget = null
|
||||||
this.pendingInteraction = 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 { Monster } from '../../game/combat.ts'
|
||||||
import type { GameEngine } from '../../game/engine.ts'
|
import type { GameEngine } from '../../game/engine.ts'
|
||||||
import type { GroundItemEntity } from '../../game/ground-items.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.
|
* Low descending refusal tone paired with delayed secondary touch landing thud.
|
||||||
*/
|
*/
|
||||||
export function playInventoryFullFeedback(): void {
|
export function playInventoryFullFeedback(audioManager: AudioManager = sharedAudioManager): void {
|
||||||
try {
|
if (typeof audioManager?.playSfx === 'function') {
|
||||||
const AudioCtx = typeof window !== 'undefined' ? (window.AudioContext || (window as any).webkitAudioContext) : null
|
audioManager.playSfx('inventory_full')
|
||||||
if (AudioCtx) {
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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', () => {
|
it('scatters multiple gold piles dropped from player inventory onto distinct sub-tile slots without stacking', () => {
|
||||||
const engine = createEngine()
|
const engine = createEngine()
|
||||||
|
engine.bag.gold = 5000
|
||||||
const playerX = engine.world.player.x
|
const playerX = engine.world.player.x
|
||||||
const playerY = engine.world.player.y
|
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).toBeDefined()
|
||||||
expect(goldEntity!.isGold).toBe(true)
|
expect(goldEntity!.isGold).toBe(true)
|
||||||
expect(goldEntity!.amount).toBe(5000)
|
expect(goldEntity!.amount).toBe(5000)
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ describe('Ground Items Pickup & Pathfinding (Issue #391)', () => {
|
||||||
describe('GameEngine Pickup Methods', () => {
|
describe('GameEngine Pickup Methods', () => {
|
||||||
it('successfully picks up gold, increments metrics, and removes from groundItems', () => {
|
it('successfully picks up gold, increments metrics, and removes from groundItems', () => {
|
||||||
const engine = createTestEngine()
|
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(gold).not.toBeNull()
|
||||||
expect(engine.groundItems.count).toBe(1)
|
expect(engine.groundItems.count).toBe(1)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -283,7 +283,7 @@ describe('Issue #492: Prevent Ground Items & Gold from Dropping into Unreachable
|
||||||
insideWallPos.x,
|
insideWallPos.x,
|
||||||
insideWallPos.y,
|
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(itemEntity.x, itemEntity.y)).toBe(0)
|
||||||
expect(terrain.overlap(goldEntity.x, goldEntity.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
|
// 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(goldEntity).toBeDefined()
|
||||||
expect(engine.groundItems.count).toBe(1)
|
expect(engine.groundItems.count).toBe(1)
|
||||||
expect(engine.ground.length).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,
|
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)
|
const res = engine.pickupItem(goldEntity!.id)
|
||||||
expect(res.success).toBe(true)
|
expect(res.success).toBe(true)
|
||||||
expect(engine.bag.gold).toBe(1200)
|
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
|
// 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)
|
expect(engine.bag.gold).toBe(0)
|
||||||
|
|
||||||
// Player picks up
|
// 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.cellY).toBe(0)
|
||||||
expect(item.cellX !== 0 || item.cellY !== 0).toBe(true)
|
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
|
// (160, 240): px = 160, py = 240
|
||||||
// cellX = floor((160/80 + 240/40) / 2) = floor((2 + 6) / 2) = 4
|
// cellX = floor((160/80 + 240/40) / 2) = floor((2 + 6) / 2) = 4
|
||||||
// cellY = floor((240/40 - 160/80) / 2) = floor((6 - 2) / 2) = 2
|
// 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)
|
// 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()
|
expect(goldDrop).not.toBeNull()
|
||||||
|
|
||||||
const controller = new SceneMouseController({
|
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