/** * 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 { 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 = {} 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) }) }