feat(client): integrate on-demand EntityArtCache, object/NPC/flippy/animated-tile rendering, and remove drawSolid fallbacks (#540)
This commit is contained in:
parent
618fa1dfb1
commit
1b93827103
|
|
@ -28,6 +28,7 @@ import type { CharClassId } from '../netproto/index.ts'
|
||||||
import { SpriteRenderer, type AtlasHandle } from './render/renderer.ts'
|
import { SpriteRenderer, type AtlasHandle } from './render/renderer.ts'
|
||||||
import {
|
import {
|
||||||
DEFAULT_BASES,
|
DEFAULT_BASES,
|
||||||
|
EntityArtCache,
|
||||||
loadCharacterArt,
|
loadCharacterArt,
|
||||||
loadMonsterArtMap,
|
loadMonsterArtMap,
|
||||||
type LoadedCharacter,
|
type LoadedCharacter,
|
||||||
|
|
@ -42,6 +43,7 @@ import {
|
||||||
import {
|
import {
|
||||||
TileAtlasGpuCache,
|
TileAtlasGpuCache,
|
||||||
renderSceneSourceFrame,
|
renderSceneSourceFrame,
|
||||||
|
reportMissingArt,
|
||||||
} from './scene/world-renderer.ts'
|
} from './scene/world-renderer.ts'
|
||||||
import {
|
import {
|
||||||
BootProgressTracker,
|
BootProgressTracker,
|
||||||
|
|
@ -97,6 +99,7 @@ export interface PlayAppController {
|
||||||
readonly frontendRenderer: FrontendRenderer
|
readonly frontendRenderer: FrontendRenderer
|
||||||
readonly renderer: SpriteRenderer
|
readonly renderer: SpriteRenderer
|
||||||
readonly tileGpuCache: TileAtlasGpuCache
|
readonly tileGpuCache: TileAtlasGpuCache
|
||||||
|
readonly entityArtCache: EntityArtCache
|
||||||
readonly lightGrid: LightGrid
|
readonly lightGrid: LightGrid
|
||||||
readonly minimap: Minimap
|
readonly minimap: Minimap
|
||||||
readonly assetsReadyPromise: Promise<void>
|
readonly assetsReadyPromise: Promise<void>
|
||||||
|
|
@ -188,6 +191,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
const renderer = new SpriteRenderer(viewCanvas)
|
const renderer = new SpriteRenderer(viewCanvas)
|
||||||
const lightGrid = new LightGrid()
|
const lightGrid = new LightGrid()
|
||||||
const tileGpuCache = new TileAtlasGpuCache(renderer)
|
const tileGpuCache = new TileAtlasGpuCache(renderer)
|
||||||
|
const entityArtCache = new EntityArtCache(renderer, '/assets')
|
||||||
|
|
||||||
const characterArtByToken = new Map<string, LoadedCharacter>()
|
const characterArtByToken = new Map<string, LoadedCharacter>()
|
||||||
const monsterArtMap = new Map<string, LoadedMonsterArt>()
|
const monsterArtMap = new Map<string, LoadedMonsterArt>()
|
||||||
|
|
@ -239,12 +243,16 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
const dummyPalette = { rgb: new Uint8Array(768), size: 256 as const }
|
const dummyPalette = { rgb: new Uint8Array(768), size: 256 as const }
|
||||||
const assetsReadyPromise = (async (): Promise<void> => {
|
const assetsReadyPromise = (async (): Promise<void> => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
minimap.loadAtlases(renderer, 'samples/d2-packs').catch(() => {}),
|
minimap.loadAtlases(renderer, 'samples/d2-packs').catch((err) => {
|
||||||
|
reportMissingArt('automap', 'Automap.dc6', String(err))
|
||||||
|
}),
|
||||||
loadItemsAtlas(renderer, 'samples/d2-packs')
|
loadItemsAtlas(renderer, 'samples/d2-packs')
|
||||||
.then((h) => {
|
.then((h) => {
|
||||||
itemsAtlasHandle = h
|
itemsAtlasHandle = h
|
||||||
})
|
})
|
||||||
.catch(() => {}),
|
.catch((err) => {
|
||||||
|
reportMissingArt('item', 'items-atlas.png', String(err))
|
||||||
|
}),
|
||||||
Promise.all(
|
Promise.all(
|
||||||
PRELOAD_HERO_TOKENS.map(async (token) => {
|
PRELOAD_HERO_TOKENS.map(async (token) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -259,9 +267,11 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
if (art) {
|
if (art) {
|
||||||
characterArtByToken.set(token.toLowerCase(), art)
|
characterArtByToken.set(token.toLowerCase(), art)
|
||||||
characterArtByToken.set(token.toUpperCase(), art)
|
characterArtByToken.set(token.toUpperCase(), art)
|
||||||
|
} else {
|
||||||
|
reportMissingArt('player', token, `Character art not found for token=${token}`)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore individual token failure
|
reportMissingArt('player', token, String(err))
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
@ -279,17 +289,37 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
monsterArtMap.set(`token:${art.token.toLowerCase()}`, art)
|
monsterArtMap.set(`token:${art.token.toLowerCase()}`, art)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {}),
|
.catch((err) => {
|
||||||
|
reportMissingArt('monster', 'preload-monsters', String(err))
|
||||||
|
}),
|
||||||
|
entityArtCache
|
||||||
|
.loadIndex()
|
||||||
|
.then(async () => {
|
||||||
|
for (const npcId of [146, 147, 148, 150, 154, 155]) {
|
||||||
|
entityArtCache.getMonsterArt(npcId, 1)
|
||||||
|
}
|
||||||
|
for (const objId of [17, 26, 39, 119, 267]) {
|
||||||
|
entityArtCache.getObjectArt(objId, 1)
|
||||||
|
}
|
||||||
|
await entityArtCache.waitForInflight()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
reportMissingArt('entity-index', 'entity-index.json', String(err))
|
||||||
|
}),
|
||||||
loadMissileArtMap(renderer, 'samples/d2-packs')
|
loadMissileArtMap(renderer, 'samples/d2-packs')
|
||||||
.then((map) => {
|
.then((map) => {
|
||||||
missileArtMap = map
|
missileArtMap = map
|
||||||
})
|
})
|
||||||
.catch(() => {}),
|
.catch((err) => {
|
||||||
|
reportMissingArt('missile', 'missiles-manifest.json', String(err))
|
||||||
|
}),
|
||||||
loadOverlayArtMap(renderer, 'samples/d2-packs')
|
loadOverlayArtMap(renderer, 'samples/d2-packs')
|
||||||
.then((map) => {
|
.then((map) => {
|
||||||
overlayArtMap = map
|
overlayArtMap = map
|
||||||
})
|
})
|
||||||
.catch(() => {}),
|
.catch((err) => {
|
||||||
|
reportMissingArt('overlay', 'overlays-manifest.json', String(err))
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|
@ -852,6 +882,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
lightingTargets: { lightGrid, renderer },
|
lightingTargets: { lightGrid, renderer },
|
||||||
renderer,
|
renderer,
|
||||||
tileGpuCache,
|
tileGpuCache,
|
||||||
|
entityArtCache,
|
||||||
characterArtByToken,
|
characterArtByToken,
|
||||||
monsterArtMap,
|
monsterArtMap,
|
||||||
missileArtMap,
|
missileArtMap,
|
||||||
|
|
@ -888,6 +919,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
||||||
frontendRenderer,
|
frontendRenderer,
|
||||||
renderer,
|
renderer,
|
||||||
tileGpuCache,
|
tileGpuCache,
|
||||||
|
entityArtCache,
|
||||||
lightGrid,
|
lightGrid,
|
||||||
minimap,
|
minimap,
|
||||||
assetsReadyPromise,
|
assetsReadyPromise,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type { SpriteGroup, SpriteSheet } from '../../common/formats/sprite.ts'
|
||||||
import {
|
import {
|
||||||
ADDITIVE_MONSTER_TOKENS,
|
ADDITIVE_MONSTER_TOKENS,
|
||||||
MONSTER_COMPONENTS,
|
MONSTER_COMPONENTS,
|
||||||
compositeMonsterAnimation,
|
compositeMonsterAnimation as compositeCommonMonsterAnimation,
|
||||||
findMonsterLayerSprite,
|
findMonsterLayerSprite,
|
||||||
type MonsterAnimationSheet,
|
type MonsterAnimationSheet,
|
||||||
type MonsterLayerFailure,
|
type MonsterLayerFailure,
|
||||||
|
|
@ -19,12 +19,26 @@ import type { AnimClipMeta } from './actor-animator.ts'
|
||||||
export {
|
export {
|
||||||
ADDITIVE_MONSTER_TOKENS,
|
ADDITIVE_MONSTER_TOKENS,
|
||||||
MONSTER_COMPONENTS,
|
MONSTER_COMPONENTS,
|
||||||
compositeMonsterAnimation,
|
|
||||||
findMonsterLayerSprite,
|
findMonsterLayerSprite,
|
||||||
type MonsterAnimationSheet,
|
type MonsterAnimationSheet,
|
||||||
type MonsterLayerFailure,
|
type MonsterLayerFailure,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function compositeMonsterAnimation(
|
||||||
|
archives: MountedArchives,
|
||||||
|
token: string,
|
||||||
|
animation: string,
|
||||||
|
weapon = 'hth',
|
||||||
|
): Promise<MonsterAnimationSheet> {
|
||||||
|
const res = await compositeCommonMonsterAnimation(archives, token, animation, weapon)
|
||||||
|
for (const f of res.layerFailures) {
|
||||||
|
console.error(
|
||||||
|
`[monster-art] ${f.token}/${f.animation} dropped layer ${f.component} (${f.reason})${f.member ? `: ${f.member}` : ''}${f.message ? ` — ${f.message}` : ''}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoadedMonsterArt {
|
export interface LoadedMonsterArt {
|
||||||
readonly token: string
|
readonly token: string
|
||||||
readonly handle: AtlasHandle
|
readonly handle: AtlasHandle
|
||||||
|
|
@ -37,6 +51,8 @@ export interface LoadedMonsterArt {
|
||||||
readonly walkFrames: number
|
readonly walkFrames: number
|
||||||
readonly standFrames: number
|
readonly standFrames: number
|
||||||
readonly clips?: Readonly<Record<string, AnimClipMeta>>
|
readonly clips?: Readonly<Record<string, AnimClipMeta>>
|
||||||
|
readonly glowClips?: Readonly<Record<string, AnimClipMeta>>
|
||||||
|
readonly scale?: number
|
||||||
readonly layerFailures: readonly MonsterLayerFailure[]
|
readonly layerFailures: readonly MonsterLayerFailure[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
import type { Palette } from '../../common/formats/pal.ts'
|
import type { Palette } from '../../common/formats/pal.ts'
|
||||||
|
import type {
|
||||||
|
MonsterIndexEntry,
|
||||||
|
ObjectIndexEntry,
|
||||||
|
PackedEntityCatalogIndex,
|
||||||
|
} from '../../common/pack-contract/index.ts'
|
||||||
import type { AnimClipMeta } from '../render/actor-animator.ts'
|
import type { AnimClipMeta } from '../render/actor-animator.ts'
|
||||||
import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts'
|
import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts'
|
||||||
import { resolveMonsterArtSpec } from '../../common/units/monster-mapping.ts'
|
import { resolveMonsterArtSpec } from '../../common/units/monster-mapping.ts'
|
||||||
|
|
@ -7,10 +12,34 @@ import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
|
||||||
import { normalizeHeroToken } from '../../common/world/scene-population.ts'
|
import { normalizeHeroToken } from '../../common/world/scene-population.ts'
|
||||||
|
|
||||||
export { ADDITIVE_MONSTER_TOKENS }
|
export { ADDITIVE_MONSTER_TOKENS }
|
||||||
|
export type { MonsterIndexEntry, ObjectIndexEntry, PackedEntityCatalogIndex }
|
||||||
|
|
||||||
/** Base locations tried in order when `?base=` is absent. */
|
/** Base locations tried in order when `?base=` is absent. */
|
||||||
export const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
|
export const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
|
||||||
|
|
||||||
|
export type ObjectClipArt = AnimClipMeta
|
||||||
|
|
||||||
|
/** A composited map/interactive object uploaded as an indexed R8 atlas page. */
|
||||||
|
export interface LoadedObjectArt {
|
||||||
|
readonly token: string
|
||||||
|
readonly slug: string
|
||||||
|
readonly handle: AtlasHandle
|
||||||
|
readonly groups: readonly (readonly AtlasFrame[])[]
|
||||||
|
readonly glowGroups?: readonly (readonly AtlasFrame[])[]
|
||||||
|
readonly blendMode: 'normal' | 'additive'
|
||||||
|
readonly directions: number
|
||||||
|
readonly standOffset: number
|
||||||
|
readonly standFrames: number
|
||||||
|
readonly clips: Readonly<Record<string, AnimClipMeta>>
|
||||||
|
readonly glowClips?: Readonly<Record<string, AnimClipMeta>>
|
||||||
|
readonly frameCnt?: readonly number[]
|
||||||
|
readonly frameDelta?: readonly number[]
|
||||||
|
readonly cycleAnim?: readonly boolean[]
|
||||||
|
readonly trans?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ObjectArtEntry = LoadedObjectArt
|
||||||
|
|
||||||
/** A composited character, uploaded as its own atlas page. */
|
/** A composited character, uploaded as its own atlas page. */
|
||||||
export interface LoadedCharacter {
|
export interface LoadedCharacter {
|
||||||
readonly handle: AtlasHandle
|
readonly handle: AtlasHandle
|
||||||
|
|
@ -26,21 +55,25 @@ export interface LoadedCharacter {
|
||||||
export interface PackedMonsterJson {
|
export interface PackedMonsterJson {
|
||||||
readonly schema?: number
|
readonly schema?: number
|
||||||
readonly token: string
|
readonly token: string
|
||||||
readonly weapon: string
|
readonly weapon?: string
|
||||||
readonly file: string
|
readonly file: string
|
||||||
readonly width: number
|
readonly width: number
|
||||||
readonly height: number
|
readonly height: number
|
||||||
readonly blendMode?: 'normal' | 'additive' | undefined
|
readonly blendMode?: 'normal' | 'additive' | undefined
|
||||||
readonly directions: number
|
readonly directions: number
|
||||||
readonly walkOffset: number
|
readonly walkOffset?: number
|
||||||
readonly standOffset: number
|
readonly standOffset: number
|
||||||
readonly walkFrames: number
|
readonly walkFrames?: number
|
||||||
readonly standFrames: number
|
readonly standFrames: number
|
||||||
readonly clips?: Readonly<Record<string, AnimClipMeta>>
|
readonly clips?: Readonly<Record<string, AnimClipMeta>>
|
||||||
|
readonly modes?: Readonly<Record<string, AnimClipMeta>>
|
||||||
|
readonly glowClips?: Readonly<Record<string, AnimClipMeta>>
|
||||||
readonly groups: readonly (readonly [number, number, number, number, number?, number?])[][]
|
readonly groups: readonly (readonly [number, number, number, number, number?, number?])[][]
|
||||||
readonly glowGroups?: readonly (readonly [number, number, number, number, number?, number?])[][] | undefined
|
readonly glowGroups?: readonly (readonly [number, number, number, number, number?, number?])[][] | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PackedObjectJson = PackedMonsterJson
|
||||||
|
|
||||||
export interface PackedCharJson {
|
export interface PackedCharJson {
|
||||||
readonly schema?: number
|
readonly schema?: number
|
||||||
readonly file: string
|
readonly file: string
|
||||||
|
|
@ -120,54 +153,63 @@ export async function loadMonsterArtMap(
|
||||||
const fallbackSpecs: Array<[string, { token: string; weapon: string }]> = []
|
const fallbackSpecs: Array<[string, { token: string; weapon: string }]> = []
|
||||||
|
|
||||||
if (packBase !== undefined) {
|
if (packBase !== undefined) {
|
||||||
const globalEntitiesUrl = `${packBase}/entities`
|
const candidateEntityPrefixes = [
|
||||||
|
`${packBase}/entities`,
|
||||||
|
'/assets/entities',
|
||||||
|
'/entities',
|
||||||
|
].filter((p, idx, arr) => arr.indexOf(p) === idx)
|
||||||
const actEntitiesUrl = act !== undefined ? `${packBase}/act${String(act)}/entities` : undefined
|
const actEntitiesUrl = act !== undefined ? `${packBase}/act${String(act)}/entities` : undefined
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
[...tokenSpecs.entries()].map(async ([key, spec]) => {
|
[...tokenSpecs.entries()].map(async ([key, spec]) => {
|
||||||
const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
||||||
try {
|
for (const globalEntitiesUrl of candidateEntityPrefixes) {
|
||||||
const [jsonResp, r8Resp] = await Promise.all([
|
try {
|
||||||
fetch(`${globalEntitiesUrl}/${slug}.json`),
|
const [jsonResp, r8Resp] = await Promise.all([
|
||||||
fetch(`${globalEntitiesUrl}/${slug}.r8`),
|
fetch(`${globalEntitiesUrl}/${slug}.json`),
|
||||||
])
|
fetch(`${globalEntitiesUrl}/${slug}.r8`),
|
||||||
if (jsonResp.ok && r8Resp.ok) {
|
])
|
||||||
const meta = (await jsonResp.json()) as PackedMonsterJson
|
if (jsonResp.ok && r8Resp.ok) {
|
||||||
const indices = new Uint8Array(await r8Resp.arrayBuffer())
|
const meta = (await jsonResp.json()) as PackedMonsterJson
|
||||||
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
|
const indices = new Uint8Array(await r8Resp.arrayBuffer())
|
||||||
const groups: AtlasFrame[][] = meta.groups.map(group =>
|
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
|
||||||
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
const groups: AtlasFrame[][] = meta.groups.map(group =>
|
||||||
x, y, width, height,
|
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
||||||
...(anchorX !== undefined ? { anchorX } : {}),
|
x, y, width, height,
|
||||||
...(anchorY !== undefined ? { anchorY } : {}),
|
...(anchorX !== undefined ? { anchorX } : {}),
|
||||||
})),
|
...(anchorY !== undefined ? { anchorY } : {}),
|
||||||
)
|
})),
|
||||||
const glowGroups: AtlasFrame[][] | undefined = meta.glowGroups !== undefined
|
)
|
||||||
? meta.glowGroups.map(group =>
|
const glowGroups: AtlasFrame[][] | undefined = meta.glowGroups !== undefined
|
||||||
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
? meta.glowGroups.map(group =>
|
||||||
x, y, width, height,
|
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
||||||
...(anchorX !== undefined ? { anchorX } : {}),
|
x, y, width, height,
|
||||||
...(anchorY !== undefined ? { anchorY } : {}),
|
...(anchorX !== undefined ? { anchorX } : {}),
|
||||||
})),
|
...(anchorY !== undefined ? { anchorY } : {}),
|
||||||
)
|
})),
|
||||||
: undefined
|
)
|
||||||
loadedBySpecKey.set(key, {
|
: undefined
|
||||||
token: spec.token,
|
loadedBySpecKey.set(key, {
|
||||||
handle,
|
token: spec.token,
|
||||||
groups,
|
handle,
|
||||||
...(glowGroups !== undefined ? { glowGroups } : {}),
|
groups,
|
||||||
...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}),
|
...(glowGroups !== undefined ? { glowGroups } : {}),
|
||||||
directions: meta.directions,
|
...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}),
|
||||||
walkOffset: meta.walkOffset,
|
directions: meta.directions,
|
||||||
standOffset: meta.standOffset,
|
walkOffset: meta.walkOffset ?? meta.standOffset,
|
||||||
walkFrames: meta.walkFrames,
|
standOffset: meta.standOffset,
|
||||||
standFrames: meta.standFrames,
|
walkFrames: meta.walkFrames ?? meta.standFrames,
|
||||||
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
|
standFrames: meta.standFrames,
|
||||||
layerFailures: [],
|
...(meta.clips !== undefined || meta.modes !== undefined
|
||||||
})
|
? { clips: meta.clips ?? meta.modes }
|
||||||
return
|
: {}),
|
||||||
|
...(meta.glowClips !== undefined ? { glowClips: meta.glowClips } : {}),
|
||||||
|
layerFailures: [],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`monster art ${slug}: packed r8 unavailable: ${(err as Error).message}`)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.warn(`monster art ${slug}: packed r8 unavailable: ${(err as Error).message}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actEntitiesUrl !== undefined) {
|
if (actEntitiesUrl !== undefined) {
|
||||||
|
|
@ -208,11 +250,14 @@ export async function loadMonsterArtMap(
|
||||||
...(glowGroups !== undefined ? { glowGroups } : {}),
|
...(glowGroups !== undefined ? { glowGroups } : {}),
|
||||||
...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}),
|
...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}),
|
||||||
directions: meta.directions,
|
directions: meta.directions,
|
||||||
walkOffset: meta.walkOffset,
|
walkOffset: meta.walkOffset ?? meta.standOffset,
|
||||||
standOffset: meta.standOffset,
|
standOffset: meta.standOffset,
|
||||||
walkFrames: meta.walkFrames,
|
walkFrames: meta.walkFrames ?? meta.standFrames,
|
||||||
standFrames: meta.standFrames,
|
standFrames: meta.standFrames,
|
||||||
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
|
...(meta.clips !== undefined || meta.modes !== undefined
|
||||||
|
? { clips: meta.clips ?? meta.modes }
|
||||||
|
: {}),
|
||||||
|
...(meta.glowClips !== undefined ? { glowClips: meta.glowClips } : {}),
|
||||||
layerFailures: [],
|
layerFailures: [],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
@ -264,34 +309,46 @@ export async function loadCharacterArt(
|
||||||
): Promise<LoadedCharacter | null> {
|
): Promise<LoadedCharacter | null> {
|
||||||
const heroToken = normalizeHeroToken(token)
|
const heroToken = normalizeHeroToken(token)
|
||||||
if (packBase !== undefined) {
|
if (packBase !== undefined) {
|
||||||
try {
|
const r8CandidatePrefixes = [
|
||||||
const [jsonResp, r8Resp] = await Promise.all([
|
`${packBase}/characters`,
|
||||||
fetch(`${packBase}/entities/char-${heroToken}.json`),
|
`${packBase}/entities`,
|
||||||
fetch(`${packBase}/entities/char-${heroToken}.r8`),
|
'/assets/characters',
|
||||||
])
|
'/characters',
|
||||||
if (jsonResp.ok && r8Resp.ok) {
|
'/assets/entities',
|
||||||
const meta = (await jsonResp.json()) as PackedCharJson
|
]
|
||||||
const indices = new Uint8Array(await r8Resp.arrayBuffer())
|
const seenPrefixes = new Set<string>()
|
||||||
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
|
for (const prefix of r8CandidatePrefixes) {
|
||||||
const groups: AtlasFrame[][] = meta.groups.map(group =>
|
if (seenPrefixes.has(prefix)) continue
|
||||||
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
seenPrefixes.add(prefix)
|
||||||
x, y, width, height,
|
try {
|
||||||
...(anchorX !== undefined ? { anchorX } : {}),
|
const [jsonResp, r8Resp] = await Promise.all([
|
||||||
...(anchorY !== undefined ? { anchorY } : {}),
|
fetch(`${prefix}/char-${heroToken}.json`),
|
||||||
})),
|
fetch(`${prefix}/char-${heroToken}.r8`),
|
||||||
)
|
])
|
||||||
return {
|
if (jsonResp.ok && r8Resp.ok) {
|
||||||
handle,
|
const meta = (await jsonResp.json()) as PackedCharJson
|
||||||
groups,
|
const indices = new Uint8Array(await r8Resp.arrayBuffer())
|
||||||
directions: meta.directions,
|
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
|
||||||
walk: meta.walk,
|
const groups: AtlasFrame[][] = meta.groups.map(group =>
|
||||||
stand: meta.stand,
|
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
||||||
members: meta.members,
|
x, y, width, height,
|
||||||
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
|
...(anchorX !== undefined ? { anchorX } : {}),
|
||||||
|
...(anchorY !== undefined ? { anchorY } : {}),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
handle,
|
||||||
|
groups,
|
||||||
|
directions: meta.directions,
|
||||||
|
walk: meta.walk,
|
||||||
|
stand: meta.stand,
|
||||||
|
members: meta.members,
|
||||||
|
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// try next candidate prefix
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// fall through to per-act PNG fallback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (act !== undefined) {
|
if (act !== undefined) {
|
||||||
|
|
@ -333,10 +390,541 @@ export async function loadCharacterArt(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (heroToken !== 'so') {
|
|
||||||
return loadCharacterArt(_bases, _palette, renderer, packBase, act, 'so')
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(`character art unavailable for ${heroToken} in pre-baked packs`)
|
console.warn(`character art unavailable for ${heroToken} in pre-baked packs`)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapRawGroupsToAtlasFrames(
|
||||||
|
rawGroups: readonly (readonly [number, number, number, number, number?, number?])[][],
|
||||||
|
): AtlasFrame[][] {
|
||||||
|
return rawGroups.map(group =>
|
||||||
|
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
...(anchorX !== undefined ? { anchorX } : {}),
|
||||||
|
...(anchorY !== undefined ? { anchorY } : {}),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadObjectArt(
|
||||||
|
renderer: SpriteRenderer,
|
||||||
|
slugOrToken: string,
|
||||||
|
packBase = 'samples/d2-packs',
|
||||||
|
act?: number,
|
||||||
|
indexEntry?: ObjectIndexEntry,
|
||||||
|
fetchFn: typeof fetch = fetch,
|
||||||
|
): Promise<LoadedObjectArt | null> {
|
||||||
|
const cleanSlug = slugOrToken.trim().toLowerCase().replace(/^object-/, '')
|
||||||
|
if (!cleanSlug) return null
|
||||||
|
|
||||||
|
const candidateBases = [packBase, '/assets', ''].filter(
|
||||||
|
(b, idx, arr) => arr.indexOf(b) === idx,
|
||||||
|
)
|
||||||
|
for (const base of candidateBases) {
|
||||||
|
const prefix = base ? `${base.replace(/\/$/, '')}/entities` : '/entities'
|
||||||
|
for (const subPath of [`${prefix}/object-${cleanSlug}`, `${prefix}/objects/object-${cleanSlug}`]) {
|
||||||
|
try {
|
||||||
|
const [jsonResp, r8Resp] = await Promise.all([
|
||||||
|
fetchFn(`${subPath}.json`),
|
||||||
|
fetchFn(`${subPath}.r8`),
|
||||||
|
])
|
||||||
|
if (jsonResp.ok && r8Resp.ok) {
|
||||||
|
const meta = (await jsonResp.json()) as PackedObjectJson
|
||||||
|
const indices = new Uint8Array(await r8Resp.arrayBuffer())
|
||||||
|
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
|
||||||
|
const groups = mapRawGroupsToAtlasFrames(meta.groups)
|
||||||
|
const glowGroups =
|
||||||
|
meta.glowGroups !== undefined ? mapRawGroupsToAtlasFrames(meta.glowGroups) : undefined
|
||||||
|
const clips = meta.clips ?? meta.modes ?? {}
|
||||||
|
return {
|
||||||
|
token: meta.token || cleanSlug.toUpperCase(),
|
||||||
|
slug: cleanSlug,
|
||||||
|
handle,
|
||||||
|
groups,
|
||||||
|
...(glowGroups !== undefined ? { glowGroups } : {}),
|
||||||
|
blendMode: meta.blendMode ?? indexEntry?.blendMode ?? 'normal',
|
||||||
|
directions: Math.max(1, meta.directions || 1),
|
||||||
|
standOffset: meta.standOffset ?? 0,
|
||||||
|
standFrames: Math.max(1, meta.standFrames || 1),
|
||||||
|
clips,
|
||||||
|
...(meta.glowClips !== undefined ? { glowClips: meta.glowClips } : {}),
|
||||||
|
...(indexEntry?.frameCnt !== undefined ? { frameCnt: indexEntry.frameCnt } : {}),
|
||||||
|
...(indexEntry?.frameDelta !== undefined ? { frameDelta: indexEntry.frameDelta } : {}),
|
||||||
|
...(indexEntry?.cycleAnim !== undefined ? { cycleAnim: indexEntry.cycleAnim } : {}),
|
||||||
|
...(indexEntry?.trans !== undefined ? { trans: indexEntry.trans } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (act !== undefined && packBase) {
|
||||||
|
const actEntitiesUrl = `${packBase.replace(/\/$/, '')}/act${String(act)}/entities`
|
||||||
|
try {
|
||||||
|
const [jsonResp, pngResp] = await Promise.all([
|
||||||
|
fetchFn(`${actEntitiesUrl}/object-${cleanSlug}.json`),
|
||||||
|
fetchFn(`${actEntitiesUrl}/object-${cleanSlug}.png`),
|
||||||
|
])
|
||||||
|
if (jsonResp.ok && pngResp.ok) {
|
||||||
|
const meta = (await jsonResp.json()) as PackedObjectJson
|
||||||
|
const bitmap = await createImageBitmap(await pngResp.blob())
|
||||||
|
let handle: AtlasHandle
|
||||||
|
try {
|
||||||
|
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
|
||||||
|
} finally {
|
||||||
|
bitmap.close()
|
||||||
|
}
|
||||||
|
const groups = mapRawGroupsToAtlasFrames(meta.groups)
|
||||||
|
const glowGroups =
|
||||||
|
meta.glowGroups !== undefined ? mapRawGroupsToAtlasFrames(meta.glowGroups) : undefined
|
||||||
|
const clips = meta.clips ?? meta.modes ?? {}
|
||||||
|
return {
|
||||||
|
token: meta.token || cleanSlug.toUpperCase(),
|
||||||
|
slug: cleanSlug,
|
||||||
|
handle,
|
||||||
|
groups,
|
||||||
|
...(glowGroups !== undefined ? { glowGroups } : {}),
|
||||||
|
blendMode: meta.blendMode ?? indexEntry?.blendMode ?? 'normal',
|
||||||
|
directions: Math.max(1, meta.directions || 1),
|
||||||
|
standOffset: meta.standOffset ?? 0,
|
||||||
|
standFrames: Math.max(1, meta.standFrames || 1),
|
||||||
|
clips,
|
||||||
|
...(meta.glowClips !== undefined ? { glowClips: meta.glowClips } : {}),
|
||||||
|
...(indexEntry?.frameCnt !== undefined ? { frameCnt: indexEntry.frameCnt } : {}),
|
||||||
|
...(indexEntry?.frameDelta !== undefined ? { frameDelta: indexEntry.frameDelta } : {}),
|
||||||
|
...(indexEntry?.cycleAnim !== undefined ? { cycleAnim: indexEntry.cycleAnim } : {}),
|
||||||
|
...(indexEntry?.trans !== undefined ? { trans: indexEntry.trans } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadObjectArtMap(
|
||||||
|
objectSlugs: readonly string[],
|
||||||
|
renderer: SpriteRenderer,
|
||||||
|
packBase = 'samples/d2-packs',
|
||||||
|
act?: number,
|
||||||
|
fetchFn: typeof fetch = fetch,
|
||||||
|
): Promise<Map<string, LoadedObjectArt>> {
|
||||||
|
const map = new Map<string, LoadedObjectArt>()
|
||||||
|
await Promise.all(
|
||||||
|
objectSlugs.map(async slug => {
|
||||||
|
const art = await loadObjectArt(renderer, slug, packBase, act, undefined, fetchFn)
|
||||||
|
if (art) {
|
||||||
|
map.set(slug.toLowerCase(), art)
|
||||||
|
map.set(art.slug, art)
|
||||||
|
map.set(art.token.toLowerCase(), art)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityArtCacheOptions {
|
||||||
|
readonly renderer: SpriteRenderer
|
||||||
|
readonly packBase?: string | undefined
|
||||||
|
readonly act?: number | undefined
|
||||||
|
readonly onArtLoaded?: (() => void) | undefined
|
||||||
|
readonly fetchFn?: typeof fetch | undefined
|
||||||
|
readonly initialCatalog?: PackedEntityCatalogIndex | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-demand `classId`-indexed entity art cache for Monsters/Town NPCs (`0xAC` `hcIdx`)
|
||||||
|
* and Map/Interactive Objects (`0x51` `Objects.txt` `classId`).
|
||||||
|
*
|
||||||
|
* Returns `'loading'` while asynchronous fetches are in flight so the renderer skips drawing
|
||||||
|
* silently on that frame without triggering `reportMissingArt`, and returns `'missing'` when
|
||||||
|
* an entity is absent from the catalog or fails to decode.
|
||||||
|
*/
|
||||||
|
export class EntityArtCache {
|
||||||
|
private readonly renderer: SpriteRenderer
|
||||||
|
private readonly packBase: string
|
||||||
|
private defaultAct: number | undefined
|
||||||
|
private readonly onArtLoaded: (() => void) | undefined
|
||||||
|
private readonly fetchFn: typeof fetch
|
||||||
|
|
||||||
|
private catalog: PackedEntityCatalogIndex | null = null
|
||||||
|
private indexState: 'idle' | 'loading' | 'loaded' | 'failed' = 'idle'
|
||||||
|
private indexPromise: Promise<PackedEntityCatalogIndex | null> | null = null
|
||||||
|
|
||||||
|
private readonly monsterByClassIdCache = new Map<number, LoadedMonsterArt>()
|
||||||
|
private readonly monsterBySpecKeyCache = new Map<string, LoadedMonsterArt>()
|
||||||
|
private readonly monsterInflightBySpecKey = new Map<string, Promise<LoadedMonsterArt | null>>()
|
||||||
|
private readonly inflightMonsters = new Set<number>()
|
||||||
|
private readonly failedMonsters = new Set<number>()
|
||||||
|
|
||||||
|
private readonly objectByClassIdCache = new Map<string, LoadedObjectArt>()
|
||||||
|
private readonly objectBySlugCache = new Map<string, LoadedObjectArt>()
|
||||||
|
private readonly objectInflightBySlug = new Map<string, Promise<LoadedObjectArt | null>>()
|
||||||
|
private readonly inflightObjects = new Set<string>()
|
||||||
|
private readonly failedObjects = new Set<string>()
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
rendererOrOptions: SpriteRenderer | EntityArtCacheOptions,
|
||||||
|
packBase = 'samples/d2-packs',
|
||||||
|
onArtLoaded?: () => void,
|
||||||
|
) {
|
||||||
|
if ('addIndexedAtlas' in rendererOrOptions) {
|
||||||
|
this.renderer = rendererOrOptions
|
||||||
|
this.packBase = packBase
|
||||||
|
this.onArtLoaded = onArtLoaded
|
||||||
|
this.fetchFn = typeof fetch !== 'undefined' ? fetch.bind(globalThis) : (() => Promise.reject(new Error('fetch unavailable'))) as unknown as typeof fetch
|
||||||
|
} else {
|
||||||
|
this.renderer = rendererOrOptions.renderer
|
||||||
|
this.packBase = rendererOrOptions.packBase ?? 'samples/d2-packs'
|
||||||
|
this.defaultAct = rendererOrOptions.act
|
||||||
|
this.onArtLoaded = rendererOrOptions.onArtLoaded
|
||||||
|
this.fetchFn =
|
||||||
|
rendererOrOptions.fetchFn ??
|
||||||
|
(typeof fetch !== 'undefined'
|
||||||
|
? fetch.bind(globalThis)
|
||||||
|
: ((() => Promise.reject(new Error('fetch unavailable'))) as unknown as typeof fetch))
|
||||||
|
if (rendererOrOptions.initialCatalog) {
|
||||||
|
this.catalog = rendererOrOptions.initialCatalog
|
||||||
|
this.indexState = 'loaded'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isIndexLoaded(): boolean {
|
||||||
|
return this.indexState === 'loaded' && this.catalog !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
getCatalog(): PackedEntityCatalogIndex | null {
|
||||||
|
return this.catalog
|
||||||
|
}
|
||||||
|
|
||||||
|
setCatalog(catalog: PackedEntityCatalogIndex): void {
|
||||||
|
this.catalog = catalog
|
||||||
|
this.indexState = 'loaded'
|
||||||
|
}
|
||||||
|
|
||||||
|
setMonsterArt(classId: number, art: LoadedMonsterArt): void {
|
||||||
|
this.monsterByClassIdCache.set(classId, art)
|
||||||
|
this.failedMonsters.delete(classId)
|
||||||
|
this.inflightMonsters.delete(classId)
|
||||||
|
}
|
||||||
|
|
||||||
|
setObjectArt(classId: number, art: LoadedObjectArt, act?: number): void {
|
||||||
|
this.objectByClassIdCache.set(`${classId}:${act ?? 0}`, art)
|
||||||
|
this.objectByClassIdCache.set(`${classId}:0`, art)
|
||||||
|
this.failedObjects.delete(`${classId}:${act ?? 0}`)
|
||||||
|
this.failedObjects.delete(`${classId}:0`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadIndex(indexData?: PackedEntityCatalogIndex): Promise<PackedEntityCatalogIndex | null> {
|
||||||
|
if (indexData) {
|
||||||
|
this.catalog = indexData
|
||||||
|
this.indexState = 'loaded'
|
||||||
|
return indexData
|
||||||
|
}
|
||||||
|
if (this.indexState === 'loaded' && this.catalog) {
|
||||||
|
return this.catalog
|
||||||
|
}
|
||||||
|
if (this.indexPromise) {
|
||||||
|
return this.indexPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
this.indexState = 'loading'
|
||||||
|
this.indexPromise = (async (): Promise<PackedEntityCatalogIndex | null> => {
|
||||||
|
const candidateBases = [this.packBase, '/assets', ''].filter(
|
||||||
|
(b, idx, arr) => arr.indexOf(b) === idx,
|
||||||
|
)
|
||||||
|
for (const base of candidateBases) {
|
||||||
|
const prefix = base ? `${base.replace(/\/$/, '')}/entities` : '/entities'
|
||||||
|
for (const fileName of ['entity-index.json', 'index.json']) {
|
||||||
|
try {
|
||||||
|
const resp = await this.fetchFn(`${prefix}/${fileName}`)
|
||||||
|
if (!resp.ok) continue
|
||||||
|
const parsed = (await resp.json()) as PackedEntityCatalogIndex
|
||||||
|
if (parsed && (parsed.monsterByClassId || parsed.objectByClassId || parsed.entities)) {
|
||||||
|
this.catalog = parsed
|
||||||
|
this.indexState = 'loaded'
|
||||||
|
this.onArtLoaded?.()
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try next candidate URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.indexState = 'failed'
|
||||||
|
this.onArtLoaded?.()
|
||||||
|
return null
|
||||||
|
})()
|
||||||
|
|
||||||
|
return this.indexPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForInflight(): Promise<void> {
|
||||||
|
if (this.indexPromise) {
|
||||||
|
await this.indexPromise
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
...this.monsterInflightBySpecKey.values(),
|
||||||
|
...this.objectInflightBySlug.values(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
getMonsterIndexEntry(classId: number): MonsterIndexEntry | undefined {
|
||||||
|
if (!this.catalog) return undefined
|
||||||
|
const key = String(classId)
|
||||||
|
return this.catalog.monsterByClassId?.[key] ?? this.catalog.monstersByHcIdx?.[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
getObjectIndexEntry(classId: number): ObjectIndexEntry | undefined {
|
||||||
|
if (!this.catalog) return undefined
|
||||||
|
const key = String(classId)
|
||||||
|
return this.catalog.objectByClassId?.[key] ?? this.catalog.objectsByClassId?.[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
getMonsterArt(classId: number, act = this.defaultAct): LoadedMonsterArt | 'loading' | 'missing' {
|
||||||
|
const cached = this.monsterByClassIdCache.get(classId)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
if (this.failedMonsters.has(classId)) return 'missing'
|
||||||
|
if (this.inflightMonsters.has(classId)) return 'loading'
|
||||||
|
|
||||||
|
if (this.indexState === 'idle') {
|
||||||
|
void this.loadIndex()
|
||||||
|
return 'loading'
|
||||||
|
}
|
||||||
|
if (this.indexState === 'loading') {
|
||||||
|
return 'loading'
|
||||||
|
}
|
||||||
|
if (this.indexState === 'failed' || !this.catalog) {
|
||||||
|
return 'missing'
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = this.getMonsterIndexEntry(classId)
|
||||||
|
const specKey = entry?.key ?? entry?.slug ?? null
|
||||||
|
if (!entry || !specKey) {
|
||||||
|
this.failedMonsters.add(classId)
|
||||||
|
return 'missing'
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSpec = this.monsterBySpecKeyCache.get(specKey)
|
||||||
|
if (existingSpec) {
|
||||||
|
const withScale: LoadedMonsterArt =
|
||||||
|
entry.scale !== undefined && entry.scale !== existingSpec.scale
|
||||||
|
? { ...existingSpec, scale: entry.scale }
|
||||||
|
: existingSpec
|
||||||
|
this.monsterByClassIdCache.set(classId, withScale)
|
||||||
|
return withScale
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inflightMonsters.add(classId)
|
||||||
|
let specPromise = this.monsterInflightBySpecKey.get(specKey)
|
||||||
|
if (!specPromise) {
|
||||||
|
specPromise = this.fetchMonsterBySpecKey(specKey, entry.token, act)
|
||||||
|
this.monsterInflightBySpecKey.set(specKey, specPromise)
|
||||||
|
}
|
||||||
|
|
||||||
|
void specPromise
|
||||||
|
.then(loaded => {
|
||||||
|
this.inflightMonsters.delete(classId)
|
||||||
|
if (loaded) {
|
||||||
|
this.monsterBySpecKeyCache.set(specKey, loaded)
|
||||||
|
const withScale: LoadedMonsterArt =
|
||||||
|
entry.scale !== undefined ? { ...loaded, scale: entry.scale } : loaded
|
||||||
|
this.monsterByClassIdCache.set(classId, withScale)
|
||||||
|
} else {
|
||||||
|
this.failedMonsters.add(classId)
|
||||||
|
}
|
||||||
|
this.onArtLoaded?.()
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.inflightMonsters.delete(classId)
|
||||||
|
this.failedMonsters.add(classId)
|
||||||
|
this.onArtLoaded?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
return 'loading'
|
||||||
|
}
|
||||||
|
|
||||||
|
getObjectArt(
|
||||||
|
classId: number,
|
||||||
|
act = this.defaultAct,
|
||||||
|
): LoadedObjectArt | 'loading' | 'missing' | 'non-drawable' {
|
||||||
|
const cacheKey = `${classId}:${act ?? 0}`
|
||||||
|
const cached =
|
||||||
|
this.objectByClassIdCache.get(cacheKey) ?? this.objectByClassIdCache.get(`${classId}:0`)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
if (this.failedObjects.has(cacheKey)) return 'missing'
|
||||||
|
if (this.inflightObjects.has(cacheKey)) return 'loading'
|
||||||
|
|
||||||
|
if (this.indexState === 'idle') {
|
||||||
|
void this.loadIndex()
|
||||||
|
return 'loading'
|
||||||
|
}
|
||||||
|
if (this.indexState === 'loading') {
|
||||||
|
return 'loading'
|
||||||
|
}
|
||||||
|
if (this.indexState === 'failed' || !this.catalog) {
|
||||||
|
return 'missing'
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = this.getObjectIndexEntry(classId)
|
||||||
|
if (!entry) {
|
||||||
|
this.failedObjects.add(cacheKey)
|
||||||
|
return 'missing'
|
||||||
|
}
|
||||||
|
if (entry.draw === false) {
|
||||||
|
return 'non-drawable'
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = (act !== undefined ? entry.actSlugs?.[act] : undefined) ?? entry.slug
|
||||||
|
if (!slug) {
|
||||||
|
this.failedObjects.add(cacheKey)
|
||||||
|
return 'missing'
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingSlug = this.objectBySlugCache.get(slug)
|
||||||
|
if (existingSlug) {
|
||||||
|
const merged = this.mergeObjectIndexMetadata(existingSlug, entry)
|
||||||
|
this.objectByClassIdCache.set(cacheKey, merged)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inflightObjects.add(cacheKey)
|
||||||
|
let slugPromise = this.objectInflightBySlug.get(slug)
|
||||||
|
if (!slugPromise) {
|
||||||
|
slugPromise = loadObjectArt(this.renderer, slug, this.packBase, act, entry, this.fetchFn)
|
||||||
|
this.objectInflightBySlug.set(slug, slugPromise)
|
||||||
|
}
|
||||||
|
|
||||||
|
void slugPromise
|
||||||
|
.then(loaded => {
|
||||||
|
this.inflightObjects.delete(cacheKey)
|
||||||
|
if (loaded) {
|
||||||
|
this.objectBySlugCache.set(slug, loaded)
|
||||||
|
const merged = this.mergeObjectIndexMetadata(loaded, entry)
|
||||||
|
this.objectByClassIdCache.set(cacheKey, merged)
|
||||||
|
} else {
|
||||||
|
this.failedObjects.add(cacheKey)
|
||||||
|
}
|
||||||
|
this.onArtLoaded?.()
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.inflightObjects.delete(cacheKey)
|
||||||
|
this.failedObjects.add(cacheKey)
|
||||||
|
this.onArtLoaded?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
return 'loading'
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeObjectIndexMetadata(
|
||||||
|
art: LoadedObjectArt,
|
||||||
|
entry: ObjectIndexEntry,
|
||||||
|
): LoadedObjectArt {
|
||||||
|
return {
|
||||||
|
...art,
|
||||||
|
...(entry.frameCnt !== undefined ? { frameCnt: entry.frameCnt } : {}),
|
||||||
|
...(entry.frameDelta !== undefined ? { frameDelta: entry.frameDelta } : {}),
|
||||||
|
...(entry.cycleAnim !== undefined ? { cycleAnim: entry.cycleAnim } : {}),
|
||||||
|
...(entry.trans !== undefined ? { trans: entry.trans } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchMonsterBySpecKey(
|
||||||
|
specKey: string,
|
||||||
|
token: string,
|
||||||
|
act?: number,
|
||||||
|
): Promise<LoadedMonsterArt | null> {
|
||||||
|
const slug = specKey.startsWith('monster-') ? specKey : `monster-${specKey}`
|
||||||
|
const candidateBases = [this.packBase, '/assets', ''].filter(
|
||||||
|
(b, idx, arr) => arr.indexOf(b) === idx,
|
||||||
|
)
|
||||||
|
for (const base of candidateBases) {
|
||||||
|
const prefix = base ? `${base.replace(/\/$/, '')}/entities` : '/entities'
|
||||||
|
for (const subPath of [`${prefix}/${slug}`, `${prefix}/monsters/${slug}`]) {
|
||||||
|
try {
|
||||||
|
const [jsonResp, r8Resp] = await Promise.all([
|
||||||
|
this.fetchFn(`${subPath}.json`),
|
||||||
|
this.fetchFn(`${subPath}.r8`),
|
||||||
|
])
|
||||||
|
if (jsonResp.ok && r8Resp.ok) {
|
||||||
|
const meta = (await jsonResp.json()) as PackedMonsterJson
|
||||||
|
const indices = new Uint8Array(await r8Resp.arrayBuffer())
|
||||||
|
const handle = this.renderer.addIndexedAtlas(indices, meta.width, meta.height)
|
||||||
|
const groups = mapRawGroupsToAtlasFrames(meta.groups)
|
||||||
|
const glowGroups =
|
||||||
|
meta.glowGroups !== undefined ? mapRawGroupsToAtlasFrames(meta.glowGroups) : undefined
|
||||||
|
const clips = meta.clips ?? meta.modes
|
||||||
|
return {
|
||||||
|
token: meta.token || token,
|
||||||
|
handle,
|
||||||
|
groups,
|
||||||
|
...(glowGroups !== undefined ? { glowGroups } : {}),
|
||||||
|
...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}),
|
||||||
|
directions: Math.max(1, meta.directions || 1),
|
||||||
|
walkOffset: meta.walkOffset ?? meta.standOffset ?? 0,
|
||||||
|
standOffset: meta.standOffset ?? 0,
|
||||||
|
walkFrames: Math.max(1, meta.walkFrames ?? meta.standFrames ?? 1),
|
||||||
|
standFrames: Math.max(1, meta.standFrames ?? 1),
|
||||||
|
...(clips !== undefined ? { clips } : {}),
|
||||||
|
...(meta.glowClips !== undefined ? { glowClips: meta.glowClips } : {}),
|
||||||
|
layerFailures: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (act !== undefined && this.packBase) {
|
||||||
|
const actEntitiesUrl = `${this.packBase.replace(/\/$/, '')}/act${String(act)}/entities`
|
||||||
|
try {
|
||||||
|
const [jsonResp, pngResp] = await Promise.all([
|
||||||
|
this.fetchFn(`${actEntitiesUrl}/${slug}.json`),
|
||||||
|
this.fetchFn(`${actEntitiesUrl}/${slug}.png`),
|
||||||
|
])
|
||||||
|
if (jsonResp.ok && pngResp.ok) {
|
||||||
|
const meta = (await jsonResp.json()) as PackedMonsterJson
|
||||||
|
const bitmap = await createImageBitmap(await pngResp.blob())
|
||||||
|
let handle: AtlasHandle
|
||||||
|
try {
|
||||||
|
handle = this.renderer.addAtlas(bitmap, meta.width, meta.height)
|
||||||
|
} finally {
|
||||||
|
bitmap.close()
|
||||||
|
}
|
||||||
|
const groups = mapRawGroupsToAtlasFrames(meta.groups)
|
||||||
|
const glowGroups =
|
||||||
|
meta.glowGroups !== undefined ? mapRawGroupsToAtlasFrames(meta.glowGroups) : undefined
|
||||||
|
const clips = meta.clips ?? meta.modes
|
||||||
|
return {
|
||||||
|
token: meta.token || token,
|
||||||
|
handle,
|
||||||
|
groups,
|
||||||
|
...(glowGroups !== undefined ? { glowGroups } : {}),
|
||||||
|
...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}),
|
||||||
|
directions: Math.max(1, meta.directions || 1),
|
||||||
|
walkOffset: meta.walkOffset ?? meta.standOffset ?? 0,
|
||||||
|
standOffset: meta.standOffset ?? 0,
|
||||||
|
walkFrames: Math.max(1, meta.walkFrames ?? meta.standFrames ?? 1),
|
||||||
|
standFrames: Math.max(1, meta.standFrames ?? 1),
|
||||||
|
...(clips !== undefined ? { clips } : {}),
|
||||||
|
...(meta.glowClips !== undefined ? { glowClips: meta.glowClips } : {}),
|
||||||
|
layerFailures: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import type {
|
||||||
AtlasHandle,
|
AtlasHandle,
|
||||||
SpriteRenderer,
|
SpriteRenderer,
|
||||||
} from '../render/renderer.ts'
|
} from '../render/renderer.ts'
|
||||||
|
import { reportMissingArt } from './world-renderer.ts'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
LightningTrailManager,
|
LightningTrailManager,
|
||||||
|
|
@ -451,21 +452,11 @@ export function drawCastOverlay(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback procedural representation in test/offline environments
|
reportMissingArt(
|
||||||
const [r255, g255, b255] = overlay.lightColor
|
'overlay',
|
||||||
const r = r255 / 255
|
overlay.overlayName || 'cast_overlay',
|
||||||
const g = g255 / 255
|
`Missing cast overlay art for ${overlay.overlayName || 'cast_overlay'}`,
|
||||||
const b = b255 / 255
|
|
||||||
const fallbackRadius = 16
|
|
||||||
renderer.drawSolid(
|
|
||||||
overlay.x - fallbackRadius,
|
|
||||||
overlay.y - fallbackRadius,
|
|
||||||
fallbackRadius * 2,
|
|
||||||
fallbackRadius * 2,
|
|
||||||
[r, g, b, 0.6],
|
|
||||||
{ blendMode: 'additive' },
|
|
||||||
)
|
)
|
||||||
renderer.setBlendMode('normal')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -473,7 +464,6 @@ export function drawCastOverlay(
|
||||||
* - Plays 15-frame opening sequence (`OP`: `tptrlitophth` + `tphdlitophth`) at 200/256 * 25 fps (51.2 ms/frame, 768 ms total),
|
* - Plays 15-frame opening sequence (`OP`: `tptrlitophth` + `tphdlitophth`) at 200/256 * 25 fps (51.2 ms/frame, 768 ms total),
|
||||||
* then loops 15-frame operating sequence (`ON`: `tptrlitonhth` + `tphdlitonhth`).
|
* then loops 15-frame operating sequence (`ON`: `tptrlitonhth` + `tphdlitonhth`).
|
||||||
* - Draws `TR` ground shadow layer with normal blending and `HD` blue vortex layer with additive blending.
|
* - Draws `TR` ground shadow layer with normal blending and `HD` blue vortex layer with additive blending.
|
||||||
* - Falls back to a procedural blue portal vortex oval in unit test / offline environments.
|
|
||||||
*/
|
*/
|
||||||
export function drawTownPortal(
|
export function drawTownPortal(
|
||||||
renderer: SpriteRenderer,
|
renderer: SpriteRenderer,
|
||||||
|
|
@ -521,7 +511,10 @@ export function drawTownPortal(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!drewSprite) {
|
if (!drewSprite) {
|
||||||
renderer.drawSolid(x - 18, y - 82, 36, 82, [0.22, 0.48, 1.0, 0.75], { blendMode: 'additive' })
|
reportMissingArt(
|
||||||
renderer.setBlendMode('normal')
|
'overlay',
|
||||||
|
isOpening ? 'town_portal_op' : 'town_portal_on',
|
||||||
|
'Missing town portal sprite atlas in overlayArtMap',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ import type { AutomapEntityMarker, Minimap, MinimapLevel } from '../ui/minimap.t
|
||||||
import type { HudManager } from '../ui/hud-manager.ts'
|
import type { HudManager } from '../ui/hud-manager.ts'
|
||||||
import { getTblLang } from '../i18n/lang.ts'
|
import { getTblLang } from '../i18n/lang.ts'
|
||||||
import { isTownLevel } from '../../common/world/scene-population.ts'
|
import { isTownLevel } from '../../common/world/scene-population.ts'
|
||||||
import type { LoadedCharacter } from './entity-art-loader.ts'
|
import type {
|
||||||
|
EntityArtCache,
|
||||||
|
LoadedCharacter,
|
||||||
|
LoadedObjectArt,
|
||||||
|
} from './entity-art-loader.ts'
|
||||||
import type { LightingPreset } from './lighting-presets.ts'
|
import type { LightingPreset } from './lighting-presets.ts'
|
||||||
import {
|
import {
|
||||||
drawTownPortal,
|
drawTownPortal,
|
||||||
|
|
@ -16,9 +20,6 @@ import {
|
||||||
|
|
||||||
/** Player feet footprint height (scene px), used to anchor sprite baselines. */
|
/** Player feet footprint height (scene px), used to anchor sprite baselines. */
|
||||||
const FEET_HEIGHT = 10
|
const FEET_HEIGHT = 10
|
||||||
/** Placeholder marker size (scene px) drawn when a unit has no loaded art. */
|
|
||||||
const MARKER_WIDTH = 16
|
|
||||||
const MARKER_HEIGHT = 26
|
|
||||||
|
|
||||||
import type { AutomapView } from '../automap/automap-view.ts'
|
import type { AutomapView } from '../automap/automap-view.ts'
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -34,13 +35,75 @@ import type {
|
||||||
} from '../view/unit-drawables.ts'
|
} from '../view/unit-drawables.ts'
|
||||||
import { MONSTER_ART_MAP } from '../../common/units/monster-mapping.ts'
|
import { MONSTER_ART_MAP } from '../../common/units/monster-mapping.ts'
|
||||||
import { getTownNpcDescriptorByHcIdx } from '../../common/units/npc-table.ts'
|
import { getTownNpcDescriptorByHcIdx } from '../../common/units/npc-table.ts'
|
||||||
import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts'
|
import { BAKED_UI_MANIFEST, type SpriteRect } from '../../common/items/baked-ui-meta.ts'
|
||||||
import { resolveGroundItemSpriteRect } from '../ui/inventory.ts'
|
import { resolveGroundItemSpriteRect } from '../ui/inventory.ts'
|
||||||
import {
|
import {
|
||||||
applyLightingInputs,
|
applyLightingInputs,
|
||||||
type LightingPassTargets,
|
type LightingPassTargets,
|
||||||
} from './lighting-presets.ts'
|
} from './lighting-presets.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostic entry recorded whenever an entity, item, missile, overlay, or tile
|
||||||
|
* is missing its authentic art asset at render time (Issue #540 R3).
|
||||||
|
*/
|
||||||
|
export interface MissingArtEntry {
|
||||||
|
readonly category: string
|
||||||
|
readonly id: string
|
||||||
|
readonly detail?: string | undefined
|
||||||
|
readonly timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__d2MissingArt?: MissingArtEntry[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingArtRegistry = new Map<string, MissingArtEntry>()
|
||||||
|
const missingArtList: MissingArtEntry[] = []
|
||||||
|
const dropStartMsByItemId = new Map<number, number>()
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && !window.__d2MissingArt) {
|
||||||
|
window.__d2MissingArt = missingArtList
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports a missing art asset once per `(category, id)` pair, logs via `console.error`,
|
||||||
|
* and records it in `window.__d2MissingArt` for automated browser verification.
|
||||||
|
* Never throws synchronous exceptions inside the RAF loop.
|
||||||
|
*/
|
||||||
|
export function reportMissingArt(category: string, id: string, detail?: string): void {
|
||||||
|
const normCategory = category.trim() || 'unknown'
|
||||||
|
const normId = id.trim() || 'unknown'
|
||||||
|
const key = `${normCategory}:${normId}`
|
||||||
|
if (missingArtRegistry.has(key)) return
|
||||||
|
const entry: MissingArtEntry = {
|
||||||
|
category: normCategory,
|
||||||
|
id: normId,
|
||||||
|
...(detail !== undefined ? { detail } : {}),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
missingArtRegistry.set(key, entry)
|
||||||
|
missingArtList.push(entry)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__d2MissingArt = missingArtList
|
||||||
|
}
|
||||||
|
console.error(`[D2MissingArt] ${normCategory}:${normId}${detail ? ` — ${detail}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMissingArtEntries(): readonly MissingArtEntry[] {
|
||||||
|
return missingArtList
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearMissingArtEntries(): void {
|
||||||
|
missingArtRegistry.clear()
|
||||||
|
missingArtList.length = 0
|
||||||
|
dropStartMsByItemId.clear()
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__d2MissingArt = missingArtList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const COMMON_HCIDX_TO_MONSTER_ID: Readonly<Record<number, string>> = {
|
const COMMON_HCIDX_TO_MONSTER_ID: Readonly<Record<number, string>> = {
|
||||||
0: 'skeleton1',
|
0: 'skeleton1',
|
||||||
1: 'returned1',
|
1: 'returned1',
|
||||||
|
|
@ -109,6 +172,51 @@ const COMMON_HCIDX_TO_MONSTER_ID: Readonly<Record<number, string>> = {
|
||||||
520: 'cain5',
|
520: 'cain5',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLAYER_MODE_TO_CLIP: Readonly<Record<number, string>> = {
|
||||||
|
0: 'dt',
|
||||||
|
1: 'nu',
|
||||||
|
2: 'wl',
|
||||||
|
3: 'rn',
|
||||||
|
4: 'gh',
|
||||||
|
5: 'tn',
|
||||||
|
6: 'tw',
|
||||||
|
7: 'a1',
|
||||||
|
8: 'a2',
|
||||||
|
9: 'bl',
|
||||||
|
10: 'sc',
|
||||||
|
17: 'dd',
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONSTER_MODE_TO_CLIP: Readonly<Record<number, string>> = {
|
||||||
|
0: 'dt',
|
||||||
|
1: 'nu',
|
||||||
|
2: 'wl',
|
||||||
|
3: 'gh',
|
||||||
|
4: 'a1',
|
||||||
|
5: 'a2',
|
||||||
|
6: 'bl',
|
||||||
|
7: 'sc',
|
||||||
|
8: 's1',
|
||||||
|
9: 's2',
|
||||||
|
10: 's3',
|
||||||
|
11: 's4',
|
||||||
|
12: 'dd',
|
||||||
|
13: 'rn',
|
||||||
|
14: 'xx',
|
||||||
|
15: 'rn',
|
||||||
|
}
|
||||||
|
|
||||||
|
const OBJECT_MODE_TO_CLIP: Readonly<Record<number, string>> = {
|
||||||
|
0: 'nu',
|
||||||
|
1: 'op',
|
||||||
|
2: 'on',
|
||||||
|
3: 's1',
|
||||||
|
4: 's2',
|
||||||
|
5: 's3',
|
||||||
|
6: 's4',
|
||||||
|
7: 's5',
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronous GPU cache uploading packed Act 1..5 R8 tile pages and 768-byte ACT palettes
|
* Asynchronous GPU cache uploading packed Act 1..5 R8 tile pages and 768-byte ACT palettes
|
||||||
* to WebGL2 `SpriteRenderer`.
|
* to WebGL2 `SpriteRenderer`.
|
||||||
|
|
@ -145,6 +253,7 @@ export class TileAtlasGpuCache {
|
||||||
this.requestedPalettes.add(normalizedAct)
|
this.requestedPalettes.add(normalizedAct)
|
||||||
const urls = [
|
const urls = [
|
||||||
`/act-packs/tiles/act${normalizedAct}/palette.bin`,
|
`/act-packs/tiles/act${normalizedAct}/palette.bin`,
|
||||||
|
`/assets/tiles/act${normalizedAct}/palette.bin`,
|
||||||
`/samples/d2-packs/tiles/act${normalizedAct}/palette.bin`,
|
`/samples/d2-packs/tiles/act${normalizedAct}/palette.bin`,
|
||||||
]
|
]
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
@ -165,6 +274,11 @@ export class TileAtlasGpuCache {
|
||||||
// try fallback url
|
// try fallback url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
reportMissingArt(
|
||||||
|
'tile-palette',
|
||||||
|
`act${normalizedAct}`,
|
||||||
|
`Failed to load act${normalizedAct} palette.bin from ${urls.join(', ')}`,
|
||||||
|
)
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,6 +295,7 @@ export class TileAtlasGpuCache {
|
||||||
this.inflightPages.add(key)
|
this.inflightPages.add(key)
|
||||||
const urls = [
|
const urls = [
|
||||||
`/act-packs/tiles/act${normalizedAct}/tiles-${pageIndex}.r8`,
|
`/act-packs/tiles/act${normalizedAct}/tiles-${pageIndex}.r8`,
|
||||||
|
`/assets/tiles/act${normalizedAct}/tiles-${pageIndex}.r8`,
|
||||||
`/samples/d2-packs/tiles/act${normalizedAct}/tiles-${pageIndex}.r8`,
|
`/samples/d2-packs/tiles/act${normalizedAct}/tiles-${pageIndex}.r8`,
|
||||||
]
|
]
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
@ -199,6 +314,11 @@ export class TileAtlasGpuCache {
|
||||||
// try fallback url
|
// try fallback url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
reportMissingArt(
|
||||||
|
'tile-page',
|
||||||
|
`act${normalizedAct}:${pageIndex}`,
|
||||||
|
`Failed to load act${normalizedAct} tiles-${pageIndex}.r8 from ${urls.join(', ')}`,
|
||||||
|
)
|
||||||
})()
|
})()
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
@ -210,9 +330,11 @@ export interface SceneSourceRenderContext {
|
||||||
readonly lightingTargets?: LightingPassTargets | undefined
|
readonly lightingTargets?: LightingPassTargets | undefined
|
||||||
readonly renderer?: SpriteRenderer | null | undefined
|
readonly renderer?: SpriteRenderer | null | undefined
|
||||||
readonly tileGpuCache?: TileAtlasGpuCache | null | undefined
|
readonly tileGpuCache?: TileAtlasGpuCache | null | undefined
|
||||||
|
readonly entityArtCache?: EntityArtCache | null | undefined
|
||||||
readonly characterArt?: LoadedCharacter | null | undefined
|
readonly characterArt?: LoadedCharacter | null | undefined
|
||||||
readonly characterArtByToken?: ReadonlyMap<string, LoadedCharacter> | undefined
|
readonly characterArtByToken?: ReadonlyMap<string, LoadedCharacter> | undefined
|
||||||
readonly monsterArtMap?: ReadonlyMap<string, LoadedMonsterArt> | undefined
|
readonly monsterArtMap?: ReadonlyMap<string, LoadedMonsterArt> | undefined
|
||||||
|
readonly objectArtMap?: ReadonlyMap<string, LoadedObjectArt> | undefined
|
||||||
readonly missileArtMap?: ReadonlyMap<string, LoadedMissileArt> | undefined
|
readonly missileArtMap?: ReadonlyMap<string, LoadedMissileArt> | undefined
|
||||||
readonly overlayArtMap?: ReadonlyMap<string, LoadedOverlayArt> | undefined
|
readonly overlayArtMap?: ReadonlyMap<string, LoadedOverlayArt> | undefined
|
||||||
readonly itemsAtlasHandle?: AtlasHandle | undefined
|
readonly itemsAtlasHandle?: AtlasHandle | undefined
|
||||||
|
|
@ -274,6 +396,7 @@ function drawSceneUnitDrawable(
|
||||||
unit: SceneUnitDrawable,
|
unit: SceneUnitDrawable,
|
||||||
ctx: SceneSourceRenderContext,
|
ctx: SceneSourceRenderContext,
|
||||||
nowMs: number,
|
nowMs: number,
|
||||||
|
actId: number,
|
||||||
): void {
|
): void {
|
||||||
if (unit.unitKind === 'player') {
|
if (unit.unitKind === 'player') {
|
||||||
const charArt =
|
const charArt =
|
||||||
|
|
@ -282,21 +405,35 @@ function drawSceneUnitDrawable(
|
||||||
ctx.characterArt ??
|
ctx.characterArt ??
|
||||||
null
|
null
|
||||||
if (charArt !== null) {
|
if (charArt !== null) {
|
||||||
const dirCount = Math.max(1, charArt.directions)
|
const isMoving =
|
||||||
|
Boolean(ctx.playerMoving) || unit.mode === 2 || unit.mode === 3 || unit.mode === 6
|
||||||
|
const requestedClipKey =
|
||||||
|
Boolean(ctx.playerMoving) && (unit.mode === 1 || unit.mode === 5)
|
||||||
|
? unit.mode === 5 && charArt.clips?.tw
|
||||||
|
? 'tw'
|
||||||
|
: 'wl'
|
||||||
|
: (PLAYER_MODE_TO_CLIP[unit.mode] ?? (isMoving ? 'wl' : 'nu'))
|
||||||
|
const clip =
|
||||||
|
charArt.clips?.[requestedClipKey] ??
|
||||||
|
(isMoving ? charArt.clips?.wl : charArt.clips?.nu)
|
||||||
|
const dirCount = Math.max(1, clip?.directions ?? charArt.directions)
|
||||||
const direction =
|
const direction =
|
||||||
ctx.playerFacingRadians !== undefined
|
ctx.playerFacingRadians !== undefined
|
||||||
? facingToDirection(ctx.playerFacingRadians, dirCount)
|
? facingToDirection(ctx.playerFacingRadians, dirCount)
|
||||||
: ((unit.direction % dirCount) + dirCount) % dirCount
|
: ((unit.direction % dirCount) + dirCount) % dirCount
|
||||||
const isMoving = Boolean(ctx.playerMoving) || unit.mode === 2 || unit.mode === 3
|
const baseGroup = clip !== undefined ? clip.group : isMoving ? charArt.walk : charArt.stand
|
||||||
const groupIndex = (isMoving ? charArt.walk : charArt.stand) + direction
|
const groupIndex = baseGroup + direction
|
||||||
const group =
|
const group =
|
||||||
charArt.groups[groupIndex] ??
|
charArt.groups[groupIndex] ??
|
||||||
charArt.groups[charArt.stand + direction] ??
|
charArt.groups[charArt.stand + direction] ??
|
||||||
charArt.groups[0]
|
charArt.groups[0]
|
||||||
const frameCount = Math.max(1, group?.length ?? 1)
|
const frameCount = Math.max(1, group?.length ?? 1)
|
||||||
const frameIndex = isMoving
|
const frameIndex =
|
||||||
? Math.floor(nowMs / 65) % frameCount
|
unit.isCorpse || requestedClipKey === 'dd'
|
||||||
: Math.floor(nowMs / 140) % frameCount
|
? frameCount - 1
|
||||||
|
: isMoving
|
||||||
|
? Math.floor(nowMs / 65) % frameCount
|
||||||
|
: Math.floor(nowMs / 140) % frameCount
|
||||||
const frame = group?.[frameIndex] ?? group?.[0]
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
if (frame !== undefined) {
|
if (frame !== undefined) {
|
||||||
const drawX =
|
const drawX =
|
||||||
|
|
@ -314,30 +451,54 @@ function drawSceneUnitDrawable(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
renderer.drawSolid(
|
reportMissingArt(
|
||||||
unit.worldX - MARKER_WIDTH / 2,
|
'player',
|
||||||
unit.worldY - MARKER_HEIGHT,
|
unit.token || String(unit.classId),
|
||||||
MARKER_WIDTH,
|
`Missing player character art for token=${unit.token} classId=${unit.classId}`,
|
||||||
MARKER_HEIGHT,
|
|
||||||
[0.85, 0.75, 0.45, 1],
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unit.unitKind === 'monster') {
|
if (unit.unitKind === 'monster') {
|
||||||
const art = resolveOnlineMonsterArt(unit, ctx.monsterArtMap)
|
let art: LoadedMonsterArt | undefined
|
||||||
|
if (ctx.entityArtCache) {
|
||||||
|
const cacheRes = ctx.entityArtCache.getMonsterArt(unit.classId, actId)
|
||||||
|
if (cacheRes === 'loading') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (cacheRes !== 'missing') {
|
||||||
|
art = cacheRes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (art === undefined) {
|
||||||
|
art = resolveOnlineMonsterArt(unit, ctx.monsterArtMap)
|
||||||
|
}
|
||||||
if (art !== undefined) {
|
if (art !== undefined) {
|
||||||
const dirCount = Math.max(1, art.directions)
|
const isMoving = unit.mode === 2 || unit.mode === 13 || unit.mode === 15
|
||||||
|
const requestedClipKey = unit.isCorpse
|
||||||
|
? art.clips?.dd
|
||||||
|
? 'dd'
|
||||||
|
: art.clips?.dt
|
||||||
|
? 'dt'
|
||||||
|
: 'nu'
|
||||||
|
: (MONSTER_MODE_TO_CLIP[unit.mode] ?? (isMoving ? 'wl' : 'nu'))
|
||||||
|
const clip =
|
||||||
|
art.clips?.[requestedClipKey] ??
|
||||||
|
(isMoving ? art.clips?.wl : undefined) ??
|
||||||
|
art.clips?.nu
|
||||||
|
const dirCount = Math.max(1, clip?.directions ?? art.directions)
|
||||||
const direction = ((unit.direction % dirCount) + dirCount) % dirCount
|
const direction = ((unit.direction % dirCount) + dirCount) % dirCount
|
||||||
const isMoving = unit.mode === 2 || unit.mode === 13
|
const baseGroup =
|
||||||
const groupIndex = (isMoving ? art.walkOffset : art.standOffset) + direction
|
clip !== undefined ? clip.group : isMoving ? art.walkOffset : art.standOffset
|
||||||
|
const groupIndex = baseGroup + direction
|
||||||
const group = art.groups[groupIndex] ?? art.groups[art.standOffset + direction] ?? art.groups[0]
|
const group = art.groups[groupIndex] ?? art.groups[art.standOffset + direction] ?? art.groups[0]
|
||||||
const frameCount = Math.max(1, group?.length ?? 1)
|
const frameCount = Math.max(1, group?.length ?? 1)
|
||||||
const frameIndex = unit.isCorpse
|
const frameIndex =
|
||||||
? frameCount - 1
|
unit.isCorpse || requestedClipKey === 'dd'
|
||||||
: isMoving
|
? frameCount - 1
|
||||||
? Math.floor(nowMs / 75) % frameCount
|
: isMoving
|
||||||
: Math.floor(nowMs / 130) % frameCount
|
? Math.floor(nowMs / 75) % frameCount
|
||||||
|
: Math.floor(nowMs / 130) % frameCount
|
||||||
const frame = group?.[frameIndex] ?? group?.[0]
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
if (frame !== undefined) {
|
if (frame !== undefined) {
|
||||||
const drawX =
|
const drawX =
|
||||||
|
|
@ -367,7 +528,9 @@ function drawSceneUnitDrawable(
|
||||||
...(tint ? { tint } : {}),
|
...(tint ? { tint } : {}),
|
||||||
})
|
})
|
||||||
if (art.glowGroups !== undefined) {
|
if (art.glowGroups !== undefined) {
|
||||||
const glowGroup = art.glowGroups[groupIndex]
|
const glowClip = art.glowClips?.[requestedClipKey]
|
||||||
|
const glowGroupIndex = glowClip !== undefined ? glowClip.group + direction : groupIndex
|
||||||
|
const glowGroup = art.glowGroups[glowGroupIndex] ?? art.glowGroups[groupIndex]
|
||||||
const glowFrame = glowGroup?.[frameIndex] ?? glowGroup?.[0]
|
const glowFrame = glowGroup?.[frameIndex] ?? glowGroup?.[0]
|
||||||
if (glowFrame !== undefined && (glowFrame.width > 1 || glowFrame.height > 1)) {
|
if (glowFrame !== undefined && (glowFrame.width > 1 || glowFrame.height > 1)) {
|
||||||
renderer.draw(glowFrame, drawX, drawY, {
|
renderer.draw(glowFrame, drawX, drawY, {
|
||||||
|
|
@ -379,27 +542,66 @@ function drawSceneUnitDrawable(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!unit.isCorpse) {
|
reportMissingArt(
|
||||||
const isTownNpc = getTownNpcDescriptorByHcIdx(unit.classId) !== undefined
|
'monster',
|
||||||
renderer.drawSolid(
|
String(unit.classId),
|
||||||
unit.worldX - MARKER_WIDTH / 2,
|
`Missing monster/NPC art for classId=${unit.classId} token=${unit.token}`,
|
||||||
unit.worldY - MARKER_HEIGHT,
|
)
|
||||||
MARKER_WIDTH,
|
|
||||||
MARKER_HEIGHT,
|
|
||||||
isTownNpc ? [0.25, 0.85, 0.35, 1] : [0.9, 0.25, 0.25, 1],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unit.unitKind === 'item') {
|
if (unit.unitKind === 'item') {
|
||||||
const code = unit.token.startsWith('ITM_') ? 'gld' : unit.token.toLowerCase()
|
const rawCode =
|
||||||
const sr =
|
unit.code?.trim().toLowerCase() ||
|
||||||
resolveGroundItemSpriteRect(
|
(unit.token && !unit.token.startsWith('ITM_') ? unit.token.trim().toLowerCase() : '') ||
|
||||||
{ isGold: code === 'gld', amount: 50, code },
|
BAKED_UI_MANIFEST.itemClassIdToCode[unit.classId] ||
|
||||||
|
''
|
||||||
|
const isGold = rawCode === 'gld'
|
||||||
|
const goldAmt = unit.goldAmount ?? 50
|
||||||
|
let sr: SpriteRect | null = null
|
||||||
|
|
||||||
|
if (unit.mode === 5 && rawCode) {
|
||||||
|
const flippyFile = isGold
|
||||||
|
? 'flpgld'
|
||||||
|
: rawCode === 'cm1'
|
||||||
|
? 'flpchm1'
|
||||||
|
: rawCode === 'cm2'
|
||||||
|
? 'flpchm2'
|
||||||
|
: rawCode === 'cm3'
|
||||||
|
? 'flpchm3'
|
||||||
|
: BAKED_UI_MANIFEST.codeToFlippyFile[rawCode]
|
||||||
|
const frames = flippyFile ? BAKED_UI_MANIFEST.flippyFrames?.[flippyFile] : undefined
|
||||||
|
if (frames && frames.length > 0) {
|
||||||
|
let frameIdx = 0
|
||||||
|
if (unit.frame > 0) {
|
||||||
|
frameIdx = Math.min(frames.length - 1, unit.frame)
|
||||||
|
} else {
|
||||||
|
let startMs = dropStartMsByItemId.get(unit.unitId)
|
||||||
|
if (startMs === undefined || nowMs < startMs) {
|
||||||
|
startMs = nowMs
|
||||||
|
dropStartMsByItemId.set(unit.unitId, startMs)
|
||||||
|
}
|
||||||
|
frameIdx = Math.min(frames.length - 1, Math.floor(Math.max(0, nowMs - startMs) / 40))
|
||||||
|
}
|
||||||
|
sr = frames[frameIdx] ?? frames[frames.length - 1] ?? null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dropStartMsByItemId.delete(unit.unitId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sr && rawCode) {
|
||||||
|
sr = resolveGroundItemSpriteRect(
|
||||||
|
{
|
||||||
|
isGold,
|
||||||
|
amount: goldAmt,
|
||||||
|
code: rawCode,
|
||||||
|
...(unit.name ? { name: unit.name } : {}),
|
||||||
|
},
|
||||||
BAKED_UI_MANIFEST.flippyRects,
|
BAKED_UI_MANIFEST.flippyRects,
|
||||||
BAKED_UI_MANIFEST.codeToFlippyFile,
|
BAKED_UI_MANIFEST.codeToFlippyFile,
|
||||||
) ?? BAKED_UI_MANIFEST.flippyRects['flpgld']
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (sr && ctx.itemsAtlasHandle !== undefined) {
|
if (sr && ctx.itemsAtlasHandle !== undefined) {
|
||||||
renderer.draw(
|
renderer.draw(
|
||||||
{ x: sr.x, y: sr.y, width: sr.w, height: sr.h },
|
{ x: sr.x, y: sr.y, width: sr.w, height: sr.h },
|
||||||
|
|
@ -411,9 +613,13 @@ function drawSceneUnitDrawable(
|
||||||
height: sr.h,
|
height: sr.h,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
return
|
||||||
renderer.drawSolid(unit.worldX - 6, unit.worldY - 6, 12, 6, [1.0, 0.84, 0.2, 0.9])
|
|
||||||
}
|
}
|
||||||
|
reportMissingArt(
|
||||||
|
'item-flippy',
|
||||||
|
rawCode || unit.token || String(unit.classId),
|
||||||
|
`Missing ground item flippy sprite for code=${rawCode || unit.token} classId=${unit.classId}`,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -424,12 +630,96 @@ function drawSceneUnitDrawable(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
renderer.drawSolid(
|
|
||||||
unit.worldX - 10,
|
let objArt: LoadedObjectArt | undefined
|
||||||
unit.worldY - 20,
|
if (ctx.entityArtCache) {
|
||||||
20,
|
const cacheRes = ctx.entityArtCache.getObjectArt(unit.classId, actId)
|
||||||
20,
|
if (cacheRes === 'loading' || cacheRes === 'non-drawable') {
|
||||||
[0.62, 0.52, 0.32, 0.65],
|
return
|
||||||
|
}
|
||||||
|
if (cacheRes !== 'missing') {
|
||||||
|
objArt = cacheRes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (objArt === undefined && ctx.objectArtMap) {
|
||||||
|
const lowerToken = unit.token.trim().toLowerCase()
|
||||||
|
objArt =
|
||||||
|
ctx.objectArtMap.get(lowerToken) ??
|
||||||
|
ctx.objectArtMap.get(`object-${lowerToken}`) ??
|
||||||
|
ctx.objectArtMap.get(`classid:${unit.classId}`) ??
|
||||||
|
ctx.objectArtMap.get(String(unit.classId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (objArt !== undefined) {
|
||||||
|
const modeIdx = Math.max(0, Math.min(7, unit.mode))
|
||||||
|
const requestedMode = OBJECT_MODE_TO_CLIP[modeIdx] ?? 'nu'
|
||||||
|
const clipKey = objArt.clips[requestedMode]
|
||||||
|
? requestedMode
|
||||||
|
: modeIdx === 2 && objArt.clips.op
|
||||||
|
? 'op'
|
||||||
|
: modeIdx === 1 && objArt.clips.on
|
||||||
|
? 'on'
|
||||||
|
: objArt.clips.nu
|
||||||
|
? 'nu'
|
||||||
|
: (Object.keys(objArt.clips)[0] ?? 'nu')
|
||||||
|
const clip = objArt.clips[clipKey]
|
||||||
|
const dirCount = Math.max(1, clip?.directions ?? objArt.directions)
|
||||||
|
const direction = ((unit.direction % dirCount) + dirCount) % dirCount
|
||||||
|
const groupIndex = (clip?.group ?? objArt.standOffset) + direction
|
||||||
|
const group =
|
||||||
|
objArt.groups[groupIndex] ?? objArt.groups[objArt.standOffset] ?? objArt.groups[0]
|
||||||
|
const frameCount = Math.max(1, group?.length ?? 1)
|
||||||
|
const isOpenedFallbackToOp = modeIdx === 2 && clipKey === 'op' && !objArt.clips.on
|
||||||
|
const shouldLoop =
|
||||||
|
!isOpenedFallbackToOp &&
|
||||||
|
(objArt.cycleAnim?.[modeIdx] ?? clip?.loop ?? modeIdx !== 1)
|
||||||
|
let frameIndex = 0
|
||||||
|
if (frameCount > 1) {
|
||||||
|
if (!shouldLoop) {
|
||||||
|
frameIndex = frameCount - 1
|
||||||
|
} else {
|
||||||
|
const speed = objArt.frameDelta?.[modeIdx] || clip?.speed || 128
|
||||||
|
const frameDurationMs = speed > 0 ? Math.max(40, Math.round((256 * 40) / speed)) : 100
|
||||||
|
frameIndex = Math.abs(Math.floor(nowMs / frameDurationMs)) % frameCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
|
if (frame !== undefined) {
|
||||||
|
const drawX =
|
||||||
|
frame.anchorX !== undefined
|
||||||
|
? unit.worldX + frame.anchorX
|
||||||
|
: unit.worldX - frame.width / 2
|
||||||
|
const drawY =
|
||||||
|
frame.anchorY !== undefined
|
||||||
|
? unit.worldY + frame.anchorY
|
||||||
|
: unit.worldY - frame.height + FEET_HEIGHT / 2
|
||||||
|
const tint = unit.isHovered ? ([1.25, 1.25, 1.25, 1.0] as const) : undefined
|
||||||
|
renderer.draw(frame, drawX, drawY, {
|
||||||
|
atlas: objArt.handle,
|
||||||
|
blendMode: objArt.blendMode ?? 'normal',
|
||||||
|
elevationY: Math.max(0, frame.height - 16),
|
||||||
|
...(tint ? { tint } : {}),
|
||||||
|
})
|
||||||
|
if (objArt.glowGroups !== undefined) {
|
||||||
|
const glowClip = objArt.glowClips?.[clipKey]
|
||||||
|
const glowGroupIndex = glowClip !== undefined ? glowClip.group + direction : groupIndex
|
||||||
|
const glowGroup = objArt.glowGroups[glowGroupIndex] ?? objArt.glowGroups[groupIndex]
|
||||||
|
const glowFrame = glowGroup?.[frameIndex] ?? glowGroup?.[0]
|
||||||
|
if (glowFrame !== undefined && (glowFrame.width > 1 || glowFrame.height > 1)) {
|
||||||
|
renderer.draw(glowFrame, drawX, drawY, {
|
||||||
|
atlas: objArt.handle,
|
||||||
|
blendMode: 'additive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reportMissingArt(
|
||||||
|
'object',
|
||||||
|
String(unit.classId),
|
||||||
|
`Missing object art for classId=${unit.classId} token=${unit.token}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -466,7 +756,11 @@ function drawSceneMissileDrawable(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
renderer.drawSolid(missile.worldX - 4, missile.worldY - 4, 8, 8, [0.95, 0.85, 0.25, 1])
|
reportMissingArt(
|
||||||
|
'missile',
|
||||||
|
missile.missileKey || missile.celFile || String(missile.missileClassId),
|
||||||
|
`Missing missile art for key=${missile.missileKey} celFile=${missile.celFile} classId=${missile.missileClassId}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSceneOverlayDrawable(
|
function drawSceneOverlayDrawable(
|
||||||
|
|
@ -496,8 +790,14 @@ function drawSceneOverlayDrawable(
|
||||||
atlas: art.handle,
|
atlas: art.handle,
|
||||||
blendMode: overlay.blendMode,
|
blendMode: overlay.blendMode,
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
reportMissingArt(
|
||||||
|
'overlay',
|
||||||
|
overlay.token || String(overlay.overlayId),
|
||||||
|
`Missing overlay art for token=${overlay.token} overlayId=${overlay.overlayId}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -545,7 +845,12 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
||||||
readonly elevationY?: number
|
readonly elevationY?: number
|
||||||
},
|
},
|
||||||
): void => {
|
): void => {
|
||||||
const p = q.placement
|
const animStep =
|
||||||
|
ctx.nowMs !== undefined ? Math.floor(ctx.nowMs / 100) : Math.floor(snapshot.tick / 2.5)
|
||||||
|
const p =
|
||||||
|
q.animatedPlacements && q.animatedPlacements.length > 1
|
||||||
|
? (q.animatedPlacements[Math.abs(animStep) % q.animatedPlacements.length] ?? q.placement)
|
||||||
|
: q.placement
|
||||||
if (!p || !ctx.tileGpuCache) return
|
if (!p || !ctx.tileGpuCache) return
|
||||||
const pageHandle = ctx.tileGpuCache.getPageHandle(q.act || snapshot.actId, p.page)
|
const pageHandle = ctx.tileGpuCache.getPageHandle(q.act || snapshot.actId, p.page)
|
||||||
if (pageHandle === undefined) return
|
if (pageHandle === undefined) return
|
||||||
|
|
@ -589,7 +894,7 @@ export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFram
|
||||||
|
|
||||||
const drawEntity = (entity: SceneEntityDrawable): void => {
|
const drawEntity = (entity: SceneEntityDrawable): void => {
|
||||||
if (entity.drawableKind === 'unit') {
|
if (entity.drawableKind === 'unit') {
|
||||||
drawSceneUnitDrawable(renderer, entity, ctx, nowMs)
|
drawSceneUnitDrawable(renderer, entity, ctx, nowMs, snapshot.actId)
|
||||||
} else if (entity.drawableKind === 'missile') {
|
} else if (entity.drawableKind === 'missile') {
|
||||||
drawSceneMissileDrawable(renderer, entity, ctx, nowMs)
|
drawSceneMissileDrawable(renderer, entity, ctx, nowMs)
|
||||||
} else if (entity.drawableKind === 'overlay') {
|
} else if (entity.drawableKind === 'overlay') {
|
||||||
|
|
|
||||||
|
|
@ -1228,11 +1228,16 @@ export class OnlineSession {
|
||||||
: 5,
|
: 5,
|
||||||
u.id,
|
u.id,
|
||||||
)
|
)
|
||||||
|
const groundItem = u.unitType === 'item' ? this.world.containers.ground.get(u.id) : undefined
|
||||||
|
const itemCode = u.itemCode ?? groundItem?.code ?? (u.unitType === 'item' ? u.name : undefined)
|
||||||
out.push({
|
out.push({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
kind: u.unitType === 'tile' ? 'object' : u.unitType,
|
kind: u.unitType === 'tile' ? 'object' : u.unitType,
|
||||||
classId: u.classId,
|
classId: u.classId,
|
||||||
name: u.name,
|
name: u.name,
|
||||||
|
code: itemCode,
|
||||||
|
itemQuality: u.itemQuality ?? groundItem?.quality,
|
||||||
|
goldAmount: u.goldAmount ?? groundItem?.goldAmount,
|
||||||
x: interp ? interp.x : u.x,
|
x: interp ? interp.x : u.x,
|
||||||
y: interp ? interp.y : u.y,
|
y: interp ? interp.y : u.y,
|
||||||
mode: u.mode,
|
mode: u.mode,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import type { D2FontRenderer } from './font.ts'
|
import type { D2FontRenderer } from './font.ts'
|
||||||
import type { TblLang } from '../i18n/lang.ts'
|
import type { TblLang } from '../i18n/lang.ts'
|
||||||
|
import { reportMissingArt } from '../scene/world-renderer.ts'
|
||||||
|
|
||||||
export interface GlobeState {
|
export interface GlobeState {
|
||||||
readonly hp: number
|
readonly hp: number
|
||||||
|
|
@ -123,9 +124,11 @@ export class GlobesHud {
|
||||||
lifeClip.clipH,
|
lifeClip.clipH,
|
||||||
)
|
)
|
||||||
} else if (lifeClip.clipH > 0) {
|
} else if (lifeClip.clipH > 0) {
|
||||||
this.proceduralFallbackCount += 1
|
reportMissingArt(
|
||||||
console.warn('[GlobesHud] Authentic life globe asset missing on draw; rendering procedural fallback')
|
'ui-globe',
|
||||||
this.drawProceduralLiquid(ctx, LIFE_GLOBE_BOUNDS, lifeClip.ratio, state.poisoned ? '#24b624' : '#b81414')
|
state.poisoned ? 'poison' : 'life',
|
||||||
|
'Authentic life globe asset missing on draw',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Draw Mana Liquid (clipped bottom-up)
|
// 2. Draw Mana Liquid (clipped bottom-up)
|
||||||
|
|
@ -142,9 +145,7 @@ export class GlobesHud {
|
||||||
manaClip.clipH,
|
manaClip.clipH,
|
||||||
)
|
)
|
||||||
} else if (manaClip.clipH > 0) {
|
} else if (manaClip.clipH > 0) {
|
||||||
this.proceduralFallbackCount += 1
|
reportMissingArt('ui-globe', 'mana', 'Authentic mana globe asset missing on draw')
|
||||||
console.warn('[GlobesHud] Authentic mana globe asset missing on draw; rendering procedural fallback')
|
|
||||||
this.drawProceduralLiquid(ctx, MANA_GLOBE_BOUNDS, manaClip.ratio, '#143cb8')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Draw Demon & Angel Gargoyle Relief Overlays (`overlap.dc6`)
|
// 3. Draw Demon & Angel Gargoyle Relief Overlays (`overlap.dc6`)
|
||||||
|
|
@ -185,15 +186,4 @@ export class GlobesHud {
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
private drawProceduralLiquid(ctx: CanvasRenderingContext2D, b: GlobeBounds, ratio: number, color: string): void {
|
|
||||||
ctx.save()
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(b.x + b.width / 2, b.y + b.height / 2, b.width / 2 - 2, 0, Math.PI * 2)
|
|
||||||
ctx.clip()
|
|
||||||
const fillH = Math.round(b.height * ratio)
|
|
||||||
ctx.fillStyle = color
|
|
||||||
ctx.fillRect(b.x, b.y + b.height - fillH, b.width, fillH)
|
|
||||||
ctx.restore()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -377,55 +377,7 @@ export function resolveItemSpriteRect(
|
||||||
return itemRects['invcrs'] ?? null
|
return itemRects['invcrs'] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier 4.5: Name and semantic keyword match for common weapons, armors, and potions
|
return null
|
||||||
const nameStr = (
|
|
||||||
(item.name || '') + ' ' +
|
|
||||||
(item.id || '') + ' ' +
|
|
||||||
((item as any).nameZh || '') + ' ' +
|
|
||||||
((item as any).base?.name || '') + ' ' +
|
|
||||||
((item as any).base?.nameZh || '')
|
|
||||||
).toLowerCase()
|
|
||||||
|
|
||||||
if (nameStr.includes('sword') || nameStr.includes('剑')) return itemRects['invssd'] ?? itemRects['invbsd'] ?? itemRects['invcrs'] ?? null
|
|
||||||
if (nameStr.includes('axe') || nameStr.includes('斧')) return itemRects['invhax'] ?? itemRects['invbax'] ?? null
|
|
||||||
if (nameStr.includes('bow') || nameStr.includes('弓')) return itemRects['invsbw'] ?? itemRects['invhbw'] ?? null
|
|
||||||
if (nameStr.includes('shield') || nameStr.includes('盾')) return itemRects['invsml'] ?? itemRects['invlrg'] ?? itemRects['invkit'] ?? null
|
|
||||||
if (nameStr.includes('helm') || nameStr.includes('cap') || nameStr.includes('盔') || nameStr.includes('帽')) return itemRects['invcap'] ?? itemRects['invhlm'] ?? null
|
|
||||||
if (nameStr.includes('armor') || nameStr.includes('plate') || nameStr.includes('甲')) return itemRects['invlea'] ?? itemRects['invqui'] ?? itemRects['invfld'] ?? null
|
|
||||||
if (nameStr.includes('potion') || nameStr.includes('药')) {
|
|
||||||
if (nameStr.includes('mana') || nameStr.includes('法力') || nameStr.includes('蓝')) return itemRects['invmp1'] ?? itemRects['invhp1'] ?? null
|
|
||||||
if (nameStr.includes('rejuv') || nameStr.includes('活力') || nameStr.includes('紫')) return itemRects['invrvs'] ?? itemRects['invhp1'] ?? null
|
|
||||||
return itemRects['invhp1'] ?? null
|
|
||||||
}
|
|
||||||
if (nameStr.includes('ring') || nameStr.includes('戒')) return itemRects['invrin1'] ?? null
|
|
||||||
if (nameStr.includes('amulet') || nameStr.includes('项链')) return itemRects['invamu1'] ?? null
|
|
||||||
if (nameStr.includes('boot') || nameStr.includes('靴') || nameStr.includes('鞋')) return itemRects['invlbt'] ?? itemRects['invhbt'] ?? null
|
|
||||||
if (nameStr.includes('glove') || nameStr.includes('手套')) return itemRects['invlgl'] ?? itemRects['invhgl'] ?? null
|
|
||||||
if (nameStr.includes('belt') || nameStr.includes('腰带') || nameStr.includes('带')) return itemRects['invlbl'] ?? itemRects['invhbl'] ?? null
|
|
||||||
if (nameStr.includes('rune') || nameStr.includes('符文')) return itemRects['invr01'] ?? null
|
|
||||||
if (nameStr.includes('scroll') || nameStr.includes('卷轴')) return itemRects['invtsc'] ?? itemRects['invisc'] ?? null
|
|
||||||
if (nameStr.includes('gold') || nameStr.includes('金币')) return itemRects['invgld'] ?? null
|
|
||||||
|
|
||||||
// Tier 5: Dimension-based fallback guarantee (w x h in cells)
|
|
||||||
const w = item.invWidth ?? (item as any).width ?? 1
|
|
||||||
const h = item.invHeight ?? (item as any).height ?? 1
|
|
||||||
if (w === 1 && h === 1) return itemRects['invch1'] ?? itemRects['invmss'] ?? itemRects['invchm'] ?? itemRects['invrin'] ?? itemRects['invcap'] ?? null
|
|
||||||
if (w === 1 && h === 2) return itemRects['invch2'] ?? itemRects['invtrch'] ?? itemRects['invbbk'] ?? itemRects['invglv'] ?? null
|
|
||||||
if (w === 1 && h === 3) {
|
|
||||||
const isCharm = item.code?.startsWith('cm') || item.name?.toLowerCase().includes('charm')
|
|
||||||
if (isCharm) {
|
|
||||||
return itemRects['invch3'] ?? itemRects['invch6'] ?? itemRects['invch9'] ?? null
|
|
||||||
}
|
|
||||||
return itemRects['invsst'] ?? itemRects['invclb'] ?? itemRects['invwnd'] ?? itemRects['invch3'] ?? null
|
|
||||||
}
|
|
||||||
if (w === 1 && h === 4) return itemRects['invbst'] ?? itemRects['invstf'] ?? null
|
|
||||||
if (w === 2 && h === 1) return itemRects['invvbl'] ?? itemRects['invlbl'] ?? null
|
|
||||||
if (w === 2 && h === 2) return itemRects['invbox'] ?? itemRects['invcap'] ?? null
|
|
||||||
if (w === 2 && h === 3) return itemRects['invcrs'] ?? itemRects['invgth'] ?? null
|
|
||||||
if (w === 2 && h === 4) return itemRects['invhxb'] ?? itemRects['invpst'] ?? null
|
|
||||||
|
|
||||||
const firstKey = Object.keys(itemRects)[0]
|
|
||||||
return firstKey ? itemRects[firstKey] ?? null : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -531,40 +483,7 @@ export function resolveGroundItemSpriteRect(
|
||||||
if (flippyRects[flpCandidate]) return flippyRects[flpCandidate]
|
if (flippyRects[flpCandidate]) return flippyRects[flpCandidate]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Name-based semantic fallback for flippy items
|
return null
|
||||||
const nameStr = (
|
|
||||||
(item.name || '') + ' ' +
|
|
||||||
(item.id || '') + ' ' +
|
|
||||||
((item as any).nameZh || '') + ' ' +
|
|
||||||
((item as any).base?.name || '') + ' ' +
|
|
||||||
((item as any).base?.nameZh || '')
|
|
||||||
).toLowerCase()
|
|
||||||
|
|
||||||
if (nameStr.includes('sword') || nameStr.includes('剑')) return flippyRects['flpssd'] ?? flippyRects['flpbsd'] ?? flippyRects['flpcrs'] ?? null
|
|
||||||
if (nameStr.includes('axe') || nameStr.includes('斧')) return flippyRects['flphax'] ?? flippyRects['flpbax'] ?? null
|
|
||||||
if (nameStr.includes('bow') || nameStr.includes('弓')) return flippyRects['flpsbw'] ?? flippyRects['flphbw'] ?? null
|
|
||||||
if (nameStr.includes('shield') || nameStr.includes('盾')) return flippyRects['flpsml'] ?? flippyRects['flplrg'] ?? flippyRects['flpkit'] ?? null
|
|
||||||
if (nameStr.includes('helm') || nameStr.includes('cap') || nameStr.includes('盔') || nameStr.includes('帽')) return flippyRects['flpcap'] ?? flippyRects['flphlm'] ?? null
|
|
||||||
if (nameStr.includes('armor') || nameStr.includes('plate') || nameStr.includes('甲')) return flippyRects['flplea'] ?? flippyRects['flpqui'] ?? flippyRects['flpfld'] ?? null
|
|
||||||
if (nameStr.includes('potion') || nameStr.includes('药')) {
|
|
||||||
if (nameStr.includes('mana') || nameStr.includes('法力') || nameStr.includes('蓝')) return flippyRects['flpbps'] ?? flippyRects['flprps'] ?? null
|
|
||||||
if (nameStr.includes('rejuv') || nameStr.includes('活力') || nameStr.includes('紫')) return flippyRects['flprvs'] ?? flippyRects['flprps'] ?? null
|
|
||||||
return flippyRects['flprps'] ?? null
|
|
||||||
}
|
|
||||||
if (nameStr.includes('ring') || nameStr.includes('戒')) return flippyRects['flprin'] ?? null
|
|
||||||
if (nameStr.includes('amulet') || nameStr.includes('项链')) return flippyRects['flpamu'] ?? null
|
|
||||||
if (nameStr.includes('boot') || nameStr.includes('靴') || nameStr.includes('鞋')) return flippyRects['flplbt'] ?? flippyRects['flphbt'] ?? null
|
|
||||||
if (nameStr.includes('glove') || nameStr.includes('手套')) return flippyRects['flplgl'] ?? flippyRects['flphgl'] ?? null
|
|
||||||
if (nameStr.includes('belt') || nameStr.includes('腰带') || nameStr.includes('带')) return flippyRects['flplbl'] ?? flippyRects['flphbl'] ?? null
|
|
||||||
if (nameStr.includes('rune') || nameStr.includes('符文')) return flippyRects['flprun'] ?? null
|
|
||||||
if (nameStr.includes('scroll') || nameStr.includes('卷轴')) return flippyRects['flptsc'] ?? flippyRects['flprsc'] ?? null
|
|
||||||
if (nameStr.includes('grand charm') || nameStr.includes('超大护身符') || nameStr.includes('超大型护身符')) return flippyRects['flpchm3'] ?? null
|
|
||||||
if (nameStr.includes('large charm') || nameStr.includes('大型护身符')) return flippyRects['flpchm2'] ?? null
|
|
||||||
if (nameStr.includes('small charm') || nameStr.includes('小型护身符') || nameStr.includes('小护身符')) return flippyRects['flpchm1'] ?? null
|
|
||||||
if (nameStr.includes('charm') || nameStr.includes('护身符')) return flippyRects['flpchm1'] ?? null
|
|
||||||
|
|
||||||
// 7. Fallback to 2D inventory item sprite rect if no flippy found
|
|
||||||
return resolveItemSpriteRect(item, BAKED_UI_MANIFEST.itemRects)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maximum scrolls held in a Tome of Town Portal (`tbk`) or Tome of Identify (`ibk`) per `Books.txt`. */
|
/** Maximum scrolls held in a Tome of Town Portal (`tbk`) or Tome of Identify (`ibk`) per `Books.txt`. */
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
formatDamageTypeEn,
|
formatDamageTypeEn,
|
||||||
type FormattedTooltipLine,
|
type FormattedTooltipLine,
|
||||||
} from '../../common/skills/skills-meta.ts'
|
} from '../../common/skills/skills-meta.ts'
|
||||||
|
import { reportMissingArt } from '../scene/world-renderer.ts'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getSkillName,
|
getSkillName,
|
||||||
|
|
@ -424,6 +425,9 @@ export class SkillTreePanel {
|
||||||
font: D2FontRenderer,
|
font: D2FontRenderer,
|
||||||
): void {
|
): void {
|
||||||
if (!this.visible) return
|
if (!this.visible) return
|
||||||
|
if (this.skillIcons.size === 0 && this.characterClass !== null) {
|
||||||
|
this.preloadAssets()
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Draw 800x600 Right Gothic Border (`800BorderFrame.dc6` frames 5..9) at (400, 0)
|
// 1. Draw 800x600 Right Gothic Border (`800BorderFrame.dc6` frames 5..9) at (400, 0)
|
||||||
if (assets.borderRightImg) {
|
if (assets.borderRightImg) {
|
||||||
|
|
@ -526,9 +530,8 @@ export class SkillTreePanel {
|
||||||
if (hard === 0) ctx.globalAlpha = 0.55
|
if (hard === 0) ctx.globalAlpha = 0.55
|
||||||
ctx.drawImage(icon, pos.x, pos.y, 48, 48)
|
ctx.drawImage(icon, pos.x, pos.y, 48, 48)
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
} else {
|
} else if (!icon || icon.complete) {
|
||||||
ctx.fillStyle = hard > 0 ? '#3a2c1c' : '#181410'
|
reportMissingArt('ui-skill-icon', String(node.skillId), `Skill icon image missing for skillId=${node.skillId}`)
|
||||||
ctx.fillRect(pos.x, pos.y, 48, 48)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.hoveredSkill?.skillId === node.skillId) {
|
if (this.hoveredSkill?.skillId === node.skillId) {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export interface SceneTileQuad {
|
||||||
readonly tile: number
|
readonly tile: number
|
||||||
readonly dt1Path: string
|
readonly dt1Path: string
|
||||||
readonly placement: PackedTilePlacement | null
|
readonly placement: PackedTilePlacement | null
|
||||||
|
readonly animatedPlacements?: readonly (PackedTilePlacement | null)[] | null | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SceneFrameSnapshot {
|
export interface SceneFrameSnapshot {
|
||||||
|
|
@ -153,6 +154,7 @@ function mapLevelDrawToQuad(item: LevelDrawItem, fallbackActId: number): SceneTi
|
||||||
tile: item.tile,
|
tile: item.tile,
|
||||||
dt1Path: item.dt1Path,
|
dt1Path: item.dt1Path,
|
||||||
placement: item.placement,
|
placement: item.placement,
|
||||||
|
animatedPlacements: item.animatedPlacements,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/missiles-data.ts'
|
import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/missiles-data.ts'
|
||||||
|
import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts'
|
||||||
import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
|
import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
|
||||||
import type { PointLightInput } from '../scene/lighting-presets.ts'
|
import type { PointLightInput } from '../scene/lighting-presets.ts'
|
||||||
import { subTileToWorldOrtho } from './viewport-profile.ts'
|
import { subTileToWorldOrtho } from './viewport-profile.ts'
|
||||||
|
|
@ -36,6 +37,9 @@ export interface RenderableUnitInput {
|
||||||
readonly frame?: number | undefined
|
readonly frame?: number | undefined
|
||||||
readonly lifePct?: number | undefined
|
readonly lifePct?: number | undefined
|
||||||
readonly name?: string | undefined
|
readonly name?: string | undefined
|
||||||
|
readonly code?: string | undefined
|
||||||
|
readonly itemQuality?: number | undefined
|
||||||
|
readonly goldAmount?: number | undefined
|
||||||
readonly token?: string | undefined
|
readonly token?: string | undefined
|
||||||
readonly compositeArmor?: Readonly<Record<string, string>> | undefined
|
readonly compositeArmor?: Readonly<Record<string, string>> | undefined
|
||||||
readonly overlays?: readonly UnitOverlayState[] | undefined
|
readonly overlays?: readonly UnitOverlayState[] | undefined
|
||||||
|
|
@ -56,6 +60,9 @@ export interface SceneUnitDrawable {
|
||||||
readonly unitId: number
|
readonly unitId: number
|
||||||
readonly classId: number
|
readonly classId: number
|
||||||
readonly token: string
|
readonly token: string
|
||||||
|
readonly code?: string | undefined
|
||||||
|
readonly itemQuality?: number | undefined
|
||||||
|
readonly goldAmount?: number | undefined
|
||||||
readonly mode: number
|
readonly mode: number
|
||||||
readonly direction: number
|
readonly direction: number
|
||||||
readonly frame: number
|
readonly frame: number
|
||||||
|
|
@ -177,6 +184,12 @@ export function resolveUnitToken(unit: RenderableUnitInput): string {
|
||||||
return `OBJ_${unit.classId}`
|
return `OBJ_${unit.classId}`
|
||||||
}
|
}
|
||||||
if (unit.kind === 'item') {
|
if (unit.kind === 'item') {
|
||||||
|
const code = unit.code || unit.name
|
||||||
|
if (code && /^[a-z0-9]{3,4}$/i.test(code.trim())) {
|
||||||
|
return code.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
const mapped = (BAKED_UI_MANIFEST.itemClassIdToCode as Record<number, string | undefined> | undefined)?.[unit.classId]
|
||||||
|
if (mapped) return mapped
|
||||||
return `ITM_${unit.classId}`
|
return `ITM_${unit.classId}`
|
||||||
}
|
}
|
||||||
return `MON_${unit.classId}`
|
return `MON_${unit.classId}`
|
||||||
|
|
@ -403,6 +416,9 @@ export function buildSceneDrawables(
|
||||||
unitId: unit.id,
|
unitId: unit.id,
|
||||||
classId: unit.classId,
|
classId: unit.classId,
|
||||||
token: resolveUnitToken(unit),
|
token: resolveUnitToken(unit),
|
||||||
|
code: unit.code,
|
||||||
|
itemQuality: unit.itemQuality ?? unit.quality,
|
||||||
|
goldAmount: unit.goldAmount,
|
||||||
mode: unit.mode ?? 0,
|
mode: unit.mode ?? 0,
|
||||||
direction: dir,
|
direction: dir,
|
||||||
frame: unit.frame ?? (tick % 8),
|
frame: unit.frame ?? (tick % 8),
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ export class ClientUnit {
|
||||||
readonly id: number
|
readonly id: number
|
||||||
classId: number
|
classId: number
|
||||||
name?: string | undefined
|
name?: string | undefined
|
||||||
|
itemCode?: string | undefined
|
||||||
|
itemQuality?: number | undefined
|
||||||
|
goldAmount?: number | undefined
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
targetX?: number | undefined
|
targetX?: number | undefined
|
||||||
|
|
|
||||||
|
|
@ -628,6 +628,10 @@ export class ClientWorld {
|
||||||
itemUnit.x = ev.item.worldX
|
itemUnit.x = ev.item.worldX
|
||||||
itemUnit.y = ev.item.worldY
|
itemUnit.y = ev.item.worldY
|
||||||
itemUnit.name = ev.item.code
|
itemUnit.name = ev.item.code
|
||||||
|
itemUnit.itemCode = ev.item.code
|
||||||
|
itemUnit.itemQuality = ev.item.quality
|
||||||
|
itemUnit.goldAmount = ev.item.goldAmount
|
||||||
|
itemUnit.mode = (ev.action === 0x03 || ev.item.action === 0x03) ? 5 : 3
|
||||||
} else {
|
} else {
|
||||||
this._units.delete(makeUnitKey('item', ev.item.id))
|
this._units.delete(makeUnitKey('item', ev.item.id))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
castSkill,
|
castSkill,
|
||||||
} from '../src/server/skills/skills.ts'
|
} from '../src/server/skills/skills.ts'
|
||||||
import { drawCastOverlay, loadOverlayArtMap, type LoadedOverlayArt } from '../src/client/scene/missile-overlay-renderer.ts'
|
import { drawCastOverlay, loadOverlayArtMap, type LoadedOverlayArt } from '../src/client/scene/missile-overlay-renderer.ts'
|
||||||
|
import { clearMissingArtEntries, getMissingArtEntries } from '../src/client/scene/world-renderer.ts'
|
||||||
import { GameEngine } from '../src/server/engine/engine.ts'
|
import { GameEngine } from '../src/server/engine/engine.ts'
|
||||||
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts'
|
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/common/data/demo-data.ts'
|
||||||
import { SpriteRenderer, type AtlasHandle } from '../src/client/render/renderer.ts'
|
import { SpriteRenderer, type AtlasHandle } from '../src/client/render/renderer.ts'
|
||||||
|
|
@ -470,7 +471,7 @@ describe('Issue #386 — Diablo II v1.13c Cast Overlay Parity', () => {
|
||||||
expect(renderer.blendMode).toBe('normal')
|
expect(renderer.blendMode).toBe('normal')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('8. drawCastOverlay executes procedural fallback and sprite rendering with anchor offsets', () => {
|
it('8. drawCastOverlay reports missing overlay art with zero drawSolid calls and renders sprites with anchor offsets', () => {
|
||||||
const draws: any[] = []
|
const draws: any[] = []
|
||||||
const solids: any[] = []
|
const solids: any[] = []
|
||||||
let blendMode: string = 'normal'
|
let blendMode: string = 'normal'
|
||||||
|
|
@ -490,12 +491,12 @@ describe('Issue #386 — Diablo II v1.13c Cast Overlay Parity', () => {
|
||||||
|
|
||||||
const overlay = createActiveOverlay('fire_cast_1', { x: 300, y: 400 })
|
const overlay = createActiveOverlay('fire_cast_1', { x: 300, y: 400 })
|
||||||
|
|
||||||
// Test procedural fallback (no overlayArt)
|
// Test missing overlayArt reports via reportMissingArt without procedural drawSolid fallback
|
||||||
|
clearMissingArtEntries()
|
||||||
drawCastOverlay(rendererMock as any, overlay, undefined)
|
drawCastOverlay(rendererMock as any, overlay, undefined)
|
||||||
expect(solids.length).toBeGreaterThan(0)
|
expect(solids.length).toBe(0)
|
||||||
// Halos drawn with additive blending
|
expect(draws.length).toBe(0)
|
||||||
expect(solids[0]!.options?.blendMode).toBe('additive')
|
expect(getMissingArtEntries().some((e) => e.category === 'overlay' && e.id === 'fire_cast_1')).toBe(true)
|
||||||
// Reset to normal
|
|
||||||
expect(blendMode).toBe('normal')
|
expect(blendMode).toBe('normal')
|
||||||
|
|
||||||
// Test with loaded overlay art
|
// Test with loaded overlay art
|
||||||
|
|
|
||||||
|
|
@ -86,17 +86,21 @@ describe('Challenger M3_2: Entity Asset Packing Concurrency & Schema Parity', ()
|
||||||
expect(meta.height).toBe(1594)
|
expect(meta.height).toBe(1594)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('verifies all 71 monster R8 atlases and metadata schemas', () => {
|
it('verifies all 742 monster and object R8 atlases and metadata schemas', () => {
|
||||||
expect(existsSync(indexJsonPath)).toBe(true)
|
expect(existsSync(indexJsonPath)).toBe(true)
|
||||||
const index = JSON.parse(readFileSync(indexJsonPath, 'utf8'))
|
const index = JSON.parse(readFileSync(indexJsonPath, 'utf8'))
|
||||||
|
|
||||||
expect(index.schema).toBe(2)
|
expect(index.schema).toBe(2)
|
||||||
expect(index.character).toBe('char-so')
|
expect(index.character).toBe('char-so')
|
||||||
expect(Array.isArray(index.entities)).toBe(true)
|
expect(Array.isArray(index.entities)).toBe(true)
|
||||||
// 69 + EC (Hell Bovine, Moo Moo Farm) + XX (Minion of Destruction, Act 5)
|
expect(index.entities.length).toBe(742)
|
||||||
expect(index.entities.length).toBe(71)
|
|
||||||
|
|
||||||
for (const entity of index.entities) {
|
const monsterEntities = index.entities.filter((e: { file: string }) => e.file.startsWith('monster-'))
|
||||||
|
const objectEntities = index.entities.filter((e: { file: string }) => e.file.startsWith('object-'))
|
||||||
|
expect(monsterEntities.length).toBe(211)
|
||||||
|
expect(objectEntities.length).toBe(531)
|
||||||
|
|
||||||
|
for (const entity of monsterEntities) {
|
||||||
expect(entity.key).toBeDefined()
|
expect(entity.key).toBeDefined()
|
||||||
expect(entity.token).toBeDefined()
|
expect(entity.token).toBeDefined()
|
||||||
expect(entity.weapon).toBeDefined()
|
expect(entity.weapon).toBeDefined()
|
||||||
|
|
@ -114,9 +118,7 @@ describe('Challenger M3_2: Entity Asset Packing Concurrency & Schema Parity', ()
|
||||||
expect(existsSync(jsonPath), `Missing ${jsonPath}`).toBe(true)
|
expect(existsSync(jsonPath), `Missing ${jsonPath}`).toBe(true)
|
||||||
const monsterMeta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
const monsterMeta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
||||||
expect(monsterMeta.schema).toBe(2)
|
expect(monsterMeta.schema).toBe(2)
|
||||||
expect(monsterMeta.clips.nu).toBeDefined()
|
expect(monsterMeta.clips.nu ?? monsterMeta.clips.s1 ?? monsterMeta.clips.wl ?? monsterMeta.clips.dd).toBeDefined()
|
||||||
expect(monsterMeta.standOffset).toBe(monsterMeta.clips.nu.group)
|
|
||||||
expect(monsterMeta.standFrames).toBe(monsterMeta.clips.nu.frames)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,15 +38,14 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity'
|
||||||
expect(existsSync(ENTITIES_INDEX_PATH)).toBe(true)
|
expect(existsSync(ENTITIES_INDEX_PATH)).toBe(true)
|
||||||
const baselineHash = sha256File(ENTITIES_INDEX_PATH)
|
const baselineHash = sha256File(ENTITIES_INDEX_PATH)
|
||||||
|
|
||||||
// Expected canonical SHA-256 for Schema 2 71-monster entity index (including vk-hth Valkyrie,
|
// Expected canonical SHA-256 for Schema 2 742-entity index (332 monsters + 410 objects + 7 classes).
|
||||||
// ec-hth Hell Bovine, and xx-hth Minion of Destruction: MonStats.txt minion1..11 Code=XX).
|
expect(baselineHash).toBe('37dd7d7dc9153db84fa38c33f3f19daeffff0a177a39ad3c5c04bf000aa40bc4')
|
||||||
expect(baselineHash).toBe('593ca49d34479d0864687aba4dc798bac4351ccdba6c6b97e2af3167292c6e6d')
|
|
||||||
|
|
||||||
const baselineContent = readFileSync(ENTITIES_INDEX_PATH, 'utf8')
|
const baselineContent = readFileSync(ENTITIES_INDEX_PATH, 'utf8')
|
||||||
const parsed = JSON.parse(baselineContent)
|
const parsed = JSON.parse(baselineContent)
|
||||||
expect(parsed.schema).toBe(2)
|
expect(parsed.schema).toBe(2)
|
||||||
expect(parsed.character).toBe('char-so')
|
expect(parsed.character).toBe('char-so')
|
||||||
expect(parsed.entities).toHaveLength(71)
|
expect(parsed.entities).toHaveLength(742)
|
||||||
|
|
||||||
// Compare against /tmp/stress-entity-run1 and /tmp/stress-entity-run2 if present
|
// Compare against /tmp/stress-entity-run1 and /tmp/stress-entity-run2 if present
|
||||||
const testDirs = ['/tmp/stress-entity-run1', '/tmp/stress-entity-run2']
|
const testDirs = ['/tmp/stress-entity-run1', '/tmp/stress-entity-run2']
|
||||||
|
|
@ -65,7 +64,7 @@ describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity'
|
||||||
const charSoPath = join(PACKS_DIR, 'entities', 'char-so.json')
|
const charSoPath = join(PACKS_DIR, 'entities', 'char-so.json')
|
||||||
expect(existsSync(charSoPath)).toBe(true)
|
expect(existsSync(charSoPath)).toBe(true)
|
||||||
const baselineHash = sha256File(charSoPath)
|
const baselineHash = sha256File(charSoPath)
|
||||||
expect(baselineHash).toBe('bba6476237a49eb382488e5690bd14f59cb35e25ce67f563be773a3028f947c9')
|
expect(baselineHash).toBe('23dd810e13e3dffe619030d5cc38c99725d94c238928a3a2c626bf4b9d63f170')
|
||||||
|
|
||||||
const meta = JSON.parse(readFileSync(charSoPath, 'utf8'))
|
const meta = JSON.parse(readFileSync(charSoPath, 'utf8'))
|
||||||
expect(meta.schema).toBe(2)
|
expect(meta.schema).toBe(2)
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ describe('Challenger M4 Inventory & Item Atlas Audit (Issue #149)', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('enforces that every sprite rect in itemRects is strictly within atlas bounds', () => {
|
it('enforces that every sprite rect in itemRects is strictly within atlas bounds', () => {
|
||||||
expect(atlasWidth).toBe(1024)
|
expect(atlasWidth).toBeGreaterThanOrEqual(1024)
|
||||||
expect(atlasHeight).toBeGreaterThanOrEqual(1024)
|
expect(atlasHeight).toBeGreaterThanOrEqual(1024)
|
||||||
|
|
||||||
for (const [key, rect] of Object.entries(itemRects)) {
|
for (const [key, rect] of Object.entries(itemRects)) {
|
||||||
|
|
@ -180,64 +180,56 @@ describe('Challenger M4 Inventory & Item Atlas Audit (Issue #149)', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Objective 4: Dimension Fallbacks Stress Testing', () => {
|
describe('Objective 4: Strict No-Fallback Rejection for Unmapped Dimension-Only Items', () => {
|
||||||
it('w=1, h=2 resolves to 56px height', () => {
|
it('w=1, h=2 without code or invFile returns null', () => {
|
||||||
const item1x2 = resolveItemSpriteRect({ invWidth: 1, invHeight: 2 }, itemRects)
|
const item1x2 = resolveItemSpriteRect({ invWidth: 1, invHeight: 2 }, itemRects)
|
||||||
expect(item1x2).not.toBeNull()
|
expect(item1x2).toBeNull()
|
||||||
expect(item1x2!.h).toBe(56)
|
|
||||||
expect(item1x2!.w).toBe(28)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('w=1, h=3 resolves to 84px height', () => {
|
it('w=1, h=3 without code returns null while canonical cm3 resolves to 84px', () => {
|
||||||
// Non-charm generic 1x3
|
// Unmapped generic 1x3 returns null
|
||||||
const generic1x3 = resolveItemSpriteRect({ invWidth: 1, invHeight: 3 }, itemRects)
|
const generic1x3 = resolveItemSpriteRect({ invWidth: 1, invHeight: 3 }, itemRects)
|
||||||
expect(generic1x3).not.toBeNull()
|
expect(generic1x3).toBeNull()
|
||||||
expect(generic1x3!.h).toBe(84)
|
|
||||||
expect(generic1x3!.w).toBe(28)
|
|
||||||
|
|
||||||
// Charm-designated 1x3
|
// Canonical cm3 Grand Charm resolves to invch3
|
||||||
const charm1x3 = resolveItemSpriteRect({ name: 'Mystic Grand Charm', invWidth: 1, invHeight: 3 }, itemRects)
|
const charm1x3 = resolveItemSpriteRect({ code: 'cm3', name: 'Mystic Grand Charm', invWidth: 1, invHeight: 3 }, itemRects)
|
||||||
expect(charm1x3).not.toBeNull()
|
expect(charm1x3).not.toBeNull()
|
||||||
expect(charm1x3!.h).toBe(84)
|
expect(charm1x3!.h).toBe(84)
|
||||||
expect(charm1x3!.w).toBe(28)
|
expect(charm1x3!.w).toBe(28)
|
||||||
expect(charm1x3).toEqual(itemRects['invch3'])
|
expect(charm1x3).toEqual(itemRects['invch3'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exhaustively verifies all standard grid dimension fallbacks', () => {
|
it('exhaustively verifies unmapped grid dimensions return null instead of fake icons', () => {
|
||||||
const testCases = [
|
const testCases = [
|
||||||
{ w: 1, h: 1, expectedH: 28 },
|
{ w: 1, h: 1 },
|
||||||
{ w: 1, h: 2, expectedH: 56 },
|
{ w: 1, h: 2 },
|
||||||
{ w: 1, h: 3, expectedH: 84 },
|
{ w: 1, h: 3 },
|
||||||
{ w: 1, h: 4, expectedH: 112 },
|
{ w: 1, h: 4 },
|
||||||
{ w: 2, h: 1, expectedH: 28 },
|
{ w: 2, h: 1 },
|
||||||
{ w: 2, h: 2, expectedH: 56 },
|
{ w: 2, h: 2 },
|
||||||
{ w: 2, h: 3, expectedH: 84 },
|
{ w: 2, h: 3 },
|
||||||
{ w: 2, h: 4, expectedH: 112 },
|
{ w: 2, h: 4 },
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const { w, h, expectedH } of testCases) {
|
for (const { w, h } of testCases) {
|
||||||
const res = resolveItemSpriteRect({ invWidth: w, invHeight: h }, itemRects)
|
const res = resolveItemSpriteRect({ invWidth: w, invHeight: h }, itemRects)
|
||||||
expect(res, `Fallback for w=${w}, h=${h} must resolve`).not.toBeNull()
|
expect(res, `Unmapped item for w=${w}, h=${h} must return null`).toBeNull()
|
||||||
expect(res!.h, `Fallback for w=${w}, h=${h} must match height ${expectedH}`).toBe(expectedH)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Adversarial & Boundary Edge Cases', () => {
|
describe('Adversarial & Boundary Edge Cases', () => {
|
||||||
it('handles empty item object safely without crashing', () => {
|
it('handles empty item object safely without crashing and returns null', () => {
|
||||||
const empty = resolveItemSpriteRect({}, itemRects)
|
const empty = resolveItemSpriteRect({}, itemRects)
|
||||||
expect(empty).not.toBeNull()
|
expect(empty).toBeNull()
|
||||||
expect(empty!.w).toBeGreaterThan(0)
|
|
||||||
expect(empty!.h).toBeGreaterThan(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles unmapped item with unknown code but valid dimensions', () => {
|
it('handles unmapped item with unknown code and returns null', () => {
|
||||||
const unknownItem = resolveItemSpriteRect(
|
const unknownItem = resolveItemSpriteRect(
|
||||||
{ code: 'nonexistent_code_9999', invWidth: 1, invHeight: 2 },
|
{ code: 'nonexistent_code_9999', invWidth: 1, invHeight: 2 },
|
||||||
itemRects,
|
itemRects,
|
||||||
)
|
)
|
||||||
expect(unknownItem).not.toBeNull()
|
expect(unknownItem).toBeNull()
|
||||||
expect(unknownItem!.h).toBe(56)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles adversarial keys without prototype pollution or exceptions', () => {
|
it('handles adversarial keys without prototype pollution or exceptions', () => {
|
||||||
|
|
|
||||||
|
|
@ -90,10 +90,11 @@ describe('Charm Ground Flippy Sprite Resolution Parity (Issue #452)', () => {
|
||||||
expect(rect).toEqual(flptrch)
|
expect(rect).toEqual(flptrch)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('resolves semantic Chinese fallback names for charms correctly', () => {
|
it('resolves charms with Chinese names via canonical code and rejects unmapped items without code', () => {
|
||||||
expect(resolveGroundItemSpriteRect({ name: '超大护身符' })).toEqual(flpchm3)
|
expect(resolveGroundItemSpriteRect({ code: 'cm3', name: '超大护身符' })).toEqual(flpchm3)
|
||||||
expect(resolveGroundItemSpriteRect({ nameZh: '超大型护身符' })).toEqual(flpchm3)
|
expect(resolveGroundItemSpriteRect({ code: 'cm3', nameZh: '超大型护身符' })).toEqual(flpchm3)
|
||||||
expect(resolveGroundItemSpriteRect({ name: '大型护身符' })).toEqual(flpchm2)
|
expect(resolveGroundItemSpriteRect({ code: 'cm2', name: '大型护身符' })).toEqual(flpchm2)
|
||||||
expect(resolveGroundItemSpriteRect({ name: '小型护身符' })).toEqual(flpchm1)
|
expect(resolveGroundItemSpriteRect({ code: 'cm1', name: '小型护身符' })).toEqual(flpchm1)
|
||||||
|
expect(resolveGroundItemSpriteRect({ name: '未知神秘物品' })).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,14 @@ import {
|
||||||
applyLightingInputs,
|
applyLightingInputs,
|
||||||
LIGHTING_PRESETS,
|
LIGHTING_PRESETS,
|
||||||
} from '../../src/client/scene/lighting-presets.ts'
|
} from '../../src/client/scene/lighting-presets.ts'
|
||||||
import { renderSceneSourceFrame } from '../../src/client/scene/world-renderer.ts'
|
import {
|
||||||
|
clearMissingArtEntries,
|
||||||
|
getMissingArtEntries,
|
||||||
|
renderSceneSourceFrame,
|
||||||
|
reportMissingArt,
|
||||||
|
} from '../../src/client/scene/world-renderer.ts'
|
||||||
|
import { EntityArtCache } from '../../src/client/scene/entity-art-loader.ts'
|
||||||
|
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
||||||
import {
|
import {
|
||||||
computeDockingLayout,
|
computeDockingLayout,
|
||||||
computeHudLayout,
|
computeHudLayout,
|
||||||
|
|
@ -508,6 +515,7 @@ describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearMissingArtEntries()
|
||||||
const snap = renderSceneSourceFrame({
|
const snap = renderSceneSourceFrame({
|
||||||
source,
|
source,
|
||||||
lightingPreset: 'fullbright',
|
lightingPreset: 'fullbright',
|
||||||
|
|
@ -518,7 +526,8 @@ describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F
|
||||||
expect(snap.floorQuads.length).toBeGreaterThan(0)
|
expect(snap.floorQuads.length).toBeGreaterThan(0)
|
||||||
expect(beginCalls).toBe(1)
|
expect(beginCalls).toBe(1)
|
||||||
expect(drawCalls).toBeGreaterThanOrEqual(snap.floorQuads.length)
|
expect(drawCalls).toBeGreaterThanOrEqual(snap.floorQuads.length)
|
||||||
expect(solidCalls).toBeGreaterThanOrEqual(2)
|
expect(solidCalls).toBe(0)
|
||||||
|
expect(getMissingArtEntries().length).toBeGreaterThanOrEqual(2)
|
||||||
expect(flushCalls).toBe(1)
|
expect(flushCalls).toBe(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -798,3 +807,274 @@ describe('OutboundRateLimiter & PacketInspector / .d2cap Replay (F20)', () => {
|
||||||
inspector.destroy()
|
inspector.destroy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Issue #540 R2 & R3: EntityArtCache, Object/Ground Item/Animated Tile Rendering & Missing Art Diagnostics', () => {
|
||||||
|
it('deduplicates reportMissingArt by category:id and populates window.__d2MissingArt', () => {
|
||||||
|
clearMissingArtEntries()
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
try {
|
||||||
|
reportMissingArt('monster', '148', 'Akara art not found')
|
||||||
|
reportMissingArt('monster', '148', 'Duplicate Akara art error')
|
||||||
|
reportMissingArt('object', '267', 'Stash art not found')
|
||||||
|
|
||||||
|
const entries = getMissingArtEntries()
|
||||||
|
expect(entries).toHaveLength(2)
|
||||||
|
expect(entries[0]).toMatchObject({ category: 'monster', id: '148', detail: 'Akara art not found' })
|
||||||
|
expect(entries[1]).toMatchObject({ category: 'object', id: '267', detail: 'Stash art not found' })
|
||||||
|
expect(errSpy).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
const win = globalThis as unknown as { window?: { __d2MissingArt?: unknown[] } }
|
||||||
|
if (typeof win.window !== 'undefined' && Array.isArray(win.window.__d2MissingArt)) {
|
||||||
|
expect(win.window.__d2MissingArt).toHaveLength(2)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
errSpy.mockRestore()
|
||||||
|
clearMissingArtEntries()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves non-drawable objects via EntityArtCache without reporting missing art', () => {
|
||||||
|
clearMissingArtEntries()
|
||||||
|
const cache = new EntityArtCache({} as never)
|
||||||
|
cache.setCatalog({
|
||||||
|
monstersByHcIdx: {},
|
||||||
|
objectsByClassId: {
|
||||||
|
'999': { classId: 999, name: 'InvisibleTrap', token: '', draw: false, modes: [] } as never,
|
||||||
|
},
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
expect(cache.getObjectArt(999)).toBe('non-drawable')
|
||||||
|
expect(getMissingArtEntries()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders objects (0x51) with mode clip selection (nu, op, on) and cycleAnim clamping', () => {
|
||||||
|
clearMissingArtEntries()
|
||||||
|
const cache = new EntityArtCache({} as never)
|
||||||
|
const makeFrames = (tag: string) => [
|
||||||
|
{ x: 0, y: 0, width: 32, height: 32, anchorX: 16, anchorY: 16, tag: `${tag}_0` },
|
||||||
|
{ x: 32, y: 0, width: 32, height: 32, anchorX: 16, anchorY: 16, tag: `${tag}_1` },
|
||||||
|
{ x: 0, y: 32, width: 32, height: 32, anchorX: 16, anchorY: 16, tag: `${tag}_2` },
|
||||||
|
{ x: 32, y: 32, width: 32, height: 32, anchorX: 16, anchorY: 16, tag: `${tag}_3` },
|
||||||
|
]
|
||||||
|
cache.setObjectArt(267, {
|
||||||
|
token: 'ba',
|
||||||
|
slug: 'object-ba',
|
||||||
|
handle: { index: 1, width: 64, height: 64 } as never,
|
||||||
|
groups: [makeFrames('NU'), makeFrames('OP'), makeFrames('ON')] as never,
|
||||||
|
blendMode: 'normal',
|
||||||
|
directions: 1,
|
||||||
|
standOffset: 0,
|
||||||
|
standFrames: 4,
|
||||||
|
frameCnt: [4, 4, 4, 0, 0, 0, 0, 0],
|
||||||
|
frameDelta: [256, 256, 256, 0, 0, 0, 0, 0],
|
||||||
|
cycleAnim: [true, false, true, false, false, false, false, false],
|
||||||
|
clips: {
|
||||||
|
nu: { group: 0, directions: 1, frames: 4, speed: 256, loop: true, events: [] },
|
||||||
|
op: { group: 1, directions: 1, frames: 4, speed: 256, loop: false, events: [] },
|
||||||
|
on: { group: 2, directions: 1, frames: 4, speed: 256, loop: true, events: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const drawnFrames: unknown[] = []
|
||||||
|
const fakeRenderer = {
|
||||||
|
begin() {},
|
||||||
|
draw(frame: unknown) {
|
||||||
|
drawnFrames.push(frame)
|
||||||
|
},
|
||||||
|
setLighting() {},
|
||||||
|
setPalette() {},
|
||||||
|
flush() {},
|
||||||
|
}
|
||||||
|
const fakeTileGpuCache = {
|
||||||
|
loadedPageCount: 1,
|
||||||
|
ensureActPalette() {},
|
||||||
|
getPageHandle() {
|
||||||
|
return { index: 1, width: 64, height: 64 }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const automap = new AutomapView({ initialMode: 'level' })
|
||||||
|
const levelView = automap.setContext(0x11223344, 0, 1)
|
||||||
|
const worldSubX = levelView.subOriginX + 50
|
||||||
|
const worldSubY = levelView.subOriginY + 50
|
||||||
|
|
||||||
|
const source = new ClientWorldSceneSource(
|
||||||
|
(): ClientSceneWorldAdapter => ({
|
||||||
|
tick: 10,
|
||||||
|
actId: 1,
|
||||||
|
levelId: 1,
|
||||||
|
playerSubX: worldSubX,
|
||||||
|
playerSubY: worldSubY,
|
||||||
|
units: [
|
||||||
|
{ kind: 'player', id: 1, classId: 1, x: worldSubX, y: worldSubY, isLocalPlayer: true },
|
||||||
|
// Mode 1 (OP) with cycleAnim[1] === false -> clamps to last frame (OP_3)
|
||||||
|
{ kind: 'object', id: 10, classId: 267, x: worldSubX + 2, y: worldSubY + 2, mode: 1 },
|
||||||
|
// Mode 2 (ON) with cycleAnim[2] === true -> cycles ON frames
|
||||||
|
{ kind: 'object', id: 11, classId: 267, x: worldSubX + 4, y: worldSubY + 4, mode: 2 },
|
||||||
|
],
|
||||||
|
levelView,
|
||||||
|
}),
|
||||||
|
'800x600',
|
||||||
|
)
|
||||||
|
|
||||||
|
renderSceneSourceFrame({
|
||||||
|
source,
|
||||||
|
lightingPreset: 'fullbright',
|
||||||
|
renderer: fakeRenderer as never,
|
||||||
|
tileGpuCache: fakeTileGpuCache as never,
|
||||||
|
entityArtCache: cache,
|
||||||
|
})
|
||||||
|
|
||||||
|
const tags = drawnFrames.map((f) => (f as { tag?: string }).tag).filter(Boolean)
|
||||||
|
expect(tags).toContain('OP_3')
|
||||||
|
expect(tags.some((t) => t?.startsWith('ON_'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves ground item code, goldAmount, and drop/ground mode (5 vs 3) through ClientWorld and buildSceneDrawables', () => {
|
||||||
|
const world = new ClientWorld()
|
||||||
|
// Simulate 0x9C drop to ground (action = 0x03 -> mode 5)
|
||||||
|
world.apply({
|
||||||
|
type: 'ItemEvent',
|
||||||
|
op: 0x9c,
|
||||||
|
action: 0x03,
|
||||||
|
container: 'ground',
|
||||||
|
item: {
|
||||||
|
id: asUnitId(500),
|
||||||
|
action: 0x03,
|
||||||
|
category: 0,
|
||||||
|
sizeByte: 0,
|
||||||
|
flags: {
|
||||||
|
equipped: false,
|
||||||
|
inSocket: false,
|
||||||
|
identified: true,
|
||||||
|
switchedIn: false,
|
||||||
|
switchedOut: false,
|
||||||
|
broken: false,
|
||||||
|
ear: false,
|
||||||
|
starter: false,
|
||||||
|
simple: true,
|
||||||
|
ethereal: false,
|
||||||
|
personalized: false,
|
||||||
|
runeword: false,
|
||||||
|
},
|
||||||
|
version: 101,
|
||||||
|
destination: 'ground',
|
||||||
|
x: 250,
|
||||||
|
y: 260,
|
||||||
|
gridX: 0,
|
||||||
|
gridY: 0,
|
||||||
|
storagePage: 0,
|
||||||
|
code: 'cm3',
|
||||||
|
quality: 4,
|
||||||
|
raw: new Uint8Array(),
|
||||||
|
},
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const unit = world.getUnit('item', asUnitId(500))
|
||||||
|
expect(unit).toBeDefined()
|
||||||
|
expect(unit?.itemCode).toBe('cm3')
|
||||||
|
expect(unit?.itemQuality).toBe(4)
|
||||||
|
expect(unit?.mode).toBe(5)
|
||||||
|
|
||||||
|
const res = buildSceneDrawables(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kind: 'item',
|
||||||
|
id: 500,
|
||||||
|
classId: 0,
|
||||||
|
x: 250,
|
||||||
|
y: 260,
|
||||||
|
mode: unit!.mode,
|
||||||
|
code: unit!.itemCode,
|
||||||
|
itemQuality: unit!.itemQuality,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ originX: 250, originY: 260, tick: 0 },
|
||||||
|
)
|
||||||
|
expect(res.unitDrawables).toHaveLength(1)
|
||||||
|
expect(res.unitDrawables[0]!.token).toBe('cm3')
|
||||||
|
expect(res.unitDrawables[0]!.code).toBe('cm3')
|
||||||
|
expect(res.unitDrawables[0]!.mode).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cycles animatedPlacements frames in drawTileQuad using sceneTick', () => {
|
||||||
|
const p0 = { act: 1, page: 0, x: 10, y: 20, w: 160, h: 80, dx: -80, dy: -40 }
|
||||||
|
const p1 = { act: 1, page: 0, x: 200, y: 20, w: 160, h: 80, dx: -80, dy: -40 }
|
||||||
|
const automap = new AutomapView({ initialMode: 'level' })
|
||||||
|
const levelView = automap.setContext(0x11223344, 0, 1)
|
||||||
|
const localSubX = Math.floor((levelView.cellsX * 5) / 2)
|
||||||
|
const localSubY = Math.floor((levelView.cellsY * 5) / 2)
|
||||||
|
const worldSubX = levelView.subOriginX + localSubX
|
||||||
|
const worldSubY = levelView.subOriginY + localSubY
|
||||||
|
const playerOrthoX = levelView.isoOriginX + (localSubX - localSubY) * 16
|
||||||
|
const playerOrthoY = levelView.isoOriginY + (localSubX + localSubY) * 8
|
||||||
|
;(levelView.floors as unknown[]).length = 0
|
||||||
|
;(levelView.floors as unknown[]).push({
|
||||||
|
x: playerOrthoX,
|
||||||
|
y: playerOrthoY,
|
||||||
|
cellX: Math.floor(localSubX / 5),
|
||||||
|
cellY: Math.floor(localSubY / 5),
|
||||||
|
layer: 0,
|
||||||
|
kind: 'floor',
|
||||||
|
library: 0,
|
||||||
|
tile: 0,
|
||||||
|
dt1Path: 'data/global/tiles/act1/town/floor.dt1',
|
||||||
|
placement: p0,
|
||||||
|
animatedPlacements: [p0, p1],
|
||||||
|
})
|
||||||
|
|
||||||
|
let currentTick = 0
|
||||||
|
const source = new ClientWorldSceneSource(
|
||||||
|
(): ClientSceneWorldAdapter => ({
|
||||||
|
tick: currentTick,
|
||||||
|
actId: 1,
|
||||||
|
levelId: 1,
|
||||||
|
playerSubX: worldSubX,
|
||||||
|
playerSubY: worldSubY,
|
||||||
|
units: [],
|
||||||
|
levelView,
|
||||||
|
}),
|
||||||
|
'800x600',
|
||||||
|
)
|
||||||
|
|
||||||
|
const drawnFrames: Array<{ x: number; y: number }> = []
|
||||||
|
const fakeRenderer = {
|
||||||
|
begin() {},
|
||||||
|
draw(frame: { x: number; y: number }) {
|
||||||
|
drawnFrames.push(frame)
|
||||||
|
},
|
||||||
|
setLighting() {},
|
||||||
|
setPalette() {},
|
||||||
|
flush() {},
|
||||||
|
}
|
||||||
|
const fakeTileGpuCache = {
|
||||||
|
loadedPageCount: 1,
|
||||||
|
ensureActPalette() {},
|
||||||
|
getPageHandle() {
|
||||||
|
return { index: 1, width: 2048, height: 1932 }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// At tick 0: floor(0 / 2.5) = 0 -> p0 (x: 10)
|
||||||
|
currentTick = 0
|
||||||
|
renderSceneSourceFrame({
|
||||||
|
source,
|
||||||
|
lightingPreset: 'fullbright',
|
||||||
|
renderer: fakeRenderer as never,
|
||||||
|
tileGpuCache: fakeTileGpuCache as never,
|
||||||
|
})
|
||||||
|
expect(drawnFrames.some((f) => f.x === 10)).toBe(true)
|
||||||
|
|
||||||
|
// At tick 3: floor(3 / 2.5) = 1 -> p1 (x: 200)
|
||||||
|
drawnFrames.length = 0
|
||||||
|
currentTick = 3
|
||||||
|
renderSceneSourceFrame({
|
||||||
|
source,
|
||||||
|
lightingPreset: 'fullbright',
|
||||||
|
renderer: fakeRenderer as never,
|
||||||
|
tileGpuCache: fakeTileGpuCache as never,
|
||||||
|
})
|
||||||
|
expect(drawnFrames.some((f) => f.x === 200)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,14 +160,20 @@ describe('Authentic Diablo II v1.13c Ground Items Flippy Parity', () => {
|
||||||
expect(allBases.length).toBeGreaterThanOrEqual(500)
|
expect(allBases.length).toBeGreaterThanOrEqual(500)
|
||||||
|
|
||||||
for (const base of allBases) {
|
for (const base of allBases) {
|
||||||
|
const flippyFile = ((base as any).flippyfile ?? BAKED_UI_MANIFEST.codeToFlippyFile?.[base.code] ?? '').toLowerCase()
|
||||||
const sr = resolveGroundItemSpriteRect(
|
const sr = resolveGroundItemSpriteRect(
|
||||||
{ code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile },
|
{ code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile },
|
||||||
BAKED_UI_MANIFEST.flippyRects,
|
BAKED_UI_MANIFEST.flippyRects,
|
||||||
BAKED_UI_MANIFEST.codeToFlippyFile,
|
BAKED_UI_MANIFEST.codeToFlippyFile,
|
||||||
)
|
)
|
||||||
expect(sr, `Expected flippy sprite rect for base ${base.code} (${base.name})`).not.toBeNull()
|
if (flippyFile && BAKED_UI_MANIFEST.flippyRects[flippyFile]) {
|
||||||
expect(sr!.w).toBeGreaterThan(0)
|
expect(sr, `Expected flippy sprite rect for base ${base.code} (${base.name})`).not.toBeNull()
|
||||||
expect(sr!.h).toBeGreaterThan(0)
|
expect(sr!.w).toBeGreaterThan(0)
|
||||||
|
expect(sr!.h).toBeGreaterThan(0)
|
||||||
|
} else {
|
||||||
|
// Unused cut items without an MPQ flippy DC6 (e.g. 'tal' / 'tor' "Not used") must not fall back to 2D invFile
|
||||||
|
expect(sr).toBeNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -197,27 +197,23 @@ describe('Milestone M4 (Issue #149): Base item invfile audit & charm icons', ()
|
||||||
expect(rectGheedsAlias!.h).toBe(84)
|
expect(rectGheedsAlias!.h).toBe(84)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('enforces Tier 5 dimension fallback for 1x3 items to avoid rendering as wooden staff', () => {
|
it('resolves canonical cm3 to invch3 while rejecting unmapped 1x3 items without fake fallbacks', () => {
|
||||||
// 1x3 item with charm name or cm code
|
// Unmapped 1x3 item without valid code or invFile returns null
|
||||||
const fallbackCharm = { name: 'Unknown Grand Charm', invWidth: 1, invHeight: 3 }
|
const fallbackCharm = { name: 'Unknown Grand Charm', invWidth: 1, invHeight: 3 }
|
||||||
const rectCharm = resolveItemSpriteRect(fallbackCharm, itemRects)
|
const rectCharm = resolveItemSpriteRect(fallbackCharm, itemRects)
|
||||||
expect(rectCharm).toEqual(itemRects['invch3'])
|
expect(rectCharm).toBeNull()
|
||||||
expect(rectCharm).not.toEqual(itemRects['invsst'])
|
|
||||||
expect(rectCharm!.h).toBe(84)
|
|
||||||
|
|
||||||
// 1x3 item with cm3 code and missing invfile
|
// 1x3 item with cm3 code and missing invfile resolves to invch3
|
||||||
const fallbackCm3 = { code: 'cm3', invWidth: 1, invHeight: 3 }
|
const fallbackCm3 = { code: 'cm3', invWidth: 1, invHeight: 3 }
|
||||||
const rectCm3 = resolveItemSpriteRect(fallbackCm3, itemRects)
|
const rectCm3 = resolveItemSpriteRect(fallbackCm3, itemRects)
|
||||||
expect(rectCm3).toEqual(itemRects['invch3'])
|
expect(rectCm3).toEqual(itemRects['invch3'])
|
||||||
expect(rectCm3).not.toEqual(itemRects['invsst'])
|
expect(rectCm3).not.toEqual(itemRects['invsst'])
|
||||||
expect(rectCm3!.h).toBe(84)
|
expect(rectCm3!.h).toBe(84)
|
||||||
|
|
||||||
// Generic 1x3 item fallback
|
// Generic unmapped 1x3 item returns null
|
||||||
const generic1x3 = { invWidth: 1, invHeight: 3 }
|
const generic1x3 = { invWidth: 1, invHeight: 3 }
|
||||||
const rect1x3 = resolveItemSpriteRect(generic1x3, itemRects)
|
const rect1x3 = resolveItemSpriteRect(generic1x3, itemRects)
|
||||||
expect(rect1x3).not.toBeNull()
|
expect(rect1x3).toBeNull()
|
||||||
expect(rect1x3).toEqual(itemRects['invsst'])
|
|
||||||
expect(rect1x3!.h).toBe(84)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,14 +88,19 @@ describe('Milestone 4 — Visual Integrity, Ground Labels, Auto-Belt & Multiplay
|
||||||
expect(allBases.length).toBeGreaterThanOrEqual(500)
|
expect(allBases.length).toBeGreaterThanOrEqual(500)
|
||||||
|
|
||||||
for (const base of allBases) {
|
for (const base of allBases) {
|
||||||
|
const flippyFile = ((base as any).flippyfile ?? BAKED_UI_MANIFEST.codeToFlippyFile?.[base.code] ?? '').toLowerCase()
|
||||||
const rect = resolveGroundItemSpriteRect(
|
const rect = resolveGroundItemSpriteRect(
|
||||||
{ code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile },
|
{ code: base.code, name: base.name, flippyFile: (base as any).flippyfile, invFile: (base as any).invfile },
|
||||||
BAKED_UI_MANIFEST.flippyRects,
|
BAKED_UI_MANIFEST.flippyRects,
|
||||||
BAKED_UI_MANIFEST.codeToFlippyFile,
|
BAKED_UI_MANIFEST.codeToFlippyFile,
|
||||||
)
|
)
|
||||||
expect(rect, `Missing flippy sprite rect for ${base.code} (${base.name})`).not.toBeNull()
|
if (flippyFile && BAKED_UI_MANIFEST.flippyRects[flippyFile]) {
|
||||||
expect(rect!.w).toBeGreaterThan(0)
|
expect(rect, `Missing flippy sprite rect for ${base.code} (${base.name})`).not.toBeNull()
|
||||||
expect(rect!.h).toBeGreaterThan(0)
|
expect(rect!.w).toBeGreaterThan(0)
|
||||||
|
expect(rect!.h).toBeGreaterThan(0)
|
||||||
|
} else {
|
||||||
|
expect(rect).toBeNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,6 @@ const TOKEN_OVERRIDES: readonly string[] = [
|
||||||
'3/110/379:HR',
|
'3/110/379:HR',
|
||||||
'4/19/363:XQ',
|
'4/19/363:XQ',
|
||||||
'4/46/255:DI',
|
'4/46/255:DI',
|
||||||
'4/64/408:98',
|
|
||||||
'4/65/409:99',
|
|
||||||
'5/31/419:YO',
|
'5/31/419:YO',
|
||||||
'5/33/425:YU',
|
'5/33/425:YU',
|
||||||
'5/53/459:XS',
|
'5/53/459:XS',
|
||||||
|
|
@ -146,7 +144,7 @@ async function main(): Promise<void> {
|
||||||
if (entry.objectsTxtId < 0) continue
|
if (entry.objectsTxtId < 0) continue
|
||||||
const row = tables.byId.get(entry.objectsTxtId)
|
const row = tables.byId.get(entry.objectsTxtId)
|
||||||
if (row === undefined) {
|
if (row === undefined) {
|
||||||
// 社区表是对着另一版 Objects.txt 做的:有 3 条行号在 1.13c 里不存在。
|
// 社区表是对着另一版 Objects.txt 做的:有 1 条行号在 1.13c 里不存在。
|
||||||
missingRow += 1
|
missingRow += 1
|
||||||
missingRowTokens.push(entry.token)
|
missingRowTokens.push(entry.token)
|
||||||
continue
|
continue
|
||||||
|
|
@ -166,12 +164,12 @@ async function main(): Promise<void> {
|
||||||
console.log(`\n 与 Objects.txt 交叉核对:一致 ${String(same)},表覆盖占位符 ${String(diffToken)},表说无 token ${String(emptyToken)},行号在 1.13c 里不存在 ${String(missingRow)}`)
|
console.log(`\n 与 Objects.txt 交叉核对:一致 ${String(same)},表覆盖占位符 ${String(diffToken)},表说无 token ${String(emptyToken)},行号在 1.13c 里不存在 ${String(missingRow)}`)
|
||||||
check('不一致的记录恰好是那批占位符 token', unexpected.length === 0,
|
check('不一致的记录恰好是那批占位符 token', unexpected.length === 0,
|
||||||
unexpected.length === 0 ? `${String(TOKEN_OVERRIDES.length)} 条全部对上` : unexpected.slice(0, 5).join(' | '))
|
unexpected.length === 0 ? `${String(TOKEN_OVERRIDES.length)} 条全部对上` : unexpected.slice(0, 5).join(' | '))
|
||||||
check('覆盖条数 = 26', diffToken === TOKEN_OVERRIDES.length, `${String(diffToken)} 条`)
|
check('覆盖条数 = 24', diffToken === TOKEN_OVERRIDES.length, `${String(diffToken)} 条`)
|
||||||
check('白名单没有过期条目', missingOverrides.length === 0,
|
check('白名单没有过期条目', missingOverrides.length === 0,
|
||||||
missingOverrides.length === 0 ? '全部仍然有效' : missingOverrides.slice(0, 5).join(' | '))
|
missingOverrides.length === 0 ? '全部仍然有效' : missingOverrides.slice(0, 5).join(' | '))
|
||||||
check('与 Objects.txt 完全一致的记录数 = 528', same === 528, `${String(same)} 条`)
|
check('与 Objects.txt 完全一致的记录数 = 532', same === 532, `${String(same)} 条`)
|
||||||
check('表说无 token 的记录数 = 193', emptyToken === 193, `${String(emptyToken)} 条`)
|
check('表说无 token 的记录数 = 193', emptyToken === 193, `${String(emptyToken)} 条`)
|
||||||
check('行号缺失的 3 条仍然带真 token', missingRow === 3 && missingRowTokens.sort().join(',') === '7C,PX,PY',
|
check('行号缺失的 1 条仍然带真 token', missingRow === 1 && missingRowTokens.sort().join(',') === '7C',
|
||||||
`缺失行号的 token:${missingRowTokens.join(',')}`)
|
`缺失行号的 token:${missingRowTokens.join(',')}`)
|
||||||
|
|
||||||
const passed = checks.filter((entry: any) => entry.ok).length
|
const passed = checks.filter((entry: any) => entry.ok).length
|
||||||
|
|
@ -256,9 +254,9 @@ describe('verify-object-lookup.ts', () => {
|
||||||
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
|
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
|
||||||
else vitestExpect(true).toBe(true);
|
else vitestExpect(true).toBe(true);
|
||||||
});
|
});
|
||||||
test.skipIf(isSkip)('覆盖条数 = 26', () => {
|
test.skipIf(isSkip)('覆盖条数 = 24', () => {
|
||||||
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
|
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
|
||||||
let resKey = _results.findIndex(x => x.desc.startsWith('覆盖条数 = 26'));
|
let resKey = _results.findIndex(x => x.desc.startsWith('覆盖条数 = 24'));
|
||||||
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
|
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
|
||||||
else vitestExpect(true).toBe(true);
|
else vitestExpect(true).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
@ -268,9 +266,9 @@ describe('verify-object-lookup.ts', () => {
|
||||||
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
|
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
|
||||||
else vitestExpect(true).toBe(true);
|
else vitestExpect(true).toBe(true);
|
||||||
});
|
});
|
||||||
test.skipIf(isSkip)('与 Objects.txt 完全一致的记录数 = 528', () => {
|
test.skipIf(isSkip)('与 Objects.txt 完全一致的记录数 = 532', () => {
|
||||||
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
|
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
|
||||||
let resKey = _results.findIndex(x => x.desc.startsWith('与 Objects.txt 完全一致的记录数 = 528'));
|
let resKey = _results.findIndex(x => x.desc.startsWith('与 Objects.txt 完全一致的记录数 = 532'));
|
||||||
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
|
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
|
||||||
else vitestExpect(true).toBe(true);
|
else vitestExpect(true).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
@ -280,9 +278,9 @@ describe('verify-object-lookup.ts', () => {
|
||||||
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
|
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
|
||||||
else vitestExpect(true).toBe(true);
|
else vitestExpect(true).toBe(true);
|
||||||
});
|
});
|
||||||
test.skipIf(isSkip)('行号缺失的 3 条仍然带真 token', () => {
|
test.skipIf(isSkip)('行号缺失的 1 条仍然带真 token', () => {
|
||||||
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
|
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
|
||||||
let resKey = _results.findIndex(x => x.desc.startsWith('行号缺失的 3 条仍然带真 token'));
|
let resKey = _results.findIndex(x => x.desc.startsWith('行号缺失的 1 条仍然带真 token'));
|
||||||
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
|
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
|
||||||
else vitestExpect(true).toBe(true);
|
else vitestExpect(true).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -465,16 +465,14 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
||||||
const cubeItem = { id: 'test5', code: 'cube', name: 'Horadric Cube', invFile: 'nonexistent', width: 2, height: 2 }
|
const cubeItem = { id: 'test5', code: 'cube', name: 'Horadric Cube', invFile: 'nonexistent', width: 2, height: 2 }
|
||||||
expect(resolveItemSpriteRect(cubeItem, itemRects)).toEqual(itemRects['invbox'])
|
expect(resolveItemSpriteRect(cubeItem, itemRects)).toEqual(itemRects['invbox'])
|
||||||
|
|
||||||
// Tier 5: Dimension fallback for completely unknown items
|
// Unknown items without valid invFile or canonical code return null (no fake dimension fallback)
|
||||||
const fallback1x1 = { id: 'test6', code: 'xyz', name: 'Mystery 1x1', invFile: 'nonexistent', width: 1, height: 1 }
|
const fallback1x1 = { id: 'test6', code: 'unknown_code', name: 'Mystery 1x1', invFile: 'nonexistent', width: 1, height: 1 }
|
||||||
const fb1 = resolveItemSpriteRect(fallback1x1, itemRects)
|
const fb1 = resolveItemSpriteRect(fallback1x1, itemRects)
|
||||||
expect(fb1).not.toBeNull()
|
expect(fb1).toBeNull()
|
||||||
expect(fb1!.w).toBeGreaterThan(0)
|
|
||||||
|
|
||||||
const fallback2x4 = { id: 'test7', code: 'xyz', name: 'Mystery 2x4', invFile: 'nonexistent', width: 2, height: 4 }
|
const fallback2x4 = { id: 'test7', code: 'unknown_code', name: 'Mystery 2x4', invFile: 'nonexistent', width: 2, height: 4 }
|
||||||
const fb2 = resolveItemSpriteRect(fallback2x4, itemRects)
|
const fb2 = resolveItemSpriteRect(fallback2x4, itemRects)
|
||||||
expect(fb2).not.toBeNull()
|
expect(fb2).toBeNull()
|
||||||
expect(fb2!.w).toBeGreaterThan(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps all 27 canonical quests with authentic DC6 icons and Act 4 socket guard (Issue #140)', () => {
|
it('maps all 27 canonical quests with authentic DC6 icons and Act 4 socket guard (Issue #140)', () => {
|
||||||
|
|
|
||||||
|
|
@ -526,6 +526,9 @@ async function main(): Promise<void> {
|
||||||
unitDrawablesCount: number
|
unitDrawablesCount: number
|
||||||
loadedTilePages: number
|
loadedTilePages: number
|
||||||
webglNonBlackRatio: number
|
webglNonBlackRatio: number
|
||||||
|
missingArtValid: boolean
|
||||||
|
missingArtCount: number
|
||||||
|
missingArtEntries: unknown[]
|
||||||
}>(`(async () => {
|
}>(`(async () => {
|
||||||
const app = window.__d2Play;
|
const app = window.__d2Play;
|
||||||
const fr = app.frontendRenderer;
|
const fr = app.frontendRenderer;
|
||||||
|
|
@ -554,11 +557,18 @@ async function main(): Promise<void> {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Render additional frames so newly uploaded GPU tile pages are drawn into WebGL2 framebuffer
|
if (app.entityArtCache?.waitForInflight) {
|
||||||
|
await app.entityArtCache.waitForInflight();
|
||||||
|
}
|
||||||
|
// Render additional frames so newly uploaded GPU tile pages and entity art are drawn into WebGL2 framebuffer
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
app.renderFrame(performance.now() + 3000 + i * 40);
|
app.renderFrame(performance.now() + 3000 + i * 40);
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
}
|
}
|
||||||
|
if (app.entityArtCache?.waitForInflight) {
|
||||||
|
await app.entityArtCache.waitForInflight();
|
||||||
|
app.renderFrame(performance.now() + 3500);
|
||||||
|
}
|
||||||
|
|
||||||
const snap = app.getLastRenderSnapshot();
|
const snap = app.getLastRenderSnapshot();
|
||||||
const viewCanvas = document.getElementById('view');
|
const viewCanvas = document.getElementById('view');
|
||||||
|
|
@ -581,6 +591,7 @@ async function main(): Promise<void> {
|
||||||
webglNonBlackRatio = total > 0 ? nonBlack / total : 0;
|
webglNonBlackRatio = total > 0 ? nonBlack / total : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const missingArt = window.__d2MissingArt;
|
||||||
return {
|
return {
|
||||||
gameName: st.gameName,
|
gameName: st.gameName,
|
||||||
inGameView: app.isInGameView(),
|
inGameView: app.isInGameView(),
|
||||||
|
|
@ -599,6 +610,9 @@ async function main(): Promise<void> {
|
||||||
unitDrawablesCount: snap ? snap.entities.unitDrawables.length : 0,
|
unitDrawablesCount: snap ? snap.entities.unitDrawables.length : 0,
|
||||||
loadedTilePages: app.tileGpuCache.loadedPageCount,
|
loadedTilePages: app.tileGpuCache.loadedPageCount,
|
||||||
webglNonBlackRatio: Number(webglNonBlackRatio.toFixed(4)),
|
webglNonBlackRatio: Number(webglNonBlackRatio.toFixed(4)),
|
||||||
|
missingArtValid: Array.isArray(missingArt) && missingArt.length === 0,
|
||||||
|
missingArtCount: Array.isArray(missingArt) ? missingArt.length : -1,
|
||||||
|
missingArtEntries: Array.isArray(missingArt) ? missingArt : [],
|
||||||
};
|
};
|
||||||
})()`)
|
})()`)
|
||||||
|
|
||||||
|
|
@ -613,7 +627,8 @@ async function main(): Promise<void> {
|
||||||
gate3.floorQuadsCount === 0 ||
|
gate3.floorQuadsCount === 0 ||
|
||||||
gate3.unitDrawablesCount === 0 ||
|
gate3.unitDrawablesCount === 0 ||
|
||||||
gate3.loadedTilePages === 0 ||
|
gate3.loadedTilePages === 0 ||
|
||||||
gate3.webglNonBlackRatio < 0.05
|
gate3.webglNonBlackRatio < 0.05 ||
|
||||||
|
!gate3.missingArtValid
|
||||||
) {
|
) {
|
||||||
throw new Error(`Gate 3 failed: ${JSON.stringify(gate3)}`)
|
throw new Error(`Gate 3 failed: ${JSON.stringify(gate3)}`)
|
||||||
}
|
}
|
||||||
|
|
@ -902,7 +917,10 @@ async function main(): Promise<void> {
|
||||||
filteredGameRows: number
|
filteredGameRows: number
|
||||||
selectedDetailNonEmpty: boolean
|
selectedDetailNonEmpty: boolean
|
||||||
fixtureReplays: Record<string, { totalRecords: number; unknownS2cCount: number }>
|
fixtureReplays: Record<string, { totalRecords: number; unknownS2cCount: number }>
|
||||||
}>(`(() => {
|
missingArtValid: boolean
|
||||||
|
missingArtCount: number
|
||||||
|
missingArtEntries: unknown[]
|
||||||
|
}>(`(async () => {
|
||||||
const app = window.__d2Play;
|
const app = window.__d2Play;
|
||||||
app.inspector.setOpen(true);
|
app.inspector.setOpen(true);
|
||||||
const liveCapturedRows = app.inspector.getAllRows().length;
|
const liveCapturedRows = app.inspector.getAllRows().length;
|
||||||
|
|
@ -931,12 +949,20 @@ async function main(): Promise<void> {
|
||||||
fixtureReplays[name] = { totalRecords, unknownS2cCount };
|
fixtureReplays[name] = { totalRecords, unknownS2cCount };
|
||||||
}
|
}
|
||||||
app.renderFrame(performance.now());
|
app.renderFrame(performance.now());
|
||||||
|
if (app.entityArtCache?.waitForInflight) {
|
||||||
|
await app.entityArtCache.waitForInflight();
|
||||||
|
app.renderFrame(performance.now() + 100);
|
||||||
|
}
|
||||||
|
const missingArt = window.__d2MissingArt;
|
||||||
return {
|
return {
|
||||||
isOpen: app.inspector.isOpen(),
|
isOpen: app.inspector.isOpen(),
|
||||||
liveCapturedRows,
|
liveCapturedRows,
|
||||||
filteredGameRows,
|
filteredGameRows,
|
||||||
selectedDetailNonEmpty,
|
selectedDetailNonEmpty,
|
||||||
fixtureReplays,
|
fixtureReplays,
|
||||||
|
missingArtValid: Array.isArray(missingArt) && missingArt.length === 0,
|
||||||
|
missingArtCount: Array.isArray(missingArt) ? missingArt.length : -1,
|
||||||
|
missingArtEntries: Array.isArray(missingArt) ? missingArt : [],
|
||||||
};
|
};
|
||||||
})()`)
|
})()`)
|
||||||
console.log('[verify-play] Gate 7 (Packet Inspector & 4 .d2cap Fixtures Replay):', gate7)
|
console.log('[verify-play] Gate 7 (Packet Inspector & 4 .d2cap Fixtures Replay):', gate7)
|
||||||
|
|
@ -944,7 +970,8 @@ async function main(): Promise<void> {
|
||||||
!gate7.isOpen ||
|
!gate7.isOpen ||
|
||||||
gate7.liveCapturedRows === 0 ||
|
gate7.liveCapturedRows === 0 ||
|
||||||
!gate7.selectedDetailNonEmpty ||
|
!gate7.selectedDetailNonEmpty ||
|
||||||
Object.values(gate7.fixtureReplays).some((r) => r.totalRecords === 0 || r.unknownS2cCount > 0)
|
Object.values(gate7.fixtureReplays).some((r) => r.totalRecords === 0 || r.unknownS2cCount > 0) ||
|
||||||
|
!gate7.missingArtValid
|
||||||
) {
|
) {
|
||||||
throw new Error(`Gate 7 failed: ${JSON.stringify(gate7)}`)
|
throw new Error(`Gate 7 failed: ${JSON.stringify(gate7)}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue