578 lines
16 KiB
TypeScript
578 lines
16 KiB
TypeScript
/**
|
|
* Offline Cast Overlay Asset & Data Baking Script.
|
|
*
|
|
* Extracts authentic Diablo II v1.13c cast overlay DCC sprites from d2data.mpq:
|
|
* - FireCast_for_Sorceress.dcc (fire_cast_1, 14 frames, box: [-55, -127, 117, 164])
|
|
* - FireCast2.dcc (fire_cast_2, 16 frames, box: [-74, -89, 145, 133])
|
|
* - IceCastNew01.dcc (ice_cast_1, 15 frames, box: [-49, -90, 97, 55])
|
|
* - IceCastNew02.dcc (ice_cast_2, 15 frames, box: [-58, -100, 115, 123])
|
|
* - IceCastNew03.dcc (ice_cast_3, 16 frames, box: [-63, -113, 127, 148])
|
|
* - LightningCast.dcc (light_cast_1, 10 frames, box: [-79, -107, 164, 144])
|
|
* - LightningCastRunesFront.dcc (light_cast_2, 10 frames, box: [-73, -154, 147, 190])
|
|
* - Teleport.dcc (teleport, 18 frames, box: [-61, -108, 136, 154])
|
|
*
|
|
* Bakes them into indexed-color PNG atlases and JSON metadata under:
|
|
* - public/overlays/
|
|
* - samples/d2-packs/overlays/
|
|
*
|
|
* And generates typed metadata in src/render/overlays-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 OverlayFrameMeta {
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly w: number
|
|
readonly h: number
|
|
readonly anchorX: number
|
|
readonly anchorY: number
|
|
}
|
|
|
|
export type OverlayFrameTuple = readonly [
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
anchorX: number,
|
|
anchorY: number,
|
|
]
|
|
|
|
export interface OverlayBox {
|
|
readonly left: number
|
|
readonly top: number
|
|
readonly width: number
|
|
readonly height: number
|
|
}
|
|
|
|
export interface OverlayMeta {
|
|
readonly name: string
|
|
readonly celFile: string
|
|
readonly width: number
|
|
readonly height: number
|
|
readonly directions: number
|
|
readonly frames: number
|
|
readonly framesPerDirection: number
|
|
readonly animRate: number
|
|
readonly trans: number
|
|
readonly preDraw: boolean
|
|
readonly lightRadius: number
|
|
readonly lightColor: readonly [number, number, number]
|
|
readonly box: OverlayBox
|
|
readonly groups: readonly (readonly OverlayFrameTuple[])[]
|
|
}
|
|
|
|
export interface OverlayTarget {
|
|
readonly name: string
|
|
readonly celFile: string
|
|
readonly aliases?: readonly string[]
|
|
readonly animRate: number
|
|
readonly trans: number
|
|
readonly preDraw: boolean
|
|
readonly lightRadius: number
|
|
readonly lightColor: readonly [number, number, number]
|
|
}
|
|
|
|
export const OVERLAY_TARGETS: readonly OverlayTarget[] = [
|
|
{
|
|
name: 'fire_cast_1',
|
|
celFile: 'FireCast_for_Sorceress',
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 9,
|
|
lightColor: [255, 178, 64] as const,
|
|
},
|
|
{
|
|
name: 'fire_cast_2',
|
|
celFile: 'FireCast2',
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 9,
|
|
lightColor: [255, 178, 64] as const,
|
|
},
|
|
{
|
|
name: 'ice_cast_1',
|
|
celFile: 'IceCastNew01',
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 9,
|
|
lightColor: [81, 81, 255] as const,
|
|
},
|
|
{
|
|
name: 'ice_cast_2',
|
|
celFile: 'IceCastNew02',
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 9,
|
|
lightColor: [81, 81, 255] as const,
|
|
},
|
|
{
|
|
name: 'ice_cast_3',
|
|
celFile: 'IceCastNew03',
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 9,
|
|
lightColor: [81, 81, 255] as const,
|
|
},
|
|
{
|
|
name: 'light_cast_1',
|
|
celFile: 'LightningCast',
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 9,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'light_cast_2',
|
|
celFile: 'LightningCastRunesFront',
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 9,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'teleport',
|
|
celFile: 'Teleport',
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 5,
|
|
lightColor: [255, 255, 200] as const,
|
|
},
|
|
// 20 Authentic Diablo II v1.13c Paladin Aura Overlays
|
|
{
|
|
name: 'might',
|
|
celFile: 'AuraMightFront',
|
|
aliases: ['aura_might_front', 'aura_might'],
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'prayer',
|
|
celFile: 'AuraPrayerFront',
|
|
aliases: ['aura_prayer_front', 'aura_prayer'],
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'resist_fire',
|
|
celFile: 'AuraResistFireFront',
|
|
aliases: ['aura_resistfire'],
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
lightRadius: 6,
|
|
lightColor: [255, 193, 103] as const,
|
|
},
|
|
{
|
|
name: 'holy_fire',
|
|
celFile: 'AuraHolyFireFront',
|
|
aliases: ['aura_holyfire_front', 'aura_holyfire'],
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'thorns',
|
|
celFile: 'AuraThornsFront',
|
|
aliases: ['aura_thorns_front', 'aura_thorns'],
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'defiance',
|
|
celFile: 'AuraDefianceFront',
|
|
aliases: ['aura_defiance_front', 'aura_defiance'],
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'resist_cold',
|
|
celFile: 'AuraResistColdBack',
|
|
aliases: ['aura_resistcold'],
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
lightRadius: 6,
|
|
lightColor: [210, 210, 255] as const,
|
|
},
|
|
{
|
|
name: 'blessed_aim',
|
|
celFile: 'BlessedAim_front',
|
|
aliases: ['blessedaimfront', 'blessed_aim_front'],
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'cleansing',
|
|
celFile: 'Cleansing_front',
|
|
aliases: ['cleansingfront', 'cleansing_front'],
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'resist_lightning',
|
|
celFile: 'AuraResistLightningBack',
|
|
aliases: ['aura_resistlight'],
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
lightRadius: 6,
|
|
lightColor: [255, 255, 200] as const,
|
|
},
|
|
{
|
|
name: 'concentration',
|
|
celFile: 'Concentration_front',
|
|
aliases: ['concentrationfront', 'concentration_front'],
|
|
animRate: 3,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'holy_freeze',
|
|
celFile: 'HolyFreeze',
|
|
aliases: ['holyfreeze', 'holywind'],
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: true,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'vigor',
|
|
celFile: 'Stamina_front',
|
|
aliases: ['staminafront', 'stamina_front', 'stamina'],
|
|
animRate: 8,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'holy_shock',
|
|
celFile: 'HolyShock_front',
|
|
aliases: ['holyshockfront', 'holyshock_front', 'holyshock'],
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'sanctuary',
|
|
celFile: 'SanctuaryAura_Front',
|
|
aliases: ['sanctuaryfront', 'sanctuary_front'],
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'meditation',
|
|
celFile: 'Meditation',
|
|
aliases: ['meditationfront'],
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'fanaticism',
|
|
celFile: 'Fanatacism_Back',
|
|
aliases: ['fanaticismback', 'aura_fanatic', 'Fanatacism_Front'],
|
|
animRate: 6,
|
|
trans: 3,
|
|
preDraw: true,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'conviction',
|
|
celFile: 'Conviction',
|
|
aliases: ['convictionback'],
|
|
animRate: 6,
|
|
trans: 3,
|
|
preDraw: true,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'redemption',
|
|
celFile: 'Redemption',
|
|
aliases: ['redemptionback'],
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: true,
|
|
lightRadius: 0,
|
|
lightColor: [255, 255, 255] as const,
|
|
},
|
|
{
|
|
name: 'salvation',
|
|
celFile: 'AuraResistAllFront',
|
|
aliases: ['aura_resistall_front', 'aura_resistall'],
|
|
animRate: 16,
|
|
trans: 3,
|
|
preDraw: false,
|
|
lightRadius: 6,
|
|
lightColor: [211, 148, 255] as const,
|
|
},
|
|
] as const
|
|
|
|
export async function bakeOverlays(projectRoot: string = process.cwd()): Promise<Record<string, OverlayMeta>> {
|
|
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', 'overlays')
|
|
const packOutDir = join(projectRoot, 'samples', 'd2-packs', 'overlays')
|
|
mkdirSync(publicOutDir, { recursive: true })
|
|
mkdirSync(packOutDir, { recursive: true })
|
|
|
|
const metaRecord: Record<string, OverlayMeta> = {}
|
|
|
|
for (const target of OVERLAY_TARGETS) {
|
|
const f = mpq.find(`data/global/overlays/${target.celFile}.dcc`)
|
|
if (!f) throw new Error(`Overlay DCC not found: data/global/overlays/${target.celFile}.dcc`)
|
|
|
|
const dcc = decodeDcc(await mpq.read(f))
|
|
const firstDir = dcc.directions[0]
|
|
if (!firstDir) throw new Error(`Overlay DCC has no directions: ${target.celFile}`)
|
|
|
|
const box: OverlayBox = {
|
|
left: firstDir.box.left,
|
|
top: firstDir.box.top,
|
|
width: firstDir.box.width,
|
|
height: firstDir.box.height,
|
|
}
|
|
|
|
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: OverlayMeta = {
|
|
name: target.name,
|
|
celFile: target.celFile,
|
|
width: packed.width,
|
|
height: packed.height,
|
|
directions: dcc.directions.length,
|
|
frames: firstDir.frames.length,
|
|
framesPerDirection: firstDir.frames.length,
|
|
animRate: target.animRate,
|
|
trans: target.trans,
|
|
preDraw: target.preDraw,
|
|
lightRadius: target.lightRadius,
|
|
lightColor: target.lightColor,
|
|
box,
|
|
groups: packed.groups,
|
|
}
|
|
|
|
metaRecord[target.name] = meta
|
|
metaRecord[target.celFile] = meta
|
|
if (target.celFile.toLowerCase() !== target.celFile) {
|
|
metaRecord[target.celFile.toLowerCase()] = meta
|
|
}
|
|
|
|
const allKeys = new Set<string>([target.name, target.celFile, ...(target.aliases ?? [])])
|
|
for (const alias of target.aliases ?? []) {
|
|
metaRecord[alias] = meta
|
|
}
|
|
|
|
const jsonStr = JSON.stringify(meta, null, 2)
|
|
|
|
// Write primary name and aliases to both public/overlays/ and samples/d2-packs/overlays/
|
|
for (const fileKey of allKeys) {
|
|
writeFileSync(join(publicOutDir, `${fileKey}.png`), packed.png)
|
|
writeFileSync(join(publicOutDir, `${fileKey}.json`), jsonStr, 'utf-8')
|
|
writeFileSync(join(packOutDir, `${fileKey}.png`), packed.png)
|
|
writeFileSync(join(packOutDir, `${fileKey}.json`), jsonStr, 'utf-8')
|
|
}
|
|
|
|
console.log(
|
|
`[Overlay Packer] Baked ${target.name} (${target.celFile}.dcc): ` +
|
|
`${meta.frames} frames, box [${box.left}, ${box.top}, ${box.width}, ${box.height}], ` +
|
|
`${packed.width}x${packed.height}, ${packed.png.byteLength} bytes PNG`,
|
|
)
|
|
}
|
|
|
|
const TARGET_CONST_NAMES: Record<string, string> = {
|
|
fire_cast_1: 'FIRE_CAST_1_META',
|
|
fire_cast_2: 'FIRE_CAST_2_META',
|
|
ice_cast_1: 'ICE_CAST_1_META',
|
|
ice_cast_2: 'ICE_CAST_2_META',
|
|
ice_cast_3: 'ICE_CAST_3_META',
|
|
light_cast_1: 'LIGHT_CAST_1_META',
|
|
light_cast_2: 'LIGHT_CAST_2_META',
|
|
teleport: 'TELEPORT_META',
|
|
might: 'MIGHT_AURA_META',
|
|
prayer: 'PRAYER_AURA_META',
|
|
resist_fire: 'RESIST_FIRE_AURA_META',
|
|
holy_fire: 'HOLY_FIRE_AURA_META',
|
|
thorns: 'THORNS_AURA_META',
|
|
defiance: 'DEFIANCE_AURA_META',
|
|
resist_cold: 'RESIST_COLD_AURA_META',
|
|
blessed_aim: 'BLESSED_AIM_AURA_META',
|
|
cleansing: 'CLEANSING_AURA_META',
|
|
resist_lightning: 'RESIST_LIGHTNING_AURA_META',
|
|
concentration: 'CONCENTRATION_AURA_META',
|
|
holy_freeze: 'HOLY_FREEZE_AURA_META',
|
|
vigor: 'VIGOR_AURA_META',
|
|
holy_shock: 'HOLY_SHOCK_AURA_META',
|
|
sanctuary: 'SANCTUARY_AURA_META',
|
|
meditation: 'MEDITATION_AURA_META',
|
|
fanaticism: 'FANATICISM_AURA_META',
|
|
conviction: 'CONVICTION_AURA_META',
|
|
redemption: 'REDEMPTION_AURA_META',
|
|
salvation: 'SALVATION_AURA_META',
|
|
}
|
|
|
|
const individualConsts = OVERLAY_TARGETS.map(t => {
|
|
const constName = TARGET_CONST_NAMES[t.name] ?? `${t.name.toUpperCase()}_META`
|
|
return `export const ${constName}: OverlayMeta = ${JSON.stringify(metaRecord[t.name], null, 2)}`
|
|
}).join('\n\n')
|
|
|
|
const overlayMetasEntries: string[] = []
|
|
const seenKeys = new Set<string>()
|
|
for (const t of OVERLAY_TARGETS) {
|
|
const constName = TARGET_CONST_NAMES[t.name] ?? `${t.name.toUpperCase()}_META`
|
|
const keys = new Set<string>([t.name, t.celFile, ...(t.aliases ?? [])])
|
|
for (const k of keys) {
|
|
if (!seenKeys.has(k)) {
|
|
seenKeys.add(k)
|
|
overlayMetasEntries.push(` ${JSON.stringify(k)}: ${constName},`)
|
|
}
|
|
const lower = k.toLowerCase()
|
|
if (!seenKeys.has(lower)) {
|
|
seenKeys.add(lower)
|
|
overlayMetasEntries.push(` ${JSON.stringify(lower)}: ${constName},`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate src/render/overlays-meta.ts
|
|
const metaTsContent = `/**
|
|
* Authentic Diablo II v1.13c Cast & Aura Overlay Atlas Metadata.
|
|
* Auto-generated by scripts/pack-overlays.ts.
|
|
* Enforces Zero Runtime MPQ/DLL Architectural Invariant.
|
|
*/
|
|
|
|
export interface OverlayFrameMeta {
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly w: number
|
|
readonly h: number
|
|
readonly anchorX: number
|
|
readonly anchorY: number
|
|
}
|
|
|
|
export type OverlayFrameTuple = readonly [
|
|
x: number,
|
|
y: number,
|
|
width: number,
|
|
height: number,
|
|
anchorX: number,
|
|
anchorY: number,
|
|
]
|
|
|
|
export interface OverlayBox {
|
|
readonly left: number
|
|
readonly top: number
|
|
readonly width: number
|
|
readonly height: number
|
|
}
|
|
|
|
export interface OverlayMeta {
|
|
readonly name: string
|
|
readonly celFile: string
|
|
readonly width: number
|
|
readonly height: number
|
|
readonly directions: number
|
|
readonly frames: number
|
|
readonly framesPerDirection: number
|
|
readonly animRate: number
|
|
readonly trans: number
|
|
readonly preDraw: boolean
|
|
readonly lightRadius: number
|
|
readonly lightColor: readonly [number, number, number]
|
|
readonly box: OverlayBox
|
|
readonly groups: readonly (readonly OverlayFrameTuple[])[]
|
|
}
|
|
|
|
${individualConsts}
|
|
|
|
export const ALL_OVERLAY_NAMES: readonly string[] = [
|
|
${OVERLAY_TARGETS.map(t => ` ${JSON.stringify(t.name)},`).join('\n')}
|
|
]
|
|
|
|
export const OVERLAY_METAS: Readonly<Record<string, OverlayMeta>> = {
|
|
${overlayMetasEntries.join('\n')}
|
|
}
|
|
`
|
|
const metaTsPath = join(projectRoot, 'src', 'render', 'overlays-meta.ts')
|
|
writeFileSync(metaTsPath, metaTsContent, 'utf-8')
|
|
console.log(`[Overlay Packer] Wrote ${metaTsPath}`)
|
|
|
|
return metaRecord
|
|
}
|
|
|
|
// Execute standalone if called from CLI
|
|
if (process.argv[1]?.endsWith('pack-overlays.ts')) {
|
|
bakeOverlays().catch((err: unknown) => {
|
|
console.error('Bake overlays failed:', err)
|
|
process.exit(1)
|
|
})
|
|
}
|