Merge branch 'feat/lighting-system' into main: Diablo II v1.13c Glide dynamic lighting system

- P0: Trans === 1 missile additive blending, || 255 stripped, 136-level lighting metadata
- P1: D2Client dLightMap.c & D2Common D2Environment.cpp reverse engineering
- P1: LightGrid (48x48 integer dynamic lightmap, Alpha-Max-Plus-Beta-Min falloff, obstacle attenuation)
- P1: Environment (25Hz day/night solar sine cycle, Act IV targets, indoor static levels)
- P1: WebGL2 multi-texture shader pipeline, zero vertex stride changes (FLOATS_PER_VERTEX = 10)
- P1: Full act-scene integration with player torch, missiles, explosions, additive objects, unshaded minimap overlay
This commit is contained in:
troytt 2026-09-24 08:21:04 +00:00
commit f424a92a1d
12 changed files with 3630 additions and 22 deletions

View File

@ -0,0 +1,118 @@
# Diablo II v1.13c Reverse Engineering Specification: `dLightMap.c` Lighting Pipeline
> **Ground Truth Binary**: `samples/d2/D2Client.dll` (v1.13c, PE ImageBase `0x6fab0000`, file timestamp 2010-03-09).
> **Source File Origin**: `..\Source\D2Client\DRAW\dLightMap.c` (string located at VA `0x6fb84104`).
> **Target Assembly Range**: `0x6fb58000` – `0x6fb5a000` (dLightMap.c), `0x6faef000` – `0x6faefee0` (light debug & map tile interface), `0x6fb10000` – `0x6fb10500` (floor tile rasterizer).
---
## 1. Light Grid Architecture & Topography
### 1.1 Dimensions & Coordinate Space
- **Grid Dimensions**: Exactly **48 × 48 cells** (2,304 cells total, hex `0x900`).
- Column index $x \in [0, 47]$ (`cmp ecx, 0x30`).
- Row index $y \in [0, 47]$ (`cmp eax, 0x30`).
- Linear address mapping: `index = 48 * y + x` (`lea eax, [eax + eax*2]; shl eax, 4; add eax, ecx`).
- **Spatial Resolution & Scale**:
- The lighting grid steps by **8 sub-tiles** per cell (`sar ecx, 3` / `add esi, 8`).
- World sub-tile coordinates $(x_{\text{sub}}, y_{\text{sub}})$ are converted to grid coordinates by:
$$x_{\text{grid}} = (x_{\text{sub}} \gg 3) - x_{\text{origin}}$$
$$y_{\text{grid}} = (y_{\text{sub}} \gg 3) - y_{\text{origin}}$$
Where $(x_{\text{origin}}, y_{\text{origin}})$ are the grid origin sub-tile offsets (`ds:0x6fbb4df0`, `ds:0x6fbb4df4`).
### 1.2 Cell Memory Layout (8 Bytes per Cell)
The primary lighting array is located at base address `0x6fbb5208` with an 8-byte stride per cell (`ecx * 8 + 0x6fbb5208`):
```c
struct D2LightCellStrc {
uint8_t nFlags; // +0x00: Obstacle / cell flag
uint8_t pad[3]; // +0x01..+0x03
uint8_t nIntensity; // +0x04: Clamped intensity [0..255]
uint8_t nRed; // +0x05: Red color channel [0..255]
uint8_t nGreen; // +0x06: Green color channel [0..255]
uint8_t nBlue; // +0x07: Blue color channel [0..255]
};
```
---
## 2. Mathematical Formulas (Decompiled from Assembly)
### 2.1 Fast Euclidean Distance Approximation (Alpha Max Plus Beta Min)
In `0x6fb597b4` – `0x6fb597d9`, Blizzard avoids floating-point square root `sqrt(dx^2 + dy^2)` using an integer approximation:
```assembly
6fb597b4: cmp ecx, eax
6fb597b6: jl 0x6fb597cb
6fb597b8: imul ecx, ecx, 0x3d7 ; 983
6fb597be: imul eax, eax, 0x197 ; 407
6fb597c4: add ecx, eax
6fb597c6: shr ecx, 0xa ; >> 10 (1024)
```
Mathematical representation:
$$\text{dist}(dx, dy) = \frac{983 \cdot \max(|dx|, |dy|) + 407 \cdot \min(|dx|, |dy|)}{1024}$$
Where:
- $\frac{983}{1024} \approx 0.95996$
- $\frac{407}{1024} \approx 0.39746$
- Maximum theoretical error across all angles is $\le 3.96\%$.
### 2.2 Light Falloff & Obstacle Attenuation Formula
In `0x6fb597de` – `0x6fb597fa`:
```assembly
6fb597de: mov edx, [esi+0x18] ; Light Radius R
6fb597e1: sub edx, ecx ; (R - dist)
6fb597e3: imul edx, [esp+0x3c] ; * intensitySlope (16.16 fixed point)
6fb597f1: sar edx, 16 ; >> 16
6fb597e8: sar edi, 1 ; edi = obstacle factor
6fb597ea: mov eax, 8
6fb597ef: sub eax, edi ; (8 - obstacle)
6fb597f4: imul eax, edx ; (8 - obstacle) * attenuated
6fb597f7: sar eax, 3 ; >> 3 (/ 8)
```
Mathematical representation:
$$\text{IntensitySlope} = \frac{\text{BaseIntensity} \ll 16}{R}$$
$$\text{Attenuated} = \max\left(0, \frac{(R - \text{dist}) \cdot \text{IntensitySlope}}{65536}\right)$$
$$\text{FinalCellIntensity} = \frac{(8 - \text{ObstacleFactor}) \cdot \text{Attenuated}}{8}$$
- When $\text{ObstacleFactor} = 0$: Light transmits at $100\%$ ($\frac{8-0}{8} = 1.0$).
- When $\text{ObstacleFactor} \ge 16$: Light is completely blocked (`cmp edi, 0x10; jge skip`).
### 2.3 Multi-Source Color Blending (Weighted Energy Conservation)
In `0x6fb58a91` – `0x6fb58b45`:
When multiple lights illuminate the same grid cell, their colors are combined via intensity-weighted averaging:
```assembly
6fb58a91: lea ecx, [esi+edx*1] ; newIntensity = existing + incoming
6fb58a94: cmp ecx, 0xff
...
6fb58abd: mov edi, [ebx*4 + 0x6fbb4e00] ; 65536 / newIntensity table lookup
6fb58ac4: imul ecx, esi ; existingRed * existingIntensity
6fb58acd: imul ebp, edx ; incomingRed * incomingIntensity
6fb58ad0: add ecx, ebp
6fb58ad2: imul ecx, edi ; sum * (65536 / newIntensity)
6fb58ad5: shr ecx, 0x10 ; >> 16
```
Mathematical representation:
$$I_{\text{total}} = \min(255, I_{\text{existing}} + I_{\text{incoming}})$$
$$R_{\text{blended}} = \min\left(255, \frac{R_{\text{existing}} \cdot I_{\text{existing}} + R_{\text{incoming}} \cdot I_{\text{incoming}}}{I_{\text{total}}}\right)$$
$$G_{\text{blended}} = \min\left(255, \frac{G_{\text{existing}} \cdot I_{\text{existing}} + G_{\text{incoming}} \cdot I_{\text{incoming}}}{I_{\text{total}}}\right)$$
$$B_{\text{blended}} = \min\left(255, \frac{B_{\text{existing}} \cdot I_{\text{existing}} + B_{\text{incoming}} \cdot I_{\text{incoming}}}{I_{\text{total}}}\right)$$
### 2.4 Sub-Tile Bilinear Interpolation (8.8 Fixed Point)
In `0x6fb58373` – `0x6fb58385`:
```assembly
mov ebx, 0x100 ; 256
sub ebx, eax ; 256 - factor
imul eax, edx ; factor * L2
imul ebx, edi ; (256 - factor) * L1
add ebx, eax
sar ebx, 0x8 ; >> 8
```
Interpolates smoothly between neighboring cells using fixed-point factor $f \in [0, 256]$:
$$\text{Sample} = \frac{(256 - f) \cdot L_1 + f \cdot L_2}{256}$$
---
## 3. Light Sources & Defaults
- **Player Base Light Radius**: Default is radius 10–13 sub-tiles adjusted by item `STAT_LIGHTRADIUS` (`CUnit.c` / `dLightMap.c`).
- **Monster Default Radius**: 8 sub-tiles (`mov ebx, 0x8` at `0x6fb59894`).
- **Missile Lights**: Read directly from `Missiles.txt` `Light`, `Red`, `Green`, `Blue` columns.
- **Flicker**: Perturbation index updated every logic frame (25Hz) modifying radius by $\pm 1$ sub-tile.
- **2D Software Mode Fallback**: When `bColoredLighting` is false (`ds:0x6fb8e830 == 0`), $R=G=B=I$ and the intensity directly drives the 32-level PL2 colormap row.

View File

@ -13,6 +13,7 @@ import { MpqArchive } from '../../mpq/archive.ts'
import { MountedArchives } from '../../mpq/mount.ts'
import { decodeAnimDataFile, type AnimDataFile, type AnimDataRecord } from '../../formats/animdata.ts'
import { parseTable, numberCell, textCell, type DataTable } from '../tables.ts'
import { CANONICAL_LEVEL_LIGHTING, getLevelLighting, type LevelLightingDef } from '../levels-meta.ts'
export const MPQ_LOAD_ORDER = [
'd2data.mpq',
@ -480,6 +481,19 @@ export interface OverlayRecord {
readonly height3: number
readonly height4: number
readonly preDraw: boolean
readonly red: number
readonly green: number
readonly blue: number
readonly raw: Readonly<Record<string, string>>
}
export interface MonStats2LightingRecord {
readonly id: string
readonly light: number
readonly lightR: number
readonly lightG: number
readonly lightB: number
readonly shadow: boolean
readonly raw: Readonly<Record<string, string>>
}
@ -629,6 +643,7 @@ export class D2DataRegistry {
readonly charStatsByCode = new Map<string, CharStatsRecord>()
readonly monStatsByHcIdx = new Map<number, MonStatsRecord>()
readonly monStatsById = new Map<string, MonStatsRecord>()
readonly monStats2ById = new Map<string, MonStats2LightingRecord>()
readonly overlaysByName = new Map<string, OverlayRecord>()
readonly petTypesByIdx = new Map<number, PetTypeRecord>()
readonly petTypesByName = new Map<string, PetTypeRecord>()
@ -678,6 +693,7 @@ export class D2DataRegistry {
this.indexItemStatCost()
this.indexCharStats()
this.indexMonStats()
this.indexMonStats2()
this.indexOverlays()
this.indexPetTypes()
this.indexElemTypesAndEvents()
@ -1258,6 +1274,23 @@ export class D2DataRegistry {
}
}
private indexMonStats2(): void {
for (const row of this.monStats2Table.rows) {
const id = textCell(row, 'Id')
if (!id || id === 'expansion') continue
const record: MonStats2LightingRecord = {
id: id.toLowerCase(),
light: numberCell(row, 'Light', 0),
lightR: numberCell(row, 'light-r', 255),
lightG: numberCell(row, 'light-g', 255),
lightB: numberCell(row, 'light-b', 255),
shadow: numberCell(row, 'Shadow', 0) !== 0,
raw: row,
}
this.monStats2ById.set(id.toLowerCase(), record)
}
}
private indexOverlays(): void {
let idx = 0
for (const row of this.overlayTable.rows) {
@ -1280,6 +1313,9 @@ export class D2DataRegistry {
height3: numberCell(row, 'Height3', 0),
height4: numberCell(row, 'Height4', 0),
preDraw: numberCell(row, 'PreDraw', 0) !== 0,
red: numberCell(row, 'Red', 255),
green: numberCell(row, 'Green', 255),
blue: numberCell(row, 'Blue', 255),
raw: row,
}
this.overlaysByName.set(overlay.toLowerCase(), record)
@ -1379,6 +1415,14 @@ export class D2DataRegistry {
return this.monStatsById.get(idOrHcIdx.trim().toLowerCase())
}
getMonStats2Lighting(id: string): MonStats2LightingRecord | undefined {
return this.monStats2ById.get(id.trim().toLowerCase())
}
getLevelLighting(levelId: number): LevelLightingDef | undefined {
return getLevelLighting(levelId)
}
getOverlay(name: string): OverlayRecord | undefined {
return this.overlaysByName.get(name.trim().toLowerCase())
}

View File

@ -0,0 +1,237 @@
/**
* Diablo II v1.13c Ground Truth Environment & Ambient Lighting System.
*
* Implements the authentic day/night cycle, solar intensity sine wave,
* and level ambient illumination decompiled from `D2Common.dll` (`D2Environment.cpp`):
* - Outdoor Sine Wave intensity curve:
* angle = (ticks / timeRate) * PI / 180
* intensity = clamp(round(sin(angle) * 128 + 128), 0, maxIntensity)
* (where second half-cycle sin is halved; Act V capped at 170).
* - Act IV fixed lerp target intensities:
* Pandemonium: 128, Outer Steppes: 64, Plains of Despair: 56, City of the Damned: 48, default: 16.
* - Rocky Summit (Level 120): fixed intensity 200, RGB (245, 240, 255).
* - Indoor levels: read from `Levels.txt` metadata (`getLevelLighting(levelId)`).
* - Color interpolation across 6 daily environment cycles:
* Sunrise (320), Morning (340), Noon (0), Afternoon (160), Sunset (180), Night (200).
*
* References:
* - D2Common.dll (0x6FD8D970, 0x6FD8DAC0, 0x6FD8DC70)
* - `scratch/D2Environment.cpp`
*/
import { getLevelLighting } from '../levels-meta.ts'
/** Degrees in a full day/night cycle. */
export const ENV_FULL_CIRCLE_DEGREES = 360
/** Degrees in a half day/night cycle. */
export const ENV_HALF_CIRCLE_DEGREES = 180
/** Number of distinct environment periods in a cycle. */
export const NUM_ENVIRONMENT_CYCLES = 6
/** Daily cycle phase. */
export enum EnvCycleIndex {
Sunrise = 0,
Morning = 1,
Noon = 2,
Afternoon = 3,
Sunset = 4,
Night = 5,
}
export interface EnvCycleDef {
readonly ticksBegin: number
readonly red: number
readonly green: number
readonly blue: number
}
/** Authentic 1.13c normal environment color cycle (`gNormalEnvironmentCycle` at 0x6FDE2180). */
export const NORMAL_ENV_CYCLES: readonly EnvCycleDef[] = [
{ ticksBegin: 320, red: 125, green: 144, blue: 243 }, // Sunrise
{ ticksBegin: 340, red: 208, green: 184, blue: 131 }, // Morning
{ ticksBegin: 0, red: 255, green: 255, blue: 255 }, // Noon
{ ticksBegin: 160, red: 255, green: 255, blue: 255 }, // Afternoon
{ ticksBegin: 180, red: 194, green: 152, blue: 193 }, // Sunset
{ ticksBegin: 200, red: 125, green: 144, blue: 243 }, // Night
]
/** Authentic 1.13c Act IV environment color cycle (`gAct4EnvironmentCycle` at 0x6FDE2200). */
export const ACT4_ENV_CYCLES: readonly EnvCycleDef[] = [
{ ticksBegin: 340, red: 243, green: 70, blue: 243 }, // Sunrise
{ ticksBegin: 350, red: 208, green: 184, blue: 131 }, // Morning
{ ticksBegin: 0, red: 255, green: 20, blue: 20 }, // Noon (blood red skies)
{ ticksBegin: 180, red: 255, green: 255, blue: 30 }, // Afternoon
{ ticksBegin: 190, red: 20, green: 152, blue: 193 }, // Sunset
{ ticksBegin: 200, red: 125, green: 144, blue: 243 }, // Night
]
/** Authentic 1.13c Eclipse (Tainted Sun quest) color cycle. */
export const ECLIPSE_ENV_CYCLES: readonly EnvCycleDef[] = [
{ ticksBegin: 300, red: 0, green: 30, blue: 243 },
{ ticksBegin: 0, red: 0, green: 30, blue: 244 },
{ ticksBegin: 60, red: 0, green: 30, blue: 243 },
{ ticksBegin: 120, red: 0, green: 30, blue: 243 },
{ ticksBegin: 180, red: 0, green: 30, blue: 244 },
{ ticksBegin: 240, red: 0, green: 30, blue: 243 },
]
/** Time rates: ticks per degree of solar rotation (`gnTimeRates`). */
export const TIME_RATES = [128, 4, 8, 0] as const
/** Ambient lighting state evaluated for the active level. */
export interface AmbientLighting {
/** Effective intensity [0..255]. */
readonly intensity: number
/** Red channel [0..255]. */
readonly red: number
/** Green channel [0..255]. */
readonly green: number
/** Blue channel [0..255]. */
readonly blue: number
/** Whether the level is completely indoor (cave, dungeon, crypt). */
readonly isInside: boolean
}
/**
* Environment controller maintaining the 25Hz day/night clock and ambient light state.
*/
export class Environment {
ticks = 0
timeRateIndex = 0
cycleIndex = 2 // Start at Noon
eclipse = false
/** Current calculated ambient state. */
currentIntensity = 255
currentRed = 255
currentGreen = 255
currentBlue = 255
get timeRate(): number {
return TIME_RATES[this.timeRateIndex] ?? 128
}
/**
* Set custom start time or cycle index.
*/
setTime(cycle: EnvCycleIndex, ticks = 0): void {
this.cycleIndex = cycle
this.ticks = ticks
}
/**
* Advance the environment clock by one logic tick (25Hz) and evaluate ambient lighting.
* Decompiled verbatim from `ENVIRONMENT_UpdateTicks`, `ENVIRONMENT_UpdateLightIntensity`, and `ENVIRONMENT_UpdateLightColor`.
*
* @param levelId - current level ID (1..136)
* @param act - act number (1..5)
* @returns evaluated AmbientLighting
*/
tick(levelId: number, act: number): AmbientLighting {
const levelMeta = getLevelLighting(levelId)
const isInside = levelMeta ? levelMeta.isInside : false
// Indoor levels have constant ambient lighting from Levels.txt
if (isInside && levelMeta) {
this.currentIntensity = levelMeta.intensity
this.currentRed = levelMeta.red
this.currentGreen = levelMeta.green
this.currentBlue = levelMeta.blue
return {
intensity: this.currentIntensity,
red: this.currentRed,
green: this.currentGreen,
blue: this.currentBlue,
isInside: true,
}
}
// 1. Advance simulation ticks
this.ticks += 1
if (!this.eclipse) {
if (act === 4) {
this.ticks += 15
} else if (this.cycleIndex === EnvCycleIndex.Night) {
this.ticks += 1
if (act === 3) {
this.ticks += 9
}
}
}
const rate = this.timeRate
const fullCycleTicks = ENV_FULL_CIRCLE_DEGREES * rate
if (this.ticks >= fullCycleTicks) {
this.ticks = 0
}
// Advance cycle index when crossing cycle boundaries
const cycles = this.eclipse
? ECLIPSE_ENV_CYCLES
: (act === 4 ? ACT4_ENV_CYCLES : NORMAL_ENV_CYCLES)
const nextCycle = (this.cycleIndex + 1) % NUM_ENVIRONMENT_CYCLES
const nextCycleDef = cycles[nextCycle]!
if (this.ticks > rate * nextCycleDef.ticksBegin && nextCycleDef.ticksBegin > 0) {
this.cycleIndex = nextCycle
}
// 2. Update light intensity (ENVIRONMENT_UpdateLightIntensity at 0x6FD8D970)
let targetIntensity = 255
if (act === 4) {
// Act IV fixed targets
switch (levelId) {
case 103: targetIntensity = 128; break // Pandemonium
case 104: targetIntensity = 64; break // Outer Steppes
case 105: targetIntensity = 56; break // Plains of Despair
case 106: targetIntensity = 48; break // City of the Damned
default: targetIntensity = 16; break
}
if (this.currentIntensity < targetIntensity) this.currentIntensity += 1
if (this.currentIntensity > targetIntensity) this.currentIntensity -= 1
if (this.currentIntensity === 0) this.currentIntensity = targetIntensity
} else if (this.eclipse) {
const eclipseIntensity = 32
if (this.currentIntensity > eclipseIntensity) this.currentIntensity -= 8
if (this.currentIntensity < eclipseIntensity) this.currentIntensity = eclipseIntensity
} else if (levelId === 120) {
// Rocky Summit fixed intensity 200
this.currentIntensity = 200
} else {
// Solar angle sine wave
const angle = (this.ticks / rate) * Math.PI / ENV_HALF_CIRCLE_DEGREES
let sinVal = Math.sin(angle)
if (this.ticks >= ENV_HALF_CIRCLE_DEGREES * rate) {
sinVal *= 0.5
}
const rawIntensity = Math.floor(sinVal * 128.0 + 128.0 + 0.5)
targetIntensity = act === 5 ? 170 : 255
this.currentIntensity = Math.max(0, Math.min(targetIntensity, rawIntensity))
}
// 3. Update light color (ENVIRONMENT_UpdateLightColor at 0x6FD8DAC0)
if (levelId === 120) {
// Rocky Summit fixed tint (245, 240, 255)
this.currentRed = 245
this.currentGreen = 240
this.currentBlue = 255
} else {
const currDef = cycles[this.cycleIndex]!
const nextDef = cycles[nextCycle]!
const denom = rate * (nextDef.ticksBegin - currDef.ticksBegin)
const ratio = denom !== 0 ? Math.max(0, Math.min(1, (this.ticks - rate * currDef.ticksBegin) / denom)) : 0
this.currentRed = Math.round(currDef.red + ratio * (nextDef.red - currDef.red))
this.currentGreen = Math.round(currDef.green + ratio * (nextDef.green - currDef.green))
this.currentBlue = Math.round(currDef.blue + ratio * (nextDef.blue - currDef.blue))
}
return {
intensity: this.currentIntensity,
red: this.currentRed,
green: this.currentGreen,
blue: this.currentBlue,
isInside: false,
}
}
}

View File

@ -0,0 +1,387 @@
/**
* Diablo II v1.13c Ground Truth Lighting Grid (`D2Client.dll` `dLightMap.c`).
*
* Implements the 48x48 cell dynamic lightmap evaluated in `dLightMap.c`:
* - Grid Dimensions: Strictly 48 x 48 cells (2,304 cells total).
* - Spatial Resolution: 8 sub-tiles per cell.
* - Alpha Max Plus Beta Min fast Euclidean distance approximation:
* dist(dx, dy) = (983 * max(|dx|, |dy|) + 407 * min(|dx|, |dy|)) >> 10
* - Linear intensity falloff & obstacle attenuation:
* IntensitySlope = (BaseIntensity << 16) / R
* Attenuated = max(0, ((R - dist) * IntensitySlope) >> 16)
* FinalIntensity = ((8 - ObstacleFactor) * Attenuated) >> 3
* - Multi-source color blending via intensity-weighted energy conservation:
* Itotal = min(255, I1 + I2)
* Cblend = min(255, (C1 * I1 + C2 * I2) / Itotal)
* - Bilinear interpolation across sub-tiles for continuous lighting.
*
* References:
* - docs/lighting_reverse_engineering_113c.md
* - samples/d2/D2Client.dll (v1.13c, Base 0x6fab0000, dLightMap.c)
*/
import {
COLLIDE_VISIBLE,
COLLIDE_DOOR,
COLLIDE_BLANK,
COLLIDE_WALL,
type CollisionGrid,
} from '../d2map.ts'
/** Width and height of the lighting grid in cells (Blizzard constant: 48). */
export const LIGHT_GRID_SIZE = 48
/** Number of sub-tiles per lighting grid cell (Blizzard constant: 8). */
export const SUB_TILES_PER_LIGHT_CELL = 8
/** Total number of cells in the grid. */
export const LIGHT_GRID_CELL_COUNT = LIGHT_GRID_SIZE * LIGHT_GRID_SIZE
/**
* Fast Euclidean distance approximation: Alpha Max Plus Beta Min.
* Assembly at 0x6fb597b4 - 0x6fb597cb:
* dist = (983 * max(|dx|, |dy|) + 407 * min(|dx|, |dy|)) / 1024
* Maximum theoretical error <= 3.96% across all angles; strictly integer operations.
*/
export function fastDistanceApprox(dx: number, dy: number): number {
const ax = Math.abs(dx)
const ay = Math.abs(dy)
const max = ax > ay ? ax : ay
const min = ax > ay ? ay : ax
return ((983 * max + 407 * min) >> 10)
}
/** One point light emitter. Coordinates are in world sub-tiles. */
export interface LightSource {
/** Emitter position in world sub-tiles. */
readonly subTileX: number
readonly subTileY: number
/** Light radius in sub-tiles (R). */
readonly radius: number
/** Base light intensity [0..255]. */
readonly intensity: number
/** Red channel [0..255]. */
readonly red: number
/** Green channel [0..255]. */
readonly green: number
/** Blue channel [0..255]. */
readonly blue: number
/** Optional flicker perturbation flag or offset. */
readonly flicker?: number
}
/**
* 48x48 Lighting Grid maintaining per-cell Intensity, Red, Green, Blue, and Obstacle flags.
*/
export class LightGrid {
/** Sub-tile origin X of the 48x48 grid in the world. */
originSubX = 0
/** Sub-tile origin Y of the 48x48 grid in the world. */
originSubY = 0
/** Intensity channel per cell [0..255]. Length: 2304. */
readonly intensity: Uint8Array = new Uint8Array(LIGHT_GRID_CELL_COUNT)
/** Red color channel per cell [0..255]. Length: 2304. */
readonly red: Uint8Array = new Uint8Array(LIGHT_GRID_CELL_COUNT)
/** Green color channel per cell [0..255]. Length: 2304. */
readonly green: Uint8Array = new Uint8Array(LIGHT_GRID_CELL_COUNT)
/** Blue color channel per cell [0..255]. Length: 2304. */
readonly blue: Uint8Array = new Uint8Array(LIGHT_GRID_CELL_COUNT)
/** Obstacle factor per cell [0..16+]. Length: 2304. */
readonly obstacle: Uint8Array = new Uint8Array(LIGHT_GRID_CELL_COUNT)
/** RGBA8 formatted texture buffer for WebGL2 lightmap upload (48 * 48 * 4 bytes). */
readonly textureBuffer: Uint8Array = new Uint8Array(LIGHT_GRID_CELL_COUNT * 4)
/** Reciprocal table for fast fixed-point color blend division (equivalent to 0x6fbb4e00). */
private static readonly reciprocalTable: Uint32Array = (() => {
const table = new Uint32Array(512)
table[0] = 0
for (let i = 1; i < 512; i += 1) {
table[i] = Math.floor(65536 / i)
}
return table
})()
/**
* Reset grid to ambient light values and clear obstacles.
*
* @param ambientIntensity - ambient intensity [0..255]
* @param ambientR - ambient red [0..255]
* @param ambientG - ambient green [0..255]
* @param ambientB - ambient blue [0..255]
*/
clear(ambientIntensity = 0, ambientR = 0, ambientG = 0, ambientB = 0): void {
this.intensity.fill(ambientIntensity)
this.red.fill(ambientR)
this.green.fill(ambientG)
this.blue.fill(ambientB)
this.obstacle.fill(0)
}
/**
* Center the 48x48 grid on a given world sub-tile position (typically player feet).
*
* @param centerSubX - world sub-tile X
* @param centerSubY - world sub-tile Y
*/
centerOnSubTile(centerSubX: number, centerSubY: number): void {
// 48 cells * 8 sub-tiles = 384 sub-tiles width and height.
// Center cell is (24, 24) -> 24 * 8 = 192 sub-tiles offset.
const halfSpan = (LIGHT_GRID_SIZE >> 1) * SUB_TILES_PER_LIGHT_CELL // 192
this.originSubX = Math.floor(centerSubX) - halfSpan
this.originSubY = Math.floor(centerSubY) - halfSpan
}
/**
* Populate cell obstacle factors from map collision masks.
* Tests bits 0x0002 (COLLIDE_VISIBLE), 0x0800 (COLLIDE_DOOR), 0x0020 (COLLIDE_BLANK), 0x0001 (COLLIDE_WALL).
*
* @param collision - scene collision grid
*/
updateObstacles(collision: CollisionGrid): void {
const gridW = collision.gridWidth
const gridH = collision.cellsY * 5
const masks = collision.collisionMasks
for (let cy = 0; cy < LIGHT_GRID_SIZE; cy += 1) {
const subY = this.originSubY + cy * SUB_TILES_PER_LIGHT_CELL
for (let cx = 0; cx < LIGHT_GRID_SIZE; cx += 1) {
const subX = this.originSubX + cx * SUB_TILES_PER_LIGHT_CELL
const cellIdx = cy * LIGHT_GRID_SIZE + cx
if (subX < 0 || subY < 0 || subX >= gridW || subY >= gridH) {
// Out of map bounds is completely opaque obstacle
this.obstacle[cellIdx] = 16
continue
}
let obstacleSum = 0
if (masks !== undefined) {
// Sample the 4 center sub-tiles of the 8x8 cell block
for (let dy = 2; dy <= 5; dy += 2) {
const sy = subY + dy
if (sy < 0 || sy >= gridH) { obstacleSum += 8; continue }
const rowOffset = sy * gridW
for (let dx = 2; dx <= 5; dx += 2) {
const sx = subX + dx
if (sx < 0 || sx >= gridW) { obstacleSum += 8; continue }
const m = masks[rowOffset + sx] ?? 0
if ((m & (COLLIDE_VISIBLE | COLLIDE_DOOR)) !== 0) {
obstacleSum += 4
} else if ((m & (COLLIDE_BLANK | COLLIDE_WALL)) !== 0) {
obstacleSum += 2
}
}
}
} else {
// Fallback if collision masks not present
const blocked = collision.blocked
const bIdx = subY * gridW + subX
if (blocked[bIdx] === 1) {
obstacleSum = 16
}
}
this.obstacle[cellIdx] = Math.min(16, obstacleSum)
}
}
}
/**
* Apply one light source to the 48x48 grid using authentic 1.13c mathematics:
* - Falloff: Attenuated = max(0, ((R - dist) * IntensitySlope) >> 16)
* - Obstacle: FinalIntensity = ((8 - ObstacleFactor) * Attenuated) >> 3
* - Color Blending: Intensity-weighted average
*
* @param light - light emitter parameters
*/
addLight(light: LightSource): void {
if (light.radius <= 0 || light.intensity <= 0) return
const effectiveRadius = light.radius + (light.flicker ?? 0)
if (effectiveRadius <= 0) return
// Precalculate IntensitySlope in 16.16 fixed-point: (BaseIntensity << 16) / R
const intensitySlope = Math.floor((light.intensity << 16) / effectiveRadius)
// Emitter coordinates in sub-tiles relative to grid origin
const relSubX = light.subTileX - this.originSubX
const relSubY = light.subTileY - this.originSubY
// Bounding box in light cells
const cellRadius = Math.ceil(effectiveRadius / SUB_TILES_PER_LIGHT_CELL)
const centerCellX = Math.floor(relSubX / SUB_TILES_PER_LIGHT_CELL)
const centerCellY = Math.floor(relSubY / SUB_TILES_PER_LIGHT_CELL)
const minCellX = Math.max(0, centerCellX - cellRadius)
const maxCellX = Math.min(LIGHT_GRID_SIZE - 1, centerCellX + cellRadius)
const minCellY = Math.max(0, centerCellY - cellRadius)
const maxCellY = Math.min(LIGHT_GRID_SIZE - 1, centerCellY + cellRadius)
const table = LightGrid.reciprocalTable
const incomingR = light.red
const incomingG = light.green
const incomingB = light.blue
for (let cy = minCellY; cy <= maxCellY; cy += 1) {
const cellCenterSubY = cy * SUB_TILES_PER_LIGHT_CELL + (SUB_TILES_PER_LIGHT_CELL >> 1)
const dSubY = cellCenterSubY - relSubY
const rowOffset = cy * LIGHT_GRID_SIZE
for (let cx = minCellX; cx <= maxCellX; cx += 1) {
const cellIdx = rowOffset + cx
const obstacleFactor = this.obstacle[cellIdx]!
// If obstacle factor >= 16 (0x10), light is completely blocked (0x6fb597ea)
if (obstacleFactor >= 16) continue
const cellCenterSubX = cx * SUB_TILES_PER_LIGHT_CELL + (SUB_TILES_PER_LIGHT_CELL >> 1)
const dSubX = cellCenterSubX - relSubX
// Fast Euclidean distance approximation
const dist = fastDistanceApprox(dSubX, dSubY)
if (dist >= effectiveRadius) continue
// Linear falloff: ((R - dist) * IntensitySlope) >> 16
const attenuated = ((effectiveRadius - dist) * intensitySlope) >> 16
if (attenuated <= 0) continue
// Obstacle attenuation: ((8 - obstacleFactor) * attenuated) >> 3
const finalIntensity = Math.max(0, ((8 - obstacleFactor) * attenuated) >> 3)
if (finalIntensity <= 0) continue
// Accumulate and blend into cell (0x6fb58a91)
const existingIntensity = this.intensity[cellIdx]!
const newTotalIntensity = Math.min(255, existingIntensity + finalIntensity)
if (existingIntensity === 0) {
this.intensity[cellIdx] = newTotalIntensity
this.red[cellIdx] = incomingR
this.green[cellIdx] = incomingG
this.blue[cellIdx] = incomingB
} else {
// Weighted average: (C_existing * I_existing + C_incoming * I_incoming) / I_total
const recip = table[newTotalIntensity] ?? Math.floor(65536 / newTotalIntensity)
const blendedR = ((this.red[cellIdx]! * existingIntensity + incomingR * finalIntensity) * recip) >> 16
const blendedG = ((this.green[cellIdx]! * existingIntensity + incomingG * finalIntensity) * recip) >> 16
const blendedB = ((this.blue[cellIdx]! * existingIntensity + incomingB * finalIntensity) * recip) >> 16
this.intensity[cellIdx] = newTotalIntensity
this.red[cellIdx] = Math.min(255, blendedR)
this.green[cellIdx] = Math.min(255, blendedG)
this.blue[cellIdx] = Math.min(255, blendedB)
}
}
}
}
/**
* Sample the lighting at an arbitrary sub-tile coordinate using 8.8 fixed-point bilinear interpolation.
* Exact decompiled match of 0x6fb58373:
* Sample = ((256 - factor) * L1 + factor * L2) >> 8
*
* @param subTileX - world sub-tile X
* @param subTileY - world sub-tile Y
* @returns RGBA color channels [0..255]
*/
sampleBilinear(subTileX: number, subTileY: number): { intensity: number; r: number; g: number; b: number } {
const relSubX = subTileX - this.originSubX
const relSubY = subTileY - this.originSubY
// Continuous cell coordinate: relSub / 8
const cellFX = relSubX / SUB_TILES_PER_LIGHT_CELL
const cellFY = relSubY / SUB_TILES_PER_LIGHT_CELL
const cx0 = Math.floor(cellFX)
const cy0 = Math.floor(cellFY)
const cx1 = Math.min(LIGHT_GRID_SIZE - 1, cx0 + 1)
const cy1 = Math.min(LIGHT_GRID_SIZE - 1, cy0 + 1)
// Clamp coordinates
const clampedX0 = Math.max(0, Math.min(LIGHT_GRID_SIZE - 1, cx0))
const clampedY0 = Math.max(0, Math.min(LIGHT_GRID_SIZE - 1, cy0))
if (clampedX0 === cx1 && clampedY0 === cy1) {
const idx = clampedY0 * LIGHT_GRID_SIZE + clampedX0
return {
intensity: this.intensity[idx]!,
r: this.red[idx]!,
g: this.green[idx]!,
b: this.blue[idx]!,
}
}
// 8.8 fixed-point fractional factors [0..256]
const fx = Math.min(256, Math.max(0, Math.round((cellFX - cx0) * 256)))
const fy = Math.min(256, Math.max(0, Math.round((cellFY - cy0) * 256)))
const invFx = 256 - fx
const invFy = 256 - fy
const idx00 = clampedY0 * LIGHT_GRID_SIZE + clampedX0
const idx10 = clampedY0 * LIGHT_GRID_SIZE + cx1
const idx01 = cy1 * LIGHT_GRID_SIZE + clampedX0
const idx11 = cy1 * LIGHT_GRID_SIZE + cx1
const lerpChannel = (arr: Uint8Array): number => {
const top = (invFx * arr[idx00]! + fx * arr[idx10]!) >> 8
const bot = (invFx * arr[idx01]! + fx * arr[idx11]!) >> 8
return (invFy * top + fy * bot) >> 8
}
return {
intensity: lerpChannel(this.intensity),
r: lerpChannel(this.red),
g: lerpChannel(this.green),
b: lerpChannel(this.blue),
}
}
/**
* Convert world sub-tile coordinate to UV coordinate on the 48x48 lightmap texture.
*
* @param subTileX - world sub-tile X
* @param subTileY - world sub-tile Y
* @returns [u, v] in range [0..1]
*/
worldSubTileToLightmapUV(subTileX: number, subTileY: number): [number, number] {
const totalSubTiles = LIGHT_GRID_SIZE * SUB_TILES_PER_LIGHT_CELL // 384
const u = (subTileX - this.originSubX) / totalSubTiles
const v = (subTileY - this.originSubY) / totalSubTiles
return [u, v]
}
/**
* Export grid state into the RGBA8 texture buffer for WebGL2 upload:
* - R: Red * Intensity / 255
* - G: Green * Intensity / 255
* - B: Blue * Intensity / 255
* - A: Intensity
*
* @returns typed array view ready for gl.texSubImage2D
*/
exportTextureBuffer(): Uint8Array {
const buf = this.textureBuffer
const intensities = this.intensity
const reds = this.red
const greens = this.green
const blues = this.blue
for (let i = 0; i < LIGHT_GRID_CELL_COUNT; i += 1) {
const idx4 = i << 2
const intens = intensities[i]!
if (intens === 0) {
buf[idx4] = 0
buf[idx4 + 1] = 0
buf[idx4 + 2] = 0
buf[idx4 + 3] = 0
} else {
// Multiply color with intensity so shader can directly sample filtered illumination
buf[idx4] = (reds[i]! * intens) >> 8
buf[idx4 + 1] = (greens[i]! * intens) >> 8
buf[idx4 + 2] = (blues[i]! * intens) >> 8
buf[idx4 + 3] = intens
}
}
return buf
}
}

1670
src/game/levels-meta.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -509,6 +509,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 178,
blue: 64,
speedPxPerSec: 500,
trans: 1,
distancePx: 1000,
},
fireball: {
@ -529,6 +530,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 178,
blue: 64,
speedPxPerSec: 500,
trans: 1,
distancePx: 1000,
},
fireexplode: {
@ -549,6 +551,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 178,
blue: 64,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
explodingarrowexp: {
@ -569,6 +572,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 178,
blue: 64,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
icebolt: {
@ -589,6 +593,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 300,
trans: 1,
distancePx: 600,
},
iceblast: {
@ -609,6 +614,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 300,
trans: 1,
distancePx: 600,
},
glacialspike: {
@ -629,6 +635,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 400,
trans: 1,
distancePx: 640,
},
iceexplode: {
@ -649,6 +656,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
freezeexplode: {
@ -669,6 +677,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
freezingarrowexp1: {
@ -689,6 +698,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
frozenorb: {
@ -709,6 +719,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 250,
trans: 1,
distancePx: 300,
},
frozenorbbolt: {
@ -729,6 +740,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 450,
trans: 1,
distancePx: 450,
},
frozenorbnova: {
@ -749,6 +761,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 600,
trans: 1,
distancePx: 600,
},
frozenorbexplode: {
@ -769,6 +782,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzardcenter: {
@ -789,6 +803,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzard1: {
@ -809,6 +824,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzard2: {
@ -829,6 +845,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzard3: {
@ -849,6 +866,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzard4: {
@ -869,6 +887,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzardexplode1: {
@ -889,6 +908,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzardexplode2: {
@ -909,6 +929,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
blizzardexplode3: {
@ -929,6 +950,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
hydra: {
@ -949,6 +971,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 255,
blue: 255,
speedPxPerSec: 400,
trans: 1,
distancePx: 480,
},
hydrafire: {
@ -969,6 +992,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 255,
blue: 255,
speedPxPerSec: 400,
trans: 1,
distancePx: 480,
},
firewallmaker: {
@ -989,6 +1013,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 0,
blue: 0,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
firewall: {
@ -1114,6 +1139,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 0,
blue: 0,
speedPxPerSec: 0,
trans: 0,
distancePx: 0,
},
meteorcenter: {
@ -1134,6 +1160,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 64,
blue: 64,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
meteortail: {
@ -1154,6 +1181,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 0,
blue: 0,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
meteorexplode: {
@ -1174,6 +1202,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 178,
blue: 64,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
meteorfire: {
@ -1194,6 +1223,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 178,
blue: 64,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
chargedbolt: {
@ -1214,6 +1244,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 255,
blue: 255,
speedPxPerSec: 300,
trans: 1,
distancePx: 1176,
},
teeth: {
@ -1234,6 +1265,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 255,
blue: 255,
speedPxPerSec: 400,
trans: 1,
distancePx: 800,
},
teethexplode: {
@ -1254,6 +1286,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 255,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
bonespear: {
@ -1274,6 +1307,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 255,
blue: 255,
speedPxPerSec: 600,
trans: 1,
distancePx: 960,
},
bonespearexplode: {
@ -1294,6 +1328,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 255,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
bonespirit: {
@ -1314,6 +1349,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 255,
blue: 255,
speedPxPerSec: 300,
trans: 1,
distancePx: 1536,
},
bonespiritexplode: {
@ -1334,6 +1370,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 255,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
poisonnova: {
@ -1354,6 +1391,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 255,
blue: 255,
speedPxPerSec: 300,
trans: 1,
distancePx: 360,
},
holybolt: {
@ -1374,6 +1412,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 222,
blue: 255,
speedPxPerSec: 500,
trans: 1,
distancePx: 1000,
},
blessedhammer: {
@ -1394,6 +1433,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 222,
blue: 255,
speedPxPerSec: 450,
trans: 1,
distancePx: 2160,
},
magicarrow: {
@ -1414,6 +1454,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 255,
blue: 255,
speedPxPerSec: 600,
trans: 1,
distancePx: 960,
},
firearrow: {
@ -1434,6 +1475,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 178,
blue: 64,
speedPxPerSec: 600,
trans: 1,
distancePx: 960,
},
icearrow: {
@ -1454,6 +1496,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 81,
blue: 255,
speedPxPerSec: 600,
trans: 1,
distancePx: 960,
},
arrow: {
@ -1474,6 +1517,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 255,
blue: 255,
speedPxPerSec: 600,
trans: 0,
distancePx: 960,
},
multipleshotarrow: {
@ -1494,6 +1538,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 255,
blue: 255,
speedPxPerSec: 600,
trans: 0,
distancePx: 1200,
},
lightningjavelin: {
@ -1514,10 +1559,23 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
green: 255,
blue: 255,
speedPxPerSec: 750,
trans: 1,
distancePx: 750,
},
})
/**
* Safely parse an RGB color channel (0..255) with fallback (default 255).
* Preserves authentic 0 values while mapping empty/unspecified cells to fallback.
*/
export function parseColorChannel(raw: string | undefined, fallback = 255): number {
if (raw === undefined) return fallback
const trimmed = raw.trim()
if (trimmed === '') return fallback
const n = Number(trimmed)
return Number.isFinite(n) ? n : fallback
}
/**
* Parses raw Missiles.txt text into a Map of missile records.
*/
@ -1564,9 +1622,9 @@ export function parseMissilesTxt(content: string): Map<string, MissileTxtData> {
const numDirections = Number(cells[numDirIdx] ?? 0) || 1
const explosionMissile = (cells[explIdx] ?? '').trim()
const light = Number(cells[lightIdx] ?? 0) || 0
const red = Number(cells[redIdx] ?? 255) || 255
const green = Number(cells[greenIdx] ?? 255) || 255
const blue = Number(cells[blueIdx] ?? 255) || 255
const red = parseColorChannel(cells[redIdx])
const green = parseColorChannel(cells[greenIdx])
const blue = parseColorChannel(cells[blueIdx])
const nextHit = Number(cells[nextHitIdx] ?? 0) || 0
const nextDelay = Number(cells[nextDelayIdx] ?? 0) || 0
const trans = Number(cells[transIdx] ?? 0) || 0
@ -1633,6 +1691,7 @@ export function getMissileTxtData(
red: 255,
green: 178,
blue: 64,
trans: 0,
speedPxPerSec: 500,
distancePx: 1000,
}
@ -1665,9 +1724,11 @@ export function getMissileTxtData(
const numDirections = Number(row[t.header.indexOf('NumDirections')] ?? 0) || 1
const explosionMissile = (row[t.header.indexOf('ExplosionMissile')] ?? '').trim()
const light = Number(row[t.header.indexOf('Light')] ?? 0) || 0
const red = Number(row[t.header.indexOf('Red')] ?? 255) || 255
const green = Number(row[t.header.indexOf('Green')] ?? 255) || 255
const blue = Number(row[t.header.indexOf('Blue')] ?? 255) || 255
const red = parseColorChannel(row[t.header.indexOf('Red')])
const green = parseColorChannel(row[t.header.indexOf('Green')])
const blue = parseColorChannel(row[t.header.indexOf('Blue')])
const transColIdx = t.header.indexOf('Trans')
const trans = transColIdx !== -1 ? Number(row[transColIdx] ?? 0) || 0 : 0
return {
name: key,
id: Number(row[t.header.indexOf('Id')] ?? 0) || 0,
@ -1685,6 +1746,7 @@ export function getMissileTxtData(
red,
green,
blue,
trans,
speedPxPerSec: vel * 25,
distancePx: vel * range,
}
@ -1710,9 +1772,10 @@ export function getMissileTxtData(
const numDirections = Number(row['NumDirections'] ?? row['numdirections'] ?? 0) || 1
const explosionMissile = (row['ExplosionMissile'] ?? row['explosionmissile'] ?? '').trim()
const light = Number(row['Light'] ?? row['light'] ?? 0) || 0
const red = Number(row['Red'] ?? row['red'] ?? 255) || 255
const green = Number(row['Green'] ?? row['green'] ?? 255) || 255
const blue = Number(row['Blue'] ?? row['blue'] ?? 255) || 255
const red = parseColorChannel(row['Red'] ?? row['red'])
const green = parseColorChannel(row['Green'] ?? row['green'])
const blue = parseColorChannel(row['Blue'] ?? row['blue'])
const trans = Number(row['Trans'] ?? row['trans'] ?? 0) || 0
return {
name: key,
id: Number(row['Id'] ?? row['id'] ?? 0) || 0,
@ -1730,6 +1793,7 @@ export function getMissileTxtData(
red,
green,
blue,
trans,
speedPxPerSec: vel * 25,
distancePx: vel * range,
}
@ -3408,9 +3472,9 @@ export function parseOverlayTxt(content: string): Map<string, OverlayTxtData> {
const preDraw = Number(cells[preDrawIdx] ?? 0) !== 0
const initRadius = Number(cells[initRadiusIdx] ?? 0) || 0
const radius = Number(cells[radiusIdx] ?? 0) || 0
const red = Number(cells[redIdx] ?? 255) || 255
const green = Number(cells[greenIdx] ?? 255) || 255
const blue = Number(cells[blueIdx] ?? 255) || 255
const red = parseColorChannel(cells[redIdx])
const green = parseColorChannel(cells[greenIdx])
const blue = parseColorChannel(cells[blueIdx])
const numDirections = Number(cells[numDirIdx] ?? 1) || 1
const xOffset = Number(cells[xOffsetIdx] ?? 0) || 0
const yOffset = Number(cells[yOffsetIdx] ?? 0) || 0
@ -3477,6 +3541,35 @@ export function getOverlayTxtData(
return overlaySource.get(key) ?? getOverlayTxtData(overlayName, undefined)
}
// D2Table ({ header, rows })
if ('header' in overlaySource && Array.isArray((overlaySource as any).header)) {
const t = overlaySource as { header: readonly string[]; rows: readonly (readonly string[])[] }
const oIdx = t.header.indexOf('Overlay')
const fIdx = t.header.indexOf('Filename')
const row = t.rows.find(
r => (oIdx !== -1 && (r[oIdx] ?? '').trim().toLowerCase() === key) ||
(fIdx !== -1 && (r[fIdx] ?? '').trim().toLowerCase() === key),
)
if (row) {
return {
overlay: oIdx !== -1 ? (row[oIdx] ?? key).trim() : key,
filename: fIdx !== -1 ? (row[fIdx] ?? '').trim() : '',
frames: Number(row[t.header.indexOf('Frames')] ?? 1) || 1,
animRate: Number(row[t.header.indexOf('AnimRate')] ?? 16) || 16,
trans: Number(row[t.header.indexOf('Trans')] ?? 0) || 0,
preDraw: Number(row[t.header.indexOf('PreDraw')] ?? 0) !== 0,
initRadius: Number(row[t.header.indexOf('InitRadius')] ?? 0) || 0,
radius: Number(row[t.header.indexOf('Radius')] ?? 0) || 0,
red: parseColorChannel(row[t.header.indexOf('Red')]),
green: parseColorChannel(row[t.header.indexOf('Green')]),
blue: parseColorChannel(row[t.header.indexOf('Blue')]),
numDirections: Number(row[t.header.indexOf('NumDirections')] ?? 1) || 1,
xOffset: Number(row[t.header.indexOf('Xoffset')] ?? 0) || 0,
yOffset: Number(row[t.header.indexOf('Yoffset')] ?? 0) || 0,
}
}
}
// DataTable ({ rows: Record<string, string>[] })
if ('rows' in overlaySource && !('header' in overlaySource)) {
const tableRows = overlaySource.rows as readonly Readonly<Record<string, string>>[]
@ -3494,9 +3587,9 @@ export function getOverlayTxtData(
preDraw: Number(row['PreDraw'] ?? row['predraw'] ?? 0) !== 0,
initRadius: Number(row['InitRadius'] ?? row['initradius'] ?? 0) || 0,
radius: Number(row['Radius'] ?? row['radius'] ?? 0) || 0,
red: Number(row['Red'] ?? row['red'] ?? 255) || 255,
green: Number(row['Green'] ?? row['green'] ?? 255) || 255,
blue: Number(row['Blue'] ?? row['blue'] ?? 255) || 255,
red: parseColorChannel(row['Red'] ?? row['red']),
green: parseColorChannel(row['Green'] ?? row['green']),
blue: parseColorChannel(row['Blue'] ?? row['blue']),
numDirections: Number(row['NumDirections'] ?? row['numdirections'] ?? 1) || 1,
xOffset: Number(row['Xoffset'] ?? row['xoffset'] ?? 0) || 0,
yOffset: Number(row['Yoffset'] ?? row['yoffset'] ?? 0) || 0,

View File

@ -80,7 +80,7 @@ export function parseTable(text: string): DataTable {
*/
export function numberCell(row: Readonly<Record<string, string>>, column: string, fallback: number): number {
const raw = row[column]
if (raw === undefined) return fallback
if (raw === undefined || raw.trim() === '') return fallback
const value = Number(raw)
return Number.isFinite(value) ? value : fallback
}

View File

@ -88,6 +88,20 @@ export interface DrawOptions {
readonly blendMode?: 'normal' | 'additive'
}
/** Dynamic scene lighting configuration for Glide-style illumination. */
export interface LightingConfig {
/** Ambient light color in range 0..1, e.g. [0.03, 0.03, 0.04]. */
readonly ambient: readonly [number, number, number]
/** Sub-tile origin of the 48x48 light grid. */
readonly originSubX: number
readonly originSubY: number
/** Scene pixel origin (e.g. grid.originX, grid.originY). */
readonly sceneOriginX: number
readonly sceneOriginY: number
/** 48x48 RGBA8 lightmap texture buffer (48 * 48 * 4 bytes). If omitted, existing lightmap texture is retained. */
readonly lightmap?: Uint8Array | null
}
/** Configuration options and event callbacks for SpriteRenderer. */
export interface RendererOptions {
/** Optional callback invoked when the WebGL context is lost. */
@ -107,7 +121,7 @@ const VERTICES_PER_QUAD = 4
/** Indices per quad (two triangles). */
const INDICES_PER_QUAD = 6
/** Floats per vertex: x, y, u, v, r, g, b, a, unit, paletteRow. */
const FLOATS_PER_VERTEX = 10
export const FLOATS_PER_VERTEX = 10
/** Initial batch capacity in quads. */
const INITIAL_CAPACITY = 8192
/**
@ -132,8 +146,11 @@ in float a_palette;
uniform vec2 u_camera;
uniform vec2 u_viewport;
uniform float u_zoom;
uniform vec2 u_sceneOrigin;
uniform vec2 u_lightOrigin;
out vec2 v_uv;
out vec4 v_tint;
out vec2 v_lightUv;
flat out int v_unit;
flat out int v_palette;
void main() {
@ -144,6 +161,17 @@ void main() {
v_tint = a_tint;
v_unit = int(a_unit);
v_palette = int(a_palette);
// Inverse isometric projection to sub-tiles:
// x_scene = (subX - subY) * 16.0 + originX
// y_scene = (subX + subY) * 8.0 + originY
// => subX = ((x_scene - originX)/16.0 + (y_scene - originY)/8.0) * 0.5
// => subY = ((y_scene - originY)/8.0 - (x_scene - originX)/16.0) * 0.5
vec2 d = a_position - u_sceneOrigin;
float subX = (d.x * (1.0 / 16.0) + d.y * (1.0 / 8.0)) * 0.5;
float subY = (d.y * (1.0 / 8.0) - d.x * (1.0 / 16.0)) * 0.5;
// Light grid spans 48 cells * 8 sub-tiles = 384 sub-tiles
v_lightUv = vec2(subX - u_lightOrigin.x, subY - u_lightOrigin.y) * (1.0 / 384.0);
}`
}
@ -152,6 +180,8 @@ void main() {
*
* Supports both standard RGBA textures (`v_palette < 0`) and 8-bit `R8` palette-indexed
* textures (`v_palette >= 0`), performing hardware-accelerated palette lookups via `texelFetch`.
* When dynamic lighting is active (`u_lightMode == 1`), modulates normal non-additive fragments
* with bilinear-filtered lightmap samples and ambient light.
*
* @param units - number of batch samplers to expose.
* @param options - compilation options including alphaDiscard.
@ -178,10 +208,15 @@ export function fragmentShaderSource(units: number, options: { alphaDiscard?: bo
precision highp float;
in vec2 v_uv;
in vec4 v_tint;
in vec2 v_lightUv;
flat in int v_unit;
flat in int v_palette;
uniform sampler2D u_textures[${String(units)}];
uniform sampler2D u_palette;
uniform sampler2D u_lightmap;
uniform vec4 u_ambient;
uniform int u_lightMode;
uniform int u_blendMode;
out vec4 outColor;
void main() {
vec4 texel;
@ -190,7 +225,13 @@ ${cases.join('\n')}
default: texel = texture(u_textures[0], v_uv); break;
}
${paletteLookup}${discardAlpha}
outColor = texel * v_tint;
if (u_lightMode == 1 && u_blendMode == 0) {
vec4 lightSample = texture(u_lightmap, v_lightUv);
vec3 totalLight = clamp(u_ambient.rgb + lightSample.rgb, 0.0, 1.0);
outColor = vec4(texel.rgb * totalLight * v_tint.rgb, texel.a * v_tint.a);
} else {
outColor = texel * v_tint;
}
}`
}
@ -223,6 +264,7 @@ export class SpriteRenderer {
private atlasTexture: WebGLTexture
private readonly whiteTexture: WebGLTexture
private readonly paletteTexture: WebGLTexture
private lightmapTexture: WebGLTexture | null = null
/** The 1×1 white page used by `drawSolid`. */
private readonly whiteAtlas: AtlasHandle
/** The default page (what `setAtlas` uploads into). */
@ -232,6 +274,12 @@ export class SpriteRenderer {
private readonly uniformZoom: WebGLUniformLocation
private readonly uniformTextures: WebGLUniformLocation
private readonly uniformPalette: WebGLUniformLocation | null
private readonly uniformLightmap: WebGLUniformLocation | null
private readonly uniformAmbient: WebGLUniformLocation | null
private readonly uniformSceneOrigin: WebGLUniformLocation | null
private readonly uniformLightOrigin: WebGLUniformLocation | null
private readonly uniformLightMode: WebGLUniformLocation | null
private readonly uniformBlendMode: WebGLUniformLocation | null
private vertices: Float32Array
private indices: Uint32Array
private quadCount = 0
@ -245,15 +293,36 @@ export class SpriteRenderer {
private cachedViewportWidth = Number.NaN
private cachedViewportHeight = Number.NaN
private cachedZoom = Number.NaN
/** Texture units this context offers the batcher for atlases (reserving 1 unit for palette). */
private cachedAmbientR = Number.NaN
private cachedAmbientG = Number.NaN
private cachedAmbientB = Number.NaN
private cachedSceneOriginX = Number.NaN
private cachedSceneOriginY = Number.NaN
private cachedOriginSubX = Number.NaN
private cachedOriginSubY = Number.NaN
private cachedLightMode = -1
private cachedBlendMode = -1
/** Texture units this context offers the batcher for atlases (reserving 1 unit for palette and 1 for lightmap). */
private readonly maxBatchTextures: number
/** Dedicated texture unit for the 256-color hardware palette texture. */
private readonly paletteUnit: number
/** Dedicated texture unit for the 48x48 dynamic lightmap texture. */
private readonly lightmapUnit: number
/** Pages bound to units `0..batchTextureCount-1` for the pending batch. */
private readonly batchTextures: (AtlasHandle | null)[]
private batchTextureCount = 0
private camera: Camera = { x: 0, y: 0, zoom: 1 }
/** Lighting parameters. */
private lightingEnabled = false
private ambientR = 1
private ambientG = 1
private ambientB = 1
private originSubX = 0
private originSubY = 0
private sceneOriginX = 0
private sceneOriginY = 0
/** `drawElements` calls issued since the last {@link begin}. */
private frameDrawCalls = 0
/** Quads submitted since the last {@link begin}. */
@ -282,9 +351,10 @@ export class SpriteRenderer {
const reportedUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null
const availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES
const clampedUnits = Math.max(2, Math.min(MAX_BATCH_TEXTURES, availableUnits))
const clampedUnits = Math.max(3, Math.min(MAX_BATCH_TEXTURES, availableUnits))
this.paletteUnit = clampedUnits - 1
this.maxBatchTextures = clampedUnits - 1
this.lightmapUnit = clampedUnits - 2
this.maxBatchTextures = clampedUnits - 2
this.batchTextures = new Array<AtlasHandle | null>(this.maxBatchTextures).fill(null)
this.program = createProgram(
@ -354,6 +424,12 @@ export class SpriteRenderer {
this.uniformZoom = requireUniform(gl, this.program, 'u_zoom')
this.uniformTextures = requireUniform(gl, this.program, 'u_textures[0]')
this.uniformPalette = gl.getUniformLocation(this.program, 'u_palette')
this.uniformLightmap = gl.getUniformLocation(this.program, 'u_lightmap')
this.uniformAmbient = gl.getUniformLocation(this.program, 'u_ambient')
this.uniformSceneOrigin = gl.getUniformLocation(this.program, 'u_sceneOrigin')
this.uniformLightOrigin = gl.getUniformLocation(this.program, 'u_lightOrigin')
this.uniformLightMode = gl.getUniformLocation(this.program, 'u_lightMode')
this.uniformBlendMode = gl.getUniformLocation(this.program, 'u_blendMode')
const samplerUnits = new Int32Array(this.maxBatchTextures)
for (let unit = 0; unit < this.maxBatchTextures; unit += 1) samplerUnits[unit] = unit
@ -362,10 +438,14 @@ export class SpriteRenderer {
if (this.uniformPalette !== null) {
gl.uniform1i(this.uniformPalette, this.paletteUnit)
}
if (this.uniformLightmap !== null) {
gl.uniform1i(this.uniformLightmap, this.lightmapUnit)
}
for (const texture of [atlasTexture, whiteTexture, paletteTexture]) {
initTextureParams(gl, texture)
}
gl.bindTexture(gl.TEXTURE_2D, whiteTexture)
if (typeof gl.texStorage2D === 'function') {
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 1, 1)
@ -739,6 +819,69 @@ export class SpriteRenderer {
}
}
/**
* Lazily acquire or allocate the 48x48 RGBA8 dynamic lightmap texture.
*/
private getLightmapTexture(): WebGLTexture {
if (this.lightmapTexture !== null) return this.lightmapTexture
const gl = this.gl
const texture = gl.createTexture()
if (texture === null) throw new RendererError('lightmap texture allocation failed')
this.lightmapTexture = texture
this.allocatedTextures.add(texture)
initTextureParams(gl, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
const emptyLightmap = new Uint8Array(48 * 48 * 4)
if (typeof gl.texStorage2D === 'function') {
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 48, 48)
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 48, 48, gl.RGBA, gl.UNSIGNED_BYTE, emptyLightmap)
} else {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 48, 48, 0, gl.RGBA, gl.UNSIGNED_BYTE, emptyLightmap)
}
return texture
}
/**
* Configure dynamic scene lighting.
*
* @param config - lighting configuration, or null to disable lighting.
*/
setLighting(config: LightingConfig | null): void {
if (this.disposed) return
if (config === null) {
if (this.lightingEnabled) {
this.flush()
this.lightingEnabled = false
}
return
}
if (!this.lightingEnabled) {
this.flush()
this.lightingEnabled = true
}
this.ambientR = config.ambient[0]
this.ambientG = config.ambient[1]
this.ambientB = config.ambient[2]
this.originSubX = config.originSubX
this.originSubY = config.originSubY
this.sceneOriginX = config.sceneOriginX
this.sceneOriginY = config.sceneOriginY
if (config.lightmap) {
const gl = this.gl
const texture = this.getLightmapTexture()
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
gl.texSubImage2D(
gl.TEXTURE_2D, 0, 0, 0, 48, 48,
gl.RGBA, gl.UNSIGNED_BYTE, config.lightmap,
)
}
}
/**
* Release an atlas texture handle and free its GPU memory.
*
@ -788,6 +931,7 @@ export class SpriteRenderer {
this.batchTextureCount = 0
this.frameDrawCalls = 0
this.frameQuads = 0
this.lightingEnabled = false
if (this.currentBlendMode !== 'normal') {
this.currentBlendMode = 'normal'
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
@ -1042,8 +1186,59 @@ export class SpriteRenderer {
this.cachedZoom = zoom
}
const lightMode = this.lightingEnabled ? 1 : 0
if (lightMode !== this.cachedLightMode && this.uniformLightMode !== null) {
gl.uniform1i(this.uniformLightMode, lightMode)
this.cachedLightMode = lightMode
}
const blendMode = this.currentBlendMode === 'additive' ? 1 : 0
if (blendMode !== this.cachedBlendMode && this.uniformBlendMode !== null) {
gl.uniform1i(this.uniformBlendMode, blendMode)
this.cachedBlendMode = blendMode
}
if (this.lightingEnabled) {
if (
(this.ambientR !== this.cachedAmbientR ||
this.ambientG !== this.cachedAmbientG ||
this.ambientB !== this.cachedAmbientB) &&
this.uniformAmbient !== null
) {
gl.uniform4f(this.uniformAmbient, this.ambientR, this.ambientG, this.ambientB, 1.0)
this.cachedAmbientR = this.ambientR
this.cachedAmbientG = this.ambientG
this.cachedAmbientB = this.ambientB
}
if (
(this.sceneOriginX !== this.cachedSceneOriginX ||
this.sceneOriginY !== this.cachedSceneOriginY) &&
this.uniformSceneOrigin !== null
) {
gl.uniform2f(this.uniformSceneOrigin, this.sceneOriginX, this.sceneOriginY)
this.cachedSceneOriginX = this.sceneOriginX
this.cachedSceneOriginY = this.sceneOriginY
}
if (
(this.originSubX !== this.cachedOriginSubX ||
this.originSubY !== this.cachedOriginSubY) &&
this.uniformLightOrigin !== null
) {
gl.uniform2f(this.uniformLightOrigin, this.originSubX, this.originSubY)
this.cachedOriginSubX = this.originSubX
this.cachedOriginSubY = this.originSubY
}
}
gl.activeTexture(gl.TEXTURE0 + this.paletteUnit)
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture)
if (this.lightingEnabled) {
const lightmap = this.getLightmapTexture()
gl.activeTexture(gl.TEXTURE0 + this.lightmapUnit)
gl.bindTexture(gl.TEXTURE_2D, lightmap)
}
for (let unit = 0; unit < this.batchTextureCount; unit += 1) {
const page = this.batchTextures[unit]
if (page === undefined || page === null) continue
@ -1068,8 +1263,18 @@ export class SpriteRenderer {
this.cachedViewportWidth = Number.NaN
this.cachedViewportHeight = Number.NaN
this.cachedZoom = Number.NaN
this.cachedAmbientR = Number.NaN
this.cachedAmbientG = Number.NaN
this.cachedAmbientB = Number.NaN
this.cachedSceneOriginX = Number.NaN
this.cachedSceneOriginY = Number.NaN
this.cachedOriginSubX = Number.NaN
this.cachedOriginSubY = Number.NaN
this.cachedLightMode = -1
this.cachedBlendMode = -1
this.atlasStorageAllocated = false
this.paletteStorageAllocated = false
this.lightmapTexture = null
this.canvas.removeEventListener('webglcontextlost', this.handleContextLost)
this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored)

View File

@ -80,6 +80,8 @@ import { Minimap } from '../ui/minimap.ts'
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
import { HudManager } from '../ui/hud-manager.ts'
import type { InventoryPanel } from '../ui/inventory.ts'
import { LightGrid } from '../game/engine/light-grid.ts'
import { Environment } from '../game/engine/environment.ts'
/** Base locations tried in order when `?base=` is absent. */
const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
@ -290,6 +292,12 @@ export interface ActSceneState {
culledDraws: number
/** Wall-clock milliseconds the last `onRender` took. */
renderMs: number
/** Active lighting mode ('Glide 1.13c' or undefined). */
lightingMode?: string | undefined
/** Ambient light intensity [0..255]. */
ambientIntensity?: number | undefined
/** Ambient color [R, G, B] in [0..255]. */
ambientColor?: readonly [number, number, number] | undefined
/** Current zoom factor and the canvas backing-store size. */
zoom: number
canvasWidth: number
@ -4339,7 +4347,7 @@ export function drawMissileProjectile(
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? shot.x + frame.anchorX : shot.x - frame.width / 2
const drawY = (frame.anchorY !== undefined ? shot.y + frame.anchorY : shot.y - frame.height / 2) - altitude
const isAdditive = missileArt.meta.name !== 'arrow'
const isAdditive = getMissileTxtData(shot.missileType ?? missileArt.meta.name).trans === 1
renderer.draw(frame, drawX, drawY, {
atlas: missileArt.handle,
...(isAdditive ? { blendMode: 'additive' } : {}),
@ -4899,6 +4907,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
})
let monsterWalkFrame = 0
const playerAnimator = new ActorAnimator()
const lightGrid = new LightGrid()
const environment = new Environment()
const lightingEnabled = param('lighting', '1') !== '0'
const switchHero = async (newHeroToken: string): Promise<boolean> => {
const nextToken = normalizeHeroToken(newHeroToken)
@ -5734,6 +5745,9 @@ if (typeof window !== 'undefined') {
state.facing = player.facing
syncEngineState(engine, state)
if (lightingEnabled) {
environment.tick(runtime.levelId, runtime.act)
}
// The automap is a view, but revealing is state: it has to advance with
// the simulation, not with however many frames the machine can draw.
@ -5762,6 +5776,108 @@ if (typeof window !== 'undefined') {
const camX = engine.world.player.x
const camY = engine.world.player.y - 16
renderer.begin({ x: camX, y: camY, zoom: camera.zoom }, [0.03, 0.03, 0.04])
if (lightingEnabled) {
const pSub = playerSubTile()
lightGrid.centerOnSubTile(pSub.x, pSub.y)
lightGrid.clear()
lightGrid.updateObstacles(runtime.grid)
// Authentic player torch light: base radius 55 sub-tiles (~11 tiles), intensity 255, white
const flicker = (Math.sin(engine.world.tick * 0.35) * 2) | 0
lightGrid.addLight({
subTileX: pSub.x,
subTileY: pSub.y,
radius: 55 + flicker,
intensity: 255,
red: 255,
green: 255,
blue: 255,
})
// Active missile lights
for (const shot of engine.projectiles) {
const mType = shot.missileType ?? (shot.skillId === '36' ? 'firebolt' : shot.skillId === '47' ? 'fireball' : undefined)
if (!mType) continue
const mData = getMissileTxtData(mType)
if (mData && mData.light > 0) {
const dx = (shot.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
const dy = (shot.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
const shotSubX = Math.round((dy + dx) / 2)
const shotSubY = Math.round((dy - dx) / 2)
lightGrid.addLight({
subTileX: shotSubX,
subTileY: shotSubY,
radius: mData.light * 5,
intensity: 255,
red: mData.red,
green: mData.green,
blue: mData.blue,
})
}
}
// Active explosion lights
for (const exp of engine.explosions) {
const expData = getMissileTxtData(exp.missileType)
const radius = (expData && expData.light > 0 ? expData.light : 8) * 5
const dx = (exp.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
const dy = (exp.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
const expSubX = Math.round((dy + dx) / 2)
const expSubY = Math.round((dy - dx) / 2)
lightGrid.addLight({
subTileX: expSubX,
subTileY: expSubY,
radius,
intensity: 255,
red: expData?.red ?? 255,
green: expData?.green ?? 160,
blue: expData?.blue ?? 50,
})
}
// Environmental additive objects (torches, flames, etc.)
if (runtime.objectDrawables) {
for (const obj of runtime.objectDrawables) {
if (obj.blendMode === 'additive' || (obj.token && ADDITIVE_OBJECT_TOKENS.has(obj.token.toUpperCase()))) {
const dx = obj.x - engine.world.player.x
const dy = obj.y - engine.world.player.y
if (dx * dx + dy * dy < 1200 * 1200) {
const odx = (obj.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
const ody = (obj.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
lightGrid.addLight({
subTileX: Math.round((ody + odx) / 2),
subTileY: Math.round((ody - odx) / 2),
radius: 35,
intensity: 220,
red: 255,
green: 180,
blue: 80,
})
}
}
}
}
const ambNorm = environment.currentIntensity / 255
const ambR = (environment.currentRed / 255) * ambNorm
const ambG = (environment.currentGreen / 255) * ambNorm
const ambB = (environment.currentBlue / 255) * ambNorm
renderer.setLighting({
ambient: [ambR, ambG, ambB],
originSubX: lightGrid.originSubX,
originSubY: lightGrid.originSubY,
sceneOriginX: runtime.grid.originX,
sceneOriginY: runtime.grid.originY,
lightmap: lightGrid.exportTextureBuffer(),
})
state.lightingMode = 'Glide 1.13c'
state.ambientIntensity = environment.currentIntensity
state.ambientColor = [environment.currentRed, environment.currentGreen, environment.currentBlue]
} else {
renderer.setLighting(null)
}
skippedDraws = 0
let culledDraws = 0
@ -6049,6 +6165,9 @@ if (typeof window !== 'undefined') {
// Roofs last, in their own pass: the engine paints them after every other
// layer so they cover the floor, the walls and anything walking under them.
drawRoofs(runtime.roofs, roofBounds)
if (lightingEnabled) {
renderer.setLighting(null)
}
// Last in the batch, so the panel sits over every world layer.
minimap.draw(
renderer,

View File

@ -0,0 +1,162 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { parseMissilesTxt, getMissileTxtData, parseOverlayTxt, getOverlayTxtData, parseColorChannel } from '../src/game/skills.ts'
import { numberCell, parseTable } from '../src/game/tables.ts'
import { CANONICAL_LEVEL_LIGHTING, getLevelLighting } from '../src/game/levels-meta.ts'
describe('P0 Data Contract & Table Repair Tests', () => {
it('1. parseColorChannel correctly preserves authentic 0 values and falls back for empty', () => {
expect(parseColorChannel('0')).toBe(0)
expect(parseColorChannel(' 0 ')).toBe(0)
expect(parseColorChannel('255')).toBe(255)
expect(parseColorChannel('128')).toBe(128)
expect(parseColorChannel('')).toBe(255)
expect(parseColorChannel(' ')).toBe(255)
expect(parseColorChannel(undefined)).toBe(255)
expect(parseColorChannel(undefined, 100)).toBe(100)
expect(parseColorChannel('invalid', 100)).toBe(100)
})
it('2. numberCell in tables.ts preserves 0 while returning fallback on empty/whitespace cells', () => {
const row = {
zeroVal: '0',
emptyVal: '',
whitespaceVal: ' ',
positiveVal: '128',
}
expect(numberCell(row, 'zeroVal', 255)).toBe(0)
expect(numberCell(row, 'emptyVal', 255)).toBe(255)
expect(numberCell(row, 'whitespaceVal', 255)).toBe(255)
expect(numberCell(row, 'missingVal', 255)).toBe(255)
expect(numberCell(row, 'positiveVal', 255)).toBe(128)
})
it('3. authentic 1.13c Missiles with 0 color channels retain 0 instead of defaulting to 255', () => {
const txtPath = join(process.cwd(), 'samples/d2/Patch_D2.mpq') // or txt
// Test with actual table rows from 1.13c Missiles.txt
const sampleTsv = [
'Missile\tId\tVel\tMaxVel\tRange\tLevRange\tCelFile\tAnimLen\tAnimSpeed\tLoopAnim\tNumDirections\tExplosionMissile\tLight\tRed\tGreen\tBlue\tTrans',
'unholybolt1\t100\t20\t20\t50\t0\tUnholyBolt\t16\t16\t1\t1\t\t5\t0\t0\t0\t2',
'curseeffectred\t101\t20\t20\t50\t0\tCurse\t16\t16\t1\t1\t\t1\t255\t0\t0\t1',
'firearrow\t12\t24\t24\t40\t0\tFireArrow\t8\t16\t0\t32\tfireexplode\t6\t255\t178\t64\t1',
'arrow\t0\t24\t24\t40\t0\tArrow\t1\t16\t0\t32\t\t0\t\t\t\t0',
].join('\n')
const map = parseMissilesTxt(sampleTsv)
const unholy = map.get('unholybolt1')!
expect(unholy).toBeDefined()
expect(unholy.light).toBe(5)
expect(unholy.red).toBe(0)
expect(unholy.green).toBe(0)
expect(unholy.blue).toBe(0)
expect(unholy.trans).toBe(2)
const curseRed = map.get('curseeffectred')!
expect(curseRed).toBeDefined()
expect(curseRed.light).toBe(1)
expect(curseRed.red).toBe(255)
expect(curseRed.green).toBe(0)
expect(curseRed.blue).toBe(0)
expect(curseRed.trans).toBe(1)
const fireArrow = map.get('firearrow')!
expect(fireArrow.trans).toBe(1)
const arrow = map.get('arrow')!
expect(arrow.trans).toBe(0)
expect(arrow.red).toBe(255) // fallback on empty
})
it('4. authentic 1.13c Overlay curse lines retain true 0 color channels', () => {
const sampleTsv = [
'overlay\tFilename\tFrames\tAnimRate\tTrans\tPreDraw\tInitRadius\tRadius\tRed\tGreen\tBlue',
'curseironmaiden\tironmaiden\t14\t16\t3\t0\t1\t6\t255\t0\t0',
'curseconfuse\tconfuse\t14\t16\t3\t0\t1\t6\t0\t255\t0',
'cursedecrepify\tdecrepify\t14\t16\t3\t0\t1\t6\t0\t0\t255',
].join('\n')
const map = parseOverlayTxt(sampleTsv)
const im = map.get('curseironmaiden')!
expect(im.red).toBe(255)
expect(im.green).toBe(0)
expect(im.blue).toBe(0)
const confuse = map.get('curseconfuse')!
expect(confuse.red).toBe(0)
expect(confuse.green).toBe(255)
expect(confuse.blue).toBe(0)
const decrepify = map.get('cursedecrepify')!
expect(decrepify.red).toBe(0)
expect(decrepify.green).toBe(0)
expect(decrepify.blue).toBe(255)
})
it('5. getMissileTxtData returns authentic trans values for physical vs magical missiles', () => {
const arrow = getMissileTxtData('arrow')
expect(arrow.trans).toBe(0)
const multi = getMissileTxtData('multipleshotarrow')
expect(multi.trans).toBe(0)
const meteor = getMissileTxtData('meteor')
expect(meteor.trans).toBe(0)
const firebolt = getMissileTxtData('firebolt')
expect(firebolt.trans).toBe(1)
const chargedbolt = getMissileTxtData('chargedbolt')
expect(chargedbolt.trans).toBe(1)
const firearrow = getMissileTxtData('firearrow')
expect(firearrow.trans).toBe(1)
const magicarrow = getMissileTxtData('magicarrow')
expect(magicarrow.trans).toBe(1)
const icearrow = getMissileTxtData('icearrow')
expect(icearrow.trans).toBe(1)
const javelin = getMissileTxtData('lightningjavelin')
expect(javelin.trans).toBe(1)
})
it('6. CANONICAL_LEVEL_LIGHTING covers all 136 1.13c levels with authentic metadata', () => {
expect(Object.keys(CANONICAL_LEVEL_LIGHTING).length).toBe(136)
// Rogue Encampment (Act 1 Town, outdoor)
const town1 = getLevelLighting(1)!
expect(town1).toBeDefined()
expect(town1.isInside).toBe(false)
expect(town1.act).toBe(1)
// Catacombs 4 (Andariel, inside)
const cata4 = getLevelLighting(39)! // Catacombs Level 4 is id 37 or 39
// Level 37: Catacombs 4
const level37 = getLevelLighting(37)!
expect(level37.isInside).toBe(true)
expect(level37.intensity).toBe(0) // 0 intensity = pitch dark, authentic 1.13c ground truth
// Lut Gholein Sewer 1 (Intensity = 8)
const sewer1 = getLevelLighting(47)!
expect(sewer1.isInside).toBe(true)
expect(sewer1.intensity).toBe(8)
// Harem (Intensity = 128)
const harem = getLevelLighting(50)!
expect(harem.isInside).toBe(true)
expect(harem.intensity).toBe(128)
// Arcane Sanctuary (NoPer = true)
const arcane = getLevelLighting(74)!
expect(arcane.noPer).toBe(true)
// Duriel's Lair (NoPer = true, inside = true)
const duriel = getLevelLighting(73)!
expect(duriel.noPer).toBe(true)
expect(duriel.isInside).toBe(true)
})
})

195
tests/light-grid.test.ts Normal file
View File

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

View File

@ -0,0 +1,378 @@
/**
* 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('attenuates light passing through obstacles in LightGrid', () => {
const grid = new LightGrid()
grid.centerOnSubTile(200, 200)
// Mock collision grid with a solid wall
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),
}
// Set obstacle mask on center cell (24, 24)
// Center sub-tile is 200. Grid origin is 200 - 192 = 8.
// Center cell (cx = 24, cy = 24) starts at subTile (8 + 24*8, 8 + 24*8) = (200, 200).
const cellStartSubX = grid.originSubX + 24 * 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
}
}
// Update obstacles
grid.updateObstacles(collision)
// Center cell should have obstacle factor 16 (4 sample points * 4 = 16)
const centerIdx = 24 * LIGHT_GRID_SIZE + 24
expect(grid.obstacle[centerIdx]).toBe(16)
// Add light at center
grid.addLight({
subTileX: 200,
subTileY: 200,
radius: 40,
intensity: 255,
red: 255,
green: 255,
blue: 255,
})
// Because obstacle factor is 16 (0x10), light is completely blocked per 0x6fb597ea
expect(grid.intensity[centerIdx]).toBe(0)
// An unblocked adjacent cell (24, 25) with obstacle 0 receives light
const unblockedIdx = 25 * LIGHT_GRID_SIZE + 24
expect(grid.obstacle[unblockedIdx]).toBe(0)
expect(grid.intensity[unblockedIdx]).toBeGreaterThan(150)
// Texture buffer export produces valid RGBA8 values
const buf = grid.exportTextureBuffer()
expect(buf.length).toBe(48 * 48 * 4)
const unblockedAlpha = buf[unblockedIdx * 4 + 3]
expect(unblockedAlpha).toBe(grid.intensity[unblockedIdx])
const unblockedRed = buf[unblockedIdx * 4]
expect(unblockedRed).toBe((grid.red[unblockedIdx] * unblockedAlpha) >> 8)
})
})