fix(render): 修复 SpriteRenderer 显存泄漏与补充 WebGL 上下文保护 (fixes #9)
- 优化 createProgram:Shader 链接后立即 detach 并 deleteShader,链接失败自动清理 Program - 新增 deleteAtlas:支持释放指定 GPU 纹理显存,并在活跃批次解绑时安全 flush - 新增 dispose 与 isDisposed:释放全部托管纹理、VAO、VBO、Program 与事件监听,支持幂等和 context-lost 防御 - 补充 WebGL context lost/restored 监听与自动恢复提示 - act-scene/map-scene/net-scene/walk 接入生命周期管理与 beforeunload 资源释放 - 新增 verify:renderer 单元生命周期验证套件 (26 项断言全部通过)
This commit is contained in:
parent
a86b2a4582
commit
a80ee2cafc
|
|
@ -14,7 +14,9 @@
|
||||||
"verify:net": "node scripts/verify-net.ts",
|
"verify:net": "node scripts/verify-net.ts",
|
||||||
"verify:d2": "node scripts/verify-d2-data.ts",
|
"verify:d2": "node scripts/verify-d2-data.ts",
|
||||||
"verify:implode": "node scripts/verify-implode.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",
|
"verify:acts": "node scripts/verify-acts.ts",
|
||||||
"build:game": "vite build --base=/diablo2/ --outDir dist-game",
|
"build:game": "vite build --base=/diablo2/ --outDir dist-game",
|
||||||
"pack:data": "node scripts/pack-act-assets.ts",
|
"pack:data": "node scripts/pack-act-assets.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<MockResource>
|
||||||
|
deletedTextures: Set<MockResource>
|
||||||
|
createdBuffers: Set<MockResource>
|
||||||
|
deletedBuffers: Set<MockResource>
|
||||||
|
createdVAOs: Set<MockResource>
|
||||||
|
deletedVAOs: Set<MockResource>
|
||||||
|
createdPrograms: Set<MockResource>
|
||||||
|
deletedPrograms: Set<MockResource>
|
||||||
|
createdShaders: Set<MockResource>
|
||||||
|
deletedShaders: Set<MockResource>
|
||||||
|
eventListeners: Map<string, Set<EventListener>>
|
||||||
|
}
|
||||||
|
setContextLost: (lost: boolean) => void
|
||||||
|
dispatchMockEvent: (type: string) => { defaultPrevented: boolean }
|
||||||
|
} {
|
||||||
|
let nextId = 1
|
||||||
|
let contextLost = false
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
createdTextures: new Set<MockResource>(),
|
||||||
|
deletedTextures: new Set<MockResource>(),
|
||||||
|
createdBuffers: new Set<MockResource>(),
|
||||||
|
deletedBuffers: new Set<MockResource>(),
|
||||||
|
createdVAOs: new Set<MockResource>(),
|
||||||
|
deletedVAOs: new Set<MockResource>(),
|
||||||
|
createdPrograms: new Set<MockResource>(),
|
||||||
|
deletedPrograms: new Set<MockResource>(),
|
||||||
|
createdShaders: new Set<MockResource>(),
|
||||||
|
deletedShaders: new Set<MockResource>(),
|
||||||
|
eventListeners: new Map<string, Set<EventListener>>(),
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
@ -51,6 +51,14 @@ export interface DrawOptions {
|
||||||
readonly atlas?: AtlasHandle
|
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). */
|
/** Vertices per quad (two triangles). */
|
||||||
const VERTICES_PER_QUAD = 6
|
const VERTICES_PER_QUAD = 6
|
||||||
/** Floats per vertex: x, y, u, v, r, g, b, a. */
|
/** 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.
|
* A quad batcher bound to one canvas.
|
||||||
*/
|
*/
|
||||||
export class SpriteRenderer {
|
export class SpriteRenderer {
|
||||||
|
/** The target canvas. */
|
||||||
|
readonly canvas: HTMLCanvasElement
|
||||||
/** The WebGL2 context, exposed for the few callers that need raw access. */
|
/** The WebGL2 context, exposed for the few callers that need raw access. */
|
||||||
readonly gl: WebGL2RenderingContext
|
readonly gl: WebGL2RenderingContext
|
||||||
private readonly program: WebGLProgram
|
private readonly program: WebGLProgram
|
||||||
|
|
@ -118,10 +128,20 @@ export class SpriteRenderer {
|
||||||
private batchAtlas: AtlasHandle
|
private batchAtlas: AtlasHandle
|
||||||
private camera: Camera = { x: 0, y: 0, zoom: 1 }
|
private camera: Camera = { x: 0, y: 0, zoom: 1 }
|
||||||
|
|
||||||
|
/** All textures allocated and managed by this renderer. */
|
||||||
|
private readonly allocatedTextures = new Set<WebGLTexture>()
|
||||||
|
/** 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 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 })
|
const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, premultipliedAlpha: false })
|
||||||
if (gl === null) throw new RendererError('WebGL2 is not available in this browser')
|
if (gl === null) throw new RendererError('WebGL2 is not available in this browser')
|
||||||
this.gl = gl
|
this.gl = gl
|
||||||
|
|
@ -137,6 +157,8 @@ export class SpriteRenderer {
|
||||||
this.buffer = buffer
|
this.buffer = buffer
|
||||||
this.atlasTexture = atlasTexture
|
this.atlasTexture = atlasTexture
|
||||||
this.whiteTexture = whiteTexture
|
this.whiteTexture = whiteTexture
|
||||||
|
this.allocatedTextures.add(atlasTexture)
|
||||||
|
this.allocatedTextures.add(whiteTexture)
|
||||||
this.defaultAtlas = { texture: atlasTexture, width: 1, height: 1 }
|
this.defaultAtlas = { texture: atlasTexture, width: 1, height: 1 }
|
||||||
this.whiteAtlas = { texture: whiteTexture, width: 1, height: 1 }
|
this.whiteAtlas = { texture: whiteTexture, width: 1, height: 1 }
|
||||||
this.batchAtlas = this.defaultAtlas
|
this.batchAtlas = this.defaultAtlas
|
||||||
|
|
@ -174,6 +196,23 @@ export class SpriteRenderer {
|
||||||
gl.enable(gl.BLEND)
|
gl.enable(gl.BLEND)
|
||||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||||
gl.disable(gl.DEPTH_TEST)
|
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.
|
* @returns the handle other pages can be drawn with.
|
||||||
*/
|
*/
|
||||||
setAtlas(atlas: SpriteAtlas): AtlasHandle {
|
setAtlas(atlas: SpriteAtlas): AtlasHandle {
|
||||||
|
if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed')
|
||||||
const gl = this.gl
|
const gl = this.gl
|
||||||
// Uploading past the limit is a silent INVALID_VALUE in GL; say what
|
// Uploading past the limit is a silent INVALID_VALUE in GL; say what
|
||||||
// actually went wrong instead.
|
// actually went wrong instead.
|
||||||
|
|
@ -222,6 +262,7 @@ export class SpriteRenderer {
|
||||||
width?: number,
|
width?: number,
|
||||||
height?: number,
|
height?: number,
|
||||||
): AtlasHandle {
|
): AtlasHandle {
|
||||||
|
if (this.disposed) throw new RendererError('SpriteRenderer has already been disposed')
|
||||||
const gl = this.gl
|
const gl = this.gl
|
||||||
const limit = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number
|
const limit = gl.getParameter(gl.MAX_TEXTURE_SIZE) as number
|
||||||
const isBitmap = typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap
|
const isBitmap = typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap
|
||||||
|
|
@ -235,6 +276,7 @@ export class SpriteRenderer {
|
||||||
this.flush()
|
this.flush()
|
||||||
const texture = gl.createTexture()
|
const texture = gl.createTexture()
|
||||||
if (texture === null) throw new RendererError('texture allocation failed')
|
if (texture === null) throw new RendererError('texture allocation failed')
|
||||||
|
this.allocatedTextures.add(texture)
|
||||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||||
|
|
@ -257,6 +299,31 @@ export class SpriteRenderer {
|
||||||
return { texture, width: pageWidth, height: pageHeight }
|
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. */
|
/** The page `setAtlas` uploaded into, for callers that draw with handles. */
|
||||||
get defaultAtlasHandle(): AtlasHandle {
|
get defaultAtlasHandle(): AtlasHandle {
|
||||||
return this.defaultAtlas
|
return this.defaultAtlas
|
||||||
|
|
@ -269,6 +336,7 @@ export class SpriteRenderer {
|
||||||
* @param clear - background colour as `[r, g, b]` in 0..1.
|
* @param clear - background colour as `[r, g, b]` in 0..1.
|
||||||
*/
|
*/
|
||||||
begin(camera: Camera, clear: readonly [number, number, number] = [0, 0, 0]): void {
|
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
|
const gl = this.gl
|
||||||
this.camera = camera
|
this.camera = camera
|
||||||
this.quadCount = 0
|
this.quadCount = 0
|
||||||
|
|
@ -287,6 +355,7 @@ export class SpriteRenderer {
|
||||||
* @param options - tint and flipping.
|
* @param options - tint and flipping.
|
||||||
*/
|
*/
|
||||||
draw(frame: AtlasFrame, x: number, y: number, options: DrawOptions = {}): void {
|
draw(frame: AtlasFrame, x: number, y: number, options: DrawOptions = {}): void {
|
||||||
|
if (this.disposed) return
|
||||||
const page = options.atlas ?? this.defaultAtlas
|
const page = options.atlas ?? this.defaultAtlas
|
||||||
// A page switch splits the batch; because it never reorders quads, painter's
|
// A page switch splits the batch; because it never reorders quads, painter's
|
||||||
// order is preserved across pages.
|
// order is preserved across pages.
|
||||||
|
|
@ -316,6 +385,7 @@ export class SpriteRenderer {
|
||||||
* @param color - `[r, g, b, a]` in 0..1.
|
* @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 {
|
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) {
|
if (this.batchAtlas !== this.whiteAtlas) {
|
||||||
this.flush()
|
this.flush()
|
||||||
this.batchAtlas = this.whiteAtlas
|
this.batchAtlas = this.whiteAtlas
|
||||||
|
|
@ -368,7 +438,7 @@ export class SpriteRenderer {
|
||||||
/** Submit every queued quad. */
|
/** Submit every queued quad. */
|
||||||
flush(): void {
|
flush(): void {
|
||||||
const gl = this.gl
|
const gl = this.gl
|
||||||
if (this.quadCount === 0) return
|
if (this.disposed || this.quadCount === 0) return
|
||||||
gl.useProgram(this.program)
|
gl.useProgram(this.program)
|
||||||
gl.bindVertexArray(this.vao)
|
gl.bindVertexArray(this.vao)
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer)
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer)
|
||||||
|
|
@ -383,6 +453,31 @@ export class SpriteRenderer {
|
||||||
this.quadCount = 0
|
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.
|
* 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.
|
* 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 gl - the context.
|
||||||
* @param vertexSource - vertex shader source.
|
* @param vertexSource - vertex shader source.
|
||||||
* @param fragmentSource - fragment shader source.
|
* @param fragmentSource - fragment shader source.
|
||||||
|
|
@ -417,17 +515,28 @@ function createProgram(gl: WebGL2RenderingContext, vertexSource: string, fragmen
|
||||||
gl.shaderSource(shader, source)
|
gl.shaderSource(shader, source)
|
||||||
gl.compileShader(shader)
|
gl.compileShader(shader)
|
||||||
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS) !== true) {
|
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
|
return shader
|
||||||
}
|
}
|
||||||
const program = gl.createProgram()
|
const program = gl.createProgram()
|
||||||
if (program === null) throw new RendererError('could not create program')
|
if (program === null) throw new RendererError('could not create program')
|
||||||
gl.attachShader(program, compile(gl.VERTEX_SHADER, vertexSource))
|
const vs = compile(gl.VERTEX_SHADER, vertexSource)
|
||||||
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragmentSource))
|
const fs = compile(gl.FRAGMENT_SHADER, fragmentSource)
|
||||||
|
gl.attachShader(program, vs)
|
||||||
|
gl.attachShader(program, fs)
|
||||||
gl.linkProgram(program)
|
gl.linkProgram(program)
|
||||||
if (gl.getProgramParameter(program, gl.LINK_STATUS) !== true) {
|
const linked = gl.getProgramParameter(program, gl.LINK_STATUS) === true
|
||||||
throw new RendererError(`program link failed: ${gl.getProgramInfoLog(program) ?? 'unknown'}`)
|
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
|
return program
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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])
|
renderer.drawSolid(player.x - MARKER_WIDTH / 2, player.y - 6, MARKER_WIDTH, 6, [0.35, 0.25, 0.15, 1])
|
||||||
} else {
|
} else {
|
||||||
const direction = facingToDirection(player.facing, character.directions)
|
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 group = character.groups[groupIndex] ?? character.groups[character.walk + direction]
|
||||||
const frameIndex = player.moving
|
const frameIndex = player.moving
|
||||||
? Math.floor(player.walkFrame / 3) % Math.max(1, group?.length ?? 1)
|
? 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.
|
* @returns true when blocked.
|
||||||
*/
|
*/
|
||||||
function collides(grid: CollisionGrid, x: number, y: number): boolean {
|
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
|
if (isBlockedAt(grid, x + (dx * FEET_WIDTH) / 2, y + (dy * FEET_HEIGHT) / 2)) return true
|
||||||
}
|
}
|
||||||
return false
|
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 x - screen x direction, -1..1.
|
||||||
* @param y - screen y direction, -1..1 (down positive).
|
* @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 {
|
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
|
return Math.round((angle / (Math.PI / 4)) + 8) % 8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1084,7 +1095,15 @@ async function boot(): Promise<void> {
|
||||||
if (runtime === null) continue
|
if (runtime === null) continue
|
||||||
publish(runtime, packBase)
|
publish(runtime, packBase)
|
||||||
void populatePackSelectors(packBase, { act: runtime.act, label: runtime.quadrant })
|
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.
|
// The first frame waits only for the spawn-area pages.
|
||||||
await runtime.loadPages(renderer, runtime.priorityPages, () => {
|
await runtime.loadPages(renderer, runtime.priorityPages, () => {
|
||||||
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
|
state.pagesLoaded = runtime.pages.filter(page => page !== null).length
|
||||||
|
|
@ -1100,7 +1119,15 @@ async function boot(): Promise<void> {
|
||||||
for (const base of bases) {
|
for (const base of bases) {
|
||||||
try {
|
try {
|
||||||
status.textContent = `正在读取归档 ${base}(HTTP range)并解码 …`
|
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', ''))
|
const runtime = await loadLiveRuntime(base, act, renderer, param('quadrant', ''))
|
||||||
publish(runtime, base)
|
publish(runtime, base)
|
||||||
runScene(runtime, renderer, started)
|
runScene(runtime, renderer, started)
|
||||||
|
|
|
||||||
|
|
@ -323,9 +323,10 @@ function decodePaletteMember(name: string, bytes: Uint8Array): Palette {
|
||||||
return { rgb: pl2.rgb, size: 256 }
|
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 running: GameLoop | null = null
|
||||||
let input: KeyboardInput | null = null
|
let input: KeyboardInput | null = null
|
||||||
|
let activeRenderer: SpriteRenderer | null = null
|
||||||
|
|
||||||
/** Fetch a URL as bytes. */
|
/** Fetch a URL as bytes. */
|
||||||
async function fetchBytes(url: string): Promise<Uint8Array> {
|
async function fetchBytes(url: string): Promise<Uint8Array> {
|
||||||
|
|
@ -368,7 +369,17 @@ function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement):
|
||||||
|
|
||||||
running?.stop()
|
running?.stop()
|
||||||
input?.detach()
|
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'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.
|
// the actor can be drawn at its depth among the walls without a second bind.
|
||||||
const combined: SpriteSheet = sources.actor === null
|
const combined: SpriteSheet = sources.actor === null
|
||||||
|
|
@ -1214,4 +1225,11 @@ function boot(): void {
|
||||||
status.textContent = '拖入 .mpq,或用 ?mpq=<url> / ?data=<fixture dir> 指定数据源。'
|
status.textContent = '拖入 .mpq,或用 ?mpq=<url> / ?data=<fixture dir> 指定数据源。'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
running?.stop()
|
||||||
|
input?.detach()
|
||||||
|
activeRenderer?.dispose()
|
||||||
|
activeRenderer = null
|
||||||
|
})
|
||||||
|
|
||||||
boot()
|
boot()
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,7 @@ const DEMO_MONSTERS: MonsterStats[] = monsterStatsFromTable(parseTable([
|
||||||
|
|
||||||
let running: GameLoop | null = null
|
let running: GameLoop | null = null
|
||||||
let input: KeyboardInput | null = null
|
let input: KeyboardInput | null = null
|
||||||
|
let activeRenderer: SpriteRenderer | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch bytes, or null when the file is absent.
|
* Fetch bytes, or null when the file is absent.
|
||||||
|
|
@ -325,7 +326,17 @@ function startScene(
|
||||||
|
|
||||||
running?.stop()
|
running?.stop()
|
||||||
input?.detach()
|
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
|
const combined: SpriteSheet = sources.actor === null
|
||||||
? scene.sheet
|
? scene.sheet
|
||||||
: { groups: [...scene.sheet.groups, ...sources.actor.sheet.groups], width: null }
|
: { 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). */
|
/** Whether the page is running inside a document (avoids running under Node). */
|
||||||
if (typeof document !== 'undefined') boot()
|
if (typeof document !== 'undefined') boot()
|
||||||
|
|
|
||||||
27
src/walk.ts
27
src/walk.ts
|
|
@ -140,6 +140,10 @@ function collides(x: number, y: number, walls: readonly Rect[]): boolean {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let activeLoop: GameLoop | null = null
|
||||||
|
let activeInput: KeyboardInput | null = null
|
||||||
|
let activeRenderer: SpriteRenderer | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Boot the scene from an archive.
|
* Boot the scene from an archive.
|
||||||
*
|
*
|
||||||
|
|
@ -173,7 +177,19 @@ async function start(archive: MpqArchive): Promise<void> {
|
||||||
const standFrames: readonly (readonly AtlasFrame[])[] | null =
|
const standFrames: readonly (readonly AtlasFrame[])[] | null =
|
||||||
stand === null ? null : atlas.groups.slice(walk.groups.length)
|
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)
|
renderer.setAtlas(atlas)
|
||||||
|
|
||||||
const walls = buildWalls()
|
const walls = buildWalls()
|
||||||
|
|
@ -239,6 +255,8 @@ async function start(archive: MpqArchive): Promise<void> {
|
||||||
+ ` · ${String(atlas.groups.length)} groups / ${String(walk.groups.reduce((n, g) => n + g.frames.length, 0))} walk frames`
|
+ ` · ${String(atlas.groups.length)} groups / ${String(walk.groups.reduce((n, g) => n + g.frames.length, 0))} walk frames`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
activeInput = input
|
||||||
|
activeLoop = loop
|
||||||
loop.start()
|
loop.start()
|
||||||
state.ready = true
|
state.ready = true
|
||||||
status.textContent = 'WASD / 方向键移动。角色为真实暗黑1 素材(战士行走/站立,宽度 96 = 游戏表数值);地面与墙为占位几何。'
|
status.textContent = 'WASD / 方向键移动。角色为真实暗黑1 素材(战士行走/站立,宽度 96 = 游戏表数值);地面与墙为占位几何。'
|
||||||
|
|
@ -286,4 +304,11 @@ function boot(): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
activeLoop?.stop()
|
||||||
|
activeInput?.detach()
|
||||||
|
activeRenderer?.dispose()
|
||||||
|
activeRenderer = null
|
||||||
|
})
|
||||||
|
|
||||||
boot()
|
boot()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue