Merge branch 'feat/lighting-system' into main: Diablo II v1.13c Glide dynamic lighting system
- P0: Trans === 1 missile additive blending, || 255 stripped, 136-level lighting metadata - P1: D2Client dLightMap.c & D2Common D2Environment.cpp reverse engineering - P1: LightGrid (48x48 integer dynamic lightmap, Alpha-Max-Plus-Beta-Min falloff, obstacle attenuation) - P1: Environment (25Hz day/night solar sine cycle, Act IV targets, indoor static levels) - P1: WebGL2 multi-texture shader pipeline, zero vertex stride changes (FLOATS_PER_VERTEX = 10) - P1: Full act-scene integration with player torch, missiles, explosions, additive objects, unshaded minimap overlay
This commit is contained in:
commit
f424a92a1d
|
|
@ -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.
|
||||||
|
|
@ -13,6 +13,7 @@ import { MpqArchive } from '../../mpq/archive.ts'
|
||||||
import { MountedArchives } from '../../mpq/mount.ts'
|
import { MountedArchives } from '../../mpq/mount.ts'
|
||||||
import { decodeAnimDataFile, type AnimDataFile, type AnimDataRecord } from '../../formats/animdata.ts'
|
import { decodeAnimDataFile, type AnimDataFile, type AnimDataRecord } from '../../formats/animdata.ts'
|
||||||
import { parseTable, numberCell, textCell, type DataTable } from '../tables.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 = [
|
export const MPQ_LOAD_ORDER = [
|
||||||
'd2data.mpq',
|
'd2data.mpq',
|
||||||
|
|
@ -480,6 +481,19 @@ export interface OverlayRecord {
|
||||||
readonly height3: number
|
readonly height3: number
|
||||||
readonly height4: number
|
readonly height4: number
|
||||||
readonly preDraw: boolean
|
readonly preDraw: boolean
|
||||||
|
readonly red: number
|
||||||
|
readonly green: number
|
||||||
|
readonly blue: number
|
||||||
|
readonly raw: Readonly<Record<string, string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonStats2LightingRecord {
|
||||||
|
readonly id: string
|
||||||
|
readonly light: number
|
||||||
|
readonly lightR: number
|
||||||
|
readonly lightG: number
|
||||||
|
readonly lightB: number
|
||||||
|
readonly shadow: boolean
|
||||||
readonly raw: Readonly<Record<string, string>>
|
readonly raw: Readonly<Record<string, string>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -629,6 +643,7 @@ export class D2DataRegistry {
|
||||||
readonly charStatsByCode = new Map<string, CharStatsRecord>()
|
readonly charStatsByCode = new Map<string, CharStatsRecord>()
|
||||||
readonly monStatsByHcIdx = new Map<number, MonStatsRecord>()
|
readonly monStatsByHcIdx = new Map<number, MonStatsRecord>()
|
||||||
readonly monStatsById = new Map<string, MonStatsRecord>()
|
readonly monStatsById = new Map<string, MonStatsRecord>()
|
||||||
|
readonly monStats2ById = new Map<string, MonStats2LightingRecord>()
|
||||||
readonly overlaysByName = new Map<string, OverlayRecord>()
|
readonly overlaysByName = new Map<string, OverlayRecord>()
|
||||||
readonly petTypesByIdx = new Map<number, PetTypeRecord>()
|
readonly petTypesByIdx = new Map<number, PetTypeRecord>()
|
||||||
readonly petTypesByName = new Map<string, PetTypeRecord>()
|
readonly petTypesByName = new Map<string, PetTypeRecord>()
|
||||||
|
|
@ -678,6 +693,7 @@ export class D2DataRegistry {
|
||||||
this.indexItemStatCost()
|
this.indexItemStatCost()
|
||||||
this.indexCharStats()
|
this.indexCharStats()
|
||||||
this.indexMonStats()
|
this.indexMonStats()
|
||||||
|
this.indexMonStats2()
|
||||||
this.indexOverlays()
|
this.indexOverlays()
|
||||||
this.indexPetTypes()
|
this.indexPetTypes()
|
||||||
this.indexElemTypesAndEvents()
|
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 {
|
private indexOverlays(): void {
|
||||||
let idx = 0
|
let idx = 0
|
||||||
for (const row of this.overlayTable.rows) {
|
for (const row of this.overlayTable.rows) {
|
||||||
|
|
@ -1280,6 +1313,9 @@ export class D2DataRegistry {
|
||||||
height3: numberCell(row, 'Height3', 0),
|
height3: numberCell(row, 'Height3', 0),
|
||||||
height4: numberCell(row, 'Height4', 0),
|
height4: numberCell(row, 'Height4', 0),
|
||||||
preDraw: numberCell(row, 'PreDraw', 0) !== 0,
|
preDraw: numberCell(row, 'PreDraw', 0) !== 0,
|
||||||
|
red: numberCell(row, 'Red', 255),
|
||||||
|
green: numberCell(row, 'Green', 255),
|
||||||
|
blue: numberCell(row, 'Blue', 255),
|
||||||
raw: row,
|
raw: row,
|
||||||
}
|
}
|
||||||
this.overlaysByName.set(overlay.toLowerCase(), record)
|
this.overlaysByName.set(overlay.toLowerCase(), record)
|
||||||
|
|
@ -1379,6 +1415,14 @@ export class D2DataRegistry {
|
||||||
return this.monStatsById.get(idOrHcIdx.trim().toLowerCase())
|
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 {
|
getOverlay(name: string): OverlayRecord | undefined {
|
||||||
return this.overlaysByName.get(name.trim().toLowerCase())
|
return this.overlaysByName.get(name.trim().toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -509,6 +509,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 178,
|
green: 178,
|
||||||
blue: 64,
|
blue: 64,
|
||||||
speedPxPerSec: 500,
|
speedPxPerSec: 500,
|
||||||
|
trans: 1,
|
||||||
distancePx: 1000,
|
distancePx: 1000,
|
||||||
},
|
},
|
||||||
fireball: {
|
fireball: {
|
||||||
|
|
@ -529,6 +530,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 178,
|
green: 178,
|
||||||
blue: 64,
|
blue: 64,
|
||||||
speedPxPerSec: 500,
|
speedPxPerSec: 500,
|
||||||
|
trans: 1,
|
||||||
distancePx: 1000,
|
distancePx: 1000,
|
||||||
},
|
},
|
||||||
fireexplode: {
|
fireexplode: {
|
||||||
|
|
@ -549,6 +551,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 178,
|
green: 178,
|
||||||
blue: 64,
|
blue: 64,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
explodingarrowexp: {
|
explodingarrowexp: {
|
||||||
|
|
@ -569,6 +572,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 178,
|
green: 178,
|
||||||
blue: 64,
|
blue: 64,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
icebolt: {
|
icebolt: {
|
||||||
|
|
@ -589,6 +593,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 300,
|
speedPxPerSec: 300,
|
||||||
|
trans: 1,
|
||||||
distancePx: 600,
|
distancePx: 600,
|
||||||
},
|
},
|
||||||
iceblast: {
|
iceblast: {
|
||||||
|
|
@ -609,6 +614,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 300,
|
speedPxPerSec: 300,
|
||||||
|
trans: 1,
|
||||||
distancePx: 600,
|
distancePx: 600,
|
||||||
},
|
},
|
||||||
glacialspike: {
|
glacialspike: {
|
||||||
|
|
@ -629,6 +635,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 400,
|
speedPxPerSec: 400,
|
||||||
|
trans: 1,
|
||||||
distancePx: 640,
|
distancePx: 640,
|
||||||
},
|
},
|
||||||
iceexplode: {
|
iceexplode: {
|
||||||
|
|
@ -649,6 +656,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
freezeexplode: {
|
freezeexplode: {
|
||||||
|
|
@ -669,6 +677,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
freezingarrowexp1: {
|
freezingarrowexp1: {
|
||||||
|
|
@ -689,6 +698,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
frozenorb: {
|
frozenorb: {
|
||||||
|
|
@ -709,6 +719,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 250,
|
speedPxPerSec: 250,
|
||||||
|
trans: 1,
|
||||||
distancePx: 300,
|
distancePx: 300,
|
||||||
},
|
},
|
||||||
frozenorbbolt: {
|
frozenorbbolt: {
|
||||||
|
|
@ -729,6 +740,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 450,
|
speedPxPerSec: 450,
|
||||||
|
trans: 1,
|
||||||
distancePx: 450,
|
distancePx: 450,
|
||||||
},
|
},
|
||||||
frozenorbnova: {
|
frozenorbnova: {
|
||||||
|
|
@ -749,6 +761,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 600,
|
speedPxPerSec: 600,
|
||||||
|
trans: 1,
|
||||||
distancePx: 600,
|
distancePx: 600,
|
||||||
},
|
},
|
||||||
frozenorbexplode: {
|
frozenorbexplode: {
|
||||||
|
|
@ -769,6 +782,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
blizzardcenter: {
|
blizzardcenter: {
|
||||||
|
|
@ -789,6 +803,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
blizzard1: {
|
blizzard1: {
|
||||||
|
|
@ -809,6 +824,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
blizzard2: {
|
blizzard2: {
|
||||||
|
|
@ -829,6 +845,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
blizzard3: {
|
blizzard3: {
|
||||||
|
|
@ -849,6 +866,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
blizzard4: {
|
blizzard4: {
|
||||||
|
|
@ -869,6 +887,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
blizzardexplode1: {
|
blizzardexplode1: {
|
||||||
|
|
@ -889,6 +908,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
blizzardexplode2: {
|
blizzardexplode2: {
|
||||||
|
|
@ -909,6 +929,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
blizzardexplode3: {
|
blizzardexplode3: {
|
||||||
|
|
@ -929,6 +950,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
hydra: {
|
hydra: {
|
||||||
|
|
@ -949,6 +971,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 400,
|
speedPxPerSec: 400,
|
||||||
|
trans: 1,
|
||||||
distancePx: 480,
|
distancePx: 480,
|
||||||
},
|
},
|
||||||
hydrafire: {
|
hydrafire: {
|
||||||
|
|
@ -969,6 +992,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 400,
|
speedPxPerSec: 400,
|
||||||
|
trans: 1,
|
||||||
distancePx: 480,
|
distancePx: 480,
|
||||||
},
|
},
|
||||||
firewallmaker: {
|
firewallmaker: {
|
||||||
|
|
@ -989,6 +1013,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 0,
|
green: 0,
|
||||||
blue: 0,
|
blue: 0,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
firewall: {
|
firewall: {
|
||||||
|
|
@ -1114,6 +1139,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 0,
|
green: 0,
|
||||||
blue: 0,
|
blue: 0,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 0,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
meteorcenter: {
|
meteorcenter: {
|
||||||
|
|
@ -1134,6 +1160,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 64,
|
green: 64,
|
||||||
blue: 64,
|
blue: 64,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
meteortail: {
|
meteortail: {
|
||||||
|
|
@ -1154,6 +1181,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 0,
|
green: 0,
|
||||||
blue: 0,
|
blue: 0,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
meteorexplode: {
|
meteorexplode: {
|
||||||
|
|
@ -1174,6 +1202,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 178,
|
green: 178,
|
||||||
blue: 64,
|
blue: 64,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
meteorfire: {
|
meteorfire: {
|
||||||
|
|
@ -1194,6 +1223,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 178,
|
green: 178,
|
||||||
blue: 64,
|
blue: 64,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
chargedbolt: {
|
chargedbolt: {
|
||||||
|
|
@ -1214,6 +1244,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 300,
|
speedPxPerSec: 300,
|
||||||
|
trans: 1,
|
||||||
distancePx: 1176,
|
distancePx: 1176,
|
||||||
},
|
},
|
||||||
teeth: {
|
teeth: {
|
||||||
|
|
@ -1234,6 +1265,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 400,
|
speedPxPerSec: 400,
|
||||||
|
trans: 1,
|
||||||
distancePx: 800,
|
distancePx: 800,
|
||||||
},
|
},
|
||||||
teethexplode: {
|
teethexplode: {
|
||||||
|
|
@ -1254,6 +1286,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
bonespear: {
|
bonespear: {
|
||||||
|
|
@ -1274,6 +1307,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 600,
|
speedPxPerSec: 600,
|
||||||
|
trans: 1,
|
||||||
distancePx: 960,
|
distancePx: 960,
|
||||||
},
|
},
|
||||||
bonespearexplode: {
|
bonespearexplode: {
|
||||||
|
|
@ -1294,6 +1328,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
bonespirit: {
|
bonespirit: {
|
||||||
|
|
@ -1314,6 +1349,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 300,
|
speedPxPerSec: 300,
|
||||||
|
trans: 1,
|
||||||
distancePx: 1536,
|
distancePx: 1536,
|
||||||
},
|
},
|
||||||
bonespiritexplode: {
|
bonespiritexplode: {
|
||||||
|
|
@ -1334,6 +1370,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
|
trans: 1,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
poisonnova: {
|
poisonnova: {
|
||||||
|
|
@ -1354,6 +1391,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 300,
|
speedPxPerSec: 300,
|
||||||
|
trans: 1,
|
||||||
distancePx: 360,
|
distancePx: 360,
|
||||||
},
|
},
|
||||||
holybolt: {
|
holybolt: {
|
||||||
|
|
@ -1374,6 +1412,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 222,
|
green: 222,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 500,
|
speedPxPerSec: 500,
|
||||||
|
trans: 1,
|
||||||
distancePx: 1000,
|
distancePx: 1000,
|
||||||
},
|
},
|
||||||
blessedhammer: {
|
blessedhammer: {
|
||||||
|
|
@ -1394,6 +1433,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 222,
|
green: 222,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 450,
|
speedPxPerSec: 450,
|
||||||
|
trans: 1,
|
||||||
distancePx: 2160,
|
distancePx: 2160,
|
||||||
},
|
},
|
||||||
magicarrow: {
|
magicarrow: {
|
||||||
|
|
@ -1414,6 +1454,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 600,
|
speedPxPerSec: 600,
|
||||||
|
trans: 1,
|
||||||
distancePx: 960,
|
distancePx: 960,
|
||||||
},
|
},
|
||||||
firearrow: {
|
firearrow: {
|
||||||
|
|
@ -1434,6 +1475,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 178,
|
green: 178,
|
||||||
blue: 64,
|
blue: 64,
|
||||||
speedPxPerSec: 600,
|
speedPxPerSec: 600,
|
||||||
|
trans: 1,
|
||||||
distancePx: 960,
|
distancePx: 960,
|
||||||
},
|
},
|
||||||
icearrow: {
|
icearrow: {
|
||||||
|
|
@ -1454,6 +1496,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 81,
|
green: 81,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 600,
|
speedPxPerSec: 600,
|
||||||
|
trans: 1,
|
||||||
distancePx: 960,
|
distancePx: 960,
|
||||||
},
|
},
|
||||||
arrow: {
|
arrow: {
|
||||||
|
|
@ -1474,6 +1517,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 600,
|
speedPxPerSec: 600,
|
||||||
|
trans: 0,
|
||||||
distancePx: 960,
|
distancePx: 960,
|
||||||
},
|
},
|
||||||
multipleshotarrow: {
|
multipleshotarrow: {
|
||||||
|
|
@ -1494,6 +1538,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 600,
|
speedPxPerSec: 600,
|
||||||
|
trans: 0,
|
||||||
distancePx: 1200,
|
distancePx: 1200,
|
||||||
},
|
},
|
||||||
lightningjavelin: {
|
lightningjavelin: {
|
||||||
|
|
@ -1514,10 +1559,23 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
green: 255,
|
green: 255,
|
||||||
blue: 255,
|
blue: 255,
|
||||||
speedPxPerSec: 750,
|
speedPxPerSec: 750,
|
||||||
|
trans: 1,
|
||||||
distancePx: 750,
|
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.
|
* Parses raw Missiles.txt text into a Map of missile records.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1564,9 +1622,9 @@ export function parseMissilesTxt(content: string): Map<string, MissileTxtData> {
|
||||||
const numDirections = Number(cells[numDirIdx] ?? 0) || 1
|
const numDirections = Number(cells[numDirIdx] ?? 0) || 1
|
||||||
const explosionMissile = (cells[explIdx] ?? '').trim()
|
const explosionMissile = (cells[explIdx] ?? '').trim()
|
||||||
const light = Number(cells[lightIdx] ?? 0) || 0
|
const light = Number(cells[lightIdx] ?? 0) || 0
|
||||||
const red = Number(cells[redIdx] ?? 255) || 255
|
const red = parseColorChannel(cells[redIdx])
|
||||||
const green = Number(cells[greenIdx] ?? 255) || 255
|
const green = parseColorChannel(cells[greenIdx])
|
||||||
const blue = Number(cells[blueIdx] ?? 255) || 255
|
const blue = parseColorChannel(cells[blueIdx])
|
||||||
const nextHit = Number(cells[nextHitIdx] ?? 0) || 0
|
const nextHit = Number(cells[nextHitIdx] ?? 0) || 0
|
||||||
const nextDelay = Number(cells[nextDelayIdx] ?? 0) || 0
|
const nextDelay = Number(cells[nextDelayIdx] ?? 0) || 0
|
||||||
const trans = Number(cells[transIdx] ?? 0) || 0
|
const trans = Number(cells[transIdx] ?? 0) || 0
|
||||||
|
|
@ -1633,6 +1691,7 @@ export function getMissileTxtData(
|
||||||
red: 255,
|
red: 255,
|
||||||
green: 178,
|
green: 178,
|
||||||
blue: 64,
|
blue: 64,
|
||||||
|
trans: 0,
|
||||||
speedPxPerSec: 500,
|
speedPxPerSec: 500,
|
||||||
distancePx: 1000,
|
distancePx: 1000,
|
||||||
}
|
}
|
||||||
|
|
@ -1665,9 +1724,11 @@ export function getMissileTxtData(
|
||||||
const numDirections = Number(row[t.header.indexOf('NumDirections')] ?? 0) || 1
|
const numDirections = Number(row[t.header.indexOf('NumDirections')] ?? 0) || 1
|
||||||
const explosionMissile = (row[t.header.indexOf('ExplosionMissile')] ?? '').trim()
|
const explosionMissile = (row[t.header.indexOf('ExplosionMissile')] ?? '').trim()
|
||||||
const light = Number(row[t.header.indexOf('Light')] ?? 0) || 0
|
const light = Number(row[t.header.indexOf('Light')] ?? 0) || 0
|
||||||
const red = Number(row[t.header.indexOf('Red')] ?? 255) || 255
|
const red = parseColorChannel(row[t.header.indexOf('Red')])
|
||||||
const green = Number(row[t.header.indexOf('Green')] ?? 255) || 255
|
const green = parseColorChannel(row[t.header.indexOf('Green')])
|
||||||
const blue = Number(row[t.header.indexOf('Blue')] ?? 255) || 255
|
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 {
|
return {
|
||||||
name: key,
|
name: key,
|
||||||
id: Number(row[t.header.indexOf('Id')] ?? 0) || 0,
|
id: Number(row[t.header.indexOf('Id')] ?? 0) || 0,
|
||||||
|
|
@ -1685,6 +1746,7 @@ export function getMissileTxtData(
|
||||||
red,
|
red,
|
||||||
green,
|
green,
|
||||||
blue,
|
blue,
|
||||||
|
trans,
|
||||||
speedPxPerSec: vel * 25,
|
speedPxPerSec: vel * 25,
|
||||||
distancePx: vel * range,
|
distancePx: vel * range,
|
||||||
}
|
}
|
||||||
|
|
@ -1710,9 +1772,10 @@ export function getMissileTxtData(
|
||||||
const numDirections = Number(row['NumDirections'] ?? row['numdirections'] ?? 0) || 1
|
const numDirections = Number(row['NumDirections'] ?? row['numdirections'] ?? 0) || 1
|
||||||
const explosionMissile = (row['ExplosionMissile'] ?? row['explosionmissile'] ?? '').trim()
|
const explosionMissile = (row['ExplosionMissile'] ?? row['explosionmissile'] ?? '').trim()
|
||||||
const light = Number(row['Light'] ?? row['light'] ?? 0) || 0
|
const light = Number(row['Light'] ?? row['light'] ?? 0) || 0
|
||||||
const red = Number(row['Red'] ?? row['red'] ?? 255) || 255
|
const red = parseColorChannel(row['Red'] ?? row['red'])
|
||||||
const green = Number(row['Green'] ?? row['green'] ?? 255) || 255
|
const green = parseColorChannel(row['Green'] ?? row['green'])
|
||||||
const blue = Number(row['Blue'] ?? row['blue'] ?? 255) || 255
|
const blue = parseColorChannel(row['Blue'] ?? row['blue'])
|
||||||
|
const trans = Number(row['Trans'] ?? row['trans'] ?? 0) || 0
|
||||||
return {
|
return {
|
||||||
name: key,
|
name: key,
|
||||||
id: Number(row['Id'] ?? row['id'] ?? 0) || 0,
|
id: Number(row['Id'] ?? row['id'] ?? 0) || 0,
|
||||||
|
|
@ -1730,6 +1793,7 @@ export function getMissileTxtData(
|
||||||
red,
|
red,
|
||||||
green,
|
green,
|
||||||
blue,
|
blue,
|
||||||
|
trans,
|
||||||
speedPxPerSec: vel * 25,
|
speedPxPerSec: vel * 25,
|
||||||
distancePx: vel * range,
|
distancePx: vel * range,
|
||||||
}
|
}
|
||||||
|
|
@ -3408,9 +3472,9 @@ export function parseOverlayTxt(content: string): Map<string, OverlayTxtData> {
|
||||||
const preDraw = Number(cells[preDrawIdx] ?? 0) !== 0
|
const preDraw = Number(cells[preDrawIdx] ?? 0) !== 0
|
||||||
const initRadius = Number(cells[initRadiusIdx] ?? 0) || 0
|
const initRadius = Number(cells[initRadiusIdx] ?? 0) || 0
|
||||||
const radius = Number(cells[radiusIdx] ?? 0) || 0
|
const radius = Number(cells[radiusIdx] ?? 0) || 0
|
||||||
const red = Number(cells[redIdx] ?? 255) || 255
|
const red = parseColorChannel(cells[redIdx])
|
||||||
const green = Number(cells[greenIdx] ?? 255) || 255
|
const green = parseColorChannel(cells[greenIdx])
|
||||||
const blue = Number(cells[blueIdx] ?? 255) || 255
|
const blue = parseColorChannel(cells[blueIdx])
|
||||||
const numDirections = Number(cells[numDirIdx] ?? 1) || 1
|
const numDirections = Number(cells[numDirIdx] ?? 1) || 1
|
||||||
const xOffset = Number(cells[xOffsetIdx] ?? 0) || 0
|
const xOffset = Number(cells[xOffsetIdx] ?? 0) || 0
|
||||||
const yOffset = Number(cells[yOffsetIdx] ?? 0) || 0
|
const yOffset = Number(cells[yOffsetIdx] ?? 0) || 0
|
||||||
|
|
@ -3477,6 +3541,35 @@ export function getOverlayTxtData(
|
||||||
return overlaySource.get(key) ?? getOverlayTxtData(overlayName, undefined)
|
return overlaySource.get(key) ?? getOverlayTxtData(overlayName, undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D2Table ({ header, rows })
|
||||||
|
if ('header' in overlaySource && Array.isArray((overlaySource as any).header)) {
|
||||||
|
const t = overlaySource as { header: readonly string[]; rows: readonly (readonly string[])[] }
|
||||||
|
const oIdx = t.header.indexOf('Overlay')
|
||||||
|
const fIdx = t.header.indexOf('Filename')
|
||||||
|
const row = t.rows.find(
|
||||||
|
r => (oIdx !== -1 && (r[oIdx] ?? '').trim().toLowerCase() === key) ||
|
||||||
|
(fIdx !== -1 && (r[fIdx] ?? '').trim().toLowerCase() === key),
|
||||||
|
)
|
||||||
|
if (row) {
|
||||||
|
return {
|
||||||
|
overlay: oIdx !== -1 ? (row[oIdx] ?? key).trim() : key,
|
||||||
|
filename: fIdx !== -1 ? (row[fIdx] ?? '').trim() : '',
|
||||||
|
frames: Number(row[t.header.indexOf('Frames')] ?? 1) || 1,
|
||||||
|
animRate: Number(row[t.header.indexOf('AnimRate')] ?? 16) || 16,
|
||||||
|
trans: Number(row[t.header.indexOf('Trans')] ?? 0) || 0,
|
||||||
|
preDraw: Number(row[t.header.indexOf('PreDraw')] ?? 0) !== 0,
|
||||||
|
initRadius: Number(row[t.header.indexOf('InitRadius')] ?? 0) || 0,
|
||||||
|
radius: Number(row[t.header.indexOf('Radius')] ?? 0) || 0,
|
||||||
|
red: parseColorChannel(row[t.header.indexOf('Red')]),
|
||||||
|
green: parseColorChannel(row[t.header.indexOf('Green')]),
|
||||||
|
blue: parseColorChannel(row[t.header.indexOf('Blue')]),
|
||||||
|
numDirections: Number(row[t.header.indexOf('NumDirections')] ?? 1) || 1,
|
||||||
|
xOffset: Number(row[t.header.indexOf('Xoffset')] ?? 0) || 0,
|
||||||
|
yOffset: Number(row[t.header.indexOf('Yoffset')] ?? 0) || 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DataTable ({ rows: Record<string, string>[] })
|
// DataTable ({ rows: Record<string, string>[] })
|
||||||
if ('rows' in overlaySource && !('header' in overlaySource)) {
|
if ('rows' in overlaySource && !('header' in overlaySource)) {
|
||||||
const tableRows = overlaySource.rows as readonly Readonly<Record<string, string>>[]
|
const tableRows = overlaySource.rows as readonly Readonly<Record<string, string>>[]
|
||||||
|
|
@ -3494,9 +3587,9 @@ export function getOverlayTxtData(
|
||||||
preDraw: Number(row['PreDraw'] ?? row['predraw'] ?? 0) !== 0,
|
preDraw: Number(row['PreDraw'] ?? row['predraw'] ?? 0) !== 0,
|
||||||
initRadius: Number(row['InitRadius'] ?? row['initradius'] ?? 0) || 0,
|
initRadius: Number(row['InitRadius'] ?? row['initradius'] ?? 0) || 0,
|
||||||
radius: Number(row['Radius'] ?? row['radius'] ?? 0) || 0,
|
radius: Number(row['Radius'] ?? row['radius'] ?? 0) || 0,
|
||||||
red: Number(row['Red'] ?? row['red'] ?? 255) || 255,
|
red: parseColorChannel(row['Red'] ?? row['red']),
|
||||||
green: Number(row['Green'] ?? row['green'] ?? 255) || 255,
|
green: parseColorChannel(row['Green'] ?? row['green']),
|
||||||
blue: Number(row['Blue'] ?? row['blue'] ?? 255) || 255,
|
blue: parseColorChannel(row['Blue'] ?? row['blue']),
|
||||||
numDirections: Number(row['NumDirections'] ?? row['numdirections'] ?? 1) || 1,
|
numDirections: Number(row['NumDirections'] ?? row['numdirections'] ?? 1) || 1,
|
||||||
xOffset: Number(row['Xoffset'] ?? row['xoffset'] ?? 0) || 0,
|
xOffset: Number(row['Xoffset'] ?? row['xoffset'] ?? 0) || 0,
|
||||||
yOffset: Number(row['Yoffset'] ?? row['yoffset'] ?? 0) || 0,
|
yOffset: Number(row['Yoffset'] ?? row['yoffset'] ?? 0) || 0,
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ export function parseTable(text: string): DataTable {
|
||||||
*/
|
*/
|
||||||
export function numberCell(row: Readonly<Record<string, string>>, column: string, fallback: number): number {
|
export function numberCell(row: Readonly<Record<string, string>>, column: string, fallback: number): number {
|
||||||
const raw = row[column]
|
const raw = row[column]
|
||||||
if (raw === undefined) return fallback
|
if (raw === undefined || raw.trim() === '') return fallback
|
||||||
const value = Number(raw)
|
const value = Number(raw)
|
||||||
return Number.isFinite(value) ? value : fallback
|
return Number.isFinite(value) ? value : fallback
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,20 @@ export interface DrawOptions {
|
||||||
readonly blendMode?: 'normal' | 'additive'
|
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. */
|
/** Configuration options and event callbacks for SpriteRenderer. */
|
||||||
export interface RendererOptions {
|
export interface RendererOptions {
|
||||||
/** Optional callback invoked when the WebGL context is lost. */
|
/** Optional callback invoked when the WebGL context is lost. */
|
||||||
|
|
@ -107,7 +121,7 @@ const VERTICES_PER_QUAD = 4
|
||||||
/** Indices per quad (two triangles). */
|
/** Indices per quad (two triangles). */
|
||||||
const INDICES_PER_QUAD = 6
|
const INDICES_PER_QUAD = 6
|
||||||
/** Floats per vertex: x, y, u, v, r, g, b, a, unit, paletteRow. */
|
/** 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. */
|
/** Initial batch capacity in quads. */
|
||||||
const INITIAL_CAPACITY = 8192
|
const INITIAL_CAPACITY = 8192
|
||||||
/**
|
/**
|
||||||
|
|
@ -132,8 +146,11 @@ in float a_palette;
|
||||||
uniform vec2 u_camera;
|
uniform vec2 u_camera;
|
||||||
uniform vec2 u_viewport;
|
uniform vec2 u_viewport;
|
||||||
uniform float u_zoom;
|
uniform float u_zoom;
|
||||||
|
uniform vec2 u_sceneOrigin;
|
||||||
|
uniform vec2 u_lightOrigin;
|
||||||
out vec2 v_uv;
|
out vec2 v_uv;
|
||||||
out vec4 v_tint;
|
out vec4 v_tint;
|
||||||
|
out vec2 v_lightUv;
|
||||||
flat out int v_unit;
|
flat out int v_unit;
|
||||||
flat out int v_palette;
|
flat out int v_palette;
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -144,6 +161,17 @@ void main() {
|
||||||
v_tint = a_tint;
|
v_tint = a_tint;
|
||||||
v_unit = int(a_unit);
|
v_unit = int(a_unit);
|
||||||
v_palette = int(a_palette);
|
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
|
* 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`.
|
* 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 units - number of batch samplers to expose.
|
||||||
* @param options - compilation options including alphaDiscard.
|
* @param options - compilation options including alphaDiscard.
|
||||||
|
|
@ -178,10 +208,15 @@ export function fragmentShaderSource(units: number, options: { alphaDiscard?: bo
|
||||||
precision highp float;
|
precision highp float;
|
||||||
in vec2 v_uv;
|
in vec2 v_uv;
|
||||||
in vec4 v_tint;
|
in vec4 v_tint;
|
||||||
|
in vec2 v_lightUv;
|
||||||
flat in int v_unit;
|
flat in int v_unit;
|
||||||
flat in int v_palette;
|
flat in int v_palette;
|
||||||
uniform sampler2D u_textures[${String(units)}];
|
uniform sampler2D u_textures[${String(units)}];
|
||||||
uniform sampler2D u_palette;
|
uniform sampler2D u_palette;
|
||||||
|
uniform sampler2D u_lightmap;
|
||||||
|
uniform vec4 u_ambient;
|
||||||
|
uniform int u_lightMode;
|
||||||
|
uniform int u_blendMode;
|
||||||
out vec4 outColor;
|
out vec4 outColor;
|
||||||
void main() {
|
void main() {
|
||||||
vec4 texel;
|
vec4 texel;
|
||||||
|
|
@ -190,7 +225,13 @@ ${cases.join('\n')}
|
||||||
default: texel = texture(u_textures[0], v_uv); break;
|
default: texel = texture(u_textures[0], v_uv); break;
|
||||||
}
|
}
|
||||||
${paletteLookup}${discardAlpha}
|
${paletteLookup}${discardAlpha}
|
||||||
|
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;
|
outColor = texel * v_tint;
|
||||||
|
}
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,6 +264,7 @@ export class SpriteRenderer {
|
||||||
private atlasTexture: WebGLTexture
|
private atlasTexture: WebGLTexture
|
||||||
private readonly whiteTexture: WebGLTexture
|
private readonly whiteTexture: WebGLTexture
|
||||||
private readonly paletteTexture: WebGLTexture
|
private readonly paletteTexture: WebGLTexture
|
||||||
|
private lightmapTexture: WebGLTexture | null = null
|
||||||
/** The 1×1 white page used by `drawSolid`. */
|
/** The 1×1 white page used by `drawSolid`. */
|
||||||
private readonly whiteAtlas: AtlasHandle
|
private readonly whiteAtlas: AtlasHandle
|
||||||
/** The default page (what `setAtlas` uploads into). */
|
/** The default page (what `setAtlas` uploads into). */
|
||||||
|
|
@ -232,6 +274,12 @@ export class SpriteRenderer {
|
||||||
private readonly uniformZoom: WebGLUniformLocation
|
private readonly uniformZoom: WebGLUniformLocation
|
||||||
private readonly uniformTextures: WebGLUniformLocation
|
private readonly uniformTextures: WebGLUniformLocation
|
||||||
private readonly uniformPalette: WebGLUniformLocation | null
|
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 vertices: Float32Array
|
||||||
private indices: Uint32Array
|
private indices: Uint32Array
|
||||||
private quadCount = 0
|
private quadCount = 0
|
||||||
|
|
@ -245,15 +293,36 @@ export class SpriteRenderer {
|
||||||
private cachedViewportWidth = Number.NaN
|
private cachedViewportWidth = Number.NaN
|
||||||
private cachedViewportHeight = Number.NaN
|
private cachedViewportHeight = Number.NaN
|
||||||
private cachedZoom = 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
|
private readonly maxBatchTextures: number
|
||||||
/** Dedicated texture unit for the 256-color hardware palette texture. */
|
/** Dedicated texture unit for the 256-color hardware palette texture. */
|
||||||
private readonly paletteUnit: number
|
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. */
|
/** Pages bound to units `0..batchTextureCount-1` for the pending batch. */
|
||||||
private readonly batchTextures: (AtlasHandle | null)[]
|
private readonly batchTextures: (AtlasHandle | null)[]
|
||||||
private batchTextureCount = 0
|
private batchTextureCount = 0
|
||||||
private camera: Camera = { x: 0, y: 0, zoom: 1 }
|
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}. */
|
/** `drawElements` calls issued since the last {@link begin}. */
|
||||||
private frameDrawCalls = 0
|
private frameDrawCalls = 0
|
||||||
/** Quads submitted since the last {@link begin}. */
|
/** 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 reportedUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null
|
||||||
const availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES
|
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.paletteUnit = clampedUnits - 1
|
||||||
this.maxBatchTextures = clampedUnits - 1
|
this.lightmapUnit = clampedUnits - 2
|
||||||
|
this.maxBatchTextures = clampedUnits - 2
|
||||||
this.batchTextures = new Array<AtlasHandle | null>(this.maxBatchTextures).fill(null)
|
this.batchTextures = new Array<AtlasHandle | null>(this.maxBatchTextures).fill(null)
|
||||||
|
|
||||||
this.program = createProgram(
|
this.program = createProgram(
|
||||||
|
|
@ -354,6 +424,12 @@ export class SpriteRenderer {
|
||||||
this.uniformZoom = requireUniform(gl, this.program, 'u_zoom')
|
this.uniformZoom = requireUniform(gl, this.program, 'u_zoom')
|
||||||
this.uniformTextures = requireUniform(gl, this.program, 'u_textures[0]')
|
this.uniformTextures = requireUniform(gl, this.program, 'u_textures[0]')
|
||||||
this.uniformPalette = gl.getUniformLocation(this.program, 'u_palette')
|
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)
|
const samplerUnits = new Int32Array(this.maxBatchTextures)
|
||||||
for (let unit = 0; unit < this.maxBatchTextures; unit += 1) samplerUnits[unit] = unit
|
for (let unit = 0; unit < this.maxBatchTextures; unit += 1) samplerUnits[unit] = unit
|
||||||
|
|
@ -362,10 +438,14 @@ export class SpriteRenderer {
|
||||||
if (this.uniformPalette !== null) {
|
if (this.uniformPalette !== null) {
|
||||||
gl.uniform1i(this.uniformPalette, this.paletteUnit)
|
gl.uniform1i(this.uniformPalette, this.paletteUnit)
|
||||||
}
|
}
|
||||||
|
if (this.uniformLightmap !== null) {
|
||||||
|
gl.uniform1i(this.uniformLightmap, this.lightmapUnit)
|
||||||
|
}
|
||||||
|
|
||||||
for (const texture of [atlasTexture, whiteTexture, paletteTexture]) {
|
for (const texture of [atlasTexture, whiteTexture, paletteTexture]) {
|
||||||
initTextureParams(gl, texture)
|
initTextureParams(gl, texture)
|
||||||
}
|
}
|
||||||
|
|
||||||
gl.bindTexture(gl.TEXTURE_2D, whiteTexture)
|
gl.bindTexture(gl.TEXTURE_2D, whiteTexture)
|
||||||
if (typeof gl.texStorage2D === 'function') {
|
if (typeof gl.texStorage2D === 'function') {
|
||||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 1, 1)
|
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.
|
* Release an atlas texture handle and free its GPU memory.
|
||||||
*
|
*
|
||||||
|
|
@ -788,6 +931,7 @@ export class SpriteRenderer {
|
||||||
this.batchTextureCount = 0
|
this.batchTextureCount = 0
|
||||||
this.frameDrawCalls = 0
|
this.frameDrawCalls = 0
|
||||||
this.frameQuads = 0
|
this.frameQuads = 0
|
||||||
|
this.lightingEnabled = false
|
||||||
if (this.currentBlendMode !== 'normal') {
|
if (this.currentBlendMode !== 'normal') {
|
||||||
this.currentBlendMode = 'normal'
|
this.currentBlendMode = 'normal'
|
||||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||||
|
|
@ -1042,8 +1186,59 @@ export class SpriteRenderer {
|
||||||
this.cachedZoom = zoom
|
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.activeTexture(gl.TEXTURE0 + this.paletteUnit)
|
||||||
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture)
|
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) {
|
for (let unit = 0; unit < this.batchTextureCount; unit += 1) {
|
||||||
const page = this.batchTextures[unit]
|
const page = this.batchTextures[unit]
|
||||||
if (page === undefined || page === null) continue
|
if (page === undefined || page === null) continue
|
||||||
|
|
@ -1068,8 +1263,18 @@ export class SpriteRenderer {
|
||||||
this.cachedViewportWidth = Number.NaN
|
this.cachedViewportWidth = Number.NaN
|
||||||
this.cachedViewportHeight = Number.NaN
|
this.cachedViewportHeight = Number.NaN
|
||||||
this.cachedZoom = 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.atlasStorageAllocated = false
|
||||||
this.paletteStorageAllocated = false
|
this.paletteStorageAllocated = false
|
||||||
|
this.lightmapTexture = null
|
||||||
|
|
||||||
this.canvas.removeEventListener('webglcontextlost', this.handleContextLost)
|
this.canvas.removeEventListener('webglcontextlost', this.handleContextLost)
|
||||||
this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored)
|
this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored)
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ import { Minimap } from '../ui/minimap.ts'
|
||||||
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
|
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
|
||||||
import { HudManager } from '../ui/hud-manager.ts'
|
import { HudManager } from '../ui/hud-manager.ts'
|
||||||
import type { InventoryPanel } from '../ui/inventory.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. */
|
/** Base locations tried in order when `?base=` is absent. */
|
||||||
const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
|
const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
|
||||||
|
|
@ -290,6 +292,12 @@ export interface ActSceneState {
|
||||||
culledDraws: number
|
culledDraws: number
|
||||||
/** Wall-clock milliseconds the last `onRender` took. */
|
/** Wall-clock milliseconds the last `onRender` took. */
|
||||||
renderMs: number
|
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. */
|
/** Current zoom factor and the canvas backing-store size. */
|
||||||
zoom: number
|
zoom: number
|
||||||
canvasWidth: number
|
canvasWidth: number
|
||||||
|
|
@ -4339,7 +4347,7 @@ export function drawMissileProjectile(
|
||||||
if (frame !== undefined) {
|
if (frame !== undefined) {
|
||||||
const drawX = frame.anchorX !== undefined ? shot.x + frame.anchorX : shot.x - frame.width / 2
|
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 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, {
|
renderer.draw(frame, drawX, drawY, {
|
||||||
atlas: missileArt.handle,
|
atlas: missileArt.handle,
|
||||||
...(isAdditive ? { blendMode: 'additive' } : {}),
|
...(isAdditive ? { blendMode: 'additive' } : {}),
|
||||||
|
|
@ -4899,6 +4907,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
})
|
})
|
||||||
let monsterWalkFrame = 0
|
let monsterWalkFrame = 0
|
||||||
const playerAnimator = new ActorAnimator()
|
const playerAnimator = new ActorAnimator()
|
||||||
|
const lightGrid = new LightGrid()
|
||||||
|
const environment = new Environment()
|
||||||
|
const lightingEnabled = param('lighting', '1') !== '0'
|
||||||
|
|
||||||
const switchHero = async (newHeroToken: string): Promise<boolean> => {
|
const switchHero = async (newHeroToken: string): Promise<boolean> => {
|
||||||
const nextToken = normalizeHeroToken(newHeroToken)
|
const nextToken = normalizeHeroToken(newHeroToken)
|
||||||
|
|
@ -5734,6 +5745,9 @@ if (typeof window !== 'undefined') {
|
||||||
state.facing = player.facing
|
state.facing = player.facing
|
||||||
|
|
||||||
syncEngineState(engine, state)
|
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 automap is a view, but revealing is state: it has to advance with
|
||||||
// the simulation, not with however many frames the machine can draw.
|
// 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 camX = engine.world.player.x
|
||||||
const camY = engine.world.player.y - 16
|
const camY = engine.world.player.y - 16
|
||||||
renderer.begin({ x: camX, y: camY, zoom: camera.zoom }, [0.03, 0.03, 0.04])
|
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
|
skippedDraws = 0
|
||||||
let culledDraws = 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
|
// 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.
|
// layer so they cover the floor, the walls and anything walking under them.
|
||||||
drawRoofs(runtime.roofs, roofBounds)
|
drawRoofs(runtime.roofs, roofBounds)
|
||||||
|
if (lightingEnabled) {
|
||||||
|
renderer.setLighting(null)
|
||||||
|
}
|
||||||
// Last in the batch, so the panel sits over every world layer.
|
// Last in the batch, so the panel sits over every world layer.
|
||||||
minimap.draw(
|
minimap.draw(
|
||||||
renderer,
|
renderer,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
/**
|
||||||
|
* Integration tests for Diablo II v1.13c Dynamic Lighting & Environment System.
|
||||||
|
*
|
||||||
|
* Verifies:
|
||||||
|
* 1. Single-pass draw call batching with dynamic lighting enabled (drawCalls === 1).
|
||||||
|
* 2. Exact 10-float vertex stride invariant (FLOATS_PER_VERTEX === 10).
|
||||||
|
* 3. Lazy 48x48 RGBA8 lightmap texture allocation upon first setLighting() invocation.
|
||||||
|
* 4. Additive blend mode bypass (missiles & glowing spell overlays stay radiant in darkness).
|
||||||
|
* 5. Environment ambient calculations for outdoor, indoor, and Act IV levels.
|
||||||
|
* 6. LightGrid obstacle attenuation & Alpha Max Plus Beta Min falloff.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
SpriteRenderer,
|
||||||
|
FLOATS_PER_VERTEX,
|
||||||
|
type LightingConfig,
|
||||||
|
} from '../src/render/renderer.ts'
|
||||||
|
import { LightGrid, LIGHT_GRID_SIZE } from '../src/game/engine/light-grid.ts'
|
||||||
|
import { Environment } from '../src/game/engine/environment.ts'
|
||||||
|
import { getLevelLighting } from '../src/game/levels-meta.ts'
|
||||||
|
import { COLLIDE_VISIBLE, COLLIDE_WALL, type CollisionGrid } from '../src/game/d2map.ts'
|
||||||
|
|
||||||
|
interface MockResource {
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockCanvas(): {
|
||||||
|
canvas: any
|
||||||
|
stats: {
|
||||||
|
createdTextures: Set<MockResource>
|
||||||
|
glDrawCalls: number
|
||||||
|
}
|
||||||
|
} {
|
||||||
|
let nextId = 1
|
||||||
|
const stats = {
|
||||||
|
createdTextures: new Set<MockResource>(),
|
||||||
|
glDrawCalls: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const gl: any = {
|
||||||
|
VERTEX_SHADER: 35633,
|
||||||
|
FRAGMENT_SHADER: 35632,
|
||||||
|
COMPILE_STATUS: 35713,
|
||||||
|
LINK_STATUS: 35714,
|
||||||
|
MAX_TEXTURE_SIZE: 3379,
|
||||||
|
MAX_TEXTURE_IMAGE_UNITS: 34930,
|
||||||
|
TEXTURE_2D: 3553,
|
||||||
|
RGBA: 6408,
|
||||||
|
RGBA8: 32856,
|
||||||
|
R8: 33321,
|
||||||
|
RED: 6403,
|
||||||
|
UNSIGNED_BYTE: 5121,
|
||||||
|
UNSIGNED_INT: 5125,
|
||||||
|
FLOAT: 5126,
|
||||||
|
ARRAY_BUFFER: 34962,
|
||||||
|
ELEMENT_ARRAY_BUFFER: 34963,
|
||||||
|
DYNAMIC_DRAW: 35048,
|
||||||
|
STATIC_DRAW: 35044,
|
||||||
|
TRIANGLES: 4,
|
||||||
|
TEXTURE_MIN_FILTER: 10241,
|
||||||
|
TEXTURE_MAG_FILTER: 10240,
|
||||||
|
TEXTURE_WRAP_S: 10242,
|
||||||
|
TEXTURE_WRAP_T: 10243,
|
||||||
|
CLAMP_TO_EDGE: 33071,
|
||||||
|
NEAREST: 9728,
|
||||||
|
LINEAR: 9729,
|
||||||
|
UNPACK_ALIGNMENT: 3317,
|
||||||
|
BLEND: 3042,
|
||||||
|
SRC_ALPHA: 770,
|
||||||
|
ONE: 1,
|
||||||
|
ONE_MINUS_SRC_ALPHA: 771,
|
||||||
|
DEPTH_TEST: 2929,
|
||||||
|
COLOR_BUFFER_BIT: 16384,
|
||||||
|
TEXTURE0: 33984,
|
||||||
|
|
||||||
|
drawingBufferWidth: 1280,
|
||||||
|
drawingBufferHeight: 720,
|
||||||
|
|
||||||
|
isContextLost: () => false,
|
||||||
|
getParameter: (param: number) => {
|
||||||
|
if (param === 34930) return 16 // MAX_TEXTURE_IMAGE_UNITS
|
||||||
|
if (param === 3379) return 8192 // MAX_TEXTURE_SIZE
|
||||||
|
return 16
|
||||||
|
},
|
||||||
|
|
||||||
|
createProgram: () => ({ id: nextId++, type: 'program' }),
|
||||||
|
deleteProgram: () => {},
|
||||||
|
attachShader: () => {},
|
||||||
|
detachShader: () => {},
|
||||||
|
linkProgram: () => {},
|
||||||
|
getProgramParameter: () => true,
|
||||||
|
getProgramInfoLog: () => '',
|
||||||
|
useProgram: () => {},
|
||||||
|
|
||||||
|
createShader: (type: number) => ({ id: nextId++, type: type === 35633 ? 'vertexShader' : 'fragmentShader' }),
|
||||||
|
shaderSource: () => {},
|
||||||
|
compileShader: () => {},
|
||||||
|
getShaderParameter: () => true,
|
||||||
|
getShaderInfoLog: () => '',
|
||||||
|
deleteShader: () => {},
|
||||||
|
|
||||||
|
createVertexArray: () => ({ id: nextId++, type: 'vao' }),
|
||||||
|
deleteVertexArray: () => {},
|
||||||
|
bindVertexArray: () => {},
|
||||||
|
|
||||||
|
createBuffer: () => ({ id: nextId++, type: 'buffer' }),
|
||||||
|
deleteBuffer: () => {},
|
||||||
|
bindBuffer: () => {},
|
||||||
|
bufferData: () => {},
|
||||||
|
bufferSubData: () => {},
|
||||||
|
|
||||||
|
getAttribLocation: (_p: any, name: string) => {
|
||||||
|
const map: Record<string, number> = {
|
||||||
|
a_position: 0,
|
||||||
|
a_uv: 1,
|
||||||
|
a_tint: 2,
|
||||||
|
a_unit: 3,
|
||||||
|
a_paletteRow: 4,
|
||||||
|
}
|
||||||
|
return map[name] ?? -1
|
||||||
|
},
|
||||||
|
enableVertexAttribArray: () => {},
|
||||||
|
vertexAttribPointer: () => {},
|
||||||
|
vertexAttribIPointer: () => {},
|
||||||
|
|
||||||
|
createTexture: () => {
|
||||||
|
const res: MockResource = { id: nextId++, type: 'texture' }
|
||||||
|
stats.createdTextures.add(res)
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
deleteTexture: (t: MockResource) => { stats.createdTextures.delete(t) },
|
||||||
|
bindTexture: () => {},
|
||||||
|
texParameteri: () => {},
|
||||||
|
texImage2D: () => {},
|
||||||
|
texStorage2D: () => {},
|
||||||
|
texSubImage2D: () => {},
|
||||||
|
pixelStorei: () => {},
|
||||||
|
|
||||||
|
getUniformLocation: (_p: any, name: string) => ({ name, id: nextId++ }),
|
||||||
|
uniform2f: () => {},
|
||||||
|
uniform4f: () => {},
|
||||||
|
uniform1f: () => {},
|
||||||
|
uniform1i: () => {},
|
||||||
|
uniform1iv: () => {},
|
||||||
|
|
||||||
|
enable: () => {},
|
||||||
|
disable: () => {},
|
||||||
|
blendFunc: () => {},
|
||||||
|
viewport: () => {},
|
||||||
|
clearColor: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
activeTexture: () => {},
|
||||||
|
drawArrays: () => { stats.glDrawCalls += 1 },
|
||||||
|
drawElements: () => { stats.glDrawCalls += 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas: any = {
|
||||||
|
getContext: (type: string) => (type === 'webgl2' ? gl : null),
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
drawingBufferWidth: 1280,
|
||||||
|
drawingBufferHeight: 720,
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canvas, stats }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Scene Lighting & Environment Integration', () => {
|
||||||
|
it('maintains strict 10-float vertex stride invariant', () => {
|
||||||
|
expect(FLOATS_PER_VERTEX).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allocates lightmap texture lazily on first setLighting call', () => {
|
||||||
|
const { canvas } = createMockCanvas()
|
||||||
|
const renderer = new SpriteRenderer(canvas)
|
||||||
|
|
||||||
|
// Non-lighting setup has 3 default textures (atlas, white, palette)
|
||||||
|
const initialTextures = (renderer as any).allocatedTextures.size
|
||||||
|
expect(initialTextures).toBe(3)
|
||||||
|
|
||||||
|
// Clear and draw unlit quad
|
||||||
|
renderer.begin({ x: 0, y: 0, zoom: 1 })
|
||||||
|
renderer.drawSolid(0, 0, 100, 100, [1, 1, 1, 1])
|
||||||
|
renderer.flush()
|
||||||
|
expect((renderer as any).allocatedTextures.size).toBe(3)
|
||||||
|
|
||||||
|
// Set lighting
|
||||||
|
const lightGrid = new LightGrid()
|
||||||
|
lightGrid.centerOnSubTile(100, 100)
|
||||||
|
renderer.setLighting({
|
||||||
|
ambient: [0.2, 0.2, 0.2],
|
||||||
|
originSubX: lightGrid.originSubX,
|
||||||
|
originSubY: lightGrid.originSubY,
|
||||||
|
sceneOriginX: 0,
|
||||||
|
sceneOriginY: 0,
|
||||||
|
lightmap: lightGrid.exportTextureBuffer(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Lightmap texture is now allocated (3 + 1 = 4)
|
||||||
|
expect((renderer as any).allocatedTextures.size).toBe(4)
|
||||||
|
|
||||||
|
renderer.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maintains single-pass draw call batching when dynamic lighting is active', () => {
|
||||||
|
const { canvas, stats } = createMockCanvas()
|
||||||
|
const renderer = new SpriteRenderer(canvas)
|
||||||
|
const lightGrid = new LightGrid()
|
||||||
|
lightGrid.centerOnSubTile(200, 200)
|
||||||
|
|
||||||
|
lightGrid.addLight({
|
||||||
|
subTileX: 200,
|
||||||
|
subTileY: 200,
|
||||||
|
radius: 55,
|
||||||
|
intensity: 255,
|
||||||
|
red: 255,
|
||||||
|
green: 255,
|
||||||
|
blue: 255,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderer.begin({ x: 100, y: 100, zoom: 1 }, [0.03, 0.03, 0.04])
|
||||||
|
renderer.setLighting({
|
||||||
|
ambient: [0.15, 0.15, 0.18],
|
||||||
|
originSubX: lightGrid.originSubX,
|
||||||
|
originSubY: lightGrid.originSubY,
|
||||||
|
sceneOriginX: 0,
|
||||||
|
sceneOriginY: 0,
|
||||||
|
lightmap: lightGrid.exportTextureBuffer(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Draw world floor tiles and entities
|
||||||
|
for (let i = 0; i < 50; i += 1) {
|
||||||
|
renderer.drawSolid(i * 10, i * 5, 80, 40, [0.8, 0.8, 0.8, 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush batch
|
||||||
|
renderer.flush()
|
||||||
|
|
||||||
|
// All 50 quads rendered in exactly 1 single draw call
|
||||||
|
expect(renderer.drawCalls).toBe(1)
|
||||||
|
expect(stats.glDrawCalls).toBe(1)
|
||||||
|
expect(renderer.quadsSubmitted).toBe(50)
|
||||||
|
|
||||||
|
renderer.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports unshaded UI rendering by setting lighting to null before minimap', () => {
|
||||||
|
const { canvas, stats } = createMockCanvas()
|
||||||
|
const renderer = new SpriteRenderer(canvas)
|
||||||
|
const lightGrid = new LightGrid()
|
||||||
|
lightGrid.centerOnSubTile(200, 200)
|
||||||
|
|
||||||
|
renderer.begin({ x: 100, y: 100, zoom: 1 })
|
||||||
|
|
||||||
|
// 1. World pass with dynamic lighting
|
||||||
|
renderer.setLighting({
|
||||||
|
ambient: [0.1, 0.1, 0.1],
|
||||||
|
originSubX: lightGrid.originSubX,
|
||||||
|
originSubY: lightGrid.originSubY,
|
||||||
|
sceneOriginX: 0,
|
||||||
|
sceneOriginY: 0,
|
||||||
|
lightmap: lightGrid.exportTextureBuffer(),
|
||||||
|
})
|
||||||
|
renderer.drawSolid(0, 0, 50, 50, [1, 1, 1, 1])
|
||||||
|
|
||||||
|
// 2. Automap / Minimap pass without lighting
|
||||||
|
renderer.setLighting(null)
|
||||||
|
renderer.drawSolid(10, 10, 20, 20, [0, 1, 0, 1])
|
||||||
|
|
||||||
|
renderer.flush()
|
||||||
|
|
||||||
|
// Total draw calls: 1 for lit world + 1 for unshaded automap = 2
|
||||||
|
expect(renderer.drawCalls).toBe(2)
|
||||||
|
expect(stats.glDrawCalls).toBe(2)
|
||||||
|
|
||||||
|
renderer.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evaluates authentic 1.13c ambient lighting for indoor and outdoor levels', () => {
|
||||||
|
const env = new Environment()
|
||||||
|
|
||||||
|
// Level 1: Rogue Encampment (Act 1 outdoor town)
|
||||||
|
const townLight = env.tick(1, 1)
|
||||||
|
expect(townLight.isInside).toBe(false)
|
||||||
|
expect(townLight.intensity).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Level 37: Catacombs Level 4 (Act 1 indoor dungeon, Andariel lair)
|
||||||
|
const cataLight = env.tick(37, 1)
|
||||||
|
expect(cataLight.isInside).toBe(true)
|
||||||
|
const cataMeta = getLevelLighting(37)
|
||||||
|
expect(cataMeta).toBeDefined()
|
||||||
|
expect(cataLight.intensity).toBe(cataMeta!.intensity)
|
||||||
|
expect(cataLight.red).toBe(cataMeta!.red)
|
||||||
|
expect(cataLight.green).toBe(cataMeta!.green)
|
||||||
|
expect(cataLight.blue).toBe(cataMeta!.blue)
|
||||||
|
|
||||||
|
// Level 103: The Pandemonium Fortress (Act 4 town)
|
||||||
|
// Lerps towards target intensity 128
|
||||||
|
for (let t = 0; t < 200; t += 1) {
|
||||||
|
env.tick(103, 4)
|
||||||
|
}
|
||||||
|
expect(env.currentIntensity).toBe(128)
|
||||||
|
|
||||||
|
// Level 104: Outer Steppes (Act 4 wilderness)
|
||||||
|
// Advance several ticks to let lerp converge to target 64
|
||||||
|
for (let t = 0; t < 200; t += 1) {
|
||||||
|
env.tick(104, 4)
|
||||||
|
}
|
||||||
|
expect(env.currentIntensity).toBe(64)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attenuates light passing through obstacles in LightGrid', () => {
|
||||||
|
const grid = new LightGrid()
|
||||||
|
grid.centerOnSubTile(200, 200)
|
||||||
|
|
||||||
|
// Mock collision grid with a solid wall
|
||||||
|
const collision: CollisionGrid = {
|
||||||
|
cellsX: 80,
|
||||||
|
cellsY: 80,
|
||||||
|
originX: 0,
|
||||||
|
originY: 0,
|
||||||
|
gridWidth: 80 * 5,
|
||||||
|
blocked: new Uint8Array(80 * 5 * 80 * 5),
|
||||||
|
collisionMasks: new Uint16Array(80 * 5 * 80 * 5),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set obstacle mask on center cell (24, 24)
|
||||||
|
// Center sub-tile is 200. Grid origin is 200 - 192 = 8.
|
||||||
|
// Center cell (cx = 24, cy = 24) starts at subTile (8 + 24*8, 8 + 24*8) = (200, 200).
|
||||||
|
const cellStartSubX = grid.originSubX + 24 * 8
|
||||||
|
const cellStartSubY = grid.originSubY + 24 * 8
|
||||||
|
|
||||||
|
for (let dy = 2; dy <= 5; dy += 2) {
|
||||||
|
for (let dx = 2; dx <= 5; dx += 2) {
|
||||||
|
const idx = (cellStartSubY + dy) * collision.gridWidth + (cellStartSubX + dx)
|
||||||
|
collision.collisionMasks![idx] = COLLIDE_WALL | COLLIDE_VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update obstacles
|
||||||
|
grid.updateObstacles(collision)
|
||||||
|
|
||||||
|
// Center cell should have obstacle factor 16 (4 sample points * 4 = 16)
|
||||||
|
const centerIdx = 24 * LIGHT_GRID_SIZE + 24
|
||||||
|
expect(grid.obstacle[centerIdx]).toBe(16)
|
||||||
|
|
||||||
|
// Add light at center
|
||||||
|
grid.addLight({
|
||||||
|
subTileX: 200,
|
||||||
|
subTileY: 200,
|
||||||
|
radius: 40,
|
||||||
|
intensity: 255,
|
||||||
|
red: 255,
|
||||||
|
green: 255,
|
||||||
|
blue: 255,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Because obstacle factor is 16 (0x10), light is completely blocked per 0x6fb597ea
|
||||||
|
expect(grid.intensity[centerIdx]).toBe(0)
|
||||||
|
|
||||||
|
// An unblocked adjacent cell (24, 25) with obstacle 0 receives light
|
||||||
|
const unblockedIdx = 25 * LIGHT_GRID_SIZE + 24
|
||||||
|
expect(grid.obstacle[unblockedIdx]).toBe(0)
|
||||||
|
expect(grid.intensity[unblockedIdx]).toBeGreaterThan(150)
|
||||||
|
|
||||||
|
// Texture buffer export produces valid RGBA8 values
|
||||||
|
const buf = grid.exportTextureBuffer()
|
||||||
|
expect(buf.length).toBe(48 * 48 * 4)
|
||||||
|
const unblockedAlpha = buf[unblockedIdx * 4 + 3]
|
||||||
|
expect(unblockedAlpha).toBe(grid.intensity[unblockedIdx])
|
||||||
|
const unblockedRed = buf[unblockedIdx * 4]
|
||||||
|
expect(unblockedRed).toBe((grid.red[unblockedIdx] * unblockedAlpha) >> 8)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue