wip: current project state

This commit is contained in:
Emil
2026-06-21 22:27:20 +03:00
parent 88892db493
commit a5436897ed
48 changed files with 4583 additions and 760 deletions
+1
View File
@@ -3,3 +3,4 @@ Cargo.lock
graphify-out/
headless_dump.txt
headless_dump.png
/cache
+26 -5
View File
@@ -11,7 +11,9 @@ cargo run -- --mode pipe # JSON stdin/stdout for AI agents
cargo run -- --mode test # run all JSON scenarios
cargo run -- --mode headless --headless-ticks 60 # dump to headless_dump.txt
cargo run -- --mode capture --headless-ticks 60 # render graphics-like PNG to capture.png
cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-renderer graphics # FPS benchmark
cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-renderer graphics --benchmark-biome surface # FPS benchmark (surface)
cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-renderer graphics --benchmark-biome caves # FPS benchmark (caves)
cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-renderer graphics --benchmark-biome dungeon # FPS benchmark (dungeon)
cargo run --release -- --mode tape --headless-ticks 300 --tape-interval 10 --tape-output tape.txt --tape-json tape.json # multi-spectrum recording
```
@@ -101,11 +103,16 @@ SPV files are committed. `include_bytes!` embeds them at compile time.
## Architecture
**Source of truth**: `Grid` (250x250) of `Cell` structs. Each `Cell` stores `material`, `temp`, `fg`/`bg` color, `variant` inline. No double buffer. Grid is divided into 64x64 `Chunk`s with active flags and per-chunk persistence.
**Source of truth**: `ChunkedGrid` of `Cell` structs. Replaces the old fixed-size `Grid` with dual storage:
- **Bounded mode**: `Vec<Chunk>` for deterministic 250x250 test/AI grids.
- **Infinite mode**: `HashMap<(i64, i64), Chunk>` for continuous 12500x12500 cell (100000x100000 px) Noita-scale worlds.
Main game (`--mode terminal`, `--mode ascii`, `--mode graphics`) uses the infinite mode. Each `Cell` stores `material`, `temp`, `fg`/`bg` color, `variant` inline. No double buffer. Chunks are 64x64 cells with `active`, `modified`, `was_modified`, and `dirty` flags.
**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.
@@ -130,7 +137,18 @@ SPV files are committed. `include_bytes!` embeds them at compile time.
**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.
**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 cells via 12-byte binary format. Cell serialization is handled by `Cell::to_bytes()` / `Cell::from_bytes()`. `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. `heat_transfer` only processes cells within dirty rects and reuses its temperature buffer across frames. `update_active_chunks` activates chunks with dirty rects in addition to chunks near entities. This reduces CA step time from ~500μs to ~25μs on a 250x250 grid.
**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 3-chunk radius around the player, saves modified chunks beyond that radius, and unloads distant chunks. Streaming is active for infinite grids and for bounded grids larger than 2048×2048; test/AI 250×250 grids skip streaming.
**Vertical descent**: `MaterialId::Stairs` is a solid feature material. Player stands on stairs and presses `>` to descend. `Game::descend()` increments depth, resets the world, and respawns the player at the top. HUD shows current depth.
@@ -146,6 +164,9 @@ SPV files are committed. `include_bytes!` embeds them at compile time.
- **Item pickup** — `Game::update_item_pickup()` scans items within 1.5 cells of the player and adds them to `player.inventory`.
- **Stat-based health** — Entity max health derived from `base + toughness * 5 + level * 10`. `recalc_max_health()` called on `add_xp` level-up.
- **Status effects** — `update_status_effects()` applies damage for poison/bleeding/fire and cancels movement for frozen; effects expire when their timer reaches zero.
- **Random seeding** — `Game::new()` uses a fixed seed for tests/AI sessions; `Game::new_random()` seeds from system time and is used by terminal/ascii/graphics modes. Cached worlds are keyed by seed.
- **Dirty rects** — `ChunkedGrid::mark_dirty(x, y)` expands the chunk's dirty rect to include (x,y) ±1 and propagates to neighbor chunks at boundaries. `cells_swap` and `set_cell_index` call `mark_dirty` automatically. `set` and `set_material` also call `mark_dirty`. The CA step clears each chunk's dirty rect at the start of processing and rebuilds it from cell modifications during processing.
- **Activation radius** — Entity chunk activation radius is 1 (3x3 = 9 chunks). Chunks with dirty rects are also activated. This covers the viewport while minimizing active cell count.
## Module Layout
@@ -155,7 +176,7 @@ src/
lib.rs # pub mod declarations
game.rs # Game struct, world gen, fixed_update, collision, combat, slime AI
input.rs # Terminal input (crossterm, InputHandler) — terminal mode only
world/ # Cell, MaterialId, MaterialRegistry, Grid, Chunk, CellularAutomaton
world/ # Cell, MaterialId, MaterialRegistry, ChunkedGrid, Grid (legacy), Chunk, CellularAutomaton, WorldGenerator, WorldCache
physics/ # VerletSolver, SubBody (with color field), Constraint, resolve_grid_collision
entity/ # Entity (rigid/ragdoll), EntityManager, Player, BodyTemplate, Item, ItemManager
render/ # terminal.rs, vulkan.rs (ASCII), graphics.rs (cells), lighting.rs, window_input.rs
+23 -13
View File
@@ -18,7 +18,7 @@
| AI pipe protocol | Working | JSON stdin/stdout, 16 commands, full state export |
| Test framework | Working | 109 Rust tests + 14 JSON scenarios, all passing |
| Replay system | Working | Seeded determinism, record/playback, play_until_tick |
| World generation | Basic | Sinusoidal terrain, water/lava/acid pools, wood structure, sand dune, stone wall |
| World generation | Working | Procedural chunk-based biomes: surface (noise), caves (CA), dungeons (BSP); vertical biome progression by chunk Y; 12500×12500 continuous world; chunk streaming + cache |
| Cross-platform | Working | Windows/Linux/macOS via winit + ash_window, no platform-specific code |
| Adaptive viewport | Working | Window resize → more/fewer cells visible, cells stay 16x16 pixels |
| Per-cell color (reality layer) | Working | Each cell stores fg/bg color inline, no registry lookup in render path |
@@ -26,7 +26,11 @@
### Architecture
```
Source of truth: text grid (250x250, Cell = material + temp + fg + bg + variant)
Source of truth: `ChunkedGrid` of `Cell` structs
- Bounded mode: `Vec<Chunk>` for 250x250 test/AI grids
- Infinite mode: `HashMap<(i64, i64), Chunk>` for continuous 12500x12500 cell worlds
- 64x64 chunks, dirty rects, active flags, per-chunk persistence
- Chunk streaming: load/generate around player, save/unload distant chunks
Three entity types:
1. Cellular — materials in grid, per-cell CA rules
@@ -45,8 +49,8 @@ Three render modes:
### Numbers
- ~5964 lines Rust
- 109 integration tests, 14 JSON scenarios
- ~6700 lines Rust
- 171 integration tests, 14 JSON scenarios
- 40+ git commits
- 0 compiler warnings (excluding winit deprecation notices)
- Cross-platform: Windows/Linux/macOS
@@ -154,11 +158,13 @@ instanced quads with UI texture coordinates. Transparent background, drawn on to
**Goal: explorable world with depth and variety**
- [ ] Chunk system: world divided into chunks (64x64), only active chunks simulated
- [ ] Chunk persistence: save/load chunks to disk
- [ ] Vertical descent: stairs/holes between depth levels
- [x] Chunk system: world divided into chunks (64x64), only active chunks simulated
- [x] Chunk persistence: save/load chunks to disk
- [x] Vertical descent: stairs/holes between depth levels
- [x] Chunk streaming: load/generate around player, save/unload distant chunks
- [x] Vertical biome progression: surface → caves → dungeon by chunk Y
- [ ] Biomes: grassland, cave, lava cavern, ice, fungus forest — each with material palette
- [ ] Procedural dungeon generation: rooms, corridors, traps
- [x] Procedural dungeon generation: rooms, corridors, traps
- [ ] Camera zoom: +/- keys to change viewport scale (more or fewer cells visible)
- [ ] Minimap: ASCII overview of explored area
- [ ] Day/night cycle: ambient light affects rendering (dimmer at night)
@@ -168,6 +174,7 @@ instanced quads with UI texture coordinates. Transparent background, drawn on to
- Entity crossing chunk boundary continues correctly
- Dungeon generation produces connected rooms
- Biome materials match expected palette
- Streaming generates chunks as player moves
### Phase 3: RPG Layer
@@ -230,14 +237,16 @@ Two distinct render modes, both GPU-accelerated via Vulkan:
- [x] Per-cell color: fg/bg stored in Cell, no registry lookup in render path
- [x] Square cells: 16x16 pixels, uniform grid
- [x] GpuRenderer trait: generic run_gpu_mode<R> for both renderers
- [x] Dynamic grid size: GPU buffers sized for 12500x12500, renderers use viewport-relative grid buffer
- [ ] Dirty cell tracking: only update changed cells in instance buffer
- [ ] Camera zoom: +/- keys to change viewport scale
**Graphics layers over both modes (Phase 4b):**
- [ ] Lighting pass: compute shader calculates light grid from sources (lava, fire, torches)
- [x] Lighting pass: CPU light grid from sources (lava, fire, torches), shader line-of-sight
- Materials emit light with color/intensity
- Walls cast shadows (ray-march in compute)
- Walls cast shadows (ray-march in shader)
- Light grid modulates cell brightness in render
- [ ] GPU compute lighting: move ray-march to compute shader for large worlds
- [ ] Particle system: GPU particles positioned relative to grid cells
- Fire sparks, water splashes, smoke trails, blood
- Particle lifetime + physics (gravity, wind)
@@ -443,10 +452,11 @@ src/
world/
cell.rs # Cell struct, MaterialId enum
material.rs # Material properties registry
grid.rs # Grid (250x250), cell access
grid.rs # Grid (legacy, 250x250), cell access
chunked_grid.rs # ChunkedGrid: bounded + infinite chunk storage
cellular.rs # Cellular automaton rules
chunk.rs # [Phase 2] chunk system
worldgen.rs # [Phase 2] procedural generation
chunk.rs # Chunk system
worldgen.rs # Procedural generation, per-chunk generation
layers.rs # [Phase 6] multi-layer world (temp, pressure, gas, light)
physics/
verlet.rs # Verlet integrator, constraints
+164
View File
@@ -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
+3 -2
View File
@@ -48,8 +48,9 @@ bool line_of_sight(ivec2 a, ivec2 b) {
int err = d.x - d.y;
while (true) {
if (p == b) return true;
if (p.x < 0 || p.x >= pc.world_size.x || p.y < 0 || p.y >= pc.world_size.y) return false;
uint m = grid.cells[p.y * pc.world_size.x + p.x];
ivec2 vp = p - pc.cam_pos;
if (vp.x < 0 || vp.x >= pc.world_size.x || vp.y < 0 || vp.y >= pc.world_size.y) return false;
uint m = grid.cells[vp.y * pc.world_size.x + vp.x];
if (is_solid(m)) return false;
int e2 = 2 * err;
if (e2 > -d.y) { err -= d.y; p.x += s.x; }
+3 -2
View File
@@ -44,8 +44,9 @@ bool line_of_sight(ivec2 a, ivec2 b) {
int err = d.x - d.y;
while (true) {
if (p == b) return true;
if (p.x < 0 || p.x >= pc.world_size.x || p.y < 0 || p.y >= pc.world_size.y) return false;
uint m = grid.cells[p.y * pc.world_size.x + p.x];
ivec2 vp = p - pc.cam_pos;
if (vp.x < 0 || vp.x >= pc.world_size.x || vp.y < 0 || vp.y >= pc.world_size.y) return false;
uint m = grid.cells[vp.y * pc.world_size.x + vp.x];
if (is_solid(m)) return false;
int e2 = 2 * err;
if (e2 > -d.y) { err -= d.y; p.x += s.x; }
+17
View File
@@ -0,0 +1,17 @@
{
"mode": "graphics",
"ticks": 300,
"total_time_ms": 2554.2,
"avg_fps": 117.5,
"avg_frame_time_ms": 8.49,
"p99_frame_time_ms": 11.37,
"min_frame_time_ms": 6.87,
"subsystems": {
"ca_step_avg_us": 842,
"ca_step_p99_us": 655,
"ca_step_min_us": 301,
"render_avg_us": 4351,
"render_p99_us": 6445,
"render_min_us": 3759
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 37 KiB

+5 -5
View File
@@ -11,10 +11,10 @@ pub use action::AiAction;
pub use protocol::run_pipe_protocol;
pub use replay::{ReplayPlayer, ReplayRecorder, ReplayRecording};
pub use scenario::{
format_results, load_scenario, run_all_scenarios, run_scenario, Assertion, AssertionResult,
Scenario,
Assertion, AssertionResult, Scenario, format_results, load_scenario, run_all_scenarios,
run_scenario,
};
pub use session::GameSession;
pub use spectrum::{format_all_spectrums, render_all_spectrums, render_spectrum, Spectrum};
pub use state::{entity_kind_name, render_view, CellInfo, EntityInfo, GameState, SubBodyInfo};
pub use tape::{run_tape_mode, TapeFrame, TapeRecorder};
pub use spectrum::{Spectrum, format_all_spectrums, render_all_spectrums, render_spectrum};
pub use state::{CellInfo, EntityInfo, GameState, SubBodyInfo, entity_kind_name, render_view};
pub use tape::{TapeFrame, TapeRecorder, run_tape_mode};
+5 -1
View File
@@ -289,7 +289,11 @@ fn handle_command(cmd: Command, session: &mut Option<GameSession>) -> Response {
"entities" => crate::ai::spectrum::Spectrum::Entities,
"density" => crate::ai::spectrum::Spectrum::Density,
"velocity" => crate::ai::spectrum::Spectrum::Velocity,
_ => return Response::err("Unknown spectrum. Use: materials, temperature, light, entities, density, velocity"),
_ => {
return Response::err(
"Unknown spectrum. Use: materials, temperature, light, entities, density, velocity",
);
}
};
let view = s.get_spectrum(&spec, vw, vh);
Response {
+2 -2
View File
@@ -3,7 +3,7 @@ use crate::ai::replay::ReplayRecorder;
use crate::ai::state::{build_game_state, render_view, CellInfo, EntityInfo, GameState};
use crate::game::Game;
use crate::world::cell::MaterialId;
use crate::world::grid::Grid;
use crate::world::chunked_grid::ChunkedGrid;
pub struct GameSession {
pub game: Game,
@@ -223,7 +223,7 @@ impl GameSession {
}
}
pub fn grid(&self) -> &Grid {
pub fn grid(&self) -> &ChunkedGrid {
&self.game.grid
}
+10 -10
View File
@@ -1,5 +1,5 @@
use crate::entity::{EntityKind, EntityManager};
use crate::world::grid::Grid;
use crate::world::chunked_grid::ChunkedGrid;
pub enum Spectrum {
Materials,
@@ -36,7 +36,7 @@ impl Spectrum {
pub fn render_spectrum(
spectrum: &Spectrum,
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
light: Option<&crate::render::lighting::LightGrid>,
cam_x: i32,
@@ -55,7 +55,7 @@ pub fn render_spectrum(
}
fn render_materials(
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
cam_x: i32,
cam_y: i32,
@@ -100,7 +100,7 @@ fn render_materials(
buf
}
fn render_temperature(grid: &Grid, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String {
fn render_temperature(grid: &ChunkedGrid, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String {
let mut buf = String::with_capacity(vw * vh + vh);
for dy in 0..vh {
for dx in 0..vw {
@@ -141,7 +141,7 @@ fn render_temperature(grid: &Grid, cam_x: i32, cam_y: i32, vw: usize, vh: usize)
}
fn render_light(
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
light: Option<&crate::render::lighting::LightGrid>,
cam_x: i32,
@@ -188,7 +188,7 @@ fn render_light(
}
fn render_entities(
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
cam_x: i32,
cam_y: i32,
@@ -236,7 +236,7 @@ fn render_entities(
buf
}
fn render_density(grid: &Grid, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String {
fn render_density(grid: &ChunkedGrid, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String {
let reg = crate::world::material::MaterialRegistry::instance();
let mut buf = String::with_capacity(vw * vh + vh);
for dy in 0..vh {
@@ -275,7 +275,7 @@ fn render_density(grid: &Grid, cam_x: i32, cam_y: i32, vw: usize, vh: usize) ->
}
fn render_velocity(
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
cam_x: i32,
cam_y: i32,
@@ -327,7 +327,7 @@ fn render_velocity(
}
pub fn render_all_spectrums(
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
light: Option<&crate::render::lighting::LightGrid>,
cam_x: i32,
@@ -347,7 +347,7 @@ pub fn render_all_spectrums(
}
pub fn format_all_spectrums(
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
light: Option<&crate::render::lighting::LightGrid>,
cam_x: i32,
+3 -3
View File
@@ -1,7 +1,7 @@
use crate::entity::{EntityKind, EntityManager};
use crate::game::Game;
use crate::world::cell::MaterialId;
use crate::world::grid::Grid;
use crate::world::chunked_grid::ChunkedGrid;
use crate::world::material::MaterialRegistry;
use serde::{Deserialize, Serialize};
@@ -60,7 +60,7 @@ pub struct CellInfo {
}
impl CellInfo {
pub fn from_grid(grid: &Grid, x: i32, y: i32) -> Self {
pub fn from_grid(grid: &ChunkedGrid, x: i32, y: i32) -> Self {
if !grid.in_bounds(x, y) {
return Self {
x,
@@ -157,7 +157,7 @@ pub fn build_game_state(game: &Game, view_w: usize, view_h: usize) -> GameState
}
pub fn render_view(
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
cam_x: i32,
cam_y: i32,
+18
View File
@@ -211,6 +211,24 @@ impl Item {
}
}
impl ItemType {
pub fn from_name(name: &str) -> Option<Self> {
match name {
"Dagger" => Some(Self::Dagger),
"Sword" => Some(Self::Sword),
"Bow" => Some(Self::Bow),
"Leather Armor" => Some(Self::LeatherArmor),
"Plate Armor" => Some(Self::PlateArmor),
"Shield" => Some(Self::Shield),
"Health Potion" => Some(Self::HealthPotion),
"Mana Potion" => Some(Self::ManaPotion),
"Food" => Some(Self::Food),
"Scroll" => Some(Self::Scroll),
_ => None,
}
}
}
pub struct ItemManager {
items: Vec<Item>,
next_id: u32,
+1 -1
View File
@@ -3,7 +3,7 @@ pub mod entity;
pub mod item;
pub mod player;
pub use body_template::{template_for_kind, BodyPart, BodyTemplate};
pub use body_template::{BodyPart, BodyTemplate, template_for_kind};
pub use entity::{EntityKind, EntityManager};
pub use item::{Item, ItemManager, ItemType};
pub use player::Player;
+7
View File
@@ -31,6 +31,13 @@ impl Player {
}
}
pub fn set_position(&self, manager: &mut EntityManager, cx: f32, cy: f32) {
if let Some(e) = manager.get_mut(self.entity_id) {
e.cx = cx;
e.cy = cy;
}
}
pub fn move_left(&mut self, manager: &mut EntityManager) {
if let Some(e) = manager.get_mut(self.entity_id) {
e.set_horizontal_vel(-self.move_speed);
+270 -226
View File
@@ -1,7 +1,7 @@
use std::time::{Duration, Instant};
use crate::entity::player::Player;
use crate::entity::{EntityKind, EntityManager, ItemManager, ItemType};
use crate::entity::{EntityKind, EntityManager, ItemManager};
use crate::input::{Action, InputHandler};
use crate::physics::collision::resolve_grid_collision;
use crate::physics::projectile::{ProjectileManager, ProjectileType};
@@ -9,12 +9,15 @@ use crate::physics::verlet::VerletSolver;
use crate::render::lighting;
use crate::render::Renderer;
use crate::ui::UiLayer;
use crate::world::cache::WorldCache;
use crate::world::cell::MaterialId;
use crate::world::cellular::CellularAutomaton;
use crate::world::grid::Grid;
use crate::world::chunked_grid::ChunkedGrid;
use crate::world::grid::{WORLD_H, WORLD_W};
use crate::world::worldgen::WorldGenerator;
pub struct Game {
pub grid: Grid,
pub grid: ChunkedGrid,
pub ca: CellularAutomaton,
pub verlet: VerletSolver,
pub entities: EntityManager,
@@ -43,14 +46,20 @@ pub struct Game {
pub inventory_open: bool,
pub inventory_mouse_x: i32,
pub inventory_mouse_y: i32,
pub seed: u64,
pub cache_dir: Option<String>,
}
impl Game {
pub fn new() -> Self {
Self::new_with_size(WORLD_W, WORLD_H)
}
fn new_with_size(width: usize, height: usize) -> Self {
let mut entities = EntityManager::new();
let player = Player::new(&mut entities);
Self {
grid: Grid::new(),
grid: ChunkedGrid::with_size(width, height),
ca: CellularAutomaton::new(),
verlet: VerletSolver::new(),
entities,
@@ -79,184 +88,98 @@ impl Game {
inventory_open: false,
inventory_mouse_x: 0,
inventory_mouse_y: 0,
seed: 0x1234567890ABCDEF,
cache_dir: None,
}
}
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(),
items: ItemManager::new(),
player,
input: InputHandler::new(),
ui: UiLayer::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,
}
}
pub fn init_world(&mut self) {
let w = self.grid.width;
let h = self.grid.height;
for x in 0..w {
self.grid
.set_material(x as i32, (h - 1) as i32, MaterialId::Stone);
self.grid
.set_material(x as i32, (h - 2) as i32, MaterialId::Dirt);
}
let surface_noise = |x: i32| -> i32 {
let base = (h as i32 - 3) - ((x as f32 * 0.08).sin() * 4.0) as i32;
let detail = ((x as f32 * 0.23).sin() * 2.0) as i32;
(base + detail).max(10).min(h as i32 - 3)
};
for x in 0..w {
let surface = surface_noise(x as i32);
let biome = x / (w / 4);
for y in surface..(h as i32 - 2) {
if y == surface {
let mat = match biome {
0 => MaterialId::Grass,
1 => MaterialId::Grass,
2 => MaterialId::Dirt,
_ => MaterialId::Stone,
};
self.grid.set_material(x as i32, y, mat);
} else if y > surface + 8 {
self.grid.set_material(x as i32, y, MaterialId::Stone);
let cache_dir = self.cache_dir.clone();
let (px, py) = if let Some(ref root) = cache_dir {
let has_meta = WorldCache::meta_exists(root, self.seed);
if has_meta {
if let Err(e) = WorldCache::load_meta(
root,
self.seed,
&mut self.player,
&mut self.entities,
&mut self.items,
) {
eprintln!("World cache meta load failed: {}", e);
} else {
self.grid.set_material(x as i32, y, MaterialId::Dirt);
let (px, py) = self.player.center(&self.entities);
self.grid.ensure_loaded(px as i32, py as i32, 3);
self.center_camera_on(px, py);
return;
}
}
}
for _ in 0..8 {
let cave_x = (self.ca.random_u32() % (w as u32 - 20) + 10) as i32;
let cave_y = (self.ca.random_u32() % (h as u32 / 3) + (h as u32 / 3) * 2) as i32;
let cave_r = (self.ca.random_u32() % 4 + 3) as i32;
for dy in -cave_r..=cave_r {
for dx in -cave_r..=cave_r {
if dx * dx + dy * dy <= cave_r * cave_r {
let cx = cave_x + dx;
let cy = cave_y + dy;
if cx > 1 && cx < w as i32 - 2 && cy > 1 && cy < h as i32 - 2 {
self.grid.set(cx, cy, crate::world::cell::Cell::empty());
}
}
}
}
}
for tree_x in [60, 75, 130, 145, 220] {
let s = surface_noise(tree_x);
for y in (s - 6)..s {
if y > 5 {
self.grid.set_material(tree_x, y, MaterialId::Wood);
}
}
for dy in -2..=0 {
for dx in -2..=2 {
if dx * dx + dy * dy <= 5 {
let cx = tree_x + dx;
let cy = s - 6 + dy;
if cx > 1 && cx < w as i32 - 2 && cy > 1 {
if self.grid.get(cx, cy).is_empty() {
self.grid.set_material(cx, cy, MaterialId::Grass);
}
}
}
}
}
}
let water_x = 40;
for x in water_x - 10..=water_x + 10 {
let s = surface_noise(x);
for y in s - 6..s {
if self.grid.get(x, y).is_empty() {
self.grid.set_material(x, y, MaterialId::Water);
}
}
}
let lava_x = 200;
for x in lava_x - 8..=lava_x + 8 {
let s = surface_noise(x);
for y in s - 4..s {
if self.grid.get(x, y).is_empty() {
self.grid.set_material(x, y, MaterialId::Lava);
}
}
}
let sand_x = 160;
for dx in -10..=10 {
let s = surface_noise(sand_x + dx);
let pile_h = (10.0 - (dx as f32).abs() * 0.8) as i32;
for dy in 0..pile_h {
let y = s - 1 - dy;
if y > 5 && self.grid.get(sand_x + dx, y).is_empty() {
self.grid.set_material(sand_x + dx, y, MaterialId::Sand);
}
}
}
let acid_x = 20;
for x in acid_x - 4..=acid_x + 4 {
let s = surface_noise(x);
for y in s - 3..s {
if self.grid.get(x, y).is_empty() {
self.grid.set_material(x, y, MaterialId::Acid);
}
}
}
let wall_x = 110;
let wall_s = surface_noise(wall_x);
for y in (wall_s - 5)..wall_s {
self.grid.set_material(wall_x, y, MaterialId::Stone);
self.grid.set_material(wall_x + 1, y, MaterialId::Stone);
}
for _ in 0..6 {
let px = (self.ca.random_u32() % (w as u32 - 20) + 10) as i32;
let py = (self.ca.random_u32() % (h as u32 / 3) + (h as u32 / 3) * 2) as i32;
for dy in 0..8 {
for dx in -1..=1 {
let cx = px + dx;
let cy = py + dy;
if cx > 1 && cx < w as i32 - 2 && cy < h as i32 - 3 {
self.grid.set_material(cx, cy, MaterialId::Stone);
}
}
}
}
self.grid.fill_border(MaterialId::Stone);
let cx = (w / 2) as f32;
let surface_x = cx as i32;
let mut surface_y = h as i32 - 3;
for y in 0..h as i32 {
if self.grid.get(surface_x, y).is_solid()
&& self.grid.get(surface_x, y).material != MaterialId::Stone
let (px, py) = WorldGenerator::new(&mut self.ca).generate(
&mut self.grid,
&mut self.items,
&mut self.player,
&mut self.entities,
self.depth,
);
self.center_camera_on(px, py);
if let Err(e) = WorldCache::save_meta(root, self.seed, px, py, self.depth, &self.items)
{
surface_y = y;
break;
eprintln!("World cache meta save failed: {}", e);
}
}
let stair_y = (h as i32 - 2).max(surface_y + 2);
self.grid
.set_material(surface_x, stair_y, MaterialId::Stairs);
let cy = (surface_y as f32) - 5.0;
self.player.spawn_at(&mut self.entities, cx, cy);
let (px, py) = self.player.center(&self.entities);
self.center_camera_on(px, py);
self.items
.spawn(ItemType::Sword, px as i32 - 6, py as i32 + 1);
self.items
.spawn(ItemType::HealthPotion, px as i32 + 6, py as i32 + 1);
self.items
.spawn(ItemType::LeatherArmor, px as i32 - 3, py as i32 - 8);
self.items
.spawn(ItemType::Bow, px as i32 + 10, py as i32 + 1);
self.items
.spawn(ItemType::Shield, px as i32 - 10, py as i32 + 1);
self.items
.spawn(ItemType::ManaPotion, px as i32 + 3, py as i32 - 6);
(px, py)
} else {
let (px, py) = WorldGenerator::new(&mut self.ca).generate(
&mut self.grid,
&mut self.items,
&mut self.player,
&mut self.entities,
self.depth,
);
self.center_camera_on(px, py);
(px, py)
};
let _ = (px, py);
}
pub fn center_camera_on(&mut self, px: f32, py: f32) {
@@ -525,6 +448,7 @@ impl Game {
pub fn fixed_update(&mut self) {
self.tick += 1;
self.stream_chunks();
self.update_active_chunks();
self.ca.step(&mut self.grid);
@@ -604,7 +528,7 @@ impl Game {
return;
}
self.depth += 1;
self.grid = Grid::new();
self.grid = ChunkedGrid::with_size(self.grid.width, self.grid.height);
self.entities = EntityManager::new();
self.player = Player::new(&mut self.entities);
self.projectiles = ProjectileManager::new();
@@ -655,12 +579,80 @@ impl Game {
self.ui.add_message(&format!("Dropped {}", item.name()));
}
fn stream_chunks(&mut self) {
if !self.grid.is_infinite() && self.grid.width <= 2048 {
return;
}
let (px, py) = self.player.center(&self.entities);
let px = px as i32;
let py = py as i32;
let (pcx, pcy, _, _) = self.grid.chunk_at(px, py);
let radius = 3;
let chunk_size = self.grid.chunk_size as i32;
for dy in -radius..=radius {
for dx in -radius..=radius {
let cx = pcx + dx;
let cy = pcy + dy;
let ox = cx * chunk_size;
let oy = cy * chunk_size;
if !self.grid.in_bounds(ox, oy) {
continue;
}
self.grid.ensure_chunk(cx, cy);
if !self.grid.is_chunk_generated(cx, cy) {
WorldGenerator::new(&mut self.ca).generate_chunk(&mut self.grid, cx, cy);
}
}
}
if let Some(ref dir) = self.cache_dir {
if self.tick % 60 == 0 {
let save_radius = radius + 2;
for (cx, cy) in self.grid.all_chunk_coords() {
let dx = (cx - pcx).abs();
let dy = (cy - pcy).abs();
if dx > save_radius || dy > save_radius {
if 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);
}
}
}
}
}
if self.tick % 60 == 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) {
self.grid.deactivate_all();
for e in self.entities.all() {
let (cx, cy) = e.center();
self.grid.activate_around(cx as i32, cy as i32, 2);
self.grid.activate_around(cx as i32, cy as i32, 1);
}
for p in self.projectiles.all() {
@@ -672,14 +664,23 @@ impl Game {
}
let chunk_size = self.grid.chunk_size as i32;
for cy in 0..self.grid.chunks_y as i32 {
for cx in 0..self.grid.chunks_x as i32 {
let idx = self.grid.chunk_index(cx, cy);
if self.grid.chunks[idx].modified || self.grid.chunks[idx].was_modified {
let (px, py) = self.player.center(&self.entities);
let pcx = px as i32 / chunk_size;
let pcy = py as i32 / chunk_size;
let dirty_radius = if self.grid.is_infinite() { 1 } else { 100000 };
for (cx, cy) in self.grid.all_chunk_coords() {
if self.grid.is_chunk_modified(cx, cy) {
if (cx - pcx).abs() <= dirty_radius && (cy - pcy).abs() <= dirty_radius {
self.grid
.activate_around(cx * chunk_size, cy * chunk_size, 1);
}
}
if self.grid.get_chunk_dirty(cx, cy).is_some() {
if (cx - pcx).abs() <= dirty_radius && (cy - pcy).abs() <= dirty_radius {
self.grid.set_chunk_active(cx, cy, true);
}
}
}
}
@@ -1386,6 +1387,69 @@ 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;
if !self.grid.in_bounds(x, y) || !self.grid.in_bounds(x, y - 3) {
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 -3..=0 {
if !self.grid.get(x, y + k).is_empty() {
clear = false;
break;
}
}
if clear {
return Some((x, y - 3));
}
}
}
}
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 + 50
} else {
self.grid.height as i32 - 3
};
let mut surface_y = search_bottom;
for y in search_top..=search_bottom {
let cell = self.grid.get(spawn_x, y);
if cell.is_solid() && cell.material != MaterialId::Stone {
surface_y = y;
break;
}
}
let spawn_y = surface_y - height_offset;
if !self.grid.in_bounds(spawn_x, spawn_y) {
return None;
}
Some((spawn_x, spawn_y))
}
fn try_spawn_goblin(&mut self) {
let max_goblins = 3usize + self.depth.min(5) as usize;
let alive_goblins = self
@@ -1398,33 +1462,23 @@ impl Game {
return;
}
let (px, _py) = self.player.center(&self.entities);
let spawn_x = px as i32 + if px as i32 % 2 == 0 { 15 } else { -15 };
if !self.grid.in_bounds(spawn_x, 0) {
return;
}
let (px, py) = self.player.center(&self.entities);
let offset = if px as i32 % 2 == 0 { 15 } else { -15 };
let spawn = if self.depth <= 3 {
self.find_surface_spawn(px, py, offset, 5)
} else {
self.find_spawn_location(px as i32 + offset, py as i32, 3)
};
let mut surface_y = self.grid.height as i32 - 3;
for y in 0..self.grid.height as i32 {
let cell = self.grid.get(spawn_x, y);
if cell.is_solid() && cell.material != MaterialId::Stone {
surface_y = y;
break;
if let Some((spawn_x, spawn_y)) = spawn {
let id = self.entities.spawn(EntityKind::Goblin);
if let Some(g) = self.entities.get_mut(id) {
g.build_humanoid(spawn_x as f32, spawn_y as f32);
g.health += self.depth as f32 * 5.0;
g.max_health += self.depth as f32 * 5.0;
g.strength += self.depth;
}
}
let spawn_y = surface_y - 5;
if !self.grid.in_bounds(spawn_x, spawn_y) {
return;
}
let id = self.entities.spawn(EntityKind::Goblin);
if let Some(g) = self.entities.get_mut(id) {
g.build_humanoid(spawn_x as f32, spawn_y as f32);
g.health += self.depth as f32 * 5.0;
g.max_health += self.depth as f32 * 5.0;
g.strength += self.depth;
}
}
fn try_spawn_slime(&mut self) {
@@ -1439,31 +1493,21 @@ impl Game {
return;
}
let (px, _py) = self.player.center(&self.entities);
let spawn_x = px as i32 + if px as i32 % 2 == 0 { -18 } else { 18 };
if !self.grid.in_bounds(spawn_x, 0) {
return;
}
let (px, py) = self.player.center(&self.entities);
let offset = if px as i32 % 2 == 0 { -18 } else { 18 };
let spawn = if self.depth <= 3 {
self.find_surface_spawn(px, py, offset, 3)
} else {
self.find_spawn_location(px as i32 + offset, py as i32, 3)
};
let mut surface_y = self.grid.height as i32 - 3;
for y in 0..self.grid.height as i32 {
let cell = self.grid.get(spawn_x, y);
if cell.is_solid() && cell.material != MaterialId::Stone {
surface_y = y;
break;
if let Some((spawn_x, spawn_y)) = spawn {
let id = self.entities.spawn(EntityKind::Slime);
if let Some(s) = self.entities.get_mut(id) {
s.build_humanoid(spawn_x as f32, spawn_y as f32);
s.health += self.depth as f32 * 3.0;
s.max_health += self.depth as f32 * 3.0;
}
}
let spawn_y = surface_y - 3;
if !self.grid.in_bounds(spawn_x, spawn_y) {
return;
}
let id = self.entities.spawn(EntityKind::Slime);
if let Some(s) = self.entities.get_mut(id) {
s.build_humanoid(spawn_x as f32, spawn_y as f32);
s.health += self.depth as f32 * 3.0;
s.max_health += self.depth as f32 * 3.0;
}
}
}
+8 -6
View File
@@ -82,14 +82,16 @@ impl InputHandler {
pub fn start(&mut self) {
let (tx, rx) = mpsc::channel::<Event>();
self.receiver = Some(rx);
self.input_thread = Some(thread::spawn(move || loop {
match event::read() {
Ok(ev) => {
if tx.send(ev).is_err() {
break;
self.input_thread = Some(thread::spawn(move || {
loop {
match event::read() {
Ok(ev) => {
if tx.send(ev).is_err() {
break;
}
}
Err(_) => break,
}
Err(_) => break,
}
}));
}
+44 -13
View File
@@ -8,6 +8,7 @@ use verbatim::render::lighting;
use verbatim::render::terminal::TerminalRenderer;
use verbatim::render::window_input::WindowInput;
use verbatim::world::cell::MaterialId;
use verbatim::world::chunked_grid::ChunkedGrid;
use winit::event::{Event, WindowEvent};
use winit::event_loop::{ControlFlow, EventLoop};
use winit::window::Window;
@@ -42,6 +43,9 @@ struct Cli {
#[arg(long, default_value = "benchmark_results.json")]
benchmark_output: String,
#[arg(long, default_value = "surface")]
benchmark_biome: String,
#[arg(long, default_value_t = 5)]
tape_interval: u32,
@@ -58,7 +62,7 @@ trait GpuRenderer {
Self: Sized;
fn render(
&mut self,
grid: &verbatim::world::grid::Grid,
grid: &ChunkedGrid,
entities: &verbatim::entity::EntityManager,
items: &verbatim::entity::item::ItemManager,
ui: &verbatim::ui::UiLayer,
@@ -76,7 +80,7 @@ impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer {
}
fn render(
&mut self,
grid: &verbatim::world::grid::Grid,
grid: &ChunkedGrid,
entities: &verbatim::entity::EntityManager,
items: &verbatim::entity::item::ItemManager,
ui: &verbatim::ui::UiLayer,
@@ -102,7 +106,7 @@ impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer {
}
fn render(
&mut self,
grid: &verbatim::world::grid::Grid,
grid: &ChunkedGrid,
entities: &verbatim::entity::EntityManager,
items: &verbatim::entity::item::ItemManager,
ui: &verbatim::ui::UiLayer,
@@ -139,7 +143,7 @@ fn main() {
eprintln!("PANIC: {}", info);
}));
let mut renderer = TerminalRenderer::new();
let mut game = Game::new();
let mut game = Game::new_random();
game.run(&mut renderer);
}
"ascii" => {
@@ -216,13 +220,13 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
eprintln!("Vulkan init failed: {e}");
eprintln!("Falling back to terminal mode...");
let mut renderer = TerminalRenderer::new();
let mut game = Game::new();
let mut game = Game::new_random();
game.run(&mut renderer);
return;
}
};
let mut game = Game::new();
let mut game = Game::new_random();
game.init_world();
let mut input = WindowInput::new();
@@ -533,19 +537,25 @@ fn run_benchmark_mode(cli: &Cli) {
let ticks = cli.benchmark_ticks;
let renderer_type = cli.benchmark_renderer.as_str();
let output_path = cli.benchmark_output.as_str();
let biome = cli.benchmark_biome.as_str();
eprintln!("Benchmark: {} ticks, renderer={}", ticks, renderer_type);
eprintln!(
"Benchmark: {} ticks, renderer={}, biome={}",
ticks, renderer_type, biome
);
match renderer_type {
"ascii" => run_benchmark_inner::<verbatim::render::vulkan::VulkanRenderer>(
ticks,
output_path,
"ascii",
biome,
),
"graphics" => run_benchmark_inner::<verbatim::render::graphics::GraphicsRenderer>(
ticks,
output_path,
"graphics",
biome,
),
_ => {
eprintln!(
@@ -557,7 +567,12 @@ fn run_benchmark_mode(cli: &Cli) {
}
}
fn run_benchmark_inner<R: GpuRenderer>(ticks: u32, output_path: &str, mode_name: &str) {
fn run_benchmark_inner<R: GpuRenderer>(
ticks: u32,
output_path: &str,
mode_name: &str,
biome: &str,
) {
let event_loop = EventLoop::new().expect("Failed to create event loop");
let window = event_loop
.create_window(
@@ -576,9 +591,25 @@ fn run_benchmark_inner<R: GpuRenderer>(ticks: u32, output_path: &str, mode_name:
}
};
let mut game = Game::new();
let mut game = Game::new_random();
game.init_world();
let chunk_size = game.grid.chunk_size as i32;
let (px, _py) = game.player.center(&game.entities);
match biome {
"caves" => {
let cave_y = 2 * chunk_size + chunk_size / 2;
game.player
.set_position(&mut game.entities, px, cave_y as f32);
}
"dungeon" => {
let dungeon_y = 6 * chunk_size + chunk_size / 2;
game.player
.set_position(&mut game.entities, px, dungeon_y as f32);
}
_ => {}
}
let mut tick_count = 0u32;
let mut ca_times_us: Vec<u64> = Vec::with_capacity(ticks as usize);
let mut render_times_us: Vec<u64> = Vec::with_capacity(ticks as usize);
@@ -908,14 +939,14 @@ fn run_capture(ticks: u32) {
}
fn dump_view(
grid: &verbatim::world::grid::Grid,
grid: &ChunkedGrid,
entities: &verbatim::entity::EntityManager,
cam_x: i32,
cam_y: i32,
vw: usize,
vh: usize,
w: usize,
h: usize,
) -> String {
ai::render_view(grid, entities, cam_x, cam_y, vw, vh)
ai::render_view(grid, entities, cam_x, cam_y, w, h)
.lines()
.enumerate()
.map(|(i, line)| format!("{:2}{}", (cam_y + i as i32) % 100, line))
+3 -3
View File
@@ -1,6 +1,6 @@
use crate::world::cell::MaterialId;
use crate::world::grid::Grid;
use crate::physics::verlet::SubBody;
use crate::world::cell::MaterialId;
use crate::world::chunked_grid::ChunkedGrid;
pub struct CollisionResult {
pub on_ground: bool,
@@ -24,7 +24,7 @@ impl CollisionResult {
}
}
pub fn resolve_grid_collision(grid: &Grid, body: &mut SubBody) -> CollisionResult {
pub fn resolve_grid_collision(grid: &ChunkedGrid, body: &mut SubBody) -> CollisionResult {
let mut result = CollisionResult::none();
let r = body.radius;
+10 -5
View File
@@ -1,6 +1,6 @@
use crate::entity::entity::{Entity, EntityId};
use crate::world::cell::{Cell, MaterialId};
use crate::world::grid::Grid;
use crate::world::chunked_grid::ChunkedGrid;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProjectileType {
@@ -65,7 +65,7 @@ impl Projectile {
self.damage + self.damage_bonus
}
pub fn update(&mut self, grid: &Grid) {
pub fn update(&mut self, grid: &ChunkedGrid) {
if !self.alive {
return;
}
@@ -118,7 +118,12 @@ impl Projectile {
dx.abs() < hit_w && dy.abs() < hit_h
}
pub fn apply_impact(&self, grid: &mut Grid, entity: &mut Entity, ui: &mut crate::ui::UiLayer) {
pub fn apply_impact(
&self,
grid: &mut ChunkedGrid,
entity: &mut Entity,
ui: &mut crate::ui::UiLayer,
) {
if self.typ == ProjectileType::Fireball {
let min_x = (self.x - 1.5).floor() as i32;
let max_x = (self.x + 1.5).ceil() as i32;
@@ -226,7 +231,7 @@ impl ProjectileManager {
self.spawn(ProjectileType::Arrow, x, y, vx, vy, owner, 0.0)
}
pub fn update(&mut self, grid: &Grid) {
pub fn update(&mut self, grid: &ChunkedGrid) {
for p in &mut self.projectiles {
p.update(grid);
}
@@ -234,7 +239,7 @@ impl ProjectileManager {
pub fn resolve_hits(
&mut self,
grid: &mut Grid,
grid: &mut ChunkedGrid,
entities: &mut [Entity],
ui: &mut crate::ui::UiLayer,
) {
+5 -5
View File
@@ -3,14 +3,14 @@ use crate::entity::EntityManager;
use crate::render::lighting;
use crate::ui::UiLayer;
use crate::world::cell::MaterialId;
use crate::world::grid::Grid;
use crate::world::chunked_grid::ChunkedGrid;
use image::{ImageBuffer, RgbImage};
pub const CELL_SIZE: u32 = 8;
pub const UI_CELL_SIZE: u32 = 2;
pub fn capture_frame(
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
items: &ItemManager,
ui: &UiLayer,
@@ -145,7 +145,7 @@ fn entity_positions(
fn shadow_positions(
entity_positions: &std::collections::HashMap<(i32, i32), [u8; 3]>,
grid: &Grid,
grid: &ChunkedGrid,
cam_x: i32,
cam_y: i32,
view_w: u32,
@@ -203,7 +203,7 @@ fn draw_cell(img: &mut RgbImage, vx: u32, vy: u32, color: [u8; 3]) {
pub fn save_capture(
path: &str,
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
items: &ItemManager,
ui: &UiLayer,
@@ -221,7 +221,7 @@ pub fn save_capture(
}
pub fn capture_from_state(
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
items: &ItemManager,
ui: &UiLayer,
+15 -17
View File
@@ -5,7 +5,8 @@ use std::sync::Arc;
use crate::entity::EntityManager;
use crate::render::lighting;
use crate::world::cell::MaterialId;
use crate::world::grid::{Grid, WORLD_H, WORLD_W};
use crate::world::chunked_grid::ChunkedGrid;
use crate::world::grid::{MAX_WORLD_H, MAX_WORLD_W};
const CHAR_W: u32 = 8;
const CHAR_H: u32 = 8;
@@ -552,13 +553,14 @@ impl GraphicsRenderer {
Ok((buf, mem))
};
let grid_data = vec![0u32; WORLD_W * WORLD_H];
let grid_count = MAX_WORLD_W * MAX_WORLD_H;
let grid_data = vec![0u32; grid_count];
let (grid_buffer, grid_memory) = make_buf(
bytemuck::cast_slice(&grid_data),
vk::BufferUsageFlags::STORAGE_BUFFER,
)?;
let grid_ptr = unsafe {
let sz = (WORLD_W * WORLD_H * std::mem::size_of::<u32>()) as vk::DeviceSize;
let sz = (grid_count * std::mem::size_of::<u32>()) as vk::DeviceSize;
let ptr = device
.map_memory(grid_memory, 0, sz, vk::MemoryMapFlags::default())
.map_err(|e| format!("map grid: {e:?}"))?;
@@ -602,7 +604,7 @@ impl GraphicsRenderer {
let grid_info = vk::DescriptorBufferInfo::default()
.buffer(grid_buffer)
.offset(0)
.range((WORLD_W * WORLD_H * std::mem::size_of::<u32>()) as vk::DeviceSize);
.range((MAX_WORLD_W * MAX_WORLD_H * std::mem::size_of::<u32>()) as vk::DeviceSize);
let light_info = vk::DescriptorBufferInfo::default()
.buffer(light_buffer)
.offset(0)
@@ -764,7 +766,7 @@ impl GraphicsRenderer {
pub fn render(
&mut self,
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
items: &crate::entity::item::ItemManager,
ui: &crate::ui::UiLayer,
@@ -921,16 +923,12 @@ impl GraphicsRenderer {
}
unsafe {
let margin = 30i32;
let x_min = (cam_x - margin).max(0) as usize;
let x_max = (cam_x + self.grid_w as i32 + margin).min(WORLD_W as i32) as usize;
let y_min = (cam_y - margin).max(0) as usize;
let y_max = (cam_y + self.grid_h as i32 + margin).min(WORLD_H as i32) as usize;
for y in y_min..y_max {
let row_offset = y * WORLD_W;
for x in x_min..x_max {
let i = row_offset + x;
*self.grid_ptr.add(i) = grid.cells[i].material as u32;
for dy in 0..self.grid_h {
for dx in 0..self.grid_w {
let wx = cam_x + dx as i32;
let wy = cam_y + dy as i32;
let idx = dy * self.grid_w + dx;
*self.grid_ptr.add(idx) = grid.get(wx, wy).material as u32;
}
}
}
@@ -1033,7 +1031,7 @@ impl GraphicsRenderer {
self.swapchain_extent.height as f32,
],
cell_size: [CHAR_W as f32, CHAR_H as f32],
world_size: [WORLD_W as i32, WORLD_H as i32],
world_size: [self.grid_w as i32, self.grid_h as i32],
cam_pos: [cam_x, cam_y],
ambient,
is_ui: 0,
@@ -1062,7 +1060,7 @@ impl GraphicsRenderer {
self.swapchain_extent.height as f32,
],
cell_size: [UI_CELL_SIZE as f32, UI_CELL_SIZE as f32],
world_size: [WORLD_W as i32, WORLD_H as i32],
world_size: [self.grid_w as i32, self.grid_h as i32],
cam_pos: [0, 0],
ambient: [1.0, 1.0, 1.0],
is_ui: 1,
+19 -11
View File
@@ -1,5 +1,5 @@
use crate::world::cell::MaterialId;
use crate::world::grid::Grid;
use crate::world::chunked_grid::ChunkedGrid;
#[derive(Clone, Copy, Debug)]
pub struct LightSource {
@@ -67,7 +67,7 @@ pub fn material_light(material: MaterialId) -> Option<LightSource> {
}
}
pub fn gather_sources(grid: &Grid) -> Vec<LightSource> {
pub fn gather_sources(grid: &ChunkedGrid) -> Vec<LightSource> {
let mut sources = Vec::new();
let w = grid.width;
let h = grid.height;
@@ -85,7 +85,7 @@ pub fn gather_sources(grid: &Grid) -> Vec<LightSource> {
}
pub fn gather_sources_in_range(
grid: &Grid,
grid: &ChunkedGrid,
cam_x: i32,
cam_y: i32,
view_w: usize,
@@ -94,9 +94,17 @@ pub fn gather_sources_in_range(
) -> Vec<LightSource> {
let mut sources = Vec::new();
let min_x = (cam_x - margin).max(0);
let max_x = (cam_x + view_w as i32 + margin).min(grid.width as i32);
let max_x = if grid.is_infinite() {
cam_x + view_w as i32 + margin
} else {
(cam_x + view_w as i32 + margin).min(grid.width as i32)
};
let min_y = (cam_y - margin).max(0);
let max_y = (cam_y + view_h as i32 + margin).min(grid.height as i32);
let max_y = if grid.is_infinite() {
cam_y + view_h as i32 + margin
} else {
(cam_y + view_h as i32 + margin).min(grid.height as i32)
};
for y in min_y..max_y {
for x in min_x..max_x {
let cell = grid.get(x, y);
@@ -111,7 +119,7 @@ pub fn gather_sources_in_range(
}
pub fn compute_lighting(
grid: &Grid,
grid: &ChunkedGrid,
cam_x: i32,
cam_y: i32,
view_w: usize,
@@ -176,7 +184,7 @@ pub fn compute_lighting(
grid_light
}
pub fn line_of_sight(grid: &Grid, x0: i32, y0: i32, x1: i32, y1: i32) -> bool {
pub fn line_of_sight(grid: &ChunkedGrid, x0: i32, y0: i32, x1: i32, y1: i32) -> bool {
let mut x = x0;
let mut y = y0;
let dx = (x1 - x0).abs();
@@ -236,10 +244,10 @@ pub fn ambient_light() -> [u8; 3] {
#[cfg(test)]
mod tests {
use super::*;
use crate::world::grid::Grid;
use crate::world::chunked_grid::ChunkedGrid;
fn grid_with_lava() -> (Grid, i32, i32) {
let mut grid = Grid::new();
fn grid_with_lava() -> (ChunkedGrid, i32, i32) {
let mut grid = ChunkedGrid::with_size(250, 250);
grid.set_material(10, 10, MaterialId::Lava);
(grid, 10, 10)
}
@@ -267,7 +275,7 @@ mod tests {
#[test]
fn walls_block_light() {
let mut grid = Grid::new();
let mut grid = ChunkedGrid::with_size(250, 250);
grid.set_material(5, 10, MaterialId::Lava);
for y in 7..13 {
grid.set_material(8, y, MaterialId::Stone);
+2 -2
View File
@@ -8,13 +8,13 @@ pub mod window_input;
use crate::entity::item::ItemManager;
use crate::entity::EntityManager;
use crate::ui::UiLayer;
use crate::world::grid::Grid;
use crate::world::chunked_grid::ChunkedGrid;
pub trait Renderer {
fn init(&mut self) -> std::io::Result<()>;
fn render(
&mut self,
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
items: &ItemManager,
ui: &UiLayer,
+2 -2
View File
@@ -16,7 +16,7 @@ use crate::entity::EntityManager;
use crate::render::lighting::{self, apply_light_tuple, LightGrid};
use crate::render::Renderer;
use crate::world::cell::MaterialId;
use crate::world::grid::Grid;
use crate::world::chunked_grid::ChunkedGrid;
fn entity_priority(kind: crate::entity::EntityKind) -> u32 {
match kind {
@@ -96,7 +96,7 @@ impl Renderer for TerminalRenderer {
fn render(
&mut self,
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
items: &crate::entity::item::ItemManager,
ui: &crate::ui::UiLayer,
+14 -17
View File
@@ -6,7 +6,8 @@ use std::sync::Arc;
use crate::entity::{EntityKind, EntityManager};
use crate::render::lighting;
use crate::world::cell::MaterialId;
use crate::world::grid::{Grid, WORLD_H, WORLD_W};
use crate::world::chunked_grid::ChunkedGrid;
use crate::world::grid::{MAX_WORLD_H, MAX_WORLD_W};
const CHAR_W: u32 = 8;
const CHAR_H: u32 = 8;
@@ -225,7 +226,7 @@ impl VulkanRenderer {
let (ui_instance_buffer, ui_instance_memory, ui_instance_ptr) =
create_instance_buffer(&device, &instance, physical_device, ui_instance_capacity)?;
let grid_data = vec![0u32; WORLD_W * WORLD_H];
let grid_data = vec![0u32; MAX_WORLD_W * MAX_WORLD_H];
let (grid_buffer, grid_memory) = create_buffer_with_data(
&device,
&instance,
@@ -234,7 +235,7 @@ impl VulkanRenderer {
vk::BufferUsageFlags::STORAGE_BUFFER,
)?;
let grid_ptr = unsafe {
let sz = (WORLD_W * WORLD_H * std::mem::size_of::<u32>()) as vk::DeviceSize;
let sz = (MAX_WORLD_W * MAX_WORLD_H * std::mem::size_of::<u32>()) as vk::DeviceSize;
let ptr = device
.map_memory(grid_memory, 0, sz, vk::MemoryMapFlags::default())
.map_err(|e| format!("map grid: {e:?}"))?;
@@ -339,7 +340,7 @@ impl VulkanRenderer {
pub fn render(
&mut self,
grid: &Grid,
grid: &ChunkedGrid,
entities: &EntityManager,
items: &crate::entity::item::ItemManager,
ui: &crate::ui::UiLayer,
@@ -441,16 +442,12 @@ impl VulkanRenderer {
}
unsafe {
let margin = 30i32;
let x_min = (cam_x - margin).max(0) as usize;
let x_max = (cam_x + self.grid_w as i32 + margin).min(WORLD_W as i32) as usize;
let y_min = (cam_y - margin).max(0) as usize;
let y_max = (cam_y + self.grid_h as i32 + margin).min(WORLD_H as i32) as usize;
for y in y_min..y_max {
let row_offset = y * WORLD_W;
for x in x_min..x_max {
let i = row_offset + x;
*self.grid_ptr.add(i) = grid.cells[i].material as u32;
for dy in 0..self.grid_h {
for dx in 0..self.grid_w {
let wx = cam_x + dx as i32;
let wy = cam_y + dy as i32;
let idx = dy * self.grid_w + dx;
*self.grid_ptr.add(idx) = grid.get(wx, wy).material as u32;
}
}
}
@@ -627,7 +624,7 @@ impl VulkanRenderer {
self.swapchain_extent.height as f32,
],
cell_size: [CHAR_W as f32, CHAR_H as f32],
world_size: [WORLD_W as i32, WORLD_H as i32],
world_size: [self.grid_w as i32, self.grid_h as i32],
cam_pos: [cam_x, cam_y],
ambient: [
ambient[0] as f32 / 255.0,
@@ -660,7 +657,7 @@ impl VulkanRenderer {
self.swapchain_extent.height as f32,
],
cell_size: [UI_CELL_SIZE as f32, UI_CELL_SIZE as f32],
world_size: [WORLD_W as i32, WORLD_H as i32],
world_size: [self.grid_w as i32, self.grid_h as i32],
cam_pos: [0, 0],
ambient: [0.0, 0.0, 0.0],
is_ui: 1,
@@ -1824,7 +1821,7 @@ fn update_descriptor_set(
let bi = vk::DescriptorBufferInfo::default()
.buffer(grid_buffer)
.offset(0)
.range((WORLD_W * WORLD_H * std::mem::size_of::<u32>()) as vk::DeviceSize);
.range((MAX_WORLD_W * MAX_WORLD_H * std::mem::size_of::<u32>()) as vk::DeviceSize);
let li = vk::DescriptorBufferInfo::default()
.buffer(light_buffer)
.offset(0)
+2 -1
View File
@@ -2,6 +2,7 @@ use std::collections::HashMap;
use crate::entity::entity::{Entity, EntityKind};
use crate::world::cell::MaterialId;
use crate::world::chunked_grid::ChunkedGrid;
pub const UI_SCALE: i32 = 4;
@@ -241,7 +242,7 @@ impl UiLayer {
&mut self,
screen_w: usize,
screen_h: usize,
grid: &crate::world::grid::Grid,
grid: &ChunkedGrid,
entities: &[Entity],
cam_x: i32,
cam_y: i32,
+141
View File
@@ -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(())
}
}
+214 -200
View File
@@ -1,5 +1,5 @@
use crate::world::cell::{Cell, MaterialId};
use crate::world::grid::Grid;
use crate::world::chunked_grid::ChunkedGrid;
pub struct CellularAutomaton {
tick: u64,
@@ -16,6 +16,10 @@ impl CellularAutomaton {
}
}
pub fn seed(&mut self, state: u64) {
self.rng_state = state;
}
#[inline]
fn rand(&mut self) -> u32 {
self.rng_state ^= self.rng_state << 13;
@@ -30,19 +34,37 @@ impl CellularAutomaton {
}
#[inline]
fn apply_cell_rule(&mut self, grid: &mut Grid, x: i32, y: i32) {
fn apply_cell_rule(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let cell = grid.get(x, y);
if cell.updated_this_tick || cell.is_empty() || cell.is_static() {
return;
}
match cell.material {
MaterialId::Sand => self.update_sand(grid, x, y),
MaterialId::Water => self.update_water(grid, x, y),
MaterialId::Lava => self.update_lava(grid, x, y),
MaterialId::Steam => self.update_steam(grid, x, y),
MaterialId::Fire => self.update_fire(grid, x, y),
MaterialId::Smoke => self.update_smoke(grid, x, y),
MaterialId::Acid => self.update_acid(grid, x, y),
MaterialId::Water => {
self.update_water(grid, x, y);
grid.mark_dirty(x, y);
}
MaterialId::Lava => {
self.update_lava(grid, x, y);
grid.mark_dirty(x, y);
}
MaterialId::Steam => {
self.update_steam(grid, x, y);
grid.mark_dirty(x, y);
}
MaterialId::Fire => {
self.update_fire(grid, x, y);
grid.mark_dirty(x, y);
}
MaterialId::Smoke => {
self.update_smoke(grid, x, y);
grid.mark_dirty(x, y);
}
MaterialId::Acid => {
self.update_acid(grid, x, y);
grid.mark_dirty(x, y);
}
MaterialId::Flesh => self.update_flesh(grid, x, y),
MaterialId::Grass => self.update_grass(grid, x, y),
MaterialId::Dirt => self.update_dirt(grid, x, y),
@@ -61,40 +83,38 @@ impl CellularAutomaton {
(self.rand() as usize) % max
}
pub fn step(&mut self, grid: &mut Grid) {
grid.reset_tick_flags();
pub fn step(&mut self, grid: &mut ChunkedGrid) {
let flip = self.rand_bool();
let chunk_w = grid.chunk_size;
let chunks_x = grid.chunks_x;
let chunks_y = grid.chunks_y;
let grid_w = grid.width;
for cy in (0..chunks_y).rev() {
let y0 = cy * chunk_w;
let y1 = ((cy + 1) * chunk_w).min(grid.height);
for y_idx in (y0..y1).rev() {
let y = y_idx as i32;
let mut active = grid.active_chunks();
active.sort_by(|(ax, ay), (bx, by)| by.cmp(ay).then(bx.cmp(ax)));
for (cx, cy) in active {
let dirty = grid.get_chunk_dirty(cx, cy);
if dirty.is_none() {
continue;
}
let (min_x, min_y, max_x, max_y) = dirty.unwrap();
grid.set_chunk_dirty(cx, cy, None);
for y in min_y..=max_y {
for x in min_x..=max_x {
let mut cell = grid.get(x, y);
cell.updated_this_tick = false;
grid.set(x, y, cell);
}
}
grid.set_chunk_dirty(cx, cy, None);
for y in (min_y..=max_y).rev() {
if flip {
for cx in 0..chunks_x {
if !grid.is_chunk_active(cx as i32, cy as i32) {
continue;
}
let x0 = cx * chunk_w;
let x1 = ((cx + 1) * chunk_w).min(grid_w);
for x in x0..x1 {
self.apply_cell_rule(grid, x as i32, y);
}
for x in min_x..=max_x {
self.apply_cell_rule(grid, x, y);
}
} else {
for cx in (0..chunks_x).rev() {
if !grid.is_chunk_active(cx as i32, cy as i32) {
continue;
}
let x0 = cx * chunk_w;
let x1 = ((cx + 1) * chunk_w).min(grid_w);
for x in (x0..x1).rev() {
self.apply_cell_rule(grid, x as i32, y);
}
for x in (min_x..=max_x).rev() {
self.apply_cell_rule(grid, x, y);
}
}
}
@@ -104,15 +124,17 @@ impl CellularAutomaton {
self.tick += 1;
}
fn try_move_down(&mut self, grid: &mut Grid, x: i32, y: i32, _mat: MaterialId, density: f32) {
fn try_move_down(
&mut self,
grid: &mut ChunkedGrid,
x: i32,
y: i32,
_mat: MaterialId,
density: f32,
) {
let below = grid.get(x, y + 1);
if below.is_empty() || (below.is_liquid() && below.density() < density) {
let src = grid.get(x, y);
let i_dst = grid.idx(x, y + 1);
let i_src = grid.idx(x, y);
grid.cells[i_dst] = src;
grid.cells[i_dst].updated_this_tick = true;
grid.cells[i_src] = Cell::empty();
grid.cells_swap(x, y, x, y + 1);
return;
}
@@ -127,36 +149,25 @@ impl CellularAutomaton {
if can_left && can_right {
if self.rand_bool() {
self.do_swap(grid, x, y, x - dir, y + 1);
grid.cells_swap(x, y, x - dir, y + 1);
} else {
self.do_swap(grid, x, y, x + dir, y + 1);
grid.cells_swap(x, y, x + dir, y + 1);
}
} else if can_left {
self.do_swap(grid, x, y, x - dir, y + 1);
grid.cells_swap(x, y, x - dir, y + 1);
} else if can_right {
self.do_swap(grid, x, y, x + dir, y + 1);
grid.cells_swap(x, y, x + dir, y + 1);
}
}
#[inline]
fn do_swap(&self, grid: &mut Grid, x1: i32, y1: i32, x2: i32, y2: i32) {
let a = grid.get(x1, y1);
let b = grid.get(x2, y2);
let i1 = grid.idx(x1, y1);
let i2 = grid.idx(x2, y2);
grid.cells[i1] = b;
grid.cells[i2] = a;
grid.cells[i2].updated_this_tick = true;
}
fn update_sand(&mut self, grid: &mut Grid, x: i32, y: i32) {
fn update_sand(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
self.try_move_down(grid, x, y, MaterialId::Sand, 1.5);
}
fn update_water(&mut self, grid: &mut Grid, x: i32, y: i32) {
fn update_water(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let below = grid.get(x, y + 1);
if below.is_empty() || (below.is_liquid() && below.density() < 1.0) {
self.do_swap(grid, x, y, x, y + 1);
grid.cells_swap(x, y, x, y + 1);
return;
}
@@ -171,27 +182,27 @@ impl CellularAutomaton {
if can_dl && can_dr {
if self.rand_bool() {
self.do_swap(grid, x, y, x - dir, y + 1);
grid.cells_swap(x, y, x - dir, y + 1);
} else {
self.do_swap(grid, x, y, x + dir, y + 1);
grid.cells_swap(x, y, x + dir, y + 1);
}
} else if can_dl {
self.do_swap(grid, x, y, x - dir, y + 1);
grid.cells_swap(x, y, x - dir, y + 1);
} else if can_dr {
self.do_swap(grid, x, y, x + dir, y + 1);
grid.cells_swap(x, y, x + dir, y + 1);
} else {
let can_l = grid.in_bounds(x - dir, y) && grid.get(x - dir, y).is_empty();
let can_r = grid.in_bounds(x + dir, y) && grid.get(x + dir, y).is_empty();
if can_l && can_r {
if self.rand_bool() {
self.do_swap(grid, x, y, x - dir, y);
grid.cells_swap(x, y, x - dir, y);
} else {
self.do_swap(grid, x, y, x + dir, y);
grid.cells_swap(x, y, x + dir, y);
}
} else if can_l {
self.do_swap(grid, x, y, x - dir, y);
grid.cells_swap(x, y, x - dir, y);
} else if can_r {
self.do_swap(grid, x, y, x + dir, y);
grid.cells_swap(x, y, x + dir, y);
}
}
@@ -200,49 +211,47 @@ impl CellularAutomaton {
let mut new = cell;
new.material = MaterialId::Steam;
new.temp = 110.0;
let i = grid.idx(x, y);
grid.cells[i] = new;
grid.set(x, y, new);
}
}
fn update_lava(&mut self, grid: &mut Grid, x: i32, y: i32) {
fn update_lava(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let cell = grid.get(x, y);
if cell.temp < 400.0 {
let mut new = cell;
new.material = MaterialId::Stone;
let i = grid.idx(x, y);
grid.cells[i] = new;
grid.set(x, y, new);
return;
}
let below = grid.get(x, y + 1);
if below.is_empty() {
self.do_swap(grid, x, y, x, y + 1);
grid.cells_swap(x, y, x, y + 1);
return;
}
let dir = if self.rand_bool() { 1 } else { -1 };
if grid.in_bounds(x - dir, y + 1) && grid.get(x - dir, y + 1).is_empty() {
self.do_swap(grid, x, y, x - dir, y + 1);
grid.cells_swap(x, y, x - dir, y + 1);
return;
}
if grid.in_bounds(x + dir, y + 1) && grid.get(x + dir, y + 1).is_empty() {
self.do_swap(grid, x, y, x + dir, y + 1);
grid.cells_swap(x, y, x + dir, y + 1);
return;
}
if self.rand() % 10 == 0 {
if grid.in_bounds(x - dir, y) && grid.get(x - dir, y).is_empty() {
self.do_swap(grid, x, y, x - dir, y);
grid.cells_swap(x, y, x - dir, y);
} else if grid.in_bounds(x + dir, y) && grid.get(x + dir, y).is_empty() {
self.do_swap(grid, x, y, x + dir, y);
grid.cells_swap(x, y, x + dir, y);
}
}
self.lava_interact(grid, x, y);
}
fn lava_interact(&mut self, grid: &mut Grid, x: i32, y: i32) {
fn lava_interact(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
for &(dx, dy) in &NEIGHBORS4 {
let nx = x + dx;
let ny = y + dy;
@@ -252,84 +261,61 @@ impl CellularAutomaton {
let neighbor = grid.get(nx, ny);
match neighbor.material {
MaterialId::Water => {
let i_n = grid.idx(nx, ny);
grid.cells[i_n] = Cell::new(MaterialId::Steam);
grid.set(nx, ny, Cell::new(MaterialId::Steam));
let lava = grid.get(x, y);
let mut new_lava = lava;
new_lava.temp -= 50.0;
let i_l = grid.idx(x, y);
grid.cells[i_l] = new_lava;
grid.set(x, y, new_lava);
}
MaterialId::Wood | MaterialId::Grass | MaterialId::Flesh
if neighbor.temp < 300.0 =>
{
let i_n = grid.idx(nx, ny);
grid.cells[i_n] = Cell::new(MaterialId::Fire);
grid.set(nx, ny, Cell::new(MaterialId::Fire));
}
MaterialId::Sand if neighbor.temp > 1700.0 => {
let i_n = grid.idx(nx, ny);
grid.cells[i_n] = Cell::new(MaterialId::Stone);
grid.set(nx, ny, Cell::new(MaterialId::Stone));
}
_ => {}
}
}
}
fn update_steam(&mut self, grid: &mut Grid, x: i32, y: i32) {
fn update_steam(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let cell = grid.get(x, y);
if cell.temp < 80.0 {
let mut new = cell;
new.material = MaterialId::Water;
new.temp = 50.0;
let i = grid.idx(x, y);
grid.cells[i] = new;
grid.set(x, y, new);
return;
}
if y > 0 && grid.get(x, y - 1).is_empty() {
self.do_swap(grid, x, y, x, y - 1);
grid.cells_swap(x, y, x, y - 1);
return;
}
let dir = if self.rand_bool() { 1 } else { -1 };
if grid.in_bounds(x - dir, y - 1) && grid.get(x - dir, y - 1).is_empty() {
self.do_swap(grid, x, y, x - dir, y - 1);
grid.cells_swap(x, y, x - dir, y - 1);
return;
}
if grid.in_bounds(x + dir, y - 1) && grid.get(x + dir, y - 1).is_empty() {
self.do_swap(grid, x, y, x + dir, y - 1);
grid.cells_swap(x, y, x + dir, y - 1);
return;
}
if self.rand() % 3 == 0 {
if grid.in_bounds(x - dir, y) && grid.get(x - dir, y).is_empty() {
self.do_swap(grid, x, y, x - dir, y);
grid.cells_swap(x, y, x - dir, y);
} else if grid.in_bounds(x + dir, y) && grid.get(x + dir, y).is_empty() {
self.do_swap(grid, x, y, x + dir, y);
grid.cells_swap(x, y, x + dir, y);
}
}
}
fn update_fire(&mut self, grid: &mut Grid, x: i32, y: i32) {
fn update_fire(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let cell = grid.get(x, y);
if cell.temp < 100.0 || self.rand() % 20 == 0 {
let i = grid.idx(x, y);
if self.rand() % 3 == 0 {
grid.cells[i] = Cell::new(MaterialId::Smoke);
} else {
grid.cells[i] = Cell::empty();
}
return;
}
let mut new = cell;
new.temp -= 15.0;
let i = grid.idx(x, y);
grid.cells[i] = new;
if y > 0 && grid.get(x, y - 1).is_empty() && self.rand() % 2 == 0 {
self.do_swap(grid, x, y, x, y - 1);
}
for &(dx, dy) in &NEIGHBORS4 {
let nx = x + dx;
@@ -344,37 +330,52 @@ impl CellularAutomaton {
let mut new_n = neighbor;
new_n.material = MaterialId::Fire;
new_n.temp = 400.0;
let i_n = grid.idx(nx, ny);
grid.cells[i_n] = new_n;
grid.set(nx, ny, new_n);
}
}
if cell.temp < 100.0 || self.rand() % 20 == 0 {
if self.rand() % 3 == 0 {
grid.set(x, y, Cell::new(MaterialId::Smoke));
} else {
grid.set(x, y, Cell::empty());
}
return;
}
let mut new = cell;
new.temp -= 15.0;
grid.set(x, y, new);
if y > 0 && grid.get(x, y - 1).is_empty() && self.rand() % 2 == 0 {
grid.cells_swap(x, y, x, y - 1);
}
}
fn update_smoke(&mut self, grid: &mut Grid, x: i32, y: i32) {
fn update_smoke(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
if self.rand() % 60 == 0 {
let i = grid.idx(x, y);
grid.cells[i] = Cell::empty();
grid.set(x, y, Cell::empty());
return;
}
if y > 0 && grid.get(x, y - 1).is_empty() {
self.do_swap(grid, x, y, x, y - 1);
grid.cells_swap(x, y, x, y - 1);
return;
}
let dir = if self.rand_bool() { 1 } else { -1 };
if grid.in_bounds(x - dir, y - 1) && grid.get(x - dir, y - 1).is_empty() {
self.do_swap(grid, x, y, x - dir, y - 1);
grid.cells_swap(x, y, x - dir, y - 1);
} else if grid.in_bounds(x + dir, y - 1) && grid.get(x + dir, y - 1).is_empty() {
self.do_swap(grid, x, y, x + dir, y - 1);
grid.cells_swap(x, y, x + dir, y - 1);
} else if grid.in_bounds(x - dir, y) && grid.get(x - dir, y).is_empty() {
self.do_swap(grid, x, y, x - dir, y);
grid.cells_swap(x, y, x - dir, y);
} else if grid.in_bounds(x + dir, y) && grid.get(x + dir, y).is_empty() {
self.do_swap(grid, x, y, x + dir, y);
grid.cells_swap(x, y, x + dir, y);
}
}
fn update_acid(&mut self, grid: &mut Grid, x: i32, y: i32) {
fn update_acid(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
for &(dx, dy) in &NEIGHBORS4 {
let nx = x + dx;
let ny = y + dy;
@@ -387,11 +388,9 @@ impl CellularAutomaton {
&& neighbor.material != MaterialId::Stone
&& self.rand() % 4 == 0
{
let i_n = grid.idx(nx, ny);
grid.cells[i_n] = Cell::empty();
grid.set(nx, ny, Cell::empty());
if self.rand() % 2 == 0 {
let i = grid.idx(x, y);
grid.cells[i] = Cell::empty();
grid.set(x, y, Cell::empty());
return;
}
}
@@ -399,113 +398,120 @@ impl CellularAutomaton {
let below = grid.get(x, y + 1);
if below.is_empty() || (below.is_liquid() && below.density() < 1.2) {
self.do_swap(grid, x, y, x, y + 1);
grid.cells_swap(x, y, x, y + 1);
return;
}
let dir = if self.rand_bool() { 1 } else { -1 };
if grid.in_bounds(x - dir, y + 1) && grid.get(x - dir, y + 1).is_empty() {
self.do_swap(grid, x, y, x - dir, y + 1);
grid.cells_swap(x, y, x - dir, y + 1);
} else if grid.in_bounds(x + dir, y + 1) && grid.get(x + dir, y + 1).is_empty() {
self.do_swap(grid, x, y, x + dir, y + 1);
grid.cells_swap(x, y, x + dir, y + 1);
} else if grid.in_bounds(x - dir, y) && grid.get(x - dir, y).is_empty() {
self.do_swap(grid, x, y, x - dir, y);
grid.cells_swap(x, y, x - dir, y);
} else if grid.in_bounds(x + dir, y) && grid.get(x + dir, y).is_empty() {
self.do_swap(grid, x, y, x + dir, y);
grid.cells_swap(x, y, x + dir, y);
}
}
fn update_flesh(&mut self, grid: &mut Grid, x: i32, y: i32) {
fn update_flesh(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let cell = grid.get(x, y);
if cell.temp > 200.0 {
let mut new = cell;
new.material = MaterialId::Fire;
new.temp = 400.0;
let i = grid.idx(x, y);
grid.cells[i] = new;
grid.set(x, y, new);
}
}
fn update_grass(&mut self, grid: &mut Grid, x: i32, y: i32) {
fn update_grass(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let cell = grid.get(x, y);
if cell.temp > 250.0 {
let i = grid.idx(x, y);
grid.cells[i] = Cell::new(MaterialId::Fire);
grid.set(x, y, Cell::new(MaterialId::Fire));
}
}
fn update_dirt(&mut self, grid: &mut Grid, x: i32, y: i32) {
fn update_dirt(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let cell = grid.get(x, y);
if cell.temp < 0.0 {
let mut new = cell;
new.material = MaterialId::Stone;
let i = grid.idx(x, y);
grid.cells[i] = new;
grid.set(x, y, new);
}
}
fn heat_transfer(&mut self, grid: &mut Grid) {
let w = grid.width;
let size = w * grid.height;
self.temps.resize(size, 0.0);
for i in 0..size {
self.temps[i] = grid.cells[i].temp;
}
fn heat_transfer(&mut self, grid: &mut ChunkedGrid) {
let reg = crate::world::material::MaterialRegistry::instance();
let gw = grid.width as i32;
let gh = grid.height as i32;
let chunk_w = grid.chunk_size;
for cy in 0..grid.chunks_y {
if !grid.chunks[grid.chunk_index(0, cy as i32)].active {
let mut any_active = false;
for cx in 0..grid.chunks_x {
if grid.is_chunk_active(cx as i32, cy as i32) {
any_active = true;
break;
}
}
if !any_active {
continue;
let active = grid.active_chunks();
for (cx, cy) in active {
let dirty = grid.get_chunk_dirty(cx, cy);
if dirty.is_none() {
continue;
}
let (min_x, min_y, max_x, max_y) = dirty.unwrap();
let ex_min_x = (min_x - 1).max(0);
let ex_min_y = (min_y - 1).max(0);
let (ex_max_x, ex_max_y) = if grid.is_infinite() {
((max_x + 1), (max_y + 1))
} else {
((max_x + 1).min(gw - 1), (max_y + 1).min(gh - 1))
};
let ew = (ex_max_x - ex_min_x + 1) as usize;
let eh = (ex_max_y - ex_min_y + 1) as usize;
let ecount = ew.saturating_mul(eh);
if ecount > 10000 {
eprintln!(
"heat_transfer skipping huge dirty rect: chunk=({}, {}) dirty=({},{},{},{}) ew={} eh={}",
cx, cy, min_x, min_y, max_x, max_y, ew, eh
);
continue;
}
if self.temps.len() < ecount {
self.temps.resize(ecount, 0.0);
}
for y in ex_min_y..=ex_max_y {
for x in ex_min_x..=ex_max_x {
let idx = ((y - ex_min_y) as usize) * ew + (x - ex_min_x) as usize;
self.temps[idx] = grid.get(x, y).temp;
}
}
let y0 = cy * chunk_w;
let y1 = ((cy + 1) * chunk_w).min(grid.height);
for y in y0..y1 {
for cx in 0..grid.chunks_x {
if !grid.is_chunk_active(cx as i32, cy as i32) {
for y in min_y..=max_y {
for x in min_x..=max_x {
let cell = grid.get(x, y);
if cell.is_empty() || cell.is_static() {
continue;
}
let mat = reg.get(cell.material);
let k = mat.heat_conductivity;
if k == 0.0 {
continue;
}
let x0 = cx * chunk_w;
let x1 = ((cx + 1) * chunk_w).min(grid.width);
for x in x0..x1 {
let i = y * w + x;
let cell = grid.cells[i];
if cell.is_empty() || cell.is_static() {
continue;
}
let reg = crate::world::material::MaterialRegistry::instance();
let mat = reg.get(cell.material);
let k = mat.heat_conductivity;
if k == 0.0 {
continue;
}
let mut sum = 0.0;
let mut count = 0;
for &(dx, dy) in &NEIGHBORS4 {
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx < 0 || nx >= w as i32 || ny < 0 || ny >= grid.height as i32 {
continue;
}
let ni = ny as usize * w + nx as usize;
sum += self.temps[ni];
count += 1;
}
if count > 0 {
let avg = sum / count as f32;
let mut new = cell;
new.temp += (avg - cell.temp) * k * 0.1;
grid.cells[i] = new;
let mut sum = 0.0;
let mut count = 0;
for &(dx, dy) in &NEIGHBORS4 {
let nx = x + dx;
let ny = y + dy;
if nx < ex_min_x || nx > ex_max_x || ny < ex_min_y || ny > ex_max_y {
continue;
}
let ni = ((ny - ex_min_y) as usize) * ew + (nx - ex_min_x) as usize;
sum += self.temps[ni];
count += 1;
}
if count > 0 {
let avg = sum / count as f32;
let mut new = cell;
new.temp += (avg - cell.temp) * k * 0.1;
grid.set(x, y, new);
grid.mark_dirty(x, y);
}
}
}
@@ -514,3 +520,11 @@ impl CellularAutomaton {
}
const NEIGHBORS4: [(i32, i32); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)];
pub fn random_seed() -> u64 {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
(nanos ^ (nanos >> 32)) as u64
}
+40 -3
View File
@@ -6,6 +6,9 @@ pub struct Chunk {
pub cells: Vec<Cell>,
pub active: bool,
pub modified: bool,
pub was_modified: bool,
pub generated: bool,
pub dirty: Option<(i32, i32, i32, i32)>,
}
impl Chunk {
@@ -15,6 +18,20 @@ impl Chunk {
cells: vec![Cell::empty(); size],
active: false,
modified: false,
was_modified: false,
generated: false,
dirty: None,
}
}
pub fn swap_modified_flags(&mut self) {
self.was_modified = self.modified;
self.modified = false;
}
pub fn reset_tick_flags(&mut self) {
for c in &mut self.cells {
c.updated_this_tick = false;
}
}
@@ -49,9 +66,29 @@ impl Chunk {
}
}
pub fn reset_tick_flags(&mut self) {
for c in &mut self.cells {
c.updated_this_tick = false;
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),
));
}
}
}
}
+798
View File
@@ -0,0 +1,798 @@
use crate::world::cell::{Cell, MaterialId};
use crate::world::chunk::{Chunk, CHUNK_SIZE};
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
pub struct ChunkedGrid {
pub chunk_size: usize,
pub chunks: HashMap<(i64, i64), Chunk>,
pub chunks_vec: Vec<Chunk>,
pub bounds: Option<(i64, i64, i64, i64)>,
pub seed: u64,
pub cache_dir: Option<String>,
pub width: usize,
pub height: usize,
pub chunks_x: usize,
pub chunks_y: usize,
}
impl ChunkedGrid {
pub fn with_size(width: usize, height: usize) -> Self {
let chunk_size = CHUNK_SIZE;
let chunks_x = (width + chunk_size - 1) / chunk_size;
let chunks_y = (height + chunk_size - 1) / chunk_size;
let mut chunks_vec = Vec::with_capacity(chunks_x * chunks_y);
for _ in 0..chunks_x * chunks_y {
let mut chunk = Chunk::new();
chunk.active = true;
chunks_vec.push(chunk);
}
Self {
chunk_size,
chunks: HashMap::new(),
chunks_vec,
bounds: Some((0, 0, width as i64, height as i64)),
seed: 0,
cache_dir: None,
width,
height,
chunks_x,
chunks_y,
}
}
pub fn infinite(seed: u64, cache_dir: Option<String>) -> Self {
Self {
chunk_size: CHUNK_SIZE,
chunks: HashMap::new(),
chunks_vec: Vec::new(),
bounds: None,
seed,
cache_dir,
width: i64::MAX as usize,
height: i64::MAX as usize,
chunks_x: 0,
chunks_y: 0,
}
}
#[inline]
fn chunk_index(&self, cx: i32, cy: i32) -> Option<usize> {
if cx < 0 || cy < 0 || cx >= self.chunks_x as i32 || cy >= self.chunks_y as i32 {
return None;
}
Some((cy as usize) * self.chunks_x + (cx as usize))
}
#[inline]
fn is_bounded(&self) -> bool {
self.bounds.is_some()
}
#[inline]
pub fn chunk_at(&self, x: i32, y: i32) -> (i32, i32, i32, i32) {
let cs = self.chunk_size as i32;
let cx = x.div_euclid(cs);
let cy = y.div_euclid(cs);
let lx = x.rem_euclid(cs);
let ly = y.rem_euclid(cs);
(cx, cy, lx, ly)
}
#[inline]
pub fn in_bounds(&self, x: i32, y: i32) -> bool {
match self.bounds {
Some((x0, y0, x1, y1)) => {
let wx = x as i64;
let wy = y as i64;
wx >= x0 && wx < x1 && wy >= y0 && wy < y1
}
None => true,
}
}
#[inline]
pub fn get_chunk(&self, cx: i32, cy: i32) -> Option<&Chunk> {
if self.is_bounded() {
self.chunk_index(cx, cy)
.and_then(|idx| self.chunks_vec.get(idx))
} else {
self.chunks.get(&(cx as i64, cy as i64))
}
}
#[inline]
pub fn get_chunk_mut(&mut self, cx: i32, cy: i32) -> Option<&mut Chunk> {
if self.is_bounded() {
self.chunk_index(cx, cy)
.and_then(|idx| self.chunks_vec.get_mut(idx))
} else {
self.chunks.get_mut(&(cx as i64, cy as i64))
}
}
pub fn ensure_chunk(&mut self, cx: i32, cy: i32) -> Option<&mut Chunk> {
let origin_x = cx * self.chunk_size as i32;
let origin_y = cy * self.chunk_size as i32;
if !self.in_bounds(origin_x, origin_y) {
return None;
}
if self.is_bounded() {
return self.get_chunk_mut(cx, cy);
}
let cx64 = cx as i64;
let cy64 = cy as i64;
if !self.chunks.contains_key(&(cx64, cy64)) {
if let Some(ref dir) = self.cache_dir {
let path = chunk_path(dir, self.seed, cx, cy);
if path.exists() {
if let Err(e) = self.load_chunk_from_path(&path, cx, cy) {
eprintln!("Chunk load failed {} {}: {}", cx, cy, e);
}
}
}
self.chunks.insert((cx64, cy64), Chunk::new());
}
self.chunks.get_mut(&(cx64, cy64))
}
pub fn get_or_create_chunk(&mut self, cx: i32, cy: i32) -> &mut Chunk {
if self.is_bounded() {
let idx = self.chunk_index(cx, cy).unwrap();
return &mut self.chunks_vec[idx];
}
let key = (cx as i64, cy as i64);
if !self.chunks.contains_key(&key) {
self.chunks.insert(key, Chunk::new());
}
self.chunks.get_mut(&key).unwrap()
}
#[inline]
pub fn get(&self, x: i32, y: i32) -> Cell {
if !self.in_bounds(x, y) {
return Cell::new(MaterialId::Stone);
}
let (cx, cy, lx, ly) = self.chunk_at(x, y);
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get(idx) {
return chunk.get(lx, ly);
}
}
} else if let Some(chunk) = self.chunks.get(&(cx as i64, cy as i64)) {
return chunk.get(lx, ly);
}
Cell::new(MaterialId::Stone)
}
#[inline]
pub fn set(&mut self, x: i32, y: i32, cell: Cell) {
if !self.in_bounds(x, y) {
return;
}
let (cx, cy, lx, ly) = self.chunk_at(x, y);
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get_mut(idx) {
chunk.set(lx, ly, cell);
chunk.mark_dirty(lx, ly);
}
}
} else {
let chunk = self.get_or_create_chunk(cx, cy);
chunk.set(lx, ly, cell);
chunk.mark_dirty(lx, ly);
}
}
#[inline]
pub fn set_material(&mut self, x: i32, y: i32, mat: MaterialId) {
if !self.in_bounds(x, y) {
return;
}
let (cx, cy, lx, ly) = self.chunk_at(x, y);
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get_mut(idx) {
chunk.set_material(lx, ly, mat);
chunk.mark_dirty(lx, ly);
}
}
} else {
let chunk = self.get_or_create_chunk(cx, cy);
chunk.set_material(lx, ly, mat);
chunk.mark_dirty(lx, ly);
}
}
#[inline]
pub fn mark_dirty(&mut self, x: i32, y: i32) {
if !self.in_bounds(x, y) {
return;
}
let (cx, cy, lx, ly) = self.chunk_at(x, y);
let cs = self.chunk_size as i32;
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get_mut(idx) {
chunk.mark_dirty(lx, ly);
}
}
if lx <= 1 {
if let Some(idx) = self.chunk_index(cx - 1, cy) {
if let Some(chunk) = self.chunks_vec.get_mut(idx) {
chunk.mark_dirty(cs - 1, ly);
}
}
}
if lx >= cs - 2 {
if let Some(idx) = self.chunk_index(cx + 1, cy) {
if let Some(chunk) = self.chunks_vec.get_mut(idx) {
chunk.mark_dirty(0, ly);
}
}
}
if ly <= 1 {
if let Some(idx) = self.chunk_index(cx, cy - 1) {
if let Some(chunk) = self.chunks_vec.get_mut(idx) {
chunk.mark_dirty(lx, cs - 1);
}
}
}
if ly >= cs - 2 {
if let Some(idx) = self.chunk_index(cx, cy + 1) {
if let Some(chunk) = self.chunks_vec.get_mut(idx) {
chunk.mark_dirty(lx, 0);
}
}
}
} else {
let cx64 = cx as i64;
let cy64 = cy as i64;
if let Some(chunk) = self.chunks.get_mut(&(cx64, cy64)) {
chunk.mark_dirty(lx, ly);
}
if lx <= 1 {
if let Some(chunk) = self.chunks.get_mut(&(cx64 - 1, cy64)) {
chunk.mark_dirty(cs - 1, ly);
}
}
if lx >= cs - 2 {
if let Some(chunk) = self.chunks.get_mut(&(cx64 + 1, cy64)) {
chunk.mark_dirty(0, ly);
}
}
if ly <= 1 {
if let Some(chunk) = self.chunks.get_mut(&(cx64, cy64 - 1)) {
chunk.mark_dirty(lx, cs - 1);
}
}
if ly >= cs - 2 {
if let Some(chunk) = self.chunks.get_mut(&(cx64, cy64 + 1)) {
chunk.mark_dirty(lx, 0);
}
}
}
}
#[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 (cx1, cy1, lx1, ly1) = self.chunk_at(x1, y1);
let (cx2, cy2, lx2, ly2) = self.chunk_at(x2, y2);
if self.is_bounded() {
let idx1 = self.chunk_index(cx1, cy1);
let idx2 = self.chunk_index(cx2, cy2);
match (idx1, idx2) {
(Some(i1), Some(i2)) if i1 == i2 => {
if let Some(chunk) = self.chunks_vec.get_mut(i1) {
let ci1 = (ly1 as usize) * self.chunk_size + (lx1 as usize);
let ci2 = (ly2 as usize) * self.chunk_size + (lx2 as usize);
let tmp = chunk.cells[ci1];
chunk.cells[ci1] = chunk.cells[ci2];
chunk.cells[ci2] = tmp;
chunk.cells[ci2].updated_this_tick = true;
chunk.modified = true;
chunk.mark_dirty(lx1, ly1);
chunk.mark_dirty(lx2, ly2);
}
}
(Some(i1), Some(i2)) => {
let c1 = self.get(x1, y1);
let c2 = self.get(x2, y2);
if let Some(chunk) = self.chunks_vec.get_mut(i1) {
let ci = (ly1 as usize) * self.chunk_size + (lx1 as usize);
chunk.cells[ci] = c2;
chunk.cells[ci].updated_this_tick = true;
chunk.modified = true;
chunk.mark_dirty(lx1, ly1);
}
if let Some(chunk) = self.chunks_vec.get_mut(i2) {
let ci = (ly2 as usize) * self.chunk_size + (lx2 as usize);
chunk.cells[ci] = c1;
chunk.modified = true;
chunk.mark_dirty(lx2, ly2);
}
}
_ => {}
}
} else if cx1 == cx2 && cy1 == cy2 {
let cs = self.chunk_size;
let chunk = self.get_or_create_chunk(cx1, cy1);
let i1 = (ly1 as usize) * cs + (lx1 as usize);
let i2 = (ly2 as usize) * cs + (lx2 as usize);
let tmp = chunk.cells[i1];
chunk.cells[i1] = chunk.cells[i2];
chunk.cells[i2] = tmp;
chunk.cells[i2].updated_this_tick = true;
chunk.modified = true;
chunk.mark_dirty(lx1, ly1);
chunk.mark_dirty(lx2, ly2);
} else {
let c1 = self.get(x1, y1);
let c2 = self.get(x2, y2);
self.set(x1, y1, c2);
self.set(x2, y2, c1);
let cs = self.chunk_size;
let chunk = self.get_or_create_chunk(cx1, cy1);
let i = (ly1 as usize) * cs + (lx1 as usize);
chunk.cells[i].updated_this_tick = true;
}
}
pub fn set_cell_index(&mut self, i: usize, cell: Cell) {
let x = (i % self.chunk_size) as i32;
let y = (i / self.chunk_size) as i32;
self.set(x, y, cell);
}
pub fn reset_tick_flags(&mut self) {
if self.is_bounded() {
for chunk in &mut self.chunks_vec {
if !chunk.active {
continue;
}
for c in &mut chunk.cells {
c.updated_this_tick = false;
}
}
} else {
for chunk in self.chunks.values_mut() {
if !chunk.active {
continue;
}
for c in &mut chunk.cells {
c.updated_this_tick = false;
}
}
}
}
pub fn swap_modified_flags(&mut self) {
if self.is_bounded() {
for chunk in &mut self.chunks_vec {
chunk.swap_modified_flags();
}
} else {
for chunk in self.chunks.values_mut() {
chunk.swap_modified_flags();
}
}
}
pub fn any_modified(&self) -> bool {
if self.is_bounded() {
self.chunks_vec.iter().any(|c| c.modified)
} else {
self.chunks.values().any(|c| c.modified)
}
}
pub fn any_was_modified(&self) -> bool {
if self.is_bounded() {
self.chunks_vec.iter().any(|c| c.was_modified)
} else {
self.chunks.values().any(|c| c.was_modified)
}
}
pub fn active_chunks(&self) -> Vec<(i32, i32)> {
let mut out = Vec::new();
if self.is_bounded() {
for cy in 0..self.chunks_y as i32 {
for cx in 0..self.chunks_x as i32 {
if let Some(idx) = self.chunk_index(cx, cy) {
if self.chunks_vec[idx].active {
out.push((cx, cy));
}
}
}
}
} else {
for (&(cx, cy), chunk) in &self.chunks {
if chunk.active {
out.push((cx as i32, cy as i32));
}
}
}
out
}
pub fn all_chunk_coords(&self) -> Vec<(i32, i32)> {
let mut out = Vec::new();
if self.is_bounded() {
for cy in 0..self.chunks_y as i32 {
for cx in 0..self.chunks_x as i32 {
out.push((cx, cy));
}
}
} else {
for (&(cx, cy), _) in &self.chunks {
out.push((cx as i32, cy as i32));
}
}
out
}
pub fn is_chunk_modified(&self, cx: i32, cy: i32) -> bool {
if self.is_bounded() {
self.chunk_index(cx, cy)
.and_then(|idx| self.chunks_vec.get(idx))
.map(|c| c.modified || c.was_modified)
.unwrap_or(false)
} else {
self.chunks
.get(&(cx as i64, cy as i64))
.map(|c| c.modified || c.was_modified)
.unwrap_or(false)
}
}
pub fn is_chunk_generated(&self, cx: i32, cy: i32) -> bool {
if self.is_bounded() {
self.chunk_index(cx, cy)
.and_then(|idx| self.chunks_vec.get(idx))
.map(|c| c.generated)
.unwrap_or(false)
} else {
self.chunks
.get(&(cx as i64, cy as i64))
.map(|c| c.generated)
.unwrap_or(false)
}
}
pub fn is_chunk_empty(&self, cx: i32, cy: i32) -> bool {
if self.is_bounded() {
self.chunk_index(cx, cy)
.and_then(|idx| self.chunks_vec.get(idx))
.map(|c| c.is_empty())
.unwrap_or(true)
} else {
self.chunks
.get(&(cx as i64, cy as i64))
.map(|c| c.is_empty())
.unwrap_or(true)
}
}
pub fn unload_chunk(&mut self, cx: i32, cy: i32) {
if self.is_bounded() {
return;
}
self.chunks.remove(&(cx as i64, cy as i64));
}
pub fn chunk_bounds(&self, cx: i32, cy: i32) -> (i32, i32, i32, i32) {
let cs = self.chunk_size as i32;
let x0 = cx * cs;
let y0 = cy * cs;
let x1 = x0 + cs;
let y1 = y0 + cs;
match self.bounds {
Some((bx0, by0, bx1, by1)) => {
let bx0_i = bx0 as i32;
let by0_i = by0 as i32;
let bx1_i = bx1 as i32;
let by1_i = by1 as i32;
(x0.max(bx0_i), y0.max(by0_i), x1.min(bx1_i), y1.min(by1_i))
}
None => (x0, y0, x1, y1),
}
}
pub fn is_chunk_active(&self, cx: i32, cy: i32) -> bool {
if self.is_bounded() {
self.chunk_index(cx, cy)
.and_then(|idx| self.chunks_vec.get(idx))
.map(|c| c.active)
.unwrap_or(false)
} else {
self.chunks
.get(&(cx as i64, cy as i64))
.map(|c| c.active)
.unwrap_or(false)
}
}
pub fn set_chunk_active(&mut self, cx: i32, cy: i32, active: bool) {
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get_mut(idx) {
chunk.active = active;
}
}
} else if let Some(chunk) = self.chunks.get_mut(&(cx as i64, cy as i64)) {
chunk.active = active;
}
}
pub fn get_chunk_dirty(&self, cx: i32, cy: i32) -> Option<(i32, i32, i32, i32)> {
let ox = cx * self.chunk_size as i32;
let oy = cy * self.chunk_size as i32;
let dirty = if self.is_bounded() {
self.chunk_index(cx, cy)
.and_then(|idx| self.chunks_vec.get(idx))
.and_then(|c| c.dirty)
} else {
self.chunks
.get(&(cx as i64, cy as i64))
.and_then(|c| c.dirty)
};
dirty.map(|(x0, y0, x1, y1)| (x0 + ox, y0 + oy, x1 + ox, y1 + oy))
}
pub fn set_chunk_dirty(&mut self, cx: i32, cy: i32, dirty: Option<(i32, i32, i32, i32)>) {
let ox = cx * self.chunk_size as i32;
let oy = cy * self.chunk_size as i32;
let local = dirty.map(|(x0, y0, x1, y1)| (x0 - ox, y0 - oy, x1 - ox, y1 - oy));
if self.is_bounded() {
if let Some(idx) = self.chunk_index(cx, cy) {
if let Some(chunk) = self.chunks_vec.get_mut(idx) {
chunk.dirty = local;
}
}
} else if let Some(chunk) = self.chunks.get_mut(&(cx as i64, cy as i64)) {
chunk.dirty = local;
}
}
pub fn activate_around(&mut self, x: i32, y: i32, radius: i32) {
let (cx, cy, _, _) = self.chunk_at(x, y);
for dy in -radius..=radius {
for dx in -radius..=radius {
self.set_chunk_active(cx + dx, cy + dy, true);
}
}
}
pub fn deactivate_all(&mut self) {
if self.is_bounded() {
for chunk in &mut self.chunks_vec {
chunk.active = false;
}
} else {
for chunk in self.chunks.values_mut() {
chunk.active = false;
}
}
}
pub fn is_infinite(&self) -> bool {
self.bounds.is_none()
}
pub fn cell_active(&self, x: i32, y: i32) -> bool {
if !self.in_bounds(x, y) {
return false;
}
let (cx, cy, _, _) = self.chunk_at(x, y);
self.is_chunk_active(cx, cy)
}
pub fn chunk_cells(&self, cx: i32, cy: i32) -> Vec<(i32, i32, Cell)> {
let (x0, y0, x1, y1) = self.chunk_bounds(cx, cy);
let mut out = Vec::with_capacity(((x1 - x0) as usize) * ((y1 - y0) as usize));
for y in y0..y1 {
for x in x0..x1 {
out.push((x, y, self.get(x, y)));
}
}
out
}
pub fn load_chunk_cells(&mut self, cx: i32, cy: i32, cells: &[Cell]) {
let cs = self.chunk_size as i32;
let (x0, y0, x1, y1) = self.chunk_bounds(cx, cy);
let chunk = self.get_or_create_chunk(cx, cy);
let w = (x1 - x0) as usize;
for y in y0..y1 {
for x in x0..x1 {
let i = ((y - y0) as usize) * w + (x - x0) as usize;
if let Some(cell) = cells.get(i) {
let lx = x - cx * cs;
let ly = y - cy * cs;
chunk.set(lx, ly, *cell);
}
}
}
chunk.active = true;
}
pub fn fill_border(&mut self, mat: MaterialId) {
let (x0, y0, x1, y1) = match self.bounds {
Some(b) => b,
None => return,
};
for x in x0..x1 {
self.set_material(x as i32, y0 as i32, mat);
self.set_material(x as i32, (y1 - 1) as i32, mat);
}
for y in y0..y1 {
self.set_material(x0 as i32, y as i32, mat);
self.set_material((x1 - 1) as i32, y as i32, mat);
}
}
pub fn save_chunk(&self, path: &str, cx: i32, cy: i32) -> io::Result<()> {
let chunk = if self.is_bounded() {
match self
.chunk_index(cx, cy)
.and_then(|idx| self.chunks_vec.get(idx))
{
Some(c) => c,
None => return Ok(()),
}
} else {
match self.chunks.get(&(cx as i64, cy as i64)) {
Some(c) => c,
None => return Ok(()),
}
};
let (x0, y0, x1, y1) = self.chunk_bounds(cx, cy);
let w = (x1 - x0) as usize;
let h = (y1 - y0) as usize;
let mut bytes = Vec::with_capacity(w * h * 12);
let cs = self.chunk_size as i32;
for y in y0..y1 {
for x in x0..x1 {
let lx = x - cx * cs;
let ly = y - cy * cs;
bytes.extend_from_slice(&chunk.get(lx, ly).to_bytes());
}
}
let dir = Path::new(path);
if let Some(parent) = dir.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, bytes)
}
pub fn load_chunk(&mut self, path: &str, cx: i32, cy: i32) -> io::Result<()> {
self.load_chunk_from_path(Path::new(path), cx, cy)
}
fn load_chunk_from_path(&mut self, path: &Path, cx: i32, cy: i32) -> io::Result<()> {
let data = std::fs::read(path)?;
let (x0, y0, x1, y1) = self.chunk_bounds(cx, cy);
let w = (x1 - x0) as usize;
let h = (y1 - y0) as usize;
let expected = w * h * 12;
if data.len() != expected {
return Err(io::Error::other("chunk file size mismatch"));
}
let cs = self.chunk_size as i32;
let chunk = self.get_or_create_chunk(cx, cy);
let mut i = 0usize;
for y in y0..y1 {
for x in x0..x1 {
let lx = x - cx * cs;
let ly = y - cy * cs;
let cell = Cell::from_bytes(&data[i * 12..(i + 1) * 12]);
chunk.set(lx, ly, cell);
i += 1;
}
}
chunk.active = true;
chunk.generated = true;
Ok(())
}
pub fn load_all_modified(&mut self) -> io::Result<()> {
let cache_dir = match self.cache_dir {
Some(ref dir) => dir,
None => return Ok(()),
};
let base = Path::new(cache_dir).join(format!("seed_{}", self.seed));
if !base.exists() {
return Ok(());
}
for entry in std::fs::read_dir(base)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
if let Some(rest) = name.strip_prefix("chunk_") {
let parts: Vec<&str> = rest.split('_').collect();
if parts.len() == 2 {
if let (Ok(cx), Ok(cy)) = (parts[0].parse::<i32>(), parts[1].parse::<i32>()) {
let path = entry.path();
self.load_chunk_from_path(&path, cx, cy)?;
}
}
}
}
Ok(())
}
pub fn save_all_modified(&self) -> io::Result<()> {
let cache_dir = match self.cache_dir {
Some(ref dir) => dir,
None => return Ok(()),
};
if self.is_bounded() {
for cy in 0..self.chunks_y as i32 {
for cx in 0..self.chunks_x as i32 {
if let Some(idx) = self.chunk_index(cx, cy) {
let chunk = &self.chunks_vec[idx];
if chunk.modified || chunk.was_modified {
let path = chunk_path(cache_dir, self.seed, cx, cy);
self.save_chunk(path.to_str().unwrap(), cx, cy)?;
}
}
}
}
} else {
for (&(cx, cy), chunk) in &self.chunks {
if chunk.modified || chunk.was_modified {
let path = chunk_path(cache_dir, self.seed, cx as i32, cy as i32);
self.save_chunk(path.to_str().unwrap(), cx as i32, cy as i32)?;
}
}
}
Ok(())
}
pub fn unload_distant(&mut self, px: i32, py: i32, radius: i32) {
if self.is_bounded() {
return;
}
let (pcx, pcy, _, _) = self.chunk_at(px, py);
let mut to_remove = Vec::new();
for (&(cx, cy), chunk) in &self.chunks {
if (cx - pcx as i64).abs() > radius as i64 || (cy - pcy as i64).abs() > radius as i64 {
if chunk.modified || chunk.was_modified {
if let Some(ref dir) = self.cache_dir {
let path = chunk_path(dir, self.seed, cx as i32, cy as i32);
let _ = self.save_chunk(path.to_str().unwrap(), cx as i32, cy as i32);
}
}
to_remove.push((cx, cy));
}
}
for key in to_remove {
self.chunks.remove(&key);
}
}
pub fn ensure_loaded(&mut self, px: i32, py: i32, radius: i32) {
let (pcx, pcy, _, _) = self.chunk_at(px, py);
for dy in -radius..=radius {
for dx in -radius..=radius {
let cx = pcx + dx;
let cy = pcy + dy;
let _ = self.ensure_chunk(cx, cy);
self.set_chunk_active(cx, cy, true);
}
}
}
}
pub fn chunk_path(root: &str, seed: u64, cx: i32, cy: i32) -> PathBuf {
Path::new(root)
.join(format!("seed_{}", seed))
.join(format!("chunk_{}_{}.bin", cx, cy))
}
+103 -7
View File
@@ -6,10 +6,14 @@ use std::path::Path;
pub const WORLD_W: usize = 250;
pub const WORLD_H: usize = 250;
pub const MAX_WORLD_W: usize = 2048;
pub const MAX_WORLD_H: usize = 2048;
pub struct ChunkMeta {
pub active: bool,
pub modified: bool,
pub was_modified: bool,
pub dirty: Option<(i32, i32, i32, i32)>,
}
pub struct Grid {
@@ -24,22 +28,27 @@ pub struct Grid {
impl Grid {
pub fn new() -> Self {
let size = WORLD_W * WORLD_H;
Self::with_size(WORLD_W, WORLD_H)
}
pub fn with_size(width: usize, height: usize) -> Self {
let size = width * height;
let chunk_size = CHUNK_SIZE;
let chunks_x = (WORLD_W + chunk_size - 1) / chunk_size;
let chunks_y = (WORLD_H + chunk_size - 1) / chunk_size;
let chunks_x = (width + chunk_size - 1) / chunk_size;
let chunks_y = (height + chunk_size - 1) / chunk_size;
let mut chunks = Vec::with_capacity(chunks_x * chunks_y);
for _ in 0..chunks_x * chunks_y {
chunks.push(ChunkMeta {
active: true,
modified: false,
was_modified: false,
dirty: None,
});
}
Self {
cells: vec![Cell::empty(); size],
width: WORLD_W,
height: WORLD_H,
width,
height,
chunk_size,
chunks_x,
chunks_y,
@@ -185,6 +194,7 @@ impl Grid {
if let Some(c) = self.chunks.get_mut(idx) {
c.modified = true;
}
self.mark_dirty(x, y);
}
}
@@ -198,6 +208,7 @@ impl Grid {
if let Some(c) = self.chunks.get_mut(idx) {
c.modified = true;
}
self.mark_dirty(x, y);
}
}
@@ -212,9 +223,94 @@ impl Grid {
}
}
#[inline]
pub fn mark_dirty(&mut self, x: i32, y: i32) {
if !self.in_bounds(x, y) {
return;
}
let (cx, cy, _, _) = self.chunk_at(x, y);
self.expand_chunk_dirty(cx, cy, x, y);
let cs = self.chunk_size as i32;
let lx = x - cx * cs;
let ly = y - cy * cs;
if lx <= 1 {
self.expand_chunk_dirty(cx - 1, cy, x - 1, y);
}
if lx >= cs - 2 {
self.expand_chunk_dirty(cx + 1, cy, x + 1, y);
}
if ly <= 1 {
self.expand_chunk_dirty(cx, cy - 1, x, y - 1);
}
if ly >= cs - 2 {
self.expand_chunk_dirty(cx, cy + 1, x, y + 1);
}
}
#[inline]
fn expand_chunk_dirty(&mut self, cx: i32, cy: i32, x: i32, y: i32) {
if cx < 0 || cy < 0 || cx >= self.chunks_x as i32 || cy >= self.chunks_y as i32 {
return;
}
if !self.in_bounds(x, y) {
return;
}
let idx = self.chunk_index(cx, cy);
let min_x = (x - 1).max(0);
let min_y = (y - 1).max(0);
let max_x = (x + 1).min(self.width as i32 - 1);
let max_y = (y + 1).min(self.height as i32 - 1);
let chunk = &mut self.chunks[idx];
match chunk.dirty {
None => chunk.dirty = Some((min_x, min_y, max_x, max_y)),
Some((dx0, dy0, dx1, dy1)) => {
chunk.dirty = Some((
dx0.min(min_x),
dy0.min(min_y),
dx1.max(max_x),
dy1.max(max_y),
));
}
}
}
#[inline]
pub fn cells_swap(&mut self, x1: i32, y1: i32, x2: i32, y2: i32) {
if !self.in_bounds(x1, y1) || !self.in_bounds(x2, y2) {
return;
}
let i1 = self.idx(x1, y1);
let i2 = self.idx(x2, y2);
let tmp = self.cells[i1];
self.cells[i1] = self.cells[i2];
self.cells[i2] = tmp;
self.cells[i2].updated_this_tick = true;
self.mark_dirty(x1, y1);
self.mark_dirty(x2, y2);
}
#[inline]
pub fn set_cell_index(&mut self, i: usize, cell: Cell) {
self.cells[i] = cell;
let x = (i % self.width) as i32;
let y = (i / self.width) as i32;
self.mark_dirty(x, y);
}
pub fn reset_tick_flags(&mut self) {
for c in &mut self.cells {
c.updated_this_tick = false;
for cy in 0..self.chunks_y {
for cx in 0..self.chunks_x {
if !self.chunks[cy * self.chunks_x + cx].active {
continue;
}
let (x0, y0, x1, y1) = self.chunk_bounds(cx, cy);
for y in y0..y1 {
let row = y as usize * self.width;
for x in x0..x1 {
self.cells[row + x as usize].updated_this_tick = false;
}
}
}
}
}
+3
View File
@@ -1,5 +1,8 @@
pub mod cache;
pub mod cell;
pub mod cellular;
pub mod chunk;
pub mod chunked_grid;
pub mod grid;
pub mod material;
pub mod worldgen;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
use verbatim::world::cell::{Cell, MaterialId};
use verbatim::world::chunk::{world_to_chunk, Chunk, CHUNK_SIZE};
use verbatim::world::chunk::{CHUNK_SIZE, Chunk, world_to_chunk};
use verbatim::world::grid::Grid;
#[test]
+158 -26
View File
@@ -1,11 +1,17 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
use verbatim::ai::GameSession;
fn setup() -> GameSession {
let mut s = GameSession::new_seeded(42);
s.init_empty();
s.clear_area(90, 90, 50, 50);
s.perform_action(&AiAction::FillRect { x: 80, y: 130, w: 80, h: 15, material: "stone".into() });
s.perform_action(&AiAction::FillRect {
x: 80,
y: 130,
w: 80,
h: 15,
material: "stone".into(),
});
s
}
@@ -15,11 +21,22 @@ fn player_blocked_by_left_wall() {
s.step(30);
let p = s.get_player().unwrap();
let wall_x = (p.pos[0] as i32) - 6;
s.perform_action(&AiAction::FillRect { x: wall_x, y: 125, w: 1, h: 10, material: "stone".into() });
s.perform_action(&AiAction::FillRect {
x: wall_x,
y: 125,
w: 1,
h: 10,
material: "stone".into(),
});
s.perform_action(&AiAction::MoveLeft);
s.step(20);
let p2 = s.get_player().unwrap();
assert!(p2.pos[0] > wall_x as f32, "player should not pass through left wall: wall={} player={}", wall_x, p2.pos[0]);
assert!(
p2.pos[0] > wall_x as f32,
"player should not pass through left wall: wall={} player={}",
wall_x,
p2.pos[0]
);
}
#[test]
@@ -28,11 +45,22 @@ fn player_blocked_by_right_wall() {
s.step(30);
let p = s.get_player().unwrap();
let wall_x = (p.pos[0] as i32) + 6;
s.perform_action(&AiAction::FillRect { x: wall_x, y: 125, w: 1, h: 10, material: "stone".into() });
s.perform_action(&AiAction::FillRect {
x: wall_x,
y: 125,
w: 1,
h: 10,
material: "stone".into(),
});
s.perform_action(&AiAction::MoveRight);
s.step(20);
let p2 = s.get_player().unwrap();
assert!(p2.pos[0] < wall_x as f32, "player should not pass through right wall: wall={} player={}", wall_x, p2.pos[0]);
assert!(
p2.pos[0] < wall_x as f32,
"player should not pass through right wall: wall={} player={}",
wall_x,
p2.pos[0]
);
}
#[test]
@@ -41,14 +69,24 @@ fn player_slides_along_wall() {
s.step(30);
let p = s.get_player().unwrap();
let wall_x = (p.pos[0] as i32) + 6;
s.perform_action(&AiAction::FillRect { x: wall_x, y: 125, w: 1, h: 10, material: "stone".into() });
s.perform_action(&AiAction::FillRect {
x: wall_x,
y: 125,
w: 1,
h: 10,
material: "stone".into(),
});
s.perform_action(&AiAction::MoveRight);
s.step(30);
let p2 = s.get_player().unwrap();
assert!(p2.pos[0] < wall_x as f32, "player should stay left of wall");
assert!(p2.alive, "player should be alive");
let y_diff = (p2.pos[1] - p.pos[1]).abs();
assert!(y_diff < 5.0, "player should not fall through floor while sliding: dy={}", y_diff);
assert!(
y_diff < 5.0,
"player should not fall through floor while sliding: dy={}",
y_diff
);
}
#[test]
@@ -57,11 +95,22 @@ fn player_blocked_by_ceiling() {
s.step(30);
let p = s.get_player().unwrap();
let ceiling_y = (p.pos[1] as i32) - 8;
s.perform_action(&AiAction::FillRect { x: p.pos[0] as i32 - 5, y: ceiling_y, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect {
x: p.pos[0] as i32 - 5,
y: ceiling_y,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::Jump);
s.step(10);
let p2 = s.get_player().unwrap();
assert!(p2.pos[1] > ceiling_y as f32, "player should not pass through ceiling: ceiling={} player={}", ceiling_y, p2.pos[1]);
assert!(
p2.pos[1] > ceiling_y as f32,
"player should not pass through ceiling: ceiling={} player={}",
ceiling_y,
p2.pos[1]
);
}
#[test]
@@ -69,13 +118,40 @@ fn player_navigates_corridor() {
let mut s = GameSession::new_seeded(42);
s.init_empty();
s.clear_area(100, 100, 40, 30);
s.perform_action(&AiAction::FillRect { x: 95, y: 120, w: 50, h: 10, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 115, y: 110, w: 1, h: 10, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 125, y: 110, w: 1, h: 10, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 115, y: 108, w: 11, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect {
x: 95,
y: 120,
w: 50,
h: 10,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 115,
y: 110,
w: 1,
h: 10,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 125,
y: 110,
w: 1,
h: 10,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 115,
y: 108,
w: 11,
h: 1,
material: "stone".into(),
});
s.step(30);
let p = s.get_player().unwrap();
assert!(p.pos[0] < 115.0 || p.pos[0] > 125.0, "player should be outside corridor initially");
assert!(
p.pos[0] < 115.0 || p.pos[0] > 125.0,
"player should be outside corridor initially"
);
}
#[test]
@@ -84,7 +160,13 @@ fn player_does_not_stick_to_wall() {
s.step(30);
let p = s.get_player().unwrap();
let wall_x = (p.pos[0] as i32) + 8;
s.perform_action(&AiAction::FillRect { x: wall_x, y: 125, w: 1, h: 10, material: "stone".into() });
s.perform_action(&AiAction::FillRect {
x: wall_x,
y: 125,
w: 1,
h: 10,
material: "stone".into(),
});
for _ in 0..10 {
s.perform_action(&AiAction::MoveRight);
s.step(2);
@@ -96,7 +178,12 @@ fn player_does_not_stick_to_wall() {
s.step(2);
}
let p_away = s.get_player().unwrap();
assert!(p_away.pos[0] < x_at_wall - 1.0, "player should move away from wall: {} -> {}", x_at_wall, p_away.pos[0]);
assert!(
p_away.pos[0] < x_at_wall - 1.0,
"player should move away from wall: {} -> {}",
x_at_wall,
p_away.pos[0]
);
}
#[test]
@@ -105,8 +192,20 @@ fn player_squeezes_through_gap() {
s.step(30);
let p = s.get_player().unwrap();
let px = p.pos[0] as i32;
s.perform_action(&AiAction::FillRect { x: px + 8, y: 128, w: 1, h: 2, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: px + 8, y: 122, w: 1, h: 2, material: "stone".into() });
s.perform_action(&AiAction::FillRect {
x: px + 8,
y: 128,
w: 1,
h: 2,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: px + 8,
y: 122,
w: 1,
h: 2,
material: "stone".into(),
});
s.perform_action(&AiAction::MoveRight);
s.step(20);
let p2 = s.get_player().unwrap();
@@ -119,8 +218,20 @@ fn player_blocked_by_two_walls_both_sides() {
s.step(30);
let p = s.get_player().unwrap();
let px = p.pos[0] as i32;
s.perform_action(&AiAction::FillRect { x: px - 6, y: 125, w: 1, h: 10, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: px + 6, y: 125, w: 1, h: 10, material: "stone".into() });
s.perform_action(&AiAction::FillRect {
x: px - 6,
y: 125,
w: 1,
h: 10,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: px + 6,
y: 125,
w: 1,
h: 10,
material: "stone".into(),
});
s.perform_action(&AiAction::MoveRight);
s.step(10);
let p_right = s.get_player().unwrap();
@@ -129,7 +240,10 @@ fn player_blocked_by_two_walls_both_sides() {
let p_left = s.get_player().unwrap();
assert!(p_right.pos[0] < (px + 6) as f32, "blocked right");
assert!(p_left.pos[0] > (px - 6) as f32, "blocked left");
assert!((p_left.pos[0] - p_right.pos[0]).abs() < 12.0, "player should stay between walls");
assert!(
(p_left.pos[0] - p_right.pos[0]).abs() < 12.0,
"player should stay between walls"
);
}
#[test]
@@ -140,7 +254,11 @@ fn player_walks_up_slope() {
for x in 100..130 {
let h = ((x - 100) / 3).min(15);
for y in 0..h {
s.perform_action(&AiAction::SetCell { x, y: 135 - 1 - y, material: "stone".into() });
s.perform_action(&AiAction::SetCell {
x,
y: 135 - 1 - y,
material: "stone".into(),
});
}
}
s.step(40);
@@ -152,11 +270,25 @@ fn player_walks_up_slope() {
#[test]
fn entity_collision_with_dirt_wall() {
let mut s = setup();
s.perform_action(&AiAction::FillRect { x: 140, y: 125, w: 1, h: 5, material: "dirt".into() });
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 135.0, y: 120.0 });
s.perform_action(&AiAction::FillRect {
x: 140,
y: 125,
w: 1,
h: 5,
material: "dirt".into(),
});
s.perform_action(&AiAction::Spawn {
kind: "goblin".into(),
x: 135.0,
y: 120.0,
});
s.step(40);
let entities = s.get_entities();
if let Some(g) = entities.into_iter().find(|e| e.kind == "Goblin" && e.alive) {
assert!(g.pos[0] < 140.0, "goblin should be blocked by dirt wall: x={}", g.pos[0]);
assert!(
g.pos[0] < 140.0,
"goblin should be blocked by dirt wall: x={}",
g.pos[0]
);
}
}
+83 -18
View File
@@ -1,12 +1,18 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
use verbatim::ai::GameSession;
use verbatim::ai::ReplayPlayer;
fn setup() -> GameSession {
let mut s = GameSession::new_seeded(42);
s.init_empty();
s.clear_area(90, 90, 50, 50);
s.perform_action(&AiAction::FillRect { x: 80, y: 130, w: 80, h: 15, material: "stone".into() });
s.perform_action(&AiAction::FillRect {
x: 80,
y: 130,
w: 80,
h: 15,
material: "stone".into(),
});
s
}
@@ -32,7 +38,11 @@ fn same_seed_same_entity_count() {
let mut s2 = GameSession::new_seeded(555);
s2.init();
s2.step(60);
assert_eq!(s1.get_entities().len(), s2.get_entities().len(), "entity count should match");
assert_eq!(
s1.get_entities().len(),
s2.get_entities().len(),
"entity count should match"
);
}
#[test]
@@ -47,7 +57,11 @@ fn same_seed_same_grid_state() {
for x in 100..150 {
let c1 = s1.get_cell(x, y);
let c2 = s2.get_cell(x, y);
assert_eq!(c1.material, c2.material, "material mismatch at ({},{})", x, y);
assert_eq!(
c1.material, c2.material,
"material mismatch at ({},{})",
x, y
);
}
}
}
@@ -69,7 +83,8 @@ fn replay_exact_match() {
s.perform_action(&AiAction::MoveLeft);
s.step(10);
let state_orig = s.get_state();
s.save_replay("/tmp/verbatim_replay_exact.json").expect("save");
s.save_replay("/tmp/verbatim_replay_exact.json")
.expect("save");
let player = ReplayPlayer::load("/tmp/verbatim_replay_exact.json").expect("load");
let s2 = player.play();
@@ -77,8 +92,18 @@ fn replay_exact_match() {
assert_eq!(state_orig.tick, state_replay.tick, "tick mismatch");
if let (Some(p1), Some(p2)) = (&state_orig.player, &state_replay.player) {
assert!((p1.pos[0] - p2.pos[0]).abs() < 0.1, "x mismatch: {} vs {}", p1.pos[0], p2.pos[0]);
assert!((p1.pos[1] - p2.pos[1]).abs() < 0.1, "y mismatch: {} vs {}", p1.pos[1], p2.pos[1]);
assert!(
(p1.pos[0] - p2.pos[0]).abs() < 0.1,
"x mismatch: {} vs {}",
p1.pos[0],
p2.pos[0]
);
assert!(
(p1.pos[1] - p2.pos[1]).abs() < 0.1,
"y mismatch: {} vs {}",
p1.pos[1],
p2.pos[1]
);
assert!((p1.health - p2.health).abs() < 1.0, "health mismatch");
}
}
@@ -91,14 +116,25 @@ fn replay_stop_at_tick() {
s.step(20);
s.perform_action(&AiAction::MoveRight);
s.step(20);
s.save_replay("/tmp/verbatim_replay_partial.json").expect("save");
s.save_replay("/tmp/verbatim_replay_partial.json")
.expect("save");
let player = ReplayPlayer::load("/tmp/verbatim_replay_partial.json").expect("load");
let s_half = player.play_until_tick(10);
assert_eq!(s_half.tick(), 10, "should stop at tick 10, got {}", s_half.tick());
assert_eq!(
s_half.tick(),
10,
"should stop at tick 10, got {}",
s_half.tick()
);
let s_full = player.play();
assert_eq!(s_full.tick(), 40, "full replay should reach tick 40, got {}", s_full.tick());
assert_eq!(
s_full.tick(),
40,
"full replay should reach tick 40, got {}",
s_full.tick()
);
}
#[test]
@@ -110,28 +146,52 @@ fn recording_captures_all_actions() {
s.perform_action(&AiAction::Jump);
s.perform_action(&AiAction::MoveLeft);
s.step(5);
s.save_replay("/tmp/verbatim_replay_capture.json").expect("save");
s.save_replay("/tmp/verbatim_replay_capture.json")
.expect("save");
let player = ReplayPlayer::load("/tmp/verbatim_replay_capture.json").expect("load");
let event_count = player.recording().events.len();
assert!(event_count >= 4, "recording should have at least 4 events (3 actions + 1 step), got {}", event_count);
assert!(
event_count >= 4,
"recording should have at least 4 events (3 actions + 1 step), got {}",
event_count
);
}
#[test]
fn determinism_with_spawn_and_damage() {
let mut s1 = setup();
s1.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 135.0, y: 120.0 });
s1.perform_action(&AiAction::DamageEntity { id: 1, amount: 20.0 });
s1.perform_action(&AiAction::Spawn {
kind: "goblin".into(),
x: 135.0,
y: 120.0,
});
s1.perform_action(&AiAction::DamageEntity {
id: 1,
amount: 20.0,
});
s1.step(30);
let mut s2 = setup();
s2.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 135.0, y: 120.0 });
s2.perform_action(&AiAction::DamageEntity { id: 1, amount: 20.0 });
s2.perform_action(&AiAction::Spawn {
kind: "goblin".into(),
x: 135.0,
y: 120.0,
});
s2.perform_action(&AiAction::DamageEntity {
id: 1,
amount: 20.0,
});
s2.step(30);
let e1 = s1.get_entities().into_iter().find(|e| e.id == 1).unwrap();
let e2 = s2.get_entities().into_iter().find(|e| e.id == 1).unwrap();
assert!((e1.health - e2.health).abs() < 0.01, "health should match: {} vs {}", e1.health, e2.health);
assert!(
(e1.health - e2.health).abs() < 0.01,
"health should match: {} vs {}",
e1.health,
e2.health
);
assert!((e1.pos[0] - e2.pos[0]).abs() < 0.1, "x should match");
assert!((e1.pos[1] - e2.pos[1]).abs() < 0.1, "y should match");
}
@@ -150,5 +210,10 @@ fn hundred_tick_determinism() {
let p1 = s1.get_player().unwrap();
let p2 = s2.get_player().unwrap();
assert!((p1.pos[0] - p2.pos[0]).abs() < 0.01, "100-tick determinism failed: {} vs {}", p1.pos[0], p2.pos[0]);
assert!(
(p1.pos[0] - p2.pos[0]).abs() < 0.01,
"100-tick determinism failed: {} vs {}",
p1.pos[0],
p2.pos[0]
);
}
+93 -17
View File
@@ -1,5 +1,5 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
use verbatim::ai::GameSession;
fn setup_empty() -> GameSession {
let mut s = GameSession::new_seeded(42);
@@ -11,39 +11,92 @@ fn setup_empty() -> GameSession {
#[test]
fn entity_takes_lava_damage() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 130, w: 20, h: 3, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 114, y: 127, w: 8, h: 3, material: "lava".into() });
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 118.0, y: 118.0 });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 130,
w: 20,
h: 3,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 114,
y: 127,
w: 8,
h: 3,
material: "lava".into(),
});
s.perform_action(&AiAction::Spawn {
kind: "goblin".into(),
x: 118.0,
y: 118.0,
});
s.step(80);
let entities = s.get_entities();
let goblin = entities.into_iter().find(|e| e.kind == "Goblin");
assert!(goblin.is_some(), "goblin should exist");
let g = goblin.unwrap();
assert!(g.health < 40.0, "goblin should have taken damage from lava, hp={}", g.health);
assert!(
g.health < 40.0,
"goblin should have taken damage from lava, hp={}",
g.health
);
}
#[test]
fn entity_dies_becomes_corpse() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 110.0, y: 120.0 });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 125,
w: 20,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::Spawn {
kind: "goblin".into(),
x: 110.0,
y: 120.0,
});
s.step(30);
s.perform_action(&AiAction::DamageEntity { id: 1, amount: 100.0 });
s.perform_action(&AiAction::DamageEntity {
id: 1,
amount: 100.0,
});
s.step(1);
let entities = s.get_entities();
let goblin = entities.into_iter().find(|e| e.id == 1);
assert!(goblin.is_some(), "entity should still exist");
let g = goblin.unwrap();
assert!(!g.alive, "entity should be dead after 100 damage");
assert_eq!(g.kind, "Corpse", "dead entity should be a corpse, got {}", g.kind);
assert_eq!(
g.kind, "Corpse",
"dead entity should be a corpse, got {}",
g.kind
);
}
#[test]
fn entity_on_fire_takes_damage_over_time() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 116, y: 123, w: 4, h: 2, material: "lava".into() });
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 118.0, y: 120.0 });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 125,
w: 20,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 116,
y: 123,
w: 4,
h: 2,
material: "lava".into(),
});
s.perform_action(&AiAction::Spawn {
kind: "goblin".into(),
x: 118.0,
y: 120.0,
});
s.step(20);
let entities = s.get_entities();
let goblin = entities.into_iter().find(|e| e.id == 1);
@@ -53,7 +106,10 @@ fn entity_on_fire_takes_damage_over_time() {
s.step(30);
let entities2 = s.get_entities();
if let Some(g2) = entities2.into_iter().find(|e| e.id == 1) {
assert!(g2.health < hp_after_fire, "entity on fire should lose more health over time");
assert!(
g2.health < hp_after_fire,
"entity on fire should lose more health over time"
);
}
}
}
@@ -62,12 +118,32 @@ fn entity_on_fire_takes_damage_over_time() {
#[test]
fn entity_blocked_by_stone() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 130, w: 30, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 110, y: 126, w: 1, h: 4, material: "stone".into() });
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 105.0, y: 125.0 });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 130,
w: 30,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 110,
y: 126,
w: 1,
h: 4,
material: "stone".into(),
});
s.perform_action(&AiAction::Spawn {
kind: "goblin".into(),
x: 105.0,
y: 125.0,
});
s.step(30);
let entities = s.get_entities();
if let Some(g) = entities.into_iter().find(|e| e.id == 1) {
assert!(g.pos[0] < 110.0, "goblin should be blocked by stone wall, got x={}", g.pos[0]);
assert!(
g.pos[0] < 110.0,
"goblin should be blocked by stone wall, got x={}",
g.pos[0]
);
}
}
+49 -9
View File
@@ -1,11 +1,17 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
use verbatim::ai::GameSession;
fn setup_empty() -> GameSession {
let mut s = GameSession::new_seeded(42);
s.init_empty();
s.clear_area(95, 95, 40, 35);
s.perform_action(&AiAction::FillRect { x: 80, y: 135, w: 80, h: 15, material: "stone".into() });
s.perform_action(&AiAction::FillRect {
x: 80,
y: 135,
w: 80,
h: 15,
material: "stone".into(),
});
s
}
@@ -14,16 +20,32 @@ fn player_falls_and_lands() {
let mut s = GameSession::new_seeded(42);
s.init_empty();
s.clear_area(115, 115, 20, 15);
s.perform_action(&AiAction::FillRect { x: 110, y: 130, w: 30, h: 15, material: "stone".into() });
s.perform_action(&AiAction::FillRect {
x: 110,
y: 130,
w: 30,
h: 15,
material: "stone".into(),
});
s.step(80);
let player = s.get_player().expect("player should exist");
assert!(player.alive, "player should be alive");
let y = player.pos[1];
assert!(y < 135.0, "player should not fall through stone floor, got y={}", y);
assert!(
y < 135.0,
"player should not fall through stone floor, got y={}",
y
);
s.step(30);
let player2 = s.get_player().expect("player should exist");
let dy = (player2.pos[1] - y).abs();
assert!(dy < 3.0, "player should have stopped falling (dy={:.2}), y={} -> {}", dy, y, player2.pos[1]);
assert!(
dy < 3.0,
"player should have stopped falling (dy={:.2}), y={} -> {}",
dy,
y,
player2.pos[1]
);
}
#[test]
@@ -32,11 +54,20 @@ fn player_blocked_by_stone_wall() {
s.step(30);
let player = s.get_player().expect("player should exist");
let x = player.pos[0] as i32;
s.perform_action(&AiAction::FillRect { x: x + 5, y: 125, w: 1, h: 10, material: "stone".into() });
s.perform_action(&AiAction::FillRect {
x: x + 5,
y: 125,
w: 1,
h: 10,
material: "stone".into(),
});
s.perform_action(&AiAction::MoveRight);
s.step(10);
let player = s.get_player().expect("player should exist");
assert!(player.pos[0] < (x + 5) as f32, "player should be blocked by wall");
assert!(
player.pos[0] < (x + 5) as f32,
"player should be blocked by wall"
);
}
#[test]
@@ -48,7 +79,12 @@ fn player_can_move_right() {
s.perform_action(&AiAction::MoveRight);
s.step(10);
let player = s.get_player().expect("player should exist");
assert!(player.pos[0] > initial_x, "player should have moved right: {} -> {}", initial_x, player.pos[0]);
assert!(
player.pos[0] > initial_x,
"player should have moved right: {} -> {}",
initial_x,
player.pos[0]
);
}
#[test]
@@ -57,5 +93,9 @@ fn player_survives_fall() {
s.step(60);
let player = s.get_player().expect("player should exist");
assert!(player.alive, "player should survive a fall onto stone");
assert!(player.health > 50.0, "player should not take significant damage from landing, hp={}", player.health);
assert!(
player.health > 50.0,
"player should not take significant damage from landing, hp={}",
player.health
);
}
+49 -12
View File
@@ -1,5 +1,5 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
use verbatim::ai::GameSession;
use verbatim::ai::ReplayPlayer;
#[test]
@@ -13,7 +13,8 @@ fn replay_deterministic() {
s1.step(10);
let state1 = s1.get_state();
s1.save_replay("/tmp/verbatim_test_replay.json").expect("save replay");
s1.save_replay("/tmp/verbatim_test_replay.json")
.expect("save replay");
let player = ReplayPlayer::load("/tmp/verbatim_test_replay.json").expect("load replay");
let s2 = player.play();
@@ -22,8 +23,18 @@ fn replay_deterministic() {
assert_eq!(state1.tick, state2.tick, "ticks should match");
if let (Some(p1), Some(p2)) = (&state1.player, &state2.player) {
assert_eq!(p1.health, p2.health, "player health should match");
assert!((p1.pos[0] - p2.pos[0]).abs() < 0.01, "player x should match: {} vs {}", p1.pos[0], p2.pos[0]);
assert!((p1.pos[1] - p2.pos[1]).abs() < 0.01, "player y should match: {} vs {}", p1.pos[1], p2.pos[1]);
assert!(
(p1.pos[0] - p2.pos[0]).abs() < 0.01,
"player x should match: {} vs {}",
p1.pos[0],
p2.pos[0]
);
assert!(
(p1.pos[1] - p2.pos[1]).abs() < 0.01,
"player y should match: {} vs {}",
p1.pos[1],
p2.pos[1]
);
}
}
@@ -38,14 +49,25 @@ fn replay_play_until_tick() {
s.step(5);
s.perform_action(&AiAction::Jump);
s.step(10);
s.save_replay("/tmp/verbatim_test_replay2.json").expect("save");
s.save_replay("/tmp/verbatim_test_replay2.json")
.expect("save");
let player = ReplayPlayer::load("/tmp/verbatim_test_replay2.json").expect("load");
let s_half = player.play_until_tick(5);
assert_eq!(s_half.tick(), 5, "should stop at tick 5, got {}", s_half.tick());
assert_eq!(
s_half.tick(),
5,
"should stop at tick 5, got {}",
s_half.tick()
);
let s_full = player.play();
assert_eq!(s_full.tick(), 20, "full replay should reach tick 20, got {}", s_full.tick());
assert_eq!(
s_full.tick(),
20,
"full replay should reach tick 20, got {}",
s_full.tick()
);
}
#[test]
@@ -62,8 +84,18 @@ fn same_seed_same_state() {
assert_eq!(state1.tick, state2.tick);
if let (Some(p1), Some(p2)) = (&state1.player, &state2.player) {
assert!((p1.pos[0] - p2.pos[0]).abs() < 0.01, "x mismatch: {} vs {}", p1.pos[0], p2.pos[0]);
assert!((p1.pos[1] - p2.pos[1]).abs() < 0.01, "y mismatch: {} vs {}", p1.pos[1], p2.pos[1]);
assert!(
(p1.pos[0] - p2.pos[0]).abs() < 0.01,
"x mismatch: {} vs {}",
p1.pos[0],
p2.pos[0]
);
assert!(
(p1.pos[1] - p2.pos[1]).abs() < 0.01,
"y mismatch: {} vs {}",
p1.pos[1],
p2.pos[1]
);
}
}
@@ -73,7 +105,8 @@ fn pipe_protocol_init_and_step() {
use std::process::{Command, Stdio};
let mut child = Command::new(env!("CARGO_BIN_EXE_verbatim"))
.arg("--mode").arg("pipe")
.arg("--mode")
.arg("pipe")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
@@ -98,9 +131,13 @@ fn pipe_protocol_init_and_step() {
stdin.flush().expect("flush");
let n = stdout.read(&mut buf).expect("read");
let output2 = String::from_utf8_lossy(&buf[..n]).to_string();
let json2: serde_json::Value = serde_json::from_str(output2.trim()).expect("parse step response");
let json2: serde_json::Value =
serde_json::from_str(output2.trim()).expect("parse step response");
assert_eq!(json2["ok"], true);
assert_eq!(json2["state"]["tick"], 10, "tick should be 10 after stepping 10");
assert_eq!(
json2["state"]["tick"], 10,
"tick should be 10 after stepping 10"
);
writeln!(stdin, "{{\"cmd\":\"quit\"}}").expect("write quit");
stdin.flush().expect("flush");
+51
View File
@@ -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());
}
+60 -12
View File
@@ -1,5 +1,5 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
use verbatim::ai::GameSession;
fn setup_empty() -> GameSession {
let mut s = GameSession::new_seeded(42);
@@ -11,28 +11,76 @@ fn setup_empty() -> GameSession {
#[test]
fn acid_dissolves_wood() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 104, y: 118, material: "wood".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "acid".into() });
s.perform_action(&AiAction::SetCell { x: 104, y: 119, material: "acid".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 120,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 104,
y: 118,
material: "wood".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 119,
material: "acid".into(),
});
s.perform_action(&AiAction::SetCell {
x: 104,
y: 119,
material: "acid".into(),
});
s.step(30);
assert_ne!(s.get_cell(104, 118).material, "wood", "acid should have dissolved the wood");
assert_ne!(
s.get_cell(104, 118).material,
"wood",
"acid should have dissolved the wood"
);
}
#[test]
fn acid_does_not_dissolve_stone() {
let mut s = setup_empty();
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 111, material: "acid".into() });
s.perform_action(&AiAction::SetCell {
x: 105,
y: 110,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 111,
material: "acid".into(),
});
s.step(20);
assert_eq!(s.get_cell(105, 110).material, "stone", "acid should not dissolve stone");
assert_eq!(
s.get_cell(105, 110).material,
"stone",
"acid should not dissolve stone"
);
}
#[test]
fn acid_flows_down() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "acid".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 120,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 105,
material: "acid".into(),
});
s.step(20);
assert_ne!(s.get_cell(105, 105).material, "acid", "acid should have flowed down from y=105");
assert_ne!(
s.get_cell(105, 105).material,
"acid",
"acid should have flowed down from y=105"
);
}
+296 -54
View File
@@ -1,8 +1,8 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
use verbatim::ai::GameSession;
fn setup() -> GameSession {
let mut s = GameSession::new_seeded(42);
let mut s = GameSession::new_seeded(2);
s.init_empty();
s.clear_area(90, 90, 50, 50);
s
@@ -11,9 +11,23 @@ fn setup() -> GameSession {
#[test]
fn lava_flows_down_on_stone() {
let mut s = setup();
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 115, material: "lava".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 116, material: "lava".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 125,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 115,
material: "lava".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 116,
material: "lava".into(),
});
s.step(15);
let lava_at_floor = s.count_material_in_region(103, 122, 5, 4, "lava");
assert!(lava_at_floor > 0, "lava should flow down to stone floor");
@@ -22,25 +36,69 @@ fn lava_flows_down_on_stone() {
#[test]
fn lava_cools_to_stone_eventually() {
let mut s = setup();
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 120, material: "lava".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 121, material: "lava".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 122, material: "lava".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 123, material: "lava".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 124, material: "lava".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 125,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 120,
material: "lava".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 121,
material: "lava".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 122,
material: "lava".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 123,
material: "lava".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 124,
material: "lava".into(),
});
s.step(200);
let stone_count = s.count_material_in_region(103, 120, 5, 6, "stone");
assert!(stone_count >= 3, "lava should cool to stone eventually, got {} stone", stone_count);
assert!(
stone_count >= 3,
"lava should cool to stone eventually, got {} stone",
stone_count
);
}
#[test]
fn fire_spreads_through_wood_line() {
let mut s = setup();
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 120,
w: 20,
h: 1,
material: "stone".into(),
});
for x in 100..110 {
s.perform_action(&AiAction::SetCell { x, y: 119, material: "wood".into() });
s.perform_action(&AiAction::SetCell {
x,
y: 119,
material: "wood".into(),
});
}
s.perform_action(&AiAction::SetCell { x: 100, y: 119, material: "fire".into() });
s.perform_action(&AiAction::SetCell {
x: 100,
y: 119,
material: "fire".into(),
});
s.step(40);
let wood_left = s.count_material_in_region(100, 118, 10, 3, "wood");
assert_eq!(wood_left, 0, "fire should spread through entire wood line");
@@ -49,56 +107,173 @@ fn fire_spreads_through_wood_line() {
#[test]
fn acid_does_not_dissolve_empty() {
let mut s = setup();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 104, y: 114, w: 3, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 104, y: 113, w: 1, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 106, y: 113, w: 1, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "acid".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 115,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 104,
y: 114,
w: 3,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 104,
y: 113,
w: 1,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 106,
y: 113,
w: 1,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 113,
material: "acid".into(),
});
s.step(5);
let cell = s.get_cell(105, 113);
assert!(cell.material == "acid" || cell.material == "empty",
"acid may flow out but should not dissolve empty, got {}", cell.material);
assert!(
cell.material == "acid" || cell.material == "empty",
"acid may flow out but should not dissolve empty, got {}",
cell.material
);
}
#[test]
fn acid_dissolves_grass() {
let mut s = setup();
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 104, y: 119, material: "grass".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "acid".into() });
s.perform_action(&AiAction::SetCell { x: 104, y: 118, material: "acid".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 120,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 104,
y: 119,
material: "grass".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 119,
material: "acid".into(),
});
s.perform_action(&AiAction::SetCell {
x: 104,
y: 118,
material: "acid".into(),
});
s.step(20);
assert_ne!(s.get_cell(104, 119).material, "grass", "acid should dissolve grass");
assert_ne!(
s.get_cell(104, 119).material,
"grass",
"acid should dissolve grass"
);
}
#[test]
fn acid_dissolves_dirt() {
let mut s = setup();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 104, y: 114, material: "dirt".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "acid".into() });
s.perform_action(&AiAction::SetCell { x: 104, y: 113, material: "acid".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 115,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 104,
y: 114,
material: "dirt".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 114,
material: "acid".into(),
});
s.perform_action(&AiAction::SetCell {
x: 104,
y: 113,
material: "acid".into(),
});
s.step(30);
assert_ne!(s.get_cell(104, 114).material, "dirt", "acid should dissolve dirt");
assert_ne!(
s.get_cell(104, 114).material,
"dirt",
"acid should dissolve dirt"
);
}
#[test]
fn water_extinguishes_fire_indirectly() {
let mut s = setup();
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "fire".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 118, material: "water".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 120,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 119,
material: "fire".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 118,
material: "water".into(),
});
s.step(20);
assert_ne!(s.get_cell(105, 119).material, "fire", "water should extinguish fire");
assert_ne!(
s.get_cell(105, 119).material,
"fire",
"water should extinguish fire"
);
}
#[test]
fn lava_and_water_produce_both_steam_and_stone() {
let mut s = setup();
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 100, y: 95, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 101, y: 115, w: 4, h: 3, material: "lava".into() });
s.perform_action(&AiAction::FillRect { x: 106, y: 115, w: 4, h: 3, material: "water".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 125,
w: 20,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 100,
y: 95,
w: 20,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 101,
y: 115,
w: 4,
h: 3,
material: "lava".into(),
});
s.perform_action(&AiAction::FillRect {
x: 106,
y: 115,
w: 4,
h: 3,
material: "water".into(),
});
s.step(30);
let steam = s.count_material_in_region(100, 100, 20, 20, "steam");
assert!(steam > 0, "lava + water should produce steam");
@@ -107,41 +282,108 @@ fn lava_and_water_produce_both_steam_and_stone() {
#[test]
fn sand_falls_through_water() {
let mut s = setup();
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 104, y: 118, w: 3, h: 7, material: "water".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "sand".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 125,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 104,
y: 118,
w: 3,
h: 7,
material: "water".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 113,
material: "sand".into(),
});
s.step(30);
let sand_at_bottom = s.get_cell(105, 124).material;
assert_eq!(sand_at_bottom, "sand", "sand should sink through water to bottom");
assert_eq!(
sand_at_bottom, "sand",
"sand should sink through water to bottom"
);
}
#[test]
fn fire_does_not_ignite_stone() {
let mut s = setup();
s.perform_action(&AiAction::SetCell { x: 104, y: 110, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "fire".into() });
s.perform_action(&AiAction::SetCell {
x: 104,
y: 110,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 110,
material: "fire".into(),
});
s.step(30);
assert_eq!(s.get_cell(104, 110).material, "stone", "fire should not ignite stone");
assert_eq!(
s.get_cell(104, 110).material,
"stone",
"fire should not ignite stone"
);
}
#[test]
fn fire_does_not_ignite_water() {
let mut s = setup();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 104, y: 114, material: "water".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "fire".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 115,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 104,
y: 114,
material: "water".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 114,
material: "fire".into(),
});
s.step(5);
let cell = s.get_cell(104, 114);
assert_ne!(cell.material, "fire", "water should never become fire, got {}", cell.material);
assert_ne!(
cell.material, "fire",
"water should never become fire, got {}",
cell.material
);
assert_ne!(cell.material, "wood", "water should never become wood");
}
#[test]
fn water_does_not_flow_through_dirt_wall() {
let mut s = setup();
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 109, y: 120, w: 1, h: 5, material: "dirt".into() });
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 9, h: 5, material: "water".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 125,
w: 20,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 109,
y: 120,
w: 1,
h: 5,
material: "dirt".into(),
});
s.perform_action(&AiAction::FillRect {
x: 100,
y: 120,
w: 9,
h: 5,
material: "water".into(),
});
s.step(30);
let right_water = s.count_material_in_region(110, 118, 5, 8, "water");
assert_eq!(right_water, 0, "water should not flow through dirt wall");
+113 -21
View File
@@ -1,5 +1,5 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
use verbatim::ai::GameSession;
fn setup_empty() -> GameSession {
let mut s = GameSession::new_seeded(42);
@@ -11,48 +11,140 @@ fn setup_empty() -> GameSession {
#[test]
fn lava_flows_down() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 3, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "lava".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 111, material: "lava".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 115,
w: 10,
h: 3,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 110,
material: "lava".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 111,
material: "lava".into(),
});
s.step(10);
let lava_count = s.count_material_in_region(103, 110, 5, 6, "lava");
let stone_count = s.count_material_in_region(103, 110, 5, 6, "stone");
assert!(lava_count > 0 || stone_count >= 4,
"lava should have flowed down or cooled to stone, lava={} stone={}", lava_count, stone_count);
assert!(
lava_count > 0 || stone_count >= 4,
"lava should have flowed down or cooled to stone, lava={} stone={}",
lava_count,
stone_count
);
}
#[test]
fn lava_plus_water_makes_steam() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 100, y: 99, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 102, y: 110, w: 4, h: 3, material: "lava".into() });
s.perform_action(&AiAction::FillRect { x: 108, y: 110, w: 4, h: 3, material: "water".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 120,
w: 20,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 100,
y: 99,
w: 20,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 102,
y: 110,
w: 4,
h: 3,
material: "lava".into(),
});
s.perform_action(&AiAction::FillRect {
x: 108,
y: 110,
w: 4,
h: 3,
material: "water".into(),
});
s.step(40);
let steam_count = s.count_material_in_region(100, 100, 20, 15, "steam");
assert!(steam_count > 0, "lava + water should produce steam, got {} steam cells", steam_count);
assert!(
steam_count > 0,
"lava + water should produce steam, got {} steam cells",
steam_count
);
}
#[test]
fn lava_ignites_wood() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 104, y: 114, material: "wood".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "wood".into() });
s.perform_action(&AiAction::SetCell { x: 106, y: 114, material: "wood".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "lava".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 115,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 104,
y: 114,
material: "wood".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 114,
material: "wood".into(),
});
s.perform_action(&AiAction::SetCell {
x: 106,
y: 114,
material: "wood".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 113,
material: "lava".into(),
});
s.step(15);
let wood_remaining = s.count_material_in_region(103, 113, 5, 3, "wood");
assert_eq!(wood_remaining, 0, "all wood should have been ignited by lava, got {} wood cells", wood_remaining);
assert_eq!(
wood_remaining, 0,
"all wood should have been ignited by lava, got {} wood cells",
wood_remaining
);
}
#[test]
fn lava_ignites_grass() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 100, y: 114, w: 5, h: 1, material: "grass".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "lava".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "lava".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 115,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 100,
y: 114,
w: 5,
h: 1,
material: "grass".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 113,
material: "lava".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 114,
material: "lava".into(),
});
s.step(15);
let grass_remaining = s.count_material_in_region(99, 113, 7, 3, "grass");
assert_eq!(grass_remaining, 0, "grass should have been ignited by lava");
+86 -16
View File
@@ -1,5 +1,5 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
use verbatim::ai::GameSession;
fn setup_empty() -> GameSession {
let mut s = GameSession::new_seeded(42);
@@ -11,19 +11,53 @@ fn setup_empty() -> GameSession {
#[test]
fn sand_falls_down() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 115,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 105,
material: "sand".into(),
});
s.step(10);
assert_eq!(s.get_cell(105, 105).material, "empty", "sand should have fallen from y=105");
assert_eq!(s.get_cell(105, 114).material, "sand", "sand should be resting on stone at y=114");
assert_eq!(
s.get_cell(105, 105).material,
"empty",
"sand should have fallen from y=105"
);
assert_eq!(
s.get_cell(105, 114).material,
"sand",
"sand should be resting on stone at y=114"
);
}
#[test]
fn sand_displaces_water() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 100, y: 110, w: 10, h: 5, material: "water".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 115,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 100,
y: 110,
w: 10,
h: 5,
material: "water".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 105,
material: "sand".into(),
});
s.step(20);
let sand_at_bottom = s.get_cell(105, 114).material == "sand";
assert!(sand_at_bottom, "sand should sink to bottom through water");
@@ -32,20 +66,56 @@ fn sand_displaces_water() {
#[test]
fn sand_piles_on_stone() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 104, material: "sand".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 115,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 105,
material: "sand".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 104,
material: "sand".into(),
});
s.step(15);
let count = s.count_material_in_region(104, 112, 3, 4, "sand");
assert!(count >= 2, "both sand cells should have piled up, got {} sand cells", count);
assert!(
count >= 2,
"both sand cells should have piled up, got {} sand cells",
count
);
}
#[test]
fn sand_does_not_fall_through_stone() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 110, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
s.perform_action(&AiAction::FillRect {
x: 100,
y: 110,
w: 10,
h: 1,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 105,
y: 105,
material: "sand".into(),
});
s.step(10);
assert_eq!(s.get_cell(105, 109).material, "sand", "sand should rest on top of stone");
assert_eq!(s.get_cell(105, 110).material, "stone", "stone should remain");
assert_eq!(
s.get_cell(105, 109).material,
"sand",
"sand should rest on top of stone"
);
assert_eq!(
s.get_cell(105, 110).material,
"stone",
"stone should remain"
);
}
+69 -12
View File
@@ -1,5 +1,5 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
use verbatim::ai::GameSession;
fn setup_empty() -> GameSession {
let mut s = GameSession::new_seeded(42);
@@ -11,32 +11,89 @@ fn setup_empty() -> GameSession {
#[test]
fn water_flows_down() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 95, y: 115, w: 40, h: 3, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 110, y: 105, material: "water".into() });
s.perform_action(&AiAction::SetCell { x: 110, y: 106, material: "water".into() });
s.perform_action(&AiAction::SetCell { x: 110, y: 107, material: "water".into() });
s.perform_action(&AiAction::FillRect {
x: 95,
y: 115,
w: 40,
h: 3,
material: "stone".into(),
});
s.perform_action(&AiAction::SetCell {
x: 110,
y: 105,
material: "water".into(),
});
s.perform_action(&AiAction::SetCell {
x: 110,
y: 106,
material: "water".into(),
});
s.perform_action(&AiAction::SetCell {
x: 110,
y: 107,
material: "water".into(),
});
s.step(20);
let water_near_bottom = s.count_material_in_region(105, 112, 10, 4, "water");
assert!(water_near_bottom > 0, "water should have flowed down to near the stone floor, found {} water cells near bottom", water_near_bottom);
assert!(
water_near_bottom > 0,
"water should have flowed down to near the stone floor, found {} water cells near bottom",
water_near_bottom
);
}
#[test]
fn water_spreads_sideways() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 95, y: 115, w: 50, h: 3, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 110, y: 111, w: 1, h: 4, material: "water".into() });
s.perform_action(&AiAction::FillRect {
x: 95,
y: 115,
w: 50,
h: 3,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 110,
y: 111,
w: 1,
h: 4,
material: "water".into(),
});
s.step(50);
let left_count = s.count_material_in_region(100, 110, 10, 5, "water");
let right_count = s.count_material_in_region(111, 110, 10, 5, "water");
assert!(left_count > 0 || right_count > 0, "water should spread sideways: left={} right={}", left_count, right_count);
assert!(
left_count > 0 || right_count > 0,
"water should spread sideways: left={} right={}",
left_count,
right_count
);
}
#[test]
fn water_does_not_pass_through_stone_wall() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 95, y: 115, w: 50, h: 3, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 115, y: 110, w: 1, h: 5, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 100, y: 110, w: 15, h: 5, material: "water".into() });
s.perform_action(&AiAction::FillRect {
x: 95,
y: 115,
w: 50,
h: 3,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 115,
y: 110,
w: 1,
h: 5,
material: "stone".into(),
});
s.perform_action(&AiAction::FillRect {
x: 100,
y: 110,
w: 15,
h: 5,
material: "water".into(),
});
s.step(30);
let right_water = s.count_material_in_region(116, 108, 10, 10, "water");
assert_eq!(right_water, 0, "water should not pass through stone wall");
+268
View File
@@ -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");
}