/** * Offline Missile Asset & Data Baking Script. * * Extracts authentic Diablo II v1.13c missile DCC sprites from MPQ archives: * - Fire: Firebolt, Fireball, FireArrowExplode2 * - Cold: Icebolt, IceBlast, GlacialSpike, IceArrowExplode, FreezeExplodeCenter * - Lightning: ChargedBolt * - Necromancer: teethMissile, teethexplode, BoneSpear, BoneSpirit, BoneSpiritExplode, poisonNova * - Paladin: HolyBoltMissile, blessedhammer * - Amazon: SafeArrow, FireArrow, IceArrow, Arrow, LightningJavelin * * 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 { encodeRgbaPng } from './png.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 = [ // Fire spells & explosions { name: 'firebolt', celFile: 'Firebolt', animSpeed: 16 }, { name: 'fireball', celFile: 'Fireball', animSpeed: 16 }, { name: 'fireexplode', celFile: 'FireArrowExplode2', animSpeed: 16 }, // Ice spells & explosions { name: 'icebolt', celFile: 'Icebolt', animSpeed: 16 }, { name: 'iceblast', celFile: 'IceBlast', animSpeed: 16 }, { name: 'glacialspike', celFile: 'GlacialSpike', animSpeed: 16 }, { name: 'iceexplode', celFile: 'IceArrowExplode', animSpeed: 16 }, { name: 'freezeexplode', celFile: 'FreezeExplodeCenter', animSpeed: 16 }, { name: 'frozenorb', celFile: 'IceOrb', animSpeed: 16 }, { name: 'frozenorbexplode', celFile: 'IceOrbExplode', animSpeed: 16 }, // Lightning { name: 'chargedbolt', celFile: 'ChargedBolt', animSpeed: 16 }, // Necromancer { name: 'teeth', celFile: 'teethMissile', animSpeed: 16 }, { name: 'teethexplode', celFile: 'teethexplode', animSpeed: 16 }, { name: 'bonespear', celFile: 'BoneSpear', animSpeed: 16 }, { name: 'bonespirit', celFile: 'BoneSpirit', animSpeed: 16 }, { name: 'bonespiritexplode', celFile: 'BoneSpiritExplode', animSpeed: 16 }, { name: 'poisonnova', celFile: 'poisonNova', animSpeed: 16 }, // Paladin { name: 'holybolt', celFile: 'HolyBoltMissile', animSpeed: 16 }, { name: 'blessedhammer', celFile: 'blessedhammer', animSpeed: 16 }, // Amazon { name: 'magicarrow', celFile: 'SafeArrow', animSpeed: 16 }, { name: 'firearrow', celFile: 'FireArrow', animSpeed: 16 }, { name: 'icearrow', celFile: 'IceArrow', animSpeed: 16 }, { name: 'arrow', celFile: 'Arrow', animSpeed: 16, trans: 0 }, { name: 'lightningjavelin', celFile: 'LightningJavelin', animSpeed: 16 }, ] as const const PAGE_SIZE = 4096 /** * Shelf-pack all frames of a missile SpriteSheet into a 32-bit RGBA PNG buffer. * Enforces Diablo II 1.13c Trans=1 transparency: * - Pure black / background antialiasing fringe pixels (max(r,g,b) <= 4 or idx 172) are strictly transparent (alpha=0). * - Semi-dark edge pixels ramp smoothly so no black borders or dark smudge boxes appear over the ground. * - Core pixels remain solid (alpha=255) for vibrant spell rendering. */ export function packMissileSheetToRgbaPng( sheet: SpriteSheet, palette: Uint8Array, trans: number = 1, ): { png: Uint8Array width: number height: number groups: [number, number, number, number, number, number][][] } { const frames = sheet.groups.flatMap(g => g.frames) const widest = Math.max(1, ...frames.map(f => f.width)) const area = frames.reduce((sum, f) => sum + (f.width + 1) * (f.height + 1), 0) let pageWidth = Math.min(PAGE_SIZE, Math.max(widest + 2, 512)) while (pageWidth < PAGE_SIZE && pageWidth * pageWidth < area * 1.15) { pageWidth *= 2 } pageWidth = Math.min(PAGE_SIZE, pageWidth) let cursorX = 0 let shelfY = 0 let shelfHeight = 0 const placements: { x: number; y: number; width: number; height: number }[] = [] for (const frame of frames) { const w = Math.max(1, Math.min(frame.width, pageWidth)) const h = Math.max(1, Math.min(frame.height, PAGE_SIZE)) if (cursorX + w > pageWidth) { shelfY += shelfHeight + 1 shelfHeight = 0 cursorX = 0 } placements.push({ x: cursorX, y: shelfY, width: w, height: h }) cursorX += w + 1 shelfHeight = Math.max(shelfHeight, h) } const pageHeight = Math.max(1, shelfY + shelfHeight) const rgba = new Uint8Array(pageWidth * pageHeight * 4) let idx = 0 for (const frame of frames) { const place = placements[idx]! idx += 1 for (let row = 0; row < place.height; row += 1) { const fromRow = row * frame.width const toRow = (place.y + row) * pageWidth + place.x for (let col = 0; col < place.width; col += 1) { const srcAt = fromRow + col const dst = (toRow + col) * 4 if (frame.mask[srcAt] === 0) { rgba[dst] = 0 rgba[dst + 1] = 0 rgba[dst + 2] = 0 rgba[dst + 3] = 0 } else { const val = frame.indices[srcAt]! if (val === 0) { rgba[dst] = 0 rgba[dst + 1] = 0 rgba[dst + 2] = 0 rgba[dst + 3] = 0 } else { const r = palette[val * 3]! const g = palette[val * 3 + 1]! const b = palette[val * 3 + 2]! if (trans === 1) { const m = Math.max(r, g, b) if (m <= 4) { rgba[dst] = 0 rgba[dst + 1] = 0 rgba[dst + 2] = 0 rgba[dst + 3] = 0 } else { const alpha = Math.min(255, Math.round(((m - 4) / (60 - 4)) * 255)) rgba[dst] = r rgba[dst + 1] = g rgba[dst + 2] = b rgba[dst + 3] = alpha } } else { rgba[dst] = r rgba[dst + 1] = g rgba[dst + 2] = b rgba[dst + 3] = 255 } } } } } } const groups: [number, number, number, number, number, number][][] = [] let consumed = 0 for (const group of sheet.groups) { const gList: [number, number, number, number, number, number][] = [] for (let i = 0; i < group.frames.length; i += 1) { const p = placements[consumed]! const f = group.frames[i]! consumed += 1 gList.push([p.x, p.y, p.width, p.height, f.anchorX ?? 0, f.anchorY ?? 0]) } groups.push(gList) } const png = encodeRgbaPng({ width: pageWidth, height: pageHeight, pixels: rgba }) return { png, width: pageWidth, height: pageHeight, groups } } function formatMetaTs(meta: MissileMeta): string { const groupsStr = meta.groups .map( group => ` [\n${group.map(f => ` [${f.join(', ')}]`).join(',\n')}\n ]`, ) .join(',\n') return `{\n name: ${JSON.stringify(meta.name)},\n celFile: ${JSON.stringify(meta.celFile)},\n width: ${meta.width},\n height: ${meta.height},\n directions: ${meta.directions},\n framesPerDirection: ${meta.framesPerDirection},\n animSpeed: ${meta.animSpeed},\n groups: [\n${groupsStr}\n ],\n}` } export async function bakeMissiles(projectRoot: string = process.cwd()): Promise> { const archivePaths = [ join(projectRoot, 'samples', 'd2', 'Patch_D2.mpq'), join(projectRoot, 'samples', 'd2', 'd2exp.mpq'), join(projectRoot, 'samples', 'd2', 'd2data.mpq'), ] const archives: MpqArchive[] = [] for (const p of archivePaths) { try { archives.push(await MpqArchive.open(await fileSource(p))) } catch { // ignore } } async function readMpqFile(path: string): Promise { for (const a of archives) { const f = a.find(path) if (f) return a.read(f) } return null } const palBytes = await readMpqFile('data/global/palette/ACT1/pal.pl2') if (!palBytes) throw new Error('data/global/palette/ACT1/pal.pl2 not found in MPQ archives') const pl2 = decodePl2(palBytes) 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 raw = await readMpqFile(`data/global/missiles/${target.celFile}.dcc`) if (!raw) throw new Error(`Missile DCC not found: data/global/missiles/${target.celFile}.dcc`) const dcc = decodeDcc(raw) 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 trans = 'trans' in target ? (target as any).trans : 1 const packed = packMissileSheetToRgbaPng(sheet, pl2.rgb, trans) 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 metaExports = MISSILE_TARGETS.map(t => { const upper = t.name.toUpperCase() return `export const ${upper}_META: MissileMeta = ${formatMetaTs(metaRecord[t.name]!)}` }).join('\n\n') const mapEntries = MISSILE_TARGETS.map(t => { return ` '${t.name}': ${t.name.toUpperCase()}_META,` }).join('\n') 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: Readonly> = { 4: [ 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, ], 8: [ 4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, 5, 5, 5, 5, 1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 6, 2, 2, 2, 2, 2, 2, 2, 2, 7, 7, 7, 7, 7, 7, 7, 7, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, ], 16: [ 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, ], 32: [ 4, 16, 16, 8, 8, 17, 17, 0, 0, 18, 18, 9, 9, 19, 19, 5, 5, 20, 20, 10, 10, 21, 21, 1, 1, 22, 22, 11, 11, 23, 23, 6, 6, 24, 24, 12, 12, 25, 25, 2, 2, 26, 26, 13, 13, 27, 27, 7, 7, 28, 28, 14, 14, 29, 29, 3, 3, 30, 30, 15, 15, 31, 31, 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 const table = DIR64_TO_DCC[directions] if (table !== undefined) { return table[dir64] ?? 0 } return 0 } ${metaExports} export const MISSILE_METAS: Readonly> = Object.freeze({ ${mapEntries} }) ` const outTsPath = join(projectRoot, 'src', 'render', 'missiles-meta.ts') writeFileSync(outTsPath, metaTsContent, 'utf-8') console.log(`[Missile Packer] Wrote ${outTsPath}`) return metaRecord } // Direct invocation via tsx if (import.meta.url === `file://${process.argv[1]}`) { bakeMissiles() .then(() => { console.log('[Missile Packer] All missile assets baked successfully.') process.exit(0) }) .catch(err => { console.error('[Missile Packer] Failed:', err) process.exit(1) }) }