From a5436897ed44c82cd23661547d0465dd8917c742 Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 21 Jun 2026 22:27:20 +0300 Subject: [PATCH] wip: current project state --- .gitignore | 1 + AGENTS.md | 31 +- PLAN.md | 36 +- WORLDGEN_PLAN.md | 164 +++++ assets/shaders/cell.vert | 5 +- assets/shaders/graphics.vert | 5 +- benchmark_results.json | 17 + capture.png | Bin 39164 -> 38090 bytes src/ai/mod.rs | 10 +- src/ai/protocol.rs | 6 +- src/ai/session.rs | 4 +- src/ai/spectrum.rs | 20 +- src/ai/state.rs | 6 +- src/entity/item.rs | 18 + src/entity/mod.rs | 2 +- src/entity/player.rs | 7 + src/game.rs | 496 +++++++------ src/input.rs | 14 +- src/main.rs | 57 +- src/physics/collision.rs | 6 +- src/physics/projectile.rs | 15 +- src/render/capture.rs | 10 +- src/render/graphics.rs | 32 +- src/render/lighting.rs | 30 +- src/render/mod.rs | 4 +- src/render/terminal.rs | 4 +- src/render/vulkan.rs | 31 +- src/ui/mod.rs | 3 +- src/world/cache.rs | 141 ++++ src/world/cellular.rs | 414 +++++------ src/world/chunk.rs | 43 +- src/world/chunked_grid.rs | 798 +++++++++++++++++++++ src/world/grid.rs | 110 ++- src/world/mod.rs | 3 + src/world/worldgen.rs | 1226 +++++++++++++++++++++++++++++++++ tests/chunks.rs | 2 +- tests/collision_robust.rs | 184 ++++- tests/determinism.rs | 101 ++- tests/entity_damage.rs | 110 ++- tests/entity_movement.rs | 58 +- tests/integration.rs | 61 +- tests/large_world.rs | 51 ++ tests/physics_acid.rs | 72 +- tests/physics_interactions.rs | 350 ++++++++-- tests/physics_lava.rs | 134 +++- tests/physics_sand.rs | 102 ++- tests/physics_water.rs | 81 ++- tests/worldgen.rs | 268 +++++++ 48 files changed, 4583 insertions(+), 760 deletions(-) create mode 100644 WORLDGEN_PLAN.md create mode 100644 benchmark_results.json create mode 100644 src/world/cache.rs create mode 100644 src/world/chunked_grid.rs create mode 100644 src/world/worldgen.rs create mode 100644 tests/large_world.rs create mode 100644 tests/worldgen.rs diff --git a/.gitignore b/.gitignore index f982fe3..954a94b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ Cargo.lock graphify-out/ headless_dump.txt headless_dump.png +/cache diff --git a/AGENTS.md b/AGENTS.md index 67703d6..58c943c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 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//`. 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 diff --git a/PLAN.md b/PLAN.md index d87cf51..d5f108f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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` 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 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 diff --git a/WORLDGEN_PLAN.md b/WORLDGEN_PLAN.md new file mode 100644 index 0000000..12f7c7d --- /dev/null +++ b/WORLDGEN_PLAN.md @@ -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//depth_/` +- 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 diff --git a/assets/shaders/cell.vert b/assets/shaders/cell.vert index 4a63a6b..38d4c90 100644 --- a/assets/shaders/cell.vert +++ b/assets/shaders/cell.vert @@ -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; } diff --git a/assets/shaders/graphics.vert b/assets/shaders/graphics.vert index 44e9140..bbc59a2 100644 --- a/assets/shaders/graphics.vert +++ b/assets/shaders/graphics.vert @@ -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; } diff --git a/benchmark_results.json b/benchmark_results.json new file mode 100644 index 0000000..6e983b5 --- /dev/null +++ b/benchmark_results.json @@ -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 + } +} \ No newline at end of file diff --git a/capture.png b/capture.png index f6e9933506edf24627d6c890439823f153cbb255..9c32b6380cc62add8c77462fc5e067e2367fe69a 100644 GIT binary patch literal 38090 zcmeHw3wTpizHdu~q7^#GNR>)+M&`(os&S@3!G`W}l-uh;tmisd<XUsv>ZSMO76U}&TOuux#$%Zh2iPQykD|x zoZR&6q2t{L%KiS)>X&E-dTq-7V#YT0cW+-?nzC};-~EHK{v%XpW=z6k+F7*jjrBtQ zdR>@;E0Tp)+m;I=3;M$oC)ipgg(BlQf$afZF=H{+{M;8d6!avARSzD~PPCn6`TKS0 zl(S?#i_S11iS{r-Sb<5E7-MUXEV$QrTIE9rZHxJ~D$8(+u39|sCBqGT6tO6KZ>YiX+LEYv=A|t(Kj!wxn?$>S8{nGistH=6HAdjX5h0V6DMkWFu8sBX&%k; zg_?mS+Q~U5r}7`8`4ZQ4$)o~P6#a-bzegpdby2iw)_UFF-9?Oo*MBjND9*-O|98U6 zTwU(14awtVm6mZVTdKZv!KCQIa{ArD=oNo&Bq=K`ip`@v*dtw|!f-e;@myA3)4{0q zb&0lL8;>m)hR6zy&*|2vQbS^#y6rV>nd%VH;E7p9b&Hv=+0u`qZsE&|wDvvXyHb0O zYJO?kqqQ&6;ge)E5I++5ss&jl`Z=0c;`9@h%GsB&4@5GJEbD zr?f1SoOfaFri>Jk*x-4NkAb1_0WG=s9Kw0>KSHa>4=J*AWKr9Z&|^7W_X&S-!uuHCBNw}9yceHas4Fui zyUd)Zf8PRYe*2QFKI_@iPGVpcTKpGnuMf(g36XiI6JkigOnAyI;XxW_IP z{tZV$8f;lU^2vl3K5NQtfpXn7wBipTB>J#jC*LpB?Mgp1zOT}dDpzcur7BWY86V*k z1Ddi5Ez94sns$>wboKb{i9%aO1sYGBLm#Ifs+e}nP@NBtm2>~fBRdJ%T>{5iEHsiI zI1KKr0pJ}m3vWIxYqjT|iIbo8mAETn)B!QF>GjplzK#XW*;@6A@gz}rt9d9ipk>-T zZMaEkDrzW*FdnoP{=$fltj_Ds#Le`%@Kp2T{xU=LqYmfckL?DA+HcuZ%ZZmxD*K~JIJl-cZ0l$Shmnq)r7UsU&D>?=HcBm&ax!`(1-qa$UmE33Kwjd?W#KH?4o2o@_GF|Ca z{jtmwEGs}JNxyQYyfo(pK~BAD9HC%!t9>f_h`^=IH91Q!xHzitCbd| z340(H^{d+@SUAE~yr@;&m58FzL)dv9OHr>Hoo$KbLs|Y~XbL}YSk`u+oR3r2H&)GJ z*<{yM>sp^QH{#jRGGEAf4vWf`#jkhvRfZqy+|}cEut~26!)udTo3kU|2Ez;WJ0M=S zjXqasZ4O(JRllyu0G<(gsG{>J-SB~2;d$I=SV`eg%|=V z#^|{n=TzNg((Zgu#-r49W@xtS^+PA=AGDPIFDif-LPo7Q3#bXf6bf$;YCZvg+J;tP zml^ND3m;3wLO{=s=5=ms00jCp>Xt_-)=EFlt6uX8h0Pu&0j(KnNh9hY*Q|#FTIH7^ zxbS&}KL{8e!_US45~XgU)ga(a&Xu5eEbt)zJvd@(XO@W zNv3W`-BV;akFo4_1vg)=!0{+l!GA7f38pY?V^w{AKgV57E))`6Ks!_IpNMzp^)h6O zAJ!u%RQLP(m zSN~4S$rz8bvFy3+2{q-_-O*;cVP~8wgtl#BE@t5*?J?gZ&3ClX)CnLn4uj0S#EGnx zZ@(Uiou>$NS&DUO!MdEZTU1etQZP$liBOId`4}o6pWl%po#C!eT89eqT2hcN9}aGD zKa?6gMgm$m7*i(B58MZU8J)8USugfH;vbKOxEqZzuv6mYSd9dTCIArSVIb<1f>SI^ zIe|5^J{f(Ra(inS-TQEwa$zXufdl(g4nEdB+9SH1FV*PjHbp3gfQ|!E_5Tm#;AhR5 z0g);7VCk^TeGOpLYV{|x*Q?gD^>04aet=dl^YJ!MtqzDbil;AHM2f1gMEi!A3rmH& zg#4%2xnEb-_%Y(~n^6+MtLzJ*SKc|BT;%|ZoQ2EEIXvyv0S=U zKX6$vFqvCSe$ZD%|yB_`#J4W`TQJ*0G>) zAY2{BcKOqAukB`JUbqgQJf=;dFUUd#uBQz(W;3R&$$-z>8ho!wqQ6Zr?#2|mZ|$>Q z)AqknLr!xAcnGT#bC5bm%sq}hwKmn)Q@wGOpZWt1nFk**-uY7iWiFat9BS-S6nMg6yu?ArO+czr6kZ&_YCF7kQ zp?S6G7Dwt=gjlhmyju5Ck<9+JuwC5d5xLHNCqEa!I0h$=>T$S>W601r7UtkYkdc)i zde2f?HGzF|a5017+QMjKng8lBLPeib7bxHqpQJKNsmlP(*=~b;^Bx-Be z`pBxm#`XBJK_lA7g`E|I~9^m8ERK~G7&zjs(v{W zjx*W;>zZqH^yu9T@Vf(aNizRg-MFKfBmuUF)3Ii=952VzyQ(dkNU(`vsS)|D_lavn z%Hv0k&L))z=bj*}kN5OW6;b)lC_DSLiv9>aH^Ruuqz)(WfhJ-Jg=+H*;n-$e5@Q|R zPogAZ)V{pI-}E-Oc?mY%K0KB4`ro^5{qo5ro4~x#HZiojmWqx0Yw8NVj~X`wBp$a6 zA%2?Wz`n!(EB-No;Dr%iQS}7_9rMRt-ltZFe@^`Vh;eS2w@eXRV~x zY}4!KYWwCG;SgG|<@7yhx`#jG7j}QE^oW)@-&keI2dBE4Obgt3n(P<1jaoykgH}oI zLy&V0ywYdI$XMfn$`Im7Mi1NlZd5Yjt3(uG%H_uQa_mopD<9C!k$Rz2t{@&KC2}h6 zB-m3%--soyZ84a4NwTNgniccwA_hH&X7f!gdKe1ZzB$(X({#?g!W*%C?HlVQl-0>C z<_Av4u&zYo_Yx?^{M?ZFfy&{tx_*HkLq!gBgCJC)WrUT8L98sUr_dJ%Xoc3bC5u}u zwa&VkvxrjK-z_V` zhgGlP!wRiGqRPId;uTNJxDD7P~PQ{qHL>br9>aliYo$oy6v>s zvCu8yz3Tp0^<>-$IVS;(to996Bo%WoJ)elX*5M{$va}c_jf!ws`Q`jvoRmdeJ${au z551czJY1LM(@`L&v;M^c)c$NjBZEqqgn4-s^YX_PmM@?gXAg(^`_}!#RT-i+od>mh zaCPl)!b#MFBS5J;0#tNUTEL7-Gr6k%Nx!#ZF z8d`B{l96AY5#QD$YQKr{LR6#@yh@XV3T zX}G0Vn)8Ixc#ra&i@(K6qkbnU&6#If-CD6Ct9cTXt(x)pKvZm#SWj#FBWucNTP)-O z#?MLxm!RR(s5ptF;@sxCEJmi*U&x(i;h_yK@3OFo{pz>>-7(+ zsP|E9s>vXzr#DiCrdggO*k!Q#2*~XmD0|_}&P^~k(>7RU9DU{#GX7$i-GCnJx5c4H z8(~B+qAO7}Wb4Dl-_Vgo8+dN__ebn+!fZ323{%|T`=q?a#>2NLNgtM3o++KrvKhcd zId4|~S8Vk>mQ`?Fk}SYXpTA)df0AlCnk(hoLH*u9jzUHYofu2Dx`7igrTIs8FZv^! zph0@`G_E9R=&}Po!O8 zT?Uw9vt$BJxZv((>#!yS+W{ zg+2t6dVnCvCji9pfitBirm1y@O@L5%scVm|AFzh^_v=BNvTHeF_h?jO+{4##Wz9hY74Mh3g4u09D%e zZR+Qmq;*$t{@IgR-Ss)d53Okq%wPp>3jL%A5!ls!%rJArD?ow%PUrsV8k}NdaLw(T8PF ztL$BKNo0$?!gZ+(Iw7b*-FktU0Fj2b8`fMy34wE{fYA4|quA&6Ge-&b!fF~{8DQ@G zeh-&l^yT{a#Ep9WV1nWj7|SRtS?=6iBBDP2kgwaKg<#E^o!FZDHVrqYK;Km0)xLMK z8*7hk^LlDIZ^^7SoxuK_8w;~rbn|lc=>%x1U1VKP4l7l#bxh*bFr#Rq#4^or(QJ;R zthMe;!a_I1R&(XRXO{vcdczM2y>rbnaNPa9xuj<}XGgJq#!6}bUZ138Vvz)%XuY!m zxsKka$}7(9wA*(ooUfBA4A!*)8pXL08h2}Ma{++yQ*JOOZEB06>wYnR4SF~OEUm!( zo`6r_!veQH?_5xSzcadlH2Z%@Rw)Jc6usSkg+hh?C2iE*cWz(`Y@(~G*Ua#7=$*ds zhArQvwR|W<-B3F?^bB}v#V!8x98FLnOaVdK1&Q)pPbl`J)r)5NqM5@;Xn(lh- z796?wq+>8+QXenc48gfl4x4Dw$D~!qm^P<$LXLGPjZ}Ex#s@Xr9$2i=si!wWP-gn> z7x!I*nXCaJKVs^B!I)%tT4}@Zyc!a#J4%_U0CPMidmF=CfjPU?ZvRe!rVQiY$qVY~ z0CTR>0+0`6X}-T9bIr{81X1h111UKbDIIqE5ytk6N5km5Pd8vu|N0$ETD@}Sfjb5S5|90%N;G$ z^f4KdJ?eA!c`U+LV>GZ~af0yOscb_j^;dn`;lG-J@eW{oi$1Mlg{i>B9TZW_U}UzD zdu*P>Q-3%FC7xs7RirQ@sQ=kbIdJXZ*0@94d4Fo(Qph=P3Hd*}gO$&3-t1@FbaTJ) z34Uf(e-d*q?BKAKqYu&Z#r4s{nSME+6nJ?>U{ZckIQ|jCL}aXP_S^R2TD)y?U%NfG zKC&=Zu6UKw+jG0)GWGT@=XrD7y%6ncW8Q((KJOJ1S9R-c*rRk!%fm|ll7rEnT6}Cu zq8QJUSqy{yLg#EqNBJG}DLf@u#s3Kq-B3N$T;k2HzIc8C%SIOrl$*6J*^G&WO-e&M zlz8DWlcO|O+E?X#7djGmFwO@ymDw2DnMMWb5J17=VI8TWl?-%UE>%kO2hjZJ_&#U{K*# z2Da9p8$ScP`0t||<*-`q~27THa&PVvU+Xmsr z1?GEZ*&60M(_pjnrK*#6^K&op-z|bC^_Jp^soC+V*?`=8tW87t$`~!H-R!vArSdC# zsk}{Saq$zBC9oMm#yFHV_UpriAg{~(3=t(UURJ_dv~;n{Iob~& zWuv8<)+JhEzot6arZy&1q}gamD*wMy0q|MWEgKH)C?m#{o-S9+Voq#D6O)|TMy2U* z3eyMR+Eg24i*s34EY&C>GW2Rsn2!)C5=Btw42gEwu~H_znGMsY{X!1>zgUbQ(h8l+ zn3HDnMs4m)xq@MwGH?uAed2q1{U!tdkX#XOy9o`kS0iKPiXAhy;T7SHf;xJm<*Qb4 zTSK+OYHxTYG8U)rQS~Ja&8N&+`i0q~*lx~gUIK`#$qGk1rF~KK?sDDZnt@($HvEGg z5Qe4siilmat;HQu&!DFlQTV; zt@aAD9FT{AM((L31{`Ikfcq`v_QuOvNap?|cEN07@^On2-n+nEqc0LAqU~6p^hVZc z6SUZK`wfZ?$OqxU7`kFi-Dx?}KS7yHHqH_j@s#H6?krkgSi zyl?{(qSxOoR>O8FXs%9^I0m7)YAvQNEk)TbBJ1M7?b|!J`#Ro4odbtIOB{afH6)es zH#7LSg^s=1V7ta^PG~$a?O^on#ON!!>8bFV2axsBmXK*RvT-x?URt?@F+i7+~AET*+CuB-DtW%27y;TOAj z2OXqyK-8D=``Qnng3Y$_Z z5-&^%mD!F${MfF2?F@5<41~<0mU6)NoKwXX03}=Z(2R&fEW2|NI&i8A>&^|&AxK=g!=k2-wUyecD6w@yj8C$p-aTNH6jYq3^ViH zc9x%wrbutq#ayH}c3m}?x!tZK#Ud&#wdF3B-I+4~ggI-FeWIT^F||5RL6;;|y~09- zRciE1ZBJEaI;deedQ3&t5bJOU+ZKN-&LKODuNJxj93uSW=;l9XroW`Ij zF$LBHBUd$$M8Ot1oMz-oHM+fG*#6Q0@4(6BPoX8PbU38)<8hIkL=Q|u+re5RaXW}l zb6&I-PGrN!Vt4SWpn1Us<_v_5VjMQIYUq6w>Y+Md|AdSFHLRAp`8d1?3MWxJq#r|@ zFLxj*!z5_Q5XO7&kEQ)X*D+s8tASc5TW0U3}c6 z44Gap>EmCAnUH?*!^al4{x24zuOWY=oqS=imV7!s`fmR-qav1X@C}niZ)n}b&t=#3aaKr zru4}GO5EWYZm^~|CW*Qx0fcd9#U1pKgNhYd*BH%-#!E1q&8-NwD^8>T6&F4h8>GeO z=|2NckeA+Uly}&3u?&=^Q_2PH^v9rtQhhSK8SLp57R2sNd}}*;KAGF~3_mx<@*ky8 z?YA~O1h3NT-a8LA9b2}iLwCd+rAj6#Mq*oG3}k}2+I6vL3i*Na>qE=!4G(<(D>Oa1 z(iqL9-`o3Vx}<&&;7r6!KKmDF26?XJD|nV%kyLmUR!(ov<-d`xg&jV#m|iHnR=e7F zmGkI3$2o|lKoPhGrikREX;th7``BFhmscN^{{V`{-hj)0q{1pubm=nEpo0V~qlw{? zgf=^78{SJrgq*y#_Gp8dYg%V$=F%Knt+`KvoDG$kZXH5wOv*HXQ}yyO;5pPa;xib# zZN-hV4mldIu|rllHrEL@6dc~<(|h7uw{1bsrS_%IgFapB-pTd`+g7XeNi;)hYA_6^ zQoPbxoB$0ad>LF&EPD!O0Vo(A`hM z9fWdzQ{Z$XaANm!Cr=S8Y1a}@GUwBbhDJVmaV+X+Octa8EiTB-SWJY2eiJlke=LO* z+r3zqiV7NZpm?()=r=*X3Hr_O#IGAM2yEbv#E;t49R!;o*bK*>VM}s*7`%cx2sS~m z34)D3%m(wcA&Uscn_#LQOgVzpTCf8Z%%OuhbTEhZuO5bzCKT-11k0#k+b&p12P^4d zB|RJz{DLKX)GnAn-MI;r<3kB|zwQEMaIKf2t%i_l&a8>F*;w1;;K`ssg2oVZgdj2m zQ85@1g3&M-4TI4z7!8BbFc=Mk(J&YdgVFFGi-v)>Kny&p=-(fxLGsgXLHN8()bVId zdexcqFvgnC)SJySw5C0r*wn0bscQ@_jU-HIny#G0X$-D;#_3Aa_vECRgFC9`32%6= zfIIUUYopJjnD&I~wuIrAUHI$^fz2U112aEc=<^Ij;nC||QiD}midK1lMCX(=%$F=a6g zKxd~EMlhU1E4}2GI@KfP$H0j*=|gl3zt70;HE^SSfxSc9Vp0su=hG7M>L%$rQu|JD zJus+io;4o}il0do$g+gj+xBJ6PX}z-f1YgrOLE6;X&R%Od;-5{DDajg_=qBQ3Q?KD zv0v7y)n{lr%ZtDOt+)LaHpA{s?JX=ywy$rPP<^ve8g2VaWKITr#NF|Ywo|I;4^`GP z?Zwr-stRpcK}|Y*Wbd-dHIMCjDN%P6UQ7XZlsXH~hgZ!*AN$;5HZRf+Ho|urJHk?< zZC^;789H*q3*vzcrasF-#<&ty*eEe8t&a+f-;2t~-J^L!>X)kAQe~%C5T3rUV|sL$OZc2GX2yug zZ!+LBL^SY0Uwm4)%Oj`Ks9Tt$B24y#5p0r^@cCa4-)`B35#}QXF~2^~CSo5FBHQI1 zk_dki5qIF%qWmHCOTzgA7iRi)Q#oizJxhtM^aFTwjwM9EH%r~h#DnI#b>njLf(0_# z+iA~780SgM*P0Av((b(#@D<4VlrXHAkzX<;6ACD1NbMeh*jb|W8f*bsj7Ns0XunrE z@OewH9uN$s(MXJhBF}MouaQRv-iGH$RIZH$SE$c|4?;Uu@dx31q>0mWN>%o^bbzZ1 zZ->VY0};#+U0B)h+7B<)JBT*W%fv0*HeN$4NJuV%FZg2fCiKB4GX3qtPs4UAxk0WV zLo9HsA@IxKgTMxF9I5#7b}`N+=zmLMtgZ>KYJ{L9aTb9$>Y4#`z7g0mbYYraj8eVf zfNSfs^8wdJTp?FP=BOILy&EGrKs9px3);ajW>P^-rFxz*gEBUfXPbB9rO;N^)^qPZkm? zjriG|(@S5I0TWO}7?ENsCKX_A&~)$cRww3OM+iOPL&K2R@U0UFe^Ixv2#Q8X$t}mr z-XTYd8;l!^!s_%$-TjEb-s%5+x}ha1XfY8Ya|UZO%uBvo4L%a2)gB6mhDLe#@_LtB z`IuXNCZQMhe|u$K^}E0;9^j)verXK(iFSC6Cw0yUir~BH*uH%11wORxkw4K>4JJB_ za8=xiuddMIGd`ADN_%b6@7OI;!n#QCkCL55wCVE*A5C{sby&Vb7EP;tJV1DZ2$>_2 za~n29L~Koj8#lvt8TsLH0^&M9J$TuYPBvZJ|MtemtRe8f=N7&2?4jSk_TK*m>rnt} literal 39164 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 diff --git a/src/ai/mod.rs b/src/ai/mod.rs index 935e491..da27f92 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -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}; diff --git a/src/ai/protocol.rs b/src/ai/protocol.rs index ff7b883..1b5f8fc 100644 --- a/src/ai/protocol.rs +++ b/src/ai/protocol.rs @@ -289,7 +289,11 @@ fn handle_command(cmd: Command, session: &mut Option) -> Response { "entities" => crate::ai::spectrum::Spectrum::Entities, "density" => crate::ai::spectrum::Spectrum::Density, "velocity" => crate::ai::spectrum::Spectrum::Velocity, - _ => 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 { diff --git a/src/ai/session.rs b/src/ai/session.rs index 726f866..432137c 100644 --- a/src/ai/session.rs +++ b/src/ai/session.rs @@ -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 } diff --git a/src/ai/spectrum.rs b/src/ai/spectrum.rs index e595aa7..3c8d81c 100644 --- a/src/ai/spectrum.rs +++ b/src/ai/spectrum.rs @@ -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, diff --git a/src/ai/state.rs b/src/ai/state.rs index c0924fc..b77a807 100644 --- a/src/ai/state.rs +++ b/src/ai/state.rs @@ -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, diff --git a/src/entity/item.rs b/src/entity/item.rs index 25fa98a..52ac9c1 100644 --- a/src/entity/item.rs +++ b/src/entity/item.rs @@ -211,6 +211,24 @@ impl Item { } } +impl ItemType { + pub fn from_name(name: &str) -> Option { + 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, next_id: u32, diff --git a/src/entity/mod.rs b/src/entity/mod.rs index 3b51c46..0b12d2a 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -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; diff --git a/src/entity/player.rs b/src/entity/player.rs index a224262..543b59f 100644 --- a/src/entity/player.rs +++ b/src/entity/player.rs @@ -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); diff --git a/src/game.rs b/src/game.rs index 539f827..1f28c60 100644 --- a/src/game.rs +++ b/src/game.rs @@ -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, } 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; - } } } diff --git a/src/input.rs b/src/input.rs index df63251..1fa54f0 100644 --- a/src/input.rs +++ b/src/input.rs @@ -82,14 +82,16 @@ impl InputHandler { pub fn start(&mut self) { let (tx, rx) = mpsc::channel::(); 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, } })); } diff --git a/src/main.rs b/src/main.rs index 54e2511..2267258 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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(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::( ticks, output_path, "ascii", + biome, ), "graphics" => run_benchmark_inner::( ticks, output_path, "graphics", + biome, ), _ => { eprintln!( @@ -557,7 +567,12 @@ fn run_benchmark_mode(cli: &Cli) { } } -fn run_benchmark_inner(ticks: u32, output_path: &str, mode_name: &str) { +fn run_benchmark_inner( + 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(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 = Vec::with_capacity(ticks as usize); let mut render_times_us: Vec = 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)) diff --git a/src/physics/collision.rs b/src/physics/collision.rs index 36f84e4..04b362a 100644 --- a/src/physics/collision.rs +++ b/src/physics/collision.rs @@ -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; diff --git a/src/physics/projectile.rs b/src/physics/projectile.rs index 33669bc..92e021b 100644 --- a/src/physics/projectile.rs +++ b/src/physics/projectile.rs @@ -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, ) { diff --git a/src/render/capture.rs b/src/render/capture.rs index 8bd1322..270cd45 100644 --- a/src/render/capture.rs +++ b/src/render/capture.rs @@ -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, diff --git a/src/render/graphics.rs b/src/render/graphics.rs index 1991da7..eea8601 100644 --- a/src/render/graphics.rs +++ b/src/render/graphics.rs @@ -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::()) as vk::DeviceSize; + let sz = (grid_count * std::mem::size_of::()) 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::()) as vk::DeviceSize); + .range((MAX_WORLD_W * MAX_WORLD_H * std::mem::size_of::()) 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, diff --git a/src/render/lighting.rs b/src/render/lighting.rs index e3804fc..003568c 100644 --- a/src/render/lighting.rs +++ b/src/render/lighting.rs @@ -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 { } } -pub fn gather_sources(grid: &Grid) -> Vec { +pub fn gather_sources(grid: &ChunkedGrid) -> Vec { let mut sources = Vec::new(); let w = grid.width; let h = grid.height; @@ -85,7 +85,7 @@ pub fn gather_sources(grid: &Grid) -> Vec { } 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 { 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); diff --git a/src/render/mod.rs b/src/render/mod.rs index 7df71ef..b26277e 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -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, diff --git a/src/render/terminal.rs b/src/render/terminal.rs index b42fab5..17dabeb 100644 --- a/src/render/terminal.rs +++ b/src/render/terminal.rs @@ -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, diff --git a/src/render/vulkan.rs b/src/render/vulkan.rs index 93fa870..13805b5 100644 --- a/src/render/vulkan.rs +++ b/src/render/vulkan.rs @@ -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::()) as vk::DeviceSize; + let sz = (MAX_WORLD_W * MAX_WORLD_H * std::mem::size_of::()) 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::()) as vk::DeviceSize); + .range((MAX_WORLD_W * MAX_WORLD_H * std::mem::size_of::()) as vk::DeviceSize); let li = vk::DescriptorBufferInfo::default() .buffer(light_buffer) .offset(0) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7d5ebd2..98f9f75 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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, diff --git a/src/world/cache.rs b/src/world/cache.rs new file mode 100644 index 0000000..2a23082 --- /dev/null +++ b/src/world/cache.rs @@ -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, +} + +#[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 { + 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(()) + } +} diff --git a/src/world/cellular.rs b/src/world/cellular.rs index 2d0a098..7a5281d 100644 --- a/src/world/cellular.rs +++ b/src/world/cellular.rs @@ -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 +} diff --git a/src/world/chunk.rs b/src/world/chunk.rs index d44f6e8..990706c 100644 --- a/src/world/chunk.rs +++ b/src/world/chunk.rs @@ -6,6 +6,9 @@ pub struct Chunk { pub cells: Vec, 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), + )); + } } } } diff --git a/src/world/chunked_grid.rs b/src/world/chunked_grid.rs new file mode 100644 index 0000000..e5920e6 --- /dev/null +++ b/src/world/chunked_grid.rs @@ -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, + pub bounds: Option<(i64, i64, i64, i64)>, + pub seed: u64, + pub cache_dir: Option, + 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) -> 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 { + 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::(), parts[1].parse::()) { + 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)) +} diff --git a/src/world/grid.rs b/src/world/grid.rs index b52ed60..ef19150 100644 --- a/src/world/grid.rs +++ b/src/world/grid.rs @@ -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; + } + } + } } } diff --git a/src/world/mod.rs b/src/world/mod.rs index e2f2ea9..92868c0 100644 --- a/src/world/mod.rs +++ b/src/world/mod.rs @@ -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; diff --git a/src/world/worldgen.rs b/src/world/worldgen.rs new file mode 100644 index 0000000..54686ef --- /dev/null +++ b/src/world/worldgen.rs @@ -0,0 +1,1226 @@ +use crate::entity::player::Player; +use crate::entity::{EntityManager, ItemManager, ItemType}; +use crate::world::cell::{Cell, MaterialId}; +use crate::world::cellular::CellularAutomaton; +use crate::world::chunk::CHUNK_SIZE; +use crate::world::chunked_grid::ChunkedGrid; + +pub struct WorldGenerator<'a> { + ca: &'a mut CellularAutomaton, +} + +#[derive(Clone, Copy, Debug)] +struct Rect { + x: i32, + y: i32, + w: i32, + h: i32, +} + +impl<'a> WorldGenerator<'a> { + pub fn new(ca: &'a mut CellularAutomaton) -> Self { + Self { ca } + } + + pub fn generate( + &mut self, + grid: &mut ChunkedGrid, + items: &mut ItemManager, + player: &mut Player, + entities: &mut EntityManager, + depth: u32, + ) -> (f32, f32) { + let (px, py) = if grid.width <= 2048 && grid.height <= 2048 { + if depth <= 3 { + self.generate_surface(grid, depth) + } else if depth <= 6 { + self.generate_caves(grid, depth) + } else { + self.generate_dungeon(grid, items, depth) + } + } else { + self.generate_spawn_region(grid, items, depth) + }; + + player.spawn_at(entities, px as f32, py as f32); + if let Some(e) = player.entity_mut(entities) { + e.facing_right = true; + } + + self.place_items(grid, items, px, py, depth); + (px as f32, py as f32) + } + + fn generate_spawn_region( + &mut self, + grid: &mut ChunkedGrid, + _items: &mut ItemManager, + _depth: u32, + ) -> (i32, i32) { + let sx = if grid.is_infinite() { + 1000 + } else { + (grid.width as i32 / 2).max(100) + }; + let surface_y = self.surface_height_world(sx); + let spawn_cx = sx / CHUNK_SIZE as i32; + let spawn_cy = (surface_y / CHUNK_SIZE as i32).max(0); + for dy in -1..=2 { + for dx in -2..=2 { + let cx = spawn_cx + dx; + let cy = spawn_cy + dy; + if grid.in_bounds(cx * CHUNK_SIZE as i32, cy * CHUNK_SIZE as i32) { + self.generate_chunk(grid, cx, cy); + } + } + } + grid.set_material(sx, surface_y, MaterialId::Stairs); + if grid.get(sx, surface_y + 1).is_empty() { + grid.set_material(sx, surface_y + 1, MaterialId::Stone); + } + let spawn_y = surface_y - 8; + (sx, spawn_y) + } + + pub fn generate_chunk(&mut self, grid: &mut ChunkedGrid, cx: i32, cy: i32) { + if grid.is_chunk_generated(cx, cy) { + return; + } + if cy < 2 { + self.generate_surface_chunk(grid, cx, cy); + } else if cy < 6 { + self.generate_cave_chunk(grid, cx, cy); + } else { + self.generate_dungeon_chunk(grid, cx, cy); + } + if let Some(chunk) = grid.get_chunk_mut(cx, cy) { + chunk.generated = true; + chunk.modified = true; + chunk.was_modified = true; + } + } + + fn generate_surface_chunk(&mut self, grid: &mut ChunkedGrid, cx: i32, cy: i32) { + let x0 = cx * CHUNK_SIZE as i32; + let y0 = cy * CHUNK_SIZE as i32; + let mut surface = Vec::with_capacity(CHUNK_SIZE); + for lx in 0..CHUNK_SIZE as i32 { + let x = x0 + lx; + let s = self.surface_height_world(x); + surface.push(s); + for y in y0..(y0 + CHUNK_SIZE as i32) { + if y < s { + continue; + } + if y == s { + grid.set_material(x, y, MaterialId::Grass); + } else if y > s + 8 { + grid.set_material(x, y, MaterialId::Stone); + } else { + grid.set_material(x, y, MaterialId::Dirt); + } + } + } + let chunk_depth = (cy / 2).max(1) as u32; + self.place_surface_features_chunk(grid, cx, cy, &surface, chunk_depth); + grid.fill_border(MaterialId::Stone); + } + + fn generate_cave_chunk(&mut self, grid: &mut ChunkedGrid, cx: i32, cy: i32) { + let x0 = cx * CHUNK_SIZE as i32; + let y0 = cy * CHUNK_SIZE as i32; + let depth = (cy / 2).max(2) as u32; + for y in y0..(y0 + CHUNK_SIZE as i32) { + for x in x0..(x0 + CHUNK_SIZE as i32) { + grid.set_material(x, y, MaterialId::Stone); + } + } + let fill_prob = 0.42; + let iterations = 4; + self.carve_ca_caves_chunk(grid, cx, cy, fill_prob, iterations); + let pool_count = (1 + depth / 3).min(3) as i32; + for _ in 0..pool_count { + let typ = self.random_cave_pool_type(depth); + self.place_cave_pool_chunk(grid, cx, cy, typ, 2, 4); + } + grid.fill_border(MaterialId::Stone); + } + + fn generate_dungeon_chunk(&mut self, grid: &mut ChunkedGrid, cx: i32, cy: i32) { + let x0 = cx * CHUNK_SIZE as i32; + let y0 = cy * CHUNK_SIZE as i32; + for y in y0..(y0 + CHUNK_SIZE as i32) { + for x in x0..(x0 + CHUNK_SIZE as i32) { + grid.set_material(x, y, MaterialId::Stone); + } + } + let root = Rect { + x: x0 + 6, + y: y0 + 6, + w: CHUNK_SIZE as i32 - 12, + h: CHUNK_SIZE as i32 - 12, + }; + let tree = self.build_bsp(root); + let mut rooms = Vec::new(); + self.collect_rooms(&tree, &mut rooms); + for room in &rooms { + self.carve_room(grid, *room); + } + self.connect_bsp_rooms(&tree, grid); + grid.fill_border(MaterialId::Stone); + if let Some(chunk) = grid.get_chunk_mut(cx, cy) { + chunk.modified = true; + chunk.was_modified = true; + } + } + + fn generate_surface(&mut self, grid: &mut ChunkedGrid, depth: u32) -> (i32, i32) { + let w = grid.width as i32; + let h = grid.height as i32; + let mut surface = vec![h - 3; grid.width]; + + for x in 0..grid.width { + let s = self.surface_height(x as i32, h); + surface[x] = s; + for y in s..h - 2 { + if y == s { + let mat = if depth == 1 { + MaterialId::Grass + } else { + MaterialId::Dirt + }; + grid.set_material(x as i32, y, mat); + } else if y > s + 8 { + grid.set_material(x as i32, y, MaterialId::Stone); + } else { + grid.set_material(x as i32, y, MaterialId::Dirt); + } + } + grid.set_material(x as i32, h - 2, MaterialId::Dirt); + grid.set_material(x as i32, h - 1, MaterialId::Stone); + } + + self.carve_underground_caves(grid, &surface); + self.place_surface_features(grid, &surface, depth); + + grid.fill_border(MaterialId::Stone); + + let sx = w / 2; + let mut surface_y = h - 3; + for y in 0..h { + let cell = grid.get(sx, y); + if cell.is_solid() && cell.material != MaterialId::Stone { + surface_y = y; + break; + } + } + grid.set_material(sx, surface_y, MaterialId::Stairs); + if grid.get(sx, surface_y + 1).is_empty() { + grid.set_material(sx, surface_y + 1, MaterialId::Stone); + } + + let spawn_y = surface_y - 5; + (sx, spawn_y) + } + + fn generate_caves(&mut self, grid: &mut ChunkedGrid, depth: u32) -> (i32, i32) { + let w = grid.width as i32; + let h = grid.height as i32; + + for y in 0..h { + for x in 0..w { + grid.set_material(x, y, MaterialId::Stone); + } + } + + self.carve_ca_caves(grid, 0.45, 5); + self.seal_disconnected_regions(grid); + + let pool_count = (2 + depth / 2).min(6) as i32; + for _ in 0..pool_count { + let typ = self.random_cave_pool_type(depth); + self.place_cave_pool(grid, typ, 3, 5); + } + + let stalactites = self.random_range_i32(3, 7); + for _ in 0..stalactites { + let x = self.random_range_i32(8, w - 8); + let y_top = self.random_range_i32(3, 12); + let len = self.random_range_i32(3, 9); + for dy in 0..len { + let y = y_top + dy; + if grid.get(x, y).material != MaterialId::Stone { + continue; + } + grid.set_material(x, y, MaterialId::Stone); + } + } + + grid.fill_border(MaterialId::Stone); + + let spawn = self.find_safe_spawn(grid); + let stairs = self.find_empty_with_floor(grid, w - 15, h - 15); + if let Some((sx, sy)) = stairs { + grid.set_material(sx, sy, MaterialId::Stairs); + if grid.get(sx, sy + 1).is_empty() { + grid.set_material(sx, sy + 1, MaterialId::Stone); + } + } + + spawn + } + + fn generate_dungeon( + &mut self, + grid: &mut ChunkedGrid, + items: &mut ItemManager, + _depth: u32, + ) -> (i32, i32) { + let w = grid.width as i32; + let h = grid.height as i32; + + for y in 0..h { + for x in 0..w { + grid.set_material(x, y, MaterialId::Stone); + } + } + + let root = Rect { + x: 8, + y: 8, + w: w - 16, + h: h - 16, + }; + let tree = self.build_bsp(root); + let mut rooms = Vec::new(); + self.collect_rooms(&tree, &mut rooms); + + if rooms.is_empty() { + return self.fallback_spawn(grid); + } + + for room in &rooms { + self.carve_room(grid, *room); + } + self.connect_bsp_rooms(&tree, grid); + grid.fill_border(MaterialId::Stone); + + let spawn_room = rooms[0]; + let spawn_x = spawn_room.x + spawn_room.w / 2; + let spawn_y = spawn_room.y + spawn_room.h - 5; + let spawn = (spawn_x, spawn_y); + + let mut stairs_room = rooms[0]; + let mut best_dist = 0; + for room in &rooms { + let cx = room.x + room.w / 2; + let cy = room.y + room.h / 2; + let d = (cx - spawn_x).abs() + (cy - spawn_y).abs(); + if d > best_dist { + best_dist = d; + stairs_room = *room; + } + } + let sx = stairs_room.x + stairs_room.w / 2; + let sy = stairs_room.y + stairs_room.h; + grid.set_material(sx, sy, MaterialId::Stairs); + + let item_count = (3 + self.random_range_i32(0, 3)).min(rooms.len() as i32); + let mut placed_rooms = rooms.clone(); + self.shuffle_rooms(&mut placed_rooms); + for i in 0..item_count as usize { + let room = placed_rooms[i]; + let (ix, iy) = self.random_point_in_room(room); + if let Some(typ) = self.random_item_type() { + items.spawn(typ, ix, iy); + } + } + + let trap_count = self.random_range_i32(1, 3).min(rooms.len() as i32); + for _ in 0..trap_count as usize { + let room = rooms[self.random_usize(rooms.len())]; + let (tx, ty) = self.random_point_in_room(room); + if grid.get(tx, ty).is_empty() { + grid.set_material(tx, ty, MaterialId::Acid); + } + } + + spawn + } + + fn place_items( + &mut self, + grid: &ChunkedGrid, + items: &mut ItemManager, + px: i32, + py: i32, + depth: u32, + ) { + if depth <= 3 { + let base_y = py + 1; + let offsets = [(-6, 1), (6, 1), (-3, -8), (10, 1), (-10, 1), (3, -6)]; + let types = [ + ItemType::Sword, + ItemType::HealthPotion, + ItemType::LeatherArmor, + ItemType::Bow, + ItemType::Shield, + ItemType::ManaPotion, + ]; + for (i, (dx, dy)) in offsets.iter().enumerate() { + let x = px + dx; + let y = base_y + dy; + if grid.in_bounds(x, y) { + items.spawn(types[i], x, y); + } + } + } + } + + fn place_surface_features(&mut self, grid: &mut ChunkedGrid, surface: &[i32], depth: u32) { + let tree_count = self.random_range_i32(3, 7); + self.place_trees(grid, surface, tree_count); + + let pool_count = (2 + depth / 2).min(5) as i32; + for _ in 0..pool_count { + let typ = self.random_surface_pool_type(depth); + self.place_surface_pool(grid, typ, 3, 6); + } + + let dune_count = self.random_range_i32(1, 4); + self.place_sand_dunes(grid, surface, dune_count); + + let wall_count = self.random_range_i32(1, 4); + self.place_walls(grid, surface, wall_count); + } + + fn place_surface_features_chunk( + &mut self, + grid: &mut ChunkedGrid, + cx: i32, + _cy: i32, + surface: &[i32], + depth: u32, + ) { + let x0 = cx * CHUNK_SIZE as i32; + let x1 = x0 + CHUNK_SIZE as i32; + let tree_count = self.random_range_i32(0, 2); + self.place_trees_chunk(grid, x0, x1, surface, tree_count); + + let pool_count = (depth / 2).min(2) as i32; + for _ in 0..pool_count { + let typ = self.random_surface_pool_type(depth); + self.place_surface_pool_chunk(grid, x0, x1, surface, typ, 2, 4); + } + + let dune_count = self.random_range_i32(0, 2); + self.place_sand_dunes_chunk(grid, x0, x1, surface, dune_count); + + let wall_count = self.random_range_i32(0, 2); + self.place_walls_chunk(grid, x0, x1, surface, wall_count); + } + + fn place_trees_chunk( + &mut self, + grid: &mut ChunkedGrid, + x0: i32, + _x1: i32, + surface: &[i32], + count: i32, + ) { + for _ in 0..count { + let lx = self.random_range_i32(2, CHUNK_SIZE as i32 - 2); + let x = x0 + lx; + let s = surface[lx as usize]; + if s < 10 { + continue; + } + for y in (s - 6)..s { + if grid.in_bounds(x, y) { + grid.set_material(x, y, MaterialId::Wood); + } + } + for dy in -2..=0 { + for dx in -2..=2 { + if dx * dx + dy * dy <= 5 { + let cx = x + dx; + let cy = s - 6 + dy; + if grid.in_bounds(cx, cy) && grid.get(cx, cy).is_empty() { + grid.set_material(cx, cy, MaterialId::Grass); + } + } + } + } + } + } + + fn place_surface_pool_chunk( + &mut self, + grid: &mut ChunkedGrid, + x0: i32, + _x1: i32, + surface: &[i32], + typ: MaterialId, + min_r: i32, + max_r: i32, + ) { + let lx = self.random_range_i32(4, CHUNK_SIZE as i32 - 4); + let x = x0 + lx; + let s = surface[lx as usize]; + let r = self.random_range_i32(min_r, max_r); + let cy = self.random_range_i32(s + 2, s + r + 2); + for dy in -r..=r { + for dx in -r..=r { + if dx * dx + dy * dy <= r * r { + let px = x + dx; + let py = cy + dy; + if grid.in_bounds(px, py) { + grid.set_material(px, py, typ); + } + } + } + } + } + + fn place_cave_pool_chunk( + &mut self, + grid: &mut ChunkedGrid, + cx: i32, + cy: i32, + typ: MaterialId, + min_r: i32, + max_r: i32, + ) { + let x0 = cx * CHUNK_SIZE as i32; + let y0 = cy * CHUNK_SIZE as i32; + let x1 = x0 + CHUNK_SIZE as i32; + let y1 = y0 + CHUNK_SIZE as i32; + for _ in 0..100 { + let px = self.random_range_i32(x0 + 4, x1 - 4); + let py = self.random_range_i32(y0 + 4, y1 - 4); + if grid.get(px, py).is_empty() { + continue; + } + let mut has_empty_neighbor = false; + for (dx, dy) in NEIGHBORS4 { + if grid.get(px + dx, py + dy).is_empty() { + has_empty_neighbor = true; + break; + } + } + if !has_empty_neighbor { + continue; + } + let r = self.random_range_i32(min_r, max_r); + for dy in -r..=r { + for dx in -r..=r { + if dx * dx + dy * dy <= r * r { + let x = px + dx; + let y = py + dy; + if grid.in_bounds(x, y) && !grid.get(x, y).is_empty() { + grid.set_material(x, y, typ); + } + } + } + } + return; + } + } + + fn place_sand_dunes_chunk( + &mut self, + grid: &mut ChunkedGrid, + x0: i32, + x1: i32, + surface: &[i32], + count: i32, + ) { + for _ in 0..count { + let lx = self.random_range_i32(4, (x1 - x0 - 4).max(5)); + let x = x0 + lx; + let s = surface[lx as usize]; + let width = self.random_range_i32(4, 10); + for dx in -width / 2..=width / 2 { + let pile = (width / 2 - dx.abs()).max(1) + 1; + for dy in 0..pile { + let y = s - 1 - dy; + if grid.in_bounds(x + dx, y) && grid.get(x + dx, y).is_empty() { + grid.set_material(x + dx, y, MaterialId::Sand); + } + } + } + } + } + + fn place_walls_chunk( + &mut self, + grid: &mut ChunkedGrid, + x0: i32, + x1: i32, + surface: &[i32], + count: i32, + ) { + for _ in 0..count { + let lx = self.random_range_i32(4, (x1 - x0 - 4).max(5)); + let x = x0 + lx; + let s = surface[lx as usize]; + let h = self.random_range_i32(2, 5); + for y in (s - h)..s { + if grid.in_bounds(x, y) { + grid.set_material(x, y, MaterialId::Stone); + } + if grid.in_bounds(x + 1, y) { + grid.set_material(x + 1, y, MaterialId::Stone); + } + } + } + } + + fn place_trees(&mut self, grid: &mut ChunkedGrid, surface: &[i32], count: i32) { + let w = grid.width as i32; + for _ in 0..count { + let x = self.random_range_i32(10, w - 10); + let s = surface[x as usize]; + if s < 10 { + continue; + } + for y in (s - 6)..s { + if grid.in_bounds(x, y) { + grid.set_material(x, y, MaterialId::Wood); + } + } + for dy in -2..=0 { + for dx in -2..=2 { + if dx * dx + dy * dy <= 5 { + let cx = x + dx; + let cy = s - 6 + dy; + if grid.in_bounds(cx, cy) && grid.get(cx, cy).is_empty() { + grid.set_material(cx, cy, MaterialId::Grass); + } + } + } + } + } + } + + fn place_surface_pool( + &mut self, + grid: &mut ChunkedGrid, + typ: MaterialId, + min_r: i32, + max_r: i32, + ) { + let w = grid.width as i32; + let cx = self.random_range_i32(15, w - 15); + let surface = self.find_surface_near(grid, cx); + let r = self.random_range_i32(min_r, max_r); + let cy = self.random_range_i32(surface + 2, surface + r + 2); + for dy in -r..=r { + for dx in -r..=r { + if dx * dx + dy * dy <= r * r { + let x = cx + dx; + let y = cy + dy; + if grid.in_bounds(x, y) { + grid.set_material(x, y, typ); + } + } + } + } + } + + fn place_cave_pool(&mut self, grid: &mut ChunkedGrid, typ: MaterialId, min_r: i32, max_r: i32) { + let w = grid.width as i32; + let h = grid.height as i32; + for _ in 0..100 { + let cx = self.random_range_i32(10, w - 10); + let cy = self.random_range_i32(10, h - 10); + if grid.get(cx, cy).is_empty() { + continue; + } + let mut has_empty_neighbor = false; + for (dx, dy) in NEIGHBORS4 { + if grid.get(cx + dx, cy + dy).is_empty() { + has_empty_neighbor = true; + break; + } + } + if !has_empty_neighbor { + continue; + } + let r = self.random_range_i32(min_r, max_r); + for dy in -r..=r { + for dx in -r..=r { + if dx * dx + dy * dy <= r * r { + let x = cx + dx; + let y = cy + dy; + if grid.in_bounds(x, y) && !grid.get(x, y).is_empty() { + grid.set_material(x, y, typ); + } + } + } + } + return; + } + } + + fn place_sand_dunes(&mut self, grid: &mut ChunkedGrid, surface: &[i32], count: i32) { + let w = grid.width as i32; + for _ in 0..count { + let x = self.random_range_i32(15, w - 15); + let s = surface[x as usize]; + let width = self.random_range_i32(6, 16); + for dx in -width / 2..=width / 2 { + let pile = (width / 2 - dx.abs()).max(1) + 1; + for dy in 0..pile { + let y = s - 1 - dy; + if grid.in_bounds(x + dx, y) && grid.get(x + dx, y).is_empty() { + grid.set_material(x + dx, y, MaterialId::Sand); + } + } + } + } + } + + fn place_walls(&mut self, grid: &mut ChunkedGrid, surface: &[i32], count: i32) { + let w = grid.width as i32; + for _ in 0..count { + let x = self.random_range_i32(10, w - 10); + let s = surface[x as usize]; + let h = self.random_range_i32(3, 7); + for y in (s - h)..s { + if grid.in_bounds(x, y) { + grid.set_material(x, y, MaterialId::Stone); + } + if grid.in_bounds(x + 1, y) { + grid.set_material(x + 1, y, MaterialId::Stone); + } + } + } + } + + fn carve_underground_caves(&mut self, grid: &mut ChunkedGrid, surface: &[i32]) { + let w = grid.width as i32; + let h = grid.height as i32; + for _ in 0..8 { + let cx = self.random_range_i32(10, w - 10); + let lower = (h * 2 / 3).max(surface[cx as usize] + 12); + let cy = self.random_range_i32(lower, h - 10); + let r = self.random_range_i32(3, 6); + for dy in -r..=r { + for dx in -r..=r { + if dx * dx + dy * dy <= r * r { + let x = cx + dx; + let y = cy + dy; + if grid.in_bounds(x, y) && y > surface[x as usize] + 4 { + grid.set(x, y, Cell::empty()); + } + } + } + } + } + } + + fn carve_ca_caves(&mut self, grid: &mut ChunkedGrid, fill_prob: f64, iterations: i32) { + let w = grid.width as i32; + let h = grid.height as i32; + for y in 1..h - 1 { + for x in 1..w - 1 { + if self.ca.random_u32() as f64 / (u32::MAX as f64) < fill_prob { + grid.set(x, y, Cell::empty()); + } + } + } + + let size = (w * h) as usize; + let mut buf = Vec::with_capacity(size); + for y in 0..h { + for x in 0..w { + buf.push(grid.get(x, y)); + } + } + for _ in 0..iterations { + for y in 1..h - 1 { + for x in 1..w - 1 { + let walls = self.wall_count(grid, x, y); + let i = (y * w + x) as usize; + if walls > 4 { + buf[i] = Cell::new(MaterialId::Stone); + } else if walls < 4 { + buf[i] = Cell::empty(); + } + } + } + for y in 0..h { + for x in 0..w { + let i = (y * w + x) as usize; + grid.set(x, y, buf[i]); + } + } + } + } + + fn carve_ca_caves_chunk( + &mut self, + grid: &mut ChunkedGrid, + cx: i32, + cy: i32, + fill_prob: f64, + iterations: i32, + ) { + let x0 = cx * CHUNK_SIZE as i32; + let y0 = cy * CHUNK_SIZE as i32; + let x1 = x0 + CHUNK_SIZE as i32; + let y1 = y0 + CHUNK_SIZE as i32; + for y in y0 + 1..y1 - 1 { + for x in x0 + 1..x1 - 1 { + if self.ca.random_u32() as f64 / (u32::MAX as f64) < fill_prob { + grid.set(x, y, Cell::empty()); + } + } + } + + let size = CHUNK_SIZE * CHUNK_SIZE; + let mut buf = Vec::with_capacity(size); + for y in y0..y1 { + for x in x0..x1 { + buf.push(grid.get(x, y)); + } + } + let w = CHUNK_SIZE as i32; + for _ in 0..iterations { + for y in y0 + 1..y1 - 1 { + for x in x0 + 1..x1 - 1 { + let walls = self.wall_count(grid, x, y); + let ly = y - y0; + let lx = x - x0; + let i = (ly * w + lx) as usize; + if walls > 4 { + buf[i] = Cell::new(MaterialId::Stone); + } else if walls < 4 { + buf[i] = Cell::empty(); + } + } + } + for y in y0..y1 { + for x in x0..x1 { + let ly = y - y0; + let lx = x - x0; + let i = (ly * w + lx) as usize; + grid.set(x, y, buf[i]); + } + } + } + } + + fn wall_count(&self, grid: &ChunkedGrid, x: i32, y: i32) -> i32 { + let mut count = 0; + for dy in -1..=1 { + for dx in -1..=1 { + if dx == 0 && dy == 0 { + continue; + } + let nx = x + dx; + let ny = y + dy; + if !grid.in_bounds(nx, ny) { + count += 1; + } else { + let cell = grid.get(nx, ny); + if cell.is_solid() || cell.material == MaterialId::Stone { + count += 1; + } + } + } + } + count + } + + fn seal_disconnected_regions(&mut self, grid: &mut ChunkedGrid) { + let w = grid.width as i32; + let h = grid.height as i32; + let size = (w * h) as usize; + let mut visited = vec![false; size]; + let mut best = Vec::new(); + + for y in 0..h { + for x in 0..w { + let idx = (y * w + x) as usize; + if grid.get(x, y).is_empty() && !visited[idx] { + let mut comp = Vec::new(); + self.flood_fill(grid, &mut visited, x, y, &mut comp); + if comp.len() > best.len() { + best = comp; + } + } + } + } + + let mut in_best = vec![false; size]; + for (x, y) in &best { + in_best[(*y * w + *x) as usize] = true; + } + + for y in 0..h { + for x in 0..w { + let idx = (y * w + x) as usize; + if grid.get(x, y).is_empty() && !in_best[idx] { + grid.set_material(x, y, MaterialId::Stone); + } + } + } + } + + fn flood_fill( + &self, + grid: &ChunkedGrid, + visited: &mut [bool], + x: i32, + y: i32, + comp: &mut Vec<(i32, i32)>, + ) { + let w = grid.width as i32; + let mut stack = vec![(x, y)]; + while let Some((cx, cy)) = stack.pop() { + let idx = (cy * w + cx) as usize; + if visited[idx] || !grid.in_bounds(cx, cy) || !grid.get(cx, cy).is_empty() { + continue; + } + visited[idx] = true; + comp.push((cx, cy)); + for &(dx, dy) in &NEIGHBORS4 { + stack.push((cx + dx, cy + dy)); + } + } + } + + fn build_bsp(&mut self, rect: Rect) -> BspNode { + let mut node = BspNode { + left: None, + right: None, + room: None, + }; + if let Some((left, right)) = self.split_rect(rect) { + node.left = Some(Box::new(self.build_bsp(left))); + node.right = Some(Box::new(self.build_bsp(right))); + } else { + node.room = Some(self.carve_room_rect(rect)); + } + node + } + + fn split_rect(&mut self, rect: Rect) -> Option<(Rect, Rect)> { + let min_size = 22; + if rect.w < min_size * 2 || rect.h < min_size * 2 { + return None; + } + if rect.w > rect.h { + let split = self.random_range_i32(min_size, rect.w - min_size); + let left = Rect { + x: rect.x, + y: rect.y, + w: split, + h: rect.h, + }; + let right = Rect { + x: rect.x + split, + y: rect.y, + w: rect.w - split, + h: rect.h, + }; + Some((left, right)) + } else { + let split = self.random_range_i32(min_size, rect.h - min_size); + let top = Rect { + x: rect.x, + y: rect.y, + w: rect.w, + h: split, + }; + let bottom = Rect { + x: rect.x, + y: rect.y + split, + w: rect.w, + h: rect.h - split, + }; + Some((top, bottom)) + } + } + + fn carve_room_rect(&mut self, rect: Rect) -> Rect { + let min_w = 9; + let min_h = 9; + let max_pad_x = ((rect.w - min_w) / 2).max(1); + let max_pad_y = ((rect.h - min_h) / 2).max(1); + let pad_x = self.random_range_i32(1, max_pad_x + 1); + let pad_y = self.random_range_i32(1, max_pad_y + 1); + Rect { + x: rect.x + pad_x, + y: rect.y + pad_y, + w: rect.w - pad_x * 2, + h: rect.h - pad_y * 2, + } + } + + fn carve_room(&mut self, grid: &mut ChunkedGrid, room: Rect) { + for y in room.y..room.y + room.h { + for x in room.x..room.x + room.w { + grid.set(x, y, Cell::empty()); + } + } + } + + fn collect_rooms(&self, node: &BspNode, rooms: &mut Vec) { + if let Some(room) = node.room { + rooms.push(room); + } + if let Some(ref left) = node.left { + self.collect_rooms(left, rooms); + } + if let Some(ref right) = node.right { + self.collect_rooms(right, rooms); + } + } + + fn connect_bsp_rooms(&mut self, node: &BspNode, grid: &mut ChunkedGrid) { + if let (Some(left), Some(right)) = (&node.left, &node.right) { + let r1 = self.find_first_room(left); + let r2 = self.find_first_room(right); + if let (Some(a), Some(b)) = (r1, r2) { + self.carve_l_corridor(grid, &a, &b); + } + self.connect_bsp_rooms(left, grid); + self.connect_bsp_rooms(right, grid); + } + } + + fn find_first_room(&self, node: &BspNode) -> Option { + if let Some(room) = node.room { + return Some(room); + } + if let Some(ref left) = node.left { + if let Some(room) = self.find_first_room(left) { + return Some(room); + } + } + if let Some(ref right) = node.right { + if let Some(room) = self.find_first_room(right) { + return Some(room); + } + } + None + } + + fn carve_l_corridor(&mut self, grid: &mut ChunkedGrid, a: &Rect, b: &Rect) { + let c1 = (a.x + a.w / 2, a.y + a.h / 2); + let c2 = (b.x + b.w / 2, b.y + b.h / 2); + let x0 = c1.0.min(c2.0); + let x1 = c1.0.max(c2.0); + for x in x0..=x1 { + for dy in 0..2 { + if grid.in_bounds(x, c1.1 + dy) { + grid.set(x, c1.1 + dy, Cell::empty()); + } + } + } + let y0 = c1.1.min(c2.1); + let y1 = c1.1.max(c2.1); + for y in y0..=y1 { + for dx in 0..2 { + if grid.in_bounds(c2.0 + dx, y) { + grid.set(c2.0 + dx, y, Cell::empty()); + } + } + } + } + + fn find_safe_spawn(&mut self, grid: &ChunkedGrid) -> (i32, i32) { + let w = grid.width as i32; + let h = grid.height as i32; + for floor_y in 10..h - 10 { + for x in 10..w - 10 { + if !grid.get(x, floor_y).is_solid() { + continue; + } + let cy = floor_y - 3; + if !self.vertical_clear(grid, x, cy) { + continue; + } + if !self.horizontal_clear(grid, x, cy) { + continue; + } + return (x, cy); + } + } + (w / 2, h / 2) + } + + fn find_empty_with_floor( + &mut self, + grid: &ChunkedGrid, + target_x: i32, + target_y: i32, + ) -> Option<(i32, i32)> { + let w = grid.width as i32; + let h = grid.height as i32; + let mut best = None; + let mut best_dist = i32::MAX; + for y in 5..h - 5 { + for x in 5..w - 5 { + if grid.get(x, y).is_empty() + && grid.in_bounds(x, y + 1) + && grid.get(x, y + 1).is_solid() + { + let d = (x - target_x).abs() + (y - target_y).abs(); + if d < best_dist { + best_dist = d; + best = Some((x, y)); + } + } + } + } + best + } + + fn vertical_clear(&self, grid: &ChunkedGrid, x: i32, y: i32) -> bool { + for dy in -3..=2 { + if !grid.in_bounds(x, y + dy) || !grid.get(x, y + dy).is_empty() { + return false; + } + } + grid.in_bounds(x, y + 3) && grid.get(x, y + 3).is_solid() + } + + fn horizontal_clear(&self, grid: &ChunkedGrid, x: i32, y: i32) -> bool { + for dx in -3..=3 { + if !grid.in_bounds(x + dx, y) || !grid.get(x + dx, y).is_empty() { + return false; + } + } + true + } + + fn fallback_spawn(&mut self, grid: &mut ChunkedGrid) -> (i32, i32) { + let w = grid.width as i32; + let h = grid.height as i32; + for y in (10..h - 10).rev() { + for x in 10..w - 10 { + if grid.get(x, y).is_empty() { + grid.set(x, y, Cell::empty()); + return (x, y); + } + } + } + (w / 2, h / 2) + } + + fn surface_height(&mut self, x: i32, h: i32) -> i32 { + let base = (h - 3) - ((x as f32 * 0.08).sin() * 4.0) as i32; + let detail = ((x as f32 * 0.23).sin() * 2.0) as i32; + let micro = ((x as f32 * 0.57).sin() * 1.0) as i32; + (base + detail + micro).max(10).min(h - 3) + } + + const SURFACE_BASE_Y: i32 = 120; + + fn surface_height_world(&mut self, x: i32) -> i32 { + let base = Self::SURFACE_BASE_Y - ((x as f32 * 0.08).sin() * 4.0) as i32; + let detail = ((x as f32 * 0.23).sin() * 2.0) as i32; + let micro = ((x as f32 * 0.57).sin() * 1.0) as i32; + (base + detail + micro).max(10) + } + + fn find_surface_near(&mut self, grid: &ChunkedGrid, x: i32) -> i32 { + let h = grid.height as i32; + for y in 0..h { + if grid.get(x, y).is_solid() && grid.get(x, y).material != MaterialId::Stone { + return y; + } + } + h - 3 + } + + fn random_surface_pool_type(&mut self, depth: u32) -> MaterialId { + let r = self.ca.random_u32() % 100; + if depth == 1 { + match r { + 0..=50 => MaterialId::Water, + 51..=80 => MaterialId::Sand, + _ => MaterialId::Acid, + } + } else { + match r { + 0..=40 => MaterialId::Water, + 41..=60 => MaterialId::Sand, + 61..=80 => MaterialId::Acid, + _ => MaterialId::Lava, + } + } + } + + fn random_cave_pool_type(&mut self, depth: u32) -> MaterialId { + let r = self.ca.random_u32() % 100; + match depth { + 4 => match r { + 0..=50 => MaterialId::Water, + 51..=75 => MaterialId::Lava, + _ => MaterialId::Acid, + }, + _ => match r { + 0..=30 => MaterialId::Water, + 31..=60 => MaterialId::Lava, + _ => MaterialId::Acid, + }, + } + } + + fn random_item_type(&mut self) -> Option { + let types = [ + ItemType::Dagger, + ItemType::Sword, + ItemType::Bow, + ItemType::LeatherArmor, + ItemType::PlateArmor, + ItemType::Shield, + ItemType::HealthPotion, + ItemType::ManaPotion, + ItemType::Food, + ItemType::Scroll, + ]; + let idx = self.random_usize(types.len()); + Some(types[idx]) + } + + fn random_point_in_room(&mut self, room: Rect) -> (i32, i32) { + let x = room.x + self.random_range_i32(1, room.w - 1); + let y = room.y + self.random_range_i32(1, room.h - 1); + (x, y) + } + + fn shuffle_rooms(&mut self, rooms: &mut Vec) { + for i in (1..rooms.len()).rev() { + let j = self.random_usize(i + 1); + rooms.swap(i, j); + } + } + + fn random_range_i32(&mut self, min: i32, max: i32) -> i32 { + if max <= min { + return min; + } + min + (self.ca.random_u32() % (max - min) as u32) as i32 + } + + fn random_usize(&mut self, max: usize) -> usize { + if max == 0 { + return 0; + } + self.ca.random_usize(max) + } +} + +struct BspNode { + left: Option>, + right: Option>, + room: Option, +} + +const NEIGHBORS4: [(i32, i32); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)]; diff --git a/tests/chunks.rs b/tests/chunks.rs index de386d0..0b3ae9e 100644 --- a/tests/chunks.rs +++ b/tests/chunks.rs @@ -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] diff --git a/tests/collision_robust.rs b/tests/collision_robust.rs index d2dfbba..5e6c740 100644 --- a/tests/collision_robust.rs +++ b/tests/collision_robust.rs @@ -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] + ); } } diff --git a/tests/determinism.rs b/tests/determinism.rs index d1bac9a..e4e2654 100644 --- a/tests/determinism.rs +++ b/tests/determinism.rs @@ -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] + ); } diff --git a/tests/entity_damage.rs b/tests/entity_damage.rs index a98cb1e..230b880 100644 --- a/tests/entity_damage.rs +++ b/tests/entity_damage.rs @@ -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] + ); } } diff --git a/tests/entity_movement.rs b/tests/entity_movement.rs index 8976517..e3a01d1 100644 --- a/tests/entity_movement.rs +++ b/tests/entity_movement.rs @@ -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 + ); } diff --git a/tests/integration.rs b/tests/integration.rs index 8369ce2..ad68ecb 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -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"); diff --git a/tests/large_world.rs b/tests/large_world.rs new file mode 100644 index 0000000..a67abb9 --- /dev/null +++ b/tests/large_world.rs @@ -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()); +} diff --git a/tests/physics_acid.rs b/tests/physics_acid.rs index 5233ecb..b6cc240 100644 --- a/tests/physics_acid.rs +++ b/tests/physics_acid.rs @@ -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" + ); } diff --git a/tests/physics_interactions.rs b/tests/physics_interactions.rs index 243fb82..8ae2138 100644 --- a/tests/physics_interactions.rs +++ b/tests/physics_interactions.rs @@ -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"); diff --git a/tests/physics_lava.rs b/tests/physics_lava.rs index 02a002b..8fa670c 100644 --- a/tests/physics_lava.rs +++ b/tests/physics_lava.rs @@ -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"); diff --git a/tests/physics_sand.rs b/tests/physics_sand.rs index b7fa01c..7554502 100644 --- a/tests/physics_sand.rs +++ b/tests/physics_sand.rs @@ -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" + ); } diff --git a/tests/physics_water.rs b/tests/physics_water.rs index bcda172..3464a36 100644 --- a/tests/physics_water.rs +++ b/tests/physics_water.rs @@ -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"); diff --git a/tests/worldgen.rs b/tests/worldgen.rs new file mode 100644 index 0000000..73807f7 --- /dev/null +++ b/tests/worldgen.rs @@ -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"); +}