/** * 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) }) }