fix(frontend): align pre-game tick accumulator and anim rates with 1.13c ground truth
This commit is contained in:
parent
8dac79e4e4
commit
f356c8b1d3
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
// 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<void> {
|
|||
// 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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
// ============================================================================
|
||||
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<void> {
|
|||
// 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<void> {
|
|||
|
||||
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!`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<CampfireAnimPhase, number>
|
||||
/** Per-phase tick multiplier (`durMs = rate * 40ms`, `D2Launch.dll` @ `0x6fa60c08`). */
|
||||
frameRate: Record<CampfireAnimPhase, number>
|
||||
}
|
||||
|
||||
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<HeroToken, HeroAvatarAnimState>
|
||||
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<CampfireAnimPhase, number>
|
||||
/** 1.13c `D2Launch.dll` `@ 0x6fa60c08` per-mode tick multiplier (`durMs = rate * 40ms`). */
|
||||
readonly defaultRates: Record<CampfireAnimPhase, number>
|
||||
}
|
||||
>
|
||||
> = {
|
||||
|
|
@ -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<CampfireClassId, Readonly<Record<CampfireAnimPhase, number>>>
|
||||
> = {
|
||||
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<HeroToken, { readonly frameCount: number; readonly animRate: number }>
|
||||
> = {
|
||||
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<HeroToken, HeroAvatarAnimState> {
|
||||
const avatarAnims = {} as Record<HeroToken, HeroAvatarAnimState>
|
||||
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<CampfireClassId, CampfireClassSlotState>
|
||||
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<Record<number, CampfireClassId>> = {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, FontAtlasMeta>
|
||||
|
||||
// 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<void>[] = []
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PlayAppController> {
|
|||
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<PlayAppController> {
|
|||
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<PlayAppController> {
|
|||
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<PlayAppController> {
|
|||
})
|
||||
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<PlayAppController> {
|
|||
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<PlayAppController> {
|
|||
})
|
||||
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<PlayAppController> {
|
|||
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<PlayAppController> {
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue