| 1 |
# CLAUDE.md |
| 2 |
|
| 3 |
DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — check `docs/bevy/` before guessing APIs). |
| 4 |
|
| 5 |
## User rules |
| 6 |
- Vision: fully simulated colony. Every job colonists can be ordered to do, they must also be able to decide to do autonomously (driven by colony demand); manual orders are a bonus/override. |
| 7 |
- Code is `cargo fmt`ed; run it before committing. |
| 8 |
- Commit after each logical chunk; short commit msgs. |
| 9 |
- Minimal code comments. "Why" explanations go into a test asserting the behaviour, comment lives in the test. |
| 10 |
- Track work in `tasks.md` (done + planned). |
| 11 |
- This file: dense notes for Claude, not user facing. |
| 12 |
|
| 13 |
## Layout |
| 14 |
- `src/actions.rs` `Action` message enum + `KEYBINDS` table (single source of truth for keys; `keyboard_actions` in PreUpdate after `InputSystems`). All non-camera input goes through actions: consumers read `MessageReader<Action>` (sim speed/pause, worldgen NewWorld, zones tool/Cancel, selection Cancel, ui ToggleUi). New keybind = add Action variant + KEYBINDS entry + label + TOOLBAR entry (test enforces every keybind has a button). |
| 15 |
- `src/ui.rs` `HudPlugin`: bottom toolbar of `ui_widgets::Button`s (`ActivateOnPress` — click-on-release was flaky when the pointer moves and clicks in one frame) with `ActionButton(Action)`; global `On<Activate>` observer writes the action. `style_buttons` highlights active state (`is_active`), hover, pressed. `UiVisible` + `HideableUi` marker (toolbar, info panel) toggled by H/button; `ShowUiButton` appears when hidden. `PointerOverUi` from picking `HoverMap` (any hovered entity with `Node`; layout containers are `Pickable::IGNORE`); `update_cursor` clears world/tile and selection/orders/zone tool ignore clicks while over UI. |
| 16 |
- `src/main.rs` app wiring: DefaultPlugins (nearest sampling, canvas `#bevy`, fit_canvas_to_parent), adds plugins. |
| 17 |
- `src/world.rs` `Terrain` enum (index = tileset layer, `ALL` order matters; rock = Stone/IronOre/GoldOre, impassable), `Plant` enum (Tree; separate layer, adds move cost). `WorldMap` resource: per-tile terrain, `Option<Plant>`, height in meters (0..`MAX_HEIGHT`=100); row-major, (0,0)=bottom-left, y up. `move_cost(pos)` = terrain + plant cost (None = impassable); `step_cost(from,to)` adds `UPHILL_COST_PER_METER` × climb — use step_cost for anything that moves. `TILE_SIZE`=16 world units, `hash2` deterministic int hash. |
| 18 |
- `src/noise.rs` seeded 2D gradient (Perlin-style, quintic fade) noise + `fbm` (octave i uses seed+i). Range [-1,1]. |
| 19 |
- `src/worldgen.rs` `WorldGenPlugin` inserts `WorldSeed` + `WorldMap` (256x256) at build time and writes `WorldGenerated` message at Startup; R → seed+1, regenerate, write message. `generate(size, seed)`: elevation fbm → height (min-max normalised to meters) and deep/shallow/sand/rock by **quantile** thresholds (fixed terrain shares per seed); ore = quantiles of two ore noises among rock tiles (gold 3%, iron 10%); fertility fbm top 20% → soil; rivers; trees (forest noise top 35% → 45% tree chance, else 3%; only Grass/Soil, placed after rivers). Rivers: `FlowField` = priority flood from water + non-rock edge tiles over non-rock tiles, each tile gets a downstream neighbour (spills out of depressions at lowest rim); `pick_rivers` traces 40 random mountain-foot sources, keeps the 4 longest spaced ≥40 apart; `carve_river` → ShallowWater, wider downstream, never over rock/deep water. `largest_region` (4-conn flood of walkable), `spawn_tiles(map, n)` = BFS from largest-region tile nearest map center. Everything reacting to a new world runs `.run_if(on_message::<WorldGenerated>)`: tilemap refresh, colonist respawn, camera recenter, selection clear. |
| 20 |
- `src/tilemap.rs` renders map as `TilemapChunk`s (bevy::sprite_render) of `CHUNK_SIZE`=64; chunk transform = chunk center (TilemapChunk is centered on its transform). Tile (x,y) world center = (pos+0.5)*TILE_SIZE, so map spans [0, size*TILE_SIZE]. Tileset is generated in code (no assets): stacked 16x16 layers -> `reinterpret_stacked_2d_as_array`. Per-tile tint via `TileData.color`. Two layers of chunks (`Layer::Terrain` z=0, `Layer::Plants` z=0.5, alpha blend, tree texture drawn in code), spawned once at Startup (`Chunk{origin, layer}`); terrain tint = jitter × `hillshade` (light from NW, `RELIEF` exaggeration) × height brightness, water unshaded; ore = stone texture with coloured specks; world changes overwrite `TilemapChunkTileData` in place (full on WorldGenerated, per-chunk on `TileChanged`) (bevy's `update_tilemap_chunk_indices` re-uploads via change detection). |
| 21 |
- `src/camera.rs` `CameraController` on Camera2d: WASD/arrows pan (shift = fast, speed scaled by ortho scale), middle/right drag pan (from cursor delta, not AccumulatedMouseMotion — that doesn't fire for web), scroll zoom toward cursor (`zoom_towards`), camera center clamped to world rect. |
| 22 |
- `src/selection.rs` (`update_cursor` is pub; other modules order `.after(update_cursor)`) `Cursor` resource (screen/world/tile) and `Selection` enum (None/Tile/Colonist(Entity)). PostUpdate after `TransformSystems::Propagate` (fresh camera GlobalTransform). LMB: pick nearest colonist within `PICK_RADIUS` of cursor world pos, else tile, off-map clears; Esc clears. RMB click (press/release within `CLICK_SLOP_PX`, else it was a drag-pan) writes `MoveOrder` for selected colonist. Gizmos: hover rect, selected rect / colonist circle + remaining path linestrip. Top-left UI text (clock + speed/PAUSED, seed, wood stock/goal + stored, zone tool hint, hover incl. stockpile/items, selection incl. job/carrying). Chop progress bars (gizmo lines) above working colonists; selected colonist's target tree outlined. Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`. |
| 23 |
- `src/pathfinding.rs` generic `astar(map, start, is_goal, heuristic)`, 8-dir, no corner cutting, cost = (10|14) * `map.step_cost` (100 = normal flat grass), octile heuristic. `find_path(start, goal)`, `find_path_adjacent(start, target)` (ends on a tile Chebyshev-adjacent to target; works for impassable targets; empty if already adjacent), `nearest_tile(start, max_tiles, pred)` BFS over walkable tiles. Paths exclude start, include goal. |
| 24 |
- `src/colonist.rs` `Colonist{name}` (spawned/respawned on `WorldGenerated` at `spawn_tiles`) requires `TilePos` (world.rs; last reached tile), `Path(VecDeque)`, `CurrentJob`, `IdleTimer`. `follow_path` (SimSystems::Move) moves `SimPos`, speed / step cost. `repath` keeps the tile being entered at the front. |
| 25 |
- `src/jobs.rs` job system. `CurrentJob(Option<Job{task, ordered}>)`, `Task::{MoveTo, Chop{tree, progress}, Haul{pickup: Option<tile>, to}}`. `Reservations` keyed by `Target::{Tree, Pickup, Storage}(tile)` → entity, so colonists never share a target; cleared on WorldGenerated. `ColonyGoals{wood}` = demand. `choose_jobs` (SimSystems::Decide), for idle colonists (no job, empty path, IdleTimer done), in priority order: deliver what they're carrying (or drop it if no storage) → chop if `wood_wanted` (stock incl. carried + trees being chopped × WOOD_PER_TREE) → haul nearest loose item (not on a stockpile) to nearest stockpile tile with room (`find_haul`/`find_storage`) → wander. `run_jobs` (SimSystems::Act) per-task state machine returning `Step::{Continue, Done}`; Done releases all reservations and restarts the idle timer (short after work, 10 s after an ordered move). Chop walks adjacent, ticks to CHOP_TICKS, removes tree, `TileChanged`, drops wood. Haul walks onto the pickup tile, `take_items` up to min(CARRY_CAPACITY, room at dest), walks to dest, drops what fits (remainder stays in `Carrying` and gets delivered by the next choice). `Order{colonist, tile}` (Update, applies while paused): tree → ordered Chop; loose item → ordered Haul; else ordered MoveTo. Orders cancel the job but keep `Carrying`. Adding a job = Task variant + reserve_task arm + demand check in choose_jobs + run_jobs arm. |
| 26 |
- `src/items.rs` `Item{kind, count}` + `TilePos` entities (Mesh2d rect, z=0.7), one stack per tile, `STACK_LIMIT`=50; `ItemQuery` alias; `drop_items` stacks, `take_items`, `room_for(tile, kind)` (0 if other kind present), `stock()`. `Carrying(Option<Item>)` on colonists, shown via hidden/visible `CarriedVisual` child. Items cleared on WorldGenerated. |
| 27 |
- `src/zones.rs` `Zones` resource (stockpile tile set; only walkable dry land). New worlds get a 4x4 starting stockpile next to spawn. `ZoneTool` (Z toggles, Esc exits): left-drag adds, shift-drag removes (`zone_tool` input + `draw_zone_preview` gizmo, PostUpdate after `update_cursor`); `update_selection` is skipped while the tool is active. Rendered as tilemap `Layer::Zones` (z=0.3), refreshed on `resource_changed::<Zones>`. |
| 28 |
- `src/sim.rs` `SimPlugin`: `SimSystems::{Decide, Act, Move}` chained sets in FixedUpdate; fixed timestep `Time<Fixed>` 60 Hz = sim ticks; all sim logic in `FixedUpdate` (Res<Time> there = fixed delta). `SimTick` counter (FixedFirst), `format_clock` (600 ticks/hour = 10 s real at 1x, starts Day 1 06:00). `SimSpeed` Normal/Fast/Fastest = 1x/3x/6x via `Time<Virtual>::set_relative_speed`; keys 1/2/3 set speed + unpause, Space toggles pause. Render interpolation: sim entities hold `SimPos` (+ `PrevSimPos`, copied in FixedFirst); `interpolate_transforms` lerps Transform by `overstep_fraction` in `RunFixedMainLoop`/AfterFixedMainLoop. Spawn with `sim_pos(v)` so prev==cur. Never write Transform of sim entities directly. |
| 29 |
- `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep). |
| 30 |
|
| 31 |
## Gotchas |
| 32 |
- WebGL2 (wgpu GLES) guesses array texture view dim from layer count: 1→D2, 6→Cube, 6k→CubeArray. Tileset layer count padded via `tileset_layer_count`. Symptom: black screen + "wgpu-hal heuristics assumed ... Cube" errors. |
| 33 |
- `UVec2::div_ceil` doesn't exist in this glam. |
| 34 |
- No `rand` dep; use `SimRng` (stateful) or `hash2` (positional noise). |
| 35 |
|
| 36 |
## Build / run |
| 37 |
- Native: `cargo run`. Tests: `cargo test`. |
| 38 |
- Web: `./scripts/build-web.sh [profile]` (default `wasm-release`, ~3.5 min cold) → `target/web/`. Needs wasm-bindgen-cli matching Cargo.lock `wasm-bindgen` version (currently 0.2.129, installed in ~/.cargo/bin). |
| 39 |
- Shift-held drags can't be driven in the browser pane (computer-tool modifiers / synthetic Shift keydown don't reach Bevy as a held key during a real drag); test modifier logic headlessly. |
| 40 |
- Browser verification: `.claude/launch.json` config `web` (python http.server :8080 serving target/web) → `preview_start name=web`, then screenshot/scroll in built-in browser. Synthetic JS pointer events are ignored by winit; real drags only via computer tool left_click_drag (left button) — temporarily bind Left to test drag code. computer tool `right_click` works for RMB clicks. Pause (Space) before clicking moving colonists. |
| 41 |
- Browser pane screenshots can lag 1-2s behind the canvas (verified: rAF gaps ≤51ms while screenshot still showed old frame). Wait ≥3s after an action, or cross-check via hover text before concluding a render bug. |
| 42 |
- Bevy logs in browser console; check for ERROR after load. Console log persists across reloads in the pane — count "Creating new window" lines to tell which load an error belongs to. |
| 43 |
|
| 44 |
## Docs |
| 45 |
- `docs/bevy/examples/` full 0.20 examples (e.g. `2d/tilemap_chunk.rs`, `camera/*`). `docs/bevy/release-content/` migration guides + release notes. |
| 46 |
- Crate sources: `~/.cargo/registry/src/index.crates.io-*/bevy_*-0.20.0/`. |