diff --git a/package.json b/package.json index 6390ad4..5b71c3e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "verify:net": "node scripts/verify-net.ts", "verify:d2": "node scripts/verify-d2-data.ts", "verify:implode": "node scripts/verify-implode.ts", - "verify:all": "node scripts/verify-combat.ts && node scripts/verify-items.ts && node scripts/verify-m4.ts && node scripts/verify-m5.ts && node scripts/verify-net.ts", + "verify:collision-orientation": "node scripts/verify-collision-orientation.ts", + "verify:renderer": "npx tsx scripts/verify-renderer-lifecycle.ts", + "verify:all": "node scripts/verify-combat.ts && node scripts/verify-items.ts && node scripts/verify-m4.ts && node scripts/verify-m5.ts && node scripts/verify-net.ts && node scripts/verify-collision-orientation.ts", "verify:acts": "node scripts/verify-acts.ts", "build:game": "vite build --base=/diablo2/ --outDir dist-game", "pack:data": "node scripts/pack-act-assets.ts", diff --git a/scripts/verify-renderer-lifecycle.ts b/scripts/verify-renderer-lifecycle.ts new file mode 100644 index 0000000..1f42916 --- /dev/null +++ b/scripts/verify-renderer-lifecycle.ts @@ -0,0 +1,322 @@ +/** + * Verification test for SpriteRenderer GPU resource lifecycle, deleteAtlas, + * dispose, and WebGL context loss handling (Issue #9). + */ +import { SpriteRenderer, RendererError } from '../src/render/renderer.ts' + +interface MockResource { + id: number + type: string +} + +function createMockCanvas(): { + canvas: any + stats: { + createdTextures: Set + deletedTextures: Set + createdBuffers: Set + deletedBuffers: Set + createdVAOs: Set + deletedVAOs: Set + createdPrograms: Set + deletedPrograms: Set + createdShaders: Set + deletedShaders: Set + eventListeners: Map> + } + setContextLost: (lost: boolean) => void + dispatchMockEvent: (type: string) => { defaultPrevented: boolean } +} { + let nextId = 1 + let contextLost = false + + const stats = { + createdTextures: new Set(), + deletedTextures: new Set(), + createdBuffers: new Set(), + deletedBuffers: new Set(), + createdVAOs: new Set(), + deletedVAOs: new Set(), + createdPrograms: new Set(), + deletedPrograms: new Set(), + createdShaders: new Set(), + deletedShaders: new Set(), + eventListeners: new Map>(), + } + + const gl: any = { + VERTEX_SHADER: 35633, + FRAGMENT_SHADER: 35632, + COMPILE_STATUS: 35713, + LINK_STATUS: 35714, + MAX_TEXTURE_SIZE: 3379, + TEXTURE_2D: 3553, + RGBA: 6408, + UNSIGNED_BYTE: 5121, + FLOAT: 5126, + ARRAY_BUFFER: 34962, + DYNAMIC_DRAW: 35048, + TRIANGLES: 4, + TEXTURE_MIN_FILTER: 10241, + TEXTURE_MAG_FILTER: 10240, + TEXTURE_WRAP_S: 10242, + TEXTURE_WRAP_T: 10243, + CLAMP_TO_EDGE: 33071, + NEAREST: 9728, + UNPACK_ALIGNMENT: 3317, + BLEND: 3042, + SRC_ALPHA: 770, + ONE_MINUS_SRC_ALPHA: 771, + DEPTH_TEST: 2929, + COLOR_BUFFER_BIT: 16384, + TEXTURE0: 33984, + + drawingBufferWidth: 800, + drawingBufferHeight: 600, + + isContextLost: () => contextLost, + + createProgram: () => { + const res: MockResource = { id: nextId++, type: 'program' } + stats.createdPrograms.add(res) + return res + }, + deleteProgram: (p: MockResource) => { stats.deletedPrograms.add(p) }, + attachShader: () => {}, + detachShader: () => {}, + linkProgram: () => {}, + getProgramParameter: () => true, + getProgramInfoLog: () => '', + useProgram: () => {}, + + createShader: (type: number) => { + const res: MockResource = { id: nextId++, type: type === 35633 ? 'vertexShader' : 'fragmentShader' } + stats.createdShaders.add(res) + return res + }, + shaderSource: () => {}, + compileShader: () => {}, + getShaderParameter: () => true, + getShaderInfoLog: () => '', + deleteShader: (s: MockResource) => { stats.deletedShaders.add(s) }, + + createVertexArray: () => { + const res: MockResource = { id: nextId++, type: 'vao' } + stats.createdVAOs.add(res) + return res + }, + deleteVertexArray: (vao: MockResource) => { stats.deletedVAOs.add(vao) }, + bindVertexArray: () => {}, + + createBuffer: () => { + const res: MockResource = { id: nextId++, type: 'buffer' } + stats.createdBuffers.add(res) + return res + }, + deleteBuffer: (b: MockResource) => { stats.deletedBuffers.add(b) }, + bindBuffer: () => {}, + bufferData: () => {}, + bufferSubData: () => {}, + + getAttribLocation: (_p: any, name: string) => (name === 'a_position' ? 0 : name === 'a_uv' ? 1 : 2), + enableVertexAttribArray: () => {}, + vertexAttribPointer: () => {}, + + createTexture: () => { + const res: MockResource = { id: nextId++, type: 'texture' } + stats.createdTextures.add(res) + return res + }, + deleteTexture: (t: MockResource) => { stats.deletedTextures.add(t) }, + bindTexture: () => {}, + texParameteri: () => {}, + texImage2D: () => {}, + pixelStorei: () => {}, + + getParameter: (param: number) => (param === 3379 ? 4096 : 0), + getUniformLocation: (_p: any, name: string) => ({ name }), + uniform2f: () => {}, + uniform1f: () => {}, + uniform1i: () => {}, + + enable: () => {}, + disable: () => {}, + blendFunc: () => {}, + viewport: () => {}, + clearColor: () => {}, + clear: () => {}, + activeTexture: () => {}, + drawArrays: () => {}, + } + + const eventListeners = stats.eventListeners + + const canvas: any = { + getContext: (type: string) => (type === 'webgl2' ? gl : null), + addEventListener: (type: string, listener: EventListener) => { + if (!eventListeners.has(type)) eventListeners.set(type, new Set()) + eventListeners.get(type)!.add(listener) + }, + removeEventListener: (type: string, listener: EventListener) => { + eventListeners.get(type)?.delete(listener) + }, + drawingBufferWidth: 800, + drawingBufferHeight: 600, + } + + return { + canvas, + stats, + setContextLost: (lost: boolean) => { contextLost = lost }, + dispatchMockEvent: (type: string) => { + let defaultPrevented = false + const event: any = { + type, + preventDefault: () => { defaultPrevented = true }, + } + const set = eventListeners.get(type) + if (set) { + for (const listener of set) { + listener(event) + } + } + return { defaultPrevented } + }, + } +} + +function runTests(): void { + console.log('== Running SpriteRenderer Lifecycle Verification ==\n') + + let passed = 0 + let failed = 0 + + function assert(condition: boolean, name: string): void { + if (condition) { + console.log(` ✓ ${name}`) + passed += 1 + } else { + console.error(` ✗ ${name}`) + failed += 1 + } + } + + // Test 1: Shader cleanup after compilation + { + const mock = createMockCanvas() + const renderer = new SpriteRenderer(mock.canvas) + + assert(mock.stats.createdShaders.size === 2, 'Two shaders created (VS & FS)') + assert(mock.stats.deletedShaders.size === 2, 'Both shaders detached and deleted right after program link') + assert(mock.stats.createdPrograms.size === 1, 'Program created') + assert(mock.stats.createdVAOs.size === 1, 'VAO created') + assert(mock.stats.createdBuffers.size === 1, 'VBO buffer created') + assert(mock.stats.createdTextures.size === 2, 'Initial textures created (atlas + white)') + assert(renderer.isDisposed === false, 'Renderer is active and not disposed') + + renderer.dispose() + } + + // Test 2: addAtlas & deleteAtlas + { + const mock = createMockCanvas() + const renderer = new SpriteRenderer(mock.canvas) + + const extra = renderer.addAtlas({ pixels: new Uint8ClampedArray(64 * 64 * 4), width: 64, height: 64 }) + assert(mock.stats.createdTextures.size === 3, 'addAtlas creates a new GPU texture (3 total)') + + // Draw with this extra atlas + renderer.begin({ x: 0, y: 0, zoom: 1 }) + renderer.draw({ x: 0, y: 0, width: 32, height: 32 }, 0, 0, { atlas: extra }) + + // Now delete this atlas + const deleted = renderer.deleteAtlas(extra) + assert(deleted === true, 'deleteAtlas returned true for managed handle') + assert(mock.stats.deletedTextures.has(extra.texture as any), 'GPU texture was deleted via gl.deleteTexture') + + // Deleting again should be safe and return false + const deletedAgain = renderer.deleteAtlas(extra) + assert(deletedAgain === false, 'deleteAtlas returned false on already deleted handle') + + renderer.dispose() + } + + // Test 3: dispose() cleans up all resources + { + const mock = createMockCanvas() + const renderer = new SpriteRenderer(mock.canvas) + const extra1 = renderer.addAtlas({ pixels: new Uint8ClampedArray(32 * 32 * 4), width: 32, height: 32 }) + const extra2 = renderer.addAtlas({ pixels: new Uint8ClampedArray(32 * 32 * 4), width: 32, height: 32 }) + void extra1 + void extra2 + + assert(mock.stats.createdTextures.size === 4, '4 total textures created before dispose') + + renderer.dispose() + assert(renderer.isDisposed === true, 'isDisposed is true after dispose()') + assert(mock.stats.deletedTextures.size === 4, 'All 4 textures were deleted on dispose()') + assert(mock.stats.deletedBuffers.size === 1, 'VBO was deleted on dispose()') + assert(mock.stats.deletedVAOs.size === 1, 'VAO was deleted on dispose()') + assert(mock.stats.deletedPrograms.size === 1, 'Program was deleted on dispose()') + + // Idempotent dispose + renderer.dispose() + assert(mock.stats.deletedPrograms.size === 1, 'Second dispose() is safe and idempotent') + + // Calling methods after dispose throws RendererError + let threwOnBegin = false + try { + renderer.begin({ x: 0, y: 0, zoom: 1 }) + } catch (err) { + if (err instanceof RendererError) threwOnBegin = true + } + assert(threwOnBegin, 'begin() throws RendererError when called on disposed renderer') + + let threwOnAdd = false + try { + renderer.addAtlas({ pixels: new Uint8ClampedArray(16), width: 2, height: 2 }) + } catch (err) { + if (err instanceof RendererError) threwOnAdd = true + } + assert(threwOnAdd, 'addAtlas() throws RendererError when called on disposed renderer') + } + + // Test 4: WebGL Context Lost & Restored + { + const mock = createMockCanvas() + const tracker = { lostFired: false, restoredFired: false } + + const renderer = new SpriteRenderer(mock.canvas, { + onContextLost: () => { tracker.lostFired = true }, + onContextRestored: () => { tracker.restoredFired = true }, + }) + + const lostEvent = mock.dispatchMockEvent('webglcontextlost') + assert(lostEvent.defaultPrevented === true, 'webglcontextlost calls event.preventDefault() to allow recovery') + assert(tracker.lostFired === true, 'onContextLost callback fired') + + mock.dispatchMockEvent('webglcontextrestored') + assert(tracker.restoredFired === true, 'onContextRestored callback fired') + + // Dispose cleans up event listeners + renderer.dispose() + assert(mock.stats.eventListeners.get('webglcontextlost')?.size === 0, 'Context lost listener removed on dispose') + assert(mock.stats.eventListeners.get('webglcontextrestored')?.size === 0, 'Context restored listener removed on dispose') + } + + // Test 5: Dispose when context is already lost + { + const mock = createMockCanvas() + const renderer = new SpriteRenderer(mock.canvas) + mock.setContextLost(true) + + // Dispose should not attempt invalid GL deletes when context is lost + renderer.dispose() + assert(renderer.isDisposed === true, 'dispose() safely completes when context is already lost') + } + + console.log(`\n== Summary: ${String(passed)} passed, ${String(failed)} failed ==`) + if (failed > 0) process.exit(1) +} + +runTests() diff --git a/src/render/renderer.ts b/src/render/renderer.ts index 2e8d063..4acbc00 100644 --- a/src/render/renderer.ts +++ b/src/render/renderer.ts @@ -51,6 +51,14 @@ export interface DrawOptions { readonly atlas?: AtlasHandle } +/** Configuration options and event callbacks for SpriteRenderer. */ +export interface RendererOptions { + /** Optional callback invoked when the WebGL context is lost. */ + readonly onContextLost?: (event: Event) => void + /** Optional callback invoked when the WebGL context is restored. */ + readonly onContextRestored?: () => void +} + /** Vertices per quad (two triangles). */ const VERTICES_PER_QUAD = 6 /** Floats per vertex: x, y, u, v, r, g, b, a. */ @@ -95,6 +103,8 @@ export class RendererError extends Error {} * A quad batcher bound to one canvas. */ export class SpriteRenderer { + /** The target canvas. */ + readonly canvas: HTMLCanvasElement /** The WebGL2 context, exposed for the few callers that need raw access. */ readonly gl: WebGL2RenderingContext private readonly program: WebGLProgram @@ -118,10 +128,20 @@ export class SpriteRenderer { private batchAtlas: AtlasHandle private camera: Camera = { x: 0, y: 0, zoom: 1 } + /** All textures allocated and managed by this renderer. */ + private readonly allocatedTextures = new Set() + /** Whether this renderer has been disposed. */ + private disposed = false + /** Listener references for cleanup. */ + private readonly handleContextLost: (event: Event) => void + private readonly handleContextRestored: () => void + /** * @param canvas - the canvas to render into. + * @param options - optional lifecycle hooks and configuration. */ - constructor(canvas: HTMLCanvasElement) { + constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) { + this.canvas = canvas const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, premultipliedAlpha: false }) if (gl === null) throw new RendererError('WebGL2 is not available in this browser') this.gl = gl @@ -137,6 +157,8 @@ export class SpriteRenderer { this.buffer = buffer this.atlasTexture = atlasTexture this.whiteTexture = whiteTexture + this.allocatedTextures.add(atlasTexture) + this.allocatedTextures.add(whiteTexture) this.defaultAtlas = { texture: atlasTexture, width: 1, height: 1 } this.whiteAtlas = { texture: whiteTexture, width: 1, height: 1 } this.batchAtlas = this.defaultAtlas @@ -174,6 +196,23 @@ export class SpriteRenderer { gl.enable(gl.BLEND) gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) gl.disable(gl.DEPTH_TEST) + + this.handleContextLost = (event: Event): void => { + event.preventDefault() + console.warn('WebGL2 context lost') + options.onContextLost?.(event) + } + this.handleContextRestored = (): void => { + console.info('WebGL2 context restored') + options.onContextRestored?.() + } + canvas.addEventListener('webglcontextlost', this.handleContextLost) + canvas.addEventListener('webglcontextrestored', this.handleContextRestored) + } + + /** Whether this renderer and its GPU resources have been disposed. */ + get isDisposed(): boolean { + return this.disposed } /** @@ -183,6 +222,7 @@ export class SpriteRenderer { * @returns the handle other pages can be drawn with. */ setAtlas(atlas: SpriteAtlas): AtlasHandle { + if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed') const gl = this.gl // Uploading past the limit is a silent INVALID_VALUE in GL; say what // actually went wrong instead. @@ -222,6 +262,7 @@ export class SpriteRenderer { 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 isBitmap = typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap @@ -235,6 +276,7 @@ export class SpriteRenderer { this.flush() const texture = gl.createTexture() if (texture === null) throw new RendererError('texture allocation failed') + this.allocatedTextures.add(texture) gl.bindTexture(gl.TEXTURE_2D, texture) gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) @@ -257,6 +299,31 @@ export class SpriteRenderer { return { texture, width: pageWidth, height: pageHeight } } + /** + * Release an atlas texture handle and free its GPU memory. + * + * @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 + if (!this.allocatedTextures.has(handle.texture)) return false + + if (this.batchAtlas === handle) { + this.flush() + this.batchAtlas = this.defaultAtlas === handle ? this.whiteAtlas : this.defaultAtlas + } + if (this.defaultAtlas === handle) { + this.defaultAtlas = this.whiteAtlas + } + + if (!this.gl.isContextLost()) { + this.gl.deleteTexture(handle.texture) + } + this.allocatedTextures.delete(handle.texture) + return true + } + /** The page `setAtlas` uploaded into, for callers that draw with handles. */ get defaultAtlasHandle(): AtlasHandle { return this.defaultAtlas @@ -269,6 +336,7 @@ export class SpriteRenderer { * @param clear - background colour as `[r, g, b]` in 0..1. */ begin(camera: Camera, clear: readonly [number, number, number] = [0, 0, 0]): void { + if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed') const gl = this.gl this.camera = camera this.quadCount = 0 @@ -287,6 +355,7 @@ export class SpriteRenderer { * @param options - tint and flipping. */ draw(frame: AtlasFrame, x: number, y: number, options: DrawOptions = {}): void { + if (this.disposed) return const page = options.atlas ?? this.defaultAtlas // A page switch splits the batch; because it never reorders quads, painter's // order is preserved across pages. @@ -316,6 +385,7 @@ export class SpriteRenderer { * @param color - `[r, g, b, a]` in 0..1. */ drawSolid(x: number, y: number, width: number, height: number, color: readonly [number, number, number, number]): void { + if (this.disposed) return if (this.batchAtlas !== this.whiteAtlas) { this.flush() this.batchAtlas = this.whiteAtlas @@ -368,7 +438,7 @@ export class SpriteRenderer { /** Submit every queued quad. */ flush(): void { const gl = this.gl - if (this.quadCount === 0) return + if (this.disposed || this.quadCount === 0) return gl.useProgram(this.program) gl.bindVertexArray(this.vao) gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer) @@ -383,6 +453,31 @@ export class SpriteRenderer { this.quadCount = 0 } + /** + * Destroy all WebGL2 GPU resources (textures, VAO, VBO, program) and detach event listeners. + */ + dispose(): void { + if (this.disposed) return + this.disposed = true + + this.canvas.removeEventListener('webglcontextlost', this.handleContextLost) + this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored) + + const gl = this.gl + if (!gl.isContextLost()) { + for (const texture of this.allocatedTextures) { + gl.deleteTexture(texture) + } + this.allocatedTextures.clear() + gl.deleteBuffer(this.buffer) + gl.deleteVertexArray(this.vao) + gl.deleteProgram(this.program) + } else { + this.allocatedTextures.clear() + } + this.quadCount = 0 + } + /** * Grow the vertex buffer, preserving queued geometry. * @@ -405,6 +500,9 @@ const WHITE: readonly [number, number, number, number] = [1, 1, 1, 1] /** * Compile and link a program, reporting shader logs on failure. * + * Shaders are detached and flagged for deletion immediately after linking to + * prevent driver-level memory leaks. + * * @param gl - the context. * @param vertexSource - vertex shader source. * @param fragmentSource - fragment shader source. @@ -417,17 +515,28 @@ function createProgram(gl: WebGL2RenderingContext, vertexSource: string, fragmen gl.shaderSource(shader, source) gl.compileShader(shader) if (gl.getShaderParameter(shader, gl.COMPILE_STATUS) !== true) { - throw new RendererError(`shader compile failed: ${gl.getShaderInfoLog(shader) ?? 'unknown'}`) + const log = gl.getShaderInfoLog(shader) ?? 'unknown' + gl.deleteShader(shader) + throw new RendererError(`shader compile failed: ${log}`) } return shader } const program = gl.createProgram() if (program === null) throw new RendererError('could not create program') - gl.attachShader(program, compile(gl.VERTEX_SHADER, vertexSource)) - gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragmentSource)) + const vs = compile(gl.VERTEX_SHADER, vertexSource) + const fs = compile(gl.FRAGMENT_SHADER, fragmentSource) + gl.attachShader(program, vs) + gl.attachShader(program, fs) gl.linkProgram(program) - if (gl.getProgramParameter(program, gl.LINK_STATUS) !== true) { - throw new RendererError(`program link failed: ${gl.getProgramInfoLog(program) ?? 'unknown'}`) + const linked = gl.getProgramParameter(program, gl.LINK_STATUS) === true + const log = linked ? '' : (gl.getProgramInfoLog(program) ?? 'unknown') + gl.detachShader(program, vs) + gl.deleteShader(vs) + gl.detachShader(program, fs) + gl.deleteShader(fs) + if (!linked) { + gl.deleteProgram(program) + throw new RendererError(`program link failed: ${log}`) } return program } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index c9edda9..bed0674 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -740,7 +740,7 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number renderer.drawSolid(player.x - MARKER_WIDTH / 2, player.y - 6, MARKER_WIDTH, 6, [0.35, 0.25, 0.15, 1]) } else { const direction = facingToDirection(player.facing, character.directions) - const groupIndex = character.walk + (player.moving ? direction : character.stand) + const groupIndex = (player.moving ? character.walk : character.stand) + direction const group = character.groups[groupIndex] ?? character.groups[character.walk + direction] const frameIndex = player.moving ? Math.floor(player.walkFrame / 3) % Math.max(1, group?.length ?? 1) @@ -810,6 +810,16 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number ) }) } + + window.addEventListener('beforeunload', () => { + loop.stop() + input.detach() + if (character?.handle) renderer.deleteAtlas(character.handle) + for (const page of runtime.pages) { + if (page !== null) renderer.deleteAtlas(page) + } + renderer.dispose() + }) } /** @@ -821,7 +831,8 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number * @returns true when blocked. */ function collides(grid: CollisionGrid, x: number, y: number): boolean { - for (const [dx, dy] of [[-1, -1], [1, -1], [-1, 1], [1, 1]] as const) { + if (isBlockedAt(grid, x, y)) return true + for (const [dx, dy] of [[-1, -1], [1, -1], [-1, 1], [1, 1], [0, -1], [0, 1], [-1, 0], [1, 0]] as const) { if (isBlockedAt(grid, x + (dx * FEET_WIDTH) / 2, y + (dy * FEET_HEIGHT) / 2)) return true } return false @@ -849,10 +860,10 @@ function cellOf(grid: CollisionGrid, x: number, y: number): { x: number; y: numb * * @param x - screen x direction, -1..1. * @param y - screen y direction, -1..1 (down positive). - * @returns the facing index, 0 = north. + * @returns the facing index, 0 = south, turning clockwise (west). */ function facingOf(x: number, y: number): number { - const angle = Math.atan2(x, -y) + const angle = Math.atan2(-x, y) return Math.round((angle / (Math.PI / 4)) + 8) % 8 } @@ -1084,7 +1095,15 @@ async function boot(): Promise { if (runtime === null) continue publish(runtime, packBase) void populatePackSelectors(packBase, { act: runtime.act, label: runtime.quadrant }) - const renderer = new SpriteRenderer(canvas) + const renderer = new SpriteRenderer(canvas, { + onContextLost: () => { + status.textContent = 'WebGL 上下文丢失,正在尝试恢复…' + }, + onContextRestored: () => { + status.textContent = 'WebGL 上下文已恢复,正在重新加载…' + location.reload() + }, + }) // The first frame waits only for the spawn-area pages. await runtime.loadPages(renderer, runtime.priorityPages, () => { state.pagesLoaded = runtime.pages.filter(page => page !== null).length @@ -1100,7 +1119,15 @@ async function boot(): Promise { for (const base of bases) { try { status.textContent = `正在读取归档 ${base}(HTTP range)并解码 …` - const renderer = new SpriteRenderer(canvas) + const renderer = new SpriteRenderer(canvas, { + onContextLost: () => { + status.textContent = 'WebGL 上下文丢失,正在尝试恢复…' + }, + onContextRestored: () => { + status.textContent = 'WebGL 上下文已恢复,正在重新加载…' + location.reload() + }, + }) const runtime = await loadLiveRuntime(base, act, renderer, param('quadrant', '')) publish(runtime, base) runScene(runtime, renderer, started) diff --git a/src/scene/map-scene.ts b/src/scene/map-scene.ts index 92a0472..631a244 100644 --- a/src/scene/map-scene.ts +++ b/src/scene/map-scene.ts @@ -323,9 +323,10 @@ function decodePaletteMember(name: string, bytes: Uint8Array): Palette { return { rgb: pl2.rgb, size: 256 } } -/** The running loop and input, so a reload can retire the previous scene. */ +/** The running loop, input, and renderer, so a reload can retire the previous scene. */ let running: GameLoop | null = null let input: KeyboardInput | null = null +let activeRenderer: SpriteRenderer | null = null /** Fetch a URL as bytes. */ async function fetchBytes(url: string): Promise { @@ -368,7 +369,17 @@ function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement): running?.stop() input?.detach() - const renderer = new SpriteRenderer(canvas) + activeRenderer?.dispose() + const renderer = new SpriteRenderer(canvas, { + onContextLost: () => { + status.textContent = 'WebGL 上下文丢失,正在尝试恢复…' + }, + onContextRestored: () => { + status.textContent = 'WebGL 上下文已恢复,正在重新加载…' + location.reload() + }, + }) + activeRenderer = renderer // The actor's frames ride in the *same* atlas as the tiles: one texture means // the actor can be drawn at its depth among the walls without a second bind. const combined: SpriteSheet = sources.actor === null @@ -1214,4 +1225,11 @@ function boot(): void { status.textContent = '拖入 .mpq,或用 ?mpq= / ?data= 指定数据源。' } +window.addEventListener('beforeunload', () => { + running?.stop() + input?.detach() + activeRenderer?.dispose() + activeRenderer = null +}) + boot() diff --git a/src/scene/net-scene.ts b/src/scene/net-scene.ts index a0759cf..5f23d46 100644 --- a/src/scene/net-scene.ts +++ b/src/scene/net-scene.ts @@ -174,6 +174,7 @@ const DEMO_MONSTERS: MonsterStats[] = monsterStatsFromTable(parseTable([ let running: GameLoop | null = null let input: KeyboardInput | null = null +let activeRenderer: SpriteRenderer | null = null /** * Fetch bytes, or null when the file is absent. @@ -325,7 +326,17 @@ function startScene( running?.stop() input?.detach() - const renderer = new SpriteRenderer(canvas) + activeRenderer?.dispose() + const renderer = new SpriteRenderer(canvas, { + onContextLost: () => { + status.textContent = 'WebGL 上下文丢失,正在尝试恢复…' + }, + onContextRestored: () => { + status.textContent = 'WebGL 上下文已恢复,正在重新加载…' + location.reload() + }, + }) + activeRenderer = renderer const combined: SpriteSheet = sources.actor === null ? scene.sheet : { groups: [...scene.sheet.groups, ...sources.actor.sheet.groups], width: null } @@ -608,5 +619,14 @@ function boot(): void { })() } +if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + running?.stop() + input?.detach() + activeRenderer?.dispose() + activeRenderer = null + }) +} + /** Whether the page is running inside a document (avoids running under Node). */ if (typeof document !== 'undefined') boot() diff --git a/src/walk.ts b/src/walk.ts index 73f0be6..503a5fb 100644 --- a/src/walk.ts +++ b/src/walk.ts @@ -140,6 +140,10 @@ function collides(x: number, y: number, walls: readonly Rect[]): boolean { return false } +let activeLoop: GameLoop | null = null +let activeInput: KeyboardInput | null = null +let activeRenderer: SpriteRenderer | null = null + /** * Boot the scene from an archive. * @@ -173,7 +177,19 @@ async function start(archive: MpqArchive): Promise { const standFrames: readonly (readonly AtlasFrame[])[] | null = stand === null ? null : atlas.groups.slice(walk.groups.length) - const renderer = new SpriteRenderer(canvas) + activeLoop?.stop() + activeInput?.detach() + activeRenderer?.dispose() + const renderer = new SpriteRenderer(canvas, { + onContextLost: () => { + status.textContent = 'WebGL 上下文丢失,正在尝试恢复…' + }, + onContextRestored: () => { + status.textContent = 'WebGL 上下文已恢复,正在重新加载…' + location.reload() + }, + }) + activeRenderer = renderer renderer.setAtlas(atlas) const walls = buildWalls() @@ -239,6 +255,8 @@ async function start(archive: MpqArchive): Promise { + ` · ${String(atlas.groups.length)} groups / ${String(walk.groups.reduce((n, g) => n + g.frames.length, 0))} walk frames` }, }) + activeInput = input + activeLoop = loop loop.start() state.ready = true status.textContent = 'WASD / 方向键移动。角色为真实暗黑1 素材(战士行走/站立,宽度 96 = 游戏表数值);地面与墙为占位几何。' @@ -286,4 +304,11 @@ function boot(): void { } } +window.addEventListener('beforeunload', () => { + activeLoop?.stop() + activeInput?.detach() + activeRenderer?.dispose() + activeRenderer = null +}) + boot()