From 855d610168f8e24ee478c7b768bcd3b190880a9a Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sun, 27 Sep 2026 07:47:31 +0000 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E7=82=B9=E5=87=BB=E5=95=86=E4=BA=BA?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E4=B9=B0/=E5=8D=96/=E4=BF=AE=E7=90=86?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E5=90=8E=E9=BC=A0=E6=A0=87=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E4=B8=BA=E5=8E=9F=E7=89=88=20buysell.dc6=20?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E5=85=89=E6=A0=87=20(Fixes=20#491)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/ui/cursor.png | Bin 5736 -> 8885 bytes scripts/pack-cursor.ts | 98 +++++++++++++-- src/ui/cursor-meta.ts | 223 +++++++++++++++++++++++++++++++++- src/ui/cursor.ts | 95 +++++++++++---- src/ui/hud-manager.ts | 86 +++++++++---- src/ui/world-panels.ts | 21 ++++ tests/cursor.test.ts | 164 +++++++++++++++++++++++-- tests/vendor-panel-ui.test.ts | 19 +-- 8 files changed, 634 insertions(+), 72 deletions(-) diff --git a/public/ui/cursor.png b/public/ui/cursor.png index c43d19a65eb5850f2b643a55c4210a4e2ab50db4..fe9e98e3434587b9def123715ee4dab27498df06 100644 GIT binary patch delta 8142 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 delta 4968 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 mQ4I|#N9cPEjql$(`u+!{dSj3%27{ge0000 } @@ -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 = { protate: [], ohand: [], orotate: [], + buysell: [], } const allFrames: Record = {} @@ -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 } 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 diff --git a/src/ui/cursor-meta.ts b/src/ui/cursor-meta.ts index d09f5e6..c88de40 100644 --- a/src/ui/cursor-meta.ts +++ b/src/ui/cursor-meta.ts @@ -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 } 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 diff --git a/src/ui/cursor.ts b/src/ui/cursor.ts index 3b3491f..de4d8d1 100644 --- a/src/ui/cursor.ts +++ b/src/ui/cursor.ts @@ -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,35 +161,46 @@ 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.state === 'POINTING') { - anim = 'protate' - // Looping pointing hand swaying animation driven by animation clock - const safeClock = Math.max(0, nowMs) - frameIndex = Math.floor(safeClock / this.frameDurationMs) % 8 - } else if (this.state === 'OPENING') { - anim = 'ohand' - // Plays ohand 8 frames sequentially - const openingElapsed = Math.max(0, idleMs - this.idleThresholdMs) - frameIndex = Math.min(7, Math.max(0, Math.floor(openingElapsed / this.openingFrameDurationMs))) + 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 { - anim = 'orotate' - // Open palm swaying idle animation loops 8 frames - const openIdleElapsed = Math.max(0, idleMs - this.idleThresholdMs - openingTotalMs) - frameIndex = Math.floor(openIdleElapsed / this.frameDurationMs) % 8 - } + const idleMs = Math.max(0, nowMs - this.lastActivityTime) + const openingTotalMs = 8 * this.openingFrameDurationMs - if (frameIndex < 0 || isNaN(frameIndex)) { - frameIndex = 0 - } + if (this.state === 'POINTING') { + anim = 'protate' + // Looping pointing hand swaying animation driven by animation clock + const safeClock = Math.max(0, nowMs) + frameIndex = Math.floor(safeClock / this.frameDurationMs) % 8 + } else if (this.state === 'OPENING') { + anim = 'ohand' + // Plays ohand 8 frames sequentially + const openingElapsed = Math.max(0, idleMs - this.idleThresholdMs) + frameIndex = Math.min(7, Math.max(0, Math.floor(openingElapsed / this.openingFrameDurationMs))) + } else { + anim = 'orotate' + // Open palm swaying idle animation loops 8 frames + const openIdleElapsed = Math.max(0, idleMs - this.idleThresholdMs - openingTotalMs) + frameIndex = Math.floor(openIdleElapsed / this.frameDurationMs) % 8 + } - const frameList = this.metadata[anim] ?? this.metadata.protate - const frameMeta = frameList[frameIndex] ?? this.metadata.protate[0]! + if (frameIndex < 0 || isNaN(frameIndex)) { + frameIndex = 0 + } + + const frameList = this.metadata[anim] ?? this.metadata.protate + 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( diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index ec2797f..89aba2a 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -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 && @@ -1015,9 +1047,6 @@ export class HudManager { return } } - } - - this.hudCanvas.addEventListener('mousedown', handleHudMouseDown) } private handleSkillAllocated(skillId: number): void { @@ -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', - }) - } } } diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 0695d4b..9811374 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -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> = { @@ -130,6 +132,7 @@ export const VENDOR_BUTTON_SPECS: Readonly { +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') + }) }) }) + diff --git a/tests/vendor-panel-ui.test.ts b/tests/vendor-panel-ui.test.ts index 183c4da..e8ccd33 100644 --- a/tests/vendor-panel-ui.test.ts +++ b/tests/vendor-panel-ui.test.ts @@ -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 () => {