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.
|
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).
|
||||||
|
|
|
||||||
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.
|
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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// (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"),
|
||||||
|
|
|
||||||
|
|
@ -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"/>
|
||||||
|
|
|
||||||
|
|
@ -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 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();
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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),
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue