diff --git a/acts.html b/acts.html
index e0afa56..7e4e4f1 100644
--- a/acts.html
+++ b/acts.html
@@ -10,7 +10,7 @@
html, body { margin: 0; height: 100%; background: #0b0906; color: #e8dcc8;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: hidden; }
/* 画布铺满窗口;HUD 与控制器浮在上面 */
- #view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; background: #000; }
+ #view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; background: #000; cursor: none; }
.overlay { position: fixed; z-index: 2; background: rgba(11, 9, 6, .82); border: 1px solid #33291f;
border-radius: 6px; backdrop-filter: blur(3px); }
header { top: 0; left: 0; right: 0; padding: 6px 12px; border-radius: 0; border-width: 0 0 1px 0;
@@ -29,7 +29,7 @@
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
#status:empty { display: none; }
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
- #d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; }
+ #d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; cursor: none; }
.hint { color: #6d5a34; }
diff --git a/public/ui/cursor.png b/public/ui/cursor.png
new file mode 100644
index 0000000..a1e86fc
Binary files /dev/null and b/public/ui/cursor.png differ
diff --git a/public/ui/manifest.json b/public/ui/manifest.json
index 0219817..1fa5f6c 100644
--- a/public/ui/manifest.json
+++ b/public/ui/manifest.json
@@ -4061,6 +4061,7 @@
"skillPoints": "/ui/skillpoints.png",
"genericSkills": "/ui/generic-skills.png",
"cursorHand": "/ui/cursor-hand.png",
+ "cursorAtlas": "/ui/cursor.png",
"questTabs": "/ui/quest-tabs.png",
"waypointTabs": "/ui/waypoint-tabs.png",
"waypointIcons": "/ui/waypoint-icons.png",
diff --git a/scripts/pack-cursor.ts b/scripts/pack-cursor.ts
new file mode 100644
index 0000000..e9db2fd
--- /dev/null
+++ b/scripts/pack-cursor.ts
@@ -0,0 +1,193 @@
+/**
+ * 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)
+ *
+ * Packs all 24 frames into public/ui/cursor.png with metadata in src/ui/cursor-meta.ts.
+ * Enforces Zero Runtime MPQ/DLL Architectural Invariant.
+ */
+
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { MpqArchive } from '../src/mpq/archive.ts'
+import { fileSource } from '../src/mpq/file-source.ts'
+import { decodeDc6, type Dc6Frame } from '../src/formats/dc6.ts'
+import { decodePl2 } from '../src/formats/pl2.ts'
+import { encodeIndexedPng } from './png.ts'
+
+export interface CursorFrameMeta {
+ readonly x: number
+ readonly y: number
+ readonly w: number
+ readonly h: number
+ readonly offsetX: number
+ readonly offsetY: number
+ readonly hotspotX: number
+ readonly hotspotY: number
+}
+
+export interface CursorAtlasMetadata {
+ readonly atlasWidth: number
+ readonly atlasHeight: number
+ readonly frameWidth: number
+ readonly frameHeight: number
+ readonly protate: readonly CursorFrameMeta[]
+ readonly ohand: readonly CursorFrameMeta[]
+ readonly orotate: readonly CursorFrameMeta[]
+ readonly frames: Record
+}
+
+export async function packCursorAssets(projectRoot: string = process.cwd()): Promise {
+ const mpqPath = join(projectRoot, 'samples', 'd2', 'd2data.mpq')
+ const mpq = await MpqArchive.open(await fileSource(mpqPath))
+
+ const palFile = mpq.find('data/global/palette/ACT1/pal.pl2')
+ if (!palFile) throw new Error('data/global/palette/ACT1/pal.pl2 not found in d2data.mpq')
+ const pl2 = decodePl2(await mpq.read(palFile))
+
+ const animNames = ['protate', 'ohand', 'orotate'] as const
+ const decodedSheets: Record = {}
+
+ for (const name of animNames) {
+ const file = mpq.find(`data/global/ui/CURSOR/${name}.dc6`)
+ if (!file) throw new Error(`Cursor DC6 not found: data/global/ui/CURSOR/${name}.dc6`)
+ const sheet = decodeDc6(await mpq.read(file))
+ const frames = sheet.groups[0]?.frames ?? []
+ if (frames.length !== 8) {
+ throw new Error(`Expected 8 frames in ${name}.dc6, got ${frames.length}`)
+ }
+ decodedSheets[name] = frames
+ }
+
+ // Layout: 3 rows (protate, ohand, orotate), 8 columns
+ const CELL_W = 32
+ const CELL_H = 32
+ const COLS = 8
+ const ROWS = 3
+ const atlasWidth = CELL_W * COLS // 256
+ const atlasHeight = CELL_H * ROWS // 96
+ const pixels = new Uint8Array(atlasWidth * atlasHeight)
+
+ const metaLists: Record = {
+ protate: [],
+ ohand: [],
+ orotate: [],
+ }
+ const allFrames: Record = {}
+
+ animNames.forEach((anim, row) => {
+ const frames = decodedSheets[anim]!
+ frames.forEach((frame, col) => {
+ const dstX = col * CELL_W
+ const dstY = row * CELL_H
+
+ // Blit frame pixels into atlas
+ 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
+ }
+ }
+ }
+ }
+
+ // Hotspot calculation:
+ // In D2 DC6 coordinates, pointing finger tip is at col 0, row 0 of frame 0.
+ // For protate (offsetX = -1), the tip is at (0, 0).
+ // When palm spreads in ohand/orotate (offsetX = -2), the anchor shifts left 1px.
+ const hotspotX = anim === 'protate' ? 0 : Math.max(0, -frame.offsetX - 1)
+ const hotspotY = 0
+
+ const frameMeta: CursorFrameMeta = {
+ x: dstX,
+ y: dstY,
+ w: frame.width,
+ h: frame.height,
+ offsetX: frame.offsetX,
+ offsetY: frame.offsetY,
+ hotspotX,
+ hotspotY,
+ }
+
+ metaLists[anim]!.push(frameMeta)
+ allFrames[`${anim}_${col}`] = frameMeta
+ })
+ })
+
+ const atlasMeta: CursorAtlasMetadata = {
+ atlasWidth,
+ atlasHeight,
+ frameWidth: CELL_W,
+ frameHeight: CELL_H,
+ protate: metaLists.protate!,
+ ohand: metaLists.ohand!,
+ orotate: metaLists.orotate!,
+ frames: allFrames,
+ }
+
+ // Save public/ui/cursor.png
+ const outDir = join(projectRoot, 'public', 'ui')
+ mkdirSync(outDir, { recursive: true })
+ const pngData = encodeIndexedPng({
+ width: atlasWidth,
+ height: atlasHeight,
+ pixels,
+ palette: pl2.rgb,
+ transparentIndex: 0,
+ })
+ writeFileSync(join(outDir, 'cursor.png'), pngData)
+
+ // Save src/ui/cursor-meta.ts
+ const metaCode = `/**
+ * Authentic Diablo II v1.13c Cursor Atlas Metadata.
+ * Auto-generated by scripts/pack-cursor.ts.
+ * Enforces Zero Runtime MPQ/DLL Architectural Invariant.
+ */
+
+export interface CursorFrameMeta {
+ readonly x: number
+ readonly y: number
+ readonly w: number
+ readonly h: number
+ readonly offsetX: number
+ readonly offsetY: number
+ readonly hotspotX: number
+ readonly hotspotY: number
+}
+
+export interface CursorAtlasMetadata {
+ readonly atlasWidth: number
+ readonly atlasHeight: number
+ readonly frameWidth: number
+ readonly frameHeight: number
+ readonly protate: readonly CursorFrameMeta[]
+ readonly ohand: readonly CursorFrameMeta[]
+ readonly orotate: 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('Saved metadata to src/ui/cursor-meta.ts')
+
+ return atlasMeta
+}
+
+if (process.argv[1]?.endsWith('pack-cursor.ts')) {
+ packCursorAssets()
+ .then(() => process.exit(0))
+ .catch((err) => {
+ console.error('pack-cursor failed:', err)
+ process.exit(1)
+ })
+}
diff --git a/scripts/pack-ui.ts b/scripts/pack-ui.ts
index ff9de68..da217ec 100644
--- a/scripts/pack-ui.ts
+++ b/scripts/pack-ui.ts
@@ -18,6 +18,7 @@ import type { SpriteFrame, SpriteSheet } from '../src/formats/sprite.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
import { parseTable } from '../src/game/tables.ts'
import { encodeIndexedPng } from './png.ts'
+import { packCursorAssets } from './pack-cursor.ts'
interface BlitPlacement {
readonly frame: SpriteFrame
@@ -376,6 +377,10 @@ async function main(): Promise {
const ohand = await decodeUiDc6('data/global/ui/CURSOR/ohand.dc6')
savePng('cursor-hand.png', ohand.groups[0]!.frames[0]!.width, ohand.groups[0]!.frames[0]!.height, ohand.groups[0]!.frames[0]!.indices)
+ // Extract authentic dynamic cursor atlas (Issue #148: protate, ohand, orotate)
+ console.log('Extracting authentic dynamic cursor atlas (Issue #148)...')
+ await packCursorAssets(process.cwd())
+
// 7. Extract Bitmap Fonts (`data/local/font/latin/*.dc6 + *.tbl`)
console.log('Extracting bitmap fonts...')
const fontNames = ['font6', 'font8', 'font16', 'font30', 'fontexocet10', 'fontformal12'] as const
@@ -636,6 +641,7 @@ async function main(): Promise {
skillPoints: '/ui/skillpoints.png',
genericSkills: '/ui/generic-skills.png',
cursorHand: '/ui/cursor-hand.png',
+ cursorAtlas: '/ui/cursor.png',
questTabs: '/ui/quest-tabs.png',
waypointTabs: '/ui/waypoint-tabs.png',
waypointIcons: '/ui/waypoint-icons.png',
@@ -753,6 +759,8 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
questRects: ${JSON.stringify(questRects, null, 2)},
images: ${JSON.stringify(imagesMap, null, 2)},
}
+
+export { CURSOR_METADATA, type CursorFrameMeta, type CursorAtlasMetadata } from './cursor-meta.ts'
`
writeFileSync(join(process.cwd(), 'src', 'ui', 'baked-ui-meta.ts'), bakedMetaCode, 'utf8')
console.log(`Successfully decoded ${decodedCount} DC6 sheets (${decodeFailures} failures)!`)
diff --git a/src/ui/baked-ui-meta.ts b/src/ui/baked-ui-meta.ts
index a9bfbc8..497f207 100644
--- a/src/ui/baked-ui-meta.ts
+++ b/src/ui/baked-ui-meta.ts
@@ -4142,6 +4142,7 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
"skillPoints": "/ui/skillpoints.png",
"genericSkills": "/ui/generic-skills.png",
"cursorHand": "/ui/cursor-hand.png",
+ "cursorAtlas": "/ui/cursor.png",
"questTabs": "/ui/quest-tabs.png",
"waypointTabs": "/ui/waypoint-tabs.png",
"waypointIcons": "/ui/waypoint-icons.png",
@@ -4149,3 +4150,5 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
"questsAtlas": "/ui/quests-atlas.png"
},
}
+
+export { CURSOR_METADATA, type CursorFrameMeta, type CursorAtlasMetadata } from './cursor-meta.ts'
diff --git a/src/ui/cursor-meta.ts b/src/ui/cursor-meta.ts
new file mode 100644
index 0000000..d09f5e6
--- /dev/null
+++ b/src/ui/cursor-meta.ts
@@ -0,0 +1,522 @@
+/**
+ * Authentic Diablo II v1.13c Cursor Atlas Metadata.
+ * Auto-generated by scripts/pack-cursor.ts.
+ * Enforces Zero Runtime MPQ/DLL Architectural Invariant.
+ */
+
+export interface CursorFrameMeta {
+ readonly x: number
+ readonly y: number
+ readonly w: number
+ readonly h: number
+ readonly offsetX: number
+ readonly offsetY: number
+ readonly hotspotX: number
+ readonly hotspotY: number
+}
+
+export interface CursorAtlasMetadata {
+ readonly atlasWidth: number
+ readonly atlasHeight: number
+ readonly frameWidth: number
+ readonly frameHeight: number
+ readonly protate: readonly CursorFrameMeta[]
+ readonly ohand: readonly CursorFrameMeta[]
+ readonly orotate: readonly CursorFrameMeta[]
+ readonly frames: Record
+}
+
+export const CURSOR_METADATA: CursorAtlasMetadata = {
+ "atlasWidth": 256,
+ "atlasHeight": 96,
+ "frameWidth": 32,
+ "frameHeight": 32,
+ "protate": [
+ {
+ "x": 0,
+ "y": 0,
+ "w": 30,
+ "h": 27,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ {
+ "x": 32,
+ "y": 0,
+ "w": 31,
+ "h": 27,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ {
+ "x": 64,
+ "y": 0,
+ "w": 31,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ {
+ "x": 96,
+ "y": 0,
+ "w": 31,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ {
+ "x": 128,
+ "y": 0,
+ "w": 31,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ {
+ "x": 160,
+ "y": 0,
+ "w": 31,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ {
+ "x": 192,
+ "y": 0,
+ "w": 31,
+ "h": 27,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ {
+ "x": 224,
+ "y": 0,
+ "w": 30,
+ "h": 27,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ }
+ ],
+ "ohand": [
+ {
+ "x": 0,
+ "y": 32,
+ "w": 32,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ {
+ "x": 32,
+ "y": 32,
+ "w": 32,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ {
+ "x": 64,
+ "y": 32,
+ "w": 32,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ {
+ "x": 96,
+ "y": 32,
+ "w": 32,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ {
+ "x": 128,
+ "y": 32,
+ "w": 32,
+ "h": 26,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ {
+ "x": 160,
+ "y": 32,
+ "w": 32,
+ "h": 27,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ {
+ "x": 192,
+ "y": 32,
+ "w": 32,
+ "h": 28,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ {
+ "x": 224,
+ "y": 32,
+ "w": 32,
+ "h": 29,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ }
+ ],
+ "orotate": [
+ {
+ "x": 0,
+ "y": 64,
+ "w": 31,
+ "h": 30,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ {
+ "x": 32,
+ "y": 64,
+ "w": 32,
+ "h": 30,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ {
+ "x": 64,
+ "y": 64,
+ "w": 32,
+ "h": 29,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ {
+ "x": 96,
+ "y": 64,
+ "w": 32,
+ "h": 29,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ {
+ "x": 128,
+ "y": 64,
+ "w": 32,
+ "h": 29,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ {
+ "x": 160,
+ "y": 64,
+ "w": 32,
+ "h": 29,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ {
+ "x": 192,
+ "y": 64,
+ "w": 32,
+ "h": 30,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ {
+ "x": 224,
+ "y": 64,
+ "w": 31,
+ "h": 30,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ }
+ ],
+ "frames": {
+ "protate_0": {
+ "x": 0,
+ "y": 0,
+ "w": 30,
+ "h": 27,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ "protate_1": {
+ "x": 32,
+ "y": 0,
+ "w": 31,
+ "h": 27,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ "protate_2": {
+ "x": 64,
+ "y": 0,
+ "w": 31,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ "protate_3": {
+ "x": 96,
+ "y": 0,
+ "w": 31,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ "protate_4": {
+ "x": 128,
+ "y": 0,
+ "w": 31,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ "protate_5": {
+ "x": 160,
+ "y": 0,
+ "w": 31,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ "protate_6": {
+ "x": 192,
+ "y": 0,
+ "w": 31,
+ "h": 27,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ "protate_7": {
+ "x": 224,
+ "y": 0,
+ "w": 30,
+ "h": 27,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ "ohand_0": {
+ "x": 0,
+ "y": 32,
+ "w": 32,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ "ohand_1": {
+ "x": 32,
+ "y": 32,
+ "w": 32,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ "ohand_2": {
+ "x": 64,
+ "y": 32,
+ "w": 32,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ "ohand_3": {
+ "x": 96,
+ "y": 32,
+ "w": 32,
+ "h": 26,
+ "offsetX": -1,
+ "offsetY": 24,
+ "hotspotX": 0,
+ "hotspotY": 0
+ },
+ "ohand_4": {
+ "x": 128,
+ "y": 32,
+ "w": 32,
+ "h": 26,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ "ohand_5": {
+ "x": 160,
+ "y": 32,
+ "w": 32,
+ "h": 27,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ "ohand_6": {
+ "x": 192,
+ "y": 32,
+ "w": 32,
+ "h": 28,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ "ohand_7": {
+ "x": 224,
+ "y": 32,
+ "w": 32,
+ "h": 29,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ "orotate_0": {
+ "x": 0,
+ "y": 64,
+ "w": 31,
+ "h": 30,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ "orotate_1": {
+ "x": 32,
+ "y": 64,
+ "w": 32,
+ "h": 30,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ "orotate_2": {
+ "x": 64,
+ "y": 64,
+ "w": 32,
+ "h": 29,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ "orotate_3": {
+ "x": 96,
+ "y": 64,
+ "w": 32,
+ "h": 29,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ "orotate_4": {
+ "x": 128,
+ "y": 64,
+ "w": 32,
+ "h": 29,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ "orotate_5": {
+ "x": 160,
+ "y": 64,
+ "w": 32,
+ "h": 29,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ "orotate_6": {
+ "x": 192,
+ "y": 64,
+ "w": 32,
+ "h": 30,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ },
+ "orotate_7": {
+ "x": 224,
+ "y": 64,
+ "w": 31,
+ "h": 30,
+ "offsetX": -2,
+ "offsetY": 24,
+ "hotspotX": 1,
+ "hotspotY": 0
+ }
+ }
+} as const
diff --git a/src/ui/cursor.ts b/src/ui/cursor.ts
new file mode 100644
index 0000000..3b3491f
--- /dev/null
+++ b/src/ui/cursor.ts
@@ -0,0 +1,224 @@
+/**
+ * Diablo II v1.13c Authentic Dynamic Cursor & Idle State Machine (`src/ui/cursor.ts`).
+ *
+ * Implements:
+ * - 3-State Cursor Machine:
+ * 1. POINTING: Active state when mouse is moving or idle < 2000ms. Renders `protate` swaying frames.
+ * 2. OPENING: Triggered after 2000ms of inactivity. Plays `ohand` 8 frames sequentially.
+ * 3. OPEN_IDLE: Once `ohand` finishes, loops `orotate` 8 frames.
+ * - Any mouse movement or mouse event (mousedown/mouseup) resets the idle timer back to 0
+ * and returns immediately to POINTING.
+ * - Accurate Hotspot and Coordinate Mapping:
+ * - Mouse position (mouseX, mouseY) is the cursor hotspot.
+ * - Hand cursor draws with index finger tip at (mouseX, mouseY).
+ * - Held items (this.inventory.cursorItem) are rendered centered at the cursor hotspot.
+ * - Zero Runtime MPQ/DLL calls.
+ */
+
+import {
+ CURSOR_METADATA,
+ type CursorAtlasMetadata,
+ type CursorFrameMeta,
+} from './cursor-meta.ts'
+
+export type CursorState = 'POINTING' | 'OPENING' | 'OPEN_IDLE'
+export type CursorAnimation = 'protate' | 'ohand' | 'orotate'
+
+export interface CursorRenderInfo {
+ readonly state: CursorState
+ readonly animation: CursorAnimation
+ readonly frameIndex: number
+ readonly frame: CursorFrameMeta
+ readonly drawX: number
+ readonly drawY: number
+ readonly hotspotX: number
+ readonly hotspotY: number
+ readonly bounds: {
+ readonly x: number
+ readonly y: number
+ readonly width: number
+ readonly height: number
+ }
+}
+
+export interface HeldItemRenderInfo {
+ readonly atlas: CanvasImageSource
+ readonly rect: {
+ readonly x: number
+ readonly y: number
+ readonly w: number
+ readonly h: number
+ }
+}
+
+export class CursorController {
+ readonly metadata: CursorAtlasMetadata
+ mouseX: number = 0
+ mouseY: number = 0
+ lastActivityTime: number = 0
+ state: CursorState = 'POINTING'
+
+ /** Milliseconds of inactivity before transitioning from POINTING to OPENING. */
+ idleThresholdMs: number = 2000
+
+ /** Frame playback duration for looping swaying animations (protate & orotate). */
+ frameDurationMs: number = 125 // 8 FPS
+
+ /** Frame playback duration for one-shot finger-to-palm opening transition (ohand). */
+ openingFrameDurationMs: number = 125 // 8 frames * 125ms = 1000ms total opening time
+
+ constructor(metadata: CursorAtlasMetadata = CURSOR_METADATA, initialTimeMs: number = 0) {
+ this.metadata = metadata
+ this.lastActivityTime = initialTimeMs
+ }
+
+ /**
+ * Resets the idle timer and reverts state immediately to POINTING.
+ */
+ resetIdle(nowMs: number = performance.now()): void {
+ this.lastActivityTime = nowMs
+ this.state = 'POINTING'
+ }
+
+ /**
+ * Updates cursor position and resets the idle timer.
+ */
+ handleMouseMove(x: number, y: number, nowMs: number = performance.now()): void {
+ this.mouseX = x
+ this.mouseY = y
+ this.resetIdle(nowMs)
+ }
+
+ /**
+ * Mouse down event handler; resets idle timer to keep cursor in POINTING state.
+ */
+ handleMouseDown(nowMs: number = performance.now()): void {
+ this.resetIdle(nowMs)
+ }
+
+ /**
+ * Mouse up event handler; resets idle timer to keep cursor in POINTING state.
+ */
+ handleMouseUp(nowMs: number = performance.now()): void {
+ this.resetIdle(nowMs)
+ }
+
+ /**
+ * Evaluates the state machine transitions based on elapsed idle time.
+ */
+ update(nowMs: number = performance.now()): CursorState {
+ const idleMs = Math.max(0, nowMs - this.lastActivityTime)
+ const openingTotalMs = 8 * this.openingFrameDurationMs
+
+ if (idleMs < this.idleThresholdMs) {
+ this.state = 'POINTING'
+ } else if (idleMs < this.idleThresholdMs + openingTotalMs) {
+ this.state = 'OPENING'
+ } else {
+ this.state = 'OPEN_IDLE'
+ }
+
+ return this.state
+ }
+
+ /**
+ * Computes the current active animation, frame index, drawing coordinates, and bounds.
+ */
+ 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
+
+ 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
+ }
+
+ if (frameIndex < 0 || isNaN(frameIndex)) {
+ frameIndex = 0
+ }
+
+ const frameList = this.metadata[anim] ?? this.metadata.protate
+ const frameMeta = frameList[frameIndex] ?? this.metadata.protate[0]!
+
+ const drawX = Math.round(this.mouseX - frameMeta.hotspotX)
+ const drawY = Math.round(this.mouseY - frameMeta.hotspotY)
+
+ return {
+ state: this.state,
+ animation: anim,
+ frameIndex,
+ frame: frameMeta,
+ drawX,
+ drawY,
+ hotspotX: frameMeta.hotspotX,
+ hotspotY: frameMeta.hotspotY,
+ bounds: {
+ x: drawX,
+ y: drawY,
+ width: frameMeta.w,
+ height: frameMeta.h,
+ },
+ }
+ }
+
+ /**
+ * Returns current cursor rendering bounds.
+ */
+ getRenderBounds(nowMs: number = performance.now()): { x: number; y: number; width: number; height: number } {
+ return this.getCurrentFrame(nowMs).bounds
+ }
+
+ /**
+ * Renders the cursor (or held item) to the 2D context.
+ */
+ render(
+ ctx: CanvasRenderingContext2D,
+ cursorAtlas: CanvasImageSource | null,
+ nowMs: number = performance.now(),
+ heldItem?: HeldItemRenderInfo | null,
+ ): void {
+ this.update(nowMs)
+
+ // When an item is held on cursor, render the item centered at cursor hotspot
+ if (heldItem && heldItem.atlas && heldItem.rect) {
+ const sr = heldItem.rect
+ const dx = Math.round(this.mouseX - sr.w / 2)
+ const dy = Math.round(this.mouseY - sr.h / 2)
+ ctx.drawImage(heldItem.atlas, sr.x, sr.y, sr.w, sr.h, dx, dy, sr.w, sr.h)
+ return
+ }
+
+ // Otherwise, render the authentic animated hand cursor
+ if (cursorAtlas) {
+ const info = this.getCurrentFrame(nowMs)
+ ctx.drawImage(
+ cursorAtlas,
+ info.frame.x,
+ info.frame.y,
+ info.frame.w,
+ info.frame.h,
+ info.drawX,
+ info.drawY,
+ info.frame.w,
+ info.frame.h,
+ )
+ }
+ }
+}
diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts
index b52b8bf..6828538 100644
--- a/src/ui/hud-manager.ts
+++ b/src/ui/hud-manager.ts
@@ -23,11 +23,13 @@ import { InventoryPanel, type EquipSlotId, resolveItemSpriteRect } from './inven
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'
export interface PublishedHudState {
ready: boolean
dc6DecodeFailures: number
decodedDc6Count: number
+ cursorState: CursorState
assetsLoaded: {
ctrlPnl: boolean
globes: boolean
@@ -156,6 +158,7 @@ export class HudManager {
readonly charSheet = new CharacterSheetPanel()
readonly skillTree = new SkillTreePanel()
readonly worldPanels = new WorldPanelsHud()
+ readonly cursor = new CursorController()
leftPanel: LeftDockPanelKind = 'none'
rightPanel: 'none' | 'inv' | 'skill' = 'none'
@@ -190,6 +193,7 @@ export class HudManager {
ready: false,
dc6DecodeFailures: 0,
decodedDc6Count: 415,
+ cursorState: 'POINTING',
assetsLoaded: {
ctrlPnl: false,
globes: false,
@@ -234,6 +238,9 @@ export class HudManager {
onDropItemToGround?: (name: string) => void
},
) {
+ if (this.hudCanvas) {
+ this.hudCanvas.style.cursor = 'none'
+ }
if (typeof window !== 'undefined') {
window.__d2webHud = this.state
window.__d2webHudInstance = this
@@ -287,6 +294,7 @@ export class HudManager {
skillPoints: `${baseUrl}/skillpoints.png`,
genericSkills: `${baseUrl}/generic-skills.png`,
cursorHand: `${baseUrl}/cursor-hand.png`,
+ cursorAtlas: `${baseUrl}/cursor.png`,
itemsAtlas: `${baseUrl}/items-atlas.png`,
questsAtlas: `${baseUrl}/quests-atlas.png`,
}
@@ -465,6 +473,7 @@ export class HudManager {
const pt = this.clientToLogical(e.clientX, e.clientY)
this.mouseX = pt.x
this.mouseY = pt.y
+ this.cursor.handleMouseMove(pt.x, pt.y)
this.globes.handleMouseMove(pt.x, pt.y)
this.belt.handleMouseMove(pt.x, pt.y)
this.hotkeys.handleMouseMove(pt.x, pt.y)
@@ -473,6 +482,14 @@ export class HudManager {
this.skillTree.handleMouseMove(pt.x, pt.y)
})
+ window.addEventListener('mousedown', () => {
+ this.cursor.handleMouseDown()
+ })
+
+ window.addEventListener('mouseup', () => {
+ this.cursor.handleMouseUp()
+ })
+
this.hudCanvas.addEventListener('contextmenu', (e) => {
e.preventDefault()
})
@@ -615,6 +632,7 @@ export class HudManager {
this.state.miniPanelOpen = this.controlBar.miniPanelOpen
this.state.isRunning = this.isRunning
this.state.showGroundLabels = this.showGroundLabels
+ this.state.cursorState = this.cursor.state
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
@@ -808,27 +826,32 @@ export class HudManager {
this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font)
}
- // 10. Held `cursorItem` Sprite Following Mouse
- if (this.inventory.cursorItem) {
- const ci = this.inventory.cursorItem
- const sr = resolveItemSpriteRect(ci, this.itemRects)
- const atlas = this.images.get('itemsAtlas')
- if (atlas && sr) {
- ctx.drawImage(
- atlas,
- sr.x,
- sr.y,
- sr.w,
- sr.h,
- Math.round(this.mouseX - sr.w / 2),
- Math.round(this.mouseY - sr.h / 2),
- sr.w,
- sr.h,
- )
- }
- }
+ // 10. Authentic Hand Cursor & Held Item at Hotspot
+ this.renderCursor(ctx, nowMs)
ctx.restore()
this.syncPublishedState()
}
+
+ /**
+ * Renders the authentic animated hand cursor or held cursorItem at cursor hotspot.
+ */
+ renderCursor(ctx: CanvasRenderingContext2D, nowMs: number = performance.now()): void {
+ const cursorAtlas = this.images.get('cursorAtlas') ?? this.images.get('cursorHand') ?? null
+ const heldItem = this.inventory.cursorItem
+ ? {
+ atlas: this.images.get('itemsAtlas') ?? null,
+ rect: resolveItemSpriteRect(this.inventory.cursorItem, this.itemRects) ?? null,
+ }
+ : null
+
+ this.cursor.render(
+ ctx,
+ cursorAtlas,
+ nowMs,
+ heldItem && heldItem.atlas && heldItem.rect
+ ? { atlas: heldItem.atlas, rect: heldItem.rect }
+ : null,
+ )
+ }
}
diff --git a/tests/cursor.test.ts b/tests/cursor.test.ts
new file mode 100644
index 0000000..ee0ddab
--- /dev/null
+++ b/tests/cursor.test.ts
@@ -0,0 +1,368 @@
+/**
+ * Unit & Parity Tests for Diablo II v1.13c Authentic Dynamic Cursor System (Issue #148).
+ *
+ * Verifies:
+ * 1. Offline DC6 Cursor Extraction & Metadata Parity:
+ * - All 8 frames for protate.dc6, ohand.dc6, and orotate.dc6.
+ * - Zero runtime MPQ/DLL dependencies.
+ * 2. 3-State Cursor State Machine Transitions:
+ * - Initial state is POINTING.
+ * - Inactivity >= 2000ms advances to OPENING (plays ohand 8 frames sequentially).
+ * - Completion of OPENING advances to OPEN_IDLE (loops orotate 8 frames).
+ * - Any mouse movement or mouse event (mousedown/mouseup) resets the timer and reverts to POINTING.
+ * 3. Coordinates, Hotspot Alignment, and Rendering Bounds:
+ * - (mouseX, mouseY) tracking and bounds calculation.
+ * - Held items (cursorItem) rendered centered at cursor hotspot.
+ * 4. Integration with HudManager & Canvas.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import { existsSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import {
+ CursorController,
+ type CursorState,
+ type CursorRenderInfo,
+} from '../src/ui/cursor.ts'
+import {
+ CURSOR_METADATA,
+ type CursorAtlasMetadata,
+ type CursorFrameMeta,
+} from '../src/ui/cursor-meta.ts'
+import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
+import { HudManager } from '../src/ui/hud-manager.ts'
+
+describe('Diablo II v1.13c Dynamic Cursor System (Issue #148)', () => {
+ 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')
+ expect(existsSync(atlasPath)).toBe(true)
+ const data = readFileSync(atlasPath)
+ expect(data.length).toBeGreaterThan(0)
+ })
+
+ it('loads authentic D2 1.13c cursor atlas metadata with 24 total frames (8 each)', () => {
+ expect(CURSOR_METADATA.atlasWidth).toBe(256)
+ expect(CURSOR_METADATA.atlasHeight).toBe(96)
+ expect(CURSOR_METADATA.frameWidth).toBe(32)
+ expect(CURSOR_METADATA.frameHeight).toBe(32)
+
+ // Exactly 8 frames per animation
+ expect(CURSOR_METADATA.protate).toHaveLength(8)
+ expect(CURSOR_METADATA.ohand).toHaveLength(8)
+ expect(CURSOR_METADATA.orotate).toHaveLength(8)
+
+ // 24 frames indexed in frames map
+ expect(Object.keys(CURSOR_METADATA.frames)).toHaveLength(24)
+ })
+
+ it('verifies all frame coordinates are bounded inside the 256x96 atlas', () => {
+ const allFrames = [
+ ...CURSOR_METADATA.protate,
+ ...CURSOR_METADATA.ohand,
+ ...CURSOR_METADATA.orotate,
+ ]
+
+ for (const frame of allFrames) {
+ expect(frame.x).toBeGreaterThanOrEqual(0)
+ expect(frame.y).toBeGreaterThanOrEqual(0)
+ expect(frame.w).toBeGreaterThan(0)
+ expect(frame.h).toBeGreaterThan(0)
+ expect(frame.x + frame.w).toBeLessThanOrEqual(CURSOR_METADATA.atlasWidth)
+ expect(frame.y + frame.h).toBeLessThanOrEqual(CURSOR_METADATA.atlasHeight)
+ }
+ })
+
+ it('verifies DC6 offset parity and hotspot anchor definitions', () => {
+ // protate: offsetX = -1, hotspot = (0, 0)
+ for (const frame of CURSOR_METADATA.protate) {
+ expect(frame.offsetX).toBe(-1)
+ expect(frame.offsetY).toBe(24)
+ expect(frame.hotspotX).toBe(0)
+ expect(frame.hotspotY).toBe(0)
+ }
+
+ // ohand: frames 0..3 have offsetX = -1, frames 4..7 have offsetX = -2
+ for (let i = 0; i < 4; i++) {
+ expect(CURSOR_METADATA.ohand[i]!.offsetX).toBe(-1)
+ }
+ for (let i = 4; i < 8; i++) {
+ expect(CURSOR_METADATA.ohand[i]!.offsetX).toBe(-2)
+ }
+
+ // orotate: all frames have offsetX = -2
+ for (const frame of CURSOR_METADATA.orotate) {
+ expect(frame.offsetX).toBe(-2)
+ expect(frame.offsetY).toBe(24)
+ }
+ })
+
+ it('verifies baked UI manifest includes cursorAtlas entry', () => {
+ expect(BAKED_UI_MANIFEST.images.cursorAtlas).toBe('/ui/cursor.png')
+ })
+ })
+
+ describe('2. State Machine Logic & Idle Transitions', () => {
+ it('starts in POINTING state initially and while actively moving', () => {
+ const cursor = new CursorController(CURSOR_METADATA, 0)
+ expect(cursor.state).toBe('POINTING')
+
+ // Mouse movements keep resetting idle timer
+ cursor.handleMouseMove(100, 100, 500)
+ expect(cursor.state).toBe('POINTING')
+ expect(cursor.lastActivityTime).toBe(500)
+
+ cursor.handleMouseMove(150, 120, 1200)
+ expect(cursor.state).toBe('POINTING')
+ expect(cursor.lastActivityTime).toBe(1200)
+
+ // Below 2000ms idle, remains POINTING
+ expect(cursor.update(3199)).toBe('POINTING')
+ expect(cursor.state).toBe('POINTING')
+ })
+
+ it('transitions to OPENING after 2000ms idle and plays 8 frames sequentially', () => {
+ const cursor = new CursorController(CURSOR_METADATA, 1000)
+ cursor.frameDurationMs = 125
+ cursor.openingFrameDurationMs = 125
+
+ // At exactly 2000ms idle (t = 3000ms)
+ const stateAt2000 = cursor.update(3000)
+ expect(stateAt2000).toBe('OPENING')
+ expect(cursor.state).toBe('OPENING')
+
+ // Check frame progression during OPENING (8 frames * 125ms = 1000ms total)
+ // Frame 0 at t = 3000ms (0ms into opening)
+ let info = cursor.getCurrentFrame(3000)
+ expect(info.state).toBe('OPENING')
+ expect(info.animation).toBe('ohand')
+ expect(info.frameIndex).toBe(0)
+
+ // Frame 1 at t = 3125ms (125ms into opening)
+ info = cursor.getCurrentFrame(3125)
+ expect(info.frameIndex).toBe(1)
+
+ // Frame 4 at t = 3500ms (500ms into opening)
+ info = cursor.getCurrentFrame(3500)
+ expect(info.frameIndex).toBe(4)
+
+ // Frame 7 at t = 3875ms (875ms into opening)
+ info = cursor.getCurrentFrame(3875)
+ expect(info.frameIndex).toBe(7)
+
+ // Frame 7 clamped right before completion at t = 3999ms
+ info = cursor.getCurrentFrame(3999)
+ expect(info.state).toBe('OPENING')
+ expect(info.frameIndex).toBe(7)
+ })
+
+ it('advances from OPENING to OPEN_IDLE after ohand completes and loops orotate', () => {
+ const cursor = new CursorController(CURSOR_METADATA, 0)
+ cursor.frameDurationMs = 125
+ cursor.openingFrameDurationMs = 125
+
+ // 2000ms idle + 1000ms opening = 3000ms total to reach OPEN_IDLE
+ expect(cursor.update(2999)).toBe('OPENING')
+ expect(cursor.update(3000)).toBe('OPEN_IDLE')
+ expect(cursor.state).toBe('OPEN_IDLE')
+
+ // Frame 0 of orotate at t = 3000ms
+ let info = cursor.getCurrentFrame(3000)
+ expect(info.state).toBe('OPEN_IDLE')
+ expect(info.animation).toBe('orotate')
+ expect(info.frameIndex).toBe(0)
+
+ // Frame 1 of orotate at t = 3125ms
+ info = cursor.getCurrentFrame(3125)
+ expect(info.frameIndex).toBe(1)
+
+ // Frame 7 of orotate at t = 3875ms
+ info = cursor.getCurrentFrame(3875)
+ expect(info.frameIndex).toBe(7)
+
+ // Loops back to frame 0 at t = 4000ms (1000ms into open idle)
+ info = cursor.getCurrentFrame(4000)
+ expect(info.state).toBe('OPEN_IDLE')
+ expect(info.frameIndex).toBe(0)
+
+ // Loops to frame 3 at t = 4375ms
+ info = cursor.getCurrentFrame(4375)
+ expect(info.frameIndex).toBe(3)
+ })
+
+ it('reverts immediately to POINTING upon mouse movement at any stage', () => {
+ const cursor = new CursorController(CURSOR_METADATA, 0)
+
+ // Fast forward to OPEN_IDLE (t = 5000ms)
+ cursor.update(5000)
+ expect(cursor.state).toBe('OPEN_IDLE')
+
+ // Mouse moves
+ cursor.handleMouseMove(400, 300, 5001)
+ expect(cursor.state).toBe('POINTING')
+ expect(cursor.lastActivityTime).toBe(5001)
+ expect(cursor.mouseX).toBe(400)
+ expect(cursor.mouseY).toBe(300)
+
+ const info = cursor.getCurrentFrame(5001)
+ expect(info.state).toBe('POINTING')
+ expect(info.animation).toBe('protate')
+ })
+
+ it('reverts immediately to POINTING upon mouse click (mousedown/mouseup) events', () => {
+ const cursor = new CursorController(CURSOR_METADATA, 0)
+
+ // In OPENING state (t = 2500ms)
+ cursor.update(2500)
+ expect(cursor.state).toBe('OPENING')
+
+ // Mousedown triggers reset
+ cursor.handleMouseDown(2501)
+ expect(cursor.state).toBe('POINTING')
+
+ // Advance again to OPENING (t = 4501ms)
+ cursor.update(4502)
+ expect(cursor.state).toBe('OPENING')
+
+ // Mouseup triggers reset
+ cursor.handleMouseUp(4503)
+ expect(cursor.state).toBe('POINTING')
+ })
+ })
+
+ describe('3. Coordinates, Hotspot & Rendering Bounds', () => {
+ it('accurately aligns draw coordinates with mouse position and frame hotspots', () => {
+ const cursor = new CursorController(CURSOR_METADATA, 0)
+ cursor.handleMouseMove(320, 240, 0)
+
+ const info = cursor.getCurrentFrame(0)
+ expect(info.drawX).toBe(320)
+ expect(info.drawY).toBe(240)
+ expect(info.bounds).toEqual({
+ x: 320,
+ y: 240,
+ width: info.frame.w,
+ height: info.frame.h,
+ })
+
+ const bounds = cursor.getRenderBounds(0)
+ expect(bounds).toEqual(info.bounds)
+ })
+
+ it('renders hand cursor frames onto Canvas 2D context', () => {
+ const cursor = new CursorController(CURSOR_METADATA, 0)
+ cursor.handleMouseMove(100, 200, 0)
+
+ const mockCtx = {
+ drawImage: vi.fn(),
+ } as unknown as CanvasRenderingContext2D
+
+ const mockAtlas = {} as CanvasImageSource
+
+ cursor.render(mockCtx, mockAtlas, 0)
+
+ expect(mockCtx.drawImage).toHaveBeenCalledTimes(1)
+ const frame0 = CURSOR_METADATA.protate[0]!
+ expect(mockCtx.drawImage).toHaveBeenCalledWith(
+ mockAtlas,
+ frame0.x,
+ frame0.y,
+ frame0.w,
+ frame0.h,
+ 100,
+ 200,
+ frame0.w,
+ frame0.h,
+ )
+ })
+
+ it('renders held item centered at cursor hotspot when cursorItem is present', () => {
+ const cursor = new CursorController(CURSOR_METADATA, 0)
+ cursor.handleMouseMove(400, 300, 0)
+
+ const mockCtx = {
+ drawImage: vi.fn(),
+ } as unknown as CanvasRenderingContext2D
+
+ const mockItemsAtlas = {} as CanvasImageSource
+ const mockCursorAtlas = {} as CanvasImageSource
+
+ // Held 2x3 broad sword (width 28, height 84)
+ cursor.render(mockCtx, mockCursorAtlas, 0, {
+ atlas: mockItemsAtlas,
+ rect: { x: 50, y: 100, w: 28, h: 84 },
+ })
+
+ // Held item should be drawn centered at mouseX=400, mouseY=300
+ expect(mockCtx.drawImage).toHaveBeenCalledTimes(1)
+ expect(mockCtx.drawImage).toHaveBeenCalledWith(
+ mockItemsAtlas,
+ 50,
+ 100,
+ 28,
+ 84,
+ 400 - 14, // 386
+ 300 - 42, // 258
+ 28,
+ 84,
+ )
+ })
+ })
+
+ describe('4. HudManager Integration', () => {
+ it('initializes HudManager with cursor: none and tracks cursor state in published state', () => {
+ const mockCanvas = {
+ style: {} as CSSStyleDeclaration,
+ width: 800,
+ height: 600,
+ getContext: vi.fn(() => ({
+ save: vi.fn(),
+ restore: vi.fn(),
+ clearRect: vi.fn(),
+ translate: vi.fn(),
+ scale: vi.fn(),
+ drawImage: vi.fn(),
+ })),
+ addEventListener: vi.fn(),
+ getBoundingClientRect: vi.fn(() => ({
+ left: 0,
+ top: 0,
+ width: 800,
+ height: 600,
+ })),
+ } as unknown as HTMLCanvasElement
+
+ const hud = new HudManager(mockCanvas, {
+ onToggleAutomap: vi.fn(),
+ onWaypointTeleport: vi.fn(),
+ })
+
+ // Canvas cursor style hidden
+ expect(mockCanvas.style.cursor).toBe('none')
+
+ // Initial cursor state
+ expect(hud.cursor).toBeInstanceOf(CursorController)
+ expect(hud.cursor.state).toBe('POINTING')
+ expect(hud.state.cursorState).toBe('POINTING')
+
+ // Moving mouse updates hud.cursor position and stays POINTING
+ hud.cursor.handleMouseMove(200, 150, 100)
+ hud.syncPublishedState()
+ expect(hud.cursor.mouseX).toBe(200)
+ expect(hud.cursor.mouseY).toBe(150)
+ expect(hud.state.cursorState).toBe('POINTING')
+
+ // Advancing past 2000ms idle updates hud.cursor and published state to OPENING
+ hud.cursor.update(2200)
+ hud.syncPublishedState()
+ expect(hud.cursor.state).toBe('OPENING')
+ expect(hud.state.cursorState).toBe('OPENING')
+
+ // Advancing past opening duration updates to OPEN_IDLE
+ hud.cursor.update(3500)
+ hud.syncPublishedState()
+ expect(hud.cursor.state).toBe('OPEN_IDLE')
+ expect(hud.state.cursorState).toBe('OPEN_IDLE')
+ })
+ })
+})