feat: add persistent-world station game

Bevy ECS workspace with three crates:
- game_core: rsn data definitions, components, Tiled map loader, protocol
- game_server: authoritative simulation, Rune scripting, persistence,
  dedicated server binary
- game_client: rendering, UI and input; embeds the server for
  singleplayer and hosting

Items, recipes, buildings and skills are defined in assets/data, behavior
in Rune scripts under assets/scripts, and maps are made in Tiled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-18 19:44:13 +02:00
commit a43949be14
68 changed files with 12838 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/target
/saves
__pycache__/
/.NOCOMMIT

20
CHANGELOG.md Normal file
View File

@ -0,0 +1,20 @@
# Changelog
All notable changes to this project are documented in this file. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- Persistent-world station game with singleplayer, hosting (`--host`) and
joining (`--connect`), plus a dedicated `game-server`.
- Data-driven items, recipes, buildings and skills in `assets/data/*.rsn`.
- Rune scripting for item use, building interaction and crafting hooks.
- Tiled map support (solid tiles/layers; `spawn`, `building` and `item`
objects).
- Saving of the world and characters (autosave, and on exit, Ctrl-C or
SIGTERM).
- Crafting, construction/deconstruction, skills, hunger, health and respawning.
- Windows client builds for GitHub and Gitea CI; release builds run without a
console window.

6823
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

31
Cargo.toml Normal file
View File

@ -0,0 +1,31 @@
[workspace]
resolver = "3"
members = ["crates/game_core", "crates/game_server", "crates/game_client"]
default-members = ["crates/game_client"]
[workspace.package]
version = "0.1.0"
edition = "2024"
[workspace.dependencies]
bevy = { version = "0.19", default-features = false }
bevy_replicon = "0.44"
bevy_replicon_renet = "0.20"
serde = { version = "1", features = ["derive"] }
rsn = "0.2"
rune = "0.14"
tiled = "0.16"
thiserror = "2"
game_core = { path = "crates/game_core" }
game_server = { path = "crates/game_server" }
[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 3
[workspace.lints.clippy]
# Bevy system queries are naturally verbose.
type_complexity = "allow"
too_many_arguments = "allow"

187
README.md Normal file
View File

@ -0,0 +1,187 @@
# Station
A persistent-world, RPG-style station simulation inspired by Space Station 13, built
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
[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/`.
- **Tiled maps** – maps are regular [Tiled](https://www.mapeditor.org) `.tmx` files.
- **Persistent world** – no rounds. The world and every character (inventory, skills,
vitals, position) are saved and restored across restarts.
- **Singleplayer and multiplayer** – one authoritative server simulation, used locally
in singleplayer and over UDP in multiplayer
([bevy_replicon](https://crates.io/crates/bevy_replicon) + renet).
## Crates
| Crate | Purpose |
|----------------------|-------------------------------------------------------------------------|
| `crates/game_core` | Shared data definitions, components, Tiled loader, network protocol |
| `crates/game_server` | Authoritative simulation, Rune scripting, persistence, `game-server` binary |
| `crates/game_client` | Rendering, UI and input (`game` binary); embeds the server for singleplayer/host |
The client never changes game state directly. It sends requests (`Login`, `SetMoveDir`,
`Interact`, `Craft`, …) and the server replicates the resulting state back. In
singleplayer the same server systems run inside the client process, so singleplayer and
multiplayer behave identically.
## Running
```sh
cargo run # singleplayer (world: saves/singleplayer.rsn)
cargo run -- --host 5000 # singleplayer world that others can join
cargo run -- --connect 127.0.0.1:5000 # join a server or host
cargo run -p game_server --bin game-server # dedicated server (world: saves/server.rsn)
```
Client options: `--name NAME`, `--save FILE`, `--no-save`, `--map MAP`.
Server options: `--port PORT`, `--save FILE`, `--no-save`, `--map MAP`.
On Windows, release builds of the client open no console window, so command-line
output such as `--help` and logs isn't shown. Use a debug build to see it.
The world autosaves periodically and on exit (window close, Ctrl-C, SIGTERM).
Characters are identified by name and are restored when that name logs in again.
## Controls
| Key | Action |
|---------------------|------------------------------------------------------|
| WASD / arrows | Move (you face the direction you move) |
| E | Pick up (own tile, otherwise the faced tile) |
| F / left click | Interact with the faced / clicked building |
| U | Use the selected item (eat, heal, …) |
| G | Drop the selected item |
| X | Deconstruct the faced building |
| 1–9, 0, mouse wheel | Select an inventory slot |
| C / B | Crafting / build menu (W/S select, Enter confirm, Esc close) |
| H | Help |
## 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
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.
### Items
```rsn
items: [
{ id: "apple", name: "Apple", sprite: "sprites/items/apple.png", max_stack: 10,
tags: ["food"], on_use: Some("food::eat"), consume_on_use: true,
props: { "nutrition": 15 } },
]
```
Required: `id`, `name`, `sprite`. Optional: `description`, `max_stack` (default 50),
`tags`, `on_use` (script hook), `consume_on_use`, `props` (numbers readable by scripts).
### Recipes
```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 },
]
```
Required: `id`, `inputs`, `outputs`. Optional: `name`, `tools` (needed but not consumed),
`station` (a tag provided by a building adjacent to the player), `skill`, `xp`, `on_craft`
(script hook). Ingredients are written as `("item", count)` or `{ item: "item", count: n }`.
### Buildings
Anything placed on the grid: walls, doors, machines, trees, rocks.
```rsn
buildings: [
{ id: "door", name: "Door", sprite: "sprites/buildings/door_closed.png",
variants: { "open": "sprites/buildings/door_open.png" },
construction: Some({ materials: [("iron_plate", 2), ("plank", 1)],
tools: ["screwdriver"], xp: 10,
skill: Some({ skill: "construction", level: 1 }) }),
on_interact: Some("door::toggle") },
]
```
Required: `id`, `name`, `sprite`. Optional: `description`, `variants` (name → sprite),
`solid` (default true), `max_health`, `stations` (tags for recipes), `construction`
(makes it buildable from the build menu), `deconstructible` (default true; refunds
materials), `on_interact` (script hook), `vars` (integer state, persisted per building).
### Skills
```rsn
skills: [ { id: "smithing", name: "Smithing" } ]
```
### Scripts
Each `.rn` file in `assets/scripts/` is a module named after its path
(`scripts/door.rn` → `door`, `scripts/npc/guard.rn` → `npc::guard`). Data files refer to
public functions as `"module::function"`. Every hook receives one argument, `ctx`;
returning `false` signals failure (for example, a used item is then not consumed).
Script errors are logged and have no effect on the world.
```rune
// assets/scripts/door.rn
pub fn toggle(ctx) {
if ctx.is_solid() {
ctx.set_solid(false);
ctx.set_variant("open");
ctx.message("You open the door.");
} else {
ctx.set_solid(true);
ctx.set_variant("");
ctx.message("You close the door.");
}
}
```
`ctx` methods:
| Area | Methods |
|-----------|---------|
| Actor | `actor_name()`, `message(text)` |
| Inventory | `item_count(id)`, `has_item(id, n)`, `give_item(id, n)`, `take_item(id, n) -> bool`, `spawn_item(id, n)` (on the ground) |
| Vitals | `health()`, `max_health()`, `heal(n)`, `damage(n)`, `nutrition()`, `feed(n)` (integers) |
| Skills | `skill_level(id)`, `add_xp(id, n)` |
| Used item | `item_id()`, `item_prop(key)` |
| Building | `target_name()`, `get_var(key)`, `set_var(key, n)`, `is_solid()`, `set_solid(b)`, `variant()`, `set_variant(name)` (`""` = default sprite), `destroy_target()` |
| Misc | `random(max)` → `0..max` |
### Maps (Tiled)
Maps are orthogonal `.tmx` files with embedded or external tilesets. Each map cell is one
game tile (drawn at 32×32; 32 px tilesets look best).
- Tile layers are rendered in order. A tile is solid if the tile has the boolean custom
property `solid = true` in its tileset, or its layer has `solid = true`.
- Object layers spawn gameplay entities by object **class**:
- `spawn` – player spawn point.
- `building` – custom property `proto` = building id; any integer properties
override the building's `vars`.
- `item` – custom property `proto` = item id and optional `count`.
- Objects are placed on the tile containing their position (point objects work well).
A map is only used to create a new world; after that the save file is authoritative.
Delete the save file to start a fresh world.
`tools/gen_placeholder_assets.py` regenerates the placeholder sprites and example map.
## Development
```sh
cargo nextest run --workspace # unit tests + headless full-simulation test
cargo clippy --workspace --all-targets
```
Tests run with [cargo-nextest](https://nexte.st) (`cargo install cargo-nextest --locked`);
its configuration is in `.config/nextest.toml`. CI uses `--profile ci`, which writes a
JUnit report to `target/nextest/ci/junit.xml`.

56
assets/data/buildings.rsn Normal file
View File

@ -0,0 +1,56 @@
// Building prototypes (anything placed on the grid: walls, machines, trees, ...).
//
// Fields: id, name, sprite (required); description, variants (name -> sprite),
// solid (true), max_health (100), stations (tags for recipes), construction
// ({ materials, tools, skill, xp }), deconstructible (true), on_interact, vars.
buildings: [
{
id: "wall", name: "Wall", sprite: "sprites/buildings/wall.png",
max_health: 300,
construction: Some({ materials: [("iron_plate", 2)], tools: ["wrench"], xp: 5,
skill: Some({ skill: "construction", level: 1 }) }),
},
{
id: "door", name: "Door", sprite: "sprites/buildings/door_closed.png",
variants: { "open": "sprites/buildings/door_open.png" },
construction: Some({ materials: [("iron_plate", 2), ("plank", 1)], tools: ["screwdriver"], xp: 10,
skill: Some({ skill: "construction", level: 1 }) }),
on_interact: Some("door::toggle"),
},
{
id: "workbench", name: "Workbench", sprite: "sprites/buildings/workbench.png",
stations: ["workbench"],
construction: Some({ materials: [("plank", 4)], xp: 10, skill: Some({ skill: "carpentry", level: 1 }) }),
},
{
id: "furnace", name: "Furnace", sprite: "sprites/buildings/furnace.png",
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: "table", name: "Table", sprite: "sprites/buildings/table.png",
construction: Some({ materials: [("plank", 2)], skill: Some({ skill: "carpentry", level: 1 }), xp: 5 }),
},
{
id: "vending_machine", name: "Vending machine", sprite: "sprites/buildings/vending.png",
description: "Dispenses bread while stock lasts.",
deconstructible: false,
on_interact: Some("vending::dispense"),
vars: { "stock": 5 },
},
{
id: "tree", name: "Tree", sprite: "sprites/buildings/tree.png",
deconstructible: false,
on_interact: Some("gather::chop"),
vars: { "wood": 3 },
},
{
id: "ore_rock", name: "Ore rock", sprite: "sprites/buildings/ore_rock.png",
deconstructible: false,
on_interact: Some("gather::mine"),
vars: { "ore": 5 },
},
]

31
assets/data/items.rsn Normal file
View File

@ -0,0 +1,31 @@
// Item prototypes.
//
// Fields: id, name, sprite (required); description, max_stack (50), tags, on_use
// (script hook "module::function"), consume_on_use, props (numbers for scripts).
items: [
// Materials
{ id: "iron_ore", name: "Iron ore", sprite: "sprites/items/iron_ore.png",
description: "Rough ore. Smelt it in a furnace.", tags: ["ore"] },
{ id: "iron_plate", name: "Iron plate", sprite: "sprites/items/iron_plate.png",
description: "Standard building material.", tags: ["metal"] },
{ id: "log", name: "Log", sprite: "sprites/items/log.png", max_stack: 20, tags: ["wood"] },
{ id: "plank", name: "Plank", sprite: "sprites/items/plank.png", tags: ["wood"] },
// Tools
{ id: "screwdriver", name: "Screwdriver", sprite: "sprites/items/screwdriver.png", max_stack: 1, tags: ["tool"] },
{ id: "wrench", name: "Wrench", sprite: "sprites/items/wrench.png", max_stack: 1, tags: ["tool"] },
{ id: "pickaxe", name: "Pickaxe", sprite: "sprites/items/pickaxe.png", max_stack: 1, tags: ["tool"] },
{ id: "axe", name: "Axe", sprite: "sprites/items/axe.png", max_stack: 1, tags: ["tool"] },
// Food
{ id: "apple", name: "Apple", sprite: "sprites/items/apple.png", max_stack: 10, tags: ["food"],
on_use: Some("food::eat"), consume_on_use: true, props: { "nutrition": 15 } },
{ id: "bread", name: "Bread", sprite: "sprites/items/bread.png", max_stack: 10, tags: ["food"],
on_use: Some("food::eat"), consume_on_use: true, props: { "nutrition": 25 } },
{ id: "sandwich", name: "Sandwich", sprite: "sprites/items/sandwich.png", max_stack: 5, tags: ["food"],
on_use: Some("food::eat"), consume_on_use: true, props: { "nutrition": 50, "heal": 5 } },
// Medical
{ id: "medkit", name: "Medkit", sprite: "sprites/items/medkit.png", max_stack: 5, tags: ["medical"],
on_use: Some("medical::treat"), consume_on_use: true, props: { "heal": 40 } },
]

59
assets/data/recipes.rsn Normal file
View File

@ -0,0 +1,59 @@
// Crafting recipes.
//
// Fields: id, inputs, outputs (required); name, tools (not consumed), station
// (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"),
inputs: [("log", 1)],
outputs: [("plank", 4)],
station: Some("workbench"),
skill: Some({ skill: "carpentry", level: 1 }),
xp: 5,
},
{
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,
},
{
id: "make_axe",
inputs: [("iron_plate", 1), ("plank", 2)],
outputs: [("axe", 1)],
tools: ["wrench"],
station: Some("workbench"),
skill: Some({ skill: "carpentry", level: 1 }),
xp: 25,
},
{
id: "make_sandwich",
inputs: [("bread", 1), ("apple", 1)],
outputs: [("sandwich", 1)],
skill: Some({ skill: "cooking", level: 1 }),
xp: 15,
on_craft: Some("food::sandwich_crafted"),
},
{
id: "make_medkit",
inputs: [("iron_plate", 1), ("apple", 2)],
outputs: [("medkit", 1)],
station: Some("workbench"),
skill: Some({ skill: "medicine", level: 2 }),
xp: 30,
},
]

10
assets/data/skills.rsn Normal file
View File

@ -0,0 +1,10 @@
// Skills gain experience from crafting, construction and scripted actions.
skills: [
{ id: "smithing", name: "Smithing" },
{ id: "carpentry", name: "Carpentry" },
{ id: "cooking", name: "Cooking" },
{ id: "construction", name: "Construction" },
{ id: "mining", name: "Mining" },
{ id: "woodcutting", name: "Woodcutting" },
{ id: "medicine", name: "Medicine" },
]

239
assets/maps/station.tmx Normal file
View File

@ -0,0 +1,239 @@
<?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">
<tileset firstgid="1" source="../tilesets/station.tsx"/>
<layer id="1" name="Ground" width="40" height="28">
<data encoding="csv">
4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,
4,4,4,4,4,4,4,4,4,4,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,4,4,
4,4,4,4,4,4,4,4,4,4,7,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,6,6,6,6,6,6,6,6,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,6,6,6,6,6,6,6,6,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,6,6,6,6,6,6,6,6,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,6,6,6,6,6,6,6,6,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,6,6,6,6,6,6,6,6,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,6,6,6,6,6,6,6,6,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,6,6,6,6,6,6,6,6,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,6,6,6,6,6,6,6,6,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,6,6,6,6,6,6,6,6,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,6,6,6,6,6,6,6,6,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,6,6,6,6,6,6,6,6,6,6,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,6,6,6,6,6,6,6,6,6,6,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,5,5,5,5,5,5,4,4,4,7,4,4,
4,4,4,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,5,5,5,5,5,5,4,4,4,7,4,4,
4,4,4,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,5,5,5,5,5,5,4,4,4,7,4,4,
4,4,4,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,5,5,5,5,5,5,4,4,4,7,4,4,
4,4,4,4,4,4,4,4,4,4,7,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,5,5,5,5,5,5,4,4,4,7,4,4,
4,4,4,4,4,4,4,4,4,4,7,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,5,5,5,5,5,5,4,4,4,7,4,4,
4,4,4,4,4,4,4,4,4,4,7,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,4,4,4,4,4,4,4,7,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,7,4,4,
4,4,4,4,4,4,4,4,4,4,7,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,
4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4
</data>
</layer>
<layer id="2" name="Walls" width="40" height="28">
<data encoding="csv">
3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,3,3,8,8,3,3,0,3,3,3,8,3,3,3,3,3,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,3,3,3,3,0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,3,3,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3
</data>
</layer>
<objectgroup id="3" name="Objects">
<object id="1" name="arrivals" class="spawn" x="336" y="272"><point/>
</object>
<object id="2" name="" class="building" x="592" y="272">
<properties>
<property name="proto" value="door"/>
</properties>
<point/>
</object>
<object id="3" name="" class="building" x="272" y="464">
<properties>
<property name="proto" value="door"/>
</properties>
<point/>
</object>
<object id="4" name="" class="building" x="336" y="112">
<properties>
<property name="proto" value="door"/>
</properties>
<point/>
</object>
<object id="5" name="" class="building" x="656" y="176">
<properties>
<property name="proto" value="workbench"/>
</properties>
<point/>
</object>
<object id="6" name="" class="building" x="784" y="176">
<properties>
<property name="proto" value="furnace"/>
</properties>
<point/>
</object>
<object id="7" 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">
<properties>
<property name="proto" value="table"/>
</properties>
<point/>
</object>
<object id="9" 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">
<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">
<properties>
<property name="proto" value="screwdriver"/>
</properties>
<point/>
</object>
<object id="12" 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">
<properties>
<property name="proto" value="pickaxe"/>
</properties>
<point/>
</object>
<object id="14" 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">
<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">
<properties>
<property name="proto" value="medkit"/>
</properties>
<point/>
</object>
<object id="17" 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">
<properties>
<property name="proto" value="tree"/>
</properties>
<point/>
</object>
<object id="19" 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">
<properties>
<property name="proto" value="tree"/>
</properties>
<point/>
</object>
<object id="21" 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">
<properties>
<property name="proto" value="tree"/>
</properties>
<point/>
</object>
<object id="23" 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">
<properties>
<property name="proto" value="ore_rock"/>
</properties>
<point/>
</object>
<object id="25" 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">
<properties>
<property name="proto" value="ore_rock"/>
</properties>
<point/>
</object>
<object id="27" 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">
<properties>
<property name="proto" value="log"/>
<property name="count" type="int" value="4"/>
</properties>
<point/>
</object>
</objectgroup>
</map>

12
assets/scripts/door.rn Normal file
View File

@ -0,0 +1,12 @@
// Doors toggle between closed (solid) and open (passable).
pub fn toggle(ctx) {
if ctx.is_solid() {
ctx.set_solid(false);
ctx.set_variant("open");
ctx.message("You open the door.");
} else {
ctx.set_solid(true);
ctx.set_variant("");
ctx.message("You close the door.");
}
}

20
assets/scripts/food.rn Normal file
View File

@ -0,0 +1,20 @@
// Generic food: nutrition and healing come from the item's `props` in items.rsn.
pub fn eat(ctx) {
if ctx.nutrition() >= 100 {
ctx.message("You are too full to eat.");
return false;
}
ctx.feed(ctx.item_prop("nutrition"));
let heal = ctx.item_prop("heal");
if heal > 0 {
ctx.heal(heal);
}
ctx.message("Delicious.");
}
pub fn sandwich_crafted(ctx) {
if ctx.skill_level("cooking") >= 3 && ctx.random(4) == 0 {
ctx.give_item("sandwich", 1);
ctx.message("Your cooking experience pays off: you made an extra sandwich!");
}
}

14
assets/scripts/furnace.rn Normal file
View File

@ -0,0 +1,14 @@
// 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.");
}

31
assets/scripts/gather.rn Normal file
View File

@ -0,0 +1,31 @@
// Resource nodes: each node has a finite amount stored in a building variable.
fn harvest(ctx, tool, resource, item, skill, verb) {
if !ctx.has_item(tool, 1) {
ctx.message(`You need a ${tool} to ${verb} the ${ctx.target_name()}.`);
return false;
}
let left = ctx.get_var(resource);
// Higher skill levels sometimes yield a bonus.
let amount = 1;
if ctx.random(10) < ctx.skill_level(skill) {
amount = 2;
}
ctx.give_item(item, amount);
ctx.add_xp(skill, 10);
ctx.set_var(resource, left - 1);
if left - 1 <= 0 {
ctx.destroy_target();
ctx.message(`The ${ctx.target_name()} is depleted.`);
} else {
ctx.message(`You ${verb} the ${ctx.target_name()} and get ${amount} ${item}.`);
}
}
pub fn chop(ctx) {
harvest(ctx, "axe", "wood", "log", "woodcutting", "chop")
}
pub fn mine(ctx) {
harvest(ctx, "pickaxe", "ore", "iron_ore", "mining", "mine")
}

11
assets/scripts/medical.rn Normal file
View File

@ -0,0 +1,11 @@
// Medkits heal more with medicine skill.
pub fn treat(ctx) {
if ctx.health() >= ctx.max_health() {
ctx.message("You are not hurt.");
return false;
}
let bonus = (ctx.skill_level("medicine") - 1) * 5;
ctx.heal(ctx.item_prop("heal") + bonus);
ctx.add_xp("medicine", 15);
ctx.message("You patch yourself up.");
}

12
assets/scripts/vending.rn Normal file
View File

@ -0,0 +1,12 @@
// Free bread while stock lasts. Stock is a per-machine variable that is saved
// with the world, so every machine keeps its own count.
pub fn dispense(ctx) {
let stock = ctx.get_var("stock");
if stock <= 0 {
ctx.message("The vending machine is empty.");
return false;
}
ctx.set_var("stock", stock - 1);
ctx.give_item("bread", 1);
ctx.message(`The machine dispenses some bread. (${stock - 1} left)`);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 B

BIN
assets/tilesets/station.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 741 B

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<tileset version="1.10" tiledversion="1.10.2" name="station" tilewidth="32" tileheight="32" tilecount="8" columns="4">
<image source="station.png" width="128" height="64"/>
<tile id="0">
<properties><property name="name" value="floor"/></properties>
</tile>
<tile id="1">
<properties><property name="name" value="dark_floor"/></properties>
</tile>
<tile id="2">
<properties><property name="name" value="wall"/><property name="solid" type="bool" value="true"/></properties>
</tile>
<tile id="3">
<properties><property name="name" value="grass"/></properties>
</tile>
<tile id="4">
<properties><property name="name" value="water"/><property name="solid" type="bool" value="true"/></properties>
</tile>
<tile id="5">
<properties><property name="name" value="plating"/></properties>
</tile>
<tile id="6">
<properties><property name="name" value="dirt"/></properties>
</tile>
<tile id="7">
<properties><property name="name" value="window"/><property name="solid" type="bool" value="true"/></properties>
</tile>
</tileset>

View File

@ -0,0 +1,18 @@
[package]
name = "game_client"
version.workspace = true
edition.workspace = true
[[bin]]
name = "game"
path = "src/main.rs"
[dependencies]
game_core = { workspace = true }
game_server = { workspace = true }
bevy = { workspace = true, default-features = true }
bevy_replicon = { workspace = true }
bevy_replicon_renet = { workspace = true }
[lints]
workspace = true

View File

@ -0,0 +1,235 @@
//! Keyboard and mouse input, translated into protocol events.
use bevy::{input::mouse::MouseWheel, prelude::*, window::PrimaryWindow};
use bevy_replicon::prelude::*;
use game_core::{
GameData,
components::*,
grid::{Dir, TILE_SIZE},
protocol::*,
};
use game_server::actions::REACH;
use crate::{
render::LocalCharacter,
ui::{Menu, UiState, menu_entries},
};
pub struct InputPlugin;
impl Plugin for InputPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, (movement, menu_input, actions, mouse).chain());
}
}
const MOVE_KEYS: [(KeyCode, Dir); 8] = [
(KeyCode::KeyW, Dir::North),
(KeyCode::ArrowUp, Dir::North),
(KeyCode::KeyS, Dir::South),
(KeyCode::ArrowDown, Dir::South),
(KeyCode::KeyA, Dir::West),
(KeyCode::ArrowLeft, Dir::West),
(KeyCode::KeyD, Dir::East),
(KeyCode::ArrowRight, Dir::East),
];
/// Sends the held direction whenever it changes. The most recently pressed key wins.
fn movement(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
ui: Res<UiState>,
mut held: Local<Vec<Dir>>,
mut sent: Local<Option<Dir>>,
) {
held.retain(|dir| {
MOVE_KEYS
.iter()
.any(|(key, d)| d == dir && keys.pressed(*key))
});
for (key, dir) in MOVE_KEYS {
if keys.just_pressed(key) && !held.contains(&dir) {
held.push(dir);
}
}
let dir = if ui.menu.is_some_and(|m| m != Menu::Help) {
None
} else {
held.last().copied()
};
if *sent != dir {
*sent = dir;
commands.client_trigger(SetMoveDir(dir));
}
}
fn local_state(
local: &LocalCharacter,
characters: &Query<(&GridPos, &Facing, &Inventory), With<Character>>,
) -> Option<(GridPos, Dir, usize)> {
let (pos, facing, inventory) = characters.get(local.0?).ok()?;
Some((*pos, facing.0, inventory.slots.len()))
}
fn menu_input(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut ui: ResMut<UiState>,
data: Res<GameData>,
local: Res<LocalCharacter>,
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
) {
let Some(menu) = ui.menu else { return };
if keys.just_pressed(KeyCode::Escape) {
ui.menu = None;
return;
}
let entries = menu_entries(menu, &data);
if entries.is_empty() {
return;
}
if keys.any_just_pressed([KeyCode::KeyW, KeyCode::ArrowUp]) {
ui.cursor = (ui.cursor + entries.len() - 1) % entries.len();
}
if keys.any_just_pressed([KeyCode::KeyS, KeyCode::ArrowDown]) {
ui.cursor = (ui.cursor + 1) % entries.len();
}
if keys.any_just_pressed([KeyCode::Enter, KeyCode::Space, KeyCode::NumpadEnter]) {
let id = entries[ui.cursor.min(entries.len() - 1)].clone();
match menu {
Menu::Craft => commands.client_trigger(Craft { recipe: id }),
Menu::Build => {
if let Some((pos, facing, _)) = local_state(&local, &characters) {
commands.client_trigger(Construct {
building: id,
target: pos.step(facing),
});
}
}
Menu::Help => {}
}
}
}
const SLOT_KEYS: [KeyCode; 10] = [
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
KeyCode::Digit6,
KeyCode::Digit7,
KeyCode::Digit8,
KeyCode::Digit9,
KeyCode::Digit0,
];
#[allow(clippy::too_many_arguments)]
fn actions(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut wheel: MessageReader<MouseWheel>,
mut ui: ResMut<UiState>,
local: Res<LocalCharacter>,
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
items: Query<&GridPos, With<GroundItem>>,
) {
if keys.just_pressed(KeyCode::KeyH) {
ui.toggle(Menu::Help);
}
if keys.just_pressed(KeyCode::KeyC) {
ui.toggle(Menu::Craft);
}
if keys.just_pressed(KeyCode::KeyB) {
ui.toggle(Menu::Build);
}
let Some((pos, facing, slots)) = local_state(&local, &characters) else {
return;
};
for (i, key) in SLOT_KEYS.iter().enumerate() {
if keys.just_pressed(*key) {
ui.selected_slot = i;
}
}
for ev in wheel.read() {
let len = INVENTORY_SLOTS;
ui.selected_slot = if ev.y > 0.0 {
(ui.selected_slot + len - 1) % len
} else {
(ui.selected_slot + 1) % len
};
}
if slots > 0
&& ui.selected_slot >= slots
&& keys.any_just_pressed([KeyCode::KeyU, KeyCode::KeyG])
{
ui.selected_slot = slots - 1;
}
if ui.menu.is_some_and(|m| m != Menu::Help) {
return;
}
let front = pos.step(facing);
if keys.just_pressed(KeyCode::KeyE) {
let target = if items.iter().any(|p| *p == pos) {
pos
} else {
front
};
commands.client_trigger(PickUp { target });
}
if keys.just_pressed(KeyCode::KeyF) {
commands.client_trigger(Interact { target: front });
}
if keys.just_pressed(KeyCode::KeyX) {
commands.client_trigger(Deconstruct { target: front });
}
if keys.just_pressed(KeyCode::KeyU) {
commands.client_trigger(UseItem {
slot: ui.selected_slot,
});
}
if keys.just_pressed(KeyCode::KeyG) {
commands.client_trigger(DropItem {
slot: ui.selected_slot,
});
}
}
/// Left click on a tile within reach: interact with a building, otherwise pick up items.
fn mouse(
mut commands: Commands,
buttons: Res<ButtonInput<MouseButton>>,
window: Query<&Window, With<PrimaryWindow>>,
camera: Query<(&Camera, &GlobalTransform)>,
local: Res<LocalCharacter>,
characters: Query<&GridPos, With<Character>>,
buildings: Query<&GridPos, With<Building>>,
) {
if !buttons.just_pressed(MouseButton::Left) {
return;
}
let Some(me) = local.0.and_then(|e| characters.get(e).ok()) else {
return;
};
let (Ok(window), Ok((camera, camera_transform))) = (window.single(), camera.single()) else {
return;
};
let Some(cursor) = window.cursor_position() else {
return;
};
let Ok(world) = camera.viewport_to_world_2d(camera_transform, cursor) else {
return;
};
let target = GridPos::new(
(world.x / TILE_SIZE).floor() as i32,
(-world.y / TILE_SIZE).floor() as i32,
);
if me.distance(target) > REACH {
return;
}
if buildings.iter().any(|p| *p == target) {
commands.client_trigger(Interact { target });
} else {
commands.client_trigger(PickUp { target });
}
}

View File

@ -0,0 +1,166 @@
//! Game client.
//!
//! ```text
//! game singleplayer (persistent local world)
//! game --host [PORT] singleplayer world that friends can join
//! game --connect ADDR[:PORT] join a dedicated server or host
//! ```
//! Common options: `--name NAME`, `--save FILE`, `--map MAP`.
// Release builds on Windows run without a console window.
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
mod input;
mod net;
mod render;
mod ui;
use std::{net::SocketAddr, path::PathBuf};
use bevy::{prelude::*, window::WindowResolution};
use bevy_replicon::prelude::*;
use bevy_replicon_renet::RepliconRenetPlugins;
use game_core::CorePlugin;
use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings};
/// How this client runs.
#[derive(Clone, Debug)]
pub enum Mode {
/// Server logic runs in-process without networking.
Singleplayer,
/// Server logic runs in-process and accepts remote players.
Host { port: u16 },
/// Pure client connected to a remote server.
Join { addr: SocketAddr },
}
/// The local player's login name, used to find our character among replicated ones.
#[derive(Resource, Clone, Debug)]
pub struct LocalPlayer {
pub name: String,
}
#[derive(Resource, Clone, Debug)]
pub struct ClientMode(pub Mode);
fn main() -> AppExit {
let (mode, name, settings) = parse_args();
let mut app = App::new();
let assets = game_core::assets_dir();
app.add_plugins((
DefaultPlugins
.set(AssetPlugin {
file_path: assets.to_string_lossy().into_owned(),
..default()
})
.set(ImagePlugin::default_nearest())
.set(WindowPlugin {
primary_window: Some(Window {
title: format!("Station - {name}"),
resolution: WindowResolution::new(1280, 800),
..default()
}),
..default()
}),
RepliconPlugins,
RepliconRenetPlugins,
CorePlugin,
));
match &mode {
Mode::Singleplayer => {
app.add_plugins(GameServerPlugin { settings });
}
Mode::Host { port } => {
app.add_plugins((
GameServerPlugin { settings },
NetworkServerPlugin { port: *port },
));
}
Mode::Join { .. } => {}
}
app.insert_resource(LocalPlayer { name })
.insert_resource(ClientMode(mode))
.insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.05)))
.add_plugins((
net::NetPlugin,
render::RenderPlugin,
input::InputPlugin,
ui::UiPlugin,
))
.run()
}
fn parse_args() -> (Mode, String, ServerSettings) {
let mut mode = Mode::Singleplayer;
let mut name = std::env::var("USER")
.ok()
.filter(|n| game_server::players::valid_name(n))
.unwrap_or_else(|| "Player".to_owned());
let mut settings = ServerSettings {
save_path: Some(PathBuf::from("saves/singleplayer.rsn")),
..default()
};
let mut args = std::env::args().skip(1).peekable();
while let Some(arg) = args.next() {
let mut value = |what: &str| {
args.next()
.unwrap_or_else(|| usage(&format!("{arg} needs {what}")))
};
match arg.as_str() {
"--host" => {
let port = match args.peek() {
Some(p) if !p.starts_with("--") => args
.next()
.unwrap()
.parse()
.unwrap_or_else(|_| usage("invalid port")),
_ => game_core::DEFAULT_PORT,
};
mode = Mode::Host { port };
}
"--connect" => {
let addr = value("an address");
let addr = if addr.contains(':') {
addr
} else {
format!("{addr}:{}", game_core::DEFAULT_PORT)
};
let addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
.ok()
.and_then(|mut a| a.next())
.unwrap_or_else(|| usage(&format!("can't resolve `{addr}`")));
mode = Mode::Join { addr };
}
"--name" => name = value("a name"),
"--save" => settings.save_path = Some(value("a file").into()),
"--no-save" => settings.save_path = None,
"--map" => settings.map = value("a map path"),
"-h" | "--help" => usage(""),
other => usage(&format!("unknown argument `{other}`")),
}
}
if !game_server::players::valid_name(&name) {
usage("names may only contain letters, digits, spaces, `_` and `-` (max 24)");
}
(mode, name, settings)
}
fn usage(error: &str) -> ! {
if !error.is_empty() {
eprintln!("error: {error}\n");
}
eprintln!(
"usage: game [--host [PORT] | --connect ADDR[:PORT]] [--name NAME] [--save FILE | --no-save] [--map MAP]\n\n\
(no mode) singleplayer, world saved to saves/singleplayer.rsn\n\
--host play your world and let others join (default port {port})\n\
--connect join a server (default port {port})\n\
--name character name; characters persist per world\n\
--save world file for singleplayer/host\n\
--map Tiled map for new worlds (default {map})",
port = game_core::DEFAULT_PORT,
map = game_core::DEFAULT_MAP,
);
std::process::exit(if error.is_empty() { 0 } else { 2 })
}

View File

@ -0,0 +1,89 @@
//! Connecting to servers and logging in.
use std::{
net::{Ipv4Addr, UdpSocket},
time::SystemTime,
};
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use bevy_replicon_renet::{
RenetChannelsExt, RenetClient,
netcode::{ClientAuthentication, NetcodeClientTransport},
renet::ConnectionConfig,
};
use game_core::protocol::Login;
use crate::{ClientMode, LocalPlayer, Mode, ui::GameLogBuffer};
pub struct NetPlugin;
impl Plugin for NetPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, connect)
// Local modes: the server systems are in this app, log in immediately.
.add_systems(PostStartup, login.run_if(is_local))
.add_systems(OnEnter(ClientState::Connected), (login, connected))
.add_systems(OnExit(ClientState::Connected), disconnected);
}
}
fn is_local(mode: Res<ClientMode>) -> bool {
!matches!(mode.0, Mode::Join { .. })
}
fn connect(
mut commands: Commands,
mode: Res<ClientMode>,
channels: Res<RepliconChannels>,
mut log: ResMut<GameLogBuffer>,
) {
let Mode::Join { addr } = mode.0 else { return };
let result = (|| -> Result<_, Box<dyn std::error::Error>> {
let client = RenetClient::new(ConnectionConfig {
server_channels_config: channels.server_configs(),
client_channels_config: channels.client_configs(),
..Default::default()
});
let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
let authentication = ClientAuthentication::Unsecure {
client_id: current_time.as_millis() as u64,
protocol_id: game_core::PROTOCOL_ID,
server_addr: addr,
user_data: None,
};
let transport = NetcodeClientTransport::new(current_time, authentication, socket)?;
Ok((client, transport))
})();
match result {
Ok((client, transport)) => {
info!("connecting to {addr}");
log.push(format!("Connecting to {addr}..."));
commands.insert_resource(client);
commands.insert_resource(transport);
}
Err(e) => {
error!("failed to connect: {e}");
log.push(format!("Failed to connect: {e}"));
}
}
}
fn login(mut commands: Commands, player: Res<LocalPlayer>) {
commands.client_trigger(Login {
name: player.name.clone(),
});
}
fn connected(mut log: ResMut<GameLogBuffer>) {
log.push("Connected.".into());
}
fn disconnected(mut log: ResMut<GameLogBuffer>, transport: Option<Res<NetcodeClientTransport>>) {
let reason = transport
.and_then(|t| t.disconnect_reason())
.map(|r| format!(" ({r:?})"))
.unwrap_or_default();
log.push(format!("Disconnected from server{reason}."));
}

View File

@ -0,0 +1,297 @@
//! World rendering: Tiled map layers, sprites for replicated entities and the camera.
use bevy::prelude::*;
use game_core::{
GameData,
components::*,
grid::{Dir, TILE_SIZE, grid_to_world},
map::MapLayout,
};
use crate::LocalPlayer;
pub struct RenderPlugin;
impl Plugin for RenderPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<LocalCharacter>()
.add_systems(Startup, setup)
.add_observer(spawn_map)
.add_systems(
Update,
(
find_local_character,
building_visuals,
item_visuals,
character_visuals,
move_characters,
update_marker,
follow_camera,
)
.chain(),
);
}
}
const Z_BUILDING: f32 = 1.0;
const Z_ITEM: f32 = 2.0;
const Z_CHARACTER: f32 = 3.0;
const Z_MARKER: f32 = 4.0;
/// Our own character, once it has been replicated.
#[derive(Resource, Default)]
pub struct LocalCharacter(pub Option<Entity>);
/// Highlights the tile in front of the player (target of interactions).
#[derive(Component)]
struct FacingMarker;
fn setup(mut commands: Commands) {
commands.spawn(Camera2d);
commands.spawn((
FacingMarker,
Sprite::from_color(Color::srgba(1.0, 0.9, 0.2, 0.18), Vec2::splat(TILE_SIZE)),
Transform::from_xyz(0.0, 0.0, Z_MARKER),
Visibility::Hidden,
));
}
/// Builds sprites for every tile when the world map arrives. Tiles are children
/// of the map entity, so they disappear with it (e.g. on disconnect).
fn spawn_map(
add: On<Add, WorldMap>,
mut commands: Commands,
maps: Query<&WorldMap>,
asset_server: Res<AssetServer>,
mut layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
let Ok(map) = maps.get(add.entity) else {
return;
};
let layout = match MapLayout::load(&game_core::assets_dir(), &map.path) {
Ok(layout) => layout,
Err(e) => {
error!("can't display map: {e}");
return;
}
};
let atlases: Vec<_> = layout
.tilesets
.iter()
.map(|ts| {
let rows = ts.tile_count.div_ceil(ts.columns.max(1));
let atlas = TextureAtlasLayout::from_grid(
UVec2::new(ts.tile_width, ts.tile_height),
ts.columns,
rows,
Some(UVec2::splat(ts.spacing)),
Some(UVec2::splat(ts.margin)),
);
(asset_server.load(ts.image.clone()), layouts.add(atlas))
})
.collect();
commands
.entity(add.entity)
.insert((Transform::default(), Visibility::default()));
for (layer_index, layer) in layout.layers.iter().enumerate() {
let z = layer_index as f32 * 0.01;
for (i, cell) in layer.cells.iter().enumerate() {
let Some(cell) = cell else { continue };
let Some((image, atlas)) = atlases.get(cell.tileset) else {
continue;
};
let pos = GridPos::new(
(i as u32 % layout.width) as i32,
(i as u32 / layout.width) as i32,
);
commands.spawn((
ChildOf(add.entity),
Sprite {
image: image.clone(),
texture_atlas: Some(TextureAtlas {
layout: atlas.clone(),
index: cell.id as usize,
}),
flip_x: cell.flip_h,
flip_y: cell.flip_v,
custom_size: Some(Vec2::splat(TILE_SIZE)),
..default()
},
Transform::from_translation(grid_to_world(pos).extend(z)),
));
}
}
info!(
"map `{}` displayed ({}x{})",
map.path, layout.width, layout.height
);
}
fn find_local_character(
mut local: ResMut<LocalCharacter>,
player: Res<LocalPlayer>,
characters: Query<(Entity, &Character)>,
) {
if local.0.is_some_and(|e| characters.contains(e)) {
return;
}
local.0 = characters
.iter()
.find(|(_, c)| c.name == player.name)
.map(|(e, _)| e);
}
fn sprite(asset_server: &AssetServer, path: &str, size: f32) -> Sprite {
Sprite {
image: asset_server.load(path.to_owned()),
custom_size: Some(Vec2::splat(size)),
..default()
}
}
fn building_visuals(
mut commands: Commands,
data: Res<GameData>,
asset_server: Res<AssetServer>,
buildings: Query<
(Entity, &Building, &BuildingState, &GridPos, Has<Transform>),
Or<(Changed<BuildingState>, Changed<GridPos>, Without<Sprite>)>,
>,
) {
for (entity, building, state, pos, has_transform) in &buildings {
let Some(def) = data.buildings.get(&building.proto) else {
continue;
};
let mut entity = commands.entity(entity);
entity.insert(sprite(
&asset_server,
def.sprite_for(state.variant.as_deref()),
TILE_SIZE,
));
let transform = Transform::from_translation(grid_to_world(*pos).extend(Z_BUILDING));
if has_transform {
entity.insert(transform);
} else {
entity.insert((transform, Visibility::default()));
}
}
}
#[derive(Component)]
struct StackLabel;
fn item_visuals(
mut commands: Commands,
data: Res<GameData>,
asset_server: Res<AssetServer>,
items: Query<(Entity, &GroundItem, &GridPos), Or<(Changed<GroundItem>, Changed<GridPos>)>>,
children: Query<&Children>,
labels: Query<(), With<StackLabel>>,
) {
for (entity, item, pos) in &items {
let Some(def) = data.items.get(&item.0.item) else {
continue;
};
for child in children.get(entity).into_iter().flatten() {
if labels.contains(*child) {
commands.entity(*child).despawn();
}
}
commands.entity(entity).insert((
sprite(&asset_server, &def.sprite, TILE_SIZE * 0.75),
Transform::from_translation(grid_to_world(*pos).extend(Z_ITEM)),
Visibility::default(),
));
if item.0.count > 1 {
commands.spawn((
ChildOf(entity),
StackLabel,
Text2d::new(item.0.count.to_string()),
TextFont::from_font_size(10.0),
Transform::from_xyz(9.0, -9.0, 0.1),
));
}
}
}
fn character_visuals(
mut commands: Commands,
asset_server: Res<AssetServer>,
local: Res<LocalCharacter>,
added: Query<(Entity, &Character, &GridPos), Added<Character>>,
mut facing: Query<(&Facing, &mut Sprite), (With<Character>, Changed<Facing>)>,
) {
for (entity, character, pos) in &added {
let mut sprite = sprite(&asset_server, "sprites/characters/player.png", TILE_SIZE);
if local.0 != Some(entity) {
sprite.color = Color::srgb(0.8, 0.9, 1.0);
}
commands.entity(entity).insert((
sprite,
Transform::from_translation(grid_to_world(*pos).extend(Z_CHARACTER)),
Visibility::default(),
children![(
Text2d::new(character.name.clone()),
TextFont::from_font_size(11.0),
Transform::from_xyz(0.0, TILE_SIZE * 0.7, 0.1),
)],
));
}
for (facing, mut sprite) in &mut facing {
sprite.flip_x = facing.0 == Dir::West;
}
}
/// Glides characters between tiles; teleports for large jumps (respawn, login).
fn move_characters(
time: Res<Time>,
mut characters: Query<(&GridPos, &mut Transform), With<Character>>,
) {
let speed = TILE_SIZE / game_server::movement::MOVE_DELAY * 1.2;
for (pos, mut transform) in &mut characters {
let target = grid_to_world(*pos);
let current = transform.translation.truncate();
let delta = target - current;
if delta.length() > TILE_SIZE * 3.0 {
transform.translation = target.extend(Z_CHARACTER);
} else {
let step = delta.clamp_length_max(speed * time.delta_secs());
transform.translation += step.extend(0.0);
}
}
}
fn update_marker(
local: Res<LocalCharacter>,
characters: Query<(&GridPos, &Facing)>,
mut marker: Query<(&mut Transform, &mut Visibility), With<FacingMarker>>,
) {
let Ok((mut transform, mut visibility)) = marker.single_mut() else {
return;
};
match local.0.and_then(|e| characters.get(e).ok()) {
Some((pos, facing)) => {
transform.translation = grid_to_world(pos.step(facing.0)).extend(Z_MARKER);
*visibility = Visibility::Visible;
}
None => *visibility = Visibility::Hidden,
}
}
fn follow_camera(
local: Res<LocalCharacter>,
time: Res<Time>,
targets: Query<&Transform, (With<Character>, Without<Camera2d>)>,
mut camera: Query<&mut Transform, With<Camera2d>>,
) {
let Some(target) = local.0.and_then(|e| targets.get(e).ok()) else {
return;
};
let Ok(mut camera) = camera.single_mut() else {
return;
};
let goal = target.translation.truncate().extend(camera.translation.z);
let t = 1.0 - (-8.0 * time.delta_secs()).exp();
camera.translation = camera.translation.lerp(goal, t);
}

View File

@ -0,0 +1,380 @@
//! Text HUD: vitals, inventory, skills, message log, crafting/build menus and help.
use std::collections::VecDeque;
use bevy::prelude::*;
use game_core::{GameData, components::*, protocol::GameLog};
use game_server::{
actions::REACH,
crafting::{item_name, try_construct, try_craft},
};
use crate::{LocalPlayer, render::LocalCharacter};
pub struct UiPlugin;
impl Plugin for UiPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GameLogBuffer>()
.init_resource::<UiState>()
.add_observer(|ev: On<GameLog>, mut log: ResMut<GameLogBuffer>| {
log.push(ev.text.clone())
})
.add_systems(Startup, setup)
.add_systems(
Update,
(
status_text,
inventory_text,
skills_text,
log_text,
menu_text,
),
);
}
}
const LOG_LINES: usize = 10;
#[derive(Resource, Default)]
pub struct GameLogBuffer {
lines: VecDeque<String>,
}
impl GameLogBuffer {
pub fn push(&mut self, line: String) {
info!("{line}");
self.lines.push_back(line);
while self.lines.len() > LOG_LINES {
self.lines.pop_front();
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Menu {
Craft,
Build,
Help,
}
#[derive(Resource, Default)]
pub struct UiState {
pub selected_slot: usize,
pub menu: Option<Menu>,
pub cursor: usize,
}
impl UiState {
pub fn toggle(&mut self, menu: Menu) {
self.menu = if self.menu == Some(menu) {
None
} else {
Some(menu)
};
self.cursor = 0;
}
}
/// Entries of the open menu (recipe or building ids).
pub fn menu_entries(menu: Menu, data: &GameData) -> Vec<String> {
match menu {
Menu::Craft => data.recipes.keys().cloned().collect(),
Menu::Build => data
.buildings
.values()
.filter(|b| b.construction.is_some())
.map(|b| b.id.clone())
.collect(),
Menu::Help => Vec::new(),
}
}
#[derive(Component)]
enum Panel {
Status,
Inventory,
Skills,
Log,
Menu,
}
fn panel(kind: Panel, node: Node) -> impl Bundle {
(
kind,
Node {
position_type: PositionType::Absolute,
padding: UiRect::all(Val::Px(8.0)),
..node
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.6)),
Text::default(),
TextFont::from_font_size(14.0),
)
}
fn setup(mut commands: Commands) {
commands.spawn(panel(
Panel::Status,
Node {
left: Val::Px(8.0),
top: Val::Px(8.0),
..default()
},
));
commands.spawn(panel(
Panel::Skills,
Node {
left: Val::Px(8.0),
top: Val::Px(110.0),
..default()
},
));
commands.spawn(panel(
Panel::Inventory,
Node {
right: Val::Px(8.0),
top: Val::Px(8.0),
min_width: Val::Px(200.0),
..default()
},
));
commands.spawn(panel(
Panel::Log,
Node {
left: Val::Px(8.0),
bottom: Val::Px(8.0),
max_width: Val::Percent(60.0),
..default()
},
));
commands.spawn((
panel(
Panel::Menu,
Node {
left: Val::Percent(25.0),
top: Val::Percent(15.0),
width: Val::Percent(50.0),
..default()
},
),
Visibility::Hidden,
));
}
fn set_text(panels: &mut Query<(&Panel, &mut Text)>, which: fn(&Panel) -> bool, text: String) {
for (panel, mut current) in panels.iter_mut() {
if which(panel) && current.0 != text {
current.0 = text.clone();
}
}
}
fn status_text(
local: Res<LocalCharacter>,
player: Res<LocalPlayer>,
characters: Query<(&Vitals, &GridPos)>,
mut panels: Query<(&Panel, &mut Text)>,
) {
let text = match local.0.and_then(|e| characters.get(e).ok()) {
Some((vitals, pos)) => format!(
"{}\nHealth {:>3.0}/{:.0}\nNutrition {:>3.0}%{}\nPosition {},{}\n[H] help",
player.name,
vitals.health,
vitals.max_health,
vitals.nutrition,
if vitals.nutrition < 20.0 {
" (hungry!)"
} else {
""
},
pos.x,
pos.y,
),
None => format!("{}\nWaiting for the server...", player.name),
};
set_text(&mut panels, |p| matches!(p, Panel::Status), text);
}
fn inventory_text(
local: Res<LocalCharacter>,
data: Res<GameData>,
ui: Res<UiState>,
inventories: Query<&Inventory>,
mut panels: Query<(&Panel, &mut Text)>,
) {
let Some(inventory) = local.0.and_then(|e| inventories.get(e).ok()) else {
return;
};
let mut text = format!(
"Inventory ({}/{})\n",
inventory.slots.len(),
INVENTORY_SLOTS
);
if inventory.slots.is_empty() {
text.push_str(" (empty)\n");
}
for (i, stack) in inventory.slots.iter().enumerate() {
let marker = if i == ui.selected_slot { '>' } else { ' ' };
let key = if i < 9 {
format!("{}", i + 1)
} else if i == 9 {
"0".into()
} else {
" ".into()
};
text.push_str(&format!(
"{marker}{key} {} x{}\n",
item_name(&data, &stack.item),
stack.count
));
}
if let Some(def) = inventory
.slots
.get(ui.selected_slot)
.and_then(|s| data.items.get(&s.item))
{
if !def.description.is_empty() {
text.push_str(&format!("\n{}", def.description));
}
if def.on_use.is_some() {
text.push_str("\n[U] use");
}
}
set_text(&mut panels, |p| matches!(p, Panel::Inventory), text);
}
fn skills_text(
local: Res<LocalCharacter>,
data: Res<GameData>,
skills: Query<&Skills>,
mut panels: Query<(&Panel, &mut Text)>,
) {
let Some(skills) = local.0.and_then(|e| skills.get(e).ok()) else {
return;
};
let mut text = "Skills\n".to_owned();
for def in data.skills.values() {
let progress = skills.skills.get(&def.id).copied().unwrap_or_default();
let level = progress.level();
let next = SkillProgress::xp_for_level(level + 1);
text.push_str(&format!(
"{:<12} {:>2} ({}/{} xp)\n",
def.name, level, progress.xp, next
));
}
set_text(
&mut panels,
|p| matches!(p, Panel::Skills),
text.trim_end().to_owned(),
);
}
fn log_text(log: Res<GameLogBuffer>, mut panels: Query<(&Panel, &mut Text)>) {
if !log.is_changed() {
return;
}
let text = log.lines.iter().cloned().collect::<Vec<_>>().join("\n");
set_text(&mut panels, |p| matches!(p, Panel::Log), text);
}
fn ingredients(data: &GameData, list: &[game_core::data::Ingredient]) -> String {
list.iter()
.map(|i| format!("{} {}", i.count, item_name(data, &i.item)))
.collect::<Vec<_>>()
.join(", ")
}
const HELP: &str = "\
Controls
WASD / arrows move
E pick up (own tile, else facing tile)
F interact with the highlighted tile
Left click interact / pick up on an adjacent tile
1-9, 0, wheel select inventory slot
U use selected item
G drop selected stack
C crafting menu
B build menu (builds on the highlighted tile)
X deconstruct the highlighted building
Enter confirm in menus, Esc closes
H toggle this help";
#[allow(clippy::too_many_arguments)]
fn menu_text(
local: Res<LocalCharacter>,
data: Res<GameData>,
ui: Res<UiState>,
characters: Query<(&GridPos, &Inventory, &Skills)>,
buildings: Query<(&Building, &GridPos)>,
mut panels: Query<(&Panel, &mut Text, &mut Visibility)>,
) {
let Some((_, mut text, mut visibility)) =
panels.iter_mut().find(|(p, _, _)| matches!(p, Panel::Menu))
else {
return;
};
let Some(menu) = ui.menu else {
visibility.set_if_neq(Visibility::Hidden);
return;
};
visibility.set_if_neq(Visibility::Visible);
let new_text = if menu == Menu::Help {
HELP.to_owned()
} else {
let Some((pos, inventory, skills)) = local.0.and_then(|e| characters.get(e).ok()) else {
return;
};
let stations: Vec<String> = buildings
.iter()
.filter(|(_, p)| p.distance(*pos) <= REACH)
.filter_map(|(b, _)| data.buildings.get(&b.proto))
.flat_map(|def| def.stations.iter().cloned())
.collect();
let title = match menu {
Menu::Craft => "Crafting (W/S select, Enter craft, Esc close)",
_ => "Build on highlighted tile (W/S select, Enter build, Esc close)",
};
let mut out = format!("{title}\n\n");
for (i, id) in menu_entries(menu, &data).iter().enumerate() {
// Dry-run the server rules on copies to show what is missing.
let (mut inv, mut sk) = (inventory.clone(), skills.clone());
let (name, recipe, status) = match menu {
Menu::Craft => {
let r = &data.recipes[id];
let mut recipe = format!(
"{} -> {}",
ingredients(&data, &r.inputs),
ingredients(&data, &r.outputs)
);
if let Some(station) = &r.station {
recipe.push_str(&format!(" @{station}"));
}
let status = try_craft(&data, id, &stations, &mut inv, &mut sk).map(|_| ());
(r.display_name(&data).to_owned(), recipe, status)
}
_ => {
let b = &data.buildings[id];
let c = b.construction.as_ref().expect("filtered");
let mut recipe = ingredients(&data, &c.materials);
if !c.tools.is_empty() {
let tools: Vec<_> = c.tools.iter().map(|t| item_name(&data, t)).collect();
recipe.push_str(&format!(" tools: {}", tools.join(", ")));
}
let status = try_construct(&data, id, &mut inv, &mut sk).map(|_| ());
(b.name.clone(), recipe, status)
}
};
let marker = if i == ui.cursor { '>' } else { ' ' };
let status = match status {
Ok(()) => "ready".to_owned(),
Err(reason) => reason,
};
out.push_str(&format!("{marker} {name}\n {recipe}\n {status}\n"));
}
out
};
if text.0 != new_text {
text.0 = new_text;
}
}

View File

@ -0,0 +1,15 @@
[package]
name = "game_core"
version.workspace = true
edition.workspace = true
[dependencies]
bevy = { workspace = true, features = ["std", "bevy_log", "bevy_state", "serialize", "multi_threaded"] }
bevy_replicon = { workspace = true }
serde = { workspace = true }
rsn = { workspace = true }
thiserror = { workspace = true }
tiled = { workspace = true }
[lints]
workspace = true

View File

@ -0,0 +1,267 @@
//! Replicated gameplay components shared by server and client.
use std::collections::BTreeMap;
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
use crate::{data::GameData, grid::Dir};
pub const INVENTORY_SLOTS: usize = 20;
/// Position on the tile grid. Tiled's coordinate system is used: `y` grows downwards.
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GridPos {
pub x: i32,
pub y: i32,
}
impl GridPos {
pub const fn new(x: i32, y: i32) -> Self {
Self { x, y }
}
pub fn step(self, dir: Dir) -> Self {
let (dx, dy) = dir.delta();
Self::new(self.x + dx, self.y + dy)
}
/// Chebyshev distance, so diagonal neighbours count as adjacent.
pub fn distance(self, other: Self) -> i32 {
(self.x - other.x).abs().max((self.y - other.y).abs())
}
}
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Facing(pub Dir);
/// A player character. Names are unique and identify the persistent character.
#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[require(GridPos, Facing, Vitals, Inventory, Skills)]
pub struct Character {
pub name: String,
}
#[derive(Component, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Vitals {
pub health: f32,
pub max_health: f32,
/// 100 = full, 0 = starving.
pub nutrition: f32,
}
impl Default for Vitals {
fn default() -> Self {
Self {
health: 100.0,
max_health: 100.0,
nutrition: 100.0,
}
}
}
impl Vitals {
pub fn heal(&mut self, amount: f32) {
self.health = (self.health + amount).clamp(0.0, self.max_health);
}
pub fn feed(&mut self, amount: f32) {
self.nutrition = (self.nutrition + amount).clamp(0.0, 100.0);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ItemStack {
pub item: String,
pub count: u32,
}
impl ItemStack {
pub fn new(item: impl Into<String>, count: u32) -> Self {
Self {
item: item.into(),
count,
}
}
}
#[derive(Component, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Inventory {
pub slots: Vec<ItemStack>,
}
impl Inventory {
pub fn count(&self, item: &str) -> u32 {
self.slots
.iter()
.filter(|s| s.item == item)
.map(|s| s.count)
.sum()
}
pub fn has(&self, item: &str, count: u32) -> bool {
self.count(item) >= count
}
/// How many of `item` would fit, honoring stack sizes and slot limits.
pub fn space_for(&self, item: &str, data: &GameData) -> u32 {
let max = data.max_stack(item);
let partial: u32 = self
.slots
.iter()
.filter(|s| s.item == item)
.map(|s| max.saturating_sub(s.count))
.sum();
let free_slots = INVENTORY_SLOTS.saturating_sub(self.slots.len()) as u32;
partial + free_slots * max
}
/// Adds items and returns how many did not fit.
pub fn add(&mut self, item: &str, mut count: u32, data: &GameData) -> u32 {
let max = data.max_stack(item);
for slot in self.slots.iter_mut().filter(|s| s.item == item) {
let moved = count.min(max.saturating_sub(slot.count));
slot.count += moved;
count -= moved;
}
while count > 0 && self.slots.len() < INVENTORY_SLOTS {
let moved = count.min(max);
self.slots.push(ItemStack::new(item, moved));
count -= moved;
}
count
}
/// Removes `count` items if available. Returns `false` and changes nothing otherwise.
pub fn remove(&mut self, item: &str, mut count: u32) -> bool {
if !self.has(item, count) {
return false;
}
for slot in self.slots.iter_mut().rev().filter(|s| s.item == item) {
let taken = count.min(slot.count);
slot.count -= taken;
count -= taken;
}
self.slots.retain(|s| s.count > 0);
true
}
/// Removes up to `count` items from a specific slot.
pub fn take_from_slot(&mut self, slot: usize, count: u32) -> Option<ItemStack> {
let stack = self.slots.get_mut(slot)?;
let taken = count.min(stack.count);
stack.count -= taken;
let item = stack.item.clone();
if stack.count == 0 {
self.slots.remove(slot);
}
Some(ItemStack::new(item, taken))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillProgress {
pub xp: u32,
}
impl SkillProgress {
/// Level 1 at 0 xp, each further level needs progressively more xp (50, 200, 450, ...).
pub fn level(self) -> u32 {
1 + ((self.xp as f32 / 50.0).sqrt()) as u32
}
pub fn xp_for_level(level: u32) -> u32 {
let l = level.saturating_sub(1);
50 * l * l
}
}
#[derive(Component, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Skills {
pub skills: BTreeMap<String, SkillProgress>,
}
impl Skills {
pub fn level(&self, skill: &str) -> u32 {
self.skills.get(skill).copied().unwrap_or_default().level()
}
/// Adds xp and returns the new level if a level-up happened.
pub fn add_xp(&mut self, skill: &str, xp: u32) -> Option<u32> {
let progress = self.skills.entry(skill.to_owned()).or_default();
let before = progress.level();
progress.xp = progress.xp.saturating_add(xp);
let after = progress.level();
(after > before).then_some(after)
}
}
/// An item stack lying on the floor.
#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[require(GridPos)]
pub struct GroundItem(pub ItemStack);
/// A placed building (wall, door, workbench, ...).
#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[require(GridPos, BuildingState)]
pub struct Building {
pub proto: String,
}
/// Mutable per-instance building state, editable from scripts.
#[derive(Component, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingState {
pub solid: bool,
pub variant: Option<String>,
pub health: i32,
pub vars: BTreeMap<String, i64>,
}
/// 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 {
pub path: String,
}
#[cfg(test)]
mod tests {
use super::*;
fn data() -> GameData {
GameData::from_source(
r#"items: [
{ id: "ore", name: "Ore", sprite: "", max_stack: 5 },
{ id: "gem", name: "Gem", sprite: "", max_stack: 1 },
]"#,
)
.unwrap()
}
#[test]
fn inventory_stacks_and_limits() {
let data = data();
let mut inv = Inventory::default();
assert_eq!(inv.add("ore", 12, &data), 0);
assert_eq!(inv.slots.len(), 3);
assert_eq!(inv.count("ore"), 12);
assert!(inv.remove("ore", 8));
assert_eq!(inv.count("ore"), 4);
assert!(!inv.remove("ore", 5));
assert_eq!(inv.count("ore"), 4);
let leftover = inv.add("gem", 100, &data);
assert_eq!(inv.slots.len(), INVENTORY_SLOTS);
assert_eq!(leftover, 100 - (INVENTORY_SLOTS as u32 - 1));
assert_eq!(inv.space_for("ore", &data), 1);
}
#[test]
fn skill_levels() {
let mut skills = Skills::default();
assert_eq!(skills.level("x"), 1);
assert_eq!(skills.add_xp("x", 49), None);
assert_eq!(skills.add_xp("x", 1), Some(2));
assert_eq!(SkillProgress::xp_for_level(3), 200);
assert_eq!(skills.add_xp("x", 150), Some(3));
}
}

View File

@ -0,0 +1,499 @@
//! Data-driven prototype definitions loaded from `.rsn` files.
//!
//! Every `.rsn` file below `assets/data/` is parsed as a [`DataFile`]. A file can
//! contain any mix of `items`, `recipes`, `buildings` and `skills`, which makes it
//! easy for content packs to ship everything related to a feature in one file.
use std::{
collections::{BTreeMap, HashMap},
fs,
path::{Path, PathBuf},
};
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
/// Item and amount pair, written as `("iron_ore", 2)` or `{ item: "iron_ore", count: 2 }`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "IngredientRepr")]
pub struct Ingredient {
pub item: String,
pub count: u32,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum IngredientRepr {
Tuple(String, u32),
Map { item: String, count: u32 },
}
impl From<IngredientRepr> for Ingredient {
fn from(repr: IngredientRepr) -> Self {
let (item, count) = match repr {
IngredientRepr::Tuple(item, count) | IngredientRepr::Map { item, count } => {
(item, count)
}
};
Self { item, count }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillRequirement {
pub skill: String,
pub level: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ItemDef {
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
/// Sprite path relative to the assets directory.
pub sprite: String,
#[serde(default = "default_max_stack")]
pub max_stack: u32,
#[serde(default)]
pub tags: Vec<String>,
/// Script hook (`"module::function"`) executed when the item is used.
#[serde(default)]
pub on_use: Option<String>,
/// Remove one item from the stack after a successful `on_use`.
#[serde(default)]
pub consume_on_use: bool,
/// Free-form numeric properties readable from scripts (`ctx.item_prop("nutrition")`).
#[serde(default)]
pub props: BTreeMap<String, i64>,
}
fn default_max_stack() -> u32 {
50
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RecipeDef {
pub id: String,
#[serde(default)]
pub name: Option<String>,
pub inputs: Vec<Ingredient>,
pub outputs: Vec<Ingredient>,
/// Items that must be in the inventory but are not consumed.
#[serde(default)]
pub tools: Vec<String>,
/// Station tag a nearby building must provide (see [`BuildingDef::stations`]).
#[serde(default)]
pub station: Option<String>,
#[serde(default)]
pub skill: Option<SkillRequirement>,
/// Experience granted to `skill` on success.
#[serde(default)]
pub xp: u32,
/// Script hook executed after crafting succeeded.
#[serde(default)]
pub on_craft: Option<String>,
}
impl RecipeDef {
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)
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Construction {
pub materials: Vec<Ingredient>,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub skill: Option<SkillRequirement>,
#[serde(default)]
pub xp: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BuildingDef {
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
pub sprite: String,
/// Alternative sprites selectable from scripts with `ctx.set_variant(name)`.
#[serde(default)]
pub variants: HashMap<String, String>,
#[serde(default = "default_true")]
pub solid: bool,
#[serde(default = "default_health")]
pub max_health: i32,
/// Station tags this building provides to recipes.
#[serde(default)]
pub stations: Vec<String>,
/// How players can build it. Buildings without construction can only be placed in maps.
#[serde(default)]
pub construction: Option<Construction>,
/// Allow deconstruction, refunding the construction materials.
#[serde(default = "default_true")]
pub deconstructible: bool,
#[serde(default)]
pub on_interact: Option<String>,
/// Initial per-instance script variables.
#[serde(default)]
pub vars: BTreeMap<String, i64>,
}
impl BuildingDef {
pub fn sprite_for(&self, variant: Option<&str>) -> &str {
variant
.and_then(|v| self.variants.get(v))
.unwrap_or(&self.sprite)
}
}
fn default_true() -> bool {
true
}
fn default_health() -> i32 {
100
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SkillDef {
pub id: String,
pub name: String,
}
/// Root of a data file. All sections are optional.
#[derive(Debug, Default, Deserialize)]
pub struct DataFile {
#[serde(default)]
pub items: Vec<ItemDef>,
#[serde(default)]
pub recipes: Vec<RecipeDef>,
#[serde(default)]
pub buildings: Vec<BuildingDef>,
#[serde(default)]
pub skills: Vec<SkillDef>,
}
#[derive(Debug, thiserror::Error)]
pub enum DataError {
#[error("failed to read `{path}`: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse `{path}`: {message}")]
Parse { path: PathBuf, message: String },
#[error("invalid game data:\n {}", .0.join("\n "))]
Invalid(Vec<String>),
}
/// All prototypes, keyed by id. Ordered maps keep menus and iteration stable.
#[derive(Resource, Debug, Default, Clone)]
pub struct GameData {
pub items: BTreeMap<String, ItemDef>,
pub recipes: BTreeMap<String, RecipeDef>,
pub buildings: BTreeMap<String, BuildingDef>,
pub skills: BTreeMap<String, SkillDef>,
}
impl GameData {
/// Loads and validates every `.rsn` file below `dir` (recursively, in sorted order).
pub fn load_dir(dir: &Path) -> Result<Self, DataError> {
let mut files = Vec::new();
collect_rsn_files(dir, &mut files)?;
files.sort();
let mut data = GameData::default();
let mut errors = Vec::new();
for path in files {
let source = fs::read_to_string(&path).map_err(|source| DataError::Io {
path: path.clone(),
source,
})?;
let file = parse_data_file(&source).map_err(|message| DataError::Parse {
path: path.clone(),
message,
})?;
data.merge(file, &path, &mut errors);
}
errors.extend(data.validate());
if errors.is_empty() {
Ok(data)
} else {
Err(DataError::Invalid(errors))
}
}
/// Parses a single in-memory data file. Mostly useful for tests and tools.
pub fn from_source(source: &str) -> Result<Self, DataError> {
let file = parse_data_file(source).map_err(|message| DataError::Parse {
path: PathBuf::from("<memory>"),
message,
})?;
let mut data = GameData::default();
let mut errors = Vec::new();
data.merge(file, Path::new("<memory>"), &mut errors);
errors.extend(data.validate());
if errors.is_empty() {
Ok(data)
} else {
Err(DataError::Invalid(errors))
}
}
fn merge(&mut self, file: DataFile, path: &Path, errors: &mut Vec<String>) {
fn insert<T>(
map: &mut BTreeMap<String, T>,
kind: &str,
id: String,
value: T,
path: &Path,
errors: &mut Vec<String>,
) {
match map.entry(id) {
std::collections::btree_map::Entry::Occupied(e) => {
errors.push(format!(
"{kind} `{}` defined twice (in {})",
e.key(),
path.display()
));
}
std::collections::btree_map::Entry::Vacant(e) => {
e.insert(value);
}
}
}
for item in file.items {
insert(&mut self.items, "item", item.id.clone(), item, path, errors);
}
for recipe in file.recipes {
insert(
&mut self.recipes,
"recipe",
recipe.id.clone(),
recipe,
path,
errors,
);
}
for building in file.buildings {
insert(
&mut self.buildings,
"building",
building.id.clone(),
building,
path,
errors,
);
}
for skill in file.skills {
insert(
&mut self.skills,
"skill",
skill.id.clone(),
skill,
path,
errors,
);
}
}
/// Checks that all cross references point to existing prototypes.
pub fn validate(&self) -> Vec<String> {
let mut errors = Vec::new();
let check_item = |errors: &mut Vec<String>, ctx: &str, id: &str| {
if !self.items.contains_key(id) {
errors.push(format!("{ctx}: unknown item `{id}`"));
}
};
let check_skill = |errors: &mut Vec<String>, ctx: &str, req: &Option<SkillRequirement>| {
if let Some(req) = req
&& !self.skills.contains_key(&req.skill)
{
errors.push(format!("{ctx}: unknown skill `{}`", req.skill));
}
};
let stations: Vec<&String> = self.buildings.values().flat_map(|b| &b.stations).collect();
for item in self.items.values() {
if item.max_stack == 0 {
errors.push(format!("item `{}`: max_stack must be at least 1", item.id));
}
}
for recipe in self.recipes.values() {
let ctx = format!("recipe `{}`", recipe.id);
if recipe.outputs.is_empty() {
errors.push(format!("{ctx}: has no outputs"));
}
for ing in recipe.inputs.iter().chain(&recipe.outputs) {
check_item(&mut errors, &ctx, &ing.item);
if ing.count == 0 {
errors.push(format!("{ctx}: count of `{}` must be at least 1", ing.item));
}
}
for tool in &recipe.tools {
check_item(&mut errors, &ctx, tool);
}
if let Some(station) = &recipe.station
&& !stations.contains(&station)
{
errors.push(format!("{ctx}: no building provides station `{station}`"));
}
check_skill(&mut errors, &ctx, &recipe.skill);
if recipe.xp > 0 && recipe.skill.is_none() {
errors.push(format!("{ctx}: grants xp but has no skill"));
}
}
for building in self.buildings.values() {
let ctx = format!("building `{}`", building.id);
if let Some(construction) = &building.construction {
for ing in &construction.materials {
check_item(&mut errors, &ctx, &ing.item);
}
for tool in &construction.tools {
check_item(&mut errors, &ctx, tool);
}
check_skill(&mut errors, &ctx, &construction.skill);
}
}
errors
}
/// Asset-relative sprite paths of every prototype, for preloading and validation.
pub fn sprite_paths(&self) -> impl Iterator<Item = &str> {
self.items
.values()
.map(|i| i.sprite.as_str())
.chain(self.buildings.values().flat_map(|b| {
std::iter::once(b.sprite.as_str()).chain(b.variants.values().map(String::as_str))
}))
}
pub fn max_stack(&self, item: &str) -> u32 {
self.items.get(item).map_or(1, |i| i.max_stack)
}
}
fn parse_data_file(source: &str) -> Result<DataFile, String> {
rsn::parser::Config::default()
.allow_implicit_map_at_root(true)
.deserialize(source)
.map_err(|err| {
let (line, col) = line_col(source, err.location.start);
format!("{} (line {line}, column {col})", err.kind)
})
}
fn line_col(source: &str, offset: usize) -> (usize, usize) {
let before = &source[..offset.min(source.len())];
let line = before.matches('\n').count() + 1;
let col = before.rsplit('\n').next().map_or(0, |l| l.chars().count()) + 1;
(line, col)
}
fn collect_rsn_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), DataError> {
let entries = fs::read_dir(dir).map_err(|source| DataError::Io {
path: dir.to_path_buf(),
source,
})?;
for entry in entries {
let path = entry
.map_err(|source| DataError::Io {
path: dir.to_path_buf(),
source,
})?
.path();
if path.is_dir() {
collect_rsn_files(&path, out)?;
} else if path.extension().is_some_and(|e| e == "rsn") {
out.push(path);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
// Comments are allowed.
skills: [{ id: "crafting", name: "Crafting" }]
items: [
{ id: "ore", name: "Ore", sprite: "ore.png" },
{ id: "plate", name: "Plate", sprite: "plate.png", max_stack: 10, tags: ["metal"] },
{ id: "food", name: "Food", sprite: "food.png", on_use: Some("food::eat"), consume_on_use: true },
]
recipes: [
{
id: "plate",
inputs: [("ore", 2)],
outputs: [{ item: "plate", count: 1 }],
station: Some("bench"),
skill: Some({ skill: "crafting", level: 1 }),
xp: 5,
},
]
buildings: [
{ id: "bench", name: "Bench", sprite: "bench.png", stations: ["bench"],
construction: Some({ materials: [("plate", 2)] }), vars: { "uses": 0 } },
]
"#;
#[test]
fn parses_sample() {
let data = GameData::from_source(SAMPLE).unwrap();
assert_eq!(data.items.len(), 3);
assert_eq!(data.items["ore"].max_stack, 50);
assert_eq!(data.items["plate"].max_stack, 10);
assert_eq!(data.items["food"].on_use.as_deref(), Some("food::eat"));
let recipe = &data.recipes["plate"];
assert_eq!(
recipe.inputs[0],
Ingredient {
item: "ore".into(),
count: 2
}
);
assert_eq!(recipe.station.as_deref(), Some("bench"));
assert_eq!(recipe.skill.as_ref().unwrap().level, 1);
let bench = &data.buildings["bench"];
assert!(bench.solid);
assert_eq!(bench.vars["uses"], 0);
assert_eq!(bench.construction.as_ref().unwrap().materials.len(), 1);
}
#[test]
fn reports_unknown_references() {
let err = GameData::from_source(
r#"recipes: [{ id: "x", inputs: [("missing", 1)], outputs: [("also_missing", 1)] }]"#,
)
.unwrap_err();
let DataError::Invalid(errors) = err else {
panic!("{err}")
};
assert_eq!(errors.len(), 2);
}
#[test]
fn reports_parse_location() {
let err = GameData::from_source("items: [ { id: } ]").unwrap_err();
assert!(err.to_string().contains("line 1"), "{err}");
}
#[test]
fn shipped_data_is_valid() {
let dir = crate::assets_dir().join("data");
GameData::load_dir(&dir).unwrap();
}
}

View File

@ -0,0 +1,37 @@
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
use crate::components::GridPos;
/// World units per tile. Tiled maps are expected to use the same tile size,
/// other sizes are scaled to fit.
pub const TILE_SIZE: f32 = 32.0;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Dir {
North,
#[default]
South,
East,
West,
}
impl Dir {
/// Grid delta, with `y` pointing down like in Tiled.
pub fn delta(self) -> (i32, i32) {
match self {
Dir::North => (0, -1),
Dir::South => (0, 1),
Dir::East => (1, 0),
Dir::West => (-1, 0),
}
}
}
/// Converts a grid position to the center of the tile in Bevy world space (y up).
pub fn grid_to_world(pos: GridPos) -> Vec2 {
Vec2::new(
pos.x as f32 * TILE_SIZE + TILE_SIZE / 2.0,
-(pos.y as f32 * TILE_SIZE + TILE_SIZE / 2.0),
)
}

View File

@ -0,0 +1,69 @@
//! Shared game code: data definitions, components, map loading and network protocol.
pub mod components;
pub mod data;
pub mod grid;
pub mod map;
pub mod protocol;
use std::path::{Path, PathBuf};
use bevy::prelude::*;
pub use data::GameData;
/// Map loaded when no other map is configured, relative to the assets directory.
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;
/// Locates the `assets` directory.
///
/// Uses `GAME_ASSETS_DIR` if set, otherwise searches upwards from the working
/// directory and the executable for a directory named `assets` containing `data/`.
pub fn assets_dir() -> PathBuf {
if let Some(dir) = std::env::var_os("GAME_ASSETS_DIR") {
return dir.into();
}
let starts = [
std::env::current_dir().ok(),
std::env::current_exe().ok(),
Some(PathBuf::from(env!("CARGO_MANIFEST_DIR"))),
];
starts
.into_iter()
.flatten()
.find_map(|start| {
start
.ancestors()
.map(|p| p.join("assets"))
.find(|p| p.join("data").is_dir())
})
.unwrap_or_else(|| PathBuf::from("assets"))
}
/// Absolute path for an asset-relative path.
pub fn asset_path(relative: impl AsRef<Path>) -> PathBuf {
assets_dir().join(relative)
}
/// Loads [`GameData`] and registers the network protocol.
pub struct CorePlugin;
impl Plugin for CorePlugin {
fn build(&self, app: &mut App) {
let dir = assets_dir().join("data");
let data = GameData::load_dir(&dir).unwrap_or_else(|e| panic!("{e}"));
info!(
"loaded {} items, {} recipes, {} buildings, {} skills from `{}`",
data.items.len(),
data.recipes.len(),
data.buildings.len(),
data.skills.len(),
dir.display()
);
app.insert_resource(data)
.add_plugins(protocol::ProtocolPlugin);
}
}

251
crates/game_core/src/map.rs Normal file
View File

@ -0,0 +1,251 @@
//! Tiled (`.tmx`) map support.
//!
//! Conventions understood by the game:
//! - Tile layers are rendered in order. A tile is solid when its tile (in the tileset)
//! or its layer has the boolean custom property `solid = true`.
//! - Object layers spawn gameplay entities based on the object *class*:
//! - `spawn`: player spawn point.
//! - `building`: building with the custom property `proto` (a building id).
//! - `item`: ground item with the property `proto` (an item id) and optional `count`.
//!
//! Any other custom properties are passed through in [`MapObject::properties`]
//! (buildings copy integer properties into their script variables).
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
use crate::components::GridPos;
#[derive(Clone, Debug)]
pub struct TilesetInfo {
/// Image path relative to the assets directory.
pub image: String,
pub image_width: u32,
pub image_height: u32,
pub tile_width: u32,
pub tile_height: u32,
pub columns: u32,
pub tile_count: u32,
pub spacing: u32,
pub margin: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TileCell {
pub tileset: usize,
pub id: u32,
pub flip_h: bool,
pub flip_v: bool,
}
#[derive(Clone, Debug)]
pub struct TileLayerData {
pub name: String,
/// Row-major cells, `width * height` entries.
pub cells: Vec<Option<TileCell>>,
}
#[derive(Clone, Debug)]
pub struct MapObject {
pub id: u32,
pub class: String,
pub name: String,
pub pos: GridPos,
pub properties: BTreeMap<String, String>,
}
#[derive(Clone, Debug)]
pub struct MapLayout {
pub width: u32,
pub height: u32,
pub tile_width: u32,
pub tile_height: u32,
pub tilesets: Vec<TilesetInfo>,
pub layers: Vec<TileLayerData>,
pub objects: Vec<MapObject>,
solid: Vec<bool>,
}
#[derive(Debug, thiserror::Error)]
pub enum MapError {
#[error("failed to load map `{0}`: {1}")]
Tiled(PathBuf, tiled::Error),
#[error("map `{0}`: {1}")]
Unsupported(PathBuf, String),
}
impl MapLayout {
/// Loads `map_path` (relative to `assets_dir`).
pub fn load(assets_dir: &Path, map_path: &str) -> Result<Self, MapError> {
let full = assets_dir.join(map_path);
let unsupported = |msg: String| MapError::Unsupported(full.clone(), msg);
let map = tiled::Loader::new()
.load_tmx_map(&full)
.map_err(|e| MapError::Tiled(full.clone(), e))?;
if map.orientation != tiled::Orientation::Orthogonal {
return Err(unsupported("only orthogonal maps are supported".into()));
}
let assets_canon = assets_dir
.canonicalize()
.unwrap_or_else(|_| assets_dir.into());
let mut tilesets = Vec::new();
for ts in map.tilesets() {
let image = ts.image.as_ref().ok_or_else(|| {
unsupported(format!(
"tileset `{}` must be based on a single image (image collections are not supported)",
ts.name
))
})?;
let image_path = image.source.canonicalize().unwrap_or(image.source.clone());
let rel = image_path.strip_prefix(&assets_canon).map_err(|_| {
unsupported(format!(
"tileset image `{}` must be inside the assets directory",
image.source.display()
))
})?;
tilesets.push(TilesetInfo {
image: rel.to_string_lossy().replace('\\', "/"),
image_width: image.width as u32,
image_height: image.height as u32,
tile_width: ts.tile_width,
tile_height: ts.tile_height,
columns: ts.columns,
tile_count: ts.tilecount,
spacing: ts.spacing,
margin: ts.margin,
});
}
let (width, height) = (map.width, map.height);
let mut layers = Vec::new();
let mut solid = vec![false; (width * height) as usize];
let mut objects = Vec::new();
let mut stack: Vec<tiled::Layer> = map.layers().collect();
stack.reverse();
while let Some(layer) = stack.pop() {
let layer_solid = bool_prop(&layer.properties, "solid");
match layer.layer_type() {
tiled::LayerType::Tiles(tiles) => {
if matches!(tiles, tiled::TileLayer::Infinite(_)) {
return Err(unsupported("infinite maps are not supported".into()));
}
let mut cells = Vec::with_capacity(solid.len());
for y in 0..height as i32 {
for x in 0..width as i32 {
let cell = tiles.get_tile(x, y).map(|t| {
let tile_solid = t
.get_tile()
.is_some_and(|d| bool_prop(&d.properties, "solid"));
if tile_solid || layer_solid {
solid[(y as u32 * width + x as u32) as usize] = true;
}
TileCell {
tileset: t.tileset_index(),
id: t.id(),
flip_h: t.flip_h,
flip_v: t.flip_v,
}
});
cells.push(cell);
}
}
layers.push(TileLayerData {
name: layer.name.clone(),
cells,
});
}
tiled::LayerType::Objects(group) => {
for obj in group.objects() {
let tw = map.tile_width as f32;
let th = map.tile_height as f32;
// Tile objects are anchored at their bottom-left corner.
let y = if obj.get_tile().is_some() {
obj.y - 1.0
} else {
obj.y
};
objects.push(MapObject {
id: obj.id(),
class: obj.user_type.clone(),
name: obj.name.clone(),
pos: GridPos::new((obj.x / tw).floor() as i32, (y / th).floor() as i32),
properties: obj
.properties
.iter()
.map(|(k, v)| (k.clone(), prop_to_string(v)))
.collect(),
});
}
}
tiled::LayerType::Group(group) => {
let children: Vec<_> = group.layers().collect();
stack.extend(children.into_iter().rev());
}
tiled::LayerType::Image(_) => {}
}
}
Ok(Self {
width,
height,
tile_width: map.tile_width,
tile_height: map.tile_height,
tilesets,
layers,
objects,
solid,
})
}
pub fn in_bounds(&self, pos: GridPos) -> bool {
pos.x >= 0 && pos.y >= 0 && (pos.x as u32) < self.width && (pos.y as u32) < self.height
}
/// Tiles outside of the map are always solid.
pub fn is_solid(&self, pos: GridPos) -> bool {
!self.in_bounds(pos) || self.solid[(pos.y as u32 * self.width + pos.x as u32) as usize]
}
pub fn objects_of_class<'a>(&'a self, class: &'a str) -> impl Iterator<Item = &'a MapObject> {
self.objects.iter().filter(move |o| o.class == class)
}
}
fn bool_prop(props: &tiled::Properties, key: &str) -> bool {
matches!(props.get(key), Some(tiled::PropertyValue::BoolValue(true)))
}
fn prop_to_string(value: &tiled::PropertyValue) -> String {
use tiled::PropertyValue::*;
match value {
BoolValue(v) => v.to_string(),
FloatValue(v) => v.to_string(),
IntValue(v) => v.to_string(),
StringValue(v) | FileValue(v) => v.clone(),
ObjectValue(v) => v.to_string(),
other => format!("{other:?}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loads_shipped_map() {
let map = MapLayout::load(&crate::assets_dir(), crate::DEFAULT_MAP).unwrap();
assert!(map.width > 0 && map.height > 0);
assert!(!map.layers.is_empty());
assert!(map.objects_of_class("spawn").next().is_some());
assert!(map.is_solid(GridPos::new(-1, 0)));
assert!(
map.is_solid(GridPos::new(0, 0)),
"map border should be walls"
);
let spawn = map.objects_of_class("spawn").next().unwrap().pos;
assert!(!map.is_solid(spawn));
}
}

View File

@ -0,0 +1,91 @@
//! Network protocol: replicated components and client/server events.
//!
//! Registration order must be identical on server and client, so everything is
//! registered here and used by both.
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use serde::{Deserialize, Serialize};
use crate::{components::*, grid::Dir};
/// First event a client sends; selects (or creates) the persistent character.
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct Login {
pub name: String,
}
/// Direction the player is holding, `None` to stop.
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct SetMoveDir(pub Option<Dir>);
/// Pick up the item on the given tile (own or adjacent).
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct PickUp {
pub target: GridPos,
}
/// Interact with the building on the given adjacent tile.
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct Interact {
pub target: GridPos,
}
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct UseItem {
pub slot: usize,
}
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct DropItem {
pub slot: usize,
}
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct Craft {
pub recipe: String,
}
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct Construct {
pub building: String,
pub target: GridPos,
}
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct Deconstruct {
pub target: GridPos,
}
/// Text shown in the receiving player's message log.
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct GameLog {
pub text: String,
}
pub struct ProtocolPlugin;
impl Plugin for ProtocolPlugin {
fn build(&self, app: &mut App) {
app.replicate::<WorldMap>()
.replicate::<GridPos>()
.replicate::<Facing>()
.replicate::<Character>()
.replicate::<Vitals>()
.replicate::<Inventory>()
.replicate::<Skills>()
.replicate::<GroundItem>()
.replicate::<Building>()
.replicate::<BuildingState>()
.add_client_event::<Login>(Channel::Ordered)
.add_client_event::<SetMoveDir>(Channel::Ordered)
.add_client_event::<PickUp>(Channel::Ordered)
.add_client_event::<Interact>(Channel::Ordered)
.add_client_event::<UseItem>(Channel::Ordered)
.add_client_event::<DropItem>(Channel::Ordered)
.add_client_event::<Craft>(Channel::Ordered)
.add_client_event::<Construct>(Channel::Ordered)
.add_client_event::<Deconstruct>(Channel::Ordered)
.add_server_event::<GameLog>(Channel::Ordered);
}
}

View File

@ -0,0 +1,24 @@
[package]
name = "game_server"
version.workspace = true
edition.workspace = true
[[bin]]
name = "game-server"
path = "src/main.rs"
[dependencies]
game_core = { workspace = true }
bevy = { workspace = true, features = ["std", "bevy_log", "bevy_state", "serialize", "multi_threaded"] }
bevy_replicon = { workspace = true }
bevy_replicon_renet = { workspace = true }
serde = { workspace = true }
rsn = { workspace = true }
rune = { workspace = true }
tiled = { workspace = true }
thiserror = { workspace = true }
# Enables graceful shutdown (and saving) on SIGTERM/SIGHUP via Bevy's Ctrl-C handler.
ctrlc = { version = "3.5", features = ["termination"] }
[lints]
workspace = true

View File

@ -0,0 +1,551 @@
//! Player actions: picking up, dropping, using items, interacting, crafting and building.
//!
//! Everything is validated on the server; clients only send intents.
use std::sync::Arc;
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use game_core::{GameData, components::*, protocol::*};
use crate::{
crafting::{self, item_name},
players::{Players, tell},
scripting::{ScriptEngine, ScriptState, ScriptTarget},
world::{WorldLayout, spawn_building, spawn_ground_item},
};
/// Read-only game data shared with scripts.
#[derive(Resource, Clone)]
pub struct SharedData(pub Arc<GameData>);
/// Maximum distance (in tiles) for interactions.
pub const REACH: i32 = 1;
type CharacterData<'a> = (
&'a Character,
&'a GridPos,
&'a mut Facing,
&'a mut Inventory,
&'a mut Vitals,
&'a mut Skills,
);
type Characters<'w, 's> = Query<'w, 's, CharacterData<'static>>;
type Buildings<'w, 's> = Query<
'w,
's,
(
Entity,
&'static Building,
&'static GridPos,
&'static mut BuildingState,
),
Without<Character>,
>;
type GroundItems<'w, 's> =
Query<'w, 's, (Entity, &'static mut GroundItem, &'static GridPos), Without<Character>>;
fn face_towards(facing: &mut Mut<Facing>, from: GridPos, to: GridPos) {
use game_core::grid::Dir;
let (dx, dy) = (to.x - from.x, to.y - from.y);
let dir = if dx.abs() >= dy.abs() && dx != 0 {
if dx > 0 { Dir::East } else { Dir::West }
} else if dy != 0 {
if dy > 0 { Dir::South } else { Dir::North }
} else {
return;
};
facing.set_if_neq(Facing(dir));
}
fn announce_level_up(
commands: &mut Commands,
client: ClientId,
data: &GameData,
level_up: Option<(String, u32)>,
) {
if let Some((skill, level)) = level_up {
let name = data.skills.get(&skill).map_or(skill.as_str(), |s| &s.name);
tell(
commands,
client,
format!("Your {name} skill is now level {level}!"),
);
}
}
/// Adds items to an inventory, dropping whatever does not fit at `pos`.
fn give_or_drop(
commands: &mut Commands,
data: &GameData,
inventory: &mut Inventory,
stack: ItemStack,
pos: GridPos,
) {
let leftover = inventory.add(&stack.item, stack.count, data);
if leftover > 0 {
spawn_ground_item(commands, ItemStack::new(stack.item, leftover), pos);
}
}
struct ScriptOutcome {
success: bool,
target: Option<ScriptTarget>,
spawned: Vec<ItemStack>,
}
/// Runs a script hook for a character. Changes the script makes to the
/// character are applied; messages are sent to the client. Scripts that error
/// out have no effect.
#[allow(clippy::too_many_arguments)]
fn run_hook(
commands: &mut Commands,
engine: &ScriptEngine,
data: &SharedData,
client: ClientId,
hook: &str,
(character, inventory, vitals, skills): (&Character, &mut Inventory, &mut Vitals, &mut Skills),
item: Option<String>,
target: Option<ScriptTarget>,
) -> Option<ScriptOutcome> {
let mut state = ScriptState::new(
data.0.clone(),
character.name.clone(),
inventory.clone(),
vitals.clone(),
skills.clone(),
);
state.item = item;
state.target = target;
match engine.call(hook, state) {
Ok((state, success)) => {
if *inventory != state.inventory {
*inventory = state.inventory;
}
if *vitals != state.vitals {
*vitals = state.vitals;
}
if *skills != state.skills {
*skills = state.skills;
}
for message in state.messages {
tell(commands, client, message);
}
Some(ScriptOutcome {
success,
target: state.target,
spawned: state.spawned,
})
}
Err(e) => {
error!("{e}");
tell(commands, client, "Something went wrong. (script error)");
None
}
}
}
pub fn on_pick_up(
ev: On<FromClient<PickUp>>,
mut commands: Commands,
players: Res<Players>,
data: Res<GameData>,
mut characters: Characters,
mut items: GroundItems,
) {
let client = ev.client_id;
let target = ev.message.target;
let Some(&entity) = players.by_client.get(&client) else {
return;
};
let Ok((_, pos, mut facing, mut inventory, _, _)) = characters.get_mut(entity) else {
return;
};
if pos.distance(target) > REACH {
return;
}
face_towards(&mut facing, *pos, target);
let mut picked = Vec::new();
for (item_entity, mut item, item_pos) in &mut items {
if *item_pos != target {
continue;
}
let leftover = inventory.add(&item.0.item, item.0.count, &data);
let taken = item.0.count - leftover;
if taken > 0 {
picked.push(format!("{taken} {}", item_name(&data, &item.0.item)));
}
if leftover == 0 {
commands.entity(item_entity).despawn();
} else if taken > 0 {
item.0.count = leftover;
}
}
if picked.is_empty() {
let full = items.iter().any(|(_, _, p)| *p == target);
tell(
&mut commands,
client,
if full {
"Your inventory is full."
} else {
"There is nothing to pick up."
},
);
} else {
tell(
&mut commands,
client,
format!("You pick up {}.", picked.join(", ")),
);
}
}
pub fn on_drop_item(
ev: On<FromClient<DropItem>>,
mut commands: Commands,
players: Res<Players>,
data: Res<GameData>,
mut characters: Characters,
mut items: GroundItems,
) {
let client = ev.client_id;
let Some(&entity) = players.by_client.get(&client) else {
return;
};
let Ok((_, pos, _, mut inventory, _, _)) = characters.get_mut(entity) else {
return;
};
let Some(stack) = inventory.take_from_slot(ev.message.slot, u32::MAX) else {
return;
};
tell(
&mut commands,
client,
format!(
"You drop {} {}.",
stack.count,
item_name(&data, &stack.item)
),
);
// Merge with a matching stack on the floor to avoid clutter.
if let Some((_, mut existing, _)) = items
.iter_mut()
.find(|(_, i, p)| *p == pos && i.0.item == stack.item)
{
existing.0.count += stack.count;
} else {
spawn_ground_item(&mut commands, stack, *pos);
}
}
pub fn on_use_item(
ev: On<FromClient<UseItem>>,
mut commands: Commands,
players: Res<Players>,
data: Res<SharedData>,
engine: Res<ScriptEngine>,
mut characters: Characters,
) {
let client = ev.client_id;
let Some(&entity) = players.by_client.get(&client) else {
return;
};
let Ok((character, pos, _, mut inventory, mut vitals, mut skills)) = characters.get_mut(entity)
else {
return;
};
let Some(item_id) = inventory.slots.get(ev.message.slot).map(|s| s.item.clone()) else {
return;
};
let Some(def) = data.0.items.get(&item_id) else {
return;
};
let Some(hook) = &def.on_use else {
tell(
&mut commands,
client,
format!("You can't use the {}.", def.name),
);
return;
};
let Some(outcome) = run_hook(
&mut commands,
&engine,
&data,
client,
hook,
(character, &mut inventory, &mut vitals, &mut skills),
Some(item_id.clone()),
None,
) else {
return;
};
if outcome.success && def.consume_on_use {
inventory.remove(&item_id, 1);
}
for stack in outcome.spawned {
spawn_ground_item(&mut commands, stack, *pos);
}
}
#[allow(clippy::too_many_arguments)]
pub fn on_interact(
ev: On<FromClient<Interact>>,
mut commands: Commands,
players: Res<Players>,
data: Res<SharedData>,
engine: Res<ScriptEngine>,
mut characters: Characters,
mut buildings: Buildings,
) {
let client = ev.client_id;
let target = ev.message.target;
let Some(&entity) = players.by_client.get(&client) else {
return;
};
let Ok((character, pos, mut facing, mut inventory, mut vitals, mut skills)) =
characters.get_mut(entity)
else {
return;
};
if pos.distance(target) > REACH {
tell(&mut commands, client, "That is too far away.");
return;
}
face_towards(&mut facing, *pos, target);
let Some((building_entity, building, _, mut state)) =
buildings.iter_mut().find(|(_, _, p, _)| **p == target)
else {
tell(&mut commands, client, "There is nothing there.");
return;
};
let Some(def) = data.0.buildings.get(&building.proto) else {
return;
};
let Some(hook) = &def.on_interact else {
let mut text = def.name.clone();
if !def.description.is_empty() {
text = format!("{text}: {}", def.description);
}
if !def.stations.is_empty() {
text = format!("{text} (crafting station, press C to craft)");
}
tell(&mut commands, client, text);
return;
};
let script_target = ScriptTarget {
name: def.name.clone(),
state: state.clone(),
destroyed: false,
};
let Some(outcome) = run_hook(
&mut commands,
&engine,
&data,
client,
hook,
(character, &mut inventory, &mut vitals, &mut skills),
None,
Some(script_target),
) else {
return;
};
if let Some(new_target) = outcome.target {
if new_target.destroyed {
commands.entity(building_entity).despawn();
} else {
state.set_if_neq(new_target.state);
}
}
for stack in outcome.spawned {
spawn_ground_item(&mut commands, stack, target);
}
}
#[allow(clippy::too_many_arguments)]
pub fn on_craft(
ev: On<FromClient<Craft>>,
mut commands: Commands,
players: Res<Players>,
data: Res<SharedData>,
engine: Res<ScriptEngine>,
mut characters: Characters,
buildings: Query<(&Building, &GridPos), Without<Character>>,
) {
let client = ev.client_id;
let Some(&entity) = players.by_client.get(&client) else {
return;
};
let Ok((character, pos, _, mut inventory, mut vitals, mut skills)) = characters.get_mut(entity)
else {
return;
};
let stations: Vec<String> = buildings
.iter()
.filter(|(_, p)| p.distance(*pos) <= REACH)
.filter_map(|(b, _)| data.0.buildings.get(&b.proto))
.flat_map(|def| def.stations.iter().cloned())
.collect();
let mut new_inventory = inventory.clone();
let mut new_skills = skills.clone();
match crafting::try_craft(
&data.0,
&ev.message.recipe,
&stations,
&mut new_inventory,
&mut new_skills,
) {
Ok(crafted) => {
*inventory = new_inventory;
*skills = new_skills;
let recipe = &data.0.recipes[&ev.message.recipe];
tell(
&mut commands,
client,
format!("You craft: {}.", recipe.display_name(&data.0)),
);
announce_level_up(&mut commands, client, &data.0, crafted.level_up);
if let Some(hook) = crafted.on_craft
&& let Some(outcome) = run_hook(
&mut commands,
&engine,
&data,
client,
&hook,
(character, &mut inventory, &mut vitals, &mut skills),
None,
None,
)
{
for stack in outcome.spawned {
spawn_ground_item(&mut commands, stack, *pos);
}
}
}
Err(reason) => tell(&mut commands, client, reason),
}
}
#[allow(clippy::too_many_arguments)]
pub fn on_construct(
ev: On<FromClient<Construct>>,
mut commands: Commands,
players: Res<Players>,
data: Res<GameData>,
layout: Res<WorldLayout>,
mut characters: Characters,
buildings: Query<&GridPos, (With<Building>, Without<Character>)>,
occupants: Query<&GridPos, (With<Character>, Without<Building>)>,
) {
let client = ev.client_id;
let target = ev.message.target;
let Some(&entity) = players.by_client.get(&client) else {
return;
};
// Checked before borrowing the actor mutably.
let occupied = occupants.iter().any(|p| *p == target);
let Ok((_, pos, mut facing, mut inventory, _, mut skills)) = characters.get_mut(entity) else {
return;
};
if pos.distance(target) > REACH {
tell(&mut commands, client, "That is too far away.");
return;
}
face_towards(&mut facing, *pos, target);
let Some(def) = data.buildings.get(&ev.message.building) else {
return;
};
if layout.0.is_solid(target) || buildings.iter().any(|p| *p == target) {
tell(&mut commands, client, "There is no room to build there.");
return;
}
if def.solid && occupied {
tell(&mut commands, client, "Someone is standing there.");
return;
}
let mut new_inventory = inventory.clone();
let mut new_skills = skills.clone();
match crafting::try_construct(&data, &def.id, &mut new_inventory, &mut new_skills) {
Ok(level_up) => {
*inventory = new_inventory;
*skills = new_skills;
spawn_building(&mut commands, &data, &def.id, target, Default::default());
tell(&mut commands, client, format!("You build a {}.", def.name));
announce_level_up(&mut commands, client, &data, level_up);
}
Err(reason) => tell(&mut commands, client, reason),
}
}
pub fn on_deconstruct(
ev: On<FromClient<Deconstruct>>,
mut commands: Commands,
players: Res<Players>,
data: Res<GameData>,
mut characters: Characters,
buildings: Query<(Entity, &Building, &GridPos), Without<Character>>,
) {
let client = ev.client_id;
let target = ev.message.target;
let Some(&entity) = players.by_client.get(&client) else {
return;
};
let Ok((_, pos, mut facing, mut inventory, _, _)) = characters.get_mut(entity) else {
return;
};
if pos.distance(target) > REACH {
tell(&mut commands, client, "That is too far away.");
return;
}
face_towards(&mut facing, *pos, target);
let Some((building_entity, building, _)) = buildings.iter().find(|(_, _, p)| **p == target)
else {
tell(&mut commands, client, "There is nothing to deconstruct.");
return;
};
let Some(def) = data.buildings.get(&building.proto) else {
return;
};
if !def.deconstructible {
tell(
&mut commands,
client,
format!("The {} can't be deconstructed.", def.name),
);
return;
}
if let Some(construction) = &def.construction {
let missing: Vec<&str> = construction
.tools
.iter()
.filter(|t| !inventory.has(t, 1))
.map(|t| item_name(&data, t))
.collect();
if !missing.is_empty() {
tell(
&mut commands,
client,
format!("You need: {}.", missing.join(", ")),
);
return;
}
for material in &construction.materials {
give_or_drop(
&mut commands,
&data,
&mut inventory,
ItemStack::new(&material.item, material.count),
*pos,
);
}
}
commands.entity(building_entity).despawn();
tell(
&mut commands,
client,
format!("You take apart the {}.", def.name),
);
}

View File

@ -0,0 +1,235 @@
//! Pure crafting and construction rules, independent of the ECS so they are easy to test.
use game_core::{
GameData,
components::{Inventory, Skills},
data::{Ingredient, SkillRequirement},
};
/// Result of a successful craft, applied by the caller.
#[derive(Debug, PartialEq, Eq)]
pub struct Crafted {
pub level_up: Option<(String, u32)>,
pub on_craft: Option<String>,
}
pub fn item_name<'a>(data: &'a GameData, id: &'a str) -> &'a str {
data.items.get(id).map_or(id, |i| i.name.as_str())
}
fn check_requirements(
data: &GameData,
inventory: &Inventory,
skills: &Skills,
ingredients: &[Ingredient],
tools: &[String],
skill: Option<&SkillRequirement>,
) -> Result<(), String> {
if let Some(req) = skill {
let level = skills.level(&req.skill);
if level < req.level {
let name = data
.skills
.get(&req.skill)
.map_or(req.skill.as_str(), |s| &s.name);
return Err(format!(
"Requires {name} level {} (you have {level}).",
req.level
));
}
}
let missing_tools: Vec<&str> = tools
.iter()
.filter(|t| !inventory.has(t, 1))
.map(|t| item_name(data, t))
.collect();
if !missing_tools.is_empty() {
return Err(format!("You need: {}.", missing_tools.join(", ")));
}
let missing: Vec<String> = ingredients
.iter()
.filter(|i| !inventory.has(&i.item, i.count))
.map(|i| {
format!(
"{} {} (have {})",
i.count,
item_name(data, &i.item),
inventory.count(&i.item)
)
})
.collect();
if !missing.is_empty() {
return Err(format!("Missing materials: {}.", missing.join(", ")));
}
Ok(())
}
/// Crafts `recipe` with the inventory. `stations` are the station tags available nearby.
/// On failure nothing is changed and a player-facing reason is returned.
pub fn try_craft(
data: &GameData,
recipe: &str,
stations: &[String],
inventory: &mut Inventory,
skills: &mut Skills,
) -> Result<Crafted, String> {
let recipe = data
.recipes
.get(recipe)
.ok_or_else(|| format!("Unknown recipe `{recipe}`."))?;
if let Some(station) = &recipe.station
&& !stations.contains(station)
{
return Err(format!("You need to stand next to a {station}."));
}
check_requirements(
data,
inventory,
skills,
&recipe.inputs,
&recipe.tools,
recipe.skill.as_ref(),
)?;
// Verify outputs fit before consuming anything.
let mut trial = inventory.clone();
for input in &recipe.inputs {
trial.remove(&input.item, input.count);
}
for output in &recipe.outputs {
if trial.add(&output.item, output.count, data) > 0 {
return Err("Your inventory is full.".into());
}
}
*inventory = trial;
let level_up = recipe.skill.as_ref().and_then(|req| {
skills
.add_xp(&req.skill, recipe.xp)
.map(|level| (req.skill.clone(), level))
});
Ok(Crafted {
level_up,
on_craft: recipe.on_craft.clone(),
})
}
/// Consumes construction materials for `building`. Returns the xp level-up, if any.
pub fn try_construct(
data: &GameData,
building: &str,
inventory: &mut Inventory,
skills: &mut Skills,
) -> Result<Option<(String, u32)>, String> {
let def = data
.buildings
.get(building)
.ok_or_else(|| format!("Unknown building `{building}`."))?;
let construction = def
.construction
.as_ref()
.ok_or_else(|| format!("{} can not be built.", def.name))?;
check_requirements(
data,
inventory,
skills,
&construction.materials,
&construction.tools,
construction.skill.as_ref(),
)?;
for m in &construction.materials {
inventory.remove(&m.item, m.count);
}
Ok(construction.skill.as_ref().and_then(|req| {
skills
.add_xp(&req.skill, construction.xp)
.map(|level| (req.skill.clone(), level))
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn data() -> GameData {
GameData::load_dir(&game_core::asset_path("data")).unwrap()
}
#[test]
fn craft_planks_needs_station_and_materials() {
let data = data();
let mut inv = Inventory::default();
let mut skills = Skills::default();
let bench = vec!["workbench".to_owned()];
let err = try_craft(&data, "saw_planks", &[], &mut inv, &mut skills).unwrap_err();
assert!(err.contains("workbench"), "{err}");
let err = try_craft(&data, "saw_planks", &bench, &mut inv, &mut skills).unwrap_err();
assert!(err.contains("Missing"), "{err}");
inv.add("log", 2, &data);
try_craft(&data, "saw_planks", &bench, &mut inv, &mut skills).unwrap();
assert_eq!(inv.count("log"), 1);
assert_eq!(inv.count("plank"), 4);
assert_eq!(skills.skills["carpentry"].xp, 5);
}
#[test]
fn craft_requires_tools_and_skill() {
let data = data();
let mut inv = Inventory::default();
let mut skills = Skills::default();
let bench = vec!["workbench".to_owned()];
inv.add("iron_plate", 2, &data);
inv.add("plank", 1, &data);
let err = try_craft(&data, "make_pickaxe", &bench, &mut inv, &mut skills).unwrap_err();
assert!(err.contains("Wrench"), "{err}");
inv.add("wrench", 1, &data);
try_craft(&data, "make_pickaxe", &bench, &mut inv, &mut skills).unwrap();
assert!(inv.has("wrench", 1) && inv.has("pickaxe", 1));
inv.add("iron_plate", 1, &data);
inv.add("apple", 2, &data);
let err = try_craft(&data, "make_medkit", &bench, &mut inv, &mut skills).unwrap_err();
assert!(err.contains("level 2"), "{err}");
skills.add_xp("medicine", 50);
let crafted = try_craft(&data, "make_medkit", &bench, &mut inv, &mut skills).unwrap();
assert_eq!(crafted.on_craft, None);
assert!(inv.has("medkit", 1));
}
#[test]
fn full_inventory_is_not_consumed() {
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);
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();
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));
}
#[test]
fn construct_consumes_materials() {
let data = data();
let mut inv = Inventory::default();
let mut skills = Skills::default();
assert!(try_construct(&data, "tree", &mut inv, &mut skills).is_err());
assert!(try_construct(&data, "table", &mut inv, &mut skills).is_err());
inv.add("plank", 3, &data);
try_construct(&data, "table", &mut inv, &mut skills).unwrap();
assert_eq!(inv.count("plank"), 1);
}
}

View File

@ -0,0 +1,85 @@
//! Authoritative game simulation.
//!
//! Used by the dedicated server binary and embedded in the client for
//! singleplayer and hosting. Clients only send intents (see
//! [`game_core::protocol`]); all game rules run here.
pub mod actions;
pub mod crafting;
pub mod movement;
pub mod network;
pub mod persistence;
pub mod players;
pub mod scripting;
pub mod survival;
pub mod world;
use std::{path::PathBuf, sync::Arc, time::Duration};
use bevy::prelude::*;
use game_core::GameData;
pub use network::NetworkServerPlugin;
/// Configuration of the simulation.
#[derive(Resource, Clone, Debug)]
pub struct ServerSettings {
/// Tiled map, relative to the assets directory.
pub map: String,
/// Where the persistent world is stored. `None` disables saving.
pub save_path: Option<PathBuf>,
pub autosave_interval: Duration,
}
impl Default for ServerSettings {
fn default() -> Self {
Self {
map: game_core::DEFAULT_MAP.to_owned(),
save_path: None,
autosave_interval: Duration::from_secs(60),
}
}
}
/// Runs the game world. Requires [`game_core::CorePlugin`] and the replicon plugins.
pub struct GameServerPlugin {
pub settings: ServerSettings,
}
impl Plugin for GameServerPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(self.settings.clone())
.insert_resource(persistence::AutosaveTimer(Timer::new(
self.settings.autosave_interval,
TimerMode::Repeating,
)))
.init_resource::<players::Players>()
.init_resource::<players::CharacterStore>()
.init_resource::<survival::SurvivalTimer>()
.add_systems(Startup, (load_scripts, world::setup_world).chain())
.add_systems(
FixedUpdate,
(movement::move_characters, survival::tick_survival),
)
.add_systems(Update, persistence::autosave)
.add_systems(Last, persistence::save_on_exit)
.add_observer(players::on_login)
.add_observer(players::on_disconnect)
.add_observer(movement::on_set_move_dir)
.add_observer(actions::on_pick_up)
.add_observer(actions::on_drop_item)
.add_observer(actions::on_use_item)
.add_observer(actions::on_interact)
.add_observer(actions::on_craft)
.add_observer(actions::on_construct)
.add_observer(actions::on_deconstruct);
}
}
fn load_scripts(mut commands: Commands, data: Res<GameData>) {
let dir = game_core::asset_path("scripts");
let engine = scripting::ScriptEngine::load(&dir, &data).unwrap_or_else(|e| panic!("{e}"));
info!("compiled scripts from `{}`", dir.display());
commands.insert_resource(engine);
commands.insert_resource(actions::SharedData(Arc::new(data.clone())));
}

View File

@ -0,0 +1,71 @@
//! Headless dedicated server.
//!
//! ```text
//! game-server [--port 5000] [--save saves/server.rsn | --no-save] [--map maps/station.tmx]
//! ```
use std::{path::PathBuf, time::Duration};
use bevy::{app::ScheduleRunnerPlugin, log::LogPlugin, prelude::*, state::app::StatesPlugin};
use bevy_replicon::prelude::*;
use bevy_replicon_renet::RepliconRenetPlugins;
use game_core::CorePlugin;
use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings};
fn main() -> AppExit {
let mut port = game_core::DEFAULT_PORT;
let mut settings = ServerSettings {
save_path: Some(PathBuf::from("saves/server.rsn")),
..Default::default()
};
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
let mut value = |name: &str| {
args.next()
.unwrap_or_else(|| usage(&format!("{name} needs a value")))
};
match arg.as_str() {
"--port" => {
port = value("--port")
.parse()
.unwrap_or_else(|_| usage("invalid port"))
}
"--save" => settings.save_path = Some(value("--save").into()),
"--no-save" => settings.save_path = None,
"--map" => settings.map = value("--map"),
"-h" | "--help" => usage(""),
other => usage(&format!("unknown argument `{other}`")),
}
}
App::new()
.add_plugins((
MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64(
1.0 / 60.0,
))),
StatesPlugin,
LogPlugin::default(),
bevy::app::TerminalCtrlCHandlerPlugin,
RepliconPlugins,
RepliconRenetPlugins,
CorePlugin,
GameServerPlugin { settings },
NetworkServerPlugin { port },
))
.run()
}
fn usage(error: &str) -> ! {
if !error.is_empty() {
eprintln!("error: {error}\n");
}
eprintln!(
"usage: game-server [--port PORT] [--save FILE | --no-save] [--map MAP]\n\n\
--port UDP port to listen on (default {})\n\
--save persistent world file (default saves/server.rsn)\n\
--map Tiled map relative to the assets directory (default {})",
game_core::DEFAULT_PORT,
game_core::DEFAULT_MAP
);
std::process::exit(if error.is_empty() { 0 } else { 2 })
}

View File

@ -0,0 +1,58 @@
//! Tile-based character movement.
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use game_core::{components::*, grid::Dir, protocol::SetMoveDir};
use crate::{
players::Players,
world::{WorldLayout, is_blocked},
};
/// Seconds per tile step.
pub const MOVE_DELAY: f32 = 0.16;
#[derive(Component, Default, Debug)]
pub struct MoveIntent {
pub dir: Option<Dir>,
pub cooldown: f32,
}
pub fn on_set_move_dir(
ev: On<FromClient<SetMoveDir>>,
players: Res<Players>,
mut intents: Query<(&mut MoveIntent, &mut Facing)>,
) {
let Some(&entity) = players.by_client.get(&ev.client_id) else {
return;
};
if let Ok((mut intent, mut facing)) = intents.get_mut(entity) {
intent.dir = ev.message.0;
if let Some(dir) = intent.dir {
facing.set_if_neq(Facing(dir));
}
}
}
pub fn move_characters(
time: Res<Time>,
layout: Option<Res<WorldLayout>>,
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
mut characters: Query<(&mut MoveIntent, &mut GridPos, &mut Facing), With<Character>>,
) {
let Some(layout) = layout else { return };
let dt = time.delta_secs();
for (mut intent, mut pos, mut facing) in &mut characters {
intent.cooldown = (intent.cooldown - dt).max(0.0);
let Some(dir) = intent.dir else { continue };
if intent.cooldown > 0.0 {
continue;
}
facing.set_if_neq(Facing(dir));
let next = pos.step(dir);
if !is_blocked(next, &layout.0, buildings.iter()) {
*pos = next;
intent.cooldown = MOVE_DELAY;
}
}
}

View File

@ -0,0 +1,68 @@
//! Starting the renet network server (host and dedicated modes).
use std::{
net::{Ipv4Addr, SocketAddr, UdpSocket},
time::SystemTime,
};
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use bevy_replicon_renet::{
RenetChannelsExt, RenetServer,
netcode::{NetcodeServerTransport, ServerAuthentication, ServerConfig},
renet::ConnectionConfig,
};
pub const MAX_CLIENTS: usize = 32;
/// Opens a UDP server on `port` once all replicon channels are registered.
pub struct NetworkServerPlugin {
pub port: u16,
}
#[derive(Resource)]
struct ServerPort(u16);
impl Plugin for NetworkServerPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(ServerPort(self.port))
.add_systems(Startup, start_server);
}
}
fn start_server(mut commands: Commands, channels: Res<RepliconChannels>, port: Res<ServerPort>) {
match create_server(&channels, port.0) {
Ok((server, transport)) => {
info!("listening on UDP port {}", port.0);
commands.insert_resource(server);
commands.insert_resource(transport);
}
Err(e) => {
error!("failed to start server on port {}: {e}", port.0);
commands.write_message(AppExit::error());
}
}
}
pub fn create_server(
channels: &RepliconChannels,
port: u16,
) -> Result<(RenetServer, NetcodeServerTransport), Box<dyn std::error::Error>> {
let server = RenetServer::new(ConnectionConfig {
server_channels_config: channels.server_configs(),
client_channels_config: channels.client_configs(),
..Default::default()
});
let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port))?;
let server_config = ServerConfig {
current_time,
max_clients: MAX_CLIENTS,
protocol_id: game_core::PROTOCOL_ID,
authentication: ServerAuthentication::Unsecure,
// Not used with unsecure authentication, but must be valid.
public_addresses: vec![SocketAddr::from((Ipv4Addr::LOCALHOST, port))],
};
let transport = NetcodeServerTransport::new(server_config, socket)?;
Ok((server, transport))
}

View File

@ -0,0 +1,241 @@
//! Persistent world storage in rsn format.
//!
//! The world (buildings, ground items, all characters including offline ones) is
//! written to a single human-readable `.rsn` file. Saves are atomic: data is
//! written to a temporary file which then replaces the previous save.
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
use bevy::prelude::*;
use game_core::{GameData, components::*, grid::Dir};
use serde::{Deserialize, Serialize};
use crate::{
ServerSettings,
players::CharacterStore,
world::{spawn_building_with_state, spawn_ground_item},
};
pub const SAVE_VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SavedCharacter {
pub pos: GridPos,
pub facing: Dir,
pub vitals: Vitals,
pub inventory: Inventory,
pub skills: Skills,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SavedBuilding {
pub proto: String,
pub pos: GridPos,
pub state: BuildingState,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SavedItem {
pub stack: ItemStack,
pub pos: GridPos,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct SaveGame {
pub version: u32,
pub map: String,
pub buildings: Vec<SavedBuilding>,
pub items: Vec<SavedItem>,
pub characters: BTreeMap<String, SavedCharacter>,
}
#[derive(Resource)]
pub struct AutosaveTimer(pub Timer);
pub fn load(path: Option<&Path>) -> Option<SaveGame> {
let path = path?;
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => panic!("failed to read save `{}`: {e}", path.display()),
};
let save: SaveGame = rsn::from_str(&text)
.unwrap_or_else(|e| panic!("save `{}` is corrupt: {e}", path.display()));
if save.version != SAVE_VERSION {
panic!(
"save `{}` has version {}, expected {SAVE_VERSION}",
path.display(),
save.version
);
}
Some(save)
}
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);
} else {
warn!(
"dropping saved building with removed prototype `{}`",
b.proto
);
}
}
for item in save.items {
if data.items.contains_key(&item.stack.item) {
spawn_ground_item(commands, item.stack, item.pos);
} else {
warn!(
"dropping saved item with removed prototype `{}`",
item.stack.item
);
}
}
commands.insert_resource(CharacterStore(save.characters));
}
pub fn write(path: &Path, save: &SaveGame) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let text = rsn::to_string_pretty(save).map_err(std::io::Error::other)?;
let tmp: PathBuf = path.with_extension("rsn.tmp");
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path)
}
/// Collects the full world state.
pub fn snapshot(world: &mut World) -> SaveGame {
let map = world.resource::<ServerSettings>().map.clone();
let mut characters = world.resource::<CharacterStore>().0.clone();
let mut q = world.query::<(&Character, &GridPos, &Facing, &Vitals, &Inventory, &Skills)>();
for (c, pos, facing, vitals, inventory, skills) in q.iter(world) {
characters.insert(
c.name.clone(),
SavedCharacter {
pos: *pos,
facing: facing.0,
vitals: vitals.clone(),
inventory: inventory.clone(),
skills: skills.clone(),
},
);
}
let mut q = world.query::<(&Building, &GridPos, &BuildingState)>();
let buildings = q
.iter(world)
.map(|(b, pos, state)| SavedBuilding {
proto: b.proto.clone(),
pos: *pos,
state: state.clone(),
})
.collect();
let mut q = world.query::<(&GroundItem, &GridPos)>();
let items = q
.iter(world)
.map(|(item, pos)| SavedItem {
stack: item.0.clone(),
pos: *pos,
})
.collect();
SaveGame {
version: SAVE_VERSION,
map,
buildings,
items,
characters,
}
}
pub fn save_world(world: &mut World) {
let Some(path) = world.resource::<ServerSettings>().save_path.clone() else {
return;
};
let save = snapshot(world);
match write(&path, &save) {
Ok(()) => debug!("world saved to `{}`", path.display()),
Err(e) => error!("failed to save world to `{}`: {e}", path.display()),
}
}
pub fn autosave(world: &mut World) {
let delta = world.resource::<Time<Real>>().delta();
let finished = world
.resource_mut::<AutosaveTimer>()
.0
.tick(delta)
.just_finished();
if finished {
save_world(world);
}
}
pub fn save_on_exit(world: &mut World) {
let exiting = !world.resource::<Messages<AppExit>>().is_empty();
if exiting {
info!("saving world before exit");
save_world(world);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn save_roundtrip() {
let mut save = SaveGame {
version: SAVE_VERSION,
map: "maps/test.tmx".into(),
..default()
};
let mut inventory = Inventory::default();
inventory.slots.push(ItemStack::new("apple", 3));
let mut skills = Skills::default();
skills.add_xp("mining", 120);
save.characters.insert(
"Alice".into(),
SavedCharacter {
pos: GridPos::new(3, -4),
facing: Dir::West,
vitals: Vitals {
health: 42.5,
..default()
},
inventory,
skills,
},
);
save.buildings.push(SavedBuilding {
proto: "door".into(),
pos: GridPos::new(1, 2),
state: BuildingState {
solid: false,
variant: Some("open".into()),
health: 100,
vars: [("stock".to_owned(), 3)].into(),
},
});
save.buildings.push(SavedBuilding {
proto: "wall".into(),
pos: GridPos::new(0, 0),
state: BuildingState::default(),
});
save.items.push(SavedItem {
stack: ItemStack::new("log", 2),
pos: GridPos::new(5, 5),
});
let dir = std::env::temp_dir().join(format!("game_save_test_{}", std::process::id()));
let path = dir.join("world.rsn");
write(&path, &save).unwrap();
let loaded = load(Some(&path)).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
assert_eq!(loaded, save);
assert!(load(Some(&dir.join("missing.rsn"))).is_none());
}
}

View File

@ -0,0 +1,172 @@
//! Login/logout and the mapping between network clients and characters.
use std::collections::{BTreeMap, HashMap};
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use game_core::{
GameData,
components::*,
protocol::{GameLog, Login},
};
use crate::{
movement::MoveIntent,
persistence::SavedCharacter,
world::{SpawnPoint, WorldLayout, find_free_tile},
};
/// Which client controls a character. Server-only.
#[derive(Component, Clone, Copy, Debug)]
pub struct Controller(pub ClientId);
/// Online characters by client.
#[derive(Resource, Default)]
pub struct Players {
pub by_client: HashMap<ClientId, Entity>,
}
/// Characters that are currently offline, by name. Persisted with the world.
#[derive(Resource, Default)]
pub struct CharacterStore(pub BTreeMap<String, SavedCharacter>);
pub const MAX_NAME_LEN: usize = 24;
pub fn tell(commands: &mut Commands, client: ClientId, text: impl Into<String>) {
commands.server_trigger(ToClients {
targets: SendTargets::Single(client),
message: GameLog { text: text.into() },
});
}
pub fn valid_name(name: &str) -> bool {
let len = name.chars().count();
(1..=MAX_NAME_LEN).contains(&len)
&& name.trim() == name
&& name
.chars()
.all(|c| c.is_alphanumeric() || c == ' ' || c == '_' || c == '-')
}
#[allow(clippy::too_many_arguments)]
pub fn on_login(
login: On<FromClient<Login>>,
mut commands: Commands,
mut players: ResMut<Players>,
mut store: ResMut<CharacterStore>,
mut disconnects: MessageWriter<DisconnectRequest>,
data: Res<GameData>,
spawn: Res<SpawnPoint>,
layout: Res<WorldLayout>,
characters: Query<&Character>,
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
) {
let client = login.client_id;
let name = login.message.name.clone();
if players.by_client.contains_key(&client) {
return;
}
let reject = |commands: &mut Commands,
disconnects: &mut MessageWriter<DisconnectRequest>,
reason: &str| {
tell(commands, client, reason);
if let ClientId::Client(entity) = client {
disconnects.write(DisconnectRequest { client: entity });
}
};
if !valid_name(&name) {
reject(&mut commands, &mut disconnects, "Invalid character name.");
return;
}
if characters.iter().any(|c| c.name == name) {
reject(
&mut commands,
&mut disconnects,
"That character is already online.",
);
return;
}
let (saved, returning) = match store.0.remove(&name) {
Some(saved) => (saved, true),
None => (new_character(&data, spawn.0), false),
};
let pos = find_free_tile(saved.pos, &layout.0, &buildings);
let entity = commands
.spawn((
Replicated,
Character { name: name.clone() },
pos,
Facing(saved.facing),
saved.vitals,
saved.inventory,
saved.skills,
Controller(client),
MoveIntent::default(),
))
.id();
players.by_client.insert(client, entity);
info!("{name} logged in ({client:?})");
let greeting = if returning {
format!("Welcome back, {name}.")
} else {
format!("Welcome to the station, {name}. Press H for help.")
};
tell(&mut commands, client, greeting);
}
fn new_character(data: &GameData, spawn: GridPos) -> SavedCharacter {
let mut inventory = Inventory::default();
if data.items.contains_key("apple") {
inventory.add("apple", 2, data);
}
SavedCharacter {
pos: spawn,
facing: Default::default(),
vitals: Vitals::default(),
inventory,
skills: Skills::default(),
}
}
/// Stores the character of a disconnecting client and removes it from the world.
pub fn on_disconnect(
remove: On<Remove, ConnectedClient>,
mut commands: Commands,
mut players: ResMut<Players>,
mut store: ResMut<CharacterStore>,
characters: Query<(&Character, &GridPos, &Facing, &Vitals, &Inventory, &Skills)>,
) {
let Some(entity) = players.by_client.remove(&ClientId::Client(remove.entity)) else {
return;
};
if let Ok((c, pos, facing, vitals, inventory, skills)) = characters.get(entity) {
info!("{} logged out", c.name);
store.0.insert(
c.name.clone(),
SavedCharacter {
pos: *pos,
facing: facing.0,
vitals: vitals.clone(),
inventory: inventory.clone(),
skills: skills.clone(),
},
);
}
commands.entity(entity).despawn();
}
#[cfg(test)]
mod tests {
use super::valid_name;
#[test]
fn names() {
assert!(valid_name("Jane Doe"));
assert!(valid_name("x_1-2"));
assert!(!valid_name(""));
assert!(!valid_name(" padded"));
assert!(!valid_name("semi;colon"));
assert!(!valid_name(&"a".repeat(25)));
}
}

View File

@ -0,0 +1,593 @@
//! Rune scripting.
//!
//! Every `.rn` file in `assets/scripts/` is compiled into its own unit. Data files
//! refer to script functions with hooks of the form `"module::function"` where the
//! module is the file path without extension (`scripts/door.rn` -> `door`,
//! `scripts/npc/guard.rn` -> `npc::guard`).
//!
//! Scripts get a single argument, `ctx`, with methods to inspect and modify the
//! acting character, the used item and the target building. Changes are staged in
//! the context and applied to the world after the script returned. A script
//! returning `false` signals failure (for example, an item is then not consumed).
use std::{
collections::{BTreeMap, HashMap},
path::Path,
sync::{Arc, Mutex, MutexGuard},
};
use bevy::prelude::*;
use game_core::{
GameData,
components::{BuildingState, Inventory, ItemStack, Skills, Vitals},
};
use rune::{
Any, Context, ContextError, Diagnostics, Module, Source, Sources, Unit, Vm,
runtime::RuntimeContext, termcolor::Buffer,
};
#[derive(Debug, thiserror::Error)]
pub enum ScriptError {
#[error("failed to read scripts: {0}")]
Io(#[from] std::io::Error),
#[error("failed to compile `{0}`:\n{1}")]
Compile(String, String),
#[error("script setup failed: {0}")]
Setup(String),
#[error("{0}")]
InvalidHooks(String),
#[error("unknown script hook `{0}`")]
UnknownHook(String),
#[error("script `{0}` failed: {1}")]
Runtime(String, String),
}
/// The building a script acts on.
#[derive(Clone, Debug)]
pub struct ScriptTarget {
pub name: String,
pub state: BuildingState,
pub destroyed: bool,
}
/// Everything a script can read and modify. Applied back to the world afterwards.
#[derive(Debug)]
pub struct ScriptState {
pub actor_name: String,
pub inventory: Inventory,
pub vitals: Vitals,
pub skills: Skills,
pub item: Option<String>,
pub target: Option<ScriptTarget>,
pub messages: Vec<String>,
/// Items to drop on the target tile (or the actor's tile without target).
pub spawned: Vec<ItemStack>,
data: Arc<GameData>,
rng: u64,
}
impl ScriptState {
pub fn new(
data: Arc<GameData>,
actor_name: String,
inventory: Inventory,
vitals: Vitals,
skills: Skills,
) -> Self {
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0x2545_f491, |d| d.as_nanos() as u64);
Self {
actor_name,
inventory,
vitals,
skills,
item: None,
target: None,
messages: Vec::new(),
spawned: Vec::new(),
data,
rng: seed | 1,
}
}
fn known_item(&mut self, id: &str) -> bool {
let known = self.data.items.contains_key(id);
if !known {
warn!("script referenced unknown item `{id}`");
}
known
}
}
/// The `ctx` object passed to scripts.
#[derive(Any, Clone)]
#[rune(item = ::game)]
pub struct Ctx {
state: Arc<Mutex<ScriptState>>,
}
impl Ctx {
fn lock(&self) -> MutexGuard<'_, ScriptState> {
self.state.lock().unwrap_or_else(|e| e.into_inner())
}
/// Name of the acting character.
#[rune::function(keep)]
fn actor_name(&self) -> String {
self.lock().actor_name.clone()
}
/// Shows a message to the acting player.
#[rune::function(keep)]
fn message(&self, text: &str) {
self.lock().messages.push(text.to_owned());
}
#[rune::function(keep)]
fn item_count(&self, item: &str) -> i64 {
self.lock().inventory.count(item) as i64
}
#[rune::function(keep)]
fn has_item(&self, item: &str, count: i64) -> bool {
self.lock().inventory.has(item, count.max(0) as u32)
}
/// Gives items to the actor; whatever does not fit is dropped on the floor.
#[rune::function(keep)]
fn give_item(&self, item: &str, count: i64) {
let mut s = self.lock();
if count <= 0 || !s.known_item(item) {
return;
}
let data = s.data.clone();
let leftover = s.inventory.add(item, count as u32, &data);
if leftover > 0 {
s.spawned.push(ItemStack::new(item, leftover));
}
}
/// Removes items from the actor. Returns `false` (and removes nothing) if missing.
#[rune::function(keep)]
fn take_item(&self, item: &str, count: i64) -> bool {
self.lock().inventory.remove(item, count.max(0) as u32)
}
/// Drops items on the target tile.
#[rune::function(keep)]
fn spawn_item(&self, item: &str, count: i64) {
let mut s = self.lock();
if count > 0 && s.known_item(item) {
s.spawned.push(ItemStack::new(item, count as u32));
}
}
#[rune::function(keep)]
fn health(&self) -> i64 {
self.lock().vitals.health.round() as i64
}
#[rune::function(keep)]
fn max_health(&self) -> i64 {
self.lock().vitals.max_health.round() as i64
}
#[rune::function(keep)]
fn heal(&self, amount: i64) {
self.lock().vitals.heal(amount as f32);
}
#[rune::function(keep)]
fn damage(&self, amount: i64) {
self.lock().vitals.heal(-(amount as f32));
}
#[rune::function(keep)]
fn nutrition(&self) -> i64 {
self.lock().vitals.nutrition.round() as i64
}
#[rune::function(keep)]
fn feed(&self, amount: i64) {
self.lock().vitals.feed(amount as f32);
}
#[rune::function(keep)]
fn skill_level(&self, skill: &str) -> i64 {
self.lock().skills.level(skill) as i64
}
/// Grants experience and announces level-ups.
#[rune::function(keep)]
fn add_xp(&self, skill: &str, amount: i64) {
let mut s = self.lock();
if !s.data.skills.contains_key(skill) {
warn!("script referenced unknown skill `{skill}`");
return;
}
if let Some(level) = s.skills.add_xp(skill, amount.max(0) as u32) {
let name = s.data.skills[skill].name.clone();
s.messages
.push(format!("Your {name} skill is now level {level}!"));
}
}
/// Id of the item being used, or an empty string.
#[rune::function(keep)]
fn item_id(&self) -> String {
self.lock().item.clone().unwrap_or_default()
}
/// Numeric property of the used item from its data definition (`props`), or 0.
#[rune::function(keep)]
fn item_prop(&self, key: &str) -> i64 {
let s = self.lock();
s.item
.as_ref()
.and_then(|i| s.data.items.get(i))
.and_then(|def| def.props.get(key))
.copied()
.unwrap_or(0)
}
/// Display name of the target building, or an empty string.
#[rune::function(keep)]
fn target_name(&self) -> String {
self.lock()
.target
.as_ref()
.map(|t| t.name.clone())
.unwrap_or_default()
}
/// Reads a target building variable, 0 if unset.
#[rune::function(keep)]
fn get_var(&self, key: &str) -> i64 {
self.lock()
.target
.as_ref()
.and_then(|t| t.state.vars.get(key).copied())
.unwrap_or(0)
}
#[rune::function(keep)]
fn set_var(&self, key: &str, value: i64) {
if let Some(t) = self.lock().target.as_mut() {
t.state.vars.insert(key.to_owned(), value);
}
}
#[rune::function(keep)]
fn is_solid(&self) -> bool {
self.lock().target.as_ref().is_some_and(|t| t.state.solid)
}
#[rune::function(keep)]
fn set_solid(&self, solid: bool) {
if let Some(t) = self.lock().target.as_mut() {
t.state.solid = solid;
}
}
/// Current sprite variant of the target, empty for the default sprite.
#[rune::function(keep)]
fn variant(&self) -> String {
self.lock()
.target
.as_ref()
.and_then(|t| t.state.variant.clone())
.unwrap_or_default()
}
/// Switches the target sprite to one of its `variants`; empty string resets.
#[rune::function(keep)]
fn set_variant(&self, variant: &str) {
if let Some(t) = self.lock().target.as_mut() {
t.state.variant = (!variant.is_empty()).then(|| variant.to_owned());
}
}
/// Removes the target building from the world.
#[rune::function(keep)]
fn destroy_target(&self) {
if let Some(t) = self.lock().target.as_mut() {
t.destroyed = true;
}
}
/// Random integer in `0..max` (0 if `max <= 0`).
#[rune::function(keep)]
fn random(&self, max: i64) -> i64 {
if max <= 0 {
return 0;
}
let mut s = self.lock();
// xorshift64*
s.rng ^= s.rng >> 12;
s.rng ^= s.rng << 25;
s.rng ^= s.rng >> 27;
(s.rng.wrapping_mul(0x2545_f491_4f6c_dd1d) % max as u64) as i64
}
}
fn game_module() -> Result<Module, ContextError> {
let mut m = Module::with_crate("game")?;
m.ty::<Ctx>()?;
m.function_meta(Ctx::actor_name__meta)?;
m.function_meta(Ctx::message__meta)?;
m.function_meta(Ctx::item_count__meta)?;
m.function_meta(Ctx::has_item__meta)?;
m.function_meta(Ctx::give_item__meta)?;
m.function_meta(Ctx::take_item__meta)?;
m.function_meta(Ctx::spawn_item__meta)?;
m.function_meta(Ctx::health__meta)?;
m.function_meta(Ctx::max_health__meta)?;
m.function_meta(Ctx::heal__meta)?;
m.function_meta(Ctx::damage__meta)?;
m.function_meta(Ctx::nutrition__meta)?;
m.function_meta(Ctx::feed__meta)?;
m.function_meta(Ctx::skill_level__meta)?;
m.function_meta(Ctx::add_xp__meta)?;
m.function_meta(Ctx::item_id__meta)?;
m.function_meta(Ctx::item_prop__meta)?;
m.function_meta(Ctx::target_name__meta)?;
m.function_meta(Ctx::get_var__meta)?;
m.function_meta(Ctx::set_var__meta)?;
m.function_meta(Ctx::is_solid__meta)?;
m.function_meta(Ctx::set_solid__meta)?;
m.function_meta(Ctx::variant__meta)?;
m.function_meta(Ctx::set_variant__meta)?;
m.function_meta(Ctx::destroy_target__meta)?;
m.function_meta(Ctx::random__meta)?;
Ok(m)
}
/// Compiled scripts, keyed by module name.
#[derive(Resource)]
pub struct ScriptEngine {
runtime: Arc<RuntimeContext>,
units: HashMap<String, Arc<Unit>>,
}
impl ScriptEngine {
/// Compiles all scripts in `dir` and verifies all hooks referenced by `data`.
pub fn load(dir: &Path, data: &GameData) -> Result<Self, ScriptError> {
let mut files = Vec::new();
collect_scripts(dir, dir, &mut files)?;
let mut sources = BTreeMap::new();
for (module, path) in files {
sources.insert(module, std::fs::read_to_string(&path)?);
}
let engine = Self::from_sources(sources)?;
engine.verify_hooks(data)?;
Ok(engine)
}
/// Compiles scripts from memory. Keys are module names.
pub fn from_sources(sources: BTreeMap<String, String>) -> Result<Self, ScriptError> {
let setup = |e: &dyn std::fmt::Display| ScriptError::Setup(e.to_string());
let mut context = Context::with_default_modules().map_err(|e| setup(&e))?;
context
.install(game_module().map_err(|e| setup(&e))?)
.map_err(|e| setup(&e))?;
let runtime = Arc::new(context.runtime().map_err(|e| setup(&e))?);
let mut units = HashMap::new();
for (module, code) in sources {
let mut sources = Sources::new();
sources
.insert(Source::new(&module, &code).map_err(|e| setup(&e))?)
.map_err(|e| setup(&e))?;
let mut diagnostics = Diagnostics::new();
let result = rune::prepare(&mut sources)
.with_context(&context)
.with_diagnostics(&mut diagnostics)
.build();
let mut report = Buffer::no_color();
if !diagnostics.is_empty() {
let _ = diagnostics.emit(&mut report, &sources);
}
let report = String::from_utf8_lossy(report.as_slice()).into_owned();
match result {
Ok(unit) => {
if diagnostics.has_warning() {
warn!("script `{module}`:\n{report}");
}
units.insert(module, Arc::new(unit));
}
Err(_) => return Err(ScriptError::Compile(module, report)),
}
}
Ok(Self { runtime, units })
}
fn verify_hooks(&self, data: &GameData) -> Result<(), ScriptError> {
let hooks = data
.items
.values()
.filter_map(|i| i.on_use.as_ref().map(|h| (format!("item `{}`", i.id), h)))
.chain(data.recipes.values().filter_map(|r| {
r.on_craft
.as_ref()
.map(|h| (format!("recipe `{}`", r.id), h))
}))
.chain(data.buildings.values().filter_map(|b| {
b.on_interact
.as_ref()
.map(|h| (format!("building `{}`", b.id), h))
}));
let missing: Vec<String> = hooks
.filter(|(_, hook)| !self.has_hook(hook))
.map(|(owner, hook)| format!("{owner}: script hook `{hook}` not found"))
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(ScriptError::InvalidHooks(missing.join("\n")))
}
}
fn resolve(&self, hook: &str) -> Option<(Arc<Unit>, String)> {
let (module, function) = hook.rsplit_once("::")?;
let unit = self.units.get(module)?;
Some((unit.clone(), function.to_owned()))
}
pub fn has_hook(&self, hook: &str) -> bool {
self.resolve(hook).is_some_and(|(unit, function)| {
Vm::new(self.runtime.clone(), unit)
.lookup_function([function.as_str()])
.is_ok()
})
}
/// Runs `hook` with `state`, returning the modified state and whether it succeeded.
pub fn call(&self, hook: &str, state: ScriptState) -> Result<(ScriptState, bool), ScriptError> {
let (unit, function) = self
.resolve(hook)
.ok_or_else(|| ScriptError::UnknownHook(hook.to_owned()))?;
let shared = Arc::new(Mutex::new(state));
let ctx = Ctx {
state: shared.clone(),
};
let mut vm = Vm::new(self.runtime.clone(), unit);
let result = vm.call([function.as_str()], (ctx,));
drop(vm);
let state = Arc::try_unwrap(shared)
.map_err(|_| {
ScriptError::Runtime(hook.into(), "script kept a reference to ctx".into())
})?
.into_inner()
.unwrap_or_else(|e| e.into_inner());
let value = result.map_err(|e| ScriptError::Runtime(hook.into(), e.to_string()))?;
let success = rune::from_value::<bool>(value).unwrap_or(true);
Ok((state, success))
}
}
fn collect_scripts(
root: &Path,
dir: &Path,
out: &mut Vec<(String, std::path::PathBuf)>,
) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
collect_scripts(root, &path, out)?;
} else if path.extension().is_some_and(|e| e == "rn") {
let rel = path.strip_prefix(root).unwrap_or(&path).with_extension("");
let module = rel
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("::");
out.push((module, path));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn data() -> Arc<GameData> {
Arc::new(
GameData::from_source(
r#"
skills: [{ id: "mining", name: "Mining" }]
items: [{ id: "ore", name: "Ore", sprite: "", max_stack: 3, props: { "power": 7 } }]
"#,
)
.unwrap(),
)
}
fn engine(code: &str) -> ScriptEngine {
ScriptEngine::from_sources([("test".to_owned(), code.to_owned())].into()).unwrap()
}
fn state() -> ScriptState {
ScriptState::new(
data(),
"Alice".into(),
Inventory::default(),
Vitals::default(),
Skills::default(),
)
}
#[test]
fn script_modifies_state() {
let engine = engine(
r#"
pub fn run(ctx) {
ctx.give_item("ore", 5);
ctx.damage(10);
ctx.add_xp("mining", 60);
ctx.message(`Hi ${ctx.actor_name()} ${ctx.item_prop("power")}`);
ctx.set_var("count", ctx.get_var("count") + 1);
ctx.set_variant("open");
ctx.set_solid(!ctx.is_solid());
}
"#,
);
let mut s = state();
s.item = Some("ore".into());
s.target = Some(ScriptTarget {
name: "Door".into(),
state: BuildingState {
solid: true,
..default()
},
destroyed: false,
});
let (s, ok) = engine.call("test::run", s).unwrap();
assert!(ok);
assert_eq!(s.inventory.count("ore"), 5);
assert_eq!(s.vitals.health, 90.0);
assert_eq!(s.skills.level("mining"), 2);
assert!(s.messages[0].contains("level 2"));
assert_eq!(s.messages[1], "Hi Alice 7");
let t = s.target.unwrap();
assert_eq!(t.state.vars["count"], 1);
assert_eq!(t.state.variant.as_deref(), Some("open"));
assert!(!t.state.solid);
}
#[test]
fn false_return_means_failure() {
let engine = engine("pub fn run(ctx) { false }");
let (_, ok) = engine.call("test::run", state()).unwrap();
assert!(!ok);
}
#[test]
fn errors_are_reported() {
let engine = engine("pub fn run(ctx) { ctx.no_such_method() }");
assert!(matches!(
engine.call("test::run", state()),
Err(ScriptError::Runtime(..))
));
assert!(matches!(
engine.call("test::missing", state()),
Err(ScriptError::Runtime(..))
));
assert!(matches!(
engine.call("nope::run", state()),
Err(ScriptError::UnknownHook(..))
));
assert!(matches!(
ScriptEngine::from_sources([("bad".into(), "pub fn (".into())].into()),
Err(ScriptError::Compile(..))
));
}
#[test]
fn shipped_scripts_compile_and_hooks_exist() {
let assets = game_core::assets_dir();
let data = GameData::load_dir(&assets.join("data")).unwrap();
ScriptEngine::load(&assets.join("scripts"), &data).unwrap();
}
}

View File

@ -0,0 +1,71 @@
//! Hunger, natural regeneration, starvation and death.
use bevy::prelude::*;
use game_core::components::*;
use crate::{
players::{Controller, tell},
world::{SpawnPoint, spawn_ground_item},
};
/// Nutrition lost per second (a full stomach lasts about 25 minutes).
pub const HUNGER_RATE: f32 = 100.0 / (25.0 * 60.0);
pub const STARVATION_DAMAGE: f32 = 0.5;
pub const REGEN_RATE: f32 = 0.2;
#[derive(Resource)]
pub struct SurvivalTimer(pub Timer);
impl Default for SurvivalTimer {
fn default() -> Self {
Self(Timer::from_seconds(1.0, TimerMode::Repeating))
}
}
pub fn tick_survival(
mut commands: Commands,
time: Res<Time>,
mut timer: ResMut<SurvivalTimer>,
spawn: Option<Res<SpawnPoint>>,
mut characters: Query<(
&Character,
&Controller,
&mut GridPos,
&mut Vitals,
&mut Inventory,
)>,
) {
if !timer.0.tick(time.delta()).just_finished() {
return;
}
let dt = timer.0.duration().as_secs_f32();
for (character, controller, mut pos, mut vitals, mut inventory) in &mut characters {
vitals.nutrition = (vitals.nutrition - HUNGER_RATE * dt).max(0.0);
if vitals.nutrition <= 0.0 {
vitals.heal(-STARVATION_DAMAGE * dt);
} else if vitals.nutrition > 50.0 {
vitals.heal(REGEN_RATE * dt);
}
if vitals.health > 0.0 {
continue;
}
// Death: drop everything where you fell and wake up at the spawn point.
info!("{} died", character.name);
for stack in std::mem::take(&mut inventory.slots) {
spawn_ground_item(&mut commands, stack, *pos);
}
if let Some(spawn) = &spawn {
*pos = spawn.0;
}
*vitals = Vitals {
nutrition: 50.0,
..default()
};
tell(
&mut commands,
controller.0,
"You died. Your belongings were left where you fell.",
);
}
}

View File

@ -0,0 +1,172 @@
//! World setup: Tiled map, spawning buildings and items, and tile occupancy.
use std::{collections::BTreeMap, sync::Arc};
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use game_core::{
GameData,
components::{Building, BuildingState, Character, GridPos, GroundItem, ItemStack, WorldMap},
map::MapLayout,
};
use crate::{ServerSettings, persistence};
/// The loaded map, used for collision and spawn points.
#[derive(Resource, Clone)]
pub struct WorldLayout(pub Arc<MapLayout>);
#[derive(Resource, Clone, Copy)]
pub struct SpawnPoint(pub GridPos);
pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) {
let assets = game_core::assets_dir();
let layout = MapLayout::load(&assets, &settings.map).unwrap_or_else(|e| panic!("{e}"));
let spawn = layout
.objects_of_class("spawn")
.next()
.map(|o| o.pos)
.unwrap_or_else(|| {
warn!("map has no `spawn` object, using the map center");
GridPos::new(layout.width as i32 / 2, layout.height as i32 / 2)
});
commands.insert_resource(SpawnPoint(spawn));
commands.spawn((
Replicated,
WorldMap {
path: settings.map.clone(),
},
));
match persistence::load(settings.save_path.as_deref()) {
Some(save) => {
info!(
"restoring persistent world: {} buildings, {} items, {} characters",
save.buildings.len(),
save.items.len(),
save.characters.len()
);
if save.map != settings.map {
warn!(
"save was made on map `{}`, now running `{}`",
save.map, settings.map
);
}
persistence::restore(&mut commands, &data, save);
}
None => {
info!("creating a new world from `{}`", settings.map);
spawn_map_objects(&mut commands, &data, &layout);
}
}
commands.insert_resource(WorldLayout(Arc::new(layout)));
}
fn spawn_map_objects(commands: &mut Commands, data: &GameData, layout: &MapLayout) {
for obj in &layout.objects {
let proto = obj.properties.get("proto");
match (obj.class.as_str(), proto) {
("spawn", _) => {}
("building", Some(proto)) => {
// Integer custom properties override the building's script variables.
let vars = obj
.properties
.iter()
.filter_map(|(k, v)| Some((k.clone(), v.parse::<i64>().ok()?)))
.collect();
if spawn_building(commands, data, proto, obj.pos, vars).is_none() {
warn!("map object {}: unknown building `{proto}`", obj.id);
}
}
("item", Some(proto)) => {
let count = obj
.properties
.get("count")
.and_then(|c| c.parse().ok())
.unwrap_or(1);
if data.items.contains_key(proto) {
spawn_ground_item(commands, ItemStack::new(proto, count), obj.pos);
} else {
warn!("map object {}: unknown item `{proto}`", obj.id);
}
}
(class, _) => warn!(
"map object {} (`{}`): unsupported class `{class}` or missing `proto` property",
obj.id, obj.name
),
}
}
}
/// Spawns a building with its default state. `vars` override the prototype's variables.
pub fn spawn_building(
commands: &mut Commands,
data: &GameData,
proto: &str,
pos: GridPos,
vars: BTreeMap<String, i64>,
) -> Option<Entity> {
let def = data.buildings.get(proto)?;
let mut state = BuildingState {
solid: def.solid,
variant: None,
health: def.max_health,
vars: def.vars.clone(),
};
state.vars.extend(vars);
Some(spawn_building_with_state(commands, proto, pos, state))
}
pub fn spawn_building_with_state(
commands: &mut Commands,
proto: &str,
pos: GridPos,
state: BuildingState,
) -> Entity {
commands
.spawn((
Replicated,
Building {
proto: proto.to_owned(),
},
pos,
state,
))
.id()
}
pub fn spawn_ground_item(commands: &mut Commands, stack: ItemStack, pos: GridPos) -> Entity {
commands.spawn((Replicated, GroundItem(stack), pos)).id()
}
/// Whether a character can not enter `pos`.
pub fn is_blocked<'a>(
pos: GridPos,
layout: &MapLayout,
buildings: impl IntoIterator<Item = (&'a GridPos, &'a BuildingState)>,
) -> bool {
layout.is_solid(pos) || buildings.into_iter().any(|(p, s)| *p == pos && s.solid)
}
/// Finds the closest free tile around `start` (spiral search), for spawning characters.
pub fn find_free_tile(
start: GridPos,
layout: &MapLayout,
buildings: &Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
) -> GridPos {
for radius in 0..16i32 {
for dy in -radius..=radius {
for dx in -radius..=radius {
if dx.abs() != radius && dy.abs() != radius {
continue;
}
let pos = GridPos::new(start.x + dx, start.y + dy);
if !is_blocked(pos, layout, buildings.iter()) {
return pos;
}
}
}
}
start
}

View File

@ -0,0 +1,181 @@
//! Runs the full server simulation headless, the same way singleplayer does:
//! client events are triggered locally and handled by the server observers.
use std::time::Duration;
use bevy::{prelude::*, state::app::StatesPlugin, time::TimeUpdateStrategy};
use bevy_replicon::prelude::*;
use game_core::{CorePlugin, components::*, grid::Dir, protocol::*};
use game_server::{GameServerPlugin, ServerSettings};
#[derive(Resource, Default)]
struct Log(Vec<String>);
fn app() -> App {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
StatesPlugin,
RepliconPlugins,
CorePlugin,
GameServerPlugin {
settings: ServerSettings::default(),
},
))
.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_millis(
50,
)))
.init_resource::<Log>()
.add_observer(|ev: On<GameLog>, mut log: ResMut<Log>| log.0.push(ev.text.clone()));
// Normally done by `App::run`; replicon builds its message systems in `finish`.
app.finish();
app.cleanup();
app.update();
app
}
fn send(app: &mut App, ev: impl Event) {
app.world_mut().client_trigger(ev);
// Resent locally in PostUpdate, triggered on the server in the next PreUpdate.
app.update();
app.update();
}
fn take_log(app: &mut App) -> Vec<String> {
// Server events also go through a local resend before the client observer sees them.
app.update();
std::mem::take(&mut app.world_mut().resource_mut::<Log>().0)
}
fn count(app: &App, me: Entity, item: &str) -> u32 {
app.world().get::<Inventory>(me).unwrap().count(item)
}
#[test]
fn login_move_and_interact() {
let mut app = app();
// The map defines buildings and items.
let mut buildings = app.world_mut().query::<&Building>();
assert_eq!(buildings.iter(app.world()).count(), 18);
send(
&mut app,
Login {
name: "Tester".into(),
},
);
let me = app
.world_mut()
.query_filtered::<Entity, With<Character>>()
.single(app.world())
.expect("one character");
let start = *app.world().get::<GridPos>(me).unwrap();
let log = take_log(&mut app);
assert!(log.iter().any(|t| t.contains("Tester")), "{log:?}");
assert_eq!(count(&app, me, "apple"), 2);
// A second login with the same name is rejected.
send(
&mut app,
Login {
name: "Tester".into(),
},
);
let mut characters = app.world_mut().query::<&Character>();
assert_eq!(characters.iter(app.world()).count(), 1);
// Walk east one tile.
send(&mut app, SetMoveDir(Some(Dir::East)));
send(&mut app, SetMoveDir(None));
for _ in 0..5 {
app.update();
}
let pos = *app.world().get::<GridPos>(me).unwrap();
assert_eq!(pos, GridPos::new(start.x + 1, start.y), "start {start:?}");
// Eat an apple through the Rune script.
app.world_mut().get_mut::<Vitals>(me).unwrap().nutrition = 50.0;
send(&mut app, UseItem { slot: 0 });
let vitals = app.world().get::<Vitals>(me).unwrap();
assert!(vitals.nutrition > 50.0, "{vitals:?}");
assert_eq!(count(&app, me, "apple"), 1);
assert!(take_log(&mut app).iter().any(|t| t.contains("Delicious")));
// Build a table next to us.
let target = GridPos::new(pos.x, pos.y + 1);
let data = app.world().resource::<game_core::GameData>().clone();
app.world_mut()
.get_mut::<Inventory>(me)
.unwrap()
.add("plank", 2, &data);
send(
&mut app,
Construct {
building: "table".into(),
target,
},
);
let log = take_log(&mut app);
let mut q = app.world_mut().query::<(&Building, &GridPos)>();
assert!(
q.iter(app.world())
.any(|(b, p)| b.proto == "table" && *p == target),
"{log:?}"
);
assert_eq!(count(&app, me, "plank"), 0);
// Doors toggle via script.
let door_pos = GridPos::new(pos.x + 1, pos.y + 1);
app.world_mut().spawn((
Building {
proto: "door".into(),
},
door_pos,
BuildingState {
solid: true,
..default()
},
));
send(&mut app, Interact { target: door_pos });
let mut q = app
.world_mut()
.query::<(&Building, &GridPos, &BuildingState)>();
let (_, _, state) = q
.iter(app.world())
.find(|(b, p, _)| b.proto == "door" && **p == door_pos)
.unwrap();
assert!(!state.solid);
assert_eq!(state.variant.as_deref(), Some("open"));
// Deconstruction refunds the materials.
send(&mut app, Deconstruct { target });
let mut q = app.world_mut().query::<(&Building, &GridPos)>();
assert!(!q.iter(app.world()).any(|(_, p)| *p == target));
assert_eq!(count(&app, me, "plank"), 2);
// Drop and pick up again.
let slot = app
.world()
.get::<Inventory>(me)
.unwrap()
.slots
.iter()
.position(|s| s.item == "plank")
.unwrap();
send(&mut app, DropItem { slot });
assert_eq!(count(&app, me, "plank"), 0);
send(&mut app, PickUp { target: pos });
assert_eq!(count(&app, me, "plank"), 2);
// Crafting without a station fails with a reason.
take_log(&mut app);
send(
&mut app,
Craft {
recipe: "smelt_iron".into(),
},
);
let log = take_log(&mut app);
assert!(log.iter().any(|t| t.contains("furnace")), "{log:?}");
}

View File

@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""Generates placeholder art and the starter Tiled map.
Run from the repository root: `python3 tools/gen_placeholder_assets.py`.
Requires Pillow. Replace the generated PNGs with real art at any time; the map
(`assets/maps/station.tmx`) can be opened and edited in Tiled.
"""
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
ASSETS = ROOT / "assets"
T = 32
def canvas():
return Image.new("RGBA", (T, T), (0, 0, 0, 0))
def save(img, rel):
path = ASSETS / rel
path.parent.mkdir(parents=True, exist_ok=True)
img.save(path)
# --- Tileset -----------------------------------------------------------------
# (name, color, solid, pattern)
TILES = [
("floor", (120, 124, 130), False, "grid"),
("dark_floor", (80, 84, 92), False, "grid"),
("wall", (60, 66, 80), True, "brick"),
("grass", (70, 140, 60), False, "speckle"),
("water", (40, 90, 170), True, "wave"),
("plating", (150, 150, 140), False, "rivets"),
("dirt", (120, 90, 60), False, "speckle"),
("window", (120, 180, 210), True, "glass"),
]
COLS = 4
def draw_tile(d, ox, oy, color, pattern):
d.rectangle([ox, oy, ox + T - 1, oy + T - 1], fill=color)
dark = tuple(max(0, c - 30) for c in color)
light = tuple(min(255, c + 30) for c in color)
if pattern == "grid":
d.rectangle([ox, oy, ox + T - 1, oy + T - 1], outline=dark)
elif pattern == "brick":
for y in range(0, T, 8):
d.line([ox, oy + y, ox + T - 1, oy + y], fill=dark)
off = 0 if (y // 8) % 2 == 0 else 8
for x in range(off, T, 16):
d.line([ox + x, oy + y, ox + x, oy + y + 7], fill=dark)
d.rectangle([ox, oy, ox + T - 1, oy + T - 1], outline=(30, 32, 40))
elif pattern == "speckle":
for i in range(24):
x = (i * 37) % T
y = (i * 53 + 7) % T
d.point([ox + x, oy + y], fill=light if i % 2 else dark)
elif pattern == "wave":
for y in range(4, T - 4, 8):
for x in range(0, T - 8, 8):
d.arc([ox + x, oy + y, ox + x + 8, oy + y + 4], 180, 360, fill=light)
elif pattern == "rivets":
d.rectangle([ox, oy, ox + T - 1, oy + T - 1], outline=dark)
for x, y in [(3, 3), (T - 4, 3), (3, T - 4), (T - 4, T - 4)]:
d.point([ox + x, oy + y], fill=dark)
elif pattern == "glass":
d.rectangle([ox, oy, ox + T - 1, oy + T - 1], outline=(60, 66, 80), width=3)
d.line([ox + 8, oy + 8, ox + 16, oy + 16], fill=(230, 240, 255))
def gen_tileset():
rows = (len(TILES) + COLS - 1) // COLS
img = Image.new("RGBA", (COLS * T, rows * T), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
for i, (_, color, _, pattern) in enumerate(TILES):
draw_tile(d, (i % COLS) * T, (i // COLS) * T, color, pattern)
save(img, "tilesets/station.png")
tiles_xml = []
for i, (name, _, solid, _) in enumerate(TILES):
props = f'<property name="name" value="{name}"/>'
if solid:
props += '<property name="solid" type="bool" value="true"/>'
tiles_xml.append(f' <tile id="{i}">\n <properties>{props}</properties>\n </tile>')
tsx = f"""<?xml version="1.0" encoding="UTF-8"?>
<tileset version="1.10" tiledversion="1.10.2" name="station" tilewidth="{T}" tileheight="{T}" tilecount="{len(TILES)}" columns="{COLS}">
<image source="station.png" width="{COLS * T}" height="{rows * T}"/>
{chr(10).join(tiles_xml)}
</tileset>
"""
(ASSETS / "tilesets/station.tsx").write_text(tsx)
# --- Sprites -----------------------------------------------------------------
def sprite(rel, *shapes):
img = canvas()
d = ImageDraw.Draw(img)
for shape in shapes:
shape(d)
save(img, rel)
def blob(color):
return lambda d: d.ellipse([7, 9, 25, 25], fill=color, outline=(20, 20, 20))
def plate(color):
return lambda d: (d.rectangle([6, 10, 26, 22], fill=color, outline=(30, 30, 30)),
d.line([8, 12, 24, 12], fill=(230, 230, 230)))
def gen_sprites():
# Items
sprite("sprites/items/iron_ore.png", blob((140, 100, 90)))
sprite("sprites/items/iron_plate.png", plate((170, 175, 185)))
sprite("sprites/items/log.png",
lambda d: d.rectangle([5, 12, 27, 21], fill=(120, 80, 40), outline=(60, 40, 20)),
lambda d: d.ellipse([22, 12, 28, 21], fill=(200, 160, 100)))
sprite("sprites/items/plank.png", plate((190, 140, 80)))
sprite("sprites/items/apple.png",
lambda d: d.ellipse([9, 10, 23, 24], fill=(200, 30, 30), outline=(90, 10, 10)),
lambda d: d.line([16, 6, 16, 11], fill=(80, 50, 20), width=2))
sprite("sprites/items/bread.png",
lambda d: d.rounded_rectangle([6, 11, 26, 23], 5, fill=(210, 160, 90), outline=(120, 80, 30)))
sprite("sprites/items/sandwich.png",
lambda d: d.polygon([(6, 22), (26, 22), (16, 9)], fill=(230, 200, 140), outline=(120, 90, 40)),
lambda d: d.line([9, 19, 23, 19], fill=(60, 170, 60), width=2))
sprite("sprites/items/screwdriver.png",
lambda d: d.line([8, 24, 18, 14], fill=(220, 180, 40), width=4),
lambda d: d.line([18, 14, 25, 7], fill=(190, 190, 200), width=2))
sprite("sprites/items/wrench.png",
lambda d: d.line([9, 23, 21, 11], fill=(170, 170, 180), width=3),
lambda d: d.ellipse([18, 6, 26, 14], outline=(170, 170, 180), width=3))
sprite("sprites/items/medkit.png",
lambda d: d.rectangle([7, 9, 25, 24], fill=(240, 240, 240), outline=(80, 80, 80)),
lambda d: d.rectangle([14, 11, 18, 22], fill=(210, 30, 30)),
lambda d: d.rectangle([10, 15, 22, 18], fill=(210, 30, 30)))
sprite("sprites/items/pickaxe.png",
lambda d: d.line([9, 26, 20, 11], fill=(120, 80, 40), width=3),
lambda d: d.arc([8, 4, 30, 20], 200, 340, fill=(160, 160, 170), width=3))
sprite("sprites/items/axe.png",
lambda d: d.line([9, 26, 20, 8], fill=(120, 80, 40), width=3),
lambda d: d.polygon([(18, 6), (26, 8), (24, 16), (18, 12)], fill=(170, 170, 180)))
# Buildings
def wall(d):
d.rectangle([0, 0, T - 1, T - 1], fill=(95, 100, 115), outline=(40, 44, 52), width=2)
d.line([0, 16, T, 16], fill=(70, 74, 86))
def door(open_):
def f(d):
d.rectangle([0, 0, T - 1, T - 1], fill=(70, 74, 86))
if open_:
d.rectangle([2, 2, 6, T - 3], fill=(150, 160, 70))
d.rectangle([T - 7, 2, T - 3, T - 3], fill=(150, 160, 70))
else:
d.rectangle([2, 2, T - 3, T - 3], fill=(150, 160, 70), outline=(60, 60, 30))
d.line([16, 2, 16, T - 3], fill=(60, 60, 30), width=2)
return f
sprite("sprites/buildings/wall.png", wall)
sprite("sprites/buildings/door_closed.png", door(False))
sprite("sprites/buildings/door_open.png", door(True))
sprite("sprites/buildings/workbench.png",
lambda d: d.rectangle([2, 8, 29, 18], fill=(150, 100, 50), outline=(70, 45, 20)),
lambda d: d.rectangle([4, 18, 7, 28], fill=(110, 70, 30)),
lambda d: d.rectangle([24, 18, 27, 28], fill=(110, 70, 30)),
lambda d: d.line([10, 6, 18, 6], fill=(170, 170, 180), width=2))
furnace_body = lambda d: d.rectangle([3, 3, 28, 29], fill=(90, 85, 85), outline=(40, 35, 35), width=2)
sprite("sprites/buildings/furnace.png", furnace_body,
lambda d: d.rectangle([9, 15, 22, 25], fill=(60, 30, 20), outline=(40, 35, 35)))
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)))
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)),
lambda d: d.rectangle([25, 16, 27, 28], fill=(120, 120, 130)))
sprite("sprites/buildings/vending.png",
lambda d: d.rectangle([5, 1, 26, 30], fill=(40, 90, 160), outline=(20, 40, 80), width=2),
lambda d: d.rectangle([9, 5, 22, 18], fill=(170, 220, 255)),
lambda d: d.rectangle([9, 22, 22, 26], fill=(20, 20, 30)))
sprite("sprites/buildings/tree.png",
lambda d: d.rectangle([14, 20, 18, 30], fill=(100, 70, 40)),
lambda d: d.ellipse([4, 2, 28, 24], fill=(40, 120, 50), outline=(20, 70, 30)))
sprite("sprites/buildings/ore_rock.png",
lambda d: d.polygon([(4, 28), (8, 10), (18, 4), (28, 14), (27, 28)], fill=(110, 105, 100), outline=(50, 50, 50)),
lambda d: d.ellipse([10, 14, 15, 19], fill=(180, 110, 90)),
lambda d: d.ellipse([18, 18, 23, 23], fill=(180, 110, 90)))
# Characters
sprite("sprites/characters/player.png",
lambda d: d.ellipse([10, 3, 22, 14], fill=(240, 200, 160), outline=(60, 40, 30)),
lambda d: d.rectangle([9, 14, 23, 25], fill=(60, 80, 160), outline=(30, 40, 80)),
lambda d: d.rectangle([10, 25, 14, 30], fill=(40, 40, 50)),
lambda d: d.rectangle([18, 25, 22, 30], fill=(40, 40, 50)))
# --- Map ---------------------------------------------------------------------
W, H = 40, 28
FLOOR, DARK, WALL, GRASS, WATER, PLATING, DIRT, WINDOW = range(len(TILES))
def gen_map():
ground = [[GRASS] * W for _ in range(H)]
walls = [[None] * W for _ in range(H)]
def room(x0, y0, x1, y1, floor):
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
ground[y][x] = floor
if x in (x0, x1) or y in (y0, y1):
walls[y][x] = WALL
for x in range(W):
walls[0][x] = walls[H - 1][x] = WALL
for y in range(H):
walls[y][0] = walls[y][W - 1] = WALL
room(3, 3, 18, 14, FLOOR) # main hall
room(18, 3, 27, 14, PLATING) # workshop
room(3, 14, 12, 21, DARK) # kitchen
for x in (6, 7, 14):
walls[3][x] = WINDOW
# Door openings; the doors themselves are buildings on the object layer.
for x, y in [(18, 8), (8, 14), (10, 14), (10, 3)]:
walls[y][x] = None
walls[14][10] = WALL
for y in range(1, 3):
ground[y][10] = DIRT
for y in range(15, 27):
if walls[y][10] is None and ground[y][10] == GRASS:
ground[y][10] = DIRT
for y in range(18, 24):
for x in range(28, 34):
ground[y][x] = WATER
# Outside path from the north door around to the east.
for x in range(10, 38):
ground[1][x] = DIRT
for y in range(1, 26):
ground[y][37] = DIRT
def csv(layer):
return ",\n".join(",".join("0" if t is None else str(t + 1) for t in row) for row in layer)
objects = []
next_id = [1]
def obj(cls, x, y, name="", **props):
oid = next_id[0]
next_id[0] += 1
prop_xml = ""
if props:
items = []
for k, v in props.items():
typ = ' type="int"' if isinstance(v, int) else ""
items.append(f' <property name="{k}"{typ} value="{v}"/>')
prop_xml = "\n <properties>\n" + "\n".join(items) + "\n </properties>\n "
objects.append(
f' <object id="{oid}" name="{name}" class="{cls}" x="{x * T + T // 2}" y="{y * T + T // 2}">{prop_xml}<point/>\n </object>'
)
obj("spawn", 10, 8, "arrivals")
obj("building", 18, 8, proto="door")
obj("building", 8, 14, proto="door")
obj("building", 10, 3, proto="door")
obj("building", 20, 5, proto="workbench")
obj("building", 24, 5, proto="furnace")
obj("building", 5, 5, proto="vending_machine", stock=5)
obj("building", 5, 17, proto="table")
obj("building", 6, 17, proto="table")
obj("item", 6, 18, proto="bread", count=2)
obj("item", 21, 10, proto="screwdriver")
obj("item", 22, 10, proto="wrench")
obj("item", 23, 10, proto="pickaxe")
obj("item", 24, 10, proto="axe")
obj("item", 12, 10, proto="iron_ore", count=6)
obj("item", 14, 6, proto="medkit")
for x, y in [(4, 24), (6, 25), (15, 22), (22, 20), (33, 5), (34, 9), (31, 14)]:
obj("building", x, y, proto="tree")
for x, y in [(30, 4), (33, 11), (35, 15)]:
obj("building", x, y, proto="ore_rock")
obj("item", 25, 22, proto="apple", count=3)
obj("item", 14, 25, proto="log", count=4)
tmx = f"""<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="{W}" height="{H}" tilewidth="{T}" tileheight="{T}" infinite="0" nextlayerid="4" nextobjectid="{next_id[0]}">
<tileset firstgid="1" source="../tilesets/station.tsx"/>
<layer id="1" name="Ground" width="{W}" height="{H}">
<data encoding="csv">
{csv(ground)}
</data>
</layer>
<layer id="2" name="Walls" width="{W}" height="{H}">
<data encoding="csv">
{csv(walls)}
</data>
</layer>
<objectgroup id="3" name="Objects">
{chr(10).join(objects)}
</objectgroup>
</map>
"""
(ASSETS / "maps").mkdir(parents=True, exist_ok=True)
(ASSETS / "maps/station.tmx").write_text(tmx)
if __name__ == "__main__":
gen_tileset()
gen_sprites()
gen_map()
print("placeholder assets written to", ASSETS)