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:
|
* 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:
|
* 2. Schema 2 format contract:
|
||||||
* - `schema === 2`
|
* - `schema === 2`
|
||||||
* - `groups` entries are 6-tuples `[x, y, w, h, ax, ay]` with valid bounding boxes inside texture boundaries.
|
* - `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. Characters (`char-am`, `char-so`, `char-ne`, `char-pa`, `char-ba`, `char-dz`, `char-ai`):
|
||||||
* 3. Character (`char-so`):
|
|
||||||
* - Directions = 16
|
* - Directions = 16
|
||||||
* - All 11 player modes present: `wl, nu, rn, a1, a2, sc, gh, dt, dd, tn, tw`.
|
* - 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.
|
* - 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`.
|
* - Backward-compatibility aliases `walk` and `stand`.
|
||||||
* 4. Monsters:
|
* 4. Monsters & Town NPCs (`monster-*.{json,r8}` + `monsterByClassId` / `monstersByHcIdx` 0..733):
|
||||||
* - All unique art specs verified.
|
* - 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}.
|
* - Directions in {1, 4, 8, 16}.
|
||||||
* - At least `nu` mode present, and `wl` where supported.
|
* 5. Objects (`object-*.{json,r8}` + `objectByClassId` / `objectsByClassId` 0..572 + `objectLookupByActTypeId`):
|
||||||
* - Backward-compatibility aliases `walkOffset`, `standOffset`.
|
* - All 531 unique drawable object art specs verified (`26` Gibbet, `39` Rogue Torch, `119` Waypoint, `267` Stash, etc.).
|
||||||
* 5. Asset file integrity:
|
* 6. UI, Flippy Multi-Frame Sprites, Missiles, Overlays & Skills:
|
||||||
* - `.r8` files have exact size `width * height`.
|
* - `ui/manifest.json` & `ui/ui-atlas.json` (`itemRects`, `flippyRects`, `flippyFrames`, `itemClassIdToCode` 0..658).
|
||||||
* - `.png` files have valid PNG signatures and matching header dimensions.
|
* - Valid PNG headers and dimensions (`0` corrupt PNGs, `0` missing indices).
|
||||||
*/
|
*/
|
||||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
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 }
|
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 {
|
function verifyFrameTuples(groups: any[][], width: number, height: number, label: string): void {
|
||||||
for (let g = 0; g < groups.length; g += 1) {
|
for (let g = 0; g < groups.length; g += 1) {
|
||||||
const group = groups[g]!
|
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)}`)
|
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
|
const [x, y, w, h, ax, ay] = tuple
|
||||||
if (typeof x !== 'number' || typeof y !== 'number' || typeof w !== 'number' || typeof h !== 'number' ||
|
if (
|
||||||
typeof ax !== 'number' || typeof ay !== 'number') {
|
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)}`)
|
throw new Error(`${label}: group ${g} frame ${f} contains non-numeric fields: ${JSON.stringify(tuple)}`)
|
||||||
}
|
}
|
||||||
if (w < 1 || h < 1) {
|
if (w < 1 || h < 1) {
|
||||||
throw new Error(`${label}: group ${g} frame ${f} has non-positive dimension: ${w}x${h}`)
|
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) {
|
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) {
|
if (meta.schema !== 2) {
|
||||||
throw new Error(`${label}: expected schema === 2, got ${meta.schema}`)
|
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
|
const dirCount = clip.directions ?? meta.directions
|
||||||
if (clip.group < 0 || clip.group + dirCount > meta.groups.length) {
|
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) {
|
if (clip.frames < 1) {
|
||||||
throw new Error(`${label}: mode ${mode} has invalid frame count: ${clip.frames}`)
|
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 (
|
||||||
if (!meta.clips.nu!.loop || !meta.clips.wl!.loop || !meta.clips.rn!.loop || !meta.clips.tn!.loop || !meta.clips.tw!.loop) {
|
!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`)
|
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`)
|
throw new Error(`${label}: action/one-shot player modes (a1, a2, sc, gh, dt, dd) must have loop: false`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check event ground truth
|
if (checkSorceressEvents) {
|
||||||
const a1AttackEvent = meta.clips.a1!.events.find(e => e.c === 1)
|
const a1AttackEvent = meta.clips.a1!.events.find(e => e.c === 1)
|
||||||
if (!a1AttackEvent || a1AttackEvent.f !== 9) {
|
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)}`)
|
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)
|
const scCastEvent = meta.clips.sc!.events.find(e => e.c === 1)
|
||||||
if (!scCastEvent || scCastEvent.f !== 7) {
|
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)}`)
|
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) {
|
if (meta.walk !== meta.clips.wl!.group) {
|
||||||
throw new Error(`${label}: walk alias mismatch: ${meta.walk} vs ${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) {
|
if (!meta.token || !meta.weapon) {
|
||||||
throw new Error(`${label}: missing token or 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}`)
|
throw new Error(`${label}: invalid directions count: ${meta.directions}`)
|
||||||
}
|
}
|
||||||
if (!meta.clips || !meta.clips.nu) {
|
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)) {
|
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) {
|
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) {
|
if (clip.frames < 1) {
|
||||||
throw new Error(`${label}: mode ${mode} has invalid frame count: ${clip.frames}`)
|
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) {
|
if (clip.speed <= 0) {
|
||||||
throw new Error(`${label}: mode ${mode} has invalid speed: ${clip.speed}`)
|
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) {
|
if (meta.clips.wl && meta.walkOffset !== meta.clips.wl.group) {
|
||||||
throw new Error(`${label}: walkOffset mismatch: ${meta.walkOffset} vs ${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}`)
|
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)) {
|
if (meta.blendMode && !['normal', 'additive'].includes(meta.blendMode)) {
|
||||||
throw new Error(`${label}: invalid blendMode: ${meta.blendMode}`)
|
throw new Error(`${label}: invalid blendMode: ${meta.blendMode}`)
|
||||||
}
|
}
|
||||||
if (meta.glowGroups) {
|
if (meta.glowGroups) {
|
||||||
if (meta.glowGroups.length !== meta.groups.length) {
|
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)`)
|
verifyFrameTuples(meta.glowGroups, meta.width, meta.height, `${label} (glowGroups)`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runVerification(): void {
|
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')
|
const globalDir = join(PACK_ROOT, 'entities')
|
||||||
if (!existsSync(globalDir)) {
|
const catalog = JSON.parse(readFileSync(join(globalDir, 'index.json'), 'utf8')) as PackedEntityCatalogIndex
|
||||||
throw new Error(`Global entity directory not found: ${globalDir}`)
|
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
|
// Verify Act 1 Town NPCs in monsterByClassId
|
||||||
const charJsonPath = join(globalDir, 'char-so.json')
|
const requiredTownNpcHcIdx = [146, 147, 148, 150, 154, 155]
|
||||||
const charR8Path = join(globalDir, 'char-so.r8')
|
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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)) {
|
if (!existsSync(charJsonPath) || !existsSync(charR8Path)) {
|
||||||
throw new Error(`Missing global char-so: ${charJsonPath} or ${charR8Path}`)
|
throw new Error(`Missing ${charSlug} in ${dir}: ${charJsonPath} or ${charR8Path}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const charMeta: EntityMeta = JSON.parse(readFileSync(charJsonPath, 'utf8'))
|
const charMeta: EntityMeta = JSON.parse(readFileSync(charJsonPath, 'utf8'))
|
||||||
verifyCharacterMeta(charMeta, 'Global char-so')
|
verifyCharacterMeta(charMeta, `${dir}/${charSlug}`, charSlug === 'char-so')
|
||||||
verifyFrameTuples(charMeta.groups, charMeta.width, charMeta.height, 'Global char-so')
|
verifyFrameTuples(charMeta.groups, charMeta.width, charMeta.height, `${dir}/${charSlug}`)
|
||||||
|
|
||||||
const charR8Bytes = readFileSync(charR8Path)
|
const charR8Bytes = readFileSync(charR8Path)
|
||||||
if (charR8Bytes.byteLength !== charMeta.width * charMeta.height) {
|
if (charR8Bytes.byteLength !== charMeta.width * charMeta.height) {
|
||||||
throw new Error(`Global char-so.r8 byte length mismatch: ${charR8Bytes.byteLength} vs ${charMeta.width * charMeta.height}`)
|
throw new Error(
|
||||||
|
`${dir}/${charSlug}.r8 byte length mismatch: ${charR8Bytes.byteLength} vs ${charMeta.width * charMeta.height}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
console.log(`✓ Global char-so: verified ${charMeta.width}x${charMeta.height} atlas with ${Object.keys(charMeta.clips!).length} clips, ${charMeta.groups.length} groups`)
|
}
|
||||||
|
}
|
||||||
|
console.log(`✓ Verified all 7 player class R8 atlases (${CHARACTER_SLUGS.join(', ')}) across all entity directories`)
|
||||||
|
|
||||||
// Global monsters
|
// 3. Verify all Global monsters (monster-*.json / .r8)
|
||||||
const globalFiles = readdirSync(globalDir)
|
const globalFiles = readdirSync(globalDir)
|
||||||
const monsterJsonFiles = globalFiles.filter(f => f.startsWith('monster-') && f.endsWith('.json'))
|
const monsterJsonFiles = globalFiles.filter(f => f.startsWith('monster-') && f.endsWith('.json'))
|
||||||
if (monsterJsonFiles.length < 50) {
|
if (monsterJsonFiles.length < 200) {
|
||||||
throw new Error(`Expected at least 50 global monster files, found: ${monsterJsonFiles.length}`)
|
throw new Error(`Expected at least 200 global monster files, found: ${monsterJsonFiles.length}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const f of monsterJsonFiles) {
|
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}`)
|
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) {
|
for (let act = 1; act <= 5; act += 1) {
|
||||||
const actDir = join(PACK_ROOT, `act${act}`, 'entities')
|
const actDir = join(PACK_ROOT, `act${act}`, 'entities')
|
||||||
if (!existsSync(actDir)) {
|
if (!existsSync(actDir)) {
|
||||||
console.warn(`Act ${act} entity dir not found: ${actDir} (skipped)`)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,19 +456,9 @@ export function runVerification(): void {
|
||||||
const actCharPng = join(actDir, 'char-so.png')
|
const actCharPng = join(actDir, 'char-so.png')
|
||||||
if (existsSync(actCharJson) && existsSync(actCharPng)) {
|
if (existsSync(actCharJson) && existsSync(actCharPng)) {
|
||||||
const meta: EntityMeta = JSON.parse(readFileSync(actCharJson, 'utf8'))
|
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`)
|
verifyFrameTuples(meta.groups, meta.width, meta.height, `Act ${act} char-so`)
|
||||||
|
verifyPngFile(actCharPng, `Act ${act} char-so.png`, meta.width, meta.height)
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const actFiles = readdirSync(actDir)
|
const actFiles = readdirSync(actDir)
|
||||||
|
|
@ -252,22 +474,12 @@ export function runVerification(): void {
|
||||||
const meta: EntityMeta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
const meta: EntityMeta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
||||||
verifyMonsterMeta(meta, `Act ${act} ${slug}`)
|
verifyMonsterMeta(meta, `Act ${act} ${slug}`)
|
||||||
verifyFrameTuples(meta.groups, meta.width, meta.height, `Act ${act} ${slug}`)
|
verifyFrameTuples(meta.groups, meta.width, meta.height, `Act ${act} ${slug}`)
|
||||||
|
verifyPngFile(pngPath, `Act ${act} ${slug}.png`, meta.width, meta.height)
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
console.log(`✓ Act ${act}: verified char-so + ${actMonJsonFiles.length} monster PNG atlases`)
|
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]}`) {
|
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') {
|
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)
|
runSubscript(TARGET_SCRIPTS[key]!, restArgs, rootDir)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,12 @@ export function generateAssetManifest(options?: GenerateAssetManifestOptions): A
|
||||||
|
|
||||||
if (outputFile) {
|
if (outputFile) {
|
||||||
mkdirSync(dirname(outputFile), { recursive: true })
|
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
|
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]))),
|
tokenToRows: Object.fromEntries([...tokenJoin].sort((a, b) => a[0].localeCompare(b[0]))),
|
||||||
}
|
}
|
||||||
|
|
||||||
const animDir = join(outDir, 'anim')
|
const animJsonStr = JSON.stringify(animJson)
|
||||||
await mkdir(animDir, { recursive: true })
|
const monstatsJsonStr = JSON.stringify(monstatsJson)
|
||||||
const animPath = join(animDir, 'animdata.json')
|
const targetDirs = new Set<string>([
|
||||||
const monstatsPath = join(animDir, 'monstats2.json')
|
join(outDir, 'anim'),
|
||||||
await writeFile(animPath, JSON.stringify(animJson))
|
join(process.cwd(), 'public', 'assets', 'anim'),
|
||||||
await writeFile(monstatsPath, JSON.stringify(monstatsJson))
|
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 ${animPath} (${String(Object.keys(clips).length)} clips)`)
|
||||||
console.log(`wrote ${monstatsPath} (${String(rows.size)} rows)`)
|
console.log(`wrote ${monstatsPath} (${String(rows.size)} rows)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.url === `file://${process.argv[1] ?? ''}`) {
|
function parseAnimDataCliArgs(argv: readonly string[]): { archiveDir: string; outDir: string } {
|
||||||
const [, , archiveDir, outDir] = process.argv
|
let archiveDir = 'samples/d2'
|
||||||
await bakeAnimData(archiveDir ?? 'samples/d2', outDir ?? 'samples/d2-packs')
|
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
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
if (process.argv.includes('--write') || !fs.existsSync(outPath)) {
|
||||||
fs.writeFileSync(outPath, fileContent, 'utf8')
|
fs.writeFileSync(outPath, fileContent, 'utf8')
|
||||||
console.log(
|
console.log(
|
||||||
`Wrote ${outPath}: ${Object.keys(engOut).length} ENG keys, ${Object.keys(chiOut).length} CHI keys, ${Object.keys(iscMeta).length} ISC entries.`,
|
`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) => {
|
main().catch((err) => {
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,18 @@
|
||||||
* - dz: Druid (德鲁伊)
|
* - dz: Druid (德鲁伊)
|
||||||
* - ai: Assassin (刺客)
|
* - ai: Assassin (刺客)
|
||||||
*/
|
*/
|
||||||
import { mkdir, writeFile } from 'node:fs/promises'
|
import { mkdir, writeFile, copyFile } from 'node:fs/promises'
|
||||||
import { existsSync, readFileSync } from 'node:fs'
|
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 { MpqArchive } from '../../common/mpq/archive.ts'
|
||||||
import { fileSource } from '../io/file-source.ts'
|
import { fileSource } from '../io/file-source.ts'
|
||||||
import { MountedArchives } from '../../common/mpq/mount.ts'
|
import { MountedArchives } from '../../common/mpq/mount.ts'
|
||||||
import { decodePl2 } from '../../common/formats/pl2.ts'
|
import { decodePl2 } from '../../common/formats/pl2.ts'
|
||||||
import { loadCharacterSheet, type CharacterSheet } from '../../common/units/character.ts'
|
import { loadCharacterSheet, type CharacterSheet } from '../../common/units/character.ts'
|
||||||
import type { SpriteSheet } from '../../common/formats/sprite.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 { 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 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
|
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')
|
const animDataJsonPath = join(outDir, 'anim', 'animdata.json')
|
||||||
if (!existsSync(animDataJsonPath)) {
|
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 animData = JSON.parse(readFileSync(animDataJsonPath, 'utf8'))
|
||||||
|
|
||||||
const globalEntityDir = join(outDir, 'entities')
|
const globalEntityDir = join(outDir, 'entities')
|
||||||
|
const globalCharactersDir = join(outDir, 'characters')
|
||||||
await mkdir(globalEntityDir, { recursive: true })
|
await mkdir(globalEntityDir, { recursive: true })
|
||||||
|
await mkdir(globalCharactersDir, { recursive: true })
|
||||||
|
|
||||||
// Read palettes for Act 1..5
|
// Read palettes for Act 1..5
|
||||||
const actPalettes: { act: number; palette: Uint8Array; dir: string }[] = []
|
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) {
|
for (const spec of HERO_SPECS) {
|
||||||
const { token, nameZh, nameEn } = spec
|
const { token, code, nameZh, nameEn } = spec
|
||||||
const charT0 = performance.now()
|
const charT0 = performance.now()
|
||||||
console.log(`Baking character: ${nameZh} (${nameEn}, token: ${token})...`)
|
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 packedCharR8 = packSheetToR8(charJoined)
|
||||||
|
const r8File = `char-${token}.r8`
|
||||||
// Save global R8 atlas
|
const jsonFile = `char-${token}.json`
|
||||||
await writeFile(join(globalEntityDir, `char-${token}.r8`), packedCharR8.r8)
|
const metaJson = JSON.stringify({
|
||||||
await writeFile(
|
|
||||||
join(globalEntityDir, `char-${token}.json`),
|
|
||||||
JSON.stringify({
|
|
||||||
schema: 2,
|
schema: 2,
|
||||||
token,
|
token,
|
||||||
file: `char-${token}.r8`,
|
file: r8File,
|
||||||
format: 'r8',
|
format: 'r8',
|
||||||
width: packedCharR8.width,
|
width: packedCharR8.width,
|
||||||
height: packedCharR8.height,
|
height: packedCharR8.height,
|
||||||
directions: charClips.wl?.directions ?? 16,
|
directions: charClips.wl?.directions ?? 16,
|
||||||
walk: charClips.wl?.frames ?? 8,
|
walk: charClips.wl?.group ?? 0,
|
||||||
stand: charClips.nu?.frames ?? 8,
|
stand: charClips.nu?.group ?? 16,
|
||||||
walkOffset: 0,
|
walkOffset: charClips.wl?.group ?? 0,
|
||||||
standOffset: charClips.nu?.group ?? 16,
|
standOffset: charClips.nu?.group ?? 16,
|
||||||
|
walkFrames: charClips.wl?.frames ?? 8,
|
||||||
|
standFrames: charClips.nu?.frames ?? 8,
|
||||||
members: totalCharMembers,
|
members: totalCharMembers,
|
||||||
clips: charClips,
|
clips: charClips,
|
||||||
|
modes: charClips,
|
||||||
groups: packedCharR8.groups,
|
groups: packedCharR8.groups,
|
||||||
}, null, 2)
|
}, 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
|
// Save per-Act PNGs
|
||||||
for (const { palette, dir } of actPalettes) {
|
for (const { palette, dir } of actPalettes) {
|
||||||
|
|
@ -155,7 +191,7 @@ export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'sam
|
||||||
const charPngName = `char-${token}.png`
|
const charPngName = `char-${token}.png`
|
||||||
await writeFile(join(dir, charPngName), charPng)
|
await writeFile(join(dir, charPngName), charPng)
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(dir, `char-${token}.json`),
|
join(dir, jsonFile),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
schema: 2,
|
schema: 2,
|
||||||
token,
|
token,
|
||||||
|
|
@ -164,6 +200,7 @@ export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'sam
|
||||||
height: packedCharR8.height,
|
height: packedCharR8.height,
|
||||||
directions: charClips.wl?.directions ?? 16,
|
directions: charClips.wl?.directions ?? 16,
|
||||||
clips: charClips,
|
clips: charClips,
|
||||||
|
modes: charClips,
|
||||||
walk: charClips.wl?.group ?? 0,
|
walk: charClips.wl?.group ?? 0,
|
||||||
stand: charClips.nu?.group ?? 16,
|
stand: charClips.nu?.group ?? 16,
|
||||||
members: totalCharMembers,
|
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})`)
|
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!`)
|
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]}`) {
|
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)
|
console.error('Failed to bake characters:', err)
|
||||||
process.exit(1)
|
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))
|
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(
|
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!`,
|
`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)
|
console.error('pack-frontend-ui failed:', err)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -871,9 +871,28 @@ ${mapEntries}
|
||||||
})
|
})
|
||||||
`
|
`
|
||||||
|
|
||||||
|
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')
|
const outTsPath = join(projectRoot, 'src', 'client', 'render', 'missiles-meta.ts')
|
||||||
writeFileSync(outTsPath, metaTsContent, 'utf-8')
|
writeFileSync(outTsPath, metaTsContent, 'utf-8')
|
||||||
console.log(`[Missile Packer] Wrote ${outTsPath}`)
|
console.log(`[Missile Packer] Wrote ${outTsPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
return metaRecord
|
return metaRecord
|
||||||
}
|
}
|
||||||
|
|
@ -890,3 +909,4 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2024,7 +2024,79 @@ export const OVERLAY_METAS: Readonly<Record<string, OverlayMeta>> = {
|
||||||
${overlayMetasEntries.join('\n')}
|
${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')
|
const metaTsPath = join(projectRoot, 'src', 'client', 'render', 'overlays-meta.ts')
|
||||||
writeFileSync(metaTsPath, metaTsContent, 'utf-8')
|
writeFileSync(metaTsPath, metaTsContent, 'utf-8')
|
||||||
console.log(`[Overlay Packer] Wrote ${metaTsPath}`)
|
console.log(`[Overlay Packer] Wrote ${metaTsPath}`)
|
||||||
|
|
@ -2038,7 +2110,6 @@ ${overlayMetasEntries.join('\n')}
|
||||||
console.log(`[Overlay Packer] Wrote sandboxed ${sandboxMetaPath}`)
|
console.log(`[Overlay Packer] Wrote sandboxed ${sandboxMetaPath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return metaRecord
|
return metaRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2049,3 +2120,4 @@ if (process.argv[1]?.endsWith('pack-overlays.ts')) {
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,17 @@ async function main() {
|
||||||
totalIcons++
|
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!`)
|
console.log(`Extracted ${totalIcons} skill icons!`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,3 +204,4 @@ main().catch(err => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -568,12 +568,52 @@ export async function runPackTiles(
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeFile(join(outDir, 'index.json'), JSON.stringify(catalogIndex, null, 2))
|
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
|
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
|
const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||||
if (isMain) {
|
if (isMain) {
|
||||||
const [archiveArg, outArg] = process.argv.slice(2)
|
const { archiveArg, outArg } = parseTilesCliArgs(process.argv.slice(2))
|
||||||
const t0 = performance.now()
|
const t0 = performance.now()
|
||||||
const result = await runPackTiles(archiveArg, outArg)
|
const result = await runPackTiles(archiveArg, outArg)
|
||||||
const elapsedSec = ((performance.now() - t0) / 1000).toFixed(2)
|
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)}`,
|
`in ${elapsedSec}s — missingFiles=${String(result.missingFiles.length)}, invisWalFloorVerified=${String(result.invisWalFloorVerified)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ export interface SpriteRect {
|
||||||
readonly y: number
|
readonly y: number
|
||||||
readonly w: number
|
readonly w: number
|
||||||
readonly h: number
|
readonly h: number
|
||||||
|
readonly offsetX?: number | undefined
|
||||||
|
readonly offsetY?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AutomapSpriteKey =
|
export type AutomapSpriteKey =
|
||||||
|
|
@ -135,12 +137,15 @@ function transformSpriteFrame(frame: SpriteFrame, table: Uint8Array): SpriteFram
|
||||||
indices[i] = 0
|
indices[i] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const dc6Frame = frame as SpriteFrame & { readonly offsetX?: number; readonly offsetY?: number }
|
||||||
return {
|
return {
|
||||||
width: frame.width,
|
width: frame.width,
|
||||||
height: frame.height,
|
height: frame.height,
|
||||||
indices,
|
indices,
|
||||||
mask: frame.mask,
|
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...')
|
console.log('Mounting Diablo II v1.13c MPQ archives...')
|
||||||
const archives = new MountedArchives()
|
const archives = new MountedArchives()
|
||||||
for (const m of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
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 pl2 = decodePl2(await archives.read('data/global/palette/ACT1/pal.pl2'))
|
||||||
const outDir = join(process.cwd(), 'public', 'ui')
|
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')
|
const fontsDir = join(outDir, 'fonts')
|
||||||
mkdirSync(outDir, { recursive: true })
|
for (const d of targetUiDirs) {
|
||||||
mkdirSync(fontsDir, { recursive: true })
|
mkdirSync(d, { recursive: true })
|
||||||
|
mkdirSync(join(d, 'fonts'), { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
let decodedCount = 0
|
let decodedCount = 0
|
||||||
let decodeFailures = 0
|
let decodeFailures = 0
|
||||||
|
|
@ -362,7 +372,9 @@ async function main(): Promise<void> {
|
||||||
palette: pl2.rgb,
|
palette: pl2.rgb,
|
||||||
transparentIndex: transparent ? 0 : undefined,
|
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 ---
|
// --- 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 ---
|
// --- STAGE 2: Comprehensive Items, Flippy Ground Sprites & Unique Items Packing ---
|
||||||
console.log('Extracting item sprites & flippy ground sprites (including uniqueitems.txt & setitems.txt)...')
|
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 baseUniqueInvFile: Record<string, string> = {}
|
||||||
const baseSetInvFile: Record<string, string> = {}
|
const baseSetInvFile: Record<string, string> = {}
|
||||||
const baseFlippyFile: 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
|
// A. Base Items from weapons.txt, armor.txt, misc.txt
|
||||||
for (const t of ['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}`)))
|
const tbl = parseTable(new TextDecoder().decode(await archives.read(`data/global/excel/${t}`)))
|
||||||
for (const r of tbl.rows) {
|
for (const r of tbl.rows) {
|
||||||
const code = r['code']?.trim()
|
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()
|
const inv = r['invfile']?.trim().toLowerCase()
|
||||||
if (code && inv) {
|
if (code && inv) {
|
||||||
baseInvFile[code] = inv
|
baseInvFile[code] = inv
|
||||||
|
|
@ -1019,31 +1040,49 @@ async function main(): Promise<void> {
|
||||||
codeToFlippyFile[flp] = flp
|
codeToFlippyFile[flp] = flp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pack items-atlas.png (ATLAS_W = 1024)
|
// Pack items-atlas.png (ATLAS_W = 2048)
|
||||||
const sortedInvNames = [...uniqueInvFiles].sort()
|
const sortedInvNames = [...uniqueInvFiles].sort()
|
||||||
const ATLAS_W = 1024
|
const ATLAS_W = 2048
|
||||||
let curX = 0
|
let curX = 0
|
||||||
let curY = 0
|
let curY = 0
|
||||||
let rowH = 0
|
let rowH = 0
|
||||||
const itemRects: Record<string, SpriteRect> = {}
|
const itemRects: Record<string, SpriteRect> = {}
|
||||||
const flippyRects: Record<string, SpriteRect> = {}
|
const flippyRects: Record<string, SpriteRect> = {}
|
||||||
|
const flippyFrames: Record<string, SpriteRect[]> = {}
|
||||||
const itemPlacements: BlitPlacement[] = []
|
const itemPlacements: BlitPlacement[] = []
|
||||||
const decodedBaseFrames = new Map<string, SpriteFrame>()
|
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) {
|
for (const invName of sortedInvNames) {
|
||||||
const dc6 = await decodeUiDc6(`data/global/items/${invName}.dc6`)
|
const dc6 = await decodeUiDc6(`data/global/items/${invName}.dc6`)
|
||||||
const frame = dc6.groups[0]?.frames[0]
|
const frame = dc6.groups[0]?.frames[0]
|
||||||
if (!frame) continue
|
if (!frame) continue
|
||||||
decodedBaseFrames.set(invName, frame)
|
decodedBaseFrames.set(invName, frame)
|
||||||
if (curX + frame.width > ATLAS_W) {
|
itemRects[invName] = placeFrameInAtlas(frame, false)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-bake and stitch transformed item frames (cgrn, dpur, lgry, blac, whit, etc.)
|
// 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 table = getTransformTable(t.colorCode)
|
||||||
const transformedFrame = transformSpriteFrame(baseFrame, table)
|
const transformedFrame = transformSpriteFrame(baseFrame, table)
|
||||||
if (curX + transformedFrame.width > ATLAS_W) {
|
itemRects[t.transformedName] = placeFrameInAtlas(transformedFrame, false)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- STAGE 2.5: Authentic Flippy Ground Item Sprites (213 DC6 files) ---
|
// --- STAGE 2.5: Authentic Flippy Ground Item Sprites (all animation frames 0..N-1) ---
|
||||||
console.log('Packing authentic flippy ground item sprites...')
|
console.log('Packing authentic flippy ground item sprites (all animation frames)...')
|
||||||
const sortedFlippyNames = [...uniqueFlippyFiles].sort()
|
const sortedFlippyNames = [...uniqueFlippyFiles].sort()
|
||||||
const decodedFlippyBaseFrames = new Map<string, SpriteFrame>()
|
const decodedFlippyBaseFrames = new Map<string, readonly SpriteFrame[]>()
|
||||||
|
|
||||||
for (const flpName of sortedFlippyNames) {
|
for (const flpName of sortedFlippyNames) {
|
||||||
const dc6 = await decodeUiDc6(`data/global/items/${flpName}.dc6`)
|
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
|
// 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++) {
|
for (let g = 0; g < dc6.groups.length; g++) {
|
||||||
const groupFrames = dc6.groups[g]?.frames
|
const groupFrames = dc6.groups[g]?.frames
|
||||||
const lastFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined
|
if (!groupFrames || groupFrames.length === 0) continue
|
||||||
if (!lastFrame) continue
|
|
||||||
if (curX + lastFrame.width > ATLAS_W) {
|
|
||||||
curX = 0
|
|
||||||
curY += rowH + 2
|
|
||||||
rowH = 0
|
|
||||||
}
|
|
||||||
const key = `flpgld_${g}`
|
const key = `flpgld_${g}`
|
||||||
const rect: SpriteRect = { x: curX, y: curY, w: lastFrame.width, h: lastFrame.height }
|
const rectsForGroup: SpriteRect[] = []
|
||||||
flippyRects[key] = rect
|
for (const f of groupFrames) {
|
||||||
itemRects[key] = rect
|
rectsForGroup.push(placeFrameInAtlas(f, true))
|
||||||
itemPlacements.push({ frame: lastFrame, x: curX, y: curY })
|
|
||||||
curX += lastFrame.width + 2
|
|
||||||
if (lastFrame.height > rowH) rowH = lastFrame.height
|
|
||||||
}
|
}
|
||||||
if (flippyRects['flpgld_0']) {
|
const lastRect = rectsForGroup[rectsForGroup.length - 1]!
|
||||||
|
flippyFrames[key] = rectsForGroup
|
||||||
|
flippyRects[key] = lastRect
|
||||||
|
itemRects[key] = lastRect
|
||||||
|
}
|
||||||
|
if (flippyRects['flpgld_0'] && flippyFrames['flpgld_0']) {
|
||||||
|
flippyFrames['flpgld'] = flippyFrames['flpgld_0']
|
||||||
flippyRects['flpgld'] = flippyRects['flpgld_0']
|
flippyRects['flpgld'] = flippyRects['flpgld_0']
|
||||||
itemRects['flpgld'] = flippyRects['flpgld_0']
|
itemRects['flpgld'] = flippyRects['flpgld_0']
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const groupFrames = dc6.groups[0]?.frames
|
const groupFrames = dc6.groups[0]?.frames
|
||||||
const lastFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined
|
if (!groupFrames || groupFrames.length === 0) continue
|
||||||
if (!lastFrame) continue
|
decodedFlippyBaseFrames.set(flpName, groupFrames)
|
||||||
decodedFlippyBaseFrames.set(flpName, lastFrame)
|
const rectsForFlippy: SpriteRect[] = []
|
||||||
if (curX + lastFrame.width > ATLAS_W) {
|
for (const f of groupFrames) {
|
||||||
curX = 0
|
rectsForFlippy.push(placeFrameInAtlas(f, true))
|
||||||
curY += rowH + 2
|
|
||||||
rowH = 0
|
|
||||||
}
|
}
|
||||||
const rect: SpriteRect = { x: curX, y: curY, w: lastFrame.width, h: lastFrame.height }
|
const lastRect = rectsForFlippy[rectsForFlippy.length - 1]!
|
||||||
flippyRects[flpName] = rect
|
flippyFrames[flpName] = rectsForFlippy
|
||||||
itemRects[flpName] = rect
|
flippyRects[flpName] = lastRect
|
||||||
itemPlacements.push({ frame: lastFrame, x: curX, y: curY })
|
itemRects[flpName] = lastRect
|
||||||
curX += lastFrame.width + 2
|
|
||||||
if (lastFrame.height > rowH) rowH = lastFrame.height
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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()) {
|
for (const t of transformedFlippyMap.values()) {
|
||||||
let baseFrame = decodedFlippyBaseFrames.get(t.baseFlp)
|
let baseFrames = decodedFlippyBaseFrames.get(t.baseFlp)
|
||||||
if (!baseFrame) {
|
if (!baseFrames) {
|
||||||
if (archives.has(`data/global/items/${t.baseFlp}.dc6`)) {
|
if (archives.has(`data/global/items/${t.baseFlp}.dc6`)) {
|
||||||
const dc6 = await decodeUiDc6(`data/global/items/${t.baseFlp}.dc6`)
|
const dc6 = await decodeUiDc6(`data/global/items/${t.baseFlp}.dc6`)
|
||||||
const groupFrames = dc6.groups[0]?.frames
|
baseFrames = dc6.groups[0]?.frames
|
||||||
baseFrame = groupFrames ? groupFrames[groupFrames.length - 1] : undefined
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!baseFrame) continue
|
if (!baseFrames || baseFrames.length === 0) continue
|
||||||
const table = getTransformTable(t.colorCode)
|
const table = getTransformTable(t.colorCode)
|
||||||
const transformedFrame = transformSpriteFrame(baseFrame, table)
|
const rectsForTransformed: SpriteRect[] = []
|
||||||
if (curX + transformedFrame.width > ATLAS_W) {
|
for (const bf of baseFrames) {
|
||||||
curX = 0
|
const transformedFrame = transformSpriteFrame(bf, table)
|
||||||
curY += rowH + 2
|
rectsForTransformed.push(placeFrameInAtlas(transformedFrame, true))
|
||||||
rowH = 0
|
}
|
||||||
|
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)
|
const atlasH = Math.max(1, curY + rowH)
|
||||||
|
|
@ -1390,8 +1425,11 @@ async function main(): Promise<void> {
|
||||||
transparentIndex: 0,
|
transparentIndex: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
writeFileSync(join(outDir, spec.uiPngName), pngBytes)
|
for (const d of targetUiDirs) {
|
||||||
writeFileSync(join(automapOutDir, `${spec.key}.png`), pngBytes)
|
writeFileSync(join(d, spec.uiPngName), pngBytes)
|
||||||
|
mkdirSync(join(d, 'automap'), { recursive: true })
|
||||||
|
writeFileSync(join(d, 'automap', `${spec.key}.png`), pngBytes)
|
||||||
|
}
|
||||||
|
|
||||||
automapAtlases[spec.key] = {
|
automapAtlases[spec.key] = {
|
||||||
image: `/assets/ui/automap/${spec.key}.png`,
|
image: `/assets/ui/automap/${spec.key}.png`,
|
||||||
|
|
@ -1408,9 +1446,12 @@ async function main(): Promise<void> {
|
||||||
const automapManifest: AutomapUiManifest = {
|
const automapManifest: AutomapUiManifest = {
|
||||||
atlases: automapAtlases,
|
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> = {
|
const imagesMap: Record<string, string> = {
|
||||||
ctrlPnl: '/ui/ctrlpnl-800.png',
|
ctrlPnl: '/ui/ctrlpnl-800.png',
|
||||||
globeLife: '/ui/globe-life.png',
|
globeLife: '/ui/globe-life.png',
|
||||||
|
|
@ -1474,13 +1515,19 @@ async function main(): Promise<void> {
|
||||||
codeToFlippyFile,
|
codeToFlippyFile,
|
||||||
itemRects,
|
itemRects,
|
||||||
flippyRects,
|
flippyRects,
|
||||||
|
flippyFrames,
|
||||||
|
itemClassIdToCode,
|
||||||
questRects,
|
questRects,
|
||||||
images: imagesMap,
|
images: imagesMap,
|
||||||
automapAtlases,
|
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 = `/**
|
const bakedMetaCode = `/**
|
||||||
* Auto-generated baked UI metadata for Diablo II Web client.
|
* Auto-generated baked UI metadata for Diablo II Web client.
|
||||||
* Generated by \`scripts/pack-ui.ts\`.
|
* Generated by \`scripts/pack-ui.ts\`.
|
||||||
|
|
@ -1492,6 +1539,8 @@ export interface SpriteRect {
|
||||||
readonly y: number
|
readonly y: number
|
||||||
readonly w: number
|
readonly w: number
|
||||||
readonly h: number
|
readonly h: number
|
||||||
|
readonly offsetX?: number | undefined
|
||||||
|
readonly offsetY?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AutomapSpriteKey =
|
export type AutomapSpriteKey =
|
||||||
|
|
@ -1546,6 +1595,8 @@ export interface BakedUiManifest {
|
||||||
readonly codeToFlippyFile: Record<string, string>
|
readonly codeToFlippyFile: Record<string, string>
|
||||||
readonly itemRects: Record<string, SpriteRect>
|
readonly itemRects: Record<string, SpriteRect>
|
||||||
readonly flippyRects: 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 questRects: Record<string, SpriteRect>
|
||||||
readonly images: Record<string, string>
|
readonly images: Record<string, string>
|
||||||
readonly automapAtlases?: Readonly<Record<AutomapSpriteKey, AutomapSpriteAtlasMeta>>
|
readonly automapAtlases?: Readonly<Record<AutomapSpriteKey, AutomapSpriteAtlasMeta>>
|
||||||
|
|
@ -1613,6 +1664,8 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
||||||
codeToFlippyFile: ${JSON.stringify(codeToFlippyFile, null, 2)},
|
codeToFlippyFile: ${JSON.stringify(codeToFlippyFile, null, 2)},
|
||||||
itemRects: ${JSON.stringify(itemRects, null, 2)},
|
itemRects: ${JSON.stringify(itemRects, null, 2)},
|
||||||
flippyRects: ${JSON.stringify(flippyRects, 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)},
|
questRects: ${JSON.stringify(questRects, null, 2)},
|
||||||
images: ${JSON.stringify(imagesMap, null, 2)},
|
images: ${JSON.stringify(imagesMap, null, 2)},
|
||||||
automapAtlases: AUTOMAP_UI_MANIFEST.atlases,
|
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')
|
writeFileSync(join(process.cwd(), 'src', 'common', 'items', 'baked-ui-meta.ts'), bakedMetaCode, 'utf8')
|
||||||
console.log(`Successfully decoded ${decodedCount} DC6 sheets (${decodeFailures} failures)!`)
|
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) => {
|
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)
|
console.error('pack-ui failed:', err)
|
||||||
process.exit(1)
|
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 {
|
export interface ClipMeta {
|
||||||
readonly group: number
|
readonly group: number
|
||||||
readonly directions?: number
|
readonly directions: number
|
||||||
readonly frames: number
|
readonly frames: number
|
||||||
readonly framesPerDir?: number
|
readonly framesPerDir?: number
|
||||||
readonly speed: number
|
readonly speed: number
|
||||||
|
|
@ -173,6 +173,7 @@ export interface EntityMeta {
|
||||||
readonly frameDurationMs?: number
|
readonly frameDurationMs?: number
|
||||||
readonly animSpeed?: number
|
readonly animSpeed?: number
|
||||||
readonly clips?: Record<string, ClipMeta>
|
readonly clips?: Record<string, ClipMeta>
|
||||||
|
readonly modes?: Record<string, ClipMeta>
|
||||||
readonly glowClips?: Record<string, ClipMeta>
|
readonly glowClips?: Record<string, ClipMeta>
|
||||||
readonly walk?: number
|
readonly walk?: number
|
||||||
readonly stand?: number
|
readonly stand?: number
|
||||||
|
|
@ -188,6 +189,109 @@ export interface EntityMeta {
|
||||||
export type PackedMonsterJson = EntityMeta
|
export type PackedMonsterJson = EntityMeta
|
||||||
export type PackedCharJson = 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 {
|
export interface PackedTilePlacement {
|
||||||
readonly act: number
|
readonly act: number
|
||||||
readonly page: number
|
readonly page: number
|
||||||
|
|
@ -312,6 +416,8 @@ export interface SpriteRect {
|
||||||
readonly y: number
|
readonly y: number
|
||||||
readonly w: number
|
readonly w: number
|
||||||
readonly h: number
|
readonly h: number
|
||||||
|
readonly offsetX?: number | undefined
|
||||||
|
readonly offsetY?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AutomapSpriteKey =
|
export type AutomapSpriteKey =
|
||||||
|
|
@ -366,6 +472,8 @@ export interface BakedUiManifest {
|
||||||
readonly codeToFlippyFile: Record<string, string>
|
readonly codeToFlippyFile: Record<string, string>
|
||||||
readonly itemRects: Record<string, SpriteRect>
|
readonly itemRects: Record<string, SpriteRect>
|
||||||
readonly flippyRects: 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 questRects: Record<string, SpriteRect>
|
||||||
readonly images: Record<string, string>
|
readonly images: Record<string, string>
|
||||||
readonly automapAtlases?: Readonly<Record<AutomapSpriteKey, AutomapSpriteAtlasMeta>>
|
readonly automapAtlases?: Readonly<Record<AutomapSpriteKey, AutomapSpriteAtlasMeta>>
|
||||||
|
|
@ -532,6 +640,9 @@ const CACHEABLE_PREFIXES = [
|
||||||
'skills/',
|
'skills/',
|
||||||
'ui/',
|
'ui/',
|
||||||
'assets/',
|
'assets/',
|
||||||
|
'entities/',
|
||||||
|
'characters/',
|
||||||
|
'tiles/',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const CACHEABLE_EXT_RE =
|
const CACHEABLE_EXT_RE =
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@
|
||||||
import type { MountedArchives } from '../mpq/mount.ts'
|
import type { MountedArchives } from '../mpq/mount.ts'
|
||||||
import { decodeCof, cofLayerOrder } from '../formats/cof.ts'
|
import { decodeCof, cofLayerOrder } from '../formats/cof.ts'
|
||||||
import type { CofFile } 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 { 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 type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
||||||
import { dir64ToDcc } from './character.ts'
|
import { dir64ToDcc } from './character.ts'
|
||||||
|
|
||||||
|
|
@ -15,6 +17,21 @@ export const MONSTER_COMPONENTS: readonly string[] = [
|
||||||
's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8'
|
'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.
|
* 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',
|
'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(
|
export function findMonsterLayerSprite(
|
||||||
names: readonly string[],
|
names: readonly string[],
|
||||||
|
|
@ -38,9 +95,10 @@ export function findMonsterLayerSprite(
|
||||||
const weapons = Array.from(new Set([layerWeaponClass, weapon, 'hth'].filter((w): w is string => Boolean(w))))
|
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()]
|
const anims = animation.toLowerCase() === 'wl' ? ['wl', 'nu'] : [animation.toLowerCase()]
|
||||||
|
|
||||||
|
for (const ext of ['.dcc', '.dc6']) {
|
||||||
for (const anim of anims) {
|
for (const anim of anims) {
|
||||||
for (const w of weapons) {
|
for (const w of weapons) {
|
||||||
const suffix = `${anim}${w.toLowerCase()}.dcc`
|
const suffix = `${anim}${w.toLowerCase()}${ext}`
|
||||||
const hits = names.filter(name => {
|
const hits = names.filter(name => {
|
||||||
const lowerName = name.toLowerCase()
|
const lowerName = name.toLowerCase()
|
||||||
return lowerName.startsWith(prefix) && lowerName.endsWith(suffix)
|
return lowerName.startsWith(prefix) && lowerName.endsWith(suffix)
|
||||||
|
|
@ -51,6 +109,7 @@ export function findMonsterLayerSprite(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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 {
|
export interface MonsterLayerFailure {
|
||||||
readonly token: string
|
readonly token: string
|
||||||
readonly animation: string
|
readonly animation: string
|
||||||
|
|
@ -107,10 +273,11 @@ export async function compositeMonsterAnimation(
|
||||||
animation: string,
|
animation: string,
|
||||||
weapon: string = 'hth',
|
weapon: string = 'hth',
|
||||||
): Promise<MonsterAnimationSheet> {
|
): Promise<MonsterAnimationSheet> {
|
||||||
const lower = token.toLowerCase()
|
const lower = token.trim().toLowerCase()
|
||||||
const root = `data\\global\\monsters\\${lower}\\`
|
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 usedAnim = animation.toLowerCase()
|
||||||
let usedWeapon = weapon.toLowerCase()
|
let usedWeapon = weapon.toLowerCase()
|
||||||
const candidates = [
|
const candidates = [
|
||||||
|
|
@ -126,20 +293,59 @@ export async function compositeMonsterAnimation(
|
||||||
|
|
||||||
let matchedCof: string | undefined
|
let matchedCof: string | undefined
|
||||||
for (const cand of candidates) {
|
for (const cand of candidates) {
|
||||||
matchedCof = names.find(n => n.toLowerCase() === cand.toLowerCase())
|
matchedCof = fileIndex.byLower.get(cand.toLowerCase())
|
||||||
if (matchedCof) {
|
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'
|
if (cand.endsWith('hth.cof')) usedWeapon = 'hth'
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!matchedCof) {
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cof: CofFile
|
||||||
|
if (matchedCof) {
|
||||||
const cofBytes = await archives.read(matchedCof)
|
const cofBytes = await archives.read(matchedCof)
|
||||||
const cof: CofFile = decodeCof(cofBytes)
|
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 layerResults = await Promise.all(cof.layers.map(async (layer) => {
|
||||||
const component = MONSTER_COMPONENTS[layer.type]
|
const component = MONSTER_COMPONENTS[layer.type]
|
||||||
|
|
@ -151,7 +357,7 @@ export async function compositeMonsterAnimation(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const member = findMonsterLayerSprite(
|
const member = findMonsterLayerSprite(
|
||||||
names,
|
tokenFiles,
|
||||||
root,
|
root,
|
||||||
lower,
|
lower,
|
||||||
usedAnim,
|
usedAnim,
|
||||||
|
|
@ -168,7 +374,11 @@ export async function compositeMonsterAnimation(
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const bytes = await archives.read(member)
|
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) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
sprite: null,
|
sprite: null,
|
||||||
|
|
@ -188,14 +398,124 @@ export async function compositeMonsterAnimation(
|
||||||
const sprites: (DccFile | null)[] = layerResults.map(r => r.sprite)
|
const sprites: (DccFile | null)[] = layerResults.map(r => r.sprite)
|
||||||
const decodedMembers: string[] = layerResults.flatMap(r => r.member ? [r.member] : [])
|
const decodedMembers: string[] = layerResults.flatMap(r => r.member ? [r.member] : [])
|
||||||
const layerFailures: MonsterLayerFailure[] = layerResults.flatMap(r => r.failure ? [r.failure] : [])
|
const layerFailures: MonsterLayerFailure[] = layerResults.flatMap(r => r.failure ? [r.failure] : [])
|
||||||
for (const failure of layerFailures) {
|
|
||||||
console.error(
|
return compositeLoadedLayers(cof, sprites, decodedMembers, layerFailures, 0, 0)
|
||||||
`monster art layer dropped: ${failure.token}/${failure.component ?? '?'} (${failure.animation}) `
|
|
||||||
+ `— ${failure.reason}${failure.member !== null ? ` [${failure.member}]` : ''}`
|
|
||||||
+ `${failure.message !== undefined ? `: ${failure.message}` : ''}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 isAdditiveLayer = (layerIdx: number): boolean => {
|
||||||
const layer = cof.layers[layerIdx]
|
const layer = cof.layers[layerIdx]
|
||||||
return layer !== undefined && (layer.drawEffect === 3 || layer.transparent)
|
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) {
|
for (let layerIdx = 0; layerIdx < cof.layers.length; layerIdx += 1) {
|
||||||
const dcc = sprites[layerIdx]
|
const dcc = sprites[layerIdx]
|
||||||
if (!dcc) continue
|
if (!dcc || dcc.directions.length === 0) continue
|
||||||
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
|
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
|
||||||
const dirData = dcc.directions[dccDir]
|
const dirData = dcc.directions[dccDir] ?? dcc.directions[0]
|
||||||
if (!dirData) continue
|
if (!dirData || dirData.frames.length === 0) continue
|
||||||
const frameData = dirData.frames[f]
|
const frameData = dirData.frames[f] ?? dirData.frames[f % dirData.frames.length]
|
||||||
if (!frameData) continue
|
if (!frameData) continue
|
||||||
left = Math.min(left, dirData.box.left)
|
left = Math.min(left, dirData.box.left)
|
||||||
top = Math.min(top, dirData.box.top)
|
top = Math.min(top, dirData.box.top)
|
||||||
|
|
@ -242,8 +562,8 @@ export async function compositeMonsterAnimation(
|
||||||
height: 1,
|
height: 1,
|
||||||
indices: new Uint8Array(1),
|
indices: new Uint8Array(1),
|
||||||
mask: new Uint8Array(1),
|
mask: new Uint8Array(1),
|
||||||
anchorX: 0,
|
anchorX: anchorOffsetX,
|
||||||
anchorY: 0,
|
anchorY: anchorOffsetY,
|
||||||
}
|
}
|
||||||
baseFrames.push(emptyFrame)
|
baseFrames.push(emptyFrame)
|
||||||
if (isComposite) glowFrames.push(emptyFrame)
|
if (isComposite) glowFrames.push(emptyFrame)
|
||||||
|
|
@ -272,11 +592,11 @@ export async function compositeMonsterAnimation(
|
||||||
const order = cofLayerOrder(cof, dir, f)
|
const order = cofLayerOrder(cof, dir, f)
|
||||||
for (const layerIdx of order) {
|
for (const layerIdx of order) {
|
||||||
const dcc = sprites[layerIdx]
|
const dcc = sprites[layerIdx]
|
||||||
if (!dcc) continue
|
if (!dcc || dcc.directions.length === 0) continue
|
||||||
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
|
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
|
||||||
const dirData = dcc.directions[dccDir]
|
const dirData = dcc.directions[dccDir] ?? dcc.directions[0]
|
||||||
if (!dirData) continue
|
if (!dirData || dirData.frames.length === 0) continue
|
||||||
const frameData = dirData.frames[f]
|
const frameData = dirData.frames[f] ?? dirData.frames[f % dirData.frames.length]
|
||||||
if (!frameData) continue
|
if (!frameData) continue
|
||||||
const atX = Math.round(dirData.box.left) - boxLeft
|
const atX = Math.round(dirData.box.left) - boxLeft
|
||||||
const atY = Math.round(dirData.box.top) - boxTop
|
const atY = Math.round(dirData.box.top) - boxTop
|
||||||
|
|
@ -292,8 +612,8 @@ export async function compositeMonsterAnimation(
|
||||||
height: frameHeight,
|
height: frameHeight,
|
||||||
indices: baseTarget.indices,
|
indices: baseTarget.indices,
|
||||||
mask: baseTarget.mask,
|
mask: baseTarget.mask,
|
||||||
anchorX: boxLeft,
|
anchorX: anchorOffsetX + boxLeft,
|
||||||
anchorY: boxTop,
|
anchorY: anchorOffsetY + boxTop,
|
||||||
})
|
})
|
||||||
if (glowTarget !== null) {
|
if (glowTarget !== null) {
|
||||||
glowFrames.push({
|
glowFrames.push({
|
||||||
|
|
@ -301,8 +621,8 @@ export async function compositeMonsterAnimation(
|
||||||
height: frameHeight,
|
height: frameHeight,
|
||||||
indices: glowTarget.indices,
|
indices: glowTarget.indices,
|
||||||
mask: glowTarget.mask,
|
mask: glowTarget.mask,
|
||||||
anchorX: boxLeft,
|
anchorX: anchorOffsetX + boxLeft,
|
||||||
anchorY: boxTop,
|
anchorY: anchorOffsetY + boxTop,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -774,3 +774,752 @@ export function resolveMonsterArtSpec(id: string): MonsterArtSpec | null {
|
||||||
}
|
}
|
||||||
return 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 { cell, parseTable } from '../world/acts.ts'
|
||||||
import { cofLayerOrder, decodeCof } from '../formats/cof.ts'
|
import { cofLayerOrder, decodeCof } from '../formats/cof.ts'
|
||||||
import type { CofFile } from '../formats/cof.ts'
|
import type { CofFile } from '../formats/cof.ts'
|
||||||
|
import { decodeDc6 } from '../formats/dc6.ts'
|
||||||
import { decodeDcc } from '../formats/dcc.ts'
|
import { decodeDcc } from '../formats/dcc.ts'
|
||||||
import type { DccFile } from '../formats/dcc.ts'
|
import type { DccFile } from '../formats/dcc.ts'
|
||||||
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
||||||
import type { MountedArchives } from '../mpq/mount.ts'
|
import type { MountedArchives } from '../mpq/mount.ts'
|
||||||
import { OBJECT_TYPE_OBJECT, OBJECT_TYPE_MONSTER, lookupObject } from './object-lookup.ts'
|
import { OBJECT_TYPE_OBJECT, OBJECT_TYPE_MONSTER, lookupObject } from './object-lookup.ts'
|
||||||
import type { ObjectLookupEntry } 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. */
|
/** `Objects.txt` inside the archives. */
|
||||||
const OBJECTS_TABLE = 'data\\global\\excel\\objects.txt'
|
const OBJECTS_TABLE = 'data\\global\\excel\\objects.txt'
|
||||||
|
|
@ -327,6 +331,8 @@ export interface ObjectsRow {
|
||||||
readonly id: number
|
readonly id: number
|
||||||
/** `Name`, e.g. `Barrel`. */
|
/** `Name`, e.g. `Barrel`. */
|
||||||
readonly name: string
|
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. */
|
/** `Token`, upper-cased: the `<TOKEN>` in every path the engine builds. */
|
||||||
readonly token: string
|
readonly token: string
|
||||||
/** `SubClass` — the object class (`OBJSUBCLASS_DOOR` = 0x80, `CHEST` = 0x08, ...). */
|
/** `SubClass` — the object class (`OBJSUBCLASS_DOOR` = 0x80, `CHEST` = 0x08, ...). */
|
||||||
|
|
@ -341,6 +347,8 @@ export interface ObjectsRow {
|
||||||
readonly yOffset: number
|
readonly yOffset: number
|
||||||
/** `IsDoor` — doors take the operate path rather than the casket path. */
|
/** `IsDoor` — doors take the operate path rather than the casket path. */
|
||||||
readonly isDoor: boolean
|
readonly isDoor: boolean
|
||||||
|
/** `OperateRange` — interaction range in sub-tiles. */
|
||||||
|
readonly operateRange?: number | undefined
|
||||||
/** `Trans` — transparency mode. */
|
/** `Trans` — transparency mode. */
|
||||||
readonly trans: number
|
readonly trans: number
|
||||||
/** `BlendMode` — additive or normal blending mode. */
|
/** `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.
|
* @param row - the raw cells.
|
||||||
* @returns the typed row.
|
* @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[] =>
|
const numbers = (prefix: string, count: number): number[] =>
|
||||||
Array.from({ length: count }, (_, index) => Number(cell(table, row, `${prefix}${String(index)}`) || '0'))
|
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) !== '')
|
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'
|
? 'additive'
|
||||||
: 'normal'
|
: 'normal'
|
||||||
return {
|
return {
|
||||||
id: Number(cell(table, row, 'Id')),
|
id: sequentialId,
|
||||||
name: cell(table, row, 'Name'),
|
name: cell(table, row, 'Name'),
|
||||||
|
description: cell(table, row, 'Description - not loaded') || cell(table, row, 'Name'),
|
||||||
token,
|
token,
|
||||||
subClass: Number(cell(table, row, 'SubClass') || '0'),
|
subClass: Number(cell(table, row, 'SubClass') || '0'),
|
||||||
act: Number(cell(table, row, 'Act') || '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'),
|
xOffset: Number(cell(table, row, 'Xoffset') || '0'),
|
||||||
yOffset: Number(cell(table, row, 'Yoffset') || '0'),
|
yOffset: Number(cell(table, row, 'Yoffset') || '0'),
|
||||||
isDoor: cell(table, row, 'IsDoor') !== '0' && cell(table, row, 'IsDoor') !== '',
|
isDoor: cell(table, row, 'IsDoor') !== '0' && cell(table, row, 'IsDoor') !== '',
|
||||||
|
operateRange: Number(cell(table, row, 'OperateRange') || '0'),
|
||||||
trans,
|
trans,
|
||||||
blendMode,
|
blendMode,
|
||||||
draw: Number(cell(table, row, 'Draw') || '0'),
|
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 table = parseTable(await archives.read(OBJECTS_TABLE))
|
||||||
const rows: ObjectsRow[] = []
|
const rows: ObjectsRow[] = []
|
||||||
for (const raw of table.rows) {
|
for (const raw of table.rows) {
|
||||||
const row = toObjectsRow(table, raw)
|
const idStr = cell(table, raw, 'Id').trim()
|
||||||
if (!Number.isFinite(row.id)) continue
|
const nameStr = cell(table, raw, 'Name').trim()
|
||||||
|
if (idStr === '' || nameStr.toLowerCase() === 'expansion') continue
|
||||||
|
const row = toObjectsRow(table, raw, rows.length)
|
||||||
rows.push(row)
|
rows.push(row)
|
||||||
}
|
}
|
||||||
const byId = new Map<number, ObjectsRow>()
|
const byId = new Map<number, ObjectsRow>()
|
||||||
|
|
@ -887,11 +899,21 @@ export async function loadObjectSheet(
|
||||||
const component = OBJECT_COMPONENTS[layer.type]
|
const component = OBJECT_COMPONENTS[layer.type]
|
||||||
if (component === undefined) { notes.push(`layer type ${String(layer.type)} has no component directory`); sprites.push(null); continue }
|
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 wanted = objectSpriteMember(token, component, modeIndex, baseIsMonsters)
|
||||||
|
const wantedNoLit = wanted.replace(/lit(\.dcc)$/i, '$1')
|
||||||
let found = findMember(names, wanted)
|
let found = findMember(names, wanted)
|
||||||
if (found === undefined) found = findMember(names, wanted.replace(/\.dcc$/i, '.dc6'))
|
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 }
|
if (found === undefined) { notes.push(`no sprite for component ${component} (${wanted})`); sprites.push(null); continue }
|
||||||
try {
|
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)
|
members.push(found)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notes.push(`${found}: ${(err as Error).message}`)
|
notes.push(`${found}: ${(err as Error).message}`)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue