Add wooden floors
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-25 09:42 UTC
- commit
- 3313af764848e8bc6a6c24304cf295828f0c6be1
- parent
- 681918401f
- tree
- browse at this commit
14 files changed +185 -37
| CLAUDE.md | +8 -4 | |
| src/animals.rs | +2 -4 | |
| src/building.rs | +40 -3 | |
| src/family.rs | +37 -8 | |
| src/jobs/mod.rs | +16 -5 | |
| src/jobs/tests.rs | +1 -0 | |
| src/light.rs | +19 -4 | |
| src/mood.rs | +8 -1 | |
| src/pathfinding.rs | +22 -2 | |
| src/selection.rs | +3 -0 | |
| src/tilemap.rs | +3 -0 | |
| src/world.rs | +19 -4 | |
| src/worldgen.rs | +6 -2 | |
| tasks.md | +1 -0 |
CLAUDE.md +8 -4
| @@ -14,7 +14,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 14 | 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 | 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 | 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; Gravel = mined floor, Path/Paved from traffic, never generated), `Plant` enum (Tree, BerryBush, Crop; separate layer with per-tile growth 0..1, adds move cost; `ripe_plant`). Worldgen scatters berry bushes (2%) with random growth. `WorldMap` resource: per-tile terrain, `Option<Plant>`, wall flag (blocks movement), roof flag, plant growth, traffic wear, 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. | |
| 17 | +- `src/world.rs` `Terrain` enum (index = tileset layer, `ALL` order matters; rock = Stone/IronOre/GoldOre, impassable; Gravel = mined floor, Path/Paved from traffic, WoodFloor built (cost 60 like Paved), never generated), `Plant` enum (Tree, BerryBush, Crop; separate layer with per-tile growth 0..1, adds move cost; `ripe_plant`). Worldgen scatters berry bushes (2%) with random growth. `WorldMap` resource: per-tile terrain, `Option<Plant>`, wall flag (blocks movement), roof flag, plant growth, traffic wear, 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 | 18 | - `src/noise.rs` seeded 2D gradient (Perlin-style, quintic fade) noise + `fbm` (octave i uses seed+i). Range [-1,1]. |
| 19 | 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 | 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`. Chunk layers (`Layer::Terrain` z=0, `Layer::Zones` z=0.3, `Layer::Plants` z=0.5 (tileset per `PlantLook`: tree, bush, ripe bush, sprout, young crop, ripe crop), `Layer::Roofs` z=0.85 translucent striped overlay, alpha blend, tree texture drawn in code), spawned once at Startup (`Chunk{origin, layer}`); terrain tint = jitter × SHADE_FLAT × height brightness; hill shading is a separate `HillShade` mesh over the whole map (z 0.1) with a one-texel-per-tile black-alpha texture (`shadow_alpha(hillshade)`, light from NW, `RELIEF`), linearly sampled so slopes look smooth, water transparent, rebuilt on WorldGenerated; 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). |
| @@ -30,7 +30,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 30 | 30 | - `src/structures.rs` `Structure{kind: StructureKind::{Campfire, Torch, Bed, Wall, Door}}` + TilePos entities; `Fuel(u32)` only on kinds with `fuel_ticks()` (fires: 12h/8h), `burn_fuel` (SimSystems::Act) despawns at 0; beds are permanent. Kind stats: `cost()` (material, amount): fires/bed/door wood, wall 2 stone; `light()` radius+color (fires only), `name()`. Visuals are Mesh2d children (campfire = stone ring + crossed logs + triangle flame, torch = stick + small flame, bed = frame + blanket + pillow, wall = stone square, door = brown square + knob; avoid plain circles, those read as colonists; z=0.8). `spawn_structure` inserts Fuel when needed. Cleared on WorldGenerated. |
| 31 | 31 | - `src/needs.rs` `Rest(f32)` on colonists (1 = rested): `update_rest` (SimSystems::Act) drains 1/24 per game hour awake, recovers 1/7 per hour (1/5 in a bed, ×ROOF_REST_BONUS 1.25 under a roof) while `is_sleeping` (Sleep job, at spot, path empty). `wants_sleep`: rest < NAP_REST (0.3) any time, or night (21–6) and rest < BEDTIME_REST (0.9). `done_sleeping`: rest full and not night (sleep lasts until morning). EXHAUSTED_REST (0.1) makes choose_jobs drop autonomous (not ordered) jobs. `SleepContext::spot` = nearest bed not reserved as `Target::SleepSpot`, else nearest free tile (dry, no plant/stockpile/fire/item, unreserved `Target::SleepSpot`) to the nearest campfire, else home. Sleeping colonists show a `SleepMarker` Text2d "z" child. |
| 32 | 32 | - `src/building.rs` construction planning. `BuildKind::{Structure(StructureKind), Roof, Pave}` with `cost()`/`work()`/`blocks_tile()` (walls). `Blueprints(HashMap<tile, BuildKind>)` resource, drawn as gizmo outlines, cleared on WorldGenerated. `plan_shelter` (SimSystems::Decide, every 300 ticks, only while no non-Pave blueprints; merges its plan into Blueprints): for unroofed beds, if `enclosed_room` (4-conn flood bounded by walls/doors/rock, ≤ MAX_ROOM_TILES, not touching map edge) → Roof blueprints for unroofed room tiles; else `ShelterContext::plan` tries every wall ring with 0–2 tiles margin per side around the beds' bbox, cheapest (ring length + penalty per margin-0 side) where every ring tile is rock/existing wall/door or buildable (dry, no plant/item/structure; empty stockpile tiles allowed and removed from the stockpile when planned), one Door on the non-corner ring tile nearest the campfire/home. |
| 33 | -- Build tool (building.rs): `BuildTool{kind}` `mode: Option<BuildMode::{Place(kind), Deconstruct}>` cycled by B / Build button through `BUILD_TOOL_CYCLE` (Wall, Door, Campfire, Torch, Bed, Deconstruct, off); Deconstruct drag marks structure tiles as `BuildKind::Deconstruct` blueprints (cost 0 → Build task without pickup; completion despawns the structure, clears wall flag, refunds half its cost); Wall drag = room outline (`placements`), others place at the release tile; `place_blueprints` skips plants/walls/structures/water/rock and clears stockpile under walls/doors; shift-drag removes blueprints in the rect. Zone tool and build tool cancel each other; selection ignores clicks while either is active. Colonists build manual blueprints via `find_blueprint_work`. | |
| 33 | +- Build tool (building.rs): `BuildTool{kind}` `mode: Option<BuildMode::{Place(kind), Deconstruct}>` cycled by B / Build button through `BUILD_TOOL_CYCLE` (Wall, Door, Campfire, Torch, Bed, Workbench, Statue, Floor (drag fills a rect via `place_floors`), Deconstruct, off); Deconstruct drag marks structure tiles as `BuildKind::Deconstruct` blueprints (cost 0 → Build task without pickup; completion despawns the structure, clears wall flag, refunds half its cost); Wall drag = room outline (`placements`), others place at the release tile; `place_blueprints` skips plants/walls/structures/water/rock and clears stockpile under walls/doors; shift-drag removes blueprints in the rect. Zone tool and build tool cancel each other; selection ignores clicks while either is active. Colonists build manual blueprints via `find_blueprint_work`. | |
| 34 | 34 | - `src/designations.rs` `Designations(HashSet<tile>)` of trees/rock (`designatable` = site_work Chop/Mine) marked with the Chop/Mine tool (X; drag adds, shift-drag clears; yellow X gizmos); `prune_designations` drops worked-out tiles. choose_jobs works designated sites (find_work with a site predicate) right after food gathering, regardless of stock goals. Tools (zone, build, designate) cancel each other. |
| 35 | 35 | - Refuel: `BuildKind::Refuel(kind)` (cost = kind.cost(), 60 work) reuses the Build task; `find_refuel` (before find_lighting) picks the lowest-fuel fire within 2×HOME_RADIUS of home below 1/REFUEL_BELOW_SHARE of full; completion resets Fuel to full. |
| 36 | 36 | - Camera zoom buttons: Action::ZoomIn/ZoomOut (=/-) → `button_zoom` using `CameraController::zoomed`. |
| @@ -42,18 +42,22 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 42 | 42 | - `src/paths.rs` traffic wear. `follow_path` adds 1 wear when a colonist (not an animal) enters a tile. `update_paths` (hourly): wear × WEAR_KEPT_PER_HOUR (0.98); Grass/Soil/Sand with wear ≥ PATH_WEAR (8), no plant, not farm → `Terrain::Path`; Path with wear < REGROW_WEAR → Grass. `plan_paving` (hourly) keeps up to PAVE_BATCH `BuildKind::Pave` blueprints on the busiest Path tiles with wear ≥ PAVE_WEAR (20); built with 1 stone → `Terrain::Paved`. Path cost 80, Paved 60 (= CHEAPEST_MOVE_COST, which A*'s heuristic uses; a test enforces no terrain is cheaper), so colonists prefer and reinforce them. Hover shows wear. Tuned from observed traffic of 3 colonists: busiest tiles reach ~15–30 wear by day 3–4. |
| 43 | 43 | - `src/economy.rs` learned stock targets. `Stock::{Wood, Stone, Iron, Gold, RawFood, Meals}` groups item kinds (raw foods are one group). `ColonyGoals` keeps `use_per_day` per stock (EMA, ~24 h memory; seeded with guesses), `record_use(kind, n)` is called by jobs for real consumption (eating, cooking input, build material — not spoilage), `learn_needs` (hourly) updates rates and goals = clamp(rate × buffer days (2 materials, 1.5 food), min, 300) + material for pending blueprints (`planned_needs`). `wanted(kind, stocks, on_the_way)` compares the whole group. Stock counts only ground items `in_colony` (Chebyshev ≤ `COLONY_RADIUS` 60 of home; colonist searches cover it, tested) plus carried ones — far stray stacks used to satisfy goals while unreachable (jobs, info panel and arrivals all use this). `ColonyGoals::fixed` for tests. |
| 44 | 44 | - `src/population.rs` `ColonyLog` (last 4 events, shown in the info panel). `starve`: `Starvation` ticks at zero hunger; after STARVATION_TICKS (1 day) the colonist dies (drops load, releases reservations). `arrivals` daily at 10:00: if `prosperous` (< MAX_COLONISTS 20, stored nutrition ≥ 1 per colonist, a free bed or enough wood for one) and a 60% roll, a newcomer (`spawn_colonist`, next unused name) appears at an `edge_tile` with an unordered MoveTo home. |
| 45 | -- `src/family.rs` families. `Family(u32)` (shared by partners and their children), `Partner(Entity)`, `Child{born}` (CHILD_SCALE visual, grows up after CHILDHOOD_TICKS = 4 days in `grow_up`) on colonists. `bond` (hourly): unpartnered adults not of the same family within BOND_RANGE accumulate `Affinity` hours per pair; ≥ BOND_HOURS + BOND_CHANCE roll → partners with a new family id; dangling Partner removed when the partner dies. `Homes{by_family: family → Home{lo, hi}}` (wall-ring corners, interior HOME_INTERIOR 4×3, `bed_tiles` 2 beds top-left, `owner(tile)`). `plan_homes` (every 300 ticks, Decide): homes of extinct families move to `Homes.vacant` (serde default; `all()`, `is_home()` cover owned + vacant; `owner()` owned only, so vacant beds count as communal for sleeping); `abandoned_home` adopts enclosed HOME_INTERIOR rooms around unowned beds as vacant (old saves); couples without a home take the vacant home nearest the colony before a new one is planned. Floods `reachable` from colony home (if walkable) and drops blueprints with no reachable tile in their 3×3 (sealed pockets inflated goals forever). Per home: `door_repair` (no door/planned door whose `outward` tile is reachable → Door on an empty door site, else Deconstruct the best wall; runs even with blueprints pending), else roofs (`roofs_needed`), statue for owned built homes. `surplus_beds`: reachable communal beds beyond homeless adults → Deconstruct (nearest kept). New homes via `HomeContext::plan` (BFS from colony home, center ≥ HOME_MIN_DISTANCE, core open + 1-tile belt unclaimed AND walkable — homes used to be packed wall to wall with doors facing a neighbour's wall) → wall/door/bed blueprints (door nearest colony home). `births` (daily 20:00): family with 2 living partners, built home (roofed, 2 beds), < MAX_CHILDREN, colony < MAX_COLONISTS and food ≥ colonists, BIRTH_CHANCE → child spawned inside home. Children: choose_jobs gives them only sleep/eat/wander; `find_bed_site` counts adults. Sleep: own home bed (adults), free roofed home floor (children), else communal beds (`Homes::owner` None) → campfire. `plan_shelter` ignores beds inside homes (owned or vacant). Selection text shows partner / child days left. | |
| 45 | +- `src/family.rs` families. `Family(u32)` (shared by partners and their children), `Partner(Entity)`, `Child{born}` (CHILD_SCALE visual, grows up after CHILDHOOD_TICKS = 4 days in `grow_up`) on colonists. `bond` (hourly): unpartnered adults not of the same family within BOND_RANGE accumulate `Affinity` hours per pair; ≥ BOND_HOURS + BOND_CHANCE roll → partners with a new family id; dangling Partner removed when the partner dies. `Homes{by_family: family → Home{lo, hi}}` (wall-ring corners, interior HOME_INTERIOR 4×3, `bed_tiles` 2 beds top-left, `owner(tile)`). `plan_homes` (every 300 ticks, Decide): homes of extinct families move to `Homes.vacant` (serde default; `all()`, `is_home()` cover owned + vacant; `owner()` owned only, so vacant beds count as communal for sleeping); `abandoned_home` adopts enclosed HOME_INTERIOR rooms around unowned beds as vacant (old saves); couples without a home take the vacant home nearest the colony before a new one is planned. Floods `reachable` from colony home (if walkable) and drops blueprints with no reachable tile in their 3×3 (sealed pockets inflated goals forever). Per home: `door_repair` (no door/planned door whose `outward` tile is reachable → Door on an empty door site, else Deconstruct the best wall; runs even with blueprints pending), else roofs (`roofs_needed`), statue + `BuildKind::Floor` on unfloored interior tiles (`floored`; doesn't overwrite other blueprints) for owned built homes. `surplus_beds`: reachable communal beds beyond homeless adults → Deconstruct (nearest kept). New homes via `HomeContext::plan` (BFS from colony home, center ≥ HOME_MIN_DISTANCE, core open + 1-tile belt unclaimed AND walkable — homes used to be packed wall to wall with doors facing a neighbour's wall) → wall/door/bed blueprints (door nearest colony home). `births` (daily 20:00): family with 2 living partners, built home (roofed, 2 beds), < MAX_CHILDREN, colony < MAX_COLONISTS and food ≥ colonists, BIRTH_CHANCE → child spawned inside home. Children: choose_jobs gives them only sleep/eat/wander; `find_bed_site` counts adults. Sleep: own home bed (adults), free roofed home floor (children), else communal beds (`Homes::owner` None) → campfire. `plan_shelter` ignores beds inside homes (owned or vacant). Selection text shows partner / child days left. | |
| 46 | 46 | - `src/mood.rs` `Mood(f32)` (default 0.6) eases hourly (1/MOOD_HOURS) toward `mood_target(MoodFactors)`: hunger, rest, last meal cooked/raw within a day, `Sick`, partner, built family home, grief (a `Losses` tick within 2 days; starvation and old age push losses). `Ate{colonist, kind}` message from run_jobs' Eat completion → `digest`: `LastMeal`, raw food rolls `poison_chance` (meat 15%, berries/veg 4%) → `Sick{until}` 12 h (work rate halved in run_jobs). `leave`: adults below LEAVE_MOOD for LEAVE_AFTER_HOURS (48, `Unhappy` hours) leave (despawn, drop load). `Age{born}` (1 day = 1 year; `assign_age` gives new adults 18–40, children 0); `old_age` daily: from OLD_AGE 60, death chance 3%/year over. Selection shows mood/age/sick. |
| 47 | 47 | - Storage rules (zones.rs): `Zones.rules` per stockpile tile `StorageRule{refused: Vec<Stock>, priority: StoragePriority::{Low, Normal, High}}` (absent = default), `accepts(tile, kind)`, `update_region_rule` applies to the 4-connected stockpile region; removing tiles drops rules. `find_storage` tries priorities High→Normal→Low, nearest accepting free tile with room; `find_haul` treats items as loose when their tile doesn't accept them or a higher-priority accepting tile with room exists (`storage_candidates`), so stock moves up the priorities and settles. `ColonyGoals` overrides (`adjust` ±, `reset`, `is_overridden`) win over learned goals. |
| 48 | 48 | - `src/panel.rs` top-right colonist panel (also: stockpile section with accept toggles + priority cycle when a stockpile tile is selected; collapsible Stock targets with -/+/auto per Stock): roster of `ColonistButton`s (rebuilt when names/child/mood face change; click selects) and, for a selected adult, `WorkToggle` buttons per `WorkType::{Food, Build, Gather, Haul, Tame}` toggling the colonist's `WorkPriorities` (required on Colonist; red = disabled). choose_jobs' autonomous candidates are a `(WorkType, finder)` list filtered by `allows`; orders ignore priorities. |
| 49 | 49 | - `src/climate.rs` seasons/temperature. Year = YEAR_DAYS 12 (4 seasons × SEASON_DAYS 3, starts Spring; independent of ageing's day=year). `outdoor_temperature(tick)` = seasonal cosine (MEAN 12, swing 14, coldest at COLDEST_YEAR_FRACTION) + day/night swing (coldest 04:00); `daily_mean(day)`, `growing_day` (mean ≥ 8), `sowing_season` (next SOW_AHEAD_DAYS growing; gates find_sowing), `cold_days_ahead` (non-growing days in the next FORESIGHT_DAYS 6 → `ColonyGoals::set_cold_days`, food/wood buffer days = max(normal, cold days)). `growth_factor(temp)` scales plant growth; Crop with growth < 1 dies below FROST_TEMP. `spoil_factor` slows spoilage in cold. `tile_temperature(map, outdoor, tile, fires)`: roof pulls toward 16 by ROOF_INSULATION 0.7, fires add `StructureKind::heat()` with light-radius falloff, blocked by walls. `Warmth(f32)` on colonists (required), `update_warmth` every 60 ticks; `cold()` < COLD_TEMP 5 → hunger ×1.5, rest recovery ×0.5, mood −0.15. choose_jobs: cold colonists MoveTo `find_warm_spot` (tiles ≥ WARM_TEMP near fires) before work; `chilly_soon` triggers find_lighting's campfire (torches only when dark). `snow_cover(tick)` → `LightMap::with_snow` (8 steps, triggers recompute) → tilemap `Layer::Snow` (z 0.2, white alpha overlay on unroofed land). Info panel shows season/day/temp (font has no ° glyph). |
| 50 | -- Crafting (jobs): `Task::Craft{recipe: Recipe::{Meal, Tools}, pickup, station, progress}` (was Cook): recipe gives station kind (Campfire/Workbench), accepted input, input count, work, output; `find_crafting`, `input_source`. Tools: `WorkType::Craft` candidate when Tools wanted and TOOL_IRON iron available → craft at a workbench, else `find_workbench` (one, near home, autonomous Structure build like campfire/bed). `Task::Equip{pickup}` (before autonomous candidates) for adults without `Tool{wear}`; equip records Tools use (learned demand); `tool_rate` ×1.5 work; wear per working tick, removed at TOOL_LIFE_TICKS (2 days). Iron has min goal 6. `StructureKind::Statue` (5 gold): plan_homes adds one blueprint at `Home::statue_tile` once a home is built → gold demand via planned_needs; mood +0.1 (`beauty`) when the family home has one. | |
| 50 | +- Crafting (jobs): `Task::Craft{recipe: Recipe::{Meal, Tools}, pickup, station, progress}` (was Cook): recipe gives station kind (Campfire/Workbench), accepted input, input count, work, output; `find_crafting`, `input_source`. Tools: `WorkType::Craft` candidate when Tools wanted and TOOL_IRON iron available → craft at a workbench, else `find_workbench` (one, near home, autonomous Structure build like campfire/bed). `Task::Equip{pickup}` (before autonomous candidates) for adults without `Tool{wear}`; equip records Tools use (learned demand); `tool_rate` ×1.5 work; wear per working tick, removed at TOOL_LIFE_TICKS (2 days). Iron has min goal 6. `StructureKind::Statue` (5 gold): plan_homes adds one blueprint at `Home::statue_tile` once a home is built → gold demand via planned_needs; mood +0.1 (`beauty`) when the family home has one; +0.05 (`floored`) when its interior is all WoodFloor. | |
| 51 | 51 | - `src/save.rs` save/load. Action::Save/Load (F5/F9). `snapshot(world) -> SaveGame` (serde_json; SAVE_VERSION): seed, tick, rng, `WorldMap::layers()` (`MapLayers`: RLE terrain/plants/walls/roofs as u8 + sparse growth/wear; heights regenerated by `generate(size, seed)` in `restore_map`), `Zones::save` (`ZonesSave`, UVec2-keyed maps become Vecs — serde_json needs string keys), blueprints Vec, Homes, Designations, ColonyGoals, log, losses, colonists (`ColonistSave`, partner as index), items, structures (+fuel), animals. Not saved: jobs, paths, reservations, Affinity, idle timers (colonists restart idle). Storage: web localStorage key `simulator-save` (~150 KB), native `simulator-save.json` in cwd. Load: `start_load` (PreUpdate, exclusive) parses, inserts WorldSeed + restored map, writes WorldGenerated (all reset systems run in Update), stores `PendingLoad`; `finish_load` (PostUpdate) despawns colonists/animals/items/structures and `apply`s the save via SystemState spawn helpers (`spawn_colonist`, `spawn_stack`, `spawn_structure` → Entity, `spawn_animal`). New persistent state = add to SaveGame + snapshot + apply (+ serde derives; bevy `serialize` feature for glam). |
| 52 | 52 | - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep). |
| 53 | 53 | |
| 54 | 54 | ## Performance notes |
| 55 | 55 | - Native profiling: temporarily add a Startup system writing `Action::SetSpeed(SimSpeed::Max)` in sim.rs, `CARGO_PROFILE_RELEASE_DEBUG=true cargo build --release`, run, `sample <pid> 15 -file out.txt`, aggregate `simulator::` symbols (revert the hack). |
| 56 | 56 | - Hot spots fixed so far: predicate BFS scanning items per tile (use `nearest_of` with precollected sets), searches that cannot match (pre-check wanted kinds, empty farm sets), choose_jobs setup skipped unless someone is ready (idle timer ticked in a pre-pass), periodic passes over only `WorldMap::worn_tiles` / `growing_plants`, light-driven tilemap re-tints rate-limited to 100 ms, `BuildContext.occupied` set. |
| 57 | +- `BuildKind::Floor` (1 wood, 90 work): not blocked by structures (like Roof), sets Terrain::WoodFloor; Pave/Floor blueprints don't hold back plan_shelter. | |
| 58 | +- Lighting recomputes on `WorldMap::shape_version()` (bumped only by set_wall/set_roof changes), not on every TileChanged — flooring 100+ tiles made it a full-map pass per tile. | |
| 59 | +- `search_reach` is the flood's half-width (√max/2); it was the full width, so nearest_of's precheck let far targets through and every haul search swept its budget for map-edge stacks. `ColonyReach` resource (Option<Reach>, refreshed by plan_homes every 300 ticks, cleared on WorldGenerated) filters find_haul stacks and blueprint sites (`allows`, `allows_near`). Animal migration uses `zones.home` instead of `spawn_tiles` (full largest-region scan). | |
| 60 | +- Browser timings are only valid while the pane is visible: a hidden/popped-out-behind pane throttles rAF to ~1 Hz (check `tabs_context`); otherwise measure natively (temporary Update system logging SimTick per 10 s real time). | |
| 57 | 61 | - Searches and A* share `can_step` (no corner cutting): a target the BFS could reach diagonally but A* couldn't made A* exhaust the map (~10 ms) many times a second on a 50-day save. Mined yields drop on the miner's tile when the site is only diagonally reachable. |
| 58 | 62 | - Profiling a saved colony: copy the browser save to `simulator-save.json` (gitignored; the native build reads it with F9), temporarily add a Startup system writing `Action::Load` + `SetSpeed(Max)`. `sample` sometimes captures only a handful of samples on the first try — rerun. |
| 59 | 63 | - choose_jobs: wanted kinds computed once per colonist into an array (`ItemKind::index`), `occupied` set built once per pass; `find_blueprint_work` pre-collects stand tiles of affordable unreserved blueprints (none → no search); `find_haul` only counts loose stacks whose kind has a storage candidate and uses `nearest_of`; gather `find_work` failures are memoised (`GatherMemo`: same wanted-kind mask → skip for GATHER_RETRY_TICKS). `Reach::flood` (bitmap) for plan_homes' reachability. |
src/animals.rs +2 -4
| @@ -258,14 +258,12 @@ fn migrate( | ||
| 258 | 258 | assets: Res<AnimalAssets>, |
| 259 | 259 | mut rng: ResMut<SimRng>, |
| 260 | 260 | animals: Query<&Animal, Without<Tame>>, |
| 261 | + zones: Res<Zones>, | |
| 261 | 262 | ) { |
| 262 | 263 | if !tick.0.is_multiple_of(MIGRATION_INTERVAL_TICKS) || tick.0 == 0 { |
| 263 | 264 | return; |
| 264 | 265 | } |
| 265 | - let colony = spawn_tiles(&map, 1) | |
| 266 | - .first() | |
| 267 | - .copied() | |
| 268 | - .unwrap_or(map.size / 2); | |
| 266 | + let colony = zones.home(map.size / 2); | |
| 269 | 267 | for kind in AnimalKind::ALL { |
| 270 | 268 | let alive = animals.iter().filter(|a| a.kind == kind).count(); |
| 271 | 269 | if missing_animals(kind, alive) |
src/building.rs +40 -3
| @@ -12,7 +12,7 @@ use crate::{ | ||
| 12 | 12 | sim::{SimSystems, SimTick}, |
| 13 | 13 | structures::{Structure, StructureKind}, |
| 14 | 14 | ui::PointerOverUi, |
| 15 | - world::{TILE_SIZE, TilePos, WorldMap}, | |
| 15 | + world::{TILE_SIZE, Terrain, TilePos, WorldMap}, | |
| 16 | 16 | worldgen::WorldGenerated, |
| 17 | 17 | zones::{PREVIEW_ADD_COLOR, PREVIEW_REMOVE_COLOR, Zones, can_zone, removing}, |
| 18 | 18 | }; |
| @@ -48,6 +48,7 @@ const WALL_BLUEPRINT_COLOR: Color = Color::srgba(0.5, 0.75, 1.0, 0.8); | ||
| 48 | 48 | const DOOR_BLUEPRINT_COLOR: Color = Color::srgba(1.0, 0.8, 0.4, 0.8); |
| 49 | 49 | const PAVE_BLUEPRINT_COLOR: Color = Color::srgba(0.85, 0.85, 0.85, 0.5); |
| 50 | 50 | const DECONSTRUCT_COLOR: Color = Color::srgba(1.0, 0.3, 0.25, 0.9); |
| 51 | +const FLOOR_BLUEPRINT_COLOR: Color = Color::srgba(0.75, 0.5, 0.25, 0.6); | |
| 51 | 52 | const ROOF_BLUEPRINT_COLOR: Color = Color::srgba(0.8, 0.6, 1.0, 0.35); |
| 52 | 53 | |
| 53 | 54 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] |
| @@ -55,6 +56,7 @@ pub enum BuildKind { | ||
| 55 | 56 | Structure(StructureKind), |
| 56 | 57 | Roof, |
| 57 | 58 | Pave, |
| 59 | + Floor, | |
| 58 | 60 | Refuel(StructureKind), |
| 59 | 61 | Deconstruct, |
| 60 | 62 | } |
| @@ -65,6 +67,7 @@ impl BuildKind { | ||
| 65 | 67 | BuildKind::Structure(kind) => kind.cost(), |
| 66 | 68 | BuildKind::Roof => (ItemKind::Wood, 1), |
| 67 | 69 | BuildKind::Pave => (ItemKind::Stone, 1), |
| 70 | + BuildKind::Floor => (ItemKind::Wood, 1), | |
| 68 | 71 | BuildKind::Refuel(kind) => kind.cost(), |
| 69 | 72 | BuildKind::Deconstruct => (ItemKind::Wood, 0), |
| 70 | 73 | } |
| @@ -81,6 +84,7 @@ impl BuildKind { | ||
| 81 | 84 | BuildKind::Structure(StructureKind::Statue) => 900, |
| 82 | 85 | BuildKind::Roof => 120, |
| 83 | 86 | BuildKind::Pave => 120, |
| 87 | + BuildKind::Floor => 90, | |
| 84 | 88 | BuildKind::Refuel(_) => 60, |
| 85 | 89 | BuildKind::Deconstruct => 200, |
| 86 | 90 | } |
| @@ -91,6 +95,7 @@ impl BuildKind { | ||
| 91 | 95 | BuildKind::Structure(kind) => kind.name(), |
| 92 | 96 | BuildKind::Roof => "roof", |
| 93 | 97 | BuildKind::Pave => "paving", |
| 98 | + BuildKind::Floor => "floor", | |
| 94 | 99 | BuildKind::Refuel(_) => "refuelling", |
| 95 | 100 | BuildKind::Deconstruct => "deconstruction", |
| 96 | 101 | } |
| @@ -263,7 +268,10 @@ pub fn plan_shelter( | ||
| 263 | 268 | mut blueprints: ResMut<Blueprints>, |
| 264 | 269 | homes: Res<Homes>, |
| 265 | 270 | ) { |
| 266 | - let shelter_pending = blueprints.0.values().any(|k| *k != BuildKind::Pave); | |
| 271 | + let shelter_pending = blueprints | |
| 272 | + .0 | |
| 273 | + .values() | |
| 274 | + .any(|k| !matches!(k, BuildKind::Pave | BuildKind::Floor)); | |
| 267 | 275 | if !tick.0.is_multiple_of(PLAN_INTERVAL_TICKS) || shelter_pending { |
| 268 | 276 | return; |
| 269 | 277 | } |
| @@ -303,10 +311,11 @@ pub fn plan_shelter( | ||
| 303 | 311 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 304 | 312 | pub enum BuildMode { |
| 305 | 313 | Place(StructureKind), |
| 314 | + Floor, | |
| 306 | 315 | Deconstruct, |
| 307 | 316 | } |
| 308 | 317 | |
| 309 | -pub const BUILD_TOOL_CYCLE: [BuildMode; 8] = [ | |
| 318 | +pub const BUILD_TOOL_CYCLE: [BuildMode; 9] = [ | |
| 310 | 319 | BuildMode::Place(StructureKind::Wall), |
| 311 | 320 | BuildMode::Place(StructureKind::Door), |
| 312 | 321 | BuildMode::Place(StructureKind::Campfire), |
| @@ -314,6 +323,7 @@ pub const BUILD_TOOL_CYCLE: [BuildMode; 8] = [ | ||
| 314 | 323 | BuildMode::Place(StructureKind::Bed), |
| 315 | 324 | BuildMode::Place(StructureKind::Workbench), |
| 316 | 325 | BuildMode::Place(StructureKind::Statue), |
| 326 | + BuildMode::Floor, | |
| 317 | 327 | BuildMode::Deconstruct, |
| 318 | 328 | ]; |
| 319 | 329 | |
| @@ -357,6 +367,19 @@ pub fn mark_deconstruction( | ||
| 357 | 367 | } |
| 358 | 368 | } |
| 359 | 369 | |
| 370 | +pub fn place_floors(map: &WorldMap, blueprints: &mut Blueprints, start: UVec2, end: UVec2) { | |
| 371 | + for tile in rect(start, end) { | |
| 372 | + let p = tile.as_ivec2(); | |
| 373 | + let floorable = can_zone(map, p) | |
| 374 | + && !map.is_wall(p) | |
| 375 | + && map.plant(p).is_none() | |
| 376 | + && map.get(p) != Some(Terrain::WoodFloor); | |
| 377 | + if floorable && blueprints.get(tile).is_none() { | |
| 378 | + blueprints.0.insert(tile, BuildKind::Floor); | |
| 379 | + } | |
| 380 | + } | |
| 381 | +} | |
| 382 | + | |
| 360 | 383 | pub fn placements(kind: StructureKind, start: UVec2, end: UVec2) -> Vec<UVec2> { |
| 361 | 384 | if kind != StructureKind::Wall { |
| 362 | 385 | return vec![end]; |
| @@ -441,6 +464,7 @@ fn build_tool( | ||
| 441 | 464 | let tiles = placements(kind, start, end); |
| 442 | 465 | place_blueprints(&map, &mut zones, &mut blueprints, &occupied, kind, &tiles); |
| 443 | 466 | } |
| 467 | + BuildMode::Floor => place_floors(&map, &mut blueprints, start, end), | |
| 444 | 468 | BuildMode::Deconstruct => mark_deconstruction(&mut blueprints, &occupied, start, end), |
| 445 | 469 | } |
| 446 | 470 | } |
| @@ -479,6 +503,7 @@ fn draw_blueprints(mut gizmos: Gizmos, blueprints: Res<Blueprints>) { | ||
| 479 | 503 | BuildKind::Structure(StructureKind::Door) => DOOR_BLUEPRINT_COLOR, |
| 480 | 504 | BuildKind::Roof => ROOF_BLUEPRINT_COLOR, |
| 481 | 505 | BuildKind::Pave => PAVE_BLUEPRINT_COLOR, |
| 506 | + BuildKind::Floor => FLOOR_BLUEPRINT_COLOR, | |
| 482 | 507 | BuildKind::Deconstruct => DECONSTRUCT_COLOR, |
| 483 | 508 | _ => WALL_BLUEPRINT_COLOR, |
| 484 | 509 | }; |
| @@ -642,4 +667,16 @@ mod tests { | ||
| 642 | 667 | let map = WorldMap::filled(UVec2::splat(30), Terrain::Grass); |
| 643 | 668 | assert!(enclosed_room(&map, &HashSet::new(), UVec2::new(15, 15)).is_none()); |
| 644 | 669 | } |
| 670 | + | |
| 671 | + #[test] | |
| 672 | + fn the_floor_tool_fills_an_area_but_skips_walls_and_water() { | |
| 673 | + let mut map = WorldMap::filled(UVec2::splat(10), Terrain::Grass); | |
| 674 | + map.set_wall(UVec2::new(2, 2), true); | |
| 675 | + map.set(UVec2::new(3, 3), Terrain::ShallowWater); | |
| 676 | + map.set(UVec2::new(1, 1), Terrain::WoodFloor); | |
| 677 | + let mut blueprints = Blueprints::default(); | |
| 678 | + place_floors(&map, &mut blueprints, UVec2::new(3, 3), UVec2::ONE); | |
| 679 | + assert_eq!(blueprints.0.len(), 9 - 3); | |
| 680 | + assert!(blueprints.0.values().all(|k| *k == BuildKind::Floor)); | |
| 681 | + } | |
| 645 | 682 | } |
src/family.rs +37 -8
| @@ -8,12 +8,12 @@ use crate::{ | ||
| 8 | 8 | colonist::{Colonist, ColonistAssets, NAMES, spawn_colonist}, |
| 9 | 9 | economy::in_colony, |
| 10 | 10 | items::{Item, ItemAssets}, |
| 11 | - pathfinding::{Reach, nearest_tile}, | |
| 11 | + pathfinding::{ColonyReach, Reach, nearest_tile}, | |
| 12 | 12 | population::{ColonyLog, MAX_COLONISTS, food_in_store}, |
| 13 | 13 | rng::SimRng, |
| 14 | 14 | sim::{SimSystems, SimTick, TICKS_PER_HOUR}, |
| 15 | 15 | structures::{Structure, StructureKind}, |
| 16 | - world::{TilePos, WorldMap}, | |
| 16 | + world::{Terrain, TilePos, WorldMap}, | |
| 17 | 17 | worldgen::WorldGenerated, |
| 18 | 18 | zones::{Zones, can_zone}, |
| 19 | 19 | }; |
| @@ -24,6 +24,7 @@ impl Plugin for FamilyPlugin { | ||
| 24 | 24 | fn build(&self, app: &mut App) { |
| 25 | 25 | app.init_resource::<Homes>() |
| 26 | 26 | .init_resource::<Affinity>() |
| 27 | + .init_resource::<ColonyReach>() | |
| 27 | 28 | .add_systems(Update, clear_families.run_if(on_message::<WorldGenerated>)) |
| 28 | 29 | .add_systems( |
| 29 | 30 | FixedUpdate, |
| @@ -190,7 +191,12 @@ impl Homes { | ||
| 190 | 191 | #[derive(Resource, Default, Debug)] |
| 191 | 192 | pub struct Affinity(pub HashMap<(Entity, Entity), u32>); |
| 192 | 193 | |
| 193 | -fn clear_families(mut homes: ResMut<Homes>, mut affinity: ResMut<Affinity>) { | |
| 194 | +fn clear_families( | |
| 195 | + mut homes: ResMut<Homes>, | |
| 196 | + mut affinity: ResMut<Affinity>, | |
| 197 | + mut reach: ResMut<ColonyReach>, | |
| 198 | +) { | |
| 199 | + reach.0 = None; | |
| 194 | 200 | homes.by_family.clear(); |
| 195 | 201 | homes.vacant.clear(); |
| 196 | 202 | affinity.0.clear(); |
| @@ -385,6 +391,10 @@ pub fn door_repair( | ||
| 385 | 391 | Some((site, kind)) |
| 386 | 392 | } |
| 387 | 393 | |
| 394 | +pub fn floored(map: &WorldMap, tile: UVec2) -> bool { | |
| 395 | + map.get(tile.as_ivec2()) == Some(Terrain::WoodFloor) | |
| 396 | +} | |
| 397 | + | |
| 388 | 398 | pub fn abandoned_home(map: &WorldMap, doors: &HashSet<UVec2>, bed: UVec2) -> Option<Home> { |
| 389 | 399 | let room = enclosed_room(map, doors, bed)?; |
| 390 | 400 | let lo = room.iter().fold(UVec2::MAX, |a, t| a.min(*t)); |
| @@ -427,6 +437,7 @@ fn plan_homes( | ||
| 427 | 437 | structures: Query<(&TilePos, &Structure), Without<Colonist>>, |
| 428 | 438 | items: Query<&TilePos, With<Item>>, |
| 429 | 439 | families: Query<(Option<&Family>, Has<Partner>, Has<Child>), With<Colonist>>, |
| 440 | + mut colony_reach: ResMut<ColonyReach>, | |
| 430 | 441 | ) { |
| 431 | 442 | if !tick.0.is_multiple_of(HOME_PLAN_INTERVAL_TICKS) { |
| 432 | 443 | return; |
| @@ -485,6 +496,10 @@ fn plan_homes( | ||
| 485 | 496 | homes.by_family.insert(family, home); |
| 486 | 497 | } |
| 487 | 498 | let reachable = Reach::flood(&map, colony_home); |
| 499 | + colony_reach.0 = map | |
| 500 | + .move_cost(colony_home.as_ivec2()) | |
| 501 | + .is_some() | |
| 502 | + .then(|| reachable.clone()); | |
| 488 | 503 | let anchored = map.move_cost(colony_home.as_ivec2()).is_some(); |
| 489 | 504 | blueprints |
| 490 | 505 | .0 |
| @@ -511,9 +526,14 @@ fn plan_homes( | ||
| 511 | 526 | for tile in roofs { |
| 512 | 527 | blueprints.0.insert(tile, BuildKind::Roof); |
| 513 | 528 | } |
| 514 | - if built && owned.contains(home) && !statues.iter().any(|&t| home.contains(t)) { | |
| 515 | - let statue = BuildKind::Structure(StructureKind::Statue); | |
| 516 | - blueprints.0.insert(home.statue_tile(), statue); | |
| 529 | + if built && owned.contains(home) { | |
| 530 | + if !statues.iter().any(|&t| home.contains(t)) { | |
| 531 | + let statue = BuildKind::Structure(StructureKind::Statue); | |
| 532 | + blueprints.0.insert(home.statue_tile(), statue); | |
| 533 | + } | |
| 534 | + for tile in home.interior().filter(|t| !floored(&map, *t)) { | |
| 535 | + blueprints.0.entry(tile).or_insert(BuildKind::Floor); | |
| 536 | + } | |
| 517 | 537 | } |
| 518 | 538 | } |
| 519 | 539 | let homeless_adults = families |
| @@ -789,9 +809,10 @@ mod tests { | ||
| 789 | 809 | } |
| 790 | 810 | |
| 791 | 811 | // Gold has one use: a statue in each finished home, which lifts the family's mood. Planning |
| 792 | - // it is what makes the colony want gold at all. | |
| 812 | + // it is what makes the colony want gold at all. Finished homes also get a wooden floor (a | |
| 813 | + // smaller mood boost), built under beds and statues alike. | |
| 793 | 814 | #[test] |
| 794 | - fn finished_homes_get_a_statue() { | |
| 815 | + fn finished_homes_get_a_statue_and_floor() { | |
| 795 | 816 | let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass); |
| 796 | 817 | let home = Home::at(UVec2::new(5, 5)); |
| 797 | 818 | let door = UVec2::new(7, 5); |
| @@ -810,6 +831,7 @@ mod tests { | ||
| 810 | 831 | by_family: HashMap::from([(1, home)]), |
| 811 | 832 | ..default() |
| 812 | 833 | }) |
| 834 | + .init_resource::<ColonyReach>() | |
| 813 | 835 | .add_systems(Update, plan_homes); |
| 814 | 836 | app.world_mut().spawn(( |
| 815 | 837 | Structure { |
| @@ -825,6 +847,11 @@ mod tests { | ||
| 825 | 847 | blueprints.get(home.statue_tile()), |
| 826 | 848 | Some(BuildKind::Structure(StructureKind::Statue)) |
| 827 | 849 | ); |
| 850 | + let floors = home | |
| 851 | + .interior() | |
| 852 | + .filter(|t| blueprints.get(*t) == Some(BuildKind::Floor)) | |
| 853 | + .count(); | |
| 854 | + assert_eq!(floors, home.interior().count() - 1); | |
| 828 | 855 | } |
| 829 | 856 | |
| 830 | 857 | fn walled(map: &mut WorldMap, home: Home, door: UVec2) -> HashSet<UVec2> { |
| @@ -907,6 +934,7 @@ mod tests { | ||
| 907 | 934 | (UVec2::new(2, 2), BuildKind::Roof), |
| 908 | 935 | (UVec2::new(12, 12), BuildKind::Deconstruct), |
| 909 | 936 | ]))) |
| 937 | + .init_resource::<ColonyReach>() | |
| 910 | 938 | .add_systems(Update, plan_homes); |
| 911 | 939 | app.update(); |
| 912 | 940 | let blueprints = app.world().resource::<Blueprints>(); |
| @@ -932,6 +960,7 @@ mod tests { | ||
| 932 | 960 | .init_resource::<Zones>() |
| 933 | 961 | .init_resource::<Blueprints>() |
| 934 | 962 | .init_resource::<Homes>() |
| 963 | + .init_resource::<ColonyReach>() | |
| 935 | 964 | .add_systems(Update, plan_homes); |
| 936 | 965 | let world = app.world_mut(); |
| 937 | 966 | let structure = |kind| Structure { kind }; |
src/jobs/mod.rs +16 -5
| @@ -26,7 +26,9 @@ use crate::{ | ||
| 26 | 26 | }, |
| 27 | 27 | mood::{Ate, Sick}, |
| 28 | 28 | needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep}, |
| 29 | - pathfinding::{can_step, find_path, find_path_adjacent, is_adjacent, nearest_of, nearest_tile}, | |
| 29 | + pathfinding::{ | |
| 30 | + ColonyReach, can_step, find_path, find_path_adjacent, is_adjacent, nearest_of, nearest_tile, | |
| 31 | + }, | |
| 30 | 32 | rng::SimRng, |
| 31 | 33 | sim::{SimSystems, SimTick, hour_of_day}, |
| 32 | 34 | structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure}, |
| @@ -593,6 +595,7 @@ pub fn find_haul( | ||
| 593 | 595 | map: &WorldMap, |
| 594 | 596 | zones: &Zones, |
| 595 | 597 | reservations: &Reservations, |
| 598 | + reach: &ColonyReach, | |
| 596 | 599 | items: &[(Item, TilePos)], |
| 597 | 600 | from: UVec2, |
| 598 | 601 | ) -> Option<Task> { |
| @@ -615,6 +618,7 @@ pub fn find_haul( | ||
| 615 | 618 | .iter() |
| 616 | 619 | .filter(|(item, pos)| { |
| 617 | 620 | reservations.is_free(Target::Pickup(pos.0)) |
| 621 | + && reach.allows(pos.0) | |
| 618 | 622 | && misplaced(item, pos.0, &mut best) |
| 619 | 623 | && *room.entry(item.kind).or_insert_with(|| { |
| 620 | 624 | storage_candidates(zones, reservations, &stacks, item.kind) |
| @@ -769,6 +773,7 @@ pub struct BuildContext<'a> { | ||
| 769 | 773 | pub planned: &'a [(UVec2, StructureKind)], |
| 770 | 774 | pub blueprints: &'a Blueprints, |
| 771 | 775 | pub occupied: &'a HashSet<UVec2>, |
| 776 | + pub reach: &'a ColonyReach, | |
| 772 | 777 | } |
| 773 | 778 | |
| 774 | 779 | impl BuildContext<'_> { |
| @@ -1078,6 +1083,7 @@ pub fn find_blueprint_work(ctx: &BuildContext, from: UVec2) -> Option<Task> { | ||
| 1078 | 1083 | for (&site, &kind) in &ctx.blueprints.0 { |
| 1079 | 1084 | let (material, amount) = kind.cost(); |
| 1080 | 1085 | let affordable = amount == 0 || stock.get(&material).copied().unwrap_or(0) >= amount; |
| 1086 | + let affordable = affordable && ctx.reach.allows_near(site); | |
| 1081 | 1087 | if !affordable || !ctx.reservations.is_free(Target::Site(site)) { |
| 1082 | 1088 | continue; |
| 1083 | 1089 | } |
| @@ -1162,10 +1168,10 @@ fn choose_jobs( | ||
| 1162 | 1168 | ), |
| 1163 | 1169 | With<Colonist>, |
| 1164 | 1170 | >, |
| 1165 | - plans: (Res<Homes>, Res<Designations>), | |
| 1171 | + plans: (Res<Homes>, Res<Designations>, Res<ColonyReach>), | |
| 1166 | 1172 | mut gather_memo: Local<GatherMemo>, |
| 1167 | 1173 | ) { |
| 1168 | - let (homes, designations) = plans; | |
| 1174 | + let (homes, designations, reach) = plans; | |
| 1169 | 1175 | let mut anyone_ready = false; |
| 1170 | 1176 | for (_, _, path, job, mut idle, _, rest, hunger, ..) in &mut colonists { |
| 1171 | 1177 | let idle_now = job.0.is_none() && path.0.is_empty(); |
| @@ -1328,6 +1334,7 @@ fn choose_jobs( | ||
| 1328 | 1334 | } else { |
| 1329 | 1335 | occupied.extend(planned.iter().map(|(pos, _)| *pos)); |
| 1330 | 1336 | let build = BuildContext { |
| 1337 | + reach: &reach, | |
| 1331 | 1338 | map: &map, |
| 1332 | 1339 | zones: &zones, |
| 1333 | 1340 | reservations: &reservations, |
| @@ -1425,7 +1432,7 @@ fn choose_jobs( | ||
| 1425 | 1432 | .flatten() |
| 1426 | 1433 | }), |
| 1427 | 1434 | (WorkType::Haul, &|| { |
| 1428 | - find_haul(&map, &zones, &reservations, &snapshot, tile.0) | |
| 1435 | + find_haul(&map, &zones, &reservations, &reach, &snapshot, tile.0) | |
| 1429 | 1436 | }), |
| 1430 | 1437 | ]; |
| 1431 | 1438 | let task = candidates |
| @@ -1913,7 +1920,7 @@ fn run_jobs( | ||
| 1913 | 1920 | let structure_there = structures.iter().any(|(_, pos, ..)| pos.0 == site); |
| 1914 | 1921 | let blocked = map.plant(site.as_ivec2()).is_some() |
| 1915 | 1922 | || match kind { |
| 1916 | - BuildKind::Roof => false, | |
| 1923 | + BuildKind::Roof | BuildKind::Floor => false, | |
| 1917 | 1924 | BuildKind::Refuel(fire) => !structures |
| 1918 | 1925 | .iter() |
| 1919 | 1926 | .any(|(_, pos, s, _)| pos.0 == site && s.kind == fire), |
| @@ -2000,6 +2007,10 @@ fn run_jobs( | ||
| 2000 | 2007 | map.set(site, Terrain::Paved); |
| 2001 | 2008 | changed.write(TileChanged(site)); |
| 2002 | 2009 | } |
| 2010 | + BuildKind::Floor => { | |
| 2011 | + map.set(site, Terrain::WoodFloor); | |
| 2012 | + changed.write(TileChanged(site)); | |
| 2013 | + } | |
| 2003 | 2014 | BuildKind::Deconstruct => { |
| 2004 | 2015 | for (entity, pos, structure, _) in &structures { |
| 2005 | 2016 | if pos.0 != site { |
src/jobs/tests.rs +1 -0
| @@ -21,6 +21,7 @@ fn test_app(map: WorldMap) -> App { | ||
| 21 | 21 | .init_resource::<Time>() |
| 22 | 22 | .init_resource::<Reservations>() |
| 23 | 23 | .init_resource::<crate::family::Homes>() |
| 24 | + .init_resource::<crate::pathfinding::ColonyReach>() | |
| 24 | 25 | .add_message::<crate::mood::Ate>() |
| 25 | 26 | .init_resource::<crate::designations::Designations>() |
| 26 | 27 | .init_resource::<ColonyGoals>() |
src/light.rs +19 -4
| @@ -4,7 +4,7 @@ use crate::{ | ||
| 4 | 4 | climate::snow_cover, |
| 5 | 5 | sim::{SimSystems, SimTick, TICKS_PER_HOUR, hour_of_day}, |
| 6 | 6 | structures::{Structure, StructureKind}, |
| 7 | - world::{TileChanged, TilePos, WorldMap}, | |
| 7 | + world::{TilePos, WorldMap}, | |
| 8 | 8 | }; |
| 9 | 9 | |
| 10 | 10 | pub struct LightPlugin; |
| @@ -164,13 +164,14 @@ fn update_light_map( | ||
| 164 | 164 | structures: Query<(&TilePos, &Structure)>, |
| 165 | 165 | added: Query<(), Added<Structure>>, |
| 166 | 166 | mut removed: RemovedComponents<Structure>, |
| 167 | - mut changed: MessageReader<TileChanged>, | |
| 168 | 167 | mut light: ResMut<LightMap>, |
| 168 | + mut shape: Local<u64>, | |
| 169 | 169 | ) { |
| 170 | 170 | let day = daylight(hour_of_day(*tick)); |
| 171 | 171 | let step = (day * AMBIENT_STEPS).round() as u32; |
| 172 | - let tiles_changed = changed.read().count() > 0; | |
| 173 | - let sources_changed = !added.is_empty() || removed.read().count() > 0 || tiles_changed; | |
| 172 | + let reshaped = map.shape_version() != *shape; | |
| 173 | + *shape = map.shape_version(); | |
| 174 | + let sources_changed = !added.is_empty() || removed.read().count() > 0 || reshaped; | |
| 174 | 175 | let snow = LightMap::default().with_snow(snow_cover(*tick)).snow_step; |
| 175 | 176 | if !sources_changed |
| 176 | 177 | && step == light.ambient_step |
| @@ -259,4 +260,18 @@ mod tests { | ||
| 259 | 260 | let bed = UVec2::new(4, 4); |
| 260 | 261 | assert_eq!(light_at(StructureKind::Bed, bed, bed), Vec3::ZERO); |
| 261 | 262 | } |
| 263 | + | |
| 264 | + // Lighting depends only on walls and roofs. Recomputing it (a full-map pass) on every tile | |
| 265 | + // change made flooring a home, one tile at a time by 20 colonists, halve Max speed. | |
| 266 | + #[test] | |
| 267 | + fn only_walls_and_roofs_change_the_lighting_shape() { | |
| 268 | + let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass); | |
| 269 | + let start = map.shape_version(); | |
| 270 | + map.set(UVec2::ONE, Terrain::WoodFloor); | |
| 271 | + map.set_wall(UVec2::ZERO, false); | |
| 272 | + assert_eq!(map.shape_version(), start); | |
| 273 | + map.set_wall(UVec2::ZERO, true); | |
| 274 | + map.set_roof(UVec2::ONE, true); | |
| 275 | + assert_eq!(map.shape_version(), start + 2); | |
| 276 | + } | |
| 262 | 277 | } |
src/mood.rs +8 -1
| @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; | ||
| 4 | 4 | use crate::{ |
| 5 | 5 | climate::Warmth, |
| 6 | 6 | colonist::Colonist, |
| 7 | - family::{Child, Family, Homes, Partner}, | |
| 7 | + family::{Child, Family, Homes, Partner, floored}, | |
| 8 | 8 | food::{HUNGRY, Hunger}, |
| 9 | 9 | items::{Carrying, ItemAssets, ItemKind, ItemQuery, drop_items}, |
| 10 | 10 | jobs::Reservations, |
| @@ -110,6 +110,7 @@ pub struct MoodFactors { | ||
| 110 | 110 | pub grieving: bool, |
| 111 | 111 | pub cold: bool, |
| 112 | 112 | pub beauty: bool, |
| 113 | + pub floored: bool, | |
| 113 | 114 | } |
| 114 | 115 | |
| 115 | 116 | pub fn mood_target(f: MoodFactors) -> f32 { |
| @@ -139,6 +140,9 @@ pub fn mood_target(f: MoodFactors) -> f32 { | ||
| 139 | 140 | if f.beauty { |
| 140 | 141 | mood += 0.1; |
| 141 | 142 | } |
| 143 | + if f.floored { | |
| 144 | + mood += 0.05; | |
| 145 | + } | |
| 142 | 146 | mood.clamp(0.0, 1.0) |
| 143 | 147 | } |
| 144 | 148 | |
| @@ -220,6 +224,7 @@ fn update_mood( | ||
| 220 | 224 | let own_home = homes.of(family.copied()); |
| 221 | 225 | let home = own_home.is_some_and(|h| h.interior().all(|t| map.is_roofed(t.as_ivec2()))); |
| 222 | 226 | let beauty = own_home.is_some_and(|h| statues.iter().any(|&t| h.contains(t))); |
| 227 | + let floored = own_home.is_some_and(|h| h.interior().all(|t| floored(&map, t))); | |
| 223 | 228 | let target = mood_target(MoodFactors { |
| 224 | 229 | fed: hunger.0, |
| 225 | 230 | rest: rest.0, |
| @@ -232,6 +237,7 @@ fn update_mood( | ||
| 232 | 237 | grieving, |
| 233 | 238 | cold: warmth.cold(), |
| 234 | 239 | beauty, |
| 240 | + floored, | |
| 235 | 241 | }); |
| 236 | 242 | mood.0 += (target - mood.0) / MOOD_HOURS; |
| 237 | 243 | } |
| @@ -341,6 +347,7 @@ mod tests { | ||
| 341 | 347 | partner: true, |
| 342 | 348 | home: true, |
| 343 | 349 | beauty: true, |
| 350 | + floored: true, | |
| 344 | 351 | last_meal_cooked: Some(true), |
| 345 | 352 | ..content() |
| 346 | 353 | }); |
src/pathfinding.rs +22 -2
| @@ -83,11 +83,25 @@ pub fn nearest_tile( | ||
| 83 | 83 | None |
| 84 | 84 | } |
| 85 | 85 | |
| 86 | +#[derive(Default, Debug, Clone)] | |
| 86 | 87 | pub struct Reach { |
| 87 | 88 | width: u32, |
| 88 | 89 | seen: Vec<bool>, |
| 89 | 90 | } |
| 90 | 91 | |
| 92 | +#[derive(Resource, Default, Debug, Clone)] | |
| 93 | +pub struct ColonyReach(pub Option<Reach>); | |
| 94 | + | |
| 95 | +impl ColonyReach { | |
| 96 | + pub fn allows(&self, tile: UVec2) -> bool { | |
| 97 | + self.0.as_ref().is_none_or(|reach| reach.contains(tile)) | |
| 98 | + } | |
| 99 | + | |
| 100 | + pub fn allows_near(&self, tile: UVec2) -> bool { | |
| 101 | + self.0.as_ref().is_none_or(|reach| reach.near(tile)) | |
| 102 | + } | |
| 103 | +} | |
| 104 | + | |
| 91 | 105 | impl Reach { |
| 92 | 106 | pub fn flood(map: &WorldMap, start: UVec2) -> Self { |
| 93 | 107 | let width = map.size.x; |
| @@ -129,7 +143,7 @@ impl Reach { | ||
| 129 | 143 | } |
| 130 | 144 | |
| 131 | 145 | pub fn search_reach(max_tiles: usize) -> u32 { |
| 132 | - (max_tiles as f32).sqrt().ceil() as u32 | |
| 146 | + ((max_tiles as f32).sqrt() / 2.0).ceil() as u32 | |
| 133 | 147 | } |
| 134 | 148 | |
| 135 | 149 | pub fn nearest_of( |
| @@ -250,7 +264,8 @@ mod tests { | ||
| 250 | 264 | |
| 251 | 265 | // Idle colonists look for food and cooking input several times per game minute. A stack far |
| 252 | 266 | // beyond what the flood fill can cover made every one of those searches visit the full tile |
| 253 | - // budget and fail, which dominated frame time at high speeds. | |
| 267 | + // budget and fail, which dominated frame time at high speeds. The reach is a radius: the | |
| 268 | + // flood fills a square, so its half-width (not its width, as it once was) bounds it. | |
| 254 | 269 | #[test] |
| 255 | 270 | fn target_searches_skip_targets_beyond_their_reach() { |
| 256 | 271 | let map = WorldMap::filled(UVec2::splat(300), Terrain::Grass); |
| @@ -266,6 +281,11 @@ mod tests { | ||
| 266 | 281 | nearest_of(&map, start, max_tiles, &HashSet::from([far, near])), |
| 267 | 282 | Some(near) |
| 268 | 283 | ); |
| 284 | + let edge = start + UVec2::new(search_reach(max_tiles) - 1, 0); | |
| 285 | + assert_eq!( | |
| 286 | + nearest_of(&map, start, max_tiles, &HashSet::from([edge])), | |
| 287 | + Some(edge) | |
| 288 | + ); | |
| 269 | 289 | } |
| 270 | 290 | |
| 271 | 291 | fn map_from(rows: &[&str]) -> WorldMap { |
src/selection.rs +3 -0
| @@ -411,6 +411,9 @@ fn update_tile_info( | ||
| 411 | 411 | "\nSTOCKPILE TOOL: drag to add, shift-drag to remove".to_string() |
| 412 | 412 | } |
| 413 | 413 | (Some(ZoneKind::Farm), _) => "\nFARM TOOL: drag to add, shift-drag to remove".to_string(), |
| 414 | + (None, Some(BuildMode::Floor)) => { | |
| 415 | + "\nBUILD FLOOR: drag an area, shift-drag to remove plans, B for next".to_string() | |
| 416 | + } | |
| 414 | 417 | (None, Some(BuildMode::Deconstruct)) => { |
| 415 | 418 | "\nDECONSTRUCT: drag over structures, shift-drag to remove plans, B for next" |
| 416 | 419 | .to_string() |
src/tilemap.rs +3 -0
| @@ -369,8 +369,11 @@ fn terrain_texture(terrain: Terrain) -> Texture { | ||
| 369 | 369 | let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE); |
| 370 | 370 | let mortar = terrain == Terrain::Paved |
| 371 | 371 | && (y % 5 == 0 || (x + if (y / 5) % 2 == 0 { 0 } else { 4 }) % 8 == 0); |
| 372 | + let seam = terrain == Terrain::WoodFloor && (y % 4 == 0 || (x + (y / 4) * 5) % 16 == 0); | |
| 372 | 373 | let c = if mortar { |
| 373 | 374 | Srgba::rgb_u8(110, 106, 100) |
| 375 | + } else if seam { | |
| 376 | + Srgba::rgb_u8(100, 70, 42) | |
| 374 | 377 | } else { |
| 375 | 378 | speck.unwrap_or(base) |
| 376 | 379 | }; |
src/world.rs +19 -4
| @@ -22,10 +22,11 @@ pub enum Terrain { | ||
| 22 | 22 | Gravel, |
| 23 | 23 | Path, |
| 24 | 24 | Paved, |
| 25 | + WoodFloor, | |
| 25 | 26 | } |
| 26 | 27 | |
| 27 | 28 | impl Terrain { |
| 28 | - pub const ALL: [Terrain; 11] = [ | |
| 29 | + pub const ALL: [Terrain; 12] = [ | |
| 29 | 30 | Terrain::Grass, |
| 30 | 31 | Terrain::Soil, |
| 31 | 32 | Terrain::Sand, |
| @@ -37,6 +38,7 @@ impl Terrain { | ||
| 37 | 38 | Terrain::Gravel, |
| 38 | 39 | Terrain::Path, |
| 39 | 40 | Terrain::Paved, |
| 41 | + Terrain::WoodFloor, | |
| 40 | 42 | ]; |
| 41 | 43 | |
| 42 | 44 | pub fn index(self) -> u16 { |
| @@ -45,7 +47,7 @@ impl Terrain { | ||
| 45 | 47 | |
| 46 | 48 | pub fn move_cost(self) -> Option<u32> { |
| 47 | 49 | match self { |
| 48 | - Terrain::Paved => Some(CHEAPEST_MOVE_COST), | |
| 50 | + Terrain::Paved | Terrain::WoodFloor => Some(CHEAPEST_MOVE_COST), | |
| 49 | 51 | Terrain::Path => Some(80), |
| 50 | 52 | Terrain::Grass | Terrain::Soil => Some(100), |
| 51 | 53 | Terrain::Sand | Terrain::Gravel => Some(130), |
| @@ -73,6 +75,7 @@ impl Terrain { | ||
| 73 | 75 | Terrain::Gravel => Srgba::rgb_u8(150, 146, 138), |
| 74 | 76 | Terrain::Path => Srgba::rgb_u8(160, 128, 86), |
| 75 | 77 | Terrain::Paved => Srgba::rgb_u8(168, 164, 156), |
| 78 | + Terrain::WoodFloor => Srgba::rgb_u8(150, 108, 66), | |
| 76 | 79 | } |
| 77 | 80 | } |
| 78 | 81 | |
| @@ -158,6 +161,7 @@ pub struct WorldMap { | ||
| 158 | 161 | wear: Vec<f32>, |
| 159 | 162 | worn: HashSet<UVec2>, |
| 160 | 163 | growing: HashSet<UVec2>, |
| 164 | + shape_version: u64, | |
| 161 | 165 | } |
| 162 | 166 | |
| 163 | 167 | impl WorldMap { |
| @@ -174,6 +178,7 @@ impl WorldMap { | ||
| 174 | 178 | wear: vec![0.0; n], |
| 175 | 179 | worn: HashSet::new(), |
| 176 | 180 | growing: HashSet::new(), |
| 181 | + shape_version: 0, | |
| 177 | 182 | } |
| 178 | 183 | } |
| 179 | 184 | |
| @@ -233,7 +238,10 @@ impl WorldMap { | ||
| 233 | 238 | |
| 234 | 239 | pub fn set_wall(&mut self, pos: UVec2, wall: bool) { |
| 235 | 240 | let i = self.index(pos); |
| 236 | - self.walls[i] = wall; | |
| 241 | + if self.walls[i] != wall { | |
| 242 | + self.walls[i] = wall; | |
| 243 | + self.shape_version += 1; | |
| 244 | + } | |
| 237 | 245 | } |
| 238 | 246 | |
| 239 | 247 | pub fn growth(&self, pos: IVec2) -> f32 { |
| @@ -350,7 +358,14 @@ impl WorldMap { | ||
| 350 | 358 | |
| 351 | 359 | pub fn set_roof(&mut self, pos: UVec2, roof: bool) { |
| 352 | 360 | let i = self.index(pos); |
| 353 | - self.roofs[i] = roof; | |
| 361 | + if self.roofs[i] != roof { | |
| 362 | + self.roofs[i] = roof; | |
| 363 | + self.shape_version += 1; | |
| 364 | + } | |
| 365 | + } | |
| 366 | + | |
| 367 | + pub fn shape_version(&self) -> u64 { | |
| 368 | + self.shape_version | |
| 354 | 369 | } |
| 355 | 370 | |
| 356 | 371 | pub fn move_cost(&self, pos: IVec2) -> Option<u32> { |
src/worldgen.rs +6 -2
| @@ -374,8 +374,12 @@ mod tests { | ||
| 374 | 374 | .map(|&t| share(&map, t)) |
| 375 | 375 | .sum(); |
| 376 | 376 | assert!((rock - STONE_SHARE).abs() < 0.02); |
| 377 | - let generated = | |
| 378 | - |t: &Terrain| !matches!(t, Terrain::Gravel | Terrain::Path | Terrain::Paved); | |
| 377 | + let generated = |t: &Terrain| { | |
| 378 | + !matches!( | |
| 379 | + t, | |
| 380 | + Terrain::Gravel | Terrain::Path | Terrain::Paved | Terrain::WoodFloor | |
| 381 | + ) | |
| 382 | + }; | |
| 379 | 383 | for terrain in Terrain::ALL.into_iter().filter(generated) { |
| 380 | 384 | assert!(share(&map, terrain) > 0.0, "seed {seed} lacks {terrain:?}"); |
| 381 | 385 | } |
tasks.md +1 -0
| @@ -43,6 +43,7 @@ | ||
| 43 | 43 | - [x] Max speed fix: target searches respect A*'s no-corner-cutting rule (unreachable diagonal stacks caused repeated full-map path searches) |
| 44 | 44 | - [x] Housing upkeep: vacant homes kept and reused by new couples, abandoned enclosed homes adopted, blocked doors repaired, homes keep a walkable gap, unreachable blueprints dropped, surplus communal beds deconstructed |
| 45 | 45 | - [x] Colonist cap raised to 20 (26 names); job search pass to keep Max speed up at 20 colonists |
| 46 | +- [x] Wood floors: colonists floor finished homes (mood bonus), Build tool floor mode; light only recomputes on wall/roof changes; search reach fix for far stacks | |
| 46 | 47 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 47 | 48 | |
| 48 | 49 | ## Planned |