Compare commits

..

18 Commits
v0.0.1 ... main

Author SHA1 Message Date
Steve Biedermann 3a24531529 feat!: simulate multiple map levels connected by teleports
Test / Clippy and tests (nextest) (push) Failing after 1m2s Details
Windows package / Build Windows package (push) Failing after 53s Details
Worlds now consist of levels: the server's map plus every map reached
through `teleport` triggers (`target` object name, optional `map` file
property relative to the map). All levels are simulated all the time;
replicon only sends clients their character's level, and singleplayer
hides other levels client-side. Interactions, construction, machines and
item drops are confined to the actor's level.

The default map gains stairs in the main hall leading to a new cellar
level and stairs back up.

Exports write every level's map, naming the main map after the server's
map; import validates and replaces all levels and moves characters on
removed levels to the spawn point.

BREAKING CHANGE: save schema version 3 stores entity levels and level
terrain. Exported main maps are named after the server's map (for example
`station.tmx`) instead of `world.tmx`. `Level` is a new replicated
component, changing the network protocol.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 02:13:09 +02:00
Steve Biedermann 823f0f76e6 feat(server)!: export worlds as ready-to-use Tiled projects
`export [DIR]` now writes a project directory containing world.tmx, a
station.tiled-project that loads the bundled extensions, a copy of the
extensions, relative tileset image copies and a README. An existing
project file is kept on re-export. `import` accepts the directory or a
map file; copied tileset images map back to the identical asset paths.

Add the Tiled action "Station: Convert All Project Maps", which converts
painted gameplay tiles in every map of the opened project at once and
leaves the results open for review.

BREAKING CHANGE: the export path is now a directory instead of a .tmx
file, and default export locations changed to <db-stem>.<world>.tiled/,
saves/<world>/ or saves/world/.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 01:01:04 +02:00
Steve Biedermann 232305490d feat(tiled): paint objects with independent brush properties
Test / Clippy and tests (nextest) (push) Failing after 1m33s Details
Windows package / Build Windows package (push) Failing after 41s Details
Add a grid-snapped object brush with configurable per-tile defaults, drag previews, independently editable building properties and one-step stroke undo. Validate placement before changing the map, preserve integer properties and report mouse-callback errors without blocking the editor.

Ship a Tiled project that loads the bundled extensions after the standard trust prompt, ignore local editor sessions and document the painting workflow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-21 23:36:34 +02:00
Steve Biedermann 3c568dc6f3 chore: format Cargo.toml 2026-10-21 22:59:03 +02:00
Steve Biedermann 402aa12043 fix(tiled): match default layer order and visibility
Place Objects above Ground Items and Triggers, with Floors and Wiring/Plumbing hidden by default. Apply the defaults to exports and newly created conversion layers while preserving existing layer visibility. Verify hidden layers still import gameplay entities.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-21 22:53:24 +02:00
Steve Biedermann b0fa5b3ec4 feat(xtask): serve watched packages over local HTTP
Add watch-only --serve on 127.0.0.1:9932 with directory listings, streamed downloads and HEAD requests. Serve the selected output directory independently of rebuilds and reject paths outside its root. Document usage and test HTTP responses and option parsing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-21 22:42:51 +02:00
Steve Biedermann 6af3df0ecc feat(xtask): watch sources and debounce automatic packaging
Wait for a ten-second quiet period before rebuilding and packaging, resetting the timer on each source change. Queue edits during builds or while dist/NOREBUILD exists, exclude generated outputs, and forward pack options. Recompile packaging code for each run and keep watching after build failures.

Document watcher usage and require agents to hold dist/NOREBUILD throughout their work.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-21 22:30:03 +02:00
Steve Biedermann 29275c38fc fix(tiled): enforce tmx exports and group objects by category
Normalize export filenames to .tmx and return the actual saved path in command replies. Separate floors, structures, ground items and spawn points into named object layers, with a reserved wiring/plumbing layer. Reuse the same categories in the Tiled converter and preserve undo behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-21 22:20:13 +02:00
Steve Biedermann 8f24528304 feat(world)!: add layered floors and painted-tile conversion
Separate floors from structures for occupancy, rendering, interaction and deconstruction. Convert marked Tiled tiles to stateful gameplay buildings, retain a configurable default terrain tile, and preserve legacy saved terrain without reseeding it.

Bundle a Tiled conversion extension with editable per-object properties, conflict checks and single-step undo. Cover conversion, persistence, collision and gameplay in Rust tests and run the extension tests in CI.

BREAKING CHANGE: network protocol version 6 adds layered building state and default terrain metadata; clients and servers must be upgraded together.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-21 21:40:20 +02:00
Steve Biedermann fadd159de6 docs: document SS13 feature gaps and development priorities
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-21 20:57:50 +02:00
Steve Biedermann 4d7d777e86 feat(chat): add native text editing, history and command completion
Use Bevy editable text with word navigation, keyboard and pointer selection, system clipboard integration and IME-aware submission. Add bounded history with draft restoration and permission-aware Tab completion. Cover native keyboard, mouse and clipboard event handling in tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-21 20:22:32 +02:00
Steve Biedermann 43051c9f5d feat(world)!: add lossless Tiled export and import
Preserve building state, machine contents and progress in editable Tiled properties. Share property decoding between seeds and imports; replace terrain and objects atomically while keeping characters. Persist terrain in schema version 2 and replicate it to existing and joining clients.

BREAKING CHANGE: network protocol version 5 replicates authoritative terrain; older clients and servers cannot connect.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-21 20:22:13 +02:00
Steve Biedermann 0c9136eaae feat!: move characters freely instead of tile by tile
Test / Clippy and tests (nextest) (push) Failing after 1m59s Details
Windows package / Build Windows package (push) Failing after 59s Details
Characters have a continuous Position simulated on the server with a
square collision box, sliding along walls and corner assist into
openings. GridPos is derived from the position, so interactions,
building and reach stay tile-based. The exact position is saved.

BREAKING CHANGE: protocol version 4 replaces SetMoveDir with SetMovement
and replicates Position.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-18 23:15:16 +02:00
Steve Biedermann 2d6f52e8a4 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>
2026-10-18 22:29:05 +02:00
Steve Biedermann 09ace2607c feat(server): add file and command line config
Add a dedicated server configuration file (`server.toml`) with clap-based
command line overrides. The server now supports runtime config changes via
terminal commands, and config edits are written back to the TOML file while
preserving comments.

Also let host mode feed the shared network startup through the same server
config resource.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-18 21:41:58 +02:00
Steve Biedermann afd3d3a537 feat(server): store worlds in sqlite or postgres with sqlx
Worlds are identified by a UUID and every table is keyed by it, so many
worlds can share one database. The schema is created and migrated
automatically. Autosaves are written on a background tokio runtime; exit
saves block until done.

Singleplayer saves are one SQLite file per save game in the platform data
directory. The dedicated server takes --database URL (default
sqlite://saves/server.sqlite) and --world NAME|UUID.

BREAKING CHANGE: the rsn save format and the server's --save FILE option
are removed; existing saves are not migrated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-18 20:47:43 +02:00
Steve Biedermann 3cf0561401 feat: add data-driven machines
Machines give a building input/output slots and their own timed recipes.
The server processes them, replicates their state and saves it with the
world. The furnace becomes a machine and an assembler is added as an
example with a custom window layout. The client window follows with the
new UI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-18 20:28:16 +02:00
Steve Biedermann 0d025b824e ci(github): package client and server for Windows, Linux and macOS
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-18 20:21:07 +02:00
82 changed files with 15466 additions and 1338 deletions

View File

@ -47,6 +47,13 @@ jobs:
- name: Test - name: Test
run: cargo nextest run --workspace --locked --profile ci run: cargo nextest run --workspace --locked --profile ci
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Test Tiled extension
run: node --test tools/tiled.test.cjs
- name: Upload test report - name: Upload test report
if: always() if: always()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3

82
.github/workflows/package.yml vendored Normal file
View File

@ -0,0 +1,82 @@
# Builds the client and server for every platform and packs everything needed to
# run them into an archive: a zip on Windows and a tar.gz elsewhere.
name: Package
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: Package ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- { os: windows-latest, target: x86_64-pc-windows-msvc }
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
- { os: macos-latest, target: aarch64-apple-darwin }
steps:
- uses: actions/checkout@v4
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache cargo
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Pack client and server
run: cargo xtask pack --target ${{ matrix.target }}
- name: Upload archive
uses: actions/upload-artifact@v4
with:
name: station-${{ matrix.target }}
path: |
dist/station-*.zip
dist/station-*.tar.gz
if-no-files-found: error
# The archive is already compressed.
compression-level: 0
release:
name: Publish release
if: startsWith(github.ref, 'refs/tags/v')
needs: package
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- uses: softprops/action-gh-release@v2
with:
files: |
dist/station-*.zip
dist/station-*.tar.gz

View File

@ -39,6 +39,13 @@ jobs:
- name: Test - name: Test
run: cargo nextest run --workspace --locked --profile ci run: cargo nextest run --workspace --locked --profile ci
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Test Tiled extension
run: node --test tools/tiled.test.cjs
- name: Upload test report - name: Upload test report
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@ -1,47 +0,0 @@
name: Windows package
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
build:
name: Build Windows package
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Pack client and server
run: cargo xtask pack --target x86_64-pc-windows-msvc
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: station-windows
path: dist/station-*/
if-no-files-found: error
- name: Publish release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
files: dist/station-*.zip

1
.gitignore vendored
View File

@ -3,3 +3,4 @@
__pycache__/ __pycache__/
/.NOCOMMIT /.NOCOMMIT
dist dist
*.tiled-session

View File

@ -10,6 +10,10 @@ Station 13, with singleplayer and multiplayer. It is built in Rust (edition
## Agent rules ## Agent rules
- Before starting work, create `dist/NOREBUILD` (creating `dist/` if needed) to
pause automatic rebuilds. Remove it only after all work, validation and commits
are complete. If it already exists, leave it in place; it may belong to another
active agent or the user.
- Never push changes unless explicitly told to. - Never push changes unless explicitly told to.
- After changing files, run `cargo fmt --all`. Do not override project - After changing files, run `cargo fmt --all`. Do not override project
formatting configuration or invoke a formatter directly on individual files. formatting configuration or invoke a formatter directly on individual files.
@ -45,6 +49,7 @@ Station 13, with singleplayer and multiplayer. It is built in Rust (edition
- `crates/game_client/`: the `game` binary: rendering, UI and input; embeds the - `crates/game_client/`: the `game` binary: rendering, UI and input; embeds the
server for singleplayer and hosting. server for singleplayer and hosting.
- `assets/data/`: item, recipe, building and skill definitions (`.rsn`). - `assets/data/`: item, recipe, building and skill definitions (`.rsn`).
- `assets/ui/`: client UI layouts and theme (`.rsn`), see the README.
- `assets/scripts/`: Rune scripts (`.rn`), referenced as `"module::function"`. - `assets/scripts/`: Rune scripts (`.rn`), referenced as `"module::function"`.
- `assets/maps/`, `assets/tilesets/`, `assets/sprites/`: Tiled maps and art. - `assets/maps/`, `assets/tilesets/`, `assets/sprites/`: Tiled maps and art.
- `xtask/`: project automation (`cargo xtask pack` builds and archives the - `xtask/`: project automation (`cargo xtask pack` builds and archives the
@ -71,9 +76,13 @@ conventions) in sync with API, configuration, and behavior changes.
- Rune scripts stage changes in `ScriptState`; they are applied after the - Rune scripts stage changes in `ScriptState`; they are applied after the
script returns, and a failing script has no effect. Ctx methods need script returns, and a failing script has no effect. Ctx methods need
`#[rune::function(keep)]`; script-facing numbers are integers. `#[rune::function(keep)]`; script-facing numbers are integers.
- Save files (`persistence.rs`, `SAVE_VERSION`) are the source of truth for an - The database (`persistence.rs`) is the source of truth for an existing world;
existing world; the Tiled map only seeds new worlds. Changing saved types the Tiled map only seeds new worlds. All tables are keyed by the world UUID.
requires a version bump or a compatible migration. Schema changes need a new entry in `MIGRATIONS` and a `SCHEMA_VERSION` bump.
Use portable SQL (SQLite and PostgreSQL) with `$N` placeholders. Saved
component types are stored as JSON, so changes must stay serde-compatible.
- Test PostgreSQL with `STATION_TEST_POSTGRES_URL=postgres://... cargo nextest run`
(for example against a throwaway docker `postgres` container).
- Characters are identified by name only (no authentication); renet uses - Characters are identified by name only (no authentication); renet uses
unsecure authentication. Do not treat client input as trusted. unsecure authentication. Do not treat client input as trusted.
- Replicon builds its systems in `Plugin::finish`; headless tests must call - Replicon builds its systems in `Plugin::finish`; headless tests must call

View File

@ -5,8 +5,47 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Changed
- **Breaking:** worlds consist of levels: the server's map plus every map reached
through `teleport` triggers. All levels are simulated even when nobody is on them,
and clients only receive their character's level. The save schema (version 3)
stores each entity's level and the levels' terrain.
- **Breaking:** `export [DIR]` writes a ready-to-use Tiled project directory
(the main map named after the server's map, e.g. `station.tmx`, every level's
map, `station.tiled-project`, the Station extensions and relative tileset image
copies) instead of a single map file; `import` accepts that directory or the
main map file and replaces all levels.
- Tiled exports and newly created conversion layers place Objects above Ground
Items, Triggers, Wiring/Plumbing and Floors, with Floors and Wiring/Plumbing
hidden by default in the editor without affecting gameplay.
### Added ### Added
- `teleport` triggers in Tiled maps (`target` object name, optional `map` file)
move characters between places and levels.
- Stairs in the default map's main hall lead to a new cellar level and back up.
- **Map > Station: Convert All Project Maps** in Tiled converts painted gameplay
tiles in every map of the opened project at once.
- A Station Object Brush in Tiled with per-tile brush property settings and
independently editable wall/floor objects, drag previews and stroke undo.
Open the bundled `assets/station.tiled-project` to load Station extensions
without installing them globally (Tiled requests permission on first use).
- `cargo xtask watch --serve` serves the packaging output with directory listings
and downloads at `http://127.0.0.1:9932/`, including while rebuilds are paused.
- `cargo xtask watch` rebuilds and packs after a resettable 10-second quiet period,
with automatic rebuilds paused while `dist/NOREBUILD` exists.
- Separate gameplay floor and structure layers, with buildable/removable floors
and windows. Marked painted Tiled tiles become stateful gameplay entities;
removing floors exposes the map's configurable default terrain.
- A bundled Tiled extension to convert painted gameplay tiles into editable
objects, preserving properties and flips with conflict checks and undo support.
- Admin `export` and `import` commands for editable Tiled snapshots of
terrain, buildings and ground items, including health, script variables, machine
contents and processing state. Imports replace the map while preserving characters.
- Native chat text editing with word navigation, keyboard/mouse selection, system
clipboard copy/cut/paste, bounded sent-message history and role-aware command completion.
- Persistent-world station game with singleplayer, hosting (`--host`) and - Persistent-world station game with singleplayer, hosting (`--host`) and
joining (`--connect`), plus a dedicated `game-server`. joining (`--connect`), plus a dedicated `game-server`.
- Data-driven items, recipes, buildings and skills in `assets/data/*.rsn`. - Data-driven items, recipes, buildings and skills in `assets/data/*.rsn`.
@ -20,3 +59,49 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
console window. console window.
- `cargo xtask pack` builds the client and server and packs them with their - `cargo xtask pack` builds the client and server and packs them with their
assets into a distributable archive; CI publishes these archives. assets into a distributable archive; CI publishes these archives.
- Machines (`machines` in data files): buildings with input/output slots that
process their own timed recipes, with optional custom window layouts and
background images. The furnace is now a machine (ore + log fuel), and a new
assembler makes tools.
- GitHub `Package` workflow builds archives for Windows (zip), Linux and macOS
(tar.gz).
- Worlds are stored with sqlx in SQLite (default) or PostgreSQL, keyed by a world
UUID so many worlds can share one database. Singleplayer saves are SQLite files
in the platform data directory. Autosaves run in the background. The server has
new `--database URL` and `--world NAME|UUID` options.
- 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
- Tiled exports always use the `.tmx` extension and separate floors, structures,
ground items and spawn points into named object layers, with a reserved
wiring/plumbing layer. The Tiled converter uses the same layer organization.
- Tiled property overrides apply to new-world seeds as well as imported maps.
Terrain is persisted with world state and replicated to connected clients.
Save databases migrate automatically to schema version 2.
- Characters move freely instead of tile by tile: diagonal movement, sliding
along walls and around corners. Their exact position is saved.
- **Breaking:** network protocol version 6. Layered building state and authoritative terrain are replicated,
logins carry a player id and password, and movement is sent as a direction vector,
so older clients and servers can't connect. Characters saved by name
in existing worlds are not carried over to player ids.
### Removed
- The rsn save file format and the `--save FILE` server option. Existing rsn saves
are not migrated.

872
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,11 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["crates/game_core", "crates/game_server", "crates/game_client", "xtask"] members = [
"crates/game_core",
"crates/game_server",
"crates/game_client",
"xtask"
]
default-members = ["crates/game_client"] default-members = ["crates/game_client"]
[workspace.package] [workspace.package]
@ -16,6 +21,20 @@ rsn = "0.2"
rune = "0.14" rune = "0.14"
tiled = "0.16" tiled = "0.16"
thiserror = "2" thiserror = "2"
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
sqlx = { version = "0.9", default-features = false, features = [
"any",
"sqlite",
"postgres",
"runtime-tokio",
"tls-rustls-ring-webpki"
] }
tokio = { version = "1", features = ["rt-multi-thread"] }
directories = "6"
clap = { version = "4.6", features = ["derive"] }
toml = "1.1"
toml_edit = "0.25"
game_core = { path = "crates/game_core" } game_core = { path = "crates/game_core" }
game_server = { path = "crates/game_server" } game_server = { path = "crates/game_server" }

460
README.md
View File

@ -4,13 +4,15 @@ A persistent-world, RPG-style station simulation inspired by Space Station 13, b
with [Bevy](https://bevy.org) in Rust. with [Bevy](https://bevy.org) in Rust.
- **ECS** – all game state lives in Bevy entities and components. - **ECS** – all game state lives in Bevy entities and components.
- **Data driven** – items, recipes, buildings and skills are defined in - **Data driven** – items, recipes, buildings, machines and skills are defined in
[rsn](https://crates.io/crates/rsn) files under `assets/data/`. [rsn](https://crates.io/crates/rsn) files under `assets/data/`.
- **Scriptable** – behaviour is written in [Rune](https://rune-rs.github.io) scripts - **Scriptable** – behaviour is written in [Rune](https://rune-rs.github.io) scripts
under `assets/scripts/`. under `assets/scripts/`.
- **Tiled maps** – maps are regular [Tiled](https://www.mapeditor.org) `.tmx` files. - **Tiled maps** – maps are regular [Tiled](https://www.mapeditor.org) `.tmx` files.
- **Persistent world** – no rounds. The world and every character (inventory, skills, - **Persistent world** – no rounds. The world and every character (inventory, skills,
vitals, position) are saved and restored across restarts. vitals, position) are saved and restored across restarts.
- **Free movement** – characters walk anywhere, not tile by tile, and slide along
walls. Interactions still target the tile you face.
- **Singleplayer and multiplayer** – one authoritative server simulation, used locally - **Singleplayer and multiplayer** – one authoritative server simulation, used locally
in singleplayer and over UDP in multiplayer in singleplayer and over UDP in multiplayer
([bevy_replicon](https://crates.io/crates/bevy_replicon) + renet). ([bevy_replicon](https://crates.io/crates/bevy_replicon) + renet).
@ -23,7 +25,10 @@ with [Bevy](https://bevy.org) in Rust.
| `crates/game_server` | Authoritative simulation, Rune scripting, persistence, `game-server` binary | | `crates/game_server` | Authoritative simulation, Rune scripting, persistence, `game-server` binary |
| `crates/game_client` | Rendering, UI and input (`game` binary); embeds the server for singleplayer/host | | `crates/game_client` | Rendering, UI and input (`game` binary); embeds the server for singleplayer/host |
The client never changes game state directly. It sends requests (`Login`, `SetMoveDir`, See [SS13 feature gaps](docs/ss13-feature-gaps.md) for an inventory of major
systems and smaller features that are not yet implemented or are only partial.
The client never changes game state directly. It sends requests (`Login`, `SetMovement`,
`Interact`, `Craft`, …) and the server replicates the resulting state back. In `Interact`, `Craft`, …) and the server replicates the resulting state back. In
singleplayer the same server systems run inside the client process, so singleplayer and singleplayer the same server systems run inside the client process, so singleplayer and
multiplayer behave identically. multiplayer behave identically.
@ -31,39 +36,206 @@ multiplayer behave identically.
## Running ## Running
```sh ```sh
cargo run # singleplayer (world: saves/singleplayer.rsn) cargo run # start menu
cargo run -- --host 5000 # singleplayer world that others can join cargo run -p game_server --bin game-server # dedicated server (saves/server.sqlite, world `main`)
cargo run -- --connect 127.0.0.1:5000 # join a server or host
cargo run -p game_server --bin game-server # dedicated server (world: saves/server.rsn)
``` ```
Client options: `--name NAME`, `--save FILE`, `--no-save`, `--map MAP`. The start menu offers **New game** (optionally open to other players),
Server options: `--port PORT`, `--save FILE`, `--no-save`, `--map MAP`. **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`.
On Windows, release builds of the client open no console window, so command-line On Windows, release builds of the client open no console window, so command-line
output such as `--help` and logs isn't shown. Use a debug build to see it. output such as `--help` and logs isn't shown. Use a debug build to see it.
The world autosaves periodically and on exit (window close, Ctrl-C, SIGTERM). The world autosaves periodically (in the background) and on exit (window close,
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
Worlds are stored with [sqlx](https://crates.io/crates/sqlx) in SQLite or
PostgreSQL. Every world has a UUID, and all rows are keyed by it, so any number of
worlds can share one database.
- **Singleplayer/host:** each save game is its own SQLite file in the platform data
directory: `~/.local/share/station/saves/` on Linux,
`%APPDATA%\station\data\saves\` on Windows and
`~/Library/Application Support/station/saves/` on macOS.
- **Dedicated server:** `--database` takes a sqlx URL and defaults to
`sqlite://saves/server.sqlite`. Use `postgres://user:password@host/db` for
PostgreSQL. `--world` selects a world by name (created if missing) or by UUID.
```sh
game-server --database postgres://station:secret@db/station --world europa
```
The schema is created and migrated automatically. To run the PostgreSQL tests,
set `STATION_TEST_POSTGRES_URL` to a database URL; without it they are skipped.
The native chat clipboard test can be enabled with `STATION_TEST_CLIPBOARD=1`
when running tests in a desktop session with an available system clipboard.
### Dedicated server configuration
The dedicated server reads `server.toml` by default and creates it on first
start. Command-line options override the file for one run only:
```sh
game-server --config prod.toml --port 5001 --database sqlite://saves/prod.sqlite
```
The config file also stores the server password, MOTD, max players, the
database URL and world, plus access lists (admins, whitelist, bans). While the
server is running, type commands into its terminal:
```text
help
players
save
config get database.world
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 |
| `export [DIR]` | admins | Export terrain, buildings and ground items as a Tiled project |
| `import [DIR\|FILE]` | admins | Replace terrain, buildings and ground items from Tiled; keep characters |
| `config get KEY`, `config set KEY VALUE` | admins | Read or change a setting, e.g. `config set motd Hi` |
| `stop` | console, host | Save and shut down |
> **Security:** the network transport is unencrypted and unauthenticated. The
> password is sent in plain text and player ids can be impersonated by anyone who
> knows them. Treat the password as a guard against casual visitors, not as
> real security, and don't reuse a valuable password.
## Controls ## Controls
Chat supports cursor navigation (arrows, Home/End, Ctrl+Left/Right), Shift selection,
Ctrl+A, mouse positioning/drag selection, double-click word selection, and system
clipboard copy/cut/paste (Ctrl+C/X/V). On macOS, use the usual Command shortcuts and
Option for word navigation. Up/Down recall the last 100 sent messages and commands;
Down past the newest entry restores the unsent draft. Tab completes command names
and subcommands allowed for your role; repeated Tab cycles matches, and Shift+Tab
cycles backwards. Escape closes chat without sending.
| Key | Action | | Key | Action |
|---------------------|------------------------------------------------------| |---------------------|------------------------------------------------------|
| WASD / arrows | Move (you face the direction you move) | | WASD / arrows | Move freely, also diagonally (you face that way) |
| E | Pick up (own tile, otherwise the faced tile) | | E | Pick up (own tile, otherwise the faced tile) |
| F / left click | Interact with the faced / clicked building | | F / left click | Interact with the faced / clicked building; machines open their window |
| U | Use the selected item (eat, heal, …) | | U | Use the selected item (eat, heal, …) |
| G | Drop the selected item | | G | Drop the selected item |
| X | Deconstruct the faced building | | X | Deconstruct the faced building |
| 1–9, 0, mouse wheel | Select an inventory slot | | 1–9, 0, mouse wheel | Select an inventory slot (or click it) |
| C / B | Crafting / build menu (W/S select, Enter confirm, Esc close) | | C / B | Crafting / build menu (W/S select, Enter confirm, or click) |
| H | Help | | H | Help |
| Enter / T | Chat; `/` starts a command |
| Esc | Close the open window, otherwise pause menu (leave world, quit) |
In a machine window, click an empty input slot to insert the selected inventory
item and click a filled slot to take its items. Hover over items, recipes and
buttons for tooltips.
## User interface
The menus and the HUD are built from layout files in `assets/ui/` and can be
restyled or rearranged without recompiling:
| File | Screen |
|--------------------|------------------------------------------------|
| `theme.rsn` | Font sizes, spacing and named colors used by all layouts |
| `main_menu.rsn`, `new_game.rsn`, `load_game.rsn`, `connect.rsn`, `connecting.rsn` | Start menu screens |
| `hud.rsn` | In-game HUD, menus, chat and pause overlay |
A layout has one `root` node. Nodes:
| Node | Fields |
|------------------------------|------------------------------------------------------------|
| `Column {..}`, `Row {..}`, `Panel {..}` | `children`, `gap`, `padding`, `align` (`Start`, `Center`, `End`, `Stretch`), `justify` (`Start`, `Center`, `End`, `SpaceBetween`), `width`/`height` (`Auto`, `Px(n)`, `Percent(n)`, `Fill`), `anchor` (`TopLeft` … `BottomRight`, relative to the screen), `offset: (x, y)`, `background`, `border`, `radius` |
| `Text {..}` | `text`, `size`, `color`, `max_width` |
| `Button {..}` | `text`, `action`, `width` |
| `Input {..}` | `id`, `value`, `placeholder`, `password`, `max_len`, `width`, `submit` (action on Enter) |
| `Checkbox {..}` | `id`, `text`, `checked` |
| `Image {..}` | `path` (PNG below `assets/`), `width`, `height` |
| `Spacer` | Takes up the remaining space |
| `Widget("name")` | Content generated by the game |
Every node except `Spacer` and `Widget` also accepts `tooltip` and
`visible`. `visible` names a binding or form field and shows the node only
while it is set; prefix it with `!` to invert it. Colors are `#rrggbb`,
`#rrggbbaa` or a theme color name. Texts, tooltips and input values may contain
`{binding}` placeholders:
```rsn
Panel {
anchor: TopLeft,
offset: (10, 10),
children: [
Text { text: "{character}", color: "accent", tooltip: "Playing on {server}" },
Text { text: "You are hungry!", color: "error", visible: "hungry" },
Button { text: "Craft", action: "menu:craft", tooltip: "Crafting (C)" },
],
}
```
- **Bindings:** menu: `message`, `profile_name`, `last_address`,
`default_port`, `saves_dir`, `save_count`, `has_saves`, `suggested_world`,
`version`, `status`. HUD: `character`, `server`, `admin`, `health`,
`max_health`, `nutrition`, `hungry`, `position`, `craft_open`, `build_open`,
`help_open`, `chat_open`, `paused`.
- **Widgets:** `save_list` (menu); `inventory`, `skills`, `log`, `craft_list`,
`build_list` (HUD).
- **Actions:** `screen:main|new_game|load_game|connect`, `new_game`, `join`,
`quit` (menu); `menu:craft|build|help|close`, `chat_send`, `resume`, `leave`,
`quit` (HUD). The menu forms read the inputs `world`, `name`, `address`,
`password`, `port` and the checkbox `host`; the HUD chat input is `chat`.
## Modding ## Modding
All content is loaded from `assets/` at startup. Every `.rsn` file below `assets/data/` All content is loaded from `assets/` at startup. Every `.rsn` file below `assets/data/`
(recursively) may contain any mix of `items`, `recipes`, `buildings` and `skills`, so a (recursively) may contain any mix of `items`, `recipes`, `buildings`, `machines` and
`skills`, so a
feature can ship in a single file. References between prototypes and script hooks are feature can ship in a single file. References between prototypes and script hooks are
validated at load time and errors name the offending file and id. validated at load time and errors name the offending file and id.
@ -84,10 +256,9 @@ Required: `id`, `name`, `sprite`. Optional: `description`, `max_stack` (default
```rsn ```rsn
recipes: [ recipes: [
{ id: "smelt_iron", name: Some("Smelt iron plate"), { id: "make_pickaxe", inputs: [("iron_plate", 2), ("plank", 1)],
inputs: [("iron_ore", 2)], outputs: [("iron_plate", 1)], outputs: [("pickaxe", 1)], tools: ["wrench"], station: Some("workbench"),
tools: [], station: Some("furnace"), skill: Some({ skill: "smithing", level: 1 }), xp: 25 },
skill: Some({ skill: "smithing", level: 1 }), xp: 10 },
] ]
``` ```
@ -97,7 +268,7 @@ Required: `id`, `inputs`, `outputs`. Optional: `name`, `tools` (needed but not c
### Buildings ### Buildings
Anything placed on the grid: walls, doors, machines, trees, rocks. Anything placed on the grid: floors, walls, doors, machines, trees, rocks.
```rsn ```rsn
buildings: [ buildings: [
@ -111,10 +282,46 @@ buildings: [
``` ```
Required: `id`, `name`, `sprite`. Optional: `description`, `variants` (name → sprite), Required: `id`, `name`, `sprite`. Optional: `description`, `variants` (name → sprite),
`solid` (default true), `max_health`, `stations` (tags for recipes), `construction` `layer` (`"structure"` by default, or `"floor"`), `solid` (default true),
`max_health`, `stations` (tags for recipes), `construction`
(makes it buildable from the build menu), `deconstructible` (default true; refunds (makes it buildable from the build menu), `deconstructible` (default true; refunds
materials), `on_interact` (script hook), `vars` (integer state, persisted per building). materials), `on_interact` (script hook), `vars` (integer state, persisted per building).
Each cell can hold one floor and one structure, plus ground items. Floors render
below structures and have the same health, script state and construction mechanics.
Interactions and deconstruction target the structure first, then the floor once
the structure is gone. Remove a structure before replacing the floor underneath it.
### Machines
A machine adds item slots and its own recipes to the building with the same `id`.
Interacting with it opens the machine window: put items into the input slots, the
machine processes the first recipe whose inputs are present and whose outputs fit, and
the results appear in the output slots. Machine contents are saved with the world and
dropped when the machine is taken apart.
```rsn
machines: [
{ id: "assembler", input_slots: 3, output_slots: 1, active_variant: Some("active"),
recipes: [
{ id: "assemble_wrench", inputs: [("iron_plate", 2)], outputs: [("wrench", 1)],
time: 3.0 },
],
ui: { background: Some("ui/machines/assembler.png"), size: Some((260.0, 150.0)),
inputs: [(16.0, 14.0), (16.0, 96.0), (70.0, 55.0)], outputs: [(200.0, 55.0)],
progress: Some((124.0, 69.0, 60.0, 12.0)) } },
]
```
Required: `id` (an existing building), `recipes` (`id`, `inputs`, `outputs`, optional
`name` and `time` in seconds, default 1). Optional: `input_slots` and `output_slots`
(default 1), `active_variant` (building variant shown while working) and `ui`. Without
`ui` positions, inputs are stacked on the left and outputs on the right with a progress
bar between them. `ui` fields: `background` (PNG drawn behind the slots), `size`
(window content size in pixels), `slot_size` (default 40), `inputs`/`outputs` (one
`(x, y)` per slot, measured from the top-left corner) and `progress`
(`(x, y, width, height)`).
### Skills ### Skills
```rsn ```rsn
@ -158,20 +365,192 @@ pub fn toggle(ctx) {
### Maps (Tiled) ### Maps (Tiled)
Maps are orthogonal `.tmx` files with embedded or external tilesets. Each map cell is one Maps are finite orthogonal `.tmx` files (at most 1,000,000 cells) with embedded or
game tile (drawn at 32×32; 32 px tilesets look best). external tilesets. Each map cell is one game tile (drawn at 32×32; 32 px tilesets look best).
Layer/group offsets are not supported.
- Tile layers are rendered in order. A tile is solid if the tile has the boolean custom - Unmarked tile layers are static terrain/decorations, rendered in order. A tile is solid if the tile has the boolean custom
property `solid = true` in its tileset, or its layer has `solid = true`. property `solid = true` in its tileset, or its layer has `solid = true`.
- A tileset tile with a string `proto` property becomes a gameplay building when
painted. Its `state.*`, `machine.*` and integer script-variable properties become
instance overrides. The painted cell is cleared from static terrain, so removing
the entity cannot leave an invisible wall or a duplicate floor behind. The
shipped floor, dark floor, plating, wall and window tiles use this convention.
- Object layers spawn gameplay entities by object **class**: - Object layers spawn gameplay entities by object **class**:
- `spawn` – player spawn point. - `spawn` – player spawn point.
- `building` – custom property `proto` = building id; any integer properties - `building` – custom property `proto` = building id. `state.health`,
override the building's `vars`. `state.solid`, `state.variant` and `state.vars` override the prototype defaults.
`state.layer` must match the prototype (`"floor"` or `"structure"`, as JSON
strings). `state.flip_h`, `state.flip_v` and `state.flip_d` preserve tile flips.
Integer custom properties without a `state.`/`machine.` prefix also override
individual script variables, for compatibility with existing maps.
Machines accept `machine.inputs`, `machine.outputs`, `machine.recipe` and
`machine.progress`.
- `item` – custom property `proto` = item id and optional `count`. - `item` – custom property `proto` = item id and optional `count`.
- `teleport` – a trigger that moves characters entering its tile to the object
named by the string property `target`. The optional **file** property `map`
names the target's map, relative to this map; without it the target is on the
same map. Arrival does not trigger the destination's own teleport; step off
and back on to use it. The arrival tile falls back to the nearest free tile.
- Objects are placed on the tile containing their position (point objects work well). - Objects are placed on the tile containing their position (point objects work well).
Tile objects inherit gameplay properties from their tiles; object properties
override them. Painted and explicit buildings share the same occupancy checks.
A map is only used to create a new world; after that the save file is authoritative. Set the map's **integer** `default_tile` property to a static tile's local ID
Delete the save file to start a fresh world. (as shown in Tiled's tileset editor). The optional integer `default_tileset`
selects its tileset by zero-based position in the map's tileset list (default 0).
The default tile fills empty cells below gameplay floors; removing a floor exposes
it wherever no explicit static terrain is present. Its `solid` property controls
movement there, while a non-solid gameplay floor covers that default collision.
Explicit static terrain collision still applies. The default cannot reference a
tile with `proto`, and cannot be built/deconstructed itself. The starter map
uses dirt (tile 6). Maps remain finite: outside their bounds is always blocked.
Old maps without a default retain their empty, passable background.
#### Levels
The server's map (`station.tmx` by default) is the main level. Every map reached
through `teleport` triggers, directly or via other levels, is loaded as another
level, up to 64 per world. A level is identified by its path relative to the main
map's directory and must stay inside that directory. All levels are simulated
all the time, whether or not anyone is on them, so machines keep working and
scripts keep running. Clients only receive the level their character is on.
Characters, buildings and ground items remember their level in the save.
Teleports are validated when a world loads or imports: unknown maps or missing
targets are errors. The default map has stairs in the main hall leading to
`cellar.tmx` and back up.
A default map seeds a new world; after that the database stores the authoritative
terrain and entity state of every level. Existing saves acquire their terrain
snapshot on their next save. Levels referenced since a world was saved (for example
the cellar, for worlds created before it existed) are seeded from their maps. To
apply a map to an existing world, use the admin `import` command.
Existing saved terrain is not automatically converted or reseeded on startup:
import a marked map to convert old static walls/floors. Legacy saves without a
terrain snapshot retain static seed terrain until explicitly imported.
#### Converting painted tiles inside Tiled
Open the bundled `assets/station.tiled-project` in Tiled 1.10 or newer. It loads
`assets/tiled/station.js` as a project extension; accept Tiled's **Enable
Extensions** prompt the first time. No copying or global plugin installation is
needed. The project and extension are also included in packaged downloads.
For use without the project, open **Edit > Preferences > Plugins > Open**, copy
the contents of `assets/tiled/` into that extensions directory, then enable/reload it.
To draw walls or floors with independent properties:
1. Select a single gameplay tile in the **Tilesets** panel.
2. Choose **Map > Station: Brush Properties** (also available in the Station
brush toolbar). Set health, solidity, script variables or other tile-defined
state. JSON-valued fields such as `state.vars` and `state.variant` keep their
JSON syntax. Prototype and gameplay layer are fixed by the selected tile.
3. Select **Station Object Brush** in the tools toolbar and click or drag to
draw. Each stroke places visual `building` tile objects, snapping to the grid,
with a separate copy of the brush properties on every object. A stroke is one
undo step; Escape cancels it before placement.
4. Use **Select Objects** to edit individual placed objects in the Properties
panel. Floors go into the initially hidden **Floors** layer; enable its eye
icon to inspect them.
Brush settings are remembered per tile until extensions reload or Tiled closes.
Changing them affects only future strokes, not tileset defaults or existing
objects. Floor/structure overlap is allowed, but a stroke cannot overwrite an
existing building in the same gameplay layer, overlap unconverted gameplay
tiles, or write to a locked destination. Edit/delete the existing object or
convert the painted tiles first. The normal Tiled Stamp Brush still paints
shared tileset defaults, not per-cell properties. Rejected strokes leave the map
unchanged and report the reason in the status bar and **Console** view.
Paint the marked tiles normally, then choose **Map > Station: Convert Painted
Tiles to Objects**. The extension replaces all marked painted cells with selected
`building` point objects in separate **Floors** and **Objects** layers. Existing
layers with those names are reused. The converter also creates **Wiring/Plumbing**,
**Ground Items** and **Triggers** layers when missing. Select an object
to edit its health, solidity or other custom properties in the Properties panel.
New layers use the same order and visibility defaults as world exports below;
existing layers retain their visibility settings.
Unmarked terrain stays painted. Floor/structure overlaps are allowed; same-layer
overlaps abort the entire conversion. Conversion is a single undoable operation,
and running it again does not duplicate objects.
To convert every map at once, choose **Map > Station: Convert All Project Maps**.
It opens each `.tmx` file in the project's folders (including subfolders),
converts it as above and reports how many maps and tiles changed, plus any maps
that failed. Converted maps stay open and unsaved so you can review or undo them
per map; use **File > Save All** to keep the result. Maps without painted
gameplay tiles are closed again unless they were already open.
For custom tiles, set `proto` to a building ID and add the desired `state.*` or
`machine.*` defaults on the tileset tile. Floor tiles should include `state.layer`
as the string `"floor"` (with JSON quotes) so the extension can check occupancy.
The shipped gameplay tiles include editable state defaults. Exporting a world
also fills in any missing tileset state defaults from the loaded game definitions.
Omitted properties still use prototype defaults when loaded by the game.
Conversion in Tiled and automatic conversion on seed/import use the same property
and coordinate conventions; conversion is optional before loading a map.
#### Editing a running world's map
1. Run `/export` in chat, or `export` in the server terminal. It writes a
ready-to-use Tiled project directory and replies with the path of its
`station.tiled-project` file (a server-local path). With SQLite the directory
defaults to `<database-stem>.<world-uuid>.tiled/` beside the database. With
PostgreSQL it defaults to `saves/<world-uuid>/`; without saving, `saves/world/`.
An explicit directory, including spaces, may follow either command; relative
paths are relative to the server's working directory. The directory contains:
- `<map>.tmx` (e.g. `station.tmx`, named after the server's map): the main level;
- the other levels' maps at their level paths (e.g. `cellar.tmx`);
- `station.tiled-project`: a Tiled project that loads the bundled extensions;
- `extensions/`: a copy of `assets/tiled/` (refreshed on every export);
- `tilesets/`: copies of the tileset images, referenced relatively, so the
directory can be copied to another machine and opened there;
- `README.txt`: short usage instructions.
Re-exporting into the same directory overwrites the map, extensions, images and
README but keeps an existing project file, so project settings survive.
2. Open `station.tiled-project` in Tiled 1.10.1 or newer (**Project > Open
Project**), click **Enable Extensions** when asked, then open the main map
(or any level) from the Project panel. Edit and save. Buildings and ground items are point
objects with their prototype and full mutable state in custom properties.
Terrain layers and collision properties are included. The tileset image copies
are for display only: on import they map back to the server's identical asset
paths, and editing them does not change the game.
Objects are organized into **Floors**, **Objects** (walls, machines and other
structures), **Ground Items**, and **Triggers** (spawn points and teleports).
**Wiring/Plumbing** is an empty organizational placeholder; it does not add
wiring, plumbing or trigger mechanics. From top to bottom, Tiled shows
**Objects**, **Ground Items**, **Triggers**, **Wiring/Plumbing**, **Floors**,
then the painted terrain layers (for the default map, **Walls** above **Ground**).
**Floors** and **Wiring/Plumbing** are hidden by default to reduce editor clutter;
hidden layers still load into the game. Hide or lock individual layers in Tiled
to edit one category without the others getting in the way. Object classes and
properties determine gameplay, not the editor layer names.
3. Copy the directory back to the server if you edited it elsewhere, then run
`/import` (or `/import DIR`, or `/import FILE` naming the main `.tmx` file;
levels are read relative to it). **Import replaces all levels, terrain,
buildings and ground items, including deletions.** Add a level by creating its
map and pointing a teleport at it; levels no longer reached by any teleport are
removed, and characters on them move to the main spawn point. Otherwise it
does not merge objects or change online or offline characters, their positions
or inventories. Export a separate backup
before importing, and avoid editing a stale export while players keep building.
Booleans and small integers use native Tiled property types. Other state values
use JSON in string properties: for example `state.variant` is `"open"` (including
the quotes) or `null`, `state.vars` is `{"stock":2}`, and `machine.inputs` is
`[{"item":"iron_ore","count":4},null]`. `machine.recipe` is `"smelt_iron"` or `null`;
`machine.progress` is elapsed seconds. Large integers use JSON strings to avoid
Tiled's 32-bit integer limit. These same overrides work in default maps, not just
exports; omitted properties retain prototype defaults.
Import validates the whole map before changing anything, and commits it to the
database before applying it to the simulation. Invalid prototypes, properties,
positions or machine state abort the import. Saving-disabled worlds can still
import, but changes are only in memory. Terrain changes are replicated immediately
to connected clients and retained across restarts; remote clients need matching
sprite assets, not a copy of the exported map. Characters are left at their current
positions, so avoid enclosing them in newly solid terrain.
`tools/gen_placeholder_assets.py` regenerates the placeholder sprites and example map. `tools/gen_placeholder_assets.py` regenerates the placeholder sprites and example map.
@ -179,6 +558,7 @@ Delete the save file to start a fresh world.
```sh ```sh
cargo nextest run --workspace # unit tests + headless full-simulation test cargo nextest run --workspace # unit tests + headless full-simulation test
node --test tools/tiled.test.cjs # Tiled extension tests (Node.js 22+)
cargo clippy --workspace --all-targets cargo clippy --workspace --all-targets
``` ```
@ -199,3 +579,27 @@ stripped `dist` profile and writes `dist/station-<version>-<target>.zip` (Window
and the changelog. Unpack it and run the binaries from that directory. The unpacked and the changelog. Unpack it and run the binaries from that directory. The unpacked
staging directory is kept next to the archive. Options: `--format zip|tar.gz`, staging directory is kept next to the archive. Options: `--format zip|tar.gz`,
`--out-dir DIR`, `--debug`. `--out-dir DIR`, `--debug`.
Run `cargo xtask watch` to rebuild and pack automatically after source changes.
It accepts the same options as `pack`, for example `cargo xtask watch --debug`.
The watcher waits for 10 seconds without further changes; each change resets
that timer. It watches Git-tracked and non-ignored untracked files, including
assets, manifests and packaging code, and ignores `dist/`, `target/`, Git metadata
and the selected output directory. Git must be installed. No build runs on startup.
Add `--serve` to browse and download the output files over HTTP:
`cargo xtask watch --serve` serves `dist/` at <http://127.0.0.1:9932/>.
With `--out-dir DIR`, it serves that directory instead. The server is local-only,
provides directory listings and file downloads, and remains available during
builds and while rebuilds are paused. Stop the watcher to stop serving.
Create `dist/NOREBUILD` to pause automatic builds. Changes remain queued while
the file exists; deleting it starts a fresh 10-second wait for pending changes.
An already running build finishes, and subsequent builds never overlap. Build
failures are reported without stopping the watcher; a later source change retries.
Press Ctrl-C to stop. Run this from the source checkout, not a packaged archive.
The GitHub `Package` workflow (`.github/workflows/package.yml`) runs `pack` for Windows
(zip), Linux and macOS (tar.gz), uploads each archive as an artifact and attaches them
to the release when a `v*` tag is pushed. The Gitea workflow cross-compiles the Windows
package with MinGW.

View File

@ -4,6 +4,25 @@
// solid (true), max_health (100), stations (tags for recipes), construction // solid (true), max_health (100), stations (tags for recipes), construction
// ({ materials, tools, skill, xp }), deconstructible (true), on_interact, vars. // ({ materials, tools, skill, xp }), deconstructible (true), on_interact, vars.
buildings: [ buildings: [
{
id: "floor", name: "Floor", sprite: "sprites/buildings/floor.png",
layer: "floor", solid: false,
construction: Some({ materials: [("iron_plate", 1)], tools: ["wrench"], xp: 2 }),
},
{
id: "dark_floor", name: "Dark floor", sprite: "sprites/buildings/dark_floor.png",
layer: "floor", solid: false,
construction: Some({ materials: [("iron_plate", 1)], tools: ["wrench"], xp: 2 }),
},
{
id: "plating", name: "Plating", sprite: "sprites/buildings/plating.png",
layer: "floor", solid: false,
construction: Some({ materials: [("iron_plate", 1)], tools: ["wrench"], xp: 2 }),
},
{
id: "window", name: "Window", sprite: "sprites/buildings/window.png",
construction: Some({ materials: [("iron_plate", 1)], tools: ["wrench"], xp: 3 }),
},
{ {
id: "wall", name: "Wall", sprite: "sprites/buildings/wall.png", id: "wall", name: "Wall", sprite: "sprites/buildings/wall.png",
max_health: 300, max_health: 300,
@ -24,11 +43,17 @@ buildings: [
}, },
{ {
id: "furnace", name: "Furnace", sprite: "sprites/buildings/furnace.png", id: "furnace", name: "Furnace", sprite: "sprites/buildings/furnace.png",
description: "Smelts ore. Fuel it with logs.",
variants: { "lit": "sprites/buildings/furnace_lit.png" }, variants: { "lit": "sprites/buildings/furnace_lit.png" },
stations: ["furnace"],
construction: Some({ materials: [("iron_ore", 4), ("log", 1)], xp: 10, construction: Some({ materials: [("iron_ore", 4), ("log", 1)], xp: 10,
skill: Some({ skill: "construction", level: 1 }) }), skill: Some({ skill: "construction", level: 1 }) }),
on_interact: Some("furnace::toggle"), },
{
id: "assembler", name: "Assembler", sprite: "sprites/buildings/assembler.png",
description: "Assembles tools from parts.",
variants: { "active": "sprites/buildings/assembler_active.png" },
construction: Some({ materials: [("iron_plate", 4), ("plank", 2)], tools: ["screwdriver"],
xp: 20, skill: Some({ skill: "construction", level: 1 }) }),
}, },
{ {
id: "table", name: "Table", sprite: "sprites/buildings/table.png", id: "table", name: "Table", sprite: "sprites/buildings/table.png",
@ -41,6 +66,16 @@ buildings: [
on_interact: Some("vending::dispense"), on_interact: Some("vending::dispense"),
vars: { "stock": 5 }, vars: { "stock": 5 },
}, },
{
id: "stairs_down", name: "Stairs down", sprite: "sprites/buildings/stairs_down.png",
description: "Leads to the level below.",
solid: false, deconstructible: false,
},
{
id: "stairs_up", name: "Stairs up", sprite: "sprites/buildings/stairs_up.png",
description: "Leads to the level above.",
solid: false, deconstructible: false,
},
{ {
id: "tree", name: "Tree", sprite: "sprites/buildings/tree.png", id: "tree", name: "Tree", sprite: "sprites/buildings/tree.png",
deconstructible: false, deconstructible: false,

54
assets/data/machines.rsn Normal file
View File

@ -0,0 +1,54 @@
// Machines: buildings with item slots that process their own recipes over time.
// Interacting with a machine opens its window; put items into the input slots
// and collect the results from the output slots.
//
// Fields:
// id id of the building this machine belongs to (required)
// input_slots number of input slots (1)
// output_slots number of output slots (1)
// recipes [{ id, name, inputs, outputs, time (seconds, default 1.0) }]
// The first recipe whose inputs are present and whose outputs
// fit is processed.
// active_variant building variant shown while working
// ui window layout, everything optional:
// background PNG drawn behind the slots (relative to assets/)
// size (width, height) of the window content in pixels
// slot_size slot size in pixels (40)
// inputs one (x, y) per input slot; default: column on the left
// outputs one (x, y) per output slot; default: column on the right
// progress progress bar (x, y, width, height); default: between the columns
machines: [
{
id: "furnace",
input_slots: 2,
output_slots: 1,
active_variant: Some("lit"),
recipes: [
{
id: "smelt_iron",
name: Some("Smelt iron plates"),
inputs: [("iron_ore", 4), ("log", 1)],
outputs: [("iron_plate", 2)],
time: 4.0,
},
],
},
{
id: "assembler",
input_slots: 3,
output_slots: 1,
active_variant: Some("active"),
recipes: [
{ id: "assemble_wrench", inputs: [("iron_plate", 2)], outputs: [("wrench", 1)], time: 3.0 },
{ id: "assemble_screwdriver", inputs: [("iron_plate", 1), ("plank", 1)],
outputs: [("screwdriver", 1)], time: 3.0 },
],
ui: {
background: Some("ui/machines/assembler.png"),
size: Some((260.0, 150.0)),
inputs: [(16.0, 14.0), (16.0, 96.0), (70.0, 55.0)],
outputs: [(200.0, 55.0)],
progress: Some((124.0, 69.0, 60.0, 12.0)),
},
},
]

View File

@ -4,15 +4,6 @@
// (tag provided by an adjacent building), skill ({ skill, level }), xp, on_craft. // (tag provided by an adjacent building), skill ({ skill, level }), xp, on_craft.
// Ingredients can be written as ("item", count) or { item: "item", count: n }. // Ingredients can be written as ("item", count) or { item: "item", count: n }.
recipes: [ recipes: [
{
id: "smelt_iron",
name: Some("Smelt iron plate"),
inputs: [("iron_ore", 2)],
outputs: [("iron_plate", 1)],
station: Some("furnace"),
skill: Some({ skill: "smithing", level: 1 }),
xp: 10,
},
{ {
id: "saw_planks", id: "saw_planks",
name: Some("Saw planks"), name: Some("Saw planks"),

75
assets/maps/cellar.tmx Normal file
View File

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="14" height="10" tilewidth="32" tileheight="32" infinite="0" nextlayerid="5" nextobjectid="7">
<properties><property name="default_tile" type="int" value="6"/></properties>
<tileset firstgid="1" source="../tilesets/station.tsx"/>
<layer id="1" name="Ground" width="14" height="10">
<data encoding="csv">
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
2,2,2,2,2,2,2,2,2,2,2,2,2,2
</data>
</layer>
<layer id="2" name="Walls" width="14" height="10">
<data encoding="csv">
3,3,3,3,3,3,3,3,3,3,3,3,3,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
3,3,3,3,3,3,3,3,3,3,3,3,3,3
</data>
</layer>
<objectgroup id="3" name="Objects">
<object id="1" name="" class="building" x="80" y="80">
<properties>
<property name="proto" value="stairs_up"/>
</properties>
<point/>
</object>
<object id="2" name="" class="building" x="368" y="80">
<properties>
<property name="proto" value="furnace"/>
</properties>
<point/>
</object>
<object id="3" name="" class="building" x="368" y="240">
<properties>
<property name="proto" value="table"/>
</properties>
<point/>
</object>
<object id="4" name="" class="item" x="336" y="240">
<properties>
<property name="proto" value="iron_ore"/>
<property name="count" type="int" value="4"/>
</properties>
<point/>
</object>
<object id="5" name="" class="item" x="208" y="208">
<properties>
<property name="proto" value="log"/>
<property name="count" type="int" value="2"/>
</properties>
<point/>
</object>
</objectgroup>
<objectgroup id="4" name="Triggers">
<object id="6" name="stairs_up" class="teleport" x="64" y="64" width="32" height="32">
<properties>
<property name="map" type="file" value="station.tmx"/>
<property name="target" value="stairs_down"/>
</properties>
</object>
</objectgroup>
</map>

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="40" height="28" tilewidth="32" tileheight="32" infinite="0" nextlayerid="4" nextobjectid="29"> <map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="40" height="28" tilewidth="32" tileheight="32" infinite="0" nextlayerid="5" nextobjectid="32">
<properties><property name="default_tile" type="int" value="6"/></properties>
<tileset firstgid="1" source="../tilesets/station.tsx"/> <tileset firstgid="1" source="../tilesets/station.tsx"/>
<layer id="1" name="Ground" width="40" height="28"> <layer id="1" name="Ground" width="40" height="28">
<data encoding="csv"> <data encoding="csv">
@ -66,7 +67,8 @@
</data> </data>
</layer> </layer>
<objectgroup id="3" name="Objects"> <objectgroup id="3" name="Objects">
<object id="1" name="arrivals" class="spawn" x="336" y="272"><point/> <object id="1" name="arrivals" class="spawn" x="336" y="272">
<point/>
</object> </object>
<object id="2" name="" class="building" x="592" y="272"> <object id="2" name="" class="building" x="592" y="272">
<properties> <properties>
@ -98,142 +100,162 @@
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="7" name="" class="building" x="176" y="176"> <object id="7" name="" class="building" x="720" y="176">
<properties>
<property name="proto" value="assembler"/>
</properties>
<point/>
</object>
<object id="8" name="" class="building" x="176" y="176">
<properties> <properties>
<property name="proto" value="vending_machine"/> <property name="proto" value="vending_machine"/>
<property name="stock" type="int" value="5"/> <property name="stock" type="int" value="5"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="8" name="" class="building" x="176" y="560"> <object id="9" name="" class="building" x="176" y="560">
<properties> <properties>
<property name="proto" value="table"/> <property name="proto" value="table"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="9" name="" class="building" x="208" y="560"> <object id="10" name="" class="building" x="208" y="560">
<properties> <properties>
<property name="proto" value="table"/> <property name="proto" value="table"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="10" name="" class="item" x="208" y="592"> <object id="11" name="" class="item" x="208" y="592">
<properties> <properties>
<property name="proto" value="bread"/> <property name="proto" value="bread"/>
<property name="count" type="int" value="2"/> <property name="count" type="int" value="2"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="11" name="" class="item" x="688" y="336"> <object id="12" name="" class="item" x="688" y="336">
<properties> <properties>
<property name="proto" value="screwdriver"/> <property name="proto" value="screwdriver"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="12" name="" class="item" x="720" y="336"> <object id="13" name="" class="item" x="720" y="336">
<properties> <properties>
<property name="proto" value="wrench"/> <property name="proto" value="wrench"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="13" name="" class="item" x="752" y="336"> <object id="14" name="" class="item" x="752" y="336">
<properties> <properties>
<property name="proto" value="pickaxe"/> <property name="proto" value="pickaxe"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="14" name="" class="item" x="784" y="336"> <object id="15" name="" class="item" x="784" y="336">
<properties> <properties>
<property name="proto" value="axe"/> <property name="proto" value="axe"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="15" name="" class="item" x="400" y="336"> <object id="16" name="" class="item" x="400" y="336">
<properties> <properties>
<property name="proto" value="iron_ore"/> <property name="proto" value="iron_ore"/>
<property name="count" type="int" value="6"/> <property name="count" type="int" value="6"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="16" name="" class="item" x="464" y="208"> <object id="17" name="" class="item" x="464" y="208">
<properties> <properties>
<property name="proto" value="medkit"/> <property name="proto" value="medkit"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="17" name="" class="building" x="144" y="784"> <object id="18" name="" class="building" x="144" y="784">
<properties> <properties>
<property name="proto" value="tree"/> <property name="proto" value="tree"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="18" name="" class="building" x="208" y="816"> <object id="19" name="" class="building" x="208" y="816">
<properties> <properties>
<property name="proto" value="tree"/> <property name="proto" value="tree"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="19" name="" class="building" x="496" y="720"> <object id="20" name="" class="building" x="496" y="720">
<properties> <properties>
<property name="proto" value="tree"/> <property name="proto" value="tree"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="20" name="" class="building" x="720" y="656"> <object id="21" name="" class="building" x="720" y="656">
<properties> <properties>
<property name="proto" value="tree"/> <property name="proto" value="tree"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="21" name="" class="building" x="1072" y="176"> <object id="22" name="" class="building" x="1072" y="176">
<properties> <properties>
<property name="proto" value="tree"/> <property name="proto" value="tree"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="22" name="" class="building" x="1104" y="304"> <object id="23" name="" class="building" x="1104" y="304">
<properties> <properties>
<property name="proto" value="tree"/> <property name="proto" value="tree"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="23" name="" class="building" x="1008" y="464"> <object id="24" name="" class="building" x="1008" y="464">
<properties> <properties>
<property name="proto" value="tree"/> <property name="proto" value="tree"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="24" name="" class="building" x="976" y="144"> <object id="25" name="" class="building" x="976" y="144">
<properties> <properties>
<property name="proto" value="ore_rock"/> <property name="proto" value="ore_rock"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="25" name="" class="building" x="1072" y="368"> <object id="26" name="" class="building" x="1072" y="368">
<properties> <properties>
<property name="proto" value="ore_rock"/> <property name="proto" value="ore_rock"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="26" name="" class="building" x="1136" y="496"> <object id="27" name="" class="building" x="1136" y="496">
<properties> <properties>
<property name="proto" value="ore_rock"/> <property name="proto" value="ore_rock"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="27" name="" class="item" x="816" y="720"> <object id="28" name="" class="item" x="816" y="720">
<properties> <properties>
<property name="proto" value="apple"/> <property name="proto" value="apple"/>
<property name="count" type="int" value="3"/> <property name="count" type="int" value="3"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="28" name="" class="item" x="464" y="816"> <object id="29" name="" class="item" x="464" y="816">
<properties> <properties>
<property name="proto" value="log"/> <property name="proto" value="log"/>
<property name="count" type="int" value="4"/> <property name="count" type="int" value="4"/>
</properties> </properties>
<point/> <point/>
</object> </object>
<object id="30" name="" class="building" x="528" y="400">
<properties>
<property name="proto" value="stairs_down"/>
</properties>
<point/>
</object>
</objectgroup>
<objectgroup id="4" name="Triggers">
<object id="31" name="stairs_down" class="teleport" x="512" y="384" width="32" height="32">
<properties>
<property name="map" type="file" value="cellar.tmx"/>
<property name="target" value="stairs_up"/>
</properties>
</object>
</objectgroup> </objectgroup>
</map> </map>

View File

@ -1,14 +0,0 @@
// Lighting the furnace is cosmetic for now, but burns a log.
pub fn toggle(ctx) {
if ctx.variant() == "lit" {
ctx.set_variant("");
ctx.message("You let the furnace cool down.");
return;
}
if !ctx.take_item("log", 1) {
ctx.message("You need a log to light the furnace.");
return false;
}
ctx.set_variant("lit");
ctx.message("The furnace roars to life.");
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 B

View File

@ -0,0 +1,9 @@
{
"extensionsPath": "tiled",
"folders": [
"maps",
"tilesets",
"sprites"
],
"propertyTypes": []
}

5
assets/tiled/brush.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="#90a4ae" stroke="#37474f" d="M2 11h9v9H2zm11 0h9v9h-9zM2 1h9v8H2z"/>
<path fill="#ffca28" stroke="#795548" d="m20 1 3 3-9 9-3-3z"/>
<path fill="#5c6bc0" stroke="#283593" d="M10 11c-4 0-2 6-6 6 5 3 10-1 9-4z"/>
</svg>

After

Width:  |  Height:  |  Size: 319 B

466
assets/tiled/station.js Normal file
View File

@ -0,0 +1,466 @@
/* global tiled, File, FileInfo, TileMap, TileLayer, MapObject, ObjectGroup, Dialog, Qt */
"use strict";
function stationGameplayProperties(properties) {
const result = {};
for (const name of Object.keys(properties)) {
if (name === "proto" || name.startsWith("state.") || name.startsWith("machine.")
|| (name !== "solid" && Number.isInteger(properties[name]))) {
result[name] = properties[name];
}
}
return result;
}
function stationLayer(properties, defaults) {
const layer = properties["state.layer"] === undefined
? (defaults && defaults.get(properties.proto)) || "structure"
: JSON.parse(properties["state.layer"]);
if (layer !== "floor" && layer !== "structure")
throw new Error("state.layer must be the JSON string \"floor\" or \"structure\".");
return layer;
}
function stationMapLayers(map) {
if (!map || !map.isTileMap)
throw new Error("Open a Station map first.");
if (map.infinite || map.orientation !== TileMap.Orthogonal)
throw new Error("Station supports finite orthogonal maps only.");
if (map.width <= 0 || map.height <= 0 || map.width * map.height > 1000000)
throw new Error("Station maps must contain between 1 and 1,000,000 cells.");
const layers = [];
function visit(parent) {
for (let i = 0; i < parent.layerCount; ++i) {
const layer = parent.layerAt(i);
if (layer.offset.x !== 0 || layer.offset.y !== 0)
throw new Error("Station does not support layer offsets: " + layer.name);
if (layer.isGroupLayer)
visit(layer);
else
layers.push(layer);
}
}
visit(map);
return layers;
}
function stationPrototypeLayers(map) {
const prototypeLayers = new Map();
for (const tileset of map.tilesets) {
for (const tile of tileset.tiles) {
const properties = tile.properties();
if (properties.proto === undefined || properties["state.layer"] === undefined)
continue;
const layer = stationLayer(properties);
if (prototypeLayers.has(properties.proto) && prototypeLayers.get(properties.proto) !== layer)
throw new Error("Conflicting state.layer defaults for " + properties.proto);
prototypeLayers.set(properties.proto, layer);
}
}
return prototypeLayers;
}
function stationConvert(map) {
const layers = stationMapLayers(map);
const prototypeLayers = stationPrototypeLayers(map);
const occupied = new Set();
function reserve(x, y, properties) {
const key = x + "," + y + "," + stationLayer(properties, prototypeLayers);
if (occupied.has(key))
throw new Error("Two gameplay objects occupy " + key + ". Nothing was converted.");
occupied.add(key);
}
for (const layer of layers.filter(layer => layer.isObjectLayer)) {
for (const object of layer.objects) {
if (object.className !== "building")
continue;
const properties = Object.assign({},
object.tile ? stationGameplayProperties(object.tile.properties()) : {},
object.properties());
reserve(Math.floor(object.x / map.tileWidth),
Math.floor((object.y - (object.tile ? 1 : 0)) / map.tileHeight), properties);
}
}
const planned = [];
for (const layer of layers.filter(layer => layer.isTileLayer)) {
if (layer.locked)
throw new Error("Unlock tile layer before converting: " + layer.name);
for (let y = 0; y < map.height; ++y) {
for (let x = 0; x < map.width; ++x) {
const tile = layer.tileAt(x, y);
if (!tile || tile.property("proto") === undefined)
continue;
const properties = stationGameplayProperties(tile.properties());
if (typeof properties.proto !== "string" || !properties.proto)
throw new Error("Tile proto must be a nonempty building ID.");
const cell = layer.cellAt(x, y);
for (const [key, value] of [
["state.flip_h", cell.flippedHorizontally],
["state.flip_v", cell.flippedVertically],
["state.flip_d", cell.flippedAntiDiagonally],
]) {
if (value)
properties[key] = true;
}
reserve(x, y, properties);
planned.push({layer, x, y, properties});
}
}
}
if (planned.length === 0)
return 0;
const names = ["Floors", "Wiring/Plumbing", "Triggers", "Ground Items", "Objects"];
const destinations = new Map();
for (const name of names) {
const matches = layers.filter(layer => layer.isObjectLayer && layer.name === name);
if (matches.length > 1)
throw new Error("Multiple object layers named " + name + "; rename them before converting.");
if (matches.length === 1) {
if (matches[0].locked)
throw new Error("Unlock object layer before converting: " + name);
destinations.set(name, matches[0]);
}
}
map.macro("Convert Station painted tiles to objects", function () {
for (const name of names) {
if (!destinations.has(name)) {
const objects = new ObjectGroup();
objects.name = name;
objects.visible = name !== "Floors" && name !== "Wiring/Plumbing";
map.addLayer(objects);
destinations.set(name, objects);
}
}
const selected = [];
const edits = new Map();
for (const entry of planned) {
const object = new MapObject();
object.name = entry.properties.proto;
object.className = "building";
object.shape = MapObject.Point;
object.x = (entry.x + 0.5) * map.tileWidth;
object.y = (entry.y + 0.5) * map.tileHeight;
object.setProperties(entry.properties);
const name = stationLayer(entry.properties, prototypeLayers) === "floor" ? "Floors" : "Objects";
destinations.get(name).addObject(object);
selected.push(object);
if (!edits.has(entry.layer))
edits.set(entry.layer, entry.layer.edit());
edits.get(entry.layer).setTile(entry.x, entry.y, null);
}
for (const edit of edits.values())
edit.apply();
map.selectedObjects = selected;
});
return planned.length;
}
const stationConvertAction = tiled.registerAction("StationConvertPaintedTiles", function () {
try {
const count = stationConvert(tiled.activeAsset);
tiled.log("Station: converted " + count + " painted tiles to editable point objects.");
} catch (error) {
tiled.alert("Station conversion failed: " + error.message);
}
});
stationConvertAction.text = "Station: Convert Painted Tiles to Objects";
function stationProjectMapFiles(project) {
if (!project || !project.fileName || project.folders.length === 0)
throw new Error("Open a Station project first (Project > Open Project...).");
const files = new Set();
function visit(dir) {
const fileFilter = File.Files | File.NoSymLinks;
for (const name of File.directoryEntries(dir, fileFilter)) {
if (name.toLowerCase().endsWith(".tmx"))
files.add(FileInfo.cleanPath(dir + "/" + name));
}
// Symlinked folders are skipped so link cycles cannot loop forever.
const dirFilter = File.Dirs | File.NoDotAndDotDot | File.NoSymLinks;
for (const name of File.directoryEntries(dir, dirFilter))
visit(dir + "/" + name);
}
for (const folder of project.folders)
visit(folder);
return [...files].sort();
}
// Converted maps stay open and unsaved so the result can be reviewed and undone per map.
function stationConvertProject() {
const files = stationProjectMapFiles(tiled.project);
const previous = tiled.activeAsset;
const report = {maps: 0, tiles: 0, failures: []};
for (const file of files) {
const wasOpen = tiled.openAssets.some(asset => FileInfo.cleanPath(asset.fileName) === file);
const asset = tiled.open(file);
try {
if (!asset)
throw new Error("could not open the map");
const count = stationConvert(asset);
if (count > 0) {
report.maps++;
report.tiles += count;
} else if (!wasOpen) {
tiled.close(asset);
}
} catch (error) {
report.failures.push(file + ": " + error.message);
if (asset && !wasOpen && !asset.modified)
tiled.close(asset);
}
}
if (previous && tiled.openAssets.includes(previous))
tiled.activeAsset = previous;
return report;
}
const stationConvertProjectAction = tiled.registerAction("StationConvertProjectMaps", function () {
try {
const report = stationConvertProject();
let message = "Station: converted " + report.tiles + " painted tiles in " + report.maps
+ " maps. Review them and use File > Save All.";
if (report.failures.length > 0)
message += "\n\nNot converted:\n" + report.failures.join("\n");
tiled.log(message);
tiled.alert(message);
} catch (error) {
tiled.alert("Station conversion failed: " + error.message);
}
});
stationConvertProjectAction.text = "Station: Convert All Project Maps";
const stationBrushProperties = new Map();
function stationBrushSettings(tile) {
if (!tile || typeof tile.property("proto") !== "string" || !tile.property("proto"))
throw new Error("Select one gameplay wall or floor tile in the Tilesets panel first.");
return Object.assign({}, stationGameplayProperties(tile.properties()), stationBrushProperties.get(tile));
}
function stationConfigureBrush(tile) {
const properties = stationBrushSettings(tile);
const dialog = new Dialog("Station Brush Properties");
dialog.newRowMode = Dialog.SingleWidgetRows;
dialog.addLabel("Properties apply only to newly drawn objects; tileset defaults are unchanged.");
const inputs = new Map();
for (const [name, value] of Object.entries(properties)) {
if (name === "proto" || name === "state.layer") {
dialog.addLabel(name + ": " + value);
} else if (typeof value === "boolean") {
const input = dialog.addCheckBox(name);
input.checked = value;
inputs.set(name, input);
} else {
inputs.set(name, dialog.addTextInput(name, String(value)));
}
}
dialog.addButton("Apply").clicked.connect(function () {
try {
const edited = Object.assign({}, properties);
for (const [name, input] of inputs) {
const original = properties[name];
let value = typeof original === "boolean" ? input.checked : input.text;
if (typeof original === "number") {
const integer = Number.isInteger(original) && name !== "machine.progress";
if (!value.trim() || !Number.isFinite(Number(value))
|| (integer && (!Number.isInteger(Number(value))
|| Number(value) < -2147483648 || Number(value) > 2147483647)))
throw new Error(name + " must be a valid " + (integer ? "32-bit integer." : "number."));
value = Number(value);
} else if (typeof original === "string"
&& (name.startsWith("state.") || name.startsWith("machine."))) {
JSON.parse(value);
}
edited[name] = value;
}
stationLayer(edited);
stationBrushProperties.set(tile, edited);
dialog.accept();
} catch (error) {
tiled.alert("Invalid brush properties: " + error.message);
}
});
dialog.addButton("Cancel").clicked.connect(function () { dialog.reject(); });
dialog.exec();
}
function stationPaint(map, tile, properties, cells) {
const layers = stationMapLayers(map);
const defaults = stationPrototypeLayers(map);
const kind = stationLayer(properties, defaults);
const name = kind === "floor" ? "Floors" : "Objects";
const destinations = layers.filter(layer => layer.isObjectLayer && layer.name === name);
if (destinations.length > 1)
throw new Error("Multiple object layers named " + name + "; rename them before painting.");
if (destinations.length && destinations[0].locked)
throw new Error("Unlock object layer before painting: " + name);
const wanted = new Set(cells.map(cell => cell.x + "," + cell.y));
for (const cell of cells) {
if (cell.x < 0 || cell.y < 0 || cell.x >= map.width || cell.y >= map.height)
throw new Error("Cannot paint outside the map.");
}
for (const layer of layers) {
if (layer.isObjectLayer) {
for (const object of layer.objects) {
if (object.className !== "building")
continue;
const state = Object.assign({},
object.tile ? stationGameplayProperties(object.tile.properties()) : {},
object.properties());
const key = Math.floor(object.x / map.tileWidth) + ","
+ Math.floor((object.y - (object.tile ? 1 : 0)) / map.tileHeight);
if (wanted.has(key) && stationLayer(state, defaults) === kind)
throw new Error("A " + kind + " already occupies " + key + ". Edit that object or delete it first.");
}
} else if (layer.isTileLayer) {
for (const cell of cells) {
const painted = layer.tileAt(cell.x, cell.y);
if (painted && painted.property("proto") !== undefined
&& stationLayer(painted.properties(), defaults) === kind)
throw new Error("Painted gameplay tiles in " + layer.name + " at "
+ cell.x + "," + cell.y + " overlap this stroke. Convert them to objects first.");
}
}
}
if (!cells.length)
return;
map.macro("Paint Station objects", function () {
let destination = destinations[0];
if (!destination) {
destination = new ObjectGroup();
destination.name = name;
destination.visible = kind !== "floor";
map.addLayer(destination);
}
const selected = [];
for (const cell of cells) {
const object = new MapObject();
object.name = properties.proto;
object.className = "building";
object.tile = tile;
object.width = map.tileWidth;
object.height = map.tileHeight;
object.x = cell.x * map.tileWidth;
object.y = (cell.y + 1) * map.tileHeight;
for (const [key, value] of Object.entries(properties))
object.setProperty(key, value);
destination.addObject(object);
selected.push(object);
}
map.selectedObjects = selected;
});
}
function stationBrushMove(tool) {
if (!tool.stroke)
return;
const map = tool.map;
const end = {
x: Math.max(0, Math.min(map.width - 1, tool.tilePosition.x)),
y: Math.max(0, Math.min(map.height - 1, tool.tilePosition.y)),
};
const start = tool.lastCell || end;
const steps = Math.max(Math.abs(end.x - start.x), Math.abs(end.y - start.y));
for (let i = 0; i <= steps; ++i) {
const cell = {
x: Math.round(start.x + (end.x - start.x) * (steps ? i / steps : 0)),
y: Math.round(start.y + (end.y - start.y) * (steps ? i / steps : 0)),
};
tool.stroke.set(cell.x + "," + cell.y, cell);
}
tool.lastCell = end;
const preview = new TileMap();
preview.setSize(map.width, map.height);
preview.setTileSize(map.tileWidth, map.tileHeight);
preview.addTileset(tool.brushTile.tileset);
const layer = new TileLayer();
preview.addLayer(layer);
const edit = layer.edit();
for (const cell of tool.stroke.values())
edit.setTile(cell.x, cell.y, tool.brushTile);
edit.apply();
tool.preview = preview;
}
function stationBrushError(tool, error) {
tool.errorMessage = "Station brush: " + error.message;
tool.updateStatusInfo();
// Modal dialogs inside mouse callbacks keep the map's mouse grab until they close.
tiled.log(tool.errorMessage);
}
const stationPropertiesAction = tiled.registerAction("StationBrushProperties", function () {
try {
const tiles = tiled.mapEditor.tilesetsView.selectedTiles;
if (tiles.length !== 1)
throw new Error("Select exactly one gameplay tile in the Tilesets panel.");
stationConfigureBrush(tiles[0]);
} catch (error) {
tiled.alert("Station brush: " + error.message);
}
});
stationPropertiesAction.text = "Station: Brush Properties";
if (tiled.mapEditor) {
tiled.extendMenu("Map", [
{action: "StationConvertPaintedTiles"},
{action: "StationConvertProjectMaps"},
{action: "StationBrushProperties"},
]);
tiled.registerTool("StationObjectBrush", {
name: "Station Object Brush",
icon: "brush.svg",
usesSelectedTiles: true,
toolBarActions: ["StationBrushProperties"],
mousePressed: function (button) {
if (button !== Qt.LeftButton)
return;
try {
this.errorMessage = "";
this.updateStatusInfo();
stationMapLayers(this.map);
if (this.tilePosition.x < 0 || this.tilePosition.y < 0
|| this.tilePosition.x >= this.map.width || this.tilePosition.y >= this.map.height)
throw new Error("Cannot paint outside the map.");
this.brushTile = this.selectedTile;
this.brushProperties = stationBrushSettings(this.brushTile);
this.stroke = new Map();
this.lastCell = null;
stationBrushMove(this);
} catch (error) {
this.deactivated();
stationBrushError(this, error);
}
},
mouseMoved: function () { stationBrushMove(this); },
mouseReleased: function (button) {
if (button !== Qt.LeftButton || !this.stroke)
return;
try {
stationBrushMove(this);
const cells = [...this.stroke.values()];
this.deactivated();
stationPaint(this.map, this.brushTile, this.brushProperties, cells);
} catch (error) {
this.deactivated();
stationBrushError(this, error);
}
},
deactivated: function () {
this.stroke = null;
this.lastCell = null;
this.preview = new TileMap();
},
mapChanged: function () { this.deactivated(); },
keyPressed: function (key) {
if (key === Qt.Key_Escape)
this.deactivated();
},
updateStatusInfo: function () {
this.statusInfo = this.errorMessage || "Station Object Brush: drag to paint; Escape cancels.";
},
});
}

View File

@ -2,13 +2,13 @@
<tileset version="1.10" tiledversion="1.10.2" name="station" tilewidth="32" tileheight="32" tilecount="8" columns="4"> <tileset version="1.10" tiledversion="1.10.2" name="station" tilewidth="32" tileheight="32" tilecount="8" columns="4">
<image source="station.png" width="128" height="64"/> <image source="station.png" width="128" height="64"/>
<tile id="0"> <tile id="0">
<properties><property name="name" value="floor"/></properties> <properties><property name="name" value="floor"/><property name="proto" value="floor"/><property name="state.layer" value="&quot;floor&quot;"/><property name="state.health" type="int" value="100"/><property name="state.solid" type="bool" value="false"/><property name="state.variant" value="null"/><property name="state.vars" value="{}"/></properties>
</tile> </tile>
<tile id="1"> <tile id="1">
<properties><property name="name" value="dark_floor"/></properties> <properties><property name="name" value="dark_floor"/><property name="proto" value="dark_floor"/><property name="state.layer" value="&quot;floor&quot;"/><property name="state.health" type="int" value="100"/><property name="state.solid" type="bool" value="false"/><property name="state.variant" value="null"/><property name="state.vars" value="{}"/></properties>
</tile> </tile>
<tile id="2"> <tile id="2">
<properties><property name="name" value="wall"/><property name="solid" type="bool" value="true"/></properties> <properties><property name="name" value="wall"/><property name="solid" type="bool" value="true"/><property name="proto" value="wall"/><property name="state.layer" value="&quot;structure&quot;"/><property name="state.health" type="int" value="300"/><property name="state.solid" type="bool" value="true"/><property name="state.variant" value="null"/><property name="state.vars" value="{}"/></properties>
</tile> </tile>
<tile id="3"> <tile id="3">
<properties><property name="name" value="grass"/></properties> <properties><property name="name" value="grass"/></properties>
@ -17,12 +17,12 @@
<properties><property name="name" value="water"/><property name="solid" type="bool" value="true"/></properties> <properties><property name="name" value="water"/><property name="solid" type="bool" value="true"/></properties>
</tile> </tile>
<tile id="5"> <tile id="5">
<properties><property name="name" value="plating"/></properties> <properties><property name="name" value="plating"/><property name="proto" value="plating"/><property name="state.layer" value="&quot;floor&quot;"/><property name="state.health" type="int" value="100"/><property name="state.solid" type="bool" value="false"/><property name="state.variant" value="null"/><property name="state.vars" value="{}"/></properties>
</tile> </tile>
<tile id="6"> <tile id="6">
<properties><property name="name" value="dirt"/></properties> <properties><property name="name" value="dirt"/></properties>
</tile> </tile>
<tile id="7"> <tile id="7">
<properties><property name="name" value="window"/><property name="solid" type="bool" value="true"/></properties> <properties><property name="name" value="window"/><property name="solid" type="bool" value="true"/><property name="proto" value="window"/><property name="state.layer" value="&quot;structure&quot;"/><property name="state.health" type="int" value="100"/><property name="state.solid" type="bool" value="true"/><property name="state.variant" value="null"/><property name="state.vars" value="{}"/></properties>
</tile> </tile>
</tileset> </tileset>

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

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

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

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

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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

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

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

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

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

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

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

View File

@ -10,9 +10,14 @@ path = "src/main.rs"
[dependencies] [dependencies]
game_core = { workspace = true } game_core = { workspace = true }
game_server = { workspace = true } game_server = { workspace = true }
bevy = { workspace = true, default-features = true } bevy = { workspace = true, default-features = true, features = ["bevy_ui_widgets", "system_clipboard"] }
bevy_replicon = { workspace = true } bevy_replicon = { workspace = true }
bevy_replicon_renet = { workspace = true } bevy_replicon_renet = { workspace = true }
directories = { workspace = true }
rsn = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
uuid = { workspace = true }
[lints] [lints]
workspace = true workspace = true

View File

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

View File

@ -11,55 +11,95 @@ use game_core::{
use game_server::actions::REACH; use game_server::actions::REACH;
use crate::{ use crate::{
render::LocalCharacter, hud::{Menu, UiState, activate_entry, menu_entries},
ui::{Menu, UiState, menu_entries}, machine::{InteractAt, OpenMachine},
render::{LocalCharacter, OffLevel},
session::AppState,
ui::{TextEditSystems, UiFocus, pointer_over_ui},
}; };
pub struct InputPlugin; pub struct InputPlugin;
impl Plugin for InputPlugin { impl Plugin for InputPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.add_systems(Update, (movement, menu_input, actions, mouse).chain()); app.add_systems(
Update,
(
window_keys,
movement,
(menu_input, actions, mouse).run_if(game_input_allowed),
)
.chain()
.after(TextEditSystems)
.run_if(in_state(AppState::InGame)),
);
} }
} }
const MOVE_KEYS: [(KeyCode, Dir); 8] = [ fn game_input_allowed(focus: Res<UiFocus>, ui: Res<UiState>) -> bool {
(KeyCode::KeyW, Dir::North), !focus.captures_keyboard() && !ui.paused && !ui.chat_open
(KeyCode::ArrowUp, Dir::North), }
(KeyCode::KeyS, Dir::South),
(KeyCode::ArrowDown, Dir::South),
(KeyCode::KeyA, Dir::West),
(KeyCode::ArrowLeft, Dir::West),
(KeyCode::KeyD, Dir::East),
(KeyCode::ArrowRight, Dir::East),
];
/// Sends the held direction whenever it changes. The most recently pressed key wins. /// Esc closes the innermost window or toggles the pause menu; Enter/T/`/` open the chat.
fn window_keys(
keys: Res<ButtonInput<KeyCode>>,
focus: Res<UiFocus>,
mut ui: ResMut<UiState>,
mut machine: ResMut<OpenMachine>,
mut form: ResMut<crate::ui::UiForm>,
) {
if focus.captures_keyboard() || ui.chat_open {
return;
}
if keys.just_pressed(KeyCode::Escape) {
if machine.0.is_some() {
machine.0 = None;
} else if ui.menu.is_some() {
ui.menu = None;
} else {
ui.paused = !ui.paused;
}
return;
}
if ui.paused || ui.menu.is_some_and(|m| m != Menu::Help) {
return;
}
if keys.any_just_pressed([KeyCode::Enter, KeyCode::NumpadEnter, KeyCode::KeyT]) {
ui.chat_open = true;
} else if keys.any_just_pressed([KeyCode::Slash, KeyCode::NumpadDivide]) {
form.0.insert("chat".into(), "/".into());
ui.chat_open = true;
}
}
/// Sends the held movement direction whenever it changes.
fn movement( fn movement(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
ui: Res<UiState>, ui: Res<UiState>,
mut held: Local<Vec<Dir>>, focus: Res<UiFocus>,
mut sent: Local<Option<Dir>>, mut sent: Local<SetMovement>,
) { ) {
held.retain(|dir| { let axis = |neg: [KeyCode; 2], pos: [KeyCode; 2]| {
MOVE_KEYS i8::from(keys.any_pressed(pos)) - i8::from(keys.any_pressed(neg))
.iter()
.any(|(key, d)| d == dir && keys.pressed(*key))
});
for (key, dir) in MOVE_KEYS {
if keys.just_pressed(key) && !held.contains(&dir) {
held.push(dir);
}
}
let dir = if ui.menu.is_some_and(|m| m != Menu::Help) {
None
} else {
held.last().copied()
}; };
if *sent != dir { let movement = if ui.blocks_game() || ui.chat_open || focus.captures_keyboard() {
*sent = dir; SetMovement::default()
commands.client_trigger(SetMoveDir(dir)); } else {
SetMovement {
x: axis(
[KeyCode::KeyA, KeyCode::ArrowLeft],
[KeyCode::KeyD, KeyCode::ArrowRight],
),
y: axis(
[KeyCode::KeyW, KeyCode::ArrowUp],
[KeyCode::KeyS, KeyCode::ArrowDown],
),
}
};
if *sent != movement {
*sent = movement;
commands.client_trigger(movement);
} }
} }
@ -80,10 +120,6 @@ fn menu_input(
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>, characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
) { ) {
let Some(menu) = ui.menu else { return }; let Some(menu) = ui.menu else { return };
if keys.just_pressed(KeyCode::Escape) {
ui.menu = None;
return;
}
let entries = menu_entries(menu, &data); let entries = menu_entries(menu, &data);
if entries.is_empty() { if entries.is_empty() {
return; return;
@ -96,17 +132,8 @@ fn menu_input(
} }
if keys.any_just_pressed([KeyCode::Enter, KeyCode::Space, KeyCode::NumpadEnter]) { if keys.any_just_pressed([KeyCode::Enter, KeyCode::Space, KeyCode::NumpadEnter]) {
let id = entries[ui.cursor.min(entries.len() - 1)].clone(); let id = entries[ui.cursor.min(entries.len() - 1)].clone();
match menu { if let Some((pos, facing, _)) = local_state(&local, &characters) {
Menu::Craft => commands.client_trigger(Craft { recipe: id }), activate_entry(&mut commands, menu, id, pos, facing);
Menu::Build => {
if let Some((pos, facing, _)) = local_state(&local, &characters) {
commands.client_trigger(Construct {
building: id,
target: pos.step(facing),
});
}
}
Menu::Help => {}
} }
} }
} }
@ -130,9 +157,10 @@ fn actions(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut wheel: MessageReader<MouseWheel>, mut wheel: MessageReader<MouseWheel>,
mut ui: ResMut<UiState>, mut ui: ResMut<UiState>,
machine: Res<OpenMachine>,
local: Res<LocalCharacter>, local: Res<LocalCharacter>,
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>, characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
items: Query<&GridPos, With<GroundItem>>, items: Query<&GridPos, (With<GroundItem>, Without<OffLevel>)>,
) { ) {
if keys.just_pressed(KeyCode::KeyH) { if keys.just_pressed(KeyCode::KeyH) {
ui.toggle(Menu::Help); ui.toggle(Menu::Help);
@ -151,9 +179,16 @@ fn actions(
ui.selected_slot = i; ui.selected_slot = i;
} }
} }
for ev in wheel.read() { let wheel_events: Vec<f32> = wheel.read().map(|ev| ev.y).collect();
// Open windows have their own list navigation.
let wheel_events = if ui.menu.is_some() || machine.0.is_some() {
Vec::new()
} else {
wheel_events
};
for y in wheel_events {
let len = INVENTORY_SLOTS; let len = INVENTORY_SLOTS;
ui.selected_slot = if ev.y > 0.0 { ui.selected_slot = if y > 0.0 {
(ui.selected_slot + len - 1) % len (ui.selected_slot + len - 1) % len
} else { } else {
(ui.selected_slot + 1) % len (ui.selected_slot + 1) % len
@ -178,7 +213,7 @@ fn actions(
commands.client_trigger(PickUp { target }); commands.client_trigger(PickUp { target });
} }
if keys.just_pressed(KeyCode::KeyF) { if keys.just_pressed(KeyCode::KeyF) {
commands.client_trigger(Interact { target: front }); commands.trigger(InteractAt(front));
} }
if keys.just_pressed(KeyCode::KeyX) { if keys.just_pressed(KeyCode::KeyX) {
commands.client_trigger(Deconstruct { target: front }); commands.client_trigger(Deconstruct { target: front });
@ -196,16 +231,22 @@ fn actions(
} }
/// Left click on a tile within reach: interact with a building, otherwise pick up items. /// Left click on a tile within reach: interact with a building, otherwise pick up items.
#[allow(clippy::too_many_arguments)]
fn mouse( fn mouse(
mut commands: Commands, mut commands: Commands,
buttons: Res<ButtonInput<MouseButton>>, buttons: Res<ButtonInput<MouseButton>>,
interactions: Query<&Interaction>,
ui: Res<UiState>,
window: Query<&Window, With<PrimaryWindow>>, window: Query<&Window, With<PrimaryWindow>>,
camera: Query<(&Camera, &GlobalTransform)>, camera: Query<(&Camera, &GlobalTransform)>,
local: Res<LocalCharacter>, local: Res<LocalCharacter>,
characters: Query<&GridPos, With<Character>>, characters: Query<&GridPos, With<Character>>,
buildings: Query<&GridPos, With<Building>>, buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<OffLevel>)>,
) { ) {
if !buttons.just_pressed(MouseButton::Left) { if !buttons.just_pressed(MouseButton::Left)
|| ui.blocks_game()
|| pointer_over_ui(&interactions)
{
return; return;
} }
let Some(me) = local.0.and_then(|e| characters.get(e).ok()) else { let Some(me) = local.0.and_then(|e| characters.get(e).ok()) else {
@ -227,8 +268,11 @@ fn mouse(
if me.distance(target) > REACH { if me.distance(target) > REACH {
return; return;
} }
if buildings.iter().any(|p| *p == target) { if buildings
commands.client_trigger(Interact { target }); .iter()
.any(|(p, state)| *p == target && state.layer == game_core::data::BuildingLayer::Structure)
{
commands.trigger(InteractAt(target));
} else { } else {
commands.client_trigger(PickUp { target }); commands.client_trigger(PickUp { target });
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,11 +4,10 @@ use bevy::prelude::*;
use game_core::{ use game_core::{
GameData, GameData,
components::*, components::*,
grid::{Dir, TILE_SIZE, grid_to_world}, grid::{Dir, TILE_SIZE, grid_to_world, position_to_world},
map::MapLayout,
}; };
use crate::LocalPlayer; use crate::session::SessionInfo;
pub struct RenderPlugin; pub struct RenderPlugin;
@ -16,15 +15,16 @@ impl Plugin for RenderPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<LocalCharacter>() app.init_resource::<LocalCharacter>()
.add_systems(Startup, setup) .add_systems(Startup, setup)
.add_observer(spawn_map)
.add_systems( .add_systems(
Update, Update,
( (
find_local_character, find_local_character,
spawn_map,
building_visuals, building_visuals,
item_visuals, item_visuals,
character_visuals, character_visuals,
move_characters, move_characters,
hide_other_levels,
update_marker, update_marker,
follow_camera, follow_camera,
) )
@ -59,86 +59,104 @@ fn setup(mut commands: Commands) {
/// Builds sprites for every tile when the world map arrives. Tiles are children /// Builds sprites for every tile when the world map arrives. Tiles are children
/// of the map entity, so they disappear with it (e.g. on disconnect). /// of the map entity, so they disappear with it (e.g. on disconnect).
fn spawn_map( fn spawn_map(
add: On<Add, WorldMap>,
mut commands: Commands, mut commands: Commands,
maps: Query<&WorldMap>, maps: Query<(Entity, &WorldMap), Changed<WorldMap>>,
asset_server: Res<AssetServer>, asset_server: Res<AssetServer>,
mut layouts: ResMut<Assets<TextureAtlasLayout>>, mut layouts: ResMut<Assets<TextureAtlasLayout>>,
) { ) {
let Ok(map) = maps.get(add.entity) else { for (entity, map) in &maps {
return; let layout = &map.layout;
}; commands.entity(entity).despawn_children();
let layout = match MapLayout::load(&game_core::assets_dir(), &map.path) { let atlases: Vec<_> = layout
Ok(layout) => layout, .tilesets
Err(e) => { .iter()
error!("can't display map: {e}"); .map(|ts| {
return; let rows = ts.tile_count.div_ceil(ts.columns.max(1));
} let atlas = TextureAtlasLayout::from_grid(
}; UVec2::new(ts.tile_width, ts.tile_height),
let atlases: Vec<_> = layout ts.columns,
.tilesets rows,
.iter() Some(UVec2::splat(ts.spacing)),
.map(|ts| { Some(UVec2::splat(ts.margin)),
let rows = ts.tile_count.div_ceil(ts.columns.max(1)); );
let atlas = TextureAtlasLayout::from_grid( (asset_server.load(ts.image.clone()), layouts.add(atlas))
UVec2::new(ts.tile_width, ts.tile_height), })
ts.columns, .collect();
rows,
Some(UVec2::splat(ts.spacing)),
Some(UVec2::splat(ts.margin)),
);
(asset_server.load(ts.image.clone()), layouts.add(atlas))
})
.collect();
commands commands
.entity(add.entity) .entity(entity)
.insert((Transform::default(), Visibility::default())); .insert((Transform::default(), Visibility::default()));
for (layer_index, layer) in layout.layers.iter().enumerate() { let default_layer = layout
let z = layer_index as f32 * 0.01; .default_tile
for (i, cell) in layer.cells.iter().enumerate() { .map(|cell| game_core::map::TileLayerData {
let Some(cell) = cell else { continue }; name: "Default terrain".into(),
let Some((image, atlas)) = atlases.get(cell.tileset) else { solid: false,
continue; cells: vec![Some(cell); (layout.width * layout.height) as usize],
}; });
let pos = GridPos::new( for (layer_index, layer) in default_layer.iter().chain(layout.layers.iter()).enumerate() {
(i as u32 % layout.width) as i32, let z = layer_index as f32 * 0.01;
(i as u32 / layout.width) as i32, for (i, cell) in layer.cells.iter().enumerate() {
); let Some(cell) = cell else { continue };
commands.spawn(( let Some((image, atlas)) = atlases.get(cell.tileset) else {
ChildOf(add.entity), continue;
Sprite { };
image: image.clone(), let pos = GridPos::new(
texture_atlas: Some(TextureAtlas { (i as u32 % layout.width) as i32,
layout: atlas.clone(), (i as u32 / layout.width) as i32,
index: cell.id as usize, );
}), commands.spawn((
flip_x: cell.flip_h, ChildOf(entity),
flip_y: cell.flip_v, Sprite {
custom_size: Some(Vec2::splat(TILE_SIZE)), image: image.clone(),
..default() texture_atlas: Some(TextureAtlas {
}, layout: atlas.clone(),
Transform::from_translation(grid_to_world(pos).extend(z)), index: cell.id as usize,
)); }),
flip_x: if cell.flip_d {
!cell.flip_v
} else {
cell.flip_h
},
flip_y: if cell.flip_d {
cell.flip_h
} else {
cell.flip_v
},
custom_size: Some(Vec2::splat(TILE_SIZE)),
..default()
},
Transform::from_translation(grid_to_world(pos).extend(z)).with_rotation(
Quat::from_rotation_z(if cell.flip_d {
-std::f32::consts::FRAC_PI_2
} else {
0.0
}),
),
));
}
} }
info!(
"map `{}` displayed ({}x{})",
map.path, layout.width, layout.height
);
} }
info!(
"map `{}` displayed ({}x{})",
map.path, layout.width, layout.height
);
} }
fn find_local_character( fn find_local_character(
mut local: ResMut<LocalCharacter>, mut local: ResMut<LocalCharacter>,
player: Res<LocalPlayer>, session: Res<SessionInfo>,
characters: Query<(Entity, &Character)>, characters: Query<(Entity, &Character)>,
) { ) {
if local.0.is_some_and(|e| characters.contains(e)) { if local.0.is_some_and(|e| characters.contains(e)) {
return; return;
} }
if session.character.is_empty() {
local.0 = None;
return;
}
local.0 = characters local.0 = characters
.iter() .iter()
.find(|(_, c)| c.name == player.name) .find(|(_, c)| c.name == session.character)
.map(|(e, _)| e); .map(|(e, _)| e);
} }
@ -164,12 +182,34 @@ fn building_visuals(
continue; continue;
}; };
let mut entity = commands.entity(entity); let mut entity = commands.entity(entity);
entity.insert(sprite( let mut visual = sprite(
&asset_server, &asset_server,
def.sprite_for(state.variant.as_deref()), def.sprite_for(state.variant.as_deref()),
TILE_SIZE, TILE_SIZE,
)); );
let transform = Transform::from_translation(grid_to_world(*pos).extend(Z_BUILDING)); visual.flip_x = if state.flip_d {
!state.flip_v
} else {
state.flip_h
};
visual.flip_y = if state.flip_d {
state.flip_h
} else {
state.flip_v
};
entity.insert(visual);
let z = if state.layer == game_core::data::BuildingLayer::Floor {
0.5
} else {
Z_BUILDING
};
let transform = Transform::from_translation(grid_to_world(*pos).extend(z)).with_rotation(
Quat::from_rotation_z(if state.flip_d {
-std::f32::consts::FRAC_PI_2
} else {
0.0
}),
);
if has_transform { if has_transform {
entity.insert(transform); entity.insert(transform);
} else { } else {
@ -219,7 +259,7 @@ fn character_visuals(
mut commands: Commands, mut commands: Commands,
asset_server: Res<AssetServer>, asset_server: Res<AssetServer>,
local: Res<LocalCharacter>, local: Res<LocalCharacter>,
added: Query<(Entity, &Character, &GridPos), Added<Character>>, added: Query<(Entity, &Character, &Position), Added<Character>>,
mut facing: Query<(&Facing, &mut Sprite), (With<Character>, Changed<Facing>)>, mut facing: Query<(&Facing, &mut Sprite), (With<Character>, Changed<Facing>)>,
) { ) {
for (entity, character, pos) in &added { for (entity, character, pos) in &added {
@ -229,7 +269,7 @@ fn character_visuals(
} }
commands.entity(entity).insert(( commands.entity(entity).insert((
sprite, sprite,
Transform::from_translation(grid_to_world(*pos).extend(Z_CHARACTER)), Transform::from_translation(position_to_world(pos.0).extend(Z_CHARACTER)),
Visibility::default(), Visibility::default(),
children![( children![(
Text2d::new(character.name.clone()), Text2d::new(character.name.clone()),
@ -243,21 +283,53 @@ fn character_visuals(
} }
} }
/// Glides characters between tiles; teleports for large jumps (respawn, login). /// Smooths replicated positions, which arrive at the server tick rate; teleports for large
/// jumps (respawn, login).
fn move_characters( fn move_characters(
time: Res<Time>, time: Res<Time>,
mut characters: Query<(&GridPos, &mut Transform), With<Character>>, mut characters: Query<(&Position, &mut Transform), With<Character>>,
) { ) {
let speed = TILE_SIZE / game_server::movement::MOVE_DELAY * 1.2; let t = 1.0 - (-20.0 * time.delta_secs()).exp();
for (pos, mut transform) in &mut characters { for (pos, mut transform) in &mut characters {
let target = grid_to_world(*pos); let target = position_to_world(pos.0);
let current = transform.translation.truncate(); let current = transform.translation.truncate();
let delta = target - current; let delta = target - current;
if delta.length() > TILE_SIZE * 3.0 { let next = if delta.length() > TILE_SIZE * 3.0 {
transform.translation = target.extend(Z_CHARACTER); target
} else { } else {
let step = delta.clamp_length_max(speed * time.delta_secs()); current + delta * t
transform.translation += step.extend(0.0); };
transform.translation = next.extend(Z_CHARACTER);
}
}
/// Marks entities on another level than our character; exclude them from lookups by
/// position.
#[derive(Component)]
pub struct OffLevel;
/// Remote clients only receive their own level, but singleplayer and hosting render
/// straight from the server world, which holds every level.
fn hide_other_levels(
mut commands: Commands,
local: Res<LocalCharacter>,
levels: Query<&Level>,
mut entities: Query<(Entity, &Level, Option<&mut Visibility>, Has<OffLevel>)>,
) {
let current = local.0.and_then(|e| levels.get(e).ok()).cloned();
for (entity, level, visibility, off) in &mut entities {
let on_level = current.as_ref().is_none_or(|current| current == level);
if let Some(mut visibility) = visibility {
visibility.set_if_neq(if on_level {
Visibility::Inherited
} else {
Visibility::Hidden
});
}
if on_level && off {
commands.entity(entity).remove::<OffLevel>();
} else if !on_level && !off {
commands.entity(entity).insert(OffLevel);
} }
} }
} }
@ -292,6 +364,11 @@ fn follow_camera(
return; return;
}; };
let goal = target.translation.truncate().extend(camera.translation.z); let goal = target.translation.truncate().extend(camera.translation.z);
// Don't pan across the map after teleports.
if goal.distance(camera.translation) > TILE_SIZE * 8.0 {
camera.translation = goal;
return;
}
let t = 1.0 - (-8.0 * time.delta_secs()).exp(); let t = 1.0 - (-8.0 * time.delta_secs()).exp();
camera.translation = camera.translation.lerp(goal, t); camera.translation = camera.translation.lerp(goal, t);
} }

View File

@ -0,0 +1,149 @@
//! 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::{Path, PathBuf};
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 {
directories::ProjectDirs::from("", "", "station")
.map(|dirs| dirs.data_dir().join("saves"))
.unwrap_or_else(|| PathBuf::from("saves"))
}
/// File name for a save: the name reduced to `[a-z0-9_-]`.
pub fn slug(name: &str) -> String {
let slug: String = name
.trim()
.chars()
.map(|c| match c {
'a'..='z' | '0'..='9' | '-' | '_' => c,
'A'..='Z' => c.to_ascii_lowercase(),
_ => '_',
})
.collect();
if slug.is_empty() {
"world".into()
} else {
slug
}
}
/// Storage for the save game called `name`.
pub fn storage(name: &str) -> StorageSettings {
let path = saves_dir().join(format!("{}.sqlite", slug(name)));
StorageSettings {
database_url: sqlite_url(&path),
world: WorldSelector::Name(name.to_owned()),
}
}
/// A world stored in a save file.
#[derive(Clone, Debug)]
pub struct SaveEntry {
pub path: PathBuf,
pub world: WorldInfo,
}
impl SaveEntry {
pub fn storage(&self) -> StorageSettings {
StorageSettings {
database_url: sqlite_url(&self.path),
world: WorldSelector::Id(self.world.id),
}
}
}
/// All worlds in the saves directory, most recently saved first.
pub fn list() -> Vec<SaveEntry> {
let Ok(dir) = std::fs::read_dir(saves_dir()) else {
return Vec::new();
};
let files: Vec<PathBuf> = dir
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|e| e == "sqlite"))
.collect();
let runtime = game_server::persistence::runtime();
let mut saves = Vec::new();
for path in files {
let worlds = runtime.block_on(async {
let db = Database::connect(&sqlite_url(&path)).await?;
let worlds = db.list_worlds().await;
db.close().await;
worlds
});
match worlds {
Ok(worlds) => saves.extend(worlds.into_iter().map(|world| SaveEntry {
path: path.clone(),
world,
})),
Err(e) => bevy::log::warn!("skipping save {}: {e}", path.display()),
}
}
saves.sort_by_key(|s| std::cmp::Reverse(s.world.saved_at));
saves
}
/// Whether a save called `name` exists.
pub fn exists(name: &str) -> bool {
let path = saves_dir().join(format!("{}.sqlite", slug(name)));
path.exists()
&& list()
.iter()
.any(|s| s.path == path && s.world.name == name)
}
/// Deletes a world, and its file once no other world is left in it.
pub fn delete(save: &SaveEntry) -> Result<(), String> {
let runtime = game_server::persistence::runtime();
let remaining = runtime
.block_on(async {
let db = Database::connect(&sqlite_url(&save.path)).await?;
db.delete_world(save.world.id).await?;
let remaining = db.list_worlds().await?.len();
db.close().await;
Ok::<_, game_server::persistence::StorageError>(remaining)
})
.map_err(|e| e.to_string())?;
if remaining == 0 {
remove_database(&save.path).map_err(|e| e.to_string())?;
}
Ok(())
}
fn remove_database(path: &Path) -> std::io::Result<()> {
std::fs::remove_file(path)?;
for suffix in ["-wal", "-shm"] {
let mut extra = path.as_os_str().to_owned();
extra.push(suffix);
let _ = std::fs::remove_file(extra);
}
Ok(())
}
/// Human readable age of a unix timestamp.
pub fn age(timestamp: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let secs = (now - timestamp).max(0);
match secs {
0..60 => "just now".into(),
60..3600 => format!("{} min ago", secs / 60),
3600..86400 => format!("{} h ago", secs / 3600),
_ => format!("{} days ago", secs / 86400),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugs() {
assert_eq!(slug("My World 2"), "my_world_2");
assert_eq!(slug(" "), "world");
assert_eq!(slug("a/../b"), "a____b");
}
}

View File

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

View File

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

View File

@ -0,0 +1,693 @@
//! Chat-specific history and completion on top of Bevy's native text editor.
use std::collections::{BTreeSet, VecDeque};
use bevy::{
clipboard::Clipboard,
input::{
ButtonState,
keyboard::{Key, KeyboardInput},
},
input_focus::InputFocus,
prelude::*,
text::{EditableText, FontCx, LayoutCx, TextEdit},
};
use bevy_replicon::prelude::ClientId;
use game_server::commands::{CommandSource, help};
use super::{TextInput, UiAction, UiFocus, UiForm};
use crate::session::{SessionInfo, SessionRequest, SessionTarget};
const HISTORY_LIMIT: usize = 100;
#[derive(Resource, Default)]
pub struct ChatHistory {
entries: VecDeque<String>,
cursor: Option<usize>,
draft: String,
completion: Option<Completion>,
}
impl ChatHistory {
pub fn remember(&mut self, text: &str) {
if self.entries.back().is_none_or(|last| last != text) {
self.entries.push_back(text.to_owned());
if self.entries.len() > HISTORY_LIMIT {
self.entries.pop_front();
}
}
self.reset_navigation();
}
fn reset_navigation(&mut self) {
self.cursor = None;
self.draft.clear();
self.completion = None;
}
fn navigate(&mut self, current: &str, older: bool) -> Option<String> {
if self.entries.is_empty() {
return None;
}
let next = match (self.cursor, older) {
(None, true) => {
self.draft = current.to_owned();
Some(self.entries.len() - 1)
}
(Some(i), true) => Some(i.saturating_sub(1)),
(Some(i), false) if i + 1 < self.entries.len() => Some(i + 1),
(Some(_), false) => None,
(None, false) => return None,
};
self.cursor = next;
self.completion = None;
Some(next.map_or_else(|| self.draft.clone(), |i| self.entries[i].clone()))
}
fn complete(
&mut self,
value: &str,
caret: usize,
source: CommandSource,
backwards: bool,
) -> Option<(String, usize)> {
if let Some(cycle) = &mut self.completion
&& cycle.last_value == value
&& cycle.last_caret == caret
&& cycle.source == source
{
cycle.index = if backwards {
(cycle.index + cycle.candidates.len() - 1) % cycle.candidates.len()
} else {
(cycle.index + 1) % cycle.candidates.len()
};
return Some(cycle.result());
}
let before = value.get(..caret)?.strip_prefix('/')?;
let (head, prefix) = before
.rsplit_once(' ')
.map_or(("", before), |(h, p)| (h, p));
let word = before.split(' ').count() - 1;
let mut candidates = BTreeSet::new();
for (usage, _) in help(source) {
let parts: Vec<_> = usage.split(' ').collect();
let previous: Vec<_> = head.split(' ').filter(|s| !s.is_empty()).collect();
if previous.iter().enumerate().any(|(i, token)| {
parts
.get(i)
.is_none_or(|part| !part.split('|').any(|p| p == *token))
}) {
continue;
}
if let Some(part) = parts.get(word) {
candidates.extend(
part.split('|')
.filter(|p| {
p.chars().all(|c| c.is_ascii_lowercase()) && p.starts_with(prefix)
})
.map(str::to_owned),
);
}
}
if candidates.is_empty() {
self.completion = None;
return None;
}
let candidates: Vec<_> = candidates.into_iter().collect();
let index = if backwards { candidates.len() - 1 } else { 0 };
let mut cycle = Completion {
start: caret - prefix.len(),
suffix: value[caret..]
.find(char::is_whitespace)
.map_or("", |end| &value[caret + end..])
.to_owned(),
leading: value[..caret - prefix.len()].to_owned(),
source,
candidates,
index,
last_value: String::new(),
last_caret: 0,
};
let result = cycle.result();
self.completion = Some(cycle);
Some(result)
}
}
struct Completion {
start: usize,
leading: String,
suffix: String,
source: CommandSource,
candidates: Vec<String>,
index: usize,
last_value: String,
last_caret: usize,
}
impl Completion {
fn result(&mut self) -> (String, usize) {
let candidate = &self.candidates[self.index];
self.last_value = format!("{}{}{}", self.leading, candidate, self.suffix);
self.last_caret = self.start + candidate.len();
(self.last_value.clone(), self.last_caret)
}
}
#[derive(Component)]
pub struct ChatEditor {
synced: String,
}
impl ChatEditor {
pub fn new(value: String) -> Self {
Self { synced: value }
}
}
pub fn edit(
mut commands: Commands,
mut keys: MessageReader<KeyboardInput>,
modifiers: Res<ButtonInput<Key>>,
mut focus: ResMut<UiFocus>,
mut native_focus: ResMut<InputFocus>,
mut form: ResMut<UiForm>,
mut history: ResMut<ChatHistory>,
mut inputs: Query<(Entity, &TextInput, &mut EditableText, &mut ChatEditor)>,
mut fonts: ResMut<FontCx>,
mut layouts: ResMut<LayoutCx>,
mut clipboard: ResMut<Clipboard>,
session: Option<Res<SessionInfo>>,
request: Option<Res<SessionRequest>>,
) {
let Some((entity, input, mut text, mut editor)) = inputs.iter_mut().next() else {
keys.clear();
return;
};
if form.get(&input.id) != editor.synced {
let value = form.get(&input.id).to_owned();
replace(&mut text, &value, value.len(), &mut fonts, &mut layouts);
editor.synced = value;
}
let was_composing = text.is_composing();
text.apply_pending_edits(&mut fonts, &mut layouts.0, &mut clipboard, |c| {
!c.is_control()
});
let mut value = text.value().to_string();
if focus.input != Some(entity) {
keys.clear();
return;
}
let source = CommandSource::Player {
client: ClientId::Server,
admin: session.is_some_and(|s| s.admin),
owner: request.is_some_and(|r| matches!(r.target, SessionTarget::Local { .. })),
};
let shift = modifiers.pressed(Key::Shift);
let shortcut = modifiers.any_pressed([Key::Control, Key::Alt, Key::Super]);
for key in keys.read().filter(|k| k.state == ButtonState::Pressed) {
focus.consumed = true;
if was_composing || text.is_composing() {
continue;
}
match key.logical_key {
Key::Enter => {
form.0.insert(input.id.clone(), value.clone());
if let Some(action) = &input.submit {
commands.trigger(UiAction(action.clone()));
}
text.clear();
editor.synced = value.clone();
focus.input = None;
native_focus.clear();
break;
}
Key::Escape => {
history.reset_navigation();
focus.input = None;
native_focus.clear();
break;
}
Key::ArrowUp | Key::ArrowDown if !shift && !shortcut => {
if let Some(recalled) = history.navigate(&value, key.logical_key == Key::ArrowUp) {
replace(
&mut text,
&recalled,
recalled.len(),
&mut fonts,
&mut layouts,
);
value = recalled;
}
}
Key::Tab if !shortcut => {
let selection = text.editor.raw_selection();
let caret = selection.focus().index();
if selection.is_collapsed()
&& let Some((completed, caret)) = history.complete(&value, caret, source, shift)
&& completed.chars().count() <= input.max_len
{
replace(&mut text, &completed, caret, &mut fonts, &mut layouts);
value = completed;
}
}
Key::Shift | Key::Control | Key::Alt | Key::Super => {}
_ => history.completion = None,
}
}
if focus.input == Some(entity) && editor.synced != value {
form.0.insert(input.id.clone(), value.clone());
editor.synced = value;
}
}
fn replace(
text: &mut EditableText,
value: &str,
caret: usize,
fonts: &mut FontCx,
layouts: &mut LayoutCx,
) {
text.clear();
text.editor.set_text(value);
text.editor
.driver(fonts, &mut layouts.0)
.move_to_byte(caret);
text.queue_edit(TextEdit::CollapseSelection);
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::{
camera::RenderTarget,
input::{InputPlugin, keyboard::NativeKeyCode},
input_focus::{InputDispatchPlugin, InputFocusPlugin},
picking::{
backend::HitData,
events::{Drag, Pointer, Press, Release},
pointer::{Location, PointerButton, PointerId},
},
text::DEFAULT_FONT_DATA,
ui_widgets::EditableTextInputPlugin,
window::{Ime, PrimaryWindow, WindowRef},
};
fn app() -> (App, Entity, Entity) {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
InputPlugin,
InputFocusPlugin,
InputDispatchPlugin,
EditableTextInputPlugin,
))
.init_resource::<FontCx>()
.init_resource::<LayoutCx>()
.init_resource::<Clipboard>()
.init_resource::<UiScale>()
.init_resource::<UiFocus>()
.init_resource::<UiForm>()
.init_resource::<ChatHistory>()
.add_message::<Ime>()
.add_message::<Pointer<Release>>()
.add_systems(Update, edit);
let mut fonts = app.world_mut().resource_mut::<FontCx>();
fonts
.collection
.register_fonts(DEFAULT_FONT_DATA.to_vec().into(), None);
fonts.set_sans_serif_family("Fira Mono").unwrap();
let window = app
.world_mut()
.spawn((Window::default(), PrimaryWindow))
.id();
let input = app
.world_mut()
.spawn((
TextInput {
id: "chat".into(),
placeholder: String::new(),
password: false,
max_len: 200,
submit: Some("chat_send".into()),
},
EditableText {
allow_newlines: false,
max_characters: Some(200),
..default()
},
ChatEditor::new(String::new()),
))
.id();
app.world_mut().resource_mut::<UiFocus>().input = Some(input);
app.world_mut()
.resource_mut::<InputFocus>()
.set(input, bevy::input_focus::FocusCause::Navigated);
app.finish();
app.cleanup();
app.update();
(app, window, input)
}
fn key(app: &mut App, window: Entity, logical_key: Key, text: Option<&str>) {
for state in [ButtonState::Pressed, ButtonState::Released] {
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::Unidentified(NativeKeyCode::Unidentified),
logical_key: logical_key.clone(),
state,
text: text.map(Into::into),
repeat: false,
window,
});
app.update();
}
}
fn value(app: &App, input: Entity) -> String {
app.world()
.get::<EditableText>(input)
.unwrap()
.value()
.to_string()
}
#[test]
fn native_chat_keyboard_navigation_selection_history_and_submission() {
let (mut app, window, input) = app();
key(
&mut app,
window,
Key::Character("hello world".into()),
Some("hello world"),
);
assert_eq!(value(&app, input), "hello world");
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.press(Key::Control);
key(&mut app, window, Key::ArrowLeft, None);
assert_eq!(
app.world()
.get::<EditableText>(input)
.unwrap()
.editor
.raw_selection()
.focus()
.index(),
6
);
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.press(Key::Shift);
key(&mut app, window, Key::ArrowRight, None);
assert_eq!(
app.world()
.get::<EditableText>(input)
.unwrap()
.editor
.raw_selection()
.text_range(),
6..11
);
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.release(Key::Control);
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.release(Key::Shift);
key(
&mut app,
window,
Key::Character("café".into()),
Some("café"),
);
assert_eq!(value(&app, input), "hello café");
key(&mut app, window, Key::ArrowLeft, None);
key(&mut app, window, Key::Delete, None);
assert_eq!(value(&app, input), "hello caf");
app.world_mut()
.resource_mut::<ChatHistory>()
.remember("sent");
key(&mut app, window, Key::ArrowUp, None);
assert_eq!(value(&app, input), "sent");
key(&mut app, window, Key::ArrowDown, None);
assert_eq!(value(&app, input), "hello caf");
key(&mut app, window, Key::Enter, None);
assert_eq!(app.world().resource::<UiForm>().get("chat"), "hello caf");
assert!(value(&app, input).is_empty());
assert!(app.world().resource::<UiFocus>().input.is_none());
assert!(app.world().resource::<InputFocus>().get() != Some(input));
}
#[test]
fn native_chat_completion_and_external_drafts() {
let (mut app, window, input) = app();
app.insert_resource(SessionInfo {
admin: true,
..default()
});
app.world_mut()
.resource_mut::<UiForm>()
.0
.insert("chat".into(), "/exp".into());
app.update();
key(&mut app, window, Key::Tab, None);
assert_eq!(value(&app, input), "/export");
assert_eq!(app.world().resource::<UiForm>().get("chat"), "/export");
key(&mut app, window, Key::Escape, None);
assert!(app.world().resource::<UiFocus>().input.is_none());
}
#[test]
fn native_chat_enter_confirms_ime_before_submitting() {
let (mut app, window, input) = app();
app.world_mut()
.get_mut::<EditableText>(input)
.unwrap()
.queue_edit(TextEdit::ImeSetCompose {
value: "é".into(),
cursor: None,
});
app.update();
assert!(
app.world()
.get::<EditableText>(input)
.unwrap()
.is_composing()
);
app.world_mut().write_message(Ime::Commit {
window,
value: "é".into(),
});
key(&mut app, window, Key::Enter, None);
assert_eq!(value(&app, input), "é");
assert_eq!(app.world().resource::<UiFocus>().input, Some(input));
key(&mut app, window, Key::Enter, None);
assert!(value(&app, input).is_empty());
assert_eq!(app.world().resource::<UiForm>().get("chat"), "é");
}
#[test]
fn native_chat_mouse_selection_and_length_limits() {
let (mut app, window, input) = app();
key(
&mut app,
window,
Key::Character("hello world".into()),
Some("hello world"),
);
let target = RenderTarget::Window(WindowRef::Entity(window))
.normalize(Some(window))
.unwrap();
app.world_mut().trigger(Pointer::new(
PointerId::Mouse,
Location {
target: target.clone(),
position: Vec2::ZERO,
},
Press {
button: PointerButton::Primary,
hit: HitData::new(window, 0.0, None, None),
count: 1,
},
input,
));
assert!(
!app.world()
.get::<EditableText>(input)
.unwrap()
.pending_edits
.is_empty()
);
app.update();
assert_eq!(
app.world()
.get::<EditableText>(input)
.unwrap()
.editor
.raw_selection()
.focus()
.index(),
0
);
app.world_mut().trigger(Pointer::new(
PointerId::Mouse,
Location {
target,
position: Vec2::splat(10_000.0),
},
Drag {
button: PointerButton::Primary,
distance: Vec2::splat(10_000.0),
delta: Vec2::splat(10_000.0),
},
input,
));
app.update();
assert_eq!(
app.world()
.get::<EditableText>(input)
.unwrap()
.editor
.raw_selection()
.text_range(),
0..11
);
key(
&mut app,
window,
Key::Character("replacement".into()),
Some("replacement"),
);
assert_eq!(value(&app, input), "replacement");
let too_long = "x".repeat(201);
key(
&mut app,
window,
Key::Character(too_long.clone().into()),
Some(&too_long),
);
assert_eq!(value(&app, input), "replacement");
}
#[test]
fn native_chat_system_clipboard() {
if std::env::var("STATION_TEST_CLIPBOARD").as_deref() != Ok("1") {
eprintln!("STATION_TEST_CLIPBOARD not set; skipping system clipboard test");
return;
}
let (mut app, window, input) = app();
app.world_mut()
.resource_mut::<Clipboard>()
.set_text("paste me")
.unwrap();
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.press(Key::Control);
key(&mut app, window, Key::Character("v".into()), None);
assert_eq!(value(&app, input), "paste me");
key(&mut app, window, Key::Character("a".into()), None);
key(&mut app, window, Key::Character("x".into()), None);
assert!(value(&app, input).is_empty());
assert_eq!(
app.world_mut()
.resource_mut::<Clipboard>()
.fetch_text()
.poll_result()
.unwrap()
.unwrap(),
"paste me"
);
key(&mut app, window, Key::Character("v".into()), None);
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.release(Key::Control);
key(&mut app, window, Key::Character("!".into()), Some("!"));
app.world_mut()
.resource_mut::<ButtonInput<Key>>()
.press(Key::Control);
key(&mut app, window, Key::Character("a".into()), None);
key(&mut app, window, Key::Character("c".into()), None);
assert_eq!(
app.world_mut()
.resource_mut::<Clipboard>()
.fetch_text()
.poll_result()
.unwrap()
.unwrap(),
"paste me!"
);
}
#[test]
fn history_preserves_draft_and_is_bounded() {
let mut history = ChatHistory::default();
history.remember("first");
history.remember("second");
history.remember("second");
assert_eq!(history.entries.len(), 2);
assert_eq!(history.navigate("draft", true).as_deref(), Some("second"));
assert_eq!(history.navigate("edited", true).as_deref(), Some("first"));
assert_eq!(history.navigate("", true).as_deref(), Some("first"));
assert_eq!(history.navigate("", false).as_deref(), Some("second"));
assert_eq!(history.navigate("", false).as_deref(), Some("draft"));
assert_eq!(history.navigate("", false), None);
for i in 0..HISTORY_LIMIT + 1 {
history.remember(&i.to_string());
}
assert_eq!(history.entries.len(), HISTORY_LIMIT);
assert_eq!(history.entries.front().map(String::as_str), Some("1"));
}
fn player(admin: bool) -> CommandSource {
CommandSource::Player {
client: ClientId::Server,
admin,
owner: false,
}
}
#[test]
fn completion_uses_permissions_and_supports_subcommands_and_suffixes() {
let mut history = ChatHistory::default();
assert_eq!(history.complete("/exp", 4, player(false), false), None);
assert_eq!(
history.complete("/exp", 4, player(true), false),
Some(("/export".into(), 7))
);
assert_eq!(
history.complete("/imp file.tmx", 4, player(true), false),
Some(("/import file.tmx".into(), 7))
);
assert_eq!(
history.complete("/export file.tmx", 4, player(true), false),
Some(("/export file.tmx".into(), 7))
);
assert_eq!(
history.complete("/export file.tmx", 7, player(false), false),
None
);
assert_eq!(
history.complete("/whitelist a", 12, player(true), false),
Some(("/whitelist add".into(), 14))
);
assert_eq!(history.complete("hello", 5, player(true), false), None);
assert_eq!(history.complete("/export ", 8, player(true), false), None);
}
#[test]
fn completion_cycles_in_both_directions() {
let mut history = ChatHistory::default();
assert_eq!(
history.complete("/s", 2, CommandSource::Console, false),
Some(("/save".into(), 5))
);
assert_eq!(
history.complete("/save", 5, CommandSource::Console, false),
Some(("/say".into(), 4))
);
assert_eq!(
history.complete("/say", 4, CommandSource::Console, true),
Some(("/save".into(), 5))
);
}
}

View File

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

View File

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

View File

@ -5,7 +5,10 @@ use std::collections::BTreeMap;
use bevy::prelude::*; use bevy::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{data::GameData, grid::Dir}; use crate::{
data::{BuildingDef, BuildingLayer, GameData, Ingredient, MachineDef, MachineRecipeDef},
grid::Dir,
};
pub const INVENTORY_SLOTS: usize = 20; pub const INVENTORY_SLOTS: usize = 20;
@ -32,12 +35,28 @@ impl GridPos {
} }
} }
/// Free position of a character in tiles, in Tiled's coordinate system (`y` down).
/// Tile `(x, y)` covers `x..x + 1` and `y..y + 1`. The server keeps the character's
/// [`GridPos`] at the tile containing this point.
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Position(pub Vec2);
impl Position {
pub fn center_of(tile: GridPos) -> Self {
Self(Vec2::new(tile.x as f32 + 0.5, tile.y as f32 + 0.5))
}
pub fn tile(self) -> GridPos {
GridPos::new(self.0.x.floor() as i32, self.0.y.floor() as i32)
}
}
#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Facing(pub Dir); pub struct Facing(pub Dir);
/// A player character. Names are unique and identify the persistent character. /// A player character. Names are unique and identify the persistent character.
#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[require(GridPos, Facing, Vitals, Inventory, Skills)] #[require(GridPos, Position, Facing, Vitals, Inventory, Skills)]
pub struct Character { pub struct Character {
pub name: String, pub name: String,
} }
@ -70,7 +89,7 @@ impl Vitals {
} }
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ItemStack { pub struct ItemStack {
pub item: String, pub item: String,
pub count: u32, pub count: u32,
@ -211,16 +230,251 @@ pub struct Building {
/// Mutable per-instance building state, editable from scripts. /// Mutable per-instance building state, editable from scripts.
#[derive(Component, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Component, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingState { pub struct BuildingState {
#[serde(default)]
pub layer: BuildingLayer,
#[serde(default)]
pub flip_h: bool,
#[serde(default)]
pub flip_v: bool,
#[serde(default)]
pub flip_d: bool,
pub solid: bool, pub solid: bool,
pub variant: Option<String>, pub variant: Option<String>,
pub health: i32, pub health: i32,
pub vars: BTreeMap<String, i64>, pub vars: BTreeMap<String, i64>,
} }
/// The Tiled map the world is built on. Path is relative to the assets directory. impl BuildingState {
pub fn new(def: &BuildingDef) -> Self {
Self {
layer: def.layer,
solid: def.solid,
variant: None,
health: def.max_health,
vars: def.vars.clone(),
..Default::default()
}
}
}
/// Which slot of a machine an action refers to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MachineSlot {
Input(usize),
Output(usize),
}
/// Item slots and processing state of a machine building (see [`crate::data::MachineDef`]).
#[derive(Component, Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Machine {
pub inputs: Vec<Option<ItemStack>>,
pub outputs: Vec<Option<ItemStack>>,
/// Recipe currently being processed.
pub recipe: Option<String>,
/// Seconds spent on the current recipe.
pub progress: f32,
}
impl Machine {
pub fn new(def: &MachineDef) -> Self {
Self {
inputs: vec![None; def.input_slots],
outputs: vec![None; def.output_slots],
recipe: None,
progress: 0.0,
}
}
/// Adapts the slot counts to a (possibly changed) definition and returns
/// the stacks that no longer have a slot.
pub fn fit_to(&mut self, def: &MachineDef) -> Vec<ItemStack> {
let mut removed = Vec::new();
for (slots, len) in [
(&mut self.inputs, def.input_slots),
(&mut self.outputs, def.output_slots),
] {
if slots.len() > len {
removed.extend(slots.drain(len..).flatten());
}
slots.resize(len, None);
}
if self
.recipe
.as_ref()
.is_some_and(|r| def.recipe(r).is_none())
{
self.recipe = None;
self.progress = 0.0;
}
removed
}
pub fn slot(&self, slot: MachineSlot) -> Option<&ItemStack> {
match slot {
MachineSlot::Input(i) => self.inputs.get(i)?.as_ref(),
MachineSlot::Output(i) => self.outputs.get(i)?.as_ref(),
}
}
/// All stacks in the machine, e.g. to drop them when it is taken apart.
pub fn contents(&self) -> impl Iterator<Item = &ItemStack> {
self.inputs.iter().chain(&self.outputs).flatten()
}
pub fn input_count(&self, item: &str) -> u32 {
self.inputs
.iter()
.flatten()
.filter(|s| s.item == item)
.map(|s| s.count)
.sum()
}
/// Puts up to `count` of `item` into an input slot (`None`: any fitting slot)
/// and returns how many were inserted.
pub fn insert(&mut self, slot: Option<usize>, item: &str, count: u32, data: &GameData) -> u32 {
let max = data.max_stack(item);
let mut left = count;
let fill = |s: &mut Option<ItemStack>, left: &mut u32| match s {
Some(stack) if stack.item == item => {
let moved = (*left).min(max.saturating_sub(stack.count));
stack.count += moved;
*left -= moved;
}
Some(_) => {}
None if *left > 0 => {
let moved = (*left).min(max);
*s = Some(ItemStack::new(item, moved));
*left -= moved;
}
None => {}
};
match slot {
Some(i) => {
if let Some(s) = self.inputs.get_mut(i) {
fill(s, &mut left);
}
}
None => {
// Top up existing stacks first, then use empty slots.
for s in self.inputs.iter_mut().filter(|s| s.is_some()) {
fill(s, &mut left);
}
for s in self.inputs.iter_mut().filter(|s| s.is_none()) {
fill(s, &mut left);
}
}
}
count - left
}
/// Removes up to `count` items from a slot.
pub fn take(&mut self, slot: MachineSlot, count: u32) -> Option<ItemStack> {
let entry = match slot {
MachineSlot::Input(i) => self.inputs.get_mut(i)?,
MachineSlot::Output(i) => self.outputs.get_mut(i)?,
};
let stack = entry.as_mut()?;
let taken = count.min(stack.count);
stack.count -= taken;
let item = stack.item.clone();
if stack.count == 0 {
*entry = None;
}
(taken > 0).then(|| ItemStack::new(item, taken))
}
/// Whether all inputs of the recipe are present and its outputs fit.
pub fn can_process(&self, recipe: &MachineRecipeDef, data: &GameData) -> bool {
recipe
.inputs
.iter()
.all(|i| self.input_count(&i.item) >= i.count)
&& self.clone().add_outputs(&recipe.outputs, data)
}
/// Adds outputs if all of them fit; changes nothing otherwise.
fn add_outputs(&mut self, outputs: &[Ingredient], data: &GameData) -> bool {
let mut slots = self.outputs.clone();
for output in outputs {
let max = data.max_stack(&output.item);
let mut left = output.count;
for s in slots.iter_mut() {
if let Some(stack) = s
&& stack.item == output.item
{
let moved = left.min(max.saturating_sub(stack.count));
stack.count += moved;
left -= moved;
}
}
for s in slots.iter_mut() {
if left > 0 && s.is_none() {
let moved = left.min(max);
*s = Some(ItemStack::new(&output.item, moved));
left -= moved;
}
}
if left > 0 {
return false;
}
}
self.outputs = slots;
true
}
/// Consumes the recipe inputs and produces its outputs. Returns `false`
/// (and changes nothing) if that is not possible.
pub fn complete(&mut self, recipe: &MachineRecipeDef, data: &GameData) -> bool {
if !self.can_process(recipe, data) {
return false;
}
for input in &recipe.inputs {
let mut left = input.count;
for s in self.inputs.iter_mut().rev() {
if let Some(stack) = s
&& stack.item == input.item
{
let taken = left.min(stack.count);
stack.count -= taken;
left -= taken;
if stack.count == 0 {
*s = None;
}
}
}
}
self.add_outputs(&recipe.outputs, data)
}
}
/// Authoritative terrain; clients only need local assets for its tileset images.
#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorldMap { pub struct WorldMap {
pub path: String, pub path: String,
pub layout: crate::map::MapLayout,
}
/// The level (map) an entity is on: the path of its Tiled map relative to the
/// main map's directory, with `/` separators.
///
/// Immutable because it drives replication visibility; re-insert it to move an entity.
#[derive(Component, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[component(immutable)]
pub struct Level(pub String);
/// The level a client receives entities of. Lives on the replicon client entity.
#[derive(Component, Clone, Debug, PartialEq, Eq)]
#[component(immutable)]
pub struct ViewLevel(pub String);
impl bevy_replicon::prelude::VisibilityFilter for Level {
type ClientComponent = ViewLevel;
type Scope = Entity;
fn is_visible(&self, _client: Entity, view: Option<&ViewLevel>) -> bool {
view.is_some_and(|view| view.0 == self.0)
}
} }
#[cfg(test)] #[cfg(test)]
@ -255,6 +509,45 @@ mod tests {
assert_eq!(inv.space_for("ore", &data), 1); assert_eq!(inv.space_for("ore", &data), 1);
} }
#[test]
fn machine_slots_and_processing() {
let data = GameData::from_source(
r#"
items: [
{ id: "ore", name: "Ore", sprite: "", max_stack: 5 },
{ id: "plate", name: "Plate", sprite: "", max_stack: 2 },
]
buildings: [{ id: "m", name: "M", sprite: "" }]
machines: [{ id: "m", input_slots: 2, recipes: [
{ id: "r", inputs: [("ore", 3)], outputs: [("plate", 1)] },
] }]
"#,
)
.unwrap();
let def = &data.machines["m"];
let recipe = def.recipe("r").unwrap();
let mut machine = Machine::new(def);
assert_eq!(machine.insert(None, "ore", 7, &data), 7);
assert_eq!(machine.insert(Some(1), "ore", 9, &data), 3);
assert_eq!(machine.input_count("ore"), 10);
assert!(machine.can_process(recipe, &data));
assert!(machine.complete(recipe, &data));
assert!(machine.complete(recipe, &data));
assert_eq!(machine.input_count("ore"), 4);
// The output slot holds at most two plates.
assert!(!machine.can_process(recipe, &data));
assert!(!machine.complete(recipe, &data));
assert_eq!(
machine.take(MachineSlot::Output(0), 1),
Some(ItemStack::new("plate", 1))
);
assert!(machine.complete(recipe, &data));
assert_eq!(machine.input_count("ore"), 1);
assert!(!machine.can_process(recipe, &data));
assert_eq!(machine.take(MachineSlot::Input(5), 1), None);
assert_eq!(machine.contents().map(|s| s.count).sum::<u32>(), 3);
}
#[test] #[test]
fn skill_levels() { fn skill_levels() {
let mut skills = Skills::default(); let mut skills = Skills::default();

View File

@ -119,6 +119,14 @@ pub struct Construction {
pub xp: u32, pub xp: u32,
} }
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BuildingLayer {
Floor,
#[default]
Structure,
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BuildingDef { pub struct BuildingDef {
pub id: String, pub id: String,
@ -126,6 +134,8 @@ pub struct BuildingDef {
#[serde(default)] #[serde(default)]
pub description: String, pub description: String,
pub sprite: String, pub sprite: String,
#[serde(default)]
pub layer: BuildingLayer,
/// Alternative sprites selectable from scripts with `ctx.set_variant(name)`. /// Alternative sprites selectable from scripts with `ctx.set_variant(name)`.
#[serde(default)] #[serde(default)]
pub variants: HashMap<String, String>, pub variants: HashMap<String, String>,
@ -171,6 +181,159 @@ pub struct SkillDef {
pub name: String, pub name: String,
} }
/// A recipe processed by a machine over time.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MachineRecipeDef {
pub id: String,
#[serde(default)]
pub name: Option<String>,
pub inputs: Vec<Ingredient>,
pub outputs: Vec<Ingredient>,
/// Processing time in seconds.
#[serde(default = "default_machine_time")]
pub time: f32,
}
fn default_machine_time() -> f32 {
1.0
}
impl MachineRecipeDef {
pub fn display_name<'a>(&'a self, data: &'a GameData) -> &'a str {
if let Some(name) = &self.name {
return name;
}
self.outputs
.first()
.and_then(|o| data.items.get(&o.item))
.map(|i| i.name.as_str())
.unwrap_or(&self.id)
}
}
/// Position (in pixels, relative to the window's top-left corner) of a slot.
pub type UiPoint = (f32, f32);
/// Layout of the machine window. Everything is optional: by default inputs are
/// stacked on the left, outputs on the right and the progress bar in between.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MachineUiDef {
/// Background image (relative to the assets directory), drawn at `size`.
#[serde(default)]
pub background: Option<String>,
/// Window content size in pixels. Defaults to a size fitting the default layout.
#[serde(default)]
pub size: Option<(f32, f32)>,
/// Slot size in pixels.
#[serde(default = "default_slot_size")]
pub slot_size: f32,
/// One position per input slot. Empty uses the default layout.
#[serde(default)]
pub inputs: Vec<UiPoint>,
/// One position per output slot. Empty uses the default layout.
#[serde(default)]
pub outputs: Vec<UiPoint>,
/// Progress bar as `(x, y, width, height)`.
#[serde(default)]
pub progress: Option<(f32, f32, f32, f32)>,
}
fn default_slot_size() -> f32 {
40.0
}
impl Default for MachineUiDef {
fn default() -> Self {
Self {
background: None,
size: None,
slot_size: default_slot_size(),
inputs: Vec::new(),
outputs: Vec::new(),
progress: None,
}
}
}
/// Gap between slots and window border in the default layout.
pub const MACHINE_UI_GAP: f32 = 8.0;
const MACHINE_UI_ARROW: f32 = 64.0;
/// Fully resolved positions of all machine window elements.
#[derive(Clone, Debug, PartialEq)]
pub struct MachineLayout {
pub size: (f32, f32),
pub slot_size: f32,
pub inputs: Vec<UiPoint>,
pub outputs: Vec<UiPoint>,
pub progress: (f32, f32, f32, f32),
}
/// A building that processes items: players put items into input slots and the
/// machine turns them into outputs according to its own recipes.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MachineDef {
/// Id of the building this machine logic belongs to.
pub id: String,
#[serde(default = "default_one")]
pub input_slots: usize,
#[serde(default = "default_one")]
pub output_slots: usize,
pub recipes: Vec<MachineRecipeDef>,
/// Building variant shown while the machine is working.
#[serde(default)]
pub active_variant: Option<String>,
#[serde(default)]
pub ui: MachineUiDef,
}
fn default_one() -> usize {
1
}
impl MachineDef {
pub fn recipe(&self, id: &str) -> Option<&MachineRecipeDef> {
self.recipes.iter().find(|r| r.id == id)
}
/// Resolves the window layout, filling in defaults.
pub fn layout(&self) -> MachineLayout {
let ui = &self.ui;
let s = ui.slot_size;
let gap = MACHINE_UI_GAP;
let column = |count: usize, x: f32| -> Vec<UiPoint> {
(0..count)
.map(|i| (x, gap + i as f32 * (s + gap)))
.collect()
};
let rows = self.input_slots.max(self.output_slots).max(1) as f32;
let default_height = gap + rows * (s + gap);
let default_width = gap + s + gap + MACHINE_UI_ARROW + gap + s + gap;
let size = ui.size.unwrap_or((default_width, default_height));
let inputs = if ui.inputs.is_empty() {
column(self.input_slots, gap)
} else {
ui.inputs.clone()
};
let outputs = if ui.outputs.is_empty() {
column(self.output_slots, size.0 - gap - s)
} else {
ui.outputs.clone()
};
let progress = ui.progress.unwrap_or_else(|| {
let x = gap + s + gap;
(x, size.1 / 2.0 - 6.0, size.0 - 2.0 * x, 12.0)
});
MachineLayout {
size,
slot_size: s,
inputs,
outputs,
progress,
}
}
}
/// Root of a data file. All sections are optional. /// Root of a data file. All sections are optional.
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
pub struct DataFile { pub struct DataFile {
@ -181,6 +344,8 @@ pub struct DataFile {
#[serde(default)] #[serde(default)]
pub buildings: Vec<BuildingDef>, pub buildings: Vec<BuildingDef>,
#[serde(default)] #[serde(default)]
pub machines: Vec<MachineDef>,
#[serde(default)]
pub skills: Vec<SkillDef>, pub skills: Vec<SkillDef>,
} }
@ -203,6 +368,8 @@ pub struct GameData {
pub items: BTreeMap<String, ItemDef>, pub items: BTreeMap<String, ItemDef>,
pub recipes: BTreeMap<String, RecipeDef>, pub recipes: BTreeMap<String, RecipeDef>,
pub buildings: BTreeMap<String, BuildingDef>, pub buildings: BTreeMap<String, BuildingDef>,
/// Machines, keyed by the id of the building they belong to.
pub machines: BTreeMap<String, MachineDef>,
pub skills: BTreeMap<String, SkillDef>, pub skills: BTreeMap<String, SkillDef>,
} }
@ -296,6 +463,16 @@ impl GameData {
errors, errors,
); );
} }
for machine in file.machines {
insert(
&mut self.machines,
"machine",
machine.id.clone(),
machine,
path,
errors,
);
}
for skill in file.skills { for skill in file.skills {
insert( insert(
&mut self.skills, &mut self.skills,
@ -366,6 +543,70 @@ impl GameData {
check_skill(&mut errors, &ctx, &construction.skill); check_skill(&mut errors, &ctx, &construction.skill);
} }
} }
for machine in self.machines.values() {
let ctx = format!("machine `{}`", machine.id);
let Some(building) = self.buildings.get(&machine.id) else {
errors.push(format!("{ctx}: no building with this id"));
continue;
};
if let Some(variant) = &machine.active_variant
&& !building.variants.contains_key(variant)
{
errors.push(format!(
"{ctx}: building has no variant `{variant}` (active_variant)"
));
}
if machine.input_slots == 0 || machine.output_slots == 0 {
errors.push(format!("{ctx}: needs at least one input and output slot"));
}
let ui = &machine.ui;
for (kind, positions, slots) in [
("inputs", &ui.inputs, machine.input_slots),
("outputs", &ui.outputs, machine.output_slots),
] {
if !positions.is_empty() && positions.len() != slots {
errors.push(format!(
"{ctx}: ui.{kind} has {} positions but there are {slots} slots",
positions.len()
));
}
}
if ui.slot_size <= 0.0 {
errors.push(format!("{ctx}: ui.slot_size must be positive"));
}
let mut ids = Vec::new();
for recipe in &machine.recipes {
let ctx = format!("{ctx}, recipe `{}`", recipe.id);
if ids.contains(&&recipe.id) {
errors.push(format!("{ctx}: defined twice"));
}
ids.push(&recipe.id);
if recipe.inputs.is_empty() || recipe.outputs.is_empty() {
errors.push(format!("{ctx}: needs inputs and outputs"));
}
if recipe.time <= 0.0 {
errors.push(format!("{ctx}: time must be positive"));
}
for (kind, list, slots) in [
("inputs", &recipe.inputs, machine.input_slots),
("outputs", &recipe.outputs, machine.output_slots),
] {
if list.len() > slots {
errors.push(format!(
"{ctx}: {} different {kind} but only {slots} slots",
list.len()
));
}
for ing in list {
check_item(&mut errors, &ctx, &ing.item);
if ing.count == 0 {
errors
.push(format!("{ctx}: count of `{}` must be at least 1", ing.item));
}
}
}
}
}
errors errors
} }
@ -377,6 +618,11 @@ impl GameData {
.chain(self.buildings.values().flat_map(|b| { .chain(self.buildings.values().flat_map(|b| {
std::iter::once(b.sprite.as_str()).chain(b.variants.values().map(String::as_str)) std::iter::once(b.sprite.as_str()).chain(b.variants.values().map(String::as_str))
})) }))
.chain(
self.machines
.values()
.filter_map(|m| m.ui.background.as_deref()),
)
} }
pub fn max_stack(&self, item: &str) -> u32 { pub fn max_stack(&self, item: &str) -> u32 {
@ -491,6 +737,51 @@ mod tests {
assert!(err.to_string().contains("line 1"), "{err}"); assert!(err.to_string().contains("line 1"), "{err}");
} }
const MACHINE: &str = r#"
items: [
{ id: "ore", name: "Ore", sprite: "ore.png" },
{ id: "plate", name: "Plate", sprite: "plate.png" },
]
buildings: [
{ id: "press", name: "Press", sprite: "press.png", variants: { "on": "press_on.png" } },
]
machines: [
{
id: "press",
input_slots: 2,
active_variant: Some("on"),
recipes: [{ id: "press", inputs: [("ore", 3)], outputs: [("plate", 1)], time: 2.5 }],
ui: { background: Some("ui/press.png"), outputs: [(100.0, 20.0)] },
},
]
"#;
#[test]
fn parses_machines_with_default_layout() {
let data = GameData::from_source(MACHINE).unwrap();
let press = &data.machines["press"];
assert_eq!((press.input_slots, press.output_slots), (2, 1));
assert_eq!(press.recipe("press").unwrap().time, 2.5);
let layout = press.layout();
// Inputs default to a column on the left, the custom output position is kept.
assert_eq!(layout.inputs, vec![(8.0, 8.0), (8.0, 56.0)]);
assert_eq!(layout.outputs, vec![(100.0, 20.0)]);
assert_eq!(layout.size.1, 8.0 + 2.0 * 48.0);
assert!(data.sprite_paths().any(|p| p == "ui/press.png"));
}
#[test]
fn rejects_invalid_machines() {
let bad = MACHINE
.replace("Some(\"on\")", "Some(\"off\")")
.replace("[(100.0, 20.0)]", "[(1.0, 1.0), (2.0, 2.0)]")
.replace("(\"ore\", 3)", "(\"ore\", 3), (\"x\", 1), (\"plate\", 1)");
let DataError::Invalid(errors) = GameData::from_source(&bad).unwrap_err() else {
panic!()
};
assert_eq!(errors.len(), 4, "{errors:#?}");
}
#[test] #[test]
fn shipped_data_is_valid() { fn shipped_data_is_valid() {
let dir = crate::assets_dir().join("data"); let dir = crate::assets_dir().join("data");

View File

@ -28,6 +28,11 @@ impl Dir {
} }
} }
/// Converts a [`Position`](crate::components::Position) in tiles to Bevy world space (y up).
pub fn position_to_world(pos: Vec2) -> Vec2 {
Vec2::new(pos.x * TILE_SIZE, -pos.y * TILE_SIZE)
}
/// Converts a grid position to the center of the tile in Bevy world space (y up). /// Converts a grid position to the center of the tile in Bevy world space (y up).
pub fn grid_to_world(pos: GridPos) -> Vec2 { pub fn grid_to_world(pos: GridPos) -> Vec2 {
Vec2::new( Vec2::new(

View File

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

View File

@ -7,18 +7,21 @@
//! - `spawn`: player spawn point. //! - `spawn`: player spawn point.
//! - `building`: building with the custom property `proto` (a building id). //! - `building`: building with the custom property `proto` (a building id).
//! - `item`: ground item with the property `proto` (an item id) and optional `count`. //! - `item`: ground item with the property `proto` (an item id) and optional `count`.
//! - `teleport`: trigger leading to the object named by `target`, on the map named by
//! the optional file property `map` (relative to this map).
//! //!
//! Any other custom properties are passed through in [`MapObject::properties`] //! State overrides (`state.*`, `machine.*`) and legacy integer script variables
//! (buildings copy integer properties into their script variables). //! are decoded by the server for both new worlds and imports.
use std::{ use std::{
collections::BTreeMap, collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use crate::components::GridPos; use crate::components::GridPos;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TilesetInfo { pub struct TilesetInfo {
/// Image path relative to the assets directory. /// Image path relative to the assets directory.
pub image: String, pub image: String,
@ -30,24 +33,29 @@ pub struct TilesetInfo {
pub tile_count: u32, pub tile_count: u32,
pub spacing: u32, pub spacing: u32,
pub margin: u32, pub margin: u32,
pub solid_tiles: BTreeSet<u32>,
#[serde(default)]
pub gameplay_tiles: BTreeMap<u32, BTreeMap<String, String>>,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TileCell { pub struct TileCell {
pub tileset: usize, pub tileset: usize,
pub id: u32, pub id: u32,
pub flip_h: bool, pub flip_h: bool,
pub flip_v: bool, pub flip_v: bool,
pub flip_d: bool,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TileLayerData { pub struct TileLayerData {
pub name: String, pub name: String,
pub solid: bool,
/// Row-major cells, `width * height` entries. /// Row-major cells, `width * height` entries.
pub cells: Vec<Option<TileCell>>, pub cells: Vec<Option<TileCell>>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MapObject { pub struct MapObject {
pub id: u32, pub id: u32,
pub class: String, pub class: String,
@ -56,7 +64,7 @@ pub struct MapObject {
pub properties: BTreeMap<String, String>, pub properties: BTreeMap<String, String>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MapLayout { pub struct MapLayout {
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
@ -65,6 +73,8 @@ pub struct MapLayout {
pub tilesets: Vec<TilesetInfo>, pub tilesets: Vec<TilesetInfo>,
pub layers: Vec<TileLayerData>, pub layers: Vec<TileLayerData>,
pub objects: Vec<MapObject>, pub objects: Vec<MapObject>,
#[serde(default)]
pub default_tile: Option<TileCell>,
solid: Vec<bool>, solid: Vec<bool>,
} }
@ -77,8 +87,32 @@ pub enum MapError {
} }
impl MapLayout { impl MapLayout {
/// Loads `map_path` (relative to `assets_dir`). /// Loads an asset-relative or absolute map path.
pub fn load(assets_dir: &Path, map_path: &str) -> Result<Self, MapError> { pub fn load(assets_dir: &Path, map_path: &str) -> Result<Self, MapError> {
Self::load_inner(assets_dir, map_path, true, None)
}
/// Like [`Self::load`], but tileset images inside `mirror` stand for the asset at the same
/// relative path, so exported projects carrying image copies stay portable.
pub fn load_with_image_mirror(
assets_dir: &Path,
map_path: &str,
mirror: &Path,
) -> Result<Self, MapError> {
Self::load_inner(assets_dir, map_path, true, Some(mirror))
}
/// Legacy saves have entity state but no terrain snapshot; do not reseed their entities.
pub fn load_legacy_terrain(assets_dir: &Path, map_path: &str) -> Result<Self, MapError> {
Self::load_inner(assets_dir, map_path, false, None)
}
fn load_inner(
assets_dir: &Path,
map_path: &str,
convert_painted: bool,
mirror: Option<&Path>,
) -> Result<Self, MapError> {
let full = assets_dir.join(map_path); let full = assets_dir.join(map_path);
let unsupported = |msg: String| MapError::Unsupported(full.clone(), msg); let unsupported = |msg: String| MapError::Unsupported(full.clone(), msg);
let map = tiled::Loader::new() let map = tiled::Loader::new()
@ -87,10 +121,14 @@ impl MapLayout {
if map.orientation != tiled::Orientation::Orthogonal { if map.orientation != tiled::Orientation::Orthogonal {
return Err(unsupported("only orthogonal maps are supported".into())); return Err(unsupported("only orthogonal maps are supported".into()));
} }
if map.infinite() {
return Err(unsupported("infinite maps are not supported".into()));
}
let assets_canon = assets_dir let assets_canon = assets_dir
.canonicalize() .canonicalize()
.unwrap_or_else(|_| assets_dir.into()); .unwrap_or_else(|_| assets_dir.into());
let mirror_canon = mirror.and_then(|m| m.canonicalize().ok());
let mut tilesets = Vec::new(); let mut tilesets = Vec::new();
for ts in map.tilesets() { for ts in map.tilesets() {
let image = ts.image.as_ref().ok_or_else(|| { let image = ts.image.as_ref().ok_or_else(|| {
@ -100,12 +138,21 @@ impl MapLayout {
)) ))
})?; })?;
let image_path = image.source.canonicalize().unwrap_or(image.source.clone()); let image_path = image.source.canonicalize().unwrap_or(image.source.clone());
let rel = image_path.strip_prefix(&assets_canon).map_err(|_| { let rel = image_path
unsupported(format!( .strip_prefix(&assets_canon)
"tileset image `{}` must be inside the assets directory", .ok()
image.source.display() .or_else(|| {
)) mirror_canon
})?; .as_deref()
.and_then(|m| image_path.strip_prefix(m).ok())
.filter(|rel| assets_canon.join(rel).is_file())
})
.ok_or_else(|| {
unsupported(format!(
"tileset image `{}` must be inside the assets directory",
image.source.display()
))
})?;
tilesets.push(TilesetInfo { tilesets.push(TilesetInfo {
image: rel.to_string_lossy().replace('\\', "/"), image: rel.to_string_lossy().replace('\\', "/"),
image_width: image.width as u32, image_width: image.width as u32,
@ -116,16 +163,46 @@ impl MapLayout {
tile_count: ts.tilecount, tile_count: ts.tilecount,
spacing: ts.spacing, spacing: ts.spacing,
margin: ts.margin, margin: ts.margin,
solid_tiles: ts
.tiles()
.filter(|(_, tile)| bool_prop(&tile.properties, "solid"))
.map(|(id, _)| id)
.collect(),
gameplay_tiles: ts
.tiles()
.filter(|(_, tile)| tile.properties.contains_key("proto"))
.map(|(id, tile)| (id, Self::gameplay_properties(&tile.properties)))
.collect(),
}); });
} }
let (width, height) = (map.width, map.height); let (width, height) = (map.width, map.height);
if width == 0 || height == 0 || map.tile_width == 0 || map.tile_height == 0 {
return Err(unsupported(
"map and tile dimensions must be nonzero".into(),
));
}
if width > i32::MAX as u32
|| height > i32::MAX as u32
|| u64::from(width) * u64::from(height) > 1_000_000
{
return Err(unsupported(
"maps may contain at most 1,000,000 cells".into(),
));
}
let mut layers = Vec::new(); let mut layers = Vec::new();
let mut solid = vec![false; (width * height) as usize]; let mut solid = vec![false; (width * height) as usize];
let mut objects = Vec::new(); let mut objects = Vec::new();
let mut painted = Vec::new();
let mut stack: Vec<tiled::Layer> = map.layers().collect(); let mut stack: Vec<tiled::Layer> = map.layers().collect();
stack.reverse(); stack.reverse();
while let Some(layer) = stack.pop() { while let Some(layer) = stack.pop() {
if layer.offset_x != 0.0 || layer.offset_y != 0.0 {
return Err(unsupported(format!(
"layer `{}`: offsets are not supported",
layer.name
)));
}
let layer_solid = bool_prop(&layer.properties, "solid"); let layer_solid = bool_prop(&layer.properties, "solid");
match layer.layer_type() { match layer.layer_type() {
tiled::LayerType::Tiles(tiles) => { tiled::LayerType::Tiles(tiles) => {
@ -135,25 +212,42 @@ impl MapLayout {
let mut cells = Vec::with_capacity(solid.len()); let mut cells = Vec::with_capacity(solid.len());
for y in 0..height as i32 { for y in 0..height as i32 {
for x in 0..width as i32 { for x in 0..width as i32 {
let cell = tiles.get_tile(x, y).map(|t| { let cell = tiles.get_tile(x, y).and_then(|t| {
if convert_painted
&& let Some(properties) =
tilesets[t.tileset_index()].gameplay_tiles.get(&t.id())
{
let mut properties = properties.clone();
Self::add_flips(&mut properties, t.flip_h, t.flip_v, t.flip_d);
painted.push(MapObject {
id: 0,
class: "building".into(),
name: String::new(),
pos: GridPos::new(x, y),
properties,
});
return None;
}
let tile_solid = t let tile_solid = t
.get_tile() .get_tile()
.is_some_and(|d| bool_prop(&d.properties, "solid")); .is_some_and(|d| bool_prop(&d.properties, "solid"));
if tile_solid || layer_solid { if tile_solid || layer_solid {
solid[(y as u32 * width + x as u32) as usize] = true; solid[(y as u32 * width + x as u32) as usize] = true;
} }
TileCell { Some(TileCell {
tileset: t.tileset_index(), tileset: t.tileset_index(),
id: t.id(), id: t.id(),
flip_h: t.flip_h, flip_h: t.flip_h,
flip_v: t.flip_v, flip_v: t.flip_v,
} flip_d: t.flip_d,
})
}); });
cells.push(cell); cells.push(cell);
} }
} }
layers.push(TileLayerData { layers.push(TileLayerData {
name: layer.name.clone(), name: layer.name.clone(),
solid: layer_solid,
cells, cells,
}); });
} }
@ -167,16 +261,24 @@ impl MapLayout {
} else { } else {
obj.y obj.y
}; };
let mut properties = BTreeMap::new();
if let Some(tile) = obj.get_tile() {
if let Some(def) = tile.get_tile() {
properties = Self::gameplay_properties(&def.properties);
}
Self::add_flips(&mut properties, tile.flip_h, tile.flip_v, tile.flip_d);
}
properties.extend(
obj.properties
.iter()
.map(|(k, v)| (k.clone(), prop_to_string(v))),
);
objects.push(MapObject { objects.push(MapObject {
id: obj.id(), id: obj.id(),
class: obj.user_type.clone(), class: obj.user_type.clone(),
name: obj.name.clone(), name: obj.name.clone(),
pos: GridPos::new((obj.x / tw).floor() as i32, (y / th).floor() as i32), pos: GridPos::new((obj.x / tw).floor() as i32, (y / th).floor() as i32),
properties: obj properties,
.properties
.iter()
.map(|(k, v)| (k.clone(), prop_to_string(v)))
.collect(),
}); });
} }
} }
@ -188,6 +290,53 @@ impl MapLayout {
} }
} }
let mut next_id = objects.iter().map(|o| o.id).max().unwrap_or(0);
for mut object in painted {
next_id = next_id
.checked_add(1)
.ok_or_else(|| unsupported("too many objects".into()))?;
object.id = next_id;
objects.push(object);
}
let default_tile = if let Some(value) = map.properties.get("default_tile") {
let tiled::PropertyValue::IntValue(id) = value else {
return Err(unsupported(
"default_tile must be a nonnegative local tile ID".into(),
));
};
let index = match map.properties.get("default_tileset") {
None => 0,
Some(tiled::PropertyValue::IntValue(index)) if *index >= 0 => *index as usize,
_ => {
return Err(unsupported(
"default_tileset must be a nonnegative tileset index".into(),
));
}
};
let ts = tilesets
.get(index)
.ok_or_else(|| unsupported("unknown default tileset".into()))?;
if *id < 0
|| *id as u32 >= ts.tile_count
|| ts.gameplay_tiles.contains_key(&(*id as u32))
{
return Err(unsupported(
"default_tile must reference a static terrain tile".into(),
));
}
Some(TileCell {
tileset: index,
id: *id as u32,
flip_h: false,
flip_v: false,
flip_d: false,
})
} else {
if map.properties.contains_key("default_tileset") {
return Err(unsupported("default_tileset requires default_tile".into()));
}
None
};
Ok(Self { Ok(Self {
width, width,
height, height,
@ -196,6 +345,7 @@ impl MapLayout {
tilesets, tilesets,
layers, layers,
objects, objects,
default_tile,
solid, solid,
}) })
} }
@ -206,7 +356,46 @@ impl MapLayout {
/// Tiles outside of the map are always solid. /// Tiles outside of the map are always solid.
pub fn is_solid(&self, pos: GridPos) -> bool { pub fn is_solid(&self, pos: GridPos) -> bool {
!self.in_bounds(pos) || self.solid[(pos.y as u32 * self.width + pos.x as u32) as usize] self.is_solid_with_floor(pos, false)
}
pub fn is_solid_with_floor(&self, pos: GridPos, has_floor: bool) -> bool {
if !self.in_bounds(pos) {
return true;
}
let i = (pos.y as u32 * self.width + pos.x as u32) as usize;
self.solid[i]
|| (!has_floor
&& !self.layers.iter().any(|layer| layer.cells[i].is_some())
&& self
.default_tile
.is_some_and(|cell| self.tilesets[cell.tileset].solid_tiles.contains(&cell.id)))
}
fn gameplay_properties(properties: &tiled::Properties) -> BTreeMap<String, String> {
properties
.iter()
.filter(|(name, value)| {
name.as_str() == "proto"
|| name.starts_with("state.")
|| name.starts_with("machine.")
|| (name.as_str() != "solid"
&& matches!(value, tiled::PropertyValue::IntValue(_)))
})
.map(|(key, value)| (key.clone(), prop_to_string(value)))
.collect()
}
fn add_flips(properties: &mut BTreeMap<String, String>, h: bool, v: bool, d: bool) {
for (key, value) in [
("state.flip_h", h),
("state.flip_v", v),
("state.flip_d", d),
] {
if value {
properties.insert(key.into(), "true".into());
}
}
} }
pub fn objects_of_class<'a>(&'a self, class: &'a str) -> impl Iterator<Item = &'a MapObject> { pub fn objects_of_class<'a>(&'a self, class: &'a str) -> impl Iterator<Item = &'a MapObject> {
@ -241,11 +430,24 @@ mod tests {
assert!(!map.layers.is_empty()); assert!(!map.layers.is_empty());
assert!(map.objects_of_class("spawn").next().is_some()); assert!(map.objects_of_class("spawn").next().is_some());
assert!(map.is_solid(GridPos::new(-1, 0))); assert!(map.is_solid(GridPos::new(-1, 0)));
assert!( assert!(!map.is_solid(GridPos::new(0, 0)));
map.is_solid(GridPos::new(0, 0)), assert!(map.objects.iter().any(|o| o.pos == GridPos::new(0, 0)
"map border should be walls" && o.properties.get("proto").is_some_and(|p| p == "wall")));
);
let spawn = map.objects_of_class("spawn").next().unwrap().pos; let spawn = map.objects_of_class("spawn").next().unwrap().pos;
assert!(!map.is_solid(spawn)); assert!(!map.is_solid(spawn));
} }
#[test]
fn legacy_terrain_stays_static() {
let legacy =
MapLayout::load_legacy_terrain(&crate::assets_dir(), crate::DEFAULT_MAP).unwrap();
assert!(legacy.is_solid(GridPos::new(0, 0)));
assert!(legacy.layers[1].cells[0].is_some());
assert!(!legacy.objects.iter().any(|object| {
object
.properties
.get("proto")
.is_some_and(|proto| proto == "floor" || proto == "wall")
}));
}
} }

View File

@ -7,17 +7,27 @@ use bevy::prelude::*;
use bevy_replicon::prelude::*; use bevy_replicon::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{components::*, grid::Dir}; use crate::components::*;
/// First event a client sends; selects (or creates) the persistent character. /// First event a client sends; selects (or creates) the persistent character.
#[derive(Event, Clone, Debug, Serialize, Deserialize)] #[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct Login { pub struct Login {
/// UUID identifying the player across sessions. Each player id owns one
/// character per world.
pub player_id: String,
/// Name for a new character; ignored when the player already has one.
pub name: String, pub name: String,
/// Server password, empty if none.
pub password: String,
} }
/// Direction the player is holding, `None` to stop. /// Movement keys the player is holding: `x` and `y` are -1, 0 or 1 (`y` down,
#[derive(Event, Clone, Debug, Serialize, Deserialize)] /// both 0 to stop). Diagonals move at the same speed as straight lines.
pub struct SetMoveDir(pub Option<Dir>); #[derive(Event, Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetMovement {
pub x: i8,
pub y: i8,
}
/// Pick up the item on the given tile (own or adjacent). /// Pick up the item on the given tile (own or adjacent).
#[derive(Event, Clone, Debug, Serialize, Deserialize)] #[derive(Event, Clone, Debug, Serialize, Deserialize)]
@ -57,18 +67,65 @@ pub struct Deconstruct {
pub target: GridPos, pub target: GridPos,
} }
/// Move items from an inventory slot into a machine on an adjacent tile.
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct MachineInsert {
pub target: GridPos,
pub inventory_slot: usize,
/// Input slot to put the items in, `None` for any fitting slot.
pub machine_slot: Option<usize>,
pub count: u32,
}
/// Move items from a machine slot into the inventory.
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct MachineTake {
pub target: GridPos,
pub slot: MachineSlot,
pub count: u32,
}
/// A chat message, or a command if it starts with `/`.
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct Chat {
pub text: String,
}
/// Text shown in the receiving player's message log. /// Text shown in the receiving player's message log.
#[derive(Event, Clone, Debug, Serialize, Deserialize)] #[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct GameLog { pub struct GameLog {
pub text: String, pub text: String,
} }
/// Login accepted.
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct Welcome {
pub server_name: String,
pub motd: String,
/// Name of the player's character.
pub character: String,
pub admin: bool,
}
/// The server is about to close the connection (login rejected, kicked, banned).
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct Rejected {
pub reason: String,
}
/// The player's admin rights changed.
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
pub struct AdminStatus {
pub admin: bool,
}
pub struct ProtocolPlugin; pub struct ProtocolPlugin;
impl Plugin for ProtocolPlugin { impl Plugin for ProtocolPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.replicate::<WorldMap>() app.replicate::<WorldMap>()
.replicate::<GridPos>() .replicate::<GridPos>()
.replicate::<Position>()
.replicate::<Facing>() .replicate::<Facing>()
.replicate::<Character>() .replicate::<Character>()
.replicate::<Vitals>() .replicate::<Vitals>()
@ -77,8 +134,10 @@ impl Plugin for ProtocolPlugin {
.replicate::<GroundItem>() .replicate::<GroundItem>()
.replicate::<Building>() .replicate::<Building>()
.replicate::<BuildingState>() .replicate::<BuildingState>()
.replicate::<Machine>()
.replicate::<Level>()
.add_client_event::<Login>(Channel::Ordered) .add_client_event::<Login>(Channel::Ordered)
.add_client_event::<SetMoveDir>(Channel::Ordered) .add_client_event::<SetMovement>(Channel::Ordered)
.add_client_event::<PickUp>(Channel::Ordered) .add_client_event::<PickUp>(Channel::Ordered)
.add_client_event::<Interact>(Channel::Ordered) .add_client_event::<Interact>(Channel::Ordered)
.add_client_event::<UseItem>(Channel::Ordered) .add_client_event::<UseItem>(Channel::Ordered)
@ -86,6 +145,12 @@ impl Plugin for ProtocolPlugin {
.add_client_event::<Craft>(Channel::Ordered) .add_client_event::<Craft>(Channel::Ordered)
.add_client_event::<Construct>(Channel::Ordered) .add_client_event::<Construct>(Channel::Ordered)
.add_client_event::<Deconstruct>(Channel::Ordered) .add_client_event::<Deconstruct>(Channel::Ordered)
.add_server_event::<GameLog>(Channel::Ordered); .add_client_event::<MachineInsert>(Channel::Ordered)
.add_client_event::<MachineTake>(Channel::Ordered)
.add_client_event::<Chat>(Channel::Ordered)
.add_server_event::<GameLog>(Channel::Ordered)
.add_server_event::<Welcome>(Channel::Ordered)
.add_server_event::<Rejected>(Channel::Ordered)
.add_server_event::<AdminStatus>(Channel::Ordered);
} }
} }

View File

@ -17,6 +17,14 @@ rsn = { workspace = true }
rune = { workspace = true } rune = { workspace = true }
tiled = { workspace = true } tiled = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
serde_json = { workspace = true }
quick-xml = "0.41"
uuid = { workspace = true }
sqlx = { workspace = true }
tokio = { workspace = true }
clap = { workspace = true }
toml = { workspace = true }
toml_edit = { workspace = true }
# Enables graceful shutdown (and saving) on SIGTERM/SIGHUP via Bevy's Ctrl-C handler. # Enables graceful shutdown (and saving) on SIGTERM/SIGHUP via Bevy's Ctrl-C handler.
ctrlc = { version = "3.5", features = ["termination"] } ctrlc = { version = "3.5", features = ["termination"] }

View File

@ -10,9 +10,10 @@ use game_core::{GameData, components::*, protocol::*};
use crate::{ use crate::{
crafting::{self, item_name}, crafting::{self, item_name},
movement,
players::{Players, tell}, players::{Players, tell},
scripting::{ScriptEngine, ScriptState, ScriptTarget}, scripting::{ScriptEngine, ScriptState, ScriptTarget},
world::{WorldLayout, spawn_building, spawn_ground_item}, world::{Level, Levels, spawn_building, spawn_ground_item},
}; };
/// Read-only game data shared with scripts. /// Read-only game data shared with scripts.
@ -29,6 +30,7 @@ type CharacterData<'a> = (
&'a mut Inventory, &'a mut Inventory,
&'a mut Vitals, &'a mut Vitals,
&'a mut Skills, &'a mut Skills,
&'a Level,
); );
type Characters<'w, 's> = Query<'w, 's, CharacterData<'static>>; type Characters<'w, 's> = Query<'w, 's, CharacterData<'static>>;
@ -40,11 +42,21 @@ type Buildings<'w, 's> = Query<
&'static Building, &'static Building,
&'static GridPos, &'static GridPos,
&'static mut BuildingState, &'static mut BuildingState,
&'static Level,
),
Without<Character>,
>;
type GroundItems<'w, 's> = Query<
'w,
's,
(
Entity,
&'static mut GroundItem,
&'static GridPos,
&'static Level,
), ),
Without<Character>, Without<Character>,
>; >;
type GroundItems<'w, 's> =
Query<'w, 's, (Entity, &'static mut GroundItem, &'static GridPos), Without<Character>>;
fn face_towards(facing: &mut Mut<Facing>, from: GridPos, to: GridPos) { fn face_towards(facing: &mut Mut<Facing>, from: GridPos, to: GridPos) {
use game_core::grid::Dir; use game_core::grid::Dir;
@ -81,11 +93,12 @@ fn give_or_drop(
data: &GameData, data: &GameData,
inventory: &mut Inventory, inventory: &mut Inventory,
stack: ItemStack, stack: ItemStack,
level: &Level,
pos: GridPos, pos: GridPos,
) { ) {
let leftover = inventory.add(&stack.item, stack.count, data); let leftover = inventory.add(&stack.item, stack.count, data);
if leftover > 0 { if leftover > 0 {
spawn_ground_item(commands, ItemStack::new(stack.item, leftover), pos); spawn_ground_item(commands, ItemStack::new(stack.item, leftover), level, pos);
} }
} }
@ -159,7 +172,7 @@ pub fn on_pick_up(
let Some(&entity) = players.by_client.get(&client) else { let Some(&entity) = players.by_client.get(&client) else {
return; return;
}; };
let Ok((_, pos, mut facing, mut inventory, _, _)) = characters.get_mut(entity) else { let Ok((_, pos, mut facing, mut inventory, _, _, level)) = characters.get_mut(entity) else {
return; return;
}; };
if pos.distance(target) > REACH { if pos.distance(target) > REACH {
@ -167,8 +180,8 @@ pub fn on_pick_up(
} }
face_towards(&mut facing, *pos, target); face_towards(&mut facing, *pos, target);
let mut picked = Vec::new(); let mut picked = Vec::new();
for (item_entity, mut item, item_pos) in &mut items { for (item_entity, mut item, item_pos, item_level) in &mut items {
if *item_pos != target { if *item_pos != target || item_level != level {
continue; continue;
} }
let leftover = inventory.add(&item.0.item, item.0.count, &data); let leftover = inventory.add(&item.0.item, item.0.count, &data);
@ -183,7 +196,7 @@ pub fn on_pick_up(
} }
} }
if picked.is_empty() { if picked.is_empty() {
let full = items.iter().any(|(_, _, p)| *p == target); let full = items.iter().any(|(_, _, p, l)| *p == target && l == level);
tell( tell(
&mut commands, &mut commands,
client, client,
@ -214,7 +227,7 @@ pub fn on_drop_item(
let Some(&entity) = players.by_client.get(&client) else { let Some(&entity) = players.by_client.get(&client) else {
return; return;
}; };
let Ok((_, pos, _, mut inventory, _, _)) = characters.get_mut(entity) else { let Ok((_, pos, _, mut inventory, _, _, level)) = characters.get_mut(entity) else {
return; return;
}; };
let Some(stack) = inventory.take_from_slot(ev.message.slot, u32::MAX) else { let Some(stack) = inventory.take_from_slot(ev.message.slot, u32::MAX) else {
@ -230,13 +243,13 @@ pub fn on_drop_item(
), ),
); );
// Merge with a matching stack on the floor to avoid clutter. // Merge with a matching stack on the floor to avoid clutter.
if let Some((_, mut existing, _)) = items if let Some((_, mut existing, _, _)) = items
.iter_mut() .iter_mut()
.find(|(_, i, p)| *p == pos && i.0.item == stack.item) .find(|(_, i, p, l)| *p == pos && *l == level && i.0.item == stack.item)
{ {
existing.0.count += stack.count; existing.0.count += stack.count;
} else { } else {
spawn_ground_item(&mut commands, stack, *pos); spawn_ground_item(&mut commands, stack, level, *pos);
} }
} }
@ -252,7 +265,8 @@ pub fn on_use_item(
let Some(&entity) = players.by_client.get(&client) else { let Some(&entity) = players.by_client.get(&client) else {
return; return;
}; };
let Ok((character, pos, _, mut inventory, mut vitals, mut skills)) = characters.get_mut(entity) let Ok((character, pos, _, mut inventory, mut vitals, mut skills, level)) =
characters.get_mut(entity)
else { else {
return; return;
}; };
@ -286,7 +300,7 @@ pub fn on_use_item(
inventory.remove(&item_id, 1); inventory.remove(&item_id, 1);
} }
for stack in outcome.spawned { for stack in outcome.spawned {
spawn_ground_item(&mut commands, stack, *pos); spawn_ground_item(&mut commands, stack, level, *pos);
} }
} }
@ -305,7 +319,7 @@ pub fn on_interact(
let Some(&entity) = players.by_client.get(&client) else { let Some(&entity) = players.by_client.get(&client) else {
return; return;
}; };
let Ok((character, pos, mut facing, mut inventory, mut vitals, mut skills)) = let Ok((character, pos, mut facing, mut inventory, mut vitals, mut skills, level)) =
characters.get_mut(entity) characters.get_mut(entity)
else { else {
return; return;
@ -315,8 +329,10 @@ pub fn on_interact(
return; return;
} }
face_towards(&mut facing, *pos, target); face_towards(&mut facing, *pos, target);
let Some((building_entity, building, _, mut state)) = let Some((building_entity, building, _, mut state, _)) = buildings
buildings.iter_mut().find(|(_, _, p, _)| **p == target) .iter_mut()
.filter(|(_, _, p, _, l)| **p == target && *l == level)
.max_by_key(|(_, _, _, state, _)| state.layer)
else { else {
tell(&mut commands, client, "There is nothing there."); tell(&mut commands, client, "There is nothing there.");
return; return;
@ -325,6 +341,10 @@ pub fn on_interact(
return; return;
}; };
let Some(hook) = &def.on_interact else { let Some(hook) = &def.on_interact else {
// Clients open the machine window themselves.
if data.0.machines.contains_key(&def.id) {
return;
}
let mut text = def.name.clone(); let mut text = def.name.clone();
if !def.description.is_empty() { if !def.description.is_empty() {
text = format!("{text}: {}", def.description); text = format!("{text}: {}", def.description);
@ -360,7 +380,7 @@ pub fn on_interact(
} }
} }
for stack in outcome.spawned { for stack in outcome.spawned {
spawn_ground_item(&mut commands, stack, target); spawn_ground_item(&mut commands, stack, level, target);
} }
} }
@ -372,20 +392,21 @@ pub fn on_craft(
data: Res<SharedData>, data: Res<SharedData>,
engine: Res<ScriptEngine>, engine: Res<ScriptEngine>,
mut characters: Characters, mut characters: Characters,
buildings: Query<(&Building, &GridPos), Without<Character>>, buildings: Query<(&Building, &GridPos, &Level), Without<Character>>,
) { ) {
let client = ev.client_id; let client = ev.client_id;
let Some(&entity) = players.by_client.get(&client) else { let Some(&entity) = players.by_client.get(&client) else {
return; return;
}; };
let Ok((character, pos, _, mut inventory, mut vitals, mut skills)) = characters.get_mut(entity) let Ok((character, pos, _, mut inventory, mut vitals, mut skills, level)) =
characters.get_mut(entity)
else { else {
return; return;
}; };
let stations: Vec<String> = buildings let stations: Vec<String> = buildings
.iter() .iter()
.filter(|(_, p)| p.distance(*pos) <= REACH) .filter(|(_, p, l)| p.distance(*pos) <= REACH && *l == level)
.filter_map(|(b, _)| data.0.buildings.get(&b.proto)) .filter_map(|(b, _, _)| data.0.buildings.get(&b.proto))
.flat_map(|def| def.stations.iter().cloned()) .flat_map(|def| def.stations.iter().cloned())
.collect(); .collect();
@ -421,7 +442,7 @@ pub fn on_craft(
) )
{ {
for stack in outcome.spawned { for stack in outcome.spawned {
spawn_ground_item(&mut commands, stack, *pos); spawn_ground_item(&mut commands, stack, level, *pos);
} }
} }
} }
@ -435,21 +456,27 @@ pub fn on_construct(
mut commands: Commands, mut commands: Commands,
players: Res<Players>, players: Res<Players>,
data: Res<GameData>, data: Res<GameData>,
layout: Res<WorldLayout>, levels: Res<Levels>,
mut characters: Characters, mut characters: Characters,
buildings: Query<&GridPos, (With<Building>, Without<Character>)>, buildings: Query<(&GridPos, &BuildingState, &Level), (With<Building>, Without<Character>)>,
occupants: Query<&GridPos, (With<Character>, Without<Building>)>, occupants: Query<(&Position, &Level), (With<Character>, Without<Building>)>,
) { ) {
let client = ev.client_id; let client = ev.client_id;
let target = ev.message.target; let target = ev.message.target;
let Some(&entity) = players.by_client.get(&client) else { let Some(&entity) = players.by_client.get(&client) else {
return; return;
}; };
// Checked before borrowing the actor mutably. let Ok((_, pos, mut facing, mut inventory, _, mut skills, level)) = characters.get_mut(entity)
let occupied = occupants.iter().any(|p| *p == target); else {
let Ok((_, pos, mut facing, mut inventory, _, mut skills)) = characters.get_mut(entity) else {
return; return;
}; };
let level = level.clone();
let Some(layout) = levels.layout(&level) else {
return;
};
let occupied = occupants
.iter()
.any(|(p, l)| *l == level && movement::overlaps_tile(p.0, target));
if pos.distance(target) > REACH { if pos.distance(target) > REACH {
tell(&mut commands, client, "That is too far away."); tell(&mut commands, client, "That is too far away.");
return; return;
@ -458,7 +485,20 @@ pub fn on_construct(
let Some(def) = data.buildings.get(&ev.message.building) else { let Some(def) = data.buildings.get(&ev.message.building) else {
return; return;
}; };
if layout.0.is_solid(target) || buildings.iter().any(|p| *p == target) { let floor = def.layer == game_core::data::BuildingLayer::Floor;
let here = || {
buildings
.iter()
.filter(|(p, _, l)| **p == target && **l == level)
};
let has_floor =
here().any(|(_, state, _)| state.layer == game_core::data::BuildingLayer::Floor);
if layout.is_solid_with_floor(target, floor || has_floor)
|| here().any(|(_, state, _)| {
state.layer == def.layer
|| (floor && state.layer == game_core::data::BuildingLayer::Structure)
})
{
tell(&mut commands, client, "There is no room to build there."); tell(&mut commands, client, "There is no room to build there.");
return; return;
} }
@ -472,7 +512,14 @@ pub fn on_construct(
Ok(level_up) => { Ok(level_up) => {
*inventory = new_inventory; *inventory = new_inventory;
*skills = new_skills; *skills = new_skills;
spawn_building(&mut commands, &data, &def.id, target, Default::default()); spawn_building(
&mut commands,
&data,
&def.id,
&level,
target,
Default::default(),
);
tell(&mut commands, client, format!("You build a {}.", def.name)); tell(&mut commands, client, format!("You build a {}.", def.name));
announce_level_up(&mut commands, client, &data, level_up); announce_level_up(&mut commands, client, &data, level_up);
} }
@ -486,14 +533,24 @@ pub fn on_deconstruct(
players: Res<Players>, players: Res<Players>,
data: Res<GameData>, data: Res<GameData>,
mut characters: Characters, mut characters: Characters,
buildings: Query<(Entity, &Building, &GridPos), Without<Character>>, buildings: Query<
(
Entity,
&Building,
&GridPos,
&BuildingState,
Option<&Machine>,
&Level,
),
Without<Character>,
>,
) { ) {
let client = ev.client_id; let client = ev.client_id;
let target = ev.message.target; let target = ev.message.target;
let Some(&entity) = players.by_client.get(&client) else { let Some(&entity) = players.by_client.get(&client) else {
return; return;
}; };
let Ok((_, pos, mut facing, mut inventory, _, _)) = characters.get_mut(entity) else { let Ok((_, pos, mut facing, mut inventory, _, _, level)) = characters.get_mut(entity) else {
return; return;
}; };
if pos.distance(target) > REACH { if pos.distance(target) > REACH {
@ -501,7 +558,10 @@ pub fn on_deconstruct(
return; return;
} }
face_towards(&mut facing, *pos, target); face_towards(&mut facing, *pos, target);
let Some((building_entity, building, _)) = buildings.iter().find(|(_, _, p)| **p == target) let Some((building_entity, building, _, _, machine, _)) = buildings
.iter()
.filter(|(_, _, p, _, _, l)| **p == target && *l == level)
.max_by_key(|(_, _, _, state, _, _)| state.layer)
else { else {
tell(&mut commands, client, "There is nothing to deconstruct."); tell(&mut commands, client, "There is nothing to deconstruct.");
return; return;
@ -538,10 +598,14 @@ pub fn on_deconstruct(
&data, &data,
&mut inventory, &mut inventory,
ItemStack::new(&material.item, material.count), ItemStack::new(&material.item, material.count),
level,
*pos, *pos,
); );
} }
} }
for stack in machine.into_iter().flat_map(Machine::contents) {
spawn_ground_item(&mut commands, stack.clone(), level, target);
}
commands.entity(building_entity).despawn(); commands.entity(building_entity).despawn();
tell( tell(
&mut commands, &mut commands,

View File

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

View File

@ -0,0 +1,375 @@
//! Server configuration: a TOML file, overridable from the command line and
//! editable at runtime by admins (`config set`), which also updates the file
//! while preserving its comments and layout.
use std::{
path::{Path, PathBuf},
time::Duration,
};
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
use toml_edit::DocumentMut;
use crate::{
ServerSettings,
persistence::{StorageSettings, WorldSelector},
};
/// Everything a server operator can configure.
#[derive(Resource, Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ServerConfig {
/// Shown to players when they join.
pub name: String,
pub motd: String,
/// UDP port.
pub port: u16,
pub max_players: usize,
/// Required to join when not empty.
pub password: String,
/// Tiled map used to create new worlds.
pub map: String,
/// Seconds between autosaves.
pub autosave_secs: u64,
pub database: DatabaseConfig,
pub access: AccessConfig,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DatabaseConfig {
/// Persist the world. When false, the world is reset on every start.
pub save: bool,
/// sqlx URL: `sqlite://path` or `postgres://user:password@host/db`.
pub url: String,
/// World name (created if missing) or UUID.
pub world: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AccessConfig {
/// Player ids with admin rights.
pub admins: Vec<String>,
/// Only players in `whitelist` may join.
pub whitelist_enabled: bool,
/// Player names or ids.
pub whitelist: Vec<String>,
/// Banned player names or ids.
pub banned: Vec<String>,
}
pub const DEFAULT_CONFIG_PATH: &str = "server.toml";
impl Default for ServerConfig {
fn default() -> Self {
Self {
name: "Station".into(),
motd: String::new(),
port: game_core::DEFAULT_PORT,
max_players: 32,
password: String::new(),
map: game_core::DEFAULT_MAP.into(),
autosave_secs: 60,
database: DatabaseConfig::default(),
access: AccessConfig::default(),
}
}
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
save: true,
url: "sqlite://saves/server.sqlite".into(),
world: "main".into(),
}
}
}
/// Written when the configuration file does not exist yet. Must match [`ServerConfig::default`].
pub const DEFAULT_CONFIG: &str = r#"# Station server configuration.
# Command line options override these values for a single run.
# Admins can change values at runtime with `config set KEY VALUE`, which also updates this file.
# Server name and message of the day, shown to players when they join.
name = "Station"
motd = ""
# UDP port to listen on.
port = 5000
max_players = 32
# Players must enter this password to join. Empty = no password.
# Note: connections are not encrypted.
password = ""
# Tiled map (relative to the assets directory) used to create new worlds.
map = "maps/station.tmx"
# Seconds between autosaves.
autosave_secs = 60
[database]
# Persist the world. When false, the world is reset on every start.
save = true
# sqlx URL: "sqlite://path/to/file.sqlite" or "postgres://user:password@host/database".
url = "sqlite://saves/server.sqlite"
# World name (created if missing) or world UUID. Many worlds can share one database.
world = "main"
[access]
# Player ids (shown in the server log when a player joins) with admin rights.
admins = []
# When enabled, only whitelisted player names or ids may join.
whitelist_enabled = false
whitelist = []
# Banned player names or ids.
banned = []
"#;
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("{0}: {1}")]
Io(PathBuf, std::io::Error),
#[error("{0}: {1}")]
Parse(PathBuf, String),
#[error("unknown setting `{0}`")]
UnknownKey(String),
#[error("invalid value for `{0}`: {1}")]
InvalidValue(String, String),
}
impl ServerConfig {
/// Simulation settings derived from this configuration.
pub fn settings(&self) -> ServerSettings {
ServerSettings {
map: self.map.clone(),
storage: self.database.save.then(|| StorageSettings {
database_url: self.database.url.clone(),
world: WorldSelector::parse(&self.database.world),
}),
autosave_interval: Duration::from_secs(self.autosave_secs.max(1)),
owner: None,
}
}
fn to_document(&self) -> DocumentMut {
toml::to_string(self)
.expect("config serializes")
.parse()
.expect("serialized config parses")
}
/// Current value of a dotted key (e.g. `database.world`) in TOML syntax.
pub fn get(&self, key: &str) -> Result<String, ConfigError> {
let doc = self.to_document();
let mut item = doc.as_item();
for part in key.split('.') {
item = item
.get(part)
.ok_or_else(|| ConfigError::UnknownKey(key.into()))?;
}
Ok(match item {
toml_edit::Item::Value(value) => value.to_string().trim().to_owned(),
other => other.to_string().trim().to_owned(),
})
}
/// Sets a dotted key. `value` is TOML (`42`, `true`, `["a"]`); anything that
/// isn't valid TOML is taken as a string.
pub fn set(&mut self, key: &str, value: &str) -> Result<(), ConfigError> {
let value = parse_value(value);
let mut doc = self.to_document();
set_in_document(&mut doc, key, value)?;
*self = from_document(&doc).map_err(|e| invalid(key, e))?;
Ok(())
}
}
fn invalid(key: &str, e: String) -> ConfigError {
if e.contains("unknown field") {
ConfigError::UnknownKey(key.into())
} else {
ConfigError::InvalidValue(key.into(), e)
}
}
fn parse_value(value: &str) -> toml_edit::Value {
value
.trim()
.parse()
.unwrap_or_else(|_| toml_edit::Value::from(value))
}
fn from_document(doc: &DocumentMut) -> Result<ServerConfig, String> {
toml::from_str(&doc.to_string()).map_err(|e| e.message().to_owned())
}
fn set_in_document(
doc: &mut DocumentMut,
key: &str,
value: toml_edit::Value,
) -> Result<(), ConfigError> {
let parts: Vec<&str> = key.split('.').collect();
let (last, tables) = parts.split_last().expect("split yields one part");
let mut table = doc.as_table_mut();
for part in tables {
table = table
.entry(part)
.or_insert_with(toml_edit::table)
.as_table_mut()
.ok_or_else(|| ConfigError::UnknownKey(key.into()))?;
}
match table.get_mut(last) {
// Keeps the comments around the existing value.
Some(toml_edit::Item::Value(old)) => {
let decor = old.decor().clone();
*old = value;
*old.decor_mut() = decor;
}
Some(_) => return Err(ConfigError::UnknownKey(key.into())),
None => {
table.insert(last, toml_edit::value(value));
}
}
Ok(())
}
/// The configuration file backing a [`ServerConfig`].
#[derive(Resource, Debug)]
pub struct ConfigFile {
pub path: PathBuf,
doc: DocumentMut,
}
impl ConfigFile {
/// Loads the file, creating it with [`DEFAULT_CONFIG`] if it does not exist.
pub fn load_or_create(path: &Path) -> Result<(Self, ServerConfig, bool), ConfigError> {
let io = |e| ConfigError::Io(path.to_owned(), e);
let (text, created) = match std::fs::read_to_string(path) {
Ok(text) => (text, false),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if let Some(dir) = path.parent()
&& !dir.as_os_str().is_empty()
{
std::fs::create_dir_all(dir).map_err(io)?;
}
std::fs::write(path, DEFAULT_CONFIG).map_err(io)?;
(DEFAULT_CONFIG.to_owned(), true)
}
Err(e) => return Err(io(e)),
};
let parse = |e: String| ConfigError::Parse(path.to_owned(), e);
let doc: DocumentMut = text
.parse()
.map_err(|e: toml_edit::TomlError| parse(e.to_string()))?;
let config = toml::from_str(&text).map_err(|e: toml::de::Error| parse(e.to_string()))?;
Ok((
Self {
path: path.to_owned(),
doc,
},
config,
created,
))
}
/// Sets a key in the file and writes it.
pub fn set(&mut self, key: &str, value: &str) -> Result<(), ConfigError> {
let mut doc = self.doc.clone();
set_in_document(&mut doc, key, parse_value(value))?;
from_document(&doc).map_err(|e| invalid(key, e))?;
std::fs::write(&self.path, doc.to_string())
.map_err(|e| ConfigError::Io(self.path.clone(), e))?;
self.doc = doc;
Ok(())
}
}
/// Changes a setting at runtime (and in the config file, if there is one).
/// Returns a note if the change only takes effect after a restart.
pub fn set_runtime(
world: &mut World,
key: &str,
value: &str,
) -> Result<Option<&'static str>, ConfigError> {
let mut config = world.resource::<ServerConfig>().clone();
config.set(key, value)?;
if let Some(mut file) = world.get_resource_mut::<ConfigFile>() {
file.set(key, value)?;
}
let restart = matches!(key.split('.').next(), Some("port" | "map" | "database"));
if config.autosave_secs != world.resource::<ServerConfig>().autosave_secs
&& let Some(mut timer) = world.get_resource_mut::<crate::persistence::AutosaveTimer>()
{
timer
.0
.set_duration(Duration::from_secs(config.autosave_secs.max(1)));
}
world.insert_resource(config);
Ok(restart.then_some("takes effect after a restart"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_file_matches_defaults() {
let config: ServerConfig = toml::from_str(DEFAULT_CONFIG).unwrap();
assert_eq!(config, ServerConfig::default());
}
#[test]
fn get_and_set() {
let mut config = ServerConfig::default();
config.set("max_players", "4").unwrap();
config.set("database.world", "europa").unwrap();
config.set("motd", "hello there").unwrap();
config.set("access.admins", r#"["abc"]"#).unwrap();
assert_eq!(config.max_players, 4);
assert_eq!(config.database.world, "europa");
assert_eq!(config.motd, "hello there");
assert_eq!(config.access.admins, ["abc"]);
assert_eq!(config.get("database.world").unwrap(), "\"europa\"");
assert_eq!(config.get("max_players").unwrap(), "4");
assert!(matches!(
config.set("nope", "1"),
Err(ConfigError::UnknownKey(_))
));
assert!(matches!(
config.set("database.nope", "1"),
Err(ConfigError::UnknownKey(_))
));
assert!(matches!(
config.get("nope"),
Err(ConfigError::UnknownKey(_))
));
assert!(matches!(
config.set("port", "-1"),
Err(ConfigError::InvalidValue(..))
));
assert_eq!(config.port, game_core::DEFAULT_PORT);
}
#[test]
fn file_edits_keep_comments() {
let dir = std::env::temp_dir().join(format!("station_config_test_{}", std::process::id()));
let path = dir.join("server.toml");
let (mut file, config, created) = ConfigFile::load_or_create(&path).unwrap();
assert!(created);
assert_eq!(config, ServerConfig::default());
file.set("password", "secret").unwrap();
file.set("access.banned", r#"["griefer"]"#).unwrap();
assert!(file.set("port", "\"x\"").is_err());
let (_, config, created) = ConfigFile::load_or_create(&path).unwrap();
assert!(!created);
assert_eq!(config.password, "secret");
assert_eq!(config.access.banned, ["griefer"]);
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("# Players must enter this password to join."));
std::fs::remove_dir_all(&dir).unwrap();
}
}

View File

@ -0,0 +1,50 @@
//! Reads commands from the dedicated server's standard input.
use std::{
io::BufRead,
sync::{
Mutex,
mpsc::{Receiver, channel},
},
};
use bevy::prelude::*;
use crate::commands::{CommandSource, execute};
pub struct ConsolePlugin;
#[derive(Resource)]
struct ConsoleInput(Mutex<Receiver<String>>);
impl Plugin for ConsolePlugin {
fn build(&self, app: &mut App) {
let (tx, rx) = channel();
std::thread::Builder::new()
.name("console".into())
.spawn(move || {
for line in std::io::stdin().lock().lines() {
let Ok(line) = line else { break };
if tx.send(line).is_err() {
break;
}
}
})
.expect("failed to spawn the console thread");
app.insert_resource(ConsoleInput(Mutex::new(rx)))
.add_systems(Update, run_console_commands);
}
}
fn run_console_commands(world: &mut World) {
let lines: Vec<String> = {
let input = world.resource::<ConsoleInput>();
let rx = input.0.lock().expect("console lock");
rx.try_iter().collect()
};
for line in lines {
for reply in execute(world, CommandSource::Console, &line) {
info!(target: "console", "{reply}");
}
}
}

View File

@ -203,22 +203,22 @@ mod tests {
let data = data(); let data = data();
let mut inv = Inventory::default(); let mut inv = Inventory::default();
let mut skills = Skills::default(); let mut skills = Skills::default();
let furnace = vec!["furnace".to_owned()]; let bench = vec!["workbench".to_owned()];
// Ore stays in its slot (3 - 2 = 1 left), so the plate needs a free slot. // Logs stay in their slot (2 - 1 = 1 left), so the planks need a free slot.
inv.add("iron_ore", 3, &data); inv.add("log", 2, &data);
for _ in 1..game_core::components::INVENTORY_SLOTS { for _ in 1..game_core::components::INVENTORY_SLOTS {
inv.add("pickaxe", 1, &data); inv.add("pickaxe", 1, &data);
} }
let before = inv.clone(); let before = inv.clone();
let err = try_craft(&data, "smelt_iron", &furnace, &mut inv, &mut skills).unwrap_err(); let err = try_craft(&data, "saw_planks", &bench, &mut inv, &mut skills).unwrap_err();
assert!(err.contains("full"), "{err}"); assert!(err.contains("full"), "{err}");
assert_eq!(inv, before); assert_eq!(inv, before);
assert!(skills.skills.is_empty()); assert!(skills.skills.is_empty());
// Consuming a whole stack frees its slot for the output. // Consuming a whole stack frees its slot for the output.
inv.remove("iron_ore", 1); inv.remove("log", 1);
try_craft(&data, "smelt_iron", &furnace, &mut inv, &mut skills).unwrap(); try_craft(&data, "saw_planks", &bench, &mut inv, &mut skills).unwrap();
assert!(inv.has("iron_plate", 1)); assert!(inv.has("plank", 4));
} }
#[test] #[test]

View File

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

View File

@ -5,7 +5,13 @@
//! [`game_core::protocol`]); all game rules run here. //! [`game_core::protocol`]); all game rules run here.
pub mod actions; pub mod actions;
pub mod commands;
pub mod config;
pub mod console;
pub mod crafting; pub mod crafting;
pub mod embedded;
pub mod machines;
pub mod map_io;
pub mod movement; pub mod movement;
pub mod network; pub mod network;
pub mod persistence; pub mod persistence;
@ -14,9 +20,10 @@ pub mod scripting;
pub mod survival; pub mod survival;
pub mod world; pub mod world;
use std::{path::PathBuf, sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
use bevy::prelude::*; use bevy::prelude::*;
use bevy_replicon::server::visibility::AppVisibilityExt;
use game_core::GameData; use game_core::GameData;
pub use network::NetworkServerPlugin; pub use network::NetworkServerPlugin;
@ -26,17 +33,21 @@ pub use network::NetworkServerPlugin;
pub struct ServerSettings { pub struct ServerSettings {
/// Tiled map, relative to the assets directory. /// Tiled map, relative to the assets directory.
pub map: String, pub map: String,
/// Where the persistent world is stored. `None` disables saving. /// Database and world to persist to. `None` disables saving.
pub save_path: Option<PathBuf>, pub storage: Option<persistence::StorageSettings>,
pub autosave_interval: Duration, pub autosave_interval: Duration,
/// Player id that always has admin rights and bypasses access restrictions
/// (the host of an embedded server).
pub owner: Option<String>,
} }
impl Default for ServerSettings { impl Default for ServerSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
map: game_core::DEFAULT_MAP.to_owned(), map: game_core::DEFAULT_MAP.to_owned(),
save_path: None, storage: None,
autosave_interval: Duration::from_secs(60), autosave_interval: Duration::from_secs(60),
owner: None,
} }
} }
} }
@ -53,32 +64,72 @@ impl Plugin for GameServerPlugin {
self.settings.autosave_interval, self.settings.autosave_interval,
TimerMode::Repeating, TimerMode::Repeating,
))) )))
.init_resource::<config::ServerConfig>()
.init_resource::<players::Players>() .init_resource::<players::Players>()
.init_resource::<players::CharacterStore>() .init_resource::<players::CharacterStore>()
.init_resource::<players::RejectedLogins>()
.init_resource::<survival::SurvivalTimer>() .init_resource::<survival::SurvivalTimer>()
.add_systems(Startup, (load_scripts, world::setup_world).chain()) .add_visibility_filter::<world::Level>()
.add_systems(
Startup,
(
load_scripts,
world::setup_world.run_if(not(resource_exists::<ServerError>)),
)
.chain(),
)
.add_systems( .add_systems(
FixedUpdate, FixedUpdate,
(movement::move_characters, survival::tick_survival), (
(movement::move_characters, survival::tick_survival),
movement::sync_tiles,
movement::use_teleports,
machines::tick_machines,
)
.chain()
.run_if(resource_exists::<world::Levels>),
)
.add_systems(
Update,
(
persistence::autosave,
players::enforce_access.run_if(resource_changed::<config::ServerConfig>),
),
) )
.add_systems(Update, persistence::autosave)
.add_systems(Last, persistence::save_on_exit) .add_systems(Last, persistence::save_on_exit)
.add_observer(players::on_login) .add_observer(players::on_login)
.add_observer(players::on_disconnect) .add_observer(players::on_disconnect)
.add_observer(movement::on_set_move_dir) .add_observer(players::on_chat)
.add_observer(movement::on_set_movement)
.add_observer(actions::on_pick_up) .add_observer(actions::on_pick_up)
.add_observer(actions::on_drop_item) .add_observer(actions::on_drop_item)
.add_observer(actions::on_use_item) .add_observer(actions::on_use_item)
.add_observer(actions::on_interact) .add_observer(actions::on_interact)
.add_observer(actions::on_craft) .add_observer(actions::on_craft)
.add_observer(actions::on_construct) .add_observer(actions::on_construct)
.add_observer(actions::on_deconstruct); .add_observer(actions::on_deconstruct)
.add_observer(machines::on_machine_insert)
.add_observer(machines::on_machine_take);
} }
} }
/// Why the server failed to start. Inserted together with an error [`AppExit`].
#[derive(Resource, Clone, Debug)]
pub struct ServerError(pub String);
/// Reports a fatal startup error and requests exit.
pub fn fail(commands: &mut Commands, message: String) {
error!("{message}");
commands.insert_resource(ServerError(message));
commands.write_message(AppExit::error());
}
fn load_scripts(mut commands: Commands, data: Res<GameData>) { fn load_scripts(mut commands: Commands, data: Res<GameData>) {
let dir = game_core::asset_path("scripts"); let dir = game_core::asset_path("scripts");
let engine = scripting::ScriptEngine::load(&dir, &data).unwrap_or_else(|e| panic!("{e}")); let engine = match scripting::ScriptEngine::load(&dir, &data) {
Ok(engine) => engine,
Err(e) => return fail(&mut commands, e.to_string()),
};
info!("compiled scripts from `{}`", dir.display()); info!("compiled scripts from `{}`", dir.display());
commands.insert_resource(engine); commands.insert_resource(engine);
commands.insert_resource(actions::SharedData(Arc::new(data.clone()))); commands.insert_resource(actions::SharedData(Arc::new(data.clone())));

View File

@ -0,0 +1,163 @@
//! Machines: buildings with item slots that process their own recipes over time.
use bevy::prelude::*;
use bevy_replicon::prelude::*;
use game_core::{GameData, components::*, protocol::*};
use crate::{
actions::REACH,
crafting::item_name,
players::{Players, tell},
world::Level,
};
/// Advances every machine: picks the first recipe it can process, counts up
/// its progress and turns inputs into outputs once the recipe time has passed.
pub fn tick_machines(
time: Res<Time>,
data: Res<GameData>,
mut machines: Query<(&Building, &mut Machine, &mut BuildingState)>,
) {
let dt = time.delta_secs();
for (building, mut machine, mut state) in &mut machines {
let Some(def) = data.machines.get(&building.proto) else {
continue;
};
let current = machine
.recipe
.as_deref()
.and_then(|id| def.recipe(id))
.filter(|r| machine.can_process(r, &data));
let recipe = current.or_else(|| def.recipes.iter().find(|r| machine.can_process(r, &data)));
match recipe {
None => {
if machine.recipe.is_some() || machine.progress != 0.0 {
machine.recipe = None;
machine.progress = 0.0;
}
}
Some(recipe) => {
if machine.recipe.as_deref() != Some(recipe.id.as_str()) {
machine.recipe = Some(recipe.id.clone());
machine.progress = 0.0;
}
machine.progress += dt;
if machine.progress >= recipe.time {
machine.complete(recipe, &data);
machine.progress = 0.0;
}
}
}
if let Some(active) = &def.active_variant {
let running = machine.recipe.is_some();
let shown = state.variant.as_ref() == Some(active);
if running != shown {
state.variant = running.then(|| active.clone());
}
}
}
}
type Actor<'a> = (&'a GridPos, &'a mut Inventory, &'a Level);
type Machines<'w, 's> = Query<
'w,
's,
(
&'static GridPos,
&'static Building,
&'static mut Machine,
&'static Level,
),
Without<Character>,
>;
fn machine_in_reach<'a>(
level: &Level,
pos: GridPos,
target: GridPos,
machines: &'a mut Machines,
) -> Result<(&'a Building, Mut<'a, Machine>), &'static str> {
if pos.distance(target) > REACH {
return Err("That is too far away.");
}
machines
.iter_mut()
.find(|(p, _, _, l)| **p == target && *l == level)
.map(|(_, b, m, _)| (b, m))
.ok_or("There is no machine there.")
}
pub fn on_machine_insert(
ev: On<FromClient<MachineInsert>>,
mut commands: Commands,
players: Res<Players>,
data: Res<GameData>,
mut characters: Query<Actor<'static>, With<Character>>,
mut machines: Machines,
) {
let client = ev.client_id;
let msg = &ev.message;
let Some(&entity) = players.by_client.get(&client) else {
return;
};
let Ok((pos, mut inventory, level)) = characters.get_mut(entity) else {
return;
};
let (_, mut machine) = match machine_in_reach(level, *pos, msg.target, &mut machines) {
Ok(found) => found,
Err(reason) => return tell(&mut commands, client, reason),
};
let Some(stack) = inventory.slots.get(msg.inventory_slot).cloned() else {
return;
};
let wanted = msg.count.min(stack.count);
// Insert into a copy first so a full machine causes no change at all.
let mut updated = machine.clone();
let moved = updated.insert(msg.machine_slot, &stack.item, wanted, &data);
if moved == 0 {
tell(
&mut commands,
client,
format!("The {} doesn't fit.", item_name(&data, &stack.item)),
);
return;
}
inventory.take_from_slot(msg.inventory_slot, moved);
*machine = updated;
}
pub fn on_machine_take(
ev: On<FromClient<MachineTake>>,
mut commands: Commands,
players: Res<Players>,
data: Res<GameData>,
mut characters: Query<Actor<'static>, With<Character>>,
mut machines: Machines,
) {
let client = ev.client_id;
let msg = &ev.message;
let Some(&entity) = players.by_client.get(&client) else {
return;
};
let Ok((pos, mut inventory, level)) = characters.get_mut(entity) else {
return;
};
let (_, mut machine) = match machine_in_reach(level, *pos, msg.target, &mut machines) {
Ok(found) => found,
Err(reason) => return tell(&mut commands, client, reason),
};
let Some(stack) = machine.slot(msg.slot) else {
return;
};
let count = msg
.count
.min(stack.count)
.min(inventory.space_for(&stack.item, &data));
if count == 0 {
tell(&mut commands, client, "Your inventory is full.");
return;
}
if let Some(taken) = machine.take(msg.slot, count) {
inventory.add(&taken.item, taken.count, &data);
}
}

View File

@ -1,71 +1,149 @@
//! Headless dedicated server. //! Headless dedicated server.
//! //!
//! ```text //! Configured by a TOML file (default `server.toml`, created on first start) and
//! game-server [--port 5000] [--save saves/server.rsn | --no-save] [--map maps/station.tmx] //! command line options, which override the file for one run. Run with `--help`
//! ``` //! for the options. Commands (`help`, `save`, `config set`, ...) can be typed into
//! the terminal.
use std::{path::PathBuf, time::Duration}; use std::{path::PathBuf, time::Duration};
use bevy::{app::ScheduleRunnerPlugin, log::LogPlugin, prelude::*, state::app::StatesPlugin}; use bevy::{app::ScheduleRunnerPlugin, log::LogPlugin, prelude::*, state::app::StatesPlugin};
use bevy_replicon::prelude::*; use bevy_replicon::prelude::*;
use bevy_replicon_renet::RepliconRenetPlugins; use bevy_replicon_renet::RepliconRenetPlugins;
use clap::Parser;
use game_core::CorePlugin; use game_core::CorePlugin;
use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings}; use game_server::{
GameServerPlugin, NetworkServerPlugin,
config::{ConfigFile, DEFAULT_CONFIG_PATH, ServerConfig},
console::ConsolePlugin,
};
/// Station dedicated server.
#[derive(Parser, Debug)]
#[command(
version,
about,
after_help = "Options override the config file for this run only."
)]
struct Cli {
/// Configuration file, created with defaults if missing.
#[arg(short, long, value_name = "FILE", default_value = DEFAULT_CONFIG_PATH)]
config: PathBuf,
/// UDP port to listen on.
#[arg(short, long)]
port: Option<u16>,
/// Password players need to join (empty for none).
#[arg(long)]
password: Option<String>,
#[arg(long, value_name = "N")]
max_players: Option<usize>,
/// Database URL: sqlite://FILE or postgres://USER:PASSWORD@HOST/DB.
#[arg(long, value_name = "URL")]
database: Option<String>,
/// World name (created if missing) or UUID.
#[arg(long, value_name = "NAME|UUID")]
world: Option<String>,
/// Don't persist the world.
#[arg(long)]
no_save: bool,
/// Tiled map for new worlds, relative to the assets directory.
#[arg(long)]
map: Option<String>,
/// Seconds between autosaves.
#[arg(long, value_name = "SECS")]
autosave: Option<u64>,
#[arg(long)]
motd: Option<String>,
/// Grant admin rights to a player id (repeatable).
#[arg(long, value_name = "ID")]
admin: Vec<String>,
/// Only allow whitelisted players.
#[arg(long)]
whitelist: bool,
/// Print the effective configuration and exit.
#[arg(long)]
print_config: bool,
}
impl Cli {
fn apply(self, config: &mut ServerConfig) {
let Cli {
port,
password,
max_players,
database,
world,
no_save,
map,
autosave,
motd,
admin,
whitelist,
..
} = self;
macro_rules! set {
($($field:ident).+ = $value:expr) => {
if let Some(value) = $value {
config.$($field).+ = value;
}
};
}
set!(port = port);
set!(password = password);
set!(max_players = max_players);
set!(database.url = database);
set!(database.world = world);
set!(map = map);
set!(autosave_secs = autosave);
set!(motd = motd);
if no_save {
config.database.save = false;
}
if whitelist {
config.access.whitelist_enabled = true;
}
config.access.admins.extend(admin);
}
}
fn main() -> AppExit { fn main() -> AppExit {
let mut port = game_core::DEFAULT_PORT; let cli = Cli::parse();
let mut settings = ServerSettings { let (file, mut config, created) = match ConfigFile::load_or_create(&cli.config) {
save_path: Some(PathBuf::from("saves/server.rsn")), Ok(loaded) => loaded,
..Default::default() Err(e) => {
}; eprintln!("error: {e}");
let mut args = std::env::args().skip(1); return AppExit::error();
while let Some(arg) = args.next() {
let mut value = |name: &str| {
args.next()
.unwrap_or_else(|| usage(&format!("{name} needs a value")))
};
match arg.as_str() {
"--port" => {
port = value("--port")
.parse()
.unwrap_or_else(|_| usage("invalid port"))
}
"--save" => settings.save_path = Some(value("--save").into()),
"--no-save" => settings.save_path = None,
"--map" => settings.map = value("--map"),
"-h" | "--help" => usage(""),
other => usage(&format!("unknown argument `{other}`")),
} }
};
let print = cli.print_config;
cli.apply(&mut config);
if print {
print!("{}", toml::to_string(&config).expect("config serializes"));
return AppExit::Success;
} }
App::new() let mut app = App::new();
.add_plugins(( app.add_plugins((
MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64( MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64(
1.0 / 60.0, 1.0 / 60.0,
))), ))),
StatesPlugin, StatesPlugin,
LogPlugin::default(), LogPlugin::default(),
bevy::app::TerminalCtrlCHandlerPlugin, bevy::app::TerminalCtrlCHandlerPlugin,
RepliconPlugins, RepliconPlugins,
RepliconRenetPlugins, RepliconRenetPlugins,
CorePlugin, CorePlugin,
GameServerPlugin { settings }, GameServerPlugin {
NetworkServerPlugin { port }, settings: config.settings(),
)) },
.run() NetworkServerPlugin::public(config),
} ConsolePlugin,
));
fn usage(error: &str) -> ! { if created {
if !error.is_empty() { info!("created config file `{}`", file.path.display());
eprintln!("error: {error}\n"); } else {
info!("loaded config file `{}`", file.path.display());
} }
eprintln!( app.insert_resource(file);
"usage: game-server [--port PORT] [--save FILE | --no-save] [--map MAP]\n\n\ app.run()
--port UDP port to listen on (default {})\n\
--save persistent world file (default saves/server.rsn)\n\
--map Tiled map relative to the assets directory (default {})",
game_core::DEFAULT_PORT,
game_core::DEFAULT_MAP
);
std::process::exit(if error.is_empty() { 0 } else { 2 })
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,58 +1,289 @@
//! Tile-based character movement. //! Free character movement with collision against solid tiles and buildings.
use bevy::prelude::*; use bevy::prelude::*;
use bevy_replicon::prelude::*; use bevy_replicon::prelude::*;
use game_core::{components::*, grid::Dir, protocol::SetMoveDir}; use game_core::{components::*, grid::Dir, protocol::SetMovement};
use crate::{ use crate::{
players::Players, players::{Controller, Players},
world::{WorldLayout, is_blocked}, world::{Level, Levels, Occupancy, free_tile_on, move_character},
}; };
/// Seconds per tile step. /// Walking speed in tiles per second.
pub const MOVE_DELAY: f32 = 0.16; pub const MOVE_SPEED: f32 = 4.5;
/// Half the side of a character's square collision box, in tiles. Less than 0.5,
/// so characters fit through one tile wide gaps.
pub const CHARACTER_RADIUS: f32 = 0.3;
/// How far (in tiles) a character blocked by a corner is pushed sideways
/// towards an opening, so doorways don't need pixel-exact aiming.
const CORNER_ASSIST: f32 = 0.45;
/// Gap left between a character and the obstacle it touches.
const CONTACT_GAP: f32 = 1e-3;
/// Longest movement per collision step, below the box size so no tile is skipped.
const MAX_STEP: f32 = 0.25;
/// Normalized walking direction (`y` down), zero when standing still.
#[derive(Component, Default, Debug)] #[derive(Component, Default, Debug)]
pub struct MoveIntent { pub struct MoveIntent(pub Vec2);
pub dir: Option<Dir>,
pub cooldown: f32,
}
pub fn on_set_move_dir( pub fn on_set_movement(
ev: On<FromClient<SetMoveDir>>, ev: On<FromClient<SetMovement>>,
players: Res<Players>, players: Res<Players>,
mut intents: Query<(&mut MoveIntent, &mut Facing)>, mut intents: Query<(&mut MoveIntent, &mut Facing)>,
) { ) {
let Some(&entity) = players.by_client.get(&ev.client_id) else { let Some(&entity) = players.by_client.get(&ev.client_id) else {
return; return;
}; };
if let Ok((mut intent, mut facing)) = intents.get_mut(entity) { let Ok((mut intent, mut facing)) = intents.get_mut(entity) else {
intent.dir = ev.message.0; return;
if let Some(dir) = intent.dir { };
facing.set_if_neq(Facing(dir)); let input = Vec2::new(ev.message.x.signum() as f32, ev.message.y.signum() as f32);
} intent.0 = input.normalize_or_zero();
if let Some(dir) = facing_for(input, facing.0) {
facing.set_if_neq(Facing(dir));
} }
} }
/// Facing for a movement input. While moving diagonally, the current facing is kept
/// if it is one of the two directions, so it doesn't flip back and forth.
fn facing_for(input: Vec2, current: Dir) -> Option<Dir> {
let horizontal = match input.x {
x if x > 0.0 => Some(Dir::East),
x if x < 0.0 => Some(Dir::West),
_ => None,
};
let vertical = match input.y {
y if y > 0.0 => Some(Dir::South),
y if y < 0.0 => Some(Dir::North),
_ => None,
};
if horizontal == Some(current) || vertical == Some(current) {
return Some(current);
}
horizontal.or(vertical)
}
pub fn move_characters( pub fn move_characters(
time: Res<Time>, time: Res<Time>,
layout: Option<Res<WorldLayout>>, levels: Res<Levels>,
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>, buildings: Query<(&Level, &GridPos, &BuildingState), (With<Building>, Without<Character>)>,
mut characters: Query<(&mut MoveIntent, &mut GridPos, &mut Facing), With<Character>>, mut characters: Query<(&Level, &MoveIntent, &mut Position), With<Character>>,
) { ) {
let Some(layout) = layout else { return }; let occupancy = Occupancy::by_level(buildings.iter());
let empty = Occupancy::default();
let dt = time.delta_secs(); let dt = time.delta_secs();
for (mut intent, mut pos, mut facing) in &mut characters { for (level, intent, mut pos) in &mut characters {
intent.cooldown = (intent.cooldown - dt).max(0.0); if intent.0 == Vec2::ZERO {
let Some(dir) = intent.dir else { continue };
if intent.cooldown > 0.0 {
continue; continue;
} }
facing.set_if_neq(Facing(dir)); let Some(layout) = levels.layout(level) else {
let next = pos.step(dir); continue;
if !is_blocked(next, &layout.0, buildings.iter()) { };
*pos = next; let occupancy = occupancy.get(level.0.as_str()).unwrap_or(&empty);
intent.cooldown = MOVE_DELAY; let blocked = |tile: GridPos| occupancy.is_blocked(tile, layout);
let next = slide(pos.0, intent.0 * MOVE_SPEED * dt, &blocked);
if next != pos.0 {
pos.0 = next;
} }
} }
} }
/// Keeps [`GridPos`] at the tile under each character, for tile-based interactions.
pub fn sync_tiles(mut characters: Query<(&Position, &mut GridPos), Changed<Position>>) {
for (pos, mut tile) in &mut characters {
tile.set_if_neq(pos.tile());
}
}
/// The tile a character's teleport triggers were last checked on.
#[derive(Component, Clone, Debug, PartialEq, Eq)]
pub struct LastTile(pub Level, pub GridPos);
/// Sends characters entering a teleport trigger's tile to its destination. A
/// character that appears on a trigger (login, arrival) has to step off first.
pub fn use_teleports(
mut commands: Commands,
levels: Res<Levels>,
buildings: Query<(&Level, &GridPos, &BuildingState), (With<Building>, Without<Character>)>,
characters: Query<
(
Entity,
&Level,
&GridPos,
Option<&LastTile>,
Option<&Controller>,
),
With<Character>,
>,
) {
for (entity, level, tile, last, controller) in &characters {
let current = LastTile(level.clone(), *tile);
if last == Some(&current) {
continue;
}
let destination = last
.is_some()
.then(|| levels.teleports.get(&(level.0.clone(), *tile)))
.flatten();
let Some(destination) = destination else {
commands.entity(entity).insert(current);
continue;
};
let target = Level(destination.level.clone());
let pos = free_tile_on(&target, destination.pos, &levels, &buildings);
move_character(&mut commands, entity, controller.map(|c| c.0), target, pos);
}
}
/// Tiles overlapped by a character's collision box at `pos`.
fn box_tiles(pos: Vec2) -> impl Iterator<Item = GridPos> {
let min = (pos - CHARACTER_RADIUS).floor().as_ivec2();
let max = (pos + CHARACTER_RADIUS).floor().as_ivec2();
(min.y..=max.y).flat_map(move |y| (min.x..=max.x).map(move |x| GridPos::new(x, y)))
}
/// Whether a character at `pos` overlaps `tile`.
pub fn overlaps_tile(pos: Vec2, tile: GridPos) -> bool {
box_tiles(pos).any(|t| t == tile)
}
/// Whether moving from `from` to `to` makes the box overlap a blocked tile. Tiles it
/// already overlaps don't count, so characters can walk out of a door that closed on them.
fn enters_blocked(from: Vec2, to: Vec2, blocked: &impl Fn(GridPos) -> bool) -> bool {
let old: Vec<GridPos> = box_tiles(from).collect();
box_tiles(to).any(|tile| !old.contains(&tile) && blocked(tile))
}
/// Moves a character's box by `delta`, sliding along obstacles and around corners.
pub fn slide(mut pos: Vec2, delta: Vec2, blocked: &impl Fn(GridPos) -> bool) -> Vec2 {
let steps = (delta.abs().max_element() / MAX_STEP).ceil().max(1.0);
let step = delta / steps;
for _ in 0..steps as u32 {
let mut progressed = [false; 2];
for axis in 0..2 {
if step[axis] == 0.0 {
continue;
}
let mut next = pos;
next[axis] += step[axis];
if enters_blocked(pos, next, blocked) {
// Move up to the obstacle instead.
next[axis] = if step[axis] > 0.0 {
(next[axis] + CHARACTER_RADIUS).floor() - CHARACTER_RADIUS - CONTACT_GAP
} else {
(next[axis] - CHARACTER_RADIUS).floor() + 1.0 + CHARACTER_RADIUS + CONTACT_GAP
};
if (next[axis] - pos[axis]) * step[axis] <= 0.0
|| enters_blocked(pos, next, blocked)
{
continue;
}
}
progressed[axis] = next[axis] != pos[axis];
pos = next;
}
for axis in 0..2 {
let other = 1 - axis;
if !progressed[axis] && step[axis] != 0.0 && step[other] == 0.0 {
pos = assist(pos, axis, step[axis], blocked);
}
}
}
pos
}
/// Shifts a character blocked along `axis` sideways towards a nearby opening.
fn assist(pos: Vec2, axis: usize, step: f32, blocked: &impl Fn(GridPos) -> bool) -> Vec2 {
let other = 1 - axis;
let row = pos[other].floor();
let mut lanes = [row - 0.5, row + 0.5, row + 1.5];
lanes.sort_by(|a, b| (a - pos[other]).abs().total_cmp(&(b - pos[other]).abs()));
for lane in lanes {
let offset = lane - pos[other];
if offset.abs() > CORNER_ASSIST {
continue;
}
// Centered in the lane, would the character get ahead?
let mut probe = pos;
probe[other] = lane;
let mut ahead = probe;
ahead[axis] += step.signum() * (CHARACTER_RADIUS + CONTACT_GAP * 2.0).min(0.5);
if box_tiles(ahead).any(blocked) {
continue;
}
let mut next = pos;
next[other] += offset.clamp(-step.abs(), step.abs());
if !enters_blocked(pos, next, blocked) {
return next;
}
}
pos
}
#[cfg(test)]
mod tests {
use super::*;
/// A wall column at x = 2 with a gap at y = 1.
fn wall(tile: GridPos) -> bool {
tile.x == 2 && tile.y != 1
}
fn walk(mut pos: Vec2, dir: Vec2, seconds: f32) -> Vec2 {
let dt = 1.0 / 64.0;
for _ in 0..(seconds / dt) as u32 {
pos = slide(pos, dir.normalize() * MOVE_SPEED * dt, &wall);
}
pos
}
#[test]
fn moves_freely_between_tiles() {
let pos = slide(Vec2::new(0.5, 5.5), Vec2::new(0.3, 0.2), &wall);
assert!((pos - Vec2::new(0.8, 5.7)).length() < 1e-5, "{pos}");
}
#[test]
fn stops_at_walls_and_slides_along_them() {
let pos = walk(Vec2::new(0.5, 5.5), Vec2::new(1.0, 0.0), 1.0);
assert!((pos.x - (2.0 - CHARACTER_RADIUS)).abs() < 0.01, "{pos}");
assert_eq!(pos.y, 5.5);
let pos = walk(Vec2::new(0.5, 5.5), Vec2::new(1.0, 1.0), 1.0);
assert!(
pos.x < 2.0 - CHARACTER_RADIUS + 0.01 && pos.y > 7.0,
"{pos}"
);
}
#[test]
fn corners_guide_into_openings() {
// Slightly below the gap's center: the corner pushes the character into it.
let pos = walk(Vec2::new(0.5, 1.8), Vec2::new(1.0, 0.0), 1.5);
assert!(pos.x > 3.0, "{pos}");
assert_eq!(box_tiles(pos).map(|t| t.y).max(), Some(1), "{pos}");
assert_eq!(box_tiles(pos).map(|t| t.y).min(), Some(1), "{pos}");
// Far from the gap the wall just stops the character.
let pos = walk(Vec2::new(0.5, 3.5), Vec2::new(1.0, 0.0), 1.5);
assert!(pos.x < 2.0 && pos.y == 3.5, "{pos}");
}
#[test]
fn can_leave_blocked_tiles() {
let pos = walk(Vec2::new(2.5, 3.5), Vec2::new(-1.0, 0.0), 0.5);
assert!(pos.x < 2.0, "{pos}");
}
#[test]
fn facing_keeps_diagonal_component() {
assert_eq!(
facing_for(Vec2::new(1.0, 1.0), Dir::South),
Some(Dir::South)
);
assert_eq!(facing_for(Vec2::new(1.0, 1.0), Dir::North), Some(Dir::East));
assert_eq!(
facing_for(Vec2::new(0.0, -1.0), Dir::East),
Some(Dir::North)
);
assert_eq!(facing_for(Vec2::ZERO, Dir::East), None);
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -5,7 +5,7 @@ use game_core::components::*;
use crate::{ use crate::{
players::{Controller, tell}, players::{Controller, tell},
world::{SpawnPoint, spawn_ground_item}, world::{Level, Levels, move_character, spawn_ground_item},
}; };
/// Nutrition lost per second (a full stomach lasts about 25 minutes). /// Nutrition lost per second (a full stomach lasts about 25 minutes).
@ -26,11 +26,13 @@ pub fn tick_survival(
mut commands: Commands, mut commands: Commands,
time: Res<Time>, time: Res<Time>,
mut timer: ResMut<SurvivalTimer>, mut timer: ResMut<SurvivalTimer>,
spawn: Option<Res<SpawnPoint>>, levels: Res<Levels>,
mut characters: Query<( mut characters: Query<(
Entity,
&Character, &Character,
&Controller, &Controller,
&mut GridPos, &Level,
&GridPos,
&mut Vitals, &mut Vitals,
&mut Inventory, &mut Inventory,
)>, )>,
@ -39,7 +41,7 @@ pub fn tick_survival(
return; return;
} }
let dt = timer.0.duration().as_secs_f32(); let dt = timer.0.duration().as_secs_f32();
for (character, controller, mut pos, mut vitals, mut inventory) in &mut characters { for (entity, character, controller, level, pos, mut vitals, mut inventory) in &mut characters {
vitals.nutrition = (vitals.nutrition - HUNGER_RATE * dt).max(0.0); vitals.nutrition = (vitals.nutrition - HUNGER_RATE * dt).max(0.0);
if vitals.nutrition <= 0.0 { if vitals.nutrition <= 0.0 {
vitals.heal(-STARVATION_DAMAGE * dt); vitals.heal(-STARVATION_DAMAGE * dt);
@ -53,11 +55,16 @@ pub fn tick_survival(
// Death: drop everything where you fell and wake up at the spawn point. // Death: drop everything where you fell and wake up at the spawn point.
info!("{} died", character.name); info!("{} died", character.name);
for stack in std::mem::take(&mut inventory.slots) { for stack in std::mem::take(&mut inventory.slots) {
spawn_ground_item(&mut commands, stack, *pos); spawn_ground_item(&mut commands, stack, level, *pos);
}
if let Some(spawn) = &spawn {
*pos = spawn.0;
} }
let (spawn_level, spawn) = levels.spawn();
move_character(
&mut commands,
entity,
Some(controller.0),
spawn_level,
spawn,
);
*vitals = Vitals { *vitals = Vitals {
nutrition: 50.0, nutrition: 50.0,
..default() ..default()

View File

@ -1,102 +1,238 @@
//! World setup: Tiled map, spawning buildings and items, and tile occupancy. //! World setup: Tiled maps (levels), spawning buildings and items, and tile occupancy.
use std::{collections::BTreeMap, sync::Arc}; use std::{
collections::{BTreeMap, HashMap, HashSet},
path::Path,
sync::Arc,
};
use bevy::prelude::*; use bevy::prelude::*;
use bevy_replicon::prelude::*; use bevy_replicon::prelude::*;
use game_core::{ use game_core::{
GameData, GameData,
components::{Building, BuildingState, Character, GridPos, GroundItem, ItemStack, WorldMap}, components::{
Building, BuildingState, Character, GridPos, GroundItem, ItemStack, Machine, Position,
WorldMap,
},
data::BuildingLayer,
map::MapLayout, map::MapLayout,
}; };
use crate::{ServerSettings, persistence}; use crate::{ServerSettings, map_io, movement::LastTile, persistence};
/// The loaded map, used for collision and spawn points. pub use game_core::components::{Level, ViewLevel};
#[derive(Resource, Clone)]
pub struct WorldLayout(pub Arc<MapLayout>);
#[derive(Resource, Clone, Copy)] /// Where a teleport trigger sends characters.
pub struct SpawnPoint(pub GridPos); #[derive(Clone, Debug, PartialEq, Eq)]
pub struct Destination {
pub level: String,
pub pos: GridPos,
}
/// All levels of the world. Every level is simulated, whether players are on it or not.
#[derive(Resource, Clone, Debug)]
pub struct Levels {
/// Id of the level new characters spawn on.
pub main: String,
pub layouts: BTreeMap<String, Arc<MapLayout>>,
pub teleports: HashMap<(String, GridPos), Destination>,
}
impl Levels {
/// Validates every teleport trigger.
pub fn new(
main: String,
main_layout: MapLayout,
others: BTreeMap<String, MapLayout>,
) -> Result<Self, String> {
let mut layouts: BTreeMap<String, Arc<MapLayout>> = others
.into_iter()
.map(|(id, layout)| (id, Arc::new(layout)))
.collect();
layouts.insert(main.clone(), Arc::new(main_layout));
let mut teleports = HashMap::new();
for (id, layout) in &layouts {
for object in layout.objects_of_class("teleport") {
let invalid = |message: String| {
format!(
"level `{id}`, teleport {} (`{}`): {message}",
object.id, object.name
)
};
let level = map_io::teleport_level(id, object).map_err(invalid)?;
let target_layout = layouts
.get(&level)
.ok_or_else(|| invalid(format!("unknown map `{level}`")))?;
let target = object
.properties
.get("target")
.filter(|t| !t.is_empty())
.ok_or_else(|| invalid("missing `target`".into()))?;
let pos = target_layout
.objects
.iter()
.find(|o| o.name == *target)
.ok_or_else(|| invalid(format!("no object named `{target}` in `{level}`")))?
.pos;
teleports.insert((id.clone(), object.pos), Destination { level, pos });
}
}
Ok(Self {
main,
layouts,
teleports,
})
}
pub fn layout(&self, level: &Level) -> Option<&MapLayout> {
self.layouts.get(&level.0).map(Arc::as_ref)
}
pub fn main_level(&self) -> Level {
Level(self.main.clone())
}
pub fn main_layout(&self) -> &MapLayout {
&self.layouts[&self.main]
}
/// Where new and respawning characters appear.
pub fn spawn(&self) -> (Level, GridPos) {
(self.main_level(), map_io::spawn_point(self.main_layout()))
}
/// Saves store `None` for the main level, so it can be renamed.
pub fn from_saved(&self, saved: Option<&str>) -> Level {
Level(saved.unwrap_or(&self.main).to_owned())
}
pub fn to_saved(&self, level: &Level) -> Option<String> {
(level.0 != self.main).then(|| level.0.clone())
}
}
/// Level id of a map path: its file name.
pub fn main_level_id(map: &str) -> String {
Path::new(map)
.file_name()
.map_or_else(|| map.to_owned(), |n| n.to_string_lossy().into_owned())
}
/// Path of a level's map, given the main map's path.
pub fn level_map_path(main_map: &str, level: &str) -> String {
Path::new(main_map)
.parent()
.unwrap_or(Path::new(""))
.join(level)
.to_string_lossy()
.replace('\\', "/")
}
pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) { pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) {
let assets = game_core::assets_dir(); let assets = game_core::assets_dir();
let layout = MapLayout::load(&assets, &settings.map).unwrap_or_else(|e| panic!("{e}")); let main = main_level_id(&settings.map);
let spawn = layout let load = |level: &str| {
.objects_of_class("spawn") MapLayout::load(&assets, &level_map_path(&settings.map, level))
.next() .map_err(map_io::MapIoError::from)
.map(|o| o.pos) };
.unwrap_or_else(|| { let seed = || map_io::decode_world(&main, &data, load);
warn!("map has no `spawn` object, using the map center"); let mut save = None;
GridPos::new(layout.width as i32 / 2, layout.height as i32 / 2) if let Some(storage) = &settings.storage {
}); let (storage, loaded) = match persistence::Storage::open(storage, &settings.map, || {
commands.insert_resource(SpawnPoint(spawn)); seed().map_err(|e| e.to_string())
}) {
commands.spawn(( Ok(opened) => opened,
Replicated, Err(e) => {
WorldMap { return crate::fail(
path: settings.map.clone(), &mut commands,
}, format!("failed to open the world database: {e}"),
)); );
}
match persistence::load(settings.save_path.as_deref()) { };
Some(save) => { info!("world `{}` ({})", storage.world.name, storage.world.id);
let mut loaded = loaded;
if storage.world.map != settings.map {
warn!(
"world was created on map `{}`, now running `{}`",
storage.world.map, settings.map
);
map_io::rename_main_level(&mut loaded, &main_level_id(&storage.world.map), &main);
}
save = Some(loaded);
commands.insert_resource(storage);
}
let mut save = match save {
Some(mut save) => {
info!( info!(
"restoring persistent world: {} buildings, {} items, {} characters", "restoring persistent world: {} levels, {} buildings, {} items, {} characters",
save.levels.len() + 1,
save.buildings.len(), save.buildings.len(),
save.items.len(), save.items.len(),
save.characters.len() save.characters.len()
); );
if save.map != settings.map { if save.layout.is_none() {
warn!( match MapLayout::load_legacy_terrain(&assets, &settings.map) {
"save was made on map `{}`, now running `{}`", Ok(layout) => save.layout = Some(layout),
save.map, settings.map Err(e) => return crate::fail(&mut commands, e.to_string()),
); }
} }
persistence::restore(&mut commands, &data, save); // Levels referenced since the world was saved start from their map.
if let Err(e) = map_io::add_referenced_levels(&mut save, &main, &data, load) {
return crate::fail(&mut commands, e.to_string());
}
save
} }
None => { None => {
info!("creating a new world from `{}`", settings.map); info!("creating a new world from `{}`", settings.map);
spawn_map_objects(&mut commands, &data, &layout); match seed() {
Ok(save) => save,
Err(e) => return crate::fail(&mut commands, e.to_string()),
}
} }
} };
commands.insert_resource(WorldLayout(Arc::new(layout))); let layout = save.layout.take().expect("world has terrain");
let levels = match Levels::new(main, layout, std::mem::take(&mut save.levels)) {
Ok(levels) => levels,
Err(e) => return crate::fail(&mut commands, e),
};
persistence::restore(&mut commands, &data, &levels, save);
install_levels(&mut commands, levels);
} }
fn spawn_map_objects(commands: &mut Commands, data: &GameData, layout: &MapLayout) { /// Spawns the replicated terrain of every level and makes `levels` current.
for obj in &layout.objects { pub fn install_levels(commands: &mut Commands, levels: Levels) {
let proto = obj.properties.get("proto"); for (id, layout) in &levels.layouts {
match (obj.class.as_str(), proto) { commands.spawn((
("spawn", _) => {} Replicated,
("building", Some(proto)) => { Level(id.clone()),
// Integer custom properties override the building's script variables. WorldMap {
let vars = obj path: id.clone(),
.properties layout: (**layout).clone(),
.iter() },
.filter_map(|(k, v)| Some((k.clone(), v.parse::<i64>().ok()?))) ));
.collect();
if spawn_building(commands, data, proto, obj.pos, vars).is_none() {
warn!("map object {}: unknown building `{proto}`", obj.id);
}
}
("item", Some(proto)) => {
let count = obj
.properties
.get("count")
.and_then(|c| c.parse().ok())
.unwrap_or(1);
if data.items.contains_key(proto) {
spawn_ground_item(commands, ItemStack::new(proto, count), obj.pos);
} else {
warn!("map object {}: unknown item `{proto}`", obj.id);
}
}
(class, _) => warn!(
"map object {} (`{}`): unsupported class `{class}` or missing `proto` property",
obj.id, obj.name
),
}
} }
commands.insert_resource(levels);
}
/// Moves a character to a tile, possibly on another level, and makes its
/// client follow. Teleport triggers on the arrival tile only fire after the
/// character stepped off it.
pub fn move_character(
commands: &mut Commands,
entity: Entity,
client: Option<ClientId>,
level: Level,
tile: GridPos,
) {
if let Some(ClientId::Client(client)) = client {
commands.entity(client).insert(ViewLevel(level.0.clone()));
}
commands.entity(entity).insert((
LastTile(level.clone(), tile),
level,
Position::center_of(tile),
tile,
));
} }
/// Spawns a building with its default state. `vars` override the prototype's variables. /// Spawns a building with its default state. `vars` override the prototype's variables.
@ -104,40 +240,85 @@ pub fn spawn_building(
commands: &mut Commands, commands: &mut Commands,
data: &GameData, data: &GameData,
proto: &str, proto: &str,
level: &Level,
pos: GridPos, pos: GridPos,
vars: BTreeMap<String, i64>, vars: BTreeMap<String, i64>,
) -> Option<Entity> { ) -> Option<Entity> {
let def = data.buildings.get(proto)?; let def = data.buildings.get(proto)?;
let mut state = BuildingState { let mut state = BuildingState::new(def);
solid: def.solid,
variant: None,
health: def.max_health,
vars: def.vars.clone(),
};
state.vars.extend(vars); state.vars.extend(vars);
Some(spawn_building_with_state(commands, proto, pos, state)) let machine = data.machines.get(proto).map(Machine::new);
Some(spawn_building_with_state(
commands, proto, level, pos, state, machine,
))
} }
pub fn spawn_building_with_state( pub fn spawn_building_with_state(
commands: &mut Commands, commands: &mut Commands,
proto: &str, proto: &str,
level: &Level,
pos: GridPos, pos: GridPos,
state: BuildingState, state: BuildingState,
machine: Option<Machine>,
) -> Entity {
let mut entity = commands.spawn((
Replicated,
Building {
proto: proto.to_owned(),
},
level.clone(),
pos,
state,
));
if let Some(machine) = machine {
entity.insert(machine);
}
entity.id()
}
pub fn spawn_ground_item(
commands: &mut Commands,
stack: ItemStack,
level: &Level,
pos: GridPos,
) -> Entity { ) -> Entity {
commands commands
.spawn(( .spawn((Replicated, GroundItem(stack), level.clone(), pos))
Replicated,
Building {
proto: proto.to_owned(),
},
pos,
state,
))
.id() .id()
} }
pub fn spawn_ground_item(commands: &mut Commands, stack: ItemStack, pos: GridPos) -> Entity { /// Solid and floor buildings of one level.
commands.spawn((Replicated, GroundItem(stack), pos)).id() #[derive(Default)]
pub struct Occupancy {
solid: HashSet<GridPos>,
floors: HashSet<GridPos>,
}
impl Occupancy {
pub fn add(&mut self, pos: GridPos, state: &BuildingState) {
if state.solid {
self.solid.insert(pos);
}
if state.layer == BuildingLayer::Floor {
self.floors.insert(pos);
}
}
/// Occupancy of every level.
pub fn by_level<'a>(
buildings: impl IntoIterator<Item = (&'a Level, &'a GridPos, &'a BuildingState)>,
) -> HashMap<&'a str, Self> {
let mut levels: HashMap<&str, Self> = HashMap::new();
for (level, pos, state) in buildings {
levels.entry(&level.0).or_default().add(*pos, state);
}
levels
}
/// Whether a character can not enter `pos`.
pub fn is_blocked(&self, pos: GridPos, layout: &MapLayout) -> bool {
self.solid.contains(&pos) || layout.is_solid_with_floor(pos, self.floors.contains(&pos))
}
} }
/// Whether a character can not enter `pos`. /// Whether a character can not enter `pos`.
@ -146,15 +327,20 @@ pub fn is_blocked<'a>(
layout: &MapLayout, layout: &MapLayout,
buildings: impl IntoIterator<Item = (&'a GridPos, &'a BuildingState)>, buildings: impl IntoIterator<Item = (&'a GridPos, &'a BuildingState)>,
) -> bool { ) -> bool {
layout.is_solid(pos) || buildings.into_iter().any(|(p, s)| *p == pos && s.solid) let mut has_floor = false;
for (p, state) in buildings {
if *p == pos {
if state.solid {
return true;
}
has_floor |= state.layer == BuildingLayer::Floor;
}
}
layout.is_solid_with_floor(pos, has_floor)
} }
/// Finds the closest free tile around `start` (spiral search), for spawning characters. /// Finds the closest free tile around `start` (spiral search), for placing characters.
pub fn find_free_tile( pub fn find_free_tile(start: GridPos, blocked: impl Fn(GridPos) -> bool) -> GridPos {
start: GridPos,
layout: &MapLayout,
buildings: &Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
) -> GridPos {
for radius in 0..16i32 { for radius in 0..16i32 {
for dy in -radius..=radius { for dy in -radius..=radius {
for dx in -radius..=radius { for dx in -radius..=radius {
@ -162,7 +348,7 @@ pub fn find_free_tile(
continue; continue;
} }
let pos = GridPos::new(start.x + dx, start.y + dy); let pos = GridPos::new(start.x + dx, start.y + dy);
if !is_blocked(pos, layout, buildings.iter()) { if !blocked(pos) {
return pos; return pos;
} }
} }
@ -170,3 +356,20 @@ pub fn find_free_tile(
} }
start start
} }
/// Free tile near `start` on `level`, for placing characters.
pub fn free_tile_on(
level: &Level,
start: GridPos,
levels: &Levels,
buildings: &Query<(&Level, &GridPos, &BuildingState), (With<Building>, Without<Character>)>,
) -> GridPos {
let Some(layout) = levels.layout(level) else {
return start;
};
let mut occupancy = Occupancy::default();
for (_, pos, state) in buildings.iter().filter(|(l, _, _)| *l == level) {
occupancy.add(*pos, state);
}
find_free_tile(start, |pos| occupancy.is_blocked(pos, layout))
}

File diff suppressed because it is too large Load Diff

142
docs/ss13-feature-gaps.md Normal file
View File

@ -0,0 +1,142 @@
# SS13 Feature Gaps
This document is an inventory of major systems and smaller features that are
not present, or are only represented in a basic form, in Station. It is based
on the current source code and shipped content, not a promise or schedule for
implementation. Space Station 13 differs substantially between codebases and
servers, so this is a practical comparison with common SS13 gameplay rather
than an exhaustive specification.
Station currently provides persistent worlds rather than rounds; free
movement; inventory and item use; crafting, construction and gathering; skills;
simple hunger and health; basic machines; chat; multiplayer; admin controls;
and world/character persistence. Those systems provide a foundation, but do
not yet form the crew-driven, round-based station simulation typical of SS13.
## Major Systems
These are large gameplay pillars. Many depend on several other systems and
would need coordinated data, server simulation, UI, networking and content.
- [ ] **Round lifecycle and lobby:** Lobby, ready-up, round start and end,
round timers, end-of-round summary, and a clean reset or new-round flow.
- Not needed, as I want to go for long running servers instead of round based
- [ ] **Crew roles and departments:** Job selection or assignment, department
rosters, role-specific equipment and responsibilities, access levels, and
command structure. Current skills gate a few recipes; they are not jobs.
- [ ] **Antagonists, objectives and events:** Hidden roles, role objectives,
round-specific threats, dynamic events, and systems for declaring or resolving
round outcomes.
- Not needed for now
- [ ] **Combat and security:** Character-versus-character combat, weapons,
projectiles, armor, restraints, arrest and evidence workflows, and security
tools. Current damage is limited to basic survival/script effects rather than
a combat model.
- [ ] **Atmospherics and environmental simulation:** Gas mixtures, pressure,
oxygen and breathing, temperature, fire, vent/scrubber networks, and
environmental hazards.
- [ ] **Power and electrical engineering:** Power generation and distribution,
cables, breakers, consumers, power outages, and electrical hazards.
- [ ] **Medical simulation:** Damage types and body zones, injuries, surgery,
diseases, status effects, diagnosis, medical equipment, and treatment
workflows. Current health/nutrition and healing items are intentionally
simple.
- [ ] **Chemistry and reagents:** Reagent containers, mixtures, reactions,
chemical effects, toxins, and chemistry equipment. Current crafting consumes
discrete item ingredients rather than simulating reagents.
- [ ] **Research and technology progression:** Research points, unlock trees,
department research workflows, and fabrication tied to discovered technology.
Current crafting uses direct recipes and skill requirements.
- [ ] **Station infrastructure and machinery:** Interconnected machinery and
control systems, including powered/access-controlled airlocks, alarms,
cameras, consoles, telecoms, and other department-specific equipment. Current
doors are manually toggled and machines are generic timed processors.
- [ ] **Communications and evacuation:** Radio channels, headsets, intercoms,
station-wide alerts, emergency communications, shuttle request/control,
evacuation, and escape outcomes. Current player chat is global.
- Communication is needed, evacuation right now isn't
- [ ] **Character roles and species simulation:** Species traits, body structure,
species-specific needs, equipment restrictions, and distinctive capabilities.
- [ ] **NPCs and autonomous station systems:** NPC behavior, station AI, robots
or cyborgs, pets, and other non-player entities with useful behavior.
- [ ] **Station-scale content and workflows:** Full department layouts,
interactive props, specialized tools, jobs' equipment, and multi-step
objectives that give the above systems a playable station context. Current
map and item/building/recipe data are a small prototype set.
## Minor Systems And Features
These are smaller in scope than the pillars above, though some will rely on a
major system before they become useful.
- [ ] **Roleplay interactions:** Emotes, visible speech bubbles, character
examine/look descriptions, and contextual examine text for items and
buildings.
- [ ] **Equipment and clothing:** Wearable slots, uniforms, storage containers,
backpacks, hand-held equipment, and item-specific interaction rules. Current
inventory is a simple slot list without equipment slots.
- [ ] **Richer status and damage feedback:** Temporary status effects,
damage indicators, incapacitation states, and clearer causes of health loss.
- [ ] **Access credentials:** ID cards, access levels, door authorization,
access-denied feedback, and role-based access management.
- [ ] **World interaction details:** Pulling and pushing objects, dragging
incapacitated characters, furniture placement, storage access, and more
contextual interactions.
- [ ] **Construction and repair details:** Building damage and repair, tools
with distinct uses, dismantling steps, and clearer construction feedback.
- [ ] **Item affordances:** Item examination, splitting and merging stacks,
container contents, transfer between containers, and item-specific
interactions.
- [ ] **Personal communication:** Local speech, radio device/channel selection,
department channels, and communication feedback. The current chat is global.
- [ ] **Character appearance and identity:** Configurable appearance, pronouns,
character descriptions, and richer identity/role presentation.
- [ ] **Sensory feedback:** Sound effects, ambient station audio, visual status
cues, and alerts for nearby events.
- [ ] **Player onboarding:** In-game controls and interaction hints, role/job
guidance, and contextual help for station equipment.
- [ ] **Administration and moderation tools:** In-game observation, teleport or
intervention tools, logs and audit trails, and richer player reports. The
server already has basic admin, ban, kick, whitelist, and configuration
controls.
- [ ] **Server discovery and reconnect experience:** Server browser, connection
recovery, and clearer handling of disconnects. Current play supports direct
address joining.
- Not needed for now
- [ ] **Content breadth and polish:** More items, recipes, machines, maps,
station props, sprite variety, animations, sound, and UI for the gameplay
systems added over time.
## Existing Systems That Are Only Partial
- **Survival:** Hunger and health exist, but there is no breathing, fatigue,
temperature, toxin, or broad status-effect simulation.
- **Medicine:** Healing exists, but there is no diagnosis, body-part treatment,
surgery, disease, or reagent-based care.
- **Engineering:** Construction and deconstruction exist, but there is no power,
atmospherics, object damage/repair loop, or linked engineering machinery.
- **Machines:** Input/output slots and timed recipes exist, but machines are not
connected to power, research, access control, or station-wide networks.
- **Social play:** Multiplayer and global chat exist, but there are no local
speech, radios, role channels, or round objectives to structure crew play.
- **Scripting and modding:** Data files and Rune hooks support content
customization, but the current hooks do not provide a broad event, AI, or
multi-entity simulation framework.
## Suggested Development Order
This is a dependency-oriented sequence, not a commitment:
1. Decide whether Station remains a persistent-world sandbox or moves toward a
round-based loop; that choice affects persistence, roles, objectives and
server lifecycle.
- I want to stay persistent world sandbox style. The community should be able to build custom maps (scenarios) that can have goals
2. Establish character state and interaction foundations: damage/status model,
equipment, access, and robust item/container interactions.
3. Add station infrastructure primitives such as power, atmospherics and
machinery networks before building complex department equipment on top.
4. Build role workflows and communications, then round objectives, antagonists,
emergencies and evacuation around those foundations.
5. Expand departments, content, audiovisual feedback and administration tools
alongside playtesting and balancing.
- There should also be tools to generate and modify content, so that communities can make custom content in a simple way

View File

@ -77,12 +77,24 @@ def gen_tileset():
for i, (_, color, _, pattern) in enumerate(TILES): for i, (_, color, _, pattern) in enumerate(TILES):
draw_tile(d, (i % COLS) * T, (i // COLS) * T, color, pattern) draw_tile(d, (i % COLS) * T, (i // COLS) * T, color, pattern)
save(img, "tilesets/station.png") save(img, "tilesets/station.png")
for i, (name, _, _, _) in enumerate(TILES):
if name in {"floor", "dark_floor", "plating", "window"}:
x, y = (i % COLS) * T, (i // COLS) * T
save(img.crop((x, y, x + T, y + T)), f"sprites/buildings/{name}.png")
tiles_xml = [] tiles_xml = []
for i, (name, _, solid, _) in enumerate(TILES): for i, (name, _, solid, _) in enumerate(TILES):
props = f'<property name="name" value="{name}"/>' props = f'<property name="name" value="{name}"/>'
if solid: if solid:
props += '<property name="solid" type="bool" value="true"/>' props += '<property name="solid" type="bool" value="true"/>'
if name in {"floor", "dark_floor", "plating", "wall", "window"}:
props += f'<property name="proto" value="{name}"/>'
layer = "floor" if name in {"floor", "dark_floor", "plating"} else "structure"
props += f'<property name="state.layer" value="&quot;{layer}&quot;"/>'
props += f'<property name="state.health" type="int" value="{300 if name == "wall" else 100}"/>'
props += f'<property name="state.solid" type="bool" value="{str(solid).lower()}"/>'
props += '<property name="state.variant" value="null"/>'
props += '<property name="state.vars" value="{}"/>'
tiles_xml.append(f' <tile id="{i}">\n <properties>{props}</properties>\n </tile>') tiles_xml.append(f' <tile id="{i}">\n <properties>{props}</properties>\n </tile>')
tsx = f"""<?xml version="1.0" encoding="UTF-8"?> tsx = f"""<?xml version="1.0" encoding="UTF-8"?>
<tileset version="1.10" tiledversion="1.10.2" name="station" tilewidth="{T}" tileheight="{T}" tilecount="{len(TILES)}" columns="{COLS}"> <tileset version="1.10" tiledversion="1.10.2" name="station" tilewidth="{T}" tileheight="{T}" tilecount="{len(TILES)}" columns="{COLS}">
@ -174,6 +186,13 @@ def gen_sprites():
sprite("sprites/buildings/furnace_lit.png", furnace_body, sprite("sprites/buildings/furnace_lit.png", furnace_body,
lambda d: d.rectangle([9, 15, 22, 25], fill=(255, 230, 80), outline=(255, 120, 0)), lambda d: d.rectangle([9, 15, 22, 25], fill=(255, 230, 80), outline=(255, 120, 0)),
lambda d: d.polygon([(11, 15), (16, 7), (20, 15)], fill=(255, 160, 40))) lambda d: d.polygon([(11, 15), (16, 7), (20, 15)], fill=(255, 160, 40)))
assembler_body = lambda d: d.rectangle([2, 4, 29, 29], fill=(70, 110, 120), outline=(30, 50, 60), width=2)
sprite("sprites/buildings/assembler.png", assembler_body,
lambda d: d.rectangle([8, 10, 23, 20], fill=(30, 40, 45)),
lambda d: d.rectangle([12, 23, 19, 26], fill=(40, 60, 60)))
sprite("sprites/buildings/assembler_active.png", assembler_body,
lambda d: d.rectangle([8, 10, 23, 20], fill=(120, 230, 170)),
lambda d: d.rectangle([12, 23, 19, 26], fill=(90, 255, 120)))
sprite("sprites/buildings/table.png", sprite("sprites/buildings/table.png",
lambda d: d.rectangle([2, 8, 29, 16], fill=(170, 170, 180), outline=(80, 80, 90)), lambda d: d.rectangle([2, 8, 29, 16], fill=(170, 170, 180), outline=(80, 80, 90)),
lambda d: d.rectangle([4, 16, 6, 28], fill=(120, 120, 130)), lambda d: d.rectangle([4, 16, 6, 28], fill=(120, 120, 130)),
@ -190,6 +209,22 @@ def gen_sprites():
lambda d: d.ellipse([10, 14, 15, 19], fill=(180, 110, 90)), lambda d: d.ellipse([10, 14, 15, 19], fill=(180, 110, 90)),
lambda d: d.ellipse([18, 18, 23, 23], fill=(180, 110, 90))) lambda d: d.ellipse([18, 18, 23, 23], fill=(180, 110, 90)))
def stairs(down):
def f(d):
d.rectangle([0, 0, T - 1, T - 1], fill=(60, 55, 50), outline=(30, 28, 25), width=2)
for i in range(4):
# Steps shrink and darken towards the far end of the flight.
inset = 3 + i * 3 if down else 12 - i * 3
shade = 150 - i * 25 if down else 75 + i * 25
y = 3 + i * 7
d.rectangle([inset, y, T - 1 - inset, y + 5], fill=(shade, shade - 10, shade - 25))
tip, base = (27, 20) if down else (5, 12)
d.polygon([(13, base), (19, base), (16, tip)], fill=(230, 200, 80))
return f
sprite("sprites/buildings/stairs_down.png", stairs(True))
sprite("sprites/buildings/stairs_up.png", stairs(False))
# Characters # Characters
sprite("sprites/characters/player.png", sprite("sprites/characters/player.png",
lambda d: d.ellipse([10, 3, 22, 14], fill=(240, 200, 160), outline=(60, 40, 30)), lambda d: d.ellipse([10, 3, 22, 14], fill=(240, 200, 160), outline=(60, 40, 30)),
@ -198,6 +233,27 @@ def gen_sprites():
lambda d: d.rectangle([18, 25, 22, 30], fill=(40, 40, 50))) lambda d: d.rectangle([18, 25, 22, 30], fill=(40, 40, 50)))
# --- UI ----------------------------------------------------------------------
def gen_ui():
# Assembler window background; slot frames match `ui` in assets/data/machines.rsn.
w, h, s = 260, 150, 40
img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle([0, 0, w - 1, h - 1], radius=10, fill=(38, 52, 60), outline=(90, 140, 150), width=2)
for i in range(0, w, 12):
d.line([i, 2, i, h - 3], fill=(44, 60, 68))
inputs = [(16, 14), (16, 96), (70, 55)]
output = (200, 55)
# Conveyor lines from the inputs to the output.
for x, y in inputs:
d.line([x + s // 2, y + s // 2, 110, 75], fill=(110, 160, 170), width=3)
d.line([110, 75, output[0], 75], fill=(110, 160, 170), width=3)
for x, y in inputs + [output]:
d.rounded_rectangle([x - 4, y - 4, x + s + 3, y + s + 3], radius=6, fill=(24, 32, 38), outline=(140, 200, 210), width=2)
d.polygon([(186, 66), (196, 75), (186, 84)], fill=(140, 200, 210))
save(img, "ui/machines/assembler.png")
# --- Map --------------------------------------------------------------------- # --- Map ---------------------------------------------------------------------
W, H = 40, 28 W, H = 40, 28
FLOOR, DARK, WALL, GRASS, WATER, PLATING, DIRT, WINDOW = range(len(TILES)) FLOOR, DARK, WALL, GRASS, WATER, PLATING, DIRT, WINDOW = range(len(TILES))
@ -242,32 +298,15 @@ def gen_map():
for y in range(1, 26): for y in range(1, 26):
ground[y][37] = DIRT ground[y][37] = DIRT
def csv(layer): objects, triggers = MapObjects(), MapObjects()
return ",\n".join(",".join("0" if t is None else str(t + 1) for t in row) for row in layer) obj = objects.point
objects = []
next_id = [1]
def obj(cls, x, y, name="", **props):
oid = next_id[0]
next_id[0] += 1
prop_xml = ""
if props:
items = []
for k, v in props.items():
typ = ' type="int"' if isinstance(v, int) else ""
items.append(f' <property name="{k}"{typ} value="{v}"/>')
prop_xml = "\n <properties>\n" + "\n".join(items) + "\n </properties>\n "
objects.append(
f' <object id="{oid}" name="{name}" class="{cls}" x="{x * T + T // 2}" y="{y * T + T // 2}">{prop_xml}<point/>\n </object>'
)
obj("spawn", 10, 8, "arrivals") obj("spawn", 10, 8, "arrivals")
obj("building", 18, 8, proto="door") obj("building", 18, 8, proto="door")
obj("building", 8, 14, proto="door") obj("building", 8, 14, proto="door")
obj("building", 10, 3, proto="door") obj("building", 10, 3, proto="door")
obj("building", 20, 5, proto="workbench") obj("building", 20, 5, proto="workbench")
obj("building", 24, 5, proto="furnace") obj("building", 24, 5, proto="furnace")
obj("building", 22, 5, proto="assembler")
obj("building", 5, 5, proto="vending_machine", stock=5) obj("building", 5, 5, proto="vending_machine", stock=5)
obj("building", 5, 17, proto="table") obj("building", 5, 17, proto="table")
obj("building", 6, 17, proto="table") obj("building", 6, 17, proto="table")
@ -284,31 +323,89 @@ def gen_map():
obj("building", x, y, proto="ore_rock") obj("building", x, y, proto="ore_rock")
obj("item", 25, 22, proto="apple", count=3) obj("item", 25, 22, proto="apple", count=3)
obj("item", 14, 25, proto="log", count=4) obj("item", 14, 25, proto="log", count=4)
obj("building", 16, 12, proto="stairs_down")
triggers.teleport(16, 12, "stairs_down", "cellar.tmx", "stairs_up")
write_map("station.tmx", W, H, ground, walls, objects, triggers)
def gen_cellar():
w, h = 14, 10
ground = [[DARK] * w for _ in range(h)]
walls = [[WALL if x in (0, w - 1) or y in (0, h - 1) else None for x in range(w)] for y in range(h)]
objects, triggers = MapObjects(), MapObjects()
objects.point("building", 2, 2, proto="stairs_up")
objects.point("building", 11, 2, proto="furnace")
objects.point("building", 11, 7, proto="table")
objects.point("item", 10, 7, proto="iron_ore", count=4)
objects.point("item", 6, 6, proto="log", count=2)
triggers.teleport(2, 2, "stairs_up", "station.tmx", "stairs_down")
write_map("cellar.tmx", w, h, ground, walls, objects, triggers)
class MapObjects:
"""Objects of one object layer; ids are unique per map via the shared counter."""
next_id = 1
def __init__(self):
self.xml = []
def _add(self, cls, name, geometry, props, body=""):
oid = MapObjects.next_id
MapObjects.next_id += 1
prop_xml = ""
if props:
items = []
for k, (typ, v) in props.items():
typ = f' type="{typ}"' if typ else ""
items.append(f' <property name="{k}"{typ} value="{v}"/>')
prop_xml = "\n <properties>\n" + "\n".join(items) + "\n </properties>"
self.xml.append(f' <object id="{oid}" name="{name}" class="{cls}" {geometry}>{prop_xml}{body}\n </object>')
def point(self, cls, x, y, name="", **props):
props = {k: ("int" if isinstance(v, int) else None, v) for k, v in props.items()}
self._add(cls, name, f'x="{x * T + T // 2}" y="{y * T + T // 2}"', props, "\n <point/>")
def teleport(self, x, y, name, map_file, target):
props = {"map": ("file", map_file), "target": (None, target)}
self._add("teleport", name, f'x="{x * T}" y="{y * T}" width="{T}" height="{T}"', props)
def write_map(file, w, h, ground, walls, objects, triggers):
def csv(layer):
return ",\n".join(",".join("0" if t is None else str(t + 1) for t in row) for row in layer)
tmx = f"""<?xml version="1.0" encoding="UTF-8"?> tmx = f"""<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="{W}" height="{H}" tilewidth="{T}" tileheight="{T}" infinite="0" nextlayerid="4" nextobjectid="{next_id[0]}"> <map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="{w}" height="{h}" tilewidth="{T}" tileheight="{T}" infinite="0" nextlayerid="5" nextobjectid="{MapObjects.next_id}">
<properties><property name="default_tile" type="int" value="6"/></properties>
<tileset firstgid="1" source="../tilesets/station.tsx"/> <tileset firstgid="1" source="../tilesets/station.tsx"/>
<layer id="1" name="Ground" width="{W}" height="{H}"> <layer id="1" name="Ground" width="{w}" height="{h}">
<data encoding="csv"> <data encoding="csv">
{csv(ground)} {csv(ground)}
</data> </data>
</layer> </layer>
<layer id="2" name="Walls" width="{W}" height="{H}"> <layer id="2" name="Walls" width="{w}" height="{h}">
<data encoding="csv"> <data encoding="csv">
{csv(walls)} {csv(walls)}
</data> </data>
</layer> </layer>
<objectgroup id="3" name="Objects"> <objectgroup id="3" name="Objects">
{chr(10).join(objects)} {chr(10).join(objects.xml)}
</objectgroup>
<objectgroup id="4" name="Triggers">
{chr(10).join(triggers.xml)}
</objectgroup> </objectgroup>
</map> </map>
""" """
(ASSETS / "maps").mkdir(parents=True, exist_ok=True) (ASSETS / "maps").mkdir(parents=True, exist_ok=True)
(ASSETS / "maps/station.tmx").write_text(tmx) (ASSETS / "maps" / file).write_text(tmx)
MapObjects.next_id = 1
if __name__ == "__main__": if __name__ == "__main__":
gen_tileset() gen_tileset()
gen_sprites() gen_sprites()
gen_ui()
gen_map() gen_map()
gen_cellar()
print("placeholder assets written to", ASSETS) print("placeholder assets written to", ASSETS)

550
tools/tiled.test.cjs Normal file
View File

@ -0,0 +1,550 @@
"use strict";
const assert = require("node:assert/strict");
const {readFileSync} = require("node:fs");
const {join} = require("node:path");
const {test} = require("node:test");
const vm = require("node:vm");
function harness() {
class ObjectGroup {
constructor() {
this.isObjectLayer = true;
this.offset = {x: 0, y: 0};
this.objects = [];
}
addObject(object) { this.objects.push(object); }
}
class MapObject {
static Point = "point";
setProperties(properties) { this.props = properties; }
setProperty(name, value) {
this.props ||= {};
this.props[name] = value;
}
properties() { return this.props; }
}
const messages = [];
const actions = {};
const tools = {};
const dialogState = {values: {}, apply: true};
class Dialog {
static SingleWidgetRows = 1;
constructor() { this.inputs = {}; this.buttons = {}; }
addLabel() {}
addCheckBox(name) { return this.inputs[name] = {checked: false}; }
addTextInput(name, text) { return this.inputs[name] = {text}; }
addButton(name) {
return {clicked: {connect: callback => { this.buttons[name] = callback; }}};
}
accept() { this.accepted = true; }
reject() { this.accepted = false; }
exec() {
for (const [name, value] of Object.entries(dialogState.values)) {
if (typeof value === "boolean") this.inputs[name].checked = value;
else this.inputs[name].text = value;
}
this.buttons[dialogState.apply ? "Apply" : "Cancel"]();
}
}
class TileMap {
static Orthogonal = "orthogonal";
constructor() { this.layers = []; }
setSize(width, height) { this.width = width; this.height = height; }
setTileSize(width, height) { this.tileWidth = width; this.tileHeight = height; }
addLayer(layer) { this.layers.push(layer); }
addTileset() {}
}
class TileLayer {
constructor() { this.cells = new Map(); }
edit() {
return {
setTile: (x, y, tile) => { this.cells.set(`${x},${y}`, tile); },
apply() {},
};
}
}
const context = {
MapObject, ObjectGroup, TileMap, TileLayer, Dialog,
Qt: {LeftButton: 1, RightButton: 2, Key_Escape: 27},
tiled: {
mapEditor: {tilesetsView: {selectedTiles: []}},
registerAction(name, callback) { actions[name] = callback; return {}; },
registerTool(name, tool) { tools[name] = tool; return tool; },
extendMenu(name, entries) {
assert.equal(name, "Map");
assert.equal(entries[0].action, "StationConvertPaintedTiles");
},
log(text) { messages.push(text); },
alert(text) { messages.push(text); },
},
};
vm.createContext(context);
vm.runInContext(readFileSync(join(__dirname, "../assets/tiled/station.js"), "utf8"), context);
const map = {
isTileMap: true, orientation: "orthogonal", infinite: false,
width: 2, height: 2, tileWidth: 32, tileHeight: 32,
layers: [], tilesets: [], macros: 0,
get layerCount() { return this.layers.length; },
layerAt(index) { return this.layers[index]; },
addLayer(layer) { this.layers.push(layer); },
macro(name, callback) {
assert.ok(["Convert Station painted tiles to objects", "Paint Station objects"].includes(name));
this.macros++;
const layers = [...this.layers];
const cells = layers.filter(layer => layer.isTileLayer)
.map(layer => [layer, new Map(layer.cells)]);
const objects = layers.filter(layer => layer.isObjectLayer)
.map(layer => [layer, [...layer.objects]]);
callback();
this.undo = () => {
this.layers = layers;
for (const [layer, snapshot] of cells) layer.cells = snapshot;
for (const [layer, snapshot] of objects) layer.objects = snapshot;
};
},
};
context.tiled.activeAsset = map;
tools.StationObjectBrush.map = map;
for (const name of ["mousePressed", "mouseReleased", "mouseMoved"]) {
const callback = tools.StationObjectBrush[name];
tools.StationObjectBrush[name] = function (...args) {
const index = name === "mouseMoved" ? 0 : 1;
this.tilePosition = {
x: Math.floor(args[index] / map.tileWidth),
y: Math.floor(args[index + 1] / map.tileHeight),
};
return callback.apply(this, args);
};
}
return {map, context, actions, messages, tools, dialogState};
}
function tile(properties) {
return {property: name => properties[name], properties: () => properties};
}
function layer(entries) {
return {
name: "Paint", offset: {x: 0, y: 0}, isTileLayer: true, locked: false,
cells: new Map(entries.map(([x, y, tile, flags = {}]) => [`${x},${y}`, {tile, flags}])),
tileAt(x, y) { return this.cells.get(`${x},${y}`)?.tile || null; },
cellAt(x, y) { return this.cells.get(`${x},${y}`).flags; },
edit() {
const pending = [];
return {
setTile(x, y, value) { assert.equal(value, null); pending.push(`${x},${y}`); },
apply: () => { for (const key of pending) this.cells.delete(key); },
};
},
};
}
test("converts floors and walls together, retaining properties, flags and decorative terrain", () => {
const {map, context} = harness();
const floor = tile({
proto: "floor", "state.layer": '"floor"', "state.health": 17,
"state.vars": '{"stock":2}', name: "metadata", solid: false,
"state.flip_h": false, "state.flip_v": false, "state.flip_d": false,
"machine.progress": 1.25, legacy: 3,
});
const ground = layer([
[0, 0, floor, {flippedHorizontally: true, flippedVertically: true, flippedAntiDiagonally: true}],
[1, 0, tile({solid: true})],
]);
const wall = layer([[0, 0, tile({proto: "wall", "state.layer": '"structure"', "state.health": 300})]]);
map.layers.push(ground, wall);
assert.equal(context.stationConvert(map), 2);
assert.equal(map.macros, 1);
assert.deepEqual(map.layers.slice(2).map(layer => layer.name),
["Floors", "Wiring/Plumbing", "Triggers", "Ground Items", "Objects"]);
assert.deepEqual(map.layers.slice(2).map(layer => layer.visible),
[false, false, true, true, true]);
const objects = [map.layers[2].objects[0], map.layers[6].objects[0]];
assert.equal(map.layers[2].objects.length, 1);
assert.equal(map.layers[3].objects.length, 0);
assert.equal(map.layers[6].objects.length, 1);
assert.equal(objects[0].className, "building");
assert.equal(objects[0].shape, "point");
assert.equal(objects[0].x, 16);
assert.equal(objects[0].y, 16);
assert.equal(objects[0].props["state.health"], 17);
assert.equal(objects[0].props["state.vars"], '{"stock":2}');
assert.equal(objects[0].props["machine.progress"], 1.25);
assert.equal(objects[0].props.legacy, 3);
assert.equal(objects[0].props["state.flip_h"], true);
assert.equal(objects[0].props["state.flip_v"], true);
assert.equal(objects[0].props["state.flip_d"], true);
assert.equal(objects[0].props.name, undefined);
assert.equal(objects[0].props.solid, undefined);
assert.equal(ground.tileAt(0, 0), null);
assert.notEqual(ground.tileAt(1, 0), null);
assert.equal(wall.tileAt(0, 0), null);
assert.deepEqual([...map.selectedObjects], objects);
assert.equal(context.stationConvert(map), 0);
assert.equal(map.macros, 1);
map.undo();
assert.equal(map.layers.length, 2);
assert.equal(ground.tileAt(0, 0), floor);
assert.notEqual(wall.tileAt(0, 0), null);
});
test("reuses categorized object layers and undoes additions without changing existing objects", () => {
const {map, context} = harness();
const floors = new context.ObjectGroup();
floors.name = "Floors";
floors.visible = true;
const existing = {className: "building", x: 48, y: 48, tile: null,
properties: () => ({proto: "floor", "state.layer": '"floor"'})};
floors.addObject(existing);
const objects = new context.ObjectGroup();
objects.name = "Objects";
objects.visible = false;
map.layers.push(floors, objects, layer([[0, 0, tile({proto: "floor", "state.layer": '"floor"'})]]));
assert.equal(context.stationConvert(map), 1);
assert.equal(map.layers.filter(layer => layer.name === "Floors").length, 1);
assert.equal(floors.objects.length, 2);
assert.equal(floors.objects[0], existing);
assert.equal(objects.objects.length, 0);
assert.equal(floors.visible, true);
assert.equal(objects.visible, false);
map.undo();
assert.equal(floors.objects.length, 1);
assert.equal(floors.objects[0], existing);
assert.equal(map.layers.length, 3);
});
test("refuses ambiguous or locked destination layers before modifying the map", () => {
for (const duplicate of [false, true]) {
const {map, context} = harness();
const floors = new context.ObjectGroup();
floors.name = "Floors";
map.layers.push(floors, layer([[0, 0, tile({proto: "floor", "state.layer": '"floor"'})]]));
if (duplicate) {
const second = new context.ObjectGroup();
second.name = "Floors";
map.layers.push(second);
} else {
floors.locked = true;
}
assert.throws(() => context.stationConvert(map), duplicate ? /Multiple object layers/ : /Unlock object layer/);
assert.equal(map.macros, 0);
assert.equal(floors.objects.length, 0);
}
});
test("rejects same-layer duplicates before making changes", () => {
for (const properties of [{proto: "wall"}, {proto: "floor", "state.layer": '"floor"'}]) {
const {map, context} = harness();
map.layers.push(layer([[0, 0, tile(properties)]]), layer([[0, 0, tile(properties)]]));
assert.throws(() => context.stationConvert(map), /Two gameplay objects/);
assert.equal(map.macros, 0);
assert.notEqual(map.layers[0].tileAt(0, 0), null);
}
});
test("detects conflicts with existing tile objects and point objects", () => {
for (const isTile of [false, true]) {
const {map, context} = harness();
const objects = new context.ObjectGroup();
objects.addObject({
className: "building", x: 16, y: isTile ? 32 : 16,
tile: isTile ? tile({proto: "wall"}) : null,
properties: () => ({proto: "wall"}),
});
map.layers.push(objects, layer([[0, 0, tile({proto: "wall"})]]));
assert.throws(() => context.stationConvert(map), /Two gameplay objects/);
assert.equal(map.macros, 0);
}
});
test("uses tileset prototype layers for existing objects with omitted defaults", () => {
for (const painted of [{proto: "wall"}, {proto: "floor", "state.layer": '"floor"'}]) {
const {map, context} = harness();
map.tilesets.push({tiles: [tile({proto: "floor", "state.layer": '"floor"'})]});
const objects = new context.ObjectGroup();
objects.addObject({
className: "building", x: 16, y: 16, tile: null,
properties: () => ({proto: "floor"}),
});
map.layers.push(objects, layer([[0, 0, tile(painted)]]));
if (painted.proto === "wall")
assert.equal(context.stationConvert(map), 1);
else
assert.throws(() => context.stationConvert(map), /Two gameplay objects/);
}
});
test("walks groups and refuses unsupported maps, offsets, locks and invalid layer properties", () => {
const {map, context} = harness();
const ground = layer([[1, 1, tile({proto: "plating", "state.layer": '"floor"'})]]);
map.layers.push({isGroupLayer: true, offset: {x: 0, y: 0}, layerCount: 1, layerAt: () => ground});
assert.equal(context.stationConvert(map), 1);
assert.equal(map.layers[1].objects[0].x, 48);
for (const mutate of [
map => { map.infinite = true; },
map => { map.orientation = "isometric"; },
map => { map.width = 0; },
map => { map.width = 1000001; },
map => { map.layers[0].offset.x = 32; },
map => { map.layers[0].locked = true; },
map => { map.layers[0] = layer([[0, 0, tile({proto: "floor", "state.layer": '"wrong"'})]]); },
map => { map.layers[0] = layer([[0, 0, tile({proto: ""})]]); },
]) {
const {map, context} = harness();
map.layers.push(layer([[0, 0, tile({proto: "floor"})]]));
mutate(map);
assert.throws(() => context.stationConvert(map));
assert.equal(map.macros, 0);
}
});
test("menu action reports conversion and validation failures explicitly", () => {
const {map, actions, messages} = harness();
actions.StationConvertPaintedTiles();
assert.match(messages[0], /converted 0/);
map.infinite = true;
actions.StationConvertPaintedTiles();
assert.match(messages[1], /conversion failed/);
});
test("brush configures future objects without changing tileset or previous object properties", () => {
const {map, context, actions, dialogState, tools} = harness();
const defaults = {proto: "wall", "state.layer": '"structure"', "state.health": 300,
"state.solid": true, "state.vars": "{}", "state.variant": "null"};
const wall = tile(defaults);
const brush = tools.StationObjectBrush;
brush.selectedTile = wall;
context.tiled.mapEditor.tilesetsView.selectedTiles = [wall];
dialogState.values = {"state.health": "17", "state.solid": false, "state.vars": '{"stock":3}'};
actions.StationBrushProperties();
brush.mousePressed(1, 1, 1);
assert.equal(map.macros, 0);
assert.equal(brush.preview.layers[0].cells.size, 1);
brush.mouseReleased(1, 1, 1);
const object = map.layers[0].objects[0];
assert.equal(object.props["state.health"], 17);
assert.equal(object.props["state.solid"], false);
assert.equal(object.props["state.vars"], '{"stock":3}');
assert.equal(object.tile, wall);
assert.equal(object.className, "building");
assert.equal(object.x, 0);
assert.equal(object.y, 32);
assert.equal(object.width, 32);
assert.equal(defaults["state.health"], 300);
dialogState.values = {"state.health": "42"};
actions.StationBrushProperties();
brush.mousePressed(1, 33, 1);
brush.mouseReleased(1, 33, 1);
assert.equal(map.layers[0].objects[1].props["state.health"], 42);
assert.equal(object.props["state.health"], 17);
map.undo();
assert.equal(map.layers[0].objects.length, 1);
});
test("brush interpolates fast drags, deduplicates cells and undoes the entire stroke", () => {
const {map, tools} = harness();
map.width = 8;
const brush = tools.StationObjectBrush;
brush.selectedTile = tile({proto: "floor", "state.layer": '"floor"', "state.health": 100});
brush.mousePressed(1, 1, 1);
brush.mouseMoved(225, 1);
brush.mouseMoved(1, 1);
brush.mouseReleased(1, 225, 1);
assert.equal(map.macros, 1);
assert.equal(map.layers[0].name, "Floors");
assert.equal(map.layers[0].visible, false);
assert.equal(map.layers[0].objects.length, 8);
assert.equal(map.selectedObjects.length, 8);
assert.equal(brush.stroke, null);
map.undo();
assert.equal(map.layers.length, 0);
});
test("brush refuses occupied, painted, locked, duplicate and invalid maps without partial edits", () => {
for (const scenario of ["object", "painted", "locked", "duplicate", "offset", "infinite"]) {
const {map, context, tools, messages} = harness();
const wall = tile({proto: "wall", "state.layer": '"structure"'});
const group = new context.ObjectGroup();
group.name = "Objects";
map.layers.push(group);
if (scenario === "object") {
const object = new context.MapObject();
Object.assign(object, {className: "building", x: 16, y: 16});
object.setProperties({proto: "wall"});
group.addObject(object);
} else if (scenario === "painted") map.layers.push(layer([[1, 0, wall]]));
else if (scenario === "locked") group.locked = true;
else if (scenario === "duplicate") map.layers.push(Object.assign(new context.ObjectGroup(), {name: "Objects"}));
else if (scenario === "offset") group.offset.x = 10;
else if (scenario === "infinite") map.infinite = true;
const brush = tools.StationObjectBrush;
brush.selectedTile = wall;
context.tiled.alert = () => { assert.fail("Mouse callbacks must not show modal dialogs"); };
context.tiled.log = text => {
assert.equal(brush.stroke, null);
brush.mouseReleased(1, 33, 1);
messages.push(text);
};
brush.mousePressed(1, 1, 1);
brush.mouseReleased(1, 33, 1);
assert.equal(map.macros, 0, scenario);
assert.equal(messages.length, 1, scenario);
assert.match(messages[0], /Station brush:/);
assert.equal(brush.statusInfo, messages[0]);
}
});
test("brush allows floor and structure overlaps and cancels on Escape or map change", () => {
const {map, tools} = harness();
const brush = tools.StationObjectBrush;
brush.selectedTile = tile({proto: "floor", "state.layer": '"floor"'});
brush.mousePressed(1, 1, 1);
brush.keyPressed(27);
brush.mouseReleased(1, 1, 1);
assert.equal(map.macros, 0);
brush.mousePressed(1, 1, 1);
brush.mapChanged();
brush.mouseReleased(1, 1, 1);
assert.equal(map.macros, 0);
brush.mousePressed(2, 1, 1);
assert.equal(map.macros, 0);
brush.mousePressed(1, 1, 1);
brush.mouseReleased(1, 1, 1);
brush.selectedTile = tile({proto: "wall", "state.layer": '"structure"'});
brush.mousePressed(1, 1, 1);
brush.mouseReleased(1, 1, 1);
assert.equal(map.layers[0].objects.length, 1);
assert.equal(map.layers[1].objects.length, 1);
});
test("brush reports invalid input and cancellation preserves configured defaults", () => {
const {context, actions, tools, messages, dialogState} = harness();
actions.StationBrushProperties();
assert.match(messages.pop(), /Select exactly one/);
const wall = tile({proto: "wall", "state.health": 300, "state.vars": "{}"});
context.tiled.mapEditor.tilesetsView.selectedTiles = [wall];
for (const value of ["", "NaN", "1.5", "9007199254740992", "2147483648", "-2147483649"]) {
dialogState.values = {"state.health": value};
actions.StationBrushProperties();
assert.match(messages.pop(), /valid 32-bit integer/);
}
dialogState.values = {"state.vars": "invalid json"};
actions.StationBrushProperties();
assert.match(messages.pop(), /Invalid brush properties/);
dialogState.values = {"state.health": "17"};
dialogState.apply = false;
actions.StationBrushProperties();
assert.equal(context.stationBrushSettings(wall)["state.health"], 300);
dialogState.apply = true;
dialogState.values = {"state.health": "2147483647"};
actions.StationBrushProperties();
assert.equal(context.stationBrushSettings(wall)["state.health"], 2147483647);
tools.StationObjectBrush.selectedTile = tile({name: "water"});
tools.StationObjectBrush.mousePressed(1, 1, 1);
assert.match(messages.pop(), /Select one gameplay/);
});
test("brush supports fractional machine progress even when its default is zero", () => {
const {context, actions, dialogState} = harness();
const machine = tile({proto: "furnace", "machine.progress": 0});
context.tiled.mapEditor.tilesetsView.selectedTiles = [machine];
dialogState.values = {"machine.progress": "1.25"};
actions.StationBrushProperties();
assert.equal(context.stationBrushSettings(machine)["machine.progress"], 1.25);
});
test("bundled project loads the shipped extension directory", () => {
const project = JSON.parse(readFileSync(join(__dirname, "../assets/station.tiled-project"), "utf8"));
const extension = join(__dirname, "../assets", project.extensionsPath, "station.js");
assert.match(readFileSync(extension, "utf8"), /StationObjectBrush/);
for (const folder of project.folders)
assert.ok(require("node:fs").statSync(join(__dirname, "../assets", folder)).isDirectory());
});
function projectHarness(maps) {
const h = harness();
const {context} = h;
const entries = {
"/p": {files: ["a.tmx", "notes.txt"], dirs: ["sub", "extensions"]},
"/p/sub": {files: ["B.TMX", "c.tmx"], dirs: []},
"/p/extensions": {files: ["station.js"], dirs: []},
};
context.File = {
Files: 2, Dirs: 1, NoSymLinks: 8, NoDotAndDotDot: 0x6000,
directoryEntries(dir, filter) {
assert.ok(filter & context.File.NoSymLinks);
const entry = entries[dir] || {files: [], dirs: []};
return filter & context.File.Files ? entry.files : entry.dirs;
},
};
context.FileInfo = {cleanPath: path => path.replace(/\/+/g, "/")};
const original = h.map;
original.fileName = "/p/a.tmx";
original.modified = false;
const closed = [];
context.tiled.project = {fileName: "/p/station.tiled-project", folders: ["/p"]};
context.tiled.openAssets = [original];
context.tiled.open = file => {
let asset = context.tiled.openAssets.find(a => a.fileName === file);
if (!asset && maps[file]) {
asset = maps[file];
asset.fileName = file;
context.tiled.openAssets.push(asset);
}
if (asset)
context.tiled.activeAsset = asset;
return asset || null;
};
context.tiled.close = asset => {
closed.push(asset.fileName);
context.tiled.openAssets = context.tiled.openAssets.filter(a => a !== asset);
return true;
};
return {...h, closed};
}
test("project action converts every project map and reports failures per map", () => {
const sample = harness().map;
function clone(entries, extra = {}) {
return Object.assign(Object.create(sample), {layers: [layer(entries)], macros: 0, modified: false}, extra);
}
const wall = tile({proto: "wall", "state.layer": '"structure"'});
const converted = clone([[0, 0, wall], [1, 1, wall]]);
const untouched = clone([[0, 0, tile({solid: true})]]);
const broken = clone([[0, 0, wall]], {infinite: true});
const {context, closed, map} = projectHarness({
"/p/sub/B.TMX": converted, "/p/sub/c.tmx": untouched,
});
map.layers.push(layer([[0, 0, wall]]));
context.tiled.openAssets.push(broken);
broken.fileName = "/p/missing.tmx";
const report = context.stationConvertProject();
assert.equal(report.maps, 2);
assert.equal(report.tiles, 3);
assert.equal(converted.macros, 1);
assert.equal(map.macros, 1);
assert.deepEqual(closed, ["/p/sub/c.tmx"]);
assert.ok(context.tiled.openAssets.includes(converted));
assert.equal(context.tiled.activeAsset, map);
assert.equal(report.failures.length, 0);
const {context: other, actions: otherActions, messages: otherMessages, closed: otherClosed} =
projectHarness({"/p/sub/B.TMX": clone([[0, 0, wall]], {infinite: true})});
otherActions.StationConvertProjectMaps();
assert.match(otherMessages.at(-1), /converted 0 painted tiles in 0 maps/);
assert.match(otherMessages.at(-1), /B\.TMX: Station supports finite/);
assert.match(otherMessages.at(-1), /c\.tmx: could not open/);
assert.deepEqual(otherClosed, ["/p/sub/B.TMX"]);
assert.equal(other.tiled.openAssets.length, 1);
other.tiled.project = {fileName: "", folders: []};
otherActions.StationConvertProjectMaps();
assert.match(otherMessages.at(-1), /Open a Station project first/);
});
test("exported projects load the copied extension directory", () => {
const source = readFileSync(join(__dirname, "../crates/game_server/src/map_io.rs"), "utf8");
assert.match(source, /"extensionsPath": "extensions"/);
assert.match(source, /join\("extensions"\)/);
});

View File

@ -6,8 +6,10 @@ publish = false
[dependencies] [dependencies]
flate2 = "1" flate2 = "1"
percent-encoding = "2"
serde_json = "1" serde_json = "1"
tar = { version = "0.4", default-features = false } tar = { version = "0.4", default-features = false }
tiny_http = "0.12"
zip = { version = "9", default-features = false, features = ["deflate-flate2"] } zip = { version = "9", default-features = false, features = ["deflate-flate2"] }
[lints] [lints]

View File

@ -2,8 +2,12 @@
//! //!
//! ```text //! ```text
//! cargo xtask pack [--target TRIPLE] [--format zip|tar.gz] [--out-dir DIR] [--debug] //! cargo xtask pack [--target TRIPLE] [--format zip|tar.gz] [--out-dir DIR] [--debug]
//! cargo xtask watch [pack options] [--serve]
//! ``` //! ```
mod serve;
mod watch;
use std::{ use std::{
env, env,
error::Error, error::Error,
@ -25,6 +29,9 @@ fn main() -> ExitCode {
let mut args = env::args().skip(1); let mut args = env::args().skip(1);
let result = match args.next().as_deref() { let result = match args.next().as_deref() {
Some("pack") => PackOptions::parse(args).and_then(|opts| pack(&opts)), Some("pack") => PackOptions::parse(args).and_then(|opts| pack(&opts)),
Some("watch") => {
PackOptions::parse_watch(args).and_then(|(opts, serve)| watch::run(&opts, serve))
}
Some("-h" | "--help") | None => { Some("-h" | "--help") | None => {
print_help(); print_help();
return ExitCode::SUCCESS; return ExitCode::SUCCESS;
@ -44,12 +51,15 @@ fn print_help() {
println!( println!(
"usage: cargo xtask <command>\n\n\ "usage: cargo xtask <command>\n\n\
commands:\n \ commands:\n \
pack build the client and server and pack them with their assets\n\n\ pack build the client and server and pack them with their assets\n \
pack options:\n \ watch pack after sources are unchanged for 10 seconds; pause with dist/NOREBUILD\n\n\
pack/watch options:\n \
--target TRIPLE cross-compile for TRIPLE (default: host)\n \ --target TRIPLE cross-compile for TRIPLE (default: host)\n \
--format zip|tar.gz archive format (default: zip for Windows, tar.gz otherwise)\n \ --format zip|tar.gz archive format (default: zip for Windows, tar.gz otherwise)\n \
--out-dir DIR output directory (default: dist)\n \ --out-dir DIR output directory (default: dist)\n \
--debug pack a debug build instead of the stripped `dist` profile" --debug pack a debug build instead of the stripped `dist` profile\n\n\
watch options:\n \
--serve serve the output directory at http://127.0.0.1:9932"
); );
} }
@ -94,7 +104,16 @@ struct PackOptions {
impl PackOptions { impl PackOptions {
fn parse(mut args: impl Iterator<Item = String>) -> Result<Self> { fn parse(mut args: impl Iterator<Item = String>) -> Result<Self> {
Self::parse_options(&mut args, false).map(|(opts, _)| opts)
}
fn parse_watch(mut args: impl Iterator<Item = String>) -> Result<(Self, bool)> {
Self::parse_options(&mut args, true)
}
fn parse_options(args: &mut impl Iterator<Item = String>, watch: bool) -> Result<(Self, bool)> {
let mut opts = Self::default(); let mut opts = Self::default();
let mut serve = false;
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
let mut value = |name: &str| args.next().ok_or_else(|| format!("{name} needs a value")); let mut value = |name: &str| args.next().ok_or_else(|| format!("{name} needs a value"));
match arg.as_str() { match arg.as_str() {
@ -102,14 +121,15 @@ impl PackOptions {
"--format" => opts.format = Some(Format::parse(&value("--format")?)?), "--format" => opts.format = Some(Format::parse(&value("--format")?)?),
"--out-dir" => opts.out_dir = Some(value("--out-dir")?.into()), "--out-dir" => opts.out_dir = Some(value("--out-dir")?.into()),
"--debug" => opts.debug = true, "--debug" => opts.debug = true,
"--serve" if watch => serve = true,
"-h" | "--help" => { "-h" | "--help" => {
print_help(); print_help();
std::process::exit(0); std::process::exit(0);
} }
other => return Err(format!("unknown pack option `{other}`").into()), other => return Err(format!("unknown option `{other}`").into()),
} }
} }
Ok(opts) Ok((opts, serve))
} }
} }

235
xtask/src/serve.rs Normal file
View File

@ -0,0 +1,235 @@
use std::{
fs::{self, File},
io,
path::{Component, Path},
thread::{self, JoinHandle},
};
use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
use tiny_http::{Header, Method, Request, Response, Server};
use crate::Result;
pub fn start(root: &Path) -> Result<JoinHandle<()>> {
let server = Server::http("127.0.0.1:9932")
.map_err(|error| format!("could not start HTTP server: {error}"))?;
let root = root.to_path_buf();
println!("Serving {} at http://127.0.0.1:9932/", root.display());
Ok(thread::spawn(move || {
for request in server.incoming_requests() {
if let Err(error) = respond(request, &root) {
eprintln!("HTTP request failed: {error}");
}
}
}))
}
fn header(name: &str, value: &str) -> Header {
Header::from_bytes(name, value).expect("valid HTTP header")
}
fn text(request: Request, status: u16, body: &str) -> io::Result<()> {
request.respond(
Response::from_string(body)
.with_status_code(status)
.with_header(header("Cache-Control", "no-store")),
)
}
fn respond(request: Request, root: &Path) -> io::Result<()> {
if !matches!(request.method(), Method::Get | Method::Head) {
return request.respond(
Response::from_string("Method not allowed")
.with_status_code(405)
.with_header(header("Allow", "GET, HEAD")),
);
}
let url = request.url().split('?').next().unwrap_or("/");
let Ok(decoded) = percent_decode_str(url).decode_utf8() else {
return text(request, 400, "Invalid URL encoding");
};
if !decoded.starts_with('/')
|| decoded.contains(['\\', '\0'])
|| Path::new(decoded.trim_start_matches('/'))
.components()
.any(|part| !matches!(part, Component::Normal(_) | Component::CurDir))
{
return text(request, 403, "Forbidden");
}
let path = match root.join(decoded.trim_start_matches('/')).canonicalize() {
Ok(path) if path.starts_with(root) => path,
Ok(_) => return text(request, 403, "Forbidden"),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return text(request, 404, "Not found");
}
Err(error) => {
eprintln!("Could not resolve HTTP path: {error}");
return text(request, 500, "Could not read path");
}
};
if path.is_dir() {
if !url.ends_with('/') {
let location = format!("{url}/");
return request
.respond(Response::empty(301).with_header(header("Location", &location)));
}
let listing = match listing(&path) {
Ok(listing) => listing,
Err(error) => {
eprintln!("Could not list {}: {error}", path.display());
return text(request, 500, "Could not list directory");
}
};
return request.respond(
Response::from_string(listing)
.with_header(header("Content-Type", "text/html; charset=utf-8"))
.with_header(header("Cache-Control", "no-store"))
.with_header(header("Content-Security-Policy", "default-src 'none'")),
);
}
if !path.is_file() {
return text(request, 403, "Forbidden");
}
match File::open(&path) {
Ok(file) => request.respond(
Response::from_file(file)
.with_header(header("Content-Type", "application/octet-stream"))
.with_header(header("X-Content-Type-Options", "nosniff"))
.with_header(header("Cache-Control", "no-store")),
),
Err(error) => {
eprintln!("Could not open {}: {error}", path.display());
text(request, 500, "Could not read file")
}
}
}
fn listing(path: &Path) -> io::Result<String> {
let mut entries = fs::read_dir(path)?.collect::<io::Result<Vec<_>>>()?;
entries.sort_by_key(|entry| entry.file_name());
let mut html = String::from(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Station downloads</title>\
</head><body><h1>Station downloads</h1><ul>",
);
for entry in entries {
let name = entry.file_name();
let name = name
.to_str()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "filename is not UTF-8"))?;
let href = utf8_percent_encode(name, NON_ALPHANUMERIC);
let label = name
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;");
html.push_str(&format!("<li><a href=\"./{href}\">{label}</a></li>"));
}
html.push_str("</ul></body></html>");
Ok(html)
}
#[cfg(test)]
mod tests {
use std::{
io::{Read, Write},
net::TcpStream,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use super::*;
use crate::PackOptions;
#[test]
fn serve_is_watch_only_and_does_not_consume_pack_option_values() {
let parse =
|args: &[&str]| PackOptions::parse_watch(args.iter().map(|arg| (*arg).to_owned()));
let (options, serve) = parse(&["--serve", "--debug", "--out-dir", "custom"]).unwrap();
assert!(serve);
assert!(options.debug);
assert_eq!(options.out_dir.unwrap(), Path::new("custom"));
assert!(!parse(&["--debug"]).unwrap().1);
let (options, serve) = parse(&["--out-dir", "--serve"]).unwrap();
assert!(!serve);
assert_eq!(options.out_dir.unwrap(), Path::new("--serve"));
assert!(PackOptions::parse(["--serve".to_owned()].into_iter()).is_err());
}
#[test]
fn http_lists_downloads_supports_head_and_confines_paths() {
let temp = std::env::temp_dir().join(format!(
"station-http-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let root = temp.join("dist");
fs::create_dir_all(root.join("nested")).unwrap();
let root = root.canonicalize().unwrap();
fs::write(root.join("build & <test>.zip"), [0, 1, 255, 10]).unwrap();
fs::write(root.join("nested/file.txt"), "nested contents").unwrap();
fs::write(temp.join("private.txt"), "private").unwrap();
let server = Server::http("127.0.0.1:0").unwrap();
let exchange = |method: &str, url: &str| {
let mut stream = TcpStream::connect(server.server_addr().to_ip().unwrap()).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
write!(
stream,
"{method} {url} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
)
.unwrap();
let request = server
.recv_timeout(Duration::from_secs(3))
.unwrap()
.unwrap();
respond(request, &root).unwrap();
let mut bytes = Vec::new();
stream.read_to_end(&mut bytes).unwrap();
let split = bytes
.windows(4)
.position(|part| part == b"\r\n\r\n")
.unwrap();
(
String::from_utf8(bytes[..split].to_vec()).unwrap(),
bytes[split + 4..].to_vec(),
)
};
let (headers, body) = exchange("GET", "/");
assert!(headers.starts_with("HTTP/1.1 200"));
let html = String::from_utf8(body).unwrap();
assert!(html.contains("build &amp; &lt;test&gt;.zip"));
assert!(html.contains("href=\"./build%20%26%20%3Ctest%3E%2Ezip\""));
let (headers, body) = exchange("GET", "/build%20%26%20%3Ctest%3E.zip?download=1");
assert!(headers.starts_with("HTTP/1.1 200"));
assert_eq!(body, [0, 1, 255, 10]);
let (headers, body) = exchange("HEAD", "/build%20%26%20%3Ctest%3E.zip");
assert!(headers.starts_with("HTTP/1.1 200"));
assert!(headers.to_lowercase().contains("content-length: 4"));
assert!(body.is_empty());
assert!(exchange("GET", "/nested").0.starts_with("HTTP/1.1 301"));
assert_eq!(exchange("GET", "/nested/file.txt").1, b"nested contents");
assert!(exchange("GET", "/missing").0.starts_with("HTTP/1.1 404"));
assert!(exchange("POST", "/").0.starts_with("HTTP/1.1 405"));
for url in [
"/../private.txt",
"/%2e%2e/private.txt",
"/..%5cprivate.txt",
"/%00",
] {
assert!(exchange("GET", url).0.starts_with("HTTP/1.1 403"), "{url}");
}
assert!(exchange("GET", "/%FF").0.starts_with("HTTP/1.1 400"));
#[cfg(unix)]
{
std::os::unix::fs::symlink(temp.join("private.txt"), root.join("link")).unwrap();
assert!(exchange("GET", "/link").0.starts_with("HTTP/1.1 403"));
}
fs::write(root.join("nested/file.txt"), "new contents").unwrap();
assert_eq!(exchange("GET", "/nested/file.txt").1, b"new contents");
fs::remove_dir_all(temp).unwrap();
}
}

299
xtask/src/watch.rs Normal file
View File

@ -0,0 +1,299 @@
use std::{
collections::{BTreeMap, hash_map::DefaultHasher},
fs,
hash::{Hash, Hasher},
io,
path::{Path, PathBuf},
process::{Child, Command},
thread,
time::{Duration, Instant},
};
use crate::{PackOptions, Result, cargo, workspace_root};
const DEBOUNCE: Duration = Duration::from_secs(10);
const POLL_INTERVAL: Duration = Duration::from_millis(500);
#[derive(Default)]
struct PendingBuild {
last_change: Option<Instant>,
paused: bool,
}
impl PendingBuild {
fn observe(&mut self, changed: bool, paused: bool, now: Instant) {
if changed || (self.paused && !paused && self.last_change.is_some()) {
self.last_change = Some(now);
}
self.paused = paused;
}
fn ready(&self, now: Instant) -> bool {
!self.paused
&& self
.last_change
.is_some_and(|last| now.duration_since(last) >= DEBOUNCE)
}
}
pub fn run(options: &PackOptions, serve: bool) -> Result<()> {
let root = workspace_root().canonicalize()?;
let output = root.join(options.out_dir.as_deref().unwrap_or(Path::new("dist")));
fs::create_dir_all(&output)?;
let output = output.canonicalize()?;
if root.starts_with(&output) {
return Err("watch output directory must not contain the workspace".into());
}
let server = if serve {
Some(crate::serve::start(&output)?)
} else {
None
};
let excluded = [root.join("dist"), root.join("target"), output];
let guard = root.join("dist/NOREBUILD");
let mut previous = snapshot(&root, &excluded)?;
let mut pending = PendingBuild::default();
let mut build: Option<Child> = None;
println!("Watching sources; packing after 10 seconds without changes. Ctrl-C to stop.");
loop {
if server.as_ref().is_some_and(thread::JoinHandle::is_finished) {
return Err("HTTP server stopped unexpectedly".into());
}
let current = snapshot(&root, &excluded)?;
let changed = current != previous;
previous = current;
let paused = guard.try_exists()?;
if paused != pending.paused {
if paused {
println!("Packing paused: {} exists.", guard.display());
} else {
println!("Packing resumed; pending changes wait another 10 seconds.");
}
}
pending.observe(changed, paused, Instant::now());
if changed {
println!("Source changes detected; resetting the 10-second timer.");
}
if let Some(child) = &mut build
&& let Some(status) = child.try_wait()?
{
if status.success() {
println!("Build and pack completed.");
} else {
eprintln!("Build and pack failed ({status}); watching for further changes.");
}
build = None;
}
if build.is_none() && pending.ready(Instant::now()) && !guard.try_exists()? {
println!("Sources settled; rebuilding and packing.");
// Recompile xtask too, so edits to packaging code take effect without restarting the watcher.
build = Some(pack_command(&root, options).spawn()?);
pending.last_change = None;
}
thread::sleep(POLL_INTERVAL);
}
}
fn pack_command(root: &Path, options: &PackOptions) -> Command {
let mut command = cargo();
command
.current_dir(root)
.args(["run", "--package", "xtask", "--", "pack"]);
if let Some(target) = &options.target {
command.args(["--target", target]);
}
if let Some(format) = options.format {
command.args(["--format", format.extension()]);
}
if let Some(output) = &options.out_dir {
command.arg("--out-dir").arg(output);
}
if options.debug {
command.arg("--debug");
}
command
}
fn snapshot(root: &Path, excluded: &[PathBuf]) -> Result<BTreeMap<PathBuf, u64>> {
let files = Command::new("git")
.current_dir(root)
.args([
"ls-files",
"--cached",
"--others",
"--exclude-standard",
"-z",
])
.output()?;
if !files.status.success() {
return Err(format!(
"could not list watched sources: {}",
String::from_utf8_lossy(&files.stderr).trim()
)
.into());
}
let mut snapshot = BTreeMap::new();
for name in files
.stdout
.split(|byte| *byte == 0)
.filter(|name| !name.is_empty())
{
let path = root.join(std::str::from_utf8(name)?);
if excluded.iter().any(|output| path.starts_with(output)) {
continue;
}
let read = || -> io::Result<Option<u64>> {
let metadata = fs::symlink_metadata(&path)?;
let mut hash = DefaultHasher::new();
if metadata.file_type().is_symlink() {
fs::read_link(&path)?.hash(&mut hash);
} else if metadata.is_file() {
fs::read(&path)?.hash(&mut hash);
} else {
return Ok(None);
}
Ok(Some(hash.finish()))
};
match read() {
Ok(Some(hash)) => {
snapshot.insert(path, hash);
}
Ok(None) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("could not watch {}: {error}", path.display()).into()),
}
}
Ok(snapshot)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debounce_waits_ten_seconds_and_each_change_resets_it() {
let start = Instant::now();
let mut pending = PendingBuild::default();
assert!(!pending.ready(start + DEBOUNCE));
pending.observe(true, false, start);
assert!(!pending.ready(start + DEBOUNCE - Duration::from_nanos(1)));
assert!(pending.ready(start + DEBOUNCE));
pending.observe(true, false, start + Duration::from_secs(9));
assert!(!pending.ready(start + Duration::from_secs(18)));
assert!(pending.ready(start + Duration::from_secs(19)));
}
#[test]
fn guard_keeps_changes_pending_and_removal_restarts_debounce() {
let start = Instant::now();
let mut pending = PendingBuild::default();
pending.observe(true, false, start);
pending.observe(false, true, start + Duration::from_secs(9));
assert!(!pending.ready(start + Duration::from_secs(100)));
pending.observe(true, true, start + Duration::from_secs(100));
pending.observe(false, false, start + Duration::from_secs(200));
assert!(!pending.ready(start + Duration::from_secs(209)));
assert!(pending.ready(start + Duration::from_secs(210)));
pending.last_change = None;
pending.observe(false, true, start + Duration::from_secs(211));
pending.observe(false, false, start + Duration::from_secs(212));
assert!(!pending.ready(start + Duration::from_secs(222)));
}
#[test]
fn edits_during_a_build_remain_queued_for_the_next_build() {
let start = Instant::now();
let mut pending = PendingBuild::default();
pending.observe(true, false, start);
assert!(pending.ready(start + DEBOUNCE));
pending.last_change = None;
pending.observe(true, false, start + Duration::from_secs(12));
pending.observe(true, false, start + Duration::from_secs(18));
assert!(!pending.ready(start + Duration::from_secs(27)));
assert!(pending.ready(start + Duration::from_secs(28)));
assert!(pending.ready(start + Duration::from_secs(40)));
}
#[test]
fn forwards_pack_options() {
let options = PackOptions::parse(
[
"--target",
"x86_64-pc-windows-gnu",
"--format",
"zip",
"--out-dir",
"custom output",
"--debug",
]
.into_iter()
.map(str::to_owned),
)
.unwrap();
let command = pack_command(Path::new("."), &options);
assert_eq!(
command.get_args().collect::<Vec<_>>(),
[
"run",
"--package",
"xtask",
"--",
"pack",
"--target",
"x86_64-pc-windows-gnu",
"--format",
"zip",
"--out-dir",
"custom output",
"--debug",
]
);
}
#[test]
fn watches_edits_additions_deletions_and_ignores_git_and_outputs() {
let root = std::env::temp_dir().join(format!(
"station-watch-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&root).unwrap();
let git = |args: &[&str]| {
let status = Command::new("git")
.current_dir(&root)
.args(args)
.status()
.unwrap();
assert!(status.success());
};
git(&["init", "--quiet"]);
fs::write(root.join(".gitignore"), "target/\ndist/\nignored/\n").unwrap();
fs::write(root.join("source.rs"), "one").unwrap();
git(&["add", ".gitignore", "source.rs"]);
let excluded = [
root.join("dist"),
root.join("target"),
root.join("custom output"),
];
let initial = snapshot(&root, &excluded).unwrap();
fs::write(root.join(".git/test-output"), "metadata").unwrap();
for directory in ["dist", "target", "ignored", "custom output"] {
fs::create_dir(root.join(directory)).unwrap();
fs::write(root.join(directory).join("generated"), "output").unwrap();
}
assert_eq!(snapshot(&root, &excluded).unwrap(), initial);
fs::write(root.join("source.rs"), "two").unwrap();
let edited = snapshot(&root, &excluded).unwrap();
assert_ne!(edited, initial);
fs::write(root.join("new.rs"), "new").unwrap();
let added = snapshot(&root, &excluded).unwrap();
assert_ne!(added, edited);
fs::remove_file(root.join("source.rs")).unwrap();
assert_ne!(snapshot(&root, &excluded).unwrap(), added);
fs::remove_dir_all(root).unwrap();
}
}