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: - **AI observation** uses multi-spectrum ASCII layers via pipe protocol:
- `materials` — material type per cell - `materials` — material type per cell
- `temperature` — heat levels encoded as characters - `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 - `entities` — entity positions and types only
- `density` — material density visualization - `density` — material density visualization
- `velocity` — entity movement speed and CA activity - `velocity` — entity movement speed and CA activity
- `gas` — gas type + density per cell (NEW)
- `pressure` — pressure levels per cell (NEW)
## Tape System ## Tape System
@@ -44,7 +46,7 @@ Each tape frame contains:
- Tick number, depth, kills, score - Tick number, depth, kills, score
- Camera position - Camera position
- Player HP, position, entity count - Player HP, position, entity count
- All 6 spectrum layers as ASCII text - All 8 spectrum layers as ASCII text
Pipe protocol spectrum commands: Pipe protocol spectrum commands:
- `{"cmd":"get_spectrum","spectrum":"materials","w":80,"h":25}` — single spectrum - `{"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: **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. - **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. 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()`. 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. **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). **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`. **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. **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. **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) - `2 <= cy < 6` — caves (stone, CA-carved empty space, lava/water/acid pools)
- `cy >= 6` — dungeon (BSP rooms, corridors, stone walls) - `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. **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. - **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. - **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. - **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 ## Module Layout
@@ -190,12 +197,13 @@ src/
lib.rs # pub mod declarations lib.rs # pub mod declarations
game.rs # Game struct, world gen, fixed_update, collision, combat, slime AI game.rs # Game struct, world gen, fixed_update, collision, combat, slime AI
input.rs # Terminal input (crossterm, InputHandler) — terminal mode only 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 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 entity/ # Entity (rigid/ragdoll), EntityManager, Player, BodyTemplate, Item, ItemManager
render/ # terminal.rs, vulkan.rs (ASCII), graphics.rs (cells), lighting.rs, window_input.rs render/ # terminal.rs, vulkan.rs (ASCII), graphics.rs (cells), lighting.rs, window_input.rs, capture.rs
ai/ # GameSession, AiAction, pipe protocol, replay, scenarios ai/ # GameSession, AiAction, pipe protocol, replay, scenarios, spectrum
ui/ # UiLayer, HUD, messages, damage numbers, edge indicators ui/ # UiLayer (flat Vec), HUD, messages, damage numbers, edge indicators, scalable font
``` ```
## PLAN.md ## PLAN.md
+62 -50
View File
@@ -10,50 +10,60 @@
| System | Status | Details | | System | Status | Details |
|--------|--------|---------| |--------|--------|---------|
| Cellular automaton | Working | 14 materials: sand, water, stone, lava, wood, flesh, bone, steam, fire, acid, smoke, grass, dirt, empty | | 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 | | Ragdoll corpses | Working | Verlet constraints, death = rigid→ragdoll transition with inherited velocity |
| Terminal renderer | Working | Full terminal size, ANSI truecolor, diff-based rendering | | 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 | | 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 | | AI pipe protocol | Working | JSON stdin/stdout, 16 commands, full state export, 8 spectrums (materials/temp/light/entities/density/velocity/gas/pressure) |
| Test framework | Working | 109 Rust tests + 14 JSON scenarios, all passing | | 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 | | 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 | | 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 | | Adaptive viewport | Working | Window resize → more/fewer cells visible, cells stay 8x8 pixels |
| Per-cell color (reality layer) | Working | Each cell stores fg/bg color inline, no registry lookup in render path | | 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 ### 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 - Bounded mode: `Vec<Chunk>` for 250x250 test/AI grids
- Infinite mode: `HashMap<(i64, i64), Chunk>` for continuous 12500x12500 cell worlds - Infinite mode: `HashMap<(i64,i64), Chunk>` for continuous worlds
- 64x64 chunks, dirty rects, active flags, per-chunk persistence - 64x64 chunks, dirty rects, all loaded chunks active every tick
- Chunk streaming: load/generate around player, save/unload distant chunks - 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: Three entity types:
1. Cellular — materials in grid, per-cell CA rules 1. Cellular — materials in grid, per-cell CA rules
2. Rigid — alive entities, AABB collider, slope stepping, single velocity 2. Rigid — alive entities, AABB collider, slope stepping, single velocity
3. Ragdoll — corpses, loose Verlet bodies, independent physics 3. Ragdoll — corpses, loose Verlet bodies, independent physics
Game loop: fixed 60Hz timestep Game loop: fixed 60Hz timestep, uniform across all chunks
Physics tick: CA step → rigid update (slope step) → ragdoll update → damage Physics tick: CA step → heat_transfer → gas_step → pressure_step → light_step
Render: terminal (ANSI) / ascii (Vulkan glyphs) / graphics (Vulkan cells) / pipe (JSON) / headless (file) → entity updates → combat → status effects → gas damage
Render: terminal (ANSI) / ascii (Vulkan glyphs) / graphics (Vulkan cells) / pipe (JSON)
Three render modes: Audio: 15 embedded WAV sounds via rodio, overlapping playback, ambient scanning
--mode terminal → pure ANSI ASCII in terminal UI: flat Vec array, procedurally scalable font, non-destructive overlay
--mode ascii → Vulkan window with ASCII characters (glyph atlas)
--mode graphics → Vulkan window with colored cells (no glyphs, material base colors)
``` ```
### Numbers ### Numbers
- ~6700 lines Rust - ~9000 lines Rust
- 171 integration tests, 14 JSON scenarios - 185 integration tests, 14 JSON scenarios
- 40+ git commits - 50+ git commits
- 0 compiler warnings (excluding winit deprecation notices) - 0 compiler warnings (excluding winit deprecation notices)
- Cross-platform: Windows/Linux/macOS - 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] Knockback: damage applies velocity impulse to rigid body center
- [x] Goblin AI: move toward player, attack when adjacent - [x] Goblin AI: move toward player, attack when adjacent
- [x] Slime AI: jump toward player, contact damage - [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) - Player shoots with directional input (mouse aim or movement-direction)
- Projectile = small AABB, velocity, damage, lifetime - Projectile = small AABB, velocity, damage, lifetime
- On hit with entity: deal damage, destroy projectile - On hit with entity: deal damage, destroy projectile
- On hit with solid cell: stop/destroy - On hit with solid cell: stop/destroy
- Fireball: ignites materials it touches (Noita-style material interaction) - Fireball: ignites materials it touches (Noita-style material interaction)
- Magic bolt: pure damage, no material effect - Magic bolt: pure damage, no material effect
- [ ] Health bars in render (colored indicator above entity) - [x] Health bars in render (colored indicator above entity)
- [ ] Death → ragdoll → corpse decomposition (flesh cells drop into grid over time) - [x] Death → ragdoll → corpse decomposition (flesh cells drop into grid over time)
- [ ] Material interaction with entities: entity walks through fire → ignites, acid → dissolves - [x] Material interaction with entities: entity walks through fire → ignites, acid → dissolves
- [ ] Goblin AI: flee when low HP - [x] Goblin AI: flee when low HP
**Tests needed:** **Tests needed:**
- Projectile travels and deals damage on hit - Projectile travels and deals damage on hit
@@ -106,34 +116,34 @@ Render pipeline per frame:
3. Present to screen 3. Present to screen
``` ```
- [ ] `UiLayer` struct: sparse map of (screen_x, screen_y) → (char, fg_color, bg_color) - [x] `UiLayer` struct: flat `Vec<Option<UiCell>>` array for O(1) cell access
- UI elements write to this map, not to the grid - 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 - Renderer composites: if UiLayer has a cell at (x,y), it overrides the world cell visually
- World state is never modified by UI - 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 - ████░░░░ style, colored green→yellow→red by HP ratio
- Positioned relative to player's screen position, scrolls with camera - Positioned relative to player's screen position, scrolls with camera
- [ ] Entity labels: small text above/below entities (name, level for RPG) - [x] Entity labels: small text above/below entities (name, level for RPG)
- [ ] Status effect icons: burning 🔥, poisoned, frozen — shown next to entity - [x] Status effect icons: burning, poisoned, frozen — shown next to entity
- [ ] HUD bar (bottom of screen, non-destructive): - [x] HUD bar (bottom of screen, non-destructive):
- HP: ████████░░ 80/100 - HP: ████████░░ 80/100
- Material brush: [Sand] (current selected) - Material brush: [Sand] (current selected)
- Tick: 1234 Depth: 1 - Tick: 1234 Depth: 1
- FPS counter (debug mode) - FPS counter (debug mode)
- [ ] Tooltip on hover: when cursor is over a cell, show material name + temperature - [ ] 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) - 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 - List of items, selected highlight, weight/value display
- Opens/closes without affecting simulation - Opens/closes without affecting simulation
- [ ] Menu system (pause, settings, save/load): full-screen overlay with border - [ ] Menu system (pause, settings, save/load): full-screen overlay with border
- Game loop pauses (or continues in background), UI captures input - 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 - Each minimap cell = 5x5 world cells, averaged material color
- Player position marker, entity dots - Player position marker, entity dots
- [ ] Crosshair/targeting: when aiming projectiles, shows trajectory preview - [ ] Crosshair/targeting: when aiming projectiles, shows trajectory preview
- [ ] Damage numbers: floating text above entities when hit, rises and fades - [x] Damage numbers: floating text above entities when hit, rises and fades
- [ ] Screen-edge indicators: arrows pointing to off-screen entities of interest - [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. **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 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** **Goal: character progression, inventory, abilities**
- [ ] Stats: strength, agility, toughness, willpower — affect damage, speed, HP, etc. - [x] Stats: strength, agility, toughness, willpower — affect damage, speed, HP, etc.
- [ ] Inventory system: items as data structs, pick up by walking over, drop with key - [x] Inventory system: items as data structs, pick up by walking over, drop with key
- [ ] Equipment: weapon affects melee damage/range, armor affects damage reduction - [x] Equipment: weapon affects melee damage/range, armor affects damage reduction
- [ ] Items in world: weapons, potions, scrolls, food — rendered as distinct ASCII chars - [x] Items in world: weapons, potions, scrolls, food — rendered as distinct ASCII chars
- [ ] Mutations (Caves of Qud style): modify entity properties - [ ] Mutations (Caves of Qud style): modify entity properties
- "Silicon skin" → entity material becomes Stone, immune to acid - "Silicon skin" → entity material becomes Stone, immune to acid
- "Flame body" → entity emits fire cells, immune to fire - "Flame body" → entity emits fire cells, immune to fire
- "Liquid form" → entity can squeeze through 1-cell gaps - "Liquid form" → entity can squeeze through 1-cell gaps
- "Multiple arms" → extra attack, can hold more items - "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) - [ ] 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 - [ ] Dialogue: talk to NPCs, simple text tree
**Tests needed:** **Tests needed:**
@@ -277,17 +287,17 @@ Two distinct render modes, both GPU-accelerated via Vulkan:
- [ ] Boss entity: large rigid body (10x10), multiple attack patterns - [ ] Boss entity: large rigid body (10x10), multiple attack patterns
- [ ] Books/readable items: lore text displayed in terminal - [ ] Books/readable items: lore text displayed in terminal
- [ ] Crafting: combine materials to create new ones (water + dirt = mud) - [ ] 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) - [ ] Save/load: full game state to file (grid + entities + player + inventory)
- [ ] Death screen: stats summary, cause of death - [ ] Death screen: stats summary, cause of death
- [ ] Tutorial: first-time controls overlay - [ ] Tutorial: first-time controls overlay
- [ ] Difficulty scaling: deeper levels = stronger enemies - [ ] Difficulty scaling: deeper levels = stronger enemies
### Phase 6: Advanced Physics ### Phase 6: Advanced Physics (DONE)
**Goal: deeper Noita-style material simulation** **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 - 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 - Pressure layer: liquids have pressure, flow through pipes and U-bends
- Temperature layer: proper heat diffusion, materials melt/freeze at thresholds - 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.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.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.3 (done) | Combat, goblin AI, projectiles, corpse decomposition | June 2026 |
| 0.35 | UI layer: health bar, HUD, message log, minimap, inventory overlay | July 2026 | | 0.35 (done) | UI layer: health bar, HUD, message log, minimap, inventory overlay, scalable font | June 2026 |
| 0.4 | Chunks, biomes, dungeon gen, camera zoom | August 2026 | | 0.4 (done) | Chunks, biomes, dungeon gen, world scale ×5, chunk streaming + cache | June 2026 |
| 0.5 | RPG layer: stats, inventory, mutations, XP | October 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.6 | Lighting/particles/textures (Phase 4b) | December 2026 |
| 0.7 | Multi-layer world: air, pressure, temperature, light as separate grids | Feb 2027 | | 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 | | 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() { void main() {
vec2 pixel = (in_grid + in_pos) * pc.cell_size; if (pc.is_ui == 2u) {
gl_Position = vec4( vec2 world_pos = in_grid;
2.0 * pixel.x / pc.screen_size.x - 1.0, vec2 screen = (world_pos - vec2(pc.cam_pos)) * pc.cell_size + in_pos * pc.cell_size;
2.0 * pixel.y / pc.screen_size.y - 1.0, gl_Position = vec4(
0.0, 1.0 2.0 * screen.x / pc.screen_size.x - 1.0,
); 2.0 * screen.y / pc.screen_size.y - 1.0,
out_color = in_color; 0.0, 1.0
if (pc.is_ui != 0u) { );
out_color = in_color;
out_light = vec3(1.0); out_light = vec3(1.0);
} else { } else {
ivec2 world_pos = ivec2(in_grid + vec2(pc.cam_pos)); vec2 pixel = (in_grid + in_pos) * pc.cell_size;
out_light = compute_light(world_pos); 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", "mode": "graphics",
"ticks": 600, "ticks": 300,
"total_time_ms": 3483.9, "total_time_ms": 2104.1,
"avg_fps": 172.2, "avg_fps": 142.6,
"avg_frame_time_ms": 5.78, "avg_frame_time_ms": 6.99,
"p99_frame_time_ms": 14.20, "p99_frame_time_ms": 15.98,
"min_frame_time_ms": 4.20, "min_frame_time_ms": 5.43,
"subsystems": { "subsystems": {
"ca_step_avg_us": 1397, "ca_step_avg_us": 2900,
"ca_step_p99_us": 10172, "ca_step_p99_us": 11976,
"ca_step_min_us": 303, "ca_step_min_us": 1280,
"render_avg_us": 3668, "render_avg_us": 3459,
"render_p99_us": 5514, "render_p99_us": 5031,
"render_min_us": 3259 "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::entity::{EntityKind, EntityManager, ItemManager};
use crate::input::{Action, InputHandler}; use crate::input::{Action, InputHandler};
use crate::physics::collision::resolve_grid_collision; use crate::physics::collision::resolve_grid_collision;
use crate::physics::particle::ParticleManager;
use crate::physics::projectile::{ProjectileManager, ProjectileType}; use crate::physics::projectile::{ProjectileManager, ProjectileType};
use crate::physics::verlet::VerletSolver; use crate::physics::verlet::VerletSolver;
use crate::render::lighting; use crate::render::lighting;
@@ -24,6 +25,7 @@ pub struct Game {
pub verlet: VerletSolver, pub verlet: VerletSolver,
pub entities: EntityManager, pub entities: EntityManager,
pub projectiles: ProjectileManager, pub projectiles: ProjectileManager,
pub particles: ParticleManager,
pub items: ItemManager, pub items: ItemManager,
pub player: Player, pub player: Player,
pub input: InputHandler, pub input: InputHandler,
@@ -51,6 +53,11 @@ pub struct Game {
pub inventory_mouse_y: i32, pub inventory_mouse_y: i32,
pub seed: u64, pub seed: u64,
pub cache_dir: Option<String>, 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 { impl Game {
@@ -67,6 +74,7 @@ impl Game {
verlet: VerletSolver::new(), verlet: VerletSolver::new(),
entities, entities,
projectiles: ProjectileManager::new(), projectiles: ProjectileManager::new(),
particles: ParticleManager::new(2000),
items: ItemManager::new(), items: ItemManager::new(),
player, player,
input: InputHandler::new(), input: InputHandler::new(),
@@ -94,6 +102,11 @@ impl Game {
inventory_mouse_y: 0, inventory_mouse_y: 0,
seed: 0x1234567890ABCDEF, seed: 0x1234567890ABCDEF,
cache_dir: None, 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(), verlet: VerletSolver::new(),
entities, entities,
projectiles: ProjectileManager::new(), projectiles: ProjectileManager::new(),
particles: ParticleManager::new(2000),
items: ItemManager::new(), items: ItemManager::new(),
player, player,
input: InputHandler::new(), input: InputHandler::new(),
@@ -137,6 +151,11 @@ impl Game {
inventory_mouse_y: 0, inventory_mouse_y: 0,
seed, seed,
cache_dir, 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, self.cam_y,
vw, vw,
vh, vh,
lighting::ambient_light(), lighting::ambient_light_at_tick(self.tick),
); );
if let Err(e) = renderer.render( 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); .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() { for e in self.entities.all() {
if !e.alive || e.kind == EntityKind::Corpse { if !e.alive || e.kind == EntityKind::Corpse {
continue; continue;
@@ -483,10 +552,79 @@ impl Game {
self.try_spawn_slime(); self.try_spawn_slime();
} }
self.spawn_ambient_particles();
self.play_ambient_sounds(); self.play_ambient_sounds();
self.particles.update();
self.grid.swap_modified_flags(); 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) { fn play_ambient_sounds(&mut self) {
if !self.audio.is_enabled() { if !self.audio.is_enabled() {
return; return;
@@ -660,10 +798,9 @@ impl Game {
let px = px as i32; let px = px as i32;
let py = py as i32; let py = py as i32;
let (pcx, pcy, _, _) = self.grid.chunk_at(px, py); 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 chunk_size = self.grid.chunk_size as i32;
let mut to_generate: Vec<(i32, i32)> = Vec::new();
for dy in -radius..=radius { for dy in -radius..=radius {
for dx in -radius..=radius { for dx in -radius..=radius {
let cx = pcx + dx; let cx = pcx + dx;
@@ -675,16 +812,11 @@ impl Game {
} }
self.grid.ensure_chunk(cx, cy); self.grid.ensure_chunk(cx, cy);
if !self.grid.is_chunk_generated(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 { if let Some(ref dir) = self.cache_dir {
let save_radius = radius + 2; let save_radius = radius + 2;
if self.tick % 10 == 0 { if self.tick % 10 == 0 {
@@ -727,11 +859,6 @@ impl Game {
} }
fn update_active_chunks(&mut self) { 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() { if !self.grid.is_infinite() {
for e in self.entities.all() { for e in self.entities.all() {
let (cx, cy) = e.center(); let (cx, cy) = e.center();
@@ -747,21 +874,8 @@ impl Game {
self.grid.deactivate_all(); 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() { for (cx, cy) in self.grid.all_chunk_coords() {
if self.grid.get_chunk_dirty(cx, cy).is_some() { self.grid.set_chunk_active(cx, cy, true);
if (cx - pcx).abs() <= 1 && (cy - pcy).abs() <= 1 {
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_w(&self) -> usize;
fn grid_h(&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 { impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer {
@@ -98,6 +100,12 @@ impl GpuRenderer for verbatim::render::vulkan::VulkanRenderer {
fn grid_h(&self) -> usize { fn grid_h(&self) -> usize {
verbatim::render::vulkan::VulkanRenderer::grid_h(self) 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 { impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer {
@@ -124,6 +132,12 @@ impl GpuRenderer for verbatim::render::graphics::GraphicsRenderer {
fn grid_h(&self) -> usize { fn grid_h(&self) -> usize {
verbatim::render::graphics::GraphicsRenderer::grid_h(self) 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() { fn main() {
@@ -257,6 +271,20 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
} }
WindowEvent::CursorMoved { position, .. } => { WindowEvent::CursorMoved { position, .. } => {
input.on_mouse_move(position.x, position.y); 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, .. } => { WindowEvent::MouseInput { state, button, .. } => {
input.on_mouse_button(button, state); input.on_mouse_button(button, state);
@@ -321,9 +349,12 @@ fn run_gpu_mode<R: GpuRenderer>(title: &str) {
|| input.shoot_up || input.shoot_up
|| input.shoot_down || input.shoot_down
{ {
let cell_px = renderer.cell_pixel_size() as f64;
let (px, py) = game.player.center(&game.entities); 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_x =
let player_screen_y = ((py as i32 - game.cam_y) as f64) * 8.0 + 4.0; ((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 (dx, dy) = if input.shoot_mouse {
let mx = input.mouse_x - player_screen_x; 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); game.build_ui(vw, vh);
renderer.upload_particles(&game.particles);
renderer.render( renderer.render(
&game.grid, &game.grid,
&game.entities, &game.entities,
+1
View File
@@ -1,3 +1,4 @@
pub mod collision; pub mod collision;
pub mod particle;
pub mod projectile; pub mod projectile;
pub mod verlet; 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); let cell = grid.get(x, y);
if cell.is_empty() { 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 } else if cell.material == MaterialId::Wood
|| cell.material == MaterialId::Grass || cell.material == MaterialId::Grass
|| cell.material == MaterialId::Flesh || cell.material == MaterialId::Flesh
{ {
let mut ignited = cell; let mut ignited = cell;
ignited.material = MaterialId::Fire; ignited.material = MaterialId::Fire;
ignited.updated_this_tick = true;
grid.set(x, y, ignited); grid.set(x, y, ignited);
grid.set_temp(x, y, 400.0); grid.set_temp(x, y, 400.0);
} }
+116
View File
@@ -46,6 +46,14 @@ struct ColorInstance {
color: [u8; 4], color: [u8; 4],
} }
#[repr(C)]
#[derive(Clone, Copy, Default)]
struct ParticleInstance {
grid_x: f32,
grid_y: f32,
color: [u8; 4],
}
#[repr(C)] #[repr(C)]
#[derive(bytemuck::NoUninit, Clone, Copy, Default)] #[derive(bytemuck::NoUninit, Clone, Copy, Default)]
struct GpuLightSource { struct GpuLightSource {
@@ -115,6 +123,12 @@ pub struct GraphicsRenderer {
ui_instance_ptr: *mut ColorInstance, ui_instance_ptr: *mut ColorInstance,
ui_instance_capacity: usize, 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_buffer: vk::Buffer,
grid_memory: vk::DeviceMemory, grid_memory: vk::DeviceMemory,
grid_ptr: *mut u32, grid_ptr: *mut u32,
@@ -706,6 +720,44 @@ impl GraphicsRenderer {
ptr as *mut ColorInstance 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 { Ok(Self {
grid_w, grid_w,
grid_h, grid_h,
@@ -742,6 +794,11 @@ impl GraphicsRenderer {
ui_instance_memory, ui_instance_memory,
ui_instance_ptr, ui_instance_ptr,
ui_instance_capacity: ui_capacity, 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_buffer,
grid_memory, grid_memory,
@@ -1076,6 +1133,35 @@ impl GraphicsRenderer {
device.cmd_draw_indexed(cmd, 6, ui_count as u32, 0, 0, 0); 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); device.cmd_end_render_pass(cmd);
let _ = device.end_command_buffer(cmd); let _ = device.end_command_buffer(cmd);
@@ -1111,6 +1197,33 @@ impl GraphicsRenderer {
self.grid_h 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) { fn check_resize(&mut self) {
let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance); let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance);
let caps = match unsafe { let caps = match unsafe {
@@ -1342,6 +1455,9 @@ impl Drop for GraphicsRenderer {
self.device.free_memory(self.instance_memory, None); self.device.free_memory(self.instance_memory, None);
self.device.destroy_buffer(self.ui_instance_buffer, None); self.device.destroy_buffer(self.ui_instance_buffer, None);
self.device.free_memory(self.ui_instance_memory, 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.destroy_buffer(self.vertex_buffer, None);
self.device.free_memory(self.vertex_memory, None); self.device.free_memory(self.vertex_memory, None);
self.device.destroy_buffer(self.index_buffer, 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] [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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+6
View File
@@ -708,6 +708,12 @@ impl VulkanRenderer {
self.grid_h 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) { fn check_resize(&mut self) {
let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance); let sl = ash::khr::surface::Instance::new(&self.entry, &self.instance);
let caps = match unsafe { let caps = match unsafe {
-1
View File
@@ -206,7 +206,6 @@ impl WindowInput {
self.cam_up = keys.contains(&KeyCode::KeyI); self.cam_up = keys.contains(&KeyCode::KeyI);
self.cam_down = keys.contains(&KeyCode::KeyO); self.cam_down = keys.contains(&KeyCode::KeyO);
self.quit = keys.contains(&KeyCode::KeyQ) || keys.contains(&KeyCode::Escape); self.quit = keys.contains(&KeyCode::KeyQ) || keys.contains(&KeyCode::Escape);
let now_audio = keys.contains(&KeyCode::KeyM); let now_audio = keys.contains(&KeyCode::KeyM);
self.toggle_audio = now_audio && !self.audio_was_down; self.toggle_audio = now_audio && !self.audio_was_down;
self.audio_was_down = now_audio; self.audio_was_down = now_audio;
+179 -19
View File
@@ -127,14 +127,6 @@ impl CellularAutomaton {
None => continue, None => continue,
}; };
let (min_x, min_y, max_x, max_y) = dirty; 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); grid.set_chunk_dirty(cx, cy, None);
@@ -154,6 +146,8 @@ impl CellularAutomaton {
self.heat_transfer(grid, &active, &pre_dirty); self.heat_transfer(grid, &active, &pre_dirty);
self.gas_step(grid, &active, &pre_dirty); self.gas_step(grid, &active, &pre_dirty);
self.pressure_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.light_step(grid, &active);
self.tick += 1; self.tick += 1;
} }
@@ -243,6 +237,7 @@ impl CellularAutomaton {
if temp > 100.0 { if temp > 100.0 {
let mut new = grid.get(x, y); let mut new = grid.get(x, y);
new.material = MaterialId::Steam; new.material = MaterialId::Steam;
new.updated_this_tick = true;
grid.set(x, y, new); grid.set(x, y, new);
grid.set_temp(x, y, 110.0); grid.set_temp(x, y, 110.0);
} }
@@ -301,7 +296,10 @@ impl CellularAutomaton {
grid.set_temp(x, y, lava_temp - 50.0); grid.set_temp(x, y, lava_temp - 50.0);
} }
MaterialId::Wood | MaterialId::Grass | MaterialId::Flesh if n_temp < 300.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); grid.set_temp(nx, ny, 400.0);
} }
MaterialId::Sand if n_temp > 1700.0 => { MaterialId::Sand if n_temp > 1700.0 => {
@@ -377,6 +375,7 @@ impl CellularAutomaton {
if mat.flammable && n_temp < mat.ignition_temp { if mat.flammable && n_temp < mat.ignition_temp {
let mut new_n = neighbor; let mut new_n = neighbor;
new_n.material = MaterialId::Fire; new_n.material = MaterialId::Fire;
new_n.updated_this_tick = true;
grid.set(nx, ny, new_n); grid.set(nx, ny, new_n);
grid.set_temp(nx, ny, 400.0); grid.set_temp(nx, ny, 400.0);
} }
@@ -473,6 +472,7 @@ impl CellularAutomaton {
if temp > 200.0 { if temp > 200.0 {
let mut new = grid.get(x, y); let mut new = grid.get(x, y);
new.material = MaterialId::Fire; new.material = MaterialId::Fire;
new.updated_this_tick = true;
grid.set(x, y, new); grid.set(x, y, new);
grid.set_temp(x, y, 400.0); 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) { fn update_grass(&mut self, grid: &mut ChunkedGrid, x: i32, y: i32) {
let temp = grid.get_temp(x, y); let temp = grid.get_temp(x, y);
if temp > 250.0 { 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); grid.set_temp(x, y, 400.0);
} }
} }
@@ -514,9 +517,6 @@ impl CellularAutomaton {
}; };
let w = max_x - min_x + 1; let w = max_x - min_x + 1;
let h = max_y - min_y + 1; let h = max_y - min_y + 1;
if w * h > 2048 {
continue;
}
let ox = cx * cs; let ox = cx * cs;
let oy = cy * cs; let oy = cy * cs;
let mut edge_temps: Vec<((i32, i32), f32)> = Vec::new(); let mut edge_temps: Vec<((i32, i32), f32)> = Vec::new();
@@ -606,9 +606,6 @@ impl CellularAutomaton {
}; };
let w = max_x - min_x + 1; let w = max_x - min_x + 1;
let h = max_y - min_y + 1; let h = max_y - min_y + 1;
if w * h > 2048 {
continue;
}
let ox = cx * cs; let ox = cx * cs;
let oy = cy * cs; let oy = cy * cs;
let chunk = match grid.get_chunk_mut(cx, cy) { let chunk = match grid.get_chunk_mut(cx, cy) {
@@ -712,9 +709,6 @@ impl CellularAutomaton {
}; };
let w = max_x - min_x + 1; let w = max_x - min_x + 1;
let h = max_y - min_y + 1; let h = max_y - min_y + 1;
if w * h > 2048 {
continue;
}
let ox = cx * cs; let ox = cx * cs;
let oy = cy * cs; let oy = cy * cs;
let chunk = match grid.get_chunk_mut(cx, cy) { 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)]) { fn light_step(&mut self, grid: &mut ChunkedGrid, active: &[(i32, i32)]) {
self.light_tick += 1; self.light_tick += 1;
if self.light_tick % 20 != 0 { if self.light_tick % 20 != 0 {
+17
View File
@@ -9,6 +9,7 @@ pub struct Chunk {
pub gas_type: Vec<u8>, pub gas_type: Vec<u8>,
pub gas_density: Vec<u8>, pub gas_density: Vec<u8>,
pub light: Vec<[u8; 3]>, pub light: Vec<[u8; 3]>,
pub electricity: Vec<u8>,
pub active: bool, pub active: bool,
pub modified: bool, pub modified: bool,
pub was_modified: bool, pub was_modified: bool,
@@ -28,6 +29,7 @@ impl Chunk {
gas_type: vec![0; CHUNK_AREA], gas_type: vec![0; CHUNK_AREA],
gas_density: vec![0; CHUNK_AREA], gas_density: vec![0; CHUNK_AREA],
light: vec![[0, 0, 0]; CHUNK_AREA], light: vec![[0, 0, 0]; CHUNK_AREA],
electricity: vec![0; CHUNK_AREA],
active: false, active: false,
modified: false, modified: false,
was_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 { pub fn is_empty(&self) -> bool {
self.cells.iter().all(|c| c.is_empty()) 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 (cx1, cy1, lx1, ly1) = self.chunk_at(x1, y1);
let (cx2, cy2, lx2, ly2) = self.chunk_at(x2, y2); 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() { if self.is_bounded() {
let idx1 = self.chunk_index(cx1, cy1); let idx1 = self.chunk_index(cx1, cy1);
let idx2 = self.chunk_index(cx2, cy2); let idx2 = self.chunk_index(cx2, cy2);
@@ -456,6 +450,7 @@ impl ChunkedGrid {
chunk.pressure.swap(ci1, ci2); chunk.pressure.swap(ci1, ci2);
chunk.gas_type.swap(ci1, ci2); chunk.gas_type.swap(ci1, ci2);
chunk.gas_density.swap(ci1, ci2); chunk.gas_density.swap(ci1, ci2);
chunk.electricity.swap(ci1, ci2);
chunk.cells[ci2].updated_this_tick = true; chunk.cells[ci2].updated_this_tick = true;
chunk.modified = true; chunk.modified = true;
chunk.mark_dirty(lx1, ly1); chunk.mark_dirty(lx1, ly1);
@@ -463,28 +458,38 @@ impl ChunkedGrid {
} }
} }
(Some(i1), Some(i2)) => { (Some(i1), Some(i2)) => {
let c1 = self.get(x1, y1); let (lo, hi) = if i1 < i2 { (i1, i2) } else { (i2, i1) };
let c2 = self.get(x2, y2); let (left, right) = self.chunks_vec.split_at_mut(hi);
if let Some(chunk) = self.chunks_vec.get_mut(i1) { let (ch_lo, ch_hi) = if i1 < i2 {
let ci = (ly1 as usize) * self.chunk_size + (lx1 as usize); (left.get_mut(lo), right.first_mut())
chunk.cells[ci] = c2; } else {
chunk.temps[ci] = t2; (right.first_mut(), left.get_mut(lo))
chunk.pressure[ci] = p2; };
chunk.gas_type[ci] = g2.0; if let (Some(ch1), Some(ch2)) = (ch_lo, ch_hi) {
chunk.gas_density[ci] = g2.1; let ci1 = (ly1 as usize) * self.chunk_size + (lx1 as usize);
chunk.cells[ci].updated_this_tick = true; let ci2 = (ly2 as usize) * self.chunk_size + (lx2 as usize);
chunk.modified = true; let (c1, t1, p1, gt1, gd1) = (
chunk.mark_dirty(lx1, ly1); ch1.cells[ci1],
} ch1.temps[ci1],
if let Some(chunk) = self.chunks_vec.get_mut(i2) { ch1.pressure[ci1],
let ci = (ly2 as usize) * self.chunk_size + (lx2 as usize); ch1.gas_type[ci1],
chunk.cells[ci] = c1; ch1.gas_density[ci1],
chunk.temps[ci] = t1; );
chunk.pressure[ci] = p1; ch1.cells[ci1] = ch2.cells[ci2];
chunk.gas_type[ci] = g1.0; ch1.temps[ci1] = ch2.temps[ci2];
chunk.gas_density[ci] = g1.1; ch1.pressure[ci1] = ch2.pressure[ci2];
chunk.modified = true; ch1.gas_type[ci1] = ch2.gas_type[ci2];
chunk.mark_dirty(lx2, ly2); 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.pressure.swap(i1, i2);
chunk.gas_type.swap(i1, i2); chunk.gas_type.swap(i1, i2);
chunk.gas_density.swap(i1, i2); chunk.gas_density.swap(i1, i2);
chunk.electricity.swap(i1, i2);
chunk.cells[i2].updated_this_tick = true; chunk.cells[i2].updated_this_tick = true;
chunk.modified = true; chunk.modified = true;
chunk.mark_dirty(lx1, ly1); chunk.mark_dirty(lx1, ly1);
@@ -506,6 +512,12 @@ impl ChunkedGrid {
} else { } else {
let c1 = self.get(x1, y1); let c1 = self.get(x1, y1);
let c2 = self.get(x2, y2); 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(x1, y1, c2);
self.set_temp(x1, y1, t2); self.set_temp(x1, y1, t2);
self.set_pressure(x1, y1, p2); self.set_pressure(x1, y1, p2);
@@ -850,6 +862,9 @@ impl ChunkedGrid {
for l in &chunk.light { for l in &chunk.light {
bytes.extend_from_slice(&l[..]); bytes.extend_from_slice(&l[..]);
} }
for &e in &chunk.electricity {
bytes.push(e);
}
let dir = Path::new(path); let dir = Path::new(path);
if let Some(parent) = dir.parent() { if let Some(parent) = dir.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
@@ -909,6 +924,13 @@ impl ChunkedGrid {
chunk.light[i] = [data[off], data[off + 1], data[off + 2]]; chunk.light[i] = [data[off], data[off + 1], data[off + 2]];
off += 3; off += 3;
} }
for i in 0..area {
if off >= data.len() {
break;
}
chunk.electricity[i] = data[off];
off += 1;
}
} else { } else {
let (x0, y0, x1, y1) = bounds; let (x0, y0, x1, y1) = bounds;
let w = (x1 - x0) as usize; let w = (x1 - x0) as usize;
+48
View File
@@ -13,6 +13,9 @@ pub struct Material {
pub ignition_temp: f32, pub ignition_temp: f32,
pub melt_temp: f32, pub melt_temp: f32,
pub heat_conductivity: f32, pub heat_conductivity: f32,
pub conductive: bool,
pub conductivity: f32,
pub structural: bool,
pub color_fg: (u8, u8, u8), pub color_fg: (u8, u8, u8),
pub color_bg: (u8, u8, u8), pub color_bg: (u8, u8, u8),
pub display_char: char, pub display_char: char,
@@ -40,6 +43,9 @@ impl MaterialRegistry {
ignition_temp: f32::INFINITY, ignition_temp: f32::INFINITY,
melt_temp: f32::INFINITY, melt_temp: f32::INFINITY,
heat_conductivity: 0.0, heat_conductivity: 0.0,
conductive: false,
conductivity: 0.0,
structural: false,
color_fg: (15, 15, 20), color_fg: (15, 15, 20),
color_bg: (10, 10, 15), color_bg: (10, 10, 15),
display_char: ' ', display_char: ' ',
@@ -56,6 +62,9 @@ impl MaterialRegistry {
ignition_temp: f32::INFINITY, ignition_temp: f32::INFINITY,
melt_temp: 1700.0, melt_temp: 1700.0,
heat_conductivity: 0.2, heat_conductivity: 0.2,
conductive: false,
conductivity: 0.0,
structural: false,
color_fg: (218, 178, 90), color_fg: (218, 178, 90),
color_bg: (60, 50, 30), color_bg: (60, 50, 30),
display_char: '.', display_char: '.',
@@ -72,6 +81,9 @@ impl MaterialRegistry {
ignition_temp: f32::INFINITY, ignition_temp: f32::INFINITY,
melt_temp: 0.0, melt_temp: 0.0,
heat_conductivity: 0.4, heat_conductivity: 0.4,
conductive: true,
conductivity: 0.6,
structural: false,
color_fg: (64, 128, 220), color_fg: (64, 128, 220),
color_bg: (20, 40, 80), color_bg: (20, 40, 80),
display_char: '~', display_char: '~',
@@ -88,6 +100,9 @@ impl MaterialRegistry {
ignition_temp: f32::INFINITY, ignition_temp: f32::INFINITY,
melt_temp: 1200.0, melt_temp: 1200.0,
heat_conductivity: 0.3, heat_conductivity: 0.3,
conductive: false,
conductivity: 0.0,
structural: true,
color_fg: (120, 120, 130), color_fg: (120, 120, 130),
color_bg: (40, 40, 50), color_bg: (40, 40, 50),
display_char: '#', display_char: '#',
@@ -104,6 +119,9 @@ impl MaterialRegistry {
ignition_temp: f32::INFINITY, ignition_temp: f32::INFINITY,
melt_temp: f32::INFINITY, melt_temp: f32::INFINITY,
heat_conductivity: 0.05, heat_conductivity: 0.05,
conductive: false,
conductivity: 0.0,
structural: false,
color_fg: (255, 80, 20), color_fg: (255, 80, 20),
color_bg: (120, 20, 0), color_bg: (120, 20, 0),
display_char: '#', display_char: '#',
@@ -120,6 +138,9 @@ impl MaterialRegistry {
ignition_temp: 300.0, ignition_temp: 300.0,
melt_temp: f32::INFINITY, melt_temp: f32::INFINITY,
heat_conductivity: 0.1, heat_conductivity: 0.1,
conductive: false,
conductivity: 0.0,
structural: true,
color_fg: (140, 90, 50), color_fg: (140, 90, 50),
color_bg: (50, 30, 20), color_bg: (50, 30, 20),
display_char: 'T', display_char: 'T',
@@ -136,6 +157,9 @@ impl MaterialRegistry {
ignition_temp: 200.0, ignition_temp: 200.0,
melt_temp: f32::INFINITY, melt_temp: f32::INFINITY,
heat_conductivity: 0.15, heat_conductivity: 0.15,
conductive: false,
conductivity: 0.0,
structural: false,
color_fg: (180, 50, 50), color_fg: (180, 50, 50),
color_bg: (60, 15, 15), color_bg: (60, 15, 15),
display_char: '%', display_char: '%',
@@ -152,6 +176,9 @@ impl MaterialRegistry {
ignition_temp: f32::INFINITY, ignition_temp: f32::INFINITY,
melt_temp: f32::INFINITY, melt_temp: f32::INFINITY,
heat_conductivity: 0.1, heat_conductivity: 0.1,
conductive: false,
conductivity: 0.0,
structural: false,
color_fg: (220, 210, 190), color_fg: (220, 210, 190),
color_bg: (60, 55, 50), color_bg: (60, 55, 50),
display_char: '`', display_char: '`',
@@ -168,6 +195,9 @@ impl MaterialRegistry {
ignition_temp: f32::INFINITY, ignition_temp: f32::INFINITY,
melt_temp: f32::INFINITY, melt_temp: f32::INFINITY,
heat_conductivity: 0.2, heat_conductivity: 0.2,
conductive: false,
conductivity: 0.0,
structural: false,
color_fg: (200, 200, 220), color_fg: (200, 200, 220),
color_bg: (30, 30, 40), color_bg: (30, 30, 40),
display_char: '~', display_char: '~',
@@ -184,6 +214,9 @@ impl MaterialRegistry {
ignition_temp: f32::INFINITY, ignition_temp: f32::INFINITY,
melt_temp: f32::INFINITY, melt_temp: f32::INFINITY,
heat_conductivity: 0.6, heat_conductivity: 0.6,
conductive: false,
conductivity: 0.0,
structural: false,
color_fg: (255, 160, 40), color_fg: (255, 160, 40),
color_bg: (100, 30, 0), color_bg: (100, 30, 0),
display_char: '^', display_char: '^',
@@ -200,6 +233,9 @@ impl MaterialRegistry {
ignition_temp: f32::INFINITY, ignition_temp: f32::INFINITY,
melt_temp: f32::INFINITY, melt_temp: f32::INFINITY,
heat_conductivity: 0.3, heat_conductivity: 0.3,
conductive: false,
conductivity: 0.0,
structural: false,
color_fg: (100, 255, 60), color_fg: (100, 255, 60),
color_bg: (20, 60, 10), color_bg: (20, 60, 10),
display_char: '~', display_char: '~',
@@ -216,6 +252,9 @@ impl MaterialRegistry {
ignition_temp: f32::INFINITY, ignition_temp: f32::INFINITY,
melt_temp: f32::INFINITY, melt_temp: f32::INFINITY,
heat_conductivity: 0.1, heat_conductivity: 0.1,
conductive: false,
conductivity: 0.0,
structural: false,
color_fg: (100, 100, 100), color_fg: (100, 100, 100),
color_bg: (20, 20, 20), color_bg: (20, 20, 20),
display_char: '*', display_char: '*',
@@ -232,6 +271,9 @@ impl MaterialRegistry {
ignition_temp: 250.0, ignition_temp: 250.0,
melt_temp: f32::INFINITY, melt_temp: f32::INFINITY,
heat_conductivity: 0.1, heat_conductivity: 0.1,
conductive: false,
conductivity: 0.0,
structural: false,
color_fg: (80, 200, 60), color_fg: (80, 200, 60),
color_bg: (20, 50, 15), color_bg: (20, 50, 15),
display_char: '"', display_char: '"',
@@ -248,6 +290,9 @@ impl MaterialRegistry {
ignition_temp: f32::INFINITY, ignition_temp: f32::INFINITY,
melt_temp: f32::INFINITY, melt_temp: f32::INFINITY,
heat_conductivity: 0.2, heat_conductivity: 0.2,
conductive: false,
conductivity: 0.0,
structural: false,
color_fg: (100, 70, 50), color_fg: (100, 70, 50),
color_bg: (40, 30, 20), color_bg: (40, 30, 20),
display_char: ':', display_char: ':',
@@ -264,6 +309,9 @@ impl MaterialRegistry {
ignition_temp: f32::INFINITY, ignition_temp: f32::INFINITY,
melt_temp: f32::INFINITY, melt_temp: f32::INFINITY,
heat_conductivity: 0.0, heat_conductivity: 0.0,
conductive: false,
conductivity: 0.0,
structural: false,
color_fg: (255, 220, 80), color_fg: (255, 220, 80),
color_bg: (60, 50, 20), color_bg: (60, 50, 20),
display_char: '>', display_char: '>',
+6 -6
View File
@@ -1182,18 +1182,18 @@ impl<'a> WorldGenerator<'a> {
} }
fn surface_height(&mut self, x: i32, h: i32) -> i32 { 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 base = (h - 3) - ((x as f32 * 0.012).sin() * scf(3.0)) as i32;
let detail = ((x as f32 * 0.23).sin() * scf(2.0)) as i32; let detail = ((x as f32 * 0.04).sin() * scf(1.5)) as i32;
let micro = ((x as f32 * 0.57).sin() * scf(1.0)) as i32; let micro = ((x as f32 * 0.11).sin() * scf(0.5)) as i32;
(base + detail + micro).max(10).min(h - 3) (base + detail + micro).max(10).min(h - 3)
} }
const SURFACE_BASE_Y: i32 = 120; const SURFACE_BASE_Y: i32 = 120;
fn surface_height_world(&mut self, x: i32) -> i32 { 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 base = Self::SURFACE_BASE_Y - ((x as f32 * 0.012).sin() * scf(3.0)) as i32;
let detail = ((x as f32 * 0.23).sin() * scf(2.0)) as i32; let detail = ((x as f32 * 0.04).sin() * scf(1.5)) as i32;
let micro = ((x as f32 * 0.57).sin() * scf(1.0)) as i32; let micro = ((x as f32 * 0.11).sin() * scf(0.5)) as i32;
(base + detail + micro).max(10) (base + detail + micro).max(10)
} }