diablo2-web/scripts/pack-missiles.ts

428 lines
14 KiB
TypeScript

/**
* 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<Record<string, MissileMeta>> {
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<Uint8Array | null> {
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<string, MissileMeta> = {}
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<Record<number, readonly number[]>> = {
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<Record<string, MissileMeta>> = 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)
})
}