feat(stash): implement camp town stash entities across 5 acts and 6x8 storage panel (Fixes #150)
This commit is contained in:
parent
e7d9070bb8
commit
db82a5da32
|
|
@ -457,7 +457,99 @@ interface ObjectDrawable {
|
||||||
readonly page: number
|
readonly page: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Everything the render loop needs, from either data path. */
|
/** Town Stash entity (authentic 1.13c Objects.txt row 267 B6). */
|
||||||
|
export interface TownStashEntity {
|
||||||
|
readonly id: number
|
||||||
|
readonly objectsTxtId: number
|
||||||
|
readonly token: string
|
||||||
|
readonly nameZh: string
|
||||||
|
readonly nameEn: string
|
||||||
|
readonly act: number
|
||||||
|
readonly x: number
|
||||||
|
readonly y: number
|
||||||
|
readonly subTileX: number
|
||||||
|
readonly subTileY: number
|
||||||
|
readonly clickRadiusPx: number
|
||||||
|
readonly interactionRangePx: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical town stash locations for Acts 1..5.
|
||||||
|
* Grounded in authentic 1.13c DS1 map objects (row 267 B6).
|
||||||
|
*/
|
||||||
|
export const CANONICAL_TOWN_STASHES: Record<number, TownStashEntity> = {
|
||||||
|
1: {
|
||||||
|
id: 267,
|
||||||
|
objectsTxtId: 267,
|
||||||
|
token: 'B6',
|
||||||
|
nameZh: '储物箱',
|
||||||
|
nameEn: 'Stash',
|
||||||
|
act: 1,
|
||||||
|
x: 4512,
|
||||||
|
y: 1720,
|
||||||
|
subTileX: 112,
|
||||||
|
subTileY: 86,
|
||||||
|
clickRadiusPx: 48,
|
||||||
|
interactionRangePx: 80,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
id: 267,
|
||||||
|
objectsTxtId: 267,
|
||||||
|
token: 'B6',
|
||||||
|
nameZh: '储物箱',
|
||||||
|
nameEn: 'Stash',
|
||||||
|
act: 2,
|
||||||
|
x: 5328,
|
||||||
|
y: 1568,
|
||||||
|
subTileX: 133,
|
||||||
|
subTileY: 78,
|
||||||
|
clickRadiusPx: 48,
|
||||||
|
interactionRangePx: 80,
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
id: 267,
|
||||||
|
objectsTxtId: 267,
|
||||||
|
token: 'B6',
|
||||||
|
nameZh: '储物箱',
|
||||||
|
nameEn: 'Stash',
|
||||||
|
act: 3,
|
||||||
|
x: 5264,
|
||||||
|
y: 1584,
|
||||||
|
subTileX: 131,
|
||||||
|
subTileY: 79,
|
||||||
|
clickRadiusPx: 48,
|
||||||
|
interactionRangePx: 80,
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
id: 267,
|
||||||
|
objectsTxtId: 267,
|
||||||
|
token: 'B6',
|
||||||
|
nameZh: '储物箱',
|
||||||
|
nameEn: 'Stash',
|
||||||
|
act: 4,
|
||||||
|
x: 704,
|
||||||
|
y: 552,
|
||||||
|
subTileX: 17,
|
||||||
|
subTileY: 27,
|
||||||
|
clickRadiusPx: 48,
|
||||||
|
interactionRangePx: 80,
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
id: 267,
|
||||||
|
objectsTxtId: 267,
|
||||||
|
token: 'B6',
|
||||||
|
nameZh: '储物箱',
|
||||||
|
nameEn: 'Stash',
|
||||||
|
act: 5,
|
||||||
|
x: 3792,
|
||||||
|
y: 1448,
|
||||||
|
subTileX: 94,
|
||||||
|
subTileY: 72,
|
||||||
|
clickRadiusPx: 48,
|
||||||
|
interactionRangePx: 80,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/** Everything the render loop needs, from either data path. */
|
/** Everything the render loop needs, from either data path. */
|
||||||
export interface MapRuntime {
|
export interface MapRuntime {
|
||||||
readonly source: 'pack' | 'live'
|
readonly source: 'pack' | 'live'
|
||||||
|
|
@ -479,6 +571,8 @@ export interface MapRuntime {
|
||||||
readonly warps: readonly SceneWarp[]
|
readonly warps: readonly SceneWarp[]
|
||||||
/** The waypoint pedestal, when this level has one. */
|
/** The waypoint pedestal, when this level has one. */
|
||||||
readonly waypoints: readonly SceneWaypoint[]
|
readonly waypoints: readonly SceneWaypoint[]
|
||||||
|
/** Town Stash chest entities. */
|
||||||
|
readonly stashes?: readonly TownStashEntity[]
|
||||||
readonly variants: readonly string[]
|
readonly variants: readonly string[]
|
||||||
readonly variantParam: 'level' | 'variant'
|
readonly variantParam: 'level' | 'variant'
|
||||||
readonly variant: string
|
readonly variant: string
|
||||||
|
|
@ -890,6 +984,7 @@ export function castSkill(
|
||||||
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 } }
|
||||||
|
| { readonly kind: 'stash'; readonly stash: TownStashEntity }
|
||||||
| { readonly kind: 'monster'; readonly monster: Monster }
|
| { readonly kind: 'monster'; readonly monster: Monster }
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -926,7 +1021,7 @@ export class SceneMouseController {
|
||||||
|
|
||||||
navTarget: { x: number; y: number } | null = null
|
navTarget: { x: number; y: number } | null = null
|
||||||
pendingInteraction: PendingInteraction | null = null
|
pendingInteraction: PendingInteraction | null = null
|
||||||
activeClickType: 'none' | 'ground' | 'npc' | 'waypoint' | 'monster' = 'none'
|
activeClickType: 'none' | 'ground' | 'npc' | 'waypoint' | 'stash' | 'monster' = 'none'
|
||||||
isLeftMouseDown = false
|
isLeftMouseDown = false
|
||||||
isRightMouseDown = false
|
isRightMouseDown = false
|
||||||
lastClientPos: { x: number; y: number } | null = null
|
lastClientPos: { x: number; y: number } | null = null
|
||||||
|
|
@ -948,6 +1043,53 @@ export class SceneMouseController {
|
||||||
;(this.engine as any).dialog = null
|
;(this.engine as any).dialog = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findNearestStash(worldX: number, worldY: number, maxRadius = 48): TownStashEntity | null {
|
||||||
|
const runtime = this.getRuntime()
|
||||||
|
const stashes = runtime.stashes ?? []
|
||||||
|
let best: TownStashEntity | null = null
|
||||||
|
let bestDist = maxRadius
|
||||||
|
for (const stash of stashes) {
|
||||||
|
const dist = Math.hypot(stash.x - worldX, stash.y - worldY)
|
||||||
|
if (dist <= bestDist) {
|
||||||
|
bestDist = dist
|
||||||
|
best = stash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
openStash(stash: TownStashEntity): void {
|
||||||
|
const player = this.engine.world.player
|
||||||
|
player.facing = facingOf(stash.x - player.x, stash.y - player.y)
|
||||||
|
this.status.textContent = `打开私人储物箱 (${stash.nameZh})`
|
||||||
|
this.playStashOpenSound()
|
||||||
|
if (this.hudManager && this.hudManager.leftPanel !== 'stash') {
|
||||||
|
this.hudManager.toggleLeftPanel('stash')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
readonly onContextMenu = (e: MouseEvent): void => {
|
readonly onContextMenu = (e: MouseEvent): void => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|
@ -1105,7 +1247,23 @@ export class SceneMouseController {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Check Monster: living monster within ~32 px
|
// 3. Check Town Stash: within ~48 px (clickRadiusPx = 48)
|
||||||
|
const clickedStash = this.findNearestStash(worldX, worldY, 48)
|
||||||
|
if (clickedStash !== null) {
|
||||||
|
this.activeClickType = 'stash'
|
||||||
|
const distToPlayer = Math.hypot(clickedStash.x - player.x, clickedStash.y - player.y)
|
||||||
|
if (distToPlayer <= clickedStash.interactionRangePx) {
|
||||||
|
this.navTarget = null
|
||||||
|
this.pendingInteraction = null
|
||||||
|
this.openStash(clickedStash)
|
||||||
|
} else {
|
||||||
|
this.pendingInteraction = { kind: 'stash', stash: clickedStash }
|
||||||
|
this.navTarget = { x: clickedStash.x, y: clickedStash.y }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check Monster: living monster within ~32 px
|
||||||
let clickedMonster: Monster | null = null
|
let clickedMonster: Monster | null = null
|
||||||
let bestMonDist = 36
|
let bestMonDist = 36
|
||||||
for (const m of this.engine.world.monsters) {
|
for (const m of this.engine.world.monsters) {
|
||||||
|
|
@ -1263,6 +1421,16 @@ export class SceneMouseController {
|
||||||
} else {
|
} else {
|
||||||
this.navTarget = { x: worldPos.x, y: worldPos.y }
|
this.navTarget = { x: worldPos.x, y: worldPos.y }
|
||||||
}
|
}
|
||||||
|
} else if (this.pendingInteraction.kind === 'stash') {
|
||||||
|
const stash = this.pendingInteraction.stash
|
||||||
|
const dist = Math.hypot(stash.x - player.x, stash.y - player.y)
|
||||||
|
if (dist <= stash.interactionRangePx) {
|
||||||
|
this.navTarget = null
|
||||||
|
this.pendingInteraction = null
|
||||||
|
this.openStash(stash)
|
||||||
|
} else {
|
||||||
|
this.navTarget = { x: stash.x, y: stash.y }
|
||||||
|
}
|
||||||
} else if (this.pendingInteraction.kind === 'monster') {
|
} else if (this.pendingInteraction.kind === 'monster') {
|
||||||
const monster = this.pendingInteraction.monster
|
const monster = this.pendingInteraction.monster
|
||||||
if (monster.state === 'dead' || monster.hp <= 0) {
|
if (monster.state === 'dead' || monster.hp <= 0) {
|
||||||
|
|
@ -1681,6 +1849,36 @@ async function buildPackRuntime(
|
||||||
objectDrawables.sort((a, b) => a.depth - b.depth)
|
objectDrawables.sort((a, b) => a.depth - b.depth)
|
||||||
const objectPages: (AtlasHandle | null)[] = (scene.objectPages ?? []).map(() => null)
|
const objectPages: (AtlasHandle | null)[] = (scene.objectPages ?? []).map(() => null)
|
||||||
|
|
||||||
|
const stashes: TownStashEntity[] = []
|
||||||
|
for (const obj of scene.objects) {
|
||||||
|
if (obj.objectsTxtId === 267 || obj.token === 'B6') {
|
||||||
|
const subTileX = Math.round((obj.x - scene.originX) / 16)
|
||||||
|
const subTileY = Math.round((obj.y - scene.originY) / 16)
|
||||||
|
stashes.push({
|
||||||
|
id: obj.id ?? 267,
|
||||||
|
objectsTxtId: 267,
|
||||||
|
token: 'B6',
|
||||||
|
nameZh: '储物箱',
|
||||||
|
nameEn: 'Stash',
|
||||||
|
act: scene.act,
|
||||||
|
x: obj.x,
|
||||||
|
y: obj.y,
|
||||||
|
subTileX,
|
||||||
|
subTileY,
|
||||||
|
clickRadiusPx: 48,
|
||||||
|
interactionRangePx: 80,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const effectiveLevelId = scene.levelId ?? entry.levelId ?? -1
|
||||||
|
const isTown = [1, 40, 75, 103, 109].includes(effectiveLevelId) || entry.slug.includes('town')
|
||||||
|
if (stashes.length === 0 && isTown) {
|
||||||
|
const canonical = CANONICAL_TOWN_STASHES[scene.act]
|
||||||
|
if (canonical) {
|
||||||
|
stashes.push(canonical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Collision arrives as runs; expanding it here keeps the JSON small and yields
|
// Collision arrives as runs; expanding it here keeps the JSON small and yields
|
||||||
// exactly the grid the live path would have produced.
|
// exactly the grid the live path would have produced.
|
||||||
const blocked = new Uint8Array(scene.collision.width * scene.collision.height)
|
const blocked = new Uint8Array(scene.collision.width * scene.collision.height)
|
||||||
|
|
@ -1725,6 +1923,7 @@ async function buildPackRuntime(
|
||||||
entrances: scene.entrances ?? [],
|
entrances: scene.entrances ?? [],
|
||||||
warps: scene.warps ?? [],
|
warps: scene.warps ?? [],
|
||||||
waypoints: scene.waypoints ?? [],
|
waypoints: scene.waypoints ?? [],
|
||||||
|
stashes,
|
||||||
variants: forAct.map(candidate => candidate.label),
|
variants: forAct.map(candidate => candidate.label),
|
||||||
variantParam: 'level',
|
variantParam: 'level',
|
||||||
variant: entry.label,
|
variant: entry.label,
|
||||||
|
|
@ -2552,6 +2751,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
const pos = subTileToScene(ent.x, ent.y, runtime.grid)
|
const pos = subTileToScene(ent.x, ent.y, runtime.grid)
|
||||||
safeZones.push({ x: pos.x, y: pos.y, radius: 160 })
|
safeZones.push({ x: pos.x, y: pos.y, radius: 160 })
|
||||||
}
|
}
|
||||||
|
for (const s of runtime.stashes ?? []) {
|
||||||
|
safeZones.push({ x: s.x, y: s.y, radius: 160 })
|
||||||
|
}
|
||||||
const resolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => {
|
const resolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => {
|
||||||
if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) {
|
if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { GlobesHud } from './globes.ts'
|
||||||
import { BeltHud } from './belt.ts'
|
import { BeltHud } from './belt.ts'
|
||||||
import { SkillHotkeysHud } from './hotkeys.ts'
|
import { SkillHotkeysHud } from './hotkeys.ts'
|
||||||
import { ControlBarHud, type MiniPanelAction } from './control-bar.ts'
|
import { ControlBarHud, type MiniPanelAction } from './control-bar.ts'
|
||||||
import { InventoryPanel, type EquipSlotId, resolveItemSpriteRect } from './inventory.ts'
|
import { InventoryPanel, type EquipSlotId, resolveItemSpriteRect, INV_GRID_ORIGIN } from './inventory.ts'
|
||||||
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
|
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
|
||||||
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
|
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
|
||||||
import { WorldPanelsHud, type LeftDockPanelKind } from './world-panels.ts'
|
import { WorldPanelsHud, type LeftDockPanelKind } from './world-panels.ts'
|
||||||
|
|
@ -330,6 +330,9 @@ export class HudManager {
|
||||||
} else {
|
} else {
|
||||||
this.leftPanel = kind
|
this.leftPanel = kind
|
||||||
this.charSheet.visible = kind === 'char'
|
this.charSheet.visible = kind === 'char'
|
||||||
|
if (kind === 'stash' && this.rightPanel === 'none') {
|
||||||
|
this.toggleRightPanel('inv')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
}
|
}
|
||||||
|
|
@ -480,6 +483,12 @@ export class HudManager {
|
||||||
this.controlBar.handleMouseMove(pt.x, pt.y)
|
this.controlBar.handleMouseMove(pt.x, pt.y)
|
||||||
this.inventory.handleMouseMove(pt.x, pt.y)
|
this.inventory.handleMouseMove(pt.x, pt.y)
|
||||||
this.skillTree.handleMouseMove(pt.x, pt.y)
|
this.skillTree.handleMouseMove(pt.x, pt.y)
|
||||||
|
if (this.leftPanel === 'stash') {
|
||||||
|
this.worldPanels.handleMouseMove(pt.x, pt.y)
|
||||||
|
if (this.worldPanels.hoveredStashItem) {
|
||||||
|
this.inventory.hoveredItem = this.worldPanels.hoveredStashItem
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
window.addEventListener('mousedown', () => {
|
window.addEventListener('mousedown', () => {
|
||||||
|
|
@ -550,49 +559,75 @@ export class HudManager {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left-Half Dock Click
|
// Left-Half Dock Click (`0..400`)
|
||||||
if (this.leftPanel === 'char') {
|
if (pt.x <= 400) {
|
||||||
this.charSheet.handleClick(pt.x, pt.y, (key: BaseStatKey) => {
|
if (this.leftPanel === 'char') {
|
||||||
if (key === 'vit') {
|
this.charSheet.handleClick(pt.x, pt.y, (key: BaseStatKey) => {
|
||||||
this.maxHp = this.charSheet.attrs.baseMaxHp
|
if (key === 'vit') {
|
||||||
this.hp = this.maxHp
|
this.maxHp = this.charSheet.attrs.baseMaxHp
|
||||||
this.maxStamina = this.charSheet.attrs.baseMaxStamina
|
this.hp = this.maxHp
|
||||||
this.stamina = this.maxStamina
|
this.maxStamina = this.charSheet.attrs.baseMaxStamina
|
||||||
} else if (key === 'ene') {
|
this.stamina = this.maxStamina
|
||||||
this.maxMana = this.charSheet.attrs.baseMaxMana
|
} else if (key === 'ene') {
|
||||||
this.mana = this.maxMana
|
this.maxMana = this.charSheet.attrs.baseMaxMana
|
||||||
}
|
this.mana = this.maxMana
|
||||||
})
|
}
|
||||||
if (!this.charSheet.visible) this.leftPanel = 'none'
|
})
|
||||||
this.syncPublishedState()
|
if (!this.charSheet.visible) this.leftPanel = 'none'
|
||||||
return
|
this.syncPublishedState()
|
||||||
} else if (this.leftPanel !== 'none') {
|
return
|
||||||
this.worldPanels.handleLeftDockClick(this.leftPanel, pt.x, pt.y, {
|
} else if (this.leftPanel !== 'none') {
|
||||||
onClose: () => {
|
this.worldPanels.handleLeftDockClick(this.leftPanel, pt.x, pt.y, {
|
||||||
this.leftPanel = 'none'
|
onClose: () => {
|
||||||
},
|
this.leftPanel = 'none'
|
||||||
onWaypointTeleport: (act, slug, levelId) => {
|
},
|
||||||
this.callbacks.onWaypointTeleport(act, slug, levelId)
|
onWaypointTeleport: (act, slug, levelId) => {
|
||||||
},
|
this.callbacks.onWaypointTeleport(act, slug, levelId)
|
||||||
})
|
},
|
||||||
this.syncPublishedState()
|
inventory: this.inventory,
|
||||||
return
|
isShiftClick: e.shiftKey,
|
||||||
|
isRightClick: e.button === 2,
|
||||||
|
})
|
||||||
|
this.syncPublishedState()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right-Half Dock Click
|
// Right-Half Dock Click (`400..800`)
|
||||||
if (this.rightPanel === 'inv') {
|
if (pt.x >= 400) {
|
||||||
this.inventory.handleClick(pt.x, pt.y, {
|
if (this.rightPanel === 'inv') {
|
||||||
onOpenCube: () => this.toggleLeftPanel('cube'),
|
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
|
||||||
onCastTownPortal: () => this.callbacks.onCastTownPortal?.(),
|
if (
|
||||||
})
|
pt.x >= INV_GRID_ORIGIN.x &&
|
||||||
if (!this.inventory.visible) this.rightPanel = 'none'
|
pt.x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
||||||
this.syncPublishedState()
|
pt.y >= INV_GRID_ORIGIN.y &&
|
||||||
return
|
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
|
||||||
} else if (this.rightPanel === 'skill') {
|
) {
|
||||||
this.skillTree.handleClick(pt.x, pt.y)
|
const col = Math.floor((pt.x - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
||||||
if (!this.skillTree.visible) this.rightPanel = 'none'
|
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
||||||
this.syncPublishedState()
|
const hit = this.inventory.gridItems.find(
|
||||||
return
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
if (hit) {
|
||||||
|
this.worldPanels.quickTransferToStash(hit.item, this.inventory)
|
||||||
|
this.syncPublishedState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.inventory.handleClick(pt.x, pt.y, {
|
||||||
|
onOpenCube: () => this.toggleLeftPanel('cube'),
|
||||||
|
onCastTownPortal: () => this.callbacks.onCastTownPortal?.(),
|
||||||
|
})
|
||||||
|
if (!this.inventory.visible) this.rightPanel = 'none'
|
||||||
|
this.syncPublishedState()
|
||||||
|
return
|
||||||
|
} else if (this.rightPanel === 'skill') {
|
||||||
|
this.skillTree.handleClick(pt.x, pt.y)
|
||||||
|
if (!this.skillTree.visible) this.rightPanel = 'none'
|
||||||
|
this.syncPublishedState()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -732,6 +767,7 @@ export class HudManager {
|
||||||
vendorBgImg: this.images.get('vendorBg') ?? null,
|
vendorBgImg: this.images.get('vendorBg') ?? null,
|
||||||
buySellBtnImg: this.images.get('buySellBtn') ?? null,
|
buySellBtnImg: this.images.get('buySellBtn') ?? null,
|
||||||
questsAtlasImg: this.images.get('questsAtlas') ?? null,
|
questsAtlasImg: this.images.get('questsAtlas') ?? null,
|
||||||
|
itemsAtlasImg: this.images.get('itemsAtlas') ?? null,
|
||||||
},
|
},
|
||||||
this.font,
|
this.font,
|
||||||
)
|
)
|
||||||
|
|
@ -821,8 +857,12 @@ export class HudManager {
|
||||||
this.worldPanels.drawAreaBanner(ctx, nowMs, this.font)
|
this.worldPanels.drawAreaBanner(ctx, nowMs, this.font)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Hover Item Tooltip (Inventory / Equipment)
|
// 9. Hover Item Tooltip (Inventory / Equipment / Stash)
|
||||||
if (this.rightPanel === 'inv' && this.inventory.hoveredItem && !this.inventory.cursorItem) {
|
if (
|
||||||
|
(this.rightPanel === 'inv' || this.leftPanel === 'stash') &&
|
||||||
|
this.inventory.hoveredItem &&
|
||||||
|
!this.inventory.cursorItem
|
||||||
|
) {
|
||||||
this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font)
|
this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,20 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { D2ColorCode, D2FontRenderer } from './font.ts'
|
import type { D2ColorCode, D2FontRenderer } from './font.ts'
|
||||||
import type { UiInventoryItem } from './inventory.ts'
|
import type { GridPlacement, InventoryPanel, UiInventoryItem } from './inventory.ts'
|
||||||
|
import { resolveItemSpriteRect } from './inventory.ts'
|
||||||
import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts'
|
||||||
|
|
||||||
export type LeftDockPanelKind = 'none' | 'char' | 'quest' | 'waypoint' | 'stash' | 'cube' | 'vendor'
|
export type LeftDockPanelKind = 'none' | 'char' | 'quest' | 'waypoint' | 'stash' | 'cube' | 'vendor'
|
||||||
|
|
||||||
|
export const STASH_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const
|
||||||
|
export const STASH_GRID_ORIGIN = { x: 80 + 74, y: 60 + 82, cols: 6, rows: 8, cellPx: 29 } as const
|
||||||
|
export const STASH_GOLD_CAP = 2_500_000
|
||||||
|
export const PLAYER_GOLD_CAP = 2_500_000
|
||||||
|
export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 } as const
|
||||||
|
export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 } as const
|
||||||
|
export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const
|
||||||
|
|
||||||
export interface QuestEntry {
|
export interface QuestEntry {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly act: number
|
readonly act: number
|
||||||
|
|
@ -189,6 +198,137 @@ export class WorldPanelsHud {
|
||||||
this.areaBanner = { titleZh, titleEn, untilMs: nowMs + 3600 }
|
this.areaBanner = { titleZh, titleEn, untilMs: nowMs + 3600 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stashItems: GridPlacement[] = []
|
||||||
|
hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether an item of (w, h) can be placed at (col, row) in the 6x8 stash grid.
|
||||||
|
* Throws RangeError if placed out of bounds. Returns all overlapping GridPlacements.
|
||||||
|
*/
|
||||||
|
getStashOverlaps(col: number, row: number, w: number, h: number): GridPlacement[] {
|
||||||
|
if (col < 0 || row < 0 || col + w > STASH_GRID_ORIGIN.cols || row + h > STASH_GRID_ORIGIN.rows) {
|
||||||
|
throw new RangeError('Out of 6x8 stash grid bounds')
|
||||||
|
}
|
||||||
|
return this.stashItems.filter(p => {
|
||||||
|
const ox = col < p.col + p.item.invWidth && col + w > p.col
|
||||||
|
const oy = row < p.row + p.item.invHeight && row + h > p.row
|
||||||
|
return ox && oy
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
canPlaceInStash(col: number, row: number, w: number, h: number): boolean {
|
||||||
|
if (col < 0 || row < 0 || col + w > STASH_GRID_ORIGIN.cols || row + h > STASH_GRID_ORIGIN.rows) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return this.getStashOverlaps(col, row, w, h).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
autoPlaceInStash(item: UiInventoryItem): boolean {
|
||||||
|
for (let col = 0; col <= STASH_GRID_ORIGIN.cols - item.invWidth; col++) {
|
||||||
|
for (let row = 0; row <= STASH_GRID_ORIGIN.rows - item.invHeight; row++) {
|
||||||
|
if (this.canPlaceInStash(col, row, item.invWidth, item.invHeight)) {
|
||||||
|
this.stashItems.push({ item, col, row })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
clickStashCell(
|
||||||
|
col: number,
|
||||||
|
row: number,
|
||||||
|
cursorItem: UiInventoryItem | null,
|
||||||
|
): { handled: boolean; newCursorItem: UiInventoryItem | null } {
|
||||||
|
if (!cursorItem) {
|
||||||
|
const hit = this.stashItems.find(
|
||||||
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
if (!hit) return { handled: false, newCursorItem: null }
|
||||||
|
this.stashItems = this.stashItems.filter(p => p !== hit)
|
||||||
|
return { handled: true, newCursorItem: hit.item }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
col < 0 ||
|
||||||
|
row < 0 ||
|
||||||
|
col + cursorItem.invWidth > STASH_GRID_ORIGIN.cols ||
|
||||||
|
row + cursorItem.invHeight > STASH_GRID_ORIGIN.rows
|
||||||
|
) {
|
||||||
|
return { handled: false, newCursorItem: cursorItem }
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlaps = this.getStashOverlaps(col, row, cursorItem.invWidth, cursorItem.invHeight)
|
||||||
|
if (overlaps.length === 0) {
|
||||||
|
this.stashItems.push({ item: cursorItem, col, row })
|
||||||
|
return { handled: true, newCursorItem: null }
|
||||||
|
}
|
||||||
|
if (overlaps.length === 1) {
|
||||||
|
const single = overlaps[0]!
|
||||||
|
this.stashItems = this.stashItems.filter(p => p !== single)
|
||||||
|
this.stashItems.push({ item: cursorItem, col, row })
|
||||||
|
return { handled: true, newCursorItem: single.item }
|
||||||
|
}
|
||||||
|
return { handled: false, newCursorItem: cursorItem }
|
||||||
|
}
|
||||||
|
|
||||||
|
quickTransferToStash(item: UiInventoryItem, fromInventory: InventoryPanel): boolean {
|
||||||
|
const hitIdx = fromInventory.gridItems.findIndex(p => p.item.id === item.id)
|
||||||
|
if (hitIdx === -1) return false
|
||||||
|
if (!this.autoPlaceInStash(item)) return false
|
||||||
|
fromInventory.gridItems.splice(hitIdx, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
quickTransferFromStash(item: UiInventoryItem, toInventory: InventoryPanel): boolean {
|
||||||
|
const hitIdx = this.stashItems.findIndex(p => p.item.id === item.id)
|
||||||
|
if (hitIdx === -1) return false
|
||||||
|
if (!toInventory.autoPlaceInGrid(item)) return false
|
||||||
|
this.stashItems.splice(hitIdx, 1)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
depositGold(amount: number, playerInventory: { gold: number }): number {
|
||||||
|
if (amount <= 0) return 0
|
||||||
|
const available = Math.max(0, Math.min(amount, playerInventory.gold, STASH_GOLD_CAP - this.stashGold))
|
||||||
|
if (available <= 0) return 0
|
||||||
|
playerInventory.gold -= available
|
||||||
|
this.stashGold += available
|
||||||
|
return available
|
||||||
|
}
|
||||||
|
|
||||||
|
withdrawGold(amount: number, playerInventory: { gold: number }): number {
|
||||||
|
if (amount <= 0) return 0
|
||||||
|
const available = Math.max(0, Math.min(amount, this.stashGold, PLAYER_GOLD_CAP - playerInventory.gold))
|
||||||
|
if (available <= 0) return 0
|
||||||
|
this.stashGold -= available
|
||||||
|
playerInventory.gold += available
|
||||||
|
return available
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseMove(logicalX: number, logicalY: number): void {
|
||||||
|
this.hoveredStashItem = null
|
||||||
|
if (
|
||||||
|
logicalX >= STASH_GRID_ORIGIN.x &&
|
||||||
|
logicalX < STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx &&
|
||||||
|
logicalY >= STASH_GRID_ORIGIN.y &&
|
||||||
|
logicalY < STASH_GRID_ORIGIN.y + STASH_GRID_ORIGIN.rows * STASH_GRID_ORIGIN.cellPx
|
||||||
|
) {
|
||||||
|
const col = Math.floor((logicalX - STASH_GRID_ORIGIN.x) / STASH_GRID_ORIGIN.cellPx)
|
||||||
|
const row = Math.floor((logicalY - STASH_GRID_ORIGIN.y) / STASH_GRID_ORIGIN.cellPx)
|
||||||
|
const hit = this.stashItems.find(
|
||||||
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
if (hit) {
|
||||||
|
this.hoveredStashItem = {
|
||||||
|
item: hit.item,
|
||||||
|
x: STASH_GRID_ORIGIN.x + (hit.col + hit.item.invWidth / 2) * STASH_GRID_ORIGIN.cellPx,
|
||||||
|
y: STASH_GRID_ORIGIN.y + hit.row * STASH_GRID_ORIGIN.cellPx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleLeftDockClick(
|
handleLeftDockClick(
|
||||||
kind: Exclude<LeftDockPanelKind, 'none' | 'char'>,
|
kind: Exclude<LeftDockPanelKind, 'none' | 'char'>,
|
||||||
logicalX: number,
|
logicalX: number,
|
||||||
|
|
@ -196,6 +336,9 @@ export class WorldPanelsHud {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onWaypointTeleport: (act: number, slug: string, levelId: number) => void
|
onWaypointTeleport: (act: number, slug: string, levelId: number) => void
|
||||||
|
inventory?: InventoryPanel
|
||||||
|
isShiftClick?: boolean
|
||||||
|
isRightClick?: boolean
|
||||||
},
|
},
|
||||||
): boolean {
|
): boolean {
|
||||||
if (logicalX > 400) return false
|
if (logicalX > 400) return false
|
||||||
|
|
@ -251,6 +394,68 @@ export class WorldPanelsHud {
|
||||||
this.cubeTransmuteCount += 1
|
this.cubeTransmuteCount += 1
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
} else if (kind === 'stash') {
|
||||||
|
// 1. Gold deposit button
|
||||||
|
if (
|
||||||
|
logicalX >= STASH_DEPOSIT_BTN_BOUNDS.x &&
|
||||||
|
logicalX <= STASH_DEPOSIT_BTN_BOUNDS.x + STASH_DEPOSIT_BTN_BOUNDS.w &&
|
||||||
|
logicalY >= STASH_DEPOSIT_BTN_BOUNDS.y &&
|
||||||
|
logicalY <= STASH_DEPOSIT_BTN_BOUNDS.y + STASH_DEPOSIT_BTN_BOUNDS.h
|
||||||
|
) {
|
||||||
|
if (callbacks.inventory) {
|
||||||
|
const depositAmt = Math.min(50_000, callbacks.inventory.gold, STASH_GOLD_CAP - this.stashGold)
|
||||||
|
if (depositAmt > 0) {
|
||||||
|
this.depositGold(depositAmt, callbacks.inventory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Gold withdraw button
|
||||||
|
if (
|
||||||
|
logicalX >= STASH_WITHDRAW_BTN_BOUNDS.x &&
|
||||||
|
logicalX <= STASH_WITHDRAW_BTN_BOUNDS.x + STASH_WITHDRAW_BTN_BOUNDS.w &&
|
||||||
|
logicalY >= STASH_WITHDRAW_BTN_BOUNDS.y &&
|
||||||
|
logicalY <= STASH_WITHDRAW_BTN_BOUNDS.y + STASH_WITHDRAW_BTN_BOUNDS.h
|
||||||
|
) {
|
||||||
|
if (callbacks.inventory) {
|
||||||
|
const withdrawAmt = Math.min(50_000, this.stashGold, PLAYER_GOLD_CAP - callbacks.inventory.gold)
|
||||||
|
if (withdrawAmt > 0) {
|
||||||
|
this.withdrawGold(withdrawAmt, callbacks.inventory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Stash 6x8 Grid
|
||||||
|
if (
|
||||||
|
logicalX >= STASH_GRID_ORIGIN.x &&
|
||||||
|
logicalX < STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx &&
|
||||||
|
logicalY >= STASH_GRID_ORIGIN.y &&
|
||||||
|
logicalY < STASH_GRID_ORIGIN.y + STASH_GRID_ORIGIN.rows * STASH_GRID_ORIGIN.cellPx
|
||||||
|
) {
|
||||||
|
const col = Math.floor((logicalX - STASH_GRID_ORIGIN.x) / STASH_GRID_ORIGIN.cellPx)
|
||||||
|
const row = Math.floor((logicalY - STASH_GRID_ORIGIN.y) / STASH_GRID_ORIGIN.cellPx)
|
||||||
|
|
||||||
|
if ((callbacks.isShiftClick || callbacks.isRightClick) && callbacks.inventory) {
|
||||||
|
const hit = this.stashItems.find(
|
||||||
|
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||||
|
)
|
||||||
|
if (hit) {
|
||||||
|
this.quickTransferFromStash(hit.item, callbacks.inventory)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callbacks.inventory) {
|
||||||
|
const result = this.clickStashCell(col, row, callbacks.inventory.cursorItem)
|
||||||
|
if (result.handled) {
|
||||||
|
callbacks.inventory.cursorItem = result.newCursorItem
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -267,6 +472,7 @@ export class WorldPanelsHud {
|
||||||
vendorBgImg: HTMLImageElement | null
|
vendorBgImg: HTMLImageElement | null
|
||||||
buySellBtnImg: HTMLImageElement | null
|
buySellBtnImg: HTMLImageElement | null
|
||||||
questsAtlasImg?: HTMLImageElement | null
|
questsAtlasImg?: HTMLImageElement | null
|
||||||
|
itemsAtlasImg?: HTMLImageElement | null
|
||||||
},
|
},
|
||||||
font: D2FontRenderer,
|
font: D2FontRenderer,
|
||||||
): void {
|
): void {
|
||||||
|
|
@ -372,11 +578,111 @@ export class WorldPanelsHud {
|
||||||
}
|
}
|
||||||
} else if (kind === 'stash') {
|
} else if (kind === 'stash') {
|
||||||
if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy)
|
if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy)
|
||||||
font.drawText(ctx, `私人储物箱金币: ${this.stashGold.toLocaleString()}`, ox + 160, oy + 32, {
|
font.drawText(ctx, '私人储物箱 (Private Stash)', ox + 160, oy + 26, {
|
||||||
font: 'fontexocet10',
|
font: 'fontexocet10',
|
||||||
color: 'gold',
|
color: 'gold',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
|
font.drawText(ctx, `储物箱金币: ${this.stashGold.toLocaleString()}`, ox + 160, oy + 48, {
|
||||||
|
font: 'font8',
|
||||||
|
color: 'white',
|
||||||
|
align: 'center',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 6x8 grid cell borders
|
||||||
|
for (let r = 0; r < STASH_GRID_ORIGIN.rows; r++) {
|
||||||
|
for (let c = 0; c < STASH_GRID_ORIGIN.cols; c++) {
|
||||||
|
const cx = STASH_GRID_ORIGIN.x + c * STASH_GRID_ORIGIN.cellPx
|
||||||
|
const cy = STASH_GRID_ORIGIN.y + r * STASH_GRID_ORIGIN.cellPx
|
||||||
|
ctx.strokeStyle = 'rgba(64, 52, 38, 0.4)'
|
||||||
|
ctx.strokeRect(cx + 0.5, cy + 0.5, STASH_GRID_ORIGIN.cellPx - 1, STASH_GRID_ORIGIN.cellPx - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw items stored in stash
|
||||||
|
const atlas = assets.itemsAtlasImg ?? null
|
||||||
|
for (const placed of this.stashItems) {
|
||||||
|
const gx = STASH_GRID_ORIGIN.x + placed.col * STASH_GRID_ORIGIN.cellPx
|
||||||
|
const gy = STASH_GRID_ORIGIN.y + placed.row * STASH_GRID_ORIGIN.cellPx
|
||||||
|
const gw = placed.item.invWidth * STASH_GRID_ORIGIN.cellPx
|
||||||
|
const gh = placed.item.invHeight * STASH_GRID_ORIGIN.cellPx
|
||||||
|
|
||||||
|
ctx.fillStyle =
|
||||||
|
placed.item.quality === 'unique'
|
||||||
|
? 'rgba(58, 42, 16, 0.45)'
|
||||||
|
: placed.item.quality === 'set'
|
||||||
|
? 'rgba(16, 52, 22, 0.45)'
|
||||||
|
: 'rgba(20, 32, 54, 0.45)'
|
||||||
|
ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2)
|
||||||
|
|
||||||
|
const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects)
|
||||||
|
if (atlas && rect) {
|
||||||
|
const scale = Math.min(gw / rect.w, gh / rect.h, 1)
|
||||||
|
const dw = Math.round(rect.w * scale)
|
||||||
|
const dh = Math.round(rect.h * scale)
|
||||||
|
const dx = gx + Math.round((gw - dw) / 2)
|
||||||
|
const dy = gy + Math.round((gh - dh) / 2)
|
||||||
|
ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh)
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle =
|
||||||
|
placed.item.quality === 'unique' ? '#908858' : placed.item.quality === 'set' ? '#00c400' : '#4850b8'
|
||||||
|
ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deposit button
|
||||||
|
ctx.fillStyle = 'rgba(20, 16, 12, 0.9)'
|
||||||
|
ctx.fillRect(
|
||||||
|
STASH_DEPOSIT_BTN_BOUNDS.x,
|
||||||
|
STASH_DEPOSIT_BTN_BOUNDS.y,
|
||||||
|
STASH_DEPOSIT_BTN_BOUNDS.w,
|
||||||
|
STASH_DEPOSIT_BTN_BOUNDS.h,
|
||||||
|
)
|
||||||
|
ctx.strokeStyle = '#8a7248'
|
||||||
|
ctx.strokeRect(
|
||||||
|
STASH_DEPOSIT_BTN_BOUNDS.x + 0.5,
|
||||||
|
STASH_DEPOSIT_BTN_BOUNDS.y + 0.5,
|
||||||
|
STASH_DEPOSIT_BTN_BOUNDS.w - 1,
|
||||||
|
STASH_DEPOSIT_BTN_BOUNDS.h - 1,
|
||||||
|
)
|
||||||
|
font.drawText(
|
||||||
|
ctx,
|
||||||
|
'存入',
|
||||||
|
STASH_DEPOSIT_BTN_BOUNDS.x + STASH_DEPOSIT_BTN_BOUNDS.w / 2,
|
||||||
|
STASH_DEPOSIT_BTN_BOUNDS.y + 16,
|
||||||
|
{
|
||||||
|
font: 'font8',
|
||||||
|
color: 'gold',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Withdraw button
|
||||||
|
ctx.fillStyle = 'rgba(20, 16, 12, 0.9)'
|
||||||
|
ctx.fillRect(
|
||||||
|
STASH_WITHDRAW_BTN_BOUNDS.x,
|
||||||
|
STASH_WITHDRAW_BTN_BOUNDS.y,
|
||||||
|
STASH_WITHDRAW_BTN_BOUNDS.w,
|
||||||
|
STASH_WITHDRAW_BTN_BOUNDS.h,
|
||||||
|
)
|
||||||
|
ctx.strokeStyle = '#8a7248'
|
||||||
|
ctx.strokeRect(
|
||||||
|
STASH_WITHDRAW_BTN_BOUNDS.x + 0.5,
|
||||||
|
STASH_WITHDRAW_BTN_BOUNDS.y + 0.5,
|
||||||
|
STASH_WITHDRAW_BTN_BOUNDS.w - 1,
|
||||||
|
STASH_WITHDRAW_BTN_BOUNDS.h - 1,
|
||||||
|
)
|
||||||
|
font.drawText(
|
||||||
|
ctx,
|
||||||
|
'取出',
|
||||||
|
STASH_WITHDRAW_BTN_BOUNDS.x + STASH_WITHDRAW_BTN_BOUNDS.w / 2,
|
||||||
|
STASH_WITHDRAW_BTN_BOUNDS.y + 16,
|
||||||
|
{
|
||||||
|
font: 'font8',
|
||||||
|
color: 'gold',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
)
|
||||||
} else if (kind === 'cube') {
|
} else if (kind === 'cube') {
|
||||||
if (assets.cubeBgImg) ctx.drawImage(assets.cubeBgImg, ox, oy)
|
if (assets.cubeBgImg) ctx.drawImage(assets.cubeBgImg, ox, oy)
|
||||||
font.drawText(ctx, `赫拉迪克方块 (已合成 ${this.cubeTransmuteCount} 次)`, ox + 160, oy + 32, {
|
font.drawText(ctx, `赫拉迪克方块 (已合成 ${this.cubeTransmuteCount} 次)`, ox + 160, oy + 32, {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,607 @@
|
||||||
|
/**
|
||||||
|
* tests/stash.test.ts
|
||||||
|
*
|
||||||
|
* Comprehensive Unit and Parity Test Suite for Milestone M5 (Issue #150):
|
||||||
|
* Camp Town Stash Entities Across 5 Acts & 6x8 Storage Panel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest'
|
||||||
|
import { readFileSync, existsSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import {
|
||||||
|
STASH_PANEL_ORIGIN,
|
||||||
|
STASH_GRID_ORIGIN,
|
||||||
|
STASH_GOLD_CAP,
|
||||||
|
PLAYER_GOLD_CAP,
|
||||||
|
STASH_DEPOSIT_BTN_BOUNDS,
|
||||||
|
STASH_WITHDRAW_BTN_BOUNDS,
|
||||||
|
WorldPanelsHud,
|
||||||
|
} from '../src/ui/world-panels.ts'
|
||||||
|
import {
|
||||||
|
InventoryPanel,
|
||||||
|
type UiInventoryItem,
|
||||||
|
INV_GRID_ORIGIN,
|
||||||
|
} from '../src/ui/inventory.ts'
|
||||||
|
import {
|
||||||
|
type TownStashEntity,
|
||||||
|
CANONICAL_TOWN_STASHES,
|
||||||
|
SceneMouseController,
|
||||||
|
type MapRuntime,
|
||||||
|
} from '../src/scene/act-scene.ts'
|
||||||
|
import { GameEngine } from '../src/game/engine.ts'
|
||||||
|
import { createIsoTerrain } from '../src/game/iso-terrain.ts'
|
||||||
|
import { ViewportCamera } from '../src/sim/camera.ts'
|
||||||
|
import { KeyboardInput } from '../src/sim/input.ts'
|
||||||
|
import { WaypointNetwork } from '../src/game/portal.ts'
|
||||||
|
import { HudManager } from '../src/ui/hud-manager.ts'
|
||||||
|
|
||||||
|
class MockWindow extends EventTarget {
|
||||||
|
innerWidth = 1920
|
||||||
|
innerHeight = 1080
|
||||||
|
devicePixelRatio = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof globalThis.window === 'undefined') {
|
||||||
|
// @ts-ignore
|
||||||
|
globalThis.window = new MockWindow()
|
||||||
|
}
|
||||||
|
if (typeof globalThis.HTMLInputElement === 'undefined') {
|
||||||
|
// @ts-ignore
|
||||||
|
globalThis.HTMLInputElement = class HTMLInputElement {}
|
||||||
|
}
|
||||||
|
if (typeof globalThis.HTMLSelectElement === 'undefined') {
|
||||||
|
// @ts-ignore
|
||||||
|
globalThis.HTMLSelectElement = class HTMLSelectElement {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDummyItem(id: string, code: string, w: number, h: number, nameZh: string): UiInventoryItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
code,
|
||||||
|
invFile: code,
|
||||||
|
name: nameZh,
|
||||||
|
nameZh,
|
||||||
|
baseNameZh: nameZh,
|
||||||
|
quality: 'normal',
|
||||||
|
invWidth: w,
|
||||||
|
invHeight: h,
|
||||||
|
allowedSlots: [],
|
||||||
|
stats: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Milestone M5 (Issue #150): Camp Town Stash Entities Across 5 Acts & 6x8 Storage Panel', () => {
|
||||||
|
describe('1. Authentic Town Stash Entities Across Acts 1..5', () => {
|
||||||
|
it('defines canonical stash entities for all 5 acts with Objects.txt row 267 B6 token', () => {
|
||||||
|
for (let act = 1; act <= 5; act++) {
|
||||||
|
const stash = CANONICAL_TOWN_STASHES[act]
|
||||||
|
expect(stash, `Expected canonical stash for Act ${act}`).toBeDefined()
|
||||||
|
expect(stash.act).toBe(act)
|
||||||
|
expect(stash.objectsTxtId).toBe(267)
|
||||||
|
expect(stash.token).toBe('B6')
|
||||||
|
expect(stash.clickRadiusPx).toBe(48)
|
||||||
|
expect(stash.interactionRangePx).toBe(80)
|
||||||
|
expect(stash.x).toBeGreaterThan(0)
|
||||||
|
expect(stash.y).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies exact 1.13c coordinates for all 5 act towns', () => {
|
||||||
|
// Act 1: Rogue Encampment (towne1 default)
|
||||||
|
expect(CANONICAL_TOWN_STASHES[1].x).toBe(4512)
|
||||||
|
expect(CANONICAL_TOWN_STASHES[1].y).toBe(1720)
|
||||||
|
expect(CANONICAL_TOWN_STASHES[1].subTileX).toBe(112)
|
||||||
|
expect(CANONICAL_TOWN_STASHES[1].subTileY).toBe(86)
|
||||||
|
|
||||||
|
// Act 2: Lut Gholein
|
||||||
|
expect(CANONICAL_TOWN_STASHES[2].x).toBe(5328)
|
||||||
|
expect(CANONICAL_TOWN_STASHES[2].y).toBe(1568)
|
||||||
|
|
||||||
|
// Act 3: Kurast Docks
|
||||||
|
expect(CANONICAL_TOWN_STASHES[3].x).toBe(5264)
|
||||||
|
expect(CANONICAL_TOWN_STASHES[3].y).toBe(1584)
|
||||||
|
|
||||||
|
// Act 4: The Pandemonium Fortress
|
||||||
|
expect(CANONICAL_TOWN_STASHES[4].x).toBe(704)
|
||||||
|
expect(CANONICAL_TOWN_STASHES[4].y).toBe(552)
|
||||||
|
|
||||||
|
// Act 5: Harrogath
|
||||||
|
expect(CANONICAL_TOWN_STASHES[5].x).toBe(3792)
|
||||||
|
expect(CANONICAL_TOWN_STASHES[5].y).toBe(1448)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies prebaked town scenes in samples/d2-packs contain the B6 stash object', () => {
|
||||||
|
const townScenes: Record<number, string> = {
|
||||||
|
1: 'samples/d2-packs/act1-towne1/scene.json',
|
||||||
|
2: 'samples/d2-packs/act2-lutn/scene.json',
|
||||||
|
3: 'samples/d2-packs/act3-docktown3/scene.json',
|
||||||
|
4: 'samples/d2-packs/act4-fortress/scene.json',
|
||||||
|
5: 'samples/d2-packs/act5-townwest/scene.json',
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let act = 1; act <= 5; act++) {
|
||||||
|
const p = join(process.cwd(), townScenes[act]!)
|
||||||
|
if (existsSync(p)) {
|
||||||
|
const raw = readFileSync(p, 'utf-8')
|
||||||
|
const scene = JSON.parse(raw)
|
||||||
|
const stashObj = scene.objects?.find((o: any) => o.objectsTxtId === 267 || o.token === 'B6')
|
||||||
|
expect(stashObj, `Expected B6 stash object in scene ${townScenes[act]}`).toBeDefined()
|
||||||
|
expect(stashObj.token).toBe('B6')
|
||||||
|
expect(stashObj.objectsTxtId).toBe(267)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('2. Stash Panel & 6x8 Grid Layout & Dimensions', () => {
|
||||||
|
it('defines authentic 6x8 grid origin and 29x29 cell size inside 320x432 panel', () => {
|
||||||
|
expect(STASH_PANEL_ORIGIN.w).toBe(320)
|
||||||
|
expect(STASH_PANEL_ORIGIN.h).toBe(432)
|
||||||
|
expect(STASH_GRID_ORIGIN.cols).toBe(6)
|
||||||
|
expect(STASH_GRID_ORIGIN.rows).toBe(8)
|
||||||
|
expect(STASH_GRID_ORIGIN.cellPx).toBe(29)
|
||||||
|
|
||||||
|
// Absolute grid top-left: ox (80) + 74 = 154, oy (60) + 82 = 142
|
||||||
|
expect(STASH_GRID_ORIGIN.x).toBe(154)
|
||||||
|
expect(STASH_GRID_ORIGIN.y).toBe(142)
|
||||||
|
|
||||||
|
// Absolute grid width: 6 * 29 = 174px -> right boundary = 154 + 174 = 328
|
||||||
|
// Absolute grid height: 8 * 29 = 232px -> bottom boundary = 142 + 232 = 374
|
||||||
|
expect(STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx).toBe(328)
|
||||||
|
expect(STASH_GRID_ORIGIN.y + STASH_GRID_ORIGIN.rows * STASH_GRID_ORIGIN.cellPx).toBe(374)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defines deposit and withdraw button bounds inside stash panel', () => {
|
||||||
|
expect(STASH_DEPOSIT_BTN_BOUNDS.x).toBeGreaterThanOrEqual(STASH_PANEL_ORIGIN.x)
|
||||||
|
expect(STASH_DEPOSIT_BTN_BOUNDS.x + STASH_DEPOSIT_BTN_BOUNDS.w).toBeLessThanOrEqual(
|
||||||
|
STASH_PANEL_ORIGIN.x + STASH_PANEL_ORIGIN.w,
|
||||||
|
)
|
||||||
|
expect(STASH_WITHDRAW_BTN_BOUNDS.x).toBeGreaterThanOrEqual(STASH_PANEL_ORIGIN.x)
|
||||||
|
expect(STASH_WITHDRAW_BTN_BOUNDS.x + STASH_WITHDRAW_BTN_BOUNDS.w).toBeLessThanOrEqual(
|
||||||
|
STASH_PANEL_ORIGIN.x + STASH_PANEL_ORIGIN.w,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirms stash background graphic asset exists in public/ui/stash-bg.png', () => {
|
||||||
|
const bgPath = join(process.cwd(), 'public/ui/stash-bg.png')
|
||||||
|
expect(existsSync(bgPath)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('3. Stash Grid Placement, Collision, and Bounds Validation', () => {
|
||||||
|
let panels: WorldPanelsHud
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
panels = new WorldPanelsHud()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('places an item in free stash cells and checks collision overlaps', () => {
|
||||||
|
const ring = createDummyItem('ring1', 'rin', 1, 1, '乔丹之石 (Stone of Jordan)')
|
||||||
|
expect(panels.canPlaceInStash(0, 0, 1, 1)).toBe(true)
|
||||||
|
panels.stashItems.push({ item: ring, col: 0, row: 0 })
|
||||||
|
|
||||||
|
// Same cell is now blocked
|
||||||
|
expect(panels.canPlaceInStash(0, 0, 1, 1)).toBe(false)
|
||||||
|
expect(panels.getStashOverlaps(0, 0, 1, 1)).toHaveLength(1)
|
||||||
|
expect(panels.getStashOverlaps(0, 0, 1, 1)[0]!.item.id).toBe('ring1')
|
||||||
|
|
||||||
|
// Adjacent cell (1, 0) is free
|
||||||
|
expect(panels.canPlaceInStash(1, 0, 1, 1)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects multi-cell items overlapping existing items', () => {
|
||||||
|
const sword = createDummyItem('sword1', 'crs', 2, 3, '水晶剑 (Crystal Sword)')
|
||||||
|
panels.stashItems.push({ item: sword, col: 1, row: 1 })
|
||||||
|
|
||||||
|
// An overlapping 2x2 shield at (0, 1) overlaps sword at col 1
|
||||||
|
expect(panels.canPlaceInStash(0, 1, 2, 2)).toBe(false)
|
||||||
|
const overlaps = panels.getStashOverlaps(0, 1, 2, 2)
|
||||||
|
expect(overlaps).toHaveLength(1)
|
||||||
|
expect(overlaps[0]!.item.id).toBe('sword1')
|
||||||
|
|
||||||
|
// Non-overlapping placement at (3, 1) is allowed
|
||||||
|
expect(panels.canPlaceInStash(3, 1, 2, 2)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strictly enforces 6x8 bounds (throws RangeError in getStashOverlaps, returns false in canPlaceInStash)', () => {
|
||||||
|
// 2x2 item at col 5, row 0 exceeds 6 cols (5 + 2 = 7 > 6)
|
||||||
|
expect(panels.canPlaceInStash(5, 0, 2, 2)).toBe(false)
|
||||||
|
expect(() => panels.getStashOverlaps(5, 0, 2, 2)).toThrow(RangeError)
|
||||||
|
|
||||||
|
// 1x3 item at col 0, row 6 exceeds 8 rows (6 + 3 = 9 > 8)
|
||||||
|
expect(panels.canPlaceInStash(0, 6, 1, 3)).toBe(false)
|
||||||
|
expect(() => panels.getStashOverlaps(0, 6, 1, 3)).toThrow(RangeError)
|
||||||
|
|
||||||
|
// Negative coordinates
|
||||||
|
expect(panels.canPlaceInStash(-1, 0, 1, 1)).toBe(false)
|
||||||
|
expect(() => panels.getStashOverlaps(-1, 0, 1, 1)).toThrow(RangeError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('autoPlaceInStash finds the first free slot or returns false when full', () => {
|
||||||
|
const grandCharm = createDummyItem('gc1', 'cm3', 1, 3, '超大型护身符 (Grand Charm)')
|
||||||
|
const placed = panels.autoPlaceInStash(grandCharm)
|
||||||
|
expect(placed).toBe(true)
|
||||||
|
expect(panels.stashItems).toHaveLength(1)
|
||||||
|
expect(panels.stashItems[0]!.col).toBe(0)
|
||||||
|
expect(panels.stashItems[0]!.row).toBe(0)
|
||||||
|
|
||||||
|
// Second grand charm should be placed at col 0, row 3 (underneath)
|
||||||
|
const gc2 = createDummyItem('gc2', 'cm3', 1, 3, '超大型护身符 (Grand Charm 2)')
|
||||||
|
expect(panels.autoPlaceInStash(gc2)).toBe(true)
|
||||||
|
expect(panels.stashItems[1]!.col).toBe(0)
|
||||||
|
expect(panels.stashItems[1]!.row).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clickStashCell handles picking up, placing, and swapping items', () => {
|
||||||
|
const helm = createDummyItem('helm1', 'cap', 2, 2, '军帽 (Shako)')
|
||||||
|
panels.stashItems.push({ item: helm, col: 0, row: 0 })
|
||||||
|
|
||||||
|
// 1. Pick up item with empty cursor
|
||||||
|
const pickRes = panels.clickStashCell(0, 0, null)
|
||||||
|
expect(pickRes.handled).toBe(true)
|
||||||
|
expect(pickRes.newCursorItem?.id).toBe('helm1')
|
||||||
|
expect(panels.stashItems).toHaveLength(0)
|
||||||
|
|
||||||
|
// 2. Place item down at (2, 2)
|
||||||
|
const placeRes = panels.clickStashCell(2, 2, pickRes.newCursorItem)
|
||||||
|
expect(placeRes.handled).toBe(true)
|
||||||
|
expect(placeRes.newCursorItem).toBeNull()
|
||||||
|
expect(panels.stashItems).toHaveLength(1)
|
||||||
|
expect(panels.stashItems[0]!.col).toBe(2)
|
||||||
|
expect(panels.stashItems[0]!.row).toBe(2)
|
||||||
|
|
||||||
|
// 3. Swap with held ring
|
||||||
|
const ring = createDummyItem('ring2', 'rin', 1, 1, '布尔凯索之戒')
|
||||||
|
const swapRes = panels.clickStashCell(2, 2, ring)
|
||||||
|
expect(swapRes.handled).toBe(true)
|
||||||
|
expect(swapRes.newCursorItem?.id).toBe('helm1')
|
||||||
|
expect(panels.stashItems).toHaveLength(1)
|
||||||
|
expect(panels.stashItems[0]!.item.id).toBe('ring2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('4. Quick Transfer Between Inventory and Stash', () => {
|
||||||
|
let panels: WorldPanelsHud
|
||||||
|
let inv: InventoryPanel
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
panels = new WorldPanelsHud()
|
||||||
|
inv = new InventoryPanel()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('quickTransferToStash moves item from inventory to stash and removes from inventory', () => {
|
||||||
|
const item = createDummyItem('transfer1', 'rin', 1, 1, '测试戒指')
|
||||||
|
inv.gridItems = [{ item, col: 0, row: 0 }]
|
||||||
|
expect(panels.stashItems).toHaveLength(0)
|
||||||
|
|
||||||
|
const success = panels.quickTransferToStash(item, inv)
|
||||||
|
expect(success).toBe(true)
|
||||||
|
expect(inv.gridItems).toHaveLength(0)
|
||||||
|
expect(panels.stashItems).toHaveLength(1)
|
||||||
|
expect(panels.stashItems[0]!.item.id).toBe('transfer1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('quickTransferToStash fails without removing item if stash is full', () => {
|
||||||
|
// Fill stash with 48 1x1 items
|
||||||
|
for (let r = 0; r < 8; r++) {
|
||||||
|
for (let c = 0; c < 6; c++) {
|
||||||
|
panels.stashItems.push({
|
||||||
|
item: createDummyItem(`fill-${r}-${c}`, 'rin', 1, 1, '填充物'),
|
||||||
|
col: c,
|
||||||
|
row: r,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(panels.stashItems).toHaveLength(48)
|
||||||
|
|
||||||
|
const overflowItem = createDummyItem('overflow', 'rin', 1, 1, '溢出物品')
|
||||||
|
inv.gridItems = [{ item: overflowItem, col: 0, row: 0 }]
|
||||||
|
|
||||||
|
const success = panels.quickTransferToStash(overflowItem, inv)
|
||||||
|
expect(success).toBe(false)
|
||||||
|
expect(inv.gridItems).toHaveLength(1)
|
||||||
|
expect(panels.stashItems).toHaveLength(48)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('quickTransferFromStash moves item from stash to inventory and removes from stash', () => {
|
||||||
|
const item = createDummyItem('fromStash1', 'rin', 1, 1, '储物箱戒指')
|
||||||
|
panels.stashItems = [{ item, col: 0, row: 0 }]
|
||||||
|
inv.gridItems = []
|
||||||
|
|
||||||
|
const success = panels.quickTransferFromStash(item, inv)
|
||||||
|
expect(success).toBe(true)
|
||||||
|
expect(panels.stashItems).toHaveLength(0)
|
||||||
|
expect(inv.gridItems).toHaveLength(1)
|
||||||
|
expect(inv.gridItems[0]!.item.id).toBe('fromStash1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('quickTransferFromStash fails without removing item if inventory is full', () => {
|
||||||
|
// Fill inventory with 40 1x1 items
|
||||||
|
inv.gridItems = []
|
||||||
|
for (let r = 0; r < 4; r++) {
|
||||||
|
for (let c = 0; c < 10; c++) {
|
||||||
|
inv.gridItems.push({
|
||||||
|
item: createDummyItem(`inv-fill-${r}-${c}`, 'rin', 1, 1, '背包填充'),
|
||||||
|
col: c,
|
||||||
|
row: r,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(inv.gridItems).toHaveLength(40)
|
||||||
|
|
||||||
|
const stashItem = createDummyItem('cantTransfer', 'rin', 1, 1, '无法转移')
|
||||||
|
panels.stashItems = [{ item: stashItem, col: 0, row: 0 }]
|
||||||
|
|
||||||
|
const success = panels.quickTransferFromStash(stashItem, inv)
|
||||||
|
expect(success).toBe(false)
|
||||||
|
expect(panels.stashItems).toHaveLength(1)
|
||||||
|
expect(inv.gridItems).toHaveLength(40)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('5. Gold Deposit and Withdrawal with Caps', () => {
|
||||||
|
let panels: WorldPanelsHud
|
||||||
|
let inv: InventoryPanel
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
panels = new WorldPanelsHud()
|
||||||
|
inv = new InventoryPanel()
|
||||||
|
inv.gold = 500_000
|
||||||
|
panels.stashGold = 100_000
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deposits gold from inventory into stash up to capacity', () => {
|
||||||
|
const deposited = panels.depositGold(50_000, inv)
|
||||||
|
expect(deposited).toBe(50_000)
|
||||||
|
expect(inv.gold).toBe(450_000)
|
||||||
|
expect(panels.stashGold).toBe(150_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('withdraws gold from stash into inventory up to player capacity', () => {
|
||||||
|
const withdrawn = panels.withdrawGold(40_000, inv)
|
||||||
|
expect(withdrawn).toBe(40_000)
|
||||||
|
expect(inv.gold).toBe(540_000)
|
||||||
|
expect(panels.stashGold).toBe(60_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects STASH_GOLD_CAP (2,500,000) and clamps deposit amounts', () => {
|
||||||
|
expect(STASH_GOLD_CAP).toBe(2_500_000)
|
||||||
|
panels.stashGold = 2_490_000
|
||||||
|
inv.gold = 100_000
|
||||||
|
|
||||||
|
const deposited = panels.depositGold(50_000, inv)
|
||||||
|
expect(deposited).toBe(10_000)
|
||||||
|
expect(panels.stashGold).toBe(2_500_000)
|
||||||
|
expect(inv.gold).toBe(90_000)
|
||||||
|
|
||||||
|
// Further deposits do nothing
|
||||||
|
expect(panels.depositGold(10_000, inv)).toBe(0)
|
||||||
|
expect(panels.stashGold).toBe(2_500_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects PLAYER_GOLD_CAP (2,500,000) and clamps withdrawal amounts', () => {
|
||||||
|
expect(PLAYER_GOLD_CAP).toBe(2_500_000)
|
||||||
|
panels.stashGold = 500_000
|
||||||
|
inv.gold = 2_480_000
|
||||||
|
|
||||||
|
const withdrawn = panels.withdrawGold(50_000, inv)
|
||||||
|
expect(withdrawn).toBe(20_000)
|
||||||
|
expect(inv.gold).toBe(2_500_000)
|
||||||
|
expect(panels.stashGold).toBe(480_000)
|
||||||
|
|
||||||
|
// Further withdrawals do nothing
|
||||||
|
expect(panels.withdrawGold(10_000, inv)).toBe(0)
|
||||||
|
expect(inv.gold).toBe(2_500_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles zero or negative transfers safely', () => {
|
||||||
|
expect(panels.depositGold(0, inv)).toBe(0)
|
||||||
|
expect(panels.depositGold(-500, inv)).toBe(0)
|
||||||
|
expect(panels.withdrawGold(0, inv)).toBe(0)
|
||||||
|
expect(panels.withdrawGold(-500, inv)).toBe(0)
|
||||||
|
expect(inv.gold).toBe(500_000)
|
||||||
|
expect(panels.stashGold).toBe(100_000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('6. SceneMouseController Stash Click & Interaction', () => {
|
||||||
|
let engine: GameEngine
|
||||||
|
let camera: ViewportCamera
|
||||||
|
let input: KeyboardInput
|
||||||
|
let waypointNetwork: WaypointNetwork
|
||||||
|
let statusElem: HTMLElement
|
||||||
|
let controller: SceneMouseController
|
||||||
|
let runtime: MapRuntime
|
||||||
|
let stashOpenCount: number
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stashOpenCount = 0
|
||||||
|
const grid = {
|
||||||
|
originX: 0,
|
||||||
|
originY: 0,
|
||||||
|
cellsX: 100,
|
||||||
|
cellsY: 100,
|
||||||
|
blocked: new Uint8Array(500 * 500),
|
||||||
|
gridWidth: 500,
|
||||||
|
}
|
||||||
|
const terrain = createIsoTerrain(grid, 8000, 4000, { width: 20, height: 10 })
|
||||||
|
engine = new GameEngine(terrain, {
|
||||||
|
spawn: { x: 4500, y: 1700 },
|
||||||
|
stats: [],
|
||||||
|
monsterCount: 0,
|
||||||
|
questDefs: [],
|
||||||
|
npcDefs: [],
|
||||||
|
} as any)
|
||||||
|
const mockCanvas = {
|
||||||
|
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||||||
|
clientWidth: 800,
|
||||||
|
clientHeight: 600,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
getContext: () => null,
|
||||||
|
style: { cursor: '' },
|
||||||
|
} as unknown as HTMLCanvasElement
|
||||||
|
|
||||||
|
camera = new ViewportCamera(mockCanvas, 8000, 4000)
|
||||||
|
input = new KeyboardInput()
|
||||||
|
waypointNetwork = new WaypointNetwork()
|
||||||
|
statusElem = { textContent: '' } as unknown as HTMLElement
|
||||||
|
|
||||||
|
const mockHudManager = {
|
||||||
|
leftPanel: 'none',
|
||||||
|
toggleLeftPanel: (panel: any) => {
|
||||||
|
if (panel === 'stash') stashOpenCount++
|
||||||
|
mockHudManager.leftPanel = panel
|
||||||
|
},
|
||||||
|
clientToLogical: (x: number, y: number) => ({ x, y }),
|
||||||
|
isPointInterceptedByHud: () => false,
|
||||||
|
} as unknown as HudManager
|
||||||
|
|
||||||
|
runtime = {
|
||||||
|
source: 'pack',
|
||||||
|
base: 'samples/d2-packs',
|
||||||
|
level: 'Rogue Encampment',
|
||||||
|
act: 1,
|
||||||
|
levelId: 1,
|
||||||
|
entryPath: 'act1-towne1',
|
||||||
|
entrances: [],
|
||||||
|
warps: [],
|
||||||
|
waypoints: [],
|
||||||
|
stashes: [CANONICAL_TOWN_STASHES[1]!],
|
||||||
|
variants: ['towne1'],
|
||||||
|
variantParam: 'level',
|
||||||
|
variant: 'towne1',
|
||||||
|
quadrants: ['towne1'],
|
||||||
|
quadrantParam: 'level',
|
||||||
|
quadrant: 'towne1',
|
||||||
|
cellsX: 100,
|
||||||
|
cellsY: 100,
|
||||||
|
widthPx: 8000,
|
||||||
|
heightPx: 4000,
|
||||||
|
grid,
|
||||||
|
floors: [],
|
||||||
|
walls: [],
|
||||||
|
roofs: [],
|
||||||
|
objectDrawables: [],
|
||||||
|
spawn: { x: 4500, y: 1700 },
|
||||||
|
npcs: [],
|
||||||
|
monsterPacks: [],
|
||||||
|
monsterTypes: [],
|
||||||
|
objects: 1,
|
||||||
|
frames: 0,
|
||||||
|
notes: [],
|
||||||
|
pages: [],
|
||||||
|
objectPages: [],
|
||||||
|
charBases: [],
|
||||||
|
palette: null,
|
||||||
|
priorityPages: [],
|
||||||
|
loadOrder: [],
|
||||||
|
loadPages: async () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
controller = new SceneMouseController({
|
||||||
|
canvas: mockCanvas,
|
||||||
|
engine,
|
||||||
|
camera,
|
||||||
|
input,
|
||||||
|
getRuntime: () => runtime,
|
||||||
|
hudManager: mockHudManager,
|
||||||
|
waypointNetwork,
|
||||||
|
status: statusElem,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds stash within 48px click radius and ignores clicks further away', () => {
|
||||||
|
const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720)
|
||||||
|
expect(controller.findNearestStash(stash.x, stash.y, 48)).toEqual(stash)
|
||||||
|
expect(controller.findNearestStash(stash.x + 30, stash.y + 20, 48)).toEqual(stash)
|
||||||
|
|
||||||
|
// 60px away exceeds 48px radius
|
||||||
|
expect(controller.findNearestStash(stash.x + 50, stash.y + 50, 48)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens stash immediately when player is within 80px interaction range', () => {
|
||||||
|
const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720)
|
||||||
|
engine.world.player.x = stash.x - 30
|
||||||
|
engine.world.player.y = stash.y
|
||||||
|
|
||||||
|
controller.handleLeftClickWorld(stash.x, stash.y)
|
||||||
|
|
||||||
|
expect(controller.activeClickType).toBe('stash')
|
||||||
|
expect(controller.pendingInteraction).toBeNull()
|
||||||
|
expect(controller.navTarget).toBeNull()
|
||||||
|
expect(stashOpenCount).toBe(1)
|
||||||
|
expect(statusElem.textContent).toContain('储物箱')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('walks toward stash when player is outside 80px range, then opens upon arrival in tick()', () => {
|
||||||
|
const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720)
|
||||||
|
engine.world.player.x = stash.x - 200
|
||||||
|
engine.world.player.y = stash.y
|
||||||
|
|
||||||
|
controller.handleLeftClickWorld(stash.x, stash.y)
|
||||||
|
|
||||||
|
expect(controller.activeClickType).toBe('stash')
|
||||||
|
expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash })
|
||||||
|
expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y })
|
||||||
|
expect(stashOpenCount).toBe(0)
|
||||||
|
|
||||||
|
// Simulate player walking and arriving within 80px
|
||||||
|
engine.world.player.x = stash.x - 60
|
||||||
|
controller.tick()
|
||||||
|
|
||||||
|
expect(controller.pendingInteraction).toBeNull()
|
||||||
|
expect(controller.navTarget).toBeNull()
|
||||||
|
expect(stashOpenCount).toBe(1)
|
||||||
|
expect(statusElem.textContent).toContain('储物箱')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('7. HUD Manager Integration & Split Dock Synergy', () => {
|
||||||
|
let hudCanvas: HTMLCanvasElement
|
||||||
|
let hud: HudManager
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
hudCanvas = {
|
||||||
|
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
getContext: () => null,
|
||||||
|
style: { cursor: '' },
|
||||||
|
} as unknown as HTMLCanvasElement
|
||||||
|
|
||||||
|
hud = new HudManager(hudCanvas, {
|
||||||
|
onWaypointTeleport: () => {},
|
||||||
|
onToggleAutomap: () => {},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggleLeftPanel("stash") automatically opens inventory on the right dock', () => {
|
||||||
|
expect(hud.leftPanel).toBe('none')
|
||||||
|
expect(hud.rightPanel).toBe('none')
|
||||||
|
|
||||||
|
hud.toggleLeftPanel('stash')
|
||||||
|
expect(hud.leftPanel).toBe('stash')
|
||||||
|
expect(hud.rightPanel).toBe('inv')
|
||||||
|
expect(hud.inventory.visible).toBe(true)
|
||||||
|
|
||||||
|
// Toggling stash off leaves right panel intact or allows closing all
|
||||||
|
hud.toggleLeftPanel('stash')
|
||||||
|
expect(hud.leftPanel).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closeAllPanels closes stash and inventory together', () => {
|
||||||
|
hud.toggleLeftPanel('stash')
|
||||||
|
expect(hud.leftPanel).toBe('stash')
|
||||||
|
expect(hud.rightPanel).toBe('inv')
|
||||||
|
|
||||||
|
const closed = hud.closeAllPanels()
|
||||||
|
expect(closed).toBe(true)
|
||||||
|
expect(hud.leftPanel).toBe('none')
|
||||||
|
expect(hud.rightPanel).toBe('none')
|
||||||
|
expect(hud.inventory.visible).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue