diablo2-web/src/client/scene/world-renderer.ts

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
}