feat: add data-driven machines

Machines give a building input/output slots and their own timed recipes.
The server processes them, replicates their state and saves it with the
world. The furnace becomes a machine and an assembler is added as an
example with a custom window layout. The client window follows with the
new UI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-18 20:28:16 +02:00
parent 0d025b824e
commit 3cf0561401
22 changed files with 1033 additions and 85 deletions

View File

@ -20,5 +20,9 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
console window. console window.
- `cargo xtask pack` builds the client and server and packs them with their - `cargo xtask pack` builds the client and server and packs them with their
assets into a distributable archive; CI publishes these archives. 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 - GitHub `Package` workflow builds archives for Windows (zip), Linux and macOS
(tar.gz). (tar.gz).

View File

@ -4,7 +4,7 @@ A persistent-world, RPG-style station simulation inspired by Space Station 13, b
with [Bevy](https://bevy.org) in Rust. with [Bevy](https://bevy.org) in Rust.
- **ECS** – all game state lives in Bevy entities and components. - **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/`. [rsn](https://crates.io/crates/rsn) files under `assets/data/`.
- **Scriptable** – behaviour is written in [Rune](https://rune-rs.github.io) scripts - **Scriptable** – behaviour is written in [Rune](https://rune-rs.github.io) scripts
under `assets/scripts/`. under `assets/scripts/`.
@ -63,7 +63,8 @@ Characters are identified by name and are restored when that name logs in again.
## Modding ## Modding
All content is loaded from `assets/` at startup. Every `.rsn` file below `assets/data/` 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 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. 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 ```rsn
recipes: [ recipes: [
{ id: "smelt_iron", name: Some("Smelt iron plate"), { id: "make_pickaxe", inputs: [("iron_plate", 2), ("plank", 1)],
inputs: [("iron_ore", 2)], outputs: [("iron_plate", 1)], outputs: [("pickaxe", 1)], tools: ["wrench"], station: Some("workbench"),
tools: [], station: Some("furnace"), skill: Some({ skill: "smithing", level: 1 }), xp: 25 },
skill: Some({ skill: "smithing", level: 1 }), xp: 10 },
] ]
``` ```
@ -115,6 +115,36 @@ Required: `id`, `name`, `sprite`. Optional: `description`, `variants` (name →
(makes it buildable from the build menu), `deconstructible` (default true; refunds (makes it buildable from the build menu), `deconstructible` (default true; refunds
materials), `on_interact` (script hook), `vars` (integer state, persisted per building). 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 ### Skills
```rsn ```rsn

View File

@ -24,11 +24,17 @@ buildings: [
}, },
{ {
id: "furnace", name: "Furnace", sprite: "sprites/buildings/furnace.png", id: "furnace", name: "Furnace", sprite: "sprites/buildings/furnace.png",
description: "Smelts ore. Fuel it with logs.",
variants: { "lit": "sprites/buildings/furnace_lit.png" }, variants: { "lit": "sprites/buildings/furnace_lit.png" },
stations: ["furnace"],
construction: Some({ materials: [("iron_ore", 4), ("log", 1)], xp: 10, construction: Some({ materials: [("iron_ore", 4), ("log", 1)], xp: 10,
skill: Some({ skill: "construction", level: 1 }) }), 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", id: "table", name: "Table", sprite: "sprites/buildings/table.png",

54
assets/data/machines.rsn Normal file
View File

@ -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)),
},
},
]

View File

@ -4,15 +4,6 @@
// (tag provided by an adjacent building), skill ({ skill, level }), xp, on_craft. // (tag provided by an adjacent building), skill ({ skill, level }), xp, on_craft.
// Ingredients can be written as ("item", count) or { item: "item", count: n }. // Ingredients can be written as ("item", count) or { item: "item", count: n }.
recipes: [ 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", id: "saw_planks",
name: Some("Saw planks"), name: Some("Saw planks"),

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="40" height="28" tilewidth="32" tileheight="32" infinite="0" nextlayerid="4" nextobjectid="29"> <map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="40" height="28" tilewidth="32" tileheight="32" infinite="0" nextlayerid="4" nextobjectid="30">
<tileset firstgid="1" source="../tilesets/station.tsx"/> <tileset firstgid="1" source="../tilesets/station.tsx"/>
<layer id="1" name="Ground" width="40" height="28"> <layer id="1" name="Ground" width="40" height="28">
<data encoding="csv"> <data encoding="csv">
@ -98,137 +98,143 @@
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="7" name="" class="building" x="176" y="176"> <object id="7" name="" class="building" x="720" y="176">
<properties>
<property name="proto" value="assembler"/>
</properties>
<point/>
</object>
<object id="8" name="" class="building" x="176" y="176">
<properties> <properties>
<property name="proto" value="vending_machine"/> <property name="proto" value="vending_machine"/>
<property name="stock" type="int" value="5"/> <property name="stock" type="int" value="5"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="8" name="" class="building" x="176" y="560"> <object id="9" name="" class="building" x="176" y="560">
<properties> <properties>
<property name="proto" value="table"/> <property name="proto" value="table"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="9" name="" class="building" x="208" y="560"> <object id="10" name="" class="building" x="208" y="560">
<properties> <properties>
<property name="proto" value="table"/> <property name="proto" value="table"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="10" name="" class="item" x="208" y="592"> <object id="11" name="" class="item" x="208" y="592">
<properties> <properties>
<property name="proto" value="bread"/> <property name="proto" value="bread"/>
<property name="count" type="int" value="2"/> <property name="count" type="int" value="2"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="11" name="" class="item" x="688" y="336"> <object id="12" name="" class="item" x="688" y="336">
<properties> <properties>
<property name="proto" value="screwdriver"/> <property name="proto" value="screwdriver"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="12" name="" class="item" x="720" y="336"> <object id="13" name="" class="item" x="720" y="336">
<properties> <properties>
<property name="proto" value="wrench"/> <property name="proto" value="wrench"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="13" name="" class="item" x="752" y="336"> <object id="14" name="" class="item" x="752" y="336">
<properties> <properties>
<property name="proto" value="pickaxe"/> <property name="proto" value="pickaxe"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="14" name="" class="item" x="784" y="336"> <object id="15" name="" class="item" x="784" y="336">
<properties> <properties>
<property name="proto" value="axe"/> <property name="proto" value="axe"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="15" name="" class="item" x="400" y="336"> <object id="16" name="" class="item" x="400" y="336">
<properties> <properties>
<property name="proto" value="iron_ore"/> <property name="proto" value="iron_ore"/>
<property name="count" type="int" value="6"/> <property name="count" type="int" value="6"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="16" name="" class="item" x="464" y="208"> <object id="17" name="" class="item" x="464" y="208">
<properties> <properties>
<property name="proto" value="medkit"/> <property name="proto" value="medkit"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="17" name="" class="building" x="144" y="784"> <object id="18" name="" class="building" x="144" y="784">
<properties> <properties>
<property name="proto" value="tree"/> <property name="proto" value="tree"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="18" name="" class="building" x="208" y="816"> <object id="19" name="" class="building" x="208" y="816">
<properties> <properties>
<property name="proto" value="tree"/> <property name="proto" value="tree"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="19" name="" class="building" x="496" y="720"> <object id="20" name="" class="building" x="496" y="720">
<properties> <properties>
<property name="proto" value="tree"/> <property name="proto" value="tree"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="20" name="" class="building" x="720" y="656"> <object id="21" name="" class="building" x="720" y="656">
<properties> <properties>
<property name="proto" value="tree"/> <property name="proto" value="tree"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="21" name="" class="building" x="1072" y="176"> <object id="22" name="" class="building" x="1072" y="176">
<properties> <properties>
<property name="proto" value="tree"/> <property name="proto" value="tree"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="22" name="" class="building" x="1104" y="304"> <object id="23" name="" class="building" x="1104" y="304">
<properties> <properties>
<property name="proto" value="tree"/> <property name="proto" value="tree"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="23" name="" class="building" x="1008" y="464"> <object id="24" name="" class="building" x="1008" y="464">
<properties> <properties>
<property name="proto" value="tree"/> <property name="proto" value="tree"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="24" name="" class="building" x="976" y="144"> <object id="25" name="" class="building" x="976" y="144">
<properties> <properties>
<property name="proto" value="ore_rock"/> <property name="proto" value="ore_rock"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="25" name="" class="building" x="1072" y="368"> <object id="26" name="" class="building" x="1072" y="368">
<properties> <properties>
<property name="proto" value="ore_rock"/> <property name="proto" value="ore_rock"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="26" name="" class="building" x="1136" y="496"> <object id="27" name="" class="building" x="1136" y="496">
<properties> <properties>
<property name="proto" value="ore_rock"/> <property name="proto" value="ore_rock"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="27" name="" class="item" x="816" y="720"> <object id="28" name="" class="item" x="816" y="720">
<properties> <properties>
<property name="proto" value="apple"/> <property name="proto" value="apple"/>
<property name="count" type="int" value="3"/> <property name="count" type="int" value="3"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="28" name="" class="item" x="464" y="816"> <object id="29" name="" class="item" x="464" y="816">
<properties> <properties>
<property name="proto" value="log"/> <property name="proto" value="log"/>
<property name="count" type="int" value="4"/> <property name="count" type="int" value="4"/>

View File

@ -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.");
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -5,7 +5,10 @@ use std::collections::BTreeMap;
use bevy::prelude::*; use bevy::prelude::*;
use serde::{Deserialize, Serialize}; 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; pub const INVENTORY_SLOTS: usize = 20;
@ -217,6 +220,197 @@ pub struct BuildingState {
pub vars: BTreeMap<String, i64>, pub vars: BTreeMap<String, i64>,
} }
/// 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<Option<ItemStack>>,
pub outputs: Vec<Option<ItemStack>>,
/// Recipe currently being processed.
pub recipe: Option<String>,
/// 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<ItemStack> {
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<Item = &ItemStack> {
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<usize>, item: &str, count: u32, data: &GameData) -> u32 {
let max = data.max_stack(item);
let mut left = count;
let fill = |s: &mut Option<ItemStack>, 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<ItemStack> {
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. /// The Tiled map the world is built on. Path is relative to the assets directory.
#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorldMap { pub struct WorldMap {
@ -255,6 +449,45 @@ mod tests {
assert_eq!(inv.space_for("ore", &data), 1); 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::<u32>(), 3);
}
#[test] #[test]
fn skill_levels() { fn skill_levels() {
let mut skills = Skills::default(); let mut skills = Skills::default();

View File

@ -171,6 +171,159 @@ pub struct SkillDef {
pub name: String, 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<String>,
pub inputs: Vec<Ingredient>,
pub outputs: Vec<Ingredient>,
/// 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<String>,
/// 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<UiPoint>,
/// One position per output slot. Empty uses the default layout.
#[serde(default)]
pub outputs: Vec<UiPoint>,
/// 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<UiPoint>,
pub outputs: Vec<UiPoint>,
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<MachineRecipeDef>,
/// Building variant shown while the machine is working.
#[serde(default)]
pub active_variant: Option<String>,
#[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<UiPoint> {
(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. /// Root of a data file. All sections are optional.
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
pub struct DataFile { pub struct DataFile {
@ -181,6 +334,8 @@ pub struct DataFile {
#[serde(default)] #[serde(default)]
pub buildings: Vec<BuildingDef>, pub buildings: Vec<BuildingDef>,
#[serde(default)] #[serde(default)]
pub machines: Vec<MachineDef>,
#[serde(default)]
pub skills: Vec<SkillDef>, pub skills: Vec<SkillDef>,
} }
@ -203,6 +358,8 @@ pub struct GameData {
pub items: BTreeMap<String, ItemDef>, pub items: BTreeMap<String, ItemDef>,
pub recipes: BTreeMap<String, RecipeDef>, pub recipes: BTreeMap<String, RecipeDef>,
pub buildings: BTreeMap<String, BuildingDef>, pub buildings: BTreeMap<String, BuildingDef>,
/// Machines, keyed by the id of the building they belong to.
pub machines: BTreeMap<String, MachineDef>,
pub skills: BTreeMap<String, SkillDef>, pub skills: BTreeMap<String, SkillDef>,
} }
@ -296,6 +453,16 @@ impl GameData {
errors, errors,
); );
} }
for machine in file.machines {
insert(
&mut self.machines,
"machine",
machine.id.clone(),
machine,
path,
errors,
);
}
for skill in file.skills { for skill in file.skills {
insert( insert(
&mut self.skills, &mut self.skills,
@ -366,6 +533,70 @@ impl GameData {
check_skill(&mut errors, &ctx, &construction.skill); 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 errors
} }
@ -377,6 +608,11 @@ impl GameData {
.chain(self.buildings.values().flat_map(|b| { .chain(self.buildings.values().flat_map(|b| {
std::iter::once(b.sprite.as_str()).chain(b.variants.values().map(String::as_str)) 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 { pub fn max_stack(&self, item: &str) -> u32 {
@ -491,6 +727,51 @@ mod tests {
assert!(err.to_string().contains("line 1"), "{err}"); 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] #[test]
fn shipped_data_is_valid() { fn shipped_data_is_valid() {
let dir = crate::assets_dir().join("data"); let dir = crate::assets_dir().join("data");

View File

@ -16,7 +16,7 @@ pub use data::GameData;
pub const DEFAULT_MAP: &str = "maps/station.tmx"; pub const DEFAULT_MAP: &str = "maps/station.tmx";
pub const DEFAULT_PORT: u16 = 5000; pub const DEFAULT_PORT: u16 = 5000;
/// Bump when the protocol changes in incompatible ways. /// Bump when the protocol changes in incompatible ways.
pub const PROTOCOL_ID: u64 = 1; pub const PROTOCOL_ID: u64 = 2;
/// Locates the `assets` directory. /// Locates the `assets` directory.
/// ///

View File

@ -57,6 +57,24 @@ pub struct Deconstruct {
pub target: GridPos, 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<usize>,
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. /// Text shown in the receiving player's message log.
#[derive(Event, Clone, Debug, Serialize, Deserialize)] #[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct GameLog { pub struct GameLog {
@ -77,6 +95,7 @@ impl Plugin for ProtocolPlugin {
.replicate::<GroundItem>() .replicate::<GroundItem>()
.replicate::<Building>() .replicate::<Building>()
.replicate::<BuildingState>() .replicate::<BuildingState>()
.replicate::<Machine>()
.add_client_event::<Login>(Channel::Ordered) .add_client_event::<Login>(Channel::Ordered)
.add_client_event::<SetMoveDir>(Channel::Ordered) .add_client_event::<SetMoveDir>(Channel::Ordered)
.add_client_event::<PickUp>(Channel::Ordered) .add_client_event::<PickUp>(Channel::Ordered)
@ -86,6 +105,8 @@ impl Plugin for ProtocolPlugin {
.add_client_event::<Craft>(Channel::Ordered) .add_client_event::<Craft>(Channel::Ordered)
.add_client_event::<Construct>(Channel::Ordered) .add_client_event::<Construct>(Channel::Ordered)
.add_client_event::<Deconstruct>(Channel::Ordered) .add_client_event::<Deconstruct>(Channel::Ordered)
.add_client_event::<MachineInsert>(Channel::Ordered)
.add_client_event::<MachineTake>(Channel::Ordered)
.add_server_event::<GameLog>(Channel::Ordered); .add_server_event::<GameLog>(Channel::Ordered);
} }
} }

View File

@ -325,6 +325,10 @@ pub fn on_interact(
return; return;
}; };
let Some(hook) = &def.on_interact else { 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(); let mut text = def.name.clone();
if !def.description.is_empty() { if !def.description.is_empty() {
text = format!("{text}: {}", def.description); text = format!("{text}: {}", def.description);
@ -486,7 +490,7 @@ pub fn on_deconstruct(
players: Res<Players>, players: Res<Players>,
data: Res<GameData>, data: Res<GameData>,
mut characters: Characters, mut characters: Characters,
buildings: Query<(Entity, &Building, &GridPos), Without<Character>>, buildings: Query<(Entity, &Building, &GridPos, Option<&Machine>), Without<Character>>,
) { ) {
let client = ev.client_id; let client = ev.client_id;
let target = ev.message.target; let target = ev.message.target;
@ -501,7 +505,8 @@ pub fn on_deconstruct(
return; return;
} }
face_towards(&mut facing, *pos, target); 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 { else {
tell(&mut commands, client, "There is nothing to deconstruct."); tell(&mut commands, client, "There is nothing to deconstruct.");
return; 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(); commands.entity(building_entity).despawn();
tell( tell(
&mut commands, &mut commands,

View File

@ -203,22 +203,22 @@ mod tests {
let data = data(); let data = data();
let mut inv = Inventory::default(); let mut inv = Inventory::default();
let mut skills = Skills::default(); let mut skills = Skills::default();
let furnace = vec!["furnace".to_owned()]; let bench = vec!["workbench".to_owned()];
// Ore stays in its slot (3 - 2 = 1 left), so the plate needs a free slot. // Logs stay in their slot (2 - 1 = 1 left), so the planks need a free slot.
inv.add("iron_ore", 3, &data); inv.add("log", 2, &data);
for _ in 1..game_core::components::INVENTORY_SLOTS { for _ in 1..game_core::components::INVENTORY_SLOTS {
inv.add("pickaxe", 1, &data); inv.add("pickaxe", 1, &data);
} }
let before = inv.clone(); 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!(err.contains("full"), "{err}");
assert_eq!(inv, before); assert_eq!(inv, before);
assert!(skills.skills.is_empty()); assert!(skills.skills.is_empty());
// Consuming a whole stack frees its slot for the output. // Consuming a whole stack frees its slot for the output.
inv.remove("iron_ore", 1); inv.remove("log", 1);
try_craft(&data, "smelt_iron", &furnace, &mut inv, &mut skills).unwrap(); try_craft(&data, "saw_planks", &bench, &mut inv, &mut skills).unwrap();
assert!(inv.has("iron_plate", 1)); assert!(inv.has("plank", 4));
} }
#[test] #[test]

View File

@ -6,6 +6,7 @@
pub mod actions; pub mod actions;
pub mod crafting; pub mod crafting;
pub mod machines;
pub mod movement; pub mod movement;
pub mod network; pub mod network;
pub mod persistence; pub mod persistence;
@ -59,7 +60,11 @@ impl Plugin for GameServerPlugin {
.add_systems(Startup, (load_scripts, world::setup_world).chain()) .add_systems(Startup, (load_scripts, world::setup_world).chain())
.add_systems( .add_systems(
FixedUpdate, FixedUpdate,
(movement::move_characters, survival::tick_survival), (
movement::move_characters,
survival::tick_survival,
machines::tick_machines,
),
) )
.add_systems(Update, persistence::autosave) .add_systems(Update, persistence::autosave)
.add_systems(Last, persistence::save_on_exit) .add_systems(Last, persistence::save_on_exit)
@ -72,7 +77,9 @@ impl Plugin for GameServerPlugin {
.add_observer(actions::on_interact) .add_observer(actions::on_interact)
.add_observer(actions::on_craft) .add_observer(actions::on_craft)
.add_observer(actions::on_construct) .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);
} }
} }

View File

@ -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<Time>,
data: Res<GameData>,
mut machines: Query<(&Building, &mut Machine, &mut BuildingState)>,
) {
let dt = time.delta_secs();
for (building, mut machine, mut state) in &mut machines {
let Some(def) = data.machines.get(&building.proto) else {
continue;
};
let current = machine
.recipe
.as_deref()
.and_then(|id| def.recipe(id))
.filter(|r| machine.can_process(r, &data));
let recipe = current.or_else(|| def.recipes.iter().find(|r| machine.can_process(r, &data)));
match recipe {
None => {
if machine.recipe.is_some() || machine.progress != 0.0 {
machine.recipe = None;
machine.progress = 0.0;
}
}
Some(recipe) => {
if machine.recipe.as_deref() != Some(recipe.id.as_str()) {
machine.recipe = Some(recipe.id.clone());
machine.progress = 0.0;
}
machine.progress += dt;
if machine.progress >= recipe.time {
machine.complete(recipe, &data);
machine.progress = 0.0;
}
}
}
if let Some(active) = &def.active_variant {
let running = machine.recipe.is_some();
let shown = state.variant.as_ref() == Some(active);
if running != shown {
state.variant = running.then(|| active.clone());
}
}
}
}
type Actor<'a> = (&'a GridPos, &'a mut Inventory);
fn machine_in_reach<'a>(
pos: GridPos,
target: GridPos,
machines: &'a mut Query<(&GridPos, &Building, &mut Machine), Without<Character>>,
) -> Result<(&'a Building, Mut<'a, Machine>), &'static str> {
if pos.distance(target) > REACH {
return Err("That is too far away.");
}
machines
.iter_mut()
.find(|(p, _, _)| **p == target)
.map(|(_, b, m)| (b, m))
.ok_or("There is no machine there.")
}
pub fn on_machine_insert(
ev: On<FromClient<MachineInsert>>,
mut commands: Commands,
players: Res<Players>,
data: Res<GameData>,
mut characters: Query<Actor<'static>, With<Character>>,
mut machines: Query<(&GridPos, &Building, &mut Machine), Without<Character>>,
) {
let client = ev.client_id;
let msg = &ev.message;
let Some(&entity) = players.by_client.get(&client) else {
return;
};
let Ok((pos, mut inventory)) = characters.get_mut(entity) else {
return;
};
let (_, mut machine) = match machine_in_reach(*pos, msg.target, &mut machines) {
Ok(found) => found,
Err(reason) => return tell(&mut commands, client, reason),
};
let Some(stack) = inventory.slots.get(msg.inventory_slot).cloned() else {
return;
};
let wanted = msg.count.min(stack.count);
// Insert into a copy first so a full machine causes no change at all.
let mut updated = machine.clone();
let moved = updated.insert(msg.machine_slot, &stack.item, wanted, &data);
if moved == 0 {
tell(
&mut commands,
client,
format!("The {} doesn't fit.", item_name(&data, &stack.item)),
);
return;
}
inventory.take_from_slot(msg.inventory_slot, moved);
*machine = updated;
}
pub fn on_machine_take(
ev: On<FromClient<MachineTake>>,
mut commands: Commands,
players: Res<Players>,
data: Res<GameData>,
mut characters: Query<Actor<'static>, With<Character>>,
mut machines: Query<(&GridPos, &Building, &mut Machine), Without<Character>>,
) {
let client = ev.client_id;
let msg = &ev.message;
let Some(&entity) = players.by_client.get(&client) else {
return;
};
let Ok((pos, mut inventory)) = characters.get_mut(entity) else {
return;
};
let (_, mut machine) = match machine_in_reach(*pos, msg.target, &mut machines) {
Ok(found) => found,
Err(reason) => return tell(&mut commands, client, reason),
};
let Some(stack) = machine.slot(msg.slot) else {
return;
};
let count = msg
.count
.min(stack.count)
.min(inventory.space_for(&stack.item, &data));
if count == 0 {
tell(&mut commands, client, "Your inventory is full.");
return;
}
if let Some(taken) = machine.take(msg.slot, count) {
inventory.add(&taken.item, taken.count, &data);
}
}

View File

@ -35,6 +35,8 @@ pub struct SavedBuilding {
pub proto: String, pub proto: String,
pub pos: GridPos, pub pos: GridPos,
pub state: BuildingState, pub state: BuildingState,
#[serde(default)]
pub machine: Option<Machine>,
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -77,7 +79,15 @@ pub fn load(path: Option<&Path>) -> Option<SaveGame> {
pub fn restore(commands: &mut Commands, data: &GameData, save: SaveGame) { pub fn restore(commands: &mut Commands, data: &GameData, save: SaveGame) {
for b in save.buildings { for b in save.buildings {
if data.buildings.contains_key(&b.proto) { if data.buildings.contains_key(&b.proto) {
spawn_building_with_state(commands, &b.proto, b.pos, b.state); // Machine definitions may have changed since the save was written.
let machine = data.machines.get(&b.proto).map(|def| {
let mut machine = b.machine.unwrap_or_else(|| Machine::new(def));
for stack in machine.fit_to(def) {
spawn_ground_item(commands, stack, b.pos);
}
machine
});
spawn_building_with_state(commands, &b.proto, b.pos, b.state, machine);
} else { } else {
warn!( warn!(
"dropping saved building with removed prototype `{}`", "dropping saved building with removed prototype `{}`",
@ -125,13 +135,14 @@ pub fn snapshot(world: &mut World) -> SaveGame {
}, },
); );
} }
let mut q = world.query::<(&Building, &GridPos, &BuildingState)>(); let mut q = world.query::<(&Building, &GridPos, &BuildingState, Option<&Machine>)>();
let buildings = q let buildings = q
.iter(world) .iter(world)
.map(|(b, pos, state)| SavedBuilding { .map(|(b, pos, state, machine)| SavedBuilding {
proto: b.proto.clone(), proto: b.proto.clone(),
pos: *pos, pos: *pos,
state: state.clone(), state: state.clone(),
machine: machine.cloned(),
}) })
.collect(); .collect();
let mut q = world.query::<(&GroundItem, &GridPos)>(); let mut q = world.query::<(&GroundItem, &GridPos)>();
@ -219,11 +230,18 @@ mod tests {
health: 100, health: 100,
vars: [("stock".to_owned(), 3)].into(), vars: [("stock".to_owned(), 3)].into(),
}, },
machine: None,
}); });
save.buildings.push(SavedBuilding { save.buildings.push(SavedBuilding {
proto: "wall".into(), proto: "wall".into(),
pos: GridPos::new(0, 0), pos: GridPos::new(0, 0),
state: BuildingState::default(), state: BuildingState::default(),
machine: Some(Machine {
inputs: vec![Some(ItemStack::new("iron_ore", 3)), None],
outputs: vec![None],
recipe: Some("smelt_iron".into()),
progress: 1.5,
}),
}); });
save.items.push(SavedItem { save.items.push(SavedItem {
stack: ItemStack::new("log", 2), stack: ItemStack::new("log", 2),

View File

@ -6,7 +6,9 @@ use bevy::prelude::*;
use bevy_replicon::prelude::*; use bevy_replicon::prelude::*;
use game_core::{ use game_core::{
GameData, GameData,
components::{Building, BuildingState, Character, GridPos, GroundItem, ItemStack, WorldMap}, components::{
Building, BuildingState, Character, GridPos, GroundItem, ItemStack, Machine, WorldMap,
},
map::MapLayout, map::MapLayout,
}; };
@ -115,7 +117,10 @@ pub fn spawn_building(
vars: def.vars.clone(), vars: def.vars.clone(),
}; };
state.vars.extend(vars); state.vars.extend(vars);
Some(spawn_building_with_state(commands, proto, pos, state)) let machine = data.machines.get(proto).map(Machine::new);
Some(spawn_building_with_state(
commands, proto, pos, state, machine,
))
} }
pub fn spawn_building_with_state( pub fn spawn_building_with_state(
@ -123,17 +128,20 @@ pub fn spawn_building_with_state(
proto: &str, proto: &str,
pos: GridPos, pos: GridPos,
state: BuildingState, state: BuildingState,
machine: Option<Machine>,
) -> Entity { ) -> Entity {
commands let mut entity = commands.spawn((
.spawn((
Replicated, Replicated,
Building { Building {
proto: proto.to_owned(), proto: proto.to_owned(),
}, },
pos, pos,
state, state,
)) ));
.id() if let Some(machine) = machine {
entity.insert(machine);
}
entity.id()
} }
pub fn spawn_ground_item(commands: &mut Commands, stack: ItemStack, pos: GridPos) -> Entity { pub fn spawn_ground_item(commands: &mut Commands, stack: ItemStack, pos: GridPos) -> Entity {

View File

@ -57,7 +57,7 @@ fn login_move_and_interact() {
// The map defines buildings and items. // The map defines buildings and items.
let mut buildings = app.world_mut().query::<&Building>(); let mut buildings = app.world_mut().query::<&Building>();
assert_eq!(buildings.iter(app.world()).count(), 18); assert_eq!(buildings.iter(app.world()).count(), 19);
send( send(
&mut app, &mut app,
@ -173,9 +173,124 @@ fn login_move_and_interact() {
send( send(
&mut app, &mut app,
Craft { Craft {
recipe: "smelt_iron".into(), recipe: "saw_planks".into(),
}, },
); );
let log = take_log(&mut app); let log = take_log(&mut app);
assert!(log.iter().any(|t| t.contains("furnace")), "{log:?}"); assert!(log.iter().any(|t| t.contains("workbench")), "{log:?}");
}
#[test]
fn machines_process_items() {
let mut app = app();
send(
&mut app,
Login {
name: "Smith".into(),
},
);
let me = app
.world_mut()
.query_filtered::<Entity, With<Character>>()
.single(app.world())
.unwrap();
let pos = *app.world().get::<GridPos>(me).unwrap();
let target = GridPos::new(pos.x + 1, pos.y);
let data = app.world().resource::<game_core::GameData>().clone();
// Ignore anything the map placed there.
let mut q = app.world_mut().query::<(Entity, &GridPos)>();
let blocking: Vec<_> = q
.iter(app.world())
.filter(|(e, p)| **p == target && *e != me)
.map(|(e, _)| e)
.collect();
for e in blocking {
app.world_mut().despawn(e);
}
{
let mut inventory = app.world_mut().get_mut::<Inventory>(me).unwrap();
inventory.slots.clear();
inventory.add("iron_ore", 5, &data);
inventory.add("log", 1, &data);
inventory.add("plank", 1, &data);
inventory.add("screwdriver", 1, &data);
inventory.add("iron_plate", 4, &data);
}
send(
&mut app,
Construct {
building: "furnace".into(),
target,
},
);
let furnace = app
.world_mut()
.query::<(Entity, &Building, &GridPos)>()
.iter(app.world())
.find(|(_, b, p)| b.proto == "furnace" && **p == target)
.map(|(e, _, _)| e);
let log = take_log(&mut app);
let Some(furnace) = furnace else {
panic!("furnace not built: {log:?}");
};
assert!(app.world().get::<Machine>(furnace).is_some());
{
let mut inventory = app.world_mut().get_mut::<Inventory>(me).unwrap();
inventory.add("iron_ore", 4, &data);
inventory.add("log", 1, &data);
}
let slot_of = |app: &App, item: &str| {
app.world()
.get::<Inventory>(me)
.unwrap()
.slots
.iter()
.position(|s| s.item == item)
.unwrap()
};
for item in ["iron_ore", "log"] {
let slot = slot_of(&app, item);
send(
&mut app,
MachineInsert {
target,
inventory_slot: slot,
machine_slot: None,
count: u32::MAX,
},
);
}
// 1 ore left over from construction plus 4 added.
assert_eq!(count(&app, me, "iron_ore"), 0);
let machine = app.world().get::<Machine>(furnace).unwrap();
assert_eq!(machine.input_count("iron_ore"), 5);
assert_eq!(machine.recipe.as_deref(), Some("smelt_iron"));
let state = app.world().get::<BuildingState>(furnace).unwrap();
assert_eq!(state.variant.as_deref(), Some("lit"));
// 4 seconds of 50 ms steps.
for _ in 0..90 {
app.update();
}
let machine = app.world().get::<Machine>(furnace).unwrap().clone();
assert_eq!(machine.input_count("iron_ore"), 1, "{machine:?}");
assert_eq!(machine.outputs[0], Some(ItemStack::new("iron_plate", 2)));
assert_eq!(machine.recipe, None);
let state = app.world().get::<BuildingState>(furnace).unwrap();
assert_eq!(state.variant, None);
send(
&mut app,
MachineTake {
target,
slot: MachineSlot::Output(0),
count: u32::MAX,
},
);
assert_eq!(count(&app, me, "iron_plate"), 6);
// Taking a machine apart returns its contents.
send(&mut app, Deconstruct { target });
send(&mut app, PickUp { target });
assert_eq!(count(&app, me, "iron_ore"), 5);
} }

View File

@ -174,6 +174,13 @@ def gen_sprites():
sprite("sprites/buildings/furnace_lit.png", furnace_body, sprite("sprites/buildings/furnace_lit.png", furnace_body,
lambda d: d.rectangle([9, 15, 22, 25], fill=(255, 230, 80), outline=(255, 120, 0)), lambda d: d.rectangle([9, 15, 22, 25], fill=(255, 230, 80), outline=(255, 120, 0)),
lambda d: d.polygon([(11, 15), (16, 7), (20, 15)], fill=(255, 160, 40))) lambda d: d.polygon([(11, 15), (16, 7), (20, 15)], fill=(255, 160, 40)))
assembler_body = lambda d: d.rectangle([2, 4, 29, 29], fill=(70, 110, 120), outline=(30, 50, 60), width=2)
sprite("sprites/buildings/assembler.png", assembler_body,
lambda d: d.rectangle([8, 10, 23, 20], fill=(30, 40, 45)),
lambda d: d.rectangle([12, 23, 19, 26], fill=(40, 60, 60)))
sprite("sprites/buildings/assembler_active.png", assembler_body,
lambda d: d.rectangle([8, 10, 23, 20], fill=(120, 230, 170)),
lambda d: d.rectangle([12, 23, 19, 26], fill=(90, 255, 120)))
sprite("sprites/buildings/table.png", sprite("sprites/buildings/table.png",
lambda d: d.rectangle([2, 8, 29, 16], fill=(170, 170, 180), outline=(80, 80, 90)), lambda d: d.rectangle([2, 8, 29, 16], fill=(170, 170, 180), outline=(80, 80, 90)),
lambda d: d.rectangle([4, 16, 6, 28], fill=(120, 120, 130)), lambda d: d.rectangle([4, 16, 6, 28], fill=(120, 120, 130)),
@ -198,6 +205,27 @@ def gen_sprites():
lambda d: d.rectangle([18, 25, 22, 30], fill=(40, 40, 50))) lambda d: d.rectangle([18, 25, 22, 30], fill=(40, 40, 50)))
# --- UI ----------------------------------------------------------------------
def gen_ui():
# Assembler window background; slot frames match `ui` in assets/data/machines.rsn.
w, h, s = 260, 150, 40
img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle([0, 0, w - 1, h - 1], radius=10, fill=(38, 52, 60), outline=(90, 140, 150), width=2)
for i in range(0, w, 12):
d.line([i, 2, i, h - 3], fill=(44, 60, 68))
inputs = [(16, 14), (16, 96), (70, 55)]
output = (200, 55)
# Conveyor lines from the inputs to the output.
for x, y in inputs:
d.line([x + s // 2, y + s // 2, 110, 75], fill=(110, 160, 170), width=3)
d.line([110, 75, output[0], 75], fill=(110, 160, 170), width=3)
for x, y in inputs + [output]:
d.rounded_rectangle([x - 4, y - 4, x + s + 3, y + s + 3], radius=6, fill=(24, 32, 38), outline=(140, 200, 210), width=2)
d.polygon([(186, 66), (196, 75), (186, 84)], fill=(140, 200, 210))
save(img, "ui/machines/assembler.png")
# --- Map --------------------------------------------------------------------- # --- Map ---------------------------------------------------------------------
W, H = 40, 28 W, H = 40, 28
FLOOR, DARK, WALL, GRASS, WATER, PLATING, DIRT, WINDOW = range(len(TILES)) FLOOR, DARK, WALL, GRASS, WATER, PLATING, DIRT, WINDOW = range(len(TILES))
@ -268,6 +296,7 @@ def gen_map():
obj("building", 10, 3, proto="door") obj("building", 10, 3, proto="door")
obj("building", 20, 5, proto="workbench") obj("building", 20, 5, proto="workbench")
obj("building", 24, 5, proto="furnace") obj("building", 24, 5, proto="furnace")
obj("building", 22, 5, proto="assembler")
obj("building", 5, 5, proto="vending_machine", stock=5) obj("building", 5, 5, proto="vending_machine", stock=5)
obj("building", 5, 17, proto="table") obj("building", 5, 17, proto="table")
obj("building", 6, 17, proto="table") obj("building", 6, 17, proto="table")
@ -310,5 +339,6 @@ def gen_map():
if __name__ == "__main__": if __name__ == "__main__":
gen_tileset() gen_tileset()
gen_sprites() gen_sprites()
gen_ui()
gen_map() gen_map()
print("placeholder assets written to", ASSETS) print("placeholder assets written to", ASSETS)