diff --git a/AGENTS.md b/AGENTS.md index 0ba4c2d..a6afbc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,11 @@ - 所有暗金怪(`SuperUniques`)必须自动推导并解析其随从(Minions)所依赖的全部 DCC/DC6 动画与材质资源,一并纳入离线打包构建。 2. **原版文本数据转录不得篡改或简化**: - `SuperUniques.txt`、`MonStats.txt` 等元数据必须严格保留原版 Class 标识(如 `scarab2`, `zombie5`, `fetishshaman4`, `nihlathakboss` 等具体变体),禁止粗暴截断变体数字后缀或自编别名。 +3. **技能与投射物美术资源必须采用原版资源并离线烘焙 (Missile & Spell FX Parity)**: + - 严禁长期使用由代码绘制的方块、纯几何色块或纯程序化简易图形作为技能投射物/特效替代品。 + - 所有技能投射物(Missiles)、次生爆炸(Explosions)、状态光环与法术特效,必须严格以 1.13c 原版 MPQ(`d2data.mpq` / `d2exp.mpq` / `Patch_D2.mpq`)中由 `Missiles.txt` 的 `CelFile` 字段所引用的原版 DCC / DC6 动画文件为准(例如 `Firebolt.dcc`, `Fireball.dcc`, `FireArrowExplode2.dcc` 等)。 + - 必须通过离线解包烘焙脚本(如 `scripts/pack-missiles.ts`)结合章节调色板(`ACT1/pal.pl2`),将原版 DCC/DC6 的全部朝向(如 16 方向)与全帧序列预打包烘焙为 Web 原生 Sprite Atlas(图集)及对应元数据,供前端 WebGL 运行时直接高效索引与渲染。 + - 音效接入解耦:现阶段音效系统(`Sounds.txt` / WAV 音频资源)允许暂缓接入,但视觉动画必须具备 100% 像素级原版 DCC 贴图资产与帧率对齐。 --- diff --git a/package.json b/package.json index e1ffabe..7d7b75a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "pack:ui": "tsx scripts/pack-ui.ts", "pack:animdata": "tsx scripts/pack-animdata.ts", "pack:entities": "tsx scripts/pack-entity-assets.ts", + "pack:missiles": "tsx scripts/pack-missiles.ts", "verify:entity-packs": "tsx scripts/verify-entity-packs.ts", "verify:packs": "tsx scripts/verify-packs.ts", "verify:bundle-no-mpq": "tsx scripts/verify-bundle-no-mpq.ts", diff --git a/public/missiles/fireball.json b/public/missiles/fireball.json new file mode 100644 index 0000000..bc86e55 --- /dev/null +++ b/public/missiles/fireball.json @@ -0,0 +1,683 @@ +{ + "name": "fireball", + "celFile": "Fireball", + "width": 1024, + "height": 335, + "directions": 16, + "framesPerDirection": 5, + "animSpeed": 16, + "groups": [ + [ + [ + 0, + 0, + 75, + 48, + -19, + -74 + ], + [ + 76, + 0, + 75, + 48, + -19, + -74 + ], + [ + 152, + 0, + 75, + 48, + -19, + -74 + ], + [ + 228, + 0, + 75, + 48, + -19, + -74 + ], + [ + 304, + 0, + 75, + 48, + -19, + -74 + ] + ], + [ + [ + 380, + 0, + 79, + 52, + -19, + -62 + ], + [ + 460, + 0, + 79, + 52, + -19, + -62 + ], + [ + 540, + 0, + 79, + 52, + -19, + -62 + ], + [ + 620, + 0, + 79, + 52, + -19, + -62 + ], + [ + 700, + 0, + 79, + 52, + -19, + -62 + ] + ], + [ + [ + 780, + 0, + 81, + 52, + -63, + -61 + ], + [ + 862, + 0, + 81, + 52, + -63, + -61 + ], + [ + 0, + 53, + 81, + 52, + -63, + -61 + ], + [ + 82, + 53, + 81, + 52, + -63, + -61 + ], + [ + 164, + 53, + 81, + 52, + -63, + -61 + ] + ], + [ + [ + 246, + 53, + 74, + 48, + -55, + -74 + ], + [ + 321, + 53, + 74, + 48, + -55, + -74 + ], + [ + 396, + 53, + 74, + 48, + -55, + -74 + ], + [ + 471, + 53, + 74, + 48, + -55, + -74 + ], + [ + 546, + 53, + 74, + 48, + -55, + -74 + ] + ], + [ + [ + 621, + 53, + 34, + 55, + -17, + -81 + ], + [ + 656, + 53, + 34, + 55, + -17, + -81 + ], + [ + 691, + 53, + 34, + 55, + -17, + -81 + ], + [ + 726, + 53, + 34, + 55, + -17, + -81 + ], + [ + 761, + 53, + 34, + 55, + -17, + -81 + ] + ], + [ + [ + 796, + 53, + 94, + 40, + -20, + -65 + ], + [ + 891, + 53, + 94, + 40, + -20, + -65 + ], + [ + 0, + 109, + 94, + 40, + -20, + -65 + ], + [ + 95, + 109, + 94, + 40, + -20, + -65 + ], + [ + 190, + 109, + 94, + 40, + -20, + -65 + ] + ], + [ + [ + 285, + 109, + 40, + 61, + -20, + -63 + ], + [ + 326, + 109, + 40, + 61, + -20, + -63 + ], + [ + 367, + 109, + 40, + 61, + -20, + -63 + ], + [ + 408, + 109, + 40, + 61, + -20, + -63 + ], + [ + 449, + 109, + 40, + 61, + -20, + -63 + ] + ], + [ + [ + 490, + 109, + 91, + 37, + -72, + -63 + ], + [ + 582, + 109, + 91, + 37, + -72, + -63 + ], + [ + 674, + 109, + 91, + 37, + -72, + -63 + ], + [ + 766, + 109, + 91, + 37, + -72, + -63 + ], + [ + 858, + 109, + 91, + 37, + -72, + -63 + ] + ], + [ + [ + 950, + 109, + 53, + 53, + -17, + -79 + ], + [ + 0, + 171, + 53, + 53, + -17, + -79 + ], + [ + 54, + 171, + 53, + 53, + -17, + -79 + ], + [ + 108, + 171, + 53, + 53, + -17, + -79 + ], + [ + 162, + 171, + 53, + 53, + -17, + -79 + ] + ], + [ + [ + 216, + 171, + 88, + 41, + -19, + -68 + ], + [ + 305, + 171, + 88, + 41, + -19, + -68 + ], + [ + 394, + 171, + 88, + 41, + -19, + -68 + ], + [ + 483, + 171, + 88, + 41, + -19, + -68 + ], + [ + 572, + 171, + 88, + 41, + -19, + -68 + ] + ], + [ + [ + 661, + 171, + 89, + 45, + -19, + -62 + ], + [ + 751, + 171, + 89, + 45, + -19, + -62 + ], + [ + 841, + 171, + 89, + 45, + -19, + -62 + ], + [ + 931, + 171, + 89, + 45, + -19, + -62 + ], + [ + 0, + 225, + 89, + 45, + -19, + -62 + ] + ], + [ + [ + 90, + 225, + 59, + 57, + -17, + -62 + ], + [ + 150, + 225, + 59, + 57, + -17, + -62 + ], + [ + 210, + 225, + 59, + 57, + -17, + -62 + ], + [ + 270, + 225, + 59, + 57, + -17, + -62 + ], + [ + 330, + 225, + 59, + 57, + -17, + -62 + ] + ], + [ + [ + 390, + 225, + 59, + 59, + -42, + -62 + ], + [ + 450, + 225, + 59, + 59, + -42, + -62 + ], + [ + 510, + 225, + 59, + 59, + -42, + -62 + ], + [ + 570, + 225, + 59, + 59, + -42, + -62 + ], + [ + 630, + 225, + 59, + 59, + -42, + -62 + ] + ], + [ + [ + 690, + 225, + 92, + 43, + -72, + -61 + ], + [ + 783, + 225, + 92, + 43, + -72, + -61 + ], + [ + 876, + 225, + 92, + 43, + -72, + -61 + ], + [ + 0, + 285, + 92, + 43, + -72, + -61 + ], + [ + 93, + 285, + 92, + 43, + -72, + -61 + ] + ], + [ + [ + 186, + 285, + 85, + 43, + -65, + -69 + ], + [ + 272, + 285, + 85, + 43, + -65, + -69 + ], + [ + 358, + 285, + 85, + 43, + -65, + -69 + ], + [ + 444, + 285, + 85, + 43, + -65, + -69 + ], + [ + 530, + 285, + 85, + 43, + -65, + -69 + ] + ], + [ + [ + 616, + 285, + 53, + 50, + -35, + -77 + ], + [ + 670, + 285, + 53, + 50, + -35, + -77 + ], + [ + 724, + 285, + 53, + 50, + -35, + -77 + ], + [ + 778, + 285, + 53, + 50, + -35, + -77 + ], + [ + 832, + 285, + 53, + 50, + -35, + -77 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/fireball.png b/public/missiles/fireball.png new file mode 100644 index 0000000..3201007 Binary files /dev/null and b/public/missiles/fireball.png differ diff --git a/public/missiles/firebolt.json b/public/missiles/firebolt.json new file mode 100644 index 0000000..748527c --- /dev/null +++ b/public/missiles/firebolt.json @@ -0,0 +1,683 @@ +{ + "name": "firebolt", + "celFile": "Firebolt", + "width": 512, + "height": 439, + "directions": 16, + "framesPerDirection": 5, + "animSpeed": 16, + "groups": [ + [ + [ + 0, + 0, + 55, + 39, + -12, + -71 + ], + [ + 56, + 0, + 55, + 39, + -12, + -71 + ], + [ + 112, + 0, + 55, + 39, + -12, + -71 + ], + [ + 168, + 0, + 55, + 39, + -12, + -71 + ], + [ + 224, + 0, + 55, + 39, + -12, + -71 + ] + ], + [ + [ + 280, + 0, + 63, + 41, + -12, + -55 + ], + [ + 344, + 0, + 63, + 41, + -12, + -55 + ], + [ + 408, + 0, + 63, + 41, + -12, + -55 + ], + [ + 0, + 42, + 63, + 41, + -12, + -55 + ], + [ + 64, + 42, + 63, + 41, + -12, + -55 + ] + ], + [ + [ + 128, + 42, + 62, + 41, + -49, + -56 + ], + [ + 191, + 42, + 62, + 41, + -49, + -56 + ], + [ + 254, + 42, + 62, + 41, + -49, + -56 + ], + [ + 317, + 42, + 62, + 41, + -49, + -56 + ], + [ + 380, + 42, + 62, + 41, + -49, + -56 + ] + ], + [ + [ + 443, + 42, + 56, + 40, + -44, + -71 + ], + [ + 0, + 84, + 56, + 40, + -44, + -71 + ], + [ + 57, + 84, + 56, + 40, + -44, + -71 + ], + [ + 114, + 84, + 56, + 40, + -44, + -71 + ], + [ + 171, + 84, + 56, + 40, + -44, + -71 + ] + ], + [ + [ + 228, + 84, + 30, + 45, + -15, + -76 + ], + [ + 259, + 84, + 30, + 45, + -15, + -76 + ], + [ + 290, + 84, + 30, + 45, + -15, + -76 + ], + [ + 321, + 84, + 30, + 45, + -15, + -76 + ], + [ + 352, + 84, + 30, + 45, + -15, + -76 + ] + ], + [ + [ + 383, + 84, + 71, + 30, + -13, + -58 + ], + [ + 0, + 130, + 71, + 30, + -13, + -58 + ], + [ + 72, + 130, + 71, + 30, + -13, + -58 + ], + [ + 144, + 130, + 71, + 30, + -13, + -58 + ], + [ + 216, + 130, + 71, + 30, + -13, + -58 + ] + ], + [ + [ + 288, + 130, + 32, + 46, + -15, + -56 + ], + [ + 321, + 130, + 32, + 46, + -15, + -56 + ], + [ + 354, + 130, + 32, + 46, + -15, + -56 + ], + [ + 387, + 130, + 32, + 46, + -15, + -56 + ], + [ + 420, + 130, + 32, + 46, + -15, + -56 + ] + ], + [ + [ + 0, + 177, + 71, + 28, + -57, + -57 + ], + [ + 72, + 177, + 71, + 28, + -57, + -57 + ], + [ + 144, + 177, + 71, + 28, + -57, + -57 + ], + [ + 216, + 177, + 71, + 28, + -57, + -57 + ], + [ + 288, + 177, + 71, + 28, + -57, + -57 + ] + ], + [ + [ + 360, + 177, + 42, + 44, + -12, + -75 + ], + [ + 403, + 177, + 42, + 44, + -12, + -75 + ], + [ + 446, + 177, + 42, + 44, + -12, + -75 + ], + [ + 0, + 222, + 42, + 44, + -12, + -75 + ], + [ + 43, + 222, + 42, + 44, + -12, + -75 + ] + ], + [ + [ + 86, + 222, + 68, + 33, + -14, + -65 + ], + [ + 155, + 222, + 68, + 33, + -14, + -65 + ], + [ + 224, + 222, + 68, + 33, + -14, + -65 + ], + [ + 293, + 222, + 68, + 33, + -14, + -65 + ], + [ + 362, + 222, + 68, + 33, + -14, + -65 + ] + ], + [ + [ + 431, + 222, + 69, + 32, + -12, + -56 + ], + [ + 0, + 267, + 69, + 32, + -12, + -56 + ], + [ + 70, + 267, + 69, + 32, + -12, + -56 + ], + [ + 140, + 267, + 69, + 32, + -12, + -56 + ], + [ + 210, + 267, + 69, + 32, + -12, + -56 + ] + ], + [ + [ + 280, + 267, + 49, + 45, + -12, + -55 + ], + [ + 330, + 267, + 49, + 45, + -12, + -55 + ], + [ + 380, + 267, + 49, + 45, + -12, + -55 + ], + [ + 430, + 267, + 49, + 45, + -12, + -55 + ], + [ + 0, + 313, + 49, + 45, + -12, + -55 + ] + ], + [ + [ + 50, + 313, + 47, + 45, + -35, + -55 + ], + [ + 98, + 313, + 47, + 45, + -35, + -55 + ], + [ + 146, + 313, + 47, + 45, + -35, + -55 + ], + [ + 194, + 313, + 47, + 45, + -35, + -55 + ], + [ + 242, + 313, + 47, + 45, + -35, + -55 + ] + ], + [ + [ + 290, + 313, + 71, + 36, + -58, + -56 + ], + [ + 362, + 313, + 71, + 36, + -58, + -56 + ], + [ + 434, + 313, + 71, + 36, + -58, + -56 + ], + [ + 0, + 359, + 71, + 36, + -58, + -56 + ], + [ + 72, + 359, + 71, + 36, + -58, + -56 + ] + ], + [ + [ + 144, + 359, + 67, + 35, + -54, + -65 + ], + [ + 212, + 359, + 67, + 35, + -54, + -65 + ], + [ + 280, + 359, + 67, + 35, + -54, + -65 + ], + [ + 348, + 359, + 67, + 35, + -54, + -65 + ], + [ + 416, + 359, + 67, + 35, + -54, + -65 + ] + ], + [ + [ + 0, + 396, + 44, + 43, + -32, + -74 + ], + [ + 45, + 396, + 44, + 43, + -32, + -74 + ], + [ + 90, + 396, + 44, + 43, + -32, + -74 + ], + [ + 135, + 396, + 44, + 43, + -32, + -74 + ], + [ + 180, + 396, + 44, + 43, + -32, + -74 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/firebolt.png b/public/missiles/firebolt.png new file mode 100644 index 0000000..41ac3e6 Binary files /dev/null and b/public/missiles/firebolt.png differ diff --git a/public/missiles/fireexplode.json b/public/missiles/fireexplode.json new file mode 100644 index 0000000..aeda0c5 --- /dev/null +++ b/public/missiles/fireexplode.json @@ -0,0 +1,109 @@ +{ + "name": "fireexplode", + "celFile": "FireArrowExplode2", + "width": 512, + "height": 141, + "directions": 1, + "framesPerDirection": 12, + "animSpeed": 16, + "groups": [ + [ + [ + 0, + 0, + 78, + 70, + -39, + -81 + ], + [ + 79, + 0, + 78, + 70, + -39, + -81 + ], + [ + 158, + 0, + 78, + 70, + -39, + -81 + ], + [ + 237, + 0, + 78, + 70, + -39, + -81 + ], + [ + 316, + 0, + 78, + 70, + -39, + -81 + ], + [ + 395, + 0, + 78, + 70, + -39, + -81 + ], + [ + 0, + 71, + 78, + 70, + -39, + -81 + ], + [ + 79, + 71, + 78, + 70, + -39, + -81 + ], + [ + 158, + 71, + 78, + 70, + -39, + -81 + ], + [ + 237, + 71, + 78, + 70, + -39, + -81 + ], + [ + 316, + 71, + 78, + 70, + -39, + -81 + ], + [ + 395, + 71, + 78, + 70, + -39, + -81 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/fireexplode.png b/public/missiles/fireexplode.png new file mode 100644 index 0000000..c5d5b93 Binary files /dev/null and b/public/missiles/fireexplode.png differ diff --git a/scripts/pack-missiles.ts b/scripts/pack-missiles.ts new file mode 100644 index 0000000..270c88e --- /dev/null +++ b/scripts/pack-missiles.ts @@ -0,0 +1,213 @@ +/** + * Offline Missile Asset & Data Baking Script. + * + * Extracts authentic Diablo II v1.13c missile DCC sprites from d2data.mpq: + * - Firebolt.dcc (16 directions, 5 frames per direction) + * - Fireball.dcc (16 directions, 5 frames per direction) + * - FireArrowExplode2.dcc (1 direction, 12 frames) + * + * Bakes them into indexed-color PNG atlases and JSON metadata under: + * - public/missiles/ + * - samples/d2-packs/missiles/ + * + * And generates typed metadata in src/render/missiles-meta.ts. + * Enforces Zero Runtime MPQ/DLL Architectural Invariant. + */ + +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { decodeDcc } from '../src/formats/dcc.ts' +import { decodePl2 } from '../src/formats/pl2.ts' +import { packSheetToPng } from './pack-entity-assets.ts' +import type { SpriteFrame, SpriteSheet } from '../src/formats/sprite.ts' + +export interface MissileFrameMeta { + readonly x: number + readonly y: number + readonly w: number + readonly h: number + readonly anchorX: number + readonly anchorY: number +} + +export type MissileFrameTuple = readonly [ + x: number, + y: number, + width: number, + height: number, + anchorX: number, + anchorY: number, +] + +export interface MissileMeta { + readonly name: string + readonly celFile: string + readonly width: number + readonly height: number + readonly directions: number + readonly framesPerDirection: number + readonly animSpeed: number + readonly groups: readonly (readonly MissileFrameTuple[])[] +} + +const MISSILE_TARGETS = [ + { name: 'firebolt', celFile: 'Firebolt', animSpeed: 16 }, + { name: 'fireball', celFile: 'Fireball', animSpeed: 16 }, + { name: 'fireexplode', celFile: 'FireArrowExplode2', animSpeed: 16 }, +] as const + +export async function bakeMissiles(projectRoot: string = process.cwd()): Promise> { + const mpqPath = join(projectRoot, 'samples', 'd2', 'd2data.mpq') + const mpq = await MpqArchive.open(await fileSource(mpqPath)) + + const palFile = mpq.find('data/global/palette/ACT1/pal.pl2') + if (!palFile) throw new Error('data/global/palette/ACT1/pal.pl2 not found in d2data.mpq') + const pl2 = decodePl2(await mpq.read(palFile)) + + const publicOutDir = join(projectRoot, 'public', 'missiles') + const packOutDir = join(projectRoot, 'samples', 'd2-packs', 'missiles') + mkdirSync(publicOutDir, { recursive: true }) + mkdirSync(packOutDir, { recursive: true }) + + const metaRecord: Record = {} + + for (const target of MISSILE_TARGETS) { + const f = mpq.find(`data/global/missiles/${target.celFile}.dcc`) + if (!f) throw new Error(`Missile DCC not found: data/global/missiles/${target.celFile}.dcc`) + + const dcc = decodeDcc(await mpq.read(f)) + const groups = dcc.directions.map(dir => ({ + frames: dir.frames.map( + (frame): SpriteFrame => ({ + width: frame.frame.width, + height: frame.frame.height, + indices: frame.frame.indices, + mask: frame.frame.mask, + anchorX: dir.box.left, + anchorY: dir.box.top, + }), + ), + })) + + const sheet: SpriteSheet = { groups, width: null } + const packed = packSheetToPng(sheet, pl2.rgb) + + const meta: MissileMeta = { + name: target.name, + celFile: target.celFile, + width: packed.width, + height: packed.height, + directions: dcc.directions.length, + framesPerDirection: dcc.directions[0]?.frames.length ?? 0, + animSpeed: target.animSpeed, + groups: packed.groups, + } + + metaRecord[target.name] = meta + + // Write PNG and JSON to public/missiles/ and samples/d2-packs/missiles/ + const jsonStr = JSON.stringify(meta, null, 2) + writeFileSync(join(publicOutDir, `${target.name}.png`), packed.png) + writeFileSync(join(publicOutDir, `${target.name}.json`), jsonStr, 'utf-8') + writeFileSync(join(packOutDir, `${target.name}.png`), packed.png) + writeFileSync(join(packOutDir, `${target.name}.json`), jsonStr, 'utf-8') + + console.log(`[Missile Packer] Baked ${target.name} (${target.celFile}.dcc): ${packed.width}x${packed.height}, ${packed.png.byteLength} bytes PNG`) + } + + // Generate src/render/missiles-meta.ts + const metaTsContent = `/** + * Authentic Diablo II v1.13c Missile Atlas Metadata. + * Auto-generated by scripts/pack-missiles.ts. + * Enforces Zero Runtime MPQ/DLL Architectural Invariant. + */ + +export interface MissileFrameMeta { + readonly x: number + readonly y: number + readonly w: number + readonly h: number + readonly anchorX: number + readonly anchorY: number +} + +export type MissileFrameTuple = readonly [ + x: number, + y: number, + width: number, + height: number, + anchorX: number, + anchorY: number, +] + +export interface MissileMeta { + readonly name: string + readonly celFile: string + readonly width: number + readonly height: number + readonly directions: number + readonly framesPerDirection: number + readonly animSpeed: number + readonly groups: readonly (readonly MissileFrameTuple[])[] +} + +/** + * The engine's 64-direction space -> DCC direction tables. + * Ported from OpenDiablo2 d2fileformats/d2dcc/dcc_dir_lookup.go (Dir64ToDcc). + */ +const DIR64_TO_DCC_16: readonly number[] = [ + 4, 4, 8, 8, 8, 8, 0, 0, 0, 0, 9, 9, 9, 9, 5, 5, + 5, 5, 10, 10, 10, 10, 1, 1, 1, 1, 11, 11, 11, 11, 6, 6, + 6, 6, 12, 12, 12, 12, 2, 2, 2, 2, 13, 13, 13, 13, 7, 7, + 7, 7, 14, 14, 14, 14, 3, 3, 3, 3, 15, 15, 15, 15, 4, 4, +] + +/** + * Calculate DCC direction index for a missile based on 2D velocity vector. + * + * In Diablo II's 64-direction space: + * - South (0, +y) is dir 0 + * - West (-x, 0) is dir 16 + * - North (0, -y) is dir 32 + * - East (+x, 0) is dir 48 + */ +export function velocityToDccDirection(vx: number, vy: number, directions = 16): number { + if (directions <= 1) return 0 + const angle = Math.atan2(vy, vx) // -PI to PI + // Map angle so that South (PI/2) is 0, turning clockwise + const fraction = (angle - Math.PI / 2) / (2 * Math.PI) + const dir64 = Math.round((((fraction % 1) + 1) % 1) * 64) % 64 + if (directions === 16) { + return DIR64_TO_DCC_16[dir64] ?? 0 + } + return 0 +} + +export const FIREBOLT_META: MissileMeta = ${JSON.stringify(metaRecord['firebolt'], null, 2)} + +export const FIREBALL_META: MissileMeta = ${JSON.stringify(metaRecord['fireball'], null, 2)} + +export const FIREEXPLODE_META: MissileMeta = ${JSON.stringify(metaRecord['fireexplode'], null, 2)} + +export const MISSILE_METAS: Readonly> = { + firebolt: FIREBOLT_META, + fireball: FIREBALL_META, + fireexplode: FIREEXPLODE_META, +} +` + const metaTsPath = join(projectRoot, 'src', 'render', 'missiles-meta.ts') + writeFileSync(metaTsPath, metaTsContent, 'utf-8') + console.log(`[Missile Packer] Wrote ${metaTsPath}`) + + return metaRecord +} + +// Execute standalone if called from CLI +if (process.argv[1]?.endsWith('pack-missiles.ts')) { + bakeMissiles().catch((err: unknown) => { + console.error('Bake missiles failed:', err) + process.exit(1) + }) +} diff --git a/src/render/missiles-meta.ts b/src/render/missiles-meta.ts new file mode 100644 index 0000000..824506d --- /dev/null +++ b/src/render/missiles-meta.ts @@ -0,0 +1,1550 @@ +/** + * Authentic Diablo II v1.13c Missile Atlas Metadata. + * Auto-generated by scripts/pack-missiles.ts. + * Enforces Zero Runtime MPQ/DLL Architectural Invariant. + */ + +export interface MissileFrameMeta { + readonly x: number + readonly y: number + readonly w: number + readonly h: number + readonly anchorX: number + readonly anchorY: number +} + +export type MissileFrameTuple = readonly [ + x: number, + y: number, + width: number, + height: number, + anchorX: number, + anchorY: number, +] + +export interface MissileMeta { + readonly name: string + readonly celFile: string + readonly width: number + readonly height: number + readonly directions: number + readonly framesPerDirection: number + readonly animSpeed: number + readonly groups: readonly (readonly MissileFrameTuple[])[] +} + +/** + * The engine's 64-direction space -> DCC direction tables. + * Ported from OpenDiablo2 d2fileformats/d2dcc/dcc_dir_lookup.go (Dir64ToDcc). + */ +const DIR64_TO_DCC_16: readonly number[] = [ + 4, 4, 8, 8, 8, 8, 0, 0, 0, 0, 9, 9, 9, 9, 5, 5, + 5, 5, 10, 10, 10, 10, 1, 1, 1, 1, 11, 11, 11, 11, 6, 6, + 6, 6, 12, 12, 12, 12, 2, 2, 2, 2, 13, 13, 13, 13, 7, 7, + 7, 7, 14, 14, 14, 14, 3, 3, 3, 3, 15, 15, 15, 15, 4, 4, +] + +/** + * Calculate DCC direction index for a missile based on 2D velocity vector. + * + * In Diablo II's 64-direction space: + * - South (0, +y) is dir 0 + * - West (-x, 0) is dir 16 + * - North (0, -y) is dir 32 + * - East (+x, 0) is dir 48 + */ +export function velocityToDccDirection(vx: number, vy: number, directions = 16): number { + if (directions <= 1) return 0 + const angle = Math.atan2(vy, vx) // -PI to PI + // Map angle so that South (PI/2) is 0, turning clockwise + const fraction = (angle - Math.PI / 2) / (2 * Math.PI) + const dir64 = Math.round((((fraction % 1) + 1) % 1) * 64) % 64 + if (directions === 16) { + return DIR64_TO_DCC_16[dir64] ?? 0 + } + return 0 +} + +export const FIREBOLT_META: MissileMeta = { + "name": "firebolt", + "celFile": "Firebolt", + "width": 512, + "height": 439, + "directions": 16, + "framesPerDirection": 5, + "animSpeed": 16, + "groups": [ + [ + [ + 0, + 0, + 55, + 39, + -12, + -71 + ], + [ + 56, + 0, + 55, + 39, + -12, + -71 + ], + [ + 112, + 0, + 55, + 39, + -12, + -71 + ], + [ + 168, + 0, + 55, + 39, + -12, + -71 + ], + [ + 224, + 0, + 55, + 39, + -12, + -71 + ] + ], + [ + [ + 280, + 0, + 63, + 41, + -12, + -55 + ], + [ + 344, + 0, + 63, + 41, + -12, + -55 + ], + [ + 408, + 0, + 63, + 41, + -12, + -55 + ], + [ + 0, + 42, + 63, + 41, + -12, + -55 + ], + [ + 64, + 42, + 63, + 41, + -12, + -55 + ] + ], + [ + [ + 128, + 42, + 62, + 41, + -49, + -56 + ], + [ + 191, + 42, + 62, + 41, + -49, + -56 + ], + [ + 254, + 42, + 62, + 41, + -49, + -56 + ], + [ + 317, + 42, + 62, + 41, + -49, + -56 + ], + [ + 380, + 42, + 62, + 41, + -49, + -56 + ] + ], + [ + [ + 443, + 42, + 56, + 40, + -44, + -71 + ], + [ + 0, + 84, + 56, + 40, + -44, + -71 + ], + [ + 57, + 84, + 56, + 40, + -44, + -71 + ], + [ + 114, + 84, + 56, + 40, + -44, + -71 + ], + [ + 171, + 84, + 56, + 40, + -44, + -71 + ] + ], + [ + [ + 228, + 84, + 30, + 45, + -15, + -76 + ], + [ + 259, + 84, + 30, + 45, + -15, + -76 + ], + [ + 290, + 84, + 30, + 45, + -15, + -76 + ], + [ + 321, + 84, + 30, + 45, + -15, + -76 + ], + [ + 352, + 84, + 30, + 45, + -15, + -76 + ] + ], + [ + [ + 383, + 84, + 71, + 30, + -13, + -58 + ], + [ + 0, + 130, + 71, + 30, + -13, + -58 + ], + [ + 72, + 130, + 71, + 30, + -13, + -58 + ], + [ + 144, + 130, + 71, + 30, + -13, + -58 + ], + [ + 216, + 130, + 71, + 30, + -13, + -58 + ] + ], + [ + [ + 288, + 130, + 32, + 46, + -15, + -56 + ], + [ + 321, + 130, + 32, + 46, + -15, + -56 + ], + [ + 354, + 130, + 32, + 46, + -15, + -56 + ], + [ + 387, + 130, + 32, + 46, + -15, + -56 + ], + [ + 420, + 130, + 32, + 46, + -15, + -56 + ] + ], + [ + [ + 0, + 177, + 71, + 28, + -57, + -57 + ], + [ + 72, + 177, + 71, + 28, + -57, + -57 + ], + [ + 144, + 177, + 71, + 28, + -57, + -57 + ], + [ + 216, + 177, + 71, + 28, + -57, + -57 + ], + [ + 288, + 177, + 71, + 28, + -57, + -57 + ] + ], + [ + [ + 360, + 177, + 42, + 44, + -12, + -75 + ], + [ + 403, + 177, + 42, + 44, + -12, + -75 + ], + [ + 446, + 177, + 42, + 44, + -12, + -75 + ], + [ + 0, + 222, + 42, + 44, + -12, + -75 + ], + [ + 43, + 222, + 42, + 44, + -12, + -75 + ] + ], + [ + [ + 86, + 222, + 68, + 33, + -14, + -65 + ], + [ + 155, + 222, + 68, + 33, + -14, + -65 + ], + [ + 224, + 222, + 68, + 33, + -14, + -65 + ], + [ + 293, + 222, + 68, + 33, + -14, + -65 + ], + [ + 362, + 222, + 68, + 33, + -14, + -65 + ] + ], + [ + [ + 431, + 222, + 69, + 32, + -12, + -56 + ], + [ + 0, + 267, + 69, + 32, + -12, + -56 + ], + [ + 70, + 267, + 69, + 32, + -12, + -56 + ], + [ + 140, + 267, + 69, + 32, + -12, + -56 + ], + [ + 210, + 267, + 69, + 32, + -12, + -56 + ] + ], + [ + [ + 280, + 267, + 49, + 45, + -12, + -55 + ], + [ + 330, + 267, + 49, + 45, + -12, + -55 + ], + [ + 380, + 267, + 49, + 45, + -12, + -55 + ], + [ + 430, + 267, + 49, + 45, + -12, + -55 + ], + [ + 0, + 313, + 49, + 45, + -12, + -55 + ] + ], + [ + [ + 50, + 313, + 47, + 45, + -35, + -55 + ], + [ + 98, + 313, + 47, + 45, + -35, + -55 + ], + [ + 146, + 313, + 47, + 45, + -35, + -55 + ], + [ + 194, + 313, + 47, + 45, + -35, + -55 + ], + [ + 242, + 313, + 47, + 45, + -35, + -55 + ] + ], + [ + [ + 290, + 313, + 71, + 36, + -58, + -56 + ], + [ + 362, + 313, + 71, + 36, + -58, + -56 + ], + [ + 434, + 313, + 71, + 36, + -58, + -56 + ], + [ + 0, + 359, + 71, + 36, + -58, + -56 + ], + [ + 72, + 359, + 71, + 36, + -58, + -56 + ] + ], + [ + [ + 144, + 359, + 67, + 35, + -54, + -65 + ], + [ + 212, + 359, + 67, + 35, + -54, + -65 + ], + [ + 280, + 359, + 67, + 35, + -54, + -65 + ], + [ + 348, + 359, + 67, + 35, + -54, + -65 + ], + [ + 416, + 359, + 67, + 35, + -54, + -65 + ] + ], + [ + [ + 0, + 396, + 44, + 43, + -32, + -74 + ], + [ + 45, + 396, + 44, + 43, + -32, + -74 + ], + [ + 90, + 396, + 44, + 43, + -32, + -74 + ], + [ + 135, + 396, + 44, + 43, + -32, + -74 + ], + [ + 180, + 396, + 44, + 43, + -32, + -74 + ] + ] + ] +} + +export const FIREBALL_META: MissileMeta = { + "name": "fireball", + "celFile": "Fireball", + "width": 1024, + "height": 335, + "directions": 16, + "framesPerDirection": 5, + "animSpeed": 16, + "groups": [ + [ + [ + 0, + 0, + 75, + 48, + -19, + -74 + ], + [ + 76, + 0, + 75, + 48, + -19, + -74 + ], + [ + 152, + 0, + 75, + 48, + -19, + -74 + ], + [ + 228, + 0, + 75, + 48, + -19, + -74 + ], + [ + 304, + 0, + 75, + 48, + -19, + -74 + ] + ], + [ + [ + 380, + 0, + 79, + 52, + -19, + -62 + ], + [ + 460, + 0, + 79, + 52, + -19, + -62 + ], + [ + 540, + 0, + 79, + 52, + -19, + -62 + ], + [ + 620, + 0, + 79, + 52, + -19, + -62 + ], + [ + 700, + 0, + 79, + 52, + -19, + -62 + ] + ], + [ + [ + 780, + 0, + 81, + 52, + -63, + -61 + ], + [ + 862, + 0, + 81, + 52, + -63, + -61 + ], + [ + 0, + 53, + 81, + 52, + -63, + -61 + ], + [ + 82, + 53, + 81, + 52, + -63, + -61 + ], + [ + 164, + 53, + 81, + 52, + -63, + -61 + ] + ], + [ + [ + 246, + 53, + 74, + 48, + -55, + -74 + ], + [ + 321, + 53, + 74, + 48, + -55, + -74 + ], + [ + 396, + 53, + 74, + 48, + -55, + -74 + ], + [ + 471, + 53, + 74, + 48, + -55, + -74 + ], + [ + 546, + 53, + 74, + 48, + -55, + -74 + ] + ], + [ + [ + 621, + 53, + 34, + 55, + -17, + -81 + ], + [ + 656, + 53, + 34, + 55, + -17, + -81 + ], + [ + 691, + 53, + 34, + 55, + -17, + -81 + ], + [ + 726, + 53, + 34, + 55, + -17, + -81 + ], + [ + 761, + 53, + 34, + 55, + -17, + -81 + ] + ], + [ + [ + 796, + 53, + 94, + 40, + -20, + -65 + ], + [ + 891, + 53, + 94, + 40, + -20, + -65 + ], + [ + 0, + 109, + 94, + 40, + -20, + -65 + ], + [ + 95, + 109, + 94, + 40, + -20, + -65 + ], + [ + 190, + 109, + 94, + 40, + -20, + -65 + ] + ], + [ + [ + 285, + 109, + 40, + 61, + -20, + -63 + ], + [ + 326, + 109, + 40, + 61, + -20, + -63 + ], + [ + 367, + 109, + 40, + 61, + -20, + -63 + ], + [ + 408, + 109, + 40, + 61, + -20, + -63 + ], + [ + 449, + 109, + 40, + 61, + -20, + -63 + ] + ], + [ + [ + 490, + 109, + 91, + 37, + -72, + -63 + ], + [ + 582, + 109, + 91, + 37, + -72, + -63 + ], + [ + 674, + 109, + 91, + 37, + -72, + -63 + ], + [ + 766, + 109, + 91, + 37, + -72, + -63 + ], + [ + 858, + 109, + 91, + 37, + -72, + -63 + ] + ], + [ + [ + 950, + 109, + 53, + 53, + -17, + -79 + ], + [ + 0, + 171, + 53, + 53, + -17, + -79 + ], + [ + 54, + 171, + 53, + 53, + -17, + -79 + ], + [ + 108, + 171, + 53, + 53, + -17, + -79 + ], + [ + 162, + 171, + 53, + 53, + -17, + -79 + ] + ], + [ + [ + 216, + 171, + 88, + 41, + -19, + -68 + ], + [ + 305, + 171, + 88, + 41, + -19, + -68 + ], + [ + 394, + 171, + 88, + 41, + -19, + -68 + ], + [ + 483, + 171, + 88, + 41, + -19, + -68 + ], + [ + 572, + 171, + 88, + 41, + -19, + -68 + ] + ], + [ + [ + 661, + 171, + 89, + 45, + -19, + -62 + ], + [ + 751, + 171, + 89, + 45, + -19, + -62 + ], + [ + 841, + 171, + 89, + 45, + -19, + -62 + ], + [ + 931, + 171, + 89, + 45, + -19, + -62 + ], + [ + 0, + 225, + 89, + 45, + -19, + -62 + ] + ], + [ + [ + 90, + 225, + 59, + 57, + -17, + -62 + ], + [ + 150, + 225, + 59, + 57, + -17, + -62 + ], + [ + 210, + 225, + 59, + 57, + -17, + -62 + ], + [ + 270, + 225, + 59, + 57, + -17, + -62 + ], + [ + 330, + 225, + 59, + 57, + -17, + -62 + ] + ], + [ + [ + 390, + 225, + 59, + 59, + -42, + -62 + ], + [ + 450, + 225, + 59, + 59, + -42, + -62 + ], + [ + 510, + 225, + 59, + 59, + -42, + -62 + ], + [ + 570, + 225, + 59, + 59, + -42, + -62 + ], + [ + 630, + 225, + 59, + 59, + -42, + -62 + ] + ], + [ + [ + 690, + 225, + 92, + 43, + -72, + -61 + ], + [ + 783, + 225, + 92, + 43, + -72, + -61 + ], + [ + 876, + 225, + 92, + 43, + -72, + -61 + ], + [ + 0, + 285, + 92, + 43, + -72, + -61 + ], + [ + 93, + 285, + 92, + 43, + -72, + -61 + ] + ], + [ + [ + 186, + 285, + 85, + 43, + -65, + -69 + ], + [ + 272, + 285, + 85, + 43, + -65, + -69 + ], + [ + 358, + 285, + 85, + 43, + -65, + -69 + ], + [ + 444, + 285, + 85, + 43, + -65, + -69 + ], + [ + 530, + 285, + 85, + 43, + -65, + -69 + ] + ], + [ + [ + 616, + 285, + 53, + 50, + -35, + -77 + ], + [ + 670, + 285, + 53, + 50, + -35, + -77 + ], + [ + 724, + 285, + 53, + 50, + -35, + -77 + ], + [ + 778, + 285, + 53, + 50, + -35, + -77 + ], + [ + 832, + 285, + 53, + 50, + -35, + -77 + ] + ] + ] +} + +export const FIREEXPLODE_META: MissileMeta = { + "name": "fireexplode", + "celFile": "FireArrowExplode2", + "width": 512, + "height": 141, + "directions": 1, + "framesPerDirection": 12, + "animSpeed": 16, + "groups": [ + [ + [ + 0, + 0, + 78, + 70, + -39, + -81 + ], + [ + 79, + 0, + 78, + 70, + -39, + -81 + ], + [ + 158, + 0, + 78, + 70, + -39, + -81 + ], + [ + 237, + 0, + 78, + 70, + -39, + -81 + ], + [ + 316, + 0, + 78, + 70, + -39, + -81 + ], + [ + 395, + 0, + 78, + 70, + -39, + -81 + ], + [ + 0, + 71, + 78, + 70, + -39, + -81 + ], + [ + 79, + 71, + 78, + 70, + -39, + -81 + ], + [ + 158, + 71, + 78, + 70, + -39, + -81 + ], + [ + 237, + 71, + 78, + 70, + -39, + -81 + ], + [ + 316, + 71, + 78, + 70, + -39, + -81 + ], + [ + 395, + 71, + 78, + 70, + -39, + -81 + ] + ] + ] +} + +export const MISSILE_METAS: Readonly> = { + firebolt: FIREBOLT_META, + fireball: FIREBALL_META, + fireexplode: FIREEXPLODE_META, +} diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 6fd2a09..45d1e57 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -55,6 +55,8 @@ import { buildBounds, intersects, viewportRect } from '../render/cull.ts' import { RoofFadeController } from '../render/roofs.ts' import { facingToDirection } from '../game/character.ts' import type { LoadedMonsterArt } from '../game/monster-art.ts' +import type { MissileMeta } from '../render/missiles-meta.ts' +import { velocityToDccDirection } from '../render/missiles-meta.ts' import { ActorAnimator, type AnimClipMeta } from '../game/actor-animator.ts' import { resolveMonsterArtSpec } from '../game/monster-mapping.ts' import { ACT_NAMES_ZH, LEVEL_NAMES_ZH, getLevelNames, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts' @@ -2937,11 +2939,94 @@ function buildStreamingRooms( } /** - * Render a Diablo II 1.13c style Fire Bolt projectile: - * Luminous multi-layered fireball with white-hot core, fiery body, glowing halo, - * and a trailing plume of animated fire/ember particles extending behind the velocity vector. + * Packed missile animation asset loaded into GPU memory. */ -export function drawFireboltProjectile(renderer: SpriteRenderer, shot: Projectile): void { +export interface LoadedMissileArt { + readonly meta: MissileMeta + readonly handle: AtlasHandle + readonly frames: AtlasFrame[][] +} + +/** + * Load authentic 1.13c baked missile atlases from /missiles/*.png or packBase. + */ +export async function loadMissileArtMap( + renderer: SpriteRenderer, + packBase?: string, +): Promise> { + const map = new Map() + if (typeof fetch === 'undefined') return map + const targets = ['firebolt', 'fireball', 'fireexplode'] + const baseCandidates = [ + '/missiles', + packBase ? `${packBase}/missiles` : '', + 'samples/d2-packs/missiles', + ].filter(Boolean) + + await Promise.all( + targets.map(async name => { + for (const base of baseCandidates) { + try { + const [jsonResp, pngResp] = await Promise.all([ + fetch(`${base}/${name}.json`), + fetch(`${base}/${name}.png`), + ]) + if (jsonResp.ok && pngResp.ok) { + const meta = (await jsonResp.json()) as MissileMeta + const blob = await pngResp.blob() + const bitmap = await createImageBitmap(blob) + let handle: AtlasHandle + try { + handle = renderer.addAtlas(bitmap, meta.width, meta.height) + } finally { + bitmap.close() + } + const frames: AtlasFrame[][] = meta.groups.map(group => + group.map(([x, y, width, height, anchorX, anchorY]) => ({ + x, + y, + width, + height, + ...(anchorX !== undefined ? { anchorX } : {}), + ...(anchorY !== undefined ? { anchorY } : {}), + })), + ) + map.set(name, { meta, handle, frames }) + break + } + } catch { + // ignore candidate error and continue to next fallback path + } + } + }), + ) + return map +} + +/** + * Render a Diablo II 1.13c style Fire Bolt projectile: + * - Uses authentic baked Firebolt.dcc sprite atlas when available. + * - Gracefully falls back to procedural glowing fireball & trailing particles in test/offline environments. + */ +export function drawFireboltProjectile( + renderer: SpriteRenderer, + shot: Projectile, + missileArt?: LoadedMissileArt, +): void { + if (missileArt !== undefined) { + const dccDir = velocityToDccDirection(shot.vx, shot.vy, missileArt.meta.directions) + const frameCount = Math.max(1, missileArt.meta.framesPerDirection) + const frameIndex = Math.floor(((50 - shot.ttl) * missileArt.meta.animSpeed) / 16) % frameCount + const group = missileArt.frames[dccDir] ?? missileArt.frames[0] + const frame = group?.[frameIndex] ?? group?.[0] + if (frame !== undefined) { + const drawX = frame.anchorX !== undefined ? shot.x + frame.anchorX : shot.x - frame.width / 2 + const drawY = frame.anchorY !== undefined ? shot.y + frame.anchorY : shot.y - frame.height / 2 + renderer.draw(frame, drawX, drawY, { atlas: missileArt.handle }) + return + } + } + const x = shot.x const y = shot.y const vx = shot.vx @@ -2954,7 +3039,7 @@ export function drawFireboltProjectile(renderer: SpriteRenderer, shot: Projectil for (let i = 1; i <= 5; i += 1) { const dist = i * 4.2 // Sinusoidal organic wobble perpendicular to travel direction - const flicker = Math.sin((shot.ttl * 1.8) + i * 1.5) * (i * 0.75) + const flicker = Math.sin(shot.ttl * 1.8 + i * 1.5) * (i * 0.75) const px = x - dirX * dist - dirY * flicker const py = y - dirY * dist + dirX * flicker const size = Math.max(3, 8 - i) @@ -2963,7 +3048,7 @@ export function drawFireboltProjectile(renderer: SpriteRenderer, shot: Projectil else if (i === 2) color = [0.98, 0.52, 0.08, 0.62] else if (i === 3) color = [0.92, 0.32, 0.04, 0.46] else if (i === 4) color = [0.75, 0.18, 0.02, 0.32] - else color = [0.45, 0.10, 0.02, 0.18] + else color = [0.45, 0.1, 0.02, 0.18] renderer.drawSolid(px - size / 2, py - size / 2, size, size, color) } @@ -2971,7 +3056,7 @@ export function drawFireboltProjectile(renderer: SpriteRenderer, shot: Projectil renderer.drawSolid(x - 10, y - 10, 20, 20, [1.0, 0.35, 0.05, 0.22]) // 3. Medium bright flame body - renderer.drawSolid(x - 7, y - 7, 14, 14, [1.0, 0.62, 0.10, 0.55]) + renderer.drawSolid(x - 7, y - 7, 14, 14, [1.0, 0.62, 0.1, 0.55]) // 4. Inner intense fire renderer.drawSolid(x - 4, y - 4, 8, 8, [1.0, 0.88, 0.22, 0.88]) @@ -2982,9 +3067,27 @@ export function drawFireboltProjectile(renderer: SpriteRenderer, shot: Projectil /** * Render a Diablo II 1.13c style fire explosion (fireexplode): - * 12-frame expanding fireball burst with bright initial flash and radial fiery sparks. + * - Uses authentic baked FireArrowExplode2.dcc sprite atlas when available. + * - Falls back to procedural explosion in test/offline environments. */ -export function drawFireExplosion(renderer: SpriteRenderer, exp: ActiveExplosion): void { +export function drawFireExplosion( + renderer: SpriteRenderer, + exp: ActiveExplosion, + explosionArt?: LoadedMissileArt, +): void { + if (explosionArt !== undefined) { + const frameCount = Math.max(1, explosionArt.meta.framesPerDirection) + const frameIndex = Math.min(Math.max(0, exp.frame), frameCount - 1) + const group = explosionArt.frames[0] + const frame = group?.[frameIndex] ?? group?.[0] + if (frame !== undefined) { + const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2 + const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2 + renderer.draw(frame, drawX, drawY, { atlas: explosionArt.handle }) + return + } + } + const progress = Math.min(1, Math.max(0, exp.frame / exp.maxFrames)) const inv = 1 - progress const x = exp.x @@ -3150,6 +3253,10 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st let character: LoadedCharacter | null = null let monsterArtMap = new Map() + let missileArtMap = new Map() + void loadMissileArtMap(renderer, packEntityBase).then(map => { + missileArtMap = map + }) let monsterWalkFrame = 0 const playerAnimator = new ActorAnimator() @@ -4182,7 +4289,9 @@ if (typeof window !== 'undefined') { for (const shot of engine.projectiles) { pushEntity(shot.x, shot.y, () => { if (shot.skillId === '36' || shot.skillId === 'firebolt' || shot.missileType === 'firebolt') { - drawFireboltProjectile(renderer, shot) + drawFireboltProjectile(renderer, shot, missileArtMap.get('firebolt')) + } else if (shot.skillId === '47' || shot.skillId === 'fireball' || shot.missileType === 'fireball') { + drawFireboltProjectile(renderer, shot, missileArtMap.get('fireball')) } else { renderer.drawSolid(shot.x - 4, shot.y - 4, 8, 8, [0.9, 0.9, 0.2, 1]) } @@ -4191,7 +4300,7 @@ if (typeof window !== 'undefined') { for (const exp of engine.explosions) { pushEntity(exp.x, exp.y, () => { - drawFireExplosion(renderer, exp) + drawFireExplosion(renderer, exp, missileArtMap.get('fireexplode')) }) } diff --git a/tests/firebolt-missile-113c.test.ts b/tests/firebolt-missile-113c.test.ts index 758f02d..181982f 100644 --- a/tests/firebolt-missile-113c.test.ts +++ b/tests/firebolt-missile-113c.test.ts @@ -22,10 +22,11 @@ import { tickProjectiles, type Projectile, } from '../src/game/skills.ts' -import { getSkillRange, drawFireboltProjectile, drawFireExplosion } from '../src/scene/act-scene.ts' +import { getSkillRange, drawFireboltProjectile, drawFireExplosion, type LoadedMissileArt } from '../src/scene/act-scene.ts' import { GameEngine } from '../src/game/engine.ts' import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts' -import { SpriteRenderer } from '../src/render/renderer.ts' +import { SpriteRenderer, type AtlasHandle } from '../src/render/renderer.ts' +import { FIREBOLT_META, FIREEXPLODE_META, velocityToDccDirection } from '../src/render/missiles-meta.ts' describe('Issue #385 — Fire Bolt (Skill #36) & Missiles.txt Ground Truth', () => { const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt') @@ -368,4 +369,117 @@ describe('Issue #385 — Fire Bolt (Skill #36) & Missiles.txt Ground Truth', () // 2 blast layers + 1 flash core + 8 radial sparks = 11 quads expect(drawnQuads.length).toBe(11) }) + + it('10. Verifies offline baked authentic missile DCC assets and metadata', () => { + const publicFireboltPng = path.resolve(__dirname, '../public/missiles/firebolt.png') + const publicFireboltJson = path.resolve(__dirname, '../public/missiles/firebolt.json') + const publicExplodePng = path.resolve(__dirname, '../public/missiles/fireexplode.png') + const publicExplodeJson = path.resolve(__dirname, '../public/missiles/fireexplode.json') + + expect(fs.existsSync(publicFireboltPng)).toBe(true) + expect(fs.statSync(publicFireboltPng).size).toBeGreaterThan(50000) + + expect(fs.existsSync(publicFireboltJson)).toBe(true) + const fireboltJson = JSON.parse(fs.readFileSync(publicFireboltJson, 'utf-8')) + expect(fireboltJson.name).toBe('firebolt') + expect(fireboltJson.celFile).toBe('Firebolt') + expect(fireboltJson.directions).toBe(16) + expect(fireboltJson.framesPerDirection).toBe(5) + expect(fireboltJson.groups.length).toBe(16) + expect(fireboltJson.groups[0].length).toBe(5) + + expect(fs.existsSync(publicExplodePng)).toBe(true) + expect(fs.statSync(publicExplodePng).size).toBeGreaterThan(10000) + + expect(fs.existsSync(publicExplodeJson)).toBe(true) + const explodeJson = JSON.parse(fs.readFileSync(publicExplodeJson, 'utf-8')) + expect(explodeJson.name).toBe('fireexplode') + expect(explodeJson.celFile).toBe('FireArrowExplode2') + expect(explodeJson.directions).toBe(1) + expect(explodeJson.framesPerDirection).toBe(12) + expect(explodeJson.groups[0].length).toBe(12) + + // Check samples/d2-packs mirrors + expect(fs.existsSync(path.resolve(__dirname, '../samples/d2-packs/missiles/firebolt.png'))).toBe(true) + expect(fs.existsSync(path.resolve(__dirname, '../samples/d2-packs/missiles/fireexplode.png'))).toBe(true) + }) + + it('11. Renders authentic baked DCC frames with correct direction mapping and sprite-space anchors', () => { + const drawnSprites: any[] = [] + const mockAtlasHandle = { + texture: {} as WebGLTexture, + width: 512, + height: 439, + } as AtlasHandle + + const mockRenderer = { + draw(frame: any, x: number, y: number, options?: any) { + drawnSprites.push({ frame, x, y, options }) + }, + drawSolid() {}, + } as unknown as SpriteRenderer + + // Build loaded art from metadata + const fireboltFrames = FIREBOLT_META.groups.map(group => + group.map(([x, y, width, height, anchorX, anchorY]) => ({ + x, y, width, height, anchorX, anchorY, + })) + ) + const fireboltArt: LoadedMissileArt = { + meta: FIREBOLT_META, + handle: mockAtlasHandle, + frames: fireboltFrames, + } + + // Shot moving East (+x): vx = 20, vy = 0 + const dccEast = velocityToDccDirection(20, 0, 16) + expect(dccEast).toBeDefined() + + const shot: Projectile = { + skillId: '36', + missileType: 'firebolt', + x: 300, + y: 200, + vx: 20, + vy: 0, + damage: 16, + ttl: 48, + fromPlayer: true, + } + + drawFireboltProjectile(mockRenderer, shot, fireboltArt) + expect(drawnSprites.length).toBe(1) + const drawn = drawnSprites[0]! + expect(drawn.options.atlas).toBe(mockAtlasHandle) + // Verify sprite is positioned with anchor offset: x + anchorX, y + anchorY + expect(drawn.x).toBe(300 + drawn.frame.anchorX) + expect(drawn.y).toBe(200 + drawn.frame.anchorY) + + // Verify explosion DCC rendering + const explodeFrames = FIREEXPLODE_META.groups.map(group => + group.map(([x, y, width, height, anchorX, anchorY]) => ({ + x, y, width, height, anchorX, anchorY, + })) + ) + const explodeArt: LoadedMissileArt = { + meta: FIREEXPLODE_META, + handle: mockAtlasHandle, + frames: explodeFrames, + } + + drawnSprites.length = 0 + drawFireExplosion(mockRenderer, { + missileType: 'fireexplode', + x: 350, + y: 250, + frame: 3, + maxFrames: 12, + }, explodeArt) + + expect(drawnSprites.length).toBe(1) + const expDrawn = drawnSprites[0]! + expect(expDrawn.options.atlas).toBe(mockAtlasHandle) + expect(expDrawn.x).toBe(350 + expDrawn.frame.anchorX) + expect(expDrawn.y).toBe(250 + expDrawn.frame.anchorY) + }) })