/** * Bake character and monster animations into web-native indexed PNG atlases. * * Pre-packing entity animations into `samples/d2-packs/act/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 { MONSTER_ART_MAP } 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][][] } /** * 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 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 pixels = 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) { pixels[toRow + col] = 0 } else { const val = frame.indices[srcAt]! pixels[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) } const png = encodeIndexedPng({ width: pageWidth, height: pageHeight, pixels, palette, transparentIndex: 0 }) return { png, width: pageWidth, height: pageHeight, groups } } export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/d2-packs'): Promise { 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 const uniqueSpecs = new Map() for (const spec of Object.values(MONSTER_ART_MAP)) { const key = `${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}` if (!uniqueSpecs.has(key)) { uniqueSpecs.set(key, spec) } } // Composite each monster animation once const compositedMonsters = new Map() for (const [key, spec] of uniqueSpecs) { try { console.log(`Composing monster ${spec.token} (${spec.weapon})...`) 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}`) } } // 3. Encode indexed PNGs per Act palette 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 const packedChar = packSheetToPng(charJoined, palette) const charPngName = 'char-so.png' await writeFile(join(actEntityDir, charPngName), packedChar.png) await writeFile( join(actEntityDir, 'char-so.json'), JSON.stringify({ file: charPngName, width: packedChar.width, height: packedChar.height, directions: charWalk.directions, walk: 0, stand: charWalk.sheet.groups.length, members: charWalk.members.length + charStand.members.length, groups: packedChar.groups, }), ) // Pack monsters for (const [key, m] of compositedMonsters) { const packedMon = packSheetToPng(m.sheet, palette) const monPngName = `monster-${key}.png` await writeFile(join(actEntityDir, monPngName), packedMon.png) await writeFile( join(actEntityDir, `monster-${key}.json`), JSON.stringify({ token: m.token, weapon: m.weapon, file: monPngName, width: packedMon.width, height: packedMon.height, directions: m.directions, walkOffset: m.walkOffset, standOffset: m.standOffset, walkFrames: m.walkFrames, standFrames: m.standFrames, groups: packedMon.groups, }), ) } console.log(`Act ${act}: packed char-so + ${compositedMonsters.size} 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) }) }