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:
parent
09ace2607c
commit
2d6f52e8a4
|
|
@ -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
|
||||
server for singleplayer and hosting.
|
||||
- `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/maps/`, `assets/tilesets/`, `assets/sprites/`: Tiled maps and art.
|
||||
- `xtask/`: project automation (`cargo xtask pack` builds and archives the
|
||||
|
|
|
|||
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -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
|
||||
overrides, and it accepts terminal commands for save, shutdown and live config
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -3108,6 +3108,10 @@ dependencies = [
|
|||
"directories",
|
||||
"game_core",
|
||||
"game_server",
|
||||
"rsn",
|
||||
"serde",
|
||||
"toml",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
126
README.md
126
README.md
|
|
@ -31,13 +31,22 @@ multiplayer behave identically.
|
|||
## Running
|
||||
|
||||
```sh
|
||||
cargo run # singleplayer (save game `singleplayer`)
|
||||
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 # start menu
|
||||
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`,
|
||||
`--max-players N`, `--database URL`, `--world NAME|UUID`, `--no-save`,
|
||||
`--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.
|
||||
|
||||
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
|
||||
name logs in again.
|
||||
Ctrl-C, SIGTERM).
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -92,19 +109,110 @@ config set autosave_secs 30
|
|||
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
|
||||
|
||||
| Key | Action |
|
||||
|---------------------|------------------------------------------------------|
|
||||
| WASD / arrows | Move (you face the direction you move) |
|
||||
| E | Pick up (own tile, otherwise the faced tile) |
|
||||
| F / left click | Interact with the faced / clicked building |
|
||||
| F / left click | Interact with the faced / clicked building; machines open their window |
|
||||
| U | Use the selected item (eat, heal, …) |
|
||||
| G | Drop the selected item |
|
||||
| X | Deconstruct the faced building |
|
||||
| 1–9, 0, mouse wheel | Select an inventory slot |
|
||||
| C / B | Crafting / build menu (W/S select, Enter confirm, Esc close) |
|
||||
| 1–9, 0, mouse wheel | Select an inventory slot (or click it) |
|
||||
| C / B | Crafting / build menu (W/S select, Enter confirm, or click) |
|
||||
| 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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" },
|
||||
],
|
||||
}
|
||||
|
|
@ -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." },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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" },
|
||||
],
|
||||
}
|
||||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -14,6 +14,10 @@ bevy = { workspace = true, default-features = true }
|
|||
bevy_replicon = { workspace = true }
|
||||
bevy_replicon_renet = { workspace = true }
|
||||
directories = { workspace = true }
|
||||
rsn = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,15 +11,64 @@ use game_core::{
|
|||
use game_server::actions::REACH;
|
||||
|
||||
use crate::{
|
||||
hud::{Menu, UiState, activate_entry, menu_entries},
|
||||
machine::{InteractAt, OpenMachine},
|
||||
render::LocalCharacter,
|
||||
ui::{Menu, UiState, menu_entries},
|
||||
session::AppState,
|
||||
ui::{TextEditSystems, UiFocus, pointer_over_ui},
|
||||
};
|
||||
|
||||
pub struct InputPlugin;
|
||||
|
||||
impl Plugin for InputPlugin {
|
||||
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,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
ui: Res<UiState>,
|
||||
focus: Res<UiFocus>,
|
||||
mut held: Local<Vec<Dir>>,
|
||||
mut sent: Local<Option<Dir>>,
|
||||
) {
|
||||
|
|
@ -52,7 +102,7 @@ fn movement(
|
|||
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
|
||||
} else {
|
||||
held.last().copied()
|
||||
|
|
@ -80,10 +130,6 @@ fn menu_input(
|
|||
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
|
||||
) {
|
||||
let Some(menu) = ui.menu else { return };
|
||||
if keys.just_pressed(KeyCode::Escape) {
|
||||
ui.menu = None;
|
||||
return;
|
||||
}
|
||||
let entries = menu_entries(menu, &data);
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
|
|
@ -96,17 +142,8 @@ fn menu_input(
|
|||
}
|
||||
if keys.any_just_pressed([KeyCode::Enter, KeyCode::Space, KeyCode::NumpadEnter]) {
|
||||
let id = entries[ui.cursor.min(entries.len() - 1)].clone();
|
||||
match menu {
|
||||
Menu::Craft => commands.client_trigger(Craft { recipe: id }),
|
||||
Menu::Build => {
|
||||
if let Some((pos, facing, _)) = local_state(&local, &characters) {
|
||||
commands.client_trigger(Construct {
|
||||
building: id,
|
||||
target: pos.step(facing),
|
||||
});
|
||||
}
|
||||
}
|
||||
Menu::Help => {}
|
||||
activate_entry(&mut commands, menu, id, pos, facing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -130,6 +167,7 @@ fn actions(
|
|||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut wheel: MessageReader<MouseWheel>,
|
||||
mut ui: ResMut<UiState>,
|
||||
machine: Res<OpenMachine>,
|
||||
local: Res<LocalCharacter>,
|
||||
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
|
||||
items: Query<&GridPos, With<GroundItem>>,
|
||||
|
|
@ -151,9 +189,16 @@ fn actions(
|
|||
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;
|
||||
ui.selected_slot = if ev.y > 0.0 {
|
||||
ui.selected_slot = if y > 0.0 {
|
||||
(ui.selected_slot + len - 1) % len
|
||||
} else {
|
||||
(ui.selected_slot + 1) % len
|
||||
|
|
@ -178,7 +223,7 @@ fn actions(
|
|||
commands.client_trigger(PickUp { target });
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyF) {
|
||||
commands.client_trigger(Interact { target: front });
|
||||
commands.trigger(InteractAt(front));
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyX) {
|
||||
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.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mouse(
|
||||
mut commands: Commands,
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
interactions: Query<&Interaction>,
|
||||
ui: Res<UiState>,
|
||||
window: Query<&Window, With<PrimaryWindow>>,
|
||||
camera: Query<(&Camera, &GlobalTransform)>,
|
||||
local: Res<LocalCharacter>,
|
||||
characters: Query<&GridPos, With<Character>>,
|
||||
buildings: Query<&GridPos, With<Building>>,
|
||||
) {
|
||||
if !buttons.just_pressed(MouseButton::Left) {
|
||||
if !buttons.just_pressed(MouseButton::Left)
|
||||
|| ui.blocks_game()
|
||||
|| pointer_over_ui(&interactions)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(me) = local.0.and_then(|e| characters.get(e).ok()) else {
|
||||
|
|
@ -228,7 +279,7 @@ fn mouse(
|
|||
return;
|
||||
}
|
||||
if buildings.iter().any(|p| *p == target) {
|
||||
commands.client_trigger(Interact { target });
|
||||
commands.trigger(InteractAt(target));
|
||||
} else {
|
||||
commands.client_trigger(PickUp { target });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +1,39 @@
|
|||
//! Game client.
|
||||
//!
|
||||
//! ```text
|
||||
//! game singleplayer (persistent local world)
|
||||
//! game --host [PORT] singleplayer world that friends can join
|
||||
//! game start menu
|
||||
//! 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
|
||||
//! ```
|
||||
//! 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.
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
mod hud;
|
||||
mod input;
|
||||
mod net;
|
||||
mod machine;
|
||||
mod menu;
|
||||
mod profile;
|
||||
mod render;
|
||||
mod saves;
|
||||
mod session;
|
||||
mod ui;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use bevy::{prelude::*, window::WindowResolution};
|
||||
use bevy_replicon::prelude::*;
|
||||
use bevy_replicon_renet::RepliconRenetPlugins;
|
||||
use game_core::CorePlugin;
|
||||
use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings, config::ServerConfig};
|
||||
|
||||
/// How this client runs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Mode {
|
||||
/// Server logic runs in-process without networking.
|
||||
Singleplayer,
|
||||
/// Server logic runs in-process and accepts remote players.
|
||||
Host { port: u16 },
|
||||
/// Pure client connected to a remote server.
|
||||
Join { addr: SocketAddr },
|
||||
}
|
||||
|
||||
/// The local player's login name, used to find our character among replicated ones.
|
||||
#[derive(Resource, Clone, Debug)]
|
||||
pub struct LocalPlayer {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Resource, Clone, Debug)]
|
||||
pub struct ClientMode(pub Mode);
|
||||
use crate::{
|
||||
profile::Profile,
|
||||
session::{AppState, SessionRequest, SessionTarget},
|
||||
};
|
||||
|
||||
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 assets = game_core::assets_dir();
|
||||
app.add_plugins((
|
||||
|
|
@ -57,7 +45,7 @@ fn main() -> AppExit {
|
|||
.set(ImagePlugin::default_nearest())
|
||||
.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: format!("Station - {name}"),
|
||||
title: "Station".to_owned(),
|
||||
resolution: WindowResolution::new(1280, 800),
|
||||
..default()
|
||||
}),
|
||||
|
|
@ -66,48 +54,36 @@ fn main() -> AppExit {
|
|||
RepliconPlugins,
|
||||
RepliconRenetPlugins,
|
||||
CorePlugin,
|
||||
));
|
||||
|
||||
match &mode {
|
||||
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(profile)
|
||||
.insert_resource(menu::StartMap(map))
|
||||
.insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.05)))
|
||||
.add_plugins((
|
||||
net::NetPlugin,
|
||||
ui::UiFrameworkPlugin,
|
||||
session::SessionPlugin,
|
||||
menu::MenuPlugin,
|
||||
hud::HudPlugin,
|
||||
machine::MachinePlugin,
|
||||
render::RenderPlugin,
|
||||
input::InputPlugin,
|
||||
ui::UiPlugin,
|
||||
))
|
||||
.run()
|
||||
));
|
||||
if let Some(request) = request {
|
||||
app.insert_resource(request);
|
||||
app.world_mut()
|
||||
.resource_mut::<NextState<AppState>>()
|
||||
.set(AppState::Connecting);
|
||||
}
|
||||
app.run()
|
||||
}
|
||||
|
||||
fn parse_args() -> (Mode, String, ServerSettings) {
|
||||
let mut mode = Mode::Singleplayer;
|
||||
let mut name = std::env::var("USER")
|
||||
.ok()
|
||||
.filter(|n| game_server::players::valid_name(n))
|
||||
.unwrap_or_else(|| "Player".to_owned());
|
||||
let mut settings = ServerSettings {
|
||||
storage: Some(saves::storage("singleplayer")),
|
||||
..default()
|
||||
};
|
||||
/// Returns the session to start right away (skipping the menu), if any, and the map for new worlds.
|
||||
fn parse_args(profile: &mut Profile) -> (Option<SessionRequest>, String) {
|
||||
let mut host = None;
|
||||
let mut connect = None;
|
||||
let mut save = None;
|
||||
let mut no_save = false;
|
||||
let mut password = String::new();
|
||||
let mut map = game_core::DEFAULT_MAP.to_owned();
|
||||
let mut args = std::env::args().skip(1).peekable();
|
||||
while let Some(arg) = args.next() {
|
||||
let mut value = |what: &str| {
|
||||
|
|
@ -116,41 +92,49 @@ fn parse_args() -> (Mode, String, ServerSettings) {
|
|||
};
|
||||
match arg.as_str() {
|
||||
"--host" => {
|
||||
let port = match args.peek() {
|
||||
host = Some(match args.peek() {
|
||||
Some(p) if !p.starts_with("--") => args
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap_or_else(|_| usage("invalid port")),
|
||||
_ => game_core::DEFAULT_PORT,
|
||||
};
|
||||
mode = Mode::Host { port };
|
||||
});
|
||||
}
|
||||
"--connect" => {
|
||||
let addr = value("an address");
|
||||
let addr = if addr.contains(':') {
|
||||
addr
|
||||
} else {
|
||||
format!("{addr}:{}", game_core::DEFAULT_PORT)
|
||||
};
|
||||
let addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
|
||||
.ok()
|
||||
.and_then(|mut a| a.next())
|
||||
.unwrap_or_else(|| usage(&format!("can't resolve `{addr}`")));
|
||||
mode = Mode::Join { addr };
|
||||
}
|
||||
"--name" => name = value("a name"),
|
||||
"--save" => settings.storage = Some(saves::storage(&value("a save name"))),
|
||||
"--no-save" => settings.storage = None,
|
||||
"--map" => settings.map = value("a map path"),
|
||||
"--connect" => connect = Some(value("an address")),
|
||||
"--name" => profile.name = value("a name"),
|
||||
"--password" => password = value("a password"),
|
||||
"--save" => save = Some(value("a save name")),
|
||||
"--no-save" => no_save = true,
|
||||
"--map" => map = value("a map path"),
|
||||
"-h" | "--help" => usage(""),
|
||||
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)");
|
||||
}
|
||||
(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) -> ! {
|
||||
|
|
@ -158,12 +142,15 @@ fn usage(error: &str) -> ! {
|
|||
eprintln!("error: {error}\n");
|
||||
}
|
||||
eprintln!(
|
||||
"usage: game [--host [PORT] | --connect ADDR[:PORT]] [--name NAME] [--save NAME | --no-save] [--map MAP]\n\n\
|
||||
(no mode) singleplayer, world saved as `singleplayer`\n\
|
||||
--host play your world and let others join (default port {port})\n\
|
||||
"usage: game [--save NAME | --no-save] [--host [PORT]] [--connect ADDR[:PORT]]\n \
|
||||
[--name NAME] [--password PASSWORD] [--map MAP]\n\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\
|
||||
--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})",
|
||||
port = game_core::DEFAULT_PORT,
|
||||
map = game_core::DEFAULT_MAP,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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}."));
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ use game_core::{
|
|||
map::MapLayout,
|
||||
};
|
||||
|
||||
use crate::LocalPlayer;
|
||||
use crate::session::SessionInfo;
|
||||
|
||||
pub struct RenderPlugin;
|
||||
|
||||
|
|
@ -130,15 +130,19 @@ fn spawn_map(
|
|||
|
||||
fn find_local_character(
|
||||
mut local: ResMut<LocalCharacter>,
|
||||
player: Res<LocalPlayer>,
|
||||
session: Res<SessionInfo>,
|
||||
characters: Query<(Entity, &Character)>,
|
||||
) {
|
||||
if local.0.is_some_and(|e| characters.contains(e)) {
|
||||
return;
|
||||
}
|
||||
if session.character.is_empty() {
|
||||
local.0 = None;
|
||||
return;
|
||||
}
|
||||
local.0 = characters
|
||||
.iter()
|
||||
.find(|(_, c)| c.name == player.name)
|
||||
.find(|(_, c)| c.name == session.character)
|
||||
.map(|(e, _)| e);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
//! 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).
|
||||
|
||||
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.
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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 item: String,
|
||||
pub count: u32,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ pub use data::GameData;
|
|||
pub const DEFAULT_MAP: &str = "maps/station.tmx";
|
||||
pub const DEFAULT_PORT: u16 = 5000;
|
||||
/// Bump when the protocol changes in incompatible ways.
|
||||
pub const PROTOCOL_ID: u64 = 2;
|
||||
pub const PROTOCOL_ID: u64 = 3;
|
||||
|
||||
/// Locates the `assets` directory.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -12,7 +12,13 @@ use crate::{components::*, grid::Dir};
|
|||
/// First event a client sends; selects (or creates) the persistent character.
|
||||
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Login {
|
||||
/// 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,
|
||||
/// Server password, empty if none.
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Direction the player is holding, `None` to stop.
|
||||
|
|
@ -75,12 +81,40 @@ pub struct MachineTake {
|
|||
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.
|
||||
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GameLog {
|
||||
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;
|
||||
|
||||
impl Plugin for ProtocolPlugin {
|
||||
|
|
@ -107,6 +141,10 @@ impl Plugin for ProtocolPlugin {
|
|||
.add_client_event::<Deconstruct>(Channel::Ordered)
|
||||
.add_client_event::<MachineInsert>(Channel::Ordered)
|
||||
.add_client_event::<MachineTake>(Channel::Ordered)
|
||||
.add_server_event::<GameLog>(Channel::Ordered);
|
||||
.add_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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_replicon::prelude::*;
|
||||
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.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CommandSource {
|
||||
/// The dedicated server's terminal.
|
||||
Console,
|
||||
Player {
|
||||
client: ClientId,
|
||||
admin: bool,
|
||||
/// Host of the embedded server; may also stop it.
|
||||
owner: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub const HELP: &[(&str, &str)] = &[
|
||||
("help", "list commands"),
|
||||
("players", "list online players"),
|
||||
("say MESSAGE", "message all players"),
|
||||
("save", "save the world now"),
|
||||
impl CommandSource {
|
||||
fn is_admin(self) -> bool {
|
||||
match self {
|
||||
Self::Console => true,
|
||||
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",
|
||||
"show a setting, e.g. `config get access.whitelist`",
|
||||
Permission::Admin,
|
||||
),
|
||||
(
|
||||
"config set KEY VALUE",
|
||||
"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.
|
||||
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 (command, rest) = line.split_once(' ').unwrap_or((line, ""));
|
||||
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 {
|
||||
"" => vec![],
|
||||
"help" => HELP
|
||||
.iter()
|
||||
.map(|(usage, what)| format!("{usage:<22} {what}"))
|
||||
"help" => help(source)
|
||||
.into_iter()
|
||||
.map(|(usage, what)| format!("{usage:<28} {what}"))
|
||||
.collect(),
|
||||
"players" => {
|
||||
let names: Vec<String> = world
|
||||
.query::<&Character>()
|
||||
let mut q = world.query::<(&Character, &PlayerId, Has<players::Admin>)>();
|
||||
let mut names: Vec<String> = q
|
||||
.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();
|
||||
let count = world.resource::<Players>().by_client.len();
|
||||
vec![format!("{count} online: {}", names.join(", "))]
|
||||
names.sort();
|
||||
let mut reply = vec![format!("{} online", names.len())];
|
||||
reply.extend(names);
|
||||
reply
|
||||
}
|
||||
"say" if !rest.is_empty() => {
|
||||
let text = format!("[server] {rest}");
|
||||
let text = format!("[{actor}] {rest}");
|
||||
world.commands().server_trigger(ToClients {
|
||||
targets: SendTargets::All,
|
||||
message: GameLog { text: text.clone() },
|
||||
|
|
@ -58,6 +172,35 @@ pub fn execute(world: &mut World, _source: CommandSource, line: &str) -> Vec<Str
|
|||
world.flush();
|
||||
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" => {
|
||||
if world.contains_resource::<persistence::Storage>() {
|
||||
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),
|
||||
"stop" => {
|
||||
info!("{actor} stopped the server");
|
||||
world.write_message(AppExit::Success);
|
||||
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()],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ impl ServerConfig {
|
|||
world: WorldSelector::parse(&self.database.world),
|
||||
}),
|
||||
autosave_interval: Duration::from_secs(self.autosave_secs.max(1)),
|
||||
owner: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ pub mod commands;
|
|||
pub mod config;
|
||||
pub mod console;
|
||||
pub mod crafting;
|
||||
pub mod embedded;
|
||||
pub mod machines;
|
||||
pub mod movement;
|
||||
pub mod network;
|
||||
|
|
@ -33,6 +34,9 @@ pub struct ServerSettings {
|
|||
/// Database and world to persist to. `None` disables saving.
|
||||
pub storage: Option<persistence::StorageSettings>,
|
||||
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 {
|
||||
|
|
@ -41,6 +45,7 @@ impl Default for ServerSettings {
|
|||
map: game_core::DEFAULT_MAP.to_owned(),
|
||||
storage: None,
|
||||
autosave_interval: Duration::from_secs(60),
|
||||
owner: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -60,20 +65,36 @@ impl Plugin for GameServerPlugin {
|
|||
.init_resource::<config::ServerConfig>()
|
||||
.init_resource::<players::Players>()
|
||||
.init_resource::<players::CharacterStore>()
|
||||
.init_resource::<players::RejectedLogins>()
|
||||
.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(
|
||||
FixedUpdate,
|
||||
(
|
||||
movement::move_characters,
|
||||
survival::tick_survival,
|
||||
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_observer(players::on_login)
|
||||
.add_observer(players::on_disconnect)
|
||||
.add_observer(players::on_chat)
|
||||
.add_observer(movement::on_set_move_dir)
|
||||
.add_observer(actions::on_pick_up)
|
||||
.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>) {
|
||||
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());
|
||||
commands.insert_resource(engine);
|
||||
commands.insert_resource(actions::SharedData(Arc::new(data.clone())));
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ fn main() -> AppExit {
|
|||
GameServerPlugin {
|
||||
settings: config.settings(),
|
||||
},
|
||||
NetworkServerPlugin { config },
|
||||
NetworkServerPlugin::public(config),
|
||||
ConsolePlugin,
|
||||
));
|
||||
if created {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
//! Starting the renet network server (host and dedicated modes).
|
||||
|
||||
use std::{
|
||||
net::{Ipv4Addr, SocketAddr, UdpSocket},
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_replicon::prelude::*;
|
||||
use bevy_replicon_renet::{
|
||||
RenetChannelsExt, RenetServer,
|
||||
netcode::{self, NetcodeServerTransport, ServerAuthentication},
|
||||
RenetChannelsExt, RenetClient, RenetServer,
|
||||
netcode::{
|
||||
self, ClientAuthentication, NetcodeClientTransport, NetcodeServerTransport,
|
||||
ServerAuthentication,
|
||||
},
|
||||
renet::ConnectionConfig,
|
||||
};
|
||||
|
||||
|
|
@ -23,11 +26,32 @@ const EXTRA_SLOTS: usize = 4;
|
|||
/// `config` the server's [`ServerConfig`] resource.
|
||||
pub struct NetworkServerPlugin {
|
||||
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 {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.insert_resource(self.config.clone())
|
||||
.insert_resource(BindIp(self.bind))
|
||||
.add_systems(Startup, start_server);
|
||||
}
|
||||
}
|
||||
|
|
@ -36,41 +60,70 @@ fn start_server(
|
|||
mut commands: Commands,
|
||||
channels: Res<RepliconChannels>,
|
||||
config: Res<ServerConfig>,
|
||||
bind: Res<BindIp>,
|
||||
) {
|
||||
let port = config.port;
|
||||
match create_server(&channels, port, config.max_players + EXTRA_SLOTS) {
|
||||
Ok((server, transport)) => {
|
||||
info!("listening on UDP port {port}");
|
||||
let addr = SocketAddr::new(bind.0, config.port);
|
||||
match create_server(&channels, addr, config.max_players + EXTRA_SLOTS) {
|
||||
Ok((server, transport, bound)) => {
|
||||
info!("listening on UDP {bound}");
|
||||
commands.insert_resource(server);
|
||||
commands.insert_resource(transport);
|
||||
commands.insert_resource(BoundAddr(bound));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("failed to start server on port {port}: {e}");
|
||||
commands.write_message(AppExit::error());
|
||||
}
|
||||
Err(e) => crate::fail(&mut commands, format!("failed to listen on {addr}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_server(
|
||||
channels: &RepliconChannels,
|
||||
port: u16,
|
||||
addr: SocketAddr,
|
||||
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 {
|
||||
server_channels_config: channels.server_configs(),
|
||||
client_channels_config: channels.client_configs(),
|
||||
..Default::default()
|
||||
});
|
||||
let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
|
||||
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port))?;
|
||||
let socket = UdpSocket::bind(addr)?;
|
||||
let bound = socket.local_addr()?;
|
||||
let server_config = netcode::ServerConfig {
|
||||
current_time,
|
||||
max_clients,
|
||||
protocol_id: game_core::PROTOCOL_ID,
|
||||
authentication: ServerAuthentication::Unsecure,
|
||||
// Not used with unsecure authentication, but must be valid.
|
||||
public_addresses: vec![SocketAddr::from((Ipv4Addr::LOCALHOST, port))],
|
||||
public_addresses: vec![bound],
|
||||
};
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ use tokio::{runtime::Runtime, task::JoinHandle};
|
|||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
players::CharacterStore,
|
||||
players::{CharacterStore, PlayerId},
|
||||
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.
|
||||
pub fn snapshot(world: &mut World) -> SaveGame {
|
||||
let mut characters = world.resource::<CharacterStore>().0.clone();
|
||||
let mut q = world.query::<(&Character, &GridPos, &Facing, &Vitals, &Inventory, &Skills)>();
|
||||
for (c, pos, facing, vitals, inventory, skills) in q.iter(world) {
|
||||
let mut q = world.query::<(
|
||||
&Character,
|
||||
&PlayerId,
|
||||
&GridPos,
|
||||
&Facing,
|
||||
&Vitals,
|
||||
&Inventory,
|
||||
&Skills,
|
||||
)>();
|
||||
for (c, id, pos, facing, vitals, inventory, skills) in q.iter(world) {
|
||||
characters.insert(
|
||||
c.name.clone(),
|
||||
id.0.clone(),
|
||||
SavedCharacter {
|
||||
name: c.name.clone(),
|
||||
pos: *pos,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
//! 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_replicon::prelude::*;
|
||||
use game_core::{
|
||||
GameData,
|
||||
components::*,
|
||||
protocol::{GameLog, Login},
|
||||
protocol::{AdminStatus, Chat, GameLog, Login, Rejected, Welcome},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ServerSettings,
|
||||
commands::{self, CommandSource},
|
||||
config::ServerConfig,
|
||||
movement::MoveIntent,
|
||||
persistence::SavedCharacter,
|
||||
world::{SpawnPoint, WorldLayout, find_free_tile},
|
||||
|
|
@ -20,13 +23,21 @@ use crate::{
|
|||
#[derive(Component, Clone, Copy, Debug)]
|
||||
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.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct Players {
|
||||
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)]
|
||||
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 == '-')
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn on_login(
|
||||
login: On<FromClient<Login>>,
|
||||
mut commands: Commands,
|
||||
mut players: ResMut<Players>,
|
||||
mut store: ResMut<CharacterStore>,
|
||||
mut disconnects: MessageWriter<DisconnectRequest>,
|
||||
data: Res<GameData>,
|
||||
spawn: Res<SpawnPoint>,
|
||||
layout: Res<WorldLayout>,
|
||||
characters: Query<&Character>,
|
||||
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
||||
) {
|
||||
let client = login.client_id;
|
||||
let name = login.message.name.clone();
|
||||
if players.by_client.contains_key(&client) {
|
||||
return;
|
||||
/// Everything [`check_access`] needs to know about a login attempt.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct AccessRequest<'a> {
|
||||
pub player_id: &'a str,
|
||||
/// Character name (the stored one for returning players).
|
||||
pub name: &'a str,
|
||||
pub password: &'a str,
|
||||
/// Host of an embedded server or a local client; bypasses all restrictions.
|
||||
pub owner: bool,
|
||||
pub online: usize,
|
||||
pub id_online: bool,
|
||||
pub new_character: bool,
|
||||
/// Another player's character already uses this name.
|
||||
pub name_taken: bool,
|
||||
}
|
||||
|
||||
/// Whether a player may join. Pure so the rules can be tested in isolation.
|
||||
pub fn check_access(config: &ServerConfig, req: &AccessRequest) -> Result<(), String> {
|
||||
if uuid::Uuid::parse_str(req.player_id).is_err() {
|
||||
return Err("Invalid player id.".into());
|
||||
}
|
||||
let reject = |commands: &mut Commands,
|
||||
disconnects: &mut MessageWriter<DisconnectRequest>,
|
||||
reason: &str| {
|
||||
tell(commands, client, reason);
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
if characters.iter().any(|c| c.name == name) {
|
||||
reject(
|
||||
&mut commands,
|
||||
&mut disconnects,
|
||||
"That character is already online.",
|
||||
);
|
||||
let player_id = login.player_id.trim().to_lowercase();
|
||||
let stored_name = character_name(world, &player_id);
|
||||
let new_character = stored_name.is_none();
|
||||
let name = stored_name.unwrap_or_else(|| login.name.trim().to_owned());
|
||||
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;
|
||||
}
|
||||
|
||||
let (saved, returning) = match store.0.remove(&name) {
|
||||
Some(saved) => (saved, true),
|
||||
None => (new_character(&data, &name, spawn.0), false),
|
||||
let saved = match world.resource_mut::<CharacterStore>().0.remove(&player_id) {
|
||||
Some(saved) => saved,
|
||||
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 entity = commands
|
||||
.spawn((
|
||||
let mut buildings =
|
||||
world.query_filtered::<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>();
|
||||
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,
|
||||
Character { name: name.clone() },
|
||||
pos,
|
||||
|
|
@ -102,20 +268,155 @@ pub fn on_login(
|
|||
saved.inventory,
|
||||
saved.skills,
|
||||
Controller(client),
|
||||
PlayerId(player_id.clone()),
|
||||
MoveIntent::default(),
|
||||
))
|
||||
.id();
|
||||
players.by_client.insert(client, entity);
|
||||
info!("{name} logged in ({client:?})");
|
||||
let greeting = if returning {
|
||||
format!("Welcome back, {name}.")
|
||||
} else {
|
||||
));
|
||||
if admin {
|
||||
entity.insert(Admin);
|
||||
}
|
||||
let entity = entity.id();
|
||||
world
|
||||
.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.")
|
||||
} 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);
|
||||
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();
|
||||
if data.items.contains_key("apple") {
|
||||
inventory.add("apple", 2, data);
|
||||
|
|
@ -136,15 +437,23 @@ pub fn on_disconnect(
|
|||
mut commands: Commands,
|
||||
mut players: ResMut<Players>,
|
||||
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 {
|
||||
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);
|
||||
store.0.insert(
|
||||
c.name.clone(),
|
||||
id.0.clone(),
|
||||
SavedCharacter {
|
||||
name: c.name.clone(),
|
||||
pos: *pos,
|
||||
|
|
@ -160,7 +469,98 @@ pub fn on_disconnect(
|
|||
|
||||
#[cfg(test)]
|
||||
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]
|
||||
fn names() {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ pub struct SpawnPoint(pub GridPos);
|
|||
|
||||
pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) {
|
||||
let assets = game_core::assets_dir();
|
||||
let layout = MapLayout::load(&assets, &settings.map).unwrap_or_else(|e| panic!("{e}"));
|
||||
let layout = match MapLayout::load(&assets, &settings.map) {
|
||||
Ok(layout) => layout,
|
||||
Err(e) => return crate::fail(&mut commands, e.to_string()),
|
||||
};
|
||||
let spawn = layout
|
||||
.objects_of_class("spawn")
|
||||
.next()
|
||||
|
|
@ -43,8 +46,15 @@ pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data:
|
|||
|
||||
let mut save = None;
|
||||
if let Some(storage) = &settings.storage {
|
||||
let (storage, loaded) = persistence::Storage::open(storage, &settings.map)
|
||||
.unwrap_or_else(|e| panic!("failed to open the world database: {e}"));
|
||||
let (storage, loaded) = match persistence::Storage::open(storage, &settings.map) {
|
||||
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);
|
||||
if loaded.is_some() && storage.world.map != settings.map {
|
||||
warn!(
|
||||
|
|
|
|||
|
|
@ -41,6 +41,16 @@ fn send(app: &mut App, ev: impl Event) {
|
|||
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> {
|
||||
// Server events also go through a local resend before the client observer sees them.
|
||||
app.update();
|
||||
|
|
@ -59,12 +69,7 @@ fn login_move_and_interact() {
|
|||
let mut buildings = app.world_mut().query::<&Building>();
|
||||
assert_eq!(buildings.iter(app.world()).count(), 19);
|
||||
|
||||
send(
|
||||
&mut app,
|
||||
Login {
|
||||
name: "Tester".into(),
|
||||
},
|
||||
);
|
||||
send(&mut app, login("Tester"));
|
||||
let me = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<Character>>()
|
||||
|
|
@ -75,16 +80,34 @@ fn login_move_and_interact() {
|
|||
assert!(log.iter().any(|t| t.contains("Tester")), "{log:?}");
|
||||
assert_eq!(count(&app, me, "apple"), 2);
|
||||
|
||||
// A second login with the same name is rejected.
|
||||
send(
|
||||
&mut app,
|
||||
Login {
|
||||
name: "Tester".into(),
|
||||
},
|
||||
);
|
||||
// A second login of the same client is ignored.
|
||||
send(&mut app, login("Tester"));
|
||||
let mut characters = app.world_mut().query::<&Character>();
|
||||
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.
|
||||
send(&mut app, SetMoveDir(Some(Dir::East)));
|
||||
send(&mut app, SetMoveDir(None));
|
||||
|
|
@ -183,12 +206,7 @@ fn login_move_and_interact() {
|
|||
#[test]
|
||||
fn machines_process_items() {
|
||||
let mut app = app();
|
||||
send(
|
||||
&mut app,
|
||||
Login {
|
||||
name: "Smith".into(),
|
||||
},
|
||||
);
|
||||
send(&mut app, login("Smith"));
|
||||
let me = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, With<Character>>()
|
||||
|
|
@ -294,3 +312,175 @@ fn machines_process_items() {
|
|||
send(&mut app, PickUp { target });
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue