Compare commits

..

No commits in common. "a80ee2cafcfb1ebef947caaf65c50d9874aec6ed" and "cd81a7e686efa67915a1ebf5b66899df877773ee" have entirely different histories.

11 changed files with 49 additions and 842 deletions

View File

@ -14,9 +14,7 @@
"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:collision-orientation": "node scripts/verify-collision-orientation.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: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",

View File

@ -1,204 +0,0 @@
// verify-collision-orientation.ts — 永久断言 DT1 子格阻挡标志位的行序与 OpenDiablo2 / D2MOO 完全一致
//
// 事故与背景(Issue #4):
// DT1 每张瓦片记录末尾有 25 个字节的子格碰撞标志。在原始文件里是「自下而上」存储的:
// 文件顺序的第 0..4 字节是这一格 5×5 足迹的最下面一行(subY=4),第 20..24 字节是最上面一行(subY=0)。
// 之前解码器按 0..24 顺序存入 subTileFlags,导致每个格子内部的阻挡带被上下镜像。
//
// 两个官方/引擎逆向实现互相印证:
// 1. OpenDiablo2 `reference/opendiablo2/d2core/d2map/d2mapengine/map_tile.go:17`:
// var subtileLookup = [5][5]int{
// {20, 21, 22, 23, 24}, // 房间子格 (x, 0) —— 最上面一行,读文件 20..24
// {15, 16, 17, 18, 19},
// {10, 11, 12, 13, 14},
// {5, 6, 7, 8, 9},
// {0, 1, 2, 3, 4}, // 最下面一行,读文件 0..4
// }
// 2. D2MOO `D2Collision.cpp:93 sub_6FD411F0`:
// `pTmp = &v5[5 * (nY - nCappedY + 4) - nX];` 配合循环 `pTmp -= 5;`,对房间第 r 行取文件第 (4-r) 行。
//
// 本脚本作为永久验收测试,确保:
// 1. 解码后的 `subTileFlags[subY * 5 + subX].raw` 严格等于 OpenDiablo2 的 `subtileLookup[subY][subX]`;
// 2. 严格等于 D2MOO 的 `(4 - subY) * 5 + subX` 逆向公式;
// 3. 只有最下行阻挡的文件(0..4=1),解出后只在 subY=4 阻挡;
// 4. 只有最上行阻挡的文件(20..24=1),解出后只在 subY=0 阻挡。
//
// 运行:npx tsx scripts/verify-collision-orientation.ts
import { decodeDt1, SUB_TILE_GRID } from '../src/formats/dt1.ts'
/**
* OpenDiablo2 canonical 5×5 lookup table.
* `reference/opendiablo2/d2core/d2map/d2mapengine/map_tile.go:17`
*/
const OD2_SUBTILE_LOOKUP = [
[20, 21, 22, 23, 24], // 房间子格 (x, 0) —— 最上面一行,读文件 20..24
[15, 16, 17, 18, 19],
[10, 11, 12, 13, 14],
[5, 6, 7, 8, 9],
[0, 1, 2, 3, 4], // 房间子格 (x, 4) —— 最下面一行,读文件 0..4
] as const
/**
* D2MOO canonical formula: `(4 - subY) * 5 + subX`.
*/
function d2mooLookup(subX: number, subY: number): number {
return (4 - subY) * 5 + subX
}
/**
* 构造一个仅包含头部和一个瓦片记录的最小合法 DT1 二进制字节数组。
* 瓦片的 25 字节 subTileFlags 按其在文件中的偏移填入 0..24。
*/
function createSyntheticDt1(customFlags?: (fileIndex: number) => number): Uint8Array {
const tileDataStart = 276
const tileRecordSize = 96
const blockHeaderSize = 20
const blockDataStart = tileDataStart + tileRecordSize + blockHeaderSize
const totalLength = blockDataStart + 32
const buf = new Uint8Array(totalLength)
const view = new DataView(buf.buffer)
view.setInt32(0, 7, true)
view.setInt32(4, 6, true)
view.setInt32(268, 1, true) // 1 tile
view.setInt32(272, tileDataStart, true)
const at = tileDataStart
view.setInt32(at + 0, 0, true) // direction
view.setInt16(at + 4, 0, true) // roof height
view.setUint16(at + 6, 0, true) // material flags
view.setInt32(at + 8, -80, true) // height
view.setInt32(at + 12, 160, true) // width
view.setInt32(at + 20, 0, true) // type
view.setInt32(at + 24, 1, true) // style
view.setInt32(at + 28, 0, true) // sequence
view.setInt32(at + 32, 0, true) // rarity
// 25 字节 flags 在 at + 40
for (let i = 0; i < 25; i += 1) {
buf[at + 40 + i] = customFlags ? customFlags(i) : i
}
const blockHeaderPointer = tileDataStart + tileRecordSize
view.setInt32(at + 72, blockHeaderPointer, true)
view.setInt32(at + 76, blockHeaderSize, true)
view.setInt32(at + 80, 1, true) // 1 block
// Block header
view.setInt16(blockHeaderPointer + 0, 0, true) // x
view.setInt16(blockHeaderPointer + 2, 0, true) // y
buf[blockHeaderPointer + 6] = 0 // gridX
buf[blockHeaderPointer + 7] = 0 // gridY
view.setInt16(blockHeaderPointer + 8, 0, true) // format (RLE)
view.setInt32(blockHeaderPointer + 10, 4, true) // length
view.setInt32(blockHeaderPointer + 16, blockDataStart - blockHeaderPointer, true)
return buf
}
const checks: { name: string; ok: boolean; detail: string }[] = []
function check(name: string, ok: boolean, detail: string): void {
checks.push({ name, ok, detail })
console.log(`${ok ? 'ok ' : 'FAIL'} ${name} — ${detail}`)
}
function main(): void {
console.log('== DT1 Collision Orientation Verification ==\n')
// 1. OD2 lookup table vs D2MOO formula equivalence
let od2EqualsD2moo = true
for (let subY = 0; subY < 5; subY += 1) {
for (let subX = 0; subX < 5; subX += 1) {
if (OD2_SUBTILE_LOOKUP[subY][subX] !== d2mooLookup(subX, subY)) {
od2EqualsD2moo = false
}
}
}
check(
'reference parity',
od2EqualsD2moo,
'OpenDiablo2 subtileLookup and D2MOO formula (4-subY)*5+subX produce identical indices for all 25 sub-tiles',
)
// 2. Decode synthetic DT1 with sequential file bytes 0..24
const dt1Bytes = createSyntheticDt1()
const decoded = decodeDt1(dt1Bytes)
check('synthetic decode', decoded.tiles.length === 1, 'decoded 1 synthetic tile')
const tile = decoded.tiles[0]!
let mismatchCount = 0
const mismatches: string[] = []
for (let subY = 0; subY < SUB_TILE_GRID; subY += 1) {
for (let subX = 0; subX < SUB_TILE_GRID; subX += 1) {
const actualRaw = tile.subTileFlags[subY * SUB_TILE_GRID + subX]?.raw
const expectedFileIndex = OD2_SUBTILE_LOOKUP[subY][subX]
if (actualRaw !== expectedFileIndex) {
mismatchCount += 1
mismatches.push(`subTile(${subX}, ${subY}): actual=${String(actualRaw)}, expected=${String(expectedFileIndex)}`)
}
}
}
check(
'row order matches OD2 subtileLookup',
mismatchCount === 0,
mismatchCount === 0
? 'all 25 sub-tiles mapped from file index to room grid row-order correctly'
: `${String(mismatchCount)} sub-tiles mismatched: ${mismatches.join(', ')}`,
)
// 3. Verify top row (subY = 0) specifically gets file bytes 20..24
const topRowActual = [0, 1, 2, 3, 4].map(x => tile.subTileFlags[0 * 5 + x]?.raw)
const topRowExpected = [20, 21, 22, 23, 24]
check(
'top row mapping (subY=0)',
JSON.stringify(topRowActual) === JSON.stringify(topRowExpected),
`room row 0 (top) reads file bytes [${topRowActual.join(', ')}], expected [${topRowExpected.join(', ')}]`,
)
// 4. Verify bottom row (subY = 4) specifically gets file bytes 0..4
const bottomRowActual = [0, 1, 2, 3, 4].map(x => tile.subTileFlags[4 * 5 + x]?.raw)
const bottomRowExpected = [0, 1, 2, 3, 4]
check(
'bottom row mapping (subY=4)',
JSON.stringify(bottomRowActual) === JSON.stringify(bottomRowExpected),
`room row 4 (bottom) reads file bytes [${bottomRowActual.join(', ')}], expected [${bottomRowExpected.join(', ')}]`,
)
// 5. Semantic directional test: only file bottom row (0..4) blocked -> decoded subY=4 must block, subY=0 must NOT block
const fileBottomBlockedDt1 = createSyntheticDt1(fileIdx => (fileIdx < 5 ? 1 : 0))
const decodedBottomBlocked = decodeDt1(fileBottomBlockedDt1).tiles[0]!
const subY4Blocked = [0, 1, 2, 3, 4].every(x => decodedBottomBlocked.subTileFlags[4 * 5 + x]?.blockWalk === true)
const subY0Unblocked = [0, 1, 2, 3, 4].every(x => decodedBottomBlocked.subTileFlags[0 * 5 + x]?.blockWalk === false)
check(
'file bottom row is room bottom row',
subY4Blocked && subY0Unblocked,
`file bytes 0..4 blocked -> decoded subY=4 blocked: ${String(subY4Blocked)}, subY=0 unblocked: ${String(subY0Unblocked)}`,
)
// 6. Semantic directional test: only file top row (20..24) blocked -> decoded subY=0 must block, subY=4 must NOT block
const fileTopBlockedDt1 = createSyntheticDt1(fileIdx => (fileIdx >= 20 ? 1 : 0))
const decodedTopBlocked = decodeDt1(fileTopBlockedDt1).tiles[0]!
const subY0Blocked = [0, 1, 2, 3, 4].every(x => decodedTopBlocked.subTileFlags[0 * 5 + x]?.blockWalk === true)
const subY4Unblocked = [0, 1, 2, 3, 4].every(x => decodedTopBlocked.subTileFlags[4 * 5 + x]?.blockWalk === false)
check(
'file top row is room top row',
subY0Blocked && subY4Unblocked,
`file bytes 20..24 blocked -> decoded subY=0 blocked: ${String(subY0Blocked)}, subY=4 unblocked: ${String(subY4Unblocked)}`,
)
console.log()
const failures = checks.filter(c => !c.ok)
if (failures.length === 0) {
console.log(`ALL ${String(checks.length)} CHECKS PASSED — DT1 sub-tile orientation verified against OpenDiablo2 and D2MOO`)
process.exit(0)
} else {
console.error(`${String(failures.length)} of ${String(checks.length)} CHECKS FAILED`)
process.exit(1)
}
}
main()

View File

@ -1,322 +0,0 @@
/**
* 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()

View File

@ -31,7 +31,7 @@ const BLOCK_PIXEL_HEIGHT = 32
/** Bytes per block header. */ /** Bytes per block header. */
const BLOCK_HEADER_SIZE = 20 const BLOCK_HEADER_SIZE = 20
/** Sub-tile grid edge (5×5 collision flags per tile). */ /** Sub-tile grid edge (5×5 collision flags per tile). */
export const SUB_TILE_GRID = 5 const SUB_TILE_GRID = 5
/** Bytes of one isometric block. */ /** Bytes of one isometric block. */
const ISOMETRIC_BLOCK_SIZE = 256 const ISOMETRIC_BLOCK_SIZE = 256
/** Row offsets of the isometric diamond, one entry per row. */ /** Row offsets of the isometric diamond, one entry per row. */
@ -286,16 +286,8 @@ export function decodeDt1(data: Uint8Array): Dt1 {
for (let index = 0; index < tileCount; index += 1) { for (let index = 0; index < tileCount; index += 1) {
const at = tileDataStart + index * TILE_RECORD_SIZE const at = tileDataStart + index * TILE_RECORD_SIZE
const subTileFlags: SubTileFlags[] = [] const subTileFlags: SubTileFlags[] = []
for (let subY = 0; subY < SUB_TILE_GRID; subY += 1) { for (let sub = 0; sub < SUB_TILE_GRID * SUB_TILE_GRID; sub += 1) {
for (let subX = 0; subX < SUB_TILE_GRID; subX += 1) { subTileFlags.push(subTileFlagsOf(data[at + 40 + sub]!))
// DT1 sub-tile collision flags are stored bottom-to-top in the binary
// (bytes 0..4 are the bottom row subY=4, bytes 20..24 are the top row subY=0).
// OpenDiablo2 subtileLookup: row 0 -> 20..24, row 4 -> 0..4.
// D2MOO D2Collision.cpp: pTmp = &v5[5 * (nY - nCappedY + 4) - nX].
// Normalize into top-to-bottom row order [subY * 5 + subX].
const fileIndex = (SUB_TILE_GRID - 1 - subY) * SUB_TILE_GRID + subX
subTileFlags.push(subTileFlagsOf(data[at + 40 + fileIndex]!))
}
} }
const blockHeaderPointer = i32(data, at + 72) const blockHeaderPointer = i32(data, at + 72)
const blockHeaderSize = i32(data, at + 76) const blockHeaderSize = i32(data, at + 76)

View File

@ -151,7 +151,6 @@ export async function loadCharacterSheet(
const groups: { frames: SpriteFrame[] }[] = [] const groups: { frames: SpriteFrame[] }[] = []
let skipped = 0 let skipped = 0
for (let direction = 0; direction < cof.numberOfDirections; direction += 1) { for (let direction = 0; direction < cof.numberOfDirections; direction += 1) {
const dir64 = Math.round((direction * 64) / cof.numberOfDirections)
const frames: SpriteFrame[] = [] const frames: SpriteFrame[] = []
for (let index = 0; index < cof.framesPerDirection; index += 1) { for (let index = 0; index < cof.framesPerDirection; index += 1) {
// Union box across the layers that have art for this direction, so every // Union box across the layers that have art for this direction, so every
@ -164,8 +163,7 @@ export async function loadCharacterSheet(
for (let layer = 0; layer < cof.layers.length; layer += 1) { for (let layer = 0; layer < cof.layers.length; layer += 1) {
const sprite = sprites[layer] const sprite = sprites[layer]
if (sprite === null || sprite === undefined) continue if (sprite === null || sprite === undefined) continue
const dccDir = dir64ToDcc(dir64, sprite.directions.length) const layerDirection = sprite.directions[direction % sprite.directions.length]
const layerDirection = sprite.directions[dccDir]
const frame = layerDirection?.frames[index] const frame = layerDirection?.frames[index]
if (frame === undefined) { skipped += 1; continue } if (frame === undefined) { skipped += 1; continue }
left = Math.min(left, layerDirection!.box.left) left = Math.min(left, layerDirection!.box.left)
@ -193,8 +191,7 @@ export async function loadCharacterSheet(
.filter(entry => entry.sprite !== null && entry.sprite !== undefined) .filter(entry => entry.sprite !== null && entry.sprite !== undefined)
for (const entry of ordered) { for (const entry of ordered) {
const sprite = entry.sprite as DccFile const sprite = entry.sprite as DccFile
const dccDir = dir64ToDcc(dir64, sprite.directions.length) const layerDirection = sprite.directions[direction % sprite.directions.length]
const layerDirection = sprite.directions[dccDir]
const frame = layerDirection?.frames[index] const frame = layerDirection?.frames[index]
if (frame === undefined) continue if (frame === undefined) continue
blit(composed, frame.frame, Math.round(layerDirection!.box.left) - boxLeft, Math.round(layerDirection!.box.top) - boxTop) blit(composed, frame.frame, Math.round(layerDirection!.box.left) - boxLeft, Math.round(layerDirection!.box.top) - boxTop)
@ -266,68 +263,17 @@ export function dir64ToCof(direction: number, directions: number): number {
} }
/** /**
* The engine's 64-direction space → DCC direction tables. * Map a screen facing to a COF direction.
* *
* Ported from OpenDiablo2 `d2fileformats/d2dcc/dcc_dir_lookup.go` (`Dir64ToDcc`), * The page's input is eight screen directions starting north, which in the
* which reproduces the engine's internal DCC direction layout. Unlike COF files * engine's 64-direction space are the eight multiples of 8 — so this is
* (which index directions sequentially along a circle), DCC files store directions * {@link dir64ToCof} applied to `facing * 8`. Keeping the conversion in the
* in a hierarchical / permuted order (e.g. 16 directions: 4, 8, 0, 9, 5, 10, ...). * engine's terms means the result stays right if the facing ever gets finer
*/ * (mouse aiming, 16-way input), where the plain "two steps per facing" reading
const DIR64_TO_DCC: Readonly<Record<number, readonly number[]>> = { * would not: with eight facings the two agree, which
4: [ * `npm run verify:dcc` checks for every direction count.
0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2,
2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0,
],
8: [
4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5,
5, 5, 5, 5, 1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6,
6, 6, 6, 6, 2, 2, 2, 2, 2, 2, 2, 2, 7, 7, 7, 7,
7, 7, 7, 7, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4,
],
16: [
4, 4, 8, 8, 8, 8, 0, 0, 0, 0, 9, 9, 9, 9, 5, 5,
5, 5, 10, 10, 10, 10, 1, 1, 1, 1, 11, 11, 11, 11, 6, 6,
6, 6, 12, 12, 12, 12, 2, 2, 2, 2, 13, 13, 13, 13, 7, 7,
7, 7, 14, 14, 14, 14, 3, 3, 3, 3, 15, 15, 15, 15, 4, 4,
],
32: [
4, 16, 16, 8, 8, 17, 17, 0, 0, 18, 18, 9, 9, 19, 19, 5,
5, 20, 20, 10, 10, 21, 21, 1, 1, 22, 22, 11, 11, 23, 23, 6,
6, 24, 24, 12, 12, 25, 25, 2, 2, 26, 26, 13, 13, 27, 27, 7,
7, 28, 28, 14, 14, 29, 29, 3, 3, 30, 30, 15, 15, 31, 31, 4,
],
64: [
4, 32, 16, 33, 8, 34, 17, 35, 0, 36, 18, 37, 9, 38, 19, 39,
5, 40, 20, 41, 10, 42, 21, 43, 1, 44, 22, 45, 11, 46, 23, 47,
6, 48, 24, 49, 12, 50, 25, 51, 2, 52, 26, 53, 13, 54, 27, 55,
7, 56, 28, 57, 14, 58, 29, 59, 3, 60, 30, 61, 15, 62, 31, 63,
],
}
/**
* Map a 64-direction space direction to a DCC direction, the way the engine does.
* *
* @param direction - 0..63. * @param facing - 0 = north, clockwise, 0..7.
* @param directions - directions in the DCC (4, 8, 16, 32 or 64).
* @returns the DCC direction index, 0 when the count is not one the engine uses.
*/
export function dir64ToDcc(direction: number, directions: number): number {
const table = DIR64_TO_DCC[directions]
if (table === undefined) return 0
const index = ((Math.trunc(direction) % 64) + 64) % 64
return table[index] ?? 0
}
/**
* Map a facing (0 = south, turning west: 0..7) to a COF direction.
*
* The game's eight facings start at south and turn clockwise
* (South = 0, SouthWest = 1, West = 2, NorthWest = 3, North = 4, NorthEast = 5, East = 6, SouthEast = 7),
* which in the engine's 64-direction space are the eight multiples of 8.
*
* @param facing - 0 = south, turning west, 0..7.
* @param directions - directions in the COF. * @param directions - directions in the COF.
* @returns the direction index. * @returns the direction index.
*/ */

View File

@ -51,14 +51,6 @@ 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. */
@ -103,8 +95,6 @@ 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
@ -128,20 +118,10 @@ 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, options: RendererOptions = {}) { constructor(canvas: HTMLCanvasElement) {
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
@ -157,8 +137,6 @@ 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
@ -196,23 +174,6 @@ 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
} }
/** /**
@ -222,7 +183,6 @@ 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.
@ -262,7 +222,6 @@ 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
@ -276,7 +235,6 @@ 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)
@ -299,31 +257,6 @@ 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
@ -336,7 +269,6 @@ 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
@ -355,7 +287,6 @@ 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.
@ -385,7 +316,6 @@ 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
@ -438,7 +368,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.disposed || this.quadCount === 0) return if (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)
@ -453,31 +383,6 @@ 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.
* *
@ -500,9 +405,6 @@ 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.
@ -515,28 +417,17 @@ 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) {
const log = gl.getShaderInfoLog(shader) ?? 'unknown' throw new RendererError(`shader compile failed: ${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')
const vs = compile(gl.VERTEX_SHADER, vertexSource) gl.attachShader(program, compile(gl.VERTEX_SHADER, vertexSource))
const fs = compile(gl.FRAGMENT_SHADER, fragmentSource) gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragmentSource))
gl.attachShader(program, vs)
gl.attachShader(program, fs)
gl.linkProgram(program) gl.linkProgram(program)
const linked = gl.getProgramParameter(program, gl.LINK_STATUS) === true if (gl.getProgramParameter(program, gl.LINK_STATUS) !== true) {
const log = linked ? '' : (gl.getProgramInfoLog(program) ?? 'unknown') throw new RendererError(`program link failed: ${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
} }

View File

@ -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 = (player.moving ? character.walk : character.stand) + direction const groupIndex = character.walk + (player.moving ? direction : character.stand)
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,16 +810,6 @@ 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()
})
} }
/** /**
@ -831,8 +821,7 @@ 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 {
if (isBlockedAt(grid, x, y)) return true for (const [dx, dy] of [[-1, -1], [1, -1], [-1, 1], [1, 1]] as const) {
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
@ -860,10 +849,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 = south, turning clockwise (west). * @returns the facing index, 0 = north.
*/ */
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
} }
@ -1095,15 +1084,7 @@ 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
@ -1119,15 +1100,7 @@ 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)

View File

@ -323,10 +323,9 @@ function decodePaletteMember(name: string, bytes: Uint8Array): Palette {
return { rgb: pl2.rgb, size: 256 } return { rgb: pl2.rgb, size: 256 }
} }
/** The running loop, input, and renderer, so a reload can retire the previous scene. */ /** The running loop and input, 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> {
@ -369,17 +368,7 @@ function startScene(sources: MapSources, status: HTMLElement, hud: HTMLElement):
running?.stop() running?.stop()
input?.detach() input?.detach()
activeRenderer?.dispose() const renderer = new SpriteRenderer(canvas)
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
@ -1225,11 +1214,4 @@ 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()

View File

@ -174,7 +174,6 @@ 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.
@ -326,17 +325,7 @@ function startScene(
running?.stop() running?.stop()
input?.detach() input?.detach()
activeRenderer?.dispose() const renderer = new SpriteRenderer(canvas)
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 }
@ -619,14 +608,5 @@ 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()

View File

@ -140,10 +140,6 @@ 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.
* *
@ -177,19 +173,7 @@ 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)
activeLoop?.stop() const renderer = new SpriteRenderer(canvas)
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()
@ -255,8 +239,6 @@ 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 = 游戏表数值);地面与墙为占位几何。'
@ -304,11 +286,4 @@ function boot(): void {
} }
} }
window.addEventListener('beforeunload', () => {
activeLoop?.stop()
activeInput?.detach()
activeRenderer?.dispose()
activeRenderer = null
})
boot() boot()

View File

@ -103,10 +103,7 @@ func dumpDt1(data []byte) (any, error) {
tiles := []any{} tiles := []any{}
for _, tile := range library.Tiles { for _, tile := range library.Tiles {
flags := []any{} flags := []any{}
for subY := 0; subY < 5; subY++ { for _, flag := range tile.SubTileFlags {
for subX := 0; subX < 5; subX++ {
fileIndex := (4-subY)*5 + subX
flag := tile.SubTileFlags[fileIndex]
var raw byte var raw byte
if flag.BlockWalk { if flag.BlockWalk {
raw |= 1 raw |= 1
@ -125,7 +122,6 @@ func dumpDt1(data []byte) (any, error) {
} }
flags = append(flags, int(raw)) flags = append(flags, int(raw))
} }
}
blocks := []any{} blocks := []any{}
for _, block := range tile.Blocks { for _, block := range tile.Blocks {
blocks = append(blocks, map[string]any{ blocks = append(blocks, map[string]any{