feat!: simulate multiple map levels connected by teleports
Worlds now consist of levels: the server's map plus every map reached through `teleport` triggers (`target` object name, optional `map` file property relative to the map). All levels are simulated all the time; replicon only sends clients their character's level, and singleplayer hides other levels client-side. Interactions, construction, machines and item drops are confined to the actor's level. The default map gains stairs in the main hall leading to a new cellar level and stairs back up. Exports write every level's map, naming the main map after the server's map; import validates and replaces all levels and moves characters on removed levels to the spawn point. BREAKING CHANGE: save schema version 3 stores entity levels and level terrain. Exported main maps are named after the server's map (for example `station.tmx`) instead of `world.tmx`. `Level` is a new replicated component, changing the network protocol. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
823f0f76e6
commit
3a24531529
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -7,16 +7,25 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
### Changed
|
### 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
|
- **Breaking:** `export [DIR]` writes a ready-to-use Tiled project directory
|
||||||
(`world.tmx`, `station.tiled-project`, the Station extensions and relative
|
(the main map named after the server's map, e.g. `station.tmx`, every level's
|
||||||
tileset image copies) instead of a single map file; `import` accepts that
|
map, `station.tiled-project`, the Station extensions and relative tileset image
|
||||||
directory or a map file.
|
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
|
- Tiled exports and newly created conversion layers place Objects above Ground
|
||||||
Items, Triggers, Wiring/Plumbing and Floors, with Floors and Wiring/Plumbing
|
Items, Triggers, Wiring/Plumbing and Floors, with Floors and Wiring/Plumbing
|
||||||
hidden by default in the editor without affecting gameplay.
|
hidden by default in the editor without affecting gameplay.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- `teleport` triggers in Tiled maps (`target` object name, optional `map` file)
|
||||||
|
move characters between places and levels.
|
||||||
|
- Stairs in the default map's main hall lead to a new cellar level and back up.
|
||||||
|
|
||||||
- **Map > Station: Convert All Project Maps** in Tiled converts painted gameplay
|
- **Map > Station: Convert All Project Maps** in Tiled converts painted gameplay
|
||||||
tiles in every map of the opened project at once.
|
tiles in every map of the opened project at once.
|
||||||
- A Station Object Brush in Tiled with per-tile brush property settings and
|
- A Station Object Brush in Tiled with per-tile brush property settings and
|
||||||
|
|
|
||||||
43
README.md
43
README.md
|
|
@ -387,6 +387,11 @@ Layer/group offsets are not supported.
|
||||||
Machines accept `machine.inputs`, `machine.outputs`, `machine.recipe` and
|
Machines accept `machine.inputs`, `machine.outputs`, `machine.recipe` and
|
||||||
`machine.progress`.
|
`machine.progress`.
|
||||||
- `item` – custom property `proto` = item id and optional `count`.
|
- `item` – custom property `proto` = item id and optional `count`.
|
||||||
|
- `teleport` – a trigger that moves characters entering its tile to the object
|
||||||
|
named by the string property `target`. The optional **file** property `map`
|
||||||
|
names the target's map, relative to this map; without it the target is on the
|
||||||
|
same map. Arrival does not trigger the destination's own teleport; step off
|
||||||
|
and back on to use it. The arrival tile falls back to the nearest free tile.
|
||||||
- Objects are placed on the tile containing their position (point objects work well).
|
- Objects are placed on the tile containing their position (point objects work well).
|
||||||
Tile objects inherit gameplay properties from their tiles; object properties
|
Tile objects inherit gameplay properties from their tiles; object properties
|
||||||
override them. Painted and explicit buildings share the same occupancy checks.
|
override them. Painted and explicit buildings share the same occupancy checks.
|
||||||
|
|
@ -402,9 +407,24 @@ 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.
|
uses dirt (tile 6). Maps remain finite: outside their bounds is always blocked.
|
||||||
Old maps without a default retain their empty, passable background.
|
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
|
A default map seeds a new world; after that the database stores the authoritative
|
||||||
terrain and entity state. Existing saves acquire their terrain snapshot on their
|
terrain and entity state of every level. Existing saves acquire their terrain
|
||||||
next save. To apply a map to an existing world, use the admin `import` command.
|
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:
|
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
|
import a marked map to convert old static walls/floors. Legacy saves without a
|
||||||
terrain snapshot retain static seed terrain until explicitly imported.
|
terrain snapshot retain static seed terrain until explicitly imported.
|
||||||
|
|
@ -479,7 +499,8 @@ and coordinate conventions; conversion is optional before loading a map.
|
||||||
PostgreSQL it defaults to `saves/<world-uuid>/`; without saving, `saves/world/`.
|
PostgreSQL it defaults to `saves/<world-uuid>/`; without saving, `saves/world/`.
|
||||||
An explicit directory, including spaces, may follow either command; relative
|
An explicit directory, including spaces, may follow either command; relative
|
||||||
paths are relative to the server's working directory. The directory contains:
|
paths are relative to the server's working directory. The directory contains:
|
||||||
- `world.tmx`: the exported map;
|
- `<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;
|
- `station.tiled-project`: a Tiled project that loads the bundled extensions;
|
||||||
- `extensions/`: a copy of `assets/tiled/` (refreshed on every export);
|
- `extensions/`: a copy of `assets/tiled/` (refreshed on every export);
|
||||||
- `tilesets/`: copies of the tileset images, referenced relatively, so the
|
- `tilesets/`: copies of the tileset images, referenced relatively, so the
|
||||||
|
|
@ -489,14 +510,14 @@ and coordinate conventions; conversion is optional before loading a map.
|
||||||
Re-exporting into the same directory overwrites the map, extensions, images and
|
Re-exporting into the same directory overwrites the map, extensions, images and
|
||||||
README but keeps an existing project file, so project settings survive.
|
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
|
2. Open `station.tiled-project` in Tiled 1.10.1 or newer (**Project > Open
|
||||||
Project**), click **Enable Extensions** when asked, then open `world.tmx`
|
Project**), click **Enable Extensions** when asked, then open the main map
|
||||||
from the Project panel. Edit and save. Buildings and ground items are point
|
(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.
|
objects with their prototype and full mutable state in custom properties.
|
||||||
Terrain layers and collision properties are included. The tileset image copies
|
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
|
are for display only: on import they map back to the server's identical asset
|
||||||
paths, and editing them does not change the game.
|
paths, and editing them does not change the game.
|
||||||
Objects are organized into **Floors**, **Objects** (walls, machines and other
|
Objects are organized into **Floors**, **Objects** (walls, machines and other
|
||||||
structures), **Ground Items**, and **Triggers** (currently spawn points).
|
structures), **Ground Items**, and **Triggers** (spawn points and teleports).
|
||||||
**Wiring/Plumbing** is an empty organizational placeholder; it does not add
|
**Wiring/Plumbing** is an empty organizational placeholder; it does not add
|
||||||
wiring, plumbing or trigger mechanics. From top to bottom, Tiled shows
|
wiring, plumbing or trigger mechanics. From top to bottom, Tiled shows
|
||||||
**Objects**, **Ground Items**, **Triggers**, **Wiring/Plumbing**, **Floors**,
|
**Objects**, **Ground Items**, **Triggers**, **Wiring/Plumbing**, **Floors**,
|
||||||
|
|
@ -506,9 +527,13 @@ and coordinate conventions; conversion is optional before loading a map.
|
||||||
to edit one category without the others getting in the way. Object classes and
|
to edit one category without the others getting in the way. Object classes and
|
||||||
properties determine gameplay, not the editor layer names.
|
properties determine gameplay, not the editor layer names.
|
||||||
3. Copy the directory back to the server if you edited it elsewhere, then run
|
3. Copy the directory back to the server if you edited it elsewhere, then run
|
||||||
`/import` (or `/import DIR`, or `/import FILE` for a single `.tmx` file). **Import replaces all terrain, buildings and
|
`/import` (or `/import DIR`, or `/import FILE` naming the main `.tmx` file;
|
||||||
ground items, including deletions.** It does not merge objects or change online
|
levels are read relative to it). **Import replaces all levels, terrain,
|
||||||
or offline characters, their positions or inventories. Export a separate backup
|
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.
|
before importing, and avoid editing a stale export while players keep building.
|
||||||
|
|
||||||
Booleans and small integers use native Tiled property types. Other state values
|
Booleans and small integers use native Tiled property types. Other state values
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,16 @@ buildings: [
|
||||||
on_interact: Some("vending::dispense"),
|
on_interact: Some("vending::dispense"),
|
||||||
vars: { "stock": 5 },
|
vars: { "stock": 5 },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "stairs_down", name: "Stairs down", sprite: "sprites/buildings/stairs_down.png",
|
||||||
|
description: "Leads to the level below.",
|
||||||
|
solid: false, deconstructible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stairs_up", name: "Stairs up", sprite: "sprites/buildings/stairs_up.png",
|
||||||
|
description: "Leads to the level above.",
|
||||||
|
solid: false, deconstructible: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "tree", name: "Tree", sprite: "sprites/buildings/tree.png",
|
id: "tree", name: "Tree", sprite: "sprites/buildings/tree.png",
|
||||||
deconstructible: false,
|
deconstructible: false,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="14" height="10" tilewidth="32" tileheight="32" infinite="0" nextlayerid="5" nextobjectid="7">
|
||||||
|
<properties><property name="default_tile" type="int" value="6"/></properties>
|
||||||
|
<tileset firstgid="1" source="../tilesets/station.tsx"/>
|
||||||
|
<layer id="1" name="Ground" width="14" height="10">
|
||||||
|
<data encoding="csv">
|
||||||
|
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
|
||||||
|
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
|
||||||
|
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
|
||||||
|
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
|
||||||
|
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
|
||||||
|
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
|
||||||
|
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
|
||||||
|
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
|
||||||
|
2,2,2,2,2,2,2,2,2,2,2,2,2,2,
|
||||||
|
2,2,2,2,2,2,2,2,2,2,2,2,2,2
|
||||||
|
</data>
|
||||||
|
</layer>
|
||||||
|
<layer id="2" name="Walls" width="14" height="10">
|
||||||
|
<data encoding="csv">
|
||||||
|
3,3,3,3,3,3,3,3,3,3,3,3,3,3,
|
||||||
|
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
|
||||||
|
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
|
||||||
|
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
|
||||||
|
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
|
||||||
|
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
|
||||||
|
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
|
||||||
|
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
|
||||||
|
3,0,0,0,0,0,0,0,0,0,0,0,0,3,
|
||||||
|
3,3,3,3,3,3,3,3,3,3,3,3,3,3
|
||||||
|
</data>
|
||||||
|
</layer>
|
||||||
|
<objectgroup id="3" name="Objects">
|
||||||
|
<object id="1" name="" class="building" x="80" y="80">
|
||||||
|
<properties>
|
||||||
|
<property name="proto" value="stairs_up"/>
|
||||||
|
</properties>
|
||||||
|
<point/>
|
||||||
|
</object>
|
||||||
|
<object id="2" name="" class="building" x="368" y="80">
|
||||||
|
<properties>
|
||||||
|
<property name="proto" value="furnace"/>
|
||||||
|
</properties>
|
||||||
|
<point/>
|
||||||
|
</object>
|
||||||
|
<object id="3" name="" class="building" x="368" y="240">
|
||||||
|
<properties>
|
||||||
|
<property name="proto" value="table"/>
|
||||||
|
</properties>
|
||||||
|
<point/>
|
||||||
|
</object>
|
||||||
|
<object id="4" name="" class="item" x="336" y="240">
|
||||||
|
<properties>
|
||||||
|
<property name="proto" value="iron_ore"/>
|
||||||
|
<property name="count" type="int" value="4"/>
|
||||||
|
</properties>
|
||||||
|
<point/>
|
||||||
|
</object>
|
||||||
|
<object id="5" name="" class="item" x="208" y="208">
|
||||||
|
<properties>
|
||||||
|
<property name="proto" value="log"/>
|
||||||
|
<property name="count" type="int" value="2"/>
|
||||||
|
</properties>
|
||||||
|
<point/>
|
||||||
|
</object>
|
||||||
|
</objectgroup>
|
||||||
|
<objectgroup id="4" name="Triggers">
|
||||||
|
<object id="6" name="stairs_up" class="teleport" x="64" y="64" width="32" height="32">
|
||||||
|
<properties>
|
||||||
|
<property name="map" type="file" value="station.tmx"/>
|
||||||
|
<property name="target" value="stairs_down"/>
|
||||||
|
</properties>
|
||||||
|
</object>
|
||||||
|
</objectgroup>
|
||||||
|
</map>
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="40" height="28" tilewidth="32" tileheight="32" infinite="0" nextlayerid="4" nextobjectid="30">
|
<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>
|
<properties><property name="default_tile" type="int" value="6"/></properties>
|
||||||
<tileset firstgid="1" source="../tilesets/station.tsx"/>
|
<tileset firstgid="1" source="../tilesets/station.tsx"/>
|
||||||
<layer id="1" name="Ground" width="40" height="28">
|
<layer id="1" name="Ground" width="40" height="28">
|
||||||
|
|
@ -67,7 +67,8 @@
|
||||||
</data>
|
</data>
|
||||||
</layer>
|
</layer>
|
||||||
<objectgroup id="3" name="Objects">
|
<objectgroup id="3" name="Objects">
|
||||||
<object id="1" name="arrivals" class="spawn" x="336" y="272"><point/>
|
<object id="1" name="arrivals" class="spawn" x="336" y="272">
|
||||||
|
<point/>
|
||||||
</object>
|
</object>
|
||||||
<object id="2" name="" class="building" x="592" y="272">
|
<object id="2" name="" class="building" x="592" y="272">
|
||||||
<properties>
|
<properties>
|
||||||
|
|
@ -242,5 +243,19 @@
|
||||||
</properties>
|
</properties>
|
||||||
<point/>
|
<point/>
|
||||||
</object>
|
</object>
|
||||||
|
<object id="30" name="" class="building" x="528" y="400">
|
||||||
|
<properties>
|
||||||
|
<property name="proto" value="stairs_down"/>
|
||||||
|
</properties>
|
||||||
|
<point/>
|
||||||
|
</object>
|
||||||
|
</objectgroup>
|
||||||
|
<objectgroup id="4" name="Triggers">
|
||||||
|
<object id="31" name="stairs_down" class="teleport" x="512" y="384" width="32" height="32">
|
||||||
|
<properties>
|
||||||
|
<property name="map" type="file" value="cellar.tmx"/>
|
||||||
|
<property name="target" value="stairs_up"/>
|
||||||
|
</properties>
|
||||||
|
</object>
|
||||||
</objectgroup>
|
</objectgroup>
|
||||||
</map>
|
</map>
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 264 B |
Binary file not shown.
|
After Width: | Height: | Size: 250 B |
|
|
@ -18,7 +18,7 @@ use game_server::{
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
machine::OpenMachine,
|
machine::OpenMachine,
|
||||||
render::LocalCharacter,
|
render::{LocalCharacter, OffLevel},
|
||||||
session::{self, AppState, SessionEnded, SessionInfo},
|
session::{self, AppState, SessionEnded, SessionInfo},
|
||||||
ui::{
|
ui::{
|
||||||
self, OnClick, ThemedButton, Tooltip, UiAction, UiBindings, UiContext, UiFocus, UiForm,
|
self, OnClick, ThemedButton, Tooltip, UiAction, UiBindings, UiContext, UiFocus, UiForm,
|
||||||
|
|
@ -519,7 +519,7 @@ fn recipe_widgets(
|
||||||
ui: Res<UiState>,
|
ui: Res<UiState>,
|
||||||
local: Res<LocalCharacter>,
|
local: Res<LocalCharacter>,
|
||||||
characters: Query<(&GridPos, &Inventory, &Skills)>,
|
characters: Query<(&GridPos, &Inventory, &Skills)>,
|
||||||
buildings: Query<(&Building, &GridPos)>,
|
buildings: Query<(&Building, &GridPos), Without<OffLevel>>,
|
||||||
mut slots: Query<(Entity, &mut WidgetSlot)>,
|
mut slots: Query<(Entity, &mut WidgetSlot)>,
|
||||||
) {
|
) {
|
||||||
let Some((pos, inventory, skills)) = local.0.and_then(|e| characters.get(e).ok()) else {
|
let Some((pos, inventory, skills)) = local.0.and_then(|e| characters.get(e).ok()) else {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ use game_server::actions::REACH;
|
||||||
use crate::{
|
use crate::{
|
||||||
hud::{Menu, UiState, activate_entry, menu_entries},
|
hud::{Menu, UiState, activate_entry, menu_entries},
|
||||||
machine::{InteractAt, OpenMachine},
|
machine::{InteractAt, OpenMachine},
|
||||||
render::LocalCharacter,
|
render::{LocalCharacter, OffLevel},
|
||||||
session::AppState,
|
session::AppState,
|
||||||
ui::{TextEditSystems, UiFocus, pointer_over_ui},
|
ui::{TextEditSystems, UiFocus, pointer_over_ui},
|
||||||
};
|
};
|
||||||
|
|
@ -160,7 +160,7 @@ fn actions(
|
||||||
machine: Res<OpenMachine>,
|
machine: Res<OpenMachine>,
|
||||||
local: Res<LocalCharacter>,
|
local: Res<LocalCharacter>,
|
||||||
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
|
characters: Query<(&GridPos, &Facing, &Inventory), With<Character>>,
|
||||||
items: Query<&GridPos, With<GroundItem>>,
|
items: Query<&GridPos, (With<GroundItem>, Without<OffLevel>)>,
|
||||||
) {
|
) {
|
||||||
if keys.just_pressed(KeyCode::KeyH) {
|
if keys.just_pressed(KeyCode::KeyH) {
|
||||||
ui.toggle(Menu::Help);
|
ui.toggle(Menu::Help);
|
||||||
|
|
@ -241,7 +241,7 @@ fn mouse(
|
||||||
camera: Query<(&Camera, &GlobalTransform)>,
|
camera: Query<(&Camera, &GlobalTransform)>,
|
||||||
local: Res<LocalCharacter>,
|
local: Res<LocalCharacter>,
|
||||||
characters: Query<&GridPos, With<Character>>,
|
characters: Query<&GridPos, With<Character>>,
|
||||||
buildings: Query<(&GridPos, &BuildingState), With<Building>>,
|
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<OffLevel>)>,
|
||||||
) {
|
) {
|
||||||
if !buttons.just_pressed(MouseButton::Left)
|
if !buttons.just_pressed(MouseButton::Left)
|
||||||
|| ui.blocks_game()
|
|| ui.blocks_game()
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ use game_server::actions::REACH;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
hud::{UiState, spawn_item_slot},
|
hud::{UiState, spawn_item_slot},
|
||||||
render::LocalCharacter,
|
render::{LocalCharacter, OffLevel},
|
||||||
session::{AppState, SessionEnded},
|
session::{AppState, SessionEnded},
|
||||||
ui::{OnClick, ThemedButton, Tooltip, UiAction, layout::Theme},
|
ui::{OnClick, ThemedButton, Tooltip, UiAction, layout::Theme},
|
||||||
};
|
};
|
||||||
|
|
@ -48,7 +48,7 @@ fn interact_at(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut open: ResMut<OpenMachine>,
|
mut open: ResMut<OpenMachine>,
|
||||||
mut ui: ResMut<UiState>,
|
mut ui: ResMut<UiState>,
|
||||||
machines: Query<(Entity, &GridPos), With<Machine>>,
|
machines: Query<(Entity, &GridPos), (With<Machine>, Without<OffLevel>)>,
|
||||||
) {
|
) {
|
||||||
match machines.iter().find(|(_, p)| **p == ev.0) {
|
match machines.iter().find(|(_, p)| **p == ev.0) {
|
||||||
Some((entity, _)) => {
|
Some((entity, _)) => {
|
||||||
|
|
@ -77,7 +77,7 @@ fn close_when_invalid(
|
||||||
mut open: ResMut<OpenMachine>,
|
mut open: ResMut<OpenMachine>,
|
||||||
local: Res<LocalCharacter>,
|
local: Res<LocalCharacter>,
|
||||||
positions: Query<&GridPos>,
|
positions: Query<&GridPos>,
|
||||||
machines: Query<&GridPos, With<Machine>>,
|
machines: Query<&GridPos, (With<Machine>, Without<OffLevel>)>,
|
||||||
) {
|
) {
|
||||||
let Some(machine) = open.0 else { return };
|
let Some(machine) = open.0 else { return };
|
||||||
let me = local.0.and_then(|e| positions.get(e).ok());
|
let me = local.0.and_then(|e| positions.get(e).ok());
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ impl Plugin for RenderPlugin {
|
||||||
item_visuals,
|
item_visuals,
|
||||||
character_visuals,
|
character_visuals,
|
||||||
move_characters,
|
move_characters,
|
||||||
|
hide_other_levels,
|
||||||
update_marker,
|
update_marker,
|
||||||
follow_camera,
|
follow_camera,
|
||||||
)
|
)
|
||||||
|
|
@ -302,6 +303,37 @@ fn move_characters(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn update_marker(
|
fn update_marker(
|
||||||
local: Res<LocalCharacter>,
|
local: Res<LocalCharacter>,
|
||||||
characters: Query<(&GridPos, &Facing)>,
|
characters: Query<(&GridPos, &Facing)>,
|
||||||
|
|
@ -332,6 +364,11 @@ fn follow_camera(
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let goal = target.translation.truncate().extend(camera.translation.z);
|
let goal = target.translation.truncate().extend(camera.translation.z);
|
||||||
|
// Don't pan across the map after teleports.
|
||||||
|
if goal.distance(camera.translation) > TILE_SIZE * 8.0 {
|
||||||
|
camera.translation = goal;
|
||||||
|
return;
|
||||||
|
}
|
||||||
let t = 1.0 - (-8.0 * time.delta_secs()).exp();
|
let t = 1.0 - (-8.0 * time.delta_secs()).exp();
|
||||||
camera.translation = camera.translation.lerp(goal, t);
|
camera.translation = camera.translation.lerp(goal, t);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -455,6 +455,28 @@ pub struct WorldMap {
|
||||||
pub layout: crate::map::MapLayout,
|
pub layout: crate::map::MapLayout,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The level (map) an entity is on: the path of its Tiled map relative to the
|
||||||
|
/// main map's directory, with `/` separators.
|
||||||
|
///
|
||||||
|
/// Immutable because it drives replication visibility; re-insert it to move an entity.
|
||||||
|
#[derive(Component, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[component(immutable)]
|
||||||
|
pub struct Level(pub String);
|
||||||
|
|
||||||
|
/// The level a client receives entities of. Lives on the replicon client entity.
|
||||||
|
#[derive(Component, Clone, Debug, PartialEq, Eq)]
|
||||||
|
#[component(immutable)]
|
||||||
|
pub struct ViewLevel(pub String);
|
||||||
|
|
||||||
|
impl bevy_replicon::prelude::VisibilityFilter for Level {
|
||||||
|
type ClientComponent = ViewLevel;
|
||||||
|
type Scope = Entity;
|
||||||
|
|
||||||
|
fn is_visible(&self, _client: Entity, view: Option<&ViewLevel>) -> bool {
|
||||||
|
view.is_some_and(|view| view.0 == self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
//! - `spawn`: player spawn point.
|
//! - `spawn`: player spawn point.
|
||||||
//! - `building`: building with the custom property `proto` (a building id).
|
//! - `building`: building with the custom property `proto` (a building id).
|
||||||
//! - `item`: ground item with the property `proto` (an item id) and optional `count`.
|
//! - `item`: ground item with the property `proto` (an item id) and optional `count`.
|
||||||
|
//! - `teleport`: trigger leading to the object named by `target`, on the map named by
|
||||||
|
//! the optional file property `map` (relative to this map).
|
||||||
//!
|
//!
|
||||||
//! State overrides (`state.*`, `machine.*`) and legacy integer script variables
|
//! State overrides (`state.*`, `machine.*`) and legacy integer script variables
|
||||||
//! are decoded by the server for both new worlds and imports.
|
//! are decoded by the server for both new worlds and imports.
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ impl Plugin for ProtocolPlugin {
|
||||||
.replicate::<Building>()
|
.replicate::<Building>()
|
||||||
.replicate::<BuildingState>()
|
.replicate::<BuildingState>()
|
||||||
.replicate::<Machine>()
|
.replicate::<Machine>()
|
||||||
|
.replicate::<Level>()
|
||||||
.add_client_event::<Login>(Channel::Ordered)
|
.add_client_event::<Login>(Channel::Ordered)
|
||||||
.add_client_event::<SetMovement>(Channel::Ordered)
|
.add_client_event::<SetMovement>(Channel::Ordered)
|
||||||
.add_client_event::<PickUp>(Channel::Ordered)
|
.add_client_event::<PickUp>(Channel::Ordered)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ use crate::{
|
||||||
movement,
|
movement,
|
||||||
players::{Players, tell},
|
players::{Players, tell},
|
||||||
scripting::{ScriptEngine, ScriptState, ScriptTarget},
|
scripting::{ScriptEngine, ScriptState, ScriptTarget},
|
||||||
world::{WorldLayout, spawn_building, spawn_ground_item},
|
world::{Level, Levels, spawn_building, spawn_ground_item},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Read-only game data shared with scripts.
|
/// Read-only game data shared with scripts.
|
||||||
|
|
@ -30,6 +30,7 @@ type CharacterData<'a> = (
|
||||||
&'a mut Inventory,
|
&'a mut Inventory,
|
||||||
&'a mut Vitals,
|
&'a mut Vitals,
|
||||||
&'a mut Skills,
|
&'a mut Skills,
|
||||||
|
&'a Level,
|
||||||
);
|
);
|
||||||
|
|
||||||
type Characters<'w, 's> = Query<'w, 's, CharacterData<'static>>;
|
type Characters<'w, 's> = Query<'w, 's, CharacterData<'static>>;
|
||||||
|
|
@ -41,11 +42,21 @@ type Buildings<'w, 's> = Query<
|
||||||
&'static Building,
|
&'static Building,
|
||||||
&'static GridPos,
|
&'static GridPos,
|
||||||
&'static mut BuildingState,
|
&'static mut BuildingState,
|
||||||
|
&'static Level,
|
||||||
|
),
|
||||||
|
Without<Character>,
|
||||||
|
>;
|
||||||
|
type GroundItems<'w, 's> = Query<
|
||||||
|
'w,
|
||||||
|
's,
|
||||||
|
(
|
||||||
|
Entity,
|
||||||
|
&'static mut GroundItem,
|
||||||
|
&'static GridPos,
|
||||||
|
&'static Level,
|
||||||
),
|
),
|
||||||
Without<Character>,
|
Without<Character>,
|
||||||
>;
|
>;
|
||||||
type GroundItems<'w, 's> =
|
|
||||||
Query<'w, 's, (Entity, &'static mut GroundItem, &'static GridPos), Without<Character>>;
|
|
||||||
|
|
||||||
fn face_towards(facing: &mut Mut<Facing>, from: GridPos, to: GridPos) {
|
fn face_towards(facing: &mut Mut<Facing>, from: GridPos, to: GridPos) {
|
||||||
use game_core::grid::Dir;
|
use game_core::grid::Dir;
|
||||||
|
|
@ -82,11 +93,12 @@ fn give_or_drop(
|
||||||
data: &GameData,
|
data: &GameData,
|
||||||
inventory: &mut Inventory,
|
inventory: &mut Inventory,
|
||||||
stack: ItemStack,
|
stack: ItemStack,
|
||||||
|
level: &Level,
|
||||||
pos: GridPos,
|
pos: GridPos,
|
||||||
) {
|
) {
|
||||||
let leftover = inventory.add(&stack.item, stack.count, data);
|
let leftover = inventory.add(&stack.item, stack.count, data);
|
||||||
if leftover > 0 {
|
if leftover > 0 {
|
||||||
spawn_ground_item(commands, ItemStack::new(stack.item, leftover), pos);
|
spawn_ground_item(commands, ItemStack::new(stack.item, leftover), level, pos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,7 +172,7 @@ pub fn on_pick_up(
|
||||||
let Some(&entity) = players.by_client.get(&client) else {
|
let Some(&entity) = players.by_client.get(&client) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok((_, pos, mut facing, mut inventory, _, _)) = characters.get_mut(entity) else {
|
let Ok((_, pos, mut facing, mut inventory, _, _, level)) = characters.get_mut(entity) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if pos.distance(target) > REACH {
|
if pos.distance(target) > REACH {
|
||||||
|
|
@ -168,8 +180,8 @@ pub fn on_pick_up(
|
||||||
}
|
}
|
||||||
face_towards(&mut facing, *pos, target);
|
face_towards(&mut facing, *pos, target);
|
||||||
let mut picked = Vec::new();
|
let mut picked = Vec::new();
|
||||||
for (item_entity, mut item, item_pos) in &mut items {
|
for (item_entity, mut item, item_pos, item_level) in &mut items {
|
||||||
if *item_pos != target {
|
if *item_pos != target || item_level != level {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let leftover = inventory.add(&item.0.item, item.0.count, &data);
|
let leftover = inventory.add(&item.0.item, item.0.count, &data);
|
||||||
|
|
@ -184,7 +196,7 @@ pub fn on_pick_up(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if picked.is_empty() {
|
if picked.is_empty() {
|
||||||
let full = items.iter().any(|(_, _, p)| *p == target);
|
let full = items.iter().any(|(_, _, p, l)| *p == target && l == level);
|
||||||
tell(
|
tell(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
client,
|
client,
|
||||||
|
|
@ -215,7 +227,7 @@ pub fn on_drop_item(
|
||||||
let Some(&entity) = players.by_client.get(&client) else {
|
let Some(&entity) = players.by_client.get(&client) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok((_, pos, _, mut inventory, _, _)) = characters.get_mut(entity) else {
|
let Ok((_, pos, _, mut inventory, _, _, level)) = characters.get_mut(entity) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(stack) = inventory.take_from_slot(ev.message.slot, u32::MAX) else {
|
let Some(stack) = inventory.take_from_slot(ev.message.slot, u32::MAX) else {
|
||||||
|
|
@ -231,13 +243,13 @@ pub fn on_drop_item(
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// Merge with a matching stack on the floor to avoid clutter.
|
// Merge with a matching stack on the floor to avoid clutter.
|
||||||
if let Some((_, mut existing, _)) = items
|
if let Some((_, mut existing, _, _)) = items
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|(_, i, p)| *p == pos && i.0.item == stack.item)
|
.find(|(_, i, p, l)| *p == pos && *l == level && i.0.item == stack.item)
|
||||||
{
|
{
|
||||||
existing.0.count += stack.count;
|
existing.0.count += stack.count;
|
||||||
} else {
|
} else {
|
||||||
spawn_ground_item(&mut commands, stack, *pos);
|
spawn_ground_item(&mut commands, stack, level, *pos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,7 +265,8 @@ pub fn on_use_item(
|
||||||
let Some(&entity) = players.by_client.get(&client) else {
|
let Some(&entity) = players.by_client.get(&client) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok((character, pos, _, mut inventory, mut vitals, mut skills)) = characters.get_mut(entity)
|
let Ok((character, pos, _, mut inventory, mut vitals, mut skills, level)) =
|
||||||
|
characters.get_mut(entity)
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -287,7 +300,7 @@ pub fn on_use_item(
|
||||||
inventory.remove(&item_id, 1);
|
inventory.remove(&item_id, 1);
|
||||||
}
|
}
|
||||||
for stack in outcome.spawned {
|
for stack in outcome.spawned {
|
||||||
spawn_ground_item(&mut commands, stack, *pos);
|
spawn_ground_item(&mut commands, stack, level, *pos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,7 +319,7 @@ pub fn on_interact(
|
||||||
let Some(&entity) = players.by_client.get(&client) else {
|
let Some(&entity) = players.by_client.get(&client) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok((character, pos, mut facing, mut inventory, mut vitals, mut skills)) =
|
let Ok((character, pos, mut facing, mut inventory, mut vitals, mut skills, level)) =
|
||||||
characters.get_mut(entity)
|
characters.get_mut(entity)
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -316,10 +329,10 @@ pub fn on_interact(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
face_towards(&mut facing, *pos, target);
|
face_towards(&mut facing, *pos, target);
|
||||||
let Some((building_entity, building, _, mut state)) = buildings
|
let Some((building_entity, building, _, mut state, _)) = buildings
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.filter(|(_, _, p, _)| **p == target)
|
.filter(|(_, _, p, _, l)| **p == target && *l == level)
|
||||||
.max_by_key(|(_, _, _, state)| state.layer)
|
.max_by_key(|(_, _, _, state, _)| state.layer)
|
||||||
else {
|
else {
|
||||||
tell(&mut commands, client, "There is nothing there.");
|
tell(&mut commands, client, "There is nothing there.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -367,7 +380,7 @@ pub fn on_interact(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for stack in outcome.spawned {
|
for stack in outcome.spawned {
|
||||||
spawn_ground_item(&mut commands, stack, target);
|
spawn_ground_item(&mut commands, stack, level, target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,20 +392,21 @@ pub fn on_craft(
|
||||||
data: Res<SharedData>,
|
data: Res<SharedData>,
|
||||||
engine: Res<ScriptEngine>,
|
engine: Res<ScriptEngine>,
|
||||||
mut characters: Characters,
|
mut characters: Characters,
|
||||||
buildings: Query<(&Building, &GridPos), Without<Character>>,
|
buildings: Query<(&Building, &GridPos, &Level), Without<Character>>,
|
||||||
) {
|
) {
|
||||||
let client = ev.client_id;
|
let client = ev.client_id;
|
||||||
let Some(&entity) = players.by_client.get(&client) else {
|
let Some(&entity) = players.by_client.get(&client) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok((character, pos, _, mut inventory, mut vitals, mut skills)) = characters.get_mut(entity)
|
let Ok((character, pos, _, mut inventory, mut vitals, mut skills, level)) =
|
||||||
|
characters.get_mut(entity)
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let stations: Vec<String> = buildings
|
let stations: Vec<String> = buildings
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, p)| p.distance(*pos) <= REACH)
|
.filter(|(_, p, l)| p.distance(*pos) <= REACH && *l == level)
|
||||||
.filter_map(|(b, _)| data.0.buildings.get(&b.proto))
|
.filter_map(|(b, _, _)| data.0.buildings.get(&b.proto))
|
||||||
.flat_map(|def| def.stations.iter().cloned())
|
.flat_map(|def| def.stations.iter().cloned())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -428,7 +442,7 @@ pub fn on_craft(
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
for stack in outcome.spawned {
|
for stack in outcome.spawned {
|
||||||
spawn_ground_item(&mut commands, stack, *pos);
|
spawn_ground_item(&mut commands, stack, level, *pos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -442,23 +456,27 @@ pub fn on_construct(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
players: Res<Players>,
|
players: Res<Players>,
|
||||||
data: Res<GameData>,
|
data: Res<GameData>,
|
||||||
layout: Res<WorldLayout>,
|
levels: Res<Levels>,
|
||||||
mut characters: Characters,
|
mut characters: Characters,
|
||||||
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
buildings: Query<(&GridPos, &BuildingState, &Level), (With<Building>, Without<Character>)>,
|
||||||
occupants: Query<&Position, (With<Character>, Without<Building>)>,
|
occupants: Query<(&Position, &Level), (With<Character>, Without<Building>)>,
|
||||||
) {
|
) {
|
||||||
let client = ev.client_id;
|
let client = ev.client_id;
|
||||||
let target = ev.message.target;
|
let target = ev.message.target;
|
||||||
let Some(&entity) = players.by_client.get(&client) else {
|
let Some(&entity) = players.by_client.get(&client) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
// Checked before borrowing the actor mutably.
|
let Ok((_, pos, mut facing, mut inventory, _, mut skills, level)) = characters.get_mut(entity)
|
||||||
let occupied = occupants
|
else {
|
||||||
.iter()
|
|
||||||
.any(|p| movement::overlaps_tile(p.0, target));
|
|
||||||
let Ok((_, pos, mut facing, mut inventory, _, mut skills)) = characters.get_mut(entity) else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let level = level.clone();
|
||||||
|
let Some(layout) = levels.layout(&level) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let occupied = occupants
|
||||||
|
.iter()
|
||||||
|
.any(|(p, l)| *l == level && movement::overlaps_tile(p.0, target));
|
||||||
if pos.distance(target) > REACH {
|
if pos.distance(target) > REACH {
|
||||||
tell(&mut commands, client, "That is too far away.");
|
tell(&mut commands, client, "That is too far away.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -468,14 +486,17 @@ pub fn on_construct(
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let floor = def.layer == game_core::data::BuildingLayer::Floor;
|
let floor = def.layer == game_core::data::BuildingLayer::Floor;
|
||||||
let has_floor = buildings
|
let here = || {
|
||||||
|
buildings
|
||||||
.iter()
|
.iter()
|
||||||
.any(|(p, state)| *p == target && state.layer == game_core::data::BuildingLayer::Floor);
|
.filter(|(p, _, l)| **p == target && **l == level)
|
||||||
if layout.0.is_solid_with_floor(target, floor || has_floor)
|
};
|
||||||
|| buildings.iter().any(|(p, state)| {
|
let has_floor =
|
||||||
*p == target
|
here().any(|(_, state, _)| state.layer == game_core::data::BuildingLayer::Floor);
|
||||||
&& (state.layer == def.layer
|
if layout.is_solid_with_floor(target, floor || has_floor)
|
||||||
|| (floor && state.layer == game_core::data::BuildingLayer::Structure))
|
|| here().any(|(_, state, _)| {
|
||||||
|
state.layer == def.layer
|
||||||
|
|| (floor && state.layer == game_core::data::BuildingLayer::Structure)
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
tell(&mut commands, client, "There is no room to build there.");
|
tell(&mut commands, client, "There is no room to build there.");
|
||||||
|
|
@ -491,7 +512,14 @@ pub fn on_construct(
|
||||||
Ok(level_up) => {
|
Ok(level_up) => {
|
||||||
*inventory = new_inventory;
|
*inventory = new_inventory;
|
||||||
*skills = new_skills;
|
*skills = new_skills;
|
||||||
spawn_building(&mut commands, &data, &def.id, target, Default::default());
|
spawn_building(
|
||||||
|
&mut commands,
|
||||||
|
&data,
|
||||||
|
&def.id,
|
||||||
|
&level,
|
||||||
|
target,
|
||||||
|
Default::default(),
|
||||||
|
);
|
||||||
tell(&mut commands, client, format!("You build a {}.", def.name));
|
tell(&mut commands, client, format!("You build a {}.", def.name));
|
||||||
announce_level_up(&mut commands, client, &data, level_up);
|
announce_level_up(&mut commands, client, &data, level_up);
|
||||||
}
|
}
|
||||||
|
|
@ -512,6 +540,7 @@ pub fn on_deconstruct(
|
||||||
&GridPos,
|
&GridPos,
|
||||||
&BuildingState,
|
&BuildingState,
|
||||||
Option<&Machine>,
|
Option<&Machine>,
|
||||||
|
&Level,
|
||||||
),
|
),
|
||||||
Without<Character>,
|
Without<Character>,
|
||||||
>,
|
>,
|
||||||
|
|
@ -521,7 +550,7 @@ pub fn on_deconstruct(
|
||||||
let Some(&entity) = players.by_client.get(&client) else {
|
let Some(&entity) = players.by_client.get(&client) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok((_, pos, mut facing, mut inventory, _, _)) = characters.get_mut(entity) else {
|
let Ok((_, pos, mut facing, mut inventory, _, _, level)) = characters.get_mut(entity) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if pos.distance(target) > REACH {
|
if pos.distance(target) > REACH {
|
||||||
|
|
@ -529,10 +558,10 @@ pub fn on_deconstruct(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
face_towards(&mut facing, *pos, target);
|
face_towards(&mut facing, *pos, target);
|
||||||
let Some((building_entity, building, _, _, machine)) = buildings
|
let Some((building_entity, building, _, _, machine, _)) = buildings
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, _, p, _, _)| **p == target)
|
.filter(|(_, _, p, _, _, l)| **p == target && *l == level)
|
||||||
.max_by_key(|(_, _, _, state, _)| state.layer)
|
.max_by_key(|(_, _, _, state, _, _)| state.layer)
|
||||||
else {
|
else {
|
||||||
tell(&mut commands, client, "There is nothing to deconstruct.");
|
tell(&mut commands, client, "There is nothing to deconstruct.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -569,12 +598,13 @@ pub fn on_deconstruct(
|
||||||
&data,
|
&data,
|
||||||
&mut inventory,
|
&mut inventory,
|
||||||
ItemStack::new(&material.item, material.count),
|
ItemStack::new(&material.item, material.count),
|
||||||
|
level,
|
||||||
*pos,
|
*pos,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for stack in machine.into_iter().flat_map(Machine::contents) {
|
for stack in machine.into_iter().flat_map(Machine::contents) {
|
||||||
spawn_ground_item(&mut commands, stack.clone(), target);
|
spawn_ground_item(&mut commands, stack.clone(), level, target);
|
||||||
}
|
}
|
||||||
commands.entity(building_entity).despawn();
|
commands.entity(building_entity).despawn();
|
||||||
tell(
|
tell(
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ pub mod world;
|
||||||
use std::{sync::Arc, time::Duration};
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy_replicon::server::visibility::AppVisibilityExt;
|
||||||
use game_core::GameData;
|
use game_core::GameData;
|
||||||
|
|
||||||
pub use network::NetworkServerPlugin;
|
pub use network::NetworkServerPlugin;
|
||||||
|
|
@ -68,6 +69,7 @@ impl Plugin for GameServerPlugin {
|
||||||
.init_resource::<players::CharacterStore>()
|
.init_resource::<players::CharacterStore>()
|
||||||
.init_resource::<players::RejectedLogins>()
|
.init_resource::<players::RejectedLogins>()
|
||||||
.init_resource::<survival::SurvivalTimer>()
|
.init_resource::<survival::SurvivalTimer>()
|
||||||
|
.add_visibility_filter::<world::Level>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Startup,
|
Startup,
|
||||||
(
|
(
|
||||||
|
|
@ -81,10 +83,11 @@ impl Plugin for GameServerPlugin {
|
||||||
(
|
(
|
||||||
(movement::move_characters, survival::tick_survival),
|
(movement::move_characters, survival::tick_survival),
|
||||||
movement::sync_tiles,
|
movement::sync_tiles,
|
||||||
|
movement::use_teleports,
|
||||||
machines::tick_machines,
|
machines::tick_machines,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.run_if(resource_exists::<world::WorldLayout>),
|
.run_if(resource_exists::<world::Levels>),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use crate::{
|
||||||
actions::REACH,
|
actions::REACH,
|
||||||
crafting::item_name,
|
crafting::item_name,
|
||||||
players::{Players, tell},
|
players::{Players, tell},
|
||||||
|
world::Level,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Advances every machine: picks the first recipe it can process, counts up
|
/// Advances every machine: picks the first recipe it can process, counts up
|
||||||
|
|
@ -57,20 +58,32 @@ pub fn tick_machines(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Actor<'a> = (&'a GridPos, &'a mut Inventory);
|
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>(
|
fn machine_in_reach<'a>(
|
||||||
|
level: &Level,
|
||||||
pos: GridPos,
|
pos: GridPos,
|
||||||
target: GridPos,
|
target: GridPos,
|
||||||
machines: &'a mut Query<(&GridPos, &Building, &mut Machine), Without<Character>>,
|
machines: &'a mut Machines,
|
||||||
) -> Result<(&'a Building, Mut<'a, Machine>), &'static str> {
|
) -> Result<(&'a Building, Mut<'a, Machine>), &'static str> {
|
||||||
if pos.distance(target) > REACH {
|
if pos.distance(target) > REACH {
|
||||||
return Err("That is too far away.");
|
return Err("That is too far away.");
|
||||||
}
|
}
|
||||||
machines
|
machines
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|(p, _, _)| **p == target)
|
.find(|(p, _, _, l)| **p == target && *l == level)
|
||||||
.map(|(_, b, m)| (b, m))
|
.map(|(_, b, m, _)| (b, m))
|
||||||
.ok_or("There is no machine there.")
|
.ok_or("There is no machine there.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,17 +93,17 @@ pub fn on_machine_insert(
|
||||||
players: Res<Players>,
|
players: Res<Players>,
|
||||||
data: Res<GameData>,
|
data: Res<GameData>,
|
||||||
mut characters: Query<Actor<'static>, With<Character>>,
|
mut characters: Query<Actor<'static>, With<Character>>,
|
||||||
mut machines: Query<(&GridPos, &Building, &mut Machine), Without<Character>>,
|
mut machines: Machines,
|
||||||
) {
|
) {
|
||||||
let client = ev.client_id;
|
let client = ev.client_id;
|
||||||
let msg = &ev.message;
|
let msg = &ev.message;
|
||||||
let Some(&entity) = players.by_client.get(&client) else {
|
let Some(&entity) = players.by_client.get(&client) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok((pos, mut inventory)) = characters.get_mut(entity) else {
|
let Ok((pos, mut inventory, level)) = characters.get_mut(entity) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let (_, mut machine) = match machine_in_reach(*pos, msg.target, &mut machines) {
|
let (_, mut machine) = match machine_in_reach(level, *pos, msg.target, &mut machines) {
|
||||||
Ok(found) => found,
|
Ok(found) => found,
|
||||||
Err(reason) => return tell(&mut commands, client, reason),
|
Err(reason) => return tell(&mut commands, client, reason),
|
||||||
};
|
};
|
||||||
|
|
@ -119,17 +132,17 @@ pub fn on_machine_take(
|
||||||
players: Res<Players>,
|
players: Res<Players>,
|
||||||
data: Res<GameData>,
|
data: Res<GameData>,
|
||||||
mut characters: Query<Actor<'static>, With<Character>>,
|
mut characters: Query<Actor<'static>, With<Character>>,
|
||||||
mut machines: Query<(&GridPos, &Building, &mut Machine), Without<Character>>,
|
mut machines: Machines,
|
||||||
) {
|
) {
|
||||||
let client = ev.client_id;
|
let client = ev.client_id;
|
||||||
let msg = &ev.message;
|
let msg = &ev.message;
|
||||||
let Some(&entity) = players.by_client.get(&client) else {
|
let Some(&entity) = players.by_client.get(&client) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok((pos, mut inventory)) = characters.get_mut(entity) else {
|
let Ok((pos, mut inventory, level)) = characters.get_mut(entity) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let (_, mut machine) = match machine_in_reach(*pos, msg.target, &mut machines) {
|
let (_, mut machine) = match machine_in_reach(level, *pos, msg.target, &mut machines) {
|
||||||
Ok(found) => found,
|
Ok(found) => found,
|
||||||
Err(reason) => return tell(&mut commands, client, reason),
|
Err(reason) => return tell(&mut commands, client, reason),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@ use std::{
|
||||||
collections::{BTreeMap, BTreeSet},
|
collections::{BTreeMap, BTreeSet},
|
||||||
io,
|
io,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use game_core::{GameData, components::*, map::MapLayout};
|
use game_core::{
|
||||||
|
GameData,
|
||||||
|
components::*,
|
||||||
|
map::{MapLayout, MapObject},
|
||||||
|
};
|
||||||
use quick_xml::{
|
use quick_xml::{
|
||||||
Writer,
|
Writer,
|
||||||
events::{BytesEnd, BytesStart, BytesText, Event},
|
events::{BytesEnd, BytesStart, BytesText, Event},
|
||||||
|
|
@ -17,10 +20,14 @@ use serde::{Serialize, de::DeserializeOwned};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
persistence::{self, SaveGame, SavedBuilding, SavedItem, Storage},
|
persistence::{self, SaveGame, SavedBuilding, SavedCharacter, SavedItem, Storage},
|
||||||
world::{SpawnPoint, WorldLayout, spawn_building_with_state, spawn_ground_item},
|
players::{CharacterStore, Controller},
|
||||||
|
world::{Level, Levels, install_levels, move_character},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Upper bound for levels reachable through teleports, against runaway references.
|
||||||
|
pub const MAX_LEVELS: usize = 64;
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum MapIoError {
|
pub enum MapIoError {
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
|
|
@ -49,9 +56,16 @@ pub fn decode(layout: MapLayout, data: &GameData) -> Result<SaveGame, MapIoError
|
||||||
if !layout.in_bounds(object.pos) {
|
if !layout.in_bounds(object.pos) {
|
||||||
return Err(invalid("position is outside the map".into()));
|
return Err(invalid("position is outside the map".into()));
|
||||||
}
|
}
|
||||||
if object.class == "spawn" {
|
match object.class.as_str() {
|
||||||
|
"spawn" => continue,
|
||||||
|
"teleport" => {
|
||||||
|
if object.properties.get("target").is_none_or(|t| t.is_empty()) {
|
||||||
|
return Err(invalid("teleport without `target`".into()));
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
let proto = object
|
let proto = object
|
||||||
.properties
|
.properties
|
||||||
.get("proto")
|
.get("proto")
|
||||||
|
|
@ -125,6 +139,7 @@ pub fn decode(layout: MapLayout, data: &GameData) -> Result<SaveGame, MapIoError
|
||||||
pos: object.pos,
|
pos: object.pos,
|
||||||
state,
|
state,
|
||||||
machine,
|
machine,
|
||||||
|
level: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"item" => {
|
"item" => {
|
||||||
|
|
@ -137,17 +152,195 @@ pub fn decode(layout: MapLayout, data: &GameData) -> Result<SaveGame, MapIoError
|
||||||
save.items.push(SavedItem {
|
save.items.push(SavedItem {
|
||||||
stack,
|
stack,
|
||||||
pos: object.pos,
|
pos: object.pos,
|
||||||
|
level: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
class => return Err(invalid(format!("unsupported class `{class}`"))),
|
class => return Err(invalid(format!("unsupported class `{class}`"))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut terrain = layout;
|
let mut terrain = layout;
|
||||||
terrain.objects.retain(|o| o.class == "spawn");
|
terrain
|
||||||
|
.objects
|
||||||
|
.retain(|o| matches!(o.class.as_str(), "spawn" | "teleport"));
|
||||||
save.layout = Some(terrain);
|
save.layout = Some(terrain);
|
||||||
Ok(save)
|
Ok(save)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves a map path relative to the map file `from` (both relative to the
|
||||||
|
/// main map's directory) into a level id.
|
||||||
|
pub fn resolve_level(from: &str, relative: &str) -> Result<String, String> {
|
||||||
|
let relative = relative.replace('\\', "/");
|
||||||
|
if relative.starts_with('/') || relative.contains(':') {
|
||||||
|
return Err(format!("map path `{relative}` must be relative"));
|
||||||
|
}
|
||||||
|
let mut parts: Vec<&str> = from.split('/').collect();
|
||||||
|
parts.pop();
|
||||||
|
for part in relative.split('/') {
|
||||||
|
match part {
|
||||||
|
"" | "." => {}
|
||||||
|
".." => {
|
||||||
|
parts
|
||||||
|
.pop()
|
||||||
|
.ok_or_else(|| format!("map path `{relative}` leaves the map directory"))?;
|
||||||
|
}
|
||||||
|
part => parts.push(part),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
return Err(format!("map path `{relative}` names no file"));
|
||||||
|
}
|
||||||
|
Ok(parts.join("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path of the map file `to` relative to the map file `from`.
|
||||||
|
pub fn relative_level_path(from: &str, to: &str) -> String {
|
||||||
|
let from: Vec<&str> = from.split('/').collect();
|
||||||
|
let from = &from[..from.len() - 1];
|
||||||
|
let to: Vec<&str> = to.split('/').collect();
|
||||||
|
let common = from
|
||||||
|
.iter()
|
||||||
|
.zip(&to[..to.len() - 1])
|
||||||
|
.take_while(|(a, b)| a == b)
|
||||||
|
.count();
|
||||||
|
let mut parts = vec![".."; from.len() - common];
|
||||||
|
parts.extend(&to[common..]);
|
||||||
|
parts.join("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Level a teleport object leads to. Its `map` property is relative to the level's
|
||||||
|
/// map; without one the teleport stays on the level.
|
||||||
|
pub fn teleport_level(level: &str, object: &MapObject) -> Result<String, String> {
|
||||||
|
match object.properties.get("map").filter(|m| !m.is_empty()) {
|
||||||
|
Some(map) => resolve_level(level, map),
|
||||||
|
None => Ok(level.to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points saved teleports to the main level `old` at `new`, for worlds whose server
|
||||||
|
/// map was renamed. The main level is saved without its id, so only teleports
|
||||||
|
/// refer to it by name.
|
||||||
|
pub fn rename_main_level(save: &mut SaveGame, old: &str, new: &str) {
|
||||||
|
if old == new {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (level, layout) in &mut save.levels {
|
||||||
|
for object in layout.objects.iter_mut().filter(|o| o.class == "teleport") {
|
||||||
|
if teleport_level(level, object).is_ok_and(|target| target == old) {
|
||||||
|
object
|
||||||
|
.properties
|
||||||
|
.insert("map".into(), relative_level_path(level, new));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrites the `map` properties of a level's teleports to canonical level ids,
|
||||||
|
/// treating `main_file` as another name of the main level. Returns the targets.
|
||||||
|
fn normalize_teleports(
|
||||||
|
layout: &mut MapLayout,
|
||||||
|
level: &str,
|
||||||
|
main: &str,
|
||||||
|
main_file: &str,
|
||||||
|
) -> Result<BTreeSet<String>, MapIoError> {
|
||||||
|
let file = if level == main { main_file } else { level };
|
||||||
|
let mut targets = BTreeSet::new();
|
||||||
|
for object in layout.objects.iter_mut().filter(|o| o.class == "teleport") {
|
||||||
|
let mut target = teleport_level(file, object).map_err(|e| {
|
||||||
|
MapIoError::Invalid(format!("level `{level}`, teleport {}: {e}", object.id))
|
||||||
|
})?;
|
||||||
|
if target == main_file {
|
||||||
|
target = main.to_owned();
|
||||||
|
}
|
||||||
|
if target == level {
|
||||||
|
object.properties.remove("map");
|
||||||
|
} else {
|
||||||
|
object
|
||||||
|
.properties
|
||||||
|
.insert("map".into(), relative_level_path(level, &target));
|
||||||
|
}
|
||||||
|
targets.insert(target);
|
||||||
|
}
|
||||||
|
Ok(targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a world from its main map and every map reachable through teleports.
|
||||||
|
/// `load` reads a level's map by level id.
|
||||||
|
pub fn decode_world(
|
||||||
|
main: &str,
|
||||||
|
data: &GameData,
|
||||||
|
mut load: impl FnMut(&str) -> Result<MapLayout, MapIoError>,
|
||||||
|
) -> Result<SaveGame, MapIoError> {
|
||||||
|
decode_world_named(main, main, data, &mut load)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_world_named(
|
||||||
|
main: &str,
|
||||||
|
main_file: &str,
|
||||||
|
data: &GameData,
|
||||||
|
mut load: impl FnMut(&str) -> Result<MapLayout, MapIoError>,
|
||||||
|
) -> Result<SaveGame, MapIoError> {
|
||||||
|
let mut save = decode(load(main)?, data)
|
||||||
|
.map_err(|e| MapIoError::Invalid(format!("level `{main}`: {e}")))?;
|
||||||
|
add_levels(&mut save, main, main_file, data, load)?;
|
||||||
|
Ok(save)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes the maps of levels that teleports lead to but `save` lacks.
|
||||||
|
pub fn add_referenced_levels(
|
||||||
|
save: &mut SaveGame,
|
||||||
|
main: &str,
|
||||||
|
data: &GameData,
|
||||||
|
load: impl FnMut(&str) -> Result<MapLayout, MapIoError>,
|
||||||
|
) -> Result<(), MapIoError> {
|
||||||
|
add_levels(save, main, main, data, load)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_levels(
|
||||||
|
save: &mut SaveGame,
|
||||||
|
main: &str,
|
||||||
|
main_file: &str,
|
||||||
|
data: &GameData,
|
||||||
|
mut load: impl FnMut(&str) -> Result<MapLayout, MapIoError>,
|
||||||
|
) -> Result<(), MapIoError> {
|
||||||
|
let mut queue = vec![main.to_owned()];
|
||||||
|
queue.extend(save.levels.keys().cloned());
|
||||||
|
let mut known: BTreeSet<String> = queue.iter().cloned().collect();
|
||||||
|
while let Some(id) = queue.pop() {
|
||||||
|
let layout = if id == main {
|
||||||
|
save.layout.as_mut()
|
||||||
|
} else {
|
||||||
|
save.levels.get_mut(&id)
|
||||||
|
}
|
||||||
|
.ok_or_else(|| MapIoError::Invalid("world has no terrain".into()))?;
|
||||||
|
for target in normalize_teleports(layout, &id, main, main_file)? {
|
||||||
|
if !known.insert(target.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if known.len() > MAX_LEVELS {
|
||||||
|
return Err(MapIoError::Invalid(format!(
|
||||||
|
"more than {MAX_LEVELS} levels"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let in_level = |e: MapIoError| MapIoError::Invalid(format!("level `{target}`: {e}"));
|
||||||
|
let mut level = decode(load(&target).map_err(in_level)?, data).map_err(in_level)?;
|
||||||
|
for b in &mut level.buildings {
|
||||||
|
b.level = Some(target.clone());
|
||||||
|
}
|
||||||
|
for item in &mut level.items {
|
||||||
|
item.level = Some(target.clone());
|
||||||
|
}
|
||||||
|
save.buildings.append(&mut level.buildings);
|
||||||
|
save.items.append(&mut level.items);
|
||||||
|
save.levels.insert(
|
||||||
|
target.clone(),
|
||||||
|
level.layout.expect("decoded map has terrain"),
|
||||||
|
);
|
||||||
|
queue.push(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_stack(stack: &ItemStack, data: &GameData) -> Result<(), String> {
|
fn validate_stack(stack: &ItemStack, data: &GameData) -> Result<(), String> {
|
||||||
if !data.items.contains_key(&stack.item) || stack.count == 0 {
|
if !data.items.contains_key(&stack.item) || stack.count == 0 {
|
||||||
return Err(format!("unknown item or zero count: `{}`", stack.item));
|
return Err(format!("unknown item or zero count: `{}`", stack.item));
|
||||||
|
|
@ -174,8 +367,6 @@ fn overlay<T: Serialize + DeserializeOwned>(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map file inside an exported project directory.
|
|
||||||
pub const PROJECT_MAP: &str = "world.tmx";
|
|
||||||
pub const PROJECT_FILE: &str = "station.tiled-project";
|
pub const PROJECT_FILE: &str = "station.tiled-project";
|
||||||
const PROJECT: &str = r#"{
|
const PROJECT: &str = r#"{
|
||||||
"automappingRulesFile": "",
|
"automappingRulesFile": "",
|
||||||
|
|
@ -191,7 +382,9 @@ const PROJECT_README: &str = "\
|
||||||
Station world export
|
Station world export
|
||||||
|
|
||||||
1. Open station.tiled-project in Tiled 1.10.1 or newer (Project > Open Project...).
|
1. Open station.tiled-project in Tiled 1.10.1 or newer (Project > Open Project...).
|
||||||
2. Click \"Enable Extensions\" when Tiled asks, then open world.tmx.
|
2. Click \"Enable Extensions\" when Tiled asks, then open the main map (named after
|
||||||
|
the server's map, e.g. station.tmx). Other maps are levels reached through
|
||||||
|
teleports.
|
||||||
3. Edit the map. Use the Map menu entries starting with \"Station:\" to set brush
|
3. Edit the map. Use the Map menu entries starting with \"Station:\" to set brush
|
||||||
properties, convert painted tiles to objects, or convert every map in the project.
|
properties, convert painted tiles to objects, or convert every map in the project.
|
||||||
4. Save, then run `/import <this directory>` (or `import <this directory>` on the
|
4. Save, then run `/import <this directory>` (or `import <this directory>` on the
|
||||||
|
|
@ -200,7 +393,7 @@ Station world export
|
||||||
Tileset images are copies for display; changing them does not change the game.
|
Tileset images are copies for display; changing them does not change the game.
|
||||||
";
|
";
|
||||||
|
|
||||||
/// Default export directory; import reads [`PROJECT_MAP`] inside it.
|
/// Default export directory; import reads the main map inside it.
|
||||||
pub fn default_path(world: &World) -> PathBuf {
|
pub fn default_path(world: &World) -> PathBuf {
|
||||||
world.get_resource::<Storage>().map_or_else(
|
world.get_resource::<Storage>().map_or_else(
|
||||||
|| PathBuf::from("saves/world"),
|
|| PathBuf::from("saves/world"),
|
||||||
|
|
@ -208,8 +401,9 @@ pub fn default_path(world: &World) -> PathBuf {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `.tmx` path names the map file directly; anything else is the project directory.
|
/// A `.tmx` path names the main map file directly; anything else is the project
|
||||||
fn project_paths(path: &Path) -> (PathBuf, PathBuf) {
|
/// directory, with the main map named after its level id.
|
||||||
|
fn project_paths(path: &Path, main: &str) -> (PathBuf, PathBuf) {
|
||||||
if path
|
if path
|
||||||
.extension()
|
.extension()
|
||||||
.is_some_and(|e| e.eq_ignore_ascii_case("tmx"))
|
.is_some_and(|e| e.eq_ignore_ascii_case("tmx"))
|
||||||
|
|
@ -220,15 +414,23 @@ fn project_paths(path: &Path) -> (PathBuf, PathBuf) {
|
||||||
.unwrap_or(Path::new("."));
|
.unwrap_or(Path::new("."));
|
||||||
(dir.to_path_buf(), path.to_path_buf())
|
(dir.to_path_buf(), path.to_path_buf())
|
||||||
} else {
|
} else {
|
||||||
(path.to_path_buf(), path.join(PROJECT_MAP))
|
(path.to_path_buf(), path.join(main))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn main_level(world: &World) -> Result<String, MapIoError> {
|
||||||
|
world
|
||||||
|
.get_resource::<Levels>()
|
||||||
|
.map(|levels| levels.main.clone())
|
||||||
|
.ok_or_else(|| MapIoError::Invalid("the world is not loaded".into()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Exports to a ready-to-use Tiled project and returns the path of its project file.
|
/// Exports to a ready-to-use Tiled project and returns the path of its project file.
|
||||||
pub fn export(world: &mut World, path: &Path) -> Result<PathBuf, MapIoError> {
|
pub fn export(world: &mut World, path: &Path) -> Result<PathBuf, MapIoError> {
|
||||||
|
let main = main_level(world)?;
|
||||||
let mut save = persistence::snapshot(world);
|
let mut save = persistence::snapshot(world);
|
||||||
let data = world.resource::<GameData>();
|
let data = world.resource::<GameData>();
|
||||||
if let Some(layout) = &mut save.layout {
|
for layout in save.layout.iter_mut().chain(save.levels.values_mut()) {
|
||||||
for tileset in &mut layout.tilesets {
|
for tileset in &mut layout.tilesets {
|
||||||
for tile in tileset.gameplay_tiles.values_mut() {
|
for tile in tileset.gameplay_tiles.values_mut() {
|
||||||
let proto = tile
|
let proto = tile
|
||||||
|
|
@ -247,24 +449,28 @@ pub fn export(world: &mut World, path: &Path) -> Result<PathBuf, MapIoError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
write_project(&save, path)
|
write_project(&save, &main, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes the map with the tileset images, Station extension and project file next to it.
|
/// Writes the maps of all levels with the tileset images, Station extension and
|
||||||
pub fn write_project(save: &SaveGame, path: &Path) -> Result<PathBuf, MapIoError> {
|
/// project file next to them.
|
||||||
let (dir, map) = project_paths(path);
|
pub fn write_project(save: &SaveGame, main: &str, path: &Path) -> Result<PathBuf, MapIoError> {
|
||||||
let layout = save
|
let (dir, map) = project_paths(path, main);
|
||||||
.layout
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| MapIoError::Invalid("world has no terrain".into()))?;
|
|
||||||
std::fs::create_dir_all(&dir)?;
|
std::fs::create_dir_all(&dir)?;
|
||||||
let assets = game_core::assets_dir();
|
let assets = game_core::assets_dir();
|
||||||
for ts in &layout.tilesets {
|
let images: BTreeSet<&str> = save
|
||||||
let target = dir.join(&ts.image);
|
.layout
|
||||||
|
.iter()
|
||||||
|
.chain(save.levels.values())
|
||||||
|
.flat_map(|layout| &layout.tilesets)
|
||||||
|
.map(|ts| ts.image.as_str())
|
||||||
|
.collect();
|
||||||
|
for image in images {
|
||||||
|
let target = dir.join(image);
|
||||||
if let Some(parent) = target.parent() {
|
if let Some(parent) = target.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
std::fs::copy(assets.join(&ts.image), target)?;
|
std::fs::copy(assets.join(image), target)?;
|
||||||
}
|
}
|
||||||
let extensions = dir.join("extensions");
|
let extensions = dir.join("extensions");
|
||||||
std::fs::create_dir_all(&extensions)?;
|
std::fs::create_dir_all(&extensions)?;
|
||||||
|
|
@ -280,54 +486,94 @@ pub fn write_project(save: &SaveGame, path: &Path) -> Result<PathBuf, MapIoError
|
||||||
std::fs::write(&project, PROJECT)?;
|
std::fs::write(&project, PROJECT)?;
|
||||||
}
|
}
|
||||||
std::fs::write(dir.join("README.txt"), PROJECT_README)?;
|
std::fs::write(dir.join("README.txt"), PROJECT_README)?;
|
||||||
write_map(save, &map, ImageSources::Relative)?;
|
write_levels(save, main, &map, ImageSources::Relative)?;
|
||||||
Ok(project)
|
Ok(project)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accepts a project directory (reads its [`PROJECT_MAP`]) or a map file.
|
/// Accepts a project directory (reads the main map inside it) or the main map file.
|
||||||
|
/// Levels are loaded from the maps teleports reference.
|
||||||
pub fn import(world: &mut World, path: &Path) -> Result<(), MapIoError> {
|
pub fn import(world: &mut World, path: &Path) -> Result<(), MapIoError> {
|
||||||
|
let main = main_level(world)?;
|
||||||
let path = if path.is_dir() {
|
let path = if path.is_dir() {
|
||||||
path.join(PROJECT_MAP)
|
path.join(&main)
|
||||||
} else {
|
} else {
|
||||||
path.to_path_buf()
|
path.to_path_buf()
|
||||||
};
|
};
|
||||||
let path = path.canonicalize()?;
|
let path = path.canonicalize()?;
|
||||||
let mirror = path.parent().unwrap_or(Path::new("."));
|
let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
|
||||||
let layout = MapLayout::load_with_image_mirror(
|
let main_file = path
|
||||||
&game_core::assets_dir(),
|
.file_name()
|
||||||
&path.to_string_lossy(),
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
mirror,
|
.unwrap_or_else(|| main.clone());
|
||||||
)?;
|
let assets = game_core::assets_dir();
|
||||||
let mut save = decode(layout, world.resource::<GameData>())?;
|
let mut save = decode_world_named(&main, &main_file, world.resource::<GameData>(), |id| {
|
||||||
|
let file = if id == main {
|
||||||
|
path.clone()
|
||||||
|
} else {
|
||||||
|
dir.join(id)
|
||||||
|
};
|
||||||
|
Ok(MapLayout::load_with_image_mirror(
|
||||||
|
&assets,
|
||||||
|
&file.to_string_lossy(),
|
||||||
|
&dir,
|
||||||
|
)?)
|
||||||
|
})?;
|
||||||
|
let levels = Levels::new(
|
||||||
|
main.clone(),
|
||||||
|
save.layout.clone().expect("decoded map has terrain"),
|
||||||
|
save.levels.clone(),
|
||||||
|
)
|
||||||
|
.map_err(MapIoError::Invalid)?;
|
||||||
|
|
||||||
save.characters = persistence::snapshot(world).characters;
|
save.characters = persistence::snapshot(world).characters;
|
||||||
|
for character in save.characters.values_mut() {
|
||||||
|
relocate_from_removed_level(&levels, character);
|
||||||
|
}
|
||||||
if let Some(mut storage) = world.get_resource_mut::<Storage>() {
|
if let Some(mut storage) = world.get_resource_mut::<Storage>() {
|
||||||
// Wait for any old autosave before committing the replacement.
|
// Wait for any old autosave before committing the replacement.
|
||||||
storage.save_blocking(&save)?;
|
storage.save_blocking(&save)?;
|
||||||
}
|
}
|
||||||
let layout = save.layout.take().expect("decoded map has terrain");
|
for character in world.resource_mut::<CharacterStore>().0.values_mut() {
|
||||||
let spawn = spawn_point(&layout);
|
relocate_from_removed_level(&levels, character);
|
||||||
let mut query = world.query_filtered::<Entity, Or<(With<Building>, With<GroundItem>)>>();
|
}
|
||||||
|
let mut query =
|
||||||
|
world.query_filtered::<(Entity, &Level, Option<&Controller>), With<Character>>();
|
||||||
|
let stranded: Vec<_> = query
|
||||||
|
.iter(world)
|
||||||
|
.filter(|(_, level, _)| levels.layout(level).is_none())
|
||||||
|
.map(|(entity, _, controller)| (entity, controller.map(|c| c.0)))
|
||||||
|
.collect();
|
||||||
|
let mut query =
|
||||||
|
world.query_filtered::<Entity, Or<(With<Building>, With<GroundItem>, With<WorldMap>)>>();
|
||||||
let entities: Vec<_> = query.iter(world).collect();
|
let entities: Vec<_> = query.iter(world).collect();
|
||||||
for entity in entities {
|
for entity in entities {
|
||||||
world.despawn(entity);
|
world.despawn(entity);
|
||||||
}
|
}
|
||||||
let mut maps = world.query::<&mut WorldMap>();
|
let data = world.resource::<GameData>().clone();
|
||||||
for mut map in maps.iter_mut(world) {
|
|
||||||
map.layout = layout.clone();
|
|
||||||
}
|
|
||||||
world.insert_resource(SpawnPoint(spawn));
|
|
||||||
world.insert_resource(WorldLayout(Arc::new(layout)));
|
|
||||||
let mut commands = world.commands();
|
let mut commands = world.commands();
|
||||||
for b in save.buildings {
|
for (entity, client) in stranded {
|
||||||
spawn_building_with_state(&mut commands, &b.proto, b.pos, b.state, b.machine);
|
let (level, pos) = levels.spawn();
|
||||||
}
|
move_character(&mut commands, entity, client, level, pos);
|
||||||
for item in save.items {
|
|
||||||
spawn_ground_item(&mut commands, item.stack, item.pos);
|
|
||||||
}
|
}
|
||||||
|
persistence::restore_entities(&mut commands, &data, &levels, save);
|
||||||
|
install_levels(&mut commands, levels);
|
||||||
world.flush();
|
world.flush();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Characters on levels that no longer exist wake up at the spawn point.
|
||||||
|
fn relocate_from_removed_level(levels: &Levels, character: &mut SavedCharacter) {
|
||||||
|
if levels
|
||||||
|
.layout(&levels.from_saved(character.level.as_deref()))
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
let (level, pos) = levels.spawn();
|
||||||
|
character.level = levels.to_saved(&level);
|
||||||
|
character.pos = pos;
|
||||||
|
character.position = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn spawn_point(layout: &MapLayout) -> GridPos {
|
pub fn spawn_point(layout: &MapLayout) -> GridPos {
|
||||||
layout.objects_of_class("spawn").next().map_or_else(
|
layout.objects_of_class("spawn").next().map_or_else(
|
||||||
|| GridPos::new(layout.width as i32 / 2, layout.height as i32 / 2),
|
|| GridPos::new(layout.width as i32 / 2, layout.height as i32 / 2),
|
||||||
|
|
@ -408,17 +654,85 @@ enum ImageSources {
|
||||||
Relative,
|
Relative,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes a standalone map file that references the assets directory.
|
/// Writes standalone map files that reference the assets directory: the main
|
||||||
pub fn write(save: &SaveGame, path: &Path) -> Result<PathBuf, MapIoError> {
|
/// level at `path`, the other levels next to it. Returns the main map's path.
|
||||||
write_map(save, path, ImageSources::Assets)
|
pub fn write(save: &SaveGame, main: &str, path: &Path) -> Result<PathBuf, MapIoError> {
|
||||||
|
write_levels(save, main, path, ImageSources::Assets)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_map(save: &SaveGame, path: &Path, images: ImageSources) -> Result<PathBuf, MapIoError> {
|
fn write_levels(
|
||||||
|
save: &SaveGame,
|
||||||
|
main: &str,
|
||||||
|
path: &Path,
|
||||||
|
images: ImageSources,
|
||||||
|
) -> Result<PathBuf, MapIoError> {
|
||||||
let path = path.with_extension("tmx");
|
let path = path.with_extension("tmx");
|
||||||
|
let dir = path.parent().unwrap_or(Path::new(""));
|
||||||
|
let main_file = path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| main.to_owned());
|
||||||
let layout = save
|
let layout = save
|
||||||
.layout
|
.layout
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| MapIoError::Invalid("world has no terrain".into()))?;
|
.ok_or_else(|| MapIoError::Invalid("world has no terrain".into()))?;
|
||||||
|
let file_of = |level: &str| {
|
||||||
|
if level == main {
|
||||||
|
main_file.clone()
|
||||||
|
} else {
|
||||||
|
level.to_owned()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (id, layout) in
|
||||||
|
std::iter::once((main, layout)).chain(save.levels.iter().map(|(id, l)| (id.as_str(), l)))
|
||||||
|
{
|
||||||
|
let saved = (id != main).then_some(id);
|
||||||
|
if saved.is_some() && id == main_file {
|
||||||
|
return Err(MapIoError::Invalid(format!(
|
||||||
|
"level `{id}` would overwrite the main map"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let level = LevelOut {
|
||||||
|
file: file_of(id),
|
||||||
|
layout,
|
||||||
|
buildings: save
|
||||||
|
.buildings
|
||||||
|
.iter()
|
||||||
|
.filter(|b| b.level.as_deref() == saved)
|
||||||
|
.collect(),
|
||||||
|
items: save
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|i| i.level.as_deref() == saved)
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
let target = if saved.is_some() {
|
||||||
|
dir.join(id)
|
||||||
|
} else {
|
||||||
|
path.clone()
|
||||||
|
};
|
||||||
|
write_map(&level, &file_of, &target, images)?;
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One level's map file.
|
||||||
|
struct LevelOut<'a> {
|
||||||
|
/// Path relative to the main map's directory.
|
||||||
|
file: String,
|
||||||
|
layout: &'a MapLayout,
|
||||||
|
buildings: Vec<&'a SavedBuilding>,
|
||||||
|
items: Vec<&'a SavedItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_map(
|
||||||
|
level: &LevelOut,
|
||||||
|
file_of: &dyn Fn(&str) -> String,
|
||||||
|
path: &Path,
|
||||||
|
images: ImageSources,
|
||||||
|
) -> Result<(), MapIoError> {
|
||||||
|
let layout = level.layout;
|
||||||
|
let depth = level.file.matches('/').count();
|
||||||
let assets = game_core::assets_dir().canonicalize()?;
|
let assets = game_core::assets_dir().canonicalize()?;
|
||||||
let mut xml = Writer::new_with_indent(Vec::new(), b' ', 1);
|
let mut xml = Writer::new_with_indent(Vec::new(), b' ', 1);
|
||||||
tag(
|
tag(
|
||||||
|
|
@ -474,7 +788,7 @@ fn write_map(save: &SaveGame, path: &Path, images: ImageSources) -> Result<PathB
|
||||||
ImageSources::Assets => {
|
ImageSources::Assets => {
|
||||||
assets.join(&ts.image).to_string_lossy().replace('\\', "/")
|
assets.join(&ts.image).to_string_lossy().replace('\\', "/")
|
||||||
}
|
}
|
||||||
ImageSources::Relative => ts.image.clone(),
|
ImageSources::Relative => format!("{}{}", "../".repeat(depth), ts.image),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
("width", ts.image_width.to_string()),
|
("width", ts.image_width.to_string()),
|
||||||
|
|
@ -580,7 +894,7 @@ fn write_map(save: &SaveGame, path: &Path, images: ImageSources) -> Result<PathB
|
||||||
],
|
],
|
||||||
false,
|
false,
|
||||||
)?;
|
)?;
|
||||||
for b in save.buildings.iter().filter(|b| {
|
for b in level.buildings.iter().filter(|b| {
|
||||||
if b.state.layer == game_core::data::BuildingLayer::Floor {
|
if b.state.layer == game_core::data::BuildingLayer::Floor {
|
||||||
*name == "Floors"
|
*name == "Floors"
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -596,7 +910,7 @@ fn write_map(save: &SaveGame, path: &Path, images: ImageSources) -> Result<PathB
|
||||||
id += 1;
|
id += 1;
|
||||||
}
|
}
|
||||||
if *name == "Ground Items" {
|
if *name == "Ground Items" {
|
||||||
for item in &save.items {
|
for item in &level.items {
|
||||||
let fields = BTreeMap::from([
|
let fields = BTreeMap::from([
|
||||||
("proto".into(), Value::String(item.stack.item.clone())),
|
("proto".into(), Value::String(item.stack.item.clone())),
|
||||||
("count".into(), Value::from(item.stack.count)),
|
("count".into(), Value::from(item.stack.count)),
|
||||||
|
|
@ -626,6 +940,20 @@ fn write_map(save: &SaveGame, path: &Path, images: ImageSources) -> Result<PathB
|
||||||
)?;
|
)?;
|
||||||
id += 1;
|
id += 1;
|
||||||
}
|
}
|
||||||
|
for object in layout.objects.iter().filter(|o| o.class == "teleport") {
|
||||||
|
let target = teleport_level(&level.file, object).map_err(MapIoError::Invalid)?;
|
||||||
|
let mut fields = Vec::new();
|
||||||
|
// Relative to this file, so the project stays portable.
|
||||||
|
let map = relative_level_path(&level.file, &file_of(&target));
|
||||||
|
if map != level.file.rsplit('/').next().unwrap_or_default() {
|
||||||
|
fields.push(("map", "file", map));
|
||||||
|
}
|
||||||
|
if let Some(name) = object.properties.get("target") {
|
||||||
|
fields.push(("target", "string", name.clone()));
|
||||||
|
}
|
||||||
|
write_trigger(&mut xml, id, object, layout, &fields)?;
|
||||||
|
id += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
end(&mut xml, "objectgroup")?;
|
end(&mut xml, "objectgroup")?;
|
||||||
}
|
}
|
||||||
|
|
@ -635,13 +963,59 @@ fn write_map(save: &SaveGame, path: &Path, images: ImageSources) -> Result<PathB
|
||||||
}
|
}
|
||||||
let temporary = path.with_extension(format!("{}.tmp", uuid::Uuid::new_v4()));
|
let temporary = path.with_extension(format!("{}.tmp", uuid::Uuid::new_v4()));
|
||||||
std::fs::write(&temporary, xml.into_inner())?;
|
std::fs::write(&temporary, xml.into_inner())?;
|
||||||
if let Err(error) = std::fs::rename(&temporary, &path) {
|
if let Err(error) = std::fs::rename(&temporary, path) {
|
||||||
if let Err(cleanup) = std::fs::remove_file(&temporary) {
|
if let Err(cleanup) = std::fs::remove_file(&temporary) {
|
||||||
warn!("failed to remove {}: {cleanup}", temporary.display());
|
warn!("failed to remove {}: {cleanup}", temporary.display());
|
||||||
}
|
}
|
||||||
return Err(error.into());
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
Ok(path)
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tile-sized rectangle with plain Tiled properties (`(name, type, value)`).
|
||||||
|
fn write_trigger(
|
||||||
|
xml: &mut Xml,
|
||||||
|
id: u32,
|
||||||
|
object: &MapObject,
|
||||||
|
layout: &MapLayout,
|
||||||
|
fields: &[(&str, &str, String)],
|
||||||
|
) -> Result<(), MapIoError> {
|
||||||
|
tag(
|
||||||
|
xml,
|
||||||
|
"object",
|
||||||
|
&[
|
||||||
|
("id", id.to_string()),
|
||||||
|
("type", object.class.clone()),
|
||||||
|
("name", object.name.clone()),
|
||||||
|
(
|
||||||
|
"x",
|
||||||
|
(i64::from(object.pos.x) * i64::from(layout.tile_width)).to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"y",
|
||||||
|
(i64::from(object.pos.y) * i64::from(layout.tile_height)).to_string(),
|
||||||
|
),
|
||||||
|
("width", layout.tile_width.to_string()),
|
||||||
|
("height", layout.tile_height.to_string()),
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
)?;
|
||||||
|
tag(xml, "properties", &[], false)?;
|
||||||
|
for (name, kind, value) in fields {
|
||||||
|
tag(
|
||||||
|
xml,
|
||||||
|
"property",
|
||||||
|
&[
|
||||||
|
("name", (*name).into()),
|
||||||
|
("type", (*kind).into()),
|
||||||
|
("value", value.clone()),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
end(xml, "properties")?;
|
||||||
|
end(xml, "object")?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_object(
|
fn write_object(
|
||||||
|
|
@ -681,6 +1055,8 @@ fn write_object(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
const MAIN: &str = "station.tmx";
|
||||||
|
|
||||||
fn seeded() -> (GameData, SaveGame) {
|
fn seeded() -> (GameData, SaveGame) {
|
||||||
let assets = game_core::assets_dir();
|
let assets = game_core::assets_dir();
|
||||||
let data = GameData::load_dir(&assets.join("data")).unwrap();
|
let data = GameData::load_dir(&assets.join("data")).unwrap();
|
||||||
|
|
@ -699,7 +1075,7 @@ mod tests {
|
||||||
let dir = path().with_extension("project");
|
let dir = path().with_extension("project");
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
std::fs::write(dir.join(PROJECT_FILE), "{\"custom\": true}").unwrap();
|
std::fs::write(dir.join(PROJECT_FILE), "{\"custom\": true}").unwrap();
|
||||||
let project = write_project(&save, &dir.join("custom.TMX")).unwrap();
|
let project = write_project(&save, MAIN, &dir.join("custom.TMX")).unwrap();
|
||||||
assert_eq!(project, dir.join(PROJECT_FILE));
|
assert_eq!(project, dir.join(PROJECT_FILE));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read_to_string(&project).unwrap(),
|
std::fs::read_to_string(&project).unwrap(),
|
||||||
|
|
@ -735,7 +1111,7 @@ mod tests {
|
||||||
let (data, save) = seeded();
|
let (data, save) = seeded();
|
||||||
for extension in ["", "backup", "TMX", "tmx"] {
|
for extension in ["", "backup", "TMX", "tmx"] {
|
||||||
let requested = path().with_extension(extension);
|
let requested = path().with_extension(extension);
|
||||||
let saved = write(&save, &requested).unwrap();
|
let saved = write(&save, MAIN, &requested).unwrap();
|
||||||
assert_eq!(saved, requested.with_extension("tmx"));
|
assert_eq!(saved, requested.with_extension("tmx"));
|
||||||
assert!(saved.is_file());
|
assert!(saved.is_file());
|
||||||
if requested != saved {
|
if requested != saved {
|
||||||
|
|
@ -871,7 +1247,9 @@ mod tests {
|
||||||
save.layout
|
save.layout
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.objects_of_class("spawn")
|
.objects
|
||||||
|
.iter()
|
||||||
|
.filter(|o| o.class == "spawn" || o.class == "teleport")
|
||||||
.count()
|
.count()
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|
@ -880,6 +1258,61 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn level_paths_stay_inside_the_map_directory() {
|
||||||
|
assert_eq!(
|
||||||
|
resolve_level("station.tmx", "cellar.tmx").unwrap(),
|
||||||
|
"cellar.tmx"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_level("floors/b1.tmx", "./../station.tmx").unwrap(),
|
||||||
|
"station.tmx"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_level("station.tmx", "floors\\b1.tmx").unwrap(),
|
||||||
|
"floors/b1.tmx"
|
||||||
|
);
|
||||||
|
for bad in ["../outside.tmx", "/etc/passwd", "C:/map.tmx", "."] {
|
||||||
|
assert!(resolve_level("station.tmx", bad).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
for (from, to) in [
|
||||||
|
("station.tmx", "cellar.tmx"),
|
||||||
|
("station.tmx", "floors/b1.tmx"),
|
||||||
|
("floors/b1.tmx", "station.tmx"),
|
||||||
|
("floors/b1.tmx", "floors/b2.tmx"),
|
||||||
|
("a/x.tmx", "b/y.tmx"),
|
||||||
|
] {
|
||||||
|
let relative = relative_level_path(from, to);
|
||||||
|
assert_eq!(resolve_level(from, &relative).unwrap(), to, "{relative}");
|
||||||
|
}
|
||||||
|
assert_eq!(relative_level_path("a/x.tmx", "b/y.tmx"), "../b/y.tmx");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renamed_main_level_keeps_teleports_back_to_it() {
|
||||||
|
let mut layout = fixture("", "").unwrap();
|
||||||
|
let teleport = |id, map: &str| game_core::map::MapObject {
|
||||||
|
id,
|
||||||
|
class: "teleport".into(),
|
||||||
|
name: String::new(),
|
||||||
|
pos: GridPos::new(0, 0),
|
||||||
|
properties: BTreeMap::from([
|
||||||
|
("map".into(), map.into()),
|
||||||
|
("target".into(), "stairs".into()),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
layout.objects = vec![teleport(1, "../station.tmx"), teleport(2, "b2.tmx")];
|
||||||
|
let mut save = SaveGame::default();
|
||||||
|
save.levels.insert("floors/b1.tmx".into(), layout);
|
||||||
|
rename_main_level(&mut save, "station.tmx", "outpost.tmx");
|
||||||
|
let maps: Vec<_> = save.levels["floors/b1.tmx"]
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.map(|o| o.properties["map"].as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(maps, ["../outpost.tmx", "b2.tmx"]);
|
||||||
|
}
|
||||||
|
|
||||||
fn fixture(layers: &str, properties: &str) -> Result<MapLayout, game_core::map::MapError> {
|
fn fixture(layers: &str, properties: &str) -> Result<MapLayout, game_core::map::MapError> {
|
||||||
let file = path();
|
let file = path();
|
||||||
let tileset = game_core::assets_dir().join("tilesets/station.tsx");
|
let tileset = game_core::assets_dir().join("tilesets/station.tsx");
|
||||||
|
|
@ -934,7 +1367,7 @@ mod tests {
|
||||||
assert!(blocked(GridPos::new(1, 1), &painted.buildings));
|
assert!(blocked(GridPos::new(1, 1), &painted.buildings));
|
||||||
assert!(blocked(GridPos::new(-1, 0), &painted.buildings));
|
assert!(blocked(GridPos::new(-1, 0), &painted.buildings));
|
||||||
let output = path();
|
let output = path();
|
||||||
write(&painted, &output).unwrap();
|
write(&painted, MAIN, &output).unwrap();
|
||||||
let loaded = decode(
|
let loaded = decode(
|
||||||
MapLayout::load(&game_core::assets_dir(), output.to_str().unwrap()).unwrap(),
|
MapLayout::load(&game_core::assets_dir(), output.to_str().unwrap()).unwrap(),
|
||||||
&data,
|
&data,
|
||||||
|
|
@ -1032,7 +1465,7 @@ mod tests {
|
||||||
flip_d: true,
|
flip_d: true,
|
||||||
});
|
});
|
||||||
let file = path();
|
let file = path();
|
||||||
write(&save, &file).unwrap();
|
write(&save, MAIN, &file).unwrap();
|
||||||
let loaded = decode(
|
let loaded = decode(
|
||||||
MapLayout::load(&game_core::assets_dir(), file.to_str().unwrap()).unwrap(),
|
MapLayout::load(&game_core::assets_dir(), file.to_str().unwrap()).unwrap(),
|
||||||
&data,
|
&data,
|
||||||
|
|
@ -1081,7 +1514,7 @@ mod tests {
|
||||||
cell.flip_v = true;
|
cell.flip_v = true;
|
||||||
cell.flip_d = true;
|
cell.flip_d = true;
|
||||||
let file = path();
|
let file = path();
|
||||||
write(&save, &file).unwrap();
|
write(&save, MAIN, &file).unwrap();
|
||||||
let loaded = MapLayout::load(&game_core::assets_dir(), file.to_str().unwrap()).unwrap();
|
let loaded = MapLayout::load(&game_core::assets_dir(), file.to_str().unwrap()).unwrap();
|
||||||
let mut roundtrip = decode(loaded, &data).unwrap();
|
let mut roundtrip = decode(loaded, &data).unwrap();
|
||||||
let key = |b: &SavedBuilding| (b.state.layer, b.pos.x, b.pos.y);
|
let key = |b: &SavedBuilding| (b.state.layer, b.pos.x, b.pos.y);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
//! Free character movement with collision against solid tiles and buildings.
|
//! Free character movement with collision against solid tiles and buildings.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_replicon::prelude::*;
|
use bevy_replicon::prelude::*;
|
||||||
use game_core::{components::*, grid::Dir, protocol::SetMovement};
|
use game_core::{components::*, grid::Dir, protocol::SetMovement};
|
||||||
|
|
||||||
use crate::{players::Players, world::WorldLayout};
|
use crate::{
|
||||||
|
players::{Controller, Players},
|
||||||
|
world::{Level, Levels, Occupancy, free_tile_on, move_character},
|
||||||
|
};
|
||||||
|
|
||||||
/// Walking speed in tiles per second.
|
/// Walking speed in tiles per second.
|
||||||
pub const MOVE_SPEED: f32 = 4.5;
|
pub const MOVE_SPEED: f32 = 4.5;
|
||||||
|
|
@ -64,29 +65,22 @@ fn facing_for(input: Vec2, current: Dir) -> Option<Dir> {
|
||||||
|
|
||||||
pub fn move_characters(
|
pub fn move_characters(
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
layout: Option<Res<WorldLayout>>,
|
levels: Res<Levels>,
|
||||||
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
buildings: Query<(&Level, &GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
||||||
mut characters: Query<(&MoveIntent, &mut Position), With<Character>>,
|
mut characters: Query<(&Level, &MoveIntent, &mut Position), With<Character>>,
|
||||||
) {
|
) {
|
||||||
let Some(layout) = layout else { return };
|
let occupancy = Occupancy::by_level(buildings.iter());
|
||||||
let solid: HashSet<GridPos> = buildings
|
let empty = Occupancy::default();
|
||||||
.iter()
|
|
||||||
.filter(|(_, state)| state.solid)
|
|
||||||
.map(|(pos, _)| *pos)
|
|
||||||
.collect();
|
|
||||||
let floors: HashSet<GridPos> = buildings
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, state)| state.layer == game_core::data::BuildingLayer::Floor)
|
|
||||||
.map(|(pos, _)| *pos)
|
|
||||||
.collect();
|
|
||||||
let blocked = |tile: GridPos| {
|
|
||||||
layout.0.is_solid_with_floor(tile, floors.contains(&tile)) || solid.contains(&tile)
|
|
||||||
};
|
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (intent, mut pos) in &mut characters {
|
for (level, intent, mut pos) in &mut characters {
|
||||||
if intent.0 == Vec2::ZERO {
|
if intent.0 == Vec2::ZERO {
|
||||||
continue;
|
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);
|
let next = slide(pos.0, intent.0 * MOVE_SPEED * dt, &blocked);
|
||||||
if next != pos.0 {
|
if next != pos.0 {
|
||||||
pos.0 = next;
|
pos.0 = next;
|
||||||
|
|
@ -101,6 +95,46 @@ pub fn sync_tiles(mut characters: Query<(&Position, &mut GridPos), Changed<Posit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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`.
|
/// Tiles overlapped by a character's collision box at `pos`.
|
||||||
fn box_tiles(pos: Vec2) -> impl Iterator<Item = GridPos> {
|
fn box_tiles(pos: Vec2) -> impl Iterator<Item = GridPos> {
|
||||||
let min = (pos - CHARACTER_RADIUS).floor().as_ivec2();
|
let min = (pos - CHARACTER_RADIUS).floor().as_ivec2();
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,11 @@ use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
players::{CharacterStore, PlayerId},
|
players::{CharacterStore, PlayerId},
|
||||||
world::{spawn_building_with_state, spawn_ground_item},
|
world::{Level, Levels, spawn_building_with_state, spawn_ground_item},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Bump together with a new entry in [`MIGRATIONS`].
|
/// Bump together with a new entry in [`MIGRATIONS`].
|
||||||
const SCHEMA_VERSION: i64 = 2;
|
const SCHEMA_VERSION: i64 = 3;
|
||||||
|
|
||||||
/// Schema migrations, applied in order. Index `i` upgrades from version `i` to `i + 1`.
|
/// Schema migrations, applied in order. Index `i` upgrades from version `i` to `i + 1`.
|
||||||
/// Only portable SQL (SQLite and PostgreSQL) may be used.
|
/// Only portable SQL (SQLite and PostgreSQL) may be used.
|
||||||
|
|
@ -66,9 +66,20 @@ const MIGRATIONS: &[&[&str]] = &[
|
||||||
)",
|
)",
|
||||||
],
|
],
|
||||||
&["ALTER TABLE worlds ADD COLUMN layout TEXT"],
|
&["ALTER TABLE worlds ADD COLUMN layout TEXT"],
|
||||||
|
&[
|
||||||
|
"ALTER TABLE buildings ADD COLUMN level TEXT",
|
||||||
|
"ALTER TABLE ground_items ADD COLUMN level TEXT",
|
||||||
|
"CREATE TABLE levels (
|
||||||
|
world_id TEXT NOT NULL REFERENCES worlds(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
layout TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (world_id, name)
|
||||||
|
)",
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
const DELETE_WORLD_ROWS: [&str; 3] = [
|
const DELETE_WORLD_ROWS: [&str; 4] = [
|
||||||
|
"DELETE FROM levels WHERE world_id = $1",
|
||||||
"DELETE FROM buildings WHERE world_id = $1",
|
"DELETE FROM buildings WHERE world_id = $1",
|
||||||
"DELETE FROM ground_items WHERE world_id = $1",
|
"DELETE FROM ground_items WHERE world_id = $1",
|
||||||
"DELETE FROM characters WHERE world_id = $1",
|
"DELETE FROM characters WHERE world_id = $1",
|
||||||
|
|
@ -99,6 +110,9 @@ pub struct SavedCharacter {
|
||||||
pub vitals: Vitals,
|
pub vitals: Vitals,
|
||||||
pub inventory: Inventory,
|
pub inventory: Inventory,
|
||||||
pub skills: Skills,
|
pub skills: Skills,
|
||||||
|
/// `None` is the main level.
|
||||||
|
#[serde(default)]
|
||||||
|
pub level: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
|
@ -108,18 +122,27 @@ pub struct SavedBuilding {
|
||||||
pub state: BuildingState,
|
pub state: BuildingState,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub machine: Option<Machine>,
|
pub machine: Option<Machine>,
|
||||||
|
/// `None` is the main level.
|
||||||
|
#[serde(default)]
|
||||||
|
pub level: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct SavedItem {
|
pub struct SavedItem {
|
||||||
pub stack: ItemStack,
|
pub stack: ItemStack,
|
||||||
pub pos: GridPos,
|
pub pos: GridPos,
|
||||||
|
/// `None` is the main level.
|
||||||
|
#[serde(default)]
|
||||||
|
pub level: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full state of a world.
|
/// Full state of a world.
|
||||||
#[derive(Clone, Debug, Default, PartialEq)]
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
pub struct SaveGame {
|
pub struct SaveGame {
|
||||||
|
/// Terrain of the main level.
|
||||||
pub layout: Option<MapLayout>,
|
pub layout: Option<MapLayout>,
|
||||||
|
/// Terrain of the other levels, by level id.
|
||||||
|
pub levels: BTreeMap<String, MapLayout>,
|
||||||
pub buildings: Vec<SavedBuilding>,
|
pub buildings: Vec<SavedBuilding>,
|
||||||
pub items: Vec<SavedItem>,
|
pub items: Vec<SavedItem>,
|
||||||
/// All characters (online and offline), by player id.
|
/// All characters (online and offline), by player id.
|
||||||
|
|
@ -359,8 +382,19 @@ impl Database {
|
||||||
.ok_or(StorageError::NoSuchWorld(id))?;
|
.ok_or(StorageError::NoSuchWorld(id))?;
|
||||||
let layout: Option<String> = row.try_get(0)?;
|
let layout: Option<String> = row.try_get(0)?;
|
||||||
save.layout = layout.as_deref().map(serde_json::from_str).transpose()?;
|
save.layout = layout.as_deref().map(serde_json::from_str).transpose()?;
|
||||||
let rows =
|
let rows = sqlx::query("SELECT name, layout FROM levels WHERE world_id = $1")
|
||||||
sqlx::query("SELECT proto, x, y, state, machine FROM buildings WHERE world_id = $1")
|
.bind(world.clone())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
for row in rows {
|
||||||
|
save.levels.insert(
|
||||||
|
row.try_get(0)?,
|
||||||
|
serde_json::from_str(row.try_get::<&str, _>(1)?)?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT proto, x, y, state, machine, level FROM buildings WHERE world_id = $1",
|
||||||
|
)
|
||||||
.bind(world.clone())
|
.bind(world.clone())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -371,9 +405,11 @@ impl Database {
|
||||||
pos: grid_pos(row.try_get(1)?, row.try_get(2)?),
|
pos: grid_pos(row.try_get(1)?, row.try_get(2)?),
|
||||||
state: serde_json::from_str(row.try_get::<&str, _>(3)?)?,
|
state: serde_json::from_str(row.try_get::<&str, _>(3)?)?,
|
||||||
machine: machine.as_deref().map(serde_json::from_str).transpose()?,
|
machine: machine.as_deref().map(serde_json::from_str).transpose()?,
|
||||||
|
level: row.try_get(5)?,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let rows = sqlx::query("SELECT item, count, x, y FROM ground_items WHERE world_id = $1")
|
let rows =
|
||||||
|
sqlx::query("SELECT item, count, x, y, level FROM ground_items WHERE world_id = $1")
|
||||||
.bind(world.clone())
|
.bind(world.clone())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -382,6 +418,7 @@ impl Database {
|
||||||
save.items.push(SavedItem {
|
save.items.push(SavedItem {
|
||||||
stack: ItemStack::new(row.try_get::<String, _>(0)?, count as u32),
|
stack: ItemStack::new(row.try_get::<String, _>(0)?, count as u32),
|
||||||
pos: grid_pos(row.try_get(2)?, row.try_get(3)?),
|
pos: grid_pos(row.try_get(2)?, row.try_get(3)?),
|
||||||
|
level: row.try_get(4)?,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let rows = sqlx::query("SELECT player_id, data FROM characters WHERE world_id = $1")
|
let rows = sqlx::query("SELECT player_id, data FROM characters WHERE world_id = $1")
|
||||||
|
|
@ -423,10 +460,22 @@ impl Database {
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
let levels: Vec<_> = save.levels.iter().collect();
|
||||||
|
for chunk in levels.chunks(BATCH) {
|
||||||
|
let sql = insert_sql("levels (world_id, name, layout)", 3, chunk.len());
|
||||||
|
let mut query = sqlx::query(AssertSqlSafe(sql));
|
||||||
|
for (name, layout) in chunk {
|
||||||
|
query = query
|
||||||
|
.bind(world.clone())
|
||||||
|
.bind((*name).clone())
|
||||||
|
.bind(serde_json::to_string(layout)?);
|
||||||
|
}
|
||||||
|
query.execute(&mut *tx).await?;
|
||||||
|
}
|
||||||
for chunk in save.buildings.chunks(BATCH) {
|
for chunk in save.buildings.chunks(BATCH) {
|
||||||
let sql = insert_sql(
|
let sql = insert_sql(
|
||||||
"buildings (world_id, proto, x, y, state, machine)",
|
"buildings (world_id, proto, x, y, state, machine, level)",
|
||||||
6,
|
7,
|
||||||
chunk.len(),
|
chunk.len(),
|
||||||
);
|
);
|
||||||
let mut query = sqlx::query(AssertSqlSafe(sql));
|
let mut query = sqlx::query(AssertSqlSafe(sql));
|
||||||
|
|
@ -437,12 +486,17 @@ impl Database {
|
||||||
.bind(i64::from(b.pos.x))
|
.bind(i64::from(b.pos.x))
|
||||||
.bind(i64::from(b.pos.y))
|
.bind(i64::from(b.pos.y))
|
||||||
.bind(serde_json::to_string(&b.state)?)
|
.bind(serde_json::to_string(&b.state)?)
|
||||||
.bind(b.machine.as_ref().map(serde_json::to_string).transpose()?);
|
.bind(b.machine.as_ref().map(serde_json::to_string).transpose()?)
|
||||||
|
.bind(b.level.clone());
|
||||||
}
|
}
|
||||||
query.execute(&mut *tx).await?;
|
query.execute(&mut *tx).await?;
|
||||||
}
|
}
|
||||||
for chunk in save.items.chunks(BATCH) {
|
for chunk in save.items.chunks(BATCH) {
|
||||||
let sql = insert_sql("ground_items (world_id, item, count, x, y)", 5, chunk.len());
|
let sql = insert_sql(
|
||||||
|
"ground_items (world_id, item, count, x, y, level)",
|
||||||
|
6,
|
||||||
|
chunk.len(),
|
||||||
|
);
|
||||||
let mut query = sqlx::query(AssertSqlSafe(sql));
|
let mut query = sqlx::query(AssertSqlSafe(sql));
|
||||||
for item in chunk {
|
for item in chunk {
|
||||||
query = query
|
query = query
|
||||||
|
|
@ -450,7 +504,8 @@ impl Database {
|
||||||
.bind(item.stack.item.clone())
|
.bind(item.stack.item.clone())
|
||||||
.bind(i64::from(item.stack.count))
|
.bind(i64::from(item.stack.count))
|
||||||
.bind(i64::from(item.pos.x))
|
.bind(i64::from(item.pos.x))
|
||||||
.bind(i64::from(item.pos.y));
|
.bind(i64::from(item.pos.y))
|
||||||
|
.bind(item.level.clone());
|
||||||
}
|
}
|
||||||
query.execute(&mut *tx).await?;
|
query.execute(&mut *tx).await?;
|
||||||
}
|
}
|
||||||
|
|
@ -622,18 +677,38 @@ impl Drop for Storage {
|
||||||
#[derive(Resource)]
|
#[derive(Resource)]
|
||||||
pub struct AutosaveTimer(pub Timer);
|
pub struct AutosaveTimer(pub Timer);
|
||||||
|
|
||||||
pub fn restore(commands: &mut Commands, data: &GameData, save: SaveGame) {
|
/// Spawns the saved characters' store and the entities of all levels.
|
||||||
|
pub fn restore(commands: &mut Commands, data: &GameData, levels: &Levels, save: SaveGame) {
|
||||||
|
let characters = restore_entities(commands, data, levels, save);
|
||||||
|
commands.insert_resource(CharacterStore(characters));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns saved buildings and ground items; returns the saved characters.
|
||||||
|
pub fn restore_entities(
|
||||||
|
commands: &mut Commands,
|
||||||
|
data: &GameData,
|
||||||
|
levels: &Levels,
|
||||||
|
save: SaveGame,
|
||||||
|
) -> BTreeMap<String, SavedCharacter> {
|
||||||
|
let level_of = |saved: Option<&str>| {
|
||||||
|
let level = levels.from_saved(saved);
|
||||||
|
levels.layout(&level).is_some().then_some(level)
|
||||||
|
};
|
||||||
for b in save.buildings {
|
for b in save.buildings {
|
||||||
|
let Some(level) = level_of(b.level.as_deref()) else {
|
||||||
|
warn!("dropping saved building on removed level {:?}", b.level);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if data.buildings.contains_key(&b.proto) {
|
if data.buildings.contains_key(&b.proto) {
|
||||||
// Machine definitions may have changed since the save was written.
|
// Machine definitions may have changed since the save was written.
|
||||||
let machine = data.machines.get(&b.proto).map(|def| {
|
let machine = data.machines.get(&b.proto).map(|def| {
|
||||||
let mut machine = b.machine.unwrap_or_else(|| Machine::new(def));
|
let mut machine = b.machine.unwrap_or_else(|| Machine::new(def));
|
||||||
for stack in machine.fit_to(def) {
|
for stack in machine.fit_to(def) {
|
||||||
spawn_ground_item(commands, stack, b.pos);
|
spawn_ground_item(commands, stack, &level, b.pos);
|
||||||
}
|
}
|
||||||
machine
|
machine
|
||||||
});
|
});
|
||||||
spawn_building_with_state(commands, &b.proto, b.pos, b.state, machine);
|
spawn_building_with_state(commands, &b.proto, &level, b.pos, b.state, machine);
|
||||||
} else {
|
} else {
|
||||||
warn!(
|
warn!(
|
||||||
"dropping saved building with removed prototype `{}`",
|
"dropping saved building with removed prototype `{}`",
|
||||||
|
|
@ -642,8 +717,12 @@ pub fn restore(commands: &mut Commands, data: &GameData, save: SaveGame) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for item in save.items {
|
for item in save.items {
|
||||||
|
let Some(level) = level_of(item.level.as_deref()) else {
|
||||||
|
warn!("dropping saved item on removed level {:?}", item.level);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if data.items.contains_key(&item.stack.item) {
|
if data.items.contains_key(&item.stack.item) {
|
||||||
spawn_ground_item(commands, item.stack, item.pos);
|
spawn_ground_item(commands, item.stack, &level, item.pos);
|
||||||
} else {
|
} else {
|
||||||
warn!(
|
warn!(
|
||||||
"dropping saved item with removed prototype `{}`",
|
"dropping saved item with removed prototype `{}`",
|
||||||
|
|
@ -651,11 +730,16 @@ pub fn restore(commands: &mut Commands, data: &GameData, save: SaveGame) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
commands.insert_resource(CharacterStore(save.characters));
|
save.characters
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collects the full world state.
|
/// Collects the full world state.
|
||||||
pub fn snapshot(world: &mut World) -> SaveGame {
|
pub fn snapshot(world: &mut World) -> SaveGame {
|
||||||
|
let levels = world.get_resource::<Levels>().cloned();
|
||||||
|
let saved_level = |level: Option<&Level>| match (&levels, level) {
|
||||||
|
(Some(levels), Some(level)) => levels.to_saved(level),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
let mut characters = world.resource::<CharacterStore>().0.clone();
|
let mut characters = world.resource::<CharacterStore>().0.clone();
|
||||||
let mut q = world.query::<(
|
let mut q = world.query::<(
|
||||||
&Character,
|
&Character,
|
||||||
|
|
@ -666,8 +750,9 @@ pub fn snapshot(world: &mut World) -> SaveGame {
|
||||||
&Vitals,
|
&Vitals,
|
||||||
&Inventory,
|
&Inventory,
|
||||||
&Skills,
|
&Skills,
|
||||||
|
Option<&Level>,
|
||||||
)>();
|
)>();
|
||||||
for (c, id, pos, position, facing, vitals, inventory, skills) in q.iter(world) {
|
for (c, id, pos, position, facing, vitals, inventory, skills, level) in q.iter(world) {
|
||||||
characters.insert(
|
characters.insert(
|
||||||
id.0.clone(),
|
id.0.clone(),
|
||||||
SavedCharacter {
|
SavedCharacter {
|
||||||
|
|
@ -678,31 +763,50 @@ pub fn snapshot(world: &mut World) -> SaveGame {
|
||||||
vitals: vitals.clone(),
|
vitals: vitals.clone(),
|
||||||
inventory: inventory.clone(),
|
inventory: inventory.clone(),
|
||||||
skills: skills.clone(),
|
skills: skills.clone(),
|
||||||
|
level: saved_level(level),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let mut q = world.query::<(&Building, &GridPos, &BuildingState, Option<&Machine>)>();
|
let mut q = world.query::<(
|
||||||
|
&Building,
|
||||||
|
&GridPos,
|
||||||
|
&BuildingState,
|
||||||
|
Option<&Machine>,
|
||||||
|
Option<&Level>,
|
||||||
|
)>();
|
||||||
let buildings = q
|
let buildings = q
|
||||||
.iter(world)
|
.iter(world)
|
||||||
.map(|(b, pos, state, machine)| SavedBuilding {
|
.map(|(b, pos, state, machine, level)| SavedBuilding {
|
||||||
proto: b.proto.clone(),
|
proto: b.proto.clone(),
|
||||||
pos: *pos,
|
pos: *pos,
|
||||||
state: state.clone(),
|
state: state.clone(),
|
||||||
machine: machine.cloned(),
|
machine: machine.cloned(),
|
||||||
|
level: saved_level(level),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let mut q = world.query::<(&GroundItem, &GridPos)>();
|
let mut q = world.query::<(&GroundItem, &GridPos, Option<&Level>)>();
|
||||||
let items = q
|
let items = q
|
||||||
.iter(world)
|
.iter(world)
|
||||||
.map(|(item, pos)| SavedItem {
|
.map(|(item, pos, level)| SavedItem {
|
||||||
stack: item.0.clone(),
|
stack: item.0.clone(),
|
||||||
pos: *pos,
|
pos: *pos,
|
||||||
|
level: saved_level(level),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
let (layout, levels) = match levels {
|
||||||
|
Some(levels) => {
|
||||||
|
let mut layouts: BTreeMap<_, _> = levels
|
||||||
|
.layouts
|
||||||
|
.iter()
|
||||||
|
.map(|(id, layout)| (id.clone(), (**layout).clone()))
|
||||||
|
.collect();
|
||||||
|
(layouts.remove(&levels.main), layouts)
|
||||||
|
}
|
||||||
|
None => (None, BTreeMap::new()),
|
||||||
|
};
|
||||||
SaveGame {
|
SaveGame {
|
||||||
layout: world
|
layout,
|
||||||
.get_resource::<crate::world::WorldLayout>()
|
levels,
|
||||||
.map(|l| (*l.0).clone()),
|
|
||||||
buildings,
|
buildings,
|
||||||
items,
|
items,
|
||||||
characters,
|
characters,
|
||||||
|
|
@ -763,6 +867,10 @@ mod tests {
|
||||||
),
|
),
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
save.levels.insert(
|
||||||
|
"cellar.tmx".into(),
|
||||||
|
MapLayout::load(&game_core::assets_dir(), "maps/cellar.tmx").unwrap(),
|
||||||
|
);
|
||||||
let mut inventory = Inventory::default();
|
let mut inventory = Inventory::default();
|
||||||
inventory.slots.push(ItemStack::new("apple", 3));
|
inventory.slots.push(ItemStack::new("apple", 3));
|
||||||
let mut skills = Skills::default();
|
let mut skills = Skills::default();
|
||||||
|
|
@ -780,6 +888,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
inventory,
|
inventory,
|
||||||
skills,
|
skills,
|
||||||
|
level: Some("cellar.tmx".into()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
save.buildings.push(SavedBuilding {
|
save.buildings.push(SavedBuilding {
|
||||||
|
|
@ -793,6 +902,7 @@ mod tests {
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
machine: None,
|
machine: None,
|
||||||
|
level: None,
|
||||||
});
|
});
|
||||||
save.buildings.push(SavedBuilding {
|
save.buildings.push(SavedBuilding {
|
||||||
proto: "floor".into(),
|
proto: "floor".into(),
|
||||||
|
|
@ -804,6 +914,7 @@ mod tests {
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
machine: None,
|
machine: None,
|
||||||
|
level: None,
|
||||||
});
|
});
|
||||||
save.buildings.push(SavedBuilding {
|
save.buildings.push(SavedBuilding {
|
||||||
proto: "furnace".into(),
|
proto: "furnace".into(),
|
||||||
|
|
@ -815,12 +926,14 @@ mod tests {
|
||||||
recipe: Some("smelt_iron".into()),
|
recipe: Some("smelt_iron".into()),
|
||||||
progress: 1.5,
|
progress: 1.5,
|
||||||
}),
|
}),
|
||||||
|
level: Some("cellar.tmx".into()),
|
||||||
});
|
});
|
||||||
// More rows than one insert batch.
|
// More rows than one insert batch.
|
||||||
for i in 0..250 {
|
for i in 0..250 {
|
||||||
save.items.push(SavedItem {
|
save.items.push(SavedItem {
|
||||||
stack: ItemStack::new("log", 2),
|
stack: ItemStack::new("log", 2),
|
||||||
pos: GridPos::new(i, 5),
|
pos: GridPos::new(i, 5),
|
||||||
|
level: (i % 2 == 0).then(|| "cellar.tmx".into()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
save
|
save
|
||||||
|
|
@ -929,10 +1042,14 @@ mod tests {
|
||||||
let rt = runtime();
|
let rt = runtime();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let db = Database::connect("sqlite::memory:").await.unwrap();
|
let db = Database::connect("sqlite::memory:").await.unwrap();
|
||||||
sqlx::query("ALTER TABLE worlds DROP COLUMN layout")
|
for undo in [
|
||||||
.execute(&db.pool)
|
"ALTER TABLE worlds DROP COLUMN layout",
|
||||||
.await
|
"ALTER TABLE buildings DROP COLUMN level",
|
||||||
.unwrap();
|
"ALTER TABLE ground_items DROP COLUMN level",
|
||||||
|
"DROP TABLE levels",
|
||||||
|
] {
|
||||||
|
sqlx::query(undo).execute(&db.pool).await.unwrap();
|
||||||
|
}
|
||||||
sqlx::query("UPDATE station_schema SET version = 1")
|
sqlx::query("UPDATE station_schema SET version = 1")
|
||||||
.execute(&db.pool)
|
.execute(&db.pool)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ use crate::{
|
||||||
config::ServerConfig,
|
config::ServerConfig,
|
||||||
movement::MoveIntent,
|
movement::MoveIntent,
|
||||||
persistence::SavedCharacter,
|
persistence::SavedCharacter,
|
||||||
world::{SpawnPoint, WorldLayout, find_free_tile},
|
world::{Level, Levels, ViewLevel, free_tile_on},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Which client controls a character. Server-only.
|
/// Which client controls a character. Server-only.
|
||||||
|
|
@ -242,20 +242,20 @@ fn handle_login(world: &mut World, client: ClientId, login: Login) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let levels = world.resource::<Levels>().clone();
|
||||||
let saved = match world.resource_mut::<CharacterStore>().0.remove(&player_id) {
|
let saved = match world.resource_mut::<CharacterStore>().0.remove(&player_id) {
|
||||||
Some(saved) => saved,
|
Some(saved) => saved,
|
||||||
None => {
|
None => new_character_data(world.resource::<GameData>(), &name, levels.spawn().1),
|
||||||
let spawn = world.resource::<SpawnPoint>().0;
|
|
||||||
new_character_data(world.resource::<GameData>(), &name, spawn)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let mut buildings =
|
let (level, start) = match levels.from_saved(saved.level.as_deref()) {
|
||||||
world.query_filtered::<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>();
|
level if levels.layout(&level).is_some() => (level, saved.pos),
|
||||||
let pos = find_free_tile(
|
// The level was removed from the world.
|
||||||
saved.pos,
|
_ => levels.spawn(),
|
||||||
&world.resource::<WorldLayout>().0,
|
};
|
||||||
&buildings.query(world),
|
let mut buildings = world
|
||||||
|
.query_filtered::<(&Level, &GridPos, &BuildingState), (With<Building>, Without<Character>)>(
|
||||||
);
|
);
|
||||||
|
let pos = free_tile_on(&level, start, &levels, &buildings.query(world));
|
||||||
// The saved spot is only kept if the tile is still free.
|
// The saved spot is only kept if the tile is still free.
|
||||||
let position = saved
|
let position = saved
|
||||||
.position
|
.position
|
||||||
|
|
@ -276,11 +276,15 @@ fn handle_login(world: &mut World, client: ClientId, login: Login) {
|
||||||
Controller(client),
|
Controller(client),
|
||||||
PlayerId(player_id.clone()),
|
PlayerId(player_id.clone()),
|
||||||
MoveIntent::default(),
|
MoveIntent::default(),
|
||||||
|
level.clone(),
|
||||||
));
|
));
|
||||||
if admin {
|
if admin {
|
||||||
entity.insert(Admin);
|
entity.insert(Admin);
|
||||||
}
|
}
|
||||||
let entity = entity.id();
|
let entity = entity.id();
|
||||||
|
if let ClientId::Client(client) = client {
|
||||||
|
world.entity_mut(client).insert(ViewLevel(level.0));
|
||||||
|
}
|
||||||
world
|
world
|
||||||
.resource_mut::<Players>()
|
.resource_mut::<Players>()
|
||||||
.by_client
|
.by_client
|
||||||
|
|
@ -435,6 +439,7 @@ pub fn new_character_data(data: &GameData, name: &str, spawn: GridPos) -> SavedC
|
||||||
vitals: Vitals::default(),
|
vitals: Vitals::default(),
|
||||||
inventory,
|
inventory,
|
||||||
skills: Skills::default(),
|
skills: Skills::default(),
|
||||||
|
level: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,8 +449,10 @@ pub fn on_disconnect(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut players: ResMut<Players>,
|
mut players: ResMut<Players>,
|
||||||
mut store: ResMut<CharacterStore>,
|
mut store: ResMut<CharacterStore>,
|
||||||
|
levels: Option<Res<Levels>>,
|
||||||
characters: Query<(
|
characters: Query<(
|
||||||
&Character,
|
&Character,
|
||||||
|
&Level,
|
||||||
&PlayerId,
|
&PlayerId,
|
||||||
&GridPos,
|
&GridPos,
|
||||||
&Position,
|
&Position,
|
||||||
|
|
@ -458,7 +465,9 @@ pub fn on_disconnect(
|
||||||
let Some(entity) = players.by_client.remove(&ClientId::Client(remove.entity)) else {
|
let Some(entity) = players.by_client.remove(&ClientId::Client(remove.entity)) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Ok((c, id, pos, position, facing, vitals, inventory, skills)) = characters.get(entity) {
|
if let Ok((c, level, id, pos, position, facing, vitals, inventory, skills)) =
|
||||||
|
characters.get(entity)
|
||||||
|
{
|
||||||
info!("{} logged out", c.name);
|
info!("{} logged out", c.name);
|
||||||
store.0.insert(
|
store.0.insert(
|
||||||
id.0.clone(),
|
id.0.clone(),
|
||||||
|
|
@ -470,6 +479,7 @@ pub fn on_disconnect(
|
||||||
vitals: vitals.clone(),
|
vitals: vitals.clone(),
|
||||||
inventory: inventory.clone(),
|
inventory: inventory.clone(),
|
||||||
skills: skills.clone(),
|
skills: skills.clone(),
|
||||||
|
level: levels.as_ref().and_then(|l| l.to_saved(level)),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use game_core::components::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
players::{Controller, tell},
|
players::{Controller, tell},
|
||||||
world::{SpawnPoint, spawn_ground_item},
|
world::{Level, Levels, move_character, spawn_ground_item},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Nutrition lost per second (a full stomach lasts about 25 minutes).
|
/// Nutrition lost per second (a full stomach lasts about 25 minutes).
|
||||||
|
|
@ -26,12 +26,13 @@ pub fn tick_survival(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
mut timer: ResMut<SurvivalTimer>,
|
mut timer: ResMut<SurvivalTimer>,
|
||||||
spawn: Option<Res<SpawnPoint>>,
|
levels: Res<Levels>,
|
||||||
mut characters: Query<(
|
mut characters: Query<(
|
||||||
|
Entity,
|
||||||
&Character,
|
&Character,
|
||||||
&Controller,
|
&Controller,
|
||||||
|
&Level,
|
||||||
&GridPos,
|
&GridPos,
|
||||||
&mut Position,
|
|
||||||
&mut Vitals,
|
&mut Vitals,
|
||||||
&mut Inventory,
|
&mut Inventory,
|
||||||
)>,
|
)>,
|
||||||
|
|
@ -40,7 +41,7 @@ pub fn tick_survival(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let dt = timer.0.duration().as_secs_f32();
|
let dt = timer.0.duration().as_secs_f32();
|
||||||
for (character, controller, pos, mut position, mut vitals, mut inventory) in &mut characters {
|
for (entity, character, controller, level, pos, mut vitals, mut inventory) in &mut characters {
|
||||||
vitals.nutrition = (vitals.nutrition - HUNGER_RATE * dt).max(0.0);
|
vitals.nutrition = (vitals.nutrition - HUNGER_RATE * dt).max(0.0);
|
||||||
if vitals.nutrition <= 0.0 {
|
if vitals.nutrition <= 0.0 {
|
||||||
vitals.heal(-STARVATION_DAMAGE * dt);
|
vitals.heal(-STARVATION_DAMAGE * dt);
|
||||||
|
|
@ -54,11 +55,16 @@ pub fn tick_survival(
|
||||||
// Death: drop everything where you fell and wake up at the spawn point.
|
// Death: drop everything where you fell and wake up at the spawn point.
|
||||||
info!("{} died", character.name);
|
info!("{} died", character.name);
|
||||||
for stack in std::mem::take(&mut inventory.slots) {
|
for stack in std::mem::take(&mut inventory.slots) {
|
||||||
spawn_ground_item(&mut commands, stack, *pos);
|
spawn_ground_item(&mut commands, stack, level, *pos);
|
||||||
}
|
|
||||||
if let Some(spawn) = &spawn {
|
|
||||||
*position = Position::center_of(spawn.0);
|
|
||||||
}
|
}
|
||||||
|
let (spawn_level, spawn) = levels.spawn();
|
||||||
|
move_character(
|
||||||
|
&mut commands,
|
||||||
|
entity,
|
||||||
|
Some(controller.0),
|
||||||
|
spawn_level,
|
||||||
|
spawn,
|
||||||
|
);
|
||||||
*vitals = Vitals {
|
*vitals = Vitals {
|
||||||
nutrition: 50.0,
|
nutrition: 50.0,
|
||||||
..default()
|
..default()
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,141 @@
|
||||||
//! World setup: Tiled map, spawning buildings and items, and tile occupancy.
|
//! World setup: Tiled maps (levels), spawning buildings and items, and tile occupancy.
|
||||||
|
|
||||||
use std::{collections::BTreeMap, sync::Arc};
|
use std::{
|
||||||
|
collections::{BTreeMap, HashMap, HashSet},
|
||||||
|
path::Path,
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_replicon::prelude::*;
|
use bevy_replicon::prelude::*;
|
||||||
use game_core::{
|
use game_core::{
|
||||||
GameData,
|
GameData,
|
||||||
components::{
|
components::{
|
||||||
Building, BuildingState, Character, GridPos, GroundItem, ItemStack, Machine, WorldMap,
|
Building, BuildingState, Character, GridPos, GroundItem, ItemStack, Machine, Position,
|
||||||
|
WorldMap,
|
||||||
},
|
},
|
||||||
data::BuildingLayer,
|
data::BuildingLayer,
|
||||||
map::MapLayout,
|
map::MapLayout,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{ServerSettings, map_io, persistence};
|
use crate::{ServerSettings, map_io, movement::LastTile, persistence};
|
||||||
|
|
||||||
/// The loaded map, used for collision and spawn points.
|
pub use game_core::components::{Level, ViewLevel};
|
||||||
#[derive(Resource, Clone)]
|
|
||||||
pub struct WorldLayout(pub Arc<MapLayout>);
|
|
||||||
|
|
||||||
#[derive(Resource, Clone, Copy)]
|
/// Where a teleport trigger sends characters.
|
||||||
pub struct SpawnPoint(pub GridPos);
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Destination {
|
||||||
|
pub level: String,
|
||||||
|
pub pos: GridPos,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All levels of the world. Every level is simulated, whether players are on it or not.
|
||||||
|
#[derive(Resource, Clone, Debug)]
|
||||||
|
pub struct Levels {
|
||||||
|
/// Id of the level new characters spawn on.
|
||||||
|
pub main: String,
|
||||||
|
pub layouts: BTreeMap<String, Arc<MapLayout>>,
|
||||||
|
pub teleports: HashMap<(String, GridPos), Destination>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Levels {
|
||||||
|
/// Validates every teleport trigger.
|
||||||
|
pub fn new(
|
||||||
|
main: String,
|
||||||
|
main_layout: MapLayout,
|
||||||
|
others: BTreeMap<String, MapLayout>,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let mut layouts: BTreeMap<String, Arc<MapLayout>> = others
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, layout)| (id, Arc::new(layout)))
|
||||||
|
.collect();
|
||||||
|
layouts.insert(main.clone(), Arc::new(main_layout));
|
||||||
|
let mut teleports = HashMap::new();
|
||||||
|
for (id, layout) in &layouts {
|
||||||
|
for object in layout.objects_of_class("teleport") {
|
||||||
|
let invalid = |message: String| {
|
||||||
|
format!(
|
||||||
|
"level `{id}`, teleport {} (`{}`): {message}",
|
||||||
|
object.id, object.name
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let level = map_io::teleport_level(id, object).map_err(invalid)?;
|
||||||
|
let target_layout = layouts
|
||||||
|
.get(&level)
|
||||||
|
.ok_or_else(|| invalid(format!("unknown map `{level}`")))?;
|
||||||
|
let target = object
|
||||||
|
.properties
|
||||||
|
.get("target")
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.ok_or_else(|| invalid("missing `target`".into()))?;
|
||||||
|
let pos = target_layout
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.find(|o| o.name == *target)
|
||||||
|
.ok_or_else(|| invalid(format!("no object named `{target}` in `{level}`")))?
|
||||||
|
.pos;
|
||||||
|
teleports.insert((id.clone(), object.pos), Destination { level, pos });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
main,
|
||||||
|
layouts,
|
||||||
|
teleports,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn layout(&self, level: &Level) -> Option<&MapLayout> {
|
||||||
|
self.layouts.get(&level.0).map(Arc::as_ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn main_level(&self) -> Level {
|
||||||
|
Level(self.main.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn main_layout(&self) -> &MapLayout {
|
||||||
|
&self.layouts[&self.main]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where new and respawning characters appear.
|
||||||
|
pub fn spawn(&self) -> (Level, GridPos) {
|
||||||
|
(self.main_level(), map_io::spawn_point(self.main_layout()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves store `None` for the main level, so it can be renamed.
|
||||||
|
pub fn from_saved(&self, saved: Option<&str>) -> Level {
|
||||||
|
Level(saved.unwrap_or(&self.main).to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_saved(&self, level: &Level) -> Option<String> {
|
||||||
|
(level.0 != self.main).then(|| level.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Level id of a map path: its file name.
|
||||||
|
pub fn main_level_id(map: &str) -> String {
|
||||||
|
Path::new(map)
|
||||||
|
.file_name()
|
||||||
|
.map_or_else(|| map.to_owned(), |n| n.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path of a level's map, given the main map's path.
|
||||||
|
pub fn level_map_path(main_map: &str, level: &str) -> String {
|
||||||
|
Path::new(main_map)
|
||||||
|
.parent()
|
||||||
|
.unwrap_or(Path::new(""))
|
||||||
|
.join(level)
|
||||||
|
.to_string_lossy()
|
||||||
|
.replace('\\', "/")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) {
|
pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) {
|
||||||
let seed = || {
|
let assets = game_core::assets_dir();
|
||||||
MapLayout::load(&game_core::assets_dir(), &settings.map)
|
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)
|
.map_err(map_io::MapIoError::from)
|
||||||
.and_then(|layout| map_io::decode(layout, &data))
|
|
||||||
};
|
};
|
||||||
|
let seed = || map_io::decode_world(&main, &data, load);
|
||||||
let mut save = None;
|
let mut save = None;
|
||||||
if let Some(storage) = &settings.storage {
|
if let Some(storage) = &settings.storage {
|
||||||
let (storage, loaded) = match persistence::Storage::open(storage, &settings.map, || {
|
let (storage, loaded) = match persistence::Storage::open(storage, &settings.map, || {
|
||||||
|
|
@ -42,53 +150,89 @@ pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data:
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
info!("world `{}` ({})", storage.world.name, storage.world.id);
|
info!("world `{}` ({})", storage.world.name, storage.world.id);
|
||||||
|
let mut loaded = loaded;
|
||||||
if storage.world.map != settings.map {
|
if storage.world.map != settings.map {
|
||||||
warn!(
|
warn!(
|
||||||
"world was created on map `{}`, now running `{}`",
|
"world was created on map `{}`, now running `{}`",
|
||||||
storage.world.map, settings.map
|
storage.world.map, settings.map
|
||||||
);
|
);
|
||||||
|
map_io::rename_main_level(&mut loaded, &main_level_id(&storage.world.map), &main);
|
||||||
}
|
}
|
||||||
save = Some(loaded);
|
save = Some(loaded);
|
||||||
commands.insert_resource(storage);
|
commands.insert_resource(storage);
|
||||||
}
|
}
|
||||||
let (layout, save) = match save {
|
let mut save = match save {
|
||||||
Some(mut save) => {
|
Some(mut save) => {
|
||||||
info!(
|
info!(
|
||||||
"restoring persistent world: {} buildings, {} items, {} characters",
|
"restoring persistent world: {} levels, {} buildings, {} items, {} characters",
|
||||||
|
save.levels.len() + 1,
|
||||||
save.buildings.len(),
|
save.buildings.len(),
|
||||||
save.items.len(),
|
save.items.len(),
|
||||||
save.characters.len()
|
save.characters.len()
|
||||||
);
|
);
|
||||||
let layout = match save.layout.take() {
|
if save.layout.is_none() {
|
||||||
Some(layout) => layout,
|
match MapLayout::load_legacy_terrain(&assets, &settings.map) {
|
||||||
None => {
|
Ok(layout) => save.layout = Some(layout),
|
||||||
match MapLayout::load_legacy_terrain(&game_core::assets_dir(), &settings.map) {
|
|
||||||
Ok(layout) => layout,
|
|
||||||
Err(e) => return crate::fail(&mut commands, e.to_string()),
|
Err(e) => return crate::fail(&mut commands, e.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
// Levels referenced since the world was saved start from their map.
|
||||||
(layout, save)
|
if let Err(e) = map_io::add_referenced_levels(&mut save, &main, &data, load) {
|
||||||
|
return crate::fail(&mut commands, e.to_string());
|
||||||
|
}
|
||||||
|
save
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
info!("creating a new world from `{}`", settings.map);
|
info!("creating a new world from `{}`", settings.map);
|
||||||
let mut save = match seed() {
|
match seed() {
|
||||||
Ok(save) => save,
|
Ok(save) => save,
|
||||||
Err(e) => return crate::fail(&mut commands, e.to_string()),
|
Err(e) => return crate::fail(&mut commands, e.to_string()),
|
||||||
};
|
}
|
||||||
(save.layout.take().expect("decoded map has terrain"), save)
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
commands.insert_resource(SpawnPoint(map_io::spawn_point(&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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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((
|
commands.spawn((
|
||||||
Replicated,
|
Replicated,
|
||||||
|
Level(id.clone()),
|
||||||
WorldMap {
|
WorldMap {
|
||||||
path: settings.map.clone(),
|
path: id.clone(),
|
||||||
layout: layout.clone(),
|
layout: (**layout).clone(),
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
persistence::restore(&mut commands, &data, save);
|
}
|
||||||
commands.insert_resource(WorldLayout(Arc::new(layout)));
|
commands.insert_resource(levels);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves a character to a tile, possibly on another level, and makes its
|
||||||
|
/// client follow. Teleport triggers on the arrival tile only fire after the
|
||||||
|
/// character stepped off it.
|
||||||
|
pub fn move_character(
|
||||||
|
commands: &mut Commands,
|
||||||
|
entity: Entity,
|
||||||
|
client: Option<ClientId>,
|
||||||
|
level: Level,
|
||||||
|
tile: GridPos,
|
||||||
|
) {
|
||||||
|
if let Some(ClientId::Client(client)) = client {
|
||||||
|
commands.entity(client).insert(ViewLevel(level.0.clone()));
|
||||||
|
}
|
||||||
|
commands.entity(entity).insert((
|
||||||
|
LastTile(level.clone(), tile),
|
||||||
|
level,
|
||||||
|
Position::center_of(tile),
|
||||||
|
tile,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns a building with its default state. `vars` override the prototype's variables.
|
/// Spawns a building with its default state. `vars` override the prototype's variables.
|
||||||
|
|
@ -96,6 +240,7 @@ pub fn spawn_building(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
data: &GameData,
|
data: &GameData,
|
||||||
proto: &str,
|
proto: &str,
|
||||||
|
level: &Level,
|
||||||
pos: GridPos,
|
pos: GridPos,
|
||||||
vars: BTreeMap<String, i64>,
|
vars: BTreeMap<String, i64>,
|
||||||
) -> Option<Entity> {
|
) -> Option<Entity> {
|
||||||
|
|
@ -104,13 +249,14 @@ pub fn spawn_building(
|
||||||
state.vars.extend(vars);
|
state.vars.extend(vars);
|
||||||
let machine = data.machines.get(proto).map(Machine::new);
|
let machine = data.machines.get(proto).map(Machine::new);
|
||||||
Some(spawn_building_with_state(
|
Some(spawn_building_with_state(
|
||||||
commands, proto, pos, state, machine,
|
commands, proto, level, pos, state, machine,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn_building_with_state(
|
pub fn spawn_building_with_state(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
proto: &str,
|
proto: &str,
|
||||||
|
level: &Level,
|
||||||
pos: GridPos,
|
pos: GridPos,
|
||||||
state: BuildingState,
|
state: BuildingState,
|
||||||
machine: Option<Machine>,
|
machine: Option<Machine>,
|
||||||
|
|
@ -120,6 +266,7 @@ pub fn spawn_building_with_state(
|
||||||
Building {
|
Building {
|
||||||
proto: proto.to_owned(),
|
proto: proto.to_owned(),
|
||||||
},
|
},
|
||||||
|
level.clone(),
|
||||||
pos,
|
pos,
|
||||||
state,
|
state,
|
||||||
));
|
));
|
||||||
|
|
@ -129,8 +276,49 @@ pub fn spawn_building_with_state(
|
||||||
entity.id()
|
entity.id()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spawn_ground_item(commands: &mut Commands, stack: ItemStack, pos: GridPos) -> Entity {
|
pub fn spawn_ground_item(
|
||||||
commands.spawn((Replicated, GroundItem(stack), pos)).id()
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a character can not enter `pos`.
|
/// Whether a character can not enter `pos`.
|
||||||
|
|
@ -151,12 +339,8 @@ pub fn is_blocked<'a>(
|
||||||
layout.is_solid_with_floor(pos, has_floor)
|
layout.is_solid_with_floor(pos, has_floor)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finds the closest free tile around `start` (spiral search), for spawning characters.
|
/// Finds the closest free tile around `start` (spiral search), for placing characters.
|
||||||
pub fn find_free_tile(
|
pub fn find_free_tile(start: GridPos, blocked: impl Fn(GridPos) -> bool) -> GridPos {
|
||||||
start: GridPos,
|
|
||||||
layout: &MapLayout,
|
|
||||||
buildings: &Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
|
||||||
) -> GridPos {
|
|
||||||
for radius in 0..16i32 {
|
for radius in 0..16i32 {
|
||||||
for dy in -radius..=radius {
|
for dy in -radius..=radius {
|
||||||
for dx in -radius..=radius {
|
for dx in -radius..=radius {
|
||||||
|
|
@ -164,7 +348,7 @@ pub fn find_free_tile(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let pos = GridPos::new(start.x + dx, start.y + dy);
|
let pos = GridPos::new(start.x + dx, start.y + dy);
|
||||||
if !is_blocked(pos, layout, buildings.iter()) {
|
if !blocked(pos) {
|
||||||
return pos;
|
return pos;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -172,3 +356,20 @@ pub fn find_free_tile(
|
||||||
}
|
}
|
||||||
start
|
start
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Free tile near `start` on `level`, for placing characters.
|
||||||
|
pub fn free_tile_on(
|
||||||
|
level: &Level,
|
||||||
|
start: GridPos,
|
||||||
|
levels: &Levels,
|
||||||
|
buildings: &Query<(&Level, &GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
||||||
|
) -> GridPos {
|
||||||
|
let Some(layout) = levels.layout(level) else {
|
||||||
|
return start;
|
||||||
|
};
|
||||||
|
let mut occupancy = Occupancy::default();
|
||||||
|
for (_, pos, state) in buildings.iter().filter(|(l, _, _)| *l == level) {
|
||||||
|
occupancy.add(*pos, state);
|
||||||
|
}
|
||||||
|
find_free_tile(start, |pos| occupancy.is_blocked(pos, layout))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,28 @@ fn login(name: &str) -> Login {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Level id of [`game_core::DEFAULT_MAP`].
|
||||||
|
const MAIN: &str = "station.tmx";
|
||||||
|
|
||||||
|
fn main_level() -> game_server::world::Level {
|
||||||
|
game_server::world::Level(MAIN.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes an exported project with all its levels.
|
||||||
|
fn decode_project(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
data: &game_core::GameData,
|
||||||
|
) -> game_server::persistence::SaveGame {
|
||||||
|
game_server::map_io::decode_world(MAIN, data, |id| {
|
||||||
|
Ok(game_core::map::MapLayout::load_with_image_mirror(
|
||||||
|
&game_core::assets_dir(),
|
||||||
|
dir.join(id).to_str().unwrap(),
|
||||||
|
dir,
|
||||||
|
)?)
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn take_log(app: &mut App) -> Vec<String> {
|
fn take_log(app: &mut App) -> Vec<String> {
|
||||||
// Server events also go through a local resend before the client observer sees them.
|
// Server events also go through a local resend before the client observer sees them.
|
||||||
app.update();
|
app.update();
|
||||||
|
|
@ -62,7 +84,7 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
|
||||||
use game_server::{
|
use game_server::{
|
||||||
commands::{self, CommandSource},
|
commands::{self, CommandSource},
|
||||||
map_io, persistence,
|
map_io, persistence,
|
||||||
world::WorldLayout,
|
world::{Level, Levels},
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
|
|
@ -74,7 +96,7 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
|
||||||
.single(app.world())
|
.single(app.world())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let dir = std::env::temp_dir().join(format!("station-import-{}", uuid::Uuid::new_v4()));
|
let dir = std::env::temp_dir().join(format!("station-import-{}", uuid::Uuid::new_v4()));
|
||||||
let file = dir.join(map_io::PROJECT_MAP);
|
let file = dir.join(MAIN);
|
||||||
let filename = file.to_str().unwrap();
|
let filename = file.to_str().unwrap();
|
||||||
let ordinary_player = CommandSource::Player {
|
let ordinary_player = CommandSource::Player {
|
||||||
client: ClientId::Server,
|
client: ClientId::Server,
|
||||||
|
|
@ -128,12 +150,12 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
|
||||||
let mut edited = map_io::decode(map, app.world().resource::<game_core::GameData>()).unwrap();
|
let mut edited = map_io::decode(map, app.world().resource::<game_core::GameData>()).unwrap();
|
||||||
edited.items.truncate(1);
|
edited.items.truncate(1);
|
||||||
edited.buildings.retain(|b| b.proto != "tree");
|
edited.buildings.retain(|b| b.proto != "tree");
|
||||||
map_io::write(&edited, &file).unwrap();
|
map_io::write(&edited, MAIN, &file).unwrap();
|
||||||
edited = map_io::decode(
|
edited = decode_project(&dir, app.world().resource::<game_core::GameData>());
|
||||||
game_core::map::MapLayout::load(&game_core::assets_dir(), filename).unwrap(),
|
assert!(
|
||||||
app.world().resource::<game_core::GameData>(),
|
edited.levels.contains_key("cellar.tmx"),
|
||||||
)
|
"levels next to the main map are imported"
|
||||||
.unwrap();
|
);
|
||||||
let reply = commands::execute(
|
let reply = commands::execute(
|
||||||
app.world_mut(),
|
app.world_mut(),
|
||||||
CommandSource::Console,
|
CommandSource::Console,
|
||||||
|
|
@ -143,26 +165,45 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
|
||||||
let imported = persistence::snapshot(app.world_mut());
|
let imported = persistence::snapshot(app.world_mut());
|
||||||
assert!(
|
assert!(
|
||||||
app.world()
|
app.world()
|
||||||
.resource::<WorldLayout>()
|
.resource::<Levels>()
|
||||||
.0
|
.main_layout()
|
||||||
.is_solid(GridPos::new(0, 0))
|
.is_solid(GridPos::new(0, 0))
|
||||||
);
|
);
|
||||||
assert_eq!(imported.buildings, edited.buildings);
|
let key = |b: &persistence::SavedBuilding| (b.level.clone(), b.state.layer, b.pos.x, b.pos.y);
|
||||||
assert_eq!(imported.items, edited.items);
|
let sorted = |mut buildings: Vec<persistence::SavedBuilding>| {
|
||||||
|
buildings.sort_by_key(key);
|
||||||
|
buildings
|
||||||
|
};
|
||||||
|
let sorted_items = |mut items: Vec<persistence::SavedItem>| {
|
||||||
|
items.sort_by_key(|i| (i.level.clone(), i.pos.x, i.pos.y, i.stack.item.clone()));
|
||||||
|
items
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
sorted(imported.buildings.clone()),
|
||||||
|
sorted(edited.buildings.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sorted_items(imported.items.clone()),
|
||||||
|
sorted_items(edited.items.clone())
|
||||||
|
);
|
||||||
|
assert_eq!(imported.levels, edited.levels);
|
||||||
assert_eq!(imported.characters, expected.characters);
|
assert_eq!(imported.characters, expected.characters);
|
||||||
assert!(
|
assert!(
|
||||||
app.world().get::<Character>(character).is_some(),
|
app.world().get::<Character>(character).is_some(),
|
||||||
"online entity must survive"
|
"online entity must survive"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.world().resource::<WorldLayout>().0.as_ref(),
|
app.world().resource::<Levels>().main_layout(),
|
||||||
edited.layout.as_ref().unwrap()
|
|
||||||
);
|
|
||||||
let mut maps = app.world_mut().query::<&WorldMap>();
|
|
||||||
assert_eq!(
|
|
||||||
&maps.single(app.world()).unwrap().layout,
|
|
||||||
edited.layout.as_ref().unwrap()
|
edited.layout.as_ref().unwrap()
|
||||||
);
|
);
|
||||||
|
let mut maps = app.world_mut().query::<(&WorldMap, &Level)>();
|
||||||
|
let maps: Vec<_> = maps.iter(app.world()).collect();
|
||||||
|
assert_eq!(maps.len(), 2, "one terrain entity per level");
|
||||||
|
let (main_map, _) = maps
|
||||||
|
.iter()
|
||||||
|
.find(|(_, level)| **level == main_level())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(&main_map.layout, edited.layout.as_ref().unwrap());
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&file,
|
&file,
|
||||||
original.replace(
|
original.replace(
|
||||||
|
|
@ -186,7 +227,7 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
|
||||||
persistence::Storage::open(&settings, game_core::DEFAULT_MAP, || Ok(imported.clone()))
|
persistence::Storage::open(&settings, game_core::DEFAULT_MAP, || Ok(imported.clone()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
app.world_mut().insert_resource(storage);
|
app.world_mut().insert_resource(storage);
|
||||||
map_io::write(&expected, &file).unwrap();
|
map_io::write(&expected, MAIN, &file).unwrap();
|
||||||
map_io::import(app.world_mut(), &file).unwrap();
|
map_io::import(app.world_mut(), &file).unwrap();
|
||||||
let storage = app.world().resource::<persistence::Storage>();
|
let storage = app.world().resource::<persistence::Storage>();
|
||||||
let mut persisted = storage
|
let mut persisted = storage
|
||||||
|
|
@ -194,11 +235,21 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
|
||||||
.block_on(storage.db.load_world(storage.world.id))
|
.block_on(storage.db.load_world(storage.world.id))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
expected.characters = imported.characters;
|
expected.characters = imported.characters;
|
||||||
let key = |b: &persistence::SavedBuilding| (b.state.layer, b.pos.x, b.pos.y);
|
|
||||||
persisted.buildings.sort_by_key(key);
|
persisted.buildings.sort_by_key(key);
|
||||||
expected.buildings.sort_by_key(key);
|
expected.buildings.sort_by_key(key);
|
||||||
assert_eq!(persisted.buildings, expected.buildings);
|
assert_eq!(persisted.buildings, expected.buildings);
|
||||||
assert_eq!(persisted.items, expected.items);
|
assert_eq!(sorted_items(persisted.items), sorted_items(expected.items));
|
||||||
|
let without_ids = |levels: &std::collections::BTreeMap<String, game_core::map::MapLayout>| {
|
||||||
|
let mut levels = levels.clone();
|
||||||
|
for layout in levels.values_mut() {
|
||||||
|
layout.objects.iter_mut().for_each(|o| o.id = 0);
|
||||||
|
}
|
||||||
|
levels
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
without_ids(&persisted.levels),
|
||||||
|
without_ids(&expected.levels)
|
||||||
|
);
|
||||||
assert_eq!(persisted.characters, expected.characters);
|
assert_eq!(persisted.characters, expected.characters);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
persisted.layout.as_ref().unwrap().layers,
|
persisted.layout.as_ref().unwrap().layers,
|
||||||
|
|
@ -207,7 +258,7 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
|
||||||
let before_failure = persistence::snapshot(app.world_mut());
|
let before_failure = persistence::snapshot(app.world_mut());
|
||||||
let storage = app.world().resource::<persistence::Storage>();
|
let storage = app.world().resource::<persistence::Storage>();
|
||||||
storage.runtime.block_on(storage.db.close());
|
storage.runtime.block_on(storage.db.close());
|
||||||
map_io::write(&edited, &file).unwrap();
|
map_io::write(&edited, MAIN, &file).unwrap();
|
||||||
assert!(map_io::import(app.world_mut(), &file).is_err());
|
assert!(map_io::import(app.world_mut(), &file).is_err());
|
||||||
assert_eq!(persistence::snapshot(app.world_mut()), before_failure);
|
assert_eq!(persistence::snapshot(app.world_mut()), before_failure);
|
||||||
std::fs::remove_dir_all(dir).unwrap();
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
|
|
@ -222,7 +273,7 @@ fn floors_and_structures_build_and_deconstruct_independently_over_default_terrai
|
||||||
use game_core::data::BuildingLayer;
|
use game_core::data::BuildingLayer;
|
||||||
use game_server::{
|
use game_server::{
|
||||||
map_io, persistence,
|
map_io, persistence,
|
||||||
world::{self, WorldLayout},
|
world::{self, Level, Levels},
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
|
|
@ -243,8 +294,9 @@ fn floors_and_structures_build_and_deconstruct_independently_over_default_terrai
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.id = 4;
|
.id = 4;
|
||||||
let file = std::env::temp_dir().join(format!("station-floors-{}.tmx", uuid::Uuid::new_v4()));
|
let dir = std::env::temp_dir().join(format!("station-floors-{}", uuid::Uuid::new_v4()));
|
||||||
map_io::write(&save, &file).unwrap();
|
let file = dir.join("edited.tmx");
|
||||||
|
map_io::write(&save, MAIN, &file).unwrap();
|
||||||
map_io::import(app.world_mut(), &file).unwrap();
|
map_io::import(app.world_mut(), &file).unwrap();
|
||||||
|
|
||||||
let data = app.world().resource::<game_core::GameData>().clone();
|
let data = app.world().resource::<game_core::GameData>().clone();
|
||||||
|
|
@ -255,19 +307,25 @@ fn floors_and_structures_build_and_deconstruct_independently_over_default_terrai
|
||||||
inventory.add("wrench", 1, &data);
|
inventory.add("wrench", 1, &data);
|
||||||
}
|
}
|
||||||
let blocked = |app: &mut App| {
|
let blocked = |app: &mut App| {
|
||||||
let mut q = app.world_mut().query::<(&GridPos, &BuildingState)>();
|
let mut q = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<(&GridPos, &BuildingState, &Level)>();
|
||||||
world::is_blocked(
|
world::is_blocked(
|
||||||
target,
|
target,
|
||||||
&app.world().resource::<WorldLayout>().0,
|
app.world().resource::<Levels>().main_layout(),
|
||||||
q.iter(app.world()),
|
q.iter(app.world())
|
||||||
|
.filter(|(_, _, l)| **l == main_level())
|
||||||
|
.map(|(p, s, _)| (p, s)),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let layers = |app: &mut App| {
|
let layers = |app: &mut App| {
|
||||||
let mut q = app.world_mut().query::<(&GridPos, &BuildingState)>();
|
let mut q = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<(&GridPos, &BuildingState, &Level)>();
|
||||||
let mut layers: Vec<_> = q
|
let mut layers: Vec<_> = q
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.filter(|(p, _)| **p == target)
|
.filter(|(p, _, l)| **p == target && **l == main_level())
|
||||||
.map(|(_, state)| state.layer)
|
.map(|(_, state, _)| state.layer)
|
||||||
.collect();
|
.collect();
|
||||||
layers.sort();
|
layers.sort();
|
||||||
layers
|
layers
|
||||||
|
|
@ -326,7 +384,7 @@ fn floors_and_structures_build_and_deconstruct_independently_over_default_terrai
|
||||||
send(&mut app, SetMovement { x: 0, y: 0 });
|
send(&mut app, SetMovement { x: 0, y: 0 });
|
||||||
assert!(app.world().get::<Position>(me).unwrap().0.x < target.x as f32);
|
assert!(app.world().get::<Position>(me).unwrap().0.x < target.x as f32);
|
||||||
|
|
||||||
let project = file.with_extension("project");
|
let project = dir.join("project");
|
||||||
let project_file = map_io::export(app.world_mut(), &project).unwrap();
|
let project_file = map_io::export(app.world_mut(), &project).unwrap();
|
||||||
assert_eq!(project_file, project.join(map_io::PROJECT_FILE));
|
assert_eq!(project_file, project.join(map_io::PROJECT_FILE));
|
||||||
let settings: serde_json::Value =
|
let settings: serde_json::Value =
|
||||||
|
|
@ -334,12 +392,17 @@ fn floors_and_structures_build_and_deconstruct_independently_over_default_terrai
|
||||||
assert_eq!(settings["extensionsPath"], "extensions");
|
assert_eq!(settings["extensionsPath"], "extensions");
|
||||||
assert!(project.join("extensions/station.js").is_file());
|
assert!(project.join("extensions/station.js").is_file());
|
||||||
assert!(project.join("extensions/brush.svg").is_file());
|
assert!(project.join("extensions/brush.svg").is_file());
|
||||||
let map = std::fs::read_to_string(project.join(map_io::PROJECT_MAP)).unwrap();
|
let map = std::fs::read_to_string(project.join(MAIN)).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
map.contains(r#"source="tilesets/"#),
|
map.contains(r#"source="tilesets/"#),
|
||||||
"images must be project-relative"
|
"images must be project-relative"
|
||||||
);
|
);
|
||||||
let tilesets = app.world().resource::<WorldLayout>().0.tilesets.clone();
|
let tilesets = app
|
||||||
|
.world()
|
||||||
|
.resource::<Levels>()
|
||||||
|
.main_layout()
|
||||||
|
.tilesets
|
||||||
|
.clone();
|
||||||
for tileset in &tilesets {
|
for tileset in &tilesets {
|
||||||
assert!(project.join(&tileset.image).is_file());
|
assert!(project.join(&tileset.image).is_file());
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +411,7 @@ fn floors_and_structures_build_and_deconstruct_independently_over_default_terrai
|
||||||
tilesets.iter().map(|t| t.image.clone()).collect::<Vec<_>>()
|
tilesets.iter().map(|t| t.image.clone()).collect::<Vec<_>>()
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
images(&app.world().resource::<WorldLayout>().0.tilesets),
|
images(&app.world().resource::<Levels>().main_layout().tilesets),
|
||||||
images(&tilesets),
|
images(&tilesets),
|
||||||
"copied images must resolve back to the shipped assets"
|
"copied images must resolve back to the shipped assets"
|
||||||
);
|
);
|
||||||
|
|
@ -371,8 +434,7 @@ fn floors_and_structures_build_and_deconstruct_independently_over_default_terrai
|
||||||
}
|
}
|
||||||
send(&mut app, SetMovement { x: 0, y: 0 });
|
send(&mut app, SetMovement { x: 0, y: 0 });
|
||||||
assert!(app.world().get::<Position>(me).unwrap().0.x > target.x as f32 + 0.3);
|
assert!(app.world().get::<Position>(me).unwrap().0.x > target.x as f32 + 0.3);
|
||||||
std::fs::remove_file(file).unwrap();
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
std::fs::remove_dir_all(project).unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -387,10 +449,10 @@ fn painted_walls_are_deconstructible_without_leaving_static_collision() {
|
||||||
let target = GridPos::new(0, 0);
|
let target = GridPos::new(0, 0);
|
||||||
let wall = app
|
let wall = app
|
||||||
.world_mut()
|
.world_mut()
|
||||||
.query::<(Entity, &Building, &GridPos)>()
|
.query::<(Entity, &Building, &GridPos, &game_server::world::Level)>()
|
||||||
.iter(app.world())
|
.iter(app.world())
|
||||||
.find(|(_, b, p)| b.proto == "wall" && **p == target)
|
.find(|(_, b, p, l)| b.proto == "wall" && **p == target && **l == main_level())
|
||||||
.map(|(entity, _, _)| entity)
|
.map(|(entity, _, _, _)| entity)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(app.world().get::<BuildingState>(wall).unwrap().health, 300);
|
assert_eq!(app.world().get::<BuildingState>(wall).unwrap().health, 300);
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
|
|
@ -403,11 +465,18 @@ fn painted_walls_are_deconstructible_without_leaving_static_collision() {
|
||||||
.add("wrench", 1, &data);
|
.add("wrench", 1, &data);
|
||||||
send(&mut app, Deconstruct { target });
|
send(&mut app, Deconstruct { target });
|
||||||
assert!(app.world().get_entity(wall).is_err());
|
assert!(app.world().get_entity(wall).is_err());
|
||||||
let mut buildings = app.world_mut().query::<(&GridPos, &BuildingState)>();
|
let mut buildings = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<(&GridPos, &BuildingState, &game_server::world::Level)>();
|
||||||
assert!(!game_server::world::is_blocked(
|
assert!(!game_server::world::is_blocked(
|
||||||
target,
|
target,
|
||||||
&app.world().resource::<game_server::world::WorldLayout>().0,
|
app.world()
|
||||||
buildings.iter(app.world())
|
.resource::<game_server::world::Levels>()
|
||||||
|
.main_layout(),
|
||||||
|
buildings
|
||||||
|
.iter(app.world())
|
||||||
|
.filter(|(_, _, l)| **l == main_level())
|
||||||
|
.map(|(p, s, _)| (p, s))
|
||||||
));
|
));
|
||||||
assert_eq!(count(&app, me, "iron_plate"), 2);
|
assert_eq!(count(&app, me, "iron_plate"), 2);
|
||||||
}
|
}
|
||||||
|
|
@ -521,6 +590,7 @@ fn login_move_and_interact() {
|
||||||
proto: "door".into(),
|
proto: "door".into(),
|
||||||
},
|
},
|
||||||
door_pos,
|
door_pos,
|
||||||
|
main_level(),
|
||||||
BuildingState {
|
BuildingState {
|
||||||
solid: true,
|
solid: true,
|
||||||
..default()
|
..default()
|
||||||
|
|
@ -572,6 +642,128 @@ fn login_move_and_interact() {
|
||||||
assert!(log.iter().any(|t| t.contains("workbench")), "{log:?}");
|
assert!(log.iter().any(|t| t.contains("workbench")), "{log:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Places the character in the middle of `pos` on its current level.
|
||||||
|
fn place(app: &mut App, me: Entity, pos: GridPos) {
|
||||||
|
let mut entity = app.world_mut().entity_mut(me);
|
||||||
|
entity.insert((Position::center_of(pos), pos));
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walks one step in `dx` until the tile or level changes.
|
||||||
|
fn step(app: &mut App, me: Entity, dx: i8) {
|
||||||
|
use game_server::world::Level;
|
||||||
|
let before = (
|
||||||
|
*app.world().get::<GridPos>(me).unwrap(),
|
||||||
|
app.world().get::<Level>(me).unwrap().clone(),
|
||||||
|
);
|
||||||
|
send(app, SetMovement { x: dx, y: 0 });
|
||||||
|
for _ in 0..20 {
|
||||||
|
let now = (
|
||||||
|
*app.world().get::<GridPos>(me).unwrap(),
|
||||||
|
app.world().get::<Level>(me).unwrap().clone(),
|
||||||
|
);
|
||||||
|
if now != before {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
send(app, SetMovement::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stairs_teleport_between_levels_that_all_keep_running() {
|
||||||
|
use game_server::world::Level;
|
||||||
|
let cellar = Level("cellar.tmx".into());
|
||||||
|
let stairs_down = GridPos::new(16, 12);
|
||||||
|
let stairs_up = GridPos::new(2, 2);
|
||||||
|
let cellar_furnace = GridPos::new(11, 2);
|
||||||
|
|
||||||
|
let mut app = app();
|
||||||
|
let mut q = app.world_mut().query::<(&Building, &GridPos, &Level)>();
|
||||||
|
let placed: Vec<_> = q
|
||||||
|
.iter(app.world())
|
||||||
|
.map(|(b, p, l)| (b.proto.clone(), *p, l.clone()))
|
||||||
|
.collect();
|
||||||
|
assert!(placed.contains(&("stairs_down".into(), stairs_down, main_level())));
|
||||||
|
assert!(placed.contains(&("stairs_up".into(), stairs_up, cellar.clone())));
|
||||||
|
assert!(placed.contains(&("furnace".into(), cellar_furnace, cellar.clone())));
|
||||||
|
|
||||||
|
send(&mut app, login("Climber"));
|
||||||
|
let me = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<Character>>()
|
||||||
|
.single(app.world())
|
||||||
|
.unwrap();
|
||||||
|
place(&mut app, me, GridPos::new(15, 12));
|
||||||
|
step(&mut app, me, 1);
|
||||||
|
assert_eq!(app.world().get::<Level>(me), Some(&cellar));
|
||||||
|
assert_eq!(app.world().get::<GridPos>(me), Some(&stairs_up));
|
||||||
|
// Arriving on the up stairs doesn't bounce straight back.
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
assert_eq!(app.world().get::<Level>(me), Some(&cellar));
|
||||||
|
|
||||||
|
// Interactions only reach the character's own level.
|
||||||
|
let data = app.world().resource::<game_core::GameData>().clone();
|
||||||
|
let beside = GridPos::new(3, 2);
|
||||||
|
app.world_mut().spawn((
|
||||||
|
GroundItem(ItemStack::new("medkit", 1)),
|
||||||
|
main_level(),
|
||||||
|
beside,
|
||||||
|
));
|
||||||
|
app.world_mut()
|
||||||
|
.get_mut::<Inventory>(me)
|
||||||
|
.unwrap()
|
||||||
|
.slots
|
||||||
|
.clear();
|
||||||
|
send(&mut app, PickUp { target: beside });
|
||||||
|
assert_eq!(count(&app, me, "medkit"), 0);
|
||||||
|
|
||||||
|
// Fuel the cellar furnace, then leave it running unattended.
|
||||||
|
place(&mut app, me, GridPos::new(10, 2));
|
||||||
|
{
|
||||||
|
let mut inventory = app.world_mut().get_mut::<Inventory>(me).unwrap();
|
||||||
|
inventory.add("iron_ore", 4, &data);
|
||||||
|
inventory.add("log", 1, &data);
|
||||||
|
}
|
||||||
|
for item in ["iron_ore", "log"] {
|
||||||
|
let slot = app
|
||||||
|
.world()
|
||||||
|
.get::<Inventory>(me)
|
||||||
|
.unwrap()
|
||||||
|
.slots
|
||||||
|
.iter()
|
||||||
|
.position(|s| s.item == item)
|
||||||
|
.unwrap();
|
||||||
|
send(
|
||||||
|
&mut app,
|
||||||
|
MachineInsert {
|
||||||
|
target: cellar_furnace,
|
||||||
|
inventory_slot: slot,
|
||||||
|
machine_slot: None,
|
||||||
|
count: u32::MAX,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(count(&app, me, "iron_ore"), 0, "{:?}", take_log(&mut app));
|
||||||
|
|
||||||
|
place(&mut app, me, GridPos::new(3, 2));
|
||||||
|
step(&mut app, me, -1);
|
||||||
|
assert_eq!(app.world().get::<Level>(me), Some(&main_level()));
|
||||||
|
assert_eq!(app.world().get::<GridPos>(me), Some(&stairs_down));
|
||||||
|
|
||||||
|
for _ in 0..90 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
let mut furnaces = app.world_mut().query::<(&Machine, &GridPos, &Level)>();
|
||||||
|
let (machine, _, _) = furnaces
|
||||||
|
.iter(app.world())
|
||||||
|
.find(|(_, p, l)| **p == cellar_furnace && **l == cellar)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(machine.outputs[0], Some(ItemStack::new("iron_plate", 2)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn machines_process_items() {
|
fn machines_process_items() {
|
||||||
let mut app = app();
|
let mut app = app();
|
||||||
|
|
@ -803,8 +995,21 @@ mod network {
|
||||||
join(&mut host, HOST, "Host", "");
|
join(&mut host, HOST, "Host", "");
|
||||||
let mut guest = client(addr);
|
let mut guest = client(addr);
|
||||||
join(&mut guest, GUEST, "Guest", "");
|
join(&mut guest, GUEST, "Guest", "");
|
||||||
|
// Only the level the character is on replicates.
|
||||||
|
pump(&mut [&mut guest, &mut host], |app| {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&Character>()
|
||||||
|
.iter(app.world())
|
||||||
|
.any(|c| c.name == "Guest")
|
||||||
|
});
|
||||||
|
let mut levels = guest.world_mut().query::<&game_server::world::Level>();
|
||||||
|
let levels: std::collections::BTreeSet<_> =
|
||||||
|
levels.iter(guest.world()).map(|l| l.0.clone()).collect();
|
||||||
|
assert_eq!(levels, [super::MAIN.to_owned()].into());
|
||||||
|
let mut maps = guest.world_mut().query::<&WorldMap>();
|
||||||
|
assert_eq!(maps.iter(guest.world()).count(), 1);
|
||||||
let dir = std::env::temp_dir().join(format!("station-network-{}", uuid::Uuid::new_v4()));
|
let dir = std::env::temp_dir().join(format!("station-network-{}", uuid::Uuid::new_v4()));
|
||||||
let file = dir.join(map_io::PROJECT_MAP);
|
let file = dir.join(super::MAIN);
|
||||||
host.world_mut().client_trigger(Chat {
|
host.world_mut().client_trigger(Chat {
|
||||||
text: format!("/export {}", dir.display()),
|
text: format!("/export {}", dir.display()),
|
||||||
});
|
});
|
||||||
|
|
@ -828,7 +1033,7 @@ mod network {
|
||||||
flip_d: false,
|
flip_d: false,
|
||||||
});
|
});
|
||||||
let edited = map_io::decode(map, &data).unwrap();
|
let edited = map_io::decode(map, &data).unwrap();
|
||||||
map_io::write(&edited, &file).unwrap();
|
map_io::write(&edited, super::MAIN, &file).unwrap();
|
||||||
host.world_mut().client_trigger(Chat {
|
host.world_mut().client_trigger(Chat {
|
||||||
text: format!("/import {}", dir.display()),
|
text: format!("/import {}", dir.display()),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,22 @@ def gen_sprites():
|
||||||
lambda d: d.ellipse([10, 14, 15, 19], fill=(180, 110, 90)),
|
lambda d: d.ellipse([10, 14, 15, 19], fill=(180, 110, 90)),
|
||||||
lambda d: d.ellipse([18, 18, 23, 23], fill=(180, 110, 90)))
|
lambda d: d.ellipse([18, 18, 23, 23], fill=(180, 110, 90)))
|
||||||
|
|
||||||
|
def stairs(down):
|
||||||
|
def f(d):
|
||||||
|
d.rectangle([0, 0, T - 1, T - 1], fill=(60, 55, 50), outline=(30, 28, 25), width=2)
|
||||||
|
for i in range(4):
|
||||||
|
# Steps shrink and darken towards the far end of the flight.
|
||||||
|
inset = 3 + i * 3 if down else 12 - i * 3
|
||||||
|
shade = 150 - i * 25 if down else 75 + i * 25
|
||||||
|
y = 3 + i * 7
|
||||||
|
d.rectangle([inset, y, T - 1 - inset, y + 5], fill=(shade, shade - 10, shade - 25))
|
||||||
|
tip, base = (27, 20) if down else (5, 12)
|
||||||
|
d.polygon([(13, base), (19, base), (16, tip)], fill=(230, 200, 80))
|
||||||
|
return f
|
||||||
|
|
||||||
|
sprite("sprites/buildings/stairs_down.png", stairs(True))
|
||||||
|
sprite("sprites/buildings/stairs_up.png", stairs(False))
|
||||||
|
|
||||||
# Characters
|
# Characters
|
||||||
sprite("sprites/characters/player.png",
|
sprite("sprites/characters/player.png",
|
||||||
lambda d: d.ellipse([10, 3, 22, 14], fill=(240, 200, 160), outline=(60, 40, 30)),
|
lambda d: d.ellipse([10, 3, 22, 14], fill=(240, 200, 160), outline=(60, 40, 30)),
|
||||||
|
|
@ -282,26 +298,8 @@ def gen_map():
|
||||||
for y in range(1, 26):
|
for y in range(1, 26):
|
||||||
ground[y][37] = DIRT
|
ground[y][37] = DIRT
|
||||||
|
|
||||||
def csv(layer):
|
objects, triggers = MapObjects(), MapObjects()
|
||||||
return ",\n".join(",".join("0" if t is None else str(t + 1) for t in row) for row in layer)
|
obj = objects.point
|
||||||
|
|
||||||
objects = []
|
|
||||||
next_id = [1]
|
|
||||||
|
|
||||||
def obj(cls, x, y, name="", **props):
|
|
||||||
oid = next_id[0]
|
|
||||||
next_id[0] += 1
|
|
||||||
prop_xml = ""
|
|
||||||
if props:
|
|
||||||
items = []
|
|
||||||
for k, v in props.items():
|
|
||||||
typ = ' type="int"' if isinstance(v, int) else ""
|
|
||||||
items.append(f' <property name="{k}"{typ} value="{v}"/>')
|
|
||||||
prop_xml = "\n <properties>\n" + "\n".join(items) + "\n </properties>\n "
|
|
||||||
objects.append(
|
|
||||||
f' <object id="{oid}" name="{name}" class="{cls}" x="{x * T + T // 2}" y="{y * T + T // 2}">{prop_xml}<point/>\n </object>'
|
|
||||||
)
|
|
||||||
|
|
||||||
obj("spawn", 10, 8, "arrivals")
|
obj("spawn", 10, 8, "arrivals")
|
||||||
obj("building", 18, 8, proto="door")
|
obj("building", 18, 8, proto="door")
|
||||||
obj("building", 8, 14, proto="door")
|
obj("building", 8, 14, proto="door")
|
||||||
|
|
@ -325,28 +323,83 @@ def gen_map():
|
||||||
obj("building", x, y, proto="ore_rock")
|
obj("building", x, y, proto="ore_rock")
|
||||||
obj("item", 25, 22, proto="apple", count=3)
|
obj("item", 25, 22, proto="apple", count=3)
|
||||||
obj("item", 14, 25, proto="log", count=4)
|
obj("item", 14, 25, proto="log", count=4)
|
||||||
|
obj("building", 16, 12, proto="stairs_down")
|
||||||
|
triggers.teleport(16, 12, "stairs_down", "cellar.tmx", "stairs_up")
|
||||||
|
write_map("station.tmx", W, H, ground, walls, objects, triggers)
|
||||||
|
|
||||||
|
|
||||||
|
def gen_cellar():
|
||||||
|
w, h = 14, 10
|
||||||
|
ground = [[DARK] * w for _ in range(h)]
|
||||||
|
walls = [[WALL if x in (0, w - 1) or y in (0, h - 1) else None for x in range(w)] for y in range(h)]
|
||||||
|
objects, triggers = MapObjects(), MapObjects()
|
||||||
|
objects.point("building", 2, 2, proto="stairs_up")
|
||||||
|
objects.point("building", 11, 2, proto="furnace")
|
||||||
|
objects.point("building", 11, 7, proto="table")
|
||||||
|
objects.point("item", 10, 7, proto="iron_ore", count=4)
|
||||||
|
objects.point("item", 6, 6, proto="log", count=2)
|
||||||
|
triggers.teleport(2, 2, "stairs_up", "station.tmx", "stairs_down")
|
||||||
|
write_map("cellar.tmx", w, h, ground, walls, objects, triggers)
|
||||||
|
|
||||||
|
|
||||||
|
class MapObjects:
|
||||||
|
"""Objects of one object layer; ids are unique per map via the shared counter."""
|
||||||
|
|
||||||
|
next_id = 1
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.xml = []
|
||||||
|
|
||||||
|
def _add(self, cls, name, geometry, props, body=""):
|
||||||
|
oid = MapObjects.next_id
|
||||||
|
MapObjects.next_id += 1
|
||||||
|
prop_xml = ""
|
||||||
|
if props:
|
||||||
|
items = []
|
||||||
|
for k, (typ, v) in props.items():
|
||||||
|
typ = f' type="{typ}"' if typ else ""
|
||||||
|
items.append(f' <property name="{k}"{typ} value="{v}"/>')
|
||||||
|
prop_xml = "\n <properties>\n" + "\n".join(items) + "\n </properties>"
|
||||||
|
self.xml.append(f' <object id="{oid}" name="{name}" class="{cls}" {geometry}>{prop_xml}{body}\n </object>')
|
||||||
|
|
||||||
|
def point(self, cls, x, y, name="", **props):
|
||||||
|
props = {k: ("int" if isinstance(v, int) else None, v) for k, v in props.items()}
|
||||||
|
self._add(cls, name, f'x="{x * T + T // 2}" y="{y * T + T // 2}"', props, "\n <point/>")
|
||||||
|
|
||||||
|
def teleport(self, x, y, name, map_file, target):
|
||||||
|
props = {"map": ("file", map_file), "target": (None, target)}
|
||||||
|
self._add("teleport", name, f'x="{x * T}" y="{y * T}" width="{T}" height="{T}"', props)
|
||||||
|
|
||||||
|
|
||||||
|
def write_map(file, w, h, ground, walls, objects, triggers):
|
||||||
|
def csv(layer):
|
||||||
|
return ",\n".join(",".join("0" if t is None else str(t + 1) for t in row) for row in layer)
|
||||||
|
|
||||||
tmx = f"""<?xml version="1.0" encoding="UTF-8"?>
|
tmx = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="{W}" height="{H}" tilewidth="{T}" tileheight="{T}" infinite="0" nextlayerid="4" nextobjectid="{next_id[0]}">
|
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="{w}" height="{h}" tilewidth="{T}" tileheight="{T}" infinite="0" nextlayerid="5" nextobjectid="{MapObjects.next_id}">
|
||||||
<properties><property name="default_tile" type="int" value="6"/></properties>
|
<properties><property name="default_tile" type="int" value="6"/></properties>
|
||||||
<tileset firstgid="1" source="../tilesets/station.tsx"/>
|
<tileset firstgid="1" source="../tilesets/station.tsx"/>
|
||||||
<layer id="1" name="Ground" width="{W}" height="{H}">
|
<layer id="1" name="Ground" width="{w}" height="{h}">
|
||||||
<data encoding="csv">
|
<data encoding="csv">
|
||||||
{csv(ground)}
|
{csv(ground)}
|
||||||
</data>
|
</data>
|
||||||
</layer>
|
</layer>
|
||||||
<layer id="2" name="Walls" width="{W}" height="{H}">
|
<layer id="2" name="Walls" width="{w}" height="{h}">
|
||||||
<data encoding="csv">
|
<data encoding="csv">
|
||||||
{csv(walls)}
|
{csv(walls)}
|
||||||
</data>
|
</data>
|
||||||
</layer>
|
</layer>
|
||||||
<objectgroup id="3" name="Objects">
|
<objectgroup id="3" name="Objects">
|
||||||
{chr(10).join(objects)}
|
{chr(10).join(objects.xml)}
|
||||||
|
</objectgroup>
|
||||||
|
<objectgroup id="4" name="Triggers">
|
||||||
|
{chr(10).join(triggers.xml)}
|
||||||
</objectgroup>
|
</objectgroup>
|
||||||
</map>
|
</map>
|
||||||
"""
|
"""
|
||||||
(ASSETS / "maps").mkdir(parents=True, exist_ok=True)
|
(ASSETS / "maps").mkdir(parents=True, exist_ok=True)
|
||||||
(ASSETS / "maps/station.tmx").write_text(tmx)
|
(ASSETS / "maps" / file).write_text(tmx)
|
||||||
|
MapObjects.next_id = 1
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
@ -354,4 +407,5 @@ if __name__ == "__main__":
|
||||||
gen_sprites()
|
gen_sprites()
|
||||||
gen_ui()
|
gen_ui()
|
||||||
gen_map()
|
gen_map()
|
||||||
|
gen_cellar()
|
||||||
print("placeholder assets written to", ASSETS)
|
print("placeholder assets written to", ASSETS)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue