diff --git a/CHANGELOG.md b/CHANGELOG.md index b0161f2..97f5c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,5 +20,9 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). console window. - `cargo xtask pack` builds the client and server and packs them with their assets into a distributable archive; CI publishes these archives. +- Machines (`machines` in data files): buildings with input/output slots that + process their own timed recipes, with optional custom window layouts and + background images. The furnace is now a machine (ore + log fuel), and a new + assembler makes tools. - GitHub `Package` workflow builds archives for Windows (zip), Linux and macOS (tar.gz). diff --git a/README.md b/README.md index f79f37e..fb81569 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A persistent-world, RPG-style station simulation inspired by Space Station 13, b with [Bevy](https://bevy.org) in Rust. - **ECS** – all game state lives in Bevy entities and components. -- **Data driven** – items, recipes, buildings and skills are defined in +- **Data driven** – items, recipes, buildings, machines and skills are defined in [rsn](https://crates.io/crates/rsn) files under `assets/data/`. - **Scriptable** – behaviour is written in [Rune](https://rune-rs.github.io) scripts under `assets/scripts/`. @@ -63,7 +63,8 @@ Characters are identified by name and are restored when that name logs in again. ## Modding All content is loaded from `assets/` at startup. Every `.rsn` file below `assets/data/` -(recursively) may contain any mix of `items`, `recipes`, `buildings` and `skills`, so a +(recursively) may contain any mix of `items`, `recipes`, `buildings`, `machines` and +`skills`, so a feature can ship in a single file. References between prototypes and script hooks are validated at load time and errors name the offending file and id. @@ -84,10 +85,9 @@ Required: `id`, `name`, `sprite`. Optional: `description`, `max_stack` (default ```rsn recipes: [ - { id: "smelt_iron", name: Some("Smelt iron plate"), - inputs: [("iron_ore", 2)], outputs: [("iron_plate", 1)], - tools: [], station: Some("furnace"), - skill: Some({ skill: "smithing", level: 1 }), xp: 10 }, + { id: "make_pickaxe", inputs: [("iron_plate", 2), ("plank", 1)], + outputs: [("pickaxe", 1)], tools: ["wrench"], station: Some("workbench"), + skill: Some({ skill: "smithing", level: 1 }), xp: 25 }, ] ``` @@ -115,6 +115,36 @@ Required: `id`, `name`, `sprite`. Optional: `description`, `variants` (name → (makes it buildable from the build menu), `deconstructible` (default true; refunds materials), `on_interact` (script hook), `vars` (integer state, persisted per building). +### Machines + +A machine adds item slots and its own recipes to the building with the same `id`. +Interacting with it opens the machine window: put items into the input slots, the +machine processes the first recipe whose inputs are present and whose outputs fit, and +the results appear in the output slots. Machine contents are saved with the world and +dropped when the machine is taken apart. + +```rsn +machines: [ + { id: "assembler", input_slots: 3, output_slots: 1, active_variant: Some("active"), + recipes: [ + { id: "assemble_wrench", inputs: [("iron_plate", 2)], outputs: [("wrench", 1)], + time: 3.0 }, + ], + ui: { background: Some("ui/machines/assembler.png"), size: Some((260.0, 150.0)), + inputs: [(16.0, 14.0), (16.0, 96.0), (70.0, 55.0)], outputs: [(200.0, 55.0)], + progress: Some((124.0, 69.0, 60.0, 12.0)) } }, +] +``` + +Required: `id` (an existing building), `recipes` (`id`, `inputs`, `outputs`, optional +`name` and `time` in seconds, default 1). Optional: `input_slots` and `output_slots` +(default 1), `active_variant` (building variant shown while working) and `ui`. Without +`ui` positions, inputs are stacked on the left and outputs on the right with a progress +bar between them. `ui` fields: `background` (PNG drawn behind the slots), `size` +(window content size in pixels), `slot_size` (default 40), `inputs`/`outputs` (one +`(x, y)` per slot, measured from the top-left corner) and `progress` +(`(x, y, width, height)`). + ### Skills ```rsn diff --git a/assets/data/buildings.rsn b/assets/data/buildings.rsn index a39fc70..39afef8 100644 --- a/assets/data/buildings.rsn +++ b/assets/data/buildings.rsn @@ -24,11 +24,17 @@ buildings: [ }, { id: "furnace", name: "Furnace", sprite: "sprites/buildings/furnace.png", + description: "Smelts ore. Fuel it with logs.", variants: { "lit": "sprites/buildings/furnace_lit.png" }, - stations: ["furnace"], construction: Some({ materials: [("iron_ore", 4), ("log", 1)], xp: 10, skill: Some({ skill: "construction", level: 1 }) }), - on_interact: Some("furnace::toggle"), + }, + { + id: "assembler", name: "Assembler", sprite: "sprites/buildings/assembler.png", + description: "Assembles tools from parts.", + variants: { "active": "sprites/buildings/assembler_active.png" }, + construction: Some({ materials: [("iron_plate", 4), ("plank", 2)], tools: ["screwdriver"], + xp: 20, skill: Some({ skill: "construction", level: 1 }) }), }, { id: "table", name: "Table", sprite: "sprites/buildings/table.png", diff --git a/assets/data/machines.rsn b/assets/data/machines.rsn new file mode 100644 index 0000000..926d9cd --- /dev/null +++ b/assets/data/machines.rsn @@ -0,0 +1,54 @@ +// Machines: buildings with item slots that process their own recipes over time. +// Interacting with a machine opens its window; put items into the input slots +// and collect the results from the output slots. +// +// Fields: +// id id of the building this machine belongs to (required) +// input_slots number of input slots (1) +// output_slots number of output slots (1) +// recipes [{ id, name, inputs, outputs, time (seconds, default 1.0) }] +// The first recipe whose inputs are present and whose outputs +// fit is processed. +// active_variant building variant shown while working +// ui window layout, everything optional: +// background PNG drawn behind the slots (relative to assets/) +// size (width, height) of the window content in pixels +// slot_size slot size in pixels (40) +// inputs one (x, y) per input slot; default: column on the left +// outputs one (x, y) per output slot; default: column on the right +// progress progress bar (x, y, width, height); default: between the columns +machines: [ + { + id: "furnace", + input_slots: 2, + output_slots: 1, + active_variant: Some("lit"), + recipes: [ + { + id: "smelt_iron", + name: Some("Smelt iron plates"), + inputs: [("iron_ore", 4), ("log", 1)], + outputs: [("iron_plate", 2)], + time: 4.0, + }, + ], + }, + { + id: "assembler", + input_slots: 3, + output_slots: 1, + active_variant: Some("active"), + recipes: [ + { id: "assemble_wrench", inputs: [("iron_plate", 2)], outputs: [("wrench", 1)], time: 3.0 }, + { id: "assemble_screwdriver", inputs: [("iron_plate", 1), ("plank", 1)], + outputs: [("screwdriver", 1)], time: 3.0 }, + ], + ui: { + background: Some("ui/machines/assembler.png"), + size: Some((260.0, 150.0)), + inputs: [(16.0, 14.0), (16.0, 96.0), (70.0, 55.0)], + outputs: [(200.0, 55.0)], + progress: Some((124.0, 69.0, 60.0, 12.0)), + }, + }, +] diff --git a/assets/data/recipes.rsn b/assets/data/recipes.rsn index 26136bb..076c20d 100644 --- a/assets/data/recipes.rsn +++ b/assets/data/recipes.rsn @@ -4,15 +4,6 @@ // (tag provided by an adjacent building), skill ({ skill, level }), xp, on_craft. // Ingredients can be written as ("item", count) or { item: "item", count: n }. recipes: [ - { - id: "smelt_iron", - name: Some("Smelt iron plate"), - inputs: [("iron_ore", 2)], - outputs: [("iron_plate", 1)], - station: Some("furnace"), - skill: Some({ skill: "smithing", level: 1 }), - xp: 10, - }, { id: "saw_planks", name: Some("Saw planks"), diff --git a/assets/maps/station.tmx b/assets/maps/station.tmx index 13c8861..7e27746 100644 --- a/assets/maps/station.tmx +++ b/assets/maps/station.tmx @@ -1,5 +1,5 @@ - + @@ -98,137 +98,143 @@ - + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + diff --git a/assets/scripts/furnace.rn b/assets/scripts/furnace.rn deleted file mode 100644 index 1ce5545..0000000 --- a/assets/scripts/furnace.rn +++ /dev/null @@ -1,14 +0,0 @@ -// Lighting the furnace is cosmetic for now, but burns a log. -pub fn toggle(ctx) { - if ctx.variant() == "lit" { - ctx.set_variant(""); - ctx.message("You let the furnace cool down."); - return; - } - if !ctx.take_item("log", 1) { - ctx.message("You need a log to light the furnace."); - return false; - } - ctx.set_variant("lit"); - ctx.message("The furnace roars to life."); -} diff --git a/assets/sprites/buildings/assembler.png b/assets/sprites/buildings/assembler.png new file mode 100644 index 0000000..a1445cd Binary files /dev/null and b/assets/sprites/buildings/assembler.png differ diff --git a/assets/sprites/buildings/assembler_active.png b/assets/sprites/buildings/assembler_active.png new file mode 100644 index 0000000..1336cda Binary files /dev/null and b/assets/sprites/buildings/assembler_active.png differ diff --git a/assets/ui/machines/assembler.png b/assets/ui/machines/assembler.png new file mode 100644 index 0000000..9f09013 Binary files /dev/null and b/assets/ui/machines/assembler.png differ diff --git a/crates/game_core/src/components.rs b/crates/game_core/src/components.rs index 36f72d3..aa2b78e 100644 --- a/crates/game_core/src/components.rs +++ b/crates/game_core/src/components.rs @@ -5,7 +5,10 @@ use std::collections::BTreeMap; use bevy::prelude::*; use serde::{Deserialize, Serialize}; -use crate::{data::GameData, grid::Dir}; +use crate::{ + data::{GameData, Ingredient, MachineDef, MachineRecipeDef}, + grid::Dir, +}; pub const INVENTORY_SLOTS: usize = 20; @@ -217,6 +220,197 @@ pub struct BuildingState { pub vars: BTreeMap, } +/// Which slot of a machine an action refers to. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum MachineSlot { + Input(usize), + Output(usize), +} + +/// Item slots and processing state of a machine building (see [`crate::data::MachineDef`]). +#[derive(Component, Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Machine { + pub inputs: Vec>, + pub outputs: Vec>, + /// Recipe currently being processed. + pub recipe: Option, + /// Seconds spent on the current recipe. + pub progress: f32, +} + +impl Machine { + pub fn new(def: &MachineDef) -> Self { + Self { + inputs: vec![None; def.input_slots], + outputs: vec![None; def.output_slots], + recipe: None, + progress: 0.0, + } + } + + /// Adapts the slot counts to a (possibly changed) definition and returns + /// the stacks that no longer have a slot. + pub fn fit_to(&mut self, def: &MachineDef) -> Vec { + let mut removed = Vec::new(); + for (slots, len) in [ + (&mut self.inputs, def.input_slots), + (&mut self.outputs, def.output_slots), + ] { + if slots.len() > len { + removed.extend(slots.drain(len..).flatten()); + } + slots.resize(len, None); + } + if self + .recipe + .as_ref() + .is_some_and(|r| def.recipe(r).is_none()) + { + self.recipe = None; + self.progress = 0.0; + } + removed + } + + pub fn slot(&self, slot: MachineSlot) -> Option<&ItemStack> { + match slot { + MachineSlot::Input(i) => self.inputs.get(i)?.as_ref(), + MachineSlot::Output(i) => self.outputs.get(i)?.as_ref(), + } + } + + /// All stacks in the machine, e.g. to drop them when it is taken apart. + pub fn contents(&self) -> impl Iterator { + self.inputs.iter().chain(&self.outputs).flatten() + } + + pub fn input_count(&self, item: &str) -> u32 { + self.inputs + .iter() + .flatten() + .filter(|s| s.item == item) + .map(|s| s.count) + .sum() + } + + /// Puts up to `count` of `item` into an input slot (`None`: any fitting slot) + /// and returns how many were inserted. + pub fn insert(&mut self, slot: Option, item: &str, count: u32, data: &GameData) -> u32 { + let max = data.max_stack(item); + let mut left = count; + let fill = |s: &mut Option, left: &mut u32| match s { + Some(stack) if stack.item == item => { + let moved = (*left).min(max.saturating_sub(stack.count)); + stack.count += moved; + *left -= moved; + } + Some(_) => {} + None if *left > 0 => { + let moved = (*left).min(max); + *s = Some(ItemStack::new(item, moved)); + *left -= moved; + } + None => {} + }; + match slot { + Some(i) => { + if let Some(s) = self.inputs.get_mut(i) { + fill(s, &mut left); + } + } + None => { + // Top up existing stacks first, then use empty slots. + for s in self.inputs.iter_mut().filter(|s| s.is_some()) { + fill(s, &mut left); + } + for s in self.inputs.iter_mut().filter(|s| s.is_none()) { + fill(s, &mut left); + } + } + } + count - left + } + + /// Removes up to `count` items from a slot. + pub fn take(&mut self, slot: MachineSlot, count: u32) -> Option { + let entry = match slot { + MachineSlot::Input(i) => self.inputs.get_mut(i)?, + MachineSlot::Output(i) => self.outputs.get_mut(i)?, + }; + let stack = entry.as_mut()?; + let taken = count.min(stack.count); + stack.count -= taken; + let item = stack.item.clone(); + if stack.count == 0 { + *entry = None; + } + (taken > 0).then(|| ItemStack::new(item, taken)) + } + + /// Whether all inputs of the recipe are present and its outputs fit. + pub fn can_process(&self, recipe: &MachineRecipeDef, data: &GameData) -> bool { + recipe + .inputs + .iter() + .all(|i| self.input_count(&i.item) >= i.count) + && self.clone().add_outputs(&recipe.outputs, data) + } + + /// Adds outputs if all of them fit; changes nothing otherwise. + fn add_outputs(&mut self, outputs: &[Ingredient], data: &GameData) -> bool { + let mut slots = self.outputs.clone(); + for output in outputs { + let max = data.max_stack(&output.item); + let mut left = output.count; + for s in slots.iter_mut() { + if let Some(stack) = s + && stack.item == output.item + { + let moved = left.min(max.saturating_sub(stack.count)); + stack.count += moved; + left -= moved; + } + } + for s in slots.iter_mut() { + if left > 0 && s.is_none() { + let moved = left.min(max); + *s = Some(ItemStack::new(&output.item, moved)); + left -= moved; + } + } + if left > 0 { + return false; + } + } + self.outputs = slots; + true + } + + /// Consumes the recipe inputs and produces its outputs. Returns `false` + /// (and changes nothing) if that is not possible. + pub fn complete(&mut self, recipe: &MachineRecipeDef, data: &GameData) -> bool { + if !self.can_process(recipe, data) { + return false; + } + for input in &recipe.inputs { + let mut left = input.count; + for s in self.inputs.iter_mut().rev() { + if let Some(stack) = s + && stack.item == input.item + { + let taken = left.min(stack.count); + stack.count -= taken; + left -= taken; + if stack.count == 0 { + *s = None; + } + } + } + } + self.add_outputs(&recipe.outputs, data) + } +} + /// The Tiled map the world is built on. Path is relative to the assets directory. #[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WorldMap { @@ -255,6 +449,45 @@ mod tests { assert_eq!(inv.space_for("ore", &data), 1); } + #[test] + fn machine_slots_and_processing() { + let data = GameData::from_source( + r#" + items: [ + { id: "ore", name: "Ore", sprite: "", max_stack: 5 }, + { id: "plate", name: "Plate", sprite: "", max_stack: 2 }, + ] + buildings: [{ id: "m", name: "M", sprite: "" }] + machines: [{ id: "m", input_slots: 2, recipes: [ + { id: "r", inputs: [("ore", 3)], outputs: [("plate", 1)] }, + ] }] + "#, + ) + .unwrap(); + let def = &data.machines["m"]; + let recipe = def.recipe("r").unwrap(); + let mut machine = Machine::new(def); + assert_eq!(machine.insert(None, "ore", 7, &data), 7); + assert_eq!(machine.insert(Some(1), "ore", 9, &data), 3); + assert_eq!(machine.input_count("ore"), 10); + assert!(machine.can_process(recipe, &data)); + assert!(machine.complete(recipe, &data)); + assert!(machine.complete(recipe, &data)); + assert_eq!(machine.input_count("ore"), 4); + // The output slot holds at most two plates. + assert!(!machine.can_process(recipe, &data)); + assert!(!machine.complete(recipe, &data)); + assert_eq!( + machine.take(MachineSlot::Output(0), 1), + Some(ItemStack::new("plate", 1)) + ); + assert!(machine.complete(recipe, &data)); + assert_eq!(machine.input_count("ore"), 1); + assert!(!machine.can_process(recipe, &data)); + assert_eq!(machine.take(MachineSlot::Input(5), 1), None); + assert_eq!(machine.contents().map(|s| s.count).sum::(), 3); + } + #[test] fn skill_levels() { let mut skills = Skills::default(); diff --git a/crates/game_core/src/data.rs b/crates/game_core/src/data.rs index 465c274..033064d 100644 --- a/crates/game_core/src/data.rs +++ b/crates/game_core/src/data.rs @@ -171,6 +171,159 @@ pub struct SkillDef { pub name: String, } +/// A recipe processed by a machine over time. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MachineRecipeDef { + pub id: String, + #[serde(default)] + pub name: Option, + pub inputs: Vec, + pub outputs: Vec, + /// Processing time in seconds. + #[serde(default = "default_machine_time")] + pub time: f32, +} + +fn default_machine_time() -> f32 { + 1.0 +} + +impl MachineRecipeDef { + pub fn display_name<'a>(&'a self, data: &'a GameData) -> &'a str { + if let Some(name) = &self.name { + return name; + } + self.outputs + .first() + .and_then(|o| data.items.get(&o.item)) + .map(|i| i.name.as_str()) + .unwrap_or(&self.id) + } +} + +/// Position (in pixels, relative to the window's top-left corner) of a slot. +pub type UiPoint = (f32, f32); + +/// Layout of the machine window. Everything is optional: by default inputs are +/// stacked on the left, outputs on the right and the progress bar in between. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MachineUiDef { + /// Background image (relative to the assets directory), drawn at `size`. + #[serde(default)] + pub background: Option, + /// Window content size in pixels. Defaults to a size fitting the default layout. + #[serde(default)] + pub size: Option<(f32, f32)>, + /// Slot size in pixels. + #[serde(default = "default_slot_size")] + pub slot_size: f32, + /// One position per input slot. Empty uses the default layout. + #[serde(default)] + pub inputs: Vec, + /// One position per output slot. Empty uses the default layout. + #[serde(default)] + pub outputs: Vec, + /// Progress bar as `(x, y, width, height)`. + #[serde(default)] + pub progress: Option<(f32, f32, f32, f32)>, +} + +fn default_slot_size() -> f32 { + 40.0 +} + +impl Default for MachineUiDef { + fn default() -> Self { + Self { + background: None, + size: None, + slot_size: default_slot_size(), + inputs: Vec::new(), + outputs: Vec::new(), + progress: None, + } + } +} + +/// Gap between slots and window border in the default layout. +pub const MACHINE_UI_GAP: f32 = 8.0; +const MACHINE_UI_ARROW: f32 = 64.0; + +/// Fully resolved positions of all machine window elements. +#[derive(Clone, Debug, PartialEq)] +pub struct MachineLayout { + pub size: (f32, f32), + pub slot_size: f32, + pub inputs: Vec, + pub outputs: Vec, + pub progress: (f32, f32, f32, f32), +} + +/// A building that processes items: players put items into input slots and the +/// machine turns them into outputs according to its own recipes. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MachineDef { + /// Id of the building this machine logic belongs to. + pub id: String, + #[serde(default = "default_one")] + pub input_slots: usize, + #[serde(default = "default_one")] + pub output_slots: usize, + pub recipes: Vec, + /// Building variant shown while the machine is working. + #[serde(default)] + pub active_variant: Option, + #[serde(default)] + pub ui: MachineUiDef, +} + +fn default_one() -> usize { + 1 +} + +impl MachineDef { + pub fn recipe(&self, id: &str) -> Option<&MachineRecipeDef> { + self.recipes.iter().find(|r| r.id == id) + } + + /// Resolves the window layout, filling in defaults. + pub fn layout(&self) -> MachineLayout { + let ui = &self.ui; + let s = ui.slot_size; + let gap = MACHINE_UI_GAP; + let column = |count: usize, x: f32| -> Vec { + (0..count) + .map(|i| (x, gap + i as f32 * (s + gap))) + .collect() + }; + let rows = self.input_slots.max(self.output_slots).max(1) as f32; + let default_height = gap + rows * (s + gap); + let default_width = gap + s + gap + MACHINE_UI_ARROW + gap + s + gap; + let size = ui.size.unwrap_or((default_width, default_height)); + let inputs = if ui.inputs.is_empty() { + column(self.input_slots, gap) + } else { + ui.inputs.clone() + }; + let outputs = if ui.outputs.is_empty() { + column(self.output_slots, size.0 - gap - s) + } else { + ui.outputs.clone() + }; + let progress = ui.progress.unwrap_or_else(|| { + let x = gap + s + gap; + (x, size.1 / 2.0 - 6.0, size.0 - 2.0 * x, 12.0) + }); + MachineLayout { + size, + slot_size: s, + inputs, + outputs, + progress, + } + } +} + /// Root of a data file. All sections are optional. #[derive(Debug, Default, Deserialize)] pub struct DataFile { @@ -181,6 +334,8 @@ pub struct DataFile { #[serde(default)] pub buildings: Vec, #[serde(default)] + pub machines: Vec, + #[serde(default)] pub skills: Vec, } @@ -203,6 +358,8 @@ pub struct GameData { pub items: BTreeMap, pub recipes: BTreeMap, pub buildings: BTreeMap, + /// Machines, keyed by the id of the building they belong to. + pub machines: BTreeMap, pub skills: BTreeMap, } @@ -296,6 +453,16 @@ impl GameData { errors, ); } + for machine in file.machines { + insert( + &mut self.machines, + "machine", + machine.id.clone(), + machine, + path, + errors, + ); + } for skill in file.skills { insert( &mut self.skills, @@ -366,6 +533,70 @@ impl GameData { check_skill(&mut errors, &ctx, &construction.skill); } } + for machine in self.machines.values() { + let ctx = format!("machine `{}`", machine.id); + let Some(building) = self.buildings.get(&machine.id) else { + errors.push(format!("{ctx}: no building with this id")); + continue; + }; + if let Some(variant) = &machine.active_variant + && !building.variants.contains_key(variant) + { + errors.push(format!( + "{ctx}: building has no variant `{variant}` (active_variant)" + )); + } + if machine.input_slots == 0 || machine.output_slots == 0 { + errors.push(format!("{ctx}: needs at least one input and output slot")); + } + let ui = &machine.ui; + for (kind, positions, slots) in [ + ("inputs", &ui.inputs, machine.input_slots), + ("outputs", &ui.outputs, machine.output_slots), + ] { + if !positions.is_empty() && positions.len() != slots { + errors.push(format!( + "{ctx}: ui.{kind} has {} positions but there are {slots} slots", + positions.len() + )); + } + } + if ui.slot_size <= 0.0 { + errors.push(format!("{ctx}: ui.slot_size must be positive")); + } + let mut ids = Vec::new(); + for recipe in &machine.recipes { + let ctx = format!("{ctx}, recipe `{}`", recipe.id); + if ids.contains(&&recipe.id) { + errors.push(format!("{ctx}: defined twice")); + } + ids.push(&recipe.id); + if recipe.inputs.is_empty() || recipe.outputs.is_empty() { + errors.push(format!("{ctx}: needs inputs and outputs")); + } + if recipe.time <= 0.0 { + errors.push(format!("{ctx}: time must be positive")); + } + for (kind, list, slots) in [ + ("inputs", &recipe.inputs, machine.input_slots), + ("outputs", &recipe.outputs, machine.output_slots), + ] { + if list.len() > slots { + errors.push(format!( + "{ctx}: {} different {kind} but only {slots} slots", + list.len() + )); + } + for ing in list { + check_item(&mut errors, &ctx, &ing.item); + if ing.count == 0 { + errors + .push(format!("{ctx}: count of `{}` must be at least 1", ing.item)); + } + } + } + } + } errors } @@ -377,6 +608,11 @@ impl GameData { .chain(self.buildings.values().flat_map(|b| { std::iter::once(b.sprite.as_str()).chain(b.variants.values().map(String::as_str)) })) + .chain( + self.machines + .values() + .filter_map(|m| m.ui.background.as_deref()), + ) } pub fn max_stack(&self, item: &str) -> u32 { @@ -491,6 +727,51 @@ mod tests { assert!(err.to_string().contains("line 1"), "{err}"); } + const MACHINE: &str = r#" + items: [ + { id: "ore", name: "Ore", sprite: "ore.png" }, + { id: "plate", name: "Plate", sprite: "plate.png" }, + ] + buildings: [ + { id: "press", name: "Press", sprite: "press.png", variants: { "on": "press_on.png" } }, + ] + machines: [ + { + id: "press", + input_slots: 2, + active_variant: Some("on"), + recipes: [{ id: "press", inputs: [("ore", 3)], outputs: [("plate", 1)], time: 2.5 }], + ui: { background: Some("ui/press.png"), outputs: [(100.0, 20.0)] }, + }, + ] + "#; + + #[test] + fn parses_machines_with_default_layout() { + let data = GameData::from_source(MACHINE).unwrap(); + let press = &data.machines["press"]; + assert_eq!((press.input_slots, press.output_slots), (2, 1)); + assert_eq!(press.recipe("press").unwrap().time, 2.5); + let layout = press.layout(); + // Inputs default to a column on the left, the custom output position is kept. + assert_eq!(layout.inputs, vec![(8.0, 8.0), (8.0, 56.0)]); + assert_eq!(layout.outputs, vec![(100.0, 20.0)]); + assert_eq!(layout.size.1, 8.0 + 2.0 * 48.0); + assert!(data.sprite_paths().any(|p| p == "ui/press.png")); + } + + #[test] + fn rejects_invalid_machines() { + let bad = MACHINE + .replace("Some(\"on\")", "Some(\"off\")") + .replace("[(100.0, 20.0)]", "[(1.0, 1.0), (2.0, 2.0)]") + .replace("(\"ore\", 3)", "(\"ore\", 3), (\"x\", 1), (\"plate\", 1)"); + let DataError::Invalid(errors) = GameData::from_source(&bad).unwrap_err() else { + panic!() + }; + assert_eq!(errors.len(), 4, "{errors:#?}"); + } + #[test] fn shipped_data_is_valid() { let dir = crate::assets_dir().join("data"); diff --git a/crates/game_core/src/lib.rs b/crates/game_core/src/lib.rs index 073d8ec..b73653b 100644 --- a/crates/game_core/src/lib.rs +++ b/crates/game_core/src/lib.rs @@ -16,7 +16,7 @@ pub use data::GameData; pub const DEFAULT_MAP: &str = "maps/station.tmx"; pub const DEFAULT_PORT: u16 = 5000; /// Bump when the protocol changes in incompatible ways. -pub const PROTOCOL_ID: u64 = 1; +pub const PROTOCOL_ID: u64 = 2; /// Locates the `assets` directory. /// diff --git a/crates/game_core/src/protocol.rs b/crates/game_core/src/protocol.rs index 0e78024..15c521b 100644 --- a/crates/game_core/src/protocol.rs +++ b/crates/game_core/src/protocol.rs @@ -57,6 +57,24 @@ pub struct Deconstruct { pub target: GridPos, } +/// Move items from an inventory slot into a machine on an adjacent tile. +#[derive(Event, Clone, Debug, Serialize, Deserialize)] +pub struct MachineInsert { + pub target: GridPos, + pub inventory_slot: usize, + /// Input slot to put the items in, `None` for any fitting slot. + pub machine_slot: Option, + pub count: u32, +} + +/// Move items from a machine slot into the inventory. +#[derive(Event, Clone, Debug, Serialize, Deserialize)] +pub struct MachineTake { + pub target: GridPos, + pub slot: MachineSlot, + pub count: u32, +} + /// Text shown in the receiving player's message log. #[derive(Event, Clone, Debug, Serialize, Deserialize)] pub struct GameLog { @@ -77,6 +95,7 @@ impl Plugin for ProtocolPlugin { .replicate::() .replicate::() .replicate::() + .replicate::() .add_client_event::(Channel::Ordered) .add_client_event::(Channel::Ordered) .add_client_event::(Channel::Ordered) @@ -86,6 +105,8 @@ impl Plugin for ProtocolPlugin { .add_client_event::(Channel::Ordered) .add_client_event::(Channel::Ordered) .add_client_event::(Channel::Ordered) + .add_client_event::(Channel::Ordered) + .add_client_event::(Channel::Ordered) .add_server_event::(Channel::Ordered); } } diff --git a/crates/game_server/src/actions.rs b/crates/game_server/src/actions.rs index 987f5a1..b473ba3 100644 --- a/crates/game_server/src/actions.rs +++ b/crates/game_server/src/actions.rs @@ -325,6 +325,10 @@ pub fn on_interact( return; }; let Some(hook) = &def.on_interact else { + // Clients open the machine window themselves. + if data.0.machines.contains_key(&def.id) { + return; + } let mut text = def.name.clone(); if !def.description.is_empty() { text = format!("{text}: {}", def.description); @@ -486,7 +490,7 @@ pub fn on_deconstruct( players: Res, data: Res, mut characters: Characters, - buildings: Query<(Entity, &Building, &GridPos), Without>, + buildings: Query<(Entity, &Building, &GridPos, Option<&Machine>), Without>, ) { let client = ev.client_id; let target = ev.message.target; @@ -501,7 +505,8 @@ pub fn on_deconstruct( return; } face_towards(&mut facing, *pos, target); - let Some((building_entity, building, _)) = buildings.iter().find(|(_, _, p)| **p == target) + let Some((building_entity, building, _, machine)) = + buildings.iter().find(|(_, _, p, _)| **p == target) else { tell(&mut commands, client, "There is nothing to deconstruct."); return; @@ -542,6 +547,9 @@ pub fn on_deconstruct( ); } } + for stack in machine.into_iter().flat_map(Machine::contents) { + spawn_ground_item(&mut commands, stack.clone(), target); + } commands.entity(building_entity).despawn(); tell( &mut commands, diff --git a/crates/game_server/src/crafting.rs b/crates/game_server/src/crafting.rs index 8cffb15..390609a 100644 --- a/crates/game_server/src/crafting.rs +++ b/crates/game_server/src/crafting.rs @@ -203,22 +203,22 @@ mod tests { let data = data(); let mut inv = Inventory::default(); let mut skills = Skills::default(); - let furnace = vec!["furnace".to_owned()]; - // Ore stays in its slot (3 - 2 = 1 left), so the plate needs a free slot. - inv.add("iron_ore", 3, &data); + let bench = vec!["workbench".to_owned()]; + // Logs stay in their slot (2 - 1 = 1 left), so the planks need a free slot. + inv.add("log", 2, &data); for _ in 1..game_core::components::INVENTORY_SLOTS { inv.add("pickaxe", 1, &data); } let before = inv.clone(); - let err = try_craft(&data, "smelt_iron", &furnace, &mut inv, &mut skills).unwrap_err(); + let err = try_craft(&data, "saw_planks", &bench, &mut inv, &mut skills).unwrap_err(); assert!(err.contains("full"), "{err}"); assert_eq!(inv, before); assert!(skills.skills.is_empty()); // Consuming a whole stack frees its slot for the output. - inv.remove("iron_ore", 1); - try_craft(&data, "smelt_iron", &furnace, &mut inv, &mut skills).unwrap(); - assert!(inv.has("iron_plate", 1)); + inv.remove("log", 1); + try_craft(&data, "saw_planks", &bench, &mut inv, &mut skills).unwrap(); + assert!(inv.has("plank", 4)); } #[test] diff --git a/crates/game_server/src/lib.rs b/crates/game_server/src/lib.rs index 6546ad3..8887380 100644 --- a/crates/game_server/src/lib.rs +++ b/crates/game_server/src/lib.rs @@ -6,6 +6,7 @@ pub mod actions; pub mod crafting; +pub mod machines; pub mod movement; pub mod network; pub mod persistence; @@ -59,7 +60,11 @@ impl Plugin for GameServerPlugin { .add_systems(Startup, (load_scripts, world::setup_world).chain()) .add_systems( FixedUpdate, - (movement::move_characters, survival::tick_survival), + ( + movement::move_characters, + survival::tick_survival, + machines::tick_machines, + ), ) .add_systems(Update, persistence::autosave) .add_systems(Last, persistence::save_on_exit) @@ -72,7 +77,9 @@ impl Plugin for GameServerPlugin { .add_observer(actions::on_interact) .add_observer(actions::on_craft) .add_observer(actions::on_construct) - .add_observer(actions::on_deconstruct); + .add_observer(actions::on_deconstruct) + .add_observer(machines::on_machine_insert) + .add_observer(machines::on_machine_take); } } diff --git a/crates/game_server/src/machines.rs b/crates/game_server/src/machines.rs new file mode 100644 index 0000000..a2d1307 --- /dev/null +++ b/crates/game_server/src/machines.rs @@ -0,0 +1,150 @@ +//! Machines: buildings with item slots that process their own recipes over time. + +use bevy::prelude::*; +use bevy_replicon::prelude::*; +use game_core::{GameData, components::*, protocol::*}; + +use crate::{ + actions::REACH, + crafting::item_name, + players::{Players, tell}, +}; + +/// Advances every machine: picks the first recipe it can process, counts up +/// its progress and turns inputs into outputs once the recipe time has passed. +pub fn tick_machines( + time: Res