feat: GPU optimization — lighting, viewport CA, benchmark mode, 531 FPS
- 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.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
graphify-out/
|
||||
headless_dump.txt
|
||||
headless_dump.png
|
||||
|
||||
@@ -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<R>()`. 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,9 +1,10 @@
|
||||
#version 450
|
||||
|
||||
layout(location = 0) in vec4 in_color;
|
||||
layout(location = 1) in vec3 in_light;
|
||||
|
||||
layout(location = 0) out vec4 out_color;
|
||||
|
||||
void main() {
|
||||
out_color = in_color;
|
||||
out_color = vec4(in_color.rgb * in_light, in_color.a);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -10,6 +15,59 @@ layout(location = 1) in vec2 in_grid;
|
||||
layout(location = 2) in vec4 in_color;
|
||||
|
||||
layout(location = 0) out vec4 out_color;
|
||||
layout(location = 1) out vec3 out_light;
|
||||
|
||||
layout(std430, binding = 0) readonly buffer GridBuffer {
|
||||
uint cells[];
|
||||
} grid;
|
||||
|
||||
struct LightSrc {
|
||||
vec2 pos;
|
||||
float radius;
|
||||
float pad0;
|
||||
vec3 color;
|
||||
float pad1;
|
||||
};
|
||||
|
||||
layout(std430, binding = 1) 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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
+108
-258
@@ -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))
|
||||
|
||||
|
||||
+52
-3
@@ -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<ProjectileType> {
|
||||
match name.to_lowercase().as_str() {
|
||||
"arrow" => Some(ProjectileType::Arrow),
|
||||
"fireball" | "fire" => Some(ProjectileType::Fireball),
|
||||
"magic" | "bolt" | "magic_bolt" => Some(ProjectileType::MagicBolt),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SubBodyInfo>,
|
||||
}
|
||||
@@ -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<MaterialId> {
|
||||
"smoke" => Some(MaterialId::Smoke),
|
||||
"grass" => Some(MaterialId::Grass),
|
||||
"dirt" => Some(MaterialId::Dirt),
|
||||
"stairs" => Some(MaterialId::Stairs),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+148
-7
@@ -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;
|
||||
|
||||
@@ -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<Item>,
|
||||
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<Item> {
|
||||
&mut self.items
|
||||
}
|
||||
|
||||
pub fn remove_at(&mut self, x: i32, y: i32) -> Option<Item> {
|
||||
if let Some(idx) = self.items.iter().position(|i| i.x == x && i.y == y) {
|
||||
Some(self.items.remove(idx))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -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};
|
||||
|
||||
+16
-2
@@ -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<Item>,
|
||||
pub weapon: Option<Item>,
|
||||
pub armor: Option<Item>,
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
+464
-22
@@ -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<usize> = 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) {
|
||||
|
||||
+157
-34
@@ -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::<Event>();
|
||||
self.receiver = Some(rx);
|
||||
self.input_thread = Some(thread::spawn(move || {
|
||||
loop {
|
||||
match event::read() {
|
||||
Ok(ev) => {
|
||||
if tx.send(ev).is_err() {
|
||||
break;
|
||||
}
|
||||
self.input_thread = Some(thread::spawn(move || loop {
|
||||
match event::read() {
|
||||
Ok(ev) => {
|
||||
if tx.send(ev).is_err() {
|
||||
break;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+7
-6
@@ -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;
|
||||
|
||||
+341
-5
@@ -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<String>,
|
||||
|
||||
#[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<R: GpuRenderer>(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<R: GpuRenderer>(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<R: GpuRenderer>(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<R: GpuRenderer>(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<R: GpuRenderer>(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::<verbatim::render::vulkan::VulkanRenderer>(
|
||||
ticks,
|
||||
output_path,
|
||||
"ascii",
|
||||
),
|
||||
"graphics" => run_benchmark_inner::<verbatim::render::graphics::GraphicsRenderer>(
|
||||
ticks,
|
||||
output_path,
|
||||
"graphics",
|
||||
),
|
||||
_ => {
|
||||
eprintln!(
|
||||
"Unknown benchmark renderer: {}. Use ascii or graphics.",
|
||||
renderer_type
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_benchmark_inner<R: GpuRenderer>(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<u64> = Vec::with_capacity(ticks as usize);
|
||||
let mut render_times_us: Vec<u64> = Vec::with_capacity(ticks as usize);
|
||||
let mut frame_times_us: Vec<u64> = 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::<u64>() / ca_times_us.len() as u64;
|
||||
let render_avg_us =
|
||||
render_times_us.iter().sum::<u64>() / render_times_us.len() as u64;
|
||||
let frame_avg_us =
|
||||
frame_times_us.iter().sum::<u64>() / 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<u64> = 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,
|
||||
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
pub mod verlet;
|
||||
pub mod collision;
|
||||
pub mod projectile;
|
||||
pub mod verlet;
|
||||
|
||||
@@ -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<Projectile>,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
+431
-20
@@ -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<vk::ImageView>,
|
||||
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<u8>,
|
||||
entity_color_buf: Vec<[u8; 4]>,
|
||||
item_color_buf: Vec<[u8; 4]>,
|
||||
shadow_buf: Vec<bool>,
|
||||
|
||||
descriptor_set_layout: vk::DescriptorSetLayout,
|
||||
descriptor_pool: vk::DescriptorPool,
|
||||
descriptor_set: vk::DescriptorSet,
|
||||
|
||||
window: Arc<winit::window::Window>,
|
||||
}
|
||||
|
||||
@@ -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::<PushConstants>() 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::<u32>()) 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::<GpuLightSource>()) 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::<u32>()) as vk::DeviceSize);
|
||||
let light_info = vk::DescriptorBufferInfo::default()
|
||||
.buffer(light_buffer)
|
||||
.offset(0)
|
||||
.range((MAX_LIGHT_SOURCES * std::mem::size_of::<GpuLightSource>()) 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::<ColorInstance>()) 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);
|
||||
}
|
||||
|
||||
@@ -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<LightSource> {
|
||||
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<LightSource> {
|
||||
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<LightSource> {
|
||||
let mut sources = Vec::new();
|
||||
let min_x = (cam_x - margin).max(0);
|
||||
let max_x = (cam_x + view_w as i32 + margin).min(grid.width as i32);
|
||||
let 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::<u32>()
|
||||
> far.iter().map(|&v| v as u32).sum::<u32>()
|
||||
);
|
||||
}
|
||||
|
||||
#[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::<u32>()
|
||||
> shadow_side.iter().map(|&v| v as u32).sum::<u32>()
|
||||
);
|
||||
}
|
||||
}
|
||||
+17
-4
@@ -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;
|
||||
|
||||
+161
-30
@@ -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] {
|
||||
|
||||
+438
-62
@@ -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<vk::ImageView>,
|
||||
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<char, (f32, f32, f32, f32)>,
|
||||
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<u8>,
|
||||
entity_char_buf: Vec<char>,
|
||||
entity_color_buf: Vec<[u8; 4]>,
|
||||
item_char_buf: Vec<char>,
|
||||
item_color_buf: Vec<[u8; 4]>,
|
||||
shadow_buf: Vec<bool>,
|
||||
|
||||
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::<u32>()) 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::<GpuLightSource>()) 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::Image>, vk::Format, vk::Extent2D), String> {
|
||||
) -> Result<
|
||||
(
|
||||
vk::SwapchainKHR,
|
||||
Vec<vk::Image>,
|
||||
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<char, (f32, f32, f32, f32)>,
|
||||
[(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<char> = " !\"#$%&'()*+,-./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::<u32>()) as vk::DeviceSize);
|
||||
let li = vk::DescriptorBufferInfo::default()
|
||||
.buffer(light_buffer)
|
||||
.offset(0)
|
||||
.range((MAX_LIGHT_SOURCES * std::mem::size_of::<GpuLightSource>()) 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, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
+101
-19
@@ -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<u8>,
|
||||
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<KeyCode>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+737
@@ -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<DamageNumber>,
|
||||
}
|
||||
|
||||
#[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<Item = &(i32, i32)> {
|
||||
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<DamageNumber> = 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::<usize>() 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,
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
+128
-62
@@ -4,6 +4,7 @@ use crate::world::grid::Grid;
|
||||
pub struct CellularAutomaton {
|
||||
tick: u64,
|
||||
rng_state: u64,
|
||||
temps: Vec<f32>,
|
||||
}
|
||||
|
||||
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<i32> = 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<f32> = 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)];
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
use crate::world::cell::{Cell, MaterialId};
|
||||
|
||||
pub const CHUNK_SIZE: usize = 64;
|
||||
|
||||
pub struct Chunk {
|
||||
pub cells: Vec<Cell>,
|
||||
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<Item = (i32, i32)> {
|
||||
(0..CHUNK_SIZE as i32).flat_map(|y| (0..CHUNK_SIZE as i32).map(move |x| (x, y)))
|
||||
}
|
||||
@@ -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<Cell>,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub chunk_size: usize,
|
||||
pub chunks_x: usize,
|
||||
pub chunks_y: usize,
|
||||
pub chunks: Vec<ChunkMeta>,
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
+18
-3
@@ -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: '>',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
+6
-3
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<u32> = 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
|
||||
);
|
||||
}
|
||||
+230
-44
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
+76
-15
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+94
-23
@@ -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);
|
||||
|
||||
+177
@@ -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");
|
||||
}
|
||||
+3
-3
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user