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>
|
|
@ -0,0 +1,4 @@
|
|||
/target
|
||||
/saves
|
||||
__pycache__/
|
||||
/.NOCOMMIT
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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 },
|
||||
},
|
||||
]
|
||||
|
|
@ -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 } },
|
||||
]
|
||||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
|
@ -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" },
|
||||
]
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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!");
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
@ -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)`);
|
||||
}
|
||||
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 135 B |
|
After Width: | Height: | Size: 187 B |
|
After Width: | Height: | Size: 244 B |
|
After Width: | Height: | Size: 275 B |
|
After Width: | Height: | Size: 175 B |
|
After Width: | Height: | Size: 265 B |
|
After Width: | Height: | Size: 193 B |
|
After Width: | Height: | Size: 149 B |
|
After Width: | Height: | Size: 194 B |
|
After Width: | Height: | Size: 246 B |
|
After Width: | Height: | Size: 226 B |
|
After Width: | Height: | Size: 223 B |
|
After Width: | Height: | Size: 173 B |
|
After Width: | Height: | Size: 203 B |
|
After Width: | Height: | Size: 155 B |
|
After Width: | Height: | Size: 166 B |
|
After Width: | Height: | Size: 174 B |
|
After Width: | Height: | Size: 234 B |
|
After Width: | Height: | Size: 155 B |
|
After Width: | Height: | Size: 204 B |
|
After Width: | Height: | Size: 220 B |
|
After Width: | Height: | Size: 194 B |
|
After Width: | Height: | Size: 741 B |
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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}."));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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())));
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
@ -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)
|
||||