diff --git a/AGENTS.md b/AGENTS.md index 58c943c..d4898e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 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` — temperature per cell (16 KB/chunk) +- `pressure: Vec` — pressure per cell, 128 = atmospheric (4 KB/chunk) +- `gas_type: Vec` — gas type: 0=air, 1=smoke, 2=poison, 3=CO2, 4=steam (4 KB/chunk) +- `gas_density: Vec` — 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). diff --git a/MULTILAYER_PLAN.md b/MULTILAYER_PLAN.md new file mode 100644 index 0000000..1b9b5e1 --- /dev/null +++ b/MULTILAYER_PLAN.md @@ -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` 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 ~36 KB (material + color, no temp) + temps: Vec 16 KB (temperature) + pressure: Vec 4 KB (0-255, 128 = atmospheric) + gas_type: Vec 4 KB (0=air, 1=smoke, 2=poison, 3=CO2, 4=steam) + gas_density:Vec 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, + pub temps: Vec, + pub pressure: Vec, + pub gas_type: Vec, + pub gas_density: Vec, + 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_/chunk__.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 | diff --git a/benchmark_results.json b/benchmark_results.json index 6e983b5..f31d96f 100644 --- a/benchmark_results.json +++ b/benchmark_results.json @@ -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 } } \ No newline at end of file diff --git a/src/ai/protocol.rs b/src/ai/protocol.rs index 1b5f8fc..a080a62 100644 --- a/src/ai/protocol.rs +++ b/src/ai/protocol.rs @@ -289,6 +289,8 @@ fn handle_command(cmd: Command, session: &mut Option) -> 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", diff --git a/src/ai/scenario.rs b/src/ai/scenario.rs index 190d3b8..fb98c76 100644 --- a/src/ai/scenario.rs +++ b/src/ai/scenario.rs @@ -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 ), } } diff --git a/src/ai/session.rs b/src/ai/session.rs index 432137c..79dc32f 100644 --- a/src/ai/session.rs +++ b/src/ai/session.rs @@ -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 { 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)); } diff --git a/src/ai/spectrum.rs b/src/ai/spectrum.rs index 3c8d81c..3f26e47 100644 --- a/src/ai/spectrum.rs +++ b/src/ai/spectrum.rs @@ -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 +} diff --git a/src/ai/state.rs b/src/ai/state.rs index b77a807..27b0844 100644 --- a/src/ai/state.rs +++ b/src/ai/state.rs @@ -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, diff --git a/src/game.rs b/src/game.rs index 1f28c60..43b966b 100644 --- a/src/game.rs +++ b/src/game.rs @@ -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 { diff --git a/src/physics/projectile.rs b/src/physics/projectile.rs index 92e021b..4d8764d 100644 --- a/src/physics/projectile.rs +++ b/src/physics/projectile.rs @@ -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); } } } diff --git a/src/world/cell.rs b/src/world/cell.rs index 2fb83a4..472d523 100644 --- a/src/world/cell.rs +++ b/src/world/cell.rs @@ -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, diff --git a/src/world/cellular.rs b/src/world/cellular.rs index 7a5281d..37c4d7a 100644 --- a/src/world/cellular.rs +++ b/src/world/cellular.rs @@ -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, + temps_buf: Vec, + 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)]; diff --git a/src/world/chunk.rs b/src/world/chunk.rs index 990706c..da9766b 100644 --- a/src/world/chunk.rs +++ b/src/world/chunk.rs @@ -4,6 +4,11 @@ pub const CHUNK_SIZE: usize = 64; pub struct Chunk { pub cells: Vec, + pub temps: Vec, + pub pressure: Vec, + pub gas_type: Vec, + pub gas_density: Vec, + 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()) } diff --git a/src/world/chunked_grid.rs b/src/world/chunked_grid.rs index e5920e6..a5a5d5e 100644 --- a/src/world/chunked_grid.rs +++ b/src/world/chunked_grid.rs @@ -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::(), parts[1].parse::()) { diff --git a/src/world/grid.rs b/src/world/grid.rs index ef19150..f119b6a 100644 --- a/src/world/grid.rs +++ b/src/world/grid.rs @@ -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; } diff --git a/tests/chunks.rs b/tests/chunks.rs index 0b3ae9e..148a0a3 100644 --- a/tests/chunks.rs +++ b/tests/chunks.rs @@ -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] diff --git a/tests/multilayer.rs b/tests/multilayer.rs new file mode 100644 index 0000000..406f9d5 --- /dev/null +++ b/tests/multilayer.rs @@ -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 + ); +}