diff --git a/public/ui/frontend/manifest.json b/public/ui/frontend/manifest.json index c6b457b..c31669b 100644 --- a/public/ui/frontend/manifest.json +++ b/public/ui/frontend/manifest.json @@ -7,6 +7,7 @@ "width": 2028, "height": 614, "frameCount": 30, + "rate": 1, "frames": [ { "x": 0, @@ -255,6 +256,7 @@ "width": 1791, "height": 974, "frameCount": 30, + "rate": 1, "frames": [ { "x": 0, @@ -503,6 +505,7 @@ "width": 1962, "height": 83, "frameCount": 30, + "rate": 1, "frames": [ { "x": 0, @@ -751,6 +754,7 @@ "width": 2023, "height": 115, "frameCount": 30, + "rate": 1, "frames": [ { "x": 0, @@ -999,6 +1003,7 @@ "width": 1998, "height": 309, "frameCount": 30, + "rate": 1, "frames": [ { "x": 0, @@ -1247,6 +1252,7 @@ "width": 2313, "height": 513, "frameCount": 10, + "rate": 3, "frames": [ { "x": 0, @@ -1338,6 +1344,7 @@ "width": 2010, "height": 397, "frameCount": 26, + "rate": 3, "frames": [ { "x": 0, @@ -1554,6 +1561,7 @@ "width": 2010, "height": 397, "frameCount": 26, + "rate": 3, "frames": [ { "x": 0, @@ -1770,6 +1778,7 @@ "width": 1967, "height": 469, "frameCount": 18, + "rate": 2, "frames": [ { "x": 0, @@ -1922,6 +1931,7 @@ "width": 2043, "height": 1251, "frameCount": 54, + "rate": 1, "frames": [ { "x": 0, @@ -2362,6 +2372,7 @@ "width": 2005, "height": 433, "frameCount": 30, + "rate": 1, "frames": [ { "x": 0, @@ -2612,6 +2623,7 @@ "width": 1601, "height": 189, "frameCount": 31, + "rate": 3, "frames": [ { "x": 0, @@ -2868,6 +2880,7 @@ "width": 1601, "height": 189, "frameCount": 31, + "rate": 3, "frames": [ { "x": 0, @@ -3124,6 +3137,7 @@ "width": 2001, "height": 375, "frameCount": 31, + "rate": 2, "frames": [ { "x": 0, @@ -3380,6 +3394,7 @@ "width": 2034, "height": 805, "frameCount": 91, + "rate": 1, "frames": [ { "x": 0, @@ -4116,6 +4131,7 @@ "width": 1983, "height": 382, "frameCount": 51, + "rate": 1, "frames": [ { "x": 0, @@ -4534,6 +4550,7 @@ "width": 708, "height": 183, "frameCount": 12, + "rate": 3, "frames": [ { "x": 0, @@ -4638,6 +4655,7 @@ "width": 708, "height": 183, "frameCount": 12, + "rate": 3, "frames": [ { "x": 0, @@ -4742,6 +4760,7 @@ "width": 1566, "height": 233, "frameCount": 12, + "rate": 2, "frames": [ { "x": 0, @@ -4846,6 +4865,7 @@ "width": 1947, "height": 485, "frameCount": 12, + "rate": 2, "frames": [ { "x": 0, @@ -4950,6 +4970,7 @@ "width": 1998, "height": 676, "frameCount": 38, + "rate": 1, "frames": [ { "x": 0, @@ -5262,6 +5283,7 @@ "width": 1919, "height": 482, "frameCount": 38, + "rate": 1, "frames": [ { "x": 0, @@ -5574,6 +5596,7 @@ "width": 1951, "height": 448, "frameCount": 28, + "rate": 1, "frames": [ { "x": 0, @@ -5806,6 +5829,7 @@ "width": 2002, "height": 472, "frameCount": 28, + "rate": 1, "frames": [ { "x": 0, @@ -6040,6 +6064,7 @@ "width": 1403, "height": 183, "frameCount": 16, + "rate": 2, "frames": [ { "x": 0, @@ -6176,6 +6201,7 @@ "width": 1403, "height": 183, "frameCount": 16, + "rate": 2, "frames": [ { "x": 0, @@ -6312,6 +6338,7 @@ "width": 1989, "height": 404, "frameCount": 26, + "rate": 1, "frames": [ { "x": 0, @@ -6528,6 +6555,7 @@ "width": 2033, "height": 1122, "frameCount": 64, + "rate": 1, "frames": [ { "x": 0, @@ -7048,6 +7076,7 @@ "width": 1813, "height": 201, "frameCount": 19, + "rate": 1, "frames": [ { "x": 0, @@ -7210,6 +7239,7 @@ "width": 2009, "height": 359, "frameCount": 26, + "rate": 2, "frames": [ { "x": 0, @@ -7426,6 +7456,7 @@ "width": 2009, "height": 359, "frameCount": 26, + "rate": 2, "frames": [ { "x": 0, @@ -7642,6 +7673,7 @@ "width": 1190, "height": 173, "frameCount": 9, + "rate": 2, "frames": [ { "x": 0, @@ -7722,6 +7754,7 @@ "width": 2014, "height": 1161, "frameCount": 80, + "rate": 1, "frames": [ { "x": 0, @@ -8370,6 +8403,7 @@ "width": 2045, "height": 597, "frameCount": 40, + "rate": 1, "frames": [ { "x": 0, @@ -8700,6 +8734,7 @@ "width": 2035, "height": 333, "frameCount": 32, + "rate": 2, "frames": [ { "x": 0, @@ -8964,6 +8999,7 @@ "width": 2035, "height": 333, "frameCount": 32, + "rate": 2, "frames": [ { "x": 0, @@ -9228,6 +9264,7 @@ "width": 1306, "height": 209, "frameCount": 12, + "rate": 1, "frames": [ { "x": 0, @@ -9332,6 +9369,7 @@ "width": 1263, "height": 77, "frameCount": 12, + "rate": 1, "frames": [ { "x": 0, @@ -9436,6 +9474,7 @@ "width": 2000, "height": 606, "frameCount": 52, + "rate": 1, "frames": [ { "x": 0, @@ -9860,6 +9899,7 @@ "width": 1299, "height": 68, "frameCount": 52, + "rate": 1, "frames": [ { "x": 0, @@ -10284,6 +10324,7 @@ "width": 2013, "height": 388, "frameCount": 30, + "rate": 1, "frames": [ { "x": 0, @@ -10532,6 +10573,7 @@ "width": 991, "height": 73, "frameCount": 30, + "rate": 1, "frames": [ { "x": 0, @@ -10782,6 +10824,7 @@ "width": 1838, "height": 199, "frameCount": 21, + "rate": 2, "frames": [ { "x": 0, @@ -10958,6 +11001,7 @@ "width": 1838, "height": 199, "frameCount": 21, + "rate": 2, "frames": [ { "x": 0, @@ -11134,6 +11178,7 @@ "width": 2034, "height": 483, "frameCount": 21, + "rate": 2, "frames": [ { "x": 0, @@ -11310,6 +11355,7 @@ "width": 2023, "height": 1666, "frameCount": 121, + "rate": 1, "frames": [ { "x": 0, @@ -12286,6 +12332,7 @@ "width": 2045, "height": 460, "frameCount": 40, + "rate": 1, "frames": [ { "x": 0, @@ -12617,6 +12664,7 @@ "width": 464, "height": 75, "frameCount": 16, + "animRate": 80, "frames": [ { "x": 0, @@ -12753,6 +12801,7 @@ "width": 276, "height": 76, "frameCount": 12, + "animRate": 64, "frames": [ { "x": 0, @@ -12857,6 +12906,7 @@ "width": 448, "height": 72, "frameCount": 16, + "animRate": 80, "frames": [ { "x": 0, @@ -12993,6 +13043,7 @@ "width": 768, "height": 78, "frameCount": 16, + "animRate": 80, "frames": [ { "x": 0, @@ -13129,6 +13180,7 @@ "width": 528, "height": 72, "frameCount": 16, + "animRate": 128, "frames": [ { "x": 0, @@ -13265,6 +13317,7 @@ "width": 512, "height": 68, "frameCount": 16, + "animRate": 80, "frames": [ { "x": 0, @@ -13401,6 +13454,7 @@ "width": 340, "height": 75, "frameCount": 10, + "animRate": 96, "frames": [ { "x": 0, diff --git a/src/baker/packers/pack-frontend-ui.ts b/src/baker/packers/pack-frontend-ui.ts index 02fe5f4..8e75cd7 100644 --- a/src/baker/packers/pack-frontend-ui.ts +++ b/src/baker/packers/pack-frontend-ui.ts @@ -12,7 +12,7 @@ * and English TBL strings into `public/ui/frontend/`. */ -import { mkdirSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { MpqArchive } from '../../common/mpq/archive.ts' import { fileSource } from '../io/file-source.ts' @@ -45,6 +45,8 @@ export interface PackedAnimStripMeta { readonly width: number readonly height: number readonly frameCount: number + readonly rate?: number + readonly animRate?: number readonly frames: readonly PackedStripFrameMeta[] } @@ -228,6 +230,10 @@ function packFramesToGrid( file: string, frames: readonly SpriteFrame[], maxRowWidth = 2048, + options?: { + readonly rate?: number + readonly animRate?: number + }, ): { meta: PackedAnimStripMeta pixels: Uint8Array @@ -267,6 +273,8 @@ function packFramesToGrid( width: maxW, height: totalH, frameCount: frames.length, + ...(options?.rate !== undefined ? { rate: options.rate } : {}), + ...(options?.animRate !== undefined ? { animRate: options.animRate } : {}), frames: frameMetas, }, pixels, @@ -334,12 +342,21 @@ function buildFontAtlas( } } +function resolveDefaultArchiveDir(argDir?: string): string { + if (argDir && existsSync(argDir)) return argDir + if (process.env.D2_DATA_DIR && existsSync(process.env.D2_DATA_DIR)) return process.env.D2_DATA_DIR + if (existsSync('samples/d2')) return 'samples/d2' + if (existsSync('/usr/local/google/home/taodao/d2-data')) return '/usr/local/google/home/taodao/d2-data' + return argDir ?? 'samples/d2' +} + async function main(): Promise { const t0 = performance.now() - console.log('Mounting Diablo II v1.13c MPQ archives...') + const archiveDir = resolveDefaultArchiveDir(process.argv[2]) + console.log(`Mounting Diablo II v1.13c MPQ archives from ${archiveDir}...`) const archives = new MountedArchives() for (const m of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { - archives.add(m, await MpqArchive.open(await fileSource(`samples/d2/${m}`))) + archives.add(m, await MpqArchive.open(await fileSource(join(archiveDir, m)))) } const skyPl2 = decodePl2(await archives.read('data\\global\\palette\\sky\\pal.pl2')) @@ -553,7 +570,7 @@ async function main(): Promise { logoBlackLDc6.groups[0]!.frames, logoBlackRDc6.groups[0]!.frames, ) - const packedBlack = packFramesToGrid('logo-main-black.png', blackFrames, 2048) + const packedBlack = packFramesToGrid('logo-main-black.png', blackFrames, 2048, { rate: 1 }) savePng(packedBlack.meta.file, packedBlack.meta.width, packedBlack.meta.height, packedBlack.pixels, skyPl2.rgb, true) animStrips['logoMainBlack'] = packedBlack.meta @@ -561,7 +578,7 @@ async function main(): Promise { logoFireLDc6.groups[0]!.frames, logoFireRDc6.groups[0]!.frames, ) - const packedFire = packFramesToGrid('logo-main-fire.png', fireFrames, 2048) + const packedFire = packFramesToGrid('logo-main-fire.png', fireFrames, 2048, { rate: 1 }) savePng(packedFire.meta.file, packedFire.meta.width, packedFire.meta.height, packedFire.pixels, skyPl2.rgb, true) animStrips['logoMainFire'] = packedFire.meta } @@ -570,11 +587,11 @@ async function main(): Promise { const bnetBlackDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\D2logoBlackBnet.dc6') const bnetFireDc6 = await decodeUiDc6('data\\global\\ui\\BIGMENU\\D2logoBnet.dc6') { - const packedBlack = packFramesToGrid('logo-bnet-black.png', bnetBlackDc6.groups[0]!.frames, 2048) + const packedBlack = packFramesToGrid('logo-bnet-black.png', bnetBlackDc6.groups[0]!.frames, 2048, { rate: 1 }) savePng(packedBlack.meta.file, packedBlack.meta.width, packedBlack.meta.height, packedBlack.pixels, skyPl2.rgb, true) animStrips['logoBnetBlack'] = packedBlack.meta - const packedFire = packFramesToGrid('logo-bnet-fire.png', bnetFireDc6.groups[0]!.frames, 2048) + const packedFire = packFramesToGrid('logo-bnet-fire.png', bnetFireDc6.groups[0]!.frames, 2048, { rate: 1 }) savePng(packedFire.meta.file, packedFire.meta.width, packedFire.meta.height, packedFire.pixels, skyPl2.rgb, true) animStrips['logoBnetFire'] = packedFire.meta } @@ -582,7 +599,7 @@ async function main(): Promise { // Campfire `fire.dc6` at control anchor (345, 454) on charactercreationscreenEXP.dc6 const fireDc6 = await decodeUiDc6('data\\global\\ui\\FrontEnd\\fire.dc6') { - const packed = packFramesToGrid('campfire/fire-strip.png', fireDc6.groups[0]!.frames, 2048) + const packed = packFramesToGrid('campfire/fire-strip.png', fireDc6.groups[0]!.frames, 2048, { rate: 1 }) savePng(packed.meta.file, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true) animStrips['campfireFlame'] = packed.meta } @@ -591,7 +608,7 @@ async function main(): Promise { // 5. 7-Class Campfire Multi-State Animations (fechar/pal.pl2) // ============================================================================ console.log('Packing 7-class campfire multi-state animations...') - // Per D2Launch.dll 0x6fa68df8 & 0x6fa5d780: + // Per D2Launch.dll 0x6fa68df8, 0x6fa60c08 & 0x6fa5d780: // Note: ptAnimTable != NULL does NOT subtract frame0.offset! interface ClassCampfireSpec { readonly id: string @@ -600,6 +617,7 @@ async function main(): Promise { readonly states: readonly { readonly key: string readonly base: string + readonly rate: number readonly overlay?: string readonly additiveOverlay?: boolean }[] @@ -611,11 +629,11 @@ async function main(): Promise { dir: 'amazon', prefix: 'am', states: [ - { key: 'nu1', base: 'amnu1.dc6' }, - { key: 'nu2', base: 'amnu2.dc6' }, - { key: 'nu3', base: 'amnu3.dc6' }, - { key: 'fw', base: 'amfw.dc6', overlay: 'amfws.dc6', additiveOverlay: false }, - { key: 'bw', base: 'ambw.dc6' }, + { key: 'nu1', base: 'amnu1.dc6', rate: 3 }, + { key: 'nu2', base: 'amnu2.dc6', rate: 3 }, + { key: 'nu3', base: 'amnu3.dc6', rate: 2 }, + { key: 'fw', base: 'amfw.dc6', rate: 1, overlay: 'amfws.dc6', additiveOverlay: false }, + { key: 'bw', base: 'ambw.dc6', rate: 1 }, ], }, { @@ -623,11 +641,11 @@ async function main(): Promise { dir: 'assassin', prefix: 'as', states: [ - { key: 'nu1', base: 'asnu1.dc6' }, - { key: 'nu2', base: 'asnu2.dc6' }, - { key: 'nu3', base: 'asnu3.dc6' }, - { key: 'fw', base: 'asfw.dc6' }, - { key: 'bw', base: 'asbw.dc6' }, + { key: 'nu1', base: 'asnu1.dc6', rate: 3 }, + { key: 'nu2', base: 'asnu2.dc6', rate: 3 }, + { key: 'nu3', base: 'asnu3.dc6', rate: 2 }, + { key: 'fw', base: 'asfw.dc6', rate: 1 }, + { key: 'bw', base: 'asbw.dc6', rate: 1 }, ], }, { @@ -635,11 +653,11 @@ async function main(): Promise { dir: 'necromancer', prefix: 'ne', states: [ - { key: 'nu1', base: 'nenu1.dc6' }, - { key: 'nu2', base: 'nenu2.dc6' }, - { key: 'nu3', base: 'nenu3.dc6', overlay: 'nenu3s.dc6', additiveOverlay: true }, - { key: 'fw', base: 'nefw.dc6', overlay: 'nefws.dc6', additiveOverlay: true }, - { key: 'bw', base: 'nebw.dc6', overlay: 'nebws.dc6', additiveOverlay: true }, + { key: 'nu1', base: 'nenu1.dc6', rate: 3 }, + { key: 'nu2', base: 'nenu2.dc6', rate: 3 }, + { key: 'nu3', base: 'nenu3.dc6', rate: 2, overlay: 'nenu3s.dc6', additiveOverlay: true }, + { key: 'fw', base: 'nefw.dc6', rate: 1, overlay: 'nefws.dc6', additiveOverlay: true }, + { key: 'bw', base: 'nebw.dc6', rate: 1, overlay: 'nebws.dc6', additiveOverlay: true }, ], }, { @@ -647,11 +665,11 @@ async function main(): Promise { dir: 'barbarian', prefix: 'ba', states: [ - { key: 'nu1', base: 'banu1.dc6' }, - { key: 'nu2', base: 'banu2.dc6' }, - { key: 'nu3', base: 'banu3.dc6' }, - { key: 'fw', base: 'bafw.dc6', overlay: 'bafws.dc6', additiveOverlay: false }, - { key: 'bw', base: 'babw.dc6' }, + { key: 'nu1', base: 'banu1.dc6', rate: 2 }, + { key: 'nu2', base: 'banu2.dc6', rate: 2 }, + { key: 'nu3', base: 'banu3.dc6', rate: 1 }, + { key: 'fw', base: 'bafw.dc6', rate: 1, overlay: 'bafws.dc6', additiveOverlay: false }, + { key: 'bw', base: 'babw.dc6', rate: 1 }, ], }, { @@ -659,11 +677,11 @@ async function main(): Promise { dir: 'paladin', prefix: 'pa', states: [ - { key: 'nu1', base: 'panu1.dc6' }, - { key: 'nu2', base: 'panu2.dc6' }, - { key: 'nu3', base: 'panu3.dc6' }, - { key: 'fw', base: 'pafw.dc6', overlay: 'pafws.dc6', additiveOverlay: false }, - { key: 'bw', base: 'pabw.dc6' }, + { key: 'nu1', base: 'panu1.dc6', rate: 2 }, + { key: 'nu2', base: 'panu2.dc6', rate: 2 }, + { key: 'nu3', base: 'panu3.dc6', rate: 2 }, + { key: 'fw', base: 'pafw.dc6', rate: 1, overlay: 'pafws.dc6', additiveOverlay: false }, + { key: 'bw', base: 'pabw.dc6', rate: 1 }, ], }, { @@ -671,11 +689,11 @@ async function main(): Promise { dir: 'sorceress', prefix: 'so', states: [ - { key: 'nu1', base: 'sonu1.dc6' }, - { key: 'nu2', base: 'sonu2.dc6' }, - { key: 'nu3', base: 'sonu3.dc6', overlay: 'sonu3s.dc6', additiveOverlay: true }, - { key: 'fw', base: 'sofw.dc6', overlay: 'sofws.dc6', additiveOverlay: true }, - { key: 'bw', base: 'sobw.dc6', overlay: 'sobws.dc6', additiveOverlay: true }, + { key: 'nu1', base: 'sonu1.dc6', rate: 2 }, + { key: 'nu2', base: 'sonu2.dc6', rate: 2 }, + { key: 'nu3', base: 'sonu3.dc6', rate: 1, overlay: 'sonu3s.dc6', additiveOverlay: true }, + { key: 'fw', base: 'sofw.dc6', rate: 1, overlay: 'sofws.dc6', additiveOverlay: true }, + { key: 'bw', base: 'sobw.dc6', rate: 1, overlay: 'sobws.dc6', additiveOverlay: true }, ], }, { @@ -683,11 +701,11 @@ async function main(): Promise { dir: 'druid', prefix: 'dz', states: [ - { key: 'nu1', base: 'dznu1.dc6' }, - { key: 'nu2', base: 'dznu2.dc6' }, - { key: 'nu3', base: 'dznu3.dc6' }, - { key: 'fw', base: 'dzfw.dc6' }, - { key: 'bw', base: 'dzbw.dc6' }, + { key: 'nu1', base: 'dznu1.dc6', rate: 2 }, + { key: 'nu2', base: 'dznu2.dc6', rate: 2 }, + { key: 'nu3', base: 'dznu3.dc6', rate: 2 }, + { key: 'fw', base: 'dzfw.dc6', rate: 1 }, + { key: 'bw', base: 'dzbw.dc6', rate: 1 }, ], }, ] @@ -705,18 +723,18 @@ async function main(): Promise { if (ovDc6 && !st.additiveOverlay) { const composited = compositeOverlayFrames(baseDc6.groups[0]!.frames, ovDc6.groups[0]!.frames) const relFile = `campfire/${cls.id}-${st.key}.png` - const packed = packFramesToGrid(relFile, composited, 2048) + const packed = packFramesToGrid(relFile, composited, 2048, { rate: st.rate }) savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true) campfireClasses[cls.id]![st.key] = packed.meta } else { const relFile = `campfire/${cls.id}-${st.key}.png` - const packed = packFramesToGrid(relFile, baseDc6.groups[0]!.frames, 2048) + const packed = packFramesToGrid(relFile, baseDc6.groups[0]!.frames, 2048, { rate: st.rate }) savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, fecharPl2.rgb, true) campfireClasses[cls.id]![st.key] = packed.meta if (ovDc6 && st.additiveOverlay) { const ovRelFile = `campfire/${cls.id}-${st.key}-ov.png` - const ovPacked = packFramesToGrid(ovRelFile, ovDc6.groups[0]!.frames, 2048) + const ovPacked = packFramesToGrid(ovRelFile, ovDc6.groups[0]!.frames, 2048, { rate: st.rate }) savePng(ovRelFile, ovPacked.meta.width, ovPacked.meta.height, ovPacked.pixels, fecharPl2.rgb, true) campfireClasses[cls.id]![`${st.key}_ov`] = ovPacked.meta } @@ -729,7 +747,7 @@ async function main(): Promise { // ============================================================================ console.log('Packing 10-frame Door Opening Loading Screen...') const loadingDc6 = await decodeUiDc6('data\\global\\ui\\Loading\\Loadingscreen.dc6') - const loadingPacked = packFramesToGrid('loading-door-strip.png', loadingDc6.groups[0]!.frames, 2560) + const loadingPacked = packFramesToGrid('loading-door-strip.png', loadingDc6.groups[0]!.frames, 2560, { rate: 3 }) savePng( loadingPacked.meta.file, loadingPacked.meta.width, @@ -751,7 +769,7 @@ async function main(): Promise { // Group 0 in loadCharacterSheet is direction 0 (dir64=0 -> dccDir=4 = straight South towards camera) const southFrames = tnSheet.sheet.groups[0]!.frames const relFile = `avatars/char-${token}-tn.png` - const packed = packFramesToGrid(relFile, southFrames, 2048) + const packed = packFramesToGrid(relFile, southFrames, 2048, { animRate: tnSheet.speed ?? 256 }) savePng(relFile, packed.meta.width, packed.meta.height, packed.pixels, skyPl2.rgb, true) charAvatars[token] = packed.meta } @@ -936,9 +954,10 @@ async function main(): Promise { writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2)) const assetsFrontendDir = join(process.cwd(), 'public', 'assets', 'ui', 'frontend') - const { cpSync } = await import('node:fs') - mkdirSync(assetsFrontendDir, { recursive: true }) - cpSync(outDir, assetsFrontendDir, { recursive: true, force: true }) + if (existsSync(assetsFrontendDir)) { + const { cpSync } = await import('node:fs') + cpSync(outDir, assetsFrontendDir, { recursive: true, force: true }) + } console.log( `Successfully packed ${decodedCount} DC6 sheets + 7 TN character avatars + ${FONT_NAMES.length * FONT_COLORS.length} colored font atlases in ${(performance.now() - t0).toFixed(0)} ms!`, ) diff --git a/src/client/frontend/flow-state.ts b/src/client/frontend/flow-state.ts index 9b57008..b696dd6 100644 --- a/src/client/frontend/flow-state.ts +++ b/src/client/frontend/flow-state.ts @@ -31,6 +31,15 @@ export type CampfireAnimPhase = 'nu1' | 'nu2' | 'fw' | 'nu3' | 'bw' export type GameDifficulty = 'normal' | 'nightmare' | 'hell' +/** Fixed 25fps front-end tick budget (40ms per tick, `D2Win.dll` @ `0x6f8f8980`). */ +export const FRONTEND_TICK_MS = 40 + +/** Maximum catch-up ticks allowed in a single frame step to prevent spiral-of-death bursts. */ +export const FRONTEND_MAX_CATCHUP_TICKS = 5 + +/** Maximum frame delta (in milliseconds) accumulated in a single frame step (`5 * 40ms = 200ms`). */ +export const FRONTEND_MAX_FRAME_DELTA_MS = FRONTEND_TICK_MS * FRONTEND_MAX_CATCHUP_TICKS + export interface CampfireClassSlotState { readonly id: CampfireClassId readonly heroToken: HeroToken @@ -43,7 +52,22 @@ export interface CampfireClassSlotState { readonly hitBox: { readonly x: number; readonly y: number; readonly w: number; readonly h: number } phase: CampfireAnimPhase frameIndex: number + /** Ticks elapsed in the current animation frame (`0 .. rate - 1`). */ + phaseTicks: number frameCount: Record + /** Per-phase tick multiplier (`durMs = rate * 40ms`, `D2Launch.dll` @ `0x6fa60c08`). */ + frameRate: Record +} + +export interface HeroAvatarAnimState { + readonly heroToken: HeroToken + frameCount: number + /** 8.8 fixed-point COF animation speed (`D2Win.dll` @ `0x6f8ec48b`). */ + animRate: number + /** 8.8 fixed-point accumulator (`acc += animRate` once per 40ms tick, `D2Win.dll` @ `0x6f8ee836`). */ + acc: number + /** Current frame index (`acc >> 8`, wraps when `frame >= framesPerDir - 1`). */ + frameIndex: number } export interface RosterCharacter { @@ -90,6 +114,10 @@ export interface BnetFlowState { stageTicks: number /** Global animation tick counter (25fps). */ globalTick: number + /** Fractional millisecond accumulator for fixed 25fps (40ms/tick) stepping. */ + tickAccumulatorMs: number + /** Last `requestAnimationFrame` timestamp (ms) observed by `stepFlowFrame`, or `null` initially. */ + lastFrameTimeMs: number | null /** Whether live D2OnlineFlow is active (falls back to mock when false). */ onlineConnected?: boolean | undefined @@ -108,8 +136,9 @@ export interface BnetFlowState { isLadder: boolean charCreateError: string | null - // Stage 4: 8-Slot Character Selection + // Stage 4: 8-Slot Character Selection & Lobby TN Avatar 8.8 Fixed-Point Accumulators roster: RosterCharacter[] + avatarAnims: Record selectedSlotIndex: number deleteConfirmOpen: boolean @@ -156,6 +185,18 @@ export const CAMPFIRE_CLASS_ORDER: readonly CampfireClassId[] = [ 'dz', ] as const +export const HERO_TOKENS: readonly HeroToken[] = [ + 'am', + 'ai', + 'ne', + 'ba', + 'pa', + 'so', + 'dz', +] as const + +export const HERO_TOKEN_ORDER: readonly HeroToken[] = HERO_TOKENS + export const CAMPFIRE_CLASS_SPECS: Readonly< Record< CampfireClassId, @@ -167,6 +208,8 @@ export const CAMPFIRE_CLASS_SPECS: Readonly< readonly anchorY: number readonly hitBox: { readonly x: number; readonly y: number; readonly w: number; readonly h: number } readonly defaultFrames: Record + /** 1.13c `D2Launch.dll` `@ 0x6fa60c08` per-mode tick multiplier (`durMs = rate * 40ms`). */ + readonly defaultRates: Record } > > = { @@ -177,7 +220,8 @@ export const CAMPFIRE_CLASS_SPECS: Readonly< anchorX: 100, anchorY: 337, hitBox: { x: 56, y: 153, w: 88, h: 184 }, - defaultFrames: { nu1: 35, nu2: 24, fw: 51, nu3: 10, bw: 32 }, + defaultFrames: { nu1: 26, nu2: 56, fw: 28, nu3: 12, bw: 22 }, + defaultRates: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 }, }, as: { id: 'as', @@ -186,7 +230,8 @@ export const CAMPFIRE_CLASS_SPECS: Readonly< anchorX: 232, anchorY: 364, hitBox: { x: 188, y: 180, w: 88, h: 184 }, - defaultFrames: { nu1: 42, nu2: 8, fw: 38, nu3: 16, bw: 29 }, + defaultFrames: { nu1: 30, nu2: 45, fw: 18, nu3: 16, bw: 17 }, + defaultRates: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 }, }, ne: { id: 'ne', @@ -195,7 +240,8 @@ export const CAMPFIRE_CLASS_SPECS: Readonly< anchorX: 301, anchorY: 333, hitBox: { x: 257, y: 149, w: 88, h: 184 }, - defaultFrames: { nu1: 12, nu2: 8, fw: 52, nu3: 16, bw: 28 }, + defaultFrames: { nu1: 12, nu2: 39, fw: 26, nu3: 16, bw: 15 }, + defaultRates: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 }, }, ba: { id: 'ba', @@ -204,7 +250,8 @@ export const CAMPFIRE_CLASS_SPECS: Readonly< anchorX: 400, anchorY: 330, hitBox: { x: 356, y: 146, w: 88, h: 184 }, - defaultFrames: { nu1: 38, nu2: 8, fw: 63, nu3: 16, bw: 32 }, + defaultFrames: { nu1: 16, nu2: 51, fw: 32, nu3: 16, bw: 17 }, + defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 }, }, pa: { id: 'pa', @@ -213,7 +260,8 @@ export const CAMPFIRE_CLASS_SPECS: Readonly< anchorX: 521, anchorY: 339, hitBox: { x: 477, y: 155, w: 88, h: 184 }, - defaultFrames: { nu1: 37, nu2: 8, fw: 61, nu3: 24, bw: 28 }, + defaultFrames: { nu1: 19, nu2: 65, fw: 28, nu3: 13, bw: 15 }, + defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 }, }, so: { id: 'so', @@ -222,7 +270,8 @@ export const CAMPFIRE_CLASS_SPECS: Readonly< anchorX: 626, anchorY: 353, hitBox: { x: 582, y: 169, w: 88, h: 184 }, - defaultFrames: { nu1: 30, nu2: 8, fw: 50, nu3: 26, bw: 28 }, + defaultFrames: { nu1: 18, nu2: 52, fw: 28, nu3: 16, bw: 16 }, + defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 }, }, dz: { id: 'dz', @@ -231,10 +280,69 @@ export const CAMPFIRE_CLASS_SPECS: Readonly< anchorX: 720, anchorY: 370, hitBox: { x: 676, y: 186, w: 88, h: 184 }, - defaultFrames: { nu1: 30, nu2: 18, fw: 49, nu3: 16, bw: 33 }, + defaultFrames: { nu1: 26, nu2: 53, fw: 25, nu3: 16, bw: 17 }, + defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 }, }, } +/** + * 1.13c `D2Launch.dll` `@ 0x6fa60c08` campfire per-class, per-phase rate table (`durMs = rate * 40ms`). + */ +export const CAMPFIRE_ANIM_RATES: Readonly< + Record>> +> = { + am: CAMPFIRE_CLASS_SPECS.am.defaultRates, + as: CAMPFIRE_CLASS_SPECS.as.defaultRates, + ne: CAMPFIRE_CLASS_SPECS.ne.defaultRates, + ba: CAMPFIRE_CLASS_SPECS.ba.defaultRates, + pa: CAMPFIRE_CLASS_SPECS.pa.defaultRates, + so: CAMPFIRE_CLASS_SPECS.so.defaultRates, + dz: CAMPFIRE_CLASS_SPECS.dz.defaultRates, +} + +/** + * 1.13c Town Neutral (`TN` `HTH`) COF `frameCount` (`framesPerDir`) and 8.8 fixed-point `animRate` values + * (`D2Win.dll` `@ 0x6f8ec48b`, `0x6f8ee836`): + * - `am`, `so`, `ne`, `ba`: `80` (16 frames, ~7.8125 fps) + * - `pa`: `128` (16 frames, 12.5 fps) + * - `dz`: `96` (10 frames, 9.375 fps) + * - `ai` (`as`): `64` (12 frames, 6.25 fps) + */ +export const HERO_TN_ANIM_SPECS: Readonly< + Record +> = { + am: { frameCount: 16, animRate: 80 }, + ai: { frameCount: 12, animRate: 64 }, + ne: { frameCount: 16, animRate: 80 }, + ba: { frameCount: 16, animRate: 80 }, + pa: { frameCount: 16, animRate: 128 }, + so: { frameCount: 16, animRate: 80 }, + dz: { frameCount: 10, animRate: 96 }, +} + +export function createInitialHeroAvatarAnims(): Record { + const avatarAnims = {} as Record + for (const token of HERO_TOKENS) { + const spec = HERO_TN_ANIM_SPECS[token] + avatarAnims[token] = { + heroToken: token, + frameCount: spec.frameCount, + animRate: spec.animRate, + acc: 0, + frameIndex: 0, + } + } + return avatarAnims +} + +export function resetHeroAvatarAnims(state: BnetFlowState): void { + for (const token of HERO_TOKENS) { + const anim = state.avatarAnims[token] + anim.acc = 0 + anim.frameIndex = 0 + } +} + export function createInitialFlowState(initialRoster: RosterCharacter[] = []): BnetFlowState { const campfireClasses = {} as Record for (const id of CAMPFIRE_CLASS_ORDER) { @@ -248,7 +356,9 @@ export function createInitialFlowState(initialRoster: RosterCharacter[] = []): B hitBox: spec.hitBox, phase: 'nu1', frameIndex: 0, + phaseTicks: 0, frameCount: { ...spec.defaultFrames }, + frameRate: { ...spec.defaultRates }, } } @@ -257,6 +367,8 @@ export function createInitialFlowState(initialRoster: RosterCharacter[] = []): B gateway: 'U.S. East', stageTicks: 0, globalTick: 0, + tickAccumulatorMs: 0, + lastFrameTimeMs: null, // No prefilled credentials: the user (or a configured bot) must type them. accountName: '', @@ -273,6 +385,7 @@ export function createInitialFlowState(initialRoster: RosterCharacter[] = []): B charCreateError: null, roster: [...initialRoster], + avatarAnims: createInitialHeroAvatarAnims(), selectedSlotIndex: 0, deleteConfirmOpen: false, @@ -355,6 +468,7 @@ export function submitBattleNetLogin(state: BnetFlowState): boolean { state.stage = 'char_select' state.selectedSlotIndex = 0 state.activeInput = null + resetHeroAvatarAnims(state) } return true } @@ -377,6 +491,7 @@ export function enterCharCreate(state: BnetFlowState): void { const slot = state.campfireClasses[id] slot.phase = 'nu1' slot.frameIndex = 0 + slot.phaseTicks = 0 } } @@ -390,11 +505,13 @@ export function hoverCampfireClass(state: BnetFlowState, classId: CampfireClassI if (prev && state.campfireClasses[prev].phase === 'nu2') { state.campfireClasses[prev].phase = 'nu1' state.campfireClasses[prev].frameIndex = 0 + state.campfireClasses[prev].phaseTicks = 0 } state.hoveredClass = classId if (classId && state.campfireClasses[classId].phase === 'nu1') { state.campfireClasses[classId].phase = 'nu2' state.campfireClasses[classId].frameIndex = 0 + state.campfireClasses[classId].phaseTicks = 0 } } @@ -415,9 +532,11 @@ export function selectCampfireClass(state: BnetFlowState, classId: CampfireClass if (prevSlot.phase === 'fw' || prevSlot.phase === 'nu3') { prevSlot.phase = 'bw' prevSlot.frameIndex = 0 + prevSlot.phaseTicks = 0 } else { prevSlot.phase = 'nu1' prevSlot.frameIndex = 0 + prevSlot.phaseTicks = 0 } } state.selectedClass = classId @@ -425,6 +544,7 @@ export function selectCampfireClass(state: BnetFlowState, classId: CampfireClass const nextSlot = state.campfireClasses[classId] nextSlot.phase = 'fw' nextSlot.frameIndex = 0 + nextSlot.phaseTicks = 0 state.activeInput = 'charName' } @@ -464,6 +584,7 @@ export function submitCharacterCreate(state: BnetFlowState): RosterCharacter | n state.stage = 'char_select' state.stageTicks = 0 state.activeInput = null + resetHeroAvatarAnims(state) return newChar } @@ -539,6 +660,7 @@ export function confirmCharacterSelect(state: BnetFlowState): boolean { state.difficulty = 'normal' } state.activeInput = 'gameName' + resetHeroAvatarAnims(state) return true } @@ -588,10 +710,43 @@ export function submitCreateGame(state: BnetFlowState): boolean { state.stage = 'door_loading' state.stageTicks = 0 state.doorFrame = 0 + state.sceneReady = false state.activeInput = null return true } +/** + * Compute the current looping frame index for a `D2Win.dll` `AnimImage` control + * (`0x6f8f0a50..0x6f8f0c3a`): + * - Advances 1 frame every `rate` ticks (40ms * `rate` per frame). + * - Wraps at `framesPerDir - 1` (`0x6f8f0ac6: lea eax, [ecx - 1]; cmp [esi + 0x5c], eax; jl ...; mov [esi + 0x5c], 0`). + */ +export function getAnimImageLoopFrame(tick: number, framesPerDir: number, rate = 1): number { + const loopLen = Math.max(1, framesPerDir - 1) + const stepRate = Math.max(1, rate | 0) + return Math.floor(Math.max(0, tick) / stepRate) % loopLen +} + +/** + * Advance a single Character Selection / Lobby `TN` `HTH` hero avatar by 1 tick (40ms) + * per `D2Win.dll` (`0x6f8ee81b..0x6f8ee850`): + * - `acc += animRate` (8.8 fixed-point COF speed) + * - `frame = acc >> 8` + * - If `frame >= framesPerDir - 1`, wrap `acc = 0` (`frame = 0`). + */ +export function stepHeroAvatarAnim(anim: HeroAvatarAnimState): void { + const wrapFrame = Math.max(1, anim.frameCount - 1) + const nextAcc = anim.acc + anim.animRate + const nextFrame = nextAcc >> 8 + if (nextFrame >= wrapFrame) { + anim.acc = 0 + anim.frameIndex = 0 + } else { + anim.acc = nextAcc + anim.frameIndex = nextFrame + } +} + /** * Advance the front-end state machine by 1 tick (25fps = 40ms per tick). */ @@ -599,6 +754,10 @@ export function stepFlowTick(state: BnetFlowState): void { state.globalTick += 1 state.stageTicks += 1 + for (const token of HERO_TOKENS) { + stepHeroAvatarAnim(state.avatarAnims[token]) + } + if (state.stage === 'bnet_connecting') { // After 18 ticks (~720ms), connecting modal completes and transitions to Battle.net Login if (state.stageTicks >= 18) { @@ -613,12 +772,20 @@ export function stepFlowTick(state: BnetFlowState): void { for (const id of CAMPFIRE_CLASS_ORDER) { const slot = state.campfireClasses[id] const count = Math.max(1, slot.frameCount[slot.phase] || 1) + const rate = Math.max(1, slot.frameRate[slot.phase] || 1) + slot.phaseTicks += 1 + if (slot.phaseTicks < rate) { + continue + } + slot.phaseTicks = 0 + if (slot.phase === 'fw') { if (slot.frameIndex + 1 < count) { slot.frameIndex += 1 } else { slot.phase = 'nu3' slot.frameIndex = 0 + slot.phaseTicks = 0 } } else if (slot.phase === 'bw') { if (slot.frameIndex + 1 < count) { @@ -626,9 +793,12 @@ export function stepFlowTick(state: BnetFlowState): void { } else { slot.phase = state.hoveredClass === id ? 'nu2' : 'nu1' slot.frameIndex = 0 + slot.phaseTicks = 0 } } else { - slot.frameIndex = (slot.frameIndex + 1) % count + // Looping phases (nu1, nu2, nu3) wrap at framesPerDir - 1 per D2Win_AnimImage_Draw (0x6f8f0ac6) + const loopCount = Math.max(1, count - 1) + slot.frameIndex = (slot.frameIndex + 1) % loopCount } } return @@ -641,10 +811,61 @@ export function stepFlowTick(state: BnetFlowState): void { if (state.doorFrame >= 9 && state.stageTicks >= 30 && state.sceneReady) { state.stage = 'in_game' state.stageTicks = 0 + state.tickAccumulatorMs = 0 + state.lastFrameTimeMs = null } } } +const TICK_EPSILON_MS = 1e-6 + +/** + * Advance the front-end state machine by an elapsed frame delta `dtMs` using a fixed + * 40ms (25fps) accumulator and a bounded catch-up clamp (`FRONTEND_MAX_FRAME_DELTA_MS = 200ms`). + * Returns the number of 25fps ticks executed during this frame. + */ +export function stepFlowByDeltaMs(state: BnetFlowState, dtMs: number): number { + if (!Number.isFinite(dtMs) || dtMs <= 0) { + return 0 + } + const clampedDelta = Math.min(dtMs, FRONTEND_MAX_FRAME_DELTA_MS) + state.tickAccumulatorMs = Math.min( + state.tickAccumulatorMs + clampedDelta, + FRONTEND_MAX_FRAME_DELTA_MS, + ) + let steps = 0 + while ( + state.tickAccumulatorMs + TICK_EPSILON_MS >= FRONTEND_TICK_MS && + steps < FRONTEND_MAX_CATCHUP_TICKS + ) { + state.tickAccumulatorMs = Math.max(0, state.tickAccumulatorMs - FRONTEND_TICK_MS) + stepFlowTick(state) + steps += 1 + } + return steps +} + +/** + * Advance the front-end state machine from a `requestAnimationFrame` timestamp `nowMs` (in ms). + * On the first frame (`state.lastFrameTimeMs === null`), anchors `lastFrameTimeMs = nowMs` without + * advancing a tick; subsequent frames compute `nowMs - state.lastFrameTimeMs` and step fixed 40ms ticks. + */ +export function stepFlowFrame(state: BnetFlowState, nowMs: number): number { + if (!Number.isFinite(nowMs)) { + return 0 + } + if (state.lastFrameTimeMs === null) { + state.lastFrameTimeMs = nowMs + return 0 + } + const dtMs = nowMs - state.lastFrameTimeMs + if (dtMs <= 0) { + return 0 + } + state.lastFrameTimeMs = nowMs + return stepFlowByDeltaMs(state, dtMs) +} + export const CHAR_CLASS_ID_TO_CAMPFIRE: Readonly> = { 0: 'am', 1: 'so', @@ -732,6 +953,7 @@ export function applyOnlineCharactersToFlowState( state.stage = 'char_select' state.stageTicks = 0 state.activeInput = null + resetHeroAvatarAnims(state) } } @@ -745,6 +967,8 @@ export function applyOnlineCharactersToFlowState( export function returnToLoginAfterSessionLoss(state: BnetFlowState, reason: string): void { state.stage = 'bnet_login' state.stageTicks = 0 + state.tickAccumulatorMs = 0 + state.lastFrameTimeMs = null state.onlineConnected = false state.loginError = reason state.lastError = reason @@ -759,4 +983,5 @@ export function returnToLoginAfterSessionLoss(state: BnetFlowState, reason: stri state.doorFrame = 0 state.sceneReady = false state.activeInput = 'accountPassword' + resetHeroAvatarAnims(state) } diff --git a/src/client/frontend/frontend-renderer.ts b/src/client/frontend/frontend-renderer.ts index e981a32..fd84070 100644 --- a/src/client/frontend/frontend-renderer.ts +++ b/src/client/frontend/frontend-renderer.ts @@ -15,13 +15,16 @@ import { CursorController } from '../ui/cursor.ts' import type { FontAtlasMeta } from '../ui/font.ts' import { CAMPFIRE_CLASS_ORDER, + HERO_TOKENS, canSubmitCreateGame, cancelConnecting, clickBattleNetButton, confirmCharacterSelect, enterCharCreate, + getAnimImageLoopFrame, hoverCampfireClass, isDifficultyUnlocked, + resetHeroAvatarAnims, selectCampfireClass, submitBattleNetLogin, submitCharacterCreate, @@ -47,6 +50,8 @@ export interface PackedAnimStripMeta { readonly width: number readonly height: number readonly frameCount: number + readonly rate?: number + readonly animRate?: number readonly frames: readonly PackedAnimFrame[] } @@ -256,16 +261,41 @@ export class FrontendRenderer { this.manifest = (await manifestRes.json()) as FrontendManifest this.fontMetas = (await fontRes.json()) as Record - // Synchronize frame counts from manifest into flow state + // Synchronize frame counts and playback rates from manifest into flow state for (const id of CAMPFIRE_CLASS_ORDER) { const clsStrips = this.manifest.campfireClasses[id] if (!clsStrips) continue const slot = this.state.campfireClasses[id] for (const phase of ['nu1', 'nu2', 'fw', 'nu3', 'bw'] as const) { - if (clsStrips[phase]) { - slot.frameCount[phase] = clsStrips[phase]!.frameCount + const strip = clsStrips[phase] + if (strip) { + slot.frameCount[phase] = strip.frameCount + if (typeof strip.rate === 'number' && strip.rate > 0) { + slot.frameRate[phase] = strip.rate + } } } + const activeCount = Math.max(1, slot.frameCount[slot.phase] || 1) + const activeBound = + slot.phase === 'fw' || slot.phase === 'bw' ? activeCount : Math.max(1, activeCount - 1) + if (slot.frameIndex >= activeBound) { + slot.frameIndex = 0 + slot.phaseTicks = 0 + } + } + + for (const token of HERO_TOKENS) { + const avatarStrip = this.manifest.charAvatars[token] + if (!avatarStrip) continue + const anim = this.state.avatarAnims[token] + anim.frameCount = avatarStrip.frameCount + if (typeof avatarStrip.animRate === 'number' && avatarStrip.animRate > 0) { + anim.animRate = avatarStrip.animRate + } + if (anim.frameIndex >= Math.max(1, anim.frameCount - 1)) { + anim.acc = 0 + anim.frameIndex = 0 + } } const promises: Promise[] = [] @@ -780,6 +810,7 @@ export class FrontendRenderer { this.state.stage = 'char_select' this.state.stageTicks = 0 this.state.activeInput = null + resetHeroAvatarAnims(this.state) } else { this.state.stage = 'bnet_login' this.state.stageTicks = 0 @@ -860,6 +891,7 @@ export class FrontendRenderer { 0, Math.min(this.state.selectedSlotIndex, this.state.roster.length - 1), ) + resetHeroAvatarAnims(this.state) } } }, @@ -1036,6 +1068,7 @@ export class FrontendRenderer { this.state.stage = 'char_select' this.state.stageTicks = 0 this.state.activeInput = null + resetHeroAvatarAnims(this.state) }, }, // Create / Join Game sub-panel buttons (`CancelButtonBlank.dc6` 96x32 @ (433,401) & `GameButtonBlank.dc6` 172x32 @ (594,401)) @@ -1169,8 +1202,16 @@ export class FrontendRenderer { private drawBurningMainLogo(): void { if (!this.manifest) return const tick = this.state.globalTick - this.drawAnimStripFrame(this.manifest.animStrips['logoMainBlack'], tick, 400, 120, false) - this.drawAnimStripFrame(this.manifest.animStrips['logoMainFire'], tick, 400, 120, true) + const blackStrip = this.manifest.animStrips['logoMainBlack'] + const fireStrip = this.manifest.animStrips['logoMainFire'] + if (blackStrip) { + const frameIdx = getAnimImageLoopFrame(tick, blackStrip.frameCount, blackStrip.rate ?? 1) + this.drawAnimStripFrame(blackStrip, frameIdx, 400, 120, false) + } + if (fireStrip) { + const frameIdx = getAnimImageLoopFrame(tick, fireStrip.frameCount, fireStrip.rate ?? 1) + this.drawAnimStripFrame(fireStrip, frameIdx, 400, 120, true) + } } private renderMainMenu(showConnectingModal: boolean): void { @@ -1314,14 +1355,16 @@ export class FrontendRenderer { this.drawCampfireClass(id) } - // Campfire flame (`fire.dc6`, control anchor (345, 454) minus f0.offset (-34, 132) -> (379, 322)) - this.drawAnimStripFrame( - this.manifest!.animStrips['campfireFlame'], - this.state.globalTick, - 379, - 322, - true, - ) + // Campfire flame (`fire.dc6`, control anchor (345, 454) minus f0.offset (-34, 132) -> (379, 322), rate=1, wraps at framesPerDir - 1) + const flameStrip = this.manifest!.animStrips['campfireFlame'] + if (flameStrip) { + const flameFrame = getAnimImageLoopFrame( + this.state.globalTick, + flameStrip.frameCount, + flameStrip.rate ?? 1, + ) + this.drawAnimStripFrame(flameStrip, flameFrame, 379, 322, true) + } for (const id of frontClasses) { this.drawCampfireClass(id) @@ -1461,8 +1504,10 @@ export class FrontendRenderer { } // Draw animated Level 1 unequipped Town-Neutral (TN) avatar standing on left side of slot + // Per D2Win.dll (0x6f8ee81b..0x6f8ee850), advances via 8.8 fixed-point COF animRate accumulator const avatarStrip = this.manifest!.charAvatars[char.heroToken] - this.drawAnimStripFrame(avatarStrip, this.state.globalTick, slotX + 36, slotY + 82, false) + const avatarFrame = this.state.avatarAnims[char.heroToken]?.frameIndex ?? 0 + this.drawAnimStripFrame(avatarStrip, avatarFrame, slotX + 36, slotY + 82, false) // Right side metadata: Name (gold or red if Hardcore), Level & Class, Expansion Character, Expires in 90 days const textX = slotX + 78 @@ -1503,21 +1548,17 @@ export class FrontendRenderer { const L = s.lobby // Top-left & Top-right small burning D2 logos (`D2logoBlackBnet.dc6` + `D2logoBnet.dc6` at (84, 52) & (726, 52) per D2Multi.dll 0x6f9ea1b0 & 0x6f9ea240) + const bnetBlackStrip = this.manifest!.animStrips['logoBnetBlack'] + const bnetFireStrip = this.manifest!.animStrips['logoBnetFire'] + const bnetBlackFrame = bnetBlackStrip + ? getAnimImageLoopFrame(this.state.globalTick, bnetBlackStrip.frameCount, bnetBlackStrip.rate ?? 1) + : 0 + const bnetFireFrame = bnetFireStrip + ? getAnimImageLoopFrame(this.state.globalTick, bnetFireStrip.frameCount, bnetFireStrip.rate ?? 1) + : 0 for (const logoX of [84, 726]) { - this.drawAnimStripFrame( - this.manifest!.animStrips['logoBnetBlack'], - this.state.globalTick, - logoX, - 52, - false, - ) - this.drawAnimStripFrame( - this.manifest!.animStrips['logoBnetFire'], - this.state.globalTick, - logoX, - 52, - true, - ) + this.drawAnimStripFrame(bnetBlackStrip, bnetBlackFrame, logoX, 52, false) + this.drawAnimStripFrame(bnetFireStrip, bnetFireFrame, logoX, 52, true) } // Left Chat Window messages (`x = 28..382, y = 112..408` per D2Multi.dll 0x6f9ea120) @@ -1689,7 +1730,8 @@ export class FrontendRenderer { const selectedChar = this.state.roster[this.state.selectedSlotIndex] if (selectedChar) { const avatarStrip = this.manifest!.charAvatars[selectedChar.heroToken] - this.drawAnimStripFrame(avatarStrip, this.state.globalTick, 64, 578, false) + const avatarFrame = this.state.avatarAnims[selectedChar.heroToken]?.frameIndex ?? 0 + this.drawAnimStripFrame(avatarStrip, avatarFrame, 64, 578, false) this.drawBitmapText(selectedChar.name, 134, 528, { font: 'font16', color: selectedChar.isHardcore ? 1 : 4, @@ -1944,21 +1986,28 @@ export class FrontendRenderer { cancelConnecting(this.state) } else if (this.state.stage === 'bnet_login') { this.state.stage = 'main_menu' + this.state.stageTicks = 0 this.state.activeInput = null } else if (this.state.stage === 'char_create') { if (this.state.roster.length > 0) { this.state.stage = 'char_select' + this.state.stageTicks = 0 this.state.activeInput = null + resetHeroAvatarAnims(this.state) } else { this.state.stage = 'bnet_login' + this.state.stageTicks = 0 this.state.activeInput = 'accountName' } } else if (this.state.stage === 'char_select') { this.state.stage = 'main_menu' + this.state.stageTicks = 0 this.state.activeInput = null } else if (this.state.stage === 'bnet_lobby') { this.state.stage = 'char_select' + this.state.stageTicks = 0 this.state.activeInput = null + resetHeroAvatarAnims(this.state) } return } diff --git a/src/client/main.ts b/src/client/main.ts index 36e9dde..207bca4 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -16,8 +16,9 @@ import { applyOnlineCharactersToFlowState, createInitialFlowState, isDifficultyUnlocked, + resetHeroAvatarAnims, returnToLoginAfterSessionLoss, - stepFlowTick, + stepFlowFrame, type BnetFlowState, } from './frontend/flow-state.ts' import { FrontendRenderer } from './frontend/frontend-renderer.ts' @@ -452,6 +453,11 @@ async function bootPlayApp(): Promise { flowState.lobbySubPanel = 'create' flowState.createGameError = null flowState.activeInput = 'gameName' + flowState.stageTicks = 0 + flowState.doorFrame = 0 + flowState.tickAccumulatorMs = 0 + flowState.lastFrameTimeMs = null + resetHeroAvatarAnims(flowState) toolbar.setConnectionState({ phase: 'mcp', rttMs: session.pingMs }) syncVisibility() frontendRenderer.render(performance.now()) @@ -573,6 +579,8 @@ async function bootPlayApp(): Promise { try { await session.selectCharacter(selected.name) st.stage = 'bnet_lobby' + st.stageTicks = 0 + resetHeroAvatarAnims(st) st.lobbySubPanel = 'create' st.createGameError = null if (!isDifficultyUnlocked(selected, st.difficulty)) { @@ -647,6 +655,7 @@ async function bootPlayApp(): Promise { st.stage = 'door_loading' st.stageTicks = 0 st.doorFrame = 0 + st.sceneReady = false st.createGameError = null st.lastError = null st.statusText = '' @@ -665,11 +674,20 @@ async function bootPlayApp(): Promise { }) st.sceneReady = true st.stage = 'in_game' + st.stageTicks = 0 + st.tickAccumulatorMs = 0 + st.lastFrameTimeMs = null inGameView = true syncVisibility() } catch (err) { if (err instanceof SessionLostError) return st.stage = 'bnet_lobby' + st.stageTicks = 0 + st.doorFrame = 0 + st.sceneReady = false + st.tickAccumulatorMs = 0 + st.lastFrameTimeMs = null + resetHeroAvatarAnims(st) st.createGameError = err instanceof Error ? err.message : String(err) st.lastError = st.createGameError frontendRenderer.render(performance.now()) @@ -679,6 +697,7 @@ async function bootPlayApp(): Promise { st.stage = 'door_loading' st.stageTicks = 0 st.doorFrame = 0 + st.sceneReady = false st.createGameError = null st.lastError = null st.statusText = '' @@ -691,11 +710,20 @@ async function bootPlayApp(): Promise { }) st.sceneReady = true st.stage = 'in_game' + st.stageTicks = 0 + st.tickAccumulatorMs = 0 + st.lastFrameTimeMs = null inGameView = true syncVisibility() } catch (err) { if (err instanceof SessionLostError) return st.stage = 'bnet_lobby' + st.stageTicks = 0 + st.doorFrame = 0 + st.sceneReady = false + st.tickAccumulatorMs = 0 + st.lastFrameTimeMs = null + resetHeroAvatarAnims(st) st.createGameError = err instanceof Error ? err.message : String(err) st.lastError = st.createGameError frontendRenderer.render(performance.now()) @@ -725,6 +753,9 @@ async function bootPlayApp(): Promise { inGameView = true flowState.sceneReady = true flowState.stage = 'in_game' + flowState.stageTicks = 0 + flowState.tickAccumulatorMs = 0 + flowState.lastFrameTimeMs = null toolbar.setConnectionState({ phase: 'd2gs', rttMs: snap.pingMs }) } else if (snap.phase === 'lobby') { if (inGameView || flowState.stage === 'in_game') { @@ -853,14 +884,19 @@ async function bootPlayApp(): Promise { lastFrameMs = nowMs if (!inGameView) { - stepFlowTick(flowState) + stepFlowFrame(flowState, nowMs) if (flowState.stage === 'in_game' && (session.phase === 'ingame' || session.phase === 'replay')) { inGameView = true + flowState.tickAccumulatorMs = 0 + flowState.lastFrameTimeMs = null syncVisibility() } else { frontendRenderer.render(nowMs) return } + } else { + flowState.tickAccumulatorMs = 0 + flowState.lastFrameTimeMs = null } session.tick(dtMs || 40, nowMs) diff --git a/src/common/units/character.ts b/src/common/units/character.ts index 066e163..f2e285e 100644 --- a/src/common/units/character.ts +++ b/src/common/units/character.ts @@ -41,6 +41,8 @@ export interface CharacterSheet { readonly framesPerDirection: number /** Layers the COF declared. */ readonly layers: number + /** Animation speed byte from the COF header (8.8 fixed-point, 256 = 25fps). */ + readonly speed?: number /** Layer/direction combinations skipped because a sprite was missing. */ readonly skipped: number /** The DCC members that were decoded, for provenance. */ @@ -226,6 +228,7 @@ export async function loadCharacterSheet( directions: cof.numberOfDirections, framesPerDirection: cof.framesPerDirection, layers: cof.layers.length, + speed: cof.speed, skipped, members, notes, diff --git a/tests/frontend/flow-state.test.ts b/tests/frontend/flow-state.test.ts index ad654b8..a336a80 100644 --- a/tests/frontend/flow-state.test.ts +++ b/tests/frontend/flow-state.test.ts @@ -1,6 +1,13 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { + CAMPFIRE_ANIM_RATES, CAMPFIRE_CLASS_ORDER, + FRONTEND_MAX_CATCHUP_TICKS, + FRONTEND_TICK_MS, + HERO_TN_ANIM_SPECS, + HERO_TOKENS, canSubmitCreateGame, cancelConnecting, charSummaryToRosterCharacter, @@ -8,18 +15,22 @@ import { confirmCharacterSelect, createInitialFlowState, enterCharCreate, + getAnimImageLoopFrame, getCharacterMaxDifficulty, hoverCampfireClass, isDifficultyUnlocked, isValidCharacterName, + returnToLoginAfterSessionLoss, selectCampfireClass, + stepFlowByDeltaMs, + stepFlowFrame, stepFlowTick, submitBattleNetLogin, submitCharacterCreate, submitCreateGame, toggleChatGem, } from '../../src/client/frontend/flow-state.ts' -import { FrontendRenderer } from '../../src/client/frontend/frontend-renderer.ts' +import { FrontendRenderer, type FrontendManifest } from '../../src/client/frontend/frontend-renderer.ts' import { parseMcpCharStatString } from '../../src/netproto/mcp/packets.ts' describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => { @@ -292,4 +303,503 @@ describe('Diablo II v1.13c Front-End & Battle.net Flow State Machine', () => { expect(created?.heroToken).toBe('ai') expect(created?.className).toBe('Assassin') }) + + it('decouples front-end tick progression from display refresh rate (25 ticks/sec across 60Hz, 120Hz, 144Hz) and clamps large frame deltas', () => { + expect(FRONTEND_TICK_MS).toBe(40) + expect(FRONTEND_MAX_CATCHUP_TICKS).toBe(5) + + for (const refreshHz of [60, 120, 144]) { + const state = createInitialFlowState() + // First frame anchors lastFrameTimeMs at t = 0ms + expect(stepFlowFrame(state, 0)).toBe(0) + expect(state.globalTick).toBe(0) + + for (let i = 1; i <= refreshHz; i++) { + stepFlowFrame(state, (i * 1000) / refreshHz) + } + expect(state.globalTick).toBe(25) + expect(state.stageTicks).toBe(25) + } + + // Also verify delta-based stepping across 60Hz, 120Hz, 144Hz, and 240Hz + for (const refreshHz of [60, 120, 144, 240]) { + const state = createInitialFlowState() + const dtMs = 1000 / refreshHz + for (let i = 0; i < refreshHz; i++) { + stepFlowByDeltaMs(state, dtMs) + } + expect(state.globalTick).toBe(25) + } + + // Verify bounded catch-up clamp (e.g. 5000ms tab switch clamps to 5 ticks = 200ms) + const pausedState = createInitialFlowState() + expect(stepFlowFrame(pausedState, 100)).toBe(0) + const catchupSteps = stepFlowFrame(pausedState, 5100) + expect(catchupSteps).toBe(FRONTEND_MAX_CATCHUP_TICKS) + expect(pausedState.globalTick).toBe(FRONTEND_MAX_CATCHUP_TICKS) + expect(pausedState.tickAccumulatorMs).toBe(0) + + // Non-positive or non-finite timestamps/deltas must be true no-ops that do not rewind lastFrameTimeMs + expect(stepFlowFrame(pausedState, 5050)).toBe(0) + expect(pausedState.lastFrameTimeMs).toBe(5100) + expect(stepFlowFrame(pausedState, 5116)).toBe(0) + expect(pausedState.tickAccumulatorMs).toBe(16) + expect(stepFlowFrame(pausedState, Number.NaN)).toBe(0) + expect(stepFlowByDeltaMs(pausedState, -16)).toBe(0) + expect(stepFlowByDeltaMs(pausedState, Number.NaN)).toBe(0) + expect(pausedState.globalTick).toBe(FRONTEND_MAX_CATCHUP_TICKS) + }) + + it('wraps AnimImage looping controls (burning logos & campfire flame) at framesPerDir - 1 with rate = 1 per D2Win.dll (0x6f8f0ac6)', () => { + // 30-frame DC6 animations (D2logoBlackLeft/Right, D2logoFireLeft/Right, D2logoBlackBnet, D2logoBnet, fire.dc6) + // loop over frames 0..28 (29 frames) and wrap to 0 at tick 29 + for (let t = 0; t <= 28; t++) { + expect(getAnimImageLoopFrame(t, 30, 1)).toBe(t) + } + expect(getAnimImageLoopFrame(29, 30, 1)).toBe(0) + expect(getAnimImageLoopFrame(30, 30, 1)).toBe(1) + expect(getAnimImageLoopFrame(57, 30, 1)).toBe(28) + expect(getAnimImageLoopFrame(58, 30, 1)).toBe(0) + }) + + it('advances 7-class campfire animations at exact 1.13c D2Launch.dll (0x6fa60c08) per-class, per-phase rates and wraps looping phases at framesPerDir - 1', () => { + // Verify table against 1.13c D2Launch.dll @ 0x6fa60c08 + expect(CAMPFIRE_ANIM_RATES).toEqual({ + as: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 }, + dz: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 }, + am: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 }, + ne: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 }, + ba: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 }, + so: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 }, + pa: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 }, + }) + + const state = createInitialFlowState() + enterCharCreate(state) + + // 1. Back idle ('nu1'): + // - Amazon ('am'): rate = 3 (120ms/frame), frameCount = 26 -> loops 0..24 (25 frames, wraps at tick 75) + // - Barbarian ('ba'): rate = 2 (80ms/frame), frameCount = 16 -> loops 0..14 (15 frames, wraps at tick 30) + stepFlowTick(state) // tick 1 + expect(state.campfireClasses.am.frameIndex).toBe(0) + expect(state.campfireClasses.ba.frameIndex).toBe(0) + + stepFlowTick(state) // tick 2 + expect(state.campfireClasses.am.frameIndex).toBe(0) + expect(state.campfireClasses.ba.frameIndex).toBe(1) + + stepFlowTick(state) // tick 3 + expect(state.campfireClasses.am.frameIndex).toBe(1) + expect(state.campfireClasses.ba.frameIndex).toBe(1) + + // Step to tick 28 -> Barbarian at frame 14 (last frame before wrap at framesPerDir - 1 = 15) + for (let t = 4; t <= 28; t++) { + stepFlowTick(state) + } + expect(state.campfireClasses.ba.frameIndex).toBe(14) + stepFlowTick(state) // tick 29 + expect(state.campfireClasses.ba.frameIndex).toBe(14) + stepFlowTick(state) // tick 30 -> wraps to 0 (never reaches frame 15) + expect(state.campfireClasses.ba.frameIndex).toBe(0) + + // Step to tick 72 -> Amazon at frame 24 (last frame before wrap at framesPerDir - 1 = 25) + for (let t = 31; t <= 72; t++) { + stepFlowTick(state) + } + expect(state.campfireClasses.am.frameIndex).toBe(24) + stepFlowTick(state) // tick 73 + stepFlowTick(state) // tick 74 + expect(state.campfireClasses.am.frameIndex).toBe(24) + stepFlowTick(state) // tick 75 -> wraps to 0 (never reaches frame 25) + expect(state.campfireClasses.am.frameIndex).toBe(0) + + // 2. Hover ('nu2') and Select ('fw' -> 'nu3'): + hoverCampfireClass(state, 'as') + expect(state.campfireClasses.as.phase).toBe('nu2') + expect(state.campfireClasses.as.frameIndex).toBe(0) + // Assassin nu2 has rate = 3 + stepFlowTick(state) + stepFlowTick(state) + expect(state.campfireClasses.as.frameIndex).toBe(0) + stepFlowTick(state) + expect(state.campfireClasses.as.frameIndex).toBe(1) + + // Select Paladin ('pa'): fw has rate = 1 (28 frames), then nu3 has rate = 2 (13 frames -> wraps at 12) + selectCampfireClass(state, 'pa') + expect(state.campfireClasses.pa.phase).toBe('fw') + for (let i = 0; i < 28; i++) { + stepFlowTick(state) + } + expect(state.campfireClasses.pa.phase).toBe('nu3') + expect(state.campfireClasses.pa.frameIndex).toBe(0) + + // Paladin nu3 advances 1 frame every 2 ticks and wraps at 13 - 1 = 12 + stepFlowTick(state) + expect(state.campfireClasses.pa.frameIndex).toBe(0) + stepFlowTick(state) + expect(state.campfireClasses.pa.frameIndex).toBe(1) + for (let i = 0; i < 20; i++) { + stepFlowTick(state) + } + expect(state.campfireClasses.pa.frameIndex).toBe(11) + stepFlowTick(state) + expect(state.campfireClasses.pa.frameIndex).toBe(11) + stepFlowTick(state) + expect(state.campfireClasses.pa.frameIndex).toBe(0) + }) + + it('advances Character Selection & Lobby TN HTH hero avatars via 1.13c D2Win.dll (0x6f8ee81b) 8.8 fixed-point COF animRate accumulator', () => { + expect(HERO_TN_ANIM_SPECS).toEqual({ + am: { frameCount: 16, animRate: 80 }, + so: { frameCount: 16, animRate: 80 }, + ne: { frameCount: 16, animRate: 80 }, + pa: { frameCount: 16, animRate: 128 }, + ba: { frameCount: 16, animRate: 80 }, + dz: { frameCount: 10, animRate: 96 }, + ai: { frameCount: 12, animRate: 64 }, + }) + + const state = createInitialFlowState() + for (const token of HERO_TOKENS) { + expect(state.avatarAnims[token].acc).toBe(0) + expect(state.avatarAnims[token].frameIndex).toBe(0) + } + + // Tick 1: + // am: acc=80 -> frame 0; pa: acc=128 -> frame 0; dz: acc=96 -> frame 0; ai: acc=64 -> frame 0 + stepFlowTick(state) + expect(state.avatarAnims.am.frameIndex).toBe(0) + expect(state.avatarAnims.pa.frameIndex).toBe(0) + expect(state.avatarAnims.dz.frameIndex).toBe(0) + expect(state.avatarAnims.ai.frameIndex).toBe(0) + + // Tick 2: + // pa: acc=256 -> frame 1; others still at frame 0 + stepFlowTick(state) + expect(state.avatarAnims.pa.frameIndex).toBe(1) + expect(state.avatarAnims.am.frameIndex).toBe(0) + expect(state.avatarAnims.dz.frameIndex).toBe(0) + expect(state.avatarAnims.ai.frameIndex).toBe(0) + + // Tick 3: + // dz: acc=288 -> frame 1; am: acc=240 -> frame 0; ai: acc=192 -> frame 0 + stepFlowTick(state) + expect(state.avatarAnims.dz.frameIndex).toBe(1) + expect(state.avatarAnims.am.frameIndex).toBe(0) + expect(state.avatarAnims.ai.frameIndex).toBe(0) + + // Tick 4: + // am: acc=320 -> frame 1; ai: acc=256 -> frame 1; pa: acc=512 -> frame 2 + stepFlowTick(state) + expect(state.avatarAnims.am.frameIndex).toBe(1) + expect(state.avatarAnims.so.frameIndex).toBe(1) + expect(state.avatarAnims.ne.frameIndex).toBe(1) + expect(state.avatarAnims.ba.frameIndex).toBe(1) + expect(state.avatarAnims.ai.frameIndex).toBe(1) + expect(state.avatarAnims.pa.frameIndex).toBe(2) + + // Advance to tick 24 -> Druid (dz: frameCount=10, wrapFrame=9, animRate=96) reaches 24*96 = 2304 (2304>>8 = 9 >= 9) -> wraps to 0 + for (let t = 5; t <= 23; t++) { + stepFlowTick(state) + } + expect(state.avatarAnims.dz.frameIndex).toBe(8) + stepFlowTick(state) // tick 24 + expect(state.avatarAnims.dz.frameIndex).toBe(0) + expect(state.avatarAnims.dz.acc).toBe(0) + + // Advance to tick 30 -> Paladin (pa: frameCount=16, wrapFrame=15, animRate=128) reaches 30*128 = 3840 (3840>>8 = 15 >= 15) -> wraps to 0 + for (let t = 25; t <= 29; t++) { + stepFlowTick(state) + } + expect(state.avatarAnims.pa.frameIndex).toBe(14) + stepFlowTick(state) // tick 30 + expect(state.avatarAnims.pa.frameIndex).toBe(0) + expect(state.avatarAnims.pa.acc).toBe(0) + + // Advance to tick 44 -> Assassin (ai: frameCount=12, wrapFrame=11, animRate=64) reaches 44*64 = 2816 (2816>>8 = 11 >= 11) -> wraps to 0 + for (let t = 31; t <= 43; t++) { + stepFlowTick(state) + } + expect(state.avatarAnims.ai.frameIndex).toBe(10) + stepFlowTick(state) // tick 44 + expect(state.avatarAnims.ai.frameIndex).toBe(0) + expect(state.avatarAnims.ai.acc).toBe(0) + + // Advance to tick 48 -> Amazon/Sorceress/Necromancer/Barbarian (frameCount=16, wrapFrame=15, animRate=80) reach 48*80 = 3840 (3840>>8 = 15 >= 15) -> wrap to 0 + for (let t = 45; t <= 47; t++) { + stepFlowTick(state) + } + expect(state.avatarAnims.am.frameIndex).toBe(14) + stepFlowTick(state) // tick 48 + expect(state.avatarAnims.am.frameIndex).toBe(0) + expect(state.avatarAnims.so.frameIndex).toBe(0) + expect(state.avatarAnims.ne.frameIndex).toBe(0) + expect(state.avatarAnims.ba.frameIndex).toBe(0) + }) + + it('keeps public/ui/frontend/manifest.json and FrontendRenderer aligned on 1.13c rates and wrapped frame indices', () => { + const manifestPath = join(process.cwd(), 'public', 'ui', 'frontend', 'manifest.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as FrontendManifest + + // 1. Verify manifest animStrips rates + expect(manifest.animStrips.logoMainBlack?.rate).toBe(1) + expect(manifest.animStrips.logoMainFire?.rate).toBe(1) + expect(manifest.animStrips.logoBnetBlack?.rate).toBe(1) + expect(manifest.animStrips.logoBnetFire?.rate).toBe(1) + expect(manifest.animStrips.campfireFlame?.rate).toBe(1) + expect(manifest.animStrips.loadingDoor?.rate).toBe(3) + + // 2. Verify manifest campfireClasses rates + for (const clsId of CAMPFIRE_CLASS_ORDER) { + for (const phase of ['nu1', 'nu2', 'fw', 'nu3', 'bw'] as const) { + expect(manifest.campfireClasses[clsId]?.[phase]?.rate).toBe( + CAMPFIRE_ANIM_RATES[clsId][phase], + ) + } + } + + // 3. Verify manifest charAvatars animRates & frameCounts + for (const token of HERO_TOKENS) { + expect(manifest.charAvatars[token]?.frameCount).toBe(HERO_TN_ANIM_SPECS[token].frameCount) + expect(manifest.charAvatars[token]?.animRate).toBe(HERO_TN_ANIM_SPECS[token].animRate) + } + + // 4. Verify FrontendRenderer draws wrapped logo/flame frames and COF-accumulated avatar frames + const state = createInitialFlowState() + const drawCalls: { img: unknown; sx: number; sy: number; dx: number; dy: number }[] = [] + const fakeCtx = { + imageSmoothingEnabled: false, + clearRect: () => {}, + save: () => {}, + restore: () => {}, + fillRect: () => {}, + drawImage: ( + img: unknown, + sx: number, + sy: number, + _sw?: number, + _sh?: number, + dx?: number, + dy?: number, + ) => { + drawCalls.push({ img, sx, sy, dx: dx ?? sx, dy: dy ?? sy }) + }, + } + const fakeCanvas = { + getContext: () => fakeCtx, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), + } as unknown as HTMLCanvasElement + + const renderer = new FrontendRenderer(fakeCanvas, state) + renderer.manifest = manifest + renderer.ready = true + for (const strip of Object.values(manifest.animStrips)) { + renderer.images.set(strip.file, { id: strip.file, width: strip.width, height: strip.height } as unknown as HTMLImageElement) + } + for (const strip of Object.values(manifest.charAvatars)) { + renderer.images.set(strip.file, { id: strip.file, width: strip.width, height: strip.height } as unknown as HTMLImageElement) + } + + // At globalTick = 29, 30-frame main menu logo wraps to frame 0 (not frame 29) + state.stage = 'main_menu' + state.globalTick = 29 + drawCalls.length = 0 + renderer.render(1000) + const logoCall = drawCalls.find( + (c) => (c.img as { id?: string })?.id === 'logo-main-black.png', + ) + expect(logoCall).toBeDefined() + const logoFrame0 = manifest.animStrips.logoMainBlack!.frames[0]! + expect(logoCall?.sx).toBe(logoFrame0.x) + expect(logoCall?.sy).toBe(logoFrame0.y) + + // In char_select after 2 ticks, Sorceress (animRate=80) is still on frame 0 (160>>8 = 0), not frame 2 + state.roster = [ + { + id: 'so-1', + name: 'Isendra', + classId: 'so', + heroToken: 'so', + className: 'Sorceress', + level: 1, + isExpansion: true, + isHardcore: false, + isLadder: true, + progression: 0, + maxDifficulty: 'normal', + createdAt: 0, + }, + ] + state.stage = 'char_select' + state.globalTick = 0 + state.avatarAnims.so.acc = 0 + state.avatarAnims.so.frameIndex = 0 + stepFlowTick(state) + stepFlowTick(state) + expect(state.globalTick).toBe(2) + expect(state.avatarAnims.so.frameIndex).toBe(0) + + drawCalls.length = 0 + renderer.render(1080) + const avatarCallAtTick2 = drawCalls.find( + (c) => (c.img as { id?: string })?.id === 'avatars/char-so-tn.png', + ) + const soFrame0 = manifest.charAvatars.so!.frames[0]! + expect(avatarCallAtTick2?.sx).toBe(soFrame0.x) + expect(avatarCallAtTick2?.sy).toBe(soFrame0.y) + + // After 2 more ticks (tick 4 -> acc = 320 -> 320>>8 = 1), Sorceress advances to frame 1 + stepFlowTick(state) + stepFlowTick(state) + expect(state.avatarAnims.so.frameIndex).toBe(1) + drawCalls.length = 0 + renderer.render(1160) + const avatarCallAtTick4 = drawCalls.find( + (c) => (c.img as { id?: string })?.id === 'avatars/char-so-tn.png', + ) + const soFrame1 = manifest.charAvatars.so!.frames[1]! + expect(avatarCallAtTick4?.sx).toBe(soFrame1.x) + expect(avatarCallAtTick4?.sy).toBe(soFrame1.y) + }) + + it('resets lastFrameTimeMs and tickAccumulatorMs when entering in_game or returning after session loss to prevent catch-up bursts', () => { + const state = createInitialFlowState() + state.stage = 'door_loading' + state.doorFrame = 9 + state.stageTicks = 29 + state.sceneReady = true + + // Anchor at 1000ms and advance 65ms (1 tick + 25ms leftover in tickAccumulatorMs) + expect(stepFlowFrame(state, 1000)).toBe(0) + expect(stepFlowFrame(state, 1065)).toBe(1) + expect(state.stage).toBe('in_game') + expect(state.tickAccumulatorMs).toBe(0) + expect(state.lastFrameTimeMs).toBeNull() + + // Simulate a stale timestamp/accumulator right before a background session disconnect + state.lastFrameTimeMs = 2000 + state.tickAccumulatorMs = 35 + state.avatarAnims.so.acc = 512 + state.avatarAnims.so.frameIndex = 2 + + returnToLoginAfterSessionLoss(state, 'Connection lost') + expect(state.stage).toBe('bnet_login') + expect(state.stageTicks).toBe(0) + expect(state.tickAccumulatorMs).toBe(0) + expect(state.lastFrameTimeMs).toBeNull() + expect(state.avatarAnims.so.acc).toBe(0) + expect(state.avatarAnims.so.frameIndex).toBe(0) + + // First front-end frame after 30s background disconnect must anchor (0 ticks), not burst 5 catch-up ticks + const tickBefore = state.globalTick + expect(stepFlowFrame(state, 32000)).toBe(0) + expect(state.globalTick).toBe(tickBefore) + expect(stepFlowFrame(state, 32040)).toBe(1) + expect(state.globalTick).toBe(tickBefore + 1) + + // Subsequent game creation after a prior in_game session must reset sceneReady = false + state.stage = 'bnet_lobby' + state.gameName = 'SecondRoom' + state.maxPlayers = '8' + state.sceneReady = true + expect(submitCreateGame(state)).toBe(true) + expect(state.stage).toBe('door_loading') + expect(state.sceneReady).toBe(false) + for (let i = 0; i < 35; i++) { + stepFlowTick(state) + } + expect(state.doorFrame).toBe(9) + expect(state.stage).toBe('door_loading') + }) + + it('resets stageTicks and avatarAnims across FrontendRenderer Escape key transitions and offline character deletion', () => { + const manifestPath = join(process.cwd(), 'public', 'ui', 'frontend', 'manifest.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as FrontendManifest + const state = createInitialFlowState() + const fakeCtx = { + imageSmoothingEnabled: false, + clearRect: () => {}, + save: () => {}, + restore: () => {}, + fillRect: () => {}, + drawImage: () => {}, + } + const fakeCanvas = { + getContext: () => fakeCtx, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), + } as unknown as HTMLCanvasElement + + const renderer = new FrontendRenderer(fakeCanvas, state) + renderer.manifest = manifest + renderer.ready = true + + const makeEscapeEvent = (): KeyboardEvent => + ({ + key: 'Escape', + preventDefault: () => {}, + }) as unknown as KeyboardEvent + + // 1. Escape on bnet_login -> main_menu resets stageTicks + state.stage = 'bnet_login' + state.stageTicks = 12 + renderer.handleKeyDown(makeEscapeEvent()) + expect(state.stage).toBe('main_menu') + expect(state.stageTicks).toBe(0) + + // 2. Escape on char_create (empty roster) -> bnet_login resets stageTicks + state.roster = [] + enterCharCreate(state) + stepFlowTick(state) + stepFlowTick(state) + expect(state.stageTicks).toBe(2) + renderer.handleKeyDown(makeEscapeEvent()) + expect(state.stage).toBe('bnet_login') + expect(state.stageTicks).toBe(0) + + // 3. Escape on char_create (non-empty roster) -> char_select resets stageTicks and avatarAnims + enterCharCreate(state) + selectCampfireClass(state, 'pa') + state.charName = 'Roland' + submitCharacterCreate(state) + enterCharCreate(state) + for (let i = 0; i < 6; i++) stepFlowTick(state) + expect(state.stageTicks).toBe(6) + expect(state.avatarAnims.pa.frameIndex).toBeGreaterThan(0) + renderer.handleKeyDown(makeEscapeEvent()) + expect(state.stage).toBe('char_select') + expect(state.stageTicks).toBe(0) + expect(state.avatarAnims.pa.acc).toBe(0) + expect(state.avatarAnims.pa.frameIndex).toBe(0) + + // 4. Escape on bnet_lobby -> char_select resets stageTicks and avatarAnims + confirmCharacterSelect(state) + for (let i = 0; i < 6; i++) stepFlowTick(state) + expect(state.stage).toBe('bnet_lobby') + expect(state.stageTicks).toBe(6) + expect(state.avatarAnims.pa.frameIndex).toBeGreaterThan(0) + renderer.handleKeyDown(makeEscapeEvent()) + expect(state.stage).toBe('char_select') + expect(state.stageTicks).toBe(0) + expect(state.avatarAnims.pa.acc).toBe(0) + expect(state.avatarAnims.pa.frameIndex).toBe(0) + + // 5. Offline char_select_delete button resets avatarAnims + for (let i = 0; i < 6; i++) stepFlowTick(state) + expect(state.avatarAnims.pa.frameIndex).toBeGreaterThan(0) + const deleteBtn = renderer.getActiveButtons().find((b) => b.id === 'char_select_delete') + expect(deleteBtn).toBeDefined() + deleteBtn!.onClick() + expect(state.roster).toHaveLength(0) + expect(state.avatarAnims.pa.acc).toBe(0) + expect(state.avatarAnims.pa.frameIndex).toBe(0) + + // 6. Escape on char_select -> main_menu resets stageTicks + state.stageTicks = 9 + renderer.handleKeyDown(makeEscapeEvent()) + expect(state.stage).toBe('main_menu') + expect(state.stageTicks).toBe(0) + }) }) + + +