fix(ui): 点击商人面板买/卖/修理按钮后鼠标样式切换为原版 buysell.dc6 交易光标 (Fixes #491)
This commit is contained in:
parent
57c57c2662
commit
855d610168
Binary file not shown.
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 8.7 KiB |
|
|
@ -2,11 +2,12 @@
|
|||
* scripts/pack-cursor.ts
|
||||
*
|
||||
* Extracts authentic Diablo II v1.13c cursor DC6 assets from d2data.mpq:
|
||||
* - protate.dc6 (8 frames: pointing hand swaying animation)
|
||||
* - ohand.dc6 (8 frames: finger-to-palm opening transition)
|
||||
* - orotate.dc6 (8 frames: open palm swaying idle animation)
|
||||
* - protate.dc6 (8 frames: pointing hand swaying animation, CursorId 5)
|
||||
* - ohand.dc6 (8 frames: finger-to-palm opening transition, CursorId 2)
|
||||
* - orotate.dc6 (8 frames: open palm swaying idle animation, CursorId 3)
|
||||
* - buysell.dc6 (10 frames: Identify=0, Repair=1, Buy=3, Sell=4, etc., CursorId 6 at 0x6fba8558)
|
||||
*
|
||||
* Packs all 24 frames into public/ui/cursor.png with metadata in src/ui/cursor-meta.ts.
|
||||
* Packs all 34 frames into public/ui/cursor.png with metadata in src/ui/cursor-meta.ts.
|
||||
* Enforces Zero Runtime MPQ/DLL Architectural Invariant.
|
||||
*/
|
||||
|
||||
|
|
@ -29,14 +30,25 @@ export interface CursorFrameMeta {
|
|||
readonly hotspotY: number
|
||||
}
|
||||
|
||||
export const BUYSELL_CURSOR_FRAMES = {
|
||||
identify: 0,
|
||||
repair: 1,
|
||||
buy: 3,
|
||||
sell: 4,
|
||||
} as const
|
||||
|
||||
export type BuySellCursorMode = keyof typeof BUYSELL_CURSOR_FRAMES
|
||||
|
||||
export interface CursorAtlasMetadata {
|
||||
readonly atlasWidth: number
|
||||
readonly atlasHeight: number
|
||||
readonly frameWidth: number
|
||||
readonly frameHeight: number
|
||||
readonly buysellFrameHeight: number
|
||||
readonly protate: readonly CursorFrameMeta[]
|
||||
readonly ohand: readonly CursorFrameMeta[]
|
||||
readonly orotate: readonly CursorFrameMeta[]
|
||||
readonly buysell: readonly CursorFrameMeta[]
|
||||
readonly frames: Record<string, CursorFrameMeta>
|
||||
}
|
||||
|
||||
|
|
@ -62,19 +74,32 @@ export async function packCursorAssets(projectRoot: string = process.cwd()): Pro
|
|||
decodedSheets[name] = frames
|
||||
}
|
||||
|
||||
// Layout: 3 rows (protate, ohand, orotate), 8 columns
|
||||
const buysellFile = mpq.find('data/global/ui/CURSOR/buysell.dc6')
|
||||
if (!buysellFile) throw new Error('Cursor DC6 not found: data/global/ui/CURSOR/buysell.dc6')
|
||||
const buysellSheet = decodeDc6(await mpq.read(buysellFile))
|
||||
const buysellFrames = buysellSheet.groups[0]?.frames ?? []
|
||||
if (buysellFrames.length !== 10) {
|
||||
throw new Error(`Expected 10 frames in buysell.dc6, got ${buysellFrames.length}`)
|
||||
}
|
||||
|
||||
// Layout:
|
||||
// - Rows 0..2 (y = 0..95): 3 hand animations (protate, ohand, orotate), 8 columns x 32x32
|
||||
// - Rows 3..4 (y = 96..175): buysell.dc6 10 frames (frames 0..7 at y=96, frames 8..9 at y=136), 32x40 cells
|
||||
const CELL_W = 32
|
||||
const CELL_H = 32
|
||||
const BUYSELL_CELL_H = 40
|
||||
const COLS = 8
|
||||
const ROWS = 3
|
||||
const HAND_ROWS = 3
|
||||
const BUYSELL_ROWS = 2
|
||||
const atlasWidth = CELL_W * COLS // 256
|
||||
const atlasHeight = CELL_H * ROWS // 96
|
||||
const atlasHeight = CELL_H * HAND_ROWS + BUYSELL_CELL_H * BUYSELL_ROWS // 96 + 80 = 176
|
||||
const pixels = new Uint8Array(atlasWidth * atlasHeight)
|
||||
|
||||
const metaLists: Record<string, CursorFrameMeta[]> = {
|
||||
protate: [],
|
||||
ohand: [],
|
||||
orotate: [],
|
||||
buysell: [],
|
||||
}
|
||||
const allFrames: Record<string, CursorFrameMeta> = {}
|
||||
|
||||
|
|
@ -122,14 +147,53 @@ export async function packCursorAssets(projectRoot: string = process.cwd()): Pro
|
|||
})
|
||||
})
|
||||
|
||||
// Blit buysell.dc6 (10 frames of 32x40)
|
||||
const buysellBaseY = CELL_H * HAND_ROWS // 96
|
||||
buysellFrames.forEach((frame, idx) => {
|
||||
const col = idx % COLS
|
||||
const row = Math.floor(idx / COLS)
|
||||
const dstX = col * CELL_W
|
||||
const dstY = buysellBaseY + row * BUYSELL_CELL_H
|
||||
|
||||
for (let sy = 0; sy < frame.height; sy++) {
|
||||
const ty = dstY + sy
|
||||
for (let sx = 0; sx < frame.width; sx++) {
|
||||
const tx = dstX + sx
|
||||
const srcIdx = sy * frame.width + sx
|
||||
if (frame.mask[srcIdx] !== 0) {
|
||||
const color = frame.indices[srcIdx] ?? 0
|
||||
if (color !== 0) {
|
||||
pixels[ty * atlasWidth + tx] = color
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const frameMeta: CursorFrameMeta = {
|
||||
x: dstX,
|
||||
y: dstY,
|
||||
w: frame.width,
|
||||
h: frame.height,
|
||||
offsetX: frame.offsetX,
|
||||
offsetY: frame.offsetY,
|
||||
hotspotX: 0,
|
||||
hotspotY: 0,
|
||||
}
|
||||
|
||||
metaLists.buysell!.push(frameMeta)
|
||||
allFrames[`buysell_${idx}`] = frameMeta
|
||||
})
|
||||
|
||||
const atlasMeta: CursorAtlasMetadata = {
|
||||
atlasWidth,
|
||||
atlasHeight,
|
||||
frameWidth: CELL_W,
|
||||
frameHeight: CELL_H,
|
||||
buysellFrameHeight: BUYSELL_CELL_H,
|
||||
protate: metaLists.protate!,
|
||||
ohand: metaLists.ohand!,
|
||||
orotate: metaLists.orotate!,
|
||||
buysell: metaLists.buysell!,
|
||||
frames: allFrames,
|
||||
}
|
||||
|
||||
|
|
@ -163,21 +227,39 @@ export interface CursorFrameMeta {
|
|||
readonly hotspotY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* D2Client.dll v1.13c \`buysell.dc6\` cursor frame indices (\`0x6fac62c0\` / \`0x6fbbb238\` +0x10):
|
||||
* - Frame 0: Identify target cursor (\`0x6fb36fb7\` / packet \`0x3f\`)
|
||||
* - Frame 1: Repair anvil/hammer cursor (\`0x6fb3ba70\`, \`nTradeMode = 4\`)
|
||||
* - Frame 3: Buy coin/hand cursor (\`0x6fb3ba30\`, \`nTradeMode = 2\`)
|
||||
* - Frame 4: Sell coin/hand cursor (\`0x6fb3b9f0\`, \`nTradeMode = 3\`)
|
||||
*/
|
||||
export const BUYSELL_CURSOR_FRAMES = {
|
||||
identify: 0,
|
||||
repair: 1,
|
||||
buy: 3,
|
||||
sell: 4,
|
||||
} as const
|
||||
|
||||
export type BuySellCursorMode = keyof typeof BUYSELL_CURSOR_FRAMES
|
||||
|
||||
export interface CursorAtlasMetadata {
|
||||
readonly atlasWidth: number
|
||||
readonly atlasHeight: number
|
||||
readonly frameWidth: number
|
||||
readonly frameHeight: number
|
||||
readonly buysellFrameHeight: number
|
||||
readonly protate: readonly CursorFrameMeta[]
|
||||
readonly ohand: readonly CursorFrameMeta[]
|
||||
readonly orotate: readonly CursorFrameMeta[]
|
||||
readonly buysell: readonly CursorFrameMeta[]
|
||||
readonly frames: Record<string, CursorFrameMeta>
|
||||
}
|
||||
|
||||
export const CURSOR_METADATA: CursorAtlasMetadata = ${JSON.stringify(atlasMeta, null, 2)} as const
|
||||
`
|
||||
writeFileSync(join(projectRoot, 'src', 'ui', 'cursor-meta.ts'), metaCode, 'utf8')
|
||||
console.log(`Extracted 24 authentic cursor frames -> public/ui/cursor.png (${atlasWidth}x${atlasHeight})`)
|
||||
console.log(`Extracted 34 authentic cursor frames (24 hand + 10 buysell) -> public/ui/cursor.png (${atlasWidth}x${atlasHeight})`)
|
||||
console.log('Saved metadata to src/ui/cursor-meta.ts')
|
||||
|
||||
return atlasMeta
|
||||
|
|
|
|||
|
|
@ -15,22 +15,41 @@ export interface CursorFrameMeta {
|
|||
readonly hotspotY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* D2Client.dll v1.13c `buysell.dc6` cursor frame indices (`0x6fac62c0` / `0x6fbbb238` +0x10):
|
||||
* - Frame 0: Identify target cursor (`0x6fb36fb7` / packet `0x3f`)
|
||||
* - Frame 1: Repair anvil/hammer cursor (`0x6fb3ba70`, `nTradeMode = 4`)
|
||||
* - Frame 3: Buy coin/hand cursor (`0x6fb3ba30`, `nTradeMode = 2`)
|
||||
* - Frame 4: Sell coin/hand cursor (`0x6fb3b9f0`, `nTradeMode = 3`)
|
||||
*/
|
||||
export const BUYSELL_CURSOR_FRAMES = {
|
||||
identify: 0,
|
||||
repair: 1,
|
||||
buy: 3,
|
||||
sell: 4,
|
||||
} as const
|
||||
|
||||
export type BuySellCursorMode = keyof typeof BUYSELL_CURSOR_FRAMES
|
||||
|
||||
export interface CursorAtlasMetadata {
|
||||
readonly atlasWidth: number
|
||||
readonly atlasHeight: number
|
||||
readonly frameWidth: number
|
||||
readonly frameHeight: number
|
||||
readonly buysellFrameHeight: number
|
||||
readonly protate: readonly CursorFrameMeta[]
|
||||
readonly ohand: readonly CursorFrameMeta[]
|
||||
readonly orotate: readonly CursorFrameMeta[]
|
||||
readonly buysell: readonly CursorFrameMeta[]
|
||||
readonly frames: Record<string, CursorFrameMeta>
|
||||
}
|
||||
|
||||
export const CURSOR_METADATA: CursorAtlasMetadata = {
|
||||
"atlasWidth": 256,
|
||||
"atlasHeight": 96,
|
||||
"atlasHeight": 176,
|
||||
"frameWidth": 32,
|
||||
"frameHeight": 32,
|
||||
"buysellFrameHeight": 40,
|
||||
"protate": [
|
||||
{
|
||||
"x": 0,
|
||||
|
|
@ -277,6 +296,108 @@ export const CURSOR_METADATA: CursorAtlasMetadata = {
|
|||
"hotspotY": 0
|
||||
}
|
||||
],
|
||||
"buysell": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
{
|
||||
"x": 32,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
{
|
||||
"x": 64,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
{
|
||||
"x": 96,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
{
|
||||
"x": 128,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
{
|
||||
"x": 160,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
{
|
||||
"x": 192,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
{
|
||||
"x": 224,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 136,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
{
|
||||
"x": 32,
|
||||
"y": 136,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
}
|
||||
],
|
||||
"frames": {
|
||||
"protate_0": {
|
||||
"x": 0,
|
||||
|
|
@ -517,6 +638,106 @@ export const CURSOR_METADATA: CursorAtlasMetadata = {
|
|||
"offsetY": 24,
|
||||
"hotspotX": 1,
|
||||
"hotspotY": 0
|
||||
},
|
||||
"buysell_0": {
|
||||
"x": 0,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
"buysell_1": {
|
||||
"x": 32,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
"buysell_2": {
|
||||
"x": 64,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
"buysell_3": {
|
||||
"x": 96,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
"buysell_4": {
|
||||
"x": 128,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
"buysell_5": {
|
||||
"x": 160,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
"buysell_6": {
|
||||
"x": 192,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
"buysell_7": {
|
||||
"x": 224,
|
||||
"y": 96,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
"buysell_8": {
|
||||
"x": 0,
|
||||
"y": 136,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
},
|
||||
"buysell_9": {
|
||||
"x": 32,
|
||||
"y": 136,
|
||||
"w": 32,
|
||||
"h": 40,
|
||||
"offsetX": 0,
|
||||
"offsetY": 0,
|
||||
"hotspotX": 0,
|
||||
"hotspotY": 0
|
||||
}
|
||||
}
|
||||
} as const
|
||||
|
|
|
|||
|
|
@ -16,13 +16,16 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
BUYSELL_CURSOR_FRAMES,
|
||||
CURSOR_METADATA,
|
||||
type BuySellCursorMode,
|
||||
type CursorAtlasMetadata,
|
||||
type CursorFrameMeta,
|
||||
} from './cursor-meta.ts'
|
||||
|
||||
export { BUYSELL_CURSOR_FRAMES, type BuySellCursorMode }
|
||||
export type CursorState = 'POINTING' | 'OPENING' | 'OPEN_IDLE'
|
||||
export type CursorAnimation = 'protate' | 'ohand' | 'orotate'
|
||||
export type CursorAnimation = 'protate' | 'ohand' | 'orotate' | 'buysell'
|
||||
|
||||
export interface CursorRenderInfo {
|
||||
readonly state: CursorState
|
||||
|
|
@ -56,8 +59,19 @@ export class CursorController {
|
|||
mouseX: number = 0
|
||||
mouseY: number = 0
|
||||
lastActivityTime: number = 0
|
||||
lastNowMs: number = 0
|
||||
state: CursorState = 'POINTING'
|
||||
|
||||
/**
|
||||
* Active D2Client.dll `buysell.dc6` cursor override (`CursorId = 6` at `0x6fba8558`):
|
||||
* - `'buy'` -> frame 3 (`0x6fb3ba30`, `nTradeMode = 2`)
|
||||
* - `'sell'` -> frame 4 (`0x6fb3b9f0`, `nTradeMode = 3`)
|
||||
* - `'repair'` -> frame 1 (`0x6fb3ba70`, `nTradeMode = 4`)
|
||||
* - `'identify'` -> frame 0 (`0x6fb36fb7` / packet `0x3f`)
|
||||
* - `null` -> standard hand cursor (`protate` / `ohand` / `orotate`)
|
||||
*/
|
||||
tradeCursorMode: BuySellCursorMode | null = null
|
||||
|
||||
/** Milliseconds of inactivity before transitioning from POINTING to OPENING. */
|
||||
idleThresholdMs: number = 2000
|
||||
|
||||
|
|
@ -70,6 +84,17 @@ export class CursorController {
|
|||
constructor(metadata: CursorAtlasMetadata = CURSOR_METADATA, initialTimeMs: number = 0) {
|
||||
this.metadata = metadata
|
||||
this.lastActivityTime = initialTimeMs
|
||||
this.lastNowMs = initialTimeMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or clears the `buysell.dc6` cursor mode (`'buy' | 'sell' | 'repair' | 'identify' | null`).
|
||||
*/
|
||||
setTradeCursorMode(mode: BuySellCursorMode | null, nowMs: number = this.lastNowMs): void {
|
||||
if (this.tradeCursorMode !== mode) {
|
||||
this.tradeCursorMode = mode
|
||||
this.resetIdle(nowMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -77,6 +102,7 @@ export class CursorController {
|
|||
*/
|
||||
resetIdle(nowMs: number = performance.now()): void {
|
||||
this.lastActivityTime = nowMs
|
||||
this.lastNowMs = nowMs
|
||||
this.state = 'POINTING'
|
||||
}
|
||||
|
||||
|
|
@ -105,8 +131,16 @@ export class CursorController {
|
|||
|
||||
/**
|
||||
* Evaluates the state machine transitions based on elapsed idle time.
|
||||
* Per D2Client.dll `0x6fac62f0` (`D2CLIENT_UpdateCursorIdle`), idle hand opening only runs
|
||||
* when `g_nCursorState == 1 && g_nCursorId == 5` (i.e. when `tradeCursorMode === null`).
|
||||
*/
|
||||
update(nowMs: number = performance.now()): CursorState {
|
||||
this.lastNowMs = nowMs
|
||||
if (this.tradeCursorMode !== null) {
|
||||
this.state = 'POINTING'
|
||||
return this.state
|
||||
}
|
||||
|
||||
const idleMs = Math.max(0, nowMs - this.lastActivityTime)
|
||||
const openingTotalMs = 8 * this.openingFrameDurationMs
|
||||
|
||||
|
|
@ -127,11 +161,21 @@ export class CursorController {
|
|||
getCurrentFrame(nowMs: number = performance.now()): CursorRenderInfo {
|
||||
this.update(nowMs)
|
||||
|
||||
const idleMs = Math.max(0, nowMs - this.lastActivityTime)
|
||||
const openingTotalMs = 8 * this.openingFrameDurationMs
|
||||
|
||||
let anim: CursorAnimation
|
||||
let frameIndex: number
|
||||
let frameMeta: CursorFrameMeta
|
||||
|
||||
if (this.tradeCursorMode !== null) {
|
||||
anim = 'buysell'
|
||||
frameIndex = BUYSELL_CURSOR_FRAMES[this.tradeCursorMode]
|
||||
const buysellFrame = this.metadata.buysell?.[frameIndex]
|
||||
if (!buysellFrame) {
|
||||
throw new Error(`Missing buysell cursor frame ${frameIndex} for mode '${this.tradeCursorMode}'`)
|
||||
}
|
||||
frameMeta = buysellFrame
|
||||
} else {
|
||||
const idleMs = Math.max(0, nowMs - this.lastActivityTime)
|
||||
const openingTotalMs = 8 * this.openingFrameDurationMs
|
||||
|
||||
if (this.state === 'POINTING') {
|
||||
anim = 'protate'
|
||||
|
|
@ -155,7 +199,8 @@ export class CursorController {
|
|||
}
|
||||
|
||||
const frameList = this.metadata[anim] ?? this.metadata.protate
|
||||
const frameMeta = frameList[frameIndex] ?? this.metadata.protate[0]!
|
||||
frameMeta = frameList[frameIndex] ?? this.metadata.protate[0]!
|
||||
}
|
||||
|
||||
const drawX = Math.round(this.mouseX - frameMeta.hotspotX)
|
||||
const drawY = Math.round(this.mouseY - frameMeta.hotspotY)
|
||||
|
|
@ -205,7 +250,7 @@ export class CursorController {
|
|||
return
|
||||
}
|
||||
|
||||
// Otherwise, render the authentic animated hand cursor
|
||||
// Otherwise, render the authentic animated hand cursor or buysell trade cursor
|
||||
if (cursorAtlas) {
|
||||
const info = this.getCurrentFrame(nowMs)
|
||||
ctx.drawImage(
|
||||
|
|
|
|||
|
|
@ -31,8 +31,13 @@ import {
|
|||
} from './inventory.ts'
|
||||
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
|
||||
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
|
||||
import { WorldPanelsHud, type LeftDockPanelKind } from './world-panels.ts'
|
||||
import { CursorController, type CursorState } from './cursor.ts'
|
||||
import { WorldPanelsHud, type LeftDockPanelKind, type VendorTradeState } from './world-panels.ts'
|
||||
import {
|
||||
CursorController,
|
||||
type BuySellCursorMode,
|
||||
type CursorAnimation,
|
||||
type CursorState,
|
||||
} from './cursor.ts'
|
||||
import type { CharacterClassCode } from '../game/classes.ts'
|
||||
import {
|
||||
createStarterProfileForClass,
|
||||
|
|
@ -45,6 +50,9 @@ export interface PublishedHudState {
|
|||
dc6DecodeFailures: number
|
||||
decodedDc6Count: number
|
||||
cursorState: CursorState
|
||||
cursorTradeMode?: BuySellCursorMode | null
|
||||
cursorAnimation?: CursorAnimation
|
||||
cursorFrameIndex?: number
|
||||
assetsLoaded: {
|
||||
ctrlPnl: boolean
|
||||
globes: boolean
|
||||
|
|
@ -81,6 +89,7 @@ export interface PublishedHudState {
|
|||
docking: DockingLayout
|
||||
activeVendor?: string | null
|
||||
vendorMode?: 'trade' | 'gamble'
|
||||
vendorTradeState?: VendorTradeState
|
||||
vendorTab?: string
|
||||
vendorItemsCount?: number
|
||||
npcMenuOpen?: boolean
|
||||
|
|
@ -258,6 +267,9 @@ export class HudManager {
|
|||
dc6DecodeFailures: 0,
|
||||
decodedDc6Count: 415,
|
||||
cursorState: 'POINTING',
|
||||
cursorTradeMode: null,
|
||||
cursorAnimation: 'protate',
|
||||
cursorFrameIndex: 0,
|
||||
assetsLoaded: {
|
||||
ctrlPnl: false,
|
||||
globes: false,
|
||||
|
|
@ -292,6 +304,7 @@ export class HudManager {
|
|||
stamina: 465,
|
||||
maxStamina: 465,
|
||||
docking: computeDockingLayout(800, 600, 1),
|
||||
vendorTradeState: 'idle',
|
||||
}
|
||||
|
||||
currentClass: CharacterClassCode | null = null
|
||||
|
|
@ -750,14 +763,17 @@ export class HudManager {
|
|||
}
|
||||
|
||||
const intercept =
|
||||
this.isPointInterceptedByHud(this.mouseX, this.mouseY) || this.inventory.cursorItem !== null
|
||||
this.isPointInterceptedByHud(this.mouseX, this.mouseY) ||
|
||||
this.inventory.cursorItem !== null ||
|
||||
this.worldPanels.vendorTradeState !== 'idle' ||
|
||||
this.inventory.isIdentifyMode()
|
||||
this.hudCanvas.style.pointerEvents = intercept ? 'auto' : 'none'
|
||||
})
|
||||
|
||||
window.addEventListener('mousedown', (e) => {
|
||||
this.cursor.handleMouseDown()
|
||||
if (e.target !== this.hudCanvas && e.target instanceof HTMLCanvasElement) {
|
||||
handleHudMouseDown(e)
|
||||
this.handleHudMouseDown(e)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -770,7 +786,10 @@ export class HudManager {
|
|||
e.preventDefault()
|
||||
})
|
||||
|
||||
const handleHudMouseDown = (e: MouseEvent): void => {
|
||||
this.hudCanvas.addEventListener('mousedown', (e) => this.handleHudMouseDown(e))
|
||||
}
|
||||
|
||||
handleHudMouseDown(e: MouseEvent): void {
|
||||
if (e.button !== 0 && e.button !== 2) return
|
||||
const pt = this.clientToLogical(e.clientX, e.clientY)
|
||||
if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) {
|
||||
|
|
@ -785,6 +804,13 @@ export class HudManager {
|
|||
this.syncPublishedState()
|
||||
return
|
||||
}
|
||||
if (this.worldPanels.vendorTradeState !== 'idle') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
this.worldPanels.vendorTradeState = 'idle'
|
||||
this.syncPublishedState()
|
||||
return
|
||||
}
|
||||
// If holding cursorItem and clicking outside panels onto the ground, drop it
|
||||
if (this.inventory.cursorItem !== null) {
|
||||
e.preventDefault()
|
||||
|
|
@ -950,6 +976,12 @@ export class HudManager {
|
|||
|
||||
// Vendor Repair or Sell/Quick-Sell from Player Inventory
|
||||
if (this.leftPanel === 'vendor') {
|
||||
// D2Client.dll 0x6fb472d0: Right-clicking while in Buy/Sell/Repair cursor mode cancels back to idle
|
||||
if (e.button === 2 && this.worldPanels.vendorTradeState !== 'idle') {
|
||||
this.worldPanels.vendorTradeState = 'idle'
|
||||
this.syncPublishedState()
|
||||
return
|
||||
}
|
||||
if (
|
||||
rightX >= INV_GRID_ORIGIN.x &&
|
||||
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
||||
|
|
@ -1017,9 +1049,6 @@ export class HudManager {
|
|||
}
|
||||
}
|
||||
|
||||
this.hudCanvas.addEventListener('mousedown', handleHudMouseDown)
|
||||
}
|
||||
|
||||
private handleSkillAllocated(skillId: number): void {
|
||||
if (isPassiveSkill(skillId)) return
|
||||
const catalogEntry = SKILLS_BY_ID[skillId]
|
||||
|
|
@ -1062,7 +1091,22 @@ export class HudManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes the `CursorController` `buysell.dc6` override mode from active HUD states
|
||||
* (`inventory.isIdentifyMode()` or `worldPanels.vendorTradeState`).
|
||||
*/
|
||||
syncCursorMode(nowMs: number = this.cursor.lastNowMs): void {
|
||||
const tradeMode: BuySellCursorMode | null = this.inventory.isIdentifyMode()
|
||||
? 'identify'
|
||||
: this.leftPanel === 'vendor' && this.worldPanels.vendorTradeState !== 'idle'
|
||||
? this.worldPanels.vendorTradeState
|
||||
: null
|
||||
this.cursor.setTradeCursorMode(tradeMode, nowMs)
|
||||
}
|
||||
|
||||
syncPublishedState(): void {
|
||||
this.syncCursorMode(this.cursor.lastNowMs)
|
||||
const cursorInfo = this.cursor.getCurrentFrame(this.cursor.lastNowMs)
|
||||
this.state.dc6DecodeFailures = this.dc6DecodeFailures
|
||||
this.state.decodedDc6Count = this.decodedDc6Count
|
||||
this.state.assetsLoaded = {
|
||||
|
|
@ -1084,6 +1128,9 @@ export class HudManager {
|
|||
this.state.isRunning = this.isRunning
|
||||
this.state.showGroundLabels = this.showGroundLabels
|
||||
this.state.cursorState = this.cursor.state
|
||||
this.state.cursorTradeMode = this.cursor.tradeCursorMode
|
||||
this.state.cursorAnimation = cursorInfo.animation
|
||||
this.state.cursorFrameIndex = cursorInfo.frameIndex
|
||||
this.state.cursorItem = this.inventory.cursorItem?.id ?? null
|
||||
this.state.equippedSlots = Object.keys(this.inventory.equipped) as EquipSlotId[]
|
||||
this.state.bagItemsCount = this.inventory.gridItems.length
|
||||
|
|
@ -1102,6 +1149,7 @@ export class HudManager {
|
|||
this.state.docking = this.getDockingLayout()
|
||||
this.state.activeVendor = this.worldPanels.activeVendorDescriptor?.displayName ?? null
|
||||
this.state.vendorMode = this.worldPanels.vendorMode
|
||||
this.state.vendorTradeState = this.worldPanels.vendorTradeState
|
||||
this.state.vendorTab = this.worldPanels.activeVendorTab
|
||||
this.state.vendorItemsCount = this.worldPanels.getActiveVendorPlacements().length
|
||||
this.state.npcMenuOpen = this.worldPanels.npcMenu !== null
|
||||
|
|
@ -1136,9 +1184,12 @@ export class HudManager {
|
|||
const deltaLeft = -marginW
|
||||
const deltaRight = marginW
|
||||
|
||||
// Enable pointer events on HUD canvas only when hovering HUD/Panels or holding cursorItem
|
||||
// Enable pointer events on HUD canvas only when hovering HUD/Panels, holding cursorItem, or in active trade/identify cursor mode
|
||||
const intercept =
|
||||
this.isPointInterceptedByHud(this.mouseX, this.mouseY) || this.inventory.cursorItem !== null
|
||||
this.isPointInterceptedByHud(this.mouseX, this.mouseY) ||
|
||||
this.inventory.cursorItem !== null ||
|
||||
this.worldPanels.vendorTradeState !== 'idle' ||
|
||||
this.inventory.isIdentifyMode()
|
||||
this.hudCanvas.style.pointerEvents = intercept ? 'auto' : 'none'
|
||||
|
||||
const ctx = this.hudCanvas.getContext('2d')
|
||||
|
|
@ -1325,7 +1376,7 @@ export class HudManager {
|
|||
this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font, this.leftPanel === 'vendor')
|
||||
}
|
||||
|
||||
// 11. Authentic Hand Cursor & Held Item at Hotspot
|
||||
// 11. Authentic Hand / Trade Cursor & Held Item at Hotspot
|
||||
this.renderCursor(ctx, nowMs)
|
||||
|
||||
ctx.restore()
|
||||
|
|
@ -1333,12 +1384,10 @@ export class HudManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Renders the authentic animated hand cursor or held cursorItem at cursor hotspot.
|
||||
* Renders the authentic animated hand cursor, `buysell.dc6` trade/repair/identify cursor, or held cursorItem at cursor hotspot.
|
||||
*/
|
||||
renderCursor(ctx: CanvasRenderingContext2D, nowMs: number = performance.now()): void {
|
||||
if (this.inventory.isIdentifyMode()) {
|
||||
this.cursor.resetIdle(nowMs)
|
||||
}
|
||||
this.syncCursorMode(nowMs)
|
||||
const cursorAtlas = this.images.get('cursorAtlas') ?? this.images.get('cursorHand') ?? null
|
||||
const heldItem = this.inventory.cursorItem
|
||||
? {
|
||||
|
|
@ -1355,12 +1404,5 @@ export class HudManager {
|
|||
? { atlas: heldItem.atlas, rect: heldItem.rect }
|
||||
: null,
|
||||
)
|
||||
|
||||
if (this.inventory.isIdentifyMode() && !this.inventory.cursorItem) {
|
||||
this.font.drawText(ctx, '?', this.mouseX + 12, this.mouseY - 4, {
|
||||
font: 'fontexocet10',
|
||||
color: 'red',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,8 @@ export interface VendorButtonSpec {
|
|||
readonly labelZh: string
|
||||
readonly labelEn: string
|
||||
readonly paramA: number
|
||||
/** D2Client.dll `0x6fbbb238` (+0x10) `buysell.dc6` cursor frame index (`3` Buy, `4` Sell, `1` Repair, `null` none) */
|
||||
readonly cursorFrame: number | null
|
||||
}
|
||||
|
||||
export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButtonSpec>> = {
|
||||
|
|
@ -130,6 +132,7 @@ export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButton
|
|||
labelZh: '',
|
||||
labelEn: '',
|
||||
paramA: 1,
|
||||
cursorFrame: null,
|
||||
},
|
||||
buy: {
|
||||
kind: 'buy',
|
||||
|
|
@ -140,6 +143,7 @@ export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButton
|
|||
labelZh: '買',
|
||||
labelEn: 'Buy',
|
||||
paramA: 2,
|
||||
cursorFrame: 3,
|
||||
},
|
||||
sell: {
|
||||
kind: 'sell',
|
||||
|
|
@ -150,6 +154,7 @@ export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButton
|
|||
labelZh: '賣',
|
||||
labelEn: 'Sell',
|
||||
paramA: 3,
|
||||
cursorFrame: 4,
|
||||
},
|
||||
repair: {
|
||||
kind: 'repair',
|
||||
|
|
@ -160,6 +165,7 @@ export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButton
|
|||
labelZh: '修復',
|
||||
labelEn: 'Repair',
|
||||
paramA: 4,
|
||||
cursorFrame: 1,
|
||||
},
|
||||
repairAll: {
|
||||
kind: 'repairAll',
|
||||
|
|
@ -170,6 +176,7 @@ export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButton
|
|||
labelZh: '修復所有裝備',
|
||||
labelEn: 'Repair all equipment',
|
||||
paramA: 1,
|
||||
cursorFrame: null,
|
||||
},
|
||||
close: {
|
||||
kind: 'close',
|
||||
|
|
@ -180,6 +187,7 @@ export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButton
|
|||
labelZh: '關閉',
|
||||
labelEn: 'Close',
|
||||
paramA: 9,
|
||||
cursorFrame: null,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -659,6 +667,15 @@ export class WorldPanelsHud {
|
|||
return isPressed ? spec.pressedFrame : spec.baseFrame
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active `buysell.dc6` cursor frame index per D2Client.dll `0x6fb475e1` / `0x6fbbb238` (+0x10),
|
||||
* or `null` when `vendorTradeState === 'idle'`.
|
||||
*/
|
||||
getActiveVendorCursorFrame(): number | null {
|
||||
if (this.vendorTradeState === 'idle') return null
|
||||
return VENDOR_BUTTON_SPECS[this.vendorTradeState].cursorFrame
|
||||
}
|
||||
|
||||
showVendorStatus(text: string, color: D2ColorCode = 'gold', nowMs = performance.now()): void {
|
||||
this.vendorStatusMsg = { text, color, untilMs: nowMs + 2800 }
|
||||
}
|
||||
|
|
@ -1950,6 +1967,10 @@ export class WorldPanelsHud {
|
|||
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
|
||||
throw new Error('Cannot handle vendor click without an active vendor descriptor')
|
||||
}
|
||||
if (callbacks.isRightClick && this.vendorTradeState !== 'idle') {
|
||||
this.vendorTradeState = 'idle'
|
||||
return true
|
||||
}
|
||||
const relX = logicalX - ox
|
||||
const relY = logicalY - oy
|
||||
|
||||
|
|
|
|||
|
|
@ -25,14 +25,16 @@ import {
|
|||
type CursorRenderInfo,
|
||||
} from '../src/ui/cursor.ts'
|
||||
import {
|
||||
BUYSELL_CURSOR_FRAMES,
|
||||
CURSOR_METADATA,
|
||||
type CursorAtlasMetadata,
|
||||
type CursorFrameMeta,
|
||||
} from '../src/ui/cursor-meta.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||
import { HudManager } from '../src/ui/hud-manager.ts'
|
||||
import { VENDOR_BUTTON_TABLE_X } from '../src/ui/world-panels.ts'
|
||||
|
||||
describe('Diablo II v1.13c Dynamic Cursor System (Issue #148)', () => {
|
||||
describe('Diablo II v1.13c Dynamic Cursor System (Issue #148 & #491)', () => {
|
||||
describe('1. Asset Extraction & Atlas Metadata', () => {
|
||||
it('verifies public/ui/cursor.png exists and is non-empty', () => {
|
||||
const atlasPath = join(process.cwd(), 'public', 'ui', 'cursor.png')
|
||||
|
|
@ -41,26 +43,37 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148)', () => {
|
|||
expect(data.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('loads authentic D2 1.13c cursor atlas metadata with 24 total frames (8 each)', () => {
|
||||
it('loads authentic D2 1.13c cursor atlas metadata with 34 total frames (24 hand + 10 buysell)', () => {
|
||||
expect(CURSOR_METADATA.atlasWidth).toBe(256)
|
||||
expect(CURSOR_METADATA.atlasHeight).toBe(96)
|
||||
expect(CURSOR_METADATA.atlasHeight).toBe(176)
|
||||
expect(CURSOR_METADATA.frameWidth).toBe(32)
|
||||
expect(CURSOR_METADATA.frameHeight).toBe(32)
|
||||
expect(CURSOR_METADATA.buysellFrameHeight).toBe(40)
|
||||
|
||||
// Exactly 8 frames per animation
|
||||
// 8 frames per hand animation + 10 frames for buysell.dc6
|
||||
expect(CURSOR_METADATA.protate).toHaveLength(8)
|
||||
expect(CURSOR_METADATA.ohand).toHaveLength(8)
|
||||
expect(CURSOR_METADATA.orotate).toHaveLength(8)
|
||||
expect(CURSOR_METADATA.buysell).toHaveLength(10)
|
||||
|
||||
// 24 frames indexed in frames map
|
||||
expect(Object.keys(CURSOR_METADATA.frames)).toHaveLength(24)
|
||||
// 34 frames indexed in frames map
|
||||
expect(Object.keys(CURSOR_METADATA.frames)).toHaveLength(34)
|
||||
|
||||
// D2Client.dll 0x6fbbb238 (+0x10) & 0x6fb36fb7 buysell.dc6 frame mapping
|
||||
expect(BUYSELL_CURSOR_FRAMES).toEqual({
|
||||
identify: 0,
|
||||
repair: 1,
|
||||
buy: 3,
|
||||
sell: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies all frame coordinates are bounded inside the 256x96 atlas', () => {
|
||||
it('verifies all frame coordinates are bounded inside the 256x176 atlas', () => {
|
||||
const allFrames = [
|
||||
...CURSOR_METADATA.protate,
|
||||
...CURSOR_METADATA.ohand,
|
||||
...CURSOR_METADATA.orotate,
|
||||
...CURSOR_METADATA.buysell,
|
||||
]
|
||||
|
||||
for (const frame of allFrames) {
|
||||
|
|
@ -95,6 +108,16 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148)', () => {
|
|||
expect(frame.offsetX).toBe(-2)
|
||||
expect(frame.offsetY).toBe(24)
|
||||
}
|
||||
|
||||
// buysell: 10 frames of 32x40, offset = (0, 0), hotspot = (0, 0)
|
||||
for (const frame of CURSOR_METADATA.buysell) {
|
||||
expect(frame.w).toBe(32)
|
||||
expect(frame.h).toBe(40)
|
||||
expect(frame.offsetX).toBe(0)
|
||||
expect(frame.offsetY).toBe(0)
|
||||
expect(frame.hotspotX).toBe(0)
|
||||
expect(frame.hotspotY).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies baked UI manifest includes cursorAtlas entry', () => {
|
||||
|
|
@ -228,6 +251,54 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148)', () => {
|
|||
cursor.handleMouseUp(4503)
|
||||
expect(cursor.state).toBe('POINTING')
|
||||
})
|
||||
|
||||
it('switches to buysell.dc6 frame (buy=3, sell=4, repair=1, identify=0) and suppresses idle transitions per D2CLIENT_UpdateCursorIdle (0x6fac62f0)', () => {
|
||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
||||
cursor.handleMouseMove(250, 180, 0)
|
||||
|
||||
// Buy cursor -> buysell frame 3 (32x40)
|
||||
cursor.setTradeCursorMode('buy', 100)
|
||||
expect(cursor.tradeCursorMode).toBe('buy')
|
||||
let info = cursor.getCurrentFrame(100)
|
||||
expect(info.animation).toBe('buysell')
|
||||
expect(info.frameIndex).toBe(3)
|
||||
expect(info.frame).toBe(CURSOR_METADATA.buysell[3])
|
||||
expect(info.bounds).toEqual({ x: 250, y: 180, width: 32, height: 40 })
|
||||
|
||||
// Even after 5000ms idle, trade cursor mode suppresses OPENING/OPEN_IDLE
|
||||
expect(cursor.update(6000)).toBe('POINTING')
|
||||
info = cursor.getCurrentFrame(6000)
|
||||
expect(info.animation).toBe('buysell')
|
||||
expect(info.frameIndex).toBe(3)
|
||||
|
||||
// Sell cursor -> buysell frame 4
|
||||
cursor.setTradeCursorMode('sell', 6100)
|
||||
info = cursor.getCurrentFrame(6100)
|
||||
expect(info.animation).toBe('buysell')
|
||||
expect(info.frameIndex).toBe(4)
|
||||
expect(info.frame).toBe(CURSOR_METADATA.buysell[4])
|
||||
|
||||
// Repair cursor -> buysell frame 1
|
||||
cursor.setTradeCursorMode('repair', 6200)
|
||||
info = cursor.getCurrentFrame(6200)
|
||||
expect(info.animation).toBe('buysell')
|
||||
expect(info.frameIndex).toBe(1)
|
||||
expect(info.frame).toBe(CURSOR_METADATA.buysell[1])
|
||||
|
||||
// Identify cursor -> buysell frame 0
|
||||
cursor.setTradeCursorMode('identify', 6300)
|
||||
info = cursor.getCurrentFrame(6300)
|
||||
expect(info.animation).toBe('buysell')
|
||||
expect(info.frameIndex).toBe(0)
|
||||
expect(info.frame).toBe(CURSOR_METADATA.buysell[0])
|
||||
|
||||
// Clearing tradeCursorMode restores standard hand cursor (protate)
|
||||
cursor.setTradeCursorMode(null, 6000)
|
||||
info = cursor.getCurrentFrame(6000)
|
||||
expect(info.state).toBe('POINTING')
|
||||
expect(info.animation).toBe('protate')
|
||||
expect(info.frameIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Coordinates, Hotspot & Rendering Bounds', () => {
|
||||
|
|
@ -309,8 +380,8 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('4. HudManager Integration', () => {
|
||||
it('initializes HudManager with cursor: none and tracks cursor state in published state', () => {
|
||||
describe('4. HudManager Integration & Vendor Buy/Sell/Repair Cursor Switching (Issue #491)', () => {
|
||||
function createTestHud() {
|
||||
const mockCanvas = {
|
||||
style: {} as CSSStyleDeclaration,
|
||||
width: 800,
|
||||
|
|
@ -337,6 +408,24 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148)', () => {
|
|||
onWaypointTeleport: vi.fn(),
|
||||
})
|
||||
|
||||
const clickAt = (clientX: number, clientY: number, button = 0) => {
|
||||
hud.handleHudMouseDown({
|
||||
clientX,
|
||||
clientY,
|
||||
button,
|
||||
shiftKey: false,
|
||||
stopPropagation: vi.fn(),
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as MouseEvent)
|
||||
hud.worldPanels.releaseMomentaryVendorButton()
|
||||
}
|
||||
|
||||
return { hud, mockCanvas, clickAt }
|
||||
}
|
||||
|
||||
it('initializes HudManager with cursor: none and tracks cursor state in published state', () => {
|
||||
const { hud, mockCanvas } = createTestHud()
|
||||
|
||||
// Canvas cursor style hidden
|
||||
expect(mockCanvas.style.cursor).toBe('none')
|
||||
|
||||
|
|
@ -344,6 +433,8 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148)', () => {
|
|||
expect(hud.cursor).toBeInstanceOf(CursorController)
|
||||
expect(hud.cursor.state).toBe('POINTING')
|
||||
expect(hud.state.cursorState).toBe('POINTING')
|
||||
expect(hud.state.cursorAnimation).toBe('protate')
|
||||
expect(hud.state.cursorFrameIndex).toBe(0)
|
||||
|
||||
// Moving mouse updates hud.cursor position and stays POINTING
|
||||
hud.cursor.handleMouseMove(200, 150, 100)
|
||||
|
|
@ -364,5 +455,60 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148)', () => {
|
|||
expect(hud.cursor.state).toBe('OPEN_IDLE')
|
||||
expect(hud.state.cursorState).toBe('OPEN_IDLE')
|
||||
})
|
||||
|
||||
it('switches cursor to buysell.dc6 frame 3 (Buy), frame 4 (Sell), and frame 1 (Repair) when clicking vendor buttons, and resets on toggle/right-click/close', () => {
|
||||
const { hud, clickAt } = createTestHud()
|
||||
|
||||
// Open Charsi vendor panel
|
||||
expect(hud.openVendorSession('Charsi', 'trade', 0)).toBe(true)
|
||||
expect(hud.state.leftPanel).toBe('vendor')
|
||||
expect(hud.state.vendorTradeState).toBe('idle')
|
||||
expect(hud.state.cursorTradeMode).toBeNull()
|
||||
expect(hud.state.cursorAnimation).toBe('protate')
|
||||
|
||||
// 1. Click Buy button (slot 0) -> switches cursor to buysell.dc6 frame 3
|
||||
clickAt(80 + VENDOR_BUTTON_TABLE_X[0] + 20, 60 + 390, 0)
|
||||
expect(hud.state.vendorTradeState).toBe('buy')
|
||||
expect(hud.state.cursorTradeMode).toBe('buy')
|
||||
expect(hud.state.cursorAnimation).toBe('buysell')
|
||||
expect(hud.state.cursorFrameIndex).toBe(3)
|
||||
|
||||
// 2. Click Sell button (slot 1) -> switches cursor to buysell.dc6 frame 4
|
||||
clickAt(80 + VENDOR_BUTTON_TABLE_X[1] + 20, 60 + 390, 0)
|
||||
expect(hud.state.vendorTradeState).toBe('sell')
|
||||
expect(hud.state.cursorTradeMode).toBe('sell')
|
||||
expect(hud.state.cursorAnimation).toBe('buysell')
|
||||
expect(hud.state.cursorFrameIndex).toBe(4)
|
||||
|
||||
// 3. Click Repair button (slot 2 on Charsi) -> switches cursor to buysell.dc6 frame 1
|
||||
clickAt(80 + VENDOR_BUTTON_TABLE_X[2] + 20, 60 + 390, 0)
|
||||
expect(hud.state.vendorTradeState).toBe('repair')
|
||||
expect(hud.state.cursorTradeMode).toBe('repair')
|
||||
expect(hud.state.cursorAnimation).toBe('buysell')
|
||||
expect(hud.state.cursorFrameIndex).toBe(1)
|
||||
|
||||
// 4. Clicking the active Repair button again toggles back to idle and restores protate
|
||||
clickAt(80 + VENDOR_BUTTON_TABLE_X[2] + 20, 60 + 390, 0)
|
||||
expect(hud.state.vendorTradeState).toBe('idle')
|
||||
expect(hud.state.cursorTradeMode).toBeNull()
|
||||
expect(hud.state.cursorAnimation).toBe('protate')
|
||||
|
||||
// 5. Activate Buy mode again, then right-click inside vendor panel -> cancels back to idle cursor
|
||||
clickAt(80 + VENDOR_BUTTON_TABLE_X[0] + 20, 60 + 390, 0)
|
||||
expect(hud.state.cursorTradeMode).toBe('buy')
|
||||
clickAt(80 + 150, 60 + 200, 2)
|
||||
expect(hud.state.vendorTradeState).toBe('idle')
|
||||
expect(hud.state.cursorTradeMode).toBeNull()
|
||||
expect(hud.state.cursorAnimation).toBe('protate')
|
||||
|
||||
// 6. Activate Sell mode, then close panels -> cancels back to idle cursor
|
||||
clickAt(80 + VENDOR_BUTTON_TABLE_X[1] + 20, 60 + 390, 0)
|
||||
expect(hud.state.cursorTradeMode).toBe('sell')
|
||||
hud.closeAllPanels()
|
||||
expect(hud.state.leftPanel).toBe('none')
|
||||
expect(hud.state.cursorTradeMode).toBeNull()
|
||||
expect(hud.state.cursorAnimation).toBe('protate')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,7 @@ import { MpqArchive } from '../src/mpq/archive.ts'
|
|||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||
<<<<<<< HEAD
|
||||
import type { D2ColorCode, D2FontName as FontSizeName, D2FontRenderer } from '../src/ui/font.ts'
|
||||
=======
|
||||
import type { D2ColorCode, D2FontRenderer, D2FontName } from '../src/ui/font.ts'
|
||||
>>>>>>> ce00574 (fix(ui): restore authentic D2 v1.13c NPC dialog popup menu (Fixes #489))
|
||||
import { InventoryPanel, type UiInventoryItem } from '../src/ui/inventory.ts'
|
||||
import {
|
||||
BLACKSMITH_CLASS_IDS,
|
||||
|
|
@ -403,20 +399,23 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
|
|||
inventory: inv,
|
||||
}
|
||||
|
||||
// Click slot 0 (Buy) -> latches 'buy' (frame 3)
|
||||
// Click slot 0 (Buy) -> latches 'buy' (button frame 3, cursor frame 3)
|
||||
wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[0] + 20, 60 + 390, callbacks)
|
||||
expect(wp.vendorTradeState).toBe('buy')
|
||||
expect(wp.getVendorButtonFrame(0)).toBe(3)
|
||||
expect(wp.getActiveVendorCursorFrame()).toBe(3)
|
||||
|
||||
// Click slot 1 (Sell) -> switches to 'sell' (frame 5)
|
||||
// Click slot 1 (Sell) -> switches to 'sell' (button frame 5, cursor frame 4)
|
||||
wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[1] + 20, 60 + 390, callbacks)
|
||||
expect(wp.vendorTradeState).toBe('sell')
|
||||
expect(wp.getVendorButtonFrame(1)).toBe(5)
|
||||
expect(wp.getActiveVendorCursorFrame()).toBe(4)
|
||||
|
||||
// Click slot 2 (Repair) -> switches to 'repair' (frame 7)
|
||||
// Click slot 2 (Repair) -> switches to 'repair' (button frame 7, cursor frame 1)
|
||||
wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[2] + 20, 60 + 390, callbacks)
|
||||
expect(wp.vendorTradeState).toBe('repair')
|
||||
expect(wp.getVendorButtonFrame(2)).toBe(7)
|
||||
expect(wp.getActiveVendorCursorFrame()).toBe(1)
|
||||
expect(wp.repairCursorMode).toBe(true)
|
||||
|
||||
// Hover slot 3 (Repair All) -> verifies left-anchored framed hover text "修復所有裝備:${cost}"
|
||||
|
|
@ -437,6 +436,7 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
|
|||
wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, callbacks)
|
||||
expect(closed).toBe(false)
|
||||
expect(wp.vendorTradeState).toBe('idle')
|
||||
expect(wp.getActiveVendorCursorFrame()).toBeNull()
|
||||
expect(wp.pressedMomentarySlot).toBe(3)
|
||||
expect(helm.durability?.current).toBe(12)
|
||||
wp.releaseMomentaryVendorButton()
|
||||
|
|
@ -476,9 +476,14 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
|
|||
{ xOffset: 240, width: 79, height: 31, centerX: 281, labelZh: '其他' },
|
||||
])
|
||||
expect(VENDOR_BUTTON_SPECS.buy.labelZh).toBe('買')
|
||||
expect(VENDOR_BUTTON_SPECS.buy.cursorFrame).toBe(3)
|
||||
expect(VENDOR_BUTTON_SPECS.sell.labelZh).toBe('賣')
|
||||
expect(VENDOR_BUTTON_SPECS.sell.cursorFrame).toBe(4)
|
||||
expect(VENDOR_BUTTON_SPECS.repair.labelZh).toBe('修復')
|
||||
expect(VENDOR_BUTTON_SPECS.repair.cursorFrame).toBe(1)
|
||||
expect(VENDOR_BUTTON_SPECS.repairAll.cursorFrame).toBeNull()
|
||||
expect(VENDOR_BUTTON_SPECS.close.labelZh).toBe('關閉')
|
||||
expect(VENDOR_BUTTON_SPECS.close.cursorFrame).toBeNull()
|
||||
})
|
||||
|
||||
it('verifies baked boxpieces.png and focus16.png dimensions and MPQ byte parity', async () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue