Compare commits
10
Commits
93a6bcb35c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d54d987ac | ||
|
|
4a89612081 | ||
|
|
69e3a6578e | ||
|
|
43085772cd | ||
|
|
d6bd77c133 | ||
|
|
f09c44f7a0 | ||
|
|
22de8bc42e | ||
|
|
423000e576 | ||
|
|
5ca8b7e917 | ||
|
|
01a350aebd |
@@ -27,10 +27,12 @@ Rust edition 2024, requires rustc >= 1.96. Vulkan 1.2+ required for `ascii`/`gra
|
||||
- **AI observation** uses multi-spectrum ASCII layers via pipe protocol:
|
||||
- `materials` — material type per cell
|
||||
- `temperature` — heat levels encoded as characters
|
||||
- `light` — light intensity per cell
|
||||
- `light` — light intensity per cell (world-space fallback)
|
||||
- `entities` — entity positions and types only
|
||||
- `density` — material density visualization
|
||||
- `velocity` — entity movement speed and CA activity
|
||||
- `gas` — gas type + density per cell (NEW)
|
||||
- `pressure` — pressure levels per cell (NEW)
|
||||
|
||||
## Tape System
|
||||
|
||||
@@ -44,7 +46,7 @@ Each tape frame contains:
|
||||
- Tick number, depth, kills, score
|
||||
- Camera position
|
||||
- Player HP, position, entity count
|
||||
- All 6 spectrum layers as ASCII text
|
||||
- All 8 spectrum layers as ASCII text
|
||||
|
||||
Pipe protocol spectrum commands:
|
||||
- `{"cmd":"get_spectrum","spectrum":"materials","w":80,"h":25}` — single spectrum
|
||||
@@ -107,7 +109,7 @@ SPV files are committed. `include_bytes!` embeds them at compile time.
|
||||
**Source of truth**: `ChunkedGrid` of `Cell` structs with parallel per-chunk layer arrays. Replaces the old fixed-size `Grid` with dual storage:
|
||||
|
||||
- **Bounded mode**: `Vec<Chunk>` for deterministic 250x250 test/AI grids.
|
||||
- **Infinite mode**: `HashMap<(i64, i64), Chunk>` for continuous 12500x12500 cell (100000x100000 px) Noita-scale worlds.
|
||||
- **Infinite mode**: `HashMap<(i64, i64), Chunk>` for continuous Noita-scale worlds.
|
||||
|
||||
Main game (`--mode terminal`, `--mode ascii`, `--mode graphics`) uses the infinite mode. Each `Cell` stores `material`, `fg`/`bg` color, `variant` inline (9 bytes, no temp). Temperature, gas, pressure, and light are stored in parallel arrays per chunk. Chunks are 64x64 cells with `active`, `modified`, `was_modified`, `generated`, and `dirty` flags.
|
||||
|
||||
@@ -120,10 +122,12 @@ Main game (`--mode terminal`, `--mode ascii`, `--mode graphics`) uses the infini
|
||||
|
||||
Layer access via `grid.get_temp()`/`set_temp()`, `grid.get_gas()`/`set_gas()`, `grid.get_pressure()`/`set_pressure()`, `grid.get_light()`/`set_light()`. All `set_*` methods call `mark_dirty()` (shared dirty rect). `cells_swap` swaps all layers. `set_material` also sets `default_temp()`.
|
||||
|
||||
**Simulation steps** per `fixed_update()`: `apply_gas_damage()` → `ca.step()` (material CA + heat_transfer + gas_step + pressure_step + light_step) → entity updates → combat → status effects. The `ca.step()` saves pre-clear dirty rects and passes them to layer steps so heat/gas/pressure can diffuse beyond CA-active cells.
|
||||
**Simulation steps** per `fixed_update()`: `apply_gas_damage()` → `ca.step()` (material CA + heat_transfer + gas_step + pressure_step + light_step) → entity updates → combat → status effects. The `ca.step()` saves pre-clear dirty rects and passes them to layer steps so heat/gas/pressure can diffuse beyond CA-active cells. All loaded chunks are activated every tick — uniform simulation rate across the world.
|
||||
|
||||
**Serialization**: Multi-section chunk format with `VWM1` magic header. Sections: cells (8 bytes × 4096), temps (4 × 4096), gas (2 × 4096), pressure (1 × 4096), light (3 × 4096). Old 12-byte format auto-detected and loaded for backward compat.
|
||||
|
||||
**Audio** (`src/audio/mod.rs`): `AudioEngine` using `rodio` for WAV playback. 15 procedurally generated sounds (jump, shoot, hit, explosion, death, pickup, descend, powerup, step, lava_bubble, acid_sizzle, water_splash, fire_crackle, ui_click, goblin_growl) embedded via `include_bytes!`. Each sound creates its own `Sink` + `detach()` for overlapping playback. Ambient sounds scan 15-cell radius around player. `M` key toggles audio. Gracefully degrades when no audio device available.
|
||||
|
||||
**Four entity kinds**: `Player`, `Goblin`, `Slime`, `Corpse`. Three physics types: cellular (CA materials in grid), rigid (alive entities, AABB + slope stepping), ragdoll (corpses, Verlet constraints).
|
||||
|
||||
**Three render modes**: `terminal` (crossterm ANSI), `ascii` (Vulkan glyph atlas + instanced), `graphics` (Vulkan colored quads). All three read the same `ChunkedGrid` + `EntityManager` and an optional `LightGrid` overlay. GPU renderers upload a viewport-relative grid buffer and index it in shaders with `cam_pos`.
|
||||
@@ -149,11 +153,11 @@ Layer access via `grid.get_temp()`/`set_temp()`, `grid.get_gas()`/`set_gas()`, `
|
||||
|
||||
**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.
|
||||
**UI layer**: `ui::UiLayer` overlays non-destructive UI on all renderers. Uses a flat `Vec<Option<UiCell>>` array (not HashMap) for O(1) cell access. `font_scale` auto-computed from screen height — text, panels, health bars, and minimap scale proportionally. Health bars above entities, bottom-line HUD, scrolling message log, floating damage numbers, screen-edge indicators, death screen, entity labels, status icons, minimap (sampled with step_by(4)), and a character panel. UI is drawn unlit on top of the world. In GPU (`ascii`/`graphics`) and capture modes the UI is rendered as a separate 2x2 pixel-per-cell pass; terminal mode renders UI at full character size.
|
||||
|
||||
**Chunk system**: `ChunkedGrid` is divided into 64x64 `Chunk`s. Each chunk tracks `active`, `modified`, `was_modified`, `generated`, and `dirty` (an optional bounding rect of cells that need processing). `save_chunk(path, cx, cy)` and `load_chunk(path, cx, cy)` serialize chunk cells via 12-byte binary format. Cell serialization is handled by `Cell::to_bytes()` / `Cell::from_bytes()`. `Chunk::generated` is set when a chunk is generated or loaded from cache, preventing accidental regeneration.
|
||||
**Chunk system**: `ChunkedGrid` is divided into 64x64 `Chunk`s. Each chunk tracks `active`, `modified`, `was_modified`, `generated`, and `dirty` (an optional bounding rect of cells that need processing). `save_chunk(path, cx, cy)` and `load_chunk(path, cx, cy)` serialize chunk data via multi-section `VWM1` binary format. Cell serialization is handled by `Cell::to_bytes()` / `Cell::from_bytes()` (8 bytes/cell). `Chunk::generated` is set when a chunk is generated or loaded from cache, preventing accidental regeneration.
|
||||
|
||||
**Dirty rect optimization** (Noita-style): Each chunk maintains a `dirty: Option<(i32, i32, i32, i32)>` bounding rect of cells that need CA processing. When a cell changes via `set`, `set_material`, `cells_swap`, or `set_cell_index`, the chunk's dirty rect is expanded to include that cell ±1 (for neighbor influence). The CA step only iterates cells within dirty rects, skipping chunks with no dirty rect entirely. Liquids and gases (water, lava, acid, steam, fire, smoke) re-mark themselves dirty after processing so they continue to flow and react. Sand and other solids can sleep when at rest. `heat_transfer` only processes cells within dirty rects and reuses its temperature buffer across frames. `update_active_chunks` activates chunks with dirty rects in addition to chunks near entities. This reduces CA step time from ~500μs to ~25μs on a 250x250 grid.
|
||||
**Dirty rect optimization** (Noita-style): Each chunk maintains a `dirty: Option<(i32, i32, i32, i32)>` bounding rect of cells that need CA processing. When a cell changes via `set`, `set_material`, `cells_swap`, or `set_cell_index`, the chunk's dirty rect is expanded to include that cell ±1 (for neighbor influence). The CA step only iterates cells within dirty rects, skipping chunks with no dirty rect entirely. Liquids and gases (water, lava, acid, steam, fire, smoke) re-mark themselves dirty after processing so they continue to flow and react. Sand and other solids can sleep when at rest. All dirty rects are processed fully — no size limits — ensuring uniform simulation across chunks. `update_active_chunks` activates all loaded chunks every tick for consistent physics.
|
||||
|
||||
**World cache**: main game seeds are saved in `Game::seed` and written to `cache/worlds/<seed>/`. Each cached world stores per-chunk binary files plus a `meta.json` with player spawn and item placement. `Game::init_world()` loads the cache if it exists; otherwise it generates the spawn region and saves it. This makes Noita-scale worlds load instantly after the first visit.
|
||||
|
||||
@@ -162,7 +166,7 @@ Layer access via `grid.get_temp()`/`set_temp()`, `grid.get_gas()`/`set_gas()`, `
|
||||
- `2 <= cy < 6` — caves (stone, CA-carved empty space, lava/water/acid pools)
|
||||
- `cy >= 6` — dungeon (BSP rooms, corridors, stone walls)
|
||||
|
||||
**Chunk streaming**: `Game::stream_chunks()` is called every `fixed_update`. It loads cached chunks (or generates new ones) in a 3-chunk radius around the player, saves modified chunks beyond that radius, and unloads distant chunks. Streaming is active for infinite grids and for bounded grids larger than 2048×2048; test/AI 250×250 grids skip streaming.
|
||||
**Chunk streaming**: `Game::stream_chunks()` is called every `fixed_update`. It loads cached chunks (or generates new ones) in a 2-chunk radius around the player, saves modified chunks beyond that radius every 10 ticks (1 chunk at a time), and unloads distant chunks every 120 ticks. All loaded chunks are activated for uniform simulation. Streaming is active for infinite grids; test/AI 250×250 grids skip streaming.
|
||||
|
||||
**Vertical descent**: `MaterialId::Stairs` is a solid feature material. Player stands on stairs and presses `>` to descend. `Game::descend()` increments depth, resets the world, and respawns the player at the top. HUD shows current depth.
|
||||
|
||||
@@ -181,6 +185,9 @@ Layer access via `grid.get_temp()`/`set_temp()`, `grid.get_gas()`/`set_gas()`, `
|
||||
- **Random seeding** — `Game::new()` uses a fixed seed for tests/AI sessions; `Game::new_random()` seeds from system time and is used by terminal/ascii/graphics modes. Cached worlds are keyed by seed.
|
||||
- **Dirty rects** — `ChunkedGrid::mark_dirty(x, y)` expands the chunk's dirty rect to include (x,y) ±1 and propagates to neighbor chunks at boundaries. `cells_swap` and `set_cell_index` call `mark_dirty` automatically. `set` and `set_material` also call `mark_dirty`. The CA step clears each chunk's dirty rect at the start of processing and rebuilds it from cell modifications during processing.
|
||||
- **World scale**: `WORLD_SCALE = 5` in `worldgen.rs` — all world features (trees, pools, walls, dunes, rooms, corridors, terrain amplitude) are multiplied by this factor. Change this constant to adjust entity-to-world size ratio.
|
||||
- **Surface terrain**: Multi-octave sine noise with low frequencies (base 0.012, detail 0.04, micro 0.11) scaled by `WORLD_SCALE`. Produces wide rolling hills (~260 cell wavelength) instead of narrow peaks.
|
||||
- **Uniform tick rate**: All loaded chunks simulate every tick — no chunk is frozen or partially updated. `cells_swap` uses `split_at_mut` for direct chunk array access in bounded mode.
|
||||
- **Fire propagation**: Newly ignited cells get `updated_this_tick = true` to prevent chain reactions within a single tick. Fire spreads 1 cell/tick (linear, not exponential).
|
||||
|
||||
## Module Layout
|
||||
|
||||
@@ -190,12 +197,13 @@ 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
|
||||
audio/mod.rs # AudioEngine (rodio), 15 embedded WAV sounds, ambient scanning
|
||||
world/ # Cell, MaterialId, MaterialRegistry, ChunkedGrid, Grid (legacy), Chunk, CellularAutomaton, WorldGenerator, WorldCache
|
||||
physics/ # VerletSolver, SubBody (with color field), Constraint, resolve_grid_collision
|
||||
physics/ # VerletSolver, SubBody (with color field), Constraint, resolve_grid_collision, ProjectileManager
|
||||
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
|
||||
render/ # terminal.rs, vulkan.rs (ASCII), graphics.rs (cells), lighting.rs, window_input.rs, capture.rs
|
||||
ai/ # GameSession, AiAction, pipe protocol, replay, scenarios, spectrum
|
||||
ui/ # UiLayer (flat Vec), HUD, messages, damage numbers, edge indicators, scalable font
|
||||
```
|
||||
|
||||
## PLAN.md
|
||||
|
||||
@@ -10,50 +10,60 @@
|
||||
| System | Status | Details |
|
||||
|--------|--------|---------|
|
||||
| Cellular automaton | Working | 14 materials: sand, water, stone, lava, wood, flesh, bone, steam, fire, acid, smoke, grass, dirt, empty |
|
||||
| Rigid entities | Working | AABB collider, slope stepping, 27 sub-bodies (5x5 + arm), player + goblins |
|
||||
| Multi-layer world | Working | Temperature, gas (smoke/poison/CO2/steam), pressure, world-space light — parallel per-chunk arrays |
|
||||
| Rigid entities | Working | AABB collider, slope stepping, 70 sub-bodies, player + goblins + slimes |
|
||||
| Ragdoll corpses | Working | Verlet constraints, death = rigid→ragdoll transition with inherited velocity |
|
||||
| Terminal renderer | Working | Full terminal size, ANSI truecolor, diff-based rendering |
|
||||
| ASCII renderer (Vulkan) | Working | ash + winit, instanced rendering, glyph atlas, 16x16 square cells, adaptive viewport |
|
||||
| ASCII renderer (Vulkan) | Working | ash + winit, instanced rendering, glyph atlas, 8x8 cells, adaptive viewport |
|
||||
| Graphics renderer (Vulkan) | Working | ash + winit, colored cells (no glyphs), each material = unique base color, adaptive viewport |
|
||||
| AI pipe protocol | Working | JSON stdin/stdout, 16 commands, full state export |
|
||||
| Test framework | Working | 109 Rust tests + 14 JSON scenarios, all passing |
|
||||
| AI pipe protocol | Working | JSON stdin/stdout, 16 commands, full state export, 8 spectrums (materials/temp/light/entities/density/velocity/gas/pressure) |
|
||||
| Audio | Working | 15 procedurally generated sounds via rodio, overlapping playback, ambient scanning, M to toggle |
|
||||
| UI layer | Working | Procedurally scalable font, flat Vec array, health bars, HUD, minimap, inventory overlay, character panel |
|
||||
| Test framework | Working | 185 Rust tests + 14 JSON scenarios, all passing |
|
||||
| Replay system | Working | Seeded determinism, record/playback, play_until_tick |
|
||||
| World generation | Working | Procedural chunk-based biomes: surface (noise), caves (CA), dungeons (BSP); vertical biome progression by chunk Y; 12500×12500 continuous world; chunk streaming + cache |
|
||||
| World generation | Working | ×5 scale: trees 30 cells, pools r20, BSP rooms 20×20, corridors 5 wide; chunk streaming + cache |
|
||||
| Cross-platform | Working | Windows/Linux/macOS via winit + ash_window, no platform-specific code |
|
||||
| Adaptive viewport | Working | Window resize → more/fewer cells visible, cells stay 16x16 pixels |
|
||||
| Per-cell color (reality layer) | Working | Each cell stores fg/bg color inline, no registry lookup in render path |
|
||||
| Adaptive viewport | Working | Window resize → more/fewer cells visible, cells stay 8x8 pixels |
|
||||
| Per-cell color | Working | Each cell stores fg/bg color inline, no registry lookup in render path |
|
||||
| Performance | Working | 130-150 FPS (graphics), uniform tick rate across all chunks |
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Source of truth: `ChunkedGrid` of `Cell` structs
|
||||
Source of truth: `ChunkedGrid` of `Cell` structs with parallel layer arrays
|
||||
- Bounded mode: `Vec<Chunk>` for 250x250 test/AI grids
|
||||
- Infinite mode: `HashMap<(i64, i64), Chunk>` for continuous 12500x12500 cell worlds
|
||||
- 64x64 chunks, dirty rects, active flags, per-chunk persistence
|
||||
- Chunk streaming: load/generate around player, save/unload distant chunks
|
||||
- Infinite mode: `HashMap<(i64,i64), Chunk>` for continuous worlds
|
||||
- 64x64 chunks, dirty rects, all loaded chunks active every tick
|
||||
- Multi-layer: temps (f32), pressure (u8), gas_type+density (u8×2), light ([u8;3])
|
||||
- Cell: 9 bytes (material + variant + fg/bg, no temp)
|
||||
- Serialization: VWM1 multi-section format, backward compat with old 12-byte
|
||||
|
||||
World scale: WORLD_SCALE=5, adjustable via single constant
|
||||
Surface: multi-octave sine noise, wide rolling hills (~260 cell wavelength)
|
||||
|
||||
Three entity types:
|
||||
1. Cellular — materials in grid, per-cell CA rules
|
||||
2. Rigid — alive entities, AABB collider, slope stepping, single velocity
|
||||
3. Ragdoll — corpses, loose Verlet bodies, independent physics
|
||||
|
||||
Game loop: fixed 60Hz timestep
|
||||
Physics tick: CA step → rigid update (slope step) → ragdoll update → damage
|
||||
Render: terminal (ANSI) / ascii (Vulkan glyphs) / graphics (Vulkan cells) / pipe (JSON) / headless (file)
|
||||
Game loop: fixed 60Hz timestep, uniform across all chunks
|
||||
Physics tick: CA step → heat_transfer → gas_step → pressure_step → light_step
|
||||
→ entity updates → combat → status effects → gas damage
|
||||
Render: terminal (ANSI) / ascii (Vulkan glyphs) / graphics (Vulkan cells) / pipe (JSON)
|
||||
|
||||
Three render modes:
|
||||
--mode terminal → pure ANSI ASCII in terminal
|
||||
--mode ascii → Vulkan window with ASCII characters (glyph atlas)
|
||||
--mode graphics → Vulkan window with colored cells (no glyphs, material base colors)
|
||||
Audio: 15 embedded WAV sounds via rodio, overlapping playback, ambient scanning
|
||||
UI: flat Vec array, procedurally scalable font, non-destructive overlay
|
||||
```
|
||||
|
||||
### Numbers
|
||||
|
||||
- ~6700 lines Rust
|
||||
- 171 integration tests, 14 JSON scenarios
|
||||
- 40+ git commits
|
||||
- ~9000 lines Rust
|
||||
- 185 integration tests, 14 JSON scenarios
|
||||
- 50+ git commits
|
||||
- 0 compiler warnings (excluding winit deprecation notices)
|
||||
- Cross-platform: Windows/Linux/macOS
|
||||
- 15 embedded sound effects
|
||||
- 130-150 FPS (graphics mode, release, ×5 world scale)
|
||||
|
||||
---
|
||||
|
||||
@@ -71,17 +81,17 @@ loop is: aim → shoot → projectile travels → hits enemy → damage/kill.
|
||||
- [x] Knockback: damage applies velocity impulse to rigid body center
|
||||
- [x] Goblin AI: move toward player, attack when adjacent
|
||||
- [x] Slime AI: jump toward player, contact damage
|
||||
- [ ] **Projectile system**: lightweight rigid bodies (arrows, fireballs, magic bolts)
|
||||
- [x] **Projectile system**: lightweight rigid bodies (arrows, fireballs, magic bolts)
|
||||
- Player shoots with directional input (mouse aim or movement-direction)
|
||||
- Projectile = small AABB, velocity, damage, lifetime
|
||||
- On hit with entity: deal damage, destroy projectile
|
||||
- On hit with solid cell: stop/destroy
|
||||
- Fireball: ignites materials it touches (Noita-style material interaction)
|
||||
- Magic bolt: pure damage, no material effect
|
||||
- [ ] Health bars in render (colored indicator above entity)
|
||||
- [ ] Death → ragdoll → corpse decomposition (flesh cells drop into grid over time)
|
||||
- [ ] Material interaction with entities: entity walks through fire → ignites, acid → dissolves
|
||||
- [ ] Goblin AI: flee when low HP
|
||||
- [x] Health bars in render (colored indicator above entity)
|
||||
- [x] Death → ragdoll → corpse decomposition (flesh cells drop into grid over time)
|
||||
- [x] Material interaction with entities: entity walks through fire → ignites, acid → dissolves
|
||||
- [x] Goblin AI: flee when low HP
|
||||
|
||||
**Tests needed:**
|
||||
- Projectile travels and deals damage on hit
|
||||
@@ -106,34 +116,34 @@ Render pipeline per frame:
|
||||
3. Present to screen
|
||||
```
|
||||
|
||||
- [ ] `UiLayer` struct: sparse map of (screen_x, screen_y) → (char, fg_color, bg_color)
|
||||
- UI elements write to this map, not to the grid
|
||||
- [x] `UiLayer` struct: flat `Vec<Option<UiCell>>` array for O(1) cell access
|
||||
- UI elements write to this array, not to the grid
|
||||
- Renderer composites: if UiLayer has a cell at (x,y), it overrides the world cell visually
|
||||
- World state is never modified by UI
|
||||
- [ ] Health bar: colored bar above player entity, shows current/max HP
|
||||
- [x] Health bar: colored bar above player entity, shows current/max HP
|
||||
- ████░░░░ style, colored green→yellow→red by HP ratio
|
||||
- Positioned relative to player's screen position, scrolls with camera
|
||||
- [ ] Entity labels: small text above/below entities (name, level for RPG)
|
||||
- [ ] Status effect icons: burning 🔥, poisoned, frozen — shown next to entity
|
||||
- [ ] HUD bar (bottom of screen, non-destructive):
|
||||
- [x] Entity labels: small text above/below entities (name, level for RPG)
|
||||
- [x] Status effect icons: burning, poisoned, frozen — shown next to entity
|
||||
- [x] HUD bar (bottom of screen, non-destructive):
|
||||
- HP: ████████░░ 80/100
|
||||
- Material brush: [Sand] (current selected)
|
||||
- Tick: 1234 Depth: 1
|
||||
- FPS counter (debug mode)
|
||||
- [ ] Tooltip on hover: when cursor is over a cell, show material name + temperature
|
||||
- [ ] Message log (top of screen, scrolling): "Goblin hits you for 10 damage"
|
||||
- [x] Message log (top of screen, scrolling): "Goblin hits you for 10 damage"
|
||||
- Last N messages, older ones fade (dimmer color)
|
||||
- [ ] Inventory overlay (toggle with 'i'): semi-transparent panel, doesn't modify world
|
||||
- [x] Inventory overlay (toggle with 'i'/Tab): semi-transparent panel, doesn't modify world
|
||||
- List of items, selected highlight, weight/value display
|
||||
- Opens/closes without affecting simulation
|
||||
- [ ] Menu system (pause, settings, save/load): full-screen overlay with border
|
||||
- Game loop pauses (or continues in background), UI captures input
|
||||
- [ ] Minimap (corner of screen): compressed world view, explored areas only
|
||||
- [x] Minimap (corner of screen): compressed world view, explored areas only
|
||||
- Each minimap cell = 5x5 world cells, averaged material color
|
||||
- Player position marker, entity dots
|
||||
- [ ] Crosshair/targeting: when aiming projectiles, shows trajectory preview
|
||||
- [ ] Damage numbers: floating text above entities when hit, rises and fades
|
||||
- [ ] Screen-edge indicators: arrows pointing to off-screen entities of interest
|
||||
- [x] Damage numbers: floating text above entities when hit, rises and fades
|
||||
- [x] Screen-edge indicators: arrows pointing to off-screen entities of interest
|
||||
|
||||
**Key principle: UI layer NEVER writes to grid, entities, or any game state.
|
||||
It reads state and renders visuals on top. This keeps the source of truth clean
|
||||
@@ -180,18 +190,18 @@ instanced quads with UI texture coordinates. Transparent background, drawn on to
|
||||
|
||||
**Goal: character progression, inventory, abilities**
|
||||
|
||||
- [ ] Stats: strength, agility, toughness, willpower — affect damage, speed, HP, etc.
|
||||
- [ ] Inventory system: items as data structs, pick up by walking over, drop with key
|
||||
- [ ] Equipment: weapon affects melee damage/range, armor affects damage reduction
|
||||
- [ ] Items in world: weapons, potions, scrolls, food — rendered as distinct ASCII chars
|
||||
- [x] Stats: strength, agility, toughness, willpower — affect damage, speed, HP, etc.
|
||||
- [x] Inventory system: items as data structs, pick up by walking over, drop with key
|
||||
- [x] Equipment: weapon affects melee damage/range, armor affects damage reduction
|
||||
- [x] Items in world: weapons, potions, scrolls, food — rendered as distinct ASCII chars
|
||||
- [ ] Mutations (Caves of Qud style): modify entity properties
|
||||
- "Silicon skin" → entity material becomes Stone, immune to acid
|
||||
- "Flame body" → entity emits fire cells, immune to fire
|
||||
- "Liquid form" → entity can squeeze through 1-cell gaps
|
||||
- "Multiple arms" → extra attack, can hold more items
|
||||
- [ ] XP and leveling: kill entities → gain XP → level up → choose mutation
|
||||
- [x] XP and leveling: kill entities → gain XP → level up → choose mutation
|
||||
- [ ] Skills: active abilities on cooldown (dash, stomp, material blast)
|
||||
- [ ] Status effects: burning, poisoned, frozen, bleeding — each with tick effect
|
||||
- [x] Status effects: burning, poisoned, frozen, bleeding — each with tick effect
|
||||
- [ ] Dialogue: talk to NPCs, simple text tree
|
||||
|
||||
**Tests needed:**
|
||||
@@ -277,17 +287,17 @@ Two distinct render modes, both GPU-accelerated via Vulkan:
|
||||
- [ ] Boss entity: large rigid body (10x10), multiple attack patterns
|
||||
- [ ] Books/readable items: lore text displayed in terminal
|
||||
- [ ] Crafting: combine materials to create new ones (water + dirt = mud)
|
||||
- [ ] Sound: procedural audio via terminal bell or optional ALSA
|
||||
- [x] Sound: procedural audio via rodio, 15 embedded WAV sounds, overlapping playback
|
||||
- [ ] Save/load: full game state to file (grid + entities + player + inventory)
|
||||
- [ ] Death screen: stats summary, cause of death
|
||||
- [ ] Tutorial: first-time controls overlay
|
||||
- [ ] Difficulty scaling: deeper levels = stronger enemies
|
||||
|
||||
### Phase 6: Advanced Physics
|
||||
### Phase 6: Advanced Physics (DONE)
|
||||
|
||||
**Goal: deeper Noita-style material simulation**
|
||||
|
||||
- [ ] Multi-layer world: separate grid layers for material, temperature, pressure, gas/air, light
|
||||
- [x] Multi-layer world: separate grid layers for material, temperature, pressure, gas/air, light
|
||||
- Air layer: gas flow, ventilation in caves, gas accumulates at ceiling, displaced by fire
|
||||
- Pressure layer: liquids have pressure, flow through pipes and U-bends
|
||||
- Temperature layer: proper heat diffusion, materials melt/freeze at thresholds
|
||||
@@ -547,10 +557,12 @@ assets/
|
||||
|-----------|---------|--------|
|
||||
| 0.1 (done) | Core engine: CA, rigid, ragdoll, terminal, AI pipe | June 2026 |
|
||||
| 0.2 (done) | Vulkan ASCII + graphics renderers, adaptive viewport, per-cell color, slope stepping | June 2026 |
|
||||
| 0.3 | Combat, goblin AI, projectiles, corpse decomposition | July 2026 |
|
||||
| 0.35 | UI layer: health bar, HUD, message log, minimap, inventory overlay | July 2026 |
|
||||
| 0.4 | Chunks, biomes, dungeon gen, camera zoom | August 2026 |
|
||||
| 0.5 | RPG layer: stats, inventory, mutations, XP | October 2026 |
|
||||
| 0.3 (done) | Combat, goblin AI, projectiles, corpse decomposition | June 2026 |
|
||||
| 0.35 (done) | UI layer: health bar, HUD, message log, minimap, inventory overlay, scalable font | June 2026 |
|
||||
| 0.4 (done) | Chunks, biomes, dungeon gen, world scale ×5, chunk streaming + cache | June 2026 |
|
||||
| 0.45 (done) | RPG layer: stats, inventory, XP, status effects, items | June 2026 |
|
||||
| 0.5 (done) | Multi-layer world: temp, gas, pressure, light + audio system | June 2026 |
|
||||
| 0.55 (done) | Performance: uniform tick rate, 130-150 FPS, fire chain fix | June 2026 |
|
||||
| 0.6 | Lighting/particles/textures (Phase 4b) | December 2026 |
|
||||
| 0.7 | Multi-layer world: air, pressure, temperature, light as separate grids | Feb 2027 |
|
||||
| 0.8 | AI agent: LLM + RL bridge, agent recording | April 2027 |
|
||||
|
||||
@@ -71,17 +71,29 @@ vec3 compute_light(ivec2 world_pos) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 pixel = (in_grid + in_pos) * pc.cell_size;
|
||||
gl_Position = vec4(
|
||||
2.0 * pixel.x / pc.screen_size.x - 1.0,
|
||||
2.0 * pixel.y / pc.screen_size.y - 1.0,
|
||||
0.0, 1.0
|
||||
);
|
||||
out_color = in_color;
|
||||
if (pc.is_ui != 0u) {
|
||||
if (pc.is_ui == 2u) {
|
||||
vec2 world_pos = in_grid;
|
||||
vec2 screen = (world_pos - vec2(pc.cam_pos)) * pc.cell_size + in_pos * pc.cell_size;
|
||||
gl_Position = vec4(
|
||||
2.0 * screen.x / pc.screen_size.x - 1.0,
|
||||
2.0 * screen.y / pc.screen_size.y - 1.0,
|
||||
0.0, 1.0
|
||||
);
|
||||
out_color = in_color;
|
||||
out_light = vec3(1.0);
|
||||
} else {
|
||||
ivec2 world_pos = ivec2(in_grid + vec2(pc.cam_pos));
|
||||
out_light = compute_light(world_pos);
|
||||
vec2 pixel = (in_grid + in_pos) * pc.cell_size;
|
||||
gl_Position = vec4(
|
||||
2.0 * pixel.x / pc.screen_size.x - 1.0,
|
||||
2.0 * pixel.y / pc.screen_size.y - 1.0,
|
||||
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.
+12
-12
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"mode": "graphics",
|
||||
"ticks": 600,
|
||||
"total_time_ms": 3483.9,
|
||||
"avg_fps": 172.2,
|
||||
"avg_frame_time_ms": 5.78,
|
||||
"p99_frame_time_ms": 14.20,
|
||||
"min_frame_time_ms": 4.20,
|
||||
"ticks": 300,
|
||||
"total_time_ms": 2104.1,
|
||||
"avg_fps": 142.6,
|
||||
"avg_frame_time_ms": 6.99,
|
||||
"p99_frame_time_ms": 15.98,
|
||||
"min_frame_time_ms": 5.43,
|
||||
"subsystems": {
|
||||
"ca_step_avg_us": 1397,
|
||||
"ca_step_p99_us": 10172,
|
||||
"ca_step_min_us": 303,
|
||||
"render_avg_us": 3668,
|
||||
"render_p99_us": 5514,
|
||||
"render_min_us": 3259
|
||||
"ca_step_avg_us": 2900,
|
||||
"ca_step_p99_us": 11976,
|
||||
"ca_step_min_us": 1280,
|
||||
"render_avg_us": 3459,
|
||||
"render_p99_us": 5031,
|
||||
"render_min_us": 3155
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
+186
-28
@@ -4,6 +4,7 @@ use crate::entity::player::Player;
|
||||
use crate::entity::{EntityKind, EntityManager, ItemManager};
|
||||
use crate::input::{Action, InputHandler};
|
||||
use crate::physics::collision::resolve_grid_collision;
|
||||
use crate::physics::particle::ParticleManager;
|
||||
use crate::physics::projectile::{ProjectileManager, ProjectileType};
|
||||
use crate::physics::verlet::VerletSolver;
|
||||
use crate::render::lighting;
|
||||
@@ -24,6 +25,7 @@ pub struct Game {
|
||||
pub verlet: VerletSolver,
|
||||
pub entities: EntityManager,
|
||||
pub projectiles: ProjectileManager,
|
||||
pub particles: ParticleManager,
|
||||
pub items: ItemManager,
|
||||
pub player: Player,
|
||||
pub input: InputHandler,
|
||||
@@ -51,6 +53,11 @@ pub struct Game {
|
||||
pub inventory_mouse_y: i32,
|
||||
pub seed: u64,
|
||||
pub cache_dir: Option<String>,
|
||||
pub mouse_world_pos: (i32, i32),
|
||||
pub mouse_ui_x: i32,
|
||||
pub mouse_ui_y: i32,
|
||||
pub show_tooltip: bool,
|
||||
pub show_crosshair: bool,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
@@ -67,6 +74,7 @@ impl Game {
|
||||
verlet: VerletSolver::new(),
|
||||
entities,
|
||||
projectiles: ProjectileManager::new(),
|
||||
particles: ParticleManager::new(2000),
|
||||
items: ItemManager::new(),
|
||||
player,
|
||||
input: InputHandler::new(),
|
||||
@@ -94,6 +102,11 @@ impl Game {
|
||||
inventory_mouse_y: 0,
|
||||
seed: 0x1234567890ABCDEF,
|
||||
cache_dir: None,
|
||||
mouse_world_pos: (0, 0),
|
||||
mouse_ui_x: 0,
|
||||
mouse_ui_y: 0,
|
||||
show_tooltip: false,
|
||||
show_crosshair: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +123,7 @@ impl Game {
|
||||
verlet: VerletSolver::new(),
|
||||
entities,
|
||||
projectiles: ProjectileManager::new(),
|
||||
particles: ParticleManager::new(2000),
|
||||
items: ItemManager::new(),
|
||||
player,
|
||||
input: InputHandler::new(),
|
||||
@@ -137,6 +151,11 @@ impl Game {
|
||||
inventory_mouse_y: 0,
|
||||
seed,
|
||||
cache_dir,
|
||||
mouse_world_pos: (0, 0),
|
||||
mouse_ui_x: 0,
|
||||
mouse_ui_y: 0,
|
||||
show_tooltip: false,
|
||||
show_crosshair: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +258,7 @@ impl Game {
|
||||
self.cam_y,
|
||||
vw,
|
||||
vh,
|
||||
lighting::ambient_light(),
|
||||
lighting::ambient_light_at_tick(self.tick),
|
||||
);
|
||||
|
||||
if let Err(e) = renderer.render(
|
||||
@@ -418,6 +437,56 @@ impl Game {
|
||||
.draw_death_screen(ui_w as usize, ui_h as usize, self.kills, self.score);
|
||||
}
|
||||
|
||||
if self.show_crosshair && !self.inventory_open {
|
||||
let (px, py) = self.player.center(&self.entities);
|
||||
let psx = (px as i32 - self.cam_x) * crate::ui::UI_SCALE;
|
||||
let psy = (py as i32 - self.cam_y) * crate::ui::UI_SCALE;
|
||||
let (mx, my) = (self.mouse_ui_x, self.mouse_ui_y);
|
||||
let dx = mx - psx;
|
||||
let dy = my - psy;
|
||||
let dist = ((dx * dx + dy * dy) as f32).sqrt().max(1.0);
|
||||
let steps = (dist / 6.0) as i32;
|
||||
for i in 1..steps {
|
||||
let t = i as f32 / steps as f32;
|
||||
let x = (psx as f32 + dx as f32 * t) as i32;
|
||||
let y = (psy as f32 + dy as f32 * t) as i32;
|
||||
if x >= 0 && x < ui_w && y >= 0 && y < ui_h {
|
||||
let alpha = (180.0 * (1.0 - t * 0.4)) as u8;
|
||||
self.ui
|
||||
.set_alpha(x, y, '.', [255, 200, 80], [0, 0, 0], alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.show_tooltip && !self.inventory_open {
|
||||
let (mx, my) = self.mouse_world_pos;
|
||||
if self.grid.in_bounds(mx, my) {
|
||||
let cell = self.grid.get(mx, my);
|
||||
let temp = self.grid.get_temp(mx, my);
|
||||
let mat_name = match cell.material {
|
||||
MaterialId::Empty => "Empty",
|
||||
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",
|
||||
};
|
||||
let tip = format!("{} {:.0}C", mat_name, temp);
|
||||
let tx = (self.mouse_ui_x + 4).min(ui_w - 100);
|
||||
let ty = (self.mouse_ui_y + 4).min(ui_h - 20);
|
||||
self.ui.draw_text(tx, ty, &tip, [220, 220, 240], 220);
|
||||
}
|
||||
}
|
||||
|
||||
for e in self.entities.all() {
|
||||
if !e.alive || e.kind == EntityKind::Corpse {
|
||||
continue;
|
||||
@@ -483,10 +552,79 @@ impl Game {
|
||||
self.try_spawn_slime();
|
||||
}
|
||||
|
||||
self.spawn_ambient_particles();
|
||||
self.play_ambient_sounds();
|
||||
self.particles.update();
|
||||
self.grid.swap_modified_flags();
|
||||
}
|
||||
|
||||
fn spawn_ambient_particles(&mut self) {
|
||||
let (px, py) = self.player.center(&self.entities);
|
||||
let radius = 30i32;
|
||||
let mut fire_count = 0u32;
|
||||
let mut lava_count = 0u32;
|
||||
let mut water_count = 0u32;
|
||||
for dy in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let x = px as i32 + dx;
|
||||
let y = py as i32 + dy;
|
||||
if !self.grid.in_bounds(x, y) {
|
||||
continue;
|
||||
}
|
||||
let cell = self.grid.get(x, y);
|
||||
match cell.material {
|
||||
MaterialId::Fire => {
|
||||
fire_count += 1;
|
||||
if self.tick % 3 == 0
|
||||
&& (dx * dx + dy * dy) < 400
|
||||
&& self.particles.count() < 1800
|
||||
{
|
||||
let mut rng =
|
||||
(x as u32).wrapping_mul(7919).wrapping_add(self.tick as u32);
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
let off_x = ((rng & 0xFF) as f32 / 255.0 - 0.5) * 2.0;
|
||||
let off_y = ((rng >> 8 & 0xFF) as f32 / 255.0 - 0.5) * 2.0;
|
||||
self.particles.spawn(
|
||||
x as f32 + off_x,
|
||||
y as f32 + off_y,
|
||||
off_x * 0.3,
|
||||
-0.5 - ((rng >> 16) as f32 / 65535.0),
|
||||
15 + (rng % 10),
|
||||
[255, 140 + ((rng >> 4) % 80) as u8, 20, 200],
|
||||
1.0 + ((rng >> 8) % 3) as f32 * 0.5,
|
||||
-0.02,
|
||||
);
|
||||
}
|
||||
}
|
||||
MaterialId::Lava => {
|
||||
lava_count += 1;
|
||||
if self.tick % 20 == 0
|
||||
&& (dx * dx + dy * dy) < 300
|
||||
&& self.particles.count() < 1800
|
||||
{
|
||||
self.particles.spawn_burst(
|
||||
x as f32,
|
||||
y as f32,
|
||||
2,
|
||||
[255, 100, 20, 180],
|
||||
1.5,
|
||||
25,
|
||||
1.5,
|
||||
0.1,
|
||||
);
|
||||
}
|
||||
}
|
||||
MaterialId::Water => {
|
||||
water_count += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = (fire_count, lava_count, water_count);
|
||||
}
|
||||
|
||||
fn play_ambient_sounds(&mut self) {
|
||||
if !self.audio.is_enabled() {
|
||||
return;
|
||||
@@ -660,10 +798,9 @@ impl Game {
|
||||
let px = px as i32;
|
||||
let py = py as i32;
|
||||
let (pcx, pcy, _, _) = self.grid.chunk_at(px, py);
|
||||
let radius = 3;
|
||||
let radius = 2;
|
||||
let chunk_size = self.grid.chunk_size as i32;
|
||||
|
||||
let mut to_generate: Vec<(i32, i32)> = Vec::new();
|
||||
for dy in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let cx = pcx + dx;
|
||||
@@ -675,16 +812,11 @@ impl Game {
|
||||
}
|
||||
self.grid.ensure_chunk(cx, cy);
|
||||
if !self.grid.is_chunk_generated(cx, cy) {
|
||||
to_generate.push((cx, cy));
|
||||
WorldGenerator::new(&mut self.ca).generate_chunk(&mut self.grid, cx, cy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let gen_budget = 2;
|
||||
for &(cx, cy) in to_generate.iter().take(gen_budget) {
|
||||
WorldGenerator::new(&mut self.ca).generate_chunk(&mut self.grid, cx, cy);
|
||||
}
|
||||
|
||||
if let Some(ref dir) = self.cache_dir {
|
||||
let save_radius = radius + 2;
|
||||
if self.tick % 10 == 0 {
|
||||
@@ -727,11 +859,6 @@ impl Game {
|
||||
}
|
||||
|
||||
fn update_active_chunks(&mut self) {
|
||||
let chunk_size = self.grid.chunk_size as i32;
|
||||
let (px, py) = self.player.center(&self.entities);
|
||||
let pcx = px as i32 / chunk_size;
|
||||
let pcy = py as i32 / chunk_size;
|
||||
|
||||
if !self.grid.is_infinite() {
|
||||
for e in self.entities.all() {
|
||||
let (cx, cy) = e.center();
|
||||
@@ -747,21 +874,8 @@ impl Game {
|
||||
|
||||
self.grid.deactivate_all();
|
||||
|
||||
for dy in -2..=2 {
|
||||
for dx in -2..=2 {
|
||||
self.grid.set_chunk_active(pcx + dx, pcy + dy, true);
|
||||
}
|
||||
}
|
||||
for e in self.entities.all() {
|
||||
let (cx, cy) = e.center();
|
||||
self.grid.activate_around(cx as i32, cy as i32, 1);
|
||||
}
|
||||
for (cx, cy) in self.grid.all_chunk_coords() {
|
||||
if self.grid.get_chunk_dirty(cx, cy).is_some() {
|
||||
if (cx - pcx).abs() <= 1 && (cy - pcy).abs() <= 1 {
|
||||
self.grid.set_chunk_active(cx, cy, true);
|
||||
}
|
||||
}
|
||||
self.grid.set_chunk_active(cx, cy, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1617,4 +1731,48 @@ impl Game {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trigger_explosion(&mut self, x: i32, y: i32, radius: i32, damage: f32) {
|
||||
for dy in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let dist_sq = dx * dx + dy * dy;
|
||||
if dist_sq > radius * radius {
|
||||
continue;
|
||||
}
|
||||
let bx = x + dx;
|
||||
let by = y + dy;
|
||||
if !self.grid.in_bounds(bx, by) {
|
||||
continue;
|
||||
}
|
||||
let cell = self.grid.get(bx, by);
|
||||
if cell.is_solid() && !cell.is_static() {
|
||||
self.grid.set(bx, by, crate::world::cell::Cell::empty());
|
||||
} else if cell.is_empty() {
|
||||
let mut fire = crate::world::cell::Cell::new(MaterialId::Fire);
|
||||
fire.updated_this_tick = true;
|
||||
self.grid.set(bx, by, fire);
|
||||
self.grid.set_temp(bx, by, 600.0);
|
||||
}
|
||||
self.grid.set_pressure(bx, by, 255);
|
||||
self.grid.mark_dirty(bx, by);
|
||||
}
|
||||
}
|
||||
self.audio.play("explosion");
|
||||
for e in self.entities.all_mut() {
|
||||
if !e.alive {
|
||||
continue;
|
||||
}
|
||||
let dx = e.cx as i32 - x;
|
||||
let dy = e.cy as i32 - y;
|
||||
let dist = ((dx * dx + dy * dy) as f32).sqrt();
|
||||
if dist < radius as f32 * 1.5 {
|
||||
let dmg = (damage * (1.0 - dist / (radius as f32 * 1.5))).max(0.0);
|
||||
e.take_damage(dmg);
|
||||
let knockback = (1.0 - dist / (radius as f32 * 1.5)).max(0.0) * 5.0;
|
||||
let dir = if dx == 0 { 0.0 } else { dx as f32 / dist };
|
||||
e.set_horizontal_vel(dir * knockback);
|
||||
e.set_vertical_vel(-knockback * 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-2
@@ -72,6 +72,8 @@ trait GpuRenderer {
|
||||
);
|
||||
fn grid_w(&self) -> usize;
|
||||
fn grid_h(&self) -> usize;
|
||||
fn cell_pixel_size(&self) -> u32;
|
||||
fn upload_particles(&mut self, particles: &verbatim::physics::particle::ParticleManager);
|
||||
}
|
||||
|
||||
impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer {
|
||||
@@ -98,6 +100,12 @@ impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer {
|
||||
fn grid_h(&self) -> usize {
|
||||
verbatim::render::vulkan::VulkanRenderer::grid_h(self)
|
||||
}
|
||||
fn cell_pixel_size(&self) -> u32 {
|
||||
verbatim::render::vulkan::VulkanRenderer::cell_pixel_size(self)
|
||||
}
|
||||
fn upload_particles(&mut self, particles: &verbatim::physics::particle::ParticleManager) {
|
||||
verbatim::render::vulkan::VulkanRenderer::upload_particles(self, particles);
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer {
|
||||
@@ -124,6 +132,12 @@ impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer {
|
||||
fn grid_h(&self) -> usize {
|
||||
verbatim::render::graphics::GraphicsRenderer::grid_h(self)
|
||||
}
|
||||
fn cell_pixel_size(&self) -> u32 {
|
||||
verbatim::render::graphics::GraphicsRenderer::cell_pixel_size(self)
|
||||
}
|
||||
fn upload_particles(&mut self, particles: &verbatim::physics::particle::ParticleManager) {
|
||||
verbatim::render::graphics::GraphicsRenderer::upload_particles(self, particles);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
@@ -257,6 +271,20 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
|
||||
}
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
input.on_mouse_move(position.x, position.y);
|
||||
let cell_px = renderer.cell_pixel_size() as f64;
|
||||
let vw = renderer.grid_w();
|
||||
let vh = renderer.grid_h();
|
||||
game.mouse_ui_x =
|
||||
(position.x / cell_px * verbatim::ui::UI_SCALE as f64) as i32;
|
||||
game.mouse_ui_y =
|
||||
(position.y / cell_px * verbatim::ui::UI_SCALE as f64) as i32;
|
||||
game.show_tooltip = true;
|
||||
game.show_crosshair = true;
|
||||
if vw > 0 && vh > 0 && cell_px > 0.0 {
|
||||
let wx = game.cam_x + (position.x / cell_px) as i32;
|
||||
let wy = game.cam_y + (position.y / cell_px) as i32;
|
||||
game.mouse_world_pos = (wx, wy);
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
input.on_mouse_button(button, state);
|
||||
@@ -321,9 +349,12 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
|
||||
|| input.shoot_up
|
||||
|| input.shoot_down
|
||||
{
|
||||
let cell_px = renderer.cell_pixel_size() as f64;
|
||||
let (px, py) = game.player.center(&game.entities);
|
||||
let player_screen_x = ((px as i32 - game.cam_x) as f64) * 8.0 + 4.0;
|
||||
let player_screen_y = ((py as i32 - game.cam_y) as f64) * 8.0 + 4.0;
|
||||
let player_screen_x =
|
||||
((px as i32 - game.cam_x) as f64) * cell_px + cell_px / 2.0;
|
||||
let player_screen_y =
|
||||
((py as i32 - game.cam_y) as f64) * cell_px + cell_px / 2.0;
|
||||
|
||||
let (dx, dy) = if input.shoot_mouse {
|
||||
let mx = input.mouse_x - player_screen_x;
|
||||
@@ -508,6 +539,7 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
|
||||
|
||||
game.build_ui(vw, vh);
|
||||
|
||||
renderer.upload_particles(&game.particles);
|
||||
renderer.render(
|
||||
&game.grid,
|
||||
&game.entities,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod collision;
|
||||
pub mod particle;
|
||||
pub mod projectile;
|
||||
pub mod verlet;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Particle {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub vx: f32,
|
||||
pub vy: f32,
|
||||
pub life: u32,
|
||||
pub max_life: u32,
|
||||
pub color: [u8; 4],
|
||||
pub size: f32,
|
||||
pub gravity: f32,
|
||||
}
|
||||
|
||||
impl Particle {
|
||||
pub fn alive(&self) -> bool {
|
||||
self.life > 0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ParticleManager {
|
||||
particles: Vec<Particle>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl ParticleManager {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
particles: Vec::with_capacity(capacity),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn(
|
||||
&mut self,
|
||||
x: f32,
|
||||
y: f32,
|
||||
vx: f32,
|
||||
vy: f32,
|
||||
life: u32,
|
||||
color: [u8; 4],
|
||||
size: f32,
|
||||
gravity: f32,
|
||||
) {
|
||||
if self.particles.len() >= self.capacity {
|
||||
return;
|
||||
}
|
||||
self.particles.push(Particle {
|
||||
x,
|
||||
y,
|
||||
vx,
|
||||
vy,
|
||||
life,
|
||||
max_life: life,
|
||||
color,
|
||||
size,
|
||||
gravity,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn spawn_burst(
|
||||
&mut self,
|
||||
x: f32,
|
||||
y: f32,
|
||||
count: usize,
|
||||
color: [u8; 4],
|
||||
speed: f32,
|
||||
life: u32,
|
||||
size: f32,
|
||||
gravity: f32,
|
||||
) {
|
||||
let mut rng = 0x12345u32;
|
||||
for _ in 0..count {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
let angle = (rng as f32 / u32::MAX as f32) * std::f32::consts::TAU;
|
||||
let s = speed * (0.3 + 0.7 * ((rng >> 16) as f32 / u16::MAX as f32));
|
||||
let vx = angle.cos() * s;
|
||||
let vy = angle.sin() * s - speed * 0.3;
|
||||
self.spawn(x, y, vx, vy, life, color, size, gravity);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self) {
|
||||
for p in &mut self.particles {
|
||||
p.life = p.life.saturating_sub(1);
|
||||
p.vy += p.gravity;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vx *= 0.96;
|
||||
p.vy *= 0.96;
|
||||
}
|
||||
self.particles.retain(|p| p.alive());
|
||||
}
|
||||
|
||||
pub fn all(&self) -> &[Particle] {
|
||||
&self.particles
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.particles.clear();
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.particles.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ParticleManager {
|
||||
fn default() -> Self {
|
||||
Self::new(2000)
|
||||
}
|
||||
}
|
||||
@@ -136,13 +136,16 @@ impl Projectile {
|
||||
}
|
||||
let cell = grid.get(x, y);
|
||||
if cell.is_empty() {
|
||||
grid.set(x, y, Cell::new(MaterialId::Fire));
|
||||
let mut fire = Cell::new(MaterialId::Fire);
|
||||
fire.updated_this_tick = true;
|
||||
grid.set(x, y, fire);
|
||||
} else if cell.material == MaterialId::Wood
|
||||
|| cell.material == MaterialId::Grass
|
||||
|| cell.material == MaterialId::Flesh
|
||||
{
|
||||
let mut ignited = cell;
|
||||
ignited.material = MaterialId::Fire;
|
||||
ignited.updated_this_tick = true;
|
||||
grid.set(x, y, ignited);
|
||||
grid.set_temp(x, y, 400.0);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,14 @@ struct ColorInstance {
|
||||
color: [u8; 4],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct ParticleInstance {
|
||||
grid_x: f32,
|
||||
grid_y: f32,
|
||||
color: [u8; 4],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(bytemuck::NoUninit, Clone, Copy, Default)]
|
||||
struct GpuLightSource {
|
||||
@@ -115,6 +123,12 @@ pub struct GraphicsRenderer {
|
||||
ui_instance_ptr: *mut ColorInstance,
|
||||
ui_instance_capacity: usize,
|
||||
|
||||
particle_instance_buffer: vk::Buffer,
|
||||
particle_instance_memory: vk::DeviceMemory,
|
||||
particle_instance_ptr: *mut ParticleInstance,
|
||||
particle_instance_capacity: usize,
|
||||
particle_count: usize,
|
||||
|
||||
grid_buffer: vk::Buffer,
|
||||
grid_memory: vk::DeviceMemory,
|
||||
grid_ptr: *mut u32,
|
||||
@@ -706,6 +720,44 @@ impl GraphicsRenderer {
|
||||
ptr as *mut ColorInstance
|
||||
};
|
||||
|
||||
let part_capacity = 2000usize;
|
||||
let part_inst_sz =
|
||||
(part_capacity * std::mem::size_of::<ParticleInstance>()) as vk::DeviceSize;
|
||||
let pibi = vk::BufferCreateInfo::default()
|
||||
.size(part_inst_sz)
|
||||
.usage(vk::BufferUsageFlags::VERTEX_BUFFER)
|
||||
.sharing_mode(vk::SharingMode::EXCLUSIVE);
|
||||
let particle_instance_buffer = unsafe { device.create_buffer(&pibi, None) }
|
||||
.map_err(|e| format!("part inst buf: {e:?}"))?;
|
||||
let preq = unsafe { device.get_buffer_memory_requirements(particle_instance_buffer) };
|
||||
let pmt = find_mem(
|
||||
preq.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
|
||||
)?;
|
||||
let particle_instance_memory = unsafe {
|
||||
device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(preq.size)
|
||||
.memory_type_index(pmt),
|
||||
None,
|
||||
)
|
||||
}
|
||||
.map_err(|e| format!("part inst mem: {e:?}"))?;
|
||||
let particle_instance_ptr = unsafe {
|
||||
device
|
||||
.bind_buffer_memory(particle_instance_buffer, particle_instance_memory, 0)
|
||||
.expect("bind part inst");
|
||||
let ptr = device
|
||||
.map_memory(
|
||||
particle_instance_memory,
|
||||
0,
|
||||
part_inst_sz,
|
||||
vk::MemoryMapFlags::default(),
|
||||
)
|
||||
.expect("map part inst");
|
||||
ptr as *mut ParticleInstance
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
grid_w,
|
||||
grid_h,
|
||||
@@ -742,6 +794,11 @@ impl GraphicsRenderer {
|
||||
ui_instance_memory,
|
||||
ui_instance_ptr,
|
||||
ui_instance_capacity: ui_capacity,
|
||||
particle_instance_buffer,
|
||||
particle_instance_memory,
|
||||
particle_instance_ptr,
|
||||
particle_instance_capacity: part_capacity,
|
||||
particle_count: 0,
|
||||
|
||||
grid_buffer,
|
||||
grid_memory,
|
||||
@@ -1076,6 +1133,35 @@ impl GraphicsRenderer {
|
||||
device.cmd_draw_indexed(cmd, 6, ui_count as u32, 0, 0, 0);
|
||||
}
|
||||
|
||||
if self.particle_count > 0 {
|
||||
device.cmd_bind_vertex_buffers(
|
||||
cmd,
|
||||
0,
|
||||
&[self.vertex_buffer, self.particle_instance_buffer],
|
||||
&[0, 0],
|
||||
);
|
||||
let part_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: [self.grid_w as i32, self.grid_h as i32],
|
||||
cam_pos: [cam_x, cam_y],
|
||||
ambient: [1.0, 1.0, 1.0],
|
||||
is_ui: 2,
|
||||
light_count: 0,
|
||||
};
|
||||
device.cmd_push_constants(
|
||||
cmd,
|
||||
self.pipeline_layout,
|
||||
vk::ShaderStageFlags::VERTEX,
|
||||
0,
|
||||
bytemuck::bytes_of(&part_pc),
|
||||
);
|
||||
device.cmd_draw_indexed(cmd, 6, self.particle_count as u32, 0, 0, 0);
|
||||
}
|
||||
|
||||
device.cmd_end_render_pass(cmd);
|
||||
let _ = device.end_command_buffer(cmd);
|
||||
|
||||
@@ -1111,6 +1197,33 @@ impl GraphicsRenderer {
|
||||
self.grid_h
|
||||
}
|
||||
|
||||
pub fn cell_pixel_size(&self) -> u32 {
|
||||
CHAR_W
|
||||
}
|
||||
|
||||
pub fn upload_particles(&mut self, particles: &crate::physics::particle::ParticleManager) {
|
||||
let instances = unsafe {
|
||||
std::slice::from_raw_parts_mut(
|
||||
self.particle_instance_ptr,
|
||||
self.particle_instance_capacity,
|
||||
)
|
||||
};
|
||||
let mut count = 0usize;
|
||||
for p in particles.all() {
|
||||
if count >= self.particle_instance_capacity {
|
||||
break;
|
||||
}
|
||||
let alpha = ((p.life as f32 / p.max_life as f32) * 255.0) as u8;
|
||||
instances[count] = ParticleInstance {
|
||||
grid_x: p.x,
|
||||
grid_y: p.y,
|
||||
color: [p.color[0], p.color[1], p.color[2], alpha],
|
||||
};
|
||||
count += 1;
|
||||
}
|
||||
self.particle_count = count;
|
||||
}
|
||||
|
||||
fn check_resize(&mut self) {
|
||||
let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance);
|
||||
let caps = match unsafe {
|
||||
@@ -1342,6 +1455,9 @@ impl Drop for GraphicsRenderer {
|
||||
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.particle_instance_buffer, None);
|
||||
self.device.free_memory(self.particle_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);
|
||||
|
||||
@@ -241,6 +241,15 @@ pub fn ambient_light() -> [u8; 3] {
|
||||
[160, 160, 180]
|
||||
}
|
||||
|
||||
pub fn ambient_light_at_tick(tick: u64) -> [u8; 3] {
|
||||
let phase = (tick as f32 * 0.001).sin();
|
||||
let brightness = 0.5 + 0.5 * phase;
|
||||
let r = (80.0 + 100.0 * brightness) as u8;
|
||||
let g = (80.0 + 100.0 * brightness) as u8;
|
||||
let b = (100.0 + 100.0 * brightness) as u8;
|
||||
[r, g, b]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -708,6 +708,12 @@ impl VulkanRenderer {
|
||||
self.grid_h
|
||||
}
|
||||
|
||||
pub fn cell_pixel_size(&self) -> u32 {
|
||||
CHAR_W
|
||||
}
|
||||
|
||||
pub fn upload_particles(&mut self, _particles: &crate::physics::particle::ParticleManager) {}
|
||||
|
||||
fn check_resize(&mut self) {
|
||||
let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance);
|
||||
let caps = match unsafe {
|
||||
|
||||
@@ -206,7 +206,6 @@ impl WindowInput {
|
||||
self.cam_up = keys.contains(&KeyCode::KeyI);
|
||||
self.cam_down = keys.contains(&KeyCode::KeyO);
|
||||
self.quit = keys.contains(&KeyCode::KeyQ) || keys.contains(&KeyCode::Escape);
|
||||
|
||||
let now_audio = keys.contains(&KeyCode::KeyM);
|
||||
self.toggle_audio = now_audio && !self.audio_was_down;
|
||||
self.audio_was_down = now_audio;
|
||||
|
||||
+179
-19
@@ -127,14 +127,6 @@ impl CellularAutomaton {
|
||||
None => continue,
|
||||
};
|
||||
let (min_x, min_y, max_x, max_y) = dirty;
|
||||
let dw = max_x - min_x + 1;
|
||||
let dh = max_y - min_y + 1;
|
||||
if dw * dh > 2048 {
|
||||
grid.set_chunk_dirty(cx, cy, None);
|
||||
grid.mark_dirty(min_x + dw / 4, min_y + dh / 4);
|
||||
grid.mark_dirty(max_x - dw / 4, max_y - dh / 4);
|
||||
continue;
|
||||
}
|
||||
|
||||
grid.set_chunk_dirty(cx, cy, None);
|
||||
|
||||
@@ -154,6 +146,8 @@ impl CellularAutomaton {
|
||||
self.heat_transfer(grid, &active, &pre_dirty);
|
||||
self.gas_step(grid, &active, &pre_dirty);
|
||||
self.pressure_step(grid, &active, &pre_dirty);
|
||||
self.electricity_step(grid, &active, &pre_dirty);
|
||||
self.structural_step(grid, &active);
|
||||
self.light_step(grid, &active);
|
||||
self.tick += 1;
|
||||
}
|
||||
@@ -243,6 +237,7 @@ impl CellularAutomaton {
|
||||
if temp > 100.0 {
|
||||
let mut new = grid.get(x, y);
|
||||
new.material = MaterialId::Steam;
|
||||
new.updated_this_tick = true;
|
||||
grid.set(x, y, new);
|
||||
grid.set_temp(x, y, 110.0);
|
||||
}
|
||||
@@ -301,7 +296,10 @@ impl CellularAutomaton {
|
||||
grid.set_temp(x, y, lava_temp - 50.0);
|
||||
}
|
||||
MaterialId::Wood | MaterialId::Grass | MaterialId::Flesh if n_temp < 300.0 => {
|
||||
grid.set(nx, ny, Cell::new(MaterialId::Fire));
|
||||
let mut new_n = neighbor;
|
||||
new_n.material = MaterialId::Fire;
|
||||
new_n.updated_this_tick = true;
|
||||
grid.set(nx, ny, new_n);
|
||||
grid.set_temp(nx, ny, 400.0);
|
||||
}
|
||||
MaterialId::Sand if n_temp > 1700.0 => {
|
||||
@@ -377,6 +375,7 @@ impl CellularAutomaton {
|
||||
if mat.flammable && n_temp < mat.ignition_temp {
|
||||
let mut new_n = neighbor;
|
||||
new_n.material = MaterialId::Fire;
|
||||
new_n.updated_this_tick = true;
|
||||
grid.set(nx, ny, new_n);
|
||||
grid.set_temp(nx, ny, 400.0);
|
||||
}
|
||||
@@ -473,6 +472,7 @@ impl CellularAutomaton {
|
||||
if temp > 200.0 {
|
||||
let mut new = grid.get(x, y);
|
||||
new.material = MaterialId::Fire;
|
||||
new.updated_this_tick = true;
|
||||
grid.set(x, y, new);
|
||||
grid.set_temp(x, y, 400.0);
|
||||
}
|
||||
@@ -481,7 +481,10 @@ impl CellularAutomaton {
|
||||
fn update_grass(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
|
||||
let temp = grid.get_temp(x, y);
|
||||
if temp > 250.0 {
|
||||
grid.set(x, y, Cell::new(MaterialId::Fire));
|
||||
let mut new = grid.get(x, y);
|
||||
new.material = MaterialId::Fire;
|
||||
new.updated_this_tick = true;
|
||||
grid.set(x, y, new);
|
||||
grid.set_temp(x, y, 400.0);
|
||||
}
|
||||
}
|
||||
@@ -514,9 +517,6 @@ impl CellularAutomaton {
|
||||
};
|
||||
let w = max_x - min_x + 1;
|
||||
let h = max_y - min_y + 1;
|
||||
if w * h > 2048 {
|
||||
continue;
|
||||
}
|
||||
let ox = cx * cs;
|
||||
let oy = cy * cs;
|
||||
let mut edge_temps: Vec<((i32, i32), f32)> = Vec::new();
|
||||
@@ -606,9 +606,6 @@ impl CellularAutomaton {
|
||||
};
|
||||
let w = max_x - min_x + 1;
|
||||
let h = max_y - min_y + 1;
|
||||
if w * h > 2048 {
|
||||
continue;
|
||||
}
|
||||
let ox = cx * cs;
|
||||
let oy = cy * cs;
|
||||
let chunk = match grid.get_chunk_mut(cx, cy) {
|
||||
@@ -712,9 +709,6 @@ impl CellularAutomaton {
|
||||
};
|
||||
let w = max_x - min_x + 1;
|
||||
let h = max_y - min_y + 1;
|
||||
if w * h > 2048 {
|
||||
continue;
|
||||
}
|
||||
let ox = cx * cs;
|
||||
let oy = cy * cs;
|
||||
let chunk = match grid.get_chunk_mut(cx, cy) {
|
||||
@@ -768,6 +762,172 @@ impl CellularAutomaton {
|
||||
}
|
||||
}
|
||||
|
||||
fn electricity_step(
|
||||
&mut self,
|
||||
grid: &mut ChunkedGrid,
|
||||
active: &[(i32, i32)],
|
||||
pre_dirty: &std::collections::HashMap<(i32, i32), (i32, i32, i32, i32)>,
|
||||
) {
|
||||
let reg = crate::world::material::MaterialRegistry::instance();
|
||||
let cs = CHUNK_SIZE as i32;
|
||||
for &(cx, cy) in active {
|
||||
let dirty = grid.get_chunk_dirty(cx, cy);
|
||||
let pre = pre_dirty.get(&(cx, cy)).copied();
|
||||
let (min_x, min_y, max_x, max_y) = match (dirty, pre) {
|
||||
(Some(d), Some(p)) => (d.0.min(p.0), d.1.min(p.1), d.2.max(p.2), d.3.max(p.3)),
|
||||
(Some(d), None) => d,
|
||||
(None, Some(p)) => p,
|
||||
(None, None) => continue,
|
||||
};
|
||||
let w = max_x - min_x + 1;
|
||||
let h = max_y - min_y + 1;
|
||||
let ox = cx * cs;
|
||||
let oy = cy * cs;
|
||||
let chunk = match grid.get_chunk_mut(cx, cy) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
let has_elec = chunk.electricity.iter().any(|&e| e > 0);
|
||||
if !has_elec {
|
||||
continue;
|
||||
}
|
||||
let mut changes: Vec<(usize, u8)> = Vec::new();
|
||||
for ly in 0..h {
|
||||
let wy = min_y + ly - oy;
|
||||
if wy < 0 || wy >= cs {
|
||||
continue;
|
||||
}
|
||||
for lx in 0..w {
|
||||
let wx = min_x + lx - ox;
|
||||
if wx < 0 || wx >= cs {
|
||||
continue;
|
||||
}
|
||||
let idx = (wy as usize) * CHUNK_SIZE + (wx as usize);
|
||||
let cur = chunk.electricity[idx];
|
||||
if cur == 0 {
|
||||
continue;
|
||||
}
|
||||
let mat = reg.get(chunk.cells[idx].material);
|
||||
if !mat.conductive && cur < 200 {
|
||||
changes.push((idx, cur.saturating_sub(20)));
|
||||
continue;
|
||||
}
|
||||
let mut spread = 0u8;
|
||||
for &(dx, dy) in &NEIGHBORS4 {
|
||||
let nx = wx + dx;
|
||||
let ny = wy + dy;
|
||||
if nx < 0 || nx >= cs || ny < 0 || ny >= cs {
|
||||
continue;
|
||||
}
|
||||
let ni = (ny as usize) * CHUNK_SIZE + (nx as usize);
|
||||
let n_mat = reg.get(chunk.cells[ni].material);
|
||||
let n_elec = chunk.electricity[ni];
|
||||
if n_mat.conductive && n_elec < cur {
|
||||
let diff = (cur - n_elec) / 4;
|
||||
if diff > 0 {
|
||||
changes.push((ni, n_elec + diff));
|
||||
spread += diff;
|
||||
}
|
||||
}
|
||||
}
|
||||
if spread > 0 {
|
||||
changes.push((idx, cur.saturating_sub(spread)));
|
||||
} else {
|
||||
changes.push((idx, cur.saturating_sub(5)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (idx, val) in changes {
|
||||
chunk.electricity[idx] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn structural_step(&mut self, grid: &mut ChunkedGrid, active: &[(i32, i32)]) {
|
||||
if !grid.is_infinite() {
|
||||
return;
|
||||
}
|
||||
if self.tick % 30 != 0 {
|
||||
return;
|
||||
}
|
||||
let reg = crate::world::material::MaterialRegistry::instance();
|
||||
let cs = CHUNK_SIZE as i32;
|
||||
for &(cx, cy) in active {
|
||||
let dirty = match grid.get_chunk_dirty(cx, cy) {
|
||||
Some(d) => d,
|
||||
None => continue,
|
||||
};
|
||||
let (min_x, min_y, max_x, max_y) = dirty;
|
||||
let ox = cx * cs;
|
||||
let oy = cy * cs;
|
||||
let chunk = match grid.get_chunk_mut(cx, cy) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
let mut to_collapse: Vec<(usize, i32, i32)> = Vec::new();
|
||||
for ly in 0..(max_y - min_y + 1) {
|
||||
let wy = min_y + ly - oy;
|
||||
if wy < 0 || wy >= cs {
|
||||
continue;
|
||||
}
|
||||
for lx in 0..(max_x - min_x + 1) {
|
||||
let wx = min_x + lx - ox;
|
||||
if wx < 0 || wx >= cs {
|
||||
continue;
|
||||
}
|
||||
let idx = (wy as usize) * CHUNK_SIZE + (wx as usize);
|
||||
let mat = reg.get(chunk.cells[idx].material);
|
||||
if !mat.structural {
|
||||
continue;
|
||||
}
|
||||
let below = if wy + 1 < cs {
|
||||
let bi = ((wy + 1) as usize) * CHUNK_SIZE + (wx as usize);
|
||||
chunk.cells[bi].is_solid()
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if below {
|
||||
continue;
|
||||
}
|
||||
let below_left = if wy + 1 < cs && wx > 0 {
|
||||
let bi = ((wy + 1) as usize) * CHUNK_SIZE + ((wx - 1) as usize);
|
||||
chunk.cells[bi].is_solid()
|
||||
} else {
|
||||
true
|
||||
};
|
||||
let below_right = if wy + 1 < cs && wx + 1 < cs {
|
||||
let bi = ((wy + 1) as usize) * CHUNK_SIZE + ((wx + 1) as usize);
|
||||
chunk.cells[bi].is_solid()
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if below_left || below_right {
|
||||
continue;
|
||||
}
|
||||
let left = if wx > 0 {
|
||||
chunk.cells[(wy as usize) * CHUNK_SIZE + ((wx - 1) as usize)].is_solid()
|
||||
} else {
|
||||
true
|
||||
};
|
||||
let right = if wx + 1 < cs {
|
||||
chunk.cells[(wy as usize) * CHUNK_SIZE + ((wx + 1) as usize)].is_solid()
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if !left && !right {
|
||||
to_collapse.push((idx, wx, wy));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (idx, wx, wy) in to_collapse {
|
||||
chunk.cells[idx] = Cell::new(MaterialId::Sand);
|
||||
chunk.temps[idx] = 20.0;
|
||||
chunk.modified = true;
|
||||
chunk.mark_dirty(wx, wy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn light_step(&mut self, grid: &mut ChunkedGrid, active: &[(i32, i32)]) {
|
||||
self.light_tick += 1;
|
||||
if self.light_tick % 20 != 0 {
|
||||
|
||||
@@ -9,6 +9,7 @@ pub struct Chunk {
|
||||
pub gas_type: Vec<u8>,
|
||||
pub gas_density: Vec<u8>,
|
||||
pub light: Vec<[u8; 3]>,
|
||||
pub electricity: Vec<u8>,
|
||||
pub active: bool,
|
||||
pub modified: bool,
|
||||
pub was_modified: bool,
|
||||
@@ -28,6 +29,7 @@ impl Chunk {
|
||||
gas_type: vec![0; CHUNK_AREA],
|
||||
gas_density: vec![0; CHUNK_AREA],
|
||||
light: vec![[0, 0, 0]; CHUNK_AREA],
|
||||
electricity: vec![0; CHUNK_AREA],
|
||||
active: false,
|
||||
modified: false,
|
||||
was_modified: false,
|
||||
@@ -142,6 +144,21 @@ impl Chunk {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get_electricity(&self, x: i32, y: i32) -> u8 {
|
||||
if !Self::in_bounds(x, y) {
|
||||
return 0;
|
||||
}
|
||||
self.electricity[Self::idx(x, y)]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_electricity(&mut self, x: i32, y: i32, val: u8) {
|
||||
if Self::in_bounds(x, y) {
|
||||
self.electricity[Self::idx(x, y)] = val;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.cells.iter().all(|c| c.is_empty())
|
||||
}
|
||||
|
||||
+50
-28
@@ -437,12 +437,6 @@ impl ChunkedGrid {
|
||||
}
|
||||
let (cx1, cy1, lx1, ly1) = self.chunk_at(x1, y1);
|
||||
let (cx2, cy2, lx2, ly2) = self.chunk_at(x2, y2);
|
||||
let t1 = self.get_temp(x1, y1);
|
||||
let t2 = self.get_temp(x2, y2);
|
||||
let p1 = self.get_pressure(x1, y1);
|
||||
let p2 = self.get_pressure(x2, y2);
|
||||
let g1 = self.get_gas(x1, y1);
|
||||
let g2 = self.get_gas(x2, y2);
|
||||
if self.is_bounded() {
|
||||
let idx1 = self.chunk_index(cx1, cy1);
|
||||
let idx2 = self.chunk_index(cx2, cy2);
|
||||
@@ -456,6 +450,7 @@ impl ChunkedGrid {
|
||||
chunk.pressure.swap(ci1, ci2);
|
||||
chunk.gas_type.swap(ci1, ci2);
|
||||
chunk.gas_density.swap(ci1, ci2);
|
||||
chunk.electricity.swap(ci1, ci2);
|
||||
chunk.cells[ci2].updated_this_tick = true;
|
||||
chunk.modified = true;
|
||||
chunk.mark_dirty(lx1, ly1);
|
||||
@@ -463,28 +458,38 @@ impl ChunkedGrid {
|
||||
}
|
||||
}
|
||||
(Some(i1), Some(i2)) => {
|
||||
let c1 = self.get(x1, y1);
|
||||
let c2 = self.get(x2, y2);
|
||||
if let Some(chunk) = self.chunks_vec.get_mut(i1) {
|
||||
let ci = (ly1 as usize) * self.chunk_size + (lx1 as usize);
|
||||
chunk.cells[ci] = c2;
|
||||
chunk.temps[ci] = t2;
|
||||
chunk.pressure[ci] = p2;
|
||||
chunk.gas_type[ci] = g2.0;
|
||||
chunk.gas_density[ci] = g2.1;
|
||||
chunk.cells[ci].updated_this_tick = true;
|
||||
chunk.modified = true;
|
||||
chunk.mark_dirty(lx1, ly1);
|
||||
}
|
||||
if let Some(chunk) = self.chunks_vec.get_mut(i2) {
|
||||
let ci = (ly2 as usize) * self.chunk_size + (lx2 as usize);
|
||||
chunk.cells[ci] = c1;
|
||||
chunk.temps[ci] = t1;
|
||||
chunk.pressure[ci] = p1;
|
||||
chunk.gas_type[ci] = g1.0;
|
||||
chunk.gas_density[ci] = g1.1;
|
||||
chunk.modified = true;
|
||||
chunk.mark_dirty(lx2, ly2);
|
||||
let (lo, hi) = if i1 < i2 { (i1, i2) } else { (i2, i1) };
|
||||
let (left, right) = self.chunks_vec.split_at_mut(hi);
|
||||
let (ch_lo, ch_hi) = if i1 < i2 {
|
||||
(left.get_mut(lo), right.first_mut())
|
||||
} else {
|
||||
(right.first_mut(), left.get_mut(lo))
|
||||
};
|
||||
if let (Some(ch1), Some(ch2)) = (ch_lo, ch_hi) {
|
||||
let ci1 = (ly1 as usize) * self.chunk_size + (lx1 as usize);
|
||||
let ci2 = (ly2 as usize) * self.chunk_size + (lx2 as usize);
|
||||
let (c1, t1, p1, gt1, gd1) = (
|
||||
ch1.cells[ci1],
|
||||
ch1.temps[ci1],
|
||||
ch1.pressure[ci1],
|
||||
ch1.gas_type[ci1],
|
||||
ch1.gas_density[ci1],
|
||||
);
|
||||
ch1.cells[ci1] = ch2.cells[ci2];
|
||||
ch1.temps[ci1] = ch2.temps[ci2];
|
||||
ch1.pressure[ci1] = ch2.pressure[ci2];
|
||||
ch1.gas_type[ci1] = ch2.gas_type[ci2];
|
||||
ch1.gas_density[ci1] = ch2.gas_density[ci2];
|
||||
ch1.cells[ci1].updated_this_tick = true;
|
||||
ch1.modified = true;
|
||||
ch1.mark_dirty(lx1, ly1);
|
||||
ch2.cells[ci2] = c1;
|
||||
ch2.temps[ci2] = t1;
|
||||
ch2.pressure[ci2] = p1;
|
||||
ch2.gas_type[ci2] = gt1;
|
||||
ch2.gas_density[ci2] = gd1;
|
||||
ch2.modified = true;
|
||||
ch2.mark_dirty(lx2, ly2);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -499,6 +504,7 @@ impl ChunkedGrid {
|
||||
chunk.pressure.swap(i1, i2);
|
||||
chunk.gas_type.swap(i1, i2);
|
||||
chunk.gas_density.swap(i1, i2);
|
||||
chunk.electricity.swap(i1, i2);
|
||||
chunk.cells[i2].updated_this_tick = true;
|
||||
chunk.modified = true;
|
||||
chunk.mark_dirty(lx1, ly1);
|
||||
@@ -506,6 +512,12 @@ impl ChunkedGrid {
|
||||
} else {
|
||||
let c1 = self.get(x1, y1);
|
||||
let c2 = self.get(x2, y2);
|
||||
let t1 = self.get_temp(x1, y1);
|
||||
let t2 = self.get_temp(x2, y2);
|
||||
let p1 = self.get_pressure(x1, y1);
|
||||
let p2 = self.get_pressure(x2, y2);
|
||||
let g1 = self.get_gas(x1, y1);
|
||||
let g2 = self.get_gas(x2, y2);
|
||||
self.set(x1, y1, c2);
|
||||
self.set_temp(x1, y1, t2);
|
||||
self.set_pressure(x1, y1, p2);
|
||||
@@ -850,6 +862,9 @@ impl ChunkedGrid {
|
||||
for l in &chunk.light {
|
||||
bytes.extend_from_slice(&l[..]);
|
||||
}
|
||||
for &e in &chunk.electricity {
|
||||
bytes.push(e);
|
||||
}
|
||||
let dir = Path::new(path);
|
||||
if let Some(parent) = dir.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
@@ -909,6 +924,13 @@ impl ChunkedGrid {
|
||||
chunk.light[i] = [data[off], data[off + 1], data[off + 2]];
|
||||
off += 3;
|
||||
}
|
||||
for i in 0..area {
|
||||
if off >= data.len() {
|
||||
break;
|
||||
}
|
||||
chunk.electricity[i] = data[off];
|
||||
off += 1;
|
||||
}
|
||||
} else {
|
||||
let (x0, y0, x1, y1) = bounds;
|
||||
let w = (x1 - x0) as usize;
|
||||
|
||||
@@ -13,6 +13,9 @@ pub struct Material {
|
||||
pub ignition_temp: f32,
|
||||
pub melt_temp: f32,
|
||||
pub heat_conductivity: f32,
|
||||
pub conductive: bool,
|
||||
pub conductivity: f32,
|
||||
pub structural: bool,
|
||||
pub color_fg: (u8, u8, u8),
|
||||
pub color_bg: (u8, u8, u8),
|
||||
pub display_char: char,
|
||||
@@ -40,6 +43,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.0,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (15, 15, 20),
|
||||
color_bg: (10, 10, 15),
|
||||
display_char: ' ',
|
||||
@@ -56,6 +62,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: 1700.0,
|
||||
heat_conductivity: 0.2,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (218, 178, 90),
|
||||
color_bg: (60, 50, 30),
|
||||
display_char: '.',
|
||||
@@ -72,6 +81,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: 0.0,
|
||||
heat_conductivity: 0.4,
|
||||
conductive: true,
|
||||
conductivity: 0.6,
|
||||
structural: false,
|
||||
color_fg: (64, 128, 220),
|
||||
color_bg: (20, 40, 80),
|
||||
display_char: '~',
|
||||
@@ -88,6 +100,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: 1200.0,
|
||||
heat_conductivity: 0.3,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: true,
|
||||
color_fg: (120, 120, 130),
|
||||
color_bg: (40, 40, 50),
|
||||
display_char: '#',
|
||||
@@ -104,6 +119,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.05,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (255, 80, 20),
|
||||
color_bg: (120, 20, 0),
|
||||
display_char: '#',
|
||||
@@ -120,6 +138,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: 300.0,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.1,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: true,
|
||||
color_fg: (140, 90, 50),
|
||||
color_bg: (50, 30, 20),
|
||||
display_char: 'T',
|
||||
@@ -136,6 +157,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: 200.0,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.15,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (180, 50, 50),
|
||||
color_bg: (60, 15, 15),
|
||||
display_char: '%',
|
||||
@@ -152,6 +176,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.1,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (220, 210, 190),
|
||||
color_bg: (60, 55, 50),
|
||||
display_char: '`',
|
||||
@@ -168,6 +195,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.2,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (200, 200, 220),
|
||||
color_bg: (30, 30, 40),
|
||||
display_char: '~',
|
||||
@@ -184,6 +214,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.6,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (255, 160, 40),
|
||||
color_bg: (100, 30, 0),
|
||||
display_char: '^',
|
||||
@@ -200,6 +233,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.3,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (100, 255, 60),
|
||||
color_bg: (20, 60, 10),
|
||||
display_char: '~',
|
||||
@@ -216,6 +252,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.1,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (100, 100, 100),
|
||||
color_bg: (20, 20, 20),
|
||||
display_char: '*',
|
||||
@@ -232,6 +271,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: 250.0,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.1,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (80, 200, 60),
|
||||
color_bg: (20, 50, 15),
|
||||
display_char: '"',
|
||||
@@ -248,6 +290,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.2,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (100, 70, 50),
|
||||
color_bg: (40, 30, 20),
|
||||
display_char: ':',
|
||||
@@ -264,6 +309,9 @@ impl MaterialRegistry {
|
||||
ignition_temp: f32::INFINITY,
|
||||
melt_temp: f32::INFINITY,
|
||||
heat_conductivity: 0.0,
|
||||
conductive: false,
|
||||
conductivity: 0.0,
|
||||
structural: false,
|
||||
color_fg: (255, 220, 80),
|
||||
color_bg: (60, 50, 20),
|
||||
display_char: '>',
|
||||
|
||||
@@ -1182,18 +1182,18 @@ impl<'a> WorldGenerator<'a> {
|
||||
}
|
||||
|
||||
fn surface_height(&mut self, x: i32, h: i32) -> i32 {
|
||||
let base = (h - 3) - ((x as f32 * 0.08).sin() * scf(4.0)) as i32;
|
||||
let detail = ((x as f32 * 0.23).sin() * scf(2.0)) as i32;
|
||||
let micro = ((x as f32 * 0.57).sin() * scf(1.0)) as i32;
|
||||
let base = (h - 3) - ((x as f32 * 0.012).sin() * scf(3.0)) as i32;
|
||||
let detail = ((x as f32 * 0.04).sin() * scf(1.5)) as i32;
|
||||
let micro = ((x as f32 * 0.11).sin() * scf(0.5)) as i32;
|
||||
(base + detail + micro).max(10).min(h - 3)
|
||||
}
|
||||
|
||||
const SURFACE_BASE_Y: i32 = 120;
|
||||
|
||||
fn surface_height_world(&mut self, x: i32) -> i32 {
|
||||
let base = Self::SURFACE_BASE_Y - ((x as f32 * 0.08).sin() * scf(4.0)) as i32;
|
||||
let detail = ((x as f32 * 0.23).sin() * scf(2.0)) as i32;
|
||||
let micro = ((x as f32 * 0.57).sin() * scf(1.0)) as i32;
|
||||
let base = Self::SURFACE_BASE_Y - ((x as f32 * 0.012).sin() * scf(3.0)) as i32;
|
||||
let detail = ((x as f32 * 0.04).sin() * scf(1.5)) as i32;
|
||||
let micro = ((x as f32 * 0.11).sin() * scf(0.5)) as i32;
|
||||
(base + detail + micro).max(10)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user