feat(char,items): dynamic Sorceress equipment paperdoll appearance, weapon classes, and 1.13c palette dye shifts
This commit is contained in:
parent
4e461e1fdb
commit
6601dcde82
|
|
@ -31,9 +31,11 @@
|
|||
"pack:ui": "tsx scripts/pack-ui.ts",
|
||||
"pack:animdata": "tsx scripts/pack-animdata.ts",
|
||||
"pack:entities": "tsx scripts/pack-entity-assets.ts",
|
||||
"pack:char-so": "tsx scripts/pack-char-so-layers.ts",
|
||||
"pack:missiles": "tsx scripts/pack-missiles.ts",
|
||||
"pack:overlays": "tsx scripts/pack-overlays.ts",
|
||||
"verify:entity-packs": "tsx scripts/verify-entity-packs.ts",
|
||||
"verify:char-appearance": "tsx scripts/verify-char-appearance.ts",
|
||||
"verify:packs": "tsx scripts/verify-packs.ts",
|
||||
"verify:bundle-no-mpq": "tsx scripts/verify-bundle-no-mpq.ts",
|
||||
"verify:animation": "tsx scripts/verify-animation-browser.ts",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* Bake Sorceress (`so`) modular paperdoll DCC layers, COF descriptors, AnimData clips,
|
||||
* and `Palette/*.dat` color shift tables into `samples/d2-packs/chars/so/`.
|
||||
*
|
||||
* Ground Truth (Diablo II v1.13c):
|
||||
* - `data\global\chars\so\cof\*.cof` (94 COF files)
|
||||
* - `data\global\chars\so\<comp>\so<comp><variant><mode><wclass>.dcc` (1,669 DCC files across 77 `<comp>-<variant>` pairs)
|
||||
* - `data\global\items\Palette\*.dat` (8 palette shift tables, each 21 colors × 256 bytes = 5,376 bytes)
|
||||
*
|
||||
* This offline pack script eliminates any runtime dependency on MPQ archives while allowing
|
||||
* instant on-demand loading and caching of paperdoll component variants and dye transforms.
|
||||
*/
|
||||
import { mkdir, writeFile, rename } from 'node:fs/promises'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
|
||||
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
|
||||
|
||||
/**
|
||||
* 1.13c `D2CMP.dll` `D2Comp.cpp` palette shift `.dat` files indexed by `Transform` (1..8).
|
||||
*/
|
||||
export const PALETTE_SHIFT_FILES: Readonly<Record<number, string>> = {
|
||||
1: 'data\\global\\items\\Palette\\grey.dat',
|
||||
2: 'data\\global\\items\\Palette\\grey2.dat',
|
||||
3: 'data\\global\\items\\Palette\\gold.dat',
|
||||
4: 'data\\global\\items\\Palette\\brown.dat',
|
||||
5: 'data\\global\\items\\Palette\\greybrown.dat',
|
||||
6: 'data\\global\\items\\Palette\\invgrey.dat',
|
||||
7: 'data\\global\\items\\Palette\\invgrey2.dat',
|
||||
8: 'data\\global\\items\\Palette\\invgreybrown.dat',
|
||||
}
|
||||
|
||||
export interface SorceressLayerPackManifest {
|
||||
readonly schema: 1
|
||||
readonly metaFile: string
|
||||
readonly cofs: Record<string, [number, number]>
|
||||
readonly palettes: Record<number, [number, number]>
|
||||
readonly animClips: Record<string, { speed: number; events: [number, number][] }>
|
||||
readonly layers: Record<
|
||||
string,
|
||||
{
|
||||
readonly file: string
|
||||
readonly dccs: Record<string, [number, number]>
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
async function writeFileAtomic(targetPath: string, content: string | Uint8Array): Promise<void> {
|
||||
const tmpPath = `${targetPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`
|
||||
await writeFile(tmpPath, content)
|
||||
await rename(tmpPath, targetPath)
|
||||
}
|
||||
|
||||
export async function packSorceressLayers(
|
||||
archiveDir = 'samples/d2',
|
||||
outDir = 'samples/d2-packs',
|
||||
): Promise<SorceressLayerPackManifest> {
|
||||
const archives = new MountedArchives()
|
||||
for (const name of MOUNTS) {
|
||||
const fullPath = join(archiveDir, name)
|
||||
if (!existsSync(fullPath)) {
|
||||
throw new Error(`Required MPQ archive missing: ${fullPath}`)
|
||||
}
|
||||
const archive = await MpqArchive.open(await fileSource(fullPath))
|
||||
archives.add(name, archive)
|
||||
}
|
||||
|
||||
const animDataPath = join(outDir, 'anim', 'animdata.json')
|
||||
if (!existsSync(animDataPath)) {
|
||||
throw new Error(`Missing ${animDataPath}. Run 'npm run pack:animdata' first.`)
|
||||
}
|
||||
const animData = JSON.parse(readFileSync(animDataPath, 'utf8')) as {
|
||||
clips: Record<string, { frames: number; speed: number; events?: [number, number][] }>
|
||||
}
|
||||
|
||||
const soOutDir = join(outDir, 'chars', 'so')
|
||||
const layersOutDir = join(soOutDir, 'layers')
|
||||
await mkdir(layersOutDir, { recursive: true })
|
||||
|
||||
// 1. Collect all members under data\global\chars\so\
|
||||
const allMembers = await archives.listFiles()
|
||||
const cofPaths: string[] = []
|
||||
const dccByCompVariant = new Map<string, { modeWclass: string; path: string }[]>()
|
||||
|
||||
for (const member of allMembers) {
|
||||
const norm = member.replace(/\//g, '\\')
|
||||
const lower = norm.toLowerCase()
|
||||
if (!lower.startsWith('data\\global\\chars\\so\\')) continue
|
||||
|
||||
if (lower.startsWith('data\\global\\chars\\so\\cof\\') && lower.endsWith('.cof')) {
|
||||
cofPaths.push(norm)
|
||||
continue
|
||||
}
|
||||
|
||||
if (lower.endsWith('.dcc')) {
|
||||
// Format: data\global\chars\so\<comp>\so<comp><lit><mode><wclass>.dcc
|
||||
const parts = lower.split('\\')
|
||||
if (parts.length !== 6) continue
|
||||
const comp = parts[4]!
|
||||
const fileName = parts[5]!
|
||||
const baseName = fileName.slice(0, -4) // strip .dcc
|
||||
// baseName is e.g. "sohdcapnu1hs" (2 + 2 + 3 + 2 + 3 = 12 chars)
|
||||
if (!baseName.startsWith(`so${comp}`) || baseName.length !== 12) continue
|
||||
const variant = baseName.slice(4, 7)
|
||||
const modeWclass = baseName.slice(7, 12).toUpperCase()
|
||||
const key = `${comp}-${variant}`
|
||||
const list = dccByCompVariant.get(key) ?? []
|
||||
list.push({ modeWclass, path: norm })
|
||||
dccByCompVariant.set(key, list)
|
||||
}
|
||||
}
|
||||
|
||||
cofPaths.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
|
||||
|
||||
// 2. Pack meta.bin (COFs + Palette shift .dat files)
|
||||
const metaChunks: Uint8Array[] = []
|
||||
let metaOffset = 0
|
||||
const cofs: Record<string, [number, number]> = {}
|
||||
const animClips: Record<string, { speed: number; events: [number, number][] }> = {}
|
||||
|
||||
for (const cofPath of cofPaths) {
|
||||
const fileName = cofPath.split('\\').pop()!
|
||||
const cofToken = fileName.slice(0, -4).toUpperCase() // e.g. SONUHTH
|
||||
const bytes = await archives.read(cofPath)
|
||||
cofs[cofToken] = [metaOffset, bytes.byteLength]
|
||||
metaChunks.push(bytes)
|
||||
metaOffset += bytes.byteLength
|
||||
|
||||
const clipRec = animData.clips[cofToken]
|
||||
animClips[cofToken] = {
|
||||
speed: clipRec?.speed ?? 256,
|
||||
events: clipRec?.events ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
const palettes: Record<number, [number, number]> = {}
|
||||
for (let transIdx = 1; transIdx <= 8; transIdx += 1) {
|
||||
const palPath = PALETTE_SHIFT_FILES[transIdx]!
|
||||
const bytes = await archives.read(palPath)
|
||||
if (bytes.byteLength !== 21 * 256) {
|
||||
throw new Error(`Unexpected palette shift size for ${palPath}: ${bytes.byteLength} (expected 5376)`)
|
||||
}
|
||||
palettes[transIdx] = [metaOffset, bytes.byteLength]
|
||||
metaChunks.push(bytes)
|
||||
metaOffset += bytes.byteLength
|
||||
}
|
||||
|
||||
const metaBin = new Uint8Array(metaOffset)
|
||||
let cursor = 0
|
||||
for (const chunk of metaChunks) {
|
||||
metaBin.set(chunk, cursor)
|
||||
cursor += chunk.byteLength
|
||||
}
|
||||
await writeFileAtomic(join(soOutDir, 'meta.bin'), metaBin)
|
||||
|
||||
// 3. Pack each <comp>-<variant>.bin
|
||||
const sortedLayerKeys = [...dccByCompVariant.keys()].sort()
|
||||
const layers: Record<string, { file: string; dccs: Record<string, [number, number]> }> = {}
|
||||
|
||||
let totalDccCount = 0
|
||||
let totalDccBytes = 0
|
||||
|
||||
for (const key of sortedLayerKeys) {
|
||||
const entries = dccByCompVariant.get(key)!
|
||||
entries.sort((a, b) => a.modeWclass.localeCompare(b.modeWclass))
|
||||
|
||||
const chunks: Uint8Array[] = []
|
||||
let offset = 0
|
||||
const dccs: Record<string, [number, number]> = {}
|
||||
|
||||
for (const entry of entries) {
|
||||
const bytes = await archives.read(entry.path)
|
||||
dccs[entry.modeWclass] = [offset, bytes.byteLength]
|
||||
chunks.push(bytes)
|
||||
offset += bytes.byteLength
|
||||
totalDccCount += 1
|
||||
}
|
||||
|
||||
const layerBin = new Uint8Array(offset)
|
||||
let pos = 0
|
||||
for (const chunk of chunks) {
|
||||
layerBin.set(chunk, pos)
|
||||
pos += chunk.byteLength
|
||||
}
|
||||
|
||||
const relFile = `layers/${key}.bin`
|
||||
await writeFileAtomic(join(soOutDir, relFile), layerBin)
|
||||
layers[key] = { file: relFile, dccs }
|
||||
totalDccBytes += offset
|
||||
}
|
||||
|
||||
const manifest: SorceressLayerPackManifest = {
|
||||
schema: 1,
|
||||
metaFile: 'meta.bin',
|
||||
cofs,
|
||||
palettes,
|
||||
animClips,
|
||||
layers,
|
||||
}
|
||||
|
||||
await writeFileAtomic(join(soOutDir, 'manifest.json'), JSON.stringify(manifest))
|
||||
console.log(
|
||||
`Packed Sorceress paperdoll layers: ${Object.keys(cofs).length} COFs, 8 Palette tables (${(metaOffset / 1024).toFixed(1)} KB meta.bin), ${sortedLayerKeys.length} layer packs (${totalDccCount} DCCs, ${(totalDccBytes / 1024 / 1024).toFixed(2)} MB)`,
|
||||
)
|
||||
return manifest
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
packSorceressLayers().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
|
@ -298,7 +298,7 @@ async function processCharJob(job: CharJob, ctx: EntityWorkerContext): Promise<C
|
|||
let charGroupOffset = 0
|
||||
let totalCharMembers = 0
|
||||
for (const mode of PLAYER_MODES) {
|
||||
const sheet = await loadCharacterSheet(archives, 'so', mode, 'hth')
|
||||
const sheet = await loadCharacterSheet(archives, 'so', mode, 'hth', undefined, true)
|
||||
charSheets.push(sheet)
|
||||
totalCharMembers += sheet.members.length
|
||||
|
||||
|
|
@ -814,12 +814,20 @@ export async function bakeEntities(archiveDir = 'samples/d2', outDir = 'samples/
|
|||
|
||||
// 2. Build job list: Job 0 = Sorceress character, Jobs 1..N = Monsters
|
||||
const jobs: EntityJob[] = []
|
||||
jobs.push({
|
||||
const charJob: CharJob = {
|
||||
type: 'char',
|
||||
jobId: 0,
|
||||
archiveDir,
|
||||
outDir,
|
||||
})
|
||||
}
|
||||
jobs.push(charJob)
|
||||
|
||||
if (process.env.CHAR_ONLY === '1') {
|
||||
const ctx = await EntityWorkerContext.create(archiveDir, outDir)
|
||||
await processCharJob(charJob, ctx)
|
||||
console.log('✓ Packed char-so (CHAR_ONLY=1).')
|
||||
return
|
||||
}
|
||||
|
||||
const entries = [...uniqueSpecs.entries()]
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,691 @@
|
|||
import { readFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { COLOR_CODES, GEM_TRANSFORM_BY_CODE } from '../src/game/gems-data.ts'
|
||||
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
||||
import { resolveItemColorTransform } from '../src/ui/item-bridge.ts'
|
||||
import {
|
||||
SorceressLayerCompositor,
|
||||
resolveSorceressAppearance,
|
||||
} from '../src/game/char-appearance.ts'
|
||||
import type { ResolvableEquippedItem } from '../src/game/char-appearance.ts'
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
function assert(cond: boolean, message: string): void {
|
||||
if (!cond) {
|
||||
throw new Error(`ASSERTION FAILED: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function makeItem(overrides: Partial<ResolvableEquippedItem> & { code: string; name: string }): ResolvableEquippedItem {
|
||||
return {
|
||||
quality: 'normal',
|
||||
identified: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
interface CdpSession {
|
||||
send: <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
|
||||
evalJs: <T = unknown>(expression: string) => Promise<T>
|
||||
close: () => void
|
||||
}
|
||||
|
||||
async function connectCdp(debugPort: number, onEvent: (msg: Record<string, any>) => void): Promise<CdpSession> {
|
||||
let wsUrl: string | null = null
|
||||
let lastErr = 'none'
|
||||
for (let i = 0; i < 80; i++) {
|
||||
await sleep(200)
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
|
||||
const pages = (await res.json()) as { type: string; webSocketDebuggerUrl?: string }[]
|
||||
const page = pages.find(p => p.type === 'page')
|
||||
if (page?.webSocketDebuggerUrl !== undefined) {
|
||||
wsUrl = page.webSocketDebuggerUrl
|
||||
break
|
||||
}
|
||||
lastErr = 'no page target yet'
|
||||
} catch (err) {
|
||||
lastErr = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
if (wsUrl === null) throw new Error(`could not reach Chrome CDP endpoint (last error: ${lastErr})`)
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
await new Promise<void>((res, rej) => {
|
||||
ws.onopen = () => res()
|
||||
ws.onerror = () => rej(new Error('CDP WebSocket failed to open'))
|
||||
})
|
||||
|
||||
let idCounter = 1
|
||||
const send = <T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
timeoutMs = 20000,
|
||||
): Promise<T> =>
|
||||
new Promise<T>((res, rej) => {
|
||||
const id = idCounter++
|
||||
const timer = setTimeout(() => {
|
||||
ws.removeEventListener('message', handler)
|
||||
rej(new Error(`CDP ${method} timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
const handler = (event: MessageEvent): void => {
|
||||
const msg = JSON.parse(String(event.data)) as Record<string, any>
|
||||
if (msg['id'] === id) {
|
||||
clearTimeout(timer)
|
||||
ws.removeEventListener('message', handler)
|
||||
if (msg['error'] !== undefined) rej(new Error(`CDP ${method}: ${JSON.stringify(msg['error'])}`))
|
||||
else res(msg['result'] as T)
|
||||
}
|
||||
}
|
||||
ws.addEventListener('message', handler)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
|
||||
ws.addEventListener('message', (event: MessageEvent) => {
|
||||
onEvent(JSON.parse(String(event.data)) as Record<string, any>)
|
||||
})
|
||||
|
||||
const evalJs = async <T = unknown>(expression: string): Promise<T> => {
|
||||
const res = await send<{ result: { value: T }; exceptionDetails?: unknown }>('Runtime.evaluate', {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
})
|
||||
if (res.exceptionDetails !== undefined) {
|
||||
throw new Error(`in-page JS threw: ${JSON.stringify(res.exceptionDetails)}\n expression: ${expression}`)
|
||||
}
|
||||
return res.result.value
|
||||
}
|
||||
|
||||
return { send, evalJs, close: () => ws.close() }
|
||||
}
|
||||
|
||||
async function runUnitAndCompositorChecks(): Promise<void> {
|
||||
console.log('--- 1. Verifying Item Color & Palette Tables ---')
|
||||
assert(COLOR_CODES.length === 21, `Expected 21 COLOR_CODES, got ${COLOR_CODES.length}`)
|
||||
assert(COLOR_CODES[0] === 'whit' && COLOR_CODES[20] === 'bwht', 'COLOR_CODES boundary mismatch')
|
||||
assert(Object.keys(GEM_TRANSFORM_BY_CODE).length === 35, 'Expected 35 gem transform entries')
|
||||
|
||||
const dropTables = getEmbeddedDropTables()
|
||||
|
||||
// Unidentified item -> undefined
|
||||
const unidUnique = resolveItemColorTransform(
|
||||
{ name: 'The Oculus', identified: false } as any,
|
||||
'unique',
|
||||
'oba',
|
||||
dropTables,
|
||||
)
|
||||
assert(
|
||||
unidUnique.chrtransform === undefined,
|
||||
`Unidentified item must resolve to undefined chrtransform, got ${String(unidUnique.chrtransform)}`,
|
||||
)
|
||||
|
||||
// Unique items
|
||||
assert(
|
||||
resolveItemColorTransform({ name: "Ginther's Rift", identified: true } as any, 'unique', '9cr', dropTables).chrtransform === 'cblu',
|
||||
"Ginther's Rift should resolve to cblu",
|
||||
)
|
||||
assert(
|
||||
resolveItemColorTransform({ name: 'Harlequin Crest', identified: true } as any, 'unique', 'uap', dropTables).chrtransform === 'cgrn',
|
||||
'Harlequin Crest should resolve to cgrn',
|
||||
)
|
||||
assert(
|
||||
resolveItemColorTransform({ name: 'Skin of the Vipermagi', identified: true } as any, 'unique', 'xea', dropTables).chrtransform === 'dblu',
|
||||
'Skin of the Vipermagi should resolve to dblu',
|
||||
)
|
||||
|
||||
// Set items (including display name vs internal index fallback)
|
||||
assert(
|
||||
resolveItemColorTransform({ name: "Tal Rasha's Horadric Crest", identified: true } as any, 'set', 'xsk', dropTables).chrtransform === 'dpur',
|
||||
"Tal Rasha's Horadric Crest should resolve to dpur",
|
||||
)
|
||||
assert(
|
||||
resolveItemColorTransform({ name: "Tal Rasha's Guardianship", identified: true } as any, 'set', 'uth', dropTables).chrtransform === 'dpur',
|
||||
"Tal Rasha's Guardianship (uth) should resolve to dpur",
|
||||
)
|
||||
|
||||
// Magic/Rare suffix > prefix priority
|
||||
assert(
|
||||
resolveItemColorTransform(
|
||||
{
|
||||
name: 'Cobalt Cap of the Mammoth',
|
||||
identified: true,
|
||||
rolledMagicAffixes: {
|
||||
prefixes: [{ name: 'Cobalt', transform: true, transformColor: 'cblu' }],
|
||||
suffixes: [{ name: 'of the Mammoth', transform: true, transformColor: 'cred' }],
|
||||
},
|
||||
} as any,
|
||||
'magic',
|
||||
'cap',
|
||||
dropTables,
|
||||
).chrtransform === 'cred',
|
||||
'Magic suffix color (cred) must override prefix color (cblu)',
|
||||
)
|
||||
assert(
|
||||
resolveItemColorTransform(
|
||||
{
|
||||
name: 'Cobalt Cap',
|
||||
identified: true,
|
||||
rolledMagicAffixes: {
|
||||
prefixes: [{ name: 'Cobalt', transform: true, transformColor: 'cblu' }],
|
||||
suffixes: [{ name: 'of Dexterity', transform: false, transformColor: '' }],
|
||||
},
|
||||
} as any,
|
||||
'magic',
|
||||
'cap',
|
||||
dropTables,
|
||||
).chrtransform === 'cblu',
|
||||
'Magic prefix color (cblu) must apply when suffix has no transform',
|
||||
)
|
||||
|
||||
// Socketed gems vs Runeword exclusion
|
||||
assert(
|
||||
resolveItemColorTransform(
|
||||
{
|
||||
name: 'Socketed Cap',
|
||||
identified: true,
|
||||
socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }],
|
||||
} as any,
|
||||
'normal',
|
||||
'cap',
|
||||
dropTables,
|
||||
).chrtransform === 'whit',
|
||||
'Socketed Perfect Diamond (gpw) must dye normal item whit',
|
||||
)
|
||||
assert(
|
||||
resolveItemColorTransform(
|
||||
{
|
||||
name: 'Enigma',
|
||||
identified: true,
|
||||
runewordRunes: "'JahIthBer'",
|
||||
socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }],
|
||||
} as any,
|
||||
'rune',
|
||||
'utp',
|
||||
dropTables,
|
||||
).chrtransform === undefined,
|
||||
'Runeword must not inherit Unique or Gem color transform',
|
||||
)
|
||||
|
||||
// AutoMagic
|
||||
assert(
|
||||
resolveItemColorTransform(
|
||||
{
|
||||
name: 'Plate Mail',
|
||||
identified: true,
|
||||
autoAffix: { name: 'Cobalt', transform: true, transformColor: 'cblu' },
|
||||
} as any,
|
||||
'normal',
|
||||
'plt',
|
||||
dropTables,
|
||||
).chrtransform === 'cblu',
|
||||
'AutoMagic Cobalt must resolve to cblu',
|
||||
)
|
||||
console.log('✓ Item color resolution (ITEMS_GetColor) passed all checks.')
|
||||
|
||||
console.log('--- 2. Verifying Sorceress 10-Component Appearance Mapping ---')
|
||||
// Unarmed
|
||||
const unarmed = resolveSorceressAppearance({}, dropTables)
|
||||
assert(unarmed.wclass === 'hth', `Unarmed wclass should be hth, got ${unarmed.wclass}`)
|
||||
assert(unarmed.layers.hd.variant === 'lit', 'Unarmed hd should be lit')
|
||||
assert(unarmed.layers.tr.variant === 'lit', 'Unarmed tr should be lit')
|
||||
assert(
|
||||
unarmed.layers.rh.variant === null &&
|
||||
unarmed.layers.lh.variant === null &&
|
||||
unarmed.layers.sh.variant === null,
|
||||
'Unarmed hands/shield must be null',
|
||||
)
|
||||
|
||||
// Circlet exemption (D2Inventory.cpp:2624)
|
||||
const circletSpec = resolveSorceressAppearance(
|
||||
{ helm: makeItem({ code: 'ci3', name: "Griffon's Eye", quality: 'unique', chrtransform: 'cblu' }) },
|
||||
dropTables,
|
||||
)
|
||||
assert(
|
||||
circletSpec.layers.hd.variant === 'lit' && circletSpec.layers.hd.color === null,
|
||||
'Circlet (ci3) must keep hd=lit with null color',
|
||||
)
|
||||
|
||||
// Asymmetric Studded Leather (stu) vs Breast Plate (brs)
|
||||
const stuSpec = resolveSorceressAppearance(
|
||||
{ armor: makeItem({ code: 'stu', name: 'Studded Leather', chrtransform: 'cgrn' }) },
|
||||
dropTables,
|
||||
)
|
||||
assert(
|
||||
stuSpec.layers.tr.variant === 'lit' &&
|
||||
stuSpec.layers.lg.variant === 'med' &&
|
||||
stuSpec.layers.ra.variant === 'med' &&
|
||||
stuSpec.layers.la.variant === 'lit' &&
|
||||
stuSpec.layers.s1.variant === 'med' &&
|
||||
stuSpec.layers.s2.variant === 'med' &&
|
||||
stuSpec.layers.tr.color === 'cgrn',
|
||||
'Studded Leather (stu) asymmetric component mapping mismatch',
|
||||
)
|
||||
|
||||
const brsSpec = resolveSorceressAppearance(
|
||||
{ armor: makeItem({ code: 'brs', name: 'Breast Plate', chrtransform: 'lgld' }) },
|
||||
dropTables,
|
||||
)
|
||||
assert(
|
||||
brsSpec.layers.tr.variant === 'hvy' &&
|
||||
brsSpec.layers.lg.variant === 'lit' &&
|
||||
brsSpec.layers.ra.variant === 'lit' &&
|
||||
brsSpec.layers.la.variant === 'lit' &&
|
||||
brsSpec.layers.s1.variant === 'hvy' &&
|
||||
brsSpec.layers.s2.variant === 'hvy' &&
|
||||
brsSpec.layers.tr.color === 'lgld',
|
||||
'Breast Plate (brs) component/color mapping mismatch',
|
||||
)
|
||||
|
||||
// Weapons: Orb + Shield, Bow (lh!), Crossbow (rh!)
|
||||
const orbShieldSpec = resolveSorceressAppearance(
|
||||
{
|
||||
weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique', chrtransform: 'whit' }),
|
||||
weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cblu' }),
|
||||
},
|
||||
dropTables,
|
||||
)
|
||||
assert(
|
||||
orbShieldSpec.wclass === '1hs' &&
|
||||
orbShieldSpec.layers.rh.variant === 'ob4' &&
|
||||
orbShieldSpec.layers.rh.color === 'whit' &&
|
||||
orbShieldSpec.layers.sh.variant === 'kit' &&
|
||||
orbShieldSpec.layers.sh.color === 'cblu',
|
||||
`Orb + Monarch shield appearance mismatch: ${JSON.stringify(orbShieldSpec)}`,
|
||||
)
|
||||
|
||||
const bowSpec = resolveSorceressAppearance(
|
||||
{
|
||||
weapon1: makeItem({ code: 'sbw', name: 'Short Bow' }),
|
||||
},
|
||||
dropTables,
|
||||
)
|
||||
assert(
|
||||
bowSpec.wclass === 'bow' &&
|
||||
bowSpec.layers.lh.variant === 'sbw' &&
|
||||
bowSpec.layers.rh.variant === null &&
|
||||
bowSpec.layers.sh.variant === null,
|
||||
'Short Bow must set wclass=bow, lh=sbw, rh=null, sh=null',
|
||||
)
|
||||
|
||||
const xbwSpec = resolveSorceressAppearance(
|
||||
{ weapon1: makeItem({ code: 'lxb', name: 'Light Crossbow' }) },
|
||||
dropTables,
|
||||
)
|
||||
assert(
|
||||
xbwSpec.wclass === 'xbw' && xbwSpec.layers.rh.variant === 'lxb' && xbwSpec.layers.lh.variant === null,
|
||||
'Crossbow must set wclass=xbw, rh=lxb, lh=null',
|
||||
)
|
||||
console.log('✓ Sorceress 10-component appearance mapping passed all checks.')
|
||||
|
||||
console.log('--- 3. Verifying SorceressLayerCompositor Offline Pack & Palette Shifts ---')
|
||||
const origFetch = globalThis.fetch
|
||||
const rootDir = resolve('.')
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const urlStr = String(input)
|
||||
const relPath = urlStr.replace(/^\/+/, '')
|
||||
const buf = await readFile(join(rootDir, relPath))
|
||||
return new Response(buf, { status: 200 })
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const compositor = new SorceressLayerCompositor('samples/d2-packs/chars/so')
|
||||
const compUnarmed = await compositor.composite(unarmed)
|
||||
const compOrbShield = await compositor.composite(orbShieldSpec)
|
||||
const compOrbShieldRed = await compositor.composite(
|
||||
resolveSorceressAppearance(
|
||||
{
|
||||
helm: makeItem({ code: 'crn', name: 'Crown', chrtransform: 'cred' }),
|
||||
armor: makeItem({ code: 'ful', name: 'Full Plate Mail', chrtransform: 'cred' }),
|
||||
weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique' }),
|
||||
weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cred' }),
|
||||
},
|
||||
dropTables,
|
||||
),
|
||||
)
|
||||
const compOrbShieldBlue = await compositor.composite(
|
||||
resolveSorceressAppearance(
|
||||
{
|
||||
helm: makeItem({ code: 'crn', name: 'Crown', chrtransform: 'cblu' }),
|
||||
armor: makeItem({ code: 'ful', name: 'Full Plate Mail', chrtransform: 'cblu' }),
|
||||
weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique' }),
|
||||
weapon2: makeItem({ code: 'uit', name: 'Monarch', chrtransform: 'cblu' }),
|
||||
},
|
||||
dropTables,
|
||||
),
|
||||
)
|
||||
|
||||
assert(compUnarmed.members === 47, `Expected 47 unarmed members across 11 modes, got ${compUnarmed.members}`)
|
||||
assert(compOrbShield.members > compUnarmed.members, `Expected orb+shield members > unarmed, got ${compOrbShield.members}`)
|
||||
assert(compOrbShieldRed.members > compOrbShield.members, `Expected full plate+crown+orb+shield members > orb+shield, got ${compOrbShieldRed.members}`)
|
||||
|
||||
// Compare palette indices between Red and Blue dyed identical gear
|
||||
assert(
|
||||
compOrbShieldRed.r8.length === compOrbShieldBlue.r8.length,
|
||||
'Identical gear with different dye colors must produce same atlas dimensions',
|
||||
)
|
||||
let diffPixels = 0
|
||||
for (let i = 0; i < compOrbShieldRed.r8.length; i += 1) {
|
||||
if (compOrbShieldRed.r8[i] !== compOrbShieldBlue.r8[i]) diffPixels += 1
|
||||
}
|
||||
assert(
|
||||
diffPixels > 50_000,
|
||||
`Expected >50,000 palette-shifted pixels between cred and cblu gear, got ${diffPixels}`,
|
||||
)
|
||||
console.log(`✓ SorceressLayerCompositor verified (${diffPixels.toLocaleString()} dyed pixels shifted between cred and cblu).`)
|
||||
} finally {
|
||||
globalThis.fetch = origFetch
|
||||
}
|
||||
}
|
||||
|
||||
async function startViteServer(port: number): Promise<ChildProcess> {
|
||||
const child = spawn('npx', ['vite', '--host', '127.0.0.1', '--port', String(port)], {
|
||||
cwd: resolve('.'),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < 20_000) {
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${String(port)}/acts.html`)
|
||||
if (resp.ok) return child
|
||||
} catch {
|
||||
// wait
|
||||
}
|
||||
await sleep(250)
|
||||
}
|
||||
child.kill('SIGTERM')
|
||||
throw new Error('Timed out waiting for Vite dev server')
|
||||
}
|
||||
|
||||
async function runBrowserE2ECheck(): Promise<void> {
|
||||
console.log('--- 4. Verifying Live WebGL Browser E2E Appearance & Dye Updates ---')
|
||||
const port = 5173
|
||||
let server: ChildProcess | null = null
|
||||
const debugPort = 9249
|
||||
let chrome: ChildProcess | null = null
|
||||
try {
|
||||
const probe = await fetch(`http://127.0.0.1:${String(port)}/acts.html`).catch(() => null)
|
||||
if (!probe || !probe.ok) {
|
||||
server = await startViteServer(port)
|
||||
}
|
||||
|
||||
const consoleErrors: string[] = []
|
||||
const mpqRequests: string[] = []
|
||||
|
||||
chrome = spawn('/usr/bin/google-chrome', [
|
||||
'--headless=new',
|
||||
`--remote-debugging-port=${String(debugPort)}`,
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--enable-webgl',
|
||||
'--ignore-gpu-blocklist',
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--window-size=1280,720',
|
||||
'about:blank',
|
||||
])
|
||||
|
||||
const cdp = await connectCdp(debugPort, msg => {
|
||||
const method = msg['method'] as string | undefined
|
||||
if (method === 'Network.requestWillBeSent') {
|
||||
const req = msg['params'].request as { url: string }
|
||||
if (/\.mpq(\?|$)/i.test(req.url)) mpqRequests.push(req.url)
|
||||
} else if (method === 'Runtime.consoleAPICalled') {
|
||||
if (msg['params'].type === 'error') {
|
||||
const args = (msg['params'].args ?? []) as { value?: unknown; description?: string }[]
|
||||
const text = args.map(a => String(a.value ?? a.description ?? '')).join(' ')
|
||||
consoleErrors.push(text)
|
||||
}
|
||||
} else if (method === 'Runtime.exceptionThrown') {
|
||||
const d = msg['params'].exceptionDetails as { text?: string; exception?: { description?: string } }
|
||||
consoleErrors.push(d.exception?.description ?? d.text ?? 'unknown exception')
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await cdp.send('Page.enable')
|
||||
await cdp.send('Runtime.enable')
|
||||
await cdp.send('Network.enable')
|
||||
|
||||
const targetUrl = `http://127.0.0.1:${String(port)}/acts.html?source=pack&base=samples/d2-packs&act=1&hero=so`
|
||||
await cdp.send('Page.navigate', { url: targetUrl })
|
||||
|
||||
let ready = false
|
||||
for (let i = 0; i < 120; i += 1) {
|
||||
ready = await cdp.evalJs<boolean>(`Boolean(window.__d2webAct?.ready)`)
|
||||
if (ready) break
|
||||
await sleep(300)
|
||||
}
|
||||
assert(ready, 'Scene did not become ready within timeout')
|
||||
|
||||
// 1. Check initial Sorceress starter equipment appearance (Shako + Tal Armor + Oculus + Spirit Monarch)
|
||||
const initialInfo = await cdp.evalJs<{
|
||||
heroToken: string
|
||||
weaponClass: string
|
||||
members: number
|
||||
key: string
|
||||
appearance: any
|
||||
}>(`(() => {
|
||||
const act = window.__d2webAct
|
||||
return {
|
||||
heroToken: act?.heroToken,
|
||||
weaponClass: act?.weaponClass,
|
||||
members: act?.characterMembers,
|
||||
key: act?.characterAppearanceKey,
|
||||
appearance: act?.characterAppearance,
|
||||
}
|
||||
})()`)
|
||||
|
||||
assert(initialInfo.heroToken === 'so', `Expected heroToken=so, got ${String(initialInfo.heroToken)}`)
|
||||
assert(initialInfo.weaponClass === '1hs', `Expected starter weaponClass=1hs, got ${String(initialInfo.weaponClass)}`)
|
||||
assert(
|
||||
initialInfo.appearance?.layers?.hd?.variant === 'msk' &&
|
||||
initialInfo.appearance?.layers?.hd?.color === 'dpur',
|
||||
`Expected starter hd=msk:dpur (Tal Rasha's Horadric Crest), got ${JSON.stringify(initialInfo.appearance?.layers?.hd)}`,
|
||||
)
|
||||
assert(
|
||||
initialInfo.appearance?.layers?.tr?.variant === 'med' &&
|
||||
initialInfo.appearance?.layers?.tr?.color === 'dpur' &&
|
||||
initialInfo.appearance?.layers?.ra?.variant === 'hvy',
|
||||
`Expected starter tr=med:dpur, ra=hvy (Tal Rasha's Guardianship), got ${JSON.stringify(initialInfo.appearance?.layers?.tr)}`,
|
||||
)
|
||||
assert(
|
||||
initialInfo.appearance?.layers?.rh?.variant === 'ob4' &&
|
||||
initialInfo.appearance?.layers?.rh?.color === 'dpur',
|
||||
`Expected starter rh=ob4:dpur (Tal Rasha's Lidless Eye), got ${JSON.stringify(initialInfo.appearance?.layers?.rh)}`,
|
||||
)
|
||||
assert(
|
||||
initialInfo.appearance?.layers?.sh?.variant === null &&
|
||||
initialInfo.appearance?.layers?.sh?.color === null,
|
||||
`Expected starter sh=null:null (5-piece Tal Rasha set has no shield), got ${JSON.stringify(initialInfo.appearance?.layers?.sh)}`,
|
||||
)
|
||||
|
||||
// 1b. Equip Monarch Shield (uit -> kit, lgld) into weapon2 alongside Tal Rasha's Lidless Eye
|
||||
const gearUpdateShield = await cdp.evalJs<{
|
||||
changed: boolean
|
||||
weaponClass: string
|
||||
appearance: any
|
||||
}>(`(async () => {
|
||||
const hud = window.__d2webHudInstance
|
||||
const scene = window.__d2webActScene
|
||||
const act = window.__d2webAct
|
||||
hud.inventory.equipped = {
|
||||
...hud.inventory.equipped,
|
||||
weapon2: {
|
||||
id: 'gold-monarch',
|
||||
code: 'uit',
|
||||
name: "Holy Monarch",
|
||||
nameZh: '神圣统治者大盾',
|
||||
invFile: 'invuit',
|
||||
quality: 'magic',
|
||||
chrtransform: 'lgld',
|
||||
identified: true,
|
||||
invWidth: 2,
|
||||
invHeight: 3,
|
||||
allowedSlots: ['weapon2'],
|
||||
stats: [],
|
||||
},
|
||||
}
|
||||
const changed = await scene.syncAppearance()
|
||||
return {
|
||||
changed,
|
||||
weaponClass: act.weaponClass,
|
||||
appearance: act.characterAppearance,
|
||||
}
|
||||
})()`)
|
||||
|
||||
assert(gearUpdateShield.changed === true, 'Expected syncAppearance() to return true after equipping Monarch shield')
|
||||
assert(
|
||||
gearUpdateShield.appearance?.layers?.sh?.variant === 'kit' &&
|
||||
gearUpdateShield.appearance?.layers?.sh?.color === 'lgld',
|
||||
`Expected sh=kit:lgld (Holy Monarch), got ${JSON.stringify(gearUpdateShield.appearance?.layers?.sh)}`,
|
||||
)
|
||||
|
||||
// 2. Equip Crown (cred) + Studded Leather (cblu) + Short Staff (sst)
|
||||
const gearUpdate1 = await cdp.evalJs<{
|
||||
changed: boolean
|
||||
weaponClass: string
|
||||
members: number
|
||||
key: string
|
||||
appearance: any
|
||||
}>(`(async () => {
|
||||
const hud = window.__d2webHudInstance
|
||||
const scene = window.__d2webActScene
|
||||
const act = window.__d2webAct
|
||||
hud.inventory.equipped = {
|
||||
helm: {
|
||||
id: 'red-crown',
|
||||
code: 'crn',
|
||||
name: 'Ruby Crown',
|
||||
nameZh: '红宝石皇冠',
|
||||
invFile: 'invcrn',
|
||||
quality: 'magic',
|
||||
chrtransform: 'cred',
|
||||
identified: true,
|
||||
invWidth: 2,
|
||||
invHeight: 2,
|
||||
allowedSlots: ['helm'],
|
||||
stats: [],
|
||||
},
|
||||
armor: {
|
||||
id: 'blue-stu',
|
||||
code: 'stu',
|
||||
name: 'Cobalt Studded Leather',
|
||||
nameZh: '钴蓝镶嵌皮甲',
|
||||
invFile: 'invstu',
|
||||
quality: 'magic',
|
||||
chrtransform: 'cblu',
|
||||
identified: true,
|
||||
invWidth: 2,
|
||||
invHeight: 3,
|
||||
allowedSlots: ['armor'],
|
||||
stats: [],
|
||||
},
|
||||
weapon1: {
|
||||
id: 'short-staff',
|
||||
code: 'sst',
|
||||
name: 'Short Staff',
|
||||
nameZh: '短棍',
|
||||
invFile: 'invsst',
|
||||
quality: 'normal',
|
||||
identified: true,
|
||||
invWidth: 1,
|
||||
invHeight: 3,
|
||||
allowedSlots: ['weapon1'],
|
||||
stats: [],
|
||||
},
|
||||
}
|
||||
const changed = await scene.syncAppearance()
|
||||
return {
|
||||
changed,
|
||||
weaponClass: act.weaponClass,
|
||||
members: act.characterMembers,
|
||||
key: act.characterAppearanceKey,
|
||||
appearance: act.characterAppearance,
|
||||
}
|
||||
})()`)
|
||||
|
||||
assert(gearUpdate1.changed === true, 'Expected syncAppearance() to return true after equipping Crown + Studded Leather + Short Staff')
|
||||
assert(gearUpdate1.weaponClass === 'stf', `Expected weaponClass=stf, got ${String(gearUpdate1.weaponClass)}`)
|
||||
assert(
|
||||
gearUpdate1.appearance.layers.hd.variant === 'crn' &&
|
||||
gearUpdate1.appearance.layers.hd.color === 'cred',
|
||||
'Ruby Crown should resolve to crn:cred',
|
||||
)
|
||||
assert(
|
||||
gearUpdate1.appearance.layers.tr.variant === 'lit' &&
|
||||
gearUpdate1.appearance.layers.tr.color === 'cblu' &&
|
||||
gearUpdate1.appearance.layers.lg.variant === 'med' &&
|
||||
gearUpdate1.appearance.layers.ra.variant === 'med' &&
|
||||
gearUpdate1.appearance.layers.la.variant === 'lit',
|
||||
`Cobalt Studded Leather should resolve to tr=lit:cblu, lg=med, ra=med, la=lit, got ${JSON.stringify(gearUpdate1.appearance.layers)}`,
|
||||
)
|
||||
assert(
|
||||
gearUpdate1.appearance.layers.rh.variant === 'bst' &&
|
||||
gearUpdate1.appearance.layers.sh.variant === null,
|
||||
`Short Staff (sst -> alternateGfx=bst) should resolve to rh=bst and sh=null, got ${JSON.stringify(gearUpdate1.appearance.layers)}`,
|
||||
)
|
||||
|
||||
// 3. Equip Bow (Short Bow) and verify wclass='bow' and lh='sbw'
|
||||
const gearUpdateBow = await cdp.evalJs<{
|
||||
changed: boolean
|
||||
weaponClass: string
|
||||
members: number
|
||||
appearance: any
|
||||
}>(`(async () => {
|
||||
const hud = window.__d2webHudInstance
|
||||
const scene = window.__d2webActScene
|
||||
const act = window.__d2webAct
|
||||
hud.inventory.equipped = {
|
||||
weapon1: {
|
||||
id: 'test-bow',
|
||||
code: 'sbw',
|
||||
name: 'Short Bow',
|
||||
nameZh: '短弓',
|
||||
invFile: 'invsbw',
|
||||
quality: 'normal',
|
||||
identified: true,
|
||||
invWidth: 2,
|
||||
invHeight: 3,
|
||||
allowedSlots: ['weapon1'],
|
||||
stats: [],
|
||||
},
|
||||
}
|
||||
const changed = await scene.syncAppearance()
|
||||
return {
|
||||
changed,
|
||||
weaponClass: act.weaponClass,
|
||||
members: act.characterMembers,
|
||||
appearance: act.characterAppearance,
|
||||
}
|
||||
})()`)
|
||||
|
||||
assert(gearUpdateBow.changed === true, 'Expected syncAppearance() to return true when equipping Short Bow')
|
||||
assert(gearUpdateBow.weaponClass === 'bow', `Expected weaponClass=bow, got ${String(gearUpdateBow.weaponClass)}`)
|
||||
assert(gearUpdateBow.appearance.layers.lh?.variant === 'sbw', 'Short Bow must equip on LH layer')
|
||||
assert(gearUpdateBow.appearance.layers.rh?.variant === null, 'Short Bow must leave RH layer null')
|
||||
|
||||
assert(consoleErrors.length === 0, `Browser console/page errors: ${consoleErrors.join('; ')}`)
|
||||
assert(mpqRequests.length === 0, `Forbidden runtime MPQ requests: ${mpqRequests.join('; ')}`)
|
||||
console.log('✓ Live WebGL Browser E2E appearance & dye verification passed with 0 errors and 0 MPQ requests.')
|
||||
} finally {
|
||||
cdp.close()
|
||||
}
|
||||
} finally {
|
||||
if (chrome) chrome.kill('SIGKILL')
|
||||
if (server) server.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await runUnitAndCompositorChecks()
|
||||
await runBrowserE2ECheck()
|
||||
console.log('\n🎉 ALL SORCERESS APPEARANCE & COLOR VERIFICATION CHECKS PASSED!')
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -77,6 +77,17 @@ export class ActorAnimator {
|
|||
this.isFinished = clip.frames <= 1 && !clip.loop
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize the active clip metadata in place (e.g. after a dynamic paperdoll
|
||||
* or weapon-class re-composite) while preserving the current playback frame.
|
||||
*/
|
||||
syncClip(clip: AnimClipMeta): void {
|
||||
this.currentClip = clip
|
||||
if (this.index >= clip.frames) {
|
||||
this.index = Math.max(0, clip.frames - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance one 25Hz simulation tick.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -96,6 +96,26 @@ export interface ArmorBase extends ItemBase {
|
|||
readonly quest?: number | undefined
|
||||
readonly questDiffCheck?: boolean | undefined
|
||||
readonly vendorColumns?: Readonly<Record<number, VendorItemColumns>> | undefined
|
||||
/** 3-letter character paperdoll graphic token from alternategfx column (fallback to code). */
|
||||
readonly alternategfx?: string | undefined
|
||||
/** Composite layer index from component column (0=hd, 7=sh, 16=body armor/gloves/boots/belt). */
|
||||
readonly component?: number | undefined
|
||||
/** Armor component weight (0=lit, 1=med, 2=hvy) for Right Arm. */
|
||||
readonly rArm?: number | undefined
|
||||
/** Armor component weight (0=lit, 1=med, 2=hvy) for Left Arm. */
|
||||
readonly lArm?: number | undefined
|
||||
/** Armor component weight (0=lit, 1=med, 2=hvy) for Torso. */
|
||||
readonly torso?: number | undefined
|
||||
/** Armor component weight (0=lit, 1=med, 2=hvy) for Legs. */
|
||||
readonly legs?: number | undefined
|
||||
/** Armor component weight (0=lit, 1=med, 2=hvy) for Right Shoulder Pad (s1). */
|
||||
readonly rSPad?: number | undefined
|
||||
/** Armor component weight (0=lit, 1=med, 2=hvy) for Left Shoulder Pad (s2). */
|
||||
readonly lSPad?: number | undefined
|
||||
/** Character paperdoll palette shift table index (0..8) from Transform column. */
|
||||
readonly transform?: number | undefined
|
||||
/** Inventory 2D icon palette shift table index (0..8) from InvTrans column. */
|
||||
readonly invTrans?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -240,6 +260,17 @@ export function parseArmorTable(table: D2Table): ArmorTable {
|
|||
const quest = numAt(row, 'quest', 0)
|
||||
const questDiffCheck = strAt(row, 'questdiffcheck') === '1'
|
||||
|
||||
const alternategfx = (strAt(row, 'alternategfx') || code).toLowerCase()
|
||||
const component = numAt(row, 'component', 16)
|
||||
const rArm = numAt(row, 'rArm', 0)
|
||||
const lArm = numAt(row, 'lArm', 0)
|
||||
const torso = numAt(row, 'Torso', 0)
|
||||
const legs = numAt(row, 'Legs', 0)
|
||||
const rSPad = numAt(row, 'rSPad', 0)
|
||||
const lSPad = numAt(row, 'lSPad', 0)
|
||||
const transform = numAt(row, 'Transform', 0)
|
||||
const invTrans = numAt(row, 'InvTrans', 0)
|
||||
|
||||
const vendorColumns: Record<number, VendorItemColumns> = {}
|
||||
for (let vId = 0; vId < VENDOR_COUNT; vId++) {
|
||||
const prefix = VENDOR_COLUMN_PREFIXES[vId]!
|
||||
|
|
@ -301,6 +332,16 @@ export function parseArmorTable(table: D2Table): ArmorTable {
|
|||
quest,
|
||||
questDiffCheck,
|
||||
vendorColumns,
|
||||
alternategfx,
|
||||
component,
|
||||
rArm,
|
||||
lArm,
|
||||
torso,
|
||||
legs,
|
||||
rSPad,
|
||||
lSPad,
|
||||
transform,
|
||||
invTrans,
|
||||
}
|
||||
|
||||
all.push(armor)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,746 @@
|
|||
/**
|
||||
* Dynamic Sorceress (`so`) paperdoll appearance resolver and runtime layer compositor.
|
||||
*
|
||||
* Implements the 1.13c composite layer, weapon class, and palette shift pipeline:
|
||||
* - Resolves equipped `helm`, `armor`, `weapon1`, and `weapon2` into the 10 active
|
||||
* character composite layers (`hd`, `tr`, `lg`, `ra`, `la`, `rh`, `lh`, `sh`, `s1`, `s2`)
|
||||
* and active weapon animation class (`hth`, `1hs`, `1ht`, `stf`, `bow`, `xbw`, `2hs`, `2ht`).
|
||||
* - Resolves per-item color shifts (`Transform` 1..8 x `Colors.txt` 0..20) across Unique, Set,
|
||||
* Magic/Rare/Crafted (suffixes before prefixes), Socketed Gem, and AutoMagic items.
|
||||
* - Composites multi-mode DCC layers in COF priority order with 256-byte palette remap tables
|
||||
* and packs all 11 animation modes into an R8 indexed atlas page.
|
||||
*/
|
||||
|
||||
import { decodeCof, cofLayerOrder, type CofFile } from '../formats/cof.ts'
|
||||
import { decodeDcc, type DccFile } from '../formats/dcc.ts'
|
||||
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
||||
import type { AnimClipMeta } from './actor-animator.ts'
|
||||
import { COMPONENTS, dir64ToDcc } from './character.ts'
|
||||
import type { DropTables } from './drop-pipeline.ts'
|
||||
import { COLOR_CODES } from './gems-data.ts'
|
||||
import type { Item } from './items.ts'
|
||||
import { resolveItemColorTransform } from '../ui/item-bridge.ts'
|
||||
import type { EquipSlotId, UiItemQuality } from '../ui/inventory.ts'
|
||||
|
||||
/** All 10 active paperdoll component layers used by Sorceress animations. */
|
||||
export const SORCERESS_COMPONENTS = [
|
||||
'hd',
|
||||
'tr',
|
||||
'lg',
|
||||
'ra',
|
||||
'la',
|
||||
'rh',
|
||||
'lh',
|
||||
'sh',
|
||||
's1',
|
||||
's2',
|
||||
] as const
|
||||
|
||||
export type SorceressComponent = (typeof SORCERESS_COMPONENTS)[number]
|
||||
|
||||
/** Standard 11 player animation modes in pack order. */
|
||||
export const SORCERESS_PLAYER_MODES = [
|
||||
'wl',
|
||||
'nu',
|
||||
'rn',
|
||||
'a1',
|
||||
'a2',
|
||||
'sc',
|
||||
'gh',
|
||||
'dt',
|
||||
'dd',
|
||||
'tn',
|
||||
'tw',
|
||||
] as const
|
||||
|
||||
const LOOPING_PLAYER_MODES = new Set<string>(['nu', 'wl', 'rn', 'tn', 'tw'])
|
||||
|
||||
const ARMOR_TIERS: readonly ('lit' | 'med' | 'hvy')[] = ['lit', 'med', 'hvy']
|
||||
|
||||
const VALID_HEAD_VARIANTS = new Set<string>([
|
||||
'lit',
|
||||
'bhm',
|
||||
'cap',
|
||||
'crn',
|
||||
'fhl',
|
||||
'ghm',
|
||||
'hlm',
|
||||
'msk',
|
||||
'skp',
|
||||
])
|
||||
|
||||
/** Visual specification for a single paperdoll layer. */
|
||||
export interface LayerAppearance {
|
||||
/** 3-letter DCC variant code (e.g. `'lit'`, `'cap'`, `'hvy'`, `'ob4'`, `'kit'`), or `null` if unequipped. */
|
||||
readonly variant: string | null
|
||||
/** Palette shift table index from `Armor.txt` / `Weapons.txt` `Transform` column (`0..8`). */
|
||||
readonly transform: number
|
||||
/** `Colors.txt` 4-letter code (e.g. `'cgrn'`, `'dpur'`, `'whit'`), or `null` if unshifted. */
|
||||
readonly color: string | null
|
||||
}
|
||||
|
||||
/** Complete resolved visual state of the Sorceress paperdoll across all layers and weapon class. */
|
||||
export interface SorceressAppearanceSpec {
|
||||
/** Active animation weapon class (`'hth'`, `'1hs'`, `'1ht'`, `'stf'`, `'bow'`, `'xbw'`, `'2hs'`, `'2ht'`). */
|
||||
readonly wclass: string
|
||||
/** Per-component visual variant and dye configuration. */
|
||||
readonly layers: Readonly<Record<SorceressComponent, LayerAppearance>>
|
||||
}
|
||||
|
||||
/** Minimal item interface required to resolve equipped paperdoll appearance. */
|
||||
export interface ResolvableEquippedItem {
|
||||
readonly code?: string | undefined
|
||||
readonly name?: string | undefined
|
||||
readonly quality?: UiItemQuality | undefined
|
||||
readonly chrtransform?: string | undefined
|
||||
readonly invtransform?: string | undefined
|
||||
readonly identified?: boolean | undefined
|
||||
readonly isGambleMystery?: boolean | undefined
|
||||
readonly rawItem?: Item | undefined
|
||||
}
|
||||
|
||||
/** Manifest structure for `samples/d2-packs/chars/so/manifest.json`. */
|
||||
export interface SorceressLayerPackManifest {
|
||||
readonly schema: 1
|
||||
readonly metaFile: string
|
||||
readonly cofs: Readonly<Record<string, readonly [number, number]>>
|
||||
readonly palettes: Readonly<Record<number, readonly [number, number]>>
|
||||
readonly animClips: Readonly<
|
||||
Record<string, { readonly speed: number; readonly events: readonly (readonly [number, number])[] }>
|
||||
>
|
||||
readonly layers: Readonly<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
readonly file: string
|
||||
readonly dccs: Readonly<Record<string, readonly [number, number]>>
|
||||
}
|
||||
>
|
||||
>
|
||||
}
|
||||
|
||||
/** Result of compositing and shelf-packing all 11 Sorceress animation modes into an R8 atlas. */
|
||||
export interface CompositedSorceressR8 {
|
||||
readonly key: string
|
||||
readonly spec: SorceressAppearanceSpec
|
||||
readonly r8: Uint8Array
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly directions: number
|
||||
readonly walk: number
|
||||
readonly stand: number
|
||||
readonly members: number
|
||||
readonly clips: Readonly<Record<string, AnimClipMeta>>
|
||||
readonly groups: readonly (readonly [number, number, number, number, number, number])[][]
|
||||
}
|
||||
|
||||
/** Pluggable resource loader for browser `fetch` or Node test runners. */
|
||||
export interface CharLayerAssetLoader {
|
||||
loadJson(relPath: string): Promise<unknown>
|
||||
loadBin(relPath: string): Promise<Uint8Array>
|
||||
}
|
||||
|
||||
function tierCode(value: number | undefined): 'lit' | 'med' | 'hvy' {
|
||||
const idx = Math.max(0, Math.min(2, Math.trunc(value ?? 0)))
|
||||
return ARMOR_TIERS[idx] ?? 'lit'
|
||||
}
|
||||
|
||||
function isItemUnidentified(item: ResolvableEquippedItem): boolean {
|
||||
return Boolean(
|
||||
item.identified === false ||
|
||||
item.isGambleMystery === true ||
|
||||
item.rawItem?.identified === false ||
|
||||
item.rawItem?.isGambleMystery === true ||
|
||||
(item.rawItem?.flags !== undefined && item.rawItem.flags.identified === false),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveEquippedChrColor(
|
||||
item: ResolvableEquippedItem,
|
||||
code: string,
|
||||
dropTables: DropTables,
|
||||
): string | null {
|
||||
if (isItemUnidentified(item)) {
|
||||
return null
|
||||
}
|
||||
if (item.chrtransform && COLOR_CODES.includes(item.chrtransform as any)) {
|
||||
return item.chrtransform
|
||||
}
|
||||
const resolved = resolveItemColorTransform(
|
||||
item.rawItem ?? ({
|
||||
code,
|
||||
name: item.name,
|
||||
quality: item.quality,
|
||||
identified: item.identified,
|
||||
runewordRunes: (item as any).runewordRunes,
|
||||
} as any),
|
||||
item.quality ?? 'normal',
|
||||
code,
|
||||
dropTables,
|
||||
)
|
||||
if (resolved.chrtransform && COLOR_CODES.includes(resolved.chrtransform as any)) {
|
||||
return resolved.chrtransform
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a deterministic string key uniquely identifying a Sorceress appearance state.
|
||||
*/
|
||||
export function appearanceKey(spec: SorceressAppearanceSpec): string {
|
||||
const parts: string[] = [`wc=${spec.wclass}`]
|
||||
for (const comp of SORCERESS_COMPONENTS) {
|
||||
const layer = spec.layers[comp]
|
||||
const v = layer.variant ?? '-'
|
||||
const t = layer.variant && layer.color && layer.transform > 0 ? String(layer.transform) : '0'
|
||||
const c = layer.variant && layer.color && layer.transform > 0 ? layer.color : '-'
|
||||
parts.push(`${comp}=${v}:${t}:${c}`)
|
||||
}
|
||||
return parts.join('|')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the resolved appearance is the baseline unarmed `lit` state
|
||||
* (matching the static pre-baked `char-so.r8`).
|
||||
*/
|
||||
export function isDefaultUnarmedAppearance(spec: SorceressAppearanceSpec): boolean {
|
||||
if (spec.wclass !== 'hth') return false
|
||||
for (const comp of ['hd', 'tr', 'lg', 'ra', 'la'] as const) {
|
||||
const l = spec.layers[comp]
|
||||
if (l.variant !== 'lit') return false
|
||||
if (l.transform > 0 && l.color !== null) return false
|
||||
}
|
||||
for (const comp of ['rh', 'lh', 'sh', 's1', 's2'] as const) {
|
||||
if (spec.layers[comp].variant !== null) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Sorceress's 10 paperdoll layers, weapon class, and color shifts
|
||||
* from the currently equipped items.
|
||||
*/
|
||||
export function resolveSorceressAppearance(
|
||||
equipped: Partial<Record<EquipSlotId, ResolvableEquippedItem | null | undefined>>,
|
||||
dropTables: DropTables,
|
||||
): SorceressAppearanceSpec {
|
||||
const layers: Record<SorceressComponent, LayerAppearance> = {
|
||||
hd: { variant: 'lit', transform: 0, color: null },
|
||||
tr: { variant: 'lit', transform: 0, color: null },
|
||||
lg: { variant: 'lit', transform: 0, color: null },
|
||||
ra: { variant: 'lit', transform: 0, color: null },
|
||||
la: { variant: 'lit', transform: 0, color: null },
|
||||
rh: { variant: null, transform: 0, color: null },
|
||||
lh: { variant: null, transform: 0, color: null },
|
||||
sh: { variant: null, transform: 0, color: null },
|
||||
s1: { variant: null, transform: 0, color: null },
|
||||
s2: { variant: null, transform: 0, color: null },
|
||||
}
|
||||
|
||||
// 1. Helm -> HD (Circlets keep default 'lit' head and do not dye hair/skin)
|
||||
const helmItem = equipped.helm
|
||||
const helmCode = helmItem?.code?.trim()
|
||||
if (helmItem && helmCode) {
|
||||
const helmBase = dropTables.armor.get(helmCode)
|
||||
if (helmBase) {
|
||||
const isCirclet =
|
||||
helmBase.type === 'circ' ||
|
||||
helmBase.alternategfx === 'cir' ||
|
||||
helmBase.alternategfx === 'lit' ||
|
||||
dropTables.isA(helmBase.type, 'circ')
|
||||
if (!isCirclet) {
|
||||
const rawVar = (helmBase.alternategfx || helmBase.code).toLowerCase()
|
||||
const variant = VALID_HEAD_VARIANTS.has(rawVar) ? rawVar : 'lit'
|
||||
const transform = helmBase.transform ?? 0
|
||||
const color = transform > 0 ? resolveEquippedChrColor(helmItem, helmCode, dropTables) : null
|
||||
layers.hd = { variant, transform, color }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Body Armor -> TR, LG, RA, LA, S1, S2
|
||||
const armorItem = equipped.armor
|
||||
const armorCode = armorItem?.code?.trim()
|
||||
if (armorItem && armorCode) {
|
||||
const armorBase = dropTables.armor.get(armorCode)
|
||||
if (armorBase) {
|
||||
const transform = armorBase.transform ?? 0
|
||||
const color = transform > 0 ? resolveEquippedChrColor(armorItem, armorCode, dropTables) : null
|
||||
layers.tr = { variant: tierCode(armorBase.torso), transform, color }
|
||||
layers.lg = { variant: tierCode(armorBase.legs), transform, color }
|
||||
layers.ra = { variant: tierCode(armorBase.rArm), transform, color }
|
||||
layers.la = { variant: tierCode(armorBase.lArm), transform, color }
|
||||
layers.s1 = { variant: tierCode(armorBase.rSPad), transform, color }
|
||||
layers.s2 = { variant: tierCode(armorBase.lSPad), transform, color }
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Weapon1 & Weapon2 -> wclass, RH (weapons/orbs/staves/crossbows), LH (bows), SH (shields)
|
||||
let wclass = 'hth'
|
||||
let hasTwoHandedWeapon = false
|
||||
const handSlots = [equipped.weapon1, equipped.weapon2]
|
||||
|
||||
for (const handItem of handSlots) {
|
||||
const rawCode = handItem?.code?.trim()
|
||||
if (!handItem || !rawCode) continue
|
||||
const code = rawCode === '7wa' && handItem.name === 'Colossus Blade' ? '7gd' : rawCode
|
||||
const weapBase = dropTables.weapons.get(code)
|
||||
if (weapBase) {
|
||||
const activeWc = weapBase.twoHanded
|
||||
? (weapBase.twoHandedWClass || weapBase.wclass)
|
||||
: weapBase.wclass
|
||||
if (activeWc) {
|
||||
wclass = activeWc.toLowerCase()
|
||||
}
|
||||
if (weapBase.twoHanded || wclass === 'bow' || wclass === 'xbw' || wclass === 'stf' || wclass === '2hs' || wclass === '2ht') {
|
||||
hasTwoHandedWeapon = true
|
||||
}
|
||||
const rawVar = (weapBase.alternateGfx || weapBase.code).toLowerCase()
|
||||
const transform = weapBase.transform ?? 0
|
||||
const color = transform > 0 ? resolveEquippedChrColor(handItem, code, dropTables) : null
|
||||
if (weapBase.component === 6 || wclass === 'bow') {
|
||||
layers.lh = { variant: rawVar, transform, color }
|
||||
} else if (weapBase.component === 5) {
|
||||
layers.rh = { variant: rawVar, transform, color }
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check shield in either hand slot when not wielding a two-handed weapon
|
||||
if (!hasTwoHandedWeapon) {
|
||||
for (const handItem of handSlots) {
|
||||
const code = handItem?.code?.trim()
|
||||
if (!handItem || !code) continue
|
||||
const shieldBase = dropTables.armor.get(code)
|
||||
if (shieldBase && (shieldBase.component === 7 || shieldBase.type === 'shie' || dropTables.isA(shieldBase.type, 'shie'))) {
|
||||
const rawVar = (shieldBase.alternategfx || shieldBase.code).toLowerCase()
|
||||
const transform = shieldBase.transform ?? 0
|
||||
const color = transform > 0 ? resolveEquippedChrColor(handItem, code, dropTables) : null
|
||||
layers.sh = { variant: rawVar, transform, color }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { wclass, layers }
|
||||
}
|
||||
|
||||
/**
|
||||
* Blit one DCC layer frame onto a destination canvas, optionally applying a 256-byte palette remap table.
|
||||
*/
|
||||
function blitWithRemap(
|
||||
target: { indices: Uint8Array; mask: Uint8Array; width: number; height: number },
|
||||
source: SpriteFrame,
|
||||
atX: number,
|
||||
atY: number,
|
||||
remap: Uint8Array | null,
|
||||
): void {
|
||||
for (let y = 0; y < source.height; y += 1) {
|
||||
const ty = atY + y
|
||||
if (ty < 0 || ty >= target.height) continue
|
||||
for (let x = 0; x < source.width; x += 1) {
|
||||
const at = y * source.width + x
|
||||
if (source.mask[at] === 0) continue
|
||||
const tx = atX + x
|
||||
if (tx < 0 || tx >= target.width) continue
|
||||
const to = ty * target.width + tx
|
||||
const rawIdx = source.indices[at]!
|
||||
target.indices[to] = remap !== null ? (remap[rawIdx] ?? rawIdx) : rawIdx
|
||||
target.mask[to] = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_ATLAS_PAGE_SIZE = 4096
|
||||
|
||||
/**
|
||||
* Shelf-pack all frames of a SpriteSheet into a single raw 8-bit index buffer (`gl.R8`).
|
||||
* Index 0 represents transparency; opaque pixels with index 0 are remapped to `opaqueBlack` (172).
|
||||
*/
|
||||
export function packAppearanceSheetToR8(
|
||||
sheet: SpriteSheet,
|
||||
opaqueBlack = 172,
|
||||
): {
|
||||
readonly r8: Uint8Array
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly groups: [number, number, number, number, number, number][][]
|
||||
} {
|
||||
const frames = sheet.groups.flatMap(g => g.frames)
|
||||
const widest = Math.max(1, ...frames.map(f => f.width))
|
||||
const area = frames.reduce((sum, f) => sum + (f.width + 1) * (f.height + 1), 0)
|
||||
let pageWidth = Math.min(MAX_ATLAS_PAGE_SIZE, Math.max(widest + 2, 512))
|
||||
while (pageWidth < MAX_ATLAS_PAGE_SIZE && pageWidth * pageWidth < area * 1.15) {
|
||||
pageWidth *= 2
|
||||
}
|
||||
pageWidth = Math.min(MAX_ATLAS_PAGE_SIZE, pageWidth)
|
||||
|
||||
let cursorX = 0
|
||||
let shelfY = 0
|
||||
let shelfHeight = 0
|
||||
const placements: { x: number; y: number; width: number; height: number }[] = []
|
||||
|
||||
for (const frame of frames) {
|
||||
const w = Math.max(1, Math.min(frame.width, pageWidth))
|
||||
const h = Math.max(1, Math.min(frame.height, MAX_ATLAS_PAGE_SIZE))
|
||||
if (cursorX + w > pageWidth) {
|
||||
shelfY += shelfHeight + 1
|
||||
shelfHeight = 0
|
||||
cursorX = 0
|
||||
}
|
||||
placements.push({ x: cursorX, y: shelfY, width: w, height: h })
|
||||
cursorX += w + 1
|
||||
shelfHeight = Math.max(shelfHeight, h)
|
||||
}
|
||||
|
||||
const pageHeight = Math.max(1, shelfY + shelfHeight)
|
||||
const r8 = new Uint8Array(pageWidth * pageHeight)
|
||||
|
||||
let idx = 0
|
||||
for (const frame of frames) {
|
||||
const place = placements[idx]!
|
||||
idx += 1
|
||||
for (let row = 0; row < place.height; row += 1) {
|
||||
const fromRow = row * frame.width
|
||||
const toRow = (place.y + row) * pageWidth + place.x
|
||||
for (let col = 0; col < place.width; col += 1) {
|
||||
const srcAt = fromRow + col
|
||||
if (frame.mask[srcAt] === 0) {
|
||||
r8[toRow + col] = 0
|
||||
} else {
|
||||
const val = frame.indices[srcAt]!
|
||||
r8[toRow + col] = val === 0 ? opaqueBlack : val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const groups: [number, number, number, number, number, number][][] = []
|
||||
let consumed = 0
|
||||
for (const group of sheet.groups) {
|
||||
const gList: [number, number, number, number, number, number][] = []
|
||||
for (let i = 0; i < group.frames.length; i += 1) {
|
||||
const p = placements[consumed]!
|
||||
const f = group.frames[i]!
|
||||
consumed += 1
|
||||
gList.push([p.x, p.y, p.width, p.height, f.anchorX ?? 0, f.anchorY ?? 0])
|
||||
}
|
||||
groups.push(gList)
|
||||
}
|
||||
|
||||
return { r8, width: pageWidth, height: pageHeight, groups }
|
||||
}
|
||||
|
||||
function createFetchLoader(packBaseUrl: string): CharLayerAssetLoader {
|
||||
const base = packBaseUrl.replace(/\/+$/, '').replace(/\/chars\/so$/i, '')
|
||||
return {
|
||||
async loadJson(relPath: string): Promise<unknown> {
|
||||
const resp = await fetch(`${base}/chars/so/${relPath}`)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch Sorceress layer JSON ${relPath}: HTTP ${String(resp.status)}`)
|
||||
}
|
||||
return resp.json()
|
||||
},
|
||||
async loadBin(relPath: string): Promise<Uint8Array> {
|
||||
const resp = await fetch(`${base}/chars/so/${relPath}`)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch Sorceress layer binary ${relPath}: HTTP ${String(resp.status)}`)
|
||||
}
|
||||
return new Uint8Array(await resp.arrayBuffer())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand Sorceress paperdoll layer compositor with in-memory COF, Palette, and DCC caching.
|
||||
*/
|
||||
export class SorceressLayerCompositor {
|
||||
private readonly loader: CharLayerAssetLoader
|
||||
private initPromise: Promise<void> | null = null
|
||||
private manifest: SorceressLayerPackManifest | null = null
|
||||
private metaBin: Uint8Array | null = null
|
||||
private readonly cofCache = new Map<string, CofFile>()
|
||||
private readonly layerBinCache = new Map<string, Promise<Uint8Array>>()
|
||||
private readonly dccCache = new Map<string, DccFile>()
|
||||
|
||||
constructor(packBaseOrLoader: string | CharLayerAssetLoader = 'samples/d2-packs') {
|
||||
this.loader =
|
||||
typeof packBaseOrLoader === 'string'
|
||||
? createFetchLoader(packBaseOrLoader)
|
||||
: packBaseOrLoader
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (this.manifest !== null && this.metaBin !== null) return
|
||||
if (this.initPromise === null) {
|
||||
this.initPromise = (async () => {
|
||||
const [manifestRaw, metaBytes] = await Promise.all([
|
||||
this.loader.loadJson('manifest.json'),
|
||||
this.loader.loadBin('meta.bin'),
|
||||
])
|
||||
this.manifest = manifestRaw as SorceressLayerPackManifest
|
||||
this.metaBin = metaBytes
|
||||
})()
|
||||
}
|
||||
await this.initPromise
|
||||
}
|
||||
|
||||
private getCof(cofToken: string): CofFile | null {
|
||||
const upper = cofToken.toUpperCase()
|
||||
const cached = this.cofCache.get(upper)
|
||||
if (cached !== undefined) return cached
|
||||
if (!this.manifest || !this.metaBin) return null
|
||||
const range = this.manifest.cofs[upper]
|
||||
if (!range) return null
|
||||
const [offset, length] = range
|
||||
const decoded = decodeCof(this.metaBin.subarray(offset, offset + length))
|
||||
this.cofCache.set(upper, decoded)
|
||||
return decoded
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the 256-byte palette remap table for `(transform, colorCode)`.
|
||||
*/
|
||||
getPaletteRemap(transform: number, colorCode: string | null): Uint8Array | null {
|
||||
if (transform <= 0 || !colorCode || !this.manifest || !this.metaBin) return null
|
||||
const colorIdx = COLOR_CODES.indexOf(colorCode as any)
|
||||
if (colorIdx < 0 || colorIdx >= 21) return null
|
||||
const range = this.manifest.palettes[transform]
|
||||
if (!range) return null
|
||||
const [palOffset, palLen] = range
|
||||
if (palLen < (colorIdx + 1) * 256) return null
|
||||
const start = palOffset + colorIdx * 256
|
||||
return this.metaBin.subarray(start, start + 256)
|
||||
}
|
||||
|
||||
private async getLayerBin(compVariantKey: string): Promise<Uint8Array | null> {
|
||||
if (!this.manifest) return null
|
||||
const entry = this.manifest.layers[compVariantKey]
|
||||
if (!entry) return null
|
||||
let p = this.layerBinCache.get(compVariantKey)
|
||||
if (!p) {
|
||||
p = this.loader.loadBin(entry.file)
|
||||
this.layerBinCache.set(compVariantKey, p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
private async getDcc(
|
||||
comp: SorceressComponent,
|
||||
variant: string,
|
||||
mode: string,
|
||||
wclass: string,
|
||||
): Promise<DccFile | null> {
|
||||
if (!this.manifest) return null
|
||||
const compVariantKey = `${comp}-${variant.toLowerCase()}`
|
||||
const layerEntry = this.manifest.layers[compVariantKey]
|
||||
if (!layerEntry) return null
|
||||
|
||||
const modeWclass = `${mode.toUpperCase()}${wclass.toUpperCase()}`
|
||||
const range = layerEntry.dccs[modeWclass]
|
||||
if (!range) return null
|
||||
|
||||
const cacheKey = `${compVariantKey}:${modeWclass}`
|
||||
const cached = this.dccCache.get(cacheKey)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
const bin = await this.getLayerBin(compVariantKey)
|
||||
if (!bin) return null
|
||||
const [offset, length] = range
|
||||
const decoded = decodeDcc(bin.subarray(offset, offset + length))
|
||||
this.dccCache.set(cacheKey, decoded)
|
||||
return decoded
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite all 11 Sorceress animation modes for the given `SorceressAppearanceSpec`
|
||||
* and pack them into a single R8 indexed atlas buffer.
|
||||
*/
|
||||
async composite(spec: SorceressAppearanceSpec): Promise<CompositedSorceressR8> {
|
||||
await this.ensureInitialized()
|
||||
const manifest = this.manifest!
|
||||
|
||||
// Pre-fetch all required <comp>-<variant>.bin files in parallel
|
||||
const neededLayerKeys = new Set<string>()
|
||||
for (const comp of SORCERESS_COMPONENTS) {
|
||||
const v = spec.layers[comp].variant
|
||||
if (v) {
|
||||
const key = `${comp}-${v.toLowerCase()}`
|
||||
if (manifest.layers[key]) {
|
||||
neededLayerKeys.add(key)
|
||||
} else if (comp === 'hd' && manifest.layers['hd-lit']) {
|
||||
neededLayerKeys.add('hd-lit')
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all([...neededLayerKeys].map(k => this.getLayerBin(k)))
|
||||
|
||||
const allGroups: { frames: SpriteFrame[] }[] = []
|
||||
const clips: Record<string, AnimClipMeta> = {}
|
||||
let groupOffset = 0
|
||||
let totalMembers = 0
|
||||
|
||||
for (const mode of SORCERESS_PLAYER_MODES) {
|
||||
// Per D2Composit.cpp:68-75, death (dt) and corpse (dd) always use 'hth'
|
||||
const requestedWc = mode === 'dt' || mode === 'dd' ? 'hth' : spec.wclass
|
||||
let activeWc = requestedWc
|
||||
let effectiveMode: string = mode
|
||||
if (mode === 'a2' && (activeWc === 'hth' || !this.getCof(`SOA2${activeWc.toUpperCase()}`))) {
|
||||
effectiveMode = 'a1'
|
||||
}
|
||||
let cofToken = `SO${effectiveMode.toUpperCase()}${activeWc.toUpperCase()}`
|
||||
let cof = this.getCof(cofToken)
|
||||
if (!cof && activeWc !== 'hth') {
|
||||
activeWc = 'hth'
|
||||
cofToken = `SO${effectiveMode.toUpperCase()}HTH`
|
||||
cof = this.getCof(cofToken)
|
||||
}
|
||||
if (!cof) {
|
||||
throw new Error(`Missing Sorceress COF descriptor for ${cofToken}`)
|
||||
}
|
||||
|
||||
// Resolve DCC + palette remap for each layer in the COF
|
||||
const layerEntries = await Promise.all(
|
||||
cof.layers.map(async (cofLayer) => {
|
||||
const compName = COMPONENTS[cofLayer.type] as SorceressComponent | undefined
|
||||
if (!compName || !SORCERESS_COMPONENTS.includes(compName)) {
|
||||
return { dcc: null, remap: null }
|
||||
}
|
||||
// In dt/dd modes, weapons and shields are dropped (unarmed death animation)
|
||||
if ((mode === 'dt' || mode === 'dd') && (compName === 'rh' || compName === 'lh' || compName === 'sh')) {
|
||||
return { dcc: null, remap: null }
|
||||
}
|
||||
const layerSpec = spec.layers[compName]
|
||||
if (!layerSpec || !layerSpec.variant) {
|
||||
return { dcc: null, remap: null }
|
||||
}
|
||||
|
||||
const layerWc =
|
||||
cofLayer.weaponClass && cofLayer.weaponClass.trim().length > 0
|
||||
? cofLayer.weaponClass.trim().toLowerCase()
|
||||
: activeWc
|
||||
|
||||
let dcc = await this.getDcc(compName, layerSpec.variant, effectiveMode, layerWc)
|
||||
if (!dcc && layerWc !== activeWc) {
|
||||
dcc = await this.getDcc(compName, layerSpec.variant, effectiveMode, activeWc)
|
||||
}
|
||||
if (!dcc && activeWc !== 'hth') {
|
||||
dcc = await this.getDcc(compName, layerSpec.variant, effectiveMode, 'hth')
|
||||
}
|
||||
if (!dcc && compName === 'hd' && layerSpec.variant !== 'lit') {
|
||||
dcc = await this.getDcc('hd', 'lit', effectiveMode, layerWc)
|
||||
}
|
||||
if (!dcc) {
|
||||
return { dcc: null, remap: null }
|
||||
}
|
||||
const remap = this.getPaletteRemap(layerSpec.transform, layerSpec.color)
|
||||
return { dcc, remap }
|
||||
}),
|
||||
)
|
||||
|
||||
for (const entry of layerEntries) {
|
||||
if (entry.dcc !== null) totalMembers += 1
|
||||
}
|
||||
|
||||
for (let direction = 0; direction < cof.numberOfDirections; direction += 1) {
|
||||
const dir64 = Math.round((direction * 64) / cof.numberOfDirections)
|
||||
const frames: SpriteFrame[] = []
|
||||
|
||||
for (let frameIdx = 0; frameIdx < cof.framesPerDirection; frameIdx += 1) {
|
||||
let left = Number.POSITIVE_INFINITY
|
||||
let top = Number.POSITIVE_INFINITY
|
||||
let right = Number.NEGATIVE_INFINITY
|
||||
let bottom = Number.NEGATIVE_INFINITY
|
||||
let placedCount = 0
|
||||
|
||||
for (let l = 0; l < cof.layers.length; l += 1) {
|
||||
const sprite = layerEntries[l]?.dcc
|
||||
if (!sprite) continue
|
||||
const dccDir = dir64ToDcc(dir64, sprite.directions.length)
|
||||
const layerDir = sprite.directions[dccDir]
|
||||
const frame = layerDir?.frames[frameIdx]
|
||||
if (!frame) continue
|
||||
left = Math.min(left, layerDir!.box.left)
|
||||
top = Math.min(top, layerDir!.box.top)
|
||||
right = Math.max(right, layerDir!.box.left + layerDir!.box.width)
|
||||
bottom = Math.max(bottom, layerDir!.box.top + layerDir!.box.height)
|
||||
placedCount += 1
|
||||
}
|
||||
|
||||
if (placedCount === 0) {
|
||||
frames.push({
|
||||
width: 1,
|
||||
height: 1,
|
||||
indices: new Uint8Array(1),
|
||||
mask: new Uint8Array(1),
|
||||
anchorX: 0,
|
||||
anchorY: 0,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const boxLeft = Math.round(left)
|
||||
const boxTop = Math.round(top)
|
||||
const width = Math.max(1, Math.round(right) - boxLeft)
|
||||
const height = Math.max(1, Math.round(bottom) - boxTop)
|
||||
const composed: SpriteFrame = {
|
||||
width,
|
||||
height,
|
||||
indices: new Uint8Array(width * height),
|
||||
mask: new Uint8Array(width * height),
|
||||
anchorX: boxLeft,
|
||||
anchorY: boxTop,
|
||||
}
|
||||
|
||||
const order = cofLayerOrder(cof, direction, frameIdx)
|
||||
for (const layerIndex of order) {
|
||||
const entry = layerEntries[layerIndex]
|
||||
if (!entry || !entry.dcc) continue
|
||||
const dccDir = dir64ToDcc(dir64, entry.dcc.directions.length)
|
||||
const layerDir = entry.dcc.directions[dccDir]
|
||||
const frame = layerDir?.frames[frameIdx]
|
||||
if (!frame) continue
|
||||
blitWithRemap(
|
||||
composed,
|
||||
frame.frame,
|
||||
Math.round(layerDir!.box.left) - boxLeft,
|
||||
Math.round(layerDir!.box.top) - boxTop,
|
||||
entry.remap,
|
||||
)
|
||||
}
|
||||
frames.push(composed)
|
||||
}
|
||||
allGroups.push({ frames })
|
||||
}
|
||||
|
||||
const animRecord = manifest.animClips[cofToken]
|
||||
const speed = animRecord?.speed ?? 256
|
||||
const events = (animRecord?.events ?? []).map(([f, c]) => ({ f, c }))
|
||||
clips[mode] = {
|
||||
group: groupOffset,
|
||||
directions: cof.numberOfDirections,
|
||||
frames: cof.framesPerDirection,
|
||||
speed,
|
||||
loop: LOOPING_PLAYER_MODES.has(mode),
|
||||
events,
|
||||
}
|
||||
groupOffset += cof.numberOfDirections
|
||||
}
|
||||
|
||||
const packed = packAppearanceSheetToR8({ groups: allGroups, width: null }, 172)
|
||||
return {
|
||||
key: appearanceKey(spec),
|
||||
spec,
|
||||
r8: packed.r8,
|
||||
width: packed.width,
|
||||
height: packed.height,
|
||||
directions: clips.wl?.directions ?? 16,
|
||||
walk: clips.wl?.group ?? 0,
|
||||
stand: clips.nu?.group ?? 16,
|
||||
members: totalMembers,
|
||||
clips,
|
||||
groups: packed.groups,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1234,3 +1234,56 @@ export function getSocketableInfo(code: string | undefined): (GemInfo | RuneInfo
|
|||
const c = code.trim().toLowerCase()
|
||||
return GEM_INFO[c] || RUNE_INFO[c]
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical 1.13c `Colors.txt` color codes (indices 0..20).
|
||||
*/
|
||||
export const COLOR_CODES: readonly string[] = [
|
||||
'whit', // 0: White
|
||||
'lgry', // 1: Light Grey
|
||||
'dgry', // 2: Dark Grey
|
||||
'blac', // 3: Black
|
||||
'lblu', // 4: Light Blue
|
||||
'dblu', // 5: Dark Blue
|
||||
'cblu', // 6: Crystal Blue
|
||||
'lred', // 7: Light Red
|
||||
'dred', // 8: Dark Red
|
||||
'cred', // 9: Crystal Red
|
||||
'lgrn', // 10: Light Green
|
||||
'dgrn', // 11: Dark Green
|
||||
'cgrn', // 12: Crystal Green
|
||||
'lyel', // 13: Light Yellow
|
||||
'dyel', // 14: Dark Yellow
|
||||
'lgld', // 15: Light Gold
|
||||
'dgld', // 16: Dark Gold
|
||||
'lpur', // 17: Light Purple
|
||||
'dpur', // 18: Dark Purple
|
||||
'oran', // 19: Orange
|
||||
'bwht', // 20: Bright White
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Canonical 1.13c `Gems.txt` `transform` column (0..20 index into `Colors.txt`) for all 35 gems.
|
||||
* Runes (`r01..r33`) have empty `transform` in `Gems.txt` and never dye socketed equipment.
|
||||
*/
|
||||
export const GEM_TRANSFORM_BY_CODE: Readonly<Record<string, number>> = {
|
||||
gcv: 18, gfv: 18, gsv: 18, gzv: 18, gpv: 17,
|
||||
gcb: 5, gfb: 5, gsb: 5, glb: 5, gpb: 6,
|
||||
gcg: 11, gfg: 11, gsg: 11, glg: 11, gpg: 12,
|
||||
gcr: 8, gfr: 8, gsr: 8, glr: 8, gpr: 9,
|
||||
gcw: 1, gfw: 1, gsw: 1, glw: 1, gpw: 0,
|
||||
gcy: 14, gfy: 14, gsy: 14, gly: 14, gpy: 13,
|
||||
skc: 2, skf: 2, sku: 2, skl: 2, skz: 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the 4-letter `Colors.txt` code (e.g. `'cgrn'`, `'cred'`, `'lpur'`) for a gem code,
|
||||
* or `undefined` if not a gem.
|
||||
*/
|
||||
export function getGemColorTransform(code: string | undefined): string | undefined {
|
||||
if (!code) return undefined
|
||||
const idx = GEM_TRANSFORM_BY_CODE[code.trim().toLowerCase()]
|
||||
if (idx === undefined) return undefined
|
||||
return COLOR_CODES[idx]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,28 @@ export interface ItemBase {
|
|||
}>> | undefined
|
||||
/** Whether the item can spawn in normal gameplay or vendor stores. */
|
||||
readonly spawnable?: boolean | undefined
|
||||
/** 3-letter character paperdoll graphic token from alternategfx / alternateGfx column. */
|
||||
readonly alternategfx?: string | undefined
|
||||
/** Alias for alternategfx on WeaponBase. */
|
||||
readonly alternateGfx?: string | undefined
|
||||
/** Composite layer index (0=hd, 5=rh, 6=lh, 7=sh, 16=armor/misc). */
|
||||
readonly component?: number | undefined
|
||||
/** Armor component weight (0=lit, 1=med, 2=hvy) for Right Arm. */
|
||||
readonly rArm?: number | undefined
|
||||
/** Armor component weight (0=lit, 1=med, 2=hvy) for Left Arm. */
|
||||
readonly lArm?: number | undefined
|
||||
/** Armor component weight (0=lit, 1=med, 2=hvy) for Torso. */
|
||||
readonly torso?: number | undefined
|
||||
/** Armor component weight (0=lit, 1=med, 2=hvy) for Legs. */
|
||||
readonly legs?: number | undefined
|
||||
/** Armor component weight (0=lit, 1=med, 2=hvy) for Right Shoulder Pad (s1). */
|
||||
readonly rSPad?: number | undefined
|
||||
/** Armor component weight (0=lit, 1=med, 2=hvy) for Left Shoulder Pad (s2). */
|
||||
readonly lSPad?: number | undefined
|
||||
/** Character 3D/composite palette shift file index (0..8) from Transform column. */
|
||||
readonly transform?: number | undefined
|
||||
/** Inventory 2D icon palette shift file index (0..8) from InvTrans column. */
|
||||
readonly invTrans?: number | undefined
|
||||
/** Whether this weapon requires two hands (from Weapons.txt 2handed column). */
|
||||
readonly twoHanded?: boolean | undefined
|
||||
/** Whether a Barbarian can wield this two-handed weapon in one hand (from Weapons.txt 1or2handed column). */
|
||||
|
|
|
|||
|
|
@ -127,6 +127,16 @@ export interface WeaponBase extends ItemBase {
|
|||
readonly quest?: number | undefined
|
||||
readonly questDiffCheck?: boolean | undefined
|
||||
readonly vendorColumns?: Readonly<Record<number, VendorItemColumns>> | undefined
|
||||
/** 3-letter character paperdoll graphic token from alternateGfx column (fallback to code). */
|
||||
readonly alternateGfx?: string | undefined
|
||||
/** Lowercase alias matching ArmorBase.alternategfx. */
|
||||
readonly alternategfx?: string | undefined
|
||||
/** Composite layer index from component column (5=rh, 6=lh, 16=none). */
|
||||
readonly component?: number | undefined
|
||||
/** Character paperdoll palette shift table index (0..8) from Transform column. */
|
||||
readonly transform?: number | undefined
|
||||
/** Inventory 2D icon palette shift table index (0..8) from InvTrans column. */
|
||||
readonly invTrans?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -308,6 +318,12 @@ export function parseWeaponsTable(input: D2Table | Uint8Array | string): WeaponT
|
|||
const quest = parseNumber(getCell(row, 'quest'), 0)
|
||||
const questDiffCheck = getCell(row, 'questdiffcheck') === '1'
|
||||
|
||||
const alternateGfx = (getCell(row, 'alternategfx') || code).toLowerCase()
|
||||
const alternategfx = alternateGfx
|
||||
const component = parseNumber(getCell(row, 'component'), 16)
|
||||
const transform = parseNumber(getCell(row, 'transform'), 0)
|
||||
const invTrans = parseNumber(getCell(row, 'invtrans'), 0)
|
||||
|
||||
const vendorColumns: Record<number, VendorItemColumns> = {}
|
||||
for (let vId = 0; vId < VENDOR_COUNT; vId++) {
|
||||
const prefix = VENDOR_COLUMN_PREFIXES[vId]!
|
||||
|
|
@ -382,6 +398,11 @@ export function parseWeaponsTable(input: D2Table | Uint8Array | string): WeaponT
|
|||
quest,
|
||||
questDiffCheck,
|
||||
vendorColumns,
|
||||
alternateGfx,
|
||||
alternategfx,
|
||||
component,
|
||||
transform,
|
||||
invTrans,
|
||||
}
|
||||
|
||||
all.push(weapon)
|
||||
|
|
|
|||
|
|
@ -83,6 +83,12 @@ import type { AtlasHandle } from '../render/renderer.ts'
|
|||
import { buildBounds, intersects, viewportRect } from '../render/cull.ts'
|
||||
import { RoofFadeController } from '../render/roofs.ts'
|
||||
import { facingToDirection } from '../game/character.ts'
|
||||
import {
|
||||
SorceressLayerCompositor,
|
||||
resolveSorceressAppearance,
|
||||
appearanceKey,
|
||||
type SorceressAppearanceSpec,
|
||||
} from '../game/char-appearance.ts'
|
||||
import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../game/monster-art.ts'
|
||||
export { ADDITIVE_MONSTER_TOKENS } from '../game/monster-art.ts'
|
||||
import { velocityToDccDirection, MISSILE_METAS, type MissileMeta, type MissileFrameTuple } from '../render/missiles-meta.ts'
|
||||
|
|
@ -704,10 +710,14 @@ export interface ActSceneState {
|
|||
heroName?: string
|
||||
/** Bounding box of the player sprite drawn this frame in scene coordinates. */
|
||||
playerDrawRect?: { x: number; y: number; w: number; h: number } | undefined
|
||||
/** Equipped or active weapon class code (e.g. 'hth', '1hs', '2hs'). */
|
||||
/** Equipped or active weapon class code (e.g. 'hth', '1hs', '2hs', 'stf', 'orb', 'bow', 'xbw'). */
|
||||
weaponClass?: string | null | undefined
|
||||
/** Current animation clip mode name of the player (e.g. 'nu', 'wl', 'rn', 'a1', 'sc'). */
|
||||
playerClip?: string | null | undefined
|
||||
/** Canonical appearance cache key for dynamic modular paperdoll compositing. */
|
||||
characterAppearanceKey?: string | undefined
|
||||
/** Active Sorceress 16-component appearance and palette shift spec. */
|
||||
characterAppearance?: SorceressAppearanceSpec | undefined
|
||||
/** Milliseconds from boot to the first rendered frame. */
|
||||
loadMs: number
|
||||
missing: number
|
||||
|
|
@ -854,6 +864,7 @@ declare global {
|
|||
enterTownPortal?: () => void
|
||||
getPortal?: () => OpenPortal | null
|
||||
getMinimap?: () => Minimap
|
||||
syncAppearance?: () => Promise<boolean>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7833,6 +7844,14 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
let packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
|
||||
|
||||
let character: LoadedCharacter | null = null
|
||||
const dropTables = getEmbeddedDropTables()
|
||||
let hudManager: HudManager | null = null
|
||||
const soCompositor = new SorceressLayerCompositor(
|
||||
packEntityBase ?? 'samples/d2-packs',
|
||||
)
|
||||
let activeAppearanceKey = ''
|
||||
let appearanceSeq = 0
|
||||
|
||||
let monsterArtMap = new Map<string, LoadedMonsterArt>()
|
||||
let missileArtMap = new Map<string, LoadedMissileArt>()
|
||||
void loadMissileArtMap(renderer, packEntityBase).then(map => {
|
||||
|
|
@ -7858,6 +7877,81 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
? LIGHTING_PRESETS[currentLightingPreset].labelEn
|
||||
: LIGHTING_PRESETS[currentLightingPreset].labelZh
|
||||
|
||||
const syncSorceressAppearance = async (): Promise<boolean> => {
|
||||
const hudInst = hudManager ?? (
|
||||
typeof window !== 'undefined'
|
||||
? (window as unknown as { __d2webHudInstance?: HudManager }).__d2webHudInstance ?? null
|
||||
: null
|
||||
)
|
||||
if (currentHeroToken !== 'so' || hudInst === null) {
|
||||
activeAppearanceKey = ''
|
||||
state.characterAppearanceKey = undefined
|
||||
state.characterAppearance = undefined
|
||||
if (hudInst !== null) {
|
||||
const wCode = hudInst.inventory.equipped.weapon1?.code
|
||||
state.weaponClass = wCode === '7wa' ? '2hs' : (wCode ? '1hs' : 'hth')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const spec = resolveSorceressAppearance(
|
||||
hudInst.inventory.equipped,
|
||||
dropTables,
|
||||
)
|
||||
const key = appearanceKey(spec)
|
||||
state.weaponClass = spec.wclass
|
||||
state.characterAppearance = spec
|
||||
|
||||
if (key === activeAppearanceKey) {
|
||||
return false
|
||||
}
|
||||
activeAppearanceKey = key
|
||||
state.characterAppearanceKey = key
|
||||
const seq = ++appearanceSeq
|
||||
|
||||
try {
|
||||
const composited = await soCompositor.composite(spec)
|
||||
if (seq !== appearanceSeq || currentHeroToken !== 'so') return false
|
||||
const newHandle = renderer.addIndexedAtlas(composited.r8, composited.width, composited.height)
|
||||
const groups: AtlasFrame[][] = composited.groups.map(group =>
|
||||
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
anchorX,
|
||||
anchorY,
|
||||
})),
|
||||
)
|
||||
if (character?.handle) {
|
||||
renderer.deleteAtlas(character.handle)
|
||||
}
|
||||
character = {
|
||||
handle: newHandle,
|
||||
groups,
|
||||
directions: composited.directions,
|
||||
walk: composited.walk,
|
||||
stand: composited.stand,
|
||||
members: composited.members,
|
||||
clips: composited.clips,
|
||||
}
|
||||
state.character = true
|
||||
state.characterMembers = composited.members
|
||||
state.characterAppearanceKey = key
|
||||
state.characterAppearance = spec
|
||||
if (playerAnimator.mode !== null) {
|
||||
const nextClip = composited.clips[playerAnimator.mode]
|
||||
if (nextClip !== undefined) {
|
||||
playerAnimator.syncClip(nextClip)
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('Sorceress appearance composite failed:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const switchLighting = (preset: string): LightingPreset => {
|
||||
currentLightingPreset = normalizeLightingPreset(preset)
|
||||
const cfg = LIGHTING_PRESETS[currentLightingPreset]
|
||||
|
|
@ -7926,6 +8020,13 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
if (hudManager !== null) {
|
||||
hudManager.switchClass(nextInfo.code as CharacterClassCode)
|
||||
}
|
||||
activeAppearanceKey = ''
|
||||
if (nextToken === 'so') {
|
||||
await syncSorceressAppearance()
|
||||
} else {
|
||||
state.characterAppearanceKey = undefined
|
||||
state.characterAppearance = undefined
|
||||
}
|
||||
if (heroSelect !== null && heroSelect.value !== nextToken) {
|
||||
heroSelect.value = nextToken
|
||||
}
|
||||
|
|
@ -7936,13 +8037,13 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
} catch {
|
||||
// ignore in non-browser context
|
||||
}
|
||||
if (nextChar.clips?.nu) {
|
||||
playerAnimator.play('nu', nextChar.clips.nu, { restart: true })
|
||||
if (character?.clips?.nu) {
|
||||
playerAnimator.play('nu', character.clips.nu, { restart: true })
|
||||
}
|
||||
const switchedPrefix = isEn ? 'Switched hero to ' : '已切换角色为 '
|
||||
status.textContent = isEn
|
||||
? `${switchedPrefix}${heroDispName} (${String(nextChar.members)} layers).`
|
||||
: `${switchedPrefix}${heroDispName}(${String(nextChar.members)} 层素材)。`
|
||||
? `${switchedPrefix}${heroDispName} (${String(character.members)} layers).`
|
||||
: `${switchedPrefix}${heroDispName}(${String(character.members)} 层素材)。`
|
||||
setTimeout(() => {
|
||||
if (status.textContent.startsWith(switchedPrefix)) {
|
||||
status.textContent = ''
|
||||
|
|
@ -7971,9 +8072,11 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
switchLighting,
|
||||
getLighting: () => currentLightingPreset,
|
||||
reloadMonsters: (opts) => reloadMonsters(opts),
|
||||
syncAppearance: () => syncSorceressAppearance(),
|
||||
}
|
||||
;(state as any).switchHero = switchHero
|
||||
;(state as any).switchLighting = switchLighting
|
||||
;(state as any).syncAppearance = syncSorceressAppearance
|
||||
state.reloadMonsters = (opts) => reloadMonsters(opts)
|
||||
}
|
||||
const monsterAnimators = new Map<number, ActorAnimator>()
|
||||
|
|
@ -8048,9 +8151,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
return pack
|
||||
})
|
||||
const streamingRooms = buildStreamingRooms(runtime, resolvedPacks, safeZones)
|
||||
// Drop tables are pre-baked into memory via getEmbeddedDropTables() (zero network, zero MPQ).
|
||||
const dropTables = getEmbeddedDropTables()
|
||||
let hudManager: HudManager | null = null
|
||||
|
||||
const engine = new GameEngine(
|
||||
createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, {
|
||||
|
|
@ -9164,11 +9264,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
}
|
||||
playerAnimator.tick()
|
||||
state.playerClip = playerAnimator.mode ?? (playerMoving ? 'wl' : 'nu')
|
||||
const hud = (typeof window !== 'undefined'
|
||||
? (window as unknown as { __d2webHudInstance?: { inventory?: { equipped?: { weapon1?: { code?: string } } } } }).__d2webHudInstance
|
||||
: undefined)
|
||||
const w1Code = hud?.inventory?.equipped?.weapon1?.code
|
||||
state.weaponClass = w1Code === '7wa' ? '2hs' : (w1Code ? '1hs' : 'hth')
|
||||
void syncSorceressAppearance()
|
||||
}
|
||||
|
||||
// Advance monster animations
|
||||
|
|
@ -10029,6 +10125,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
hudManager.hotkeys.rightSkillId = heroInfo.defaultRightSkill
|
||||
hudManager.syncPublishedState()
|
||||
}
|
||||
if (currentHeroToken === 'so') {
|
||||
await syncSorceressAppearance()
|
||||
}
|
||||
}
|
||||
|
||||
const initialWalked = cellOf(runtime.grid, engine.world.player.x, engine.world.player.y)
|
||||
|
|
|
|||
|
|
@ -21,12 +21,15 @@ import {
|
|||
import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts'
|
||||
import { formatItemTooltip } from '../game/item-tooltip.ts'
|
||||
import { translateItemName, translateRareName, translateMagicName } from '../game/tooltip-i18n.ts'
|
||||
import { isRuneCode } from '../game/gems-data.ts'
|
||||
import { isRuneCode, getGemColorTransform, COLOR_CODES } from '../game/gems-data.ts'
|
||||
import { CANONICAL_AUTOMAGIC_TABLE } from '../game/automagic.ts'
|
||||
import type { D2ColorCode } from './font.ts'
|
||||
import { isA } from '../game/item-types.ts'
|
||||
import { inferItemClassFromType } from '../game/affixes.ts'
|
||||
import { CHARACTER_CLASS_CODES, type CharacterClassCode } from '../game/classes.ts'
|
||||
|
||||
export { COLOR_CODES, getGemColorTransform }
|
||||
|
||||
const VALID_CLASS_CODES = new Set<string>(CHARACTER_CLASS_CODES)
|
||||
|
||||
/**
|
||||
|
|
@ -144,6 +147,8 @@ export function getEffectiveItemReqClass(item: {
|
|||
*/
|
||||
export function resolveItemRequiredClass(
|
||||
base: {
|
||||
readonly id?: string | undefined
|
||||
readonly code?: string | undefined
|
||||
readonly type?: string | undefined
|
||||
readonly type2?: string | undefined
|
||||
readonly tags?: readonly string[] | undefined
|
||||
|
|
@ -152,6 +157,7 @@ export function resolveItemRequiredClass(
|
|||
readonly itemClass?: string | undefined
|
||||
},
|
||||
rawItem?: {
|
||||
readonly code?: string | undefined
|
||||
readonly reqClass?: string | undefined
|
||||
readonly itemClass?: string | undefined
|
||||
},
|
||||
|
|
@ -206,6 +212,11 @@ export function resolveItemRequiredClass(
|
|||
}
|
||||
}
|
||||
|
||||
const rawCode = (rawItem?.code ?? base.code ?? base.id ?? '').trim().toLowerCase()
|
||||
if (rawCode && CANONICAL_CLASS_SPECIFIC_ITEM_CODES[rawCode]) {
|
||||
return CANONICAL_CLASS_SPECIFIC_ITEM_CODES[rawCode]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
|
@ -350,6 +361,178 @@ export function resolveAllowedSlots(
|
|||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground Truth (`D2Common.dll` 1.13c `ITEMS_GetColor` @ `0x6fd99750`, `D2Common/src/Items/Items.cpp:3651-3836`):
|
||||
* Resolves the 2D inventory palette shift (`invtransform`) and 3D character paperdoll palette shift (`chrtransform`)
|
||||
* for an item across all quality tiers:
|
||||
* - Unidentified / gamble mystery items have no color transform (`undefined`).
|
||||
* - Unique (`ITEMQUAL_UNIQUE`): `uniqueItemDef.invtransform` / `uniqueItemDef.chrtransform` (or lookup in `dropTables.uniques`).
|
||||
* - Set (`ITEMQUAL_SET`): `setItemDef.invtransform` / `setItemDef.chrtransform` (or lookup in `dropTables.sets.items`).
|
||||
* - Magic / Rare / Crafted (`ITEMQUAL_MAGIC` | `ITEMQUAL_RARE` | `ITEMQUAL_CRAFT` | `ITEMQUAL_TEMPERED`):
|
||||
* checks rolled **suffixes** first (`MagicSuffix.txt`), first suffix with `transform === true && transformColor` wins;
|
||||
* if none, checks rolled **prefixes** (`MagicPrefix.txt`), first prefix with `transform === true && transformColor` wins.
|
||||
* - Low / Normal / Superior (and non-runeword socketed items):
|
||||
* checks `socketedItems` in socket order; the first socketed gem (`Gems.txt` `isGem`) maps `gem.transform` (`0..20`)
|
||||
* to `COLOR_CODES[gem.transform]`.
|
||||
* - AutoMagic fallback (`item.autoAffixId`): checks `CANONICAL_AUTOMAGIC_TABLE` for `transform && transformColor`.
|
||||
*/
|
||||
export function resolveItemColorTransform(
|
||||
item: Item,
|
||||
quality: UiItemQuality,
|
||||
code: string,
|
||||
dropTables?: DropTables,
|
||||
): { invtransform?: string | undefined; chrtransform?: string | undefined } {
|
||||
const isUnidentified = Boolean(
|
||||
item.isGambleMystery ||
|
||||
item.identified === false ||
|
||||
(item.flags !== undefined && item.flags.identified === false),
|
||||
)
|
||||
if (isUnidentified || (item as any).runewordRunes || item.flags?.runeword) {
|
||||
return { invtransform: undefined, chrtransform: undefined }
|
||||
}
|
||||
|
||||
let invtransform: string | undefined =
|
||||
(item as any).invtransform ?? item.uniqueItemDef?.invtransform ?? item.setItemDef?.invtransform
|
||||
let chrtransform: string | undefined =
|
||||
(item as any).chrtransform ?? item.uniqueItemDef?.chrtransform ?? item.setItemDef?.chrtransform
|
||||
|
||||
if (quality === 'unique' && (!invtransform || !chrtransform) && dropTables?.uniques) {
|
||||
const targetName = (item.name ?? '').trim()
|
||||
let match =
|
||||
(targetName ? dropTables.uniques.get?.(targetName) : undefined) ??
|
||||
(targetName
|
||||
? dropTables.uniques.all?.find(
|
||||
u => u.enabled && u.index.trim().toLowerCase() === targetName.toLowerCase(),
|
||||
)
|
||||
: undefined)
|
||||
if (!match && code) {
|
||||
const byCode = dropTables.uniques.getEnabledByCode?.(code.trim())
|
||||
if (byCode && byCode.length === 1) {
|
||||
match = byCode[0]
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
if (!invtransform && match.invtransform) invtransform = match.invtransform.trim().toLowerCase()
|
||||
if (!chrtransform && match.chrtransform) chrtransform = match.chrtransform.trim().toLowerCase()
|
||||
}
|
||||
} else if (quality === 'set' && (!invtransform || !chrtransform) && dropTables?.sets?.items) {
|
||||
const targetName = (item.name ?? '').trim()
|
||||
let match =
|
||||
(targetName ? dropTables.sets.items.getByIndex?.(targetName) : undefined) ??
|
||||
(targetName
|
||||
? dropTables.sets.items.entries?.find(
|
||||
s => s.index.trim().toLowerCase() === targetName.toLowerCase(),
|
||||
)
|
||||
: undefined)
|
||||
if (!match && code) {
|
||||
const lowerCode = code.trim().toLowerCase()
|
||||
const byCode = dropTables.sets.items.entries?.filter(
|
||||
s => s.item.trim().toLowerCase() === lowerCode,
|
||||
)
|
||||
if (byCode && byCode.length === 1) {
|
||||
match = byCode[0]
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
if (!invtransform && match.invtransform) invtransform = match.invtransform.trim().toLowerCase()
|
||||
if (!chrtransform && match.chrtransform) chrtransform = match.chrtransform.trim().toLowerCase()
|
||||
}
|
||||
} else if (quality === 'magic' || quality === 'rare' || quality === 'craft') {
|
||||
if (!invtransform && !chrtransform) {
|
||||
let affixColor: string | undefined
|
||||
const suffixes =
|
||||
item.rolledMagicAffixes?.suffixes ??
|
||||
item.rolledRareAffixes?.suffixes ??
|
||||
(item.rolledMagicAffixes?.suffix ? [item.rolledMagicAffixes.suffix] : [])
|
||||
for (const suf of suffixes) {
|
||||
if (suf && suf.transform && suf.transformColor) {
|
||||
affixColor = suf.transformColor.trim().toLowerCase()
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!affixColor && dropTables?.magicAffixes?.suffixes) {
|
||||
const sufList = dropTables.magicAffixes.suffixes.all ?? (dropTables.magicAffixes.suffixes as any)
|
||||
const sufIds = (item as any).magicSuffixIds ?? (item.magicSuffixId !== undefined ? [item.magicSuffixId] : [])
|
||||
for (const id of sufIds) {
|
||||
if (typeof id === 'number' && id > 0 && Array.isArray(sufList)) {
|
||||
const suf = sufList[id - 1] ?? sufList.find((s: any) => s.id === id)
|
||||
if (suf && suf.transform && suf.transformColor) {
|
||||
affixColor = suf.transformColor.trim().toLowerCase()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!affixColor) {
|
||||
const prefixes =
|
||||
item.rolledMagicAffixes?.prefixes ??
|
||||
item.rolledRareAffixes?.prefixes ??
|
||||
(item.rolledMagicAffixes?.prefix ? [item.rolledMagicAffixes.prefix] : [])
|
||||
for (const pre of prefixes) {
|
||||
if (pre && pre.transform && pre.transformColor) {
|
||||
affixColor = pre.transformColor.trim().toLowerCase()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!affixColor && dropTables?.magicAffixes?.prefixes) {
|
||||
const preList = dropTables.magicAffixes.prefixes.all ?? (dropTables.magicAffixes.prefixes as any)
|
||||
const preIds = (item as any).magicPrefixIds ?? (item.magicPrefixId !== undefined ? [item.magicPrefixId] : [])
|
||||
for (const id of preIds) {
|
||||
if (typeof id === 'number' && id > 0 && Array.isArray(preList)) {
|
||||
const pre = preList[id - 1] ?? preList.find((p: any) => p.id === id)
|
||||
if (pre && pre.transform && pre.transformColor) {
|
||||
affixColor = pre.transformColor.trim().toLowerCase()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (affixColor) {
|
||||
invtransform = affixColor
|
||||
chrtransform = affixColor
|
||||
}
|
||||
}
|
||||
} else if (quality === 'normal' && !(item as any).runewordRunes && !item.flags?.runeword) {
|
||||
if (!invtransform && !chrtransform && Array.isArray(item.socketedItems)) {
|
||||
for (const sock of item.socketedItems) {
|
||||
const sockCode = (sock?.code ?? sock?.base?.id ?? '').trim().toLowerCase()
|
||||
const gemColor = getGemColorTransform(sockCode)
|
||||
if (gemColor) {
|
||||
invtransform = gemColor
|
||||
chrtransform = gemColor
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: AutoMagic (`Items.cpp:3816-3827`)
|
||||
if (!invtransform && !chrtransform) {
|
||||
const directAuto = (item as any).autoAffix
|
||||
if (directAuto && directAuto.transform && directAuto.transformColor) {
|
||||
const autoColor = String(directAuto.transformColor).trim().toLowerCase()
|
||||
invtransform = autoColor
|
||||
chrtransform = autoColor
|
||||
} else if (typeof item.autoAffixId === 'number' && item.autoAffixId > 0) {
|
||||
const autoTable = dropTables?.autoMagic ?? CANONICAL_AUTOMAGIC_TABLE
|
||||
const autoEntry =
|
||||
autoTable.entries[item.autoAffixId - 1] ??
|
||||
autoTable.entries.find(e => e.id === item.autoAffixId)
|
||||
if (autoEntry && autoEntry.transform && autoEntry.transformColor) {
|
||||
const autoColor = autoEntry.transformColor.trim().toLowerCase()
|
||||
invtransform = autoColor
|
||||
chrtransform = autoColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
invtransform: invtransform ? invtransform.trim().toLowerCase() : undefined,
|
||||
chrtransform: chrtransform ? chrtransform.trim().toLowerCase() : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the DC6 inventory graphic sprite key (`invFile`) following
|
||||
* the authentic Diablo II 1.13c resolution hierarchy and guaranteeing
|
||||
|
|
@ -362,8 +545,10 @@ export function resolveItemInvFile(
|
|||
dropTables?: DropTables,
|
||||
): { invFile: string; invtransform?: string | undefined; chrtransform?: string | undefined } {
|
||||
let invFile: string | undefined = undefined
|
||||
let invtransform: string | undefined = item.uniqueItemDef?.invtransform ?? item.setItemDef?.invtransform
|
||||
const chrtransform: string | undefined = item.uniqueItemDef?.chrtransform ?? item.setItemDef?.chrtransform
|
||||
const resolvedColors = resolveItemColorTransform(item, quality, code, dropTables)
|
||||
let invtransform: string | undefined = resolvedColors.invtransform
|
||||
const chrtransform: string | undefined = resolvedColors.chrtransform
|
||||
|
||||
|
||||
// 1. Explicit Unique / Set Table Graphics
|
||||
if (quality === 'unique') {
|
||||
|
|
|
|||
|
|
@ -106,10 +106,7 @@ describe('Issue #506: Diablo II v1.13c Character Equipment Restrictions & Red Re
|
|||
|
||||
it('Requirement 1: blocks equipping items exceeding character level, strength, dexterity, or class requirements', () => {
|
||||
const inv = new InventoryPanel()
|
||||
delete inv.equipped.helm
|
||||
delete inv.equipped.armor
|
||||
delete inv.equipped.weapon1
|
||||
delete inv.equipped.weapon2
|
||||
;(inv as unknown as { equipped: Record<string, unknown> }).equipped = {}
|
||||
|
||||
inv.setPlayerContext({
|
||||
level: 40,
|
||||
|
|
@ -313,8 +310,7 @@ describe('Issue #506: Diablo II v1.13c Character Equipment Restrictions & Red Re
|
|||
|
||||
it('Requirement 2 (Default Non-Barbarian): allows 1H weapon + shield OR 1 two-handed weapon, blocks dual-wielding and 2H + shield', () => {
|
||||
const inv = new InventoryPanel()
|
||||
delete inv.equipped.weapon1
|
||||
delete inv.equipped.weapon2
|
||||
;(inv as unknown as { equipped: Record<string, unknown> }).equipped = {}
|
||||
|
||||
inv.setPlayerContext({
|
||||
level: 85,
|
||||
|
|
@ -451,8 +447,7 @@ describe('Issue #506: Diablo II v1.13c Character Equipment Restrictions & Red Re
|
|||
|
||||
it('Requirement 2 (Barbarian 1.13c): allows dual-wielding 1H weapons and 1or2handed two-handed swords, and 2H sword + shield, while blocking strict 2H dual-wielding', () => {
|
||||
const inv = new InventoryPanel()
|
||||
delete inv.equipped.weapon1
|
||||
delete inv.equipped.weapon2
|
||||
;(inv as unknown as { equipped: Record<string, unknown> }).equipped = {}
|
||||
|
||||
inv.setPlayerContext({
|
||||
level: 85,
|
||||
|
|
@ -606,21 +601,18 @@ describe('Issue #506: Diablo II v1.13c Character Equipment Restrictions & Red Re
|
|||
const cbBase = tables.weapons.get('7gd')!
|
||||
const uiCb = itemToUiInventoryItem(
|
||||
{
|
||||
id: '9001',
|
||||
name: 'Colossus Blade',
|
||||
base: cbBase,
|
||||
quality: 2,
|
||||
level: 85,
|
||||
identified: true,
|
||||
ethereal: false,
|
||||
socketed: false,
|
||||
sockets: 0,
|
||||
stats: {},
|
||||
prefix: 0,
|
||||
suffix: 0,
|
||||
location: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
prefix: null,
|
||||
suffix: null,
|
||||
invWidth: 2,
|
||||
invHeight: 4,
|
||||
stack: 1,
|
||||
value: 1000,
|
||||
},
|
||||
tables,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
import { readFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { COLOR_CODES, GEM_TRANSFORM_BY_CODE } from '../src/game/gems-data.ts'
|
||||
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
||||
import { resolveItemColorTransform } from '../src/ui/item-bridge.ts'
|
||||
import {
|
||||
SorceressLayerCompositor,
|
||||
appearanceKey,
|
||||
isDefaultUnarmedAppearance,
|
||||
resolveSorceressAppearance,
|
||||
type ResolvableEquippedItem,
|
||||
} from '../src/game/char-appearance.ts'
|
||||
|
||||
function makeItem(overrides: Partial<ResolvableEquippedItem> & { code: string; name: string }): ResolvableEquippedItem {
|
||||
return {
|
||||
quality: 'normal',
|
||||
identified: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Sorceress Equipment Appearance & Palette Shift (1.13c D2Composit / ITEMS_GetColor)', () => {
|
||||
const dropTables = getEmbeddedDropTables()
|
||||
|
||||
it('resolves item color transforms per 1.13c ITEMS_GetColor (Items.cpp:3699)', () => {
|
||||
expect(COLOR_CODES).toHaveLength(21)
|
||||
expect(COLOR_CODES[0]).toBe('whit')
|
||||
expect(COLOR_CODES[20]).toBe('bwht')
|
||||
expect(Object.keys(GEM_TRANSFORM_BY_CODE)).toHaveLength(35)
|
||||
|
||||
// Unidentified items never dye
|
||||
expect(
|
||||
resolveItemColorTransform({ name: 'Harlequin Crest', identified: false } as any, 'unique', 'uap', dropTables)
|
||||
.chrtransform,
|
||||
).toBeUndefined()
|
||||
|
||||
// Unique items
|
||||
expect(
|
||||
resolveItemColorTransform({ name: 'Harlequin Crest', identified: true } as any, 'unique', 'uap', dropTables)
|
||||
.chrtransform,
|
||||
).toBe('cgrn')
|
||||
expect(
|
||||
resolveItemColorTransform({ name: 'Skin of the Vipermagi', identified: true } as any, 'unique', 'xea', dropTables)
|
||||
.chrtransform,
|
||||
).toBe('dblu')
|
||||
|
||||
// Set items (including display name vs internal index fallback)
|
||||
expect(
|
||||
resolveItemColorTransform(
|
||||
{ name: "Tal Rasha's Horadric Crest", identified: true } as any,
|
||||
'set',
|
||||
'xsk',
|
||||
dropTables,
|
||||
).chrtransform,
|
||||
).toBe('dpur')
|
||||
expect(
|
||||
resolveItemColorTransform(
|
||||
{ name: "Tal Rasha's Guardianship", identified: true } as any,
|
||||
'set',
|
||||
'uth',
|
||||
dropTables,
|
||||
).chrtransform,
|
||||
).toBe('dpur')
|
||||
|
||||
// Magic/Rare/Crafted: suffix overrides prefix
|
||||
expect(
|
||||
resolveItemColorTransform(
|
||||
{
|
||||
name: 'Cobalt Cap of the Mammoth',
|
||||
identified: true,
|
||||
rolledMagicAffixes: {
|
||||
prefixes: [{ name: 'Cobalt', transform: true, transformColor: 'cblu' }],
|
||||
suffixes: [{ name: 'of the Mammoth', transform: true, transformColor: 'cred' }],
|
||||
},
|
||||
} as any,
|
||||
'magic',
|
||||
'cap',
|
||||
dropTables,
|
||||
).chrtransform,
|
||||
).toBe('cred')
|
||||
|
||||
// Socketed gems vs Runeword exclusion
|
||||
expect(
|
||||
resolveItemColorTransform(
|
||||
{
|
||||
name: 'Socketed Cap',
|
||||
identified: true,
|
||||
socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }],
|
||||
} as any,
|
||||
'normal',
|
||||
'cap',
|
||||
dropTables,
|
||||
).chrtransform,
|
||||
).toBe('whit')
|
||||
expect(
|
||||
resolveItemColorTransform(
|
||||
{
|
||||
name: 'Enigma',
|
||||
identified: true,
|
||||
runewordRunes: "'JahIthBer'",
|
||||
socketedItems: [{ code: 'gpw', name: 'Perfect Diamond' }],
|
||||
} as any,
|
||||
'rune',
|
||||
'utp',
|
||||
dropTables,
|
||||
).chrtransform,
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps equipped gear to 10 Sorceress paperdoll components and weapon classes', () => {
|
||||
const unarmed = resolveSorceressAppearance({}, dropTables)
|
||||
expect(unarmed.wclass).toBe('hth')
|
||||
expect(isDefaultUnarmedAppearance(unarmed)).toBe(true)
|
||||
|
||||
// Circlet exemption (D2Inventory.cpp:2624)
|
||||
const circletSpec = resolveSorceressAppearance(
|
||||
{ helm: makeItem({ code: 'ci3', name: "Griffon's Eye", quality: 'unique', chrtransform: 'cblu' }) },
|
||||
dropTables,
|
||||
)
|
||||
expect(circletSpec.layers.hd.variant).toBe('lit')
|
||||
expect(circletSpec.layers.hd.color).toBeNull()
|
||||
|
||||
// Asymmetric Studded Leather (stu)
|
||||
const stuSpec = resolveSorceressAppearance(
|
||||
{ armor: makeItem({ code: 'stu', name: 'Studded Leather', chrtransform: 'cgrn' }) },
|
||||
dropTables,
|
||||
)
|
||||
expect(stuSpec.layers.tr).toEqual({ variant: 'lit', transform: 7, color: 'cgrn' })
|
||||
expect(stuSpec.layers.lg).toEqual({ variant: 'med', transform: 7, color: 'cgrn' })
|
||||
expect(stuSpec.layers.ra).toEqual({ variant: 'med', transform: 7, color: 'cgrn' })
|
||||
expect(stuSpec.layers.la).toEqual({ variant: 'lit', transform: 7, color: 'cgrn' })
|
||||
expect(stuSpec.layers.s1).toEqual({ variant: 'med', transform: 7, color: 'cgrn' })
|
||||
expect(stuSpec.layers.s2).toEqual({ variant: 'med', transform: 7, color: 'cgrn' })
|
||||
|
||||
// Bow on LH vs Crossbow on RH
|
||||
const bowSpec = resolveSorceressAppearance(
|
||||
{ weapon1: makeItem({ code: 'sbw', name: 'Short Bow' }) },
|
||||
dropTables,
|
||||
)
|
||||
expect(bowSpec.wclass).toBe('bow')
|
||||
expect(bowSpec.layers.lh.variant).toBe('sbw')
|
||||
expect(bowSpec.layers.rh.variant).toBeNull()
|
||||
|
||||
const xbwSpec = resolveSorceressAppearance(
|
||||
{ weapon1: makeItem({ code: 'lxb', name: 'Light Crossbow' }) },
|
||||
dropTables,
|
||||
)
|
||||
expect(xbwSpec.wclass).toBe('xbw')
|
||||
expect(xbwSpec.layers.rh.variant).toBe('lxb')
|
||||
expect(xbwSpec.layers.lh.variant).toBeNull()
|
||||
})
|
||||
|
||||
it('composites Sorceress layers from offline pack with palette shift tables', async () => {
|
||||
const packDir = resolve(process.cwd(), 'samples/d2-packs/chars/so')
|
||||
const compositor = new SorceressLayerCompositor({
|
||||
async loadJson<T>(relPath: string): Promise<T> {
|
||||
const raw = await readFile(join(packDir, relPath), 'utf8')
|
||||
return JSON.parse(raw) as T
|
||||
},
|
||||
async loadBin(relPath: string): Promise<Uint8Array> {
|
||||
const buf = await readFile(join(packDir, relPath))
|
||||
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
|
||||
},
|
||||
})
|
||||
|
||||
const redSpec = resolveSorceressAppearance(
|
||||
{
|
||||
helm: makeItem({ code: 'crn', name: 'Ruby Crown', quality: 'magic', chrtransform: 'cred' }),
|
||||
armor: makeItem({ code: 'fld', name: 'Ruby Field Plate', quality: 'magic', chrtransform: 'cred' }),
|
||||
weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique', chrtransform: 'whit' }),
|
||||
weapon2: makeItem({ code: 'uit', name: 'Monarch', quality: 'magic', chrtransform: 'cred' }),
|
||||
},
|
||||
dropTables,
|
||||
)
|
||||
const blueSpec = resolveSorceressAppearance(
|
||||
{
|
||||
helm: makeItem({ code: 'crn', name: 'Cobalt Crown', quality: 'magic', chrtransform: 'cblu' }),
|
||||
armor: makeItem({ code: 'fld', name: 'Cobalt Field Plate', quality: 'magic', chrtransform: 'cblu' }),
|
||||
weapon1: makeItem({ code: 'oba', name: 'The Oculus', quality: 'unique', chrtransform: 'whit' }),
|
||||
weapon2: makeItem({ code: 'uit', name: 'Monarch', quality: 'magic', chrtransform: 'cblu' }),
|
||||
},
|
||||
dropTables,
|
||||
)
|
||||
|
||||
expect(appearanceKey(redSpec)).not.toBe(appearanceKey(blueSpec))
|
||||
const redAtlas = await compositor.composite(redSpec)
|
||||
const blueAtlas = await compositor.composite(blueSpec)
|
||||
expect(redAtlas.width).toBe(blueAtlas.width)
|
||||
expect(redAtlas.height).toBe(blueAtlas.height)
|
||||
|
||||
let diffPixels = 0
|
||||
for (let i = 0; i < redAtlas.r8.length; i += 1) {
|
||||
if (redAtlas.r8[i] !== blueAtlas.r8[i]) diffPixels += 1
|
||||
}
|
||||
expect(diffPixels).toBeGreaterThan(100000)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue