Files
Verbatim/AGENTS.md
T
Emil 4c9a074bb0 feat: world scale ×5 — trees, pools, walls, rooms, corridors all 5× larger
- WORLD_SCALE=5 constant in worldgen.rs, adjustable via single constant
- Trees: trunk 30 cells tall, canopy radius 10 (was 6/2)
- Pools: radius 10-30 (was 3-6)
- Walls: height 10-35, width 5 (was 3-7/2)
- Sand dunes: width 20-80 (was 6-16)
- BSP rooms: min 20×20, corridors width 5 (was 9×9/2)
- Surface terrain: amplitude ×5 (±20 vs ±4)
- Dirt layer depth: 40 cells (was 8)
- Spawn offsets ×5 for goblins/slimes
- Dirty rects cleared after world generation to avoid CA processing static terrain
- Chunk activation by dirty rects only (not modified flag)
- Dirty rect size limit 4096 cells to prevent CA spikes
- CA fill_prob for caves reduced 0.45→0.38 for larger open spaces
- All 185 tests + 14 scenarios pass
- Benchmark: ~18 FPS (regression from 85 FPS due to larger world features)
- Performance optimization deferred to separate pass
2026-06-22 08:27:15 +03:00

204 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md
## Build & Run
```sh
cargo build # debug build
cargo run --release -- --mode graphics # Vulkan colored cells (PRIMARY, recommended)
cargo run --release -- --mode ascii # Vulkan ASCII glyphs (debug backend)
cargo run -- --mode terminal # ANSI terminal mode (legacy)
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 --benchmark-biome surface # FPS benchmark (surface)
cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-renderer graphics --benchmark-biome caves # FPS benchmark (caves)
cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-renderer graphics --benchmark-biome dungeon # FPS benchmark (dungeon)
cargo run --release -- --mode tape --headless-ticks 300 --tape-interval 10 --tape-output tape.txt --tape-json tape.json # multi-spectrum recording
```
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.
## Architecture Priorities
- **Graphics mode** (`--mode graphics`) is the PRIMARY renderer — colored cells, 8x8 px, 16:9 window
- **ASCII mode** (`--mode ascii`) is a DEBUG backend — same Vulkan pipeline with glyph atlas
- **Terminal mode** (`--mode terminal`) is LEGACY — kept for headless/test compatibility
- **AI observation** uses multi-spectrum ASCII layers via pipe protocol:
- `materials` — material type per cell
- `temperature` — heat levels encoded as characters
- `light` — light intensity per cell
- `entities` — entity positions and types only
- `density` — material density visualization
- `velocity` — entity movement speed and CA activity
## Tape System
```sh
# Record all spectrums every 10 ticks for 300 ticks
cargo run --release -- --mode tape --headless-ticks 300 --tape-interval 10 \
--tape-output tape.txt --tape-json tape.json
```
Each tape frame contains:
- Tick number, depth, kills, score
- Camera position
- Player HP, position, entity count
- All 6 spectrum layers as ASCII text
Pipe protocol spectrum commands:
- `{"cmd":"get_spectrum","spectrum":"materials","w":80,"h":25}` — single spectrum
- `{"cmd":"get_all_spectrums","w":80,"h":25}` — all spectrums at once
## Tests
```sh
cargo test # all 185 integration tests (171 original + 14 multilayer)
cargo test --test physics_sand # single test file
cargo test --test multilayer # multi-layer world 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 (keyboard fallback)
- **Left-click** — shoot toward mouse cursor (Noita-style aiming)
- **Mouse position** — player faces mouse direction (left/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 offset
- `m` — toggle audio
- `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**: `ChunkedGrid` of `Cell` structs with parallel per-chunk layer arrays. Replaces the old fixed-size `Grid` with dual storage:
- **Bounded mode**: `Vec<Chunk>` for deterministic 250x250 test/AI grids.
- **Infinite mode**: `HashMap<(i64, i64), Chunk>` for continuous 12500x12500 cell (100000x100000 px) Noita-scale worlds.
Main game (`--mode terminal`, `--mode ascii`, `--mode graphics`) uses the infinite mode. Each `Cell` stores `material`, `fg`/`bg` color, `variant` inline (9 bytes, no temp). Temperature, gas, pressure, and light are stored in parallel arrays per chunk. Chunks are 64x64 cells with `active`, `modified`, `was_modified`, `generated`, and `dirty` flags.
**Multi-layer world** (Phase 6): Each `Chunk` has 5 parallel arrays alongside `cells`:
- `temps: Vec<f32>` — temperature per cell (16 KB/chunk)
- `pressure: Vec<u8>` — pressure per cell, 128 = atmospheric (4 KB/chunk)
- `gas_type: Vec<u8>` — gas type: 0=air, 1=smoke, 2=poison, 3=CO2, 4=steam (4 KB/chunk)
- `gas_density: Vec<u8>` — gas concentration 0-255 (4 KB/chunk)
- `light: Vec<[u8;3]>` — world-space RGB light (12 KB/chunk)
Layer access via `grid.get_temp()`/`set_temp()`, `grid.get_gas()`/`set_gas()`, `grid.get_pressure()`/`set_pressure()`, `grid.get_light()`/`set_light()`. All `set_*` methods call `mark_dirty()` (shared dirty rect). `cells_swap` swaps all layers. `set_material` also sets `default_temp()`.
**Simulation steps** per `fixed_update()`: `apply_gas_damage()``ca.step()` (material CA + heat_transfer + gas_step + pressure_step + light_step) → entity updates → combat → status effects. The `ca.step()` saves pre-clear dirty rects and passes them to layer steps so heat/gas/pressure can diffuse beyond CA-active cells.
**Serialization**: Multi-section chunk format with `VWM1` magic header. Sections: cells (8 bytes × 4096), temps (4 × 4096), gas (2 × 4096), pressure (1 × 4096), light (3 × 4096). Old 12-byte format auto-detected and loaded for backward compat.
**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 `ChunkedGrid` + `EntityManager` and an optional `LightGrid` overlay. GPU renderers upload a viewport-relative grid buffer and index it in shaders with `cam_pos`.
**Lighting pass**: `render::lighting::LightGrid` is computed each frame on the CPU. Light sources are emitted by `Lava` and `Fire` cells. Light attenuates with distance and is blocked by solid cells (ray-cast line-of-sight). The ambient light level is configurable per mode; the default ambient is `[100, 100, 120]`. The `Renderer` trait and all renderers accept `Option<&LightGrid>`; `UiLayer` elements are drawn unlit on top.
**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**: `ChunkedGrid` is divided into 64x64 `Chunk`s. Each chunk tracks `active`, `modified`, `was_modified`, `generated`, and `dirty` (an optional bounding rect of cells that need processing). `save_chunk(path, cx, cy)` and `load_chunk(path, cx, cy)` serialize chunk cells via 12-byte binary format. Cell serialization is handled by `Cell::to_bytes()` / `Cell::from_bytes()`. `Chunk::generated` is set when a chunk is generated or loaded from cache, preventing accidental regeneration.
**Dirty rect optimization** (Noita-style): Each chunk maintains a `dirty: Option<(i32, i32, i32, i32)>` bounding rect of cells that need CA processing. When a cell changes via `set`, `set_material`, `cells_swap`, or `set_cell_index`, the chunk's dirty rect is expanded to include that cell ±1 (for neighbor influence). The CA step only iterates cells within dirty rects, skipping chunks with no dirty rect entirely. Liquids and gases (water, lava, acid, steam, fire, smoke) re-mark themselves dirty after processing so they continue to flow and react. Sand and other solids can sleep when at rest. `heat_transfer` only processes cells within dirty rects and reuses its temperature buffer across frames. `update_active_chunks` activates chunks with dirty rects in addition to chunks near entities. This reduces CA step time from ~500μs to ~25μs on a 250x250 grid.
**World cache**: main game seeds are saved in `Game::seed` and written to `cache/worlds/<seed>/`. Each cached world stores per-chunk binary files plus a `meta.json` with player spawn and item placement. `Game::init_world()` loads the cache if it exists; otherwise it generates the spawn region and saves it. This makes Noita-scale worlds load instantly after the first visit.
**Vertical biome progression**: World Y (`cy`) selects biome per chunk:
- `cy < 2` — surface (grass, dirt, stone, trees, pools, dunes)
- `2 <= cy < 6` — caves (stone, CA-carved empty space, lava/water/acid pools)
- `cy >= 6` — dungeon (BSP rooms, corridors, stone walls)
**Chunk streaming**: `Game::stream_chunks()` is called every `fixed_update`. It loads cached chunks (or generates new ones) in a 3-chunk radius around the player, saves modified chunks beyond that radius, and unloads distant chunks. Streaming is active for infinite grids and for bounded grids larger than 2048×2048; test/AI 250×250 grids skip streaming.
**Vertical descent**: `MaterialId::Stairs` is a solid feature material. Player stands on stairs and presses `>` to descend. `Game::descend()` increments depth, resets the world, and respawns the player at the top. HUD shows current depth.
## 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.
- **Random seeding** — `Game::new()` uses a fixed seed for tests/AI sessions; `Game::new_random()` seeds from system time and is used by terminal/ascii/graphics modes. Cached worlds are keyed by seed.
- **Dirty rects** — `ChunkedGrid::mark_dirty(x, y)` expands the chunk's dirty rect to include (x,y) ±1 and propagates to neighbor chunks at boundaries. `cells_swap` and `set_cell_index` call `mark_dirty` automatically. `set` and `set_material` also call `mark_dirty`. The CA step clears each chunk's dirty rect at the start of processing and rebuilds it from cell modifications during processing.
- **World scale**: `WORLD_SCALE = 5` in `worldgen.rs` — all world features (trees, pools, walls, dunes, rooms, corridors, terrain amplitude) are multiplied by this factor. Change this constant to adjust entity-to-world size ratio.
## 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, ChunkedGrid, Grid (legacy), Chunk, CellularAutomaton, WorldGenerator, WorldCache
physics/ # VerletSolver, SubBody (with color field), Constraint, resolve_grid_collision
entity/ # Entity (rigid/ragdoll), EntityManager, Player, BodyTemplate, Item, ItemManager
render/ # terminal.rs, vulkan.rs (ASCII), graphics.rs (cells), lighting.rs, window_input.rs
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.