Compare commits
No commits in common. "main" and "v0.0.1" have entirely different histories.
|
|
@ -47,13 +47,6 @@ jobs:
|
|||
- name: Test
|
||||
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
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -39,13 +39,6 @@ jobs:
|
|||
- name: Test
|
||||
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
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
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
|
||||
|
|
@ -3,4 +3,3 @@
|
|||
__pycache__/
|
||||
/.NOCOMMIT
|
||||
dist
|
||||
*.tiled-session
|
||||
|
|
|
|||
15
AGENTS.md
|
|
@ -10,10 +10,6 @@ Station 13, with singleplayer and multiplayer. It is built in Rust (edition
|
|||
|
||||
## 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.
|
||||
- After changing files, run `cargo fmt --all`. Do not override project
|
||||
formatting configuration or invoke a formatter directly on individual files.
|
||||
|
|
@ -49,7 +45,6 @@ Station 13, with singleplayer and multiplayer. It is built in Rust (edition
|
|||
- `crates/game_client/`: the `game` binary: rendering, UI and input; embeds the
|
||||
server for singleplayer and hosting.
|
||||
- `assets/data/`: item, recipe, building and skill definitions (`.rsn`).
|
||||
- `assets/ui/`: client UI layouts and theme (`.rsn`), see the README.
|
||||
- `assets/scripts/`: Rune scripts (`.rn`), referenced as `"module::function"`.
|
||||
- `assets/maps/`, `assets/tilesets/`, `assets/sprites/`: Tiled maps and art.
|
||||
- `xtask/`: project automation (`cargo xtask pack` builds and archives the
|
||||
|
|
@ -76,13 +71,9 @@ conventions) in sync with API, configuration, and behavior changes.
|
|||
- Rune scripts stage changes in `ScriptState`; they are applied after the
|
||||
script returns, and a failing script has no effect. Ctx methods need
|
||||
`#[rune::function(keep)]`; script-facing numbers are integers.
|
||||
- The database (`persistence.rs`) is the source of truth for an existing world;
|
||||
the Tiled map only seeds new worlds. All tables are keyed by the world UUID.
|
||||
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).
|
||||
- Save files (`persistence.rs`, `SAVE_VERSION`) are the source of truth for an
|
||||
existing world; the Tiled map only seeds new worlds. Changing saved types
|
||||
requires a version bump or a compatible migration.
|
||||
- Characters are identified by name only (no authentication); renet uses
|
||||
unsecure authentication. Do not treat client input as trusted.
|
||||
- Replicon builds its systems in `Plugin::finish`; headless tests must call
|
||||
|
|
|
|||
85
CHANGELOG.md
|
|
@ -5,47 +5,8 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||
|
||||
## [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
|
||||
|
||||
- `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
|
||||
joining (`--connect`), plus a dedicated `game-server`.
|
||||
- Data-driven items, recipes, buildings and skills in `assets/data/*.rsn`.
|
||||
|
|
@ -59,49 +20,3 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||
console window.
|
||||
- `cargo xtask pack` builds the client and server and packs them with their
|
||||
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.
|
||||
|
|
|
|||
21
Cargo.toml
|
|
@ -1,11 +1,6 @@
|
|||
[workspace]
|
||||
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"]
|
||||
|
||||
[workspace.package]
|
||||
|
|
@ -21,20 +16,6 @@ rsn = "0.2"
|
|||
rune = "0.14"
|
||||
tiled = "0.16"
|
||||
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_server = { path = "crates/game_server" }
|
||||
|
||||
|
|
|
|||
460
README.md
|
|
@ -4,15 +4,13 @@ A persistent-world, RPG-style station simulation inspired by Space Station 13, b
|
|||
with [Bevy](https://bevy.org) in Rust.
|
||||
|
||||
- **ECS** – all game state lives in Bevy entities and components.
|
||||
- **Data driven** – items, recipes, buildings, machines and skills are defined in
|
||||
- **Data driven** – items, recipes, buildings and skills are defined in
|
||||
[rsn](https://crates.io/crates/rsn) files under `assets/data/`.
|
||||
- **Scriptable** – behaviour is written in [Rune](https://rune-rs.github.io) scripts
|
||||
under `assets/scripts/`.
|
||||
- **Tiled maps** – maps are regular [Tiled](https://www.mapeditor.org) `.tmx` files.
|
||||
- **Persistent world** – no rounds. The world and every character (inventory, skills,
|
||||
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
|
||||
in singleplayer and over UDP in multiplayer
|
||||
([bevy_replicon](https://crates.io/crates/bevy_replicon) + renet).
|
||||
|
|
@ -25,10 +23,7 @@ with [Bevy](https://bevy.org) in Rust.
|
|||
| `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 |
|
||||
|
||||
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`,
|
||||
The client never changes game state directly. It sends requests (`Login`, `SetMoveDir`,
|
||||
`Interact`, `Craft`, …) and the server replicates the resulting state back. In
|
||||
singleplayer the same server systems run inside the client process, so singleplayer and
|
||||
multiplayer behave identically.
|
||||
|
|
@ -36,206 +31,39 @@ multiplayer behave identically.
|
|||
## Running
|
||||
|
||||
```sh
|
||||
cargo run # start menu
|
||||
cargo run -p game_server --bin game-server # dedicated server (saves/server.sqlite, world `main`)
|
||||
cargo run # singleplayer (world: saves/singleplayer.rsn)
|
||||
cargo run -- --host 5000 # singleplayer world that others can join
|
||||
cargo run -- --connect 127.0.0.1:5000 # join a server or host
|
||||
cargo run -p game_server --bin game-server # dedicated server (world: saves/server.rsn)
|
||||
```
|
||||
|
||||
The start menu offers **New game** (optionally open to other players),
|
||||
**Load game** (play or delete a save) and **Join server**. Command-line options
|
||||
skip the menu:
|
||||
|
||||
```sh
|
||||
cargo run -- --save mars # play (or create) the local world `mars`
|
||||
cargo run -- --save mars --host 5000 --password secret # let others join it
|
||||
cargo run -- --connect 127.0.0.1:5000 --password secret # join a server or host
|
||||
```
|
||||
|
||||
Client options: `--save NAME`, `--no-save`, `--host [PORT]`,
|
||||
`--connect ADDR[:PORT]`, `--name NAME`, `--password TEXT`, `--map MAP`.
|
||||
Server options: `--config FILE`, `--port PORT`, `--password TEXT`,
|
||||
`--max-players N`, `--database URL`, `--world NAME|UUID`, `--no-save`,
|
||||
`--map MAP`, `--autosave SECS`, `--motd TEXT`, `--admin ID`, `--whitelist`.
|
||||
Client options: `--name NAME`, `--save FILE`, `--no-save`, `--map MAP`.
|
||||
Server options: `--port PORT`, `--save FILE`, `--no-save`, `--map MAP`.
|
||||
|
||||
On Windows, release builds of the client open no console window, so command-line
|
||||
output such as `--help` and logs isn't shown. Use a debug build to see it.
|
||||
|
||||
The world autosaves periodically (in the background) and on exit (window close,
|
||||
Ctrl-C, SIGTERM).
|
||||
|
||||
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.
|
||||
The world autosaves periodically and on exit (window close, Ctrl-C, SIGTERM).
|
||||
Characters are identified by name and are restored when that name logs in again.
|
||||
|
||||
## 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 |
|
||||
|---------------------|------------------------------------------------------|
|
||||
| WASD / arrows | Move freely, also diagonally (you face that way) |
|
||||
| WASD / arrows | Move (you face the direction you move) |
|
||||
| E | Pick up (own tile, otherwise the faced tile) |
|
||||
| F / left click | Interact with the faced / clicked building; machines open their window |
|
||||
| F / left click | Interact with the faced / clicked building |
|
||||
| U | Use the selected item (eat, heal, …) |
|
||||
| G | Drop the selected item |
|
||||
| X | Deconstruct the faced building |
|
||||
| 1–9, 0, mouse wheel | Select an inventory slot (or click it) |
|
||||
| C / B | Crafting / build menu (W/S select, Enter confirm, or click) |
|
||||
| 1–9, 0, mouse wheel | Select an inventory slot |
|
||||
| C / B | Crafting / build menu (W/S select, Enter confirm, Esc close) |
|
||||
| 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
|
||||
|
||||
All content is loaded from `assets/` at startup. Every `.rsn` file below `assets/data/`
|
||||
(recursively) may contain any mix of `items`, `recipes`, `buildings`, `machines` and
|
||||
`skills`, so a
|
||||
(recursively) may contain any mix of `items`, `recipes`, `buildings` and `skills`, so a
|
||||
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.
|
||||
|
||||
|
|
@ -256,9 +84,10 @@ Required: `id`, `name`, `sprite`. Optional: `description`, `max_stack` (default
|
|||
|
||||
```rsn
|
||||
recipes: [
|
||||
{ id: "make_pickaxe", inputs: [("iron_plate", 2), ("plank", 1)],
|
||||
outputs: [("pickaxe", 1)], tools: ["wrench"], station: Some("workbench"),
|
||||
skill: Some({ skill: "smithing", level: 1 }), xp: 25 },
|
||||
{ id: "smelt_iron", name: Some("Smelt iron plate"),
|
||||
inputs: [("iron_ore", 2)], outputs: [("iron_plate", 1)],
|
||||
tools: [], station: Some("furnace"),
|
||||
skill: Some({ skill: "smithing", level: 1 }), xp: 10 },
|
||||
]
|
||||
```
|
||||
|
||||
|
|
@ -268,7 +97,7 @@ Required: `id`, `inputs`, `outputs`. Optional: `name`, `tools` (needed but not c
|
|||
|
||||
### Buildings
|
||||
|
||||
Anything placed on the grid: floors, walls, doors, machines, trees, rocks.
|
||||
Anything placed on the grid: walls, doors, machines, trees, rocks.
|
||||
|
||||
```rsn
|
||||
buildings: [
|
||||
|
|
@ -282,46 +111,10 @@ buildings: [
|
|||
```
|
||||
|
||||
Required: `id`, `name`, `sprite`. Optional: `description`, `variants` (name → sprite),
|
||||
`layer` (`"structure"` by default, or `"floor"`), `solid` (default true),
|
||||
`max_health`, `stations` (tags for recipes), `construction`
|
||||
`solid` (default true), `max_health`, `stations` (tags for recipes), `construction`
|
||||
(makes it buildable from the build menu), `deconstructible` (default true; refunds
|
||||
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
|
||||
|
||||
```rsn
|
||||
|
|
@ -365,192 +158,20 @@ pub fn toggle(ctx) {
|
|||
|
||||
### Maps (Tiled)
|
||||
|
||||
Maps are finite orthogonal `.tmx` files (at most 1,000,000 cells) with embedded or
|
||||
external tilesets. Each map cell is one game tile (drawn at 32×32; 32 px tilesets look best).
|
||||
Layer/group offsets are not supported.
|
||||
Maps are orthogonal `.tmx` files with embedded or external tilesets. Each map cell is one
|
||||
game tile (drawn at 32×32; 32 px tilesets look best).
|
||||
|
||||
- Unmarked tile layers are static terrain/decorations, rendered in order. A tile is solid if the tile has the boolean custom
|
||||
- Tile layers are 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`.
|
||||
- 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**:
|
||||
- `spawn` – player spawn point.
|
||||
- `building` – custom property `proto` = building id. `state.health`,
|
||||
`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`.
|
||||
- `building` – custom property `proto` = building id; any integer properties
|
||||
override the building's `vars`.
|
||||
- `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).
|
||||
Tile objects inherit gameplay properties from their tiles; object properties
|
||||
override them. Painted and explicit buildings share the same occupancy checks.
|
||||
|
||||
Set the map's **integer** `default_tile` property to a static tile's local ID
|
||||
(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.
|
||||
A map is only used to create a new world; after that the save file is authoritative.
|
||||
Delete the save file to start a fresh world.
|
||||
|
||||
`tools/gen_placeholder_assets.py` regenerates the placeholder sprites and example map.
|
||||
|
||||
|
|
@ -558,7 +179,6 @@ positions, so avoid enclosing them in newly solid terrain.
|
|||
|
||||
```sh
|
||||
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
|
||||
```
|
||||
|
||||
|
|
@ -579,27 +199,3 @@ 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
|
||||
staging directory is kept next to the archive. Options: `--format zip|tar.gz`,
|
||||
`--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.
|
||||
|
|
|
|||
|
|
@ -4,25 +4,6 @@
|
|||
// solid (true), max_health (100), stations (tags for recipes), construction
|
||||
// ({ materials, tools, skill, xp }), deconstructible (true), on_interact, vars.
|
||||
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",
|
||||
max_health: 300,
|
||||
|
|
@ -43,17 +24,11 @@ buildings: [
|
|||
},
|
||||
{
|
||||
id: "furnace", name: "Furnace", sprite: "sprites/buildings/furnace.png",
|
||||
description: "Smelts ore. Fuel it with logs.",
|
||||
variants: { "lit": "sprites/buildings/furnace_lit.png" },
|
||||
stations: ["furnace"],
|
||||
construction: Some({ materials: [("iron_ore", 4), ("log", 1)], xp: 10,
|
||||
skill: Some({ skill: "construction", level: 1 }) }),
|
||||
},
|
||||
{
|
||||
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 }) }),
|
||||
on_interact: Some("furnace::toggle"),
|
||||
},
|
||||
{
|
||||
id: "table", name: "Table", sprite: "sprites/buildings/table.png",
|
||||
|
|
@ -66,16 +41,6 @@ buildings: [
|
|||
on_interact: Some("vending::dispense"),
|
||||
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",
|
||||
deconstructible: false,
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
// 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)),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
@ -4,6 +4,15 @@
|
|||
// (tag provided by an adjacent building), skill ({ skill, level }), xp, on_craft.
|
||||
// Ingredients can be written as ("item", count) or { item: "item", count: n }.
|
||||
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",
|
||||
name: Some("Saw planks"),
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
<?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>
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
<?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="5" nextobjectid="32">
|
||||
<properties><property name="default_tile" type="int" value="6"/></properties>
|
||||
<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">
|
||||
<tileset firstgid="1" source="../tilesets/station.tsx"/>
|
||||
<layer id="1" name="Ground" width="40" height="28">
|
||||
<data encoding="csv">
|
||||
|
|
@ -67,8 +66,7 @@
|
|||
</data>
|
||||
</layer>
|
||||
<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 id="2" name="" class="building" x="592" y="272">
|
||||
<properties>
|
||||
|
|
@ -100,162 +98,142 @@
|
|||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<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">
|
||||
<object id="7" name="" class="building" x="176" y="176">
|
||||
<properties>
|
||||
<property name="proto" value="vending_machine"/>
|
||||
<property name="stock" type="int" value="5"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="9" name="" class="building" x="176" y="560">
|
||||
<object id="8" name="" class="building" x="176" y="560">
|
||||
<properties>
|
||||
<property name="proto" value="table"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="10" name="" class="building" x="208" y="560">
|
||||
<object id="9" name="" class="building" x="208" y="560">
|
||||
<properties>
|
||||
<property name="proto" value="table"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="11" name="" class="item" x="208" y="592">
|
||||
<object id="10" name="" class="item" x="208" y="592">
|
||||
<properties>
|
||||
<property name="proto" value="bread"/>
|
||||
<property name="count" type="int" value="2"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="12" name="" class="item" x="688" y="336">
|
||||
<object id="11" name="" class="item" x="688" y="336">
|
||||
<properties>
|
||||
<property name="proto" value="screwdriver"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="13" name="" class="item" x="720" y="336">
|
||||
<object id="12" name="" class="item" x="720" y="336">
|
||||
<properties>
|
||||
<property name="proto" value="wrench"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="14" name="" class="item" x="752" y="336">
|
||||
<object id="13" name="" class="item" x="752" y="336">
|
||||
<properties>
|
||||
<property name="proto" value="pickaxe"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="15" name="" class="item" x="784" y="336">
|
||||
<object id="14" name="" class="item" x="784" y="336">
|
||||
<properties>
|
||||
<property name="proto" value="axe"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="16" name="" class="item" x="400" y="336">
|
||||
<object id="15" name="" class="item" x="400" y="336">
|
||||
<properties>
|
||||
<property name="proto" value="iron_ore"/>
|
||||
<property name="count" type="int" value="6"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="17" name="" class="item" x="464" y="208">
|
||||
<object id="16" name="" class="item" x="464" y="208">
|
||||
<properties>
|
||||
<property name="proto" value="medkit"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="18" name="" class="building" x="144" y="784">
|
||||
<object id="17" name="" class="building" x="144" y="784">
|
||||
<properties>
|
||||
<property name="proto" value="tree"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="19" name="" class="building" x="208" y="816">
|
||||
<object id="18" name="" class="building" x="208" y="816">
|
||||
<properties>
|
||||
<property name="proto" value="tree"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="20" name="" class="building" x="496" y="720">
|
||||
<object id="19" name="" class="building" x="496" y="720">
|
||||
<properties>
|
||||
<property name="proto" value="tree"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="21" name="" class="building" x="720" y="656">
|
||||
<object id="20" name="" class="building" x="720" y="656">
|
||||
<properties>
|
||||
<property name="proto" value="tree"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="22" name="" class="building" x="1072" y="176">
|
||||
<object id="21" name="" class="building" x="1072" y="176">
|
||||
<properties>
|
||||
<property name="proto" value="tree"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="23" name="" class="building" x="1104" y="304">
|
||||
<object id="22" name="" class="building" x="1104" y="304">
|
||||
<properties>
|
||||
<property name="proto" value="tree"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="24" name="" class="building" x="1008" y="464">
|
||||
<object id="23" name="" class="building" x="1008" y="464">
|
||||
<properties>
|
||||
<property name="proto" value="tree"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="25" name="" class="building" x="976" y="144">
|
||||
<object id="24" name="" class="building" x="976" y="144">
|
||||
<properties>
|
||||
<property name="proto" value="ore_rock"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="26" name="" class="building" x="1072" y="368">
|
||||
<object id="25" name="" class="building" x="1072" y="368">
|
||||
<properties>
|
||||
<property name="proto" value="ore_rock"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="27" name="" class="building" x="1136" y="496">
|
||||
<object id="26" name="" class="building" x="1136" y="496">
|
||||
<properties>
|
||||
<property name="proto" value="ore_rock"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="28" name="" class="item" x="816" y="720">
|
||||
<object id="27" name="" class="item" x="816" y="720">
|
||||
<properties>
|
||||
<property name="proto" value="apple"/>
|
||||
<property name="count" type="int" value="3"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="29" name="" class="item" x="464" y="816">
|
||||
<object id="28" name="" class="item" x="464" y="816">
|
||||
<properties>
|
||||
<property name="proto" value="log"/>
|
||||
<property name="count" type="int" value="4"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</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>
|
||||
</map>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
// 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.");
|
||||
}
|
||||
|
Before Width: | Height: | Size: 190 B |
|
Before Width: | Height: | Size: 188 B |
|
Before Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 127 B |
|
Before Width: | Height: | Size: 145 B |
|
Before Width: | Height: | Size: 264 B |
|
Before Width: | Height: | Size: 250 B |
|
Before Width: | Height: | Size: 183 B |
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"extensionsPath": "tiled",
|
||||
"folders": [
|
||||
"maps",
|
||||
"tilesets",
|
||||
"sprites"
|
||||
],
|
||||
"propertyTypes": []
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 319 B |
|
|
@ -1,466 +0,0 @@
|
|||
/* 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.";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -2,13 +2,13 @@
|
|||
<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"/>
|
||||
<tile id="0">
|
||||
<properties><property name="name" value="floor"/><property name="proto" value="floor"/><property name="state.layer" value=""floor""/><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>
|
||||
<properties><property name="name" value="floor"/></properties>
|
||||
</tile>
|
||||
<tile id="1">
|
||||
<properties><property name="name" value="dark_floor"/><property name="proto" value="dark_floor"/><property name="state.layer" value=""floor""/><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>
|
||||
<properties><property name="name" value="dark_floor"/></properties>
|
||||
</tile>
|
||||
<tile id="2">
|
||||
<properties><property name="name" value="wall"/><property name="solid" type="bool" value="true"/><property name="proto" value="wall"/><property name="state.layer" value=""structure""/><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>
|
||||
<properties><property name="name" value="wall"/><property name="solid" type="bool" value="true"/></properties>
|
||||
</tile>
|
||||
<tile id="3">
|
||||
<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>
|
||||
</tile>
|
||||
<tile id="5">
|
||||
<properties><property name="name" value="plating"/><property name="proto" value="plating"/><property name="state.layer" value=""floor""/><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>
|
||||
<properties><property name="name" value="plating"/></properties>
|
||||
</tile>
|
||||
<tile id="6">
|
||||
<properties><property name="name" value="dirt"/></properties>
|
||||
</tile>
|
||||
<tile id="7">
|
||||
<properties><property name="name" value="window"/><property name="solid" type="bool" value="true"/><property name="proto" value="window"/><property name="state.layer" value=""structure""/><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>
|
||||
<properties><property name="name" value="window"/><property name="solid" type="bool" value="true"/></properties>
|
||||
</tile>
|
||||
</tileset>
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
// "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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
// 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" },
|
||||
],
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
// 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." },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
// "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" },
|
||||
],
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
|
@ -1,47 +0,0 @@
|
|||
// 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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
// "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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
// 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",
|
||||
}
|
||||
|
|
@ -10,14 +10,9 @@ path = "src/main.rs"
|
|||
[dependencies]
|
||||
game_core = { workspace = true }
|
||||
game_server = { workspace = true }
|
||||
bevy = { workspace = true, default-features = true, features = ["bevy_ui_widgets", "system_clipboard"] }
|
||||
bevy = { workspace = true, default-features = true }
|
||||
bevy_replicon = { workspace = true }
|
||||
bevy_replicon_renet = { workspace = true }
|
||||
directories = { workspace = true }
|
||||
rsn = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
|
|
@ -1,675 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,95 +11,55 @@ use game_core::{
|
|||
use game_server::actions::REACH;
|
||||
|
||||
use crate::{
|
||||
hud::{Menu, UiState, activate_entry, menu_entries},
|
||||
machine::{InteractAt, OpenMachine},
|
||||
render::{LocalCharacter, OffLevel},
|
||||
session::AppState,
|
||||
ui::{TextEditSystems, UiFocus, pointer_over_ui},
|
||||
render::LocalCharacter,
|
||||
ui::{Menu, UiState, menu_entries},
|
||||
};
|
||||
|
||||
pub struct InputPlugin;
|
||||
|
||||
impl Plugin for InputPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
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)),
|
||||
);
|
||||
app.add_systems(Update, (movement, menu_input, actions, mouse).chain());
|
||||
}
|
||||
}
|
||||
|
||||
fn game_input_allowed(focus: Res<UiFocus>, ui: Res<UiState>) -> bool {
|
||||
!focus.captures_keyboard() && !ui.paused && !ui.chat_open
|
||||
}
|
||||
const MOVE_KEYS: [(KeyCode, Dir); 8] = [
|
||||
(KeyCode::KeyW, Dir::North),
|
||||
(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),
|
||||
];
|
||||
|
||||
/// 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.
|
||||
/// Sends the held direction whenever it changes. The most recently pressed key wins.
|
||||
fn movement(
|
||||
mut commands: Commands,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
ui: Res<UiState>,
|
||||
focus: Res<UiFocus>,
|
||||
mut sent: Local<SetMovement>,
|
||||
mut held: Local<Vec<Dir>>,
|
||||
mut sent: Local<Option<Dir>>,
|
||||
) {
|
||||
let axis = |neg: [KeyCode; 2], pos: [KeyCode; 2]| {
|
||||
i8::from(keys.any_pressed(pos)) - i8::from(keys.any_pressed(neg))
|
||||
};
|
||||
let movement = if ui.blocks_game() || ui.chat_open || focus.captures_keyboard() {
|
||||
SetMovement::default()
|
||||
} else {
|
||||
SetMovement {
|
||||
x: axis(
|
||||
[KeyCode::KeyA, KeyCode::ArrowLeft],
|
||||
[KeyCode::KeyD, KeyCode::ArrowRight],
|
||||
),
|
||||
y: axis(
|
||||
[KeyCode::KeyW, KeyCode::ArrowUp],
|
||||
[KeyCode::KeyS, KeyCode::ArrowDown],
|
||||
),
|
||||
held.retain(|dir| {
|
||||
MOVE_KEYS
|
||||
.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 != movement {
|
||||
*sent = movement;
|
||||
commands.client_trigger(movement);
|
||||
if *sent != dir {
|
||||
*sent = dir;
|
||||
commands.client_trigger(SetMoveDir(dir));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +80,10 @@ fn menu_input(
|
|||
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
|
||||
) {
|
||||
let Some(menu) = ui.menu else { return };
|
||||
if keys.just_pressed(KeyCode::Escape) {
|
||||
ui.menu = None;
|
||||
return;
|
||||
}
|
||||
let entries = menu_entries(menu, &data);
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
|
|
@ -132,8 +96,17 @@ fn menu_input(
|
|||
}
|
||||
if keys.any_just_pressed([KeyCode::Enter, KeyCode::Space, KeyCode::NumpadEnter]) {
|
||||
let id = entries[ui.cursor.min(entries.len() - 1)].clone();
|
||||
match menu {
|
||||
Menu::Craft => commands.client_trigger(Craft { recipe: id }),
|
||||
Menu::Build => {
|
||||
if let Some((pos, facing, _)) = local_state(&local, &characters) {
|
||||
activate_entry(&mut commands, menu, id, pos, facing);
|
||||
commands.client_trigger(Construct {
|
||||
building: id,
|
||||
target: pos.step(facing),
|
||||
});
|
||||
}
|
||||
}
|
||||
Menu::Help => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -157,10 +130,9 @@ fn actions(
|
|||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut wheel: MessageReader<MouseWheel>,
|
||||
mut ui: ResMut<UiState>,
|
||||
machine: Res<OpenMachine>,
|
||||
local: Res<LocalCharacter>,
|
||||
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
|
||||
items: Query<&GridPos, (With<GroundItem>, Without<OffLevel>)>,
|
||||
items: Query<&GridPos, With<GroundItem>>,
|
||||
) {
|
||||
if keys.just_pressed(KeyCode::KeyH) {
|
||||
ui.toggle(Menu::Help);
|
||||
|
|
@ -179,16 +151,9 @@ fn actions(
|
|||
ui.selected_slot = i;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
for ev in wheel.read() {
|
||||
let len = INVENTORY_SLOTS;
|
||||
ui.selected_slot = if y > 0.0 {
|
||||
ui.selected_slot = if ev.y > 0.0 {
|
||||
(ui.selected_slot + len - 1) % len
|
||||
} else {
|
||||
(ui.selected_slot + 1) % len
|
||||
|
|
@ -213,7 +178,7 @@ fn actions(
|
|||
commands.client_trigger(PickUp { target });
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyF) {
|
||||
commands.trigger(InteractAt(front));
|
||||
commands.client_trigger(Interact { target: front });
|
||||
}
|
||||
if keys.just_pressed(KeyCode::KeyX) {
|
||||
commands.client_trigger(Deconstruct { target: front });
|
||||
|
|
@ -231,22 +196,16 @@ fn actions(
|
|||
}
|
||||
|
||||
/// Left click on a tile within reach: interact with a building, otherwise pick up items.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mouse(
|
||||
mut commands: Commands,
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
interactions: Query<&Interaction>,
|
||||
ui: Res<UiState>,
|
||||
window: Query<&Window, With<PrimaryWindow>>,
|
||||
camera: Query<(&Camera, &GlobalTransform)>,
|
||||
local: Res<LocalCharacter>,
|
||||
characters: Query<&GridPos, With<Character>>,
|
||||
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<OffLevel>)>,
|
||||
buildings: Query<&GridPos, With<Building>>,
|
||||
) {
|
||||
if !buttons.just_pressed(MouseButton::Left)
|
||||
|| ui.blocks_game()
|
||||
|| pointer_over_ui(&interactions)
|
||||
{
|
||||
if !buttons.just_pressed(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
let Some(me) = local.0.and_then(|e| characters.get(e).ok()) else {
|
||||
|
|
@ -268,11 +227,8 @@ fn mouse(
|
|||
if me.distance(target) > REACH {
|
||||
return;
|
||||
}
|
||||
if buildings
|
||||
.iter()
|
||||
.any(|(p, state)| *p == target && state.layer == game_core::data::BuildingLayer::Structure)
|
||||
{
|
||||
commands.trigger(InteractAt(target));
|
||||
if buildings.iter().any(|p| *p == target) {
|
||||
commands.client_trigger(Interact { target });
|
||||
} else {
|
||||
commands.client_trigger(PickUp { target });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,428 +0,0 @@
|
|||
//! 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +1,50 @@
|
|||
//! Game client.
|
||||
//!
|
||||
//! ```text
|
||||
//! game start menu
|
||||
//! game --save NAME play a local world directly
|
||||
//! game --host [PORT] play a local world that friends can join
|
||||
//! game singleplayer (persistent local world)
|
||||
//! game --host [PORT] singleplayer world that friends can join
|
||||
//! game --connect ADDR[:PORT] join a dedicated server or host
|
||||
//! ```
|
||||
//! Common options: `--name NAME`, `--password PASSWORD`, `--map MAP`, `--no-save`.
|
||||
//! Common options: `--name NAME`, `--save FILE`, `--map MAP`.
|
||||
|
||||
// Release builds on Windows run without a console window.
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
mod hud;
|
||||
mod input;
|
||||
mod machine;
|
||||
mod menu;
|
||||
mod profile;
|
||||
mod net;
|
||||
mod render;
|
||||
mod saves;
|
||||
mod session;
|
||||
mod ui;
|
||||
|
||||
use std::{net::SocketAddr, path::PathBuf};
|
||||
|
||||
use bevy::{prelude::*, window::WindowResolution};
|
||||
use bevy_replicon::prelude::*;
|
||||
use bevy_replicon_renet::RepliconRenetPlugins;
|
||||
use game_core::CorePlugin;
|
||||
use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings};
|
||||
|
||||
use crate::{
|
||||
profile::Profile,
|
||||
session::{AppState, SessionRequest, SessionTarget},
|
||||
};
|
||||
/// How this client runs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Mode {
|
||||
/// Server logic runs in-process without networking.
|
||||
Singleplayer,
|
||||
/// Server logic runs in-process and accepts remote players.
|
||||
Host { port: u16 },
|
||||
/// Pure client connected to a remote server.
|
||||
Join { addr: SocketAddr },
|
||||
}
|
||||
|
||||
/// The local player's login name, used to find our character among replicated ones.
|
||||
#[derive(Resource, Clone, Debug)]
|
||||
pub struct LocalPlayer {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Resource, Clone, Debug)]
|
||||
pub struct ClientMode(pub Mode);
|
||||
|
||||
fn main() -> AppExit {
|
||||
let mut profile = Profile::load();
|
||||
let (request, map) = parse_args(&mut profile);
|
||||
let (mode, name, settings) = parse_args();
|
||||
let mut app = App::new();
|
||||
let assets = game_core::assets_dir();
|
||||
app.add_plugins((
|
||||
|
|
@ -45,7 +56,7 @@ fn main() -> AppExit {
|
|||
.set(ImagePlugin::default_nearest())
|
||||
.set(WindowPlugin {
|
||||
primary_window: Some(Window {
|
||||
title: "Station".to_owned(),
|
||||
title: format!("Station - {name}"),
|
||||
resolution: WindowResolution::new(1280, 800),
|
||||
..default()
|
||||
}),
|
||||
|
|
@ -54,36 +65,43 @@ fn main() -> AppExit {
|
|||
RepliconPlugins,
|
||||
RepliconRenetPlugins,
|
||||
CorePlugin,
|
||||
))
|
||||
.insert_resource(profile)
|
||||
.insert_resource(menu::StartMap(map))
|
||||
));
|
||||
|
||||
match &mode {
|
||||
Mode::Singleplayer => {
|
||||
app.add_plugins(GameServerPlugin { settings });
|
||||
}
|
||||
Mode::Host { port } => {
|
||||
app.add_plugins((
|
||||
GameServerPlugin { settings },
|
||||
NetworkServerPlugin { port: *port },
|
||||
));
|
||||
}
|
||||
Mode::Join { .. } => {}
|
||||
}
|
||||
|
||||
app.insert_resource(LocalPlayer { name })
|
||||
.insert_resource(ClientMode(mode))
|
||||
.insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.05)))
|
||||
.add_plugins((
|
||||
ui::UiFrameworkPlugin,
|
||||
session::SessionPlugin,
|
||||
menu::MenuPlugin,
|
||||
hud::HudPlugin,
|
||||
machine::MachinePlugin,
|
||||
net::NetPlugin,
|
||||
render::RenderPlugin,
|
||||
input::InputPlugin,
|
||||
));
|
||||
if let Some(request) = request {
|
||||
app.insert_resource(request);
|
||||
app.world_mut()
|
||||
.resource_mut::<NextState<AppState>>()
|
||||
.set(AppState::Connecting);
|
||||
}
|
||||
app.run()
|
||||
ui::UiPlugin,
|
||||
))
|
||||
.run()
|
||||
}
|
||||
|
||||
/// Returns the session to start right away (skipping the menu), if any, and the map for new worlds.
|
||||
fn parse_args(profile: &mut Profile) -> (Option<SessionRequest>, String) {
|
||||
let mut host = None;
|
||||
let mut connect = None;
|
||||
let mut save = None;
|
||||
let mut no_save = false;
|
||||
let mut password = String::new();
|
||||
let mut map = game_core::DEFAULT_MAP.to_owned();
|
||||
fn parse_args() -> (Mode, String, ServerSettings) {
|
||||
let mut mode = Mode::Singleplayer;
|
||||
let mut name = std::env::var("USER")
|
||||
.ok()
|
||||
.filter(|n| game_server::players::valid_name(n))
|
||||
.unwrap_or_else(|| "Player".to_owned());
|
||||
let mut settings = ServerSettings {
|
||||
save_path: Some(PathBuf::from("saves/singleplayer.rsn")),
|
||||
..default()
|
||||
};
|
||||
let mut args = std::env::args().skip(1).peekable();
|
||||
while let Some(arg) = args.next() {
|
||||
let mut value = |what: &str| {
|
||||
|
|
@ -92,49 +110,41 @@ fn parse_args(profile: &mut Profile) -> (Option<SessionRequest>, String) {
|
|||
};
|
||||
match arg.as_str() {
|
||||
"--host" => {
|
||||
host = Some(match args.peek() {
|
||||
let port = match args.peek() {
|
||||
Some(p) if !p.starts_with("--") => args
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap_or_else(|_| usage("invalid port")),
|
||||
_ => game_core::DEFAULT_PORT,
|
||||
});
|
||||
};
|
||||
mode = Mode::Host { port };
|
||||
}
|
||||
"--connect" => connect = Some(value("an address")),
|
||||
"--name" => profile.name = value("a name"),
|
||||
"--password" => password = value("a password"),
|
||||
"--save" => save = Some(value("a save name")),
|
||||
"--no-save" => no_save = true,
|
||||
"--map" => map = value("a map path"),
|
||||
"--connect" => {
|
||||
let addr = value("an address");
|
||||
let addr = if addr.contains(':') {
|
||||
addr
|
||||
} else {
|
||||
format!("{addr}:{}", game_core::DEFAULT_PORT)
|
||||
};
|
||||
let addr = std::net::ToSocketAddrs::to_socket_addrs(&addr)
|
||||
.ok()
|
||||
.and_then(|mut a| a.next())
|
||||
.unwrap_or_else(|| usage(&format!("can't resolve `{addr}`")));
|
||||
mode = Mode::Join { addr };
|
||||
}
|
||||
"--name" => name = value("a name"),
|
||||
"--save" => settings.save_path = Some(value("a file").into()),
|
||||
"--no-save" => settings.save_path = None,
|
||||
"--map" => settings.map = value("a map path"),
|
||||
"-h" | "--help" => usage(""),
|
||||
other => usage(&format!("unknown argument `{other}`")),
|
||||
}
|
||||
}
|
||||
if !game_server::players::valid_name(&profile.name) {
|
||||
if !game_server::players::valid_name(&name) {
|
||||
usage("names may only contain letters, digits, spaces, `_` and `-` (max 24)");
|
||||
}
|
||||
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)
|
||||
(mode, name, settings)
|
||||
}
|
||||
|
||||
fn usage(error: &str) -> ! {
|
||||
|
|
@ -142,19 +152,15 @@ fn usage(error: &str) -> ! {
|
|||
eprintln!("error: {error}\n");
|
||||
}
|
||||
eprintln!(
|
||||
"usage: game [--save NAME | --no-save] [--host [PORT]] [--connect ADDR[:PORT]]\n \
|
||||
[--name NAME] [--password PASSWORD] [--map MAP]\n\n\
|
||||
(no options) show the start menu\n\
|
||||
--save play the local world NAME (created if missing), stored in {saves}\n\
|
||||
--no-save play a local world that is not saved\n\
|
||||
--host let others join your local world (default port {port}, password from --password)\n\
|
||||
"usage: game [--host [PORT] | --connect ADDR[:PORT]] [--name NAME] [--save FILE | --no-save] [--map MAP]\n\n\
|
||||
(no mode) singleplayer, world saved to saves/singleplayer.rsn\n\
|
||||
--host play your world and let others join (default port {port})\n\
|
||||
--connect join a server (default port {port})\n\
|
||||
--name character name; characters persist per world\n\
|
||||
--password server password (joining) or password for others (hosting)\n\
|
||||
--save world file for singleplayer/host\n\
|
||||
--map Tiled map for new worlds (default {map})",
|
||||
port = game_core::DEFAULT_PORT,
|
||||
map = game_core::DEFAULT_MAP,
|
||||
saves = saves::saves_dir().display(),
|
||||
);
|
||||
std::process::exit(if error.is_empty() { 0 } else { 2 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,380 +0,0 @@
|
|||
//! 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
//! 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}."));
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,10 +4,11 @@ use bevy::prelude::*;
|
|||
use game_core::{
|
||||
GameData,
|
||||
components::*,
|
||||
grid::{Dir, TILE_SIZE, grid_to_world, position_to_world},
|
||||
grid::{Dir, TILE_SIZE, grid_to_world},
|
||||
map::MapLayout,
|
||||
};
|
||||
|
||||
use crate::session::SessionInfo;
|
||||
use crate::LocalPlayer;
|
||||
|
||||
pub struct RenderPlugin;
|
||||
|
||||
|
|
@ -15,16 +16,15 @@ impl Plugin for RenderPlugin {
|
|||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<LocalCharacter>()
|
||||
.add_systems(Startup, setup)
|
||||
.add_observer(spawn_map)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
find_local_character,
|
||||
spawn_map,
|
||||
building_visuals,
|
||||
item_visuals,
|
||||
character_visuals,
|
||||
move_characters,
|
||||
hide_other_levels,
|
||||
update_marker,
|
||||
follow_camera,
|
||||
)
|
||||
|
|
@ -59,14 +59,22 @@ fn setup(mut commands: Commands) {
|
|||
/// 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).
|
||||
fn spawn_map(
|
||||
add: On<Add, WorldMap>,
|
||||
mut commands: Commands,
|
||||
maps: Query<(Entity, &WorldMap), Changed<WorldMap>>,
|
||||
maps: Query<&WorldMap>,
|
||||
asset_server: Res<AssetServer>,
|
||||
mut layouts: ResMut<Assets<TextureAtlasLayout>>,
|
||||
) {
|
||||
for (entity, map) in &maps {
|
||||
let layout = &map.layout;
|
||||
commands.entity(entity).despawn_children();
|
||||
let Ok(map) = maps.get(add.entity) else {
|
||||
return;
|
||||
};
|
||||
let layout = match MapLayout::load(&game_core::assets_dir(), &map.path) {
|
||||
Ok(layout) => layout,
|
||||
Err(e) => {
|
||||
error!("can't display map: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let atlases: Vec<_> = layout
|
||||
.tilesets
|
||||
.iter()
|
||||
|
|
@ -84,16 +92,9 @@ fn spawn_map(
|
|||
.collect();
|
||||
|
||||
commands
|
||||
.entity(entity)
|
||||
.entity(add.entity)
|
||||
.insert((Transform::default(), Visibility::default()));
|
||||
let default_layer = layout
|
||||
.default_tile
|
||||
.map(|cell| game_core::map::TileLayerData {
|
||||
name: "Default terrain".into(),
|
||||
solid: false,
|
||||
cells: vec![Some(cell); (layout.width * layout.height) as usize],
|
||||
});
|
||||
for (layer_index, layer) in default_layer.iter().chain(layout.layers.iter()).enumerate() {
|
||||
for (layer_index, layer) in layout.layers.iter().enumerate() {
|
||||
let z = layer_index as f32 * 0.01;
|
||||
for (i, cell) in layer.cells.iter().enumerate() {
|
||||
let Some(cell) = cell else { continue };
|
||||
|
|
@ -105,33 +106,19 @@ fn spawn_map(
|
|||
(i as u32 / layout.width) as i32,
|
||||
);
|
||||
commands.spawn((
|
||||
ChildOf(entity),
|
||||
ChildOf(add.entity),
|
||||
Sprite {
|
||||
image: image.clone(),
|
||||
texture_atlas: Some(TextureAtlas {
|
||||
layout: atlas.clone(),
|
||||
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
|
||||
},
|
||||
flip_x: cell.flip_h,
|
||||
flip_y: 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
|
||||
}),
|
||||
),
|
||||
Transform::from_translation(grid_to_world(pos).extend(z)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -139,24 +126,19 @@ fn spawn_map(
|
|||
"map `{}` displayed ({}x{})",
|
||||
map.path, layout.width, layout.height
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn find_local_character(
|
||||
mut local: ResMut<LocalCharacter>,
|
||||
session: Res<SessionInfo>,
|
||||
player: Res<LocalPlayer>,
|
||||
characters: Query<(Entity, &Character)>,
|
||||
) {
|
||||
if local.0.is_some_and(|e| characters.contains(e)) {
|
||||
return;
|
||||
}
|
||||
if session.character.is_empty() {
|
||||
local.0 = None;
|
||||
return;
|
||||
}
|
||||
local.0 = characters
|
||||
.iter()
|
||||
.find(|(_, c)| c.name == session.character)
|
||||
.find(|(_, c)| c.name == player.name)
|
||||
.map(|(e, _)| e);
|
||||
}
|
||||
|
||||
|
|
@ -182,34 +164,12 @@ fn building_visuals(
|
|||
continue;
|
||||
};
|
||||
let mut entity = commands.entity(entity);
|
||||
let mut visual = sprite(
|
||||
entity.insert(sprite(
|
||||
&asset_server,
|
||||
def.sprite_for(state.variant.as_deref()),
|
||||
TILE_SIZE,
|
||||
);
|
||||
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
|
||||
}),
|
||||
);
|
||||
));
|
||||
let transform = Transform::from_translation(grid_to_world(*pos).extend(Z_BUILDING));
|
||||
if has_transform {
|
||||
entity.insert(transform);
|
||||
} else {
|
||||
|
|
@ -259,7 +219,7 @@ fn character_visuals(
|
|||
mut commands: Commands,
|
||||
asset_server: Res<AssetServer>,
|
||||
local: Res<LocalCharacter>,
|
||||
added: Query<(Entity, &Character, &Position), Added<Character>>,
|
||||
added: Query<(Entity, &Character, &GridPos), Added<Character>>,
|
||||
mut facing: Query<(&Facing, &mut Sprite), (With<Character>, Changed<Facing>)>,
|
||||
) {
|
||||
for (entity, character, pos) in &added {
|
||||
|
|
@ -269,7 +229,7 @@ fn character_visuals(
|
|||
}
|
||||
commands.entity(entity).insert((
|
||||
sprite,
|
||||
Transform::from_translation(position_to_world(pos.0).extend(Z_CHARACTER)),
|
||||
Transform::from_translation(grid_to_world(*pos).extend(Z_CHARACTER)),
|
||||
Visibility::default(),
|
||||
children![(
|
||||
Text2d::new(character.name.clone()),
|
||||
|
|
@ -283,53 +243,21 @@ fn character_visuals(
|
|||
}
|
||||
}
|
||||
|
||||
/// Smooths replicated positions, which arrive at the server tick rate; teleports for large
|
||||
/// jumps (respawn, login).
|
||||
/// Glides characters between tiles; teleports for large jumps (respawn, login).
|
||||
fn move_characters(
|
||||
time: Res<Time>,
|
||||
mut characters: Query<(&Position, &mut Transform), With<Character>>,
|
||||
mut characters: Query<(&GridPos, &mut Transform), With<Character>>,
|
||||
) {
|
||||
let t = 1.0 - (-20.0 * time.delta_secs()).exp();
|
||||
let speed = TILE_SIZE / game_server::movement::MOVE_DELAY * 1.2;
|
||||
for (pos, mut transform) in &mut characters {
|
||||
let target = position_to_world(pos.0);
|
||||
let target = grid_to_world(*pos);
|
||||
let current = transform.translation.truncate();
|
||||
let delta = target - current;
|
||||
let next = if delta.length() > TILE_SIZE * 3.0 {
|
||||
target
|
||||
if delta.length() > TILE_SIZE * 3.0 {
|
||||
transform.translation = target.extend(Z_CHARACTER);
|
||||
} else {
|
||||
current + delta * t
|
||||
};
|
||||
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);
|
||||
let step = delta.clamp_length_max(speed * time.delta_secs());
|
||||
transform.translation += step.extend(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -364,11 +292,6 @@ fn follow_camera(
|
|||
return;
|
||||
};
|
||||
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();
|
||||
camera.translation = camera.translation.lerp(goal, t);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
//! 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,380 @@
|
|||
//! 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,693 +0,0 @@
|
|||
//! 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,352 +0,0 @@
|
|||
//! 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,888 +0,0 @@
|
|||
//! 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -5,10 +5,7 @@ use std::collections::BTreeMap;
|
|||
use bevy::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
data::{BuildingDef, BuildingLayer, GameData, Ingredient, MachineDef, MachineRecipeDef},
|
||||
grid::Dir,
|
||||
};
|
||||
use crate::{data::GameData, grid::Dir};
|
||||
|
||||
pub const INVENTORY_SLOTS: usize = 20;
|
||||
|
||||
|
|
@ -35,28 +32,12 @@ 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)]
|
||||
pub struct Facing(pub Dir);
|
||||
|
||||
/// A player character. Names are unique and identify the persistent character.
|
||||
#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[require(GridPos, Position, Facing, Vitals, Inventory, Skills)]
|
||||
#[require(GridPos, Facing, Vitals, Inventory, Skills)]
|
||||
pub struct Character {
|
||||
pub name: String,
|
||||
}
|
||||
|
|
@ -89,7 +70,7 @@ impl Vitals {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ItemStack {
|
||||
pub item: String,
|
||||
pub count: u32,
|
||||
|
|
@ -230,251 +211,16 @@ pub struct Building {
|
|||
/// Mutable per-instance building state, editable from scripts.
|
||||
#[derive(Component, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
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 variant: Option<String>,
|
||||
pub health: i32,
|
||||
pub vars: BTreeMap<String, i64>,
|
||||
}
|
||||
|
||||
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.
|
||||
/// The Tiled map the world is built on. Path is relative to the assets directory.
|
||||
#[derive(Component, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorldMap {
|
||||
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)]
|
||||
|
|
@ -509,45 +255,6 @@ mod tests {
|
|||
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]
|
||||
fn skill_levels() {
|
||||
let mut skills = Skills::default();
|
||||
|
|
|
|||
|
|
@ -119,14 +119,6 @@ pub struct Construction {
|
|||
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)]
|
||||
pub struct BuildingDef {
|
||||
pub id: String,
|
||||
|
|
@ -134,8 +126,6 @@ pub struct BuildingDef {
|
|||
#[serde(default)]
|
||||
pub description: String,
|
||||
pub sprite: String,
|
||||
#[serde(default)]
|
||||
pub layer: BuildingLayer,
|
||||
/// Alternative sprites selectable from scripts with `ctx.set_variant(name)`.
|
||||
#[serde(default)]
|
||||
pub variants: HashMap<String, String>,
|
||||
|
|
@ -181,159 +171,6 @@ pub struct SkillDef {
|
|||
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.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct DataFile {
|
||||
|
|
@ -344,8 +181,6 @@ pub struct DataFile {
|
|||
#[serde(default)]
|
||||
pub buildings: Vec<BuildingDef>,
|
||||
#[serde(default)]
|
||||
pub machines: Vec<MachineDef>,
|
||||
#[serde(default)]
|
||||
pub skills: Vec<SkillDef>,
|
||||
}
|
||||
|
||||
|
|
@ -368,8 +203,6 @@ pub struct GameData {
|
|||
pub items: BTreeMap<String, ItemDef>,
|
||||
pub recipes: BTreeMap<String, RecipeDef>,
|
||||
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>,
|
||||
}
|
||||
|
||||
|
|
@ -463,16 +296,6 @@ impl GameData {
|
|||
errors,
|
||||
);
|
||||
}
|
||||
for machine in file.machines {
|
||||
insert(
|
||||
&mut self.machines,
|
||||
"machine",
|
||||
machine.id.clone(),
|
||||
machine,
|
||||
path,
|
||||
errors,
|
||||
);
|
||||
}
|
||||
for skill in file.skills {
|
||||
insert(
|
||||
&mut self.skills,
|
||||
|
|
@ -543,70 +366,6 @@ impl GameData {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
@ -618,11 +377,6 @@ impl GameData {
|
|||
.chain(self.buildings.values().flat_map(|b| {
|
||||
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 {
|
||||
|
|
@ -737,51 +491,6 @@ mod tests {
|
|||
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]
|
||||
fn shipped_data_is_valid() {
|
||||
let dir = crate::assets_dir().join("data");
|
||||
|
|
|
|||
|
|
@ -28,11 +28,6 @@ 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).
|
||||
pub fn grid_to_world(pos: GridPos) -> Vec2 {
|
||||
Vec2::new(
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ pub use data::GameData;
|
|||
pub const DEFAULT_MAP: &str = "maps/station.tmx";
|
||||
pub const DEFAULT_PORT: u16 = 5000;
|
||||
/// Bump when the protocol changes in incompatible ways.
|
||||
pub const PROTOCOL_ID: u64 = 6;
|
||||
pub const PROTOCOL_ID: u64 = 1;
|
||||
|
||||
/// Locates the `assets` directory.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -7,21 +7,18 @@
|
|||
//! - `spawn`: player spawn point.
|
||||
//! - `building`: building with the custom property `proto` (a building id).
|
||||
//! - `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).
|
||||
//!
|
||||
//! State overrides (`state.*`, `machine.*`) and legacy integer script variables
|
||||
//! are decoded by the server for both new worlds and imports.
|
||||
//! Any other custom properties are passed through in [`MapObject::properties`]
|
||||
//! (buildings copy integer properties into their script variables).
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::components::GridPos;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TilesetInfo {
|
||||
/// Image path relative to the assets directory.
|
||||
pub image: String,
|
||||
|
|
@ -33,29 +30,24 @@ pub struct TilesetInfo {
|
|||
pub tile_count: u32,
|
||||
pub spacing: 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, Serialize, Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct TileCell {
|
||||
pub tileset: usize,
|
||||
pub id: u32,
|
||||
pub flip_h: bool,
|
||||
pub flip_v: bool,
|
||||
pub flip_d: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TileLayerData {
|
||||
pub name: String,
|
||||
pub solid: bool,
|
||||
/// Row-major cells, `width * height` entries.
|
||||
pub cells: Vec<Option<TileCell>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MapObject {
|
||||
pub id: u32,
|
||||
pub class: String,
|
||||
|
|
@ -64,7 +56,7 @@ pub struct MapObject {
|
|||
pub properties: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MapLayout {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
|
|
@ -73,8 +65,6 @@ pub struct MapLayout {
|
|||
pub tilesets: Vec<TilesetInfo>,
|
||||
pub layers: Vec<TileLayerData>,
|
||||
pub objects: Vec<MapObject>,
|
||||
#[serde(default)]
|
||||
pub default_tile: Option<TileCell>,
|
||||
solid: Vec<bool>,
|
||||
}
|
||||
|
||||
|
|
@ -87,32 +77,8 @@ pub enum MapError {
|
|||
}
|
||||
|
||||
impl MapLayout {
|
||||
/// Loads an asset-relative or absolute map path.
|
||||
/// Loads `map_path` (relative to `assets_dir`).
|
||||
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 unsupported = |msg: String| MapError::Unsupported(full.clone(), msg);
|
||||
let map = tiled::Loader::new()
|
||||
|
|
@ -121,14 +87,10 @@ impl MapLayout {
|
|||
if map.orientation != tiled::Orientation::Orthogonal {
|
||||
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
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| assets_dir.into());
|
||||
let mirror_canon = mirror.and_then(|m| m.canonicalize().ok());
|
||||
let mut tilesets = Vec::new();
|
||||
for ts in map.tilesets() {
|
||||
let image = ts.image.as_ref().ok_or_else(|| {
|
||||
|
|
@ -138,16 +100,7 @@ impl MapLayout {
|
|||
))
|
||||
})?;
|
||||
let image_path = image.source.canonicalize().unwrap_or(image.source.clone());
|
||||
let rel = image_path
|
||||
.strip_prefix(&assets_canon)
|
||||
.ok()
|
||||
.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(|| {
|
||||
let rel = image_path.strip_prefix(&assets_canon).map_err(|_| {
|
||||
unsupported(format!(
|
||||
"tileset image `{}` must be inside the assets directory",
|
||||
image.source.display()
|
||||
|
|
@ -163,46 +116,16 @@ impl MapLayout {
|
|||
tile_count: ts.tilecount,
|
||||
spacing: ts.spacing,
|
||||
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);
|
||||
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 solid = vec![false; (width * height) as usize];
|
||||
let mut objects = Vec::new();
|
||||
let mut painted = Vec::new();
|
||||
let mut stack: Vec<tiled::Layer> = map.layers().collect();
|
||||
stack.reverse();
|
||||
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");
|
||||
match layer.layer_type() {
|
||||
tiled::LayerType::Tiles(tiles) => {
|
||||
|
|
@ -212,42 +135,25 @@ impl MapLayout {
|
|||
let mut cells = Vec::with_capacity(solid.len());
|
||||
for y in 0..height as i32 {
|
||||
for x in 0..width as i32 {
|
||||
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 cell = tiles.get_tile(x, y).map(|t| {
|
||||
let tile_solid = t
|
||||
.get_tile()
|
||||
.is_some_and(|d| bool_prop(&d.properties, "solid"));
|
||||
if tile_solid || layer_solid {
|
||||
solid[(y as u32 * width + x as u32) as usize] = true;
|
||||
}
|
||||
Some(TileCell {
|
||||
TileCell {
|
||||
tileset: t.tileset_index(),
|
||||
id: t.id(),
|
||||
flip_h: t.flip_h,
|
||||
flip_v: t.flip_v,
|
||||
flip_d: t.flip_d,
|
||||
})
|
||||
}
|
||||
});
|
||||
cells.push(cell);
|
||||
}
|
||||
}
|
||||
layers.push(TileLayerData {
|
||||
name: layer.name.clone(),
|
||||
solid: layer_solid,
|
||||
cells,
|
||||
});
|
||||
}
|
||||
|
|
@ -261,24 +167,16 @@ impl MapLayout {
|
|||
} else {
|
||||
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 {
|
||||
id: obj.id(),
|
||||
class: obj.user_type.clone(),
|
||||
name: obj.name.clone(),
|
||||
pos: GridPos::new((obj.x / tw).floor() as i32, (y / th).floor() as i32),
|
||||
properties,
|
||||
properties: obj
|
||||
.properties
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), prop_to_string(v)))
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -290,53 +188,6 @@ 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 {
|
||||
width,
|
||||
height,
|
||||
|
|
@ -345,7 +196,6 @@ impl MapLayout {
|
|||
tilesets,
|
||||
layers,
|
||||
objects,
|
||||
default_tile,
|
||||
solid,
|
||||
})
|
||||
}
|
||||
|
|
@ -356,46 +206,7 @@ impl MapLayout {
|
|||
|
||||
/// Tiles outside of the map are always solid.
|
||||
pub fn is_solid(&self, pos: GridPos) -> bool {
|
||||
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());
|
||||
}
|
||||
}
|
||||
!self.in_bounds(pos) || self.solid[(pos.y as u32 * self.width + pos.x as u32) as usize]
|
||||
}
|
||||
|
||||
pub fn objects_of_class<'a>(&'a self, class: &'a str) -> impl Iterator<Item = &'a MapObject> {
|
||||
|
|
@ -430,24 +241,11 @@ mod tests {
|
|||
assert!(!map.layers.is_empty());
|
||||
assert!(map.objects_of_class("spawn").next().is_some());
|
||||
assert!(map.is_solid(GridPos::new(-1, 0)));
|
||||
assert!(!map.is_solid(GridPos::new(0, 0)));
|
||||
assert!(map.objects.iter().any(|o| o.pos == GridPos::new(0, 0)
|
||||
&& o.properties.get("proto").is_some_and(|p| p == "wall")));
|
||||
assert!(
|
||||
map.is_solid(GridPos::new(0, 0)),
|
||||
"map border should be walls"
|
||||
);
|
||||
let spawn = map.objects_of_class("spawn").next().unwrap().pos;
|
||||
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")
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,27 +7,17 @@ use bevy::prelude::*;
|
|||
use bevy_replicon::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::components::*;
|
||||
use crate::{components::*, grid::Dir};
|
||||
|
||||
/// First event a client sends; selects (or creates) the persistent character.
|
||||
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Login {
|
||||
/// UUID identifying the player across sessions. Each player id owns one
|
||||
/// character per world.
|
||||
pub player_id: String,
|
||||
/// Name for a new character; ignored when the player already has one.
|
||||
pub name: String,
|
||||
/// Server password, empty if none.
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Movement keys the player is holding: `x` and `y` are -1, 0 or 1 (`y` down,
|
||||
/// both 0 to stop). Diagonals move at the same speed as straight lines.
|
||||
#[derive(Event, Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SetMovement {
|
||||
pub x: i8,
|
||||
pub y: i8,
|
||||
}
|
||||
/// Direction the player is holding, `None` to stop.
|
||||
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SetMoveDir(pub Option<Dir>);
|
||||
|
||||
/// Pick up the item on the given tile (own or adjacent).
|
||||
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||
|
|
@ -67,65 +57,18 @@ pub struct Deconstruct {
|
|||
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.
|
||||
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GameLog {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Login accepted.
|
||||
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Welcome {
|
||||
pub server_name: String,
|
||||
pub motd: String,
|
||||
/// Name of the player's character.
|
||||
pub character: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
/// The server is about to close the connection (login rejected, kicked, banned).
|
||||
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Rejected {
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// The player's admin rights changed.
|
||||
#[derive(Event, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AdminStatus {
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
pub struct ProtocolPlugin;
|
||||
|
||||
impl Plugin for ProtocolPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.replicate::<WorldMap>()
|
||||
.replicate::<GridPos>()
|
||||
.replicate::<Position>()
|
||||
.replicate::<Facing>()
|
||||
.replicate::<Character>()
|
||||
.replicate::<Vitals>()
|
||||
|
|
@ -134,10 +77,8 @@ impl Plugin for ProtocolPlugin {
|
|||
.replicate::<GroundItem>()
|
||||
.replicate::<Building>()
|
||||
.replicate::<BuildingState>()
|
||||
.replicate::<Machine>()
|
||||
.replicate::<Level>()
|
||||
.add_client_event::<Login>(Channel::Ordered)
|
||||
.add_client_event::<SetMovement>(Channel::Ordered)
|
||||
.add_client_event::<SetMoveDir>(Channel::Ordered)
|
||||
.add_client_event::<PickUp>(Channel::Ordered)
|
||||
.add_client_event::<Interact>(Channel::Ordered)
|
||||
.add_client_event::<UseItem>(Channel::Ordered)
|
||||
|
|
@ -145,12 +86,6 @@ impl Plugin for ProtocolPlugin {
|
|||
.add_client_event::<Craft>(Channel::Ordered)
|
||||
.add_client_event::<Construct>(Channel::Ordered)
|
||||
.add_client_event::<Deconstruct>(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);
|
||||
.add_server_event::<GameLog>(Channel::Ordered);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,14 +17,6 @@ rsn = { workspace = true }
|
|||
rune = { workspace = true }
|
||||
tiled = { 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.
|
||||
ctrlc = { version = "3.5", features = ["termination"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,9 @@ use game_core::{GameData, components::*, protocol::*};
|
|||
|
||||
use crate::{
|
||||
crafting::{self, item_name},
|
||||
movement,
|
||||
players::{Players, tell},
|
||||
scripting::{ScriptEngine, ScriptState, ScriptTarget},
|
||||
world::{Level, Levels, spawn_building, spawn_ground_item},
|
||||
world::{WorldLayout, spawn_building, spawn_ground_item},
|
||||
};
|
||||
|
||||
/// Read-only game data shared with scripts.
|
||||
|
|
@ -30,7 +29,6 @@ type CharacterData<'a> = (
|
|||
&'a mut Inventory,
|
||||
&'a mut Vitals,
|
||||
&'a mut Skills,
|
||||
&'a Level,
|
||||
);
|
||||
|
||||
type Characters<'w, 's> = Query<'w, 's, CharacterData<'static>>;
|
||||
|
|
@ -42,21 +40,11 @@ type Buildings<'w, 's> = Query<
|
|||
&'static Building,
|
||||
&'static GridPos,
|
||||
&'static mut BuildingState,
|
||||
&'static Level,
|
||||
),
|
||||
Without<Character>,
|
||||
>;
|
||||
type GroundItems<'w, 's> = Query<
|
||||
'w,
|
||||
's,
|
||||
(
|
||||
Entity,
|
||||
&'static mut GroundItem,
|
||||
&'static GridPos,
|
||||
&'static Level,
|
||||
),
|
||||
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) {
|
||||
use game_core::grid::Dir;
|
||||
|
|
@ -93,12 +81,11 @@ fn give_or_drop(
|
|||
data: &GameData,
|
||||
inventory: &mut Inventory,
|
||||
stack: ItemStack,
|
||||
level: &Level,
|
||||
pos: GridPos,
|
||||
) {
|
||||
let leftover = inventory.add(&stack.item, stack.count, data);
|
||||
if leftover > 0 {
|
||||
spawn_ground_item(commands, ItemStack::new(stack.item, leftover), level, pos);
|
||||
spawn_ground_item(commands, ItemStack::new(stack.item, leftover), pos);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +159,7 @@ pub fn on_pick_up(
|
|||
let Some(&entity) = players.by_client.get(&client) else {
|
||||
return;
|
||||
};
|
||||
let Ok((_, pos, mut facing, mut inventory, _, _, level)) = characters.get_mut(entity) else {
|
||||
let Ok((_, pos, mut facing, mut inventory, _, _)) = characters.get_mut(entity) else {
|
||||
return;
|
||||
};
|
||||
if pos.distance(target) > REACH {
|
||||
|
|
@ -180,8 +167,8 @@ pub fn on_pick_up(
|
|||
}
|
||||
face_towards(&mut facing, *pos, target);
|
||||
let mut picked = Vec::new();
|
||||
for (item_entity, mut item, item_pos, item_level) in &mut items {
|
||||
if *item_pos != target || item_level != level {
|
||||
for (item_entity, mut item, item_pos) in &mut items {
|
||||
if *item_pos != target {
|
||||
continue;
|
||||
}
|
||||
let leftover = inventory.add(&item.0.item, item.0.count, &data);
|
||||
|
|
@ -196,7 +183,7 @@ pub fn on_pick_up(
|
|||
}
|
||||
}
|
||||
if picked.is_empty() {
|
||||
let full = items.iter().any(|(_, _, p, l)| *p == target && l == level);
|
||||
let full = items.iter().any(|(_, _, p)| *p == target);
|
||||
tell(
|
||||
&mut commands,
|
||||
client,
|
||||
|
|
@ -227,7 +214,7 @@ pub fn on_drop_item(
|
|||
let Some(&entity) = players.by_client.get(&client) else {
|
||||
return;
|
||||
};
|
||||
let Ok((_, pos, _, mut inventory, _, _, level)) = characters.get_mut(entity) else {
|
||||
let Ok((_, pos, _, mut inventory, _, _)) = characters.get_mut(entity) else {
|
||||
return;
|
||||
};
|
||||
let Some(stack) = inventory.take_from_slot(ev.message.slot, u32::MAX) else {
|
||||
|
|
@ -243,13 +230,13 @@ pub fn on_drop_item(
|
|||
),
|
||||
);
|
||||
// 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()
|
||||
.find(|(_, i, p, l)| *p == pos && *l == level && i.0.item == stack.item)
|
||||
.find(|(_, i, p)| *p == pos && i.0.item == stack.item)
|
||||
{
|
||||
existing.0.count += stack.count;
|
||||
} else {
|
||||
spawn_ground_item(&mut commands, stack, level, *pos);
|
||||
spawn_ground_item(&mut commands, stack, *pos);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -265,8 +252,7 @@ pub fn on_use_item(
|
|||
let Some(&entity) = players.by_client.get(&client) else {
|
||||
return;
|
||||
};
|
||||
let Ok((character, pos, _, mut inventory, mut vitals, mut skills, level)) =
|
||||
characters.get_mut(entity)
|
||||
let Ok((character, pos, _, mut inventory, mut vitals, mut skills)) = characters.get_mut(entity)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
|
@ -300,7 +286,7 @@ pub fn on_use_item(
|
|||
inventory.remove(&item_id, 1);
|
||||
}
|
||||
for stack in outcome.spawned {
|
||||
spawn_ground_item(&mut commands, stack, level, *pos);
|
||||
spawn_ground_item(&mut commands, stack, *pos);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -319,7 +305,7 @@ pub fn on_interact(
|
|||
let Some(&entity) = players.by_client.get(&client) else {
|
||||
return;
|
||||
};
|
||||
let Ok((character, pos, mut facing, mut inventory, mut vitals, mut skills, level)) =
|
||||
let Ok((character, pos, mut facing, mut inventory, mut vitals, mut skills)) =
|
||||
characters.get_mut(entity)
|
||||
else {
|
||||
return;
|
||||
|
|
@ -329,10 +315,8 @@ pub fn on_interact(
|
|||
return;
|
||||
}
|
||||
face_towards(&mut facing, *pos, target);
|
||||
let Some((building_entity, building, _, mut state, _)) = buildings
|
||||
.iter_mut()
|
||||
.filter(|(_, _, p, _, l)| **p == target && *l == level)
|
||||
.max_by_key(|(_, _, _, state, _)| state.layer)
|
||||
let Some((building_entity, building, _, mut state)) =
|
||||
buildings.iter_mut().find(|(_, _, p, _)| **p == target)
|
||||
else {
|
||||
tell(&mut commands, client, "There is nothing there.");
|
||||
return;
|
||||
|
|
@ -341,10 +325,6 @@ pub fn on_interact(
|
|||
return;
|
||||
};
|
||||
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();
|
||||
if !def.description.is_empty() {
|
||||
text = format!("{text}: {}", def.description);
|
||||
|
|
@ -380,7 +360,7 @@ pub fn on_interact(
|
|||
}
|
||||
}
|
||||
for stack in outcome.spawned {
|
||||
spawn_ground_item(&mut commands, stack, level, target);
|
||||
spawn_ground_item(&mut commands, stack, target);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -392,21 +372,20 @@ pub fn on_craft(
|
|||
data: Res<SharedData>,
|
||||
engine: Res<ScriptEngine>,
|
||||
mut characters: Characters,
|
||||
buildings: Query<(&Building, &GridPos, &Level), Without<Character>>,
|
||||
buildings: Query<(&Building, &GridPos), Without<Character>>,
|
||||
) {
|
||||
let client = ev.client_id;
|
||||
let Some(&entity) = players.by_client.get(&client) else {
|
||||
return;
|
||||
};
|
||||
let Ok((character, pos, _, mut inventory, mut vitals, mut skills, level)) =
|
||||
characters.get_mut(entity)
|
||||
let Ok((character, pos, _, mut inventory, mut vitals, mut skills)) = characters.get_mut(entity)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let stations: Vec<String> = buildings
|
||||
.iter()
|
||||
.filter(|(_, p, l)| p.distance(*pos) <= REACH && *l == level)
|
||||
.filter_map(|(b, _, _)| data.0.buildings.get(&b.proto))
|
||||
.filter(|(_, p)| p.distance(*pos) <= REACH)
|
||||
.filter_map(|(b, _)| data.0.buildings.get(&b.proto))
|
||||
.flat_map(|def| def.stations.iter().cloned())
|
||||
.collect();
|
||||
|
||||
|
|
@ -442,7 +421,7 @@ pub fn on_craft(
|
|||
)
|
||||
{
|
||||
for stack in outcome.spawned {
|
||||
spawn_ground_item(&mut commands, stack, level, *pos);
|
||||
spawn_ground_item(&mut commands, stack, *pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -456,27 +435,21 @@ pub fn on_construct(
|
|||
mut commands: Commands,
|
||||
players: Res<Players>,
|
||||
data: Res<GameData>,
|
||||
levels: Res<Levels>,
|
||||
layout: Res<WorldLayout>,
|
||||
mut characters: Characters,
|
||||
buildings: Query<(&GridPos, &BuildingState, &Level), (With<Building>, Without<Character>)>,
|
||||
occupants: Query<(&Position, &Level), (With<Character>, Without<Building>)>,
|
||||
buildings: Query<&GridPos, (With<Building>, Without<Character>)>,
|
||||
occupants: Query<&GridPos, (With<Character>, Without<Building>)>,
|
||||
) {
|
||||
let client = ev.client_id;
|
||||
let target = ev.message.target;
|
||||
let Some(&entity) = players.by_client.get(&client) else {
|
||||
return;
|
||||
};
|
||||
let Ok((_, pos, mut facing, mut inventory, _, mut skills, level)) = characters.get_mut(entity)
|
||||
else {
|
||||
// Checked before borrowing the actor mutably.
|
||||
let occupied = occupants.iter().any(|p| *p == target);
|
||||
let Ok((_, pos, mut facing, mut inventory, _, mut skills)) = characters.get_mut(entity) else {
|
||||
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 {
|
||||
tell(&mut commands, client, "That is too far away.");
|
||||
return;
|
||||
|
|
@ -485,20 +458,7 @@ pub fn on_construct(
|
|||
let Some(def) = data.buildings.get(&ev.message.building) else {
|
||||
return;
|
||||
};
|
||||
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)
|
||||
})
|
||||
{
|
||||
if layout.0.is_solid(target) || buildings.iter().any(|p| *p == target) {
|
||||
tell(&mut commands, client, "There is no room to build there.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -512,14 +472,7 @@ pub fn on_construct(
|
|||
Ok(level_up) => {
|
||||
*inventory = new_inventory;
|
||||
*skills = new_skills;
|
||||
spawn_building(
|
||||
&mut commands,
|
||||
&data,
|
||||
&def.id,
|
||||
&level,
|
||||
target,
|
||||
Default::default(),
|
||||
);
|
||||
spawn_building(&mut commands, &data, &def.id, target, Default::default());
|
||||
tell(&mut commands, client, format!("You build a {}.", def.name));
|
||||
announce_level_up(&mut commands, client, &data, level_up);
|
||||
}
|
||||
|
|
@ -533,24 +486,14 @@ pub fn on_deconstruct(
|
|||
players: Res<Players>,
|
||||
data: Res<GameData>,
|
||||
mut characters: Characters,
|
||||
buildings: Query<
|
||||
(
|
||||
Entity,
|
||||
&Building,
|
||||
&GridPos,
|
||||
&BuildingState,
|
||||
Option<&Machine>,
|
||||
&Level,
|
||||
),
|
||||
Without<Character>,
|
||||
>,
|
||||
buildings: Query<(Entity, &Building, &GridPos), Without<Character>>,
|
||||
) {
|
||||
let client = ev.client_id;
|
||||
let target = ev.message.target;
|
||||
let Some(&entity) = players.by_client.get(&client) else {
|
||||
return;
|
||||
};
|
||||
let Ok((_, pos, mut facing, mut inventory, _, _, level)) = characters.get_mut(entity) else {
|
||||
let Ok((_, pos, mut facing, mut inventory, _, _)) = characters.get_mut(entity) else {
|
||||
return;
|
||||
};
|
||||
if pos.distance(target) > REACH {
|
||||
|
|
@ -558,10 +501,7 @@ pub fn on_deconstruct(
|
|||
return;
|
||||
}
|
||||
face_towards(&mut facing, *pos, target);
|
||||
let Some((building_entity, building, _, _, machine, _)) = buildings
|
||||
.iter()
|
||||
.filter(|(_, _, p, _, _, l)| **p == target && *l == level)
|
||||
.max_by_key(|(_, _, _, state, _, _)| state.layer)
|
||||
let Some((building_entity, building, _)) = buildings.iter().find(|(_, _, p)| **p == target)
|
||||
else {
|
||||
tell(&mut commands, client, "There is nothing to deconstruct.");
|
||||
return;
|
||||
|
|
@ -598,14 +538,10 @@ pub fn on_deconstruct(
|
|||
&data,
|
||||
&mut inventory,
|
||||
ItemStack::new(&material.item, material.count),
|
||||
level,
|
||||
*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();
|
||||
tell(
|
||||
&mut commands,
|
||||
|
|
|
|||
|
|
@ -1,506 +0,0 @@
|
|||
//! 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()],
|
||||
}
|
||||
}
|
||||
|
|
@ -1,375 +0,0 @@
|
|||
//! 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
//! 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -203,22 +203,22 @@ mod tests {
|
|||
let data = data();
|
||||
let mut inv = Inventory::default();
|
||||
let mut skills = Skills::default();
|
||||
let bench = vec!["workbench".to_owned()];
|
||||
// Logs stay in their slot (2 - 1 = 1 left), so the planks need a free slot.
|
||||
inv.add("log", 2, &data);
|
||||
let furnace = vec!["furnace".to_owned()];
|
||||
// Ore stays in its slot (3 - 2 = 1 left), so the plate needs a free slot.
|
||||
inv.add("iron_ore", 3, &data);
|
||||
for _ in 1..game_core::components::INVENTORY_SLOTS {
|
||||
inv.add("pickaxe", 1, &data);
|
||||
}
|
||||
let before = inv.clone();
|
||||
let err = try_craft(&data, "saw_planks", &bench, &mut inv, &mut skills).unwrap_err();
|
||||
let err = try_craft(&data, "smelt_iron", &furnace, &mut inv, &mut skills).unwrap_err();
|
||||
assert!(err.contains("full"), "{err}");
|
||||
assert_eq!(inv, before);
|
||||
assert!(skills.skills.is_empty());
|
||||
|
||||
// Consuming a whole stack frees its slot for the output.
|
||||
inv.remove("log", 1);
|
||||
try_craft(&data, "saw_planks", &bench, &mut inv, &mut skills).unwrap();
|
||||
assert!(inv.has("plank", 4));
|
||||
inv.remove("iron_ore", 1);
|
||||
try_craft(&data, "smelt_iron", &furnace, &mut inv, &mut skills).unwrap();
|
||||
assert!(inv.has("iron_plate", 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,179 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -5,13 +5,7 @@
|
|||
//! [`game_core::protocol`]); all game rules run here.
|
||||
|
||||
pub mod actions;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod console;
|
||||
pub mod crafting;
|
||||
pub mod embedded;
|
||||
pub mod machines;
|
||||
pub mod map_io;
|
||||
pub mod movement;
|
||||
pub mod network;
|
||||
pub mod persistence;
|
||||
|
|
@ -20,10 +14,9 @@ pub mod scripting;
|
|||
pub mod survival;
|
||||
pub mod world;
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_replicon::server::visibility::AppVisibilityExt;
|
||||
use game_core::GameData;
|
||||
|
||||
pub use network::NetworkServerPlugin;
|
||||
|
|
@ -33,21 +26,17 @@ pub use network::NetworkServerPlugin;
|
|||
pub struct ServerSettings {
|
||||
/// Tiled map, relative to the assets directory.
|
||||
pub map: String,
|
||||
/// Database and world to persist to. `None` disables saving.
|
||||
pub storage: Option<persistence::StorageSettings>,
|
||||
/// Where the persistent world is stored. `None` disables saving.
|
||||
pub save_path: Option<PathBuf>,
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
map: game_core::DEFAULT_MAP.to_owned(),
|
||||
storage: None,
|
||||
save_path: None,
|
||||
autosave_interval: Duration::from_secs(60),
|
||||
owner: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -64,72 +53,32 @@ impl Plugin for GameServerPlugin {
|
|||
self.settings.autosave_interval,
|
||||
TimerMode::Repeating,
|
||||
)))
|
||||
.init_resource::<config::ServerConfig>()
|
||||
.init_resource::<players::Players>()
|
||||
.init_resource::<players::CharacterStore>()
|
||||
.init_resource::<players::RejectedLogins>()
|
||||
.init_resource::<survival::SurvivalTimer>()
|
||||
.add_visibility_filter::<world::Level>()
|
||||
.add_systems(
|
||||
Startup,
|
||||
(
|
||||
load_scripts,
|
||||
world::setup_world.run_if(not(resource_exists::<ServerError>)),
|
||||
)
|
||||
.chain(),
|
||||
)
|
||||
.add_systems(Startup, (load_scripts, world::setup_world).chain())
|
||||
.add_systems(
|
||||
FixedUpdate,
|
||||
(
|
||||
(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_observer(players::on_login)
|
||||
.add_observer(players::on_disconnect)
|
||||
.add_observer(players::on_chat)
|
||||
.add_observer(movement::on_set_movement)
|
||||
.add_observer(movement::on_set_move_dir)
|
||||
.add_observer(actions::on_pick_up)
|
||||
.add_observer(actions::on_drop_item)
|
||||
.add_observer(actions::on_use_item)
|
||||
.add_observer(actions::on_interact)
|
||||
.add_observer(actions::on_craft)
|
||||
.add_observer(actions::on_construct)
|
||||
.add_observer(actions::on_deconstruct)
|
||||
.add_observer(machines::on_machine_insert)
|
||||
.add_observer(machines::on_machine_take);
|
||||
.add_observer(actions::on_deconstruct);
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the server failed to start. Inserted together with an error [`AppExit`].
|
||||
#[derive(Resource, Clone, Debug)]
|
||||
pub struct ServerError(pub String);
|
||||
|
||||
/// Reports a fatal startup error and requests exit.
|
||||
pub fn fail(commands: &mut Commands, message: String) {
|
||||
error!("{message}");
|
||||
commands.insert_resource(ServerError(message));
|
||||
commands.write_message(AppExit::error());
|
||||
}
|
||||
|
||||
fn load_scripts(mut commands: Commands, data: Res<GameData>) {
|
||||
let dir = game_core::asset_path("scripts");
|
||||
let engine = match scripting::ScriptEngine::load(&dir, &data) {
|
||||
Ok(engine) => engine,
|
||||
Err(e) => return fail(&mut commands, e.to_string()),
|
||||
};
|
||||
let engine = scripting::ScriptEngine::load(&dir, &data).unwrap_or_else(|e| panic!("{e}"));
|
||||
info!("compiled scripts from `{}`", dir.display());
|
||||
commands.insert_resource(engine);
|
||||
commands.insert_resource(actions::SharedData(Arc::new(data.clone())));
|
||||
|
|
|
|||
|
|
@ -1,163 +0,0 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,129 +1,45 @@
|
|||
//! Headless dedicated server.
|
||||
//!
|
||||
//! Configured by a TOML file (default `server.toml`, created on first start) and
|
||||
//! 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.
|
||||
//! ```text
|
||||
//! game-server [--port 5000] [--save saves/server.rsn | --no-save] [--map maps/station.tmx]
|
||||
//! ```
|
||||
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
|
||||
use bevy::{app::ScheduleRunnerPlugin, log::LogPlugin, prelude::*, state::app::StatesPlugin};
|
||||
use bevy_replicon::prelude::*;
|
||||
use bevy_replicon_renet::RepliconRenetPlugins;
|
||||
use clap::Parser;
|
||||
use game_core::CorePlugin;
|
||||
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);
|
||||
}
|
||||
}
|
||||
use game_server::{GameServerPlugin, NetworkServerPlugin, ServerSettings};
|
||||
|
||||
fn main() -> AppExit {
|
||||
let cli = Cli::parse();
|
||||
let (file, mut config, created) = match ConfigFile::load_or_create(&cli.config) {
|
||||
Ok(loaded) => loaded,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
return AppExit::error();
|
||||
}
|
||||
let mut port = game_core::DEFAULT_PORT;
|
||||
let mut settings = ServerSettings {
|
||||
save_path: Some(PathBuf::from("saves/server.rsn")),
|
||||
..Default::default()
|
||||
};
|
||||
let print = cli.print_config;
|
||||
cli.apply(&mut config);
|
||||
if print {
|
||||
print!("{}", toml::to_string(&config).expect("config serializes"));
|
||||
return AppExit::Success;
|
||||
let mut args = std::env::args().skip(1);
|
||||
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 mut app = App::new();
|
||||
app.add_plugins((
|
||||
App::new()
|
||||
.add_plugins((
|
||||
MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64(
|
||||
1.0 / 60.0,
|
||||
))),
|
||||
|
|
@ -133,17 +49,23 @@ fn main() -> AppExit {
|
|||
RepliconPlugins,
|
||||
RepliconRenetPlugins,
|
||||
CorePlugin,
|
||||
GameServerPlugin {
|
||||
settings: config.settings(),
|
||||
},
|
||||
NetworkServerPlugin::public(config),
|
||||
ConsolePlugin,
|
||||
));
|
||||
if created {
|
||||
info!("created config file `{}`", file.path.display());
|
||||
} else {
|
||||
info!("loaded config file `{}`", file.path.display());
|
||||
}
|
||||
app.insert_resource(file);
|
||||
app.run()
|
||||
GameServerPlugin { settings },
|
||||
NetworkServerPlugin { port },
|
||||
))
|
||||
.run()
|
||||
}
|
||||
|
||||
fn usage(error: &str) -> ! {
|
||||
if !error.is_empty() {
|
||||
eprintln!("error: {error}\n");
|
||||
}
|
||||
eprintln!(
|
||||
"usage: game-server [--port PORT] [--save FILE | --no-save] [--map MAP]\n\n\
|
||||
--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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,289 +1,58 @@
|
|||
//! Free character movement with collision against solid tiles and buildings.
|
||||
//! Tile-based character movement.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_replicon::prelude::*;
|
||||
use game_core::{components::*, grid::Dir, protocol::SetMovement};
|
||||
use game_core::{components::*, grid::Dir, protocol::SetMoveDir};
|
||||
|
||||
use crate::{
|
||||
players::{Controller, Players},
|
||||
world::{Level, Levels, Occupancy, free_tile_on, move_character},
|
||||
players::Players,
|
||||
world::{WorldLayout, is_blocked},
|
||||
};
|
||||
|
||||
/// Walking speed in tiles per second.
|
||||
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;
|
||||
/// Seconds per tile step.
|
||||
pub const MOVE_DELAY: f32 = 0.16;
|
||||
|
||||
/// Normalized walking direction (`y` down), zero when standing still.
|
||||
#[derive(Component, Default, Debug)]
|
||||
pub struct MoveIntent(pub Vec2);
|
||||
pub struct MoveIntent {
|
||||
pub dir: Option<Dir>,
|
||||
pub cooldown: f32,
|
||||
}
|
||||
|
||||
pub fn on_set_movement(
|
||||
ev: On<FromClient<SetMovement>>,
|
||||
pub fn on_set_move_dir(
|
||||
ev: On<FromClient<SetMoveDir>>,
|
||||
players: Res<Players>,
|
||||
mut intents: Query<(&mut MoveIntent, &mut Facing)>,
|
||||
) {
|
||||
let Some(&entity) = players.by_client.get(&ev.client_id) else {
|
||||
return;
|
||||
};
|
||||
let Ok((mut intent, mut facing)) = intents.get_mut(entity) else {
|
||||
return;
|
||||
};
|
||||
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) {
|
||||
if let Ok((mut intent, mut facing)) = intents.get_mut(entity) {
|
||||
intent.dir = ev.message.0;
|
||||
if let Some(dir) = intent.dir {
|
||||
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(
|
||||
time: Res<Time>,
|
||||
levels: Res<Levels>,
|
||||
buildings: Query<(&Level, &GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
||||
mut characters: Query<(&Level, &MoveIntent, &mut Position), With<Character>>,
|
||||
layout: Option<Res<WorldLayout>>,
|
||||
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
||||
mut characters: Query<(&mut MoveIntent, &mut GridPos, &mut Facing), With<Character>>,
|
||||
) {
|
||||
let occupancy = Occupancy::by_level(buildings.iter());
|
||||
let empty = Occupancy::default();
|
||||
let Some(layout) = layout else { return };
|
||||
let dt = time.delta_secs();
|
||||
for (level, intent, mut pos) in &mut characters {
|
||||
if intent.0 == Vec2::ZERO {
|
||||
for (mut intent, mut pos, mut facing) in &mut characters {
|
||||
intent.cooldown = (intent.cooldown - dt).max(0.0);
|
||||
let Some(dir) = intent.dir else { continue };
|
||||
if intent.cooldown > 0.0 {
|
||||
continue;
|
||||
}
|
||||
let Some(layout) = levels.layout(level) else {
|
||||
continue;
|
||||
};
|
||||
let occupancy = occupancy.get(level.0.as_str()).unwrap_or(&empty);
|
||||
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;
|
||||
facing.set_if_neq(Facing(dir));
|
||||
let next = pos.step(dir);
|
||||
if !is_blocked(next, &layout.0, buildings.iter()) {
|
||||
*pos = next;
|
||||
intent.cooldown = MOVE_DELAY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(¤t) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,129 +1,68 @@
|
|||
//! Starting the renet network server (host and dedicated modes).
|
||||
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket},
|
||||
net::{Ipv4Addr, SocketAddr, UdpSocket},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_replicon::prelude::*;
|
||||
use bevy_replicon_renet::{
|
||||
RenetChannelsExt, RenetClient, RenetServer,
|
||||
netcode::{
|
||||
self, ClientAuthentication, NetcodeClientTransport, NetcodeServerTransport,
|
||||
ServerAuthentication,
|
||||
},
|
||||
RenetChannelsExt, RenetServer,
|
||||
netcode::{NetcodeServerTransport, ServerAuthentication, ServerConfig},
|
||||
renet::ConnectionConfig,
|
||||
};
|
||||
|
||||
use crate::config::ServerConfig;
|
||||
pub const MAX_CLIENTS: usize = 32;
|
||||
|
||||
/// 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.
|
||||
/// Opens a UDP server on `port` once all replicon channels are registered.
|
||||
pub struct NetworkServerPlugin {
|
||||
pub config: ServerConfig,
|
||||
/// Address to listen on: unspecified for everyone, loopback for local-only.
|
||||
/// The port comes from the config; port 0 picks a free one.
|
||||
pub bind: IpAddr,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
impl NetworkServerPlugin {
|
||||
/// Listens on all interfaces.
|
||||
pub fn public(config: ServerConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
bind: Ipv4Addr::UNSPECIFIED.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Address the server listens on.
|
||||
#[derive(Resource, Clone, Copy, Debug)]
|
||||
pub struct BoundAddr(pub SocketAddr);
|
||||
|
||||
#[derive(Resource)]
|
||||
struct BindIp(IpAddr);
|
||||
struct ServerPort(u16);
|
||||
|
||||
impl Plugin for NetworkServerPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.insert_resource(self.config.clone())
|
||||
.insert_resource(BindIp(self.bind))
|
||||
app.insert_resource(ServerPort(self.port))
|
||||
.add_systems(Startup, start_server);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_server(
|
||||
mut commands: Commands,
|
||||
channels: Res<RepliconChannels>,
|
||||
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}");
|
||||
fn start_server(mut commands: Commands, channels: Res<RepliconChannels>, port: Res<ServerPort>) {
|
||||
match create_server(&channels, port.0) {
|
||||
Ok((server, transport)) => {
|
||||
info!("listening on UDP port {}", port.0);
|
||||
commands.insert_resource(server);
|
||||
commands.insert_resource(transport);
|
||||
commands.insert_resource(BoundAddr(bound));
|
||||
}
|
||||
Err(e) => crate::fail(&mut commands, format!("failed to listen on {addr}: {e}")),
|
||||
Err(e) => {
|
||||
error!("failed to start server on port {}: {e}", port.0);
|
||||
commands.write_message(AppExit::error());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_server(
|
||||
channels: &RepliconChannels,
|
||||
addr: SocketAddr,
|
||||
max_clients: usize,
|
||||
) -> Result<(RenetServer, NetcodeServerTransport, SocketAddr), Box<dyn std::error::Error>> {
|
||||
port: u16,
|
||||
) -> Result<(RenetServer, NetcodeServerTransport), Box<dyn std::error::Error>> {
|
||||
let server = RenetServer::new(ConnectionConfig {
|
||||
server_channels_config: channels.server_configs(),
|
||||
client_channels_config: channels.client_configs(),
|
||||
..Default::default()
|
||||
});
|
||||
let current_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH)?;
|
||||
let socket = UdpSocket::bind(addr)?;
|
||||
let bound = socket.local_addr()?;
|
||||
let server_config = netcode::ServerConfig {
|
||||
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, port))?;
|
||||
let server_config = ServerConfig {
|
||||
current_time,
|
||||
max_clients,
|
||||
max_clients: MAX_CLIENTS,
|
||||
protocol_id: game_core::PROTOCOL_ID,
|
||||
authentication: ServerAuthentication::Unsecure,
|
||||
// Not used with unsecure authentication, but must be valid.
|
||||
public_addresses: vec![bound],
|
||||
public_addresses: vec![SocketAddr::from((Ipv4Addr::LOCALHOST, port))],
|
||||
};
|
||||
let transport = NetcodeServerTransport::new(server_config, socket)?;
|
||||
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))
|
||||
Ok((server, transport))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,32 @@
|
|||
//! Login/logout and the mapping between network clients and characters.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_replicon::prelude::*;
|
||||
use game_core::{
|
||||
GameData,
|
||||
components::*,
|
||||
protocol::{AdminStatus, Chat, GameLog, Login, Rejected, Welcome},
|
||||
protocol::{GameLog, Login},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ServerSettings,
|
||||
commands::{self, CommandSource},
|
||||
config::ServerConfig,
|
||||
movement::MoveIntent,
|
||||
persistence::SavedCharacter,
|
||||
world::{Level, Levels, ViewLevel, free_tile_on},
|
||||
world::{SpawnPoint, WorldLayout, find_free_tile},
|
||||
};
|
||||
|
||||
/// Which client controls a character. Server-only.
|
||||
#[derive(Component, Clone, Copy, Debug)]
|
||||
pub struct Controller(pub ClientId);
|
||||
|
||||
/// The player owning a character (a UUID sent by the client). Server-only.
|
||||
#[derive(Component, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PlayerId(pub String);
|
||||
|
||||
/// Marks characters of players with admin rights. Server-only.
|
||||
#[derive(Component, Clone, Copy, Debug)]
|
||||
pub struct Admin;
|
||||
|
||||
/// Online characters by client.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct Players {
|
||||
pub by_client: HashMap<ClientId, Entity>,
|
||||
}
|
||||
|
||||
/// Characters that are currently offline, by player id. Persisted with the world.
|
||||
/// Characters that are currently offline, by name. Persisted with the world.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct CharacterStore(pub BTreeMap<String, SavedCharacter>);
|
||||
|
||||
|
|
@ -59,387 +48,84 @@ pub fn valid_name(name: &str) -> bool {
|
|||
.all(|c| c.is_alphanumeric() || c == ' ' || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
/// Everything [`check_access`] needs to know about a login attempt.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct AccessRequest<'a> {
|
||||
pub player_id: &'a str,
|
||||
/// Character name (the stored one for returning players).
|
||||
pub name: &'a str,
|
||||
pub password: &'a str,
|
||||
/// Host of an embedded server or a local client; bypasses all restrictions.
|
||||
pub owner: bool,
|
||||
pub online: usize,
|
||||
pub id_online: bool,
|
||||
pub new_character: bool,
|
||||
/// Another player's character already uses this name.
|
||||
pub name_taken: bool,
|
||||
}
|
||||
|
||||
/// Whether a player may join. Pure so the rules can be tested in isolation.
|
||||
pub fn check_access(config: &ServerConfig, req: &AccessRequest) -> Result<(), String> {
|
||||
if uuid::Uuid::parse_str(req.player_id).is_err() {
|
||||
return Err("Invalid player id.".into());
|
||||
}
|
||||
let 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) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn on_login(
|
||||
login: On<FromClient<Login>>,
|
||||
mut commands: Commands,
|
||||
mut players: ResMut<Players>,
|
||||
mut store: ResMut<CharacterStore>,
|
||||
mut disconnects: MessageWriter<DisconnectRequest>,
|
||||
data: Res<GameData>,
|
||||
spawn: Res<SpawnPoint>,
|
||||
layout: Res<WorldLayout>,
|
||||
characters: Query<&Character>,
|
||||
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
||||
) {
|
||||
let client = login.client_id;
|
||||
let 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) {
|
||||
let name = login.message.name.clone();
|
||||
if 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();
|
||||
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 });
|
||||
}
|
||||
log.0.push_back(entry);
|
||||
disconnect(&mut world.commands(), client, reason);
|
||||
world.flush();
|
||||
};
|
||||
if !valid_name(&name) {
|
||||
reject(&mut commands, &mut disconnects, "Invalid character name.");
|
||||
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>)>(
|
||||
if characters.iter().any(|c| c.name == name) {
|
||||
reject(
|
||||
&mut commands,
|
||||
&mut disconnects,
|
||||
"That character is already online.",
|
||||
);
|
||||
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((
|
||||
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,
|
||||
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 {
|
||||
))
|
||||
.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.")
|
||||
};
|
||||
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 {
|
||||
fn new_character(data: &GameData, spawn: GridPos) -> SavedCharacter {
|
||||
let mut inventory = Inventory::default();
|
||||
if data.items.contains_key("apple") {
|
||||
inventory.add("apple", 2, data);
|
||||
}
|
||||
SavedCharacter {
|
||||
name: name.to_owned(),
|
||||
pos: spawn,
|
||||
position: None,
|
||||
facing: Default::default(),
|
||||
vitals: Vitals::default(),
|
||||
inventory,
|
||||
skills: Skills::default(),
|
||||
level: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -449,37 +135,21 @@ pub fn on_disconnect(
|
|||
mut commands: Commands,
|
||||
mut players: ResMut<Players>,
|
||||
mut store: ResMut<CharacterStore>,
|
||||
levels: Option<Res<Levels>>,
|
||||
characters: Query<(
|
||||
&Character,
|
||||
&Level,
|
||||
&PlayerId,
|
||||
&GridPos,
|
||||
&Position,
|
||||
&Facing,
|
||||
&Vitals,
|
||||
&Inventory,
|
||||
&Skills,
|
||||
)>,
|
||||
characters: Query<(&Character, &GridPos, &Facing, &Vitals, &Inventory, &Skills)>,
|
||||
) {
|
||||
let Some(entity) = players.by_client.remove(&ClientId::Client(remove.entity)) else {
|
||||
return;
|
||||
};
|
||||
if let Ok((c, level, id, pos, position, facing, vitals, inventory, skills)) =
|
||||
characters.get(entity)
|
||||
{
|
||||
if let Ok((c, pos, facing, vitals, inventory, skills)) = characters.get(entity) {
|
||||
info!("{} logged out", c.name);
|
||||
store.0.insert(
|
||||
id.0.clone(),
|
||||
c.name.clone(),
|
||||
SavedCharacter {
|
||||
name: c.name.clone(),
|
||||
pos: *pos,
|
||||
position: Some(*position),
|
||||
facing: facing.0,
|
||||
vitals: vitals.clone(),
|
||||
inventory: inventory.clone(),
|
||||
skills: skills.clone(),
|
||||
level: levels.as_ref().and_then(|l| l.to_saved(level)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -488,98 +158,7 @@ pub fn on_disconnect(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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(()));
|
||||
}
|
||||
use super::valid_name;
|
||||
|
||||
#[test]
|
||||
fn names() {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use game_core::components::*;
|
|||
|
||||
use crate::{
|
||||
players::{Controller, tell},
|
||||
world::{Level, Levels, move_character, spawn_ground_item},
|
||||
world::{SpawnPoint, spawn_ground_item},
|
||||
};
|
||||
|
||||
/// Nutrition lost per second (a full stomach lasts about 25 minutes).
|
||||
|
|
@ -26,13 +26,11 @@ pub fn tick_survival(
|
|||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut timer: ResMut<SurvivalTimer>,
|
||||
levels: Res<Levels>,
|
||||
spawn: Option<Res<SpawnPoint>>,
|
||||
mut characters: Query<(
|
||||
Entity,
|
||||
&Character,
|
||||
&Controller,
|
||||
&Level,
|
||||
&GridPos,
|
||||
&mut GridPos,
|
||||
&mut Vitals,
|
||||
&mut Inventory,
|
||||
)>,
|
||||
|
|
@ -41,7 +39,7 @@ pub fn tick_survival(
|
|||
return;
|
||||
}
|
||||
let dt = timer.0.duration().as_secs_f32();
|
||||
for (entity, character, controller, level, pos, mut vitals, mut inventory) in &mut characters {
|
||||
for (character, controller, mut pos, mut vitals, mut inventory) in &mut characters {
|
||||
vitals.nutrition = (vitals.nutrition - HUNGER_RATE * dt).max(0.0);
|
||||
if vitals.nutrition <= 0.0 {
|
||||
vitals.heal(-STARVATION_DAMAGE * dt);
|
||||
|
|
@ -55,16 +53,11 @@ pub fn tick_survival(
|
|||
// Death: drop everything where you fell and wake up at the spawn point.
|
||||
info!("{} died", character.name);
|
||||
for stack in std::mem::take(&mut inventory.slots) {
|
||||
spawn_ground_item(&mut commands, stack, level, *pos);
|
||||
spawn_ground_item(&mut commands, stack, *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 {
|
||||
nutrition: 50.0,
|
||||
..default()
|
||||
|
|
|
|||
|
|
@ -1,238 +1,102 @@
|
|||
//! World setup: Tiled maps (levels), spawning buildings and items, and tile occupancy.
|
||||
//! World setup: Tiled map, spawning buildings and items, and tile occupancy.
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_replicon::prelude::*;
|
||||
use game_core::{
|
||||
GameData,
|
||||
components::{
|
||||
Building, BuildingState, Character, GridPos, GroundItem, ItemStack, Machine, Position,
|
||||
WorldMap,
|
||||
},
|
||||
data::BuildingLayer,
|
||||
components::{Building, BuildingState, Character, GridPos, GroundItem, ItemStack, WorldMap},
|
||||
map::MapLayout,
|
||||
};
|
||||
|
||||
use crate::{ServerSettings, map_io, movement::LastTile, persistence};
|
||||
use crate::{ServerSettings, persistence};
|
||||
|
||||
pub use game_core::components::{Level, ViewLevel};
|
||||
/// The loaded map, used for collision and spawn points.
|
||||
#[derive(Resource, Clone)]
|
||||
pub struct WorldLayout(pub Arc<MapLayout>);
|
||||
|
||||
/// Where a teleport trigger sends characters.
|
||||
#[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('\\', "/")
|
||||
}
|
||||
#[derive(Resource, Clone, Copy)]
|
||||
pub struct SpawnPoint(pub GridPos);
|
||||
|
||||
pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) {
|
||||
let assets = game_core::assets_dir();
|
||||
let main = main_level_id(&settings.map);
|
||||
let load = |level: &str| {
|
||||
MapLayout::load(&assets, &level_map_path(&settings.map, level))
|
||||
.map_err(map_io::MapIoError::from)
|
||||
};
|
||||
let seed = || map_io::decode_world(&main, &data, load);
|
||||
let mut save = None;
|
||||
if let Some(storage) = &settings.storage {
|
||||
let (storage, loaded) = match persistence::Storage::open(storage, &settings.map, || {
|
||||
seed().map_err(|e| e.to_string())
|
||||
}) {
|
||||
Ok(opened) => opened,
|
||||
Err(e) => {
|
||||
return crate::fail(
|
||||
&mut commands,
|
||||
format!("failed to open the world database: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
info!("world `{}` ({})", storage.world.name, storage.world.id);
|
||||
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) => {
|
||||
let layout = MapLayout::load(&assets, &settings.map).unwrap_or_else(|e| panic!("{e}"));
|
||||
let spawn = layout
|
||||
.objects_of_class("spawn")
|
||||
.next()
|
||||
.map(|o| o.pos)
|
||||
.unwrap_or_else(|| {
|
||||
warn!("map has no `spawn` object, using the map center");
|
||||
GridPos::new(layout.width as i32 / 2, layout.height as i32 / 2)
|
||||
});
|
||||
commands.insert_resource(SpawnPoint(spawn));
|
||||
|
||||
commands.spawn((
|
||||
Replicated,
|
||||
WorldMap {
|
||||
path: settings.map.clone(),
|
||||
},
|
||||
));
|
||||
|
||||
match persistence::load(settings.save_path.as_deref()) {
|
||||
Some(save) => {
|
||||
info!(
|
||||
"restoring persistent world: {} levels, {} buildings, {} items, {} characters",
|
||||
save.levels.len() + 1,
|
||||
"restoring persistent world: {} buildings, {} items, {} characters",
|
||||
save.buildings.len(),
|
||||
save.items.len(),
|
||||
save.characters.len()
|
||||
);
|
||||
if save.layout.is_none() {
|
||||
match MapLayout::load_legacy_terrain(&assets, &settings.map) {
|
||||
Ok(layout) => save.layout = Some(layout),
|
||||
Err(e) => return crate::fail(&mut commands, e.to_string()),
|
||||
if save.map != settings.map {
|
||||
warn!(
|
||||
"save was made on map `{}`, now running `{}`",
|
||||
save.map, settings.map
|
||||
);
|
||||
}
|
||||
}
|
||||
// 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
|
||||
persistence::restore(&mut commands, &data, save);
|
||||
}
|
||||
None => {
|
||||
info!("creating a new world from `{}`", settings.map);
|
||||
match seed() {
|
||||
Ok(save) => save,
|
||||
Err(e) => return crate::fail(&mut commands, e.to_string()),
|
||||
spawn_map_objects(&mut commands, &data, &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);
|
||||
commands.insert_resource(WorldLayout(Arc::new(layout)));
|
||||
}
|
||||
|
||||
/// Spawns the replicated terrain of every level and makes `levels` current.
|
||||
pub fn install_levels(commands: &mut Commands, levels: Levels) {
|
||||
for (id, layout) in &levels.layouts {
|
||||
commands.spawn((
|
||||
Replicated,
|
||||
Level(id.clone()),
|
||||
WorldMap {
|
||||
path: id.clone(),
|
||||
layout: (**layout).clone(),
|
||||
},
|
||||
));
|
||||
fn spawn_map_objects(commands: &mut Commands, data: &GameData, layout: &MapLayout) {
|
||||
for obj in &layout.objects {
|
||||
let proto = obj.properties.get("proto");
|
||||
match (obj.class.as_str(), proto) {
|
||||
("spawn", _) => {}
|
||||
("building", Some(proto)) => {
|
||||
// Integer custom properties override the building's script variables.
|
||||
let vars = obj
|
||||
.properties
|
||||
.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.
|
||||
|
|
@ -240,85 +104,40 @@ pub fn spawn_building(
|
|||
commands: &mut Commands,
|
||||
data: &GameData,
|
||||
proto: &str,
|
||||
level: &Level,
|
||||
pos: GridPos,
|
||||
vars: BTreeMap<String, i64>,
|
||||
) -> Option<Entity> {
|
||||
let def = data.buildings.get(proto)?;
|
||||
let mut state = BuildingState::new(def);
|
||||
let mut state = BuildingState {
|
||||
solid: def.solid,
|
||||
variant: None,
|
||||
health: def.max_health,
|
||||
vars: def.vars.clone(),
|
||||
};
|
||||
state.vars.extend(vars);
|
||||
let machine = data.machines.get(proto).map(Machine::new);
|
||||
Some(spawn_building_with_state(
|
||||
commands, proto, level, pos, state, machine,
|
||||
))
|
||||
Some(spawn_building_with_state(commands, proto, pos, state))
|
||||
}
|
||||
|
||||
pub fn spawn_building_with_state(
|
||||
commands: &mut Commands,
|
||||
proto: &str,
|
||||
level: &Level,
|
||||
pos: GridPos,
|
||||
state: BuildingState,
|
||||
machine: Option<Machine>,
|
||||
) -> Entity {
|
||||
let mut entity = commands.spawn((
|
||||
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 {
|
||||
commands
|
||||
.spawn((Replicated, GroundItem(stack), level.clone(), pos))
|
||||
))
|
||||
.id()
|
||||
}
|
||||
|
||||
/// Solid and floor buildings of one level.
|
||||
#[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))
|
||||
}
|
||||
pub fn spawn_ground_item(commands: &mut Commands, stack: ItemStack, pos: GridPos) -> Entity {
|
||||
commands.spawn((Replicated, GroundItem(stack), pos)).id()
|
||||
}
|
||||
|
||||
/// Whether a character can not enter `pos`.
|
||||
|
|
@ -327,20 +146,15 @@ pub fn is_blocked<'a>(
|
|||
layout: &MapLayout,
|
||||
buildings: impl IntoIterator<Item = (&'a GridPos, &'a BuildingState)>,
|
||||
) -> bool {
|
||||
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)
|
||||
layout.is_solid(pos) || buildings.into_iter().any(|(p, s)| *p == pos && s.solid)
|
||||
}
|
||||
|
||||
/// Finds the closest free tile around `start` (spiral search), for placing characters.
|
||||
pub fn find_free_tile(start: GridPos, blocked: impl Fn(GridPos) -> bool) -> GridPos {
|
||||
/// Finds the closest free tile around `start` (spiral search), for spawning characters.
|
||||
pub fn find_free_tile(
|
||||
start: GridPos,
|
||||
layout: &MapLayout,
|
||||
buildings: &Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
||||
) -> GridPos {
|
||||
for radius in 0..16i32 {
|
||||
for dy in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
|
|
@ -348,7 +162,7 @@ pub fn find_free_tile(start: GridPos, blocked: impl Fn(GridPos) -> bool) -> Grid
|
|||
continue;
|
||||
}
|
||||
let pos = GridPos::new(start.x + dx, start.y + dy);
|
||||
if !blocked(pos) {
|
||||
if !is_blocked(pos, layout, buildings.iter()) {
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
|
|
@ -356,20 +170,3 @@ pub fn find_free_tile(start: GridPos, blocked: impl Fn(GridPos) -> bool) -> Grid
|
|||
}
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,142 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -77,24 +77,12 @@ def gen_tileset():
|
|||
for i, (_, color, _, pattern) in enumerate(TILES):
|
||||
draw_tile(d, (i % COLS) * T, (i // COLS) * T, color, pattern)
|
||||
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 = []
|
||||
for i, (name, _, solid, _) in enumerate(TILES):
|
||||
props = f'<property name="name" value="{name}"/>'
|
||||
if solid:
|
||||
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=""{layer}""/>'
|
||||
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>')
|
||||
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}">
|
||||
|
|
@ -186,13 +174,6 @@ def gen_sprites():
|
|||
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.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",
|
||||
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)),
|
||||
|
|
@ -209,22 +190,6 @@ def gen_sprites():
|
|||
lambda d: d.ellipse([10, 14, 15, 19], 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
|
||||
sprite("sprites/characters/player.png",
|
||||
lambda d: d.ellipse([10, 3, 22, 14], fill=(240, 200, 160), outline=(60, 40, 30)),
|
||||
|
|
@ -233,27 +198,6 @@ def gen_sprites():
|
|||
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 ---------------------------------------------------------------------
|
||||
W, H = 40, 28
|
||||
FLOOR, DARK, WALL, GRASS, WATER, PLATING, DIRT, WINDOW = range(len(TILES))
|
||||
|
|
@ -298,15 +242,32 @@ def gen_map():
|
|||
for y in range(1, 26):
|
||||
ground[y][37] = DIRT
|
||||
|
||||
objects, triggers = MapObjects(), MapObjects()
|
||||
obj = objects.point
|
||||
def csv(layer):
|
||||
return ",\n".join(",".join("0" if t is None else str(t + 1) for t in row) for row in layer)
|
||||
|
||||
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("building", 18, 8, proto="door")
|
||||
obj("building", 8, 14, proto="door")
|
||||
obj("building", 10, 3, proto="door")
|
||||
obj("building", 20, 5, proto="workbench")
|
||||
obj("building", 24, 5, proto="furnace")
|
||||
obj("building", 22, 5, proto="assembler")
|
||||
obj("building", 5, 5, proto="vending_machine", stock=5)
|
||||
obj("building", 5, 17, proto="table")
|
||||
obj("building", 6, 17, proto="table")
|
||||
|
|
@ -323,89 +284,31 @@ def gen_map():
|
|||
obj("building", x, y, proto="ore_rock")
|
||||
obj("item", 25, 22, proto="apple", count=3)
|
||||
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"?>
|
||||
<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>
|
||||
<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]}">
|
||||
<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">
|
||||
{csv(ground)}
|
||||
</data>
|
||||
</layer>
|
||||
<layer id="2" name="Walls" width="{w}" height="{h}">
|
||||
<layer id="2" name="Walls" width="{W}" height="{H}">
|
||||
<data encoding="csv">
|
||||
{csv(walls)}
|
||||
</data>
|
||||
</layer>
|
||||
<objectgroup id="3" name="Objects">
|
||||
{chr(10).join(objects.xml)}
|
||||
</objectgroup>
|
||||
<objectgroup id="4" name="Triggers">
|
||||
{chr(10).join(triggers.xml)}
|
||||
{chr(10).join(objects)}
|
||||
</objectgroup>
|
||||
</map>
|
||||
"""
|
||||
(ASSETS / "maps").mkdir(parents=True, exist_ok=True)
|
||||
(ASSETS / "maps" / file).write_text(tmx)
|
||||
MapObjects.next_id = 1
|
||||
(ASSETS / "maps/station.tmx").write_text(tmx)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gen_tileset()
|
||||
gen_sprites()
|
||||
gen_ui()
|
||||
gen_map()
|
||||
gen_cellar()
|
||||
print("placeholder assets written to", ASSETS)
|
||||
|
|
|
|||
|
|
@ -1,550 +0,0 @@
|
|||
"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"\)/);
|
||||
});
|
||||
|
|
@ -6,10 +6,8 @@ publish = false
|
|||
|
||||
[dependencies]
|
||||
flate2 = "1"
|
||||
percent-encoding = "2"
|
||||
serde_json = "1"
|
||||
tar = { version = "0.4", default-features = false }
|
||||
tiny_http = "0.12"
|
||||
zip = { version = "9", default-features = false, features = ["deflate-flate2"] }
|
||||
|
||||
[lints]
|
||||
|
|
|
|||
|
|
@ -2,12 +2,8 @@
|
|||
//!
|
||||
//! ```text
|
||||
//! 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::{
|
||||
env,
|
||||
error::Error,
|
||||
|
|
@ -29,9 +25,6 @@ fn main() -> ExitCode {
|
|||
let mut args = env::args().skip(1);
|
||||
let result = match args.next().as_deref() {
|
||||
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 => {
|
||||
print_help();
|
||||
return ExitCode::SUCCESS;
|
||||
|
|
@ -51,15 +44,12 @@ fn print_help() {
|
|||
println!(
|
||||
"usage: cargo xtask <command>\n\n\
|
||||
commands:\n \
|
||||
pack build the client and server and pack them with their assets\n \
|
||||
watch pack after sources are unchanged for 10 seconds; pause with dist/NOREBUILD\n\n\
|
||||
pack/watch options:\n \
|
||||
pack build the client and server and pack them with their assets\n\n\
|
||||
pack options:\n \
|
||||
--target TRIPLE cross-compile for TRIPLE (default: host)\n \
|
||||
--format zip|tar.gz archive format (default: zip for Windows, tar.gz otherwise)\n \
|
||||
--out-dir DIR output directory (default: dist)\n \
|
||||
--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"
|
||||
--debug pack a debug build instead of the stripped `dist` profile"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -104,16 +94,7 @@ struct PackOptions {
|
|||
|
||||
impl PackOptions {
|
||||
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 serve = false;
|
||||
while let Some(arg) = args.next() {
|
||||
let mut value = |name: &str| args.next().ok_or_else(|| format!("{name} needs a value"));
|
||||
match arg.as_str() {
|
||||
|
|
@ -121,15 +102,14 @@ impl PackOptions {
|
|||
"--format" => opts.format = Some(Format::parse(&value("--format")?)?),
|
||||
"--out-dir" => opts.out_dir = Some(value("--out-dir")?.into()),
|
||||
"--debug" => opts.debug = true,
|
||||
"--serve" if watch => serve = true,
|
||||
"-h" | "--help" => {
|
||||
print_help();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => return Err(format!("unknown option `{other}`").into()),
|
||||
other => return Err(format!("unknown pack option `{other}`").into()),
|
||||
}
|
||||
}
|
||||
Ok((opts, serve))
|
||||
Ok(opts)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,235 +0,0 @@
|
|||
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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'");
|
||||
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 & <test>.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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,299 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||