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 mkdir(outDir, { recursive: true })
|
||||||
await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1))
|
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(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
|
||||||
console.log(`Skipped artless spawns: ${skippedArtlessSpawns}, Skipped no-art NPCs: ${skippedMissingArtSpawns}`)
|
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
|
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.
|
* 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(
|
async function loadMonsterArtMap(
|
||||||
bases: readonly string[],
|
bases: readonly string[],
|
||||||
monsterIds: readonly string[],
|
monsterIds: readonly string[],
|
||||||
palette: Palette,
|
palette: Palette,
|
||||||
renderer: SpriteRenderer,
|
renderer: SpriteRenderer,
|
||||||
|
packBase?: string,
|
||||||
|
act?: number,
|
||||||
): Promise<Map<string, LoadedMonsterArt>> {
|
): Promise<Map<string, LoadedMonsterArt>> {
|
||||||
const map = new Map<string, LoadedMonsterArt>()
|
const map = new Map<string, LoadedMonsterArt>()
|
||||||
if (monsterIds.length === 0) return map
|
if (monsterIds.length === 0) return map
|
||||||
try {
|
try {
|
||||||
const archives = await getMountedDataArchives(bases)
|
// Resolve unique (token, weapon) specs needed
|
||||||
if (!archives) return map
|
|
||||||
|
|
||||||
// Resolve unique tokens needed
|
|
||||||
const tokenSpecs = new Map<string, { token: string; weapon: string }>()
|
const tokenSpecs = new Map<string, { token: string; weapon: string }>()
|
||||||
for (const id of monsterIds) {
|
for (const id of monsterIds) {
|
||||||
const spec = resolveMonsterArtSpec(id)
|
const spec = resolveMonsterArtSpec(id)
|
||||||
if (spec !== null && !tokenSpecs.has(spec.token)) {
|
if (spec !== null) {
|
||||||
tokenSpecs.set(spec.token, spec)
|
const key = `${spec.token}-${spec.weapon}`
|
||||||
|
if (!tokenSpecs.has(key)) {
|
||||||
|
tokenSpecs.set(key, spec)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load each monster token atlas in parallel
|
const loadedBySpecKey = new Map<string, LoadedMonsterArt>()
|
||||||
const loadedByToken = new Map<string, LoadedMonsterArt>()
|
const fallbackSpecs: Array<[string, { token: string; weapon: string }]> = []
|
||||||
await Promise.all(
|
|
||||||
[...tokenSpecs.entries()].map(async ([token, spec]) => {
|
if (packBase !== undefined && act !== undefined) {
|
||||||
try {
|
const entitiesUrl = `${packBase}/act${String(act)}/entities`
|
||||||
const loaded = await loadMonsterAtlas(archives, token, spec.weapon, palette, renderer)
|
await Promise.all(
|
||||||
loadedByToken.set(token, loaded)
|
[...tokenSpecs.entries()].map(async ([key, spec]) => {
|
||||||
} catch (err) {
|
const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
||||||
console.warn(`monster art ${token} (${spec.weapon}) unavailable: ${(err as Error).message}`)
|
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
|
// Map monster id to loaded art
|
||||||
for (const id of monsterIds) {
|
for (const id of monsterIds) {
|
||||||
const spec = resolveMonsterArtSpec(id)
|
const spec = resolveMonsterArtSpec(id)
|
||||||
if (spec !== null) {
|
if (spec !== null) {
|
||||||
const loaded = loadedByToken.get(spec.token)
|
const key = `${spec.token}-${spec.weapon}`
|
||||||
|
const loaded = loadedBySpecKey.get(key)
|
||||||
if (loaded !== undefined) {
|
if (loaded !== undefined) {
|
||||||
map.set(id.toLowerCase(), loaded)
|
map.set(id.toLowerCase(), loaded)
|
||||||
}
|
}
|
||||||
|
|
@ -878,7 +959,42 @@ async function loadCharacterArt(
|
||||||
bases: readonly string[],
|
bases: readonly string[],
|
||||||
palette: Palette,
|
palette: Palette,
|
||||||
renderer: SpriteRenderer,
|
renderer: SpriteRenderer,
|
||||||
|
packBase?: string,
|
||||||
|
act?: number,
|
||||||
): Promise<LoadedCharacter | null> {
|
): 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 {
|
try {
|
||||||
const archives = await getMountedCharArchives(bases)
|
const archives = await getMountedCharArchives(bases)
|
||||||
if (!archives) return null
|
if (!archives) return null
|
||||||
|
|
@ -1289,18 +1405,33 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
|
||||||
preloadRemaining(runtime, renderer)
|
preloadRemaining(runtime, renderer)
|
||||||
if (runtime.palette !== null) {
|
if (runtime.palette !== null) {
|
||||||
const paletteForEntities = runtime.palette
|
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
|
// Collect monster types to load
|
||||||
const activeMonsterIds = runtime.monsterTypes.length > 0
|
const activeMonsterIds = runtime.monsterTypes.length > 0
|
||||||
? runtime.monsterTypes
|
? runtime.monsterTypes
|
||||||
: (runtime.monsterCount > 0 ? runtime.monsterStats.map(s => s.id) : [])
|
: (runtime.monsterCount > 0 ? runtime.monsterStats.map(s => s.id) : [])
|
||||||
|
|
||||||
if (activeMonsterIds.length > 0) {
|
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
|
monsterArtMap = loadedMap
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadCharacterArt(runtime.charBases, paletteForEntities, renderer).then((loaded) => {
|
void loadCharacterArt(
|
||||||
|
runtime.charBases,
|
||||||
|
paletteForEntities,
|
||||||
|
renderer,
|
||||||
|
packEntityBase,
|
||||||
|
packEntityAct,
|
||||||
|
).then((loaded) => {
|
||||||
character = loaded
|
character = loaded
|
||||||
state.character = loaded !== null
|
state.character = loaded !== null
|
||||||
if (loaded !== null) state.characterMembers = loaded.members
|
if (loaded !== null) state.characterMembers = loaded.members
|
||||||
|
|
@ -1313,7 +1444,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
|
||||||
: / 角色[^。]*。/u,
|
: / 角色[^。]*。/u,
|
||||||
loaded === null
|
loaded === null
|
||||||
? ' 角色暂为占位棋子:d2char.mpq 不可用时会退回这里。'
|
? ' 角色暂为占位棋子: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()
|
loop.stop()
|
||||||
input.detach()
|
input.detach()
|
||||||
if (character?.handle) renderer.deleteAtlas(character.handle)
|
if (character?.handle) renderer.deleteAtlas(character.handle)
|
||||||
|
for (const art of monsterArtMap.values()) {
|
||||||
|
renderer.deleteAtlas(art.handle)
|
||||||
|
}
|
||||||
for (const page of runtime.pages) {
|
for (const page of runtime.pages) {
|
||||||
if (page !== null) renderer.deleteAtlas(page)
|
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