523 lines
18 KiB
TypeScript
523 lines
18 KiB
TypeScript
/**
|
|
* Bake character and monster animations into web-native indexed PNG and raw R8 atlases (Schema 2).
|
|
*
|
|
* Pre-packing entity animations into `samples/d2-packs/entities/` and `samples/d2-packs/act<N>/entities/`
|
|
* eliminates runtime HTTP Range requests to `d2char.mpq` / `d2data.mpq` / `d2exp.mpq`
|
|
* and removes JS-side DCC/COF decompression & compositing during level boot.
|
|
*
|
|
* Schema 2 provides:
|
|
* - Multi-mode animations for player (11 modes: wl, nu, rn, a1, a2, sc, gh, dt, dd, tn, tw).
|
|
* - Multi-mode animations for monsters (filtered by MonStats2.txt m* bits).
|
|
* - Exact 1.13c fractional speeds and chronological frame events from AnimData.D2.
|
|
* - Per-frame 6-tuples [x, y, w, h, ax, ay] with exact sprite-space anchor offsets.
|
|
* - Backward-compatibility aliases (walk, stand, walkOffset, standOffset).
|
|
*/
|
|
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 { compositeMonsterAnimation, type MonsterAnimationSheet } from '../src/game/monster-art.ts'
|
|
import { ACT_MONSTER_SPECS, MONSTER_ART_MAP, resolveMonsterArtSpec } from '../src/game/monster-mapping.ts'
|
|
import {
|
|
CANONICAL_SUPER_UNIQUE_LANDMARKS,
|
|
CANONICAL_SUPERUNIQUES_TABLE,
|
|
readSuperUniques,
|
|
} from '../src/game/monsters.ts'
|
|
import type { SpriteSheet } from '../src/formats/sprite.ts'
|
|
import { encodeIndexedPng } from './png.ts'
|
|
|
|
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
|
|
const PAGE_SIZE = 4096
|
|
|
|
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'])
|
|
|
|
const TARGET_MONSTER_MODES = ['wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd'] as const
|
|
const LOOPING_MONSTER_MODES = new Set(['nu', 'wl', 'rn'])
|
|
|
|
function findOpaqueBlackIndex(palette: Uint8Array): number {
|
|
let bestIdx = 1
|
|
let bestDist = Infinity
|
|
for (let i = 1; i < 256; i += 1) {
|
|
const r = palette[i * 3]!
|
|
const g = palette[i * 3 + 1]!
|
|
const b = palette[i * 3 + 2]!
|
|
const dist = r * r + g * g + b * b
|
|
if (dist < bestDist) {
|
|
bestDist = dist
|
|
bestIdx = i
|
|
if (dist === 0) break
|
|
}
|
|
}
|
|
return bestIdx
|
|
}
|
|
|
|
export interface PackedEntitySheet {
|
|
readonly png: Uint8Array
|
|
readonly width: number
|
|
readonly height: number
|
|
readonly groups: [number, number, number, number, number, number][][]
|
|
}
|
|
|
|
export interface PackedEntityR8 {
|
|
readonly r8: Uint8Array
|
|
readonly width: number
|
|
readonly height: number
|
|
readonly groups: [number, number, number, number, number, number][][]
|
|
}
|
|
|
|
/**
|
|
* Shelf-pack all frames of a SpriteSheet into a single raw 8-bit index buffer (`gl.R8`).
|
|
* Index 0 represents transparency; opaque pixels with original index 0 are remapped to 172 (`rgb(4,4,4)`).
|
|
* Each frame tuple stores [x, y, width, height, anchorX, anchorY] (Schema 2).
|
|
*/
|
|
export function packSheetToR8(sheet: SpriteSheet, opaqueBlack = 172): PackedEntityR8 {
|
|
const frames = sheet.groups.flatMap(g => g.frames)
|
|
const widest = Math.max(1, ...frames.map(f => f.width))
|
|
const area = frames.reduce((sum, f) => sum + (f.width + 1) * (f.height + 1), 0)
|
|
let pageWidth = Math.min(PAGE_SIZE, Math.max(widest + 2, 512))
|
|
while (pageWidth < PAGE_SIZE && pageWidth * pageWidth < area * 1.15) {
|
|
pageWidth *= 2
|
|
}
|
|
pageWidth = Math.min(PAGE_SIZE, pageWidth)
|
|
|
|
let cursorX = 0
|
|
let shelfY = 0
|
|
let shelfHeight = 0
|
|
const placements: { x: number; y: number; width: number; height: number }[] = []
|
|
|
|
for (const frame of frames) {
|
|
const w = Math.max(1, Math.min(frame.width, pageWidth))
|
|
const h = Math.max(1, Math.min(frame.height, PAGE_SIZE))
|
|
if (cursorX + w > pageWidth) {
|
|
shelfY += shelfHeight + 1
|
|
shelfHeight = 0
|
|
cursorX = 0
|
|
}
|
|
placements.push({ x: cursorX, y: shelfY, width: w, height: h })
|
|
cursorX += w + 1
|
|
shelfHeight = Math.max(shelfHeight, h)
|
|
}
|
|
|
|
const pageHeight = Math.max(1, shelfY + shelfHeight)
|
|
const r8 = new Uint8Array(pageWidth * pageHeight)
|
|
|
|
let idx = 0
|
|
for (const frame of frames) {
|
|
const place = placements[idx]!
|
|
idx += 1
|
|
for (let row = 0; row < place.height; row += 1) {
|
|
const fromRow = row * frame.width
|
|
const toRow = (place.y + row) * pageWidth + place.x
|
|
for (let col = 0; col < place.width; col += 1) {
|
|
const srcAt = fromRow + col
|
|
if (frame.mask[srcAt] === 0) {
|
|
r8[toRow + col] = 0
|
|
} else {
|
|
const val = frame.indices[srcAt]!
|
|
r8[toRow + col] = val === 0 ? opaqueBlack : val
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const groups: [number, number, number, number, number, number][][] = []
|
|
let consumed = 0
|
|
for (const group of sheet.groups) {
|
|
const gList: [number, number, number, number, number, number][] = []
|
|
for (let i = 0; i < group.frames.length; i += 1) {
|
|
const p = placements[consumed]!
|
|
const f = group.frames[i]!
|
|
consumed += 1
|
|
gList.push([p.x, p.y, p.width, p.height, f.anchorX ?? 0, f.anchorY ?? 0])
|
|
}
|
|
groups.push(gList)
|
|
}
|
|
|
|
return { r8, width: pageWidth, height: pageHeight, groups }
|
|
}
|
|
|
|
/**
|
|
* Shelf-pack all frames of a SpriteSheet into a single indexed PNG buffer.
|
|
*/
|
|
export function packSheetToPng(sheet: SpriteSheet, palette: Uint8Array): PackedEntitySheet {
|
|
const opaqueBlack = findOpaqueBlackIndex(palette)
|
|
const packed = packSheetToR8(sheet, opaqueBlack)
|
|
const png = encodeIndexedPng({
|
|
width: packed.width,
|
|
height: packed.height,
|
|
pixels: packed.r8,
|
|
palette,
|
|
transparentIndex: 0,
|
|
})
|
|
return { png, width: packed.width, height: packed.height, groups: packed.groups }
|
|
}
|
|
|
|
export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/d2-packs'): Promise<void> {
|
|
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}`)
|
|
}
|
|
}
|
|
|
|
// 0. Load timing and mode tables from anim/
|
|
const animDataJsonPath = join(outDir, 'anim', 'animdata.json')
|
|
const monstats2JsonPath = join(outDir, 'anim', 'monstats2.json')
|
|
if (!existsSync(animDataJsonPath) || !existsSync(monstats2JsonPath)) {
|
|
throw new Error(`AnimData or MonStats2 JSON missing in ${join(outDir, 'anim')}. Run 'npm run pack:animdata' first.`)
|
|
}
|
|
const animData = JSON.parse(readFileSync(animDataJsonPath, 'utf8'))
|
|
const monstats2 = JSON.parse(readFileSync(monstats2JsonPath, 'utf8'))
|
|
|
|
// 1. Load character sheets for all 11 player modes
|
|
console.log('Composing Sorceress (so) 11 animation modes...')
|
|
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) {
|
|
const sheet = await loadCharacterSheet(archives, 'so', mode, 'hth')
|
|
charSheets.push(sheet)
|
|
totalCharMembers += sheet.members.length
|
|
|
|
const cofName = `SO${mode.toUpperCase()}HTH`
|
|
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,
|
|
}
|
|
|
|
// 2. Collect unique monster (token, weapon) specs across Acts 1..5
|
|
const uniqueSpecs = new Map<string, { token: string; weapon: string }>()
|
|
for (let act = 1; act <= 5; act += 1) {
|
|
const specs = ACT_MONSTER_SPECS[act] ?? []
|
|
for (const spec of specs) {
|
|
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
|
|
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
|
if (!uniqueSpecs.has(key)) {
|
|
uniqueSpecs.set(key, spec)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Include SuperUnique landmark bosses and minions
|
|
const superUniques = readSuperUniques(CANONICAL_SUPERUNIQUES_TABLE)
|
|
const landmarkMinionIds = new Set<string>()
|
|
for (const landmark of CANONICAL_SUPER_UNIQUE_LANDMARKS) {
|
|
if (landmark.minionMonsterId) {
|
|
landmarkMinionIds.add(landmark.minionMonsterId)
|
|
}
|
|
}
|
|
|
|
const allSuperUniqueMonsterIds = new Set<string>()
|
|
for (const su of superUniques) {
|
|
allSuperUniqueMonsterIds.add(su.monsterId)
|
|
}
|
|
for (const minionId of landmarkMinionIds) {
|
|
allSuperUniqueMonsterIds.add(minionId)
|
|
}
|
|
|
|
for (const monsterId of allSuperUniqueMonsterIds) {
|
|
const spec = resolveMonsterArtSpec(monsterId)
|
|
if (spec && spec.token && spec.token.toLowerCase() !== 'xx') {
|
|
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
|
if (!uniqueSpecs.has(key)) {
|
|
uniqueSpecs.set(key, spec)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Map allowed modes for each spec from MonStats2.txt
|
|
const specAllowedModes = new Map<string, Set<string>>()
|
|
for (const [key, spec] of uniqueSpecs) {
|
|
const modes = new Set<string>()
|
|
for (const [id, s] of Object.entries(MONSTER_ART_MAP)) {
|
|
if (s.token.toLowerCase() === spec.token.toLowerCase() && s.weapon.toLowerCase() === spec.weapon.toLowerCase()) {
|
|
const row = monstats2.rows[id.toLowerCase()]
|
|
if (row?.modes) {
|
|
for (const m of Object.keys(row.modes)) {
|
|
modes.add(m.toLowerCase())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Every active monster must at least have 'nu'
|
|
modes.add('nu')
|
|
specAllowedModes.set(key, modes)
|
|
}
|
|
|
|
// Composite monster animations (concurrency limit 6)
|
|
const compositedMonsters = new Map<string, {
|
|
key: string
|
|
token: string
|
|
weapon: string
|
|
directions: number
|
|
walkOffset?: number
|
|
standOffset: number
|
|
walkFrames?: number
|
|
standFrames: number
|
|
clips: Record<string, {
|
|
group: number
|
|
directions: number
|
|
frames: number
|
|
speed: number
|
|
loop: boolean
|
|
events: { f: number; c: number }[]
|
|
}>
|
|
sheet: SpriteSheet
|
|
}>()
|
|
|
|
const entries = [...uniqueSpecs.entries()]
|
|
let cursor = 0
|
|
const workers = Array.from({ length: Math.min(6, entries.length) }, async () => {
|
|
for (;;) {
|
|
const idx = cursor
|
|
cursor += 1
|
|
if (idx >= entries.length) return
|
|
const [key, spec] = entries[idx]!
|
|
if (!spec.token || spec.token.toLowerCase() === 'xx') continue
|
|
|
|
const allowed = specAllowedModes.get(key) ?? new Set(['nu'])
|
|
const modesToProbe = TARGET_MONSTER_MODES.filter(m => allowed.has(m))
|
|
|
|
const monSheets: { mode: string; sheet: MonsterAnimationSheet }[] = []
|
|
for (const mode of modesToProbe) {
|
|
try {
|
|
const anim = await compositeMonsterAnimation(archives, spec.token, mode, spec.weapon)
|
|
monSheets.push({ mode, sheet: anim })
|
|
} catch {
|
|
// Mode COF not found for this monster; skip
|
|
}
|
|
}
|
|
|
|
if (monSheets.length === 0) {
|
|
console.warn(`No animations could be composited for monster ${key}`)
|
|
continue
|
|
}
|
|
|
|
const clips: Record<string, {
|
|
group: number
|
|
directions: number
|
|
frames: number
|
|
speed: number
|
|
loop: boolean
|
|
events: { f: number; c: number }[]
|
|
}> = {}
|
|
|
|
let groupOffset = 0
|
|
let baseDirections = 8
|
|
for (const { mode, sheet } of monSheets) {
|
|
const animKey = `${spec.token.toUpperCase()}${mode.toUpperCase()}${spec.weapon.toUpperCase()}`
|
|
const animHthKey = `${spec.token.toUpperCase()}${mode.toUpperCase()}HTH`
|
|
const animRecord = animData.clips[animKey] ?? animData.clips[animHthKey]
|
|
const speed = animRecord?.speed ?? 256
|
|
const events = (animRecord?.events ?? []).map(([f, c]: [number, number]) => ({ f, c }))
|
|
|
|
clips[mode] = {
|
|
group: groupOffset,
|
|
directions: sheet.directions,
|
|
frames: sheet.framesPerDirection,
|
|
speed,
|
|
loop: LOOPING_MONSTER_MODES.has(mode),
|
|
events,
|
|
}
|
|
if (mode === 'nu') {
|
|
baseDirections = sheet.directions
|
|
}
|
|
groupOffset += sheet.sheet.groups.length
|
|
}
|
|
|
|
if (!clips.nu) {
|
|
console.warn(`Monster ${key} has no 'nu' clip! Skipping.`)
|
|
continue
|
|
}
|
|
|
|
const joinedSheet: SpriteSheet = {
|
|
groups: monSheets.flatMap(s => s.sheet.sheet.groups),
|
|
width: null,
|
|
}
|
|
|
|
compositedMonsters.set(key, {
|
|
key,
|
|
token: spec.token,
|
|
weapon: spec.weapon,
|
|
directions: baseDirections,
|
|
...(clips.wl ? { walkOffset: clips.wl.group, walkFrames: clips.wl.frames } : {}),
|
|
standOffset: clips.nu.group,
|
|
standFrames: clips.nu.frames,
|
|
clips,
|
|
sheet: joinedSheet,
|
|
})
|
|
}
|
|
})
|
|
await Promise.all(workers)
|
|
console.log(`Composited ${compositedMonsters.size} unique monster specs across Acts 1..5.`)
|
|
|
|
// 3. Emit global act-independent raw 8-bit (.r8) index textures into outDir/entities
|
|
const globalEntityDir = join(outDir, 'entities')
|
|
await mkdir(globalEntityDir, { recursive: true })
|
|
|
|
const packedCharR8 = packSheetToR8(charJoined, 172)
|
|
await writeFile(join(globalEntityDir, 'char-so.r8'), packedCharR8.r8)
|
|
await writeFile(
|
|
join(globalEntityDir, 'char-so.json'),
|
|
JSON.stringify({
|
|
schema: 2,
|
|
file: 'char-so.r8',
|
|
format: 'r8',
|
|
width: packedCharR8.width,
|
|
height: packedCharR8.height,
|
|
directions: charClips.wl.directions,
|
|
clips: charClips,
|
|
walk: charClips.wl.frames,
|
|
stand: charClips.nu.frames,
|
|
walkOffset: charClips.wl.group,
|
|
standOffset: charClips.nu.group,
|
|
members: totalCharMembers,
|
|
groups: packedCharR8.groups,
|
|
}),
|
|
)
|
|
|
|
const packedMonstersR8 = new Map<string, PackedEntityR8>()
|
|
for (const [key, m] of compositedMonsters) {
|
|
const packedMonR8 = packSheetToR8(m.sheet, 172)
|
|
packedMonstersR8.set(key, packedMonR8)
|
|
await writeFile(join(globalEntityDir, `monster-${key}.r8`), packedMonR8.r8)
|
|
await writeFile(
|
|
join(globalEntityDir, `monster-${key}.json`),
|
|
JSON.stringify({
|
|
schema: 2,
|
|
token: m.token,
|
|
weapon: m.weapon,
|
|
file: `monster-${key}.r8`,
|
|
format: 'r8',
|
|
width: packedMonR8.width,
|
|
height: packedMonR8.height,
|
|
directions: m.directions,
|
|
clips: m.clips,
|
|
walkOffset: m.walkOffset,
|
|
standOffset: m.standOffset,
|
|
walkFrames: m.walkFrames,
|
|
standFrames: m.standFrames,
|
|
groups: packedMonR8.groups,
|
|
}),
|
|
)
|
|
}
|
|
console.log(`Global R8 entities: packed char-so.r8 + ${packedMonstersR8.size} monster .r8 atlases into ${globalEntityDir}`)
|
|
|
|
// 4. Encode indexed PNGs per Act palette (legacy fallback)
|
|
for (let act = 1; act <= 5; act += 1) {
|
|
try {
|
|
const pl2 = decodePl2(await archives.read(`data\\global\\palette\\act${String(act)}\\pal.pl2`))
|
|
const palette = pl2.rgb
|
|
const actEntityDir = join(outDir, `act${String(act)}`, 'entities')
|
|
await mkdir(actEntityDir, { recursive: true })
|
|
|
|
// Pack character PNG from cached R8
|
|
const charPng = encodeIndexedPng({
|
|
width: packedCharR8.width,
|
|
height: packedCharR8.height,
|
|
pixels: packedCharR8.r8,
|
|
palette,
|
|
transparentIndex: 0,
|
|
})
|
|
const charPngName = 'char-so.png'
|
|
await writeFile(join(actEntityDir, charPngName), charPng)
|
|
await writeFile(
|
|
join(actEntityDir, 'char-so.json'),
|
|
JSON.stringify({
|
|
schema: 2,
|
|
file: charPngName,
|
|
width: packedCharR8.width,
|
|
height: packedCharR8.height,
|
|
directions: charClips.wl.directions,
|
|
clips: charClips,
|
|
walk: charClips.wl.group,
|
|
stand: charClips.nu.group,
|
|
members: totalCharMembers,
|
|
groups: packedCharR8.groups,
|
|
}),
|
|
)
|
|
|
|
// Pack monsters used in this act
|
|
const actSpecs = ACT_MONSTER_SPECS[act] ?? []
|
|
let packedCount = 0
|
|
for (const spec of actSpecs) {
|
|
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
|
const m = compositedMonsters.get(key)
|
|
const packedMonR8 = packedMonstersR8.get(key)
|
|
if (!m || !packedMonR8) continue
|
|
const monPng = encodeIndexedPng({
|
|
width: packedMonR8.width,
|
|
height: packedMonR8.height,
|
|
pixels: packedMonR8.r8,
|
|
palette,
|
|
transparentIndex: 0,
|
|
})
|
|
const monPngName = `monster-${key}.png`
|
|
await writeFile(join(actEntityDir, monPngName), monPng)
|
|
await writeFile(
|
|
join(actEntityDir, `monster-${key}.json`),
|
|
JSON.stringify({
|
|
schema: 2,
|
|
token: m.token,
|
|
weapon: m.weapon,
|
|
file: monPngName,
|
|
width: packedMonR8.width,
|
|
height: packedMonR8.height,
|
|
directions: m.directions,
|
|
clips: m.clips,
|
|
walkOffset: m.walkOffset,
|
|
standOffset: m.standOffset,
|
|
walkFrames: m.walkFrames,
|
|
standFrames: m.standFrames,
|
|
groups: packedMonR8.groups,
|
|
}),
|
|
)
|
|
packedCount += 1
|
|
}
|
|
console.log(`Act ${act}: packed char-so + ${packedCount} monster atlases into ${actEntityDir}`)
|
|
} catch (err) {
|
|
console.warn(`Act ${act} entity packing skipped: ${(err as Error).message}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
const archiveDir = process.argv[2] ?? 'samples/d2'
|
|
const outDir = process.argv[3] ?? 'samples/d2-packs'
|
|
bakeEntities(archiveDir, outDir).catch(err => {
|
|
console.error(err)
|
|
process.exit(1)
|
|
})
|
|
}
|