1041 lines
34 KiB
TypeScript
1041 lines
34 KiB
TypeScript
import { CANONICAL_113C_OVERLAYS } from '../../common/skills/missiles-data.ts'
|
|
import { dir64ToClipDirection } from '../../common/units/direction.ts'
|
|
import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts'
|
|
import { npcDisplayName } from '../../common/units/npc-table.ts'
|
|
import type { AtlasHandle, SpriteRenderer } from '../render/renderer.ts'
|
|
import type { AutomapEntityMarker, Minimap, MinimapLevel } from '../ui/minimap.ts'
|
|
import type { HudManager } from '../ui/hud-manager.ts'
|
|
import { getTblLang } from '../i18n/lang.ts'
|
|
import { isTownLevel } from '../../common/world/scene-population.ts'
|
|
import type {
|
|
EntityArtCache,
|
|
LoadedCharacter,
|
|
LoadedObjectArt,
|
|
} from './entity-art-loader.ts'
|
|
import type { LightingPreset } from './lighting-presets.ts'
|
|
import {
|
|
drawTownPortal,
|
|
type LoadedMissileArt,
|
|
type LoadedOverlayArt,
|
|
} from './missile-overlay-renderer.ts'
|
|
|
|
/** Player feet footprint height (scene px), used to anchor sprite baselines. */
|
|
const FEET_HEIGHT = 10
|
|
|
|
import type { AutomapView } from '../automap/automap-view.ts'
|
|
import type {
|
|
SceneFrameSnapshot,
|
|
SceneSource,
|
|
SceneTileQuad,
|
|
} from '../view/scene-source.ts'
|
|
import type {
|
|
SceneEntityDrawable,
|
|
SceneMissileDrawable,
|
|
SceneOverlayDrawable,
|
|
SceneUnitDrawable,
|
|
} from '../view/unit-drawables.ts'
|
|
import { MONSTER_ART_MAP } from '../../common/units/monster-mapping.ts'
|
|
import { getTownNpcDescriptorByHcIdx } from '../../common/units/npc-table.ts'
|
|
import { BAKED_UI_MANIFEST, type SpriteRect } from '../../common/items/baked-ui-meta.ts'
|
|
import { layoutGroundItemLabels } from '../../common/items/ground-items.ts'
|
|
import { resolveGroundItemSpriteRect } from '../ui/inventory.ts'
|
|
import {
|
|
applyLightingInputs,
|
|
type LightingPassTargets,
|
|
} from './lighting-presets.ts'
|
|
|
|
export { layoutGroundItemLabels }
|
|
|
|
/**
|
|
* 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>> = {
|
|
0: 'skeleton1',
|
|
1: 'returned1',
|
|
2: 'bonewarrior1',
|
|
3: 'burningdead1',
|
|
4: 'horror1',
|
|
5: 'zombie1',
|
|
6: 'hungrydead1',
|
|
7: 'ghoul1',
|
|
8: 'drownedcarcass1',
|
|
9: 'plaguebearer1',
|
|
10: 'afflicted1',
|
|
15: 'foulcrow1',
|
|
16: 'bloodhawk1',
|
|
19: 'fallen1',
|
|
20: 'carver1',
|
|
21: 'devilkin1',
|
|
22: 'darkone1',
|
|
24: 'brute1',
|
|
25: 'yeti1',
|
|
29: 'sandmaggot1',
|
|
52: 'ghost1',
|
|
58: 'corruptrogue1',
|
|
59: 'darkhunter1',
|
|
63: 'quillrat1',
|
|
64: 'spikefiend1',
|
|
68: 'goatman1',
|
|
69: 'nightclan1',
|
|
73: 'fallenshaman1',
|
|
74: 'carvershaman1',
|
|
146: 'cain1',
|
|
147: 'gheed',
|
|
148: 'akara',
|
|
149: 'chicken',
|
|
150: 'kashya',
|
|
151: 'rat',
|
|
152: 'rogue1',
|
|
154: 'charsi',
|
|
155: 'warriv1',
|
|
175: 'warriv2',
|
|
176: 'atma',
|
|
177: 'drognan',
|
|
178: 'fara',
|
|
198: 'greiz',
|
|
199: 'elzix',
|
|
200: 'geglash',
|
|
201: 'jerhyn',
|
|
202: 'lysander',
|
|
210: 'meshif1',
|
|
244: 'cain2',
|
|
245: 'cain3',
|
|
246: 'cain4',
|
|
252: 'asheara',
|
|
253: 'hratli',
|
|
254: 'alkor',
|
|
255: 'ormus',
|
|
256: 'natalya',
|
|
257: 'halbu',
|
|
367: 'tyrael1',
|
|
405: 'jamella',
|
|
511: 'larzuk',
|
|
512: 'drehya',
|
|
513: 'malah',
|
|
514: 'nihlathak',
|
|
515: 'qual-kehk',
|
|
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
|
|
* to WebGL2 `SpriteRenderer`.
|
|
*/
|
|
export class TileAtlasGpuCache {
|
|
private readonly renderer: SpriteRenderer
|
|
private readonly pageHandles = new Map<string, AtlasHandle>()
|
|
private readonly inflightPages = new Set<string>()
|
|
private readonly requestedPalettes = new Set<number>()
|
|
private readonly paletteBytesByAct = new Map<number, Uint8Array>()
|
|
private activePaletteAct = 0
|
|
|
|
constructor(renderer: SpriteRenderer) {
|
|
this.renderer = renderer
|
|
}
|
|
|
|
get loadedPageCount(): number {
|
|
return this.pageHandles.size
|
|
}
|
|
|
|
ensureActPalette(actId: number): void {
|
|
const normalizedAct = Math.max(1, Math.min(5, Math.floor(actId || 1)))
|
|
const existing = this.paletteBytesByAct.get(normalizedAct)
|
|
if (existing) {
|
|
if (this.activePaletteAct !== normalizedAct) {
|
|
this.renderer.setPalette(existing)
|
|
this.activePaletteAct = normalizedAct
|
|
}
|
|
return
|
|
}
|
|
if (this.requestedPalettes.has(normalizedAct) || typeof fetch === 'undefined') {
|
|
return
|
|
}
|
|
this.requestedPalettes.add(normalizedAct)
|
|
const urls = [
|
|
`/act-packs/tiles/act${normalizedAct}/palette.bin`,
|
|
`/assets/tiles/act${normalizedAct}/palette.bin`,
|
|
`/samples/d2-packs/tiles/act${normalizedAct}/palette.bin`,
|
|
]
|
|
void (async () => {
|
|
for (const url of urls) {
|
|
try {
|
|
const res = await fetch(url)
|
|
if (!res.ok) continue
|
|
const bytes = new Uint8Array(await res.arrayBuffer())
|
|
if (bytes.byteLength >= 768) {
|
|
this.paletteBytesByAct.set(normalizedAct, bytes)
|
|
if (this.activePaletteAct === 0 || this.activePaletteAct === normalizedAct) {
|
|
this.renderer.setPalette(bytes)
|
|
this.activePaletteAct = normalizedAct
|
|
}
|
|
return
|
|
}
|
|
} catch {
|
|
// try fallback url
|
|
}
|
|
}
|
|
reportMissingArt(
|
|
'tile-palette',
|
|
`act${normalizedAct}`,
|
|
`Failed to load act${normalizedAct} palette.bin from ${urls.join(', ')}`,
|
|
)
|
|
})()
|
|
}
|
|
|
|
getPageHandle(actId: number, pageIndex: number): AtlasHandle | undefined {
|
|
const normalizedAct = Math.max(1, Math.min(5, Math.floor(actId || 1)))
|
|
const key = `${normalizedAct}:${pageIndex}`
|
|
const cached = this.pageHandles.get(key)
|
|
if (cached !== undefined) {
|
|
return cached
|
|
}
|
|
if (this.inflightPages.has(key) || typeof fetch === 'undefined') {
|
|
return undefined
|
|
}
|
|
this.inflightPages.add(key)
|
|
const urls = [
|
|
`/act-packs/tiles/act${normalizedAct}/tiles-${pageIndex}.r8`,
|
|
`/assets/tiles/act${normalizedAct}/tiles-${pageIndex}.r8`,
|
|
`/samples/d2-packs/tiles/act${normalizedAct}/tiles-${pageIndex}.r8`,
|
|
]
|
|
void (async () => {
|
|
for (const url of urls) {
|
|
try {
|
|
const res = await fetch(url)
|
|
if (!res.ok) continue
|
|
const r8 = new Uint8Array(await res.arrayBuffer())
|
|
if (r8.byteLength === 0) continue
|
|
const width = 2048
|
|
const height = Math.max(1, Math.floor(r8.byteLength / width))
|
|
const handle = this.renderer.addIndexedAtlas(r8, width, height)
|
|
this.pageHandles.set(key, handle)
|
|
return
|
|
} catch {
|
|
// try fallback url
|
|
}
|
|
}
|
|
reportMissingArt(
|
|
'tile-page',
|
|
`act${normalizedAct}:${pageIndex}`,
|
|
`Failed to load act${normalizedAct} tiles-${pageIndex}.r8 from ${urls.join(', ')}`,
|
|
)
|
|
})()
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
export interface SceneSourceRenderContext {
|
|
readonly source: SceneSource
|
|
readonly lightingPreset: LightingPreset
|
|
readonly lightingTargets?: LightingPassTargets | undefined
|
|
readonly renderer?: SpriteRenderer | null | undefined
|
|
readonly tileGpuCache?: TileAtlasGpuCache | null | undefined
|
|
readonly entityArtCache?: EntityArtCache | null | undefined
|
|
readonly characterArt?: LoadedCharacter | null | undefined
|
|
readonly characterArtByToken?: ReadonlyMap<string, LoadedCharacter> | undefined
|
|
readonly monsterArtMap?: ReadonlyMap<string, LoadedMonsterArt> | undefined
|
|
readonly objectArtMap?: ReadonlyMap<string, LoadedObjectArt> | undefined
|
|
readonly missileArtMap?: ReadonlyMap<string, LoadedMissileArt> | undefined
|
|
readonly overlayArtMap?: ReadonlyMap<string, LoadedOverlayArt> | undefined
|
|
readonly itemsAtlasHandle?: AtlasHandle | undefined
|
|
readonly minimap?: Minimap | null | undefined
|
|
readonly automapView?: AutomapView | null | undefined
|
|
readonly hudManager?: HudManager | null | undefined
|
|
readonly nowMs?: number | undefined
|
|
readonly playerMoving?: boolean | undefined
|
|
readonly onFrameSnapshot?: ((snapshot: SceneFrameSnapshot) => void) | undefined
|
|
}
|
|
|
|
function resolveOnlineMonsterArt(
|
|
unit: SceneUnitDrawable,
|
|
monsterArtMap: ReadonlyMap<string, LoadedMonsterArt> | undefined,
|
|
): LoadedMonsterArt | undefined {
|
|
if (!monsterArtMap || monsterArtMap.size === 0) return undefined
|
|
const rawToken = unit.token.trim().toLowerCase()
|
|
const direct = monsterArtMap.get(rawToken)
|
|
if (direct) return direct
|
|
|
|
const byHcIdxKey = monsterArtMap.get(`hcidx:${unit.classId}`)
|
|
if (byHcIdxKey) return byHcIdxKey
|
|
|
|
const townDesc = getTownNpcDescriptorByHcIdx(unit.classId)
|
|
if (townDesc) {
|
|
const npcKey = townDesc.npcTxtKey.toLowerCase()
|
|
const byNpc = monsterArtMap.get(npcKey) ?? monsterArtMap.get(townDesc.displayName.toLowerCase())
|
|
if (byNpc) return byNpc
|
|
const spec = MONSTER_ART_MAP[npcKey]
|
|
if (spec) {
|
|
const byToken = monsterArtMap.get(`token:${spec.token.toLowerCase()}`)
|
|
if (byToken) return byToken
|
|
}
|
|
}
|
|
|
|
const mappedId = COMMON_HCIDX_TO_MONSTER_ID[unit.classId]
|
|
if (mappedId) {
|
|
const byId = monsterArtMap.get(mappedId)
|
|
if (byId) return byId
|
|
const spec = MONSTER_ART_MAP[mappedId]
|
|
if (spec) {
|
|
const byToken = monsterArtMap.get(`token:${spec.token.toLowerCase()}`)
|
|
if (byToken) return byToken
|
|
}
|
|
}
|
|
|
|
if (unit.name) {
|
|
const lowerName = unit.name.trim().toLowerCase()
|
|
const byName = monsterArtMap.get(lowerName)
|
|
if (byName) return byName
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
function drawSceneUnitDrawable(
|
|
renderer: SpriteRenderer,
|
|
unit: SceneUnitDrawable,
|
|
ctx: SceneSourceRenderContext,
|
|
nowMs: number,
|
|
actId: number,
|
|
): void {
|
|
if (unit.unitKind === 'player') {
|
|
const charArt =
|
|
ctx.characterArtByToken?.get(unit.token.toLowerCase()) ??
|
|
ctx.characterArtByToken?.get(unit.token.toUpperCase()) ??
|
|
ctx.characterArt ??
|
|
null
|
|
if (charArt !== null) {
|
|
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 = dir64ToClipDirection(unit.direction, dirCount)
|
|
const baseGroup = clip !== undefined ? clip.group : isMoving ? charArt.walk : charArt.stand
|
|
const groupIndex = baseGroup + direction
|
|
const group =
|
|
charArt.groups[groupIndex] ??
|
|
charArt.groups[charArt.stand + direction] ??
|
|
charArt.groups[0]
|
|
const frameCount = Math.max(1, group?.length ?? 1)
|
|
const frameIndex =
|
|
unit.isCorpse || requestedClipKey === 'dd'
|
|
? frameCount - 1
|
|
: isMoving
|
|
? Math.floor(nowMs / 65) % frameCount
|
|
: Math.floor(nowMs / 140) % 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
|
|
renderer.draw(frame, drawX, drawY, {
|
|
atlas: charArt.handle,
|
|
elevationY: Math.max(0, frame.height - 16),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
reportMissingArt(
|
|
'player',
|
|
unit.token || String(unit.classId),
|
|
`Missing player character art for token=${unit.token} classId=${unit.classId}`,
|
|
)
|
|
return
|
|
}
|
|
|
|
if (unit.unitKind === 'monster') {
|
|
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) {
|
|
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 = dir64ToClipDirection(unit.direction, dirCount)
|
|
const baseGroup =
|
|
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 frameCount = Math.max(1, group?.length ?? 1)
|
|
const frameIndex =
|
|
unit.isCorpse || requestedClipKey === 'dd'
|
|
? frameCount - 1
|
|
: isMoving
|
|
? Math.floor(nowMs / 75) % frameCount
|
|
: Math.floor(nowMs / 130) % 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 isAdditiveFallback = ADDITIVE_MONSTER_TOKENS.has(art.token.toUpperCase())
|
|
const baseBlendMode = art.blendMode ?? (isAdditiveFallback ? 'additive' : 'normal')
|
|
const tint = unit.tintColor
|
|
? ([
|
|
unit.tintColor[0] / 255,
|
|
unit.tintColor[1] / 255,
|
|
unit.tintColor[2] / 255,
|
|
1.0,
|
|
] as const)
|
|
: unit.isHovered
|
|
? ([1.25, 1.25, 1.25, 1.0] as const)
|
|
: undefined
|
|
renderer.draw(frame, drawX, drawY, {
|
|
atlas: art.handle,
|
|
blendMode: baseBlendMode,
|
|
elevationY: Math.max(0, frame.height - 16),
|
|
...(tint ? { tint } : {}),
|
|
})
|
|
if (art.glowGroups !== undefined) {
|
|
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]
|
|
if (glowFrame !== undefined && (glowFrame.width > 1 || glowFrame.height > 1)) {
|
|
renderer.draw(glowFrame, drawX, drawY, {
|
|
atlas: art.handle,
|
|
blendMode: 'additive',
|
|
})
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
reportMissingArt(
|
|
'monster',
|
|
String(unit.classId),
|
|
`Missing monster/NPC art for classId=${unit.classId} token=${unit.token}`,
|
|
)
|
|
return
|
|
}
|
|
|
|
if (unit.unitKind === 'item') {
|
|
const rawCode =
|
|
unit.code?.trim().toLowerCase() ||
|
|
unit.itemCode?.trim().toLowerCase() ||
|
|
(unit.token && !unit.token.startsWith('ITM_') ? unit.token.trim().toLowerCase() : '') ||
|
|
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.codeToFlippyFile,
|
|
)
|
|
}
|
|
|
|
if (sr && ctx.itemsAtlasHandle !== undefined) {
|
|
renderer.draw(
|
|
{ x: sr.x, y: sr.y, width: sr.w, height: sr.h },
|
|
Math.round(unit.worldX - sr.w / 2),
|
|
Math.round(unit.worldY - sr.h),
|
|
{
|
|
atlas: ctx.itemsAtlasHandle,
|
|
width: sr.w,
|
|
height: sr.h,
|
|
},
|
|
)
|
|
return
|
|
}
|
|
reportMissingArt(
|
|
'item-flippy',
|
|
rawCode || unit.token || String(unit.classId),
|
|
`Missing ground item flippy sprite for code=${rawCode || unit.token} classId=${unit.classId}`,
|
|
)
|
|
return
|
|
}
|
|
|
|
if (unit.unitKind === 'warp' || unit.unitKind === 'tile') {
|
|
return
|
|
}
|
|
|
|
if (unit.unitKind === 'object') {
|
|
if (unit.classId === 59 || unit.classId === 60) {
|
|
if (ctx.overlayArtMap && ctx.overlayArtMap.size > 0) {
|
|
drawTownPortal(renderer, unit.worldX, unit.worldY, nowMs, ctx.overlayArtMap)
|
|
return
|
|
}
|
|
}
|
|
|
|
let objArt: LoadedObjectArt | undefined
|
|
if (ctx.entityArtCache) {
|
|
const cacheRes = ctx.entityArtCache.getObjectArt(unit.classId, actId)
|
|
if (cacheRes === 'loading' || cacheRes === 'non-drawable') {
|
|
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}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
function drawSceneMissileDrawable(
|
|
renderer: SpriteRenderer,
|
|
missile: SceneMissileDrawable,
|
|
ctx: SceneSourceRenderContext,
|
|
nowMs: number,
|
|
): void {
|
|
if (missile.celFile.toLowerCase() === 'sqr') {
|
|
return
|
|
}
|
|
const art =
|
|
ctx.missileArtMap?.get(missile.missileKey) ??
|
|
ctx.missileArtMap?.get(missile.celFile.toLowerCase())
|
|
if (art !== undefined) {
|
|
const dirCount = Math.max(1, art.meta.directions)
|
|
const dir = ((missile.direction % dirCount) + dirCount) % dirCount
|
|
const group = art.frames[dir] ?? art.frames[0]
|
|
const frameCount = Math.max(1, group?.length ?? 1)
|
|
const frameIdx =
|
|
missile.loopAnim === false
|
|
? Math.min(frameCount - 1, Math.max(0, Math.floor(missile.frame)))
|
|
: (missile.frame + Math.floor(nowMs / 50)) % frameCount
|
|
const frame = group?.[frameIdx] ?? group?.[0]
|
|
if (frame !== undefined) {
|
|
const altitude = missile.altitude ?? 0
|
|
const drawX =
|
|
frame.anchorX !== undefined
|
|
? missile.worldX + frame.anchorX
|
|
: missile.worldX - frame.width / 2
|
|
const drawY =
|
|
(frame.anchorY !== undefined
|
|
? missile.worldY + frame.anchorY
|
|
: missile.worldY - frame.height / 2) - altitude
|
|
renderer.draw(frame, drawX, drawY, {
|
|
atlas: art.handle,
|
|
blendMode: missile.blendMode,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
reportMissingArt(
|
|
'missile',
|
|
missile.missileKey || missile.celFile || String(missile.missileClassId),
|
|
`Missing missile art for key=${missile.missileKey} celFile=${missile.celFile} classId=${missile.missileClassId}`,
|
|
)
|
|
}
|
|
|
|
function drawSceneOverlayDrawable(
|
|
renderer: SpriteRenderer,
|
|
overlay: SceneOverlayDrawable,
|
|
ctx: SceneSourceRenderContext,
|
|
nowMs: number,
|
|
): void {
|
|
const lowerToken = overlay.token.toLowerCase()
|
|
const celFile = CANONICAL_113C_OVERLAYS[lowerToken]?.filename
|
|
const lowerCel = celFile?.toLowerCase()
|
|
const art =
|
|
ctx.overlayArtMap?.get(overlay.token) ??
|
|
ctx.overlayArtMap?.get(lowerToken) ??
|
|
(celFile ? ctx.overlayArtMap?.get(celFile) : undefined) ??
|
|
(lowerCel ? ctx.overlayArtMap?.get(lowerCel) : undefined) ??
|
|
ctx.missileArtMap?.get(overlay.token) ??
|
|
ctx.missileArtMap?.get(lowerToken) ??
|
|
(celFile ? ctx.missileArtMap?.get(celFile) : undefined) ??
|
|
(lowerCel ? ctx.missileArtMap?.get(lowerCel) : undefined)
|
|
if (art !== undefined) {
|
|
const group = art.frames[0]
|
|
const frameCount = Math.max(1, group?.length ?? 1)
|
|
const frameIdx =
|
|
overlay.loop === false
|
|
? Math.min(frameCount - 1, Math.max(0, Math.floor(overlay.frame)))
|
|
: (overlay.frame + Math.floor(nowMs / 60)) % frameCount
|
|
const frame = group?.[frameIdx] ?? group?.[0]
|
|
if (frame !== undefined) {
|
|
const drawX =
|
|
frame.anchorX !== undefined
|
|
? overlay.worldX + frame.anchorX
|
|
: overlay.worldX - frame.width / 2
|
|
const drawY =
|
|
frame.anchorY !== undefined
|
|
? overlay.worldY + frame.anchorY
|
|
: overlay.worldY - frame.height / 2
|
|
renderer.draw(frame, drawX, drawY, {
|
|
atlas: art.handle,
|
|
blendMode: overlay.blendMode,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
reportMissingArt(
|
|
'overlay',
|
|
overlay.token || String(overlay.overlayId),
|
|
`Missing overlay art for token=${overlay.token} overlayId=${overlay.overlayId}`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Render one frame driven by an engine-decoupled `SceneSource` (e.g. online `ClientWorld` + `LevelView`).
|
|
*
|
|
* Coordinate contract (#539): `SceneSource` already converts absolute D2GS world sub-tiles to
|
|
* level-local sub-tiles (`world - levelView.subOrigin`) for drawables, lighting and the player;
|
|
* this function must not re-normalise them with value-range heuristics. Until the source reports
|
|
* `isWorldReady()` (LevelView loaded + server-assigned player position) only a cleared frame and
|
|
* the HUD are drawn — the camera is never anchored on a guessed position.
|
|
*/
|
|
export function renderSceneSourceFrame(ctx: SceneSourceRenderContext): SceneFrameSnapshot {
|
|
const snapshot = ctx.source.buildFrameSnapshot()
|
|
const levelView = ctx.source.getLevelView()
|
|
const nowMs = ctx.nowMs ?? performance.now()
|
|
const renderer = ctx.renderer ?? null
|
|
|
|
if (!snapshot.worldReady) {
|
|
if (renderer !== null) {
|
|
renderer.begin({ x: 0, y: 0, zoom: 1 }, [0, 0, 0])
|
|
renderer.setLighting(null)
|
|
renderer.flush()
|
|
}
|
|
if (ctx.hudManager) {
|
|
ctx.hudManager.render(nowMs, ctx.playerMoving ?? false)
|
|
}
|
|
ctx.onFrameSnapshot?.(snapshot)
|
|
return snapshot
|
|
}
|
|
|
|
if (renderer !== null) {
|
|
ctx.tileGpuCache?.ensureActPalette(snapshot.actId)
|
|
const camX = snapshot.playerWorld.x
|
|
const camY = snapshot.playerWorld.y - 16
|
|
renderer.begin({ x: camX, y: camY, zoom: 1 }, [0.02, 0.02, 0.03])
|
|
|
|
if (ctx.lightingTargets) {
|
|
applyLightingInputs(ctx.source.buildLightingInputs(ctx.lightingPreset), ctx.lightingTargets)
|
|
}
|
|
|
|
const drawTileQuad = (
|
|
q: SceneTileQuad,
|
|
options?: {
|
|
readonly tint?: readonly [number, number, number, number]
|
|
readonly elevationY?: number
|
|
},
|
|
): void => {
|
|
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
|
|
const pageHandle = ctx.tileGpuCache.getPageHandle(q.act || snapshot.actId, p.page)
|
|
if (pageHandle === undefined) return
|
|
renderer.draw(
|
|
{ x: p.x, y: p.y, width: p.width, height: p.height },
|
|
q.worldX,
|
|
q.worldY,
|
|
{
|
|
atlas: pageHandle,
|
|
...(options?.tint ? { tint: options.tint } : {}),
|
|
...(options?.elevationY !== undefined ? { elevationY: options.elevationY } : {}),
|
|
},
|
|
)
|
|
}
|
|
|
|
// 1. Floors
|
|
for (const q of snapshot.floorQuads) {
|
|
drawTileQuad(q)
|
|
}
|
|
|
|
// 2. Shadows
|
|
for (const q of snapshot.shadowQuads) {
|
|
drawTileQuad(q, { tint: [1, 1, 1, 0.5] })
|
|
}
|
|
|
|
// 3. Depth-interleaved lower/upper walls and entities (units, missiles, overlays)
|
|
const sortedWalls = [...snapshot.lowerWallQuads, ...snapshot.upperWallQuads].sort(
|
|
(a, b) => a.sortY - b.sortY || a.cellX + a.cellY - (b.cellX + b.cellY),
|
|
)
|
|
const sortedEntities = snapshot.entities.sortedDrawables
|
|
let wallIdx = 0
|
|
const drawWallsUpToSortY = (maxSortY: number): void => {
|
|
while (wallIdx < sortedWalls.length) {
|
|
const w = sortedWalls[wallIdx]!
|
|
if (w.sortY > maxSortY) break
|
|
const h = w.placement?.height ?? 40
|
|
drawTileQuad(w, { elevationY: Math.max(0, h - 40) })
|
|
wallIdx += 1
|
|
}
|
|
}
|
|
|
|
const drawEntity = (entity: SceneEntityDrawable): void => {
|
|
if (entity.drawableKind === 'unit') {
|
|
drawSceneUnitDrawable(renderer, entity, ctx, nowMs, snapshot.actId)
|
|
} else if (entity.drawableKind === 'missile') {
|
|
drawSceneMissileDrawable(renderer, entity, ctx, nowMs)
|
|
} else if (entity.drawableKind === 'overlay') {
|
|
drawSceneOverlayDrawable(renderer, entity, ctx, nowMs)
|
|
}
|
|
}
|
|
|
|
for (const entity of sortedEntities) {
|
|
drawWallsUpToSortY(entity.sortY)
|
|
drawEntity(entity)
|
|
}
|
|
drawWallsUpToSortY(Infinity)
|
|
|
|
// 4. Roofs
|
|
for (const q of snapshot.roofQuads) {
|
|
drawTileQuad(q, { tint: [1, 1, 1, 0.85] })
|
|
}
|
|
|
|
// Disable world lighting before automap/UI sprites
|
|
renderer.setLighting(null)
|
|
|
|
// 5. Automap / Minimap via WebGL2 SpriteRenderer
|
|
if (ctx.minimap && ctx.automapView) {
|
|
const mode = ctx.automapView.getMode()
|
|
if (mode === 'off') {
|
|
ctx.minimap.visible = false
|
|
} else {
|
|
ctx.minimap.visible = true
|
|
const baseMinimapLevel = ctx.automapView.buildMinimapLevel()
|
|
const effectiveMinimapLevel: MinimapLevel = {
|
|
...baseMinimapLevel,
|
|
originX: levelView?.isoOriginX ?? 0,
|
|
originY: levelView?.isoOriginY ?? 0,
|
|
preRevealTown: isTownLevel(snapshot.levelId),
|
|
}
|
|
const localSub = snapshot.playerLocalSubTile
|
|
const playerCellX = Math.floor(localSub.x / 5)
|
|
const playerCellY = Math.floor(localSub.y / 5)
|
|
ctx.minimap.reveal(
|
|
effectiveMinimapLevel,
|
|
playerCellX,
|
|
playerCellY,
|
|
undefined,
|
|
undefined,
|
|
true,
|
|
)
|
|
const isEn = getTblLang() === 'ENG'
|
|
const automapEntities: AutomapEntityMarker[] = []
|
|
const automapPortals: {
|
|
readonly x: number
|
|
readonly y: number
|
|
readonly active: boolean
|
|
readonly kind: 'blue' | 'red'
|
|
readonly label?: string
|
|
}[] = []
|
|
for (const u of snapshot.entities.unitDrawables) {
|
|
if (u.unitKind === 'monster' && !u.isCorpse) {
|
|
const npcDesc = getTownNpcDescriptorByHcIdx(u.classId)
|
|
if (npcDesc) {
|
|
automapEntities.push({
|
|
x: u.worldX,
|
|
y: u.worldY,
|
|
kind: 'npc',
|
|
name: isEn ? npcDesc.displayName : npcDisplayName(npcDesc.displayName),
|
|
})
|
|
}
|
|
} else if (u.unitKind === 'object' && (u.classId === 59 || u.classId === 60)) {
|
|
automapPortals.push({
|
|
x: u.worldX,
|
|
y: u.worldY,
|
|
active: true,
|
|
kind: u.classId === 60 ? 'red' : 'blue',
|
|
...(u.name !== undefined ? { label: u.name } : {}),
|
|
})
|
|
} else if (u.unitKind === 'player' && u.isPartyMember) {
|
|
automapEntities.push({
|
|
x: u.worldX,
|
|
y: u.worldY,
|
|
kind: 'party',
|
|
...(u.name !== undefined ? { name: u.name } : {}),
|
|
})
|
|
}
|
|
}
|
|
ctx.minimap.draw(
|
|
renderer,
|
|
effectiveMinimapLevel,
|
|
playerCellX,
|
|
playerCellY,
|
|
{ x: camX, y: camY, zoom: 1 },
|
|
{
|
|
width: snapshot.viewportProfile.width,
|
|
height: snapshot.viewportProfile.height,
|
|
},
|
|
{
|
|
playerWorldX: snapshot.playerWorld.x,
|
|
playerWorldY: snapshot.playerWorld.y,
|
|
leftPanelOpen: Boolean(ctx.hudManager && ctx.hudManager.leftPanel !== 'none'),
|
|
rightPanelOpen: Boolean(ctx.hudManager && ctx.hudManager.rightPanel !== 'none'),
|
|
entities: automapEntities,
|
|
portals: automapPortals,
|
|
levelId: snapshot.levelId,
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
renderer.flush()
|
|
} else if (ctx.lightingTargets) {
|
|
applyLightingInputs(ctx.source.buildLightingInputs(ctx.lightingPreset), ctx.lightingTargets)
|
|
}
|
|
|
|
if (ctx.hudManager) {
|
|
ctx.hudManager.render(nowMs, ctx.playerMoving ?? false)
|
|
}
|
|
ctx.onFrameSnapshot?.(snapshot)
|
|
return snapshot
|
|
}
|
|
|
|
|