Add beds and split structures out of light module
- 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 | |
| src/jobs.rs | +129 -21 | |
| src/light.rs | +23 -203 | |
| src/main.rs | +2 -0 | |
| src/needs.rs | +105 -16 | |
| src/selection.rs | +15 -16 | |
| src/structures.rs | added | +245 -0 |
| src/tilemap.rs | +4 -1 | |
| tasks.md | +2 -1 |
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) |