196 lines
6.1 KiB
TypeScript
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)
|
|
})
|
|
})
|