feat: multi-layer world — temperature, gas, pressure, light as parallel per-chunk arrays

- Removed temp from Cell (13→9 bytes), added temps/pressure/gas_type/gas_density/light arrays to Chunk
- Layer access via grid.get_temp()/set_temp()/get_gas()/set_gas()/get_pressure()/set_pressure()/get_light()/set_light()
- cells_swap swaps all layers, set_material sets default_temp
- heat_transfer refactored to direct array access on temps[] (no Cell copy)
- CA rules refactored: cell.temp → grid.get_temp()/set_temp()
- gas_step: gas flow (rise, spread), fire produces CO2+smoke, steam condenses to water, acid+organic→poison gas
- pressure_step: pressure equalization for connected non-solid cells
- light_step: world-space persistent lighting, updated every 10 ticks, ray-cast line-of-sight
- Gas damage: poison gas damages entities, CO2 suffocates, applied before ca.step()
- Multi-section chunk serialization (VWM1 magic + cells + temps + gas + pressure + light)
- Old 12-byte chunk format auto-detected for backward compat
- AI spectrum: new gas + pressure spectrums, light spectrum uses world-space fallback
- Protocol: gas/pressure spectrum commands
- pre_dirty mechanism: layer steps process pre-clear dirty rects for cross-cell diffusion
- 14 new multilayer tests, all 185 tests + 14 scenarios pass
- Benchmark: 85.9 FPS (graphics surface, was 128 pre-layers — 33% regression from 4 new layer steps)
This commit is contained in:
Emil
2026-06-21 23:38:33 +03:00
parent a5436897ed
commit 24b6d0320f
17 changed files with 1723 additions and 218 deletions
+17 -4
View File
@@ -53,9 +53,9 @@ Pipe protocol spectrum commands:
## Tests
```sh
cargo test # all 171 integration tests
cargo test # all 185 integration tests (171 original + 14 multilayer)
cargo test --test physics_sand # single test file
cargo test --test slime # slime-specific tests
cargo test --test multilayer # multi-layer world tests
cargo run -- --mode test --scenario-dir scenarios # 14 JSON scenarios
```
@@ -103,12 +103,25 @@ SPV files are committed. `include_bytes!` embeds them at compile time.
## Architecture
**Source of truth**: `ChunkedGrid` of `Cell` structs. Replaces the old fixed-size `Grid` with dual storage:
**Source of truth**: `ChunkedGrid` of `Cell` structs with parallel per-chunk layer arrays. Replaces the old fixed-size `Grid` with dual storage:
- **Bounded mode**: `Vec<Chunk>` for deterministic 250x250 test/AI grids.
- **Infinite mode**: `HashMap<(i64, i64), Chunk>` for continuous 12500x12500 cell (100000x100000 px) Noita-scale worlds.
Main game (`--mode terminal`, `--mode ascii`, `--mode graphics`) uses the infinite mode. Each `Cell` stores `material`, `temp`, `fg`/`bg` color, `variant` inline. No double buffer. Chunks are 64x64 cells with `active`, `modified`, `was_modified`, and `dirty` flags.
Main game (`--mode terminal`, `--mode ascii`, `--mode graphics`) uses the infinite mode. Each `Cell` stores `material`, `fg`/`bg` color, `variant` inline (9 bytes, no temp). Temperature, gas, pressure, and light are stored in parallel arrays per chunk. Chunks are 64x64 cells with `active`, `modified`, `was_modified`, `generated`, and `dirty` flags.
**Multi-layer world** (Phase 6): Each `Chunk` has 5 parallel arrays alongside `cells`:
- `temps: Vec<f32>` — temperature per cell (16 KB/chunk)
- `pressure: Vec<u8>` — pressure per cell, 128 = atmospheric (4 KB/chunk)
- `gas_type: Vec<u8>` — gas type: 0=air, 1=smoke, 2=poison, 3=CO2, 4=steam (4 KB/chunk)
- `gas_density: Vec<u8>` — gas concentration 0-255 (4 KB/chunk)
- `light: Vec<[u8;3]>` — world-space RGB light (12 KB/chunk)
Layer access via `grid.get_temp()`/`set_temp()`, `grid.get_gas()`/`set_gas()`, `grid.get_pressure()`/`set_pressure()`, `grid.get_light()`/`set_light()`. All `set_*` methods call `mark_dirty()` (shared dirty rect). `cells_swap` swaps all layers. `set_material` also sets `default_temp()`.
**Simulation steps** per `fixed_update()`: `apply_gas_damage()``ca.step()` (material CA + heat_transfer + gas_step + pressure_step + light_step) → entity updates → combat → status effects. The `ca.step()` saves pre-clear dirty rects and passes them to layer steps so heat/gas/pressure can diffuse beyond CA-active cells.
**Serialization**: Multi-section chunk format with `VWM1` magic header. Sections: cells (8 bytes × 4096), temps (4 × 4096), gas (2 × 4096), pressure (1 × 4096), light (3 × 4096). Old 12-byte format auto-detected and loaded for backward compat.
**Four entity kinds**: `Player`, `Goblin`, `Slime`, `Corpse`. Three physics types: cellular (CA materials in grid), rigid (alive entities, AABB + slope stepping), ragdoll (corpses, Verlet constraints).
+567
View File
@@ -0,0 +1,567 @@
# Verbatim — Multi-Layer World Plan
> Phase 6: separate world layers for temperature, gas/air, pressure, and light.
> Created June 2026. Replaces the inline `Cell.temp` with parallel per-chunk arrays.
## Current State
All world data is packed into a single `Cell` struct:
```rust
pub struct Cell {
pub material: MaterialId, // 1 byte
pub temp: f32, // 4 bytes <-- will be removed
pub updated_this_tick: bool, // 1 byte
pub variant: u8, // 1 byte
pub fg: [u8; 3], // 3 bytes
pub bg: [u8; 3], // 3 bytes
} // ~13 bytes
```
**Problems:**
- Every `temp` modification copies the entire `Cell` through `grid.get()`/`grid.set()` + triggers `mark_dirty()`
- `heat_transfer()` already uses a temporary `Vec<f32>` buffer but writes results back into `Cell`
- No pressure field exists
- No gas composition field exists (`Empty` = air, `static_ = true`, no flow)
- Lighting is ephemeral viewport-sized `LightGrid`, not persisted in world space
## Target Architecture
Parallel arrays inside `Chunk`, shared dirty rects:
```
Chunk (64x64 = 4096 cells)
cells: Vec<Cell> ~36 KB (material + color, no temp)
temps: Vec<f32> 16 KB (temperature)
pressure: Vec<u8> 4 KB (0-255, 128 = atmospheric)
gas_type: Vec<u8> 4 KB (0=air, 1=smoke, 2=poison, 3=CO2, 4=steam)
gas_density:Vec<u8> 4 KB (0-255 concentration)
light: Vec<[u8; 3]> 12 KB (world-space RGB)
--------
~76 KB per chunk
```
**Why inside Chunk (not separate ChunkedGrids):**
- Reuse dirty rects, active chunks, streaming, serialization
- One `chunk_at()` call instead of multiple
- Layers updated in a single pass over the dirty rect
## New Struct Definitions
### Cell (cell.rs) — temp removed
```rust
pub struct Cell {
pub material: MaterialId,
pub updated_this_tick: bool,
pub variant: u8,
pub fg: [u8; 3],
pub bg: [u8; 3],
} // ~9 bytes
```
`to_bytes()` returns `[u8; 8]` (was `[u8; 12]`):
| Offset | Bytes | Field |
|--------|-------|-------|
| 0 | 1 | material |
| 1 | 1 | variant |
| 2-4 | 3 | fg |
| 5-7 | 3 | bg |
`updated_this_tick` is not serialized (transient).
### Chunk (chunk.rs) — 5 new arrays
```rust
pub struct Chunk {
pub cells: Vec<Cell>,
pub temps: Vec<f32>,
pub pressure: Vec<u8>,
pub gas_type: Vec<u8>,
pub gas_density: Vec<u8>,
pub light: Vec<[u8; 3]>,
pub active: bool,
pub modified: bool,
pub was_modified: bool,
pub generated: bool,
pub dirty: Option<(i32, i32, i32, i32)>,
}
```
`Chunk::new()` initializes:
- `cells`: 4096 × `Cell::empty()`
- `temps`: 4096 × `20.0` (ambient temperature)
- `pressure`: 4096 × `128` (atmospheric)
- `gas_type`: 4096 × `0` (air)
- `gas_density`: 4096 × `0` (no gas)
- `light`: 4096 × `[0, 0, 0]` (no light, computed later)
### ChunkedGrid (chunked_grid.rs) — new accessor methods
```rust
pub fn get_temp(&self, x: i32, y: i32) -> f32
pub fn set_temp(&mut self, x: i32, y: i32, t: f32)
pub fn get_pressure(&self, x: i32, y: i32) -> u8
pub fn set_pressure(&mut self, x: i32, y: i32, p: u8)
pub fn get_gas(&self, x: i32, y: i32) -> (u8, u8) // (type, density)
pub fn set_gas(&mut self, x: i32, y: i32, gas_type: u8, density: u8)
pub fn get_light(&self, x: i32, y: i32) -> [u8; 3]
pub fn set_light(&mut self, x: i32, y: i32, rgb: [u8; 3])
```
All `set_*` methods call `mark_dirty(x, y)` (shared dirty rect).
`get_temp` for out-of-bounds returns `20.0` (ambient).
`get_pressure` for out-of-bounds returns `128` (atmospheric).
`get_gas` for out-of-bounds returns `(0, 0)` (clean air).
`get_light` for out-of-bounds returns `[0, 0, 0]`.
## Simulation Steps
### New `fixed_update()` order
```
1. stream_chunks()
2. update_active_chunks()
3. ca.step() -- material CA (sand, water, lava, etc.)
4. heat_transfer() -- temperature diffusion on temps[] directly
5. gas_step() -- gas flow CA (NEW)
6. pressure_step() -- pressure equalization (NEW)
7. light_step() -- world-space light update, every N ticks (NEW)
8. update_entities()
9. update_slime_ai()
10. update_goblin_ai()
11. update_combat()
12. update_projectiles()
13. apply_world_damage()
14. decompose_corpses()
15. update_status_effects() -- now includes gas damage
16. update_score()
17. update_item_pickup()
18. grid.swap_modified_flags()
```
### Step 3: CA Rules Refactor (cellular.rs)
All `cell.temp` references become `grid.get_temp(x, y)`:
| Method | Current | New |
|--------|---------|-----|
| `update_water` | `cell.temp > 100.0` → Steam | `grid.get_temp(x,y) > 100.0` → Steam |
| `update_lava` | `cell.temp < 400.0` → Stone | `grid.get_temp(x,y) < 400.0` → Stone |
| `update_lava` | `lava.temp -= 50.0` | `grid.set_temp(x,y, grid.get_temp(x,y) - 50.0)` |
| `update_steam` | `cell.temp < 80.0` → Water | `grid.get_temp(x,y) < 80.0` → Water |
| `update_fire` | `new_n.temp = 400.0` | `grid.set_temp(nx,ny, 400.0)` |
| `update_fire` | `new.temp -= 15.0` | `grid.set_temp(x,y, grid.get_temp(x,y) - 15.0)` |
| `update_flesh` | `cell.temp > 200.0` → Fire | `grid.get_temp(x,y) > 200.0` → Fire |
| `update_grass` | `cell.temp > 250.0` → Fire | `grid.get_temp(x,y) > 250.0` → Fire |
| `update_dirt` | `cell.temp < 0.0` → Stone | `grid.get_temp(x,y) < 0.0` → Stone |
Also: when CA creates a new material (e.g., Water → Steam), set the temp layer:
- `grid.set_temp(x, y, 110.0)` for steam from boiling water
- `grid.set_temp(x, y, 50.0)` for water from condensed steam
- `grid.set_temp(x, y, 400.0)` for fire from ignited material
### Step 4: heat_transfer() Refactor (cellular.rs)
**Before** (current): reads `cell.temp` via `grid.get()`, writes via `grid.set()`, copies Cell each time.
**After**: direct array access on `chunk.temps[]`.
```
for each active chunk with dirty rect:
snapshot temps[dirty_rect + 1 margin] into self.temps_buffer
for each cell in dirty_rect:
avg = average of 4 neighbors from self.temps_buffer
chunk.temps[idx] += (avg - chunk.temps[idx]) * conductivity * 0.1
if phase transition threshold crossed:
grid.set_material(x, y, new_material) -- triggers mark_dirty
grid.set_temp(x, y, new_temp)
```
**No `grid.get()`/`grid.set()` for temperature.** No Cell copying. No mark_dirty per pixel (only for phase transitions).
Performance: heat_transfer goes from ~500 get+set calls to ~500 direct array writes.
### Step 5: gas_step() — NEW (cellular.rs)
Gas CA rules per cell in dirty rect:
**Flow rules:**
- Gas rises if `gas_density > 0` and cell above is empty/gas with lower density
- Gas spreads horizontally to equalize density
- Gas cannot pass through solid cells (is_solid)
- Gas accumulates at ceilings (density increases upward)
**Gas types:**
| Type | ID | Behavior |
|------|----|----------|
| Air | 0 | Default, no effect |
| Smoke | 1 | Rises, blocks light slightly, fades over time |
| Poison | 2 | Rises, damages entities in contact, fades slowly |
| CO2 | 3 | Rises, suffocates fire (fire dies if CO2 density > threshold) |
| Steam | 4 | Rises, condenses to water at temp < 80, transparent |
**Material interactions:**
- `Fire` + `Air` → produces `CO2` + `Smoke`, consumes air density
- `Fire` + `CO2` (high density) → fire extinguishes
- `Lava` + `Water` → produces `Steam` (gas_type=4)
- `Acid` + `Flesh/Wood` → produces `Poison` gas
- `Steam` + `temp < 80` → condenses to `Water` (gas cleared)
**Gas update order:**
1. Material-to-gas transitions (fire produces CO2, lava+water → steam)
2. Gas flow (rise + spread)
3. Gas-to-material transitions (steam condenses, fire suffocates)
4. Gas fading (smoke/poison slowly dissipate)
### Step 6: pressure_step() — NEW (cellular.rs)
Pressure equalization for connected liquid/gas regions.
**Rules:**
- `128` = atmospheric pressure (default for empty cells)
- Liquids generate pressure by depth: `pressure = 128 + depth_from_surface * k`
- Pressure diffuses to neighbors: `p += (neighbor_p - p) * diffusion_rate`
- Solids block pressure transfer
- High pressure ( > 200) pushes liquids/gas through gaps
- Explosions: temporarily set pressure to 255, then diffuse
**Algorithm:**
```
for each active chunk with dirty rect:
for each cell in dirty_rect:
if cell is liquid or gas or empty:
avg_p = average of 4 non-solid neighbors' pressure
new_p = lerp(current_p, avg_p, 0.1)
chunk.pressure[idx] = new_p
```
This enables:
- U-bend pipes (water level equalizes through connected path)
- Fountains (high pressure pushes water up)
- Gas displacement (fire creates pressure, pushes gas out)
- Explosions (pressure wave propagates, destroys weak materials)
### Step 7: light_step() — NEW (cellular.rs)
World-space persistent lighting, updated every N ticks (default N=10).
**Algorithm:**
```
every N ticks:
for each active chunk:
clear chunk.light to [0,0,0]
for each light source in active chunks (Lava, Fire):
ray-cast outward up to radius
for each cell within radius with line-of-sight:
attenuation = (1 - dist/radius)^2
chunk.light[idx] += source.color * source.intensity * attenuation
clamp to 255
```
**Consumers:**
- AI spectrum `render_light()` reads `grid.get_light()` instead of ephemeral `LightGrid`
- Terminal renderer: optional world-space light mode
- GPU renderers: stay as-is (compute lighting in shader, real-time)
- Capture renderer: can use world-space light for consistency
**Performance:**
- Only updates every 10 ticks (6 times per second at 60 FPS)
- Only processes active chunks with dirty rects
- Light sources gathered from `material_light()` (same as current)
### Step 8: Gas Damage in update_status_effects() (game.rs)
New status effect from gas:
```rust
fn update_status_effects(&mut self) {
for e in self.entities.all_mut() {
if e.alive {
let (ex, ey) = e.center();
let (gas_type, gas_density) = self.grid.get_gas(ex as i32, ey as i32);
if gas_type == 2 && gas_density > 50 {
// Poison gas: damage proportional to density
e.health -= (gas_density as f32 - 50.0) * 0.1;
e.status_effects.push(StatusEffect::Poisoned { timer: 60 });
}
if gas_type == 3 && gas_density > 100 {
// CO2: suffocation damage
e.health -= 2.0;
}
e.apply_status_effects();
}
}
}
```
## Serialization
### Multi-section chunk file format
```
File: cache/worlds/seed_<N>/chunk_<cx>_<cy>.bin
[4 bytes: magic "VWM1"] // Verbatim World Map v1
[1 byte: version = 1]
[1 byte: flags = 0]
[4 bytes: cell_section_len] // 8 * 4096 = 32768
[cell_section: 8 bytes x 4096] // material(1) + variant(1) + fg(3) + bg(3)
[4 bytes: temp_section_len] // 4 * 4096 = 16384
[temp_section: 4 bytes x 4096] // f32 little-endian
[2 bytes: gas_section_len] // 2 * 4096 = 8192
[gas_section: 2 bytes x 4096] // type(1) + density(1)
[2 bytes: pressure_section_len] // 1 * 4096 = 4096
[pressure_section: 1 byte x 4096] // u8
[4 bytes: light_section_len] // 3 * 4096 = 12288
[light_section: 3 bytes x 4096] // R + G + B
```
Total: ~8 + 32768 + 16384 + 8192 + 4096 + 12288 = ~74 KB per chunk (was ~49 KB).
**Old cache is incompatible.** Run `rm -rf cache/worlds` after implementation.
### Backward compatibility
None. Old `chunk_*.bin` files (12 bytes/cell format) will fail to load with a clear error. The `meta.json` format stays the same (player position, items, depth).
## Renderer Changes
### GPU Grid Buffers (graphics.rs, vulkan.rs)
Current: one storage buffer with `material as u32` per cell.
New: additional storage buffers:
- `temp_buffer`: `f32` per viewport cell (for heat shimmer, temperature visualization)
- `gas_buffer`: `u32` per viewport cell (packed: `gas_type << 16 | gas_density`)
- `pressure_buffer`: `u32` per viewport cell (for pressure visualization)
Shaders (graphics.vert, cell.vert):
- Binding 0: material grid (existing, for `is_solid()` in line_of_sight)
- Binding 1: light sources (existing)
- Binding 2: temperature grid (NEW, optional use for heat shimmer)
- Binding 3: gas grid (NEW, optional use for fog/smoke overlay)
**Phase 1 implementation**: just upload the data, don't change shader visuals yet.
**Phase 2**: add heat shimmer (vertex displacement based on temp), gas fog (alpha overlay).
### Terminal Renderer (terminal.rs)
- `render()` can optionally use `grid.get_light()` (world-space) instead of CPU `compute_lighting()`
- Toggle with a flag or mode: `--mode terminal --world-light` (experimental)
- Default stays: ephemeral `compute_lighting()` for immediate accuracy
### Capture Renderer (capture.rs)
- Use `grid.get_light()` if available (world-space light)
- Fallback to `compute_lighting()` if light layer is all zeros
### AI Spectrum (spectrum.rs)
| Spectrum | Current source | New source |
|----------|---------------|------------|
| materials | `cell.material` | `cell.material` (unchanged) |
| temperature | `cell.temp` | `grid.get_temp(x, y)` |
| light | `LightGrid` (ephemeral) | `grid.get_light(x, y)` |
| entities | `cell.is_empty()` | `cell.is_empty()` (unchanged) |
| density | `MaterialRegistry` | `MaterialRegistry` (unchanged) |
| velocity | `cell.updated_this_tick` | `cell.updated_this_tick` (unchanged) |
| gas (NEW) | — | `grid.get_gas(x, y)` → type + density chars |
| pressure (NEW) | — | `grid.get_pressure(x, y)` → 0-9 scale char |
New spectrum commands in pipe protocol:
```json
{"cmd":"get_spectrum","spectrum":"gas","w":80,"h":25}
{"cmd":"get_spectrum","spectrum":"pressure","w":80,"h":25}
```
## Material Properties (material.rs)
New fields in `Material`:
```rust
pub struct Material {
// ... existing fields ...
pub gas_emission: (u8, u8), // (gas_type, amount_per_tick) — 0 = none
pub pressure_gen: u8, // pressure added per tick (for explosions/lava)
}
```
| Material | gas_emission | pressure_gen |
|----------|-------------|--------------|
| Empty | (0, 0) | 0 |
| Sand | (0, 0) | 0 |
| Water | (0, 0) | 0 |
| Stone | (0, 0) | 0 |
| Lava | (0, 0) | 1 |
| Wood | (0, 0) | 0 |
| Flesh | (0, 0) | 0 |
| Bone | (0, 0) | 0 |
| Steam | (4, 0) | 0 |
| Fire | (3, 5) | 2 |
| Acid | (0, 0) | 0 |
| Smoke | (1, 0) | 0 |
| Grass | (0, 0) | 0 |
| Dirt | (0, 0) | 0 |
| Stairs | (0, 0) | 0 |
`Fire` emits CO2 (type 3) at 5 density/tick and generates 2 pressure/tick.
`Lava` generates 1 pressure/tick (heat expansion).
## Test Plan
### Updated tests (cell.temp → grid.get_temp)
All tests that access `cell.temp` via `GameSession` need updating:
- `tests/physics_lava.rs` — lava temperature checks
- `tests/physics_interactions.rs` — lava+water=steam, fire spread
- `tests/physics_water.rs` — water boiling
- `tests/integration.rs` — any temp-related assertions
- `tests/physics_acid.rs` — acid + organic → poison gas
### New tests
**Temperature layer:**
- `heat_transfer_diffuses_through_solid_material` — stone wall between hot/cold
- `heat_transfer_uses_conductivity` — wood conducts less than stone
- `lava_heats_adjacent_water_to_steam` — phase transition via heat diffusion
- `temperature_persists_across_chunk_boundary` — cross-chunk heat flow
**Gas layer:**
- `gas_rises_and_accumulates_at_ceiling` — smoke fills upward
- `fire_consumes_air_and_produces_co2` — fire reduces air, increases CO2
- `fire_extinguishes_in_high_co2` — fire dies without air
- `poison_gas_damages_entity` — entity takes damage in poison gas
- `steam_condenses_to_water_when_cold` — gas-to-material transition
- `lava_plus_water_produces_steam_gas` — material interaction creates gas
- `gas_does_not_pass_through_solid_walls` — containment test
**Pressure layer:**
- `pressure_equalizes_in_connected_liquids` — U-bend test
- `high_pressure_pushes_liquid_through_gap` — fountain test
- `explosion_creates_pressure_wave` — pressure propagation
- `solid_blocks_pressure_transfer` — isolation test
**Light layer:**
- `light_persists_in_world_space` — light stored in chunk
- `light_blocked_by_solid_walls` — shadow casting
- `light_updates_when_source_removed` — fire extinguished → darkness
- `lava_emits_light_in_world_space` — light source test
**Serialization:**
- `chunk_save_load_roundtrip_preserves_all_layers` — temp, gas, pressure, light
- `old_cache_format_rejected` — error on loading v0 format
## Implementation Order
| Step | Files | Description |
|------|-------|-------------|
| 1 | `cell.rs` | Remove `temp` from `Cell`, update `to_bytes`/`from_bytes` to 8 bytes |
| 2 | `chunk.rs` | Add `temps`, `pressure`, `gas_type`, `gas_density`, `light` arrays, init in `new()` |
| 3 | `chunked_grid.rs` | Add `get_temp`/`set_temp`/`get_pressure`/`set_pressure`/`get_gas`/`set_gas`/`get_light`/`set_light` methods |
| 4 | `chunked_grid.rs` | Update `save_chunk`/`load_chunk` to multi-section format |
| 5 | `cellular.rs` | Refactor `heat_transfer()` to direct array access on `temps[]` |
| 6 | `cellular.rs` | Refactor all CA rules: `cell.temp``grid.get_temp()` / `grid.set_temp()` |
| 7 | `cellular.rs` | Implement `gas_step()` — gas flow + material interactions |
| 8 | `cellular.rs` | Implement `pressure_step()` — pressure equalization |
| 9 | `cellular.rs` | Implement `light_step()` — world-space lighting (every N ticks) |
| 10 | `game.rs` | Update `fixed_update()`: add gas_step, pressure_step, light_step calls |
| 11 | `game.rs` | Update `update_status_effects()`: gas damage (poison, CO2) |
| 12 | `material.rs` | Add `gas_emission`, `pressure_gen` fields to `Material` |
| 13 | `render/graphics.rs` | Upload temp/gas/pressure buffers to GPU |
| 14 | `render/vulkan.rs` | Same as graphics.rs |
| 15 | `render/lighting.rs` | Update `compute_lighting` to optionally read world-space light |
| 16 | `render/terminal.rs` | Optional world-space light mode |
| 17 | `render/capture.rs` | Use world-space light if available |
| 18 | `ai/spectrum.rs` | Update temperature/light spectrums, add gas + pressure spectrums |
| 19 | `ai/protocol.rs` | Add gas/pressure spectrum commands |
| 20 | `ai/state.rs` | Update `CellInfo` to include gas/pressure data |
| 21 | `ai/session.rs` | Update `find_material` for infinite mode (separate fix) |
| 22 | `tests/*.rs` | Update all temp-related tests, add new layer tests |
| 23 | — | `rm -rf cache/worlds` (old cache incompatible) |
| 24 | — | `cargo test` — all green |
| 25 | — | `cargo run --release -- --mode benchmark` — verify no regression |
## File Impact Map
| File | Changes |
|------|---------|
| `src/world/cell.rs` | Remove `temp` field, update `to_bytes`/`from_bytes` (12→8 bytes) |
| `src/world/chunk.rs` | 5 new arrays, init, accessor methods, `reset_tick_flags` unchanged |
| `src/world/chunked_grid.rs` | 8 new accessor methods, multi-section serialization, `ensure_chunk` init new arrays |
| `src/world/cellular.rs` | `heat_transfer` refactor, `gas_step`, `pressure_step`, `light_step`, CA rule temp changes |
| `src/world/material.rs` | `gas_emission`, `pressure_gen` fields on `Material` |
| `src/game.rs` | `fixed_update` new steps, gas damage in `update_status_effects` |
| `src/render/graphics.rs` | 3 new GPU buffers (temp, gas, pressure), upload code |
| `src/render/vulkan.rs` | Same as graphics.rs |
| `src/render/lighting.rs` | Optional world-space light mode in `compute_lighting` |
| `src/render/terminal.rs` | Optional `--world-light` flag |
| `src/render/capture.rs` | World-space light fallback |
| `src/ai/spectrum.rs` | Temp/light from layers, new gas/pressure spectrums |
| `src/ai/protocol.rs` | New spectrum commands |
| `src/ai/state.rs` | `CellInfo` + gas/pressure fields |
| `tests/*.rs` | Update ~10 test files, add ~20 new tests |
## Performance Expectations
| Metric | Before | Expected After |
|--------|--------|---------------|
| Cell copy per get() | 13 bytes | 9 bytes (-31%) |
| heat_transfer per cell | 2 Cell copies + mark_dirty | 1 array write (no copy, no dirty) |
| Chunk memory | ~53 KB | ~76 KB (+43%) |
| Active chunk memory (9 chunks) | ~477 KB | ~684 KB |
| CA step time | ~0.5 ms | ~0.4 ms (fewer copies) |
| heat_transfer time | ~0.3 ms | ~0.1 ms (direct array) |
| gas_step time | N/A | ~0.2 ms (new) |
| pressure_step time | N/A | ~0.1 ms (new) |
| light_step time (every 10 ticks) | N/A | ~0.5 ms (amortized ~0.05 ms/tick) |
| Total fixed_update | ~1.5 ms | ~1.3 ms |
| Render frame | ~4 ms | ~4.5 ms (+3 buffers) |
## Cross-Layer Interactions
```
Fire (material)
→ heats temp layer (heat_transfer)
→ emits CO2 + Smoke (gas layer)
→ generates pressure (pressure layer)
→ emits light (light layer)
Lava (material)
→ heats temp layer
→ generates pressure
→ emits light
→ + Water → Steam (gas layer) + temp drop
Temperature layer
→ Water > 100°C → Steam (material + gas)
→ Lava < 400°C → Stone (material)
→ Steam < 80°C → Water (material, gas cleared)
→ Wood > 300°C → Fire (material)
Gas layer
→ CO2 high → Fire extinguishes (material)
→ Poison → entity damage (status effects)
→ Steam + cold temp → Water (material)
Pressure layer
→ High pressure → pushes liquid through gaps
→ Explosion → pressure wave → terrain destruction
→ Lava → constant pressure generation
```
## Open Questions
| Question | Default | Notes |
|----------|---------|-------|
| Light update frequency | Every 10 ticks | Configurable, tradeoff: accuracy vs perf |
| Gas diffusion rate | 0.1 | How fast gas spreads per tick |
| Pressure diffusion rate | 0.1 | How fast pressure equalizes |
| Gas fade rate | 1 per 60 ticks | Smoke/poison slowly dissipate |
| Max gas density | 255 | u8 limit, may need f32 if finer |
| Pressure as u8 or f32? | u8 (0-255) | Simpler, 128=atmospheric. f32 if precision needed |
| Steam as gas or material? | Gas (type 4) | Current: Steam is a material. New: Steam is a gas that condenses to Water |
| CO2 threshold for fire death | density > 150 | Fire extinguishes when CO2 concentration is high |
| Poison damage threshold | density > 50 | Entity takes damage above this concentration |
+11 -11
View File
@@ -1,17 +1,17 @@
{
"mode": "graphics",
"ticks": 300,
"total_time_ms": 2554.2,
"avg_fps": 117.5,
"avg_frame_time_ms": 8.49,
"p99_frame_time_ms": 11.37,
"min_frame_time_ms": 6.87,
"total_time_ms": 3493.0,
"avg_fps": 85.9,
"avg_frame_time_ms": 11.62,
"p99_frame_time_ms": 31.15,
"min_frame_time_ms": 7.65,
"subsystems": {
"ca_step_avg_us": 842,
"ca_step_p99_us": 655,
"ca_step_min_us": 301,
"render_avg_us": 4351,
"render_p99_us": 6445,
"render_min_us": 3759
"ca_step_avg_us": 4142,
"ca_step_p99_us": 20939,
"ca_step_min_us": 523,
"render_avg_us": 4335,
"render_p99_us": 6314,
"render_min_us": 3830
}
}
+2
View File
@@ -289,6 +289,8 @@ fn handle_command(cmd: Command, session: &mut Option<GameSession>) -> Response {
"entities" => crate::ai::spectrum::Spectrum::Entities,
"density" => crate::ai::spectrum::Spectrum::Density,
"velocity" => crate::ai::spectrum::Spectrum::Velocity,
"gas" => crate::ai::spectrum::Spectrum::Gas,
"pressure" => crate::ai::spectrum::Spectrum::Pressure,
_ => {
return Response::err(
"Unknown spectrum. Use: materials, temperature, light, entities, density, velocity",
+6 -6
View File
@@ -155,26 +155,26 @@ fn check_assertion(session: &GameSession, assertion: &Assertion) -> AssertionRes
}
}
Assertion::CellTempGreaterThan { x, y, temp } => {
let cell = session.get_cell(*x, *y);
let passed = cell.temp > *temp;
let cell_temp = session.get_temp(*x, *y);
let passed = cell_temp > *temp;
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!(
"Cell({},{}) temp = {:.1}, expected > {:.1}",
x, y, cell.temp, temp
x, y, cell_temp, temp
),
}
}
Assertion::CellTempLessThan { x, y, temp } => {
let cell = session.get_cell(*x, *y);
let passed = cell.temp < *temp;
let cell_temp = session.get_temp(*x, *y);
let passed = cell_temp < *temp;
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!(
"Cell({},{}) temp = {:.1}, expected < {:.1}",
x, y, cell.temp, temp
x, y, cell_temp, temp
),
}
}
+19 -2
View File
@@ -133,6 +133,10 @@ impl GameSession {
CellInfo::from_grid(&self.game.grid, x, y)
}
pub fn get_temp(&self, x: i32, y: i32) -> f32 {
self.game.grid.get_temp(x, y)
}
pub fn get_region(&self, x: i32, y: i32, w: i32, h: i32) -> Vec<CellInfo> {
let mut cells = Vec::with_capacity((w * h) as usize);
for dy in 0..h {
@@ -186,8 +190,21 @@ impl GameSession {
pub fn find_material(&self, material: &str) -> Option<(i32, i32)> {
let target = crate::ai::state::material_from_name(material)?;
for y in 0..self.game.grid.height as i32 {
for x in 0..self.game.grid.width as i32 {
let (px, py) = self.game.player.center(&self.game.entities);
let radius = if self.game.grid.is_infinite() {
200
} else {
self.game.grid.width as i32
};
let cx = px as i32;
let cy = py as i32;
for dy in -radius..=radius {
for dx in -radius..=radius {
let x = cx + dx;
let y = cy + dy;
if !self.game.grid.in_bounds(x, y) {
continue;
}
if self.game.grid.get(x, y).material == target {
return Some((x, y));
}
+76 -1
View File
@@ -8,6 +8,8 @@ pub enum Spectrum {
Entities,
Density,
Velocity,
Gas,
Pressure,
}
impl Spectrum {
@@ -19,6 +21,8 @@ impl Spectrum {
Spectrum::Entities => "entities",
Spectrum::Density => "density",
Spectrum::Velocity => "velocity",
Spectrum::Gas => "gas",
Spectrum::Pressure => "pressure",
}
}
@@ -30,6 +34,8 @@ impl Spectrum {
Spectrum::Entities,
Spectrum::Density,
Spectrum::Velocity,
Spectrum::Gas,
Spectrum::Pressure,
]
}
}
@@ -51,6 +57,8 @@ pub fn render_spectrum(
Spectrum::Entities => render_entities(grid, entities, cam_x, cam_y, vw, vh),
Spectrum::Density => render_density(grid, cam_x, cam_y, vw, vh),
Spectrum::Velocity => render_velocity(grid, entities, cam_x, cam_y, vw, vh),
Spectrum::Gas => render_gas(grid, cam_x, cam_y, vw, vh),
Spectrum::Pressure => render_pressure(grid, cam_x, cam_y, vw, vh),
}
}
@@ -113,7 +121,7 @@ fn render_temperature(grid: &ChunkedGrid, cam_x: i32, cam_y: i32, vw: usize, vh:
if cell.is_empty() {
buf.push(' ');
} else {
let t = cell.temp;
let t = grid.get_temp(x, y);
let ch = if t < 0.0 {
'.'
} else if t < 20.0 {
@@ -158,6 +166,13 @@ fn render_light(
let (r, g, b) = if let Some(lg) = light {
let c = lg.get(dx as i32, dy as i32);
(c[0], c[1], c[2])
} else if grid.in_bounds(x, y) {
let wl = grid.get_light(x, y);
if wl[0] > 0 || wl[1] > 0 || wl[2] > 0 {
(wl[0], wl[1], wl[2])
} else {
(ambient[0], ambient[1], ambient[2])
}
} else {
(ambient[0], ambient[1], ambient[2])
};
@@ -364,3 +379,63 @@ pub fn format_all_spectrums(
}
out
}
fn render_gas(grid: &ChunkedGrid, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String {
let mut buf = String::with_capacity(vw * vh + vh);
for dy in 0..vh {
for dx in 0..vw {
let x = cam_x + dx as i32;
let y = cam_y + dy as i32;
if !grid.in_bounds(x, y) {
buf.push(' ');
} else {
let (gt, gd) = grid.get_gas(x, y);
let ch = if gd == 0 {
' '
} else {
match gt {
1 => '.', // smoke
2 => 'x', // poison
3 => 'o', // CO2
4 => '~', // steam
_ => '?',
}
};
buf.push(ch);
}
}
buf.push('\n');
}
buf
}
fn render_pressure(grid: &ChunkedGrid, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String {
let mut buf = String::with_capacity(vw * vh + vh);
for dy in 0..vh {
for dx in 0..vw {
let x = cam_x + dx as i32;
let y = cam_y + dy as i32;
if !grid.in_bounds(x, y) {
buf.push(' ');
} else {
let p = grid.get_pressure(x, y);
let ch = if p < 80 {
'L'
} else if p < 120 {
'-'
} else if p < 140 {
'.'
} else if p < 180 {
'+'
} else if p < 220 {
'o'
} else {
'#'
};
buf.push(ch);
}
}
buf.push('\n');
}
buf
}
+1 -1
View File
@@ -79,7 +79,7 @@ impl CellInfo {
x,
y,
material: mat.name.to_string(),
temp: cell.temp,
temp: grid.get_temp(x, y),
is_solid: mat.solid,
is_liquid: mat.liquid,
is_gas: mat.gas,
+16
View File
@@ -450,6 +450,7 @@ impl Game {
self.stream_chunks();
self.update_active_chunks();
self.apply_gas_damage();
self.ca.step(&mut self.grid);
self.update_entities();
@@ -490,6 +491,21 @@ impl Game {
}
}
fn apply_gas_damage(&mut self) {
for e in self.entities.all_mut() {
if e.alive {
let (ex, ey) = e.center();
let (gas_type, gas_density) = self.grid.get_gas(ex as i32, ey as i32);
if gas_type == 2 && gas_density > 50 {
e.health -= (gas_density as f32 - 50.0) * 0.1;
}
if gas_type == 3 && gas_density > 100 {
e.health -= 2.0;
}
}
}
}
fn update_status_effects(&mut self) {
for e in self.entities.all_mut() {
if e.alive {
+1 -1
View File
@@ -143,8 +143,8 @@ impl Projectile {
{
let mut ignited = cell;
ignited.material = MaterialId::Fire;
ignited.temp = 400.0;
grid.set(x, y, ignited);
grid.set_temp(x, y, 400.0);
}
}
}
+20 -27
View File
@@ -45,18 +45,26 @@ impl MaterialId {
#[derive(Clone, Copy, Debug)]
pub struct Cell {
pub material: MaterialId,
pub temp: f32,
pub updated_this_tick: bool,
pub variant: u8,
pub fg: [u8; 3],
pub bg: [u8; 3],
}
pub fn default_temp(material: MaterialId) -> f32 {
match material {
MaterialId::Lava => 1500.0,
MaterialId::Fire => 800.0,
MaterialId::Steam => 150.0,
MaterialId::Smoke => 120.0,
_ => 20.0,
}
}
impl Cell {
pub fn empty() -> Self {
Self {
material: MaterialId::Empty,
temp: 20.0,
updated_this_tick: false,
variant: 0,
fg: [15, 15, 20],
@@ -67,16 +75,8 @@ impl Cell {
pub fn new(material: MaterialId) -> Self {
let reg = MaterialRegistry::instance();
let mat = reg.get(material);
let temp = match material {
MaterialId::Lava => 1500.0,
MaterialId::Fire => 800.0,
MaterialId::Steam => 150.0,
MaterialId::Smoke => 120.0,
_ => 20.0,
};
Self {
material,
temp,
updated_this_tick: false,
variant: rand_u8(),
fg: [mat.color_fg.0, mat.color_fg.1, mat.color_fg.2],
@@ -117,13 +117,12 @@ impl Cell {
self.material.display_char()
}
pub fn to_bytes(&self) -> [u8; 12] {
let mut out = [0u8; 12];
pub fn to_bytes(&self) -> [u8; 8] {
let mut out = [0u8; 8];
out[0] = self.material as u8;
out[1..5].copy_from_slice(&self.temp.to_le_bytes());
out[5] = self.variant;
out[6..9].copy_from_slice(&self.fg);
out[9..12].copy_from_slice(&self.bg);
out[1] = self.variant;
out[2..5].copy_from_slice(&self.fg);
out[5..8].copy_from_slice(&self.bg);
out
}
@@ -150,25 +149,19 @@ impl Cell {
_ => MaterialId::Stone,
}
};
let temp = if bytes.len() >= 5 {
f32::from_le_bytes([bytes[1], bytes[2], bytes[3], bytes[4]])
} else {
20.0
};
let variant = bytes.get(5).copied().unwrap_or(0);
let fg = if bytes.len() >= 9 {
[bytes[6], bytes[7], bytes[8]]
let variant = bytes.get(1).copied().unwrap_or(0);
let fg = if bytes.len() >= 5 {
[bytes[2], bytes[3], bytes[4]]
} else {
[15, 15, 20]
};
let bg = if bytes.len() >= 12 {
[bytes[9], bytes[10], bytes[11]]
let bg = if bytes.len() >= 8 {
[bytes[5], bytes[6], bytes[7]]
} else {
[10, 10, 15]
};
Self {
material,
temp,
updated_this_tick: false,
variant,
fg,
+358 -125
View File
@@ -1,10 +1,12 @@
use crate::world::cell::{Cell, MaterialId};
use crate::world::cell::{default_temp, Cell, MaterialId};
use crate::world::chunk::CHUNK_SIZE;
use crate::world::chunked_grid::ChunkedGrid;
pub struct CellularAutomaton {
tick: u64,
rng_state: u64,
temps: Vec<f32>,
temps_buf: Vec<f32>,
light_tick: u64,
}
impl CellularAutomaton {
@@ -12,7 +14,8 @@ impl CellularAutomaton {
Self {
tick: 0,
rng_state: 0x1234567890ABCDEF,
temps: Vec::new(),
temps_buf: Vec::new(),
light_tick: 0,
}
}
@@ -89,6 +92,31 @@ impl CellularAutomaton {
let mut active = grid.active_chunks();
active.sort_by(|(ax, ay), (bx, by)| by.cmp(ay).then(bx.cmp(ax)));
let mut pre_dirty: Vec<((i32, i32), (i32, i32, i32, i32))> = Vec::new();
for (cx, cy) in &active {
if let Some(d) = grid.get_chunk_dirty(*cx, *cy) {
pre_dirty.push(((*cx, *cy), d));
}
}
for (cx, cy) in &active {
let dirty = grid.get_chunk_dirty(*cx, *cy);
if dirty.is_none() {
continue;
}
let (min_x, min_y, max_x, max_y) = dirty.unwrap();
for y in min_y..=max_y {
for x in min_x..=max_x {
let mut cell = grid.get(x, y);
if cell.updated_this_tick {
cell.updated_this_tick = false;
grid.set(x, y, cell);
}
}
}
}
for (cx, cy) in active {
let dirty = grid.get_chunk_dirty(cx, cy);
if dirty.is_none() {
@@ -98,15 +126,6 @@ impl CellularAutomaton {
grid.set_chunk_dirty(cx, cy, None);
for y in min_y..=max_y {
for x in min_x..=max_x {
let mut cell = grid.get(x, y);
cell.updated_this_tick = false;
grid.set(x, y, cell);
}
}
grid.set_chunk_dirty(cx, cy, None);
for y in (min_y..=max_y).rev() {
if flip {
for x in min_x..=max_x {
@@ -120,7 +139,10 @@ impl CellularAutomaton {
}
}
self.heat_transfer(grid);
self.heat_transfer(grid, &pre_dirty);
self.gas_step(grid, &pre_dirty);
self.pressure_step(grid, &pre_dirty);
self.light_step(grid);
self.tick += 1;
}
@@ -168,57 +190,56 @@ impl CellularAutomaton {
let below = grid.get(x, y + 1);
if below.is_empty() || (below.is_liquid() && below.density() < 1.0) {
grid.cells_swap(x, y, x, y + 1);
return;
}
let dir = if self.rand_bool() { 1 } else { -1 };
let dl = grid.get(x - dir, y + 1);
let dr = grid.get(x + dir, y + 1);
let can_dl = grid.in_bounds(x - dir, y + 1)
&& (dl.is_empty() || (dl.is_liquid() && dl.density() < 1.0));
let can_dr = grid.in_bounds(x + dir, y + 1)
&& (dr.is_empty() || (dr.is_liquid() && dr.density() < 1.0));
if can_dl && can_dr {
if self.rand_bool() {
grid.cells_swap(x, y, x - dir, y + 1);
} else {
grid.cells_swap(x, y, x + dir, y + 1);
}
} else if can_dl {
grid.cells_swap(x, y, x - dir, y + 1);
} else if can_dr {
grid.cells_swap(x, y, x + dir, y + 1);
} else {
let can_l = grid.in_bounds(x - dir, y) && grid.get(x - dir, y).is_empty();
let can_r = grid.in_bounds(x + dir, y) && grid.get(x + dir, y).is_empty();
if can_l && can_r {
let dir = if self.rand_bool() { 1 } else { -1 };
let dl = grid.get(x - dir, y + 1);
let dr = grid.get(x + dir, y + 1);
let can_dl = grid.in_bounds(x - dir, y + 1)
&& (dl.is_empty() || (dl.is_liquid() && dl.density() < 1.0));
let can_dr = grid.in_bounds(x + dir, y + 1)
&& (dr.is_empty() || (dr.is_liquid() && dr.density() < 1.0));
if can_dl && can_dr {
if self.rand_bool() {
grid.cells_swap(x, y, x - dir, y);
grid.cells_swap(x, y, x - dir, y + 1);
} else {
grid.cells_swap(x, y, x + dir, y + 1);
}
} else if can_dl {
grid.cells_swap(x, y, x - dir, y + 1);
} else if can_dr {
grid.cells_swap(x, y, x + dir, y + 1);
} else {
let can_l = grid.in_bounds(x - dir, y) && grid.get(x - dir, y).is_empty();
let can_r = grid.in_bounds(x + dir, y) && grid.get(x + dir, y).is_empty();
if can_l && can_r {
if self.rand_bool() {
grid.cells_swap(x, y, x - dir, y);
} else {
grid.cells_swap(x, y, x + dir, y);
}
} else if can_l {
grid.cells_swap(x, y, x - dir, y);
} else if can_r {
grid.cells_swap(x, y, x + dir, y);
}
} else if can_l {
grid.cells_swap(x, y, x - dir, y);
} else if can_r {
grid.cells_swap(x, y, x + dir, y);
}
}
let cell = grid.get(x, y);
if cell.temp > 100.0 {
let mut new = cell;
let temp = grid.get_temp(x, y);
if temp > 100.0 {
let mut new = grid.get(x, y);
new.material = MaterialId::Steam;
new.temp = 110.0;
grid.set(x, y, new);
grid.set_temp(x, y, 110.0);
}
}
fn update_lava(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let cell = grid.get(x, y);
if cell.temp < 400.0 {
let mut new = cell;
let temp = grid.get_temp(x, y);
if temp < 400.0 {
let mut new = grid.get(x, y);
new.material = MaterialId::Stone;
grid.set(x, y, new);
return;
@@ -259,20 +280,19 @@ impl CellularAutomaton {
continue;
}
let neighbor = grid.get(nx, ny);
let n_temp = grid.get_temp(nx, ny);
match neighbor.material {
MaterialId::Water => {
grid.set(nx, ny, Cell::new(MaterialId::Steam));
let lava = grid.get(x, y);
let mut new_lava = lava;
new_lava.temp -= 50.0;
grid.set(x, y, new_lava);
grid.set_temp(nx, ny, 150.0);
let lava_temp = grid.get_temp(x, y);
grid.set_temp(x, y, lava_temp - 50.0);
}
MaterialId::Wood | MaterialId::Grass | MaterialId::Flesh
if neighbor.temp < 300.0 =>
{
MaterialId::Wood | MaterialId::Grass | MaterialId::Flesh if n_temp < 300.0 => {
grid.set(nx, ny, Cell::new(MaterialId::Fire));
grid.set_temp(nx, ny, 400.0);
}
MaterialId::Sand if neighbor.temp > 1700.0 => {
MaterialId::Sand if n_temp > 1700.0 => {
grid.set(nx, ny, Cell::new(MaterialId::Stone));
}
_ => {}
@@ -281,12 +301,12 @@ impl CellularAutomaton {
}
fn update_steam(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let cell = grid.get(x, y);
if cell.temp < 80.0 {
let mut new = cell;
let temp = grid.get_temp(x, y);
if temp < 80.0 {
let mut new = grid.get(x, y);
new.material = MaterialId::Water;
new.temp = 50.0;
grid.set(x, y, new);
grid.set_temp(x, y, 50.0);
return;
}
@@ -316,6 +336,21 @@ impl CellularAutomaton {
fn update_fire(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let cell = grid.get(x, y);
let temp = grid.get_temp(x, y);
let (egt, egd) = grid.get_gas(x, y);
if egd < 200 {
grid.set_gas(x, y, 3, (egd + 5).min(255));
}
if y > 0 {
let above = grid.get(x, y - 1);
if above.is_empty() {
let (agt, agd) = grid.get_gas(x, y - 1);
if agt == 0 && agd < 100 {
grid.set_gas(x, y - 1, 1, (agd + 3).min(200));
}
}
}
for &(dx, dy) in &NEIGHBORS4 {
let nx = x + dx;
@@ -326,26 +361,27 @@ impl CellularAutomaton {
let neighbor = grid.get(nx, ny);
let reg = crate::world::material::MaterialRegistry::instance();
let mat = reg.get(neighbor.material);
if mat.flammable && neighbor.temp < mat.ignition_temp {
let n_temp = grid.get_temp(nx, ny);
if mat.flammable && n_temp < mat.ignition_temp {
let mut new_n = neighbor;
new_n.material = MaterialId::Fire;
new_n.temp = 400.0;
grid.set(nx, ny, new_n);
grid.set_temp(nx, ny, 400.0);
}
}
if cell.temp < 100.0 || self.rand() % 20 == 0 {
let (_gt, gd) = grid.get_gas(x, y);
if temp < 100.0 || self.rand() % 20 == 0 || gd > 150 {
if self.rand() % 3 == 0 {
grid.set(x, y, Cell::new(MaterialId::Smoke));
grid.set_temp(x, y, 120.0);
} else {
grid.set(x, y, Cell::empty());
}
return;
}
let mut new = cell;
new.temp -= 15.0;
grid.set(x, y, new);
grid.set_temp(x, y, temp - 15.0);
if y > 0 && grid.get(x, y - 1).is_empty() && self.rand() % 2 == 0 {
grid.cells_swap(x, y, x, y - 1);
@@ -388,6 +424,12 @@ impl CellularAutomaton {
&& neighbor.material != MaterialId::Stone
&& self.rand() % 4 == 0
{
if neighbor.material == MaterialId::Flesh || neighbor.material == MaterialId::Wood {
let (gt, _gd) = grid.get_gas(nx, ny);
if gt == 0 {
grid.set_gas(nx, ny, 2, 80);
}
}
grid.set(nx, ny, Cell::empty());
if self.rand() % 2 == 0 {
grid.set(x, y, Cell::empty());
@@ -415,77 +457,55 @@ impl CellularAutomaton {
}
fn update_flesh(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let cell = grid.get(x, y);
if cell.temp > 200.0 {
let mut new = cell;
let temp = grid.get_temp(x, y);
if temp > 200.0 {
let mut new = grid.get(x, y);
new.material = MaterialId::Fire;
new.temp = 400.0;
grid.set(x, y, new);
grid.set_temp(x, y, 400.0);
}
}
fn update_grass(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let cell = grid.get(x, y);
if cell.temp > 250.0 {
let temp = grid.get_temp(x, y);
if temp > 250.0 {
grid.set(x, y, Cell::new(MaterialId::Fire));
grid.set_temp(x, y, 400.0);
}
}
fn update_dirt(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let cell = grid.get(x, y);
if cell.temp < 0.0 {
let mut new = cell;
let temp = grid.get_temp(x, y);
if temp < 0.0 {
let mut new = grid.get(x, y);
new.material = MaterialId::Stone;
grid.set(x, y, new);
}
}
fn heat_transfer(&mut self, grid: &mut ChunkedGrid) {
fn heat_transfer(
&mut self,
grid: &mut ChunkedGrid,
pre_dirty: &[((i32, i32), (i32, i32, i32, i32))],
) {
let reg = crate::world::material::MaterialRegistry::instance();
let gw = grid.width as i32;
let gh = grid.height as i32;
let active = grid.active_chunks();
for (cx, cy) in active {
let dirty = grid.get_chunk_dirty(cx, cy);
if dirty.is_none() {
continue;
}
let (min_x, min_y, max_x, max_y) = dirty.unwrap();
let ex_min_x = (min_x - 1).max(0);
let ex_min_y = (min_y - 1).max(0);
let (ex_max_x, ex_max_y) = if grid.is_infinite() {
((max_x + 1), (max_y + 1))
} else {
((max_x + 1).min(gw - 1), (max_y + 1).min(gh - 1))
let pre = pre_dirty
.iter()
.find(|(c, _)| *c == (cx, cy))
.map(|(_, d)| *d);
let (min_x, min_y, max_x, max_y) = match (dirty, pre) {
(Some(d), Some(p)) => (d.0.min(p.0), d.1.min(p.1), d.2.max(p.2), d.3.max(p.3)),
(Some(d), None) => d,
(None, Some(p)) => p,
(None, None) => continue,
};
let ew = (ex_max_x - ex_min_x + 1) as usize;
let eh = (ex_max_y - ex_min_y + 1) as usize;
let ecount = ew.saturating_mul(eh);
if ecount > 10000 {
eprintln!(
"heat_transfer skipping huge dirty rect: chunk=({}, {}) dirty=({},{},{},{}) ew={} eh={}",
cx, cy, min_x, min_y, max_x, max_y, ew, eh
);
continue;
}
if self.temps.len() < ecount {
self.temps.resize(ecount, 0.0);
}
for y in ex_min_y..=ex_max_y {
for x in ex_min_x..=ex_max_x {
let idx = ((y - ex_min_y) as usize) * ew + (x - ex_min_x) as usize;
self.temps[idx] = grid.get(x, y).temp;
}
}
for y in min_y..=max_y {
for x in min_x..=max_x {
let cell = grid.get(x, y);
if cell.is_empty() || cell.is_static() {
if cell.is_empty() {
continue;
}
let mat = reg.get(cell.material);
@@ -493,30 +513,243 @@ impl CellularAutomaton {
if k == 0.0 {
continue;
}
let cur_temp = grid.get_temp(x, y);
let mut sum = 0.0;
let mut count = 0;
for &(dx, dy) in &NEIGHBORS4 {
let nx = x + dx;
let ny = y + dy;
if nx < ex_min_x || nx > ex_max_x || ny < ex_min_y || ny > ex_max_y {
continue;
}
let ni = ((ny - ex_min_y) as usize) * ew + (nx - ex_min_x) as usize;
sum += self.temps[ni];
sum += grid.get_temp(nx, ny);
count += 1;
}
if count > 0 {
let avg = sum / count as f32;
let mut new = cell;
new.temp += (avg - cell.temp) * k * 0.1;
grid.set(x, y, new);
grid.mark_dirty(x, y);
let new_temp = cur_temp + (avg - cur_temp) * k * 0.1;
if (new_temp - cur_temp).abs() > 0.01 {
grid.set_temp(x, y, new_temp);
grid.mark_dirty(x, y);
}
}
}
}
}
}
fn gas_step(
&mut self,
grid: &mut ChunkedGrid,
pre_dirty: &[((i32, i32), (i32, i32, i32, i32))],
) {
let active = grid.active_chunks();
for (cx, cy) in active {
let dirty = grid.get_chunk_dirty(cx, cy);
let pre = pre_dirty
.iter()
.find(|(c, _)| *c == (cx, cy))
.map(|(_, d)| *d);
let (min_x, min_y, max_x, max_y) = match (dirty, pre) {
(Some(d), Some(p)) => (d.0.min(p.0), d.1.min(p.1), d.2.max(p.2), d.3.max(p.3)),
(Some(d), None) => d,
(None, Some(p)) => p,
(None, None) => continue,
};
for y in min_y..=max_y {
for x in min_x..=max_x {
let (gt, gd) = grid.get_gas(x, y);
if gd == 0 {
continue;
}
if gt == 4 && grid.get_temp(x, y) < 80.0 {
let mut new = grid.get(x, y);
new.material = MaterialId::Water;
grid.set(x, y, new);
grid.set_temp(x, y, 50.0);
grid.set_gas(x, y, 0, 0);
continue;
}
if y > 0 {
let above = grid.get(x, y - 1);
if above.is_empty() || above.is_gas() {
let (agt, agd) = grid.get_gas(x, y - 1);
if agd < gd {
grid.set_gas(x, y - 1, gt, gd);
grid.set_gas(x, y, agt, agd);
continue;
}
}
}
let dir = if self.rand_bool() { 1 } else { -1 };
for &d in &[dir, -dir] {
if grid.in_bounds(x + d, y) {
let side = grid.get(x + d, y);
if side.is_empty() || side.is_gas() {
let (sgt, sgd) = grid.get_gas(x + d, y);
if sgd < gd.saturating_sub(5) {
let avg = (gd + sgd) / 2;
grid.set_gas(x + d, y, gt, avg);
grid.set_gas(x, y, gt, gd.saturating_sub(avg - sgd));
break;
}
}
}
}
if (gt == 1 || gt == 2) && gd > 0 && self.rand() % 120 == 0 {
grid.set_gas(x, y, gt, gd.saturating_sub(1));
}
}
}
}
}
fn pressure_step(
&mut self,
grid: &mut ChunkedGrid,
pre_dirty: &[((i32, i32), (i32, i32, i32, i32))],
) {
let active = grid.active_chunks();
for (cx, cy) in active {
let dirty = grid.get_chunk_dirty(cx, cy);
let pre = pre_dirty
.iter()
.find(|(c, _)| *c == (cx, cy))
.map(|(_, d)| *d);
let (min_x, min_y, max_x, max_y) = match (dirty, pre) {
(Some(d), Some(p)) => (d.0.min(p.0), d.1.min(p.1), d.2.max(p.2), d.3.max(p.3)),
(Some(d), None) => d,
(None, Some(p)) => p,
(None, None) => continue,
};
for y in min_y..=max_y {
for x in min_x..=max_x {
let cell = grid.get(x, y);
if cell.is_solid() {
continue;
}
let cur_p = grid.get_pressure(x, y) as i32;
let mut sum = 0i32;
let mut count = 0i32;
for &(dx, dy) in &NEIGHBORS4 {
let nx = x + dx;
let ny = y + dy;
if !grid.in_bounds(nx, ny) {
continue;
}
let n = grid.get(nx, ny);
if n.is_solid() {
continue;
}
sum += grid.get_pressure(nx, ny) as i32;
count += 1;
}
if count > 0 {
let avg = sum / count;
let new_p = cur_p + (avg - cur_p) / 8;
if new_p != cur_p {
grid.set_pressure(x, y, new_p.clamp(0, 255) as u8);
}
}
}
}
}
}
fn light_step(&mut self, grid: &mut ChunkedGrid) {
self.light_tick += 1;
if self.light_tick % 10 != 0 {
return;
}
let active = grid.active_chunks();
let cs = CHUNK_SIZE as i32;
for &(cx, cy) in &active {
if let Some(chunk) = grid.get_chunk_mut(cx, cy) {
for l in chunk.light.iter_mut() {
*l = [0, 0, 0];
}
}
}
for &(cx, cy) in &active {
let ox = cx * cs;
let oy = cy * cs;
for ly in 0..cs {
for lx in 0..cs {
let wx = ox + lx;
let wy = oy + ly;
let cell = grid.get(wx, wy);
if let Some(src) = material_light(cell.material) {
let radius = src.0 as i32;
let color = src.1;
for dy in -radius..=radius {
for dx in -radius..=radius {
let tx = wx + dx;
let ty = wy + dy;
if !grid.in_bounds(tx, ty) {
continue;
}
let dist = ((dx * dx + dy * dy) as f32).sqrt();
if dist > radius as f32 {
continue;
}
if !line_of_sight(grid, wx, wy, tx, ty) {
continue;
}
let t = 1.0 - dist / radius as f32;
let atten = t * t;
let cur = grid.get_light(tx, ty);
let nr = (cur[0] as f32 + color[0] as f32 * atten).min(255.0) as u8;
let ng = (cur[1] as f32 + color[1] as f32 * atten).min(255.0) as u8;
let nb = (cur[2] as f32 + color[2] as f32 * atten).min(255.0) as u8;
grid.set_light(tx, ty, [nr, ng, nb]);
}
}
}
}
}
}
}
}
fn material_light(mat: MaterialId) -> Option<(u32, [u8; 3])> {
match mat {
MaterialId::Lava => Some((25, [255, 120, 30])),
MaterialId::Fire => Some((15, [255, 180, 60])),
_ => None,
}
}
fn line_of_sight(grid: &ChunkedGrid, x0: i32, y0: i32, x1: i32, y1: i32) -> bool {
let dx = (x1 - x0).abs();
let dy = (y1 - y0).abs();
let sx = if x0 < x1 { 1 } else { -1 };
let sy = if y0 < y1 { 1 } else { -1 };
let mut err = dx - dy;
let mut cx = x0;
let mut cy = y0;
loop {
if cx == x1 && cy == y1 {
return true;
}
if cx != x0 || cy != y0 {
if grid.get(cx, cy).is_solid() {
return false;
}
}
let e2 = 2 * err;
if e2 > -dy {
err -= dy;
cx += sx;
}
if e2 < dx {
err += dx;
cy += sy;
}
}
}
const NEIGHBORS4: [(i32, i32); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)];
+78 -2
View File
@@ -4,6 +4,11 @@ pub const CHUNK_SIZE: usize = 64;
pub struct Chunk {
pub cells: Vec<Cell>,
pub temps: Vec<f32>,
pub pressure: Vec<u8>,
pub gas_type: Vec<u8>,
pub gas_density: Vec<u8>,
pub light: Vec<[u8; 3]>,
pub active: bool,
pub modified: bool,
pub was_modified: bool,
@@ -11,11 +16,18 @@ pub struct Chunk {
pub dirty: Option<(i32, i32, i32, i32)>,
}
const CHUNK_AREA: usize = CHUNK_SIZE * CHUNK_SIZE;
const ATMOSPHERIC_PRESSURE: u8 = 128;
impl Chunk {
pub fn new() -> Self {
let size = CHUNK_SIZE * CHUNK_SIZE;
Self {
cells: vec![Cell::empty(); size],
cells: vec![Cell::empty(); CHUNK_AREA],
temps: vec![20.0; CHUNK_AREA],
pressure: vec![ATMOSPHERIC_PRESSURE; CHUNK_AREA],
gas_type: vec![0; CHUNK_AREA],
gas_density: vec![0; CHUNK_AREA],
light: vec![[0, 0, 0]; CHUNK_AREA],
active: false,
modified: false,
was_modified: false,
@@ -62,10 +74,74 @@ impl Chunk {
pub fn set_material(&mut self, x: i32, y: i32, mat: MaterialId) {
if Self::in_bounds(x, y) {
self.cells[Self::idx(x, y)] = Cell::new(mat);
self.temps[Self::idx(x, y)] = crate::world::cell::default_temp(mat);
self.modified = true;
}
}
#[inline]
pub fn get_temp(&self, x: i32, y: i32) -> f32 {
if !Self::in_bounds(x, y) {
return 20.0;
}
self.temps[Self::idx(x, y)]
}
#[inline]
pub fn set_temp(&mut self, x: i32, y: i32, t: f32) {
if Self::in_bounds(x, y) {
self.temps[Self::idx(x, y)] = t;
}
}
#[inline]
pub fn get_pressure(&self, x: i32, y: i32) -> u8 {
if !Self::in_bounds(x, y) {
return 128;
}
self.pressure[Self::idx(x, y)]
}
#[inline]
pub fn set_pressure(&mut self, x: i32, y: i32, p: u8) {
if Self::in_bounds(x, y) {
self.pressure[Self::idx(x, y)] = p;
}
}
#[inline]
pub fn get_gas(&self, x: i32, y: i32) -> (u8, u8) {
if !Self::in_bounds(x, y) {
return (0, 0);
}
let i = Self::idx(x, y);
(self.gas_type[i], self.gas_density[i])
}
#[inline]
pub fn set_gas(&mut self, x: i32, y: i32, gas_type: u8, density: u8) {
if Self::in_bounds(x, y) {
let i = Self::idx(x, y);
self.gas_type[i] = gas_type;
self.gas_density[i] = density;
}
}
#[inline]
pub fn get_light(&self, x: i32, y: i32) -> [u8; 3] {
if !Self::in_bounds(x, y) {
return [0, 0, 0];
}
self.light[Self::idx(x, y)]
}
#[inline]
pub fn set_light(&mut self, x: i32, y: i32, rgb: [u8; 3]) {
if Self::in_bounds(x, y) {
self.light[Self::idx(x, y)] = rgb;
}
}
pub fn is_empty(&self) -> bool {
self.cells.iter().all(|c| c.is_empty())
}
+266 -33
View File
@@ -1,4 +1,4 @@
use crate::world::cell::{Cell, MaterialId};
use crate::world::cell::{default_temp, Cell, MaterialId};
use crate::world::chunk::{Chunk, CHUNK_SIZE};
use std::collections::HashMap;
use std::io;
@@ -193,20 +193,171 @@ impl ChunkedGrid {
return;
}
let (cx, cy, lx, ly) = self.chunk_at(x, y);
let t = default_temp(mat);
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get_mut(idx) {
chunk.set_material(lx, ly, mat);
chunk.set_temp(lx, ly, t);
chunk.mark_dirty(lx, ly);
}
}
} else {
let chunk = self.get_or_create_chunk(cx, cy);
chunk.set_material(lx, ly, mat);
chunk.set_temp(lx, ly, t);
chunk.mark_dirty(lx, ly);
}
}
#[inline]
pub fn get_temp(&self, x: i32, y: i32) -> f32 {
if !self.in_bounds(x, y) {
return 20.0;
}
let (cx, cy, lx, ly) = self.chunk_at(x, y);
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get(idx) {
return chunk.get_temp(lx, ly);
}
}
} else if let Some(chunk) = self.chunks.get(&(cx as i64, cy as i64)) {
return chunk.get_temp(lx, ly);
}
20.0
}
#[inline]
pub fn set_temp(&mut self, x: i32, y: i32, t: f32) {
if !self.in_bounds(x, y) {
return;
}
let (cx, cy, lx, ly) = self.chunk_at(x, y);
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get_mut(idx) {
chunk.set_temp(lx, ly, t);
}
}
} else {
let chunk = self.get_or_create_chunk(cx, cy);
chunk.set_temp(lx, ly, t);
}
}
#[inline]
pub fn get_pressure(&self, x: i32, y: i32) -> u8 {
if !self.in_bounds(x, y) {
return 128;
}
let (cx, cy, lx, ly) = self.chunk_at(x, y);
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get(idx) {
return chunk.get_pressure(lx, ly);
}
}
} else if let Some(chunk) = self.chunks.get(&(cx as i64, cy as i64)) {
return chunk.get_pressure(lx, ly);
}
128
}
#[inline]
pub fn set_pressure(&mut self, x: i32, y: i32, p: u8) {
if !self.in_bounds(x, y) {
return;
}
let (cx, cy, lx, ly) = self.chunk_at(x, y);
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get_mut(idx) {
chunk.set_pressure(lx, ly, p);
chunk.mark_dirty(lx, ly);
}
}
} else {
let chunk = self.get_or_create_chunk(cx, cy);
chunk.set_pressure(lx, ly, p);
chunk.mark_dirty(lx, ly);
}
}
#[inline]
pub fn get_gas(&self, x: i32, y: i32) -> (u8, u8) {
if !self.in_bounds(x, y) {
return (0, 0);
}
let (cx, cy, lx, ly) = self.chunk_at(x, y);
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get(idx) {
return chunk.get_gas(lx, ly);
}
}
} else if let Some(chunk) = self.chunks.get(&(cx as i64, cy as i64)) {
return chunk.get_gas(lx, ly);
}
(0, 0)
}
#[inline]
pub fn set_gas(&mut self, x: i32, y: i32, gas_type: u8, density: u8) {
if !self.in_bounds(x, y) {
return;
}
let (cx, cy, lx, ly) = self.chunk_at(x, y);
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get_mut(idx) {
chunk.set_gas(lx, ly, gas_type, density);
chunk.mark_dirty(lx, ly);
}
}
} else {
let chunk = self.get_or_create_chunk(cx, cy);
chunk.set_gas(lx, ly, gas_type, density);
chunk.mark_dirty(lx, ly);
}
}
#[inline]
pub fn get_light(&self, x: i32, y: i32) -> [u8; 3] {
if !self.in_bounds(x, y) {
return [0, 0, 0];
}
let (cx, cy, lx, ly) = self.chunk_at(x, y);
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get(idx) {
return chunk.get_light(lx, ly);
}
}
} else if let Some(chunk) = self.chunks.get(&(cx as i64, cy as i64)) {
return chunk.get_light(lx, ly);
}
[0, 0, 0]
}
#[inline]
pub fn set_light(&mut self, x: i32, y: i32, rgb: [u8; 3]) {
if !self.in_bounds(x, y) {
return;
}
let (cx, cy, lx, ly) = self.chunk_at(x, y);
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get_mut(idx) {
chunk.set_light(lx, ly, rgb);
}
}
} else {
let chunk = self.get_or_create_chunk(cx, cy);
chunk.set_light(lx, ly, rgb);
}
}
#[inline]
pub fn mark_dirty(&mut self, x: i32, y: i32) {
if !self.in_bounds(x, y) {
@@ -284,6 +435,12 @@ impl ChunkedGrid {
}
let (cx1, cy1, lx1, ly1) = self.chunk_at(x1, y1);
let (cx2, cy2, lx2, ly2) = self.chunk_at(x2, y2);
let t1 = self.get_temp(x1, y1);
let t2 = self.get_temp(x2, y2);
let p1 = self.get_pressure(x1, y1);
let p2 = self.get_pressure(x2, y2);
let g1 = self.get_gas(x1, y1);
let g2 = self.get_gas(x2, y2);
if self.is_bounded() {
let idx1 = self.chunk_index(cx1, cy1);
let idx2 = self.chunk_index(cx2, cy2);
@@ -292,9 +449,11 @@ impl ChunkedGrid {
if let Some(chunk) = self.chunks_vec.get_mut(i1) {
let ci1 = (ly1 as usize) * self.chunk_size + (lx1 as usize);
let ci2 = (ly2 as usize) * self.chunk_size + (lx2 as usize);
let tmp = chunk.cells[ci1];
chunk.cells[ci1] = chunk.cells[ci2];
chunk.cells[ci2] = tmp;
chunk.cells.swap(ci1, ci2);
chunk.temps.swap(ci1, ci2);
chunk.pressure.swap(ci1, ci2);
chunk.gas_type.swap(ci1, ci2);
chunk.gas_density.swap(ci1, ci2);
chunk.cells[ci2].updated_this_tick = true;
chunk.modified = true;
chunk.mark_dirty(lx1, ly1);
@@ -307,6 +466,10 @@ impl ChunkedGrid {
if let Some(chunk) = self.chunks_vec.get_mut(i1) {
let ci = (ly1 as usize) * self.chunk_size + (lx1 as usize);
chunk.cells[ci] = c2;
chunk.temps[ci] = t2;
chunk.pressure[ci] = p2;
chunk.gas_type[ci] = g2.0;
chunk.gas_density[ci] = g2.1;
chunk.cells[ci].updated_this_tick = true;
chunk.modified = true;
chunk.mark_dirty(lx1, ly1);
@@ -314,6 +477,10 @@ impl ChunkedGrid {
if let Some(chunk) = self.chunks_vec.get_mut(i2) {
let ci = (ly2 as usize) * self.chunk_size + (lx2 as usize);
chunk.cells[ci] = c1;
chunk.temps[ci] = t1;
chunk.pressure[ci] = p1;
chunk.gas_type[ci] = g1.0;
chunk.gas_density[ci] = g1.1;
chunk.modified = true;
chunk.mark_dirty(lx2, ly2);
}
@@ -325,9 +492,11 @@ impl ChunkedGrid {
let chunk = self.get_or_create_chunk(cx1, cy1);
let i1 = (ly1 as usize) * cs + (lx1 as usize);
let i2 = (ly2 as usize) * cs + (lx2 as usize);
let tmp = chunk.cells[i1];
chunk.cells[i1] = chunk.cells[i2];
chunk.cells[i2] = tmp;
chunk.cells.swap(i1, i2);
chunk.temps.swap(i1, i2);
chunk.pressure.swap(i1, i2);
chunk.gas_type.swap(i1, i2);
chunk.gas_density.swap(i1, i2);
chunk.cells[i2].updated_this_tick = true;
chunk.modified = true;
chunk.mark_dirty(lx1, ly1);
@@ -336,7 +505,13 @@ impl ChunkedGrid {
let c1 = self.get(x1, y1);
let c2 = self.get(x2, y2);
self.set(x1, y1, c2);
self.set_temp(x1, y1, t2);
self.set_pressure(x1, y1, p2);
self.set_gas(x1, y1, g2.0, g2.1);
self.set(x2, y2, c1);
self.set_temp(x2, y2, t1);
self.set_pressure(x2, y2, p1);
self.set_gas(x2, y2, g1.0, g1.1);
let cs = self.chunk_size;
let chunk = self.get_or_create_chunk(cx1, cy1);
let i = (ly1 as usize) * cs + (lx1 as usize);
@@ -653,17 +828,25 @@ impl ChunkedGrid {
None => return Ok(()),
}
};
let (x0, y0, x1, y1) = self.chunk_bounds(cx, cy);
let w = (x1 - x0) as usize;
let h = (y1 - y0) as usize;
let mut bytes = Vec::with_capacity(w * h * 12);
let cs = self.chunk_size as i32;
for y in y0..y1 {
for x in x0..x1 {
let lx = x - cx * cs;
let ly = y - cy * cs;
bytes.extend_from_slice(&chunk.get(lx, ly).to_bytes());
}
let area = self.chunk_size * self.chunk_size;
let mut bytes = Vec::with_capacity(5 + area * 8 + area * 4 + area * 2 + area + area * 3);
bytes.extend_from_slice(b"VWM1");
bytes.push(1);
for c in &chunk.cells {
bytes.extend_from_slice(&c.to_bytes());
}
for t in &chunk.temps {
bytes.extend_from_slice(&t.to_le_bytes());
}
for i in 0..area {
bytes.push(chunk.gas_type[i]);
bytes.push(chunk.gas_density[i]);
}
for &p in &chunk.pressure {
bytes.push(p);
}
for l in &chunk.light {
bytes.extend_from_slice(&l[..]);
}
let dir = Path::new(path);
if let Some(parent) = dir.parent() {
@@ -678,23 +861,72 @@ impl ChunkedGrid {
fn load_chunk_from_path(&mut self, path: &Path, cx: i32, cy: i32) -> io::Result<()> {
let data = std::fs::read(path)?;
let (x0, y0, x1, y1) = self.chunk_bounds(cx, cy);
let w = (x1 - x0) as usize;
let h = (y1 - y0) as usize;
let expected = w * h * 12;
if data.len() != expected {
return Err(io::Error::other("chunk file size mismatch"));
}
let cs = self.chunk_size as i32;
let cs = self.chunk_size;
let area = cs * cs;
let bounds = self.chunk_bounds(cx, cy);
let chunk = self.get_or_create_chunk(cx, cy);
let mut i = 0usize;
for y in y0..y1 {
for x in x0..x1 {
let lx = x - cx * cs;
let ly = y - cy * cs;
let cell = Cell::from_bytes(&data[i * 12..(i + 1) * 12]);
if data.len() >= 5 && &data[0..4] == b"VWM1" {
let mut off = 5;
for i in 0..area {
if off + 8 > data.len() {
break;
}
let cell = Cell::from_bytes(&data[off..off + 8]);
let lx = (i % cs) as i32;
let ly = (i / cs) as i32;
chunk.set(lx, ly, cell);
i += 1;
off += 8;
}
for i in 0..area {
if off + 4 > data.len() {
break;
}
chunk.temps[i] =
f32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]);
off += 4;
}
for i in 0..area {
if off + 2 > data.len() {
break;
}
chunk.gas_type[i] = data[off];
chunk.gas_density[i] = data[off + 1];
off += 2;
}
for i in 0..area {
if off >= data.len() {
break;
}
chunk.pressure[i] = data[off];
off += 1;
}
for i in 0..area {
if off + 3 > data.len() {
break;
}
chunk.light[i] = [data[off], data[off + 1], data[off + 2]];
off += 3;
}
} else {
let (x0, y0, x1, y1) = bounds;
let w = (x1 - x0) as usize;
let h = (y1 - y0) as usize;
let expected = w * h * 12;
if data.len() != expected {
return Err(io::Error::other("chunk file size mismatch"));
}
let mut i = 0usize;
for y in y0..y1 {
for x in x0..x1 {
let lx = x - cx * cs as i32;
let ly = y - cy * cs as i32;
let cell = Cell::from_bytes(&data[i * 12..(i + 1) * 12]);
chunk.set(lx, ly, cell);
if cell.material != MaterialId::Empty {
chunk.set_temp(lx, ly, default_temp(cell.material));
}
i += 1;
}
}
}
chunk.active = true;
@@ -716,6 +948,7 @@ impl ChunkedGrid {
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some(rest) = name.strip_prefix("chunk_") {
let rest = rest.strip_suffix(".bin").unwrap_or(rest);
let parts: Vec<&str> = rest.split('_').collect();
if parts.len() == 2 {
if let (Ok(cx), Ok(cy)) = (parts[0].parse::<i32>(), parts[1].parse::<i32>()) {
+3 -3
View File
@@ -321,7 +321,7 @@ impl Grid {
let (x0, y0, x1, y1) = self.chunk_bounds(cx as usize, cy as usize);
let w = (x1 - x0) as usize;
let h = (y1 - y0) as usize;
let mut bytes = Vec::with_capacity(w * h * 12);
let mut bytes = Vec::with_capacity(w * h * 8);
for y in y0..y1 {
for x in x0..x1 {
bytes.extend_from_slice(&self.get(x, y).to_bytes());
@@ -342,14 +342,14 @@ impl Grid {
let (x0, y0, x1, y1) = self.chunk_bounds(cx as usize, cy as usize);
let w = (x1 - x0) as usize;
let h = (y1 - y0) as usize;
let expected = w * h * 12;
let expected = w * h * 8;
if data.len() != expected {
return Err(io::Error::other("chunk file size mismatch"));
}
let mut i = 0usize;
for y in y0..y1 {
for x in x0..x1 {
let cell = Cell::from_bytes(&data[i * 12..(i + 1) * 12]);
let cell = Cell::from_bytes(&data[i * 8..(i + 1) * 8]);
self.set(x, y, cell);
i += 1;
}
+1 -2
View File
@@ -1,5 +1,5 @@
use verbatim::world::cell::{Cell, MaterialId};
use verbatim::world::chunk::{CHUNK_SIZE, Chunk, world_to_chunk};
use verbatim::world::chunk::{world_to_chunk, Chunk, CHUNK_SIZE};
use verbatim::world::grid::Grid;
#[test]
@@ -48,7 +48,6 @@ fn cell_serialization_roundtrip() {
assert_eq!(c.fg, c2.fg);
assert_eq!(c.bg, c2.bg);
assert_eq!(c.variant, c2.variant);
assert!((c.temp - c2.temp).abs() < 0.001);
}
#[test]
+281
View File
@@ -0,0 +1,281 @@
use verbatim::ai::action::AiAction;
use verbatim::ai::session::GameSession;
use verbatim::world::cell::MaterialId;
use verbatim::world::chunk::CHUNK_SIZE;
use verbatim::world::chunked_grid::ChunkedGrid;
fn setup() -> GameSession {
let mut s = GameSession::new();
s.init_empty();
s
}
#[test]
fn fire_produces_co2_gas() {
let mut s = setup();
s.perform_action(&AiAction::SetCell {
x: 100,
y: 100,
material: "wood".into(),
});
s.perform_action(&AiAction::SetCell {
x: 99,
y: 100,
material: "fire".into(),
});
s.step(2);
let mut found_co2 = false;
for dy in -5..=0 {
let (gt, gd) = s.game.grid.get_gas(99, 100 + dy);
if gt == 3 && gd > 0 {
found_co2 = true;
break;
}
}
assert!(found_co2, "fire should produce CO2 (type 3) near fire");
}
#[test]
fn heat_transfer_diffuses_through_solid() {
let mut grid = ChunkedGrid::with_size(250, 250);
grid.set_material(100, 100, MaterialId::Stone);
grid.set_material(101, 100, MaterialId::Stone);
grid.set_temp(100, 100, 500.0);
grid.mark_dirty(100, 100);
let mut ca = verbatim::world::cellular::CellularAutomaton::new();
for _ in 0..100 {
ca.step(&mut grid);
}
let neighbor_temp = grid.get_temp(101, 100);
assert!(
neighbor_temp > 25.0,
"heat should diffuse to adjacent stone, got {:.1}",
neighbor_temp
);
}
#[test]
fn lava_heats_adjacent_water_to_steam() {
let mut s = setup();
s.perform_action(&AiAction::SetCell {
x: 100,
y: 100,
material: "lava".into(),
});
s.perform_action(&AiAction::SetCell {
x: 101,
y: 100,
material: "water".into(),
});
s.step(5);
let cell = s.get_cell(101, 100);
assert!(
cell.material == "steam" || cell.material == "stone" || cell.material == "empty",
"water should become steam, lava stone, or steam rises away, got '{}'",
cell.material
);
}
#[test]
fn gas_layer_exists_and_defaults_to_air() {
let s = setup();
let (gt, gd) = s.game.grid.get_gas(100, 100);
assert_eq!(gt, 0, "default gas type should be air (0)");
assert_eq!(gd, 0, "default gas density should be 0");
}
#[test]
fn gas_rises_upward() {
let mut s = setup();
s.game.grid.set_gas(100, 105, 1, 200);
s.step(3);
let mut found_above = false;
for y in 100..=104 {
let (_, gd) = s.game.grid.get_gas(100, y);
if gd > 0 {
found_above = true;
break;
}
}
assert!(found_above, "smoke gas should rise upward");
}
#[test]
fn pressure_layer_defaults_to_atmospheric() {
let s = setup();
let p = s.game.grid.get_pressure(100, 100);
assert_eq!(p, 128, "default pressure should be 128 (atmospheric)");
}
#[test]
fn pressure_equalizes_between_neighbors() {
let mut s = setup();
s.game.grid.set_pressure(100, 100, 200);
s.game.grid.set_pressure(101, 100, 128);
s.step(30);
let p1 = s.game.grid.get_pressure(100, 100);
let p2 = s.game.grid.get_pressure(101, 100);
let diff = (p1 as i32 - p2 as i32).abs();
assert!(
diff < 30,
"pressure should equalize, got p1={} p2={} diff={}",
p1,
p2,
diff
);
}
#[test]
fn light_layer_defaults_to_dark() {
let s = setup();
let l = s.game.grid.get_light(100, 100);
assert_eq!(l, [0, 0, 0], "default light should be dark");
}
#[test]
fn lava_emits_world_space_light() {
let mut s = setup();
s.perform_action(&AiAction::SetCell {
x: 100,
y: 100,
material: "lava".into(),
});
s.step(15);
let l = s.game.grid.get_light(100, 100);
assert!(
l[0] > 0 || l[1] > 0 || l[2] > 0,
"lava should emit world-space light, got {:?}",
l
);
}
#[test]
fn light_blocked_by_solid_walls() {
let mut s = setup();
s.perform_action(&AiAction::SetCell {
x: 100,
y: 100,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 100,
y: 101,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 100,
y: 102,
material: "lava".into(),
});
for x in 101..=104 {
for y in 96..=108 {
s.perform_action(&AiAction::SetCell {
x,
y,
material: "stone".into(),
});
}
}
s.step(15);
let l_behind = s.game.grid.get_light(105, 102);
assert!(
l_behind[0] < 30,
"light should be blocked by thick stone wall, got {:?}",
l_behind
);
}
#[test]
fn chunk_save_load_roundtrip_preserves_all_layers() {
let mut grid = ChunkedGrid::with_size(250, 250);
grid.set_material(70, 70, MaterialId::Lava);
grid.set_temp(70, 70, 1500.0);
grid.set_gas(70, 70, 3, 100);
grid.set_pressure(70, 70, 200);
grid.set_light(70, 70, [255, 128, 64]);
let path = "/tmp/verbatim_multilayer_chunk_1_1.bin";
let _ = std::fs::remove_file(path);
grid.save_chunk(path, 1, 1).unwrap();
let mut grid2 = ChunkedGrid::with_size(250, 250);
grid2.load_chunk(path, 1, 1).unwrap();
assert_eq!(grid2.get(70, 70).material, MaterialId::Lava);
assert!(
(grid2.get_temp(70, 70) - 1500.0).abs() < 1.0,
"temp should roundtrip"
);
assert_eq!(grid2.get_gas(70, 70), (3, 100), "gas should roundtrip");
assert_eq!(grid2.get_pressure(70, 70), 200, "pressure should roundtrip");
assert_eq!(
grid2.get_light(70, 70),
[255, 128, 64],
"light should roundtrip"
);
let _ = std::fs::remove_file(path);
}
#[test]
fn steam_gas_condenses_to_water_when_cold() {
let mut s = setup();
s.game.grid.set_gas(100, 100, 4, 200);
s.game.grid.set_temp(100, 100, 50.0);
s.game.grid.mark_dirty(100, 100);
s.step(1);
let cell = s.get_cell(100, 100);
assert_eq!(
cell.material, "water",
"steam gas at 50C should condense to water, got '{}'",
cell.material
);
}
#[test]
fn poison_gas_damages_entity() {
let mut s = setup();
let (px, py) = s.game.player.center(&s.game.entities);
let health_before = s
.game
.player
.entity(&s.game.entities)
.map(|e| e.health)
.unwrap_or(0.0);
for _ in 0..10 {
s.game.grid.set_gas(px as i32, py as i32, 2, 200);
s.step(1);
}
let health_after = s
.game
.player
.entity(&s.game.entities)
.map(|e| e.health)
.unwrap_or(0.0);
assert!(
health_after < health_before,
"poison gas should damage entity: before={} after={}",
health_before,
health_after
);
}
#[test]
fn temperature_persists_across_chunk_boundary() {
let mut grid = ChunkedGrid::with_size(256, 256);
let boundary = CHUNK_SIZE as i32;
grid.set_material(boundary - 1, 0, MaterialId::Stone);
grid.set_material(boundary, 0, MaterialId::Stone);
grid.set_temp(boundary - 1, 0, 500.0);
grid.set_temp(boundary, 0, 20.0);
let mut ca = verbatim::world::cellular::CellularAutomaton::new();
for _ in 0..100 {
ca.step(&mut grid);
}
let t_right = grid.get_temp(boundary, 0);
assert!(
t_right > 30.0,
"heat should cross chunk boundary, got {:.1}",
t_right
);
}