diablo2-web/scripts/pack-missiles.ts

746 lines
25 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, readFileSync } 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 },
{ name: 'blizzard1', celFile: 'icestormfallvar01', animSpeed: 16 },
{ name: 'blizzard2', celFile: 'icestormfallvar02', animSpeed: 16 },
{ name: 'blizzard3', celFile: 'Blizzard', animSpeed: 16 },
{ name: 'blizzardexplode1', celFile: 'icestormimpactvar01', animSpeed: 16 },
{ name: 'blizzardexplode2', celFile: 'icestormimpactvar02', 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 },
{ name: 'corpseexplosion', celFile: 'CorpseExplodeGuts', animSpeed: 16, trans: 1 },
{ name: 'poisoncorpseexplosion', celFile: 'CorpseExplodeGutsPoison', animSpeed: 16, trans: 1 },
{ name: 'poisonexplosioncloud', celFile: 'PoisonSparks', animSpeed: 16, trans: 1 },
{ name: 'poisonpuff', celFile: 'PoisonSmokePuff', animSpeed: 16, trans: 1 },
{ name: 'cursecast', celFile: 'CurseCast', animSpeed: 16, trans: 1 },
{ name: 'bonecast', celFile: 'BoneCast', animSpeed: 16, trans: 1 },
{ name: 'bonespeartrail', celFile: 'BoneSpearTrail', animSpeed: 16, trans: 1 },
{ name: 'revive', celFile: 'Revive', animSpeed: 16, trans: 1 },
// Paladin
{ name: 'holybolt', celFile: 'HolyBoltMissile', animSpeed: 16, trans: 1 },
{ name: 'blessedhammer', celFile: 'blessedhammer', animSpeed: 16, trans: 1 },
{ name: 'fistoftheheavensbolt', celFile: 'HolyBoltMissile', animSpeed: 16, trans: 1 },
{ name: 'foa', celFile: 'FOAorbiter', animSpeed: 16, trans: 1 },
{ name: 'blessedhammerex', celFile: 'blessedhammer', animSpeed: 16, trans: 1 },
{ name: 'blood1', celFile: 'BloodSmall01', animSpeed: 16, trans: 1 },
// 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 },
// Meteor (Skill 56)
{ name: 'meteor', celFile: 'Meteor', animSpeed: 16 },
{ name: 'meteortail', celFile: 'MeteorTail', animSpeed: 16 },
{ name: 'meteorcenter', celFile: 'MeteorTargetIndicator', animSpeed: 16 },
{ name: 'meteorexplode', celFile: 'ExpArrowExplode', animSpeed: 16 },
{ name: 'meteorfire', celFile: 'groundFireMedium', animSpeed: 16 },
{ name: 'firesmall', celFile: 'groundFireSmall', animSpeed: 16 },
// 18 Sorceress Skills Missiles (v1.13c Parity)
{ name: 'nova', celFile: 'ElectricNova', animSpeed: 16, trans: 1 },
{ name: 'lightningstrike', celFile: 'LightningStrike', animSpeed: 16, trans: 1 },
{ name: 'thunderstorm1', celFile: 'LightningboltBig', animSpeed: 16, trans: 1 },
{ name: 'sparkle', celFile: 'Gleam', animSpeed: 16, trans: 1 },
{ name: 'freezingarrowexp2', celFile: 'FreezeExplodeEjecta', animSpeed: 16, trans: 1 },
{ name: 'firewall', celFile: 'groundFireBig', animSpeed: 16, trans: 1 },
{ name: 'infernoflame1', celFile: 'Flamethrower', animSpeed: 16, trans: 1 },
{ name: 'infernoflame2', celFile: 'Flamethrower2', animSpeed: 16, trans: 1 },
{ name: 'frostnova', celFile: 'FrostNova', animSpeed: 16, trans: 1 },
] 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 || trans === 3) {
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'),
'/usr/local/google/home/taodao/d2-data/Patch_D2.mpq',
'/usr/local/google/home/taodao/d2-data/d2exp.mpq',
'/usr/local/google/home/taodao/d2-data/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`)
}
// Aliases for blizzard4 (from blizzard3) and blizzardexplode3 (from blizzardexplode2)
const copyAlias = (aliasName: string, sourceName: string) => {
const srcMeta = metaRecord[sourceName]
if (!srcMeta) return
const aliasMeta: MissileMeta = { ...srcMeta, name: aliasName }
const jsonStr = JSON.stringify(aliasMeta, null, 2)
writeFileSync(join(publicOutDir, `${aliasName}.json`), jsonStr, 'utf-8')
writeFileSync(join(packOutDir, `${aliasName}.json`), jsonStr, 'utf-8')
const srcPngBytes = readFileSync(join(publicOutDir, `${sourceName}.png`))
writeFileSync(join(publicOutDir, `${aliasName}.png`), srcPngBytes)
writeFileSync(join(packOutDir, `${aliasName}.png`), srcPngBytes)
console.log(`[Missile Packer] Aliased ${aliasName} -> ${sourceName}`)
}
copyAlias('blizzard4', 'blizzard3')
copyAlias('blizzardexplode3', 'blizzardexplode2')
copyAlias('meteortargetindicator', 'meteorcenter')
copyAlias('firemedium', 'meteorfire')
// 18 Sorceress Skills Missiles aliases
copyAlias('electricnova', 'nova')
copyAlias('chainlightning', 'lightningstrike')
copyAlias('lightninghit', 'lightningstrike')
copyAlias('thunderstorm', 'thunderstorm1')
copyAlias('lightningboltbig', 'thunderstorm1')
copyAlias('gleam', 'sparkle')
copyAlias('freezeexplodeejecta', 'freezingarrowexp2')
copyAlias('glacialspikeejecta', 'freezingarrowexp2')
copyAlias('blaze', 'firewall')
copyAlias('firewallmaker', 'firewall')
copyAlias('groundfirebig', 'firewall')
copyAlias('flamethrower', 'infernoflame1')
copyAlias('flamethrower2', 'infernoflame2')
copyAlias('coldunique', 'frostnova')
copyAlias('mephfrostnova', 'frostnova')
copyAlias('chillingarmorbolt', 'icebolt')
copyAlias('freezingarrowexp1', 'freezeexplode')
copyAlias('FreezingArrowExp1', 'freezeexplode')
// Necromancer aliases
copyAlias('revivesmall', 'revive')
copyAlias('revivemedium', 'revive')
copyAlias('revivelarge', 'revive')
copyAlias('bonespearexplode', 'teethexplode')
// 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 BLIZZARD4_META: MissileMeta = {
...BLIZZARD3_META,
name: 'blizzard4',
}
export const BLIZZARDEXPLODE3_META: MissileMeta = {
...BLIZZARDEXPLODE2_META,
name: 'blizzardexplode3',
}
export const HYDRA_META: MissileMeta = {
...FIREBOLT_META,
name: 'hydra',
}
export const HYDRAFIRE_META: MissileMeta = {
...FIREBOLT_META,
name: 'hydrafire',
}
export const METEORTARGETINDICATOR_META: MissileMeta = {
...METEORCENTER_META,
name: 'meteortargetindicator',
}
export const FIREMEDIUM_META: MissileMeta = {
...METEORFIRE_META,
name: 'firemedium',
}
export const ELECTRICNOVA_META: MissileMeta = {
...NOVA_META,
name: 'electricnova',
}
export const CHAINLIGHTNING_META: MissileMeta = {
...LIGHTNINGSTRIKE_META,
name: 'chainlightning',
}
export const LIGHTNINGHIT_META: MissileMeta = {
...LIGHTNINGSTRIKE_META,
name: 'lightninghit',
}
export const THUNDERSTORM_META: MissileMeta = {
...THUNDERSTORM1_META,
name: 'thunderstorm',
}
export const LIGHTNINGBOLTBIG_META: MissileMeta = {
...THUNDERSTORM1_META,
name: 'lightningboltbig',
}
export const GLEAM_META: MissileMeta = {
...SPARKLE_META,
name: 'gleam',
}
export const FREEZEEXPLODEEJECTA_META: MissileMeta = {
...FREEZINGARROWEXP2_META,
name: 'freezeexplodeejecta',
}
export const GLACIALSPIKEEJECTA_META: MissileMeta = {
...FREEZINGARROWEXP2_META,
name: 'glacialspikeejecta',
}
export const BLAZE_META: MissileMeta = {
...FIREWALL_META,
name: 'blaze',
}
export const FIREWALLMAKER_META: MissileMeta = {
...FIREWALL_META,
name: 'firewallmaker',
}
export const GROUNDFIREBIG_META: MissileMeta = {
...FIREWALL_META,
name: 'groundfirebig',
}
export const FLAMETHROWER_META: MissileMeta = {
...INFERNOFLAME1_META,
name: 'flamethrower',
}
export const FLAMETHROWER2_META: MissileMeta = {
...INFERNOFLAME2_META,
name: 'flamethrower2',
}
export const COLDUNIQUE_META: MissileMeta = {
...FROSTNOVA_META,
name: 'coldunique',
}
export const MEPHFROSTNOVA_META: MissileMeta = {
...FROSTNOVA_META,
name: 'mephfrostnova',
}
export const REVIVESMALL_META: MissileMeta = {
...REVIVE_META,
name: 'revivesmall',
}
export const REVIVEMEDIUM_META: MissileMeta = {
...REVIVE_META,
name: 'revivemedium',
}
export const REVIVELARGE_META: MissileMeta = {
...REVIVE_META,
name: 'revivelarge',
}
export const BONESPEAREXPLODE_META: MissileMeta = {
...TEETHEXPLODE_META,
name: 'bonespearexplode',
}
export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freeze({
${mapEntries}
'revivesmall': REVIVESMALL_META,
'revivemedium': REVIVEMEDIUM_META,
'revivelarge': REVIVELARGE_META,
'bonespearexplode': BONESPEAREXPLODE_META,
'chillingarmorbolt': ICEBOLT_META,
'freezingarrowexp1': FREEZEEXPLODE_META,
'FreezingArrowExp1': FREEZEEXPLODE_META,
'blizzard4': BLIZZARD4_META,
'blizzardexplode3': BLIZZARDEXPLODE3_META,
'hydra': HYDRA_META,
'hydrafire': HYDRAFIRE_META,
'meteortargetindicator': METEORTARGETINDICATOR_META,
'firemedium': FIREMEDIUM_META,
'electricnova': ELECTRICNOVA_META,
'chainlightning': CHAINLIGHTNING_META,
'lightninghit': LIGHTNINGHIT_META,
'thunderstorm': THUNDERSTORM_META,
'lightningboltbig': LIGHTNINGBOLTBIG_META,
'gleam': GLEAM_META,
'freezeexplodeejecta': FREEZEEXPLODEEJECTA_META,
'glacialspikeejecta': GLACIALSPIKEEJECTA_META,
'blaze': BLAZE_META,
'firewallmaker': FIREWALLMAKER_META,
'groundfirebig': GROUNDFIREBIG_META,
'flamethrower': FLAMETHROWER_META,
'flamethrower2': FLAMETHROWER2_META,
'coldunique': COLDUNIQUE_META,
'mephfrostnova': MEPHFROSTNOVA_META,
// Exact CelFile aliases for 18 Sorceress Skills Missiles & Ground Truth CelFiles
'ElectricNova': ELECTRICNOVA_META,
'LightningStrike': LIGHTNINGSTRIKE_META,
'ChainLightning': CHAINLIGHTNING_META,
'LightningHit': LIGHTNINGHIT_META,
'Thunderstorm': THUNDERSTORM_META,
'LightningboltBig': LIGHTNINGBOLTBIG_META,
'Gleam': GLEAM_META,
'FreezeExplodeEjecta': FREEZEEXPLODEEJECTA_META,
'GlacialSpikeEjecta': GLACIALSPIKEEJECTA_META,
'Blaze': BLAZE_META,
'FirewallMaker': FIREWALLMAKER_META,
'groundFireBig': GROUNDFIREBIG_META,
'Flamethrower': FLAMETHROWER_META,
'Flamethrower2': FLAMETHROWER2_META,
'ColdUnique': COLDUNIQUE_META,
'MephFrostNova': MEPHFROSTNOVA_META,
'FrostNova': FROSTNOVA_META,
// Base missiles CelFiles
'Firebolt': FIREBOLT_META,
'Fireball': FIREBALL_META,
'FireArrowExplode2': FIREEXPLODE_META,
'firearrowexplode2': FIREEXPLODE_META,
'Icebolt': ICEBOLT_META,
'IceBlast': ICEBLAST_META,
'GlacialSpike': GLACIALSPIKE_META,
'IceArrowExplode': ICEEXPLODE_META,
'icearrowexplode': ICEEXPLODE_META,
'FreezeExplodeCenter': FREEZEEXPLODE_META,
'freezeexplodecenter': FREEZEEXPLODE_META,
'IceOrb': FROZENORB_META,
'iceorb': FROZENORB_META,
'IceOrbExplode': FROZENORBEXPLODE_META,
'iceorbexplode': FROZENORBEXPLODE_META,
'Blizzard': BLIZZARD3_META,
'blizzard': BLIZZARD3_META,
'ChargedBolt': CHARGEDBOLT_META,
'teethMissile': TEETH_META,
'teethmissile': TEETH_META,
'BoneSpear': BONESPEAR_META,
'BoneSpirit': BONESPIRIT_META,
'BoneSpiritExplode': BONESPIRITEXPLODE_META,
'poisonNova': POISONNOVA_META,
'HolyBoltMissile': HOLYBOLT_META,
'holyboltmissile': HOLYBOLT_META,
'SafeArrow': MAGICARROW_META,
'safearrow': MAGICARROW_META,
'FireArrow': FIREARROW_META,
'IceArrow': ICEARROW_META,
'Arrow': ARROW_META,
'LightningJavelin': LIGHTNINGJAVELIN_META,
'Meteor': METEOR_META,
'MeteorTail': METEORTAIL_META,
'MeteorTargetIndicator': METEORTARGETINDICATOR_META,
'ExpArrowExplode': METEOREXPLODE_META,
'exparrowexplode': METEOREXPLODE_META,
'groundFireMedium': FIREMEDIUM_META,
'groundfiremedium': FIREMEDIUM_META,
'groundFireSmall': FIRESMALL_META,
'groundfiresmall': FIRESMALL_META,
// Necromancer CelFiles
'CorpseExplodeGuts': CORPSEEXPLOSION_META,
'corpseexplodeguts': CORPSEEXPLOSION_META,
'CorpseExplodeGutsPoison': POISONCORPSEEXPLOSION_META,
'corpseexplodegutspoison': POISONCORPSEEXPLOSION_META,
'PoisonSparks': POISONEXPLOSIONCLOUD_META,
'poisonsparks': POISONEXPLOSIONCLOUD_META,
'PoisonSmokePuff': POISONPUFF_META,
'poisonsmokepuff': POISONPUFF_META,
'CurseCast': CURSECAST_META,
'BoneCast': BONECAST_META,
'BoneSpearTrail': BONESPEARTRAIL_META,
'Revive': REVIVE_META,
// Paladin CelFiles
'FOAorbiter': FOA_META,
'foaorbiter': FOA_META,
'BloodSmall01': BLOOD1_META,
'bloodsmall01': BLOOD1_META,
})
`
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)
})
}