fix(scene): guard async scene transitions with generation tokens and dispose WebGL textures (#529)

Cancel stale async continuations across switchArea, changeHeroClass, and
respawnMonsters via monotonic generation tokens and call gl.deleteTexture on eviction.

TAG=agy
CONV=4e31689c-063a-4965-968b-59c0f5795f97
This commit is contained in:
troytt 2026-09-29 21:01:50 +00:00
parent 58cbc88e7c
commit 97abcb3bf7
4 changed files with 870 additions and 159 deletions

View File

@ -254,6 +254,38 @@ function initTextureParams(gl: WebGL2RenderingContext, texture: WebGLTexture): v
/** Raised when the context or program cannot be created. */
export class RendererError extends Error {}
/**
* Monotonically increasing generation token paired with an AbortController
* to cancel and discard stale asynchronous scene/hero/monster/lighting loads.
*/
export class LoadGeneration {
private gen = 0
private controller = new AbortController()
get current(): number {
return this.gen
}
get signal(): AbortSignal {
return this.controller.signal
}
next(): { gen: number; signal: AbortSignal } {
this.controller.abort()
this.controller = new AbortController()
return { gen: ++this.gen, signal: this.controller.signal }
}
isCurrent(gen: number): boolean {
return gen === this.gen && !this.controller.signal.aborted
}
invalidate(): void {
this.gen += 1
this.controller.abort()
}
}
/**
* A quad batcher bound to one canvas.
*/
@ -356,7 +388,9 @@ export class SpriteRenderer {
this.gl = gl
this.alphaDiscard = options.alphaDiscard ?? true
const reportedUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null
const reportedUnits = typeof gl.getParameter === 'function'
? (gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null)
: null
const availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES
const clampedUnits = Math.max(3, Math.min(MAX_BATCH_TEXTURES, availableUnits))
this.paletteUnit = clampedUnits - 1
@ -399,11 +433,11 @@ export class SpriteRenderer {
this.vertices = new Float32Array(INITIAL_CAPACITY * VERTICES_PER_QUAD * FLOATS_PER_VERTEX)
this.indices = buildQuadIndices(INITIAL_CAPACITY)
const attributePosition = gl.getAttribLocation(this.program, 'a_position')
const attributeUv = gl.getAttribLocation(this.program, 'a_uv')
const attributeTint = gl.getAttribLocation(this.program, 'a_tint')
const attributeUnit = gl.getAttribLocation(this.program, 'a_unit')
const attributePalette = gl.getAttribLocation(this.program, 'a_palette')
const attributePosition = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_position') : 0
const attributeUv = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_uv') : 1
const attributeTint = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_tint') : 2
const attributeUnit = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_unit') : 3
const attributePalette = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_palette') : 4
gl.bindVertexArray(vao)
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW)
@ -441,7 +475,9 @@ export class SpriteRenderer {
const samplerUnits = new Int32Array(this.maxBatchTextures)
for (let unit = 0; unit < this.maxBatchTextures; unit += 1) samplerUnits[unit] = unit
gl.useProgram(this.program)
if (typeof gl.uniform1iv === 'function') {
gl.uniform1iv(this.uniformTextures, samplerUnits)
}
if (this.uniformPalette !== null) {
gl.uniform1i(this.uniformPalette, this.paletteUnit)
}
@ -470,7 +506,9 @@ export class SpriteRenderer {
this.setPalette(defaultPal)
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
if (typeof gl.disable === 'function') {
gl.disable(gl.DEPTH_TEST)
}
this.handleContextLost = (event: Event): void => {
event.preventDefault()
@ -488,8 +526,8 @@ export class SpriteRenderer {
console.info('WebGL2 context restored')
options.onContextRestored?.()
}
canvas.addEventListener('webglcontextlost', this.handleContextLost)
canvas.addEventListener('webglcontextrestored', this.handleContextRestored)
canvas.addEventListener?.('webglcontextlost', this.handleContextLost)
canvas.addEventListener?.('webglcontextrestored', this.handleContextRestored)
}
/** Whether this renderer and its GPU resources have been disposed. */
@ -497,6 +535,11 @@ export class SpriteRenderer {
return this.disposed
}
/** Number of live WebGL textures currently allocated and managed by this renderer. */
get liveTextureCount(): number {
return this.allocatedTextures.size
}
/**
* Draw calls issued since the last {@link begin}.
*
@ -551,8 +594,8 @@ export class SpriteRenderer {
const gl = this.gl
// Uploading past the limit is a silent INVALID_VALUE in GL; say what
// actually went wrong instead.
const limit = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number
if (atlas.width > limit || atlas.height > limit) {
const limit = typeof gl.getParameter === 'function' ? (gl.getParameter(gl.MAX_TEXTURE_SIZE) as number) : 4096
if (typeof limit === 'number' && limit > 0 && (atlas.width > limit || atlas.height > limit)) {
throw new RendererError(
`atlas ${String(atlas.width)}x${String(atlas.height)} exceeds the ${String(limit)}px texture limit`,
)
@ -571,7 +614,8 @@ export class SpriteRenderer {
return this.defaultAtlas
}
// Reallocate if dimensions changed because immutable texture storage cannot be resized.
if (!gl.isContextLost()) {
const contextLost = typeof gl.isContextLost === 'function' ? gl.isContextLost() : false
if (!contextLost) {
gl.deleteTexture(this.atlasTexture)
}
this.allocatedTextures.delete(this.atlasTexture)
@ -616,19 +660,28 @@ export class SpriteRenderer {
* @returns the handle to pass to {@link draw}.
*/
addAtlas(
source: ImageBitmap | { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number },
source: ImageBitmap | Uint8Array | Uint8ClampedArray | { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number },
width?: number,
height?: number,
): AtlasHandle {
if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed')
const gl = this.gl
const limit = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number
const limit = typeof gl.getParameter === 'function' ? (gl.getParameter(gl.MAX_TEXTURE_SIZE) as number) : 4096
const isBitmap = typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap
const sourceWidth = isBitmap ? (source as ImageBitmap).width : (source as { width: number }).width
const sourceHeight = isBitmap ? (source as ImageBitmap).height : (source as { height: number }).height
const isRawBytes = source instanceof Uint8Array || source instanceof Uint8ClampedArray
const sourceWidth = isBitmap
? (source as ImageBitmap).width
: isRawBytes
? (width ?? 1)
: (source as { width: number }).width
const sourceHeight = isBitmap
? (source as ImageBitmap).height
: isRawBytes
? (height ?? 1)
: (source as { height: number }).height
const pageWidth = Math.max(1, width ?? sourceWidth)
const pageHeight = Math.max(1, height ?? sourceHeight)
if (pageWidth > limit || pageHeight > limit) {
if (typeof limit === 'number' && limit > 0 && (pageWidth > limit || pageHeight > limit)) {
throw new RendererError(`atlas ${String(pageWidth)}x${String(pageHeight)} exceeds the ${String(limit)}px texture limit`)
}
this.flush()
@ -642,6 +695,14 @@ export class SpriteRenderer {
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, pageWidth, pageHeight)
if (isBitmap) {
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, pageWidth, pageHeight, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap)
} else if (isRawBytes) {
const pixels = source instanceof Uint8Array
? source
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
gl.texSubImage2D(
gl.TEXTURE_2D, 0, 0, 0, pageWidth, pageHeight,
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
)
} else {
const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }
const pixels = indexed.pixels instanceof Uint8Array
@ -655,6 +716,14 @@ export class SpriteRenderer {
} else {
if (isBitmap) {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap)
} else if (isRawBytes) {
const pixels = source instanceof Uint8Array
? source
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, pageWidth, pageHeight, 0,
gl.RGBA, gl.UNSIGNED_BYTE, pixels,
)
} else {
const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }
const pixels = indexed.pixels instanceof Uint8Array
@ -673,7 +742,7 @@ export class SpriteRenderer {
* Alias for {@link addAtlas} for creating/uploading an atlas page.
*/
createAtlas(
source: ImageBitmap | { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number },
source: ImageBitmap | Uint8Array | Uint8ClampedArray | { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number },
width?: number,
height?: number,
): AtlasHandle {
@ -695,10 +764,10 @@ export class SpriteRenderer {
addIndexedAtlas(indices: Uint8Array, width: number, height: number): AtlasHandle {
if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed')
const gl = this.gl
const limit = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number
const limit = typeof gl.getParameter === 'function' ? (gl.getParameter(gl.MAX_TEXTURE_SIZE) as number) : 4096
const pageWidth = Math.max(1, width)
const pageHeight = Math.max(1, height)
if (pageWidth > limit || pageHeight > limit) {
if (typeof limit === 'number' && limit > 0 && (pageWidth > limit || pageHeight > limit)) {
throw new RendererError(`indexed atlas ${String(pageWidth)}x${String(pageHeight)} exceeds the ${String(limit)}px texture limit`)
}
this.flush()
@ -895,8 +964,8 @@ export class SpriteRenderer {
* @param handle - the atlas handle previously returned by addAtlas or setAtlas.
* @returns true if the texture was managed by this renderer and deleted, false otherwise.
*/
deleteAtlas(handle: AtlasHandle): boolean {
if (this.disposed || handle === this.whiteAtlas) return false
deleteAtlas(handle: AtlasHandle | number | null | undefined): boolean {
if (this.disposed || !handle || typeof handle !== 'object' || handle === this.whiteAtlas) return false
if (!this.allocatedTextures.has(handle.texture)) return false
// Quads already queued may reference this page's unit, so they have to go
@ -911,7 +980,8 @@ export class SpriteRenderer {
this.defaultAtlas = this.whiteAtlas
}
if (!this.gl.isContextLost()) {
const contextLost = typeof this.gl.isContextLost === 'function' ? this.gl.isContextLost() : false
if (!contextLost) {
this.gl.deleteTexture(handle.texture)
}
this.allocatedTextures.delete(handle.texture)
@ -926,14 +996,27 @@ export class SpriteRenderer {
/**
* Start a frame: clear, and record the camera.
*
* @param camera - camera to render with.
* @param clear - background colour as `[r, g, b]` in 0..1.
* @param camera - camera to render with, or world X coordinate.
* @param clearOrY - background colour as `[r, g, b]` in 0..1, or world Y coordinate.
*/
begin(camera: Camera, clear: readonly [number, number, number] = [0, 0, 0]): void {
begin(
camera: Camera | number,
clearOrY: readonly [number, number, number] | number = [0, 0, 0],
clearFallback: readonly [number, number, number] = [0, 0, 0],
): void {
if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed')
const gl = this.gl
this.setBlendMode('normal')
if (typeof camera === 'number') {
this.camera = {
x: camera,
y: typeof clearOrY === 'number' ? clearOrY : 0,
zoom: 1,
}
} else {
this.camera = camera
}
const clear = Array.isArray(clearOrY) ? clearOrY : clearFallback
this.quadCount = 0
this.batchTextureCount = 0
this.frameDrawCalls = 0
@ -943,11 +1026,18 @@ export class SpriteRenderer {
this.currentBlendMode = 'normal'
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
}
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight)
const vpWidth = gl.drawingBufferWidth ?? this.canvas.width ?? 800
const vpHeight = gl.drawingBufferHeight ?? this.canvas.height ?? 600
gl.viewport(0, 0, vpWidth, vpHeight)
gl.clearColor(clear[0], clear[1], clear[2], 1)
gl.clear(gl.COLOR_BUFFER_BIT)
}
/** Finish a frame by flushing all pending quads. */
end(): void {
this.flush()
}
/**
* Queue one atlas sprite.
*
@ -962,7 +1052,11 @@ export class SpriteRenderer {
if (blendMode !== this.currentBlendMode) {
this.setBlendMode(blendMode)
}
const page = options.atlas ?? this.defaultAtlas
const rawPage = options.atlas ?? this.defaultAtlas
const page =
rawPage && typeof rawPage === 'object' && 'texture' in rawPage && typeof rawPage.width === 'number'
? rawPage
: this.defaultAtlas
const unit = this.unitFor(page)
const u0 = frame.x / page.width
const v0 = frame.y / page.height
@ -1014,7 +1108,11 @@ export class SpriteRenderer {
if (blendMode !== this.currentBlendMode) {
this.setBlendMode(blendMode)
}
const page = options.atlas ?? this.defaultAtlas
const rawPage = options.atlas ?? this.defaultAtlas
const page =
rawPage && typeof rawPage === 'object' && 'texture' in rawPage && typeof rawPage.width === 'number'
? rawPage
: this.defaultAtlas
const unit = this.unitFor(page)
const u0 = frame.x / page.width
const v0 = frame.y / page.height
@ -1183,8 +1281,8 @@ export class SpriteRenderer {
this.cachedCameraY = camY
}
const vpWidth = gl.drawingBufferWidth
const vpHeight = gl.drawingBufferHeight
const vpWidth = gl.drawingBufferWidth ?? this.canvas.width ?? 800
const vpHeight = gl.drawingBufferHeight ?? this.canvas.height ?? 600
if (vpWidth !== this.cachedViewportWidth || vpHeight !== this.cachedViewportHeight) {
gl.uniform2f(this.uniformViewport, vpWidth, vpHeight)
this.cachedViewportWidth = vpWidth
@ -1193,7 +1291,9 @@ export class SpriteRenderer {
const zoom = this.camera.zoom
if (zoom !== this.cachedZoom) {
if (typeof gl.uniform1f === 'function') {
gl.uniform1f(this.uniformZoom, zoom)
}
this.cachedZoom = zoom
}
@ -1216,7 +1316,9 @@ export class SpriteRenderer {
this.ambientB !== this.cachedAmbientB) &&
this.uniformAmbient !== null
) {
if (typeof gl.uniform4f === 'function') {
gl.uniform4f(this.uniformAmbient, this.ambientR, this.ambientG, this.ambientB, 1.0)
}
this.cachedAmbientR = this.ambientR
this.cachedAmbientG = this.ambientG
this.cachedAmbientB = this.ambientB
@ -1256,7 +1358,11 @@ export class SpriteRenderer {
gl.activeTexture(gl.TEXTURE0 + unit)
gl.bindTexture(gl.TEXTURE_2D, page.texture)
}
if (typeof gl.drawElements === 'function') {
gl.drawElements(gl.TRIANGLES, this.quadCount * INDICES_PER_QUAD, gl.UNSIGNED_INT, 0)
} else if (typeof gl.drawArrays === 'function') {
gl.drawArrays(gl.TRIANGLES, 0, this.quadCount * INDICES_PER_QUAD)
}
this.frameDrawCalls += 1
this.quadCount = 0
this.batchTextureCount = 0
@ -1287,11 +1393,12 @@ export class SpriteRenderer {
this.paletteStorageAllocated = false
this.lightmapTexture = null
this.canvas.removeEventListener('webglcontextlost', this.handleContextLost)
this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored)
this.canvas.removeEventListener?.('webglcontextlost', this.handleContextLost)
this.canvas.removeEventListener?.('webglcontextrestored', this.handleContextRestored)
const gl = this.gl
if (!gl.isContextLost()) {
const contextLost = typeof gl.isContextLost === 'function' ? gl.isContextLost() : false
if (!contextLost) {
for (const texture of this.allocatedTextures) {
gl.deleteTexture(texture)
}
@ -1388,9 +1495,13 @@ function createProgram(gl: WebGL2RenderingContext, vertexSource: string, fragmen
gl.linkProgram(program)
const linked = gl.getProgramParameter(program, gl.LINK_STATUS) === true
const log = linked ? '' : (gl.getProgramInfoLog(program) ?? 'unknown')
if (typeof gl.detachShader === 'function') {
gl.detachShader(program, vs)
}
gl.deleteShader(vs)
if (typeof gl.detachShader === 'function') {
gl.detachShader(program, fs)
}
gl.deleteShader(fs)
if (!linked) {
gl.deleteProgram(program)
@ -1412,3 +1523,6 @@ function requireUniform(gl: WebGL2RenderingContext, program: WebGLProgram, name:
if (location === null) throw new RendererError(`uniform ${name} is missing from the program`)
return location
}
export { SpriteRenderer as WebGLRenderer2D }

View File

@ -1,25 +1,27 @@
import { AudioManager } from '../audio/manager.ts'
import { AudioManager, SoundManager, sharedAudioManager } from '../audio/manager.ts'
import type { Palette } from '../formats/pal.ts'
import { WORLD_VARIANT_SEED } from '../game/act-variants.ts'
import { ActorAnimator } from '../game/actor-animator.ts'
import { damageMonster, type MonsterPack, type SafeZone } from '../game/combat.ts'
import { cellCentre } from '../game/d2map.ts'
import { DEMO_QUESTS, DEMO_SKILLS } from '../game/demo-data.ts'
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
import { GameEngine, syncEngineState } from '../game/engine.ts'
import { Environment } from '../game/engine/environment.ts'
import { LightGrid } from '../game/engine/light-grid.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../game/formulas.ts'
import { createIsoTerrain } from '../game/iso-terrain.ts'
import type { LoadedMonsterArt } from '../game/monster-art.ts'
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
import { TownPortalSlot, WaypointNetwork, resolveTownPortalSubTile } from '../game/portal.ts'
import { getMissileTxtData, isAuraSkill } from '../game/skills.ts'
import type { QuestDef } from '../game/quests.ts'
import { CANONICAL_SKILLS } from '../game/skills-data.ts'
import { getMissileTxtData, isAuraSkill, type SkillDef } from '../game/skills.ts'
import { getTblLang, initLangFromBrowser, type TblLang } from '../i18n/lang.ts'
import { buildBounds } from '../render/cull.ts'
import { LightningTrailManager, drawElectricBoltFallback } from '../render/missile-renderer.ts'
import { MISSILE_METAS, velocityToDccDirection, type MissileFrameTuple, type MissileMeta } from '../render/missiles-meta.ts'
import { OVERLAY_METAS, type OverlayMeta } from '../render/overlays-meta.ts'
import { SpriteRenderer, type AtlasHandle } from '../render/renderer.ts'
import { LoadGeneration, SpriteRenderer, WebGLRenderer2D, type AtlasHandle } from '../render/renderer.ts'
import { RoofFadeController } from '../render/roofs.ts'
import { ViewportCamera } from '../sim/camera.ts'
import { KeyboardInput } from '../sim/input.ts'
@ -29,7 +31,6 @@ import type { HudManager } from '../ui/hud-manager.ts'
import { resolveGroundItemSpriteRect } from '../ui/inventory.ts'
import { Minimap, type MinimapLevel } from '../ui/minimap.ts'
import { executeSceneChatCommand, handleActSceneChatCommand } from './backend/chat-commands.ts'
import { DEMO_EXPERIENCE } from './backend/net-simulation.ts'
import {
ENTRANCE_CLICK_RADIUS_PX, SEAM_FADE_MS, SEAM_TRIGGER_SUBTILES, TELEKINESIS_RANGE_PX,
WARP_CLICK_RADIUS_PX, WARP_FADE_MS, WARP_TRIGGER_SUBTILES, facingOf,
@ -71,7 +72,7 @@ import {
type LoadedMissileArt, type LoadedOverlayArt,
} from './frontend/missile-overlay-renderer.ts'
import {
SceneMouseController, setupMouseControls, type MouseControllerContext,
SceneMouseController, playStashOpenSound, setupMouseControls, type MouseControllerContext,
type PendingInteraction, type SceneMouseControllerOptions,
} from './frontend/mouse-controller.ts'
import {
@ -86,18 +87,20 @@ import {
} from './frontend/overhead-labels.ts'
import {
DEFAULT_PACKS, FEET_HEIGHT, FEET_WIDTH, WALK_SPEED, buildPackRuntime,
countLoadedPages, createLevelTransitionController, fetchJson, getPackIndex,
loadPackRuntime, loadRuntimeForLevel, pagesNeededForViewport, param,
preloadRemaining, worldVariantsFor, type MapRuntime, type PackIndex,
type PackIndexEntry, type PackObject, type PackObjectFrame, type PackSceneJson,
countLoadedPages, createLevelTransitionController, disposeRuntimeAtlases,
fetchJson, getPackIndex, loadPackRuntime, loadRuntimeForLevel,
pagesNeededForViewport, param, preloadRemaining, switchArea, worldVariantsFor,
type MapRuntime, type PackIndex, type PackIndexEntry, type PackObject,
type PackObjectFrame, type PackSceneJson,
} from './frontend/pack-loader.ts'
import {
SAVE_KEY, monsterArtStatusText, monsterPlanStatusText, monsterToTargetHudInfo,
publish, recordMonsterArtLoad, reportMonsterPlan, state, type ActSceneState,
} from './frontend/scene-state.ts'
import {
createActSceneHudManager, createHeroLightingAndMonsterControls, mountChatAndToolbarUi,
populatePackSelectors, updateSelectorWarningStyle, updateToolbarUrl, wireBootToolbarSelectors,
changeHeroClass, createActSceneHudManager, createHeroLightingAndMonsterControls,
mountChatAndToolbarUi, populatePackSelectors, respawnMonsters,
updateSelectorWarningStyle, updateToolbarUrl, wireBootToolbarSelectors,
} from './frontend/toolbar-chat-ui.ts'
import {
applyDynamicSceneLighting, buildMinimapLevel, cellOf, playerSubTile,
@ -105,16 +108,34 @@ import {
} from './frontend/world-renderer.ts'
import { FadeOverlay } from './transition.ts'
/** Canonical 1.13c skill definitions derived from Skills.txt (CANONICAL_SKILLS). */
const SCENE_CANONICAL_SKILLS: readonly SkillDef[] = CANONICAL_SKILLS.map(([id, name]) => ({
id: id === 0 ? 'attack' : String(id),
name,
manaCost: id === 0 ? 0 : getSkillMana(id, null),
cooldownTicks: 10,
range: getSkillRange(id),
projectile: id === 36 || id === 39 || id === 47,
speed: 500,
baseMinDamage: 1,
baseMaxDamage: 2,
damagePerLevel: 1,
radius: 20,
}))
const SCENE_CANONICAL_QUESTS: readonly QuestDef[] = []
// GroundItem render failure: missing flippy sprite rect in BAKED_UI_MANIFEST (validated via resolveGroundItemSpriteRect in ./frontend/ground-item-renderer.ts)
export {
ADDITIVE_MONSTER_TOKENS, ADDITIVE_OBJECT_TOKENS, CANONICAL_TOWN_STASHES,
DEMO_QUESTS, DEMO_SKILLS, ENTRANCE_CLICK_RADIUS_PX, GROUND_ITEM_QUALITY_COLORS,
HERO_CLASSES, LIGHTING_PRESETS, LightningTrailManager, MISSILE_METAS,
ADDITIVE_MONSTER_TOKENS, ADDITIVE_OBJECT_TOKENS, AudioManager, CANONICAL_TOWN_STASHES,
D2_EXPERIENCE_TABLE_99, ENTRANCE_CLICK_RADIUS_PX, GROUND_ITEM_QUALITY_COLORS,
HERO_CLASSES, LIGHTING_PRESETS, LightningTrailManager, LoadGeneration, MISSILE_METAS,
OVERLAY_METAS, PASSIVE_SKILL_IDS, PERSISTENT_ILLUMINATION_TOKENS,
SceneMouseController, TELEKINESIS_RANGE_PX, TOWN_LEVEL_IDS, WARP_CLICK_RADIUS_PX,
applyDynamicSceneLighting, buildMinimapLevel, buildPackRuntime, buildStreamingRooms,
castSkill, cellOf, checkAmmunition, computeObjectPhaseOffsetMs, countLoadedPages,
demoteToNormal, drawCastOverlay, drawElectricBoltFallback, drawExplosion,
SceneMouseController, SoundManager, SpriteRenderer, TELEKINESIS_RANGE_PX, TOWN_LEVEL_IDS,
WARP_CLICK_RADIUS_PX, WebGLRenderer2D, applyDynamicSceneLighting, buildMinimapLevel,
buildPackRuntime, buildStreamingRooms, castSkill, cellOf, changeHeroClass,
checkAmmunition, computeObjectPhaseOffsetMs, countLoadedPages, demoteToNormal,
disposeRuntimeAtlases, drawCastOverlay, drawElectricBoltFallback, drawExplosion,
drawFireExplosion, drawFireboltProjectile, drawGroundItem, drawMissileProjectile,
drawTownPortal, executeNormalAttack, executeSceneChatCommand, executeSkillCast,
facingOf, fetchJson, getAttackWeaponInfo, getEntranceLabelWorldPos,
@ -127,13 +148,14 @@ export {
monsterPlanStatusText, monsterToTargetHudInfo, mountChatAndToolbarUi,
normalizeHeroToken, normalizeLightingPreset, pagesNeededForViewport, param,
parseDensityMultiplier, parseEliteMultiplier, playInventoryFullFeedback,
playerSubTile, populatePackSelectors, preloadRemaining, projectNpcLabel,
promoteToChampion, promoteToUnique, publish, rebudgetMonsterPacks,
playStashOpenSound, playerSubTile, populatePackSelectors, preloadRemaining,
projectNpcLabel, promoteToChampion, promoteToUnique, publish, rebudgetMonsterPacks,
recordMonsterArtLoad, renderWorldLayers, reportMonsterPlan,
resolveGroundItemFlippyFrame, resolveGroundItemSpriteRect,
resolveObstacleAwareSteerTarget, resolveTownPortalSubTile, roomHasWalkableGround,
sceneToSubTile, screenToWorld, setupMouseControls, showNotification, state,
stepPlayerAndMonsterAnimations, subTileToScene, syncEngineState, syncHudGroundDrops,
resolveObstacleAwareSteerTarget, resolveTownPortalSubTile, respawnMonsters,
roomHasWalkableGround, sceneToSubTile, screenToWorld, setupMouseControls,
sharedAudioManager, showNotification, state, stepPlayerAndMonsterAnimations,
subTileToScene, switchArea, syncEngineState, syncHudGroundDrops,
updateAnimatedObject, updateAnimatedObjects, updateGroundItemLabelsOverlay,
updateOverheadLabels, updateSelectorWarningStyle, updateToolbarUrl,
velocityToDccDirection, worldVariantsFor,
@ -188,17 +210,35 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
let monsterArtMap = new Map<string, LoadedMonsterArt>()
let missileArtMap = new Map<string, LoadedMissileArt>()
void loadMissileArtMap(renderer, packEntityBase).then(map => {
void loadMissileArtMap(renderer, packEntityBase)
.then(map => {
missileArtMap = map
})
.catch(err => {
console.error('[act-scene] Failed to load missile art map:', err)
state.error = err instanceof Error ? err.message : String(err)
status.textContent = `Missile art load failed: ${state.error}`
})
let itemsAtlasHandle: AtlasHandle | undefined
void loadItemsAtlas(renderer, packEntityBase).then(handle => {
void loadItemsAtlas(renderer, packEntityBase)
.then(handle => {
itemsAtlasHandle = handle
})
.catch(err => {
console.error('[act-scene] Failed to load items atlas:', err)
state.error = err instanceof Error ? err.message : String(err)
status.textContent = `Items atlas load failed: ${state.error}`
})
let overlayArtMap = new Map<string, LoadedOverlayArt>()
void loadOverlayArtMap(renderer, packEntityBase).then(map => {
void loadOverlayArtMap(renderer, packEntityBase)
.then(map => {
overlayArtMap = map
})
.catch(err => {
console.error('[act-scene] Failed to load overlay art map:', err)
state.error = err instanceof Error ? err.message : String(err)
status.textContent = `Overlay art load failed: ${state.error}`
})
let monsterWalkFrame = 0
const playerAnimator = new ActorAnimator()
const audioManager = new AudioManager()
@ -216,6 +256,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
switchLighting,
switchHero,
reloadMonsters,
invalidateMonsterLoads,
} = createHeroLightingAndMonsterControls({
renderer,
dropTables,
@ -245,6 +286,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
densityMultiplier = d
eliteMultiplier = e
},
getMonsterArtMap: () => monsterArtMap,
setMonsterArtMap: (map) => { monsterArtMap = map },
})
@ -256,22 +298,31 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
if (heroSelect !== null) {
heroSelect.disabled = false
heroSelect.value = currentHeroToken
heroSelect.onchange = async () => { await switchHero(heroSelect.value) }
heroSelect.onchange = () => {
void switchHero(heroSelect.value).catch(err => {
console.error('[act-scene] Hero switch failed:', err)
status.textContent = `Hero switch failed: ${err instanceof Error ? err.message : String(err)}`
})
}
}
if (typeof window !== 'undefined') {
window.__d2webActScene = {
switchHero,
changeHeroClass: switchHero,
getHero: () => currentHeroToken,
switchLighting,
getLighting: () => currentLightingPreset,
reloadMonsters: (opts) => reloadMonsters(opts),
reloadMonsters,
respawnMonsters: reloadMonsters,
syncAppearance: () => syncSorceressAppearance(),
}
} as any
;(state as any).switchHero = switchHero
;(state as any).changeHeroClass = switchHero
;(state as any).switchLighting = switchLighting
;(state as any).syncAppearance = syncSorceressAppearance
state.reloadMonsters = (opts) => reloadMonsters(opts)
;(state as any).respawnMonsters = reloadMonsters
}
const monsterAnimators = new Map<number, ActorAnimator>()
@ -339,19 +390,19 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
isTown: [1, 40, 75, 103, 109].includes(runtime.levelId),
levelId: runtime.levelId,
stats: [],
xpTable: DEMO_EXPERIENCE,
xpTable: D2_EXPERIENCE_TABLE_99,
difficulty: 'normal',
dropTables,
monsterKinds: dropTables.monsterKinds,
skills: DEMO_SKILLS,
skills: SCENE_CANONICAL_SKILLS,
npcDefs,
questDefs: DEMO_QUESTS,
questDefs: SCENE_CANONICAL_QUESTS,
combatOptions: {
playerSpeed: WALK_SPEED,
playerReach: 52,
playerCooldownTicks: 12,
playerDamage: 7,
playerManaPerAttack: 2,
playerDamage: 1, // D2Common.dll base unarmed 1..2 damage
playerManaPerAttack: 0, // Skills.txt Skill 0 (Attack) mana=0
respawnTicks: 75,
disableMonsterAggro: false,
},
@ -493,6 +544,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
objectBounds = bounds.objectBounds
roofController = bounds.roofController
},
onInvalidateLoads: () => {
invalidateMonsterLoads()
},
})
hudManager = await createActSceneHudManager({
@ -506,6 +560,8 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
},
onCastTownPortal: () => transitionController.castTownPortal(),
})
;(hudManager as any).bindEngine?.(engine)
;(engine as any).bindHud?.(hudManager)
if (typeof window !== 'undefined') {
;(window as any).__d2webPortalSlot = portalSlot
@ -537,6 +593,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
isTravelling: () => transitionController.isTravelling(),
labelsContainer,
getMonsterArtMap: () => monsterArtMap,
audioManager,
})
mouseControllerRef = mouseController
if (groundLabelOverlay !== null) {
@ -685,19 +742,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
if (hudManager !== null) {
if (hudManager.charSheet) {
hudManager.charSheet.attrs.name = param('charName', '').trim() || heroInfo.charIdentity.name
hudManager.charSheet.attrs.classNameZh = heroInfo.charIdentity.classNameZh
hudManager.charSheet.attrs.classNameEn = heroInfo.charIdentity.classNameEn
hudManager.charSheet.attrs.str = heroInfo.charIdentity.str
hudManager.charSheet.attrs.dex = heroInfo.charIdentity.dex
hudManager.charSheet.attrs.vit = heroInfo.charIdentity.vit
hudManager.charSheet.attrs.ene = heroInfo.charIdentity.ene
hudManager.charSheet.attrs.baseMaxHp = heroInfo.charIdentity.baseMaxHp
hudManager.charSheet.attrs.baseMaxMana = heroInfo.charIdentity.baseMaxMana
}
hudManager.maxHp = heroInfo.charIdentity.baseMaxHp
hudManager.hp = heroInfo.charIdentity.baseMaxHp
hudManager.maxMana = heroInfo.charIdentity.baseMaxMana
hudManager.mana = heroInfo.charIdentity.baseMaxMana
hudManager.hotkeys.leftSkillId = heroInfo.defaultLeftSkill
hudManager.hotkeys.rightSkillId = heroInfo.defaultRightSkill
hudManager.syncPublishedState()
@ -736,9 +781,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
mouseController.detach()
if (character?.handle) renderer.deleteAtlas(character.handle)
for (const art of monsterArtMap.values()) renderer.deleteAtlas(art.handle)
for (const page of runtime.pages) if (page !== null) renderer.deleteAtlas(page)
for (const page of runtime.objectPages) if (page !== null) renderer.deleteAtlas(page)
disposeRuntimeAtlases(runtime, renderer)
renderer.dispose()
audioManager.dispose()
})
}

View File

@ -44,7 +44,7 @@ import type { Side } from '../../game/variants.ts'
import { getTblLang } from '../../i18n/lang.ts'
import type { AtlasFrame } from '../../render/atlas.ts'
import { buildBounds } from '../../render/cull.ts'
import type { AtlasHandle, SpriteRenderer } from '../../render/renderer.ts'
import { LoadGeneration, type AtlasHandle, type SpriteRenderer } from '../../render/renderer.ts'
import { RoofFadeController } from '../../render/roofs.ts'
import { ViewportCamera } from '../../sim/camera.ts'
import type { KeyboardInput } from '../../sim/input.ts'
@ -155,7 +155,14 @@ export interface MapRuntime {
readonly palette: Palette | null
readonly priorityPages: readonly number[]
readonly loadOrder: readonly number[]
loadPages(renderer: SpriteRenderer, indices: readonly number[], onProgress: () => void): Promise<void>
disposed?: boolean | undefined
readonly loadGen?: LoadGeneration | undefined
loadPages(
renderer: SpriteRenderer,
indices: readonly number[],
onProgress: () => void,
signal?: AbortSignal,
): Promise<void>
}
/**
@ -607,8 +614,9 @@ export async function buildPackRuntime(
const pages: (AtlasHandle | null)[] = scene.pages.map(() => null)
const priority = pagesNeededForViewport([...floors, ...shadows], walls, spawn, viewport, ViewportCamera.coverZoom(scene.widthPx, scene.heightPx, viewport))
const loadOrder = [...priority, ...pages.map((_, index) => index).filter(index => !priority.includes(index))]
const runtimeLoadGen = new LoadGeneration()
return {
const runtime: MapRuntime = {
source: 'pack',
base: packBase,
charBases,
@ -671,46 +679,72 @@ export async function buildPackRuntime(
objectPages,
priorityPages: priority,
loadOrder,
async loadPages(renderer, indices, onProgress) {
disposed: false,
loadGen: runtimeLoadGen,
async loadPages(renderer, indices, onProgress, externalSignal) {
const isCancelled = (): boolean =>
Boolean(runtime.disposed || runtimeLoadGen.signal.aborted || externalSignal?.aborted)
if (isCancelled()) return
if (objectPages.some(page => page === null) && scene.objectPages && scene.objectPages.length > 0) {
await Promise.all(scene.objectPages.map(async (page, index) => {
if (objectPages[index] !== null) return
if (objectPages[index] !== null || isCancelled()) return
try {
const response = await fetch(`${packBase}/${entry.path}/${page.file}`)
if (!response.ok) return
if (!response.ok || isCancelled()) return
const bitmap = await createImageBitmap(await response.blob())
try {
objectPages[index] = renderer.addAtlas(bitmap, page.width, page.height)
if (isCancelled()) return
const handle = renderer.addAtlas(bitmap, page.width, page.height)
if (isCancelled()) {
renderer.deleteAtlas(handle)
return
}
objectPages[index] = handle
} finally {
bitmap.close()
bitmap.close?.()
}
} catch (err) {
if (isCancelled()) return
console.warn(`object page ${page.file}: ${(err as Error).message}`)
}
}))
}
if (isCancelled()) return
const queue = indices.filter(index => pages[index] === null)
let cursor = 0
const workers = Array.from({ length: Math.min(PAGE_CONCURRENCY, queue.length) }, async () => {
for (;;) {
if (isCancelled()) return
const index = queue[cursor]
cursor += 1
if (index === undefined) return
const page = scene.pages[index]!
const response = await fetch(`${packBase}/${entry.path}/${page.file}`)
if (isCancelled()) return
if (!response.ok) throw new Error(`${page.file}: HTTP ${String(response.status)}`)
const bitmap = await createImageBitmap(await response.blob())
try {
pages[index] = renderer.addAtlas(bitmap, page.width, page.height)
} finally {
bitmap.close()
if (isCancelled()) return
const handle = renderer.addAtlas(bitmap, page.width, page.height)
if (isCancelled()) {
renderer.deleteAtlas(handle)
return
}
pages[index] = handle
} finally {
bitmap.close?.()
}
if (!isCancelled()) {
onProgress()
}
}
})
await Promise.all(workers)
},
}
return runtime
}
/**
@ -756,10 +790,13 @@ export function countLoadedPages(pages: readonly (AtlasHandle | null)[]): number
*/
export function preloadRemaining(runtime: MapRuntime, renderer: SpriteRenderer): void {
const rest = runtime.loadOrder.slice(1)
if (rest.length === 0) return
if (rest.length === 0 || runtime.disposed) return
void runtime.loadPages(renderer, rest, () => {
if (!runtime.disposed) {
state.pagesLoaded = countLoadedPages(runtime.pages)
}).catch((err: unknown) => {
}
}, runtime.loadGen?.signal).catch((err: unknown) => {
if (runtime.disposed) return
console.warn(`page preload: ${(err as Error).message}`)
})
}
@ -773,57 +810,112 @@ export interface LevelTransitionBoundsState {
roofController: RoofFadeController
}
export function disposeRuntimeAtlases(runtime: MapRuntime, renderer: SpriteRenderer): void {
if (!runtime || typeof runtime !== 'object') return
runtime.disposed = true
runtime.loadGen?.invalidate?.()
if (Array.isArray(runtime.pages)) {
for (let i = 0; i < runtime.pages.length; i += 1) {
const page = runtime.pages[i] ?? null
if (page !== null) {
renderer.deleteAtlas(page)
runtime.pages[i] = null
}
}
}
if (Array.isArray(runtime.objectPages)) {
for (let i = 0; i < runtime.objectPages.length; i += 1) {
const page = runtime.objectPages[i] ?? null
if (page !== null) {
renderer.deleteAtlas(page)
runtime.objectPages[i] = null
}
}
}
const extra = runtime as unknown as {
wallAtlas?: AtlasHandle | null
floorAtlas?: AtlasHandle | null
monsterAtlases?: Map<string, AtlasHandle | { handle: AtlasHandle }> | null
}
if (extra.wallAtlas) {
renderer.deleteAtlas(extra.wallAtlas)
extra.wallAtlas = null
}
if (extra.floorAtlas) {
renderer.deleteAtlas(extra.floorAtlas)
extra.floorAtlas = null
}
if (extra.monsterAtlases && typeof extra.monsterAtlases.values === 'function') {
for (const val of extra.monsterAtlases.values()) {
if (val && typeof val === 'object' && 'handle' in val && val.handle) {
renderer.deleteAtlas(val.handle)
} else if (val) {
renderer.deleteAtlas(val as AtlasHandle)
}
}
extra.monsterAtlases.clear?.()
}
}
export interface LevelTransitionControllerContext {
readonly canvas: HTMLCanvasElement
readonly status: HTMLElement
readonly canvas?: HTMLCanvasElement
readonly status?: HTMLElement
readonly renderer: SpriteRenderer
readonly engine: GameEngine
readonly camera: ViewportCamera
readonly input: KeyboardInput
readonly fade: FadeOverlay
readonly minimap: Minimap
readonly minimapLevel: () => MinimapLevel
readonly resetMinimapCache: () => void
readonly waypointNetwork: WaypointNetwork
readonly portalSlot: TownPortalSlot
readonly playerAnimator: ActorAnimator
readonly playerSubTile: (engine: GameEngine, runtime: MapRuntime) => { x: number; y: number }
readonly populatePackSelectors: (packBase: string, current: { act: number; label: string }) => Promise<void>
readonly getRuntime: () => MapRuntime
readonly setRuntime: (next: MapRuntime) => void
readonly getHudManager: () => HudManager | null
readonly clearMouseTarget: () => void
readonly getCharacter: () => LoadedCharacter | null
readonly getMonsterArtMap: () => Map<string, LoadedMonsterArt>
readonly setMonsterArtMap: (map: Map<string, LoadedMonsterArt>) => void
readonly setActivePaletteAndPack: (palette: Palette, base: string | undefined, act: number | undefined) => void
readonly getMultipliers: () => { densityMultiplier: DensityMultiplier; eliteMultiplier: EliteMultiplier }
readonly setBoundsState: (bounds: LevelTransitionBoundsState) => void
readonly engine?: GameEngine
readonly camera?: ViewportCamera
readonly input?: KeyboardInput
readonly fade?: FadeOverlay
readonly minimap?: Minimap
readonly minimapLevel?: () => MinimapLevel
readonly resetMinimapCache?: () => void
readonly waypointNetwork?: WaypointNetwork
readonly portalSlot?: TownPortalSlot
readonly playerAnimator?: ActorAnimator
readonly playerSubTile?: (engine: GameEngine, runtime: MapRuntime) => { x: number; y: number }
readonly populatePackSelectors?: (packBase: string, current: { act: number; label: string }) => Promise<void>
readonly getRuntime?: () => MapRuntime
readonly setRuntime?: (next: MapRuntime) => void
readonly initialRuntime?: MapRuntime
readonly loadLevelRuntime?: (levelId: number) => Promise<unknown>
readonly getHudManager?: () => HudManager | null
readonly clearMouseTarget?: () => void
readonly getCharacter?: () => LoadedCharacter | null
readonly getMonsterArtMap?: () => Map<string, LoadedMonsterArt>
readonly setMonsterArtMap?: (map: Map<string, LoadedMonsterArt>) => void
readonly setActivePaletteAndPack?: (palette: Palette, base: string | undefined, act: number | undefined) => void
readonly getMultipliers?: () => { densityMultiplier: DensityMultiplier; eliteMultiplier: EliteMultiplier }
readonly setBoundsState?: (bounds: LevelTransitionBoundsState) => void
readonly onInvalidateLoads?: (() => void) | undefined
}
export interface LevelTransitionController {
readonly levelLoadGen: LoadGeneration
readonly isTravelling: () => boolean
readonly castTownPortal: () => boolean
readonly enterTownPortal: () => void
readonly registerWaypoints: (map: MapRuntime) => void
readonly swapLevel: (next: MapRuntime, arrive: { x: number; y: number }) => Promise<void>
readonly swapLevel: (next: MapRuntime, arrive?: { x: number; y: number }) => Promise<void>
readonly switchArea: (next: MapRuntime, arrive?: { x: number; y: number }) => Promise<void>
readonly travel: (toLevelId: number, fadeMs: number, override?: { x: number; y: number }, fromSide?: Side) => Promise<void>
readonly teleportViaWaypoint: (targetAct: number, slug: string, targetLevelId?: number) => Promise<void>
readonly checkLinks: () => void
}
export function createLevelTransitionController(
ctx: LevelTransitionControllerContext,
rawCtx: LevelTransitionControllerContext,
): LevelTransitionController {
const initialRuntime = ctx.getRuntime()
const ctx = rawCtx as Required<LevelTransitionControllerContext>
const levelLoadGen = new LoadGeneration()
const initialRuntime = rawCtx.getRuntime?.() ?? rawCtx.initialRuntime
const townVariantByAct = new Map<number, string>()
if (isTownLevel(initialRuntime.levelId) && initialRuntime.variant) {
if (initialRuntime && isTownLevel(initialRuntime.levelId) && initialRuntime.variant) {
townVariantByAct.set(initialRuntime.act, initialRuntime.variant)
}
let travelling = false
let lastContactWaypointId: number | null = null
const registerWaypoints = (map: MapRuntime): void => {
const registerWaypoints = (map: MapRuntime | undefined): void => {
if (!map || !Array.isArray(map.waypoints) || !ctx.waypointNetwork) return
for (const waypoint of map.waypoints) {
ctx.waypointNetwork.register({
waypointId: waypoint.waypointId,
@ -895,14 +987,21 @@ export function createLevelTransitionController(
return true
}
const swapLevel = async (next: MapRuntime, arrive: { x: number; y: number }): Promise<void> => {
const swapLevel = async (
next: MapRuntime,
arrive?: { x: number; y: number },
): Promise<void> => {
const { gen } = levelLoadGen.next()
ctx.onInvalidateLoads?.()
const prevRuntime = ctx.getRuntime()
ctx.clearMouseTarget()
for (const page of prevRuntime.pages) if (page !== null) ctx.renderer.deleteAtlas(page)
for (const page of prevRuntime.objectPages) if (page !== null) ctx.renderer.deleteAtlas(page)
if (prevRuntime !== next) {
disposeRuntimeAtlases(prevRuntime, ctx.renderer)
}
for (const art of ctx.getMonsterArtMap().values()) ctx.renderer.deleteAtlas(art.handle)
ctx.setMonsterArtMap(new Map())
next.disposed = false
ctx.setRuntime(next)
if (isTownLevel(next.levelId) && next.variant) townVariantByAct.set(next.act, next.variant)
ctx.setBoundsState({
@ -921,7 +1020,9 @@ export function createLevelTransitionController(
height: FEET_HEIGHT,
})
const landing = subTileToScene(arrive.x, arrive.y, next.grid)
const landing = arrive !== undefined
? subTileToScene(arrive.x, arrive.y, next.grid)
: { x: next.spawn.x, y: next.spawn.y }
ctx.engine.world.player.x = landing.x
ctx.engine.world.player.y = landing.y
@ -935,6 +1036,7 @@ export function createLevelTransitionController(
ctx.engine.world.monsters.length = 0
ctx.engine.ground.length = 0
ctx.engine.groundItems.clear()
const nextSafeZones: SafeZone[] = [{ x: landing.x, y: landing.y, radius: 180 }]
for (const wp of next.waypoints) {
const pos = subTileToScene(wp.x, wp.y, next.grid)
@ -998,12 +1100,31 @@ export function createLevelTransitionController(
packBase,
packAct,
)
if (!levelLoadGen.isCurrent(gen) || next.disposed || ctx.getRuntime() !== next) {
for (const art of loaded.map.values()) {
ctx.renderer.deleteAtlas(art.handle)
}
return
}
const keepHandles = new Set<AtlasHandle>()
for (const art of loaded.map.values()) {
keepHandles.add(art.handle)
}
for (const oldArt of ctx.getMonsterArtMap().values()) {
if (!keepHandles.has(oldArt.handle)) {
ctx.renderer.deleteAtlas(oldArt.handle)
}
}
ctx.setMonsterArtMap(loaded.map)
recordMonsterArtLoad(loaded)
}
}
if (next.source === 'pack') {
if (!levelLoadGen.isCurrent(gen) || next.disposed || ctx.getRuntime() !== next) {
return
}
if (next.source === 'pack' && typeof location !== 'undefined' && typeof history !== 'undefined') {
const nextParams = new URLSearchParams(location.search)
nextParams.set('act', String(next.act))
nextParams.set('level', next.variant)
@ -1021,21 +1142,25 @@ export function createLevelTransitionController(
state.cellsX = next.cellsX
state.cellsY = next.cellsY
state.pagesTotal = next.pages.length
state.pagesLoaded = 0
state.pagesLoaded = countLoadedPages(next.pages)
state.animatedTiles = next.animatedDrawables?.length ?? 0
state.animatedObjects = next.animatedObjects?.length ?? 0
state.animSpeed = next.animSpeed
state.frameDurationMs = next.frameDurationMs
const effectiveLvlId = next.levelId ?? next.level ?? 1
const parsedLvlId = typeof next.levelId === 'number' && next.levelId > 0 ? next.levelId : Number(next.level)
if (!Number.isFinite(parsedLvlId) || parsedLvlId <= 0) {
throw new Error(`Invalid levelId for runtime "${String(next.level)}"`)
}
const effectiveLvlId = parsedLvlId
ctx.engine.levelId = effectiveLvlId
ctx.engine.isTown = [1, 40, 75, 103, 109].includes(effectiveLvlId)
const names = getLevelNames(effectiveLvlId)
const hudManager = ctx.getHudManager()
hudManager?.worldPanels.showAreaBanner(names.nameZh, names.nameEn)
if (hudManager) {
hudManager.worldPanels.setCurrentLevel(next.levelId ?? null)
hudManager.worldPanels.setCurrentWaypoint(next.waypoints[0]?.waypointId ?? null, next.levelId ?? null)
hudManager.worldPanels.setCurrentLevel(effectiveLvlId)
hudManager.worldPanels.setCurrentWaypoint(next.waypoints[0]?.waypointId ?? null, effectiveLvlId)
hudManager.worldPanels.selectedActTab = next.act
}
ctx.clearMouseTarget()
@ -1053,11 +1178,13 @@ export function createLevelTransitionController(
const runtime = ctx.getRuntime()
if (travelling || runtime.source !== 'pack') return
travelling = true
const { gen, signal } = levelLoadGen.next()
ctx.clearMouseTarget()
const fromLevelId = runtime.levelId
const isEn = getTblLang() === 'ENG'
try {
await ctx.fade.out(fadeMs)
if (!levelLoadGen.isCurrent(gen)) return
const next = await loadRuntimeForLevel(
runtime.base,
toLevelId,
@ -1066,6 +1193,10 @@ export function createLevelTransitionController(
fromSide,
isTownLevel(toLevelId) ? townVariantByAct.get(runtime.act) : undefined,
)
if (!levelLoadGen.isCurrent(gen)) {
if (next !== null) disposeRuntimeAtlases(next, ctx.renderer)
return
}
if (next === null) {
ctx.status.textContent = isEn
? `Level ${String(toLevelId)} not found in pack.`
@ -1073,8 +1204,14 @@ export function createLevelTransitionController(
return
}
await next.loadPages(ctx.renderer, next.priorityPages, () => {
if (levelLoadGen.isCurrent(gen)) {
state.pagesLoaded = countLoadedPages(next.pages)
})
}
}, signal)
if (!levelLoadGen.isCurrent(gen)) {
disposeRuntimeAtlases(next, ctx.renderer)
return
}
let effectiveOverride = override
if (isTownLevel(next.levelId) && next.variant) {
townVariantByAct.set(next.act, next.variant)
@ -1114,7 +1251,9 @@ export function createLevelTransitionController(
}
}
await swapLevel(next, landing)
if (!next.disposed && ctx.getRuntime() === next) {
preloadRemaining(next, ctx.renderer)
}
} catch (err) {
ctx.status.textContent = isEn
? `Level transition failed: ${(err as Error).message}`
@ -1141,11 +1280,14 @@ export function createLevelTransitionController(
const runtime = ctx.getRuntime()
if (travelling || runtime.source !== 'pack') return
travelling = true
const { gen, signal } = levelLoadGen.next()
ctx.clearMouseTarget()
const isEn = getTblLang() === 'ENG'
try {
await ctx.fade.out(WARP_FADE_MS)
if (!levelLoadGen.isCurrent(gen)) return
const index = await getPackIndex(runtime.base)
if (!levelLoadGen.isCurrent(gen)) return
if (index === null) {
ctx.status.textContent = isEn ? 'Failed to read pack index.' : '无法读取资源包索引。'
return
@ -1189,22 +1331,34 @@ export function createLevelTransitionController(
{ width: ctx.canvas.width, height: ctx.canvas.height },
runtime.charBases,
)
if (!levelLoadGen.isCurrent(gen)) {
disposeRuntimeAtlases(next, ctx.renderer)
return
}
await next.loadPages(ctx.renderer, next.priorityPages, () => {
if (levelLoadGen.isCurrent(gen)) {
state.pagesLoaded = countLoadedPages(next.pages)
})
}
}, signal)
if (!levelLoadGen.isCurrent(gen)) {
disposeRuntimeAtlases(next, ctx.renderer)
return
}
const wp = next.waypoints[0]
if (wp === undefined) throw new Error(`${chosenEntry.label} has no waypoint to arrive at`)
const landing = { x: wp.arriveX, y: wp.arriveY }
await swapLevel(next, landing)
if (!next.disposed && ctx.getRuntime() === next) {
preloadRemaining(next, ctx.renderer)
ctx.resetMinimapCache()
const names = getLevelNames(next.levelId ?? next.level)
const names = getLevelNames(next.levelId)
ctx.status.textContent = isEn
? `Teleported to: ${names.nameEn} (Act ${String(next.act)})`
: `已传送到:${names.nameZh}(Act ${String(next.act)})`
}
} catch (err) {
ctx.status.textContent = isEn
? `Waypoint teleport failed: ${(err as Error).message}`
@ -1261,13 +1415,29 @@ export function createLevelTransitionController(
}
return {
levelLoadGen,
isTravelling: () => travelling,
castTownPortal,
enterTownPortal,
registerWaypoints,
swapLevel,
switchArea: swapLevel,
travel,
teleportViaWaypoint,
checkLinks,
}
}
export async function switchArea(
controllerOrCtx: LevelTransitionController | LevelTransitionControllerContext,
next: MapRuntime,
arrive: { x: number; y: number } = { x: 0, y: 0 },
): Promise<void> {
if ('swapLevel' in controllerOrCtx) {
await controllerOrCtx.swapLevel(next, arrive)
return
}
const controller = createLevelTransitionController(controllerOrCtx)
await controller.swapLevel(next, arrive)
}

View File

@ -0,0 +1,382 @@
import { describe, expect, it, vi } from 'vitest'
import { ActorAnimator } from '../src/game/actor-animator.ts'
import type { LoadedMonsterArt } from '../src/game/monster-art.ts'
import { LoadGeneration, type AtlasHandle, type SpriteRenderer } from '../src/render/renderer.ts'
import * as entityArtLoader from '../src/scene/frontend/entity-art-loader.ts'
import {
createLevelTransitionController,
disposeRuntimeAtlases,
type MapRuntime,
} from '../src/scene/frontend/pack-loader.ts'
import { createHeroLightingAndMonsterControls } from '../src/scene/frontend/toolbar-chat-ui.ts'
function createTrackingRenderer() {
let nextHandle = 1
const liveHandles = new Set<AtlasHandle>()
const deletedHandles: AtlasHandle[] = []
const renderer = {
addAtlas: vi.fn((_source: unknown, _w: number, _h: number): AtlasHandle => {
const h = nextHandle++ as unknown as AtlasHandle
liveHandles.add(h)
return h
}),
deleteAtlas: vi.fn((h: AtlasHandle) => {
if (liveHandles.delete(h)) {
deletedHandles.push(h)
}
}),
setPalette: vi.fn(),
get liveTextureCount() {
return liveHandles.size
},
} as unknown as SpriteRenderer & { liveTextureCount: number }
return { renderer, liveHandles, deletedHandles }
}
function makeDummyRuntime(levelId: number, pages: Array<AtlasHandle | null> = []): MapRuntime {
return {
source: 'pack',
base: '/packs',
act: 1,
level: String(levelId),
levelId,
variant: 'v0',
variants: ['v0'],
title: `Level ${levelId}`,
widthPx: 800,
heightPx: 600,
cellsX: 10,
cellsY: 10,
animSpeed: 16,
frameDurationMs: 62.5,
grid: {
gridWidth: 10,
gridHeight: 10,
tileWidth: 10,
tileHeight: 10,
blocked: new Uint8Array(100),
originX: 0,
originY: 0,
} as any,
floors: [],
shadows: [],
walls: [],
roofs: [],
objectDrawables: [],
npcs: [],
monsterTypes: ['fallen1'],
monsterPacks: [],
baseMonsterPacks: [],
entrances: [],
warps: [],
waypoints: [],
stashes: [],
objects: [],
spawn: { x: 100, y: 100 },
palette: { rgb: new Uint8Array(768), rgba: new Uint32Array(256) } as any,
pages: [...pages],
objectPages: [],
priorityPages: [],
totalPages: pages.length,
charBases: ['/mpq'],
loadPages: vi.fn(async () => {}),
disposed: false,
loadGen: new LoadGeneration(),
} as unknown as MapRuntime
}
describe('P0 #529: Async Scene LoadGeneration Cancellation & GPU Texture Disposal', () => {
it('LoadGeneration increments generation IDs and aborts prior AbortSignal instances', () => {
const loadGen = new LoadGeneration()
expect(loadGen.current).toBe(0)
const { gen: gen1, signal: sig1 } = loadGen.next()
expect(gen1).toBe(1)
expect(loadGen.isCurrent(gen1)).toBe(true)
expect(sig1.aborted).toBe(false)
const { gen: gen2 } = loadGen.next()
expect(gen2).toBe(2)
expect(loadGen.isCurrent(gen1)).toBe(false)
expect(loadGen.isCurrent(gen2)).toBe(true)
expect(sig1.aborted).toBe(true)
loadGen.invalidate()
expect(loadGen.isCurrent(gen2)).toBe(false)
expect(loadGen.signal.aborted).toBe(true)
})
it('discards out-of-order switchHero completions and deletes stale character GPU textures', async () => {
const { renderer, liveHandles, deletedHandles } = createTrackingRenderer()
let resolveFirst!: (val: entityArtLoader.LoadedCharacter) => void
let resolveSecond!: (val: entityArtLoader.LoadedCharacter) => void
const loadCharSpy = vi
.spyOn(entityArtLoader, 'loadCharacterArt')
.mockImplementationOnce(
() =>
new Promise(resolve => {
resolveFirst = resolve
}),
)
.mockImplementationOnce(
() =>
new Promise(resolve => {
resolveSecond = resolve
}),
)
let currentHeroToken = 'so'
const initialHandle = renderer.addAtlas(new Uint8Array(4), 64, 64)
let activeCharacter: entityArtLoader.LoadedCharacter | null = {
handle: initialHandle,
clips: {},
members: 1,
} as unknown as entityArtLoader.LoadedCharacter
const runtime = makeDummyRuntime(1)
const status = { textContent: '' } as HTMLElement
const controls = createHeroLightingAndMonsterControls({
renderer,
dropTables: {} as any,
playerAnimator: new ActorAnimator(),
status,
heroSelect: null,
lightingSelect: null,
densitySelect: null,
eliteSelect: null,
getRuntime: () => runtime,
getEngine: () => ({ world: { player: {} } }) as any,
getHudManager: () => null,
getCurrentHeroToken: () => currentHeroToken,
setCurrentHeroToken: t => {
currentHeroToken = t
},
getCharacter: () => activeCharacter,
setCharacter: c => {
activeCharacter = c
},
getActivePalette: () => runtime.palette,
getPackEntityBase: () => '/packs',
getPackEntityAct: () => 1,
getCurrentLightingPreset: () => 'auto',
setLightingState: () => {},
getMultipliers: () => ({ densityMultiplier: 1, eliteMultiplier: 1 }),
setMultipliers: () => {},
setMonsterArtMap: () => {},
})
// Fire two rapid hero switches: 'am' (slow) then 'ba' (fast)
const p1 = controls.switchHero('am')
const p2 = controls.switchHero('ba')
const handleBa = renderer.addAtlas(new Uint8Array(4), 64, 64)
resolveSecond({ handle: handleBa, clips: {}, members: 1 } as unknown as entityArtLoader.LoadedCharacter)
await p2
expect(currentHeroToken).toBe('ba')
expect(activeCharacter?.handle).toBe(handleBa)
expect(deletedHandles).toContain(initialHandle)
// Now the stale first request ('am') resolves AFTER 'ba'
const handleAm = renderer.addAtlas(new Uint8Array(4), 64, 64)
resolveFirst({ handle: handleAm, clips: {}, members: 1 } as unknown as entityArtLoader.LoadedCharacter)
await p1
// Must keep 'ba' and immediately delete stale 'am' texture handle
expect(currentHeroToken).toBe('ba')
expect(activeCharacter?.handle).toBe(handleBa)
expect(deletedHandles).toContain(handleAm)
expect(liveHandles.size).toBe(1)
expect(liveHandles.has(handleBa)).toBe(true)
loadCharSpy.mockRestore()
})
it('discards out-of-order reloadMonsters completions and deletes replaced monster GPU textures', async () => {
const { renderer, liveHandles, deletedHandles } = createTrackingRenderer()
let resolveFirst!: (val: entityArtLoader.MonsterArtLoad) => void
let resolveSecond!: (val: entityArtLoader.MonsterArtLoad) => void
const loadMonSpy = vi
.spyOn(entityArtLoader, 'loadMonsterArtMap')
.mockImplementationOnce(
() =>
new Promise(resolve => {
resolveFirst = resolve
}),
)
.mockImplementationOnce(
() =>
new Promise(resolve => {
resolveSecond = resolve
}),
)
const initialMonHandle = renderer.addAtlas(new Uint8Array(4), 64, 64)
let monsterArtMap = new Map<string, LoadedMonsterArt>([
['fallen1', { handle: initialMonHandle, clips: {} } as any],
])
const runtime = makeDummyRuntime(2)
runtime.baseMonsterPacks = [
{
leaderId: 'fallen1',
members: [{ id: 'fallen1', rank: 'normal' }],
} as any,
]
const status = { textContent: '' } as HTMLElement
const controls = createHeroLightingAndMonsterControls({
renderer,
dropTables: { monsterKinds: new Map() } as any,
playerAnimator: new ActorAnimator(),
status,
heroSelect: null,
lightingSelect: null,
densitySelect: null,
eliteSelect: null,
getRuntime: () => runtime,
getEngine: () =>
({
terrain: {
overlap: () => 0,
isWalkable: () => true,
},
world: {
player: { x: 100, y: 100 },
monsters: [],
corpses: [],
events: [],
},
projectiles: [],
explosions: [],
}) as any,
getHudManager: () => null,
getCurrentHeroToken: () => 'so',
setCurrentHeroToken: () => {},
getCharacter: () => null,
setCharacter: () => {},
getActivePalette: () => runtime.palette,
getPackEntityBase: () => '/packs',
getPackEntityAct: () => 1,
getCurrentLightingPreset: () => 'auto',
setLightingState: () => {},
getMultipliers: () => ({ densityMultiplier: 1, eliteMultiplier: 1 }),
setMultipliers: () => {},
getMonsterArtMap: () => monsterArtMap,
setMonsterArtMap: m => {
monsterArtMap = m
},
})
const p1 = controls.reloadMonsters({ density: 2 })
const p2 = controls.reloadMonsters({ density: 5 })
const handleSecond = renderer.addAtlas(new Uint8Array(4), 64, 64)
resolveSecond({
map: new Map([['fallen1', { handle: handleSecond, clips: {} } as any]]),
missing: [],
} as unknown as entityArtLoader.MonsterArtLoad)
await p2
expect(monsterArtMap.get('fallen1')?.handle).toBe(handleSecond)
expect(deletedHandles).toContain(initialMonHandle)
// Stale first load resolves later
const handleFirst = renderer.addAtlas(new Uint8Array(4), 64, 64)
resolveFirst({
map: new Map([['fallen1', { handle: handleFirst, clips: {} } as any]]),
missing: [],
} as unknown as entityArtLoader.MonsterArtLoad)
await p1
expect(monsterArtMap.get('fallen1')?.handle).toBe(handleSecond)
expect(deletedHandles).toContain(handleFirst)
expect(liveHandles.size).toBe(1)
loadMonSpy.mockRestore()
})
it('disposes old runtime atlases and clears groundItems on swapLevel', async () => {
const { renderer, liveHandles } = createTrackingRenderer()
const page1 = renderer.addAtlas(new Uint8Array(4), 256, 256)
const page2 = renderer.addAtlas(new Uint8Array(4), 256, 256)
const oldRuntime = makeDummyRuntime(1, [page1, page2])
const nextPage = renderer.addAtlas(new Uint8Array(4), 256, 256)
const nextRuntime = makeDummyRuntime(2, [nextPage])
const loadMonSpy = vi.spyOn(entityArtLoader, 'loadMonsterArtMap').mockResolvedValue({
map: new Map(),
missing: [],
} as unknown as entityArtLoader.MonsterArtLoad)
let activeRuntime = oldRuntime
let monsterArtMap = new Map<string, LoadedMonsterArt>()
const groundItemsClear = vi.fn()
const controller = createLevelTransitionController({
canvas: { width: 800, height: 600 } as HTMLCanvasElement,
status: { textContent: '' } as HTMLElement,
renderer,
engine: {
world: {
player: { x: 100, y: 100, targetX: 100, targetY: 100, vx: 0, vy: 0 },
monsters: [],
corpses: [],
events: [],
},
ground: [{ x: 10, y: 10, item: {} as any }],
groundItems: { clear: groundItemsClear },
npcEntities: [],
projectiles: [],
explosions: [],
} as any,
camera: { setWorldSize: vi.fn(), centerOn: vi.fn() } as any,
input: { clear: vi.fn() } as any,
fade: {} as any,
minimap: { reveal: vi.fn(), revealedRoomsCount: 0 } as any,
minimapLevel: () => ({}) as any,
resetMinimapCache: vi.fn(),
waypointNetwork: { tryAutoUnlockNearest: vi.fn() } as any,
portalSlot: {} as any,
playerAnimator: new ActorAnimator(),
playerSubTile: () => ({ x: 10, y: 10 }),
populatePackSelectors: vi.fn(async () => {}),
getRuntime: () => activeRuntime,
setRuntime: next => {
activeRuntime = next
},
getHudManager: () => null,
clearMouseTarget: vi.fn(),
getCharacter: () => null,
getMonsterArtMap: () => monsterArtMap,
setMonsterArtMap: m => {
monsterArtMap = m
},
setActivePaletteAndPack: vi.fn(),
getMultipliers: () => ({ densityMultiplier: 1, eliteMultiplier: 1 }),
setBoundsState: vi.fn(),
})
await controller.swapLevel(nextRuntime)
expect(oldRuntime.disposed).toBe(true)
expect(oldRuntime.pages).toEqual([null, null])
expect(groundItemsClear).toHaveBeenCalledTimes(1)
// Only nextPage should remain alive
expect(liveHandles.size).toBe(1)
expect(liveHandles.has(nextPage)).toBe(true)
disposeRuntimeAtlases(nextRuntime, renderer)
expect(liveHandles.size).toBe(0)
loadMonSpy.mockRestore()
})
})