Add day/night cycle with lighting and colonist-built fires

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

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 10:15 UTC
commit
46b5569b4f22afba5f3c09ae8bd636d8e004ce77
parent
2402637c30
tree
browse at this commit

9 files changed +833 -47

CLAUDE.md +3 -2
src/jobs.rs +351 -18
src/light.rs added +344 -0
src/main.rs +2 -0
src/selection.rs +66 -8
src/sim.rs +11 -0
src/tilemap.rs +52 -17
src/zones.rs +0 -1
tasks.md +4 -1

CLAUDE.md +3 -2

@@ -19,13 +19,14 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
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`. Two layers of chunks (`Layer::Terrain` z=0, `Layer::Plants` z=0.5, alpha blend, tree texture drawn in code), spawned once at Startup (`Chunk{origin, layer}`); terrain tint = jitter × `hillshade` (light from NW, `RELIEF` exaggeration) × height brightness, water unshaded; ore = stone texture with coloured specks; world changes overwrite `TilemapChunkTileData` in place (full on WorldGenerated, per-chunk on `TileChanged`) (bevy's `update_tilemap_chunk_indices` re-uploads via change detection).
21 21 - `src/camera.rs` `CameraController` on Camera2d: WASD/arrows pan (shift = fast, speed scaled by ortho scale), middle/right drag pan (from cursor delta, not AccumulatedMouseMotion — that doesn't fire for web), scroll zoom toward cursor (`zoom_towards`), camera center clamped to world rect.
22 -- `src/selection.rs` (`update_cursor` is pub; other modules order `.after(update_cursor)`) `Cursor` resource (screen/world/tile) and `Selection` enum (None/Tile/Colonist(Entity)). PostUpdate after `TransformSystems::Propagate` (fresh camera GlobalTransform). LMB: pick nearest colonist within `PICK_RADIUS` of cursor world pos, else tile, off-map clears; Esc clears. RMB click (press/release within `CLICK_SLOP_PX`, else it was a drag-pan) writes `MoveOrder` for selected colonist. Gizmos: hover rect, selected rect / colonist circle + remaining path linestrip. Top-left UI text (clock + speed/PAUSED, seed, per-material stock/goal, zone tool hint, hover incl. stockpile/items, selection incl. job/carrying). Progress bars for Work/ExpandStockpile (`progress_fraction`). Chop progress bars (gizmo lines) above working colonists; selected colonist's target tree outlined. Tests use headless `App` + `ButtonInput`/`Cursor` resources, `Messages<MoveOrder>::drain`.
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}}`. `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`) → `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, 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).
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, 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).
29 30 - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep).
30 31
31 32 ## Gotchas

src/jobs.rs +351 -18

@@ -12,12 +12,16 @@ 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 19 pathfinding::{find_path, find_path_adjacent, is_adjacent, nearest_tile},
16 20 rng::SimRng,
17 - sim::SimSystems,
21 + sim::{SimSystems, SimTick},
18 22 world::{Plant, Terrain, TileChanged, TilePos, WorldMap},
19 23 worldgen::WorldGenerated,
20 - zones::{Zones, find_stockpile_site, new_stockpile_tiles, plan_expansion},
24 + zones::{Zones, can_zone, find_stockpile_site, new_stockpile_tiles, plan_expansion},
21 25 };
22 26
23 27 pub struct JobsPlugin;
@@ -41,7 +45,11 @@ impl Plugin for JobsPlugin {
41 45 }
42 46 }
43 47
44 -pub const EXPAND_TICKS: u32 = 120;
48 +pub const EXPAND_WORK: u32 = 240;
49 +pub const CAMPFIRE_MIN_FUEL: u32 = 3 * crate::sim::TICKS_PER_HOUR as u32;
50 +pub const HOME_RADIUS: f32 = 10.0;
51 +const LIT_WORK_RATE: u32 = 2;
52 +const DARK_WORK_RATE: u32 = 1;
45 53 pub const EXPANSION_TILES: usize = 8;
46 54 pub const MIN_EMPTY_STOCKPILE_TILES: usize = 3;
47 55 const SEARCH_TILES: usize = 15000;
@@ -85,6 +93,27 @@ pub enum Task {
85 93 site: UVec2,
86 94 progress: u32,
87 95 },
96 + Build {
97 + site: UVec2,
98 + kind: StructureKind,
99 + pickup: Option<UVec2>,
100 + progress: u32,
101 + },
102 +}
103 +
104 +pub fn build_work(kind: StructureKind) -> u32 {
105 + match kind {
106 + StructureKind::Campfire => 360,
107 + StructureKind::Torch => 120,
108 + }
109 +}
110 +
111 +pub fn work_rate(light: &LightMap, tile: UVec2) -> u32 {
112 + if light.is_lit(tile) {
113 + LIT_WORK_RATE
114 + } else {
115 + DARK_WORK_RATE
116 + }
88 117 }
89 118
90 119 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -94,10 +123,10 @@ pub enum WorkKind {
94 123 }
95 124
96 125 impl WorkKind {
97 - pub fn ticks(self) -> u32 {
126 + pub fn work(self) -> u32 {
98 127 match self {
99 - WorkKind::Chop => 240,
100 - WorkKind::Mine => 360,
128 + WorkKind::Chop => 480,
129 + WorkKind::Mine => 720,
101 130 }
102 131 }
103 132
@@ -281,6 +310,12 @@ fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) {
281 310 reservations.reserve(Target::Storage(to), by);
282 311 }
283 312 Task::ExpandStockpile { .. } => reservations.reserve(Target::StockpileExpansion, by),
313 + Task::Build { site, pickup, .. } => {
314 + reservations.reserve(Target::Site(site), by);
315 + if let Some(pickup) = pickup {
316 + reservations.reserve(Target::Pickup(pickup), by);
317 + }
318 + }
284 319 }
285 320 }
286 321
@@ -385,6 +420,107 @@ fn central_item_tile(items: &[(Item, TilePos)]) -> Option<UVec2> {
385 420 })
386 421 }
387 422
423 +pub struct LightingContext<'a> {
424 + pub map: &'a WorldMap,
425 + pub zones: &'a Zones,
426 + pub reservations: &'a Reservations,
427 + pub items: &'a [(Item, TilePos)],
428 + pub fires: &'a [(UVec2, StructureKind, u32)],
429 + pub planned: &'a [(UVec2, StructureKind)],
430 +}
431 +
432 +impl LightingContext<'_> {
433 + fn buildable(&self, tile: UVec2) -> bool {
434 + can_zone(self.map, tile.as_ivec2())
435 + && !self.zones.is_stockpile(tile)
436 + && self.map.plant(tile.as_ivec2()).is_none()
437 + && !self.fires.iter().any(|(pos, ..)| *pos == tile)
438 + && !self.items.iter().any(|(_, pos)| pos.0 == tile)
439 + && self.reservations.is_free(Target::Site(tile))
440 + }
441 +
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 + fn lights(&self) -> impl Iterator<Item = (UVec2, StructureKind)> + Clone + '_ {
460 + self.fires
461 + .iter()
462 + .map(|&(pos, kind, _)| (pos, kind))
463 + .chain(self.planned.iter().copied())
464 + }
465 +
466 + fn campfire_needed(&self, home: UVec2) -> bool {
467 + let burning = self.fires.iter().any(|&(pos, kind, fuel)| {
468 + kind == StructureKind::Campfire
469 + && fuel >= CAMPFIRE_MIN_FUEL
470 + && pos.as_vec2().distance(home.as_vec2()) <= HOME_RADIUS
471 + });
472 + let planned = self
473 + .planned
474 + .iter()
475 + .any(|(_, kind)| *kind == StructureKind::Campfire);
476 + !burning && !planned
477 + }
478 +
479 + fn darkest_stockpile_tile(&self, home: UVec2) -> Option<UVec2> {
480 + self.zones
481 + .stockpile_tiles()
482 + .filter(|&t| brightness(light_from(t, self.lights())) < LIT_BRIGHTNESS)
483 + .min_by(|a, b| {
484 + a.as_vec2()
485 + .distance_squared(home.as_vec2())
486 + .total_cmp(&b.as_vec2().distance_squared(home.as_vec2()))
487 + .then((a.y, a.x).cmp(&(b.y, b.x)))
488 + })
489 + }
490 +
491 + fn site_near(&self, target: UVec2, max_distance: f32) -> Option<UVec2> {
492 + nearest_tile(self.map, target, SEARCH_TILES, |p| self.buildable(p))
493 + .filter(|site| site.as_vec2().distance(target.as_vec2()) <= max_distance)
494 + }
495 +
496 + fn wood_source(&self, from: UVec2, amount: u32) -> Option<UVec2> {
497 + nearest_tile(self.map, from, SEARCH_TILES, |p| {
498 + self.reservations.is_free(Target::Pickup(p))
499 + && self.items.iter().any(|(item, pos)| {
500 + pos.0 == p && item.kind == ItemKind::Wood && item.count >= amount
501 + })
502 + })
503 + }
504 +}
505 +
506 +pub fn find_lighting(ctx: &LightingContext, from: UVec2) -> Option<Task> {
507 + let home = ctx.home(from);
508 + let (kind, site) = if ctx.campfire_needed(home) {
509 + (StructureKind::Campfire, ctx.site_near(home, HOME_RADIUS)?)
510 + } else {
511 + let dark = ctx.darkest_stockpile_tile(home)?;
512 + let reach = StructureKind::Torch.radius() * 0.6;
513 + (StructureKind::Torch, ctx.site_near(dark, reach)?)
514 + };
515 + let pickup = ctx.wood_source(from, kind.wood_cost())?;
516 + Some(Task::Build {
517 + site,
518 + kind,
519 + pickup: Some(pickup),
520 + progress: 0,
521 + })
522 +}
523 +
388 524 fn on_the_way(map: &WorldMap, jobs: impl Iterator<Item = Option<Job>>) -> HashMap<ItemKind, u32> {
389 525 let mut totals = HashMap::new();
390 526 for job in jobs.flatten() {
@@ -405,9 +541,11 @@ fn choose_jobs(
405 541 zones: Res<Zones>,
406 542 goals: Res<ColonyGoals>,
407 543 item_assets: Res<ItemAssets>,
544 + tick: Res<SimTick>,
408 545 mut rng: ResMut<SimRng>,
409 546 mut reservations: ResMut<Reservations>,
410 547 mut items: ItemQuery,
548 + structures: Query<(&TilePos, &Structure), Without<Colonist>>,
411 549 mut colonists: Query<
412 550 (
413 551 Entity,
@@ -420,6 +558,18 @@ fn choose_jobs(
420 558 With<Colonist>,
421 559 >,
422 560 ) {
561 + let fires: Vec<(UVec2, StructureKind, u32)> = structures
562 + .iter()
563 + .map(|(pos, s)| (pos.0, s.kind, s.fuel))
564 + .collect();
565 + let mut planned: Vec<(UVec2, StructureKind)> = colonists
566 + .iter()
567 + .filter_map(|c| match c.3.0?.task {
568 + Task::Build { site, kind, .. } => Some((site, kind)),
569 + _ => None,
570 + })
571 + .collect();
572 + let dark = dark_soon(*tick);
423 573 let carried: Vec<Item> = colonists.iter().filter_map(|c| c.5.0).collect();
424 574 let stocks: HashMap<ItemKind, u32> = ItemKind::ALL
425 575 .iter()
@@ -458,7 +608,16 @@ fn choose_jobs(
458 608 }
459 609 storage.map(|to| Task::Haul { pickup: None, to })
460 610 } else {
611 + let lighting = LightingContext {
612 + map: &map,
613 + zones: &zones,
614 + reservations: &reservations,
615 + items: &snapshot,
616 + fires: &fires,
617 + planned: &planned,
618 + };
461 619 find_expansion(&map, &zones, &reservations, &snapshot)
620 + .or_else(|| dark.then(|| find_lighting(&lighting, tile.0)).flatten())
462 621 .or_else(|| find_work(&map, &reservations, tile.0, wanted))
463 622 .or_else(|| find_haul(&map, &zones, &reservations, &snapshot, tile.0))
464 623 };
@@ -468,6 +627,9 @@ fn choose_jobs(
468 627 {
469 628 *pending.entry(work.item).or_insert(0) += work.count;
470 629 }
630 + if let Task::Build { site, kind, .. } = task {
631 + planned.push((site, kind));
632 + }
471 633 reserve_task(&mut reservations, task, entity);
472 634 job.0 = Some(Job {
473 635 task,
@@ -512,16 +674,22 @@ fn run_jobs(
512 674 mut reservations: ResMut<Reservations>,
513 675 mut rng: ResMut<SimRng>,
514 676 item_assets: Res<ItemAssets>,
677 + structure_assets: Res<StructureAssets>,
678 + light: Res<LightMap>,
515 679 mut items: ItemQuery,
680 + structures: Query<&TilePos, (With<Structure>, Without<Colonist>)>,
516 681 mut changed: MessageWriter<TileChanged>,
517 - mut colonists: Query<(
518 - Entity,
519 - &TilePos,
520 - &mut Path,
521 - &mut CurrentJob,
522 - &mut IdleTimer,
523 - &mut Carrying,
524 - )>,
682 + mut colonists: Query<
683 + (
684 + Entity,
685 + &TilePos,
686 + &mut Path,
687 + &mut CurrentJob,
688 + &mut IdleTimer,
689 + &mut Carrying,
690 + ),
691 + With<Colonist>,
692 + >,
525 693 ) {
526 694 for (entity, tile, mut path, mut current, mut idle, mut carrying) in &mut colonists {
527 695 let Some(mut job) = current.0 else {
@@ -551,8 +719,8 @@ fn run_jobs(
551 719 _ => Step::Done,
552 720 }
553 721 } else {
554 - *progress += 1;
555 - if *progress < kind.ticks() {
722 + *progress += work_rate(&light, tile.0);
723 + if *progress < kind.work() {
556 724 Step::Continue
557 725 } else {
558 726 kind.complete(&mut map, site);
@@ -576,8 +744,8 @@ fn run_jobs(
576 744 if tile.0 != *site {
577 745 walk_to(&map, tile.0, *site, &mut path)
578 746 } else {
579 - *progress += 1;
580 - if *progress >= EXPAND_TICKS {
747 + *progress += work_rate(&light, tile.0);
748 + if *progress >= EXPAND_WORK {
581 749 if zones.has_stockpile() {
582 750 let tiles = plan_expansion(&map, &zones, EXPANSION_TILES);
583 751 zones.add_tiles(&map, tiles);
@@ -590,6 +758,66 @@ fn run_jobs(
590 758 }
591 759 }
592 760 }
761 + Task::Build {
762 + site,
763 + kind,
764 + pickup,
765 + progress,
766 + } => {
767 + let (site, kind) = (*site, *kind);
768 + let ours = reservations.holder(Target::Site(site)) == Some(entity);
769 + let blocked = map.plant(site.as_ivec2()).is_some()
770 + || structures.iter().any(|pos| pos.0 == site);
771 + let cost = kind.wood_cost();
772 + if !ours || blocked {
773 + Step::Done
774 + } else if let Some(from) = *pickup {
775 + if reservations.holder(Target::Pickup(from)) != Some(entity) {
776 + Step::Done
777 + } else if tile.0 != from {
778 + walk_to(&map, tile.0, from, &mut path)
779 + } else {
780 + let load = take_items(&mut commands, &mut items, from, cost)
781 + .filter(|load| load.kind == ItemKind::Wood && load.count > 0);
782 + carrying.0 = load;
783 + match load {
784 + Some(load) if load.count >= cost => {
785 + reservations.release(Target::Pickup(from));
786 + *pickup = None;
787 + Step::Continue
788 + }
789 + _ => Step::Done,
790 + }
791 + }
792 + } else if !carrying
793 + .0
794 + .is_some_and(|load| load.kind == ItemKind::Wood && load.count >= cost)
795 + {
796 + Step::Done
797 + } else if !is_adjacent(tile.0.as_ivec2(), site.as_ivec2()) {
798 + match find_path_adjacent(&map, tile.0, site) {
799 + Some(p) if !p.is_empty() => {
800 + path.0 = p.into();
801 + Step::Continue
802 + }
803 + _ => Step::Done,
804 + }
805 + } else {
806 + *progress += work_rate(&light, tile.0);
807 + if *progress < build_work(kind) {
808 + Step::Continue
809 + } else {
810 + carrying.0 = carrying.0.and_then(|load| {
811 + (load.count > cost).then_some(Item {
812 + count: load.count - cost,
813 + ..load
814 + })
815 + });
816 + spawn_structure(&mut commands, &structure_assets, site, kind);
817 + Step::Done
818 + }
819 + }
820 + }
593 821 Task::Haul { pickup, to } => {
594 822 let to = *to;
595 823 let destination_valid = zones.is_stockpile(to)
@@ -691,6 +919,9 @@ mod tests {
691 919 .init_resource::<Reservations>()
692 920 .init_resource::<ColonyGoals>()
693 921 .init_resource::<ItemAssets>()
922 + .init_resource::<StructureAssets>()
923 + .init_resource::<LightMap>()
924 + .init_resource::<SimTick>()
694 925 .init_resource::<Zones>()
695 926 .add_message::<Order>()
696 927 .add_message::<TileChanged>()
@@ -1088,6 +1319,108 @@ mod tests {
1088 1319 assert_eq!(stockpile_size(&app), 4);
1089 1320 }
1090 1321
1322 + fn set_hour(app: &mut App, hour: u64) {
1323 + let ticks = (hour + 24 - 6) % 24 * crate::sim::TICKS_PER_HOUR;
1324 + *app.world_mut().resource_mut::<SimTick>() = SimTick(ticks);
1325 + }
1326 +
1327 + fn structures(app: &mut App) -> Vec<(UVec2, StructureKind)> {
1328 + let world = app.world_mut();
1329 + world
1330 + .query::<(&TilePos, &Structure)>()
1331 + .iter(world)
1332 + .map(|(pos, s)| (pos.0, s.kind))
1333 + .collect()
1334 + }
1335 +
1336 + fn put_campfire(app: &mut App, tile: UVec2) {
1337 + app.world_mut().spawn((
1338 + Structure {
1339 + kind: StructureKind::Campfire,
1340 + fuel: StructureKind::Campfire.fuel_ticks(),
1341 + },
1342 + TilePos(tile),
1343 + ));
1344 + }
1345 +
1346 + #[test]
1347 + fn colonists_light_a_campfire_near_the_stockpile_when_night_falls() {
1348 + let mut app = hauling_app((IVec2::new(10, 10), IVec2::new(12, 12)));
1349 + put_wood(&mut app, UVec2::new(10, 10), 20);
1350 + set_hour(&mut app, 18);
1351 + spawn_colonist(&mut app, UVec2::new(2, 5));
1352 + run_ticks(&mut app, 60 * 20);
1353 + let built = structures(&mut app);
1354 + let campfire = built
1355 + .iter()
1356 + .find(|(_, kind)| *kind == StructureKind::Campfire)
1357 + .expect("a campfire");
1358 + assert!(campfire.0.as_vec2().distance(Vec2::new(11.0, 11.0)) <= HOME_RADIUS);
1359 + assert_eq!(wood_on_map(&mut app), 10);
1360 + }
1361 +
1362 + #[test]
1363 + fn no_fires_are_built_during_the_day() {
1364 + let mut app = hauling_app((IVec2::new(10, 10), IVec2::new(12, 12)));
1365 + put_wood(&mut app, UVec2::new(10, 10), 20);
1366 + set_hour(&mut app, 10);
1367 + spawn_colonist(&mut app, UVec2::new(2, 5));
1368 + run_ticks(&mut app, 60 * 10);
1369 + assert!(structures(&mut app).is_empty());
1370 + }
1371 +
1372 + #[test]
1373 + fn without_wood_nothing_can_be_built() {
1374 + let mut app = hauling_app((IVec2::new(10, 10), IVec2::new(12, 12)));
1375 + set_hour(&mut app, 22);
1376 + spawn_colonist(&mut app, UVec2::new(2, 5));
1377 + run_ticks(&mut app, 60 * 10);
1378 + assert!(structures(&mut app).is_empty());
1379 + }
1380 +
1381 + #[test]
1382 + fn torches_light_stockpile_tiles_beyond_the_campfire() {
1383 + let mut app = hauling_app((IVec2::new(2, 10), IVec2::new(17, 10)));
1384 + put_wood(&mut app, UVec2::new(9, 10), 20);
1385 + put_campfire(&mut app, UVec2::new(9, 12));
1386 + set_hour(&mut app, 22);
1387 + spawn_colonist(&mut app, UVec2::new(9, 8));
1388 + run_ticks(&mut app, 60 * 15);
1389 + assert!(
1390 + structures(&mut app)
1391 + .iter()
1392 + .any(|(_, kind)| *kind == StructureKind::Torch)
1393 + );
1394 + }
1395 +
1396 + // Light is what makes fires worth building: work in darkness goes at half speed.
1397 + #[test]
1398 + fn work_is_slower_in_the_dark() {
1399 + let tree = UVec2::new(8, 5);
1400 + let finish_ticks = |daylight: f32| {
1401 + let mut app = test_app(map_with_trees(&[tree]));
1402 + let size = app.world().resource::<WorldMap>().size;
1403 + app.insert_resource(LightMap::compute(size, daylight, []));
1404 + spawn_colonist(&mut app, UVec2::new(7, 5));
1405 + let mut ticks = 0;
1406 + while app
1407 + .world()
1408 + .resource::<WorldMap>()
1409 + .plant(tree.as_ivec2())
1410 + .is_some()
1411 + {
1412 + run_ticks(&mut app, 1);
1413 + ticks += 1;
1414 + }
1415 + ticks
1416 + };
1417 + let (lit, dark) = (finish_ticks(1.0), finish_ticks(0.0));
1418 + assert!(
1419 + dark > lit + WorkKind::Chop.work() / 2 - 10,
1420 + "lit {lit}, dark {dark}"
1421 + );
1422 + }
1423 +
1091 1424 #[test]
1092 1425 fn colonists_chop_from_a_neighbouring_tile() {
1093 1426 let tree = UVec2::new(8, 5);

src/light.rs +344 -0

@@ -0,0 +1,344 @@
1 +use bevy::prelude::*;
2 +
3 +use crate::{
4 + sim::{SimSystems, SimTick, TICKS_PER_HOUR, hour_of_day},
5 + world::{TILE_SIZE, TilePos, WorldMap},
6 + worldgen::WorldGenerated,
7 +};
8 +
9 +pub struct LightPlugin;
10 +
11 +impl Plugin for LightPlugin {
12 + fn build(&self, app: &mut App) {
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 + );
26 + }
27 +}
28 +
29 +pub const LIT_BRIGHTNESS: f32 = 0.5;
30 +pub const LIGHT_LOOKAHEAD_TICKS: u64 = TICKS_PER_HOUR * 3 / 2;
31 +const AMBIENT_STEPS: f32 = 16.0;
32 +const DAY_COLOR: Vec3 = Vec3::ONE;
33 +const NIGHT_COLOR: Vec3 = Vec3::new(0.2, 0.24, 0.4);
34 +
35 +pub fn daylight(hour: f32) -> f32 {
36 + match hour {
37 + h if h < 5.0 => 0.0,
38 + h if h < 7.0 => (h - 5.0) / 2.0,
39 + h if h < 18.0 => 1.0,
40 + h if h < 20.0 => 1.0 - (h - 18.0) / 2.0,
41 + _ => 0.0,
42 + }
43 +}
44 +
45 +pub fn ambient_color(daylight: f32) -> Vec3 {
46 + NIGHT_COLOR.lerp(DAY_COLOR, daylight)
47 +}
48 +
49 +pub fn brightness(color: Vec3) -> f32 {
50 + (color.x + color.y + color.z) / 3.0
51 +}
52 +
53 +pub fn dark_soon(tick: SimTick) -> bool {
54 + let later = SimTick(tick.0 + LIGHT_LOOKAHEAD_TICKS);
55 + daylight(hour_of_day(tick)) < LIT_BRIGHTNESS || daylight(hour_of_day(later)) < LIT_BRIGHTNESS
56 +}
57 +
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)
100 + }
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,
108 +}
109 +
110 +pub fn light_from(tile: UVec2, sources: impl IntoIterator<Item = (UVec2, StructureKind)>) -> Vec3 {
111 + sources
112 + .into_iter()
113 + .map(|(pos, kind)| kind.light_at(pos, tile))
114 + .sum()
115 +}
116 +
117 +#[derive(Resource, Default, Debug, Clone, PartialEq)]
118 +pub struct LightMap {
119 + size: UVec2,
120 + ambient_step: u32,
121 + ambient: Vec3,
122 + sources: Vec<Vec3>,
123 +}
124 +
125 +impl LightMap {
126 + pub fn color(&self, tile: UVec2) -> Vec3 {
127 + if self.sources.is_empty() || tile.x >= self.size.x || tile.y >= self.size.y {
128 + return Vec3::ONE;
129 + }
130 + (self.ambient + self.sources[(tile.y * self.size.x + tile.x) as usize]).min(Vec3::ONE)
131 + }
132 +
133 + pub fn is_lit(&self, tile: UVec2) -> bool {
134 + brightness(self.color(tile)) >= LIT_BRIGHTNESS
135 + }
136 +
137 + pub fn compute(
138 + size: UVec2,
139 + daylight: f32,
140 + sources: impl IntoIterator<Item = (UVec2, StructureKind)>,
141 + ) -> Self {
142 + let ambient_step = (daylight * AMBIENT_STEPS).round() as u32;
143 + let mut light = vec![Vec3::ZERO; size.element_product() as usize];
144 + for (pos, kind) in sources {
145 + let r = kind.radius().ceil() as i32;
146 + for dy in -r..=r {
147 + for dx in -r..=r {
148 + let tile = pos.as_ivec2() + IVec2::new(dx, dy);
149 + if tile.x < 0
150 + || tile.y < 0
151 + || tile.x >= size.x as i32
152 + || tile.y >= size.y as i32
153 + {
154 + continue;
155 + }
156 + let tile = tile.as_uvec2();
157 + light[(tile.y * size.x + tile.x) as usize] += kind.light_at(pos, tile);
158 + }
159 + }
160 + }
161 + Self {
162 + size,
163 + ambient_step,
164 + ambient: ambient_color(ambient_step as f32 / AMBIENT_STEPS),
165 + sources: light,
166 + }
167 + }
168 +}
169 +
170 +fn update_light_map(
171 + tick: Res<SimTick>,
172 + map: Res<WorldMap>,
173 + structures: Query<(&TilePos, &Structure)>,
174 + added: Query<(), Added<Structure>>,
175 + mut removed: RemovedComponents<Structure>,
176 + mut light: ResMut<LightMap>,
177 +) {
178 + let day = daylight(hour_of_day(*tick));
179 + let step = (day * AMBIENT_STEPS).round() as u32;
180 + let sources_changed = !added.is_empty() || removed.read().count() > 0;
181 + if !sources_changed && step == light.ambient_step && light.size == map.size {
182 + return;
183 + }
184 + *light = LightMap::compute(
185 + map.size,
186 + day,
187 + structures.iter().map(|(pos, s)| (pos.0, s.kind)),
188 + );
189 +}
190 +
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 +#[derive(Resource, Default)]
207 +pub struct StructureAssets {
208 + ring: (Handle<Mesh>, Handle<ColorMaterial>),
209 + flame: (Handle<Mesh>, Handle<ColorMaterial>),
210 + stick: (Handle<Mesh>, Handle<ColorMaterial>),
211 + torch_flame: (Handle<Mesh>, Handle<ColorMaterial>),
212 +}
213 +
214 +fn create_structure_assets(
215 + mut commands: Commands,
216 + mut meshes: ResMut<Assets<Mesh>>,
217 + mut materials: ResMut<Assets<ColorMaterial>>,
218 +) {
219 + let mut part = |mesh: Mesh, color: Color| (meshes.add(mesh), materials.add(color));
220 + commands.insert_resource(StructureAssets {
221 + ring: part(
222 + Circle::new(TILE_SIZE * 0.45).into(),
223 + Color::srgb_u8(90, 86, 80),
224 + ),
225 + flame: part(
226 + Circle::new(TILE_SIZE * 0.28).into(),
227 + Color::srgb_u8(255, 150, 40),
228 + ),
229 + stick: part(
230 + Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.5).into(),
231 + Color::srgb_u8(100, 70, 40),
232 + ),
233 + torch_flame: part(
234 + Circle::new(TILE_SIZE * 0.16).into(),
235 + Color::srgb_u8(255, 200, 70),
236 + ),
237 + });
238 +}
239 +
240 +pub fn spawn_structure(
241 + commands: &mut Commands,
242 + assets: &StructureAssets,
243 + tile: UVec2,
244 + kind: StructureKind,
245 +) {
246 + let part = |(mesh, material): &(Handle<Mesh>, Handle<ColorMaterial>), offset: Vec3| {
247 + (
248 + Mesh2d(mesh.clone()),
249 + MeshMaterial2d(material.clone()),
250 + Transform::from_translation(offset),
251 + )
252 + };
253 + let mut entity = commands.spawn((
254 + Name::new(format!("{kind:?}")),
255 + Structure {
256 + kind,
257 + fuel: kind.fuel_ticks(),
258 + },
259 + TilePos(tile),
260 + Transform::from_translation(WorldMap::tile_center(tile).extend(0.8)),
261 + ));
262 + match kind {
263 + StructureKind::Campfire => entity.with_children(|c| {
264 + c.spawn(part(&assets.ring, Vec3::ZERO));
265 + c.spawn(part(&assets.flame, Vec3::Z * 0.01));
266 + }),
267 + StructureKind::Torch => entity.with_children(|c| {
268 + c.spawn(part(&assets.stick, Vec3::new(0.0, -TILE_SIZE * 0.1, 0.0)));
269 + c.spawn(part(
270 + &assets.torch_flame,
271 + Vec3::new(0.0, TILE_SIZE * 0.2, 0.01),
272 + ));
273 + }),
274 + };
275 +}
276 +
277 +#[cfg(test)]
278 +mod tests {
279 + use super::*;
280 + use crate::sim::TICKS_PER_HOUR;
281 +
282 + #[test]
283 + fn days_are_bright_and_nights_dark_with_gradual_dusk_and_dawn() {
284 + assert_eq!(daylight(12.0), 1.0);
285 + assert_eq!(daylight(23.0), 0.0);
286 + assert_eq!(daylight(2.0), 0.0);
287 + assert_eq!(daylight(19.0), 0.5);
288 + assert_eq!(daylight(6.0), 0.5);
289 + }
290 +
291 + // Building a fire takes time, so colonists start before it actually gets dark.
292 + #[test]
293 + fn darkness_is_anticipated_before_dusk() {
294 + let at = |hour: u64| SimTick((hour + 24 - 6) % 24 * TICKS_PER_HOUR);
295 + assert!(!dark_soon(at(12)));
296 + assert!(dark_soon(at(18)));
297 + assert!(dark_soon(at(23)));
298 + assert!(!dark_soon(at(8)));
299 + }
300 +
301 + #[test]
302 + fn fires_light_their_surroundings_with_falloff() {
303 + let fire = UVec2::new(10, 10);
304 + let near = StructureKind::Campfire.light_at(fire, UVec2::new(11, 10));
305 + let far = StructureKind::Campfire.light_at(fire, UVec2::new(15, 10));
306 + assert!(brightness(near) > brightness(far));
307 + assert_eq!(
308 + StructureKind::Campfire.light_at(fire, UVec2::new(17, 10)),
309 + Vec3::ZERO
310 + );
311 + }
312 +
313 + #[test]
314 + fn light_map_combines_ambient_and_fires() {
315 + let fire = (UVec2::new(5, 5), StructureKind::Campfire);
316 + let night = LightMap::compute(UVec2::splat(20), 0.0, [fire]);
317 + assert!(night.is_lit(UVec2::new(5, 6)));
318 + assert!(!night.is_lit(UVec2::new(18, 18)));
319 + let day = LightMap::compute(UVec2::splat(20), 1.0, []);
320 + assert!(day.is_lit(UVec2::new(18, 18)));
321 + }
322 +
323 + #[test]
324 + fn an_empty_light_map_counts_as_daylight() {
325 + assert!(LightMap::default().is_lit(UVec2::new(3, 3)));
326 + }
327 +
328 + #[test]
329 + fn structures_burn_out() {
330 + let mut app = App::new();
331 + app.add_systems(Update, burn_fuel);
332 + let fire = app
333 + .world_mut()
334 + .spawn(Structure {
335 + kind: StructureKind::Torch,
336 + fuel: 3,
337 + })
338 + .id();
339 + for _ in 0..3 {
340 + app.update();
341 + }
342 + assert!(app.world().get_entity(fire).is_err());
343 + }
344 +}

src/main.rs +2 -0

@@ -3,6 +3,7 @@ mod camera;
3 3 mod colonist;
4 4 mod items;
5 5 mod jobs;
6 +mod light;
6 7 mod noise;
7 8 mod pathfinding;
8 9 mod rng;
@@ -41,6 +42,7 @@ fn main() {
41 42 colonist::ColonistPlugin,
42 43 items::ItemsPlugin,
43 44 jobs::JobsPlugin,
45 + light::LightPlugin,
44 46 selection::SelectionPlugin,
45 47 zones::ZonesPlugin,
46 48 ui::HudPlugin,

src/selection.rs +66 -8

@@ -5,8 +5,9 @@ use crate::{
5 5 camera::CameraController,
6 6 colonist::{Colonist, Path},
7 7 items::{Carrying, Item, ItemKind, stock},
8 - jobs::{ColonyGoals, CurrentJob, EXPAND_TICKS, Job, Order, Task, WorkKind},
9 - sim::{SimSpeed, SimTick, format_clock},
8 + jobs::{ColonyGoals, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind, build_work},
9 + light::{Structure, StructureKind, daylight},
10 + sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day},
10 11 ui::{HideableUi, PointerOverUi},
11 12 world::{TILE_SIZE, TilePos, WorldMap},
12 13 worldgen::{WorldGenerated, WorldSeed},
@@ -190,7 +191,10 @@ fn draw_highlights(
190 191 return;
191 192 };
192 193 if let Some(Job {
193 - task: Task::Work { site, .. } | Task::ExpandStockpile { site, .. },
194 + task:
195 + Task::Work { site, .. }
196 + | Task::ExpandStockpile { site, .. }
197 + | Task::Build { site, .. },
194 198 ..
195 199 }) = job.0
196 200 {
@@ -231,6 +235,7 @@ fn update_tile_info(
231 235 selection: Res<Selection>,
232 236 colonists: Query<(&Colonist, &TilePos, &Path, &CurrentJob, &Carrying)>,
233 237 items: Query<(&Item, &TilePos)>,
238 + structures: Query<(&Structure, &TilePos)>,
234 239 goals: Res<ColonyGoals>,
235 240 zones: Res<Zones>,
236 241 tool: Res<ZoneTool>,
@@ -250,7 +255,11 @@ fn update_tile_info(
250 255 })
251 256 .unwrap_or_else(|_| "-".into()),
252 257 };
253 - let clock = format_clock(*tick);
258 + let clock = format!(
259 + "{} ({})",
260 + format_clock(*tick),
261 + time_of_day(hour_of_day(*tick))
262 + );
254 263 let speed = describe_speed(*speed, time.is_paused());
255 264 let carried: Vec<Item> = colonists.iter().filter_map(|c| c.4.0).collect();
256 265 let materials = ItemKind::ALL
@@ -273,13 +282,18 @@ fn update_tile_info(
273 282 .filter(|(_, pos)| Some(pos.0) == cursor.tile)
274 283 .map(|(item, _)| format!(", {:?} x{}", item.kind, item.count))
275 284 .collect();
285 + let hovered_structure: String = structures
286 + .iter()
287 + .filter(|(_, pos)| Some(pos.0) == cursor.tile)
288 + .map(|(structure, _)| describe_structure(structure))
289 + .collect();
276 290 let tool_hint = if tool.active {
277 291 "\nSTOCKPILE TOOL: drag to add, shift-drag to remove"
278 292 } else {
279 293 ""
280 294 };
281 295 let info = format!(
282 - "{clock} {speed}\nSeed: {}\n{materials}{tool_hint}\nHover: {}{hovered_zone}{hovered_items}\nSelected: {selected}",
296 + "{clock} {speed}\nSeed: {}\n{materials}{tool_hint}\nHover: {}{hovered_zone}{hovered_items}{hovered_structure}\nSelected: {selected}",
283 297 seed.0,
284 298 describe_tile(&map, cursor.tile)
285 299 );
@@ -310,10 +324,32 @@ pub fn describe_speed(speed: SimSpeed, paused: bool) -> String {
310 324 }
311 325 }
312 326
327 +pub fn time_of_day(hour: f32) -> &'static str {
328 + match daylight(hour) {
329 + 1.0 => "day",
330 + 0.0 => "night",
331 + _ if hour < 12.0 => "dawn",
332 + _ => "dusk",
333 + }
334 +}
335 +
336 +fn structure_name(kind: StructureKind) -> &'static str {
337 + match kind {
338 + StructureKind::Campfire => "campfire",
339 + StructureKind::Torch => "torch",
340 + }
341 +}
342 +
343 +pub fn describe_structure(structure: &Structure) -> String {
344 + let hours = structure.fuel as f32 / TICKS_PER_HOUR as f32;
345 + format!(", {:?} ({hours:.1}h of fuel)", structure.kind)
346 +}
347 +
313 348 pub fn progress_fraction(task: Task) -> Option<f32> {
314 349 match task {
315 - Task::Work { kind, progress, .. } => Some(progress as f32 / kind.ticks() as f32),
316 - Task::ExpandStockpile { progress, .. } => Some(progress as f32 / EXPAND_TICKS as f32),
350 + Task::Work { kind, progress, .. } => Some(progress as f32 / kind.work() as f32),
351 + Task::ExpandStockpile { progress, .. } => Some(progress as f32 / EXPAND_WORK as f32),
352 + Task::Build { kind, progress, .. } => Some(progress as f32 / build_work(kind) as f32),
317 353 _ => None,
318 354 }
319 355 }
@@ -367,6 +403,20 @@ pub fn describe_colonist(
367 403 _,
368 404 ) => "fetching items to haul".to_string(),
369 405 (Some(Task::Haul { pickup: None, .. }), _) => "hauling to the stockpile".to_string(),
406 + (
407 + Some(Task::Build {
408 + kind,
409 + pickup: Some(_),
410 + ..
411 + }),
412 + _,
413 + ) => format!("fetching wood for a {}", structure_name(kind)),
414 + (Some(Task::Build { kind, .. }), n) if n > 0 => {
415 + format!("carrying wood to build a {}", structure_name(kind))
416 + }
417 + (Some(task @ Task::Build { kind, .. }), _) => {
418 + format!("building a {}, {}%", structure_name(kind), percent(task))
419 + }
370 420 };
371 421 let load = carrying
372 422 .map(|item| format!(", carrying {} {:?}", item.count, item.kind))
@@ -542,6 +592,14 @@ mod tests {
542 592 }
543 593
544 594 #[test]
595 + fn time_of_day_labels() {
596 + assert_eq!(time_of_day(12.0), "day");
597 + assert_eq!(time_of_day(6.0), "dawn");
598 + assert_eq!(time_of_day(19.0), "dusk");
599 + assert_eq!(time_of_day(1.0), "night");
600 + }
601 +
602 + #[test]
545 603 fn describe_speed_shows_multiplier_or_paused() {
546 604 assert_eq!(describe_speed(SimSpeed::Fastest, false), "6x");
547 605 assert_eq!(describe_speed(SimSpeed::Fast, true), "PAUSED");
@@ -567,7 +625,7 @@ mod tests {
567 625 task: Task::Work {
568 626 site: UVec2::ZERO,
569 627 kind: WorkKind::Chop,
570 - progress: WorkKind::Chop.ticks() / 2,
628 + progress: WorkKind::Chop.work() / 2,
571 629 },
572 630 ordered: false,
573 631 };

src/sim.rs +11 -0

@@ -105,6 +105,11 @@ fn speed_controls(
105 105 }
106 106 }
107 107
108 +pub fn hour_of_day(tick: SimTick) -> f32 {
109 + let hours = tick.0 as f64 / TICKS_PER_HOUR as f64 + START_HOUR as f64;
110 + (hours % HOURS_PER_DAY as f64) as f32
111 +}
112 +
108 113 pub fn format_clock(tick: SimTick) -> String {
109 114 let hours = tick.0 / TICKS_PER_HOUR + START_HOUR;
110 115 let day = hours / HOURS_PER_DAY + 1;
@@ -123,6 +128,12 @@ mod tests {
123 128 }
124 129
125 130 #[test]
131 + fn hour_of_day_wraps_around_midnight() {
132 + assert_eq!(hour_of_day(SimTick(0)), 6.0);
133 + assert_eq!(hour_of_day(SimTick(TICKS_PER_HOUR * 20)), 2.0);
134 + }
135 +
136 + #[test]
126 137 fn clock_advances_minutes_hours_and_days() {
127 138 assert_eq!(format_clock(SimTick(TICKS_PER_HOUR / 2)), "Day 1, 06:30");
128 139 assert_eq!(format_clock(SimTick(TICKS_PER_HOUR * 18)), "Day 2, 00:00");

src/tilemap.rs +52 -17

@@ -6,6 +6,7 @@ use bevy::{
6 6 };
7 7
8 8 use crate::{
9 + light::LightMap,
9 10 world::{MAX_HEIGHT, Plant, TILE_SIZE, Terrain, TileChanged, WorldMap, hash2},
10 11 worldgen::WorldGenerated,
11 12 zones::Zones,
@@ -25,7 +26,7 @@ impl Plugin for TilemapPlugin {
25 26 app.add_systems(Startup, spawn_chunks).add_systems(
26 27 Update,
27 28 (
28 - refresh_chunks.run_if(on_message::<WorldGenerated>),
29 + refresh_chunks.run_if(world_or_light_changed),
29 30 refresh_changed_chunks,
30 31 refresh_zone_chunks.run_if(resource_changed::<Zones>),
31 32 ),
@@ -49,11 +50,17 @@ impl Layer {
49 50 }
50 51 }
51 52
52 - fn tiles(self, map: &WorldMap, zones: &Zones, origin: UVec2) -> Vec<Option<TileData>> {
53 + fn tiles(
54 + self,
55 + map: &WorldMap,
56 + zones: &Zones,
57 + light: &LightMap,
58 + origin: UVec2,
59 + ) -> Vec<Option<TileData>> {
53 60 match self {
54 - Layer::Terrain => chunk_tiles(map, origin),
61 + Layer::Terrain => chunk_tiles(map, light, origin),
55 62 Layer::Zones => zone_tiles(zones, origin),
56 - Layer::Plants => plant_tiles(map, origin),
63 + Layer::Plants => plant_tiles(map, light, origin),
57 64 }
58 65 }
59 66 }
@@ -68,6 +75,7 @@ fn spawn_chunks(
68 75 mut commands: Commands,
69 76 map: Res<WorldMap>,
70 77 zones: Res<Zones>,
78 + light: Res<LightMap>,
71 79 mut images: ResMut<Assets<Image>>,
72 80 ) {
73 81 let terrain = images.add(stack_tileset(
@@ -96,7 +104,7 @@ fn spawn_chunks(
96 104 tileset: tileset.clone(),
97 105 alpha_mode: AlphaMode2d::Blend,
98 106 },
99 - TilemapChunkTileData(layer.tiles(&map, &zones, origin)),
107 + TilemapChunkTileData(layer.tiles(&map, &zones, &light, origin)),
100 108 Transform::from_translation(center.extend(layer.z())),
101 109 ));
102 110 }
@@ -107,21 +115,30 @@ fn spawn_chunks(
107 115 fn refresh_chunks(
108 116 map: Res<WorldMap>,
109 117 zones: Res<Zones>,
118 + light: Res<LightMap>,
110 119 mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>,
111 120 ) {
112 121 for (chunk, mut data) in &mut chunks {
113 - data.0 = chunk.layer.tiles(&map, &zones, chunk.origin);
122 + data.0 = chunk.layer.tiles(&map, &zones, &light, chunk.origin);
114 123 }
115 124 }
116 125
126 +fn world_or_light_changed(
127 + mut generated: MessageReader<WorldGenerated>,
128 + light: Res<LightMap>,
129 +) -> bool {
130 + generated.read().count() > 0 || light.is_changed()
131 +}
132 +
117 133 fn refresh_zone_chunks(
118 134 map: Res<WorldMap>,
119 135 zones: Res<Zones>,
136 + light: Res<LightMap>,
120 137 mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>,
121 138 ) {
122 139 for (chunk, mut data) in &mut chunks {
123 140 if chunk.layer == Layer::Zones {
124 - data.0 = chunk.layer.tiles(&map, &zones, chunk.origin);
141 + data.0 = chunk.layer.tiles(&map, &zones, &light, chunk.origin);
125 142 }
126 143 }
127 144 }
@@ -129,6 +146,7 @@ fn refresh_zone_chunks(
129 146 fn refresh_changed_chunks(
130 147 map: Res<WorldMap>,
131 148 zones: Res<Zones>,
149 + light: Res<LightMap>,
132 150 mut changes: MessageReader<TileChanged>,
133 151 mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>,
134 152 ) {
@@ -138,7 +156,7 @@ fn refresh_changed_chunks(
138 156 .collect();
139 157 for (chunk, mut data) in &mut chunks {
140 158 if origins.contains(&chunk.origin) {
141 - data.0 = chunk.layer.tiles(&map, &zones, chunk.origin);
159 + data.0 = chunk.layer.tiles(&map, &zones, &light, chunk.origin);
142 160 }
143 161 }
144 162 }
@@ -151,12 +169,12 @@ fn chunk_positions(origin: UVec2) -> impl Iterator<Item = UVec2> {
151 169 (0..CHUNK_SIZE).flat_map(move |y| (0..CHUNK_SIZE).map(move |x| origin + UVec2::new(x, y)))
152 170 }
153 171
154 -pub fn chunk_tiles(map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> {
172 +pub fn chunk_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Option<TileData>> {
155 173 chunk_positions(origin)
156 174 .map(|pos| {
157 175 map.get(pos.as_ivec2()).map(|terrain| TileData {
158 176 tileset_index: terrain.index(),
159 - color: tint(jitter(pos) * shade(map, pos)),
177 + color: tint(jitter(pos) * shade(map, pos), light.color(pos)),
160 178 ..default()
161 179 })
162 180 })
@@ -173,20 +191,20 @@ pub fn zone_tiles(zones: &Zones, origin: UVec2) -> Vec<Option<TileData>> {
173 191 .collect()
174 192 }
175 193
176 -pub fn plant_tiles(map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> {
194 +pub fn plant_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Option<TileData>> {
177 195 chunk_positions(origin)
178 196 .map(|pos| {
179 197 map.plant(pos.as_ivec2()).map(|plant| TileData {
180 198 tileset_index: plant.index(),
181 - color: tint(jitter(pos) * hillshade(map, pos)),
199 + color: tint(jitter(pos) * hillshade(map, pos), light.color(pos)),
182 200 ..default()
183 201 })
184 202 })
185 203 .collect()
186 204 }
187 205
188 -fn tint(v: f32) -> Color {
189 - Color::srgb(v, v, v)
206 +fn tint(v: f32, light: Vec3) -> Color {
207 + Color::srgb(v * light.x, v * light.y, v * light.z)
190 208 }
191 209
192 210 fn jitter(pos: UVec2) -> f32 {
@@ -307,7 +325,7 @@ mod tests {
307 325 #[test]
308 326 fn chunk_tiles_beyond_map_edge_are_empty() {
309 327 let map = WorldMap::filled(UVec2::new(CHUNK_SIZE + 2, 3), Terrain::Stone);
310 - let tiles = chunk_tiles(&map, UVec2::new(CHUNK_SIZE, 0));
328 + let tiles = chunk_tiles(&map, &LightMap::default(), UVec2::new(CHUNK_SIZE, 0));
311 329 assert_eq!(tiles.len(), (CHUNK_SIZE * CHUNK_SIZE) as usize);
312 330 assert!(tiles[0].is_some());
313 331 assert!(tiles[1].is_some());
@@ -323,6 +341,7 @@ mod tests {
323 341 let mut app = App::new();
324 342 app.insert_resource(WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Grass))
325 343 .init_resource::<Zones>()
344 + .init_resource::<LightMap>()
326 345 .add_message::<WorldGenerated>()
327 346 .add_systems(Update, refresh_chunks.run_if(on_message::<WorldGenerated>));
328 347 let map = app.world().resource::<WorldMap>().clone();
@@ -333,7 +352,7 @@ mod tests {
333 352 origin: UVec2::ZERO,
334 353 layer: Layer::Terrain,
335 354 },
336 - TilemapChunkTileData(chunk_tiles(&map, UVec2::ZERO)),
355 + TilemapChunkTileData(chunk_tiles(&map, &LightMap::default(), UVec2::ZERO)),
337 356 ))
338 357 .id();
339 358
@@ -351,6 +370,22 @@ mod tests {
351 370 }
352 371
353 372 #[test]
373 + fn darkness_tints_tiles_and_fires_warm_them() {
374 + let map = WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Grass);
375 + let fire = (UVec2::new(10, 10), crate::light::StructureKind::Campfire);
376 + let night = LightMap::compute(map.size, 0.0, [fire]);
377 + let tiles = chunk_tiles(&map, &night, UVec2::ZERO);
378 + let lum = |pos: UVec2| {
379 + let c = tiles[(pos.y * CHUNK_SIZE + pos.x) as usize]
380 + .unwrap()
381 + .color
382 + .to_srgba();
383 + c.red + c.green + c.blue
384 + };
385 + assert!(lum(UVec2::new(10, 11)) > lum(UVec2::new(40, 40)) * 2.0);
386 + }
387 +
388 + #[test]
354 389 fn chunk_origin_rounds_down_to_chunk_grid() {
355 390 assert_eq!(chunk_origin(UVec2::new(0, 63)), UVec2::new(0, 0));
356 391 assert_eq!(chunk_origin(UVec2::new(64, 130)), UVec2::new(64, 128));
@@ -367,7 +402,7 @@ mod tests {
367 402 fn plant_layer_only_has_tiles_where_plants_grow() {
368 403 let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass);
369 404 map.set_plant(UVec2::new(1, 0), Some(Plant::Tree));
370 - let tiles = plant_tiles(&map, UVec2::ZERO);
405 + let tiles = plant_tiles(&map, &LightMap::default(), UVec2::ZERO);
371 406 assert!(tiles[0].is_none());
372 407 assert_eq!(tiles[1].unwrap().tileset_index, Plant::Tree.index());
373 408 }

src/zones.rs +0 -1

@@ -40,7 +40,6 @@ impl Zones {
40 40 self.stockpile.contains(&tile)
41 41 }
42 42
43 - #[cfg(test)]
44 43 pub fn stockpile_tiles(&self) -> impl Iterator<Item = UVec2> + '_ {
45 44 self.stockpile.iter().copied()
46 45 }

tasks.md +4 -1

@@ -17,10 +17,13 @@
17 17 - [x] Stockpile zones (Z tool, starting stockpile) and hauling (auto, or right-click loose items)
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 +- [x] Day/night cycle with per-tile lighting; colonists build campfires and torches (wood, burn out); work is slower in the dark
20 21 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
21 22
22 23 ## Planned
23 -- [ ] Jobs: building (consumes materials, so goals become real demand)
24 +- [ ] Jobs: building (walls, floors; consumes materials)
25 +- [ ] Refuelling fires instead of rebuilding; manual build orders for fires
26 +- [ ] Sleep/needs (night currently only affects light and work speed)
24 27 - [ ] Stockpile filters (which item kinds a zone accepts) and storage priority
25 28 - [ ] 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)
26 29 - [ ] UI to edit colony goals (material targets)