From 2d6f52e8a458826e56d7dd7e1c7828fd0f778fea Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Sun, 18 Oct 2026 22:29:05 +0200 Subject: [PATCH] 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> --- AGENTS.md | 1 + CHANGELOG.md | 19 + Cargo.lock | 4 + README.md | 126 +++- assets/ui/connect.rsn | 55 ++ assets/ui/connecting.rsn | 13 + assets/ui/hud.rsn | 179 ++++++ assets/ui/load_game.rsn | 61 ++ assets/ui/main_menu.rsn | 47 ++ assets/ui/new_game.rsn | 68 ++ assets/ui/theme.rsn | 26 + crates/game_client/Cargo.toml | 4 + crates/game_client/src/hud.rs | 657 +++++++++++++++++++ crates/game_client/src/input.rs | 97 ++- crates/game_client/src/machine.rs | 428 +++++++++++++ crates/game_client/src/main.rs | 177 +++--- crates/game_client/src/menu.rs | 380 +++++++++++ crates/game_client/src/net.rs | 89 --- crates/game_client/src/profile.rs | 81 +++ crates/game_client/src/render.rs | 10 +- crates/game_client/src/saves.rs | 101 ++- crates/game_client/src/session.rs | 316 ++++++++++ crates/game_client/src/ui.rs | 380 ----------- crates/game_client/src/ui/layout.rs | 352 +++++++++++ crates/game_client/src/ui/mod.rs | 840 +++++++++++++++++++++++++ crates/game_core/src/components.rs | 2 +- crates/game_core/src/lib.rs | 2 +- crates/game_core/src/protocol.rs | 40 +- crates/game_server/src/commands.rs | 411 +++++++++++- crates/game_server/src/config.rs | 1 + crates/game_server/src/embedded.rs | 179 ++++++ crates/game_server/src/lib.rs | 41 +- crates/game_server/src/main.rs | 2 +- crates/game_server/src/network.rs | 85 ++- crates/game_server/src/persistence.rs | 16 +- crates/game_server/src/players.rs | 546 +++++++++++++--- crates/game_server/src/world.rs | 16 +- crates/game_server/tests/simulation.rs | 228 ++++++- 38 files changed, 5335 insertions(+), 745 deletions(-) create mode 100644 assets/ui/connect.rsn create mode 100644 assets/ui/connecting.rsn create mode 100644 assets/ui/hud.rsn create mode 100644 assets/ui/load_game.rsn create mode 100644 assets/ui/main_menu.rsn create mode 100644 assets/ui/new_game.rsn create mode 100644 assets/ui/theme.rsn create mode 100644 crates/game_client/src/hud.rs create mode 100644 crates/game_client/src/machine.rs create mode 100644 crates/game_client/src/menu.rs delete mode 100644 crates/game_client/src/net.rs create mode 100644 crates/game_client/src/profile.rs create mode 100644 crates/game_client/src/session.rs delete mode 100644 crates/game_client/src/ui.rs create mode 100644 crates/game_client/src/ui/layout.rs create mode 100644 crates/game_client/src/ui/mod.rs create mode 100644 crates/game_server/src/embedded.rs diff --git a/AGENTS.md b/AGENTS.md index 4949b21..5b89372 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ Station 13, with singleplayer and multiplayer. It is built in Rust (edition - `crates/game_client/`: the `game` binary: rendering, UI and input; embeds the server for singleplayer and hosting. - `assets/data/`: item, recipe, building and skill definitions (`.rsn`). +- `assets/ui/`: client UI layouts and theme (`.rsn`), see the README. - `assets/scripts/`: Rune scripts (`.rn`), referenced as `"module::function"`. - `assets/maps/`, `assets/tilesets/`, `assets/sprites/`: Tiled maps and art. - `xtask/`: project automation (`cargo xtask pack` builds and archives the diff --git a/CHANGELOG.md b/CHANGELOG.md index 9079bb0..fe01b44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,25 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - The dedicated server is configured by `server.toml` plus command-line overrides, and it accepts terminal commands for save, shutdown and live config changes. +- Start menu with new game (optionally hosted), load/delete saved games and + join server screens. Running the client without options shows it. +- Menus and HUD are defined by layout files in `assets/ui/` (rsn) with a shared + theme, `{binding}` placeholders, conditional visibility and tooltips. +- Machine windows: insert and take items from machine slots, using the machine's + slot positions and background image, with a progress bar. +- Chat (Enter/T) with `/` commands, a pause menu (Esc) and clickable inventory, + crafting and build menus. +- Server passwords, admins, a whitelist, bans, kicks and admin commands in the + server terminal and in-game chat. Changes are written to the config file. The + player hosting a world is always admin. +- The client profile (`client.toml`) stores a player id; characters belong to + it instead of to a name. + +### Changed + +- **Breaking:** network protocol version 3. Logins carry a player id and + password, so older clients and servers can't connect. Characters saved by name + in existing worlds are not carried over to player ids. ### Removed diff --git a/Cargo.lock b/Cargo.lock index 3d05d45..521db8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3108,6 +3108,10 @@ dependencies = [ "directories", "game_core", "game_server", + "rsn", + "serde", + "toml", + "uuid", ] [[package]] diff --git a/README.md b/README.md index dbecb1d..5ad6a2a 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,22 @@ multiplayer behave identically. ## Running ```sh -cargo run # singleplayer (save game `singleplayer`) -cargo run -- --host 5000 # singleplayer world that others can join -cargo run -- --connect 127.0.0.1:5000 # join a server or host +cargo run # start menu cargo run -p game_server --bin game-server # dedicated server (saves/server.sqlite, world `main`) ``` -Client options: `--name NAME`, `--save NAME`, `--no-save`, `--map MAP`. +The start menu offers **New game** (optionally open to other players), +**Load game** (play or delete a save) and **Join server**. Command-line options +skip the menu: + +```sh +cargo run -- --save mars # play (or create) the local world `mars` +cargo run -- --save mars --host 5000 --password secret # let others join it +cargo run -- --connect 127.0.0.1:5000 --password secret # join a server or host +``` + +Client options: `--save NAME`, `--no-save`, `--host [PORT]`, +`--connect ADDR[:PORT]`, `--name NAME`, `--password TEXT`, `--map MAP`. Server options: `--config FILE`, `--port PORT`, `--password TEXT`, `--max-players N`, `--database URL`, `--world NAME|UUID`, `--no-save`, `--map MAP`, `--autosave SECS`, `--motd TEXT`, `--admin ID`, `--whitelist`. @@ -46,8 +55,16 @@ On Windows, release builds of the client open no console window, so command-line output such as `--help` and logs isn't shown. Use a debug build to see it. The world autosaves periodically (in the background) and on exit (window close, -Ctrl-C, SIGTERM). Characters are identified by name and are restored when that -name logs in again. +Ctrl-C, SIGTERM). + +The client keeps a profile in `client.toml` in the platform config directory +(`~/.config/station/` on Linux, `%APPDATA%\station\config\` on Windows, +`~/Library/Application Support/station/` on macOS). It holds a random player +id, the last character name and the last server address. Characters belong to +the player id: logging in again restores your character, and its name is fixed +once it exists. Hosting a world uses the server settings in `host.toml` in the +same directory, except that the port and password come from the menu or the +command line. ### Save games and databases @@ -92,19 +109,110 @@ config set autosave_secs 30 stop ``` +### Passwords, admins and bans + +A server with a `password` only accepts players who send it. Admins are listed +by player id or character name in `access.admins` (`--admin ID` adds one for a +run). The player who hosts a world from the client is always admin and may stop +it; in singleplayer you are always admin. With `access.whitelist_enabled`, only +players in `access.whitelist` (and admins) may join. Banned ids and names in +`access.banned` are rejected. + +Commands are typed into the server terminal or, prefixed with `/`, into the +in-game chat. Admin commands change `server.toml` (or `host.toml`) immediately. + +| Command | Who | Action | +|--------------------------------|----------|---------------------------------------------| +| `help`, `players` | everyone | List commands / online players | +| `say MESSAGE` | admins | Message all players | +| `kick PLAYER [REASON]` | admins | Disconnect a player | +| `ban PLAYER`, `unban PLAYER`, `bans` | admins | Ban by name or id (kicks if online), lift or list bans | +| `whitelist on\|off\|list` | admins | Toggle or show the whitelist | +| `whitelist add\|remove PLAYER` | admins | Edit the whitelist | +| `admin add\|remove PLAYER`, `admin list` | admins | Grant, revoke or list admin rights | +| `rejected` | admins | Recently rejected logins (with player ids) | +| `save` | admins | Save the world now | +| `config get KEY`, `config set KEY VALUE` | admins | Read or change a setting, e.g. `config set motd Hi` | +| `stop` | console, host | Save and shut down | + +> **Security:** the network transport is unencrypted and unauthenticated. The +> password is sent in plain text and player ids can be impersonated by anyone who +> knows them. Treat the password as a guard against casual visitors, not as +> real security, and don't reuse a valuable password. + ## Controls | Key | Action | |---------------------|------------------------------------------------------| | WASD / arrows | Move (you face the direction you move) | | E | Pick up (own tile, otherwise the faced tile) | -| F / left click | Interact with the faced / clicked building | +| F / left click | Interact with the faced / clicked building; machines open their window | | U | Use the selected item (eat, heal, …) | | G | Drop the selected item | | X | Deconstruct the faced building | -| 1–9, 0, mouse wheel | Select an inventory slot | -| C / B | Crafting / build menu (W/S select, Enter confirm, Esc close) | +| 1–9, 0, mouse wheel | Select an inventory slot (or click it) | +| C / B | Crafting / build menu (W/S select, Enter confirm, or click) | | H | Help | +| Enter / T | Chat; `/` starts a command | +| Esc | Close the open window, otherwise pause menu (leave world, quit) | + +In a machine window, click an empty input slot to insert the selected inventory +item and click a filled slot to take its items. Hover over items, recipes and +buttons for tooltips. + +## User interface + +The menus and the HUD are built from layout files in `assets/ui/` and can be +restyled or rearranged without recompiling: + +| File | Screen | +|--------------------|------------------------------------------------| +| `theme.rsn` | Font sizes, spacing and named colors used by all layouts | +| `main_menu.rsn`, `new_game.rsn`, `load_game.rsn`, `connect.rsn`, `connecting.rsn` | Start menu screens | +| `hud.rsn` | In-game HUD, menus, chat and pause overlay | + +A layout has one `root` node. Nodes: + +| Node | Fields | +|------------------------------|------------------------------------------------------------| +| `Column {..}`, `Row {..}`, `Panel {..}` | `children`, `gap`, `padding`, `align` (`Start`, `Center`, `End`, `Stretch`), `justify` (`Start`, `Center`, `End`, `SpaceBetween`), `width`/`height` (`Auto`, `Px(n)`, `Percent(n)`, `Fill`), `anchor` (`TopLeft` … `BottomRight`, relative to the screen), `offset: (x, y)`, `background`, `border`, `radius` | +| `Text {..}` | `text`, `size`, `color`, `max_width` | +| `Button {..}` | `text`, `action`, `width` | +| `Input {..}` | `id`, `value`, `placeholder`, `password`, `max_len`, `width`, `submit` (action on Enter) | +| `Checkbox {..}` | `id`, `text`, `checked` | +| `Image {..}` | `path` (PNG below `assets/`), `width`, `height` | +| `Spacer` | Takes up the remaining space | +| `Widget("name")` | Content generated by the game | + +Every node except `Spacer` and `Widget` also accepts `tooltip` and +`visible`. `visible` names a binding or form field and shows the node only +while it is set; prefix it with `!` to invert it. Colors are `#rrggbb`, +`#rrggbbaa` or a theme color name. Texts, tooltips and input values may contain +`{binding}` placeholders: + +```rsn +Panel { + anchor: TopLeft, + offset: (10, 10), + children: [ + Text { text: "{character}", color: "accent", tooltip: "Playing on {server}" }, + Text { text: "You are hungry!", color: "error", visible: "hungry" }, + Button { text: "Craft", action: "menu:craft", tooltip: "Crafting (C)" }, + ], +} +``` + +- **Bindings:** menu: `message`, `profile_name`, `last_address`, + `default_port`, `saves_dir`, `save_count`, `has_saves`, `suggested_world`, + `version`, `status`. HUD: `character`, `server`, `admin`, `health`, + `max_health`, `nutrition`, `hungry`, `position`, `craft_open`, `build_open`, + `help_open`, `chat_open`, `paused`. +- **Widgets:** `save_list` (menu); `inventory`, `skills`, `log`, `craft_list`, + `build_list` (HUD). +- **Actions:** `screen:main|new_game|load_game|connect`, `new_game`, `join`, + `quit` (menu); `menu:craft|build|help|close`, `chat_send`, `resume`, `leave`, + `quit` (HUD). The menu forms read the inputs `world`, `name`, `address`, + `password`, `port` and the checkbox `host`; the HUD chat input is `chat`. ## Modding diff --git a/assets/ui/connect.rsn b/assets/ui/connect.rsn new file mode 100644 index 0000000..b4db676 --- /dev/null +++ b/assets/ui/connect.rsn @@ -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" }, + ], + }, + ], + }, + ], +} diff --git a/assets/ui/connecting.rsn b/assets/ui/connecting.rsn new file mode 100644 index 0000000..6c79a91 --- /dev/null +++ b/assets/ui/connecting.rsn @@ -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" }, + ], +} diff --git a/assets/ui/hud.rsn b/assets/ui/hud.rsn new file mode 100644 index 0000000..b3e2ac2 --- /dev/null +++ b/assets/ui/hud.rsn @@ -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." }, + ], + }, + ], + }, + ], +} diff --git a/assets/ui/load_game.rsn b/assets/ui/load_game.rsn new file mode 100644 index 0000000..79083e2 --- /dev/null +++ b/assets/ui/load_game.rsn @@ -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" }, + ], +} diff --git a/assets/ui/main_menu.rsn b/assets/ui/main_menu.rsn new file mode 100644 index 0000000..a283ae7 --- /dev/null +++ b/assets/ui/main_menu.rsn @@ -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" }, + ], + }, + ], +} diff --git a/assets/ui/new_game.rsn b/assets/ui/new_game.rsn new file mode 100644 index 0000000..66c7ee3 --- /dev/null +++ b/assets/ui/new_game.rsn @@ -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" }, + ], + }, + ], + }, + ], +} diff --git a/assets/ui/theme.rsn b/assets/ui/theme.rsn new file mode 100644 index 0000000..60f5d24 --- /dev/null +++ b/assets/ui/theme.rsn @@ -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", +} diff --git a/crates/game_client/Cargo.toml b/crates/game_client/Cargo.toml index 6000ff6..849cab6 100644 --- a/crates/game_client/Cargo.toml +++ b/crates/game_client/Cargo.toml @@ -14,6 +14,10 @@ bevy = { workspace = true, default-features = true } bevy_replicon = { workspace = true } bevy_replicon_renet = { workspace = true } directories = { workspace = true } +rsn = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } +uuid = { workspace = true } [lints] workspace = true diff --git a/crates/game_client/src/hud.rs b/crates/game_client/src/hud.rs new file mode 100644 index 0000000..01d3d21 --- /dev/null +++ b/crates/game_client/src/hud.rs @@ -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::() + .init_resource::() + .add_observer(|ev: On, mut log: ResMut| { + log.push(ev.text.clone()) + }) + .add_observer(on_action) + .add_observer( + |_: On, mut log: ResMut, mut ui: ResMut| { + *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, +} + +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, + /// 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 { + 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, + bindings: Res, + mut form: ResMut, + assets: Res, +) { + 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, + session: Res, + ui: Res, + characters: Query<(&Vitals, &GridPos)>, + mut bindings: ResMut, +) { + 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, + mut focus: ResMut, + mut opened: Local, + 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, 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, + 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, + assets: Res, + data: Res, + ui: Res, + machine: Res, + local: Res, + 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, + data: Res, + local: Res, + 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, + log: Res, + 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 ``, 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::>() + .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 { + 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, + data: Res, + ui: Res, + local: Res, + 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 = 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, + mut commands: Commands, + state: Res>, + mut ui: ResMut, + mut form: ResMut, + machine: Res, + local: Res, + 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::() 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); + } + _ => {} + } +} diff --git a/crates/game_client/src/input.rs b/crates/game_client/src/input.rs index b08e178..a1ba0d7 100644 --- a/crates/game_client/src/input.rs +++ b/crates/game_client/src/input.rs @@ -11,15 +11,64 @@ use game_core::{ use game_server::actions::REACH; use crate::{ + hud::{Menu, UiState, activate_entry, menu_entries}, + machine::{InteractAt, OpenMachine}, render::LocalCharacter, - ui::{Menu, UiState, menu_entries}, + session::AppState, + ui::{TextEditSystems, UiFocus, pointer_over_ui}, }; pub struct InputPlugin; impl Plugin for InputPlugin { fn build(&self, app: &mut App) { - app.add_systems(Update, (movement, menu_input, actions, mouse).chain()); + app.add_systems( + Update, + ( + window_keys, + movement, + (menu_input, actions, mouse).run_if(game_input_allowed), + ) + .chain() + .after(TextEditSystems) + .run_if(in_state(AppState::InGame)), + ); + } +} + +fn game_input_allowed(focus: Res, ui: Res) -> 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>, + focus: Res, + mut ui: ResMut, + mut machine: ResMut, + mut form: ResMut, +) { + if focus.captures_keyboard() || ui.chat_open { + return; + } + if keys.just_pressed(KeyCode::Escape) { + if machine.0.is_some() { + machine.0 = None; + } else if ui.menu.is_some() { + ui.menu = None; + } else { + ui.paused = !ui.paused; + } + return; + } + if ui.paused || ui.menu.is_some_and(|m| m != Menu::Help) { + return; + } + if keys.any_just_pressed([KeyCode::Enter, KeyCode::NumpadEnter, KeyCode::KeyT]) { + ui.chat_open = true; + } else if keys.any_just_pressed([KeyCode::Slash, KeyCode::NumpadDivide]) { + form.0.insert("chat".into(), "/".into()); + ui.chat_open = true; } } @@ -39,6 +88,7 @@ fn movement( mut commands: Commands, keys: Res>, ui: Res, + focus: Res, mut held: Local>, mut sent: Local>, ) { @@ -52,7 +102,7 @@ fn movement( held.push(dir); } } - let dir = if ui.menu.is_some_and(|m| m != Menu::Help) { + let dir = if ui.blocks_game() || ui.chat_open || focus.captures_keyboard() { None } else { held.last().copied() @@ -80,10 +130,6 @@ fn menu_input( characters: Query<(&GridPos, &Facing, &Inventory), With>, ) { let Some(menu) = ui.menu else { return }; - if keys.just_pressed(KeyCode::Escape) { - ui.menu = None; - return; - } let entries = menu_entries(menu, &data); if entries.is_empty() { return; @@ -96,17 +142,8 @@ fn menu_input( } if keys.any_just_pressed([KeyCode::Enter, KeyCode::Space, KeyCode::NumpadEnter]) { let id = entries[ui.cursor.min(entries.len() - 1)].clone(); - match menu { - Menu::Craft => commands.client_trigger(Craft { recipe: id }), - Menu::Build => { - if let Some((pos, facing, _)) = local_state(&local, &characters) { - commands.client_trigger(Construct { - building: id, - target: pos.step(facing), - }); - } - } - Menu::Help => {} + if let Some((pos, facing, _)) = local_state(&local, &characters) { + activate_entry(&mut commands, menu, id, pos, facing); } } } @@ -130,6 +167,7 @@ fn actions( keys: Res>, mut wheel: MessageReader, mut ui: ResMut, + machine: Res, local: Res, characters: Query<(&GridPos, &Facing, &Inventory), With>, items: Query<&GridPos, With>, @@ -151,9 +189,16 @@ fn actions( ui.selected_slot = i; } } - for ev in wheel.read() { + let wheel_events: Vec = wheel.read().map(|ev| ev.y).collect(); + // Open windows have their own list navigation. + let wheel_events = if ui.menu.is_some() || machine.0.is_some() { + Vec::new() + } else { + wheel_events + }; + for y in wheel_events { let len = INVENTORY_SLOTS; - ui.selected_slot = if ev.y > 0.0 { + ui.selected_slot = if y > 0.0 { (ui.selected_slot + len - 1) % len } else { (ui.selected_slot + 1) % len @@ -178,7 +223,7 @@ fn actions( commands.client_trigger(PickUp { target }); } if keys.just_pressed(KeyCode::KeyF) { - commands.client_trigger(Interact { target: front }); + commands.trigger(InteractAt(front)); } if keys.just_pressed(KeyCode::KeyX) { commands.client_trigger(Deconstruct { target: front }); @@ -196,16 +241,22 @@ fn actions( } /// Left click on a tile within reach: interact with a building, otherwise pick up items. +#[allow(clippy::too_many_arguments)] fn mouse( mut commands: Commands, buttons: Res>, + interactions: Query<&Interaction>, + ui: Res, window: Query<&Window, With>, camera: Query<(&Camera, &GlobalTransform)>, local: Res, characters: Query<&GridPos, With>, buildings: Query<&GridPos, With>, ) { - if !buttons.just_pressed(MouseButton::Left) { + if !buttons.just_pressed(MouseButton::Left) + || ui.blocks_game() + || pointer_over_ui(&interactions) + { return; } let Some(me) = local.0.and_then(|e| characters.get(e).ok()) else { @@ -228,7 +279,7 @@ fn mouse( return; } if buildings.iter().any(|p| *p == target) { - commands.client_trigger(Interact { target }); + commands.trigger(InteractAt(target)); } else { commands.client_trigger(PickUp { target }); } diff --git a/crates/game_client/src/machine.rs b/crates/game_client/src/machine.rs new file mode 100644 index 0000000..8e926d3 --- /dev/null +++ b/crates/game_client/src/machine.rs @@ -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::() + .add_observer(interact_at) + .add_observer(on_action) + .add_observer(|_: On, mut open: ResMut| 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); + +/// 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, + mut commands: Commands, + mut open: ResMut, + mut ui: ResMut, + machines: Query<(Entity, &GridPos), With>, +) { + 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, + local: Res, + positions: Query<&GridPos>, + machines: Query<&GridPos, With>, +) { + 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, + theme: Res, + data: Res, + assets: Res, + 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 = 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::>() + .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, + theme: Res, + data: Res, + assets: Res, + machines: Query<(&Building, &Machine)>, + mut areas: Query<(Entity, &mut SlotArea)>, + slots: Query>, + 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, + data: Res, + machines: Query<(&Building, &Machine)>, + mut fills: Query<&mut Node, With>, +) { + 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, + mut commands: Commands, + mut open: ResMut, + ui: Res, + local: Res, + 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, + }); + } + } +} diff --git a/crates/game_client/src/main.rs b/crates/game_client/src/main.rs index ce5a95d..f62b9db 100644 --- a/crates/game_client/src/main.rs +++ b/crates/game_client/src/main.rs @@ -1,51 +1,39 @@ //! Game client. //! //! ```text -//! game singleplayer (persistent local world) -//! game --host [PORT] singleplayer world that friends can join +//! game start menu +//! game --save NAME play a local world directly +//! game --host [PORT] play a local world that friends can join //! game --connect ADDR[:PORT] join a dedicated server or host //! ``` -//! Common options: `--name NAME`, `--save NAME`, `--map MAP`. +//! Common options: `--name NAME`, `--password PASSWORD`, `--map MAP`, `--no-save`. // Release builds on Windows run without a console window. #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] +mod hud; mod input; -mod net; +mod machine; +mod menu; +mod profile; mod render; mod saves; +mod session; mod ui; -use std::net::SocketAddr; - use bevy::{prelude::*, window::WindowResolution}; use bevy_replicon::prelude::*; use bevy_replicon_renet::RepliconRenetPlugins; use game_core::CorePlugin; -use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings, config::ServerConfig}; -/// How this client runs. -#[derive(Clone, Debug)] -pub enum Mode { - /// Server logic runs in-process without networking. - Singleplayer, - /// Server logic runs in-process and accepts remote players. - Host { port: u16 }, - /// Pure client connected to a remote server. - Join { addr: SocketAddr }, -} - -/// The local player's login name, used to find our character among replicated ones. -#[derive(Resource, Clone, Debug)] -pub struct LocalPlayer { - pub name: String, -} - -#[derive(Resource, Clone, Debug)] -pub struct ClientMode(pub Mode); +use crate::{ + profile::Profile, + session::{AppState, SessionRequest, SessionTarget}, +}; fn main() -> AppExit { - let (mode, name, settings) = parse_args(); + let mut profile = Profile::load(); + let (request, map) = parse_args(&mut profile); let mut app = App::new(); let assets = game_core::assets_dir(); app.add_plugins(( @@ -57,7 +45,7 @@ fn main() -> AppExit { .set(ImagePlugin::default_nearest()) .set(WindowPlugin { primary_window: Some(Window { - title: format!("Station - {name}"), + title: "Station".to_owned(), resolution: WindowResolution::new(1280, 800), ..default() }), @@ -66,48 +54,36 @@ fn main() -> AppExit { RepliconPlugins, RepliconRenetPlugins, CorePlugin, + )) + .insert_resource(profile) + .insert_resource(menu::StartMap(map)) + .insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.05))) + .add_plugins(( + ui::UiFrameworkPlugin, + session::SessionPlugin, + menu::MenuPlugin, + hud::HudPlugin, + machine::MachinePlugin, + render::RenderPlugin, + input::InputPlugin, )); - - match &mode { - Mode::Singleplayer => { - app.add_plugins(GameServerPlugin { settings }); - } - Mode::Host { port } => { - app.add_plugins(( - GameServerPlugin { settings }, - NetworkServerPlugin { - config: ServerConfig { - port: *port, - ..default() - }, - }, - )); - } - Mode::Join { .. } => {} + if let Some(request) = request { + app.insert_resource(request); + app.world_mut() + .resource_mut::>() + .set(AppState::Connecting); } - - app.insert_resource(LocalPlayer { name }) - .insert_resource(ClientMode(mode)) - .insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.05))) - .add_plugins(( - net::NetPlugin, - render::RenderPlugin, - input::InputPlugin, - ui::UiPlugin, - )) - .run() + app.run() } -fn parse_args() -> (Mode, String, ServerSettings) { - let mut mode = Mode::Singleplayer; - let mut name = std::env::var("USER") - .ok() - .filter(|n| game_server::players::valid_name(n)) - .unwrap_or_else(|| "Player".to_owned()); - let mut settings = ServerSettings { - storage: Some(saves::storage("singleplayer")), - ..default() - }; +/// Returns the session to start right away (skipping the menu), if any, and the map for new worlds. +fn parse_args(profile: &mut Profile) -> (Option, String) { + let mut host = None; + let mut connect = None; + let mut save = None; + let mut no_save = false; + let mut password = String::new(); + let mut map = game_core::DEFAULT_MAP.to_owned(); let mut args = std::env::args().skip(1).peekable(); while let Some(arg) = args.next() { let mut value = |what: &str| { @@ -116,41 +92,49 @@ fn parse_args() -> (Mode, String, ServerSettings) { }; match arg.as_str() { "--host" => { - let port = match args.peek() { + host = Some(match args.peek() { Some(p) if !p.starts_with("--") => args .next() .unwrap() .parse() .unwrap_or_else(|_| usage("invalid port")), _ => game_core::DEFAULT_PORT, - }; - mode = Mode::Host { port }; + }); } - "--connect" => { - let addr = value("an address"); - let addr = if addr.contains(':') { - addr - } else { - format!("{addr}:{}", game_core::DEFAULT_PORT) - }; - let addr = std::net::ToSocketAddrs::to_socket_addrs(&addr) - .ok() - .and_then(|mut a| a.next()) - .unwrap_or_else(|| usage(&format!("can't resolve `{addr}`"))); - mode = Mode::Join { addr }; - } - "--name" => name = value("a name"), - "--save" => settings.storage = Some(saves::storage(&value("a save name"))), - "--no-save" => settings.storage = None, - "--map" => settings.map = value("a map path"), + "--connect" => connect = Some(value("an address")), + "--name" => profile.name = value("a name"), + "--password" => password = value("a password"), + "--save" => save = Some(value("a save name")), + "--no-save" => no_save = true, + "--map" => map = value("a map path"), "-h" | "--help" => usage(""), other => usage(&format!("unknown argument `{other}`")), } } - if !game_server::players::valid_name(&name) { + if !game_server::players::valid_name(&profile.name) { usage("names may only contain letters, digits, spaces, `_` and `-` (max 24)"); } - (mode, name, settings) + let target = match (connect, host) { + (Some(_), Some(_)) => usage("--connect and --host can't be combined"), + (Some(address), None) => { + if save.is_some() || no_save { + usage("--save and --no-save only apply to local worlds"); + } + SessionTarget::Remote { address } + } + (None, host) if host.is_some() || save.is_some() || no_save => SessionTarget::Local { + storage: (!no_save).then(|| saves::storage(save.as_deref().unwrap_or("singleplayer"))), + map: map.clone(), + host: host.map(|port| (port, password.clone())), + }, + _ => return (None, map), + }; + let request = SessionRequest { + target, + name: profile.name.clone(), + password, + }; + (Some(request), map) } fn usage(error: &str) -> ! { @@ -158,13 +142,16 @@ fn usage(error: &str) -> ! { eprintln!("error: {error}\n"); } eprintln!( - "usage: game [--host [PORT] | --connect ADDR[:PORT]] [--name NAME] [--save NAME | --no-save] [--map MAP]\n\n\ - (no mode) singleplayer, world saved as `singleplayer`\n\ - --host play your world and let others join (default port {port})\n\ - --connect join a server (default port {port})\n\ - --name character name; characters persist per world\n\ - --save save game for singleplayer/host, stored in {saves}\n\ - --map Tiled map for new worlds (default {map})", + "usage: game [--save NAME | --no-save] [--host [PORT]] [--connect ADDR[:PORT]]\n \ + [--name NAME] [--password PASSWORD] [--map MAP]\n\n\ + (no options) show the start menu\n\ + --save play the local world NAME (created if missing), stored in {saves}\n\ + --no-save play a local world that is not saved\n\ + --host let others join your local world (default port {port}, password from --password)\n\ + --connect join a server (default port {port})\n\ + --name character name; characters persist per world\n\ + --password server password (joining) or password for others (hosting)\n\ + --map Tiled map for new worlds (default {map})", port = game_core::DEFAULT_PORT, map = game_core::DEFAULT_MAP, saves = saves::saves_dir().display(), diff --git a/crates/game_client/src/menu.rs b/crates/game_client/src/menu.rs new file mode 100644 index 0000000..67af81b --- /dev/null +++ b/crates/game_client/src/menu.rs @@ -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::() + .init_resource::() + .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, message: Res, mut screen: ResMut| { + 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, + /// Save to delete on the second click. + confirm_delete: Option, +} + +#[derive(Component)] +struct MenuRoot; + +fn menu_bindings( + message: Res, + profile: Res, + saves: Res, + mut bindings: ResMut, +) { + 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, + roots: Query>, + theme: Res, + mut bindings: ResMut, + mut form: ResMut, + assets: Res, + mut saves: ResMut, +) { + 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, + bindings: Res, + mut form: ResMut, + assets: Res, +) { + 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, + saves: Res, + 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, String> { + if !form.checked("host") { + return Ok(None); + } + let port = form + .get("port") + .trim() + .parse::() + .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 { + 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, + mut commands: Commands, + state: Res>, + mut screen: ResMut, + mut message: ResMut, + mut saves: ResMut, + mut profile: ResMut, + form: Res, + map: Res, + mut next: ResMut>, +) { + 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, 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::() + .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::().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, + } +} diff --git a/crates/game_client/src/net.rs b/crates/game_client/src/net.rs deleted file mode 100644 index f421614..0000000 --- a/crates/game_client/src/net.rs +++ /dev/null @@ -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) -> bool { - !matches!(mode.0, Mode::Join { .. }) -} - -fn connect( - mut commands: Commands, - mode: Res, - channels: Res, - mut log: ResMut, -) { - let Mode::Join { addr } = mode.0 else { return }; - let result = (|| -> Result<_, Box> { - 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) { - commands.client_trigger(Login { - name: player.name.clone(), - }); -} - -fn connected(mut log: ResMut) { - log.push("Connected.".into()); -} - -fn disconnected(mut log: ResMut, transport: Option>) { - let reason = transport - .and_then(|t| t.disconnect_reason()) - .map(|r| format!(" ({r:?})")) - .unwrap_or_default(); - log.push(format!("Disconnected from server{reason}.")); -} diff --git a/crates/game_client/src/profile.rs b/crates/game_client/src/profile.rs new file mode 100644 index 0000000..b7cc599 --- /dev/null +++ b/crates/game_client/src/profile.rs @@ -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::(&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()); + } + } +} diff --git a/crates/game_client/src/render.rs b/crates/game_client/src/render.rs index 36d1b4e..ac4b8da 100644 --- a/crates/game_client/src/render.rs +++ b/crates/game_client/src/render.rs @@ -8,7 +8,7 @@ use game_core::{ map::MapLayout, }; -use crate::LocalPlayer; +use crate::session::SessionInfo; pub struct RenderPlugin; @@ -130,15 +130,19 @@ fn spawn_map( fn find_local_character( mut local: ResMut, - player: Res, + session: Res, characters: Query<(Entity, &Character)>, ) { if local.0.is_some_and(|e| characters.contains(e)) { return; } + if session.character.is_empty() { + local.0 = None; + return; + } local.0 = characters .iter() - .find(|(_, c)| c.name == player.name) + .find(|(_, c)| c.name == session.character) .map(|(e, _)| e); } diff --git a/crates/game_client/src/saves.rs b/crates/game_client/src/saves.rs index 3aaccf4..ad83931 100644 --- a/crates/game_client/src/saves.rs +++ b/crates/game_client/src/saves.rs @@ -1,9 +1,9 @@ //! Local save games: one SQLite database per save in the platform's data directory //! (e.g. `~/.local/share/station/saves` on Linux, `%APPDATA%\station\data\saves` on Windows). -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use game_server::persistence::{StorageSettings, WorldSelector, sqlite_url}; +use game_server::persistence::{Database, StorageSettings, WorldInfo, WorldSelector, sqlite_url}; /// Directory holding the save databases. Falls back to `./saves` if the platform has no home. pub fn saves_dir() -> PathBuf { @@ -39,6 +39,103 @@ pub fn storage(name: &str) -> StorageSettings { } } +/// A world stored in a save file. +#[derive(Clone, Debug)] +pub struct SaveEntry { + pub path: PathBuf, + pub world: WorldInfo, +} + +impl SaveEntry { + pub fn storage(&self) -> StorageSettings { + StorageSettings { + database_url: sqlite_url(&self.path), + world: WorldSelector::Id(self.world.id), + } + } +} + +/// All worlds in the saves directory, most recently saved first. +pub fn list() -> Vec { + let Ok(dir) = std::fs::read_dir(saves_dir()) else { + return Vec::new(); + }; + let files: Vec = dir + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|e| e == "sqlite")) + .collect(); + let runtime = game_server::persistence::runtime(); + let mut saves = Vec::new(); + for path in files { + let worlds = runtime.block_on(async { + let db = Database::connect(&sqlite_url(&path)).await?; + let worlds = db.list_worlds().await; + db.close().await; + worlds + }); + match worlds { + Ok(worlds) => saves.extend(worlds.into_iter().map(|world| SaveEntry { + path: path.clone(), + world, + })), + Err(e) => bevy::log::warn!("skipping save {}: {e}", path.display()), + } + } + saves.sort_by_key(|s| std::cmp::Reverse(s.world.saved_at)); + saves +} + +/// Whether a save called `name` exists. +pub fn exists(name: &str) -> bool { + let path = saves_dir().join(format!("{}.sqlite", slug(name))); + path.exists() + && list() + .iter() + .any(|s| s.path == path && s.world.name == name) +} + +/// Deletes a world, and its file once no other world is left in it. +pub fn delete(save: &SaveEntry) -> Result<(), String> { + let runtime = game_server::persistence::runtime(); + let remaining = runtime + .block_on(async { + let db = Database::connect(&sqlite_url(&save.path)).await?; + db.delete_world(save.world.id).await?; + let remaining = db.list_worlds().await?.len(); + db.close().await; + Ok::<_, game_server::persistence::StorageError>(remaining) + }) + .map_err(|e| e.to_string())?; + if remaining == 0 { + remove_database(&save.path).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn remove_database(path: &Path) -> std::io::Result<()> { + std::fs::remove_file(path)?; + for suffix in ["-wal", "-shm"] { + let mut extra = path.as_os_str().to_owned(); + extra.push(suffix); + let _ = std::fs::remove_file(extra); + } + Ok(()) +} + +/// Human readable age of a unix timestamp. +pub fn age(timestamp: i64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as i64); + let secs = (now - timestamp).max(0); + match secs { + 0..60 => "just now".into(), + 60..3600 => format!("{} min ago", secs / 60), + 3600..86400 => format!("{} h ago", secs / 3600), + _ => format!("{} days ago", secs / 86400), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/game_client/src/session.rs b/crates/game_client/src/session.rs new file mode 100644 index 0000000..cefacaf --- /dev/null +++ b/crates/game_client/src/session.rs @@ -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::() + .init_resource::() + .init_resource::() + .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, + 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>, + profile: Res, + channels: Res, + mut bindings: ResMut, +) { + 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 { + let address = address.trim(); + if address.is_empty() { + return Err("Enter a server address.".into()); + } + let has_port = address.parse::().is_ok() + || address + .rsplit_once(':') + .is_some_and(|(host, port)| !host.contains(':') && port.parse::().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>, + channels: Res, + mut bindings: ResMut, +) { + 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>, + transport: Option>, +) { + 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>, profile: Res) { + 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, + mut info: ResMut, + mut state: ResMut>, +) { + *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, mut commands: Commands) { + let reason = rejected.reason.clone(); + commands.queue(move |world: &mut World| leave(world, Some(reason))); +} + +fn admin_status(status: On, mut info: ResMut) { + info.admin = status.admin; +} + +fn stop_on_exit(mut exit: MessageReader, 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) { + if let Some(mut transport) = world.remove_resource::() { + transport.disconnect(); + } + world.remove_resource::(); + if let Some(server) = world.remove_resource::() { + server.0.stop(); + } + let remote: Vec = world + .query_filtered::>() + .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::().0 = message; + } + world.trigger(SessionEnded); + world + .resource_mut::>() + .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()); + } +} diff --git a/crates/game_client/src/ui.rs b/crates/game_client/src/ui.rs deleted file mode 100644 index 0a4889c..0000000 --- a/crates/game_client/src/ui.rs +++ /dev/null @@ -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::() - .init_resource::() - .add_observer(|ev: On, mut log: ResMut| { - 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, -} - -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, - 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 { - 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, - player: Res, - 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, - data: Res, - ui: Res, - 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, - data: Res, - 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, mut panels: Query<(&Panel, &mut Text)>) { - if !log.is_changed() { - return; - } - let text = log.lines.iter().cloned().collect::>().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::>() - .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, - data: Res, - ui: Res, - 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 = 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; - } -} diff --git a/crates/game_client/src/ui/layout.rs b/crates/game_client/src/ui/layout.rs new file mode 100644 index 0000000..71ece34 --- /dev/null +++ b/crates/game_client/src/ui/layout.rs @@ -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, + pub padding: Option, + /// 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, + /// Distance from the anchored edges, in pixels. + pub offset: (f32, f32), + pub background: Option, + pub border: Option, + pub radius: Option, + pub tooltip: Option, + /// Only shown while this binding or form field is set (non-empty and not `false`). + /// Prefix with `!` to invert. + pub visible: Option, + pub children: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +pub struct TextNode { + pub text: String, + pub size: Option, + pub color: Option, + pub tooltip: Option, + pub visible: Option, + /// Maximum width before wrapping, in pixels. + pub max_width: Option, +} + +#[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, + pub visible: Option, +} + +#[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, + pub tooltip: Option, + pub visible: Option, +} + +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, + pub visible: Option, +} + +#[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, + pub visible: Option, +} + +#[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, +} + +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 { + 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(path: &Path) -> Result { + 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/.rsn`. Files are read every time a screen opens, so +/// edits show up without restarting the game. +pub fn load_layout(name: &str) -> Result { + parse::(&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::(&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)); + } +} diff --git a/crates/game_client/src/ui/mod.rs b/crates/game_client/src/ui/mod.rs new file mode 100644 index 0000000..f15d876 --- /dev/null +++ b/crates/game_client/src/ui/mod.rs @@ -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::() + .init_resource::() + .init_resource::() + .init_resource::() + .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); + +impl UiBindings { + pub fn set(&mut self, key: &str, value: impl Into) { + 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); + +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, + /// 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, +} + +#[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, 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/.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(entity: &mut EntityCommands, component: Option) { + if let Some(component) = component { + entity.insert(component); + } +} + +fn common(entity: &mut EntityCommands, tooltip: &Option, visible: &Option) { + 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::() + .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>, + clicked: Query<(Entity, &Interaction, Option<&OnClick>), Changed>, + inputs: Query<(), With>, + mut checkboxes: Query<&Checkbox>, + mut form: ResMut, + mut focus: ResMut, +) { + 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, + mut buttons: Query< + (&Interaction, &mut BackgroundColor), + (Changed, With), + >, +) { + 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, + 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, + mut focus: ResMut, + mut form: ResMut, + 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 = 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, + time: Res