feat(ui): implement authentic D2 dynamic cursor and idle state machine (Fixes #148)

This commit is contained in:
troytt 2026-09-22 04:01:53 +00:00
parent 550787cb0a
commit 05f1ae0d18
10 changed files with 1363 additions and 21 deletions

View File

@ -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; }
</style>
</head>

BIN
public/ui/cursor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -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",

193
scripts/pack-cursor.ts Normal file
View File

@ -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<string, CursorFrameMeta>
}
export async function packCursorAssets(projectRoot: string = process.cwd()): Promise<CursorAtlasMetadata> {
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<string, readonly Dc6Frame[]> = {}
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<string, CursorFrameMeta[]> = {
protate: [],
ohand: [],
orotate: [],
}
const allFrames: Record<string, CursorFrameMeta> = {}
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<string, CursorFrameMeta>
}
export const CURSOR_METADATA: CursorAtlasMetadata = ${JSON.stringify(atlasMeta, null, 2)} as const
`
writeFileSync(join(projectRoot, 'src', 'ui', 'cursor-meta.ts'), metaCode, 'utf8')
console.log(`Extracted 24 authentic cursor frames -> public/ui/cursor.png (${atlasWidth}x${atlasHeight})`)
console.log('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)
})
}

View File

@ -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<void> {
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<void> {
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)!`)

View File

@ -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'

522
src/ui/cursor-meta.ts Normal file
View File

@ -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<string, CursorFrameMeta>
}
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

224
src/ui/cursor.ts Normal file
View File

@ -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,
)
}
}
}

View File

@ -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,
)
}
}

368
tests/cursor.test.ts Normal file
View File

@ -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')
})
})
})