- 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.
137 lines
10 KiB
Markdown
137 lines
10 KiB
Markdown
# AGENTS.md
|
||
|
||
## Build & Run
|
||
|
||
```sh
|
||
cargo build # debug build
|
||
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). 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 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`. 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
|
||
|
||
GLSL shaders in `assets/shaders/`. Pre-compiled to SPIR-V via `glslangValidator -V`. Recompile after editing:
|
||
|
||
```sh
|
||
glslangValidator -V assets/shaders/cell.vert -o assets/shaders/cell_vert.spv
|
||
glslangValidator -V assets/shaders/cell.frag -o assets/shaders/cell_frag.spv
|
||
glslangValidator -V assets/shaders/graphics.vert -o assets/shaders/graphics_vert.spv
|
||
glslangValidator -V assets/shaders/graphics.frag -o assets/shaders/graphics_frag.spv
|
||
```
|
||
|
||
SPV files are committed. `include_bytes!` embeds them at compile time.
|
||
|
||
## Architecture
|
||
|
||
**Source of truth**: `Grid` (250x250) of `Cell` structs. Each `Cell` stores `material`, `temp`, `fg`/`bg` color, `variant` inline. No double buffer. Grid is divided into 64x64 `Chunk`s with active flags and per-chunk persistence.
|
||
|
||
**Four entity kinds**: `Player`, `Goblin`, `Slime`, `Corpse`. Three physics types: cellular (CA materials in grid), rigid (alive entities, AABB + slope stepping), ragdoll (corpses, Verlet constraints).
|
||
|
||
**Three render modes**: `terminal` (crossterm ANSI), `ascii` (Vulkan glyph atlas + instanced), `graphics` (Vulkan colored quads). All three read the same `Grid` + `EntityManager` and an optional `LightGrid` overlay.
|
||
|
||
**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<R>()`. Both have identical event loops; only shader/instance format differs.
|
||
|
||
**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**: 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 / 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
|
||
|
||
```
|
||
src/
|
||
main.rs # CLI, GpuRenderer trait, run_gpu_mode<R>(), event loops
|
||
lib.rs # pub mod declarations
|
||
game.rs # Game struct, world gen, fixed_update, collision, combat, slime AI
|
||
input.rs # Terminal input (crossterm, InputHandler) — terminal mode only
|
||
world/ # Cell, MaterialId, MaterialRegistry, Grid, Chunk, CellularAutomaton
|
||
physics/ # VerletSolver, SubBody (with color field), Constraint, resolve_grid_collision
|
||
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
|
||
|
||
Detailed roadmap with 8 phases, architecture decisions, milestones, and cross-platform support table. Read it for design context before making architectural changes. Player combat is ranged (projectiles), not melee — this is a design decision recorded in Phase 1.
|