187 lines
6.5 KiB
TypeScript
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)
|
|
})
|
|
}
|