Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d54d987ac | ||
|
|
4a89612081 | ||
|
|
69e3a6578e | ||
|
|
43085772cd | ||
|
|
d6bd77c133 | ||
|
|
f09c44f7a0 | ||
|
|
22de8bc42e | ||
|
|
423000e576 | ||
|
|
5ca8b7e917 | ||
|
|
01a350aebd | ||
|
|
93a6bcb35c | ||
|
|
3e53d709f7 | ||
|
|
17aa9ab502 | ||
|
|
b52798c634 | ||
|
|
a16645d2d8 | ||
|
|
f35f56b970 | ||
|
|
4c9a074bb0 | ||
|
|
4c1e3987ec | ||
|
|
9ce45cff4a | ||
|
|
24b6d0320f | ||
|
|
a5436897ed |
@@ -3,3 +3,4 @@ Cargo.lock
|
||||
graphify-out/
|
||||
headless_dump.txt
|
||||
headless_dump.png
|
||||
/cache
|
||||
|
||||
@@ -11,7 +11,9 @@ cargo run -- --mode pipe # JSON stdin/stdout for AI agents
|
||||
cargo run -- --mode test # run all JSON scenarios
|
||||
cargo run -- --mode headless --headless-ticks 60 # dump to headless_dump.txt
|
||||
cargo run -- --mode capture --headless-ticks 60 # render graphics-like PNG to capture.png
|
||||
cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-renderer graphics # FPS benchmark
|
||||
cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-renderer graphics --benchmark-biome surface # FPS benchmark (surface)
|
||||
cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-renderer graphics --benchmark-biome caves # FPS benchmark (caves)
|
||||
cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-renderer graphics --benchmark-biome dungeon # FPS benchmark (dungeon)
|
||||
cargo run --release -- --mode tape --headless-ticks 300 --tape-interval 10 --tape-output tape.txt --tape-json tape.json # multi-spectrum recording
|
||||
```
|
||||
|
||||
@@ -25,10 +27,12 @@ Rust edition 2024, requires rustc >= 1.96. Vulkan 1.2+ required for `ascii`/`gra
|
||||
- **AI observation** uses multi-spectrum ASCII layers via pipe protocol:
|
||||
- `materials` — material type per cell
|
||||
- `temperature` — heat levels encoded as characters
|
||||
- `light` — light intensity per cell
|
||||
- `light` — light intensity per cell (world-space fallback)
|
||||
- `entities` — entity positions and types only
|
||||
- `density` — material density visualization
|
||||
- `velocity` — entity movement speed and CA activity
|
||||
- `gas` — gas type + density per cell (NEW)
|
||||
- `pressure` — pressure levels per cell (NEW)
|
||||
|
||||
## Tape System
|
||||
|
||||
@@ -42,7 +46,7 @@ Each tape frame contains:
|
||||
- Tick number, depth, kills, score
|
||||
- Camera position
|
||||
- Player HP, position, entity count
|
||||
- All 6 spectrum layers as ASCII text
|
||||
- All 8 spectrum layers as ASCII text
|
||||
|
||||
Pipe protocol spectrum commands:
|
||||
- `{"cmd":"get_spectrum","spectrum":"materials","w":80,"h":25}` — single spectrum
|
||||
@@ -51,9 +55,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
|
||||
```
|
||||
|
||||
@@ -84,6 +88,7 @@ GPU (`--mode ascii` / `--mode graphics`):
|
||||
- `r` — drop first inventory item
|
||||
- `1`–`0` / `x` — paint material brush
|
||||
- `y` / `u` / `i` / `o` — move camera offset
|
||||
- `m` — toggle audio
|
||||
- `q` / `esc` — quit
|
||||
|
||||
## Shaders
|
||||
@@ -101,11 +106,31 @@ SPV files are committed. `include_bytes!` embeds them at compile time.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Source of truth**: `Grid` (250x250) of `Cell` structs. Each `Cell` stores `material`, `temp`, `fg`/`bg` color, `variant` inline. No double buffer. Grid is divided into 64x64 `Chunk`s with active flags and per-chunk persistence.
|
||||
**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 Noita-scale worlds.
|
||||
|
||||
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. All loaded chunks are activated every tick — uniform simulation rate across the world.
|
||||
|
||||
**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.
|
||||
|
||||
**Audio** (`src/audio/mod.rs`): `AudioEngine` using `rodio` for WAV playback. 15 procedurally generated sounds (jump, shoot, hit, explosion, death, pickup, descend, powerup, step, lava_bubble, acid_sizzle, water_splash, fire_crackle, ui_click, goblin_growl) embedded via `include_bytes!`. Each sound creates its own `Sink` + `detach()` for overlapping playback. Ambient sounds scan 15-cell radius around player. `M` key toggles audio. Gracefully degrades when no audio device available.
|
||||
|
||||
**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).
|
||||
|
||||
**Three render modes**: `terminal` (crossterm ANSI), `ascii` (Vulkan glyph atlas + instanced), `graphics` (Vulkan colored quads). All three read the same `Grid` + `EntityManager` and an optional `LightGrid` overlay.
|
||||
**Three render modes**: `terminal` (crossterm ANSI), `ascii` (Vulkan glyph atlas + instanced), `graphics` (Vulkan colored quads). All three read the same `ChunkedGrid` + `EntityManager` and an optional `LightGrid` overlay. GPU renderers upload a viewport-relative grid buffer and index it in shaders with `cam_pos`.
|
||||
|
||||
**Lighting pass**: `render::lighting::LightGrid` is computed each frame on the CPU. Light sources are emitted by `Lava` and `Fire` cells. Light attenuates with distance and is blocked by solid cells (ray-cast line-of-sight). The ambient light level is configurable per mode; the default ambient is `[100, 100, 120]`. The `Renderer` trait and all renderers accept `Option<&LightGrid>`; `UiLayer` elements are drawn unlit on top.
|
||||
|
||||
@@ -128,9 +153,20 @@ SPV files are committed. `include_bytes!` embeds them at compile time.
|
||||
|
||||
**Items & inventory**: `Item` (weapon, armor, consumable), `ItemManager`, and inventory/equipment slots on `Player`. Items spawn in the world and are picked up on contact. `Game::use_item(0)` equips weapons/armor or consumes potions; `Game::drop_item(0)` returns an item to the world. Equipped weapon adds damage bonus to projectiles; equipped armor reduces enemy contact damage.
|
||||
|
||||
**UI layer**: `ui::UiLayer` overlays non-destructive UI on all renderers. Health bars above entities, bottom-line HUD, scrolling message log, floating damage numbers, screen-edge indicators, death screen, entity labels, status icons, minimap, and a character panel. UI is drawn unlit on top of the world. In GPU (`ascii`/`graphics`) and capture modes the UI is rendered as a separate 2x2 pixel-per-cell pass; terminal mode renders UI at full character size.
|
||||
**UI layer**: `ui::UiLayer` overlays non-destructive UI on all renderers. Uses a flat `Vec<Option<UiCell>>` array (not HashMap) for O(1) cell access. `font_scale` auto-computed from screen height — text, panels, health bars, and minimap scale proportionally. Health bars above entities, bottom-line HUD, scrolling message log, floating damage numbers, screen-edge indicators, death screen, entity labels, status icons, minimap (sampled with step_by(4)), and a character panel. UI is drawn unlit on top of the world. In GPU (`ascii`/`graphics`) and capture modes the UI is rendered as a separate 2x2 pixel-per-cell pass; terminal mode renders UI at full character size.
|
||||
|
||||
**Chunk system**: `Grid` is divided into 64x64 `Chunk`s. Each chunk tracks `active` and `modified`. `save_chunk(path, cx, cy)` and `load_chunk(path, cx, cy)` serialize chunk cells via 12-byte binary format. Cell serialization is handled by `Cell::to_bytes()` / `Cell::from_bytes()`.
|
||||
**Chunk system**: `ChunkedGrid` is divided into 64x64 `Chunk`s. Each chunk tracks `active`, `modified`, `was_modified`, `generated`, and `dirty` (an optional bounding rect of cells that need processing). `save_chunk(path, cx, cy)` and `load_chunk(path, cx, cy)` serialize chunk data via multi-section `VWM1` binary format. Cell serialization is handled by `Cell::to_bytes()` / `Cell::from_bytes()` (8 bytes/cell). `Chunk::generated` is set when a chunk is generated or loaded from cache, preventing accidental regeneration.
|
||||
|
||||
**Dirty rect optimization** (Noita-style): Each chunk maintains a `dirty: Option<(i32, i32, i32, i32)>` bounding rect of cells that need CA processing. When a cell changes via `set`, `set_material`, `cells_swap`, or `set_cell_index`, the chunk's dirty rect is expanded to include that cell ±1 (for neighbor influence). The CA step only iterates cells within dirty rects, skipping chunks with no dirty rect entirely. Liquids and gases (water, lava, acid, steam, fire, smoke) re-mark themselves dirty after processing so they continue to flow and react. Sand and other solids can sleep when at rest. All dirty rects are processed fully — no size limits — ensuring uniform simulation across chunks. `update_active_chunks` activates all loaded chunks every tick for consistent physics.
|
||||
|
||||
**World cache**: main game seeds are saved in `Game::seed` and written to `cache/worlds/<seed>/`. Each cached world stores per-chunk binary files plus a `meta.json` with player spawn and item placement. `Game::init_world()` loads the cache if it exists; otherwise it generates the spawn region and saves it. This makes Noita-scale worlds load instantly after the first visit.
|
||||
|
||||
**Vertical biome progression**: World Y (`cy`) selects biome per chunk:
|
||||
- `cy < 2` — surface (grass, dirt, stone, trees, pools, dunes)
|
||||
- `2 <= cy < 6` — caves (stone, CA-carved empty space, lava/water/acid pools)
|
||||
- `cy >= 6` — dungeon (BSP rooms, corridors, stone walls)
|
||||
|
||||
**Chunk streaming**: `Game::stream_chunks()` is called every `fixed_update`. It loads cached chunks (or generates new ones) in a 2-chunk radius around the player, saves modified chunks beyond that radius every 10 ticks (1 chunk at a time), and unloads distant chunks every 120 ticks. All loaded chunks are activated for uniform simulation. Streaming is active for infinite grids; test/AI 250×250 grids skip streaming.
|
||||
|
||||
**Vertical descent**: `MaterialId::Stairs` is a solid feature material. Player stands on stairs and presses `>` to descend. `Game::descend()` increments depth, resets the world, and respawns the player at the top. HUD shows current depth.
|
||||
|
||||
@@ -146,6 +182,12 @@ SPV files are committed. `include_bytes!` embeds them at compile time.
|
||||
- **Item pickup** — `Game::update_item_pickup()` scans items within 1.5 cells of the player and adds them to `player.inventory`.
|
||||
- **Stat-based health** — Entity max health derived from `base + toughness * 5 + level * 10`. `recalc_max_health()` called on `add_xp` level-up.
|
||||
- **Status effects** — `update_status_effects()` applies damage for poison/bleeding/fire and cancels movement for frozen; effects expire when their timer reaches zero.
|
||||
- **Random seeding** — `Game::new()` uses a fixed seed for tests/AI sessions; `Game::new_random()` seeds from system time and is used by terminal/ascii/graphics modes. Cached worlds are keyed by seed.
|
||||
- **Dirty rects** — `ChunkedGrid::mark_dirty(x, y)` expands the chunk's dirty rect to include (x,y) ±1 and propagates to neighbor chunks at boundaries. `cells_swap` and `set_cell_index` call `mark_dirty` automatically. `set` and `set_material` also call `mark_dirty`. The CA step clears each chunk's dirty rect at the start of processing and rebuilds it from cell modifications during processing.
|
||||
- **World scale**: `WORLD_SCALE = 5` in `worldgen.rs` — all world features (trees, pools, walls, dunes, rooms, corridors, terrain amplitude) are multiplied by this factor. Change this constant to adjust entity-to-world size ratio.
|
||||
- **Surface terrain**: Multi-octave sine noise with low frequencies (base 0.012, detail 0.04, micro 0.11) scaled by `WORLD_SCALE`. Produces wide rolling hills (~260 cell wavelength) instead of narrow peaks.
|
||||
- **Uniform tick rate**: All loaded chunks simulate every tick — no chunk is frozen or partially updated. `cells_swap` uses `split_at_mut` for direct chunk array access in bounded mode.
|
||||
- **Fire propagation**: Newly ignited cells get `updated_this_tick = true` to prevent chain reactions within a single tick. Fire spreads 1 cell/tick (linear, not exponential).
|
||||
|
||||
## Module Layout
|
||||
|
||||
@@ -155,12 +197,13 @@ src/
|
||||
lib.rs # pub mod declarations
|
||||
game.rs # Game struct, world gen, fixed_update, collision, combat, slime AI
|
||||
input.rs # Terminal input (crossterm, InputHandler) — terminal mode only
|
||||
world/ # Cell, MaterialId, MaterialRegistry, Grid, Chunk, CellularAutomaton
|
||||
physics/ # VerletSolver, SubBody (with color field), Constraint, resolve_grid_collision
|
||||
audio/mod.rs # AudioEngine (rodio), 15 embedded WAV sounds, ambient scanning
|
||||
world/ # Cell, MaterialId, MaterialRegistry, ChunkedGrid, Grid (legacy), Chunk, CellularAutomaton, WorldGenerator, WorldCache
|
||||
physics/ # VerletSolver, SubBody (with color field), Constraint, resolve_grid_collision, ProjectileManager
|
||||
entity/ # Entity (rigid/ragdoll), EntityManager, Player, BodyTemplate, Item, ItemManager
|
||||
render/ # terminal.rs, vulkan.rs (ASCII), graphics.rs (cells), lighting.rs, window_input.rs
|
||||
ai/ # GameSession, AiAction, pipe protocol, replay, scenarios
|
||||
ui/ # UiLayer, HUD, messages, damage numbers, edge indicators
|
||||
render/ # terminal.rs, vulkan.rs (ASCII), graphics.rs (cells), lighting.rs, window_input.rs, capture.rs
|
||||
ai/ # GameSession, AiAction, pipe protocol, replay, scenarios, spectrum
|
||||
ui/ # UiLayer (flat Vec), HUD, messages, damage numbers, edge indicators, scalable font
|
||||
```
|
||||
|
||||
## PLAN.md
|
||||
|
||||
@@ -15,6 +15,7 @@ ash-window = "0.13"
|
||||
raw-window-handle = "0.6"
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
rodio = { version = "0.20", default-features = false, features = ["wav"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -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 |
|
||||
@@ -10,46 +10,60 @@
|
||||
| System | Status | Details |
|
||||
|--------|--------|---------|
|
||||
| Cellular automaton | Working | 14 materials: sand, water, stone, lava, wood, flesh, bone, steam, fire, acid, smoke, grass, dirt, empty |
|
||||
| Rigid entities | Working | AABB collider, slope stepping, 27 sub-bodies (5x5 + arm), player + goblins |
|
||||
| Multi-layer world | Working | Temperature, gas (smoke/poison/CO2/steam), pressure, world-space light — parallel per-chunk arrays |
|
||||
| Rigid entities | Working | AABB collider, slope stepping, 70 sub-bodies, player + goblins + slimes |
|
||||
| Ragdoll corpses | Working | Verlet constraints, death = rigid→ragdoll transition with inherited velocity |
|
||||
| Terminal renderer | Working | Full terminal size, ANSI truecolor, diff-based rendering |
|
||||
| ASCII renderer (Vulkan) | Working | ash + winit, instanced rendering, glyph atlas, 16x16 square cells, adaptive viewport |
|
||||
| ASCII renderer (Vulkan) | Working | ash + winit, instanced rendering, glyph atlas, 8x8 cells, adaptive viewport |
|
||||
| Graphics renderer (Vulkan) | Working | ash + winit, colored cells (no glyphs), each material = unique base color, adaptive viewport |
|
||||
| AI pipe protocol | Working | JSON stdin/stdout, 16 commands, full state export |
|
||||
| Test framework | Working | 109 Rust tests + 14 JSON scenarios, all passing |
|
||||
| AI pipe protocol | Working | JSON stdin/stdout, 16 commands, full state export, 8 spectrums (materials/temp/light/entities/density/velocity/gas/pressure) |
|
||||
| Audio | Working | 15 procedurally generated sounds via rodio, overlapping playback, ambient scanning, M to toggle |
|
||||
| UI layer | Working | Procedurally scalable font, flat Vec array, health bars, HUD, minimap, inventory overlay, character panel |
|
||||
| Test framework | Working | 185 Rust tests + 14 JSON scenarios, all passing |
|
||||
| Replay system | Working | Seeded determinism, record/playback, play_until_tick |
|
||||
| World generation | Basic | Sinusoidal terrain, water/lava/acid pools, wood structure, sand dune, stone wall |
|
||||
| World generation | Working | ×5 scale: trees 30 cells, pools r20, BSP rooms 20×20, corridors 5 wide; chunk streaming + cache |
|
||||
| Cross-platform | Working | Windows/Linux/macOS via winit + ash_window, no platform-specific code |
|
||||
| Adaptive viewport | Working | Window resize → more/fewer cells visible, cells stay 16x16 pixels |
|
||||
| Per-cell color (reality layer) | Working | Each cell stores fg/bg color inline, no registry lookup in render path |
|
||||
| Adaptive viewport | Working | Window resize → more/fewer cells visible, cells stay 8x8 pixels |
|
||||
| Per-cell color | Working | Each cell stores fg/bg color inline, no registry lookup in render path |
|
||||
| Performance | Working | 130-150 FPS (graphics), uniform tick rate across all chunks |
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Source of truth: text grid (250x250, Cell = material + temp + fg + bg + variant)
|
||||
Source of truth: `ChunkedGrid` of `Cell` structs with parallel layer arrays
|
||||
- Bounded mode: `Vec<Chunk>` for 250x250 test/AI grids
|
||||
- Infinite mode: `HashMap<(i64,i64), Chunk>` for continuous worlds
|
||||
- 64x64 chunks, dirty rects, all loaded chunks active every tick
|
||||
- Multi-layer: temps (f32), pressure (u8), gas_type+density (u8×2), light ([u8;3])
|
||||
- Cell: 9 bytes (material + variant + fg/bg, no temp)
|
||||
- Serialization: VWM1 multi-section format, backward compat with old 12-byte
|
||||
|
||||
World scale: WORLD_SCALE=5, adjustable via single constant
|
||||
Surface: multi-octave sine noise, wide rolling hills (~260 cell wavelength)
|
||||
|
||||
Three entity types:
|
||||
1. Cellular — materials in grid, per-cell CA rules
|
||||
2. Rigid — alive entities, AABB collider, slope stepping, single velocity
|
||||
3. Ragdoll — corpses, loose Verlet bodies, independent physics
|
||||
|
||||
Game loop: fixed 60Hz timestep
|
||||
Physics tick: CA step → rigid update (slope step) → ragdoll update → damage
|
||||
Render: terminal (ANSI) / ascii (Vulkan glyphs) / graphics (Vulkan cells) / pipe (JSON) / headless (file)
|
||||
Game loop: fixed 60Hz timestep, uniform across all chunks
|
||||
Physics tick: CA step → heat_transfer → gas_step → pressure_step → light_step
|
||||
→ entity updates → combat → status effects → gas damage
|
||||
Render: terminal (ANSI) / ascii (Vulkan glyphs) / graphics (Vulkan cells) / pipe (JSON)
|
||||
|
||||
Three render modes:
|
||||
--mode terminal → pure ANSI ASCII in terminal
|
||||
--mode ascii → Vulkan window with ASCII characters (glyph atlas)
|
||||
--mode graphics → Vulkan window with colored cells (no glyphs, material base colors)
|
||||
Audio: 15 embedded WAV sounds via rodio, overlapping playback, ambient scanning
|
||||
UI: flat Vec array, procedurally scalable font, non-destructive overlay
|
||||
```
|
||||
|
||||
### Numbers
|
||||
|
||||
- ~5964 lines Rust
|
||||
- 109 integration tests, 14 JSON scenarios
|
||||
- 40+ git commits
|
||||
- ~9000 lines Rust
|
||||
- 185 integration tests, 14 JSON scenarios
|
||||
- 50+ git commits
|
||||
- 0 compiler warnings (excluding winit deprecation notices)
|
||||
- Cross-platform: Windows/Linux/macOS
|
||||
- 15 embedded sound effects
|
||||
- 130-150 FPS (graphics mode, release, ×5 world scale)
|
||||
|
||||
---
|
||||
|
||||
@@ -67,17 +81,17 @@ loop is: aim → shoot → projectile travels → hits enemy → damage/kill.
|
||||
- [x] Knockback: damage applies velocity impulse to rigid body center
|
||||
- [x] Goblin AI: move toward player, attack when adjacent
|
||||
- [x] Slime AI: jump toward player, contact damage
|
||||
- [ ] **Projectile system**: lightweight rigid bodies (arrows, fireballs, magic bolts)
|
||||
- [x] **Projectile system**: lightweight rigid bodies (arrows, fireballs, magic bolts)
|
||||
- Player shoots with directional input (mouse aim or movement-direction)
|
||||
- Projectile = small AABB, velocity, damage, lifetime
|
||||
- On hit with entity: deal damage, destroy projectile
|
||||
- On hit with solid cell: stop/destroy
|
||||
- Fireball: ignites materials it touches (Noita-style material interaction)
|
||||
- Magic bolt: pure damage, no material effect
|
||||
- [ ] Health bars in render (colored indicator above entity)
|
||||
- [ ] Death → ragdoll → corpse decomposition (flesh cells drop into grid over time)
|
||||
- [ ] Material interaction with entities: entity walks through fire → ignites, acid → dissolves
|
||||
- [ ] Goblin AI: flee when low HP
|
||||
- [x] Health bars in render (colored indicator above entity)
|
||||
- [x] Death → ragdoll → corpse decomposition (flesh cells drop into grid over time)
|
||||
- [x] Material interaction with entities: entity walks through fire → ignites, acid → dissolves
|
||||
- [x] Goblin AI: flee when low HP
|
||||
|
||||
**Tests needed:**
|
||||
- Projectile travels and deals damage on hit
|
||||
@@ -102,34 +116,34 @@ Render pipeline per frame:
|
||||
3. Present to screen
|
||||
```
|
||||
|
||||
- [ ] `UiLayer` struct: sparse map of (screen_x, screen_y) → (char, fg_color, bg_color)
|
||||
- UI elements write to this map, not to the grid
|
||||
- [x] `UiLayer` struct: flat `Vec<Option<UiCell>>` array for O(1) cell access
|
||||
- UI elements write to this array, not to the grid
|
||||
- Renderer composites: if UiLayer has a cell at (x,y), it overrides the world cell visually
|
||||
- World state is never modified by UI
|
||||
- [ ] Health bar: colored bar above player entity, shows current/max HP
|
||||
- [x] Health bar: colored bar above player entity, shows current/max HP
|
||||
- ████░░░░ style, colored green→yellow→red by HP ratio
|
||||
- Positioned relative to player's screen position, scrolls with camera
|
||||
- [ ] Entity labels: small text above/below entities (name, level for RPG)
|
||||
- [ ] Status effect icons: burning 🔥, poisoned, frozen — shown next to entity
|
||||
- [ ] HUD bar (bottom of screen, non-destructive):
|
||||
- [x] Entity labels: small text above/below entities (name, level for RPG)
|
||||
- [x] Status effect icons: burning, poisoned, frozen — shown next to entity
|
||||
- [x] HUD bar (bottom of screen, non-destructive):
|
||||
- HP: ████████░░ 80/100
|
||||
- Material brush: [Sand] (current selected)
|
||||
- Tick: 1234 Depth: 1
|
||||
- FPS counter (debug mode)
|
||||
- [ ] Tooltip on hover: when cursor is over a cell, show material name + temperature
|
||||
- [ ] Message log (top of screen, scrolling): "Goblin hits you for 10 damage"
|
||||
- [x] Message log (top of screen, scrolling): "Goblin hits you for 10 damage"
|
||||
- Last N messages, older ones fade (dimmer color)
|
||||
- [ ] Inventory overlay (toggle with 'i'): semi-transparent panel, doesn't modify world
|
||||
- [x] Inventory overlay (toggle with 'i'/Tab): semi-transparent panel, doesn't modify world
|
||||
- List of items, selected highlight, weight/value display
|
||||
- Opens/closes without affecting simulation
|
||||
- [ ] Menu system (pause, settings, save/load): full-screen overlay with border
|
||||
- Game loop pauses (or continues in background), UI captures input
|
||||
- [ ] Minimap (corner of screen): compressed world view, explored areas only
|
||||
- [x] Minimap (corner of screen): compressed world view, explored areas only
|
||||
- Each minimap cell = 5x5 world cells, averaged material color
|
||||
- Player position marker, entity dots
|
||||
- [ ] Crosshair/targeting: when aiming projectiles, shows trajectory preview
|
||||
- [ ] Damage numbers: floating text above entities when hit, rises and fades
|
||||
- [ ] Screen-edge indicators: arrows pointing to off-screen entities of interest
|
||||
- [x] Damage numbers: floating text above entities when hit, rises and fades
|
||||
- [x] Screen-edge indicators: arrows pointing to off-screen entities of interest
|
||||
|
||||
**Key principle: UI layer NEVER writes to grid, entities, or any game state.
|
||||
It reads state and renders visuals on top. This keeps the source of truth clean
|
||||
@@ -154,11 +168,13 @@ instanced quads with UI texture coordinates. Transparent background, drawn on to
|
||||
|
||||
**Goal: explorable world with depth and variety**
|
||||
|
||||
- [ ] Chunk system: world divided into chunks (64x64), only active chunks simulated
|
||||
- [ ] Chunk persistence: save/load chunks to disk
|
||||
- [ ] Vertical descent: stairs/holes between depth levels
|
||||
- [x] Chunk system: world divided into chunks (64x64), only active chunks simulated
|
||||
- [x] Chunk persistence: save/load chunks to disk
|
||||
- [x] Vertical descent: stairs/holes between depth levels
|
||||
- [x] Chunk streaming: load/generate around player, save/unload distant chunks
|
||||
- [x] Vertical biome progression: surface → caves → dungeon by chunk Y
|
||||
- [ ] Biomes: grassland, cave, lava cavern, ice, fungus forest — each with material palette
|
||||
- [ ] Procedural dungeon generation: rooms, corridors, traps
|
||||
- [x] Procedural dungeon generation: rooms, corridors, traps
|
||||
- [ ] Camera zoom: +/- keys to change viewport scale (more or fewer cells visible)
|
||||
- [ ] Minimap: ASCII overview of explored area
|
||||
- [ ] Day/night cycle: ambient light affects rendering (dimmer at night)
|
||||
@@ -168,23 +184,24 @@ instanced quads with UI texture coordinates. Transparent background, drawn on to
|
||||
- Entity crossing chunk boundary continues correctly
|
||||
- Dungeon generation produces connected rooms
|
||||
- Biome materials match expected palette
|
||||
- Streaming generates chunks as player moves
|
||||
|
||||
### Phase 3: RPG Layer
|
||||
|
||||
**Goal: character progression, inventory, abilities**
|
||||
|
||||
- [ ] Stats: strength, agility, toughness, willpower — affect damage, speed, HP, etc.
|
||||
- [ ] Inventory system: items as data structs, pick up by walking over, drop with key
|
||||
- [ ] Equipment: weapon affects melee damage/range, armor affects damage reduction
|
||||
- [ ] Items in world: weapons, potions, scrolls, food — rendered as distinct ASCII chars
|
||||
- [x] Stats: strength, agility, toughness, willpower — affect damage, speed, HP, etc.
|
||||
- [x] Inventory system: items as data structs, pick up by walking over, drop with key
|
||||
- [x] Equipment: weapon affects melee damage/range, armor affects damage reduction
|
||||
- [x] Items in world: weapons, potions, scrolls, food — rendered as distinct ASCII chars
|
||||
- [ ] Mutations (Caves of Qud style): modify entity properties
|
||||
- "Silicon skin" → entity material becomes Stone, immune to acid
|
||||
- "Flame body" → entity emits fire cells, immune to fire
|
||||
- "Liquid form" → entity can squeeze through 1-cell gaps
|
||||
- "Multiple arms" → extra attack, can hold more items
|
||||
- [ ] XP and leveling: kill entities → gain XP → level up → choose mutation
|
||||
- [x] XP and leveling: kill entities → gain XP → level up → choose mutation
|
||||
- [ ] Skills: active abilities on cooldown (dash, stomp, material blast)
|
||||
- [ ] Status effects: burning, poisoned, frozen, bleeding — each with tick effect
|
||||
- [x] Status effects: burning, poisoned, frozen, bleeding — each with tick effect
|
||||
- [ ] Dialogue: talk to NPCs, simple text tree
|
||||
|
||||
**Tests needed:**
|
||||
@@ -230,14 +247,16 @@ Two distinct render modes, both GPU-accelerated via Vulkan:
|
||||
- [x] Per-cell color: fg/bg stored in Cell, no registry lookup in render path
|
||||
- [x] Square cells: 16x16 pixels, uniform grid
|
||||
- [x] GpuRenderer trait: generic run_gpu_mode<R> for both renderers
|
||||
- [x] Dynamic grid size: GPU buffers sized for 12500x12500, renderers use viewport-relative grid buffer
|
||||
- [ ] Dirty cell tracking: only update changed cells in instance buffer
|
||||
- [ ] Camera zoom: +/- keys to change viewport scale
|
||||
|
||||
**Graphics layers over both modes (Phase 4b):**
|
||||
- [ ] Lighting pass: compute shader calculates light grid from sources (lava, fire, torches)
|
||||
- [x] Lighting pass: CPU light grid from sources (lava, fire, torches), shader line-of-sight
|
||||
- Materials emit light with color/intensity
|
||||
- Walls cast shadows (ray-march in compute)
|
||||
- Walls cast shadows (ray-march in shader)
|
||||
- Light grid modulates cell brightness in render
|
||||
- [ ] GPU compute lighting: move ray-march to compute shader for large worlds
|
||||
- [ ] Particle system: GPU particles positioned relative to grid cells
|
||||
- Fire sparks, water splashes, smoke trails, blood
|
||||
- Particle lifetime + physics (gravity, wind)
|
||||
@@ -268,17 +287,17 @@ Two distinct render modes, both GPU-accelerated via Vulkan:
|
||||
- [ ] Boss entity: large rigid body (10x10), multiple attack patterns
|
||||
- [ ] Books/readable items: lore text displayed in terminal
|
||||
- [ ] Crafting: combine materials to create new ones (water + dirt = mud)
|
||||
- [ ] Sound: procedural audio via terminal bell or optional ALSA
|
||||
- [x] Sound: procedural audio via rodio, 15 embedded WAV sounds, overlapping playback
|
||||
- [ ] Save/load: full game state to file (grid + entities + player + inventory)
|
||||
- [ ] Death screen: stats summary, cause of death
|
||||
- [ ] Tutorial: first-time controls overlay
|
||||
- [ ] Difficulty scaling: deeper levels = stronger enemies
|
||||
|
||||
### Phase 6: Advanced Physics
|
||||
### Phase 6: Advanced Physics (DONE)
|
||||
|
||||
**Goal: deeper Noita-style material simulation**
|
||||
|
||||
- [ ] Multi-layer world: separate grid layers for material, temperature, pressure, gas/air, light
|
||||
- [x] Multi-layer world: separate grid layers for material, temperature, pressure, gas/air, light
|
||||
- Air layer: gas flow, ventilation in caves, gas accumulates at ceiling, displaced by fire
|
||||
- Pressure layer: liquids have pressure, flow through pipes and U-bends
|
||||
- Temperature layer: proper heat diffusion, materials melt/freeze at thresholds
|
||||
@@ -443,10 +462,11 @@ src/
|
||||
world/
|
||||
cell.rs # Cell struct, MaterialId enum
|
||||
material.rs # Material properties registry
|
||||
grid.rs # Grid (250x250), cell access
|
||||
grid.rs # Grid (legacy, 250x250), cell access
|
||||
chunked_grid.rs # ChunkedGrid: bounded + infinite chunk storage
|
||||
cellular.rs # Cellular automaton rules
|
||||
chunk.rs # [Phase 2] chunk system
|
||||
worldgen.rs # [Phase 2] procedural generation
|
||||
chunk.rs # Chunk system
|
||||
worldgen.rs # Procedural generation, per-chunk generation
|
||||
layers.rs # [Phase 6] multi-layer world (temp, pressure, gas, light)
|
||||
physics/
|
||||
verlet.rs # Verlet integrator, constraints
|
||||
@@ -537,10 +557,12 @@ assets/
|
||||
|-----------|---------|--------|
|
||||
| 0.1 (done) | Core engine: CA, rigid, ragdoll, terminal, AI pipe | June 2026 |
|
||||
| 0.2 (done) | Vulkan ASCII + graphics renderers, adaptive viewport, per-cell color, slope stepping | June 2026 |
|
||||
| 0.3 | Combat, goblin AI, projectiles, corpse decomposition | July 2026 |
|
||||
| 0.35 | UI layer: health bar, HUD, message log, minimap, inventory overlay | July 2026 |
|
||||
| 0.4 | Chunks, biomes, dungeon gen, camera zoom | August 2026 |
|
||||
| 0.5 | RPG layer: stats, inventory, mutations, XP | October 2026 |
|
||||
| 0.3 (done) | Combat, goblin AI, projectiles, corpse decomposition | June 2026 |
|
||||
| 0.35 (done) | UI layer: health bar, HUD, message log, minimap, inventory overlay, scalable font | June 2026 |
|
||||
| 0.4 (done) | Chunks, biomes, dungeon gen, world scale ×5, chunk streaming + cache | June 2026 |
|
||||
| 0.45 (done) | RPG layer: stats, inventory, XP, status effects, items | June 2026 |
|
||||
| 0.5 (done) | Multi-layer world: temp, gas, pressure, light + audio system | June 2026 |
|
||||
| 0.55 (done) | Performance: uniform tick rate, 130-150 FPS, fire chain fix | June 2026 |
|
||||
| 0.6 | Lighting/particles/textures (Phase 4b) | December 2026 |
|
||||
| 0.7 | Multi-layer world: air, pressure, temperature, light as separate grids | Feb 2027 |
|
||||
| 0.8 | AI agent: LLM + RL bridge, agent recording | April 2027 |
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# World Generator Plan
|
||||
|
||||
## Goal
|
||||
Procedural world generation with depth-based biomes, rooms, corridors, and randomized features.
|
||||
|
||||
## Current State
|
||||
- Implemented in `src/world/worldgen.rs`
|
||||
- Depth-based dispatch: surface (1-3), caves (4-6), BSP dungeon (7+)
|
||||
- Randomized features, pools, trees, walls, rooms, corridors, traps
|
||||
- World size: 2048x2048 for main game; 250x250 for tests/AI
|
||||
- Seed-based generation with `Game::seed`
|
||||
- Per-chunk world cache in `cache/worlds/<seed>/depth_<N>/`
|
||||
- Tests in `tests/worldgen.rs` and `tests/large_world.rs` (ignored, slow)
|
||||
|
||||
## Algorithms Researched
|
||||
|
||||
### BSP (Binary Space Partitioning)
|
||||
- Recursively divide space into rectangles
|
||||
- Place room in each leaf node
|
||||
- Connect siblings with corridors
|
||||
- Guarantees no overlaps
|
||||
- **Use for: dungeon rooms (depth 7+)**
|
||||
|
||||
### Cellular Automata (4-5 rule)
|
||||
- Fill grid with ~45% random walls
|
||||
- 5 iterations: wall if >=4 neighbors are walls, else empty
|
||||
- Produces organic cave shapes
|
||||
- Flood fill to verify connectivity
|
||||
- **Use for: caves (depth 4-6, and underground at depth 1-3)**
|
||||
|
||||
### Drunkard's Walk
|
||||
- Random walk digs tunnels through solid rock
|
||||
- Creates winding cave-like paths
|
||||
- **Use for: tunnels connecting rooms**
|
||||
|
||||
### Brogue Room Accretion
|
||||
- Start with one room, attach new rooms to existing structure
|
||||
- Inherently connected (tree structure)
|
||||
- Room templates: rectangle, CA blob, circle
|
||||
- **Use for: room placement strategy**
|
||||
|
||||
### Rooms and Mazes (Bob Nystrom)
|
||||
- Place rooms → fill gaps with maze → connect → remove dead ends
|
||||
- **Inspirational, not directly used**
|
||||
|
||||
### Noita — Herringbone Wang Tiles
|
||||
- Pre-made chunks laid in herringbone pattern
|
||||
- Randomized contents within chunks
|
||||
- **Too complex for now, possible future enhancement**
|
||||
|
||||
## Architecture
|
||||
|
||||
### New module: `src/world/worldgen.rs`
|
||||
|
||||
```
|
||||
WorldGenerator
|
||||
├── rng: &mut CellularAutomaton
|
||||
├── generate(grid, depth) — main entry point
|
||||
├── generate_surface(grid, depth) — depth 1-3: terrain + caves + features
|
||||
├── generate_caves(grid, depth) — depth 4-6: full underground caves
|
||||
├── generate_dungeon(grid, depth) — depth 7+: BSP rooms + corridors
|
||||
│
|
||||
├── Surface sub-methods:
|
||||
│ ├── terrain_noise(x, depth) — multi-octave surface height
|
||||
│ ├── fill_terrain(grid, depth) — fill dirt/stone/grass by depth
|
||||
│ ├── carve_underground_caves(grid) — CA caves below surface
|
||||
│ ├── place_trees(grid, count) — random tree placement
|
||||
│ ├── place_pools(grid, count, types)— random liquid pools
|
||||
│ ├── place_sand_dunes(grid, count) — sand piles
|
||||
│ └── place_walls(grid, count) — stone wall obstacles
|
||||
│
|
||||
├── Cave sub-methods:
|
||||
│ ├── ca_caves(grid, fill_prob, iterations) — cellular automata
|
||||
│ ├── flood_fill_largest(grid) — find largest connected region
|
||||
│ ├── seal_small_regions(grid) — fill disconnected caves
|
||||
│ └── place_underground_pools(grid) — lava/acid in caves
|
||||
│
|
||||
├── Dungeon sub-methods:
|
||||
│ ├── bsp_split(rect, depth) — recursive space partitioning
|
||||
│ ├── place_room(grid, rect) — carve room interior
|
||||
│ ├── connect_rooms(grid, rooms) — L-shaped corridors
|
||||
│ ├── place_doors(grid, rooms) — door at room entrances
|
||||
│ └── place_traps(grid, rooms) — acid/fire traps in rooms
|
||||
│
|
||||
└── Shared:
|
||||
├── place_stairs(grid, depth) — stairs in appropriate location
|
||||
└── place_items(game, rooms) — items in rooms/on surface
|
||||
```
|
||||
|
||||
## Depth-based Generation
|
||||
|
||||
| Depth | Type | Surface | Features | Algorithm |
|
||||
|-------|------|---------|----------|-----------|
|
||||
| 1-3 | Surface | Grass/dirt | Trees, water pools, sand dunes, stone walls, underground CA caves | Multi-octave noise + CA |
|
||||
| 4-6 | Caves | Stone/dirt | Large CA caves, lava pools, acid pools, stalactites | Cellular automata (4-5 rule) |
|
||||
| 7+ | Dungeon | Stone | BSP rooms (5x3 to 12x8), corridors, traps, stairs | BSP + corridor connection |
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Surface terrain (depth 1-3)
|
||||
- Multi-octave sine noise: `base + detail + micro`
|
||||
- Amplitude: 4-8 cells variation
|
||||
- Surface material: grass at depth 1, dirt at 2-3
|
||||
- Below surface: dirt for 8 cells, then stone
|
||||
- Border: stone walls
|
||||
|
||||
### CA cave generation (depth 4-6)
|
||||
1. Fill entire grid with stone
|
||||
2. Random fill ~45% as empty (cave candidate)
|
||||
3. Run 5 iterations of 4-5 rule:
|
||||
- Cell becomes wall if >=4 of 8 neighbors are walls
|
||||
- Cell becomes empty if <4 neighbors are walls
|
||||
4. Flood fill from center, find largest connected region
|
||||
5. Seal all cells not in largest region (fill with stone)
|
||||
6. Place lava/acid pools in random empty areas
|
||||
7. Place stalactites (stone pillars) in random positions
|
||||
|
||||
### BSP dungeon (depth 7+)
|
||||
1. Start with full grid as stone
|
||||
2. Recursively split into 2 halves (alternate H/V)
|
||||
3. Stop when area < min_room_size (15x10)
|
||||
4. In each leaf: place room (smaller than partition, centered)
|
||||
5. Connect sibling rooms with L-shaped corridor (2 wide)
|
||||
6. Place stairs in the deepest/farthest room
|
||||
7. Place items in 2-3 random rooms
|
||||
8. Place traps (acid pockets) in 1-2 rooms
|
||||
|
||||
### Feature placement (all depths)
|
||||
- All positions via RNG, not hardcoded
|
||||
- Pool count: 2 + depth/2
|
||||
- Pool radius: 4-10 cells
|
||||
- Pool types by depth:
|
||||
- 1-3: water, sand
|
||||
- 4-6: lava, acid, water
|
||||
- 7+: acid (traps in rooms)
|
||||
- Tree count: 3-7 (surface only)
|
||||
- Wall count: 1-3 (surface only)
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/world/worldgen.rs` | New module — all generation logic |
|
||||
| `src/world/mod.rs` | Add `pub mod worldgen` |
|
||||
| `src/game.rs` | `init_world()` calls `WorldGenerator::generate()` |
|
||||
| `tests/worldgen.rs` | New tests |
|
||||
|
||||
## Tests
|
||||
- Stairs exist after generation at any depth
|
||||
- At least 3 distinct materials present
|
||||
- Player spawn position is not inside solid
|
||||
- Different depths produce different structures
|
||||
- Depth 7+ has rooms (empty regions > 5x3)
|
||||
- CA caves are connected (flood fill test)
|
||||
|
||||
## Implementation Order
|
||||
1. Create `worldgen.rs` with `WorldGenerator` struct and `generate()` dispatch
|
||||
2. Implement surface generation (depth 1-3) — move existing code, add RNG
|
||||
3. Implement CA cave generation (depth 4-6)
|
||||
4. Implement BSP dungeon generation (depth 7+)
|
||||
5. Integrate into `game.rs::init_world()`
|
||||
6. Write tests
|
||||
7. Run all tests + scenarios
|
||||
8. Push
|
||||
@@ -48,8 +48,9 @@ bool line_of_sight(ivec2 a, ivec2 b) {
|
||||
int err = d.x - d.y;
|
||||
while (true) {
|
||||
if (p == b) return true;
|
||||
if (p.x < 0 || p.x >= pc.world_size.x || p.y < 0 || p.y >= pc.world_size.y) return false;
|
||||
uint m = grid.cells[p.y * pc.world_size.x + p.x];
|
||||
ivec2 vp = p - pc.cam_pos;
|
||||
if (vp.x < 0 || vp.x >= pc.world_size.x || vp.y < 0 || vp.y >= pc.world_size.y) return false;
|
||||
uint m = grid.cells[vp.y * pc.world_size.x + vp.x];
|
||||
if (is_solid(m)) return false;
|
||||
int e2 = 2 * err;
|
||||
if (e2 > -d.y) { err -= d.y; p.x += s.x; }
|
||||
|
||||
@@ -44,8 +44,9 @@ bool line_of_sight(ivec2 a, ivec2 b) {
|
||||
int err = d.x - d.y;
|
||||
while (true) {
|
||||
if (p == b) return true;
|
||||
if (p.x < 0 || p.x >= pc.world_size.x || p.y < 0 || p.y >= pc.world_size.y) return false;
|
||||
uint m = grid.cells[p.y * pc.world_size.x + p.x];
|
||||
ivec2 vp = p - pc.cam_pos;
|
||||
if (vp.x < 0 || vp.x >= pc.world_size.x || vp.y < 0 || vp.y >= pc.world_size.y) return false;
|
||||
uint m = grid.cells[vp.y * pc.world_size.x + vp.x];
|
||||
if (is_solid(m)) return false;
|
||||
int e2 = 2 * err;
|
||||
if (e2 > -d.y) { err -= d.y; p.x += s.x; }
|
||||
@@ -70,17 +71,29 @@ vec3 compute_light(ivec2 world_pos) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 pixel = (in_grid + in_pos) * pc.cell_size;
|
||||
gl_Position = vec4(
|
||||
2.0 * pixel.x / pc.screen_size.x - 1.0,
|
||||
2.0 * pixel.y / pc.screen_size.y - 1.0,
|
||||
0.0, 1.0
|
||||
);
|
||||
out_color = in_color;
|
||||
if (pc.is_ui != 0u) {
|
||||
if (pc.is_ui == 2u) {
|
||||
vec2 world_pos = in_grid;
|
||||
vec2 screen = (world_pos - vec2(pc.cam_pos)) * pc.cell_size + in_pos * pc.cell_size;
|
||||
gl_Position = vec4(
|
||||
2.0 * screen.x / pc.screen_size.x - 1.0,
|
||||
2.0 * screen.y / pc.screen_size.y - 1.0,
|
||||
0.0, 1.0
|
||||
);
|
||||
out_color = in_color;
|
||||
out_light = vec3(1.0);
|
||||
} else {
|
||||
ivec2 world_pos = ivec2(in_grid + vec2(pc.cam_pos));
|
||||
out_light = compute_light(world_pos);
|
||||
vec2 pixel = (in_grid + in_pos) * pc.cell_size;
|
||||
gl_Position = vec4(
|
||||
2.0 * pixel.x / pc.screen_size.x - 1.0,
|
||||
2.0 * pixel.y / pc.screen_size.y - 1.0,
|
||||
0.0, 1.0
|
||||
);
|
||||
out_color = in_color;
|
||||
if (pc.is_ui != 0u) {
|
||||
out_light = vec3(1.0);
|
||||
} else {
|
||||
ivec2 world_pos = ivec2(in_grid + vec2(pc.cam_pos));
|
||||
out_light = compute_light(world_pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"mode": "graphics",
|
||||
"ticks": 300,
|
||||
"total_time_ms": 2104.1,
|
||||
"avg_fps": 142.6,
|
||||
"avg_frame_time_ms": 6.99,
|
||||
"p99_frame_time_ms": 15.98,
|
||||
"min_frame_time_ms": 5.43,
|
||||
"subsystems": {
|
||||
"ca_step_avg_us": 2900,
|
||||
"ca_step_p99_us": 11976,
|
||||
"ca_step_min_us": 1280,
|
||||
"render_avg_us": 3459,
|
||||
"render_p99_us": 5031,
|
||||
"render_min_us": 3155
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 640 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
+5
-5
@@ -11,10 +11,10 @@ pub use action::AiAction;
|
||||
pub use protocol::run_pipe_protocol;
|
||||
pub use replay::{ReplayPlayer, ReplayRecorder, ReplayRecording};
|
||||
pub use scenario::{
|
||||
format_results, load_scenario, run_all_scenarios, run_scenario, Assertion, AssertionResult,
|
||||
Scenario,
|
||||
Assertion, AssertionResult, Scenario, format_results, load_scenario, run_all_scenarios,
|
||||
run_scenario,
|
||||
};
|
||||
pub use session::GameSession;
|
||||
pub use spectrum::{format_all_spectrums, render_all_spectrums, render_spectrum, Spectrum};
|
||||
pub use state::{entity_kind_name, render_view, CellInfo, EntityInfo, GameState, SubBodyInfo};
|
||||
pub use tape::{run_tape_mode, TapeFrame, TapeRecorder};
|
||||
pub use spectrum::{Spectrum, format_all_spectrums, render_all_spectrums, render_spectrum};
|
||||
pub use state::{CellInfo, EntityInfo, GameState, SubBodyInfo, entity_kind_name, render_view};
|
||||
pub use tape::{TapeFrame, TapeRecorder, run_tape_mode};
|
||||
|
||||
+7
-1
@@ -289,7 +289,13 @@ 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,
|
||||
_ => return Response::err("Unknown spectrum. Use: materials, temperature, light, entities, density, 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",
|
||||
);
|
||||
}
|
||||
};
|
||||
let view = s.get_spectrum(&spec, vw, vh);
|
||||
Response {
|
||||
|
||||
+6
-6
@@ -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
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
+21
-4
@@ -3,7 +3,7 @@ use crate::ai::replay::ReplayRecorder;
|
||||
use crate::ai::state::{build_game_state, render_view, CellInfo, EntityInfo, GameState};
|
||||
use crate::game::Game;
|
||||
use crate::world::cell::MaterialId;
|
||||
use crate::world::grid::Grid;
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
|
||||
pub struct GameSession {
|
||||
pub game: Game,
|
||||
@@ -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));
|
||||
}
|
||||
@@ -223,7 +240,7 @@ impl GameSession {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn grid(&self) -> &Grid {
|
||||
pub fn grid(&self) -> &ChunkedGrid {
|
||||
&self.game.grid
|
||||
}
|
||||
|
||||
|
||||
+86
-11
@@ -1,5 +1,5 @@
|
||||
use crate::entity::{EntityKind, EntityManager};
|
||||
use crate::world::grid::Grid;
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
|
||||
pub enum Spectrum {
|
||||
Materials,
|
||||
@@ -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,13 +34,15 @@ impl Spectrum {
|
||||
Spectrum::Entities,
|
||||
Spectrum::Density,
|
||||
Spectrum::Velocity,
|
||||
Spectrum::Gas,
|
||||
Spectrum::Pressure,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_spectrum(
|
||||
spectrum: &Spectrum,
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
light: Option<&crate::render::lighting::LightGrid>,
|
||||
cam_x: i32,
|
||||
@@ -51,11 +57,13 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_materials(
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
cam_x: i32,
|
||||
cam_y: i32,
|
||||
@@ -100,7 +108,7 @@ fn render_materials(
|
||||
buf
|
||||
}
|
||||
|
||||
fn render_temperature(grid: &Grid, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String {
|
||||
fn render_temperature(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 {
|
||||
@@ -113,7 +121,7 @@ fn render_temperature(grid: &Grid, cam_x: i32, cam_y: i32, vw: usize, vh: usize)
|
||||
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 {
|
||||
@@ -141,7 +149,7 @@ fn render_temperature(grid: &Grid, cam_x: i32, cam_y: i32, vw: usize, vh: usize)
|
||||
}
|
||||
|
||||
fn render_light(
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
light: Option<&crate::render::lighting::LightGrid>,
|
||||
cam_x: i32,
|
||||
@@ -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])
|
||||
};
|
||||
@@ -188,7 +203,7 @@ fn render_light(
|
||||
}
|
||||
|
||||
fn render_entities(
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
cam_x: i32,
|
||||
cam_y: i32,
|
||||
@@ -236,7 +251,7 @@ fn render_entities(
|
||||
buf
|
||||
}
|
||||
|
||||
fn render_density(grid: &Grid, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String {
|
||||
fn render_density(grid: &ChunkedGrid, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String {
|
||||
let reg = crate::world::material::MaterialRegistry::instance();
|
||||
let mut buf = String::with_capacity(vw * vh + vh);
|
||||
for dy in 0..vh {
|
||||
@@ -275,7 +290,7 @@ fn render_density(grid: &Grid, cam_x: i32, cam_y: i32, vw: usize, vh: usize) ->
|
||||
}
|
||||
|
||||
fn render_velocity(
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
cam_x: i32,
|
||||
cam_y: i32,
|
||||
@@ -327,7 +342,7 @@ fn render_velocity(
|
||||
}
|
||||
|
||||
pub fn render_all_spectrums(
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
light: Option<&crate::render::lighting::LightGrid>,
|
||||
cam_x: i32,
|
||||
@@ -347,7 +362,7 @@ pub fn render_all_spectrums(
|
||||
}
|
||||
|
||||
pub fn format_all_spectrums(
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
light: Option<&crate::render::lighting::LightGrid>,
|
||||
cam_x: i32,
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
use crate::entity::{EntityKind, EntityManager};
|
||||
use crate::game::Game;
|
||||
use crate::world::cell::MaterialId;
|
||||
use crate::world::grid::Grid;
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
use crate::world::material::MaterialRegistry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -60,7 +60,7 @@ pub struct CellInfo {
|
||||
}
|
||||
|
||||
impl CellInfo {
|
||||
pub fn from_grid(grid: &Grid, x: i32, y: i32) -> Self {
|
||||
pub fn from_grid(grid: &ChunkedGrid, x: i32, y: i32) -> Self {
|
||||
if !grid.in_bounds(x, y) {
|
||||
return Self {
|
||||
x,
|
||||
@@ -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,
|
||||
@@ -157,7 +157,7 @@ pub fn build_game_state(game: &Game, view_w: usize, view_h: usize) -> GameState
|
||||
}
|
||||
|
||||
pub fn render_view(
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
cam_x: i32,
|
||||
cam_y: i32,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
use rodio::source::Source;
|
||||
use rodio::{OutputStream, OutputStreamHandle, Sink};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct AudioEngine {
|
||||
_stream: Option<OutputStream>,
|
||||
handle: Option<OutputStreamHandle>,
|
||||
sounds: HashMap<&'static str, &'static [u8]>,
|
||||
last_played: HashMap<&'static str, Instant>,
|
||||
volume: f32,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl AudioEngine {
|
||||
pub fn new() -> Self {
|
||||
let (stream, handle) = match OutputStream::try_default() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("Audio init failed: {}", e);
|
||||
return Self::disabled();
|
||||
}
|
||||
};
|
||||
|
||||
let mut sounds = HashMap::new();
|
||||
sounds.insert(
|
||||
"jump",
|
||||
include_bytes!("../../assets/sounds/jump.wav") as &[u8],
|
||||
);
|
||||
sounds.insert("shoot", include_bytes!("../../assets/sounds/shoot.wav"));
|
||||
sounds.insert("hit", include_bytes!("../../assets/sounds/hit.wav"));
|
||||
sounds.insert(
|
||||
"explosion",
|
||||
include_bytes!("../../assets/sounds/explosion.wav"),
|
||||
);
|
||||
sounds.insert("death", include_bytes!("../../assets/sounds/death.wav"));
|
||||
sounds.insert("pickup", include_bytes!("../../assets/sounds/pickup.wav"));
|
||||
sounds.insert("descend", include_bytes!("../../assets/sounds/descend.wav"));
|
||||
sounds.insert("powerup", include_bytes!("../../assets/sounds/powerup.wav"));
|
||||
sounds.insert("step", include_bytes!("../../assets/sounds/step.wav"));
|
||||
sounds.insert(
|
||||
"lava_bubble",
|
||||
include_bytes!("../../assets/sounds/lava_bubble.wav"),
|
||||
);
|
||||
sounds.insert(
|
||||
"acid_sizzle",
|
||||
include_bytes!("../../assets/sounds/acid_sizzle.wav"),
|
||||
);
|
||||
sounds.insert(
|
||||
"water_splash",
|
||||
include_bytes!("../../assets/sounds/water_splash.wav"),
|
||||
);
|
||||
sounds.insert(
|
||||
"fire_crackle",
|
||||
include_bytes!("../../assets/sounds/fire_crackle.wav"),
|
||||
);
|
||||
sounds.insert(
|
||||
"ui_click",
|
||||
include_bytes!("../../assets/sounds/ui_click.wav"),
|
||||
);
|
||||
sounds.insert(
|
||||
"goblin_growl",
|
||||
include_bytes!("../../assets/sounds/goblin_growl.wav"),
|
||||
);
|
||||
|
||||
Self {
|
||||
_stream: Some(stream),
|
||||
handle: Some(handle),
|
||||
sounds,
|
||||
last_played: HashMap::new(),
|
||||
volume: 0.5,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled() -> Self {
|
||||
Self {
|
||||
_stream: None,
|
||||
handle: None,
|
||||
sounds: HashMap::new(),
|
||||
last_played: HashMap::new(),
|
||||
volume: 0.0,
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play(&mut self, name: &'static str) {
|
||||
self.play_throttled(name, 50);
|
||||
}
|
||||
|
||||
pub fn play_throttled(&mut self, name: &'static str, min_interval_ms: u64) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
if let Some(&last) = self.last_played.get(name) {
|
||||
if now.duration_since(last).as_millis() < min_interval_ms as u128 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.last_played.insert(name, now);
|
||||
|
||||
let data = match self.sounds.get(name) {
|
||||
Some(d) => *d,
|
||||
None => return,
|
||||
};
|
||||
let handle = match &self.handle {
|
||||
Some(h) => h,
|
||||
None => return,
|
||||
};
|
||||
let sink = match Sink::try_new(handle) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
if let Ok(decoder) = rodio::Decoder::new(Cursor::new(data.to_vec())) {
|
||||
let source = decoder.amplify(self.volume).convert_samples::<f32>();
|
||||
sink.append(source);
|
||||
sink.detach();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_volume(&mut self, vol: f32) {
|
||||
self.volume = vol.clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
pub fn toggle(&mut self) {
|
||||
self.enabled = !self.enabled;
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AudioEngine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,24 @@ impl Item {
|
||||
}
|
||||
}
|
||||
|
||||
impl ItemType {
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"Dagger" => Some(Self::Dagger),
|
||||
"Sword" => Some(Self::Sword),
|
||||
"Bow" => Some(Self::Bow),
|
||||
"Leather Armor" => Some(Self::LeatherArmor),
|
||||
"Plate Armor" => Some(Self::PlateArmor),
|
||||
"Shield" => Some(Self::Shield),
|
||||
"Health Potion" => Some(Self::HealthPotion),
|
||||
"Mana Potion" => Some(Self::ManaPotion),
|
||||
"Food" => Some(Self::Food),
|
||||
"Scroll" => Some(Self::Scroll),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ItemManager {
|
||||
items: Vec<Item>,
|
||||
next_id: u32,
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ pub mod entity;
|
||||
pub mod item;
|
||||
pub mod player;
|
||||
|
||||
pub use body_template::{template_for_kind, BodyPart, BodyTemplate};
|
||||
pub use body_template::{BodyPart, BodyTemplate, template_for_kind};
|
||||
pub use entity::{EntityKind, EntityManager};
|
||||
pub use item::{Item, ItemManager, ItemType};
|
||||
pub use player::Player;
|
||||
|
||||
@@ -31,6 +31,13 @@ impl Player {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_position(&self, manager: &mut EntityManager, cx: f32, cy: f32) {
|
||||
if let Some(e) = manager.get_mut(self.entity_id) {
|
||||
e.cx = cx;
|
||||
e.cy = cy;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_left(&mut self, manager: &mut EntityManager) {
|
||||
if let Some(e) = manager.get_mut(self.entity_id) {
|
||||
e.set_horizontal_vel(-self.move_speed);
|
||||
|
||||
+552
-243
@@ -1,28 +1,36 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::entity::player::Player;
|
||||
use crate::entity::{EntityKind, EntityManager, ItemManager, ItemType};
|
||||
use crate::entity::{EntityKind, EntityManager, ItemManager};
|
||||
use crate::input::{Action, InputHandler};
|
||||
use crate::physics::collision::resolve_grid_collision;
|
||||
use crate::physics::particle::ParticleManager;
|
||||
use crate::physics::projectile::{ProjectileManager, ProjectileType};
|
||||
use crate::physics::verlet::VerletSolver;
|
||||
use crate::render::lighting;
|
||||
use crate::render::Renderer;
|
||||
use crate::ui::UiLayer;
|
||||
use crate::world::cache::WorldCache;
|
||||
use crate::world::cell::MaterialId;
|
||||
use crate::world::cellular::CellularAutomaton;
|
||||
use crate::world::grid::Grid;
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
use crate::world::grid::{WORLD_H, WORLD_W};
|
||||
use crate::world::worldgen::WorldGenerator;
|
||||
|
||||
use crate::audio::AudioEngine;
|
||||
|
||||
pub struct Game {
|
||||
pub grid: Grid,
|
||||
pub grid: ChunkedGrid,
|
||||
pub ca: CellularAutomaton,
|
||||
pub verlet: VerletSolver,
|
||||
pub entities: EntityManager,
|
||||
pub projectiles: ProjectileManager,
|
||||
pub particles: ParticleManager,
|
||||
pub items: ItemManager,
|
||||
pub player: Player,
|
||||
pub input: InputHandler,
|
||||
pub ui: UiLayer,
|
||||
pub audio: AudioEngine,
|
||||
pub cam_x: i32,
|
||||
pub cam_y: i32,
|
||||
pub cam_offset_x: i32,
|
||||
@@ -43,22 +51,35 @@ pub struct Game {
|
||||
pub inventory_open: bool,
|
||||
pub inventory_mouse_x: i32,
|
||||
pub inventory_mouse_y: i32,
|
||||
pub seed: u64,
|
||||
pub cache_dir: Option<String>,
|
||||
pub mouse_world_pos: (i32, i32),
|
||||
pub mouse_ui_x: i32,
|
||||
pub mouse_ui_y: i32,
|
||||
pub show_tooltip: bool,
|
||||
pub show_crosshair: bool,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
pub fn new() -> Self {
|
||||
Self::new_with_size(WORLD_W, WORLD_H)
|
||||
}
|
||||
|
||||
fn new_with_size(width: usize, height: usize) -> Self {
|
||||
let mut entities = EntityManager::new();
|
||||
let player = Player::new(&mut entities);
|
||||
Self {
|
||||
grid: Grid::new(),
|
||||
grid: ChunkedGrid::with_size(width, height),
|
||||
ca: CellularAutomaton::new(),
|
||||
verlet: VerletSolver::new(),
|
||||
entities,
|
||||
projectiles: ProjectileManager::new(),
|
||||
particles: ParticleManager::new(2000),
|
||||
items: ItemManager::new(),
|
||||
player,
|
||||
input: InputHandler::new(),
|
||||
ui: UiLayer::new(),
|
||||
audio: AudioEngine::new(),
|
||||
cam_x: 100,
|
||||
cam_y: 100,
|
||||
cam_offset_x: 0,
|
||||
@@ -79,184 +100,110 @@ impl Game {
|
||||
inventory_open: false,
|
||||
inventory_mouse_x: 0,
|
||||
inventory_mouse_y: 0,
|
||||
seed: 0x1234567890ABCDEF,
|
||||
cache_dir: None,
|
||||
mouse_world_pos: (0, 0),
|
||||
mouse_ui_x: 0,
|
||||
mouse_ui_y: 0,
|
||||
show_tooltip: false,
|
||||
show_crosshair: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_random() -> Self {
|
||||
let seed = crate::world::cellular::random_seed();
|
||||
let cache_dir = Some("cache/worlds".to_string());
|
||||
let mut entities = EntityManager::new();
|
||||
let player = Player::new(&mut entities);
|
||||
let mut ca = CellularAutomaton::new();
|
||||
ca.seed(seed);
|
||||
Self {
|
||||
grid: ChunkedGrid::infinite(seed, cache_dir.clone()),
|
||||
ca,
|
||||
verlet: VerletSolver::new(),
|
||||
entities,
|
||||
projectiles: ProjectileManager::new(),
|
||||
particles: ParticleManager::new(2000),
|
||||
items: ItemManager::new(),
|
||||
player,
|
||||
input: InputHandler::new(),
|
||||
ui: UiLayer::new(),
|
||||
audio: AudioEngine::new(),
|
||||
cam_x: 0,
|
||||
cam_y: 0,
|
||||
cam_offset_x: 0,
|
||||
cam_offset_y: 0,
|
||||
running: true,
|
||||
tick: 0,
|
||||
fixed_dt: Duration::from_millis(16),
|
||||
accumulator: Duration::ZERO,
|
||||
last_time: Instant::now(),
|
||||
last_shot_tick: 0,
|
||||
shot_cooldown: 8,
|
||||
fireball_mode: false,
|
||||
corpse_decomp_timer: 0,
|
||||
kills: 0,
|
||||
score: 0,
|
||||
depth: 1,
|
||||
fps: 0.0,
|
||||
inventory_open: false,
|
||||
inventory_mouse_x: 0,
|
||||
inventory_mouse_y: 0,
|
||||
seed,
|
||||
cache_dir,
|
||||
mouse_world_pos: (0, 0),
|
||||
mouse_ui_x: 0,
|
||||
mouse_ui_y: 0,
|
||||
show_tooltip: false,
|
||||
show_crosshair: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_world(&mut self) {
|
||||
let w = self.grid.width;
|
||||
let h = self.grid.height;
|
||||
|
||||
for x in 0..w {
|
||||
self.grid
|
||||
.set_material(x as i32, (h - 1) as i32, MaterialId::Stone);
|
||||
self.grid
|
||||
.set_material(x as i32, (h - 2) as i32, MaterialId::Dirt);
|
||||
}
|
||||
|
||||
let surface_noise = |x: i32| -> i32 {
|
||||
let base = (h as i32 - 3) - ((x as f32 * 0.08).sin() * 4.0) as i32;
|
||||
let detail = ((x as f32 * 0.23).sin() * 2.0) as i32;
|
||||
(base + detail).max(10).min(h as i32 - 3)
|
||||
};
|
||||
|
||||
for x in 0..w {
|
||||
let surface = surface_noise(x as i32);
|
||||
let biome = x / (w / 4);
|
||||
|
||||
for y in surface..(h as i32 - 2) {
|
||||
if y == surface {
|
||||
let mat = match biome {
|
||||
0 => MaterialId::Grass,
|
||||
1 => MaterialId::Grass,
|
||||
2 => MaterialId::Dirt,
|
||||
_ => MaterialId::Stone,
|
||||
};
|
||||
self.grid.set_material(x as i32, y, mat);
|
||||
} else if y > surface + 8 {
|
||||
self.grid.set_material(x as i32, y, MaterialId::Stone);
|
||||
let cache_dir = self.cache_dir.clone();
|
||||
let (px, py) = if let Some(ref root) = cache_dir {
|
||||
let has_meta = WorldCache::meta_exists(root, self.seed);
|
||||
if has_meta {
|
||||
if let Err(e) = WorldCache::load_meta(
|
||||
root,
|
||||
self.seed,
|
||||
&mut self.player,
|
||||
&mut self.entities,
|
||||
&mut self.items,
|
||||
) {
|
||||
eprintln!("World cache meta load failed: {}", e);
|
||||
} else {
|
||||
self.grid.set_material(x as i32, y, MaterialId::Dirt);
|
||||
let (px, py) = self.player.center(&self.entities);
|
||||
self.grid.ensure_loaded(px as i32, py as i32, 3);
|
||||
self.center_camera_on(px, py);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _ in 0..8 {
|
||||
let cave_x = (self.ca.random_u32() % (w as u32 - 20) + 10) as i32;
|
||||
let cave_y = (self.ca.random_u32() % (h as u32 / 3) + (h as u32 / 3) * 2) as i32;
|
||||
let cave_r = (self.ca.random_u32() % 4 + 3) as i32;
|
||||
for dy in -cave_r..=cave_r {
|
||||
for dx in -cave_r..=cave_r {
|
||||
if dx * dx + dy * dy <= cave_r * cave_r {
|
||||
let cx = cave_x + dx;
|
||||
let cy = cave_y + dy;
|
||||
if cx > 1 && cx < w as i32 - 2 && cy > 1 && cy < h as i32 - 2 {
|
||||
self.grid.set(cx, cy, crate::world::cell::Cell::empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for tree_x in [60, 75, 130, 145, 220] {
|
||||
let s = surface_noise(tree_x);
|
||||
for y in (s - 6)..s {
|
||||
if y > 5 {
|
||||
self.grid.set_material(tree_x, y, MaterialId::Wood);
|
||||
}
|
||||
}
|
||||
for dy in -2..=0 {
|
||||
for dx in -2..=2 {
|
||||
if dx * dx + dy * dy <= 5 {
|
||||
let cx = tree_x + dx;
|
||||
let cy = s - 6 + dy;
|
||||
if cx > 1 && cx < w as i32 - 2 && cy > 1 {
|
||||
if self.grid.get(cx, cy).is_empty() {
|
||||
self.grid.set_material(cx, cy, MaterialId::Grass);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let water_x = 40;
|
||||
for x in water_x - 10..=water_x + 10 {
|
||||
let s = surface_noise(x);
|
||||
for y in s - 6..s {
|
||||
if self.grid.get(x, y).is_empty() {
|
||||
self.grid.set_material(x, y, MaterialId::Water);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lava_x = 200;
|
||||
for x in lava_x - 8..=lava_x + 8 {
|
||||
let s = surface_noise(x);
|
||||
for y in s - 4..s {
|
||||
if self.grid.get(x, y).is_empty() {
|
||||
self.grid.set_material(x, y, MaterialId::Lava);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sand_x = 160;
|
||||
for dx in -10..=10 {
|
||||
let s = surface_noise(sand_x + dx);
|
||||
let pile_h = (10.0 - (dx as f32).abs() * 0.8) as i32;
|
||||
for dy in 0..pile_h {
|
||||
let y = s - 1 - dy;
|
||||
if y > 5 && self.grid.get(sand_x + dx, y).is_empty() {
|
||||
self.grid.set_material(sand_x + dx, y, MaterialId::Sand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let acid_x = 20;
|
||||
for x in acid_x - 4..=acid_x + 4 {
|
||||
let s = surface_noise(x);
|
||||
for y in s - 3..s {
|
||||
if self.grid.get(x, y).is_empty() {
|
||||
self.grid.set_material(x, y, MaterialId::Acid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let wall_x = 110;
|
||||
let wall_s = surface_noise(wall_x);
|
||||
for y in (wall_s - 5)..wall_s {
|
||||
self.grid.set_material(wall_x, y, MaterialId::Stone);
|
||||
self.grid.set_material(wall_x + 1, y, MaterialId::Stone);
|
||||
}
|
||||
|
||||
for _ in 0..6 {
|
||||
let px = (self.ca.random_u32() % (w as u32 - 20) + 10) as i32;
|
||||
let py = (self.ca.random_u32() % (h as u32 / 3) + (h as u32 / 3) * 2) as i32;
|
||||
for dy in 0..8 {
|
||||
for dx in -1..=1 {
|
||||
let cx = px + dx;
|
||||
let cy = py + dy;
|
||||
if cx > 1 && cx < w as i32 - 2 && cy < h as i32 - 3 {
|
||||
self.grid.set_material(cx, cy, MaterialId::Stone);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.grid.fill_border(MaterialId::Stone);
|
||||
|
||||
let cx = (w / 2) as f32;
|
||||
let surface_x = cx as i32;
|
||||
let mut surface_y = h as i32 - 3;
|
||||
for y in 0..h as i32 {
|
||||
if self.grid.get(surface_x, y).is_solid()
|
||||
&& self.grid.get(surface_x, y).material != MaterialId::Stone
|
||||
let (px, py) = WorldGenerator::new(&mut self.ca).generate(
|
||||
&mut self.grid,
|
||||
&mut self.items,
|
||||
&mut self.player,
|
||||
&mut self.entities,
|
||||
self.depth,
|
||||
);
|
||||
self.center_camera_on(px, py);
|
||||
if let Err(e) = WorldCache::save_meta(root, self.seed, px, py, self.depth, &self.items)
|
||||
{
|
||||
surface_y = y;
|
||||
break;
|
||||
eprintln!("World cache meta save failed: {}", e);
|
||||
}
|
||||
}
|
||||
let stair_y = (h as i32 - 2).max(surface_y + 2);
|
||||
self.grid
|
||||
.set_material(surface_x, stair_y, MaterialId::Stairs);
|
||||
|
||||
let cy = (surface_y as f32) - 5.0;
|
||||
self.player.spawn_at(&mut self.entities, cx, cy);
|
||||
|
||||
let (px, py) = self.player.center(&self.entities);
|
||||
self.center_camera_on(px, py);
|
||||
|
||||
self.items
|
||||
.spawn(ItemType::Sword, px as i32 - 6, py as i32 + 1);
|
||||
self.items
|
||||
.spawn(ItemType::HealthPotion, px as i32 + 6, py as i32 + 1);
|
||||
self.items
|
||||
.spawn(ItemType::LeatherArmor, px as i32 - 3, py as i32 - 8);
|
||||
self.items
|
||||
.spawn(ItemType::Bow, px as i32 + 10, py as i32 + 1);
|
||||
self.items
|
||||
.spawn(ItemType::Shield, px as i32 - 10, py as i32 + 1);
|
||||
self.items
|
||||
.spawn(ItemType::ManaPotion, px as i32 + 3, py as i32 - 6);
|
||||
(px, py)
|
||||
} else {
|
||||
let (px, py) = WorldGenerator::new(&mut self.ca).generate(
|
||||
&mut self.grid,
|
||||
&mut self.items,
|
||||
&mut self.player,
|
||||
&mut self.entities,
|
||||
self.depth,
|
||||
);
|
||||
self.center_camera_on(px, py);
|
||||
(px, py)
|
||||
};
|
||||
let _ = (px, py);
|
||||
}
|
||||
|
||||
pub fn center_camera_on(&mut self, px: f32, py: f32) {
|
||||
@@ -311,7 +258,7 @@ impl Game {
|
||||
self.cam_y,
|
||||
vw,
|
||||
vh,
|
||||
lighting::ambient_light(),
|
||||
lighting::ambient_light_at_tick(self.tick),
|
||||
);
|
||||
|
||||
if let Err(e) = renderer.render(
|
||||
@@ -386,6 +333,9 @@ impl Game {
|
||||
// Jump: only on press, not held
|
||||
if self.input.jump_requested() {
|
||||
let on_ground = self.check_on_ground();
|
||||
if on_ground {
|
||||
self.audio.play("jump");
|
||||
}
|
||||
self.player.jump(&mut self.entities, on_ground);
|
||||
}
|
||||
|
||||
@@ -422,6 +372,12 @@ impl Game {
|
||||
self.ui.clear();
|
||||
self.ui.tick_messages();
|
||||
|
||||
let ui_w = (vw as i32) * crate::ui::UI_SCALE;
|
||||
let ui_h = (vh as i32) * crate::ui::UI_SCALE;
|
||||
let fs = ((ui_h / 200).max(2)).min(6) as i32;
|
||||
self.ui.set_font_scale(fs);
|
||||
self.ui.resize(ui_w as usize, ui_h as usize);
|
||||
|
||||
let player = self.player.entity(&self.entities).cloned();
|
||||
let brush = self
|
||||
.input
|
||||
@@ -429,8 +385,6 @@ impl Game {
|
||||
.to_material()
|
||||
.unwrap_or(crate::world::cell::MaterialId::Empty);
|
||||
let player_alive = player.as_ref().map(|e| e.alive).unwrap_or(false);
|
||||
let ui_w = (vw as i32) * crate::ui::UI_SCALE;
|
||||
let ui_h = (vh as i32) * crate::ui::UI_SCALE;
|
||||
if !self.inventory_open {
|
||||
self.ui
|
||||
.draw_character_panel(1, 1, player.as_ref(), &self.player);
|
||||
@@ -447,7 +401,7 @@ impl Game {
|
||||
&self.player,
|
||||
self.fps,
|
||||
);
|
||||
let msg_x = (ui_w / 2) - 24;
|
||||
let msg_x = (ui_w / 2) - 24 * fs;
|
||||
self.ui.draw_messages(msg_x, 4);
|
||||
self.ui.draw_damage_numbers(self.cam_x, self.cam_y);
|
||||
self.ui.draw_edge_indicators(
|
||||
@@ -483,6 +437,56 @@ impl Game {
|
||||
.draw_death_screen(ui_w as usize, ui_h as usize, self.kills, self.score);
|
||||
}
|
||||
|
||||
if self.show_crosshair && !self.inventory_open {
|
||||
let (px, py) = self.player.center(&self.entities);
|
||||
let psx = (px as i32 - self.cam_x) * crate::ui::UI_SCALE;
|
||||
let psy = (py as i32 - self.cam_y) * crate::ui::UI_SCALE;
|
||||
let (mx, my) = (self.mouse_ui_x, self.mouse_ui_y);
|
||||
let dx = mx - psx;
|
||||
let dy = my - psy;
|
||||
let dist = ((dx * dx + dy * dy) as f32).sqrt().max(1.0);
|
||||
let steps = (dist / 6.0) as i32;
|
||||
for i in 1..steps {
|
||||
let t = i as f32 / steps as f32;
|
||||
let x = (psx as f32 + dx as f32 * t) as i32;
|
||||
let y = (psy as f32 + dy as f32 * t) as i32;
|
||||
if x >= 0 && x < ui_w && y >= 0 && y < ui_h {
|
||||
let alpha = (180.0 * (1.0 - t * 0.4)) as u8;
|
||||
self.ui
|
||||
.set_alpha(x, y, '.', [255, 200, 80], [0, 0, 0], alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.show_tooltip && !self.inventory_open {
|
||||
let (mx, my) = self.mouse_world_pos;
|
||||
if self.grid.in_bounds(mx, my) {
|
||||
let cell = self.grid.get(mx, my);
|
||||
let temp = self.grid.get_temp(mx, my);
|
||||
let mat_name = match cell.material {
|
||||
MaterialId::Empty => "Empty",
|
||||
MaterialId::Sand => "Sand",
|
||||
MaterialId::Water => "Water",
|
||||
MaterialId::Stone => "Stone",
|
||||
MaterialId::Lava => "Lava",
|
||||
MaterialId::Wood => "Wood",
|
||||
MaterialId::Flesh => "Flesh",
|
||||
MaterialId::Bone => "Bone",
|
||||
MaterialId::Steam => "Steam",
|
||||
MaterialId::Fire => "Fire",
|
||||
MaterialId::Acid => "Acid",
|
||||
MaterialId::Smoke => "Smoke",
|
||||
MaterialId::Grass => "Grass",
|
||||
MaterialId::Dirt => "Dirt",
|
||||
MaterialId::Stairs => "Stairs",
|
||||
};
|
||||
let tip = format!("{} {:.0}C", mat_name, temp);
|
||||
let tx = (self.mouse_ui_x + 4).min(ui_w - 100);
|
||||
let ty = (self.mouse_ui_y + 4).min(ui_h - 20);
|
||||
self.ui.draw_text(tx, ty, &tip, [220, 220, 240], 220);
|
||||
}
|
||||
}
|
||||
|
||||
for e in self.entities.all() {
|
||||
if !e.alive || e.kind == EntityKind::Corpse {
|
||||
continue;
|
||||
@@ -525,7 +529,9 @@ impl Game {
|
||||
pub fn fixed_update(&mut self) {
|
||||
self.tick += 1;
|
||||
|
||||
self.stream_chunks();
|
||||
self.update_active_chunks();
|
||||
self.apply_gas_damage();
|
||||
self.ca.step(&mut self.grid);
|
||||
|
||||
self.update_entities();
|
||||
@@ -546,9 +552,120 @@ impl Game {
|
||||
self.try_spawn_slime();
|
||||
}
|
||||
|
||||
self.spawn_ambient_particles();
|
||||
self.play_ambient_sounds();
|
||||
self.particles.update();
|
||||
self.grid.swap_modified_flags();
|
||||
}
|
||||
|
||||
fn spawn_ambient_particles(&mut self) {
|
||||
let (px, py) = self.player.center(&self.entities);
|
||||
let radius = 30i32;
|
||||
let mut fire_count = 0u32;
|
||||
let mut lava_count = 0u32;
|
||||
let mut water_count = 0u32;
|
||||
for dy in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let x = px as i32 + dx;
|
||||
let y = py as i32 + dy;
|
||||
if !self.grid.in_bounds(x, y) {
|
||||
continue;
|
||||
}
|
||||
let cell = self.grid.get(x, y);
|
||||
match cell.material {
|
||||
MaterialId::Fire => {
|
||||
fire_count += 1;
|
||||
if self.tick % 3 == 0
|
||||
&& (dx * dx + dy * dy) < 400
|
||||
&& self.particles.count() < 1800
|
||||
{
|
||||
let mut rng =
|
||||
(x as u32).wrapping_mul(7919).wrapping_add(self.tick as u32);
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
let off_x = ((rng & 0xFF) as f32 / 255.0 - 0.5) * 2.0;
|
||||
let off_y = ((rng >> 8 & 0xFF) as f32 / 255.0 - 0.5) * 2.0;
|
||||
self.particles.spawn(
|
||||
x as f32 + off_x,
|
||||
y as f32 + off_y,
|
||||
off_x * 0.3,
|
||||
-0.5 - ((rng >> 16) as f32 / 65535.0),
|
||||
15 + (rng % 10),
|
||||
[255, 140 + ((rng >> 4) % 80) as u8, 20, 200],
|
||||
1.0 + ((rng >> 8) % 3) as f32 * 0.5,
|
||||
-0.02,
|
||||
);
|
||||
}
|
||||
}
|
||||
MaterialId::Lava => {
|
||||
lava_count += 1;
|
||||
if self.tick % 20 == 0
|
||||
&& (dx * dx + dy * dy) < 300
|
||||
&& self.particles.count() < 1800
|
||||
{
|
||||
self.particles.spawn_burst(
|
||||
x as f32,
|
||||
y as f32,
|
||||
2,
|
||||
[255, 100, 20, 180],
|
||||
1.5,
|
||||
25,
|
||||
1.5,
|
||||
0.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
MaterialId::Water => {
|
||||
water_count += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = (fire_count, lava_count, water_count);
|
||||
}
|
||||
|
||||
fn play_ambient_sounds(&mut self) {
|
||||
if !self.audio.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let (px, py) = self.player.center(&self.entities);
|
||||
let radius = 15i32;
|
||||
let mut has_lava = false;
|
||||
let mut has_fire = false;
|
||||
let mut has_acid = false;
|
||||
let mut has_water = false;
|
||||
for dy in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let x = px as i32 + dx;
|
||||
let y = py as i32 + dy;
|
||||
if !self.grid.in_bounds(x, y) {
|
||||
continue;
|
||||
}
|
||||
let cell = self.grid.get(x, y);
|
||||
match cell.material {
|
||||
MaterialId::Lava => has_lava = true,
|
||||
MaterialId::Fire => has_fire = true,
|
||||
MaterialId::Acid => has_acid = true,
|
||||
MaterialId::Water => has_water = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_lava && self.tick % 30 == 0 {
|
||||
self.audio.play_throttled("lava_bubble", 500);
|
||||
}
|
||||
if has_fire && self.tick % 15 == 0 {
|
||||
self.audio.play_throttled("fire_crackle", 200);
|
||||
}
|
||||
if has_acid && self.tick % 20 == 0 {
|
||||
self.audio.play_throttled("acid_sizzle", 300);
|
||||
}
|
||||
if has_water && self.tick % 40 == 0 {
|
||||
self.audio.play_throttled("water_splash", 600);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_score(&mut self) {
|
||||
let mut new_kills = 0;
|
||||
for e in self.entities.all_mut() {
|
||||
@@ -566,6 +683,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 {
|
||||
@@ -588,6 +720,7 @@ impl Game {
|
||||
let item = self.items.all_mut().remove(idx);
|
||||
self.ui.add_message(&format!("Picked up {}", item.name()));
|
||||
self.player.inventory.push(item);
|
||||
self.audio.play("pickup");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,7 +737,7 @@ impl Game {
|
||||
return;
|
||||
}
|
||||
self.depth += 1;
|
||||
self.grid = Grid::new();
|
||||
self.grid = ChunkedGrid::with_size(self.grid.width, self.grid.height);
|
||||
self.entities = EntityManager::new();
|
||||
self.player = Player::new(&mut self.entities);
|
||||
self.projectiles = ProjectileManager::new();
|
||||
@@ -613,6 +746,7 @@ impl Game {
|
||||
self.init_world();
|
||||
self.ui
|
||||
.add_message(&format!("Descended to depth {}", self.depth));
|
||||
self.audio.play("descend");
|
||||
}
|
||||
|
||||
pub fn use_item(&mut self, index: usize) {
|
||||
@@ -643,6 +777,7 @@ impl Game {
|
||||
self.player.inventory.remove(index);
|
||||
self.ui.add_message(&format!("Consumed {}", name));
|
||||
}
|
||||
self.audio.play("powerup");
|
||||
}
|
||||
|
||||
pub fn drop_item(&mut self, index: usize) {
|
||||
@@ -655,39 +790,114 @@ impl Game {
|
||||
self.ui.add_message(&format!("Dropped {}", item.name()));
|
||||
}
|
||||
|
||||
fn update_active_chunks(&mut self) {
|
||||
self.grid.deactivate_all();
|
||||
|
||||
for e in self.entities.all() {
|
||||
let (cx, cy) = e.center();
|
||||
self.grid.activate_around(cx as i32, cy as i32, 2);
|
||||
fn stream_chunks(&mut self) {
|
||||
if !self.grid.is_infinite() && self.grid.width <= 2048 {
|
||||
return;
|
||||
}
|
||||
|
||||
for p in self.projectiles.all() {
|
||||
self.grid.activate_around(p.x as i32, p.y as i32, 1);
|
||||
}
|
||||
|
||||
for item in self.items.all() {
|
||||
self.grid.activate_around(item.x, item.y, 1);
|
||||
}
|
||||
|
||||
let (px, py) = self.player.center(&self.entities);
|
||||
let px = px as i32;
|
||||
let py = py as i32;
|
||||
let (pcx, pcy, _, _) = self.grid.chunk_at(px, py);
|
||||
let radius = 2;
|
||||
let chunk_size = self.grid.chunk_size as i32;
|
||||
for cy in 0..self.grid.chunks_y as i32 {
|
||||
for cx in 0..self.grid.chunks_x as i32 {
|
||||
let idx = self.grid.chunk_index(cx, cy);
|
||||
if self.grid.chunks[idx].modified || self.grid.chunks[idx].was_modified {
|
||||
self.grid
|
||||
.activate_around(cx * chunk_size, cy * chunk_size, 1);
|
||||
|
||||
for dy in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let cx = pcx + dx;
|
||||
let cy = pcy + dy;
|
||||
let ox = cx * chunk_size;
|
||||
let oy = cy * chunk_size;
|
||||
if !self.grid.in_bounds(ox, oy) {
|
||||
continue;
|
||||
}
|
||||
self.grid.ensure_chunk(cx, cy);
|
||||
if !self.grid.is_chunk_generated(cx, cy) {
|
||||
WorldGenerator::new(&mut self.ca).generate_chunk(&mut self.grid, cx, cy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref dir) = self.cache_dir {
|
||||
let save_radius = radius + 2;
|
||||
if self.tick % 10 == 0 {
|
||||
let coords = self.grid.all_chunk_coords();
|
||||
for (cx, cy) in coords {
|
||||
let dx = (cx - pcx).abs();
|
||||
let dy = (cy - pcy).abs();
|
||||
if (dx > save_radius || dy > save_radius) && self.grid.is_chunk_modified(cx, cy)
|
||||
{
|
||||
let path = crate::world::chunked_grid::chunk_path(dir, self.seed, cx, cy);
|
||||
let _ = self.grid.save_chunk(path.to_str().unwrap(), cx, cy);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.tick % 120 == 0 {
|
||||
let unload_radius = radius + 4;
|
||||
let to_unload: Vec<(i32, i32)> = self
|
||||
.grid
|
||||
.all_chunk_coords()
|
||||
.into_iter()
|
||||
.filter(|(cx, cy)| {
|
||||
let dx = (cx - pcx).abs();
|
||||
let dy = (cy - pcy).abs();
|
||||
dx > unload_radius || dy > unload_radius
|
||||
})
|
||||
.collect();
|
||||
for (cx, cy) in to_unload {
|
||||
if self.grid.is_chunk_modified(cx, cy) {
|
||||
if let Some(ref dir) = self.cache_dir {
|
||||
let path = crate::world::chunked_grid::chunk_path(dir, self.seed, cx, cy);
|
||||
let _ = self.grid.save_chunk(path.to_str().unwrap(), cx, cy);
|
||||
}
|
||||
}
|
||||
self.grid.unload_chunk(cx, cy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_active_chunks(&mut self) {
|
||||
if !self.grid.is_infinite() {
|
||||
for e in self.entities.all() {
|
||||
let (cx, cy) = e.center();
|
||||
self.grid.activate_around(cx as i32, cy as i32, 1);
|
||||
}
|
||||
for (cx, cy) in self.grid.all_chunk_coords() {
|
||||
if self.grid.get_chunk_dirty(cx, cy).is_some() {
|
||||
self.grid.set_chunk_active(cx, cy, true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
self.grid.deactivate_all();
|
||||
|
||||
for (cx, cy) in self.grid.all_chunk_coords() {
|
||||
self.grid.set_chunk_active(cx, cy, true);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_projectiles(&mut self) {
|
||||
let count_before = self.projectiles.all().len();
|
||||
self.projectiles.update(&self.grid);
|
||||
self.projectiles
|
||||
.resolve_hits(&mut self.grid, self.entities.all_mut(), &mut self.ui);
|
||||
self.projectiles.cull_dead();
|
||||
let count_after = self.projectiles.all().len();
|
||||
if count_after < count_before {
|
||||
let any_fireball = self
|
||||
.projectiles
|
||||
.all()
|
||||
.iter()
|
||||
.any(|p| p.typ == ProjectileType::Fireball);
|
||||
if any_fireball || self.fireball_mode {
|
||||
self.audio.play("explosion");
|
||||
} else {
|
||||
self.audio.play("hit");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn player_shoot(&mut self, dir_x: f32, dir_y: f32) {
|
||||
@@ -717,6 +927,7 @@ impl Game {
|
||||
self.projectiles
|
||||
.spawn(typ, spawn_x, spawn_y, vx, vy, owner, damage_bonus);
|
||||
self.last_shot_tick = self.tick;
|
||||
self.audio.play("shoot");
|
||||
}
|
||||
|
||||
fn update_goblin_ai(&mut self) {
|
||||
@@ -942,6 +1153,7 @@ impl Game {
|
||||
_ => "Enemy hits you!",
|
||||
};
|
||||
self.ui.add_message(msg);
|
||||
self.audio.play("hit");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1386,6 +1598,71 @@ impl Game {
|
||||
}
|
||||
}
|
||||
|
||||
fn find_spawn_location(&self, near_x: i32, near_y: i32, radius: i32) -> Option<(i32, i32)> {
|
||||
for r in 0..=radius {
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
if dx.abs() + dy.abs() != r {
|
||||
continue;
|
||||
}
|
||||
let x = near_x + dx;
|
||||
let y = near_y + dy;
|
||||
let _sc = crate::world::worldgen::WORLD_SCALE;
|
||||
let clear_h = 8;
|
||||
if !self.grid.in_bounds(x, y) || !self.grid.in_bounds(x, y - clear_h) {
|
||||
continue;
|
||||
}
|
||||
if !self.grid.get(x, y).is_empty() || !self.grid.get(x, y + 1).is_solid() {
|
||||
continue;
|
||||
}
|
||||
let mut clear = true;
|
||||
for k in -clear_h..=0 {
|
||||
if !self.grid.get(x, y + k).is_empty() {
|
||||
clear = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if clear {
|
||||
return Some((x, y - clear_h));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_surface_spawn(
|
||||
&self,
|
||||
px: f32,
|
||||
py: f32,
|
||||
offset: i32,
|
||||
height_offset: i32,
|
||||
) -> Option<(i32, i32)> {
|
||||
let spawn_x = px as i32 + offset;
|
||||
if !self.grid.in_bounds(spawn_x, 0) {
|
||||
return None;
|
||||
}
|
||||
let search_top = 0;
|
||||
let search_bottom = if self.grid.is_infinite() {
|
||||
py as i32 + crate::world::worldgen::WORLD_SCALE * 50
|
||||
} else {
|
||||
self.grid.height as i32 - 3
|
||||
};
|
||||
let mut surface_y = search_bottom;
|
||||
for y in search_top..=search_bottom {
|
||||
let cell = self.grid.get(spawn_x, y);
|
||||
if cell.is_solid() && cell.material != MaterialId::Stone {
|
||||
surface_y = y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let spawn_y = surface_y - height_offset;
|
||||
if !self.grid.in_bounds(spawn_x, spawn_y) {
|
||||
return None;
|
||||
}
|
||||
Some((spawn_x, spawn_y))
|
||||
}
|
||||
|
||||
fn try_spawn_goblin(&mut self) {
|
||||
let max_goblins = 3usize + self.depth.min(5) as usize;
|
||||
let alive_goblins = self
|
||||
@@ -1398,33 +1675,27 @@ impl Game {
|
||||
return;
|
||||
}
|
||||
|
||||
let (px, _py) = self.player.center(&self.entities);
|
||||
let spawn_x = px as i32 + if px as i32 % 2 == 0 { 15 } else { -15 };
|
||||
if !self.grid.in_bounds(spawn_x, 0) {
|
||||
return;
|
||||
}
|
||||
let (px, py) = self.player.center(&self.entities);
|
||||
let offset = if px as i32 % 2 == 0 {
|
||||
crate::world::worldgen::WORLD_SCALE * 15
|
||||
} else {
|
||||
-(crate::world::worldgen::WORLD_SCALE * 15)
|
||||
};
|
||||
let spawn = if self.depth <= 3 {
|
||||
self.find_surface_spawn(px, py, offset, crate::world::worldgen::WORLD_SCALE * 5)
|
||||
} else {
|
||||
self.find_spawn_location(px as i32 + offset, py as i32, 3)
|
||||
};
|
||||
|
||||
let mut surface_y = self.grid.height as i32 - 3;
|
||||
for y in 0..self.grid.height as i32 {
|
||||
let cell = self.grid.get(spawn_x, y);
|
||||
if cell.is_solid() && cell.material != MaterialId::Stone {
|
||||
surface_y = y;
|
||||
break;
|
||||
if let Some((spawn_x, spawn_y)) = spawn {
|
||||
let id = self.entities.spawn(EntityKind::Goblin);
|
||||
if let Some(g) = self.entities.get_mut(id) {
|
||||
g.build_humanoid(spawn_x as f32, spawn_y as f32);
|
||||
g.health += self.depth as f32 * 5.0;
|
||||
g.max_health += self.depth as f32 * 5.0;
|
||||
g.strength += self.depth;
|
||||
}
|
||||
}
|
||||
let spawn_y = surface_y - 5;
|
||||
|
||||
if !self.grid.in_bounds(spawn_x, spawn_y) {
|
||||
return;
|
||||
}
|
||||
|
||||
let id = self.entities.spawn(EntityKind::Goblin);
|
||||
if let Some(g) = self.entities.get_mut(id) {
|
||||
g.build_humanoid(spawn_x as f32, spawn_y as f32);
|
||||
g.health += self.depth as f32 * 5.0;
|
||||
g.max_health += self.depth as f32 * 5.0;
|
||||
g.strength += self.depth;
|
||||
}
|
||||
}
|
||||
|
||||
fn try_spawn_slime(&mut self) {
|
||||
@@ -1439,31 +1710,69 @@ impl Game {
|
||||
return;
|
||||
}
|
||||
|
||||
let (px, _py) = self.player.center(&self.entities);
|
||||
let spawn_x = px as i32 + if px as i32 % 2 == 0 { -18 } else { 18 };
|
||||
if !self.grid.in_bounds(spawn_x, 0) {
|
||||
return;
|
||||
}
|
||||
let (px, py) = self.player.center(&self.entities);
|
||||
let offset = if px as i32 % 2 == 0 {
|
||||
-(crate::world::worldgen::WORLD_SCALE * 18)
|
||||
} else {
|
||||
crate::world::worldgen::WORLD_SCALE * 18
|
||||
};
|
||||
let spawn = if self.depth <= 3 {
|
||||
self.find_surface_spawn(px, py, offset, crate::world::worldgen::WORLD_SCALE * 3)
|
||||
} else {
|
||||
self.find_spawn_location(px as i32 + offset, py as i32, 3)
|
||||
};
|
||||
|
||||
let mut surface_y = self.grid.height as i32 - 3;
|
||||
for y in 0..self.grid.height as i32 {
|
||||
let cell = self.grid.get(spawn_x, y);
|
||||
if cell.is_solid() && cell.material != MaterialId::Stone {
|
||||
surface_y = y;
|
||||
break;
|
||||
if let Some((spawn_x, spawn_y)) = spawn {
|
||||
let id = self.entities.spawn(EntityKind::Slime);
|
||||
if let Some(s) = self.entities.get_mut(id) {
|
||||
s.build_humanoid(spawn_x as f32, spawn_y as f32);
|
||||
s.health += self.depth as f32 * 3.0;
|
||||
s.max_health += self.depth as f32 * 3.0;
|
||||
}
|
||||
}
|
||||
let spawn_y = surface_y - 3;
|
||||
}
|
||||
|
||||
if !self.grid.in_bounds(spawn_x, spawn_y) {
|
||||
return;
|
||||
pub fn trigger_explosion(&mut self, x: i32, y: i32, radius: i32, damage: f32) {
|
||||
for dy in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let dist_sq = dx * dx + dy * dy;
|
||||
if dist_sq > radius * radius {
|
||||
continue;
|
||||
}
|
||||
let bx = x + dx;
|
||||
let by = y + dy;
|
||||
if !self.grid.in_bounds(bx, by) {
|
||||
continue;
|
||||
}
|
||||
let cell = self.grid.get(bx, by);
|
||||
if cell.is_solid() && !cell.is_static() {
|
||||
self.grid.set(bx, by, crate::world::cell::Cell::empty());
|
||||
} else if cell.is_empty() {
|
||||
let mut fire = crate::world::cell::Cell::new(MaterialId::Fire);
|
||||
fire.updated_this_tick = true;
|
||||
self.grid.set(bx, by, fire);
|
||||
self.grid.set_temp(bx, by, 600.0);
|
||||
}
|
||||
self.grid.set_pressure(bx, by, 255);
|
||||
self.grid.mark_dirty(bx, by);
|
||||
}
|
||||
}
|
||||
|
||||
let id = self.entities.spawn(EntityKind::Slime);
|
||||
if let Some(s) = self.entities.get_mut(id) {
|
||||
s.build_humanoid(spawn_x as f32, spawn_y as f32);
|
||||
s.health += self.depth as f32 * 3.0;
|
||||
s.max_health += self.depth as f32 * 3.0;
|
||||
self.audio.play("explosion");
|
||||
for e in self.entities.all_mut() {
|
||||
if !e.alive {
|
||||
continue;
|
||||
}
|
||||
let dx = e.cx as i32 - x;
|
||||
let dy = e.cy as i32 - y;
|
||||
let dist = ((dx * dx + dy * dy) as f32).sqrt();
|
||||
if dist < radius as f32 * 1.5 {
|
||||
let dmg = (damage * (1.0 - dist / (radius as f32 * 1.5))).max(0.0);
|
||||
e.take_damage(dmg);
|
||||
let knockback = (1.0 - dist / (radius as f32 * 1.5)).max(0.0) * 5.0;
|
||||
let dir = if dx == 0 { 0.0 } else { dx as f32 / dist };
|
||||
e.set_horizontal_vel(dir * knockback);
|
||||
e.set_vertical_vel(-knockback * 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-6
@@ -82,14 +82,16 @@ impl InputHandler {
|
||||
pub fn start(&mut self) {
|
||||
let (tx, rx) = mpsc::channel::<Event>();
|
||||
self.receiver = Some(rx);
|
||||
self.input_thread = Some(thread::spawn(move || loop {
|
||||
match event::read() {
|
||||
Ok(ev) => {
|
||||
if tx.send(ev).is_err() {
|
||||
break;
|
||||
self.input_thread = Some(thread::spawn(move || {
|
||||
loop {
|
||||
match event::read() {
|
||||
Ok(ev) => {
|
||||
if tx.send(ev).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod ai;
|
||||
pub mod audio;
|
||||
pub mod entity;
|
||||
pub mod game;
|
||||
pub mod input;
|
||||
|
||||
+85
-15
@@ -8,6 +8,7 @@ use verbatim::render::lighting;
|
||||
use verbatim::render::terminal::TerminalRenderer;
|
||||
use verbatim::render::window_input::WindowInput;
|
||||
use verbatim::world::cell::MaterialId;
|
||||
use verbatim::world::chunked_grid::ChunkedGrid;
|
||||
use winit::event::{Event, WindowEvent};
|
||||
use winit::event_loop::{ControlFlow, EventLoop};
|
||||
use winit::window::Window;
|
||||
@@ -42,6 +43,9 @@ struct Cli {
|
||||
#[arg(long, default_value = "benchmark_results.json")]
|
||||
benchmark_output: String,
|
||||
|
||||
#[arg(long, default_value = "surface")]
|
||||
benchmark_biome: String,
|
||||
|
||||
#[arg(long, default_value_t = 5)]
|
||||
tape_interval: u32,
|
||||
|
||||
@@ -58,7 +62,7 @@ trait GpuRenderer {
|
||||
Self: Sized;
|
||||
fn render(
|
||||
&mut self,
|
||||
grid: &verbatim::world::grid::Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &verbatim::entity::EntityManager,
|
||||
items: &verbatim::entity::item::ItemManager,
|
||||
ui: &verbatim::ui::UiLayer,
|
||||
@@ -68,6 +72,8 @@ trait GpuRenderer {
|
||||
);
|
||||
fn grid_w(&self) -> usize;
|
||||
fn grid_h(&self) -> usize;
|
||||
fn cell_pixel_size(&self) -> u32;
|
||||
fn upload_particles(&mut self, particles: &verbatim::physics::particle::ParticleManager);
|
||||
}
|
||||
|
||||
impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer {
|
||||
@@ -76,7 +82,7 @@ impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer {
|
||||
}
|
||||
fn render(
|
||||
&mut self,
|
||||
grid: &verbatim::world::grid::Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &verbatim::entity::EntityManager,
|
||||
items: &verbatim::entity::item::ItemManager,
|
||||
ui: &verbatim::ui::UiLayer,
|
||||
@@ -94,6 +100,12 @@ impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer {
|
||||
fn grid_h(&self) -> usize {
|
||||
verbatim::render::vulkan::VulkanRenderer::grid_h(self)
|
||||
}
|
||||
fn cell_pixel_size(&self) -> u32 {
|
||||
verbatim::render::vulkan::VulkanRenderer::cell_pixel_size(self)
|
||||
}
|
||||
fn upload_particles(&mut self, particles: &verbatim::physics::particle::ParticleManager) {
|
||||
verbatim::render::vulkan::VulkanRenderer::upload_particles(self, particles);
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer {
|
||||
@@ -102,7 +114,7 @@ impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer {
|
||||
}
|
||||
fn render(
|
||||
&mut self,
|
||||
grid: &verbatim::world::grid::Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &verbatim::entity::EntityManager,
|
||||
items: &verbatim::entity::item::ItemManager,
|
||||
ui: &verbatim::ui::UiLayer,
|
||||
@@ -120,6 +132,12 @@ impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer {
|
||||
fn grid_h(&self) -> usize {
|
||||
verbatim::render::graphics::GraphicsRenderer::grid_h(self)
|
||||
}
|
||||
fn cell_pixel_size(&self) -> u32 {
|
||||
verbatim::render::graphics::GraphicsRenderer::cell_pixel_size(self)
|
||||
}
|
||||
fn upload_particles(&mut self, particles: &verbatim::physics::particle::ParticleManager) {
|
||||
verbatim::render::graphics::GraphicsRenderer::upload_particles(self, particles);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
@@ -139,7 +157,7 @@ fn main() {
|
||||
eprintln!("PANIC: {}", info);
|
||||
}));
|
||||
let mut renderer = TerminalRenderer::new();
|
||||
let mut game = Game::new();
|
||||
let mut game = Game::new_random();
|
||||
game.run(&mut renderer);
|
||||
}
|
||||
"ascii" => {
|
||||
@@ -216,13 +234,13 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
|
||||
eprintln!("Vulkan init failed: {e}");
|
||||
eprintln!("Falling back to terminal mode...");
|
||||
let mut renderer = TerminalRenderer::new();
|
||||
let mut game = Game::new();
|
||||
let mut game = Game::new_random();
|
||||
game.run(&mut renderer);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut game = Game::new();
|
||||
let mut game = Game::new_random();
|
||||
game.init_world();
|
||||
|
||||
let mut input = WindowInput::new();
|
||||
@@ -253,6 +271,20 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
|
||||
}
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
input.on_mouse_move(position.x, position.y);
|
||||
let cell_px = renderer.cell_pixel_size() as f64;
|
||||
let vw = renderer.grid_w();
|
||||
let vh = renderer.grid_h();
|
||||
game.mouse_ui_x =
|
||||
(position.x / cell_px * verbatim::ui::UI_SCALE as f64) as i32;
|
||||
game.mouse_ui_y =
|
||||
(position.y / cell_px * verbatim::ui::UI_SCALE as f64) as i32;
|
||||
game.show_tooltip = true;
|
||||
game.show_crosshair = true;
|
||||
if vw > 0 && vh > 0 && cell_px > 0.0 {
|
||||
let wx = game.cam_x + (position.x / cell_px) as i32;
|
||||
let wy = game.cam_y + (position.y / cell_px) as i32;
|
||||
game.mouse_world_pos = (wx, wy);
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
input.on_mouse_button(button, state);
|
||||
@@ -291,8 +323,15 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
|
||||
return;
|
||||
}
|
||||
|
||||
if input.toggle_audio {
|
||||
game.audio.toggle();
|
||||
}
|
||||
|
||||
if input.jump {
|
||||
let on_ground = game.check_on_ground();
|
||||
if on_ground {
|
||||
game.audio.play("jump");
|
||||
}
|
||||
game.player.jump(&mut game.entities, on_ground);
|
||||
}
|
||||
|
||||
@@ -310,9 +349,12 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
|
||||
|| input.shoot_up
|
||||
|| input.shoot_down
|
||||
{
|
||||
let cell_px = renderer.cell_pixel_size() as f64;
|
||||
let (px, py) = game.player.center(&game.entities);
|
||||
let player_screen_x = ((px as i32 - game.cam_x) as f64) * 8.0 + 4.0;
|
||||
let player_screen_y = ((py as i32 - game.cam_y) as f64) * 8.0 + 4.0;
|
||||
let player_screen_x =
|
||||
((px as i32 - game.cam_x) as f64) * cell_px + cell_px / 2.0;
|
||||
let player_screen_y =
|
||||
((py as i32 - game.cam_y) as f64) * cell_px + cell_px / 2.0;
|
||||
|
||||
let (dx, dy) = if input.shoot_mouse {
|
||||
let mx = input.mouse_x - player_screen_x;
|
||||
@@ -497,6 +539,7 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
|
||||
|
||||
game.build_ui(vw, vh);
|
||||
|
||||
renderer.upload_particles(&game.particles);
|
||||
renderer.render(
|
||||
&game.grid,
|
||||
&game.entities,
|
||||
@@ -533,19 +576,25 @@ fn run_benchmark_mode(cli: &Cli) {
|
||||
let ticks = cli.benchmark_ticks;
|
||||
let renderer_type = cli.benchmark_renderer.as_str();
|
||||
let output_path = cli.benchmark_output.as_str();
|
||||
let biome = cli.benchmark_biome.as_str();
|
||||
|
||||
eprintln!("Benchmark: {} ticks, renderer={}", ticks, renderer_type);
|
||||
eprintln!(
|
||||
"Benchmark: {} ticks, renderer={}, biome={}",
|
||||
ticks, renderer_type, biome
|
||||
);
|
||||
|
||||
match renderer_type {
|
||||
"ascii" => run_benchmark_inner::<verbatim::render::vulkan::VulkanRenderer>(
|
||||
ticks,
|
||||
output_path,
|
||||
"ascii",
|
||||
biome,
|
||||
),
|
||||
"graphics" => run_benchmark_inner::<verbatim::render::graphics::GraphicsRenderer>(
|
||||
ticks,
|
||||
output_path,
|
||||
"graphics",
|
||||
biome,
|
||||
),
|
||||
_ => {
|
||||
eprintln!(
|
||||
@@ -557,7 +606,12 @@ fn run_benchmark_mode(cli: &Cli) {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_benchmark_inner<R: GpuRenderer>(ticks: u32, output_path: &str, mode_name: &str) {
|
||||
fn run_benchmark_inner<R: GpuRenderer>(
|
||||
ticks: u32,
|
||||
output_path: &str,
|
||||
mode_name: &str,
|
||||
biome: &str,
|
||||
) {
|
||||
let event_loop = EventLoop::new().expect("Failed to create event loop");
|
||||
let window = event_loop
|
||||
.create_window(
|
||||
@@ -576,9 +630,25 @@ fn run_benchmark_inner<R: GpuRenderer>(ticks: u32, output_path: &str, mode_name:
|
||||
}
|
||||
};
|
||||
|
||||
let mut game = Game::new();
|
||||
let mut game = Game::new_random();
|
||||
game.init_world();
|
||||
|
||||
let chunk_size = game.grid.chunk_size as i32;
|
||||
let (px, _py) = game.player.center(&game.entities);
|
||||
match biome {
|
||||
"caves" => {
|
||||
let cave_y = 2 * chunk_size + chunk_size / 2;
|
||||
game.player
|
||||
.set_position(&mut game.entities, px, cave_y as f32);
|
||||
}
|
||||
"dungeon" => {
|
||||
let dungeon_y = 6 * chunk_size + chunk_size / 2;
|
||||
game.player
|
||||
.set_position(&mut game.entities, px, dungeon_y as f32);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let mut tick_count = 0u32;
|
||||
let mut ca_times_us: Vec<u64> = Vec::with_capacity(ticks as usize);
|
||||
let mut render_times_us: Vec<u64> = Vec::with_capacity(ticks as usize);
|
||||
@@ -908,14 +978,14 @@ fn run_capture(ticks: u32) {
|
||||
}
|
||||
|
||||
fn dump_view(
|
||||
grid: &verbatim::world::grid::Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &verbatim::entity::EntityManager,
|
||||
cam_x: i32,
|
||||
cam_y: i32,
|
||||
vw: usize,
|
||||
vh: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
) -> String {
|
||||
ai::render_view(grid, entities, cam_x, cam_y, vw, vh)
|
||||
ai::render_view(grid, entities, cam_x, cam_y, w, h)
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(i, line)| format!("{:2}{}", (cam_y + i as i32) % 100, line))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::world::cell::MaterialId;
|
||||
use crate::world::grid::Grid;
|
||||
use crate::physics::verlet::SubBody;
|
||||
use crate::world::cell::MaterialId;
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
|
||||
pub struct CollisionResult {
|
||||
pub on_ground: bool,
|
||||
@@ -24,7 +24,7 @@ impl CollisionResult {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_grid_collision(grid: &Grid, body: &mut SubBody) -> CollisionResult {
|
||||
pub fn resolve_grid_collision(grid: &ChunkedGrid, body: &mut SubBody) -> CollisionResult {
|
||||
let mut result = CollisionResult::none();
|
||||
|
||||
let r = body.radius;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod collision;
|
||||
pub mod particle;
|
||||
pub mod projectile;
|
||||
pub mod verlet;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Particle {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub vx: f32,
|
||||
pub vy: f32,
|
||||
pub life: u32,
|
||||
pub max_life: u32,
|
||||
pub color: [u8; 4],
|
||||
pub size: f32,
|
||||
pub gravity: f32,
|
||||
}
|
||||
|
||||
impl Particle {
|
||||
pub fn alive(&self) -> bool {
|
||||
self.life > 0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ParticleManager {
|
||||
particles: Vec<Particle>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl ParticleManager {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
particles: Vec::with_capacity(capacity),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn(
|
||||
&mut self,
|
||||
x: f32,
|
||||
y: f32,
|
||||
vx: f32,
|
||||
vy: f32,
|
||||
life: u32,
|
||||
color: [u8; 4],
|
||||
size: f32,
|
||||
gravity: f32,
|
||||
) {
|
||||
if self.particles.len() >= self.capacity {
|
||||
return;
|
||||
}
|
||||
self.particles.push(Particle {
|
||||
x,
|
||||
y,
|
||||
vx,
|
||||
vy,
|
||||
life,
|
||||
max_life: life,
|
||||
color,
|
||||
size,
|
||||
gravity,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn spawn_burst(
|
||||
&mut self,
|
||||
x: f32,
|
||||
y: f32,
|
||||
count: usize,
|
||||
color: [u8; 4],
|
||||
speed: f32,
|
||||
life: u32,
|
||||
size: f32,
|
||||
gravity: f32,
|
||||
) {
|
||||
let mut rng = 0x12345u32;
|
||||
for _ in 0..count {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
let angle = (rng as f32 / u32::MAX as f32) * std::f32::consts::TAU;
|
||||
let s = speed * (0.3 + 0.7 * ((rng >> 16) as f32 / u16::MAX as f32));
|
||||
let vx = angle.cos() * s;
|
||||
let vy = angle.sin() * s - speed * 0.3;
|
||||
self.spawn(x, y, vx, vy, life, color, size, gravity);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self) {
|
||||
for p in &mut self.particles {
|
||||
p.life = p.life.saturating_sub(1);
|
||||
p.vy += p.gravity;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vx *= 0.96;
|
||||
p.vy *= 0.96;
|
||||
}
|
||||
self.particles.retain(|p| p.alive());
|
||||
}
|
||||
|
||||
pub fn all(&self) -> &[Particle] {
|
||||
&self.particles
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.particles.clear();
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.particles.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ParticleManager {
|
||||
fn default() -> Self {
|
||||
Self::new(2000)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::entity::entity::{Entity, EntityId};
|
||||
use crate::world::cell::{Cell, MaterialId};
|
||||
use crate::world::grid::Grid;
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ProjectileType {
|
||||
@@ -65,7 +65,7 @@ impl Projectile {
|
||||
self.damage + self.damage_bonus
|
||||
}
|
||||
|
||||
pub fn update(&mut self, grid: &Grid) {
|
||||
pub fn update(&mut self, grid: &ChunkedGrid) {
|
||||
if !self.alive {
|
||||
return;
|
||||
}
|
||||
@@ -118,7 +118,12 @@ impl Projectile {
|
||||
dx.abs() < hit_w && dy.abs() < hit_h
|
||||
}
|
||||
|
||||
pub fn apply_impact(&self, grid: &mut Grid, entity: &mut Entity, ui: &mut crate::ui::UiLayer) {
|
||||
pub fn apply_impact(
|
||||
&self,
|
||||
grid: &mut ChunkedGrid,
|
||||
entity: &mut Entity,
|
||||
ui: &mut crate::ui::UiLayer,
|
||||
) {
|
||||
if self.typ == ProjectileType::Fireball {
|
||||
let min_x = (self.x - 1.5).floor() as i32;
|
||||
let max_x = (self.x + 1.5).ceil() as i32;
|
||||
@@ -131,15 +136,18 @@ impl Projectile {
|
||||
}
|
||||
let cell = grid.get(x, y);
|
||||
if cell.is_empty() {
|
||||
grid.set(x, y, Cell::new(MaterialId::Fire));
|
||||
let mut fire = Cell::new(MaterialId::Fire);
|
||||
fire.updated_this_tick = true;
|
||||
grid.set(x, y, fire);
|
||||
} else if cell.material == MaterialId::Wood
|
||||
|| cell.material == MaterialId::Grass
|
||||
|| cell.material == MaterialId::Flesh
|
||||
{
|
||||
let mut ignited = cell;
|
||||
ignited.material = MaterialId::Fire;
|
||||
ignited.temp = 400.0;
|
||||
ignited.updated_this_tick = true;
|
||||
grid.set(x, y, ignited);
|
||||
grid.set_temp(x, y, 400.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,7 +234,7 @@ impl ProjectileManager {
|
||||
self.spawn(ProjectileType::Arrow, x, y, vx, vy, owner, 0.0)
|
||||
}
|
||||
|
||||
pub fn update(&mut self, grid: &Grid) {
|
||||
pub fn update(&mut self, grid: &ChunkedGrid) {
|
||||
for p in &mut self.projectiles {
|
||||
p.update(grid);
|
||||
}
|
||||
@@ -234,7 +242,7 @@ impl ProjectileManager {
|
||||
|
||||
pub fn resolve_hits(
|
||||
&mut self,
|
||||
grid: &mut Grid,
|
||||
grid: &mut ChunkedGrid,
|
||||
entities: &mut [Entity],
|
||||
ui: &mut crate::ui::UiLayer,
|
||||
) {
|
||||
|
||||
@@ -3,14 +3,14 @@ use crate::entity::EntityManager;
|
||||
use crate::render::lighting;
|
||||
use crate::ui::UiLayer;
|
||||
use crate::world::cell::MaterialId;
|
||||
use crate::world::grid::Grid;
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
use image::{ImageBuffer, RgbImage};
|
||||
|
||||
pub const CELL_SIZE: u32 = 8;
|
||||
pub const UI_CELL_SIZE: u32 = 2;
|
||||
|
||||
pub fn capture_frame(
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
items: &ItemManager,
|
||||
ui: &UiLayer,
|
||||
@@ -145,7 +145,7 @@ fn entity_positions(
|
||||
|
||||
fn shadow_positions(
|
||||
entity_positions: &std::collections::HashMap<(i32, i32), [u8; 3]>,
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
cam_x: i32,
|
||||
cam_y: i32,
|
||||
view_w: u32,
|
||||
@@ -203,7 +203,7 @@ fn draw_cell(img: &mut RgbImage, vx: u32, vy: u32, color: [u8; 3]) {
|
||||
|
||||
pub fn save_capture(
|
||||
path: &str,
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
items: &ItemManager,
|
||||
ui: &UiLayer,
|
||||
@@ -221,7 +221,7 @@ pub fn save_capture(
|
||||
}
|
||||
|
||||
pub fn capture_from_state(
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
items: &ItemManager,
|
||||
ui: &UiLayer,
|
||||
|
||||
+131
-17
@@ -5,7 +5,8 @@ use std::sync::Arc;
|
||||
use crate::entity::EntityManager;
|
||||
use crate::render::lighting;
|
||||
use crate::world::cell::MaterialId;
|
||||
use crate::world::grid::{Grid, WORLD_H, WORLD_W};
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
use crate::world::grid::{MAX_WORLD_H, MAX_WORLD_W};
|
||||
|
||||
const CHAR_W: u32 = 8;
|
||||
const CHAR_H: u32 = 8;
|
||||
@@ -45,6 +46,14 @@ struct ColorInstance {
|
||||
color: [u8; 4],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct ParticleInstance {
|
||||
grid_x: f32,
|
||||
grid_y: f32,
|
||||
color: [u8; 4],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(bytemuck::NoUninit, Clone, Copy, Default)]
|
||||
struct GpuLightSource {
|
||||
@@ -114,6 +123,12 @@ pub struct GraphicsRenderer {
|
||||
ui_instance_ptr: *mut ColorInstance,
|
||||
ui_instance_capacity: usize,
|
||||
|
||||
particle_instance_buffer: vk::Buffer,
|
||||
particle_instance_memory: vk::DeviceMemory,
|
||||
particle_instance_ptr: *mut ParticleInstance,
|
||||
particle_instance_capacity: usize,
|
||||
particle_count: usize,
|
||||
|
||||
grid_buffer: vk::Buffer,
|
||||
grid_memory: vk::DeviceMemory,
|
||||
grid_ptr: *mut u32,
|
||||
@@ -552,13 +567,14 @@ impl GraphicsRenderer {
|
||||
Ok((buf, mem))
|
||||
};
|
||||
|
||||
let grid_data = vec![0u32; WORLD_W * WORLD_H];
|
||||
let grid_count = MAX_WORLD_W * MAX_WORLD_H;
|
||||
let grid_data = vec![0u32; grid_count];
|
||||
let (grid_buffer, grid_memory) = make_buf(
|
||||
bytemuck::cast_slice(&grid_data),
|
||||
vk::BufferUsageFlags::STORAGE_BUFFER,
|
||||
)?;
|
||||
let grid_ptr = unsafe {
|
||||
let sz = (WORLD_W * WORLD_H * std::mem::size_of::<u32>()) as vk::DeviceSize;
|
||||
let sz = (grid_count * std::mem::size_of::<u32>()) as vk::DeviceSize;
|
||||
let ptr = device
|
||||
.map_memory(grid_memory, 0, sz, vk::MemoryMapFlags::default())
|
||||
.map_err(|e| format!("map grid: {e:?}"))?;
|
||||
@@ -602,7 +618,7 @@ impl GraphicsRenderer {
|
||||
let grid_info = vk::DescriptorBufferInfo::default()
|
||||
.buffer(grid_buffer)
|
||||
.offset(0)
|
||||
.range((WORLD_W * WORLD_H * std::mem::size_of::<u32>()) as vk::DeviceSize);
|
||||
.range((MAX_WORLD_W * MAX_WORLD_H * std::mem::size_of::<u32>()) as vk::DeviceSize);
|
||||
let light_info = vk::DescriptorBufferInfo::default()
|
||||
.buffer(light_buffer)
|
||||
.offset(0)
|
||||
@@ -704,6 +720,44 @@ impl GraphicsRenderer {
|
||||
ptr as *mut ColorInstance
|
||||
};
|
||||
|
||||
let part_capacity = 2000usize;
|
||||
let part_inst_sz =
|
||||
(part_capacity * std::mem::size_of::<ParticleInstance>()) as vk::DeviceSize;
|
||||
let pibi = vk::BufferCreateInfo::default()
|
||||
.size(part_inst_sz)
|
||||
.usage(vk::BufferUsageFlags::VERTEX_BUFFER)
|
||||
.sharing_mode(vk::SharingMode::EXCLUSIVE);
|
||||
let particle_instance_buffer = unsafe { device.create_buffer(&pibi, None) }
|
||||
.map_err(|e| format!("part inst buf: {e:?}"))?;
|
||||
let preq = unsafe { device.get_buffer_memory_requirements(particle_instance_buffer) };
|
||||
let pmt = find_mem(
|
||||
preq.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
|
||||
)?;
|
||||
let particle_instance_memory = unsafe {
|
||||
device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(preq.size)
|
||||
.memory_type_index(pmt),
|
||||
None,
|
||||
)
|
||||
}
|
||||
.map_err(|e| format!("part inst mem: {e:?}"))?;
|
||||
let particle_instance_ptr = unsafe {
|
||||
device
|
||||
.bind_buffer_memory(particle_instance_buffer, particle_instance_memory, 0)
|
||||
.expect("bind part inst");
|
||||
let ptr = device
|
||||
.map_memory(
|
||||
particle_instance_memory,
|
||||
0,
|
||||
part_inst_sz,
|
||||
vk::MemoryMapFlags::default(),
|
||||
)
|
||||
.expect("map part inst");
|
||||
ptr as *mut ParticleInstance
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
grid_w,
|
||||
grid_h,
|
||||
@@ -740,6 +794,11 @@ impl GraphicsRenderer {
|
||||
ui_instance_memory,
|
||||
ui_instance_ptr,
|
||||
ui_instance_capacity: ui_capacity,
|
||||
particle_instance_buffer,
|
||||
particle_instance_memory,
|
||||
particle_instance_ptr,
|
||||
particle_instance_capacity: part_capacity,
|
||||
particle_count: 0,
|
||||
|
||||
grid_buffer,
|
||||
grid_memory,
|
||||
@@ -764,7 +823,7 @@ impl GraphicsRenderer {
|
||||
|
||||
pub fn render(
|
||||
&mut self,
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
items: &crate::entity::item::ItemManager,
|
||||
ui: &crate::ui::UiLayer,
|
||||
@@ -921,16 +980,12 @@ impl GraphicsRenderer {
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let margin = 30i32;
|
||||
let x_min = (cam_x - margin).max(0) as usize;
|
||||
let x_max = (cam_x + self.grid_w as i32 + margin).min(WORLD_W as i32) as usize;
|
||||
let y_min = (cam_y - margin).max(0) as usize;
|
||||
let y_max = (cam_y + self.grid_h as i32 + margin).min(WORLD_H as i32) as usize;
|
||||
for y in y_min..y_max {
|
||||
let row_offset = y * WORLD_W;
|
||||
for x in x_min..x_max {
|
||||
let i = row_offset + x;
|
||||
*self.grid_ptr.add(i) = grid.cells[i].material as u32;
|
||||
for dy in 0..self.grid_h {
|
||||
for dx in 0..self.grid_w {
|
||||
let wx = cam_x + dx as i32;
|
||||
let wy = cam_y + dy as i32;
|
||||
let idx = dy * self.grid_w + dx;
|
||||
*self.grid_ptr.add(idx) = grid.get(wx, wy).material as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1033,7 +1088,7 @@ impl GraphicsRenderer {
|
||||
self.swapchain_extent.height as f32,
|
||||
],
|
||||
cell_size: [CHAR_W as f32, CHAR_H as f32],
|
||||
world_size: [WORLD_W as i32, WORLD_H as i32],
|
||||
world_size: [self.grid_w as i32, self.grid_h as i32],
|
||||
cam_pos: [cam_x, cam_y],
|
||||
ambient,
|
||||
is_ui: 0,
|
||||
@@ -1062,7 +1117,7 @@ impl GraphicsRenderer {
|
||||
self.swapchain_extent.height as f32,
|
||||
],
|
||||
cell_size: [UI_CELL_SIZE as f32, UI_CELL_SIZE as f32],
|
||||
world_size: [WORLD_W as i32, WORLD_H as i32],
|
||||
world_size: [self.grid_w as i32, self.grid_h as i32],
|
||||
cam_pos: [0, 0],
|
||||
ambient: [1.0, 1.0, 1.0],
|
||||
is_ui: 1,
|
||||
@@ -1078,6 +1133,35 @@ impl GraphicsRenderer {
|
||||
device.cmd_draw_indexed(cmd, 6, ui_count as u32, 0, 0, 0);
|
||||
}
|
||||
|
||||
if self.particle_count > 0 {
|
||||
device.cmd_bind_vertex_buffers(
|
||||
cmd,
|
||||
0,
|
||||
&[self.vertex_buffer, self.particle_instance_buffer],
|
||||
&[0, 0],
|
||||
);
|
||||
let part_pc = PushConstants {
|
||||
screen_size: [
|
||||
self.swapchain_extent.width as f32,
|
||||
self.swapchain_extent.height as f32,
|
||||
],
|
||||
cell_size: [CHAR_W as f32, CHAR_H as f32],
|
||||
world_size: [self.grid_w as i32, self.grid_h as i32],
|
||||
cam_pos: [cam_x, cam_y],
|
||||
ambient: [1.0, 1.0, 1.0],
|
||||
is_ui: 2,
|
||||
light_count: 0,
|
||||
};
|
||||
device.cmd_push_constants(
|
||||
cmd,
|
||||
self.pipeline_layout,
|
||||
vk::ShaderStageFlags::VERTEX,
|
||||
0,
|
||||
bytemuck::bytes_of(&part_pc),
|
||||
);
|
||||
device.cmd_draw_indexed(cmd, 6, self.particle_count as u32, 0, 0, 0);
|
||||
}
|
||||
|
||||
device.cmd_end_render_pass(cmd);
|
||||
let _ = device.end_command_buffer(cmd);
|
||||
|
||||
@@ -1113,6 +1197,33 @@ impl GraphicsRenderer {
|
||||
self.grid_h
|
||||
}
|
||||
|
||||
pub fn cell_pixel_size(&self) -> u32 {
|
||||
CHAR_W
|
||||
}
|
||||
|
||||
pub fn upload_particles(&mut self, particles: &crate::physics::particle::ParticleManager) {
|
||||
let instances = unsafe {
|
||||
std::slice::from_raw_parts_mut(
|
||||
self.particle_instance_ptr,
|
||||
self.particle_instance_capacity,
|
||||
)
|
||||
};
|
||||
let mut count = 0usize;
|
||||
for p in particles.all() {
|
||||
if count >= self.particle_instance_capacity {
|
||||
break;
|
||||
}
|
||||
let alpha = ((p.life as f32 / p.max_life as f32) * 255.0) as u8;
|
||||
instances[count] = ParticleInstance {
|
||||
grid_x: p.x,
|
||||
grid_y: p.y,
|
||||
color: [p.color[0], p.color[1], p.color[2], alpha],
|
||||
};
|
||||
count += 1;
|
||||
}
|
||||
self.particle_count = count;
|
||||
}
|
||||
|
||||
fn check_resize(&mut self) {
|
||||
let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance);
|
||||
let caps = match unsafe {
|
||||
@@ -1344,6 +1455,9 @@ impl Drop for GraphicsRenderer {
|
||||
self.device.free_memory(self.instance_memory, None);
|
||||
self.device.destroy_buffer(self.ui_instance_buffer, None);
|
||||
self.device.free_memory(self.ui_instance_memory, None);
|
||||
self.device
|
||||
.destroy_buffer(self.particle_instance_buffer, None);
|
||||
self.device.free_memory(self.particle_instance_memory, None);
|
||||
self.device.destroy_buffer(self.vertex_buffer, None);
|
||||
self.device.free_memory(self.vertex_memory, None);
|
||||
self.device.destroy_buffer(self.index_buffer, None);
|
||||
|
||||
+28
-11
@@ -1,5 +1,5 @@
|
||||
use crate::world::cell::MaterialId;
|
||||
use crate::world::grid::Grid;
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct LightSource {
|
||||
@@ -67,7 +67,7 @@ pub fn material_light(material: MaterialId) -> Option<LightSource> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gather_sources(grid: &Grid) -> Vec<LightSource> {
|
||||
pub fn gather_sources(grid: &ChunkedGrid) -> Vec<LightSource> {
|
||||
let mut sources = Vec::new();
|
||||
let w = grid.width;
|
||||
let h = grid.height;
|
||||
@@ -85,7 +85,7 @@ pub fn gather_sources(grid: &Grid) -> Vec<LightSource> {
|
||||
}
|
||||
|
||||
pub fn gather_sources_in_range(
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
cam_x: i32,
|
||||
cam_y: i32,
|
||||
view_w: usize,
|
||||
@@ -94,9 +94,17 @@ pub fn gather_sources_in_range(
|
||||
) -> Vec<LightSource> {
|
||||
let mut sources = Vec::new();
|
||||
let min_x = (cam_x - margin).max(0);
|
||||
let max_x = (cam_x + view_w as i32 + margin).min(grid.width as i32);
|
||||
let max_x = if grid.is_infinite() {
|
||||
cam_x + view_w as i32 + margin
|
||||
} else {
|
||||
(cam_x + view_w as i32 + margin).min(grid.width as i32)
|
||||
};
|
||||
let min_y = (cam_y - margin).max(0);
|
||||
let max_y = (cam_y + view_h as i32 + margin).min(grid.height as i32);
|
||||
let max_y = if grid.is_infinite() {
|
||||
cam_y + view_h as i32 + margin
|
||||
} else {
|
||||
(cam_y + view_h as i32 + margin).min(grid.height as i32)
|
||||
};
|
||||
for y in min_y..max_y {
|
||||
for x in min_x..max_x {
|
||||
let cell = grid.get(x, y);
|
||||
@@ -111,7 +119,7 @@ pub fn gather_sources_in_range(
|
||||
}
|
||||
|
||||
pub fn compute_lighting(
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
cam_x: i32,
|
||||
cam_y: i32,
|
||||
view_w: usize,
|
||||
@@ -176,7 +184,7 @@ pub fn compute_lighting(
|
||||
grid_light
|
||||
}
|
||||
|
||||
pub fn line_of_sight(grid: &Grid, x0: i32, y0: i32, x1: i32, y1: i32) -> bool {
|
||||
pub fn line_of_sight(grid: &ChunkedGrid, x0: i32, y0: i32, x1: i32, y1: i32) -> bool {
|
||||
let mut x = x0;
|
||||
let mut y = y0;
|
||||
let dx = (x1 - x0).abs();
|
||||
@@ -233,13 +241,22 @@ pub fn ambient_light() -> [u8; 3] {
|
||||
[160, 160, 180]
|
||||
}
|
||||
|
||||
pub fn ambient_light_at_tick(tick: u64) -> [u8; 3] {
|
||||
let phase = (tick as f32 * 0.001).sin();
|
||||
let brightness = 0.5 + 0.5 * phase;
|
||||
let r = (80.0 + 100.0 * brightness) as u8;
|
||||
let g = (80.0 + 100.0 * brightness) as u8;
|
||||
let b = (100.0 + 100.0 * brightness) as u8;
|
||||
[r, g, b]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::world::grid::Grid;
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
|
||||
fn grid_with_lava() -> (Grid, i32, i32) {
|
||||
let mut grid = Grid::new();
|
||||
fn grid_with_lava() -> (ChunkedGrid, i32, i32) {
|
||||
let mut grid = ChunkedGrid::with_size(250, 250);
|
||||
grid.set_material(10, 10, MaterialId::Lava);
|
||||
(grid, 10, 10)
|
||||
}
|
||||
@@ -267,7 +284,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn walls_block_light() {
|
||||
let mut grid = Grid::new();
|
||||
let mut grid = ChunkedGrid::with_size(250, 250);
|
||||
grid.set_material(5, 10, MaterialId::Lava);
|
||||
for y in 7..13 {
|
||||
grid.set_material(8, y, MaterialId::Stone);
|
||||
|
||||
+2
-2
@@ -8,13 +8,13 @@ pub mod window_input;
|
||||
use crate::entity::item::ItemManager;
|
||||
use crate::entity::EntityManager;
|
||||
use crate::ui::UiLayer;
|
||||
use crate::world::grid::Grid;
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
|
||||
pub trait Renderer {
|
||||
fn init(&mut self) -> std::io::Result<()>;
|
||||
fn render(
|
||||
&mut self,
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
items: &ItemManager,
|
||||
ui: &UiLayer,
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::entity::EntityManager;
|
||||
use crate::render::lighting::{self, apply_light_tuple, LightGrid};
|
||||
use crate::render::Renderer;
|
||||
use crate::world::cell::MaterialId;
|
||||
use crate::world::grid::Grid;
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
|
||||
fn entity_priority(kind: crate::entity::EntityKind) -> u32 {
|
||||
match kind {
|
||||
@@ -96,7 +96,7 @@ impl Renderer for TerminalRenderer {
|
||||
|
||||
fn render(
|
||||
&mut self,
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
items: &crate::entity::item::ItemManager,
|
||||
ui: &crate::ui::UiLayer,
|
||||
|
||||
+20
-17
@@ -6,7 +6,8 @@ use std::sync::Arc;
|
||||
use crate::entity::{EntityKind, EntityManager};
|
||||
use crate::render::lighting;
|
||||
use crate::world::cell::MaterialId;
|
||||
use crate::world::grid::{Grid, WORLD_H, WORLD_W};
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
use crate::world::grid::{MAX_WORLD_H, MAX_WORLD_W};
|
||||
|
||||
const CHAR_W: u32 = 8;
|
||||
const CHAR_H: u32 = 8;
|
||||
@@ -225,7 +226,7 @@ impl VulkanRenderer {
|
||||
let (ui_instance_buffer, ui_instance_memory, ui_instance_ptr) =
|
||||
create_instance_buffer(&device, &instance, physical_device, ui_instance_capacity)?;
|
||||
|
||||
let grid_data = vec![0u32; WORLD_W * WORLD_H];
|
||||
let grid_data = vec![0u32; MAX_WORLD_W * MAX_WORLD_H];
|
||||
let (grid_buffer, grid_memory) = create_buffer_with_data(
|
||||
&device,
|
||||
&instance,
|
||||
@@ -234,7 +235,7 @@ impl VulkanRenderer {
|
||||
vk::BufferUsageFlags::STORAGE_BUFFER,
|
||||
)?;
|
||||
let grid_ptr = unsafe {
|
||||
let sz = (WORLD_W * WORLD_H * std::mem::size_of::<u32>()) as vk::DeviceSize;
|
||||
let sz = (MAX_WORLD_W * MAX_WORLD_H * std::mem::size_of::<u32>()) as vk::DeviceSize;
|
||||
let ptr = device
|
||||
.map_memory(grid_memory, 0, sz, vk::MemoryMapFlags::default())
|
||||
.map_err(|e| format!("map grid: {e:?}"))?;
|
||||
@@ -339,7 +340,7 @@ impl VulkanRenderer {
|
||||
|
||||
pub fn render(
|
||||
&mut self,
|
||||
grid: &Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &EntityManager,
|
||||
items: &crate::entity::item::ItemManager,
|
||||
ui: &crate::ui::UiLayer,
|
||||
@@ -441,16 +442,12 @@ impl VulkanRenderer {
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let margin = 30i32;
|
||||
let x_min = (cam_x - margin).max(0) as usize;
|
||||
let x_max = (cam_x + self.grid_w as i32 + margin).min(WORLD_W as i32) as usize;
|
||||
let y_min = (cam_y - margin).max(0) as usize;
|
||||
let y_max = (cam_y + self.grid_h as i32 + margin).min(WORLD_H as i32) as usize;
|
||||
for y in y_min..y_max {
|
||||
let row_offset = y * WORLD_W;
|
||||
for x in x_min..x_max {
|
||||
let i = row_offset + x;
|
||||
*self.grid_ptr.add(i) = grid.cells[i].material as u32;
|
||||
for dy in 0..self.grid_h {
|
||||
for dx in 0..self.grid_w {
|
||||
let wx = cam_x + dx as i32;
|
||||
let wy = cam_y + dy as i32;
|
||||
let idx = dy * self.grid_w + dx;
|
||||
*self.grid_ptr.add(idx) = grid.get(wx, wy).material as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -627,7 +624,7 @@ impl VulkanRenderer {
|
||||
self.swapchain_extent.height as f32,
|
||||
],
|
||||
cell_size: [CHAR_W as f32, CHAR_H as f32],
|
||||
world_size: [WORLD_W as i32, WORLD_H as i32],
|
||||
world_size: [self.grid_w as i32, self.grid_h as i32],
|
||||
cam_pos: [cam_x, cam_y],
|
||||
ambient: [
|
||||
ambient[0] as f32 / 255.0,
|
||||
@@ -660,7 +657,7 @@ impl VulkanRenderer {
|
||||
self.swapchain_extent.height as f32,
|
||||
],
|
||||
cell_size: [UI_CELL_SIZE as f32, UI_CELL_SIZE as f32],
|
||||
world_size: [WORLD_W as i32, WORLD_H as i32],
|
||||
world_size: [self.grid_w as i32, self.grid_h as i32],
|
||||
cam_pos: [0, 0],
|
||||
ambient: [0.0, 0.0, 0.0],
|
||||
is_ui: 1,
|
||||
@@ -711,6 +708,12 @@ impl VulkanRenderer {
|
||||
self.grid_h
|
||||
}
|
||||
|
||||
pub fn cell_pixel_size(&self) -> u32 {
|
||||
CHAR_W
|
||||
}
|
||||
|
||||
pub fn upload_particles(&mut self, _particles: &crate::physics::particle::ParticleManager) {}
|
||||
|
||||
fn check_resize(&mut self) {
|
||||
let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance);
|
||||
let caps = match unsafe {
|
||||
@@ -1824,7 +1827,7 @@ fn update_descriptor_set(
|
||||
let bi = vk::DescriptorBufferInfo::default()
|
||||
.buffer(grid_buffer)
|
||||
.offset(0)
|
||||
.range((WORLD_W * WORLD_H * std::mem::size_of::<u32>()) as vk::DeviceSize);
|
||||
.range((MAX_WORLD_W * MAX_WORLD_H * std::mem::size_of::<u32>()) as vk::DeviceSize);
|
||||
let li = vk::DescriptorBufferInfo::default()
|
||||
.buffer(light_buffer)
|
||||
.offset(0)
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct WindowInput {
|
||||
pub cam_up: bool,
|
||||
pub cam_down: bool,
|
||||
pub quit: bool,
|
||||
pub toggle_audio: bool,
|
||||
pub paint: Option<u8>,
|
||||
pub mouse_x: f64,
|
||||
pub mouse_y: f64,
|
||||
@@ -39,6 +40,7 @@ pub struct WindowInput {
|
||||
descend_was_down: bool,
|
||||
use_item_was_down: bool,
|
||||
drop_item_was_down: bool,
|
||||
audio_was_down: bool,
|
||||
down_keys: HashSet<KeyCode>,
|
||||
}
|
||||
|
||||
@@ -61,6 +63,7 @@ impl WindowInput {
|
||||
cam_up: false,
|
||||
cam_down: false,
|
||||
quit: false,
|
||||
toggle_audio: false,
|
||||
paint: None,
|
||||
mouse_x: 0.0,
|
||||
mouse_y: 0.0,
|
||||
@@ -82,6 +85,7 @@ impl WindowInput {
|
||||
descend_was_down: false,
|
||||
use_item_was_down: false,
|
||||
drop_item_was_down: false,
|
||||
audio_was_down: false,
|
||||
down_keys: HashSet::new(),
|
||||
}
|
||||
}
|
||||
@@ -202,6 +206,9 @@ impl WindowInput {
|
||||
self.cam_up = keys.contains(&KeyCode::KeyI);
|
||||
self.cam_down = keys.contains(&KeyCode::KeyO);
|
||||
self.quit = keys.contains(&KeyCode::KeyQ) || keys.contains(&KeyCode::Escape);
|
||||
let now_audio = keys.contains(&KeyCode::KeyM);
|
||||
self.toggle_audio = now_audio && !self.audio_was_down;
|
||||
self.audio_was_down = now_audio;
|
||||
|
||||
self.paint = None;
|
||||
if keys.contains(&KeyCode::Digit1) {
|
||||
|
||||
+158
-69
@@ -1,9 +1,10 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::entity::entity::{Entity, EntityKind};
|
||||
use crate::world::cell::MaterialId;
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
|
||||
pub const UI_SCALE: i32 = 4;
|
||||
pub const FONT_W: i32 = 3;
|
||||
pub const FONT_H: i32 = 5;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UiCell {
|
||||
@@ -14,9 +15,13 @@ pub struct UiCell {
|
||||
}
|
||||
|
||||
pub struct UiLayer {
|
||||
cells: HashMap<(i32, i32), UiCell>,
|
||||
cells: Vec<Option<UiCell>>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
dirty_keys: Vec<(i32, i32)>,
|
||||
messages: Vec<(String, u32)>,
|
||||
damage_numbers: Vec<DamageNumber>,
|
||||
font_scale: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -31,14 +36,39 @@ pub struct DamageNumber {
|
||||
impl UiLayer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cells: HashMap::new(),
|
||||
cells: Vec::new(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
dirty_keys: Vec::new(),
|
||||
messages: Vec::new(),
|
||||
damage_numbers: Vec::new(),
|
||||
font_scale: 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_font_scale(&mut self, scale: i32) {
|
||||
self.font_scale = scale.max(1);
|
||||
}
|
||||
|
||||
pub fn font_scale(&self) -> i32 {
|
||||
self.font_scale
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn fw(&self) -> i32 {
|
||||
FONT_W * self.font_scale
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn fh(&self) -> i32 {
|
||||
FONT_H * self.font_scale
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.cells.clear();
|
||||
for c in self.cells.iter_mut() {
|
||||
*c = None;
|
||||
}
|
||||
self.dirty_keys.clear();
|
||||
self.damage_numbers.retain(|d| d.life > 0);
|
||||
for d in &mut self.damage_numbers {
|
||||
d.y -= 0.1;
|
||||
@@ -46,28 +76,56 @@ impl UiLayer {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, w: usize, h: usize) {
|
||||
if self.width == w && self.height == h && !self.cells.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.width = w;
|
||||
self.height = h;
|
||||
self.cells.clear();
|
||||
self.cells.resize(w * h, None);
|
||||
self.dirty_keys.clear();
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn idx(&self, x: i32, y: i32) -> Option<usize> {
|
||||
if x < 0 || y < 0 || x as usize >= self.width || y as usize >= self.height {
|
||||
return None;
|
||||
}
|
||||
Some(y as usize * self.width + x as usize)
|
||||
}
|
||||
|
||||
pub fn set(&mut self, x: i32, y: i32, ch: char, fg: [u8; 3], bg: [u8; 3]) {
|
||||
self.cells.insert(
|
||||
(x, y),
|
||||
UiCell {
|
||||
if let Some(i) = self.idx(x, y) {
|
||||
let was_none = self.cells[i].is_none();
|
||||
self.cells[i] = Some(UiCell {
|
||||
ch,
|
||||
fg,
|
||||
bg,
|
||||
alpha: 255,
|
||||
},
|
||||
);
|
||||
});
|
||||
if was_none {
|
||||
self.dirty_keys.push((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_alpha(&mut self, x: i32, y: i32, ch: char, fg: [u8; 3], bg: [u8; 3], alpha: u8) {
|
||||
self.cells.insert((x, y), UiCell { ch, fg, bg, alpha });
|
||||
if let Some(i) = self.idx(x, y) {
|
||||
let was_none = self.cells[i].is_none();
|
||||
self.cells[i] = Some(UiCell { ch, fg, bg, alpha });
|
||||
if was_none {
|
||||
self.dirty_keys.push((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, x: i32, y: i32) -> Option<&UiCell> {
|
||||
self.cells.get(&(x, y))
|
||||
self.idx(x, y).and_then(|i| self.cells[i].as_ref())
|
||||
}
|
||||
|
||||
pub fn keys(&self) -> impl Iterator<Item = &(i32, i32)> {
|
||||
self.cells.keys()
|
||||
self.dirty_keys.iter()
|
||||
}
|
||||
|
||||
pub fn add_message(&mut self, text: &str) {
|
||||
@@ -173,7 +231,8 @@ impl UiLayer {
|
||||
return;
|
||||
}
|
||||
let ratio = (health / max_health).clamp(0.0, 1.0);
|
||||
let filled = (ratio * width as f32).round() as i32;
|
||||
let scaled_width = width * self.font_scale;
|
||||
let filled = (ratio * scaled_width as f32).round() as i32;
|
||||
let color = if ratio > 0.6 {
|
||||
[60, 220, 60]
|
||||
} else if ratio > 0.3 {
|
||||
@@ -181,7 +240,7 @@ impl UiLayer {
|
||||
} else {
|
||||
[240, 50, 50]
|
||||
};
|
||||
for i in 0..width {
|
||||
for i in 0..scaled_width {
|
||||
let x = screen_x + i;
|
||||
let ch = if i < filled { '#' } else { '-' };
|
||||
let fg = if i < filled { color } else { [80, 80, 80] };
|
||||
@@ -197,7 +256,7 @@ impl UiLayer {
|
||||
let (sx, sy) = entity_screen_pos_ui(e, cam_x, cam_y);
|
||||
let label = e.name().to_uppercase();
|
||||
let top = sy - (e.half_h as i32 * UI_SCALE) - 7;
|
||||
let x = sx - (Self::text_width(&label) / 2);
|
||||
let x = sx - (self.text_width(&label) / 2);
|
||||
let fg = entity_kind_color(e);
|
||||
self.draw_text(x, top, &label, fg, 255);
|
||||
}
|
||||
@@ -241,7 +300,7 @@ impl UiLayer {
|
||||
&mut self,
|
||||
screen_w: usize,
|
||||
screen_h: usize,
|
||||
grid: &crate::world::grid::Grid,
|
||||
grid: &ChunkedGrid,
|
||||
entities: &[Entity],
|
||||
cam_x: i32,
|
||||
cam_y: i32,
|
||||
@@ -268,8 +327,8 @@ impl UiLayer {
|
||||
let mut g = 0u32;
|
||||
let mut b = 0u32;
|
||||
let mut n = 0u32;
|
||||
for yy in 0..scale {
|
||||
for xx in 0..scale {
|
||||
for yy in (0..scale).step_by(4) {
|
||||
for xx in (0..scale).step_by(4) {
|
||||
let cell = grid.get(wx + xx, wy + yy);
|
||||
if cell.material != MaterialId::Empty {
|
||||
let c = cell.fg;
|
||||
@@ -334,8 +393,9 @@ impl UiLayer {
|
||||
player: Option<&Entity>,
|
||||
player_state: &crate::entity::player::Player,
|
||||
) {
|
||||
let w = 52i32;
|
||||
let h = 62i32;
|
||||
let fs = self.font_scale;
|
||||
let w = 52 * fs / 2;
|
||||
let h = 62 * fs / 2;
|
||||
let bg = [22u8, 26, 38];
|
||||
let border = [160u8, 180, 255];
|
||||
let title = [220u8, 230, 255];
|
||||
@@ -374,12 +434,20 @@ impl UiLayer {
|
||||
);
|
||||
|
||||
let title_text = "STATUS";
|
||||
let tx = start_x + (w - Self::text_width(title_text)) / 2;
|
||||
let tx = start_x + (w - self.text_width(title_text)) / 2;
|
||||
self.draw_text(tx, start_y + 2, title_text, title, 255);
|
||||
|
||||
let row1 = start_y + 10 * fs / 2;
|
||||
let row2 = start_y + 18 * fs / 2;
|
||||
let row3 = start_y + 26 * fs / 2;
|
||||
let row4 = start_y + 36 * fs / 2;
|
||||
let row5 = start_y + 44 * fs / 2;
|
||||
let row_inv = start_y + 52 * fs / 2;
|
||||
let row_items = start_y + 58 * fs / 2;
|
||||
|
||||
if let Some(p) = player {
|
||||
let hp_ratio = (p.health / p.max_health).clamp(0.0, 1.0);
|
||||
let hp_filled = (hp_ratio * 38.0).round() as i32;
|
||||
let hp_filled = (hp_ratio * 38.0 * fs as f32 / 2.0).round() as i32;
|
||||
let bar_color = if hp_ratio > 0.6 {
|
||||
[80, 240, 80]
|
||||
} else if hp_ratio > 0.3 {
|
||||
@@ -387,43 +455,43 @@ impl UiLayer {
|
||||
} else {
|
||||
[255, 60, 60]
|
||||
};
|
||||
self.draw_text(start_x + 3, start_y + 10, "HP", fg, 255);
|
||||
for i in 0..38 {
|
||||
self.draw_text(start_x + 3, row1, "HP", fg, 255);
|
||||
for i in 0..(38 * fs / 2) {
|
||||
let ch = if i < hp_filled { '#' } else { '-' };
|
||||
let c = if i < hp_filled {
|
||||
bar_color
|
||||
} else {
|
||||
[80, 80, 100]
|
||||
};
|
||||
self.set(start_x + 10 + i, start_y + 10, ch, c, bg);
|
||||
self.set(start_x + 10 * fs / 2 + i, row1, ch, c, bg);
|
||||
}
|
||||
let hp_text = format!(
|
||||
"{}/{} LV:{}",
|
||||
p.health as i32, p.max_health as i32, p.level
|
||||
);
|
||||
self.draw_text(start_x + 3, start_y + 18, &hp_text, fg, 255);
|
||||
self.draw_text(start_x + 3, row2, &hp_text, fg, 255);
|
||||
|
||||
let xp_ratio = (p.xp as f32 / p.xp_to_level() as f32).clamp(0.0, 1.0);
|
||||
let xp_filled = (xp_ratio * 38.0).round() as i32;
|
||||
self.draw_text(start_x + 3, start_y + 26, "XP", fg, 255);
|
||||
for i in 0..38 {
|
||||
let xp_filled = (xp_ratio * 38.0 * fs as f32 / 2.0).round() as i32;
|
||||
self.draw_text(start_x + 3, row3, "XP", fg, 255);
|
||||
for i in 0..(38 * fs / 2) {
|
||||
let ch = if i < xp_filled { '#' } else { '-' };
|
||||
let c = if i < xp_filled {
|
||||
[80, 160, 240]
|
||||
} else {
|
||||
[60, 60, 80]
|
||||
};
|
||||
self.set(start_x + 10 + i, start_y + 26, ch, c, bg);
|
||||
self.set(start_x + 10 * fs / 2 + i, row3, ch, c, bg);
|
||||
}
|
||||
|
||||
let stats = format!("STR:{} AGI:{}", p.strength, p.agility);
|
||||
self.draw_text(start_x + 3, start_y + 36, &stats, dim, 255);
|
||||
self.draw_text(start_x + 3, row4, &stats, dim, 255);
|
||||
let stats2 = format!("TOU:{} WIL:{}", p.toughness, p.willpower);
|
||||
self.draw_text(start_x + 3, start_y + 44, &stats2, dim, 255);
|
||||
self.draw_text(start_x + 3, row5, &stats2, dim, 255);
|
||||
}
|
||||
|
||||
let inv_title = "INVENTORY";
|
||||
self.draw_text(start_x + 3, start_y + 52, inv_title, fg, 255);
|
||||
self.draw_text(start_x + 3, row_inv, inv_title, fg, 255);
|
||||
let mut ix = start_x + 3;
|
||||
for item in player_state.inventory.iter().take(8) {
|
||||
if ix + 2 >= start_x + w - 2 {
|
||||
@@ -431,10 +499,10 @@ impl UiLayer {
|
||||
}
|
||||
let [ch1, ch2] = item.display_glyph();
|
||||
let col = item.color();
|
||||
self.set(ix, start_y + 58, ch1, col, bg);
|
||||
self.set(ix, row_items, ch1, col, bg);
|
||||
self.set(
|
||||
ix + 1,
|
||||
start_y + 58,
|
||||
row_items,
|
||||
ch2,
|
||||
[col[0] / 2, col[1] / 2, col[2] / 2],
|
||||
bg,
|
||||
@@ -461,9 +529,7 @@ impl UiLayer {
|
||||
String::new()
|
||||
};
|
||||
if !status.is_empty() {
|
||||
for (i, ch) in status.chars().enumerate() {
|
||||
self.set(start_x + 30 + i as i32, start_y + 38, ch, [255, 80, 80], bg);
|
||||
}
|
||||
self.draw_text(start_x + 30 * fs / 2, row4, &status, [255, 80, 80], 255);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,9 +551,9 @@ impl UiLayer {
|
||||
|
||||
let cols = 4i32;
|
||||
let rows = 2i32;
|
||||
let slot_w = 12i32;
|
||||
let slot_h = 10i32;
|
||||
let gap = 3i32;
|
||||
let slot_w = 12i32 * self.font_scale / 2;
|
||||
let slot_h = 10i32 * self.font_scale / 2;
|
||||
let gap = 3i32 * self.font_scale / 2;
|
||||
let panel_w = cols * slot_w + (cols + 1) * gap + 4;
|
||||
let panel_h = rows * slot_h + (rows + 1) * gap + 28;
|
||||
|
||||
@@ -527,7 +593,7 @@ impl UiLayer {
|
||||
self.draw_text(px_start + 4, py_start + 3, title, title_col, 255);
|
||||
let count_text = format!("({}/{} items)", player_state.inventory.len(), cols * rows);
|
||||
self.draw_text(
|
||||
px_start + 4 + Self::text_width(title) + 2,
|
||||
px_start + 4 + self.text_width(title) + 2,
|
||||
py_start + 3,
|
||||
&count_text,
|
||||
dim,
|
||||
@@ -541,7 +607,7 @@ impl UiLayer {
|
||||
for col in 0..cols {
|
||||
let idx = (row * cols + col) as usize;
|
||||
let sx = px_start + gap + col * (slot_w + gap) + 2;
|
||||
let sy = py_start + 14 + row * (slot_h + gap) + gap;
|
||||
let sy = py_start + 14 * self.font_scale / 2 + row * (slot_h + gap) + gap;
|
||||
|
||||
for y in 0..slot_h {
|
||||
for x in 0..slot_w {
|
||||
@@ -583,7 +649,7 @@ impl UiLayer {
|
||||
);
|
||||
|
||||
let label = item.name();
|
||||
let label_w = Self::text_width(label) as i32;
|
||||
let label_w = self.text_width(label) as i32;
|
||||
let lx = sx + (slot_w - label_w) / 2;
|
||||
let ly = sy + slot_h - 1;
|
||||
if lx >= sx && lx + label_w <= sx + slot_w {
|
||||
@@ -671,6 +737,8 @@ impl UiLayer {
|
||||
player_state: &crate::entity::player::Player,
|
||||
fps: f32,
|
||||
) {
|
||||
let fs = self.font_scale;
|
||||
let fh = self.fh();
|
||||
let bg = [18u8, 20, 30];
|
||||
let border = [160u8, 170, 210];
|
||||
let fill_alpha = 140u8;
|
||||
@@ -686,11 +754,11 @@ impl UiLayer {
|
||||
.map(|i| i.display_string())
|
||||
.unwrap_or(" ".to_string());
|
||||
|
||||
let bar_h = 28i32;
|
||||
let bar_h = (28 * fs / 2).max(fh * 3);
|
||||
let y_top = screen_h as i32 - bar_h;
|
||||
let y_row1 = y_top + 2;
|
||||
let y_row2 = y_top + 12;
|
||||
let y_row3 = y_top + 22;
|
||||
let y_row2 = y_top + 2 + fh + 2;
|
||||
let y_row3 = y_top + 2 + (fh + 2) * 2;
|
||||
|
||||
for row in y_top..screen_h as i32 {
|
||||
for x in 0..screen_w {
|
||||
@@ -706,7 +774,8 @@ impl UiLayer {
|
||||
self.draw_text(0, y_row1, "HP", [220, 220, 220], 255);
|
||||
if let Some(p) = player {
|
||||
let hp_ratio = (p.health / p.max_health).clamp(0.0, 1.0);
|
||||
let hp_filled = (hp_ratio * 28.0).round() as i32;
|
||||
let hp_bar_w = 28 * fs / 2;
|
||||
let hp_filled = (hp_ratio * hp_bar_w as f32).round() as i32;
|
||||
let bar_fg = if hp_ratio > 0.6 {
|
||||
[80, 240, 80]
|
||||
} else if hp_ratio > 0.3 {
|
||||
@@ -714,17 +783,23 @@ impl UiLayer {
|
||||
} else {
|
||||
[255, 60, 60]
|
||||
};
|
||||
for i in 0..28 {
|
||||
for i in 0..hp_bar_w {
|
||||
let ch = if i < hp_filled { '#' } else { '-' };
|
||||
let c = if i < hp_filled { bar_fg } else { [70, 70, 90] };
|
||||
self.set(10 + i, y_row1 + 4, ch, c, bg);
|
||||
self.set(10 * fs / 2 + i, y_row1, ch, c, bg);
|
||||
}
|
||||
let hp_text = format!("{} / {}", p.health as i32, p.max_health as i32);
|
||||
self.draw_text(40, y_row1, &hp_text, [220, 220, 220], 255);
|
||||
self.draw_text(
|
||||
(40 * fs / 2).max(10 * fs / 2 + hp_bar_w + 2),
|
||||
y_row1,
|
||||
&hp_text,
|
||||
[220, 220, 220],
|
||||
255,
|
||||
);
|
||||
}
|
||||
|
||||
let brush_color = brush_color(brush);
|
||||
self.set(0, y_row2 + 4, '#', brush_color, bg);
|
||||
self.set(0, y_row2, '#', brush_color, bg);
|
||||
let brush_text = format!(" {}", brush_name);
|
||||
self.draw_text(1, y_row2, &brush_text, [200, 200, 140], 255);
|
||||
|
||||
@@ -735,7 +810,7 @@ impl UiLayer {
|
||||
player_state.inventory.len(),
|
||||
fps as i32
|
||||
);
|
||||
let gear_w = Self::text_width(&gear);
|
||||
let gear_w = self.text_width(&gear);
|
||||
let gear_x = (screen_w as i32 - gear_w).max(0);
|
||||
self.draw_text(gear_x, y_row2, &gear, [160, 180, 220], 255);
|
||||
|
||||
@@ -748,14 +823,14 @@ impl UiLayer {
|
||||
depth,
|
||||
tick
|
||||
);
|
||||
let stats_w = Self::text_width(&stats);
|
||||
let stats_w = self.text_width(&stats);
|
||||
let stats_x = (screen_w as i32 - stats_w).max(0);
|
||||
self.draw_text(stats_x, y_row3, &stats, [180, 190, 220], 255);
|
||||
|
||||
if let Some(p) = player {
|
||||
if p.on_fire {
|
||||
let msg = "ON FIRE!";
|
||||
self.draw_text(40, y_top - 7, msg, [255, 100, 20], 255);
|
||||
self.draw_text(40 * fs / 2, y_top - fh - 2, msg, [255, 100, 20], 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -763,6 +838,7 @@ impl UiLayer {
|
||||
pub fn draw_messages(&mut self, x: i32, y: i32) {
|
||||
let mut yy = y;
|
||||
let messages: Vec<(String, u32)> = self.messages.iter().rev().take(8).cloned().collect();
|
||||
let line_h = self.fh() + 1;
|
||||
for (msg, life) in messages {
|
||||
if yy < 0 {
|
||||
break;
|
||||
@@ -774,7 +850,7 @@ impl UiLayer {
|
||||
(200.0 * fade) as u8,
|
||||
];
|
||||
self.draw_text(x, yy, &msg, fg, 255);
|
||||
yy -= 6;
|
||||
yy -= line_h;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,20 +858,21 @@ impl UiLayer {
|
||||
let cx = screen_w as i32 / 2;
|
||||
let cy = screen_h as i32 / 2;
|
||||
let msg = "YOU DIED";
|
||||
let x = cx - (Self::text_width(msg) / 2);
|
||||
let x = cx - (self.text_width(msg) / 2);
|
||||
self.draw_text(x, cy - 3, msg, [255, 50, 50], 255);
|
||||
let stats = format!("KILLS: {} SCORE: {}", kills, score);
|
||||
let x2 = cx - (Self::text_width(&stats) / 2);
|
||||
let x2 = cx - (self.text_width(&stats) / 2);
|
||||
self.draw_text(x2, cy + 3, &stats, [200, 200, 200], 255);
|
||||
}
|
||||
|
||||
pub fn text_width(text: &str) -> i32 {
|
||||
pub fn text_width(&self, text: &str) -> i32 {
|
||||
text.chars()
|
||||
.map(|c| if c == '\n' { 0 } else { 3 })
|
||||
.map(|c| if c == '\n' { 0 } else { self.fw() as usize })
|
||||
.sum::<usize>() as i32
|
||||
}
|
||||
|
||||
pub fn draw_text(&mut self, x: i32, y: i32, text: &str, fg: [u8; 3], alpha: u8) {
|
||||
let fs = self.font_scale;
|
||||
let mut cx = x;
|
||||
for c in text.chars() {
|
||||
if c == '\n' {
|
||||
@@ -803,21 +880,33 @@ impl UiLayer {
|
||||
continue;
|
||||
}
|
||||
if let Some(bitmap) = char_bitmap(c) {
|
||||
for row in 0..5 {
|
||||
for col in 0..3 {
|
||||
let filled = (bitmap[row] >> (2 - col)) & 1 == 1;
|
||||
for row in 0..FONT_H {
|
||||
for col in 0..FONT_W {
|
||||
let filled = (bitmap[row as usize] >> (FONT_W - 1 - col)) & 1 == 1;
|
||||
let a = if filled { alpha } else { 0 };
|
||||
self.set_alpha(cx + col as i32, y + row as i32, c, fg, [0, 0, 0], a);
|
||||
for dy in 0..fs {
|
||||
for dx in 0..fs {
|
||||
let px = cx + col * fs + dx;
|
||||
let py = y + row * fs + dy;
|
||||
self.set_alpha(px, py, c, fg, [0, 0, 0], a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for row in 0..5 {
|
||||
for col in 0..3 {
|
||||
self.set_alpha(cx + col as i32, y + row as i32, c, fg, [0, 0, 0], alpha);
|
||||
for row in 0..FONT_H {
|
||||
for col in 0..FONT_W {
|
||||
for dy in 0..fs {
|
||||
for dx in 0..fs {
|
||||
let px = cx + col * fs + dx;
|
||||
let py = y + row * fs + dy;
|
||||
self.set_alpha(px, py, c, fg, [0, 0, 0], alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cx += 3;
|
||||
cx += self.fw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
use crate::entity::item::{ItemManager, ItemType};
|
||||
use crate::entity::player::Player;
|
||||
use crate::entity::EntityManager;
|
||||
use crate::world::chunked_grid::ChunkedGrid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const CACHE_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct CacheMeta {
|
||||
version: u32,
|
||||
seed: u64,
|
||||
player_x: f32,
|
||||
player_y: f32,
|
||||
depth: u32,
|
||||
items: Vec<CachedItem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct CachedItem {
|
||||
typ: String,
|
||||
x: i32,
|
||||
y: i32,
|
||||
}
|
||||
|
||||
pub struct WorldCache;
|
||||
|
||||
impl WorldCache {
|
||||
pub fn path(root: &str, seed: u64) -> PathBuf {
|
||||
PathBuf::from(root).join(format!("seed_{}", seed))
|
||||
}
|
||||
|
||||
pub fn meta_path(root: &str, seed: u64) -> PathBuf {
|
||||
Self::path(root, seed).join("meta.json")
|
||||
}
|
||||
|
||||
pub fn chunk_path(root: &str, seed: u64, cx: i32, cy: i32) -> PathBuf {
|
||||
Self::path(root, seed).join(format!("chunk_{}_{}.bin", cx, cy))
|
||||
}
|
||||
|
||||
pub fn meta_exists(root: &str, seed: u64) -> bool {
|
||||
Self::meta_path(root, seed).exists()
|
||||
}
|
||||
|
||||
pub fn chunk_exists(root: &str, seed: u64, cx: i32, cy: i32) -> bool {
|
||||
Self::chunk_path(root, seed, cx, cy).exists()
|
||||
}
|
||||
|
||||
pub fn save_meta(
|
||||
root: &str,
|
||||
seed: u64,
|
||||
player_x: f32,
|
||||
player_y: f32,
|
||||
depth: u32,
|
||||
items: &ItemManager,
|
||||
) -> io::Result<()> {
|
||||
let path = Self::path(root, seed);
|
||||
std::fs::create_dir_all(&path)?;
|
||||
let meta = CacheMeta {
|
||||
version: CACHE_VERSION,
|
||||
seed,
|
||||
player_x,
|
||||
player_y,
|
||||
depth,
|
||||
items: items
|
||||
.all()
|
||||
.iter()
|
||||
.map(|i| CachedItem {
|
||||
typ: i.name().to_string(),
|
||||
x: i.x,
|
||||
y: i.y,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let meta_json = serde_json::to_string_pretty(&meta)?;
|
||||
std::fs::write(path.join("meta.json"), meta_json)
|
||||
}
|
||||
|
||||
pub fn load_meta(
|
||||
root: &str,
|
||||
seed: u64,
|
||||
player: &mut Player,
|
||||
entities: &mut EntityManager,
|
||||
items: &mut ItemManager,
|
||||
) -> io::Result<u32> {
|
||||
let path = Self::path(root, seed);
|
||||
let meta_json = std::fs::read_to_string(path.join("meta.json"))?;
|
||||
let meta: CacheMeta = serde_json::from_str(&meta_json)
|
||||
.map_err(|e| io::Error::other(format!("cache meta parse: {}", e)))?;
|
||||
if meta.version != CACHE_VERSION {
|
||||
return Err(io::Error::other("cache version mismatch"));
|
||||
}
|
||||
player.spawn_at(entities, meta.player_x, meta.player_y);
|
||||
items.all_mut().clear();
|
||||
for ci in meta.items {
|
||||
if let Some(typ) = ItemType::from_name(&ci.typ) {
|
||||
items.spawn(typ, ci.x, ci.y);
|
||||
}
|
||||
}
|
||||
Ok(meta.depth)
|
||||
}
|
||||
|
||||
pub fn save_chunk(
|
||||
root: &str,
|
||||
seed: u64,
|
||||
cx: i32,
|
||||
cy: i32,
|
||||
grid: &ChunkedGrid,
|
||||
) -> io::Result<()> {
|
||||
let path = Self::chunk_path(root, seed, cx, cy);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
grid.save_chunk(path.to_str().unwrap(), cx, cy)
|
||||
}
|
||||
|
||||
pub fn load_chunk(
|
||||
root: &str,
|
||||
seed: u64,
|
||||
cx: i32,
|
||||
cy: i32,
|
||||
grid: &mut ChunkedGrid,
|
||||
) -> io::Result<()> {
|
||||
let path = Self::chunk_path(root, seed, cx, cy);
|
||||
grid.load_chunk(path.to_str().unwrap(), cx, cy)
|
||||
}
|
||||
|
||||
pub fn save_all_loaded(root: &str, seed: u64, grid: &ChunkedGrid) -> io::Result<()> {
|
||||
let path = Self::path(root, seed);
|
||||
std::fs::create_dir_all(&path)?;
|
||||
for (&(cx, cy), chunk) in &grid.chunks {
|
||||
if chunk.modified || chunk.was_modified {
|
||||
let file = Self::chunk_path(root, seed, cx as i32, cy as i32);
|
||||
grid.save_chunk(file.to_str().unwrap(), cx as i32, cy as i32)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+20
-27
@@ -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,
|
||||
|
||||
+787
-254
File diff suppressed because it is too large
Load Diff
+135
-5
@@ -4,17 +4,48 @@ 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 electricity: Vec<u8>,
|
||||
pub active: bool,
|
||||
pub modified: bool,
|
||||
pub was_modified: bool,
|
||||
pub generated: bool,
|
||||
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],
|
||||
electricity: vec![0; CHUNK_AREA],
|
||||
active: false,
|
||||
modified: false,
|
||||
was_modified: false,
|
||||
generated: false,
|
||||
dirty: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn swap_modified_flags(&mut self) {
|
||||
self.was_modified = self.modified;
|
||||
self.modified = false;
|
||||
}
|
||||
|
||||
pub fn reset_tick_flags(&mut self) {
|
||||
for c in &mut self.cells {
|
||||
c.updated_this_tick = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,13 +76,112 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_tick_flags(&mut self) {
|
||||
for c in &mut self.cells {
|
||||
c.updated_this_tick = false;
|
||||
#[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;
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get_electricity(&self, x: i32, y: i32) -> u8 {
|
||||
if !Self::in_bounds(x, y) {
|
||||
return 0;
|
||||
}
|
||||
self.electricity[Self::idx(x, y)]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_electricity(&mut self, x: i32, y: i32, val: u8) {
|
||||
if Self::in_bounds(x, y) {
|
||||
self.electricity[Self::idx(x, y)] = val;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.cells.iter().all(|c| c.is_empty())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn mark_dirty(&mut self, x: i32, y: i32) {
|
||||
if !Self::in_bounds(x, y) {
|
||||
return;
|
||||
}
|
||||
let min_x = (x - 1).max(0);
|
||||
let min_y = (y - 1).max(0);
|
||||
let max_x = (x + 1).min(CHUNK_SIZE as i32 - 1);
|
||||
let max_y = (y + 1).min(CHUNK_SIZE as i32 - 1);
|
||||
match self.dirty {
|
||||
None => self.dirty = Some((min_x, min_y, max_x, max_y)),
|
||||
Some((dx0, dy0, dx1, dy1)) => {
|
||||
self.dirty = Some((
|
||||
dx0.min(min_x),
|
||||
dy0.min(min_y),
|
||||
dx1.max(max_x),
|
||||
dy1.max(max_y),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+106
-10
@@ -6,10 +6,14 @@ use std::path::Path;
|
||||
pub const WORLD_W: usize = 250;
|
||||
pub const WORLD_H: usize = 250;
|
||||
|
||||
pub const MAX_WORLD_W: usize = 2048;
|
||||
pub const MAX_WORLD_H: usize = 2048;
|
||||
|
||||
pub struct ChunkMeta {
|
||||
pub active: bool,
|
||||
pub modified: bool,
|
||||
pub was_modified: bool,
|
||||
pub dirty: Option<(i32, i32, i32, i32)>,
|
||||
}
|
||||
|
||||
pub struct Grid {
|
||||
@@ -24,22 +28,27 @@ pub struct Grid {
|
||||
|
||||
impl Grid {
|
||||
pub fn new() -> Self {
|
||||
let size = WORLD_W * WORLD_H;
|
||||
Self::with_size(WORLD_W, WORLD_H)
|
||||
}
|
||||
|
||||
pub fn with_size(width: usize, height: usize) -> Self {
|
||||
let size = width * height;
|
||||
let chunk_size = CHUNK_SIZE;
|
||||
let chunks_x = (WORLD_W + chunk_size - 1) / chunk_size;
|
||||
let chunks_y = (WORLD_H + chunk_size - 1) / chunk_size;
|
||||
let chunks_x = (width + chunk_size - 1) / chunk_size;
|
||||
let chunks_y = (height + chunk_size - 1) / chunk_size;
|
||||
let mut chunks = Vec::with_capacity(chunks_x * chunks_y);
|
||||
for _ in 0..chunks_x * chunks_y {
|
||||
chunks.push(ChunkMeta {
|
||||
active: true,
|
||||
modified: false,
|
||||
was_modified: false,
|
||||
dirty: None,
|
||||
});
|
||||
}
|
||||
Self {
|
||||
cells: vec![Cell::empty(); size],
|
||||
width: WORLD_W,
|
||||
height: WORLD_H,
|
||||
width,
|
||||
height,
|
||||
chunk_size,
|
||||
chunks_x,
|
||||
chunks_y,
|
||||
@@ -185,6 +194,7 @@ impl Grid {
|
||||
if let Some(c) = self.chunks.get_mut(idx) {
|
||||
c.modified = true;
|
||||
}
|
||||
self.mark_dirty(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +208,7 @@ impl Grid {
|
||||
if let Some(c) = self.chunks.get_mut(idx) {
|
||||
c.modified = true;
|
||||
}
|
||||
self.mark_dirty(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,9 +223,94 @@ impl Grid {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn mark_dirty(&mut self, x: i32, y: i32) {
|
||||
if !self.in_bounds(x, y) {
|
||||
return;
|
||||
}
|
||||
let (cx, cy, _, _) = self.chunk_at(x, y);
|
||||
self.expand_chunk_dirty(cx, cy, x, y);
|
||||
let cs = self.chunk_size as i32;
|
||||
let lx = x - cx * cs;
|
||||
let ly = y - cy * cs;
|
||||
if lx <= 1 {
|
||||
self.expand_chunk_dirty(cx - 1, cy, x - 1, y);
|
||||
}
|
||||
if lx >= cs - 2 {
|
||||
self.expand_chunk_dirty(cx + 1, cy, x + 1, y);
|
||||
}
|
||||
if ly <= 1 {
|
||||
self.expand_chunk_dirty(cx, cy - 1, x, y - 1);
|
||||
}
|
||||
if ly >= cs - 2 {
|
||||
self.expand_chunk_dirty(cx, cy + 1, x, y + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn expand_chunk_dirty(&mut self, cx: i32, cy: i32, x: i32, y: i32) {
|
||||
if cx < 0 || cy < 0 || cx >= self.chunks_x as i32 || cy >= self.chunks_y as i32 {
|
||||
return;
|
||||
}
|
||||
if !self.in_bounds(x, y) {
|
||||
return;
|
||||
}
|
||||
let idx = self.chunk_index(cx, cy);
|
||||
let min_x = (x - 1).max(0);
|
||||
let min_y = (y - 1).max(0);
|
||||
let max_x = (x + 1).min(self.width as i32 - 1);
|
||||
let max_y = (y + 1).min(self.height as i32 - 1);
|
||||
let chunk = &mut self.chunks[idx];
|
||||
match chunk.dirty {
|
||||
None => chunk.dirty = Some((min_x, min_y, max_x, max_y)),
|
||||
Some((dx0, dy0, dx1, dy1)) => {
|
||||
chunk.dirty = Some((
|
||||
dx0.min(min_x),
|
||||
dy0.min(min_y),
|
||||
dx1.max(max_x),
|
||||
dy1.max(max_y),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn cells_swap(&mut self, x1: i32, y1: i32, x2: i32, y2: i32) {
|
||||
if !self.in_bounds(x1, y1) || !self.in_bounds(x2, y2) {
|
||||
return;
|
||||
}
|
||||
let i1 = self.idx(x1, y1);
|
||||
let i2 = self.idx(x2, y2);
|
||||
let tmp = self.cells[i1];
|
||||
self.cells[i1] = self.cells[i2];
|
||||
self.cells[i2] = tmp;
|
||||
self.cells[i2].updated_this_tick = true;
|
||||
self.mark_dirty(x1, y1);
|
||||
self.mark_dirty(x2, y2);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_cell_index(&mut self, i: usize, cell: Cell) {
|
||||
self.cells[i] = cell;
|
||||
let x = (i % self.width) as i32;
|
||||
let y = (i / self.width) as i32;
|
||||
self.mark_dirty(x, y);
|
||||
}
|
||||
|
||||
pub fn reset_tick_flags(&mut self) {
|
||||
for c in &mut self.cells {
|
||||
c.updated_this_tick = false;
|
||||
for cy in 0..self.chunks_y {
|
||||
for cx in 0..self.chunks_x {
|
||||
if !self.chunks[cy * self.chunks_x + cx].active {
|
||||
continue;
|
||||
}
|
||||
let (x0, y0, x1, y1) = self.chunk_bounds(cx, cy);
|
||||
for y in y0..y1 {
|
||||
let row = y as usize * self.width;
|
||||
for x in x0..x1 {
|
||||
self.cells[row + x as usize].updated_this_tick = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,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());
|
||||
@@ -246,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;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ pub struct Material {
|
||||
pub ignition_temp: f32,
|
||||
pub melt_temp: f32,
|
||||
pub heat_conductivity: f32,
|
||||
pub conductive: bool,
|
||||
pub conductivity: f32,
|
||||
pub structural: bool,
|
||||
pub color_fg: (u8, u8, u8),
|
||||
pub color_bg: (u8, u8, u8),
|
||||
pub display_char: char,
|
||||
@@ -40,6 +43,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.0,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (15, 15, 20),
|
||||
color_bg: (10, 10, 15),
|
||||
display_char: ' ',
|
||||
@@ -56,6 +62,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: 1700.0,
|
||||
heat_conductivity: 0.2,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (218, 178, 90),
|
||||
color_bg: (60, 50, 30),
|
||||
display_char: '.',
|
||||
@@ -72,6 +81,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: 0.0,
|
||||
heat_conductivity: 0.4,
|
||||
conductive: true,
|
||||
conductivity: 0.6,
|
||||
structural: false,
|
||||
color_fg: (64, 128, 220),
|
||||
color_bg: (20, 40, 80),
|
||||
display_char: '~',
|
||||
@@ -88,6 +100,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: 1200.0,
|
||||
heat_conductivity: 0.3,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: true,
|
||||
color_fg: (120, 120, 130),
|
||||
color_bg: (40, 40, 50),
|
||||
display_char: '#',
|
||||
@@ -104,6 +119,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.05,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (255, 80, 20),
|
||||
color_bg: (120, 20, 0),
|
||||
display_char: '#',
|
||||
@@ -120,6 +138,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: 300.0,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.1,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: true,
|
||||
color_fg: (140, 90, 50),
|
||||
color_bg: (50, 30, 20),
|
||||
display_char: 'T',
|
||||
@@ -136,6 +157,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: 200.0,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.15,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (180, 50, 50),
|
||||
color_bg: (60, 15, 15),
|
||||
display_char: '%',
|
||||
@@ -152,6 +176,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.1,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (220, 210, 190),
|
||||
color_bg: (60, 55, 50),
|
||||
display_char: '`',
|
||||
@@ -168,6 +195,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.2,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (200, 200, 220),
|
||||
color_bg: (30, 30, 40),
|
||||
display_char: '~',
|
||||
@@ -184,6 +214,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.6,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (255, 160, 40),
|
||||
color_bg: (100, 30, 0),
|
||||
display_char: '^',
|
||||
@@ -200,6 +233,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.3,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (100, 255, 60),
|
||||
color_bg: (20, 60, 10),
|
||||
display_char: '~',
|
||||
@@ -216,6 +252,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.1,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (100, 100, 100),
|
||||
color_bg: (20, 20, 20),
|
||||
display_char: '*',
|
||||
@@ -232,6 +271,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: 250.0,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.1,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (80, 200, 60),
|
||||
color_bg: (20, 50, 15),
|
||||
display_char: '"',
|
||||
@@ -248,6 +290,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.2,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (100, 70, 50),
|
||||
color_bg: (40, 30, 20),
|
||||
display_char: ':',
|
||||
@@ -264,6 +309,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.0,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (255, 220, 80),
|
||||
color_bg: (60, 50, 20),
|
||||
display_char: '>',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
pub mod cache;
|
||||
pub mod cell;
|
||||
pub mod cellular;
|
||||
pub mod chunk;
|
||||
pub mod chunked_grid;
|
||||
pub mod grid;
|
||||
pub mod material;
|
||||
pub mod worldgen;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||
|
||||
+158
-26
@@ -1,11 +1,17 @@
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
fn setup() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
s.init_empty();
|
||||
s.clear_area(90, 90, 50, 50);
|
||||
s.perform_action(&AiAction::FillRect { x: 80, y: 130, w: 80, h: 15, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 80,
|
||||
y: 130,
|
||||
w: 80,
|
||||
h: 15,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
@@ -15,11 +21,22 @@ fn player_blocked_by_left_wall() {
|
||||
s.step(30);
|
||||
let p = s.get_player().unwrap();
|
||||
let wall_x = (p.pos[0] as i32) - 6;
|
||||
s.perform_action(&AiAction::FillRect { x: wall_x, y: 125, w: 1, h: 10, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: wall_x,
|
||||
y: 125,
|
||||
w: 1,
|
||||
h: 10,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::MoveLeft);
|
||||
s.step(20);
|
||||
let p2 = s.get_player().unwrap();
|
||||
assert!(p2.pos[0] > wall_x as f32, "player should not pass through left wall: wall={} player={}", wall_x, p2.pos[0]);
|
||||
assert!(
|
||||
p2.pos[0] > wall_x as f32,
|
||||
"player should not pass through left wall: wall={} player={}",
|
||||
wall_x,
|
||||
p2.pos[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -28,11 +45,22 @@ fn player_blocked_by_right_wall() {
|
||||
s.step(30);
|
||||
let p = s.get_player().unwrap();
|
||||
let wall_x = (p.pos[0] as i32) + 6;
|
||||
s.perform_action(&AiAction::FillRect { x: wall_x, y: 125, w: 1, h: 10, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: wall_x,
|
||||
y: 125,
|
||||
w: 1,
|
||||
h: 10,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::MoveRight);
|
||||
s.step(20);
|
||||
let p2 = s.get_player().unwrap();
|
||||
assert!(p2.pos[0] < wall_x as f32, "player should not pass through right wall: wall={} player={}", wall_x, p2.pos[0]);
|
||||
assert!(
|
||||
p2.pos[0] < wall_x as f32,
|
||||
"player should not pass through right wall: wall={} player={}",
|
||||
wall_x,
|
||||
p2.pos[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -41,14 +69,24 @@ fn player_slides_along_wall() {
|
||||
s.step(30);
|
||||
let p = s.get_player().unwrap();
|
||||
let wall_x = (p.pos[0] as i32) + 6;
|
||||
s.perform_action(&AiAction::FillRect { x: wall_x, y: 125, w: 1, h: 10, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: wall_x,
|
||||
y: 125,
|
||||
w: 1,
|
||||
h: 10,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::MoveRight);
|
||||
s.step(30);
|
||||
let p2 = s.get_player().unwrap();
|
||||
assert!(p2.pos[0] < wall_x as f32, "player should stay left of wall");
|
||||
assert!(p2.alive, "player should be alive");
|
||||
let y_diff = (p2.pos[1] - p.pos[1]).abs();
|
||||
assert!(y_diff < 5.0, "player should not fall through floor while sliding: dy={}", y_diff);
|
||||
assert!(
|
||||
y_diff < 5.0,
|
||||
"player should not fall through floor while sliding: dy={}",
|
||||
y_diff
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -57,11 +95,22 @@ fn player_blocked_by_ceiling() {
|
||||
s.step(30);
|
||||
let p = s.get_player().unwrap();
|
||||
let ceiling_y = (p.pos[1] as i32) - 8;
|
||||
s.perform_action(&AiAction::FillRect { x: p.pos[0] as i32 - 5, y: ceiling_y, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: p.pos[0] as i32 - 5,
|
||||
y: ceiling_y,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::Jump);
|
||||
s.step(10);
|
||||
let p2 = s.get_player().unwrap();
|
||||
assert!(p2.pos[1] > ceiling_y as f32, "player should not pass through ceiling: ceiling={} player={}", ceiling_y, p2.pos[1]);
|
||||
assert!(
|
||||
p2.pos[1] > ceiling_y as f32,
|
||||
"player should not pass through ceiling: ceiling={} player={}",
|
||||
ceiling_y,
|
||||
p2.pos[1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -69,13 +118,40 @@ fn player_navigates_corridor() {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
s.init_empty();
|
||||
s.clear_area(100, 100, 40, 30);
|
||||
s.perform_action(&AiAction::FillRect { x: 95, y: 120, w: 50, h: 10, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 115, y: 110, w: 1, h: 10, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 125, y: 110, w: 1, h: 10, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 115, y: 108, w: 11, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 95,
|
||||
y: 120,
|
||||
w: 50,
|
||||
h: 10,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 115,
|
||||
y: 110,
|
||||
w: 1,
|
||||
h: 10,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 125,
|
||||
y: 110,
|
||||
w: 1,
|
||||
h: 10,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 115,
|
||||
y: 108,
|
||||
w: 11,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.step(30);
|
||||
let p = s.get_player().unwrap();
|
||||
assert!(p.pos[0] < 115.0 || p.pos[0] > 125.0, "player should be outside corridor initially");
|
||||
assert!(
|
||||
p.pos[0] < 115.0 || p.pos[0] > 125.0,
|
||||
"player should be outside corridor initially"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -84,7 +160,13 @@ fn player_does_not_stick_to_wall() {
|
||||
s.step(30);
|
||||
let p = s.get_player().unwrap();
|
||||
let wall_x = (p.pos[0] as i32) + 8;
|
||||
s.perform_action(&AiAction::FillRect { x: wall_x, y: 125, w: 1, h: 10, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: wall_x,
|
||||
y: 125,
|
||||
w: 1,
|
||||
h: 10,
|
||||
material: "stone".into(),
|
||||
});
|
||||
for _ in 0..10 {
|
||||
s.perform_action(&AiAction::MoveRight);
|
||||
s.step(2);
|
||||
@@ -96,7 +178,12 @@ fn player_does_not_stick_to_wall() {
|
||||
s.step(2);
|
||||
}
|
||||
let p_away = s.get_player().unwrap();
|
||||
assert!(p_away.pos[0] < x_at_wall - 1.0, "player should move away from wall: {} -> {}", x_at_wall, p_away.pos[0]);
|
||||
assert!(
|
||||
p_away.pos[0] < x_at_wall - 1.0,
|
||||
"player should move away from wall: {} -> {}",
|
||||
x_at_wall,
|
||||
p_away.pos[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -105,8 +192,20 @@ fn player_squeezes_through_gap() {
|
||||
s.step(30);
|
||||
let p = s.get_player().unwrap();
|
||||
let px = p.pos[0] as i32;
|
||||
s.perform_action(&AiAction::FillRect { x: px + 8, y: 128, w: 1, h: 2, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: px + 8, y: 122, w: 1, h: 2, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: px + 8,
|
||||
y: 128,
|
||||
w: 1,
|
||||
h: 2,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: px + 8,
|
||||
y: 122,
|
||||
w: 1,
|
||||
h: 2,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::MoveRight);
|
||||
s.step(20);
|
||||
let p2 = s.get_player().unwrap();
|
||||
@@ -119,8 +218,20 @@ fn player_blocked_by_two_walls_both_sides() {
|
||||
s.step(30);
|
||||
let p = s.get_player().unwrap();
|
||||
let px = p.pos[0] as i32;
|
||||
s.perform_action(&AiAction::FillRect { x: px - 6, y: 125, w: 1, h: 10, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: px + 6, y: 125, w: 1, h: 10, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: px - 6,
|
||||
y: 125,
|
||||
w: 1,
|
||||
h: 10,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: px + 6,
|
||||
y: 125,
|
||||
w: 1,
|
||||
h: 10,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::MoveRight);
|
||||
s.step(10);
|
||||
let p_right = s.get_player().unwrap();
|
||||
@@ -129,7 +240,10 @@ fn player_blocked_by_two_walls_both_sides() {
|
||||
let p_left = s.get_player().unwrap();
|
||||
assert!(p_right.pos[0] < (px + 6) as f32, "blocked right");
|
||||
assert!(p_left.pos[0] > (px - 6) as f32, "blocked left");
|
||||
assert!((p_left.pos[0] - p_right.pos[0]).abs() < 12.0, "player should stay between walls");
|
||||
assert!(
|
||||
(p_left.pos[0] - p_right.pos[0]).abs() < 12.0,
|
||||
"player should stay between walls"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -140,7 +254,11 @@ fn player_walks_up_slope() {
|
||||
for x in 100..130 {
|
||||
let h = ((x - 100) / 3).min(15);
|
||||
for y in 0..h {
|
||||
s.perform_action(&AiAction::SetCell { x, y: 135 - 1 - y, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x,
|
||||
y: 135 - 1 - y,
|
||||
material: "stone".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
s.step(40);
|
||||
@@ -152,11 +270,25 @@ fn player_walks_up_slope() {
|
||||
#[test]
|
||||
fn entity_collision_with_dirt_wall() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect { x: 140, y: 125, w: 1, h: 5, material: "dirt".into() });
|
||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 135.0, y: 120.0 });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 140,
|
||||
y: 125,
|
||||
w: 1,
|
||||
h: 5,
|
||||
material: "dirt".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 135.0,
|
||||
y: 120.0,
|
||||
});
|
||||
s.step(40);
|
||||
let entities = s.get_entities();
|
||||
if let Some(g) = entities.into_iter().find(|e| e.kind == "Goblin" && e.alive) {
|
||||
assert!(g.pos[0] < 140.0, "goblin should be blocked by dirt wall: x={}", g.pos[0]);
|
||||
assert!(
|
||||
g.pos[0] < 140.0,
|
||||
"goblin should be blocked by dirt wall: x={}",
|
||||
g.pos[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -53,6 +53,7 @@ fn depth_shown_in_hud() {
|
||||
let mut ui = verbatim::ui::UiLayer::new();
|
||||
let screen_w = 320;
|
||||
let screen_h = 100;
|
||||
ui.resize(screen_w, screen_h);
|
||||
ui.draw_hud(
|
||||
screen_w,
|
||||
screen_h,
|
||||
@@ -69,11 +70,14 @@ fn depth_shown_in_hud() {
|
||||
"LV:{} XP:{} K:{} S:{} D:{} T:{}",
|
||||
player.level, player.xp, 0, 0, s.game.depth, s.game.tick
|
||||
);
|
||||
let stats_w = verbatim::ui::UiLayer::text_width(&stats);
|
||||
let stats_w = ui.text_width(&stats);
|
||||
let stats_x = (screen_w as i32 - stats_w as i32).max(0);
|
||||
let y_row3 = screen_h as i32 - 28 + 22;
|
||||
let fs = ui.font_scale();
|
||||
let fh = 5 * fs;
|
||||
let bar_h = (28 * fs / 2).max(fh * 3);
|
||||
let y_row3 = screen_h as i32 - bar_h + 2 + (fh + 2) * 2;
|
||||
let line: String = (stats_x as i32..screen_w as i32)
|
||||
.step_by(3)
|
||||
.step_by(fs as usize * 3)
|
||||
.map(|x| ui.get(x, y_row3).map(|c| c.ch).unwrap_or(' '))
|
||||
.collect();
|
||||
assert!(line.contains("D:1"), "HUD should show depth: {}", line);
|
||||
|
||||
+83
-18
@@ -1,12 +1,18 @@
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::ReplayPlayer;
|
||||
|
||||
fn setup() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
s.init_empty();
|
||||
s.clear_area(90, 90, 50, 50);
|
||||
s.perform_action(&AiAction::FillRect { x: 80, y: 130, w: 80, h: 15, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 80,
|
||||
y: 130,
|
||||
w: 80,
|
||||
h: 15,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
@@ -32,7 +38,11 @@ fn same_seed_same_entity_count() {
|
||||
let mut s2 = GameSession::new_seeded(555);
|
||||
s2.init();
|
||||
s2.step(60);
|
||||
assert_eq!(s1.get_entities().len(), s2.get_entities().len(), "entity count should match");
|
||||
assert_eq!(
|
||||
s1.get_entities().len(),
|
||||
s2.get_entities().len(),
|
||||
"entity count should match"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -47,7 +57,11 @@ fn same_seed_same_grid_state() {
|
||||
for x in 100..150 {
|
||||
let c1 = s1.get_cell(x, y);
|
||||
let c2 = s2.get_cell(x, y);
|
||||
assert_eq!(c1.material, c2.material, "material mismatch at ({},{})", x, y);
|
||||
assert_eq!(
|
||||
c1.material, c2.material,
|
||||
"material mismatch at ({},{})",
|
||||
x, y
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +83,8 @@ fn replay_exact_match() {
|
||||
s.perform_action(&AiAction::MoveLeft);
|
||||
s.step(10);
|
||||
let state_orig = s.get_state();
|
||||
s.save_replay("/tmp/verbatim_replay_exact.json").expect("save");
|
||||
s.save_replay("/tmp/verbatim_replay_exact.json")
|
||||
.expect("save");
|
||||
|
||||
let player = ReplayPlayer::load("/tmp/verbatim_replay_exact.json").expect("load");
|
||||
let s2 = player.play();
|
||||
@@ -77,8 +92,18 @@ fn replay_exact_match() {
|
||||
|
||||
assert_eq!(state_orig.tick, state_replay.tick, "tick mismatch");
|
||||
if let (Some(p1), Some(p2)) = (&state_orig.player, &state_replay.player) {
|
||||
assert!((p1.pos[0] - p2.pos[0]).abs() < 0.1, "x mismatch: {} vs {}", p1.pos[0], p2.pos[0]);
|
||||
assert!((p1.pos[1] - p2.pos[1]).abs() < 0.1, "y mismatch: {} vs {}", p1.pos[1], p2.pos[1]);
|
||||
assert!(
|
||||
(p1.pos[0] - p2.pos[0]).abs() < 0.1,
|
||||
"x mismatch: {} vs {}",
|
||||
p1.pos[0],
|
||||
p2.pos[0]
|
||||
);
|
||||
assert!(
|
||||
(p1.pos[1] - p2.pos[1]).abs() < 0.1,
|
||||
"y mismatch: {} vs {}",
|
||||
p1.pos[1],
|
||||
p2.pos[1]
|
||||
);
|
||||
assert!((p1.health - p2.health).abs() < 1.0, "health mismatch");
|
||||
}
|
||||
}
|
||||
@@ -91,14 +116,25 @@ fn replay_stop_at_tick() {
|
||||
s.step(20);
|
||||
s.perform_action(&AiAction::MoveRight);
|
||||
s.step(20);
|
||||
s.save_replay("/tmp/verbatim_replay_partial.json").expect("save");
|
||||
s.save_replay("/tmp/verbatim_replay_partial.json")
|
||||
.expect("save");
|
||||
|
||||
let player = ReplayPlayer::load("/tmp/verbatim_replay_partial.json").expect("load");
|
||||
let s_half = player.play_until_tick(10);
|
||||
assert_eq!(s_half.tick(), 10, "should stop at tick 10, got {}", s_half.tick());
|
||||
assert_eq!(
|
||||
s_half.tick(),
|
||||
10,
|
||||
"should stop at tick 10, got {}",
|
||||
s_half.tick()
|
||||
);
|
||||
|
||||
let s_full = player.play();
|
||||
assert_eq!(s_full.tick(), 40, "full replay should reach tick 40, got {}", s_full.tick());
|
||||
assert_eq!(
|
||||
s_full.tick(),
|
||||
40,
|
||||
"full replay should reach tick 40, got {}",
|
||||
s_full.tick()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -110,28 +146,52 @@ fn recording_captures_all_actions() {
|
||||
s.perform_action(&AiAction::Jump);
|
||||
s.perform_action(&AiAction::MoveLeft);
|
||||
s.step(5);
|
||||
s.save_replay("/tmp/verbatim_replay_capture.json").expect("save");
|
||||
s.save_replay("/tmp/verbatim_replay_capture.json")
|
||||
.expect("save");
|
||||
|
||||
let player = ReplayPlayer::load("/tmp/verbatim_replay_capture.json").expect("load");
|
||||
let event_count = player.recording().events.len();
|
||||
assert!(event_count >= 4, "recording should have at least 4 events (3 actions + 1 step), got {}", event_count);
|
||||
assert!(
|
||||
event_count >= 4,
|
||||
"recording should have at least 4 events (3 actions + 1 step), got {}",
|
||||
event_count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determinism_with_spawn_and_damage() {
|
||||
let mut s1 = setup();
|
||||
s1.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 135.0, y: 120.0 });
|
||||
s1.perform_action(&AiAction::DamageEntity { id: 1, amount: 20.0 });
|
||||
s1.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 135.0,
|
||||
y: 120.0,
|
||||
});
|
||||
s1.perform_action(&AiAction::DamageEntity {
|
||||
id: 1,
|
||||
amount: 20.0,
|
||||
});
|
||||
s1.step(30);
|
||||
|
||||
let mut s2 = setup();
|
||||
s2.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 135.0, y: 120.0 });
|
||||
s2.perform_action(&AiAction::DamageEntity { id: 1, amount: 20.0 });
|
||||
s2.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 135.0,
|
||||
y: 120.0,
|
||||
});
|
||||
s2.perform_action(&AiAction::DamageEntity {
|
||||
id: 1,
|
||||
amount: 20.0,
|
||||
});
|
||||
s2.step(30);
|
||||
|
||||
let e1 = s1.get_entities().into_iter().find(|e| e.id == 1).unwrap();
|
||||
let e2 = s2.get_entities().into_iter().find(|e| e.id == 1).unwrap();
|
||||
assert!((e1.health - e2.health).abs() < 0.01, "health should match: {} vs {}", e1.health, e2.health);
|
||||
assert!(
|
||||
(e1.health - e2.health).abs() < 0.01,
|
||||
"health should match: {} vs {}",
|
||||
e1.health,
|
||||
e2.health
|
||||
);
|
||||
assert!((e1.pos[0] - e2.pos[0]).abs() < 0.1, "x should match");
|
||||
assert!((e1.pos[1] - e2.pos[1]).abs() < 0.1, "y should match");
|
||||
}
|
||||
@@ -150,5 +210,10 @@ fn hundred_tick_determinism() {
|
||||
|
||||
let p1 = s1.get_player().unwrap();
|
||||
let p2 = s2.get_player().unwrap();
|
||||
assert!((p1.pos[0] - p2.pos[0]).abs() < 0.01, "100-tick determinism failed: {} vs {}", p1.pos[0], p2.pos[0]);
|
||||
assert!(
|
||||
(p1.pos[0] - p2.pos[0]).abs() < 0.01,
|
||||
"100-tick determinism failed: {} vs {}",
|
||||
p1.pos[0],
|
||||
p2.pos[0]
|
||||
);
|
||||
}
|
||||
|
||||
+93
-17
@@ -1,5 +1,5 @@
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
fn setup_empty() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
@@ -11,39 +11,92 @@ fn setup_empty() -> GameSession {
|
||||
#[test]
|
||||
fn entity_takes_lava_damage() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 130, w: 20, h: 3, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 114, y: 127, w: 8, h: 3, material: "lava".into() });
|
||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 118.0, y: 118.0 });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 130,
|
||||
w: 20,
|
||||
h: 3,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 114,
|
||||
y: 127,
|
||||
w: 8,
|
||||
h: 3,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 118.0,
|
||||
y: 118.0,
|
||||
});
|
||||
s.step(80);
|
||||
let entities = s.get_entities();
|
||||
let goblin = entities.into_iter().find(|e| e.kind == "Goblin");
|
||||
assert!(goblin.is_some(), "goblin should exist");
|
||||
let g = goblin.unwrap();
|
||||
assert!(g.health < 40.0, "goblin should have taken damage from lava, hp={}", g.health);
|
||||
assert!(
|
||||
g.health < 40.0,
|
||||
"goblin should have taken damage from lava, hp={}",
|
||||
g.health
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_dies_becomes_corpse() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 20, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 110.0, y: 120.0 });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 125,
|
||||
w: 20,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 110.0,
|
||||
y: 120.0,
|
||||
});
|
||||
s.step(30);
|
||||
s.perform_action(&AiAction::DamageEntity { id: 1, amount: 100.0 });
|
||||
s.perform_action(&AiAction::DamageEntity {
|
||||
id: 1,
|
||||
amount: 100.0,
|
||||
});
|
||||
s.step(1);
|
||||
let entities = s.get_entities();
|
||||
let goblin = entities.into_iter().find(|e| e.id == 1);
|
||||
assert!(goblin.is_some(), "entity should still exist");
|
||||
let g = goblin.unwrap();
|
||||
assert!(!g.alive, "entity should be dead after 100 damage");
|
||||
assert_eq!(g.kind, "Corpse", "dead entity should be a corpse, got {}", g.kind);
|
||||
assert_eq!(
|
||||
g.kind, "Corpse",
|
||||
"dead entity should be a corpse, got {}",
|
||||
g.kind
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_on_fire_takes_damage_over_time() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 20, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 116, y: 123, w: 4, h: 2, material: "lava".into() });
|
||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 118.0, y: 120.0 });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 125,
|
||||
w: 20,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 116,
|
||||
y: 123,
|
||||
w: 4,
|
||||
h: 2,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 118.0,
|
||||
y: 120.0,
|
||||
});
|
||||
s.step(20);
|
||||
let entities = s.get_entities();
|
||||
let goblin = entities.into_iter().find(|e| e.id == 1);
|
||||
@@ -53,7 +106,10 @@ fn entity_on_fire_takes_damage_over_time() {
|
||||
s.step(30);
|
||||
let entities2 = s.get_entities();
|
||||
if let Some(g2) = entities2.into_iter().find(|e| e.id == 1) {
|
||||
assert!(g2.health < hp_after_fire, "entity on fire should lose more health over time");
|
||||
assert!(
|
||||
g2.health < hp_after_fire,
|
||||
"entity on fire should lose more health over time"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,12 +118,32 @@ fn entity_on_fire_takes_damage_over_time() {
|
||||
#[test]
|
||||
fn entity_blocked_by_stone() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 130, w: 30, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 110, y: 126, w: 1, h: 4, material: "stone".into() });
|
||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 105.0, y: 125.0 });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 130,
|
||||
w: 30,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 110,
|
||||
y: 126,
|
||||
w: 1,
|
||||
h: 4,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 105.0,
|
||||
y: 125.0,
|
||||
});
|
||||
s.step(30);
|
||||
let entities = s.get_entities();
|
||||
if let Some(g) = entities.into_iter().find(|e| e.id == 1) {
|
||||
assert!(g.pos[0] < 110.0, "goblin should be blocked by stone wall, got x={}", g.pos[0]);
|
||||
assert!(
|
||||
g.pos[0] < 110.0,
|
||||
"goblin should be blocked by stone wall, got x={}",
|
||||
g.pos[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
fn setup_empty() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
s.init_empty();
|
||||
s.clear_area(95, 95, 40, 35);
|
||||
s.perform_action(&AiAction::FillRect { x: 80, y: 135, w: 80, h: 15, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 80,
|
||||
y: 135,
|
||||
w: 80,
|
||||
h: 15,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
@@ -14,16 +20,32 @@ fn player_falls_and_lands() {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
s.init_empty();
|
||||
s.clear_area(115, 115, 20, 15);
|
||||
s.perform_action(&AiAction::FillRect { x: 110, y: 130, w: 30, h: 15, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 110,
|
||||
y: 130,
|
||||
w: 30,
|
||||
h: 15,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.step(80);
|
||||
let player = s.get_player().expect("player should exist");
|
||||
assert!(player.alive, "player should be alive");
|
||||
let y = player.pos[1];
|
||||
assert!(y < 135.0, "player should not fall through stone floor, got y={}", y);
|
||||
assert!(
|
||||
y < 135.0,
|
||||
"player should not fall through stone floor, got y={}",
|
||||
y
|
||||
);
|
||||
s.step(30);
|
||||
let player2 = s.get_player().expect("player should exist");
|
||||
let dy = (player2.pos[1] - y).abs();
|
||||
assert!(dy < 3.0, "player should have stopped falling (dy={:.2}), y={} -> {}", dy, y, player2.pos[1]);
|
||||
assert!(
|
||||
dy < 3.0,
|
||||
"player should have stopped falling (dy={:.2}), y={} -> {}",
|
||||
dy,
|
||||
y,
|
||||
player2.pos[1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -32,11 +54,20 @@ fn player_blocked_by_stone_wall() {
|
||||
s.step(30);
|
||||
let player = s.get_player().expect("player should exist");
|
||||
let x = player.pos[0] as i32;
|
||||
s.perform_action(&AiAction::FillRect { x: x + 5, y: 125, w: 1, h: 10, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: x + 5,
|
||||
y: 125,
|
||||
w: 1,
|
||||
h: 10,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::MoveRight);
|
||||
s.step(10);
|
||||
let player = s.get_player().expect("player should exist");
|
||||
assert!(player.pos[0] < (x + 5) as f32, "player should be blocked by wall");
|
||||
assert!(
|
||||
player.pos[0] < (x + 5) as f32,
|
||||
"player should be blocked by wall"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -48,7 +79,12 @@ fn player_can_move_right() {
|
||||
s.perform_action(&AiAction::MoveRight);
|
||||
s.step(10);
|
||||
let player = s.get_player().expect("player should exist");
|
||||
assert!(player.pos[0] > initial_x, "player should have moved right: {} -> {}", initial_x, player.pos[0]);
|
||||
assert!(
|
||||
player.pos[0] > initial_x,
|
||||
"player should have moved right: {} -> {}",
|
||||
initial_x,
|
||||
player.pos[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -57,5 +93,9 @@ fn player_survives_fall() {
|
||||
s.step(60);
|
||||
let player = s.get_player().expect("player should exist");
|
||||
assert!(player.alive, "player should survive a fall onto stone");
|
||||
assert!(player.health > 50.0, "player should not take significant damage from landing, hp={}", player.health);
|
||||
assert!(
|
||||
player.health > 50.0,
|
||||
"player should not take significant damage from landing, hp={}",
|
||||
player.health
|
||||
);
|
||||
}
|
||||
|
||||
+49
-12
@@ -1,5 +1,5 @@
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::ReplayPlayer;
|
||||
|
||||
#[test]
|
||||
@@ -13,7 +13,8 @@ fn replay_deterministic() {
|
||||
s1.step(10);
|
||||
let state1 = s1.get_state();
|
||||
|
||||
s1.save_replay("/tmp/verbatim_test_replay.json").expect("save replay");
|
||||
s1.save_replay("/tmp/verbatim_test_replay.json")
|
||||
.expect("save replay");
|
||||
|
||||
let player = ReplayPlayer::load("/tmp/verbatim_test_replay.json").expect("load replay");
|
||||
let s2 = player.play();
|
||||
@@ -22,8 +23,18 @@ fn replay_deterministic() {
|
||||
assert_eq!(state1.tick, state2.tick, "ticks should match");
|
||||
if let (Some(p1), Some(p2)) = (&state1.player, &state2.player) {
|
||||
assert_eq!(p1.health, p2.health, "player health should match");
|
||||
assert!((p1.pos[0] - p2.pos[0]).abs() < 0.01, "player x should match: {} vs {}", p1.pos[0], p2.pos[0]);
|
||||
assert!((p1.pos[1] - p2.pos[1]).abs() < 0.01, "player y should match: {} vs {}", p1.pos[1], p2.pos[1]);
|
||||
assert!(
|
||||
(p1.pos[0] - p2.pos[0]).abs() < 0.01,
|
||||
"player x should match: {} vs {}",
|
||||
p1.pos[0],
|
||||
p2.pos[0]
|
||||
);
|
||||
assert!(
|
||||
(p1.pos[1] - p2.pos[1]).abs() < 0.01,
|
||||
"player y should match: {} vs {}",
|
||||
p1.pos[1],
|
||||
p2.pos[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,14 +49,25 @@ fn replay_play_until_tick() {
|
||||
s.step(5);
|
||||
s.perform_action(&AiAction::Jump);
|
||||
s.step(10);
|
||||
s.save_replay("/tmp/verbatim_test_replay2.json").expect("save");
|
||||
s.save_replay("/tmp/verbatim_test_replay2.json")
|
||||
.expect("save");
|
||||
|
||||
let player = ReplayPlayer::load("/tmp/verbatim_test_replay2.json").expect("load");
|
||||
let s_half = player.play_until_tick(5);
|
||||
assert_eq!(s_half.tick(), 5, "should stop at tick 5, got {}", s_half.tick());
|
||||
assert_eq!(
|
||||
s_half.tick(),
|
||||
5,
|
||||
"should stop at tick 5, got {}",
|
||||
s_half.tick()
|
||||
);
|
||||
|
||||
let s_full = player.play();
|
||||
assert_eq!(s_full.tick(), 20, "full replay should reach tick 20, got {}", s_full.tick());
|
||||
assert_eq!(
|
||||
s_full.tick(),
|
||||
20,
|
||||
"full replay should reach tick 20, got {}",
|
||||
s_full.tick()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -62,8 +84,18 @@ fn same_seed_same_state() {
|
||||
|
||||
assert_eq!(state1.tick, state2.tick);
|
||||
if let (Some(p1), Some(p2)) = (&state1.player, &state2.player) {
|
||||
assert!((p1.pos[0] - p2.pos[0]).abs() < 0.01, "x mismatch: {} vs {}", p1.pos[0], p2.pos[0]);
|
||||
assert!((p1.pos[1] - p2.pos[1]).abs() < 0.01, "y mismatch: {} vs {}", p1.pos[1], p2.pos[1]);
|
||||
assert!(
|
||||
(p1.pos[0] - p2.pos[0]).abs() < 0.01,
|
||||
"x mismatch: {} vs {}",
|
||||
p1.pos[0],
|
||||
p2.pos[0]
|
||||
);
|
||||
assert!(
|
||||
(p1.pos[1] - p2.pos[1]).abs() < 0.01,
|
||||
"y mismatch: {} vs {}",
|
||||
p1.pos[1],
|
||||
p2.pos[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +105,8 @@ fn pipe_protocol_init_and_step() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_verbatim"))
|
||||
.arg("--mode").arg("pipe")
|
||||
.arg("--mode")
|
||||
.arg("pipe")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
@@ -98,9 +131,13 @@ fn pipe_protocol_init_and_step() {
|
||||
stdin.flush().expect("flush");
|
||||
let n = stdout.read(&mut buf).expect("read");
|
||||
let output2 = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
let json2: serde_json::Value = serde_json::from_str(output2.trim()).expect("parse step response");
|
||||
let json2: serde_json::Value =
|
||||
serde_json::from_str(output2.trim()).expect("parse step response");
|
||||
assert_eq!(json2["ok"], true);
|
||||
assert_eq!(json2["state"]["tick"], 10, "tick should be 10 after stepping 10");
|
||||
assert_eq!(
|
||||
json2["state"]["tick"], 10,
|
||||
"tick should be 10 after stepping 10"
|
||||
);
|
||||
|
||||
writeln!(stdin, "{{\"cmd\":\"quit\"}}").expect("write quit");
|
||||
stdin.flush().expect("flush");
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
use verbatim::game::Game;
|
||||
|
||||
#[test]
|
||||
#[ignore = "slow: generates a 12500x12500 world"]
|
||||
fn large_world_initialization() {
|
||||
let mut game = Game::new_random();
|
||||
assert!(game.grid.is_infinite());
|
||||
game.init_world();
|
||||
let (px, py) = game.player.center(&game.entities);
|
||||
assert!(px >= 0.0 && py >= 0.0);
|
||||
let foot_x = px as i32;
|
||||
let foot_y = (py + 3.0).ceil() as i32;
|
||||
assert!(
|
||||
!game.grid.get(foot_x, foot_y).is_solid(),
|
||||
"player spawn should not be inside solid: px={} py={} foot=({},{}) mat={:?}",
|
||||
px,
|
||||
py,
|
||||
foot_x,
|
||||
foot_y,
|
||||
game.grid.get(foot_x, foot_y).material
|
||||
);
|
||||
if let Some(ref root) = game.cache_dir {
|
||||
let meta = verbatim::world::cache::WorldCache::meta_path(root, game.seed);
|
||||
assert!(
|
||||
meta.exists(),
|
||||
"world cache should be written after generation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "slow: loads cached 12500x12500 world"]
|
||||
fn large_world_cache_roundtrip() {
|
||||
let mut game = Game::new_random();
|
||||
game.init_world();
|
||||
let (px, py) = game.player.center(&game.entities);
|
||||
let item_count = game.items.all().len();
|
||||
let root = game.cache_dir.clone().unwrap();
|
||||
let seed = game.seed;
|
||||
|
||||
let mut game2 = Game::new_random();
|
||||
game2.seed = seed;
|
||||
game2.ca.seed(seed);
|
||||
game2.cache_dir = Some(root);
|
||||
game2.init_world();
|
||||
|
||||
let (px2, py2) = game2.player.center(&game2.entities);
|
||||
assert!((px - px2).abs() < 0.01 && (py - py2).abs() < 0.01);
|
||||
assert_eq!(game2.items.all().len(), item_count);
|
||||
assert!(game2.grid.is_infinite());
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
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 grid = ChunkedGrid::with_size(250, 250);
|
||||
grid.set_pressure(100, 100, 200);
|
||||
grid.set_pressure(101, 100, 128);
|
||||
grid.mark_dirty(100, 100);
|
||||
grid.mark_dirty(101, 100);
|
||||
let mut ca = verbatim::world::cellular::CellularAutomaton::new();
|
||||
for _ in 0..100 {
|
||||
ca.step(&mut grid);
|
||||
}
|
||||
let p1 = grid.get_pressure(100, 100);
|
||||
let p2 = 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 grid = ChunkedGrid::with_size(250, 250);
|
||||
for &(dx, dy) in &[
|
||||
(0, 0),
|
||||
(0, 1),
|
||||
(0, -1),
|
||||
(1, 0),
|
||||
(-1, 0),
|
||||
(1, 1),
|
||||
(-1, 1),
|
||||
(1, -1),
|
||||
(-1, -1),
|
||||
] {
|
||||
grid.set_material(
|
||||
100 + dx,
|
||||
100 + dy,
|
||||
if dx == 0 && dy == 0 {
|
||||
MaterialId::Lava
|
||||
} else {
|
||||
MaterialId::Stone
|
||||
},
|
||||
);
|
||||
}
|
||||
grid.mark_dirty(100, 100);
|
||||
let mut ca = verbatim::world::cellular::CellularAutomaton::new();
|
||||
for _ in 0..100 {
|
||||
ca.step(&mut grid);
|
||||
}
|
||||
let l = 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(40);
|
||||
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);
|
||||
grid.mark_dirty(boundary - 1, 0);
|
||||
grid.mark_dirty(boundary, 0);
|
||||
let mut ca = verbatim::world::cellular::CellularAutomaton::new();
|
||||
for _ in 0..200 {
|
||||
ca.step(&mut grid);
|
||||
}
|
||||
let t_near = grid.get_temp(boundary - 1, 0);
|
||||
let t_far = grid.get_temp(boundary, 0);
|
||||
assert!(
|
||||
t_near < 400.0,
|
||||
"heat should diffuse from hot cell, got {:.1}",
|
||||
t_near
|
||||
);
|
||||
assert!(
|
||||
(t_near - t_far).abs() < 200.0,
|
||||
"temperatures should converge somewhat, near={:.1} far={:.1}",
|
||||
t_near,
|
||||
t_far
|
||||
);
|
||||
}
|
||||
+60
-12
@@ -1,5 +1,5 @@
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
fn setup_empty() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
@@ -11,28 +11,76 @@ fn setup_empty() -> GameSession {
|
||||
#[test]
|
||||
fn acid_dissolves_wood() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 104, y: 118, material: "wood".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "acid".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 104, y: 119, material: "acid".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 120,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 104,
|
||||
y: 118,
|
||||
material: "wood".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 119,
|
||||
material: "acid".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 104,
|
||||
y: 119,
|
||||
material: "acid".into(),
|
||||
});
|
||||
s.step(30);
|
||||
assert_ne!(s.get_cell(104, 118).material, "wood", "acid should have dissolved the wood");
|
||||
assert_ne!(
|
||||
s.get_cell(104, 118).material,
|
||||
"wood",
|
||||
"acid should have dissolved the wood"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acid_does_not_dissolve_stone() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 111, material: "acid".into() });
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 110,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 111,
|
||||
material: "acid".into(),
|
||||
});
|
||||
s.step(20);
|
||||
assert_eq!(s.get_cell(105, 110).material, "stone", "acid should not dissolve stone");
|
||||
assert_eq!(
|
||||
s.get_cell(105, 110).material,
|
||||
"stone",
|
||||
"acid should not dissolve stone"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acid_flows_down() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "acid".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 120,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 105,
|
||||
material: "acid".into(),
|
||||
});
|
||||
s.step(20);
|
||||
assert_ne!(s.get_cell(105, 105).material, "acid", "acid should have flowed down from y=105");
|
||||
assert_ne!(
|
||||
s.get_cell(105, 105).material,
|
||||
"acid",
|
||||
"acid should have flowed down from y=105"
|
||||
);
|
||||
}
|
||||
|
||||
+296
-54
@@ -1,8 +1,8 @@
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
fn setup() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
let mut s = GameSession::new_seeded(2);
|
||||
s.init_empty();
|
||||
s.clear_area(90, 90, 50, 50);
|
||||
s
|
||||
@@ -11,9 +11,23 @@ fn setup() -> GameSession {
|
||||
#[test]
|
||||
fn lava_flows_down_on_stone() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 115, material: "lava".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 116, material: "lava".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 125,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 115,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 116,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.step(15);
|
||||
let lava_at_floor = s.count_material_in_region(103, 122, 5, 4, "lava");
|
||||
assert!(lava_at_floor > 0, "lava should flow down to stone floor");
|
||||
@@ -22,25 +36,69 @@ fn lava_flows_down_on_stone() {
|
||||
#[test]
|
||||
fn lava_cools_to_stone_eventually() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 120, material: "lava".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 121, material: "lava".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 122, material: "lava".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 123, material: "lava".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 124, material: "lava".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 125,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 120,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 121,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 122,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 123,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 124,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.step(200);
|
||||
let stone_count = s.count_material_in_region(103, 120, 5, 6, "stone");
|
||||
assert!(stone_count >= 3, "lava should cool to stone eventually, got {} stone", stone_count);
|
||||
assert!(
|
||||
stone_count >= 3,
|
||||
"lava should cool to stone eventually, got {} stone",
|
||||
stone_count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_spreads_through_wood_line() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 20, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 120,
|
||||
w: 20,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
for x in 100..110 {
|
||||
s.perform_action(&AiAction::SetCell { x, y: 119, material: "wood".into() });
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x,
|
||||
y: 119,
|
||||
material: "wood".into(),
|
||||
});
|
||||
}
|
||||
s.perform_action(&AiAction::SetCell { x: 100, y: 119, material: "fire".into() });
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 100,
|
||||
y: 119,
|
||||
material: "fire".into(),
|
||||
});
|
||||
s.step(40);
|
||||
let wood_left = s.count_material_in_region(100, 118, 10, 3, "wood");
|
||||
assert_eq!(wood_left, 0, "fire should spread through entire wood line");
|
||||
@@ -49,56 +107,173 @@ fn fire_spreads_through_wood_line() {
|
||||
#[test]
|
||||
fn acid_does_not_dissolve_empty() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 104, y: 114, w: 3, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 104, y: 113, w: 1, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 106, y: 113, w: 1, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "acid".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 115,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 104,
|
||||
y: 114,
|
||||
w: 3,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 104,
|
||||
y: 113,
|
||||
w: 1,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 106,
|
||||
y: 113,
|
||||
w: 1,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 113,
|
||||
material: "acid".into(),
|
||||
});
|
||||
s.step(5);
|
||||
let cell = s.get_cell(105, 113);
|
||||
assert!(cell.material == "acid" || cell.material == "empty",
|
||||
"acid may flow out but should not dissolve empty, got {}", cell.material);
|
||||
assert!(
|
||||
cell.material == "acid" || cell.material == "empty",
|
||||
"acid may flow out but should not dissolve empty, got {}",
|
||||
cell.material
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acid_dissolves_grass() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 104, y: 119, material: "grass".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "acid".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 104, y: 118, material: "acid".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 120,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 104,
|
||||
y: 119,
|
||||
material: "grass".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 119,
|
||||
material: "acid".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 104,
|
||||
y: 118,
|
||||
material: "acid".into(),
|
||||
});
|
||||
s.step(20);
|
||||
assert_ne!(s.get_cell(104, 119).material, "grass", "acid should dissolve grass");
|
||||
assert_ne!(
|
||||
s.get_cell(104, 119).material,
|
||||
"grass",
|
||||
"acid should dissolve grass"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acid_dissolves_dirt() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 104, y: 114, material: "dirt".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "acid".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 104, y: 113, material: "acid".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 115,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 104,
|
||||
y: 114,
|
||||
material: "dirt".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 114,
|
||||
material: "acid".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 104,
|
||||
y: 113,
|
||||
material: "acid".into(),
|
||||
});
|
||||
s.step(30);
|
||||
assert_ne!(s.get_cell(104, 114).material, "dirt", "acid should dissolve dirt");
|
||||
assert_ne!(
|
||||
s.get_cell(104, 114).material,
|
||||
"dirt",
|
||||
"acid should dissolve dirt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn water_extinguishes_fire_indirectly() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "fire".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 118, material: "water".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 120,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 119,
|
||||
material: "fire".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 118,
|
||||
material: "water".into(),
|
||||
});
|
||||
s.step(20);
|
||||
assert_ne!(s.get_cell(105, 119).material, "fire", "water should extinguish fire");
|
||||
assert_ne!(
|
||||
s.get_cell(105, 119).material,
|
||||
"fire",
|
||||
"water should extinguish fire"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lava_and_water_produce_both_steam_and_stone() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 20, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 95, w: 20, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 101, y: 115, w: 4, h: 3, material: "lava".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 106, y: 115, w: 4, h: 3, material: "water".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 125,
|
||||
w: 20,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 95,
|
||||
w: 20,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 101,
|
||||
y: 115,
|
||||
w: 4,
|
||||
h: 3,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 106,
|
||||
y: 115,
|
||||
w: 4,
|
||||
h: 3,
|
||||
material: "water".into(),
|
||||
});
|
||||
s.step(30);
|
||||
let steam = s.count_material_in_region(100, 100, 20, 20, "steam");
|
||||
assert!(steam > 0, "lava + water should produce steam");
|
||||
@@ -107,41 +282,108 @@ fn lava_and_water_produce_both_steam_and_stone() {
|
||||
#[test]
|
||||
fn sand_falls_through_water() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 104, y: 118, w: 3, h: 7, material: "water".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "sand".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 125,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 104,
|
||||
y: 118,
|
||||
w: 3,
|
||||
h: 7,
|
||||
material: "water".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 113,
|
||||
material: "sand".into(),
|
||||
});
|
||||
s.step(30);
|
||||
let sand_at_bottom = s.get_cell(105, 124).material;
|
||||
assert_eq!(sand_at_bottom, "sand", "sand should sink through water to bottom");
|
||||
assert_eq!(
|
||||
sand_at_bottom, "sand",
|
||||
"sand should sink through water to bottom"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_does_not_ignite_stone() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::SetCell { x: 104, y: 110, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "fire".into() });
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 104,
|
||||
y: 110,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 110,
|
||||
material: "fire".into(),
|
||||
});
|
||||
s.step(30);
|
||||
assert_eq!(s.get_cell(104, 110).material, "stone", "fire should not ignite stone");
|
||||
assert_eq!(
|
||||
s.get_cell(104, 110).material,
|
||||
"stone",
|
||||
"fire should not ignite stone"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_does_not_ignite_water() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 104, y: 114, material: "water".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "fire".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 115,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 104,
|
||||
y: 114,
|
||||
material: "water".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 114,
|
||||
material: "fire".into(),
|
||||
});
|
||||
s.step(5);
|
||||
let cell = s.get_cell(104, 114);
|
||||
assert_ne!(cell.material, "fire", "water should never become fire, got {}", cell.material);
|
||||
assert_ne!(
|
||||
cell.material, "fire",
|
||||
"water should never become fire, got {}",
|
||||
cell.material
|
||||
);
|
||||
assert_ne!(cell.material, "wood", "water should never become wood");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn water_does_not_flow_through_dirt_wall() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 20, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 109, y: 120, w: 1, h: 5, material: "dirt".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 9, h: 5, material: "water".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 125,
|
||||
w: 20,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 109,
|
||||
y: 120,
|
||||
w: 1,
|
||||
h: 5,
|
||||
material: "dirt".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 120,
|
||||
w: 9,
|
||||
h: 5,
|
||||
material: "water".into(),
|
||||
});
|
||||
s.step(30);
|
||||
let right_water = s.count_material_in_region(110, 118, 5, 8, "water");
|
||||
assert_eq!(right_water, 0, "water should not flow through dirt wall");
|
||||
|
||||
+113
-21
@@ -1,5 +1,5 @@
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
fn setup_empty() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
@@ -11,48 +11,140 @@ fn setup_empty() -> GameSession {
|
||||
#[test]
|
||||
fn lava_flows_down() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 3, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "lava".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 111, material: "lava".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 115,
|
||||
w: 10,
|
||||
h: 3,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 110,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 111,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.step(10);
|
||||
let lava_count = s.count_material_in_region(103, 110, 5, 6, "lava");
|
||||
let stone_count = s.count_material_in_region(103, 110, 5, 6, "stone");
|
||||
assert!(lava_count > 0 || stone_count >= 4,
|
||||
"lava should have flowed down or cooled to stone, lava={} stone={}", lava_count, stone_count);
|
||||
assert!(
|
||||
lava_count > 0 || stone_count >= 4,
|
||||
"lava should have flowed down or cooled to stone, lava={} stone={}",
|
||||
lava_count,
|
||||
stone_count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lava_plus_water_makes_steam() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 20, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 99, w: 20, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 102, y: 110, w: 4, h: 3, material: "lava".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 108, y: 110, w: 4, h: 3, material: "water".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 120,
|
||||
w: 20,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 99,
|
||||
w: 20,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 102,
|
||||
y: 110,
|
||||
w: 4,
|
||||
h: 3,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 108,
|
||||
y: 110,
|
||||
w: 4,
|
||||
h: 3,
|
||||
material: "water".into(),
|
||||
});
|
||||
s.step(40);
|
||||
let steam_count = s.count_material_in_region(100, 100, 20, 15, "steam");
|
||||
assert!(steam_count > 0, "lava + water should produce steam, got {} steam cells", steam_count);
|
||||
assert!(
|
||||
steam_count > 0,
|
||||
"lava + water should produce steam, got {} steam cells",
|
||||
steam_count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lava_ignites_wood() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 104, y: 114, material: "wood".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "wood".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 106, y: 114, material: "wood".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "lava".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 115,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 104,
|
||||
y: 114,
|
||||
material: "wood".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 114,
|
||||
material: "wood".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 106,
|
||||
y: 114,
|
||||
material: "wood".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 113,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.step(15);
|
||||
let wood_remaining = s.count_material_in_region(103, 113, 5, 3, "wood");
|
||||
assert_eq!(wood_remaining, 0, "all wood should have been ignited by lava, got {} wood cells", wood_remaining);
|
||||
assert_eq!(
|
||||
wood_remaining, 0,
|
||||
"all wood should have been ignited by lava, got {} wood cells",
|
||||
wood_remaining
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lava_ignites_grass() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 114, w: 5, h: 1, material: "grass".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "lava".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "lava".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 115,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 114,
|
||||
w: 5,
|
||||
h: 1,
|
||||
material: "grass".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 113,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 114,
|
||||
material: "lava".into(),
|
||||
});
|
||||
s.step(15);
|
||||
let grass_remaining = s.count_material_in_region(99, 113, 7, 3, "grass");
|
||||
assert_eq!(grass_remaining, 0, "grass should have been ignited by lava");
|
||||
|
||||
+86
-16
@@ -1,5 +1,5 @@
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
fn setup_empty() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
@@ -11,19 +11,53 @@ fn setup_empty() -> GameSession {
|
||||
#[test]
|
||||
fn sand_falls_down() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 115,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 105,
|
||||
material: "sand".into(),
|
||||
});
|
||||
s.step(10);
|
||||
assert_eq!(s.get_cell(105, 105).material, "empty", "sand should have fallen from y=105");
|
||||
assert_eq!(s.get_cell(105, 114).material, "sand", "sand should be resting on stone at y=114");
|
||||
assert_eq!(
|
||||
s.get_cell(105, 105).material,
|
||||
"empty",
|
||||
"sand should have fallen from y=105"
|
||||
);
|
||||
assert_eq!(
|
||||
s.get_cell(105, 114).material,
|
||||
"sand",
|
||||
"sand should be resting on stone at y=114"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sand_displaces_water() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 110, w: 10, h: 5, material: "water".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 115,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 110,
|
||||
w: 10,
|
||||
h: 5,
|
||||
material: "water".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 105,
|
||||
material: "sand".into(),
|
||||
});
|
||||
s.step(20);
|
||||
let sand_at_bottom = s.get_cell(105, 114).material == "sand";
|
||||
assert!(sand_at_bottom, "sand should sink to bottom through water");
|
||||
@@ -32,20 +66,56 @@ fn sand_displaces_water() {
|
||||
#[test]
|
||||
fn sand_piles_on_stone() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 104, material: "sand".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 115,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 105,
|
||||
material: "sand".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 104,
|
||||
material: "sand".into(),
|
||||
});
|
||||
s.step(15);
|
||||
let count = s.count_material_in_region(104, 112, 3, 4, "sand");
|
||||
assert!(count >= 2, "both sand cells should have piled up, got {} sand cells", count);
|
||||
assert!(
|
||||
count >= 2,
|
||||
"both sand cells should have piled up, got {} sand cells",
|
||||
count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sand_does_not_fall_through_stone() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 110, w: 10, h: 1, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 110,
|
||||
w: 10,
|
||||
h: 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 105,
|
||||
y: 105,
|
||||
material: "sand".into(),
|
||||
});
|
||||
s.step(10);
|
||||
assert_eq!(s.get_cell(105, 109).material, "sand", "sand should rest on top of stone");
|
||||
assert_eq!(s.get_cell(105, 110).material, "stone", "stone should remain");
|
||||
assert_eq!(
|
||||
s.get_cell(105, 109).material,
|
||||
"sand",
|
||||
"sand should rest on top of stone"
|
||||
);
|
||||
assert_eq!(
|
||||
s.get_cell(105, 110).material,
|
||||
"stone",
|
||||
"stone should remain"
|
||||
);
|
||||
}
|
||||
|
||||
+69
-12
@@ -1,5 +1,5 @@
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
fn setup_empty() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
@@ -11,32 +11,89 @@ fn setup_empty() -> GameSession {
|
||||
#[test]
|
||||
fn water_flows_down() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 95, y: 115, w: 40, h: 3, material: "stone".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 110, y: 105, material: "water".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 110, y: 106, material: "water".into() });
|
||||
s.perform_action(&AiAction::SetCell { x: 110, y: 107, material: "water".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 95,
|
||||
y: 115,
|
||||
w: 40,
|
||||
h: 3,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 110,
|
||||
y: 105,
|
||||
material: "water".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 110,
|
||||
y: 106,
|
||||
material: "water".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 110,
|
||||
y: 107,
|
||||
material: "water".into(),
|
||||
});
|
||||
s.step(20);
|
||||
let water_near_bottom = s.count_material_in_region(105, 112, 10, 4, "water");
|
||||
assert!(water_near_bottom > 0, "water should have flowed down to near the stone floor, found {} water cells near bottom", water_near_bottom);
|
||||
assert!(
|
||||
water_near_bottom > 0,
|
||||
"water should have flowed down to near the stone floor, found {} water cells near bottom",
|
||||
water_near_bottom
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn water_spreads_sideways() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 95, y: 115, w: 50, h: 3, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 110, y: 111, w: 1, h: 4, material: "water".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 95,
|
||||
y: 115,
|
||||
w: 50,
|
||||
h: 3,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 110,
|
||||
y: 111,
|
||||
w: 1,
|
||||
h: 4,
|
||||
material: "water".into(),
|
||||
});
|
||||
s.step(50);
|
||||
let left_count = s.count_material_in_region(100, 110, 10, 5, "water");
|
||||
let right_count = s.count_material_in_region(111, 110, 10, 5, "water");
|
||||
assert!(left_count > 0 || right_count > 0, "water should spread sideways: left={} right={}", left_count, right_count);
|
||||
assert!(
|
||||
left_count > 0 || right_count > 0,
|
||||
"water should spread sideways: left={} right={}",
|
||||
left_count,
|
||||
right_count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn water_does_not_pass_through_stone_wall() {
|
||||
let mut s = setup_empty();
|
||||
s.perform_action(&AiAction::FillRect { x: 95, y: 115, w: 50, h: 3, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 115, y: 110, w: 1, h: 5, material: "stone".into() });
|
||||
s.perform_action(&AiAction::FillRect { x: 100, y: 110, w: 15, h: 5, material: "water".into() });
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 95,
|
||||
y: 115,
|
||||
w: 50,
|
||||
h: 3,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 115,
|
||||
y: 110,
|
||||
w: 1,
|
||||
h: 5,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 110,
|
||||
w: 15,
|
||||
h: 5,
|
||||
material: "water".into(),
|
||||
});
|
||||
s.step(30);
|
||||
let right_water = s.count_material_in_region(116, 108, 10, 10, "water");
|
||||
assert_eq!(right_water, 0, "water should not pass through stone wall");
|
||||
|
||||
@@ -172,6 +172,7 @@ fn ui_hud_shows_player_hp() {
|
||||
let mut s = setup();
|
||||
let mut ui = verbatim::ui::UiLayer::new();
|
||||
let player = s.game.entities.all()[0].clone();
|
||||
ui.resize(80, 25);
|
||||
ui.draw_hud(
|
||||
80,
|
||||
25,
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
fn init_at_depth(depth: u32) -> GameSession {
|
||||
let mut s = GameSession::new_seeded(123);
|
||||
s.game.depth = depth;
|
||||
s.init();
|
||||
s
|
||||
}
|
||||
|
||||
fn count_distinct_materials(s: &GameSession) -> usize {
|
||||
let mut present = Vec::new();
|
||||
for y in 0..s.game.grid.height as i32 {
|
||||
for x in 0..s.game.grid.width as i32 {
|
||||
let mat = s.game.grid.get(x, y).material;
|
||||
if !present.contains(&mat) {
|
||||
present.push(mat);
|
||||
}
|
||||
}
|
||||
}
|
||||
present.len()
|
||||
}
|
||||
|
||||
fn total_empty(s: &GameSession) -> usize {
|
||||
let mut count = 0;
|
||||
for y in 0..s.game.grid.height as i32 {
|
||||
for x in 0..s.game.grid.width as i32 {
|
||||
if s.game.grid.get(x, y).is_empty() {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn player_not_in_solid(s: &GameSession) -> bool {
|
||||
let (px, py) = s.game.player.center(&s.game.entities);
|
||||
let ix = px as i32;
|
||||
let iy = py as i32;
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
if s.game.grid.get(ix + dx, iy + dy).is_solid() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn flood_fill_count(s: &GameSession, x: i32, y: i32) -> usize {
|
||||
let w = s.game.grid.width as i32;
|
||||
let h = s.game.grid.height as i32;
|
||||
let mut visited = vec![false; (w * h) as usize];
|
||||
let mut q = VecDeque::new();
|
||||
q.push_back((x, y));
|
||||
let mut count = 0;
|
||||
while let Some((cx, cy)) = q.pop_front() {
|
||||
let idx = (cy * w + cx) as usize;
|
||||
if visited[idx] || !s.game.grid.in_bounds(cx, cy) || !s.game.grid.get(cx, cy).is_empty() {
|
||||
continue;
|
||||
}
|
||||
visited[idx] = true;
|
||||
count += 1;
|
||||
for (dx, dy) in [(0, 1), (0, -1), (1, 0), (-1, 0)] {
|
||||
q.push_back((cx + dx, cy + dy));
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn count_empty_regions(s: &GameSession) -> usize {
|
||||
let w = s.game.grid.width as i32;
|
||||
let h = s.game.grid.height as i32;
|
||||
let mut visited = vec![false; (w * h) as usize];
|
||||
let mut regions = 0;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let idx = (y * w + x) as usize;
|
||||
if !visited[idx] && s.game.grid.get(x, y).is_empty() {
|
||||
regions += 1;
|
||||
let mut stack = vec![(x, y)];
|
||||
while let Some((cx, cy)) = stack.pop() {
|
||||
let i = (cy * w + cx) as usize;
|
||||
if visited[i]
|
||||
|| !s.game.grid.in_bounds(cx, cy)
|
||||
|| !s.game.grid.get(cx, cy).is_empty()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
visited[i] = true;
|
||||
for (dx, dy) in [(0, 1), (0, -1), (1, 0), (-1, 0)] {
|
||||
stack.push((cx + dx, cy + dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
regions
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_generation_has_stairs_and_open_spawn() {
|
||||
let s = init_at_depth(1);
|
||||
assert!(
|
||||
s.find_material("stairs").is_some(),
|
||||
"surface should have stairs"
|
||||
);
|
||||
assert!(
|
||||
player_not_in_solid(&s),
|
||||
"player should spawn in an open cell"
|
||||
);
|
||||
assert!(
|
||||
count_distinct_materials(&s) >= 3,
|
||||
"surface should have several materials"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cave_generation_has_stairs_and_connected_empty() {
|
||||
let s = init_at_depth(4);
|
||||
assert!(
|
||||
s.find_material("stairs").is_some(),
|
||||
"cave should have stairs"
|
||||
);
|
||||
assert!(
|
||||
player_not_in_solid(&s),
|
||||
"player should spawn in an open cell"
|
||||
);
|
||||
|
||||
let empty = total_empty(&s);
|
||||
assert!(
|
||||
empty > 100,
|
||||
"cave should have a meaningful empty region: {}",
|
||||
empty
|
||||
);
|
||||
|
||||
let (px, py) = s.game.player.center(&s.game.entities);
|
||||
let connected = flood_fill_count(&s, px as i32, py as i32);
|
||||
assert!(
|
||||
connected >= empty * 95 / 100,
|
||||
"cave should be mostly connected: {} of {}",
|
||||
connected,
|
||||
empty
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
count_empty_regions(&s),
|
||||
1,
|
||||
"cave should be a single connected empty region"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dungeon_generation_has_stairs_and_rooms() {
|
||||
let s = init_at_depth(7);
|
||||
assert!(
|
||||
s.find_material("stairs").is_some(),
|
||||
"dungeon should have stairs"
|
||||
);
|
||||
assert!(
|
||||
player_not_in_solid(&s),
|
||||
"player should spawn in an open cell"
|
||||
);
|
||||
assert!(
|
||||
count_distinct_materials(&s) >= 3,
|
||||
"dungeon should have several materials"
|
||||
);
|
||||
|
||||
let empty = total_empty(&s);
|
||||
assert!(
|
||||
empty > 500,
|
||||
"dungeon should have many room cells: {}",
|
||||
empty
|
||||
);
|
||||
|
||||
let regions = count_empty_regions(&s);
|
||||
assert!(
|
||||
regions >= 1 && regions <= 4,
|
||||
"dungeon rooms should be connected or nearly connected: {} regions",
|
||||
regions
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_depths_produce_different_structures() {
|
||||
let s1 = init_at_depth(1);
|
||||
let s2 = init_at_depth(4);
|
||||
let s3 = init_at_depth(7);
|
||||
|
||||
let empty1 = total_empty(&s1);
|
||||
let empty2 = total_empty(&s2);
|
||||
let empty3 = total_empty(&s3);
|
||||
|
||||
assert!(
|
||||
empty1 != empty2 || empty2 != empty3,
|
||||
"depths should differ in empty space: {} {} {}",
|
||||
empty1,
|
||||
empty2,
|
||||
empty3
|
||||
);
|
||||
|
||||
let grass1 = s1.count_material_in_region(
|
||||
0,
|
||||
0,
|
||||
s1.game.grid.width as i32,
|
||||
s1.game.grid.height as i32,
|
||||
"grass",
|
||||
);
|
||||
let grass2 = s2.count_material_in_region(
|
||||
0,
|
||||
0,
|
||||
s2.game.grid.width as i32,
|
||||
s2.game.grid.height as i32,
|
||||
"grass",
|
||||
);
|
||||
let grass3 = s3.count_material_in_region(
|
||||
0,
|
||||
0,
|
||||
s3.game.grid.width as i32,
|
||||
s3.game.grid.height as i32,
|
||||
"grass",
|
||||
);
|
||||
assert!(
|
||||
grass1 > grass2 && grass2 == grass3,
|
||||
"grass should dominate surface and vanish in deeper levels: {} {} {}",
|
||||
grass1,
|
||||
grass2,
|
||||
grass3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dungeon_has_large_empty_rooms() {
|
||||
let s = init_at_depth(8);
|
||||
let mut found_room = false;
|
||||
for y in 5..s.game.grid.height as i32 - 5 {
|
||||
for x in 5..s.game.grid.width as i32 - 5 {
|
||||
let mut w = 0;
|
||||
while x + w < s.game.grid.width as i32 - 5 && s.game.grid.get(x + w, y).is_empty() {
|
||||
w += 1;
|
||||
}
|
||||
let mut h = 0;
|
||||
while y + h < s.game.grid.height as i32 - 5 && s.game.grid.get(x, y + h).is_empty() {
|
||||
h += 1;
|
||||
}
|
||||
if w >= 5 && h >= 5 {
|
||||
found_room = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(found_room, "dungeon should contain rooms at least 5x5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_generation_respects_seeds() {
|
||||
let s1 = init_at_depth(3);
|
||||
let s2 = init_at_depth(3);
|
||||
let (p1, _) = s1.game.player.center(&s1.game.entities);
|
||||
let (p2, _) = s2.game.player.center(&s2.game.entities);
|
||||
assert!(
|
||||
(p1 - p2).abs() < 0.01,
|
||||
"same seed should place player at the same x"
|
||||
);
|
||||
let m1 = s1.find_material("stairs");
|
||||
let m2 = s2.find_material("stairs");
|
||||
assert_eq!(m1, m2, "same seed should place stairs at the same location");
|
||||
}
|
||||
Reference in New Issue
Block a user