diablo2-web/src/render/unit-sprites.ts

184 lines
6.0 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c Unified 7-Class, Missile, Overlay & Summon Sprite Loader (`UnitSpriteLoader`).
*
* - Maps all 7 player classes (`ama`->`am`, `sor`->`so`, `nec`->`ne`, `pal`->`pa`, `bar`->`ba`, `dru`->`dz`, `ass`->`ai`)
* across `d2char.mpq` and `d2exp.mpq` with 16 composite layers (`hd..s8`).
* - Decodes missile `.dcc` sprites (`data\global\missiles\<CelFile>.dcc`) via `decodeDcc`.
* - Decodes state/cast overlay `.dcc`/`.dc6` sprites (`data\global\overlays\<Filename>.dcc` or `.dc6`)
* and distinguishes `PreDraw === true` (ground aura rings) vs `PreDraw === false` (overhead curses/flashes).
* - Decodes summon/trap/morph monster sprites (`data\global\monsters\<Code>\`).
*/
import type { MountedArchives } from '../mpq/mount.ts'
import { decodeDcc } from '../formats/dcc.ts'
import { decodeDc6 } from '../formats/dc6.ts'
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
import { loadCharacterSheet, type CharacterSheet } from '../game/character.ts'
export const CLASS_CODE_TO_CHAR_TOKEN: Readonly<Record<string, string>> = {
ama: 'am',
am: 'am',
amazon: 'am',
sor: 'so',
so: 'so',
sorceress: 'so',
nec: 'ne',
ne: 'ne',
necromancer: 'ne',
pal: 'pa',
pa: 'pa',
paladin: 'pa',
bar: 'ba',
ba: 'ba',
barbarian: 'ba',
dru: 'dz',
dz: 'dz',
druid: 'dz',
ass: 'ai',
ai: 'ai',
assassin: 'ai',
}
export function resolvePlayerCharToken(classOrToken: string): string {
const key = classOrToken.trim().toLowerCase()
return CLASS_CODE_TO_CHAR_TOKEN[key] ?? 'so'
}
export class UnitSpriteLoader {
readonly archives: MountedArchives
private readonly charSheetCache = new Map<string, Promise<CharacterSheet>>()
private readonly missileSheetCache = new Map<string, Promise<SpriteSheet>>()
private readonly overlaySheetCache = new Map<string, Promise<SpriteSheet>>()
private readonly monsterSheetCache = new Map<string, Promise<CharacterSheet>>()
constructor(archives: MountedArchives) {
this.archives = archives
}
/**
* Load and cache a composite character sheet for any of the 7 hero classes (`am`, `so`, `ne`, `pa`, `ba`, `dz`, `ai`).
*/
loadPlayerClassSheet(
classOrToken: string,
mode = 'sc',
weaponClass = 'hth',
): Promise<CharacterSheet> {
const token = resolvePlayerCharToken(classOrToken)
const anim = mode.trim().toLowerCase()
const wclass = weaponClass.trim().toLowerCase()
const key = `${token}:${anim}:${wclass}`
const existing = this.charSheetCache.get(key)
if (existing) return existing
const promise = loadCharacterSheet(this.archives, token, anim, wclass)
this.charSheetCache.set(key, promise)
return promise
}
/**
* Load and cache a missile sprite (`data\global\missiles\<celFile>.dcc`).
*/
loadMissileSprite(celFile: string): Promise<SpriteSheet> {
const clean = celFile.trim().replace(/\.(dcc|dc6)$/i, '')
const key = clean.toLowerCase()
const existing = this.missileSheetCache.get(key)
if (existing) return existing
const promise = (async (): Promise<SpriteSheet> => {
const dccPath = `data\\global\\missiles\\${clean}.dcc`
const bytes = await this.archives.read(dccPath)
const dcc = decodeDcc(bytes)
const groups = dcc.directions.map(dir => ({
frames: dir.frames.map(
(f): SpriteFrame => ({
width: f.frame.width,
height: f.frame.height,
indices: f.frame.indices,
mask: f.frame.mask,
anchorX: dir.box.left,
anchorY: dir.box.top,
}),
),
}))
return { groups, width: null }
})()
this.missileSheetCache.set(key, promise)
return promise
}
/**
* Load and cache a state/cast overlay sprite (`data\global\overlays\<filename>.dcc` with `.dc6` fallback).
* Accepts either an `Overlay.txt` `Filename` (`CurseAmplifyDamageEffect`) or `overlay` ID (`curseamplifydamage`).
*/
loadOverlaySprite(filenameOrOverlayId: string): Promise<SpriteSheet> {
const clean = filenameOrOverlayId.trim().replace(/\.(dcc|dc6)$/i, '')
const key = clean.toLowerCase()
const existing = this.overlaySheetCache.get(key)
if (existing) return existing
const promise = (async (): Promise<SpriteSheet> => {
const candidates = [
clean,
key === 'cursampdamage' || key === 'curseamplifydamage' ? 'CurseAmplifyDamageEffect' : '',
'AuraFanatic',
].filter(Boolean)
for (const cand of candidates) {
const dccPath = `data\\global\\overlays\\${cand}.dcc`
if (this.archives.find(dccPath)) {
const bytes = await this.archives.read(dccPath)
const dcc = decodeDcc(bytes)
const groups = dcc.directions.map(dir => ({
frames: dir.frames.map(
(f): SpriteFrame => ({
width: f.frame.width,
height: f.frame.height,
indices: f.frame.indices,
mask: f.frame.mask,
anchorX: dir.box.left,
anchorY: dir.box.top,
}),
),
}))
return { groups, width: null }
}
}
const dc6Path = `data\\global\\overlays\\${clean}.dc6`
const bytes = await this.archives.read(dc6Path)
return decodeDc6(bytes)
})()
this.overlaySheetCache.set(key, promise)
return promise
}
/**
* Load and cache a summoned pet, trap, or shapeshift monster sprite (`data\global\monsters\<code>\`).
*/
loadMonsterOrPetSheet(
monCode: string,
mode = 'nu',
weaponClass = 'hth',
): Promise<CharacterSheet> {
const code = monCode.trim().toLowerCase()
const anim = mode.trim().toLowerCase()
const wclass = weaponClass.trim().toLowerCase()
const key = `${code}:${anim}:${wclass}`
const existing = this.monsterSheetCache.get(key)
if (existing) return existing
const promise = loadCharacterSheet(
this.archives,
code,
anim,
wclass,
`data\\global\\monsters\\${code}\\`,
)
this.monsterSheetCache.set(key, promise)
return promise
}
}