From 05f1ae0d18f6e615cd2e35b814fd516243a8f7af Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Tue, 22 Sep 2026 04:01:53 +0000 Subject: [PATCH] feat(ui): implement authentic D2 dynamic cursor and idle state machine (Fixes #148) --- acts.html | 4 +- public/ui/cursor.png | Bin 0 -> 5633 bytes public/ui/manifest.json | 1 + scripts/pack-cursor.ts | 193 +++++++++++++++ scripts/pack-ui.ts | 8 + src/ui/baked-ui-meta.ts | 3 + src/ui/cursor-meta.ts | 522 ++++++++++++++++++++++++++++++++++++++++ src/ui/cursor.ts | 224 +++++++++++++++++ src/ui/hud-manager.ts | 61 +++-- tests/cursor.test.ts | 368 ++++++++++++++++++++++++++++ 10 files changed, 1363 insertions(+), 21 deletions(-) create mode 100644 public/ui/cursor.png create mode 100644 scripts/pack-cursor.ts create mode 100644 src/ui/cursor-meta.ts create mode 100644 src/ui/cursor.ts create mode 100644 tests/cursor.test.ts 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 0000000000000000000000000000000000000000..a1e86fca73becf50707ab977d3deb85ff29faa0e GIT binary patch literal 5633 zcmV+c7XImpP)wkN^OTND#aL0MLXW^u%n8d{C^MWDE=l6c7*`92_Vl z94ss>JUB2oI5&fI6kH?-R5%z^I3#Qs5NIqATtp;rBp7HsBz!CkWK1k@NCL^QNaAeeAi zpm;#MU>ux;NW@fB$ZR02d|0rENXUFt;BX+goK*ZQ0KklJ%$QK*gfQT6aL|}|_=r60 zpg7pLWZiroE&h3I7py0SddIayeNd2NMN{382kWq++2*%e4zY@3=Ak37)SsBSU40! z5F}_QJY*;+cuWw0I81~vFqBAOj5u7NP*AK)Y^+pxyd+@QWO&eIkl=uz+@v7kc4!sh;WdQkerl+fRwnPprEj*v!oQbD|6K1S<(kX##2%>+9t*G~jno8N zPUVl?_r_gGInIv<00Q6v2)Kb~nES5c&2T@9FS>^x#qS=Z@!jXsUp{^Q>-1g0alP#C z=UT3p_|G|fwN(4Z4R{5hQ0w1Coc9!~dxeAH9Toj~Eb?GWdHczyRj5T>UG>p)U53Y+Z!`?lU-zuPZ-<$GQG~fQJ+>t7HE#sLwO> zsWO!hT;oq|hqh59`~bR9!~Y}=wr$(!h^vL_{yW8?YfEYXs-YDE0q@&IsWcC-5!Mpk zXr&^qrTzntPczi=_$T^wt@__m{0e`hnU+TIL}5uP95(3`c3;s9ni#}Vz_)>>D7|TH zKoxG223tSu%M!}QIW(X}yfKV>)FAZd@r~Dw4}{;{ru4NVeKpYs68*FMAuE|$woVtQ z5Q6imk|8o)k{H1a;X$whvj|>!r9yDMe-?4T_)lj+#)Bdq3t{LpxYJG(?mKu);7ETJ zm_FwOBbwunG+?XzYtsbFmWWz?N|(UznJoqU-GZIlz8#0Jru{=_N+e5eBk88V3GPSwe2%_({@4WIyCD&i`S1Wrm6HjcZM#r@ zu(l6CzEn>M*xG$f4SW~^uc4Y~@iz2s@c}JOFye`k*sfWi_ilhu2h`LAcV?l2C;N>Y zPPdBfNMCPPi1C zv^z&!2Rn4E17(5_uQ1|mRPtng)?gmLkK>zUCvkfg5PLW$(KpK<9vme``vhIk0u8Nt zp7>ExahAO=4V}pnPrf=1rpd>VD{kSk0pW3c+Nyaz2|x6&R1o@~8W{GWr0jm$f1bWM z{_tW3K5CE#b}mDq(gmq#XuaQpj6*0%9RqmK4+&12fDa{FN6Y2P2cf?SP$Kmwbvy5W zqL0QmCXVlmhaabJhCjS$5f7Bc1ZTZS0NJ0X1A$`>%)Nlqk)3eT=HT#~vnfu)YxYaQ z<2UQ1{-)sm{t`dZ7qyJMx#v^Wqx)g{^8DeX1(^t)r~>&6)Q{`OWCp@%JEQ}P$3LAF z)tIgO{zp7~5!N7jS;FCb{MBhabv4ol<|(Bwq`cwUBMDw{UML9AQ}IS zzCw-kb&?iO`NOHHj9RLIJ!;VRAmed~gE#k(Hh@SXZyNMJ9ujW^5ho`>ek-W^3{L&o zbrUcfz?iM11hZL{rJ(+}~*F0#Ex{JTs6f(DD9~yfgYd(}!!g#-9==u$Irwv?B|b zN5)BkSeLYL?2m@XZQ$v9BES+sk3@VY}7Ej}cr$147nLa-ZN~AC4Po)I# zj_F1R>m>$grzZPkQ7W~SX7KUUVt=`T3jO*mP?%^fvPtb=EZ@VukPPBdN;uVNa(5V8IXY_g50518%^!WgXa-`IF;k}pK zo?Y1S2{ZU7u>r;t2Rw<-u+bC8HGG-F0}gpSvf$uFhV60~>4V`5T;op(V=PB2r28XC zuN$zpQPFJINi0T@l9zmX%D+X2FN6E~l6j{dJ&1jJnR&}57vNsR229^D($}_M#NHSD zp(H}Svd*m#i_Lq;;pBkuYnJ#GyQdlEuHhR$-(cM9Lyr6@f1&``jbx|Tc$lT&qjoQI zk%*4~e;1!^uo;h7FLMGq;}0bUW@orY!2k7kjlOIA{rrA@Kfj;fd;0LU+Rp~iZ_9QT zze78VfAaJAs^B|+e5cK|+dOY(P+0S=%IEP7ZTlAeHY0%bQtcj(ErnL=CF~Ak-=+^B zgx}!z=i@`OD*s5PUxwX?(e*Zo>yZFVZdAfveV- zmQKi9`QW))n0>q!@Kc=>`CO`Y4#y?#t3u()%f!9Fx?*~=1I6-^r|-&e0&`Q*Jai=M7krlRw;phznynx1Yy zs`7&s@tUTFV2WU^7jR?By*52R#tNZhP9CYv4(mag-~}F5Y5_al*uL>GA)}-;NC_G_Akj-!a>VHlW-}5p%EY%k*NXkAf!48s z@1zWiqT!b=5>V)z2=B2b@sfa29b2hoXnmt(0db@-zE6%%z+Nbcu^OvU=vW) z2OK0FK*U%z6^qFivXOwU<4GoT;2$S1a72RP>=BZH9wj`LfU4U_V5u=0 z!NqRLyF_(hpkNs%I96sCBM~LzoGCUHL8aQ*K-b)0INy%RI!kAEzcnBcd(sk8x3dPkK9%i90YyJ@A)qyC?IFH_t8x@jC+@{O3^Jj; zeVhp3EWA!a;DCbTbRZ$}%Hb?l5*IxF*(W%ekSZb$4!=t20iapQWB;#UC-|hnF+t!Y z;AkW)pz{oisDRKrpGiSkBPR^NC?Lmv_Ci3H86q(OAv$xKVIUzC_gOp&Nx+SX)QUL2 z=xZHKk!AFT%MC0o`TaS1%CK+~0bvYRU8JBKHE0zTZ?I#IPLhLz(^cZUT1^{{i%NxW z0rCr+b24Kd3_PS-FvkBUc`^qxIt0&;`2lC~_md382C0AsK3Hl@1*9e^Ch;9yxfteC zB)sAxhbPGo7{`dWS6B9+@6)JE47z}FzN_FOgW(*fZ5K?h@IKkknZq%9!lyeSfgmOT zjE~zvr70xb-5do}e1=O2aJFdN40=v68B<8!g7|3;4~l^)HO8%p2!Ag~h*XKUbj$bO zClTjWi1;M6_=NKWPh}%zK)f|@%3>~q0>ND#6viB$5Dqy!C`R6*74X-& zZ3Z8MYPR1>aT%I_%nk6v(<}~2bnU!>B{BaY2#Dt{6tU+*MnL2#rYR<80;28gn8V|Q z*dXa;i*}&HVYioFQD`S@y>o=ywwG`cG6%P5{BA#pc;Ng1YB21q6gead<>KQUncY63a(Eald{DN|BKX5kAKMyz2>v zL5hK`$#|^1n@K@s7BK~X@bCjWr~_`3r7`ThLf)raG& z(ep~pMDf?~WoC$54|-5D@p)gGKQ{T{H+-hW=ehCgR*I~jy^GG*2$bO#8TuA7eOLIywFy=&-F~5Wjg!)W?ilyGrP?*Epk!OO*r}w3 z#;T}Cd_h;BAs9gG!QP< z(6F!ShVdZ(%4h3e!_4?^^tn1j40=}24h+v_| zAA88L+1Uh!AHbDjT!`5-d$EA2KyGX8A+Ta>7Fen6drPCrxf&I#nEZ2$zo#`cHlB;X^fp&RW5uR(!qXZW>>f%L#}{j8tYMzMEBxVMievm- zsG+fnOY6>LIH^(bmY^Wk&{)ME^~WXT1O_N3H8jrR_lQyrjmh}a8X8QWxme?rzC3>y zDKfbl8fWo!xkklVf2oGXd3;`~p>c_;w1&oc_+O}@@mJ`Z@`uwFat)0O3WOROXLSU` z8XD*QjZSN5Oyl=S4UN^s_(=_oiv$UwhDM%06oe$z(AfAl0^}MRVuHe3jf=DK#Tpt@ zJgK2^IsUYU#<_sR8X7tNa2hk2siCpryqW*UH8fUdRwmZakoXf*WTLN#na6s1Eau=e z6A&{Vy+a{GBu2*-8cwnC;X%eZJ}+l@oa4d4Lw0 +} + +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') + }) + }) +})