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
|
* scripts/pack-cursor.ts
|
||||||
*
|
*
|
||||||
* Extracts authentic Diablo II v1.13c cursor DC6 assets from d2data.mpq:
|
* Extracts authentic Diablo II v1.13c cursor DC6 assets from d2data.mpq:
|
||||||
* - protate.dc6 (8 frames: pointing hand swaying animation)
|
* - protate.dc6 (8 frames: pointing hand swaying animation, CursorId 5)
|
||||||
* - ohand.dc6 (8 frames: finger-to-palm opening transition)
|
* - ohand.dc6 (8 frames: finger-to-palm opening transition, CursorId 2)
|
||||||
* - orotate.dc6 (8 frames: open palm swaying idle animation)
|
* - 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.
|
* Enforces Zero Runtime MPQ/DLL Architectural Invariant.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -29,14 +30,25 @@ export interface CursorFrameMeta {
|
||||||
readonly hotspotY: number
|
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 {
|
export interface CursorAtlasMetadata {
|
||||||
readonly atlasWidth: number
|
readonly atlasWidth: number
|
||||||
readonly atlasHeight: number
|
readonly atlasHeight: number
|
||||||
readonly frameWidth: number
|
readonly frameWidth: number
|
||||||
readonly frameHeight: number
|
readonly frameHeight: number
|
||||||
|
readonly buysellFrameHeight: number
|
||||||
readonly protate: readonly CursorFrameMeta[]
|
readonly protate: readonly CursorFrameMeta[]
|
||||||
readonly ohand: readonly CursorFrameMeta[]
|
readonly ohand: readonly CursorFrameMeta[]
|
||||||
readonly orotate: readonly CursorFrameMeta[]
|
readonly orotate: readonly CursorFrameMeta[]
|
||||||
|
readonly buysell: readonly CursorFrameMeta[]
|
||||||
readonly frames: Record<string, CursorFrameMeta>
|
readonly frames: Record<string, CursorFrameMeta>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,19 +74,32 @@ export async function packCursorAssets(projectRoot: string = process.cwd()): Pro
|
||||||
decodedSheets[name] = frames
|
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_W = 32
|
||||||
const CELL_H = 32
|
const CELL_H = 32
|
||||||
|
const BUYSELL_CELL_H = 40
|
||||||
const COLS = 8
|
const COLS = 8
|
||||||
const ROWS = 3
|
const HAND_ROWS = 3
|
||||||
|
const BUYSELL_ROWS = 2
|
||||||
const atlasWidth = CELL_W * COLS // 256
|
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 pixels = new Uint8Array(atlasWidth * atlasHeight)
|
||||||
|
|
||||||
const metaLists: Record<string, CursorFrameMeta[]> = {
|
const metaLists: Record<string, CursorFrameMeta[]> = {
|
||||||
protate: [],
|
protate: [],
|
||||||
ohand: [],
|
ohand: [],
|
||||||
orotate: [],
|
orotate: [],
|
||||||
|
buysell: [],
|
||||||
}
|
}
|
||||||
const allFrames: Record<string, CursorFrameMeta> = {}
|
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 = {
|
const atlasMeta: CursorAtlasMetadata = {
|
||||||
atlasWidth,
|
atlasWidth,
|
||||||
atlasHeight,
|
atlasHeight,
|
||||||
frameWidth: CELL_W,
|
frameWidth: CELL_W,
|
||||||
frameHeight: CELL_H,
|
frameHeight: CELL_H,
|
||||||
|
buysellFrameHeight: BUYSELL_CELL_H,
|
||||||
protate: metaLists.protate!,
|
protate: metaLists.protate!,
|
||||||
ohand: metaLists.ohand!,
|
ohand: metaLists.ohand!,
|
||||||
orotate: metaLists.orotate!,
|
orotate: metaLists.orotate!,
|
||||||
|
buysell: metaLists.buysell!,
|
||||||
frames: allFrames,
|
frames: allFrames,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,21 +227,39 @@ export interface CursorFrameMeta {
|
||||||
readonly hotspotY: number
|
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 {
|
export interface CursorAtlasMetadata {
|
||||||
readonly atlasWidth: number
|
readonly atlasWidth: number
|
||||||
readonly atlasHeight: number
|
readonly atlasHeight: number
|
||||||
readonly frameWidth: number
|
readonly frameWidth: number
|
||||||
readonly frameHeight: number
|
readonly frameHeight: number
|
||||||
|
readonly buysellFrameHeight: number
|
||||||
readonly protate: readonly CursorFrameMeta[]
|
readonly protate: readonly CursorFrameMeta[]
|
||||||
readonly ohand: readonly CursorFrameMeta[]
|
readonly ohand: readonly CursorFrameMeta[]
|
||||||
readonly orotate: readonly CursorFrameMeta[]
|
readonly orotate: readonly CursorFrameMeta[]
|
||||||
|
readonly buysell: readonly CursorFrameMeta[]
|
||||||
readonly frames: Record<string, CursorFrameMeta>
|
readonly frames: Record<string, CursorFrameMeta>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CURSOR_METADATA: CursorAtlasMetadata = ${JSON.stringify(atlasMeta, null, 2)} as const
|
export const CURSOR_METADATA: CursorAtlasMetadata = ${JSON.stringify(atlasMeta, null, 2)} as const
|
||||||
`
|
`
|
||||||
writeFileSync(join(projectRoot, 'src', 'ui', 'cursor-meta.ts'), metaCode, 'utf8')
|
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')
|
console.log('Saved metadata to src/ui/cursor-meta.ts')
|
||||||
|
|
||||||
return atlasMeta
|
return atlasMeta
|
||||||
|
|
|
||||||
|
|
@ -15,22 +15,41 @@ export interface CursorFrameMeta {
|
||||||
readonly hotspotY: number
|
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 {
|
export interface CursorAtlasMetadata {
|
||||||
readonly atlasWidth: number
|
readonly atlasWidth: number
|
||||||
readonly atlasHeight: number
|
readonly atlasHeight: number
|
||||||
readonly frameWidth: number
|
readonly frameWidth: number
|
||||||
readonly frameHeight: number
|
readonly frameHeight: number
|
||||||
|
readonly buysellFrameHeight: number
|
||||||
readonly protate: readonly CursorFrameMeta[]
|
readonly protate: readonly CursorFrameMeta[]
|
||||||
readonly ohand: readonly CursorFrameMeta[]
|
readonly ohand: readonly CursorFrameMeta[]
|
||||||
readonly orotate: readonly CursorFrameMeta[]
|
readonly orotate: readonly CursorFrameMeta[]
|
||||||
|
readonly buysell: readonly CursorFrameMeta[]
|
||||||
readonly frames: Record<string, CursorFrameMeta>
|
readonly frames: Record<string, CursorFrameMeta>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CURSOR_METADATA: CursorAtlasMetadata = {
|
export const CURSOR_METADATA: CursorAtlasMetadata = {
|
||||||
"atlasWidth": 256,
|
"atlasWidth": 256,
|
||||||
"atlasHeight": 96,
|
"atlasHeight": 176,
|
||||||
"frameWidth": 32,
|
"frameWidth": 32,
|
||||||
"frameHeight": 32,
|
"frameHeight": 32,
|
||||||
|
"buysellFrameHeight": 40,
|
||||||
"protate": [
|
"protate": [
|
||||||
{
|
{
|
||||||
"x": 0,
|
"x": 0,
|
||||||
|
|
@ -277,6 +296,108 @@ export const CURSOR_METADATA: CursorAtlasMetadata = {
|
||||||
"hotspotY": 0
|
"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": {
|
"frames": {
|
||||||
"protate_0": {
|
"protate_0": {
|
||||||
"x": 0,
|
"x": 0,
|
||||||
|
|
@ -517,6 +638,106 @@ export const CURSOR_METADATA: CursorAtlasMetadata = {
|
||||||
"offsetY": 24,
|
"offsetY": 24,
|
||||||
"hotspotX": 1,
|
"hotspotX": 1,
|
||||||
"hotspotY": 0
|
"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
|
} as const
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,16 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BUYSELL_CURSOR_FRAMES,
|
||||||
CURSOR_METADATA,
|
CURSOR_METADATA,
|
||||||
|
type BuySellCursorMode,
|
||||||
type CursorAtlasMetadata,
|
type CursorAtlasMetadata,
|
||||||
type CursorFrameMeta,
|
type CursorFrameMeta,
|
||||||
} from './cursor-meta.ts'
|
} from './cursor-meta.ts'
|
||||||
|
|
||||||
|
export { BUYSELL_CURSOR_FRAMES, type BuySellCursorMode }
|
||||||
export type CursorState = 'POINTING' | 'OPENING' | 'OPEN_IDLE'
|
export type CursorState = 'POINTING' | 'OPENING' | 'OPEN_IDLE'
|
||||||
export type CursorAnimation = 'protate' | 'ohand' | 'orotate'
|
export type CursorAnimation = 'protate' | 'ohand' | 'orotate' | 'buysell'
|
||||||
|
|
||||||
export interface CursorRenderInfo {
|
export interface CursorRenderInfo {
|
||||||
readonly state: CursorState
|
readonly state: CursorState
|
||||||
|
|
@ -56,8 +59,19 @@ export class CursorController {
|
||||||
mouseX: number = 0
|
mouseX: number = 0
|
||||||
mouseY: number = 0
|
mouseY: number = 0
|
||||||
lastActivityTime: number = 0
|
lastActivityTime: number = 0
|
||||||
|
lastNowMs: number = 0
|
||||||
state: CursorState = 'POINTING'
|
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. */
|
/** Milliseconds of inactivity before transitioning from POINTING to OPENING. */
|
||||||
idleThresholdMs: number = 2000
|
idleThresholdMs: number = 2000
|
||||||
|
|
||||||
|
|
@ -70,6 +84,17 @@ export class CursorController {
|
||||||
constructor(metadata: CursorAtlasMetadata = CURSOR_METADATA, initialTimeMs: number = 0) {
|
constructor(metadata: CursorAtlasMetadata = CURSOR_METADATA, initialTimeMs: number = 0) {
|
||||||
this.metadata = metadata
|
this.metadata = metadata
|
||||||
this.lastActivityTime = initialTimeMs
|
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 {
|
resetIdle(nowMs: number = performance.now()): void {
|
||||||
this.lastActivityTime = nowMs
|
this.lastActivityTime = nowMs
|
||||||
|
this.lastNowMs = nowMs
|
||||||
this.state = 'POINTING'
|
this.state = 'POINTING'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,8 +131,16 @@ export class CursorController {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluates the state machine transitions based on elapsed idle time.
|
* 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 {
|
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 idleMs = Math.max(0, nowMs - this.lastActivityTime)
|
||||||
const openingTotalMs = 8 * this.openingFrameDurationMs
|
const openingTotalMs = 8 * this.openingFrameDurationMs
|
||||||
|
|
||||||
|
|
@ -127,11 +161,21 @@ export class CursorController {
|
||||||
getCurrentFrame(nowMs: number = performance.now()): CursorRenderInfo {
|
getCurrentFrame(nowMs: number = performance.now()): CursorRenderInfo {
|
||||||
this.update(nowMs)
|
this.update(nowMs)
|
||||||
|
|
||||||
const idleMs = Math.max(0, nowMs - this.lastActivityTime)
|
|
||||||
const openingTotalMs = 8 * this.openingFrameDurationMs
|
|
||||||
|
|
||||||
let anim: CursorAnimation
|
let anim: CursorAnimation
|
||||||
let frameIndex: number
|
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') {
|
if (this.state === 'POINTING') {
|
||||||
anim = 'protate'
|
anim = 'protate'
|
||||||
|
|
@ -155,7 +199,8 @@ export class CursorController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const frameList = this.metadata[anim] ?? this.metadata.protate
|
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 drawX = Math.round(this.mouseX - frameMeta.hotspotX)
|
||||||
const drawY = Math.round(this.mouseY - frameMeta.hotspotY)
|
const drawY = Math.round(this.mouseY - frameMeta.hotspotY)
|
||||||
|
|
@ -205,7 +250,7 @@ export class CursorController {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, render the authentic animated hand cursor
|
// Otherwise, render the authentic animated hand cursor or buysell trade cursor
|
||||||
if (cursorAtlas) {
|
if (cursorAtlas) {
|
||||||
const info = this.getCurrentFrame(nowMs)
|
const info = this.getCurrentFrame(nowMs)
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,13 @@ import {
|
||||||
} from './inventory.ts'
|
} 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, type VendorTradeState } from './world-panels.ts'
|
||||||
import { CursorController, type CursorState } from './cursor.ts'
|
import {
|
||||||
|
CursorController,
|
||||||
|
type BuySellCursorMode,
|
||||||
|
type CursorAnimation,
|
||||||
|
type CursorState,
|
||||||
|
} from './cursor.ts'
|
||||||
import type { CharacterClassCode } from '../game/classes.ts'
|
import type { CharacterClassCode } from '../game/classes.ts'
|
||||||
import {
|
import {
|
||||||
createStarterProfileForClass,
|
createStarterProfileForClass,
|
||||||
|
|
@ -45,6 +50,9 @@ export interface PublishedHudState {
|
||||||
dc6DecodeFailures: number
|
dc6DecodeFailures: number
|
||||||
decodedDc6Count: number
|
decodedDc6Count: number
|
||||||
cursorState: CursorState
|
cursorState: CursorState
|
||||||
|
cursorTradeMode?: BuySellCursorMode | null
|
||||||
|
cursorAnimation?: CursorAnimation
|
||||||
|
cursorFrameIndex?: number
|
||||||
assetsLoaded: {
|
assetsLoaded: {
|
||||||
ctrlPnl: boolean
|
ctrlPnl: boolean
|
||||||
globes: boolean
|
globes: boolean
|
||||||
|
|
@ -81,6 +89,7 @@ export interface PublishedHudState {
|
||||||
docking: DockingLayout
|
docking: DockingLayout
|
||||||
activeVendor?: string | null
|
activeVendor?: string | null
|
||||||
vendorMode?: 'trade' | 'gamble'
|
vendorMode?: 'trade' | 'gamble'
|
||||||
|
vendorTradeState?: VendorTradeState
|
||||||
vendorTab?: string
|
vendorTab?: string
|
||||||
vendorItemsCount?: number
|
vendorItemsCount?: number
|
||||||
npcMenuOpen?: boolean
|
npcMenuOpen?: boolean
|
||||||
|
|
@ -258,6 +267,9 @@ export class HudManager {
|
||||||
dc6DecodeFailures: 0,
|
dc6DecodeFailures: 0,
|
||||||
decodedDc6Count: 415,
|
decodedDc6Count: 415,
|
||||||
cursorState: 'POINTING',
|
cursorState: 'POINTING',
|
||||||
|
cursorTradeMode: null,
|
||||||
|
cursorAnimation: 'protate',
|
||||||
|
cursorFrameIndex: 0,
|
||||||
assetsLoaded: {
|
assetsLoaded: {
|
||||||
ctrlPnl: false,
|
ctrlPnl: false,
|
||||||
globes: false,
|
globes: false,
|
||||||
|
|
@ -292,6 +304,7 @@ export class HudManager {
|
||||||
stamina: 465,
|
stamina: 465,
|
||||||
maxStamina: 465,
|
maxStamina: 465,
|
||||||
docking: computeDockingLayout(800, 600, 1),
|
docking: computeDockingLayout(800, 600, 1),
|
||||||
|
vendorTradeState: 'idle',
|
||||||
}
|
}
|
||||||
|
|
||||||
currentClass: CharacterClassCode | null = null
|
currentClass: CharacterClassCode | null = null
|
||||||
|
|
@ -750,14 +763,17 @@ export class HudManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
const intercept =
|
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'
|
this.hudCanvas.style.pointerEvents = intercept ? 'auto' : 'none'
|
||||||
})
|
})
|
||||||
|
|
||||||
window.addEventListener('mousedown', (e) => {
|
window.addEventListener('mousedown', (e) => {
|
||||||
this.cursor.handleMouseDown()
|
this.cursor.handleMouseDown()
|
||||||
if (e.target !== this.hudCanvas && e.target instanceof HTMLCanvasElement) {
|
if (e.target !== this.hudCanvas && e.target instanceof HTMLCanvasElement) {
|
||||||
handleHudMouseDown(e)
|
this.handleHudMouseDown(e)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -770,7 +786,10 @@ export class HudManager {
|
||||||
e.preventDefault()
|
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
|
if (e.button !== 0 && e.button !== 2) return
|
||||||
const pt = this.clientToLogical(e.clientX, e.clientY)
|
const pt = this.clientToLogical(e.clientX, e.clientY)
|
||||||
if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) {
|
if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) {
|
||||||
|
|
@ -785,6 +804,13 @@ export class HudManager {
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
return
|
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 holding cursorItem and clicking outside panels onto the ground, drop it
|
||||||
if (this.inventory.cursorItem !== null) {
|
if (this.inventory.cursorItem !== null) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -950,6 +976,12 @@ export class HudManager {
|
||||||
|
|
||||||
// Vendor Repair or Sell/Quick-Sell from Player Inventory
|
// Vendor Repair or Sell/Quick-Sell from Player Inventory
|
||||||
if (this.leftPanel === 'vendor') {
|
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 (
|
if (
|
||||||
rightX >= INV_GRID_ORIGIN.x &&
|
rightX >= INV_GRID_ORIGIN.x &&
|
||||||
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
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 {
|
private handleSkillAllocated(skillId: number): void {
|
||||||
if (isPassiveSkill(skillId)) return
|
if (isPassiveSkill(skillId)) return
|
||||||
const catalogEntry = SKILLS_BY_ID[skillId]
|
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 {
|
syncPublishedState(): void {
|
||||||
|
this.syncCursorMode(this.cursor.lastNowMs)
|
||||||
|
const cursorInfo = this.cursor.getCurrentFrame(this.cursor.lastNowMs)
|
||||||
this.state.dc6DecodeFailures = this.dc6DecodeFailures
|
this.state.dc6DecodeFailures = this.dc6DecodeFailures
|
||||||
this.state.decodedDc6Count = this.decodedDc6Count
|
this.state.decodedDc6Count = this.decodedDc6Count
|
||||||
this.state.assetsLoaded = {
|
this.state.assetsLoaded = {
|
||||||
|
|
@ -1084,6 +1128,9 @@ export class HudManager {
|
||||||
this.state.isRunning = this.isRunning
|
this.state.isRunning = this.isRunning
|
||||||
this.state.showGroundLabels = this.showGroundLabels
|
this.state.showGroundLabels = this.showGroundLabels
|
||||||
this.state.cursorState = this.cursor.state
|
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.cursorItem = this.inventory.cursorItem?.id ?? null
|
||||||
this.state.equippedSlots = Object.keys(this.inventory.equipped) as EquipSlotId[]
|
this.state.equippedSlots = Object.keys(this.inventory.equipped) as EquipSlotId[]
|
||||||
this.state.bagItemsCount = this.inventory.gridItems.length
|
this.state.bagItemsCount = this.inventory.gridItems.length
|
||||||
|
|
@ -1102,6 +1149,7 @@ export class HudManager {
|
||||||
this.state.docking = this.getDockingLayout()
|
this.state.docking = this.getDockingLayout()
|
||||||
this.state.activeVendor = this.worldPanels.activeVendorDescriptor?.displayName ?? null
|
this.state.activeVendor = this.worldPanels.activeVendorDescriptor?.displayName ?? null
|
||||||
this.state.vendorMode = this.worldPanels.vendorMode
|
this.state.vendorMode = this.worldPanels.vendorMode
|
||||||
|
this.state.vendorTradeState = this.worldPanels.vendorTradeState
|
||||||
this.state.vendorTab = this.worldPanels.activeVendorTab
|
this.state.vendorTab = this.worldPanels.activeVendorTab
|
||||||
this.state.vendorItemsCount = this.worldPanels.getActiveVendorPlacements().length
|
this.state.vendorItemsCount = this.worldPanels.getActiveVendorPlacements().length
|
||||||
this.state.npcMenuOpen = this.worldPanels.npcMenu !== null
|
this.state.npcMenuOpen = this.worldPanels.npcMenu !== null
|
||||||
|
|
@ -1136,9 +1184,12 @@ export class HudManager {
|
||||||
const deltaLeft = -marginW
|
const deltaLeft = -marginW
|
||||||
const deltaRight = 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 =
|
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'
|
this.hudCanvas.style.pointerEvents = intercept ? 'auto' : 'none'
|
||||||
|
|
||||||
const ctx = this.hudCanvas.getContext('2d')
|
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')
|
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)
|
this.renderCursor(ctx, nowMs)
|
||||||
|
|
||||||
ctx.restore()
|
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 {
|
renderCursor(ctx: CanvasRenderingContext2D, nowMs: number = performance.now()): void {
|
||||||
if (this.inventory.isIdentifyMode()) {
|
this.syncCursorMode(nowMs)
|
||||||
this.cursor.resetIdle(nowMs)
|
|
||||||
}
|
|
||||||
const cursorAtlas = this.images.get('cursorAtlas') ?? this.images.get('cursorHand') ?? null
|
const cursorAtlas = this.images.get('cursorAtlas') ?? this.images.get('cursorHand') ?? null
|
||||||
const heldItem = this.inventory.cursorItem
|
const heldItem = this.inventory.cursorItem
|
||||||
? {
|
? {
|
||||||
|
|
@ -1355,12 +1404,5 @@ export class HudManager {
|
||||||
? { atlas: heldItem.atlas, rect: heldItem.rect }
|
? { atlas: heldItem.atlas, rect: heldItem.rect }
|
||||||
: null,
|
: 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 labelZh: string
|
||||||
readonly labelEn: string
|
readonly labelEn: string
|
||||||
readonly paramA: number
|
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>> = {
|
export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButtonSpec>> = {
|
||||||
|
|
@ -130,6 +132,7 @@ export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButton
|
||||||
labelZh: '',
|
labelZh: '',
|
||||||
labelEn: '',
|
labelEn: '',
|
||||||
paramA: 1,
|
paramA: 1,
|
||||||
|
cursorFrame: null,
|
||||||
},
|
},
|
||||||
buy: {
|
buy: {
|
||||||
kind: 'buy',
|
kind: 'buy',
|
||||||
|
|
@ -140,6 +143,7 @@ export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButton
|
||||||
labelZh: '買',
|
labelZh: '買',
|
||||||
labelEn: 'Buy',
|
labelEn: 'Buy',
|
||||||
paramA: 2,
|
paramA: 2,
|
||||||
|
cursorFrame: 3,
|
||||||
},
|
},
|
||||||
sell: {
|
sell: {
|
||||||
kind: 'sell',
|
kind: 'sell',
|
||||||
|
|
@ -150,6 +154,7 @@ export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButton
|
||||||
labelZh: '賣',
|
labelZh: '賣',
|
||||||
labelEn: 'Sell',
|
labelEn: 'Sell',
|
||||||
paramA: 3,
|
paramA: 3,
|
||||||
|
cursorFrame: 4,
|
||||||
},
|
},
|
||||||
repair: {
|
repair: {
|
||||||
kind: 'repair',
|
kind: 'repair',
|
||||||
|
|
@ -160,6 +165,7 @@ export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButton
|
||||||
labelZh: '修復',
|
labelZh: '修復',
|
||||||
labelEn: 'Repair',
|
labelEn: 'Repair',
|
||||||
paramA: 4,
|
paramA: 4,
|
||||||
|
cursorFrame: 1,
|
||||||
},
|
},
|
||||||
repairAll: {
|
repairAll: {
|
||||||
kind: 'repairAll',
|
kind: 'repairAll',
|
||||||
|
|
@ -170,6 +176,7 @@ export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButton
|
||||||
labelZh: '修復所有裝備',
|
labelZh: '修復所有裝備',
|
||||||
labelEn: 'Repair all equipment',
|
labelEn: 'Repair all equipment',
|
||||||
paramA: 1,
|
paramA: 1,
|
||||||
|
cursorFrame: null,
|
||||||
},
|
},
|
||||||
close: {
|
close: {
|
||||||
kind: 'close',
|
kind: 'close',
|
||||||
|
|
@ -180,6 +187,7 @@ export const VENDOR_BUTTON_SPECS: Readonly<Record<VendorButtonKind, VendorButton
|
||||||
labelZh: '關閉',
|
labelZh: '關閉',
|
||||||
labelEn: 'Close',
|
labelEn: 'Close',
|
||||||
paramA: 9,
|
paramA: 9,
|
||||||
|
cursorFrame: null,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -659,6 +667,15 @@ export class WorldPanelsHud {
|
||||||
return isPressed ? spec.pressedFrame : spec.baseFrame
|
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 {
|
showVendorStatus(text: string, color: D2ColorCode = 'gold', nowMs = performance.now()): void {
|
||||||
this.vendorStatusMsg = { text, color, untilMs: nowMs + 2800 }
|
this.vendorStatusMsg = { text, color, untilMs: nowMs + 2800 }
|
||||||
}
|
}
|
||||||
|
|
@ -1950,6 +1967,10 @@ export class WorldPanelsHud {
|
||||||
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
|
if (!this.activeVendorDescriptor || this.activeVendorDescriptor.vendorId === null) {
|
||||||
throw new Error('Cannot handle vendor click without an active vendor descriptor')
|
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 relX = logicalX - ox
|
||||||
const relY = logicalY - oy
|
const relY = logicalY - oy
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,14 +25,16 @@ import {
|
||||||
type CursorRenderInfo,
|
type CursorRenderInfo,
|
||||||
} from '../src/ui/cursor.ts'
|
} from '../src/ui/cursor.ts'
|
||||||
import {
|
import {
|
||||||
|
BUYSELL_CURSOR_FRAMES,
|
||||||
CURSOR_METADATA,
|
CURSOR_METADATA,
|
||||||
type CursorAtlasMetadata,
|
type CursorAtlasMetadata,
|
||||||
type CursorFrameMeta,
|
type CursorFrameMeta,
|
||||||
} from '../src/ui/cursor-meta.ts'
|
} from '../src/ui/cursor-meta.ts'
|
||||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||||
import { HudManager } from '../src/ui/hud-manager.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', () => {
|
describe('1. Asset Extraction & Atlas Metadata', () => {
|
||||||
it('verifies public/ui/cursor.png exists and is non-empty', () => {
|
it('verifies public/ui/cursor.png exists and is non-empty', () => {
|
||||||
const atlasPath = join(process.cwd(), 'public', 'ui', 'cursor.png')
|
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)
|
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.atlasWidth).toBe(256)
|
||||||
expect(CURSOR_METADATA.atlasHeight).toBe(96)
|
expect(CURSOR_METADATA.atlasHeight).toBe(176)
|
||||||
expect(CURSOR_METADATA.frameWidth).toBe(32)
|
expect(CURSOR_METADATA.frameWidth).toBe(32)
|
||||||
expect(CURSOR_METADATA.frameHeight).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.protate).toHaveLength(8)
|
||||||
expect(CURSOR_METADATA.ohand).toHaveLength(8)
|
expect(CURSOR_METADATA.ohand).toHaveLength(8)
|
||||||
expect(CURSOR_METADATA.orotate).toHaveLength(8)
|
expect(CURSOR_METADATA.orotate).toHaveLength(8)
|
||||||
|
expect(CURSOR_METADATA.buysell).toHaveLength(10)
|
||||||
|
|
||||||
// 24 frames indexed in frames map
|
// 34 frames indexed in frames map
|
||||||
expect(Object.keys(CURSOR_METADATA.frames)).toHaveLength(24)
|
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 = [
|
const allFrames = [
|
||||||
...CURSOR_METADATA.protate,
|
...CURSOR_METADATA.protate,
|
||||||
...CURSOR_METADATA.ohand,
|
...CURSOR_METADATA.ohand,
|
||||||
...CURSOR_METADATA.orotate,
|
...CURSOR_METADATA.orotate,
|
||||||
|
...CURSOR_METADATA.buysell,
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const frame of allFrames) {
|
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.offsetX).toBe(-2)
|
||||||
expect(frame.offsetY).toBe(24)
|
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', () => {
|
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)
|
cursor.handleMouseUp(4503)
|
||||||
expect(cursor.state).toBe('POINTING')
|
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', () => {
|
describe('3. Coordinates, Hotspot & Rendering Bounds', () => {
|
||||||
|
|
@ -309,8 +380,8 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148)', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('4. HudManager Integration', () => {
|
describe('4. HudManager Integration & Vendor Buy/Sell/Repair Cursor Switching (Issue #491)', () => {
|
||||||
it('initializes HudManager with cursor: none and tracks cursor state in published state', () => {
|
function createTestHud() {
|
||||||
const mockCanvas = {
|
const mockCanvas = {
|
||||||
style: {} as CSSStyleDeclaration,
|
style: {} as CSSStyleDeclaration,
|
||||||
width: 800,
|
width: 800,
|
||||||
|
|
@ -337,6 +408,24 @@ describe('Diablo II v1.13c Dynamic Cursor System (Issue #148)', () => {
|
||||||
onWaypointTeleport: vi.fn(),
|
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
|
// Canvas cursor style hidden
|
||||||
expect(mockCanvas.style.cursor).toBe('none')
|
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).toBeInstanceOf(CursorController)
|
||||||
expect(hud.cursor.state).toBe('POINTING')
|
expect(hud.cursor.state).toBe('POINTING')
|
||||||
expect(hud.state.cursorState).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
|
// Moving mouse updates hud.cursor position and stays POINTING
|
||||||
hud.cursor.handleMouseMove(200, 150, 100)
|
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.cursor.state).toBe('OPEN_IDLE')
|
||||||
expect(hud.state.cursorState).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 { fileSource } from '../src/mpq/file-source.ts'
|
||||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.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'
|
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 { InventoryPanel, type UiInventoryItem } from '../src/ui/inventory.ts'
|
||||||
import {
|
import {
|
||||||
BLACKSMITH_CLASS_IDS,
|
BLACKSMITH_CLASS_IDS,
|
||||||
|
|
@ -403,20 +399,23 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => {
|
||||||
inventory: inv,
|
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)
|
wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[0] + 20, 60 + 390, callbacks)
|
||||||
expect(wp.vendorTradeState).toBe('buy')
|
expect(wp.vendorTradeState).toBe('buy')
|
||||||
expect(wp.getVendorButtonFrame(0)).toBe(3)
|
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)
|
wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[1] + 20, 60 + 390, callbacks)
|
||||||
expect(wp.vendorTradeState).toBe('sell')
|
expect(wp.vendorTradeState).toBe('sell')
|
||||||
expect(wp.getVendorButtonFrame(1)).toBe(5)
|
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)
|
wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[2] + 20, 60 + 390, callbacks)
|
||||||
expect(wp.vendorTradeState).toBe('repair')
|
expect(wp.vendorTradeState).toBe('repair')
|
||||||
expect(wp.getVendorButtonFrame(2)).toBe(7)
|
expect(wp.getVendorButtonFrame(2)).toBe(7)
|
||||||
|
expect(wp.getActiveVendorCursorFrame()).toBe(1)
|
||||||
expect(wp.repairCursorMode).toBe(true)
|
expect(wp.repairCursorMode).toBe(true)
|
||||||
|
|
||||||
// Hover slot 3 (Repair All) -> verifies left-anchored framed hover text "修復所有裝備:${cost}"
|
// 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)
|
wp.handleLeftDockClick('vendor', 80 + VENDOR_BUTTON_TABLE_X[3] + 20, 60 + 390, callbacks)
|
||||||
expect(closed).toBe(false)
|
expect(closed).toBe(false)
|
||||||
expect(wp.vendorTradeState).toBe('idle')
|
expect(wp.vendorTradeState).toBe('idle')
|
||||||
|
expect(wp.getActiveVendorCursorFrame()).toBeNull()
|
||||||
expect(wp.pressedMomentarySlot).toBe(3)
|
expect(wp.pressedMomentarySlot).toBe(3)
|
||||||
expect(helm.durability?.current).toBe(12)
|
expect(helm.durability?.current).toBe(12)
|
||||||
wp.releaseMomentaryVendorButton()
|
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: '其他' },
|
{ xOffset: 240, width: 79, height: 31, centerX: 281, labelZh: '其他' },
|
||||||
])
|
])
|
||||||
expect(VENDOR_BUTTON_SPECS.buy.labelZh).toBe('買')
|
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.labelZh).toBe('賣')
|
||||||
|
expect(VENDOR_BUTTON_SPECS.sell.cursorFrame).toBe(4)
|
||||||
expect(VENDOR_BUTTON_SPECS.repair.labelZh).toBe('修復')
|
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.labelZh).toBe('關閉')
|
||||||
|
expect(VENDOR_BUTTON_SPECS.close.cursorFrame).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('verifies baked boxpieces.png and focus16.png dimensions and MPQ byte parity', async () => {
|
it('verifies baked boxpieces.png and focus16.png dimensions and MPQ byte parity', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue