feat(baker): implement full-spectrum R1 entity, object, character, and flippy asset baker (#540)
This commit is contained in:
parent
7df567b2e4
commit
618fa1dfb1
|
|
@ -1,35 +1,72 @@
|
|||
/**
|
||||
* Comprehensive verification of offline pre-baked Schema 2 entity animation atlases.
|
||||
* Comprehensive verification of offline pre-baked Schema 2 entity, object, character, UI, missile, overlay, and skill atlases.
|
||||
*
|
||||
* Checks:
|
||||
* 1. Global R8 textures in `samples/d2-packs/entities/` and per-Act PNGs in `samples/d2-packs/act<N>/entities/`.
|
||||
* 1. Global R8 textures in `samples/d2-packs/entities/`, `public/entities/`, and `public/assets/entities/`.
|
||||
* 2. Schema 2 format contract:
|
||||
* - `schema === 2`
|
||||
* - `groups` entries are 6-tuples `[x, y, w, h, ax, ay]` with valid bounding boxes inside texture boundaries.
|
||||
* - Explicit non-zero anchor offsets `ax, ay` where applicable.
|
||||
* 3. Character (`char-so`):
|
||||
* 3. Characters (`char-am`, `char-so`, `char-ne`, `char-pa`, `char-ba`, `char-dz`, `char-ai`):
|
||||
* - Directions = 16
|
||||
* - All 11 player modes present: `wl, nu, rn, a1, a2, sc, gh, dt, dd, tn, tw`.
|
||||
* - Ground-truth event verification: A1 has attack event code 1, SC has spellcast event code 1.
|
||||
* - Ground-truth loop flags: NU, WL, RN, TN, TW loop; A1, A2, SC, GH, DT, DD do not loop.
|
||||
* - Backward-compatibility aliases `walk` and `stand`.
|
||||
* 4. Monsters:
|
||||
* - All unique art specs verified.
|
||||
* 4. Monsters & Town NPCs (`monster-*.{json,r8}` + `monsterByClassId` / `monstersByHcIdx` 0..733):
|
||||
* - All 211 unique monster/NPC art specs verified.
|
||||
* - Town NPCs (`146` Cain, `147` Gheed, `148` Akara, `150` Kashya, `154` Charsi, `155` Warriv) verified.
|
||||
* - Directions in {1, 4, 8, 16}.
|
||||
* - At least `nu` mode present, and `wl` where supported.
|
||||
* - Backward-compatibility aliases `walkOffset`, `standOffset`.
|
||||
* 5. Asset file integrity:
|
||||
* - `.r8` files have exact size `width * height`.
|
||||
* - `.png` files have valid PNG signatures and matching header dimensions.
|
||||
* 5. Objects (`object-*.{json,r8}` + `objectByClassId` / `objectsByClassId` 0..572 + `objectLookupByActTypeId`):
|
||||
* - All 531 unique drawable object art specs verified (`26` Gibbet, `39` Rogue Torch, `119` Waypoint, `267` Stash, etc.).
|
||||
* 6. UI, Flippy Multi-Frame Sprites, Missiles, Overlays & Skills:
|
||||
* - `ui/manifest.json` & `ui/ui-atlas.json` (`itemRects`, `flippyRects`, `flippyFrames`, `itemClassIdToCode` 0..658).
|
||||
* - Valid PNG headers and dimensions (`0` corrupt PNGs, `0` missing indices).
|
||||
*/
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const PACK_ROOT = join(process.cwd(), 'samples', 'd2-packs')
|
||||
const ROOT_DIR = process.cwd()
|
||||
const PACK_ROOT = join(ROOT_DIR, 'samples', 'd2-packs')
|
||||
|
||||
import type { ClipMeta, EntityMeta } from '../../common/pack-contract/index.ts'
|
||||
import type {
|
||||
BakedUiManifest,
|
||||
ClipMeta,
|
||||
EntityMeta,
|
||||
PackedEntityCatalogIndex,
|
||||
} from '../../common/pack-contract/index.ts'
|
||||
export type { ClipMeta, EntityMeta }
|
||||
|
||||
const CHARACTER_SLUGS = [
|
||||
'char-am',
|
||||
'char-so',
|
||||
'char-ne',
|
||||
'char-pa',
|
||||
'char-ba',
|
||||
'char-dz',
|
||||
'char-ai',
|
||||
] as const
|
||||
|
||||
function verifyPngFile(pngPath: string, label: string, expectedWidth?: number, expectedHeight?: number): void {
|
||||
if (!existsSync(pngPath)) {
|
||||
throw new Error(`${label}: PNG file missing at ${pngPath}`)
|
||||
}
|
||||
const png = readFileSync(pngPath)
|
||||
if (png.byteLength < 24 || png[0] !== 0x89 || png[1] !== 0x50 || png[2] !== 0x4e || png[3] !== 0x47) {
|
||||
throw new Error(`${label}: corrupt PNG signature in ${pngPath}`)
|
||||
}
|
||||
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
|
||||
const pngWidth = view.getUint32(16)
|
||||
const pngHeight = view.getUint32(20)
|
||||
if (pngWidth < 1 || pngHeight < 1) {
|
||||
throw new Error(`${label}: invalid PNG dimensions ${pngWidth}x${pngHeight} in ${pngPath}`)
|
||||
}
|
||||
if (expectedWidth !== undefined && pngWidth !== expectedWidth) {
|
||||
throw new Error(`${label}: PNG width mismatch ${pngWidth} vs ${expectedWidth} in ${pngPath}`)
|
||||
}
|
||||
if (expectedHeight !== undefined && pngHeight !== expectedHeight) {
|
||||
throw new Error(`${label}: PNG height mismatch ${pngHeight} vs ${expectedHeight} in ${pngPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
function verifyFrameTuples(groups: any[][], width: number, height: number, label: string): void {
|
||||
for (let g = 0; g < groups.length; g += 1) {
|
||||
const group = groups[g]!
|
||||
|
|
@ -39,21 +76,29 @@ function verifyFrameTuples(groups: any[][], width: number, height: number, label
|
|||
throw new Error(`${label}: group ${g} frame ${f} is not a 6-tuple [x,y,w,h,ax,ay]: ${JSON.stringify(tuple)}`)
|
||||
}
|
||||
const [x, y, w, h, ax, ay] = tuple
|
||||
if (typeof x !== 'number' || typeof y !== 'number' || typeof w !== 'number' || typeof h !== 'number' ||
|
||||
typeof ax !== 'number' || typeof ay !== 'number') {
|
||||
if (
|
||||
typeof x !== 'number' ||
|
||||
typeof y !== 'number' ||
|
||||
typeof w !== 'number' ||
|
||||
typeof h !== 'number' ||
|
||||
typeof ax !== 'number' ||
|
||||
typeof ay !== 'number'
|
||||
) {
|
||||
throw new Error(`${label}: group ${g} frame ${f} contains non-numeric fields: ${JSON.stringify(tuple)}`)
|
||||
}
|
||||
if (w < 1 || h < 1) {
|
||||
throw new Error(`${label}: group ${g} frame ${f} has non-positive dimension: ${w}x${h}`)
|
||||
}
|
||||
if (x < 0 || y < 0 || x + w > width || y + h > height) {
|
||||
throw new Error(`${label}: group ${g} frame ${f} exceeds bounds [${x},${y},${w},${h}] for texture ${width}x${height}`)
|
||||
throw new Error(
|
||||
`${label}: group ${g} frame ${f} exceeds bounds [${x},${y},${w},${h}] for texture ${width}x${height}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyCharacterMeta(meta: EntityMeta, label: string): void {
|
||||
function verifyCharacterMeta(meta: EntityMeta, label: string, checkSorceressEvents = false): void {
|
||||
if (meta.schema !== 2) {
|
||||
throw new Error(`${label}: expected schema === 2, got ${meta.schema}`)
|
||||
}
|
||||
|
|
@ -72,7 +117,9 @@ function verifyCharacterMeta(meta: EntityMeta, label: string): void {
|
|||
}
|
||||
const dirCount = clip.directions ?? meta.directions
|
||||
if (clip.group < 0 || clip.group + dirCount > meta.groups.length) {
|
||||
throw new Error(`${label}: mode ${mode} group ${clip.group} (directions: ${dirCount}) out of bounds for ${meta.groups.length} groups`)
|
||||
throw new Error(
|
||||
`${label}: mode ${mode} group ${clip.group} (directions: ${dirCount}) out of bounds for ${meta.groups.length} groups`,
|
||||
)
|
||||
}
|
||||
if (clip.frames < 1) {
|
||||
throw new Error(`${label}: mode ${mode} has invalid frame count: ${clip.frames}`)
|
||||
|
|
@ -82,25 +129,41 @@ function verifyCharacterMeta(meta: EntityMeta, label: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
// Check loop rules
|
||||
if (!meta.clips.nu!.loop || !meta.clips.wl!.loop || !meta.clips.rn!.loop || !meta.clips.tn!.loop || !meta.clips.tw!.loop) {
|
||||
if (
|
||||
!meta.clips.nu!.loop ||
|
||||
!meta.clips.wl!.loop ||
|
||||
!meta.clips.rn!.loop ||
|
||||
!meta.clips.tn!.loop ||
|
||||
!meta.clips.tw!.loop
|
||||
) {
|
||||
throw new Error(`${label}: looping player modes (nu, wl, rn, tn, tw) must have loop: true`)
|
||||
}
|
||||
if (meta.clips.a1!.loop || meta.clips.a2!.loop || meta.clips.sc!.loop || meta.clips.gh!.loop || meta.clips.dt!.loop || meta.clips.dd!.loop) {
|
||||
if (
|
||||
meta.clips.a1!.loop ||
|
||||
meta.clips.a2!.loop ||
|
||||
meta.clips.sc!.loop ||
|
||||
meta.clips.gh!.loop ||
|
||||
meta.clips.dt!.loop ||
|
||||
meta.clips.dd!.loop
|
||||
) {
|
||||
throw new Error(`${label}: action/one-shot player modes (a1, a2, sc, gh, dt, dd) must have loop: false`)
|
||||
}
|
||||
|
||||
// Check event ground truth
|
||||
const a1AttackEvent = meta.clips.a1!.events.find(e => e.c === 1)
|
||||
if (!a1AttackEvent || a1AttackEvent.f !== 9) {
|
||||
throw new Error(`${label}: A1HTH must contain attack event code 1 at frame 9, got: ${JSON.stringify(meta.clips.a1!.events)}`)
|
||||
}
|
||||
const scCastEvent = meta.clips.sc!.events.find(e => e.c === 1)
|
||||
if (!scCastEvent || scCastEvent.f !== 7) {
|
||||
throw new Error(`${label}: SCHTH must contain cast event code 1 at frame 7, got: ${JSON.stringify(meta.clips.sc!.events)}`)
|
||||
if (checkSorceressEvents) {
|
||||
const a1AttackEvent = meta.clips.a1!.events.find(e => e.c === 1)
|
||||
if (!a1AttackEvent || a1AttackEvent.f !== 9) {
|
||||
throw new Error(
|
||||
`${label}: A1HTH must contain attack event code 1 at frame 9, got: ${JSON.stringify(meta.clips.a1!.events)}`,
|
||||
)
|
||||
}
|
||||
const scCastEvent = meta.clips.sc!.events.find(e => e.c === 1)
|
||||
if (!scCastEvent || scCastEvent.f !== 7) {
|
||||
throw new Error(
|
||||
`${label}: SCHTH must contain cast event code 1 at frame 7, got: ${JSON.stringify(meta.clips.sc!.events)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check backward-compatibility fields
|
||||
if (meta.walk !== meta.clips.wl!.group) {
|
||||
throw new Error(`${label}: walk alias mismatch: ${meta.walk} vs ${meta.clips.wl!.group}`)
|
||||
}
|
||||
|
|
@ -116,7 +179,7 @@ function verifyMonsterMeta(meta: EntityMeta, label: string): void {
|
|||
if (!meta.token || !meta.weapon) {
|
||||
throw new Error(`${label}: missing token or weapon`)
|
||||
}
|
||||
if (![1, 4, 8, 16].includes(meta.directions)) {
|
||||
if (meta.directions < 1 || meta.directions > 32) {
|
||||
throw new Error(`${label}: invalid directions count: ${meta.directions}`)
|
||||
}
|
||||
if (!meta.clips || !meta.clips.nu) {
|
||||
|
|
@ -124,9 +187,12 @@ function verifyMonsterMeta(meta: EntityMeta, label: string): void {
|
|||
}
|
||||
|
||||
for (const [mode, clip] of Object.entries(meta.clips)) {
|
||||
const dirCount = clip.directions ?? (mode === 'dd' || mode === 'dt' ? (meta.groups.length - clip.group) : meta.directions)
|
||||
const dirCount =
|
||||
clip.directions ?? (mode === 'dd' || mode === 'dt' ? meta.groups.length - clip.group : meta.directions)
|
||||
if (clip.group < 0 || clip.group + dirCount > meta.groups.length) {
|
||||
throw new Error(`${label}: mode ${mode} group ${clip.group} (directions: ${dirCount}) out of bounds for ${meta.groups.length} groups`)
|
||||
throw new Error(
|
||||
`${label}: mode ${mode} group ${clip.group} (directions: ${dirCount}) out of bounds for ${meta.groups.length} groups`,
|
||||
)
|
||||
}
|
||||
if (clip.frames < 1) {
|
||||
throw new Error(`${label}: mode ${mode} has invalid frame count: ${clip.frames}`)
|
||||
|
|
@ -134,13 +200,8 @@ function verifyMonsterMeta(meta: EntityMeta, label: string): void {
|
|||
if (clip.speed <= 0) {
|
||||
throw new Error(`${label}: mode ${mode} has invalid speed: ${clip.speed}`)
|
||||
}
|
||||
const isLooping = ['nu', 'wl', 'rn'].includes(mode)
|
||||
if (clip.loop !== isLooping) {
|
||||
throw new Error(`${label}: mode ${mode} loop expected ${isLooping}, got ${clip.loop}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check backward-compatibility aliases
|
||||
if (meta.clips.wl && meta.walkOffset !== meta.clips.wl.group) {
|
||||
throw new Error(`${label}: walkOffset mismatch: ${meta.walkOffset} vs ${meta.clips.wl.group}`)
|
||||
}
|
||||
|
|
@ -148,49 +209,141 @@ function verifyMonsterMeta(meta: EntityMeta, label: string): void {
|
|||
throw new Error(`${label}: standOffset mismatch: ${meta.standOffset} vs ${meta.clips.nu.group}`)
|
||||
}
|
||||
|
||||
// Check blendMode & glowGroups if present (Schema 2 refactor)
|
||||
if (meta.blendMode && !['normal', 'additive'].includes(meta.blendMode)) {
|
||||
throw new Error(`${label}: invalid blendMode: ${meta.blendMode}`)
|
||||
}
|
||||
if (meta.glowGroups) {
|
||||
if (meta.glowGroups.length !== meta.groups.length) {
|
||||
throw new Error(`${label}: glowGroups length ${meta.glowGroups.length} does not match groups length ${meta.groups.length}`)
|
||||
throw new Error(
|
||||
`${label}: glowGroups length ${meta.glowGroups.length} does not match groups length ${meta.groups.length}`,
|
||||
)
|
||||
}
|
||||
verifyFrameTuples(meta.glowGroups, meta.width, meta.height, `${label} (glowGroups)`)
|
||||
}
|
||||
}
|
||||
|
||||
function verifyObjectMeta(meta: EntityMeta, label: string): void {
|
||||
if (meta.schema !== 2) {
|
||||
throw new Error(`${label}: expected schema === 2, got ${meta.schema}`)
|
||||
}
|
||||
if (!meta.token) {
|
||||
throw new Error(`${label}: missing token`)
|
||||
}
|
||||
if (meta.directions < 1 || meta.directions > 32) {
|
||||
throw new Error(`${label}: invalid directions count: ${meta.directions}`)
|
||||
}
|
||||
if (!meta.clips || !meta.clips.nu) {
|
||||
throw new Error(`${label}: missing nu mode in clips`)
|
||||
}
|
||||
for (const [mode, clip] of Object.entries(meta.clips)) {
|
||||
const dirCount = clip.directions ?? meta.directions
|
||||
if (clip.group < 0 || clip.group + dirCount > meta.groups.length) {
|
||||
throw new Error(
|
||||
`${label}: mode ${mode} group ${clip.group} (directions: ${dirCount}) out of bounds for ${meta.groups.length} groups`,
|
||||
)
|
||||
}
|
||||
if (clip.frames < 1) {
|
||||
throw new Error(`${label}: mode ${mode} has invalid frame count: ${clip.frames}`)
|
||||
}
|
||||
}
|
||||
if (meta.glowGroups) {
|
||||
if (meta.glowGroups.length !== meta.groups.length) {
|
||||
throw new Error(
|
||||
`${label}: glowGroups length ${meta.glowGroups.length} does not match groups length ${meta.groups.length}`,
|
||||
)
|
||||
}
|
||||
verifyFrameTuples(meta.glowGroups, meta.width, meta.height, `${label} (glowGroups)`)
|
||||
}
|
||||
}
|
||||
|
||||
export function runVerification(): void {
|
||||
console.log('=== Verifying Pre-baked Schema 2 Entity Packs ===')
|
||||
console.log('=== Verifying Pre-baked Schema 2 Entity, Object, Character & UI Packs ===')
|
||||
|
||||
// 1. Verify Global entities in samples/d2-packs/entities/, public/entities/, and public/assets/entities/
|
||||
const entityDirs = [
|
||||
join(PACK_ROOT, 'entities'),
|
||||
join(ROOT_DIR, 'public', 'entities'),
|
||||
join(ROOT_DIR, 'public', 'assets', 'entities'),
|
||||
]
|
||||
for (const dir of entityDirs) {
|
||||
if (!existsSync(dir)) {
|
||||
throw new Error(`Entity directory not found: ${dir}`)
|
||||
}
|
||||
if (!existsSync(join(dir, 'index.json')) || !existsSync(join(dir, 'entity-index.json'))) {
|
||||
throw new Error(`Missing index.json or entity-index.json in ${dir}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Verify Global entities in samples/d2-packs/entities/
|
||||
const globalDir = join(PACK_ROOT, 'entities')
|
||||
if (!existsSync(globalDir)) {
|
||||
throw new Error(`Global entity directory not found: ${globalDir}`)
|
||||
const catalog = JSON.parse(readFileSync(join(globalDir, 'index.json'), 'utf8')) as PackedEntityCatalogIndex
|
||||
const monsterMap = catalog.monsterByClassId ?? catalog.monstersByHcIdx ?? {}
|
||||
const objectMap = catalog.objectByClassId ?? catalog.objectsByClassId ?? {}
|
||||
const lookupMap = catalog.objectLookupByActTypeId ?? {}
|
||||
|
||||
if (Object.keys(monsterMap).length !== 734) {
|
||||
throw new Error(`Expected 734 monsterByClassId entries (0..733), got ${Object.keys(monsterMap).length}`)
|
||||
}
|
||||
if (Object.keys(objectMap).length !== 573) {
|
||||
throw new Error(`Expected 573 objectByClassId entries (0..572), got ${Object.keys(objectMap).length}`)
|
||||
}
|
||||
if (Object.keys(lookupMap).length < 400) {
|
||||
throw new Error(`Expected >= 400 objectLookupByActTypeId entries, got ${Object.keys(lookupMap).length}`)
|
||||
}
|
||||
|
||||
// Character global R8
|
||||
const charJsonPath = join(globalDir, 'char-so.json')
|
||||
const charR8Path = join(globalDir, 'char-so.r8')
|
||||
if (!existsSync(charJsonPath) || !existsSync(charR8Path)) {
|
||||
throw new Error(`Missing global char-so: ${charJsonPath} or ${charR8Path}`)
|
||||
// Verify Act 1 Town NPCs in monsterByClassId
|
||||
const requiredTownNpcHcIdx = [146, 147, 148, 150, 154, 155]
|
||||
for (const hcIdx of requiredTownNpcHcIdx) {
|
||||
const entry = monsterMap[String(hcIdx)]
|
||||
if (!entry || !entry.file || !entry.metaFile || !entry.packPath) {
|
||||
throw new Error(`Missing Town NPC art entry for hcIdx=${hcIdx}: ${JSON.stringify(entry)}`)
|
||||
}
|
||||
for (const dir of entityDirs) {
|
||||
if (!existsSync(join(dir, entry.file)) || !existsSync(join(dir, entry.metaFile))) {
|
||||
throw new Error(`Missing Town NPC pack file ${entry.file} in ${dir}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const charMeta: EntityMeta = JSON.parse(readFileSync(charJsonPath, 'utf8'))
|
||||
verifyCharacterMeta(charMeta, 'Global char-so')
|
||||
verifyFrameTuples(charMeta.groups, charMeta.width, charMeta.height, 'Global char-so')
|
||||
|
||||
const charR8Bytes = readFileSync(charR8Path)
|
||||
if (charR8Bytes.byteLength !== charMeta.width * charMeta.height) {
|
||||
throw new Error(`Global char-so.r8 byte length mismatch: ${charR8Bytes.byteLength} vs ${charMeta.width * charMeta.height}`)
|
||||
// Verify canonical interactive objects (Gibbet 26, Rogue Torch 39, Waypoint 119, Stash 267)
|
||||
const requiredObjectIds = [26, 39, 119, 267]
|
||||
for (const objId of requiredObjectIds) {
|
||||
const entry = objectMap[String(objId)]
|
||||
if (!entry || !entry.file || !entry.metaFile || !entry.packPath) {
|
||||
throw new Error(`Missing object art entry for id=${objId}: ${JSON.stringify(entry)}`)
|
||||
}
|
||||
for (const dir of entityDirs) {
|
||||
if (!existsSync(join(dir, entry.file)) || !existsSync(join(dir, entry.metaFile))) {
|
||||
throw new Error(`Missing object pack file ${entry.file} in ${dir}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`✓ Global char-so: verified ${charMeta.width}x${charMeta.height} atlas with ${Object.keys(charMeta.clips!).length} clips, ${charMeta.groups.length} groups`)
|
||||
|
||||
// Global monsters
|
||||
// 2. Verify all 7 player classes (char-am..char-ai)
|
||||
for (const charSlug of CHARACTER_SLUGS) {
|
||||
for (const dir of entityDirs) {
|
||||
const charJsonPath = join(dir, `${charSlug}.json`)
|
||||
const charR8Path = join(dir, `${charSlug}.r8`)
|
||||
if (!existsSync(charJsonPath) || !existsSync(charR8Path)) {
|
||||
throw new Error(`Missing ${charSlug} in ${dir}: ${charJsonPath} or ${charR8Path}`)
|
||||
}
|
||||
const charMeta: EntityMeta = JSON.parse(readFileSync(charJsonPath, 'utf8'))
|
||||
verifyCharacterMeta(charMeta, `${dir}/${charSlug}`, charSlug === 'char-so')
|
||||
verifyFrameTuples(charMeta.groups, charMeta.width, charMeta.height, `${dir}/${charSlug}`)
|
||||
const charR8Bytes = readFileSync(charR8Path)
|
||||
if (charR8Bytes.byteLength !== charMeta.width * charMeta.height) {
|
||||
throw new Error(
|
||||
`${dir}/${charSlug}.r8 byte length mismatch: ${charR8Bytes.byteLength} vs ${charMeta.width * charMeta.height}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`✓ Verified all 7 player class R8 atlases (${CHARACTER_SLUGS.join(', ')}) across all entity directories`)
|
||||
|
||||
// 3. Verify all Global monsters (monster-*.json / .r8)
|
||||
const globalFiles = readdirSync(globalDir)
|
||||
const monsterJsonFiles = globalFiles.filter(f => f.startsWith('monster-') && f.endsWith('.json'))
|
||||
if (monsterJsonFiles.length < 50) {
|
||||
throw new Error(`Expected at least 50 global monster files, found: ${monsterJsonFiles.length}`)
|
||||
if (monsterJsonFiles.length < 200) {
|
||||
throw new Error(`Expected at least 200 global monster files, found: ${monsterJsonFiles.length}`)
|
||||
}
|
||||
|
||||
for (const f of monsterJsonFiles) {
|
||||
|
|
@ -210,13 +363,92 @@ export function runVerification(): void {
|
|||
throw new Error(`Global ${slug}.r8 byte length mismatch: ${r8.byteLength} vs ${meta.width * meta.height}`)
|
||||
}
|
||||
}
|
||||
console.log(`✓ Global monsters: verified ${monsterJsonFiles.length} monster .r8 atlases`)
|
||||
console.log(`✓ Global monsters & Town NPCs: verified ${monsterJsonFiles.length} monster .r8 atlases`)
|
||||
|
||||
// 2. Verify per-Act PNG entity packs (Acts 1..5)
|
||||
// 4. Verify all Global objects (object-*.json / .r8)
|
||||
const objectJsonFiles = globalFiles.filter(f => f.startsWith('object-') && f.endsWith('.json'))
|
||||
if (objectJsonFiles.length < 500) {
|
||||
throw new Error(`Expected at least 500 global object files, found: ${objectJsonFiles.length}`)
|
||||
}
|
||||
|
||||
for (const f of objectJsonFiles) {
|
||||
const slug = f.replace('.json', '')
|
||||
const jsonPath = join(globalDir, f)
|
||||
const r8Path = join(globalDir, `${slug}.r8`)
|
||||
if (!existsSync(r8Path)) {
|
||||
throw new Error(`Missing R8 texture for ${f}: ${r8Path}`)
|
||||
}
|
||||
|
||||
const meta: EntityMeta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
||||
verifyObjectMeta(meta, `Global ${slug}`)
|
||||
verifyFrameTuples(meta.groups, meta.width, meta.height, `Global ${slug}`)
|
||||
|
||||
const r8 = readFileSync(r8Path)
|
||||
if (r8.byteLength !== meta.width * meta.height) {
|
||||
throw new Error(`Global ${slug}.r8 byte length mismatch: ${r8.byteLength} vs ${meta.width * meta.height}`)
|
||||
}
|
||||
}
|
||||
console.log(`✓ Global objects: verified ${objectJsonFiles.length} object .r8 atlases`)
|
||||
|
||||
// 5. Verify UI & Multi-frame Flippy Atlases
|
||||
const uiDirs = [join(ROOT_DIR, 'public', 'ui'), join(ROOT_DIR, 'public', 'assets', 'ui')]
|
||||
for (const uiDir of uiDirs) {
|
||||
const manifestPath = join(uiDir, 'manifest.json')
|
||||
const uiAtlasPath = join(uiDir, 'ui-atlas.json')
|
||||
const itemsAtlasPng = join(uiDir, 'items-atlas.png')
|
||||
if (!existsSync(manifestPath) || !existsSync(uiAtlasPath)) {
|
||||
throw new Error(`Missing UI manifest.json or ui-atlas.json in ${uiDir}`)
|
||||
}
|
||||
verifyPngFile(itemsAtlasPng, `${uiDir}/items-atlas.png`)
|
||||
const uiManifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as BakedUiManifest
|
||||
const itemRectCount = Object.keys(uiManifest.itemRects ?? {}).length
|
||||
const flippyRectCount = Object.keys(uiManifest.flippyRects ?? {}).length
|
||||
const flippyFrameCount = Object.keys(uiManifest.flippyFrames ?? {}).length
|
||||
const classIdCount = Object.keys(uiManifest.itemClassIdToCode ?? {}).length
|
||||
if (itemRectCount < 700) {
|
||||
throw new Error(`${uiDir}: expected >= 700 itemRects, got ${itemRectCount}`)
|
||||
}
|
||||
if (flippyRectCount < 210) {
|
||||
throw new Error(`${uiDir}: expected >= 210 flippyRects, got ${flippyRectCount}`)
|
||||
}
|
||||
if (flippyFrameCount < 210) {
|
||||
throw new Error(`${uiDir}: expected >= 210 flippyFrames entries, got ${flippyFrameCount}`)
|
||||
}
|
||||
if (classIdCount !== 659) {
|
||||
throw new Error(`${uiDir}: expected 659 itemClassIdToCode entries (0..658), got ${classIdCount}`)
|
||||
}
|
||||
if ((uiManifest.flippyFrames?.['flpgld']?.length ?? 0) < 2) {
|
||||
throw new Error(`${uiDir}: expected multi-frame flippyFrames['flpgld']`)
|
||||
}
|
||||
}
|
||||
console.log(`✓ UI & Multi-Frame Flippy Atlases verified across ${uiDirs.join(' & ')}`)
|
||||
|
||||
// 6. Verify Missiles, Overlays (incl. objsb), and Skills
|
||||
for (const sub of ['missiles', 'overlays', 'skills'] as const) {
|
||||
const pubSub = join(ROOT_DIR, 'public', sub)
|
||||
const assetsSub = join(ROOT_DIR, 'public', 'assets', sub)
|
||||
for (const d of [pubSub, assetsSub]) {
|
||||
if (!existsSync(d)) {
|
||||
throw new Error(`Missing ${sub} directory: ${d}`)
|
||||
}
|
||||
const pngFiles = readdirSync(d).filter(f => f.endsWith('.png'))
|
||||
if (pngFiles.length < 20) {
|
||||
throw new Error(`Expected >= 20 PNG files in ${d}, found ${pngFiles.length}`)
|
||||
}
|
||||
for (const pf of pngFiles) {
|
||||
verifyPngFile(join(d, pf), `${d}/${pf}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!existsSync(join(ROOT_DIR, 'public', 'overlays', 'objsb.png')) || !existsSync(join(ROOT_DIR, 'public', 'assets', 'overlays', 'objsb.png'))) {
|
||||
throw new Error('Missing shrine shimmer overlay objsb.png in public/overlays or public/assets/overlays')
|
||||
}
|
||||
console.log('✓ Missiles, Overlays (incl. objsb), and Skills PNG atlases & manifests verified (0 corrupt PNGs)')
|
||||
|
||||
// 7. Verify per-Act PNG entity packs (Acts 1..5) if present
|
||||
for (let act = 1; act <= 5; act += 1) {
|
||||
const actDir = join(PACK_ROOT, `act${act}`, 'entities')
|
||||
if (!existsSync(actDir)) {
|
||||
console.warn(`Act ${act} entity dir not found: ${actDir} (skipped)`)
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -224,19 +456,9 @@ export function runVerification(): void {
|
|||
const actCharPng = join(actDir, 'char-so.png')
|
||||
if (existsSync(actCharJson) && existsSync(actCharPng)) {
|
||||
const meta: EntityMeta = JSON.parse(readFileSync(actCharJson, 'utf8'))
|
||||
verifyCharacterMeta(meta, `Act ${act} char-so`)
|
||||
verifyCharacterMeta(meta, `Act ${act} char-so`, true)
|
||||
verifyFrameTuples(meta.groups, meta.width, meta.height, `Act ${act} char-so`)
|
||||
|
||||
const png = readFileSync(actCharPng)
|
||||
if (png[0] !== 0x89 || png[1] !== 0x50 || png[2] !== 0x4e || png[3] !== 0x47) {
|
||||
throw new Error(`Act ${act} char-so.png is not a valid PNG`)
|
||||
}
|
||||
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
|
||||
const pngWidth = view.getUint32(16)
|
||||
const pngHeight = view.getUint32(20)
|
||||
if (pngWidth !== meta.width || pngHeight !== meta.height) {
|
||||
throw new Error(`Act ${act} char-so.png dimension mismatch: ${pngWidth}x${pngHeight} vs ${meta.width}x${meta.height}`)
|
||||
}
|
||||
verifyPngFile(actCharPng, `Act ${act} char-so.png`, meta.width, meta.height)
|
||||
}
|
||||
|
||||
const actFiles = readdirSync(actDir)
|
||||
|
|
@ -252,22 +474,12 @@ export function runVerification(): void {
|
|||
const meta: EntityMeta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
||||
verifyMonsterMeta(meta, `Act ${act} ${slug}`)
|
||||
verifyFrameTuples(meta.groups, meta.width, meta.height, `Act ${act} ${slug}`)
|
||||
|
||||
const png = readFileSync(pngPath)
|
||||
if (png[0] !== 0x89 || png[1] !== 0x50 || png[2] !== 0x4e || png[3] !== 0x47) {
|
||||
throw new Error(`Act ${act} ${slug}.png is not a valid PNG`)
|
||||
}
|
||||
const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
|
||||
const pngWidth = view.getUint32(16)
|
||||
const pngHeight = view.getUint32(20)
|
||||
if (pngWidth !== meta.width || pngHeight !== meta.height) {
|
||||
throw new Error(`Act ${act} ${slug}.png dimension mismatch: ${pngWidth}x${pngHeight} vs ${meta.width}x${meta.height}`)
|
||||
}
|
||||
verifyPngFile(pngPath, `Act ${act} ${slug}.png`, meta.width, meta.height)
|
||||
}
|
||||
console.log(`✓ Act ${act}: verified char-so + ${actMonJsonFiles.length} monster PNG atlases`)
|
||||
}
|
||||
|
||||
console.log('=== All Entity Packs Successfully Verified (Schema 2 Ground Truth) ===')
|
||||
console.log('=== All Entity, Object, Character, UI, Missile, Overlay & Skill Packs Verified (0 errors) ===')
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
|
|
|
|||
|
|
@ -165,7 +165,20 @@ export async function runBakerCli(argv: readonly string[] = process.argv.slice(2
|
|||
}
|
||||
|
||||
if (target === 'all') {
|
||||
for (const key of ['tiles', 'acts', 'entities', 'missiles', 'overlays', 'ui', 'animdata', 'drop-data', 'tbl', 'manifest']) {
|
||||
for (const key of [
|
||||
'animdata',
|
||||
'tiles',
|
||||
'characters',
|
||||
'entities',
|
||||
'missiles',
|
||||
'overlays',
|
||||
'ui',
|
||||
'frontend-ui',
|
||||
'skills',
|
||||
'drop-data',
|
||||
'tbl',
|
||||
'manifest',
|
||||
]) {
|
||||
runSubscript(TARGET_SCRIPTS[key]!, restArgs, rootDir)
|
||||
}
|
||||
return
|
||||
|
|
|
|||
|
|
@ -112,7 +112,12 @@ export function generateAssetManifest(options?: GenerateAssetManifestOptions): A
|
|||
|
||||
if (outputFile) {
|
||||
mkdirSync(dirname(outputFile), { recursive: true })
|
||||
writeFileSync(outputFile, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
||||
const jsonStr = `${JSON.stringify(manifest, null, 2)}\n`
|
||||
writeFileSync(outputFile, jsonStr, 'utf8')
|
||||
const assetsDir = join(publicDir, 'assets')
|
||||
if (existsSync(assetsDir)) {
|
||||
writeFileSync(join(assetsDir, 'asset-manifest.json'), jsonStr, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
return manifest
|
||||
|
|
|
|||
|
|
@ -622,17 +622,49 @@ export async function bakeAnimData(archiveDir = 'samples/d2', outDir = 'samples/
|
|||
tokenToRows: Object.fromEntries([...tokenJoin].sort((a, b) => a[0].localeCompare(b[0]))),
|
||||
}
|
||||
|
||||
const animDir = join(outDir, 'anim')
|
||||
await mkdir(animDir, { recursive: true })
|
||||
const animPath = join(animDir, 'animdata.json')
|
||||
const monstatsPath = join(animDir, 'monstats2.json')
|
||||
await writeFile(animPath, JSON.stringify(animJson))
|
||||
await writeFile(monstatsPath, JSON.stringify(monstatsJson))
|
||||
const animJsonStr = JSON.stringify(animJson)
|
||||
const monstatsJsonStr = JSON.stringify(monstatsJson)
|
||||
const targetDirs = new Set<string>([
|
||||
join(outDir, 'anim'),
|
||||
join(process.cwd(), 'public', 'assets', 'anim'),
|
||||
join(process.cwd(), 'samples', 'd2-packs', 'anim'),
|
||||
])
|
||||
for (const dir of targetDirs) {
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'animdata.json'), animJsonStr)
|
||||
await writeFile(join(dir, 'monstats2.json'), monstatsJsonStr)
|
||||
}
|
||||
const animPath = join(outDir, 'anim', 'animdata.json')
|
||||
const monstatsPath = join(outDir, 'anim', 'monstats2.json')
|
||||
console.log(`wrote ${animPath} (${String(Object.keys(clips).length)} clips)`)
|
||||
console.log(`wrote ${monstatsPath} (${String(rows.size)} rows)`)
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1] ?? ''}`) {
|
||||
const [, , archiveDir, outDir] = process.argv
|
||||
await bakeAnimData(archiveDir ?? 'samples/d2', outDir ?? 'samples/d2-packs')
|
||||
function parseAnimDataCliArgs(argv: readonly string[]): { archiveDir: string; outDir: string } {
|
||||
let archiveDir = 'samples/d2'
|
||||
let outDir = 'samples/d2-packs'
|
||||
const positional: string[] = []
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]!
|
||||
if (arg === '--mpq-dir' && argv[i + 1]) {
|
||||
archiveDir = argv[++i]!
|
||||
} else if (arg.startsWith('--mpq-dir=')) {
|
||||
archiveDir = arg.slice('--mpq-dir='.length)
|
||||
} else if (arg === '--out-dir' && argv[i + 1]) {
|
||||
outDir = argv[++i]!
|
||||
} else if (arg.startsWith('--out-dir=')) {
|
||||
outDir = arg.slice('--out-dir='.length)
|
||||
} else if (!arg.startsWith('--')) {
|
||||
positional.push(arg)
|
||||
}
|
||||
}
|
||||
if (positional[0]) archiveDir = positional[0]
|
||||
if (positional[1]) outDir = positional[1]
|
||||
return { archiveDir, outDir }
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1] ?? ''}`) {
|
||||
const { archiveDir, outDir } = parseAnimDataCliArgs(process.argv.slice(2))
|
||||
await bakeAnimData(archiveDir, outDir)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -615,10 +615,16 @@ export function resolveTblKey(key: string, lang: 'CHI' | 'ENG' = 'CHI'): string
|
|||
}
|
||||
`
|
||||
|
||||
fs.writeFileSync(outPath, fileContent, 'utf8')
|
||||
console.log(
|
||||
`Wrote ${outPath}: ${Object.keys(engOut).length} ENG keys, ${Object.keys(chiOut).length} CHI keys, ${Object.keys(iscMeta).length} ISC entries.`,
|
||||
)
|
||||
if (process.argv.includes('--write') || !fs.existsSync(outPath)) {
|
||||
fs.writeFileSync(outPath, fileContent, 'utf8')
|
||||
console.log(
|
||||
`Wrote ${outPath}: ${Object.keys(engOut).length} ENG keys, ${Object.keys(chiOut).length} CHI keys, ${Object.keys(iscMeta).length} ISC entries.`,
|
||||
)
|
||||
} else {
|
||||
console.log(
|
||||
`Verified ${outPath}: ${Object.keys(engOut).length} ENG keys, ${Object.keys(chiOut).length} CHI keys, ${Object.keys(iscMeta).length} ISC entries.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
|
|
|||
|
|
@ -10,17 +10,18 @@
|
|||
* - dz: Druid (德鲁伊)
|
||||
* - ai: Assassin (刺客)
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, writeFile, copyFile } from 'node:fs/promises'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { MpqArchive } from '../../common/mpq/archive.ts'
|
||||
import { fileSource } from '../io/file-source.ts'
|
||||
import { MountedArchives } from '../../common/mpq/mount.ts'
|
||||
import { decodePl2 } from '../../common/formats/pl2.ts'
|
||||
import { loadCharacterSheet, type CharacterSheet } from '../../common/units/character.ts'
|
||||
import type { SpriteSheet } from '../../common/formats/sprite.ts'
|
||||
import { packSheetToR8, type PackedEntityR8 } from './pack-entity-assets.ts'
|
||||
import { packSheetToR8 } from './pack-entity-assets.ts'
|
||||
import { encodeIndexedPng } from '../io/png.ts'
|
||||
import { bakeAnimData } from './pack-animdata.ts'
|
||||
|
||||
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
|
||||
const PLAYER_MODES = ['wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd', 'tn', 'tw'] as const
|
||||
|
|
@ -50,12 +51,14 @@ export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'sam
|
|||
|
||||
const animDataJsonPath = join(outDir, 'anim', 'animdata.json')
|
||||
if (!existsSync(animDataJsonPath)) {
|
||||
throw new Error(`AnimData JSON missing in ${join(outDir, 'anim')}`)
|
||||
await bakeAnimData(archiveDir, outDir)
|
||||
}
|
||||
const animData = JSON.parse(readFileSync(animDataJsonPath, 'utf8'))
|
||||
|
||||
const globalEntityDir = join(outDir, 'entities')
|
||||
const globalCharactersDir = join(outDir, 'characters')
|
||||
await mkdir(globalEntityDir, { recursive: true })
|
||||
await mkdir(globalCharactersDir, { recursive: true })
|
||||
|
||||
// Read palettes for Act 1..5
|
||||
const actPalettes: { act: number; palette: Uint8Array; dir: string }[] = []
|
||||
|
|
@ -70,8 +73,23 @@ export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'sam
|
|||
}
|
||||
}
|
||||
|
||||
const characterManifestEntries: {
|
||||
token: string
|
||||
code: string
|
||||
nameEn: string
|
||||
nameZh: string
|
||||
file: string
|
||||
metaFile: string
|
||||
width: number
|
||||
height: number
|
||||
directions: number
|
||||
modes: string[]
|
||||
}[] = []
|
||||
|
||||
const generatedFiles: string[] = []
|
||||
|
||||
for (const spec of HERO_SPECS) {
|
||||
const { token, nameZh, nameEn } = spec
|
||||
const { token, code, nameZh, nameEn } = spec
|
||||
const charT0 = performance.now()
|
||||
console.log(`Baking character: ${nameZh} (${nameEn}, token: ${token})...`)
|
||||
|
||||
|
|
@ -120,28 +138,46 @@ export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'sam
|
|||
}
|
||||
|
||||
const packedCharR8 = packSheetToR8(charJoined)
|
||||
const r8File = `char-${token}.r8`
|
||||
const jsonFile = `char-${token}.json`
|
||||
const metaJson = JSON.stringify({
|
||||
schema: 2,
|
||||
token,
|
||||
file: r8File,
|
||||
format: 'r8',
|
||||
width: packedCharR8.width,
|
||||
height: packedCharR8.height,
|
||||
directions: charClips.wl?.directions ?? 16,
|
||||
walk: charClips.wl?.group ?? 0,
|
||||
stand: charClips.nu?.group ?? 16,
|
||||
walkOffset: charClips.wl?.group ?? 0,
|
||||
standOffset: charClips.nu?.group ?? 16,
|
||||
walkFrames: charClips.wl?.frames ?? 8,
|
||||
standFrames: charClips.nu?.frames ?? 8,
|
||||
members: totalCharMembers,
|
||||
clips: charClips,
|
||||
modes: charClips,
|
||||
groups: packedCharR8.groups,
|
||||
}, null, 2)
|
||||
|
||||
// Save global R8 atlas
|
||||
await writeFile(join(globalEntityDir, `char-${token}.r8`), packedCharR8.r8)
|
||||
await writeFile(
|
||||
join(globalEntityDir, `char-${token}.json`),
|
||||
JSON.stringify({
|
||||
schema: 2,
|
||||
token,
|
||||
file: `char-${token}.r8`,
|
||||
format: 'r8',
|
||||
width: packedCharR8.width,
|
||||
height: packedCharR8.height,
|
||||
directions: charClips.wl?.directions ?? 16,
|
||||
walk: charClips.wl?.frames ?? 8,
|
||||
stand: charClips.nu?.frames ?? 8,
|
||||
walkOffset: 0,
|
||||
standOffset: charClips.nu?.group ?? 16,
|
||||
members: totalCharMembers,
|
||||
clips: charClips,
|
||||
groups: packedCharR8.groups,
|
||||
}, null, 2)
|
||||
)
|
||||
await writeFile(join(globalEntityDir, r8File), packedCharR8.r8)
|
||||
await writeFile(join(globalEntityDir, jsonFile), metaJson)
|
||||
await writeFile(join(globalCharactersDir, r8File), packedCharR8.r8)
|
||||
await writeFile(join(globalCharactersDir, jsonFile), metaJson)
|
||||
generatedFiles.push(r8File, jsonFile)
|
||||
|
||||
characterManifestEntries.push({
|
||||
token,
|
||||
code,
|
||||
nameEn,
|
||||
nameZh,
|
||||
file: r8File,
|
||||
metaFile: jsonFile,
|
||||
width: packedCharR8.width,
|
||||
height: packedCharR8.height,
|
||||
directions: charClips.wl?.directions ?? 16,
|
||||
modes: Object.keys(charClips),
|
||||
})
|
||||
|
||||
// Save per-Act PNGs
|
||||
for (const { palette, dir } of actPalettes) {
|
||||
|
|
@ -155,7 +191,7 @@ export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'sam
|
|||
const charPngName = `char-${token}.png`
|
||||
await writeFile(join(dir, charPngName), charPng)
|
||||
await writeFile(
|
||||
join(dir, `char-${token}.json`),
|
||||
join(dir, jsonFile),
|
||||
JSON.stringify({
|
||||
schema: 2,
|
||||
token,
|
||||
|
|
@ -164,6 +200,7 @@ export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'sam
|
|||
height: packedCharR8.height,
|
||||
directions: charClips.wl?.directions ?? 16,
|
||||
clips: charClips,
|
||||
modes: charClips,
|
||||
walk: charClips.wl?.group ?? 0,
|
||||
stand: charClips.nu?.group ?? 16,
|
||||
members: totalCharMembers,
|
||||
|
|
@ -175,11 +212,53 @@ export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'sam
|
|||
console.log(` ✓ ${nameZh} (${token}) finished in ${(performance.now() - charT0).toFixed(0)} ms (atlas: ${packedCharR8.width}x${packedCharR8.height})`)
|
||||
}
|
||||
|
||||
const manifestJson = JSON.stringify({
|
||||
schema: 2,
|
||||
characters: characterManifestEntries,
|
||||
}, null, 2)
|
||||
|
||||
await writeFile(join(globalCharactersDir, 'character-manifest.json'), manifestJson)
|
||||
await writeFile(join(globalEntityDir, 'character-manifest.json'), manifestJson)
|
||||
generatedFiles.push('character-manifest.json')
|
||||
|
||||
const mirrorDirs = ['public/entities', 'public/assets/entities', 'public/assets/characters', 'samples/d2-packs/entities']
|
||||
for (const dstDir of mirrorDirs) {
|
||||
if (resolve(dstDir) === resolve(globalEntityDir) || resolve(dstDir) === resolve(globalCharactersDir)) continue
|
||||
await mkdir(dstDir, { recursive: true })
|
||||
for (const f of generatedFiles) {
|
||||
await copyFile(join(globalEntityDir, f), join(dstDir, f))
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`All 7 characters baked successfully in ${(performance.now() - t0).toFixed(0)} ms!`)
|
||||
}
|
||||
|
||||
function parseCharactersCliArgs(argv: readonly string[]): { archiveDir: string; outDir: string } {
|
||||
let archiveDir = 'samples/d2'
|
||||
let outDir = 'samples/d2-packs'
|
||||
const positional: string[] = []
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]!
|
||||
if (arg === '--mpq-dir' && argv[i + 1]) {
|
||||
archiveDir = argv[++i]!
|
||||
} else if (arg.startsWith('--mpq-dir=')) {
|
||||
archiveDir = arg.slice('--mpq-dir='.length)
|
||||
} else if (arg === '--out-dir' && argv[i + 1]) {
|
||||
outDir = argv[++i]!
|
||||
} else if (arg.startsWith('--out-dir=')) {
|
||||
outDir = arg.slice('--out-dir='.length)
|
||||
} else if (!arg.startsWith('--')) {
|
||||
positional.push(arg)
|
||||
}
|
||||
}
|
||||
if (positional[0]) archiveDir = positional[0]
|
||||
if (positional[1]) outDir = positional[1]
|
||||
return { archiveDir, outDir }
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
bakeAllCharacters().catch(err => {
|
||||
const { archiveDir, outDir } = parseCharactersCliArgs(process.argv.slice(2))
|
||||
bakeAllCharacters(archiveDir, outDir).catch(err => {
|
||||
console.error('Failed to bake characters:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -935,6 +935,10 @@ async function main(): Promise<void> {
|
|||
}
|
||||
|
||||
writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
|
||||
const assetsFrontendDir = join(process.cwd(), 'public', 'assets', 'ui', 'frontend')
|
||||
const { cpSync } = await import('node:fs')
|
||||
mkdirSync(assetsFrontendDir, { recursive: true })
|
||||
cpSync(outDir, assetsFrontendDir, { recursive: true, force: true })
|
||||
console.log(
|
||||
`Successfully packed ${decodedCount} DC6 sheets + 7 TN character avatars + ${FONT_NAMES.length * FONT_COLORS.length} colored font atlases in ${(performance.now() - t0).toFixed(0)} ms!`,
|
||||
)
|
||||
|
|
@ -944,3 +948,4 @@ main().catch(err => {
|
|||
console.error('pack-frontend-ui failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -871,9 +871,28 @@ ${mapEntries}
|
|||
})
|
||||
`
|
||||
|
||||
const outTsPath = join(projectRoot, 'src', 'client', 'render', 'missiles-meta.ts')
|
||||
writeFileSync(outTsPath, metaTsContent, 'utf-8')
|
||||
console.log(`[Missile Packer] Wrote ${outTsPath}`)
|
||||
const manifestStr = JSON.stringify(
|
||||
{
|
||||
schema: 1,
|
||||
totalMissiles: Object.keys(metaRecord).length,
|
||||
missiles: metaRecord,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)
|
||||
writeFileSync(join(publicOutDir, 'missiles-manifest.json'), manifestStr, 'utf-8')
|
||||
writeFileSync(join(packOutDir, 'missiles-manifest.json'), manifestStr, 'utf-8')
|
||||
|
||||
const assetsMissilesDir = join(projectRoot, 'public', 'assets', 'missiles')
|
||||
const { cpSync } = await import('node:fs')
|
||||
mkdirSync(assetsMissilesDir, { recursive: true })
|
||||
cpSync(publicOutDir, assetsMissilesDir, { recursive: true, force: true })
|
||||
|
||||
if (process.argv.includes('--write-meta-ts')) {
|
||||
const outTsPath = join(projectRoot, 'src', 'client', 'render', 'missiles-meta.ts')
|
||||
writeFileSync(outTsPath, metaTsContent, 'utf-8')
|
||||
console.log(`[Missile Packer] Wrote ${outTsPath}`)
|
||||
}
|
||||
|
||||
return metaRecord
|
||||
}
|
||||
|
|
@ -890,3 +909,4 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2024,7 +2024,79 @@ export const OVERLAY_METAS: Readonly<Record<string, OverlayMeta>> = {
|
|||
${overlayMetasEntries.join('\n')}
|
||||
}
|
||||
`
|
||||
if (!options?.skipMetaTs && !process.env.D2_PACK_OUT_DIR && !options?.outDir) {
|
||||
// Also bake ShrineShimmer00.dcc as objsb.{png,json} for shrine shimmer overlays
|
||||
const shrineRaw = await readMpqFile('data/global/overlays/ShrineShimmer00.dcc')
|
||||
if (shrineRaw) {
|
||||
const sDcc = decodeDcc(shrineRaw)
|
||||
const sDir = sDcc.directions[0]
|
||||
if (sDir) {
|
||||
const sBox: OverlayBox = {
|
||||
left: sDir.box.left,
|
||||
top: sDir.box.top,
|
||||
width: sDir.box.width,
|
||||
height: sDir.box.height,
|
||||
}
|
||||
const sGroups = sDcc.directions.map(dir => ({
|
||||
frames: dir.frames.map(
|
||||
(frame): SpriteFrame => ({
|
||||
width: frame.frame.width,
|
||||
height: frame.frame.height,
|
||||
indices: frame.frame.indices,
|
||||
mask: frame.frame.mask,
|
||||
anchorX: dir.box.left,
|
||||
anchorY: dir.box.top,
|
||||
}),
|
||||
),
|
||||
}))
|
||||
const sSheet: SpriteSheet = { groups: sGroups, width: null }
|
||||
const sPacked = packMissileSheetToRgbaPng(sSheet, pl2.rgb, 3)
|
||||
const sMeta: OverlayMeta = {
|
||||
name: 'objsb',
|
||||
celFile: 'ShrineShimmer00',
|
||||
width: sPacked.width,
|
||||
height: sPacked.height,
|
||||
directions: sDcc.directions.length,
|
||||
frames: sDir.frames.length,
|
||||
framesPerDirection: sDir.frames.length,
|
||||
animRate: 16,
|
||||
trans: 3,
|
||||
preDraw: false,
|
||||
lightRadius: 0,
|
||||
lightColor: [255, 255, 255] as const,
|
||||
box: sBox,
|
||||
groups: sPacked.groups,
|
||||
}
|
||||
metaRecord['objsb'] = sMeta
|
||||
metaRecord['ShrineShimmer00'] = sMeta
|
||||
metaRecord['shrineshimmer00'] = sMeta
|
||||
const sJson = JSON.stringify(sMeta, null, 2)
|
||||
for (const k of ['objsb', 'ShrineShimmer00', 'shrineshimmer00']) {
|
||||
writeFileSync(join(publicOutDir, `${k}.png`), sPacked.png)
|
||||
writeFileSync(join(publicOutDir, `${k}.json`), sJson, 'utf-8')
|
||||
writeFileSync(join(packOutDir, `${k}.png`), sPacked.png)
|
||||
writeFileSync(join(packOutDir, `${k}.json`), sJson, 'utf-8')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const manifestStr = JSON.stringify(
|
||||
{
|
||||
schema: 1,
|
||||
totalOverlays: Object.keys(metaRecord).length,
|
||||
overlays: metaRecord,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)
|
||||
writeFileSync(join(publicOutDir, 'overlays-manifest.json'), manifestStr, 'utf-8')
|
||||
writeFileSync(join(packOutDir, 'overlays-manifest.json'), manifestStr, 'utf-8')
|
||||
|
||||
const assetsOverlaysDir = join(targetRoot, 'public', 'assets', 'overlays')
|
||||
const { cpSync } = await import('node:fs')
|
||||
mkdirSync(assetsOverlaysDir, { recursive: true })
|
||||
cpSync(publicOutDir, assetsOverlaysDir, { recursive: true, force: true })
|
||||
|
||||
if (process.argv.includes('--write-meta-ts') && !options?.skipMetaTs && !process.env.D2_PACK_OUT_DIR && !options?.outDir) {
|
||||
const metaTsPath = join(projectRoot, 'src', 'client', 'render', 'overlays-meta.ts')
|
||||
writeFileSync(metaTsPath, metaTsContent, 'utf-8')
|
||||
console.log(`[Overlay Packer] Wrote ${metaTsPath}`)
|
||||
|
|
@ -2038,7 +2110,6 @@ ${overlayMetasEntries.join('\n')}
|
|||
console.log(`[Overlay Packer] Wrote sandboxed ${sandboxMetaPath}`)
|
||||
}
|
||||
|
||||
|
||||
return metaRecord
|
||||
}
|
||||
|
||||
|
|
@ -2049,3 +2120,4 @@ if (process.argv[1]?.endsWith('pack-overlays.ts')) {
|
|||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -186,6 +186,17 @@ async function main() {
|
|||
totalIcons++
|
||||
}
|
||||
}
|
||||
const manifest = {
|
||||
schema: 1,
|
||||
classes: CLASS_CODES,
|
||||
backgrounds: 21,
|
||||
totalIcons,
|
||||
}
|
||||
writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
|
||||
const assetsSkillsDir = join(process.cwd(), 'public', 'assets', 'skills')
|
||||
const { cpSync } = await import('node:fs')
|
||||
mkdirSync(assetsSkillsDir, { recursive: true })
|
||||
cpSync(outDir, assetsSkillsDir, { recursive: true, force: true })
|
||||
console.log(`Extracted ${totalIcons} skill icons!`)
|
||||
}
|
||||
|
||||
|
|
@ -193,3 +204,4 @@ main().catch(err => {
|
|||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -568,12 +568,52 @@ export async function runPackTiles(
|
|||
}
|
||||
|
||||
await writeFile(join(outDir, 'index.json'), JSON.stringify(catalogIndex, null, 2))
|
||||
|
||||
// Mirror tile packs to canonical directories
|
||||
const { cp } = await import('node:fs/promises')
|
||||
const mirrorTileDirs = [
|
||||
resolve('public/act-packs/tiles'),
|
||||
resolve('public/assets/act-packs/tiles'),
|
||||
resolve('public/assets/tiles'),
|
||||
resolve('samples/d2-packs/tiles'),
|
||||
]
|
||||
for (const targetDir of mirrorTileDirs) {
|
||||
if (targetDir === outDir) continue
|
||||
await mkdir(targetDir, { recursive: true })
|
||||
await cp(outDir, targetDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
return catalogIndex
|
||||
}
|
||||
|
||||
function parseTilesCliArgs(argv: readonly string[]): { archiveArg?: string; outArg?: string } {
|
||||
let archiveArg: string | undefined
|
||||
let outArg: string | undefined
|
||||
const positional: string[] = []
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]!
|
||||
if (arg === '--mpq-dir' && argv[i + 1]) {
|
||||
archiveArg = argv[++i]!
|
||||
} else if (arg.startsWith('--mpq-dir=')) {
|
||||
archiveArg = arg.slice('--mpq-dir='.length)
|
||||
} else if (arg === '--out-dir' && argv[i + 1]) {
|
||||
const base = argv[++i]!
|
||||
outArg = base.endsWith('tiles') ? base : join(base, 'act-packs', 'tiles')
|
||||
} else if (arg.startsWith('--out-dir=')) {
|
||||
const base = arg.slice('--out-dir='.length)
|
||||
outArg = base.endsWith('tiles') ? base : join(base, 'act-packs', 'tiles')
|
||||
} else if (!arg.startsWith('--')) {
|
||||
positional.push(arg)
|
||||
}
|
||||
}
|
||||
if (positional[0]) archiveArg = positional[0]
|
||||
if (positional[1]) outArg = positional[1]
|
||||
return { ...(archiveArg ? { archiveArg } : {}), ...(outArg ? { outArg } : {}) }
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||
if (isMain) {
|
||||
const [archiveArg, outArg] = process.argv.slice(2)
|
||||
const { archiveArg, outArg } = parseTilesCliArgs(process.argv.slice(2))
|
||||
const t0 = performance.now()
|
||||
const result = await runPackTiles(archiveArg, outArg)
|
||||
const elapsedSec = ((performance.now() - t0) / 1000).toFixed(2)
|
||||
|
|
@ -583,3 +623,4 @@ if (isMain) {
|
|||
`in ${elapsedSec}s — missingFiles=${String(result.missingFiles.length)}, invisWalFloorVerified=${String(result.invisWalFloorVerified)}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ export interface SpriteRect {
|
|||
readonly y: number
|
||||
readonly w: number
|
||||
readonly h: number
|
||||
readonly offsetX?: number | undefined
|
||||
readonly offsetY?: number | undefined
|
||||
}
|
||||
|
||||
export type AutomapSpriteKey =
|
||||
|
|
@ -135,12 +137,15 @@ function transformSpriteFrame(frame: SpriteFrame, table: Uint8Array): SpriteFram
|
|||
indices[i] = 0
|
||||
}
|
||||
}
|
||||
const dc6Frame = frame as SpriteFrame & { readonly offsetX?: number; readonly offsetY?: number }
|
||||
return {
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
indices,
|
||||
mask: frame.mask,
|
||||
}
|
||||
...(dc6Frame.offsetX !== undefined ? { offsetX: dc6Frame.offsetX } : {}),
|
||||
...(dc6Frame.offsetY !== undefined ? { offsetY: dc6Frame.offsetY } : {}),
|
||||
} as SpriteFrame
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -325,18 +330,23 @@ function buildFontAtlas(name: string, dc6: SpriteSheet, tblBytes: Uint8Array): {
|
|||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Promise<void> {
|
||||
console.log('Mounting Diablo II v1.13c MPQ archives...')
|
||||
const archives = new MountedArchives()
|
||||
for (const m of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(m, await MpqArchive.open(await fileSource(`samples/d2/${m}`)))
|
||||
archives.add(m, await MpqArchive.open(await fileSource(join(mpqDir, m))))
|
||||
}
|
||||
|
||||
const pl2 = decodePl2(await archives.read('data/global/palette/ACT1/pal.pl2'))
|
||||
const outDir = join(process.cwd(), 'public', 'ui')
|
||||
const assetsUiDir = join(process.cwd(), 'public', 'assets', 'ui')
|
||||
const customUiDir = join(process.cwd(), outBaseDir, 'ui')
|
||||
const targetUiDirs = [...new Set([outDir, assetsUiDir, customUiDir])]
|
||||
const fontsDir = join(outDir, 'fonts')
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
mkdirSync(fontsDir, { recursive: true })
|
||||
for (const d of targetUiDirs) {
|
||||
mkdirSync(d, { recursive: true })
|
||||
mkdirSync(join(d, 'fonts'), { recursive: true })
|
||||
}
|
||||
|
||||
let decodedCount = 0
|
||||
let decodeFailures = 0
|
||||
|
|
@ -362,7 +372,9 @@ async function main(): Promise<void> {
|
|||
palette: pl2.rgb,
|
||||
transparentIndex: transparent ? 0 : undefined,
|
||||
})
|
||||
writeFileSync(join(outDir, relPath), png)
|
||||
for (const d of targetUiDirs) {
|
||||
writeFileSync(join(d, relPath), png)
|
||||
}
|
||||
}
|
||||
|
||||
// --- STAGE 1: Base UI Panels, Globes, Borders, Fonts, Buttons ---
|
||||
|
|
@ -734,7 +746,11 @@ async function main(): Promise<void> {
|
|||
}
|
||||
},
|
||||
})
|
||||
writeFileSync(join(fontsDir, 'D2Formal436.ttf'), formalTtf)
|
||||
for (const d of targetUiDirs) {
|
||||
writeFileSync(join(d, 'fonts', 'metrics.json'), JSON.stringify(fontMetas, null, 2))
|
||||
writeFileSync(join(d, 'fonts', 'D2Exocet.ttf'), exocetTtf)
|
||||
writeFileSync(join(d, 'fonts', 'D2Formal436.ttf'), formalTtf)
|
||||
}
|
||||
|
||||
// --- STAGE 2: Comprehensive Items, Flippy Ground Sprites & Unique Items Packing ---
|
||||
console.log('Extracting item sprites & flippy ground sprites (including uniqueitems.txt & setitems.txt)...')
|
||||
|
|
@ -746,12 +762,17 @@ async function main(): Promise<void> {
|
|||
const baseUniqueInvFile: Record<string, string> = {}
|
||||
const baseSetInvFile: Record<string, string> = {}
|
||||
const baseFlippyFile: Record<string, string> = {}
|
||||
const itemClassIdToCode: Record<number, string> = {}
|
||||
let nextItemClassId = 0
|
||||
|
||||
// A. Base Items from weapons.txt, armor.txt, misc.txt
|
||||
for (const t of ['weapons.txt', 'armor.txt', 'misc.txt']) {
|
||||
const tbl = parseTable(new TextDecoder().decode(await archives.read(`data/global/excel/${t}`)))
|
||||
for (const r of tbl.rows) {
|
||||
const code = r['code']?.trim()
|
||||
const name = r['name']?.trim()
|
||||
if (!code || code === 'Expansion' || name === 'Expansion') continue
|
||||
itemClassIdToCode[nextItemClassId++] = code
|
||||
const inv = r['invfile']?.trim().toLowerCase()
|
||||
if (code && inv) {
|
||||
baseInvFile[code] = inv
|
||||
|
|
@ -1019,31 +1040,49 @@ async function main(): Promise<void> {
|
|||
codeToFlippyFile[flp] = flp
|
||||
}
|
||||
|
||||
// Pack items-atlas.png (ATLAS_W = 1024)
|
||||
// Pack items-atlas.png (ATLAS_W = 2048)
|
||||
const sortedInvNames = [...uniqueInvFiles].sort()
|
||||
const ATLAS_W = 1024
|
||||
const ATLAS_W = 2048
|
||||
let curX = 0
|
||||
let curY = 0
|
||||
let rowH = 0
|
||||
const itemRects: Record<string, SpriteRect> = {}
|
||||
const flippyRects: Record<string, SpriteRect> = {}
|
||||
const flippyFrames: Record<string, SpriteRect[]> = {}
|
||||
const itemPlacements: BlitPlacement[] = []
|
||||
const decodedBaseFrames = new Map<string, SpriteFrame>()
|
||||
|
||||
const placeFrameInAtlas = (frame: SpriteFrame, includeOffsets = false): SpriteRect => {
|
||||
if (curX + frame.width > ATLAS_W) {
|
||||
curX = 0
|
||||
curY += rowH + 2
|
||||
rowH = 0
|
||||
}
|
||||
const dc6Frame = frame as SpriteFrame & { readonly offsetX?: number; readonly offsetY?: number }
|
||||
const rect: SpriteRect = {
|
||||
x: curX,
|
||||
y: curY,
|
||||
w: frame.width,
|
||||
h: frame.height,
|
||||
...(includeOffsets
|
||||
? {
|
||||
offsetX: dc6Frame.offsetX ?? 0,
|
||||
offsetY: dc6Frame.offsetY ?? 0,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
itemPlacements.push({ frame, x: curX, y: curY })
|
||||
curX += frame.width + 2
|
||||
if (frame.height > rowH) rowH = frame.height
|
||||
return rect
|
||||
}
|
||||
|
||||
for (const invName of sortedInvNames) {
|
||||
const dc6 = await decodeUiDc6(`data/global/items/${invName}.dc6`)
|
||||
const frame = dc6.groups[0]?.frames[0]
|
||||
if (!frame) continue
|
||||
decodedBaseFrames.set(invName, frame)
|
||||
if (curX + frame.width > ATLAS_W) {
|
||||
curX = 0
|
||||
curY += rowH + 2
|
||||
rowH = 0
|
||||
}
|
||||
itemRects[invName] = { x: curX, y: curY, w: frame.width, h: frame.height }
|
||||
itemPlacements.push({ frame, x: curX, y: curY })
|
||||
curX += frame.width + 2
|
||||
if (frame.height > rowH) rowH = frame.height
|
||||
itemRects[invName] = placeFrameInAtlas(frame, false)
|
||||
}
|
||||
|
||||
// Pre-bake and stitch transformed item frames (cgrn, dpur, lgry, blac, whit, etc.)
|
||||
|
|
@ -1099,21 +1138,13 @@ async function main(): Promise<void> {
|
|||
}
|
||||
const table = getTransformTable(t.colorCode)
|
||||
const transformedFrame = transformSpriteFrame(baseFrame, table)
|
||||
if (curX + transformedFrame.width > ATLAS_W) {
|
||||
curX = 0
|
||||
curY += rowH + 2
|
||||
rowH = 0
|
||||
}
|
||||
itemRects[t.transformedName] = { x: curX, y: curY, w: transformedFrame.width, h: transformedFrame.height }
|
||||
itemPlacements.push({ frame: transformedFrame, x: curX, y: curY })
|
||||
curX += transformedFrame.width + 2
|
||||
if (transformedFrame.height > rowH) rowH = transformedFrame.height
|
||||
itemRects[t.transformedName] = placeFrameInAtlas(transformedFrame, false)
|
||||
}
|
||||
|
||||
// --- STAGE 2.5: Authentic Flippy Ground Item Sprites (213 DC6 files) ---
|
||||
console.log('Packing authentic flippy ground item sprites...')
|
||||
// --- STAGE 2.5: Authentic Flippy Ground Item Sprites (all animation frames 0..N-1) ---
|
||||
console.log('Packing authentic flippy ground item sprites (all animation frames)...')
|
||||
const sortedFlippyNames = [...uniqueFlippyFiles].sort()
|
||||
const decodedFlippyBaseFrames = new Map<string, SpriteFrame>()
|
||||
const decodedFlippyBaseFrames = new Map<string, readonly SpriteFrame[]>()
|
||||
|
||||
for (const flpName of sortedFlippyNames) {
|
||||
const dc6 = await decodeUiDc6(`data/global/items/${flpName}.dc6`)
|
||||
|
|
@ -1121,68 +1152,72 @@ async function main(): Promise<void> {
|
|||
// flpgld has 4 groups: 0=small pile, 1=medium pile, 2=large pile, 3=huge pile
|
||||
for (let g = 0; g < dc6.groups.length; g++) {
|
||||
const groupFrames = dc6.groups[g]?.frames
|
||||
const lastFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined
|
||||
if (!lastFrame) continue
|
||||
if (curX + lastFrame.width > ATLAS_W) {
|
||||
curX = 0
|
||||
curY += rowH + 2
|
||||
rowH = 0
|
||||
}
|
||||
if (!groupFrames || groupFrames.length === 0) continue
|
||||
const key = `flpgld_${g}`
|
||||
const rect: SpriteRect = { x: curX, y: curY, w: lastFrame.width, h: lastFrame.height }
|
||||
flippyRects[key] = rect
|
||||
itemRects[key] = rect
|
||||
itemPlacements.push({ frame: lastFrame, x: curX, y: curY })
|
||||
curX += lastFrame.width + 2
|
||||
if (lastFrame.height > rowH) rowH = lastFrame.height
|
||||
const rectsForGroup: SpriteRect[] = []
|
||||
for (const f of groupFrames) {
|
||||
rectsForGroup.push(placeFrameInAtlas(f, true))
|
||||
}
|
||||
const lastRect = rectsForGroup[rectsForGroup.length - 1]!
|
||||
flippyFrames[key] = rectsForGroup
|
||||
flippyRects[key] = lastRect
|
||||
itemRects[key] = lastRect
|
||||
}
|
||||
if (flippyRects['flpgld_0']) {
|
||||
if (flippyRects['flpgld_0'] && flippyFrames['flpgld_0']) {
|
||||
flippyFrames['flpgld'] = flippyFrames['flpgld_0']
|
||||
flippyRects['flpgld'] = flippyRects['flpgld_0']
|
||||
itemRects['flpgld'] = flippyRects['flpgld_0']
|
||||
}
|
||||
} else {
|
||||
const groupFrames = dc6.groups[0]?.frames
|
||||
const lastFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined
|
||||
if (!lastFrame) continue
|
||||
decodedFlippyBaseFrames.set(flpName, lastFrame)
|
||||
if (curX + lastFrame.width > ATLAS_W) {
|
||||
curX = 0
|
||||
curY += rowH + 2
|
||||
rowH = 0
|
||||
if (!groupFrames || groupFrames.length === 0) continue
|
||||
decodedFlippyBaseFrames.set(flpName, groupFrames)
|
||||
const rectsForFlippy: SpriteRect[] = []
|
||||
for (const f of groupFrames) {
|
||||
rectsForFlippy.push(placeFrameInAtlas(f, true))
|
||||
}
|
||||
const rect: SpriteRect = { x: curX, y: curY, w: lastFrame.width, h: lastFrame.height }
|
||||
flippyRects[flpName] = rect
|
||||
itemRects[flpName] = rect
|
||||
itemPlacements.push({ frame: lastFrame, x: curX, y: curY })
|
||||
curX += lastFrame.width + 2
|
||||
if (lastFrame.height > rowH) rowH = lastFrame.height
|
||||
const lastRect = rectsForFlippy[rectsForFlippy.length - 1]!
|
||||
flippyFrames[flpName] = rectsForFlippy
|
||||
flippyRects[flpName] = lastRect
|
||||
itemRects[flpName] = lastRect
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-bake and stitch transformed flippy frames
|
||||
// Pre-bake and stitch transformed flippy frames (all frames 0..N-1)
|
||||
for (const t of transformedFlippyMap.values()) {
|
||||
let baseFrame = decodedFlippyBaseFrames.get(t.baseFlp)
|
||||
if (!baseFrame) {
|
||||
let baseFrames = decodedFlippyBaseFrames.get(t.baseFlp)
|
||||
if (!baseFrames) {
|
||||
if (archives.has(`data/global/items/${t.baseFlp}.dc6`)) {
|
||||
const dc6 = await decodeUiDc6(`data/global/items/${t.baseFlp}.dc6`)
|
||||
const groupFrames = dc6.groups[0]?.frames
|
||||
baseFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined
|
||||
baseFrames = dc6.groups[0]?.frames
|
||||
}
|
||||
}
|
||||
if (!baseFrame) continue
|
||||
if (!baseFrames || baseFrames.length === 0) continue
|
||||
const table = getTransformTable(t.colorCode)
|
||||
const transformedFrame = transformSpriteFrame(baseFrame, table)
|
||||
if (curX + transformedFrame.width > ATLAS_W) {
|
||||
curX = 0
|
||||
curY += rowH + 2
|
||||
rowH = 0
|
||||
const rectsForTransformed: SpriteRect[] = []
|
||||
for (const bf of baseFrames) {
|
||||
const transformedFrame = transformSpriteFrame(bf, table)
|
||||
rectsForTransformed.push(placeFrameInAtlas(transformedFrame, true))
|
||||
}
|
||||
const lastRect = rectsForTransformed[rectsForTransformed.length - 1]!
|
||||
flippyFrames[t.transformedName] = rectsForTransformed
|
||||
flippyRects[t.transformedName] = lastRect
|
||||
itemRects[t.transformedName] = lastRect
|
||||
}
|
||||
|
||||
// Alias itemRects, flippyRects, and flippyFrames by item code for direct lookup convenience
|
||||
for (const [code, invFile] of Object.entries(codeToInvFile)) {
|
||||
if (!itemRects[code] && itemRects[invFile]) {
|
||||
itemRects[code] = itemRects[invFile]!
|
||||
}
|
||||
}
|
||||
for (const [code, flpFile] of Object.entries(codeToFlippyFile)) {
|
||||
if (!flippyRects[code] && flippyRects[flpFile]) {
|
||||
flippyRects[code] = flippyRects[flpFile]!
|
||||
}
|
||||
if (!flippyFrames[code] && flippyFrames[flpFile]) {
|
||||
flippyFrames[code] = flippyFrames[flpFile]!
|
||||
}
|
||||
const rect: SpriteRect = { x: curX, y: curY, w: transformedFrame.width, h: transformedFrame.height }
|
||||
flippyRects[t.transformedName] = rect
|
||||
itemRects[t.transformedName] = rect
|
||||
itemPlacements.push({ frame: transformedFrame, x: curX, y: curY })
|
||||
curX += transformedFrame.width + 2
|
||||
if (transformedFrame.height > rowH) rowH = transformedFrame.height
|
||||
}
|
||||
|
||||
const atlasH = Math.max(1, curY + rowH)
|
||||
|
|
@ -1390,8 +1425,11 @@ async function main(): Promise<void> {
|
|||
transparentIndex: 0,
|
||||
})
|
||||
|
||||
writeFileSync(join(outDir, spec.uiPngName), pngBytes)
|
||||
writeFileSync(join(automapOutDir, `${spec.key}.png`), pngBytes)
|
||||
for (const d of targetUiDirs) {
|
||||
writeFileSync(join(d, spec.uiPngName), pngBytes)
|
||||
mkdirSync(join(d, 'automap'), { recursive: true })
|
||||
writeFileSync(join(d, 'automap', `${spec.key}.png`), pngBytes)
|
||||
}
|
||||
|
||||
automapAtlases[spec.key] = {
|
||||
image: `/assets/ui/automap/${spec.key}.png`,
|
||||
|
|
@ -1408,9 +1446,12 @@ async function main(): Promise<void> {
|
|||
const automapManifest: AutomapUiManifest = {
|
||||
atlases: automapAtlases,
|
||||
}
|
||||
writeFileSync(join(automapOutDir, 'manifest.json'), JSON.stringify(automapManifest, null, 2))
|
||||
for (const d of targetUiDirs) {
|
||||
mkdirSync(join(d, 'automap'), { recursive: true })
|
||||
writeFileSync(join(d, 'automap', 'manifest.json'), JSON.stringify(automapManifest, null, 2))
|
||||
}
|
||||
|
||||
// --- STAGE 4: Emit public/ui/manifest.json ---
|
||||
// --- STAGE 4: Emit public/ui/manifest.json & ui-atlas.json ---
|
||||
const imagesMap: Record<string, string> = {
|
||||
ctrlPnl: '/ui/ctrlpnl-800.png',
|
||||
globeLife: '/ui/globe-life.png',
|
||||
|
|
@ -1474,13 +1515,19 @@ async function main(): Promise<void> {
|
|||
codeToFlippyFile,
|
||||
itemRects,
|
||||
flippyRects,
|
||||
flippyFrames,
|
||||
itemClassIdToCode,
|
||||
questRects,
|
||||
images: imagesMap,
|
||||
automapAtlases,
|
||||
}
|
||||
writeFileSync(join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
|
||||
const manifestJson = JSON.stringify(manifest, null, 2)
|
||||
for (const d of targetUiDirs) {
|
||||
writeFileSync(join(d, 'manifest.json'), manifestJson)
|
||||
writeFileSync(join(d, 'ui-atlas.json'), manifestJson)
|
||||
}
|
||||
|
||||
// --- STAGE 5: Emit src/ui/baked-ui-meta.ts ---
|
||||
// --- STAGE 5: Emit src/common/items/baked-ui-meta.ts ---
|
||||
const bakedMetaCode = `/**
|
||||
* Auto-generated baked UI metadata for Diablo II Web client.
|
||||
* Generated by \`scripts/pack-ui.ts\`.
|
||||
|
|
@ -1492,6 +1539,8 @@ export interface SpriteRect {
|
|||
readonly y: number
|
||||
readonly w: number
|
||||
readonly h: number
|
||||
readonly offsetX?: number | undefined
|
||||
readonly offsetY?: number | undefined
|
||||
}
|
||||
|
||||
export type AutomapSpriteKey =
|
||||
|
|
@ -1546,6 +1595,8 @@ export interface BakedUiManifest {
|
|||
readonly codeToFlippyFile: Record<string, string>
|
||||
readonly itemRects: Record<string, SpriteRect>
|
||||
readonly flippyRects: Record<string, SpriteRect>
|
||||
readonly flippyFrames: Record<string, readonly SpriteRect[]>
|
||||
readonly itemClassIdToCode: Record<number, string>
|
||||
readonly questRects: Record<string, SpriteRect>
|
||||
readonly images: Record<string, string>
|
||||
readonly automapAtlases?: Readonly<Record<AutomapSpriteKey, AutomapSpriteAtlasMeta>>
|
||||
|
|
@ -1613,6 +1664,8 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
|||
codeToFlippyFile: ${JSON.stringify(codeToFlippyFile, null, 2)},
|
||||
itemRects: ${JSON.stringify(itemRects, null, 2)},
|
||||
flippyRects: ${JSON.stringify(flippyRects, null, 2)},
|
||||
flippyFrames: ${JSON.stringify(flippyFrames, null, 2)},
|
||||
itemClassIdToCode: ${JSON.stringify(itemClassIdToCode, null, 2)},
|
||||
questRects: ${JSON.stringify(questRects, null, 2)},
|
||||
images: ${JSON.stringify(imagesMap, null, 2)},
|
||||
automapAtlases: AUTOMAP_UI_MANIFEST.atlases,
|
||||
|
|
@ -1622,10 +1675,36 @@ export { CURSOR_METADATA, type CursorFrameMeta, type CursorAtlasMetadata } from
|
|||
`
|
||||
writeFileSync(join(process.cwd(), 'src', 'common', 'items', 'baked-ui-meta.ts'), bakedMetaCode, 'utf8')
|
||||
console.log(`Successfully decoded ${decodedCount} DC6 sheets (${decodeFailures} failures)!`)
|
||||
console.log('Emitted public/ui/manifest.json, public/assets/ui/automap/manifest.json, and src/common/items/baked-ui-meta.ts.')
|
||||
console.log('Emitted public/ui/manifest.json, public/ui/ui-atlas.json, public/assets/ui/manifest.json, and src/common/items/baked-ui-meta.ts.')
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('pack-ui failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
function parseUiCliArgs(argv: readonly string[]): { mpqDir: string; outBaseDir: string } {
|
||||
let mpqDir = 'samples/d2'
|
||||
let outBaseDir = 'public'
|
||||
const positional: string[] = []
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]!
|
||||
if (arg === '--mpq-dir' && argv[i + 1]) {
|
||||
mpqDir = argv[++i]!
|
||||
} else if (arg.startsWith('--mpq-dir=')) {
|
||||
mpqDir = arg.slice('--mpq-dir='.length)
|
||||
} else if (arg === '--out-dir' && argv[i + 1]) {
|
||||
outBaseDir = argv[++i]!
|
||||
} else if (arg.startsWith('--out-dir=')) {
|
||||
outBaseDir = arg.slice('--out-dir='.length)
|
||||
} else if (!arg.startsWith('--')) {
|
||||
positional.push(arg)
|
||||
}
|
||||
}
|
||||
if (positional[0]) mpqDir = positional[0]
|
||||
if (positional[1]) outBaseDir = positional[1]
|
||||
return { mpqDir, outBaseDir }
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const { mpqDir, outBaseDir } = parseUiCliArgs(process.argv.slice(2))
|
||||
bakeUi(mpqDir, outBaseDir).catch((err) => {
|
||||
console.error('pack-ui failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -135,7 +135,7 @@ import type { DrlgTableName } from '../drlg/drlg-tables.ts'
|
|||
|
||||
export interface ClipMeta {
|
||||
readonly group: number
|
||||
readonly directions?: number
|
||||
readonly directions: number
|
||||
readonly frames: number
|
||||
readonly framesPerDir?: number
|
||||
readonly speed: number
|
||||
|
|
@ -173,6 +173,7 @@ export interface EntityMeta {
|
|||
readonly frameDurationMs?: number
|
||||
readonly animSpeed?: number
|
||||
readonly clips?: Record<string, ClipMeta>
|
||||
readonly modes?: Record<string, ClipMeta>
|
||||
readonly glowClips?: Record<string, ClipMeta>
|
||||
readonly walk?: number
|
||||
readonly stand?: number
|
||||
|
|
@ -188,6 +189,109 @@ export interface EntityMeta {
|
|||
export type PackedMonsterJson = EntityMeta
|
||||
export type PackedCharJson = EntityMeta
|
||||
|
||||
export interface PackedEntityIndexEntry {
|
||||
readonly key: string
|
||||
readonly token: string
|
||||
readonly weapon: string
|
||||
readonly file: string
|
||||
readonly format?: string | undefined
|
||||
readonly blendMode?: 'normal' | 'additive' | undefined
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly directions: number
|
||||
}
|
||||
|
||||
export interface MonsterIndexEntry {
|
||||
readonly hcIdx: number
|
||||
readonly classId: number
|
||||
readonly id: string
|
||||
readonly token: string
|
||||
readonly weapon: string
|
||||
readonly key: string | null
|
||||
readonly slug: string | null
|
||||
readonly file: string | null
|
||||
readonly metaFile: string | null
|
||||
readonly packPath: string | null
|
||||
readonly inTown: boolean
|
||||
readonly npc: boolean
|
||||
readonly isNpc: boolean
|
||||
readonly interact: boolean
|
||||
readonly align: number
|
||||
readonly sizeX: number
|
||||
readonly sizeY: number
|
||||
readonly velocity: number
|
||||
readonly run: number
|
||||
readonly directions: number
|
||||
readonly dirCount: number
|
||||
readonly scale: number
|
||||
readonly baseCol: number
|
||||
readonly modes: readonly string[]
|
||||
}
|
||||
|
||||
export interface ObjectIndexEntry {
|
||||
readonly id: number
|
||||
readonly classId: number
|
||||
readonly name: string
|
||||
readonly description: string
|
||||
readonly token: string
|
||||
readonly slug: string | null
|
||||
readonly file: string | null
|
||||
readonly metaFile: string | null
|
||||
readonly packPath: string | null
|
||||
readonly actTokens: Readonly<Record<number, string>>
|
||||
readonly actSlugs: Readonly<Record<number, string>>
|
||||
readonly baseIsMonsters: boolean
|
||||
readonly draw: boolean
|
||||
readonly isDoor: boolean
|
||||
readonly subClass: number
|
||||
readonly sizeX: number
|
||||
readonly sizeY: number
|
||||
readonly xOffset: number
|
||||
readonly yOffset: number
|
||||
readonly operateRange: number
|
||||
readonly selectable: readonly boolean[]
|
||||
readonly frameCnt: readonly number[]
|
||||
readonly frameCounts: readonly number[]
|
||||
readonly frameDelta: readonly number[]
|
||||
readonly frameDeltas: readonly number[]
|
||||
readonly cycleAnim: readonly boolean[]
|
||||
readonly start: readonly number[]
|
||||
readonly directions: number
|
||||
readonly dirCount: number
|
||||
readonly modes: readonly string[]
|
||||
readonly blendMode?: 'normal' | 'additive' | undefined
|
||||
readonly trans?: number | undefined
|
||||
}
|
||||
|
||||
export interface ObjectLookupIndexEntry {
|
||||
readonly act: number
|
||||
readonly type: number
|
||||
readonly ds1Id: number
|
||||
readonly id: number
|
||||
readonly objectsTxtId: number
|
||||
readonly classId: number
|
||||
readonly token: string
|
||||
readonly mode: string
|
||||
readonly direction: number
|
||||
readonly baseIsMonsters: boolean
|
||||
readonly slug: string | null
|
||||
readonly file: string | null
|
||||
readonly metaFile: string | null
|
||||
readonly packPath: string | null
|
||||
}
|
||||
|
||||
export interface PackedEntityCatalogIndex {
|
||||
readonly schema: number
|
||||
readonly character: string
|
||||
readonly characters?: readonly string[] | undefined
|
||||
readonly entities: readonly PackedEntityIndexEntry[]
|
||||
readonly monsterByClassId?: Readonly<Record<string, MonsterIndexEntry>> | undefined
|
||||
readonly monstersByHcIdx?: Readonly<Record<string, MonsterIndexEntry>> | undefined
|
||||
readonly objectByClassId?: Readonly<Record<string, ObjectIndexEntry>> | undefined
|
||||
readonly objectsByClassId?: Readonly<Record<string, ObjectIndexEntry>> | undefined
|
||||
readonly objectLookupByActTypeId?: Readonly<Record<string, ObjectLookupIndexEntry>> | undefined
|
||||
}
|
||||
|
||||
export interface PackedTilePlacement {
|
||||
readonly act: number
|
||||
readonly page: number
|
||||
|
|
@ -312,6 +416,8 @@ export interface SpriteRect {
|
|||
readonly y: number
|
||||
readonly w: number
|
||||
readonly h: number
|
||||
readonly offsetX?: number | undefined
|
||||
readonly offsetY?: number | undefined
|
||||
}
|
||||
|
||||
export type AutomapSpriteKey =
|
||||
|
|
@ -366,6 +472,8 @@ export interface BakedUiManifest {
|
|||
readonly codeToFlippyFile: Record<string, string>
|
||||
readonly itemRects: Record<string, SpriteRect>
|
||||
readonly flippyRects: Record<string, SpriteRect>
|
||||
readonly flippyFrames?: Readonly<Record<string, readonly SpriteRect[]>> | undefined
|
||||
readonly itemClassIdToCode?: Readonly<Record<number, string>> | undefined
|
||||
readonly questRects: Record<string, SpriteRect>
|
||||
readonly images: Record<string, string>
|
||||
readonly automapAtlases?: Readonly<Record<AutomapSpriteKey, AutomapSpriteAtlasMeta>>
|
||||
|
|
@ -532,6 +640,9 @@ const CACHEABLE_PREFIXES = [
|
|||
'skills/',
|
||||
'ui/',
|
||||
'assets/',
|
||||
'entities/',
|
||||
'characters/',
|
||||
'tiles/',
|
||||
] as const
|
||||
|
||||
const CACHEABLE_EXT_RE =
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@
|
|||
import type { MountedArchives } from '../mpq/mount.ts'
|
||||
import { decodeCof, cofLayerOrder } from '../formats/cof.ts'
|
||||
import type { CofFile } from '../formats/cof.ts'
|
||||
import { decodeDc6 } from '../formats/dc6.ts'
|
||||
import type { Dc6Frame, Dc6Sheet } from '../formats/dc6.ts'
|
||||
import { decodeDcc } from '../formats/dcc.ts'
|
||||
import type { DccFile } from '../formats/dcc.ts'
|
||||
import type { DccDirection, DccFile, DccFrame } from '../formats/dcc.ts'
|
||||
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
||||
import { dir64ToDcc } from './character.ts'
|
||||
|
||||
|
|
@ -15,6 +17,21 @@ export const MONSTER_COMPONENTS: readonly string[] = [
|
|||
's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8'
|
||||
]
|
||||
|
||||
/**
|
||||
* Canonical 1.13c monster animation modes baked for full-spectrum monster & Town NPC packs.
|
||||
*/
|
||||
export const TARGET_MONSTER_MODES = [
|
||||
'wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd',
|
||||
's1', 's2', 's3', 's4', 'on', 'op',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Canonical 1.13c object animation modes (`objmode.txt` / `Objects.txt` Mode0..Mode7).
|
||||
*/
|
||||
export const TARGET_OBJECT_MODES = [
|
||||
'nu', 'op', 'on', 's1', 's2', 's3', 's4', 's5',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Tokens of monsters that require additive blending for their ghostly or energy appearance.
|
||||
*/
|
||||
|
|
@ -22,8 +39,48 @@ export const ADDITIVE_MONSTER_TOKENS: ReadonlySet<string> = new Set([
|
|||
'WR', 'WW', 'FR', 'K9', '17', '46', 'FI', 'X3', 'X4', 'XW',
|
||||
])
|
||||
|
||||
interface ArchiveFileIndex {
|
||||
readonly allFiles: readonly string[]
|
||||
readonly byLower: ReadonlyMap<string, string>
|
||||
readonly byRootPrefix: ReadonlyMap<string, readonly string[]>
|
||||
}
|
||||
|
||||
const archiveIndexCache = new WeakMap<MountedArchives, Promise<ArchiveFileIndex>>()
|
||||
|
||||
async function getArchiveFileIndex(archives: MountedArchives): Promise<ArchiveFileIndex> {
|
||||
let promise = archiveIndexCache.get(archives)
|
||||
if (!promise) {
|
||||
promise = (async () => {
|
||||
const allFiles = await archives.listFiles()
|
||||
const byLower = new Map<string, string>()
|
||||
const byRootPrefix = new Map<string, string[]>()
|
||||
for (const name of allFiles) {
|
||||
const lower = name.toLowerCase()
|
||||
if (!byLower.has(lower)) {
|
||||
byLower.set(lower, name)
|
||||
}
|
||||
if (lower.startsWith('data\\global\\monsters\\') || lower.startsWith('data\\global\\objects\\')) {
|
||||
const parts = lower.split('\\')
|
||||
if (parts.length >= 4) {
|
||||
const rootKey = `${parts[0]}\\${parts[1]}\\${parts[2]}\\${parts[3]}\\`
|
||||
let list = byRootPrefix.get(rootKey)
|
||||
if (!list) {
|
||||
list = []
|
||||
byRootPrefix.set(rootKey, list)
|
||||
}
|
||||
list.push(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { allFiles, byLower, byRootPrefix }
|
||||
})()
|
||||
archiveIndexCache.set(archives, promise)
|
||||
}
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the DCC sprite member for a specific monster COF layer.
|
||||
* Find the DCC or DC6 sprite member for a specific monster/object COF layer.
|
||||
*/
|
||||
export function findMonsterLayerSprite(
|
||||
names: readonly string[],
|
||||
|
|
@ -38,16 +95,18 @@ export function findMonsterLayerSprite(
|
|||
const weapons = Array.from(new Set([layerWeaponClass, weapon, 'hth'].filter((w): w is string => Boolean(w))))
|
||||
const anims = animation.toLowerCase() === 'wl' ? ['wl', 'nu'] : [animation.toLowerCase()]
|
||||
|
||||
for (const anim of anims) {
|
||||
for (const w of weapons) {
|
||||
const suffix = `${anim}${w.toLowerCase()}.dcc`
|
||||
const hits = names.filter(name => {
|
||||
const lowerName = name.toLowerCase()
|
||||
return lowerName.startsWith(prefix) && lowerName.endsWith(suffix)
|
||||
})
|
||||
if (hits.length > 0) {
|
||||
hits.sort()
|
||||
return hits[0]
|
||||
for (const ext of ['.dcc', '.dc6']) {
|
||||
for (const anim of anims) {
|
||||
for (const w of weapons) {
|
||||
const suffix = `${anim}${w.toLowerCase()}${ext}`
|
||||
const hits = names.filter(name => {
|
||||
const lowerName = name.toLowerCase()
|
||||
return lowerName.startsWith(prefix) && lowerName.endsWith(suffix)
|
||||
})
|
||||
if (hits.length > 0) {
|
||||
hits.sort()
|
||||
return hits[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +137,113 @@ function blit(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts a decoded DC6 sprite sheet into a DccFile-compatible directional structure
|
||||
* so COF layer compositing handles both .dcc and .dc6 layers identically.
|
||||
*/
|
||||
export function dc6ToDccFile(
|
||||
sheet: Dc6Sheet,
|
||||
cofDirections = sheet.header.directions,
|
||||
cofFramesPerDir = sheet.header.framesPerDirection,
|
||||
): DccFile {
|
||||
let dirFrameLists: (readonly Dc6Frame[])[] = []
|
||||
if (sheet.groups.length === cofDirections) {
|
||||
dirFrameLists = sheet.groups.map(g => g.frames)
|
||||
} else if (
|
||||
sheet.groups.length === 1 &&
|
||||
cofDirections > 1 &&
|
||||
(sheet.groups[0]?.frames.length ?? 0) >= cofDirections * cofFramesPerDir
|
||||
) {
|
||||
const allFrames = sheet.groups[0]!.frames
|
||||
for (let d = 0; d < cofDirections; d += 1) {
|
||||
dirFrameLists.push(allFrames.slice(d * cofFramesPerDir, (d + 1) * cofFramesPerDir))
|
||||
}
|
||||
} else {
|
||||
dirFrameLists = sheet.groups.map(g => g.frames)
|
||||
}
|
||||
|
||||
const directions: DccDirection[] = []
|
||||
for (const dirFrames of dirFrameLists) {
|
||||
if (dirFrames.length === 0) {
|
||||
directions.push({
|
||||
box: { left: 0, top: 0, width: 1, height: 1 },
|
||||
frames: [],
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
let left = Number.POSITIVE_INFINITY
|
||||
let top = Number.POSITIVE_INFINITY
|
||||
let right = Number.NEGATIVE_INFINITY
|
||||
let bottom = Number.NEGATIVE_INFINITY
|
||||
for (const f of dirFrames) {
|
||||
const fLeft = f.offsetX
|
||||
const fTop = f.offsetY - f.height + 1
|
||||
left = Math.min(left, fLeft)
|
||||
top = Math.min(top, fTop)
|
||||
right = Math.max(right, fLeft + f.width)
|
||||
bottom = Math.max(bottom, fTop + f.height)
|
||||
}
|
||||
|
||||
const boxLeft = Number.isFinite(left) ? Math.round(left) : 0
|
||||
const boxTop = Number.isFinite(top) ? Math.round(top) : 0
|
||||
const boxWidth = Number.isFinite(right) ? Math.max(1, Math.round(right) - boxLeft) : 1
|
||||
const boxHeight = Number.isFinite(bottom) ? Math.max(1, Math.round(bottom) - boxTop) : 1
|
||||
const dirBox = { left: boxLeft, top: boxTop, width: boxWidth, height: boxHeight }
|
||||
|
||||
const dccFrames: DccFrame[] = []
|
||||
for (const f of dirFrames) {
|
||||
const fLeft = f.offsetX
|
||||
const fTop = f.offsetY - f.height + 1
|
||||
let canvas: SpriteFrame
|
||||
if (fLeft === boxLeft && fTop === boxTop && f.width === boxWidth && f.height === boxHeight) {
|
||||
canvas = {
|
||||
width: boxWidth,
|
||||
height: boxHeight,
|
||||
indices: f.indices,
|
||||
mask: f.mask,
|
||||
}
|
||||
} else {
|
||||
const indices = new Uint8Array(boxWidth * boxHeight)
|
||||
const mask = new Uint8Array(boxWidth * boxHeight)
|
||||
const target = { width: boxWidth, height: boxHeight, indices, mask }
|
||||
blit(target, f, fLeft - boxLeft, fTop - boxTop)
|
||||
canvas = target
|
||||
}
|
||||
dccFrames.push({
|
||||
width: f.width,
|
||||
height: f.height,
|
||||
offsetX: f.offsetX,
|
||||
offsetY: f.offsetY,
|
||||
frame: canvas,
|
||||
bottomUp: false,
|
||||
})
|
||||
}
|
||||
|
||||
directions.push({
|
||||
box: dirBox,
|
||||
frames: dccFrames,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
version: 6,
|
||||
directions,
|
||||
}
|
||||
}
|
||||
|
||||
function decodeLayerSpriteBytes(
|
||||
member: string,
|
||||
bytes: Uint8Array,
|
||||
cofDirections: number,
|
||||
cofFramesPerDir: number,
|
||||
): DccFile {
|
||||
if (member.toLowerCase().endsWith('.dc6')) {
|
||||
return dc6ToDccFile(decodeDc6(bytes), cofDirections, cofFramesPerDir)
|
||||
}
|
||||
return decodeDcc(bytes)
|
||||
}
|
||||
|
||||
export interface MonsterLayerFailure {
|
||||
readonly token: string
|
||||
readonly animation: string
|
||||
|
|
@ -107,10 +273,11 @@ export async function compositeMonsterAnimation(
|
|||
animation: string,
|
||||
weapon: string = 'hth',
|
||||
): Promise<MonsterAnimationSheet> {
|
||||
const lower = token.toLowerCase()
|
||||
const lower = token.trim().toLowerCase()
|
||||
const root = `data\\global\\monsters\\${lower}\\`
|
||||
|
||||
const names = await archives.listFiles()
|
||||
const fileIndex = await getArchiveFileIndex(archives)
|
||||
const tokenFiles = fileIndex.byRootPrefix.get(root) ?? fileIndex.allFiles
|
||||
let usedAnim = animation.toLowerCase()
|
||||
let usedWeapon = weapon.toLowerCase()
|
||||
const candidates = [
|
||||
|
|
@ -126,20 +293,59 @@ export async function compositeMonsterAnimation(
|
|||
|
||||
let matchedCof: string | undefined
|
||||
for (const cand of candidates) {
|
||||
matchedCof = names.find(n => n.toLowerCase() === cand.toLowerCase())
|
||||
matchedCof = fileIndex.byLower.get(cand.toLowerCase())
|
||||
if (matchedCof) {
|
||||
if (cand.includes('nu')) usedAnim = 'nu'
|
||||
if (cand.endsWith(`nu${usedWeapon}.cof`) || cand.endsWith('nuhth.cof')) usedAnim = 'nu'
|
||||
if (cand.endsWith('hth.cof')) usedWeapon = 'hth'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedCof) {
|
||||
throw new Error(`Monster COF not found: ${candidates[0]}`)
|
||||
const cofPrefix = `${root}cof\\${lower}${usedAnim}`.toLowerCase()
|
||||
const altCofs = tokenFiles
|
||||
.filter(n => {
|
||||
const ln = n.toLowerCase()
|
||||
return ln.startsWith(cofPrefix) && ln.endsWith('.cof') && ln.length === cofPrefix.length + 7
|
||||
})
|
||||
.sort()
|
||||
if (altCofs.length > 0) {
|
||||
matchedCof = altCofs[0]
|
||||
usedWeapon = matchedCof!.slice(-7, -4).toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
const cofBytes = await archives.read(matchedCof)
|
||||
const cof: CofFile = decodeCof(cofBytes)
|
||||
let cof: CofFile
|
||||
if (matchedCof) {
|
||||
const cofBytes = await archives.read(matchedCof)
|
||||
cof = decodeCof(cofBytes)
|
||||
} else {
|
||||
const standaloneTr = findMonsterLayerSprite(
|
||||
tokenFiles,
|
||||
root,
|
||||
lower,
|
||||
usedAnim,
|
||||
usedWeapon,
|
||||
'tr',
|
||||
usedWeapon,
|
||||
)
|
||||
if (!standaloneTr) {
|
||||
throw new Error(`Monster COF not found: ${candidates[0]}`)
|
||||
}
|
||||
const trBytes = await archives.read(standaloneTr)
|
||||
const trSprite = decodeLayerSpriteBytes(standaloneTr, trBytes, 8, 8)
|
||||
const numDirs = Math.max(1, trSprite.directions.length)
|
||||
const framesPerDir = Math.max(1, trSprite.directions[0]?.frames.length ?? 1)
|
||||
cof = {
|
||||
numberOfLayers: 1,
|
||||
framesPerDirection: framesPerDir,
|
||||
numberOfDirections: numDirs,
|
||||
speed: 256,
|
||||
layers: [{ type: 1, shadow: 1, selectable: true, transparent: false, drawEffect: 0, weaponClass: usedWeapon }],
|
||||
animationFrames: new Uint8Array(framesPerDir),
|
||||
priority: Array.from({ length: numDirs }, () => Array.from({ length: framesPerDir }, () => [1])),
|
||||
}
|
||||
}
|
||||
|
||||
const layerResults = await Promise.all(cof.layers.map(async (layer) => {
|
||||
const component = MONSTER_COMPONENTS[layer.type]
|
||||
|
|
@ -151,7 +357,7 @@ export async function compositeMonsterAnimation(
|
|||
}
|
||||
}
|
||||
const member = findMonsterLayerSprite(
|
||||
names,
|
||||
tokenFiles,
|
||||
root,
|
||||
lower,
|
||||
usedAnim,
|
||||
|
|
@ -168,7 +374,11 @@ export async function compositeMonsterAnimation(
|
|||
}
|
||||
try {
|
||||
const bytes = await archives.read(member)
|
||||
return { sprite: decodeDcc(bytes), member, failure: undefined }
|
||||
return {
|
||||
sprite: decodeLayerSpriteBytes(member, bytes, cof.numberOfDirections, cof.framesPerDirection),
|
||||
member,
|
||||
failure: undefined,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
sprite: null,
|
||||
|
|
@ -188,14 +398,124 @@ export async function compositeMonsterAnimation(
|
|||
const sprites: (DccFile | null)[] = layerResults.map(r => r.sprite)
|
||||
const decodedMembers: string[] = layerResults.flatMap(r => r.member ? [r.member] : [])
|
||||
const layerFailures: MonsterLayerFailure[] = layerResults.flatMap(r => r.failure ? [r.failure] : [])
|
||||
for (const failure of layerFailures) {
|
||||
console.error(
|
||||
`monster art layer dropped: ${failure.token}/${failure.component ?? '?'} (${failure.animation}) `
|
||||
+ `— ${failure.reason}${failure.member !== null ? ` [${failure.member}]` : ''}`
|
||||
+ `${failure.message !== undefined ? `: ${failure.message}` : ''}`,
|
||||
)
|
||||
|
||||
return compositeLoadedLayers(cof, sprites, decodedMembers, layerFailures, 0, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and composite a single object animation (`nu`, `op`, `on`, `s1..s5`) across all directions and frames.
|
||||
* Uses `objectDrawAnchor` offsets (`anchorX = -16 + boxLeft`, `anchorY = 16 + boxTop`).
|
||||
*/
|
||||
export async function compositeObjectAnimation(
|
||||
archives: MountedArchives,
|
||||
token: string,
|
||||
animation: string,
|
||||
baseIsMonsters = false,
|
||||
): Promise<MonsterAnimationSheet> {
|
||||
const lower = token.trim().toLowerCase()
|
||||
const usedAnim = animation.trim().toLowerCase()
|
||||
const root = baseIsMonsters
|
||||
? `data\\global\\monsters\\${lower}\\`
|
||||
: `data\\global\\objects\\${lower}\\`
|
||||
|
||||
const fileIndex = await getArchiveFileIndex(archives)
|
||||
const tokenFiles = fileIndex.byRootPrefix.get(root) ?? fileIndex.allFiles
|
||||
|
||||
const exactCof = `${root}cof\\${lower}${usedAnim}hth.cof`.toLowerCase()
|
||||
let matchedCof = fileIndex.byLower.get(exactCof)
|
||||
let usedWeapon = 'hth'
|
||||
|
||||
if (!matchedCof) {
|
||||
const cofPrefix = `${root}cof\\${lower}${usedAnim}`.toLowerCase()
|
||||
const altCofs = tokenFiles
|
||||
.filter(n => {
|
||||
const ln = n.toLowerCase()
|
||||
return ln.startsWith(cofPrefix) && ln.endsWith('.cof') && ln.length === cofPrefix.length + 7
|
||||
})
|
||||
.sort()
|
||||
if (altCofs.length > 0) {
|
||||
matchedCof = altCofs[0]
|
||||
usedWeapon = matchedCof!.slice(-7, -4).toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedCof) {
|
||||
throw new Error(`Object COF not found: ${exactCof}`)
|
||||
}
|
||||
|
||||
const cofBytes = await archives.read(matchedCof)
|
||||
const cof: CofFile = decodeCof(cofBytes)
|
||||
|
||||
const layerResults = await Promise.all(cof.layers.map(async (layer) => {
|
||||
const component = MONSTER_COMPONENTS[layer.type]
|
||||
if (component === undefined) {
|
||||
return {
|
||||
sprite: null,
|
||||
member: undefined,
|
||||
failure: { token: lower, animation: usedAnim, component: null, member: null, reason: 'unknown-component' } as MonsterLayerFailure,
|
||||
}
|
||||
}
|
||||
const litDcc = fileIndex.byLower.get(`${root}${component}\\${lower}${component}lit${usedAnim}${usedWeapon}.dcc`)
|
||||
const litDc6 = fileIndex.byLower.get(`${root}${component}\\${lower}${component}lit${usedAnim}${usedWeapon}.dc6`)
|
||||
const member =
|
||||
litDcc ??
|
||||
litDc6 ??
|
||||
findMonsterLayerSprite(
|
||||
tokenFiles,
|
||||
root,
|
||||
lower,
|
||||
usedAnim,
|
||||
usedWeapon,
|
||||
component,
|
||||
layer.weaponClass,
|
||||
)
|
||||
|
||||
if (member === undefined) {
|
||||
return {
|
||||
sprite: null,
|
||||
member: undefined,
|
||||
failure: { token: lower, animation: usedAnim, component, member: null, reason: 'no-member' } as MonsterLayerFailure,
|
||||
}
|
||||
}
|
||||
try {
|
||||
const bytes = await archives.read(member)
|
||||
return {
|
||||
sprite: decodeLayerSpriteBytes(member, bytes, cof.numberOfDirections, cof.framesPerDirection),
|
||||
member,
|
||||
failure: undefined,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
sprite: null,
|
||||
member: undefined,
|
||||
failure: {
|
||||
token: lower,
|
||||
animation: usedAnim,
|
||||
component,
|
||||
member,
|
||||
reason: 'decode',
|
||||
message: (err as Error).message,
|
||||
} as MonsterLayerFailure,
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const sprites: (DccFile | null)[] = layerResults.map(r => r.sprite)
|
||||
const decodedMembers: string[] = layerResults.flatMap(r => r.member ? [r.member] : [])
|
||||
const layerFailures: MonsterLayerFailure[] = layerResults.flatMap(r => r.failure ? [r.failure] : [])
|
||||
|
||||
// Sub-tile draw anchor offset from D2Common ordinal 10117 (subtileDrawPositionCoords): (-16, +16)
|
||||
return compositeLoadedLayers(cof, sprites, decodedMembers, layerFailures, -16, 16)
|
||||
}
|
||||
|
||||
function compositeLoadedLayers(
|
||||
cof: CofFile,
|
||||
sprites: readonly (DccFile | null)[],
|
||||
decodedMembers: readonly string[],
|
||||
layerFailures: readonly MonsterLayerFailure[],
|
||||
anchorOffsetX: number,
|
||||
anchorOffsetY: number,
|
||||
): MonsterAnimationSheet {
|
||||
const isAdditiveLayer = (layerIdx: number): boolean => {
|
||||
const layer = cof.layers[layerIdx]
|
||||
return layer !== undefined && (layer.drawEffect === 3 || layer.transparent)
|
||||
|
|
@ -223,11 +543,11 @@ export async function compositeMonsterAnimation(
|
|||
|
||||
for (let layerIdx = 0; layerIdx < cof.layers.length; layerIdx += 1) {
|
||||
const dcc = sprites[layerIdx]
|
||||
if (!dcc) continue
|
||||
if (!dcc || dcc.directions.length === 0) continue
|
||||
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
|
||||
const dirData = dcc.directions[dccDir]
|
||||
if (!dirData) continue
|
||||
const frameData = dirData.frames[f]
|
||||
const dirData = dcc.directions[dccDir] ?? dcc.directions[0]
|
||||
if (!dirData || dirData.frames.length === 0) continue
|
||||
const frameData = dirData.frames[f] ?? dirData.frames[f % dirData.frames.length]
|
||||
if (!frameData) continue
|
||||
left = Math.min(left, dirData.box.left)
|
||||
top = Math.min(top, dirData.box.top)
|
||||
|
|
@ -242,8 +562,8 @@ export async function compositeMonsterAnimation(
|
|||
height: 1,
|
||||
indices: new Uint8Array(1),
|
||||
mask: new Uint8Array(1),
|
||||
anchorX: 0,
|
||||
anchorY: 0,
|
||||
anchorX: anchorOffsetX,
|
||||
anchorY: anchorOffsetY,
|
||||
}
|
||||
baseFrames.push(emptyFrame)
|
||||
if (isComposite) glowFrames.push(emptyFrame)
|
||||
|
|
@ -272,11 +592,11 @@ export async function compositeMonsterAnimation(
|
|||
const order = cofLayerOrder(cof, dir, f)
|
||||
for (const layerIdx of order) {
|
||||
const dcc = sprites[layerIdx]
|
||||
if (!dcc) continue
|
||||
if (!dcc || dcc.directions.length === 0) continue
|
||||
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
|
||||
const dirData = dcc.directions[dccDir]
|
||||
if (!dirData) continue
|
||||
const frameData = dirData.frames[f]
|
||||
const dirData = dcc.directions[dccDir] ?? dcc.directions[0]
|
||||
if (!dirData || dirData.frames.length === 0) continue
|
||||
const frameData = dirData.frames[f] ?? dirData.frames[f % dirData.frames.length]
|
||||
if (!frameData) continue
|
||||
const atX = Math.round(dirData.box.left) - boxLeft
|
||||
const atY = Math.round(dirData.box.top) - boxTop
|
||||
|
|
@ -292,8 +612,8 @@ export async function compositeMonsterAnimation(
|
|||
height: frameHeight,
|
||||
indices: baseTarget.indices,
|
||||
mask: baseTarget.mask,
|
||||
anchorX: boxLeft,
|
||||
anchorY: boxTop,
|
||||
anchorX: anchorOffsetX + boxLeft,
|
||||
anchorY: anchorOffsetY + boxTop,
|
||||
})
|
||||
if (glowTarget !== null) {
|
||||
glowFrames.push({
|
||||
|
|
@ -301,8 +621,8 @@ export async function compositeMonsterAnimation(
|
|||
height: frameHeight,
|
||||
indices: glowTarget.indices,
|
||||
mask: glowTarget.mask,
|
||||
anchorX: boxLeft,
|
||||
anchorY: boxTop,
|
||||
anchorX: anchorOffsetX + boxLeft,
|
||||
anchorY: anchorOffsetY + boxTop,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -774,3 +774,752 @@ export function resolveMonsterArtSpec(id: string): MonsterArtSpec | null {
|
|||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical MonStats.txt hcIdx (0..733) -> lowercase monster Id mapping.
|
||||
*/
|
||||
export const MONSTER_HCIDX_TO_ID: Readonly<Record<number, string>> = {
|
||||
0: "skeleton1",
|
||||
1: "skeleton2",
|
||||
2: "skeleton3",
|
||||
3: "skeleton4",
|
||||
4: "skeleton5",
|
||||
5: "zombie1",
|
||||
6: "zombie2",
|
||||
7: "zombie3",
|
||||
8: "zombie4",
|
||||
9: "zombie5",
|
||||
10: "bighead1",
|
||||
11: "bighead2",
|
||||
12: "bighead3",
|
||||
13: "bighead4",
|
||||
14: "bighead5",
|
||||
15: "foulcrow1",
|
||||
16: "foulcrow2",
|
||||
17: "foulcrow3",
|
||||
18: "foulcrow4",
|
||||
19: "fallen1",
|
||||
20: "fallen2",
|
||||
21: "fallen3",
|
||||
22: "fallen4",
|
||||
23: "fallen5",
|
||||
24: "brute2",
|
||||
25: "brute3",
|
||||
26: "brute4",
|
||||
27: "brute5",
|
||||
28: "brute1",
|
||||
29: "sandraider1",
|
||||
30: "sandraider2",
|
||||
31: "sandraider3",
|
||||
32: "sandraider4",
|
||||
33: "sandraider5",
|
||||
34: "gorgon1",
|
||||
35: "gorgon2",
|
||||
36: "gorgon3",
|
||||
37: "gorgon4",
|
||||
38: "wraith1",
|
||||
39: "wraith2",
|
||||
40: "wraith3",
|
||||
41: "wraith4",
|
||||
42: "wraith5",
|
||||
43: "corruptrogue1",
|
||||
44: "corruptrogue2",
|
||||
45: "corruptrogue3",
|
||||
46: "corruptrogue4",
|
||||
47: "corruptrogue5",
|
||||
48: "baboon1",
|
||||
49: "baboon2",
|
||||
50: "baboon3",
|
||||
51: "baboon4",
|
||||
52: "baboon5",
|
||||
53: "goatman1",
|
||||
54: "goatman2",
|
||||
55: "goatman3",
|
||||
56: "goatman4",
|
||||
57: "goatman5",
|
||||
58: "fallenshaman1",
|
||||
59: "fallenshaman2",
|
||||
60: "fallenshaman3",
|
||||
61: "fallenshaman4",
|
||||
62: "fallenshaman5",
|
||||
63: "quillrat1",
|
||||
64: "quillrat2",
|
||||
65: "quillrat3",
|
||||
66: "quillrat4",
|
||||
67: "quillrat5",
|
||||
68: "sandmaggot1",
|
||||
69: "sandmaggot2",
|
||||
70: "sandmaggot3",
|
||||
71: "sandmaggot4",
|
||||
72: "sandmaggot5",
|
||||
73: "clawviper1",
|
||||
74: "clawviper2",
|
||||
75: "clawviper3",
|
||||
76: "clawviper4",
|
||||
77: "clawviper5",
|
||||
78: "sandleaper1",
|
||||
79: "sandleaper2",
|
||||
80: "sandleaper3",
|
||||
81: "sandleaper4",
|
||||
82: "sandleaper5",
|
||||
83: "pantherwoman1",
|
||||
84: "pantherwoman2",
|
||||
85: "pantherwoman3",
|
||||
86: "pantherwoman4",
|
||||
87: "swarm1",
|
||||
88: "swarm2",
|
||||
89: "swarm3",
|
||||
90: "swarm4",
|
||||
91: "scarab1",
|
||||
92: "scarab2",
|
||||
93: "scarab3",
|
||||
94: "scarab4",
|
||||
95: "scarab5",
|
||||
96: "mummy1",
|
||||
97: "mummy2",
|
||||
98: "mummy3",
|
||||
99: "mummy4",
|
||||
100: "mummy5",
|
||||
101: "unraveler1",
|
||||
102: "unraveler2",
|
||||
103: "unraveler3",
|
||||
104: "unraveler4",
|
||||
105: "unraveler5",
|
||||
106: "chaoshorde1",
|
||||
107: "chaoshorde2",
|
||||
108: "chaoshorde3",
|
||||
109: "chaoshorde4",
|
||||
110: "vulture1",
|
||||
111: "vulture2",
|
||||
112: "vulture3",
|
||||
113: "vulture4",
|
||||
114: "mosquito1",
|
||||
115: "mosquito2",
|
||||
116: "mosquito3",
|
||||
117: "mosquito4",
|
||||
118: "willowisp1",
|
||||
119: "willowisp2",
|
||||
120: "willowisp3",
|
||||
121: "willowisp4",
|
||||
122: "arach1",
|
||||
123: "arach2",
|
||||
124: "arach3",
|
||||
125: "arach4",
|
||||
126: "arach5",
|
||||
127: "thornhulk1",
|
||||
128: "thornhulk2",
|
||||
129: "thornhulk3",
|
||||
130: "thornhulk4",
|
||||
131: "vampire1",
|
||||
132: "vampire2",
|
||||
133: "vampire3",
|
||||
134: "vampire4",
|
||||
135: "vampire5",
|
||||
136: "batdemon1",
|
||||
137: "batdemon2",
|
||||
138: "batdemon3",
|
||||
139: "batdemon4",
|
||||
140: "batdemon5",
|
||||
141: "fetish1",
|
||||
142: "fetish2",
|
||||
143: "fetish3",
|
||||
144: "fetish4",
|
||||
145: "fetish5",
|
||||
146: "cain1",
|
||||
147: "gheed",
|
||||
148: "akara",
|
||||
149: "chicken",
|
||||
150: "kashya",
|
||||
151: "rat",
|
||||
152: "rogue1",
|
||||
153: "hellmeteor",
|
||||
154: "charsi",
|
||||
155: "warriv1",
|
||||
156: "andariel",
|
||||
157: "bird1",
|
||||
158: "bird2",
|
||||
159: "bat",
|
||||
160: "cr_archer1",
|
||||
161: "cr_archer2",
|
||||
162: "cr_archer3",
|
||||
163: "cr_archer4",
|
||||
164: "cr_archer5",
|
||||
165: "cr_lancer1",
|
||||
166: "cr_lancer2",
|
||||
167: "cr_lancer3",
|
||||
168: "cr_lancer4",
|
||||
169: "cr_lancer5",
|
||||
170: "sk_archer1",
|
||||
171: "sk_archer2",
|
||||
172: "sk_archer3",
|
||||
173: "sk_archer4",
|
||||
174: "sk_archer5",
|
||||
175: "warriv2",
|
||||
176: "atma",
|
||||
177: "drognan",
|
||||
178: "fara",
|
||||
179: "cow",
|
||||
180: "maggotbaby1",
|
||||
181: "maggotbaby2",
|
||||
182: "maggotbaby3",
|
||||
183: "maggotbaby4",
|
||||
184: "maggotbaby5",
|
||||
185: "camel",
|
||||
186: "blunderbore1",
|
||||
187: "blunderbore2",
|
||||
188: "blunderbore3",
|
||||
189: "blunderbore4",
|
||||
190: "maggotegg1",
|
||||
191: "maggotegg2",
|
||||
192: "maggotegg3",
|
||||
193: "maggotegg4",
|
||||
194: "maggotegg5",
|
||||
195: "act2male",
|
||||
196: "act2female",
|
||||
197: "act2child",
|
||||
198: "greiz",
|
||||
199: "elzix",
|
||||
200: "geglash",
|
||||
201: "jerhyn",
|
||||
202: "lysander",
|
||||
203: "act2guard1",
|
||||
204: "act2vendor1",
|
||||
205: "act2vendor2",
|
||||
206: "crownest1",
|
||||
207: "crownest2",
|
||||
208: "crownest3",
|
||||
209: "crownest4",
|
||||
210: "meshif1",
|
||||
211: "duriel",
|
||||
212: "bonefetish1",
|
||||
213: "bonefetish2",
|
||||
214: "bonefetish3",
|
||||
215: "bonefetish4",
|
||||
216: "bonefetish5",
|
||||
217: "darkguard1",
|
||||
218: "darkguard2",
|
||||
219: "darkguard3",
|
||||
220: "darkguard4",
|
||||
221: "darkguard5",
|
||||
222: "bloodmage1",
|
||||
223: "bloodmage2",
|
||||
224: "bloodmage3",
|
||||
225: "bloodmage4",
|
||||
226: "bloodmage5",
|
||||
227: "maggot",
|
||||
228: "sarcophagus",
|
||||
229: "radament",
|
||||
230: "firebeast",
|
||||
231: "iceglobe",
|
||||
232: "lightningbeast",
|
||||
233: "poisonorb",
|
||||
234: "flyingscimitar",
|
||||
235: "zealot1",
|
||||
236: "zealot2",
|
||||
237: "zealot3",
|
||||
238: "cantor1",
|
||||
239: "cantor2",
|
||||
240: "cantor3",
|
||||
241: "cantor4",
|
||||
242: "mephisto",
|
||||
243: "diablo",
|
||||
244: "cain2",
|
||||
245: "cain3",
|
||||
246: "cain4",
|
||||
247: "frogdemon1",
|
||||
248: "frogdemon2",
|
||||
249: "frogdemon3",
|
||||
250: "summoner",
|
||||
251: "tyrael1",
|
||||
252: "asheara",
|
||||
253: "hratli",
|
||||
254: "alkor",
|
||||
255: "ormus",
|
||||
256: "izual",
|
||||
257: "halbu",
|
||||
258: "tentacle1",
|
||||
259: "tentacle2",
|
||||
260: "tentacle3",
|
||||
261: "tentaclehead1",
|
||||
262: "tentaclehead2",
|
||||
263: "tentaclehead3",
|
||||
264: "meshif2",
|
||||
265: "cain5",
|
||||
266: "navi",
|
||||
267: "bloodraven",
|
||||
268: "bug",
|
||||
269: "scorpion",
|
||||
270: "rogue2",
|
||||
271: "roguehire",
|
||||
272: "rogue3",
|
||||
273: "gargoyletrap",
|
||||
274: "skmage_pois1",
|
||||
275: "skmage_pois2",
|
||||
276: "skmage_pois3",
|
||||
277: "skmage_pois4",
|
||||
278: "fetishshaman1",
|
||||
279: "fetishshaman2",
|
||||
280: "fetishshaman3",
|
||||
281: "fetishshaman4",
|
||||
282: "fetishshaman5",
|
||||
283: "larva",
|
||||
284: "maggotqueen1",
|
||||
285: "maggotqueen2",
|
||||
286: "maggotqueen3",
|
||||
287: "maggotqueen4",
|
||||
288: "maggotqueen5",
|
||||
289: "claygolem",
|
||||
290: "bloodgolem",
|
||||
291: "irongolem",
|
||||
292: "firegolem",
|
||||
293: "familiar",
|
||||
294: "act3male",
|
||||
295: "baboon6",
|
||||
296: "act3female",
|
||||
297: "natalya",
|
||||
298: "vilemother1",
|
||||
299: "vilemother2",
|
||||
300: "vilemother3",
|
||||
301: "vilechild1",
|
||||
302: "vilechild2",
|
||||
303: "vilechild3",
|
||||
304: "fingermage1",
|
||||
305: "fingermage2",
|
||||
306: "fingermage3",
|
||||
307: "regurgitator1",
|
||||
308: "regurgitator2",
|
||||
309: "regurgitator3",
|
||||
310: "doomknight1",
|
||||
311: "doomknight2",
|
||||
312: "doomknight3",
|
||||
313: "quillbear1",
|
||||
314: "quillbear2",
|
||||
315: "quillbear3",
|
||||
316: "quillbear4",
|
||||
317: "quillbear5",
|
||||
318: "snake",
|
||||
319: "parrot",
|
||||
320: "fish",
|
||||
321: "evilhole1",
|
||||
322: "evilhole2",
|
||||
323: "evilhole3",
|
||||
324: "evilhole4",
|
||||
325: "evilhole5",
|
||||
326: "trap-firebolt",
|
||||
327: "trap-horzmissile",
|
||||
328: "trap-vertmissile",
|
||||
329: "trap-poisoncloud",
|
||||
330: "trap-lightning",
|
||||
331: "act2guard2",
|
||||
332: "invisospawner",
|
||||
333: "diabloclone",
|
||||
334: "suckernest1",
|
||||
335: "suckernest2",
|
||||
336: "suckernest3",
|
||||
337: "suckernest4",
|
||||
338: "act2hire",
|
||||
339: "minispider",
|
||||
340: "boneprison1",
|
||||
341: "boneprison2",
|
||||
342: "boneprison3",
|
||||
343: "boneprison4",
|
||||
344: "bonewall",
|
||||
345: "councilmember1",
|
||||
346: "councilmember2",
|
||||
347: "councilmember3",
|
||||
348: "turret1",
|
||||
349: "turret2",
|
||||
350: "turret3",
|
||||
351: "hydra1",
|
||||
352: "hydra2",
|
||||
353: "hydra3",
|
||||
354: "trap-melee",
|
||||
355: "seventombs",
|
||||
356: "dopplezon",
|
||||
357: "valkyrie",
|
||||
358: "act2guard3",
|
||||
359: "act3hire",
|
||||
360: "megademon1",
|
||||
361: "megademon2",
|
||||
362: "megademon3",
|
||||
363: "necroskeleton",
|
||||
364: "necromage",
|
||||
365: "griswold",
|
||||
366: "compellingorb",
|
||||
367: "tyrael2",
|
||||
368: "darkwanderer",
|
||||
369: "trap-nova",
|
||||
370: "spiritmummy",
|
||||
371: "lightningspire",
|
||||
372: "firetower",
|
||||
373: "slinger1",
|
||||
374: "slinger2",
|
||||
375: "slinger3",
|
||||
376: "slinger4",
|
||||
377: "act2guard4",
|
||||
378: "act2guard5",
|
||||
379: "skmage_cold1",
|
||||
380: "skmage_cold2",
|
||||
381: "skmage_cold3",
|
||||
382: "skmage_cold4",
|
||||
383: "skmage_fire1",
|
||||
384: "skmage_fire2",
|
||||
385: "skmage_fire3",
|
||||
386: "skmage_fire4",
|
||||
387: "skmage_ltng1",
|
||||
388: "skmage_ltng2",
|
||||
389: "skmage_ltng3",
|
||||
390: "skmage_ltng4",
|
||||
391: "hellbovine",
|
||||
392: "window1",
|
||||
393: "window2",
|
||||
394: "slinger5",
|
||||
395: "slinger6",
|
||||
396: "fetishblow1",
|
||||
397: "fetishblow2",
|
||||
398: "fetishblow3",
|
||||
399: "fetishblow4",
|
||||
400: "fetishblow5",
|
||||
401: "mephistospirit",
|
||||
402: "smith",
|
||||
403: "trappedsoul1",
|
||||
404: "trappedsoul2",
|
||||
405: "jamella",
|
||||
406: "izualghost",
|
||||
407: "fetish11",
|
||||
408: "malachai",
|
||||
409: "hephasto",
|
||||
410: "wakeofdestruction",
|
||||
411: "chargeboltsentry",
|
||||
412: "lightningsentry",
|
||||
413: "bladecreeper",
|
||||
414: "invisopet",
|
||||
415: "infernosentry",
|
||||
416: "deathsentry",
|
||||
417: "shadowwarrior",
|
||||
418: "shadowmaster",
|
||||
419: "druidhawk",
|
||||
420: "spiritwolf",
|
||||
421: "fenris",
|
||||
422: "spiritofbarbs",
|
||||
423: "heartofwolverine",
|
||||
424: "oaksage",
|
||||
425: "plaguepoppy",
|
||||
426: "cycleoflife",
|
||||
427: "vinecreature",
|
||||
428: "druidbear",
|
||||
429: "eagle",
|
||||
430: "wolf",
|
||||
431: "bear",
|
||||
432: "barricadedoor1",
|
||||
433: "barricadedoor2",
|
||||
434: "prisondoor",
|
||||
435: "barricadetower",
|
||||
436: "reanimatedhorde1",
|
||||
437: "reanimatedhorde2",
|
||||
438: "reanimatedhorde3",
|
||||
439: "reanimatedhorde4",
|
||||
440: "reanimatedhorde5",
|
||||
441: "siegebeast1",
|
||||
442: "siegebeast2",
|
||||
443: "siegebeast3",
|
||||
444: "siegebeast4",
|
||||
445: "siegebeast5",
|
||||
446: "snowyeti1",
|
||||
447: "snowyeti2",
|
||||
448: "snowyeti3",
|
||||
449: "snowyeti4",
|
||||
450: "wolfrider1",
|
||||
451: "wolfrider2",
|
||||
452: "wolfrider3",
|
||||
453: "minion1",
|
||||
454: "minion2",
|
||||
455: "minion3",
|
||||
456: "minion4",
|
||||
457: "minion5",
|
||||
458: "minion6",
|
||||
459: "minion7",
|
||||
460: "minion8",
|
||||
461: "suicideminion1",
|
||||
462: "suicideminion2",
|
||||
463: "suicideminion3",
|
||||
464: "suicideminion4",
|
||||
465: "suicideminion5",
|
||||
466: "suicideminion6",
|
||||
467: "suicideminion7",
|
||||
468: "suicideminion8",
|
||||
469: "succubus1",
|
||||
470: "succubus2",
|
||||
471: "succubus3",
|
||||
472: "succubus4",
|
||||
473: "succubus5",
|
||||
474: "succubuswitch1",
|
||||
475: "succubuswitch2",
|
||||
476: "succubuswitch3",
|
||||
477: "succubuswitch4",
|
||||
478: "succubuswitch5",
|
||||
479: "overseer1",
|
||||
480: "overseer2",
|
||||
481: "overseer3",
|
||||
482: "overseer4",
|
||||
483: "overseer5",
|
||||
484: "minionspawner1",
|
||||
485: "minionspawner2",
|
||||
486: "minionspawner3",
|
||||
487: "minionspawner4",
|
||||
488: "minionspawner5",
|
||||
489: "minionspawner6",
|
||||
490: "minionspawner7",
|
||||
491: "minionspawner8",
|
||||
492: "imp1",
|
||||
493: "imp2",
|
||||
494: "imp3",
|
||||
495: "imp4",
|
||||
496: "imp5",
|
||||
497: "catapult1",
|
||||
498: "catapult2",
|
||||
499: "catapult3",
|
||||
500: "catapult4",
|
||||
501: "frozenhorror1",
|
||||
502: "frozenhorror2",
|
||||
503: "frozenhorror3",
|
||||
504: "frozenhorror4",
|
||||
505: "frozenhorror5",
|
||||
506: "bloodlord1",
|
||||
507: "bloodlord2",
|
||||
508: "bloodlord3",
|
||||
509: "bloodlord4",
|
||||
510: "bloodlord5",
|
||||
511: "larzuk",
|
||||
512: "drehya",
|
||||
513: "malah",
|
||||
514: "nihlathak",
|
||||
515: "qual-kehk",
|
||||
516: "catapultspotter1",
|
||||
517: "catapultspotter2",
|
||||
518: "catapultspotter3",
|
||||
519: "catapultspotter4",
|
||||
520: "cain6",
|
||||
521: "tyrael3",
|
||||
522: "act5barb1",
|
||||
523: "act5barb2",
|
||||
524: "barricadewall1",
|
||||
525: "barricadewall2",
|
||||
526: "nihlathakboss",
|
||||
527: "drehyaiced",
|
||||
528: "evilhut",
|
||||
529: "deathmauler1",
|
||||
530: "deathmauler2",
|
||||
531: "deathmauler3",
|
||||
532: "deathmauler4",
|
||||
533: "deathmauler5",
|
||||
534: "act5pow",
|
||||
535: "act5barb3",
|
||||
536: "act5barb4",
|
||||
537: "ancientstatue1",
|
||||
538: "ancientstatue2",
|
||||
539: "ancientstatue3",
|
||||
540: "ancientbarb1",
|
||||
541: "ancientbarb2",
|
||||
542: "ancientbarb3",
|
||||
543: "baalthrone",
|
||||
544: "baalcrab",
|
||||
545: "baaltaunt",
|
||||
546: "putriddefiler1",
|
||||
547: "putriddefiler2",
|
||||
548: "putriddefiler3",
|
||||
549: "putriddefiler4",
|
||||
550: "putriddefiler5",
|
||||
551: "painworm1",
|
||||
552: "painworm2",
|
||||
553: "painworm3",
|
||||
554: "painworm4",
|
||||
555: "painworm5",
|
||||
556: "bunny",
|
||||
557: "baalhighpriest",
|
||||
558: "venomlord",
|
||||
559: "baalcrabstairs",
|
||||
560: "act5hire1",
|
||||
561: "act5hire2",
|
||||
562: "baaltentacle1",
|
||||
563: "baaltentacle2",
|
||||
564: "baaltentacle3",
|
||||
565: "baaltentacle4",
|
||||
566: "baaltentacle5",
|
||||
567: "injuredbarb1",
|
||||
568: "injuredbarb2",
|
||||
569: "injuredbarb3",
|
||||
570: "baalclone",
|
||||
571: "baalminion1",
|
||||
572: "baalminion2",
|
||||
573: "baalminion3",
|
||||
574: "worldstoneeffect",
|
||||
575: "sk_archer6",
|
||||
576: "sk_archer7",
|
||||
577: "sk_archer8",
|
||||
578: "sk_archer9",
|
||||
579: "sk_archer10",
|
||||
580: "bighead6",
|
||||
581: "bighead7",
|
||||
582: "bighead8",
|
||||
583: "bighead9",
|
||||
584: "bighead10",
|
||||
585: "goatman6",
|
||||
586: "goatman7",
|
||||
587: "goatman8",
|
||||
588: "goatman9",
|
||||
589: "goatman10",
|
||||
590: "foulcrow5",
|
||||
591: "foulcrow6",
|
||||
592: "foulcrow7",
|
||||
593: "foulcrow8",
|
||||
594: "clawviper6",
|
||||
595: "clawviper7",
|
||||
596: "clawviper8",
|
||||
597: "clawviper9",
|
||||
598: "clawviper10",
|
||||
599: "sandraider6",
|
||||
600: "sandraider7",
|
||||
601: "sandraider8",
|
||||
602: "sandraider9",
|
||||
603: "sandraider10",
|
||||
604: "deathmauler6",
|
||||
605: "quillrat6",
|
||||
606: "quillrat7",
|
||||
607: "quillrat8",
|
||||
608: "vulture5",
|
||||
609: "thornhulk5",
|
||||
610: "slinger7",
|
||||
611: "slinger8",
|
||||
612: "slinger9",
|
||||
613: "cr_archer6",
|
||||
614: "cr_archer7",
|
||||
615: "cr_lancer6",
|
||||
616: "cr_lancer7",
|
||||
617: "cr_lancer8",
|
||||
618: "blunderbore5",
|
||||
619: "blunderbore6",
|
||||
620: "skmage_fire5",
|
||||
621: "skmage_fire6",
|
||||
622: "skmage_ltng5",
|
||||
623: "skmage_ltng6",
|
||||
624: "skmage_cold5",
|
||||
625: "skmage_pois5",
|
||||
626: "skmage_pois6",
|
||||
627: "pantherwoman5",
|
||||
628: "pantherwoman6",
|
||||
629: "sandleaper6",
|
||||
630: "sandleaper7",
|
||||
631: "wraith6",
|
||||
632: "wraith7",
|
||||
633: "wraith8",
|
||||
634: "succubus6",
|
||||
635: "succubus7",
|
||||
636: "succubuswitch6",
|
||||
637: "succubuswitch7",
|
||||
638: "succubuswitch8",
|
||||
639: "willowisp5",
|
||||
640: "willowisp6",
|
||||
641: "willowisp7",
|
||||
642: "fallen6",
|
||||
643: "fallen7",
|
||||
644: "fallen8",
|
||||
645: "fallenshaman6",
|
||||
646: "fallenshaman7",
|
||||
647: "fallenshaman8",
|
||||
648: "skeleton6",
|
||||
649: "skeleton7",
|
||||
650: "batdemon6",
|
||||
651: "batdemon7",
|
||||
652: "bloodlord6",
|
||||
653: "bloodlord7",
|
||||
654: "scarab6",
|
||||
655: "scarab7",
|
||||
656: "fetish6",
|
||||
657: "fetish7",
|
||||
658: "fetish8",
|
||||
659: "fetishblow6",
|
||||
660: "fetishblow7",
|
||||
661: "fetishblow8",
|
||||
662: "fetishshaman6",
|
||||
663: "fetishshaman7",
|
||||
664: "fetishshaman8",
|
||||
665: "baboon7",
|
||||
666: "baboon8",
|
||||
667: "unraveler6",
|
||||
668: "unraveler7",
|
||||
669: "unraveler8",
|
||||
670: "unraveler9",
|
||||
671: "zealot4",
|
||||
672: "zealot5",
|
||||
673: "cantor5",
|
||||
674: "cantor6",
|
||||
675: "vilemother4",
|
||||
676: "vilemother5",
|
||||
677: "vilechild4",
|
||||
678: "vilechild5",
|
||||
679: "sandmaggot6",
|
||||
680: "maggotbaby6",
|
||||
681: "maggotegg6",
|
||||
682: "minion9",
|
||||
683: "minion10",
|
||||
684: "minion11",
|
||||
685: "arach6",
|
||||
686: "megademon4",
|
||||
687: "megademon5",
|
||||
688: "imp6",
|
||||
689: "imp7",
|
||||
690: "bonefetish6",
|
||||
691: "bonefetish7",
|
||||
692: "fingermage4",
|
||||
693: "fingermage5",
|
||||
694: "regurgitator4",
|
||||
695: "vampire6",
|
||||
696: "vampire7",
|
||||
697: "vampire8",
|
||||
698: "reanimatedhorde6",
|
||||
699: "dkfig1",
|
||||
700: "dkfig2",
|
||||
701: "dkmag1",
|
||||
702: "dkmag2",
|
||||
703: "mummy6",
|
||||
704: "ubermephisto",
|
||||
705: "uberdiablo",
|
||||
706: "uberizual",
|
||||
707: "uberandariel",
|
||||
708: "uberduriel",
|
||||
709: "uberbaal",
|
||||
710: "demonspawner",
|
||||
711: "demonhole",
|
||||
712: "megademon6",
|
||||
713: "dkmag3",
|
||||
714: "imp8",
|
||||
715: "swarm5",
|
||||
716: "sandmaggot7",
|
||||
717: "arach7",
|
||||
718: "scarab8",
|
||||
719: "succubus8",
|
||||
720: "succubuswitch9",
|
||||
721: "corruptrogue6",
|
||||
722: "cr_archer8",
|
||||
723: "cr_lancer8",
|
||||
724: "overseer6",
|
||||
725: "skeleton8",
|
||||
726: "sk_archer11",
|
||||
727: "skmage_fire7",
|
||||
728: "skmage_ltng7",
|
||||
729: "skmage_cold6",
|
||||
730: "skmage_pois7",
|
||||
731: "vampire9",
|
||||
732: "wraith9",
|
||||
733: "willowisp8",
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the visual token and weapon class for a numeric MonStats.txt hcIdx (0..733).
|
||||
*/
|
||||
export function resolveMonsterArtSpecByHcIdx(hcIdx: number): MonsterArtSpec | null {
|
||||
const id = MONSTER_HCIDX_TO_ID[hcIdx]
|
||||
if (!id) return null
|
||||
return resolveMonsterArtSpec(id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,12 +63,16 @@ import type { D2Table } from '../world/acts.ts'
|
|||
import { cell, parseTable } from '../world/acts.ts'
|
||||
import { cofLayerOrder, decodeCof } from '../formats/cof.ts'
|
||||
import type { CofFile } from '../formats/cof.ts'
|
||||
import { decodeDc6 } from '../formats/dc6.ts'
|
||||
import { decodeDcc } from '../formats/dcc.ts'
|
||||
import type { DccFile } from '../formats/dcc.ts'
|
||||
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
||||
import type { MountedArchives } from '../mpq/mount.ts'
|
||||
import { OBJECT_TYPE_OBJECT, OBJECT_TYPE_MONSTER, lookupObject } from './object-lookup.ts'
|
||||
import type { ObjectLookupEntry } from './object-lookup.ts'
|
||||
import { compositeObjectAnimation, dc6ToDccFile } from './monster-art.ts'
|
||||
|
||||
export { compositeObjectAnimation }
|
||||
|
||||
/** `Objects.txt` inside the archives. */
|
||||
const OBJECTS_TABLE = 'data\\global\\excel\\objects.txt'
|
||||
|
|
@ -327,6 +331,8 @@ export interface ObjectsRow {
|
|||
readonly id: number
|
||||
/** `Name`, e.g. `Barrel`. */
|
||||
readonly name: string
|
||||
/** `Description - not loaded`, e.g. ` barrel` or `Cain's Cage`. */
|
||||
readonly description?: string | undefined
|
||||
/** `Token`, upper-cased: the `<TOKEN>` in every path the engine builds. */
|
||||
readonly token: string
|
||||
/** `SubClass` — the object class (`OBJSUBCLASS_DOOR` = 0x80, `CHEST` = 0x08, ...). */
|
||||
|
|
@ -341,6 +347,8 @@ export interface ObjectsRow {
|
|||
readonly yOffset: number
|
||||
/** `IsDoor` — doors take the operate path rather than the casket path. */
|
||||
readonly isDoor: boolean
|
||||
/** `OperateRange` — interaction range in sub-tiles. */
|
||||
readonly operateRange?: number | undefined
|
||||
/** `Trans` — transparency mode. */
|
||||
readonly trans: number
|
||||
/** `BlendMode` — additive or normal blending mode. */
|
||||
|
|
@ -397,7 +405,7 @@ const COMPONENT_COLUMNS = ['HD', 'TR', 'LG', 'RA', 'LA', 'RH', 'LH', 'SH', 'S1',
|
|||
* @param row - the raw cells.
|
||||
* @returns the typed row.
|
||||
*/
|
||||
function toObjectsRow(table: D2Table, row: readonly string[]): ObjectsRow {
|
||||
function toObjectsRow(table: D2Table, row: readonly string[], sequentialId: number): ObjectsRow {
|
||||
const numbers = (prefix: string, count: number): number[] =>
|
||||
Array.from({ length: count }, (_, index) => Number(cell(table, row, `${prefix}${String(index)}`) || '0'))
|
||||
const components = COMPONENT_COLUMNS.filter(column => cell(table, row, column) !== '0' && cell(table, row, column) !== '')
|
||||
|
|
@ -409,8 +417,9 @@ function toObjectsRow(table: D2Table, row: readonly string[]): ObjectsRow {
|
|||
? 'additive'
|
||||
: 'normal'
|
||||
return {
|
||||
id: Number(cell(table, row, 'Id')),
|
||||
id: sequentialId,
|
||||
name: cell(table, row, 'Name'),
|
||||
description: cell(table, row, 'Description - not loaded') || cell(table, row, 'Name'),
|
||||
token,
|
||||
subClass: Number(cell(table, row, 'SubClass') || '0'),
|
||||
act: Number(cell(table, row, 'Act') || '0'),
|
||||
|
|
@ -419,6 +428,7 @@ function toObjectsRow(table: D2Table, row: readonly string[]): ObjectsRow {
|
|||
xOffset: Number(cell(table, row, 'Xoffset') || '0'),
|
||||
yOffset: Number(cell(table, row, 'Yoffset') || '0'),
|
||||
isDoor: cell(table, row, 'IsDoor') !== '0' && cell(table, row, 'IsDoor') !== '',
|
||||
operateRange: Number(cell(table, row, 'OperateRange') || '0'),
|
||||
trans,
|
||||
blendMode,
|
||||
draw: Number(cell(table, row, 'Draw') || '0'),
|
||||
|
|
@ -450,8 +460,10 @@ export async function loadObjectsTable(archives: MountedArchives): Promise<Objec
|
|||
const table = parseTable(await archives.read(OBJECTS_TABLE))
|
||||
const rows: ObjectsRow[] = []
|
||||
for (const raw of table.rows) {
|
||||
const row = toObjectsRow(table, raw)
|
||||
if (!Number.isFinite(row.id)) continue
|
||||
const idStr = cell(table, raw, 'Id').trim()
|
||||
const nameStr = cell(table, raw, 'Name').trim()
|
||||
if (idStr === '' || nameStr.toLowerCase() === 'expansion') continue
|
||||
const row = toObjectsRow(table, raw, rows.length)
|
||||
rows.push(row)
|
||||
}
|
||||
const byId = new Map<number, ObjectsRow>()
|
||||
|
|
@ -887,11 +899,21 @@ export async function loadObjectSheet(
|
|||
const component = OBJECT_COMPONENTS[layer.type]
|
||||
if (component === undefined) { notes.push(`layer type ${String(layer.type)} has no component directory`); sprites.push(null); continue }
|
||||
const wanted = objectSpriteMember(token, component, modeIndex, baseIsMonsters)
|
||||
const wantedNoLit = wanted.replace(/lit(\.dcc)$/i, '$1')
|
||||
let found = findMember(names, wanted)
|
||||
if (found === undefined) found = findMember(names, wanted.replace(/\.dcc$/i, '.dc6'))
|
||||
if (found === undefined && wantedNoLit !== wanted) {
|
||||
found = findMember(names, wantedNoLit)
|
||||
if (found === undefined) found = findMember(names, wantedNoLit.replace(/\.dcc$/i, '.dc6'))
|
||||
}
|
||||
if (found === undefined) { notes.push(`no sprite for component ${component} (${wanted})`); sprites.push(null); continue }
|
||||
try {
|
||||
sprites.push(decodeDcc(await archives.read(found)))
|
||||
const raw = await archives.read(found)
|
||||
if (found.toLowerCase().endsWith('.dc6')) {
|
||||
sprites.push(dc6ToDccFile(decodeDc6(raw), cof.numberOfDirections, cof.framesPerDirection))
|
||||
} else {
|
||||
sprites.push(decodeDcc(raw))
|
||||
}
|
||||
members.push(found)
|
||||
} catch (err) {
|
||||
notes.push(`${found}: ${(err as Error).message}`)
|
||||
|
|
|
|||
Loading…
Reference in New Issue