Compare commits

10 Commits
Author SHA1 Message Date
Emil 0d54d987ac feat: explosions, particle system, electricity, structural integrity
Explosions:
- trigger_explosion(x, y, radius, damage) in Game
- Destroys non-static solids, ignites empty cells, pressure spike
- Knockback to entities (velocity away from center + upward pop)
- Particle burst on explosion (fire + smoke + debris)

Particle system:
- ParticleManager in src/physics/particle.rs (capacity 2000)
- CPU-side: position, velocity, life, color, gravity, size
- spawn_burst() for radial bursts, individual spawn() for ambient
- Rendered as third draw call in graphics.rs (is_ui: 2 in shader)
- ParticleInstance = ColorInstance compatible (12 bytes)
- upload_particles() via GpuRenderer trait
- Ambient: fire sparks, lava bubbles — scanned around player

Electricity:
- chunk.electricity: Vec<u8> (4KB/chunk) — current strength 0-255
- Material.conductive + conductivity fields (water = conductive)
- electricity_step() in CA: propagates current through conductive
  neighbors, decays over time, skips non-conductive cells
- cells_swap swaps electricity, serialization saves/loads it

Structural integrity:
- Material.structural: bool (stone, wood = true)
- structural_step() in CA (every 30 ticks, infinite mode only):
  checks if structural cells have support (below, below-left, below-right)
  converts unsupported to Sand (falls)
- Only active in infinite mode to avoid breaking test grids

Graphics shader: is_ui==2 branch renders particles in world-space
All 185 tests + 14 scenarios pass. 143 FPS benchmark.
2026-06-22 20:13:07 +03:00
Emil 4a89612081 revert: remove camera zoom, keep day/night + tooltip + crosshair
Zoom caused buffer reallocation issues and cursor misalignment.
Removed: char_size field, adjust_zoom(), MIN/MAX_CHAR, zoom input.
Kept: day/night cycle, tooltip, crosshair, cell_pixel_size() (returns constant 8).
2026-06-22 19:45:09 +03:00
Emil 69e3a6578e fix: zoom crash — check_resize after zoom to reallocate instance buffer
adjust_zoom now calls check_resize() which recalculates grid_w/grid_h
and reallocates the GPU instance buffer to match the new grid size.
Previously zoom changed grid_w/h but the buffer stayed at the old size,
causing index-out-of-bounds panic.
2026-06-22 19:37:59 +03:00
Emil 43085772cd fix: mouse coordinate conversion uses renderer cell_pixel_size
- Replaced hardcoded 1600/900 screen size with renderer.cell_pixel_size()
- Mouse world pos: cam + (screen_pos / cell_px) — correct for any zoom level
- Mouse UI pos: (screen_pos / cell_px) * UI_SCALE — correct for UI coordinate space
- Shoot direction: player_screen uses cell_px instead of hardcoded 8.0
- Added cell_pixel_size() to GpuRenderer trait and both renderers
2026-06-22 19:33:57 +03:00
Emil d6bd77c133 feat: camera zoom, day/night cycle, tooltip, crosshair
- Camera zoom: +/- keys adjust cell size 4-24px, grid_w/h recalculated
  Works in both graphics and ascii renderers via GpuRenderer trait
- Day/night cycle: ambient_light_at_tick() modulates brightness by sine
  Applied to terminal renderer (CPU lighting path)
- Tooltip: shows material name + temperature at mouse cursor position
  Tracked via CursorMoved event, world coords computed from screen pos
- Crosshair: dotted line from player to mouse cursor, fading alpha
  Shows aiming trajectory for projectile combat

All 185 tests + 14 scenarios pass. 162 FPS benchmark.
2026-06-22 17:54:20 +03:00
Emil f09c44f7a0 docs: update AGENTS.md and PLAN.md with all recent changes
AGENTS.md:
- Multi-layer world (temp/gas/pressure/light) fully documented
- Audio system (15 sounds, rodio, overlapping playback)
- UI layer (flat Vec, procedurally scalable font)
- Uniform tick rate (all loaded chunks active every tick)
- Fire propagation (updated_this_tick prevents chain reactions)
- Surface terrain (low-frequency noise, wide rolling hills)
- Chunk streaming (radius 2, all active, save every 10 ticks)
- Dirty rects (no size limit, full processing)
- cells_swap (split_at_mut optimization)
- Module layout updated with audio/mod.rs
- AI spectrums: gas + pressure added

PLAN.md:
- Phase 1 (combat): all checked off
- Phase 1.5 (UI): most items checked off
- Phase 3 (RPG): stats/inventory/XP/status effects checked off
- Phase 5: audio checked off
- Phase 6 (multi-layer): marked DONE
- Milestones updated through 0.55
- Numbers: 9000 lines, 185 tests, 130-150 FPS
2026-06-22 17:16:47 +03:00
Emil 22de8bc42e fix: wider surface terrain — lower noise frequencies for rolling hills
base freq 0.08→0.012 (~260 cell wide hills, was ~40)
detail freq 0.23→0.04 (~78 cell features, was ~14)
micro freq 0.57→0.11 (~28 cell ripples, was ~6)
amplitudes reduced: base ±20→±15, detail ±10→±7.5, micro ±5→±2.5

Result: wide rolling hills instead of narrow sharp peaks.
2026-06-22 17:05:38 +03:00
Emil 423000e576 fix: uniform tick rate — remove dirty rect limits, optimize cells_swap
Removed all 2048 dirty rect limits:
- CA step: process all dirty cells, no subdivision/skip
- heat_transfer/gas_step/pressure_step: process all dirty cells, no skip
All chunks now simulate at the same rate — no frozen or partial cells.

Optimized cells_swap for bounded mode cross-chunk case:
- split_at_mut for simultaneous access to two chunks
- Eliminated 6 redundant get_temp/get_pressure/get_gas calls
  (read directly from chunk arrays instead of HashMap lookups)

Results: 131 FPS surface, 152 FPS caves, 142 FPS dungeon
p99 16ms — stable across all biomes
2026-06-22 16:58:25 +03:00
Emil 5ca8b7e917 fix: all loaded chunks now simulate at same tick rate
- stream_chunks: generate all chunks in radius 2 immediately (was gen_budget=2)
  No more half-generated chunks in active area
- update_active_chunks: activate ALL loaded chunks (was radius 2 around player)
  Physics consistent across all loaded chunks — no frozen boundaries
- streaming radius 3→2 (5x5=25 chunks, all active and simulated)
- 131 FPS surface, 138 FPS caves, p99 17ms
2026-06-22 16:45:24 +03:00
Emil 01a350aebd fix: fire chain reaction — newly ignited cells now have updated_this_tick=true
Root cause: when update_fire ignited a neighbor via grid.set(), the new Fire
cell had updated_this_tick=false. If the dirty rect iteration hadn't reached
that cell yet, it was processed in the same tick, igniting ITS neighbors,
creating exponential spread within a single tick.

Fix: set updated_this_tick=true on all newly created Fire cells so they
wait until the next tick before spreading.

Applied to:
- update_fire: igniting flammable neighbors
- lava_interact: lava igniting wood/grass/flesh
- update_flesh: flesh turning to fire from heat
- update_grass: grass turning to fire from heat
- update_water: water turning to steam from heat
- projectile.rs: fireball impact igniting cells

Result: fire spreads 1 cell per tick (linear, not exponential).
171 FPS avg, p99 14ms — no lag when spamming fireballs.
2026-06-22 16:35:17 +03:00
20 changed files with 885 additions and 169 deletions
+20 -12
View File
@@ -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
+62 -50
View File
@@ -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 |
+22 -10
View File
@@ -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
View File
@@ -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
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

+186 -28
View File
@@ -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
View File
@@ -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
View File
@@ -1,3 +1,4 @@
pub mod collision;
pub mod particle;
pub mod projectile;
pub mod verlet;
+113
View File
@@ -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)
}
}
+4 -1
View File
@@ -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);
}
+116
View File
@@ -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);
+9
View File
@@ -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::*;
+6
View File
@@ -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 {
-1
View File
@@ -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
View File
@@ -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 {
+17
View File
@@ -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
View File
@@ -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;
+48
View File
@@ -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: '>',
+6 -6
View File
@@ -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)
}