diablo2-web/scripts/pack-characters.ts

187 lines
6.5 KiB
TypeScript

/**
* Bake all 7 Diablo II character classes into web-native indexed PNG and raw R8 atlases (Schema 2).
*
* Classes:
* - so: Sorceress (法师 / 女巫)
* - am: Amazon (亚马逊)
* - ne: Necromancer (死灵法师)
* - pa: Paladin (圣骑士)
* - ba: Barbarian (野蛮人)
* - dz: Druid (德鲁伊)
* - ai: Assassin (刺客)
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { existsSync, 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 { MountedArchives } from '../src/mpq/mount.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
import { loadCharacterSheet, type CharacterSheet } from '../src/game/character.ts'
import type { SpriteSheet } from '../src/formats/sprite.ts'
import { packSheetToR8, type PackedEntityR8 } from './pack-entity-assets.ts'
import { encodeIndexedPng } from './png.ts'
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const PLAYER_MODES = ['wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd', 'tn', 'tw'] as const
const LOOPING_PLAYER_MODES = new Set(['nu', 'wl', 'rn', 'tn', 'tw'])
export const HERO_SPECS = [
{ token: 'so', code: 'sor', nameZh: '法师', nameEn: 'Sorceress' },
{ token: 'am', code: 'ama', nameZh: '亚马逊', nameEn: 'Amazon' },
{ token: 'ne', code: 'nec', nameZh: '死灵法师', nameEn: 'Necromancer' },
{ token: 'pa', code: 'pal', nameZh: '圣骑士', nameEn: 'Paladin' },
{ token: 'ba', code: 'bar', nameZh: '野蛮人', nameEn: 'Barbarian' },
{ token: 'dz', code: 'dru', nameZh: '德鲁伊', nameEn: 'Druid' },
{ token: 'ai', code: 'ass', nameZh: '刺客', nameEn: 'Assassin' },
] as const
export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'samples/d2-packs'): Promise<void> {
const t0 = performance.now()
const archives = new MountedArchives()
for (const name of MOUNTS) {
try {
const archive = await MpqArchive.open(await fileSource(join(archiveDir, name)))
archives.add(name, archive)
} catch (err) {
console.warn(`skip ${name}: ${(err as Error).message}`)
}
}
const animDataJsonPath = join(outDir, 'anim', 'animdata.json')
if (!existsSync(animDataJsonPath)) {
throw new Error(`AnimData JSON missing in ${join(outDir, 'anim')}`)
}
const animData = JSON.parse(readFileSync(animDataJsonPath, 'utf8'))
const globalEntityDir = join(outDir, 'entities')
await mkdir(globalEntityDir, { recursive: true })
// Read palettes for Act 1..5
const actPalettes: { act: number; palette: Uint8Array; dir: string }[] = []
for (let act = 1; act <= 5; act += 1) {
try {
const pl2 = decodePl2(await archives.read(`data\\global\\palette\\act${String(act)}\\pal.pl2`))
const actEntityDir = join(outDir, `act${String(act)}`, 'entities')
await mkdir(actEntityDir, { recursive: true })
actPalettes.push({ act, palette: pl2.rgb, dir: actEntityDir })
} catch (err) {
console.warn(`Cannot read palette for act ${act}: ${(err as Error).message}`)
}
}
for (const spec of HERO_SPECS) {
const { token, nameZh, nameEn } = spec
const charT0 = performance.now()
console.log(`Baking character: ${nameZh} (${nameEn}, token: ${token})...`)
const charSheets: CharacterSheet[] = []
const charClips: Record<string, {
group: number
directions: number
frames: number
speed: number
loop: boolean
events: { f: number; c: number }[]
}> = {}
let charGroupOffset = 0
let totalCharMembers = 0
for (const mode of PLAYER_MODES) {
let modeToLoad = mode
let cofName = `${token.toUpperCase()}${mode.toUpperCase()}HTH`
if (!animData.clips[cofName] && mode === 'a2') {
modeToLoad = 'a1'
cofName = `${token.toUpperCase()}A1HTH`
}
const sheet = await loadCharacterSheet(archives, token, modeToLoad, 'hth')
charSheets.push(sheet)
totalCharMembers += sheet.members.length
const animRecord = animData.clips[cofName]
const speed = animRecord?.speed ?? 256
const events = (animRecord?.events ?? []).map(([f, c]: [number, number]) => ({ f, c }))
charClips[mode] = {
group: charGroupOffset,
directions: sheet.directions,
frames: sheet.framesPerDirection,
speed,
loop: LOOPING_PLAYER_MODES.has(mode),
events,
}
charGroupOffset += sheet.sheet.groups.length
}
const charJoined: SpriteSheet = {
groups: charSheets.flatMap(s => s.sheet.groups),
width: null,
}
const packedCharR8 = packSheetToR8(charJoined)
// Save global R8 atlas
await writeFile(join(globalEntityDir, `char-${token}.r8`), packedCharR8.r8)
await writeFile(
join(globalEntityDir, `char-${token}.json`),
JSON.stringify({
schema: 2,
token,
file: `char-${token}.r8`,
format: 'r8',
width: packedCharR8.width,
height: packedCharR8.height,
directions: charClips.wl?.directions ?? 16,
walk: charClips.wl?.frames ?? 8,
stand: charClips.nu?.frames ?? 8,
walkOffset: 0,
standOffset: charClips.nu?.group ?? 16,
members: totalCharMembers,
clips: charClips,
groups: packedCharR8.groups,
}, null, 2)
)
// Save per-Act PNGs
for (const { palette, dir } of actPalettes) {
const charPng = encodeIndexedPng({
width: packedCharR8.width,
height: packedCharR8.height,
pixels: packedCharR8.r8,
palette,
transparentIndex: 0,
})
const charPngName = `char-${token}.png`
await writeFile(join(dir, charPngName), charPng)
await writeFile(
join(dir, `char-${token}.json`),
JSON.stringify({
schema: 2,
token,
file: charPngName,
width: packedCharR8.width,
height: packedCharR8.height,
directions: charClips.wl?.directions ?? 16,
clips: charClips,
walk: charClips.wl?.group ?? 0,
stand: charClips.nu?.group ?? 16,
members: totalCharMembers,
groups: packedCharR8.groups,
}, null, 2)
)
}
console.log(` ✓ ${nameZh} (${token}) finished in ${(performance.now() - charT0).toFixed(0)} ms (atlas: ${packedCharR8.width}x${packedCharR8.height})`)
}
console.log(`All 7 characters baked successfully in ${(performance.now() - t0).toFixed(0)} ms!`)
}
if (import.meta.url === `file://${process.argv[1]}`) {
bakeAllCharacters().catch(err => {
console.error('Failed to bake characters:', err)
process.exit(1)
})
}