diff --git a/public/ui/cursor.png b/public/ui/cursor.png index c43d19a..fe9e98e 100644 Binary files a/public/ui/cursor.png and b/public/ui/cursor.png differ diff --git a/scripts/pack-cursor.ts b/scripts/pack-cursor.ts index e9db2fd..369c604 100644 --- a/scripts/pack-cursor.ts +++ b/scripts/pack-cursor.ts @@ -2,11 +2,12 @@ * scripts/pack-cursor.ts * * Extracts authentic Diablo II v1.13c cursor DC6 assets from d2data.mpq: - * - protate.dc6 (8 frames: pointing hand swaying animation) - * - ohand.dc6 (8 frames: finger-to-palm opening transition) - * - orotate.dc6 (8 frames: open palm swaying idle animation) + * - protate.dc6 (8 frames: pointing hand swaying animation, CursorId 5) + * - ohand.dc6 (8 frames: finger-to-palm opening transition, CursorId 2) + * - orotate.dc6 (8 frames: open palm swaying idle animation, CursorId 3) + * - buysell.dc6 (10 frames: Identify=0, Repair=1, Buy=3, Sell=4, etc., CursorId 6 at 0x6fba8558) * - * Packs all 24 frames into public/ui/cursor.png with metadata in src/ui/cursor-meta.ts. + * Packs all 34 frames into public/ui/cursor.png with metadata in src/ui/cursor-meta.ts. * Enforces Zero Runtime MPQ/DLL Architectural Invariant. */ @@ -29,14 +30,25 @@ export interface CursorFrameMeta { readonly hotspotY: number } +export const BUYSELL_CURSOR_FRAMES = { + identify: 0, + repair: 1, + buy: 3, + sell: 4, +} as const + +export type BuySellCursorMode = keyof typeof BUYSELL_CURSOR_FRAMES + export interface CursorAtlasMetadata { readonly atlasWidth: number readonly atlasHeight: number readonly frameWidth: number readonly frameHeight: number + readonly buysellFrameHeight: number readonly protate: readonly CursorFrameMeta[] readonly ohand: readonly CursorFrameMeta[] readonly orotate: readonly CursorFrameMeta[] + readonly buysell: readonly CursorFrameMeta[] readonly frames: Record } @@ -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 () => {