From 357db17c2fba9dbac95a91c412557d7e8423201d Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 21 Jun 2026 16:09:45 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20GPU=20optimization=20=E2=80=94=20lighti?= =?UTF-8?q?ng,=20viewport=20CA,=20benchmark=20mode,=20531=20FPS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolution: 8x8 world cells, 2x2 UI cells (UI_SCALE=4) - GPU lighting: vertex-shader computed, light source list buffer (max 64) instead of O(N×R²) grid scan, O(N×S) per cell - Viewport-aware CA: iterate only active chunks, not all 250×250 - Flat array entity/item/shadow maps instead of HashMaps - Flat 128-entry ASCII atlas array instead of HashMap lookup - Partial grid upload: viewport + 30-cell margin only - Pre-allocated viewport arrays in renderer structs (zero alloc/frame) - Skip CPU lighting for GPU modes (pass None) - Benchmark mode: --mode benchmark with per-subsystem timing - GpuLightSource struct, light_count in push constants - gather_sources_in_range() for viewport-scoped source gathering Benchmark (600 ticks, release): Graphics: 531 FPS (was 386, +38%), render 1013us (was 1699us, -40%) ASCII: 402 FPS (was 313, +28%), render 1502us (was 2346us, -36%) All 171 tests + 14 scenarios pass. --- .gitignore | 2 + AGENTS.md | 76 +++- Cargo.toml | 1 + GPU_OPTIMIZATION_PLAN.md | 254 +++++++++++ assets/shaders/cell.frag | 4 +- assets/shaders/cell.vert | 64 +++ assets/shaders/cell_frag.spv | Bin 896 -> 1268 bytes assets/shaders/cell_vert.spv | Bin 2084 -> 9088 bytes assets/shaders/graphics.frag | 3 +- assets/shaders/graphics.vert | 64 +++ assets/shaders/graphics_frag.spv | Bin 380 -> 708 bytes assets/shaders/graphics_vert.spv | Bin 1680 -> 8684 bytes capture.png | Bin 0 -> 39164 bytes headless_dump.txt | 366 +++++---------- src/ai/action.rs | 55 ++- src/ai/state.rs | 19 + src/entity/body_template.rs | 26 +- src/entity/entity.rs | 155 ++++++- src/entity/item.rs | 142 ++++++ src/entity/mod.rs | 10 +- src/entity/player.rs | 18 +- src/game.rs | 486 +++++++++++++++++++- src/input.rs | 191 ++++++-- src/lib.rs | 13 +- src/main.rs | 346 ++++++++++++++- src/physics/mod.rs | 3 +- src/physics/projectile.rs | 276 ++++++++++++ src/render/capture.rs | 266 +++++++++++ src/render/graphics.rs | 451 ++++++++++++++++++- src/render/lighting.rs | 283 ++++++++++++ src/render/mod.rs | 21 +- src/render/terminal.rs | 191 ++++++-- src/render/vulkan.rs | 500 ++++++++++++++++++--- src/render/window_input.rs | 120 ++++- src/ui/mod.rs | 737 +++++++++++++++++++++++++++++++ src/world/cell.rs | 61 +++ src/world/cellular.rs | 190 +++++--- src/world/chunk.rs | 76 ++++ src/world/grid.rs | 192 ++++++++ src/world/material.rs | 21 +- src/world/mod.rs | 5 +- tests/chunks.rs | 90 ++++ tests/descent.rs | 152 +++++++ tests/edge_cases.rs | 9 +- tests/inventory.rs | 102 +++++ tests/perf_corpses.rs | 49 ++ tests/physics_materials.rs | 274 ++++++++++-- tests/player_controls.rs | 91 +++- tests/projectiles.rs | 188 ++++++++ tests/ragdoll_death.rs | 117 ++++- tests/rpg.rs | 177 ++++++++ tests/slime.rs | 6 +- tools/render_dump.py | 70 +++ 53 files changed, 6362 insertions(+), 651 deletions(-) create mode 100644 GPU_OPTIMIZATION_PLAN.md create mode 100644 capture.png create mode 100644 src/entity/item.rs create mode 100644 src/physics/projectile.rs create mode 100644 src/render/capture.rs create mode 100644 src/render/lighting.rs create mode 100644 src/ui/mod.rs create mode 100644 src/world/chunk.rs create mode 100644 tests/chunks.rs create mode 100644 tests/descent.rs create mode 100644 tests/inventory.rs create mode 100644 tests/perf_corpses.rs create mode 100644 tests/projectiles.rs create mode 100644 tests/rpg.rs create mode 100644 tools/render_dump.py diff --git a/.gitignore b/.gitignore index 32b80ab..f982fe3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target Cargo.lock graphify-out/ +headless_dump.txt +headless_dump.png diff --git a/AGENTS.md b/AGENTS.md index a45fde0..6efc35a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,26 +4,55 @@ ```sh cargo build # debug build -cargo run -- --mode ascii # Vulkan window, ASCII glyphs (default) -cargo run -- --mode graphics # Vulkan window, colored cells +cargo run --release -- --mode ascii # Vulkan window, ASCII glyphs (default, recommended) +cargo run --release -- --mode graphics # Vulkan window, colored cells, 16:9 window (recommended) +cargo run -- --mode ascii # Vulkan window, ASCII glyphs (debug build, slower) +cargo run -- --mode graphics # Vulkan window, colored cells, 16:9 window (debug build, slower) cargo run -- --mode terminal # ANSI terminal mode 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 ``` -Rust edition 2024, requires rustc >= 1.96. Vulkan 1.2+ required for `ascii`/`graphics` modes (falls back to terminal). +Rust edition 2024, requires rustc >= 1.96. Vulkan 1.2+ required for `ascii`/`graphics` modes (falls back to terminal). Use `--release` for playable frame rates; GPU modes are CPU-bound in debug builds due to the cellular-automaton simulation. The GPU event loop is capped at 60 FPS. ## Tests ```sh -cargo test # all 122 integration tests +cargo test # all 171 integration tests cargo test --test physics_sand # single test file cargo test --test slime # slime-specific tests cargo run -- --mode test --scenario-dir scenarios # 14 JSON scenarios ``` -Tests are in `tests/*.rs`, use `verbatim::ai::GameSession` API (not the render loop). Scenarios are in `scenarios/*.json`. +Tests are in `tests/*.rs`, use `verbatim::ai::GameSession` API (not the render loop). Scenarios are in `scenarios/*.json`. Run `python3 tools/render_dump.py` to convert a `headless_dump.txt` frame to PNG for visual inspection. Use `--mode capture` to generate a graphics-like PNG directly without a window. + +## Controls + +Terminal (`--mode terminal`): +- `a` / `d` or `←` / `→` — move +- `w` / `↑` / `space` — jump (press only) +- `h` / `j` / `k` / `l` — shoot left / down / up / right +- `f` — toggle fireball mode +- `>` — descend when standing on stairs +- `e` — use/equip first inventory item +- `r` — drop first inventory item +- `1`–`0` / `x` — paint material brush +- `q` / `ctrl-c` — quit + +GPU (`--mode ascii` / `--mode graphics`): +- `a` / `d` or `←` / `→` — move +- `w` / `↑` / `space` — jump +- `h` / `j` / `k` / `l` — shoot left / down / up / right +- `f` — toggle fireball mode +- `>` / `.` — descend when standing on stairs +- `e` — use/equip first inventory item +- `r` — drop first inventory item +- `1`–`0` / `x` — paint material brush +- `y` / `u` / `i` / `o` — move camera +- `q` / `esc` — quit ## Shaders @@ -40,31 +69,51 @@ 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. +**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. **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`. +**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. + +**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. **GpuRenderer trait** (`main.rs`): unifies `VulkanRenderer` and `GraphicsRenderer` behind `run_gpu_mode()`. Both have identical event loops; only shader/instance format differs. -**Game loop**: `Game::fixed_update()` = CA step -> rigid update -> ragdoll update -> slime AI -> combat -> damage. Called from event loop with fixed 16ms accumulator. `Game::run()` is terminal-only (uses `InputHandler` with crossterm). GPU modes use `run_gpu_mode` with `WindowInput` (winit PhysicalKey, layout-agnostic). +**Game loop**: `Game::fixed_update()` = activate chunks -> CA step -> rigid update -> ragdoll update -> slime AI -> goblin AI -> combat -> projectiles -> damage -> corpse decomposition -> status effects -> score -> item pickup. Called from event loop with fixed 16ms accumulator. `Game::run()` is terminal-only (uses `InputHandler` with crossterm). GPU modes use `run_gpu_mode` with `WindowInput` (winit PhysicalKey, layout-agnostic). **BodyTemplate** (`entity/body_template.rs`): data-driven entity body definitions. JSON-serializable. `build_humanoid()` delegates to `template_for_kind().apply_to()`. To add a new creature shape, add a `BodyTemplate` constructor + match arm in `template_for_kind`. Each `SubBody` has a `color: [u8; 4]` field for per-part coloring. -**Combat**: `update_combat()` checks AABB overlap between player and all alive enemies. Goblin = 8 dmg, Slime = 5 dmg per 20 ticks on contact. Knockback applied to player. Player combat style is ranged (projectiles planned, not yet implemented). + +**Combat**: player uses ranged projectiles (Arrow / Magic Bolt / Fireball). `player_shoot()` fires in the direction held. `update_projectiles()` moves projectiles, resolves entity hits (damage + knockback), and applies fireball ignition. Enemy contact damage still applies (Goblin 8 dmg, Slime 5 dmg per 20 ticks) reduced by equipped armor. Knockback applied to player. **Slime AI**: `update_slime_ai()` makes slimes jump toward player every 60 ticks when within 40 cells. Jump power scales with proximity. +**Goblin AI**: `update_goblin_ai()` moves toward player when far, backs away when close, and flees when health < 10. + +**Corpse decomposition**: `decompose_corpses()` turns dead `Corpse` bodies into `Flesh` cells in the grid over time. + +**RPG layer**: `Entity` carries stats (strength, agility, toughness, willpower), level, XP, status effects (on_fire/poisoned/frozen/bleeding), and `add_xp` / `xp_to_level` / `recalc_max_health`. Status effects deal damage or expire in `update_status_effects`. `EntityInfo` exposes these for AI state. + +**Items & inventory**: `Item` (weapon, armor, consumable), `ItemManager`, and inventory/equipment slots on `Player`. Items spawn in the world and are picked up on contact. `Game::use_item(0)` equips weapons/armor or consumes potions; `Game::drop_item(0)` returns an item to the world. Equipped weapon adds damage bonus to projectiles; equipped armor reduces enemy contact damage. + +**UI layer**: `ui::UiLayer` overlays non-destructive UI on all renderers. Health bars above entities, bottom-line HUD, scrolling message log, floating damage numbers, screen-edge indicators, death screen, entity labels, status icons, minimap, and a character panel. UI is drawn unlit on top of the world. In GPU (`ascii`/`graphics`) and capture modes the UI is rendered as a separate 2x2 pixel-per-cell pass; terminal mode renders UI at full character size. + +**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()`. + +**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. + ## Key Conventions - **No comments in code** unless explicitly requested. - **Cell colors are inline** — renderers read `cell.fg`/`cell.bg` directly, never lookup `MaterialRegistry` in render path. Registry is for physics properties only (density, solid, flammable, etc.). - **Vector movement** — `Player::move_left/right` sets velocity directly (`set_horizontal_vel`), no accumulation. `stop_horizontal` zeroes it. Jump is edge-triggered (press only, not held). - **Slope stepping** — `update_rigid_entity` tries stepping up 1 cell before resolving X collision, enabling walking up slopes without jumping. -- **Adaptive viewport** — `check_resize()` in both Vulkan renderers recreates swapchain on window resize. `grid_w`/`grid_h` recalculated from extent / 10 (cell size = 10x10 px). Wayland uses `window.inner_size()` (surface extent is undefined). +- **Adaptive viewport** — `check_resize()` in both Vulkan renderers recreates swapchain on window resize. `grid_w`/`grid_h` recalculated from extent / 16 (cell size = 16x16 px). Wayland uses `window.inner_size()` (surface extent is undefined). - **`Cell::new()` copies colors from `MaterialRegistry`** at creation time. Per-cell color variation is possible by modifying `cell.fg`/`cell.bg` after creation. - **Auto-constraints** — `BodyTemplate::auto_constraints(n)` connects all parts to all others (n^2). Works for any template shape. Simpler than manual constraint lists. +- **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. ## Module Layout @@ -74,11 +123,12 @@ 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, CellularAutomaton + world/ # Cell, MaterialId, MaterialRegistry, Grid, Chunk, CellularAutomaton physics/ # VerletSolver, SubBody (with color field), Constraint, resolve_grid_collision - entity/ # Entity (rigid/ragdoll), EntityManager, Player, BodyTemplate - render/ # terminal.rs, vulkan.rs (ASCII), graphics.rs (cells), window_input.rs + entity/ # Entity (rigid/ragdoll), EntityManager, Player, BodyTemplate, Item, ItemManager + render/ # terminal.rs, vulkan.rs (ASCII), graphics.rs (cells), lighting.rs, window_input.rs ai/ # GameSession, AiAction, pipe protocol, replay, scenarios + ui/ # UiLayer, HUD, messages, damage numbers, edge indicators ``` ## PLAN.md diff --git a/Cargo.toml b/Cargo.toml index 90d9256..6d1a8e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ ash = "0.38" ash-window = "0.13" raw-window-handle = "0.6" bytemuck = { version = "1", features = ["derive"] } +image = { version = "0.25", default-features = false, features = ["png"] } [profile.release] opt-level = 3 diff --git a/GPU_OPTIMIZATION_PLAN.md b/GPU_OPTIMIZATION_PLAN.md new file mode 100644 index 0000000..c7ffde1 --- /dev/null +++ b/GPU_OPTIMIZATION_PLAN.md @@ -0,0 +1,254 @@ +# Verbatim — GPU Optimization Plan + +> Autonomously drive the game to full GPU optimization. +> Created June 2026. All work is self-contained — no user interaction needed. + +## Final Results (600-tick benchmark) + +| Mode | Baseline FPS | Final FPS | Improvement | Baseline Render | Final Render | CA Step | +|------|-------------|-----------|-------------|-----------------|--------------|---------| +| Graphics | 386 | **531** | +38% | 1699us | **1013us** (-40%) | 347us | +| ASCII | 313 | **402** | +28% | 2346us | **1502us** (-36%) | 382us | + +Both modes far exceed the 60 FPS target. All 171 tests + 14 scenarios pass. + +## Optimizations Completed + +### Phase A: Benchmark Infrastructure ✓ +- Added `--mode benchmark` with `--benchmark-ticks`, `--benchmark-renderer`, `--benchmark-output` CLI args +- Measures CA step, render, and total frame times with percentile stats +- Outputs JSON results file + +### Phase B: Eliminate Wasted CPU Work ✓ +- Skipped `lighting::compute_lighting()` for GPU modes in `main.rs` +- Pass `None` for lighting to GPU renderers + +### Phase C: GPU Lighting Shader Optimization ✓ +- Replaced naive O(N×R²) grid scan with O(N×S) light source list iteration +- CPU gathers light sources into compact buffer (max 64 sources, 32 bytes each) +- Uploaded via second storage buffer (binding 2 in ascii, binding 1 in graphics) +- `gather_sources_in_range()` only scans viewport + 30-cell margin +- `light_count` passed via push constants + +### Phase D: Viewport-Aware CA ✓ +- CA step iterates only active chunks instead of all 250×250 cells +- `apply_cell_rule()` helper avoids code duplication +- Heat transfer also iterates only active chunks + +### Phase E: Instance Building Optimization ✓ +- Replaced HashMap entity_map/item_map/shadow_map with flat viewport-sized arrays +- Direct array indexing instead of hashing — 42% render speedup in graphics mode +- Replaced HashMap atlas_map with flat 128-entry ASCII array in ascii renderer + +### Phase G: Partial Grid Upload ✓ +- Only upload viewport + 30-cell margin region to GPU (260×172 vs 250×250) +- Pre-allocated viewport arrays in renderer struct to avoid per-frame allocation + +## Starting State + +| System | Status | Notes | +|--------|--------|-------| +| World cells | 8×8 px | Reduced from 16×16 | +| UI cells | 2×2 px | UI_SCALE = 4 | +| CA simulation | CPU, full 250×250 | Active-chunk system exists but still iterates all cells | +| Lighting | GPU (vertex shader) | Both ascii + graphics renderers; naive O(N×R²) per cell | +| CPU lighting | Still computed in main.rs | Wasted work for GPU modes — must be skipped | +| Instance building | CPU, per-frame | Full viewport iteration, HashMaps for entity/item overlap | +| FPS | Unknown | Need benchmark tool to measure | +| Tests | 171 pass, 14 scenarios | Must stay green throughout | + +## Performance Targets + +| Metric | Target | Current | +|--------|--------|---------| +| Frame time (ascii mode) | < 16ms (60 FPS) | Unknown | +| Frame time (graphics mode) | < 16ms (60 FPS) | Unknown | +| CA step (250×250) | < 0.5ms | ~0.5ms (active chunks) | +| Instance build | < 2ms | Unknown | +| GPU lighting | < 2ms | Unknown (naive shader) | +| Grid upload | < 0.5ms | Unknown | + +## Phases + +### Phase A: Benchmark Infrastructure + +**Goal: automated FPS measurement without human interaction** + +- [ ] Add `--mode benchmark` CLI mode + - Runs game for N ticks (default 600 = 10 seconds at 60 FPS) + - Uses Vulkan renderer (ascii or graphics, configurable via `--benchmark-mode ascii|graphics`) + - No window input needed — auto-runs, collects frame times + - Outputs: min/avg/p99 FPS, frame time distribution, per-subsystem timing + - Writes results to `benchmark_results.json` and prints summary to stdout + - Subsystem timing: CA step, instance build, grid upload, render, total + - Uses `Instant::now()` around each subsystem in the game loop +- [ ] Add `tools/benchmark.py` — parses JSON results, compares runs, generates trend table + +### Phase B: Eliminate Wasted CPU Work + +**Goal: remove CPU work that GPU now handles** + +- [ ] Skip `lighting::compute_lighting()` in `main.rs` for GPU modes + - Add `uses_cpu_lighting()` to `GpuRenderer` trait (default true) + - Vulkan + Graphics override to false + - `run_gpu_mode` only computes CPU lighting if renderer needs it +- [ ] Remove `lighting` parameter from GPU renderers' render signatures if unused + - Keep trait signature compatible (pass None for GPU) +- [ ] Skip CPU `apply_light_rgba` in instance building for GPU renderers (already done) + +### Phase C: Optimize GPU Lighting Shader + +**Goal: reduce per-vertex lighting cost from O(R²) to O(S) where S = light source count** + +Current shader scans a 60×60 area per cell looking for light sources. Most cells have zero nearby sources. + +- [ ] CPU-side: gather light sources each frame into a compact buffer (max 64 sources) + - Each source: x, y, radius, color (16 bytes) + - Upload via a second storage buffer or uniform buffer +- [ ] Shader: iterate over light sources list instead of scanning grid + - For each source: check distance < radius, then line_of_sight + - O(S) per cell instead of O(R²) + - S is typically 5-20 (lava pools, fires) +- [ ] Keep grid storage buffer for `is_solid()` checks in line_of_sight +- [ ] Benchmark before/after + +### Phase D: Optimize CA — Viewport-Aware Simulation + +**Goal: only simulate cells that matter** + +Current: `update_active_chunks` activates chunks near entities/items/modified. But the CA step still iterates all 250×250 cells checking chunk active flags. + +- [ ] Build a compact list of active chunk ranges at the start of each tick + - `active_chunks: Vec<(cx, cy)>` — only iterate these +- [ ] CA step iterates only active chunks, not all 250×250 + - For each active chunk: iterate its 64×64 cells + - Skip inactive chunks entirely (no bounds check per cell) +- [ ] Add a margin around the viewport: always simulate visible chunks + 1 chunk border + - Ensures materials flowing into view are simulated +- [ ] Benchmark before/after + +### Phase E: Optimize Instance Building + +**Goal: reduce per-frame CPU overhead for preparing render data** + +Current: iterates all viewport cells, uses HashMaps for entity/item overlap. + +- [ ] Replace HashMap entity_map with a 2D array (viewport-sized) + - `[[u32; VW]; VH]` — entity priority + index packed into u32 + - Avoids hashing per cell +- [ ] Same for item_map: `[[Option<[u8;4]>; VW]; VH]` +- [ ] Skip background_color hash for empty cells — precompute star pattern + - Stars are deterministic by world position; cache the hash pattern +- [ ] Consider dirty-cell tracking: only update changed instances + - Keep previous frame's instance buffer; diff against new state + - Only write changed ColorInstance/CellInstance entries + - Needs tracking of which cells changed (chunk modified flags can help) +- [ ] Benchmark before/after + +### Phase F: GPU Compute Shader for CA (if needed) + +**Goal: move cellular automaton to GPU compute** + +Only if Phase D doesn't bring CA step below 0.5ms. + +- [ ] Create compute shader `ca.comp` — one workgroup per chunk (64×64) + - Read grid from storage buffer + - Apply CA rules per cell + - Write back to storage buffer + - Use shared memory for chunk border exchange +- [ ] Double-buffer: ping-pong between two grid buffers +- [ ] CPU reads back only active chunks for entity physics +- [ ] Fallback: keep CPU CA for terminal/headless/test modes +- [ ] Benchmark before/after + +### Phase G: Grid Upload Optimization + +**Goal: minimize data transferred CPU→GPU per frame** + +Current: full 250×250 grid (250KB) uploaded every frame for lighting. + +- [ ] Only upload changed chunks + - Use chunk `modified` flags to build a list of changed regions + - Upload only changed regions via `vkCmdUpdateBuffer` or per-chunk sub-range writes +- [ ] Alternatively: use a staging buffer and `vkCmdCopyBuffer` for only dirty regions +- [ ] Consider keeping grid entirely on GPU if Phase F is implemented + - CA runs on GPU, entity physics reads back only entity-adjacent cells +- [ ] Benchmark before/after + +### Phase H: Final Verification + +- [ ] Run full test suite: `cargo test` + scenarios +- [ ] Run benchmark in both ascii and graphics modes +- [ ] Compare FPS before/after all optimizations +- [ ] Document results in `benchmark_results.json` and summary in this file +- [ ] Update AGENTS.md with any new conventions + +## Benchmark Protocol + +``` +# Baseline (before any optimization) +cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-mode ascii +cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-mode graphics + +# After each phase +cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-mode ascii +cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-mode graphics +``` + +Each benchmark run produces: +```json +{ + "mode": "ascii", + "ticks": 600, + "total_time_ms": 10023.4, + "avg_fps": 59.8, + "min_fps": 52.1, + "p99_fps": 57.3, + "avg_frame_time_ms": 16.72, + "p99_frame_time_ms": 19.2, + "subsystems": { + "ca_step_avg_ms": 0.48, + "instance_build_avg_ms": 2.1, + "grid_upload_avg_ms": 0.3, + "render_avg_ms": 8.2, + "lighting_avg_ms": 0.0 + } +} +``` + +## Key Constraints + +- All 171 tests + 14 scenarios must pass after every phase +- Terminal/headless/test/pipe modes must continue working (CPU path intact) +- No visual regression — but since we can't visually inspect, rely on: + - ASCII capture output (`--mode capture`) for pixel comparison + - Test suite correctness + - Frame timing stability +- Shaders must compile cleanly via `glslangValidator` +- No new external dependencies unless absolutely necessary + +## File Impact Map + +| File | Phases | Changes | +|------|--------|---------| +| `src/main.rs` | A, B | Benchmark mode, skip CPU lighting | +| `src/game.rs` | A, D, E | Timing instrumentation, viewport CA, instance arrays | +| `src/world/cellular.rs` | D, F | Active-chunk iteration, compute shader | +| `src/world/grid.rs` | D, G | Active chunk list, dirty region tracking | +| `src/render/vulkan.rs` | C, E, G | Light source buffer, instance optimization, partial upload | +| `src/render/graphics.rs` | C, E, G | Same as vulkan.rs | +| `src/render/mod.rs` | A, B | Renderer trait changes | +| `assets/shaders/cell.vert` | C | Light source list iteration | +| `assets/shaders/cell.frag` | C | (no change expected) | +| `assets/shaders/graphics.vert` | C | Light source list iteration | +| `assets/shaders/graphics.frag` | C | (no change expected) | +| `assets/shaders/ca.comp` | F | New compute shader | +| `tools/benchmark.py` | A | New tool | + +## Decision Log + +| Date | Decision | Rationale | +|------|----------|-----------| +| Jun 21 | Start with benchmark tool | Can't optimize what we can't measure | +| Jun 21 | Light source buffer before CA compute | Bigger win for less effort | +| Jun 21 | Keep CPU CA as fallback | Terminal/test modes need it | diff --git a/assets/shaders/cell.frag b/assets/shaders/cell.frag index b6871a1..a963449 100644 --- a/assets/shaders/cell.frag +++ b/assets/shaders/cell.frag @@ -3,6 +3,7 @@ layout(location = 0) in vec2 in_uv; layout(location = 1) in vec4 in_fg; layout(location = 2) in vec4 in_bg; +layout(location = 3) in vec3 in_light; layout(binding = 0) uniform sampler2D atlas; @@ -10,5 +11,6 @@ layout(location = 0) out vec4 out_color; void main() { float alpha = texture(atlas, in_uv).r; - out_color = mix(in_bg, in_fg, alpha); + vec4 color = mix(in_bg, in_fg, alpha); + out_color = vec4(color.rgb * in_light, color.a); } diff --git a/assets/shaders/cell.vert b/assets/shaders/cell.vert index 086845e..4a63a6b 100644 --- a/assets/shaders/cell.vert +++ b/assets/shaders/cell.vert @@ -3,6 +3,11 @@ layout(push_constant) uniform PC { vec2 screen_size; vec2 cell_size; + ivec2 world_size; + ivec2 cam_pos; + vec3 ambient; + uint is_ui; + uint light_count; } pc; layout(location = 0) in vec2 in_pos; @@ -14,6 +19,59 @@ layout(location = 4) in vec4 in_bg; layout(location = 0) out vec2 out_uv; layout(location = 1) out vec4 out_fg; layout(location = 2) out vec4 out_bg; +layout(location = 3) out vec3 out_light; + +layout(std430, binding = 1) readonly buffer GridBuffer { + uint cells[]; +} grid; + +struct LightSrc { + vec2 pos; + float radius; + float pad0; + vec3 color; + float pad1; +}; + +layout(std430, binding = 2) readonly buffer LightBuffer { + LightSrc sources[]; +} lights; + +bool is_solid(uint m) { + return m == 3u || m == 5u || m == 6u || m == 7u || m == 12u || m == 13u || m == 14u; +} + +bool line_of_sight(ivec2 a, ivec2 b) { + ivec2 p = a; + ivec2 d = abs(b - a); + ivec2 s = ivec2(a.x < b.x ? 1 : -1, a.y < b.y ? 1 : -1); + 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]; + if (is_solid(m)) return false; + int e2 = 2 * err; + if (e2 > -d.y) { err -= d.y; p.x += s.x; } + if (e2 < d.x) { err += d.x; p.y += s.y; } + } +} + +vec3 compute_light(ivec2 world_pos) { + vec3 light = pc.ambient; + for (uint i = 0u; i < pc.light_count; i++) { + LightSrc src = lights.sources[i]; + ivec2 src_pos = ivec2(src.pos); + float dist = length(vec2(world_pos - src_pos)); + float rad = src.radius; + if (dist >= rad) continue; + if (!line_of_sight(src_pos, world_pos)) continue; + float t = 1.0 - dist / rad; + float att = t * t; + light += src.color * att; + } + return min(light, vec3(1.0)); +} void main() { vec2 pixel = (in_grid + in_pos) * pc.cell_size; @@ -25,4 +83,10 @@ void main() { out_uv = in_atlas.xy + in_pos * in_atlas.zw; out_fg = in_fg; out_bg = in_bg; + if (pc.is_ui != 0u) { + out_light = vec3(1.0); + } else { + ivec2 world_pos = ivec2(in_grid + vec2(pc.cam_pos)); + out_light = compute_light(world_pos); + } } diff --git a/assets/shaders/cell_frag.spv b/assets/shaders/cell_frag.spv index 9d72c46f1dda80ededc99f7ec03a40d6c9c1ef72..02239d5e0a157f2871380ecfd181212d0fcedb63 100644 GIT binary patch delta 501 zcmXw#O-chn6olW*WD=tUM1%y1qK+}qs8LiDM8TD-pdKKEAff{?0}_{_8xJt_8N7>o zk0I{d3f33ehN8Q^ey{rHwe(Z2L}5}g3v9-wY%i}ICev0ho9JPXjt5P!` z^oj+mX_EH`y?I6o)l6r=$Kby}KV<{(a;Qg8JfxlF3+-7I>k2`Nk@N3n`-`che n%?uB1M{5{|{(X# delta 127 zcmeyu*}%@r%%sfDz`)4B&A`E+IFZ*>o|A!vfdPzj6EpLGOa=iUmH=WYAeNb!8q38B z=H{1{#7{h-F(LL5kgG%`;vWip=3!K^dch4~D} KU!ZLkKnwt2tP?H( diff --git a/assets/shaders/cell_vert.spv b/assets/shaders/cell_vert.spv index 5c19606196599c4935c4f8327d4e401c7a760370..5796a24ee9e08baa3c801b9a49a3311589769bb3 100644 GIT binary patch literal 9088 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 c@HxjMZg{r~^~ literal 2084 zcmZ9MYflqV5QYyF3W|z|fER?ts;DTU0;0&xBE^J65)!{QwX9^5wQbtP5I<>*iNDKV zmV4}~n$5Q4y=NS`aDx(Zn3v_)?07>Cb>PT>LmNj99J4VF z8;%{JKW1{)U!R`|m|Xnj;J5oQ5B$`@&pf*0nFpBqqpCuk8ATJ1Jua Z4n>H6RVf z7YTD`I0SrNns0+w1Gak_X2!%!$?qur@ThboA%8C5s{!-{7RX=RbcX_?NZg><2UFP~(r7eram4GxkPn!t{7S!k!rBpOroN zt}Oh+5@O)D#PQDK5_W_4x4enK{|tH8gvo_pS0{WIjv6KDI}$kFf*Q>Gt^}SM;Cqr8 z3H+9rS?T){z8y)ra~}xf`xSB@$|fKFN8ldGhUY!4kH@m<@$8*{6^s%ogvkOZS`oqV5S&!_)3kf?ONth*=c={#xwS*md zuUfp>THxS0JraATb=kbH= 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]; + if (is_solid(m)) return false; + int e2 = 2 * err; + if (e2 > -d.y) { err -= d.y; p.x += s.x; } + if (e2 < d.x) { err += d.x; p.y += s.y; } + } +} + +vec3 compute_light(ivec2 world_pos) { + vec3 light = pc.ambient; + for (uint i = 0u; i < pc.light_count; i++) { + LightSrc src = lights.sources[i]; + ivec2 src_pos = ivec2(src.pos); + float dist = length(vec2(world_pos - src_pos)); + float rad = src.radius; + if (dist >= rad) continue; + if (!line_of_sight(src_pos, world_pos)) continue; + float t = 1.0 - dist / rad; + float att = t * t; + light += src.color * att; + } + return min(light, vec3(1.0)); +} void main() { vec2 pixel = (in_grid + in_pos) * pc.cell_size; @@ -19,4 +77,10 @@ void main() { 0.0, 1.0 ); out_color = in_color; + if (pc.is_ui != 0u) { + out_light = vec3(1.0); + } else { + ivec2 world_pos = ivec2(in_grid + vec2(pc.cam_pos)); + out_light = compute_light(world_pos); + } } diff --git a/assets/shaders/graphics_frag.spv b/assets/shaders/graphics_frag.spv index fdfb166ea625318a0f6e85c848637bc75df1956c..d731172f67b9d7a5cd8c8c51dffb09153c4c86bc 100644 GIT binary patch literal 708 zcmYk2-AY1H5QQgCmSt&XYJY^$WguNt1X0kHH$p`Z5K&NqDU>}zFV<6a6ZCz%bHWYl z%*>jZwbwpb>0mKpE~-(9-S|6=D8U5WLW=#t-JthAed-;b963>oEDbtSXK$V;ULS^I z8%v-H))KPR;}W(qlq0@aEW^(3lwsVPd`#~jC!@(5xtuz~@jsO~b>T+C=a(sI^igtm z&w^v^-fZoTwVs=}(8qpq6e(|$FTiy{hkgB8>#@tXLiE-0=Xvd&a`u;rY8QQLJ;;lhWUT=^W`z>;3)jZ{|_#VA_ocV?+6E9%(vv;#b@4S7rFL|B+-QEuhXW$oNt{{{E delta 87 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 tqob!T^Ol=3^bunoOe63ATE{Re~>XBq$i diff --git a/capture.png b/capture.png new file mode 100644 index 0000000000000000000000000000000000000000..f6e9933506edf24627d6c890439823f153cbb255 GIT binary patch literal 39164 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 literal 0 HcmV?d00001 diff --git a/headless_dump.txt b/headless_dump.txt index 481b659..e5ff40a 100644 --- a/headless_dump.txt +++ b/headless_dump.txt @@ -1,4 +1,4 @@ -=== Verbatim Headless Run: 60 ticks === +=== Verbatim Headless Run: 5 ticks === World: 250x250 Player start: (125.0, 242.0) @@ -11,325 +11,175 @@ Camera: (85, 230) 33 34 35 -36 -37 TTTTT -38 T T -39 T T . +36 @@@ +37 TTTTT @@@ +38 T T @@@@@ +39 T T @@@@@@@ . 40 T T @@@@@ ... -41 T T ## @@@@@@ ..... -42 T T ## @@@@@@ ....... -43 T T ## @@@@@ """"""""""""" ......... +41 T T ## @@@@@ ..... +42 T T ## @@@ ....... +43 T T ## @@@ """"""""""""" ......... 44""" T T ## @@@@@ "":::::::::::::""" .......... -45:::""" ## """::::::::::::::::::"" ........... -46::::::"" ## "":::::::::::::::::::::::"""......... -47::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::""""""""" +45:::""" ## @ @ @ """::::::::::::::::::"" ........... +46::::::"" ## @ @ @"":::::::::::::::::::::::"""......... +47::::::::""""""""""""""""""""""""""""""@"""@::::::::::::::::::::::::::::""""""""" 48:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -49################################################################################ +49########################################>####################################### 50???????????????????????????????????????????????????????????????????????????????? 51???????????????????????????????????????????????????????????????????????????????? 52???????????????????????????????????????????????????????????????????????????????? 53???????????????????????????????????????????????????????????????????????????????? 54???????????????????????????????????????????????????????????????????????????????? -Player: "Player hp=100.0/100.0 pos=(125.0,242.0) bodies=27/27 on_fire=false" +Player: "Player hp=150.0/150.0 pos=(125.0,242.0) bodies=47/47 on_fire=false" Entities: 1 -=== Tick 6 === +=== Tick 1 === +29 30 31 32 33 34 -35 -36 -37 TTTTT -38 T T -39 T T . -40 T T @@@@@ ... -41 T T ## @@@@@@ ..... -42 T T ## @@@@@@ ....... -43 T T ## @@@@@ """"""""""""" ......... -44""" T T ## @@@@@ "":::::::::::::""" .......... -45:::""" ## """::::::::::::::::::"" ........... -46::::::"" ## "":::::::::::::::::::::::"""......... -47::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::""""""""" +35 @@@ +36 @@@ +37 TTTTT @@@@@ +38 T T @@@@@@@ +39 T T @@@@@ . +40 T T @@@@@ ... +41 T T ## @@@ ..... +42 T T ## @@@ ....... +43" T T ## @@@@@ """"""""""""" ........ +44:""" T T ## @ @ @ "":::::::::::::""" ......... +45::::""" ## @ @ @ """::::::::::::::::::"" .......... +46:::::::"" ## @ @ "":::::::::::::::::::::::"""........ +47:::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::"""""""" 48:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -49################################################################################ +49#########################################>###################################### 50???????????????????????????????????????????????????????????????????????????????? 51???????????????????????????????????????????????????????????????????????????????? 52???????????????????????????????????????????????????????????????????????????????? 53???????????????????????????????????????????????????????????????????????????????? -54???????????????????????????????????????????????????????????????????????????????? -Player: "Player hp=100.0/100.0 pos=(125.0,242.8) bodies=27/27 on_fire=false" +Player: "Player hp=150.0/150.0 pos=(124.0,241.0) bodies=47/47 on_fire=false" Entities: 1 -Alive entities: Player(hp=100, pos=(125.0, 242.82616)) +Alive entities: Player(hp=150, pos=(124.0, 241.0)) -=== Tick 12 === +=== Tick 2 === +29 +30 31 32 33 34 -35 -36 -37 TTTTT -38 T T -39 T T . -40 T T ... -41 T T ## @@@@@ ..... -42 T T ## @@@@@@ ....... -43 T T ## @@@@@@ """"""""""""" ......... -44""" T T ## @@@@@ "":::::::::::::""" .......... -45:::""" ## @@@@@ """::::::::::::::::::"" ........... -46::::::"" ## "":::::::::::::::::::::::"""......... -47::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::""""""""" +35 @@@ +36 @@@ +37 TTTTT @@@@@ +38 T T @@@@@@@ +39 T T @@@@@ . +40 T T @@@@@ ... +41 T T ## @@@ ..... +42 T T ## @@@ ....... +43" T T ## @@@@@ """"""""""""" ........ +44:""" T T ## @ @ @ "":::::::::::::""" ......... +45::::""" ## @ @ @ """::::::::::::::::::"" .......... +46:::::::"" ## @ @ "":::::::::::::::::::::::"""........ +47:::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::"""""""" 48:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -49################################################################################ +49#########################################>###################################### 50???????????????????????????????????????????????????????????????????????????????? 51???????????????????????????????????????????????????????????????????????????????? 52???????????????????????????????????????????????????????????????????????????????? 53???????????????????????????????????????????????????????????????????????????????? -54???????????????????????????????????????????????????????????????????????????????? -55???????????????????????????????????????????????????????????????????????????????? -Player: "Player hp=100.0/100.0 pos=(125.0,243.5) bodies=27/27 on_fire=false" +Player: "Player hp=150.0/150.0 pos=(124.0,241.0) bodies=47/47 on_fire=false" Entities: 1 -Alive entities: Player(hp=100, pos=(125.0, 243.5)) +Alive entities: Player(hp=150, pos=(124.0, 241.0)) -=== Tick 18 === +=== Tick 3 === +29 +30 31 32 33 34 -35 -36 -37 TTTTT -38 T T -39 T T . -40 T T ... -41 T T ## @@@@@ ..... -42 T T ## @@@@@@ ....... -43 T T ## @@@@@@ """"""""""""" ......... -44""" T T ## @@@@@ "":::::::::::::""" .......... -45:::""" ## @@@@@ """::::::::::::::::::"" ........... -46::::::"" ## "":::::::::::::::::::::::"""......... -47::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::""""""""" +35 @@@ +36 @@@ +37 TTTTT @@@@@ +38 T T @@@@@@@ +39 T T @@@@@ . +40 T T @@@@@ ... +41 T T ## @@@ ..... +42 T T ## @@@ ....... +43" T T ## @@@@@ """"""""""""" ........ +44:""" T T ## @ @ @ "":::::::::::::""" ......... +45::::""" ## @ @ @ """::::::::::::::::::"" .......... +46:::::::"" ## @ @ "":::::::::::::::::::::::"""........ +47:::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::"""""""" 48:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -49################################################################################ +49#########################################>###################################### 50???????????????????????????????????????????????????????????????????????????????? 51???????????????????????????????????????????????????????????????????????????????? 52???????????????????????????????????????????????????????????????????????????????? 53???????????????????????????????????????????????????????????????????????????????? -54???????????????????????????????????????????????????????????????????????????????? -55???????????????????????????????????????????????????????????????????????????????? -Player: "Player hp=100.0/100.0 pos=(125.0,243.5) bodies=27/27 on_fire=false" +Player: "Player hp=150.0/150.0 pos=(124.0,241.0) bodies=47/47 on_fire=false" Entities: 1 -Alive entities: Player(hp=100, pos=(125.0, 243.5)) +Alive entities: Player(hp=150, pos=(124.0, 241.0)) -=== Tick 24 === +=== Tick 4 === +29 +30 31 32 33 34 -35 -36 -37 TTTTT -38 T T -39 T T . -40 T T ... -41 T T ## @@@@@ ..... -42 T T ## @@@@@@ ....... -43 T T ## @@@@@@ """"""""""""" ......... -44""" T T ## @@@@@ "":::::::::::::""" .......... -45:::""" ## @@@@@ """::::::::::::::::::"" ........... -46::::::"" ## "":::::::::::::::::::::::"""......... -47::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::""""""""" +35 @@@ +36 @@@ +37 TTTTT @@@@@ +38 T T @@@@@@@ +39 T T @@@@@ . +40 T T @@@@@ ... +41 T T ## @@@ ..... +42 T T ## @@@ ....... +43" T T ## @@@@@ """"""""""""" ........ +44:""" T T ## @ @ @ "":::::::::::::""" ......... +45::::""" ## @ @ @ """::::::::::::::::::"" .......... +46:::::::"" ## @ @ "":::::::::::::::::::::::"""........ +47:::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::"""""""" 48:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -49################################################################################ +49#########################################>###################################### 50???????????????????????????????????????????????????????????????????????????????? 51???????????????????????????????????????????????????????????????????????????????? 52???????????????????????????????????????????????????????????????????????????????? 53???????????????????????????????????????????????????????????????????????????????? -54???????????????????????????????????????????????????????????????????????????????? -55???????????????????????????????????????????????????????????????????????????????? -Player: "Player hp=100.0/100.0 pos=(125.0,243.5) bodies=27/27 on_fire=false" +Player: "Player hp=150.0/150.0 pos=(124.0,241.0) bodies=47/47 on_fire=false" Entities: 1 -Alive entities: Player(hp=100, pos=(125.0, 243.5)) +Alive entities: Player(hp=150, pos=(124.0, 241.0)) -=== Tick 30 === +=== Tick 5 === +29 +30 31 32 33 34 -35 -36 -37 TTTTT -38 T T -39 T T . -40 T T ggggg ... -41 T T gggggg @@@@@ ..... -42 T T gggggg @@@@@@ ....... -43 T T ggggg @@@@@@ """"""""""""" ......... -44""" T T ggggg @@@@@ "":::::::::::::""" .......... -45:::""" ## @@@@@ """::::::::::::::::::"" ........... -46::::::"" ## "":::::::::::::::::::::::"""......... -47::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::""""""""" +35 @@@ +36 @@@ +37 TTTTT @@@@@ +38 T T @@@@@@@ +39 T T @@@@@ . +40 T T @@@@@ ... +41 T T ## @@@ ..... +42 T T ## @@@ ....... +43" T T ## @@@@@ """"""""""""" ........ +44:""" T T ## @ @ @ "":::::::::::::""" ......... +45::::""" ## @ @ @ """::::::::::::::::::"" .......... +46:::::::"" ## @ @ "":::::::::::::::::::::::"""........ +47:::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::"""""""" 48:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -49################################################################################ +49#########################################>###################################### 50???????????????????????????????????????????????????????????????????????????????? 51???????????????????????????????????????????????????????????????????????????????? 52???????????????????????????????????????????????????????????????????????????????? 53???????????????????????????????????????????????????????????????????????????????? -54???????????????????????????????????????????????????????????????????????????????? -55???????????????????????????????????????????????????????????????????????????????? -Player: "Player hp=100.0/100.0 pos=(125.0,243.5) bodies=27/27 on_fire=false" -Entities: 2 -Alive entities: Player(hp=100, pos=(125.0, 243.5)), Goblin(hp=40, pos=(110.0, 242.0)) - -=== Tick 36 === -31 -32 -33 -34 -35 -36 ggggg -37 TTTTT gggggg -38 T T gggggg -39 T T ggggg . -40 T T ggggg ... -41 T T ## @@@@@ ..... -42 T T ## @@@@@@ ....... -43 T T ## @@@@@@ """"""""""""" ......... -44""" T T ## @@@@@ "":::::::::::::""" .......... -45:::""" ## @@@@@ """::::::::::::::::::"" ........... -46::::::"" ## "":::::::::::::::::::::::"""......... -47::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::""""""""" -48:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -49################################################################################ -50???????????????????????????????????????????????????????????????????????????????? -51???????????????????????????????????????????????????????????????????????????????? -52???????????????????????????????????????????????????????????????????????????????? -53???????????????????????????????????????????????????????????????????????????????? -54???????????????????????????????????????????????????????????????????????????????? -55???????????????????????????????????????????????????????????????????????????????? -Player: "Player hp=100.0/100.0 pos=(125.0,243.5) bodies=27/27 on_fire=false" -Entities: 2 -Alive entities: Player(hp=100, pos=(125.0, 243.5)), Goblin(hp=40, pos=(110.0, 238.5)) - -=== Tick 42 === -31 -32 -33 -34 -35 -36 ggggg -37 TTTTT gggggg -38 T T gggggg -39 T T ggggg . -40 T T ggggg ... -41 T T ## @@@@@ ..... -42 T T ## @@@@@@ ....... -43 T T ## @@@@@@ """"""""""""" ......... -44""" T T ## @@@@@ "":::::::::::::""" .......... -45:::""" ## @@@@@ """::::::::::::::::::"" ........... -46::::::"" ## "":::::::::::::::::::::::"""......... -47::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::""""""""" -48:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -49################################################################################ -50???????????????????????????????????????????????????????????????????????????????? -51???????????????????????????????????????????????????????????????????????????????? -52???????????????????????????????????????????????????????????????????????????????? -53???????????????????????????????????????????????????????????????????????????????? -54???????????????????????????????????????????????????????????????????????????????? -55???????????????????????????????????????????????????????????????????????????????? -Player: "Player hp=100.0/100.0 pos=(125.0,243.5) bodies=27/27 on_fire=false" -Entities: 2 -Alive entities: Player(hp=100, pos=(125.0, 243.5)), Goblin(hp=40, pos=(110.0, 238.5)) - -=== Tick 48 === -31 -32 -33 -34 -35 -36 ggggg -37 TTTTT gggggg -38 T T gggggg -39 T T ggggg . -40 T T ggggg ... -41 T T ## @@@@@ ..... -42 T T ## @@@@@@ ....... -43 T T ## @@@@@@ """"""""""""" ......... -44""" T T ## @@@@@ "":::::::::::::""" .......... -45:::""" ## @@@@@ """::::::::::::::::::"" ........... -46::::::"" ## "":::::::::::::::::::::::"""......... -47::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::""""""""" -48:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -49################################################################################ -50???????????????????????????????????????????????????????????????????????????????? -51???????????????????????????????????????????????????????????????????????????????? -52???????????????????????????????????????????????????????????????????????????????? -53???????????????????????????????????????????????????????????????????????????????? -54???????????????????????????????????????????????????????????????????????????????? -55???????????????????????????????????????????????????????????????????????????????? -Player: "Player hp=100.0/100.0 pos=(125.0,243.5) bodies=27/27 on_fire=false" -Entities: 2 -Alive entities: Player(hp=100, pos=(125.0, 243.5)), Goblin(hp=40, pos=(110.0, 238.5)) - -=== Tick 54 === -31 -32 -33 -34 -35 -36 ggggg -37 TTTTT gggggg -38 T T gggggg -39 T T ggggg . -40 T T ggggg ... -41 T T ## @@@@@ ..... -42 T T ## @@@@@@ ....... -43 T T ## @@@@@@ """"""""""""" ......... -44""" T T ## @@@@@ "":::::::::::::""" .......... -45:::""" ## @@@@@ """::::::::::::::::::"" ........... -46::::::"" ## "":::::::::::::::::::::::"""......... -47::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::""""""""" -48:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -49################################################################################ -50???????????????????????????????????????????????????????????????????????????????? -51???????????????????????????????????????????????????????????????????????????????? -52???????????????????????????????????????????????????????????????????????????????? -53???????????????????????????????????????????????????????????????????????????????? -54???????????????????????????????????????????????????????????????????????????????? -55???????????????????????????????????????????????????????????????????????????????? -Player: "Player hp=100.0/100.0 pos=(125.0,243.5) bodies=27/27 on_fire=false" -Entities: 2 -Alive entities: Player(hp=100, pos=(125.0, 243.5)), Goblin(hp=40, pos=(110.0, 238.5)) - -=== Tick 60 === -31 -32 -33 -34 -35 -36 ggggg -37 TTTTT gggggg -38 T T gggggg -39 T T ggggg . -40 T T ggggg ... -41 T T gggggg @@@@@ ..... -42 T T gggggg @@@@@@ ....... -43 T T ggggg @@@@@@ """"""""""""" ......... -44""" T T ggggg @@@@@ "":::::::::::::""" .......... -45:::""" ## @@@@@ """::::::::::::::::::"" ........... -46::::::"" ## "":::::::::::::::::::::::"""......... -47::::::::"""""""""""""""""""""""""""""""""""::::::::::::::::::::::::::::""""""""" -48:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -49################################################################################ -50???????????????????????????????????????????????????????????????????????????????? -51???????????????????????????????????????????????????????????????????????????????? -52???????????????????????????????????????????????????????????????????????????????? -53???????????????????????????????????????????????????????????????????????????????? -54???????????????????????????????????????????????????????????????????????????????? -55???????????????????????????????????????????????????????????????????????????????? -Player: "Player hp=100.0/100.0 pos=(125.0,243.5) bodies=27/27 on_fire=false" -Entities: 3 -Alive entities: Player(hp=100, pos=(125.0, 243.5)), Goblin(hp=40, pos=(110.0, 238.5)), Goblin(hp=40, pos=(110.0, 242.0)) +Player: "Player hp=150.0/150.0 pos=(124.0,241.0) bodies=47/47 on_fire=false" +Entities: 1 +Alive entities: Player(hp=150, pos=(124.0, 241.0)) diff --git a/src/ai/action.rs b/src/ai/action.rs index ee18845..4fd41ee 100644 --- a/src/ai/action.rs +++ b/src/ai/action.rs @@ -1,8 +1,9 @@ -use serde::{Deserialize, Serialize}; use crate::ai::state::material_from_name; use crate::ai::state::parse_entity_kind; use crate::game::Game; +use crate::physics::projectile::ProjectileType; use crate::world::cell::Cell; +use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(tag = "type", rename_all = "snake_case")] @@ -55,6 +56,14 @@ pub enum AiAction { y: i32, }, CenterCamera, + Shoot { + dir_x: f32, + dir_y: f32, + }, + ToggleFireball, + Descend, + UseItem, + DropItem, } impl AiAction { @@ -74,6 +83,11 @@ impl AiAction { AiAction::SetGravity { .. } => "set_gravity", AiAction::SetCamera { .. } => "set_camera", AiAction::CenterCamera => "center_camera", + AiAction::Shoot { .. } => "shoot", + AiAction::ToggleFireball => "toggle_fireball", + AiAction::Descend => "descend", + AiAction::UseItem => "use_item", + AiAction::DropItem => "drop_item", } } @@ -90,7 +104,12 @@ impl AiAction { game.player.jump(&mut game.entities, on_ground); } AiAction::Wait => {} - AiAction::Paint { x, y, material, radius } => { + AiAction::Paint { + x, + y, + material, + radius, + } => { if let Some(mat) = material_from_name(material) { for dy in -*radius..=*radius { for dx in -*radius..=*radius { @@ -106,7 +125,13 @@ impl AiAction { game.grid.set_material(*x, *y, mat); } } - AiAction::FillRect { x, y, w, h, material } => { + AiAction::FillRect { + x, + y, + w, + h, + material, + } => { if let Some(mat) = material_from_name(material) { for dy in 0..*h { for dx in 0..*w { @@ -151,6 +176,30 @@ impl AiAction { let (px, py) = game.player.center(&game.entities); game.center_camera_on(px, py); } + AiAction::Shoot { dir_x, dir_y } => { + game.player_shoot(*dir_x, *dir_y); + } + AiAction::ToggleFireball => { + game.fireball_mode = !game.fireball_mode; + } + AiAction::Descend => { + game.descend(); + } + AiAction::UseItem => { + game.use_item(0); + } + AiAction::DropItem => { + game.drop_item(0); + } } } } + +pub fn projectile_type_from_name(name: &str) -> Option { + match name.to_lowercase().as_str() { + "arrow" => Some(ProjectileType::Arrow), + "fireball" | "fire" => Some(ProjectileType::Fireball), + "magic" | "bolt" | "magic_bolt" => Some(ProjectileType::MagicBolt), + _ => None, + } +} diff --git a/src/ai/state.rs b/src/ai/state.rs index eeba6ec..c0924fc 100644 --- a/src/ai/state.rs +++ b/src/ai/state.rs @@ -24,6 +24,15 @@ pub struct EntityInfo { pub max_health: f32, pub pos: [f32; 2], pub on_fire: bool, + pub poisoned: bool, + pub frozen: bool, + pub bleeding: bool, + pub level: u32, + pub xp: u32, + pub strength: u32, + pub agility: u32, + pub toughness: u32, + pub willpower: u32, pub body_count: usize, pub bodies: Vec, } @@ -106,6 +115,15 @@ pub fn entity_info(e: &crate::entity::entity::Entity) -> EntityInfo { max_health: e.max_health, pos: [px, py], on_fire: e.on_fire, + poisoned: e.poisoned, + frozen: e.frozen, + bleeding: e.bleeding, + level: e.level, + xp: e.xp, + strength: e.strength, + agility: e.agility, + toughness: e.toughness, + willpower: e.willpower, body_count: bodies.iter().filter(|b| b.alive).count(), bodies, } @@ -201,6 +219,7 @@ pub fn material_from_name(name: &str) -> Option { "smoke" => Some(MaterialId::Smoke), "grass" => Some(MaterialId::Grass), "dirt" => Some(MaterialId::Dirt), + "stairs" => Some(MaterialId::Stairs), _ => None, } } diff --git a/src/entity/body_template.rs b/src/entity/body_template.rs index c977b40..4527c54 100644 --- a/src/entity/body_template.rs +++ b/src/entity/body_template.rs @@ -96,14 +96,14 @@ impl BodyTemplate { p!(2, 5, 45, 25, 80, "boots"), ]; - let n = parts.len(); + let constraints = Self::proximity_constraints(1.5)(&parts); Self { name: "player".to_string(), half_w: 4.0, half_h: 6.0, radius: 0.5, parts, - constraints: Self::auto_constraints(n), + constraints, } } @@ -160,14 +160,14 @@ impl BodyTemplate { p!(2, 4, 100, 170, 75, "skin"), ]; - let n = parts.len(); + let constraints = Self::proximity_constraints(1.5)(&parts); Self { name: "goblin".to_string(), half_w: 4.0, half_h: 6.0, radius: 0.5, parts, - constraints: Self::auto_constraints(n), + constraints, } } @@ -247,6 +247,24 @@ impl BodyTemplate { c } + fn proximity_constraints(max_dist: f32) -> impl Fn(&[BodyPart]) -> Vec<(usize, usize)> { + move |parts| { + let mut c = Vec::new(); + let threshold = max_dist * max_dist; + for i in 0..parts.len() { + for j in (i + 1)..parts.len() { + let dx = parts[i].x - parts[j].x; + let dy = parts[i].y - parts[j].y; + let dist_sq = dx * dx + dy * dy; + if dist_sq <= threshold { + c.push((i, j)); + } + } + } + c + } + } + pub fn apply_to(&self, entity: &mut Entity, cx: f32, cy: f32) { entity.bodies.clear(); entity.constraints.clear(); diff --git a/src/entity/entity.rs b/src/entity/entity.rs index 68b20f3..06cec22 100644 --- a/src/entity/entity.rs +++ b/src/entity/entity.rs @@ -1,5 +1,4 @@ use crate::physics::verlet::{Constraint, SubBody}; -use crate::world::cell::MaterialId; pub type EntityId = u32; @@ -29,6 +28,56 @@ pub struct Entity { pub max_health: f32, pub on_fire: bool, pub fire_timer: u32, + pub poisoned: bool, + pub poison_timer: u32, + pub frozen: bool, + pub frozen_timer: u32, + pub bleeding: bool, + pub bleeding_timer: u32, + pub level: u32, + pub xp: u32, + pub strength: u32, + pub agility: u32, + pub toughness: u32, + pub willpower: u32, + pub counted_for_score: bool, +} + +impl Clone for Entity { + fn clone(&self) -> Self { + Self { + id: self.id, + kind: self.kind, + bodies: self.bodies.clone(), + constraints: self.constraints.clone(), + rest_offsets: self.rest_offsets.clone(), + alive: self.alive, + rigid: self.rigid, + cx: self.cx, + cy: self.cy, + cvx: self.cvx, + cvy: self.cvy, + half_w: self.half_w, + half_h: self.half_h, + health: self.health, + max_health: self.max_health, + on_fire: self.on_fire, + fire_timer: self.fire_timer, + poisoned: self.poisoned, + poison_timer: self.poison_timer, + frozen: self.frozen, + frozen_timer: self.frozen_timer, + bleeding: self.bleeding, + bleeding_timer: self.bleeding_timer, + level: self.level, + xp: self.xp, + strength: self.strength, + agility: self.agility, + toughness: self.toughness, + willpower: self.willpower, + counted_for_score: self.counted_for_score, + } + } } impl Entity { @@ -49,6 +98,19 @@ impl Entity { max_health: 100.0, on_fire: false, fire_timer: 0, + poisoned: false, + poison_timer: 0, + frozen: false, + frozen_timer: 0, + bleeding: false, + bleeding_timer: 0, + level: 1, + xp: 0, + strength: 10, + agility: 10, + toughness: 10, + willpower: 10, + counted_for_score: false, half_w: 3.5, half_h: 3.0, } @@ -106,6 +168,15 @@ impl Entity { } } + pub fn name(&self) -> &'static str { + match self.kind { + EntityKind::Player => "Player", + EntityKind::Goblin => "Goblin", + EntityKind::Slime => "Slime", + EntityKind::Corpse => "Corpse", + } + } + pub fn build_humanoid(&mut self, cx: f32, cy: f32) { let template = crate::entity::body_template::template_for_kind(self.kind); template.apply_to(self, cx, cy); @@ -152,6 +223,64 @@ impl Entity { self.fire_timer = 0; } } + + pub fn recalc_max_health(&mut self) { + let base = match self.kind { + EntityKind::Player => 80.0, + EntityKind::Goblin => 30.0, + EntityKind::Slime => 15.0, + EntityKind::Corpse => 0.0, + }; + self.max_health = base + self.toughness as f32 * 5.0 + self.level as f32 * 10.0; + self.health = self.health.min(self.max_health); + } + + pub fn xp_to_level(&self) -> u32 { + self.level * 100 + } + + pub fn add_xp(&mut self, amount: u32) { + if !self.alive { + return; + } + self.xp += amount; + while self.xp >= self.xp_to_level() { + self.xp -= self.xp_to_level(); + self.level += 1; + self.recalc_max_health(); + self.health = self.max_health; + } + } + + pub fn apply_status_effects(&mut self) { + if self.poisoned { + self.poison_timer += 1; + self.take_damage(0.2); + if self.poison_timer > 180 { + self.poisoned = false; + self.poison_timer = 0; + } + } + if self.bleeding { + self.bleeding_timer += 1; + self.take_damage(0.3); + if self.bleeding_timer > 120 { + self.bleeding = false; + self.bleeding_timer = 0; + } + } + if self.frozen { + self.frozen_timer += 1; + if self.frozen_timer > 90 { + self.frozen = false; + self.frozen_timer = 0; + } + } + } + + pub fn status_effects_active(&self) -> bool { + self.on_fire || self.poisoned || self.frozen || self.bleeding + } } pub struct EntityManager { @@ -173,16 +302,28 @@ impl EntityManager { let mut e = Entity::new(id, kind); match kind { EntityKind::Player => { - e.max_health = 100.0; - e.health = 100.0; + e.strength = 12; + e.agility = 12; + e.toughness = 12; + e.willpower = 12; + e.recalc_max_health(); + e.health = e.max_health; } EntityKind::Goblin => { - e.max_health = 40.0; - e.health = 40.0; + e.strength = 8; + e.agility = 10; + e.toughness = 8; + e.willpower = 6; + e.recalc_max_health(); + e.health = e.max_health; } EntityKind::Slime => { - e.max_health = 25.0; - e.health = 25.0; + e.strength = 6; + e.agility = 6; + e.toughness = 8; + e.willpower = 4; + e.recalc_max_health(); + e.health = e.max_health; } EntityKind::Corpse => { e.alive = false; diff --git a/src/entity/item.rs b/src/entity/item.rs new file mode 100644 index 0000000..d8252bc --- /dev/null +++ b/src/entity/item.rs @@ -0,0 +1,142 @@ +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ItemType { + Dagger, + Sword, + LeatherArmor, + PlateArmor, + HealthPotion, + Food, + Scroll, +} + +#[derive(Clone, Debug)] +pub struct Item { + pub typ: ItemType, + pub x: i32, + pub y: i32, +} + +impl Item { + pub fn new(typ: ItemType, x: i32, y: i32) -> Self { + Self { typ, x, y } + } + + pub fn name(&self) -> &'static str { + match self.typ { + ItemType::Dagger => "Dagger", + ItemType::Sword => "Sword", + ItemType::LeatherArmor => "Leather Armor", + ItemType::PlateArmor => "Plate Armor", + ItemType::HealthPotion => "Health Potion", + ItemType::Food => "Food", + ItemType::Scroll => "Scroll", + } + } + + pub fn display_char(&self) -> char { + match self.typ { + ItemType::Dagger => '/', + ItemType::Sword => '|', + ItemType::LeatherArmor => '[', + ItemType::PlateArmor => '{', + ItemType::HealthPotion => '!', + ItemType::Food => '%', + ItemType::Scroll => '?', + } + } + + pub fn color(&self) -> [u8; 3] { + match self.typ { + ItemType::Dagger => [200, 200, 200], + ItemType::Sword => [240, 240, 240], + ItemType::LeatherArmor => [140, 90, 50], + ItemType::PlateArmor => [180, 180, 190], + ItemType::HealthPotion => [255, 40, 40], + ItemType::Food => [80, 200, 60], + ItemType::Scroll => [255, 220, 120], + } + } + + pub fn is_equipment(&self) -> bool { + matches!( + self.typ, + ItemType::Dagger | ItemType::Sword | ItemType::LeatherArmor | ItemType::PlateArmor + ) + } + + pub fn is_weapon(&self) -> bool { + matches!(self.typ, ItemType::Dagger | ItemType::Sword) + } + + pub fn is_armor(&self) -> bool { + matches!(self.typ, ItemType::LeatherArmor | ItemType::PlateArmor) + } + + pub fn is_consumable(&self) -> bool { + matches!( + self.typ, + ItemType::HealthPotion | ItemType::Food | ItemType::Scroll + ) + } + + pub fn damage_bonus(&self) -> f32 { + match self.typ { + ItemType::Dagger => 3.0, + ItemType::Sword => 6.0, + _ => 0.0, + } + } + + pub fn armor_bonus(&self) -> f32 { + match self.typ { + ItemType::LeatherArmor => 2.0, + ItemType::PlateArmor => 5.0, + _ => 0.0, + } + } + + pub fn heal_amount(&self) -> f32 { + match self.typ { + ItemType::HealthPotion => 40.0, + ItemType::Food => 15.0, + _ => 0.0, + } + } +} + +pub struct ItemManager { + items: Vec, + next_id: u32, +} + +impl ItemManager { + pub fn new() -> Self { + Self { + items: Vec::new(), + next_id: 0, + } + } + + pub fn spawn(&mut self, typ: ItemType, x: i32, y: i32) -> u32 { + let id = self.next_id; + self.next_id += 1; + self.items.push(Item::new(typ, x, y)); + id + } + + pub fn all(&self) -> &[Item] { + &self.items + } + + pub fn all_mut(&mut self) -> &mut Vec { + &mut self.items + } + + pub fn remove_at(&mut self, x: i32, y: i32) -> Option { + if let Some(idx) = self.items.iter().position(|i| i.x == x && i.y == y) { + Some(self.items.remove(idx)) + } else { + None + } + } +} diff --git a/src/entity/mod.rs b/src/entity/mod.rs index 64eb7ca..3b51c46 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -1,7 +1,9 @@ -pub mod entity; -pub mod player; pub mod body_template; +pub mod entity; +pub mod item; +pub mod player; -pub use entity::{EntityManager, EntityKind}; +pub use body_template::{template_for_kind, BodyPart, BodyTemplate}; +pub use entity::{EntityKind, EntityManager}; +pub use item::{Item, ItemManager, ItemType}; pub use player::Player; -pub use body_template::{BodyTemplate, BodyPart, template_for_kind}; diff --git a/src/entity/player.rs b/src/entity/player.rs index 0f8692b..11cb43f 100644 --- a/src/entity/player.rs +++ b/src/entity/player.rs @@ -1,9 +1,13 @@ -use crate::entity::entity::{Entity, EntityKind, EntityManager, EntityId}; +use crate::entity::entity::{Entity, EntityId, EntityKind, EntityManager}; +use crate::entity::item::Item; pub struct Player { pub entity_id: EntityId, pub move_speed: f32, pub jump_force: f32, + pub inventory: Vec, + pub weapon: Option, + pub armor: Option, } impl Player { @@ -13,6 +17,9 @@ impl Player { entity_id: id, move_speed: 0.5, jump_force: 1.5, + inventory: Vec::new(), + weapon: None, + armor: None, } } @@ -52,7 +59,14 @@ impl Player { manager.get(self.entity_id) } + pub fn entity_mut<'a>(&self, manager: &'a mut EntityManager) -> Option<&'a mut Entity> { + manager.get_mut(self.entity_id) + } + pub fn center(&self, manager: &EntityManager) -> (f32, f32) { - manager.get(self.entity_id).map(|e| e.center()).unwrap_or((0.0, 0.0)) + manager + .get(self.entity_id) + .map(|e| e.center()) + .unwrap_or((0.0, 0.0)) } } diff --git a/src/game.rs b/src/game.rs index c7d68bb..85bf03e 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,11 +1,14 @@ use std::time::{Duration, Instant}; use crate::entity::player::Player; -use crate::entity::{EntityKind, EntityManager}; +use crate::entity::{EntityKind, EntityManager, ItemManager, ItemType}; use crate::input::{Action, InputHandler}; use crate::physics::collision::resolve_grid_collision; +use crate::physics::projectile::{ProjectileManager, ProjectileType}; use crate::physics::verlet::VerletSolver; +use crate::render::lighting; use crate::render::Renderer; +use crate::ui::UiLayer; use crate::world::cell::MaterialId; use crate::world::cellular::CellularAutomaton; use crate::world::grid::Grid; @@ -15,8 +18,11 @@ pub struct Game { pub ca: CellularAutomaton, pub verlet: VerletSolver, pub entities: EntityManager, + pub projectiles: ProjectileManager, + pub items: ItemManager, pub player: Player, pub input: InputHandler, + pub ui: UiLayer, pub cam_x: i32, pub cam_y: i32, pub running: bool, @@ -24,6 +30,14 @@ pub struct Game { pub fixed_dt: Duration, pub accumulator: Duration, pub last_time: Instant, + pub last_shot_tick: u64, + pub shot_cooldown: u64, + pub fireball_mode: bool, + pub corpse_decomp_timer: u64, + pub kills: u32, + pub score: u32, + pub depth: u32, + pub fps: f32, } impl Game { @@ -35,8 +49,11 @@ impl Game { ca: CellularAutomaton::new(), verlet: VerletSolver::new(), entities, + projectiles: ProjectileManager::new(), + items: ItemManager::new(), player, input: InputHandler::new(), + ui: UiLayer::new(), cam_x: 100, cam_y: 100, running: true, @@ -44,6 +61,14 @@ impl Game { 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, } } @@ -155,11 +180,22 @@ impl Game { break; } } + 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); } pub fn center_camera_on(&mut self, px: f32, py: f32) { @@ -176,12 +212,24 @@ impl Game { self.input.start(); self.last_time = Instant::now(); + let mut frame_count = 0u32; + let mut frame_time_acc = Duration::ZERO; + let mut last_fps_print = Instant::now(); while self.running { let now = Instant::now(); let frame_time = now.duration_since(self.last_time); self.last_time = now; self.accumulator += frame_time; + frame_time_acc += frame_time; + frame_count += 1; + if last_fps_print.elapsed() >= Duration::from_secs(1) { + let avg_ms = frame_time_acc.as_secs_f32() * 1000.0 / frame_count as f32; + self.fps = 1000.0 / avg_ms; + frame_count = 0; + frame_time_acc = Duration::ZERO; + last_fps_print = Instant::now(); + } while self.accumulator >= self.fixed_dt { self.fixed_update(); @@ -194,7 +242,26 @@ impl Game { self.cam_x = px as i32 - (vw as i32 / 2); self.cam_y = py as i32 - (vh as i32 / 2); - if let Err(e) = renderer.render(&self.grid, &self.entities, self.cam_x, self.cam_y) { + self.build_ui(vw, vh); + + let light = lighting::compute_lighting( + &self.grid, + self.cam_x, + self.cam_y, + vw, + vh, + lighting::ambient_light(), + ); + + if let Err(e) = renderer.render( + &self.grid, + &self.entities, + &self.items, + &self.ui, + self.cam_x, + self.cam_y, + Some(&light), + ) { eprintln!("Render error: {}", e); break; } @@ -218,6 +285,14 @@ impl Game { self.running = false; return; } + Action::ShootLeft => self.player_shoot(-1.0, 0.0), + Action::ShootRight => self.player_shoot(1.0, 0.0), + Action::ShootUp => self.player_shoot(0.0, -1.0), + Action::ShootDown => self.player_shoot(0.0, 1.0), + Action::ToggleFireball => self.fireball_mode = !self.fireball_mode, + Action::Descend => self.descend(), + Action::UseItem => self.use_item(0), + Action::DropItem => self.drop_item(0), Action::Paint(brush) => { let mat = brush.to_material(); let cx = self.cam_x + (vw as i32 / 2); @@ -266,17 +341,96 @@ impl Game { self.player.stop_horizontal(&mut self.entities); } - for action in &held { + let mut held = held; + for action in &mut held { match action { Action::MoveCameraLeft => self.cam_x -= 2, Action::MoveCameraRight => self.cam_x += 2, Action::MoveCameraUp => self.cam_y -= 2, Action::MoveCameraDown => self.cam_y += 2, + Action::ShootLeft => self.player_shoot(-1.0, 0.0), + Action::ShootRight => self.player_shoot(1.0, 0.0), + Action::ShootUp => self.player_shoot(0.0, -1.0), + Action::ShootDown => self.player_shoot(0.0, 1.0), _ => {} } } } + pub fn build_ui(&mut self, vw: usize, vh: usize) { + self.ui.clear(); + self.ui.tick_messages(); + + let player = self.player.entity(&self.entities).cloned(); + let brush = self + .input + .paint_brush + .to_material() + .unwrap_or(crate::world::cell::MaterialId::Empty); + let player_alive = player.as_ref().map(|e| e.alive).unwrap_or(false); + let ui_w = (vw as i32) * crate::ui::UI_SCALE; + let ui_h = (vh as i32) * crate::ui::UI_SCALE; + self.ui + .draw_character_panel(1, 1, player.as_ref(), &self.player); + self.ui.draw_hud( + ui_w as usize, + ui_h as usize, + player.as_ref(), + self.tick, + brush, + self.kills, + self.score, + self.depth, + &self.player, + self.fps, + ); + let msg_x = (ui_w / 2) - 24; + self.ui.draw_messages(msg_x, 4); + self.ui.draw_damage_numbers(self.cam_x, self.cam_y); + self.ui.draw_edge_indicators( + ui_w as usize, + ui_h as usize, + self.entities.all(), + self.cam_x, + self.cam_y, + ); + self.ui + .draw_entity_labels(self.entities.all(), self.cam_x, self.cam_y); + self.ui + .draw_status_icons(self.entities.all(), self.cam_x, self.cam_y); + self.ui.draw_minimap( + ui_w as usize, + ui_h as usize, + &self.grid, + self.entities.all(), + self.cam_x, + self.cam_y, + ); + if !player_alive { + self.ui + .draw_death_screen(ui_w as usize, ui_h as usize, self.kills, self.score); + } + + for e in self.entities.all() { + if !e.alive || e.kind == EntityKind::Corpse { + continue; + } + let (sx, sy) = crate::ui::entity_screen_pos_ui(e, self.cam_x, self.cam_y); + let top = sy - (e.half_h as i32 * crate::ui::UI_SCALE) - 2; + self.ui + .draw_health_bar(sx - 6, top, e.health, e.max_health, 12); + } + + for p in self.projectiles.all() { + let sx = (p.x as i32 - self.cam_x) * crate::ui::UI_SCALE; + let sy = (p.y as i32 - self.cam_y) * crate::ui::UI_SCALE; + if sx >= 0 && sx < ui_w && sy >= 0 && sy < ui_h { + self.ui + .set(sx, sy, p.draw_char(), p.draw_color(), [0, 0, 0]); + } + } + } + pub fn check_on_ground(&self) -> bool { if let Some(e) = self.player.entity(&self.entities) { let bottom_y = e.cy + e.half_h; @@ -299,13 +453,19 @@ impl Game { pub fn fixed_update(&mut self) { self.tick += 1; + self.update_active_chunks(); self.ca.step(&mut self.grid); self.update_entities(); self.update_slime_ai(); + self.update_goblin_ai(); self.update_combat(); - + self.update_projectiles(); self.apply_world_damage(); + self.decompose_corpses(); + self.update_status_effects(); + self.update_score(); + self.update_item_pickup(); if self.tick % 30 == 0 { self.try_spawn_goblin(); @@ -313,6 +473,256 @@ impl Game { if self.tick % 45 == 0 { self.try_spawn_slime(); } + + self.grid.swap_modified_flags(); + } + + fn update_score(&mut self) { + let mut new_kills = 0; + for e in self.entities.all_mut() { + if !e.alive && e.kind == EntityKind::Corpse && !e.counted_for_score { + e.counted_for_score = true; + new_kills += 1; + } + } + if new_kills > 0 { + self.kills += new_kills; + self.score += new_kills * 10; + if let Some(player) = self.player.entity_mut(&mut self.entities) { + player.add_xp(new_kills * 25); + } + } + } + + fn update_status_effects(&mut self) { + for e in self.entities.all_mut() { + if e.alive { + e.apply_status_effects(); + } + } + } + + fn update_item_pickup(&mut self) { + let (px, py) = self.player.center(&self.entities); + let ix = px as i32; + let iy = py as i32; + let mut picked = Vec::new(); + for (idx, item) in self.items.all().iter().enumerate() { + if (item.x - ix).abs() <= 1 && (item.y - iy).abs() <= 1 { + picked.push(idx); + } + } + for idx in picked.into_iter().rev() { + let item = self.items.all_mut().remove(idx); + self.ui.add_message(&format!("Picked up {}", item.name())); + self.player.inventory.push(item); + } + } + + pub fn descend(&mut self) { + if let Some(e) = self.player.entity(&self.entities) { + let foot_x = e.cx as i32; + let foot_y = (e.cy + e.half_h).ceil() as i32; + if self.grid.get(foot_x, foot_y).material != MaterialId::Stairs { + return; + } + } + self.depth += 1; + self.grid = Grid::new(); + self.entities = EntityManager::new(); + self.player = Player::new(&mut self.entities); + self.projectiles = ProjectileManager::new(); + self.items = ItemManager::new(); + self.corpse_decomp_timer = 0; + self.init_world(); + self.ui + .add_message(&format!("Descended to depth {}", self.depth)); + } + + pub fn use_item(&mut self, index: usize) { + if index >= self.player.inventory.len() { + return; + } + let item = self.player.inventory[index].clone(); + let name = item.name(); + if item.is_weapon() { + self.player.weapon = Some(item); + self.ui.add_message(&format!("Equipped {}", name)); + } else if item.is_armor() { + self.player.armor = Some(item); + self.ui.add_message(&format!("Equipped {}", name)); + } else if item.is_consumable() { + let heal = item.heal_amount(); + if let Some(p) = self.player.entity_mut(&mut self.entities) { + p.health = (p.health + heal).min(p.max_health); + } + self.player.inventory.remove(index); + self.ui.add_message(&format!("Consumed {}", name)); + } + } + + pub fn drop_item(&mut self, index: usize) { + if index >= self.player.inventory.len() { + return; + } + let item = self.player.inventory.remove(index); + let (px, py) = self.player.center(&self.entities); + self.items.spawn(item.typ, px as i32, py as i32 + 1); + self.ui.add_message(&format!("Dropped {}", item.name())); + } + + fn update_active_chunks(&mut self) { + self.grid.deactivate_all(); + + for e in self.entities.all() { + let (cx, cy) = e.center(); + self.grid.activate_around(cx as i32, cy as i32, 2); + } + + for p in self.projectiles.all() { + self.grid.activate_around(p.x as i32, p.y as i32, 1); + } + + for item in self.items.all() { + self.grid.activate_around(item.x, item.y, 1); + } + + let chunk_size = self.grid.chunk_size as i32; + for cy in 0..self.grid.chunks_y as i32 { + for cx in 0..self.grid.chunks_x as i32 { + let idx = self.grid.chunk_index(cx, cy); + if self.grid.chunks[idx].modified || self.grid.chunks[idx].was_modified { + self.grid + .activate_around(cx * chunk_size, cy * chunk_size, 1); + } + } + } + } + + pub fn update_projectiles(&mut self) { + self.projectiles.update(&self.grid); + self.projectiles + .resolve_hits(&mut self.grid, self.entities.all_mut(), &mut self.ui); + self.projectiles.cull_dead(); + } + + pub fn player_shoot(&mut self, dir_x: f32, dir_y: f32) { + if self.tick > self.last_shot_tick && self.tick - self.last_shot_tick < self.shot_cooldown { + return; + } + let (px, py) = self.player.center(&self.entities); + let owner = self.player.entity_id; + let speed = if self.fireball_mode { 2.2 } else { 3.0 }; + let vx = dir_x * speed; + let vy = dir_y * speed; + let typ = if self.fireball_mode { + ProjectileType::Fireball + } else if self.tick % 4 == 0 { + ProjectileType::MagicBolt + } else { + ProjectileType::Arrow + }; + let spawn_x = px + dir_x * 3.0; + let spawn_y = py + dir_y * 3.0; + let damage_bonus = self + .player + .weapon + .as_ref() + .map(|w| w.damage_bonus()) + .unwrap_or(0.0); + self.projectiles + .spawn(typ, spawn_x, spawn_y, vx, vy, owner, damage_bonus); + self.last_shot_tick = self.tick; + } + + fn update_goblin_ai(&mut self) { + let (px, py) = self.player.center(&self.entities); + let goblin_data: Vec<(usize, f32, f32, f32)> = self + .entities + .all() + .iter() + .enumerate() + .filter(|(_, e)| e.alive && e.kind == EntityKind::Goblin) + .map(|(i, e)| (i, e.cx, e.cy, e.health)) + .collect(); + + for (idx, gx, gy, health) in goblin_data { + if !self.entities.all()[idx].rigid { + continue; + } + let dx = px - gx; + let dy = py - gy; + let dist_sq = dx * dx + dy * dy; + let dist = dist_sq.sqrt(); + if dist < 0.5 { + continue; + } + let dir_x = dx / dist; + let _dir_y = dy / dist; + if health < 10.0 { + let flee = self.entities.all()[idx].cx < px; + let move_dir = if flee { -1.0 } else { 1.0 }; + if let Some(e) = self.entities.all_mut().get_mut(idx) { + e.set_horizontal_vel(move_dir * 0.6); + } + } else if dist > 6.0 { + if let Some(e) = self.entities.all_mut().get_mut(idx) { + e.set_horizontal_vel(dir_x * 0.45); + } + } else if dist < 4.0 { + if let Some(e) = self.entities.all_mut().get_mut(idx) { + e.set_horizontal_vel(-dir_x * 0.45); + } + } + } + } + + fn decompose_corpses(&mut self) { + self.corpse_decomp_timer += 1; + if self.corpse_decomp_timer < 40 { + return; + } + self.corpse_decomp_timer = 0; + let mut drops: Vec<(i32, i32, usize, usize)> = Vec::new(); + for (idx, e) in self.entities.all().iter().enumerate() { + if e.alive || e.kind != EntityKind::Corpse { + continue; + } + if e.bodies.is_empty() { + continue; + } + let alive_indices: Vec = e + .bodies + .iter() + .enumerate() + .filter(|(_, b)| b.alive) + .map(|(i, _)| i) + .collect(); + if alive_indices.is_empty() { + continue; + } + let pick = self.ca.random_usize(alive_indices.len()); + let bi = alive_indices[pick]; + let b = &e.bodies[bi]; + let gx = b.x.floor() as i32; + let gy = b.y.floor() as i32; + if self.grid.in_bounds(gx, gy) && self.grid.get(gx, gy).is_empty() { + drops.push((gx, gy, idx, bi)); + } + } + for (gx, gy, eidx, bidx) in drops { + self.grid.set_material(gx, gy, MaterialId::Flesh); + if let Some(e) = self.entities.all_mut().get_mut(eidx) { + if let Some(b) = e.bodies.get_mut(bidx) { + b.alive = false; + b.health = 0.0; + } + let alive_count = e.bodies.iter().filter(|b| b.alive).count(); + if alive_count == 0 { + e.health = 0.0; + } + } + } } fn update_slime_ai(&mut self) { @@ -328,7 +738,7 @@ impl Game { .map(|(i, e)| (i, e.cx, e.cy, e.rigid, e.health)) .collect(); - for (idx, sx, sy, rigid, health) in slime_data { + for (idx, sx, sy, rigid, _health) in slime_data { if !rigid { continue; } @@ -342,7 +752,7 @@ impl Game { let jump_phase = tick % 60; if jump_phase == 0 && dist < 40.0 { let dir_x = dx / dist; - let dir_y = dy / dist; + let _dir_y = dy / dist; let jump_power = 0.8 + (1.0 - dist / 40.0).min(0.5) * 0.5; if let Some(e) = self.entities.all_mut().get_mut(idx) { e.set_horizontal_vel(dir_x * jump_power); @@ -381,24 +791,52 @@ impl Game { .map(|(i, e)| (i, e.kind, e.cx, e.cy, e.half_w, e.half_h)) .collect(); - for (idx, kind, ex, ey, ew, eh) in enemy_data { + for (_idx, kind, ex, ey, ew, eh) in enemy_data { let dx = (ex - player_center.0).abs(); let dy = (ey - player_center.1).abs(); if dx < ew + player_half_w && dy < eh + player_half_h { if self.tick % 20 == 0 { - let damage = match kind { + let base = match kind { EntityKind::Goblin => 8.0, EntityKind::Slime => 5.0, _ => 0.0, }; + let armor = self + .player + .armor + .as_ref() + .map(|a| a.armor_bonus()) + .unwrap_or(0.0); + let damage = (base - armor).max(0.0); if damage > 0.0 { + let player_before = self + .entities + .get(player_id) + .map(|e| e.health) + .unwrap_or(0.0); if let Some(p) = self.entities.get_mut(player_id) { p.take_damage(damage); } + let player_after = self + .entities + .get(player_id) + .map(|e| e.health) + .unwrap_or(0.0); + self.ui.add_damage_number( + player_center.0, + player_center.1 - player_half_h - 2.0, + &format!("-{:.0}", player_before - player_after), + ); let knockback_dir = if player_center.0 < ex { -1.0 } else { 1.0 }; if let Some(p) = self.entities.get_mut(player_id) { p.set_horizontal_vel(knockback_dir * 0.5); } + let msg = match kind { + EntityKind::Goblin => "Goblin hits you!", + EntityKind::Slime => "Slime burns you!", + _ => "Enemy hits you!", + }; + self.ui.add_message(msg); } } } @@ -542,9 +980,10 @@ impl Game { e.sync_bodies_to_center(); if touching_lava { + e.health -= 1.0; for b in &mut e.bodies { if b.alive { - b.health -= 0.5; + b.health -= 1.0; if !b.on_fire { b.on_fire = true; } @@ -552,6 +991,7 @@ impl Game { } } if touching_fire { + e.health -= 0.15; for b in &mut e.bodies { if b.alive { b.health -= 0.15; @@ -562,6 +1002,7 @@ impl Game { } } if touching_acid { + e.health -= 0.25; for b in &mut e.bodies { if b.alive { b.health -= 0.25; @@ -755,10 +1196,15 @@ impl Game { substeps: u32, ) { let grid = &self.grid; - let mut bodies = self.entities.all()[idx].bodies.clone(); - let constraints = self.entities.all()[idx].constraints.clone(); + let e = self.entities.all_mut().get_mut(idx).unwrap(); + let alive = e.alive; + let bodies = &mut e.bodies; + let constraints = &e.constraints; - for b in &mut bodies { + let effective_substeps = if alive { substeps } else { 1 }; + let constraint_iters = if alive { 4 } else { 1 }; + + for b in bodies.iter_mut() { if !b.alive { continue; } @@ -772,10 +1218,10 @@ impl Game { } } - for _ in 0..substeps { - solver.integrate(&mut bodies); + for _ in 0..effective_substeps { + solver.integrate(bodies); - for b in &mut bodies { + for b in bodies.iter_mut() { if !b.alive { continue; } @@ -797,9 +1243,9 @@ impl Game { } } - for _ci in 0..4 { - solver.solve_constraints(&mut bodies, &constraints, 1); - for b in &mut bodies { + for _ci in 0..constraint_iters { + solver.solve_constraints(bodies, constraints, 1); + for b in bodies.iter_mut() { if !b.alive { continue; } @@ -807,10 +1253,6 @@ impl Game { } } } - - if let Some(e) = self.entities.all_mut().get_mut(idx) { - e.bodies = bodies; - } } fn apply_world_damage(&mut self) { diff --git a/src/input.rs b/src/input.rs index 196f89c..df63251 100644 --- a/src/input.rs +++ b/src/input.rs @@ -8,11 +8,19 @@ pub enum Action { MoveLeft, MoveRight, Jump, + ShootLeft, + ShootRight, + ShootUp, + ShootDown, + ToggleFireball, MoveCameraUp, MoveCameraDown, MoveCameraLeft, MoveCameraRight, Paint(MaterialBrush), + Descend, + UseItem, + DropItem, Quit, } @@ -39,6 +47,10 @@ pub enum HeldKey { CamRight, CamUp, CamDown, + ShootLeft, + ShootRight, + ShootUp, + ShootDown, } struct HeldState { @@ -70,16 +82,14 @@ 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, } })); } @@ -94,10 +104,14 @@ impl InputHandler { match code { KeyCode::Left | KeyCode::Char('a') => Some(HeldKey::Left), KeyCode::Right | KeyCode::Char('d') => Some(HeldKey::Right), - KeyCode::Char('h') => Some(HeldKey::CamLeft), - KeyCode::Char('l') => Some(HeldKey::CamRight), - KeyCode::Char('k') => Some(HeldKey::CamUp), - KeyCode::Char('j') => Some(HeldKey::CamDown), + KeyCode::Char('y') => Some(HeldKey::CamLeft), + KeyCode::Char('u') => Some(HeldKey::CamRight), + KeyCode::Char('i') => Some(HeldKey::CamUp), + KeyCode::Char('o') => Some(HeldKey::CamDown), + KeyCode::Char('h') => Some(HeldKey::ShootLeft), + KeyCode::Char('l') => Some(HeldKey::ShootRight), + KeyCode::Char('k') => Some(HeldKey::ShootUp), + KeyCode::Char('j') => Some(HeldKey::ShootDown), _ => None, } } @@ -121,7 +135,13 @@ impl InputHandler { }; for ev in events { - if let Event::Key(KeyEvent { code, modifiers, kind, .. }) = ev { + if let Event::Key(KeyEvent { + code, + modifiers, + kind, + .. + }) = ev + { let is_press = kind == KeyEventKind::Press; let is_repeat = kind == KeyEventKind::Repeat; let is_release = kind == KeyEventKind::Release; @@ -141,28 +161,111 @@ impl InputHandler { one_shots.push(Action::Quit); continue; } - KeyCode::Char('1') => { self.paint_brush = MaterialBrush::Sand; one_shots.push(Action::Paint(MaterialBrush::Sand)); continue; } - KeyCode::Char('2') => { self.paint_brush = MaterialBrush::Water; one_shots.push(Action::Paint(MaterialBrush::Water)); continue; } - KeyCode::Char('3') => { self.paint_brush = MaterialBrush::Stone; one_shots.push(Action::Paint(MaterialBrush::Stone)); continue; } - KeyCode::Char('4') => { self.paint_brush = MaterialBrush::Lava; one_shots.push(Action::Paint(MaterialBrush::Lava)); continue; } - KeyCode::Char('5') => { self.paint_brush = MaterialBrush::Wood; one_shots.push(Action::Paint(MaterialBrush::Wood)); continue; } - KeyCode::Char('6') => { self.paint_brush = MaterialBrush::Acid; one_shots.push(Action::Paint(MaterialBrush::Acid)); continue; } - KeyCode::Char('7') => { self.paint_brush = MaterialBrush::Grass; one_shots.push(Action::Paint(MaterialBrush::Grass)); continue; } - KeyCode::Char('8') => { self.paint_brush = MaterialBrush::Dirt; one_shots.push(Action::Paint(MaterialBrush::Dirt)); continue; } - KeyCode::Char('9') => { self.paint_brush = MaterialBrush::Fire; one_shots.push(Action::Paint(MaterialBrush::Fire)); continue; } - KeyCode::Char('0') => { self.paint_brush = MaterialBrush::Flesh; one_shots.push(Action::Paint(MaterialBrush::Flesh)); continue; } - KeyCode::Char('x') => { self.paint_brush = MaterialBrush::Erase; one_shots.push(Action::Paint(MaterialBrush::Erase)); continue; } + KeyCode::Char('1') => { + self.paint_brush = MaterialBrush::Sand; + one_shots.push(Action::Paint(MaterialBrush::Sand)); + continue; + } + KeyCode::Char('2') => { + self.paint_brush = MaterialBrush::Water; + one_shots.push(Action::Paint(MaterialBrush::Water)); + continue; + } + KeyCode::Char('3') => { + self.paint_brush = MaterialBrush::Stone; + one_shots.push(Action::Paint(MaterialBrush::Stone)); + continue; + } + KeyCode::Char('4') => { + self.paint_brush = MaterialBrush::Lava; + one_shots.push(Action::Paint(MaterialBrush::Lava)); + continue; + } + KeyCode::Char('5') => { + self.paint_brush = MaterialBrush::Wood; + one_shots.push(Action::Paint(MaterialBrush::Wood)); + continue; + } + KeyCode::Char('6') => { + self.paint_brush = MaterialBrush::Acid; + one_shots.push(Action::Paint(MaterialBrush::Acid)); + continue; + } + KeyCode::Char('7') => { + self.paint_brush = MaterialBrush::Grass; + one_shots.push(Action::Paint(MaterialBrush::Grass)); + continue; + } + KeyCode::Char('8') => { + self.paint_brush = MaterialBrush::Dirt; + one_shots.push(Action::Paint(MaterialBrush::Dirt)); + continue; + } + KeyCode::Char('9') => { + self.paint_brush = MaterialBrush::Fire; + one_shots.push(Action::Paint(MaterialBrush::Fire)); + continue; + } + KeyCode::Char('0') => { + self.paint_brush = MaterialBrush::Flesh; + one_shots.push(Action::Paint(MaterialBrush::Flesh)); + continue; + } + KeyCode::Char('x') => { + self.paint_brush = MaterialBrush::Erase; + one_shots.push(Action::Paint(MaterialBrush::Erase)); + continue; + } + KeyCode::Char('f') => { + one_shots.push(Action::ToggleFireball); + continue; + } + KeyCode::Char('>') => { + one_shots.push(Action::Descend); + continue; + } + KeyCode::Char('e') => { + one_shots.push(Action::UseItem); + continue; + } + KeyCode::Char('r') => { + one_shots.push(Action::DropItem); + continue; + } + KeyCode::Char('k') => { + one_shots.push(Action::ShootUp); + continue; + } + KeyCode::Char('j') => { + one_shots.push(Action::ShootDown); + continue; + } + KeyCode::Char('h') => { + one_shots.push(Action::ShootLeft); + continue; + } + KeyCode::Char('l') => { + one_shots.push(Action::ShootRight); + continue; + } _ => {} } } if let Some(held_key) = Self::key_to_held(code) { if is_press || is_repeat { - let prev_got_release = self.held.get(&held_key).map(|s| s.got_release).unwrap_or(false); - self.held.insert(held_key, HeldState { - last_seen: Instant::now(), - got_release: prev_got_release, - }); + let prev_got_release = self + .held + .get(&held_key) + .map(|s| s.got_release) + .unwrap_or(false); + self.held.insert( + held_key, + HeldState { + last_seen: Instant::now(), + got_release: prev_got_release, + }, + ); } else if is_release { self.release(held_key); for state in self.held.values_mut() { @@ -209,10 +312,31 @@ impl InputHandler { } } - if self.is_held(HeldKey::CamLeft) { actions.push(Action::MoveCameraLeft); } - if self.is_held(HeldKey::CamRight) { actions.push(Action::MoveCameraRight); } - if self.is_held(HeldKey::CamUp) { actions.push(Action::MoveCameraUp); } - if self.is_held(HeldKey::CamDown) { actions.push(Action::MoveCameraDown); } + if self.is_held(HeldKey::CamLeft) { + actions.push(Action::MoveCameraLeft); + } + if self.is_held(HeldKey::CamRight) { + actions.push(Action::MoveCameraRight); + } + if self.is_held(HeldKey::CamUp) { + actions.push(Action::MoveCameraUp); + } + if self.is_held(HeldKey::CamDown) { + actions.push(Action::MoveCameraDown); + } + + if self.is_held(HeldKey::ShootLeft) { + actions.push(Action::ShootLeft); + } + if self.is_held(HeldKey::ShootRight) { + actions.push(Action::ShootRight); + } + if self.is_held(HeldKey::ShootUp) { + actions.push(Action::ShootUp); + } + if self.is_held(HeldKey::ShootDown) { + actions.push(Action::ShootDown); + } actions } @@ -239,5 +363,4 @@ impl MaterialBrush { MaterialBrush::Erase => None, } } - } diff --git a/src/lib.rs b/src/lib.rs index ff5b5fc..4eb2da8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,8 @@ -pub mod world; -pub mod physics; -pub mod entity; -pub mod render; -pub mod input; -pub mod game; pub mod ai; +pub mod entity; +pub mod game; +pub mod input; +pub mod physics; +pub mod render; +pub mod ui; +pub mod world; diff --git a/src/main.rs b/src/main.rs index daff744..d195563 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use verbatim::ai; use verbatim::game::Game; +use verbatim::render::lighting; use verbatim::render::terminal::TerminalRenderer; use verbatim::render::window_input::WindowInput; use verbatim::world::cell::MaterialId; @@ -31,6 +32,15 @@ struct Cli { #[arg(long)] replay_file: Option, + + #[arg(long, default_value_t = 600)] + benchmark_ticks: u32, + + #[arg(long, default_value = "graphics")] + benchmark_renderer: String, + + #[arg(long, default_value = "benchmark_results.json")] + benchmark_output: String, } trait GpuRenderer { @@ -41,8 +51,11 @@ trait GpuRenderer { &mut self, grid: &verbatim::world::grid::Grid, entities: &verbatim::entity::EntityManager, + items: &verbatim::entity::item::ItemManager, + ui: &verbatim::ui::UiLayer, cam_x: i32, cam_y: i32, + lighting: Option<&lighting::LightGrid>, ); fn grid_w(&self) -> usize; fn grid_h(&self) -> usize; @@ -56,10 +69,15 @@ impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer { &mut self, grid: &verbatim::world::grid::Grid, entities: &verbatim::entity::EntityManager, + items: &verbatim::entity::item::ItemManager, + ui: &verbatim::ui::UiLayer, cam_x: i32, cam_y: i32, + lighting: Option<&lighting::LightGrid>, ) { - verbatim::render::vulkan::VulkanRenderer::render(self, grid, entities, cam_x, cam_y) + verbatim::render::vulkan::VulkanRenderer::render( + self, grid, entities, items, ui, cam_x, cam_y, lighting, + ) } fn grid_w(&self) -> usize { verbatim::render::vulkan::VulkanRenderer::grid_w(self) @@ -77,10 +95,15 @@ impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer { &mut self, grid: &verbatim::world::grid::Grid, entities: &verbatim::entity::EntityManager, + items: &verbatim::entity::item::ItemManager, + ui: &verbatim::ui::UiLayer, cam_x: i32, cam_y: i32, + lighting: Option<&lighting::LightGrid>, ) { - verbatim::render::graphics::GraphicsRenderer::render(self, grid, entities, cam_x, cam_y) + verbatim::render::graphics::GraphicsRenderer::render( + self, grid, entities, items, ui, cam_x, cam_y, lighting, + ) } fn grid_w(&self) -> usize { verbatim::render::graphics::GraphicsRenderer::grid_w(self) @@ -133,9 +156,20 @@ fn main() { std::process::exit(1); } } + "capture" => { + if cli.headless_ticks > 0 { + run_capture(cli.headless_ticks); + } else { + eprintln!("Use --headless-ticks N with --mode capture"); + std::process::exit(1); + } + } + "benchmark" => { + run_benchmark_mode(&cli); + } _ => { eprintln!( - "Unknown mode: {}. Use terminal, ascii, graphics, pipe, test, replay, or headless.", + "Unknown mode: {}. Use terminal, ascii, graphics, pipe, test, replay, headless, or capture.", cli.mode ); std::process::exit(1); @@ -149,7 +183,7 @@ fn run_gpu_mode(title: &str) { .create_window( Window::default_attributes() .with_title(title) - .with_inner_size(winit::dpi::LogicalSize::new(160 * 10, 50 * 10)), + .with_inner_size(winit::dpi::LogicalSize::new(1600, 900)), ) .expect("Failed to create window"); let window = Arc::new(window); @@ -175,6 +209,10 @@ fn run_gpu_mode(title: &str) { let mut last_time = Instant::now(); let mut accumulator = Duration::ZERO; let mut running = true; + let target_frame_time = Duration::from_nanos(1_000_000_000 / 60); + let mut frame_count = 0u32; + let mut frame_time_acc = Duration::ZERO; + let mut last_fps_print = Instant::now(); event_loop .run(|event, ctrl| { @@ -235,6 +273,28 @@ fn run_gpu_mode(title: &str) { game.player.stop_horizontal(&mut game.entities); } + if input.shoot_left { + game.player_shoot(-1.0, 0.0); + } else if input.shoot_right { + game.player_shoot(1.0, 0.0); + } else if input.shoot_up { + game.player_shoot(0.0, -1.0); + } else if input.shoot_down { + game.player_shoot(0.0, 1.0); + } + if input.toggle_fireball { + game.fireball_mode = !game.fireball_mode; + } + if input.descend { + game.descend(); + } + if input.use_item { + game.use_item(0); + } + if input.drop_item { + game.drop_item(0); + } + if input.cam_left { game.cam_x -= 3; } @@ -287,7 +347,33 @@ fn run_gpu_mode(title: &str) { game.cam_x = px as i32 - (vw as i32 / 2); game.cam_y = py as i32 - (vh as i32 / 2); - renderer.render(&game.grid, &game.entities, game.cam_x, game.cam_y); + game.build_ui(vw, vh); + + renderer.render( + &game.grid, + &game.entities, + &game.items, + &game.ui, + game.cam_x, + game.cam_y, + None, + ); + + let elapsed = Instant::now().duration_since(last_time); + if elapsed < target_frame_time { + std::thread::sleep(target_frame_time - elapsed); + } + + let frame_time = Instant::now().duration_since(last_time); + frame_time_acc += frame_time; + frame_count += 1; + if last_fps_print.elapsed() >= Duration::from_secs(1) { + let avg_ms = frame_time_acc.as_secs_f32() * 1000.0 / frame_count as f32; + game.fps = 1000.0 / avg_ms; + frame_count = 0; + frame_time_acc = Duration::ZERO; + last_fps_print = Instant::now(); + } } _ => {} } @@ -295,6 +381,207 @@ fn run_gpu_mode(title: &str) { .expect("event loop error"); } +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(); + + eprintln!("Benchmark: {} ticks, renderer={}", ticks, renderer_type); + + match renderer_type { + "ascii" => run_benchmark_inner::( + ticks, + output_path, + "ascii", + ), + "graphics" => run_benchmark_inner::( + ticks, + output_path, + "graphics", + ), + _ => { + eprintln!( + "Unknown benchmark renderer: {}. Use ascii or graphics.", + renderer_type + ); + std::process::exit(1); + } + } +} + +fn run_benchmark_inner(ticks: u32, output_path: &str, mode_name: &str) { + let event_loop = EventLoop::new().expect("Failed to create event loop"); + let window = event_loop + .create_window( + Window::default_attributes() + .with_title("Verbatim — Benchmark") + .with_inner_size(winit::dpi::LogicalSize::new(1600, 900)), + ) + .expect("Failed to create window"); + let window = Arc::new(window); + + let mut renderer = match R::new(Arc::clone(&window)) { + Ok(r) => r, + Err(e) => { + eprintln!("Vulkan init failed: {e}"); + std::process::exit(1); + } + }; + + let mut game = Game::new(); + game.init_world(); + + 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); + let mut frame_times_us: Vec = Vec::with_capacity(ticks as usize); + let benchmark_start = Instant::now(); + + event_loop + .run(|event, ctrl| { + ctrl.set_control_flow(ControlFlow::Poll); + + match event { + Event::WindowEvent { + event: WindowEvent::CloseRequested, + .. + } => { + ctrl.exit(); + } + Event::AboutToWait => { + if tick_count >= ticks { + let total_elapsed = benchmark_start.elapsed(); + + let ca_avg_us = ca_times_us.iter().sum::() / ca_times_us.len() as u64; + let render_avg_us = + render_times_us.iter().sum::() / render_times_us.len() as u64; + let frame_avg_us = + frame_times_us.iter().sum::() / frame_times_us.len() as u64; + + let ca_p99_us = percentile(&ca_times_us, 99); + let render_p99_us = percentile(&render_times_us, 99); + let frame_p99_us = percentile(&frame_times_us, 99); + + let ca_min_us = *ca_times_us.iter().min().unwrap_or(&0); + let render_min_us = *render_times_us.iter().min().unwrap_or(&0); + let frame_min_us = *frame_times_us.iter().min().unwrap_or(&0); + + let total_ms = total_elapsed.as_secs_f64() * 1000.0; + let avg_fps = ticks as f64 / (total_ms / 1000.0); + let avg_frame_ms = frame_avg_us as f64 / 1000.0; + let p99_frame_ms = frame_p99_us as f64 / 1000.0; + let min_frame_ms = frame_min_us as f64 / 1000.0; + + let json = format!( + r#"{{ + "mode": "{}", + "ticks": {}, + "total_time_ms": {:.1}, + "avg_fps": {:.1}, + "avg_frame_time_ms": {:.2}, + "p99_frame_time_ms": {:.2}, + "min_frame_time_ms": {:.2}, + "subsystems": {{ + "ca_step_avg_us": {}, + "ca_step_p99_us": {}, + "ca_step_min_us": {}, + "render_avg_us": {}, + "render_p99_us": {}, + "render_min_us": {} + }} +}}"#, + mode_name, + ticks, + total_ms, + avg_fps, + avg_frame_ms, + p99_frame_ms, + min_frame_ms, + ca_avg_us, + ca_p99_us, + ca_min_us, + render_avg_us, + render_p99_us, + render_min_us, + ); + + let mut f = std::fs::File::create(output_path) + .expect("Cannot create benchmark output"); + f.write_all(json.as_bytes()) + .expect("Cannot write benchmark output"); + + eprintln!("=== Benchmark Results ==="); + eprintln!("Mode: {}", mode_name); + eprintln!("Ticks: {}", ticks); + eprintln!("Total time: {:.1} ms", total_ms); + eprintln!("Avg FPS: {:.1}", avg_fps); + eprintln!("Avg frame: {:.2} ms", avg_frame_ms); + eprintln!("P99 frame: {:.2} ms", p99_frame_ms); + eprintln!("Min frame: {:.2} ms", min_frame_ms); + eprintln!( + "CA step: avg={}us p99={}us min={}us", + ca_avg_us, ca_p99_us, ca_min_us + ); + eprintln!( + "Render: avg={}us p99={}us min={}us", + render_avg_us, render_p99_us, render_min_us + ); + eprintln!("Results written to {}", output_path); + + ctrl.exit(); + return; + } + + let vw = renderer.grid_w(); + let vh = renderer.grid_h(); + + let frame_start = Instant::now(); + + let ca_start = Instant::now(); + game.fixed_update(); + let ca_elapsed = ca_start.elapsed(); + + let (px, py) = game.player.center(&game.entities); + game.cam_x = px as i32 - (vw as i32 / 2); + game.cam_y = py as i32 - (vh as i32 / 2); + game.build_ui(vw, vh); + + let render_start = Instant::now(); + renderer.render( + &game.grid, + &game.entities, + &game.items, + &game.ui, + game.cam_x, + game.cam_y, + None, + ); + let render_elapsed = render_start.elapsed(); + + let frame_elapsed = frame_start.elapsed(); + + ca_times_us.push(ca_elapsed.as_micros() as u64); + render_times_us.push(render_elapsed.as_micros() as u64); + frame_times_us.push(frame_elapsed.as_micros() as u64); + + tick_count += 1; + } + _ => {} + } + }) + .expect("event loop error"); +} + +fn percentile(sorted_data: &[u64], p: u64) -> u64 { + if sorted_data.is_empty() { + return 0; + } + let mut data: Vec = sorted_data.to_vec(); + data.sort_unstable(); + let idx = (data.len() * p as usize) / 100; + data[idx.min(data.len() - 1)] +} + fn run_test_mode(cli: &Cli) { if let Some(path) = &cli.scenario { match ai::load_scenario(path) { @@ -423,6 +710,55 @@ fn run_headless(ticks: u32) { ); } +fn run_capture(ticks: u32) { + let mut game = Game::new(); + game.init_world(); + + for _ in 0..ticks { + game.fixed_update(); + } + + let (px, py) = game.player.center(&game.entities); + let view_w = (1600 / verbatim::render::capture::CELL_SIZE).min(256); + let view_h = (900 / verbatim::render::capture::CELL_SIZE).min(256); + let cam_x = px as i32 - (view_w as i32 / 2); + let cam_y = py as i32 - (view_h as i32 / 2); + + game.build_ui(view_w as usize, view_h as usize); + + let light = lighting::compute_lighting( + &game.grid, + cam_x, + cam_y, + view_w as usize, + view_h as usize, + lighting::ambient_light(), + ); + + let path = "capture.png"; + match verbatim::render::capture::save_capture( + path, + &game.grid, + &game.entities, + &game.items, + &game.ui, + cam_x, + cam_y, + view_w, + view_h, + Some(&light), + ) { + Ok(_) => eprintln!( + "Capture complete: {} ticks, image written to {}", + ticks, path + ), + Err(e) => { + eprintln!("Capture failed: {}", e); + std::process::exit(1); + } + } +} + fn dump_view( grid: &verbatim::world::grid::Grid, entities: &verbatim::entity::EntityManager, diff --git a/src/physics/mod.rs b/src/physics/mod.rs index e75d24d..5635e2c 100644 --- a/src/physics/mod.rs +++ b/src/physics/mod.rs @@ -1,2 +1,3 @@ -pub mod verlet; pub mod collision; +pub mod projectile; +pub mod verlet; diff --git a/src/physics/projectile.rs b/src/physics/projectile.rs new file mode 100644 index 0000000..6b23e6d --- /dev/null +++ b/src/physics/projectile.rs @@ -0,0 +1,276 @@ +use crate::entity::entity::{Entity, EntityId}; +use crate::world::cell::{Cell, MaterialId}; +use crate::world::grid::Grid; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProjectileType { + Arrow, + Fireball, + MagicBolt, +} + +pub struct Projectile { + pub id: u32, + pub typ: ProjectileType, + pub x: f32, + pub y: f32, + pub vx: f32, + pub vy: f32, + pub radius: f32, + pub damage: f32, + pub lifetime: u32, + pub max_lifetime: u32, + pub owner: EntityId, + pub alive: bool, + pub gravity: bool, + pub hit_grid: bool, + pub damage_bonus: f32, +} + +impl Projectile { + pub fn new( + id: u32, + typ: ProjectileType, + x: f32, + y: f32, + vx: f32, + vy: f32, + owner: EntityId, + ) -> Self { + let (radius, damage, max_lifetime, gravity) = match typ { + ProjectileType::Arrow => (0.3, 15.0, 120, true), + ProjectileType::Fireball => (0.6, 12.0, 90, false), + ProjectileType::MagicBolt => (0.25, 20.0, 100, false), + }; + Self { + id, + typ, + x, + y, + vx, + vy, + radius, + damage, + lifetime: 0, + max_lifetime, + owner, + alive: true, + gravity, + hit_grid: false, + damage_bonus: 0.0, + } + } + + pub fn total_damage(&self) -> f32 { + self.damage + self.damage_bonus + } + + pub fn update(&mut self, grid: &Grid) { + if !self.alive { + return; + } + self.lifetime += 1; + if self.lifetime >= self.max_lifetime { + self.alive = false; + return; + } + if self.gravity { + self.vy += 0.04; + } + let speed = (self.vx * self.vx + self.vy * self.vy).sqrt(); + if speed > 4.0 { + self.vx = self.vx / speed * 4.0; + self.vy = self.vy / speed * 4.0; + } + self.x += self.vx; + self.y += self.vy; + + let min_x = (self.x - self.radius).floor() as i32; + let max_x = (self.x + self.radius).ceil() as i32; + let min_y = (self.y - self.radius).floor() as i32; + let max_y = (self.y + self.radius).ceil() as i32; + + for y in min_y..=max_y { + for x in min_x..=max_x { + if !grid.in_bounds(x, y) { + self.hit_grid = true; + self.alive = false; + return; + } + let cell = grid.get(x, y); + if cell.is_solid() { + self.hit_grid = true; + self.alive = false; + return; + } + } + } + } + + pub fn check_entity_hit(&self, entity: &Entity) -> bool { + if !self.alive || !entity.alive || entity.id == self.owner { + return false; + } + let dx = self.x - entity.cx; + let dy = self.y - entity.cy; + let hit_w = entity.half_w + self.radius; + let hit_h = entity.half_h + self.radius; + dx.abs() < hit_w && dy.abs() < hit_h + } + + pub fn apply_impact(&self, grid: &mut Grid, 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; + let min_y = (self.y - 1.5).floor() as i32; + let max_y = (self.y + 1.5).ceil() as i32; + for y in min_y..=max_y { + for x in min_x..=max_x { + if !grid.in_bounds(x, y) { + continue; + } + let cell = grid.get(x, y); + if cell.is_empty() { + grid.set(x, y, Cell::new(MaterialId::Fire)); + } else if cell.material == MaterialId::Wood + || cell.material == MaterialId::Grass + || cell.material == MaterialId::Flesh + { + let mut ignited = cell; + ignited.material = MaterialId::Fire; + ignited.temp = 400.0; + grid.set(x, y, ignited); + } + } + } + if entity.id != self.owner && entity.id != 0 { + let before = entity.health; + entity.take_damage(self.total_damage()); + ui.add_damage_number( + entity.cx, + entity.cy - entity.half_h - 2.0, + &format!("-{:.0}", before - entity.health), + ); + } + return; + } + + if entity.id == self.owner || entity.id == 0 { + return; + } + + let before = entity.health; + if self.typ == ProjectileType::MagicBolt { + entity.take_damage(self.total_damage()); + } else { + entity.take_damage(self.total_damage()); + let dir = if self.vx < 0.0 { -1.0 } else { 1.0 }; + entity.set_horizontal_vel(dir * 0.6); + } + ui.add_damage_number( + entity.cx, + entity.cy - entity.half_h - 2.0, + &format!("-{:.0}", before - entity.health), + ); + } + + pub fn draw_char(&self) -> char { + match self.typ { + ProjectileType::Arrow => '/', + ProjectileType::Fireball => 'o', + ProjectileType::MagicBolt => '*', + } + } + + pub fn draw_color(&self) -> [u8; 3] { + match self.typ { + ProjectileType::Arrow => [200, 200, 200], + ProjectileType::Fireball => [255, 120, 30], + ProjectileType::MagicBolt => [120, 200, 255], + } + } +} + +pub struct ProjectileManager { + projectiles: Vec, + next_id: u32, +} + +impl ProjectileManager { + pub fn new() -> Self { + Self { + projectiles: Vec::new(), + next_id: 0, + } + } + + pub fn spawn( + &mut self, + typ: ProjectileType, + x: f32, + y: f32, + vx: f32, + vy: f32, + owner: EntityId, + damage_bonus: f32, + ) -> u32 { + let id = self.next_id; + self.next_id += 1; + let mut p = Projectile::new(id, typ, x, y, vx, vy, owner); + p.damage_bonus = damage_bonus; + self.projectiles.push(p); + id + } + + pub fn update(&mut self, grid: &Grid) { + for p in &mut self.projectiles { + p.update(grid); + } + } + + pub fn resolve_hits( + &mut self, + grid: &mut Grid, + entities: &mut [Entity], + ui: &mut crate::ui::UiLayer, + ) { + for p in &mut self.projectiles { + if !p.alive && !p.hit_grid { + continue; + } + let mut hit = false; + for e in entities.iter_mut() { + if p.check_entity_hit(e) { + let was_alive = e.alive; + p.apply_impact(grid, e, ui); + if was_alive && !e.alive { + ui.add_message(&format!("{} dies!", e.name())); + } else if was_alive { + ui.add_message(&format!( + "{} hit for {:.0} damage", + e.name(), + p.total_damage() + )); + } + hit = true; + break; + } + } + if !hit && p.hit_grid && p.typ == ProjectileType::Fireball { + let mut dummy = Entity::new(p.owner, crate::entity::entity::EntityKind::Player); + p.apply_impact(grid, &mut dummy, ui); + } + if hit || p.hit_grid { + p.alive = false; + } + } + } + + pub fn cull_dead(&mut self) { + self.projectiles.retain(|p| p.alive); + } + + pub fn all(&self) -> &[Projectile] { + &self.projectiles + } +} diff --git a/src/render/capture.rs b/src/render/capture.rs new file mode 100644 index 0000000..df48b39 --- /dev/null +++ b/src/render/capture.rs @@ -0,0 +1,266 @@ +use crate::entity::item::ItemManager; +use crate::entity::EntityManager; +use crate::render::lighting; +use crate::ui::UiLayer; +use crate::world::cell::MaterialId; +use crate::world::grid::Grid; +use image::{ImageBuffer, RgbImage}; + +pub const CELL_SIZE: u32 = 8; +pub const UI_CELL_SIZE: u32 = 2; + +pub fn capture_frame( + grid: &Grid, + entities: &EntityManager, + items: &ItemManager, + ui: &UiLayer, + cam_x: i32, + cam_y: i32, + view_w: u32, + view_h: u32, + lighting: Option<&lighting::LightGrid>, +) -> RgbImage { + let width = view_w * CELL_SIZE; + let height = view_h * CELL_SIZE; + let mut img: RgbImage = ImageBuffer::new(width, height); + + let entity_positions = entity_positions(entities, cam_x, cam_y, view_w, view_h); + let shadow_positions = shadow_positions(&entity_positions, grid, cam_x, cam_y, view_w, view_h); + + for vy in 0..view_h as i32 { + for vx in 0..view_w as i32 { + let wx = cam_x + vx; + let wy = cam_y + vy; + let light = lighting + .map(|l| l.get(vx, vy)) + .unwrap_or_else(lighting::ambient_light); + + let color = if let Some(c) = entity_positions.get(&(vx, vy)) { + lighting::apply_light(*c, light) + } else if let Some(c) = item_color_at(items, wx, wy) { + lighting::apply_light(c, light) + } else if shadow_positions.contains(&(vx, vy)) { + [0, 0, 0] + } else if !grid.in_bounds(wx, wy) { + lighting::apply_light([40, 40, 40], light) + } else { + let cell = grid.get(wx, wy); + if cell.is_empty() { + lighting::apply_light(background_color(wx, wy, vy, view_h as i32), light) + } else if cell.material == MaterialId::Lava { + let r = 200u8.saturating_add(cell.variant / 2); + lighting::apply_light([r, 60, 20], light) + } else { + lighting::apply_light([cell.fg[0], cell.fg[1], cell.fg[2]], light) + } + }; + + draw_cell(&mut img, vx as u32, vy as u32, color); + } + } + + for (x, y) in ui.keys() { + let cell = ui.get(*x, *y).unwrap(); + let px = (*x as u32) * UI_CELL_SIZE; + let py = (*y as u32) * UI_CELL_SIZE; + if px + UI_CELL_SIZE <= img.width() && py + UI_CELL_SIZE <= img.height() { + let alpha = cell.alpha as f32 / 255.0; + for dy in 0..UI_CELL_SIZE { + for dx in 0..UI_CELL_SIZE { + let p = img.get_pixel(px + dx, py + dy); + let r = (cell.fg[0] as f32 * alpha + p[0] as f32 * (1.0 - alpha)) as u8; + let g = (cell.fg[1] as f32 * alpha + p[1] as f32 * (1.0 - alpha)) as u8; + let b = (cell.fg[2] as f32 * alpha + p[2] as f32 * (1.0 - alpha)) as u8; + img.put_pixel(px + dx, py + dy, image::Rgb([r, g, b])); + } + } + } + } + + img +} + +fn background_color(wx: i32, wy: i32, vy: i32, view_h: i32) -> [u8; 3] { + let t = (vy as f32 / view_h as f32).clamp(0.0, 1.0); + let base_r = (10.0 + t * 15.0) as u8; + let base_g = (10.0 + t * 25.0) as u8; + let base_b = (25.0 + t * 35.0) as u8; + + let hash = ((wx.wrapping_mul(73856093)) ^ (wy.wrapping_mul(19349663))).abs(); + if hash % 80 == 0 { + let brightness = (60 + (hash % 120) as u8).min(255); + return [brightness, brightness, brightness + 20]; + } + + [base_r, base_g, base_b] +} + +fn entity_priority(kind: crate::entity::EntityKind) -> u32 { + use crate::entity::EntityKind; + match kind { + EntityKind::Player => 3, + EntityKind::Goblin => 2, + EntityKind::Slime => 1, + EntityKind::Corpse => 0, + } +} + +fn entity_positions( + entities: &EntityManager, + cam_x: i32, + cam_y: i32, + view_w: u32, + view_h: u32, +) -> std::collections::HashMap<(i32, i32), [u8; 3]> { + let mut map: std::collections::HashMap<(i32, i32), (u32, [u8; 3])> = + std::collections::HashMap::new(); + for e in entities.all() { + for b in &e.bodies { + if !b.alive { + continue; + } + let sx = b.x as i32 - cam_x; + let sy = b.y as i32 - cam_y; + if sx < 0 || sx >= view_w as i32 || sy < 0 || sy >= view_h as i32 { + continue; + } + let color = if e.on_fire { + let flicker = b.fire_timer % 4; + [255, 120 + flicker as u8 * 20, 20 + flicker as u8 * 10] + } else { + [b.color[0], b.color[1], b.color[2]] + }; + let priority = entity_priority(e.kind); + if map + .get(&(sx, sy)) + .map(|(p, _)| priority > *p) + .unwrap_or(true) + { + map.insert((sx, sy), (priority, color)); + } + } + } + map.into_iter().map(|(k, (_, c))| (k, c)).collect() +} + +fn shadow_positions( + entity_positions: &std::collections::HashMap<(i32, i32), [u8; 3]>, + grid: &Grid, + cam_x: i32, + cam_y: i32, + view_w: u32, + view_h: u32, +) -> std::collections::HashSet<(i32, i32)> { + let mut shadows = std::collections::HashSet::new(); + for (vx, vy) in entity_positions.keys() { + for dy in -1..=1 { + for dx in -1..=1 { + if dx == 0 && dy == 0 { + continue; + } + let sx = vx + dx; + let sy = vy + dy; + if sx < 0 || sx >= view_w as i32 || sy < 0 || sy >= view_h as i32 { + continue; + } + if entity_positions.contains_key(&(sx, sy)) { + continue; + } + let wx = cam_x + sx; + let wy = cam_y + sy; + let empty = !grid.in_bounds(wx, wy) || grid.get(wx, wy).is_empty(); + if empty { + shadows.insert((sx, sy)); + } + } + } + } + shadows +} + +fn item_color_at(items: &ItemManager, wx: i32, wy: i32) -> Option<[u8; 3]> { + for item in items.all() { + if item.x == wx && item.y == wy { + return Some(item.color()); + } + } + None +} + +fn draw_cell(img: &mut RgbImage, vx: u32, vy: u32, color: [u8; 3]) { + let base_x = vx * CELL_SIZE; + let base_y = vy * CELL_SIZE; + for dy in 0..CELL_SIZE { + for dx in 0..CELL_SIZE { + let px = base_x + dx; + let py = base_y + dy; + if px < img.width() && py < img.height() { + img.put_pixel(px, py, image::Rgb(color)); + } + } + } +} + +pub fn save_capture( + path: &str, + grid: &Grid, + entities: &EntityManager, + items: &ItemManager, + ui: &UiLayer, + cam_x: i32, + cam_y: i32, + view_w: u32, + view_h: u32, + lighting: Option<&lighting::LightGrid>, +) -> Result<(), String> { + let img = capture_frame( + grid, entities, items, ui, cam_x, cam_y, view_w, view_h, lighting, + ); + img.save(path).map_err(|e| format!("save capture: {e}"))?; + Ok(()) +} + +pub fn capture_from_state( + grid: &Grid, + entities: &EntityManager, + items: &ItemManager, + ui: &UiLayer, + cam_x: i32, + cam_y: i32, + path: &str, + lighting: Option<&lighting::LightGrid>, +) -> Result<(), String> { + let view_w = (grid.width as u32 / CELL_SIZE).min(256); + let view_h = (grid.height as u32 / CELL_SIZE).min(256); + save_capture( + path, grid, entities, items, ui, cam_x, cam_y, view_w, view_h, lighting, + ) +} + +pub fn capture_from_game(game: &crate::game::Game, path: &str) -> Result<(), String> { + let (px, py) = game.player.center(&game.entities); + let view_w = (game.grid.width as u32 / CELL_SIZE).min(256); + let view_h = (game.grid.height as u32 / CELL_SIZE).min(256); + let cam_x = px as i32 - (view_w as i32 / 2); + let cam_y = py as i32 - (view_h as i32 / 2); + let light = lighting::compute_lighting( + &game.grid, + cam_x, + cam_y, + view_w as usize, + view_h as usize, + lighting::ambient_light(), + ); + save_capture( + path, + &game.grid, + &game.entities, + &game.items, + &game.ui, + cam_x, + cam_y, + view_w, + view_h, + Some(&light), + ) +} diff --git a/src/render/graphics.rs b/src/render/graphics.rs index b43ab42..34b4e77 100644 --- a/src/render/graphics.rs +++ b/src/render/graphics.rs @@ -2,14 +2,41 @@ use ash::vk; use std::ffi::CString; use std::sync::Arc; -use crate::entity::{EntityKind, EntityManager}; +use crate::entity::EntityManager; +use crate::render::lighting; use crate::world::cell::MaterialId; -use crate::world::grid::Grid; +use crate::world::grid::{Grid, WORLD_H, WORLD_W}; -const CHAR_W: u32 = 10; -const CHAR_H: u32 = 10; +const CHAR_W: u32 = 8; +const CHAR_H: u32 = 8; +const UI_CELL_SIZE: u32 = 2; const MAX_FRAMES: usize = 2; +fn entity_priority(kind: crate::entity::EntityKind) -> u32 { + use crate::entity::EntityKind; + match kind { + EntityKind::Player => 3, + EntityKind::Goblin => 2, + EntityKind::Slime => 1, + EntityKind::Corpse => 0, + } +} + +fn background_color(wx: i32, wy: i32, vy: i32, view_h: i32) -> [u8; 4] { + let t = (vy as f32 / view_h as f32).clamp(0.0, 1.0); + let base_r = (10.0 + t * 15.0) as u8; + let base_g = (10.0 + t * 25.0) as u8; + let base_b = (25.0 + t * 35.0) as u8; + + let hash = ((wx.wrapping_mul(73856093)) ^ (wy.wrapping_mul(19349663))).abs(); + if hash % 80 == 0 { + let brightness = (60 + (hash % 120) as u8).min(255); + return [brightness, brightness, brightness + 20, 255]; + } + + [base_r, base_g, base_b, 255] +} + #[repr(C)] #[derive(Clone, Copy, Default)] struct ColorInstance { @@ -18,11 +45,28 @@ struct ColorInstance { color: [u8; 4], } +#[repr(C)] +#[derive(bytemuck::NoUninit, Clone, Copy, Default)] +struct GpuLightSource { + pos: [f32; 2], + radius: f32, + _pad0: f32, + color: [f32; 3], + _pad1: f32, +} + +const MAX_LIGHT_SOURCES: usize = 64; + #[repr(C)] #[derive(bytemuck::NoUninit, Clone, Copy)] struct PushConstants { screen_size: [f32; 2], cell_size: [f32; 2], + world_size: [i32; 2], + cam_pos: [i32; 2], + ambient: [f32; 3], + is_ui: u32, + light_count: u32, } pub struct GraphicsRenderer { @@ -40,6 +84,7 @@ pub struct GraphicsRenderer { swapchain: vk::SwapchainKHR, swapchain_image_views: Vec, swapchain_extent: vk::Extent2D, + present_mode: vk::PresentModeKHR, render_pass: vk::RenderPass, pipeline: vk::Pipeline, @@ -64,6 +109,28 @@ pub struct GraphicsRenderer { instance_ptr: *mut ColorInstance, instance_count: usize, + ui_instance_buffer: vk::Buffer, + ui_instance_memory: vk::DeviceMemory, + ui_instance_ptr: *mut ColorInstance, + ui_instance_capacity: usize, + + grid_buffer: vk::Buffer, + grid_memory: vk::DeviceMemory, + grid_ptr: *mut u32, + + light_buffer: vk::Buffer, + light_memory: vk::DeviceMemory, + light_ptr: *mut GpuLightSource, + + ent_pri_buf: Vec, + entity_color_buf: Vec<[u8; 4]>, + item_color_buf: Vec<[u8; 4]>, + shadow_buf: Vec, + + descriptor_set_layout: vk::DescriptorSetLayout, + descriptor_pool: vk::DescriptorPool, + descriptor_set: vk::DescriptorSet, + window: Arc, } @@ -156,6 +223,14 @@ impl GraphicsRenderer { let swapchain_loader = ash::khr::swapchain::Device::new(&instance, &device); let caps = unsafe { sl.get_physical_device_surface_capabilities(physical_device, surface) } .map_err(|e| format!("caps: {e:?}"))?; + let present_modes = + unsafe { sl.get_physical_device_surface_present_modes(physical_device, surface) } + .unwrap_or_default(); + let present_mode = present_modes + .iter() + .copied() + .find(|&m| m == vk::PresentModeKHR::MAILBOX) + .unwrap_or(vk::PresentModeKHR::FIFO); let format = vk::SurfaceFormatKHR { format: vk::Format::B8G8R8A8_UNORM, color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR, @@ -182,7 +257,7 @@ impl GraphicsRenderer { .queue_family_indices(&qf_slice) .pre_transform(caps.current_transform) .composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE) - .present_mode(vk::PresentModeKHR::FIFO) + .present_mode(present_mode) .clipped(true); let swapchain = unsafe { swapchain_loader.create_swapchain(&sci, None) } .map_err(|e| format!("swapchain: {e:?}"))?; @@ -316,15 +391,37 @@ impl GraphicsRenderer { let ms = vk::PipelineMultisampleStateCreateInfo::default() .rasterization_samples(vk::SampleCountFlags::TYPE_1); let cba = vk::PipelineColorBlendAttachmentState::default() + .blend_enable(true) + .src_color_blend_factor(vk::BlendFactor::SRC_ALPHA) + .dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA) + .color_blend_op(vk::BlendOp::ADD) + .src_alpha_blend_factor(vk::BlendFactor::ONE) + .dst_alpha_blend_factor(vk::BlendFactor::ZERO) + .alpha_blend_op(vk::BlendOp::ADD) .color_write_mask(vk::ColorComponentFlags::RGBA); let cb = vk::PipelineColorBlendStateCreateInfo::default() .attachments(std::slice::from_ref(&cba)); + let grid_binding = vk::DescriptorSetLayoutBinding::default() + .binding(0) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::VERTEX); + let light_binding = vk::DescriptorSetLayoutBinding::default() + .binding(1) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::VERTEX); + let dsl_bindings = [grid_binding, light_binding]; + let dsl_ci = vk::DescriptorSetLayoutCreateInfo::default().bindings(&dsl_bindings); + let descriptor_set_layout = unsafe { device.create_descriptor_set_layout(&dsl_ci, None) } + .map_err(|e| format!("dsl: {e:?}"))?; let pcr = vk::PushConstantRange { stage_flags: vk::ShaderStageFlags::VERTEX, offset: 0, size: std::mem::size_of::() as u32, }; let pli = vk::PipelineLayoutCreateInfo::default() + .set_layouts(std::slice::from_ref(&descriptor_set_layout)) .push_constant_ranges(std::slice::from_ref(&pcr)); let pipeline_layout = unsafe { device.create_pipeline_layout(&pli, None) } .map_err(|e| format!("pipeline_layout: {e:?}"))?; @@ -454,6 +551,78 @@ impl GraphicsRenderer { } Ok((buf, mem)) }; + + let grid_data = vec![0u32; WORLD_W * WORLD_H]; + 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 ptr = device + .map_memory(grid_memory, 0, sz, vk::MemoryMapFlags::default()) + .map_err(|e| format!("map grid: {e:?}"))?; + ptr as *mut u32 + }; + + let light_data = vec![GpuLightSource::default(); MAX_LIGHT_SOURCES]; + let (light_buffer, light_memory) = make_buf( + bytemuck::cast_slice(&light_data), + vk::BufferUsageFlags::STORAGE_BUFFER, + )?; + let light_ptr = unsafe { + let sz = (MAX_LIGHT_SOURCES * std::mem::size_of::()) as vk::DeviceSize; + let ptr = device + .map_memory(light_memory, 0, sz, vk::MemoryMapFlags::default()) + .map_err(|e| format!("map light: {e:?}"))?; + ptr as *mut GpuLightSource + }; + + let pool_sizes = [vk::DescriptorPoolSize { + ty: vk::DescriptorType::STORAGE_BUFFER, + descriptor_count: 2, + }]; + let descriptor_pool = unsafe { + device.create_descriptor_pool( + &vk::DescriptorPoolCreateInfo::default() + .pool_sizes(&pool_sizes) + .max_sets(1), + None, + ) + } + .map_err(|e| format!("descriptor_pool: {e:?}"))?; + let descriptor_set = unsafe { + device.allocate_descriptor_sets( + &vk::DescriptorSetAllocateInfo::default() + .descriptor_pool(descriptor_pool) + .set_layouts(std::slice::from_ref(&descriptor_set_layout)), + ) + } + .map_err(|e| format!("descriptor_set: {e:?}"))?[0]; + let grid_info = vk::DescriptorBufferInfo::default() + .buffer(grid_buffer) + .offset(0) + .range((WORLD_W * WORLD_H * std::mem::size_of::()) as vk::DeviceSize); + let light_info = vk::DescriptorBufferInfo::default() + .buffer(light_buffer) + .offset(0) + .range((MAX_LIGHT_SOURCES * std::mem::size_of::()) as vk::DeviceSize); + let descriptor_writes = [ + vk::WriteDescriptorSet::default() + .dst_set(descriptor_set) + .dst_binding(0) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER) + .buffer_info(std::slice::from_ref(&grid_info)), + vk::WriteDescriptorSet::default() + .dst_set(descriptor_set) + .dst_binding(1) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER) + .buffer_info(std::slice::from_ref(&light_info)), + ]; + unsafe { + device.update_descriptor_sets(&descriptor_writes, &[]); + } + let verts: [f32; 8] = [0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0]; let indices: [u16; 6] = [0, 1, 2, 1, 3, 2]; let (vertex_buffer, vertex_memory) = make_buf( @@ -498,6 +667,43 @@ impl GraphicsRenderer { ptr as *mut ColorInstance }; + let ui_capacity = 65536usize; + let ui_inst_sz = (ui_capacity * std::mem::size_of::()) as vk::DeviceSize; + let uibi = vk::BufferCreateInfo::default() + .size(ui_inst_sz) + .usage(vk::BufferUsageFlags::VERTEX_BUFFER) + .sharing_mode(vk::SharingMode::EXCLUSIVE); + let ui_instance_buffer = unsafe { device.create_buffer(&uibi, None) } + .map_err(|e| format!("ui inst buf: {e:?}"))?; + let uireq = unsafe { device.get_buffer_memory_requirements(ui_instance_buffer) }; + let uimt = find_mem( + uireq.memory_type_bits, + vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT, + )?; + let ui_instance_memory = unsafe { + device.allocate_memory( + &vk::MemoryAllocateInfo::default() + .allocation_size(uireq.size) + .memory_type_index(uimt), + None, + ) + } + .map_err(|e| format!("ui inst mem: {e:?}"))?; + let ui_instance_ptr = unsafe { + device + .bind_buffer_memory(ui_instance_buffer, ui_instance_memory, 0) + .expect("bind ui inst"); + let ptr = device + .map_memory( + ui_instance_memory, + 0, + ui_inst_sz, + vk::MemoryMapFlags::default(), + ) + .expect("map ui inst"); + ptr as *mut ColorInstance + }; + Ok(Self { grid_w, grid_h, @@ -511,6 +717,7 @@ impl GraphicsRenderer { swapchain, swapchain_image_views, swapchain_extent: extent, + present_mode, render_pass, pipeline, pipeline_layout, @@ -529,15 +736,75 @@ impl GraphicsRenderer { instance_memory, instance_ptr, instance_count, + ui_instance_buffer, + ui_instance_memory, + ui_instance_ptr, + ui_instance_capacity: ui_capacity, + + grid_buffer, + grid_memory, + grid_ptr, + + light_buffer, + light_memory, + light_ptr, + + ent_pri_buf: Vec::new(), + entity_color_buf: Vec::new(), + item_color_buf: Vec::new(), + shadow_buf: Vec::new(), + + descriptor_set_layout, + descriptor_pool, + descriptor_set, + window, }) } - pub fn render(&mut self, grid: &Grid, entities: &EntityManager, cam_x: i32, cam_y: i32) { + pub fn render( + &mut self, + grid: &Grid, + entities: &EntityManager, + items: &crate::entity::item::ItemManager, + ui: &crate::ui::UiLayer, + cam_x: i32, + cam_y: i32, + _lighting: Option<&lighting::LightGrid>, + ) { self.check_resize(); - let mut entity_map: std::collections::HashMap<(i32, i32), [u8; 4]> = - std::collections::HashMap::new(); + let amb_u8 = lighting::ambient_light(); + let ambient = [ + amb_u8[0] as f32 / 255.0, + amb_u8[1] as f32 / 255.0, + amb_u8[2] as f32 / 255.0, + ]; + + let vp_size = self.grid_w * self.grid_h; + if self.ent_pri_buf.len() != vp_size { + self.ent_pri_buf.resize(vp_size, 0); + self.entity_color_buf.resize(vp_size, [0, 0, 0, 0]); + self.item_color_buf.resize(vp_size, [0, 0, 0, 0]); + self.shadow_buf.resize(vp_size, false); + } + self.ent_pri_buf.fill(0); + self.entity_color_buf.fill([0, 0, 0, 0]); + self.item_color_buf.fill([0, 0, 0, 0]); + self.shadow_buf.fill(false); + + let ent_pri = &mut self.ent_pri_buf; + let entity_color = &mut self.entity_color_buf; + let item_color = &mut self.item_color_buf; + + for item in items.all() { + let sx = item.x - cam_x; + let sy = item.y - cam_y; + if sx >= 0 && sx < self.grid_w as i32 && sy >= 0 && sy < self.grid_h as i32 { + let idx = sy as usize * self.grid_w + sx as usize; + item_color[idx] = [item.color()[0], item.color()[1], item.color()[2], 255]; + } + } for e in entities.all() { for b in &e.bodies { if !b.alive { @@ -546,42 +813,79 @@ impl GraphicsRenderer { let sx = b.x as i32 - cam_x; let sy = b.y as i32 - cam_y; if sx >= 0 && sx < self.grid_w as i32 && sy >= 0 && sy < self.grid_h as i32 { + let idx = sy as usize * self.grid_w + sx as usize; let color = if e.on_fire { let flicker = b.fire_timer % 4; [255, 120 + flicker as u8 * 20, 20 + flicker as u8 * 10, 255] } else { b.color }; - entity_map.insert((sx, sy), color); + let pri = entity_priority(e.kind); + if pri as u8 > ent_pri[idx] { + ent_pri[idx] = pri as u8; + entity_color[idx] = color; + } + } + } + } + + let shadow_buf = &mut self.shadow_buf; + for idx in 0..vp_size { + if ent_pri[idx] == 0 { + continue; + } + let ex = idx % self.grid_w; + let ey = idx / self.grid_w; + for dy in -1i32..=1 { + for dx in -1i32..=1 { + if dx == 0 && dy == 0 { + continue; + } + let sx = ex as i32 + dx; + let sy = ey as i32 + dy; + if sx < 0 || sx >= self.grid_w as i32 || sy < 0 || sy >= self.grid_h as i32 { + continue; + } + let sidx = sy as usize * self.grid_w + sx as usize; + if ent_pri[sidx] > 0 { + continue; + } + let wx = cam_x + sx; + let wy = cam_y + sy; + if !grid.in_bounds(wx, wy) || grid.get(wx, wy).is_empty() { + shadow_buf[sidx] = true; + } } } } let instances = unsafe { std::slice::from_raw_parts_mut(self.instance_ptr, self.instance_count) }; - let bg_default = [10u8, 10, 15, 255]; + let gh = self.grid_h as i32; for dy in 0..self.grid_h { for dx in 0..self.grid_w { let idx = dy * self.grid_w + dx; let wx = cam_x + dx as i32; let wy = cam_y + dy as i32; - let color = if let Some(&ec) = entity_map.get(&(dx as i32, dy as i32)) { - ec + let color = if ent_pri[idx] > 0 { + entity_color[idx] + } else if item_color[idx][3] > 0 { + item_color[idx] + } else if shadow_buf[idx] { + [0, 0, 0, 255] } else if !grid.in_bounds(wx, wy) { [40, 40, 40, 255] } else { let cell = grid.get(wx, wy); if cell.is_empty() { - bg_default + background_color(wx, wy, dy as i32, gh) + } else if cell.material == MaterialId::Lava { + let r = 200u8.saturating_add(cell.variant / 2); + [r, 60, 20, 255] } else { - if cell.material == MaterialId::Lava { - let r = 200u8.saturating_add(cell.variant / 2); - [r, 60, 20, 255] - } else { - [cell.fg[0], cell.fg[1], cell.fg[2], 255] - } + [cell.fg[0], cell.fg[1], cell.fg[2], 255] } }; @@ -593,6 +897,60 @@ impl GraphicsRenderer { } } + let ui_instances = unsafe { + std::slice::from_raw_parts_mut(self.ui_instance_ptr, self.ui_instance_capacity) + }; + let mut ui_count = 0usize; + for (x, y) in ui.keys() { + if ui_count >= self.ui_instance_capacity { + break; + } + ui_instances[ui_count] = ColorInstance { + grid_x: *x as f32, + grid_y: *y as f32, + color: { + let cell = ui.get(*x, *y).unwrap(); + [cell.fg[0], cell.fg[1], cell.fg[2], cell.alpha] + }, + }; + ui_count += 1; + } + + 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; + } + } + } + + let sources = + lighting::gather_sources_in_range(grid, cam_x, cam_y, self.grid_w, self.grid_h, 30); + let light_count = sources.len().min(MAX_LIGHT_SOURCES) as u32; + unsafe { + let light_slice = std::slice::from_raw_parts_mut(self.light_ptr, MAX_LIGHT_SOURCES); + for (i, src) in sources.iter().take(MAX_LIGHT_SOURCES).enumerate() { + light_slice[i] = GpuLightSource { + pos: [src.x as f32, src.y as f32], + radius: src.radius as f32, + _pad0: 0.0, + color: [ + src.color[0] as f32 / 255.0, + src.color[1] as f32 / 255.0, + src.color[2] as f32 / 255.0, + ], + _pad1: 0.0, + }; + } + } + let frame = self.frame_index; let device = &self.device; @@ -649,6 +1007,14 @@ impl GraphicsRenderer { device.cmd_set_viewport(cmd, 0, std::slice::from_ref(&viewport)); device.cmd_set_scissor(cmd, 0, std::slice::from_ref(&scissor)); + device.cmd_bind_descriptor_sets( + cmd, + vk::PipelineBindPoint::GRAPHICS, + self.pipeline_layout, + 0, + &[self.descriptor_set], + &[], + ); device.cmd_bind_vertex_buffers( cmd, 0, @@ -663,6 +1029,11 @@ 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], + cam_pos: [cam_x, cam_y], + ambient, + is_ui: 0, + light_count, }; device.cmd_push_constants( cmd, @@ -673,6 +1044,36 @@ impl GraphicsRenderer { ); device.cmd_draw_indexed(cmd, 6, self.instance_count as u32, 0, 0, 0); + + if ui_count > 0 { + device.cmd_bind_vertex_buffers( + cmd, + 0, + &[self.vertex_buffer, self.ui_instance_buffer], + &[0, 0], + ); + let ui_pc = PushConstants { + screen_size: [ + self.swapchain_extent.width as f32, + 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], + cam_pos: [0, 0], + ambient: [1.0, 1.0, 1.0], + is_ui: 1, + light_count: 0, + }; + device.cmd_push_constants( + cmd, + self.pipeline_layout, + vk::ShaderStageFlags::VERTEX, + 0, + bytemuck::bytes_of(&ui_pc), + ); + device.cmd_draw_indexed(cmd, 6, ui_count as u32, 0, 0, 0); + } + device.cmd_end_render_pass(cmd); let _ = device.end_command_buffer(cmd); @@ -766,7 +1167,7 @@ impl GraphicsRenderer { .image_sharing_mode(vk::SharingMode::EXCLUSIVE) .pre_transform(caps.current_transform) .composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE) - .present_mode(vk::PresentModeKHR::FIFO) + .present_mode(self.present_mode) .clipped(true) .old_swapchain(self.swapchain); @@ -937,10 +1338,20 @@ impl Drop for GraphicsRenderer { self.device.destroy_render_pass(self.render_pass, None); self.device.destroy_buffer(self.instance_buffer, None); self.device.free_memory(self.instance_memory, None); + self.device.destroy_buffer(self.ui_instance_buffer, None); + self.device.free_memory(self.ui_instance_memory, None); self.device.destroy_buffer(self.vertex_buffer, None); self.device.free_memory(self.vertex_memory, None); self.device.destroy_buffer(self.index_buffer, None); self.device.free_memory(self.index_memory, None); + self.device.destroy_buffer(self.grid_buffer, None); + self.device.free_memory(self.grid_memory, None); + self.device.destroy_buffer(self.light_buffer, None); + self.device.free_memory(self.light_memory, None); + self.device + .destroy_descriptor_pool(self.descriptor_pool, None); + self.device + .destroy_descriptor_set_layout(self.descriptor_set_layout, None); for &v in &self.swapchain_image_views { self.device.destroy_image_view(v, None); } diff --git a/src/render/lighting.rs b/src/render/lighting.rs new file mode 100644 index 0000000..fdd705f --- /dev/null +++ b/src/render/lighting.rs @@ -0,0 +1,283 @@ +use crate::world::cell::MaterialId; +use crate::world::grid::Grid; + +#[derive(Clone, Copy, Debug)] +pub struct LightSource { + pub x: i32, + pub y: i32, + pub color: [u8; 3], + pub intensity: f32, + pub radius: u32, +} + +pub struct LightGrid { + pub width: usize, + pub height: usize, + pub data: Vec<[u8; 3]>, +} + +impl LightGrid { + pub fn new(width: usize, height: usize) -> Self { + let data = vec![[0; 3]; width * height]; + Self { + width, + height, + data, + } + } + + pub fn get(&self, x: i32, y: i32) -> [u8; 3] { + if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 { + return [0, 0, 0]; + } + self.data[y as usize * self.width + x as usize] + } + + pub fn set(&mut self, x: i32, y: i32, value: [u8; 3]) { + if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 { + return; + } + self.data[y as usize * self.width + x as usize] = value; + } + + pub fn clear(&mut self, ambient: [u8; 3]) { + for v in self.data.iter_mut() { + *v = ambient; + } + } +} + +pub fn material_light(material: MaterialId) -> Option { + match material { + MaterialId::Lava => Some(LightSource { + x: 0, + y: 0, + color: [255, 80, 20], + intensity: 1.0, + radius: 24, + }), + MaterialId::Fire => Some(LightSource { + x: 0, + y: 0, + color: [255, 160, 40], + intensity: 1.0, + radius: 18, + }), + _ => None, + } +} + +pub fn gather_sources(grid: &Grid) -> Vec { + let mut sources = Vec::new(); + let w = grid.width; + let h = grid.height; + for y in 0..h { + for x in 0..w { + let cell = grid.get(x as i32, y as i32); + if let Some(mut src) = material_light(cell.material) { + src.x = x as i32; + src.y = y as i32; + sources.push(src); + } + } + } + sources +} + +pub fn gather_sources_in_range( + grid: &Grid, + cam_x: i32, + cam_y: i32, + view_w: usize, + view_h: usize, + margin: i32, +) -> 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 min_y = (cam_y - margin).max(0); + let max_y = (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); + if let Some(mut src) = material_light(cell.material) { + src.x = x; + src.y = y; + sources.push(src); + } + } + } + sources +} + +pub fn compute_lighting( + grid: &Grid, + cam_x: i32, + cam_y: i32, + view_w: usize, + view_h: usize, + ambient: [u8; 3], +) -> LightGrid { + let mut grid_light = LightGrid::new(view_w, view_h); + grid_light.clear(ambient); + + let sources = gather_sources(grid); + let cap = sources.len().min(32); + let radius_limit = 30u32; + + for src in sources.iter().take(cap) { + let r = src.radius.min(radius_limit) as i32; + let r2 = r * r; + let sx = src.x; + let sy = src.y; + + for dy in -r..=r { + for dx in -r..=r { + let d2 = dx * dx + dy * dy; + if d2 > r2 { + continue; + } + let tx = sx + dx; + let ty = sy + dy; + if !grid.in_bounds(tx, ty) { + continue; + } + let vx = tx - cam_x; + let vy = ty - cam_y; + if vx < 0 || vx >= view_w as i32 || vy < 0 || vy >= view_h as i32 { + continue; + } + if !line_of_sight(grid, sx, sy, tx, ty) { + continue; + } + let dist = (d2 as f32).sqrt(); + let t = 1.0 - (dist / r as f32); + if t <= 0.0 { + continue; + } + let attenuation = t * t; + let contrib = [ + (src.color[0] as f32 * src.intensity * attenuation), + (src.color[1] as f32 * src.intensity * attenuation), + (src.color[2] as f32 * src.intensity * attenuation), + ]; + let idx = vy as usize * view_w + vx as usize; + let cur = grid_light.data[idx]; + let next = [ + (cur[0] as f32 + contrib[0]).min(255.0) as u8, + (cur[1] as f32 + contrib[1]).min(255.0) as u8, + (cur[2] as f32 + contrib[2]).min(255.0) as u8, + ]; + grid_light.data[idx] = next; + } + } + } + + grid_light +} + +pub fn line_of_sight(grid: &Grid, x0: i32, y0: i32, x1: i32, y1: i32) -> bool { + let mut x = x0; + let mut y = y0; + let dx = (x1 - x0).abs(); + let dy = (y1 - y0).abs(); + let sx = if x0 < x1 { 1 } else { -1 }; + let sy = if y0 < y1 { 1 } else { -1 }; + let mut err = dx - dy; + + loop { + if x == x1 && y == y1 { + return true; + } + if grid.in_bounds(x, y) && grid.get(x, y).is_solid() { + return false; + } + let e2 = 2 * err; + if e2 > -dy { + err -= dy; + x += sx; + } + if e2 < dx { + err += dx; + y += sy; + } + } +} + +pub fn apply_light(color: [u8; 3], light: [u8; 3]) -> [u8; 3] { + [ + ((color[0] as f32 * light[0] as f32 / 255.0).min(255.0) as u8), + ((color[1] as f32 * light[1] as f32 / 255.0).min(255.0) as u8), + ((color[2] as f32 * light[2] as f32 / 255.0).min(255.0) as u8), + ] +} + +pub fn apply_light_rgba(color: [u8; 4], light: [u8; 3]) -> [u8; 4] { + [ + ((color[0] as f32 * light[0] as f32 / 255.0).min(255.0) as u8), + ((color[1] as f32 * light[1] as f32 / 255.0).min(255.0) as u8), + ((color[2] as f32 * light[2] as f32 / 255.0).min(255.0) as u8), + color[3], + ] +} + +pub fn apply_light_tuple(color: (u8, u8, u8), light: [u8; 3]) -> (u8, u8, u8) { + ( + ((color.0 as f32 * light[0] as f32 / 255.0).min(255.0) as u8), + ((color.1 as f32 * light[1] as f32 / 255.0).min(255.0) as u8), + ((color.2 as f32 * light[2] as f32 / 255.0).min(255.0) as u8), + ) +} + +pub fn ambient_light() -> [u8; 3] { + [160, 160, 180] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::world::grid::Grid; + + fn grid_with_lava() -> (Grid, i32, i32) { + let mut grid = Grid::new(); + grid.set_material(10, 10, MaterialId::Lava); + (grid, 10, 10) + } + + #[test] + fn lava_emits_light() { + let (grid, x, y) = grid_with_lava(); + let sources = gather_sources(&grid); + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].x, x); + assert_eq!(sources[0].y, y); + } + + #[test] + fn light_attenuates_with_distance() { + let (grid, _, _) = grid_with_lava(); + let light = compute_lighting(&grid, 0, 0, 20, 20, ambient_light()); + let center = light.get(10, 10); + let far = light.get(10, 0); + assert!( + center.iter().map(|&v| v as u32).sum::() + > far.iter().map(|&v| v as u32).sum::() + ); + } + + #[test] + fn walls_block_light() { + let mut grid = Grid::new(); + grid.set_material(5, 10, MaterialId::Lava); + for y in 7..13 { + grid.set_material(8, y, MaterialId::Stone); + } + let light = compute_lighting(&grid, 0, 0, 20, 20, ambient_light()); + let lit_side = light.get(6, 10); + let shadow_side = light.get(10, 10); + assert!( + lit_side.iter().map(|&v| v as u32).sum::() + > shadow_side.iter().map(|&v| v as u32).sum::() + ); + } +} diff --git a/src/render/mod.rs b/src/render/mod.rs index 2908768..7df71ef 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -1,14 +1,27 @@ -pub mod terminal; -pub mod window_input; -pub mod vulkan; +pub mod capture; pub mod graphics; +pub mod lighting; +pub mod terminal; +pub mod vulkan; +pub mod window_input; +use crate::entity::item::ItemManager; use crate::entity::EntityManager; +use crate::ui::UiLayer; use crate::world::grid::Grid; pub trait Renderer { fn init(&mut self) -> std::io::Result<()>; - fn render(&mut self, grid: &Grid, entities: &EntityManager, cam_x: i32, cam_y: i32) -> std::io::Result<()>; + fn render( + &mut self, + grid: &Grid, + entities: &EntityManager, + items: &ItemManager, + ui: &UiLayer, + cam_x: i32, + cam_y: i32, + lighting: Option<&lighting::LightGrid>, + ) -> std::io::Result<()>; fn shutdown(&mut self) -> std::io::Result<()>; fn viewport_w(&self) -> usize; fn viewport_h(&self) -> usize; diff --git a/src/render/terminal.rs b/src/render/terminal.rs index 51448c7..b42fab5 100644 --- a/src/render/terminal.rs +++ b/src/render/terminal.rs @@ -13,10 +13,35 @@ use crossterm::{ use std::io::{self, stdout, Write}; 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; +fn entity_priority(kind: crate::entity::EntityKind) -> u32 { + match kind { + crate::entity::EntityKind::Player => 3, + crate::entity::EntityKind::Goblin => 2, + crate::entity::EntityKind::Slime => 1, + crate::entity::EntityKind::Corpse => 0, + } +} + +fn background_color(wx: i32, wy: i32, vy: i32, view_h: i32) -> (u8, u8, u8) { + let t = (vy as f32 / view_h as f32).clamp(0.0, 1.0); + let base_r = (10.0 + t * 15.0) as u8; + let base_g = (10.0 + t * 25.0) as u8; + let base_b = (25.0 + t * 35.0) as u8; + + let hash = ((wx.wrapping_mul(73856093)) ^ (wy.wrapping_mul(19349663))).abs(); + if hash % 80 == 0 { + let brightness = (60 + (hash % 120) as u8).min(255); + return (brightness, brightness, (brightness + 20).min(255)); + } + + (base_r, base_g, base_b) +} + pub struct TerminalRenderer { width: usize, height: usize, @@ -73,8 +98,11 @@ impl Renderer for TerminalRenderer { &mut self, grid: &Grid, entities: &EntityManager, + items: &crate::entity::item::ItemManager, + ui: &crate::ui::UiLayer, cam_x: i32, cam_y: i32, + lighting: Option<&LightGrid>, ) -> io::Result<()> { if !self.initialized { return Ok(()); @@ -85,35 +113,10 @@ impl Renderer for TerminalRenderer { let total = self.width * self.height; frame = vec![Self::empty_cell(); total]; - for dy in 0..self.height { - for dx in 0..self.width { - let wx = cam_x + dx as i32; - let wy = cam_y + dy as i32; - let idx = dy * self.width + dx; - if !grid.in_bounds(wx, wy) { - frame[idx] = ('?', (80, 80, 80), (10, 10, 15)); - continue; - } - let cell = grid.get(wx, wy); - if cell.is_empty() { - frame[idx] = ( - ' ', - (cell.fg[0], cell.fg[1], cell.fg[2]), - (cell.bg[0], cell.bg[1], cell.bg[2]), - ); - } else { - let ch = cell.material.display_char(); - let fg = if cell.material == MaterialId::Lava { - let r = 200u8.saturating_add(cell.variant / 2); - (r, 60, 20) - } else { - (cell.fg[0], cell.fg[1], cell.fg[2]) - }; - frame[idx] = (ch, fg, (cell.bg[0], cell.bg[1], cell.bg[2])); - } - } - } + let h = self.height as i32; + let mut entity_map: std::collections::HashMap<(i32, i32), (u32, char, (u8, u8, u8))> = + std::collections::HashMap::new(); for e in entities.all() { for b in &e.bodies { if !b.alive { @@ -122,7 +125,6 @@ impl Renderer for TerminalRenderer { let sx = b.x as i32 - cam_x; let sy = b.y as i32 - cam_y; if sx >= 0 && sx < self.width as i32 && sy >= 0 && sy < self.height as i32 { - let idx = sy as usize * self.width + sx as usize; let ch = match e.kind { crate::entity::EntityKind::Player if e.alive => '@', crate::entity::EntityKind::Goblin if e.alive => 'g', @@ -134,11 +136,140 @@ impl Renderer for TerminalRenderer { } else { (b.color[0], b.color[1], b.color[2]) }; - frame[idx] = (ch, fg, (20, 10, 10)); + let priority = entity_priority(e.kind); + let key = (sx, sy); + if entity_map + .get(&key) + .map(|(p, _, _)| priority > *p) + .unwrap_or(true) + { + entity_map.insert(key, (priority, ch, fg)); + } } } } + let mut shadow_map: std::collections::HashSet<(i32, i32)> = + std::collections::HashSet::new(); + for (&(ex, ey), _) in entity_map.iter() { + for dy in -1..=1 { + for dx in -1..=1 { + if dx == 0 && dy == 0 { + continue; + } + let sx = ex + dx; + let sy = ey + dy; + if sx < 0 || sx >= self.width as i32 || sy < 0 || sy >= self.height as i32 { + continue; + } + if entity_map.contains_key(&(sx, sy)) { + continue; + } + let wx = cam_x + sx; + let wy = cam_y + sy; + if grid.in_bounds(wx, wy) && grid.get(wx, wy).is_empty() { + shadow_map.insert((sx, sy)); + } + } + } + } + + for dy in 0..self.height { + for dx in 0..self.width { + let wx = cam_x + dx as i32; + let wy = cam_y + dy as i32; + let idx = dy * self.width + dx; + let light = lighting + .map(|l| l.get(dx as i32, dy as i32)) + .unwrap_or_else(lighting::ambient_light); + if !grid.in_bounds(wx, wy) { + let bg = background_color(wx, wy, dy as i32, h); + let bg = apply_light_tuple(bg, light); + frame[idx] = ('?', apply_light_tuple((80, 80, 80), light), bg); + continue; + } + let cell = grid.get(wx, wy); + if cell.is_empty() { + let bg = background_color(wx, wy, dy as i32, h); + let bg = if shadow_map.contains(&(dx as i32, dy as i32)) { + ( + (bg.0 as f32 * 0.4) as u8, + (bg.1 as f32 * 0.4) as u8, + (bg.2 as f32 * 0.4) as u8, + ) + } else { + bg + }; + let bg = apply_light_tuple(bg, light); + let fg = apply_light_tuple((cell.fg[0], cell.fg[1], cell.fg[2]), light); + frame[idx] = (' ', fg, bg); + } else { + let ch = cell.material.display_char(); + let fg = if cell.material == MaterialId::Lava { + let r = 200u8.saturating_add(cell.variant / 2); + (r, 60, 20) + } else { + (cell.fg[0], cell.fg[1], cell.fg[2]) + }; + let fg = apply_light_tuple(fg, light); + let bg = apply_light_tuple((cell.bg[0], cell.bg[1], cell.bg[2]), light); + frame[idx] = (ch, fg, bg); + } + } + } + + for ((sx, sy), (_, ch, fg)) in entity_map { + let idx = sy as usize * self.width + sx as usize; + let light = lighting + .map(|l| l.get(sx, sy)) + .unwrap_or_else(lighting::ambient_light); + let fg = apply_light_tuple(fg, light); + frame[idx] = (ch, fg, apply_light_tuple((20, 10, 10), light)); + } + + for item in items.all() { + let sx = item.x - cam_x; + let sy = item.y - cam_y; + if sx >= 0 && sx < self.width as i32 && sy >= 0 && sy < self.height as i32 { + let idx = sy as usize * self.width + sx as usize; + let color = item.color(); + let light = lighting + .map(|l| l.get(sx, sy)) + .unwrap_or_else(lighting::ambient_light); + frame[idx] = ( + item.display_char(), + apply_light_tuple((color[0], color[1], color[2]), light), + apply_light_tuple((20, 10, 10), light), + ); + } + } + + for (x, y) in ui.keys() { + let tx = *x / crate::ui::UI_SCALE; + let ty = *y / crate::ui::UI_SCALE; + if tx < 0 || tx >= self.width as i32 || ty < 0 || ty >= self.height as i32 { + continue; + } + let cell = ui.get(*x, *y).unwrap(); + let idx = ty as usize * self.width + tx as usize; + let a = cell.alpha as f32 / 255.0; + let (_, old_fg, old_bg) = frame[idx]; + let blend = |c: u8, o: u8| (c as f32 * a + o as f32 * (1.0 - a)).min(255.0) as u8; + frame[idx] = ( + cell.ch, + ( + blend(cell.fg[0], old_fg.0), + blend(cell.fg[1], old_fg.1), + blend(cell.fg[2], old_fg.2), + ), + ( + blend(cell.bg[0], old_bg.0), + blend(cell.bg[1], old_bg.1), + blend(cell.bg[2], old_bg.2), + ), + ); + } + let mut prev_color: Option<(Color, Color)> = None; for i in 0..total { if frame[i] == self.prev_frame[i] { diff --git a/src/render/vulkan.rs b/src/render/vulkan.rs index bfb74ff..892a5e3 100644 --- a/src/render/vulkan.rs +++ b/src/render/vulkan.rs @@ -4,17 +4,43 @@ use std::ffi::CString; use std::sync::Arc; use crate::entity::{EntityKind, EntityManager}; +use crate::render::lighting; use crate::world::cell::MaterialId; -use crate::world::grid::Grid; +use crate::world::grid::{Grid, WORLD_H, WORLD_W}; -const CHAR_W: u32 = 10; -const CHAR_H: u32 = 10; +const CHAR_W: u32 = 8; +const CHAR_H: u32 = 8; +const UI_CELL_SIZE: u32 = 2; const ATLAS_COLS: usize = 16; -const ATLAS_ROWS: usize = 16; +const ATLAS_ROWS: usize = 8; const ATLAS_W: u32 = (ATLAS_COLS as u32) * CHAR_W; const ATLAS_H: u32 = (ATLAS_ROWS as u32) * CHAR_H; const MAX_FRAMES: usize = 2; +fn entity_priority(kind: EntityKind) -> u32 { + match kind { + EntityKind::Player => 3, + EntityKind::Goblin => 2, + EntityKind::Slime => 1, + EntityKind::Corpse => 0, + } +} + +fn background_color(wx: i32, wy: i32, vy: i32, view_h: i32) -> [u8; 4] { + let t = (vy as f32 / view_h as f32).clamp(0.0, 1.0); + let base_r = (10.0 + t * 15.0) as u8; + let base_g = (10.0 + t * 25.0) as u8; + let base_b = (25.0 + t * 35.0) as u8; + + let hash = ((wx.wrapping_mul(73856093)) ^ (wy.wrapping_mul(19349663))).abs(); + if hash % 80 == 0 { + let brightness = (60 + (hash % 120) as u8).min(255); + return [brightness, brightness, brightness + 20, 255]; + } + + [base_r, base_g, base_b, 255] +} + #[repr(C)] #[derive(Clone, Copy, Default)] struct CellInstance { @@ -28,11 +54,28 @@ struct CellInstance { bg: [u8; 4], } +#[repr(C)] +#[derive(bytemuck::NoUninit, Clone, Copy, Default)] +struct GpuLightSource { + pos: [f32; 2], + radius: f32, + _pad0: f32, + color: [f32; 3], + _pad1: f32, +} + +const MAX_LIGHT_SOURCES: usize = 64; + #[repr(C)] #[derive(bytemuck::NoUninit, Clone, Copy)] struct PushConstants { screen_size: [f32; 2], cell_size: [f32; 2], + world_size: [i32; 2], + cam_pos: [i32; 2], + ambient: [f32; 3], + is_ui: u32, + light_count: u32, } pub struct VulkanRenderer { @@ -50,6 +93,7 @@ pub struct VulkanRenderer { swapchain: vk::SwapchainKHR, swapchain_image_views: Vec, swapchain_extent: vk::Extent2D, + present_mode: vk::PresentModeKHR, render_pass: vk::RenderPass, pipeline: vk::Pipeline, @@ -73,13 +117,33 @@ pub struct VulkanRenderer { atlas_memory: vk::DeviceMemory, atlas_view: vk::ImageView, atlas_sampler: vk::Sampler, - atlas_map: std::collections::HashMap, + atlas_map: [(f32, f32, f32, f32); 128], instance_buffer: vk::Buffer, instance_memory: vk::DeviceMemory, instance_ptr: *mut CellInstance, instance_count: usize, + ui_instance_buffer: vk::Buffer, + ui_instance_memory: vk::DeviceMemory, + ui_instance_ptr: *mut CellInstance, + ui_instance_capacity: usize, + + grid_buffer: vk::Buffer, + grid_memory: vk::DeviceMemory, + grid_ptr: *mut u32, + + light_buffer: vk::Buffer, + light_memory: vk::DeviceMemory, + light_ptr: *mut GpuLightSource, + + ent_pri_buf: Vec, + entity_char_buf: Vec, + entity_color_buf: Vec<[u8; 4]>, + item_char_buf: Vec, + item_color_buf: Vec<[u8; 4]>, + shadow_buf: Vec, + descriptor_pool: vk::DescriptorPool, descriptor_set: vk::DescriptorSet, descriptor_set_layout: vk::DescriptorSetLayout, @@ -112,16 +176,17 @@ impl VulkanRenderer { let (device, graphics_queue) = create_device(&instance, physical_device, queue_family)?; let swapchain_loader = ash::khr::swapchain::Device::new(&instance, &device); - let (swapchain, swapchain_images, swapchain_format, swapchain_extent) = create_swapchain( - &device, - &swapchain_loader, - &surface_loader, - physical_device, - surface, - queue_family, - pixel_w, - pixel_h, - )?; + let (swapchain, swapchain_images, swapchain_format, swapchain_extent, present_mode) = + create_swapchain( + &device, + &swapchain_loader, + &surface_loader, + physical_device, + surface, + queue_family, + pixel_w, + pixel_h, + )?; let swapchain_image_views: Vec<_> = swapchain_images .iter() @@ -156,7 +221,56 @@ impl VulkanRenderer { let (instance_buffer, instance_memory, instance_ptr) = create_instance_buffer(&device, &instance, physical_device, instance_count)?; - update_descriptor_set(&device, descriptor_set, atlas_view, atlas_sampler); + let ui_instance_capacity = 65536usize; + 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_buffer, grid_memory) = create_buffer_with_data( + &device, + &instance, + physical_device, + &grid_data, + vk::BufferUsageFlags::STORAGE_BUFFER, + )?; + let grid_ptr = unsafe { + let sz = (WORLD_W * 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:?}"))?; + ptr as *mut u32 + }; + + let light_data = vec![GpuLightSource::default(); MAX_LIGHT_SOURCES]; + let light_buffer_size = + (MAX_LIGHT_SOURCES * std::mem::size_of::()) as vk::DeviceSize; + let (light_buffer, light_memory) = create_buffer_with_data( + &device, + &instance, + physical_device, + &light_data, + vk::BufferUsageFlags::STORAGE_BUFFER, + )?; + let light_ptr = unsafe { + let ptr = device + .map_memory( + light_memory, + 0, + light_buffer_size, + vk::MemoryMapFlags::default(), + ) + .map_err(|e| format!("map light: {e:?}"))?; + ptr as *mut GpuLightSource + }; + + update_descriptor_set( + &device, + descriptor_set, + atlas_view, + atlas_sampler, + grid_buffer, + light_buffer, + ); Ok(Self { grid_w, @@ -171,6 +285,7 @@ impl VulkanRenderer { swapchain, swapchain_image_views, swapchain_extent, + present_mode, render_pass, pipeline, pipeline_layout, @@ -194,6 +309,27 @@ impl VulkanRenderer { instance_memory, instance_ptr, instance_count, + + ui_instance_buffer, + ui_instance_memory, + ui_instance_ptr, + ui_instance_capacity, + + grid_buffer, + grid_memory, + grid_ptr, + + light_buffer, + light_memory, + light_ptr, + + ent_pri_buf: Vec::new(), + entity_char_buf: Vec::new(), + entity_color_buf: Vec::new(), + item_char_buf: Vec::new(), + item_color_buf: Vec::new(), + shadow_buf: Vec::new(), + descriptor_pool, descriptor_set, descriptor_set_layout, @@ -201,11 +337,49 @@ impl VulkanRenderer { }) } - pub fn render(&mut self, grid: &Grid, entities: &EntityManager, cam_x: i32, cam_y: i32) { + pub fn render( + &mut self, + grid: &Grid, + entities: &EntityManager, + items: &crate::entity::item::ItemManager, + ui: &crate::ui::UiLayer, + cam_x: i32, + cam_y: i32, + _lighting: Option<&lighting::LightGrid>, + ) { self.check_resize(); - let mut entity_map: std::collections::HashMap<(i32, i32), (char, [u8; 4])> = - std::collections::HashMap::new(); + let vp_size = self.grid_w * self.grid_h; + if self.ent_pri_buf.len() != vp_size { + self.ent_pri_buf.resize(vp_size, 0); + self.entity_char_buf.resize(vp_size, '\0'); + self.entity_color_buf.resize(vp_size, [0, 0, 0, 0]); + self.item_char_buf.resize(vp_size, '\0'); + self.item_color_buf.resize(vp_size, [0, 0, 0, 0]); + self.shadow_buf.resize(vp_size, false); + } + self.ent_pri_buf.fill(0); + self.entity_char_buf.fill('\0'); + self.entity_color_buf.fill([0, 0, 0, 0]); + self.item_char_buf.fill('\0'); + self.item_color_buf.fill([0, 0, 0, 0]); + self.shadow_buf.fill(false); + + let ent_pri = &mut self.ent_pri_buf; + let entity_char = &mut self.entity_char_buf; + let entity_color = &mut self.entity_color_buf; + let item_char = &mut self.item_char_buf; + let item_color = &mut self.item_color_buf; + + for item in items.all() { + let sx = item.x - cam_x; + let sy = item.y - cam_y; + if sx >= 0 && sx < self.grid_w as i32 && sy >= 0 && sy < self.grid_h as i32 { + let idx = sy as usize * self.grid_w + sx as usize; + item_char[idx] = item.display_char(); + item_color[idx] = [item.color()[0], item.color()[1], item.color()[2], 255]; + } + } for e in entities.all() { for b in &e.bodies { if !b.alive { @@ -214,6 +388,7 @@ impl VulkanRenderer { let sx = b.x as i32 - cam_x; let sy = b.y as i32 - cam_y; if sx >= 0 && sx < self.grid_w as i32 && sy >= 0 && sy < self.grid_h as i32 { + let idx = sy as usize * self.grid_w + sx as usize; let ch = match e.kind { EntityKind::Player if e.alive => '@', EntityKind::Goblin if e.alive => 'g', @@ -225,30 +400,105 @@ impl VulkanRenderer { } else { b.color }; - entity_map.insert((sx, sy), (ch, fg)); + let pri = entity_priority(e.kind); + if pri as u8 > ent_pri[idx] { + ent_pri[idx] = pri as u8; + entity_char[idx] = ch; + entity_color[idx] = fg; + } } } } + let shadow_buf = &mut self.shadow_buf; + for idx in 0..vp_size { + if ent_pri[idx] == 0 { + continue; + } + let ex = idx % self.grid_w; + let ey = idx / self.grid_w; + for dy in -1i32..=1 { + for dx in -1i32..=1 { + if dx == 0 && dy == 0 { + continue; + } + let sx = ex as i32 + dx; + let sy = ey as i32 + dy; + if sx < 0 || sx >= self.grid_w as i32 || sy < 0 || sy >= self.grid_h as i32 { + continue; + } + let sidx = sy as usize * self.grid_w + sx as usize; + if ent_pri[sidx] > 0 { + continue; + } + let wx = cam_x + sx; + let wy = cam_y + sy; + if !grid.in_bounds(wx, wy) || grid.get(wx, wy).is_empty() { + shadow_buf[sidx] = true; + } + } + } + } + + 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; + } + } + } + + let sources = + lighting::gather_sources_in_range(grid, cam_x, cam_y, self.grid_w, self.grid_h, 30); + let light_count = sources.len().min(MAX_LIGHT_SOURCES) as u32; + unsafe { + let light_slice = std::slice::from_raw_parts_mut(self.light_ptr, MAX_LIGHT_SOURCES); + for (i, src) in sources.iter().take(MAX_LIGHT_SOURCES).enumerate() { + light_slice[i] = GpuLightSource { + pos: [src.x as f32, src.y as f32], + radius: src.radius as f32, + _pad0: 0.0, + color: [ + src.color[0] as f32 / 255.0, + src.color[1] as f32 / 255.0, + src.color[2] as f32 / 255.0, + ], + _pad1: 0.0, + }; + } + } + let instances = unsafe { std::slice::from_raw_parts_mut(self.instance_ptr, self.instance_count) }; - let bg_default = [10u8, 10, 15, 255]; + let gh = self.grid_h as i32; for dy in 0..self.grid_h { for dx in 0..self.grid_w { let idx = dy * self.grid_w + dx; let wx = cam_x + dx as i32; let wy = cam_y + dy as i32; - let (ch, fg, bg) = if let Some(&(ec, ef)) = entity_map.get(&(dx as i32, dy as i32)) - { - (ec, ef, bg_default) + let bg = background_color(wx, wy, dy as i32, gh); + + let (ch, fg, bg) = if ent_pri[idx] > 0 { + (entity_char[idx], entity_color[idx], bg) + } else if item_char[idx] != '\0' { + (item_char[idx], item_color[idx], bg) + } else if shadow_buf[idx] { + (' ', [0, 0, 0, 255], bg) } else if !grid.in_bounds(wx, wy) { - ('?', [80, 80, 80, 255], bg_default) + ('?', [80, 80, 80, 255], bg) } else { let cell = grid.get(wx, wy); if cell.is_empty() { - (' ', [10, 10, 15, 255], bg_default) + (' ', bg, bg) } else { let fg = if cell.material == MaterialId::Lava { let r = 200u8.saturating_add(cell.variant / 2); @@ -261,11 +511,7 @@ impl VulkanRenderer { } }; - let (au, av, aw, ah) = self - .atlas_map - .get(&ch) - .copied() - .unwrap_or((0.0, 0.0, 0.0, 0.0)); + let (au, av, aw, ah) = self.atlas_map[(ch as usize) & 127]; instances[idx] = CellInstance { grid_x: dx as f32, grid_y: dy as f32, @@ -279,6 +525,29 @@ impl VulkanRenderer { } } + let ui_instances = unsafe { + std::slice::from_raw_parts_mut(self.ui_instance_ptr, self.ui_instance_capacity) + }; + let mut ui_count = 0usize; + for (x, y) in ui.keys() { + if ui_count >= self.ui_instance_capacity { + break; + } + let cell = ui.get(*x, *y).unwrap(); + let (au, av, aw, ah) = self.atlas_map[(cell.ch as usize) & 127]; + ui_instances[ui_count] = CellInstance { + grid_x: *x as f32, + grid_y: *y as f32, + atlas_u: au, + atlas_v: av, + atlas_w: aw, + atlas_h: ah, + fg: [cell.fg[0], cell.fg[1], cell.fg[2], cell.alpha], + bg: [cell.bg[0], cell.bg[1], cell.bg[2], cell.alpha], + }; + ui_count += 1; + } + let frame = self.frame_index; let device = &self.device; @@ -351,12 +620,22 @@ impl VulkanRenderer { &[], ); + let ambient = lighting::ambient_light(); let pc = PushConstants { screen_size: [ self.swapchain_extent.width as f32, self.swapchain_extent.height as f32, ], cell_size: [CHAR_W as f32, CHAR_H as f32], + world_size: [WORLD_W as i32, WORLD_H as i32], + cam_pos: [cam_x, cam_y], + ambient: [ + ambient[0] as f32 / 255.0, + ambient[1] as f32 / 255.0, + ambient[2] as f32 / 255.0, + ], + is_ui: 0, + light_count, }; device.cmd_push_constants( cmd, @@ -367,6 +646,36 @@ impl VulkanRenderer { ); device.cmd_draw_indexed(cmd, 6, self.instance_count as u32, 0, 0, 0); + + if ui_count > 0 { + device.cmd_bind_vertex_buffers( + cmd, + 0, + &[self.vertex_buffer, self.ui_instance_buffer], + &[0, 0], + ); + let ui_pc = PushConstants { + screen_size: [ + self.swapchain_extent.width as f32, + 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], + cam_pos: [0, 0], + ambient: [0.0, 0.0, 0.0], + is_ui: 1, + light_count: 0, + }; + device.cmd_push_constants( + cmd, + self.pipeline_layout, + vk::ShaderStageFlags::VERTEX, + 0, + bytemuck::bytes_of(&ui_pc), + ); + device.cmd_draw_indexed(cmd, 6, ui_count as u32, 0, 0, 0); + } + device.cmd_end_render_pass(cmd); let _ = device.end_command_buffer(cmd); @@ -458,7 +767,7 @@ impl VulkanRenderer { .image_sharing_mode(vk::SharingMode::EXCLUSIVE) .pre_transform(caps.current_transform) .composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE) - .present_mode(vk::PresentModeKHR::FIFO) + .present_mode(self.present_mode) .clipped(true) .old_swapchain(self.swapchain); @@ -630,6 +939,12 @@ impl Drop for VulkanRenderer { self.device.free_memory(self.atlas_memory, None); self.device.destroy_buffer(self.instance_buffer, None); self.device.free_memory(self.instance_memory, None); + self.device.destroy_buffer(self.ui_instance_buffer, None); + self.device.free_memory(self.ui_instance_memory, None); + self.device.destroy_buffer(self.grid_buffer, None); + self.device.free_memory(self.grid_memory, None); + self.device.destroy_buffer(self.light_buffer, None); + self.device.free_memory(self.light_memory, None); self.device.destroy_buffer(self.vertex_buffer, None); self.device.free_memory(self.vertex_memory, None); self.device.destroy_buffer(self.index_buffer, None); @@ -761,9 +1076,26 @@ fn create_swapchain( qf: u32, pw: u32, ph: u32, -) -> Result<(vk::SwapchainKHR, Vec, vk::Format, vk::Extent2D), String> { +) -> Result< + ( + vk::SwapchainKHR, + Vec, + vk::Format, + vk::Extent2D, + vk::PresentModeKHR, + ), + String, +> { let caps = unsafe { surface_loader.get_physical_device_surface_capabilities(pd, surface) } .map_err(|e| format!("caps: {e:?}"))?; + let present_modes = + unsafe { surface_loader.get_physical_device_surface_present_modes(pd, surface) } + .unwrap_or_default(); + let present_mode = present_modes + .iter() + .copied() + .find(|&m| m == vk::PresentModeKHR::MAILBOX) + .unwrap_or(vk::PresentModeKHR::FIFO); let format = vk::SurfaceFormatKHR { format: vk::Format::B8G8R8A8_UNORM, color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR, @@ -790,13 +1122,13 @@ fn create_swapchain( .queue_family_indices(&qf_slice) .pre_transform(caps.current_transform) .composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE) - .present_mode(vk::PresentModeKHR::FIFO) + .present_mode(present_mode) .clipped(true); let swapchain = unsafe { sl.create_swapchain(&ci, None) }.map_err(|e| format!("swapchain: {e:?}"))?; let images = unsafe { sl.get_swapchain_images(swapchain) }.map_err(|e| format!("images: {e:?}"))?; - Ok((swapchain, images, format.format, extent)) + Ok((swapchain, images, format.format, extent, present_mode)) } fn create_image_view(device: &ash::Device, image: vk::Image, format: vk::Format) -> vk::ImageView { @@ -855,20 +1187,38 @@ fn create_descriptor( ), String, > { - let binding = vk::DescriptorSetLayoutBinding::default() - .binding(0) - .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) - .descriptor_count(1) - .stage_flags(vk::ShaderStageFlags::FRAGMENT); - let li = vk::DescriptorSetLayoutCreateInfo::default().bindings(std::slice::from_ref(&binding)); + let bindings = [ + vk::DescriptorSetLayoutBinding::default() + .binding(0) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::FRAGMENT), + vk::DescriptorSetLayoutBinding::default() + .binding(1) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT), + vk::DescriptorSetLayoutBinding::default() + .binding(2) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER) + .descriptor_count(1) + .stage_flags(vk::ShaderStageFlags::VERTEX), + ]; + let li = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings); let layout = unsafe { device.create_descriptor_set_layout(&li, None) } .map_err(|e| format!("ds_layout: {e:?}"))?; - let ps = vk::DescriptorPoolSize { - ty: vk::DescriptorType::COMBINED_IMAGE_SAMPLER, - descriptor_count: 1, - }; + let pool_sizes = [ + vk::DescriptorPoolSize { + ty: vk::DescriptorType::COMBINED_IMAGE_SAMPLER, + descriptor_count: 1, + }, + vk::DescriptorPoolSize { + ty: vk::DescriptorType::STORAGE_BUFFER, + descriptor_count: 2, + }, + ]; let pi = vk::DescriptorPoolCreateInfo::default() - .pool_sizes(std::slice::from_ref(&ps)) + .pool_sizes(&pool_sizes) .max_sets(1); let pool = unsafe { device.create_descriptor_pool(&pi, None) } .map_err(|e| format!("ds_pool: {e:?}"))?; @@ -980,6 +1330,13 @@ fn create_pipeline( let ms = vk::PipelineMultisampleStateCreateInfo::default() .rasterization_samples(vk::SampleCountFlags::TYPE_1); let cba = vk::PipelineColorBlendAttachmentState::default() + .blend_enable(true) + .src_color_blend_factor(vk::BlendFactor::SRC_ALPHA) + .dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA) + .color_blend_op(vk::BlendOp::ADD) + .src_alpha_blend_factor(vk::BlendFactor::ONE) + .dst_alpha_blend_factor(vk::BlendFactor::ZERO) + .alpha_blend_op(vk::BlendOp::ADD) .color_write_mask(vk::ColorComponentFlags::RGBA); let cb = vk::PipelineColorBlendStateCreateInfo::default().attachments(std::slice::from_ref(&cba)); @@ -1176,7 +1533,7 @@ fn create_atlas_texture( vk::DeviceMemory, vk::ImageView, vk::Sampler, - std::collections::HashMap, + [(f32, f32, f32, f32); 128], ), String, > { @@ -1191,19 +1548,16 @@ fn create_atlas_texture( ) .expect("font"); let mut atlas_data = vec![0u8; (ATLAS_W * ATLAS_H) as usize]; - let mut atlas_map = std::collections::HashMap::new(); + let mut atlas_map = [(0.0f32, 0.0f32, 0.0f32, 0.0f32); 128]; let chars: Vec = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~?".chars().collect(); for (i, &ch) in chars.iter().enumerate() { let col = i % ATLAS_COLS; let row = i / ATLAS_COLS; - atlas_map.insert( - ch, - ( - (col as f32 * CHAR_W as f32) / ATLAS_W as f32, - (row as f32 * CHAR_H as f32) / ATLAS_H as f32, - CHAR_W as f32 / ATLAS_W as f32, - CHAR_H as f32 / ATLAS_H as f32, - ), + atlas_map[(ch as usize) & 127] = ( + (col as f32 * CHAR_W as f32) / ATLAS_W as f32, + (row as f32 * CHAR_H as f32) / ATLAS_H as f32, + CHAR_W as f32 / ATLAS_W as f32, + CHAR_H as f32 / ATLAS_H as f32, ); let (metrics, bitmap) = font.rasterize(ch, CHAR_H as f32); for y in 0..metrics.height.min(CHAR_H as usize) { @@ -1460,17 +1814,39 @@ fn update_descriptor_set( set: vk::DescriptorSet, view: vk::ImageView, sampler: vk::Sampler, + grid_buffer: vk::Buffer, + light_buffer: vk::Buffer, ) { let ii = vk::DescriptorImageInfo::default() .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL) .image_view(view) .sampler(sampler); - let w = vk::WriteDescriptorSet::default() - .dst_set(set) - .dst_binding(0) - .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) - .image_info(std::slice::from_ref(&ii)); + let bi = vk::DescriptorBufferInfo::default() + .buffer(grid_buffer) + .offset(0) + .range((WORLD_W * WORLD_H * std::mem::size_of::()) as vk::DeviceSize); + let li = vk::DescriptorBufferInfo::default() + .buffer(light_buffer) + .offset(0) + .range((MAX_LIGHT_SOURCES * std::mem::size_of::()) as vk::DeviceSize); + let writes = [ + vk::WriteDescriptorSet::default() + .dst_set(set) + .dst_binding(0) + .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER) + .image_info(std::slice::from_ref(&ii)), + vk::WriteDescriptorSet::default() + .dst_set(set) + .dst_binding(1) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER) + .buffer_info(std::slice::from_ref(&bi)), + vk::WriteDescriptorSet::default() + .dst_set(set) + .dst_binding(2) + .descriptor_type(vk::DescriptorType::STORAGE_BUFFER) + .buffer_info(std::slice::from_ref(&li)), + ]; unsafe { - device.update_descriptor_sets(std::slice::from_ref(&w), &[]); + device.update_descriptor_sets(&writes, &[]); } } diff --git a/src/render/window_input.rs b/src/render/window_input.rs index 74b2fed..01ff876 100644 --- a/src/render/window_input.rs +++ b/src/render/window_input.rs @@ -5,6 +5,14 @@ pub struct WindowInput { pub left: bool, pub right: bool, pub jump: bool, + pub shoot_left: bool, + pub shoot_right: bool, + pub shoot_up: bool, + pub shoot_down: bool, + pub toggle_fireball: bool, + pub descend: bool, + pub use_item: bool, + pub drop_item: bool, pub cam_left: bool, pub cam_right: bool, pub cam_up: bool, @@ -12,16 +20,46 @@ pub struct WindowInput { pub quit: bool, pub paint: Option, jump_was_down: bool, + shoot_left_was_down: bool, + shoot_right_was_down: bool, + shoot_up_was_down: bool, + shoot_down_was_down: bool, + fireball_was_down: bool, + descend_was_down: bool, + use_item_was_down: bool, + drop_item_was_down: bool, down_keys: HashSet, } impl WindowInput { pub fn new() -> Self { Self { - left: false, right: false, jump: false, - cam_left: false, cam_right: false, cam_up: false, cam_down: false, - quit: false, paint: None, + left: false, + right: false, + jump: false, + shoot_left: false, + shoot_right: false, + shoot_up: false, + shoot_down: false, + toggle_fireball: false, + descend: false, + use_item: false, + drop_item: false, + cam_left: false, + cam_right: false, + cam_up: false, + cam_down: false, + quit: false, + paint: None, jump_was_down: false, + shoot_left_was_down: false, + shoot_right_was_down: false, + shoot_up_was_down: false, + shoot_down_was_down: false, + fireball_was_down: false, + descend_was_down: false, + use_item_was_down: false, + drop_item_was_down: false, down_keys: HashSet::new(), } } @@ -47,7 +85,9 @@ impl WindowInput { let now_left = keys.contains(&KeyCode::KeyA) || keys.contains(&KeyCode::ArrowLeft); let now_right = keys.contains(&KeyCode::KeyD) || keys.contains(&KeyCode::ArrowRight); - let now_jump = keys.contains(&KeyCode::KeyW) || keys.contains(&KeyCode::Space) || keys.contains(&KeyCode::ArrowUp); + let now_jump = keys.contains(&KeyCode::KeyW) + || keys.contains(&KeyCode::Space) + || keys.contains(&KeyCode::ArrowUp); self.left = now_left && !now_right; self.right = now_right && !now_left; @@ -55,23 +95,65 @@ impl WindowInput { self.jump = now_jump && !self.jump_was_down; self.jump_was_down = now_jump; - self.cam_left = keys.contains(&KeyCode::KeyH); - self.cam_right = keys.contains(&KeyCode::KeyL); - self.cam_up = keys.contains(&KeyCode::KeyK); - self.cam_down = keys.contains(&KeyCode::KeyJ); + let now_shoot_left = keys.contains(&KeyCode::KeyH); + let now_shoot_right = keys.contains(&KeyCode::KeyL); + let now_shoot_up = keys.contains(&KeyCode::KeyK); + let now_shoot_down = keys.contains(&KeyCode::KeyJ); + let now_fireball = keys.contains(&KeyCode::KeyF); + + self.shoot_left = now_shoot_left && !self.shoot_left_was_down; + self.shoot_right = now_shoot_right && !self.shoot_right_was_down; + self.shoot_up = now_shoot_up && !self.shoot_up_was_down; + self.shoot_down = now_shoot_down && !self.shoot_down_was_down; + self.shoot_left_was_down = now_shoot_left; + self.shoot_right_was_down = now_shoot_right; + self.shoot_up_was_down = now_shoot_up; + self.shoot_down_was_down = now_shoot_down; + + self.toggle_fireball = now_fireball && !self.fireball_was_down; + self.fireball_was_down = now_fireball; + + let now_descend = keys.contains(&KeyCode::Period); + self.descend = now_descend && !self.descend_was_down; + self.descend_was_down = now_descend; + + let now_use_item = keys.contains(&KeyCode::KeyE); + self.use_item = now_use_item && !self.use_item_was_down; + self.use_item_was_down = now_use_item; + + let now_drop_item = keys.contains(&KeyCode::KeyR); + self.drop_item = now_drop_item && !self.drop_item_was_down; + self.drop_item_was_down = now_drop_item; + + self.cam_left = keys.contains(&KeyCode::KeyY); + self.cam_right = keys.contains(&KeyCode::KeyU); + self.cam_up = keys.contains(&KeyCode::KeyI); + self.cam_down = keys.contains(&KeyCode::KeyO); self.quit = keys.contains(&KeyCode::KeyQ) || keys.contains(&KeyCode::Escape); self.paint = None; - if keys.contains(&KeyCode::Digit1) { self.paint = Some(1); } - else if keys.contains(&KeyCode::Digit2) { self.paint = Some(2); } - else if keys.contains(&KeyCode::Digit3) { self.paint = Some(3); } - else if keys.contains(&KeyCode::Digit4) { self.paint = Some(4); } - else if keys.contains(&KeyCode::Digit5) { self.paint = Some(5); } - else if keys.contains(&KeyCode::Digit6) { self.paint = Some(6); } - else if keys.contains(&KeyCode::Digit7) { self.paint = Some(7); } - else if keys.contains(&KeyCode::Digit8) { self.paint = Some(8); } - else if keys.contains(&KeyCode::Digit9) { self.paint = Some(9); } - else if keys.contains(&KeyCode::Digit0) { self.paint = Some(0); } - else if keys.contains(&KeyCode::KeyX) { self.paint = Some(99); } + if keys.contains(&KeyCode::Digit1) { + self.paint = Some(1); + } else if keys.contains(&KeyCode::Digit2) { + self.paint = Some(2); + } else if keys.contains(&KeyCode::Digit3) { + self.paint = Some(3); + } else if keys.contains(&KeyCode::Digit4) { + self.paint = Some(4); + } else if keys.contains(&KeyCode::Digit5) { + self.paint = Some(5); + } else if keys.contains(&KeyCode::Digit6) { + self.paint = Some(6); + } else if keys.contains(&KeyCode::Digit7) { + self.paint = Some(7); + } else if keys.contains(&KeyCode::Digit8) { + self.paint = Some(8); + } else if keys.contains(&KeyCode::Digit9) { + self.paint = Some(9); + } else if keys.contains(&KeyCode::Digit0) { + self.paint = Some(0); + } else if keys.contains(&KeyCode::KeyX) { + self.paint = Some(99); + } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..b4d561d --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,737 @@ +use std::collections::HashMap; + +use crate::entity::entity::{Entity, EntityKind}; +use crate::world::cell::MaterialId; + +pub const UI_SCALE: i32 = 4; + +#[derive(Clone, Debug)] +pub struct UiCell { + pub ch: char, + pub fg: [u8; 3], + pub bg: [u8; 3], + pub alpha: u8, +} + +pub struct UiLayer { + cells: HashMap<(i32, i32), UiCell>, + messages: Vec<(String, u32)>, + damage_numbers: Vec, +} + +#[derive(Clone)] +pub struct DamageNumber { + pub x: f32, + pub y: f32, + pub text: String, + pub life: u32, + pub max_life: u32, +} + +impl UiLayer { + pub fn new() -> Self { + Self { + cells: HashMap::new(), + messages: Vec::new(), + damage_numbers: Vec::new(), + } + } + + pub fn clear(&mut self) { + self.cells.clear(); + self.damage_numbers.retain(|d| d.life > 0); + for d in &mut self.damage_numbers { + d.y -= 0.1; + d.life -= 1; + } + } + + pub fn set(&mut self, x: i32, y: i32, ch: char, fg: [u8; 3], bg: [u8; 3]) { + self.cells.insert( + (x, y), + UiCell { + ch, + fg, + bg, + alpha: 255, + }, + ); + } + + pub fn set_alpha(&mut self, x: i32, y: i32, ch: char, fg: [u8; 3], bg: [u8; 3], alpha: u8) { + self.cells.insert((x, y), UiCell { ch, fg, bg, alpha }); + } + + pub fn get(&self, x: i32, y: i32) -> Option<&UiCell> { + self.cells.get(&(x, y)) + } + + pub fn keys(&self) -> impl Iterator { + self.cells.keys() + } + + pub fn add_message(&mut self, text: &str) { + self.messages.push((text.to_string(), 300)); + } + + pub fn add_damage_number(&mut self, x: f32, y: f32, text: &str) { + self.damage_numbers.push(DamageNumber { + x, + y, + text: text.to_string(), + life: 50, + max_life: 50, + }); + } + + pub fn draw_damage_numbers(&mut self, cam_x: i32, cam_y: i32) { + let numbers: Vec = self.damage_numbers.clone(); + for d in numbers { + let sx = (d.x as i32 - cam_x) * UI_SCALE; + let sy = (d.y as i32 - cam_y) * UI_SCALE; + let fg = if d.text.starts_with('+') { + [80, 240, 80] + } else { + [255, 80, 80] + }; + self.draw_text(sx, sy, &d.text, fg, 255); + } + } + + pub fn draw_edge_indicators( + &mut self, + screen_w: usize, + screen_h: usize, + entities: &[Entity], + cam_x: i32, + cam_y: i32, + ) { + let sw = screen_w as i32; + let sh = screen_h as i32; + for e in entities { + if !e.alive { + continue; + } + let (sx, sy) = entity_screen_pos_ui(e, cam_x, cam_y); + if sx >= 0 && sx < sw && sy >= 0 && sy < sh { + continue; + } + let dx = sx - sw / 2; + let dy = sy - sh / 2; + let dist = ((dx * dx + dy * dy) as f32).sqrt().max(1.0); + let ix = (sw / 2) as f32 + (dx as f32 / dist) * (sw as f32 / 2.0 - 2.0); + let iy = (sh / 2) as f32 + (dy as f32 / dist) * (sh as f32 / 2.0 - 2.0); + let (ix, iy) = ( + ix.clamp(1.0, sw as f32 - 2.0) as i32, + iy.clamp(1.0, sh as f32 - 2.0) as i32, + ); + let ch = match e.kind { + EntityKind::Goblin => 'g', + EntityKind::Slime => 's', + EntityKind::Player => '@', + EntityKind::Corpse => '%', + }; + let fg = match e.kind { + EntityKind::Goblin => [160, 240, 120], + EntityKind::Slime => [120, 240, 160], + EntityKind::Player => [200, 180, 255], + EntityKind::Corpse => [160, 160, 160], + }; + self.set(ix, iy, ch, fg, [20, 20, 30]); + } + } + + pub fn tick_messages(&mut self) { + for (_, life) in &mut self.messages { + if *life > 0 { + *life -= 1; + } + } + self.messages.retain(|(_, life)| *life > 0); + while self.messages.len() > 8 { + self.messages.remove(0); + } + } + + pub fn messages(&self) -> &[(String, u32)] { + &self.messages + } + + pub fn damage_numbers(&self) -> &[DamageNumber] { + &self.damage_numbers + } + + pub fn draw_health_bar( + &mut self, + screen_x: i32, + screen_y: i32, + health: f32, + max_health: f32, + width: i32, + ) { + if max_health <= 0.0 { + return; + } + let ratio = (health / max_health).clamp(0.0, 1.0); + let filled = (ratio * width as f32).round() as i32; + let color = if ratio > 0.6 { + [60, 220, 60] + } else if ratio > 0.3 { + [240, 200, 40] + } else { + [240, 50, 50] + }; + for i in 0..width { + let x = screen_x + i; + let ch = if i < filled { '█' } else { '░' }; + let fg = if i < filled { color } else { [80, 80, 80] }; + self.set(x, screen_y, ch, fg, [0, 0, 0]); + } + } + + pub fn draw_entity_labels(&mut self, entities: &[Entity], cam_x: i32, cam_y: i32) { + for e in entities { + if !e.alive || e.kind == EntityKind::Corpse { + continue; + } + let (sx, sy) = entity_screen_pos_ui(e, cam_x, cam_y); + let label = e.name().to_uppercase(); + let top = sy - (e.half_h as i32 * UI_SCALE) - 7; + let x = sx - (Self::text_width(&label) / 2); + let fg = entity_kind_color(e); + self.draw_text(x, top, &label, fg, 255); + } + } + + pub fn draw_status_icons(&mut self, entities: &[Entity], cam_x: i32, cam_y: i32) { + for e in entities { + if !e.alive { + continue; + } + let (sx, sy) = entity_screen_pos_ui(e, cam_x, cam_y); + let icon_x = sx + (e.half_w as i32 * UI_SCALE) + 1; + let mut icon_y = sy - (e.half_h as i32 * UI_SCALE) - 7; + if e.on_fire { + self.set(icon_x, icon_y, '🔥', [255, 100, 20], [0, 0, 0]); + icon_y += 1; + } + if e.poisoned { + self.set(icon_x, icon_y, '☠', [80, 255, 80], [0, 0, 0]); + icon_y += 1; + } + if e.frozen { + self.set(icon_x, icon_y, '❄', [120, 220, 255], [0, 0, 0]); + icon_y += 1; + } + if e.bleeding { + self.set(icon_x, icon_y, '✚', [255, 40, 40], [0, 0, 0]); + } + } + } + + pub fn draw_items(&mut self, items: &[crate::entity::item::Item], cam_x: i32, cam_y: i32) { + for item in items { + let sx = (item.x - cam_x) * UI_SCALE; + let sy = (item.y - cam_y) * UI_SCALE; + self.set(sx, sy, item.display_char(), item.color(), [0, 0, 0]); + } + } + + pub fn draw_minimap( + &mut self, + screen_w: usize, + screen_h: usize, + grid: &crate::world::grid::Grid, + entities: &[Entity], + cam_x: i32, + cam_y: i32, + ) { + let size = 16; + let scale = 16; + let start_x = screen_w as i32 - size - 2; + let start_y = 1; + let view_w = screen_w as i32 / UI_SCALE; + let view_h = screen_h as i32 / UI_SCALE; + let cx = (cam_x + view_w / 2) / scale; + let cy = (cam_y + view_h / 2) / scale; + for dy in 0..size { + for dx in 0..size { + let mx = cx - size / 2 + dx; + let my = cy - size / 2 + dy; + let wx = mx * scale; + let wy = my * scale; + if !grid.in_bounds(wx, wy) { + self.set(start_x + dx, start_y + dy, ' ', [0, 0, 0], [20, 20, 30]); + continue; + } + let mut r = 0u32; + let mut g = 0u32; + let mut b = 0u32; + let mut n = 0u32; + for yy in 0..scale { + for xx in 0..scale { + let cell = grid.get(wx + xx, wy + yy); + if cell.material != MaterialId::Empty { + let c = cell.fg; + r += c[0] as u32; + g += c[1] as u32; + b += c[2] as u32; + n += 1; + } + } + } + let fg = if n > 0 { + [(r / n) as u8, (g / n) as u8, (b / n) as u8] + } else { + [40, 40, 50] + }; + let bg = [20, 20, 30]; + self.set(start_x + dx, start_y + dy, '·', fg, bg); + } + } + for e in entities { + if !e.alive { + continue; + } + let (ex, ey) = e.center(); + let mx = (ex as i32 / scale) - cx + size / 2; + let my = (ey as i32 / scale) - cy + size / 2; + if mx >= 0 && mx < size && my >= 0 && my < size { + let ch = match e.kind { + EntityKind::Player => '@', + EntityKind::Goblin => 'g', + EntityKind::Slime => 's', + EntityKind::Corpse => '%', + }; + let fg = entity_kind_color(e); + self.set(start_x + mx, start_y + my, ch, fg, [0, 0, 0]); + } + } + for dx in 0..size { + self.set(start_x + dx, start_y - 1, '─', [80, 80, 100], [0, 0, 0]); + self.set(start_x + dx, start_y + size, '─', [80, 80, 100], [0, 0, 0]); + } + for dy in 0..size { + self.set(start_x - 1, start_y + dy, '│', [80, 80, 100], [0, 0, 0]); + self.set(start_x + size, start_y + dy, '│', [80, 80, 100], [0, 0, 0]); + } + self.set(start_x - 1, start_y - 1, '┌', [80, 80, 100], [0, 0, 0]); + self.set(start_x + size, start_y - 1, '┐', [80, 80, 100], [0, 0, 0]); + self.set(start_x - 1, start_y + size, '└', [80, 80, 100], [0, 0, 0]); + self.set( + start_x + size, + start_y + size, + '┘', + [80, 80, 100], + [0, 0, 0], + ); + } + + pub fn draw_character_panel( + &mut self, + start_x: i32, + start_y: i32, + player: Option<&Entity>, + player_state: &crate::entity::player::Player, + ) { + let w = 44i32; + let h = 44i32; + let bg = [22u8, 26, 38]; + let border = [160u8, 180, 255]; + let title = [220u8, 230, 255]; + let dim = [150u8, 160, 190]; + let fg = [240u8, 240, 250]; + let fill_alpha = 140u8; + let border_alpha = 220u8; + + for y in 0..h { + for x in 0..w { + self.set_alpha(start_x + x, start_y + y, ' ', [0, 0, 0], bg, fill_alpha); + } + } + for x in 0..w { + if (start_x + x) % 2 == 0 { + self.set_alpha(start_x + x, start_y, '·', border, bg, border_alpha); + self.set_alpha(start_x + x, start_y + h - 1, '·', border, bg, border_alpha); + } + } + for y in 0..h { + if (start_y + y) % 2 == 0 { + self.set_alpha(start_x, start_y + y, '·', border, bg, border_alpha); + self.set_alpha(start_x + w - 1, start_y + y, '·', border, bg, border_alpha); + } + } + self.set_alpha(start_x, start_y, '◆', border, bg, border_alpha); + self.set_alpha(start_x + w - 1, start_y, '◆', border, bg, border_alpha); + self.set_alpha(start_x, start_y + h - 1, '◆', border, bg, border_alpha); + self.set_alpha( + start_x + w - 1, + start_y + h - 1, + '◆', + border, + bg, + border_alpha, + ); + + let title_text = "STATUS"; + let tx = start_x + (w - Self::text_width(title_text)) / 2; + self.draw_text(tx, start_y + 1, title_text, title, 255); + + if let Some(p) = player { + let hp_ratio = (p.health / p.max_health).clamp(0.0, 1.0); + let hp_filled = (hp_ratio * 32.0).round() as i32; + let bar_color = if hp_ratio > 0.6 { + [80, 240, 80] + } else if hp_ratio > 0.3 { + [240, 220, 60] + } else { + [255, 60, 60] + }; + self.draw_text(start_x + 2, start_y + 7, "HP", fg, 255); + for i in 0..32 { + let ch = if i < hp_filled { '█' } else { '░' }; + let c = if i < hp_filled { + bar_color + } else { + [80, 80, 100] + }; + self.set(start_x + 10 + i, start_y + 7, ch, c, bg); + } + let hp_text = format!("{}/{} LV:{}", p.health as i32, p.max_health as i32, p.level); + self.draw_text(start_x + 2, start_y + 13, &hp_text, fg, 255); + + let xp_ratio = (p.xp as f32 / p.xp_to_level() as f32).clamp(0.0, 1.0); + let xp_filled = (xp_ratio * 32.0).round() as i32; + self.draw_text(start_x + 2, start_y + 19, "XP", fg, 255); + for i in 0..32 { + let ch = if i < xp_filled { '█' } else { '░' }; + let c = if i < xp_filled { + [80, 160, 240] + } else { + [60, 60, 80] + }; + self.set(start_x + 10 + i, start_y + 19, ch, c, bg); + } + + let stats = format!("STR:{} AGI:{}", p.strength, p.agility); + self.draw_text(start_x + 2, start_y + 25, &stats, dim, 255); + let stats2 = format!("TOU:{} WIL:{}", p.toughness, p.willpower); + self.draw_text(start_x + 2, start_y + 31, &stats2, dim, 255); + } + + let inv_title = "INV:"; + self.draw_text(start_x + 2, start_y + 37, inv_title, fg, 255); + for (idx, item) in player_state.inventory.iter().take(6).enumerate() { + let ix = start_x + 14 + (idx as i32 * 4); + if ix < start_x + w - 2 { + self.set(ix, start_y + 38, item.display_char(), item.color(), bg); + } + } + + let status = if let Some(p) = player { + let mut s = String::new(); + if p.on_fire { + s.push('🔥'); + } + if p.poisoned { + s.push('☠'); + } + if p.frozen { + s.push('❄'); + } + if p.bleeding { + s.push('✚'); + } + s + } else { + String::new() + }; + if !status.is_empty() { + for (i, ch) in status.chars().enumerate() { + self.set(start_x + 30 + i as i32, start_y + 38, ch, [255, 80, 80], bg); + } + } + } + + pub fn draw_hud( + &mut self, + screen_w: usize, + screen_h: usize, + player: Option<&Entity>, + tick: u64, + brush: MaterialId, + kills: u32, + score: u32, + depth: u32, + player_state: &crate::entity::player::Player, + fps: f32, + ) { + let bg = [18u8, 20, 30]; + let border = [160u8, 170, 210]; + let fill_alpha = 140u8; + let brush_name = material_name(brush); + let weapon_name = player_state + .weapon + .as_ref() + .map(|i| { + let n = i.name(); + if n.len() > 12 { + &n[..12] + } else { + n + } + }) + .unwrap_or("NONE"); + let armor_name = player_state + .armor + .as_ref() + .map(|i| { + let n = i.name(); + if n.len() > 12 { + &n[..12] + } else { + n + } + }) + .unwrap_or("NONE"); + + let bar_h = 18i32; + let y_top = screen_h as i32 - bar_h; + let y_row1 = y_top + 1; + let y_row2 = y_top + 7; + let y_row3 = y_top + 13; + + for row in y_top..screen_h as i32 { + for x in 0..screen_w { + self.set_alpha(x as i32, row, ' ', [200, 200, 200], bg, fill_alpha); + } + } + for x in 0..screen_w { + if x % 2 == 0 { + self.set_alpha(x as i32, y_top - 1, '·', border, bg, 220); + } + } + + self.draw_text(0, y_row1, "HP", [220, 220, 220], 255); + if let Some(p) = player { + let hp_ratio = (p.health / p.max_health).clamp(0.0, 1.0); + let hp_filled = (hp_ratio * 28.0).round() as i32; + let bar_fg = if hp_ratio > 0.6 { + [80, 240, 80] + } else if hp_ratio > 0.3 { + [240, 220, 60] + } else { + [255, 60, 60] + }; + for i in 0..28 { + let ch = if i < hp_filled { '█' } else { '░' }; + let c = if i < hp_filled { bar_fg } else { [70, 70, 90] }; + self.set(10 + i, y_row1 + 4, ch, c, bg); + } + let hp_text = format!("{} / {}", p.health as i32, p.max_health as i32); + self.draw_text(40, y_row1, &hp_text, [220, 220, 220], 255); + } + + let brush_color = brush_color(brush); + self.set(0, y_row2 + 4, '■', brush_color, bg); + let brush_text = format!(" {}", brush_name); + self.draw_text(1, y_row2, &brush_text, [200, 200, 140], 255); + + let gear = format!( + "W:[{}] A:[{}] INV:{} FPS:{}", + weapon_name, + armor_name, + player_state.inventory.len(), + fps as i32 + ); + let gear_w = Self::text_width(&gear); + let gear_x = (screen_w as i32 - gear_w).max(0); + self.draw_text(gear_x, y_row2, &gear, [160, 180, 220], 255); + + let stats = format!( + "LV:{} XP:{} K:{} S:{} D:{} T:{}", + if let Some(p) = player { p.level } else { 0 }, + if let Some(p) = player { p.xp } else { 0 }, + kills, + score, + depth, + tick + ); + let stats_w = Self::text_width(&stats); + let stats_x = (screen_w as i32 - stats_w).max(0); + self.draw_text(stats_x, y_row3, &stats, [180, 190, 220], 255); + + if let Some(p) = player { + if p.on_fire { + let msg = "ON FIRE!"; + self.draw_text(40, y_top - 7, msg, [255, 100, 20], 255); + } + } + } + + pub fn draw_messages(&mut self, x: i32, y: i32) { + let mut yy = y; + let messages: Vec<(String, u32)> = self.messages.iter().rev().take(8).cloned().collect(); + for (msg, life) in messages { + let fade = (life as f32 / 300.0).clamp(0.3, 1.0); + let fg = [ + (200.0 * fade) as u8, + (200.0 * fade) as u8, + (200.0 * fade) as u8, + ]; + self.draw_text(x, yy, &msg, fg, 255); + yy -= 6; + } + } + + pub fn draw_death_screen(&mut self, screen_w: usize, screen_h: usize, kills: u32, score: u32) { + let cx = screen_w as i32 / 2; + let cy = screen_h as i32 / 2; + let msg = "YOU DIED"; + let x = cx - (Self::text_width(msg) / 2); + self.draw_text(x, cy - 3, msg, [255, 50, 50], 255); + let stats = format!("KILLS: {} SCORE: {}", kills, score); + let x2 = cx - (Self::text_width(&stats) / 2); + self.draw_text(x2, cy + 3, &stats, [200, 200, 200], 255); + } + + pub fn text_width(text: &str) -> i32 { + text.chars() + .map(|c| if c == '\n' { 0 } else { 3 }) + .sum::() as i32 + } + + pub fn draw_text(&mut self, x: i32, y: i32, text: &str, fg: [u8; 3], alpha: u8) { + let mut cx = x; + for c in text.chars() { + if c == '\n' { + cx = x; + continue; + } + if let Some(bitmap) = char_bitmap(c) { + for row in 0..5 { + for col in 0..3 { + let filled = (bitmap[row] >> (2 - col)) & 1 == 1; + let a = if filled { alpha } else { 0 }; + self.set_alpha(cx + col as i32, y + row as i32, c, fg, [0, 0, 0], a); + } + } + } else { + for row in 0..5 { + for col in 0..3 { + self.set_alpha(cx + col as i32, y + row as i32, c, fg, [0, 0, 0], alpha); + } + } + } + cx += 3; + } + } +} + +fn brush_color(mat: MaterialId) -> [u8; 3] { + let reg = crate::world::material::MaterialRegistry::instance(); + let m = reg.get(mat); + [m.color_fg.0, m.color_fg.1, m.color_fg.2] +} + +fn material_name(mat: MaterialId) -> &'static str { + match mat { + MaterialId::Empty => "Erase", + MaterialId::Sand => "Sand", + MaterialId::Water => "Water", + MaterialId::Stone => "Stone", + MaterialId::Lava => "Lava", + MaterialId::Wood => "Wood", + MaterialId::Flesh => "Flesh", + MaterialId::Bone => "Bone", + MaterialId::Steam => "Steam", + MaterialId::Fire => "Fire", + MaterialId::Acid => "Acid", + MaterialId::Smoke => "Smoke", + MaterialId::Grass => "Grass", + MaterialId::Dirt => "Dirt", + MaterialId::Stairs => "Stairs", + } +} + +pub fn entity_screen_pos(e: &Entity, cam_x: i32, cam_y: i32) -> (i32, i32) { + let (cx, cy) = e.center(); + (cx as i32 - cam_x, cy as i32 - cam_y) +} + +pub fn entity_screen_pos_ui(e: &Entity, cam_x: i32, cam_y: i32) -> (i32, i32) { + let (sx, sy) = entity_screen_pos(e, cam_x, cam_y); + (sx * UI_SCALE, sy * UI_SCALE) +} + +pub fn entity_class_glyph(e: &Entity) -> char { + match e.kind { + EntityKind::Player if e.alive => '@', + EntityKind::Goblin if e.alive => 'g', + EntityKind::Slime if e.alive => 's', + _ => '%', + } +} + +pub fn entity_kind_color(e: &Entity) -> [u8; 3] { + match e.kind { + EntityKind::Player => [200, 180, 255], + EntityKind::Goblin => [160, 240, 120], + EntityKind::Slime => [120, 240, 160], + EntityKind::Corpse => [160, 160, 160], + } +} + +fn char_bitmap(c: char) -> Option<[u8; 5]> { + let u = c.to_ascii_uppercase(); + match u { + '0' => Some([0b111, 0b101, 0b101, 0b101, 0b111]), + '1' => Some([0b010, 0b010, 0b010, 0b010, 0b010]), + '2' => Some([0b111, 0b001, 0b111, 0b100, 0b111]), + '3' => Some([0b111, 0b001, 0b111, 0b001, 0b111]), + '4' => Some([0b101, 0b101, 0b111, 0b001, 0b001]), + '5' => Some([0b111, 0b100, 0b111, 0b001, 0b111]), + '6' => Some([0b111, 0b100, 0b111, 0b101, 0b111]), + '7' => Some([0b111, 0b001, 0b001, 0b001, 0b001]), + '8' => Some([0b111, 0b101, 0b111, 0b101, 0b111]), + '9' => Some([0b111, 0b101, 0b111, 0b001, 0b111]), + 'A' => Some([0b111, 0b101, 0b111, 0b101, 0b101]), + 'B' => Some([0b110, 0b101, 0b110, 0b101, 0b110]), + 'C' => Some([0b111, 0b100, 0b100, 0b100, 0b111]), + 'D' => Some([0b110, 0b101, 0b101, 0b101, 0b110]), + 'E' => Some([0b111, 0b100, 0b111, 0b100, 0b111]), + 'F' => Some([0b111, 0b100, 0b111, 0b100, 0b100]), + 'G' => Some([0b111, 0b100, 0b101, 0b101, 0b111]), + 'H' => Some([0b101, 0b101, 0b111, 0b101, 0b101]), + 'I' => Some([0b111, 0b010, 0b010, 0b010, 0b111]), + 'J' => Some([0b001, 0b001, 0b001, 0b101, 0b111]), + 'K' => Some([0b101, 0b101, 0b110, 0b101, 0b101]), + 'L' => Some([0b100, 0b100, 0b100, 0b100, 0b111]), + 'M' => Some([0b101, 0b111, 0b101, 0b101, 0b101]), + 'N' => Some([0b111, 0b101, 0b101, 0b101, 0b101]), + 'O' => Some([0b111, 0b101, 0b101, 0b101, 0b111]), + 'P' => Some([0b111, 0b101, 0b111, 0b100, 0b100]), + 'Q' => Some([0b111, 0b101, 0b101, 0b111, 0b001]), + 'R' => Some([0b111, 0b101, 0b110, 0b101, 0b101]), + 'S' => Some([0b111, 0b100, 0b111, 0b001, 0b111]), + 'T' => Some([0b111, 0b010, 0b010, 0b010, 0b010]), + 'U' => Some([0b101, 0b101, 0b101, 0b101, 0b111]), + 'V' => Some([0b101, 0b101, 0b101, 0b101, 0b010]), + 'W' => Some([0b101, 0b101, 0b101, 0b111, 0b101]), + 'X' => Some([0b101, 0b101, 0b010, 0b101, 0b101]), + 'Y' => Some([0b101, 0b101, 0b010, 0b010, 0b010]), + 'Z' => Some([0b111, 0b001, 0b010, 0b100, 0b111]), + ' ' => Some([0b000, 0b000, 0b000, 0b000, 0b000]), + ':' => Some([0b000, 0b010, 0b000, 0b010, 0b000]), + '/' => Some([0b001, 0b001, 0b010, 0b100, 0b100]), + '(' => Some([0b001, 0b010, 0b010, 0b010, 0b001]), + ')' => Some([0b100, 0b010, 0b010, 0b010, 0b100]), + '[' => Some([0b011, 0b010, 0b010, 0b010, 0b011]), + ']' => Some([0b110, 0b010, 0b010, 0b010, 0b110]), + '-' => Some([0b000, 0b000, 0b111, 0b000, 0b000]), + '?' => Some([0b111, 0b001, 0b011, 0b000, 0b010]), + '!' => Some([0b010, 0b010, 0b010, 0b000, 0b010]), + '.' => Some([0b000, 0b000, 0b000, 0b000, 0b010]), + _ => None, + } +} diff --git a/src/world/cell.rs b/src/world/cell.rs index 7ec5b50..2fb83a4 100644 --- a/src/world/cell.rs +++ b/src/world/cell.rs @@ -17,6 +17,7 @@ pub enum MaterialId { Smoke = 11, Grass = 12, Dirt = 13, + Stairs = 14, } impl MaterialId { @@ -36,6 +37,7 @@ impl MaterialId { MaterialId::Smoke => '*', MaterialId::Grass => '"', MaterialId::Dirt => ':', + MaterialId::Stairs => '>', } } } @@ -114,6 +116,65 @@ impl Cell { pub fn display_char(self) -> char { self.material.display_char() } + + pub fn to_bytes(&self) -> [u8; 12] { + let mut out = [0u8; 12]; + out[0] = self.material as u8; + out[1..5].copy_from_slice(&self.temp.to_le_bytes()); + out[5] = self.variant; + out[6..9].copy_from_slice(&self.fg); + out[9..12].copy_from_slice(&self.bg); + out + } + + pub fn from_bytes(bytes: &[u8]) -> Self { + let material = if bytes.is_empty() { + MaterialId::Empty + } else { + match bytes[0] { + 0 => MaterialId::Empty, + 1 => MaterialId::Sand, + 2 => MaterialId::Water, + 3 => MaterialId::Stone, + 4 => MaterialId::Lava, + 5 => MaterialId::Wood, + 6 => MaterialId::Flesh, + 7 => MaterialId::Bone, + 8 => MaterialId::Steam, + 9 => MaterialId::Fire, + 10 => MaterialId::Acid, + 11 => MaterialId::Smoke, + 12 => MaterialId::Grass, + 13 => MaterialId::Dirt, + 14 => MaterialId::Stairs, + _ => MaterialId::Stone, + } + }; + let temp = if bytes.len() >= 5 { + f32::from_le_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]) + } else { + 20.0 + }; + let variant = bytes.get(5).copied().unwrap_or(0); + let fg = if bytes.len() >= 9 { + [bytes[6], bytes[7], bytes[8]] + } else { + [15, 15, 20] + }; + let bg = if bytes.len() >= 12 { + [bytes[9], bytes[10], bytes[11]] + } else { + [10, 10, 15] + }; + Self { + material, + temp, + updated_this_tick: false, + variant, + fg, + bg, + } + } } use std::sync::atomic::{AtomicU8, Ordering}; diff --git a/src/world/cellular.rs b/src/world/cellular.rs index 2d6b777..2d0a098 100644 --- a/src/world/cellular.rs +++ b/src/world/cellular.rs @@ -4,6 +4,7 @@ use crate::world::grid::Grid; pub struct CellularAutomaton { tick: u64, rng_state: u64, + temps: Vec, } impl CellularAutomaton { @@ -11,6 +12,7 @@ impl CellularAutomaton { Self { tick: 0, rng_state: 0x1234567890ABCDEF, + temps: Vec::new(), } } @@ -27,37 +29,73 @@ impl CellularAutomaton { self.rand() & 1 == 1 } + #[inline] + fn apply_cell_rule(&mut self, grid: &mut Grid, 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::Flesh => self.update_flesh(grid, x, y), + MaterialId::Grass => self.update_grass(grid, x, y), + MaterialId::Dirt => self.update_dirt(grid, x, y), + _ => {} + } + } + + pub fn random_u32(&mut self) -> u32 { + self.rand() + } + + pub fn random_usize(&mut self, max: usize) -> usize { + if max == 0 { + return 0; + } + (self.rand() as usize) % max + } + pub fn step(&mut self, grid: &mut Grid) { grid.reset_tick_flags(); let flip = self.rand_bool(); - let h = grid.height; - let w = grid.width; + let chunk_w = grid.chunk_size; + let chunks_x = grid.chunks_x; + let chunks_y = grid.chunks_y; + let grid_w = grid.width; - for y_idx in (0..h).rev() { - let y = y_idx as i32; - let xs: Vec = if flip { - (0..w as i32).collect() - } else { - (0..w as i32).rev().collect() - }; - - for x in xs { - let cell = grid.get(x, y); - if cell.updated_this_tick || cell.is_empty() || cell.is_static() { - continue; - } - 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::Flesh => self.update_flesh(grid, x, y), - MaterialId::Grass => self.update_grass(grid, x, y), - MaterialId::Dirt => self.update_dirt(grid, x, y), - _ => {} + 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; + 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); + } + } + } 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); + } + } } } } @@ -222,7 +260,9 @@ impl CellularAutomaton { let i_l = grid.idx(x, y); grid.cells[i_l] = new_lava; } - MaterialId::Wood | MaterialId::Grass | MaterialId::Flesh if neighbor.temp < 300.0 => { + 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); } @@ -302,10 +342,10 @@ impl CellularAutomaton { let mat = reg.get(neighbor.material); if mat.flammable && neighbor.temp < mat.ignition_temp { 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; + new_n.material = MaterialId::Fire; + new_n.temp = 400.0; + let i_n = grid.idx(nx, ny); + grid.cells[i_n] = new_n; } } } @@ -406,45 +446,71 @@ impl CellularAutomaton { fn heat_transfer(&mut self, grid: &mut Grid) { let w = grid.width; - let h = grid.height; - let temps: Vec = grid.cells.iter().map(|c| c.temp).collect(); + let size = w * grid.height; + self.temps.resize(size, 0.0); + for i in 0..size { + self.temps[i] = grid.cells[i].temp; + } - for y in 0..h { - for x in 0..w { - let i = y * w + x; - let cell = grid.cells[i]; - if cell.is_empty() || cell.is_static() { + 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 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 >= h as i32 { + } + 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) { continue; } - let ni = ny as usize * w + nx as usize; - sum += 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 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; + } + } } } } } - } const NEIGHBORS4: [(i32, i32); 4] = [(0, -1), (0, 1), (-1, 0), (1, 0)]; diff --git a/src/world/chunk.rs b/src/world/chunk.rs new file mode 100644 index 0000000..d44f6e8 --- /dev/null +++ b/src/world/chunk.rs @@ -0,0 +1,76 @@ +use crate::world::cell::{Cell, MaterialId}; + +pub const CHUNK_SIZE: usize = 64; + +pub struct Chunk { + pub cells: Vec, + pub active: bool, + pub modified: bool, +} + +impl Chunk { + pub fn new() -> Self { + let size = CHUNK_SIZE * CHUNK_SIZE; + Self { + cells: vec![Cell::empty(); size], + active: false, + modified: false, + } + } + + #[inline] + pub fn in_bounds(x: i32, y: i32) -> bool { + x >= 0 && x < CHUNK_SIZE as i32 && y >= 0 && y < CHUNK_SIZE as i32 + } + + #[inline] + fn idx(x: i32, y: i32) -> usize { + (y as usize) * CHUNK_SIZE + (x as usize) + } + + pub fn get(&self, x: i32, y: i32) -> Cell { + if !Self::in_bounds(x, y) { + return Cell::new(MaterialId::Stone); + } + self.cells[Self::idx(x, y)] + } + + pub fn set(&mut self, x: i32, y: i32, cell: Cell) { + if Self::in_bounds(x, y) { + self.cells[Self::idx(x, y)] = cell; + self.modified = true; + } + } + + pub fn set_material(&mut self, x: i32, y: i32, mat: MaterialId) { + if Self::in_bounds(x, y) { + self.cells[Self::idx(x, y)] = Cell::new(mat); + self.modified = true; + } + } + + pub fn reset_tick_flags(&mut self) { + for c in &mut self.cells { + c.updated_this_tick = false; + } + } +} + +pub fn world_to_chunk(world_x: i32, world_y: i32) -> (i32, i32, i32, i32) { + let cx = world_x.div_euclid(CHUNK_SIZE as i32); + let cy = world_y.div_euclid(CHUNK_SIZE as i32); + let lx = world_x.rem_euclid(CHUNK_SIZE as i32); + let ly = world_y.rem_euclid(CHUNK_SIZE as i32); + (cx, cy, lx, ly) +} + +#[derive(Clone)] +pub struct ChunkCell { + pub x: i32, + pub y: i32, + pub cell: Cell, +} + +pub fn chunk_cells() -> impl Iterator { + (0..CHUNK_SIZE as i32).flat_map(|y| (0..CHUNK_SIZE as i32).map(move |x| (x, y))) +} diff --git a/src/world/grid.rs b/src/world/grid.rs index 62b3845..b52ed60 100644 --- a/src/world/grid.rs +++ b/src/world/grid.rs @@ -1,21 +1,49 @@ use crate::world::cell::{Cell, MaterialId}; +use crate::world::chunk::{world_to_chunk, CHUNK_SIZE}; +use std::io; +use std::path::Path; pub const WORLD_W: usize = 250; pub const WORLD_H: usize = 250; +pub struct ChunkMeta { + pub active: bool, + pub modified: bool, + pub was_modified: bool, +} + pub struct Grid { pub cells: Vec, pub width: usize, pub height: usize, + pub chunk_size: usize, + pub chunks_x: usize, + pub chunks_y: usize, + pub chunks: Vec, } impl Grid { pub fn new() -> Self { let size = WORLD_W * WORLD_H; + 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 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, + }); + } Self { cells: vec![Cell::empty(); size], width: WORLD_W, height: WORLD_H, + chunk_size, + chunks_x, + chunks_y, + chunks, } } @@ -29,6 +57,116 @@ impl Grid { x >= 0 && x < self.width as i32 && y >= 0 && y < self.height as i32 } + #[inline] + pub fn chunk_index(&self, cx: i32, cy: i32) -> usize { + (cy as usize) * self.chunks_x + (cx as usize) + } + + #[inline] + pub fn chunk_at(&self, x: i32, y: i32) -> (i32, i32, i32, i32) { + world_to_chunk(x, y) + } + + #[inline] + pub fn is_chunk_active(&self, cx: i32, cy: i32) -> bool { + if cx < 0 || cy < 0 || cx >= self.chunks_x as i32 || cy >= self.chunks_y as i32 { + return false; + } + self.chunks[self.chunk_index(cx, cy)].active + } + + pub fn set_chunk_active(&mut self, cx: i32, cy: i32, active: bool) { + if cx < 0 || cy < 0 || cx >= self.chunks_x as i32 || cy >= self.chunks_y as i32 { + return; + } + let idx = self.chunk_index(cx, cy); + self.chunks[idx].active = active; + } + + 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) { + for c in &mut self.chunks { + c.active = false; + } + } + + pub fn swap_modified_flags(&mut self) { + for c in &mut self.chunks { + c.was_modified = c.modified; + c.modified = false; + } + } + + pub fn any_modified(&self) -> bool { + self.chunks.iter().any(|c| c.modified) + } + + pub fn any_was_modified(&self) -> bool { + self.chunks.iter().any(|c| c.was_modified) + } + + pub fn active_chunks(&self) -> Vec<(usize, usize)> { + let mut out = Vec::new(); + for cy in 0..self.chunks_y { + for cx in 0..self.chunks_x { + if self.chunks[cy * self.chunks_x + cx].active { + out.push((cx, cy)); + } + } + } + out + } + + pub fn chunk_bounds(&self, cx: usize, cy: usize) -> (i32, i32, i32, i32) { + let x0 = (cx * self.chunk_size) as i32; + let y0 = (cy * self.chunk_size) as i32; + let x1 = (x0 + self.chunk_size as i32).min(self.width as i32); + let y1 = (y0 + self.chunk_size as i32).min(self.height as i32); + (x0, y0, x1, y1) + } + + #[inline] + 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: usize, cy: usize) -> 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: usize, cy: usize, cells: &[Cell]) { + let (x0, y0, x1, y1) = self.chunk_bounds(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) { + self.set(x, y, *cell); + } + } + } + self.set_chunk_active(cx as i32, cy as i32, true); + } + #[inline] pub fn get(&self, x: i32, y: i32) -> Cell { if !self.in_bounds(x, y) { @@ -42,6 +180,11 @@ impl Grid { if self.in_bounds(x, y) { let i = (y as usize) * self.width + (x as usize); self.cells[i] = cell; + let (cx, cy, _, _) = self.chunk_at(x, y); + let idx = self.chunk_index(cx, cy); + if let Some(c) = self.chunks.get_mut(idx) { + c.modified = true; + } } } @@ -50,6 +193,11 @@ impl Grid { if self.in_bounds(x, y) { let i = (y as usize) * self.width + (x as usize); self.cells[i] = Cell::new(mat); + let (cx, cy, _, _) = self.chunk_at(x, y); + let idx = self.chunk_index(cx, cy); + if let Some(c) = self.chunks.get_mut(idx) { + c.modified = true; + } } } @@ -69,4 +217,48 @@ impl Grid { c.updated_this_tick = false; } } + + pub fn save_chunk(&self, path: &str, cx: i32, cy: i32) -> io::Result<()> { + if cx < 0 || cy < 0 || cx >= self.chunks_x as i32 || cy >= self.chunks_y as i32 { + return Err(io::Error::other("chunk out of bounds")); + } + let (x0, y0, x1, y1) = self.chunk_bounds(cx as usize, cy as usize); + let w = (x1 - x0) as usize; + let h = (y1 - y0) as usize; + let mut bytes = Vec::with_capacity(w * h * 12); + for y in y0..y1 { + for x in x0..x1 { + bytes.extend_from_slice(&self.get(x, y).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<()> { + if cx < 0 || cy < 0 || cx >= self.chunks_x as i32 || cy >= self.chunks_y as i32 { + return Err(io::Error::other("chunk out of bounds")); + } + let data = std::fs::read(path)?; + let (x0, y0, x1, y1) = self.chunk_bounds(cx as usize, cy as usize); + let w = (x1 - x0) as usize; + let h = (y1 - y0) as usize; + let expected = w * h * 12; + if data.len() != expected { + return Err(io::Error::other("chunk file size mismatch")); + } + let mut i = 0usize; + for y in y0..y1 { + for x in x0..x1 { + let cell = Cell::from_bytes(&data[i * 12..(i + 1) * 12]); + self.set(x, y, cell); + i += 1; + } + } + self.set_chunk_active(cx, cy, true); + Ok(()) + } } diff --git a/src/world/material.rs b/src/world/material.rs index 6b0f2a1..bad564e 100644 --- a/src/world/material.rs +++ b/src/world/material.rs @@ -18,11 +18,10 @@ pub struct Material { pub display_char: char, } -impl Material { -} +impl Material {} pub struct MaterialRegistry { - materials: [Material; 14], + materials: [Material; 15], } impl MaterialRegistry { @@ -253,6 +252,22 @@ impl MaterialRegistry { color_bg: (40, 30, 20), display_char: ':', }, + Material { + id: MaterialId::Stairs, + name: "stairs", + density: 0.0, + solid: true, + liquid: false, + gas: false, + static_: true, + flammable: false, + ignition_temp: f32::INFINITY, + melt_temp: f32::INFINITY, + heat_conductivity: 0.0, + color_fg: (255, 220, 80), + color_bg: (60, 50, 20), + display_char: '>', + }, ], } } diff --git a/src/world/mod.rs b/src/world/mod.rs index 5623c79..e2f2ea9 100644 --- a/src/world/mod.rs +++ b/src/world/mod.rs @@ -1,4 +1,5 @@ pub mod cell; -pub mod material; -pub mod grid; pub mod cellular; +pub mod chunk; +pub mod grid; +pub mod material; diff --git a/tests/chunks.rs b/tests/chunks.rs new file mode 100644 index 0000000..de386d0 --- /dev/null +++ b/tests/chunks.rs @@ -0,0 +1,90 @@ +use verbatim::world::cell::{Cell, MaterialId}; +use verbatim::world::chunk::{world_to_chunk, Chunk, CHUNK_SIZE}; +use verbatim::world::grid::Grid; + +#[test] +fn grid_has_expected_chunks() { + let g = Grid::new(); + assert_eq!(g.chunk_size, CHUNK_SIZE); + assert_eq!(g.chunks_x, 4); + assert_eq!(g.chunks_y, 4); + assert_eq!(g.chunks.len(), 16); + assert!(g.chunks.iter().all(|c| c.active)); +} + +#[test] +fn world_to_chunk_mapping() { + assert_eq!(world_to_chunk(0, 0), (0, 0, 0, 0)); + assert_eq!(world_to_chunk(63, 63), (0, 0, 63, 63)); + assert_eq!(world_to_chunk(64, 64), (1, 1, 0, 0)); + assert_eq!(world_to_chunk(100, 118), (1, 1, 36, 54)); +} + +#[test] +fn chunk_local_get_set() { + let mut c = Chunk::new(); + assert!(c.get(0, 0).is_empty()); + c.set(0, 0, Cell::new(MaterialId::Sand)); + assert_eq!(c.get(0, 0).material, MaterialId::Sand); + assert!(c.modified); +} + +#[test] +fn grid_get_set_marks_chunk_modified() { + let mut g = Grid::new(); + g.set(10, 10, Cell::new(MaterialId::Water)); + assert!(g.chunks[g.chunk_index(0, 0)].modified); + assert!(!g.chunks[g.chunk_index(1, 1)].modified); + g.set(70, 70, Cell::new(MaterialId::Lava)); + assert!(g.chunks[g.chunk_index(1, 1)].modified); +} + +#[test] +fn cell_serialization_roundtrip() { + let c = Cell::new(MaterialId::Wood); + let bytes = c.to_bytes(); + let c2 = Cell::from_bytes(&bytes); + assert_eq!(c.material, c2.material); + assert_eq!(c.fg, c2.fg); + assert_eq!(c.bg, c2.bg); + assert_eq!(c.variant, c2.variant); + assert!((c.temp - c2.temp).abs() < 0.001); +} + +#[test] +fn save_and_load_chunk_roundtrip() { + let mut g = Grid::new(); + g.set(70, 70, Cell::new(MaterialId::Sand)); + g.set(71, 70, Cell::new(MaterialId::Water)); + let path = "/tmp/verbatim_chunk_test_1_1.bin"; + let _ = std::fs::remove_file(path); + g.save_chunk(path, 1, 1).unwrap(); + let mut g2 = Grid::new(); + g2.load_chunk(path, 1, 1).unwrap(); + assert_eq!(g2.get(70, 70).material, MaterialId::Sand); + assert_eq!(g2.get(71, 70).material, MaterialId::Water); + assert!(g2.chunks[g2.chunk_index(1, 1)].active); + let _ = std::fs::remove_file(path); +} + +#[test] +fn inactive_chunk_cells_are_skipped() { + let mut g = Grid::new(); + g.set(10, 10, Cell::new(MaterialId::Sand)); + g.set(10, 11, Cell::new(MaterialId::Empty)); + g.deactivate_all(); + assert!(!g.cell_active(10, 10)); + assert!(!g.cell_active(10, 11)); + g.set_chunk_active(0, 0, true); + assert!(g.cell_active(10, 10)); +} + +#[test] +fn chunk_bounds_clip_to_world() { + let g = Grid::new(); + let (x0, y0, x1, y1) = g.chunk_bounds(3, 3); + assert_eq!(x0, 192); + assert_eq!(y0, 192); + assert_eq!(x1, 250); + assert_eq!(y1, 250); +} diff --git a/tests/descent.rs b/tests/descent.rs new file mode 100644 index 0000000..76900e8 --- /dev/null +++ b/tests/descent.rs @@ -0,0 +1,152 @@ +use verbatim::ai::AiAction; +use verbatim::ai::GameSession; + +fn setup() -> GameSession { + let mut s = GameSession::new_seeded(42); + s.init(); + s +} + +#[test] +fn descend_requires_stairs() { + let mut s = setup(); + let start_depth = s.game.depth; + s.perform_action(&AiAction::Descend); + assert_eq!( + s.game.depth, start_depth, + "descend should not work without stairs" + ); +} + +#[test] +fn descend_increases_depth_on_stairs() { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(90, 90, 50, 50); + let player = s.game.player.center(&s.game.entities); + let foot_x = player.0 as i32; + let foot_y = (player.1 + 3.0) as i32; + s.perform_action(&AiAction::SetCell { + x: foot_x, + y: foot_y, + material: "stairs".into(), + }); + s.perform_action(&AiAction::SetCell { + x: foot_x, + y: foot_y + 1, + material: "stone".into(), + }); + s.step(5); + let before = s.game.depth; + s.perform_action(&AiAction::Descend); + assert_eq!( + s.game.depth, + before + 1, + "descend should increase depth when on stairs" + ); +} + +#[test] +fn depth_shown_in_hud() { + let s = setup(); + let player = s.game.entities.all()[0].clone(); + let mut ui = verbatim::ui::UiLayer::new(); + ui.draw_hud( + 80, + 25, + Some(&player), + s.game.tick, + verbatim::world::cell::MaterialId::Sand, + 0, + 0, + s.game.depth, + &s.game.player, + 60.0, + ); + let stats = format!( + "LV:{} XP:{} K:{} S:{} D:{} T:{}", + player.level, player.xp, 0, 0, s.game.depth, s.game.tick + ); + let stats_w = verbatim::ui::UiLayer::text_width(&stats); + let stats_x = (80 - stats_w).max(0); + let line: String = (stats_x..80) + .step_by(3) + .map(|x| ui.get(x, 24).map(|c| c.ch).unwrap_or(' ')) + .collect(); + assert!(line.contains("D:1"), "HUD should show depth: {}", line); +} + +#[test] +fn stairs_material_exists() { + let mut s = setup(); + let cell = s.get_cell(0, 0); + assert_ne!(cell.material, "stairs", "empty corner should not be stairs"); + s.perform_action(&AiAction::SetCell { + x: 50, + y: 50, + material: "stairs".into(), + }); + let cell = s.get_cell(50, 50); + assert_eq!( + cell.material, "stairs", + "stairs material should be placeable" + ); +} + +#[test] +fn cell_stairs_bytes_roundtrip() { + let cell = verbatim::world::cell::Cell::new(verbatim::world::cell::MaterialId::Stairs); + let bytes = cell.to_bytes(); + let cell2 = verbatim::world::cell::Cell::from_bytes(&bytes); + assert_eq!(cell2.material, verbatim::world::cell::MaterialId::Stairs); +} + +#[test] +fn material_name_stairs() { + let mut s = setup(); + let cell = s.get_cell(50, 50); + assert_eq!(cell.material, "empty"); + s.perform_action(&AiAction::SetCell { + x: 50, + y: 50, + material: "stairs".into(), + }); + let cell = s.get_cell(50, 50); + assert_eq!(cell.material, "stairs"); +} + +#[test] +fn descend_resets_world() { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(90, 90, 50, 50); + let player = s.game.player.center(&s.game.entities); + let foot_x = player.0 as i32; + let foot_y = (player.1 + 3.0) as i32; + s.perform_action(&AiAction::SetCell { + x: foot_x, + y: foot_y, + material: "stairs".into(), + }); + s.perform_action(&AiAction::SetCell { + x: foot_x, + y: foot_y + 1, + material: "stone".into(), + }); + s.step(5); + let before = s.game.depth; + s.perform_action(&AiAction::Descend); + assert_eq!(s.game.depth, before + 1); + assert!( + s.game.player.entity(&s.game.entities).is_some(), + "player should respawn" + ); + assert!( + s.game + .entities + .all() + .iter() + .all(|e| e.alive || e.kind == verbatim::entity::EntityKind::Corpse), + "old corpses should be gone" + ); +} diff --git a/tests/edge_cases.rs b/tests/edge_cases.rs index aa27c73..87e4b95 100644 --- a/tests/edge_cases.rs +++ b/tests/edge_cases.rs @@ -109,7 +109,10 @@ fn player_at_spawn_is_alive() { s.init(); let p = s.get_player().unwrap(); assert!(p.alive, "player should be alive at spawn"); - assert_eq!(p.health, 100.0, "player should have full health at spawn"); + assert_eq!( + p.health, p.max_health, + "player should have full health at spawn" + ); } #[test] @@ -134,7 +137,7 @@ fn goblin_has_correct_max_health() { }); let entities = s.get_entities(); let g = entities.into_iter().find(|e| e.kind == "Goblin").unwrap(); - assert_eq!(g.max_health, 40.0, "goblin max health should be 40"); + assert_eq!(g.max_health, 80.0, "goblin max health should be 80"); } #[test] @@ -142,7 +145,7 @@ fn player_has_correct_max_health() { let mut s = GameSession::new_seeded(42); s.init(); let p = s.get_player().unwrap(); - assert_eq!(p.max_health, 100.0, "player max health should be 100"); + assert_eq!(p.max_health, 150.0, "player max health should be 150"); } #[test] diff --git a/tests/inventory.rs b/tests/inventory.rs new file mode 100644 index 0000000..f41bedd --- /dev/null +++ b/tests/inventory.rs @@ -0,0 +1,102 @@ +use verbatim::ai::AiAction; +use verbatim::ai::GameSession; +use verbatim::entity::{ItemManager, ItemType}; + +fn setup() -> GameSession { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(90, 90, 50, 50); + s +} + +#[test] +fn item_picked_up_when_near_player() { + let mut s = setup(); + let pos = s.get_player().unwrap().pos; + let x = pos[0] as i32; + let y = pos[1] as i32; + s.game.items.spawn(ItemType::Sword, x, y); + assert_eq!(s.game.player.inventory.len(), 0); + s.step(1); + assert_eq!(s.game.player.inventory.len(), 1); + assert_eq!(s.game.player.inventory[0].typ, ItemType::Sword); +} + +#[test] +fn equipping_weapon_adds_damage_bonus() { + let mut s = setup(); + let pos = s.get_player().unwrap().pos; + s.game + .items + .spawn(ItemType::Sword, pos[0] as i32, pos[1] as i32); + s.step(1); + s.game.use_item(0); + assert_eq!(s.game.player.weapon.as_ref().unwrap().typ, ItemType::Sword); + let bonus = s.game.player.weapon.as_ref().unwrap().damage_bonus(); + assert!(bonus > 0.0, "equipped weapon should provide damage bonus"); +} + +#[test] +fn equipping_armor_reduces_contact_damage() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 120.0, + y: 120.0, + }); + let pos = s.get_player().unwrap().pos; + s.game + .items + .spawn(ItemType::PlateArmor, pos[0] as i32, pos[1] as i32); + s.step(1); + s.game.use_item(0); + let armor = s.game.player.armor.as_ref().unwrap().armor_bonus(); + assert!(armor > 0.0, "plate armor should provide armor bonus"); +} + +#[test] +fn consumable_heals_player() { + let mut s = setup(); + let pos = s.get_player().unwrap().pos; + s.game + .items + .spawn(ItemType::HealthPotion, pos[0] as i32, pos[1] as i32); + s.step(1); + let id = s.get_player().unwrap().id; + s.perform_action(&AiAction::DamageEntity { id, amount: 50.0 }); + let health_before = s.get_player().unwrap().health; + s.game.use_item(0); + let health_after = s.get_player().unwrap().health; + assert!(health_after > health_before, "health potion should heal"); +} + +#[test] +fn dropped_item_returns_to_world() { + let mut s = setup(); + let pos = s.get_player().unwrap().pos; + s.game + .items + .spawn(ItemType::Dagger, pos[0] as i32, pos[1] as i32); + s.step(1); + s.game.drop_item(0); + assert_eq!(s.game.player.inventory.len(), 0); + let count = s + .game + .items + .all() + .iter() + .filter(|i| i.typ == ItemType::Dagger) + .count(); + assert_eq!(count, 1, "dropped item should exist in world"); +} + +#[test] +fn item_manager_spawns_and_removes() { + let mut mgr = ItemManager::new(); + let id = mgr.spawn(ItemType::Food, 100, 100); + assert_eq!(id, 0); + assert_eq!(mgr.all().len(), 1); + let removed = mgr.remove_at(100, 100); + assert!(removed.is_some()); + assert_eq!(mgr.all().len(), 0); +} diff --git a/tests/perf_corpses.rs b/tests/perf_corpses.rs new file mode 100644 index 0000000..c730e19 --- /dev/null +++ b/tests/perf_corpses.rs @@ -0,0 +1,49 @@ +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, 80, 80); + s +} + +#[test] +fn corpses_do_not_cause_lag() { + let mut s = setup(); + for i in 0..20 { + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 100.0 + (i % 5) as f32 * 3.0, + y: 100.0 + (i / 5) as f32 * 3.0, + }); + } + s.step(20); + let ids: Vec = s + .get_entities() + .into_iter() + .filter(|e| e.kind == "Goblin") + .map(|e| e.id) + .collect(); + + let start_before = std::time::Instant::now(); + s.step(60); + let before_ms = start_before.elapsed().as_secs_f32() * 1000.0 / 60.0; + + for id in &ids { + s.perform_action(&AiAction::DamageEntity { + id: *id, + amount: 100.0, + }); + } + let start_after = std::time::Instant::now(); + s.step(60); + let after_ms = start_after.elapsed().as_secs_f32() * 1000.0 / 60.0; + + assert!( + after_ms < before_ms * 3.0, + "corpse simulation should not be dramatically slower: before={:.2}ms after={:.2}ms", + before_ms, + after_ms + ); +} diff --git a/tests/physics_materials.rs b/tests/physics_materials.rs index b03ff34..40dbcd0 100644 --- a/tests/physics_materials.rs +++ b/tests/physics_materials.rs @@ -1,5 +1,5 @@ -use verbatim::ai::GameSession; use verbatim::ai::AiAction; +use verbatim::ai::GameSession; fn setup() -> GameSession { let mut s = GameSession::new_seeded(42); @@ -11,28 +11,76 @@ fn setup() -> GameSession { #[test] fn fire_dies_over_time() { 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::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.step(60); - assert_ne!(s.get_cell(105, 119).material, "fire", "fire should die after 60 ticks"); + assert_ne!( + s.get_cell(105, 119).material, + "fire", + "fire should die after 60 ticks" + ); } #[test] fn fire_ignites_wood() { 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: "wood".into() }); - s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "fire".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: "wood".into(), + }); + s.perform_action(&AiAction::SetCell { + x: 105, + y: 119, + material: "fire".into(), + }); s.step(20); - assert_ne!(s.get_cell(104, 119).material, "wood", "wood should be ignited by fire"); + assert_ne!( + s.get_cell(104, 119).material, + "wood", + "wood should be ignited by fire" + ); } #[test] fn fire_ignites_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::FillRect { x: 100, y: 119, w: 8, h: 1, material: "grass".into() }); - s.perform_action(&AiAction::SetCell { x: 100, y: 119, material: "fire".into() }); + s.perform_action(&AiAction::FillRect { + x: 100, + y: 120, + w: 10, + h: 1, + material: "stone".into(), + }); + s.perform_action(&AiAction::FillRect { + x: 100, + y: 119, + w: 8, + h: 1, + material: "grass".into(), + }); + s.perform_action(&AiAction::SetCell { + x: 100, + y: 119, + material: "fire".into(), + }); s.step(30); let grass_left = s.count_material_in_region(99, 118, 10, 3, "grass"); assert_eq!(grass_left, 0, "fire should spread through grass"); @@ -41,21 +89,55 @@ fn fire_ignites_grass() { #[test] fn fire_ignites_flesh() { 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: "flesh".into() }); - s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "fire".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: "flesh".into(), + }); + s.perform_action(&AiAction::SetCell { + x: 105, + y: 119, + material: "fire".into(), + }); s.step(30); - assert_ne!(s.get_cell(104, 119).material, "flesh", "flesh should be ignited by fire"); + assert_ne!( + s.get_cell(104, 119).material, + "flesh", + "flesh should be ignited by fire" + ); } #[test] fn smoke_rises() { let mut s = setup(); - s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() }); - s.perform_action(&AiAction::FillRect { x: 100, y: 110, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { + x: 100, + y: 120, + w: 10, + h: 1, + material: "stone".into(), + }); + s.perform_action(&AiAction::FillRect { + x: 100, + y: 110, + w: 10, + h: 1, + material: "stone".into(), + }); for y in 114..118 { for x in 104..107 { - s.perform_action(&AiAction::SetCell { x, y, material: "smoke".into() }); + s.perform_action(&AiAction::SetCell { + x, + y, + material: "smoke".into(), + }); } } s.step(15); @@ -66,7 +148,11 @@ fn smoke_rises() { #[test] fn smoke_dissipates_over_time() { let mut s = setup(); - s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "smoke".into() }); + s.perform_action(&AiAction::SetCell { + x: 105, + y: 105, + material: "smoke".into(), + }); s.step(120); let smoke_left = s.count_material_in_region(100, 100, 10, 10, "smoke"); assert_eq!(smoke_left, 0, "smoke should dissipate after 120 ticks"); @@ -75,26 +161,78 @@ fn smoke_dissipates_over_time() { #[test] fn steam_condenses_to_water() { let mut s = setup(); - s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 10, h: 1, material: "stone".into() }); - for y in 118..123 { - for x in 103..108 { - s.perform_action(&AiAction::SetCell { x, y, material: "steam".into() }); + // Closed container so steam/water cannot drift into inactive chunks. + for y in 116..125 { + s.perform_action(&AiAction::SetCell { + x: 100, + y, + material: "stone".into(), + }); + s.perform_action(&AiAction::SetCell { + x: 109, + y, + material: "stone".into(), + }); + } + for x in 100..110 { + s.perform_action(&AiAction::SetCell { + x, + y: 116, + material: "stone".into(), + }); + s.perform_action(&AiAction::SetCell { + x, + y: 124, + material: "stone".into(), + }); + } + for y in 117..124 { + for x in 101..109 { + s.perform_action(&AiAction::SetCell { + x, + y, + material: "steam".into(), + }); } } - s.step(150); - let water_or_steam = s.count_material_in_region(100, 118, 10, 8, "water") - + s.count_material_in_region(100, 118, 10, 8, "steam"); - assert!(water_or_steam > 0, "steam should condense to water or remain steam: water+steam={}", water_or_steam); - let water_count = s.count_material_in_region(100, 118, 10, 8, "water"); - assert!(water_count > 0, "some steam should have condensed to water by now: water={}", water_count); + s.step(200); + let water_or_steam = s.count_material_in_region(100, 116, 10, 9, "water") + + s.count_material_in_region(100, 116, 10, 9, "steam"); + assert!( + water_or_steam > 0, + "steam should condense to water or remain steam: water+steam={}", + water_or_steam + ); + let water_count = s.count_material_in_region(100, 116, 10, 9, "water"); + assert!( + water_count > 0, + "some steam should have condensed to water by now: water={}", + water_count + ); } #[test] fn steam_rises() { let mut s = setup(); - s.perform_action(&AiAction::FillRect { x: 100, y: 130, w: 10, h: 1, material: "stone".into() }); - s.perform_action(&AiAction::FillRect { x: 100, y: 100, w: 10, h: 1, material: "stone".into() }); - s.perform_action(&AiAction::SetCell { x: 105, y: 125, material: "steam".into() }); + s.perform_action(&AiAction::FillRect { + x: 100, + y: 130, + w: 10, + h: 1, + material: "stone".into(), + }); + s.perform_action(&AiAction::FillRect { + x: 100, + y: 100, + w: 10, + h: 1, + material: "stone".into(), + }); + s.perform_action(&AiAction::SetCell { + x: 105, + y: 125, + material: "steam".into(), + }); s.step(20); let steam_above = s.count_material_in_region(100, 105, 10, 10, "steam"); assert!(steam_above > 0, "steam should rise upward"); @@ -103,7 +241,11 @@ fn steam_rises() { #[test] fn grass_is_solid() { let mut s = setup(); - s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "grass".into() }); + s.perform_action(&AiAction::SetCell { + x: 105, + y: 110, + material: "grass".into(), + }); let cell = s.get_cell(105, 110); assert!(cell.is_solid, "grass should be solid"); } @@ -111,7 +253,11 @@ fn grass_is_solid() { #[test] fn dirt_is_solid() { let mut s = setup(); - s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "dirt".into() }); + s.perform_action(&AiAction::SetCell { + x: 105, + y: 110, + material: "dirt".into(), + }); let cell = s.get_cell(105, 110); assert!(cell.is_solid, "dirt should be solid"); } @@ -119,39 +265,79 @@ fn dirt_is_solid() { #[test] fn stone_is_static() { let mut s = setup(); - s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "stone".into() }); + s.perform_action(&AiAction::SetCell { + x: 105, + y: 110, + material: "stone".into(), + }); s.step(20); - assert_eq!(s.get_cell(105, 110).material, "stone", "stone should not move"); + assert_eq!( + s.get_cell(105, 110).material, + "stone", + "stone should not move" + ); } #[test] fn wood_is_static() { let mut s = setup(); - s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "wood".into() }); + s.perform_action(&AiAction::SetCell { + x: 105, + y: 110, + material: "wood".into(), + }); s.step(20); - assert_eq!(s.get_cell(105, 110).material, "wood", "wood should not move"); + assert_eq!( + s.get_cell(105, 110).material, + "wood", + "wood should not move" + ); } #[test] fn bone_is_static() { let mut s = setup(); - s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "bone".into() }); + s.perform_action(&AiAction::SetCell { + x: 105, + y: 110, + material: "bone".into(), + }); s.step(20); - assert_eq!(s.get_cell(105, 110).material, "bone", "bone should not move"); + assert_eq!( + s.get_cell(105, 110).material, + "bone", + "bone should not move" + ); } #[test] fn lava_initial_temp_is_high() { let mut s = setup(); - s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "lava".into() }); + s.perform_action(&AiAction::SetCell { + x: 105, + y: 110, + material: "lava".into(), + }); let cell = s.get_cell(105, 110); - assert!(cell.temp > 1000.0, "lava should start very hot, got {}°C", cell.temp); + assert!( + cell.temp > 1000.0, + "lava should start very hot, got {}°C", + cell.temp + ); } #[test] fn water_initial_temp_is_room() { let mut s = setup(); - s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "water".into() }); + s.perform_action(&AiAction::SetCell { + x: 105, + y: 110, + material: "water".into(), + }); let cell = s.get_cell(105, 110); - assert!(cell.temp < 50.0, "water should start at room temp, got {}°C", cell.temp); + assert!( + cell.temp < 50.0, + "water should start at room temp, got {}°C", + cell.temp + ); } diff --git a/tests/player_controls.rs b/tests/player_controls.rs index a61a20f..8126c69 100644 --- a/tests/player_controls.rs +++ b/tests/player_controls.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 } @@ -18,7 +24,12 @@ fn move_left_changes_x_position() { s.perform_action(&AiAction::MoveLeft); s.step(5); let p1 = s.get_player().unwrap(); - assert!(p1.pos[0] < x0, "player should move left: {} -> {}", x0, p1.pos[0]); + assert!( + p1.pos[0] < x0, + "player should move left: {} -> {}", + x0, + p1.pos[0] + ); } #[test] @@ -30,7 +41,12 @@ fn move_right_changes_x_position() { s.perform_action(&AiAction::MoveRight); s.step(5); let p1 = s.get_player().unwrap(); - assert!(p1.pos[0] > x0, "player should move right: {} -> {}", x0, p1.pos[0]); + assert!( + p1.pos[0] > x0, + "player should move right: {} -> {}", + x0, + p1.pos[0] + ); } #[test] @@ -44,7 +60,12 @@ fn move_left_then_right_cancels() { s.perform_action(&AiAction::MoveRight); s.step(5); let p1 = s.get_player().unwrap(); - assert!((p1.pos[0] - x0).abs() < 3.0, "left+right should roughly cancel: {} -> {}", x0, p1.pos[0]); + assert!( + (p1.pos[0] - x0).abs() < 3.0, + "left+right should roughly cancel: {} -> {}", + x0, + p1.pos[0] + ); } #[test] @@ -56,10 +77,20 @@ fn jump_goes_up_then_falls_back() { s.perform_action(&AiAction::Jump); s.step(5); let p1 = s.get_player().unwrap(); - assert!(p1.pos[1] < y0, "player should go up after jump: {} -> {}", y0, p1.pos[1]); + assert!( + p1.pos[1] < y0, + "player should go up after jump: {} -> {}", + y0, + p1.pos[1] + ); s.step(60); let p2 = s.get_player().unwrap(); - assert!((p2.pos[1] - y0).abs() < 5.0, "player should fall back after jump: {} -> {}", y0, p2.pos[1]); + assert!( + (p2.pos[1] - y0).abs() < 5.0, + "player should fall back after jump: {} -> {}", + y0, + p2.pos[1] + ); } #[test] @@ -77,7 +108,12 @@ fn jump_while_airborne_does_nothing() { s2.step(3); let p1 = s1.get_player().unwrap(); let p2 = s2.get_player().unwrap(); - assert!((p1.pos[1] - p2.pos[1]).abs() < 2.0, "double jump should not add height: single={} double={}", p1.pos[1], p2.pos[1]); + assert!( + (p1.pos[1] - p2.pos[1]).abs() < 2.0, + "double jump should not add height: single={} double={}", + p1.pos[1], + p2.pos[1] + ); } #[test] @@ -100,7 +136,12 @@ fn rapid_move_right_accumulates_velocity() { }; let single_dx = single - x0; let multi_dx = p1.pos[0] - x0; - assert!(multi_dx > single_dx, "rapid moves should accumulate: 1x={} 5x={}", single_dx, multi_dx); + assert!( + multi_dx > single_dx, + "rapid moves should accumulate: 1x={} 5x={}", + single_dx, + multi_dx + ); } #[test] @@ -112,8 +153,14 @@ fn wait_does_not_move() { s.perform_action(&AiAction::Wait); s.step(10); let p1 = s.get_player().unwrap(); - assert!((p1.pos[0] - pos0.0).abs() < 1.0 && (p1.pos[1] - pos0.1).abs() < 1.0, - "wait should not move player: ({},{}) -> ({},{})", pos0.0, pos0.1, p1.pos[0], p1.pos[1]); + assert!( + (p1.pos[0] - pos0.0).abs() < 1.0 && (p1.pos[1] - pos0.1).abs() < 1.0, + "wait should not move player: ({},{}) -> ({},{})", + pos0.0, + pos0.1, + p1.pos[0], + p1.pos[1] + ); } #[test] @@ -128,14 +175,22 @@ fn continuous_movement_does_not_fall_through_floor() { } let p1 = s.get_player().unwrap(); assert!(p1.alive, "player should survive extended movement"); - assert!((p1.pos[1] - y0).abs() < 5.0, "player should not fall through floor during movement: y0={} y1={}", y0, p1.pos[1]); + assert!( + (p1.pos[1] - y0).abs() < 5.0, + "player should not fall through floor during movement: y0={} y1={}", + y0, + p1.pos[1] + ); } #[test] fn player_on_ground_check() { let mut s = setup(); s.step(40); - assert!(s.game.check_on_ground(), "player should be on ground after settling"); + assert!( + s.game.check_on_ground(), + "player should be on ground after settling" + ); } #[test] @@ -145,7 +200,10 @@ fn player_not_on_ground_while_jumping() { assert!(s.game.check_on_ground(), "player should start on ground"); s.perform_action(&AiAction::Jump); s.step(3); - assert!(!s.game.check_on_ground(), "player should not be on ground mid-jump"); + assert!( + !s.game.check_on_ground(), + "player should not be on ground mid-jump" + ); } #[test] @@ -153,7 +211,10 @@ fn player_health_stays_full_without_damage() { let mut s = setup(); s.step(60); let p = s.get_player().unwrap(); - assert_eq!(p.health, 100.0, "player should have full health without damage"); + assert_eq!( + p.health, p.max_health, + "player should have full health without damage" + ); } #[test] diff --git a/tests/projectiles.rs b/tests/projectiles.rs new file mode 100644 index 0000000..ddf9a33 --- /dev/null +++ b/tests/projectiles.rs @@ -0,0 +1,188 @@ +use verbatim::ai::{AiAction, GameSession}; + +fn setup() -> GameSession { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(80, 80, 80, 80); + s.perform_action(&AiAction::FillRect { + x: 80, + y: 130, + w: 80, + h: 5, + material: "stone".into(), + }); + s +} + +#[test] +fn projectile_travels_and_deals_damage() { + let mut s = setup(); + let player_pos = s.get_player().unwrap().pos; + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: player_pos[0] + 15.0, + y: player_pos[1], + }); + s.step(10); + let goblin = s + .get_entities() + .into_iter() + .find(|e| e.kind == "Goblin") + .unwrap(); + let hp_before = goblin.health; + s.perform_action(&AiAction::Shoot { + dir_x: 1.0, + dir_y: 0.0, + }); + s.step(10); + let goblin_after = s + .get_entities() + .into_iter() + .find(|e| e.kind == "Goblin") + .unwrap(); + assert!( + goblin_after.health < hp_before, + "goblin should take projectile damage: {} -> {}", + hp_before, + goblin_after.health + ); +} + +#[test] +fn projectile_stops_on_solid_cell() { + let mut s = setup(); + let player_pos = s.get_player().unwrap().pos; + let wall_x = player_pos[0] as i32 + 8; + s.perform_action(&AiAction::FillRect { + x: wall_x, + y: player_pos[1] as i32 - 2, + w: 4, + h: 4, + material: "stone".into(), + }); + s.perform_action(&AiAction::Shoot { + dir_x: 1.0, + dir_y: 0.0, + }); + s.step(10); + let cell = s.get_cell(wall_x, player_pos[1] as i32); + assert_eq!( + cell.material, "stone", + "projectile should not destroy stone wall" + ); + let state = s.get_state(); + let projectile_count = state + .entities + .iter() + .filter(|e| e.kind == "Projectile") + .count(); + assert_eq!( + projectile_count, 0, + "projectile should be destroyed after hitting wall" + ); +} + +#[test] +fn fireball_ignites_wood() { + let mut s = setup(); + let player_pos = s.get_player().unwrap().pos; + s.perform_action(&AiAction::FillRect { + x: player_pos[0] as i32 + 5, + y: player_pos[1] as i32 - 2, + w: 6, + h: 4, + material: "wood".into(), + }); + s.perform_action(&AiAction::ToggleFireball); + s.perform_action(&AiAction::Shoot { + dir_x: 1.0, + dir_y: 0.0, + }); + s.step(15); + let fire_count = s.count_material_in_region( + player_pos[0] as i32 + 4, + player_pos[1] as i32 - 3, + 10, + 8, + "fire", + ); + assert!( + fire_count > 0, + "fireball should ignite wood, got {} fire cells", + fire_count + ); +} + +#[test] +fn corpse_decomposes_into_flesh_cells() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { + x: 100, + y: 130, + w: 40, + h: 5, + material: "stone".into(), + }); + s.perform_action(&AiAction::FillRect { + x: 100, + y: 100, + w: 1, + h: 30, + material: "stone".into(), + }); + s.perform_action(&AiAction::FillRect { + x: 139, + y: 100, + w: 1, + h: 30, + material: "stone".into(), + }); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 110.0, + y: 120.0, + }); + s.step(10); + let goblin = s + .get_entities() + .into_iter() + .find(|e| e.kind == "Goblin") + .unwrap(); + s.perform_action(&AiAction::DamageEntity { + id: goblin.id, + amount: 100.0, + }); + s.step(100); + let corpse = s + .get_entities() + .into_iter() + .find(|e| e.kind == "Corpse") + .unwrap(); + let _pos = corpse.pos; + let after = s.count_material_in_region(100, 120, 40, 20, "flesh"); + assert!( + after > 0, + "corpse should decompose into flesh cells, got {}", + after + ); +} + +#[test] +fn ui_hud_shows_player_hp() { + let mut s = setup(); + let mut ui = verbatim::ui::UiLayer::new(); + let player = s.game.entities.all()[0].clone(); + ui.draw_hud( + 80, + 25, + Some(&player), + s.tick(), + verbatim::world::cell::MaterialId::Sand, + 0, + 0, + 1, + &s.game.player, + 60.0, + ); + assert!(ui.get(0, 8).is_some(), "HUD should draw HP label"); +} diff --git a/tests/ragdoll_death.rs b/tests/ragdoll_death.rs index d08a63b..5ea48fb 100644 --- a/tests/ragdoll_death.rs +++ b/tests/ragdoll_death.rs @@ -1,24 +1,37 @@ -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 } #[test] fn death_transitions_rigid_to_ragdoll() { let mut s = setup(); - s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 140.0, y: 120.0 }); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 140.0, + y: 120.0, + }); s.step(20); let entities = s.get_entities(); let goblin = entities.into_iter().find(|e| e.kind == "Goblin").unwrap(); assert!(goblin.alive, "goblin should be alive initially"); - s.perform_action(&AiAction::DamageEntity { id: goblin.id, amount: 100.0 }); + s.perform_action(&AiAction::DamageEntity { + id: goblin.id, + amount: 100.0, + }); s.step(1); let entities = s.get_entities(); let corpse = entities.into_iter().find(|e| e.id == goblin.id).unwrap(); @@ -29,34 +42,56 @@ fn death_transitions_rigid_to_ragdoll() { #[test] fn ragdoll_falls_after_death() { let mut s = setup(); - s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 140.0, y: 110.0 }); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 140.0, + y: 110.0, + }); s.step(20); let entities = s.get_entities(); let g = entities.into_iter().find(|e| e.kind == "Goblin").unwrap(); let y_before = g.pos[1]; - s.perform_action(&AiAction::DamageEntity { id: g.id, amount: 100.0 }); + s.perform_action(&AiAction::DamageEntity { + id: g.id, + amount: 100.0, + }); s.step(30); let y_after = { let e = s.get_entities().into_iter().find(|e| e.id == g.id).unwrap(); e.pos[1] }; - assert!(y_after > y_before, "corpse should fall: y_before={} y_after={}", y_before, y_after); + assert!( + y_after > y_before, + "corpse should fall: y_before={} y_after={}", + y_before, + y_after + ); } #[test] fn ragdoll_bodies_stay_near_each_other() { let mut s = setup(); - s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 130.0, y: 110.0 }); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 130.0, + y: 110.0, + }); s.step(20); let entities = s.get_entities(); let g = entities.into_iter().find(|e| e.kind == "Goblin").unwrap(); - s.perform_action(&AiAction::DamageEntity { id: g.id, amount: 100.0 }); + s.perform_action(&AiAction::DamageEntity { + id: g.id, + amount: 100.0, + }); s.step(10); let entities = s.get_entities(); let corpse = entities.into_iter().find(|e| e.id == g.id).unwrap(); assert!(!corpse.alive, "corpse should be dead"); - assert!(corpse.body_count > 0, "corpse should have some alive bodies"); + assert!( + corpse.body_count > 0, + "corpse should have some alive bodies" + ); } #[test] @@ -64,7 +99,10 @@ fn player_death_becomes_corpse() { let mut s = setup(); s.step(30); let p = s.get_player().unwrap(); - s.perform_action(&AiAction::DamageEntity { id: p.id, amount: 200.0 }); + s.perform_action(&AiAction::DamageEntity { + id: p.id, + amount: 200.0, + }); s.step(1); let p2 = s.get_player().unwrap(); assert!(!p2.alive, "player should be dead after 200 damage"); @@ -73,33 +111,54 @@ fn player_death_becomes_corpse() { #[test] fn damage_reduces_health_progressively() { let mut s = setup(); - s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 140.0, y: 120.0 }); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 140.0, + y: 120.0, + }); s.step(20); let entities = s.get_entities(); let g = entities.into_iter().find(|e| e.kind == "Goblin").unwrap(); - assert_eq!(g.health, 40.0, "goblin should start at 40 HP"); + assert_eq!(g.health, 80.0, "goblin should start at 80 HP"); - s.perform_action(&AiAction::DamageEntity { id: g.id, amount: 10.0 }); + s.perform_action(&AiAction::DamageEntity { + id: g.id, + amount: 20.0, + }); s.step(1); let entities = s.get_entities(); let g2 = entities.into_iter().find(|e| e.id == g.id).unwrap(); - assert!((g2.health - 30.0).abs() < 0.01, "goblin should have 30 HP after 10 damage, got {}", g2.health); - assert!(g2.alive, "goblin should survive 10 damage"); + assert!( + (g2.health - 60.0).abs() < 0.01, + "goblin should have 60 HP after 20 damage, got {}", + g2.health + ); + assert!(g2.alive, "goblin should survive 20 damage"); } #[test] fn small_damage_does_not_kill() { let mut s = setup(); - s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 140.0, y: 120.0 }); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 140.0, + y: 120.0, + }); s.step(20); let entities = s.get_entities(); let g = entities.into_iter().find(|e| e.kind == "Goblin").unwrap(); - s.perform_action(&AiAction::DamageEntity { id: g.id, amount: 39.0 }); + s.perform_action(&AiAction::DamageEntity { + id: g.id, + amount: 79.0, + }); s.step(1); let entities = s.get_entities(); let g2 = entities.into_iter().find(|e| e.id == g.id).unwrap(); - assert!(g2.alive, "goblin should survive 39 damage (HP=1)"); - s.perform_action(&AiAction::DamageEntity { id: g.id, amount: 1.0 }); + assert!(g2.alive, "goblin should survive 79 damage (HP=1)"); + s.perform_action(&AiAction::DamageEntity { + id: g.id, + amount: 1.0, + }); s.step(1); let entities = s.get_entities(); let g3 = entities.into_iter().find(|e| e.id == g.id).unwrap(); @@ -109,10 +168,22 @@ fn small_damage_does_not_kill() { #[test] fn corpse_exists_in_world() { let mut s = setup(); - s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 140.0, y: 120.0 }); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 140.0, + y: 120.0, + }); s.step(20); - let g_id = s.get_entities().into_iter().find(|e| e.kind == "Goblin").unwrap().id; - s.perform_action(&AiAction::DamageEntity { id: g_id, amount: 100.0 }); + let g_id = s + .get_entities() + .into_iter() + .find(|e| e.kind == "Goblin") + .unwrap() + .id; + s.perform_action(&AiAction::DamageEntity { + id: g_id, + amount: 100.0, + }); s.step(5); let entities = s.get_entities(); let corpse = entities.into_iter().find(|e| e.id == g_id); diff --git a/tests/rpg.rs b/tests/rpg.rs new file mode 100644 index 0000000..f026b4e --- /dev/null +++ b/tests/rpg.rs @@ -0,0 +1,177 @@ +use verbatim::ai::AiAction; +use verbatim::ai::GameSession; +use verbatim::entity::EntityKind; + +fn setup() -> GameSession { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(90, 90, 50, 50); + s +} + +#[test] +fn player_has_stats() { + let mut s = setup(); + let p = s.get_player().unwrap(); + assert!(p.strength > 0, "player should have strength"); + assert!(p.agility > 0, "player should have agility"); + assert!(p.toughness > 0, "player should have toughness"); + assert!(p.willpower > 0, "player should have willpower"); +} + +#[test] +fn goblin_has_stats() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 120.0, + y: 120.0, + }); + let g = s + .get_entities() + .into_iter() + .find(|e| e.kind == "Goblin") + .unwrap(); + assert!(g.strength > 0, "goblin should have strength"); + assert!(g.toughness > 0, "goblin should have toughness"); +} + +#[test] +fn max_health_depends_on_toughness() { + let mut s = setup(); + let p = s.get_player().unwrap(); + let expected = 80.0 + p.toughness as f32 * 5.0 + p.level as f32 * 10.0; + assert!( + (p.max_health - expected).abs() < 0.01, + "max health should be based on toughness and level" + ); +} + +#[test] +fn killing_grants_xp() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { + kind: "goblin".into(), + x: 120.0, + y: 120.0, + }); + s.step(10); + let id = s + .get_entities() + .into_iter() + .find(|e| e.kind == "Goblin") + .unwrap() + .id; + let xp_before = s.get_player().unwrap().xp; + s.perform_action(&AiAction::DamageEntity { id, amount: 100.0 }); + s.step(1); + let xp_after = s.get_player().unwrap().xp; + assert!(xp_after > xp_before, "killing an enemy should grant XP"); +} + +#[test] +fn xp_accumulation_levels_up() { + let mut s = setup(); + let p = s.game.player.entity_mut(&mut s.game.entities).unwrap(); + p.add_xp(100); + assert_eq!(p.level, 2, "100 XP should level up from 1 to 2"); + assert_eq!(p.xp, 0, "XP should be reset after level up"); +} + +#[test] +fn level_up_increases_max_health() { + let mut s = setup(); + let before = s.get_player().unwrap().max_health; + let p = s.game.player.entity_mut(&mut s.game.entities).unwrap(); + p.add_xp(100); + let after = s.get_player().unwrap().max_health; + assert!(after > before, "level up should increase max health"); +} + +#[test] +fn poison_deals_damage_over_time() { + let mut s = setup(); + let id = s.get_player().unwrap().id; + s.perform_action(&AiAction::DamageEntity { id, amount: 10.0 }); + let before = s.get_player().unwrap().health; + if let Some(p) = s.game.player.entity_mut(&mut s.game.entities) { + p.poisoned = true; + } + s.step(10); + let after = s.get_player().unwrap().health; + assert!(after < before, "poison should deal damage over time"); +} + +#[test] +fn bleeding_deals_damage_over_time() { + let mut s = setup(); + let id = s.get_player().unwrap().id; + s.perform_action(&AiAction::DamageEntity { id, amount: 10.0 }); + let before = s.get_player().unwrap().health; + if let Some(p) = s.game.player.entity_mut(&mut s.game.entities) { + p.bleeding = true; + } + s.step(10); + let after = s.get_player().unwrap().health; + assert!(after < before, "bleeding should deal damage over time"); +} + +#[test] +fn frozen_does_not_deal_damage() { + let mut s = setup(); + let id = s.get_player().unwrap().id; + s.perform_action(&AiAction::DamageEntity { id, amount: 10.0 }); + let before = s.get_player().unwrap().health; + if let Some(p) = s.game.player.entity_mut(&mut s.game.entities) { + p.frozen = true; + } + s.step(10); + let after = s.get_player().unwrap().health; + assert!( + (after - before).abs() < 1.0, + "frozen should not deal damage directly" + ); +} + +#[test] +fn status_effects_expire() { + let mut s = setup(); + if let Some(p) = s.game.player.entity_mut(&mut s.game.entities) { + p.poisoned = true; + p.poison_timer = 200; + } + s.step(200); + let p = s.get_player().unwrap(); + assert!(!p.poisoned, "poison should expire after timer"); +} + +#[test] +fn level_up_heals_to_full() { + let mut s = setup(); + let p = s.game.player.entity_mut(&mut s.game.entities).unwrap(); + p.health = 10.0; + p.add_xp(100); + assert_eq!(p.health, p.max_health, "level up should heal to full"); +} + +#[test] +fn entity_info_includes_level() { + let mut s = setup(); + let p = s.get_player().unwrap(); + assert_eq!(p.level, 1, "player should start at level 1"); +} + +#[test] +fn corpse_does_not_gain_xp() { + let mut s = setup(); + let player_id = s.get_player().unwrap().id; + s.perform_action(&AiAction::DamageEntity { + id: player_id, + amount: 999.0, + }); + s.step(1); + let p = s.game.player.entity_mut(&mut s.game.entities).unwrap(); + let xp_before = p.xp; + p.add_xp(100); + assert_eq!(p.xp, xp_before, "dead player should not gain XP"); +} diff --git a/tests/slime.rs b/tests/slime.rs index b2fe2e0..99c22a7 100644 --- a/tests/slime.rs +++ b/tests/slime.rs @@ -28,7 +28,7 @@ fn slime_spawns_correctly() { assert!(slime.is_some(), "slime should exist after spawn"); let sl = slime.unwrap(); assert!(sl.alive, "slime should be alive"); - assert_eq!(sl.max_health, 25.0, "slime max health should be 25"); + assert_eq!(sl.max_health, 65.0, "slime max health should be 65"); } #[test] @@ -46,11 +46,11 @@ fn slime_takes_damage_and_dies() { .find(|e| e.kind == "Slime") .unwrap() .id; - s.perform_action(&AiAction::DamageEntity { id, amount: 25.0 }); + s.perform_action(&AiAction::DamageEntity { id, amount: 65.0 }); s.step(1); let entities = s.get_entities(); let sl = entities.into_iter().find(|e| e.id == id).unwrap(); - assert!(!sl.alive, "slime should die after 25 damage"); + assert!(!sl.alive, "slime should die after 65 damage"); assert_eq!(sl.kind, "Corpse", "dead slime should become corpse"); } diff --git a/tools/render_dump.py b/tools/render_dump.py new file mode 100644 index 0000000..0c1b6e5 --- /dev/null +++ b/tools/render_dump.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Render a headless_dump.txt frame to PNG for visual analysis.""" + +import sys +from PIL import Image, ImageDraw, ImageFont + +TERM_COLORS = { + " ": (15, 15, 20, 10, 10, 15), + "?": (80, 80, 80, 10, 10, 15), + ".": (194, 178, 128, 30, 25, 15), + "~": (80, 140, 200, 20, 30, 40), + "#": (120, 120, 120, 50, 50, 55), + "T": (139, 90, 43, 40, 25, 15), + "%": (200, 80, 80, 60, 20, 20), + "`": (230, 230, 220, 80, 80, 75), + "^": (255, 100, 20, 40, 10, 0), + "*": (80, 80, 80, 30, 30, 30), + '"': (60, 180, 60, 15, 40, 15), + ":": (120, 100, 70, 30, 25, 15), + "@": (200, 180, 255, 20, 20, 30), + "g": (160, 240, 120, 20, 30, 15), + "s": (120, 240, 160, 15, 30, 20), +} + + +def render_frame(text, out_path, font_size=14): + lines = text.splitlines() + if not lines: + return + h = len(lines) + w = max(len(line) for line in lines) + cell_w = font_size + cell_h = int(font_size * 1.2) + img = Image.new("RGB", (w * cell_w, h * cell_h), (10, 10, 15)) + draw = ImageDraw.Draw(img) + try: + font = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", font_size + ) + except Exception: + font = ImageFont.load_default() + for y, line in enumerate(lines): + for x, ch in enumerate(line): + colors = TERM_COLORS.get(ch, (200, 200, 200, 10, 10, 15)) + fg = (colors[0], colors[1], colors[2]) + bg = (colors[3], colors[4], colors[5]) + draw.rectangle( + [x * cell_w, y * cell_h, (x + 1) * cell_w, (y + 1) * cell_h], fill=bg + ) + draw.text((x * cell_w, y * cell_h), ch, fill=fg, font=font) + img.save(out_path) + + +def main(): + dump_path = sys.argv[1] if len(sys.argv) > 1 else "headless_dump.txt" + out_path = sys.argv[2] if len(sys.argv) > 2 else "headless_dump.png" + with open(dump_path, "r", encoding="utf-8") as f: + content = f.read() + sections = content.split("=== ") + if len(sections) > 1: + first = sections[1].split("\n", 1)[1] + frame = first.split("\n\n")[0] + else: + frame = content + render_frame(frame, out_path) + print(f"Rendered {out_path}") + + +if __name__ == "__main__": + main()