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:
parent
58cbc88e7c
commit
97abcb3bf7
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue