diablo2-web/scripts/pack-entity-assets.ts

352 lines
12 KiB
TypeScript

/**
* Bake character and monster animations into web-native indexed PNG atlases.
*
* Pre-packing entity animations into `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.
* The browser loads small (~30-80 KB) indexed PNGs via hardware-accelerated
* `createImageBitmap`, reducing character + monster load times from >5s to <200ms.
*/
import { mkdir, writeFile } from 'node:fs/promises'
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 } from '../src/game/character.ts'
import { compositeMonsterAnimation } from '../src/game/monster-art.ts'
import { ACT_MONSTER_SPECS } from '../src/game/monster-mapping.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 = 2048
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][][]
}
export interface PackedEntityR8 {
readonly r8: Uint8Array
readonly width: number
readonly height: number
readonly groups: [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)`).
*/
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][][] = []
let consumed = 0
for (const group of sheet.groups) {
const gList: [number, number, number, number][] = []
for (let i = 0; i < group.frames.length; i += 1) {
const p = placements[consumed]!
consumed += 1
gList.push([p.x, p.y, p.width, p.height])
}
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}`)
}
}
// 1. Load character sheets once (independent of palette until PNG encoding)
console.log('Composing Sorceress (so) walk & stand animations...')
const [charWalk, charStand] = await Promise.all([
loadCharacterSheet(archives, 'so', 'wl', 'hth'),
loadCharacterSheet(archives, 'so', 'nu', 'hth'),
])
const charJoined: SpriteSheet = {
groups: [...charWalk.sheet.groups, ...charStand.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) {
const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
if (!uniqueSpecs.has(key)) {
uniqueSpecs.set(key, spec)
}
}
}
// Composite each monster animation once (with concurrency limit 6)
const compositedMonsters = new Map<string, {
key: string
token: string
weapon: string
directions: number
walkOffset: number
standOffset: number
walkFrames: number
standFrames: 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]!
try {
const [walk, stand] = await Promise.all([
compositeMonsterAnimation(archives, spec.token, 'wl', spec.weapon),
compositeMonsterAnimation(archives, spec.token, 'nu', spec.weapon),
])
const sheet: SpriteSheet = {
groups: [...walk.sheet.groups, ...stand.sheet.groups],
width: null,
}
compositedMonsters.set(key, {
key,
token: spec.token,
weapon: spec.weapon,
directions: walk.directions,
walkOffset: 0,
standOffset: walk.sheet.groups.length,
walkFrames: walk.framesPerDirection,
standFrames: stand.framesPerDirection,
sheet,
})
} catch (err) {
console.warn(`Failed to composite monster ${key}: ${(err as Error).message}`)
}
}
})
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({
file: 'char-so.r8',
format: 'r8',
width: packedCharR8.width,
height: packedCharR8.height,
directions: charWalk.directions,
walk: 0,
stand: charWalk.sheet.groups.length,
members: charWalk.members.length + charStand.members.length,
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({
token: m.token,
weapon: m.weapon,
file: `monster-${key}.r8`,
format: 'r8',
width: packedMonR8.width,
height: packedMonR8.height,
directions: m.directions,
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({
file: charPngName,
width: packedCharR8.width,
height: packedCharR8.height,
directions: charWalk.directions,
walk: 0,
stand: charWalk.sheet.groups.length,
members: charWalk.members.length + charStand.members.length,
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({
token: m.token,
weapon: m.weapon,
file: monPngName,
width: packedMonR8.width,
height: packedMonR8.height,
directions: m.directions,
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)
})
}