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/
|
graphify-out/
|
||||||
headless_dump.txt
|
headless_dump.txt
|
||||||
headless_dump.png
|
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 test # run all JSON scenarios
|
||||||
cargo run -- --mode headless --headless-ticks 60 # dump to headless_dump.txt
|
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 -- --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
|
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:
|
- **AI observation** uses multi-spectrum ASCII layers via pipe protocol:
|
||||||
- `materials` — material type per cell
|
- `materials` — material type per cell
|
||||||
- `temperature` — heat levels encoded as characters
|
- `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
|
- `entities` — entity positions and types only
|
||||||
- `density` — material density visualization
|
- `density` — material density visualization
|
||||||
- `velocity` — entity movement speed and CA activity
|
- `velocity` — entity movement speed and CA activity
|
||||||
|
- `gas` — gas type + density per cell (NEW)
|
||||||
|
- `pressure` — pressure levels per cell (NEW)
|
||||||
|
|
||||||
## Tape System
|
## Tape System
|
||||||
|
|
||||||
@@ -42,7 +46,7 @@ Each tape frame contains:
|
|||||||
- Tick number, depth, kills, score
|
- Tick number, depth, kills, score
|
||||||
- Camera position
|
- Camera position
|
||||||
- Player HP, position, entity count
|
- Player HP, position, entity count
|
||||||
- All 6 spectrum layers as ASCII text
|
- All 8 spectrum layers as ASCII text
|
||||||
|
|
||||||
Pipe protocol spectrum commands:
|
Pipe protocol spectrum commands:
|
||||||
- `{"cmd":"get_spectrum","spectrum":"materials","w":80,"h":25}` — single spectrum
|
- `{"cmd":"get_spectrum","spectrum":"materials","w":80,"h":25}` — single spectrum
|
||||||
@@ -51,9 +55,9 @@ Pipe protocol spectrum commands:
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```sh
|
```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 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
|
cargo run -- --mode test --scenario-dir scenarios # 14 JSON scenarios
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -84,6 +88,7 @@ GPU (`--mode ascii` / `--mode graphics`):
|
|||||||
- `r` — drop first inventory item
|
- `r` — drop first inventory item
|
||||||
- `1`–`0` / `x` — paint material brush
|
- `1`–`0` / `x` — paint material brush
|
||||||
- `y` / `u` / `i` / `o` — move camera offset
|
- `y` / `u` / `i` / `o` — move camera offset
|
||||||
|
- `m` — toggle audio
|
||||||
- `q` / `esc` — quit
|
- `q` / `esc` — quit
|
||||||
|
|
||||||
## Shaders
|
## Shaders
|
||||||
@@ -101,11 +106,31 @@ SPV files are committed. `include_bytes!` embeds them at compile time.
|
|||||||
|
|
||||||
## Architecture
|
## 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).
|
**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.
|
**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.
|
**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.
|
**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`.
|
- **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.
|
- **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.
|
- **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
|
## Module Layout
|
||||||
|
|
||||||
@@ -155,12 +197,13 @@ src/
|
|||||||
lib.rs # pub mod declarations
|
lib.rs # pub mod declarations
|
||||||
game.rs # Game struct, world gen, fixed_update, collision, combat, slime AI
|
game.rs # Game struct, world gen, fixed_update, collision, combat, slime AI
|
||||||
input.rs # Terminal input (crossterm, InputHandler) — terminal mode only
|
input.rs # Terminal input (crossterm, InputHandler) — terminal mode only
|
||||||
world/ # Cell, MaterialId, MaterialRegistry, Grid, Chunk, CellularAutomaton
|
audio/mod.rs # AudioEngine (rodio), 15 embedded WAV sounds, ambient scanning
|
||||||
physics/ # VerletSolver, SubBody (with color field), Constraint, resolve_grid_collision
|
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
|
entity/ # Entity (rigid/ragdoll), EntityManager, Player, BodyTemplate, Item, ItemManager
|
||||||
render/ # terminal.rs, vulkan.rs (ASCII), graphics.rs (cells), lighting.rs, window_input.rs
|
render/ # terminal.rs, vulkan.rs (ASCII), graphics.rs (cells), lighting.rs, window_input.rs, capture.rs
|
||||||
ai/ # GameSession, AiAction, pipe protocol, replay, scenarios
|
ai/ # GameSession, AiAction, pipe protocol, replay, scenarios, spectrum
|
||||||
ui/ # UiLayer, HUD, messages, damage numbers, edge indicators
|
ui/ # UiLayer (flat Vec), HUD, messages, damage numbers, edge indicators, scalable font
|
||||||
```
|
```
|
||||||
|
|
||||||
## PLAN.md
|
## PLAN.md
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ ash-window = "0.13"
|
|||||||
raw-window-handle = "0.6"
|
raw-window-handle = "0.6"
|
||||||
bytemuck = { version = "1", features = ["derive"] }
|
bytemuck = { version = "1", features = ["derive"] }
|
||||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
rodio = { version = "0.20", default-features = false, features = ["wav"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
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 |
|
| System | Status | Details |
|
||||||
|--------|--------|---------|
|
|--------|--------|---------|
|
||||||
| Cellular automaton | Working | 14 materials: sand, water, stone, lava, wood, flesh, bone, steam, fire, acid, smoke, grass, dirt, empty |
|
| 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 |
|
| Ragdoll corpses | Working | Verlet constraints, death = rigid→ragdoll transition with inherited velocity |
|
||||||
| Terminal renderer | Working | Full terminal size, ANSI truecolor, diff-based rendering |
|
| 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 |
|
| 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 |
|
| AI pipe protocol | Working | JSON stdin/stdout, 16 commands, full state export, 8 spectrums (materials/temp/light/entities/density/velocity/gas/pressure) |
|
||||||
| Test framework | Working | 109 Rust tests + 14 JSON scenarios, all passing |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Adaptive viewport | Working | Window resize → more/fewer cells visible, cells stay 8x8 pixels |
|
||||||
| Per-cell color (reality layer) | Working | Each cell stores fg/bg color inline, no registry lookup in render path |
|
| 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
|
### 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:
|
Three entity types:
|
||||||
1. Cellular — materials in grid, per-cell CA rules
|
1. Cellular — materials in grid, per-cell CA rules
|
||||||
2. Rigid — alive entities, AABB collider, slope stepping, single velocity
|
2. Rigid — alive entities, AABB collider, slope stepping, single velocity
|
||||||
3. Ragdoll — corpses, loose Verlet bodies, independent physics
|
3. Ragdoll — corpses, loose Verlet bodies, independent physics
|
||||||
|
|
||||||
Game loop: fixed 60Hz timestep
|
Game loop: fixed 60Hz timestep, uniform across all chunks
|
||||||
Physics tick: CA step → rigid update (slope step) → ragdoll update → damage
|
Physics tick: CA step → heat_transfer → gas_step → pressure_step → light_step
|
||||||
Render: terminal (ANSI) / ascii (Vulkan glyphs) / graphics (Vulkan cells) / pipe (JSON) / headless (file)
|
→ entity updates → combat → status effects → gas damage
|
||||||
|
Render: terminal (ANSI) / ascii (Vulkan glyphs) / graphics (Vulkan cells) / pipe (JSON)
|
||||||
|
|
||||||
Three render modes:
|
Audio: 15 embedded WAV sounds via rodio, overlapping playback, ambient scanning
|
||||||
--mode terminal → pure ANSI ASCII in terminal
|
UI: flat Vec array, procedurally scalable font, non-destructive overlay
|
||||||
--mode ascii → Vulkan window with ASCII characters (glyph atlas)
|
|
||||||
--mode graphics → Vulkan window with colored cells (no glyphs, material base colors)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Numbers
|
### Numbers
|
||||||
|
|
||||||
- ~5964 lines Rust
|
- ~9000 lines Rust
|
||||||
- 109 integration tests, 14 JSON scenarios
|
- 185 integration tests, 14 JSON scenarios
|
||||||
- 40+ git commits
|
- 50+ git commits
|
||||||
- 0 compiler warnings (excluding winit deprecation notices)
|
- 0 compiler warnings (excluding winit deprecation notices)
|
||||||
- Cross-platform: Windows/Linux/macOS
|
- 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] Knockback: damage applies velocity impulse to rigid body center
|
||||||
- [x] Goblin AI: move toward player, attack when adjacent
|
- [x] Goblin AI: move toward player, attack when adjacent
|
||||||
- [x] Slime AI: jump toward player, contact damage
|
- [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)
|
- Player shoots with directional input (mouse aim or movement-direction)
|
||||||
- Projectile = small AABB, velocity, damage, lifetime
|
- Projectile = small AABB, velocity, damage, lifetime
|
||||||
- On hit with entity: deal damage, destroy projectile
|
- On hit with entity: deal damage, destroy projectile
|
||||||
- On hit with solid cell: stop/destroy
|
- On hit with solid cell: stop/destroy
|
||||||
- Fireball: ignites materials it touches (Noita-style material interaction)
|
- Fireball: ignites materials it touches (Noita-style material interaction)
|
||||||
- Magic bolt: pure damage, no material effect
|
- Magic bolt: pure damage, no material effect
|
||||||
- [ ] Health bars in render (colored indicator above entity)
|
- [x] Health bars in render (colored indicator above entity)
|
||||||
- [ ] Death → ragdoll → corpse decomposition (flesh cells drop into grid over time)
|
- [x] Death → ragdoll → corpse decomposition (flesh cells drop into grid over time)
|
||||||
- [ ] Material interaction with entities: entity walks through fire → ignites, acid → dissolves
|
- [x] Material interaction with entities: entity walks through fire → ignites, acid → dissolves
|
||||||
- [ ] Goblin AI: flee when low HP
|
- [x] Goblin AI: flee when low HP
|
||||||
|
|
||||||
**Tests needed:**
|
**Tests needed:**
|
||||||
- Projectile travels and deals damage on hit
|
- Projectile travels and deals damage on hit
|
||||||
@@ -102,34 +116,34 @@ Render pipeline per frame:
|
|||||||
3. Present to screen
|
3. Present to screen
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] `UiLayer` struct: sparse map of (screen_x, screen_y) → (char, fg_color, bg_color)
|
- [x] `UiLayer` struct: flat `Vec<Option<UiCell>>` array for O(1) cell access
|
||||||
- UI elements write to this map, not to the grid
|
- 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
|
- Renderer composites: if UiLayer has a cell at (x,y), it overrides the world cell visually
|
||||||
- World state is never modified by UI
|
- 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
|
- ████░░░░ style, colored green→yellow→red by HP ratio
|
||||||
- Positioned relative to player's screen position, scrolls with camera
|
- Positioned relative to player's screen position, scrolls with camera
|
||||||
- [ ] Entity labels: small text above/below entities (name, level for RPG)
|
- [x] Entity labels: small text above/below entities (name, level for RPG)
|
||||||
- [ ] Status effect icons: burning 🔥, poisoned, frozen — shown next to entity
|
- [x] Status effect icons: burning, poisoned, frozen — shown next to entity
|
||||||
- [ ] HUD bar (bottom of screen, non-destructive):
|
- [x] HUD bar (bottom of screen, non-destructive):
|
||||||
- HP: ████████░░ 80/100
|
- HP: ████████░░ 80/100
|
||||||
- Material brush: [Sand] (current selected)
|
- Material brush: [Sand] (current selected)
|
||||||
- Tick: 1234 Depth: 1
|
- Tick: 1234 Depth: 1
|
||||||
- FPS counter (debug mode)
|
- FPS counter (debug mode)
|
||||||
- [ ] Tooltip on hover: when cursor is over a cell, show material name + temperature
|
- [ ] 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)
|
- 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
|
- List of items, selected highlight, weight/value display
|
||||||
- Opens/closes without affecting simulation
|
- Opens/closes without affecting simulation
|
||||||
- [ ] Menu system (pause, settings, save/load): full-screen overlay with border
|
- [ ] Menu system (pause, settings, save/load): full-screen overlay with border
|
||||||
- Game loop pauses (or continues in background), UI captures input
|
- 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
|
- Each minimap cell = 5x5 world cells, averaged material color
|
||||||
- Player position marker, entity dots
|
- Player position marker, entity dots
|
||||||
- [ ] Crosshair/targeting: when aiming projectiles, shows trajectory preview
|
- [ ] Crosshair/targeting: when aiming projectiles, shows trajectory preview
|
||||||
- [ ] Damage numbers: floating text above entities when hit, rises and fades
|
- [x] Damage numbers: floating text above entities when hit, rises and fades
|
||||||
- [ ] Screen-edge indicators: arrows pointing to off-screen entities of interest
|
- [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.
|
**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
|
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**
|
**Goal: explorable world with depth and variety**
|
||||||
|
|
||||||
- [ ] Chunk system: world divided into chunks (64x64), only active chunks simulated
|
- [x] Chunk system: world divided into chunks (64x64), only active chunks simulated
|
||||||
- [ ] Chunk persistence: save/load chunks to disk
|
- [x] Chunk persistence: save/load chunks to disk
|
||||||
- [ ] Vertical descent: stairs/holes between depth levels
|
- [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
|
- [ ] 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)
|
- [ ] Camera zoom: +/- keys to change viewport scale (more or fewer cells visible)
|
||||||
- [ ] Minimap: ASCII overview of explored area
|
- [ ] Minimap: ASCII overview of explored area
|
||||||
- [ ] Day/night cycle: ambient light affects rendering (dimmer at night)
|
- [ ] 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
|
- Entity crossing chunk boundary continues correctly
|
||||||
- Dungeon generation produces connected rooms
|
- Dungeon generation produces connected rooms
|
||||||
- Biome materials match expected palette
|
- Biome materials match expected palette
|
||||||
|
- Streaming generates chunks as player moves
|
||||||
|
|
||||||
### Phase 3: RPG Layer
|
### Phase 3: RPG Layer
|
||||||
|
|
||||||
**Goal: character progression, inventory, abilities**
|
**Goal: character progression, inventory, abilities**
|
||||||
|
|
||||||
- [ ] Stats: strength, agility, toughness, willpower — affect damage, speed, HP, etc.
|
- [x] Stats: strength, agility, toughness, willpower — affect damage, speed, HP, etc.
|
||||||
- [ ] Inventory system: items as data structs, pick up by walking over, drop with key
|
- [x] Inventory system: items as data structs, pick up by walking over, drop with key
|
||||||
- [ ] Equipment: weapon affects melee damage/range, armor affects damage reduction
|
- [x] Equipment: weapon affects melee damage/range, armor affects damage reduction
|
||||||
- [ ] Items in world: weapons, potions, scrolls, food — rendered as distinct ASCII chars
|
- [x] Items in world: weapons, potions, scrolls, food — rendered as distinct ASCII chars
|
||||||
- [ ] Mutations (Caves of Qud style): modify entity properties
|
- [ ] Mutations (Caves of Qud style): modify entity properties
|
||||||
- "Silicon skin" → entity material becomes Stone, immune to acid
|
- "Silicon skin" → entity material becomes Stone, immune to acid
|
||||||
- "Flame body" → entity emits fire cells, immune to fire
|
- "Flame body" → entity emits fire cells, immune to fire
|
||||||
- "Liquid form" → entity can squeeze through 1-cell gaps
|
- "Liquid form" → entity can squeeze through 1-cell gaps
|
||||||
- "Multiple arms" → extra attack, can hold more items
|
- "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)
|
- [ ] 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
|
- [ ] Dialogue: talk to NPCs, simple text tree
|
||||||
|
|
||||||
**Tests needed:**
|
**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] Per-cell color: fg/bg stored in Cell, no registry lookup in render path
|
||||||
- [x] Square cells: 16x16 pixels, uniform grid
|
- [x] Square cells: 16x16 pixels, uniform grid
|
||||||
- [x] GpuRenderer trait: generic run_gpu_mode<R> for both renderers
|
- [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
|
- [ ] Dirty cell tracking: only update changed cells in instance buffer
|
||||||
- [ ] Camera zoom: +/- keys to change viewport scale
|
- [ ] Camera zoom: +/- keys to change viewport scale
|
||||||
|
|
||||||
**Graphics layers over both modes (Phase 4b):**
|
**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
|
- 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
|
- 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
|
- [ ] Particle system: GPU particles positioned relative to grid cells
|
||||||
- Fire sparks, water splashes, smoke trails, blood
|
- Fire sparks, water splashes, smoke trails, blood
|
||||||
- Particle lifetime + physics (gravity, wind)
|
- 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
|
- [ ] Boss entity: large rigid body (10x10), multiple attack patterns
|
||||||
- [ ] Books/readable items: lore text displayed in terminal
|
- [ ] Books/readable items: lore text displayed in terminal
|
||||||
- [ ] Crafting: combine materials to create new ones (water + dirt = mud)
|
- [ ] 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)
|
- [ ] Save/load: full game state to file (grid + entities + player + inventory)
|
||||||
- [ ] Death screen: stats summary, cause of death
|
- [ ] Death screen: stats summary, cause of death
|
||||||
- [ ] Tutorial: first-time controls overlay
|
- [ ] Tutorial: first-time controls overlay
|
||||||
- [ ] Difficulty scaling: deeper levels = stronger enemies
|
- [ ] Difficulty scaling: deeper levels = stronger enemies
|
||||||
|
|
||||||
### Phase 6: Advanced Physics
|
### Phase 6: Advanced Physics (DONE)
|
||||||
|
|
||||||
**Goal: deeper Noita-style material simulation**
|
**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
|
- 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
|
- Pressure layer: liquids have pressure, flow through pipes and U-bends
|
||||||
- Temperature layer: proper heat diffusion, materials melt/freeze at thresholds
|
- Temperature layer: proper heat diffusion, materials melt/freeze at thresholds
|
||||||
@@ -443,10 +462,11 @@ src/
|
|||||||
world/
|
world/
|
||||||
cell.rs # Cell struct, MaterialId enum
|
cell.rs # Cell struct, MaterialId enum
|
||||||
material.rs # Material properties registry
|
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
|
cellular.rs # Cellular automaton rules
|
||||||
chunk.rs # [Phase 2] chunk system
|
chunk.rs # Chunk system
|
||||||
worldgen.rs # [Phase 2] procedural generation
|
worldgen.rs # Procedural generation, per-chunk generation
|
||||||
layers.rs # [Phase 6] multi-layer world (temp, pressure, gas, light)
|
layers.rs # [Phase 6] multi-layer world (temp, pressure, gas, light)
|
||||||
physics/
|
physics/
|
||||||
verlet.rs # Verlet integrator, constraints
|
verlet.rs # Verlet integrator, constraints
|
||||||
@@ -537,10 +557,12 @@ assets/
|
|||||||
|-----------|---------|--------|
|
|-----------|---------|--------|
|
||||||
| 0.1 (done) | Core engine: CA, rigid, ragdoll, terminal, AI pipe | June 2026 |
|
| 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.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.3 (done) | Combat, goblin AI, projectiles, corpse decomposition | June 2026 |
|
||||||
| 0.35 | UI layer: health bar, HUD, message log, minimap, inventory overlay | July 2026 |
|
| 0.35 (done) | UI layer: health bar, HUD, message log, minimap, inventory overlay, scalable font | June 2026 |
|
||||||
| 0.4 | Chunks, biomes, dungeon gen, camera zoom | August 2026 |
|
| 0.4 (done) | Chunks, biomes, dungeon gen, world scale ×5, chunk streaming + cache | June 2026 |
|
||||||
| 0.5 | RPG layer: stats, inventory, mutations, XP | October 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.6 | Lighting/particles/textures (Phase 4b) | December 2026 |
|
||||||
| 0.7 | Multi-layer world: air, pressure, temperature, light as separate grids | Feb 2027 |
|
| 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.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;
|
int err = d.x - d.y;
|
||||||
while (true) {
|
while (true) {
|
||||||
if (p == b) return 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;
|
ivec2 vp = p - pc.cam_pos;
|
||||||
uint m = grid.cells[p.y * pc.world_size.x + p.x];
|
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;
|
if (is_solid(m)) return false;
|
||||||
int e2 = 2 * err;
|
int e2 = 2 * err;
|
||||||
if (e2 > -d.y) { err -= d.y; p.x += s.x; }
|
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;
|
int err = d.x - d.y;
|
||||||
while (true) {
|
while (true) {
|
||||||
if (p == b) return 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;
|
ivec2 vp = p - pc.cam_pos;
|
||||||
uint m = grid.cells[p.y * pc.world_size.x + p.x];
|
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;
|
if (is_solid(m)) return false;
|
||||||
int e2 = 2 * err;
|
int e2 = 2 * err;
|
||||||
if (e2 > -d.y) { err -= d.y; p.x += s.x; }
|
if (e2 > -d.y) { err -= d.y; p.x += s.x; }
|
||||||
@@ -70,17 +71,29 @@ vec3 compute_light(ivec2 world_pos) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
vec2 pixel = (in_grid + in_pos) * pc.cell_size;
|
if (pc.is_ui == 2u) {
|
||||||
gl_Position = vec4(
|
vec2 world_pos = in_grid;
|
||||||
2.0 * pixel.x / pc.screen_size.x - 1.0,
|
vec2 screen = (world_pos - vec2(pc.cam_pos)) * pc.cell_size + in_pos * pc.cell_size;
|
||||||
2.0 * pixel.y / pc.screen_size.y - 1.0,
|
gl_Position = vec4(
|
||||||
0.0, 1.0
|
2.0 * screen.x / pc.screen_size.x - 1.0,
|
||||||
);
|
2.0 * screen.y / pc.screen_size.y - 1.0,
|
||||||
out_color = in_color;
|
0.0, 1.0
|
||||||
if (pc.is_ui != 0u) {
|
);
|
||||||
|
out_color = in_color;
|
||||||
out_light = vec3(1.0);
|
out_light = vec3(1.0);
|
||||||
} else {
|
} else {
|
||||||
ivec2 world_pos = ivec2(in_grid + vec2(pc.cam_pos));
|
vec2 pixel = (in_grid + in_pos) * pc.cell_size;
|
||||||
out_light = compute_light(world_pos);
|
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 protocol::run_pipe_protocol;
|
||||||
pub use replay::{ReplayPlayer, ReplayRecorder, ReplayRecording};
|
pub use replay::{ReplayPlayer, ReplayRecorder, ReplayRecording};
|
||||||
pub use scenario::{
|
pub use scenario::{
|
||||||
format_results, load_scenario, run_all_scenarios, run_scenario, Assertion, AssertionResult,
|
Assertion, AssertionResult, Scenario, format_results, load_scenario, run_all_scenarios,
|
||||||
Scenario,
|
run_scenario,
|
||||||
};
|
};
|
||||||
pub use session::GameSession;
|
pub use session::GameSession;
|
||||||
pub use spectrum::{format_all_spectrums, render_all_spectrums, render_spectrum, Spectrum};
|
pub use spectrum::{Spectrum, format_all_spectrums, render_all_spectrums, render_spectrum};
|
||||||
pub use state::{entity_kind_name, render_view, CellInfo, EntityInfo, GameState, SubBodyInfo};
|
pub use state::{CellInfo, EntityInfo, GameState, SubBodyInfo, entity_kind_name, render_view};
|
||||||
pub use tape::{run_tape_mode, TapeFrame, TapeRecorder};
|
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,
|
"entities" => crate::ai::spectrum::Spectrum::Entities,
|
||||||
"density" => crate::ai::spectrum::Spectrum::Density,
|
"density" => crate::ai::spectrum::Spectrum::Density,
|
||||||
"velocity" => crate::ai::spectrum::Spectrum::Velocity,
|
"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);
|
let view = s.get_spectrum(&spec, vw, vh);
|
||||||
Response {
|
Response {
|
||||||
|
|||||||
+6
-6
@@ -155,26 +155,26 @@ fn check_assertion(session: &GameSession, assertion: &Assertion) -> AssertionRes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Assertion::CellTempGreaterThan { x, y, temp } => {
|
Assertion::CellTempGreaterThan { x, y, temp } => {
|
||||||
let cell = session.get_cell(*x, *y);
|
let cell_temp = session.get_temp(*x, *y);
|
||||||
let passed = cell.temp > *temp;
|
let passed = cell_temp > *temp;
|
||||||
AssertionResult {
|
AssertionResult {
|
||||||
assertion: assertion.clone(),
|
assertion: assertion.clone(),
|
||||||
passed,
|
passed,
|
||||||
message: format!(
|
message: format!(
|
||||||
"Cell({},{}) temp = {:.1}, expected > {:.1}",
|
"Cell({},{}) temp = {:.1}, expected > {:.1}",
|
||||||
x, y, cell.temp, temp
|
x, y, cell_temp, temp
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Assertion::CellTempLessThan { x, y, temp } => {
|
Assertion::CellTempLessThan { x, y, temp } => {
|
||||||
let cell = session.get_cell(*x, *y);
|
let cell_temp = session.get_temp(*x, *y);
|
||||||
let passed = cell.temp < *temp;
|
let passed = cell_temp < *temp;
|
||||||
AssertionResult {
|
AssertionResult {
|
||||||
assertion: assertion.clone(),
|
assertion: assertion.clone(),
|
||||||
passed,
|
passed,
|
||||||
message: format!(
|
message: format!(
|
||||||
"Cell({},{}) temp = {:.1}, expected < {:.1}",
|
"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::ai::state::{build_game_state, render_view, CellInfo, EntityInfo, GameState};
|
||||||
use crate::game::Game;
|
use crate::game::Game;
|
||||||
use crate::world::cell::MaterialId;
|
use crate::world::cell::MaterialId;
|
||||||
use crate::world::grid::Grid;
|
use crate::world::chunked_grid::ChunkedGrid;
|
||||||
|
|
||||||
pub struct GameSession {
|
pub struct GameSession {
|
||||||
pub game: Game,
|
pub game: Game,
|
||||||
@@ -133,6 +133,10 @@ impl GameSession {
|
|||||||
CellInfo::from_grid(&self.game.grid, x, y)
|
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> {
|
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);
|
let mut cells = Vec::with_capacity((w * h) as usize);
|
||||||
for dy in 0..h {
|
for dy in 0..h {
|
||||||
@@ -186,8 +190,21 @@ impl GameSession {
|
|||||||
|
|
||||||
pub fn find_material(&self, material: &str) -> Option<(i32, i32)> {
|
pub fn find_material(&self, material: &str) -> Option<(i32, i32)> {
|
||||||
let target = crate::ai::state::material_from_name(material)?;
|
let target = crate::ai::state::material_from_name(material)?;
|
||||||
for y in 0..self.game.grid.height as i32 {
|
let (px, py) = self.game.player.center(&self.game.entities);
|
||||||
for x in 0..self.game.grid.width as i32 {
|
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 {
|
if self.game.grid.get(x, y).material == target {
|
||||||
return Some((x, y));
|
return Some((x, y));
|
||||||
}
|
}
|
||||||
@@ -223,7 +240,7 @@ impl GameSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn grid(&self) -> &Grid {
|
pub fn grid(&self) -> &ChunkedGrid {
|
||||||
&self.game.grid
|
&self.game.grid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+86
-11
@@ -1,5 +1,5 @@
|
|||||||
use crate::entity::{EntityKind, EntityManager};
|
use crate::entity::{EntityKind, EntityManager};
|
||||||
use crate::world::grid::Grid;
|
use crate::world::chunked_grid::ChunkedGrid;
|
||||||
|
|
||||||
pub enum Spectrum {
|
pub enum Spectrum {
|
||||||
Materials,
|
Materials,
|
||||||
@@ -8,6 +8,8 @@ pub enum Spectrum {
|
|||||||
Entities,
|
Entities,
|
||||||
Density,
|
Density,
|
||||||
Velocity,
|
Velocity,
|
||||||
|
Gas,
|
||||||
|
Pressure,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Spectrum {
|
impl Spectrum {
|
||||||
@@ -19,6 +21,8 @@ impl Spectrum {
|
|||||||
Spectrum::Entities => "entities",
|
Spectrum::Entities => "entities",
|
||||||
Spectrum::Density => "density",
|
Spectrum::Density => "density",
|
||||||
Spectrum::Velocity => "velocity",
|
Spectrum::Velocity => "velocity",
|
||||||
|
Spectrum::Gas => "gas",
|
||||||
|
Spectrum::Pressure => "pressure",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,13 +34,15 @@ impl Spectrum {
|
|||||||
Spectrum::Entities,
|
Spectrum::Entities,
|
||||||
Spectrum::Density,
|
Spectrum::Density,
|
||||||
Spectrum::Velocity,
|
Spectrum::Velocity,
|
||||||
|
Spectrum::Gas,
|
||||||
|
Spectrum::Pressure,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_spectrum(
|
pub fn render_spectrum(
|
||||||
spectrum: &Spectrum,
|
spectrum: &Spectrum,
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
light: Option<&crate::render::lighting::LightGrid>,
|
light: Option<&crate::render::lighting::LightGrid>,
|
||||||
cam_x: i32,
|
cam_x: i32,
|
||||||
@@ -51,11 +57,13 @@ pub fn render_spectrum(
|
|||||||
Spectrum::Entities => render_entities(grid, entities, cam_x, cam_y, vw, vh),
|
Spectrum::Entities => render_entities(grid, entities, cam_x, cam_y, vw, vh),
|
||||||
Spectrum::Density => render_density(grid, 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::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(
|
fn render_materials(
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
cam_x: i32,
|
cam_x: i32,
|
||||||
cam_y: i32,
|
cam_y: i32,
|
||||||
@@ -100,7 +108,7 @@ fn render_materials(
|
|||||||
buf
|
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);
|
let mut buf = String::with_capacity(vw * vh + vh);
|
||||||
for dy in 0..vh {
|
for dy in 0..vh {
|
||||||
for dx in 0..vw {
|
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() {
|
if cell.is_empty() {
|
||||||
buf.push(' ');
|
buf.push(' ');
|
||||||
} else {
|
} else {
|
||||||
let t = cell.temp;
|
let t = grid.get_temp(x, y);
|
||||||
let ch = if t < 0.0 {
|
let ch = if t < 0.0 {
|
||||||
'.'
|
'.'
|
||||||
} else if t < 20.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(
|
fn render_light(
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
light: Option<&crate::render::lighting::LightGrid>,
|
light: Option<&crate::render::lighting::LightGrid>,
|
||||||
cam_x: i32,
|
cam_x: i32,
|
||||||
@@ -158,6 +166,13 @@ fn render_light(
|
|||||||
let (r, g, b) = if let Some(lg) = light {
|
let (r, g, b) = if let Some(lg) = light {
|
||||||
let c = lg.get(dx as i32, dy as i32);
|
let c = lg.get(dx as i32, dy as i32);
|
||||||
(c[0], c[1], c[2])
|
(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 {
|
} else {
|
||||||
(ambient[0], ambient[1], ambient[2])
|
(ambient[0], ambient[1], ambient[2])
|
||||||
};
|
};
|
||||||
@@ -188,7 +203,7 @@ fn render_light(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_entities(
|
fn render_entities(
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
cam_x: i32,
|
cam_x: i32,
|
||||||
cam_y: i32,
|
cam_y: i32,
|
||||||
@@ -236,7 +251,7 @@ fn render_entities(
|
|||||||
buf
|
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 reg = crate::world::material::MaterialRegistry::instance();
|
||||||
let mut buf = String::with_capacity(vw * vh + vh);
|
let mut buf = String::with_capacity(vw * vh + vh);
|
||||||
for dy in 0..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(
|
fn render_velocity(
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
cam_x: i32,
|
cam_x: i32,
|
||||||
cam_y: i32,
|
cam_y: i32,
|
||||||
@@ -327,7 +342,7 @@ fn render_velocity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_all_spectrums(
|
pub fn render_all_spectrums(
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
light: Option<&crate::render::lighting::LightGrid>,
|
light: Option<&crate::render::lighting::LightGrid>,
|
||||||
cam_x: i32,
|
cam_x: i32,
|
||||||
@@ -347,7 +362,7 @@ pub fn render_all_spectrums(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn format_all_spectrums(
|
pub fn format_all_spectrums(
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
light: Option<&crate::render::lighting::LightGrid>,
|
light: Option<&crate::render::lighting::LightGrid>,
|
||||||
cam_x: i32,
|
cam_x: i32,
|
||||||
@@ -364,3 +379,63 @@ pub fn format_all_spectrums(
|
|||||||
}
|
}
|
||||||
out
|
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::entity::{EntityKind, EntityManager};
|
||||||
use crate::game::Game;
|
use crate::game::Game;
|
||||||
use crate::world::cell::MaterialId;
|
use crate::world::cell::MaterialId;
|
||||||
use crate::world::grid::Grid;
|
use crate::world::chunked_grid::ChunkedGrid;
|
||||||
use crate::world::material::MaterialRegistry;
|
use crate::world::material::MaterialRegistry;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ pub struct CellInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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) {
|
if !grid.in_bounds(x, y) {
|
||||||
return Self {
|
return Self {
|
||||||
x,
|
x,
|
||||||
@@ -79,7 +79,7 @@ impl CellInfo {
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
material: mat.name.to_string(),
|
material: mat.name.to_string(),
|
||||||
temp: cell.temp,
|
temp: grid.get_temp(x, y),
|
||||||
is_solid: mat.solid,
|
is_solid: mat.solid,
|
||||||
is_liquid: mat.liquid,
|
is_liquid: mat.liquid,
|
||||||
is_gas: mat.gas,
|
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(
|
pub fn render_view(
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
cam_x: i32,
|
cam_x: i32,
|
||||||
cam_y: 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 {
|
pub struct ItemManager {
|
||||||
items: Vec<Item>,
|
items: Vec<Item>,
|
||||||
next_id: u32,
|
next_id: u32,
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ pub mod entity;
|
|||||||
pub mod item;
|
pub mod item;
|
||||||
pub mod player;
|
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 entity::{EntityKind, EntityManager};
|
||||||
pub use item::{Item, ItemManager, ItemType};
|
pub use item::{Item, ItemManager, ItemType};
|
||||||
pub use player::Player;
|
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) {
|
pub fn move_left(&mut self, manager: &mut EntityManager) {
|
||||||
if let Some(e) = manager.get_mut(self.entity_id) {
|
if let Some(e) = manager.get_mut(self.entity_id) {
|
||||||
e.set_horizontal_vel(-self.move_speed);
|
e.set_horizontal_vel(-self.move_speed);
|
||||||
|
|||||||
+552
-243
@@ -1,28 +1,36 @@
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::entity::player::Player;
|
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::input::{Action, InputHandler};
|
||||||
use crate::physics::collision::resolve_grid_collision;
|
use crate::physics::collision::resolve_grid_collision;
|
||||||
|
use crate::physics::particle::ParticleManager;
|
||||||
use crate::physics::projectile::{ProjectileManager, ProjectileType};
|
use crate::physics::projectile::{ProjectileManager, ProjectileType};
|
||||||
use crate::physics::verlet::VerletSolver;
|
use crate::physics::verlet::VerletSolver;
|
||||||
use crate::render::lighting;
|
use crate::render::lighting;
|
||||||
use crate::render::Renderer;
|
use crate::render::Renderer;
|
||||||
use crate::ui::UiLayer;
|
use crate::ui::UiLayer;
|
||||||
|
use crate::world::cache::WorldCache;
|
||||||
use crate::world::cell::MaterialId;
|
use crate::world::cell::MaterialId;
|
||||||
use crate::world::cellular::CellularAutomaton;
|
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 struct Game {
|
||||||
pub grid: Grid,
|
pub grid: ChunkedGrid,
|
||||||
pub ca: CellularAutomaton,
|
pub ca: CellularAutomaton,
|
||||||
pub verlet: VerletSolver,
|
pub verlet: VerletSolver,
|
||||||
pub entities: EntityManager,
|
pub entities: EntityManager,
|
||||||
pub projectiles: ProjectileManager,
|
pub projectiles: ProjectileManager,
|
||||||
|
pub particles: ParticleManager,
|
||||||
pub items: ItemManager,
|
pub items: ItemManager,
|
||||||
pub player: Player,
|
pub player: Player,
|
||||||
pub input: InputHandler,
|
pub input: InputHandler,
|
||||||
pub ui: UiLayer,
|
pub ui: UiLayer,
|
||||||
|
pub audio: AudioEngine,
|
||||||
pub cam_x: i32,
|
pub cam_x: i32,
|
||||||
pub cam_y: i32,
|
pub cam_y: i32,
|
||||||
pub cam_offset_x: i32,
|
pub cam_offset_x: i32,
|
||||||
@@ -43,22 +51,35 @@ pub struct Game {
|
|||||||
pub inventory_open: bool,
|
pub inventory_open: bool,
|
||||||
pub inventory_mouse_x: i32,
|
pub inventory_mouse_x: i32,
|
||||||
pub inventory_mouse_y: 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 {
|
impl Game {
|
||||||
pub fn new() -> Self {
|
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 mut entities = EntityManager::new();
|
||||||
let player = Player::new(&mut entities);
|
let player = Player::new(&mut entities);
|
||||||
Self {
|
Self {
|
||||||
grid: Grid::new(),
|
grid: ChunkedGrid::with_size(width, height),
|
||||||
ca: CellularAutomaton::new(),
|
ca: CellularAutomaton::new(),
|
||||||
verlet: VerletSolver::new(),
|
verlet: VerletSolver::new(),
|
||||||
entities,
|
entities,
|
||||||
projectiles: ProjectileManager::new(),
|
projectiles: ProjectileManager::new(),
|
||||||
|
particles: ParticleManager::new(2000),
|
||||||
items: ItemManager::new(),
|
items: ItemManager::new(),
|
||||||
player,
|
player,
|
||||||
input: InputHandler::new(),
|
input: InputHandler::new(),
|
||||||
ui: UiLayer::new(),
|
ui: UiLayer::new(),
|
||||||
|
audio: AudioEngine::new(),
|
||||||
cam_x: 100,
|
cam_x: 100,
|
||||||
cam_y: 100,
|
cam_y: 100,
|
||||||
cam_offset_x: 0,
|
cam_offset_x: 0,
|
||||||
@@ -79,184 +100,110 @@ impl Game {
|
|||||||
inventory_open: false,
|
inventory_open: false,
|
||||||
inventory_mouse_x: 0,
|
inventory_mouse_x: 0,
|
||||||
inventory_mouse_y: 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) {
|
pub fn init_world(&mut self) {
|
||||||
let w = self.grid.width;
|
let cache_dir = self.cache_dir.clone();
|
||||||
let h = self.grid.height;
|
let (px, py) = if let Some(ref root) = cache_dir {
|
||||||
|
let has_meta = WorldCache::meta_exists(root, self.seed);
|
||||||
for x in 0..w {
|
if has_meta {
|
||||||
self.grid
|
if let Err(e) = WorldCache::load_meta(
|
||||||
.set_material(x as i32, (h - 1) as i32, MaterialId::Stone);
|
root,
|
||||||
self.grid
|
self.seed,
|
||||||
.set_material(x as i32, (h - 2) as i32, MaterialId::Dirt);
|
&mut self.player,
|
||||||
}
|
&mut self.entities,
|
||||||
|
&mut self.items,
|
||||||
let surface_noise = |x: i32| -> i32 {
|
) {
|
||||||
let base = (h as i32 - 3) - ((x as f32 * 0.08).sin() * 4.0) as i32;
|
eprintln!("World cache meta load failed: {}", e);
|
||||||
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);
|
|
||||||
} else {
|
} 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
let (px, py) = WorldGenerator::new(&mut self.ca).generate(
|
||||||
|
&mut self.grid,
|
||||||
for _ in 0..8 {
|
&mut self.items,
|
||||||
let cave_x = (self.ca.random_u32() % (w as u32 - 20) + 10) as i32;
|
&mut self.player,
|
||||||
let cave_y = (self.ca.random_u32() % (h as u32 / 3) + (h as u32 / 3) * 2) as i32;
|
&mut self.entities,
|
||||||
let cave_r = (self.ca.random_u32() % 4 + 3) as i32;
|
self.depth,
|
||||||
for dy in -cave_r..=cave_r {
|
);
|
||||||
for dx in -cave_r..=cave_r {
|
self.center_camera_on(px, py);
|
||||||
if dx * dx + dy * dy <= cave_r * cave_r {
|
if let Err(e) = WorldCache::save_meta(root, self.seed, px, py, self.depth, &self.items)
|
||||||
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
|
|
||||||
{
|
{
|
||||||
surface_y = y;
|
eprintln!("World cache meta save failed: {}", e);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
(px, py)
|
||||||
let stair_y = (h as i32 - 2).max(surface_y + 2);
|
} else {
|
||||||
self.grid
|
let (px, py) = WorldGenerator::new(&mut self.ca).generate(
|
||||||
.set_material(surface_x, stair_y, MaterialId::Stairs);
|
&mut self.grid,
|
||||||
|
&mut self.items,
|
||||||
let cy = (surface_y as f32) - 5.0;
|
&mut self.player,
|
||||||
self.player.spawn_at(&mut self.entities, cx, cy);
|
&mut self.entities,
|
||||||
|
self.depth,
|
||||||
let (px, py) = self.player.center(&self.entities);
|
);
|
||||||
self.center_camera_on(px, py);
|
self.center_camera_on(px, py);
|
||||||
|
(px, py)
|
||||||
self.items
|
};
|
||||||
.spawn(ItemType::Sword, px as i32 - 6, py as i32 + 1);
|
let _ = (px, py);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn center_camera_on(&mut self, px: f32, py: f32) {
|
pub fn center_camera_on(&mut self, px: f32, py: f32) {
|
||||||
@@ -311,7 +258,7 @@ impl Game {
|
|||||||
self.cam_y,
|
self.cam_y,
|
||||||
vw,
|
vw,
|
||||||
vh,
|
vh,
|
||||||
lighting::ambient_light(),
|
lighting::ambient_light_at_tick(self.tick),
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Err(e) = renderer.render(
|
if let Err(e) = renderer.render(
|
||||||
@@ -386,6 +333,9 @@ impl Game {
|
|||||||
// Jump: only on press, not held
|
// Jump: only on press, not held
|
||||||
if self.input.jump_requested() {
|
if self.input.jump_requested() {
|
||||||
let on_ground = self.check_on_ground();
|
let on_ground = self.check_on_ground();
|
||||||
|
if on_ground {
|
||||||
|
self.audio.play("jump");
|
||||||
|
}
|
||||||
self.player.jump(&mut self.entities, on_ground);
|
self.player.jump(&mut self.entities, on_ground);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +372,12 @@ impl Game {
|
|||||||
self.ui.clear();
|
self.ui.clear();
|
||||||
self.ui.tick_messages();
|
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 player = self.player.entity(&self.entities).cloned();
|
||||||
let brush = self
|
let brush = self
|
||||||
.input
|
.input
|
||||||
@@ -429,8 +385,6 @@ impl Game {
|
|||||||
.to_material()
|
.to_material()
|
||||||
.unwrap_or(crate::world::cell::MaterialId::Empty);
|
.unwrap_or(crate::world::cell::MaterialId::Empty);
|
||||||
let player_alive = player.as_ref().map(|e| e.alive).unwrap_or(false);
|
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 {
|
if !self.inventory_open {
|
||||||
self.ui
|
self.ui
|
||||||
.draw_character_panel(1, 1, player.as_ref(), &self.player);
|
.draw_character_panel(1, 1, player.as_ref(), &self.player);
|
||||||
@@ -447,7 +401,7 @@ impl Game {
|
|||||||
&self.player,
|
&self.player,
|
||||||
self.fps,
|
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_messages(msg_x, 4);
|
||||||
self.ui.draw_damage_numbers(self.cam_x, self.cam_y);
|
self.ui.draw_damage_numbers(self.cam_x, self.cam_y);
|
||||||
self.ui.draw_edge_indicators(
|
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);
|
.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() {
|
for e in self.entities.all() {
|
||||||
if !e.alive || e.kind == EntityKind::Corpse {
|
if !e.alive || e.kind == EntityKind::Corpse {
|
||||||
continue;
|
continue;
|
||||||
@@ -525,7 +529,9 @@ impl Game {
|
|||||||
pub fn fixed_update(&mut self) {
|
pub fn fixed_update(&mut self) {
|
||||||
self.tick += 1;
|
self.tick += 1;
|
||||||
|
|
||||||
|
self.stream_chunks();
|
||||||
self.update_active_chunks();
|
self.update_active_chunks();
|
||||||
|
self.apply_gas_damage();
|
||||||
self.ca.step(&mut self.grid);
|
self.ca.step(&mut self.grid);
|
||||||
|
|
||||||
self.update_entities();
|
self.update_entities();
|
||||||
@@ -546,9 +552,120 @@ impl Game {
|
|||||||
self.try_spawn_slime();
|
self.try_spawn_slime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.spawn_ambient_particles();
|
||||||
|
self.play_ambient_sounds();
|
||||||
|
self.particles.update();
|
||||||
self.grid.swap_modified_flags();
|
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) {
|
fn update_score(&mut self) {
|
||||||
let mut new_kills = 0;
|
let mut new_kills = 0;
|
||||||
for e in self.entities.all_mut() {
|
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) {
|
fn update_status_effects(&mut self) {
|
||||||
for e in self.entities.all_mut() {
|
for e in self.entities.all_mut() {
|
||||||
if e.alive {
|
if e.alive {
|
||||||
@@ -588,6 +720,7 @@ impl Game {
|
|||||||
let item = self.items.all_mut().remove(idx);
|
let item = self.items.all_mut().remove(idx);
|
||||||
self.ui.add_message(&format!("Picked up {}", item.name()));
|
self.ui.add_message(&format!("Picked up {}", item.name()));
|
||||||
self.player.inventory.push(item);
|
self.player.inventory.push(item);
|
||||||
|
self.audio.play("pickup");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,7 +737,7 @@ impl Game {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.depth += 1;
|
self.depth += 1;
|
||||||
self.grid = Grid::new();
|
self.grid = ChunkedGrid::with_size(self.grid.width, self.grid.height);
|
||||||
self.entities = EntityManager::new();
|
self.entities = EntityManager::new();
|
||||||
self.player = Player::new(&mut self.entities);
|
self.player = Player::new(&mut self.entities);
|
||||||
self.projectiles = ProjectileManager::new();
|
self.projectiles = ProjectileManager::new();
|
||||||
@@ -613,6 +746,7 @@ impl Game {
|
|||||||
self.init_world();
|
self.init_world();
|
||||||
self.ui
|
self.ui
|
||||||
.add_message(&format!("Descended to depth {}", self.depth));
|
.add_message(&format!("Descended to depth {}", self.depth));
|
||||||
|
self.audio.play("descend");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn use_item(&mut self, index: usize) {
|
pub fn use_item(&mut self, index: usize) {
|
||||||
@@ -643,6 +777,7 @@ impl Game {
|
|||||||
self.player.inventory.remove(index);
|
self.player.inventory.remove(index);
|
||||||
self.ui.add_message(&format!("Consumed {}", name));
|
self.ui.add_message(&format!("Consumed {}", name));
|
||||||
}
|
}
|
||||||
|
self.audio.play("powerup");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn drop_item(&mut self, index: usize) {
|
pub fn drop_item(&mut self, index: usize) {
|
||||||
@@ -655,39 +790,114 @@ impl Game {
|
|||||||
self.ui.add_message(&format!("Dropped {}", item.name()));
|
self.ui.add_message(&format!("Dropped {}", item.name()));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_active_chunks(&mut self) {
|
fn stream_chunks(&mut self) {
|
||||||
self.grid.deactivate_all();
|
if !self.grid.is_infinite() && self.grid.width <= 2048 {
|
||||||
|
return;
|
||||||
for e in self.entities.all() {
|
|
||||||
let (cx, cy) = e.center();
|
|
||||||
self.grid.activate_around(cx as i32, cy as i32, 2);
|
|
||||||
}
|
}
|
||||||
|
let (px, py) = self.player.center(&self.entities);
|
||||||
for p in self.projectiles.all() {
|
let px = px as i32;
|
||||||
self.grid.activate_around(p.x as i32, p.y as i32, 1);
|
let py = py as i32;
|
||||||
}
|
let (pcx, pcy, _, _) = self.grid.chunk_at(px, py);
|
||||||
|
let radius = 2;
|
||||||
for item in self.items.all() {
|
|
||||||
self.grid.activate_around(item.x, item.y, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let chunk_size = self.grid.chunk_size as i32;
|
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 {
|
for dy in -radius..=radius {
|
||||||
let idx = self.grid.chunk_index(cx, cy);
|
for dx in -radius..=radius {
|
||||||
if self.grid.chunks[idx].modified || self.grid.chunks[idx].was_modified {
|
let cx = pcx + dx;
|
||||||
self.grid
|
let cy = pcy + dy;
|
||||||
.activate_around(cx * chunk_size, cy * chunk_size, 1);
|
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) {
|
pub fn update_projectiles(&mut self) {
|
||||||
|
let count_before = self.projectiles.all().len();
|
||||||
self.projectiles.update(&self.grid);
|
self.projectiles.update(&self.grid);
|
||||||
self.projectiles
|
self.projectiles
|
||||||
.resolve_hits(&mut self.grid, self.entities.all_mut(), &mut self.ui);
|
.resolve_hits(&mut self.grid, self.entities.all_mut(), &mut self.ui);
|
||||||
self.projectiles.cull_dead();
|
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) {
|
pub fn player_shoot(&mut self, dir_x: f32, dir_y: f32) {
|
||||||
@@ -717,6 +927,7 @@ impl Game {
|
|||||||
self.projectiles
|
self.projectiles
|
||||||
.spawn(typ, spawn_x, spawn_y, vx, vy, owner, damage_bonus);
|
.spawn(typ, spawn_x, spawn_y, vx, vy, owner, damage_bonus);
|
||||||
self.last_shot_tick = self.tick;
|
self.last_shot_tick = self.tick;
|
||||||
|
self.audio.play("shoot");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_goblin_ai(&mut self) {
|
fn update_goblin_ai(&mut self) {
|
||||||
@@ -942,6 +1153,7 @@ impl Game {
|
|||||||
_ => "Enemy hits you!",
|
_ => "Enemy hits you!",
|
||||||
};
|
};
|
||||||
self.ui.add_message(msg);
|
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) {
|
fn try_spawn_goblin(&mut self) {
|
||||||
let max_goblins = 3usize + self.depth.min(5) as usize;
|
let max_goblins = 3usize + self.depth.min(5) as usize;
|
||||||
let alive_goblins = self
|
let alive_goblins = self
|
||||||
@@ -1398,33 +1675,27 @@ impl Game {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (px, _py) = self.player.center(&self.entities);
|
let (px, py) = self.player.center(&self.entities);
|
||||||
let spawn_x = px as i32 + if px as i32 % 2 == 0 { 15 } else { -15 };
|
let offset = if px as i32 % 2 == 0 {
|
||||||
if !self.grid.in_bounds(spawn_x, 0) {
|
crate::world::worldgen::WORLD_SCALE * 15
|
||||||
return;
|
} 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;
|
if let Some((spawn_x, spawn_y)) = spawn {
|
||||||
for y in 0..self.grid.height as i32 {
|
let id = self.entities.spawn(EntityKind::Goblin);
|
||||||
let cell = self.grid.get(spawn_x, y);
|
if let Some(g) = self.entities.get_mut(id) {
|
||||||
if cell.is_solid() && cell.material != MaterialId::Stone {
|
g.build_humanoid(spawn_x as f32, spawn_y as f32);
|
||||||
surface_y = y;
|
g.health += self.depth as f32 * 5.0;
|
||||||
break;
|
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) {
|
fn try_spawn_slime(&mut self) {
|
||||||
@@ -1439,31 +1710,69 @@ impl Game {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (px, _py) = self.player.center(&self.entities);
|
let (px, py) = self.player.center(&self.entities);
|
||||||
let spawn_x = px as i32 + if px as i32 % 2 == 0 { -18 } else { 18 };
|
let offset = if px as i32 % 2 == 0 {
|
||||||
if !self.grid.in_bounds(spawn_x, 0) {
|
-(crate::world::worldgen::WORLD_SCALE * 18)
|
||||||
return;
|
} 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;
|
if let Some((spawn_x, spawn_y)) = spawn {
|
||||||
for y in 0..self.grid.height as i32 {
|
let id = self.entities.spawn(EntityKind::Slime);
|
||||||
let cell = self.grid.get(spawn_x, y);
|
if let Some(s) = self.entities.get_mut(id) {
|
||||||
if cell.is_solid() && cell.material != MaterialId::Stone {
|
s.build_humanoid(spawn_x as f32, spawn_y as f32);
|
||||||
surface_y = y;
|
s.health += self.depth as f32 * 3.0;
|
||||||
break;
|
s.max_health += self.depth as f32 * 3.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let spawn_y = surface_y - 3;
|
}
|
||||||
|
|
||||||
if !self.grid.in_bounds(spawn_x, spawn_y) {
|
pub fn trigger_explosion(&mut self, x: i32, y: i32, radius: i32, damage: f32) {
|
||||||
return;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
self.audio.play("explosion");
|
||||||
let id = self.entities.spawn(EntityKind::Slime);
|
for e in self.entities.all_mut() {
|
||||||
if let Some(s) = self.entities.get_mut(id) {
|
if !e.alive {
|
||||||
s.build_humanoid(spawn_x as f32, spawn_y as f32);
|
continue;
|
||||||
s.health += self.depth as f32 * 3.0;
|
}
|
||||||
s.max_health += self.depth as f32 * 3.0;
|
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) {
|
pub fn start(&mut self) {
|
||||||
let (tx, rx) = mpsc::channel::<Event>();
|
let (tx, rx) = mpsc::channel::<Event>();
|
||||||
self.receiver = Some(rx);
|
self.receiver = Some(rx);
|
||||||
self.input_thread = Some(thread::spawn(move || loop {
|
self.input_thread = Some(thread::spawn(move || {
|
||||||
match event::read() {
|
loop {
|
||||||
Ok(ev) => {
|
match event::read() {
|
||||||
if tx.send(ev).is_err() {
|
Ok(ev) => {
|
||||||
break;
|
if tx.send(ev).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod ai;
|
pub mod ai;
|
||||||
|
pub mod audio;
|
||||||
pub mod entity;
|
pub mod entity;
|
||||||
pub mod game;
|
pub mod game;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
|||||||
+85
-15
@@ -8,6 +8,7 @@ use verbatim::render::lighting;
|
|||||||
use verbatim::render::terminal::TerminalRenderer;
|
use verbatim::render::terminal::TerminalRenderer;
|
||||||
use verbatim::render::window_input::WindowInput;
|
use verbatim::render::window_input::WindowInput;
|
||||||
use verbatim::world::cell::MaterialId;
|
use verbatim::world::cell::MaterialId;
|
||||||
|
use verbatim::world::chunked_grid::ChunkedGrid;
|
||||||
use winit::event::{Event, WindowEvent};
|
use winit::event::{Event, WindowEvent};
|
||||||
use winit::event_loop::{ControlFlow, EventLoop};
|
use winit::event_loop::{ControlFlow, EventLoop};
|
||||||
use winit::window::Window;
|
use winit::window::Window;
|
||||||
@@ -42,6 +43,9 @@ struct Cli {
|
|||||||
#[arg(long, default_value = "benchmark_results.json")]
|
#[arg(long, default_value = "benchmark_results.json")]
|
||||||
benchmark_output: String,
|
benchmark_output: String,
|
||||||
|
|
||||||
|
#[arg(long, default_value = "surface")]
|
||||||
|
benchmark_biome: String,
|
||||||
|
|
||||||
#[arg(long, default_value_t = 5)]
|
#[arg(long, default_value_t = 5)]
|
||||||
tape_interval: u32,
|
tape_interval: u32,
|
||||||
|
|
||||||
@@ -58,7 +62,7 @@ trait GpuRenderer {
|
|||||||
Self: Sized;
|
Self: Sized;
|
||||||
fn render(
|
fn render(
|
||||||
&mut self,
|
&mut self,
|
||||||
grid: &verbatim::world::grid::Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &verbatim::entity::EntityManager,
|
entities: &verbatim::entity::EntityManager,
|
||||||
items: &verbatim::entity::item::ItemManager,
|
items: &verbatim::entity::item::ItemManager,
|
||||||
ui: &verbatim::ui::UiLayer,
|
ui: &verbatim::ui::UiLayer,
|
||||||
@@ -68,6 +72,8 @@ trait GpuRenderer {
|
|||||||
);
|
);
|
||||||
fn grid_w(&self) -> usize;
|
fn grid_w(&self) -> usize;
|
||||||
fn grid_h(&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 {
|
impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer {
|
||||||
@@ -76,7 +82,7 @@ impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer {
|
|||||||
}
|
}
|
||||||
fn render(
|
fn render(
|
||||||
&mut self,
|
&mut self,
|
||||||
grid: &verbatim::world::grid::Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &verbatim::entity::EntityManager,
|
entities: &verbatim::entity::EntityManager,
|
||||||
items: &verbatim::entity::item::ItemManager,
|
items: &verbatim::entity::item::ItemManager,
|
||||||
ui: &verbatim::ui::UiLayer,
|
ui: &verbatim::ui::UiLayer,
|
||||||
@@ -94,6 +100,12 @@ impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer {
|
|||||||
fn grid_h(&self) -> usize {
|
fn grid_h(&self) -> usize {
|
||||||
verbatim::render::vulkan::VulkanRenderer::grid_h(self)
|
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 {
|
impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer {
|
||||||
@@ -102,7 +114,7 @@ impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer {
|
|||||||
}
|
}
|
||||||
fn render(
|
fn render(
|
||||||
&mut self,
|
&mut self,
|
||||||
grid: &verbatim::world::grid::Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &verbatim::entity::EntityManager,
|
entities: &verbatim::entity::EntityManager,
|
||||||
items: &verbatim::entity::item::ItemManager,
|
items: &verbatim::entity::item::ItemManager,
|
||||||
ui: &verbatim::ui::UiLayer,
|
ui: &verbatim::ui::UiLayer,
|
||||||
@@ -120,6 +132,12 @@ impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer {
|
|||||||
fn grid_h(&self) -> usize {
|
fn grid_h(&self) -> usize {
|
||||||
verbatim::render::graphics::GraphicsRenderer::grid_h(self)
|
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() {
|
fn main() {
|
||||||
@@ -139,7 +157,7 @@ fn main() {
|
|||||||
eprintln!("PANIC: {}", info);
|
eprintln!("PANIC: {}", info);
|
||||||
}));
|
}));
|
||||||
let mut renderer = TerminalRenderer::new();
|
let mut renderer = TerminalRenderer::new();
|
||||||
let mut game = Game::new();
|
let mut game = Game::new_random();
|
||||||
game.run(&mut renderer);
|
game.run(&mut renderer);
|
||||||
}
|
}
|
||||||
"ascii" => {
|
"ascii" => {
|
||||||
@@ -216,13 +234,13 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
|
|||||||
eprintln!("Vulkan init failed: {e}");
|
eprintln!("Vulkan init failed: {e}");
|
||||||
eprintln!("Falling back to terminal mode...");
|
eprintln!("Falling back to terminal mode...");
|
||||||
let mut renderer = TerminalRenderer::new();
|
let mut renderer = TerminalRenderer::new();
|
||||||
let mut game = Game::new();
|
let mut game = Game::new_random();
|
||||||
game.run(&mut renderer);
|
game.run(&mut renderer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut game = Game::new();
|
let mut game = Game::new_random();
|
||||||
game.init_world();
|
game.init_world();
|
||||||
|
|
||||||
let mut input = WindowInput::new();
|
let mut input = WindowInput::new();
|
||||||
@@ -253,6 +271,20 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
|
|||||||
}
|
}
|
||||||
WindowEvent::CursorMoved { position, .. } => {
|
WindowEvent::CursorMoved { position, .. } => {
|
||||||
input.on_mouse_move(position.x, position.y);
|
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, .. } => {
|
WindowEvent::MouseInput { state, button, .. } => {
|
||||||
input.on_mouse_button(button, state);
|
input.on_mouse_button(button, state);
|
||||||
@@ -291,8 +323,15 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.toggle_audio {
|
||||||
|
game.audio.toggle();
|
||||||
|
}
|
||||||
|
|
||||||
if input.jump {
|
if input.jump {
|
||||||
let on_ground = game.check_on_ground();
|
let on_ground = game.check_on_ground();
|
||||||
|
if on_ground {
|
||||||
|
game.audio.play("jump");
|
||||||
|
}
|
||||||
game.player.jump(&mut game.entities, on_ground);
|
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_up
|
||||||
|| input.shoot_down
|
|| input.shoot_down
|
||||||
{
|
{
|
||||||
|
let cell_px = renderer.cell_pixel_size() as f64;
|
||||||
let (px, py) = game.player.center(&game.entities);
|
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_x =
|
||||||
let player_screen_y = ((py as i32 - game.cam_y) as f64) * 8.0 + 4.0;
|
((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 (dx, dy) = if input.shoot_mouse {
|
||||||
let mx = input.mouse_x - player_screen_x;
|
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);
|
game.build_ui(vw, vh);
|
||||||
|
|
||||||
|
renderer.upload_particles(&game.particles);
|
||||||
renderer.render(
|
renderer.render(
|
||||||
&game.grid,
|
&game.grid,
|
||||||
&game.entities,
|
&game.entities,
|
||||||
@@ -533,19 +576,25 @@ fn run_benchmark_mode(cli: &Cli) {
|
|||||||
let ticks = cli.benchmark_ticks;
|
let ticks = cli.benchmark_ticks;
|
||||||
let renderer_type = cli.benchmark_renderer.as_str();
|
let renderer_type = cli.benchmark_renderer.as_str();
|
||||||
let output_path = cli.benchmark_output.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 {
|
match renderer_type {
|
||||||
"ascii" => run_benchmark_inner::<verbatim::render::vulkan::VulkanRenderer>(
|
"ascii" => run_benchmark_inner::<verbatim::render::vulkan::VulkanRenderer>(
|
||||||
ticks,
|
ticks,
|
||||||
output_path,
|
output_path,
|
||||||
"ascii",
|
"ascii",
|
||||||
|
biome,
|
||||||
),
|
),
|
||||||
"graphics" => run_benchmark_inner::<verbatim::render::graphics::GraphicsRenderer>(
|
"graphics" => run_benchmark_inner::<verbatim::render::graphics::GraphicsRenderer>(
|
||||||
ticks,
|
ticks,
|
||||||
output_path,
|
output_path,
|
||||||
"graphics",
|
"graphics",
|
||||||
|
biome,
|
||||||
),
|
),
|
||||||
_ => {
|
_ => {
|
||||||
eprintln!(
|
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 event_loop = EventLoop::new().expect("Failed to create event loop");
|
||||||
let window = event_loop
|
let window = event_loop
|
||||||
.create_window(
|
.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();
|
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 tick_count = 0u32;
|
||||||
let mut ca_times_us: Vec<u64> = Vec::with_capacity(ticks as usize);
|
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);
|
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(
|
fn dump_view(
|
||||||
grid: &verbatim::world::grid::Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &verbatim::entity::EntityManager,
|
entities: &verbatim::entity::EntityManager,
|
||||||
cam_x: i32,
|
cam_x: i32,
|
||||||
cam_y: i32,
|
cam_y: i32,
|
||||||
vw: usize,
|
w: usize,
|
||||||
vh: usize,
|
h: usize,
|
||||||
) -> String {
|
) -> String {
|
||||||
ai::render_view(grid, entities, cam_x, cam_y, vw, vh)
|
ai::render_view(grid, entities, cam_x, cam_y, w, h)
|
||||||
.lines()
|
.lines()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, line)| format!("{:2}{}", (cam_y + i as i32) % 100, line))
|
.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::physics::verlet::SubBody;
|
||||||
|
use crate::world::cell::MaterialId;
|
||||||
|
use crate::world::chunked_grid::ChunkedGrid;
|
||||||
|
|
||||||
pub struct CollisionResult {
|
pub struct CollisionResult {
|
||||||
pub on_ground: bool,
|
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 mut result = CollisionResult::none();
|
||||||
|
|
||||||
let r = body.radius;
|
let r = body.radius;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod collision;
|
pub mod collision;
|
||||||
|
pub mod particle;
|
||||||
pub mod projectile;
|
pub mod projectile;
|
||||||
pub mod verlet;
|
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::entity::entity::{Entity, EntityId};
|
||||||
use crate::world::cell::{Cell, MaterialId};
|
use crate::world::cell::{Cell, MaterialId};
|
||||||
use crate::world::grid::Grid;
|
use crate::world::chunked_grid::ChunkedGrid;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum ProjectileType {
|
pub enum ProjectileType {
|
||||||
@@ -65,7 +65,7 @@ impl Projectile {
|
|||||||
self.damage + self.damage_bonus
|
self.damage + self.damage_bonus
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(&mut self, grid: &Grid) {
|
pub fn update(&mut self, grid: &ChunkedGrid) {
|
||||||
if !self.alive {
|
if !self.alive {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,12 @@ impl Projectile {
|
|||||||
dx.abs() < hit_w && dy.abs() < hit_h
|
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 {
|
if self.typ == ProjectileType::Fireball {
|
||||||
let min_x = (self.x - 1.5).floor() as i32;
|
let min_x = (self.x - 1.5).floor() as i32;
|
||||||
let max_x = (self.x + 1.5).ceil() as i32;
|
let max_x = (self.x + 1.5).ceil() as i32;
|
||||||
@@ -131,15 +136,18 @@ impl Projectile {
|
|||||||
}
|
}
|
||||||
let cell = grid.get(x, y);
|
let cell = grid.get(x, y);
|
||||||
if cell.is_empty() {
|
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
|
} else if cell.material == MaterialId::Wood
|
||||||
|| cell.material == MaterialId::Grass
|
|| cell.material == MaterialId::Grass
|
||||||
|| cell.material == MaterialId::Flesh
|
|| cell.material == MaterialId::Flesh
|
||||||
{
|
{
|
||||||
let mut ignited = cell;
|
let mut ignited = cell;
|
||||||
ignited.material = MaterialId::Fire;
|
ignited.material = MaterialId::Fire;
|
||||||
ignited.temp = 400.0;
|
ignited.updated_this_tick = true;
|
||||||
grid.set(x, y, ignited);
|
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)
|
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 {
|
for p in &mut self.projectiles {
|
||||||
p.update(grid);
|
p.update(grid);
|
||||||
}
|
}
|
||||||
@@ -234,7 +242,7 @@ impl ProjectileManager {
|
|||||||
|
|
||||||
pub fn resolve_hits(
|
pub fn resolve_hits(
|
||||||
&mut self,
|
&mut self,
|
||||||
grid: &mut Grid,
|
grid: &mut ChunkedGrid,
|
||||||
entities: &mut [Entity],
|
entities: &mut [Entity],
|
||||||
ui: &mut crate::ui::UiLayer,
|
ui: &mut crate::ui::UiLayer,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ use crate::entity::EntityManager;
|
|||||||
use crate::render::lighting;
|
use crate::render::lighting;
|
||||||
use crate::ui::UiLayer;
|
use crate::ui::UiLayer;
|
||||||
use crate::world::cell::MaterialId;
|
use crate::world::cell::MaterialId;
|
||||||
use crate::world::grid::Grid;
|
use crate::world::chunked_grid::ChunkedGrid;
|
||||||
use image::{ImageBuffer, RgbImage};
|
use image::{ImageBuffer, RgbImage};
|
||||||
|
|
||||||
pub const CELL_SIZE: u32 = 8;
|
pub const CELL_SIZE: u32 = 8;
|
||||||
pub const UI_CELL_SIZE: u32 = 2;
|
pub const UI_CELL_SIZE: u32 = 2;
|
||||||
|
|
||||||
pub fn capture_frame(
|
pub fn capture_frame(
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
items: &ItemManager,
|
items: &ItemManager,
|
||||||
ui: &UiLayer,
|
ui: &UiLayer,
|
||||||
@@ -145,7 +145,7 @@ fn entity_positions(
|
|||||||
|
|
||||||
fn shadow_positions(
|
fn shadow_positions(
|
||||||
entity_positions: &std::collections::HashMap<(i32, i32), [u8; 3]>,
|
entity_positions: &std::collections::HashMap<(i32, i32), [u8; 3]>,
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
cam_x: i32,
|
cam_x: i32,
|
||||||
cam_y: i32,
|
cam_y: i32,
|
||||||
view_w: u32,
|
view_w: u32,
|
||||||
@@ -203,7 +203,7 @@ fn draw_cell(img: &mut RgbImage, vx: u32, vy: u32, color: [u8; 3]) {
|
|||||||
|
|
||||||
pub fn save_capture(
|
pub fn save_capture(
|
||||||
path: &str,
|
path: &str,
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
items: &ItemManager,
|
items: &ItemManager,
|
||||||
ui: &UiLayer,
|
ui: &UiLayer,
|
||||||
@@ -221,7 +221,7 @@ pub fn save_capture(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn capture_from_state(
|
pub fn capture_from_state(
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
items: &ItemManager,
|
items: &ItemManager,
|
||||||
ui: &UiLayer,
|
ui: &UiLayer,
|
||||||
|
|||||||
+131
-17
@@ -5,7 +5,8 @@ use std::sync::Arc;
|
|||||||
use crate::entity::EntityManager;
|
use crate::entity::EntityManager;
|
||||||
use crate::render::lighting;
|
use crate::render::lighting;
|
||||||
use crate::world::cell::MaterialId;
|
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_W: u32 = 8;
|
||||||
const CHAR_H: u32 = 8;
|
const CHAR_H: u32 = 8;
|
||||||
@@ -45,6 +46,14 @@ struct ColorInstance {
|
|||||||
color: [u8; 4],
|
color: [u8; 4],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
struct ParticleInstance {
|
||||||
|
grid_x: f32,
|
||||||
|
grid_y: f32,
|
||||||
|
color: [u8; 4],
|
||||||
|
}
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[derive(bytemuck::NoUninit, Clone, Copy, Default)]
|
#[derive(bytemuck::NoUninit, Clone, Copy, Default)]
|
||||||
struct GpuLightSource {
|
struct GpuLightSource {
|
||||||
@@ -114,6 +123,12 @@ pub struct GraphicsRenderer {
|
|||||||
ui_instance_ptr: *mut ColorInstance,
|
ui_instance_ptr: *mut ColorInstance,
|
||||||
ui_instance_capacity: usize,
|
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_buffer: vk::Buffer,
|
||||||
grid_memory: vk::DeviceMemory,
|
grid_memory: vk::DeviceMemory,
|
||||||
grid_ptr: *mut u32,
|
grid_ptr: *mut u32,
|
||||||
@@ -552,13 +567,14 @@ impl GraphicsRenderer {
|
|||||||
Ok((buf, mem))
|
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(
|
let (grid_buffer, grid_memory) = make_buf(
|
||||||
bytemuck::cast_slice(&grid_data),
|
bytemuck::cast_slice(&grid_data),
|
||||||
vk::BufferUsageFlags::STORAGE_BUFFER,
|
vk::BufferUsageFlags::STORAGE_BUFFER,
|
||||||
)?;
|
)?;
|
||||||
let grid_ptr = unsafe {
|
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
|
let ptr = device
|
||||||
.map_memory(grid_memory, 0, sz, vk::MemoryMapFlags::default())
|
.map_memory(grid_memory, 0, sz, vk::MemoryMapFlags::default())
|
||||||
.map_err(|e| format!("map grid: {e:?}"))?;
|
.map_err(|e| format!("map grid: {e:?}"))?;
|
||||||
@@ -602,7 +618,7 @@ impl GraphicsRenderer {
|
|||||||
let grid_info = vk::DescriptorBufferInfo::default()
|
let grid_info = vk::DescriptorBufferInfo::default()
|
||||||
.buffer(grid_buffer)
|
.buffer(grid_buffer)
|
||||||
.offset(0)
|
.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()
|
let light_info = vk::DescriptorBufferInfo::default()
|
||||||
.buffer(light_buffer)
|
.buffer(light_buffer)
|
||||||
.offset(0)
|
.offset(0)
|
||||||
@@ -704,6 +720,44 @@ impl GraphicsRenderer {
|
|||||||
ptr as *mut ColorInstance
|
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 {
|
Ok(Self {
|
||||||
grid_w,
|
grid_w,
|
||||||
grid_h,
|
grid_h,
|
||||||
@@ -740,6 +794,11 @@ impl GraphicsRenderer {
|
|||||||
ui_instance_memory,
|
ui_instance_memory,
|
||||||
ui_instance_ptr,
|
ui_instance_ptr,
|
||||||
ui_instance_capacity: ui_capacity,
|
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_buffer,
|
||||||
grid_memory,
|
grid_memory,
|
||||||
@@ -764,7 +823,7 @@ impl GraphicsRenderer {
|
|||||||
|
|
||||||
pub fn render(
|
pub fn render(
|
||||||
&mut self,
|
&mut self,
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
items: &crate::entity::item::ItemManager,
|
items: &crate::entity::item::ItemManager,
|
||||||
ui: &crate::ui::UiLayer,
|
ui: &crate::ui::UiLayer,
|
||||||
@@ -921,16 +980,12 @@ impl GraphicsRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let margin = 30i32;
|
for dy in 0..self.grid_h {
|
||||||
let x_min = (cam_x - margin).max(0) as usize;
|
for dx in 0..self.grid_w {
|
||||||
let x_max = (cam_x + self.grid_w as i32 + margin).min(WORLD_W as i32) as usize;
|
let wx = cam_x + dx as i32;
|
||||||
let y_min = (cam_y - margin).max(0) as usize;
|
let wy = cam_y + dy as i32;
|
||||||
let y_max = (cam_y + self.grid_h as i32 + margin).min(WORLD_H as i32) as usize;
|
let idx = dy * self.grid_w + dx;
|
||||||
for y in y_min..y_max {
|
*self.grid_ptr.add(idx) = grid.get(wx, wy).material as u32;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1033,7 +1088,7 @@ impl GraphicsRenderer {
|
|||||||
self.swapchain_extent.height as f32,
|
self.swapchain_extent.height as f32,
|
||||||
],
|
],
|
||||||
cell_size: [CHAR_W as f32, CHAR_H 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],
|
cam_pos: [cam_x, cam_y],
|
||||||
ambient,
|
ambient,
|
||||||
is_ui: 0,
|
is_ui: 0,
|
||||||
@@ -1062,7 +1117,7 @@ impl GraphicsRenderer {
|
|||||||
self.swapchain_extent.height as f32,
|
self.swapchain_extent.height as f32,
|
||||||
],
|
],
|
||||||
cell_size: [UI_CELL_SIZE as f32, UI_CELL_SIZE 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],
|
cam_pos: [0, 0],
|
||||||
ambient: [1.0, 1.0, 1.0],
|
ambient: [1.0, 1.0, 1.0],
|
||||||
is_ui: 1,
|
is_ui: 1,
|
||||||
@@ -1078,6 +1133,35 @@ impl GraphicsRenderer {
|
|||||||
device.cmd_draw_indexed(cmd, 6, ui_count as u32, 0, 0, 0);
|
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);
|
device.cmd_end_render_pass(cmd);
|
||||||
let _ = device.end_command_buffer(cmd);
|
let _ = device.end_command_buffer(cmd);
|
||||||
|
|
||||||
@@ -1113,6 +1197,33 @@ impl GraphicsRenderer {
|
|||||||
self.grid_h
|
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) {
|
fn check_resize(&mut self) {
|
||||||
let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance);
|
let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance);
|
||||||
let caps = match unsafe {
|
let caps = match unsafe {
|
||||||
@@ -1344,6 +1455,9 @@ impl Drop for GraphicsRenderer {
|
|||||||
self.device.free_memory(self.instance_memory, None);
|
self.device.free_memory(self.instance_memory, None);
|
||||||
self.device.destroy_buffer(self.ui_instance_buffer, None);
|
self.device.destroy_buffer(self.ui_instance_buffer, None);
|
||||||
self.device.free_memory(self.ui_instance_memory, 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.destroy_buffer(self.vertex_buffer, None);
|
||||||
self.device.free_memory(self.vertex_memory, None);
|
self.device.free_memory(self.vertex_memory, None);
|
||||||
self.device.destroy_buffer(self.index_buffer, None);
|
self.device.destroy_buffer(self.index_buffer, None);
|
||||||
|
|||||||
+28
-11
@@ -1,5 +1,5 @@
|
|||||||
use crate::world::cell::MaterialId;
|
use crate::world::cell::MaterialId;
|
||||||
use crate::world::grid::Grid;
|
use crate::world::chunked_grid::ChunkedGrid;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct LightSource {
|
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 mut sources = Vec::new();
|
||||||
let w = grid.width;
|
let w = grid.width;
|
||||||
let h = grid.height;
|
let h = grid.height;
|
||||||
@@ -85,7 +85,7 @@ pub fn gather_sources(grid: &Grid) -> Vec<LightSource> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn gather_sources_in_range(
|
pub fn gather_sources_in_range(
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
cam_x: i32,
|
cam_x: i32,
|
||||||
cam_y: i32,
|
cam_y: i32,
|
||||||
view_w: usize,
|
view_w: usize,
|
||||||
@@ -94,9 +94,17 @@ pub fn gather_sources_in_range(
|
|||||||
) -> Vec<LightSource> {
|
) -> Vec<LightSource> {
|
||||||
let mut sources = Vec::new();
|
let mut sources = Vec::new();
|
||||||
let min_x = (cam_x - margin).max(0);
|
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 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 y in min_y..max_y {
|
||||||
for x in min_x..max_x {
|
for x in min_x..max_x {
|
||||||
let cell = grid.get(x, y);
|
let cell = grid.get(x, y);
|
||||||
@@ -111,7 +119,7 @@ pub fn gather_sources_in_range(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn compute_lighting(
|
pub fn compute_lighting(
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
cam_x: i32,
|
cam_x: i32,
|
||||||
cam_y: i32,
|
cam_y: i32,
|
||||||
view_w: usize,
|
view_w: usize,
|
||||||
@@ -176,7 +184,7 @@ pub fn compute_lighting(
|
|||||||
grid_light
|
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 x = x0;
|
||||||
let mut y = y0;
|
let mut y = y0;
|
||||||
let dx = (x1 - x0).abs();
|
let dx = (x1 - x0).abs();
|
||||||
@@ -233,13 +241,22 @@ pub fn ambient_light() -> [u8; 3] {
|
|||||||
[160, 160, 180]
|
[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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::world::grid::Grid;
|
use crate::world::chunked_grid::ChunkedGrid;
|
||||||
|
|
||||||
fn grid_with_lava() -> (Grid, i32, i32) {
|
fn grid_with_lava() -> (ChunkedGrid, i32, i32) {
|
||||||
let mut grid = Grid::new();
|
let mut grid = ChunkedGrid::with_size(250, 250);
|
||||||
grid.set_material(10, 10, MaterialId::Lava);
|
grid.set_material(10, 10, MaterialId::Lava);
|
||||||
(grid, 10, 10)
|
(grid, 10, 10)
|
||||||
}
|
}
|
||||||
@@ -267,7 +284,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn walls_block_light() {
|
fn walls_block_light() {
|
||||||
let mut grid = Grid::new();
|
let mut grid = ChunkedGrid::with_size(250, 250);
|
||||||
grid.set_material(5, 10, MaterialId::Lava);
|
grid.set_material(5, 10, MaterialId::Lava);
|
||||||
for y in 7..13 {
|
for y in 7..13 {
|
||||||
grid.set_material(8, y, MaterialId::Stone);
|
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::item::ItemManager;
|
||||||
use crate::entity::EntityManager;
|
use crate::entity::EntityManager;
|
||||||
use crate::ui::UiLayer;
|
use crate::ui::UiLayer;
|
||||||
use crate::world::grid::Grid;
|
use crate::world::chunked_grid::ChunkedGrid;
|
||||||
|
|
||||||
pub trait Renderer {
|
pub trait Renderer {
|
||||||
fn init(&mut self) -> std::io::Result<()>;
|
fn init(&mut self) -> std::io::Result<()>;
|
||||||
fn render(
|
fn render(
|
||||||
&mut self,
|
&mut self,
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
items: &ItemManager,
|
items: &ItemManager,
|
||||||
ui: &UiLayer,
|
ui: &UiLayer,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use crate::entity::EntityManager;
|
|||||||
use crate::render::lighting::{self, apply_light_tuple, LightGrid};
|
use crate::render::lighting::{self, apply_light_tuple, LightGrid};
|
||||||
use crate::render::Renderer;
|
use crate::render::Renderer;
|
||||||
use crate::world::cell::MaterialId;
|
use crate::world::cell::MaterialId;
|
||||||
use crate::world::grid::Grid;
|
use crate::world::chunked_grid::ChunkedGrid;
|
||||||
|
|
||||||
fn entity_priority(kind: crate::entity::EntityKind) -> u32 {
|
fn entity_priority(kind: crate::entity::EntityKind) -> u32 {
|
||||||
match kind {
|
match kind {
|
||||||
@@ -96,7 +96,7 @@ impl Renderer for TerminalRenderer {
|
|||||||
|
|
||||||
fn render(
|
fn render(
|
||||||
&mut self,
|
&mut self,
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
items: &crate::entity::item::ItemManager,
|
items: &crate::entity::item::ItemManager,
|
||||||
ui: &crate::ui::UiLayer,
|
ui: &crate::ui::UiLayer,
|
||||||
|
|||||||
+20
-17
@@ -6,7 +6,8 @@ use std::sync::Arc;
|
|||||||
use crate::entity::{EntityKind, EntityManager};
|
use crate::entity::{EntityKind, EntityManager};
|
||||||
use crate::render::lighting;
|
use crate::render::lighting;
|
||||||
use crate::world::cell::MaterialId;
|
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_W: u32 = 8;
|
||||||
const CHAR_H: u32 = 8;
|
const CHAR_H: u32 = 8;
|
||||||
@@ -225,7 +226,7 @@ impl VulkanRenderer {
|
|||||||
let (ui_instance_buffer, ui_instance_memory, ui_instance_ptr) =
|
let (ui_instance_buffer, ui_instance_memory, ui_instance_ptr) =
|
||||||
create_instance_buffer(&device, &instance, physical_device, ui_instance_capacity)?;
|
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(
|
let (grid_buffer, grid_memory) = create_buffer_with_data(
|
||||||
&device,
|
&device,
|
||||||
&instance,
|
&instance,
|
||||||
@@ -234,7 +235,7 @@ impl VulkanRenderer {
|
|||||||
vk::BufferUsageFlags::STORAGE_BUFFER,
|
vk::BufferUsageFlags::STORAGE_BUFFER,
|
||||||
)?;
|
)?;
|
||||||
let grid_ptr = unsafe {
|
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
|
let ptr = device
|
||||||
.map_memory(grid_memory, 0, sz, vk::MemoryMapFlags::default())
|
.map_memory(grid_memory, 0, sz, vk::MemoryMapFlags::default())
|
||||||
.map_err(|e| format!("map grid: {e:?}"))?;
|
.map_err(|e| format!("map grid: {e:?}"))?;
|
||||||
@@ -339,7 +340,7 @@ impl VulkanRenderer {
|
|||||||
|
|
||||||
pub fn render(
|
pub fn render(
|
||||||
&mut self,
|
&mut self,
|
||||||
grid: &Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &EntityManager,
|
entities: &EntityManager,
|
||||||
items: &crate::entity::item::ItemManager,
|
items: &crate::entity::item::ItemManager,
|
||||||
ui: &crate::ui::UiLayer,
|
ui: &crate::ui::UiLayer,
|
||||||
@@ -441,16 +442,12 @@ impl VulkanRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let margin = 30i32;
|
for dy in 0..self.grid_h {
|
||||||
let x_min = (cam_x - margin).max(0) as usize;
|
for dx in 0..self.grid_w {
|
||||||
let x_max = (cam_x + self.grid_w as i32 + margin).min(WORLD_W as i32) as usize;
|
let wx = cam_x + dx as i32;
|
||||||
let y_min = (cam_y - margin).max(0) as usize;
|
let wy = cam_y + dy as i32;
|
||||||
let y_max = (cam_y + self.grid_h as i32 + margin).min(WORLD_H as i32) as usize;
|
let idx = dy * self.grid_w + dx;
|
||||||
for y in y_min..y_max {
|
*self.grid_ptr.add(idx) = grid.get(wx, wy).material as u32;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -627,7 +624,7 @@ impl VulkanRenderer {
|
|||||||
self.swapchain_extent.height as f32,
|
self.swapchain_extent.height as f32,
|
||||||
],
|
],
|
||||||
cell_size: [CHAR_W as f32, CHAR_H 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],
|
cam_pos: [cam_x, cam_y],
|
||||||
ambient: [
|
ambient: [
|
||||||
ambient[0] as f32 / 255.0,
|
ambient[0] as f32 / 255.0,
|
||||||
@@ -660,7 +657,7 @@ impl VulkanRenderer {
|
|||||||
self.swapchain_extent.height as f32,
|
self.swapchain_extent.height as f32,
|
||||||
],
|
],
|
||||||
cell_size: [UI_CELL_SIZE as f32, UI_CELL_SIZE 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],
|
cam_pos: [0, 0],
|
||||||
ambient: [0.0, 0.0, 0.0],
|
ambient: [0.0, 0.0, 0.0],
|
||||||
is_ui: 1,
|
is_ui: 1,
|
||||||
@@ -711,6 +708,12 @@ impl VulkanRenderer {
|
|||||||
self.grid_h
|
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) {
|
fn check_resize(&mut self) {
|
||||||
let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance);
|
let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance);
|
||||||
let caps = match unsafe {
|
let caps = match unsafe {
|
||||||
@@ -1824,7 +1827,7 @@ fn update_descriptor_set(
|
|||||||
let bi = vk::DescriptorBufferInfo::default()
|
let bi = vk::DescriptorBufferInfo::default()
|
||||||
.buffer(grid_buffer)
|
.buffer(grid_buffer)
|
||||||
.offset(0)
|
.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()
|
let li = vk::DescriptorBufferInfo::default()
|
||||||
.buffer(light_buffer)
|
.buffer(light_buffer)
|
||||||
.offset(0)
|
.offset(0)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub struct WindowInput {
|
|||||||
pub cam_up: bool,
|
pub cam_up: bool,
|
||||||
pub cam_down: bool,
|
pub cam_down: bool,
|
||||||
pub quit: bool,
|
pub quit: bool,
|
||||||
|
pub toggle_audio: bool,
|
||||||
pub paint: Option<u8>,
|
pub paint: Option<u8>,
|
||||||
pub mouse_x: f64,
|
pub mouse_x: f64,
|
||||||
pub mouse_y: f64,
|
pub mouse_y: f64,
|
||||||
@@ -39,6 +40,7 @@ pub struct WindowInput {
|
|||||||
descend_was_down: bool,
|
descend_was_down: bool,
|
||||||
use_item_was_down: bool,
|
use_item_was_down: bool,
|
||||||
drop_item_was_down: bool,
|
drop_item_was_down: bool,
|
||||||
|
audio_was_down: bool,
|
||||||
down_keys: HashSet<KeyCode>,
|
down_keys: HashSet<KeyCode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +63,7 @@ impl WindowInput {
|
|||||||
cam_up: false,
|
cam_up: false,
|
||||||
cam_down: false,
|
cam_down: false,
|
||||||
quit: false,
|
quit: false,
|
||||||
|
toggle_audio: false,
|
||||||
paint: None,
|
paint: None,
|
||||||
mouse_x: 0.0,
|
mouse_x: 0.0,
|
||||||
mouse_y: 0.0,
|
mouse_y: 0.0,
|
||||||
@@ -82,6 +85,7 @@ impl WindowInput {
|
|||||||
descend_was_down: false,
|
descend_was_down: false,
|
||||||
use_item_was_down: false,
|
use_item_was_down: false,
|
||||||
drop_item_was_down: false,
|
drop_item_was_down: false,
|
||||||
|
audio_was_down: false,
|
||||||
down_keys: HashSet::new(),
|
down_keys: HashSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,6 +206,9 @@ impl WindowInput {
|
|||||||
self.cam_up = keys.contains(&KeyCode::KeyI);
|
self.cam_up = keys.contains(&KeyCode::KeyI);
|
||||||
self.cam_down = keys.contains(&KeyCode::KeyO);
|
self.cam_down = keys.contains(&KeyCode::KeyO);
|
||||||
self.quit = keys.contains(&KeyCode::KeyQ) || keys.contains(&KeyCode::Escape);
|
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;
|
self.paint = None;
|
||||||
if keys.contains(&KeyCode::Digit1) {
|
if keys.contains(&KeyCode::Digit1) {
|
||||||
|
|||||||
+158
-69
@@ -1,9 +1,10 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use crate::entity::entity::{Entity, EntityKind};
|
use crate::entity::entity::{Entity, EntityKind};
|
||||||
use crate::world::cell::MaterialId;
|
use crate::world::cell::MaterialId;
|
||||||
|
use crate::world::chunked_grid::ChunkedGrid;
|
||||||
|
|
||||||
pub const UI_SCALE: i32 = 4;
|
pub const UI_SCALE: i32 = 4;
|
||||||
|
pub const FONT_W: i32 = 3;
|
||||||
|
pub const FONT_H: i32 = 5;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct UiCell {
|
pub struct UiCell {
|
||||||
@@ -14,9 +15,13 @@ pub struct UiCell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct UiLayer {
|
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)>,
|
messages: Vec<(String, u32)>,
|
||||||
damage_numbers: Vec<DamageNumber>,
|
damage_numbers: Vec<DamageNumber>,
|
||||||
|
font_scale: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -31,14 +36,39 @@ pub struct DamageNumber {
|
|||||||
impl UiLayer {
|
impl UiLayer {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
cells: HashMap::new(),
|
cells: Vec::new(),
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
dirty_keys: Vec::new(),
|
||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
damage_numbers: 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) {
|
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);
|
self.damage_numbers.retain(|d| d.life > 0);
|
||||||
for d in &mut self.damage_numbers {
|
for d in &mut self.damage_numbers {
|
||||||
d.y -= 0.1;
|
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]) {
|
pub fn set(&mut self, x: i32, y: i32, ch: char, fg: [u8; 3], bg: [u8; 3]) {
|
||||||
self.cells.insert(
|
if let Some(i) = self.idx(x, y) {
|
||||||
(x, y),
|
let was_none = self.cells[i].is_none();
|
||||||
UiCell {
|
self.cells[i] = Some(UiCell {
|
||||||
ch,
|
ch,
|
||||||
fg,
|
fg,
|
||||||
bg,
|
bg,
|
||||||
alpha: 255,
|
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) {
|
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> {
|
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)> {
|
pub fn keys(&self) -> impl Iterator<Item = &(i32, i32)> {
|
||||||
self.cells.keys()
|
self.dirty_keys.iter()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_message(&mut self, text: &str) {
|
pub fn add_message(&mut self, text: &str) {
|
||||||
@@ -173,7 +231,8 @@ impl UiLayer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let ratio = (health / max_health).clamp(0.0, 1.0);
|
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 {
|
let color = if ratio > 0.6 {
|
||||||
[60, 220, 60]
|
[60, 220, 60]
|
||||||
} else if ratio > 0.3 {
|
} else if ratio > 0.3 {
|
||||||
@@ -181,7 +240,7 @@ impl UiLayer {
|
|||||||
} else {
|
} else {
|
||||||
[240, 50, 50]
|
[240, 50, 50]
|
||||||
};
|
};
|
||||||
for i in 0..width {
|
for i in 0..scaled_width {
|
||||||
let x = screen_x + i;
|
let x = screen_x + i;
|
||||||
let ch = if i < filled { '#' } else { '-' };
|
let ch = if i < filled { '#' } else { '-' };
|
||||||
let fg = if i < filled { color } else { [80, 80, 80] };
|
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 (sx, sy) = entity_screen_pos_ui(e, cam_x, cam_y);
|
||||||
let label = e.name().to_uppercase();
|
let label = e.name().to_uppercase();
|
||||||
let top = sy - (e.half_h as i32 * UI_SCALE) - 7;
|
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);
|
let fg = entity_kind_color(e);
|
||||||
self.draw_text(x, top, &label, fg, 255);
|
self.draw_text(x, top, &label, fg, 255);
|
||||||
}
|
}
|
||||||
@@ -241,7 +300,7 @@ impl UiLayer {
|
|||||||
&mut self,
|
&mut self,
|
||||||
screen_w: usize,
|
screen_w: usize,
|
||||||
screen_h: usize,
|
screen_h: usize,
|
||||||
grid: &crate::world::grid::Grid,
|
grid: &ChunkedGrid,
|
||||||
entities: &[Entity],
|
entities: &[Entity],
|
||||||
cam_x: i32,
|
cam_x: i32,
|
||||||
cam_y: i32,
|
cam_y: i32,
|
||||||
@@ -268,8 +327,8 @@ impl UiLayer {
|
|||||||
let mut g = 0u32;
|
let mut g = 0u32;
|
||||||
let mut b = 0u32;
|
let mut b = 0u32;
|
||||||
let mut n = 0u32;
|
let mut n = 0u32;
|
||||||
for yy in 0..scale {
|
for yy in (0..scale).step_by(4) {
|
||||||
for xx in 0..scale {
|
for xx in (0..scale).step_by(4) {
|
||||||
let cell = grid.get(wx + xx, wy + yy);
|
let cell = grid.get(wx + xx, wy + yy);
|
||||||
if cell.material != MaterialId::Empty {
|
if cell.material != MaterialId::Empty {
|
||||||
let c = cell.fg;
|
let c = cell.fg;
|
||||||
@@ -334,8 +393,9 @@ impl UiLayer {
|
|||||||
player: Option<&Entity>,
|
player: Option<&Entity>,
|
||||||
player_state: &crate::entity::player::Player,
|
player_state: &crate::entity::player::Player,
|
||||||
) {
|
) {
|
||||||
let w = 52i32;
|
let fs = self.font_scale;
|
||||||
let h = 62i32;
|
let w = 52 * fs / 2;
|
||||||
|
let h = 62 * fs / 2;
|
||||||
let bg = [22u8, 26, 38];
|
let bg = [22u8, 26, 38];
|
||||||
let border = [160u8, 180, 255];
|
let border = [160u8, 180, 255];
|
||||||
let title = [220u8, 230, 255];
|
let title = [220u8, 230, 255];
|
||||||
@@ -374,12 +434,20 @@ impl UiLayer {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let title_text = "STATUS";
|
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);
|
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 {
|
if let Some(p) = player {
|
||||||
let hp_ratio = (p.health / p.max_health).clamp(0.0, 1.0);
|
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 {
|
let bar_color = if hp_ratio > 0.6 {
|
||||||
[80, 240, 80]
|
[80, 240, 80]
|
||||||
} else if hp_ratio > 0.3 {
|
} else if hp_ratio > 0.3 {
|
||||||
@@ -387,43 +455,43 @@ impl UiLayer {
|
|||||||
} else {
|
} else {
|
||||||
[255, 60, 60]
|
[255, 60, 60]
|
||||||
};
|
};
|
||||||
self.draw_text(start_x + 3, start_y + 10, "HP", fg, 255);
|
self.draw_text(start_x + 3, row1, "HP", fg, 255);
|
||||||
for i in 0..38 {
|
for i in 0..(38 * fs / 2) {
|
||||||
let ch = if i < hp_filled { '#' } else { '-' };
|
let ch = if i < hp_filled { '#' } else { '-' };
|
||||||
let c = if i < hp_filled {
|
let c = if i < hp_filled {
|
||||||
bar_color
|
bar_color
|
||||||
} else {
|
} else {
|
||||||
[80, 80, 100]
|
[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!(
|
let hp_text = format!(
|
||||||
"{}/{} LV:{}",
|
"{}/{} LV:{}",
|
||||||
p.health as i32, p.max_health as i32, p.level
|
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_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;
|
let xp_filled = (xp_ratio * 38.0 * fs as f32 / 2.0).round() as i32;
|
||||||
self.draw_text(start_x + 3, start_y + 26, "XP", fg, 255);
|
self.draw_text(start_x + 3, row3, "XP", fg, 255);
|
||||||
for i in 0..38 {
|
for i in 0..(38 * fs / 2) {
|
||||||
let ch = if i < xp_filled { '#' } else { '-' };
|
let ch = if i < xp_filled { '#' } else { '-' };
|
||||||
let c = if i < xp_filled {
|
let c = if i < xp_filled {
|
||||||
[80, 160, 240]
|
[80, 160, 240]
|
||||||
} else {
|
} else {
|
||||||
[60, 60, 80]
|
[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);
|
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);
|
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";
|
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;
|
let mut ix = start_x + 3;
|
||||||
for item in player_state.inventory.iter().take(8) {
|
for item in player_state.inventory.iter().take(8) {
|
||||||
if ix + 2 >= start_x + w - 2 {
|
if ix + 2 >= start_x + w - 2 {
|
||||||
@@ -431,10 +499,10 @@ impl UiLayer {
|
|||||||
}
|
}
|
||||||
let [ch1, ch2] = item.display_glyph();
|
let [ch1, ch2] = item.display_glyph();
|
||||||
let col = item.color();
|
let col = item.color();
|
||||||
self.set(ix, start_y + 58, ch1, col, bg);
|
self.set(ix, row_items, ch1, col, bg);
|
||||||
self.set(
|
self.set(
|
||||||
ix + 1,
|
ix + 1,
|
||||||
start_y + 58,
|
row_items,
|
||||||
ch2,
|
ch2,
|
||||||
[col[0] / 2, col[1] / 2, col[2] / 2],
|
[col[0] / 2, col[1] / 2, col[2] / 2],
|
||||||
bg,
|
bg,
|
||||||
@@ -461,9 +529,7 @@ impl UiLayer {
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
if !status.is_empty() {
|
if !status.is_empty() {
|
||||||
for (i, ch) in status.chars().enumerate() {
|
self.draw_text(start_x + 30 * fs / 2, row4, &status, [255, 80, 80], 255);
|
||||||
self.set(start_x + 30 + i as i32, start_y + 38, ch, [255, 80, 80], bg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,9 +551,9 @@ impl UiLayer {
|
|||||||
|
|
||||||
let cols = 4i32;
|
let cols = 4i32;
|
||||||
let rows = 2i32;
|
let rows = 2i32;
|
||||||
let slot_w = 12i32;
|
let slot_w = 12i32 * self.font_scale / 2;
|
||||||
let slot_h = 10i32;
|
let slot_h = 10i32 * self.font_scale / 2;
|
||||||
let gap = 3i32;
|
let gap = 3i32 * self.font_scale / 2;
|
||||||
let panel_w = cols * slot_w + (cols + 1) * gap + 4;
|
let panel_w = cols * slot_w + (cols + 1) * gap + 4;
|
||||||
let panel_h = rows * slot_h + (rows + 1) * gap + 28;
|
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);
|
self.draw_text(px_start + 4, py_start + 3, title, title_col, 255);
|
||||||
let count_text = format!("({}/{} items)", player_state.inventory.len(), cols * rows);
|
let count_text = format!("({}/{} items)", player_state.inventory.len(), cols * rows);
|
||||||
self.draw_text(
|
self.draw_text(
|
||||||
px_start + 4 + Self::text_width(title) + 2,
|
px_start + 4 + self.text_width(title) + 2,
|
||||||
py_start + 3,
|
py_start + 3,
|
||||||
&count_text,
|
&count_text,
|
||||||
dim,
|
dim,
|
||||||
@@ -541,7 +607,7 @@ impl UiLayer {
|
|||||||
for col in 0..cols {
|
for col in 0..cols {
|
||||||
let idx = (row * cols + col) as usize;
|
let idx = (row * cols + col) as usize;
|
||||||
let sx = px_start + gap + col * (slot_w + gap) + 2;
|
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 y in 0..slot_h {
|
||||||
for x in 0..slot_w {
|
for x in 0..slot_w {
|
||||||
@@ -583,7 +649,7 @@ impl UiLayer {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let label = item.name();
|
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 lx = sx + (slot_w - label_w) / 2;
|
||||||
let ly = sy + slot_h - 1;
|
let ly = sy + slot_h - 1;
|
||||||
if lx >= sx && lx + label_w <= sx + slot_w {
|
if lx >= sx && lx + label_w <= sx + slot_w {
|
||||||
@@ -671,6 +737,8 @@ impl UiLayer {
|
|||||||
player_state: &crate::entity::player::Player,
|
player_state: &crate::entity::player::Player,
|
||||||
fps: f32,
|
fps: f32,
|
||||||
) {
|
) {
|
||||||
|
let fs = self.font_scale;
|
||||||
|
let fh = self.fh();
|
||||||
let bg = [18u8, 20, 30];
|
let bg = [18u8, 20, 30];
|
||||||
let border = [160u8, 170, 210];
|
let border = [160u8, 170, 210];
|
||||||
let fill_alpha = 140u8;
|
let fill_alpha = 140u8;
|
||||||
@@ -686,11 +754,11 @@ impl UiLayer {
|
|||||||
.map(|i| i.display_string())
|
.map(|i| i.display_string())
|
||||||
.unwrap_or(" ".to_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_top = screen_h as i32 - bar_h;
|
||||||
let y_row1 = y_top + 2;
|
let y_row1 = y_top + 2;
|
||||||
let y_row2 = y_top + 12;
|
let y_row2 = y_top + 2 + fh + 2;
|
||||||
let y_row3 = y_top + 22;
|
let y_row3 = y_top + 2 + (fh + 2) * 2;
|
||||||
|
|
||||||
for row in y_top..screen_h as i32 {
|
for row in y_top..screen_h as i32 {
|
||||||
for x in 0..screen_w {
|
for x in 0..screen_w {
|
||||||
@@ -706,7 +774,8 @@ impl UiLayer {
|
|||||||
self.draw_text(0, y_row1, "HP", [220, 220, 220], 255);
|
self.draw_text(0, y_row1, "HP", [220, 220, 220], 255);
|
||||||
if let Some(p) = player {
|
if let Some(p) = player {
|
||||||
let hp_ratio = (p.health / p.max_health).clamp(0.0, 1.0);
|
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 {
|
let bar_fg = if hp_ratio > 0.6 {
|
||||||
[80, 240, 80]
|
[80, 240, 80]
|
||||||
} else if hp_ratio > 0.3 {
|
} else if hp_ratio > 0.3 {
|
||||||
@@ -714,17 +783,23 @@ impl UiLayer {
|
|||||||
} else {
|
} else {
|
||||||
[255, 60, 60]
|
[255, 60, 60]
|
||||||
};
|
};
|
||||||
for i in 0..28 {
|
for i in 0..hp_bar_w {
|
||||||
let ch = if i < hp_filled { '#' } else { '-' };
|
let ch = if i < hp_filled { '#' } else { '-' };
|
||||||
let c = if i < hp_filled { bar_fg } else { [70, 70, 90] };
|
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);
|
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);
|
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);
|
let brush_text = format!(" {}", brush_name);
|
||||||
self.draw_text(1, y_row2, &brush_text, [200, 200, 140], 255);
|
self.draw_text(1, y_row2, &brush_text, [200, 200, 140], 255);
|
||||||
|
|
||||||
@@ -735,7 +810,7 @@ impl UiLayer {
|
|||||||
player_state.inventory.len(),
|
player_state.inventory.len(),
|
||||||
fps as i32
|
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);
|
let gear_x = (screen_w as i32 - gear_w).max(0);
|
||||||
self.draw_text(gear_x, y_row2, &gear, [160, 180, 220], 255);
|
self.draw_text(gear_x, y_row2, &gear, [160, 180, 220], 255);
|
||||||
|
|
||||||
@@ -748,14 +823,14 @@ impl UiLayer {
|
|||||||
depth,
|
depth,
|
||||||
tick
|
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);
|
let stats_x = (screen_w as i32 - stats_w).max(0);
|
||||||
self.draw_text(stats_x, y_row3, &stats, [180, 190, 220], 255);
|
self.draw_text(stats_x, y_row3, &stats, [180, 190, 220], 255);
|
||||||
|
|
||||||
if let Some(p) = player {
|
if let Some(p) = player {
|
||||||
if p.on_fire {
|
if p.on_fire {
|
||||||
let msg = "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) {
|
pub fn draw_messages(&mut self, x: i32, y: i32) {
|
||||||
let mut yy = y;
|
let mut yy = y;
|
||||||
let messages: Vec<(String, u32)> = self.messages.iter().rev().take(8).cloned().collect();
|
let messages: Vec<(String, u32)> = self.messages.iter().rev().take(8).cloned().collect();
|
||||||
|
let line_h = self.fh() + 1;
|
||||||
for (msg, life) in messages {
|
for (msg, life) in messages {
|
||||||
if yy < 0 {
|
if yy < 0 {
|
||||||
break;
|
break;
|
||||||
@@ -774,7 +850,7 @@ impl UiLayer {
|
|||||||
(200.0 * fade) as u8,
|
(200.0 * fade) as u8,
|
||||||
];
|
];
|
||||||
self.draw_text(x, yy, &msg, fg, 255);
|
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 cx = screen_w as i32 / 2;
|
||||||
let cy = screen_h as i32 / 2;
|
let cy = screen_h as i32 / 2;
|
||||||
let msg = "YOU DIED";
|
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);
|
self.draw_text(x, cy - 3, msg, [255, 50, 50], 255);
|
||||||
let stats = format!("KILLS: {} SCORE: {}", kills, score);
|
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);
|
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()
|
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
|
.sum::<usize>() as i32
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw_text(&mut self, x: i32, y: i32, text: &str, fg: [u8; 3], alpha: u8) {
|
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;
|
let mut cx = x;
|
||||||
for c in text.chars() {
|
for c in text.chars() {
|
||||||
if c == '\n' {
|
if c == '\n' {
|
||||||
@@ -803,21 +880,33 @@ impl UiLayer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(bitmap) = char_bitmap(c) {
|
if let Some(bitmap) = char_bitmap(c) {
|
||||||
for row in 0..5 {
|
for row in 0..FONT_H {
|
||||||
for col in 0..3 {
|
for col in 0..FONT_W {
|
||||||
let filled = (bitmap[row] >> (2 - col)) & 1 == 1;
|
let filled = (bitmap[row as usize] >> (FONT_W - 1 - col)) & 1 == 1;
|
||||||
let a = if filled { alpha } else { 0 };
|
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 {
|
} else {
|
||||||
for row in 0..5 {
|
for row in 0..FONT_H {
|
||||||
for col in 0..3 {
|
for col in 0..FONT_W {
|
||||||
self.set_alpha(cx + col as i32, y + row as i32, c, fg, [0, 0, 0], alpha);
|
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)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub struct Cell {
|
pub struct Cell {
|
||||||
pub material: MaterialId,
|
pub material: MaterialId,
|
||||||
pub temp: f32,
|
|
||||||
pub updated_this_tick: bool,
|
pub updated_this_tick: bool,
|
||||||
pub variant: u8,
|
pub variant: u8,
|
||||||
pub fg: [u8; 3],
|
pub fg: [u8; 3],
|
||||||
pub bg: [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 {
|
impl Cell {
|
||||||
pub fn empty() -> Self {
|
pub fn empty() -> Self {
|
||||||
Self {
|
Self {
|
||||||
material: MaterialId::Empty,
|
material: MaterialId::Empty,
|
||||||
temp: 20.0,
|
|
||||||
updated_this_tick: false,
|
updated_this_tick: false,
|
||||||
variant: 0,
|
variant: 0,
|
||||||
fg: [15, 15, 20],
|
fg: [15, 15, 20],
|
||||||
@@ -67,16 +75,8 @@ impl Cell {
|
|||||||
pub fn new(material: MaterialId) -> Self {
|
pub fn new(material: MaterialId) -> Self {
|
||||||
let reg = MaterialRegistry::instance();
|
let reg = MaterialRegistry::instance();
|
||||||
let mat = reg.get(material);
|
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 {
|
Self {
|
||||||
material,
|
material,
|
||||||
temp,
|
|
||||||
updated_this_tick: false,
|
updated_this_tick: false,
|
||||||
variant: rand_u8(),
|
variant: rand_u8(),
|
||||||
fg: [mat.color_fg.0, mat.color_fg.1, mat.color_fg.2],
|
fg: [mat.color_fg.0, mat.color_fg.1, mat.color_fg.2],
|
||||||
@@ -117,13 +117,12 @@ impl Cell {
|
|||||||
self.material.display_char()
|
self.material.display_char()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_bytes(&self) -> [u8; 12] {
|
pub fn to_bytes(&self) -> [u8; 8] {
|
||||||
let mut out = [0u8; 12];
|
let mut out = [0u8; 8];
|
||||||
out[0] = self.material as u8;
|
out[0] = self.material as u8;
|
||||||
out[1..5].copy_from_slice(&self.temp.to_le_bytes());
|
out[1] = self.variant;
|
||||||
out[5] = self.variant;
|
out[2..5].copy_from_slice(&self.fg);
|
||||||
out[6..9].copy_from_slice(&self.fg);
|
out[5..8].copy_from_slice(&self.bg);
|
||||||
out[9..12].copy_from_slice(&self.bg);
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,25 +149,19 @@ impl Cell {
|
|||||||
_ => MaterialId::Stone,
|
_ => MaterialId::Stone,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let temp = if bytes.len() >= 5 {
|
let variant = bytes.get(1).copied().unwrap_or(0);
|
||||||
f32::from_le_bytes([bytes[1], bytes[2], bytes[3], bytes[4]])
|
let fg = if bytes.len() >= 5 {
|
||||||
} else {
|
[bytes[2], bytes[3], bytes[4]]
|
||||||
20.0
|
|
||||||
};
|
|
||||||
let variant = bytes.get(5).copied().unwrap_or(0);
|
|
||||||
let fg = if bytes.len() >= 9 {
|
|
||||||
[bytes[6], bytes[7], bytes[8]]
|
|
||||||
} else {
|
} else {
|
||||||
[15, 15, 20]
|
[15, 15, 20]
|
||||||
};
|
};
|
||||||
let bg = if bytes.len() >= 12 {
|
let bg = if bytes.len() >= 8 {
|
||||||
[bytes[9], bytes[10], bytes[11]]
|
[bytes[5], bytes[6], bytes[7]]
|
||||||
} else {
|
} else {
|
||||||
[10, 10, 15]
|
[10, 10, 15]
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
material,
|
material,
|
||||||
temp,
|
|
||||||
updated_this_tick: false,
|
updated_this_tick: false,
|
||||||
variant,
|
variant,
|
||||||
fg,
|
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 struct Chunk {
|
||||||
pub cells: Vec<Cell>,
|
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 active: bool,
|
||||||
pub modified: 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 {
|
impl Chunk {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let size = CHUNK_SIZE * CHUNK_SIZE;
|
|
||||||
Self {
|
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,
|
active: false,
|
||||||
modified: 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) {
|
pub fn set_material(&mut self, x: i32, y: i32, mat: MaterialId) {
|
||||||
if Self::in_bounds(x, y) {
|
if Self::in_bounds(x, y) {
|
||||||
self.cells[Self::idx(x, y)] = Cell::new(mat);
|
self.cells[Self::idx(x, y)] = Cell::new(mat);
|
||||||
|
self.temps[Self::idx(x, y)] = crate::world::cell::default_temp(mat);
|
||||||
self.modified = true;
|
self.modified = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_tick_flags(&mut self) {
|
#[inline]
|
||||||
for c in &mut self.cells {
|
pub fn get_temp(&self, x: i32, y: i32) -> f32 {
|
||||||
c.updated_this_tick = false;
|
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_W: usize = 250;
|
||||||
pub const WORLD_H: 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 struct ChunkMeta {
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
pub modified: bool,
|
pub modified: bool,
|
||||||
pub was_modified: bool,
|
pub was_modified: bool,
|
||||||
|
pub dirty: Option<(i32, i32, i32, i32)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Grid {
|
pub struct Grid {
|
||||||
@@ -24,22 +28,27 @@ pub struct Grid {
|
|||||||
|
|
||||||
impl Grid {
|
impl Grid {
|
||||||
pub fn new() -> Self {
|
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 chunk_size = CHUNK_SIZE;
|
||||||
let chunks_x = (WORLD_W + chunk_size - 1) / chunk_size;
|
let chunks_x = (width + chunk_size - 1) / chunk_size;
|
||||||
let chunks_y = (WORLD_H + chunk_size - 1) / chunk_size;
|
let chunks_y = (height + chunk_size - 1) / chunk_size;
|
||||||
let mut chunks = Vec::with_capacity(chunks_x * chunks_y);
|
let mut chunks = Vec::with_capacity(chunks_x * chunks_y);
|
||||||
for _ in 0..chunks_x * chunks_y {
|
for _ in 0..chunks_x * chunks_y {
|
||||||
chunks.push(ChunkMeta {
|
chunks.push(ChunkMeta {
|
||||||
active: true,
|
active: true,
|
||||||
modified: false,
|
modified: false,
|
||||||
was_modified: false,
|
was_modified: false,
|
||||||
|
dirty: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
cells: vec![Cell::empty(); size],
|
cells: vec![Cell::empty(); size],
|
||||||
width: WORLD_W,
|
width,
|
||||||
height: WORLD_H,
|
height,
|
||||||
chunk_size,
|
chunk_size,
|
||||||
chunks_x,
|
chunks_x,
|
||||||
chunks_y,
|
chunks_y,
|
||||||
@@ -185,6 +194,7 @@ impl Grid {
|
|||||||
if let Some(c) = self.chunks.get_mut(idx) {
|
if let Some(c) = self.chunks.get_mut(idx) {
|
||||||
c.modified = true;
|
c.modified = true;
|
||||||
}
|
}
|
||||||
|
self.mark_dirty(x, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +208,7 @@ impl Grid {
|
|||||||
if let Some(c) = self.chunks.get_mut(idx) {
|
if let Some(c) = self.chunks.get_mut(idx) {
|
||||||
c.modified = true;
|
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) {
|
pub fn reset_tick_flags(&mut self) {
|
||||||
for c in &mut self.cells {
|
for cy in 0..self.chunks_y {
|
||||||
c.updated_this_tick = false;
|
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 (x0, y0, x1, y1) = self.chunk_bounds(cx as usize, cy as usize);
|
||||||
let w = (x1 - x0) as usize;
|
let w = (x1 - x0) as usize;
|
||||||
let h = (y1 - y0) 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 y in y0..y1 {
|
||||||
for x in x0..x1 {
|
for x in x0..x1 {
|
||||||
bytes.extend_from_slice(&self.get(x, y).to_bytes());
|
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 (x0, y0, x1, y1) = self.chunk_bounds(cx as usize, cy as usize);
|
||||||
let w = (x1 - x0) as usize;
|
let w = (x1 - x0) as usize;
|
||||||
let h = (y1 - y0) as usize;
|
let h = (y1 - y0) as usize;
|
||||||
let expected = w * h * 12;
|
let expected = w * h * 8;
|
||||||
if data.len() != expected {
|
if data.len() != expected {
|
||||||
return Err(io::Error::other("chunk file size mismatch"));
|
return Err(io::Error::other("chunk file size mismatch"));
|
||||||
}
|
}
|
||||||
let mut i = 0usize;
|
let mut i = 0usize;
|
||||||
for y in y0..y1 {
|
for y in y0..y1 {
|
||||||
for x in x0..x1 {
|
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);
|
self.set(x, y, cell);
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ pub struct Material {
|
|||||||
pub ignition_temp: f32,
|
pub ignition_temp: f32,
|
||||||
pub melt_temp: f32,
|
pub melt_temp: f32,
|
||||||
pub heat_conductivity: f32,
|
pub heat_conductivity: f32,
|
||||||
|
pub conductive: bool,
|
||||||
|
pub conductivity: f32,
|
||||||
|
pub structural: bool,
|
||||||
pub color_fg: (u8, u8, u8),
|
pub color_fg: (u8, u8, u8),
|
||||||
pub color_bg: (u8, u8, u8),
|
pub color_bg: (u8, u8, u8),
|
||||||
pub display_char: char,
|
pub display_char: char,
|
||||||
@@ -40,6 +43,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: f32::INFINITY,
|
ignition_temp: f32::INFINITY,
|
||||||
melt_temp: f32::INFINITY,
|
melt_temp: f32::INFINITY,
|
||||||
heat_conductivity: 0.0,
|
heat_conductivity: 0.0,
|
||||||
|
conductive: false,
|
||||||
|
conductivity: 0.0,
|
||||||
|
structural: false,
|
||||||
color_fg: (15, 15, 20),
|
color_fg: (15, 15, 20),
|
||||||
color_bg: (10, 10, 15),
|
color_bg: (10, 10, 15),
|
||||||
display_char: ' ',
|
display_char: ' ',
|
||||||
@@ -56,6 +62,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: f32::INFINITY,
|
ignition_temp: f32::INFINITY,
|
||||||
melt_temp: 1700.0,
|
melt_temp: 1700.0,
|
||||||
heat_conductivity: 0.2,
|
heat_conductivity: 0.2,
|
||||||
|
conductive: false,
|
||||||
|
conductivity: 0.0,
|
||||||
|
structural: false,
|
||||||
color_fg: (218, 178, 90),
|
color_fg: (218, 178, 90),
|
||||||
color_bg: (60, 50, 30),
|
color_bg: (60, 50, 30),
|
||||||
display_char: '.',
|
display_char: '.',
|
||||||
@@ -72,6 +81,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: f32::INFINITY,
|
ignition_temp: f32::INFINITY,
|
||||||
melt_temp: 0.0,
|
melt_temp: 0.0,
|
||||||
heat_conductivity: 0.4,
|
heat_conductivity: 0.4,
|
||||||
|
conductive: true,
|
||||||
|
conductivity: 0.6,
|
||||||
|
structural: false,
|
||||||
color_fg: (64, 128, 220),
|
color_fg: (64, 128, 220),
|
||||||
color_bg: (20, 40, 80),
|
color_bg: (20, 40, 80),
|
||||||
display_char: '~',
|
display_char: '~',
|
||||||
@@ -88,6 +100,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: f32::INFINITY,
|
ignition_temp: f32::INFINITY,
|
||||||
melt_temp: 1200.0,
|
melt_temp: 1200.0,
|
||||||
heat_conductivity: 0.3,
|
heat_conductivity: 0.3,
|
||||||
|
conductive: false,
|
||||||
|
conductivity: 0.0,
|
||||||
|
structural: true,
|
||||||
color_fg: (120, 120, 130),
|
color_fg: (120, 120, 130),
|
||||||
color_bg: (40, 40, 50),
|
color_bg: (40, 40, 50),
|
||||||
display_char: '#',
|
display_char: '#',
|
||||||
@@ -104,6 +119,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: f32::INFINITY,
|
ignition_temp: f32::INFINITY,
|
||||||
melt_temp: f32::INFINITY,
|
melt_temp: f32::INFINITY,
|
||||||
heat_conductivity: 0.05,
|
heat_conductivity: 0.05,
|
||||||
|
conductive: false,
|
||||||
|
conductivity: 0.0,
|
||||||
|
structural: false,
|
||||||
color_fg: (255, 80, 20),
|
color_fg: (255, 80, 20),
|
||||||
color_bg: (120, 20, 0),
|
color_bg: (120, 20, 0),
|
||||||
display_char: '#',
|
display_char: '#',
|
||||||
@@ -120,6 +138,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: 300.0,
|
ignition_temp: 300.0,
|
||||||
melt_temp: f32::INFINITY,
|
melt_temp: f32::INFINITY,
|
||||||
heat_conductivity: 0.1,
|
heat_conductivity: 0.1,
|
||||||
|
conductive: false,
|
||||||
|
conductivity: 0.0,
|
||||||
|
structural: true,
|
||||||
color_fg: (140, 90, 50),
|
color_fg: (140, 90, 50),
|
||||||
color_bg: (50, 30, 20),
|
color_bg: (50, 30, 20),
|
||||||
display_char: 'T',
|
display_char: 'T',
|
||||||
@@ -136,6 +157,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: 200.0,
|
ignition_temp: 200.0,
|
||||||
melt_temp: f32::INFINITY,
|
melt_temp: f32::INFINITY,
|
||||||
heat_conductivity: 0.15,
|
heat_conductivity: 0.15,
|
||||||
|
conductive: false,
|
||||||
|
conductivity: 0.0,
|
||||||
|
structural: false,
|
||||||
color_fg: (180, 50, 50),
|
color_fg: (180, 50, 50),
|
||||||
color_bg: (60, 15, 15),
|
color_bg: (60, 15, 15),
|
||||||
display_char: '%',
|
display_char: '%',
|
||||||
@@ -152,6 +176,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: f32::INFINITY,
|
ignition_temp: f32::INFINITY,
|
||||||
melt_temp: f32::INFINITY,
|
melt_temp: f32::INFINITY,
|
||||||
heat_conductivity: 0.1,
|
heat_conductivity: 0.1,
|
||||||
|
conductive: false,
|
||||||
|
conductivity: 0.0,
|
||||||
|
structural: false,
|
||||||
color_fg: (220, 210, 190),
|
color_fg: (220, 210, 190),
|
||||||
color_bg: (60, 55, 50),
|
color_bg: (60, 55, 50),
|
||||||
display_char: '`',
|
display_char: '`',
|
||||||
@@ -168,6 +195,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: f32::INFINITY,
|
ignition_temp: f32::INFINITY,
|
||||||
melt_temp: f32::INFINITY,
|
melt_temp: f32::INFINITY,
|
||||||
heat_conductivity: 0.2,
|
heat_conductivity: 0.2,
|
||||||
|
conductive: false,
|
||||||
|
conductivity: 0.0,
|
||||||
|
structural: false,
|
||||||
color_fg: (200, 200, 220),
|
color_fg: (200, 200, 220),
|
||||||
color_bg: (30, 30, 40),
|
color_bg: (30, 30, 40),
|
||||||
display_char: '~',
|
display_char: '~',
|
||||||
@@ -184,6 +214,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: f32::INFINITY,
|
ignition_temp: f32::INFINITY,
|
||||||
melt_temp: f32::INFINITY,
|
melt_temp: f32::INFINITY,
|
||||||
heat_conductivity: 0.6,
|
heat_conductivity: 0.6,
|
||||||
|
conductive: false,
|
||||||
|
conductivity: 0.0,
|
||||||
|
structural: false,
|
||||||
color_fg: (255, 160, 40),
|
color_fg: (255, 160, 40),
|
||||||
color_bg: (100, 30, 0),
|
color_bg: (100, 30, 0),
|
||||||
display_char: '^',
|
display_char: '^',
|
||||||
@@ -200,6 +233,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: f32::INFINITY,
|
ignition_temp: f32::INFINITY,
|
||||||
melt_temp: f32::INFINITY,
|
melt_temp: f32::INFINITY,
|
||||||
heat_conductivity: 0.3,
|
heat_conductivity: 0.3,
|
||||||
|
conductive: false,
|
||||||
|
conductivity: 0.0,
|
||||||
|
structural: false,
|
||||||
color_fg: (100, 255, 60),
|
color_fg: (100, 255, 60),
|
||||||
color_bg: (20, 60, 10),
|
color_bg: (20, 60, 10),
|
||||||
display_char: '~',
|
display_char: '~',
|
||||||
@@ -216,6 +252,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: f32::INFINITY,
|
ignition_temp: f32::INFINITY,
|
||||||
melt_temp: f32::INFINITY,
|
melt_temp: f32::INFINITY,
|
||||||
heat_conductivity: 0.1,
|
heat_conductivity: 0.1,
|
||||||
|
conductive: false,
|
||||||
|
conductivity: 0.0,
|
||||||
|
structural: false,
|
||||||
color_fg: (100, 100, 100),
|
color_fg: (100, 100, 100),
|
||||||
color_bg: (20, 20, 20),
|
color_bg: (20, 20, 20),
|
||||||
display_char: '*',
|
display_char: '*',
|
||||||
@@ -232,6 +271,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: 250.0,
|
ignition_temp: 250.0,
|
||||||
melt_temp: f32::INFINITY,
|
melt_temp: f32::INFINITY,
|
||||||
heat_conductivity: 0.1,
|
heat_conductivity: 0.1,
|
||||||
|
conductive: false,
|
||||||
|
conductivity: 0.0,
|
||||||
|
structural: false,
|
||||||
color_fg: (80, 200, 60),
|
color_fg: (80, 200, 60),
|
||||||
color_bg: (20, 50, 15),
|
color_bg: (20, 50, 15),
|
||||||
display_char: '"',
|
display_char: '"',
|
||||||
@@ -248,6 +290,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: f32::INFINITY,
|
ignition_temp: f32::INFINITY,
|
||||||
melt_temp: f32::INFINITY,
|
melt_temp: f32::INFINITY,
|
||||||
heat_conductivity: 0.2,
|
heat_conductivity: 0.2,
|
||||||
|
conductive: false,
|
||||||
|
conductivity: 0.0,
|
||||||
|
structural: false,
|
||||||
color_fg: (100, 70, 50),
|
color_fg: (100, 70, 50),
|
||||||
color_bg: (40, 30, 20),
|
color_bg: (40, 30, 20),
|
||||||
display_char: ':',
|
display_char: ':',
|
||||||
@@ -264,6 +309,9 @@ impl MaterialRegistry {
|
|||||||
ignition_temp: f32::INFINITY,
|
ignition_temp: f32::INFINITY,
|
||||||
melt_temp: f32::INFINITY,
|
melt_temp: f32::INFINITY,
|
||||||
heat_conductivity: 0.0,
|
heat_conductivity: 0.0,
|
||||||
|
conductive: false,
|
||||||
|
conductivity: 0.0,
|
||||||
|
structural: false,
|
||||||
color_fg: (255, 220, 80),
|
color_fg: (255, 220, 80),
|
||||||
color_bg: (60, 50, 20),
|
color_bg: (60, 50, 20),
|
||||||
display_char: '>',
|
display_char: '>',
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
pub mod cache;
|
||||||
pub mod cell;
|
pub mod cell;
|
||||||
pub mod cellular;
|
pub mod cellular;
|
||||||
pub mod chunk;
|
pub mod chunk;
|
||||||
|
pub mod chunked_grid;
|
||||||
pub mod grid;
|
pub mod grid;
|
||||||
pub mod material;
|
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.fg, c2.fg);
|
||||||
assert_eq!(c.bg, c2.bg);
|
assert_eq!(c.bg, c2.bg);
|
||||||
assert_eq!(c.variant, c2.variant);
|
assert_eq!(c.variant, c2.variant);
|
||||||
assert!((c.temp - c2.temp).abs() < 0.001);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+158
-26
@@ -1,11 +1,17 @@
|
|||||||
use verbatim::ai::GameSession;
|
|
||||||
use verbatim::ai::AiAction;
|
use verbatim::ai::AiAction;
|
||||||
|
use verbatim::ai::GameSession;
|
||||||
|
|
||||||
fn setup() -> GameSession {
|
fn setup() -> GameSession {
|
||||||
let mut s = GameSession::new_seeded(42);
|
let mut s = GameSession::new_seeded(42);
|
||||||
s.init_empty();
|
s.init_empty();
|
||||||
s.clear_area(90, 90, 50, 50);
|
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
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,11 +21,22 @@ fn player_blocked_by_left_wall() {
|
|||||||
s.step(30);
|
s.step(30);
|
||||||
let p = s.get_player().unwrap();
|
let p = s.get_player().unwrap();
|
||||||
let wall_x = (p.pos[0] as i32) - 6;
|
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.perform_action(&AiAction::MoveLeft);
|
||||||
s.step(20);
|
s.step(20);
|
||||||
let p2 = s.get_player().unwrap();
|
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]
|
#[test]
|
||||||
@@ -28,11 +45,22 @@ fn player_blocked_by_right_wall() {
|
|||||||
s.step(30);
|
s.step(30);
|
||||||
let p = s.get_player().unwrap();
|
let p = s.get_player().unwrap();
|
||||||
let wall_x = (p.pos[0] as i32) + 6;
|
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.perform_action(&AiAction::MoveRight);
|
||||||
s.step(20);
|
s.step(20);
|
||||||
let p2 = s.get_player().unwrap();
|
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]
|
#[test]
|
||||||
@@ -41,14 +69,24 @@ fn player_slides_along_wall() {
|
|||||||
s.step(30);
|
s.step(30);
|
||||||
let p = s.get_player().unwrap();
|
let p = s.get_player().unwrap();
|
||||||
let wall_x = (p.pos[0] as i32) + 6;
|
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.perform_action(&AiAction::MoveRight);
|
||||||
s.step(30);
|
s.step(30);
|
||||||
let p2 = s.get_player().unwrap();
|
let p2 = s.get_player().unwrap();
|
||||||
assert!(p2.pos[0] < wall_x as f32, "player should stay left of wall");
|
assert!(p2.pos[0] < wall_x as f32, "player should stay left of wall");
|
||||||
assert!(p2.alive, "player should be alive");
|
assert!(p2.alive, "player should be alive");
|
||||||
let y_diff = (p2.pos[1] - p.pos[1]).abs();
|
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]
|
#[test]
|
||||||
@@ -57,11 +95,22 @@ fn player_blocked_by_ceiling() {
|
|||||||
s.step(30);
|
s.step(30);
|
||||||
let p = s.get_player().unwrap();
|
let p = s.get_player().unwrap();
|
||||||
let ceiling_y = (p.pos[1] as i32) - 8;
|
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.perform_action(&AiAction::Jump);
|
||||||
s.step(10);
|
s.step(10);
|
||||||
let p2 = s.get_player().unwrap();
|
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]
|
#[test]
|
||||||
@@ -69,13 +118,40 @@ fn player_navigates_corridor() {
|
|||||||
let mut s = GameSession::new_seeded(42);
|
let mut s = GameSession::new_seeded(42);
|
||||||
s.init_empty();
|
s.init_empty();
|
||||||
s.clear_area(100, 100, 40, 30);
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: 115, y: 110, w: 1, h: 10, material: "stone".into() });
|
x: 95,
|
||||||
s.perform_action(&AiAction::FillRect { x: 125, y: 110, w: 1, h: 10, material: "stone".into() });
|
y: 120,
|
||||||
s.perform_action(&AiAction::FillRect { x: 115, y: 108, w: 11, h: 1, material: "stone".into() });
|
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);
|
s.step(30);
|
||||||
let p = s.get_player().unwrap();
|
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]
|
#[test]
|
||||||
@@ -84,7 +160,13 @@ fn player_does_not_stick_to_wall() {
|
|||||||
s.step(30);
|
s.step(30);
|
||||||
let p = s.get_player().unwrap();
|
let p = s.get_player().unwrap();
|
||||||
let wall_x = (p.pos[0] as i32) + 8;
|
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 {
|
for _ in 0..10 {
|
||||||
s.perform_action(&AiAction::MoveRight);
|
s.perform_action(&AiAction::MoveRight);
|
||||||
s.step(2);
|
s.step(2);
|
||||||
@@ -96,7 +178,12 @@ fn player_does_not_stick_to_wall() {
|
|||||||
s.step(2);
|
s.step(2);
|
||||||
}
|
}
|
||||||
let p_away = s.get_player().unwrap();
|
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]
|
#[test]
|
||||||
@@ -105,8 +192,20 @@ fn player_squeezes_through_gap() {
|
|||||||
s.step(30);
|
s.step(30);
|
||||||
let p = s.get_player().unwrap();
|
let p = s.get_player().unwrap();
|
||||||
let px = p.pos[0] as i32;
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: px + 8, y: 122, w: 1, h: 2, material: "stone".into() });
|
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.perform_action(&AiAction::MoveRight);
|
||||||
s.step(20);
|
s.step(20);
|
||||||
let p2 = s.get_player().unwrap();
|
let p2 = s.get_player().unwrap();
|
||||||
@@ -119,8 +218,20 @@ fn player_blocked_by_two_walls_both_sides() {
|
|||||||
s.step(30);
|
s.step(30);
|
||||||
let p = s.get_player().unwrap();
|
let p = s.get_player().unwrap();
|
||||||
let px = p.pos[0] as i32;
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: px + 6, y: 125, w: 1, h: 10, material: "stone".into() });
|
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.perform_action(&AiAction::MoveRight);
|
||||||
s.step(10);
|
s.step(10);
|
||||||
let p_right = s.get_player().unwrap();
|
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();
|
let p_left = s.get_player().unwrap();
|
||||||
assert!(p_right.pos[0] < (px + 6) as f32, "blocked right");
|
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] > (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]
|
#[test]
|
||||||
@@ -140,7 +254,11 @@ fn player_walks_up_slope() {
|
|||||||
for x in 100..130 {
|
for x in 100..130 {
|
||||||
let h = ((x - 100) / 3).min(15);
|
let h = ((x - 100) / 3).min(15);
|
||||||
for y in 0..h {
|
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);
|
s.step(40);
|
||||||
@@ -152,11 +270,25 @@ fn player_walks_up_slope() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn entity_collision_with_dirt_wall() {
|
fn entity_collision_with_dirt_wall() {
|
||||||
let mut s = setup();
|
let mut s = setup();
|
||||||
s.perform_action(&AiAction::FillRect { x: 140, y: 125, w: 1, h: 5, material: "dirt".into() });
|
s.perform_action(&AiAction::FillRect {
|
||||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 135.0, y: 120.0 });
|
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);
|
s.step(40);
|
||||||
let entities = s.get_entities();
|
let entities = s.get_entities();
|
||||||
if let Some(g) = entities.into_iter().find(|e| e.kind == "Goblin" && e.alive) {
|
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 mut ui = verbatim::ui::UiLayer::new();
|
||||||
let screen_w = 320;
|
let screen_w = 320;
|
||||||
let screen_h = 100;
|
let screen_h = 100;
|
||||||
|
ui.resize(screen_w, screen_h);
|
||||||
ui.draw_hud(
|
ui.draw_hud(
|
||||||
screen_w,
|
screen_w,
|
||||||
screen_h,
|
screen_h,
|
||||||
@@ -69,11 +70,14 @@ fn depth_shown_in_hud() {
|
|||||||
"LV:{} XP:{} K:{} S:{} D:{} T:{}",
|
"LV:{} XP:{} K:{} S:{} D:{} T:{}",
|
||||||
player.level, player.xp, 0, 0, s.game.depth, s.game.tick
|
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 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)
|
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(' '))
|
.map(|x| ui.get(x, y_row3).map(|c| c.ch).unwrap_or(' '))
|
||||||
.collect();
|
.collect();
|
||||||
assert!(line.contains("D:1"), "HUD should show depth: {}", line);
|
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::AiAction;
|
||||||
|
use verbatim::ai::GameSession;
|
||||||
use verbatim::ai::ReplayPlayer;
|
use verbatim::ai::ReplayPlayer;
|
||||||
|
|
||||||
fn setup() -> GameSession {
|
fn setup() -> GameSession {
|
||||||
let mut s = GameSession::new_seeded(42);
|
let mut s = GameSession::new_seeded(42);
|
||||||
s.init_empty();
|
s.init_empty();
|
||||||
s.clear_area(90, 90, 50, 50);
|
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
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +38,11 @@ fn same_seed_same_entity_count() {
|
|||||||
let mut s2 = GameSession::new_seeded(555);
|
let mut s2 = GameSession::new_seeded(555);
|
||||||
s2.init();
|
s2.init();
|
||||||
s2.step(60);
|
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]
|
#[test]
|
||||||
@@ -47,7 +57,11 @@ fn same_seed_same_grid_state() {
|
|||||||
for x in 100..150 {
|
for x in 100..150 {
|
||||||
let c1 = s1.get_cell(x, y);
|
let c1 = s1.get_cell(x, y);
|
||||||
let c2 = s2.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.perform_action(&AiAction::MoveLeft);
|
||||||
s.step(10);
|
s.step(10);
|
||||||
let state_orig = s.get_state();
|
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 player = ReplayPlayer::load("/tmp/verbatim_replay_exact.json").expect("load");
|
||||||
let s2 = player.play();
|
let s2 = player.play();
|
||||||
@@ -77,8 +92,18 @@ fn replay_exact_match() {
|
|||||||
|
|
||||||
assert_eq!(state_orig.tick, state_replay.tick, "tick mismatch");
|
assert_eq!(state_orig.tick, state_replay.tick, "tick mismatch");
|
||||||
if let (Some(p1), Some(p2)) = (&state_orig.player, &state_replay.player) {
|
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!(
|
||||||
assert!((p1.pos[1] - p2.pos[1]).abs() < 0.1, "y mismatch: {} vs {}", p1.pos[1], p2.pos[1]);
|
(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");
|
assert!((p1.health - p2.health).abs() < 1.0, "health mismatch");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,14 +116,25 @@ fn replay_stop_at_tick() {
|
|||||||
s.step(20);
|
s.step(20);
|
||||||
s.perform_action(&AiAction::MoveRight);
|
s.perform_action(&AiAction::MoveRight);
|
||||||
s.step(20);
|
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 player = ReplayPlayer::load("/tmp/verbatim_replay_partial.json").expect("load");
|
||||||
let s_half = player.play_until_tick(10);
|
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();
|
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]
|
#[test]
|
||||||
@@ -110,28 +146,52 @@ fn recording_captures_all_actions() {
|
|||||||
s.perform_action(&AiAction::Jump);
|
s.perform_action(&AiAction::Jump);
|
||||||
s.perform_action(&AiAction::MoveLeft);
|
s.perform_action(&AiAction::MoveLeft);
|
||||||
s.step(5);
|
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 player = ReplayPlayer::load("/tmp/verbatim_replay_capture.json").expect("load");
|
||||||
let event_count = player.recording().events.len();
|
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]
|
#[test]
|
||||||
fn determinism_with_spawn_and_damage() {
|
fn determinism_with_spawn_and_damage() {
|
||||||
let mut s1 = setup();
|
let mut s1 = setup();
|
||||||
s1.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 135.0, y: 120.0 });
|
s1.perform_action(&AiAction::Spawn {
|
||||||
s1.perform_action(&AiAction::DamageEntity { id: 1, amount: 20.0 });
|
kind: "goblin".into(),
|
||||||
|
x: 135.0,
|
||||||
|
y: 120.0,
|
||||||
|
});
|
||||||
|
s1.perform_action(&AiAction::DamageEntity {
|
||||||
|
id: 1,
|
||||||
|
amount: 20.0,
|
||||||
|
});
|
||||||
s1.step(30);
|
s1.step(30);
|
||||||
|
|
||||||
let mut s2 = setup();
|
let mut s2 = setup();
|
||||||
s2.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 135.0, y: 120.0 });
|
s2.perform_action(&AiAction::Spawn {
|
||||||
s2.perform_action(&AiAction::DamageEntity { id: 1, amount: 20.0 });
|
kind: "goblin".into(),
|
||||||
|
x: 135.0,
|
||||||
|
y: 120.0,
|
||||||
|
});
|
||||||
|
s2.perform_action(&AiAction::DamageEntity {
|
||||||
|
id: 1,
|
||||||
|
amount: 20.0,
|
||||||
|
});
|
||||||
s2.step(30);
|
s2.step(30);
|
||||||
|
|
||||||
let e1 = s1.get_entities().into_iter().find(|e| e.id == 1).unwrap();
|
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();
|
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[0] - e2.pos[0]).abs() < 0.1, "x should match");
|
||||||
assert!((e1.pos[1] - e2.pos[1]).abs() < 0.1, "y 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 p1 = s1.get_player().unwrap();
|
||||||
let p2 = s2.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::AiAction;
|
||||||
|
use verbatim::ai::GameSession;
|
||||||
|
|
||||||
fn setup_empty() -> GameSession {
|
fn setup_empty() -> GameSession {
|
||||||
let mut s = GameSession::new_seeded(42);
|
let mut s = GameSession::new_seeded(42);
|
||||||
@@ -11,39 +11,92 @@ fn setup_empty() -> GameSession {
|
|||||||
#[test]
|
#[test]
|
||||||
fn entity_takes_lava_damage() {
|
fn entity_takes_lava_damage() {
|
||||||
let mut s = setup_empty();
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: 114, y: 127, w: 8, h: 3, material: "lava".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 118.0, y: 118.0 });
|
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);
|
s.step(80);
|
||||||
let entities = s.get_entities();
|
let entities = s.get_entities();
|
||||||
let goblin = entities.into_iter().find(|e| e.kind == "Goblin");
|
let goblin = entities.into_iter().find(|e| e.kind == "Goblin");
|
||||||
assert!(goblin.is_some(), "goblin should exist");
|
assert!(goblin.is_some(), "goblin should exist");
|
||||||
let g = goblin.unwrap();
|
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]
|
#[test]
|
||||||
fn entity_dies_becomes_corpse() {
|
fn entity_dies_becomes_corpse() {
|
||||||
let mut s = setup_empty();
|
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 {
|
||||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 110.0, y: 120.0 });
|
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.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);
|
s.step(1);
|
||||||
let entities = s.get_entities();
|
let entities = s.get_entities();
|
||||||
let goblin = entities.into_iter().find(|e| e.id == 1);
|
let goblin = entities.into_iter().find(|e| e.id == 1);
|
||||||
assert!(goblin.is_some(), "entity should still exist");
|
assert!(goblin.is_some(), "entity should still exist");
|
||||||
let g = goblin.unwrap();
|
let g = goblin.unwrap();
|
||||||
assert!(!g.alive, "entity should be dead after 100 damage");
|
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]
|
#[test]
|
||||||
fn entity_on_fire_takes_damage_over_time() {
|
fn entity_on_fire_takes_damage_over_time() {
|
||||||
let mut s = setup_empty();
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: 116, y: 123, w: 4, h: 2, material: "lava".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 118.0, y: 120.0 });
|
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);
|
s.step(20);
|
||||||
let entities = s.get_entities();
|
let entities = s.get_entities();
|
||||||
let goblin = entities.into_iter().find(|e| e.id == 1);
|
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);
|
s.step(30);
|
||||||
let entities2 = s.get_entities();
|
let entities2 = s.get_entities();
|
||||||
if let Some(g2) = entities2.into_iter().find(|e| e.id == 1) {
|
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]
|
#[test]
|
||||||
fn entity_blocked_by_stone() {
|
fn entity_blocked_by_stone() {
|
||||||
let mut s = setup_empty();
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: 110, y: 126, w: 1, h: 4, material: "stone".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 105.0, y: 125.0 });
|
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);
|
s.step(30);
|
||||||
let entities = s.get_entities();
|
let entities = s.get_entities();
|
||||||
if let Some(g) = entities.into_iter().find(|e| e.id == 1) {
|
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::AiAction;
|
||||||
|
use verbatim::ai::GameSession;
|
||||||
|
|
||||||
fn setup_empty() -> GameSession {
|
fn setup_empty() -> GameSession {
|
||||||
let mut s = GameSession::new_seeded(42);
|
let mut s = GameSession::new_seeded(42);
|
||||||
s.init_empty();
|
s.init_empty();
|
||||||
s.clear_area(95, 95, 40, 35);
|
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
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,16 +20,32 @@ fn player_falls_and_lands() {
|
|||||||
let mut s = GameSession::new_seeded(42);
|
let mut s = GameSession::new_seeded(42);
|
||||||
s.init_empty();
|
s.init_empty();
|
||||||
s.clear_area(115, 115, 20, 15);
|
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);
|
s.step(80);
|
||||||
let player = s.get_player().expect("player should exist");
|
let player = s.get_player().expect("player should exist");
|
||||||
assert!(player.alive, "player should be alive");
|
assert!(player.alive, "player should be alive");
|
||||||
let y = player.pos[1];
|
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);
|
s.step(30);
|
||||||
let player2 = s.get_player().expect("player should exist");
|
let player2 = s.get_player().expect("player should exist");
|
||||||
let dy = (player2.pos[1] - y).abs();
|
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]
|
#[test]
|
||||||
@@ -32,11 +54,20 @@ fn player_blocked_by_stone_wall() {
|
|||||||
s.step(30);
|
s.step(30);
|
||||||
let player = s.get_player().expect("player should exist");
|
let player = s.get_player().expect("player should exist");
|
||||||
let x = player.pos[0] as i32;
|
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.perform_action(&AiAction::MoveRight);
|
||||||
s.step(10);
|
s.step(10);
|
||||||
let player = s.get_player().expect("player should exist");
|
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]
|
#[test]
|
||||||
@@ -48,7 +79,12 @@ fn player_can_move_right() {
|
|||||||
s.perform_action(&AiAction::MoveRight);
|
s.perform_action(&AiAction::MoveRight);
|
||||||
s.step(10);
|
s.step(10);
|
||||||
let player = s.get_player().expect("player should exist");
|
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]
|
#[test]
|
||||||
@@ -57,5 +93,9 @@ fn player_survives_fall() {
|
|||||||
s.step(60);
|
s.step(60);
|
||||||
let player = s.get_player().expect("player should exist");
|
let player = s.get_player().expect("player should exist");
|
||||||
assert!(player.alive, "player should survive a fall onto stone");
|
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::AiAction;
|
||||||
|
use verbatim::ai::GameSession;
|
||||||
use verbatim::ai::ReplayPlayer;
|
use verbatim::ai::ReplayPlayer;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -13,7 +13,8 @@ fn replay_deterministic() {
|
|||||||
s1.step(10);
|
s1.step(10);
|
||||||
let state1 = s1.get_state();
|
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 player = ReplayPlayer::load("/tmp/verbatim_test_replay.json").expect("load replay");
|
||||||
let s2 = player.play();
|
let s2 = player.play();
|
||||||
@@ -22,8 +23,18 @@ fn replay_deterministic() {
|
|||||||
assert_eq!(state1.tick, state2.tick, "ticks should match");
|
assert_eq!(state1.tick, state2.tick, "ticks should match");
|
||||||
if let (Some(p1), Some(p2)) = (&state1.player, &state2.player) {
|
if let (Some(p1), Some(p2)) = (&state1.player, &state2.player) {
|
||||||
assert_eq!(p1.health, p2.health, "player health should match");
|
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!(
|
||||||
assert!((p1.pos[1] - p2.pos[1]).abs() < 0.01, "player y should match: {} vs {}", p1.pos[1], p2.pos[1]);
|
(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.step(5);
|
||||||
s.perform_action(&AiAction::Jump);
|
s.perform_action(&AiAction::Jump);
|
||||||
s.step(10);
|
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 player = ReplayPlayer::load("/tmp/verbatim_test_replay2.json").expect("load");
|
||||||
let s_half = player.play_until_tick(5);
|
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();
|
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]
|
#[test]
|
||||||
@@ -62,8 +84,18 @@ fn same_seed_same_state() {
|
|||||||
|
|
||||||
assert_eq!(state1.tick, state2.tick);
|
assert_eq!(state1.tick, state2.tick);
|
||||||
if let (Some(p1), Some(p2)) = (&state1.player, &state2.player) {
|
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!(
|
||||||
assert!((p1.pos[1] - p2.pos[1]).abs() < 0.01, "y mismatch: {} vs {}", p1.pos[1], p2.pos[1]);
|
(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};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
let mut child = Command::new(env!("CARGO_BIN_EXE_verbatim"))
|
let mut child = Command::new(env!("CARGO_BIN_EXE_verbatim"))
|
||||||
.arg("--mode").arg("pipe")
|
.arg("--mode")
|
||||||
|
.arg("pipe")
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
@@ -98,9 +131,13 @@ fn pipe_protocol_init_and_step() {
|
|||||||
stdin.flush().expect("flush");
|
stdin.flush().expect("flush");
|
||||||
let n = stdout.read(&mut buf).expect("read");
|
let n = stdout.read(&mut buf).expect("read");
|
||||||
let output2 = String::from_utf8_lossy(&buf[..n]).to_string();
|
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["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");
|
writeln!(stdin, "{{\"cmd\":\"quit\"}}").expect("write quit");
|
||||||
stdin.flush().expect("flush");
|
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::AiAction;
|
||||||
|
use verbatim::ai::GameSession;
|
||||||
|
|
||||||
fn setup_empty() -> GameSession {
|
fn setup_empty() -> GameSession {
|
||||||
let mut s = GameSession::new_seeded(42);
|
let mut s = GameSession::new_seeded(42);
|
||||||
@@ -11,28 +11,76 @@ fn setup_empty() -> GameSession {
|
|||||||
#[test]
|
#[test]
|
||||||
fn acid_dissolves_wood() {
|
fn acid_dissolves_wood() {
|
||||||
let mut s = setup_empty();
|
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::FillRect {
|
||||||
s.perform_action(&AiAction::SetCell { x: 104, y: 118, material: "wood".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "acid".into() });
|
y: 120,
|
||||||
s.perform_action(&AiAction::SetCell { x: 104, y: 119, material: "acid".into() });
|
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);
|
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]
|
#[test]
|
||||||
fn acid_does_not_dissolve_stone() {
|
fn acid_does_not_dissolve_stone() {
|
||||||
let mut s = setup_empty();
|
let mut s = setup_empty();
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "stone".into() });
|
s.perform_action(&AiAction::SetCell {
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 111, material: "acid".into() });
|
x: 105,
|
||||||
|
y: 110,
|
||||||
|
material: "stone".into(),
|
||||||
|
});
|
||||||
|
s.perform_action(&AiAction::SetCell {
|
||||||
|
x: 105,
|
||||||
|
y: 111,
|
||||||
|
material: "acid".into(),
|
||||||
|
});
|
||||||
s.step(20);
|
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]
|
#[test]
|
||||||
fn acid_flows_down() {
|
fn acid_flows_down() {
|
||||||
let mut s = setup_empty();
|
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::FillRect {
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "acid".into() });
|
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);
|
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::AiAction;
|
||||||
|
use verbatim::ai::GameSession;
|
||||||
|
|
||||||
fn setup() -> GameSession {
|
fn setup() -> GameSession {
|
||||||
let mut s = GameSession::new_seeded(42);
|
let mut s = GameSession::new_seeded(2);
|
||||||
s.init_empty();
|
s.init_empty();
|
||||||
s.clear_area(90, 90, 50, 50);
|
s.clear_area(90, 90, 50, 50);
|
||||||
s
|
s
|
||||||
@@ -11,9 +11,23 @@ fn setup() -> GameSession {
|
|||||||
#[test]
|
#[test]
|
||||||
fn lava_flows_down_on_stone() {
|
fn lava_flows_down_on_stone() {
|
||||||
let mut s = setup();
|
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 {
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 115, material: "lava".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 116, material: "lava".into() });
|
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);
|
s.step(15);
|
||||||
let lava_at_floor = s.count_material_in_region(103, 122, 5, 4, "lava");
|
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");
|
assert!(lava_at_floor > 0, "lava should flow down to stone floor");
|
||||||
@@ -22,25 +36,69 @@ fn lava_flows_down_on_stone() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn lava_cools_to_stone_eventually() {
|
fn lava_cools_to_stone_eventually() {
|
||||||
let mut s = setup();
|
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 {
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 120, material: "lava".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 121, material: "lava".into() });
|
y: 125,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 122, material: "lava".into() });
|
w: 10,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 123, material: "lava".into() });
|
h: 1,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 124, material: "lava".into() });
|
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);
|
s.step(200);
|
||||||
let stone_count = s.count_material_in_region(103, 120, 5, 6, "stone");
|
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]
|
#[test]
|
||||||
fn fire_spreads_through_wood_line() {
|
fn fire_spreads_through_wood_line() {
|
||||||
let mut s = setup();
|
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 {
|
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);
|
s.step(40);
|
||||||
let wood_left = s.count_material_in_region(100, 118, 10, 3, "wood");
|
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");
|
assert_eq!(wood_left, 0, "fire should spread through entire wood line");
|
||||||
@@ -49,56 +107,173 @@ fn fire_spreads_through_wood_line() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn acid_does_not_dissolve_empty() {
|
fn acid_does_not_dissolve_empty() {
|
||||||
let mut s = setup();
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: 104, y: 114, w: 3, h: 1, material: "stone".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::FillRect { x: 104, y: 113, w: 1, h: 1, material: "stone".into() });
|
y: 115,
|
||||||
s.perform_action(&AiAction::FillRect { x: 106, y: 113, w: 1, h: 1, material: "stone".into() });
|
w: 10,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "acid".into() });
|
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);
|
s.step(5);
|
||||||
let cell = s.get_cell(105, 113);
|
let cell = s.get_cell(105, 113);
|
||||||
assert!(cell.material == "acid" || cell.material == "empty",
|
assert!(
|
||||||
"acid may flow out but should not dissolve empty, got {}", cell.material);
|
cell.material == "acid" || cell.material == "empty",
|
||||||
|
"acid may flow out but should not dissolve empty, got {}",
|
||||||
|
cell.material
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn acid_dissolves_grass() {
|
fn acid_dissolves_grass() {
|
||||||
let mut s = setup();
|
let mut s = setup();
|
||||||
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() });
|
s.perform_action(&AiAction::FillRect {
|
||||||
s.perform_action(&AiAction::SetCell { x: 104, y: 119, material: "grass".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "acid".into() });
|
y: 120,
|
||||||
s.perform_action(&AiAction::SetCell { x: 104, y: 118, material: "acid".into() });
|
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);
|
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]
|
#[test]
|
||||||
fn acid_dissolves_dirt() {
|
fn acid_dissolves_dirt() {
|
||||||
let mut s = setup();
|
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 {
|
||||||
s.perform_action(&AiAction::SetCell { x: 104, y: 114, material: "dirt".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "acid".into() });
|
y: 115,
|
||||||
s.perform_action(&AiAction::SetCell { x: 104, y: 113, material: "acid".into() });
|
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);
|
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]
|
#[test]
|
||||||
fn water_extinguishes_fire_indirectly() {
|
fn water_extinguishes_fire_indirectly() {
|
||||||
let mut s = setup();
|
let mut s = setup();
|
||||||
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() });
|
s.perform_action(&AiAction::FillRect {
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "fire".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 118, material: "water".into() });
|
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);
|
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]
|
#[test]
|
||||||
fn lava_and_water_produce_both_steam_and_stone() {
|
fn lava_and_water_produce_both_steam_and_stone() {
|
||||||
let mut s = setup();
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: 100, y: 95, w: 20, h: 1, material: "stone".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::FillRect { x: 101, y: 115, w: 4, h: 3, material: "lava".into() });
|
y: 125,
|
||||||
s.perform_action(&AiAction::FillRect { x: 106, y: 115, w: 4, h: 3, material: "water".into() });
|
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);
|
s.step(30);
|
||||||
let steam = s.count_material_in_region(100, 100, 20, 20, "steam");
|
let steam = s.count_material_in_region(100, 100, 20, 20, "steam");
|
||||||
assert!(steam > 0, "lava + water should produce steam");
|
assert!(steam > 0, "lava + water should produce steam");
|
||||||
@@ -107,41 +282,108 @@ fn lava_and_water_produce_both_steam_and_stone() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn sand_falls_through_water() {
|
fn sand_falls_through_water() {
|
||||||
let mut s = setup();
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: 104, y: 118, w: 3, h: 7, material: "water".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "sand".into() });
|
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);
|
s.step(30);
|
||||||
let sand_at_bottom = s.get_cell(105, 124).material;
|
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]
|
#[test]
|
||||||
fn fire_does_not_ignite_stone() {
|
fn fire_does_not_ignite_stone() {
|
||||||
let mut s = setup();
|
let mut s = setup();
|
||||||
s.perform_action(&AiAction::SetCell { x: 104, y: 110, material: "stone".into() });
|
s.perform_action(&AiAction::SetCell {
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "fire".into() });
|
x: 104,
|
||||||
|
y: 110,
|
||||||
|
material: "stone".into(),
|
||||||
|
});
|
||||||
|
s.perform_action(&AiAction::SetCell {
|
||||||
|
x: 105,
|
||||||
|
y: 110,
|
||||||
|
material: "fire".into(),
|
||||||
|
});
|
||||||
s.step(30);
|
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]
|
#[test]
|
||||||
fn fire_does_not_ignite_water() {
|
fn fire_does_not_ignite_water() {
|
||||||
let mut s = setup();
|
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 {
|
||||||
s.perform_action(&AiAction::SetCell { x: 104, y: 114, material: "water".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "fire".into() });
|
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);
|
s.step(5);
|
||||||
let cell = s.get_cell(104, 114);
|
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");
|
assert_ne!(cell.material, "wood", "water should never become wood");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn water_does_not_flow_through_dirt_wall() {
|
fn water_does_not_flow_through_dirt_wall() {
|
||||||
let mut s = setup();
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: 109, y: 120, w: 1, h: 5, material: "dirt".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 9, h: 5, material: "water".into() });
|
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);
|
s.step(30);
|
||||||
let right_water = s.count_material_in_region(110, 118, 5, 8, "water");
|
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");
|
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::AiAction;
|
||||||
|
use verbatim::ai::GameSession;
|
||||||
|
|
||||||
fn setup_empty() -> GameSession {
|
fn setup_empty() -> GameSession {
|
||||||
let mut s = GameSession::new_seeded(42);
|
let mut s = GameSession::new_seeded(42);
|
||||||
@@ -11,48 +11,140 @@ fn setup_empty() -> GameSession {
|
|||||||
#[test]
|
#[test]
|
||||||
fn lava_flows_down() {
|
fn lava_flows_down() {
|
||||||
let mut s = setup_empty();
|
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::FillRect {
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "lava".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 111, material: "lava".into() });
|
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);
|
s.step(10);
|
||||||
let lava_count = s.count_material_in_region(103, 110, 5, 6, "lava");
|
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");
|
let stone_count = s.count_material_in_region(103, 110, 5, 6, "stone");
|
||||||
assert!(lava_count > 0 || stone_count >= 4,
|
assert!(
|
||||||
"lava should have flowed down or cooled to stone, lava={} stone={}", lava_count, stone_count);
|
lava_count > 0 || stone_count >= 4,
|
||||||
|
"lava should have flowed down or cooled to stone, lava={} stone={}",
|
||||||
|
lava_count,
|
||||||
|
stone_count
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lava_plus_water_makes_steam() {
|
fn lava_plus_water_makes_steam() {
|
||||||
let mut s = setup_empty();
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: 100, y: 99, w: 20, h: 1, material: "stone".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::FillRect { x: 102, y: 110, w: 4, h: 3, material: "lava".into() });
|
y: 120,
|
||||||
s.perform_action(&AiAction::FillRect { x: 108, y: 110, w: 4, h: 3, material: "water".into() });
|
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);
|
s.step(40);
|
||||||
let steam_count = s.count_material_in_region(100, 100, 20, 15, "steam");
|
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]
|
#[test]
|
||||||
fn lava_ignites_wood() {
|
fn lava_ignites_wood() {
|
||||||
let mut s = setup_empty();
|
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 {
|
||||||
s.perform_action(&AiAction::SetCell { x: 104, y: 114, material: "wood".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "wood".into() });
|
y: 115,
|
||||||
s.perform_action(&AiAction::SetCell { x: 106, y: 114, material: "wood".into() });
|
w: 10,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "lava".into() });
|
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);
|
s.step(15);
|
||||||
let wood_remaining = s.count_material_in_region(103, 113, 5, 3, "wood");
|
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]
|
#[test]
|
||||||
fn lava_ignites_grass() {
|
fn lava_ignites_grass() {
|
||||||
let mut s = setup_empty();
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: 100, y: 114, w: 5, h: 1, material: "grass".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "lava".into() });
|
y: 115,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "lava".into() });
|
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);
|
s.step(15);
|
||||||
let grass_remaining = s.count_material_in_region(99, 113, 7, 3, "grass");
|
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");
|
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::AiAction;
|
||||||
|
use verbatim::ai::GameSession;
|
||||||
|
|
||||||
fn setup_empty() -> GameSession {
|
fn setup_empty() -> GameSession {
|
||||||
let mut s = GameSession::new_seeded(42);
|
let mut s = GameSession::new_seeded(42);
|
||||||
@@ -11,19 +11,53 @@ fn setup_empty() -> GameSession {
|
|||||||
#[test]
|
#[test]
|
||||||
fn sand_falls_down() {
|
fn sand_falls_down() {
|
||||||
let mut s = setup_empty();
|
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 {
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
|
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);
|
s.step(10);
|
||||||
assert_eq!(s.get_cell(105, 105).material, "empty", "sand should have fallen from y=105");
|
assert_eq!(
|
||||||
assert_eq!(s.get_cell(105, 114).material, "sand", "sand should be resting on stone at y=114");
|
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]
|
#[test]
|
||||||
fn sand_displaces_water() {
|
fn sand_displaces_water() {
|
||||||
let mut s = setup_empty();
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: 100, y: 110, w: 10, h: 5, material: "water".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
|
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);
|
s.step(20);
|
||||||
let sand_at_bottom = s.get_cell(105, 114).material == "sand";
|
let sand_at_bottom = s.get_cell(105, 114).material == "sand";
|
||||||
assert!(sand_at_bottom, "sand should sink to bottom through water");
|
assert!(sand_at_bottom, "sand should sink to bottom through water");
|
||||||
@@ -32,20 +66,56 @@ fn sand_displaces_water() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn sand_piles_on_stone() {
|
fn sand_piles_on_stone() {
|
||||||
let mut s = setup_empty();
|
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 {
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
|
x: 100,
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 104, material: "sand".into() });
|
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);
|
s.step(15);
|
||||||
let count = s.count_material_in_region(104, 112, 3, 4, "sand");
|
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]
|
#[test]
|
||||||
fn sand_does_not_fall_through_stone() {
|
fn sand_does_not_fall_through_stone() {
|
||||||
let mut s = setup_empty();
|
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::FillRect {
|
||||||
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
|
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);
|
s.step(10);
|
||||||
assert_eq!(s.get_cell(105, 109).material, "sand", "sand should rest on top of stone");
|
assert_eq!(
|
||||||
assert_eq!(s.get_cell(105, 110).material, "stone", "stone should remain");
|
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::AiAction;
|
||||||
|
use verbatim::ai::GameSession;
|
||||||
|
|
||||||
fn setup_empty() -> GameSession {
|
fn setup_empty() -> GameSession {
|
||||||
let mut s = GameSession::new_seeded(42);
|
let mut s = GameSession::new_seeded(42);
|
||||||
@@ -11,32 +11,89 @@ fn setup_empty() -> GameSession {
|
|||||||
#[test]
|
#[test]
|
||||||
fn water_flows_down() {
|
fn water_flows_down() {
|
||||||
let mut s = setup_empty();
|
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::FillRect {
|
||||||
s.perform_action(&AiAction::SetCell { x: 110, y: 105, material: "water".into() });
|
x: 95,
|
||||||
s.perform_action(&AiAction::SetCell { x: 110, y: 106, material: "water".into() });
|
y: 115,
|
||||||
s.perform_action(&AiAction::SetCell { x: 110, y: 107, material: "water".into() });
|
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);
|
s.step(20);
|
||||||
let water_near_bottom = s.count_material_in_region(105, 112, 10, 4, "water");
|
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]
|
#[test]
|
||||||
fn water_spreads_sideways() {
|
fn water_spreads_sideways() {
|
||||||
let mut s = setup_empty();
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: 110, y: 111, w: 1, h: 4, material: "water".into() });
|
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);
|
s.step(50);
|
||||||
let left_count = s.count_material_in_region(100, 110, 10, 5, "water");
|
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");
|
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]
|
#[test]
|
||||||
fn water_does_not_pass_through_stone_wall() {
|
fn water_does_not_pass_through_stone_wall() {
|
||||||
let mut s = setup_empty();
|
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 {
|
||||||
s.perform_action(&AiAction::FillRect { x: 115, y: 110, w: 1, h: 5, material: "stone".into() });
|
x: 95,
|
||||||
s.perform_action(&AiAction::FillRect { x: 100, y: 110, w: 15, h: 5, material: "water".into() });
|
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);
|
s.step(30);
|
||||||
let right_water = s.count_material_in_region(116, 108, 10, 10, "water");
|
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");
|
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 s = setup();
|
||||||
let mut ui = verbatim::ui::UiLayer::new();
|
let mut ui = verbatim::ui::UiLayer::new();
|
||||||
let player = s.game.entities.all()[0].clone();
|
let player = s.game.entities.all()[0].clone();
|
||||||
|
ui.resize(80, 25);
|
||||||
ui.draw_hud(
|
ui.draw_hud(
|
||||||
80,
|
80,
|
||||||
25,
|
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