Add sleeping with a rest need
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-24 10:27 UTC
- commit
- b02b916b6bd07eedcf425d51aa987705ccb283b7
- parent
- 5a18005298
- tree
- browse at this commit
8 files changed +408 -41
| CLAUDE.md | +2 -1 | |
| src/colonist.rs | +5 -2 | |
| src/jobs.rs | +159 -24 | |
| src/main.rs | +2 -0 | |
| src/needs.rs | added | +203 -0 |
| src/selection.rs | +20 -13 | |
| src/zones.rs | +15 -0 | |
| tasks.md | +2 -1 |
CLAUDE.md +2 -1
| @@ -22,11 +22,12 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 22 | 22 | - `src/selection.rs` (`update_cursor` is pub; other modules order `.after(update_cursor)`) `Cursor` resource (screen/world/tile) and `Selection` enum (None/Tile/Colonist(Entity)). PostUpdate after `TransformSystems::Propagate` (fresh camera GlobalTransform). LMB: pick nearest colonist within `PICK_RADIUS` of cursor world pos, else tile, off-map clears; Esc clears. RMB click (press/release within `CLICK_SLOP_PX`, else it was a drag-pan) writes `MoveOrder` for selected colonist. Gizmos: hover rect, selected rect / colonist circle + remaining path linestrip. Top-left UI text (clock + speed/PAUSED, seed, per-material stock/goal, zone tool hint, hover incl. stockpile/items, selection incl. job/carrying). Progress bars for Work/ExpandStockpile/Build (`progress_fraction`). Clock shows day/dawn/dusk/night; hover shows structures with remaining fuel. Chop progress bars (gizmo lines) above working colonists; selected colonist's target tree outlined. Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`. |
| 23 | 23 | - `src/pathfinding.rs` generic `astar(map, start, is_goal, heuristic)`, 8-dir, no corner cutting, cost = (10|14) * `map.step_cost` (100 = normal flat grass), octile heuristic. `find_path(start, goal)`, `find_path_adjacent(start, target)` (ends on a tile Chebyshev-adjacent to target; works for impassable targets; empty if already adjacent), `nearest_tile(start, max_tiles, pred)` BFS over walkable tiles. Paths exclude start, include goal. |
| 24 | 24 | - `src/colonist.rs` `Colonist{name}` (spawned/respawned on `WorldGenerated` at `spawn_tiles`) requires `TilePos` (world.rs; last reached tile), `Path(VecDeque)`, `CurrentJob`, `IdleTimer`. `follow_path` (SimSystems::Move) moves `SimPos`, speed / step cost. `repath` keeps the tile being entered at the front. |
| 25 | -- `src/jobs.rs` job system. `CurrentJob(Option<Job{task, ordered}>)`, `Task::{MoveTo, Work{site, kind: WorkKind::{Chop, Mine}, progress}, Haul{pickup: Option<tile>, to}, ExpandStockpile{site, progress}, Build{site, kind: StructureKind, pickup: Option<tile>, progress}}`. Progress is in work units: `work_rate` = 2/tick when the colonist's tile is lit, 1 in the dark (`WorkKind::work`, `EXPAND_WORK`, `build_work`). `site_work(map, tile)` is the single source of what a tile yields (tree → 10 wood; Stone/IronOre/GoldOre → 10 stone / 10 iron / 5 gold, mined tile becomes Gravel). `Reservations` keyed by `Target::{Site, Pickup, Storage}(tile)` and `Target::StockpileExpansion` (one expander at a time); cleared on WorldGenerated. `ColonyGoals(HashMap<ItemKind, u32>)` = demand (wood 50, stone 40, iron 20, gold 10); `wanted(kind, stock, on_the_way)`. `choose_jobs` (SimSystems::Decide), idle colonists, priority: deliver carried load (or drop if no storage) → `find_expansion` (no stockpile + loose items → found a NEW_STOCKPILE_SIZE² stockpile at `find_stockpile_site` near the central loose item; or stockpile has < MIN_EMPTY_STOCKPILE_TILES empty tiles → grow EXPANSION_TILES via `plan_expansion`) → if `dark_soon`: `find_lighting` (`LightingContext`: campfire within HOME_RADIUS of home = stockpile tile nearest its centroid unless one burns with ≥ CAMPFIRE_MIN_FUEL or is planned; else a torch next to the stockpile tile nearest home that existing + planned fires leave dark; needs a reachable unreserved wood stack ≥ cost; Build fetches wood, walks adjacent, builds, consumes wood) → `find_work` (nearest stand tile adjacent to an unreserved site whose yield is wanted, counting yields of work in progress) → `find_haul` → wander. `run_jobs` (SimSystems::Act) per-task state machine → `Step::{Continue, Done}`; Done releases reservations, restarts idle timer. Orders: work site (tree/rock) → ordered Work; loose item → ordered Haul; else MoveTo. Orders keep `Carrying`. New job = Task variant + reserve_task arm + choose_jobs candidate + run_jobs arm (+ `site_work` entry for site work). | |
| 25 | +- `src/jobs.rs` job system. `CurrentJob(Option<Job{task, ordered}>)`, `Task::{MoveTo, Work{site, kind: WorkKind::{Chop, Mine}, progress}, Haul{pickup: Option<tile>, to}, ExpandStockpile{site, progress}, Build{site, kind: StructureKind, pickup: Option<tile>, progress}}`. Progress is in work units: `work_rate` = 2/tick when the colonist's tile is lit, 1 in the dark (`WorkKind::work`, `EXPAND_WORK`, `build_work`). `site_work(map, tile)` is the single source of what a tile yields (tree → 10 wood; Stone/IronOre/GoldOre → 10 stone / 10 iron / 5 gold, mined tile becomes Gravel). `Reservations` keyed by `Target::{Site, Pickup, Storage}(tile)` and `Target::StockpileExpansion` (one expander at a time); cleared on WorldGenerated. `ColonyGoals(HashMap<ItemKind, u32>)` = demand (wood 50, stone 40, iron 20, gold 10); `wanted(kind, stock, on_the_way)`. `choose_jobs` (SimSystems::Decide), idle colonists (exhausted ones skip the idle timer), priority: sleep if `wants_sleep` → deliver carried load (or drop if no storage) → `find_expansion` (no stockpile + loose items → found a NEW_STOCKPILE_SIZE² stockpile at `find_stockpile_site` near the central loose item; or stockpile has < MIN_EMPTY_STOCKPILE_TILES empty tiles → grow EXPANSION_TILES via `plan_expansion`) → if `dark_soon`: `find_lighting` (`LightingContext`: campfire within HOME_RADIUS of home = stockpile tile nearest its centroid unless one burns with ≥ CAMPFIRE_MIN_FUEL or is planned; else a torch next to the stockpile tile nearest home that existing + planned fires leave dark; needs a reachable unreserved wood stack ≥ cost; Build fetches wood, walks adjacent, builds, consumes wood) → `find_work` (nearest stand tile adjacent to an unreserved site whose yield is wanted, counting yields of work in progress) → `find_haul` → wander. `run_jobs` (SimSystems::Act) per-task state machine → `Step::{Continue, Done}`; Done releases reservations, restarts idle timer. Orders: work site (tree/rock) → ordered Work; loose item → ordered Haul; else MoveTo. Orders keep `Carrying`. New job = Task variant + reserve_task arm + choose_jobs candidate + run_jobs arm (+ `site_work` entry for site work). | |
| 26 | 26 | - `src/items.rs` `Item{kind: ItemKind::{Wood, Stone, Iron, Gold}, count}` + `TilePos` entities (Mesh2d rect, z=0.7), one stack per tile, `STACK_LIMIT`=50; `ItemQuery` alias; `drop_items` stacks, `take_items`, `room_for(tile, kind)` (0 if other kind present), `stock()`. `Carrying(Option<Item>)` on colonists, shown via hidden/visible `CarriedVisual` child. Items cleared on WorldGenerated. |
| 27 | 27 | - `src/zones.rs` `Zones` resource (stockpile tile set; only walkable dry land), cleared on WorldGenerated — worlds start without stockpiles, colonists found one when needed. `plan_expansion` grows one tile at a time next to stockpile/planned tiles, preferring tree-free tiles near the stockpile centroid (deterministic tiebreak). `find_stockpile_site` = BFS for nearest fully open (zonable, tree-free) square, fallback any zonable tile. `ZoneTool` (Z/button toggles, Cancel exits): left-drag adds, shift-drag removes (`zone_tool` input + `draw_zone_preview` gizmo, PostUpdate after `update_cursor`). Rendered as tilemap `Layer::Zones` (z=0.3), refreshed on `resource_changed::<Zones>`. |
| 28 | 28 | - `src/sim.rs` `SimPlugin`: `SimSystems::{Decide, Act, Move}` chained sets in FixedUpdate; fixed timestep `Time<Fixed>` 60 Hz = sim ticks; all sim logic in `FixedUpdate` (Res<Time> there = fixed delta). `SimTick` counter (FixedFirst), `format_clock` (600 ticks/hour = 10 s real at 1x, starts Day 1 06:00). `SimSpeed` Normal/Fast/Fastest = 1x/3x/6x via `Time<Virtual>::set_relative_speed`; keys 1/2/3 set speed + unpause, Space toggles pause. Render interpolation: sim entities hold `SimPos` (+ `PrevSimPos`, copied in FixedFirst); `interpolate_transforms` lerps Transform by `overstep_fraction` in `RunFixedMainLoop`/AfterFixedMainLoop. Spawn with `sim_pos(v)` so prev==cur. Never write Transform of sim entities directly. |
| 29 | 29 | - `src/light.rs` day/night + fires. `daylight(hour)` (dawn 5–7, dusk 18–20), `ambient_color` (night = blue-ish), `dark_soon(tick)` (now or within LIGHT_LOOKAHEAD_TICKS). `Structure{kind: StructureKind::{Campfire, Torch}, fuel}` + TilePos entities (Mesh2d children: campfire = stone ring + crossed logs + triangle flame, torch = stick + small flame; avoid plain circles, those read as colonists; z=0.8); `burn_fuel` (SimSystems::Act) despawns at 0. Kind stats: wood_cost 10/2, fuel 12h/8h, radius 7/4, `light_at` falloff. `LightMap` resource = ambient + per-tile source light, recomputed in `update_light_map` (SimSystems::Decide) only when the ambient step (16 levels) changes or structures are added/removed; default (empty) = fully lit. `is_lit` = brightness ≥ LIT_BRIGHTNESS. Tilemap tints terrain/plant tiles by `LightMap::color` and refreshes all chunks on `LightMap` change (zones overlay unlit, colonists/items/fires not darkened). |
| 30 | +- `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 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 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. | |
| 30 | 31 | - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep). |
| 31 | 32 | |
| 32 | 33 | ## Gotchas |
src/colonist.rs +5 -2
| @@ -5,6 +5,7 @@ use bevy::prelude::*; | ||
| 5 | 5 | use crate::{ |
| 6 | 6 | items::{Carrying, ItemAssets}, |
| 7 | 7 | jobs::{CurrentJob, IdleTimer}, |
| 8 | + needs::{Rest, sleep_marker}, | |
| 8 | 9 | pathfinding::find_path, |
| 9 | 10 | sim::{SimPos, SimSystems, sim_pos}, |
| 10 | 11 | world::{TILE_SIZE, TilePos, WorldMap}, |
| @@ -29,7 +30,9 @@ const STARTING_COLONISTS: [(&str, Color); 3] = [ | ||
| 29 | 30 | ]; |
| 30 | 31 | |
| 31 | 32 | #[derive(Component, Debug)] |
| 32 | -#[require(Transform, SimPos, TilePos, Path, CurrentJob, IdleTimer, Carrying)] | |
| 33 | +#[require( | |
| 34 | + Transform, SimPos, TilePos, Path, CurrentJob, IdleTimer, Carrying, Rest | |
| 35 | +)] | |
| 33 | 36 | pub struct Colonist { |
| 34 | 37 | pub name: String, |
| 35 | 38 | } |
| @@ -59,7 +62,7 @@ fn spawn_colonists( | ||
| 59 | 62 | MeshMaterial2d(materials.add(color)), |
| 60 | 63 | sim_pos(WorldMap::tile_center(tile)), |
| 61 | 64 | Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)), |
| 62 | - children![item_assets.carried_visual()], | |
| 65 | + children![item_assets.carried_visual(), sleep_marker()], | |
| 63 | 66 | )); |
| 64 | 67 | } |
| 65 | 68 | } |
src/jobs.rs +159 -24
| @@ -16,9 +16,10 @@ use crate::{ | ||
| 16 | 16 | LIT_BRIGHTNESS, LightMap, Structure, StructureAssets, StructureKind, brightness, dark_soon, |
| 17 | 17 | light_from, spawn_structure, |
| 18 | 18 | }, |
| 19 | + needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep}, | |
| 19 | 20 | pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_tile}, |
| 20 | 21 | rng::SimRng, |
| 21 | - sim::{SimSystems, SimTick}, | |
| 22 | + sim::{SimSystems, SimTick, hour_of_day}, | |
| 22 | 23 | world::{Plant, Terrain, TileChanged, TilePos, WorldMap}, |
| 23 | 24 | worldgen::WorldGenerated, |
| 24 | 25 | zones::{Zones, can_zone, find_stockpile_site, new_stockpile_tiles, plan_expansion}, |
| @@ -99,6 +100,9 @@ pub enum Task { | ||
| 99 | 100 | pickup: Option<UVec2>, |
| 100 | 101 | progress: u32, |
| 101 | 102 | }, |
| 103 | + Sleep { | |
| 104 | + spot: UVec2, | |
| 105 | + }, | |
| 102 | 106 | } |
| 103 | 107 | |
| 104 | 108 | pub fn build_work(kind: StructureKind) -> u32 { |
| @@ -188,6 +192,7 @@ pub enum Target { | ||
| 188 | 192 | Pickup(UVec2), |
| 189 | 193 | Storage(UVec2), |
| 190 | 194 | StockpileExpansion, |
| 195 | + SleepSpot(UVec2), | |
| 191 | 196 | } |
| 192 | 197 | |
| 193 | 198 | #[derive(Resource, Default, Debug)] |
| @@ -310,6 +315,7 @@ fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) { | ||
| 310 | 315 | reservations.reserve(Target::Storage(to), by); |
| 311 | 316 | } |
| 312 | 317 | Task::ExpandStockpile { .. } => reservations.reserve(Target::StockpileExpansion, by), |
| 318 | + Task::Sleep { spot } => reservations.reserve(Target::SleepSpot(spot), by), | |
| 313 | 319 | Task::Build { site, pickup, .. } => { |
| 314 | 320 | reservations.reserve(Target::Site(site), by); |
| 315 | 321 | if let Some(pickup) = pickup { |
| @@ -439,23 +445,6 @@ impl LightingContext<'_> { | ||
| 439 | 445 | && self.reservations.is_free(Target::Site(tile)) |
| 440 | 446 | } |
| 441 | 447 | |
| 442 | - fn home(&self, fallback: UVec2) -> UVec2 { | |
| 443 | - let tiles: Vec<UVec2> = self.zones.stockpile_tiles().collect(); | |
| 444 | - if tiles.is_empty() { | |
| 445 | - return fallback; | |
| 446 | - } | |
| 447 | - let centroid = tiles.iter().map(|t| t.as_vec2()).sum::<Vec2>() / tiles.len() as f32; | |
| 448 | - *tiles | |
| 449 | - .iter() | |
| 450 | - .min_by(|a, b| { | |
| 451 | - a.as_vec2() | |
| 452 | - .distance_squared(centroid) | |
| 453 | - .total_cmp(&b.as_vec2().distance_squared(centroid)) | |
| 454 | - .then((a.y, a.x).cmp(&(b.y, b.x))) | |
| 455 | - }) | |
| 456 | - .unwrap() | |
| 457 | - } | |
| 458 | - | |
| 459 | 448 | fn lights(&self) -> impl Iterator<Item = (UVec2, StructureKind)> + Clone + '_ { |
| 460 | 449 | self.fires |
| 461 | 450 | .iter() |
| @@ -504,7 +493,7 @@ impl LightingContext<'_> { | ||
| 504 | 493 | } |
| 505 | 494 | |
| 506 | 495 | pub fn find_lighting(ctx: &LightingContext, from: UVec2) -> Option<Task> { |
| 507 | - let home = ctx.home(from); | |
| 496 | + let home = ctx.zones.home(from); | |
| 508 | 497 | let (kind, site) = if ctx.campfire_needed(home) { |
| 509 | 498 | (StructureKind::Campfire, ctx.site_near(home, HOME_RADIUS)?) |
| 510 | 499 | } else { |
| @@ -554,6 +543,7 @@ fn choose_jobs( | ||
| 554 | 543 | &mut CurrentJob, |
| 555 | 544 | &mut IdleTimer, |
| 556 | 545 | &mut Carrying, |
| 546 | + &Rest, | |
| 557 | 547 | ), |
| 558 | 548 | With<Colonist>, |
| 559 | 549 | >, |
| @@ -579,8 +569,20 @@ fn choose_jobs( | ||
| 579 | 569 | }) |
| 580 | 570 | .collect(); |
| 581 | 571 | let mut pending = on_the_way(&map, colonists.iter().map(|c| c.3.0)); |
| 582 | - for (entity, tile, mut path, mut job, mut idle, mut carrying) in &mut colonists { | |
| 583 | - if job.0.is_some() || !path.0.is_empty() || !idle.0.tick(time.delta()).is_finished() { | |
| 572 | + let hour = hour_of_day(*tick); | |
| 573 | + for (entity, tile, mut path, mut job, mut idle, mut carrying, rest) in &mut colonists { | |
| 574 | + let exhausted = rest.0 < EXHAUSTED_REST; | |
| 575 | + if exhausted | |
| 576 | + && job | |
| 577 | + .0 | |
| 578 | + .is_some_and(|j| !j.ordered && !matches!(j.task, Task::Sleep { .. })) | |
| 579 | + { | |
| 580 | + reservations.release_all(entity); | |
| 581 | + job.0 = None; | |
| 582 | + path.0.truncate(1); | |
| 583 | + } | |
| 584 | + let rested = idle.0.tick(time.delta()).is_finished(); | |
| 585 | + if job.0.is_some() || !path.0.is_empty() || !(rested || exhausted) { | |
| 584 | 586 | continue; |
| 585 | 587 | } |
| 586 | 588 | let snapshot: Vec<(Item, TilePos)> = |
| @@ -593,7 +595,18 @@ fn choose_jobs( | ||
| 593 | 595 | pending.get(&kind).copied().unwrap_or(0), |
| 594 | 596 | ) |
| 595 | 597 | }; |
| 596 | - let task = if let Some(load) = carrying.0 { | |
| 598 | + let sleep = SleepContext { | |
| 599 | + map: &map, | |
| 600 | + zones: &zones, | |
| 601 | + reservations: &reservations, | |
| 602 | + items: &snapshot, | |
| 603 | + fires: &fires, | |
| 604 | + }; | |
| 605 | + let task = if wants_sleep(rest.0, hour) { | |
| 606 | + Some(Task::Sleep { | |
| 607 | + spot: sleep.spot(tile.0), | |
| 608 | + }) | |
| 609 | + } else if let Some(load) = carrying.0 { | |
| 597 | 610 | let storage = find_storage(&map, &zones, &reservations, refs, tile.0, load.kind); |
| 598 | 611 | if storage.is_none() { |
| 599 | 612 | drop_items( |
| @@ -676,6 +689,7 @@ fn run_jobs( | ||
| 676 | 689 | item_assets: Res<ItemAssets>, |
| 677 | 690 | structure_assets: Res<StructureAssets>, |
| 678 | 691 | light: Res<LightMap>, |
| 692 | + tick: Res<SimTick>, | |
| 679 | 693 | mut items: ItemQuery, |
| 680 | 694 | structures: Query<&TilePos, (With<Structure>, Without<Colonist>)>, |
| 681 | 695 | mut changed: MessageWriter<TileChanged>, |
| @@ -687,11 +701,13 @@ fn run_jobs( | ||
| 687 | 701 | &mut CurrentJob, |
| 688 | 702 | &mut IdleTimer, |
| 689 | 703 | &mut Carrying, |
| 704 | + &Rest, | |
| 690 | 705 | ), |
| 691 | 706 | With<Colonist>, |
| 692 | 707 | >, |
| 693 | 708 | ) { |
| 694 | - for (entity, tile, mut path, mut current, mut idle, mut carrying) in &mut colonists { | |
| 709 | + let hour = hour_of_day(*tick); | |
| 710 | + for (entity, tile, mut path, mut current, mut idle, mut carrying, rest) in &mut colonists { | |
| 695 | 711 | let Some(mut job) = current.0 else { |
| 696 | 712 | continue; |
| 697 | 713 | }; |
| @@ -700,6 +716,23 @@ fn run_jobs( | ||
| 700 | 716 | } |
| 701 | 717 | let step = match &mut job.task { |
| 702 | 718 | Task::MoveTo(_) => Step::Done, |
| 719 | + Task::Sleep { spot } => { | |
| 720 | + if reservations.holder(Target::SleepSpot(*spot)) != Some(entity) { | |
| 721 | + Step::Done | |
| 722 | + } else if tile.0 != *spot { | |
| 723 | + let step = walk_to(&map, tile.0, *spot, &mut path); | |
| 724 | + if matches!(step, Step::Done) { | |
| 725 | + reservations.release(Target::SleepSpot(*spot)); | |
| 726 | + *spot = tile.0; | |
| 727 | + reservations.reserve(Target::SleepSpot(*spot), entity); | |
| 728 | + } | |
| 729 | + Step::Continue | |
| 730 | + } else if done_sleeping(rest.0, hour) { | |
| 731 | + Step::Done | |
| 732 | + } else { | |
| 733 | + Step::Continue | |
| 734 | + } | |
| 735 | + } | |
| 703 | 736 | Task::Work { |
| 704 | 737 | site, |
| 705 | 738 | kind, |
| @@ -927,7 +960,14 @@ mod tests { | ||
| 927 | 960 | .add_message::<TileChanged>() |
| 928 | 961 | .add_systems( |
| 929 | 962 | Update, |
| 930 | - (handle_orders, choose_jobs, run_jobs, follow_path).chain(), | |
| 963 | + ( | |
| 964 | + handle_orders, | |
| 965 | + choose_jobs, | |
| 966 | + run_jobs, | |
| 967 | + follow_path, | |
| 968 | + crate::needs::update_rest, | |
| 969 | + ) | |
| 970 | + .chain(), | |
| 931 | 971 | ); |
| 932 | 972 | app |
| 933 | 973 | } |
| @@ -1421,6 +1461,101 @@ mod tests { | ||
| 1421 | 1461 | ); |
| 1422 | 1462 | } |
| 1423 | 1463 | |
| 1464 | + const NAP_TEST_REST: f32 = 0.25; | |
| 1465 | + | |
| 1466 | + fn set_rest(app: &mut App, colonist: Entity, rest: f32) { | |
| 1467 | + app.world_mut().get_mut::<Rest>(colonist).unwrap().0 = rest; | |
| 1468 | + } | |
| 1469 | + | |
| 1470 | + fn rest(app: &App, colonist: Entity) -> f32 { | |
| 1471 | + app.world().get::<Rest>(colonist).unwrap().0 | |
| 1472 | + } | |
| 1473 | + | |
| 1474 | + fn task(app: &App, colonist: Entity) -> Option<Task> { | |
| 1475 | + job(app, colonist).map(|j| j.task) | |
| 1476 | + } | |
| 1477 | + | |
| 1478 | + #[test] | |
| 1479 | + fn tired_colonists_nap_and_get_up_when_rested() { | |
| 1480 | + let mut app = test_app(map_with_trees(&[])); | |
| 1481 | + set_goals(&mut app, &[]); | |
| 1482 | + set_hour(&mut app, 10); | |
| 1483 | + let colonist = spawn_colonist(&mut app, UVec2::new(5, 5)); | |
| 1484 | + set_rest(&mut app, colonist, NAP_TEST_REST); | |
| 1485 | + run_ticks(&mut app, 61); | |
| 1486 | + assert!(matches!(task(&app, colonist), Some(Task::Sleep { .. }))); | |
| 1487 | + let mut ticks = 0; | |
| 1488 | + while matches!(task(&app, colonist), Some(Task::Sleep { .. })) && ticks < 60 * 60 * 10 { | |
| 1489 | + run_ticks(&mut app, 60); | |
| 1490 | + ticks += 60; | |
| 1491 | + } | |
| 1492 | + assert!(!matches!(task(&app, colonist), Some(Task::Sleep { .. }))); | |
| 1493 | + assert!(rest(&app, colonist) > 0.95); | |
| 1494 | + } | |
| 1495 | + | |
| 1496 | + #[test] | |
| 1497 | + fn colonists_go_to_bed_at_night_and_sleep_until_morning() { | |
| 1498 | + let mut app = test_app(map_with_trees(&[])); | |
| 1499 | + set_goals(&mut app, &[]); | |
| 1500 | + set_hour(&mut app, 22); | |
| 1501 | + let colonist = spawn_colonist(&mut app, UVec2::new(5, 5)); | |
| 1502 | + set_rest(&mut app, colonist, 0.8); | |
| 1503 | + run_ticks(&mut app, 61); | |
| 1504 | + assert!(matches!(task(&app, colonist), Some(Task::Sleep { .. }))); | |
| 1505 | + run_ticks(&mut app, 3 * crate::sim::TICKS_PER_HOUR as u32); | |
| 1506 | + assert_eq!(rest(&app, colonist), 1.0); | |
| 1507 | + assert!(matches!(task(&app, colonist), Some(Task::Sleep { .. }))); | |
| 1508 | + } | |
| 1509 | + | |
| 1510 | + #[test] | |
| 1511 | + fn rested_colonists_stay_up_during_the_day() { | |
| 1512 | + let mut app = test_app(map_with_trees(&[])); | |
| 1513 | + set_goals(&mut app, &[]); | |
| 1514 | + set_hour(&mut app, 12); | |
| 1515 | + let colonist = spawn_colonist(&mut app, UVec2::new(5, 5)); | |
| 1516 | + set_rest(&mut app, colonist, 0.8); | |
| 1517 | + run_ticks(&mut app, 60 * 5); | |
| 1518 | + assert!(!matches!(task(&app, colonist), Some(Task::Sleep { .. }))); | |
| 1519 | + } | |
| 1520 | + | |
| 1521 | + #[test] | |
| 1522 | + fn exhausted_colonists_drop_their_work_to_sleep() { | |
| 1523 | + let tree = UVec2::new(15, 5); | |
| 1524 | + let mut app = test_app(map_with_trees(&[tree])); | |
| 1525 | + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5)); | |
| 1526 | + run_ticks(&mut app, 61); | |
| 1527 | + assert!(matches!(task(&app, colonist), Some(Task::Work { .. }))); | |
| 1528 | + set_rest(&mut app, colonist, EXHAUSTED_REST / 2.0); | |
| 1529 | + run_ticks(&mut app, 30); | |
| 1530 | + assert!(matches!(task(&app, colonist), Some(Task::Sleep { .. }))); | |
| 1531 | + assert!( | |
| 1532 | + app.world() | |
| 1533 | + .resource::<Reservations>() | |
| 1534 | + .is_free(Target::Site(tree)) | |
| 1535 | + ); | |
| 1536 | + } | |
| 1537 | + | |
| 1538 | + #[test] | |
| 1539 | + fn exhaustion_does_not_override_orders() { | |
| 1540 | + let tree = UVec2::new(15, 5); | |
| 1541 | + let mut app = test_app(map_with_trees(&[tree])); | |
| 1542 | + let colonist = spawn_colonist(&mut app, UVec2::new(2, 5)); | |
| 1543 | + app.world_mut().write_message(Order { | |
| 1544 | + colonist, | |
| 1545 | + tile: tree, | |
| 1546 | + }); | |
| 1547 | + run_ticks(&mut app, 2); | |
| 1548 | + set_rest(&mut app, colonist, EXHAUSTED_REST / 2.0); | |
| 1549 | + run_ticks(&mut app, 30); | |
| 1550 | + assert!(matches!( | |
| 1551 | + job(&app, colonist), | |
| 1552 | + Some(Job { | |
| 1553 | + task: Task::Work { .. }, | |
| 1554 | + ordered: true | |
| 1555 | + }) | |
| 1556 | + )); | |
| 1557 | + } | |
| 1558 | + | |
| 1424 | 1559 | #[test] |
| 1425 | 1560 | fn colonists_chop_from_a_neighbouring_tile() { |
| 1426 | 1561 | let tree = UVec2::new(8, 5); |
src/main.rs +2 -0
| @@ -4,6 +4,7 @@ mod colonist; | ||
| 4 | 4 | mod items; |
| 5 | 5 | mod jobs; |
| 6 | 6 | mod light; |
| 7 | +mod needs; | |
| 7 | 8 | mod noise; |
| 8 | 9 | mod pathfinding; |
| 9 | 10 | mod rng; |
| @@ -43,6 +44,7 @@ fn main() { | ||
| 43 | 44 | items::ItemsPlugin, |
| 44 | 45 | jobs::JobsPlugin, |
| 45 | 46 | light::LightPlugin, |
| 47 | + needs::NeedsPlugin, | |
| 46 | 48 | selection::SelectionPlugin, |
| 47 | 49 | zones::ZonesPlugin, |
| 48 | 50 | ui::HudPlugin, |
src/needs.rs +203 -0
| @@ -0,0 +1,203 @@ | ||
| 1 | +use bevy::prelude::*; | |
| 2 | + | |
| 3 | +use crate::{ | |
| 4 | + colonist::{Colonist, Path}, | |
| 5 | + items::Item, | |
| 6 | + jobs::{CurrentJob, Job, Reservations, Target, Task}, | |
| 7 | + light::StructureKind, | |
| 8 | + pathfinding::nearest_tile, | |
| 9 | + sim::{SimSystems, TICKS_PER_HOUR}, | |
| 10 | + world::{TILE_SIZE, TilePos, WorldMap}, | |
| 11 | + zones::{Zones, can_zone}, | |
| 12 | +}; | |
| 13 | + | |
| 14 | +pub struct NeedsPlugin; | |
| 15 | + | |
| 16 | +impl Plugin for NeedsPlugin { | |
| 17 | + fn build(&self, app: &mut App) { | |
| 18 | + app.add_systems(FixedUpdate, update_rest.in_set(SimSystems::Act)) | |
| 19 | + .add_systems(Update, show_sleep_markers); | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +pub const REST_DRAIN_PER_TICK: f32 = 1.0 / (24.0 * TICKS_PER_HOUR as f32); | |
| 24 | +pub const REST_RECOVERY_PER_TICK: f32 = 1.0 / (7.0 * TICKS_PER_HOUR as f32); | |
| 25 | +pub const BEDTIME_REST: f32 = 0.9; | |
| 26 | +pub const NAP_REST: f32 = 0.3; | |
| 27 | +pub const EXHAUSTED_REST: f32 = 0.1; | |
| 28 | +const SLEEP_SPOT_SEARCH_TILES: usize = 3000; | |
| 29 | + | |
| 30 | +#[derive(Component, Debug, Clone, Copy, PartialEq)] | |
| 31 | +pub struct Rest(pub f32); | |
| 32 | + | |
| 33 | +impl Default for Rest { | |
| 34 | + fn default() -> Self { | |
| 35 | + Self(1.0) | |
| 36 | + } | |
| 37 | +} | |
| 38 | + | |
| 39 | +pub fn is_night(hour: f32) -> bool { | |
| 40 | + !(6.0..21.0).contains(&hour) | |
| 41 | +} | |
| 42 | + | |
| 43 | +pub fn wants_sleep(rest: f32, hour: f32) -> bool { | |
| 44 | + rest < NAP_REST || (is_night(hour) && rest < BEDTIME_REST) | |
| 45 | +} | |
| 46 | + | |
| 47 | +pub fn done_sleeping(rest: f32, hour: f32) -> bool { | |
| 48 | + rest >= 1.0 && !is_night(hour) | |
| 49 | +} | |
| 50 | + | |
| 51 | +pub fn is_sleeping(job: &CurrentJob, tile: &TilePos, path: &Path) -> bool { | |
| 52 | + matches!( | |
| 53 | + job.0, | |
| 54 | + Some(Job { | |
| 55 | + task: Task::Sleep { spot }, | |
| 56 | + .. | |
| 57 | + }) if spot == tile.0 && path.0.is_empty() | |
| 58 | + ) | |
| 59 | +} | |
| 60 | + | |
| 61 | +pub fn update_rest(mut colonists: Query<(&mut Rest, &CurrentJob, &TilePos, &Path)>) { | |
| 62 | + for (mut rest, job, tile, path) in &mut colonists { | |
| 63 | + let change = if is_sleeping(job, tile, path) { | |
| 64 | + REST_RECOVERY_PER_TICK | |
| 65 | + } else { | |
| 66 | + -REST_DRAIN_PER_TICK | |
| 67 | + }; | |
| 68 | + rest.0 = (rest.0 + change).clamp(0.0, 1.0); | |
| 69 | + } | |
| 70 | +} | |
| 71 | + | |
| 72 | +pub struct SleepContext<'a> { | |
| 73 | + pub map: &'a WorldMap, | |
| 74 | + pub zones: &'a Zones, | |
| 75 | + pub reservations: &'a Reservations, | |
| 76 | + pub items: &'a [(Item, TilePos)], | |
| 77 | + pub fires: &'a [(UVec2, StructureKind, u32)], | |
| 78 | +} | |
| 79 | + | |
| 80 | +impl SleepContext<'_> { | |
| 81 | + fn free(&self, tile: UVec2) -> bool { | |
| 82 | + can_zone(self.map, tile.as_ivec2()) | |
| 83 | + && self.map.plant(tile.as_ivec2()).is_none() | |
| 84 | + && !self.zones.is_stockpile(tile) | |
| 85 | + && !self.fires.iter().any(|(pos, ..)| *pos == tile) | |
| 86 | + && !self.items.iter().any(|(_, pos)| pos.0 == tile) | |
| 87 | + && self.reservations.is_free(Target::SleepSpot(tile)) | |
| 88 | + } | |
| 89 | + | |
| 90 | + pub fn spot(&self, from: UVec2) -> UVec2 { | |
| 91 | + let campfire = self | |
| 92 | + .fires | |
| 93 | + .iter() | |
| 94 | + .filter(|(_, kind, _)| *kind == StructureKind::Campfire) | |
| 95 | + .map(|(pos, ..)| *pos) | |
| 96 | + .min_by(|a, b| { | |
| 97 | + a.as_vec2() | |
| 98 | + .distance_squared(from.as_vec2()) | |
| 99 | + .total_cmp(&b.as_vec2().distance_squared(from.as_vec2())) | |
| 100 | + }); | |
| 101 | + let anchor = campfire.unwrap_or_else(|| self.zones.home(from)); | |
| 102 | + nearest_tile(self.map, anchor, SLEEP_SPOT_SEARCH_TILES, |p| self.free(p)).unwrap_or(from) | |
| 103 | + } | |
| 104 | +} | |
| 105 | + | |
| 106 | +#[derive(Component)] | |
| 107 | +pub struct SleepMarker; | |
| 108 | + | |
| 109 | +pub fn sleep_marker() -> impl Bundle { | |
| 110 | + ( | |
| 111 | + SleepMarker, | |
| 112 | + Text2d::new("z"), | |
| 113 | + TextFont::from_font_size(12.0), | |
| 114 | + TextColor(Color::srgb(0.85, 0.9, 1.0)), | |
| 115 | + Transform::from_xyz(TILE_SIZE * 0.35, TILE_SIZE * 0.45, 0.2), | |
| 116 | + Visibility::Hidden, | |
| 117 | + ) | |
| 118 | +} | |
| 119 | + | |
| 120 | +fn show_sleep_markers( | |
| 121 | + colonists: Query<(&CurrentJob, &TilePos, &Path, &Children), With<Colonist>>, | |
| 122 | + mut markers: Query<&mut Visibility, With<SleepMarker>>, | |
| 123 | +) { | |
| 124 | + for (job, tile, path, children) in &colonists { | |
| 125 | + let shown = if is_sleeping(job, tile, path) { | |
| 126 | + Visibility::Inherited | |
| 127 | + } else { | |
| 128 | + Visibility::Hidden | |
| 129 | + }; | |
| 130 | + for &child in children { | |
| 131 | + if let Ok(mut visibility) = markers.get_mut(child) { | |
| 132 | + visibility.set_if_neq(shown); | |
| 133 | + } | |
| 134 | + } | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +#[cfg(test)] | |
| 139 | +mod tests { | |
| 140 | + use super::*; | |
| 141 | + use crate::world::Terrain; | |
| 142 | + | |
| 143 | + #[test] | |
| 144 | + fn colonists_sleep_at_night_and_nap_only_when_tired() { | |
| 145 | + assert!(wants_sleep(0.8, 22.0)); | |
| 146 | + assert!(!wants_sleep(0.95, 22.0)); | |
| 147 | + assert!(!wants_sleep(0.8, 12.0)); | |
| 148 | + assert!(wants_sleep(0.2, 12.0)); | |
| 149 | + } | |
| 150 | + | |
| 151 | + // A colonist that is fully rested at 3 am keeps resting rather than wandering off into the | |
| 152 | + // dark; the night ends sleep, a full rest bar alone does not. | |
| 153 | + #[test] | |
| 154 | + fn sleep_lasts_until_morning() { | |
| 155 | + assert!(!done_sleeping(1.0, 3.0)); | |
| 156 | + assert!(done_sleeping(1.0, 7.0)); | |
| 157 | + assert!(!done_sleeping(0.7, 7.0)); | |
| 158 | + } | |
| 159 | + | |
| 160 | + #[test] | |
| 161 | + fn rest_drains_awake_and_recovers_asleep() { | |
| 162 | + let mut app = App::new(); | |
| 163 | + app.add_systems(Update, update_rest); | |
| 164 | + let tile = UVec2::new(3, 3); | |
| 165 | + let awake = app | |
| 166 | + .world_mut() | |
| 167 | + .spawn((Rest(0.5), CurrentJob(None), TilePos(tile), Path::default())) | |
| 168 | + .id(); | |
| 169 | + let asleep = app | |
| 170 | + .world_mut() | |
| 171 | + .spawn(( | |
| 172 | + Rest(0.5), | |
| 173 | + CurrentJob(Some(Job { | |
| 174 | + task: Task::Sleep { spot: tile }, | |
| 175 | + ordered: false, | |
| 176 | + })), | |
| 177 | + TilePos(tile), | |
| 178 | + Path::default(), | |
| 179 | + )) | |
| 180 | + .id(); | |
| 181 | + app.update(); | |
| 182 | + assert!(app.world().get::<Rest>(awake).unwrap().0 < 0.5); | |
| 183 | + assert!(app.world().get::<Rest>(asleep).unwrap().0 > 0.5); | |
| 184 | + } | |
| 185 | + | |
| 186 | + #[test] | |
| 187 | + fn sleepers_bed_down_by_the_campfire() { | |
| 188 | + let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass); | |
| 189 | + let zones = Zones::default(); | |
| 190 | + let reservations = Reservations::default(); | |
| 191 | + let fires = [(UVec2::new(15, 15), StructureKind::Campfire, 1000)]; | |
| 192 | + let ctx = SleepContext { | |
| 193 | + map: &map, | |
| 194 | + zones: &zones, | |
| 195 | + reservations: &reservations, | |
| 196 | + items: &[], | |
| 197 | + fires: &fires, | |
| 198 | + }; | |
| 199 | + let spot = ctx.spot(UVec2::new(2, 2)); | |
| 200 | + assert!(spot.as_vec2().distance(Vec2::splat(15.0)) < 2.0); | |
| 201 | + assert_ne!(spot, UVec2::new(15, 15)); | |
| 202 | + } | |
| 203 | +} | |
src/selection.rs +20 -13
| @@ -7,6 +7,7 @@ use crate::{ | ||
| 7 | 7 | items::{Carrying, Item, ItemKind, stock}, |
| 8 | 8 | jobs::{ColonyGoals, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind, build_work}, |
| 9 | 9 | light::{Structure, StructureKind, daylight}, |
| 10 | + needs::Rest, | |
| 10 | 11 | sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day}, |
| 11 | 12 | ui::{HideableUi, PointerOverUi}, |
| 12 | 13 | world::{TILE_SIZE, TilePos, WorldMap}, |
| @@ -233,7 +234,7 @@ fn update_tile_info( | ||
| 233 | 234 | map: Res<WorldMap>, |
| 234 | 235 | cursor: Res<Cursor>, |
| 235 | 236 | selection: Res<Selection>, |
| 236 | - colonists: Query<(&Colonist, &TilePos, &Path, &CurrentJob, &Carrying)>, | |
| 237 | + colonists: Query<(&Colonist, &TilePos, &Path, &CurrentJob, &Carrying, &Rest)>, | |
| 237 | 238 | items: Query<(&Item, &TilePos)>, |
| 238 | 239 | structures: Query<(&Structure, &TilePos)>, |
| 239 | 240 | goals: Res<ColonyGoals>, |
| @@ -250,8 +251,8 @@ fn update_tile_info( | ||
| 250 | 251 | Selection::Tile(tile) => describe_tile(&map, Some(tile)), |
| 251 | 252 | Selection::Colonist(entity) => colonists |
| 252 | 253 | .get(entity) |
| 253 | - .map(|(colonist, tile, path, job, carrying)| { | |
| 254 | - describe_colonist(colonist, tile.0, job.0, path.0.len(), carrying.0) | |
| 254 | + .map(|(colonist, tile, path, job, carrying, rest)| { | |
| 255 | + describe_colonist(colonist, tile.0, job.0, path.0.len(), carrying.0, rest.0) | |
| 255 | 256 | }) |
| 256 | 257 | .unwrap_or_else(|_| "-".into()), |
| 257 | 258 | }; |
| @@ -364,6 +365,7 @@ pub fn describe_colonist( | ||
| 364 | 365 | job: Option<Job>, |
| 365 | 366 | steps_left: usize, |
| 366 | 367 | carrying: Option<Item>, |
| 368 | + rest: f32, | |
| 367 | 369 | ) -> String { |
| 368 | 370 | let activity = match (job.map(|j| j.task), steps_left) { |
| 369 | 371 | (None, 0) => "idle".to_string(), |
| @@ -414,6 +416,8 @@ pub fn describe_colonist( | ||
| 414 | 416 | (Some(Task::Build { kind, .. }), n) if n > 0 => { |
| 415 | 417 | format!("carrying wood to build a {}", structure_name(kind)) |
| 416 | 418 | } |
| 419 | + (Some(Task::Sleep { .. }), n) if n > 0 => "going to sleep".to_string(), | |
| 420 | + (Some(Task::Sleep { .. }), _) => "sleeping".to_string(), | |
| 417 | 421 | (Some(task @ Task::Build { kind, .. }), _) => { |
| 418 | 422 | format!("building a {}, {}%", structure_name(kind), percent(task)) |
| 419 | 423 | } |
| @@ -427,8 +431,11 @@ pub fn describe_colonist( | ||
| 427 | 431 | "" |
| 428 | 432 | }; |
| 429 | 433 | format!( |
| 430 | - "{} at ({}, {}), {activity}{ordered}{load}", | |
| 431 | - colonist.name, tile.x, tile.y | |
| 434 | + "{} at ({}, {}), {activity}{ordered}{load}, rest {:.0}%", | |
| 435 | + colonist.name, | |
| 436 | + tile.x, | |
| 437 | + tile.y, | |
| 438 | + rest * 100.0 | |
| 432 | 439 | ) |
| 433 | 440 | } |
| 434 | 441 | |
| @@ -610,16 +617,16 @@ mod tests { | ||
| 610 | 617 | let ada = Colonist { name: "Ada".into() }; |
| 611 | 618 | let tile = UVec2::new(1, 2); |
| 612 | 619 | assert_eq!( |
| 613 | - describe_colonist(&ada, tile, None, 0, None), | |
| 614 | - "Ada at (1, 2), idle" | |
| 620 | + describe_colonist(&ada, tile, None, 0, None, 1.0), | |
| 621 | + "Ada at (1, 2), idle, rest 100%" | |
| 615 | 622 | ); |
| 616 | 623 | let ordered_move = Job { |
| 617 | 624 | task: Task::MoveTo(UVec2::ZERO), |
| 618 | 625 | ordered: true, |
| 619 | 626 | }; |
| 620 | 627 | assert_eq!( |
| 621 | - describe_colonist(&ada, tile, Some(ordered_move), 3, None), | |
| 622 | - "Ada at (1, 2), moving, 3 steps left (ordered)" | |
| 628 | + describe_colonist(&ada, tile, Some(ordered_move), 3, None, 1.0), | |
| 629 | + "Ada at (1, 2), moving, 3 steps left (ordered), rest 100%" | |
| 623 | 630 | ); |
| 624 | 631 | let chop = Job { |
| 625 | 632 | task: Task::Work { |
| @@ -630,8 +637,8 @@ mod tests { | ||
| 630 | 637 | ordered: false, |
| 631 | 638 | }; |
| 632 | 639 | assert_eq!( |
| 633 | - describe_colonist(&ada, tile, Some(chop), 0, None), | |
| 634 | - "Ada at (1, 2), chopping a tree, 50%" | |
| 640 | + describe_colonist(&ada, tile, Some(chop), 0, None, 0.5), | |
| 641 | + "Ada at (1, 2), chopping a tree, 50%, rest 50%" | |
| 635 | 642 | ); |
| 636 | 643 | let haul = Job { |
| 637 | 644 | task: Task::Haul { |
| @@ -645,8 +652,8 @@ mod tests { | ||
| 645 | 652 | count: 10, |
| 646 | 653 | }; |
| 647 | 654 | assert_eq!( |
| 648 | - describe_colonist(&ada, tile, Some(haul), 4, Some(wood)), | |
| 649 | - "Ada at (1, 2), hauling to the stockpile, carrying 10 Wood" | |
| 655 | + describe_colonist(&ada, tile, Some(haul), 4, Some(wood), 0.64), | |
| 656 | + "Ada at (1, 2), hauling to the stockpile, carrying 10 Wood, rest 64%" | |
| 650 | 657 | ); |
| 651 | 658 | } |
| 652 | 659 | } |
src/zones.rs +15 -0
| @@ -44,6 +44,21 @@ impl Zones { | ||
| 44 | 44 | self.stockpile.iter().copied() |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | + pub fn home(&self, fallback: UVec2) -> UVec2 { | |
| 48 | + let centroid = self.stockpile.iter().map(|t| t.as_vec2()).sum::<Vec2>() | |
| 49 | + / self.stockpile.len().max(1) as f32; | |
| 50 | + self.stockpile | |
| 51 | + .iter() | |
| 52 | + .copied() | |
| 53 | + .min_by(|a, b| { | |
| 54 | + a.as_vec2() | |
| 55 | + .distance_squared(centroid) | |
| 56 | + .total_cmp(&b.as_vec2().distance_squared(centroid)) | |
| 57 | + .then((a.y, a.x).cmp(&(b.y, b.x))) | |
| 58 | + }) | |
| 59 | + .unwrap_or(fallback) | |
| 60 | + } | |
| 61 | + | |
| 47 | 62 | pub fn has_stockpile(&self) -> bool { |
| 48 | 63 | !self.stockpile.is_empty() |
| 49 | 64 | } |
tasks.md +2 -1
| @@ -18,12 +18,13 @@ | ||
| 18 | 18 | - [x] Mining (stone/iron/gold → gravel floor), per-material colony goals driving chopping and mining |
| 19 | 19 | - [x] Colonists found a stockpile when items need storing and expand it when nearly full (no starting stockpile) |
| 20 | 20 | - [x] Day/night cycle with per-tile lighting; colonists build campfires and torches (wood, burn out); work is slower in the dark |
| 21 | +- [x] Sleep: rest need, bedtime/naps/exhaustion, colonists sleep by the campfire until morning | |
| 21 | 22 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 22 | 23 | |
| 23 | 24 | ## Planned |
| 24 | 25 | - [ ] Jobs: building (walls, floors; consumes materials) |
| 25 | 26 | - [ ] Refuelling fires instead of rebuilding; manual build orders for fires |
| 26 | -- [ ] Sleep/needs (night currently only affects light and work speed) | |
| 27 | +- [ ] Beds/shelter (sleep spots are just ground near the campfire); more needs (food) | |
| 27 | 28 | - [ ] Stockpile filters (which item kinds a zone accepts) and storage priority |
| 28 | 29 | - [ ] Designations (mark trees/rock for anyone to work) and per-colonist work priorities (job order is fixed: deliver > found/expand stockpile > chop/mine > haul > wander) |
| 29 | 30 | - [ ] UI to edit colony goals (material targets) |