Compare commits

...

2 Commits

Author SHA1 Message Date
troytt a80ee2cafc 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 项断言全部通过)
2026-09-14 07:54:51 +00:00
troytt a86b2a4582 fix(collision): 修复 DT1 碰撞网格方向与朝向对齐 (fixes #4)
- 修正 DT1 block 碰撞体 Y 轴解析方向
- 修复 character.ts 角色朝向映射函数与方向角度
- 增加 verify-collision-orientation 自动化验证脚本
- 完善 tools/go-oracle 工具链
2026-09-14 07:54:38 +00:00
11 changed files with 842 additions and 49 deletions

View File

@ -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",

View File

@ -0,0 +1,204 @@
// 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

@ -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()

View File

@ -31,7 +31,7 @@ const BLOCK_PIXEL_HEIGHT = 32
/** Bytes per block header. */
const BLOCK_HEADER_SIZE = 20
/** Sub-tile grid edge (5×5 collision flags per tile). */
const SUB_TILE_GRID = 5
export const SUB_TILE_GRID = 5
/** Bytes of one isometric block. */
const ISOMETRIC_BLOCK_SIZE = 256
/** Row offsets of the isometric diamond, one entry per row. */
@ -286,8 +286,16 @@ export function decodeDt1(data: Uint8Array): Dt1 {
for (let index = 0; index < tileCount; index += 1) {
const at = tileDataStart + index * TILE_RECORD_SIZE
const subTileFlags: SubTileFlags[] = []
for (let sub = 0; sub < SUB_TILE_GRID * SUB_TILE_GRID; sub += 1) {
subTileFlags.push(subTileFlagsOf(data[at + 40 + sub]!))
for (let subY = 0; subY < SUB_TILE_GRID; subY += 1) {
for (let subX = 0; subX < SUB_TILE_GRID; subX += 1) {
// 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 blockHeaderSize = i32(data, at + 76)

View File

@ -151,6 +151,7 @@ export async function loadCharacterSheet(
const groups: { frames: SpriteFrame[] }[] = []
let skipped = 0
for (let direction = 0; direction < cof.numberOfDirections; direction += 1) {
const dir64 = Math.round((direction * 64) / cof.numberOfDirections)
const frames: SpriteFrame[] = []
for (let index = 0; index < cof.framesPerDirection; index += 1) {
// Union box across the layers that have art for this direction, so every
@ -163,7 +164,8 @@ export async function loadCharacterSheet(
for (let layer = 0; layer < cof.layers.length; layer += 1) {
const sprite = sprites[layer]
if (sprite === null || sprite === undefined) continue
const layerDirection = sprite.directions[direction % sprite.directions.length]
const dccDir = dir64ToDcc(dir64, sprite.directions.length)
const layerDirection = sprite.directions[dccDir]
const frame = layerDirection?.frames[index]
if (frame === undefined) { skipped += 1; continue }
left = Math.min(left, layerDirection!.box.left)
@ -191,7 +193,8 @@ export async function loadCharacterSheet(
.filter(entry => entry.sprite !== null && entry.sprite !== undefined)
for (const entry of ordered) {
const sprite = entry.sprite as DccFile
const layerDirection = sprite.directions[direction % sprite.directions.length]
const dccDir = dir64ToDcc(dir64, sprite.directions.length)
const layerDirection = sprite.directions[dccDir]
const frame = layerDirection?.frames[index]
if (frame === undefined) continue
blit(composed, frame.frame, Math.round(layerDirection!.box.left) - boxLeft, Math.round(layerDirection!.box.top) - boxTop)
@ -263,17 +266,68 @@ export function dir64ToCof(direction: number, directions: number): number {
}
/**
* Map a screen facing to a COF direction.
* The engine's 64-direction space → DCC direction tables.
*
* The page's input is eight screen directions starting north, which in the
* engine's 64-direction space are the eight multiples of 8 — so this is
* {@link dir64ToCof} applied to `facing * 8`. Keeping the conversion in the
* 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
* would not: with eight facings the two agree, which
* `npm run verify:dcc` checks for every direction count.
* Ported from OpenDiablo2 `d2fileformats/d2dcc/dcc_dir_lookup.go` (`Dir64ToDcc`),
* which reproduces the engine's internal DCC direction layout. Unlike COF files
* (which index directions sequentially along a circle), DCC files store directions
* in a hierarchical / permuted order (e.g. 16 directions: 4, 8, 0, 9, 5, 10, ...).
*/
const DIR64_TO_DCC: Readonly<Record<number, readonly number[]>> = {
4: [
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 facing - 0 = north, clockwise, 0..7.
* @param direction - 0..63.
* @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.
* @returns the direction index.
*/

View File

@ -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<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 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
}

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])
} 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<void> {
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<void> {
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)

View File

@ -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<Uint8Array> {
@ -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=<url> / ?data=<fixture dir> 指定数据源。'
}
window.addEventListener('beforeunload', () => {
running?.stop()
input?.detach()
activeRenderer?.dispose()
activeRenderer = null
})
boot()

View File

@ -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()

View File

@ -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<void> {
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<void> {
+ ` · ${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()

View File

@ -103,24 +103,28 @@ func dumpDt1(data []byte) (any, error) {
tiles := []any{}
for _, tile := range library.Tiles {
flags := []any{}
for _, flag := range tile.SubTileFlags {
var raw byte
if flag.BlockWalk {
raw |= 1
for subY := 0; subY < 5; subY++ {
for subX := 0; subX < 5; subX++ {
fileIndex := (4-subY)*5 + subX
flag := tile.SubTileFlags[fileIndex]
var raw byte
if flag.BlockWalk {
raw |= 1
}
if flag.BlockLOS {
raw |= 2
}
if flag.BlockJump {
raw |= 4
}
if flag.BlockPlayerWalk {
raw |= 8
}
if flag.BlockLight {
raw |= 32
}
flags = append(flags, int(raw))
}
if flag.BlockLOS {
raw |= 2
}
if flag.BlockJump {
raw |= 4
}
if flag.BlockPlayerWalk {
raw |= 8
}
if flag.BlockLight {
raw |= 32
}
flags = append(flags, int(raw))
}
blocks := []any{}
for _, block := range tile.Blocks {