Add wooden floors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

@@ -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