feat!: start menu, file-based UI, machine windows and server access control

- Start menu with new game (optionally hosted), load/delete saves and join
  server screens; CLI options skip it.
- Menus and HUD are built from rsn layouts in assets/ui with a shared theme,
  bindings, conditional visibility and tooltips.
- Machine windows place slots and backgrounds from the machine's ui
  definition; slots are filled from and emptied into the inventory.
- Chat with / commands, pause menu, clickable inventory, craft and build
  menus.
- Server passwords, admins, whitelist, bans, kicks and admin commands from
  the terminal or chat; changes are written to the config file. The hosting
  player is always admin.
- Clients keep a player id in client.toml; characters belong to it.

BREAKING CHANGE: protocol version 3 adds the player id and password to
Login, and characters are keyed by player id instead of name, so characters
in existing worlds are not restored.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-18 22:29:05 +02:00
parent 09ace2607c
commit 2d6f52e8a4
38 changed files with 5335 additions and 745 deletions

View File

@ -45,6 +45,7 @@ Station 13, with singleplayer and multiplayer. It is built in Rust (edition
- `crates/game_client/`: the `game` binary: rendering, UI and input; embeds the - `crates/game_client/`: the `game` binary: rendering, UI and input; embeds the
server for singleplayer and hosting. server for singleplayer and hosting.
- `assets/data/`: item, recipe, building and skill definitions (`.rsn`). - `assets/data/`: item, recipe, building and skill definitions (`.rsn`).
- `assets/ui/`: client UI layouts and theme (`.rsn`), see the README.
- `assets/scripts/`: Rune scripts (`.rn`), referenced as `"module::function"`. - `assets/scripts/`: Rune scripts (`.rn`), referenced as `"module::function"`.
- `assets/maps/`, `assets/tilesets/`, `assets/sprites/`: Tiled maps and art. - `assets/maps/`, `assets/tilesets/`, `assets/sprites/`: Tiled maps and art.
- `xtask/`: project automation (`cargo xtask pack` builds and archives the - `xtask/`: project automation (`cargo xtask pack` builds and archives the

View File

@ -33,6 +33,25 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- The dedicated server is configured by `server.toml` plus command-line - The dedicated server is configured by `server.toml` plus command-line
overrides, and it accepts terminal commands for save, shutdown and live config overrides, and it accepts terminal commands for save, shutdown and live config
changes. changes.
- Start menu with new game (optionally hosted), load/delete saved games and
join server screens. Running the client without options shows it.
- Menus and HUD are defined by layout files in `assets/ui/` (rsn) with a shared
theme, `{binding}` placeholders, conditional visibility and tooltips.
- Machine windows: insert and take items from machine slots, using the machine's
slot positions and background image, with a progress bar.
- Chat (Enter/T) with `/` commands, a pause menu (Esc) and clickable inventory,
crafting and build menus.
- Server passwords, admins, a whitelist, bans, kicks and admin commands in the
server terminal and in-game chat. Changes are written to the config file. The
player hosting a world is always admin.
- The client profile (`client.toml`) stores a player id; characters belong to
it instead of to a name.
### Changed
- **Breaking:** network protocol version 3. Logins carry a player id and
password, so older clients and servers can't connect. Characters saved by name
in existing worlds are not carried over to player ids.
### Removed ### Removed

4
Cargo.lock generated
View File

@ -3108,6 +3108,10 @@ dependencies = [
"directories", "directories",
"game_core", "game_core",
"game_server", "game_server",
"rsn",
"serde",
"toml",
"uuid",
] ]
[[package]] [[package]]

126
README.md
View File

@ -31,13 +31,22 @@ multiplayer behave identically.
## Running ## Running
```sh ```sh
cargo run # singleplayer (save game `singleplayer`) cargo run # start menu
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 (saves/server.sqlite, world `main`) cargo run -p game_server --bin game-server # dedicated server (saves/server.sqlite, world `main`)
``` ```
Client options: `--name NAME`, `--save NAME`, `--no-save`, `--map MAP`. The start menu offers **New game** (optionally open to other players),
**Load game** (play or delete a save) and **Join server**. Command-line options
skip the menu:
```sh
cargo run -- --save mars # play (or create) the local world `mars`
cargo run -- --save mars --host 5000 --password secret # let others join it
cargo run -- --connect 127.0.0.1:5000 --password secret # join a server or host
```
Client options: `--save NAME`, `--no-save`, `--host [PORT]`,
`--connect ADDR[:PORT]`, `--name NAME`, `--password TEXT`, `--map MAP`.
Server options: `--config FILE`, `--port PORT`, `--password TEXT`, Server options: `--config FILE`, `--port PORT`, `--password TEXT`,
`--max-players N`, `--database URL`, `--world NAME|UUID`, `--no-save`, `--max-players N`, `--database URL`, `--world NAME|UUID`, `--no-save`,
`--map MAP`, `--autosave SECS`, `--motd TEXT`, `--admin ID`, `--whitelist`. `--map MAP`, `--autosave SECS`, `--motd TEXT`, `--admin ID`, `--whitelist`.
@ -46,8 +55,16 @@ 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. output such as `--help` and logs isn't shown. Use a debug build to see it.
The world autosaves periodically (in the background) and on exit (window close, The world autosaves periodically (in the background) and on exit (window close,
Ctrl-C, SIGTERM). Characters are identified by name and are restored when that Ctrl-C, SIGTERM).
name logs in again.
The client keeps a profile in `client.toml` in the platform config directory
(`~/.config/station/` on Linux, `%APPDATA%\station\config\` on Windows,
`~/Library/Application Support/station/` on macOS). It holds a random player
id, the last character name and the last server address. Characters belong to
the player id: logging in again restores your character, and its name is fixed
once it exists. Hosting a world uses the server settings in `host.toml` in the
same directory, except that the port and password come from the menu or the
command line.
### Save games and databases ### Save games and databases
@ -92,19 +109,110 @@ config set autosave_secs 30
stop stop
``` ```
### Passwords, admins and bans
A server with a `password` only accepts players who send it. Admins are listed
by player id or character name in `access.admins` (`--admin ID` adds one for a
run). The player who hosts a world from the client is always admin and may stop
it; in singleplayer you are always admin. With `access.whitelist_enabled`, only
players in `access.whitelist` (and admins) may join. Banned ids and names in
`access.banned` are rejected.
Commands are typed into the server terminal or, prefixed with `/`, into the
in-game chat. Admin commands change `server.toml` (or `host.toml`) immediately.
| Command | Who | Action |
|--------------------------------|----------|---------------------------------------------|
| `help`, `players` | everyone | List commands / online players |
| `say MESSAGE` | admins | Message all players |
| `kick PLAYER [REASON]` | admins | Disconnect a player |
| `ban PLAYER`, `unban PLAYER`, `bans` | admins | Ban by name or id (kicks if online), lift or list bans |
| `whitelist on\|off\|list` | admins | Toggle or show the whitelist |
| `whitelist add\|remove PLAYER` | admins | Edit the whitelist |
| `admin add\|remove PLAYER`, `admin list` | admins | Grant, revoke or list admin rights |
| `rejected` | admins | Recently rejected logins (with player ids) |
| `save` | admins | Save the world now |
| `config get KEY`, `config set KEY VALUE` | admins | Read or change a setting, e.g. `config set motd Hi` |
| `stop` | console, host | Save and shut down |
> **Security:** the network transport is unencrypted and unauthenticated. The
> password is sent in plain text and player ids can be impersonated by anyone who
> knows them. Treat the password as a guard against casual visitors, not as
> real security, and don't reuse a valuable password.
## Controls ## Controls
| Key | Action | | Key | Action |
|---------------------|------------------------------------------------------| |---------------------|------------------------------------------------------|
| WASD / arrows | Move (you face the direction you move) | | WASD / arrows | Move (you face the direction you move) |
| E | Pick up (own tile, otherwise the faced tile) | | E | Pick up (own tile, otherwise the faced tile) |
| F / left click | Interact with the faced / clicked building | | F / left click | Interact with the faced / clicked building; machines open their window |
| U | Use the selected item (eat, heal, …) | | U | Use the selected item (eat, heal, …) |
| G | Drop the selected item | | G | Drop the selected item |
| X | Deconstruct the faced building | | X | Deconstruct the faced building |
| 1–9, 0, mouse wheel | Select an inventory slot | | 1–9, 0, mouse wheel | Select an inventory slot (or click it) |
| C / B | Crafting / build menu (W/S select, Enter confirm, Esc close) | | C / B | Crafting / build menu (W/S select, Enter confirm, or click) |
| H | Help | | H | Help |
| Enter / T | Chat; `/` starts a command |
| Esc | Close the open window, otherwise pause menu (leave world, quit) |
In a machine window, click an empty input slot to insert the selected inventory
item and click a filled slot to take its items. Hover over items, recipes and
buttons for tooltips.
## User interface
The menus and the HUD are built from layout files in `assets/ui/` and can be
restyled or rearranged without recompiling:
| File | Screen |
|--------------------|------------------------------------------------|
| `theme.rsn` | Font sizes, spacing and named colors used by all layouts |
| `main_menu.rsn`, `new_game.rsn`, `load_game.rsn`, `connect.rsn`, `connecting.rsn` | Start menu screens |
| `hud.rsn` | In-game HUD, menus, chat and pause overlay |
A layout has one `root` node. Nodes:
| Node | Fields |
|------------------------------|------------------------------------------------------------|
| `Column {..}`, `Row {..}`, `Panel {..}` | `children`, `gap`, `padding`, `align` (`Start`, `Center`, `End`, `Stretch`), `justify` (`Start`, `Center`, `End`, `SpaceBetween`), `width`/`height` (`Auto`, `Px(n)`, `Percent(n)`, `Fill`), `anchor` (`TopLeft` … `BottomRight`, relative to the screen), `offset: (x, y)`, `background`, `border`, `radius` |
| `Text {..}` | `text`, `size`, `color`, `max_width` |
| `Button {..}` | `text`, `action`, `width` |
| `Input {..}` | `id`, `value`, `placeholder`, `password`, `max_len`, `width`, `submit` (action on Enter) |
| `Checkbox {..}` | `id`, `text`, `checked` |
| `Image {..}` | `path` (PNG below `assets/`), `width`, `height` |
| `Spacer` | Takes up the remaining space |
| `Widget("name")` | Content generated by the game |
Every node except `Spacer` and `Widget` also accepts `tooltip` and
`visible`. `visible` names a binding or form field and shows the node only
while it is set; prefix it with `!` to invert it. Colors are `#rrggbb`,
`#rrggbbaa` or a theme color name. Texts, tooltips and input values may contain
`{binding}` placeholders:
```rsn
Panel {
anchor: TopLeft,
offset: (10, 10),
children: [
Text { text: "{character}", color: "accent", tooltip: "Playing on {server}" },
Text { text: "You are hungry!", color: "error", visible: "hungry" },
Button { text: "Craft", action: "menu:craft", tooltip: "Crafting (C)" },
],
}
```
- **Bindings:** menu: `message`, `profile_name`, `last_address`,
`default_port`, `saves_dir`, `save_count`, `has_saves`, `suggested_world`,
`version`, `status`. HUD: `character`, `server`, `admin`, `health`,
`max_health`, `nutrition`, `hungry`, `position`, `craft_open`, `build_open`,
`help_open`, `chat_open`, `paused`.
- **Widgets:** `save_list` (menu); `inventory`, `skills`, `log`, `craft_list`,
`build_list` (HUD).
- **Actions:** `screen:main|new_game|load_game|connect`, `new_game`, `join`,
`quit` (menu); `menu:craft|build|help|close`, `chat_send`, `resume`, `leave`,
`quit` (HUD). The menu forms read the inputs `world`, `name`, `address`,
`password`, `port` and the checkbox `host`; the HUD chat input is `chat`.
## Modding ## Modding

55
assets/ui/connect.rsn Normal file
View File

@ -0,0 +1,55 @@
// "Join server" screen. Form fields: address, name, password.
root: Column {
width: Fill,
height: Fill,
align: Center,
justify: Center,
gap: 16,
background: "backdrop",
children: [
Text { text: "Join server", size: 34, color: "accent" },
Panel {
gap: 10,
width: Px(420),
children: [
Text { text: "Address", color: "dim" },
Input {
id: "address",
value: "{last_address}",
placeholder: "host or host:port",
width: Fill,
max_len: 128,
submit: "join",
tooltip: "Server address; the port defaults to {default_port}",
},
Text { text: "Character name", color: "dim" },
Input {
id: "name",
value: "{profile_name}",
width: Fill,
max_len: 24,
submit: "join",
tooltip: "Letters, digits, spaces, _ and - (max 24)",
},
Text { text: "Password", color: "dim" },
Input {
id: "password",
password: true,
placeholder: "only if the server requires one",
width: Fill,
submit: "join",
},
Text { text: "{message}", color: "error", visible: "message", max_width: 390 },
Row {
gap: 10,
justify: SpaceBetween,
width: Fill,
children: [
Button { text: "Back", action: "screen:main" },
Button { text: "Join", action: "join" },
],
},
],
},
],
}

13
assets/ui/connecting.rsn Normal file
View File

@ -0,0 +1,13 @@
// Shown while a world loads or a connection is being established.
root: Column {
width: Fill,
height: Fill,
align: Center,
justify: Center,
gap: 16,
background: "backdrop",
children: [
Text { text: "{status}", size: 22 },
Button { text: "Cancel", action: "cancel" },
],
}

179
assets/ui/hud.rsn Normal file
View File

@ -0,0 +1,179 @@
// In-game HUD. Widgets: inventory, skills, log, craft_list, build_list.
root: Column {
width: Fill,
height: Fill,
children: [
// Status and skills.
Panel {
anchor: TopLeft,
offset: (10, 10),
gap: 4,
children: [
Row {
gap: 8,
align: Center,
children: [
Text { text: "{character}", size: 18, color: "accent" },
Text {
text: "[admin]",
size: 13,
color: "good",
visible: "admin",
tooltip: "You are an admin: type /help in the chat for commands",
},
],
},
Text { text: "{server}", size: 13, color: "dim" },
Text { text: "Health {health} / {max_health}", tooltip: "You die at 0. Eat or rest to heal." },
Text { text: "Nutrition {nutrition}%", tooltip: "Drops over time. Eat food to restore it." },
Text { text: "You are hungry!", color: "error", visible: "hungry" },
Text { text: "Position {position}", size: 13, color: "dim" },
Spacer,
Text { text: "Skills", color: "dim" },
Widget("skills"),
],
},
// Inventory and window buttons.
Panel {
anchor: TopRight,
offset: (10, 10),
gap: 6,
children: [
Text { text: "Inventory", color: "dim" },
Widget("inventory"),
Row {
gap: 6,
children: [
Button { text: "Craft", action: "menu:craft", tooltip: "Crafting recipes (C)" },
Button { text: "Build", action: "menu:build", tooltip: "Construct buildings (B)" },
Button { text: "?", action: "menu:help", tooltip: "Controls (H)" },
],
},
],
},
// Message log and chat.
Column {
anchor: BottomLeft,
offset: (10, 10),
gap: 4,
width: Px(620),
children: [
Column {
gap: 1,
padding: 8,
background: "#00000099",
radius: 4,
children: [Widget("log")],
},
Input {
id: "chat",
placeholder: "Say something, or /help for commands",
width: Fill,
max_len: 200,
submit: "chat_send",
visible: "chat_open",
},
Text {
text: "Enter: chat /: command H: help",
size: 12,
color: "dim",
visible: "!chat_open",
},
],
},
Panel {
anchor: Center,
gap: 6,
visible: "craft_open",
children: [
Row {
justify: SpaceBetween,
width: Fill,
align: Center,
children: [
Text { text: "Crafting", size: 20, color: "accent" },
Button { text: "x", action: "menu:close", tooltip: "Close (Esc)" },
],
},
Text { text: "W/S select, Enter craft, or click a recipe", size: 13, color: "dim" },
Column { gap: 4, children: [Widget("craft_list")] },
],
},
Panel {
anchor: Center,
gap: 6,
visible: "build_open",
children: [
Row {
justify: SpaceBetween,
width: Fill,
align: Center,
children: [
Text { text: "Build", size: 20, color: "accent" },
Button { text: "x", action: "menu:close", tooltip: "Close (Esc)" },
],
},
Text { text: "Builds on the highlighted tile in front of you", size: 13, color: "dim" },
Column { gap: 4, children: [Widget("build_list")] },
],
},
Panel {
anchor: Center,
gap: 3,
visible: "help_open",
children: [
Row {
justify: SpaceBetween,
width: Fill,
align: Center,
children: [
Text { text: "Controls", size: 20, color: "accent" },
Button { text: "x", action: "menu:close", tooltip: "Close (Esc)" },
],
},
Text { text: "WASD / arrows move" },
Text { text: "E pick up (own tile, else facing tile)" },
Text { text: "F / left click interact, open machines" },
Text { text: "1-9, 0, wheel select inventory slot" },
Text { text: "U / G use / drop the selected item" },
Text { text: "C / B crafting / build menu" },
Text { text: "X deconstruct the highlighted building" },
Text { text: "Enter, T chat (/ starts a command, /help lists them)" },
Text { text: "Esc close windows, pause menu" },
Text { text: "Hover anything for details.", size: 13, color: "dim" },
],
},
Column {
anchor: Center,
width: Fill,
height: Fill,
align: Center,
justify: Center,
background: "#00000088",
visible: "paused",
children: [
Panel {
gap: 10,
align: Stretch,
width: Px(260),
children: [
Text { text: "Paused", size: 22, color: "accent" },
Text {
text: "The world keeps running.",
size: 13,
color: "dim",
tooltip: "The world is persistent, so it is not paused - your character just stands still.",
},
Button { text: "Resume", action: "resume" },
Button {
text: "Leave world",
action: "leave",
tooltip: "Return to the start menu. Local worlds are saved.",
},
Button { text: "Quit", action: "quit", tooltip: "Local worlds are saved." },
],
},
],
},
],
}

61
assets/ui/load_game.rsn Normal file
View File

@ -0,0 +1,61 @@
// "Load game" screen. The `save_list` widget lists the worlds in the saves directory.
root: Column {
width: Fill,
height: Fill,
align: Center,
justify: Center,
gap: 16,
background: "backdrop",
children: [
Text { text: "Load game", size: 34, color: "accent" },
Panel {
gap: 10,
width: Px(560),
children: [
Text { text: "No saved worlds yet.", color: "dim", visible: "!has_saves" },
Widget("save_list"),
Row {
gap: 10,
align: Center,
children: [
Text { text: "Character", color: "dim" },
Input {
id: "name",
value: "{profile_name}",
width: Px(200),
max_len: 24,
tooltip: "Letters, digits, spaces, _ and - (max 24)",
},
],
},
Checkbox {
id: "host",
text: "Allow other players to join",
tooltip: "Host the world on the network. You are always an admin of your own world.",
},
Row {
gap: 10,
align: Center,
visible: "host",
children: [
Text { text: "Port", color: "dim" },
Input { id: "port", value: "{default_port}", width: Px(80), max_len: 5 },
Text { text: "Password", color: "dim" },
Input {
id: "password",
password: true,
placeholder: "none",
width: Px(140),
tooltip: "Players need this password to join (empty: no password)",
},
],
},
Text { text: "{message}", color: "error", visible: "message", max_width: 530 },
Row {
children: [Button { text: "Back", action: "screen:main" }],
},
],
},
Text { text: "Saves: {saves_dir}", size: 13, color: "dim" },
],
}

47
assets/ui/main_menu.rsn Normal file
View File

@ -0,0 +1,47 @@
// Start menu. See the README ("UI files") for the node types, bindings and actions.
root: Column {
width: Fill,
height: Fill,
align: Center,
justify: Center,
gap: 24,
background: "backdrop",
children: [
Column {
align: Center,
gap: 4,
children: [
Text { text: "STATION", size: 64, color: "accent" },
Text { text: "a persistent space station", color: "dim" },
],
},
Panel {
align: Stretch,
gap: 10,
width: Px(320),
children: [
Button { text: "New game", action: "screen:new_game", tooltip: "Create a new world" },
Button {
text: "Load game",
action: "screen:load_game",
tooltip: "Continue one of your worlds\nSaves are stored in {saves_dir}",
},
Button {
text: "Join server",
action: "screen:connect",
tooltip: "Play on a dedicated server or a friend's world",
},
Spacer,
Button { text: "Quit", action: "quit" },
],
},
Text { text: "{message}", color: "error", visible: "message", max_width: 600 },
Column {
anchor: BottomRight,
offset: (12, 8),
children: [
Text { text: "v{version} - playing as {profile_name}", size: 13, color: "dim" },
],
},
],
}

68
assets/ui/new_game.rsn Normal file
View File

@ -0,0 +1,68 @@
// "New game" screen. Form fields: world, name, host, port, password.
root: Column {
width: Fill,
height: Fill,
align: Center,
justify: Center,
gap: 16,
background: "backdrop",
children: [
Text { text: "New game", size: 34, color: "accent" },
Panel {
gap: 10,
width: Px(420),
children: [
Text { text: "World name", color: "dim" },
Input {
id: "world",
value: "{suggested_world}",
width: Fill,
max_len: 32,
submit: "new_game",
tooltip: "Name of the save game",
},
Text { text: "Character name", color: "dim" },
Input {
id: "name",
value: "{profile_name}",
width: Fill,
max_len: 24,
submit: "new_game",
tooltip: "Letters, digits, spaces, _ and - (max 24)",
},
Checkbox {
id: "host",
text: "Allow other players to join",
tooltip: "Host the world on the network. You are always an admin of your own world.",
},
Row {
gap: 10,
align: Center,
visible: "host",
children: [
Text { text: "Port", color: "dim" },
Input { id: "port", value: "{default_port}", width: Px(80), max_len: 5 },
Text { text: "Password", color: "dim" },
Input {
id: "password",
password: true,
placeholder: "none",
width: Px(140),
tooltip: "Players need this password to join (empty: no password)",
},
],
},
Text { text: "{message}", color: "error", visible: "message", max_width: 390 },
Row {
gap: 10,
justify: SpaceBetween,
width: Fill,
children: [
Button { text: "Back", action: "screen:main" },
Button { text: "Create", action: "new_game" },
],
},
],
},
],
}

26
assets/ui/theme.rsn Normal file
View File

@ -0,0 +1,26 @@
// Shared look of all UI layouts. Colors are #rrggbb or #rrggbbaa and can be
// referenced by name from layouts (e.g. `color: "accent"`).
font_size: 16
small_size: 13
title_size: 34
padding: 14
gap: 8
radius: 5
colors: {
text: "#e6e6e6",
dim: "#8a8f98",
accent: "#ffcc44",
error: "#ff6b6b",
good: "#7bd88f",
panel: "#141820e6",
window: "#141820fa",
border: "#3a4252",
button: "#283040",
button_hover: "#36415a",
button_pressed: "#1c2230",
input: "#0c0f14",
slot: "#0c0f14cc",
tooltip: "#05070af0",
progress: "#ffcc44",
backdrop: "#05070a",
}

View File

@ -14,6 +14,10 @@ bevy = { workspace = true, default-features = true }
bevy_replicon = { workspace = true } bevy_replicon = { workspace = true }
bevy_replicon_renet = { workspace = true } bevy_replicon_renet = { workspace = true }
directories = { workspace = true } directories = { workspace = true }
rsn = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
uuid = { workspace = true }
[lints] [lints]
workspace = true workspace = true

View File

@ -0,0 +1,657 @@
//! In-game HUD (`assets/ui/hud.rsn`): status, inventory, skills, message log,
//! chat, crafting and build lists, help and the pause menu.
use std::collections::VecDeque;
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use game_core::{
GameData,
components::*,
data::Ingredient,
protocol::{Chat, Construct, Craft, GameLog, MachineInsert},
};
use game_server::{
actions::REACH,
crafting::{item_name, try_construct, try_craft},
};
use crate::{
machine::OpenMachine,
render::LocalCharacter,
session::{self, AppState, SessionEnded, SessionInfo},
ui::{
self, OnClick, ThemedButton, Tooltip, UiAction, UiBindings, UiContext, UiFocus, UiForm,
WidgetSlot, layout::Theme,
},
};
pub struct HudPlugin;
impl Plugin for HudPlugin {
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_observer(on_action)
.add_observer(
|_: On<SessionEnded>, mut log: ResMut<GameLogBuffer>, mut ui: ResMut<UiState>| {
*log = default();
*ui = default();
},
)
.add_systems(OnEnter(AppState::InGame), spawn_hud)
.add_systems(
Update,
(
hud_bindings,
focus_chat,
inventory_widget,
skills_widget,
log_widget,
recipe_widgets,
)
.run_if(in_state(AppState::InGame)),
);
}
}
const LOG_LINES: usize = 12;
#[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, Hash)]
pub enum Menu {
Craft,
Build,
Help,
}
#[derive(Resource, Default)]
pub struct UiState {
pub selected_slot: usize,
pub menu: Option<Menu>,
/// Highlighted entry of the craft/build list.
pub cursor: usize,
pub chat_open: bool,
pub paused: bool,
}
impl UiState {
pub fn toggle(&mut self, menu: Menu) {
self.menu = if self.menu == Some(menu) {
None
} else {
Some(menu)
};
self.cursor = 0;
}
/// Whether game controls (movement, actions) are blocked by a window.
pub fn blocks_game(&self) -> bool {
self.paused || self.menu.is_some_and(|m| m != Menu::Help)
}
}
/// Entries of a 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(),
}
}
fn spawn_hud(
mut commands: Commands,
theme: Res<Theme>,
bindings: Res<UiBindings>,
mut form: ResMut<UiForm>,
assets: Res<AssetServer>,
) {
let mut ctx = UiContext {
theme: &theme,
bindings: &bindings,
form: &mut form,
assets: &assets,
};
let root = ui::spawn_screen(&mut commands, &mut ctx, "hud");
commands
.entity(root)
.insert(DespawnOnExit(AppState::InGame));
}
#[allow(clippy::too_many_arguments)]
fn hud_bindings(
local: Res<LocalCharacter>,
session: Res<SessionInfo>,
ui: Res<UiState>,
characters: Query<(&Vitals, &GridPos)>,
mut bindings: ResMut<UiBindings>,
) {
bindings.set("character", session.character.clone());
bindings.set("server", session.server_name.clone());
bindings.flag("admin", session.admin);
if let Some((vitals, pos)) = local.0.and_then(|e| characters.get(e).ok()) {
bindings.set("health", format!("{:.0}", vitals.health));
bindings.set("max_health", format!("{:.0}", vitals.max_health));
bindings.set("nutrition", format!("{:.0}", vitals.nutrition));
bindings.flag("hungry", vitals.nutrition < 20.0);
bindings.set("position", format!("{}, {}", pos.x, pos.y));
}
bindings.flag("craft_open", ui.menu == Some(Menu::Craft));
bindings.flag("build_open", ui.menu == Some(Menu::Build));
bindings.flag("help_open", ui.menu == Some(Menu::Help));
bindings.flag("chat_open", ui.chat_open);
bindings.flag("paused", ui.paused);
}
/// Focuses the chat input when the chat opens and closes the chat when the input loses focus.
fn focus_chat(
mut ui: ResMut<UiState>,
mut focus: ResMut<UiFocus>,
mut opened: Local<bool>,
inputs: Query<(Entity, &ui::TextInput)>,
) {
let chat = inputs.iter().find(|(_, i)| i.id == "chat").map(|(e, _)| e);
if ui.chat_open && !*opened {
focus.input = chat;
} else if ui.chat_open && focus.input != chat {
ui.chat_open = false;
}
*opened = ui.chat_open;
}
fn text(theme: &Theme, text: impl Into<String>, size: f32, color: &str) -> impl Bundle {
(
Text::new(text),
TextFont::from_font_size(size),
TextColor(theme.color(color)),
)
}
/// Spawns an item slot showing `stack`. Used by the inventory and machine windows.
#[allow(clippy::too_many_arguments)]
pub fn spawn_item_slot(
commands: &mut Commands,
parent: Entity,
theme: &Theme,
assets: &AssetServer,
data: &GameData,
stack: Option<&ItemStack>,
size: f32,
highlighted: bool,
label: Option<String>,
action: String,
hint: &str,
) {
let tooltip = match stack.and_then(|s| Some((s, data.items.get(&s.item)?))) {
Some((stack, def)) => {
let mut tip = format!("{} x{}", def.name, stack.count);
if !def.description.is_empty() {
tip.push_str(&format!("\n{}", def.description));
}
if !hint.is_empty() {
tip.push_str(&format!("\n\n{hint}"));
}
tip
}
None => hint.to_owned(),
};
let slot = commands
.spawn((
ChildOf(parent),
OnClick(action),
Tooltip(tooltip),
Node {
width: Val::Px(size),
height: Val::Px(size),
border: UiRect::all(Val::Px(if highlighted { 2.0 } else { 1.0 })),
border_radius: BorderRadius::all(Val::Px(theme.radius)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(theme.color("slot")),
BorderColor::all(theme.color(if highlighted { "accent" } else { "border" })),
))
.id();
if let Some(stack) = stack {
let sprite = data
.items
.get(&stack.item)
.map(|d| d.sprite.clone())
.unwrap_or_default();
commands.spawn((
ChildOf(slot),
ImageNode::new(assets.load(sprite)),
Node {
width: Val::Px(size * 0.75),
height: Val::Px(size * 0.75),
..default()
},
));
if stack.count > 1 {
commands.spawn((
ChildOf(slot),
text(theme, stack.count.to_string(), theme.small_size, "text"),
Node {
position_type: PositionType::Absolute,
right: Val::Px(3.0),
bottom: Val::Px(1.0),
..default()
},
));
}
}
if let Some(label) = label {
commands.spawn((
ChildOf(slot),
text(theme, label, theme.small_size - 2.0, "dim"),
Node {
position_type: PositionType::Absolute,
left: Val::Px(3.0),
top: Val::Px(1.0),
..default()
},
));
}
}
const SLOT_SIZE: f32 = 44.0;
const SLOTS_PER_ROW: usize = 5;
#[allow(clippy::too_many_arguments)]
fn inventory_widget(
mut commands: Commands,
theme: Res<Theme>,
assets: Res<AssetServer>,
data: Res<GameData>,
ui: Res<UiState>,
machine: Res<OpenMachine>,
local: Res<LocalCharacter>,
inventories: Query<&Inventory>,
mut slots: Query<(Entity, &mut WidgetSlot)>,
) {
let Some(inventory) = local.0.and_then(|e| inventories.get(e).ok()) else {
return;
};
for (entity, mut slot) in &mut slots {
if slot.name != "inventory"
|| !slot.changed(&(&inventory.slots, ui.selected_slot, machine.0.is_some()))
{
continue;
}
commands.entity(entity).despawn_children();
let hint = if machine.0.is_some() {
"Click: put into the machine"
} else {
"Click: select - U: use - G: drop"
};
for row in 0..INVENTORY_SLOTS.div_ceil(SLOTS_PER_ROW) {
let row_entity = commands
.spawn((
ChildOf(entity),
Node {
column_gap: Val::Px(4.0),
..default()
},
))
.id();
for i in row * SLOTS_PER_ROW..((row + 1) * SLOTS_PER_ROW).min(INVENTORY_SLOTS) {
let key = match i {
0..9 => Some((i + 1).to_string()),
9 => Some("0".to_owned()),
_ => None,
};
spawn_item_slot(
&mut commands,
row_entity,
&theme,
&assets,
&data,
inventory.slots.get(i),
SLOT_SIZE,
i == ui.selected_slot,
key,
format!("inv:{i}"),
hint,
);
}
}
}
}
fn skills_widget(
mut commands: Commands,
theme: Res<Theme>,
data: Res<GameData>,
local: Res<LocalCharacter>,
skills: Query<&Skills>,
mut slots: Query<(Entity, &mut WidgetSlot)>,
) {
let Some(skills) = local.0.and_then(|e| skills.get(e).ok()) else {
return;
};
for (entity, mut slot) in &mut slots {
let rows: Vec<(String, u32, u32, u32)> = data
.skills
.values()
.map(|def| {
let progress = skills.skills.get(&def.id).copied().unwrap_or_default();
let level = progress.level();
(
def.name.clone(),
level,
progress.xp,
SkillProgress::xp_for_level(level + 1),
)
})
.collect();
if slot.name != "skills" || !slot.changed(&rows) {
continue;
}
commands.entity(entity).despawn_children();
for (name, level, xp, next) in rows {
commands.spawn((
ChildOf(entity),
text(
&theme,
format!("{name:<12} {level:>2}"),
theme.font_size,
"text",
),
Tooltip(format!(
"{name} level {level}\n{xp} / {next} xp to the next level"
)),
));
}
}
}
fn log_widget(
mut commands: Commands,
theme: Res<Theme>,
log: Res<GameLogBuffer>,
mut slots: Query<(Entity, &mut WidgetSlot)>,
) {
for (entity, mut slot) in &mut slots {
if slot.name != "log" || !slot.changed(&log.lines) {
continue;
}
commands.entity(entity).despawn_children();
for line in &log.lines {
// Chat lines start with `<name>`, admin notices with `[admin]`.
let color = if line.starts_with('<') {
"text"
} else if line.starts_with("[admin]") {
"accent"
} else {
"dim"
};
commands.spawn((ChildOf(entity), text(&theme, line, theme.font_size, color)));
}
}
}
fn ingredients(data: &GameData, list: &[Ingredient]) -> String {
list.iter()
.map(|i| format!("{} {}", i.count, item_name(data, &i.item)))
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Hash)]
struct RecipeRow {
action: String,
name: String,
summary: String,
details: String,
status: Result<(), String>,
}
/// Rows of the craft or build list, dry-running the server rules on copies to show what is missing.
fn recipe_rows(
menu: Menu,
data: &GameData,
inventory: &Inventory,
skills: &Skills,
stations: &[String],
) -> Vec<RecipeRow> {
menu_entries(menu, data)
.into_iter()
.map(|id| {
let (mut inv, mut sk) = (inventory.clone(), skills.clone());
match menu {
Menu::Craft => {
let r = &data.recipes[&id];
let mut details = String::new();
if let Some(station) = &r.station {
details.push_str(&format!("Station: {station}\n"));
}
if !r.tools.is_empty() {
let tools: Vec<_> = r.tools.iter().map(|t| item_name(data, t)).collect();
details.push_str(&format!("Tools: {}\n", tools.join(", ")));
}
if let Some(skill) = &r.skill {
details.push_str(&format!("Skill: {} {}\n", skill.skill, skill.level));
}
RecipeRow {
action: format!("craft:{id}"),
name: r.display_name(data).to_owned(),
summary: format!(
"{} -> {}",
ingredients(data, &r.inputs),
ingredients(data, &r.outputs)
),
details,
status: try_craft(data, &id, stations, &mut inv, &mut sk).map(|_| ()),
}
}
_ => {
let b = &data.buildings[&id];
let c = b.construction.as_ref().expect("filtered");
let mut details = String::new();
if !b.description.is_empty() {
details.push_str(&format!("{}\n", b.description));
}
if !c.tools.is_empty() {
let tools: Vec<_> = c.tools.iter().map(|t| item_name(data, t)).collect();
details.push_str(&format!("Tools: {}\n", tools.join(", ")));
}
if let Some(skill) = &c.skill {
details.push_str(&format!("Skill: {} {}\n", skill.skill, skill.level));
}
RecipeRow {
action: format!("build:{id}"),
name: b.name.clone(),
summary: ingredients(data, &c.materials),
details,
status: try_construct(data, &id, &mut inv, &mut sk).map(|_| ()),
}
}
}
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn recipe_widgets(
mut commands: Commands,
theme: Res<Theme>,
data: Res<GameData>,
ui: Res<UiState>,
local: Res<LocalCharacter>,
characters: Query<(&GridPos, &Inventory, &Skills)>,
buildings: Query<(&Building, &GridPos)>,
mut slots: Query<(Entity, &mut WidgetSlot)>,
) {
let Some((pos, inventory, skills)) = local.0.and_then(|e| characters.get(e).ok()) else {
return;
};
for (entity, mut slot) in &mut slots {
let menu = match slot.name.as_str() {
"craft_list" => Menu::Craft,
"build_list" => Menu::Build,
_ => continue,
};
if ui.menu != Some(menu) {
continue;
}
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 rows = recipe_rows(menu, &data, inventory, skills, &stations);
if !slot.changed(&(&rows, ui.cursor)) {
continue;
}
commands.entity(entity).despawn_children();
for (i, row) in rows.into_iter().enumerate() {
let (status, color) = match &row.status {
Ok(()) => ("ready".to_owned(), "good"),
Err(reason) => (reason.clone(), "error"),
};
let selected = i == ui.cursor;
commands.spawn((
ChildOf(entity),
OnClick(row.action),
ThemedButton,
Tooltip(format!(
"{}\n{}\n{}{status}",
row.name, row.summary, row.details
)),
Node {
width: Val::Px(460.0),
flex_direction: FlexDirection::Column,
padding: UiRect::axes(Val::Px(8.0), Val::Px(4.0)),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(theme.radius)),
..default()
},
BackgroundColor(theme.color("button")),
BorderColor::all(theme.color(if selected { "accent" } else { "border" })),
children![
text(
&theme,
row.name,
theme.font_size,
if row.status.is_ok() { "text" } else { "dim" }
),
text(&theme, row.summary, theme.small_size, "dim"),
text(&theme, status, theme.small_size, color),
],
));
}
}
}
/// Crafts the recipe or builds the building `id` (in front of the player).
pub fn activate_entry(
commands: &mut Commands,
menu: Menu,
id: String,
pos: GridPos,
facing: game_core::grid::Dir,
) {
match menu {
Menu::Craft => commands.client_trigger(Craft { recipe: id }),
Menu::Build => commands.client_trigger(Construct {
building: id,
target: pos.step(facing),
}),
Menu::Help => {}
}
}
#[allow(clippy::too_many_arguments)]
fn on_action(
action: On<UiAction>,
mut commands: Commands,
state: Res<State<AppState>>,
mut ui: ResMut<UiState>,
mut form: ResMut<UiForm>,
machine: Res<OpenMachine>,
local: Res<LocalCharacter>,
characters: Query<(&GridPos, &Facing, &Inventory)>,
positions: Query<&GridPos>,
) {
if *state.get() != AppState::InGame {
return;
}
let me = local.0.and_then(|e| characters.get(e).ok());
let (verb, arg) = action.0.split_once(':').unwrap_or((&action.0, ""));
match verb {
"inv" => {
let Ok(slot) = arg.parse::<usize>() else {
return;
};
ui.selected_slot = slot;
let stack = me.and_then(|(_, _, inv)| inv.slots.get(slot));
if let (Some(stack), Some(target)) =
(stack, machine.0.and_then(|m| positions.get(m).ok()))
{
commands.client_trigger(MachineInsert {
target: *target,
inventory_slot: slot,
machine_slot: None,
count: stack.count,
});
}
}
"craft" | "build" => {
let menu = if verb == "craft" {
Menu::Craft
} else {
Menu::Build
};
if let Some((pos, facing, _)) = me {
activate_entry(&mut commands, menu, arg.to_owned(), *pos, facing.0);
}
}
"menu" => match arg {
"craft" => ui.toggle(Menu::Craft),
"build" => ui.toggle(Menu::Build),
"help" => ui.toggle(Menu::Help),
_ => ui.menu = None,
},
"chat_send" => {
let text = form.get("chat").trim().to_owned();
form.0.insert("chat".into(), String::new());
ui.chat_open = false;
if !text.is_empty() {
commands.client_trigger(Chat { text });
}
}
"resume" => ui.paused = false,
"leave" => {
commands.queue(|world: &mut World| session::leave(world, None));
}
"quit" => {
commands.write_message(AppExit::Success);
}
_ => {}
}
}

View File

@ -11,15 +11,64 @@ use game_core::{
use game_server::actions::REACH; use game_server::actions::REACH;
use crate::{ use crate::{
hud::{Menu, UiState, activate_entry, menu_entries},
machine::{InteractAt, OpenMachine},
render::LocalCharacter, render::LocalCharacter,
ui::{Menu, UiState, menu_entries}, session::AppState,
ui::{TextEditSystems, UiFocus, pointer_over_ui},
}; };
pub struct InputPlugin; pub struct InputPlugin;
impl Plugin for InputPlugin { impl Plugin for InputPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(Update, (movement, menu_input, actions, mouse).chain()); app.add_systems(
Update,
(
window_keys,
movement,
(menu_input, actions, mouse).run_if(game_input_allowed),
)
.chain()
.after(TextEditSystems)
.run_if(in_state(AppState::InGame)),
);
}
}
fn game_input_allowed(focus: Res<UiFocus>, ui: Res<UiState>) -> bool {
!focus.captures_keyboard() && !ui.paused && !ui.chat_open
}
/// Esc closes the innermost window or toggles the pause menu; Enter/T/`/` open the chat.
fn window_keys(
keys: Res<ButtonInput<KeyCode>>,
focus: Res<UiFocus>,
mut ui: ResMut<UiState>,
mut machine: ResMut<OpenMachine>,
mut form: ResMut<crate::ui::UiForm>,
) {
if focus.captures_keyboard() || ui.chat_open {
return;
}
if keys.just_pressed(KeyCode::Escape) {
if machine.0.is_some() {
machine.0 = None;
} else if ui.menu.is_some() {
ui.menu = None;
} else {
ui.paused = !ui.paused;
}
return;
}
if ui.paused || ui.menu.is_some_and(|m| m != Menu::Help) {
return;
}
if keys.any_just_pressed([KeyCode::Enter, KeyCode::NumpadEnter, KeyCode::KeyT]) {
ui.chat_open = true;
} else if keys.any_just_pressed([KeyCode::Slash, KeyCode::NumpadDivide]) {
form.0.insert("chat".into(), "/".into());
ui.chat_open = true;
} }
} }
@ -39,6 +88,7 @@ fn movement(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
ui: Res<UiState>, ui: Res<UiState>,
focus: Res<UiFocus>,
mut held: Local<Vec<Dir>>, mut held: Local<Vec<Dir>>,
mut sent: Local<Option<Dir>>, mut sent: Local<Option<Dir>>,
) { ) {
@ -52,7 +102,7 @@ fn movement(
held.push(dir); held.push(dir);
} }
} }
let dir = if ui.menu.is_some_and(|m| m != Menu::Help) { let dir = if ui.blocks_game() || ui.chat_open || focus.captures_keyboard() {
None None
} else { } else {
held.last().copied() held.last().copied()
@ -80,10 +130,6 @@ fn menu_input(
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>, characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
) { ) {
let Some(menu) = ui.menu else { return }; let Some(menu) = ui.menu else { return };
if keys.just_pressed(KeyCode::Escape) {
ui.menu = None;
return;
}
let entries = menu_entries(menu, &data); let entries = menu_entries(menu, &data);
if entries.is_empty() { if entries.is_empty() {
return; return;
@ -96,17 +142,8 @@ fn menu_input(
} }
if keys.any_just_pressed([KeyCode::Enter, KeyCode::Space, KeyCode::NumpadEnter]) { if keys.any_just_pressed([KeyCode::Enter, KeyCode::Space, KeyCode::NumpadEnter]) {
let id = entries[ui.cursor.min(entries.len() - 1)].clone(); 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) { if let Some((pos, facing, _)) = local_state(&local, &characters) {
commands.client_trigger(Construct { activate_entry(&mut commands, menu, id, pos, facing);
building: id,
target: pos.step(facing),
});
}
}
Menu::Help => {}
} }
} }
} }
@ -130,6 +167,7 @@ fn actions(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut wheel: MessageReader<MouseWheel>, mut wheel: MessageReader<MouseWheel>,
mut ui: ResMut<UiState>, mut ui: ResMut<UiState>,
machine: Res<OpenMachine>,
local: Res<LocalCharacter>, local: Res<LocalCharacter>,
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>, characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
items: Query<&GridPos, With<GroundItem>>, items: Query<&GridPos, With<GroundItem>>,
@ -151,9 +189,16 @@ fn actions(
ui.selected_slot = i; ui.selected_slot = i;
} }
} }
for ev in wheel.read() { let wheel_events: Vec<f32> = wheel.read().map(|ev| ev.y).collect();
// Open windows have their own list navigation.
let wheel_events = if ui.menu.is_some() || machine.0.is_some() {
Vec::new()
} else {
wheel_events
};
for y in wheel_events {
let len = INVENTORY_SLOTS; let len = INVENTORY_SLOTS;
ui.selected_slot = if ev.y > 0.0 { ui.selected_slot = if y > 0.0 {
(ui.selected_slot + len - 1) % len (ui.selected_slot + len - 1) % len
} else { } else {
(ui.selected_slot + 1) % len (ui.selected_slot + 1) % len
@ -178,7 +223,7 @@ fn actions(
commands.client_trigger(PickUp { target }); commands.client_trigger(PickUp { target });
} }
if keys.just_pressed(KeyCode::KeyF) { if keys.just_pressed(KeyCode::KeyF) {
commands.client_trigger(Interact { target: front }); commands.trigger(InteractAt(front));
} }
if keys.just_pressed(KeyCode::KeyX) { if keys.just_pressed(KeyCode::KeyX) {
commands.client_trigger(Deconstruct { target: front }); commands.client_trigger(Deconstruct { target: front });
@ -196,16 +241,22 @@ fn actions(
} }
/// Left click on a tile within reach: interact with a building, otherwise pick up items. /// Left click on a tile within reach: interact with a building, otherwise pick up items.
#[allow(clippy::too_many_arguments)]
fn mouse( fn mouse(
mut commands: Commands, mut commands: Commands,
buttons: Res<ButtonInput<MouseButton>>, buttons: Res<ButtonInput<MouseButton>>,
interactions: Query<&Interaction>,
ui: Res<UiState>,
window: Query<&Window, With<PrimaryWindow>>, window: Query<&Window, With<PrimaryWindow>>,
camera: Query<(&Camera, &GlobalTransform)>, camera: Query<(&Camera, &GlobalTransform)>,
local: Res<LocalCharacter>, local: Res<LocalCharacter>,
characters: Query<&GridPos, With<Character>>, characters: Query<&GridPos, With<Character>>,
buildings: Query<&GridPos, With<Building>>, buildings: Query<&GridPos, With<Building>>,
) { ) {
if !buttons.just_pressed(MouseButton::Left) { if !buttons.just_pressed(MouseButton::Left)
|| ui.blocks_game()
|| pointer_over_ui(&interactions)
{
return; return;
} }
let Some(me) = local.0.and_then(|e| characters.get(e).ok()) else { let Some(me) = local.0.and_then(|e| characters.get(e).ok()) else {
@ -228,7 +279,7 @@ fn mouse(
return; return;
} }
if buildings.iter().any(|p| *p == target) { if buildings.iter().any(|p| *p == target) {
commands.client_trigger(Interact { target }); commands.trigger(InteractAt(target));
} else { } else {
commands.client_trigger(PickUp { target }); commands.client_trigger(PickUp { target });
} }

View File

@ -0,0 +1,428 @@
//! Machine window: item slots and progress of the machine the player interacts with,
//! laid out according to the machine's `ui` definition.
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use game_core::{
GameData,
components::*,
protocol::{Interact, MachineInsert, MachineTake},
};
use game_server::actions::REACH;
use crate::{
hud::{UiState, spawn_item_slot},
render::LocalCharacter,
session::{AppState, SessionEnded},
ui::{OnClick, ThemedButton, Tooltip, UiAction, layout::Theme},
};
pub struct MachinePlugin;
impl Plugin for MachinePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<OpenMachine>()
.add_observer(interact_at)
.add_observer(on_action)
.add_observer(|_: On<SessionEnded>, mut open: ResMut<OpenMachine>| open.0 = None)
.add_systems(
Update,
(close_when_invalid, window, slots, progress)
.chain()
.run_if(in_state(AppState::InGame)),
);
}
}
/// The machine whose window is open.
#[derive(Resource, Default)]
pub struct OpenMachine(pub Option<Entity>);
/// Interact with the tile at a position: opens the window of a machine there,
/// otherwise sends [`Interact`] to the server.
#[derive(Event, Clone, Copy, Debug)]
pub struct InteractAt(pub GridPos);
fn interact_at(
ev: On<InteractAt>,
mut commands: Commands,
mut open: ResMut<OpenMachine>,
mut ui: ResMut<UiState>,
machines: Query<(Entity, &GridPos), With<Machine>>,
) {
match machines.iter().find(|(_, p)| **p == ev.0) {
Some((entity, _)) => {
open.0 = Some(entity);
ui.menu = None;
}
None => commands.client_trigger(Interact { target: ev.0 }),
}
}
#[derive(Component)]
struct MachineWindow(Entity);
#[derive(Component)]
struct SlotArea {
content: u64,
}
#[derive(Component)]
struct ProgressFill;
#[derive(Component)]
struct SlotNode;
fn close_when_invalid(
mut open: ResMut<OpenMachine>,
local: Res<LocalCharacter>,
positions: Query<&GridPos>,
machines: Query<&GridPos, With<Machine>>,
) {
let Some(machine) = open.0 else { return };
let me = local.0.and_then(|e| positions.get(e).ok());
let in_reach = match (me, machines.get(machine)) {
(Some(me), Ok(pos)) => me.distance(*pos) <= REACH,
_ => false,
};
if !in_reach {
open.0 = None;
}
}
/// Spawns or despawns the window frame when the open machine changes.
#[allow(clippy::too_many_arguments)]
fn window(
mut commands: Commands,
open: Res<OpenMachine>,
theme: Res<Theme>,
data: Res<GameData>,
assets: Res<AssetServer>,
buildings: Query<&Building>,
windows: Query<(Entity, &MachineWindow)>,
) {
let current = windows.iter().next();
if current.map(|(_, w)| w.0) == open.0 {
return;
}
if let Some((entity, _)) = current {
commands.entity(entity).despawn();
}
let Some(machine) = open.0 else { return };
let Some((building, def)) = buildings
.get(machine)
.ok()
.and_then(|b| Some((data.buildings.get(&b.proto)?, data.machines.get(&b.proto)?)))
else {
return;
};
let layout = def.layout();
let text = |text: String, size: f32, color: &str| {
(
Text::new(text),
TextFont::from_font_size(size),
TextColor(theme.color(color)),
)
};
let recipes: Vec<String> = def
.recipes
.iter()
.map(|r| {
let list = |list: &[game_core::data::Ingredient]| {
list.iter()
.map(|i| {
format!(
"{} {}",
i.count,
game_server::crafting::item_name(&data, &i.item)
)
})
.collect::<Vec<_>>()
.join(", ")
};
format!(
"{}: {} -> {} ({}s)",
r.name.as_deref().unwrap_or(&r.id),
list(&r.inputs),
list(&r.outputs),
r.time
)
})
.collect();
// Full-screen wrapper to center the window.
let root = commands
.spawn((
MachineWindow(machine),
Node {
position_type: PositionType::Absolute,
width: Val::Percent(100.0),
height: Val::Percent(100.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
Pickable::IGNORE,
GlobalZIndex(10),
))
.id();
let panel = commands
.spawn((
ChildOf(root),
Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(theme.gap),
padding: UiRect::all(Val::Px(theme.padding)),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(theme.radius)),
..default()
},
BackgroundColor(theme.color("window")),
BorderColor::all(theme.color("border")),
Interaction::default(),
bevy::ui::FocusPolicy::Block,
))
.id();
commands.spawn((
ChildOf(panel),
Node {
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
column_gap: Val::Px(theme.gap * 4.0),
..default()
},
children![
(
text(building.name.clone(), theme.font_size + 4.0, "accent"),
Tooltip(format!(
"{}\n\nRecipes:\n{}",
building.description,
recipes.join("\n")
)),
),
(
OnClick("machine:close".into()),
ThemedButton,
Tooltip("Close (Esc)".into()),
Node {
padding: UiRect::axes(Val::Px(8.0), Val::Px(2.0)),
border_radius: BorderRadius::all(Val::Px(theme.radius)),
..default()
},
BackgroundColor(theme.color("button")),
children![text("x".into(), theme.font_size, "text")],
),
],
));
let mut content = commands.spawn((
ChildOf(panel),
SlotArea { content: u64::MAX },
Node {
width: Val::Px(layout.size.0),
height: Val::Px(layout.size.1),
..default()
},
));
if let Some(background) = &def.ui.background {
content.insert(ImageNode::new(assets.load(background.clone())));
}
let content = content.id();
let (x, y, w, h) = layout.progress;
commands.spawn((
ChildOf(content),
Node {
position_type: PositionType::Absolute,
left: Val::Px(x),
top: Val::Px(y),
width: Val::Px(w),
height: Val::Px(h),
border_radius: BorderRadius::all(Val::Px(h / 2.0)),
overflow: Overflow::clip(),
..default()
},
BackgroundColor(theme.color("slot")),
children![(
ProgressFill,
Node {
width: Val::Percent(0.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(theme.color("progress")),
)],
));
commands.spawn((
ChildOf(panel),
text(
"Click an inventory slot to insert, a machine slot to take.".into(),
theme.small_size,
"dim",
),
));
}
/// Rebuilds the slots when the machine contents change.
#[allow(clippy::too_many_arguments)]
fn slots(
mut commands: Commands,
open: Res<OpenMachine>,
theme: Res<Theme>,
data: Res<GameData>,
assets: Res<AssetServer>,
machines: Query<(&Building, &Machine)>,
mut areas: Query<(Entity, &mut SlotArea)>,
slots: Query<Entity, With<SlotNode>>,
children: Query<&Children>,
) {
let Some((building, machine)) = open.0.and_then(|e| machines.get(e).ok()) else {
return;
};
let Some(def) = data.machines.get(&building.proto) else {
return;
};
for (area, mut state) in &mut areas {
let hash = {
use std::hash::{Hash, Hasher};
let mut hasher = std::hash::DefaultHasher::new();
(&machine.inputs, &machine.outputs).hash(&mut hasher);
hasher.finish()
};
if state.content == hash {
continue;
}
state.content = hash;
for child in children.get(area).into_iter().flatten() {
if slots.contains(*child) {
commands.entity(*child).despawn();
}
}
let layout = def.layout();
let all = machine
.inputs
.iter()
.zip(&layout.inputs)
.enumerate()
.map(|(i, (stack, pos))| ("in", i, stack, pos))
.chain(
machine
.outputs
.iter()
.zip(&layout.outputs)
.enumerate()
.map(|(i, (stack, pos))| ("out", i, stack, pos)),
);
for (kind, i, stack, &(x, y)) in all {
let hint = match (kind, stack) {
("in", Some(_)) => "Click: take back",
("in", None) => "Input - click: insert the selected inventory item",
(_, Some(_)) => "Click: take",
(_, None) => "Output",
};
// Spawn into a positioned wrapper so the shared slot helper stays layout-agnostic.
let wrapper = commands
.spawn((
ChildOf(area),
SlotNode,
Node {
position_type: PositionType::Absolute,
left: Val::Px(x),
top: Val::Px(y),
..default()
},
Pickable::IGNORE,
))
.id();
spawn_item_slot(
&mut commands,
wrapper,
&theme,
&assets,
&data,
stack.as_ref(),
layout.slot_size,
false,
None,
format!("machine:{kind}:{i}"),
hint,
);
}
}
}
fn progress(
open: Res<OpenMachine>,
data: Res<GameData>,
machines: Query<(&Building, &Machine)>,
mut fills: Query<&mut Node, With<ProgressFill>>,
) {
let Some((building, machine)) = open.0.and_then(|e| machines.get(e).ok()) else {
return;
};
let time = machine
.recipe
.as_ref()
.and_then(|id| {
data.machines
.get(&building.proto)?
.recipes
.iter()
.find(|r| &r.id == id)
})
.map_or(0.0, |r| r.time);
let fraction = if time > 0.0 {
(machine.progress / time).clamp(0.0, 1.0)
} else {
0.0
};
for mut node in &mut fills {
let width = Val::Percent(fraction * 100.0);
if node.width != width {
node.width = width;
}
}
}
fn on_action(
action: On<UiAction>,
mut commands: Commands,
mut open: ResMut<OpenMachine>,
ui: Res<UiState>,
local: Res<LocalCharacter>,
inventories: Query<&Inventory>,
machines: Query<(&GridPos, &Machine)>,
) {
let mut parts = action.0.split(':');
if parts.next() != Some("machine") {
return;
}
let Some(entity) = open.0 else { return };
let (kind, index) = (parts.next(), parts.next().and_then(|i| i.parse().ok()));
if kind == Some("close") {
open.0 = None;
return;
}
let (Some(index), Ok((target, machine))) = (index, machines.get(entity)) else {
return;
};
let slot = match kind {
Some("in") => MachineSlot::Input(index),
Some("out") => MachineSlot::Output(index),
_ => return,
};
if let Some(stack) = machine.slot(slot) {
commands.client_trigger(MachineTake {
target: *target,
slot,
count: stack.count,
});
} else if let MachineSlot::Input(i) = slot {
let inventory = local.0.and_then(|e| inventories.get(e).ok());
if let Some(stack) = inventory.and_then(|inv| inv.slots.get(ui.selected_slot)) {
commands.client_trigger(MachineInsert {
target: *target,
inventory_slot: ui.selected_slot,
machine_slot: Some(i),
count: stack.count,
});
}
}
}

View File

@ -1,51 +1,39 @@
//! Game client. //! Game client.
//! //!
//! ```text //! ```text
//! game singleplayer (persistent local world) //! game start menu
//! game --host [PORT] singleplayer world that friends can join //! game --save NAME play a local world directly
//! game --host [PORT] play a local world that friends can join
//! game --connect ADDR[:PORT] join a dedicated server or host //! game --connect ADDR[:PORT] join a dedicated server or host
//! ``` //! ```
//! Common options: `--name NAME`, `--save NAME`, `--map MAP`. //! Common options: `--name NAME`, `--password PASSWORD`, `--map MAP`, `--no-save`.
// Release builds on Windows run without a console window. // Release builds on Windows run without a console window.
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
mod hud;
mod input; mod input;
mod net; mod machine;
mod menu;
mod profile;
mod render; mod render;
mod saves; mod saves;
mod session;
mod ui; mod ui;
use std::net::SocketAddr;
use bevy::{prelude::*, window::WindowResolution}; use bevy::{prelude::*, window::WindowResolution};
use bevy_replicon::prelude::*; use bevy_replicon::prelude::*;
use bevy_replicon_renet::RepliconRenetPlugins; use bevy_replicon_renet::RepliconRenetPlugins;
use game_core::CorePlugin; use game_core::CorePlugin;
use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings, config::ServerConfig};
/// How this client runs. use crate::{
#[derive(Clone, Debug)] profile::Profile,
pub enum Mode { session::{AppState, SessionRequest, SessionTarget},
/// 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 { fn main() -> AppExit {
let (mode, name, settings) = parse_args(); let mut profile = Profile::load();
let (request, map) = parse_args(&mut profile);
let mut app = App::new(); let mut app = App::new();
let assets = game_core::assets_dir(); let assets = game_core::assets_dir();
app.add_plugins(( app.add_plugins((
@ -57,7 +45,7 @@ fn main() -> AppExit {
.set(ImagePlugin::default_nearest()) .set(ImagePlugin::default_nearest())
.set(WindowPlugin { .set(WindowPlugin {
primary_window: Some(Window { primary_window: Some(Window {
title: format!("Station - {name}"), title: "Station".to_owned(),
resolution: WindowResolution::new(1280, 800), resolution: WindowResolution::new(1280, 800),
..default() ..default()
}), }),
@ -66,48 +54,36 @@ fn main() -> AppExit {
RepliconPlugins, RepliconPlugins,
RepliconRenetPlugins, RepliconRenetPlugins,
CorePlugin, CorePlugin,
)); ))
.insert_resource(profile)
match &mode { .insert_resource(menu::StartMap(map))
Mode::Singleplayer => {
app.add_plugins(GameServerPlugin { settings });
}
Mode::Host { port } => {
app.add_plugins((
GameServerPlugin { settings },
NetworkServerPlugin {
config: ServerConfig {
port: *port,
..default()
},
},
));
}
Mode::Join { .. } => {}
}
app.insert_resource(LocalPlayer { name })
.insert_resource(ClientMode(mode))
.insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.05))) .insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.05)))
.add_plugins(( .add_plugins((
net::NetPlugin, ui::UiFrameworkPlugin,
session::SessionPlugin,
menu::MenuPlugin,
hud::HudPlugin,
machine::MachinePlugin,
render::RenderPlugin, render::RenderPlugin,
input::InputPlugin, input::InputPlugin,
ui::UiPlugin, ));
)) if let Some(request) = request {
.run() app.insert_resource(request);
app.world_mut()
.resource_mut::<NextState<AppState>>()
.set(AppState::Connecting);
}
app.run()
} }
fn parse_args() -> (Mode, String, ServerSettings) { /// Returns the session to start right away (skipping the menu), if any, and the map for new worlds.
let mut mode = Mode::Singleplayer; fn parse_args(profile: &mut Profile) -> (Option<SessionRequest>, String) {
let mut name = std::env::var("USER") let mut host = None;
.ok() let mut connect = None;
.filter(|n| game_server::players::valid_name(n)) let mut save = None;
.unwrap_or_else(|| "Player".to_owned()); let mut no_save = false;
let mut settings = ServerSettings { let mut password = String::new();
storage: Some(saves::storage("singleplayer")), let mut map = game_core::DEFAULT_MAP.to_owned();
..default()
};
let mut args = std::env::args().skip(1).peekable(); let mut args = std::env::args().skip(1).peekable();
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
let mut value = |what: &str| { let mut value = |what: &str| {
@ -116,41 +92,49 @@ fn parse_args() -> (Mode, String, ServerSettings) {
}; };
match arg.as_str() { match arg.as_str() {
"--host" => { "--host" => {
let port = match args.peek() { host = Some(match args.peek() {
Some(p) if !p.starts_with("--") => args Some(p) if !p.starts_with("--") => args
.next() .next()
.unwrap() .unwrap()
.parse() .parse()
.unwrap_or_else(|_| usage("invalid port")), .unwrap_or_else(|_| usage("invalid port")),
_ => game_core::DEFAULT_PORT, _ => game_core::DEFAULT_PORT,
}; });
mode = Mode::Host { port };
} }
"--connect" => { "--connect" => connect = Some(value("an address")),
let addr = value("an address"); "--name" => profile.name = value("a name"),
let addr = if addr.contains(':') { "--password" => password = value("a password"),
addr "--save" => save = Some(value("a save name")),
} else { "--no-save" => no_save = true,
format!("{addr}:{}", game_core::DEFAULT_PORT) "--map" => map = value("a map path"),
};
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.storage = Some(saves::storage(&value("a save name"))),
"--no-save" => settings.storage = None,
"--map" => settings.map = value("a map path"),
"-h" | "--help" => usage(""), "-h" | "--help" => usage(""),
other => usage(&format!("unknown argument `{other}`")), other => usage(&format!("unknown argument `{other}`")),
} }
} }
if !game_server::players::valid_name(&name) { if !game_server::players::valid_name(&profile.name) {
usage("names may only contain letters, digits, spaces, `_` and `-` (max 24)"); usage("names may only contain letters, digits, spaces, `_` and `-` (max 24)");
} }
(mode, name, settings) let target = match (connect, host) {
(Some(_), Some(_)) => usage("--connect and --host can't be combined"),
(Some(address), None) => {
if save.is_some() || no_save {
usage("--save and --no-save only apply to local worlds");
}
SessionTarget::Remote { address }
}
(None, host) if host.is_some() || save.is_some() || no_save => SessionTarget::Local {
storage: (!no_save).then(|| saves::storage(save.as_deref().unwrap_or("singleplayer"))),
map: map.clone(),
host: host.map(|port| (port, password.clone())),
},
_ => return (None, map),
};
let request = SessionRequest {
target,
name: profile.name.clone(),
password,
};
(Some(request), map)
} }
fn usage(error: &str) -> ! { fn usage(error: &str) -> ! {
@ -158,12 +142,15 @@ fn usage(error: &str) -> ! {
eprintln!("error: {error}\n"); eprintln!("error: {error}\n");
} }
eprintln!( eprintln!(
"usage: game [--host [PORT] | --connect ADDR[:PORT]] [--name NAME] [--save NAME | --no-save] [--map MAP]\n\n\ "usage: game [--save NAME | --no-save] [--host [PORT]] [--connect ADDR[:PORT]]\n \
(no mode) singleplayer, world saved as `singleplayer`\n\ [--name NAME] [--password PASSWORD] [--map MAP]\n\n\
--host play your world and let others join (default port {port})\n\ (no options) show the start menu\n\
--save play the local world NAME (created if missing), stored in {saves}\n\
--no-save play a local world that is not saved\n\
--host let others join your local world (default port {port}, password from --password)\n\
--connect join a server (default port {port})\n\ --connect join a server (default port {port})\n\
--name character name; characters persist per world\n\ --name character name; characters persist per world\n\
--save save game for singleplayer/host, stored in {saves}\n\ --password server password (joining) or password for others (hosting)\n\
--map Tiled map for new worlds (default {map})", --map Tiled map for new worlds (default {map})",
port = game_core::DEFAULT_PORT, port = game_core::DEFAULT_PORT,
map = game_core::DEFAULT_MAP, map = game_core::DEFAULT_MAP,

View File

@ -0,0 +1,380 @@
//! Start menu: new game, load game, join a server. Screens are layout files in
//! `assets/ui/` (`main_menu`, `new_game`, `load_game`, `connect`, `connecting`).
use bevy::prelude::*;
use crate::{
profile::Profile,
saves::{self, SaveEntry},
session::{self, AppState, MenuMessage, SessionEnded, SessionRequest, SessionTarget},
ui::{self, OnClick, UiAction, UiBindings, UiContext, UiForm, WidgetSlot, layout::Theme},
};
pub struct MenuPlugin;
impl Plugin for MenuPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<MenuScreen>()
.init_resource::<SaveList>()
.add_systems(OnEnter(AppState::Connecting), show_connecting)
.add_systems(
Update,
(menu_bindings, show_menu, save_list)
.chain()
.run_if(in_state(AppState::Menu)),
)
.add_observer(on_action)
.add_observer(
// Errors are shown on the screen the session was started from.
|_: On<SessionEnded>, message: Res<MenuMessage>, mut screen: ResMut<MenuScreen>| {
if message.0.is_empty() {
*screen = MenuScreen::Main;
}
},
);
}
}
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MenuScreen {
#[default]
Main,
NewGame,
LoadGame,
Connect,
}
impl MenuScreen {
fn layout(self) -> &'static str {
match self {
MenuScreen::Main => "main_menu",
MenuScreen::NewGame => "new_game",
MenuScreen::LoadGame => "load_game",
MenuScreen::Connect => "connect",
}
}
}
/// Map for new worlds (`--map`).
#[derive(Resource, Clone, Debug)]
pub struct StartMap(pub String);
#[derive(Resource, Default)]
struct SaveList {
saves: Vec<SaveEntry>,
/// Save to delete on the second click.
confirm_delete: Option<usize>,
}
#[derive(Component)]
struct MenuRoot;
fn menu_bindings(
message: Res<MenuMessage>,
profile: Res<Profile>,
saves: Res<SaveList>,
mut bindings: ResMut<UiBindings>,
) {
bindings.set("message", message.0.clone());
bindings.set("profile_name", profile.name.clone());
bindings.set("last_address", profile.last_address.clone());
bindings.set("default_port", game_core::DEFAULT_PORT.to_string());
bindings.set("saves_dir", saves::saves_dir().display().to_string());
bindings.set("version", env!("CARGO_PKG_VERSION"));
save_bindings(&saves, &mut bindings);
}
fn save_bindings(saves: &SaveList, bindings: &mut UiBindings) {
bindings.set("save_count", saves.saves.len().to_string());
bindings.flag("has_saves", !saves.saves.is_empty());
bindings.set(
"suggested_world",
(1..)
.map(|i| format!("World {i}"))
.find(|name| !saves.saves.iter().any(|s| &s.world.name == name))
.expect("unbounded"),
);
}
#[allow(clippy::too_many_arguments)]
fn show_menu(
mut commands: Commands,
screen: Res<MenuScreen>,
roots: Query<Entity, With<MenuRoot>>,
theme: Res<Theme>,
mut bindings: ResMut<UiBindings>,
mut form: ResMut<UiForm>,
assets: Res<AssetServer>,
mut saves: ResMut<SaveList>,
) {
if !roots.is_empty() && !screen.is_changed() {
return;
}
for root in &roots {
commands.entity(root).despawn();
}
if matches!(*screen, MenuScreen::LoadGame | MenuScreen::NewGame) {
saves.saves = saves::list();
saves.confirm_delete = None;
save_bindings(&saves, &mut bindings);
}
let mut ctx = UiContext {
theme: &theme,
bindings: &bindings,
form: &mut form,
assets: &assets,
};
let root = ui::spawn_screen(&mut commands, &mut ctx, screen.layout());
commands
.entity(root)
.insert((MenuRoot, DespawnOnExit(AppState::Menu)));
}
fn show_connecting(
mut commands: Commands,
theme: Res<Theme>,
bindings: Res<UiBindings>,
mut form: ResMut<UiForm>,
assets: Res<AssetServer>,
) {
let mut ctx = UiContext {
theme: &theme,
bindings: &bindings,
form: &mut form,
assets: &assets,
};
let root = ui::spawn_screen(&mut commands, &mut ctx, "connecting");
commands
.entity(root)
.insert(DespawnOnExit(AppState::Connecting));
}
fn save_list(
mut commands: Commands,
theme: Res<Theme>,
saves: Res<SaveList>,
mut slots: Query<(Entity, &mut WidgetSlot)>,
) {
for (entity, mut slot) in &mut slots {
if slot.name != "save_list" {
continue;
}
let content: Vec<_> = saves
.saves
.iter()
.map(|s| (s.world.id, s.world.saved_at))
.collect();
if !slot.changed(&(content, saves.confirm_delete)) {
continue;
}
commands.entity(entity).despawn_children();
let text = |text: String, size: f32, color: &str| {
(
Text::new(text),
TextFont::from_font_size(size),
TextColor(theme.color(color)),
)
};
for (i, save) in saves.saves.iter().enumerate() {
let row = commands
.spawn((
ChildOf(entity),
Node {
width: Val::Percent(100.0),
column_gap: Val::Px(8.0),
align_items: AlignItems::Center,
padding: UiRect::all(Val::Px(6.0)),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(theme.radius)),
..default()
},
BorderColor::all(theme.color("border")),
ui::Tooltip(format!(
"File: {}\nWorld id: {}",
save.path.display(),
save.world.id
)),
))
.id();
commands
.spawn((
ChildOf(row),
Node {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
..default()
},
))
.with_children(|info| {
info.spawn(text(save.world.name.clone(), theme.font_size, "text"));
info.spawn(text(
format!(
"{} - saved {}",
save.world.map,
saves::age(save.world.saved_at)
),
theme.small_size,
"dim",
));
});
let ctx_button = |label: &str, action: String| {
(
OnClick(action),
ui::ThemedButton,
Node {
padding: UiRect::axes(Val::Px(12.0), Val::Px(6.0)),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(theme.radius)),
..default()
},
BackgroundColor(theme.color("button")),
BorderColor::all(theme.color("border")),
children![text(label.to_owned(), theme.font_size, "text")],
)
};
commands.spawn((ChildOf(row), ctx_button("Play", format!("load:{i}"))));
let delete = if saves.confirm_delete == Some(i) {
"Really delete?"
} else {
"Delete"
};
commands.spawn((ChildOf(row), ctx_button(delete, format!("delete:{i}"))));
}
}
}
/// Reads the hosting options of the new/load game screens.
fn host_options(form: &UiForm) -> Result<Option<(u16, String)>, String> {
if !form.checked("host") {
return Ok(None);
}
let port = form
.get("port")
.trim()
.parse::<u16>()
.ok()
.filter(|p| *p != 0)
.ok_or("The port must be a number between 1 and 65535.")?;
Ok(Some((port, form.get("password").to_owned())))
}
fn character_name(form: &UiForm) -> Result<String, String> {
let name = form.get("name").trim().to_owned();
if game_server::players::valid_name(&name) {
Ok(name)
} else {
Err("Names may only contain letters, digits, spaces, `_` and `-` (max 24).".into())
}
}
fn on_action(
action: On<UiAction>,
mut commands: Commands,
state: Res<State<AppState>>,
mut screen: ResMut<MenuScreen>,
mut message: ResMut<MenuMessage>,
mut saves: ResMut<SaveList>,
mut profile: ResMut<Profile>,
form: Res<UiForm>,
map: Res<StartMap>,
mut next: ResMut<NextState<AppState>>,
) {
let action = action.0.as_str();
if *state.get() == AppState::Connecting && action == "cancel" {
commands.queue(|world: &mut World| session::leave(world, None));
return;
}
if *state.get() != AppState::Menu {
return;
}
let (verb, arg) = action.split_once(':').unwrap_or((action, ""));
let result: Result<Option<SessionRequest>, String> = match verb {
"screen" => {
message.0.clear();
*screen = match arg {
"new_game" => MenuScreen::NewGame,
"load_game" => MenuScreen::LoadGame,
"connect" => MenuScreen::Connect,
_ => MenuScreen::Main,
};
Ok(None)
}
"quit" => {
commands.write_message(AppExit::Success);
Ok(None)
}
"new_game" => (|| {
let world = form.get("world").trim().to_owned();
if world.is_empty() {
return Err("Enter a name for the world.".to_owned());
}
if saves.saves.iter().any(|s| s.world.name == world) || saves::exists(&world) {
return Err(format!("A world called `{world}` already exists."));
}
Ok(Some(SessionRequest {
name: character_name(&form)?,
target: SessionTarget::Local {
storage: Some(saves::storage(&world)),
map: map.0.clone(),
host: host_options(&form)?,
},
password: form.get("password").to_owned(),
}))
})(),
"load" => (|| {
let save = arg
.parse::<usize>()
.ok()
.and_then(|i| saves.saves.get(i))
.ok_or("That save no longer exists.")?;
Ok(Some(SessionRequest {
name: character_name(&form)?,
target: SessionTarget::Local {
storage: Some(save.storage()),
map: save.world.map.clone(),
host: host_options(&form)?,
},
password: form.get("password").to_owned(),
}))
})(),
"delete" => (|| {
let i = arg.parse::<usize>().map_err(|e| e.to_string())?;
let save = saves
.saves
.get(i)
.cloned()
.ok_or("That save no longer exists.")?;
if saves.confirm_delete != Some(i) {
saves.confirm_delete = Some(i);
return Ok(None);
}
saves::delete(&save)?;
message.0 = format!("Deleted `{}`.", save.world.name);
saves.saves = saves::list();
saves.confirm_delete = None;
Ok(None)
})(),
"join" => (|| {
let address = form.get("address").trim().to_owned();
session::resolve(&address)?;
profile.last_address = address.clone();
Ok(Some(SessionRequest {
name: character_name(&form)?,
target: SessionTarget::Remote { address },
password: form.get("password").to_owned(),
}))
})(),
_ => Ok(None),
};
match result {
Ok(Some(request)) => {
message.0.clear();
profile.name = request.name.clone();
profile.save();
commands.insert_resource(request);
next.set(AppState::Connecting);
}
Ok(None) => {}
Err(e) => message.0 = e,
}
}

View File

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

View File

@ -0,0 +1,81 @@
//! The local player profile (`client.toml` in the platform's config directory):
//! a random player id that identifies this player's characters on every
//! server, plus the last used name and server address.
use std::path::PathBuf;
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Resource, Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Profile {
pub player_id: String,
pub name: String,
pub last_address: String,
}
impl Default for Profile {
fn default() -> Self {
Self {
player_id: uuid::Uuid::new_v4().to_string(),
name: std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.ok()
.filter(|n| game_server::players::valid_name(n))
.unwrap_or_else(|| "Player".to_owned()),
last_address: format!("127.0.0.1:{}", game_core::DEFAULT_PORT),
}
}
}
/// Directory for client settings, e.g. `~/.config/station` on Linux.
pub fn config_dir() -> PathBuf {
directories::ProjectDirs::from("", "", "station")
.map(|dirs| dirs.config_dir().to_owned())
.unwrap_or_else(|| PathBuf::from("."))
}
fn path() -> PathBuf {
config_dir().join("client.toml")
}
impl Profile {
/// Loads the profile, creating (and saving) a new one if there is none.
pub fn load() -> Self {
let path = path();
let profile = std::fs::read_to_string(&path).ok().and_then(|text| {
match toml::from_str::<Profile>(&text) {
Ok(profile) => Some(profile),
Err(e) => {
warn!("ignoring invalid {}: {e}", path.display());
None
}
}
});
match profile {
Some(mut profile) => {
if uuid::Uuid::parse_str(&profile.player_id).is_err() {
profile.player_id = Profile::default().player_id;
profile.save();
}
profile
}
None => {
let profile = Profile::default();
profile.save();
profile
}
}
}
pub fn save(&self) {
let path = path();
let result = std::fs::create_dir_all(config_dir()).and_then(|()| {
std::fs::write(&path, toml::to_string(self).expect("profile serializes"))
});
if let Err(e) = result {
warn!("can't save {}: {e}", path.display());
}
}
}

View File

@ -8,7 +8,7 @@ use game_core::{
map::MapLayout, map::MapLayout,
}; };
use crate::LocalPlayer; use crate::session::SessionInfo;
pub struct RenderPlugin; pub struct RenderPlugin;
@ -130,15 +130,19 @@ fn spawn_map(
fn find_local_character( fn find_local_character(
mut local: ResMut<LocalCharacter>, mut local: ResMut<LocalCharacter>,
player: Res<LocalPlayer>, session: Res<SessionInfo>,
characters: Query<(Entity, &Character)>, characters: Query<(Entity, &Character)>,
) { ) {
if local.0.is_some_and(|e| characters.contains(e)) { if local.0.is_some_and(|e| characters.contains(e)) {
return; return;
} }
if session.character.is_empty() {
local.0 = None;
return;
}
local.0 = characters local.0 = characters
.iter() .iter()
.find(|(_, c)| c.name == player.name) .find(|(_, c)| c.name == session.character)
.map(|(e, _)| e); .map(|(e, _)| e);
} }

View File

@ -1,9 +1,9 @@
//! Local save games: one SQLite database per save in the platform's data directory //! Local save games: one SQLite database per save in the platform's data directory
//! (e.g. `~/.local/share/station/saves` on Linux, `%APPDATA%\station\data\saves` on Windows). //! (e.g. `~/.local/share/station/saves` on Linux, `%APPDATA%\station\data\saves` on Windows).
use std::path::PathBuf; use std::path::{Path, PathBuf};
use game_server::persistence::{StorageSettings, WorldSelector, sqlite_url}; use game_server::persistence::{Database, StorageSettings, WorldInfo, WorldSelector, sqlite_url};
/// Directory holding the save databases. Falls back to `./saves` if the platform has no home. /// Directory holding the save databases. Falls back to `./saves` if the platform has no home.
pub fn saves_dir() -> PathBuf { pub fn saves_dir() -> PathBuf {
@ -39,6 +39,103 @@ pub fn storage(name: &str) -> StorageSettings {
} }
} }
/// A world stored in a save file.
#[derive(Clone, Debug)]
pub struct SaveEntry {
pub path: PathBuf,
pub world: WorldInfo,
}
impl SaveEntry {
pub fn storage(&self) -> StorageSettings {
StorageSettings {
database_url: sqlite_url(&self.path),
world: WorldSelector::Id(self.world.id),
}
}
}
/// All worlds in the saves directory, most recently saved first.
pub fn list() -> Vec<SaveEntry> {
let Ok(dir) = std::fs::read_dir(saves_dir()) else {
return Vec::new();
};
let files: Vec<PathBuf> = dir
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|e| e == "sqlite"))
.collect();
let runtime = game_server::persistence::runtime();
let mut saves = Vec::new();
for path in files {
let worlds = runtime.block_on(async {
let db = Database::connect(&sqlite_url(&path)).await?;
let worlds = db.list_worlds().await;
db.close().await;
worlds
});
match worlds {
Ok(worlds) => saves.extend(worlds.into_iter().map(|world| SaveEntry {
path: path.clone(),
world,
})),
Err(e) => bevy::log::warn!("skipping save {}: {e}", path.display()),
}
}
saves.sort_by_key(|s| std::cmp::Reverse(s.world.saved_at));
saves
}
/// Whether a save called `name` exists.
pub fn exists(name: &str) -> bool {
let path = saves_dir().join(format!("{}.sqlite", slug(name)));
path.exists()
&& list()
.iter()
.any(|s| s.path == path && s.world.name == name)
}
/// Deletes a world, and its file once no other world is left in it.
pub fn delete(save: &SaveEntry) -> Result<(), String> {
let runtime = game_server::persistence::runtime();
let remaining = runtime
.block_on(async {
let db = Database::connect(&sqlite_url(&save.path)).await?;
db.delete_world(save.world.id).await?;
let remaining = db.list_worlds().await?.len();
db.close().await;
Ok::<_, game_server::persistence::StorageError>(remaining)
})
.map_err(|e| e.to_string())?;
if remaining == 0 {
remove_database(&save.path).map_err(|e| e.to_string())?;
}
Ok(())
}
fn remove_database(path: &Path) -> std::io::Result<()> {
std::fs::remove_file(path)?;
for suffix in ["-wal", "-shm"] {
let mut extra = path.as_os_str().to_owned();
extra.push(suffix);
let _ = std::fs::remove_file(extra);
}
Ok(())
}
/// Human readable age of a unix timestamp.
pub fn age(timestamp: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let secs = (now - timestamp).max(0);
match secs {
0..60 => "just now".into(),
60..3600 => format!("{} min ago", secs / 60),
3600..86400 => format!("{} h ago", secs / 3600),
_ => format!("{} days ago", secs / 86400),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -0,0 +1,316 @@
//! Game sessions: starting the embedded server (singleplayer and hosting) or
//! connecting to a remote one, logging in, and leaving again.
use std::net::{IpAddr, Ipv4Addr, SocketAddr, ToSocketAddrs};
use bevy::prelude::*;
use bevy_replicon::{client::Remote, prelude::*};
use bevy_replicon_renet::{RenetClient, netcode::NetcodeClientTransport};
use game_core::protocol::{AdminStatus, Login, Rejected, Welcome};
use game_server::{
ServerSettings,
config::{ConfigFile, ServerConfig},
embedded::{EmbeddedServer, ServerStatus},
network::create_client,
persistence::StorageSettings,
};
use crate::{profile::Profile, ui::UiBindings};
pub struct SessionPlugin;
impl Plugin for SessionPlugin {
fn build(&self, app: &mut App) {
app.init_state::<AppState>()
.init_resource::<SessionInfo>()
.init_resource::<MenuMessage>()
.add_systems(OnEnter(AppState::Connecting), start)
.add_systems(OnEnter(ClientState::Connected), login)
.add_systems(
Update,
(poll_local_server, watch_connection).run_if(not(in_state(AppState::Menu))),
)
.add_systems(Last, stop_on_exit)
.add_observer(welcome)
.add_observer(rejected)
.add_observer(admin_status);
}
}
#[derive(States, Default, Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AppState {
#[default]
Menu,
Connecting,
InGame,
}
#[derive(Clone, Debug)]
pub enum SessionTarget {
/// Run the world in this process.
Local {
/// `None` plays without saving.
storage: Option<StorageSettings>,
map: String,
/// Let other players join on this port, with this password.
host: Option<(u16, String)>,
},
/// Join a server at `host[:port]`.
Remote { address: String },
}
/// What to start when entering [`AppState::Connecting`].
#[derive(Resource, Clone, Debug)]
pub struct SessionRequest {
pub target: SessionTarget,
pub name: String,
pub password: String,
}
#[derive(Resource)]
struct LocalServer(EmbeddedServer);
/// The current session, from the server's [`Welcome`].
#[derive(Resource, Default, Clone, Debug)]
pub struct SessionInfo {
pub server_name: String,
/// Name of our character (identifies it among the replicated characters).
pub character: String,
pub admin: bool,
}
/// Shown on the main menu, e.g. why the last session ended.
#[derive(Resource, Default, Debug)]
pub struct MenuMessage(pub String);
/// Triggered when a session ends so that modules can reset their state.
#[derive(Event, Clone, Copy, Debug)]
pub struct SessionEnded;
fn start(
mut commands: Commands,
request: Option<Res<SessionRequest>>,
profile: Res<Profile>,
channels: Res<RepliconChannels>,
mut bindings: ResMut<UiBindings>,
) {
let Some(request) = request else {
commands.queue(|world: &mut World| leave(world, None));
return;
};
match &request.target {
SessionTarget::Local { storage, map, host } => {
let settings = ServerSettings {
map: map.clone(),
storage: storage.clone(),
owner: Some(profile.player_id.clone()),
..default()
};
let (config, file, bind) = match host {
None => (
ServerConfig {
port: 0,
..default()
},
None,
IpAddr::V4(Ipv4Addr::LOCALHOST),
),
Some((port, password)) => {
// Access lists and other settings of hosted worlds persist here.
let path = crate::profile::config_dir().join("host.toml");
let (file, mut config) = match ConfigFile::load_or_create(&path) {
Ok((file, config, _)) => (Some(file), config),
Err(e) => {
warn!("{e}; hosting with default settings");
(None, ServerConfig::default())
}
};
config.port = *port;
config.password = password.clone();
(config, file, IpAddr::V4(Ipv4Addr::UNSPECIFIED))
}
};
bindings.set(
"status",
match host {
Some((port, _)) => format!("Starting the server on port {port}..."),
None => "Loading the world...".to_owned(),
},
);
commands.insert_resource(LocalServer(EmbeddedServer::start(
settings, config, file, bind,
)));
}
SessionTarget::Remote { address } => {
bindings.set("status", format!("Connecting to {address}..."));
if let Err(e) =
resolve(address).and_then(|addr| connect(&mut commands, &channels, addr))
{
commands.queue(move |world: &mut World| leave(world, Some(e)));
}
}
}
}
/// Resolves `host[:port]` (default port if missing).
pub fn resolve(address: &str) -> Result<SocketAddr, String> {
let address = address.trim();
if address.is_empty() {
return Err("Enter a server address.".into());
}
let has_port = address.parse::<SocketAddr>().is_ok()
|| address
.rsplit_once(':')
.is_some_and(|(host, port)| !host.contains(':') && port.parse::<u16>().is_ok());
let with_port = if has_port {
address.to_owned()
} else if address.contains(':') && !address.starts_with('[') {
// Bare IPv6 address.
format!("[{address}]:{}", game_core::DEFAULT_PORT)
} else {
format!("{address}:{}", game_core::DEFAULT_PORT)
};
with_port
.to_socket_addrs()
.ok()
.and_then(|mut addrs| addrs.next())
.ok_or_else(|| format!("Can't resolve `{address}`."))
}
fn connect(
commands: &mut Commands,
channels: &RepliconChannels,
addr: SocketAddr,
) -> Result<(), String> {
let (client, transport) =
create_client(channels, addr).map_err(|e| format!("Can't connect: {e}"))?;
info!("connecting to {addr}");
commands.insert_resource(client);
commands.insert_resource(transport);
Ok(())
}
fn poll_local_server(
mut commands: Commands,
server: Option<Res<LocalServer>>,
channels: Res<RepliconChannels>,
mut bindings: ResMut<UiBindings>,
) {
let Some(server) = server else { return };
while let Some(status) = server.0.poll() {
let message = match status {
ServerStatus::Started(addr) => {
bindings.set("status", "Joining...");
let local = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), addr.port());
match connect(&mut commands, &channels, local) {
Ok(()) => continue,
Err(e) => e,
}
}
ServerStatus::Failed(e) => format!("The server failed: {e}"),
ServerStatus::Stopped => "The server was stopped.".into(),
};
commands.queue(move |world: &mut World| leave(world, Some(message)));
}
}
fn watch_connection(
mut commands: Commands,
client: Option<Res<RenetClient>>,
transport: Option<Res<NetcodeClientTransport>>,
) {
let Some(client) = client else { return };
if !client.is_disconnected() {
return;
}
let reason = transport
.and_then(|t| t.disconnect_reason())
.map(|r| r.to_string())
.or_else(|| client.disconnect_reason().map(|r| r.to_string()))
.unwrap_or_else(|| "connection lost".into());
commands.queue(move |world: &mut World| leave(world, Some(format!("Disconnected: {reason}."))));
}
fn login(mut commands: Commands, request: Option<Res<SessionRequest>>, profile: Res<Profile>) {
let Some(request) = request else { return };
commands.client_trigger(Login {
player_id: profile.player_id.clone(),
name: request.name.clone(),
password: request.password.clone(),
});
}
fn welcome(
welcome: On<Welcome>,
mut info: ResMut<SessionInfo>,
mut state: ResMut<NextState<AppState>>,
) {
*info = SessionInfo {
server_name: welcome.server_name.clone(),
character: welcome.character.clone(),
admin: welcome.admin,
};
info!("joined `{}` as {}", welcome.server_name, welcome.character);
state.set(AppState::InGame);
}
fn rejected(rejected: On<Rejected>, mut commands: Commands) {
let reason = rejected.reason.clone();
commands.queue(move |world: &mut World| leave(world, Some(reason)));
}
fn admin_status(status: On<AdminStatus>, mut info: ResMut<SessionInfo>) {
info.admin = status.admin;
}
fn stop_on_exit(mut exit: MessageReader<AppExit>, mut commands: Commands) {
if exit.read().next().is_some() {
// Stops (and saves) a local world before the process ends.
commands.queue(|world: &mut World| leave(world, None));
}
}
/// Ends the session (disconnecting and stopping a local server, which saves the
/// world) and returns to the main menu showing `message`.
pub fn leave(world: &mut World, message: Option<String>) {
if let Some(mut transport) = world.remove_resource::<NetcodeClientTransport>() {
transport.disconnect();
}
world.remove_resource::<RenetClient>();
if let Some(server) = world.remove_resource::<LocalServer>() {
server.0.stop();
}
let remote: Vec<Entity> = world
.query_filtered::<Entity, With<Remote>>()
.iter(world)
.collect();
for entity in remote {
if let Ok(entity) = world.get_entity_mut(entity) {
entity.despawn();
}
}
world.insert_resource(SessionInfo::default());
if let Some(message) = message {
warn!("{message}");
world.resource_mut::<MenuMessage>().0 = message;
}
world.trigger(SessionEnded);
world
.resource_mut::<NextState<AppState>>()
.set(AppState::Menu);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn addresses() {
let port = game_core::DEFAULT_PORT;
assert_eq!(resolve("127.0.0.1").unwrap().port(), port);
assert_eq!(resolve("127.0.0.1:1234").unwrap().port(), 1234);
assert_eq!(resolve("::1").unwrap().port(), port);
assert_eq!(resolve("[::1]:99").unwrap().port(), 99);
assert!(resolve(" ").is_err());
}
}

View File

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

View File

@ -0,0 +1,352 @@
//! UI layout files (`assets/ui/*.rsn`) and the theme (`assets/ui/theme.rsn`).
//!
//! A layout is a tree of [`UiNode`]s. Colors are either `#rrggbb[aa]` or the name
//! of a theme color. Texts may contain `{binding}` placeholders that the game
//! fills in (see the README for the available bindings).
use std::{collections::HashMap, path::Path};
use bevy::prelude::*;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct LayoutFile {
pub root: UiNode,
}
#[derive(Clone, Debug, Deserialize)]
pub enum UiNode {
/// Children stacked vertically.
Column(Container),
/// Children side by side.
Row(Container),
/// A column with the theme's panel background, border and padding.
Panel(Container),
Text(TextNode),
Button(ButtonNode),
Input(InputNode),
Checkbox(CheckboxNode),
Image(ImageNode),
/// Takes up all remaining space in its container.
Spacer,
/// Content generated by the game, e.g. `Widget("inventory")`.
Widget(String),
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct Container {
pub gap: Option<f32>,
pub padding: Option<f32>,
/// Alignment of the children across the main axis.
pub align: Align,
/// Distribution of the children along the main axis.
pub justify: Justify,
pub width: Size,
pub height: Size,
/// Places the node relative to the screen instead of its parent.
pub anchor: Option<Anchor>,
/// Distance from the anchored edges, in pixels.
pub offset: (f32, f32),
pub background: Option<String>,
pub border: Option<String>,
pub radius: Option<f32>,
pub tooltip: Option<String>,
/// Only shown while this binding or form field is set (non-empty and not `false`).
/// Prefix with `!` to invert.
pub visible: Option<String>,
pub children: Vec<UiNode>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct TextNode {
pub text: String,
pub size: Option<f32>,
pub color: Option<String>,
pub tooltip: Option<String>,
pub visible: Option<String>,
/// Maximum width before wrapping, in pixels.
pub max_width: Option<f32>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct ButtonNode {
pub text: String,
/// Action triggered on click, e.g. `"screen:main"`.
pub action: String,
pub width: Size,
pub tooltip: Option<String>,
pub visible: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(default)]
pub struct InputNode {
/// Form field the text is stored in.
pub id: String,
/// Initial value (bindings allowed).
pub value: String,
pub placeholder: String,
/// Shows `*` instead of the text.
pub password: bool,
pub max_len: usize,
pub width: Size,
/// Action triggered by Enter.
pub submit: Option<String>,
pub tooltip: Option<String>,
pub visible: Option<String>,
}
impl Default for InputNode {
fn default() -> Self {
Self {
id: String::new(),
value: String::new(),
placeholder: String::new(),
password: false,
max_len: 64,
width: Size::Px(260.0),
submit: None,
tooltip: None,
visible: None,
}
}
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct CheckboxNode {
/// Form field set to `true` or `false`.
pub id: String,
pub text: String,
pub checked: bool,
pub tooltip: Option<String>,
pub visible: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct ImageNode {
/// PNG relative to `assets/`.
pub path: String,
pub width: Size,
pub height: Size,
pub tooltip: Option<String>,
pub visible: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize)]
pub enum Size {
#[default]
Auto,
Px(f32),
Percent(f32),
/// Grow to fill the available space.
Fill,
}
impl Size {
pub fn val(self) -> Val {
match self {
Size::Auto => Val::Auto,
Size::Px(px) => Val::Px(px),
Size::Percent(p) => Val::Percent(p),
Size::Fill => Val::Percent(100.0),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize)]
pub enum Align {
#[default]
Start,
Center,
End,
Stretch,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize)]
pub enum Justify {
#[default]
Start,
Center,
End,
SpaceBetween,
}
#[derive(Clone, Copy, Debug, PartialEq, Deserialize)]
pub enum Anchor {
TopLeft,
Top,
TopRight,
Left,
Center,
Right,
BottomLeft,
Bottom,
BottomRight,
}
impl Anchor {
/// (horizontal, vertical) placement as justify/align values of a full-screen wrapper.
pub fn placement(self) -> (JustifyContent, AlignItems) {
use Anchor::*;
let h = match self {
TopLeft | Left | BottomLeft => JustifyContent::FlexStart,
Top | Center | Bottom => JustifyContent::Center,
TopRight | Right | BottomRight => JustifyContent::FlexEnd,
};
let v = match self {
TopLeft | Top | TopRight => AlignItems::FlexStart,
Left | Center | Right => AlignItems::Center,
BottomLeft | Bottom | BottomRight => AlignItems::FlexEnd,
};
(h, v)
}
}
/// Colors and font sizes shared by all layouts.
#[derive(Resource, Clone, Debug, Deserialize)]
#[serde(default)]
pub struct Theme {
pub font_size: f32,
pub small_size: f32,
pub title_size: f32,
pub padding: f32,
pub gap: f32,
pub radius: f32,
pub colors: HashMap<String, String>,
}
impl Default for Theme {
fn default() -> Self {
Self {
font_size: 16.0,
small_size: 13.0,
title_size: 32.0,
padding: 12.0,
gap: 6.0,
radius: 4.0,
colors: HashMap::new(),
}
}
}
const FALLBACK_COLORS: &[(&str, &str)] = &[
("text", "#e6e6e6"),
("dim", "#8a8f98"),
("accent", "#ffcc44"),
("error", "#ff6b6b"),
("good", "#7bd88f"),
("panel", "#141820e6"),
("window", "#141820fa"),
("border", "#3a4252"),
("button", "#283040"),
("button_hover", "#36415a"),
("button_pressed", "#1c2230"),
("input", "#0c0f14"),
("slot", "#0c0f14cc"),
("tooltip", "#05070af0"),
("progress", "#ffcc44"),
];
impl Theme {
/// Resolves `#hex` colors and theme color names.
pub fn color(&self, name: &str) -> Color {
if let Some(hex) = name.strip_prefix('#') {
return parse_hex(hex).unwrap_or_else(|| {
warn!("invalid color `{name}`");
Color::srgb(1.0, 0.0, 1.0)
});
}
let value = self.colors.get(name).map(String::as_str).or_else(|| {
FALLBACK_COLORS
.iter()
.find(|(n, _)| *n == name)
.map(|(_, v)| *v)
});
match value {
Some(value) if value.starts_with('#') => self.color(value),
_ => {
warn!("unknown theme color `{name}`");
Color::srgb(1.0, 0.0, 1.0)
}
}
}
}
fn parse_hex(hex: &str) -> Option<Color> {
let byte = |i: usize| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok();
match hex.len() {
6 => Some(Color::srgb_u8(byte(0)?, byte(2)?, byte(4)?)),
8 => Some(Color::srgba_u8(byte(0)?, byte(2)?, byte(4)?, byte(6)?)),
_ => None,
}
}
fn parse<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
rsn::parser::Config::default()
.allow_implicit_map_at_root(true)
.deserialize(&text)
.map_err(|e| format!("{}: {e}", path.display()))
}
/// Directory of the UI files.
pub fn ui_dir() -> std::path::PathBuf {
game_core::assets_dir().join("ui")
}
/// Loads `assets/ui/<name>.rsn`. Files are read every time a screen opens, so
/// edits show up without restarting the game.
pub fn load_layout(name: &str) -> Result<UiNode, String> {
parse::<LayoutFile>(&ui_dir().join(format!("{name}.rsn"))).map(|f| f.root)
}
pub fn load_theme() -> Theme {
parse(&ui_dir().join("theme.rsn")).unwrap_or_else(|e| {
error!("can't load the UI theme: {e}");
Theme::default()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_ui_files_parse() {
let mut count = 0;
for entry in std::fs::read_dir(ui_dir()).unwrap() {
let path = entry.unwrap().path();
if path.extension().is_some_and(|e| e == "rsn") {
if path.file_stem().is_some_and(|s| s == "theme") {
let theme: Theme = parse(&path).unwrap();
for value in theme.colors.values() {
assert!(parse_hex(&value[1..]).is_some(), "{value}");
}
} else {
parse::<LayoutFile>(&path).unwrap();
}
count += 1;
}
}
assert!(count >= 2);
}
#[test]
fn colors() {
let theme = Theme::default();
assert_eq!(theme.color("#ff0000"), Color::srgb_u8(255, 0, 0));
assert_eq!(theme.color("#00000080"), Color::srgba_u8(0, 0, 0, 128));
assert_eq!(theme.color("text"), Color::srgb_u8(0xe6, 0xe6, 0xe6));
}
#[test]
fn sizes() {
assert_eq!(Size::Percent(50.0).val(), Val::Percent(50.0));
assert_eq!(Size::Fill.val(), Val::Percent(100.0));
}
}

View File

@ -0,0 +1,840 @@
//! A small data-driven UI framework on top of `bevy_ui`: layouts come from
//! rsn files (see [`layout`]), buttons trigger [`UiAction`]s, text can be bound
//! to [`UiBindings`], inputs and checkboxes write to [`UiForm`] and anything
//! with a [`Tooltip`] shows it on hover.
pub mod layout;
use std::{
collections::HashMap,
hash::{DefaultHasher, Hash, Hasher},
time::Duration,
};
use bevy::{
input::{
ButtonState,
keyboard::{Key, KeyboardInput},
},
prelude::*,
ui::FocusPolicy,
window::PrimaryWindow,
};
use layout::{Align, Container, Justify, Size, Theme, UiNode};
pub struct UiFrameworkPlugin;
impl Plugin for UiFrameworkPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(layout::load_theme())
.init_resource::<UiBindings>()
.init_resource::<UiForm>()
.init_resource::<UiFocus>()
.init_resource::<TooltipState>()
.add_systems(Startup, spawn_tooltip)
.add_systems(
Update,
(
text_editing.in_set(TextEditSystems),
(
clicks,
button_visuals,
checkbox_visuals,
input_visuals,
bound_text,
bound_tooltips,
visibility,
tooltips,
)
.after(TextEditSystems),
),
);
}
}
/// Keyboard handling of text inputs. Game input runs after it and checks
/// [`UiFocus::captures_keyboard`].
#[derive(SystemSet, Clone, Debug, PartialEq, Eq, Hash)]
pub struct TextEditSystems;
/// Triggered when a button is clicked or Enter is pressed in an input with a `submit` action.
#[derive(Event, Clone, Debug)]
pub struct UiAction(pub String);
/// Values that layout texts can show as `{name}`.
#[derive(Resource, Default, Debug)]
pub struct UiBindings(pub HashMap<String, String>);
impl UiBindings {
pub fn set(&mut self, key: &str, value: impl Into<String>) {
let value = value.into();
if self.0.get(key) != Some(&value) {
self.0.insert(key.to_owned(), value);
}
}
pub fn flag(&mut self, key: &str, value: bool) {
self.set(key, if value { "true" } else { "" });
}
/// Replaces `{name}` placeholders.
pub fn resolve(&self, template: &str) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(start) = rest.find('{') {
out.push_str(&rest[..start]);
match rest[start..].find('}') {
Some(len) => {
let key = &rest[start + 1..start + len];
out.push_str(self.0.get(key).map(String::as_str).unwrap_or_default());
rest = &rest[start + len + 1..];
}
None => {
out.push_str(&rest[start..]);
rest = "";
}
}
}
out.push_str(rest);
out
}
}
/// Values of inputs (text) and checkboxes (`true`/`false`) by field id.
#[derive(Resource, Default, Debug)]
pub struct UiForm(pub HashMap<String, String>);
impl UiForm {
pub fn get(&self, id: &str) -> &str {
self.0.get(id).map(String::as_str).unwrap_or_default()
}
pub fn checked(&self, id: &str) -> bool {
self.get(id) == "true"
}
}
/// The text input receiving keyboard input.
#[derive(Resource, Default, Debug)]
pub struct UiFocus {
pub input: Option<Entity>,
/// Keys were consumed by an input this frame (e.g. the Enter that submitted it).
consumed: bool,
}
impl UiFocus {
/// Game controls must ignore the keyboard while this is true.
pub fn captures_keyboard(&self) -> bool {
self.input.is_some() || self.consumed
}
}
/// Hover text.
#[derive(Component, Clone, Debug, PartialEq)]
#[require(Interaction)]
pub struct Tooltip(pub String);
/// Layout tooltip with `{name}` placeholders, kept up to date in [`Tooltip`].
#[derive(Component, Clone, Debug)]
struct BoundTooltip(String);
/// Triggers a [`UiAction`] when clicked.
#[derive(Component, Clone, Debug)]
#[require(Button)]
pub struct OnClick(pub String);
/// Gives a button the theme's hover/press colors.
#[derive(Component)]
pub struct ThemedButton;
#[derive(Component)]
struct BoundText(String);
#[derive(Component)]
struct VisibleWhen(String);
#[derive(Component)]
pub struct TextInput {
pub id: String,
placeholder: String,
password: bool,
max_len: usize,
submit: Option<String>,
}
#[derive(Component)]
struct Checkbox {
id: String,
mark: Entity,
}
/// Placeholder for game-generated content (`Widget("name")` in layouts).
#[derive(Component)]
pub struct WidgetSlot {
pub name: String,
/// Hash of the content last built, to rebuild only on changes.
pub content: u64,
}
impl WidgetSlot {
/// Returns true (and remembers the hash) if `content` differs from the last build.
pub fn changed(&mut self, content: &impl Hash) -> bool {
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
let hash = hasher.finish();
let changed = hash != self.content;
self.content = hash;
changed
}
}
/// Everything needed to spawn layouts.
pub struct UiContext<'a> {
pub theme: &'a Theme,
pub bindings: &'a UiBindings,
pub form: &'a mut UiForm,
pub assets: &'a AssetServer,
}
impl UiContext<'_> {
pub fn text(&self, text: impl Into<String>, size: f32, color: &str) -> impl Bundle {
(
Text::new(text),
TextFont::from_font_size(size),
TextColor(self.theme.color(color)),
)
}
/// A themed button that triggers `action`.
pub fn button(&self, text: &str, action: &str) -> impl Bundle {
let theme = self.theme;
(
OnClick(action.to_owned()),
ThemedButton,
Node {
padding: UiRect::axes(Val::Px(14.0), Val::Px(7.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(theme.radius)),
..default()
},
BackgroundColor(theme.color("button")),
BorderColor::all(theme.color("border")),
children![self.text(text, theme.font_size, "text")],
)
}
}
/// Spawns `assets/ui/<name>.rsn` as a full-screen node. On errors, the error is
/// shown instead so that layout mistakes are easy to spot.
pub fn spawn_screen(commands: &mut Commands, ctx: &mut UiContext, name: &str) -> Entity {
let root = commands
.spawn(Node {
position_type: PositionType::Absolute,
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
})
.id();
match layout::load_layout(name) {
Ok(node) => spawn_node(commands, ctx, root, &node),
Err(e) => {
error!("UI layout: {e}");
commands.spawn((ChildOf(root), ctx.text(e, ctx.theme.font_size, "error")));
}
}
root
}
fn optional<T: Component>(entity: &mut EntityCommands, component: Option<T>) {
if let Some(component) = component {
entity.insert(component);
}
}
fn common(entity: &mut EntityCommands, tooltip: &Option<String>, visible: &Option<String>) {
if let Some(tooltip) = tooltip {
entity.insert((Tooltip(String::new()), BoundTooltip(tooltip.clone())));
}
optional(entity, visible.clone().map(VisibleWhen));
}
fn size_node(width: Size, height: Size) -> Node {
Node {
width: width.val(),
height: height.val(),
flex_grow: if width == Size::Fill || height == Size::Fill {
1.0
} else {
0.0
},
..default()
}
}
/// Spawns `node` (and its children) as a child of `parent`.
pub fn spawn_node(commands: &mut Commands, ctx: &mut UiContext, parent: Entity, node: &UiNode) {
let theme = ctx.theme;
match node {
UiNode::Column(c) => {
spawn_container(commands, ctx, parent, c, FlexDirection::Column, false)
}
UiNode::Row(c) => spawn_container(commands, ctx, parent, c, FlexDirection::Row, false),
UiNode::Panel(c) => spawn_container(commands, ctx, parent, c, FlexDirection::Column, true),
UiNode::Text(t) => {
let template = t.text.clone();
let mut entity = commands.spawn((
ChildOf(parent),
ctx.text(
ctx.bindings.resolve(&template),
t.size.unwrap_or(theme.font_size),
t.color.as_deref().unwrap_or("text"),
),
Node {
max_width: t.max_width.map_or(Val::Auto, Val::Px),
..default()
},
));
if template.contains('{') {
entity.insert(BoundText(template));
}
common(&mut entity, &t.tooltip, &t.visible);
}
UiNode::Button(b) => {
let mut entity = commands.spawn((ChildOf(parent), ctx.button(&b.text, &b.action)));
if b.width != Size::Auto {
let width = b.width.val();
entity
.entry::<Node>()
.and_modify(move |mut n| n.width = width);
}
common(&mut entity, &b.tooltip, &b.visible);
}
UiNode::Input(i) => {
let value = ctx.bindings.resolve(&i.value);
ctx.form.0.insert(i.id.clone(), value);
let mut entity = commands.spawn((
ChildOf(parent),
TextInput {
id: i.id.clone(),
placeholder: i.placeholder.clone(),
password: i.password,
max_len: i.max_len,
submit: i.submit.clone(),
},
Button,
Node {
width: i.width.val(),
padding: UiRect::axes(Val::Px(8.0), Val::Px(6.0)),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(theme.radius)),
overflow: Overflow::clip(),
..default()
},
BackgroundColor(theme.color("input")),
BorderColor::all(theme.color("border")),
children![ctx.text("", theme.font_size, "text")],
));
common(&mut entity, &i.tooltip, &i.visible);
}
UiNode::Checkbox(c) => {
ctx.form.0.insert(c.id.clone(), c.checked.to_string());
let entity = commands
.spawn((
ChildOf(parent),
Button,
Node {
column_gap: Val::Px(8.0),
align_items: AlignItems::Center,
..default()
},
))
.id();
let mark = commands
.spawn((
Node {
width: Val::Px(10.0),
height: Val::Px(10.0),
..default()
},
BackgroundColor(theme.color("accent")),
))
.id();
commands
.spawn((
ChildOf(entity),
Node {
width: Val::Px(18.0),
height: Val::Px(18.0),
border: UiRect::all(Val::Px(1.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(theme.radius)),
..default()
},
BackgroundColor(theme.color("input")),
BorderColor::all(theme.color("border")),
))
.add_child(mark);
commands.spawn((ChildOf(entity), ctx.text(&c.text, theme.font_size, "text")));
let mut entity = commands.entity(entity);
entity.insert(Checkbox {
id: c.id.clone(),
mark,
});
common(&mut entity, &c.tooltip, &c.visible);
}
UiNode::Image(i) => {
let mut entity = commands.spawn((
ChildOf(parent),
bevy::ui::widget::ImageNode::new(ctx.assets.load(i.path.clone())),
size_node(i.width, i.height),
));
common(&mut entity, &i.tooltip, &i.visible);
}
UiNode::Spacer => {
commands.spawn((
ChildOf(parent),
Node {
flex_grow: 1.0,
..default()
},
));
}
UiNode::Widget(name) => {
commands.spawn((
ChildOf(parent),
WidgetSlot {
name: name.clone(),
content: 0,
},
Node {
flex_direction: FlexDirection::Column,
align_self: AlignSelf::Stretch,
row_gap: Val::Px(theme.gap / 2.0),
..default()
},
));
}
}
}
fn spawn_container(
commands: &mut Commands,
ctx: &mut UiContext,
parent: Entity,
c: &Container,
direction: FlexDirection,
panel: bool,
) {
let theme = ctx.theme;
// Anchored nodes live in a full-screen wrapper that places them. Without an
// `Interaction`, the wrapper does not block the pointer.
let parent = match c.anchor {
Some(anchor) => {
let (justify, align) = anchor.placement();
let (x, y) = c.offset;
commands
.spawn((
ChildOf(parent),
Node {
position_type: PositionType::Absolute,
width: Val::Percent(100.0),
height: Val::Percent(100.0),
justify_content: justify,
align_items: align,
padding: UiRect::axes(Val::Px(x), Val::Px(y)),
..default()
},
))
.id()
}
None => parent,
};
let gap = Val::Px(c.gap.unwrap_or(if panel { theme.gap } else { 0.0 }));
let padding = c.padding.unwrap_or(if panel { theme.padding } else { 0.0 });
let background = c.background.as_deref().or(panel.then_some("panel"));
let border = c.border.as_deref().or(panel.then_some("border"));
let mut node = size_node(c.width, c.height);
node.flex_direction = direction;
node.row_gap = gap;
node.column_gap = gap;
node.padding = UiRect::all(Val::Px(padding));
node.align_items = match c.align {
Align::Start => AlignItems::FlexStart,
Align::Center => AlignItems::Center,
Align::End => AlignItems::FlexEnd,
Align::Stretch => AlignItems::Stretch,
};
node.justify_content = match c.justify {
Justify::Start => JustifyContent::FlexStart,
Justify::Center => JustifyContent::Center,
Justify::End => JustifyContent::FlexEnd,
Justify::SpaceBetween => JustifyContent::SpaceBetween,
};
node.border_radius = BorderRadius::all(Val::Px(c.radius.unwrap_or(if panel {
theme.radius
} else {
0.0
})));
if border.is_some() {
node.border = UiRect::all(Val::Px(1.0));
}
let mut entity = commands.spawn((ChildOf(parent), node));
if let Some(background) = background {
// Backgrounds block clicks from reaching the game world.
entity.insert((
BackgroundColor(theme.color(background)),
Interaction::default(),
FocusPolicy::Block,
));
}
optional(
&mut entity,
border.map(|b| BorderColor::all(theme.color(b))),
);
common(&mut entity, &c.tooltip, &c.visible);
let id = entity.id();
for child in &c.children {
spawn_node(commands, ctx, id, child);
}
}
/// True while the pointer is over a UI element that blocks the world.
pub fn pointer_over_ui(interactions: &Query<&Interaction>) -> bool {
interactions.iter().any(|i| *i != Interaction::None)
}
fn clicks(
mut commands: Commands,
buttons: Res<ButtonInput<MouseButton>>,
clicked: Query<(Entity, &Interaction, Option<&OnClick>), Changed<Interaction>>,
inputs: Query<(), With<TextInput>>,
mut checkboxes: Query<&Checkbox>,
mut form: ResMut<UiForm>,
mut focus: ResMut<UiFocus>,
) {
let mut focused_input = false;
for (entity, interaction, on_click) in &clicked {
if *interaction != Interaction::Pressed {
continue;
}
if let Some(on_click) = on_click {
commands.trigger(UiAction(on_click.0.clone()));
}
if inputs.contains(entity) {
focus.input = Some(entity);
focused_input = true;
}
if let Ok(checkbox) = checkboxes.get_mut(entity) {
let value = !form.checked(&checkbox.id);
form.0.insert(checkbox.id.clone(), value.to_string());
}
}
if !focused_input && buttons.just_pressed(MouseButton::Left) {
focus.input = None;
}
}
fn button_visuals(
theme: Res<Theme>,
mut buttons: Query<
(&Interaction, &mut BackgroundColor),
(Changed<Interaction>, With<ThemedButton>),
>,
) {
for (interaction, mut background) in &mut buttons {
background.0 = theme.color(match interaction {
Interaction::Pressed => "button_pressed",
Interaction::Hovered => "button_hover",
Interaction::None => "button",
});
}
}
fn checkbox_visuals(
form: Res<UiForm>,
checkboxes: Query<&Checkbox>,
mut marks: Query<&mut Visibility>,
) {
if !form.is_changed() {
return;
}
for checkbox in &checkboxes {
if let Ok(mut visibility) = marks.get_mut(checkbox.mark) {
visibility.set_if_neq(if form.checked(&checkbox.id) {
Visibility::Inherited
} else {
Visibility::Hidden
});
}
}
}
fn text_editing(
mut commands: Commands,
mut keys: MessageReader<KeyboardInput>,
mut focus: ResMut<UiFocus>,
mut form: ResMut<UiForm>,
inputs: Query<(Entity, &TextInput, &InheritedVisibility, &ComputedNode)>,
) {
focus.consumed = false;
let Some(entity) = focus.input else {
keys.clear();
return;
};
let Ok((_, input, ..)) = inputs.get(entity) else {
focus.input = None;
keys.clear();
return;
};
for key in keys.read() {
if key.state != ButtonState::Pressed {
continue;
}
focus.consumed = true;
let value = form.0.entry(input.id.clone()).or_default();
match &key.logical_key {
Key::Enter => {
if let Some(action) = &input.submit {
commands.trigger(UiAction(action.clone()));
}
focus.input = None;
break;
}
Key::Escape => {
focus.input = None;
break;
}
Key::Backspace => {
value.pop();
}
Key::Tab => {
// Next visible input in spawn order, wrapping around.
let mut visible: Vec<Entity> = inputs
.iter()
.filter(|(_, _, v, node)| v.get() && node.size() != Vec2::ZERO)
.map(|(e, ..)| e)
.collect();
visible.sort();
if let Some(i) = visible.iter().position(|e| *e == entity) {
focus.input = Some(visible[(i + 1) % visible.len()]);
}
break;
}
_ => {
if let Some(text) = &key.text {
for c in text.chars().filter(|c| !c.is_control()) {
if value.chars().count() < input.max_len {
value.push(c);
}
}
}
}
}
}
}
fn input_visuals(
theme: Res<Theme>,
time: Res<Time>,
form: Res<UiForm>,
focus: Res<UiFocus>,
mut inputs: Query<(Entity, &TextInput, &Children, &mut BorderColor)>,
mut texts: Query<(&mut Text, &mut TextColor)>,
) {
let caret = ((time.elapsed_secs() * 2.0) as u32).is_multiple_of(2);
for (entity, input, children, mut border) in &mut inputs {
let focused = focus.input == Some(entity);
let value = form.get(&input.id);
let (mut shown, color) = if value.is_empty() && !focused {
(input.placeholder.clone(), "dim")
} else if input.password {
("*".repeat(value.chars().count()), "text")
} else {
(value.to_owned(), "text")
};
if focused {
shown.push(if caret { '|' } else { ' ' });
}
let border_color = BorderColor::all(theme.color(if focused { "accent" } else { "border" }));
if *border != border_color {
*border = border_color;
}
if let Some(Ok((mut text, mut text_color))) = children.first().map(|c| texts.get_mut(*c)) {
if text.0 != shown {
text.0 = shown;
}
text_color.set_if_neq(TextColor(theme.color(color)));
}
}
}
fn bound_text(bindings: Res<UiBindings>, mut texts: Query<(Ref<BoundText>, &mut Text)>) {
for (template, mut text) in &mut texts {
if bindings.is_changed() || template.is_added() {
let value = bindings.resolve(&template.0);
if text.0 != value {
text.0 = value;
}
}
}
}
fn bound_tooltips(
bindings: Res<UiBindings>,
mut tooltips: Query<(Ref<BoundTooltip>, &mut Tooltip)>,
) {
for (template, mut tooltip) in &mut tooltips {
if bindings.is_changed() || template.is_added() {
let value = bindings.resolve(&template.0);
if tooltip.0 != value {
tooltip.0 = value;
}
}
}
}
/// Whether a binding or form field named `key` is set.
pub fn is_set(bindings: &UiBindings, form: &UiForm, key: &str) -> bool {
let (key, invert) = match key.strip_prefix('!') {
Some(key) => (key, true),
None => (key, false),
};
let value = bindings
.0
.get(key)
.or_else(|| form.0.get(key))
.map(String::as_str)
.unwrap_or_default();
(!value.is_empty() && value != "false") != invert
}
fn visibility(
bindings: Res<UiBindings>,
form: Res<UiForm>,
mut nodes: Query<(Ref<VisibleWhen>, &mut Node)>,
) {
for (when, mut node) in &mut nodes {
if bindings.is_changed() || form.is_changed() || when.is_added() {
let display = if is_set(&bindings, &form, &when.0) {
Display::Flex
} else {
Display::None
};
if node.display != display {
node.display = display;
}
}
}
}
#[derive(Component)]
struct TooltipBox;
#[derive(Resource, Default)]
struct TooltipState {
hovered: Option<Entity>,
since: Duration,
}
const TOOLTIP_DELAY: Duration = Duration::from_millis(350);
fn spawn_tooltip(mut commands: Commands, theme: Res<Theme>) {
commands.spawn((
TooltipBox,
Node {
position_type: PositionType::Absolute,
display: Display::None,
padding: UiRect::axes(Val::Px(8.0), Val::Px(5.0)),
max_width: Val::Px(360.0),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(theme.radius)),
..default()
},
BackgroundColor(theme.color("tooltip")),
BorderColor::all(theme.color("border")),
GlobalZIndex(1000),
Text::default(),
TextFont::from_font_size(theme.small_size),
TextColor(theme.color("text")),
));
}
fn tooltips(
time: Res<Time>,
mut state: ResMut<TooltipState>,
window: Query<&Window, With<PrimaryWindow>>,
hovered: Query<(
Entity,
&Interaction,
&Tooltip,
&ComputedNode,
&InheritedVisibility,
)>,
mut tooltip: Query<(&mut Node, &mut Text, &ComputedNode), With<TooltipBox>>,
) {
let Ok((mut node, mut text, computed)) = tooltip.single_mut() else {
return;
};
// The most specific (smallest) hovered element wins over the panels around it.
let current = hovered
.iter()
.filter(|(_, i, t, _, v)| **i != Interaction::None && !t.0.is_empty() && v.get())
.min_by(|a, b| {
let area = |n: &ComputedNode| n.size().x * n.size().y;
area(a.3).total_cmp(&area(b.3))
});
let now = time.elapsed();
let entity = current.as_ref().map(|c| c.0);
if entity != state.hovered {
state.hovered = entity;
state.since = now;
}
let cursor = window
.single()
.ok()
.and_then(|w| Some((w.cursor_position()?, w.size())));
match (current, cursor) {
(Some((_, _, tip, ..)), Some((cursor, window))) if now - state.since >= TOOLTIP_DELAY => {
if text.0 != tip.0 {
text.0 = tip.0.clone();
}
let size = computed.size() * computed.inverse_scale_factor();
let x = (cursor.x + 16.0).min(window.x - size.x - 4.0).max(0.0);
let y = if cursor.y + 20.0 + size.y > window.y {
cursor.y - size.y - 8.0
} else {
cursor.y + 20.0
};
node.left = Val::Px(x);
node.top = Val::Px(y.max(0.0));
node.display = Display::Flex;
}
_ => {
if node.display != Display::None {
node.display = Display::None;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bindings() {
let mut b = UiBindings::default();
b.set("name", "Ann");
assert_eq!(b.resolve("Hi {name}! {missing}|{open"), "Hi Ann! |{open");
let mut form = UiForm::default();
form.0.insert("host".into(), "true".into());
assert!(is_set(&b, &form, "host"));
assert!(is_set(&b, &form, "name"));
assert!(!is_set(&b, &form, "!name"));
assert!(is_set(&b, &form, "!missing"));
}
}

View File

@ -73,7 +73,7 @@ impl Vitals {
} }
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ItemStack { pub struct ItemStack {
pub item: String, pub item: String,
pub count: u32, pub count: u32,

View File

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

View File

@ -12,7 +12,13 @@ use crate::{components::*, grid::Dir};
/// First event a client sends; selects (or creates) the persistent character. /// First event a client sends; selects (or creates) the persistent character.
#[derive(Event, Clone, Debug, Serialize, Deserialize)] #[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct Login { pub struct Login {
/// UUID identifying the player across sessions. Each player id owns one
/// character per world.
pub player_id: String,
/// Name for a new character; ignored when the player already has one.
pub name: String, pub name: String,
/// Server password, empty if none.
pub password: String,
} }
/// Direction the player is holding, `None` to stop. /// Direction the player is holding, `None` to stop.
@ -75,12 +81,40 @@ pub struct MachineTake {
pub count: u32, pub count: u32,
} }
/// A chat message, or a command if it starts with `/`.
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct Chat {
pub text: String,
}
/// Text shown in the receiving player's message log. /// Text shown in the receiving player's message log.
#[derive(Event, Clone, Debug, Serialize, Deserialize)] #[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct GameLog { pub struct GameLog {
pub text: String, pub text: String,
} }
/// Login accepted.
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct Welcome {
pub server_name: String,
pub motd: String,
/// Name of the player's character.
pub character: String,
pub admin: bool,
}
/// The server is about to close the connection (login rejected, kicked, banned).
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct Rejected {
pub reason: String,
}
/// The player's admin rights changed.
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct AdminStatus {
pub admin: bool,
}
pub struct ProtocolPlugin; pub struct ProtocolPlugin;
impl Plugin for ProtocolPlugin { impl Plugin for ProtocolPlugin {
@ -107,6 +141,10 @@ impl Plugin for ProtocolPlugin {
.add_client_event::<Deconstruct>(Channel::Ordered) .add_client_event::<Deconstruct>(Channel::Ordered)
.add_client_event::<MachineInsert>(Channel::Ordered) .add_client_event::<MachineInsert>(Channel::Ordered)
.add_client_event::<MachineTake>(Channel::Ordered) .add_client_event::<MachineTake>(Channel::Ordered)
.add_server_event::<GameLog>(Channel::Ordered); .add_client_event::<Chat>(Channel::Ordered)
.add_server_event::<GameLog>(Channel::Ordered)
.add_server_event::<Welcome>(Channel::Ordered)
.add_server_event::<Rejected>(Channel::Ordered)
.add_server_event::<AdminStatus>(Channel::Ordered);
} }
} }

View File

@ -1,56 +1,170 @@
//! Server commands, typed into the server console. //! Server commands, typed into the server console or sent by players as
//! `/command` chat messages.
use bevy::prelude::*; use bevy::prelude::*;
use bevy_replicon::prelude::*; use bevy_replicon::prelude::*;
use game_core::{components::Character, protocol::GameLog}; use game_core::{components::Character, protocol::GameLog};
use crate::{config, persistence, players::Players}; use crate::{
config, persistence,
players::{self, Controller, PlayerId, Players, RejectedLogins},
};
/// Who issued a command. /// Who issued a command.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommandSource { pub enum CommandSource {
/// The dedicated server's terminal. /// The dedicated server's terminal.
Console, Console,
Player {
client: ClientId,
admin: bool,
/// Host of the embedded server; may also stop it.
owner: bool,
},
} }
pub const HELP: &[(&str, &str)] = &[ impl CommandSource {
("help", "list commands"), fn is_admin(self) -> bool {
("players", "list online players"), match self {
("say MESSAGE", "message all players"), Self::Console => true,
("save", "save the world now"), Self::Player { admin, owner, .. } => admin || owner,
}
}
fn may_stop(self) -> bool {
match self {
Self::Console => true,
Self::Player { owner, .. } => owner,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Permission {
Everyone,
Admin,
Stop,
}
/// Usage, description and who may run it.
const COMMANDS: &[(&str, &str, Permission)] = &[
("help", "list commands", Permission::Everyone),
("players", "list online players", Permission::Everyone),
("say MESSAGE", "message all players", Permission::Admin),
(
"kick PLAYER [REASON]",
"disconnect a player",
Permission::Admin,
),
(
"ban PLAYER",
"ban a player by name or id",
Permission::Admin,
),
("unban PLAYER", "lift a ban", Permission::Admin),
("bans", "list banned players", Permission::Admin),
(
"whitelist on|off|list",
"toggle or show the whitelist",
Permission::Admin,
),
(
"whitelist add|remove PLAYER",
"edit the whitelist",
Permission::Admin,
),
(
"admin add|remove PLAYER",
"grant or revoke admin rights",
Permission::Admin,
),
("admin list", "list admins", Permission::Admin),
("rejected", "recently rejected logins", Permission::Admin),
("save", "save the world now", Permission::Admin),
( (
"config get KEY", "config get KEY",
"show a setting, e.g. `config get access.whitelist`", "show a setting, e.g. `config get access.whitelist`",
Permission::Admin,
), ),
( (
"config set KEY VALUE", "config set KEY VALUE",
"change a setting and write it to the config file", "change a setting and write it to the config file",
Permission::Admin,
), ),
("stop", "save and shut down the server"), ("stop", "save and shut down the server", Permission::Stop),
]; ];
/// Usage and description of the commands available to `source`.
pub fn help(source: CommandSource) -> Vec<(&'static str, &'static str)> {
COMMANDS
.iter()
.filter(|(_, _, p)| allowed(source, *p))
.map(|(usage, what, _)| (*usage, *what))
.collect()
}
fn allowed(source: CommandSource, permission: Permission) -> bool {
match permission {
Permission::Everyone => true,
Permission::Admin => source.is_admin(),
Permission::Stop => source.may_stop(),
}
}
fn permission(command: &str) -> Option<Permission> {
COMMANDS
.iter()
.find(|(usage, _, _)| usage.split(' ').next() == Some(command))
.map(|(_, _, p)| *p)
}
/// Runs a command line and returns the reply. /// Runs a command line and returns the reply.
pub fn execute(world: &mut World, _source: CommandSource, line: &str) -> Vec<String> { pub fn execute(world: &mut World, source: CommandSource, line: &str) -> Vec<String> {
let line = line.trim().trim_start_matches('/'); let line = line.trim().trim_start_matches('/');
let (command, rest) = line.split_once(' ').unwrap_or((line, "")); let (command, rest) = line.split_once(' ').unwrap_or((line, ""));
let rest = rest.trim(); let rest = rest.trim();
if command.is_empty() {
return vec![];
}
match permission(command) {
None => {
return vec![format!(
"Unknown command `{command}`. Type `help` for a list."
)];
}
Some(p) if !allowed(source, p) => {
return vec![format!("You are not allowed to use `{command}`.")];
}
Some(_) => {}
}
let actor = actor_name(world, source);
match command { match command {
"" => vec![], "help" => help(source)
"help" => HELP .into_iter()
.iter() .map(|(usage, what)| format!("{usage:<28} {what}"))
.map(|(usage, what)| format!("{usage:<22} {what}"))
.collect(), .collect(),
"players" => { "players" => {
let names: Vec<String> = world let mut q = world.query::<(&Character, &PlayerId, Has<players::Admin>)>();
.query::<&Character>() let mut names: Vec<String> = q
.iter(world) .iter(world)
.map(|c| c.name.clone()) .map(|(c, id, admin)| {
let mut entry = c.name.clone();
if admin {
entry.push_str(" [admin]");
}
if source.is_admin() {
entry.push_str(&format!(" ({})", id.0));
}
entry
})
.collect(); .collect();
let count = world.resource::<Players>().by_client.len(); names.sort();
vec![format!("{count} online: {}", names.join(", "))] let mut reply = vec![format!("{} online", names.len())];
reply.extend(names);
reply
} }
"say" if !rest.is_empty() => { "say" if !rest.is_empty() => {
let text = format!("[server] {rest}"); let text = format!("[{actor}] {rest}");
world.commands().server_trigger(ToClients { world.commands().server_trigger(ToClients {
targets: SendTargets::All, targets: SendTargets::All,
message: GameLog { text: text.clone() }, message: GameLog { text: text.clone() },
@ -58,6 +172,35 @@ pub fn execute(world: &mut World, _source: CommandSource, line: &str) -> Vec<Str
world.flush(); world.flush();
vec![text] vec![text]
} }
"kick" if !rest.is_empty() => {
let (target, reason) = rest.split_once(' ').unwrap_or((rest, ""));
let reason = reason.trim();
kick(world, &actor, target, reason)
}
"ban" if !rest.is_empty() => {
let target = resolve(world, rest);
match list_edit(world, "access.banned", &target.entry, true) {
Ok(true) => {
let msg = format!("{actor} banned {}.", target.label);
notify(world, &msg);
vec![msg]
}
Ok(false) => vec![format!("{} is already banned.", target.label)],
Err(e) => vec![e],
}
}
"unban" if !rest.is_empty() => unlist(world, &actor, "access.banned", rest, "banned"),
"bans" => show_list(world, |c| &c.access.banned, "Banned"),
"whitelist" => whitelist_command(world, &actor, rest),
"admin" => admin_command(world, &actor, rest),
"rejected" => {
let log = &world.resource::<RejectedLogins>().0;
if log.is_empty() {
vec!["No rejected logins.".into()]
} else {
log.iter().cloned().collect()
}
}
"save" => { "save" => {
if world.contains_resource::<persistence::Storage>() { if world.contains_resource::<persistence::Storage>() {
persistence::save_world_blocking(world); persistence::save_world_blocking(world);
@ -68,12 +211,236 @@ pub fn execute(world: &mut World, _source: CommandSource, line: &str) -> Vec<Str
} }
"config" => config_command(world, rest), "config" => config_command(world, rest),
"stop" => { "stop" => {
info!("{actor} stopped the server");
world.write_message(AppExit::Success); world.write_message(AppExit::Success);
vec!["Shutting down.".into()] vec!["Shutting down.".into()]
} }
_ => vec![format!( _ => {
"Unknown command `{command}`. Type `help` for a list." let usage: Vec<String> = COMMANDS
)], .iter()
.filter(|(usage, _, _)| usage.split(' ').next() == Some(command))
.map(|(usage, _, _)| format!("Usage: {usage}"))
.collect();
usage
}
}
}
fn actor_name(world: &mut World, source: CommandSource) -> String {
match source {
CommandSource::Console => "server".into(),
CommandSource::Player { client, .. } => world
.resource::<Players>()
.by_client
.get(&client)
.and_then(|&e| world.get::<Character>(e))
.map_or_else(|| "server".into(), |c| c.name.clone()),
}
}
/// A player named in a command.
struct Target {
/// Value stored in access lists: the player id if known, otherwise the given name.
entry: String,
/// For messages.
label: String,
}
/// Resolves a character name or player id.
fn resolve(world: &mut World, input: &str) -> Target {
let input = input.trim();
if uuid::Uuid::parse_str(input).is_ok() {
let id = input.to_lowercase();
let label = match players::character_name(world, &id) {
Some(name) => format!("{name} ({id})"),
None => id.clone(),
};
return Target { entry: id, label };
}
match players::find_player_by_name(world, input) {
Some(id) => {
let name = players::character_name(world, &id).unwrap_or_else(|| input.to_owned());
Target {
label: format!("{name} ({id})"),
entry: id,
}
}
// Unknown players are listed by name, so they can be banned or
// whitelisted before they ever join.
None => Target {
entry: input.to_owned(),
label: input.to_owned(),
},
}
}
/// Messages online admins and the server log.
fn notify(world: &mut World, text: &str) {
info!("{text}");
players::tell_admins(world, &format!("[admin] {text}"));
}
fn kick(world: &mut World, actor: &str, target: &str, reason: &str) -> Vec<String> {
let target = resolve(world, target);
let mut q = world.query::<(&PlayerId, &Controller)>();
let Some(client) = q
.iter(world)
.find(|(id, _)| id.0 == target.entry)
.map(|(_, c)| c.0)
else {
return vec![format!("{} is not online.", target.label)];
};
let text = if reason.is_empty() {
"You were kicked by an admin.".to_owned()
} else {
format!("You were kicked: {reason}")
};
players::disconnect(&mut world.commands(), client, text);
world.flush();
let msg = format!("{actor} kicked {}.", target.label);
notify(world, &msg);
vec![msg]
}
/// Adds (`add`) or removes a value from a string list setting. Returns whether it changed.
fn list_edit(world: &mut World, key: &str, value: &str, add: bool) -> Result<bool, String> {
let config = world.resource::<config::ServerConfig>();
let mut list = match key {
"access.banned" => config.access.banned.clone(),
"access.whitelist" => config.access.whitelist.clone(),
"access.admins" => config.access.admins.clone(),
_ => unreachable!("not a list setting: {key}"),
};
let before = list.len();
if add {
if list.iter().any(|e| e.eq_ignore_ascii_case(value)) {
return Ok(false);
}
list.push(value.to_owned());
} else {
list.retain(|e| !e.eq_ignore_ascii_case(value));
if list.len() == before {
return Ok(false);
}
}
let toml = toml_edit::Value::from_iter(list.iter().map(String::as_str)).to_string();
config::set_runtime(world, key, toml.trim())
.map(|_| true)
.map_err(|e| e.to_string())
}
/// Removes a player from a list by id or by name.
fn unlist(world: &mut World, actor: &str, key: &str, input: &str, what: &str) -> Vec<String> {
let target = resolve(world, input);
let name = players::character_name(world, &target.entry);
let mut changed = false;
for value in std::iter::once(target.entry.as_str())
.chain(name.as_deref())
.chain(std::iter::once(input.trim()))
{
match list_edit(world, key, value, false) {
Ok(c) => changed |= c,
Err(e) => return vec![e],
}
}
if changed {
let msg = format!("{actor}: {} is no longer {what}.", target.label);
notify(world, &msg);
vec![msg]
} else {
vec![format!("{} is not {what}.", target.label)]
}
}
fn show_list(
world: &mut World,
list: impl Fn(&config::ServerConfig) -> &Vec<String>,
title: &str,
) -> Vec<String> {
let entries = list(world.resource::<config::ServerConfig>()).clone();
if entries.is_empty() {
return vec![format!("{title}: none")];
}
let mut reply = vec![format!("{title}:")];
for entry in entries {
match players::character_name(world, &entry) {
Some(name) => reply.push(format!(" {name} ({entry})")),
None => reply.push(format!(" {entry}")),
}
}
reply
}
fn whitelist_command(world: &mut World, actor: &str, args: &str) -> Vec<String> {
let (action, target) = args.split_once(' ').unwrap_or((args, ""));
let target = target.trim();
match (action, target.is_empty()) {
("on" | "off", true) => {
let on = action == "on";
match config::set_runtime(world, "access.whitelist_enabled", &on.to_string()) {
Ok(_) => {
let msg = format!(
"{actor} turned the whitelist {}.",
if on { "on" } else { "off" }
);
notify(world, &msg);
vec![msg]
}
Err(e) => vec![e.to_string()],
}
}
("list", true) => {
let enabled = world
.resource::<config::ServerConfig>()
.access
.whitelist_enabled;
let title = format!("Whitelist ({})", if enabled { "on" } else { "off" });
show_list(world, |c| &c.access.whitelist, &title)
}
("add", false) => {
let target = resolve(world, target);
match list_edit(world, "access.whitelist", &target.entry, true) {
Ok(true) => {
let msg = format!("{actor} whitelisted {}.", target.label);
notify(world, &msg);
vec![msg]
}
Ok(false) => vec![format!("{} is already whitelisted.", target.label)],
Err(e) => vec![e],
}
}
("remove", false) => unlist(world, actor, "access.whitelist", target, "whitelisted"),
_ => vec!["Usage: whitelist on|off|list | whitelist add|remove PLAYER".into()],
}
}
fn admin_command(world: &mut World, actor: &str, args: &str) -> Vec<String> {
let (action, target) = args.split_once(' ').unwrap_or((args, ""));
let target = target.trim();
match (action, target.is_empty()) {
("list", true) => show_list(world, |c| &c.access.admins, "Admins"),
("add", false) => {
let target = resolve(world, target);
// Admin rights are bound to player ids, since names can be taken
// by anyone before the character exists.
if uuid::Uuid::parse_str(&target.entry).is_err() {
return vec![format!(
"Unknown player `{}`. Use a character name of this world or a player id.",
target.label
)];
}
match list_edit(world, "access.admins", &target.entry, true) {
Ok(true) => {
let msg = format!("{actor} made {} an admin.", target.label);
notify(world, &msg);
vec![msg]
}
Ok(false) => vec![format!("{} is already an admin.", target.label)],
Err(e) => vec![e],
}
}
("remove", false) => unlist(world, actor, "access.admins", target, "an admin"),
_ => vec!["Usage: admin list | admin add|remove PLAYER".into()],
} }
} }

View File

@ -151,6 +151,7 @@ impl ServerConfig {
world: WorldSelector::parse(&self.database.world), world: WorldSelector::parse(&self.database.world),
}), }),
autosave_interval: Duration::from_secs(self.autosave_secs.max(1)), autosave_interval: Duration::from_secs(self.autosave_secs.max(1)),
owner: None,
} }
} }

View File

@ -0,0 +1,179 @@
//! A complete server running on a background thread, used by the client for
//! singleplayer (loopback only) and hosting. The client always talks to it over
//! the network, so singleplayer behaves exactly like multiplayer.
use std::{
net::{IpAddr, SocketAddr},
panic::{AssertUnwindSafe, catch_unwind},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc::{self, Receiver, Sender},
},
thread::JoinHandle,
time::{Duration, Instant},
};
use bevy::{prelude::*, state::app::StatesPlugin};
use bevy_replicon::prelude::*;
use bevy_replicon_renet::{RenetServer, RepliconRenetPlugins, netcode::NetcodeServerTransport};
use game_core::CorePlugin;
use crate::{
GameServerPlugin, NetworkServerPlugin, ServerError, ServerSettings,
config::{ConfigFile, ServerConfig},
network::BoundAddr,
};
const TICK: Duration = Duration::from_micros(16_667);
/// Lifecycle notifications from an [`EmbeddedServer`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ServerStatus {
/// Accepting connections on this address.
Started(SocketAddr),
/// Could not start or crashed.
Failed(String),
/// Shut down (after saving).
Stopped,
}
#[derive(Debug)]
pub struct EmbeddedServer {
stop: Arc<AtomicBool>,
// Mutex only to make the server usable as a Bevy resource (`Sync`).
status: Mutex<Receiver<ServerStatus>>,
thread: Option<JoinHandle<()>>,
}
impl EmbeddedServer {
/// Starts a server listening on `bind` and the port in `config` (0 = any free port).
pub fn start(
settings: ServerSettings,
config: ServerConfig,
config_file: Option<ConfigFile>,
bind: IpAddr,
) -> Self {
let stop = Arc::new(AtomicBool::new(false));
let (tx, status) = mpsc::channel();
let thread_stop = stop.clone();
let thread = std::thread::Builder::new()
.name("server".into())
.spawn(move || {
let result = catch_unwind(AssertUnwindSafe(|| {
run(settings, config, config_file, bind, &thread_stop, &tx)
}));
let status = match result {
Ok(Ok(())) => ServerStatus::Stopped,
Ok(Err(e)) => ServerStatus::Failed(e),
Err(panic) => ServerStatus::Failed(panic_message(&*panic)),
};
let _ = tx.send(status);
})
.expect("spawning the server thread");
Self {
stop,
status: Mutex::new(status),
thread: Some(thread),
}
}
/// Next status change, if any.
pub fn poll(&self) -> Option<ServerStatus> {
self.status.lock().ok()?.try_recv().ok()
}
/// Asks the server to save and shut down without waiting.
pub fn request_stop(&self) {
self.stop.store(true, Ordering::Relaxed);
}
/// Saves, shuts down and waits for the thread to finish.
pub fn stop(mut self) {
self.join();
}
fn join(&mut self) {
self.request_stop();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
impl Drop for EmbeddedServer {
fn drop(&mut self) {
self.join();
}
}
fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
panic
.downcast_ref::<String>()
.cloned()
.or_else(|| panic.downcast_ref::<&str>().map(|s| (*s).to_owned()))
.unwrap_or_else(|| "the server crashed".into())
}
fn run(
settings: ServerSettings,
config: ServerConfig,
config_file: Option<ConfigFile>,
bind: IpAddr,
stop: &AtomicBool,
status: &Sender<ServerStatus>,
) -> Result<(), String> {
let mut app = App::new();
// No LogPlugin: the client's logger is process-wide and already set up.
app.add_plugins((
MinimalPlugins,
StatesPlugin,
RepliconPlugins,
RepliconRenetPlugins,
CorePlugin,
GameServerPlugin { settings },
NetworkServerPlugin { config, bind },
));
if let Some(file) = config_file {
app.insert_resource(file);
}
app.finish();
app.cleanup();
let mut started = false;
loop {
let frame = Instant::now();
if stop.load(Ordering::Relaxed) {
info!("stopping the embedded server");
app.world_mut().write_message(AppExit::Success);
}
app.update();
if let Some(error) = app.world().get_resource::<ServerError>() {
return Err(error.0.clone());
}
if !started && let Some(addr) = app.world().get_resource::<BoundAddr>() {
started = true;
let _ = status.send(ServerStatus::Started(addr.0));
}
if let Some(exit) = app.should_exit() {
disconnect_all(app.world_mut());
return match exit {
AppExit::Success => Ok(()),
AppExit::Error(code) => Err(format!("the server exited with code {code}")),
};
}
std::thread::sleep(TICK.saturating_sub(frame.elapsed()));
}
}
/// Tells connected clients that the server is gone instead of letting them time out.
fn disconnect_all(world: &mut World) {
if !world.contains_resource::<NetcodeServerTransport>() {
return;
}
world.resource_scope(|world, mut transport: Mut<NetcodeServerTransport>| {
if let Some(mut server) = world.get_resource_mut::<RenetServer>() {
transport.disconnect_all(&mut server);
}
});
}

View File

@ -9,6 +9,7 @@ pub mod commands;
pub mod config; pub mod config;
pub mod console; pub mod console;
pub mod crafting; pub mod crafting;
pub mod embedded;
pub mod machines; pub mod machines;
pub mod movement; pub mod movement;
pub mod network; pub mod network;
@ -33,6 +34,9 @@ pub struct ServerSettings {
/// Database and world to persist to. `None` disables saving. /// Database and world to persist to. `None` disables saving.
pub storage: Option<persistence::StorageSettings>, pub storage: Option<persistence::StorageSettings>,
pub autosave_interval: Duration, pub autosave_interval: Duration,
/// Player id that always has admin rights and bypasses access restrictions
/// (the host of an embedded server).
pub owner: Option<String>,
} }
impl Default for ServerSettings { impl Default for ServerSettings {
@ -41,6 +45,7 @@ impl Default for ServerSettings {
map: game_core::DEFAULT_MAP.to_owned(), map: game_core::DEFAULT_MAP.to_owned(),
storage: None, storage: None,
autosave_interval: Duration::from_secs(60), autosave_interval: Duration::from_secs(60),
owner: None,
} }
} }
} }
@ -60,20 +65,36 @@ impl Plugin for GameServerPlugin {
.init_resource::<config::ServerConfig>() .init_resource::<config::ServerConfig>()
.init_resource::<players::Players>() .init_resource::<players::Players>()
.init_resource::<players::CharacterStore>() .init_resource::<players::CharacterStore>()
.init_resource::<players::RejectedLogins>()
.init_resource::<survival::SurvivalTimer>() .init_resource::<survival::SurvivalTimer>()
.add_systems(Startup, (load_scripts, world::setup_world).chain()) .add_systems(
Startup,
(
load_scripts,
world::setup_world.run_if(not(resource_exists::<ServerError>)),
)
.chain(),
)
.add_systems( .add_systems(
FixedUpdate, FixedUpdate,
( (
movement::move_characters, movement::move_characters,
survival::tick_survival, survival::tick_survival,
machines::tick_machines, machines::tick_machines,
)
.run_if(resource_exists::<world::WorldLayout>),
)
.add_systems(
Update,
(
persistence::autosave,
players::enforce_access.run_if(resource_changed::<config::ServerConfig>),
), ),
) )
.add_systems(Update, persistence::autosave)
.add_systems(Last, persistence::save_on_exit) .add_systems(Last, persistence::save_on_exit)
.add_observer(players::on_login) .add_observer(players::on_login)
.add_observer(players::on_disconnect) .add_observer(players::on_disconnect)
.add_observer(players::on_chat)
.add_observer(movement::on_set_move_dir) .add_observer(movement::on_set_move_dir)
.add_observer(actions::on_pick_up) .add_observer(actions::on_pick_up)
.add_observer(actions::on_drop_item) .add_observer(actions::on_drop_item)
@ -87,9 +108,23 @@ impl Plugin for GameServerPlugin {
} }
} }
/// Why the server failed to start. Inserted together with an error [`AppExit`].
#[derive(Resource, Clone, Debug)]
pub struct ServerError(pub String);
/// Reports a fatal startup error and requests exit.
pub fn fail(commands: &mut Commands, message: String) {
error!("{message}");
commands.insert_resource(ServerError(message));
commands.write_message(AppExit::error());
}
fn load_scripts(mut commands: Commands, data: Res<GameData>) { fn load_scripts(mut commands: Commands, data: Res<GameData>) {
let dir = game_core::asset_path("scripts"); let dir = game_core::asset_path("scripts");
let engine = scripting::ScriptEngine::load(&dir, &data).unwrap_or_else(|e| panic!("{e}")); let engine = match scripting::ScriptEngine::load(&dir, &data) {
Ok(engine) => engine,
Err(e) => return fail(&mut commands, e.to_string()),
};
info!("compiled scripts from `{}`", dir.display()); info!("compiled scripts from `{}`", dir.display());
commands.insert_resource(engine); commands.insert_resource(engine);
commands.insert_resource(actions::SharedData(Arc::new(data.clone()))); commands.insert_resource(actions::SharedData(Arc::new(data.clone())));

View File

@ -136,7 +136,7 @@ fn main() -> AppExit {
GameServerPlugin { GameServerPlugin {
settings: config.settings(), settings: config.settings(),
}, },
NetworkServerPlugin { config }, NetworkServerPlugin::public(config),
ConsolePlugin, ConsolePlugin,
)); ));
if created { if created {

View File

@ -1,15 +1,18 @@
//! Starting the renet network server (host and dedicated modes). //! Starting the renet network server (host and dedicated modes).
use std::{ use std::{
net::{Ipv4Addr, SocketAddr, UdpSocket}, net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket},
time::SystemTime, time::SystemTime,
}; };
use bevy::prelude::*; use bevy::prelude::*;
use bevy_replicon::prelude::*; use bevy_replicon::prelude::*;
use bevy_replicon_renet::{ use bevy_replicon_renet::{
RenetChannelsExt, RenetServer, RenetChannelsExt, RenetClient, RenetServer,
netcode::{self, NetcodeServerTransport, ServerAuthentication}, netcode::{
self, ClientAuthentication, NetcodeClientTransport, NetcodeServerTransport,
ServerAuthentication,
},
renet::ConnectionConfig, renet::ConnectionConfig,
}; };
@ -23,11 +26,32 @@ const EXTRA_SLOTS: usize = 4;
/// `config` the server's [`ServerConfig`] resource. /// `config` the server's [`ServerConfig`] resource.
pub struct NetworkServerPlugin { pub struct NetworkServerPlugin {
pub config: ServerConfig, pub config: ServerConfig,
/// Address to listen on: unspecified for everyone, loopback for local-only.
/// The port comes from the config; port 0 picks a free one.
pub bind: IpAddr,
} }
impl NetworkServerPlugin {
/// Listens on all interfaces.
pub fn public(config: ServerConfig) -> Self {
Self {
config,
bind: Ipv4Addr::UNSPECIFIED.into(),
}
}
}
/// Address the server listens on.
#[derive(Resource, Clone, Copy, Debug)]
pub struct BoundAddr(pub SocketAddr);
#[derive(Resource)]
struct BindIp(IpAddr);
impl Plugin for NetworkServerPlugin { impl Plugin for NetworkServerPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.insert_resource(self.config.clone()) app.insert_resource(self.config.clone())
.insert_resource(BindIp(self.bind))
.add_systems(Startup, start_server); .add_systems(Startup, start_server);
} }
} }
@ -36,41 +60,70 @@ fn start_server(
mut commands: Commands, mut commands: Commands,
channels: Res<RepliconChannels>, channels: Res<RepliconChannels>,
config: Res<ServerConfig>, config: Res<ServerConfig>,
bind: Res<BindIp>,
) { ) {
let port = config.port; let addr = SocketAddr::new(bind.0, config.port);
match create_server(&channels, port, config.max_players + EXTRA_SLOTS) { match create_server(&channels, addr, config.max_players + EXTRA_SLOTS) {
Ok((server, transport)) => { Ok((server, transport, bound)) => {
info!("listening on UDP port {port}"); info!("listening on UDP {bound}");
commands.insert_resource(server); commands.insert_resource(server);
commands.insert_resource(transport); commands.insert_resource(transport);
commands.insert_resource(BoundAddr(bound));
} }
Err(e) => { Err(e) => crate::fail(&mut commands, format!("failed to listen on {addr}: {e}")),
error!("failed to start server on port {port}: {e}");
commands.write_message(AppExit::error());
}
} }
} }
pub fn create_server( pub fn create_server(
channels: &RepliconChannels, channels: &RepliconChannels,
port: u16, addr: SocketAddr,
max_clients: usize, max_clients: usize,
) -> Result<(RenetServer, NetcodeServerTransport), Box<dyn std::error::Error>> { ) -> Result<(RenetServer, NetcodeServerTransport, SocketAddr), Box<dyn std::error::Error>> {
let server = RenetServer::new(ConnectionConfig { let server = RenetServer::new(ConnectionConfig {
server_channels_config: channels.server_configs(), server_channels_config: channels.server_configs(),
client_channels_config: channels.client_configs(), client_channels_config: channels.client_configs(),
..Default::default() ..Default::default()
}); });
let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?; let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port))?; let socket = UdpSocket::bind(addr)?;
let bound = socket.local_addr()?;
let server_config = netcode::ServerConfig { let server_config = netcode::ServerConfig {
current_time, current_time,
max_clients, max_clients,
protocol_id: game_core::PROTOCOL_ID, protocol_id: game_core::PROTOCOL_ID,
authentication: ServerAuthentication::Unsecure, authentication: ServerAuthentication::Unsecure,
// Not used with unsecure authentication, but must be valid. // Not used with unsecure authentication, but must be valid.
public_addresses: vec![SocketAddr::from((Ipv4Addr::LOCALHOST, port))], public_addresses: vec![bound],
}; };
let transport = NetcodeServerTransport::new(server_config, socket)?; let transport = NetcodeServerTransport::new(server_config, socket)?;
Ok((server, transport)) Ok((server, transport, bound))
}
/// Creates a client connecting to `addr`; insert both as resources.
pub fn create_client(
channels: &RepliconChannels,
addr: SocketAddr,
) -> Result<(RenetClient, NetcodeClientTransport), 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 local: IpAddr = match (addr.ip().is_loopback(), addr.is_ipv6()) {
(true, false) => Ipv4Addr::LOCALHOST.into(),
(false, false) => Ipv4Addr::UNSPECIFIED.into(),
(true, true) => std::net::Ipv6Addr::LOCALHOST.into(),
(false, true) => std::net::Ipv6Addr::UNSPECIFIED.into(),
};
let socket = UdpSocket::bind((local, 0))?;
let authentication = ClientAuthentication::Unsecure {
// Netcode rejects a second connection with the same id.
client_id: uuid::Uuid::new_v4().as_u64_pair().0,
protocol_id: game_core::PROTOCOL_ID,
server_addr: addr,
user_data: None,
};
let transport = NetcodeClientTransport::new(current_time, authentication, socket)?;
Ok((client, transport))
} }

View File

@ -22,7 +22,7 @@ use tokio::{runtime::Runtime, task::JoinHandle};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
players::CharacterStore, players::{CharacterStore, PlayerId},
world::{spawn_building_with_state, spawn_ground_item}, world::{spawn_building_with_state, spawn_ground_item},
}; };
@ -625,10 +625,18 @@ pub fn restore(commands: &mut Commands, data: &GameData, save: SaveGame) {
/// Collects the full world state. /// Collects the full world state.
pub fn snapshot(world: &mut World) -> SaveGame { pub fn snapshot(world: &mut World) -> SaveGame {
let mut characters = world.resource::<CharacterStore>().0.clone(); let mut characters = world.resource::<CharacterStore>().0.clone();
let mut q = world.query::<(&Character, &GridPos, &Facing, &Vitals, &Inventory, &Skills)>(); let mut q = world.query::<(
for (c, pos, facing, vitals, inventory, skills) in q.iter(world) { &Character,
&PlayerId,
&GridPos,
&Facing,
&Vitals,
&Inventory,
&Skills,
)>();
for (c, id, pos, facing, vitals, inventory, skills) in q.iter(world) {
characters.insert( characters.insert(
c.name.clone(), id.0.clone(),
SavedCharacter { SavedCharacter {
name: c.name.clone(), name: c.name.clone(),
pos: *pos, pos: *pos,

View File

@ -1,16 +1,19 @@
//! Login/logout and the mapping between network clients and characters. //! Login/logout and the mapping between network clients and characters.
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap, VecDeque};
use bevy::prelude::*; use bevy::prelude::*;
use bevy_replicon::prelude::*; use bevy_replicon::prelude::*;
use game_core::{ use game_core::{
GameData, GameData,
components::*, components::*,
protocol::{GameLog, Login}, protocol::{AdminStatus, Chat, GameLog, Login, Rejected, Welcome},
}; };
use crate::{ use crate::{
ServerSettings,
commands::{self, CommandSource},
config::ServerConfig,
movement::MoveIntent, movement::MoveIntent,
persistence::SavedCharacter, persistence::SavedCharacter,
world::{SpawnPoint, WorldLayout, find_free_tile}, world::{SpawnPoint, WorldLayout, find_free_tile},
@ -20,13 +23,21 @@ use crate::{
#[derive(Component, Clone, Copy, Debug)] #[derive(Component, Clone, Copy, Debug)]
pub struct Controller(pub ClientId); pub struct Controller(pub ClientId);
/// The player owning a character (a UUID sent by the client). Server-only.
#[derive(Component, Clone, Debug, PartialEq, Eq)]
pub struct PlayerId(pub String);
/// Marks characters of players with admin rights. Server-only.
#[derive(Component, Clone, Copy, Debug)]
pub struct Admin;
/// Online characters by client. /// Online characters by client.
#[derive(Resource, Default)] #[derive(Resource, Default)]
pub struct Players { pub struct Players {
pub by_client: HashMap<ClientId, Entity>, pub by_client: HashMap<ClientId, Entity>,
} }
/// Characters that are currently offline, by name. Persisted with the world. /// Characters that are currently offline, by player id. Persisted with the world.
#[derive(Resource, Default)] #[derive(Resource, Default)]
pub struct CharacterStore(pub BTreeMap<String, SavedCharacter>); pub struct CharacterStore(pub BTreeMap<String, SavedCharacter>);
@ -48,52 +59,207 @@ pub fn valid_name(name: &str) -> bool {
.all(|c| c.is_alphanumeric() || c == ' ' || c == '_' || c == '-') .all(|c| c.is_alphanumeric() || c == ' ' || c == '_' || c == '-')
} }
#[allow(clippy::too_many_arguments)] /// Everything [`check_access`] needs to know about a login attempt.
pub fn on_login( #[derive(Clone, Copy, Debug)]
login: On<FromClient<Login>>, pub struct AccessRequest<'a> {
mut commands: Commands, pub player_id: &'a str,
mut players: ResMut<Players>, /// Character name (the stored one for returning players).
mut store: ResMut<CharacterStore>, pub name: &'a str,
mut disconnects: MessageWriter<DisconnectRequest>, pub password: &'a str,
data: Res<GameData>, /// Host of an embedded server or a local client; bypasses all restrictions.
spawn: Res<SpawnPoint>, pub owner: bool,
layout: Res<WorldLayout>, pub online: usize,
characters: Query<&Character>, pub id_online: bool,
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>, pub new_character: bool,
) { /// Another player's character already uses this name.
let client = login.client_id; pub name_taken: bool,
let name = login.message.name.clone();
if players.by_client.contains_key(&client) {
return;
} }
let reject = |commands: &mut Commands,
disconnects: &mut MessageWriter<DisconnectRequest>, /// Whether a player may join. Pure so the rules can be tested in isolation.
reason: &str| { pub fn check_access(config: &ServerConfig, req: &AccessRequest) -> Result<(), String> {
tell(commands, client, reason); if uuid::Uuid::parse_str(req.player_id).is_err() {
return Err("Invalid player id.".into());
}
let admin = req.owner || is_listed(&config.access.admins, req.player_id, req.name);
if !req.owner {
if !config.password.is_empty() && req.password != config.password {
return Err(if req.password.is_empty() {
"This server requires a password.".into()
} else {
"Wrong password.".into()
});
}
if is_listed(&config.access.banned, req.player_id, req.name) {
return Err("You are banned from this server.".into());
}
if config.access.whitelist_enabled
&& !admin
&& !is_listed(&config.access.whitelist, req.player_id, req.name)
{
return Err("You are not on the whitelist.".into());
}
if req.online >= config.max_players && !admin {
return Err("The server is full.".into());
}
}
if req.id_online {
return Err("You are already logged in.".into());
}
if req.new_character {
if !valid_name(req.name) {
return Err(format!(
"Invalid character name. Use 1-{MAX_NAME_LEN} letters, digits, spaces, `_` or `-`."
));
}
if req.name_taken {
return Err("That name belongs to another player.".into());
}
}
Ok(())
}
/// Whether a ban/whitelist/admin entry matches a player id or (case-insensitively) a name.
pub fn is_listed(list: &[String], player_id: &str, name: &str) -> bool {
list.iter()
.any(|e| e == player_id || (!name.is_empty() && e.eq_ignore_ascii_case(name)))
}
/// Whether a player has admin rights.
pub fn is_admin(
world_owner: Option<&str>,
config: &ServerConfig,
client: ClientId,
id: &str,
) -> bool {
client == ClientId::Server
|| world_owner == Some(id)
|| config.access.admins.iter().any(|a| a == id)
}
/// Recent rejected logins, for admins.
#[derive(Resource, Default)]
pub struct RejectedLogins(pub VecDeque<String>);
const REJECTED_HISTORY: usize = 20;
/// Tells a client why, then disconnects it.
pub fn disconnect(commands: &mut Commands, client: ClientId, reason: impl Into<String>) {
commands.server_trigger(ToClients {
targets: SendTargets::Single(client),
message: Rejected {
reason: reason.into(),
},
});
if let ClientId::Client(entity) = client { if let ClientId::Client(entity) = client {
disconnects.write(DisconnectRequest { client: entity }); commands.write_message(DisconnectRequest { client: entity });
} }
}; }
if !valid_name(&name) {
reject(&mut commands, &mut disconnects, "Invalid character name."); /// Sends a message to every online admin.
pub fn tell_admins(world: &mut World, text: &str) {
let mut q = world.query_filtered::<&Controller, With<Admin>>();
let admins: Vec<ClientId> = q.iter(world).map(|c| c.0).collect();
let mut commands = world.commands();
for client in admins {
tell(&mut commands, client, text);
}
world.flush();
}
/// Name of the character a player id owns in this world, online or stored.
pub fn character_name(world: &mut World, player_id: &str) -> Option<String> {
let mut q = world.query::<(&Character, &PlayerId)>();
if let Some((c, _)) = q.iter(world).find(|(_, id)| id.0 == player_id) {
return Some(c.name.clone());
}
world
.resource::<CharacterStore>()
.0
.get(player_id)
.map(|c| c.name.clone())
}
/// Player id owning a character name (case-insensitive), online or stored.
pub fn find_player_by_name(world: &mut World, name: &str) -> Option<String> {
let mut q = world.query::<(&Character, &PlayerId)>();
if let Some((_, id)) = q
.iter(world)
.find(|(c, _)| c.name.eq_ignore_ascii_case(name))
{
return Some(id.0.clone());
}
world
.resource::<CharacterStore>()
.0
.iter()
.find(|(_, c)| c.name.eq_ignore_ascii_case(name))
.map(|(id, _)| id.clone())
}
pub fn on_login(login: On<FromClient<Login>>, mut commands: Commands) {
let client = login.client_id;
let login = login.message.clone();
commands.queue(move |world: &mut World| handle_login(world, client, login));
}
fn handle_login(world: &mut World, client: ClientId, login: Login) {
if world.resource::<Players>().by_client.contains_key(&client) {
return; return;
} }
if characters.iter().any(|c| c.name == name) { let player_id = login.player_id.trim().to_lowercase();
reject( let stored_name = character_name(world, &player_id);
&mut commands, let new_character = stored_name.is_none();
&mut disconnects, let name = stored_name.unwrap_or_else(|| login.name.trim().to_owned());
"That character is already online.", let name_taken =
); new_character && find_player_by_name(world, &name).is_some_and(|owner| owner != player_id);
let owner = world.resource::<ServerSettings>().owner.as_deref() == Some(player_id.as_str())
|| client == ClientId::Server;
let id_online = world
.query::<&PlayerId>()
.iter(world)
.any(|id| id.0 == player_id);
let request = AccessRequest {
player_id: &player_id,
name: &name,
password: &login.password,
owner,
online: world.resource::<Players>().by_client.len(),
id_online,
new_character,
name_taken,
};
if let Err(reason) = check_access(world.resource::<ServerConfig>(), &request) {
let entry = format!("{name} ({player_id}): {reason}");
warn!("rejected login of {entry}");
tell_admins(world, &format!("[admin] Rejected {entry}"));
let mut log = world.resource_mut::<RejectedLogins>();
if log.0.len() >= REJECTED_HISTORY {
log.0.pop_front();
}
log.0.push_back(entry);
disconnect(&mut world.commands(), client, reason);
world.flush();
return; return;
} }
let (saved, returning) = match store.0.remove(&name) { let saved = match world.resource_mut::<CharacterStore>().0.remove(&player_id) {
Some(saved) => (saved, true), Some(saved) => saved,
None => (new_character(&data, &name, spawn.0), false), None => {
let spawn = world.resource::<SpawnPoint>().0;
new_character_data(world.resource::<GameData>(), &name, spawn)
}
}; };
let pos = find_free_tile(saved.pos, &layout.0, &buildings); let mut buildings =
let entity = commands world.query_filtered::<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>();
.spawn(( let pos = find_free_tile(
saved.pos,
&world.resource::<WorldLayout>().0,
&buildings.query(world),
);
let config = world.resource::<ServerConfig>().clone();
let owner_id = world.resource::<ServerSettings>().owner.clone();
let admin = is_admin(owner_id.as_deref(), &config, client, &player_id);
let mut entity = world.spawn((
Replicated, Replicated,
Character { name: name.clone() }, Character { name: name.clone() },
pos, pos,
@ -102,20 +268,155 @@ pub fn on_login(
saved.inventory, saved.inventory,
saved.skills, saved.skills,
Controller(client), Controller(client),
PlayerId(player_id.clone()),
MoveIntent::default(), MoveIntent::default(),
)) ));
.id(); if admin {
players.by_client.insert(client, entity); entity.insert(Admin);
info!("{name} logged in ({client:?})"); }
let greeting = if returning { let entity = entity.id();
format!("Welcome back, {name}.") world
} else { .resource_mut::<Players>()
.by_client
.insert(client, entity);
info!(
"{name} logged in (player id {player_id}, {client:?}{})",
if admin { ", admin" } else { "" }
);
let greeting = if new_character {
format!("Welcome to the station, {name}. Press H for help.") format!("Welcome to the station, {name}. Press H for help.")
} else {
format!("Welcome back, {name}.")
}; };
let mut commands = world.commands();
commands.server_trigger(ToClients {
targets: SendTargets::Single(client),
message: Welcome {
server_name: config.name.clone(),
motd: config.motd.clone(),
character: name,
admin,
},
});
tell(&mut commands, client, greeting); tell(&mut commands, client, greeting);
if !config.motd.is_empty() {
tell(&mut commands, client, config.motd);
}
world.flush();
} }
fn new_character(data: &GameData, name: &str, spawn: GridPos) -> SavedCharacter { /// Longest accepted chat message, in characters.
pub const MAX_CHAT_LEN: usize = 200;
/// Chat messages and `/commands` from players.
pub fn on_chat(chat: On<FromClient<Chat>>, mut commands: Commands) {
let client = chat.client_id;
let text: String = chat
.message
.text
.chars()
.filter(|c| !c.is_control())
.take(MAX_CHAT_LEN)
.collect();
commands.queue(move |world: &mut World| handle_chat(world, client, text.trim()));
}
fn handle_chat(world: &mut World, client: ClientId, text: &str) {
let Some(&entity) = world.resource::<Players>().by_client.get(&client) else {
return;
};
if text.is_empty() {
return;
}
if let Some(command) = text.strip_prefix('/') {
let player_id = world.get::<PlayerId>(entity).map(|id| id.0.clone());
let owner = client == ClientId::Server
|| player_id
.is_some_and(|id| world.resource::<ServerSettings>().owner.as_deref() == Some(&id));
let source = CommandSource::Player {
client,
admin: world.get::<Admin>(entity).is_some(),
owner,
};
let name = world
.get::<Character>(entity)
.map(|c| c.name.clone())
.unwrap_or_default();
info!("{name} ran `/{command}`");
let reply = commands::execute(world, source, command);
let mut cmds = world.commands();
for line in reply {
tell(&mut cmds, client, line);
}
world.flush();
return;
}
let Some(name) = world.get::<Character>(entity).map(|c| c.name.clone()) else {
return;
};
let line = format!("<{name}> {text}");
info!(target: "chat", "{line}");
world.commands().server_trigger(ToClients {
targets: SendTargets::All,
message: GameLog { text: line },
});
world.flush();
}
/// Applies access list changes to online players: kicks banned and no longer
/// whitelisted players and updates admin rights.
pub fn enforce_access(world: &mut World) {
let config = world.resource::<ServerConfig>().clone();
let owner = world.resource::<ServerSettings>().owner.clone();
let mut q = world.query::<(Entity, &Character, &PlayerId, &Controller, Has<Admin>)>();
let online: Vec<_> = q
.iter(world)
.map(|(e, c, id, ctrl, admin)| (e, c.name.clone(), id.0.clone(), ctrl.0, admin))
.collect();
for (entity, name, id, client, was_admin) in online {
let admin = is_admin(owner.as_deref(), &config, client, &id);
let privileged =
admin || client == ClientId::Server || owner.as_deref() == Some(id.as_str());
let reason = if privileged {
None
} else if is_listed(&config.access.banned, &id, &name) {
Some("You have been banned from this server.")
} else if config.access.whitelist_enabled
&& !is_listed(&config.access.whitelist, &id, &name)
{
Some("You are not on the whitelist.")
} else {
None
};
let mut commands = world.commands();
if let Some(reason) = reason {
info!("disconnecting {name}: {reason}");
disconnect(&mut commands, client, reason);
} else if admin != was_admin {
if admin {
commands.entity(entity).insert(Admin);
} else {
commands.entity(entity).remove::<Admin>();
}
commands.server_trigger(ToClients {
targets: SendTargets::Single(client),
message: AdminStatus { admin },
});
tell(
&mut commands,
client,
if admin {
"You are now an admin."
} else {
"You are no longer an admin."
},
);
}
world.flush();
}
}
pub fn new_character_data(data: &GameData, name: &str, spawn: GridPos) -> SavedCharacter {
let mut inventory = Inventory::default(); let mut inventory = Inventory::default();
if data.items.contains_key("apple") { if data.items.contains_key("apple") {
inventory.add("apple", 2, data); inventory.add("apple", 2, data);
@ -136,15 +437,23 @@ pub fn on_disconnect(
mut commands: Commands, mut commands: Commands,
mut players: ResMut<Players>, mut players: ResMut<Players>,
mut store: ResMut<CharacterStore>, mut store: ResMut<CharacterStore>,
characters: Query<(&Character, &GridPos, &Facing, &Vitals, &Inventory, &Skills)>, characters: Query<(
&Character,
&PlayerId,
&GridPos,
&Facing,
&Vitals,
&Inventory,
&Skills,
)>,
) { ) {
let Some(entity) = players.by_client.remove(&ClientId::Client(remove.entity)) else { let Some(entity) = players.by_client.remove(&ClientId::Client(remove.entity)) else {
return; return;
}; };
if let Ok((c, pos, facing, vitals, inventory, skills)) = characters.get(entity) { if let Ok((c, id, pos, facing, vitals, inventory, skills)) = characters.get(entity) {
info!("{} logged out", c.name); info!("{} logged out", c.name);
store.0.insert( store.0.insert(
c.name.clone(), id.0.clone(),
SavedCharacter { SavedCharacter {
name: c.name.clone(), name: c.name.clone(),
pos: *pos, pos: *pos,
@ -160,7 +469,98 @@ pub fn on_disconnect(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::valid_name; use super::*;
const ID: &str = "4f1c0c56-1111-4a5e-9a1e-000000000001";
fn request<'a>() -> AccessRequest<'a> {
AccessRequest {
player_id: ID,
name: "Jane",
password: "",
owner: false,
online: 0,
id_online: false,
new_character: true,
name_taken: false,
}
}
#[test]
fn access_rules() {
let mut config = ServerConfig::default();
assert_eq!(check_access(&config, &request()), Ok(()));
let bad_id = AccessRequest {
player_id: "nope",
..request()
};
assert!(check_access(&config, &bad_id).is_err());
config.password = "secret".into();
assert!(check_access(&config, &request()).is_err());
let with_password = AccessRequest {
password: "secret",
..request()
};
assert_eq!(check_access(&config, &with_password), Ok(()));
let owner = AccessRequest {
owner: true,
..request()
};
assert_eq!(check_access(&config, &owner), Ok(()));
config.password.clear();
config.access.banned = vec!["JANE".into()];
assert!(check_access(&config, &request()).is_err());
config.access.banned = vec![ID.into()];
assert!(check_access(&config, &request()).is_err());
config.access.banned.clear();
config.access.whitelist_enabled = true;
assert!(check_access(&config, &request()).is_err());
config.access.whitelist = vec!["jane".into()];
assert_eq!(check_access(&config, &request()), Ok(()));
config.access.whitelist.clear();
config.access.admins = vec![ID.into()];
assert_eq!(
check_access(&config, &request()),
Ok(()),
"admins bypass the whitelist"
);
config.access.whitelist_enabled = false;
config.access.admins.clear();
config.max_players = 1;
let full = AccessRequest {
online: 1,
..request()
};
assert!(check_access(&config, &full).is_err());
config.max_players = 32;
for req in [
AccessRequest {
id_online: true,
..request()
},
AccessRequest {
name_taken: true,
..request()
},
AccessRequest {
name: "bad;name",
..request()
},
] {
assert!(check_access(&config, &req).is_err(), "{req:?}");
}
let returning = AccessRequest {
name_taken: true,
new_character: false,
..request()
};
assert_eq!(check_access(&config, &returning), Ok(()));
}
#[test] #[test]
fn names() { fn names() {

View File

@ -23,7 +23,10 @@ pub struct SpawnPoint(pub GridPos);
pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) { pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) {
let assets = game_core::assets_dir(); let assets = game_core::assets_dir();
let layout = MapLayout::load(&assets, &settings.map).unwrap_or_else(|e| panic!("{e}")); let layout = match MapLayout::load(&assets, &settings.map) {
Ok(layout) => layout,
Err(e) => return crate::fail(&mut commands, e.to_string()),
};
let spawn = layout let spawn = layout
.objects_of_class("spawn") .objects_of_class("spawn")
.next() .next()
@ -43,8 +46,15 @@ pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data:
let mut save = None; let mut save = None;
if let Some(storage) = &settings.storage { if let Some(storage) = &settings.storage {
let (storage, loaded) = persistence::Storage::open(storage, &settings.map) let (storage, loaded) = match persistence::Storage::open(storage, &settings.map) {
.unwrap_or_else(|e| panic!("failed to open the world database: {e}")); Ok(opened) => opened,
Err(e) => {
return crate::fail(
&mut commands,
format!("failed to open the world database: {e}"),
);
}
};
info!("world `{}` ({})", storage.world.name, storage.world.id); info!("world `{}` ({})", storage.world.name, storage.world.id);
if loaded.is_some() && storage.world.map != settings.map { if loaded.is_some() && storage.world.map != settings.map {
warn!( warn!(

View File

@ -41,6 +41,16 @@ fn send(app: &mut App, ev: impl Event) {
app.update(); app.update();
} }
const PLAYER_ID: &str = "4f1c0c56-1111-4a5e-9a1e-000000000001";
fn login(name: &str) -> Login {
Login {
player_id: PLAYER_ID.into(),
name: name.into(),
password: String::new(),
}
}
fn take_log(app: &mut App) -> Vec<String> { fn take_log(app: &mut App) -> Vec<String> {
// Server events also go through a local resend before the client observer sees them. // Server events also go through a local resend before the client observer sees them.
app.update(); app.update();
@ -59,12 +69,7 @@ fn login_move_and_interact() {
let mut buildings = app.world_mut().query::<&Building>(); let mut buildings = app.world_mut().query::<&Building>();
assert_eq!(buildings.iter(app.world()).count(), 19); assert_eq!(buildings.iter(app.world()).count(), 19);
send( send(&mut app, login("Tester"));
&mut app,
Login {
name: "Tester".into(),
},
);
let me = app let me = app
.world_mut() .world_mut()
.query_filtered::<Entity, With<Character>>() .query_filtered::<Entity, With<Character>>()
@ -75,16 +80,34 @@ fn login_move_and_interact() {
assert!(log.iter().any(|t| t.contains("Tester")), "{log:?}"); assert!(log.iter().any(|t| t.contains("Tester")), "{log:?}");
assert_eq!(count(&app, me, "apple"), 2); assert_eq!(count(&app, me, "apple"), 2);
// A second login with the same name is rejected. // A second login of the same client is ignored.
send( send(&mut app, login("Tester"));
&mut app,
Login {
name: "Tester".into(),
},
);
let mut characters = app.world_mut().query::<&Character>(); let mut characters = app.world_mut().query::<&Character>();
assert_eq!(characters.iter(app.world()).count(), 1); assert_eq!(characters.iter(app.world()).count(), 1);
// The local client is the owner and may use admin commands.
send(
&mut app,
Chat {
text: "/whitelist add Someone".into(),
},
);
let log = take_log(&mut app);
assert!(
log.iter().any(|t| t.contains("whitelisted Someone")),
"{log:?}"
);
let config = app.world().resource::<game_server::config::ServerConfig>();
assert_eq!(config.access.whitelist, ["Someone"]);
send(
&mut app,
Chat {
text: "hello there".into(),
},
);
let log = take_log(&mut app);
assert!(log.iter().any(|t| t == "<Tester> hello there"), "{log:?}");
// Walk east one tile. // Walk east one tile.
send(&mut app, SetMoveDir(Some(Dir::East))); send(&mut app, SetMoveDir(Some(Dir::East)));
send(&mut app, SetMoveDir(None)); send(&mut app, SetMoveDir(None));
@ -183,12 +206,7 @@ fn login_move_and_interact() {
#[test] #[test]
fn machines_process_items() { fn machines_process_items() {
let mut app = app(); let mut app = app();
send( send(&mut app, login("Smith"));
&mut app,
Login {
name: "Smith".into(),
},
);
let me = app let me = app
.world_mut() .world_mut()
.query_filtered::<Entity, With<Character>>() .query_filtered::<Entity, With<Character>>()
@ -294,3 +312,175 @@ fn machines_process_items() {
send(&mut app, PickUp { target }); send(&mut app, PickUp { target });
assert_eq!(count(&app, me, "iron_ore"), 5); assert_eq!(count(&app, me, "iron_ore"), 5);
} }
mod network {
use std::{
net::{Ipv4Addr, SocketAddr},
time::Instant,
};
use bevy_replicon_renet::RepliconRenetPlugins;
use game_server::{
config::ServerConfig,
embedded::{EmbeddedServer, ServerStatus},
network::create_client,
};
use super::*;
const HOST: &str = "4f1c0c56-1111-4a5e-9a1e-0000000000aa";
const GUEST: &str = "4f1c0c56-1111-4a5e-9a1e-0000000000bb";
#[derive(Resource, Default)]
struct Inbox {
welcome: Option<Welcome>,
rejected: Option<String>,
log: Vec<String>,
}
fn client(addr: SocketAddr) -> App {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
StatesPlugin,
RepliconPlugins,
RepliconRenetPlugins,
CorePlugin,
))
.init_resource::<Inbox>()
.add_observer(|ev: On<Welcome>, mut inbox: ResMut<Inbox>| {
inbox.welcome = Some(ev.event().clone())
})
.add_observer(|ev: On<Rejected>, mut inbox: ResMut<Inbox>| {
inbox.rejected = Some(ev.reason.clone())
})
.add_observer(|ev: On<GameLog>, mut inbox: ResMut<Inbox>| inbox.log.push(ev.text.clone()));
app.finish();
app.cleanup();
let channels = app.world().resource::<RepliconChannels>();
let (client, transport) = create_client(channels, addr).unwrap();
app.insert_resource(client).insert_resource(transport);
app
}
/// Updates the clients until `done` returns true for the first one.
fn pump(apps: &mut [&mut App], done: impl Fn(&mut App) -> bool) {
let start = Instant::now();
loop {
for app in apps.iter_mut() {
app.update();
}
if done(apps[0]) {
return;
}
assert!(start.elapsed() < Duration::from_secs(20), "timed out");
std::thread::sleep(Duration::from_millis(5));
}
}
fn connected(app: &mut App) -> bool {
*app.world().resource::<State<ClientState>>() == ClientState::Connected
}
fn join(app: &mut App, id: &str, name: &str, password: &str) {
pump(&mut [app], connected);
app.world_mut().client_trigger(Login {
player_id: id.into(),
name: name.into(),
password: password.into(),
});
pump(&mut [app], |app| {
let inbox = app.world().resource::<Inbox>();
inbox.welcome.is_some() || inbox.rejected.is_some()
});
}
fn inbox(app: &App) -> &Inbox {
app.world().resource::<Inbox>()
}
#[test]
fn password_admins_and_bans() {
let mut config = ServerConfig {
port: 0,
password: "letmein".into(),
..Default::default()
};
config.database.save = false;
let settings = ServerSettings {
owner: Some(HOST.into()),
..config.settings()
};
let server = EmbeddedServer::start(settings, config, None, Ipv4Addr::LOCALHOST.into());
let start = Instant::now();
let addr = loop {
match server.poll() {
Some(ServerStatus::Started(addr)) => break addr,
Some(other) => panic!("{other:?}"),
None if start.elapsed() > Duration::from_secs(30) => panic!("server did not start"),
None => std::thread::sleep(Duration::from_millis(10)),
}
};
let mut intruder = client(addr);
join(&mut intruder, GUEST, "Guest", "wrong");
assert_eq!(
inbox(&intruder).rejected.as_deref(),
Some("Wrong password.")
);
// The owner needs no password and is always admin.
let mut host = client(addr);
join(&mut host, HOST, "Host", "");
let welcome = inbox(&host).welcome.clone().expect("host welcomed");
assert!(welcome.admin);
assert_eq!(welcome.character, "Host");
let mut guest = client(addr);
join(&mut guest, GUEST, "Guest", "letmein");
assert!(
!inbox(&guest)
.welcome
.as_ref()
.expect("guest welcomed")
.admin
);
guest.world_mut().client_trigger(Chat {
text: "/kick Host".into(),
});
pump(&mut [&mut guest, &mut host], |app| {
inbox(app).log.iter().any(|t| t.contains("not allowed"))
});
host.world_mut().client_trigger(Chat {
text: "/ban Guest".into(),
});
pump(&mut [&mut guest, &mut host], |app| {
inbox(app).rejected.is_some()
});
assert_eq!(
inbox(&guest).rejected.as_deref(),
Some("You have been banned from this server.")
);
pump(&mut [&mut host], |app| {
inbox(app).log.iter().any(|t| t.contains("banned Guest"))
});
drop(guest);
let mut again = client(addr);
join(&mut again, GUEST, "Guest", "letmein");
assert_eq!(
inbox(&again).rejected.as_deref(),
Some("You are banned from this server.")
);
pump(&mut [&mut host], |app| {
inbox(app)
.log
.iter()
.any(|t| t.contains("[admin] Rejected Guest"))
});
server.stop();
}
}