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:
parent
0d025b824e
commit
3cf0561401
|
|
@ -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).
|
||||
|
|
|
|||
42
README.md
42
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<?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"/>
|
||||
<layer id="1" name="Ground" width="40" height="28">
|
||||
<data encoding="csv">
|
||||
|
|
@ -98,137 +98,143 @@
|
|||
</properties>
|
||||
<point/>
|
||||
</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>
|
||||
<property name="proto" value="vending_machine"/>
|
||||
<property name="stock" type="int" value="5"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="8" name="" class="building" x="176" y="560">
|
||||
<object id="9" name="" class="building" x="176" y="560">
|
||||
<properties>
|
||||
<property name="proto" value="table"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="9" name="" class="building" x="208" y="560">
|
||||
<object id="10" name="" class="building" x="208" y="560">
|
||||
<properties>
|
||||
<property name="proto" value="table"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="10" name="" class="item" x="208" y="592">
|
||||
<object id="11" name="" class="item" x="208" y="592">
|
||||
<properties>
|
||||
<property name="proto" value="bread"/>
|
||||
<property name="count" type="int" value="2"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="11" name="" class="item" x="688" y="336">
|
||||
<object id="12" name="" class="item" x="688" y="336">
|
||||
<properties>
|
||||
<property name="proto" value="screwdriver"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="12" name="" class="item" x="720" y="336">
|
||||
<object id="13" name="" class="item" x="720" y="336">
|
||||
<properties>
|
||||
<property name="proto" value="wrench"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="13" name="" class="item" x="752" y="336">
|
||||
<object id="14" name="" class="item" x="752" y="336">
|
||||
<properties>
|
||||
<property name="proto" value="pickaxe"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="14" name="" class="item" x="784" y="336">
|
||||
<object id="15" name="" class="item" x="784" y="336">
|
||||
<properties>
|
||||
<property name="proto" value="axe"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="15" name="" class="item" x="400" y="336">
|
||||
<object id="16" name="" class="item" x="400" y="336">
|
||||
<properties>
|
||||
<property name="proto" value="iron_ore"/>
|
||||
<property name="count" type="int" value="6"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="16" name="" class="item" x="464" y="208">
|
||||
<object id="17" name="" class="item" x="464" y="208">
|
||||
<properties>
|
||||
<property name="proto" value="medkit"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="17" name="" class="building" x="144" y="784">
|
||||
<object id="18" name="" class="building" x="144" y="784">
|
||||
<properties>
|
||||
<property name="proto" value="tree"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="18" name="" class="building" x="208" y="816">
|
||||
<object id="19" name="" class="building" x="208" y="816">
|
||||
<properties>
|
||||
<property name="proto" value="tree"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="19" name="" class="building" x="496" y="720">
|
||||
<object id="20" name="" class="building" x="496" y="720">
|
||||
<properties>
|
||||
<property name="proto" value="tree"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="20" name="" class="building" x="720" y="656">
|
||||
<object id="21" name="" class="building" x="720" y="656">
|
||||
<properties>
|
||||
<property name="proto" value="tree"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="21" name="" class="building" x="1072" y="176">
|
||||
<object id="22" name="" class="building" x="1072" y="176">
|
||||
<properties>
|
||||
<property name="proto" value="tree"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="22" name="" class="building" x="1104" y="304">
|
||||
<object id="23" name="" class="building" x="1104" y="304">
|
||||
<properties>
|
||||
<property name="proto" value="tree"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="23" name="" class="building" x="1008" y="464">
|
||||
<object id="24" name="" class="building" x="1008" y="464">
|
||||
<properties>
|
||||
<property name="proto" value="tree"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="24" name="" class="building" x="976" y="144">
|
||||
<object id="25" name="" class="building" x="976" y="144">
|
||||
<properties>
|
||||
<property name="proto" value="ore_rock"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="25" name="" class="building" x="1072" y="368">
|
||||
<object id="26" name="" class="building" x="1072" y="368">
|
||||
<properties>
|
||||
<property name="proto" value="ore_rock"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="26" name="" class="building" x="1136" y="496">
|
||||
<object id="27" name="" class="building" x="1136" y="496">
|
||||
<properties>
|
||||
<property name="proto" value="ore_rock"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="27" name="" class="item" x="816" y="720">
|
||||
<object id="28" name="" class="item" x="816" y="720">
|
||||
<properties>
|
||||
<property name="proto" value="apple"/>
|
||||
<property name="count" type="int" value="3"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="28" name="" class="item" x="464" y="816">
|
||||
<object id="29" name="" class="item" x="464" y="816">
|
||||
<properties>
|
||||
<property name="proto" value="log"/>
|
||||
<property name="count" type="int" value="4"/>
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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<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.
|
||||
#[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::<u32>(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_levels() {
|
||||
let mut skills = Skills::default();
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct DataFile {
|
||||
|
|
@ -181,6 +334,8 @@ pub struct DataFile {
|
|||
#[serde(default)]
|
||||
pub buildings: Vec<BuildingDef>,
|
||||
#[serde(default)]
|
||||
pub machines: Vec<MachineDef>,
|
||||
#[serde(default)]
|
||||
pub skills: Vec<SkillDef>,
|
||||
}
|
||||
|
||||
|
|
@ -203,6 +358,8 @@ pub struct GameData {
|
|||
pub items: BTreeMap<String, ItemDef>,
|
||||
pub recipes: BTreeMap<String, RecipeDef>,
|
||||
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>,
|
||||
}
|
||||
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GameLog {
|
||||
|
|
@ -77,6 +95,7 @@ impl Plugin for ProtocolPlugin {
|
|||
.replicate::<GroundItem>()
|
||||
.replicate::<Building>()
|
||||
.replicate::<BuildingState>()
|
||||
.replicate::<Machine>()
|
||||
.add_client_event::<Login>(Channel::Ordered)
|
||||
.add_client_event::<SetMoveDir>(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::<Construct>(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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Players>,
|
||||
data: Res<GameData>,
|
||||
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 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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,8 @@ pub struct SavedBuilding {
|
|||
pub proto: String,
|
||||
pub pos: GridPos,
|
||||
pub state: BuildingState,
|
||||
#[serde(default)]
|
||||
pub machine: Option<Machine>,
|
||||
}
|
||||
|
||||
#[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) {
|
||||
for b in save.buildings {
|
||||
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 {
|
||||
warn!(
|
||||
"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
|
||||
.iter(world)
|
||||
.map(|(b, pos, state)| SavedBuilding {
|
||||
.map(|(b, pos, state, machine)| SavedBuilding {
|
||||
proto: b.proto.clone(),
|
||||
pos: *pos,
|
||||
state: state.clone(),
|
||||
machine: machine.cloned(),
|
||||
})
|
||||
.collect();
|
||||
let mut q = world.query::<(&GroundItem, &GridPos)>();
|
||||
|
|
@ -219,11 +230,18 @@ mod tests {
|
|||
health: 100,
|
||||
vars: [("stock".to_owned(), 3)].into(),
|
||||
},
|
||||
machine: None,
|
||||
});
|
||||
save.buildings.push(SavedBuilding {
|
||||
proto: "wall".into(),
|
||||
pos: GridPos::new(0, 0),
|
||||
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 {
|
||||
stack: ItemStack::new("log", 2),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ use bevy::prelude::*;
|
|||
use bevy_replicon::prelude::*;
|
||||
use game_core::{
|
||||
GameData,
|
||||
components::{Building, BuildingState, Character, GridPos, GroundItem, ItemStack, WorldMap},
|
||||
components::{
|
||||
Building, BuildingState, Character, GridPos, GroundItem, ItemStack, Machine, WorldMap,
|
||||
},
|
||||
map::MapLayout,
|
||||
};
|
||||
|
||||
|
|
@ -115,7 +117,10 @@ pub fn spawn_building(
|
|||
vars: def.vars.clone(),
|
||||
};
|
||||
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(
|
||||
|
|
@ -123,17 +128,20 @@ pub fn spawn_building_with_state(
|
|||
proto: &str,
|
||||
pos: GridPos,
|
||||
state: BuildingState,
|
||||
machine: Option<Machine>,
|
||||
) -> Entity {
|
||||
commands
|
||||
.spawn((
|
||||
Replicated,
|
||||
Building {
|
||||
proto: proto.to_owned(),
|
||||
},
|
||||
pos,
|
||||
state,
|
||||
))
|
||||
.id()
|
||||
let mut entity = commands.spawn((
|
||||
Replicated,
|
||||
Building {
|
||||
proto: proto.to_owned(),
|
||||
},
|
||||
pos,
|
||||
state,
|
||||
));
|
||||
if let Some(machine) = machine {
|
||||
entity.insert(machine);
|
||||
}
|
||||
entity.id()
|
||||
}
|
||||
|
||||
pub fn spawn_ground_item(commands: &mut Commands, stack: ItemStack, pos: GridPos) -> Entity {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ fn login_move_and_interact() {
|
|||
|
||||
// The map defines buildings and items.
|
||||
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(
|
||||
&mut app,
|
||||
|
|
@ -173,9 +173,124 @@ fn login_move_and_interact() {
|
|||
send(
|
||||
&mut app,
|
||||
Craft {
|
||||
recipe: "smelt_iron".into(),
|
||||
recipe: "saw_planks".into(),
|
||||
},
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,6 +174,13 @@ def gen_sprites():
|
|||
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.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",
|
||||
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)),
|
||||
|
|
@ -198,6 +205,27 @@ def gen_sprites():
|
|||
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 ---------------------------------------------------------------------
|
||||
W, H = 40, 28
|
||||
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", 20, 5, proto="workbench")
|
||||
obj("building", 24, 5, proto="furnace")
|
||||
obj("building", 22, 5, proto="assembler")
|
||||
obj("building", 5, 5, proto="vending_machine", stock=5)
|
||||
obj("building", 5, 17, proto="table")
|
||||
obj("building", 6, 17, proto="table")
|
||||
|
|
@ -310,5 +339,6 @@ def gen_map():
|
|||
if __name__ == "__main__":
|
||||
gen_tileset()
|
||||
gen_sprites()
|
||||
gen_ui()
|
||||
gen_map()
|
||||
print("placeholder assets written to", ASSETS)
|
||||
|
|
|
|||
Loading…
Reference in New Issue