diablo2-web/tests/light-grid.test.ts

196 lines
6.1 KiB
TypeScript

/**
* Tests for Diablo II v1.13c LightGrid and Environment engine.
* Verifies mathematical parity against decompiled D2Client `dLightMap.c` and D2Common `D2Environment.cpp`.
*/
import { describe, it, expect } from 'vitest'
import {
LightGrid,
fastDistanceApprox,
LIGHT_GRID_SIZE,
SUB_TILES_PER_LIGHT_CELL,
} from '../src/game/engine/light-grid.ts'
import {
Environment,
NORMAL_ENV_CYCLES,
ACT4_ENV_CYCLES,
EnvCycleIndex,
} from '../src/game/engine/environment.ts'
import { COLLIDE_VISIBLE, COLLIDE_BLANK, type CollisionGrid } from '../src/game/d2map.ts'
describe('D2Client v1.13c dLightMap Fast Distance Approximation', () => {
it('implements Alpha Max Plus Beta Min with <= 3.96% theoretical error', () => {
// Exact cardinal distances
expect(fastDistanceApprox(100, 0)).toBe((983 * 100) >> 10) // 95
expect(fastDistanceApprox(0, 100)).toBe((983 * 100) >> 10) // 95
expect(fastDistanceApprox(-100, 0)).toBe((983 * 100) >> 10)
// 45 degree diagonal: dx = 100, dy = 100
// Real hypot = sqrt(20000) ~ 141.42
// Approx = (983 * 100 + 407 * 100) >> 10 = 139000 >> 10 = 135 (error ~4.5% integer vs float)
const approx45 = fastDistanceApprox(100, 100)
expect(approx45).toBe(135)
})
})
describe('D2Client v1.13c LightGrid Attenuation & Blending', () => {
it('correctly creates 48x48 cell buffers with proper dimensions', () => {
const grid = new LightGrid()
expect(grid.intensity.length).toBe(48 * 48)
expect(grid.textureBuffer.length).toBe(48 * 48 * 4)
})
it('calculates linear falloff and attenuation matching 1.13c formula', () => {
const grid = new LightGrid()
grid.centerOnSubTile(200, 200)
// Add a white light at the center: radius 32 sub-tiles, intensity 255
grid.addLight({
subTileX: 200,
subTileY: 200,
radius: 32,
intensity: 255,
red: 255,
green: 255,
blue: 255,
})
// Center cell (24, 24) should be brightest
const centerIdx = 24 * LIGHT_GRID_SIZE + 24
expect(grid.intensity[centerIdx]).toBeGreaterThan(200)
expect(grid.red[centerIdx]).toBe(255)
expect(grid.green[centerIdx]).toBe(255)
expect(grid.blue[centerIdx]).toBe(255)
// Cell at distance > 32 sub-tiles (4 cells away) should have 0 added intensity
const farIdx = (24 + 5) * LIGHT_GRID_SIZE + 24
expect(grid.intensity[farIdx]).toBe(0)
})
it('blocks light completely when obstacle factor >= 16', () => {
const grid = new LightGrid()
grid.centerOnSubTile(200, 200)
// Artificially place an opaque obstacle in front of the light
const targetCellIdx = 24 * LIGHT_GRID_SIZE + 25
grid.obstacle[targetCellIdx] = 16
grid.addLight({
subTileX: 200,
subTileY: 200,
radius: 32,
intensity: 255,
red: 255,
green: 200,
blue: 100,
})
// Target cell was completely blocked
expect(grid.intensity[targetCellIdx]).toBe(0)
})
it('performs intensity-weighted color blending when lights overlap', () => {
const grid = new LightGrid()
grid.centerOnSubTile(200, 200)
// Light 1: Pure Red (255, 0, 0), Intensity 100
grid.addLight({
subTileX: 200,
subTileY: 200,
radius: 24,
intensity: 100,
red: 255,
green: 0,
blue: 0,
})
// Light 2: Pure Blue (0, 0, 255), Intensity 100 at same position
grid.addLight({
subTileX: 200,
subTileY: 200,
radius: 24,
intensity: 100,
red: 0,
green: 0,
blue: 255,
})
const centerIdx = 24 * LIGHT_GRID_SIZE + 24
// Intensities sum up to ~180-200
expect(grid.intensity[centerIdx]).toBeGreaterThan(150)
// Red and Blue should be roughly equal (purple blend)
const r = grid.red[centerIdx]!
const b = grid.blue[centerIdx]!
expect(Math.abs(r - b)).toBeLessThanOrEqual(5)
expect(grid.green[centerIdx]).toBe(0)
})
it('performs 8.8 fixed point bilinear interpolation smoothly', () => {
const grid = new LightGrid()
grid.centerOnSubTile(200, 200)
grid.addLight({
subTileX: 200,
subTileY: 200,
radius: 40,
intensity: 255,
red: 255,
green: 255,
blue: 255,
})
// Sampling at exact sub-tile positions should yield smooth gradient
const sample0 = grid.sampleBilinear(200, 200)
const sample1 = grid.sampleBilinear(208, 200)
const sample2 = grid.sampleBilinear(216, 200)
expect(sample0.intensity).toBeGreaterThan(sample1.intensity)
expect(sample1.intensity).toBeGreaterThan(sample2.intensity)
})
})
describe('D2Common v1.13c Environment Day/Night Simulation', () => {
it('indoor levels return constant ambient from Levels.txt', () => {
const env = new Environment()
// Level 8 (Den of Evil) is inside with Intensity 0 (total darkness without light sources)
const ambientDen = env.tick(8, 1)
expect(ambientDen.isInside).toBe(true)
expect(ambientDen.intensity).toBe(0)
// Level 50 (Harem Level 1) is inside with Intensity 128
const ambientHarem = env.tick(50, 2)
expect(ambientHarem.isInside).toBe(true)
expect(ambientHarem.intensity).toBe(128)
})
it('outdoor levels follow solar angle sine curve', () => {
const env = new Environment()
// Start at noon
env.setTime(EnvCycleIndex.Noon, 0)
const noonAmbient = env.tick(2, 1) // Level 2 = Blood Moor
expect(noonAmbient.isInside).toBe(false)
expect(noonAmbient.intensity).toBeGreaterThan(120)
})
it('Act IV levels lerp towards canonical fixed target intensities', () => {
const env = new Environment()
// Level 103 = Pandemonium Fortress (target 128)
env.currentIntensity = 120
const ambient = env.tick(103, 4)
expect(ambient.intensity).toBe(121) // Increments by 1 towards 128
// Level 104 = Outer Steppes (target 64)
env.currentIntensity = 100
const ambientSteppes = env.tick(104, 4)
expect(ambientSteppes.intensity).toBe(99) // Decrements by 1 towards 64
})
it('Rocky Summit (Level 120) has fixed intensity 200 and custom RGB tint', () => {
const env = new Environment()
const ambient = env.tick(120, 5)
expect(ambient.intensity).toBe(200)
expect(ambient.red).toBe(245)
expect(ambient.green).toBe(240)
expect(ambient.blue).toBe(255)
})
})