diablo2-web/scripts/pack-overlays.ts

329 lines
9.7 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 const OVERLAY_TARGETS = [
{
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,
},
] 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
const jsonStr = JSON.stringify(meta, null, 2)
// Write primary name (e.g. fire_cast_1.png) and celFile alias (e.g. FireCast_for_Sorceress.png)
for (const fileKey of [target.name, target.celFile]) {
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`,
)
}
// Generate src/render/overlays-meta.ts
const metaTsContent = `/**
* Authentic Diablo II v1.13c Cast 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[])[]
}
export const FIRE_CAST_1_META: OverlayMeta = ${JSON.stringify(metaRecord['fire_cast_1'], null, 2)}
export const FIRE_CAST_2_META: OverlayMeta = ${JSON.stringify(metaRecord['fire_cast_2'], null, 2)}
export const ICE_CAST_1_META: OverlayMeta = ${JSON.stringify(metaRecord['ice_cast_1'], null, 2)}
export const ICE_CAST_2_META: OverlayMeta = ${JSON.stringify(metaRecord['ice_cast_2'], null, 2)}
export const ICE_CAST_3_META: OverlayMeta = ${JSON.stringify(metaRecord['ice_cast_3'], null, 2)}
export const LIGHT_CAST_1_META: OverlayMeta = ${JSON.stringify(metaRecord['light_cast_1'], null, 2)}
export const LIGHT_CAST_2_META: OverlayMeta = ${JSON.stringify(metaRecord['light_cast_2'], null, 2)}
export const TELEPORT_META: OverlayMeta = ${JSON.stringify(metaRecord['teleport'], null, 2)}
export const OVERLAY_METAS: Readonly<Record<string, OverlayMeta>> = {
fire_cast_1: FIRE_CAST_1_META,
FireCast_for_Sorceress: FIRE_CAST_1_META,
fire_cast_2: FIRE_CAST_2_META,
FireCast2: FIRE_CAST_2_META,
ice_cast_1: ICE_CAST_1_META,
IceCastNew01: ICE_CAST_1_META,
ice_cast_2: ICE_CAST_2_META,
IceCastNew02: ICE_CAST_2_META,
ice_cast_3: ICE_CAST_3_META,
IceCastNew03: ICE_CAST_3_META,
light_cast_1: LIGHT_CAST_1_META,
LightningCast: LIGHT_CAST_1_META,
light_cast_2: LIGHT_CAST_2_META,
LightningCastRunesFront: LIGHT_CAST_2_META,
teleport: TELEPORT_META,
Teleport: TELEPORT_META,
}
`
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)
})
}