Add beds and split structures out of light module

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 10:36 UTC
commit
bdeba7cc88b6377bdc68249416d1f7e8cc1e58ce
parent
b02b916b6b
tree
browse at this commit

9 files changed +529 -261

CLAUDE.md +4 -3

@@ -22,12 +22,13 @@ 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 (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).
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` (`BuildContext` (shared by lighting and beds; `buildable` = dry, not stockpile/plant/structure/planned/item, unreserved): 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` → `find_bed_site` (one bed per colonist incl. planned; next to nearest bed, else campfire, else home) → 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 -- `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.
29 +- `src/light.rs` day/night lighting. `daylight(hour)` (dawn 5–7, dusk 18–20), `ambient_color` (night = blue-ish), `dark_soon(tick)` (now or within LIGHT_LOOKAHEAD_TICKS). `light_at(kind, source, tile)` falloff from `StructureKind::light()` (None for non-emitters). `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/structures not darkened).
30 +- `src/structures.rs` `Structure{kind: StructureKind::{Campfire, Torch, Bed}}` + 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: wood_cost 10/2/15, `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; avoid plain circles, those read as colonists; z=0.8). `spawn_structure` inserts Fuel when needed. Cleared on WorldGenerated.
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) 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.
31 32 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
32 33
33 34 ## Gotchas

src/jobs.rs +129 -21

@@ -12,14 +12,12 @@ use crate::{
12 12 CARRY_CAPACITY, Carrying, Item, ItemAssets, ItemKind, ItemQuery, drop_items, room_for,
13 13 stock, take_items,
14 14 },
15 - light::{
16 - LIT_BRIGHTNESS, LightMap, Structure, StructureAssets, StructureKind, brightness, dark_soon,
17 - light_from, spawn_structure,
18 - },
15 + light::{LIT_BRIGHTNESS, LightMap, brightness, dark_soon, light_from},
19 16 needs::{EXHAUSTED_REST, Rest, SleepContext, done_sleeping, wants_sleep},
20 17 pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_tile},
21 18 rng::SimRng,
22 19 sim::{SimSystems, SimTick, hour_of_day},
20 + structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure},
23 21 world::{Plant, Terrain, TileChanged, TilePos, WorldMap},
24 22 worldgen::WorldGenerated,
25 23 zones::{Zones, can_zone, find_stockpile_site, new_stockpile_tiles, plan_expansion},
@@ -109,6 +107,7 @@ pub fn build_work(kind: StructureKind) -> u32 {
109 107 match kind {
110 108 StructureKind::Campfire => 360,
111 109 StructureKind::Torch => 120,
110 + StructureKind::Bed => 600,
112 111 }
113 112 }
114 113
@@ -426,34 +425,35 @@ fn central_item_tile(items: &[(Item, TilePos)]) -> Option<UVec2> {
426 425 })
427 426 }
428 427
429 -pub struct LightingContext<'a> {
428 +pub struct BuildContext<'a> {
430 429 pub map: &'a WorldMap,
431 430 pub zones: &'a Zones,
432 431 pub reservations: &'a Reservations,
433 432 pub items: &'a [(Item, TilePos)],
434 - pub fires: &'a [(UVec2, StructureKind, u32)],
433 + pub structures: &'a [(UVec2, StructureKind, u32)],
435 434 pub planned: &'a [(UVec2, StructureKind)],
436 435 }
437 436
438 -impl LightingContext<'_> {
437 +impl BuildContext<'_> {
439 438 fn buildable(&self, tile: UVec2) -> bool {
440 439 can_zone(self.map, tile.as_ivec2())
441 440 && !self.zones.is_stockpile(tile)
442 441 && self.map.plant(tile.as_ivec2()).is_none()
443 - && !self.fires.iter().any(|(pos, ..)| *pos == tile)
442 + && !self.structures.iter().any(|(pos, ..)| *pos == tile)
443 + && !self.planned.iter().any(|(pos, _)| *pos == tile)
444 444 && !self.items.iter().any(|(_, pos)| pos.0 == tile)
445 445 && self.reservations.is_free(Target::Site(tile))
446 446 }
447 447
448 448 fn lights(&self) -> impl Iterator<Item = (UVec2, StructureKind)> + Clone + '_ {
449 - self.fires
449 + self.structures
450 450 .iter()
451 451 .map(|&(pos, kind, _)| (pos, kind))
452 452 .chain(self.planned.iter().copied())
453 453 }
454 454
455 455 fn campfire_needed(&self, home: UVec2) -> bool {
456 - let burning = self.fires.iter().any(|&(pos, kind, fuel)| {
456 + let burning = self.structures.iter().any(|&(pos, kind, fuel)| {
457 457 kind == StructureKind::Campfire
458 458 && fuel >= CAMPFIRE_MIN_FUEL
459 459 && pos.as_vec2().distance(home.as_vec2()) <= HOME_RADIUS
@@ -482,6 +482,31 @@ impl LightingContext<'_> {
482 482 .filter(|site| site.as_vec2().distance(target.as_vec2()) <= max_distance)
483 483 }
484 484
485 + fn count(&self, kind: StructureKind) -> usize {
486 + let built = self.structures.iter().filter(|s| s.1 == kind).count();
487 + built + self.planned.iter().filter(|p| p.1 == kind).count()
488 + }
489 +
490 + fn bed_anchor(&self, home: UVec2) -> UVec2 {
491 + let positions = |kind: StructureKind| {
492 + let built = self.structures.iter().map(|s| (s.0, s.1));
493 + built
494 + .chain(self.planned.iter().copied())
495 + .filter(move |s| s.1 == kind)
496 + .map(|s| s.0)
497 + };
498 + let nearest = |kind: StructureKind| {
499 + positions(kind).min_by(|a, b| {
500 + a.as_vec2()
501 + .distance_squared(home.as_vec2())
502 + .total_cmp(&b.as_vec2().distance_squared(home.as_vec2()))
503 + })
504 + };
505 + nearest(StructureKind::Bed)
506 + .or_else(|| nearest(StructureKind::Campfire))
507 + .unwrap_or(home)
508 + }
509 +
485 510 fn wood_source(&self, from: UVec2, amount: u32) -> Option<UVec2> {
486 511 nearest_tile(self.map, from, SEARCH_TILES, |p| {
487 512 self.reservations.is_free(Target::Pickup(p))
@@ -492,7 +517,7 @@ impl LightingContext<'_> {
492 517 }
493 518 }
494 519
495 -pub fn find_lighting(ctx: &LightingContext, from: UVec2) -> Option<Task> {
520 +pub fn find_lighting(ctx: &BuildContext, from: UVec2) -> Option<Task> {
496 521 let home = ctx.zones.home(from);
497 522 let (kind, site) = if ctx.campfire_needed(home) {
498 523 (StructureKind::Campfire, ctx.site_near(home, HOME_RADIUS)?)
@@ -510,6 +535,21 @@ pub fn find_lighting(ctx: &LightingContext, from: UVec2) -> Option<Task> {
510 535 })
511 536 }
512 537
538 +pub fn find_bed_site(ctx: &BuildContext, colonists: usize, from: UVec2) -> Option<Task> {
539 + if ctx.count(StructureKind::Bed) >= colonists {
540 + return None;
541 + }
542 + let anchor = ctx.bed_anchor(ctx.zones.home(from));
543 + let site = ctx.site_near(anchor, HOME_RADIUS)?;
544 + let pickup = ctx.wood_source(from, StructureKind::Bed.wood_cost())?;
545 + Some(Task::Build {
546 + site,
547 + kind: StructureKind::Bed,
548 + pickup: Some(pickup),
549 + progress: 0,
550 + })
551 +}
552 +
513 553 fn on_the_way(map: &WorldMap, jobs: impl Iterator<Item = Option<Job>>) -> HashMap<ItemKind, u32> {
514 554 let mut totals = HashMap::new();
515 555 for job in jobs.flatten() {
@@ -534,7 +574,7 @@ fn choose_jobs(
534 574 mut rng: ResMut<SimRng>,
535 575 mut reservations: ResMut<Reservations>,
536 576 mut items: ItemQuery,
537 - structures: Query<(&TilePos, &Structure), Without<Colonist>>,
577 + structures: Query<(&TilePos, &Structure, Option<&Fuel>), Without<Colonist>>,
538 578 mut colonists: Query<
539 579 (
540 580 Entity,
@@ -548,9 +588,10 @@ fn choose_jobs(
548 588 With<Colonist>,
549 589 >,
550 590 ) {
551 - let fires: Vec<(UVec2, StructureKind, u32)> = structures
591 + let colonist_count = colonists.iter().count();
592 + let structures_list: Vec<(UVec2, StructureKind, u32)> = structures
552 593 .iter()
553 - .map(|(pos, s)| (pos.0, s.kind, s.fuel))
594 + .map(|(pos, s, fuel)| (pos.0, s.kind, fuel.map_or(u32::MAX, |f| f.0)))
554 595 .collect();
555 596 let mut planned: Vec<(UVec2, StructureKind)> = colonists
556 597 .iter()
@@ -600,7 +641,7 @@ fn choose_jobs(
600 641 zones: &zones,
601 642 reservations: &reservations,
602 643 items: &snapshot,
603 - fires: &fires,
644 + structures: &structures_list,
604 645 };
605 646 let task = if wants_sleep(rest.0, hour) {
606 647 Some(Task::Sleep {
@@ -621,18 +662,19 @@ fn choose_jobs(
621 662 }
622 663 storage.map(|to| Task::Haul { pickup: None, to })
623 664 } else {
624 - let lighting = LightingContext {
665 + let build = BuildContext {
625 666 map: &map,
626 667 zones: &zones,
627 668 reservations: &reservations,
628 669 items: &snapshot,
629 - fires: &fires,
670 + structures: &structures_list,
630 671 planned: &planned,
631 672 };
632 673 find_expansion(&map, &zones, &reservations, &snapshot)
633 - .or_else(|| dark.then(|| find_lighting(&lighting, tile.0)).flatten())
674 + .or_else(|| dark.then(|| find_lighting(&build, tile.0)).flatten())
634 675 .or_else(|| find_work(&map, &reservations, tile.0, wanted))
635 676 .or_else(|| find_haul(&map, &zones, &reservations, &snapshot, tile.0))
677 + .or_else(|| find_bed_site(&build, colonist_count, tile.0))
636 678 };
637 679 if let Some(task) = task {
638 680 if let Task::Work { site, .. } = task
@@ -1046,8 +1088,20 @@ mod tests {
1046 1088 .sum()
1047 1089 }
1048 1090
1091 + fn put_beds(app: &mut App, count: u32) {
1092 + for x in 0..count {
1093 + app.world_mut().spawn((
1094 + Structure {
1095 + kind: StructureKind::Bed,
1096 + },
1097 + TilePos(UVec2::new(19 - x, 19)),
1098 + ));
1099 + }
1100 + }
1101 +
1049 1102 fn hauling_app(stockpile: (IVec2, IVec2)) -> App {
1050 1103 let mut app = test_app(map_with_trees(&[]));
1104 + put_beds(&mut app, 2);
1051 1105 set_goals(&mut app, &[]);
1052 1106 let map = app.world().resource::<WorldMap>().clone();
1053 1107 app.world_mut()
@@ -1199,6 +1253,7 @@ mod tests {
1199 1253 #[test]
1200 1254 fn colonists_found_a_stockpile_when_items_need_storing() {
1201 1255 let mut app = test_app(map_with_trees(&[]));
1256 + put_beds(&mut app, 1);
1202 1257 set_goals(&mut app, &[]);
1203 1258 put_wood(&mut app, UVec2::new(5, 5), 20);
1204 1259 spawn_colonist(&mut app, UVec2::new(2, 5));
@@ -1377,8 +1432,8 @@ mod tests {
1377 1432 app.world_mut().spawn((
1378 1433 Structure {
1379 1434 kind: StructureKind::Campfire,
1380 - fuel: StructureKind::Campfire.fuel_ticks(),
1381 1435 },
1436 + Fuel(StructureKind::Campfire.fuel_ticks().unwrap()),
1382 1437 TilePos(tile),
1383 1438 ));
1384 1439 }
@@ -1406,7 +1461,11 @@ mod tests {
1406 1461 set_hour(&mut app, 10);
1407 1462 spawn_colonist(&mut app, UVec2::new(2, 5));
1408 1463 run_ticks(&mut app, 60 * 10);
1409 - assert!(structures(&mut app).is_empty());
1464 + assert!(
1465 + structures(&mut app)
1466 + .iter()
1467 + .all(|(_, kind)| kind.light().is_none())
1468 + );
1410 1469 }
1411 1470
1412 1471 #[test]
@@ -1415,7 +1474,11 @@ mod tests {
1415 1474 set_hour(&mut app, 22);
1416 1475 spawn_colonist(&mut app, UVec2::new(2, 5));
1417 1476 run_ticks(&mut app, 60 * 10);
1418 - assert!(structures(&mut app).is_empty());
1477 + assert!(
1478 + structures(&mut app)
1479 + .iter()
1480 + .all(|(_, kind)| kind.light().is_none())
1481 + );
1419 1482 }
1420 1483
1421 1484 #[test]
@@ -1556,6 +1619,51 @@ mod tests {
1556 1619 ));
1557 1620 }
1558 1621
1622 + fn beds(app: &mut App) -> usize {
1623 + structures(app)
1624 + .iter()
1625 + .filter(|(_, kind)| *kind == StructureKind::Bed)
1626 + .count()
1627 + }
1628 +
1629 + #[test]
1630 + fn colonists_build_one_bed_each() {
1631 + let mut app = test_app(map_with_trees(&[]));
1632 + set_goals(&mut app, &[]);
1633 + let map = app.world().resource::<WorldMap>().clone();
1634 + app.world_mut().resource_mut::<Zones>().add_stockpile(
1635 + &map,
1636 + IVec2::new(2, 2),
1637 + IVec2::new(5, 5),
1638 + );
1639 + put_wood(&mut app, UVec2::new(2, 2), 50);
1640 + put_wood(&mut app, UVec2::new(3, 2), 50);
1641 + spawn_colonist(&mut app, UVec2::new(10, 10));
1642 + spawn_colonist(&mut app, UVec2::new(11, 10));
1643 + run_ticks(&mut app, 60 * 40);
1644 + assert_eq!(beds(&mut app), 2);
1645 + assert_eq!(
1646 + wood_on_map(&mut app),
1647 + 100 - 2 * StructureKind::Bed.wood_cost()
1648 + );
1649 + }
1650 +
1651 + #[test]
1652 + fn colonists_sleep_in_their_beds() {
1653 + let mut app = test_app(map_with_trees(&[]));
1654 + set_goals(&mut app, &[]);
1655 + put_beds(&mut app, 1);
1656 + set_hour(&mut app, 22);
1657 + let colonist = spawn_colonist(&mut app, UVec2::new(5, 5));
1658 + set_rest(&mut app, colonist, 0.5);
1659 + run_ticks(&mut app, 60 * 10);
1660 + assert_eq!(
1661 + app.world().get::<TilePos>(colonist).unwrap().0,
1662 + UVec2::new(19, 19)
1663 + );
1664 + assert!(matches!(task(&app, colonist), Some(Task::Sleep { .. })));
1665 + }
1666 +
1559 1667 #[test]
1560 1668 fn colonists_chop_from_a_neighbouring_tile() {
1561 1669 let tree = UVec2::new(8, 5);

src/light.rs +23 -203

@@ -2,8 +2,8 @@ use bevy::prelude::*;
2 2
3 3 use crate::{
4 4 sim::{SimSystems, SimTick, TICKS_PER_HOUR, hour_of_day},
5 - world::{TILE_SIZE, TilePos, WorldMap},
6 - worldgen::WorldGenerated,
5 + structures::{Structure, StructureKind},
6 + world::{TilePos, WorldMap},
7 7 };
8 8
9 9 pub struct LightPlugin;
@@ -11,18 +11,7 @@ pub struct LightPlugin;
11 11 impl Plugin for LightPlugin {
12 12 fn build(&self, app: &mut App) {
13 13 app.init_resource::<LightMap>()
14 - .add_systems(Startup, create_structure_assets)
15 - .add_systems(
16 - Update,
17 - clear_structures.run_if(on_message::<WorldGenerated>),
18 - )
19 - .add_systems(
20 - FixedUpdate,
21 - (
22 - update_light_map.in_set(SimSystems::Decide),
23 - burn_fuel.in_set(SimSystems::Act),
24 - ),
25 - );
14 + .add_systems(FixedUpdate, update_light_map.in_set(SimSystems::Decide));
26 15 }
27 16 }
28 17
@@ -55,62 +44,21 @@ pub fn dark_soon(tick: SimTick) -> bool {
55 44 daylight(hour_of_day(tick)) < LIT_BRIGHTNESS || daylight(hour_of_day(later)) < LIT_BRIGHTNESS
56 45 }
57 46
58 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
59 -pub enum StructureKind {
60 - Campfire,
61 - Torch,
62 -}
63 -
64 -impl StructureKind {
65 - pub fn wood_cost(self) -> u32 {
66 - match self {
67 - StructureKind::Campfire => 10,
68 - StructureKind::Torch => 2,
69 - }
70 - }
71 -
72 - pub fn fuel_ticks(self) -> u32 {
73 - let hours = match self {
74 - StructureKind::Campfire => 12,
75 - StructureKind::Torch => 8,
76 - };
77 - hours * TICKS_PER_HOUR as u32
78 - }
79 -
80 - pub fn radius(self) -> f32 {
81 - match self {
82 - StructureKind::Campfire => 7.0,
83 - StructureKind::Torch => 4.0,
84 - }
85 - }
86 -
87 - fn light_color(self) -> Vec3 {
88 - match self {
89 - StructureKind::Campfire => Vec3::new(1.1, 0.8, 0.5),
90 - StructureKind::Torch => Vec3::new(1.0, 0.8, 0.55),
91 - }
92 - }
93 -
94 - pub fn light_at(self, source: UVec2, tile: UVec2) -> Vec3 {
95 - let d = source.as_vec2().distance(tile.as_vec2()) / self.radius();
96 - if d >= 1.0 {
97 - return Vec3::ZERO;
98 - }
99 - self.light_color() * (1.0 - d * d).powi(2)
47 +pub fn light_at(kind: StructureKind, source: UVec2, tile: UVec2) -> Vec3 {
48 + let Some((radius, color)) = kind.light() else {
49 + return Vec3::ZERO;
50 + };
51 + let d = source.as_vec2().distance(tile.as_vec2()) / radius;
52 + if d >= 1.0 {
53 + return Vec3::ZERO;
100 54 }
101 -}
102 -
103 -#[derive(Component, Debug, Clone, Copy, PartialEq)]
104 -#[require(Transform, Visibility)]
105 -pub struct Structure {
106 - pub kind: StructureKind,
107 - pub fuel: u32,
55 + color * (1.0 - d * d).powi(2)
108 56 }
109 57
110 58 pub fn light_from(tile: UVec2, sources: impl IntoIterator<Item = (UVec2, StructureKind)>) -> Vec3 {
111 59 sources
112 60 .into_iter()
113 - .map(|(pos, kind)| kind.light_at(pos, tile))
61 + .map(|(pos, kind)| light_at(kind, pos, tile))
114 62 .sum()
115 63 }
116 64
@@ -142,7 +90,10 @@ impl LightMap {
142 90 let ambient_step = (daylight * AMBIENT_STEPS).round() as u32;
143 91 let mut light = vec![Vec3::ZERO; size.element_product() as usize];
144 92 for (pos, kind) in sources {
145 - let r = kind.radius().ceil() as i32;
93 + let Some((radius, _)) = kind.light() else {
94 + continue;
95 + };
96 + let r = radius.ceil() as i32;
146 97 for dy in -r..=r {
147 98 for dx in -r..=r {
148 99 let tile = pos.as_ivec2() + IVec2::new(dx, dy);
@@ -154,7 +105,7 @@ impl LightMap {
154 105 continue;
155 106 }
156 107 let tile = tile.as_uvec2();
157 - light[(tile.y * size.x + tile.x) as usize] += kind.light_at(pos, tile);
108 + light[(tile.y * size.x + tile.x) as usize] += light_at(kind, pos, tile);
158 109 }
159 110 }
160 111 }
@@ -188,126 +139,6 @@ fn update_light_map(
188 139 );
189 140 }
190 141
191 -fn burn_fuel(mut commands: Commands, mut structures: Query<(Entity, &mut Structure)>) {
192 - for (entity, mut structure) in &mut structures {
193 - structure.fuel = structure.fuel.saturating_sub(1);
194 - if structure.fuel == 0 {
195 - commands.entity(entity).despawn();
196 - }
197 - }
198 -}
199 -
200 -fn clear_structures(mut commands: Commands, structures: Query<Entity, With<Structure>>) {
201 - for entity in &structures {
202 - commands.entity(entity).despawn();
203 - }
204 -}
205 -
206 -type Part = (Handle<Mesh>, Handle<ColorMaterial>);
207 -
208 -#[derive(Resource, Default)]
209 -pub struct StructureAssets {
210 - stone: Part,
211 - log: Part,
212 - flame: Part,
213 - flame_core: Part,
214 - stick: Part,
215 -}
216 -
217 -const CAMPFIRE_STONES: usize = 7;
218 -
219 -fn flame_mesh(width: f32, height: f32) -> Mesh {
220 - Triangle2d::new(
221 - Vec2::new(-width / 2.0, -height / 2.0),
222 - Vec2::new(width / 2.0, -height / 2.0),
223 - Vec2::new(0.0, height / 2.0),
224 - )
225 - .into()
226 -}
227 -
228 -fn create_structure_assets(
229 - mut commands: Commands,
230 - mut meshes: ResMut<Assets<Mesh>>,
231 - mut materials: ResMut<Assets<ColorMaterial>>,
232 -) {
233 - let mut part = |mesh: Mesh, color: Color| (meshes.add(mesh), materials.add(color));
234 - commands.insert_resource(StructureAssets {
235 - stone: part(
236 - Circle::new(TILE_SIZE * 0.09).into(),
237 - Color::srgb_u8(120, 116, 110),
238 - ),
239 - log: part(
240 - Rectangle::new(TILE_SIZE * 0.7, TILE_SIZE * 0.14).into(),
241 - Color::srgb_u8(110, 74, 42),
242 - ),
243 - flame: part(
244 - flame_mesh(TILE_SIZE * 0.36, TILE_SIZE * 0.5),
245 - Color::srgb_u8(240, 110, 30),
246 - ),
247 - flame_core: part(
248 - flame_mesh(TILE_SIZE * 0.18, TILE_SIZE * 0.28),
249 - Color::srgb_u8(255, 220, 90),
250 - ),
251 - stick: part(
252 - Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.5).into(),
253 - Color::srgb_u8(100, 70, 40),
254 - ),
255 - });
256 -}
257 -
258 -fn part_bundle((mesh, material): &Part, transform: Transform) -> impl Bundle {
259 - (
260 - Mesh2d(mesh.clone()),
261 - MeshMaterial2d(material.clone()),
262 - transform,
263 - )
264 -}
265 -
266 -pub fn spawn_structure(
267 - commands: &mut Commands,
268 - assets: &StructureAssets,
269 - tile: UVec2,
270 - kind: StructureKind,
271 -) {
272 - let mut entity = commands.spawn((
273 - Name::new(format!("{kind:?}")),
274 - Structure {
275 - kind,
276 - fuel: kind.fuel_ticks(),
277 - },
278 - TilePos(tile),
279 - Transform::from_translation(WorldMap::tile_center(tile).extend(0.8)),
280 - ));
281 - let at = |x: f32, y: f32, z: f32| Transform::from_xyz(x * TILE_SIZE, y * TILE_SIZE, z);
282 - match kind {
283 - StructureKind::Campfire => entity.with_children(|c| {
284 - for i in 0..CAMPFIRE_STONES {
285 - let angle = i as f32 / CAMPFIRE_STONES as f32 * std::f32::consts::TAU;
286 - let pos = Vec2::from_angle(angle) * 0.38;
287 - c.spawn(part_bundle(&assets.stone, at(pos.x, pos.y, 0.0)));
288 - }
289 - for angle in [0.6_f32, -0.6] {
290 - let log = at(0.0, -0.08, 0.01).with_rotation(Quat::from_rotation_z(angle));
291 - c.spawn(part_bundle(&assets.log, log));
292 - }
293 - c.spawn(part_bundle(&assets.flame, at(0.0, 0.08, 0.02)));
294 - c.spawn(part_bundle(&assets.flame_core, at(0.0, 0.03, 0.03)));
295 - }),
296 - StructureKind::Torch => entity.with_children(|c| {
297 - c.spawn(part_bundle(&assets.stick, at(0.0, -0.15, 0.0)));
298 - let small = Vec3::splat(0.6);
299 - c.spawn(part_bundle(
300 - &assets.flame,
301 - at(0.0, 0.18, 0.01).with_scale(small),
302 - ));
303 - c.spawn(part_bundle(
304 - &assets.flame_core,
305 - at(0.0, 0.15, 0.02).with_scale(small),
306 - ));
307 - }),
308 - };
309 -}
310 -
311 142 #[cfg(test)]
312 143 mod tests {
313 144 use super::*;
@@ -335,11 +166,11 @@ mod tests {
335 166 #[test]
336 167 fn fires_light_their_surroundings_with_falloff() {
337 168 let fire = UVec2::new(10, 10);
338 - let near = StructureKind::Campfire.light_at(fire, UVec2::new(11, 10));
339 - let far = StructureKind::Campfire.light_at(fire, UVec2::new(15, 10));
169 + let near = light_at(StructureKind::Campfire, fire, UVec2::new(11, 10));
170 + let far = light_at(StructureKind::Campfire, fire, UVec2::new(15, 10));
340 171 assert!(brightness(near) > brightness(far));
341 172 assert_eq!(
342 - StructureKind::Campfire.light_at(fire, UVec2::new(17, 10)),
173 + light_at(StructureKind::Campfire, fire, UVec2::new(17, 10)),
343 174 Vec3::ZERO
344 175 );
345 176 }
@@ -360,19 +191,8 @@ mod tests {
360 191 }
361 192
362 193 #[test]
363 - fn structures_burn_out() {
364 - let mut app = App::new();
365 - app.add_systems(Update, burn_fuel);
366 - let fire = app
367 - .world_mut()
368 - .spawn(Structure {
369 - kind: StructureKind::Torch,
370 - fuel: 3,
371 - })
372 - .id();
373 - for _ in 0..3 {
374 - app.update();
375 - }
376 - assert!(app.world().get_entity(fire).is_err());
194 + fn beds_give_no_light() {
195 + let bed = UVec2::new(4, 4);
196 + assert_eq!(light_at(StructureKind::Bed, bed, bed), Vec3::ZERO);
377 197 }
378 198 }

src/main.rs +2 -0

@@ -10,6 +10,7 @@ mod pathfinding;
10 10 mod rng;
11 11 mod selection;
12 12 mod sim;
13 +mod structures;
13 14 mod tilemap;
14 15 mod ui;
15 16 mod world;
@@ -44,6 +45,7 @@ fn main() {
44 45 items::ItemsPlugin,
45 46 jobs::JobsPlugin,
46 47 light::LightPlugin,
48 + structures::StructuresPlugin,
47 49 needs::NeedsPlugin,
48 50 selection::SelectionPlugin,
49 51 zones::ZonesPlugin,

src/needs.rs +105 -16

@@ -1,12 +1,14 @@
1 +use std::collections::HashSet;
2 +
1 3 use bevy::prelude::*;
2 4
3 5 use crate::{
4 6 colonist::{Colonist, Path},
5 7 items::Item,
6 8 jobs::{CurrentJob, Job, Reservations, Target, Task},
7 - light::StructureKind,
8 9 pathfinding::nearest_tile,
9 10 sim::{SimSystems, TICKS_PER_HOUR},
11 + structures::{Structure, StructureKind},
10 12 world::{TILE_SIZE, TilePos, WorldMap},
11 13 zones::{Zones, can_zone},
12 14 };
@@ -22,6 +24,7 @@ impl Plugin for NeedsPlugin {
22 24
23 25 pub const REST_DRAIN_PER_TICK: f32 = 1.0 / (24.0 * TICKS_PER_HOUR as f32);
24 26 pub const REST_RECOVERY_PER_TICK: f32 = 1.0 / (7.0 * TICKS_PER_HOUR as f32);
27 +pub const BED_REST_RECOVERY_PER_TICK: f32 = 1.0 / (5.0 * TICKS_PER_HOUR as f32);
25 28 pub const BEDTIME_REST: f32 = 0.9;
26 29 pub const NAP_REST: f32 = 0.3;
27 30 pub const EXHAUSTED_REST: f32 = 0.1;
@@ -58,12 +61,20 @@ pub fn is_sleeping(job: &CurrentJob, tile: &TilePos, path: &Path) -> bool {
58 61 )
59 62 }
60 63
61 -pub fn update_rest(mut colonists: Query<(&mut Rest, &CurrentJob, &TilePos, &Path)>) {
64 +pub fn update_rest(
65 + structures: Query<(&TilePos, &Structure), Without<Colonist>>,
66 + mut colonists: Query<(&mut Rest, &CurrentJob, &TilePos, &Path), With<Colonist>>,
67 +) {
68 + let beds: HashSet<UVec2> = structures
69 + .iter()
70 + .filter(|(_, s)| s.kind == StructureKind::Bed)
71 + .map(|(pos, _)| pos.0)
72 + .collect();
62 73 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
74 + let change = match (is_sleeping(job, tile, path), beds.contains(&tile.0)) {
75 + (true, true) => BED_REST_RECOVERY_PER_TICK,
76 + (true, false) => REST_RECOVERY_PER_TICK,
77 + (false, _) => -REST_DRAIN_PER_TICK,
67 78 };
68 79 rest.0 = (rest.0 + change).clamp(0.0, 1.0);
69 80 }
@@ -74,7 +85,7 @@ pub struct SleepContext<'a> {
74 85 pub zones: &'a Zones,
75 86 pub reservations: &'a Reservations,
76 87 pub items: &'a [(Item, TilePos)],
77 - pub fires: &'a [(UVec2, StructureKind, u32)],
88 + pub structures: &'a [(UVec2, StructureKind, u32)],
78 89 }
79 90
80 91 impl SleepContext<'_> {
@@ -82,23 +93,34 @@ impl SleepContext<'_> {
82 93 can_zone(self.map, tile.as_ivec2())
83 94 && self.map.plant(tile.as_ivec2()).is_none()
84 95 && !self.zones.is_stockpile(tile)
85 - && !self.fires.iter().any(|(pos, ..)| *pos == tile)
96 + && !self.structures.iter().any(|(pos, ..)| *pos == tile)
86 97 && !self.items.iter().any(|(_, pos)| pos.0 == tile)
87 98 && self.reservations.is_free(Target::SleepSpot(tile))
88 99 }
89 100
90 - pub fn spot(&self, from: UVec2) -> UVec2 {
91 - let campfire = self
92 - .fires
101 + fn nearest(&self, kind: StructureKind, from: UVec2) -> Option<UVec2> {
102 + self.structures
93 103 .iter()
94 - .filter(|(_, kind, _)| *kind == StructureKind::Campfire)
104 + .filter(|(pos, k, _)| {
105 + *k == kind
106 + && (kind != StructureKind::Bed
107 + || self.reservations.is_free(Target::SleepSpot(*pos)))
108 + })
95 109 .map(|(pos, ..)| *pos)
96 110 .min_by(|a, b| {
97 111 a.as_vec2()
98 112 .distance_squared(from.as_vec2())
99 113 .total_cmp(&b.as_vec2().distance_squared(from.as_vec2()))
100 - });
101 - let anchor = campfire.unwrap_or_else(|| self.zones.home(from));
114 + })
115 + }
116 +
117 + pub fn spot(&self, from: UVec2) -> UVec2 {
118 + if let Some(bed) = self.nearest(StructureKind::Bed, from) {
119 + return bed;
120 + }
121 + let anchor = self
122 + .nearest(StructureKind::Campfire, from)
123 + .unwrap_or_else(|| self.zones.home(from));
102 124 nearest_tile(self.map, anchor, SLEEP_SPOT_SEARCH_TILES, |p| self.free(p)).unwrap_or(from)
103 125 }
104 126 }
@@ -164,11 +186,22 @@ mod tests {
164 186 let tile = UVec2::new(3, 3);
165 187 let awake = app
166 188 .world_mut()
167 - .spawn((Rest(0.5), CurrentJob(None), TilePos(tile), Path::default()))
189 + .spawn((
190 + Colonist {
191 + name: "Waker".into(),
192 + },
193 + Rest(0.5),
194 + CurrentJob(None),
195 + TilePos(tile),
196 + Path::default(),
197 + ))
168 198 .id();
169 199 let asleep = app
170 200 .world_mut()
171 201 .spawn((
202 + Colonist {
203 + name: "Sleeper".into(),
204 + },
172 205 Rest(0.5),
173 206 CurrentJob(Some(Job {
174 207 task: Task::Sleep { spot: tile },
@@ -183,6 +216,62 @@ mod tests {
183 216 assert!(app.world().get::<Rest>(asleep).unwrap().0 > 0.5);
184 217 }
185 218
219 + fn sleeper(app: &mut App, tile: UVec2) -> Entity {
220 + app.world_mut()
221 + .spawn((
222 + Colonist {
223 + name: "Sleeper".into(),
224 + },
225 + Rest(0.2),
226 + CurrentJob(Some(Job {
227 + task: Task::Sleep { spot: tile },
228 + ordered: false,
229 + })),
230 + TilePos(tile),
231 + Path::default(),
232 + ))
233 + .id()
234 + }
235 +
236 + #[test]
237 + fn beds_restore_rest_faster_than_the_ground() {
238 + let mut app = App::new();
239 + app.add_systems(Update, update_rest);
240 + let bed = UVec2::new(1, 1);
241 + app.world_mut().spawn((
242 + Structure {
243 + kind: StructureKind::Bed,
244 + },
245 + TilePos(bed),
246 + ));
247 + let in_bed = sleeper(&mut app, bed);
248 + let on_ground = sleeper(&mut app, UVec2::new(5, 5));
249 + app.update();
250 + let rest = |e| app.world().get::<Rest>(e).unwrap().0;
251 + assert!(rest(in_bed) > rest(on_ground));
252 + }
253 +
254 + #[test]
255 + fn sleepers_take_a_free_bed_over_the_campfire() {
256 + let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
257 + let zones = Zones::default();
258 + let mut reservations = Reservations::default();
259 + let structures = [
260 + (UVec2::new(3, 3), StructureKind::Campfire, 1000),
261 + (UVec2::new(15, 15), StructureKind::Bed, u32::MAX),
262 + (UVec2::new(16, 15), StructureKind::Bed, u32::MAX),
263 + ];
264 + reservations.reserve(Target::SleepSpot(UVec2::new(15, 15)), Entity::PLACEHOLDER);
265 + let ctx = SleepContext {
266 + map: &map,
267 + zones: &zones,
268 + reservations: &reservations,
269 + items: &[],
270 + structures: &structures,
271 + };
272 + assert_eq!(ctx.spot(UVec2::new(2, 2)), UVec2::new(16, 15));
273 + }
274 +
186 275 #[test]
187 276 fn sleepers_bed_down_by_the_campfire() {
188 277 let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
@@ -194,7 +283,7 @@ mod tests {
194 283 zones: &zones,
195 284 reservations: &reservations,
196 285 items: &[],
197 - fires: &fires,
286 + structures: &fires,
198 287 };
199 288 let spot = ctx.spot(UVec2::new(2, 2));
200 289 assert!(spot.as_vec2().distance(Vec2::splat(15.0)) < 2.0);

src/selection.rs +15 -16

@@ -6,9 +6,10 @@ use crate::{
6 6 colonist::{Colonist, Path},
7 7 items::{Carrying, Item, ItemKind, stock},
8 8 jobs::{ColonyGoals, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind, build_work},
9 - light::{Structure, StructureKind, daylight},
9 + light::daylight,
10 10 needs::Rest,
11 11 sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day},
12 + structures::{Fuel, Structure, StructureKind},
12 13 ui::{HideableUi, PointerOverUi},
13 14 world::{TILE_SIZE, TilePos, WorldMap},
14 15 worldgen::{WorldGenerated, WorldSeed},
@@ -236,7 +237,7 @@ fn update_tile_info(
236 237 selection: Res<Selection>,
237 238 colonists: Query<(&Colonist, &TilePos, &Path, &CurrentJob, &Carrying, &Rest)>,
238 239 items: Query<(&Item, &TilePos)>,
239 - structures: Query<(&Structure, &TilePos)>,
240 + structures: Query<(&Structure, &TilePos, Option<&Fuel>)>,
240 241 goals: Res<ColonyGoals>,
241 242 zones: Res<Zones>,
242 243 tool: Res<ZoneTool>,
@@ -285,8 +286,8 @@ fn update_tile_info(
285 286 .collect();
286 287 let hovered_structure: String = structures
287 288 .iter()
288 - .filter(|(_, pos)| Some(pos.0) == cursor.tile)
289 - .map(|(structure, _)| describe_structure(structure))
289 + .filter(|(_, pos, _)| Some(pos.0) == cursor.tile)
290 + .map(|(structure, _, fuel)| describe_structure(structure.kind, fuel.map(|f| f.0)))
290 291 .collect();
291 292 let tool_hint = if tool.active {
292 293 "\nSTOCKPILE TOOL: drag to add, shift-drag to remove"
@@ -334,18 +335,16 @@ pub fn time_of_day(hour: f32) -> &'static str {
334 335 }
335 336 }
336 337
337 -fn structure_name(kind: StructureKind) -> &'static str {
338 - match kind {
339 - StructureKind::Campfire => "campfire",
340 - StructureKind::Torch => "torch",
338 +pub fn describe_structure(kind: StructureKind, fuel: Option<u32>) -> String {
339 + match fuel {
340 + Some(fuel) => {
341 + let hours = fuel as f32 / TICKS_PER_HOUR as f32;
342 + format!(", {:?} ({hours:.1}h of fuel)", kind)
343 + }
344 + None => format!(", {kind:?}"),
341 345 }
342 346 }
343 347
344 -pub fn describe_structure(structure: &Structure) -> String {
345 - let hours = structure.fuel as f32 / TICKS_PER_HOUR as f32;
346 - format!(", {:?} ({hours:.1}h of fuel)", structure.kind)
347 -}
348 -
349 348 pub fn progress_fraction(task: Task) -> Option<f32> {
350 349 match task {
351 350 Task::Work { kind, progress, .. } => Some(progress as f32 / kind.work() as f32),
@@ -412,14 +411,14 @@ pub fn describe_colonist(
412 411 ..
413 412 }),
414 413 _,
415 - ) => format!("fetching wood for a {}", structure_name(kind)),
414 + ) => format!("fetching wood for a {}", kind.name()),
416 415 (Some(Task::Build { kind, .. }), n) if n > 0 => {
417 - format!("carrying wood to build a {}", structure_name(kind))
416 + format!("carrying wood to build a {}", kind.name())
418 417 }
419 418 (Some(Task::Sleep { .. }), n) if n > 0 => "going to sleep".to_string(),
420 419 (Some(Task::Sleep { .. }), _) => "sleeping".to_string(),
421 420 (Some(task @ Task::Build { kind, .. }), _) => {
422 - format!("building a {}, {}%", structure_name(kind), percent(task))
421 + format!("building a {}, {}%", kind.name(), percent(task))
423 422 }
424 423 };
425 424 let load = carrying

src/structures.rs +245 -0

@@ -0,0 +1,245 @@
1 +use bevy::prelude::*;
2 +
3 +use crate::{
4 + sim::{SimSystems, TICKS_PER_HOUR},
5 + world::{TILE_SIZE, TilePos, WorldMap},
6 + worldgen::WorldGenerated,
7 +};
8 +
9 +pub struct StructuresPlugin;
10 +
11 +impl Plugin for StructuresPlugin {
12 + fn build(&self, app: &mut App) {
13 + app.add_systems(Startup, create_structure_assets)
14 + .add_systems(
15 + Update,
16 + clear_structures.run_if(on_message::<WorldGenerated>),
17 + )
18 + .add_systems(FixedUpdate, burn_fuel.in_set(SimSystems::Act));
19 + }
20 +}
21 +
22 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23 +pub enum StructureKind {
24 + Campfire,
25 + Torch,
26 + Bed,
27 +}
28 +
29 +impl StructureKind {
30 + pub fn wood_cost(self) -> u32 {
31 + match self {
32 + StructureKind::Campfire => 10,
33 + StructureKind::Torch => 2,
34 + StructureKind::Bed => 15,
35 + }
36 + }
37 +
38 + pub fn fuel_ticks(self) -> Option<u32> {
39 + let hours = match self {
40 + StructureKind::Campfire => 12,
41 + StructureKind::Torch => 8,
42 + StructureKind::Bed => return None,
43 + };
44 + Some(hours * TICKS_PER_HOUR as u32)
45 + }
46 +
47 + pub fn light(self) -> Option<(f32, Vec3)> {
48 + match self {
49 + StructureKind::Campfire => Some((7.0, Vec3::new(1.1, 0.8, 0.5))),
50 + StructureKind::Torch => Some((4.0, Vec3::new(1.0, 0.8, 0.55))),
51 + StructureKind::Bed => None,
52 + }
53 + }
54 +
55 + pub fn radius(self) -> f32 {
56 + self.light().map_or(0.0, |(radius, _)| radius)
57 + }
58 +
59 + pub fn name(self) -> &'static str {
60 + match self {
61 + StructureKind::Campfire => "campfire",
62 + StructureKind::Torch => "torch",
63 + StructureKind::Bed => "bed",
64 + }
65 + }
66 +}
67 +
68 +#[derive(Component, Debug, Clone, Copy, PartialEq)]
69 +#[require(Transform, Visibility)]
70 +pub struct Structure {
71 + pub kind: StructureKind,
72 +}
73 +
74 +#[derive(Component, Debug, Clone, Copy, PartialEq)]
75 +pub struct Fuel(pub u32);
76 +
77 +fn burn_fuel(mut commands: Commands, mut fuel: Query<(Entity, &mut Fuel)>) {
78 + for (entity, mut fuel) in &mut fuel {
79 + fuel.0 = fuel.0.saturating_sub(1);
80 + if fuel.0 == 0 {
81 + commands.entity(entity).despawn();
82 + }
83 + }
84 +}
85 +
86 +fn clear_structures(mut commands: Commands, structures: Query<Entity, With<Structure>>) {
87 + for entity in &structures {
88 + commands.entity(entity).despawn();
89 + }
90 +}
91 +
92 +type Part = (Handle<Mesh>, Handle<ColorMaterial>);
93 +
94 +#[derive(Resource, Default)]
95 +pub struct StructureAssets {
96 + stone: Part,
97 + log: Part,
98 + flame: Part,
99 + flame_core: Part,
100 + stick: Part,
101 + bed_frame: Part,
102 + blanket: Part,
103 + pillow: Part,
104 +}
105 +
106 +const CAMPFIRE_STONES: usize = 7;
107 +
108 +fn flame_mesh(width: f32, height: f32) -> Mesh {
109 + Triangle2d::new(
110 + Vec2::new(-width / 2.0, -height / 2.0),
111 + Vec2::new(width / 2.0, -height / 2.0),
112 + Vec2::new(0.0, height / 2.0),
113 + )
114 + .into()
115 +}
116 +
117 +fn create_structure_assets(
118 + mut commands: Commands,
119 + mut meshes: ResMut<Assets<Mesh>>,
120 + mut materials: ResMut<Assets<ColorMaterial>>,
121 +) {
122 + let mut part = |mesh: Mesh, color: Color| (meshes.add(mesh), materials.add(color));
123 + commands.insert_resource(StructureAssets {
124 + stone: part(
125 + Circle::new(TILE_SIZE * 0.09).into(),
126 + Color::srgb_u8(120, 116, 110),
127 + ),
128 + log: part(
129 + Rectangle::new(TILE_SIZE * 0.7, TILE_SIZE * 0.14).into(),
130 + Color::srgb_u8(110, 74, 42),
131 + ),
132 + flame: part(
133 + flame_mesh(TILE_SIZE * 0.36, TILE_SIZE * 0.5),
134 + Color::srgb_u8(240, 110, 30),
135 + ),
136 + flame_core: part(
137 + flame_mesh(TILE_SIZE * 0.18, TILE_SIZE * 0.28),
138 + Color::srgb_u8(255, 220, 90),
139 + ),
140 + stick: part(
141 + Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.5).into(),
142 + Color::srgb_u8(100, 70, 40),
143 + ),
144 + bed_frame: part(
145 + Rectangle::new(TILE_SIZE * 0.7, TILE_SIZE * 0.92).into(),
146 + Color::srgb_u8(120, 82, 48),
147 + ),
148 + blanket: part(
149 + Rectangle::new(TILE_SIZE * 0.6, TILE_SIZE * 0.55).into(),
150 + Color::srgb_u8(70, 110, 160),
151 + ),
152 + pillow: part(
153 + Rectangle::new(TILE_SIZE * 0.5, TILE_SIZE * 0.18).into(),
154 + Color::srgb_u8(230, 228, 220),
155 + ),
156 + });
157 +}
158 +
159 +fn part_bundle((mesh, material): &Part, transform: Transform) -> impl Bundle {
160 + (
161 + Mesh2d(mesh.clone()),
162 + MeshMaterial2d(material.clone()),
163 + transform,
164 + )
165 +}
166 +
167 +pub fn spawn_structure(
168 + commands: &mut Commands,
169 + assets: &StructureAssets,
170 + tile: UVec2,
171 + kind: StructureKind,
172 +) {
173 + let mut entity = commands.spawn((
174 + Name::new(format!("{kind:?}")),
175 + Structure { kind },
176 + TilePos(tile),
177 + Transform::from_translation(WorldMap::tile_center(tile).extend(0.8)),
178 + ));
179 + if let Some(fuel) = kind.fuel_ticks() {
180 + entity.insert(Fuel(fuel));
181 + }
182 + let at = |x: f32, y: f32, z: f32| Transform::from_xyz(x * TILE_SIZE, y * TILE_SIZE, z);
183 + match kind {
184 + StructureKind::Campfire => entity.with_children(|c| {
185 + for i in 0..CAMPFIRE_STONES {
186 + let angle = i as f32 / CAMPFIRE_STONES as f32 * std::f32::consts::TAU;
187 + let pos = Vec2::from_angle(angle) * 0.38;
188 + c.spawn(part_bundle(&assets.stone, at(pos.x, pos.y, 0.0)));
189 + }
190 + for angle in [0.6_f32, -0.6] {
191 + let log = at(0.0, -0.08, 0.01).with_rotation(Quat::from_rotation_z(angle));
192 + c.spawn(part_bundle(&assets.log, log));
193 + }
194 + c.spawn(part_bundle(&assets.flame, at(0.0, 0.08, 0.02)));
195 + c.spawn(part_bundle(&assets.flame_core, at(0.0, 0.03, 0.03)));
196 + }),
197 + StructureKind::Bed => entity.with_children(|c| {
198 + c.spawn(part_bundle(&assets.bed_frame, at(0.0, 0.0, 0.0)));
199 + c.spawn(part_bundle(&assets.blanket, at(0.0, -0.13, 0.01)));
200 + c.spawn(part_bundle(&assets.pillow, at(0.0, 0.3, 0.01)));
201 + }),
202 + StructureKind::Torch => entity.with_children(|c| {
203 + c.spawn(part_bundle(&assets.stick, at(0.0, -0.15, 0.0)));
204 + let small = Vec3::splat(0.6);
205 + c.spawn(part_bundle(
206 + &assets.flame,
207 + at(0.0, 0.18, 0.01).with_scale(small),
208 + ));
209 + c.spawn(part_bundle(
210 + &assets.flame_core,
211 + at(0.0, 0.15, 0.02).with_scale(small),
212 + ));
213 + }),
214 + };
215 +}
216 +
217 +#[cfg(test)]
218 +mod tests {
219 + use super::*;
220 +
221 + #[test]
222 + fn fires_burn_out() {
223 + let mut app = App::new();
224 + app.add_systems(Update, burn_fuel);
225 + let fire = app
226 + .world_mut()
227 + .spawn((
228 + Structure {
229 + kind: StructureKind::Torch,
230 + },
231 + Fuel(3),
232 + ))
233 + .id();
234 + for _ in 0..3 {
235 + app.update();
236 + }
237 + assert!(app.world().get_entity(fire).is_err());
238 + }
239 +
240 + #[test]
241 + fn beds_are_permanent() {
242 + assert_eq!(StructureKind::Bed.fuel_ticks(), None);
243 + assert!(StructureKind::Campfire.fuel_ticks().is_some());
244 + }
245 +}

src/tilemap.rs +4 -1

@@ -372,7 +372,10 @@ mod tests {
372 372 #[test]
373 373 fn darkness_tints_tiles_and_fires_warm_them() {
374 374 let map = WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Grass);
375 - let fire = (UVec2::new(10, 10), crate::light::StructureKind::Campfire);
375 + let fire = (
376 + UVec2::new(10, 10),
377 + crate::structures::StructureKind::Campfire,
378 + );
376 379 let night = LightMap::compute(map.size, 0.0, [fire]);
377 380 let tiles = chunk_tiles(&map, &night, UVec2::ZERO);
378 381 let lum = |pos: UVec2| {

tasks.md +2 -1

@@ -19,12 +19,13 @@
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 21 - [x] Sleep: rest need, bedtime/naps/exhaustion, colonists sleep by the campfire until morning
22 +- [x] Beds: built autonomously (one per colonist, 15 wood), preferred for sleep, faster rest
22 23 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
23 24
24 25 ## Planned
25 26 - [ ] Jobs: building (walls, floors; consumes materials)
26 27 - [ ] Refuelling fires instead of rebuilding; manual build orders for fires
27 -- [ ] Beds/shelter (sleep spots are just ground near the campfire); more needs (food)
28 +- [ ] Shelter/rooms (walls, roofs); bed ownership; more needs (food)
28 29 - [ ] Stockpile filters (which item kinds a zone accepts) and storage priority
29 30 - [ ] 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)
30 31 - [ ] UI to edit colony goals (material targets)