214 lines
6.9 KiB
TypeScript
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)
|
|
})
|
|
}
|