diff --git a/docs/lighting_reverse_engineering_113c.md b/docs/lighting_reverse_engineering_113c.md new file mode 100644 index 0000000..2f97be5 --- /dev/null +++ b/docs/lighting_reverse_engineering_113c.md @@ -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. diff --git a/src/game/engine/data-registry.ts b/src/game/engine/data-registry.ts index 51be024..e1cc33c 100644 --- a/src/game/engine/data-registry.ts +++ b/src/game/engine/data-registry.ts @@ -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> +} + +export interface MonStats2LightingRecord { + readonly id: string + readonly light: number + readonly lightR: number + readonly lightG: number + readonly lightB: number + readonly shadow: boolean readonly raw: Readonly> } @@ -629,6 +643,7 @@ export class D2DataRegistry { readonly charStatsByCode = new Map() readonly monStatsByHcIdx = new Map() readonly monStatsById = new Map() + readonly monStats2ById = new Map() readonly overlaysByName = new Map() readonly petTypesByIdx = new Map() readonly petTypesByName = new Map() @@ -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()) } diff --git a/src/game/engine/environment.ts b/src/game/engine/environment.ts new file mode 100644 index 0000000..21f3cb8 --- /dev/null +++ b/src/game/engine/environment.ts @@ -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, + } + } +} diff --git a/src/game/engine/light-grid.ts b/src/game/engine/light-grid.ts new file mode 100644 index 0000000..1b49a72 --- /dev/null +++ b/src/game/engine/light-grid.ts @@ -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 + } +} diff --git a/src/game/levels-meta.ts b/src/game/levels-meta.ts new file mode 100644 index 0000000..c218581 --- /dev/null +++ b/src/game/levels-meta.ts @@ -0,0 +1,1670 @@ +/** + * Authentic Diablo II v1.13c Level Lighting & Ambient Metadata. + * Extracted directly from 1.13c Patch_D2.mpq data\\global\\excel\\Levels.txt. + * Enforces Zero Runtime MPQ/DLL Architectural Invariant. + */ + +export interface LevelLightingDef { + /** 1.13c Levels.txt Id (1..136) */ + readonly id: number + /** Levels.txt Name */ + readonly name: string + /** Levels.txt LevelName (in-game display key) */ + readonly levelName: string + /** Act number (1..5) */ + readonly act: number + /** Whether the level is an indoor/underground dungeon (1 = indoor, 0 = outdoor) */ + readonly isInside: boolean + /** Base light intensity (0..255). 75 indoor levels are 0; outdoors driven by ENVIRONMENT cycle */ + readonly intensity: number + /** Red lighting component (0..255) */ + readonly red: number + /** Green lighting component (0..255) */ + readonly green: number + /** Blue lighting component (0..255) */ + readonly blue: number + /** Whether perspective mode is disabled for this level (NoPer == 1) */ + readonly noPer: boolean +} + +export const CANONICAL_LEVEL_LIGHTING: Readonly> = Object.freeze({ + 1: { + id: 1, + name: 'Act 1 - Town', + levelName: 'Rogue Encampment', + act: 1, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 2: { + id: 2, + name: 'Act 1 - Wilderness 1', + levelName: 'Blood Moor', + act: 1, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 3: { + id: 3, + name: 'Act 1 - Wilderness 2', + levelName: 'Cold Plains', + act: 1, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 4: { + id: 4, + name: 'Act 1 - Wilderness 3', + levelName: 'Stony Field', + act: 1, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 5: { + id: 5, + name: 'Act 1 - Wilderness 4', + levelName: 'Dark Wood', + act: 1, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 6: { + id: 6, + name: 'Act 1 - Wilderness 5', + levelName: 'Black Marsh', + act: 1, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 7: { + id: 7, + name: 'Act 1 - Wilderness 6', + levelName: 'Tamoe Highland', + act: 1, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 8: { + id: 8, + name: 'Act 1 - Cave 1', + levelName: 'Den of Evil', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 9: { + id: 9, + name: 'Act 1 - Cave 2', + levelName: 'Cave Level 1', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 10: { + id: 10, + name: 'Act 1 - Cave 3', + levelName: 'Underground Passage Level 1', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 11: { + id: 11, + name: 'Act 1 - Cave 4', + levelName: 'Hole Level 1', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 12: { + id: 12, + name: 'Act 1 - Cave 5', + levelName: 'Pit Level 1', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 13: { + id: 13, + name: 'Act 1 - Cave 2 Treasure', + levelName: 'Cave Level 2', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 14: { + id: 14, + name: 'Act 1 - Cave 3 Treasure', + levelName: 'Underground Passage Level 2', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 15: { + id: 15, + name: 'Act 1 - Cave 4 Treasure', + levelName: 'Hole Level 2', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 16: { + id: 16, + name: 'Act 1 - Cave 5 Treasure', + levelName: 'Pit Level 2', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 17: { + id: 17, + name: 'Act 1 - Graveyard', + levelName: 'Burial Grounds', + act: 1, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 18: { + id: 18, + name: 'Act 1 - Crypt 1 A', + levelName: 'Crypt', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 19: { + id: 19, + name: 'Act 1 - Crypt 2 A', + levelName: 'Mausoleum', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 20: { + id: 20, + name: 'Act 1 - Tower 2', + levelName: 'Forgotten Tower', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 21: { + id: 21, + name: 'Act 1 - Crypt 3 A', + levelName: 'Tower Cellar Level 1', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 22: { + id: 22, + name: 'Act 1 - Crypt 3 B', + levelName: 'Tower Cellar Level 2', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 23: { + id: 23, + name: 'Act 1 - Crypt 3 C', + levelName: 'Tower Cellar Level 3', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 24: { + id: 24, + name: 'Act 1 - Crypt 3 D', + levelName: 'Tower Cellar Level 4', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 25: { + id: 25, + name: 'Act 1 - Crypt 3 E', + levelName: 'Tower Cellar Level 5', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 26: { + id: 26, + name: 'Act 1 - Monastery', + levelName: 'Monastery Gate', + act: 1, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 27: { + id: 27, + name: 'Act 1 - Courtyard 1', + levelName: 'Outer Cloister', + act: 1, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 28: { + id: 28, + name: 'Act 1 - Barracks', + levelName: 'Barracks', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 29: { + id: 29, + name: 'Act 1 - Jail 1', + levelName: 'Jail Level 1', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 30: { + id: 30, + name: 'Act 1 - Jail 2', + levelName: 'Jail Level 2', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 31: { + id: 31, + name: 'Act 1 - Jail 3', + levelName: 'Jail Level 3', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 32: { + id: 32, + name: 'Act 1 - Courtyard 2', + levelName: 'Inner Cloister', + act: 1, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 33: { + id: 33, + name: 'Act 1 - Cathedral', + levelName: 'Cathedral', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 34: { + id: 34, + name: 'Act 1 - Catacombs 1', + levelName: 'Catacombs Level 1', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 35: { + id: 35, + name: 'Act 1 - Catacombs 2', + levelName: 'Catacombs Level 2', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 36: { + id: 36, + name: 'Act 1 - Catacombs 3', + levelName: 'Catacombs Level 3', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 37: { + id: 37, + name: 'Act 1 - Catacombs 4', + levelName: 'Catacombs Level 4', + act: 1, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 38: { + id: 38, + name: 'Act 1 - Tristram', + levelName: 'Tristram', + act: 1, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 39: { + id: 39, + name: 'Act 1 - Moo Moo Farm', + levelName: 'Moo Moo Farm', + act: 1, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 40: { + id: 40, + name: 'Act 2 - Town', + levelName: 'Lut Gholein', + act: 2, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 41: { + id: 41, + name: 'Act 2 - Desert 1', + levelName: 'Rocky Waste', + act: 2, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 42: { + id: 42, + name: 'Act 2 - Desert 2', + levelName: 'Dry Hills', + act: 2, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 43: { + id: 43, + name: 'Act 2 - Desert 3', + levelName: 'Far Oasis', + act: 2, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 44: { + id: 44, + name: 'Act 2 - Desert 4', + levelName: 'Lost City', + act: 2, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 45: { + id: 45, + name: 'Act 2 - Desert 5', + levelName: 'Valley of Snakes', + act: 2, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 46: { + id: 46, + name: 'Act 2 - Valley of the Kings', + levelName: 'Canyon of the Magi', + act: 2, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: true, + }, + 47: { + id: 47, + name: 'Act 2 - Sewer 1 A', + levelName: 'Sewers Level 1', + act: 2, + isInside: true, + intensity: 8, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 48: { + id: 48, + name: 'Act 2 - Sewer 1 B', + levelName: 'Sewers Level 2', + act: 2, + isInside: true, + intensity: 8, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 49: { + id: 49, + name: 'Act 2 - Sewer 1 C', + levelName: 'Sewers Level 3', + act: 2, + isInside: true, + intensity: 8, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 50: { + id: 50, + name: 'Act 2 - Harem', + levelName: 'Harem Level 1', + act: 2, + isInside: true, + intensity: 128, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 51: { + id: 51, + name: 'Act 2 - Corrupt Harem 1', + levelName: 'Harem Level 2', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 52: { + id: 52, + name: 'Act 2 - Basement 1', + levelName: 'Palace Cellar Level 1', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 53: { + id: 53, + name: 'Act 2 - Basement 2', + levelName: 'Palace Cellar Level 2', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 54: { + id: 54, + name: 'Act 2 - Basement 3', + levelName: 'Palace Cellar Level 3', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 55: { + id: 55, + name: 'Act 2 - Tomb 1 A', + levelName: 'Stony Tomb Level 1', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 56: { + id: 56, + name: 'Act 2 - Tomb 2 A', + levelName: 'Halls of the Dead Level 1', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 57: { + id: 57, + name: 'Act 2 - Tomb 2 B', + levelName: 'Halls of the Dead Level 2', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 58: { + id: 58, + name: 'Act 2 - Tomb 3 A', + levelName: 'Claw Viper Temple Level 1', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 59: { + id: 59, + name: 'Act 2 - Tomb 1 Treasure', + levelName: 'Stony Tomb Level 2', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 60: { + id: 60, + name: 'Act 2 - Tomb 2 Treasure', + levelName: 'Halls of the Dead Level 3', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 61: { + id: 61, + name: 'Act 2 - Tomb 3 Treasure', + levelName: 'Claw Viper Temple Level 2', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 62: { + id: 62, + name: 'Act 2 - Lair 1 A', + levelName: 'Maggot Lair Level 1', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 63: { + id: 63, + name: 'Act 2 - Lair 1 B', + levelName: 'Maggot Lair Level 2', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 64: { + id: 64, + name: 'Act 2 - Lair 1 Treasure', + levelName: 'Maggot Lair Level 3', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 65: { + id: 65, + name: 'Act 2 - Sewer 2 A', + levelName: 'Ancient Tunnels', + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 66: { + id: 66, + name: 'Act 2 - Tomb Tal 1', + levelName: "Tal Rasha's Tomb", + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 67: { + id: 67, + name: 'Act 2 - Tomb Tal 2', + levelName: "Tal Rasha's Tomb", + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 68: { + id: 68, + name: 'Act 2 - Tomb Tal 3', + levelName: "Tal Rasha's Tomb", + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 69: { + id: 69, + name: 'Act 2 - Tomb Tal 4', + levelName: "Tal Rasha's Tomb", + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 70: { + id: 70, + name: 'Act 2 - Tomb Tal 5', + levelName: "Tal Rasha's Tomb", + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 71: { + id: 71, + name: 'Act 2 - Tomb Tal 6', + levelName: "Tal Rasha's Tomb", + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 72: { + id: 72, + name: 'Act 2 - Tomb Tal 7', + levelName: "Tal Rasha's Tomb", + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 73: { + id: 73, + name: "Act 2 - Duriel's Lair", + levelName: "Duriel's Lair", + act: 2, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: true, + }, + 74: { + id: 74, + name: 'Act 2 - Arcane', + levelName: 'Arcane Sanctuary', + act: 2, + isInside: true, + intensity: 128, + red: 255, + green: 255, + blue: 255, + noPer: true, + }, + 75: { + id: 75, + name: 'Act 3 - Town', + levelName: 'Kurast Docktown', + act: 3, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 76: { + id: 76, + name: 'Act 3 - Jungle 1', + levelName: 'Spider Forest', + act: 3, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 77: { + id: 77, + name: 'Act 3 - Jungle 2', + levelName: 'Great Marsh', + act: 3, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 78: { + id: 78, + name: 'Act 3 - Jungle 3', + levelName: 'Flayer Jungle', + act: 3, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 79: { + id: 79, + name: 'Act 3 - Kurast 1', + levelName: 'Lower Kurast', + act: 3, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 80: { + id: 80, + name: 'Act 3 - Kurast 2', + levelName: 'Kurast Bazaar', + act: 3, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 81: { + id: 81, + name: 'Act 3 - Kurast 3', + levelName: 'Upper Kurast', + act: 3, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 82: { + id: 82, + name: 'Act 3 - Kurast 4', + levelName: 'Kurast Causeway', + act: 3, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 83: { + id: 83, + name: 'Act 3 - Travincal', + levelName: 'Travincal', + act: 3, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 84: { + id: 84, + name: 'Act 3 - Spider 1', + levelName: 'Spider Cave', + act: 3, + isInside: true, + intensity: 30, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 85: { + id: 85, + name: 'Act 3 - Spider 2', + levelName: 'Spider Cavern', + act: 3, + isInside: true, + intensity: 30, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 86: { + id: 86, + name: 'Act 3 - Dungeon 1 A', + levelName: 'Swampy Pit Level 1', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 87: { + id: 87, + name: 'Act 3 - Dungeon 1 B', + levelName: 'Swampy Pit Level 2', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 88: { + id: 88, + name: 'Act 3 - Dungeon 2 A', + levelName: 'Flayer Dungeon Level 1', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 89: { + id: 89, + name: 'Act 3 - Dungeon 2 B', + levelName: 'Flayer Dungeon Level 2', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 90: { + id: 90, + name: 'Act 3 - Dungeon 1 Treasure', + levelName: 'Swampy Pit Level 3', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 91: { + id: 91, + name: 'Act 3 - Dungeon 2 Treasure', + levelName: 'Flayer Dungeon Level 3', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 92: { + id: 92, + name: 'Act 3 - Sewer 1', + levelName: 'Sewers Level 1', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 93: { + id: 93, + name: 'Act 3 - Sewer 2', + levelName: 'Sewers Level 2', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 94: { + id: 94, + name: 'Act 3 - Temple 1', + levelName: 'Ruined Temple', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 95: { + id: 95, + name: 'Act 3 - Temple 2', + levelName: 'Disused Fane', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 96: { + id: 96, + name: 'Act 3 - Temple 3', + levelName: 'Forgotten Reliquary', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 97: { + id: 97, + name: 'Act 3 - Temple 4', + levelName: 'Forgotten Temple', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 98: { + id: 98, + name: 'Act 3 - Temple 5', + levelName: 'Ruined Fane', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 99: { + id: 99, + name: 'Act 3 - Temple 6', + levelName: 'Disused Reliquary', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 100: { + id: 100, + name: 'Act 3 - Mephisto 1', + levelName: 'Durance of Hate Level 1', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 101: { + id: 101, + name: 'Act 3 - Mephisto 2', + levelName: 'Durance of Hate Level 2', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 102: { + id: 102, + name: 'Act 3 - Mephisto 3', + levelName: 'Durance of Hate Level 3', + act: 3, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 103: { + id: 103, + name: 'Act 4 - Town', + levelName: 'The Pandemonium Fortress', + act: 4, + isInside: false, + intensity: 140, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 104: { + id: 104, + name: 'Act 4 - Mesa 1', + levelName: 'Outer Steppes', + act: 4, + isInside: false, + intensity: 120, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 105: { + id: 105, + name: 'Act 4 - Mesa 2', + levelName: 'Plains of Despair', + act: 4, + isInside: false, + intensity: 80, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 106: { + id: 106, + name: 'Act 4 - Mesa 3', + levelName: 'City of the Damned', + act: 4, + isInside: false, + intensity: 40, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 107: { + id: 107, + name: 'Act 4 - Lava 1', + levelName: 'River of Flame', + act: 4, + isInside: false, + intensity: 120, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 108: { + id: 108, + name: 'Act 4 - Diablo 1', + levelName: 'Chaos Sanctum', + act: 4, + isInside: false, + intensity: 80, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 109: { + id: 109, + name: 'Act 5 - Town', + levelName: 'Harrogath', + act: 5, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 110: { + id: 110, + name: 'Act 5 - Siege 1', + levelName: 'Bloody Foothills', + act: 5, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 111: { + id: 111, + name: 'Act 5 - Barricade 1', + levelName: 'Rigid Highlands', + act: 5, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 112: { + id: 112, + name: 'Act 5 - Barricade 2', + levelName: 'Arreat Plateau', + act: 5, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 113: { + id: 113, + name: 'Act 5 - Ice Cave 1', + levelName: 'Crystalized Cavern Level 1', + act: 5, + isInside: true, + intensity: 30, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 114: { + id: 114, + name: 'Act 5 - Ice Cave 1A', + levelName: 'Cellar of Pity', + act: 5, + isInside: true, + intensity: 30, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 115: { + id: 115, + name: 'Act 5 - Ice Cave 2', + levelName: 'Crystalized Cavern Level 2', + act: 5, + isInside: true, + intensity: 30, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 116: { + id: 116, + name: 'Act 5 - Ice Cave 2A', + levelName: 'Echo Chamber', + act: 5, + isInside: true, + intensity: 30, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 117: { + id: 117, + name: 'Act 5 - Barricade Snow', + levelName: 'Tundra Wastelands', + act: 5, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, + 118: { + id: 118, + name: 'Act 5 - Ice Cave 3', + levelName: 'Glacial Caves Level 1', + act: 5, + isInside: true, + intensity: 30, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 119: { + id: 119, + name: 'Act 5 - Ice Cave 3A', + levelName: 'Glacial Caves Level 2', + act: 5, + isInside: true, + intensity: 30, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 120: { + id: 120, + name: 'Act 5 - Mountain Top', + levelName: 'Rocky Summit', + act: 5, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: true, + }, + 121: { + id: 121, + name: 'Act 5 - Temple Entrance', + levelName: 'Nihlathaks Temple', + act: 5, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 122: { + id: 122, + name: 'Act 5 - Temple 1', + levelName: 'Halls of Anguish', + act: 5, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 123: { + id: 123, + name: 'Act 5 - Temple 2', + levelName: "Halls of Death's Calling", + act: 5, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 124: { + id: 124, + name: 'Act 5 - Temple Boss', + levelName: 'Halls of Vaught', + act: 5, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 125: { + id: 125, + name: 'Act 5 - Hell 1', + levelName: 'Hell1', + act: 5, + isInside: false, + intensity: 120, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 126: { + id: 126, + name: 'Act 5 - Hell 2', + levelName: 'Hell2', + act: 5, + isInside: false, + intensity: 120, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 127: { + id: 127, + name: 'Act 5 - Hell 3', + levelName: 'Hell3', + act: 5, + isInside: false, + intensity: 120, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 128: { + id: 128, + name: 'Act 5 - Baal Temple 1', + levelName: 'The Worldstone Keep Level 1', + act: 5, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 129: { + id: 129, + name: 'Act 5 - Baal Temple 2', + levelName: 'The Worldstone Keep Level 2', + act: 5, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 130: { + id: 130, + name: 'Act 5 - Baal Temple 3', + levelName: 'The Worldstone Keep Level 3', + act: 5, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 131: { + id: 131, + name: 'Act 5 - Throne Room', + levelName: 'Throne of Destruction', + act: 5, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 132: { + id: 132, + name: 'Act 5 - World Stone', + levelName: 'The Worldstone Chamber', + act: 5, + isInside: true, + intensity: 40, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 133: { + id: 133, + name: 'Act 5 - Pandemonium 1', + levelName: 'Pandemonium Run 1', + act: 5, + isInside: true, + intensity: 0, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 134: { + id: 134, + name: 'Act 5 - Pandemonium 2', + levelName: 'Pandemonium Run 2', + act: 5, + isInside: false, + intensity: 40, + red: 255, + green: 255, + blue: 255, + noPer: true, + }, + 135: { + id: 135, + name: 'Act 5 - Pandemonium 3', + levelName: 'Pandemonium Run 3', + act: 5, + isInside: false, + intensity: 120, + red: 255, + green: 255, + blue: 255, + noPer: false, + }, + 136: { + id: 136, + name: 'Act 5 - Pandemonium Finale', + levelName: 'Tristram', + act: 5, + isInside: false, + intensity: 0, + red: 0, + green: 0, + blue: 0, + noPer: false, + }, +}) + +/** + * Helper to retrieve authentic 1.13c lighting definition for any level ID. + */ +export function getLevelLighting(levelId: number): LevelLightingDef | undefined { + return CANONICAL_LEVEL_LIGHTING[levelId] +} diff --git a/src/game/skills.ts b/src/game/skills.ts index a6d1065..5562173 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -509,6 +509,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 178, blue: 64, speedPxPerSec: 500, + trans: 1, distancePx: 1000, }, fireball: { @@ -529,6 +530,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 178, blue: 64, speedPxPerSec: 500, + trans: 1, distancePx: 1000, }, fireexplode: { @@ -549,6 +551,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 178, blue: 64, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, explodingarrowexp: { @@ -569,6 +572,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 178, blue: 64, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, icebolt: { @@ -589,6 +593,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 300, + trans: 1, distancePx: 600, }, iceblast: { @@ -609,6 +614,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 300, + trans: 1, distancePx: 600, }, glacialspike: { @@ -629,6 +635,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 400, + trans: 1, distancePx: 640, }, iceexplode: { @@ -649,6 +656,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, freezeexplode: { @@ -669,6 +677,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, freezingarrowexp1: { @@ -689,6 +698,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, frozenorb: { @@ -709,6 +719,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 250, + trans: 1, distancePx: 300, }, frozenorbbolt: { @@ -729,6 +740,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 450, + trans: 1, distancePx: 450, }, frozenorbnova: { @@ -749,6 +761,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 600, + trans: 1, distancePx: 600, }, frozenorbexplode: { @@ -769,6 +782,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, blizzardcenter: { @@ -789,6 +803,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, blizzard1: { @@ -809,6 +824,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, blizzard2: { @@ -829,6 +845,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, blizzard3: { @@ -849,6 +866,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, blizzard4: { @@ -869,6 +887,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, blizzardexplode1: { @@ -889,6 +908,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, blizzardexplode2: { @@ -909,6 +929,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, blizzardexplode3: { @@ -929,6 +950,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, hydra: { @@ -949,6 +971,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 255, blue: 255, speedPxPerSec: 400, + trans: 1, distancePx: 480, }, hydrafire: { @@ -969,6 +992,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 255, blue: 255, speedPxPerSec: 400, + trans: 1, distancePx: 480, }, firewallmaker: { @@ -989,6 +1013,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 0, blue: 0, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, firewall: { @@ -1114,6 +1139,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 0, blue: 0, speedPxPerSec: 0, + trans: 0, distancePx: 0, }, meteorcenter: { @@ -1134,6 +1160,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 64, blue: 64, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, meteortail: { @@ -1154,6 +1181,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 0, blue: 0, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, meteorexplode: { @@ -1174,6 +1202,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 178, blue: 64, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, meteorfire: { @@ -1194,6 +1223,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 178, blue: 64, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, chargedbolt: { @@ -1214,6 +1244,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 255, blue: 255, speedPxPerSec: 300, + trans: 1, distancePx: 1176, }, teeth: { @@ -1234,6 +1265,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 255, blue: 255, speedPxPerSec: 400, + trans: 1, distancePx: 800, }, teethexplode: { @@ -1254,6 +1286,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 255, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, bonespear: { @@ -1274,6 +1307,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 255, blue: 255, speedPxPerSec: 600, + trans: 1, distancePx: 960, }, bonespearexplode: { @@ -1294,6 +1328,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 255, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, bonespirit: { @@ -1314,6 +1349,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 255, blue: 255, speedPxPerSec: 300, + trans: 1, distancePx: 1536, }, bonespiritexplode: { @@ -1334,6 +1370,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 255, blue: 255, speedPxPerSec: 0, + trans: 1, distancePx: 0, }, poisonnova: { @@ -1354,6 +1391,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 255, blue: 255, speedPxPerSec: 300, + trans: 1, distancePx: 360, }, holybolt: { @@ -1374,6 +1412,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 222, blue: 255, speedPxPerSec: 500, + trans: 1, distancePx: 1000, }, blessedhammer: { @@ -1394,6 +1433,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 222, blue: 255, speedPxPerSec: 450, + trans: 1, distancePx: 2160, }, magicarrow: { @@ -1414,6 +1454,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 255, blue: 255, speedPxPerSec: 600, + trans: 1, distancePx: 960, }, firearrow: { @@ -1434,6 +1475,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 178, blue: 64, speedPxPerSec: 600, + trans: 1, distancePx: 960, }, icearrow: { @@ -1454,6 +1496,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 81, blue: 255, speedPxPerSec: 600, + trans: 1, distancePx: 960, }, arrow: { @@ -1474,6 +1517,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 255, blue: 255, speedPxPerSec: 600, + trans: 0, distancePx: 960, }, multipleshotarrow: { @@ -1494,6 +1538,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = green: 255, blue: 255, speedPxPerSec: 600, + trans: 0, distancePx: 1200, }, lightningjavelin: { @@ -1514,10 +1559,23 @@ export const CANONICAL_113C_MISSILES: Readonly> = 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 { 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 { 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[] }) if ('rows' in overlaySource && !('header' in overlaySource)) { const tableRows = overlaySource.rows as readonly Readonly>[] @@ -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, diff --git a/src/game/tables.ts b/src/game/tables.ts index 54874ed..0d2c6cd 100644 --- a/src/game/tables.ts +++ b/src/game/tables.ts @@ -80,7 +80,7 @@ export function parseTable(text: string): DataTable { */ export function numberCell(row: Readonly>, 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 } diff --git a/src/render/renderer.ts b/src/render/renderer.ts index 73ace3e..95f840a 100644 --- a/src/render/renderer.ts +++ b/src/render/renderer.ts @@ -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(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) diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index c982454..86cf265 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 => { 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, diff --git a/tests/data-contract-lighting.test.ts b/tests/data-contract-lighting.test.ts new file mode 100644 index 0000000..96f03d9 --- /dev/null +++ b/tests/data-contract-lighting.test.ts @@ -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) + }) +}) diff --git a/tests/light-grid.test.ts b/tests/light-grid.test.ts new file mode 100644 index 0000000..7a8321f --- /dev/null +++ b/tests/light-grid.test.ts @@ -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) + }) +}) diff --git a/tests/scene-lighting.test.ts b/tests/scene-lighting.test.ts new file mode 100644 index 0000000..48fc388 --- /dev/null +++ b/tests/scene-lighting.test.ts @@ -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 + glDrawCalls: number + } +} { + let nextId = 1 + const stats = { + createdTextures: new Set(), + 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 = { + 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) + }) +})