diff --git a/src/render/renderer.ts b/src/render/renderer.ts index 09f4563..102a7fa 100644 --- a/src/render/renderer.ts +++ b/src/render/renderer.ts @@ -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) - gl.uniform1iv(this.uniformTextures, samplerUnits) + 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) - gl.disable(gl.DEPTH_TEST) + 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') - 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.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) { - gl.uniform1f(this.uniformZoom, zoom) + 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 ) { - 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.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) } - 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.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') - gl.detachShader(program, vs) + if (typeof gl.detachShader === 'function') { + gl.detachShader(program, vs) + } gl.deleteShader(vs) - gl.detachShader(program, fs) + 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 } + diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 963954b..812a632 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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() let missileArtMap = new Map() - void loadMissileArtMap(renderer, packEntityBase).then(map => { - missileArtMap = 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 => { - itemsAtlasHandle = 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() - void loadOverlayArtMap(renderer, packEntityBase).then(map => { - overlayArtMap = 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() @@ -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() }) } diff --git a/src/scene/frontend/pack-loader.ts b/src/scene/frontend/pack-loader.ts index 6e34e67..64c74bc 100644 --- a/src/scene/frontend/pack-loader.ts +++ b/src/scene/frontend/pack-loader.ts @@ -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 + disposed?: boolean | undefined + readonly loadGen?: LoadGeneration | undefined + loadPages( + renderer: SpriteRenderer, + indices: readonly number[], + onProgress: () => void, + signal?: AbortSignal, + ): Promise } /** @@ -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) + if (isCancelled()) return + const handle = renderer.addAtlas(bitmap, page.width, page.height) + if (isCancelled()) { + renderer.deleteAtlas(handle) + return + } + pages[index] = handle } finally { - bitmap.close() + bitmap.close?.() + } + if (!isCancelled()) { + onProgress() } - 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, () => { - state.pagesLoaded = countLoadedPages(runtime.pages) - }).catch((err: unknown) => { + if (!runtime.disposed) { + state.pagesLoaded = countLoadedPages(runtime.pages) + } + }, 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 | 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 - readonly getRuntime: () => MapRuntime - readonly setRuntime: (next: MapRuntime) => void - readonly getHudManager: () => HudManager | null - readonly clearMouseTarget: () => void - readonly getCharacter: () => LoadedCharacter | null - readonly getMonsterArtMap: () => Map - readonly setMonsterArtMap: (map: Map) => 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 + readonly getRuntime?: () => MapRuntime + readonly setRuntime?: (next: MapRuntime) => void + readonly initialRuntime?: MapRuntime + readonly loadLevelRuntime?: (levelId: number) => Promise + readonly getHudManager?: () => HudManager | null + readonly clearMouseTarget?: () => void + readonly getCharacter?: () => LoadedCharacter | null + readonly getMonsterArtMap?: () => Map + readonly setMonsterArtMap?: (map: Map) => 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 + readonly swapLevel: (next: MapRuntime, arrive?: { x: number; y: number }) => Promise + readonly switchArea: (next: MapRuntime, arrive?: { x: number; y: number }) => Promise readonly travel: (toLevelId: number, fadeMs: number, override?: { x: number; y: number }, fromSide?: Side) => Promise readonly teleportViaWaypoint: (targetAct: number, slug: string, targetLevelId?: number) => Promise readonly checkLinks: () => void } export function createLevelTransitionController( - ctx: LevelTransitionControllerContext, + rawCtx: LevelTransitionControllerContext, ): LevelTransitionController { - const initialRuntime = ctx.getRuntime() + const ctx = rawCtx as Required + const levelLoadGen = new LoadGeneration() + const initialRuntime = rawCtx.getRuntime?.() ?? rawCtx.initialRuntime const townVariantByAct = new Map() - 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 => { + const swapLevel = async ( + next: MapRuntime, + arrive?: { x: number; y: number }, + ): Promise => { + 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() + 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, () => { - 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 if (isTownLevel(next.levelId) && next.variant) { townVariantByAct.set(next.act, next.variant) @@ -1114,7 +1251,9 @@ export function createLevelTransitionController( } } await swapLevel(next, landing) - preloadRemaining(next, ctx.renderer) + 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, () => { - 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] 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) - preloadRemaining(next, ctx.renderer) - ctx.resetMinimapCache() + if (!next.disposed && ctx.getRuntime() === next) { + preloadRemaining(next, ctx.renderer) + ctx.resetMinimapCache() - const names = getLevelNames(next.levelId ?? next.level) - ctx.status.textContent = isEn - ? `Teleported to: ${names.nameEn} (Act ${String(next.act)})` - : `已传送到:${names.nameZh}(Act ${String(next.act)})` + 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 { + if ('swapLevel' in controllerOrCtx) { + await controllerOrCtx.swapLevel(next, arrive) + return + } + const controller = createLevelTransitionController(controllerOrCtx) + await controller.swapLevel(next, arrive) +} + diff --git a/tests/p0-529-async-lifecycle.test.ts b/tests/p0-529-async-lifecycle.test.ts new file mode 100644 index 0000000..4df2735 --- /dev/null +++ b/tests/p0-529-async-lifecycle.test.ts @@ -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() + 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 = []): 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([ + ['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() + 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() + }) +})