437 lines
14 KiB
TypeScript
437 lines
14 KiB
TypeScript
/**
|
|
* Integration tests for Diablo II v1.13c Dynamic Lighting & Environment System.
|
|
*
|
|
* Verifies:
|
|
* 1. Single-pass draw call batching with dynamic lighting enabled (drawCalls === 1).
|
|
* 2. Exact 10-float vertex stride invariant (FLOATS_PER_VERTEX === 10).
|
|
* 3. Lazy 48x48 RGBA8 lightmap texture allocation upon first setLighting() invocation.
|
|
* 4. Additive blend mode bypass (missiles & glowing spell overlays stay radiant in darkness).
|
|
* 5. Environment ambient calculations for outdoor, indoor, and Act IV levels.
|
|
* 6. LightGrid obstacle attenuation & Alpha Max Plus Beta Min falloff.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
SpriteRenderer,
|
|
FLOATS_PER_VERTEX,
|
|
type LightingConfig,
|
|
} from '../src/render/renderer.ts'
|
|
import { LightGrid, LIGHT_GRID_SIZE } from '../src/game/engine/light-grid.ts'
|
|
import { Environment } from '../src/game/engine/environment.ts'
|
|
import { getLevelLighting } from '../src/game/levels-meta.ts'
|
|
import { COLLIDE_VISIBLE, COLLIDE_WALL, type CollisionGrid } from '../src/game/d2map.ts'
|
|
|
|
interface MockResource {
|
|
id: number
|
|
type: string
|
|
}
|
|
|
|
function createMockCanvas(): {
|
|
canvas: any
|
|
stats: {
|
|
createdTextures: Set<MockResource>
|
|
glDrawCalls: number
|
|
}
|
|
} {
|
|
let nextId = 1
|
|
const stats = {
|
|
createdTextures: new Set<MockResource>(),
|
|
glDrawCalls: 0,
|
|
}
|
|
|
|
const gl: any = {
|
|
VERTEX_SHADER: 35633,
|
|
FRAGMENT_SHADER: 35632,
|
|
COMPILE_STATUS: 35713,
|
|
LINK_STATUS: 35714,
|
|
MAX_TEXTURE_SIZE: 3379,
|
|
MAX_TEXTURE_IMAGE_UNITS: 34930,
|
|
TEXTURE_2D: 3553,
|
|
RGBA: 6408,
|
|
RGBA8: 32856,
|
|
R8: 33321,
|
|
RED: 6403,
|
|
UNSIGNED_BYTE: 5121,
|
|
UNSIGNED_INT: 5125,
|
|
FLOAT: 5126,
|
|
ARRAY_BUFFER: 34962,
|
|
ELEMENT_ARRAY_BUFFER: 34963,
|
|
DYNAMIC_DRAW: 35048,
|
|
STATIC_DRAW: 35044,
|
|
TRIANGLES: 4,
|
|
TEXTURE_MIN_FILTER: 10241,
|
|
TEXTURE_MAG_FILTER: 10240,
|
|
TEXTURE_WRAP_S: 10242,
|
|
TEXTURE_WRAP_T: 10243,
|
|
CLAMP_TO_EDGE: 33071,
|
|
NEAREST: 9728,
|
|
LINEAR: 9729,
|
|
UNPACK_ALIGNMENT: 3317,
|
|
BLEND: 3042,
|
|
SRC_ALPHA: 770,
|
|
ONE: 1,
|
|
ONE_MINUS_SRC_ALPHA: 771,
|
|
DEPTH_TEST: 2929,
|
|
COLOR_BUFFER_BIT: 16384,
|
|
TEXTURE0: 33984,
|
|
|
|
drawingBufferWidth: 1280,
|
|
drawingBufferHeight: 720,
|
|
|
|
isContextLost: () => false,
|
|
getParameter: (param: number) => {
|
|
if (param === 34930) return 16 // MAX_TEXTURE_IMAGE_UNITS
|
|
if (param === 3379) return 8192 // MAX_TEXTURE_SIZE
|
|
return 16
|
|
},
|
|
|
|
createProgram: () => ({ id: nextId++, type: 'program' }),
|
|
deleteProgram: () => {},
|
|
attachShader: () => {},
|
|
detachShader: () => {},
|
|
linkProgram: () => {},
|
|
getProgramParameter: () => true,
|
|
getProgramInfoLog: () => '',
|
|
useProgram: () => {},
|
|
|
|
createShader: (type: number) => ({ id: nextId++, type: type === 35633 ? 'vertexShader' : 'fragmentShader' }),
|
|
shaderSource: () => {},
|
|
compileShader: () => {},
|
|
getShaderParameter: () => true,
|
|
getShaderInfoLog: () => '',
|
|
deleteShader: () => {},
|
|
|
|
createVertexArray: () => ({ id: nextId++, type: 'vao' }),
|
|
deleteVertexArray: () => {},
|
|
bindVertexArray: () => {},
|
|
|
|
createBuffer: () => ({ id: nextId++, type: 'buffer' }),
|
|
deleteBuffer: () => {},
|
|
bindBuffer: () => {},
|
|
bufferData: () => {},
|
|
bufferSubData: () => {},
|
|
|
|
getAttribLocation: (_p: any, name: string) => {
|
|
const map: Record<string, number> = {
|
|
a_position: 0,
|
|
a_uv: 1,
|
|
a_tint: 2,
|
|
a_unit: 3,
|
|
a_paletteRow: 4,
|
|
}
|
|
return map[name] ?? -1
|
|
},
|
|
enableVertexAttribArray: () => {},
|
|
vertexAttribPointer: () => {},
|
|
vertexAttribIPointer: () => {},
|
|
|
|
createTexture: () => {
|
|
const res: MockResource = { id: nextId++, type: 'texture' }
|
|
stats.createdTextures.add(res)
|
|
return res
|
|
},
|
|
deleteTexture: (t: MockResource) => { stats.createdTextures.delete(t) },
|
|
bindTexture: () => {},
|
|
texParameteri: () => {},
|
|
texImage2D: () => {},
|
|
texStorage2D: () => {},
|
|
texSubImage2D: () => {},
|
|
pixelStorei: () => {},
|
|
|
|
getUniformLocation: (_p: any, name: string) => ({ name, id: nextId++ }),
|
|
uniform2f: () => {},
|
|
uniform4f: () => {},
|
|
uniform1f: () => {},
|
|
uniform1i: () => {},
|
|
uniform1iv: () => {},
|
|
|
|
enable: () => {},
|
|
disable: () => {},
|
|
blendFunc: () => {},
|
|
viewport: () => {},
|
|
clearColor: () => {},
|
|
clear: () => {},
|
|
activeTexture: () => {},
|
|
drawArrays: () => { stats.glDrawCalls += 1 },
|
|
drawElements: () => { stats.glDrawCalls += 1 },
|
|
}
|
|
|
|
const canvas: any = {
|
|
getContext: (type: string) => (type === 'webgl2' ? gl : null),
|
|
addEventListener: () => {},
|
|
removeEventListener: () => {},
|
|
width: 1280,
|
|
height: 720,
|
|
drawingBufferWidth: 1280,
|
|
drawingBufferHeight: 720,
|
|
}
|
|
|
|
return { canvas, stats }
|
|
}
|
|
|
|
describe('Scene Lighting & Environment Integration', () => {
|
|
it('maintains strict 10-float vertex stride invariant', () => {
|
|
expect(FLOATS_PER_VERTEX).toBe(10)
|
|
})
|
|
|
|
it('allocates lightmap texture lazily on first setLighting call', () => {
|
|
const { canvas } = createMockCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
|
|
// Non-lighting setup has 3 default textures (atlas, white, palette)
|
|
const initialTextures = (renderer as any).allocatedTextures.size
|
|
expect(initialTextures).toBe(3)
|
|
|
|
// Clear and draw unlit quad
|
|
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
|
renderer.drawSolid(0, 0, 100, 100, [1, 1, 1, 1])
|
|
renderer.flush()
|
|
expect((renderer as any).allocatedTextures.size).toBe(3)
|
|
|
|
// Set lighting
|
|
const lightGrid = new LightGrid()
|
|
lightGrid.centerOnSubTile(100, 100)
|
|
renderer.setLighting({
|
|
ambient: [0.2, 0.2, 0.2],
|
|
originSubX: lightGrid.originSubX,
|
|
originSubY: lightGrid.originSubY,
|
|
sceneOriginX: 0,
|
|
sceneOriginY: 0,
|
|
lightmap: lightGrid.exportTextureBuffer(),
|
|
})
|
|
|
|
// Lightmap texture is now allocated (3 + 1 = 4)
|
|
expect((renderer as any).allocatedTextures.size).toBe(4)
|
|
|
|
renderer.dispose()
|
|
})
|
|
|
|
it('maintains single-pass draw call batching when dynamic lighting is active', () => {
|
|
const { canvas, stats } = createMockCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
const lightGrid = new LightGrid()
|
|
lightGrid.centerOnSubTile(200, 200)
|
|
|
|
lightGrid.addLight({
|
|
subTileX: 200,
|
|
subTileY: 200,
|
|
radius: 55,
|
|
intensity: 255,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
})
|
|
|
|
renderer.begin({ x: 100, y: 100, zoom: 1 }, [0.03, 0.03, 0.04])
|
|
renderer.setLighting({
|
|
ambient: [0.15, 0.15, 0.18],
|
|
originSubX: lightGrid.originSubX,
|
|
originSubY: lightGrid.originSubY,
|
|
sceneOriginX: 0,
|
|
sceneOriginY: 0,
|
|
lightmap: lightGrid.exportTextureBuffer(),
|
|
})
|
|
|
|
// Draw world floor tiles and entities
|
|
for (let i = 0; i < 50; i += 1) {
|
|
renderer.drawSolid(i * 10, i * 5, 80, 40, [0.8, 0.8, 0.8, 1])
|
|
}
|
|
|
|
// Flush batch
|
|
renderer.flush()
|
|
|
|
// All 50 quads rendered in exactly 1 single draw call
|
|
expect(renderer.drawCalls).toBe(1)
|
|
expect(stats.glDrawCalls).toBe(1)
|
|
expect(renderer.quadsSubmitted).toBe(50)
|
|
|
|
renderer.dispose()
|
|
})
|
|
|
|
it('supports unshaded UI rendering by setting lighting to null before minimap', () => {
|
|
const { canvas, stats } = createMockCanvas()
|
|
const renderer = new SpriteRenderer(canvas)
|
|
const lightGrid = new LightGrid()
|
|
lightGrid.centerOnSubTile(200, 200)
|
|
|
|
renderer.begin({ x: 100, y: 100, zoom: 1 })
|
|
|
|
// 1. World pass with dynamic lighting
|
|
renderer.setLighting({
|
|
ambient: [0.1, 0.1, 0.1],
|
|
originSubX: lightGrid.originSubX,
|
|
originSubY: lightGrid.originSubY,
|
|
sceneOriginX: 0,
|
|
sceneOriginY: 0,
|
|
lightmap: lightGrid.exportTextureBuffer(),
|
|
})
|
|
renderer.drawSolid(0, 0, 50, 50, [1, 1, 1, 1])
|
|
|
|
// 2. Automap / Minimap pass without lighting
|
|
renderer.setLighting(null)
|
|
renderer.drawSolid(10, 10, 20, 20, [0, 1, 0, 1])
|
|
|
|
renderer.flush()
|
|
|
|
// Total draw calls: 1 for lit world + 1 for unshaded automap = 2
|
|
expect(renderer.drawCalls).toBe(2)
|
|
expect(stats.glDrawCalls).toBe(2)
|
|
|
|
renderer.dispose()
|
|
})
|
|
|
|
it('evaluates authentic 1.13c ambient lighting for indoor and outdoor levels', () => {
|
|
const env = new Environment()
|
|
|
|
// Level 1: Rogue Encampment (Act 1 outdoor town)
|
|
const townLight = env.tick(1, 1)
|
|
expect(townLight.isInside).toBe(false)
|
|
expect(townLight.intensity).toBeGreaterThan(0)
|
|
|
|
// Level 37: Catacombs Level 4 (Act 1 indoor dungeon, Andariel lair)
|
|
const cataLight = env.tick(37, 1)
|
|
expect(cataLight.isInside).toBe(true)
|
|
const cataMeta = getLevelLighting(37)
|
|
expect(cataMeta).toBeDefined()
|
|
expect(cataLight.intensity).toBe(cataMeta!.intensity)
|
|
expect(cataLight.red).toBe(cataMeta!.red)
|
|
expect(cataLight.green).toBe(cataMeta!.green)
|
|
expect(cataLight.blue).toBe(cataMeta!.blue)
|
|
|
|
// Level 103: The Pandemonium Fortress (Act 4 town)
|
|
// Lerps towards target intensity 128
|
|
for (let t = 0; t < 200; t += 1) {
|
|
env.tick(103, 4)
|
|
}
|
|
expect(env.currentIntensity).toBe(128)
|
|
|
|
// Level 104: Outer Steppes (Act 4 wilderness)
|
|
// Advance several ticks to let lerp converge to target 64
|
|
for (let t = 0; t < 200; t += 1) {
|
|
env.tick(104, 4)
|
|
}
|
|
expect(env.currentIntensity).toBe(64)
|
|
})
|
|
|
|
it('illuminates emitter cell and wall front-faces while casting shadows behind walls via radial ray-marching', () => {
|
|
const grid = new LightGrid()
|
|
grid.centerOnSubTile(200, 200)
|
|
|
|
const collision: CollisionGrid = {
|
|
cellsX: 80,
|
|
cellsY: 80,
|
|
originX: 0,
|
|
originY: 0,
|
|
gridWidth: 80 * 5,
|
|
blocked: new Uint8Array(80 * 5 * 80 * 5),
|
|
collisionMasks: new Uint16Array(80 * 5 * 80 * 5),
|
|
}
|
|
|
|
// Place a 2-cell thick sight-blocking wall at cells (25, 24) and (26, 24),
|
|
// and also touch the player's own cell (24, 24) to test hugging a wall
|
|
for (const cx of [24, 25, 26]) {
|
|
const cellStartSubX = grid.originSubX + cx * 8
|
|
const cellStartSubY = grid.originSubY + 24 * 8
|
|
for (let dy = 2; dy <= 5; dy += 2) {
|
|
for (let dx = 2; dx <= 5; dx += 2) {
|
|
const idx = (cellStartSubY + dy) * collision.gridWidth + (cellStartSubX + dx)
|
|
collision.collisionMasks![idx] = COLLIDE_WALL | COLLIDE_VISIBLE
|
|
}
|
|
}
|
|
}
|
|
|
|
grid.updateObstacles(collision)
|
|
|
|
const centerIdx = 24 * LIGHT_GRID_SIZE + 24
|
|
const wallFrontIdx = 24 * LIGHT_GRID_SIZE + 25
|
|
const wallSecondIdx = 24 * LIGHT_GRID_SIZE + 26
|
|
const behindWallIdx = 24 * LIGHT_GRID_SIZE + 27
|
|
|
|
expect(grid.obstacle[centerIdx]).toBe(4)
|
|
expect(grid.obstacle[wallFrontIdx]).toBe(4)
|
|
expect(grid.obstacle[wallSecondIdx]).toBe(4)
|
|
expect(grid.obstacle[behindWallIdx]).toBe(0)
|
|
|
|
// Add player torch at center (200, 200)
|
|
grid.addLight({
|
|
subTileX: 200,
|
|
subTileY: 200,
|
|
radius: 48,
|
|
intensity: 255,
|
|
red: 255,
|
|
green: 255,
|
|
blue: 255,
|
|
})
|
|
|
|
// 1. Player's own cell (24, 24) is NEVER self-occluded when hugging a wall
|
|
expect(grid.intensity[centerIdx]).toBeGreaterThan(200)
|
|
|
|
// 2. Front face of the wall (25, 24) receives bright illumination (no black blotches on walls)
|
|
expect(grid.intensity[wallFrontIdx]).toBeGreaterThan(140)
|
|
|
|
// 3. Second layer of the wall (26, 24) is in penumbra (attenuated by wallFront)
|
|
expect(grid.intensity[wallSecondIdx]).toBeLessThan(grid.intensity[wallFrontIdx]!)
|
|
|
|
// 4. Floor behind the 2-cell thick wall (27, 24) is completely in shadow (rayObstacle = 4 + 4 = 8)
|
|
expect(grid.intensity[behindWallIdx]).toBe(0)
|
|
|
|
// Texture buffer export produces valid RGBA8 values
|
|
const buf = grid.exportTextureBuffer()
|
|
expect(buf.length).toBe(48 * 48 * 4)
|
|
const frontAlpha = buf[wallFrontIdx * 4 + 3]
|
|
expect(frontAlpha).toBe(grid.intensity[wallFrontIdx])
|
|
})
|
|
|
|
it('supports interactive scene lighting presets and #lighting selector in acts.html', async () => {
|
|
const { LIGHTING_PRESETS, normalizeLightingPreset } = await import('../src/scene/act-scene.ts')
|
|
const fs = await import('node:fs')
|
|
|
|
expect(normalizeLightingPreset(undefined)).toBe('auto')
|
|
expect(normalizeLightingPreset('1')).toBe('auto')
|
|
expect(normalizeLightingPreset('0')).toBe('off')
|
|
expect(normalizeLightingPreset('off')).toBe('off')
|
|
expect(normalizeLightingPreset('noon')).toBe('noon')
|
|
expect(normalizeLightingPreset('night')).toBe('night')
|
|
expect(normalizeLightingPreset('cave')).toBe('cave')
|
|
expect(normalizeLightingPreset('hell')).toBe('hell')
|
|
expect(normalizeLightingPreset('eclipse')).toBe('eclipse')
|
|
|
|
expect(LIGHTING_PRESETS.noon.overrideAmbient?.intensity).toBe(255)
|
|
expect(LIGHTING_PRESETS.cave.overrideAmbient?.intensity).toBe(5)
|
|
expect(LIGHTING_PRESETS.off.enabled).toBe(false)
|
|
|
|
const actsHtml = fs.readFileSync('acts.html', 'utf8')
|
|
expect(actsHtml).toContain('<select id="lighting">')
|
|
for (const preset of Object.keys(LIGHTING_PRESETS)) {
|
|
expect(actsHtml).toContain(`value="${preset}"`)
|
|
}
|
|
}, 15000)
|
|
|
|
it('keeps cave player torch (radius=20 sub-tiles) and wall torches (radius=13 sub-tiles) localized without saturating outer viewport cells', () => {
|
|
const grid = new LightGrid()
|
|
grid.centerOnSubTile(200, 200)
|
|
|
|
// Add calibrated cave player torch at center (200, 200), radius=20 sub-tiles (~240px horizontal radius)
|
|
grid.addLight({
|
|
subTileX: 200,
|
|
subTileY: 200,
|
|
radius: 20,
|
|
intensity: 242,
|
|
red: 255,
|
|
green: 234,
|
|
blue: 196,
|
|
})
|
|
|
|
const centerIdx = 24 * LIGHT_GRID_SIZE + 24
|
|
// 1 cell away (dx=+1, dy=-1 => 8 sub-tiles each => dist=11, 160px horizontally): illuminated
|
|
const nearIdx = 23 * LIGHT_GRID_SIZE + 25
|
|
// 3 cells away (dx=+3, dy=-3 => 24 sub-tiles each => dist=32, 480px horizontally, well within 640px half-screen): completely dark!
|
|
const outerScreenIdx = 21 * LIGHT_GRID_SIZE + 27
|
|
|
|
expect(grid.intensity[centerIdx]).toBeGreaterThan(220)
|
|
expect(grid.intensity[nearIdx]).toBeGreaterThan(80)
|
|
expect(grid.intensity[outerScreenIdx]).toBe(0)
|
|
})
|
|
})
|
|
|