diablo2-web/scripts/pack-missiles.ts

214 lines
6.9 KiB
TypeScript

/**
* 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<Record<string, MissileMeta>> {
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<string, MissileMeta> = {}
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<Record<string, MissileMeta>> = {
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)
})
}