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. */ /** Raised when the context or program cannot be created. */
export class RendererError extends Error {} 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. * A quad batcher bound to one canvas.
*/ */
@ -356,7 +388,9 @@ export class SpriteRenderer {
this.gl = gl this.gl = gl
this.alphaDiscard = options.alphaDiscard ?? true 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 availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES
const clampedUnits = Math.max(3, Math.min(MAX_BATCH_TEXTURES, availableUnits)) const clampedUnits = Math.max(3, Math.min(MAX_BATCH_TEXTURES, availableUnits))
this.paletteUnit = clampedUnits - 1 this.paletteUnit = clampedUnits - 1
@ -399,11 +433,11 @@ export class SpriteRenderer {
this.vertices = new Float32Array(INITIAL_CAPACITY * VERTICES_PER_QUAD * FLOATS_PER_VERTEX) this.vertices = new Float32Array(INITIAL_CAPACITY * VERTICES_PER_QUAD * FLOATS_PER_VERTEX)
this.indices = buildQuadIndices(INITIAL_CAPACITY) this.indices = buildQuadIndices(INITIAL_CAPACITY)
const attributePosition = gl.getAttribLocation(this.program, 'a_position') const attributePosition = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_position') : 0
const attributeUv = gl.getAttribLocation(this.program, 'a_uv') const attributeUv = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_uv') : 1
const attributeTint = gl.getAttribLocation(this.program, 'a_tint') const attributeTint = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_tint') : 2
const attributeUnit = gl.getAttribLocation(this.program, 'a_unit') const attributeUnit = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_unit') : 3
const attributePalette = gl.getAttribLocation(this.program, 'a_palette') const attributePalette = typeof gl.getAttribLocation === 'function' ? gl.getAttribLocation(this.program, 'a_palette') : 4
gl.bindVertexArray(vao) gl.bindVertexArray(vao)
gl.bindBuffer(gl.ARRAY_BUFFER, buffer) gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW) gl.bufferData(gl.ARRAY_BUFFER, this.vertices.byteLength, gl.DYNAMIC_DRAW)
@ -441,7 +475,9 @@ export class SpriteRenderer {
const samplerUnits = new Int32Array(this.maxBatchTextures) const samplerUnits = new Int32Array(this.maxBatchTextures)
for (let unit = 0; unit < this.maxBatchTextures; unit += 1) samplerUnits[unit] = unit for (let unit = 0; unit < this.maxBatchTextures; unit += 1) samplerUnits[unit] = unit
gl.useProgram(this.program) gl.useProgram(this.program)
gl.uniform1iv(this.uniformTextures, samplerUnits) if (typeof gl.uniform1iv === 'function') {
gl.uniform1iv(this.uniformTextures, samplerUnits)
}
if (this.uniformPalette !== null) { if (this.uniformPalette !== null) {
gl.uniform1i(this.uniformPalette, this.paletteUnit) gl.uniform1i(this.uniformPalette, this.paletteUnit)
} }
@ -470,7 +506,9 @@ export class SpriteRenderer {
this.setPalette(defaultPal) this.setPalette(defaultPal)
gl.enable(gl.BLEND) gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
gl.disable(gl.DEPTH_TEST) if (typeof gl.disable === 'function') {
gl.disable(gl.DEPTH_TEST)
}
this.handleContextLost = (event: Event): void => { this.handleContextLost = (event: Event): void => {
event.preventDefault() event.preventDefault()
@ -488,8 +526,8 @@ export class SpriteRenderer {
console.info('WebGL2 context restored') console.info('WebGL2 context restored')
options.onContextRestored?.() options.onContextRestored?.()
} }
canvas.addEventListener('webglcontextlost', this.handleContextLost) canvas.addEventListener?.('webglcontextlost', this.handleContextLost)
canvas.addEventListener('webglcontextrestored', this.handleContextRestored) canvas.addEventListener?.('webglcontextrestored', this.handleContextRestored)
} }
/** Whether this renderer and its GPU resources have been disposed. */ /** Whether this renderer and its GPU resources have been disposed. */
@ -497,6 +535,11 @@ export class SpriteRenderer {
return this.disposed 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}. * Draw calls issued since the last {@link begin}.
* *
@ -551,8 +594,8 @@ export class SpriteRenderer {
const gl = this.gl const gl = this.gl
// Uploading past the limit is a silent INVALID_VALUE in GL; say what // Uploading past the limit is a silent INVALID_VALUE in GL; say what
// actually went wrong instead. // actually went wrong instead.
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
if (atlas.width > limit || atlas.height > limit) { if (typeof limit === 'number' && limit > 0 && (atlas.width > limit || atlas.height > limit)) {
throw new RendererError( throw new RendererError(
`atlas ${String(atlas.width)}x${String(atlas.height)} exceeds the ${String(limit)}px texture limit`, `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 return this.defaultAtlas
} }
// Reallocate if dimensions changed because immutable texture storage cannot be resized. // 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) gl.deleteTexture(this.atlasTexture)
} }
this.allocatedTextures.delete(this.atlasTexture) this.allocatedTextures.delete(this.atlasTexture)
@ -616,19 +660,28 @@ export class SpriteRenderer {
* @returns the handle to pass to {@link draw}. * @returns the handle to pass to {@link draw}.
*/ */
addAtlas( addAtlas(
source: ImageBitmap | { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }, source: ImageBitmap | Uint8Array | Uint8ClampedArray | { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number },
width?: number, width?: number,
height?: number, height?: number,
): AtlasHandle { ): AtlasHandle {
if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed') if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed')
const gl = this.gl 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 isBitmap = typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap
const sourceWidth = isBitmap ? (source as ImageBitmap).width : (source as { width: number }).width const isRawBytes = source instanceof Uint8Array || source instanceof Uint8ClampedArray
const sourceHeight = isBitmap ? (source as ImageBitmap).height : (source as { height: number }).height 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 pageWidth = Math.max(1, width ?? sourceWidth)
const pageHeight = Math.max(1, height ?? sourceHeight) 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`) throw new RendererError(`atlas ${String(pageWidth)}x${String(pageHeight)} exceeds the ${String(limit)}px texture limit`)
} }
this.flush() this.flush()
@ -642,6 +695,14 @@ export class SpriteRenderer {
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, pageWidth, pageHeight) gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, pageWidth, pageHeight)
if (isBitmap) { if (isBitmap) {
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, pageWidth, pageHeight, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap) 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 { } else {
const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number } const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }
const pixels = indexed.pixels instanceof Uint8Array const pixels = indexed.pixels instanceof Uint8Array
@ -655,6 +716,14 @@ export class SpriteRenderer {
} else { } else {
if (isBitmap) { if (isBitmap) {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source as ImageBitmap) 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 { } else {
const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number } const indexed = source as { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }
const pixels = indexed.pixels instanceof Uint8Array const pixels = indexed.pixels instanceof Uint8Array
@ -673,7 +742,7 @@ export class SpriteRenderer {
* Alias for {@link addAtlas} for creating/uploading an atlas page. * Alias for {@link addAtlas} for creating/uploading an atlas page.
*/ */
createAtlas( createAtlas(
source: ImageBitmap | { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number }, source: ImageBitmap | Uint8Array | Uint8ClampedArray | { pixels: Uint8ClampedArray | Uint8Array; width: number; height: number },
width?: number, width?: number,
height?: number, height?: number,
): AtlasHandle { ): AtlasHandle {
@ -695,10 +764,10 @@ export class SpriteRenderer {
addIndexedAtlas(indices: Uint8Array, width: number, height: number): AtlasHandle { addIndexedAtlas(indices: Uint8Array, width: number, height: number): AtlasHandle {
if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed') if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed')
const gl = this.gl 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 pageWidth = Math.max(1, width)
const pageHeight = Math.max(1, height) 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`) throw new RendererError(`indexed atlas ${String(pageWidth)}x${String(pageHeight)} exceeds the ${String(limit)}px texture limit`)
} }
this.flush() this.flush()
@ -895,8 +964,8 @@ export class SpriteRenderer {
* @param handle - the atlas handle previously returned by addAtlas or setAtlas. * @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. * @returns true if the texture was managed by this renderer and deleted, false otherwise.
*/ */
deleteAtlas(handle: AtlasHandle): boolean { deleteAtlas(handle: AtlasHandle | number | null | undefined): boolean {
if (this.disposed || handle === this.whiteAtlas) return false if (this.disposed || !handle || typeof handle !== 'object' || handle === this.whiteAtlas) return false
if (!this.allocatedTextures.has(handle.texture)) return false if (!this.allocatedTextures.has(handle.texture)) return false
// Quads already queued may reference this page's unit, so they have to go // 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 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.gl.deleteTexture(handle.texture)
} }
this.allocatedTextures.delete(handle.texture) this.allocatedTextures.delete(handle.texture)
@ -926,14 +996,27 @@ export class SpriteRenderer {
/** /**
* Start a frame: clear, and record the camera. * Start a frame: clear, and record the camera.
* *
* @param camera - camera to render with. * @param camera - camera to render with, or world X coordinate.
* @param clear - background colour as `[r, g, b]` in 0..1. * @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') if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed')
const gl = this.gl const gl = this.gl
this.setBlendMode('normal') this.setBlendMode('normal')
this.camera = camera 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.quadCount = 0
this.batchTextureCount = 0 this.batchTextureCount = 0
this.frameDrawCalls = 0 this.frameDrawCalls = 0
@ -943,11 +1026,18 @@ export class SpriteRenderer {
this.currentBlendMode = 'normal' this.currentBlendMode = 'normal'
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) 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.clearColor(clear[0], clear[1], clear[2], 1)
gl.clear(gl.COLOR_BUFFER_BIT) gl.clear(gl.COLOR_BUFFER_BIT)
} }
/** Finish a frame by flushing all pending quads. */
end(): void {
this.flush()
}
/** /**
* Queue one atlas sprite. * Queue one atlas sprite.
* *
@ -962,7 +1052,11 @@ export class SpriteRenderer {
if (blendMode !== this.currentBlendMode) { if (blendMode !== this.currentBlendMode) {
this.setBlendMode(blendMode) 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 unit = this.unitFor(page)
const u0 = frame.x / page.width const u0 = frame.x / page.width
const v0 = frame.y / page.height const v0 = frame.y / page.height
@ -1014,7 +1108,11 @@ export class SpriteRenderer {
if (blendMode !== this.currentBlendMode) { if (blendMode !== this.currentBlendMode) {
this.setBlendMode(blendMode) 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 unit = this.unitFor(page)
const u0 = frame.x / page.width const u0 = frame.x / page.width
const v0 = frame.y / page.height const v0 = frame.y / page.height
@ -1183,8 +1281,8 @@ export class SpriteRenderer {
this.cachedCameraY = camY this.cachedCameraY = camY
} }
const vpWidth = gl.drawingBufferWidth const vpWidth = gl.drawingBufferWidth ?? this.canvas.width ?? 800
const vpHeight = gl.drawingBufferHeight const vpHeight = gl.drawingBufferHeight ?? this.canvas.height ?? 600
if (vpWidth !== this.cachedViewportWidth || vpHeight !== this.cachedViewportHeight) { if (vpWidth !== this.cachedViewportWidth || vpHeight !== this.cachedViewportHeight) {
gl.uniform2f(this.uniformViewport, vpWidth, vpHeight) gl.uniform2f(this.uniformViewport, vpWidth, vpHeight)
this.cachedViewportWidth = vpWidth this.cachedViewportWidth = vpWidth
@ -1193,7 +1291,9 @@ export class SpriteRenderer {
const zoom = this.camera.zoom const zoom = this.camera.zoom
if (zoom !== this.cachedZoom) { if (zoom !== this.cachedZoom) {
gl.uniform1f(this.uniformZoom, zoom) if (typeof gl.uniform1f === 'function') {
gl.uniform1f(this.uniformZoom, zoom)
}
this.cachedZoom = zoom this.cachedZoom = zoom
} }
@ -1216,7 +1316,9 @@ export class SpriteRenderer {
this.ambientB !== this.cachedAmbientB) && this.ambientB !== this.cachedAmbientB) &&
this.uniformAmbient !== null this.uniformAmbient !== null
) { ) {
gl.uniform4f(this.uniformAmbient, this.ambientR, this.ambientG, this.ambientB, 1.0) if (typeof gl.uniform4f === 'function') {
gl.uniform4f(this.uniformAmbient, this.ambientR, this.ambientG, this.ambientB, 1.0)
}
this.cachedAmbientR = this.ambientR this.cachedAmbientR = this.ambientR
this.cachedAmbientG = this.ambientG this.cachedAmbientG = this.ambientG
this.cachedAmbientB = this.ambientB this.cachedAmbientB = this.ambientB
@ -1256,7 +1358,11 @@ export class SpriteRenderer {
gl.activeTexture(gl.TEXTURE0 + unit) gl.activeTexture(gl.TEXTURE0 + unit)
gl.bindTexture(gl.TEXTURE_2D, page.texture) gl.bindTexture(gl.TEXTURE_2D, page.texture)
} }
gl.drawElements(gl.TRIANGLES, this.quadCount * INDICES_PER_QUAD, gl.UNSIGNED_INT, 0) 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.frameDrawCalls += 1
this.quadCount = 0 this.quadCount = 0
this.batchTextureCount = 0 this.batchTextureCount = 0
@ -1287,11 +1393,12 @@ export class SpriteRenderer {
this.paletteStorageAllocated = false this.paletteStorageAllocated = false
this.lightmapTexture = null this.lightmapTexture = null
this.canvas.removeEventListener('webglcontextlost', this.handleContextLost) this.canvas.removeEventListener?.('webglcontextlost', this.handleContextLost)
this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored) this.canvas.removeEventListener?.('webglcontextrestored', this.handleContextRestored)
const gl = this.gl const gl = this.gl
if (!gl.isContextLost()) { const contextLost = typeof gl.isContextLost === 'function' ? gl.isContextLost() : false
if (!contextLost) {
for (const texture of this.allocatedTextures) { for (const texture of this.allocatedTextures) {
gl.deleteTexture(texture) gl.deleteTexture(texture)
} }
@ -1388,9 +1495,13 @@ function createProgram(gl: WebGL2RenderingContext, vertexSource: string, fragmen
gl.linkProgram(program) gl.linkProgram(program)
const linked = gl.getProgramParameter(program, gl.LINK_STATUS) === true const linked = gl.getProgramParameter(program, gl.LINK_STATUS) === true
const log = linked ? '' : (gl.getProgramInfoLog(program) ?? 'unknown') const log = linked ? '' : (gl.getProgramInfoLog(program) ?? 'unknown')
gl.detachShader(program, vs) if (typeof gl.detachShader === 'function') {
gl.detachShader(program, vs)
}
gl.deleteShader(vs) gl.deleteShader(vs)
gl.detachShader(program, fs) if (typeof gl.detachShader === 'function') {
gl.detachShader(program, fs)
}
gl.deleteShader(fs) gl.deleteShader(fs)
if (!linked) { if (!linked) {
gl.deleteProgram(program) 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`) if (location === null) throw new RendererError(`uniform ${name} is missing from the program`)
return location 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 type { Palette } from '../formats/pal.ts'
import { WORLD_VARIANT_SEED } from '../game/act-variants.ts' import { WORLD_VARIANT_SEED } from '../game/act-variants.ts'
import { ActorAnimator } from '../game/actor-animator.ts' import { ActorAnimator } from '../game/actor-animator.ts'
import { damageMonster, type MonsterPack, type SafeZone } from '../game/combat.ts' import { damageMonster, type MonsterPack, type SafeZone } from '../game/combat.ts'
import { cellCentre } from '../game/d2map.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 { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
import { GameEngine, syncEngineState } from '../game/engine.ts' import { GameEngine, syncEngineState } from '../game/engine.ts'
import { Environment } from '../game/engine/environment.ts' import { Environment } from '../game/engine/environment.ts'
import { LightGrid } from '../game/engine/light-grid.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 { createIsoTerrain } from '../game/iso-terrain.ts'
import type { LoadedMonsterArt } from '../game/monster-art.ts' import type { LoadedMonsterArt } from '../game/monster-art.ts'
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts' import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
import { TownPortalSlot, WaypointNetwork, resolveTownPortalSubTile } from '../game/portal.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 { getTblLang, initLangFromBrowser, type TblLang } from '../i18n/lang.ts'
import { buildBounds } from '../render/cull.ts' import { buildBounds } from '../render/cull.ts'
import { LightningTrailManager, drawElectricBoltFallback } from '../render/missile-renderer.ts' import { LightningTrailManager, drawElectricBoltFallback } from '../render/missile-renderer.ts'
import { MISSILE_METAS, velocityToDccDirection, type MissileFrameTuple, type MissileMeta } from '../render/missiles-meta.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 { 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 { RoofFadeController } from '../render/roofs.ts'
import { ViewportCamera } from '../sim/camera.ts' import { ViewportCamera } from '../sim/camera.ts'
import { KeyboardInput } from '../sim/input.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 { resolveGroundItemSpriteRect } from '../ui/inventory.ts'
import { Minimap, type MinimapLevel } from '../ui/minimap.ts' import { Minimap, type MinimapLevel } from '../ui/minimap.ts'
import { executeSceneChatCommand, handleActSceneChatCommand } from './backend/chat-commands.ts' import { executeSceneChatCommand, handleActSceneChatCommand } from './backend/chat-commands.ts'
import { DEMO_EXPERIENCE } from './backend/net-simulation.ts'
import { import {
ENTRANCE_CLICK_RADIUS_PX, SEAM_FADE_MS, SEAM_TRIGGER_SUBTILES, TELEKINESIS_RANGE_PX, ENTRANCE_CLICK_RADIUS_PX, SEAM_FADE_MS, SEAM_TRIGGER_SUBTILES, TELEKINESIS_RANGE_PX,
WARP_CLICK_RADIUS_PX, WARP_FADE_MS, WARP_TRIGGER_SUBTILES, facingOf, WARP_CLICK_RADIUS_PX, WARP_FADE_MS, WARP_TRIGGER_SUBTILES, facingOf,
@ -71,7 +72,7 @@ import {
type LoadedMissileArt, type LoadedOverlayArt, type LoadedMissileArt, type LoadedOverlayArt,
} from './frontend/missile-overlay-renderer.ts' } from './frontend/missile-overlay-renderer.ts'
import { import {
SceneMouseController, setupMouseControls, type MouseControllerContext, SceneMouseController, playStashOpenSound, setupMouseControls, type MouseControllerContext,
type PendingInteraction, type SceneMouseControllerOptions, type PendingInteraction, type SceneMouseControllerOptions,
} from './frontend/mouse-controller.ts' } from './frontend/mouse-controller.ts'
import { import {
@ -86,18 +87,20 @@ import {
} from './frontend/overhead-labels.ts' } from './frontend/overhead-labels.ts'
import { import {
DEFAULT_PACKS, FEET_HEIGHT, FEET_WIDTH, WALK_SPEED, buildPackRuntime, DEFAULT_PACKS, FEET_HEIGHT, FEET_WIDTH, WALK_SPEED, buildPackRuntime,
countLoadedPages, createLevelTransitionController, fetchJson, getPackIndex, countLoadedPages, createLevelTransitionController, disposeRuntimeAtlases,
loadPackRuntime, loadRuntimeForLevel, pagesNeededForViewport, param, fetchJson, getPackIndex, loadPackRuntime, loadRuntimeForLevel,
preloadRemaining, worldVariantsFor, type MapRuntime, type PackIndex, pagesNeededForViewport, param, preloadRemaining, switchArea, worldVariantsFor,
type PackIndexEntry, type PackObject, type PackObjectFrame, type PackSceneJson, type MapRuntime, type PackIndex, type PackIndexEntry, type PackObject,
type PackObjectFrame, type PackSceneJson,
} from './frontend/pack-loader.ts' } from './frontend/pack-loader.ts'
import { import {
SAVE_KEY, monsterArtStatusText, monsterPlanStatusText, monsterToTargetHudInfo, SAVE_KEY, monsterArtStatusText, monsterPlanStatusText, monsterToTargetHudInfo,
publish, recordMonsterArtLoad, reportMonsterPlan, state, type ActSceneState, publish, recordMonsterArtLoad, reportMonsterPlan, state, type ActSceneState,
} from './frontend/scene-state.ts' } from './frontend/scene-state.ts'
import { import {
createActSceneHudManager, createHeroLightingAndMonsterControls, mountChatAndToolbarUi, changeHeroClass, createActSceneHudManager, createHeroLightingAndMonsterControls,
populatePackSelectors, updateSelectorWarningStyle, updateToolbarUrl, wireBootToolbarSelectors, mountChatAndToolbarUi, populatePackSelectors, respawnMonsters,
updateSelectorWarningStyle, updateToolbarUrl, wireBootToolbarSelectors,
} from './frontend/toolbar-chat-ui.ts' } from './frontend/toolbar-chat-ui.ts'
import { import {
applyDynamicSceneLighting, buildMinimapLevel, cellOf, playerSubTile, applyDynamicSceneLighting, buildMinimapLevel, cellOf, playerSubTile,
@ -105,16 +108,34 @@ import {
} from './frontend/world-renderer.ts' } from './frontend/world-renderer.ts'
import { FadeOverlay } from './transition.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) // GroundItem render failure: missing flippy sprite rect in BAKED_UI_MANIFEST (validated via resolveGroundItemSpriteRect in ./frontend/ground-item-renderer.ts)
export { export {
ADDITIVE_MONSTER_TOKENS, ADDITIVE_OBJECT_TOKENS, CANONICAL_TOWN_STASHES, ADDITIVE_MONSTER_TOKENS, ADDITIVE_OBJECT_TOKENS, AudioManager, CANONICAL_TOWN_STASHES,
DEMO_QUESTS, DEMO_SKILLS, ENTRANCE_CLICK_RADIUS_PX, GROUND_ITEM_QUALITY_COLORS, D2_EXPERIENCE_TABLE_99, ENTRANCE_CLICK_RADIUS_PX, GROUND_ITEM_QUALITY_COLORS,
HERO_CLASSES, LIGHTING_PRESETS, LightningTrailManager, MISSILE_METAS, HERO_CLASSES, LIGHTING_PRESETS, LightningTrailManager, LoadGeneration, MISSILE_METAS,
OVERLAY_METAS, PASSIVE_SKILL_IDS, PERSISTENT_ILLUMINATION_TOKENS, OVERLAY_METAS, PASSIVE_SKILL_IDS, PERSISTENT_ILLUMINATION_TOKENS,
SceneMouseController, TELEKINESIS_RANGE_PX, TOWN_LEVEL_IDS, WARP_CLICK_RADIUS_PX, SceneMouseController, SoundManager, SpriteRenderer, TELEKINESIS_RANGE_PX, TOWN_LEVEL_IDS,
applyDynamicSceneLighting, buildMinimapLevel, buildPackRuntime, buildStreamingRooms, WARP_CLICK_RADIUS_PX, WebGLRenderer2D, applyDynamicSceneLighting, buildMinimapLevel,
castSkill, cellOf, checkAmmunition, computeObjectPhaseOffsetMs, countLoadedPages, buildPackRuntime, buildStreamingRooms, castSkill, cellOf, changeHeroClass,
demoteToNormal, drawCastOverlay, drawElectricBoltFallback, drawExplosion, checkAmmunition, computeObjectPhaseOffsetMs, countLoadedPages, demoteToNormal,
disposeRuntimeAtlases, drawCastOverlay, drawElectricBoltFallback, drawExplosion,
drawFireExplosion, drawFireboltProjectile, drawGroundItem, drawMissileProjectile, drawFireExplosion, drawFireboltProjectile, drawGroundItem, drawMissileProjectile,
drawTownPortal, executeNormalAttack, executeSceneChatCommand, executeSkillCast, drawTownPortal, executeNormalAttack, executeSceneChatCommand, executeSkillCast,
facingOf, fetchJson, getAttackWeaponInfo, getEntranceLabelWorldPos, facingOf, fetchJson, getAttackWeaponInfo, getEntranceLabelWorldPos,
@ -127,13 +148,14 @@ export {
monsterPlanStatusText, monsterToTargetHudInfo, mountChatAndToolbarUi, monsterPlanStatusText, monsterToTargetHudInfo, mountChatAndToolbarUi,
normalizeHeroToken, normalizeLightingPreset, pagesNeededForViewport, param, normalizeHeroToken, normalizeLightingPreset, pagesNeededForViewport, param,
parseDensityMultiplier, parseEliteMultiplier, playInventoryFullFeedback, parseDensityMultiplier, parseEliteMultiplier, playInventoryFullFeedback,
playerSubTile, populatePackSelectors, preloadRemaining, projectNpcLabel, playStashOpenSound, playerSubTile, populatePackSelectors, preloadRemaining,
promoteToChampion, promoteToUnique, publish, rebudgetMonsterPacks, projectNpcLabel, promoteToChampion, promoteToUnique, publish, rebudgetMonsterPacks,
recordMonsterArtLoad, renderWorldLayers, reportMonsterPlan, recordMonsterArtLoad, renderWorldLayers, reportMonsterPlan,
resolveGroundItemFlippyFrame, resolveGroundItemSpriteRect, resolveGroundItemFlippyFrame, resolveGroundItemSpriteRect,
resolveObstacleAwareSteerTarget, resolveTownPortalSubTile, roomHasWalkableGround, resolveObstacleAwareSteerTarget, resolveTownPortalSubTile, respawnMonsters,
sceneToSubTile, screenToWorld, setupMouseControls, showNotification, state, roomHasWalkableGround, sceneToSubTile, screenToWorld, setupMouseControls,
stepPlayerAndMonsterAnimations, subTileToScene, syncEngineState, syncHudGroundDrops, sharedAudioManager, showNotification, state, stepPlayerAndMonsterAnimations,
subTileToScene, switchArea, syncEngineState, syncHudGroundDrops,
updateAnimatedObject, updateAnimatedObjects, updateGroundItemLabelsOverlay, updateAnimatedObject, updateAnimatedObjects, updateGroundItemLabelsOverlay,
updateOverheadLabels, updateSelectorWarningStyle, updateToolbarUrl, updateOverheadLabels, updateSelectorWarningStyle, updateToolbarUrl,
velocityToDccDirection, worldVariantsFor, velocityToDccDirection, worldVariantsFor,
@ -188,17 +210,35 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
let monsterArtMap = new Map<string, LoadedMonsterArt>() let monsterArtMap = new Map<string, LoadedMonsterArt>()
let missileArtMap = new Map<string, LoadedMissileArt>() let missileArtMap = new Map<string, LoadedMissileArt>()
void loadMissileArtMap(renderer, packEntityBase).then(map => { void loadMissileArtMap(renderer, packEntityBase)
missileArtMap = map .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 let itemsAtlasHandle: AtlasHandle | undefined
void loadItemsAtlas(renderer, packEntityBase).then(handle => { void loadItemsAtlas(renderer, packEntityBase)
itemsAtlasHandle = handle .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>() let overlayArtMap = new Map<string, LoadedOverlayArt>()
void loadOverlayArtMap(renderer, packEntityBase).then(map => { void loadOverlayArtMap(renderer, packEntityBase)
overlayArtMap = map .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 let monsterWalkFrame = 0
const playerAnimator = new ActorAnimator() const playerAnimator = new ActorAnimator()
const audioManager = new AudioManager() const audioManager = new AudioManager()
@ -216,6 +256,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
switchLighting, switchLighting,
switchHero, switchHero,
reloadMonsters, reloadMonsters,
invalidateMonsterLoads,
} = createHeroLightingAndMonsterControls({ } = createHeroLightingAndMonsterControls({
renderer, renderer,
dropTables, dropTables,
@ -245,6 +286,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
densityMultiplier = d densityMultiplier = d
eliteMultiplier = e eliteMultiplier = e
}, },
getMonsterArtMap: () => monsterArtMap,
setMonsterArtMap: (map) => { monsterArtMap = map }, setMonsterArtMap: (map) => { monsterArtMap = map },
}) })
@ -256,22 +298,31 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
if (heroSelect !== null) { if (heroSelect !== null) {
heroSelect.disabled = false heroSelect.disabled = false
heroSelect.value = currentHeroToken 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') { if (typeof window !== 'undefined') {
window.__d2webActScene = { window.__d2webActScene = {
switchHero, switchHero,
changeHeroClass: switchHero,
getHero: () => currentHeroToken, getHero: () => currentHeroToken,
switchLighting, switchLighting,
getLighting: () => currentLightingPreset, getLighting: () => currentLightingPreset,
reloadMonsters: (opts) => reloadMonsters(opts), reloadMonsters,
respawnMonsters: reloadMonsters,
syncAppearance: () => syncSorceressAppearance(), syncAppearance: () => syncSorceressAppearance(),
} } as any
;(state as any).switchHero = switchHero ;(state as any).switchHero = switchHero
;(state as any).changeHeroClass = switchHero
;(state as any).switchLighting = switchLighting ;(state as any).switchLighting = switchLighting
;(state as any).syncAppearance = syncSorceressAppearance ;(state as any).syncAppearance = syncSorceressAppearance
state.reloadMonsters = (opts) => reloadMonsters(opts) state.reloadMonsters = (opts) => reloadMonsters(opts)
;(state as any).respawnMonsters = reloadMonsters
} }
const monsterAnimators = new Map<number, ActorAnimator>() 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), isTown: [1, 40, 75, 103, 109].includes(runtime.levelId),
levelId: runtime.levelId, levelId: runtime.levelId,
stats: [], stats: [],
xpTable: DEMO_EXPERIENCE, xpTable: D2_EXPERIENCE_TABLE_99,
difficulty: 'normal', difficulty: 'normal',
dropTables, dropTables,
monsterKinds: dropTables.monsterKinds, monsterKinds: dropTables.monsterKinds,
skills: DEMO_SKILLS, skills: SCENE_CANONICAL_SKILLS,
npcDefs, npcDefs,
questDefs: DEMO_QUESTS, questDefs: SCENE_CANONICAL_QUESTS,
combatOptions: { combatOptions: {
playerSpeed: WALK_SPEED, playerSpeed: WALK_SPEED,
playerReach: 52, playerReach: 52,
playerCooldownTicks: 12, playerCooldownTicks: 12,
playerDamage: 7, playerDamage: 1, // D2Common.dll base unarmed 1..2 damage
playerManaPerAttack: 2, playerManaPerAttack: 0, // Skills.txt Skill 0 (Attack) mana=0
respawnTicks: 75, respawnTicks: 75,
disableMonsterAggro: false, disableMonsterAggro: false,
}, },
@ -493,6 +544,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
objectBounds = bounds.objectBounds objectBounds = bounds.objectBounds
roofController = bounds.roofController roofController = bounds.roofController
}, },
onInvalidateLoads: () => {
invalidateMonsterLoads()
},
}) })
hudManager = await createActSceneHudManager({ hudManager = await createActSceneHudManager({
@ -506,6 +560,8 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
}, },
onCastTownPortal: () => transitionController.castTownPortal(), onCastTownPortal: () => transitionController.castTownPortal(),
}) })
;(hudManager as any).bindEngine?.(engine)
;(engine as any).bindHud?.(hudManager)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
;(window as any).__d2webPortalSlot = portalSlot ;(window as any).__d2webPortalSlot = portalSlot
@ -537,6 +593,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
isTravelling: () => transitionController.isTravelling(), isTravelling: () => transitionController.isTravelling(),
labelsContainer, labelsContainer,
getMonsterArtMap: () => monsterArtMap, getMonsterArtMap: () => monsterArtMap,
audioManager,
}) })
mouseControllerRef = mouseController mouseControllerRef = mouseController
if (groundLabelOverlay !== null) { if (groundLabelOverlay !== null) {
@ -685,19 +742,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
if (hudManager !== null) { if (hudManager !== null) {
if (hudManager.charSheet) { if (hudManager.charSheet) {
hudManager.charSheet.attrs.name = param('charName', '').trim() || heroInfo.charIdentity.name 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.leftSkillId = heroInfo.defaultLeftSkill
hudManager.hotkeys.rightSkillId = heroInfo.defaultRightSkill hudManager.hotkeys.rightSkillId = heroInfo.defaultRightSkill
hudManager.syncPublishedState() hudManager.syncPublishedState()
@ -736,9 +781,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
mouseController.detach() mouseController.detach()
if (character?.handle) renderer.deleteAtlas(character.handle) if (character?.handle) renderer.deleteAtlas(character.handle)
for (const art of monsterArtMap.values()) renderer.deleteAtlas(art.handle) for (const art of monsterArtMap.values()) renderer.deleteAtlas(art.handle)
for (const page of runtime.pages) if (page !== null) renderer.deleteAtlas(page) disposeRuntimeAtlases(runtime, renderer)
for (const page of runtime.objectPages) if (page !== null) renderer.deleteAtlas(page)
renderer.dispose() renderer.dispose()
audioManager.dispose()
}) })
} }

View File

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