perf(entities): offline pre-bake Sorceress and monster atlases into Act packs (#30)
This commit is contained in:
parent
d6e624829a
commit
270c743aef
|
|
@ -1079,5 +1079,7 @@ for (const entry of LEVELS) {
|
|||
|
||||
await mkdir(outDir, { recursive: true })
|
||||
await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1))
|
||||
const { bakeEntities } = await import('./pack-entity-assets.ts')
|
||||
await bakeEntities(archiveDir, outDir)
|
||||
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
|
||||
console.log(`Skipped artless spawns: ${skippedArtlessSpawns}, Skipped no-art NPCs: ${skippedMissingArtSpawns}`)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,252 @@
|
|||
/**
|
||||
* 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 { 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<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
|
||||
const uniqueSpecs = new Map<string, { token: string; weapon: string }>()
|
||||
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<string, {
|
||||
key: string
|
||||
token: string
|
||||
weapon: string
|
||||
directions: number
|
||||
walkOffset: number
|
||||
standOffset: number
|
||||
walkFrames: number
|
||||
standFrames: number
|
||||
sheet: SpriteSheet
|
||||
}>()
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
@ -821,48 +821,129 @@ async function getMountedCharArchives(bases: readonly string[]): Promise<Mounted
|
|||
return null
|
||||
}
|
||||
|
||||
interface PackedMonsterJson {
|
||||
readonly token: string
|
||||
readonly weapon: string
|
||||
readonly file: string
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly directions: number
|
||||
readonly walkOffset: number
|
||||
readonly standOffset: number
|
||||
readonly walkFrames: number
|
||||
readonly standFrames: number
|
||||
readonly groups: readonly (readonly [number, number, number, number])[][]
|
||||
}
|
||||
|
||||
interface PackedCharJson {
|
||||
readonly file: string
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly directions: number
|
||||
readonly walk: number
|
||||
readonly stand: number
|
||||
readonly members: number
|
||||
readonly groups: readonly (readonly [number, number, number, number])[][]
|
||||
}
|
||||
|
||||
/**
|
||||
* Load monster atlases on-demand for all monster types present in the scene.
|
||||
* Prefers pre-baked indexed PNG + JSON from the act pack when available (< 200ms),
|
||||
* falling back to runtime MPQ HTTP Range extraction only if pre-packed assets are missing.
|
||||
*/
|
||||
async function loadMonsterArtMap(
|
||||
bases: readonly string[],
|
||||
monsterIds: readonly string[],
|
||||
palette: Palette,
|
||||
renderer: SpriteRenderer,
|
||||
packBase?: string,
|
||||
act?: number,
|
||||
): Promise<Map<string, LoadedMonsterArt>> {
|
||||
const map = new Map<string, LoadedMonsterArt>()
|
||||
if (monsterIds.length === 0) return map
|
||||
try {
|
||||
const archives = await getMountedDataArchives(bases)
|
||||
if (!archives) return map
|
||||
|
||||
// Resolve unique tokens needed
|
||||
// Resolve unique (token, weapon) specs needed
|
||||
const tokenSpecs = new Map<string, { token: string; weapon: string }>()
|
||||
for (const id of monsterIds) {
|
||||
const spec = resolveMonsterArtSpec(id)
|
||||
if (spec !== null && !tokenSpecs.has(spec.token)) {
|
||||
tokenSpecs.set(spec.token, spec)
|
||||
if (spec !== null) {
|
||||
const key = `${spec.token}-${spec.weapon}`
|
||||
if (!tokenSpecs.has(key)) {
|
||||
tokenSpecs.set(key, spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load each monster token atlas in parallel
|
||||
const loadedByToken = new Map<string, LoadedMonsterArt>()
|
||||
await Promise.all(
|
||||
[...tokenSpecs.entries()].map(async ([token, spec]) => {
|
||||
try {
|
||||
const loaded = await loadMonsterAtlas(archives, token, spec.weapon, palette, renderer)
|
||||
loadedByToken.set(token, loaded)
|
||||
} catch (err) {
|
||||
console.warn(`monster art ${token} (${spec.weapon}) unavailable: ${(err as Error).message}`)
|
||||
}
|
||||
})
|
||||
)
|
||||
const loadedBySpecKey = new Map<string, LoadedMonsterArt>()
|
||||
const fallbackSpecs: Array<[string, { token: string; weapon: string }]> = []
|
||||
|
||||
if (packBase !== undefined && act !== undefined) {
|
||||
const entitiesUrl = `${packBase}/act${String(act)}/entities`
|
||||
await Promise.all(
|
||||
[...tokenSpecs.entries()].map(async ([key, spec]) => {
|
||||
const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
||||
try {
|
||||
const [jsonResp, pngResp] = await Promise.all([
|
||||
fetch(`${entitiesUrl}/${slug}.json`),
|
||||
fetch(`${entitiesUrl}/${slug}.png`),
|
||||
])
|
||||
if (jsonResp.ok && pngResp.ok) {
|
||||
const meta = (await jsonResp.json()) as PackedMonsterJson
|
||||
const bitmap = await createImageBitmap(await pngResp.blob())
|
||||
let handle: AtlasHandle
|
||||
try {
|
||||
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
|
||||
} finally {
|
||||
bitmap.close()
|
||||
}
|
||||
const groups: AtlasFrame[][] = meta.groups.map(group =>
|
||||
group.map(([x, y, width, height]) => ({ x, y, width, height }))
|
||||
)
|
||||
loadedBySpecKey.set(key, {
|
||||
token: spec.token,
|
||||
handle,
|
||||
groups,
|
||||
directions: meta.directions,
|
||||
walkOffset: meta.walkOffset,
|
||||
standOffset: meta.standOffset,
|
||||
walkFrames: meta.walkFrames,
|
||||
standFrames: meta.standFrames,
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// fall back to MPQ loading below
|
||||
}
|
||||
fallbackSpecs.push([key, spec])
|
||||
})
|
||||
)
|
||||
} else {
|
||||
fallbackSpecs.push(...tokenSpecs.entries())
|
||||
}
|
||||
|
||||
// Load any remaining specs via runtime MPQ archives
|
||||
if (fallbackSpecs.length > 0) {
|
||||
const archives = await getMountedDataArchives(bases)
|
||||
if (archives) {
|
||||
await Promise.all(
|
||||
fallbackSpecs.map(async ([key, spec]) => {
|
||||
try {
|
||||
const loaded = await loadMonsterAtlas(archives, spec.token, spec.weapon, palette, renderer)
|
||||
loadedBySpecKey.set(key, loaded)
|
||||
} catch (err) {
|
||||
console.warn(`monster art ${spec.token} (${spec.weapon}) unavailable: ${(err as Error).message}`)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Map monster id to loaded art
|
||||
for (const id of monsterIds) {
|
||||
const spec = resolveMonsterArtSpec(id)
|
||||
if (spec !== null) {
|
||||
const loaded = loadedByToken.get(spec.token)
|
||||
const key = `${spec.token}-${spec.weapon}`
|
||||
const loaded = loadedBySpecKey.get(key)
|
||||
if (loaded !== undefined) {
|
||||
map.set(id.toLowerCase(), loaded)
|
||||
}
|
||||
|
|
@ -878,7 +959,42 @@ async function loadCharacterArt(
|
|||
bases: readonly string[],
|
||||
palette: Palette,
|
||||
renderer: SpriteRenderer,
|
||||
packBase?: string,
|
||||
act?: number,
|
||||
): Promise<LoadedCharacter | null> {
|
||||
if (packBase !== undefined && act !== undefined) {
|
||||
const entitiesUrl = `${packBase}/act${String(act)}/entities`
|
||||
try {
|
||||
const [jsonResp, pngResp] = await Promise.all([
|
||||
fetch(`${entitiesUrl}/char-so.json`),
|
||||
fetch(`${entitiesUrl}/char-so.png`),
|
||||
])
|
||||
if (jsonResp.ok && pngResp.ok) {
|
||||
const meta = (await jsonResp.json()) as PackedCharJson
|
||||
const bitmap = await createImageBitmap(await pngResp.blob())
|
||||
let handle: AtlasHandle
|
||||
try {
|
||||
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
|
||||
} finally {
|
||||
bitmap.close()
|
||||
}
|
||||
const groups: AtlasFrame[][] = meta.groups.map(group =>
|
||||
group.map(([x, y, width, height]) => ({ x, y, width, height }))
|
||||
)
|
||||
return {
|
||||
handle,
|
||||
groups,
|
||||
directions: meta.directions,
|
||||
walk: meta.walk,
|
||||
stand: meta.stand,
|
||||
members: meta.members,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall back to MPQ loading below
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const archives = await getMountedCharArchives(bases)
|
||||
if (!archives) return null
|
||||
|
|
@ -1289,18 +1405,33 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
|
|||
preloadRemaining(runtime, renderer)
|
||||
if (runtime.palette !== null) {
|
||||
const paletteForEntities = runtime.palette
|
||||
const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
|
||||
const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
|
||||
// Collect monster types to load
|
||||
const activeMonsterIds = runtime.monsterTypes.length > 0
|
||||
? runtime.monsterTypes
|
||||
: (runtime.monsterCount > 0 ? runtime.monsterStats.map(s => s.id) : [])
|
||||
|
||||
if (activeMonsterIds.length > 0) {
|
||||
void loadMonsterArtMap(DEFAULT_BASES, activeMonsterIds, paletteForEntities, renderer).then((loadedMap) => {
|
||||
void loadMonsterArtMap(
|
||||
DEFAULT_BASES,
|
||||
activeMonsterIds,
|
||||
paletteForEntities,
|
||||
renderer,
|
||||
packEntityBase,
|
||||
packEntityAct,
|
||||
).then((loadedMap) => {
|
||||
monsterArtMap = loadedMap
|
||||
})
|
||||
}
|
||||
|
||||
void loadCharacterArt(runtime.charBases, paletteForEntities, renderer).then((loaded) => {
|
||||
void loadCharacterArt(
|
||||
runtime.charBases,
|
||||
paletteForEntities,
|
||||
renderer,
|
||||
packEntityBase,
|
||||
packEntityAct,
|
||||
).then((loaded) => {
|
||||
character = loaded
|
||||
state.character = loaded !== null
|
||||
if (loaded !== null) state.characterMembers = loaded.members
|
||||
|
|
@ -1313,7 +1444,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
|
|||
: / 角色[^。]*。/u,
|
||||
loaded === null
|
||||
? ' 角色暂为占位棋子:d2char.mpq 不可用时会退回这里。'
|
||||
: ` 角色为真·女法师(DCC+COF 合成,${String(loaded.members)} 层)。`,
|
||||
: ` 角色为真·女法师(${runtime.source === 'pack' ? '预烘焙图集' : 'DCC+COF 合成'},${String(loaded.members)} 层)。`,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
@ -1322,6 +1453,9 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
|
|||
loop.stop()
|
||||
input.detach()
|
||||
if (character?.handle) renderer.deleteAtlas(character.handle)
|
||||
for (const art of monsterArtMap.values()) {
|
||||
renderer.deleteAtlas(art.handle)
|
||||
}
|
||||
for (const page of runtime.pages) {
|
||||
if (page !== null) renderer.deleteAtlas(page)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { MONSTER_ART_MAP } from '../src/game/monster-mapping.ts'
|
||||
|
||||
describe('offline pre-baked entity atlases (Issue #30)', () => {
|
||||
const packRoot = join(process.cwd(), 'samples', 'd2-packs')
|
||||
const hasPacks = existsSync(join(packRoot, 'act1', 'entities', 'char-so.json'))
|
||||
|
||||
it.skipIf(!hasPacks)('bakes Sorceress char-so PNG + JSON for Acts 1..5', () => {
|
||||
for (let act = 1; act <= 5; act += 1) {
|
||||
const dir = join(packRoot, `act${act}`, 'entities')
|
||||
const jsonPath = join(dir, 'char-so.json')
|
||||
const pngPath = join(dir, 'char-so.png')
|
||||
expect(existsSync(jsonPath)).toBe(true)
|
||||
expect(existsSync(pngPath)).toBe(true)
|
||||
|
||||
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
||||
expect(meta.file).toBe('char-so.png')
|
||||
expect(meta.directions).toBe(16)
|
||||
expect(meta.walk).toBe(0)
|
||||
expect(meta.stand).toBe(16)
|
||||
expect(meta.members).toBeGreaterThanOrEqual(16)
|
||||
expect(meta.groups.length).toBe(32) // 16 walk + 16 stand
|
||||
|
||||
const png = readFileSync(pngPath)
|
||||
// Check PNG magic header
|
||||
expect(png[0]).toBe(137)
|
||||
expect(png[1]).toBe(80) // P
|
||||
expect(png[2]).toBe(78) // N
|
||||
expect(png[3]).toBe(71) // G
|
||||
// Read IHDR width and height
|
||||
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
|
||||
expect(view.getUint32(16)).toBe(meta.width)
|
||||
expect(view.getUint32(20)).toBe(meta.height)
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(!hasPacks)('bakes monster PNG + JSON atlases for all mapped monster specs across Acts 1..5', () => {
|
||||
const uniqueSpecs = new Map<string, { token: string; weapon: string }>()
|
||||
for (const spec of Object.values(MONSTER_ART_MAP)) {
|
||||
const key = `${spec.token}-${spec.weapon}`
|
||||
if (!uniqueSpecs.has(key)) uniqueSpecs.set(key, spec)
|
||||
}
|
||||
|
||||
for (let act = 1; act <= 5; act += 1) {
|
||||
const dir = join(packRoot, `act${act}`, 'entities')
|
||||
for (const spec of uniqueSpecs.values()) {
|
||||
const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
||||
const jsonPath = join(dir, `${slug}.json`)
|
||||
const pngPath = join(dir, `${slug}.png`)
|
||||
expect(existsSync(jsonPath), `missing ${jsonPath}`).toBe(true)
|
||||
expect(existsSync(pngPath), `missing ${pngPath}`).toBe(true)
|
||||
|
||||
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
||||
expect(meta.token).toBe(spec.token)
|
||||
expect(meta.weapon).toBe(spec.weapon)
|
||||
expect(meta.directions).toBe(8)
|
||||
expect(meta.walkOffset).toBe(0)
|
||||
expect(meta.standOffset).toBe(8)
|
||||
expect(meta.groups.length).toBe(16) // 8 walk + 8 stand
|
||||
|
||||
const png = readFileSync(pngPath)
|
||||
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
|
||||
expect(view.getUint32(16)).toBe(meta.width)
|
||||
expect(view.getUint32(20)).toBe(meta.height)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue