feat(lighting): implement authentic Diablo II v1.13c Glide dynamic lighting system
- P0 Data Contract Fixes: * Fix missile additive blending to use authentic 1.13c Missiles.txt trans === 1 * Strip developmental || 255 fallback from color channels to preserve authentic 0 values * Add full 136-level lighting metadata from Levels.txt in levels-meta.ts * Wire MonStats2 and Overlay lighting in data-registry.ts - P1 Reverse Engineering & Math Specification: * Reverse engineer D2Client.dll (dLightMap.c) and D2Common.dll (D2Environment.cpp) * Implement Alpha-Max-Plus-Beta-Min fast Euclidean distance approximation * Implement 16.16 fixed-point linear falloff and obstacle attenuation * Implement intensity-weighted color blending with reciprocal lookup table - P1 Core Modules: * LightGrid: 48x48 cell dynamic lightmap with sub-tile bilinear interpolation and RGBA8 export * Environment: 25Hz day/night clock, outdoor solar sine curve, Act IV targets, indoor static levels - P1 Shader & Renderer Integration: * Maintain strict 10-float vertex stride (FLOATS_PER_VERTEX = 10) * Transform screen coordinates to sub-tiles in vertex shader for v_lightUv * Glide diffuse modulation in fragment shader with additive blend bypass * Lazy 48x48 lightmap texture allocation ensuring zero overhead for unlit contexts * Single-pass draw call batching maintained (drawCalls === 1) - P1 Scene Integration & Verification: * Wire LightGrid and Environment into act-scene.ts * Add player torch with subtle flicker, missile lights, explosion flashes, and additive objects * Unshaded minimap overlay by resetting lighting prior to minimap.draw * Add comprehensive test suites: data-contract-lighting, light-grid, scene-lighting
This commit is contained in:
parent
51acb0effa
commit
b51d839d8c
|
|
@ -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 { decodeAnimDataFile, type AnimDataFile, type AnimDataRecord } from '../../formats/animdata.ts'
|
||||
import { parseTable, numberCell, textCell, type DataTable } from '../tables.ts'
|
||||
import { CANONICAL_LEVEL_LIGHTING, getLevelLighting, type LevelLightingDef } from '../levels-meta.ts'
|
||||
|
||||
export const MPQ_LOAD_ORDER = [
|
||||
'd2data.mpq',
|
||||
|
|
@ -480,6 +481,19 @@ export interface OverlayRecord {
|
|||
readonly height3: number
|
||||
readonly height4: number
|
||||
readonly preDraw: boolean
|
||||
readonly red: number
|
||||
readonly green: number
|
||||
readonly blue: number
|
||||
readonly raw: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface MonStats2LightingRecord {
|
||||
readonly id: string
|
||||
readonly light: number
|
||||
readonly lightR: number
|
||||
readonly lightG: number
|
||||
readonly lightB: number
|
||||
readonly shadow: boolean
|
||||
readonly raw: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
|
|
@ -629,6 +643,7 @@ export class D2DataRegistry {
|
|||
readonly charStatsByCode = new Map<string, CharStatsRecord>()
|
||||
readonly monStatsByHcIdx = new Map<number, MonStatsRecord>()
|
||||
readonly monStatsById = new Map<string, MonStatsRecord>()
|
||||
readonly monStats2ById = new Map<string, MonStats2LightingRecord>()
|
||||
readonly overlaysByName = new Map<string, OverlayRecord>()
|
||||
readonly petTypesByIdx = new Map<number, PetTypeRecord>()
|
||||
readonly petTypesByName = new Map<string, PetTypeRecord>()
|
||||
|
|
@ -678,6 +693,7 @@ export class D2DataRegistry {
|
|||
this.indexItemStatCost()
|
||||
this.indexCharStats()
|
||||
this.indexMonStats()
|
||||
this.indexMonStats2()
|
||||
this.indexOverlays()
|
||||
this.indexPetTypes()
|
||||
this.indexElemTypesAndEvents()
|
||||
|
|
@ -1258,6 +1274,23 @@ export class D2DataRegistry {
|
|||
}
|
||||
}
|
||||
|
||||
private indexMonStats2(): void {
|
||||
for (const row of this.monStats2Table.rows) {
|
||||
const id = textCell(row, 'Id')
|
||||
if (!id || id === 'expansion') continue
|
||||
const record: MonStats2LightingRecord = {
|
||||
id: id.toLowerCase(),
|
||||
light: numberCell(row, 'Light', 0),
|
||||
lightR: numberCell(row, 'light-r', 255),
|
||||
lightG: numberCell(row, 'light-g', 255),
|
||||
lightB: numberCell(row, 'light-b', 255),
|
||||
shadow: numberCell(row, 'Shadow', 0) !== 0,
|
||||
raw: row,
|
||||
}
|
||||
this.monStats2ById.set(id.toLowerCase(), record)
|
||||
}
|
||||
}
|
||||
|
||||
private indexOverlays(): void {
|
||||
let idx = 0
|
||||
for (const row of this.overlayTable.rows) {
|
||||
|
|
@ -1280,6 +1313,9 @@ export class D2DataRegistry {
|
|||
height3: numberCell(row, 'Height3', 0),
|
||||
height4: numberCell(row, 'Height4', 0),
|
||||
preDraw: numberCell(row, 'PreDraw', 0) !== 0,
|
||||
red: numberCell(row, 'Red', 255),
|
||||
green: numberCell(row, 'Green', 255),
|
||||
blue: numberCell(row, 'Blue', 255),
|
||||
raw: row,
|
||||
}
|
||||
this.overlaysByName.set(overlay.toLowerCase(), record)
|
||||
|
|
@ -1379,6 +1415,14 @@ export class D2DataRegistry {
|
|||
return this.monStatsById.get(idOrHcIdx.trim().toLowerCase())
|
||||
}
|
||||
|
||||
getMonStats2Lighting(id: string): MonStats2LightingRecord | undefined {
|
||||
return this.monStats2ById.get(id.trim().toLowerCase())
|
||||
}
|
||||
|
||||
getLevelLighting(levelId: number): LevelLightingDef | undefined {
|
||||
return getLevelLighting(levelId)
|
||||
}
|
||||
|
||||
getOverlay(name: string): OverlayRecord | undefined {
|
||||
return this.overlaysByName.get(name.trim().toLowerCase())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
blue: 64,
|
||||
speedPxPerSec: 500,
|
||||
trans: 1,
|
||||
distancePx: 1000,
|
||||
},
|
||||
fireball: {
|
||||
|
|
@ -529,6 +530,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 178,
|
||||
blue: 64,
|
||||
speedPxPerSec: 500,
|
||||
trans: 1,
|
||||
distancePx: 1000,
|
||||
},
|
||||
fireexplode: {
|
||||
|
|
@ -549,6 +551,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 178,
|
||||
blue: 64,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
explodingarrowexp: {
|
||||
|
|
@ -569,6 +572,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 178,
|
||||
blue: 64,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
icebolt: {
|
||||
|
|
@ -589,6 +593,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 300,
|
||||
trans: 1,
|
||||
distancePx: 600,
|
||||
},
|
||||
iceblast: {
|
||||
|
|
@ -609,6 +614,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 300,
|
||||
trans: 1,
|
||||
distancePx: 600,
|
||||
},
|
||||
glacialspike: {
|
||||
|
|
@ -629,6 +635,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 400,
|
||||
trans: 1,
|
||||
distancePx: 640,
|
||||
},
|
||||
iceexplode: {
|
||||
|
|
@ -649,6 +656,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
freezeexplode: {
|
||||
|
|
@ -669,6 +677,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
freezingarrowexp1: {
|
||||
|
|
@ -689,6 +698,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
frozenorb: {
|
||||
|
|
@ -709,6 +719,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 250,
|
||||
trans: 1,
|
||||
distancePx: 300,
|
||||
},
|
||||
frozenorbbolt: {
|
||||
|
|
@ -729,6 +740,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 450,
|
||||
trans: 1,
|
||||
distancePx: 450,
|
||||
},
|
||||
frozenorbnova: {
|
||||
|
|
@ -749,6 +761,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 600,
|
||||
trans: 1,
|
||||
distancePx: 600,
|
||||
},
|
||||
frozenorbexplode: {
|
||||
|
|
@ -769,6 +782,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzardcenter: {
|
||||
|
|
@ -789,6 +803,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzard1: {
|
||||
|
|
@ -809,6 +824,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzard2: {
|
||||
|
|
@ -829,6 +845,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzard3: {
|
||||
|
|
@ -849,6 +866,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzard4: {
|
||||
|
|
@ -869,6 +887,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzardexplode1: {
|
||||
|
|
@ -889,6 +908,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzardexplode2: {
|
||||
|
|
@ -909,6 +929,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
blizzardexplode3: {
|
||||
|
|
@ -929,6 +950,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
hydra: {
|
||||
|
|
@ -949,6 +971,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 255,
|
||||
blue: 255,
|
||||
speedPxPerSec: 400,
|
||||
trans: 1,
|
||||
distancePx: 480,
|
||||
},
|
||||
hydrafire: {
|
||||
|
|
@ -969,6 +992,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 255,
|
||||
blue: 255,
|
||||
speedPxPerSec: 400,
|
||||
trans: 1,
|
||||
distancePx: 480,
|
||||
},
|
||||
firewallmaker: {
|
||||
|
|
@ -989,6 +1013,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 0,
|
||||
blue: 0,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
firewall: {
|
||||
|
|
@ -1114,6 +1139,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 0,
|
||||
blue: 0,
|
||||
speedPxPerSec: 0,
|
||||
trans: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
meteorcenter: {
|
||||
|
|
@ -1134,6 +1160,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 64,
|
||||
blue: 64,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
meteortail: {
|
||||
|
|
@ -1154,6 +1181,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 0,
|
||||
blue: 0,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
meteorexplode: {
|
||||
|
|
@ -1174,6 +1202,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 178,
|
||||
blue: 64,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
meteorfire: {
|
||||
|
|
@ -1194,6 +1223,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 178,
|
||||
blue: 64,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
chargedbolt: {
|
||||
|
|
@ -1214,6 +1244,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 255,
|
||||
blue: 255,
|
||||
speedPxPerSec: 300,
|
||||
trans: 1,
|
||||
distancePx: 1176,
|
||||
},
|
||||
teeth: {
|
||||
|
|
@ -1234,6 +1265,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 255,
|
||||
blue: 255,
|
||||
speedPxPerSec: 400,
|
||||
trans: 1,
|
||||
distancePx: 800,
|
||||
},
|
||||
teethexplode: {
|
||||
|
|
@ -1254,6 +1286,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 255,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
bonespear: {
|
||||
|
|
@ -1274,6 +1307,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 255,
|
||||
blue: 255,
|
||||
speedPxPerSec: 600,
|
||||
trans: 1,
|
||||
distancePx: 960,
|
||||
},
|
||||
bonespearexplode: {
|
||||
|
|
@ -1294,6 +1328,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 255,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
bonespirit: {
|
||||
|
|
@ -1314,6 +1349,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 255,
|
||||
blue: 255,
|
||||
speedPxPerSec: 300,
|
||||
trans: 1,
|
||||
distancePx: 1536,
|
||||
},
|
||||
bonespiritexplode: {
|
||||
|
|
@ -1334,6 +1370,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 255,
|
||||
blue: 255,
|
||||
speedPxPerSec: 0,
|
||||
trans: 1,
|
||||
distancePx: 0,
|
||||
},
|
||||
poisonnova: {
|
||||
|
|
@ -1354,6 +1391,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 255,
|
||||
blue: 255,
|
||||
speedPxPerSec: 300,
|
||||
trans: 1,
|
||||
distancePx: 360,
|
||||
},
|
||||
holybolt: {
|
||||
|
|
@ -1374,6 +1412,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 222,
|
||||
blue: 255,
|
||||
speedPxPerSec: 500,
|
||||
trans: 1,
|
||||
distancePx: 1000,
|
||||
},
|
||||
blessedhammer: {
|
||||
|
|
@ -1394,6 +1433,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 222,
|
||||
blue: 255,
|
||||
speedPxPerSec: 450,
|
||||
trans: 1,
|
||||
distancePx: 2160,
|
||||
},
|
||||
magicarrow: {
|
||||
|
|
@ -1414,6 +1454,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 255,
|
||||
blue: 255,
|
||||
speedPxPerSec: 600,
|
||||
trans: 1,
|
||||
distancePx: 960,
|
||||
},
|
||||
firearrow: {
|
||||
|
|
@ -1434,6 +1475,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 178,
|
||||
blue: 64,
|
||||
speedPxPerSec: 600,
|
||||
trans: 1,
|
||||
distancePx: 960,
|
||||
},
|
||||
icearrow: {
|
||||
|
|
@ -1454,6 +1496,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 81,
|
||||
blue: 255,
|
||||
speedPxPerSec: 600,
|
||||
trans: 1,
|
||||
distancePx: 960,
|
||||
},
|
||||
arrow: {
|
||||
|
|
@ -1474,6 +1517,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 255,
|
||||
blue: 255,
|
||||
speedPxPerSec: 600,
|
||||
trans: 0,
|
||||
distancePx: 960,
|
||||
},
|
||||
multipleshotarrow: {
|
||||
|
|
@ -1494,6 +1538,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 255,
|
||||
blue: 255,
|
||||
speedPxPerSec: 600,
|
||||
trans: 0,
|
||||
distancePx: 1200,
|
||||
},
|
||||
lightningjavelin: {
|
||||
|
|
@ -1514,10 +1559,23 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
green: 255,
|
||||
blue: 255,
|
||||
speedPxPerSec: 750,
|
||||
trans: 1,
|
||||
distancePx: 750,
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Safely parse an RGB color channel (0..255) with fallback (default 255).
|
||||
* Preserves authentic 0 values while mapping empty/unspecified cells to fallback.
|
||||
*/
|
||||
export function parseColorChannel(raw: string | undefined, fallback = 255): number {
|
||||
if (raw === undefined) return fallback
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed === '') return fallback
|
||||
const n = Number(trimmed)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses raw Missiles.txt text into a Map of missile records.
|
||||
*/
|
||||
|
|
@ -1564,9 +1622,9 @@ export function parseMissilesTxt(content: string): Map<string, MissileTxtData> {
|
|||
const numDirections = Number(cells[numDirIdx] ?? 0) || 1
|
||||
const explosionMissile = (cells[explIdx] ?? '').trim()
|
||||
const light = Number(cells[lightIdx] ?? 0) || 0
|
||||
const red = Number(cells[redIdx] ?? 255) || 255
|
||||
const green = Number(cells[greenIdx] ?? 255) || 255
|
||||
const blue = Number(cells[blueIdx] ?? 255) || 255
|
||||
const red = parseColorChannel(cells[redIdx])
|
||||
const green = parseColorChannel(cells[greenIdx])
|
||||
const blue = parseColorChannel(cells[blueIdx])
|
||||
const nextHit = Number(cells[nextHitIdx] ?? 0) || 0
|
||||
const nextDelay = Number(cells[nextDelayIdx] ?? 0) || 0
|
||||
const trans = Number(cells[transIdx] ?? 0) || 0
|
||||
|
|
@ -1633,6 +1691,7 @@ export function getMissileTxtData(
|
|||
red: 255,
|
||||
green: 178,
|
||||
blue: 64,
|
||||
trans: 0,
|
||||
speedPxPerSec: 500,
|
||||
distancePx: 1000,
|
||||
}
|
||||
|
|
@ -1665,9 +1724,11 @@ export function getMissileTxtData(
|
|||
const numDirections = Number(row[t.header.indexOf('NumDirections')] ?? 0) || 1
|
||||
const explosionMissile = (row[t.header.indexOf('ExplosionMissile')] ?? '').trim()
|
||||
const light = Number(row[t.header.indexOf('Light')] ?? 0) || 0
|
||||
const red = Number(row[t.header.indexOf('Red')] ?? 255) || 255
|
||||
const green = Number(row[t.header.indexOf('Green')] ?? 255) || 255
|
||||
const blue = Number(row[t.header.indexOf('Blue')] ?? 255) || 255
|
||||
const red = parseColorChannel(row[t.header.indexOf('Red')])
|
||||
const green = parseColorChannel(row[t.header.indexOf('Green')])
|
||||
const blue = parseColorChannel(row[t.header.indexOf('Blue')])
|
||||
const transColIdx = t.header.indexOf('Trans')
|
||||
const trans = transColIdx !== -1 ? Number(row[transColIdx] ?? 0) || 0 : 0
|
||||
return {
|
||||
name: key,
|
||||
id: Number(row[t.header.indexOf('Id')] ?? 0) || 0,
|
||||
|
|
@ -1685,6 +1746,7 @@ export function getMissileTxtData(
|
|||
red,
|
||||
green,
|
||||
blue,
|
||||
trans,
|
||||
speedPxPerSec: vel * 25,
|
||||
distancePx: vel * range,
|
||||
}
|
||||
|
|
@ -1710,9 +1772,10 @@ export function getMissileTxtData(
|
|||
const numDirections = Number(row['NumDirections'] ?? row['numdirections'] ?? 0) || 1
|
||||
const explosionMissile = (row['ExplosionMissile'] ?? row['explosionmissile'] ?? '').trim()
|
||||
const light = Number(row['Light'] ?? row['light'] ?? 0) || 0
|
||||
const red = Number(row['Red'] ?? row['red'] ?? 255) || 255
|
||||
const green = Number(row['Green'] ?? row['green'] ?? 255) || 255
|
||||
const blue = Number(row['Blue'] ?? row['blue'] ?? 255) || 255
|
||||
const red = parseColorChannel(row['Red'] ?? row['red'])
|
||||
const green = parseColorChannel(row['Green'] ?? row['green'])
|
||||
const blue = parseColorChannel(row['Blue'] ?? row['blue'])
|
||||
const trans = Number(row['Trans'] ?? row['trans'] ?? 0) || 0
|
||||
return {
|
||||
name: key,
|
||||
id: Number(row['Id'] ?? row['id'] ?? 0) || 0,
|
||||
|
|
@ -1730,6 +1793,7 @@ export function getMissileTxtData(
|
|||
red,
|
||||
green,
|
||||
blue,
|
||||
trans,
|
||||
speedPxPerSec: vel * 25,
|
||||
distancePx: vel * range,
|
||||
}
|
||||
|
|
@ -3408,9 +3472,9 @@ export function parseOverlayTxt(content: string): Map<string, OverlayTxtData> {
|
|||
const preDraw = Number(cells[preDrawIdx] ?? 0) !== 0
|
||||
const initRadius = Number(cells[initRadiusIdx] ?? 0) || 0
|
||||
const radius = Number(cells[radiusIdx] ?? 0) || 0
|
||||
const red = Number(cells[redIdx] ?? 255) || 255
|
||||
const green = Number(cells[greenIdx] ?? 255) || 255
|
||||
const blue = Number(cells[blueIdx] ?? 255) || 255
|
||||
const red = parseColorChannel(cells[redIdx])
|
||||
const green = parseColorChannel(cells[greenIdx])
|
||||
const blue = parseColorChannel(cells[blueIdx])
|
||||
const numDirections = Number(cells[numDirIdx] ?? 1) || 1
|
||||
const xOffset = Number(cells[xOffsetIdx] ?? 0) || 0
|
||||
const yOffset = Number(cells[yOffsetIdx] ?? 0) || 0
|
||||
|
|
@ -3477,6 +3541,35 @@ export function getOverlayTxtData(
|
|||
return overlaySource.get(key) ?? getOverlayTxtData(overlayName, undefined)
|
||||
}
|
||||
|
||||
// D2Table ({ header, rows })
|
||||
if ('header' in overlaySource && Array.isArray((overlaySource as any).header)) {
|
||||
const t = overlaySource as { header: readonly string[]; rows: readonly (readonly string[])[] }
|
||||
const oIdx = t.header.indexOf('Overlay')
|
||||
const fIdx = t.header.indexOf('Filename')
|
||||
const row = t.rows.find(
|
||||
r => (oIdx !== -1 && (r[oIdx] ?? '').trim().toLowerCase() === key) ||
|
||||
(fIdx !== -1 && (r[fIdx] ?? '').trim().toLowerCase() === key),
|
||||
)
|
||||
if (row) {
|
||||
return {
|
||||
overlay: oIdx !== -1 ? (row[oIdx] ?? key).trim() : key,
|
||||
filename: fIdx !== -1 ? (row[fIdx] ?? '').trim() : '',
|
||||
frames: Number(row[t.header.indexOf('Frames')] ?? 1) || 1,
|
||||
animRate: Number(row[t.header.indexOf('AnimRate')] ?? 16) || 16,
|
||||
trans: Number(row[t.header.indexOf('Trans')] ?? 0) || 0,
|
||||
preDraw: Number(row[t.header.indexOf('PreDraw')] ?? 0) !== 0,
|
||||
initRadius: Number(row[t.header.indexOf('InitRadius')] ?? 0) || 0,
|
||||
radius: Number(row[t.header.indexOf('Radius')] ?? 0) || 0,
|
||||
red: parseColorChannel(row[t.header.indexOf('Red')]),
|
||||
green: parseColorChannel(row[t.header.indexOf('Green')]),
|
||||
blue: parseColorChannel(row[t.header.indexOf('Blue')]),
|
||||
numDirections: Number(row[t.header.indexOf('NumDirections')] ?? 1) || 1,
|
||||
xOffset: Number(row[t.header.indexOf('Xoffset')] ?? 0) || 0,
|
||||
yOffset: Number(row[t.header.indexOf('Yoffset')] ?? 0) || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DataTable ({ rows: Record<string, string>[] })
|
||||
if ('rows' in overlaySource && !('header' in overlaySource)) {
|
||||
const tableRows = overlaySource.rows as readonly Readonly<Record<string, string>>[]
|
||||
|
|
@ -3494,9 +3587,9 @@ export function getOverlayTxtData(
|
|||
preDraw: Number(row['PreDraw'] ?? row['predraw'] ?? 0) !== 0,
|
||||
initRadius: Number(row['InitRadius'] ?? row['initradius'] ?? 0) || 0,
|
||||
radius: Number(row['Radius'] ?? row['radius'] ?? 0) || 0,
|
||||
red: Number(row['Red'] ?? row['red'] ?? 255) || 255,
|
||||
green: Number(row['Green'] ?? row['green'] ?? 255) || 255,
|
||||
blue: Number(row['Blue'] ?? row['blue'] ?? 255) || 255,
|
||||
red: parseColorChannel(row['Red'] ?? row['red']),
|
||||
green: parseColorChannel(row['Green'] ?? row['green']),
|
||||
blue: parseColorChannel(row['Blue'] ?? row['blue']),
|
||||
numDirections: Number(row['NumDirections'] ?? row['numdirections'] ?? 1) || 1,
|
||||
xOffset: Number(row['Xoffset'] ?? row['xoffset'] ?? 0) || 0,
|
||||
yOffset: Number(row['Yoffset'] ?? row['yoffset'] ?? 0) || 0,
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export function parseTable(text: string): DataTable {
|
|||
*/
|
||||
export function numberCell(row: Readonly<Record<string, string>>, column: string, fallback: number): number {
|
||||
const raw = row[column]
|
||||
if (raw === undefined) return fallback
|
||||
if (raw === undefined || raw.trim() === '') return fallback
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,20 @@ export interface DrawOptions {
|
|||
readonly blendMode?: 'normal' | 'additive'
|
||||
}
|
||||
|
||||
/** Dynamic scene lighting configuration for Glide-style illumination. */
|
||||
export interface LightingConfig {
|
||||
/** Ambient light color in range 0..1, e.g. [0.03, 0.03, 0.04]. */
|
||||
readonly ambient: readonly [number, number, number]
|
||||
/** Sub-tile origin of the 48x48 light grid. */
|
||||
readonly originSubX: number
|
||||
readonly originSubY: number
|
||||
/** Scene pixel origin (e.g. grid.originX, grid.originY). */
|
||||
readonly sceneOriginX: number
|
||||
readonly sceneOriginY: number
|
||||
/** 48x48 RGBA8 lightmap texture buffer (48 * 48 * 4 bytes). If omitted, existing lightmap texture is retained. */
|
||||
readonly lightmap?: Uint8Array | null
|
||||
}
|
||||
|
||||
/** Configuration options and event callbacks for SpriteRenderer. */
|
||||
export interface RendererOptions {
|
||||
/** Optional callback invoked when the WebGL context is lost. */
|
||||
|
|
@ -107,7 +121,7 @@ const VERTICES_PER_QUAD = 4
|
|||
/** Indices per quad (two triangles). */
|
||||
const INDICES_PER_QUAD = 6
|
||||
/** Floats per vertex: x, y, u, v, r, g, b, a, unit, paletteRow. */
|
||||
const FLOATS_PER_VERTEX = 10
|
||||
export const FLOATS_PER_VERTEX = 10
|
||||
/** Initial batch capacity in quads. */
|
||||
const INITIAL_CAPACITY = 8192
|
||||
/**
|
||||
|
|
@ -132,8 +146,11 @@ in float a_palette;
|
|||
uniform vec2 u_camera;
|
||||
uniform vec2 u_viewport;
|
||||
uniform float u_zoom;
|
||||
uniform vec2 u_sceneOrigin;
|
||||
uniform vec2 u_lightOrigin;
|
||||
out vec2 v_uv;
|
||||
out vec4 v_tint;
|
||||
out vec2 v_lightUv;
|
||||
flat out int v_unit;
|
||||
flat out int v_palette;
|
||||
void main() {
|
||||
|
|
@ -144,6 +161,17 @@ void main() {
|
|||
v_tint = a_tint;
|
||||
v_unit = int(a_unit);
|
||||
v_palette = int(a_palette);
|
||||
|
||||
// Inverse isometric projection to sub-tiles:
|
||||
// x_scene = (subX - subY) * 16.0 + originX
|
||||
// y_scene = (subX + subY) * 8.0 + originY
|
||||
// => subX = ((x_scene - originX)/16.0 + (y_scene - originY)/8.0) * 0.5
|
||||
// => subY = ((y_scene - originY)/8.0 - (x_scene - originX)/16.0) * 0.5
|
||||
vec2 d = a_position - u_sceneOrigin;
|
||||
float subX = (d.x * (1.0 / 16.0) + d.y * (1.0 / 8.0)) * 0.5;
|
||||
float subY = (d.y * (1.0 / 8.0) - d.x * (1.0 / 16.0)) * 0.5;
|
||||
// Light grid spans 48 cells * 8 sub-tiles = 384 sub-tiles
|
||||
v_lightUv = vec2(subX - u_lightOrigin.x, subY - u_lightOrigin.y) * (1.0 / 384.0);
|
||||
}`
|
||||
}
|
||||
|
||||
|
|
@ -152,6 +180,8 @@ void main() {
|
|||
*
|
||||
* Supports both standard RGBA textures (`v_palette < 0`) and 8-bit `R8` palette-indexed
|
||||
* textures (`v_palette >= 0`), performing hardware-accelerated palette lookups via `texelFetch`.
|
||||
* When dynamic lighting is active (`u_lightMode == 1`), modulates normal non-additive fragments
|
||||
* with bilinear-filtered lightmap samples and ambient light.
|
||||
*
|
||||
* @param units - number of batch samplers to expose.
|
||||
* @param options - compilation options including alphaDiscard.
|
||||
|
|
@ -178,10 +208,15 @@ export function fragmentShaderSource(units: number, options: { alphaDiscard?: bo
|
|||
precision highp float;
|
||||
in vec2 v_uv;
|
||||
in vec4 v_tint;
|
||||
in vec2 v_lightUv;
|
||||
flat in int v_unit;
|
||||
flat in int v_palette;
|
||||
uniform sampler2D u_textures[${String(units)}];
|
||||
uniform sampler2D u_palette;
|
||||
uniform sampler2D u_lightmap;
|
||||
uniform vec4 u_ambient;
|
||||
uniform int u_lightMode;
|
||||
uniform int u_blendMode;
|
||||
out vec4 outColor;
|
||||
void main() {
|
||||
vec4 texel;
|
||||
|
|
@ -190,7 +225,13 @@ ${cases.join('\n')}
|
|||
default: texel = texture(u_textures[0], v_uv); break;
|
||||
}
|
||||
${paletteLookup}${discardAlpha}
|
||||
outColor = texel * v_tint;
|
||||
if (u_lightMode == 1 && u_blendMode == 0) {
|
||||
vec4 lightSample = texture(u_lightmap, v_lightUv);
|
||||
vec3 totalLight = clamp(u_ambient.rgb + lightSample.rgb, 0.0, 1.0);
|
||||
outColor = vec4(texel.rgb * totalLight * v_tint.rgb, texel.a * v_tint.a);
|
||||
} else {
|
||||
outColor = texel * v_tint;
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
|
|
@ -223,6 +264,7 @@ export class SpriteRenderer {
|
|||
private atlasTexture: WebGLTexture
|
||||
private readonly whiteTexture: WebGLTexture
|
||||
private readonly paletteTexture: WebGLTexture
|
||||
private lightmapTexture: WebGLTexture | null = null
|
||||
/** The 1×1 white page used by `drawSolid`. */
|
||||
private readonly whiteAtlas: AtlasHandle
|
||||
/** The default page (what `setAtlas` uploads into). */
|
||||
|
|
@ -232,6 +274,12 @@ export class SpriteRenderer {
|
|||
private readonly uniformZoom: WebGLUniformLocation
|
||||
private readonly uniformTextures: WebGLUniformLocation
|
||||
private readonly uniformPalette: WebGLUniformLocation | null
|
||||
private readonly uniformLightmap: WebGLUniformLocation | null
|
||||
private readonly uniformAmbient: WebGLUniformLocation | null
|
||||
private readonly uniformSceneOrigin: WebGLUniformLocation | null
|
||||
private readonly uniformLightOrigin: WebGLUniformLocation | null
|
||||
private readonly uniformLightMode: WebGLUniformLocation | null
|
||||
private readonly uniformBlendMode: WebGLUniformLocation | null
|
||||
private vertices: Float32Array
|
||||
private indices: Uint32Array
|
||||
private quadCount = 0
|
||||
|
|
@ -245,15 +293,36 @@ export class SpriteRenderer {
|
|||
private cachedViewportWidth = Number.NaN
|
||||
private cachedViewportHeight = Number.NaN
|
||||
private cachedZoom = Number.NaN
|
||||
/** Texture units this context offers the batcher for atlases (reserving 1 unit for palette). */
|
||||
private cachedAmbientR = Number.NaN
|
||||
private cachedAmbientG = Number.NaN
|
||||
private cachedAmbientB = Number.NaN
|
||||
private cachedSceneOriginX = Number.NaN
|
||||
private cachedSceneOriginY = Number.NaN
|
||||
private cachedOriginSubX = Number.NaN
|
||||
private cachedOriginSubY = Number.NaN
|
||||
private cachedLightMode = -1
|
||||
private cachedBlendMode = -1
|
||||
/** Texture units this context offers the batcher for atlases (reserving 1 unit for palette and 1 for lightmap). */
|
||||
private readonly maxBatchTextures: number
|
||||
/** Dedicated texture unit for the 256-color hardware palette texture. */
|
||||
private readonly paletteUnit: number
|
||||
/** Dedicated texture unit for the 48x48 dynamic lightmap texture. */
|
||||
private readonly lightmapUnit: number
|
||||
/** Pages bound to units `0..batchTextureCount-1` for the pending batch. */
|
||||
private readonly batchTextures: (AtlasHandle | null)[]
|
||||
private batchTextureCount = 0
|
||||
private camera: Camera = { x: 0, y: 0, zoom: 1 }
|
||||
|
||||
/** Lighting parameters. */
|
||||
private lightingEnabled = false
|
||||
private ambientR = 1
|
||||
private ambientG = 1
|
||||
private ambientB = 1
|
||||
private originSubX = 0
|
||||
private originSubY = 0
|
||||
private sceneOriginX = 0
|
||||
private sceneOriginY = 0
|
||||
|
||||
/** `drawElements` calls issued since the last {@link begin}. */
|
||||
private frameDrawCalls = 0
|
||||
/** Quads submitted since the last {@link begin}. */
|
||||
|
|
@ -282,9 +351,10 @@ export class SpriteRenderer {
|
|||
|
||||
const reportedUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS) as number | null
|
||||
const availableUnits = typeof reportedUnits === 'number' && reportedUnits > 0 ? reportedUnits : MAX_BATCH_TEXTURES
|
||||
const clampedUnits = Math.max(2, Math.min(MAX_BATCH_TEXTURES, availableUnits))
|
||||
const clampedUnits = Math.max(3, Math.min(MAX_BATCH_TEXTURES, availableUnits))
|
||||
this.paletteUnit = clampedUnits - 1
|
||||
this.maxBatchTextures = clampedUnits - 1
|
||||
this.lightmapUnit = clampedUnits - 2
|
||||
this.maxBatchTextures = clampedUnits - 2
|
||||
this.batchTextures = new Array<AtlasHandle | null>(this.maxBatchTextures).fill(null)
|
||||
|
||||
this.program = createProgram(
|
||||
|
|
@ -354,6 +424,12 @@ export class SpriteRenderer {
|
|||
this.uniformZoom = requireUniform(gl, this.program, 'u_zoom')
|
||||
this.uniformTextures = requireUniform(gl, this.program, 'u_textures[0]')
|
||||
this.uniformPalette = gl.getUniformLocation(this.program, 'u_palette')
|
||||
this.uniformLightmap = gl.getUniformLocation(this.program, 'u_lightmap')
|
||||
this.uniformAmbient = gl.getUniformLocation(this.program, 'u_ambient')
|
||||
this.uniformSceneOrigin = gl.getUniformLocation(this.program, 'u_sceneOrigin')
|
||||
this.uniformLightOrigin = gl.getUniformLocation(this.program, 'u_lightOrigin')
|
||||
this.uniformLightMode = gl.getUniformLocation(this.program, 'u_lightMode')
|
||||
this.uniformBlendMode = gl.getUniformLocation(this.program, 'u_blendMode')
|
||||
|
||||
const samplerUnits = new Int32Array(this.maxBatchTextures)
|
||||
for (let unit = 0; unit < this.maxBatchTextures; unit += 1) samplerUnits[unit] = unit
|
||||
|
|
@ -362,10 +438,14 @@ export class SpriteRenderer {
|
|||
if (this.uniformPalette !== null) {
|
||||
gl.uniform1i(this.uniformPalette, this.paletteUnit)
|
||||
}
|
||||
if (this.uniformLightmap !== null) {
|
||||
gl.uniform1i(this.uniformLightmap, this.lightmapUnit)
|
||||
}
|
||||
|
||||
for (const texture of [atlasTexture, whiteTexture, paletteTexture]) {
|
||||
initTextureParams(gl, texture)
|
||||
}
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, whiteTexture)
|
||||
if (typeof gl.texStorage2D === 'function') {
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 1, 1)
|
||||
|
|
@ -739,6 +819,69 @@ export class SpriteRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily acquire or allocate the 48x48 RGBA8 dynamic lightmap texture.
|
||||
*/
|
||||
private getLightmapTexture(): WebGLTexture {
|
||||
if (this.lightmapTexture !== null) return this.lightmapTexture
|
||||
const gl = this.gl
|
||||
const texture = gl.createTexture()
|
||||
if (texture === null) throw new RendererError('lightmap texture allocation failed')
|
||||
this.lightmapTexture = texture
|
||||
this.allocatedTextures.add(texture)
|
||||
initTextureParams(gl, texture)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
const emptyLightmap = new Uint8Array(48 * 48 * 4)
|
||||
if (typeof gl.texStorage2D === 'function') {
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, 48, 48)
|
||||
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 48, 48, gl.RGBA, gl.UNSIGNED_BYTE, emptyLightmap)
|
||||
} else {
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 48, 48, 0, gl.RGBA, gl.UNSIGNED_BYTE, emptyLightmap)
|
||||
}
|
||||
return texture
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure dynamic scene lighting.
|
||||
*
|
||||
* @param config - lighting configuration, or null to disable lighting.
|
||||
*/
|
||||
setLighting(config: LightingConfig | null): void {
|
||||
if (this.disposed) return
|
||||
if (config === null) {
|
||||
if (this.lightingEnabled) {
|
||||
this.flush()
|
||||
this.lightingEnabled = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!this.lightingEnabled) {
|
||||
this.flush()
|
||||
this.lightingEnabled = true
|
||||
}
|
||||
this.ambientR = config.ambient[0]
|
||||
this.ambientG = config.ambient[1]
|
||||
this.ambientB = config.ambient[2]
|
||||
this.originSubX = config.originSubX
|
||||
this.originSubY = config.originSubY
|
||||
this.sceneOriginX = config.sceneOriginX
|
||||
this.sceneOriginY = config.sceneOriginY
|
||||
|
||||
if (config.lightmap) {
|
||||
const gl = this.gl
|
||||
const texture = this.getLightmapTexture()
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D, 0, 0, 0, 48, 48,
|
||||
gl.RGBA, gl.UNSIGNED_BYTE, config.lightmap,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release an atlas texture handle and free its GPU memory.
|
||||
*
|
||||
|
|
@ -788,6 +931,7 @@ export class SpriteRenderer {
|
|||
this.batchTextureCount = 0
|
||||
this.frameDrawCalls = 0
|
||||
this.frameQuads = 0
|
||||
this.lightingEnabled = false
|
||||
if (this.currentBlendMode !== 'normal') {
|
||||
this.currentBlendMode = 'normal'
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
|
|
@ -1042,8 +1186,59 @@ export class SpriteRenderer {
|
|||
this.cachedZoom = zoom
|
||||
}
|
||||
|
||||
const lightMode = this.lightingEnabled ? 1 : 0
|
||||
if (lightMode !== this.cachedLightMode && this.uniformLightMode !== null) {
|
||||
gl.uniform1i(this.uniformLightMode, lightMode)
|
||||
this.cachedLightMode = lightMode
|
||||
}
|
||||
|
||||
const blendMode = this.currentBlendMode === 'additive' ? 1 : 0
|
||||
if (blendMode !== this.cachedBlendMode && this.uniformBlendMode !== null) {
|
||||
gl.uniform1i(this.uniformBlendMode, blendMode)
|
||||
this.cachedBlendMode = blendMode
|
||||
}
|
||||
|
||||
if (this.lightingEnabled) {
|
||||
if (
|
||||
(this.ambientR !== this.cachedAmbientR ||
|
||||
this.ambientG !== this.cachedAmbientG ||
|
||||
this.ambientB !== this.cachedAmbientB) &&
|
||||
this.uniformAmbient !== null
|
||||
) {
|
||||
gl.uniform4f(this.uniformAmbient, this.ambientR, this.ambientG, this.ambientB, 1.0)
|
||||
this.cachedAmbientR = this.ambientR
|
||||
this.cachedAmbientG = this.ambientG
|
||||
this.cachedAmbientB = this.ambientB
|
||||
}
|
||||
|
||||
if (
|
||||
(this.sceneOriginX !== this.cachedSceneOriginX ||
|
||||
this.sceneOriginY !== this.cachedSceneOriginY) &&
|
||||
this.uniformSceneOrigin !== null
|
||||
) {
|
||||
gl.uniform2f(this.uniformSceneOrigin, this.sceneOriginX, this.sceneOriginY)
|
||||
this.cachedSceneOriginX = this.sceneOriginX
|
||||
this.cachedSceneOriginY = this.sceneOriginY
|
||||
}
|
||||
|
||||
if (
|
||||
(this.originSubX !== this.cachedOriginSubX ||
|
||||
this.originSubY !== this.cachedOriginSubY) &&
|
||||
this.uniformLightOrigin !== null
|
||||
) {
|
||||
gl.uniform2f(this.uniformLightOrigin, this.originSubX, this.originSubY)
|
||||
this.cachedOriginSubX = this.originSubX
|
||||
this.cachedOriginSubY = this.originSubY
|
||||
}
|
||||
}
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0 + this.paletteUnit)
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.paletteTexture)
|
||||
if (this.lightingEnabled) {
|
||||
const lightmap = this.getLightmapTexture()
|
||||
gl.activeTexture(gl.TEXTURE0 + this.lightmapUnit)
|
||||
gl.bindTexture(gl.TEXTURE_2D, lightmap)
|
||||
}
|
||||
for (let unit = 0; unit < this.batchTextureCount; unit += 1) {
|
||||
const page = this.batchTextures[unit]
|
||||
if (page === undefined || page === null) continue
|
||||
|
|
@ -1068,8 +1263,18 @@ export class SpriteRenderer {
|
|||
this.cachedViewportWidth = Number.NaN
|
||||
this.cachedViewportHeight = Number.NaN
|
||||
this.cachedZoom = Number.NaN
|
||||
this.cachedAmbientR = Number.NaN
|
||||
this.cachedAmbientG = Number.NaN
|
||||
this.cachedAmbientB = Number.NaN
|
||||
this.cachedSceneOriginX = Number.NaN
|
||||
this.cachedSceneOriginY = Number.NaN
|
||||
this.cachedOriginSubX = Number.NaN
|
||||
this.cachedOriginSubY = Number.NaN
|
||||
this.cachedLightMode = -1
|
||||
this.cachedBlendMode = -1
|
||||
this.atlasStorageAllocated = false
|
||||
this.paletteStorageAllocated = false
|
||||
this.lightmapTexture = null
|
||||
|
||||
this.canvas.removeEventListener('webglcontextlost', this.handleContextLost)
|
||||
this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored)
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ import { Minimap } from '../ui/minimap.ts'
|
|||
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
|
||||
import { HudManager } from '../ui/hud-manager.ts'
|
||||
import type { InventoryPanel } from '../ui/inventory.ts'
|
||||
import { LightGrid } from '../game/engine/light-grid.ts'
|
||||
import { Environment } from '../game/engine/environment.ts'
|
||||
|
||||
/** Base locations tried in order when `?base=` is absent. */
|
||||
const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
|
||||
|
|
@ -290,6 +292,12 @@ export interface ActSceneState {
|
|||
culledDraws: number
|
||||
/** Wall-clock milliseconds the last `onRender` took. */
|
||||
renderMs: number
|
||||
/** Active lighting mode ('Glide 1.13c' or undefined). */
|
||||
lightingMode?: string | undefined
|
||||
/** Ambient light intensity [0..255]. */
|
||||
ambientIntensity?: number | undefined
|
||||
/** Ambient color [R, G, B] in [0..255]. */
|
||||
ambientColor?: readonly [number, number, number] | undefined
|
||||
/** Current zoom factor and the canvas backing-store size. */
|
||||
zoom: number
|
||||
canvasWidth: number
|
||||
|
|
@ -4339,7 +4347,7 @@ export function drawMissileProjectile(
|
|||
if (frame !== undefined) {
|
||||
const drawX = frame.anchorX !== undefined ? shot.x + frame.anchorX : shot.x - frame.width / 2
|
||||
const drawY = (frame.anchorY !== undefined ? shot.y + frame.anchorY : shot.y - frame.height / 2) - altitude
|
||||
const isAdditive = missileArt.meta.name !== 'arrow'
|
||||
const isAdditive = getMissileTxtData(shot.missileType ?? missileArt.meta.name).trans === 1
|
||||
renderer.draw(frame, drawX, drawY, {
|
||||
atlas: missileArt.handle,
|
||||
...(isAdditive ? { blendMode: 'additive' } : {}),
|
||||
|
|
@ -4899,6 +4907,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
})
|
||||
let monsterWalkFrame = 0
|
||||
const playerAnimator = new ActorAnimator()
|
||||
const lightGrid = new LightGrid()
|
||||
const environment = new Environment()
|
||||
const lightingEnabled = param('lighting', '1') !== '0'
|
||||
|
||||
const switchHero = async (newHeroToken: string): Promise<boolean> => {
|
||||
const nextToken = normalizeHeroToken(newHeroToken)
|
||||
|
|
@ -5734,6 +5745,9 @@ if (typeof window !== 'undefined') {
|
|||
state.facing = player.facing
|
||||
|
||||
syncEngineState(engine, state)
|
||||
if (lightingEnabled) {
|
||||
environment.tick(runtime.levelId, runtime.act)
|
||||
}
|
||||
|
||||
// The automap is a view, but revealing is state: it has to advance with
|
||||
// the simulation, not with however many frames the machine can draw.
|
||||
|
|
@ -5762,6 +5776,108 @@ if (typeof window !== 'undefined') {
|
|||
const camX = engine.world.player.x
|
||||
const camY = engine.world.player.y - 16
|
||||
renderer.begin({ x: camX, y: camY, zoom: camera.zoom }, [0.03, 0.03, 0.04])
|
||||
if (lightingEnabled) {
|
||||
const pSub = playerSubTile()
|
||||
lightGrid.centerOnSubTile(pSub.x, pSub.y)
|
||||
lightGrid.clear()
|
||||
lightGrid.updateObstacles(runtime.grid)
|
||||
|
||||
// Authentic player torch light: base radius 55 sub-tiles (~11 tiles), intensity 255, white
|
||||
const flicker = (Math.sin(engine.world.tick * 0.35) * 2) | 0
|
||||
lightGrid.addLight({
|
||||
subTileX: pSub.x,
|
||||
subTileY: pSub.y,
|
||||
radius: 55 + flicker,
|
||||
intensity: 255,
|
||||
red: 255,
|
||||
green: 255,
|
||||
blue: 255,
|
||||
})
|
||||
|
||||
// Active missile lights
|
||||
for (const shot of engine.projectiles) {
|
||||
const mType = shot.missileType ?? (shot.skillId === '36' ? 'firebolt' : shot.skillId === '47' ? 'fireball' : undefined)
|
||||
if (!mType) continue
|
||||
const mData = getMissileTxtData(mType)
|
||||
if (mData && mData.light > 0) {
|
||||
const dx = (shot.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
|
||||
const dy = (shot.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
||||
const shotSubX = Math.round((dy + dx) / 2)
|
||||
const shotSubY = Math.round((dy - dx) / 2)
|
||||
lightGrid.addLight({
|
||||
subTileX: shotSubX,
|
||||
subTileY: shotSubY,
|
||||
radius: mData.light * 5,
|
||||
intensity: 255,
|
||||
red: mData.red,
|
||||
green: mData.green,
|
||||
blue: mData.blue,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Active explosion lights
|
||||
for (const exp of engine.explosions) {
|
||||
const expData = getMissileTxtData(exp.missileType)
|
||||
const radius = (expData && expData.light > 0 ? expData.light : 8) * 5
|
||||
const dx = (exp.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
|
||||
const dy = (exp.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
||||
const expSubX = Math.round((dy + dx) / 2)
|
||||
const expSubY = Math.round((dy - dx) / 2)
|
||||
lightGrid.addLight({
|
||||
subTileX: expSubX,
|
||||
subTileY: expSubY,
|
||||
radius,
|
||||
intensity: 255,
|
||||
red: expData?.red ?? 255,
|
||||
green: expData?.green ?? 160,
|
||||
blue: expData?.blue ?? 50,
|
||||
})
|
||||
}
|
||||
|
||||
// Environmental additive objects (torches, flames, etc.)
|
||||
if (runtime.objectDrawables) {
|
||||
for (const obj of runtime.objectDrawables) {
|
||||
if (obj.blendMode === 'additive' || (obj.token && ADDITIVE_OBJECT_TOKENS.has(obj.token.toUpperCase()))) {
|
||||
const dx = obj.x - engine.world.player.x
|
||||
const dy = obj.y - engine.world.player.y
|
||||
if (dx * dx + dy * dy < 1200 * 1200) {
|
||||
const odx = (obj.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
|
||||
const ody = (obj.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
||||
lightGrid.addLight({
|
||||
subTileX: Math.round((ody + odx) / 2),
|
||||
subTileY: Math.round((ody - odx) / 2),
|
||||
radius: 35,
|
||||
intensity: 220,
|
||||
red: 255,
|
||||
green: 180,
|
||||
blue: 80,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ambNorm = environment.currentIntensity / 255
|
||||
const ambR = (environment.currentRed / 255) * ambNorm
|
||||
const ambG = (environment.currentGreen / 255) * ambNorm
|
||||
const ambB = (environment.currentBlue / 255) * ambNorm
|
||||
|
||||
renderer.setLighting({
|
||||
ambient: [ambR, ambG, ambB],
|
||||
originSubX: lightGrid.originSubX,
|
||||
originSubY: lightGrid.originSubY,
|
||||
sceneOriginX: runtime.grid.originX,
|
||||
sceneOriginY: runtime.grid.originY,
|
||||
lightmap: lightGrid.exportTextureBuffer(),
|
||||
})
|
||||
|
||||
state.lightingMode = 'Glide 1.13c'
|
||||
state.ambientIntensity = environment.currentIntensity
|
||||
state.ambientColor = [environment.currentRed, environment.currentGreen, environment.currentBlue]
|
||||
} else {
|
||||
renderer.setLighting(null)
|
||||
}
|
||||
skippedDraws = 0
|
||||
let culledDraws = 0
|
||||
|
||||
|
|
@ -6049,6 +6165,9 @@ if (typeof window !== 'undefined') {
|
|||
// Roofs last, in their own pass: the engine paints them after every other
|
||||
// layer so they cover the floor, the walls and anything walking under them.
|
||||
drawRoofs(runtime.roofs, roofBounds)
|
||||
if (lightingEnabled) {
|
||||
renderer.setLighting(null)
|
||||
}
|
||||
// Last in the batch, so the panel sits over every world layer.
|
||||
minimap.draw(
|
||||
renderer,
|
||||
|
|
|
|||
|
|
@ -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