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
|
- `crates/game_client/`: the `game` binary: rendering, UI and input; embeds the
|
||||||
server for singleplayer and hosting.
|
server for singleplayer and hosting.
|
||||||
- `assets/data/`: item, recipe, building and skill definitions (`.rsn`).
|
- `assets/data/`: item, recipe, building and skill definitions (`.rsn`).
|
||||||
|
- `assets/ui/`: client UI layouts and theme (`.rsn`), see the README.
|
||||||
- `assets/scripts/`: Rune scripts (`.rn`), referenced as `"module::function"`.
|
- `assets/scripts/`: Rune scripts (`.rn`), referenced as `"module::function"`.
|
||||||
- `assets/maps/`, `assets/tilesets/`, `assets/sprites/`: Tiled maps and art.
|
- `assets/maps/`, `assets/tilesets/`, `assets/sprites/`: Tiled maps and art.
|
||||||
- `xtask/`: project automation (`cargo xtask pack` builds and archives the
|
- `xtask/`: project automation (`cargo xtask pack` builds and archives the
|
||||||
|
|
|
||||||
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
|
- The dedicated server is configured by `server.toml` plus command-line
|
||||||
overrides, and it accepts terminal commands for save, shutdown and live config
|
overrides, and it accepts terminal commands for save, shutdown and live config
|
||||||
changes.
|
changes.
|
||||||
|
- Start menu with new game (optionally hosted), load/delete saved games and
|
||||||
|
join server screens. Running the client without options shows it.
|
||||||
|
- Menus and HUD are defined by layout files in `assets/ui/` (rsn) with a shared
|
||||||
|
theme, `{binding}` placeholders, conditional visibility and tooltips.
|
||||||
|
- Machine windows: insert and take items from machine slots, using the machine's
|
||||||
|
slot positions and background image, with a progress bar.
|
||||||
|
- Chat (Enter/T) with `/` commands, a pause menu (Esc) and clickable inventory,
|
||||||
|
crafting and build menus.
|
||||||
|
- Server passwords, admins, a whitelist, bans, kicks and admin commands in the
|
||||||
|
server terminal and in-game chat. Changes are written to the config file. The
|
||||||
|
player hosting a world is always admin.
|
||||||
|
- The client profile (`client.toml`) stores a player id; characters belong to
|
||||||
|
it instead of to a name.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Breaking:** network protocol version 3. Logins carry a player id and
|
||||||
|
password, so older clients and servers can't connect. Characters saved by name
|
||||||
|
in existing worlds are not carried over to player ids.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3108,6 +3108,10 @@ dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"game_core",
|
"game_core",
|
||||||
"game_server",
|
"game_server",
|
||||||
|
"rsn",
|
||||||
|
"serde",
|
||||||
|
"toml",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
126
README.md
126
README.md
|
|
@ -31,13 +31,22 @@ multiplayer behave identically.
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run # singleplayer (save game `singleplayer`)
|
cargo run # start menu
|
||||||
cargo run -- --host 5000 # singleplayer world that others can join
|
|
||||||
cargo run -- --connect 127.0.0.1:5000 # join a server or host
|
|
||||||
cargo run -p game_server --bin game-server # dedicated server (saves/server.sqlite, world `main`)
|
cargo run -p game_server --bin game-server # dedicated server (saves/server.sqlite, world `main`)
|
||||||
```
|
```
|
||||||
|
|
||||||
Client options: `--name NAME`, `--save NAME`, `--no-save`, `--map MAP`.
|
The start menu offers **New game** (optionally open to other players),
|
||||||
|
**Load game** (play or delete a save) and **Join server**. Command-line options
|
||||||
|
skip the menu:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -- --save mars # play (or create) the local world `mars`
|
||||||
|
cargo run -- --save mars --host 5000 --password secret # let others join it
|
||||||
|
cargo run -- --connect 127.0.0.1:5000 --password secret # join a server or host
|
||||||
|
```
|
||||||
|
|
||||||
|
Client options: `--save NAME`, `--no-save`, `--host [PORT]`,
|
||||||
|
`--connect ADDR[:PORT]`, `--name NAME`, `--password TEXT`, `--map MAP`.
|
||||||
Server options: `--config FILE`, `--port PORT`, `--password TEXT`,
|
Server options: `--config FILE`, `--port PORT`, `--password TEXT`,
|
||||||
`--max-players N`, `--database URL`, `--world NAME|UUID`, `--no-save`,
|
`--max-players N`, `--database URL`, `--world NAME|UUID`, `--no-save`,
|
||||||
`--map MAP`, `--autosave SECS`, `--motd TEXT`, `--admin ID`, `--whitelist`.
|
`--map MAP`, `--autosave SECS`, `--motd TEXT`, `--admin ID`, `--whitelist`.
|
||||||
|
|
@ -46,8 +55,16 @@ On Windows, release builds of the client open no console window, so command-line
|
||||||
output such as `--help` and logs isn't shown. Use a debug build to see it.
|
output such as `--help` and logs isn't shown. Use a debug build to see it.
|
||||||
|
|
||||||
The world autosaves periodically (in the background) and on exit (window close,
|
The world autosaves periodically (in the background) and on exit (window close,
|
||||||
Ctrl-C, SIGTERM). Characters are identified by name and are restored when that
|
Ctrl-C, SIGTERM).
|
||||||
name logs in again.
|
|
||||||
|
The client keeps a profile in `client.toml` in the platform config directory
|
||||||
|
(`~/.config/station/` on Linux, `%APPDATA%\station\config\` on Windows,
|
||||||
|
`~/Library/Application Support/station/` on macOS). It holds a random player
|
||||||
|
id, the last character name and the last server address. Characters belong to
|
||||||
|
the player id: logging in again restores your character, and its name is fixed
|
||||||
|
once it exists. Hosting a world uses the server settings in `host.toml` in the
|
||||||
|
same directory, except that the port and password come from the menu or the
|
||||||
|
command line.
|
||||||
|
|
||||||
### Save games and databases
|
### Save games and databases
|
||||||
|
|
||||||
|
|
@ -92,19 +109,110 @@ config set autosave_secs 30
|
||||||
stop
|
stop
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Passwords, admins and bans
|
||||||
|
|
||||||
|
A server with a `password` only accepts players who send it. Admins are listed
|
||||||
|
by player id or character name in `access.admins` (`--admin ID` adds one for a
|
||||||
|
run). The player who hosts a world from the client is always admin and may stop
|
||||||
|
it; in singleplayer you are always admin. With `access.whitelist_enabled`, only
|
||||||
|
players in `access.whitelist` (and admins) may join. Banned ids and names in
|
||||||
|
`access.banned` are rejected.
|
||||||
|
|
||||||
|
Commands are typed into the server terminal or, prefixed with `/`, into the
|
||||||
|
in-game chat. Admin commands change `server.toml` (or `host.toml`) immediately.
|
||||||
|
|
||||||
|
| Command | Who | Action |
|
||||||
|
|--------------------------------|----------|---------------------------------------------|
|
||||||
|
| `help`, `players` | everyone | List commands / online players |
|
||||||
|
| `say MESSAGE` | admins | Message all players |
|
||||||
|
| `kick PLAYER [REASON]` | admins | Disconnect a player |
|
||||||
|
| `ban PLAYER`, `unban PLAYER`, `bans` | admins | Ban by name or id (kicks if online), lift or list bans |
|
||||||
|
| `whitelist on\|off\|list` | admins | Toggle or show the whitelist |
|
||||||
|
| `whitelist add\|remove PLAYER` | admins | Edit the whitelist |
|
||||||
|
| `admin add\|remove PLAYER`, `admin list` | admins | Grant, revoke or list admin rights |
|
||||||
|
| `rejected` | admins | Recently rejected logins (with player ids) |
|
||||||
|
| `save` | admins | Save the world now |
|
||||||
|
| `config get KEY`, `config set KEY VALUE` | admins | Read or change a setting, e.g. `config set motd Hi` |
|
||||||
|
| `stop` | console, host | Save and shut down |
|
||||||
|
|
||||||
|
> **Security:** the network transport is unencrypted and unauthenticated. The
|
||||||
|
> password is sent in plain text and player ids can be impersonated by anyone who
|
||||||
|
> knows them. Treat the password as a guard against casual visitors, not as
|
||||||
|
> real security, and don't reuse a valuable password.
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---------------------|------------------------------------------------------|
|
|---------------------|------------------------------------------------------|
|
||||||
| WASD / arrows | Move (you face the direction you move) |
|
| WASD / arrows | Move (you face the direction you move) |
|
||||||
| E | Pick up (own tile, otherwise the faced tile) |
|
| E | Pick up (own tile, otherwise the faced tile) |
|
||||||
| F / left click | Interact with the faced / clicked building |
|
| F / left click | Interact with the faced / clicked building; machines open their window |
|
||||||
| U | Use the selected item (eat, heal, …) |
|
| U | Use the selected item (eat, heal, …) |
|
||||||
| G | Drop the selected item |
|
| G | Drop the selected item |
|
||||||
| X | Deconstruct the faced building |
|
| X | Deconstruct the faced building |
|
||||||
| 1–9, 0, mouse wheel | Select an inventory slot |
|
| 1–9, 0, mouse wheel | Select an inventory slot (or click it) |
|
||||||
| C / B | Crafting / build menu (W/S select, Enter confirm, Esc close) |
|
| C / B | Crafting / build menu (W/S select, Enter confirm, or click) |
|
||||||
| H | Help |
|
| H | Help |
|
||||||
|
| Enter / T | Chat; `/` starts a command |
|
||||||
|
| Esc | Close the open window, otherwise pause menu (leave world, quit) |
|
||||||
|
|
||||||
|
In a machine window, click an empty input slot to insert the selected inventory
|
||||||
|
item and click a filled slot to take its items. Hover over items, recipes and
|
||||||
|
buttons for tooltips.
|
||||||
|
|
||||||
|
## User interface
|
||||||
|
|
||||||
|
The menus and the HUD are built from layout files in `assets/ui/` and can be
|
||||||
|
restyled or rearranged without recompiling:
|
||||||
|
|
||||||
|
| File | Screen |
|
||||||
|
|--------------------|------------------------------------------------|
|
||||||
|
| `theme.rsn` | Font sizes, spacing and named colors used by all layouts |
|
||||||
|
| `main_menu.rsn`, `new_game.rsn`, `load_game.rsn`, `connect.rsn`, `connecting.rsn` | Start menu screens |
|
||||||
|
| `hud.rsn` | In-game HUD, menus, chat and pause overlay |
|
||||||
|
|
||||||
|
A layout has one `root` node. Nodes:
|
||||||
|
|
||||||
|
| Node | Fields |
|
||||||
|
|------------------------------|------------------------------------------------------------|
|
||||||
|
| `Column {..}`, `Row {..}`, `Panel {..}` | `children`, `gap`, `padding`, `align` (`Start`, `Center`, `End`, `Stretch`), `justify` (`Start`, `Center`, `End`, `SpaceBetween`), `width`/`height` (`Auto`, `Px(n)`, `Percent(n)`, `Fill`), `anchor` (`TopLeft` … `BottomRight`, relative to the screen), `offset: (x, y)`, `background`, `border`, `radius` |
|
||||||
|
| `Text {..}` | `text`, `size`, `color`, `max_width` |
|
||||||
|
| `Button {..}` | `text`, `action`, `width` |
|
||||||
|
| `Input {..}` | `id`, `value`, `placeholder`, `password`, `max_len`, `width`, `submit` (action on Enter) |
|
||||||
|
| `Checkbox {..}` | `id`, `text`, `checked` |
|
||||||
|
| `Image {..}` | `path` (PNG below `assets/`), `width`, `height` |
|
||||||
|
| `Spacer` | Takes up the remaining space |
|
||||||
|
| `Widget("name")` | Content generated by the game |
|
||||||
|
|
||||||
|
Every node except `Spacer` and `Widget` also accepts `tooltip` and
|
||||||
|
`visible`. `visible` names a binding or form field and shows the node only
|
||||||
|
while it is set; prefix it with `!` to invert it. Colors are `#rrggbb`,
|
||||||
|
`#rrggbbaa` or a theme color name. Texts, tooltips and input values may contain
|
||||||
|
`{binding}` placeholders:
|
||||||
|
|
||||||
|
```rsn
|
||||||
|
Panel {
|
||||||
|
anchor: TopLeft,
|
||||||
|
offset: (10, 10),
|
||||||
|
children: [
|
||||||
|
Text { text: "{character}", color: "accent", tooltip: "Playing on {server}" },
|
||||||
|
Text { text: "You are hungry!", color: "error", visible: "hungry" },
|
||||||
|
Button { text: "Craft", action: "menu:craft", tooltip: "Crafting (C)" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Bindings:** menu: `message`, `profile_name`, `last_address`,
|
||||||
|
`default_port`, `saves_dir`, `save_count`, `has_saves`, `suggested_world`,
|
||||||
|
`version`, `status`. HUD: `character`, `server`, `admin`, `health`,
|
||||||
|
`max_health`, `nutrition`, `hungry`, `position`, `craft_open`, `build_open`,
|
||||||
|
`help_open`, `chat_open`, `paused`.
|
||||||
|
- **Widgets:** `save_list` (menu); `inventory`, `skills`, `log`, `craft_list`,
|
||||||
|
`build_list` (HUD).
|
||||||
|
- **Actions:** `screen:main|new_game|load_game|connect`, `new_game`, `join`,
|
||||||
|
`quit` (menu); `menu:craft|build|help|close`, `chat_send`, `resume`, `leave`,
|
||||||
|
`quit` (HUD). The menu forms read the inputs `world`, `name`, `address`,
|
||||||
|
`password`, `port` and the checkbox `host`; the HUD chat input is `chat`.
|
||||||
|
|
||||||
## Modding
|
## Modding
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = { workspace = true }
|
||||||
bevy_replicon_renet = { workspace = true }
|
bevy_replicon_renet = { workspace = true }
|
||||||
directories = { workspace = true }
|
directories = { workspace = true }
|
||||||
|
rsn = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
|
||||||
|
|
@ -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 game_server::actions::REACH;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
hud::{Menu, UiState, activate_entry, menu_entries},
|
||||||
|
machine::{InteractAt, OpenMachine},
|
||||||
render::LocalCharacter,
|
render::LocalCharacter,
|
||||||
ui::{Menu, UiState, menu_entries},
|
session::AppState,
|
||||||
|
ui::{TextEditSystems, UiFocus, pointer_over_ui},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct InputPlugin;
|
pub struct InputPlugin;
|
||||||
|
|
||||||
impl Plugin for InputPlugin {
|
impl Plugin for InputPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_systems(Update, (movement, menu_input, actions, mouse).chain());
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
window_keys,
|
||||||
|
movement,
|
||||||
|
(menu_input, actions, mouse).run_if(game_input_allowed),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(TextEditSystems)
|
||||||
|
.run_if(in_state(AppState::InGame)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn game_input_allowed(focus: Res<UiFocus>, ui: Res<UiState>) -> bool {
|
||||||
|
!focus.captures_keyboard() && !ui.paused && !ui.chat_open
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Esc closes the innermost window or toggles the pause menu; Enter/T/`/` open the chat.
|
||||||
|
fn window_keys(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
focus: Res<UiFocus>,
|
||||||
|
mut ui: ResMut<UiState>,
|
||||||
|
mut machine: ResMut<OpenMachine>,
|
||||||
|
mut form: ResMut<crate::ui::UiForm>,
|
||||||
|
) {
|
||||||
|
if focus.captures_keyboard() || ui.chat_open {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if keys.just_pressed(KeyCode::Escape) {
|
||||||
|
if machine.0.is_some() {
|
||||||
|
machine.0 = None;
|
||||||
|
} else if ui.menu.is_some() {
|
||||||
|
ui.menu = None;
|
||||||
|
} else {
|
||||||
|
ui.paused = !ui.paused;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ui.paused || ui.menu.is_some_and(|m| m != Menu::Help) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if keys.any_just_pressed([KeyCode::Enter, KeyCode::NumpadEnter, KeyCode::KeyT]) {
|
||||||
|
ui.chat_open = true;
|
||||||
|
} else if keys.any_just_pressed([KeyCode::Slash, KeyCode::NumpadDivide]) {
|
||||||
|
form.0.insert("chat".into(), "/".into());
|
||||||
|
ui.chat_open = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,6 +88,7 @@ fn movement(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
ui: Res<UiState>,
|
ui: Res<UiState>,
|
||||||
|
focus: Res<UiFocus>,
|
||||||
mut held: Local<Vec<Dir>>,
|
mut held: Local<Vec<Dir>>,
|
||||||
mut sent: Local<Option<Dir>>,
|
mut sent: Local<Option<Dir>>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -52,7 +102,7 @@ fn movement(
|
||||||
held.push(dir);
|
held.push(dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let dir = if ui.menu.is_some_and(|m| m != Menu::Help) {
|
let dir = if ui.blocks_game() || ui.chat_open || focus.captures_keyboard() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
held.last().copied()
|
held.last().copied()
|
||||||
|
|
@ -80,10 +130,6 @@ fn menu_input(
|
||||||
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
|
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
|
||||||
) {
|
) {
|
||||||
let Some(menu) = ui.menu else { return };
|
let Some(menu) = ui.menu else { return };
|
||||||
if keys.just_pressed(KeyCode::Escape) {
|
|
||||||
ui.menu = None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let entries = menu_entries(menu, &data);
|
let entries = menu_entries(menu, &data);
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
return;
|
return;
|
||||||
|
|
@ -96,17 +142,8 @@ fn menu_input(
|
||||||
}
|
}
|
||||||
if keys.any_just_pressed([KeyCode::Enter, KeyCode::Space, KeyCode::NumpadEnter]) {
|
if keys.any_just_pressed([KeyCode::Enter, KeyCode::Space, KeyCode::NumpadEnter]) {
|
||||||
let id = entries[ui.cursor.min(entries.len() - 1)].clone();
|
let id = entries[ui.cursor.min(entries.len() - 1)].clone();
|
||||||
match menu {
|
|
||||||
Menu::Craft => commands.client_trigger(Craft { recipe: id }),
|
|
||||||
Menu::Build => {
|
|
||||||
if let Some((pos, facing, _)) = local_state(&local, &characters) {
|
if let Some((pos, facing, _)) = local_state(&local, &characters) {
|
||||||
commands.client_trigger(Construct {
|
activate_entry(&mut commands, menu, id, pos, facing);
|
||||||
building: id,
|
|
||||||
target: pos.step(facing),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Menu::Help => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -130,6 +167,7 @@ fn actions(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
mut wheel: MessageReader<MouseWheel>,
|
mut wheel: MessageReader<MouseWheel>,
|
||||||
mut ui: ResMut<UiState>,
|
mut ui: ResMut<UiState>,
|
||||||
|
machine: Res<OpenMachine>,
|
||||||
local: Res<LocalCharacter>,
|
local: Res<LocalCharacter>,
|
||||||
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
|
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
|
||||||
items: Query<&GridPos, With<GroundItem>>,
|
items: Query<&GridPos, With<GroundItem>>,
|
||||||
|
|
@ -151,9 +189,16 @@ fn actions(
|
||||||
ui.selected_slot = i;
|
ui.selected_slot = i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for ev in wheel.read() {
|
let wheel_events: Vec<f32> = wheel.read().map(|ev| ev.y).collect();
|
||||||
|
// Open windows have their own list navigation.
|
||||||
|
let wheel_events = if ui.menu.is_some() || machine.0.is_some() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
wheel_events
|
||||||
|
};
|
||||||
|
for y in wheel_events {
|
||||||
let len = INVENTORY_SLOTS;
|
let len = INVENTORY_SLOTS;
|
||||||
ui.selected_slot = if ev.y > 0.0 {
|
ui.selected_slot = if y > 0.0 {
|
||||||
(ui.selected_slot + len - 1) % len
|
(ui.selected_slot + len - 1) % len
|
||||||
} else {
|
} else {
|
||||||
(ui.selected_slot + 1) % len
|
(ui.selected_slot + 1) % len
|
||||||
|
|
@ -178,7 +223,7 @@ fn actions(
|
||||||
commands.client_trigger(PickUp { target });
|
commands.client_trigger(PickUp { target });
|
||||||
}
|
}
|
||||||
if keys.just_pressed(KeyCode::KeyF) {
|
if keys.just_pressed(KeyCode::KeyF) {
|
||||||
commands.client_trigger(Interact { target: front });
|
commands.trigger(InteractAt(front));
|
||||||
}
|
}
|
||||||
if keys.just_pressed(KeyCode::KeyX) {
|
if keys.just_pressed(KeyCode::KeyX) {
|
||||||
commands.client_trigger(Deconstruct { target: front });
|
commands.client_trigger(Deconstruct { target: front });
|
||||||
|
|
@ -196,16 +241,22 @@ fn actions(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Left click on a tile within reach: interact with a building, otherwise pick up items.
|
/// Left click on a tile within reach: interact with a building, otherwise pick up items.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn mouse(
|
fn mouse(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
buttons: Res<ButtonInput<MouseButton>>,
|
buttons: Res<ButtonInput<MouseButton>>,
|
||||||
|
interactions: Query<&Interaction>,
|
||||||
|
ui: Res<UiState>,
|
||||||
window: Query<&Window, With<PrimaryWindow>>,
|
window: Query<&Window, With<PrimaryWindow>>,
|
||||||
camera: Query<(&Camera, &GlobalTransform)>,
|
camera: Query<(&Camera, &GlobalTransform)>,
|
||||||
local: Res<LocalCharacter>,
|
local: Res<LocalCharacter>,
|
||||||
characters: Query<&GridPos, With<Character>>,
|
characters: Query<&GridPos, With<Character>>,
|
||||||
buildings: Query<&GridPos, With<Building>>,
|
buildings: Query<&GridPos, With<Building>>,
|
||||||
) {
|
) {
|
||||||
if !buttons.just_pressed(MouseButton::Left) {
|
if !buttons.just_pressed(MouseButton::Left)
|
||||||
|
|| ui.blocks_game()
|
||||||
|
|| pointer_over_ui(&interactions)
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(me) = local.0.and_then(|e| characters.get(e).ok()) else {
|
let Some(me) = local.0.and_then(|e| characters.get(e).ok()) else {
|
||||||
|
|
@ -228,7 +279,7 @@ fn mouse(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if buildings.iter().any(|p| *p == target) {
|
if buildings.iter().any(|p| *p == target) {
|
||||||
commands.client_trigger(Interact { target });
|
commands.trigger(InteractAt(target));
|
||||||
} else {
|
} else {
|
||||||
commands.client_trigger(PickUp { target });
|
commands.client_trigger(PickUp { target });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
//! Game client.
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! game singleplayer (persistent local world)
|
//! game start menu
|
||||||
//! game --host [PORT] singleplayer world that friends can join
|
//! game --save NAME play a local world directly
|
||||||
|
//! game --host [PORT] play a local world that friends can join
|
||||||
//! game --connect ADDR[:PORT] join a dedicated server or host
|
//! game --connect ADDR[:PORT] join a dedicated server or host
|
||||||
//! ```
|
//! ```
|
||||||
//! Common options: `--name NAME`, `--save NAME`, `--map MAP`.
|
//! Common options: `--name NAME`, `--password PASSWORD`, `--map MAP`, `--no-save`.
|
||||||
|
|
||||||
// Release builds on Windows run without a console window.
|
// Release builds on Windows run without a console window.
|
||||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod hud;
|
||||||
mod input;
|
mod input;
|
||||||
mod net;
|
mod machine;
|
||||||
|
mod menu;
|
||||||
|
mod profile;
|
||||||
mod render;
|
mod render;
|
||||||
mod saves;
|
mod saves;
|
||||||
|
mod session;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
|
|
||||||
use bevy::{prelude::*, window::WindowResolution};
|
use bevy::{prelude::*, window::WindowResolution};
|
||||||
use bevy_replicon::prelude::*;
|
use bevy_replicon::prelude::*;
|
||||||
use bevy_replicon_renet::RepliconRenetPlugins;
|
use bevy_replicon_renet::RepliconRenetPlugins;
|
||||||
use game_core::CorePlugin;
|
use game_core::CorePlugin;
|
||||||
use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings, config::ServerConfig};
|
|
||||||
|
|
||||||
/// How this client runs.
|
use crate::{
|
||||||
#[derive(Clone, Debug)]
|
profile::Profile,
|
||||||
pub enum Mode {
|
session::{AppState, SessionRequest, SessionTarget},
|
||||||
/// Server logic runs in-process without networking.
|
};
|
||||||
Singleplayer,
|
|
||||||
/// Server logic runs in-process and accepts remote players.
|
|
||||||
Host { port: u16 },
|
|
||||||
/// Pure client connected to a remote server.
|
|
||||||
Join { addr: SocketAddr },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The local player's login name, used to find our character among replicated ones.
|
|
||||||
#[derive(Resource, Clone, Debug)]
|
|
||||||
pub struct LocalPlayer {
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Resource, Clone, Debug)]
|
|
||||||
pub struct ClientMode(pub Mode);
|
|
||||||
|
|
||||||
fn main() -> AppExit {
|
fn main() -> AppExit {
|
||||||
let (mode, name, settings) = parse_args();
|
let mut profile = Profile::load();
|
||||||
|
let (request, map) = parse_args(&mut profile);
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
let assets = game_core::assets_dir();
|
let assets = game_core::assets_dir();
|
||||||
app.add_plugins((
|
app.add_plugins((
|
||||||
|
|
@ -57,7 +45,7 @@ fn main() -> AppExit {
|
||||||
.set(ImagePlugin::default_nearest())
|
.set(ImagePlugin::default_nearest())
|
||||||
.set(WindowPlugin {
|
.set(WindowPlugin {
|
||||||
primary_window: Some(Window {
|
primary_window: Some(Window {
|
||||||
title: format!("Station - {name}"),
|
title: "Station".to_owned(),
|
||||||
resolution: WindowResolution::new(1280, 800),
|
resolution: WindowResolution::new(1280, 800),
|
||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
|
|
@ -66,48 +54,36 @@ fn main() -> AppExit {
|
||||||
RepliconPlugins,
|
RepliconPlugins,
|
||||||
RepliconRenetPlugins,
|
RepliconRenetPlugins,
|
||||||
CorePlugin,
|
CorePlugin,
|
||||||
));
|
))
|
||||||
|
.insert_resource(profile)
|
||||||
match &mode {
|
.insert_resource(menu::StartMap(map))
|
||||||
Mode::Singleplayer => {
|
|
||||||
app.add_plugins(GameServerPlugin { settings });
|
|
||||||
}
|
|
||||||
Mode::Host { port } => {
|
|
||||||
app.add_plugins((
|
|
||||||
GameServerPlugin { settings },
|
|
||||||
NetworkServerPlugin {
|
|
||||||
config: ServerConfig {
|
|
||||||
port: *port,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Mode::Join { .. } => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.insert_resource(LocalPlayer { name })
|
|
||||||
.insert_resource(ClientMode(mode))
|
|
||||||
.insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.05)))
|
.insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.05)))
|
||||||
.add_plugins((
|
.add_plugins((
|
||||||
net::NetPlugin,
|
ui::UiFrameworkPlugin,
|
||||||
|
session::SessionPlugin,
|
||||||
|
menu::MenuPlugin,
|
||||||
|
hud::HudPlugin,
|
||||||
|
machine::MachinePlugin,
|
||||||
render::RenderPlugin,
|
render::RenderPlugin,
|
||||||
input::InputPlugin,
|
input::InputPlugin,
|
||||||
ui::UiPlugin,
|
));
|
||||||
))
|
if let Some(request) = request {
|
||||||
.run()
|
app.insert_resource(request);
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<NextState<AppState>>()
|
||||||
|
.set(AppState::Connecting);
|
||||||
|
}
|
||||||
|
app.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args() -> (Mode, String, ServerSettings) {
|
/// Returns the session to start right away (skipping the menu), if any, and the map for new worlds.
|
||||||
let mut mode = Mode::Singleplayer;
|
fn parse_args(profile: &mut Profile) -> (Option<SessionRequest>, String) {
|
||||||
let mut name = std::env::var("USER")
|
let mut host = None;
|
||||||
.ok()
|
let mut connect = None;
|
||||||
.filter(|n| game_server::players::valid_name(n))
|
let mut save = None;
|
||||||
.unwrap_or_else(|| "Player".to_owned());
|
let mut no_save = false;
|
||||||
let mut settings = ServerSettings {
|
let mut password = String::new();
|
||||||
storage: Some(saves::storage("singleplayer")),
|
let mut map = game_core::DEFAULT_MAP.to_owned();
|
||||||
..default()
|
|
||||||
};
|
|
||||||
let mut args = std::env::args().skip(1).peekable();
|
let mut args = std::env::args().skip(1).peekable();
|
||||||
while let Some(arg) = args.next() {
|
while let Some(arg) = args.next() {
|
||||||
let mut value = |what: &str| {
|
let mut value = |what: &str| {
|
||||||
|
|
@ -116,41 +92,49 @@ fn parse_args() -> (Mode, String, ServerSettings) {
|
||||||
};
|
};
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
"--host" => {
|
"--host" => {
|
||||||
let port = match args.peek() {
|
host = Some(match args.peek() {
|
||||||
Some(p) if !p.starts_with("--") => args
|
Some(p) if !p.starts_with("--") => args
|
||||||
.next()
|
.next()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap_or_else(|_| usage("invalid port")),
|
.unwrap_or_else(|_| usage("invalid port")),
|
||||||
_ => game_core::DEFAULT_PORT,
|
_ => game_core::DEFAULT_PORT,
|
||||||
};
|
});
|
||||||
mode = Mode::Host { port };
|
|
||||||
}
|
}
|
||||||
"--connect" => {
|
"--connect" => connect = Some(value("an address")),
|
||||||
let addr = value("an address");
|
"--name" => profile.name = value("a name"),
|
||||||
let addr = if addr.contains(':') {
|
"--password" => password = value("a password"),
|
||||||
addr
|
"--save" => save = Some(value("a save name")),
|
||||||
} else {
|
"--no-save" => no_save = true,
|
||||||
format!("{addr}:{}", game_core::DEFAULT_PORT)
|
"--map" => map = value("a map path"),
|
||||||
};
|
|
||||||
let addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
|
|
||||||
.ok()
|
|
||||||
.and_then(|mut a| a.next())
|
|
||||||
.unwrap_or_else(|| usage(&format!("can't resolve `{addr}`")));
|
|
||||||
mode = Mode::Join { addr };
|
|
||||||
}
|
|
||||||
"--name" => name = value("a name"),
|
|
||||||
"--save" => settings.storage = Some(saves::storage(&value("a save name"))),
|
|
||||||
"--no-save" => settings.storage = None,
|
|
||||||
"--map" => settings.map = value("a map path"),
|
|
||||||
"-h" | "--help" => usage(""),
|
"-h" | "--help" => usage(""),
|
||||||
other => usage(&format!("unknown argument `{other}`")),
|
other => usage(&format!("unknown argument `{other}`")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !game_server::players::valid_name(&name) {
|
if !game_server::players::valid_name(&profile.name) {
|
||||||
usage("names may only contain letters, digits, spaces, `_` and `-` (max 24)");
|
usage("names may only contain letters, digits, spaces, `_` and `-` (max 24)");
|
||||||
}
|
}
|
||||||
(mode, name, settings)
|
let target = match (connect, host) {
|
||||||
|
(Some(_), Some(_)) => usage("--connect and --host can't be combined"),
|
||||||
|
(Some(address), None) => {
|
||||||
|
if save.is_some() || no_save {
|
||||||
|
usage("--save and --no-save only apply to local worlds");
|
||||||
|
}
|
||||||
|
SessionTarget::Remote { address }
|
||||||
|
}
|
||||||
|
(None, host) if host.is_some() || save.is_some() || no_save => SessionTarget::Local {
|
||||||
|
storage: (!no_save).then(|| saves::storage(save.as_deref().unwrap_or("singleplayer"))),
|
||||||
|
map: map.clone(),
|
||||||
|
host: host.map(|port| (port, password.clone())),
|
||||||
|
},
|
||||||
|
_ => return (None, map),
|
||||||
|
};
|
||||||
|
let request = SessionRequest {
|
||||||
|
target,
|
||||||
|
name: profile.name.clone(),
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
(Some(request), map)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn usage(error: &str) -> ! {
|
fn usage(error: &str) -> ! {
|
||||||
|
|
@ -158,12 +142,15 @@ fn usage(error: &str) -> ! {
|
||||||
eprintln!("error: {error}\n");
|
eprintln!("error: {error}\n");
|
||||||
}
|
}
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"usage: game [--host [PORT] | --connect ADDR[:PORT]] [--name NAME] [--save NAME | --no-save] [--map MAP]\n\n\
|
"usage: game [--save NAME | --no-save] [--host [PORT]] [--connect ADDR[:PORT]]\n \
|
||||||
(no mode) singleplayer, world saved as `singleplayer`\n\
|
[--name NAME] [--password PASSWORD] [--map MAP]\n\n\
|
||||||
--host play your world and let others join (default port {port})\n\
|
(no options) show the start menu\n\
|
||||||
|
--save play the local world NAME (created if missing), stored in {saves}\n\
|
||||||
|
--no-save play a local world that is not saved\n\
|
||||||
|
--host let others join your local world (default port {port}, password from --password)\n\
|
||||||
--connect join a server (default port {port})\n\
|
--connect join a server (default port {port})\n\
|
||||||
--name character name; characters persist per world\n\
|
--name character name; characters persist per world\n\
|
||||||
--save save game for singleplayer/host, stored in {saves}\n\
|
--password server password (joining) or password for others (hosting)\n\
|
||||||
--map Tiled map for new worlds (default {map})",
|
--map Tiled map for new worlds (default {map})",
|
||||||
port = game_core::DEFAULT_PORT,
|
port = game_core::DEFAULT_PORT,
|
||||||
map = game_core::DEFAULT_MAP,
|
map = game_core::DEFAULT_MAP,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
map::MapLayout,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::LocalPlayer;
|
use crate::session::SessionInfo;
|
||||||
|
|
||||||
pub struct RenderPlugin;
|
pub struct RenderPlugin;
|
||||||
|
|
||||||
|
|
@ -130,15 +130,19 @@ fn spawn_map(
|
||||||
|
|
||||||
fn find_local_character(
|
fn find_local_character(
|
||||||
mut local: ResMut<LocalCharacter>,
|
mut local: ResMut<LocalCharacter>,
|
||||||
player: Res<LocalPlayer>,
|
session: Res<SessionInfo>,
|
||||||
characters: Query<(Entity, &Character)>,
|
characters: Query<(Entity, &Character)>,
|
||||||
) {
|
) {
|
||||||
if local.0.is_some_and(|e| characters.contains(e)) {
|
if local.0.is_some_and(|e| characters.contains(e)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if session.character.is_empty() {
|
||||||
|
local.0 = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
local.0 = characters
|
local.0 = characters
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(_, c)| c.name == player.name)
|
.find(|(_, c)| c.name == session.character)
|
||||||
.map(|(e, _)| e);
|
.map(|(e, _)| e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
//! Local save games: one SQLite database per save in the platform's data directory
|
//! Local save games: one SQLite database per save in the platform's data directory
|
||||||
//! (e.g. `~/.local/share/station/saves` on Linux, `%APPDATA%\station\data\saves` on Windows).
|
//! (e.g. `~/.local/share/station/saves` on Linux, `%APPDATA%\station\data\saves` on Windows).
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use game_server::persistence::{StorageSettings, WorldSelector, sqlite_url};
|
use game_server::persistence::{Database, StorageSettings, WorldInfo, WorldSelector, sqlite_url};
|
||||||
|
|
||||||
/// Directory holding the save databases. Falls back to `./saves` if the platform has no home.
|
/// Directory holding the save databases. Falls back to `./saves` if the platform has no home.
|
||||||
pub fn saves_dir() -> PathBuf {
|
pub fn saves_dir() -> PathBuf {
|
||||||
|
|
@ -39,6 +39,103 @@ pub fn storage(name: &str) -> StorageSettings {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A world stored in a save file.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SaveEntry {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub world: WorldInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SaveEntry {
|
||||||
|
pub fn storage(&self) -> StorageSettings {
|
||||||
|
StorageSettings {
|
||||||
|
database_url: sqlite_url(&self.path),
|
||||||
|
world: WorldSelector::Id(self.world.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All worlds in the saves directory, most recently saved first.
|
||||||
|
pub fn list() -> Vec<SaveEntry> {
|
||||||
|
let Ok(dir) = std::fs::read_dir(saves_dir()) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let files: Vec<PathBuf> = dir
|
||||||
|
.filter_map(|e| e.ok().map(|e| e.path()))
|
||||||
|
.filter(|p| p.extension().is_some_and(|e| e == "sqlite"))
|
||||||
|
.collect();
|
||||||
|
let runtime = game_server::persistence::runtime();
|
||||||
|
let mut saves = Vec::new();
|
||||||
|
for path in files {
|
||||||
|
let worlds = runtime.block_on(async {
|
||||||
|
let db = Database::connect(&sqlite_url(&path)).await?;
|
||||||
|
let worlds = db.list_worlds().await;
|
||||||
|
db.close().await;
|
||||||
|
worlds
|
||||||
|
});
|
||||||
|
match worlds {
|
||||||
|
Ok(worlds) => saves.extend(worlds.into_iter().map(|world| SaveEntry {
|
||||||
|
path: path.clone(),
|
||||||
|
world,
|
||||||
|
})),
|
||||||
|
Err(e) => bevy::log::warn!("skipping save {}: {e}", path.display()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saves.sort_by_key(|s| std::cmp::Reverse(s.world.saved_at));
|
||||||
|
saves
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a save called `name` exists.
|
||||||
|
pub fn exists(name: &str) -> bool {
|
||||||
|
let path = saves_dir().join(format!("{}.sqlite", slug(name)));
|
||||||
|
path.exists()
|
||||||
|
&& list()
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.path == path && s.world.name == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a world, and its file once no other world is left in it.
|
||||||
|
pub fn delete(save: &SaveEntry) -> Result<(), String> {
|
||||||
|
let runtime = game_server::persistence::runtime();
|
||||||
|
let remaining = runtime
|
||||||
|
.block_on(async {
|
||||||
|
let db = Database::connect(&sqlite_url(&save.path)).await?;
|
||||||
|
db.delete_world(save.world.id).await?;
|
||||||
|
let remaining = db.list_worlds().await?.len();
|
||||||
|
db.close().await;
|
||||||
|
Ok::<_, game_server::persistence::StorageError>(remaining)
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if remaining == 0 {
|
||||||
|
remove_database(&save.path).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_database(path: &Path) -> std::io::Result<()> {
|
||||||
|
std::fs::remove_file(path)?;
|
||||||
|
for suffix in ["-wal", "-shm"] {
|
||||||
|
let mut extra = path.as_os_str().to_owned();
|
||||||
|
extra.push(suffix);
|
||||||
|
let _ = std::fs::remove_file(extra);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human readable age of a unix timestamp.
|
||||||
|
pub fn age(timestamp: i64) -> String {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |d| d.as_secs() as i64);
|
||||||
|
let secs = (now - timestamp).max(0);
|
||||||
|
match secs {
|
||||||
|
0..60 => "just now".into(),
|
||||||
|
60..3600 => format!("{} min ago", secs / 60),
|
||||||
|
3600..86400 => format!("{} h ago", secs / 3600),
|
||||||
|
_ => format!("{} days ago", secs / 86400),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -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 struct ItemStack {
|
||||||
pub item: String,
|
pub item: String,
|
||||||
pub count: u32,
|
pub count: u32,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ pub use data::GameData;
|
||||||
pub const DEFAULT_MAP: &str = "maps/station.tmx";
|
pub const DEFAULT_MAP: &str = "maps/station.tmx";
|
||||||
pub const DEFAULT_PORT: u16 = 5000;
|
pub const DEFAULT_PORT: u16 = 5000;
|
||||||
/// Bump when the protocol changes in incompatible ways.
|
/// Bump when the protocol changes in incompatible ways.
|
||||||
pub const PROTOCOL_ID: u64 = 2;
|
pub const PROTOCOL_ID: u64 = 3;
|
||||||
|
|
||||||
/// Locates the `assets` directory.
|
/// Locates the `assets` directory.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,13 @@ use crate::{components::*, grid::Dir};
|
||||||
/// First event a client sends; selects (or creates) the persistent character.
|
/// First event a client sends; selects (or creates) the persistent character.
|
||||||
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Login {
|
pub struct Login {
|
||||||
|
/// UUID identifying the player across sessions. Each player id owns one
|
||||||
|
/// character per world.
|
||||||
|
pub player_id: String,
|
||||||
|
/// Name for a new character; ignored when the player already has one.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Server password, empty if none.
|
||||||
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Direction the player is holding, `None` to stop.
|
/// Direction the player is holding, `None` to stop.
|
||||||
|
|
@ -75,12 +81,40 @@ pub struct MachineTake {
|
||||||
pub count: u32,
|
pub count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A chat message, or a command if it starts with `/`.
|
||||||
|
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Chat {
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Text shown in the receiving player's message log.
|
/// Text shown in the receiving player's message log.
|
||||||
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct GameLog {
|
pub struct GameLog {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Login accepted.
|
||||||
|
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Welcome {
|
||||||
|
pub server_name: String,
|
||||||
|
pub motd: String,
|
||||||
|
/// Name of the player's character.
|
||||||
|
pub character: String,
|
||||||
|
pub admin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server is about to close the connection (login rejected, kicked, banned).
|
||||||
|
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Rejected {
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The player's admin rights changed.
|
||||||
|
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AdminStatus {
|
||||||
|
pub admin: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ProtocolPlugin;
|
pub struct ProtocolPlugin;
|
||||||
|
|
||||||
impl Plugin for ProtocolPlugin {
|
impl Plugin for ProtocolPlugin {
|
||||||
|
|
@ -107,6 +141,10 @@ impl Plugin for ProtocolPlugin {
|
||||||
.add_client_event::<Deconstruct>(Channel::Ordered)
|
.add_client_event::<Deconstruct>(Channel::Ordered)
|
||||||
.add_client_event::<MachineInsert>(Channel::Ordered)
|
.add_client_event::<MachineInsert>(Channel::Ordered)
|
||||||
.add_client_event::<MachineTake>(Channel::Ordered)
|
.add_client_event::<MachineTake>(Channel::Ordered)
|
||||||
.add_server_event::<GameLog>(Channel::Ordered);
|
.add_client_event::<Chat>(Channel::Ordered)
|
||||||
|
.add_server_event::<GameLog>(Channel::Ordered)
|
||||||
|
.add_server_event::<Welcome>(Channel::Ordered)
|
||||||
|
.add_server_event::<Rejected>(Channel::Ordered)
|
||||||
|
.add_server_event::<AdminStatus>(Channel::Ordered);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,170 @@
|
||||||
//! Server commands, typed into the server console.
|
//! Server commands, typed into the server console or sent by players as
|
||||||
|
//! `/command` chat messages.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_replicon::prelude::*;
|
use bevy_replicon::prelude::*;
|
||||||
use game_core::{components::Character, protocol::GameLog};
|
use game_core::{components::Character, protocol::GameLog};
|
||||||
|
|
||||||
use crate::{config, persistence, players::Players};
|
use crate::{
|
||||||
|
config, persistence,
|
||||||
|
players::{self, Controller, PlayerId, Players, RejectedLogins},
|
||||||
|
};
|
||||||
|
|
||||||
/// Who issued a command.
|
/// Who issued a command.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum CommandSource {
|
pub enum CommandSource {
|
||||||
/// The dedicated server's terminal.
|
/// The dedicated server's terminal.
|
||||||
Console,
|
Console,
|
||||||
|
Player {
|
||||||
|
client: ClientId,
|
||||||
|
admin: bool,
|
||||||
|
/// Host of the embedded server; may also stop it.
|
||||||
|
owner: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const HELP: &[(&str, &str)] = &[
|
impl CommandSource {
|
||||||
("help", "list commands"),
|
fn is_admin(self) -> bool {
|
||||||
("players", "list online players"),
|
match self {
|
||||||
("say MESSAGE", "message all players"),
|
Self::Console => true,
|
||||||
("save", "save the world now"),
|
Self::Player { admin, owner, .. } => admin || owner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn may_stop(self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Console => true,
|
||||||
|
Self::Player { owner, .. } => owner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Permission {
|
||||||
|
Everyone,
|
||||||
|
Admin,
|
||||||
|
Stop,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Usage, description and who may run it.
|
||||||
|
const COMMANDS: &[(&str, &str, Permission)] = &[
|
||||||
|
("help", "list commands", Permission::Everyone),
|
||||||
|
("players", "list online players", Permission::Everyone),
|
||||||
|
("say MESSAGE", "message all players", Permission::Admin),
|
||||||
|
(
|
||||||
|
"kick PLAYER [REASON]",
|
||||||
|
"disconnect a player",
|
||||||
|
Permission::Admin,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ban PLAYER",
|
||||||
|
"ban a player by name or id",
|
||||||
|
Permission::Admin,
|
||||||
|
),
|
||||||
|
("unban PLAYER", "lift a ban", Permission::Admin),
|
||||||
|
("bans", "list banned players", Permission::Admin),
|
||||||
|
(
|
||||||
|
"whitelist on|off|list",
|
||||||
|
"toggle or show the whitelist",
|
||||||
|
Permission::Admin,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"whitelist add|remove PLAYER",
|
||||||
|
"edit the whitelist",
|
||||||
|
Permission::Admin,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"admin add|remove PLAYER",
|
||||||
|
"grant or revoke admin rights",
|
||||||
|
Permission::Admin,
|
||||||
|
),
|
||||||
|
("admin list", "list admins", Permission::Admin),
|
||||||
|
("rejected", "recently rejected logins", Permission::Admin),
|
||||||
|
("save", "save the world now", Permission::Admin),
|
||||||
(
|
(
|
||||||
"config get KEY",
|
"config get KEY",
|
||||||
"show a setting, e.g. `config get access.whitelist`",
|
"show a setting, e.g. `config get access.whitelist`",
|
||||||
|
Permission::Admin,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"config set KEY VALUE",
|
"config set KEY VALUE",
|
||||||
"change a setting and write it to the config file",
|
"change a setting and write it to the config file",
|
||||||
|
Permission::Admin,
|
||||||
),
|
),
|
||||||
("stop", "save and shut down the server"),
|
("stop", "save and shut down the server", Permission::Stop),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Usage and description of the commands available to `source`.
|
||||||
|
pub fn help(source: CommandSource) -> Vec<(&'static str, &'static str)> {
|
||||||
|
COMMANDS
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, _, p)| allowed(source, *p))
|
||||||
|
.map(|(usage, what, _)| (*usage, *what))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed(source: CommandSource, permission: Permission) -> bool {
|
||||||
|
match permission {
|
||||||
|
Permission::Everyone => true,
|
||||||
|
Permission::Admin => source.is_admin(),
|
||||||
|
Permission::Stop => source.may_stop(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn permission(command: &str) -> Option<Permission> {
|
||||||
|
COMMANDS
|
||||||
|
.iter()
|
||||||
|
.find(|(usage, _, _)| usage.split(' ').next() == Some(command))
|
||||||
|
.map(|(_, _, p)| *p)
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs a command line and returns the reply.
|
/// Runs a command line and returns the reply.
|
||||||
pub fn execute(world: &mut World, _source: CommandSource, line: &str) -> Vec<String> {
|
pub fn execute(world: &mut World, source: CommandSource, line: &str) -> Vec<String> {
|
||||||
let line = line.trim().trim_start_matches('/');
|
let line = line.trim().trim_start_matches('/');
|
||||||
let (command, rest) = line.split_once(' ').unwrap_or((line, ""));
|
let (command, rest) = line.split_once(' ').unwrap_or((line, ""));
|
||||||
let rest = rest.trim();
|
let rest = rest.trim();
|
||||||
|
if command.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
match permission(command) {
|
||||||
|
None => {
|
||||||
|
return vec![format!(
|
||||||
|
"Unknown command `{command}`. Type `help` for a list."
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
Some(p) if !allowed(source, p) => {
|
||||||
|
return vec![format!("You are not allowed to use `{command}`.")];
|
||||||
|
}
|
||||||
|
Some(_) => {}
|
||||||
|
}
|
||||||
|
let actor = actor_name(world, source);
|
||||||
match command {
|
match command {
|
||||||
"" => vec![],
|
"help" => help(source)
|
||||||
"help" => HELP
|
.into_iter()
|
||||||
.iter()
|
.map(|(usage, what)| format!("{usage:<28} {what}"))
|
||||||
.map(|(usage, what)| format!("{usage:<22} {what}"))
|
|
||||||
.collect(),
|
.collect(),
|
||||||
"players" => {
|
"players" => {
|
||||||
let names: Vec<String> = world
|
let mut q = world.query::<(&Character, &PlayerId, Has<players::Admin>)>();
|
||||||
.query::<&Character>()
|
let mut names: Vec<String> = q
|
||||||
.iter(world)
|
.iter(world)
|
||||||
.map(|c| c.name.clone())
|
.map(|(c, id, admin)| {
|
||||||
|
let mut entry = c.name.clone();
|
||||||
|
if admin {
|
||||||
|
entry.push_str(" [admin]");
|
||||||
|
}
|
||||||
|
if source.is_admin() {
|
||||||
|
entry.push_str(&format!(" ({})", id.0));
|
||||||
|
}
|
||||||
|
entry
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let count = world.resource::<Players>().by_client.len();
|
names.sort();
|
||||||
vec![format!("{count} online: {}", names.join(", "))]
|
let mut reply = vec![format!("{} online", names.len())];
|
||||||
|
reply.extend(names);
|
||||||
|
reply
|
||||||
}
|
}
|
||||||
"say" if !rest.is_empty() => {
|
"say" if !rest.is_empty() => {
|
||||||
let text = format!("[server] {rest}");
|
let text = format!("[{actor}] {rest}");
|
||||||
world.commands().server_trigger(ToClients {
|
world.commands().server_trigger(ToClients {
|
||||||
targets: SendTargets::All,
|
targets: SendTargets::All,
|
||||||
message: GameLog { text: text.clone() },
|
message: GameLog { text: text.clone() },
|
||||||
|
|
@ -58,6 +172,35 @@ pub fn execute(world: &mut World, _source: CommandSource, line: &str) -> Vec<Str
|
||||||
world.flush();
|
world.flush();
|
||||||
vec![text]
|
vec![text]
|
||||||
}
|
}
|
||||||
|
"kick" if !rest.is_empty() => {
|
||||||
|
let (target, reason) = rest.split_once(' ').unwrap_or((rest, ""));
|
||||||
|
let reason = reason.trim();
|
||||||
|
kick(world, &actor, target, reason)
|
||||||
|
}
|
||||||
|
"ban" if !rest.is_empty() => {
|
||||||
|
let target = resolve(world, rest);
|
||||||
|
match list_edit(world, "access.banned", &target.entry, true) {
|
||||||
|
Ok(true) => {
|
||||||
|
let msg = format!("{actor} banned {}.", target.label);
|
||||||
|
notify(world, &msg);
|
||||||
|
vec![msg]
|
||||||
|
}
|
||||||
|
Ok(false) => vec![format!("{} is already banned.", target.label)],
|
||||||
|
Err(e) => vec![e],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"unban" if !rest.is_empty() => unlist(world, &actor, "access.banned", rest, "banned"),
|
||||||
|
"bans" => show_list(world, |c| &c.access.banned, "Banned"),
|
||||||
|
"whitelist" => whitelist_command(world, &actor, rest),
|
||||||
|
"admin" => admin_command(world, &actor, rest),
|
||||||
|
"rejected" => {
|
||||||
|
let log = &world.resource::<RejectedLogins>().0;
|
||||||
|
if log.is_empty() {
|
||||||
|
vec!["No rejected logins.".into()]
|
||||||
|
} else {
|
||||||
|
log.iter().cloned().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
"save" => {
|
"save" => {
|
||||||
if world.contains_resource::<persistence::Storage>() {
|
if world.contains_resource::<persistence::Storage>() {
|
||||||
persistence::save_world_blocking(world);
|
persistence::save_world_blocking(world);
|
||||||
|
|
@ -68,12 +211,236 @@ pub fn execute(world: &mut World, _source: CommandSource, line: &str) -> Vec<Str
|
||||||
}
|
}
|
||||||
"config" => config_command(world, rest),
|
"config" => config_command(world, rest),
|
||||||
"stop" => {
|
"stop" => {
|
||||||
|
info!("{actor} stopped the server");
|
||||||
world.write_message(AppExit::Success);
|
world.write_message(AppExit::Success);
|
||||||
vec!["Shutting down.".into()]
|
vec!["Shutting down.".into()]
|
||||||
}
|
}
|
||||||
_ => vec![format!(
|
_ => {
|
||||||
"Unknown command `{command}`. Type `help` for a list."
|
let usage: Vec<String> = COMMANDS
|
||||||
)],
|
.iter()
|
||||||
|
.filter(|(usage, _, _)| usage.split(' ').next() == Some(command))
|
||||||
|
.map(|(usage, _, _)| format!("Usage: {usage}"))
|
||||||
|
.collect();
|
||||||
|
usage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor_name(world: &mut World, source: CommandSource) -> String {
|
||||||
|
match source {
|
||||||
|
CommandSource::Console => "server".into(),
|
||||||
|
CommandSource::Player { client, .. } => world
|
||||||
|
.resource::<Players>()
|
||||||
|
.by_client
|
||||||
|
.get(&client)
|
||||||
|
.and_then(|&e| world.get::<Character>(e))
|
||||||
|
.map_or_else(|| "server".into(), |c| c.name.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A player named in a command.
|
||||||
|
struct Target {
|
||||||
|
/// Value stored in access lists: the player id if known, otherwise the given name.
|
||||||
|
entry: String,
|
||||||
|
/// For messages.
|
||||||
|
label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a character name or player id.
|
||||||
|
fn resolve(world: &mut World, input: &str) -> Target {
|
||||||
|
let input = input.trim();
|
||||||
|
if uuid::Uuid::parse_str(input).is_ok() {
|
||||||
|
let id = input.to_lowercase();
|
||||||
|
let label = match players::character_name(world, &id) {
|
||||||
|
Some(name) => format!("{name} ({id})"),
|
||||||
|
None => id.clone(),
|
||||||
|
};
|
||||||
|
return Target { entry: id, label };
|
||||||
|
}
|
||||||
|
match players::find_player_by_name(world, input) {
|
||||||
|
Some(id) => {
|
||||||
|
let name = players::character_name(world, &id).unwrap_or_else(|| input.to_owned());
|
||||||
|
Target {
|
||||||
|
label: format!("{name} ({id})"),
|
||||||
|
entry: id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unknown players are listed by name, so they can be banned or
|
||||||
|
// whitelisted before they ever join.
|
||||||
|
None => Target {
|
||||||
|
entry: input.to_owned(),
|
||||||
|
label: input.to_owned(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Messages online admins and the server log.
|
||||||
|
fn notify(world: &mut World, text: &str) {
|
||||||
|
info!("{text}");
|
||||||
|
players::tell_admins(world, &format!("[admin] {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kick(world: &mut World, actor: &str, target: &str, reason: &str) -> Vec<String> {
|
||||||
|
let target = resolve(world, target);
|
||||||
|
let mut q = world.query::<(&PlayerId, &Controller)>();
|
||||||
|
let Some(client) = q
|
||||||
|
.iter(world)
|
||||||
|
.find(|(id, _)| id.0 == target.entry)
|
||||||
|
.map(|(_, c)| c.0)
|
||||||
|
else {
|
||||||
|
return vec![format!("{} is not online.", target.label)];
|
||||||
|
};
|
||||||
|
let text = if reason.is_empty() {
|
||||||
|
"You were kicked by an admin.".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("You were kicked: {reason}")
|
||||||
|
};
|
||||||
|
players::disconnect(&mut world.commands(), client, text);
|
||||||
|
world.flush();
|
||||||
|
let msg = format!("{actor} kicked {}.", target.label);
|
||||||
|
notify(world, &msg);
|
||||||
|
vec![msg]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds (`add`) or removes a value from a string list setting. Returns whether it changed.
|
||||||
|
fn list_edit(world: &mut World, key: &str, value: &str, add: bool) -> Result<bool, String> {
|
||||||
|
let config = world.resource::<config::ServerConfig>();
|
||||||
|
let mut list = match key {
|
||||||
|
"access.banned" => config.access.banned.clone(),
|
||||||
|
"access.whitelist" => config.access.whitelist.clone(),
|
||||||
|
"access.admins" => config.access.admins.clone(),
|
||||||
|
_ => unreachable!("not a list setting: {key}"),
|
||||||
|
};
|
||||||
|
let before = list.len();
|
||||||
|
if add {
|
||||||
|
if list.iter().any(|e| e.eq_ignore_ascii_case(value)) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
list.push(value.to_owned());
|
||||||
|
} else {
|
||||||
|
list.retain(|e| !e.eq_ignore_ascii_case(value));
|
||||||
|
if list.len() == before {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let toml = toml_edit::Value::from_iter(list.iter().map(String::as_str)).to_string();
|
||||||
|
config::set_runtime(world, key, toml.trim())
|
||||||
|
.map(|_| true)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a player from a list by id or by name.
|
||||||
|
fn unlist(world: &mut World, actor: &str, key: &str, input: &str, what: &str) -> Vec<String> {
|
||||||
|
let target = resolve(world, input);
|
||||||
|
let name = players::character_name(world, &target.entry);
|
||||||
|
let mut changed = false;
|
||||||
|
for value in std::iter::once(target.entry.as_str())
|
||||||
|
.chain(name.as_deref())
|
||||||
|
.chain(std::iter::once(input.trim()))
|
||||||
|
{
|
||||||
|
match list_edit(world, key, value, false) {
|
||||||
|
Ok(c) => changed |= c,
|
||||||
|
Err(e) => return vec![e],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
let msg = format!("{actor}: {} is no longer {what}.", target.label);
|
||||||
|
notify(world, &msg);
|
||||||
|
vec![msg]
|
||||||
|
} else {
|
||||||
|
vec![format!("{} is not {what}.", target.label)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_list(
|
||||||
|
world: &mut World,
|
||||||
|
list: impl Fn(&config::ServerConfig) -> &Vec<String>,
|
||||||
|
title: &str,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let entries = list(world.resource::<config::ServerConfig>()).clone();
|
||||||
|
if entries.is_empty() {
|
||||||
|
return vec![format!("{title}: none")];
|
||||||
|
}
|
||||||
|
let mut reply = vec![format!("{title}:")];
|
||||||
|
for entry in entries {
|
||||||
|
match players::character_name(world, &entry) {
|
||||||
|
Some(name) => reply.push(format!(" {name} ({entry})")),
|
||||||
|
None => reply.push(format!(" {entry}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply
|
||||||
|
}
|
||||||
|
|
||||||
|
fn whitelist_command(world: &mut World, actor: &str, args: &str) -> Vec<String> {
|
||||||
|
let (action, target) = args.split_once(' ').unwrap_or((args, ""));
|
||||||
|
let target = target.trim();
|
||||||
|
match (action, target.is_empty()) {
|
||||||
|
("on" | "off", true) => {
|
||||||
|
let on = action == "on";
|
||||||
|
match config::set_runtime(world, "access.whitelist_enabled", &on.to_string()) {
|
||||||
|
Ok(_) => {
|
||||||
|
let msg = format!(
|
||||||
|
"{actor} turned the whitelist {}.",
|
||||||
|
if on { "on" } else { "off" }
|
||||||
|
);
|
||||||
|
notify(world, &msg);
|
||||||
|
vec![msg]
|
||||||
|
}
|
||||||
|
Err(e) => vec![e.to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
("list", true) => {
|
||||||
|
let enabled = world
|
||||||
|
.resource::<config::ServerConfig>()
|
||||||
|
.access
|
||||||
|
.whitelist_enabled;
|
||||||
|
let title = format!("Whitelist ({})", if enabled { "on" } else { "off" });
|
||||||
|
show_list(world, |c| &c.access.whitelist, &title)
|
||||||
|
}
|
||||||
|
("add", false) => {
|
||||||
|
let target = resolve(world, target);
|
||||||
|
match list_edit(world, "access.whitelist", &target.entry, true) {
|
||||||
|
Ok(true) => {
|
||||||
|
let msg = format!("{actor} whitelisted {}.", target.label);
|
||||||
|
notify(world, &msg);
|
||||||
|
vec![msg]
|
||||||
|
}
|
||||||
|
Ok(false) => vec![format!("{} is already whitelisted.", target.label)],
|
||||||
|
Err(e) => vec![e],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
("remove", false) => unlist(world, actor, "access.whitelist", target, "whitelisted"),
|
||||||
|
_ => vec!["Usage: whitelist on|off|list | whitelist add|remove PLAYER".into()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_command(world: &mut World, actor: &str, args: &str) -> Vec<String> {
|
||||||
|
let (action, target) = args.split_once(' ').unwrap_or((args, ""));
|
||||||
|
let target = target.trim();
|
||||||
|
match (action, target.is_empty()) {
|
||||||
|
("list", true) => show_list(world, |c| &c.access.admins, "Admins"),
|
||||||
|
("add", false) => {
|
||||||
|
let target = resolve(world, target);
|
||||||
|
// Admin rights are bound to player ids, since names can be taken
|
||||||
|
// by anyone before the character exists.
|
||||||
|
if uuid::Uuid::parse_str(&target.entry).is_err() {
|
||||||
|
return vec![format!(
|
||||||
|
"Unknown player `{}`. Use a character name of this world or a player id.",
|
||||||
|
target.label
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
match list_edit(world, "access.admins", &target.entry, true) {
|
||||||
|
Ok(true) => {
|
||||||
|
let msg = format!("{actor} made {} an admin.", target.label);
|
||||||
|
notify(world, &msg);
|
||||||
|
vec![msg]
|
||||||
|
}
|
||||||
|
Ok(false) => vec![format!("{} is already an admin.", target.label)],
|
||||||
|
Err(e) => vec![e],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
("remove", false) => unlist(world, actor, "access.admins", target, "an admin"),
|
||||||
|
_ => vec!["Usage: admin list | admin add|remove PLAYER".into()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ impl ServerConfig {
|
||||||
world: WorldSelector::parse(&self.database.world),
|
world: WorldSelector::parse(&self.database.world),
|
||||||
}),
|
}),
|
||||||
autosave_interval: Duration::from_secs(self.autosave_secs.max(1)),
|
autosave_interval: Duration::from_secs(self.autosave_secs.max(1)),
|
||||||
|
owner: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 config;
|
||||||
pub mod console;
|
pub mod console;
|
||||||
pub mod crafting;
|
pub mod crafting;
|
||||||
|
pub mod embedded;
|
||||||
pub mod machines;
|
pub mod machines;
|
||||||
pub mod movement;
|
pub mod movement;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
|
|
@ -33,6 +34,9 @@ pub struct ServerSettings {
|
||||||
/// Database and world to persist to. `None` disables saving.
|
/// Database and world to persist to. `None` disables saving.
|
||||||
pub storage: Option<persistence::StorageSettings>,
|
pub storage: Option<persistence::StorageSettings>,
|
||||||
pub autosave_interval: Duration,
|
pub autosave_interval: Duration,
|
||||||
|
/// Player id that always has admin rights and bypasses access restrictions
|
||||||
|
/// (the host of an embedded server).
|
||||||
|
pub owner: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerSettings {
|
impl Default for ServerSettings {
|
||||||
|
|
@ -41,6 +45,7 @@ impl Default for ServerSettings {
|
||||||
map: game_core::DEFAULT_MAP.to_owned(),
|
map: game_core::DEFAULT_MAP.to_owned(),
|
||||||
storage: None,
|
storage: None,
|
||||||
autosave_interval: Duration::from_secs(60),
|
autosave_interval: Duration::from_secs(60),
|
||||||
|
owner: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -60,20 +65,36 @@ impl Plugin for GameServerPlugin {
|
||||||
.init_resource::<config::ServerConfig>()
|
.init_resource::<config::ServerConfig>()
|
||||||
.init_resource::<players::Players>()
|
.init_resource::<players::Players>()
|
||||||
.init_resource::<players::CharacterStore>()
|
.init_resource::<players::CharacterStore>()
|
||||||
|
.init_resource::<players::RejectedLogins>()
|
||||||
.init_resource::<survival::SurvivalTimer>()
|
.init_resource::<survival::SurvivalTimer>()
|
||||||
.add_systems(Startup, (load_scripts, world::setup_world).chain())
|
.add_systems(
|
||||||
|
Startup,
|
||||||
|
(
|
||||||
|
load_scripts,
|
||||||
|
world::setup_world.run_if(not(resource_exists::<ServerError>)),
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
FixedUpdate,
|
FixedUpdate,
|
||||||
(
|
(
|
||||||
movement::move_characters,
|
movement::move_characters,
|
||||||
survival::tick_survival,
|
survival::tick_survival,
|
||||||
machines::tick_machines,
|
machines::tick_machines,
|
||||||
|
)
|
||||||
|
.run_if(resource_exists::<world::WorldLayout>),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
persistence::autosave,
|
||||||
|
players::enforce_access.run_if(resource_changed::<config::ServerConfig>),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.add_systems(Update, persistence::autosave)
|
|
||||||
.add_systems(Last, persistence::save_on_exit)
|
.add_systems(Last, persistence::save_on_exit)
|
||||||
.add_observer(players::on_login)
|
.add_observer(players::on_login)
|
||||||
.add_observer(players::on_disconnect)
|
.add_observer(players::on_disconnect)
|
||||||
|
.add_observer(players::on_chat)
|
||||||
.add_observer(movement::on_set_move_dir)
|
.add_observer(movement::on_set_move_dir)
|
||||||
.add_observer(actions::on_pick_up)
|
.add_observer(actions::on_pick_up)
|
||||||
.add_observer(actions::on_drop_item)
|
.add_observer(actions::on_drop_item)
|
||||||
|
|
@ -87,9 +108,23 @@ impl Plugin for GameServerPlugin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why the server failed to start. Inserted together with an error [`AppExit`].
|
||||||
|
#[derive(Resource, Clone, Debug)]
|
||||||
|
pub struct ServerError(pub String);
|
||||||
|
|
||||||
|
/// Reports a fatal startup error and requests exit.
|
||||||
|
pub fn fail(commands: &mut Commands, message: String) {
|
||||||
|
error!("{message}");
|
||||||
|
commands.insert_resource(ServerError(message));
|
||||||
|
commands.write_message(AppExit::error());
|
||||||
|
}
|
||||||
|
|
||||||
fn load_scripts(mut commands: Commands, data: Res<GameData>) {
|
fn load_scripts(mut commands: Commands, data: Res<GameData>) {
|
||||||
let dir = game_core::asset_path("scripts");
|
let dir = game_core::asset_path("scripts");
|
||||||
let engine = scripting::ScriptEngine::load(&dir, &data).unwrap_or_else(|e| panic!("{e}"));
|
let engine = match scripting::ScriptEngine::load(&dir, &data) {
|
||||||
|
Ok(engine) => engine,
|
||||||
|
Err(e) => return fail(&mut commands, e.to_string()),
|
||||||
|
};
|
||||||
info!("compiled scripts from `{}`", dir.display());
|
info!("compiled scripts from `{}`", dir.display());
|
||||||
commands.insert_resource(engine);
|
commands.insert_resource(engine);
|
||||||
commands.insert_resource(actions::SharedData(Arc::new(data.clone())));
|
commands.insert_resource(actions::SharedData(Arc::new(data.clone())));
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ fn main() -> AppExit {
|
||||||
GameServerPlugin {
|
GameServerPlugin {
|
||||||
settings: config.settings(),
|
settings: config.settings(),
|
||||||
},
|
},
|
||||||
NetworkServerPlugin { config },
|
NetworkServerPlugin::public(config),
|
||||||
ConsolePlugin,
|
ConsolePlugin,
|
||||||
));
|
));
|
||||||
if created {
|
if created {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
//! Starting the renet network server (host and dedicated modes).
|
//! Starting the renet network server (host and dedicated modes).
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
net::{Ipv4Addr, SocketAddr, UdpSocket},
|
net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket},
|
||||||
time::SystemTime,
|
time::SystemTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_replicon::prelude::*;
|
use bevy_replicon::prelude::*;
|
||||||
use bevy_replicon_renet::{
|
use bevy_replicon_renet::{
|
||||||
RenetChannelsExt, RenetServer,
|
RenetChannelsExt, RenetClient, RenetServer,
|
||||||
netcode::{self, NetcodeServerTransport, ServerAuthentication},
|
netcode::{
|
||||||
|
self, ClientAuthentication, NetcodeClientTransport, NetcodeServerTransport,
|
||||||
|
ServerAuthentication,
|
||||||
|
},
|
||||||
renet::ConnectionConfig,
|
renet::ConnectionConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -23,11 +26,32 @@ const EXTRA_SLOTS: usize = 4;
|
||||||
/// `config` the server's [`ServerConfig`] resource.
|
/// `config` the server's [`ServerConfig`] resource.
|
||||||
pub struct NetworkServerPlugin {
|
pub struct NetworkServerPlugin {
|
||||||
pub config: ServerConfig,
|
pub config: ServerConfig,
|
||||||
|
/// Address to listen on: unspecified for everyone, loopback for local-only.
|
||||||
|
/// The port comes from the config; port 0 picks a free one.
|
||||||
|
pub bind: IpAddr,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl NetworkServerPlugin {
|
||||||
|
/// Listens on all interfaces.
|
||||||
|
pub fn public(config: ServerConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
bind: Ipv4Addr::UNSPECIFIED.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Address the server listens on.
|
||||||
|
#[derive(Resource, Clone, Copy, Debug)]
|
||||||
|
pub struct BoundAddr(pub SocketAddr);
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
struct BindIp(IpAddr);
|
||||||
|
|
||||||
impl Plugin for NetworkServerPlugin {
|
impl Plugin for NetworkServerPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.insert_resource(self.config.clone())
|
app.insert_resource(self.config.clone())
|
||||||
|
.insert_resource(BindIp(self.bind))
|
||||||
.add_systems(Startup, start_server);
|
.add_systems(Startup, start_server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -36,41 +60,70 @@ fn start_server(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
channels: Res<RepliconChannels>,
|
channels: Res<RepliconChannels>,
|
||||||
config: Res<ServerConfig>,
|
config: Res<ServerConfig>,
|
||||||
|
bind: Res<BindIp>,
|
||||||
) {
|
) {
|
||||||
let port = config.port;
|
let addr = SocketAddr::new(bind.0, config.port);
|
||||||
match create_server(&channels, port, config.max_players + EXTRA_SLOTS) {
|
match create_server(&channels, addr, config.max_players + EXTRA_SLOTS) {
|
||||||
Ok((server, transport)) => {
|
Ok((server, transport, bound)) => {
|
||||||
info!("listening on UDP port {port}");
|
info!("listening on UDP {bound}");
|
||||||
commands.insert_resource(server);
|
commands.insert_resource(server);
|
||||||
commands.insert_resource(transport);
|
commands.insert_resource(transport);
|
||||||
|
commands.insert_resource(BoundAddr(bound));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => crate::fail(&mut commands, format!("failed to listen on {addr}: {e}")),
|
||||||
error!("failed to start server on port {port}: {e}");
|
|
||||||
commands.write_message(AppExit::error());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_server(
|
pub fn create_server(
|
||||||
channels: &RepliconChannels,
|
channels: &RepliconChannels,
|
||||||
port: u16,
|
addr: SocketAddr,
|
||||||
max_clients: usize,
|
max_clients: usize,
|
||||||
) -> Result<(RenetServer, NetcodeServerTransport), Box<dyn std::error::Error>> {
|
) -> Result<(RenetServer, NetcodeServerTransport, SocketAddr), Box<dyn std::error::Error>> {
|
||||||
let server = RenetServer::new(ConnectionConfig {
|
let server = RenetServer::new(ConnectionConfig {
|
||||||
server_channels_config: channels.server_configs(),
|
server_channels_config: channels.server_configs(),
|
||||||
client_channels_config: channels.client_configs(),
|
client_channels_config: channels.client_configs(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
|
let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
|
||||||
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port))?;
|
let socket = UdpSocket::bind(addr)?;
|
||||||
|
let bound = socket.local_addr()?;
|
||||||
let server_config = netcode::ServerConfig {
|
let server_config = netcode::ServerConfig {
|
||||||
current_time,
|
current_time,
|
||||||
max_clients,
|
max_clients,
|
||||||
protocol_id: game_core::PROTOCOL_ID,
|
protocol_id: game_core::PROTOCOL_ID,
|
||||||
authentication: ServerAuthentication::Unsecure,
|
authentication: ServerAuthentication::Unsecure,
|
||||||
// Not used with unsecure authentication, but must be valid.
|
// Not used with unsecure authentication, but must be valid.
|
||||||
public_addresses: vec![SocketAddr::from((Ipv4Addr::LOCALHOST, port))],
|
public_addresses: vec![bound],
|
||||||
};
|
};
|
||||||
let transport = NetcodeServerTransport::new(server_config, socket)?;
|
let transport = NetcodeServerTransport::new(server_config, socket)?;
|
||||||
Ok((server, transport))
|
Ok((server, transport, bound))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a client connecting to `addr`; insert both as resources.
|
||||||
|
pub fn create_client(
|
||||||
|
channels: &RepliconChannels,
|
||||||
|
addr: SocketAddr,
|
||||||
|
) -> Result<(RenetClient, NetcodeClientTransport), Box<dyn std::error::Error>> {
|
||||||
|
let client = RenetClient::new(ConnectionConfig {
|
||||||
|
server_channels_config: channels.server_configs(),
|
||||||
|
client_channels_config: channels.client_configs(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
|
||||||
|
let local: IpAddr = match (addr.ip().is_loopback(), addr.is_ipv6()) {
|
||||||
|
(true, false) => Ipv4Addr::LOCALHOST.into(),
|
||||||
|
(false, false) => Ipv4Addr::UNSPECIFIED.into(),
|
||||||
|
(true, true) => std::net::Ipv6Addr::LOCALHOST.into(),
|
||||||
|
(false, true) => std::net::Ipv6Addr::UNSPECIFIED.into(),
|
||||||
|
};
|
||||||
|
let socket = UdpSocket::bind((local, 0))?;
|
||||||
|
let authentication = ClientAuthentication::Unsecure {
|
||||||
|
// Netcode rejects a second connection with the same id.
|
||||||
|
client_id: uuid::Uuid::new_v4().as_u64_pair().0,
|
||||||
|
protocol_id: game_core::PROTOCOL_ID,
|
||||||
|
server_addr: addr,
|
||||||
|
user_data: None,
|
||||||
|
};
|
||||||
|
let transport = NetcodeClientTransport::new(current_time, authentication, socket)?;
|
||||||
|
Ok((client, transport))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ use tokio::{runtime::Runtime, task::JoinHandle};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
players::CharacterStore,
|
players::{CharacterStore, PlayerId},
|
||||||
world::{spawn_building_with_state, spawn_ground_item},
|
world::{spawn_building_with_state, spawn_ground_item},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -625,10 +625,18 @@ pub fn restore(commands: &mut Commands, data: &GameData, save: SaveGame) {
|
||||||
/// Collects the full world state.
|
/// Collects the full world state.
|
||||||
pub fn snapshot(world: &mut World) -> SaveGame {
|
pub fn snapshot(world: &mut World) -> SaveGame {
|
||||||
let mut characters = world.resource::<CharacterStore>().0.clone();
|
let mut characters = world.resource::<CharacterStore>().0.clone();
|
||||||
let mut q = world.query::<(&Character, &GridPos, &Facing, &Vitals, &Inventory, &Skills)>();
|
let mut q = world.query::<(
|
||||||
for (c, pos, facing, vitals, inventory, skills) in q.iter(world) {
|
&Character,
|
||||||
|
&PlayerId,
|
||||||
|
&GridPos,
|
||||||
|
&Facing,
|
||||||
|
&Vitals,
|
||||||
|
&Inventory,
|
||||||
|
&Skills,
|
||||||
|
)>();
|
||||||
|
for (c, id, pos, facing, vitals, inventory, skills) in q.iter(world) {
|
||||||
characters.insert(
|
characters.insert(
|
||||||
c.name.clone(),
|
id.0.clone(),
|
||||||
SavedCharacter {
|
SavedCharacter {
|
||||||
name: c.name.clone(),
|
name: c.name.clone(),
|
||||||
pos: *pos,
|
pos: *pos,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
//! Login/logout and the mapping between network clients and characters.
|
//! Login/logout and the mapping between network clients and characters.
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_replicon::prelude::*;
|
use bevy_replicon::prelude::*;
|
||||||
use game_core::{
|
use game_core::{
|
||||||
GameData,
|
GameData,
|
||||||
components::*,
|
components::*,
|
||||||
protocol::{GameLog, Login},
|
protocol::{AdminStatus, Chat, GameLog, Login, Rejected, Welcome},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
ServerSettings,
|
||||||
|
commands::{self, CommandSource},
|
||||||
|
config::ServerConfig,
|
||||||
movement::MoveIntent,
|
movement::MoveIntent,
|
||||||
persistence::SavedCharacter,
|
persistence::SavedCharacter,
|
||||||
world::{SpawnPoint, WorldLayout, find_free_tile},
|
world::{SpawnPoint, WorldLayout, find_free_tile},
|
||||||
|
|
@ -20,13 +23,21 @@ use crate::{
|
||||||
#[derive(Component, Clone, Copy, Debug)]
|
#[derive(Component, Clone, Copy, Debug)]
|
||||||
pub struct Controller(pub ClientId);
|
pub struct Controller(pub ClientId);
|
||||||
|
|
||||||
|
/// The player owning a character (a UUID sent by the client). Server-only.
|
||||||
|
#[derive(Component, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PlayerId(pub String);
|
||||||
|
|
||||||
|
/// Marks characters of players with admin rights. Server-only.
|
||||||
|
#[derive(Component, Clone, Copy, Debug)]
|
||||||
|
pub struct Admin;
|
||||||
|
|
||||||
/// Online characters by client.
|
/// Online characters by client.
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
pub struct Players {
|
pub struct Players {
|
||||||
pub by_client: HashMap<ClientId, Entity>,
|
pub by_client: HashMap<ClientId, Entity>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Characters that are currently offline, by name. Persisted with the world.
|
/// Characters that are currently offline, by player id. Persisted with the world.
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
pub struct CharacterStore(pub BTreeMap<String, SavedCharacter>);
|
pub struct CharacterStore(pub BTreeMap<String, SavedCharacter>);
|
||||||
|
|
||||||
|
|
@ -48,52 +59,207 @@ pub fn valid_name(name: &str) -> bool {
|
||||||
.all(|c| c.is_alphanumeric() || c == ' ' || c == '_' || c == '-')
|
.all(|c| c.is_alphanumeric() || c == ' ' || c == '_' || c == '-')
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
/// Everything [`check_access`] needs to know about a login attempt.
|
||||||
pub fn on_login(
|
#[derive(Clone, Copy, Debug)]
|
||||||
login: On<FromClient<Login>>,
|
pub struct AccessRequest<'a> {
|
||||||
mut commands: Commands,
|
pub player_id: &'a str,
|
||||||
mut players: ResMut<Players>,
|
/// Character name (the stored one for returning players).
|
||||||
mut store: ResMut<CharacterStore>,
|
pub name: &'a str,
|
||||||
mut disconnects: MessageWriter<DisconnectRequest>,
|
pub password: &'a str,
|
||||||
data: Res<GameData>,
|
/// Host of an embedded server or a local client; bypasses all restrictions.
|
||||||
spawn: Res<SpawnPoint>,
|
pub owner: bool,
|
||||||
layout: Res<WorldLayout>,
|
pub online: usize,
|
||||||
characters: Query<&Character>,
|
pub id_online: bool,
|
||||||
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
pub new_character: bool,
|
||||||
) {
|
/// Another player's character already uses this name.
|
||||||
let client = login.client_id;
|
pub name_taken: bool,
|
||||||
let name = login.message.name.clone();
|
}
|
||||||
if players.by_client.contains_key(&client) {
|
|
||||||
return;
|
/// 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,
|
let admin = req.owner || is_listed(&config.access.admins, req.player_id, req.name);
|
||||||
disconnects: &mut MessageWriter<DisconnectRequest>,
|
if !req.owner {
|
||||||
reason: &str| {
|
if !config.password.is_empty() && req.password != config.password {
|
||||||
tell(commands, client, reason);
|
return Err(if req.password.is_empty() {
|
||||||
|
"This server requires a password.".into()
|
||||||
|
} else {
|
||||||
|
"Wrong password.".into()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if is_listed(&config.access.banned, req.player_id, req.name) {
|
||||||
|
return Err("You are banned from this server.".into());
|
||||||
|
}
|
||||||
|
if config.access.whitelist_enabled
|
||||||
|
&& !admin
|
||||||
|
&& !is_listed(&config.access.whitelist, req.player_id, req.name)
|
||||||
|
{
|
||||||
|
return Err("You are not on the whitelist.".into());
|
||||||
|
}
|
||||||
|
if req.online >= config.max_players && !admin {
|
||||||
|
return Err("The server is full.".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.id_online {
|
||||||
|
return Err("You are already logged in.".into());
|
||||||
|
}
|
||||||
|
if req.new_character {
|
||||||
|
if !valid_name(req.name) {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid character name. Use 1-{MAX_NAME_LEN} letters, digits, spaces, `_` or `-`."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if req.name_taken {
|
||||||
|
return Err("That name belongs to another player.".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a ban/whitelist/admin entry matches a player id or (case-insensitively) a name.
|
||||||
|
pub fn is_listed(list: &[String], player_id: &str, name: &str) -> bool {
|
||||||
|
list.iter()
|
||||||
|
.any(|e| e == player_id || (!name.is_empty() && e.eq_ignore_ascii_case(name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a player has admin rights.
|
||||||
|
pub fn is_admin(
|
||||||
|
world_owner: Option<&str>,
|
||||||
|
config: &ServerConfig,
|
||||||
|
client: ClientId,
|
||||||
|
id: &str,
|
||||||
|
) -> bool {
|
||||||
|
client == ClientId::Server
|
||||||
|
|| world_owner == Some(id)
|
||||||
|
|| config.access.admins.iter().any(|a| a == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recent rejected logins, for admins.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub struct RejectedLogins(pub VecDeque<String>);
|
||||||
|
|
||||||
|
const REJECTED_HISTORY: usize = 20;
|
||||||
|
|
||||||
|
/// Tells a client why, then disconnects it.
|
||||||
|
pub fn disconnect(commands: &mut Commands, client: ClientId, reason: impl Into<String>) {
|
||||||
|
commands.server_trigger(ToClients {
|
||||||
|
targets: SendTargets::Single(client),
|
||||||
|
message: Rejected {
|
||||||
|
reason: reason.into(),
|
||||||
|
},
|
||||||
|
});
|
||||||
if let ClientId::Client(entity) = client {
|
if let ClientId::Client(entity) = client {
|
||||||
disconnects.write(DisconnectRequest { client: entity });
|
commands.write_message(DisconnectRequest { client: entity });
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
if !valid_name(&name) {
|
|
||||||
reject(&mut commands, &mut disconnects, "Invalid character name.");
|
/// Sends a message to every online admin.
|
||||||
|
pub fn tell_admins(world: &mut World, text: &str) {
|
||||||
|
let mut q = world.query_filtered::<&Controller, With<Admin>>();
|
||||||
|
let admins: Vec<ClientId> = q.iter(world).map(|c| c.0).collect();
|
||||||
|
let mut commands = world.commands();
|
||||||
|
for client in admins {
|
||||||
|
tell(&mut commands, client, text);
|
||||||
|
}
|
||||||
|
world.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name of the character a player id owns in this world, online or stored.
|
||||||
|
pub fn character_name(world: &mut World, player_id: &str) -> Option<String> {
|
||||||
|
let mut q = world.query::<(&Character, &PlayerId)>();
|
||||||
|
if let Some((c, _)) = q.iter(world).find(|(_, id)| id.0 == player_id) {
|
||||||
|
return Some(c.name.clone());
|
||||||
|
}
|
||||||
|
world
|
||||||
|
.resource::<CharacterStore>()
|
||||||
|
.0
|
||||||
|
.get(player_id)
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Player id owning a character name (case-insensitive), online or stored.
|
||||||
|
pub fn find_player_by_name(world: &mut World, name: &str) -> Option<String> {
|
||||||
|
let mut q = world.query::<(&Character, &PlayerId)>();
|
||||||
|
if let Some((_, id)) = q
|
||||||
|
.iter(world)
|
||||||
|
.find(|(c, _)| c.name.eq_ignore_ascii_case(name))
|
||||||
|
{
|
||||||
|
return Some(id.0.clone());
|
||||||
|
}
|
||||||
|
world
|
||||||
|
.resource::<CharacterStore>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.find(|(_, c)| c.name.eq_ignore_ascii_case(name))
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_login(login: On<FromClient<Login>>, mut commands: Commands) {
|
||||||
|
let client = login.client_id;
|
||||||
|
let login = login.message.clone();
|
||||||
|
commands.queue(move |world: &mut World| handle_login(world, client, login));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_login(world: &mut World, client: ClientId, login: Login) {
|
||||||
|
if world.resource::<Players>().by_client.contains_key(&client) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if characters.iter().any(|c| c.name == name) {
|
let player_id = login.player_id.trim().to_lowercase();
|
||||||
reject(
|
let stored_name = character_name(world, &player_id);
|
||||||
&mut commands,
|
let new_character = stored_name.is_none();
|
||||||
&mut disconnects,
|
let name = stored_name.unwrap_or_else(|| login.name.trim().to_owned());
|
||||||
"That character is already online.",
|
let name_taken =
|
||||||
);
|
new_character && find_player_by_name(world, &name).is_some_and(|owner| owner != player_id);
|
||||||
|
let owner = world.resource::<ServerSettings>().owner.as_deref() == Some(player_id.as_str())
|
||||||
|
|| client == ClientId::Server;
|
||||||
|
let id_online = world
|
||||||
|
.query::<&PlayerId>()
|
||||||
|
.iter(world)
|
||||||
|
.any(|id| id.0 == player_id);
|
||||||
|
let request = AccessRequest {
|
||||||
|
player_id: &player_id,
|
||||||
|
name: &name,
|
||||||
|
password: &login.password,
|
||||||
|
owner,
|
||||||
|
online: world.resource::<Players>().by_client.len(),
|
||||||
|
id_online,
|
||||||
|
new_character,
|
||||||
|
name_taken,
|
||||||
|
};
|
||||||
|
if let Err(reason) = check_access(world.resource::<ServerConfig>(), &request) {
|
||||||
|
let entry = format!("{name} ({player_id}): {reason}");
|
||||||
|
warn!("rejected login of {entry}");
|
||||||
|
tell_admins(world, &format!("[admin] Rejected {entry}"));
|
||||||
|
let mut log = world.resource_mut::<RejectedLogins>();
|
||||||
|
if log.0.len() >= REJECTED_HISTORY {
|
||||||
|
log.0.pop_front();
|
||||||
|
}
|
||||||
|
log.0.push_back(entry);
|
||||||
|
disconnect(&mut world.commands(), client, reason);
|
||||||
|
world.flush();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (saved, returning) = match store.0.remove(&name) {
|
let saved = match world.resource_mut::<CharacterStore>().0.remove(&player_id) {
|
||||||
Some(saved) => (saved, true),
|
Some(saved) => saved,
|
||||||
None => (new_character(&data, &name, spawn.0), false),
|
None => {
|
||||||
|
let spawn = world.resource::<SpawnPoint>().0;
|
||||||
|
new_character_data(world.resource::<GameData>(), &name, spawn)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let pos = find_free_tile(saved.pos, &layout.0, &buildings);
|
let mut buildings =
|
||||||
let entity = commands
|
world.query_filtered::<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>();
|
||||||
.spawn((
|
let pos = find_free_tile(
|
||||||
|
saved.pos,
|
||||||
|
&world.resource::<WorldLayout>().0,
|
||||||
|
&buildings.query(world),
|
||||||
|
);
|
||||||
|
let config = world.resource::<ServerConfig>().clone();
|
||||||
|
let owner_id = world.resource::<ServerSettings>().owner.clone();
|
||||||
|
let admin = is_admin(owner_id.as_deref(), &config, client, &player_id);
|
||||||
|
let mut entity = world.spawn((
|
||||||
Replicated,
|
Replicated,
|
||||||
Character { name: name.clone() },
|
Character { name: name.clone() },
|
||||||
pos,
|
pos,
|
||||||
|
|
@ -102,20 +268,155 @@ pub fn on_login(
|
||||||
saved.inventory,
|
saved.inventory,
|
||||||
saved.skills,
|
saved.skills,
|
||||||
Controller(client),
|
Controller(client),
|
||||||
|
PlayerId(player_id.clone()),
|
||||||
MoveIntent::default(),
|
MoveIntent::default(),
|
||||||
))
|
));
|
||||||
.id();
|
if admin {
|
||||||
players.by_client.insert(client, entity);
|
entity.insert(Admin);
|
||||||
info!("{name} logged in ({client:?})");
|
}
|
||||||
let greeting = if returning {
|
let entity = entity.id();
|
||||||
format!("Welcome back, {name}.")
|
world
|
||||||
} else {
|
.resource_mut::<Players>()
|
||||||
|
.by_client
|
||||||
|
.insert(client, entity);
|
||||||
|
info!(
|
||||||
|
"{name} logged in (player id {player_id}, {client:?}{})",
|
||||||
|
if admin { ", admin" } else { "" }
|
||||||
|
);
|
||||||
|
let greeting = if new_character {
|
||||||
format!("Welcome to the station, {name}. Press H for help.")
|
format!("Welcome to the station, {name}. Press H for help.")
|
||||||
|
} else {
|
||||||
|
format!("Welcome back, {name}.")
|
||||||
};
|
};
|
||||||
|
let mut commands = world.commands();
|
||||||
|
commands.server_trigger(ToClients {
|
||||||
|
targets: SendTargets::Single(client),
|
||||||
|
message: Welcome {
|
||||||
|
server_name: config.name.clone(),
|
||||||
|
motd: config.motd.clone(),
|
||||||
|
character: name,
|
||||||
|
admin,
|
||||||
|
},
|
||||||
|
});
|
||||||
tell(&mut commands, client, greeting);
|
tell(&mut commands, client, greeting);
|
||||||
|
if !config.motd.is_empty() {
|
||||||
|
tell(&mut commands, client, config.motd);
|
||||||
|
}
|
||||||
|
world.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_character(data: &GameData, name: &str, spawn: GridPos) -> SavedCharacter {
|
/// Longest accepted chat message, in characters.
|
||||||
|
pub const MAX_CHAT_LEN: usize = 200;
|
||||||
|
|
||||||
|
/// Chat messages and `/commands` from players.
|
||||||
|
pub fn on_chat(chat: On<FromClient<Chat>>, mut commands: Commands) {
|
||||||
|
let client = chat.client_id;
|
||||||
|
let text: String = chat
|
||||||
|
.message
|
||||||
|
.text
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_control())
|
||||||
|
.take(MAX_CHAT_LEN)
|
||||||
|
.collect();
|
||||||
|
commands.queue(move |world: &mut World| handle_chat(world, client, text.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_chat(world: &mut World, client: ClientId, text: &str) {
|
||||||
|
let Some(&entity) = world.resource::<Players>().by_client.get(&client) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(command) = text.strip_prefix('/') {
|
||||||
|
let player_id = world.get::<PlayerId>(entity).map(|id| id.0.clone());
|
||||||
|
let owner = client == ClientId::Server
|
||||||
|
|| player_id
|
||||||
|
.is_some_and(|id| world.resource::<ServerSettings>().owner.as_deref() == Some(&id));
|
||||||
|
let source = CommandSource::Player {
|
||||||
|
client,
|
||||||
|
admin: world.get::<Admin>(entity).is_some(),
|
||||||
|
owner,
|
||||||
|
};
|
||||||
|
let name = world
|
||||||
|
.get::<Character>(entity)
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
info!("{name} ran `/{command}`");
|
||||||
|
let reply = commands::execute(world, source, command);
|
||||||
|
let mut cmds = world.commands();
|
||||||
|
for line in reply {
|
||||||
|
tell(&mut cmds, client, line);
|
||||||
|
}
|
||||||
|
world.flush();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(name) = world.get::<Character>(entity).map(|c| c.name.clone()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let line = format!("<{name}> {text}");
|
||||||
|
info!(target: "chat", "{line}");
|
||||||
|
world.commands().server_trigger(ToClients {
|
||||||
|
targets: SendTargets::All,
|
||||||
|
message: GameLog { text: line },
|
||||||
|
});
|
||||||
|
world.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies access list changes to online players: kicks banned and no longer
|
||||||
|
/// whitelisted players and updates admin rights.
|
||||||
|
pub fn enforce_access(world: &mut World) {
|
||||||
|
let config = world.resource::<ServerConfig>().clone();
|
||||||
|
let owner = world.resource::<ServerSettings>().owner.clone();
|
||||||
|
let mut q = world.query::<(Entity, &Character, &PlayerId, &Controller, Has<Admin>)>();
|
||||||
|
let online: Vec<_> = q
|
||||||
|
.iter(world)
|
||||||
|
.map(|(e, c, id, ctrl, admin)| (e, c.name.clone(), id.0.clone(), ctrl.0, admin))
|
||||||
|
.collect();
|
||||||
|
for (entity, name, id, client, was_admin) in online {
|
||||||
|
let admin = is_admin(owner.as_deref(), &config, client, &id);
|
||||||
|
let privileged =
|
||||||
|
admin || client == ClientId::Server || owner.as_deref() == Some(id.as_str());
|
||||||
|
let reason = if privileged {
|
||||||
|
None
|
||||||
|
} else if is_listed(&config.access.banned, &id, &name) {
|
||||||
|
Some("You have been banned from this server.")
|
||||||
|
} else if config.access.whitelist_enabled
|
||||||
|
&& !is_listed(&config.access.whitelist, &id, &name)
|
||||||
|
{
|
||||||
|
Some("You are not on the whitelist.")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut commands = world.commands();
|
||||||
|
if let Some(reason) = reason {
|
||||||
|
info!("disconnecting {name}: {reason}");
|
||||||
|
disconnect(&mut commands, client, reason);
|
||||||
|
} else if admin != was_admin {
|
||||||
|
if admin {
|
||||||
|
commands.entity(entity).insert(Admin);
|
||||||
|
} else {
|
||||||
|
commands.entity(entity).remove::<Admin>();
|
||||||
|
}
|
||||||
|
commands.server_trigger(ToClients {
|
||||||
|
targets: SendTargets::Single(client),
|
||||||
|
message: AdminStatus { admin },
|
||||||
|
});
|
||||||
|
tell(
|
||||||
|
&mut commands,
|
||||||
|
client,
|
||||||
|
if admin {
|
||||||
|
"You are now an admin."
|
||||||
|
} else {
|
||||||
|
"You are no longer an admin."
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
world.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_character_data(data: &GameData, name: &str, spawn: GridPos) -> SavedCharacter {
|
||||||
let mut inventory = Inventory::default();
|
let mut inventory = Inventory::default();
|
||||||
if data.items.contains_key("apple") {
|
if data.items.contains_key("apple") {
|
||||||
inventory.add("apple", 2, data);
|
inventory.add("apple", 2, data);
|
||||||
|
|
@ -136,15 +437,23 @@ pub fn on_disconnect(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut players: ResMut<Players>,
|
mut players: ResMut<Players>,
|
||||||
mut store: ResMut<CharacterStore>,
|
mut store: ResMut<CharacterStore>,
|
||||||
characters: Query<(&Character, &GridPos, &Facing, &Vitals, &Inventory, &Skills)>,
|
characters: Query<(
|
||||||
|
&Character,
|
||||||
|
&PlayerId,
|
||||||
|
&GridPos,
|
||||||
|
&Facing,
|
||||||
|
&Vitals,
|
||||||
|
&Inventory,
|
||||||
|
&Skills,
|
||||||
|
)>,
|
||||||
) {
|
) {
|
||||||
let Some(entity) = players.by_client.remove(&ClientId::Client(remove.entity)) else {
|
let Some(entity) = players.by_client.remove(&ClientId::Client(remove.entity)) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Ok((c, pos, facing, vitals, inventory, skills)) = characters.get(entity) {
|
if let Ok((c, id, pos, facing, vitals, inventory, skills)) = characters.get(entity) {
|
||||||
info!("{} logged out", c.name);
|
info!("{} logged out", c.name);
|
||||||
store.0.insert(
|
store.0.insert(
|
||||||
c.name.clone(),
|
id.0.clone(),
|
||||||
SavedCharacter {
|
SavedCharacter {
|
||||||
name: c.name.clone(),
|
name: c.name.clone(),
|
||||||
pos: *pos,
|
pos: *pos,
|
||||||
|
|
@ -160,7 +469,98 @@ pub fn on_disconnect(
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::valid_name;
|
use super::*;
|
||||||
|
|
||||||
|
const ID: &str = "4f1c0c56-1111-4a5e-9a1e-000000000001";
|
||||||
|
|
||||||
|
fn request<'a>() -> AccessRequest<'a> {
|
||||||
|
AccessRequest {
|
||||||
|
player_id: ID,
|
||||||
|
name: "Jane",
|
||||||
|
password: "",
|
||||||
|
owner: false,
|
||||||
|
online: 0,
|
||||||
|
id_online: false,
|
||||||
|
new_character: true,
|
||||||
|
name_taken: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn access_rules() {
|
||||||
|
let mut config = ServerConfig::default();
|
||||||
|
assert_eq!(check_access(&config, &request()), Ok(()));
|
||||||
|
let bad_id = AccessRequest {
|
||||||
|
player_id: "nope",
|
||||||
|
..request()
|
||||||
|
};
|
||||||
|
assert!(check_access(&config, &bad_id).is_err());
|
||||||
|
|
||||||
|
config.password = "secret".into();
|
||||||
|
assert!(check_access(&config, &request()).is_err());
|
||||||
|
let with_password = AccessRequest {
|
||||||
|
password: "secret",
|
||||||
|
..request()
|
||||||
|
};
|
||||||
|
assert_eq!(check_access(&config, &with_password), Ok(()));
|
||||||
|
let owner = AccessRequest {
|
||||||
|
owner: true,
|
||||||
|
..request()
|
||||||
|
};
|
||||||
|
assert_eq!(check_access(&config, &owner), Ok(()));
|
||||||
|
config.password.clear();
|
||||||
|
|
||||||
|
config.access.banned = vec!["JANE".into()];
|
||||||
|
assert!(check_access(&config, &request()).is_err());
|
||||||
|
config.access.banned = vec![ID.into()];
|
||||||
|
assert!(check_access(&config, &request()).is_err());
|
||||||
|
config.access.banned.clear();
|
||||||
|
|
||||||
|
config.access.whitelist_enabled = true;
|
||||||
|
assert!(check_access(&config, &request()).is_err());
|
||||||
|
config.access.whitelist = vec!["jane".into()];
|
||||||
|
assert_eq!(check_access(&config, &request()), Ok(()));
|
||||||
|
config.access.whitelist.clear();
|
||||||
|
config.access.admins = vec![ID.into()];
|
||||||
|
assert_eq!(
|
||||||
|
check_access(&config, &request()),
|
||||||
|
Ok(()),
|
||||||
|
"admins bypass the whitelist"
|
||||||
|
);
|
||||||
|
config.access.whitelist_enabled = false;
|
||||||
|
config.access.admins.clear();
|
||||||
|
|
||||||
|
config.max_players = 1;
|
||||||
|
let full = AccessRequest {
|
||||||
|
online: 1,
|
||||||
|
..request()
|
||||||
|
};
|
||||||
|
assert!(check_access(&config, &full).is_err());
|
||||||
|
config.max_players = 32;
|
||||||
|
|
||||||
|
for req in [
|
||||||
|
AccessRequest {
|
||||||
|
id_online: true,
|
||||||
|
..request()
|
||||||
|
},
|
||||||
|
AccessRequest {
|
||||||
|
name_taken: true,
|
||||||
|
..request()
|
||||||
|
},
|
||||||
|
AccessRequest {
|
||||||
|
name: "bad;name",
|
||||||
|
..request()
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(check_access(&config, &req).is_err(), "{req:?}");
|
||||||
|
}
|
||||||
|
let returning = AccessRequest {
|
||||||
|
name_taken: true,
|
||||||
|
new_character: false,
|
||||||
|
..request()
|
||||||
|
};
|
||||||
|
assert_eq!(check_access(&config, &returning), Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn names() {
|
fn names() {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,10 @@ pub struct SpawnPoint(pub GridPos);
|
||||||
|
|
||||||
pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) {
|
pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) {
|
||||||
let assets = game_core::assets_dir();
|
let assets = game_core::assets_dir();
|
||||||
let layout = MapLayout::load(&assets, &settings.map).unwrap_or_else(|e| panic!("{e}"));
|
let layout = match MapLayout::load(&assets, &settings.map) {
|
||||||
|
Ok(layout) => layout,
|
||||||
|
Err(e) => return crate::fail(&mut commands, e.to_string()),
|
||||||
|
};
|
||||||
let spawn = layout
|
let spawn = layout
|
||||||
.objects_of_class("spawn")
|
.objects_of_class("spawn")
|
||||||
.next()
|
.next()
|
||||||
|
|
@ -43,8 +46,15 @@ pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data:
|
||||||
|
|
||||||
let mut save = None;
|
let mut save = None;
|
||||||
if let Some(storage) = &settings.storage {
|
if let Some(storage) = &settings.storage {
|
||||||
let (storage, loaded) = persistence::Storage::open(storage, &settings.map)
|
let (storage, loaded) = match persistence::Storage::open(storage, &settings.map) {
|
||||||
.unwrap_or_else(|e| panic!("failed to open the world database: {e}"));
|
Ok(opened) => opened,
|
||||||
|
Err(e) => {
|
||||||
|
return crate::fail(
|
||||||
|
&mut commands,
|
||||||
|
format!("failed to open the world database: {e}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
info!("world `{}` ({})", storage.world.name, storage.world.id);
|
info!("world `{}` ({})", storage.world.name, storage.world.id);
|
||||||
if loaded.is_some() && storage.world.map != settings.map {
|
if loaded.is_some() && storage.world.map != settings.map {
|
||||||
warn!(
|
warn!(
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,16 @@ fn send(app: &mut App, ev: impl Event) {
|
||||||
app.update();
|
app.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLAYER_ID: &str = "4f1c0c56-1111-4a5e-9a1e-000000000001";
|
||||||
|
|
||||||
|
fn login(name: &str) -> Login {
|
||||||
|
Login {
|
||||||
|
player_id: PLAYER_ID.into(),
|
||||||
|
name: name.into(),
|
||||||
|
password: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn take_log(app: &mut App) -> Vec<String> {
|
fn take_log(app: &mut App) -> Vec<String> {
|
||||||
// Server events also go through a local resend before the client observer sees them.
|
// Server events also go through a local resend before the client observer sees them.
|
||||||
app.update();
|
app.update();
|
||||||
|
|
@ -59,12 +69,7 @@ fn login_move_and_interact() {
|
||||||
let mut buildings = app.world_mut().query::<&Building>();
|
let mut buildings = app.world_mut().query::<&Building>();
|
||||||
assert_eq!(buildings.iter(app.world()).count(), 19);
|
assert_eq!(buildings.iter(app.world()).count(), 19);
|
||||||
|
|
||||||
send(
|
send(&mut app, login("Tester"));
|
||||||
&mut app,
|
|
||||||
Login {
|
|
||||||
name: "Tester".into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let me = app
|
let me = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.query_filtered::<Entity, With<Character>>()
|
.query_filtered::<Entity, With<Character>>()
|
||||||
|
|
@ -75,16 +80,34 @@ fn login_move_and_interact() {
|
||||||
assert!(log.iter().any(|t| t.contains("Tester")), "{log:?}");
|
assert!(log.iter().any(|t| t.contains("Tester")), "{log:?}");
|
||||||
assert_eq!(count(&app, me, "apple"), 2);
|
assert_eq!(count(&app, me, "apple"), 2);
|
||||||
|
|
||||||
// A second login with the same name is rejected.
|
// A second login of the same client is ignored.
|
||||||
send(
|
send(&mut app, login("Tester"));
|
||||||
&mut app,
|
|
||||||
Login {
|
|
||||||
name: "Tester".into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let mut characters = app.world_mut().query::<&Character>();
|
let mut characters = app.world_mut().query::<&Character>();
|
||||||
assert_eq!(characters.iter(app.world()).count(), 1);
|
assert_eq!(characters.iter(app.world()).count(), 1);
|
||||||
|
|
||||||
|
// The local client is the owner and may use admin commands.
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Chat {
|
||||||
|
text: "/whitelist add Someone".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let log = take_log(&mut app);
|
||||||
|
assert!(
|
||||||
|
log.iter().any(|t| t.contains("whitelisted Someone")),
|
||||||
|
"{log:?}"
|
||||||
|
);
|
||||||
|
let config = app.world().resource::<game_server::config::ServerConfig>();
|
||||||
|
assert_eq!(config.access.whitelist, ["Someone"]);
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
Chat {
|
||||||
|
text: "hello there".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let log = take_log(&mut app);
|
||||||
|
assert!(log.iter().any(|t| t == "<Tester> hello there"), "{log:?}");
|
||||||
|
|
||||||
// Walk east one tile.
|
// Walk east one tile.
|
||||||
send(&mut app, SetMoveDir(Some(Dir::East)));
|
send(&mut app, SetMoveDir(Some(Dir::East)));
|
||||||
send(&mut app, SetMoveDir(None));
|
send(&mut app, SetMoveDir(None));
|
||||||
|
|
@ -183,12 +206,7 @@ fn login_move_and_interact() {
|
||||||
#[test]
|
#[test]
|
||||||
fn machines_process_items() {
|
fn machines_process_items() {
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
send(
|
send(&mut app, login("Smith"));
|
||||||
&mut app,
|
|
||||||
Login {
|
|
||||||
name: "Smith".into(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let me = app
|
let me = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.query_filtered::<Entity, With<Character>>()
|
.query_filtered::<Entity, With<Character>>()
|
||||||
|
|
@ -294,3 +312,175 @@ fn machines_process_items() {
|
||||||
send(&mut app, PickUp { target });
|
send(&mut app, PickUp { target });
|
||||||
assert_eq!(count(&app, me, "iron_ore"), 5);
|
assert_eq!(count(&app, me, "iron_ore"), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod network {
|
||||||
|
use std::{
|
||||||
|
net::{Ipv4Addr, SocketAddr},
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
|
use bevy_replicon_renet::RepliconRenetPlugins;
|
||||||
|
use game_server::{
|
||||||
|
config::ServerConfig,
|
||||||
|
embedded::{EmbeddedServer, ServerStatus},
|
||||||
|
network::create_client,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const HOST: &str = "4f1c0c56-1111-4a5e-9a1e-0000000000aa";
|
||||||
|
const GUEST: &str = "4f1c0c56-1111-4a5e-9a1e-0000000000bb";
|
||||||
|
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct Inbox {
|
||||||
|
welcome: Option<Welcome>,
|
||||||
|
rejected: Option<String>,
|
||||||
|
log: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client(addr: SocketAddr) -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins((
|
||||||
|
MinimalPlugins,
|
||||||
|
StatesPlugin,
|
||||||
|
RepliconPlugins,
|
||||||
|
RepliconRenetPlugins,
|
||||||
|
CorePlugin,
|
||||||
|
))
|
||||||
|
.init_resource::<Inbox>()
|
||||||
|
.add_observer(|ev: On<Welcome>, mut inbox: ResMut<Inbox>| {
|
||||||
|
inbox.welcome = Some(ev.event().clone())
|
||||||
|
})
|
||||||
|
.add_observer(|ev: On<Rejected>, mut inbox: ResMut<Inbox>| {
|
||||||
|
inbox.rejected = Some(ev.reason.clone())
|
||||||
|
})
|
||||||
|
.add_observer(|ev: On<GameLog>, mut inbox: ResMut<Inbox>| inbox.log.push(ev.text.clone()));
|
||||||
|
app.finish();
|
||||||
|
app.cleanup();
|
||||||
|
let channels = app.world().resource::<RepliconChannels>();
|
||||||
|
let (client, transport) = create_client(channels, addr).unwrap();
|
||||||
|
app.insert_resource(client).insert_resource(transport);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the clients until `done` returns true for the first one.
|
||||||
|
fn pump(apps: &mut [&mut App], done: impl Fn(&mut App) -> bool) {
|
||||||
|
let start = Instant::now();
|
||||||
|
loop {
|
||||||
|
for app in apps.iter_mut() {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
if done(apps[0]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(start.elapsed() < Duration::from_secs(20), "timed out");
|
||||||
|
std::thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connected(app: &mut App) -> bool {
|
||||||
|
*app.world().resource::<State<ClientState>>() == ClientState::Connected
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join(app: &mut App, id: &str, name: &str, password: &str) {
|
||||||
|
pump(&mut [app], connected);
|
||||||
|
app.world_mut().client_trigger(Login {
|
||||||
|
player_id: id.into(),
|
||||||
|
name: name.into(),
|
||||||
|
password: password.into(),
|
||||||
|
});
|
||||||
|
pump(&mut [app], |app| {
|
||||||
|
let inbox = app.world().resource::<Inbox>();
|
||||||
|
inbox.welcome.is_some() || inbox.rejected.is_some()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inbox(app: &App) -> &Inbox {
|
||||||
|
app.world().resource::<Inbox>()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_admins_and_bans() {
|
||||||
|
let mut config = ServerConfig {
|
||||||
|
port: 0,
|
||||||
|
password: "letmein".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
config.database.save = false;
|
||||||
|
let settings = ServerSettings {
|
||||||
|
owner: Some(HOST.into()),
|
||||||
|
..config.settings()
|
||||||
|
};
|
||||||
|
let server = EmbeddedServer::start(settings, config, None, Ipv4Addr::LOCALHOST.into());
|
||||||
|
let start = Instant::now();
|
||||||
|
let addr = loop {
|
||||||
|
match server.poll() {
|
||||||
|
Some(ServerStatus::Started(addr)) => break addr,
|
||||||
|
Some(other) => panic!("{other:?}"),
|
||||||
|
None if start.elapsed() > Duration::from_secs(30) => panic!("server did not start"),
|
||||||
|
None => std::thread::sleep(Duration::from_millis(10)),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut intruder = client(addr);
|
||||||
|
join(&mut intruder, GUEST, "Guest", "wrong");
|
||||||
|
assert_eq!(
|
||||||
|
inbox(&intruder).rejected.as_deref(),
|
||||||
|
Some("Wrong password.")
|
||||||
|
);
|
||||||
|
|
||||||
|
// The owner needs no password and is always admin.
|
||||||
|
let mut host = client(addr);
|
||||||
|
join(&mut host, HOST, "Host", "");
|
||||||
|
let welcome = inbox(&host).welcome.clone().expect("host welcomed");
|
||||||
|
assert!(welcome.admin);
|
||||||
|
assert_eq!(welcome.character, "Host");
|
||||||
|
|
||||||
|
let mut guest = client(addr);
|
||||||
|
join(&mut guest, GUEST, "Guest", "letmein");
|
||||||
|
assert!(
|
||||||
|
!inbox(&guest)
|
||||||
|
.welcome
|
||||||
|
.as_ref()
|
||||||
|
.expect("guest welcomed")
|
||||||
|
.admin
|
||||||
|
);
|
||||||
|
|
||||||
|
guest.world_mut().client_trigger(Chat {
|
||||||
|
text: "/kick Host".into(),
|
||||||
|
});
|
||||||
|
pump(&mut [&mut guest, &mut host], |app| {
|
||||||
|
inbox(app).log.iter().any(|t| t.contains("not allowed"))
|
||||||
|
});
|
||||||
|
|
||||||
|
host.world_mut().client_trigger(Chat {
|
||||||
|
text: "/ban Guest".into(),
|
||||||
|
});
|
||||||
|
pump(&mut [&mut guest, &mut host], |app| {
|
||||||
|
inbox(app).rejected.is_some()
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
inbox(&guest).rejected.as_deref(),
|
||||||
|
Some("You have been banned from this server.")
|
||||||
|
);
|
||||||
|
pump(&mut [&mut host], |app| {
|
||||||
|
inbox(app).log.iter().any(|t| t.contains("banned Guest"))
|
||||||
|
});
|
||||||
|
|
||||||
|
drop(guest);
|
||||||
|
let mut again = client(addr);
|
||||||
|
join(&mut again, GUEST, "Guest", "letmein");
|
||||||
|
assert_eq!(
|
||||||
|
inbox(&again).rejected.as_deref(),
|
||||||
|
Some("You are banned from this server.")
|
||||||
|
);
|
||||||
|
pump(&mut [&mut host], |app| {
|
||||||
|
inbox(app)
|
||||||
|
.log
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.contains("[admin] Rejected Guest"))
|
||||||
|
});
|
||||||
|
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue