Add saving and loading
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-10-25 06:57 UTC
- commit
- 4ab42bfca2b359c8c435c4676e3e2f5ef133eb90
- parent
- e31b807e1c
- tree
- browse at this commit
21 files changed +729 -24
| CLAUDE.md | +1 -0 | |
| Cargo.lock | +24 -0 | |
| Cargo.toml | +6 -1 | |
| src/actions.rs | +7 -1 | |
| src/animals.rs | +2 -1 | |
| src/building.rs | +2 -1 | |
| src/designations.rs | +2 -1 | |
| src/economy.rs | +3 -2 | |
| src/family.rs | +3 -2 | |
| src/items.rs | +10 -5 | |
| src/jobs/mod.rs | +3 -2 | |
| src/main.rs | +2 -0 | |
| src/mood.rs | +2 -1 | |
| src/population.rs | +2 -1 | |
| src/rng.rs | +2 -1 | |
| src/save.rs | added | +531 -0 |
| src/structures.rs | +4 -2 | |
| src/ui.rs | +3 -1 | |
| src/world.rs | +91 -0 | |
| src/zones.rs | +28 -2 | |
| tasks.md | +1 -0 |
CLAUDE.md +1 -0
| @@ -48,6 +48,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che | ||
| 48 | 48 | - `src/panel.rs` top-right colonist panel (also: stockpile section with accept toggles + priority cycle when a stockpile tile is selected; collapsible Stock targets with -/+/auto per Stock): roster of `ColonistButton`s (rebuilt when names/child/mood face change; click selects) and, for a selected adult, `WorkToggle` buttons per `WorkType::{Food, Build, Gather, Haul, Tame}` toggling the colonist's `WorkPriorities` (required on Colonist; red = disabled). choose_jobs' autonomous candidates are a `(WorkType, finder)` list filtered by `allows`; orders ignore priorities. |
| 49 | 49 | - `src/climate.rs` seasons/temperature. Year = YEAR_DAYS 12 (4 seasons × SEASON_DAYS 3, starts Spring; independent of ageing's day=year). `outdoor_temperature(tick)` = seasonal cosine (MEAN 12, swing 14, coldest at COLDEST_YEAR_FRACTION) + day/night swing (coldest 04:00); `daily_mean(day)`, `growing_day` (mean ≥ 8), `sowing_season` (next SOW_AHEAD_DAYS growing; gates find_sowing), `cold_days_ahead` (non-growing days in the next FORESIGHT_DAYS 6 → `ColonyGoals::set_cold_days`, food/wood buffer days = max(normal, cold days)). `growth_factor(temp)` scales plant growth; Crop with growth < 1 dies below FROST_TEMP. `spoil_factor` slows spoilage in cold. `tile_temperature(map, outdoor, tile, fires)`: roof pulls toward 16 by ROOF_INSULATION 0.7, fires add `StructureKind::heat()` with light-radius falloff, blocked by walls. `Warmth(f32)` on colonists (required), `update_warmth` every 60 ticks; `cold()` < COLD_TEMP 5 → hunger ×1.5, rest recovery ×0.5, mood −0.15. choose_jobs: cold colonists MoveTo `find_warm_spot` (tiles ≥ WARM_TEMP near fires) before work; `chilly_soon` triggers find_lighting's campfire (torches only when dark). `snow_cover(tick)` → `LightMap::with_snow` (8 steps, triggers recompute) → tilemap `Layer::Snow` (z 0.2, white alpha overlay on unroofed land). Info panel shows season/day/temp (font has no ° glyph). |
| 50 | 50 | - Crafting (jobs): `Task::Craft{recipe: Recipe::{Meal, Tools}, pickup, station, progress}` (was Cook): recipe gives station kind (Campfire/Workbench), accepted input, input count, work, output; `find_crafting`, `input_source`. Tools: `WorkType::Craft` candidate when Tools wanted and TOOL_IRON iron available → craft at a workbench, else `find_workbench` (one, near home, autonomous Structure build like campfire/bed). `Task::Equip{pickup}` (before autonomous candidates) for adults without `Tool{wear}`; equip records Tools use (learned demand); `tool_rate` ×1.5 work; wear per working tick, removed at TOOL_LIFE_TICKS (2 days). Iron has min goal 6. `StructureKind::Statue` (5 gold): plan_homes adds one blueprint at `Home::statue_tile` once a home is built → gold demand via planned_needs; mood +0.1 (`beauty`) when the family home has one. |
| 51 | +- `src/save.rs` save/load. Action::Save/Load (F5/F9). `snapshot(world) -> SaveGame` (serde_json; SAVE_VERSION): seed, tick, rng, `WorldMap::layers()` (`MapLayers`: RLE terrain/plants/walls/roofs as u8 + sparse growth/wear; heights regenerated by `generate(size, seed)` in `restore_map`), `Zones::save` (`ZonesSave`, UVec2-keyed maps become Vecs — serde_json needs string keys), blueprints Vec, Homes, Designations, ColonyGoals, log, losses, colonists (`ColonistSave`, partner as index), items, structures (+fuel), animals. Not saved: jobs, paths, reservations, Affinity, idle timers (colonists restart idle). Storage: web localStorage key `simulator-save` (~150 KB), native `simulator-save.json` in cwd. Load: `start_load` (PreUpdate, exclusive) parses, inserts WorldSeed + restored map, writes WorldGenerated (all reset systems run in Update), stores `PendingLoad`; `finish_load` (PostUpdate) despawns colonists/animals/items/structures and `apply`s the save via SystemState spawn helpers (`spawn_colonist`, `spawn_stack`, `spawn_structure` → Entity, `spawn_animal`). New persistent state = add to SaveGame + snapshot + apply (+ serde derives; bevy `serialize` feature for glam). | |
| 51 | 52 | - `src/rng.rs` `SimRng` SplitMix64 resource, seeded (deterministic sim; no `rand` dep). |
| 52 | 53 | |
| 53 | 54 | ## Performance notes |
Cargo.lock +24 -0
| @@ -24,6 +24,8 @@ version = "0.24.1" | ||
| 24 | 24 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 25 | 25 | checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" |
| 26 | 26 | dependencies = [ |
| 27 | + "enumn", | |
| 28 | + "serde", | |
| 27 | 29 | "uuid", |
| 28 | 30 | ] |
| 29 | 31 | |
| @@ -453,6 +455,7 @@ dependencies = [ | ||
| 453 | 455 | "bevy_derive", |
| 454 | 456 | "bevy_ecs", |
| 455 | 457 | "bevy_reflect", |
| 458 | + "serde", | |
| 456 | 459 | ] |
| 457 | 460 | |
| 458 | 461 | [[package]] |
| @@ -777,6 +780,7 @@ dependencies = [ | ||
| 777 | 780 | "bevy_ui_widgets", |
| 778 | 781 | "bevy_window", |
| 779 | 782 | "bevy_world_serialization", |
| 783 | + "serde", | |
| 780 | 784 | "tracing", |
| 781 | 785 | ] |
| 782 | 786 | |
| @@ -1085,6 +1089,7 @@ dependencies = [ | ||
| 1085 | 1089 | "bevy_reflect", |
| 1086 | 1090 | "derive_more", |
| 1087 | 1091 | "log", |
| 1092 | + "serde", | |
| 1088 | 1093 | "smol_str", |
| 1089 | 1094 | "thiserror 2.0.21", |
| 1090 | 1095 | ] |
| @@ -1821,6 +1826,7 @@ dependencies = [ | ||
| 1821 | 1826 | "bevy_window", |
| 1822 | 1827 | "derive_more", |
| 1823 | 1828 | "parley", |
| 1829 | + "serde", | |
| 1824 | 1830 | "smallvec", |
| 1825 | 1831 | "swash", |
| 1826 | 1832 | "taffy", |
| @@ -1902,6 +1908,7 @@ dependencies = [ | ||
| 1902 | 1908 | "bevy_platform", |
| 1903 | 1909 | "disqualified", |
| 1904 | 1910 | "indexmap", |
| 1911 | + "serde", | |
| 1905 | 1912 | "thread_local", |
| 1906 | 1913 | ] |
| 1907 | 1914 | |
| @@ -2558,6 +2565,17 @@ dependencies = [ | ||
| 2558 | 2565 | ] |
| 2559 | 2566 | |
| 2560 | 2567 | [[package]] |
| 2568 | +name = "enumn" | |
| 2569 | +version = "0.1.14" | |
| 2570 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2571 | +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" | |
| 2572 | +dependencies = [ | |
| 2573 | + "proc-macro2", | |
| 2574 | + "quote", | |
| 2575 | + "syn 2.0.119", | |
| 2576 | +] | |
| 2577 | + | |
| 2578 | +[[package]] | |
| 2561 | 2579 | name = "equivalent" |
| 2562 | 2580 | version = "1.0.3" |
| 2563 | 2581 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -4912,6 +4930,9 @@ name = "simulator" | ||
| 4912 | 4930 | version = "0.1.0" |
| 4913 | 4931 | dependencies = [ |
| 4914 | 4932 | "bevy", |
| 4933 | + "serde", | |
| 4934 | + "serde_json", | |
| 4935 | + "web-sys", | |
| 4915 | 4936 | ] |
| 4916 | 4937 | |
| 4917 | 4938 | [[package]] |
| @@ -4944,6 +4965,9 @@ name = "smallvec" | ||
| 4944 | 4965 | version = "1.16.4" |
| 4945 | 4966 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4946 | 4967 | checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" |
| 4968 | +dependencies = [ | |
| 4969 | + "serde", | |
| 4970 | +] | |
| 4947 | 4971 | |
| 4948 | 4972 | [[package]] |
| 4949 | 4973 | name = "smithay-client-toolkit" |
Cargo.toml +6 -1
| @@ -4,7 +4,12 @@ version = "0.1.0" | ||
| 4 | 4 | edition = "2024" |
| 5 | 5 | |
| 6 | 6 | [dependencies] |
| 7 | -bevy = { version = "0.20.0" } | |
| 7 | +bevy = { version = "0.20.0", features = ["serialize"] } | |
| 8 | +serde = { version = "1", features = ["derive"] } | |
| 9 | +serde_json = "1" | |
| 10 | + | |
| 11 | +[target.'cfg(target_arch = "wasm32")'.dependencies] | |
| 12 | +web-sys = { version = "0.3", features = ["Window", "Storage"] } | |
| 8 | 13 | |
| 9 | 14 | # Enable a small amount of optimization in the dev profile. |
| 10 | 15 | [profile.dev] |
src/actions.rs +7 -1
| @@ -24,9 +24,11 @@ pub enum Action { | ||
| 24 | 24 | ToggleUi, |
| 25 | 25 | ZoomIn, |
| 26 | 26 | ZoomOut, |
| 27 | + Save, | |
| 28 | + Load, | |
| 27 | 29 | } |
| 28 | 30 | |
| 29 | -pub const KEYBINDS: [(KeyCode, Action); 14] = [ | |
| 31 | +pub const KEYBINDS: [(KeyCode, Action); 16] = [ | |
| 30 | 32 | (KeyCode::Space, Action::TogglePause), |
| 31 | 33 | (KeyCode::Digit1, Action::SetSpeed(SimSpeed::Normal)), |
| 32 | 34 | (KeyCode::Digit2, Action::SetSpeed(SimSpeed::Fast)), |
| @@ -41,6 +43,8 @@ pub const KEYBINDS: [(KeyCode, Action); 14] = [ | ||
| 41 | 43 | (KeyCode::KeyH, Action::ToggleUi), |
| 42 | 44 | (KeyCode::Equal, Action::ZoomIn), |
| 43 | 45 | (KeyCode::Minus, Action::ZoomOut), |
| 46 | + (KeyCode::F5, Action::Save), | |
| 47 | + (KeyCode::F9, Action::Load), | |
| 44 | 48 | ]; |
| 45 | 49 | |
| 46 | 50 | impl Action { |
| @@ -60,6 +64,8 @@ impl Action { | ||
| 60 | 64 | Action::ToggleUi => "Hide", |
| 61 | 65 | Action::ZoomIn => "+", |
| 62 | 66 | Action::ZoomOut => "-", |
| 67 | + Action::Save => "Save", | |
| 68 | + Action::Load => "Load", | |
| 63 | 69 | } |
| 64 | 70 | } |
| 65 | 71 | |
src/animals.rs +2 -1
| @@ -1,3 +1,4 @@ | ||
| 1 | +use serde::{Deserialize, Serialize}; | |
| 1 | 2 | use std::{ops::Range, time::Duration}; |
| 2 | 3 | |
| 3 | 4 | use bevy::prelude::*; |
| @@ -41,7 +42,7 @@ const MIN_DISTANCE_FROM_COLONY: f32 = 25.0; | ||
| 41 | 42 | const MIGRATION_INTERVAL_TICKS: u64 = 2 * TICKS_PER_HOUR; |
| 42 | 43 | const PLACEMENT_ATTEMPTS: usize = 200; |
| 43 | 44 | |
| 44 | -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 45 | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 45 | 46 | pub enum AnimalKind { |
| 46 | 47 | Deer, |
| 47 | 48 | Rabbit, |
src/building.rs +2 -1
| @@ -1,3 +1,4 @@ | ||
| 1 | +use serde::{Deserialize, Serialize}; | |
| 1 | 2 | use std::collections::{HashMap, HashSet, VecDeque}; |
| 2 | 3 | |
| 3 | 4 | use bevy::prelude::*; |
| @@ -49,7 +50,7 @@ const PAVE_BLUEPRINT_COLOR: Color = Color::srgba(0.85, 0.85, 0.85, 0.5); | ||
| 49 | 50 | const DECONSTRUCT_COLOR: Color = Color::srgba(1.0, 0.3, 0.25, 0.9); |
| 50 | 51 | const ROOF_BLUEPRINT_COLOR: Color = Color::srgba(0.8, 0.6, 1.0, 0.35); |
| 51 | 52 | |
| 52 | -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 53 | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 53 | 54 | pub enum BuildKind { |
| 54 | 55 | Structure(StructureKind), |
| 55 | 56 | Roof, |
src/designations.rs +2 -1
| @@ -1,3 +1,4 @@ | ||
| 1 | +use serde::{Deserialize, Serialize}; | |
| 1 | 2 | use std::collections::HashSet; |
| 2 | 3 | |
| 3 | 4 | use bevy::prelude::*; |
| @@ -39,7 +40,7 @@ impl Plugin for DesignationsPlugin { | ||
| 39 | 40 | const PRUNE_INTERVAL_TICKS: u64 = 60; |
| 40 | 41 | const DESIGNATION_COLOR: Color = Color::srgba(1.0, 0.85, 0.2, 0.9); |
| 41 | 42 | |
| 42 | -#[derive(Resource, Default, Debug)] | |
| 43 | +#[derive(Resource, Default, Debug, Serialize, Deserialize)] | |
| 43 | 44 | pub struct Designations(pub HashSet<UVec2>); |
| 44 | 45 | |
| 45 | 46 | #[derive(Resource, Default, Debug)] |
src/economy.rs +3 -2
| @@ -1,3 +1,4 @@ | ||
| 1 | +use serde::{Deserialize, Serialize}; | |
| 1 | 2 | use std::collections::HashMap; |
| 2 | 3 | |
| 3 | 4 | use bevy::prelude::*; |
| @@ -22,7 +23,7 @@ pub const COLONY_RADIUS: i32 = 60; | ||
| 22 | 23 | const HOURS_OF_MEMORY: f32 = 24.0; |
| 23 | 24 | const MAX_GOAL: f32 = 300.0; |
| 24 | 25 | |
| 25 | -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 26 | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 26 | 27 | pub enum Stock { |
| 27 | 28 | Wood, |
| 28 | 29 | Stone, |
| @@ -113,7 +114,7 @@ impl Stock { | ||
| 113 | 114 | } |
| 114 | 115 | } |
| 115 | 116 | |
| 116 | -#[derive(Resource, Debug, Clone)] | |
| 117 | +#[derive(Resource, Debug, Clone, Serialize, Deserialize)] | |
| 117 | 118 | pub struct ColonyGoals { |
| 118 | 119 | goals: HashMap<Stock, u32>, |
| 119 | 120 | use_per_day: HashMap<Stock, f32>, |
src/family.rs +3 -2
| @@ -1,3 +1,4 @@ | ||
| 1 | +use serde::{Deserialize, Serialize}; | |
| 1 | 2 | use std::collections::{HashMap, HashSet}; |
| 2 | 3 | |
| 3 | 4 | use bevy::prelude::*; |
| @@ -68,7 +69,7 @@ impl Child { | ||
| 68 | 69 | } |
| 69 | 70 | } |
| 70 | 71 | |
| 71 | -#[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 72 | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 72 | 73 | pub struct Home { |
| 73 | 74 | pub lo: UVec2, |
| 74 | 75 | pub hi: UVec2, |
| @@ -133,7 +134,7 @@ impl Home { | ||
| 133 | 134 | } |
| 134 | 135 | } |
| 135 | 136 | |
| 136 | -#[derive(Resource, Default, Debug)] | |
| 137 | +#[derive(Resource, Default, Debug, Clone, Serialize, Deserialize)] | |
| 137 | 138 | pub struct Homes { |
| 138 | 139 | pub by_family: HashMap<u32, Home>, |
| 139 | 140 | next_family: u32, |
src/items.rs +10 -5
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | use bevy::prelude::*; |
| 2 | +use serde::{Deserialize, Serialize}; | |
| 2 | 3 | |
| 3 | 4 | use crate::{ |
| 4 | 5 | world::{TILE_SIZE, TilePos, WorldMap}, |
| @@ -19,7 +20,7 @@ impl Plugin for ItemsPlugin { | ||
| 19 | 20 | } |
| 20 | 21 | } |
| 21 | 22 | |
| 22 | -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 23 | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 23 | 24 | pub enum ItemKind { |
| 24 | 25 | Wood, |
| 25 | 26 | Stone, |
| @@ -79,7 +80,7 @@ impl ItemKind { | ||
| 79 | 80 | } |
| 80 | 81 | } |
| 81 | 82 | |
| 82 | -#[derive(Component, Debug, Clone, Copy, PartialEq)] | |
| 83 | +#[derive(Component, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] | |
| 83 | 84 | #[require(Transform)] |
| 84 | 85 | pub struct Item { |
| 85 | 86 | pub kind: ItemKind, |
| @@ -201,12 +202,16 @@ pub fn drop_items( | ||
| 201 | 202 | item.merge(dropped); |
| 202 | 203 | return; |
| 203 | 204 | } |
| 205 | + spawn_stack(commands, assets, tile, dropped); | |
| 206 | +} | |
| 207 | + | |
| 208 | +pub fn spawn_stack(commands: &mut Commands, assets: &ItemAssets, tile: UVec2, item: Item) { | |
| 204 | 209 | commands.spawn(( |
| 205 | - Name::new(format!("{kind:?}")), | |
| 206 | - dropped, | |
| 210 | + Name::new(format!("{:?}", item.kind)), | |
| 211 | + item, | |
| 207 | 212 | TilePos(tile), |
| 208 | 213 | Mesh2d(assets.mesh.clone()), |
| 209 | - MeshMaterial2d(assets.material(kind)), | |
| 214 | + MeshMaterial2d(assets.material(item.kind)), | |
| 210 | 215 | Transform::from_translation(WorldMap::tile_center(tile).extend(0.7)), |
| 211 | 216 | )); |
| 212 | 217 | } |
src/jobs/mod.rs +3 -2
| @@ -1,3 +1,4 @@ | ||
| 1 | +use serde::{Deserialize, Serialize}; | |
| 1 | 2 | use std::{ |
| 2 | 3 | collections::{HashMap, HashSet}, |
| 3 | 4 | ops::Range, |
| @@ -82,7 +83,7 @@ const NEIGHBORS: [IVec2; 8] = [ | ||
| 82 | 83 | IVec2::new(-1, -1), |
| 83 | 84 | ]; |
| 84 | 85 | |
| 85 | -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 86 | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 86 | 87 | pub enum WorkType { |
| 87 | 88 | Food, |
| 88 | 89 | Build, |
| @@ -114,7 +115,7 @@ impl WorkType { | ||
| 114 | 115 | } |
| 115 | 116 | } |
| 116 | 117 | |
| 117 | -#[derive(Component, Default, Debug, Clone, PartialEq, Eq)] | |
| 118 | +#[derive(Component, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 118 | 119 | pub struct WorkPriorities { |
| 119 | 120 | disabled: Vec<WorkType>, |
| 120 | 121 | } |
src/main.rs +2 -0
| @@ -21,6 +21,7 @@ mod paths; | ||
| 21 | 21 | mod plants; |
| 22 | 22 | mod population; |
| 23 | 23 | mod rng; |
| 24 | +mod save; | |
| 24 | 25 | mod selection; |
| 25 | 26 | mod sim; |
| 26 | 27 | mod structures; |
| @@ -79,5 +80,6 @@ fn main() { | ||
| 79 | 80 | zones::ZonesPlugin, |
| 80 | 81 | ui::HudPlugin, |
| 81 | 82 | )) |
| 83 | + .add_plugins(save::SavePlugin) | |
| 82 | 84 | .run(); |
| 83 | 85 | } |
src/mood.rs +2 -1
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | use bevy::prelude::*; |
| 2 | +use serde::{Deserialize, Serialize}; | |
| 2 | 3 | |
| 3 | 4 | use crate::{ |
| 4 | 5 | climate::Warmth, |
| @@ -83,7 +84,7 @@ impl Age { | ||
| 83 | 84 | } |
| 84 | 85 | } |
| 85 | 86 | |
| 86 | -#[derive(Resource, Default, Debug)] | |
| 87 | +#[derive(Resource, Default, Debug, Serialize, Deserialize)] | |
| 87 | 88 | pub struct Losses(pub Vec<u64>); |
| 88 | 89 | |
| 89 | 90 | fn clear_losses(mut losses: ResMut<Losses>) { |
src/population.rs +2 -1
| @@ -1,3 +1,4 @@ | ||
| 1 | +use serde::{Deserialize, Serialize}; | |
| 1 | 2 | use std::collections::VecDeque; |
| 2 | 3 | |
| 3 | 4 | use bevy::prelude::*; |
| @@ -38,7 +39,7 @@ const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR; | ||
| 38 | 39 | const LOG_LENGTH: usize = 4; |
| 39 | 40 | const EDGE_ATTEMPTS: usize = 100; |
| 40 | 41 | |
| 41 | -#[derive(Resource, Default, Debug)] | |
| 42 | +#[derive(Resource, Default, Debug, Serialize, Deserialize)] | |
| 42 | 43 | pub struct ColonyLog(pub VecDeque<String>); |
| 43 | 44 | |
| 44 | 45 | impl ColonyLog { |
src/rng.rs +2 -1
| @@ -1,8 +1,9 @@ | ||
| 1 | +use serde::{Deserialize, Serialize}; | |
| 1 | 2 | use std::ops::Range; |
| 2 | 3 | |
| 3 | 4 | use bevy::prelude::*; |
| 4 | 5 | |
| 5 | -#[derive(Resource, Clone, Debug)] | |
| 6 | +#[derive(Resource, Clone, Debug, Serialize, Deserialize)] | |
| 6 | 7 | pub struct SimRng(u64); |
| 7 | 8 | |
| 8 | 9 | impl SimRng { |
src/save.rs +531 -0
| @@ -0,0 +1,531 @@ | ||
| 1 | +use bevy::{ecs::system::SystemState, prelude::*}; | |
| 2 | +use serde::{Deserialize, Serialize}; | |
| 3 | + | |
| 4 | +use crate::{ | |
| 5 | + actions::Action, | |
| 6 | + animals::{Animal, AnimalAssets, AnimalKind, Tame, spawn_animal}, | |
| 7 | + building::{Blueprints, BuildKind}, | |
| 8 | + colonist::{Colonist, ColonistAssets, NAMES, spawn_colonist}, | |
| 9 | + designations::Designations, | |
| 10 | + economy::ColonyGoals, | |
| 11 | + family::{Affinity, CHILD_SCALE, Child, Family, Homes, Partner}, | |
| 12 | + food::Hunger, | |
| 13 | + items::{Carrying, Item, ItemAssets, spawn_stack}, | |
| 14 | + jobs::{Tool, WorkPriorities}, | |
| 15 | + mood::{Age, LastMeal, Losses, Mood, Sick, Unhappy}, | |
| 16 | + needs::Rest, | |
| 17 | + population::{ColonyLog, Starvation}, | |
| 18 | + rng::SimRng, | |
| 19 | + sim::{SimPos, SimTick, sim_pos}, | |
| 20 | + structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure}, | |
| 21 | + world::{MapLayers, TilePos, WorldMap}, | |
| 22 | + worldgen::{WorldGenerated, WorldSeed, generate}, | |
| 23 | + zones::{Zones, ZonesSave}, | |
| 24 | +}; | |
| 25 | + | |
| 26 | +pub struct SavePlugin; | |
| 27 | + | |
| 28 | +impl Plugin for SavePlugin { | |
| 29 | + fn build(&self, app: &mut App) { | |
| 30 | + app.add_systems(Update, save_game) | |
| 31 | + .add_systems(PreUpdate, start_load) | |
| 32 | + .add_systems(PostUpdate, finish_load); | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +const SAVE_VERSION: u32 = 1; | |
| 37 | + | |
| 38 | +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] | |
| 39 | +pub struct ColonistSave { | |
| 40 | + name: String, | |
| 41 | + pos: Vec2, | |
| 42 | + tile: UVec2, | |
| 43 | + rest: f32, | |
| 44 | + hunger: f32, | |
| 45 | + mood: Option<f32>, | |
| 46 | + born: Option<i64>, | |
| 47 | + family: Option<u32>, | |
| 48 | + partner: Option<usize>, | |
| 49 | + child_born: Option<u64>, | |
| 50 | + carrying: Option<Item>, | |
| 51 | + priorities: WorkPriorities, | |
| 52 | + tool_wear: Option<u32>, | |
| 53 | + sick_until: Option<u64>, | |
| 54 | + last_meal: Option<(bool, u64)>, | |
| 55 | + unhappy: u32, | |
| 56 | + starvation: u32, | |
| 57 | +} | |
| 58 | + | |
| 59 | +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] | |
| 60 | +pub struct AnimalSave { | |
| 61 | + kind: AnimalKind, | |
| 62 | + pos: Vec2, | |
| 63 | + tile: UVec2, | |
| 64 | + tame: bool, | |
| 65 | +} | |
| 66 | + | |
| 67 | +#[derive(Serialize, Deserialize, Debug)] | |
| 68 | +pub struct SaveGame { | |
| 69 | + version: u32, | |
| 70 | + seed: u32, | |
| 71 | + tick: u64, | |
| 72 | + rng: SimRng, | |
| 73 | + map: MapLayers, | |
| 74 | + zones: ZonesSave, | |
| 75 | + blueprints: Vec<(UVec2, BuildKind)>, | |
| 76 | + homes: Homes, | |
| 77 | + designations: Designations, | |
| 78 | + goals: ColonyGoals, | |
| 79 | + log: ColonyLog, | |
| 80 | + losses: Losses, | |
| 81 | + colonists: Vec<ColonistSave>, | |
| 82 | + items: Vec<(UVec2, Item)>, | |
| 83 | + structures: Vec<(UVec2, StructureKind, Option<u32>)>, | |
| 84 | + animals: Vec<AnimalSave>, | |
| 85 | +} | |
| 86 | + | |
| 87 | +#[allow(clippy::type_complexity)] | |
| 88 | +pub fn snapshot(world: &mut World) -> SaveGame { | |
| 89 | + let mut colonist_query = world.query::<( | |
| 90 | + Entity, | |
| 91 | + &Colonist, | |
| 92 | + &SimPos, | |
| 93 | + &TilePos, | |
| 94 | + &Rest, | |
| 95 | + &Hunger, | |
| 96 | + &Carrying, | |
| 97 | + &WorkPriorities, | |
| 98 | + ( | |
| 99 | + Option<&Mood>, | |
| 100 | + Option<&Age>, | |
| 101 | + Option<&Family>, | |
| 102 | + Option<&Partner>, | |
| 103 | + Option<&Child>, | |
| 104 | + Option<&Tool>, | |
| 105 | + Option<&Sick>, | |
| 106 | + Option<&LastMeal>, | |
| 107 | + Option<&Unhappy>, | |
| 108 | + Option<&Starvation>, | |
| 109 | + ), | |
| 110 | + )>(); | |
| 111 | + let rows: Vec<_> = colonist_query.iter(world).collect(); | |
| 112 | + let order: Vec<Entity> = rows.iter().map(|r| r.0).collect(); | |
| 113 | + let colonists = rows | |
| 114 | + .iter() | |
| 115 | + .map( | |
| 116 | + |&(_, colonist, pos, tile, rest, hunger, carrying, priorities, extra)| { | |
| 117 | + let (mood, age, family, partner, child, tool, sick, meal, unhappy, starvation) = | |
| 118 | + extra; | |
| 119 | + ColonistSave { | |
| 120 | + name: colonist.name.clone(), | |
| 121 | + pos: pos.0, | |
| 122 | + tile: tile.0, | |
| 123 | + rest: rest.0, | |
| 124 | + hunger: hunger.0, | |
| 125 | + mood: mood.map(|m| m.0), | |
| 126 | + born: age.map(|a| a.born), | |
| 127 | + family: family.map(|f| f.0), | |
| 128 | + partner: partner.and_then(|p| order.iter().position(|&e| e == p.0)), | |
| 129 | + child_born: child.map(|c| c.born), | |
| 130 | + carrying: carrying.0, | |
| 131 | + priorities: priorities.clone(), | |
| 132 | + tool_wear: tool.map(|t| t.wear), | |
| 133 | + sick_until: sick.map(|s| s.until), | |
| 134 | + last_meal: meal.map(|m| (m.cooked, m.tick)), | |
| 135 | + unhappy: unhappy.map_or(0, |u| u.0), | |
| 136 | + starvation: starvation.map_or(0, |s| s.0), | |
| 137 | + } | |
| 138 | + }, | |
| 139 | + ) | |
| 140 | + .collect(); | |
| 141 | + let items = world | |
| 142 | + .query::<(&Item, &TilePos)>() | |
| 143 | + .iter(world) | |
| 144 | + .map(|(item, pos)| (pos.0, *item)) | |
| 145 | + .collect(); | |
| 146 | + let structures = world | |
| 147 | + .query::<(&Structure, &TilePos, Option<&Fuel>)>() | |
| 148 | + .iter(world) | |
| 149 | + .map(|(s, pos, fuel)| (pos.0, s.kind, fuel.map(|f| f.0))) | |
| 150 | + .collect(); | |
| 151 | + let animals = world | |
| 152 | + .query::<(&Animal, &SimPos, &TilePos, Has<Tame>)>() | |
| 153 | + .iter(world) | |
| 154 | + .map(|(animal, pos, tile, tame)| AnimalSave { | |
| 155 | + kind: animal.kind, | |
| 156 | + pos: pos.0, | |
| 157 | + tile: tile.0, | |
| 158 | + tame, | |
| 159 | + }) | |
| 160 | + .collect(); | |
| 161 | + let mut blueprints: Vec<(UVec2, BuildKind)> = world | |
| 162 | + .resource::<Blueprints>() | |
| 163 | + .0 | |
| 164 | + .iter() | |
| 165 | + .map(|(t, k)| (*t, *k)) | |
| 166 | + .collect(); | |
| 167 | + blueprints.sort_by_key(|(t, _)| (t.y, t.x)); | |
| 168 | + SaveGame { | |
| 169 | + version: SAVE_VERSION, | |
| 170 | + seed: world.resource::<WorldSeed>().0, | |
| 171 | + tick: world.resource::<SimTick>().0, | |
| 172 | + rng: world.resource::<SimRng>().clone(), | |
| 173 | + map: world.resource::<WorldMap>().layers(), | |
| 174 | + zones: world.resource::<Zones>().save(), | |
| 175 | + blueprints, | |
| 176 | + homes: world.resource::<Homes>().clone(), | |
| 177 | + designations: Designations(world.resource::<Designations>().0.clone()), | |
| 178 | + goals: world.resource::<ColonyGoals>().clone(), | |
| 179 | + log: ColonyLog(world.resource::<ColonyLog>().0.clone()), | |
| 180 | + losses: Losses(world.resource::<Losses>().0.clone()), | |
| 181 | + colonists, | |
| 182 | + items, | |
| 183 | + structures, | |
| 184 | + animals, | |
| 185 | + } | |
| 186 | +} | |
| 187 | + | |
| 188 | +pub fn restore_map(save: &SaveGame, size: UVec2) -> Result<WorldMap, String> { | |
| 189 | + if save.version != SAVE_VERSION { | |
| 190 | + return Err(format!("unsupported save version {}", save.version)); | |
| 191 | + } | |
| 192 | + let mut map = generate(size, save.seed); | |
| 193 | + map.restore(&save.map)?; | |
| 194 | + Ok(map) | |
| 195 | +} | |
| 196 | + | |
| 197 | +#[derive(Resource)] | |
| 198 | +struct PendingLoad(SaveGame); | |
| 199 | + | |
| 200 | +fn despawn_all<T: Component>(world: &mut World) { | |
| 201 | + let entities: Vec<Entity> = world | |
| 202 | + .query_filtered::<Entity, With<T>>() | |
| 203 | + .iter(world) | |
| 204 | + .collect(); | |
| 205 | + for entity in entities { | |
| 206 | + world.despawn(entity); | |
| 207 | + } | |
| 208 | +} | |
| 209 | + | |
| 210 | +type SpawnParams<'w, 's> = ( | |
| 211 | + Commands<'w, 's>, | |
| 212 | + Res<'w, ColonistAssets>, | |
| 213 | + Res<'w, ItemAssets>, | |
| 214 | + Res<'w, StructureAssets>, | |
| 215 | + Res<'w, AnimalAssets>, | |
| 216 | + ResMut<'w, Assets<ColorMaterial>>, | |
| 217 | +); | |
| 218 | + | |
| 219 | +pub fn apply(world: &mut World, save: SaveGame) { | |
| 220 | + despawn_all::<Colonist>(world); | |
| 221 | + despawn_all::<Animal>(world); | |
| 222 | + despawn_all::<Item>(world); | |
| 223 | + despawn_all::<Structure>(world); | |
| 224 | + world.insert_resource(SimTick(save.tick)); | |
| 225 | + world.insert_resource(save.rng); | |
| 226 | + world.insert_resource(Zones::load(save.zones)); | |
| 227 | + world.insert_resource(Blueprints(save.blueprints.into_iter().collect())); | |
| 228 | + world.insert_resource(save.homes); | |
| 229 | + world.insert_resource(save.designations); | |
| 230 | + world.insert_resource(save.goals); | |
| 231 | + world.insert_resource(save.log); | |
| 232 | + world.insert_resource(save.losses); | |
| 233 | + world.insert_resource(Affinity::default()); | |
| 234 | + let mut state: SystemState<SpawnParams> = SystemState::new(world); | |
| 235 | + let ( | |
| 236 | + mut commands, | |
| 237 | + colonist_assets, | |
| 238 | + item_assets, | |
| 239 | + structure_assets, | |
| 240 | + animal_assets, | |
| 241 | + mut materials, | |
| 242 | + ) = state.get_mut(world).expect("assets exist"); | |
| 243 | + for (tile, item) in save.items { | |
| 244 | + spawn_stack(&mut commands, &item_assets, tile, item); | |
| 245 | + } | |
| 246 | + for (tile, kind, fuel) in save.structures { | |
| 247 | + let entity = spawn_structure(&mut commands, &structure_assets, tile, kind); | |
| 248 | + if let Some(fuel) = fuel { | |
| 249 | + commands.entity(entity).insert(Fuel(fuel)); | |
| 250 | + } | |
| 251 | + } | |
| 252 | + for animal in save.animals { | |
| 253 | + let entity = spawn_animal(&mut commands, &animal_assets, animal.kind, animal.tile); | |
| 254 | + let mut entity = commands.entity(entity); | |
| 255 | + entity.insert(sim_pos(animal.pos)); | |
| 256 | + if animal.tame { | |
| 257 | + entity.insert(Tame); | |
| 258 | + } | |
| 259 | + } | |
| 260 | + let entities: Vec<Entity> = save | |
| 261 | + .colonists | |
| 262 | + .iter() | |
| 263 | + .map(|c| { | |
| 264 | + let index = NAMES.iter().position(|n| *n == c.name).unwrap_or(0); | |
| 265 | + let entity = spawn_colonist( | |
| 266 | + &mut commands, | |
| 267 | + &colonist_assets, | |
| 268 | + &item_assets, | |
| 269 | + &mut materials, | |
| 270 | + index, | |
| 271 | + c.tile, | |
| 272 | + ); | |
| 273 | + let mut e = commands.entity(entity); | |
| 274 | + e.insert(( | |
| 275 | + Colonist { | |
| 276 | + name: c.name.clone(), | |
| 277 | + }, | |
| 278 | + Name::new(c.name.clone()), | |
| 279 | + sim_pos(c.pos), | |
| 280 | + Rest(c.rest), | |
| 281 | + Hunger(c.hunger), | |
| 282 | + Carrying(c.carrying), | |
| 283 | + c.priorities.clone(), | |
| 284 | + Unhappy(c.unhappy), | |
| 285 | + Starvation(c.starvation), | |
| 286 | + )); | |
| 287 | + if let Some(mood) = c.mood { | |
| 288 | + e.insert(Mood(mood)); | |
| 289 | + } | |
| 290 | + if let Some(born) = c.born { | |
| 291 | + e.insert(Age { born }); | |
| 292 | + } | |
| 293 | + if let Some(family) = c.family { | |
| 294 | + e.insert(Family(family)); | |
| 295 | + } | |
| 296 | + if let Some(born) = c.child_born { | |
| 297 | + e.insert(( | |
| 298 | + Child { born }, | |
| 299 | + Transform::from_translation(c.pos.extend(1.0)) | |
| 300 | + .with_scale(Vec3::splat(CHILD_SCALE)), | |
| 301 | + )); | |
| 302 | + } | |
| 303 | + if let Some(wear) = c.tool_wear { | |
| 304 | + e.insert(Tool { wear }); | |
| 305 | + } | |
| 306 | + if let Some(until) = c.sick_until { | |
| 307 | + e.insert(Sick { until }); | |
| 308 | + } | |
| 309 | + if let Some((cooked, tick)) = c.last_meal { | |
| 310 | + e.insert(LastMeal { cooked, tick }); | |
| 311 | + } | |
| 312 | + entity | |
| 313 | + }) | |
| 314 | + .collect(); | |
| 315 | + for (c, &entity) in save.colonists.iter().zip(&entities) { | |
| 316 | + if let Some(&partner) = c.partner.and_then(|i| entities.get(i)) { | |
| 317 | + commands.entity(entity).insert(Partner(partner)); | |
| 318 | + } | |
| 319 | + } | |
| 320 | + state.apply(world); | |
| 321 | +} | |
| 322 | + | |
| 323 | +const SAVE_KEY: &str = "simulator-save"; | |
| 324 | + | |
| 325 | +#[cfg(target_arch = "wasm32")] | |
| 326 | +fn storage() -> Result<web_sys::Storage, String> { | |
| 327 | + web_sys::window() | |
| 328 | + .and_then(|w| w.local_storage().ok().flatten()) | |
| 329 | + .ok_or_else(|| "no local storage".to_string()) | |
| 330 | +} | |
| 331 | + | |
| 332 | +#[cfg(target_arch = "wasm32")] | |
| 333 | +fn write_save(data: &str) -> Result<(), String> { | |
| 334 | + storage()? | |
| 335 | + .set_item(SAVE_KEY, data) | |
| 336 | + .map_err(|_| "storage is full".to_string()) | |
| 337 | +} | |
| 338 | + | |
| 339 | +#[cfg(target_arch = "wasm32")] | |
| 340 | +fn read_save() -> Result<String, String> { | |
| 341 | + storage()? | |
| 342 | + .get_item(SAVE_KEY) | |
| 343 | + .ok() | |
| 344 | + .flatten() | |
| 345 | + .ok_or_else(|| "no saved game".to_string()) | |
| 346 | +} | |
| 347 | + | |
| 348 | +#[cfg(not(target_arch = "wasm32"))] | |
| 349 | +fn save_path() -> std::path::PathBuf { | |
| 350 | + std::path::PathBuf::from(format!("{SAVE_KEY}.json")) | |
| 351 | +} | |
| 352 | + | |
| 353 | +#[cfg(not(target_arch = "wasm32"))] | |
| 354 | +fn write_save(data: &str) -> Result<(), String> { | |
| 355 | + std::fs::write(save_path(), data).map_err(|e| e.to_string()) | |
| 356 | +} | |
| 357 | + | |
| 358 | +#[cfg(not(target_arch = "wasm32"))] | |
| 359 | +fn read_save() -> Result<String, String> { | |
| 360 | + std::fs::read_to_string(save_path()).map_err(|_| "no saved game".to_string()) | |
| 361 | +} | |
| 362 | + | |
| 363 | +fn save_game(world: &mut World, mut reader: Local<Option<SystemState<MessageReader<Action>>>>) { | |
| 364 | + let state = reader.get_or_insert_with(|| SystemState::new(world)); | |
| 365 | + let requested = state | |
| 366 | + .get_mut(world) | |
| 367 | + .is_ok_and(|mut actions| actions.read().any(|a| *a == Action::Save)); | |
| 368 | + if !requested { | |
| 369 | + return; | |
| 370 | + } | |
| 371 | + let save = snapshot(world); | |
| 372 | + let result = serde_json::to_string(&save) | |
| 373 | + .map_err(|e| e.to_string()) | |
| 374 | + .and_then(|data| write_save(&data)); | |
| 375 | + let tick = *world.resource::<SimTick>(); | |
| 376 | + let message = match result { | |
| 377 | + Ok(()) => "Game saved".to_string(), | |
| 378 | + Err(e) => format!("Save failed: {e}"), | |
| 379 | + }; | |
| 380 | + world.resource_mut::<ColonyLog>().push(tick, message); | |
| 381 | +} | |
| 382 | + | |
| 383 | +fn start_load(world: &mut World, mut reader: Local<Option<SystemState<MessageReader<Action>>>>) { | |
| 384 | + let state = reader.get_or_insert_with(|| SystemState::new(world)); | |
| 385 | + let requested = state | |
| 386 | + .get_mut(world) | |
| 387 | + .is_ok_and(|mut actions| actions.read().any(|a| *a == Action::Load)); | |
| 388 | + if !requested { | |
| 389 | + return; | |
| 390 | + } | |
| 391 | + let size = world.resource::<WorldMap>().size; | |
| 392 | + let loaded = read_save().and_then(|data| { | |
| 393 | + let save: SaveGame = serde_json::from_str(&data).map_err(|e| e.to_string())?; | |
| 394 | + let map = restore_map(&save, size)?; | |
| 395 | + Ok((save, map)) | |
| 396 | + }); | |
| 397 | + match loaded { | |
| 398 | + Ok((save, map)) => { | |
| 399 | + world.insert_resource(WorldSeed(save.seed)); | |
| 400 | + world.insert_resource(map); | |
| 401 | + world.write_message(WorldGenerated); | |
| 402 | + world.insert_resource(PendingLoad(save)); | |
| 403 | + } | |
| 404 | + Err(e) => { | |
| 405 | + let tick = *world.resource::<SimTick>(); | |
| 406 | + world | |
| 407 | + .resource_mut::<ColonyLog>() | |
| 408 | + .push(tick, format!("Load failed: {e}")); | |
| 409 | + } | |
| 410 | + } | |
| 411 | +} | |
| 412 | + | |
| 413 | +fn finish_load(world: &mut World) { | |
| 414 | + let Some(PendingLoad(save)) = world.remove_resource::<PendingLoad>() else { | |
| 415 | + return; | |
| 416 | + }; | |
| 417 | + let tick = SimTick(save.tick); | |
| 418 | + apply(world, save); | |
| 419 | + world | |
| 420 | + .resource_mut::<ColonyLog>() | |
| 421 | + .push(tick, "Game loaded".to_string()); | |
| 422 | +} | |
| 423 | + | |
| 424 | +#[cfg(test)] | |
| 425 | +mod tests { | |
| 426 | + use super::*; | |
| 427 | + use crate::{items::ItemKind, world::Plant}; | |
| 428 | + | |
| 429 | + fn app() -> App { | |
| 430 | + let mut app = App::new(); | |
| 431 | + app.insert_resource(WorldSeed(7)) | |
| 432 | + .insert_resource(generate(UVec2::splat(64), 7)) | |
| 433 | + .insert_resource(SimTick(12345)) | |
| 434 | + .insert_resource(SimRng::new(3)) | |
| 435 | + .init_resource::<Zones>() | |
| 436 | + .init_resource::<Blueprints>() | |
| 437 | + .init_resource::<Homes>() | |
| 438 | + .init_resource::<Designations>() | |
| 439 | + .init_resource::<ColonyGoals>() | |
| 440 | + .init_resource::<ColonyLog>() | |
| 441 | + .init_resource::<Losses>() | |
| 442 | + .init_resource::<ColonistAssets>() | |
| 443 | + .init_resource::<ItemAssets>() | |
| 444 | + .init_resource::<StructureAssets>() | |
| 445 | + .init_resource::<AnimalAssets>() | |
| 446 | + .init_resource::<Assets<ColorMaterial>>(); | |
| 447 | + app | |
| 448 | + } | |
| 449 | + | |
| 450 | + // Jobs, paths and reservations are not saved: loaded colonists start idle and choose again, | |
| 451 | + // which every job already handles after an interruption. Everything else round-trips. | |
| 452 | + #[test] | |
| 453 | + fn a_saved_colony_loads_back_the_same() { | |
| 454 | + let mut app = app(); | |
| 455 | + let world = app.world_mut(); | |
| 456 | + { | |
| 457 | + let mut map = world.resource_mut::<WorldMap>(); | |
| 458 | + map.set_plant(UVec2::new(3, 3), Some(Plant::Crop)); | |
| 459 | + map.set_growth(UVec2::new(3, 3), 0.42); | |
| 460 | + map.set_wall(UVec2::new(4, 4), true); | |
| 461 | + map.set_roof(UVec2::new(5, 5), true); | |
| 462 | + map.set_wear(UVec2::new(6, 6), 12.5); | |
| 463 | + } | |
| 464 | + world.resource_mut::<Blueprints>().0.insert( | |
| 465 | + UVec2::new(9, 9), | |
| 466 | + BuildKind::Structure(StructureKind::Statue), | |
| 467 | + ); | |
| 468 | + let ada = world | |
| 469 | + .spawn(( | |
| 470 | + Colonist { name: "Ada".into() }, | |
| 471 | + sim_pos(Vec2::new(40.0, 40.0)), | |
| 472 | + TilePos(UVec2::new(2, 2)), | |
| 473 | + Hunger(0.4), | |
| 474 | + Family(1), | |
| 475 | + Tool { wear: 99 }, | |
| 476 | + Carrying(Some(Item::new(ItemKind::Wood, 7))), | |
| 477 | + )) | |
| 478 | + .id(); | |
| 479 | + let bram = world | |
| 480 | + .spawn(( | |
| 481 | + Colonist { | |
| 482 | + name: "Bram".into(), | |
| 483 | + }, | |
| 484 | + Family(1), | |
| 485 | + Partner(ada), | |
| 486 | + )) | |
| 487 | + .id(); | |
| 488 | + world.entity_mut(ada).insert(Partner(bram)); | |
| 489 | + world.spawn((Item::new(ItemKind::Meal, 3), TilePos(UVec2::new(1, 1)))); | |
| 490 | + world.spawn(( | |
| 491 | + Structure { | |
| 492 | + kind: StructureKind::Campfire, | |
| 493 | + }, | |
| 494 | + TilePos(UVec2::new(8, 8)), | |
| 495 | + Fuel(500), | |
| 496 | + )); | |
| 497 | + let saved = snapshot(world); | |
| 498 | + let json = serde_json::to_string(&saved).unwrap(); | |
| 499 | + assert!(json.len() < 100_000, "{}", json.len()); | |
| 500 | + | |
| 501 | + let mut fresh = self::app(); | |
| 502 | + let world = fresh.world_mut(); | |
| 503 | + world.spawn(Colonist { | |
| 504 | + name: "Stray".into(), | |
| 505 | + }); | |
| 506 | + let loaded: SaveGame = serde_json::from_str(&json).unwrap(); | |
| 507 | + let map = restore_map(&loaded, UVec2::splat(64)).unwrap(); | |
| 508 | + world.insert_resource(map); | |
| 509 | + apply(world, loaded); | |
| 510 | + | |
| 511 | + let map = world.resource::<WorldMap>(); | |
| 512 | + assert_eq!(map.growth(IVec2::new(3, 3)), 0.42); | |
| 513 | + assert!(map.is_wall(IVec2::new(4, 4)) && map.is_roofed(IVec2::new(5, 5))); | |
| 514 | + assert_eq!(map.wear(IVec2::new(6, 6)), 12.5); | |
| 515 | + assert_eq!(map.worn_tiles(), vec![UVec2::new(6, 6)]); | |
| 516 | + assert_eq!(world.resource::<SimTick>().0, 12345); | |
| 517 | + assert_eq!(world.resource::<Blueprints>().0.len(), 1); | |
| 518 | + let mut colonists = world.query::<(Entity, &Colonist, Option<&Partner>, &Hunger)>(); | |
| 519 | + let rows: Vec<_> = colonists.iter(world).collect(); | |
| 520 | + assert_eq!(rows.len(), 2); | |
| 521 | + let find = |name: &str| rows.iter().find(|r| r.1.name == name).unwrap(); | |
| 522 | + let (ada, bram) = (find("Ada"), find("Bram")); | |
| 523 | + assert_eq!(ada.2.map(|p| p.0), Some(bram.0)); | |
| 524 | + assert_eq!(ada.3.0, 0.4); | |
| 525 | + assert_eq!(world.get::<Tool>(ada.0), Some(&Tool { wear: 99 })); | |
| 526 | + assert_eq!(world.get::<Carrying>(ada.0).unwrap().0.unwrap().count, 7); | |
| 527 | + let fuel: Vec<u32> = world.query::<&Fuel>().iter(world).map(|f| f.0).collect(); | |
| 528 | + assert_eq!(fuel, vec![500]); | |
| 529 | + assert_eq!(world.query::<&Item>().iter(world).count(), 1); | |
| 530 | + } | |
| 531 | +} | |
src/structures.rs +4 -2
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | use bevy::prelude::*; |
| 2 | +use serde::{Deserialize, Serialize}; | |
| 2 | 3 | |
| 3 | 4 | use crate::{ |
| 4 | 5 | items::ItemKind, |
| @@ -20,7 +21,7 @@ impl Plugin for StructuresPlugin { | ||
| 20 | 21 | } |
| 21 | 22 | } |
| 22 | 23 | |
| 23 | -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 24 | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 24 | 25 | pub enum StructureKind { |
| 25 | 26 | Campfire, |
| 26 | 27 | Torch, |
| @@ -240,7 +241,7 @@ pub fn spawn_structure( | ||
| 240 | 241 | assets: &StructureAssets, |
| 241 | 242 | tile: UVec2, |
| 242 | 243 | kind: StructureKind, |
| 243 | -) { | |
| 244 | +) -> Entity { | |
| 244 | 245 | let mut entity = commands.spawn(( |
| 245 | 246 | Name::new(format!("{kind:?}")), |
| 246 | 247 | Structure { kind }, |
| @@ -302,6 +303,7 @@ pub fn spawn_structure( | ||
| 302 | 303 | )); |
| 303 | 304 | }), |
| 304 | 305 | }; |
| 306 | + entity.id() | |
| 305 | 307 | } |
| 306 | 308 | |
| 307 | 309 | #[cfg(test)] |
src/ui.rs +3 -1
| @@ -36,7 +36,7 @@ impl Plugin for HudPlugin { | ||
| 36 | 36 | } |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | -const TOOLBAR: [Action; 14] = [ | |
| 39 | +const TOOLBAR: [Action; 16] = [ | |
| 40 | 40 | Action::TogglePause, |
| 41 | 41 | Action::SetSpeed(SimSpeed::Normal), |
| 42 | 42 | Action::SetSpeed(SimSpeed::Fast), |
| @@ -48,6 +48,8 @@ const TOOLBAR: [Action; 14] = [ | ||
| 48 | 48 | Action::ToggleDesignateTool, |
| 49 | 49 | Action::Cancel, |
| 50 | 50 | Action::NewWorld, |
| 51 | + Action::Save, | |
| 52 | + Action::Load, | |
| 51 | 53 | Action::ZoomIn, |
| 52 | 54 | Action::ZoomOut, |
| 53 | 55 | Action::ToggleUi, |
src/world.rs +91 -0
| @@ -1,3 +1,4 @@ | ||
| 1 | +use serde::{Deserialize, Serialize}; | |
| 1 | 2 | use std::collections::HashSet; |
| 2 | 3 | |
| 3 | 4 | use bevy::prelude::*; |
| @@ -93,6 +94,8 @@ pub enum Plant { | ||
| 93 | 94 | } |
| 94 | 95 | |
| 95 | 96 | impl Plant { |
| 97 | + pub const ALL: [Plant; 3] = [Plant::Tree, Plant::BerryBush, Plant::Crop]; | |
| 98 | + | |
| 96 | 99 | pub fn extra_move_cost(self) -> u32 { |
| 97 | 100 | match self { |
| 98 | 101 | Plant::Tree => 100, |
| @@ -118,6 +121,28 @@ impl Plant { | ||
| 118 | 121 | #[derive(Component, Default, Debug, Clone, Copy, PartialEq)] |
| 119 | 122 | pub struct TilePos(pub UVec2); |
| 120 | 123 | |
| 124 | +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] | |
| 125 | +pub struct MapLayers { | |
| 126 | + size: UVec2, | |
| 127 | + terrain: Vec<(u8, u32)>, | |
| 128 | + plants: Vec<(u8, u32)>, | |
| 129 | + walls: Vec<(u8, u32)>, | |
| 130 | + roofs: Vec<(u8, u32)>, | |
| 131 | + growth: Vec<(u32, f32)>, | |
| 132 | + wear: Vec<(u32, f32)>, | |
| 133 | +} | |
| 134 | + | |
| 135 | +fn run_length(values: impl Iterator<Item = u8>) -> Vec<(u8, u32)> { | |
| 136 | + let mut runs: Vec<(u8, u32)> = Vec::new(); | |
| 137 | + for v in values { | |
| 138 | + match runs.last_mut() { | |
| 139 | + Some((last, count)) if *last == v => *count += 1, | |
| 140 | + _ => runs.push((v, 1)), | |
| 141 | + } | |
| 142 | + } | |
| 143 | + runs | |
| 144 | +} | |
| 145 | + | |
| 121 | 146 | #[derive(Message, Debug, Clone, Copy, PartialEq)] |
| 122 | 147 | pub struct TileChanged(pub UVec2); |
| 123 | 148 | |
| @@ -243,6 +268,72 @@ impl WorldMap { | ||
| 243 | 268 | } |
| 244 | 269 | } |
| 245 | 270 | |
| 271 | + pub fn layers(&self) -> MapLayers { | |
| 272 | + let sparse = |values: &[f32]| { | |
| 273 | + values | |
| 274 | + .iter() | |
| 275 | + .enumerate() | |
| 276 | + .filter(|(_, v)| **v != 0.0) | |
| 277 | + .map(|(i, v)| (i as u32, *v)) | |
| 278 | + .collect() | |
| 279 | + }; | |
| 280 | + MapLayers { | |
| 281 | + size: self.size, | |
| 282 | + terrain: run_length(self.tiles.iter().map(|t| t.index() as u8)), | |
| 283 | + plants: run_length(self.plants.iter().map(|p| p.map_or(0, |p| p as u8 + 1))), | |
| 284 | + walls: run_length(self.walls.iter().map(|&w| w as u8)), | |
| 285 | + roofs: run_length(self.roofs.iter().map(|&r| r as u8)), | |
| 286 | + growth: sparse(&self.growth), | |
| 287 | + wear: sparse(&self.wear), | |
| 288 | + } | |
| 289 | + } | |
| 290 | + | |
| 291 | + pub fn restore(&mut self, layers: &MapLayers) -> Result<(), String> { | |
| 292 | + let n = self.tiles.len(); | |
| 293 | + let unpacked = |runs: &[(u8, u32)]| { | |
| 294 | + let values: Vec<u8> = runs | |
| 295 | + .iter() | |
| 296 | + .flat_map(|&(v, count)| std::iter::repeat_n(v, count as usize)) | |
| 297 | + .collect(); | |
| 298 | + if values.len() == n { | |
| 299 | + Ok(values) | |
| 300 | + } else { | |
| 301 | + Err("map layer has the wrong size".to_string()) | |
| 302 | + } | |
| 303 | + }; | |
| 304 | + if layers.size != self.size { | |
| 305 | + return Err("map size differs".into()); | |
| 306 | + } | |
| 307 | + let terrain = unpacked(&layers.terrain)?; | |
| 308 | + let plants = unpacked(&layers.plants)?; | |
| 309 | + let walls = unpacked(&layers.walls)?; | |
| 310 | + let roofs = unpacked(&layers.roofs)?; | |
| 311 | + for (i, pos) in self.positions().enumerate() { | |
| 312 | + let terrain = Terrain::ALL | |
| 313 | + .get(terrain[i] as usize) | |
| 314 | + .ok_or("unknown terrain")?; | |
| 315 | + self.set(pos, *terrain); | |
| 316 | + let plant = match plants[i] { | |
| 317 | + 0 => None, | |
| 318 | + p => Some(*Plant::ALL.get(p as usize - 1).ok_or("unknown plant")?), | |
| 319 | + }; | |
| 320 | + self.set_plant(pos, plant); | |
| 321 | + self.set_wall(pos, walls[i] != 0); | |
| 322 | + self.set_roof(pos, roofs[i] != 0); | |
| 323 | + self.set_growth(pos, 0.0); | |
| 324 | + self.set_wear(pos, 0.0); | |
| 325 | + } | |
| 326 | + let width = self.size.x; | |
| 327 | + let at = |i: u32| UVec2::new(i % width, i / width); | |
| 328 | + for &(i, growth) in &layers.growth { | |
| 329 | + self.set_growth(at(i), growth); | |
| 330 | + } | |
| 331 | + for &(i, wear) in &layers.wear { | |
| 332 | + self.set_wear(at(i), wear); | |
| 333 | + } | |
| 334 | + Ok(()) | |
| 335 | + } | |
| 336 | + | |
| 246 | 337 | pub fn worn_tiles(&self) -> Vec<UVec2> { |
| 247 | 338 | let mut tiles: Vec<UVec2> = self.worn.iter().copied().collect(); |
| 248 | 339 | tiles.sort_by_key(|t| (t.y, t.x)); |
src/zones.rs +28 -2
| @@ -1,3 +1,4 @@ | ||
| 1 | +use serde::{Deserialize, Serialize}; | |
| 1 | 2 | use std::collections::{HashMap, HashSet}; |
| 2 | 3 | |
| 3 | 4 | use bevy::prelude::*; |
| @@ -39,7 +40,9 @@ pub enum ZoneKind { | ||
| 39 | 40 | Farm, |
| 40 | 41 | } |
| 41 | 42 | |
| 42 | -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] | |
| 43 | +#[derive( | |
| 44 | + Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, | |
| 45 | +)] | |
| 43 | 46 | pub enum StoragePriority { |
| 44 | 47 | Low, |
| 45 | 48 | #[default] |
| @@ -71,7 +74,7 @@ impl StoragePriority { | ||
| 71 | 74 | } |
| 72 | 75 | } |
| 73 | 76 | |
| 74 | -#[derive(Clone, Debug, Default, PartialEq)] | |
| 77 | +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 75 | 78 | pub struct StorageRule { |
| 76 | 79 | pub refused: Vec<Stock>, |
| 77 | 80 | pub priority: StoragePriority, |
| @@ -103,7 +106,30 @@ pub struct Zones { | ||
| 103 | 106 | rules: HashMap<UVec2, StorageRule>, |
| 104 | 107 | } |
| 105 | 108 | |
| 109 | +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] | |
| 110 | +pub struct ZonesSave { | |
| 111 | + stockpile: Vec<UVec2>, | |
| 112 | + farm: Vec<UVec2>, | |
| 113 | + rules: Vec<(UVec2, StorageRule)>, | |
| 114 | +} | |
| 115 | + | |
| 106 | 116 | impl Zones { |
| 117 | + pub fn save(&self) -> ZonesSave { | |
| 118 | + ZonesSave { | |
| 119 | + stockpile: self.stockpile.iter().copied().collect(), | |
| 120 | + farm: self.farm.iter().copied().collect(), | |
| 121 | + rules: self.rules.iter().map(|(t, r)| (*t, r.clone())).collect(), | |
| 122 | + } | |
| 123 | + } | |
| 124 | + | |
| 125 | + pub fn load(save: ZonesSave) -> Self { | |
| 126 | + Self { | |
| 127 | + stockpile: save.stockpile.into_iter().collect(), | |
| 128 | + farm: save.farm.into_iter().collect(), | |
| 129 | + rules: save.rules.into_iter().collect(), | |
| 130 | + } | |
| 131 | + } | |
| 132 | + | |
| 107 | 133 | pub fn rule(&self, tile: UVec2) -> &StorageRule { |
| 108 | 134 | self.rules.get(&tile).unwrap_or(&DEFAULT_RULE) |
| 109 | 135 | } |
tasks.md +1 -0
| @@ -39,6 +39,7 @@ | ||
| 39 | 39 | - [x] Seasons: 12-day year, temperature (season + day/night), crops stop growing in the cold and die in frost, sowing only in the growing season, cold slows spoilage, snow cover |
| 40 | 40 | - [x] Cold: colonist warmth (roofs insulate, fires heat); cold drains food, slows rest, lowers mood; cold colonists warm up by fires; campfires built when chilly, not only dark; food and wood targets cover the cold days ahead |
| 41 | 41 | - [x] Workshops: workbench, iron tools (crafted on demand, equipped, +50% work speed, wear out); gold statues in finished homes lift mood; Craft work type |
| 42 | +- [x] Save/load (F5/F9, toolbar buttons): browser localStorage on web, simulator-save.json natively | |
| 42 | 43 | - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map |
| 43 | 44 | |
| 44 | 45 | ## Planned |