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
|
||||
|
||||
- **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
|
||||
(`world.tmx`, `station.tiled-project`, the Station extensions and relative
|
||||
tileset image copies) instead of a single map file; `import` accepts that
|
||||
directory or a map file.
|
||||
(the main map named after the server's map, e.g. `station.tmx`, every level's
|
||||
map, `station.tiled-project`, the Station extensions and relative tileset image
|
||||
copies) instead of a single map file; `import` accepts that directory or the
|
||||
main map file and replaces all levels.
|
||||
- Tiled exports and newly created conversion layers place Objects above Ground
|
||||
Items, Triggers, Wiring/Plumbing and Floors, with Floors and Wiring/Plumbing
|
||||
hidden by default in the editor without affecting gameplay.
|
||||
|
||||
### Added
|
||||
|
||||
- `teleport` triggers in Tiled maps (`target` object name, optional `map` file)
|
||||
move characters between places and levels.
|
||||
- Stairs in the default map's main hall lead to a new cellar level and back up.
|
||||
|
||||
- **Map > Station: Convert All Project Maps** in Tiled converts painted gameplay
|
||||
tiles in every map of the opened project at once.
|
||||
- A Station Object Brush in Tiled with per-tile brush property settings and
|
||||
|
|
|
|||
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
|
||||
`machine.progress`.
|
||||
- `item` – custom property `proto` = item id and optional `count`.
|
||||
- `teleport` – a trigger that moves characters entering its tile to the object
|
||||
named by the string property `target`. The optional **file** property `map`
|
||||
names the target's map, relative to this map; without it the target is on the
|
||||
same map. Arrival does not trigger the destination's own teleport; step off
|
||||
and back on to use it. The arrival tile falls back to the nearest free tile.
|
||||
- Objects are placed on the tile containing their position (point objects work well).
|
||||
Tile objects inherit gameplay properties from their tiles; object properties
|
||||
override them. Painted and explicit buildings share the same occupancy checks.
|
||||
|
|
@ -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.
|
||||
Old maps without a default retain their empty, passable background.
|
||||
|
||||
#### Levels
|
||||
|
||||
The server's map (`station.tmx` by default) is the main level. Every map reached
|
||||
through `teleport` triggers, directly or via other levels, is loaded as another
|
||||
level, up to 64 per world. A level is identified by its path relative to the main
|
||||
map's directory and must stay inside that directory. All levels are simulated
|
||||
all the time, whether or not anyone is on them, so machines keep working and
|
||||
scripts keep running. Clients only receive the level their character is on.
|
||||
Characters, buildings and ground items remember their level in the save.
|
||||
Teleports are validated when a world loads or imports: unknown maps or missing
|
||||
targets are errors. The default map has stairs in the main hall leading to
|
||||
`cellar.tmx` and back up.
|
||||
|
||||
A default map seeds a new world; after that the database stores the authoritative
|
||||
terrain and entity state. Existing saves acquire their terrain snapshot on their
|
||||
next save. To apply a map to an existing world, use the admin `import` command.
|
||||
terrain and entity state of every level. Existing saves acquire their terrain
|
||||
snapshot on their next save. Levels referenced since a world was saved (for example
|
||||
the cellar, for worlds created before it existed) are seeded from their maps. To
|
||||
apply a map to an existing world, use the admin `import` command.
|
||||
Existing saved terrain is not automatically converted or reseeded on startup:
|
||||
import a marked map to convert old static walls/floors. Legacy saves without a
|
||||
terrain snapshot retain static seed terrain until explicitly imported.
|
||||
|
|
@ -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/`.
|
||||
An explicit directory, including spaces, may follow either command; relative
|
||||
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;
|
||||
- `extensions/`: a copy of `assets/tiled/` (refreshed on every export);
|
||||
- `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
|
||||
README but keeps an existing project file, so project settings survive.
|
||||
2. Open `station.tiled-project` in Tiled 1.10.1 or newer (**Project > Open
|
||||
Project**), click **Enable Extensions** when asked, then open `world.tmx`
|
||||
from the Project panel. Edit and save. Buildings and ground items are point
|
||||
Project**), click **Enable Extensions** when asked, then open the main map
|
||||
(or any level) from the Project panel. Edit and save. Buildings and ground items are point
|
||||
objects with their prototype and full mutable state in custom properties.
|
||||
Terrain layers and collision properties are included. The tileset image copies
|
||||
are for display only: on import they map back to the server's identical asset
|
||||
paths, and editing them does not change the game.
|
||||
Objects are organized into **Floors**, **Objects** (walls, machines and other
|
||||
structures), **Ground Items**, and **Triggers** (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 or trigger mechanics. From top to bottom, Tiled shows
|
||||
**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
|
||||
properties determine gameplay, not the editor layer names.
|
||||
3. Copy the directory back to the server if you edited it elsewhere, then run
|
||||
`/import` (or `/import DIR`, or `/import FILE` for a single `.tmx` file). **Import replaces all terrain, buildings and
|
||||
ground items, including deletions.** It does not merge objects or change online
|
||||
or offline characters, their positions or inventories. Export a separate backup
|
||||
`/import` (or `/import DIR`, or `/import FILE` naming the main `.tmx` file;
|
||||
levels are read relative to it). **Import replaces all levels, terrain,
|
||||
buildings and ground items, including deletions.** Add a level by creating its
|
||||
map and pointing a teleport at it; levels no longer reached by any teleport are
|
||||
removed, and characters on them move to the main spawn point. Otherwise it
|
||||
does not merge objects or change online or offline characters, their positions
|
||||
or inventories. Export a separate backup
|
||||
before importing, and avoid editing a stale export while players keep building.
|
||||
|
||||
Booleans and small integers use native Tiled property types. Other state values
|
||||
|
|
|
|||
|
|
@ -66,6 +66,16 @@ buildings: [
|
|||
on_interact: Some("vending::dispense"),
|
||||
vars: { "stock": 5 },
|
||||
},
|
||||
{
|
||||
id: "stairs_down", name: "Stairs down", sprite: "sprites/buildings/stairs_down.png",
|
||||
description: "Leads to the level below.",
|
||||
solid: false, deconstructible: false,
|
||||
},
|
||||
{
|
||||
id: "stairs_up", name: "Stairs up", sprite: "sprites/buildings/stairs_up.png",
|
||||
description: "Leads to the level above.",
|
||||
solid: false, deconstructible: false,
|
||||
},
|
||||
{
|
||||
id: "tree", name: "Tree", sprite: "sprites/buildings/tree.png",
|
||||
deconstructible: false,
|
||||
|
|
|
|||
|
|
@ -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"?>
|
||||
<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>
|
||||
<tileset firstgid="1" source="../tilesets/station.tsx"/>
|
||||
<layer id="1" name="Ground" width="40" height="28">
|
||||
|
|
@ -67,7 +67,8 @@
|
|||
</data>
|
||||
</layer>
|
||||
<objectgroup id="3" name="Objects">
|
||||
<object id="1" name="arrivals" class="spawn" x="336" y="272"><point/>
|
||||
<object id="1" name="arrivals" class="spawn" x="336" y="272">
|
||||
<point/>
|
||||
</object>
|
||||
<object id="2" name="" class="building" x="592" y="272">
|
||||
<properties>
|
||||
|
|
@ -242,5 +243,19 @@
|
|||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
<object id="30" name="" class="building" x="528" y="400">
|
||||
<properties>
|
||||
<property name="proto" value="stairs_down"/>
|
||||
</properties>
|
||||
<point/>
|
||||
</object>
|
||||
</objectgroup>
|
||||
<objectgroup id="4" name="Triggers">
|
||||
<object id="31" name="stairs_down" class="teleport" x="512" y="384" width="32" height="32">
|
||||
<properties>
|
||||
<property name="map" type="file" value="cellar.tmx"/>
|
||||
<property name="target" value="stairs_up"/>
|
||||
</properties>
|
||||
</object>
|
||||
</objectgroup>
|
||||
</map>
|
||||
|
|
|
|||
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::{
|
||||
machine::OpenMachine,
|
||||
render::LocalCharacter,
|
||||
render::{LocalCharacter, OffLevel},
|
||||
session::{self, AppState, SessionEnded, SessionInfo},
|
||||
ui::{
|
||||
self, OnClick, ThemedButton, Tooltip, UiAction, UiBindings, UiContext, UiFocus, UiForm,
|
||||
|
|
@ -519,7 +519,7 @@ fn recipe_widgets(
|
|||
ui: Res<UiState>,
|
||||
local: Res<LocalCharacter>,
|
||||
characters: Query<(&GridPos, &Inventory, &Skills)>,
|
||||
buildings: Query<(&Building, &GridPos)>,
|
||||
buildings: Query<(&Building, &GridPos), Without<OffLevel>>,
|
||||
mut slots: Query<(Entity, &mut WidgetSlot)>,
|
||||
) {
|
||||
let Some((pos, inventory, skills)) = local.0.and_then(|e| characters.get(e).ok()) else {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use game_server::actions::REACH;
|
|||
use crate::{
|
||||
hud::{Menu, UiState, activate_entry, menu_entries},
|
||||
machine::{InteractAt, OpenMachine},
|
||||
render::LocalCharacter,
|
||||
render::{LocalCharacter, OffLevel},
|
||||
session::AppState,
|
||||
ui::{TextEditSystems, UiFocus, pointer_over_ui},
|
||||
};
|
||||
|
|
@ -160,7 +160,7 @@ fn actions(
|
|||
machine: Res<OpenMachine>,
|
||||
local: Res<LocalCharacter>,
|
||||
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) {
|
||||
ui.toggle(Menu::Help);
|
||||
|
|
@ -241,7 +241,7 @@ fn mouse(
|
|||
camera: Query<(&Camera, &GlobalTransform)>,
|
||||
local: Res<LocalCharacter>,
|
||||
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)
|
||||
|| ui.blocks_game()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use game_server::actions::REACH;
|
|||
|
||||
use crate::{
|
||||
hud::{UiState, spawn_item_slot},
|
||||
render::LocalCharacter,
|
||||
render::{LocalCharacter, OffLevel},
|
||||
session::{AppState, SessionEnded},
|
||||
ui::{OnClick, ThemedButton, Tooltip, UiAction, layout::Theme},
|
||||
};
|
||||
|
|
@ -48,7 +48,7 @@ fn interact_at(
|
|||
mut commands: Commands,
|
||||
mut open: ResMut<OpenMachine>,
|
||||
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) {
|
||||
Some((entity, _)) => {
|
||||
|
|
@ -77,7 +77,7 @@ fn close_when_invalid(
|
|||
mut open: ResMut<OpenMachine>,
|
||||
local: Res<LocalCharacter>,
|
||||
positions: Query<&GridPos>,
|
||||
machines: Query<&GridPos, With<Machine>>,
|
||||
machines: Query<&GridPos, (With<Machine>, Without<OffLevel>)>,
|
||||
) {
|
||||
let Some(machine) = open.0 else { return };
|
||||
let me = local.0.and_then(|e| positions.get(e).ok());
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ impl Plugin for RenderPlugin {
|
|||
item_visuals,
|
||||
character_visuals,
|
||||
move_characters,
|
||||
hide_other_levels,
|
||||
update_marker,
|
||||
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(
|
||||
local: Res<LocalCharacter>,
|
||||
characters: Query<(&GridPos, &Facing)>,
|
||||
|
|
@ -332,6 +364,11 @@ fn follow_camera(
|
|||
return;
|
||||
};
|
||||
let goal = target.translation.truncate().extend(camera.translation.z);
|
||||
// Don't pan across the map after teleports.
|
||||
if goal.distance(camera.translation) > TILE_SIZE * 8.0 {
|
||||
camera.translation = goal;
|
||||
return;
|
||||
}
|
||||
let t = 1.0 - (-8.0 * time.delta_secs()).exp();
|
||||
camera.translation = camera.translation.lerp(goal, t);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -455,6 +455,28 @@ pub struct WorldMap {
|
|||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
//! - `spawn`: player spawn point.
|
||||
//! - `building`: building with the custom property `proto` (a building id).
|
||||
//! - `item`: ground item with the property `proto` (an item id) and optional `count`.
|
||||
//! - `teleport`: trigger leading to the object named by `target`, on the map named by
|
||||
//! the optional file property `map` (relative to this map).
|
||||
//!
|
||||
//! State overrides (`state.*`, `machine.*`) and legacy integer script variables
|
||||
//! are decoded by the server for both new worlds and imports.
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ impl Plugin for ProtocolPlugin {
|
|||
.replicate::<Building>()
|
||||
.replicate::<BuildingState>()
|
||||
.replicate::<Machine>()
|
||||
.replicate::<Level>()
|
||||
.add_client_event::<Login>(Channel::Ordered)
|
||||
.add_client_event::<SetMovement>(Channel::Ordered)
|
||||
.add_client_event::<PickUp>(Channel::Ordered)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use crate::{
|
|||
movement,
|
||||
players::{Players, tell},
|
||||
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.
|
||||
|
|
@ -30,6 +30,7 @@ type CharacterData<'a> = (
|
|||
&'a mut Inventory,
|
||||
&'a mut Vitals,
|
||||
&'a mut Skills,
|
||||
&'a Level,
|
||||
);
|
||||
|
||||
type Characters<'w, 's> = Query<'w, 's, CharacterData<'static>>;
|
||||
|
|
@ -41,11 +42,21 @@ type Buildings<'w, 's> = Query<
|
|||
&'static Building,
|
||||
&'static GridPos,
|
||||
&'static mut BuildingState,
|
||||
&'static Level,
|
||||
),
|
||||
Without<Character>,
|
||||
>;
|
||||
type GroundItems<'w, 's> = Query<
|
||||
'w,
|
||||
's,
|
||||
(
|
||||
Entity,
|
||||
&'static mut GroundItem,
|
||||
&'static GridPos,
|
||||
&'static Level,
|
||||
),
|
||||
Without<Character>,
|
||||
>;
|
||||
type GroundItems<'w, 's> =
|
||||
Query<'w, 's, (Entity, &'static mut GroundItem, &'static GridPos), Without<Character>>;
|
||||
|
||||
fn face_towards(facing: &mut Mut<Facing>, from: GridPos, to: GridPos) {
|
||||
use game_core::grid::Dir;
|
||||
|
|
@ -82,11 +93,12 @@ fn give_or_drop(
|
|||
data: &GameData,
|
||||
inventory: &mut Inventory,
|
||||
stack: ItemStack,
|
||||
level: &Level,
|
||||
pos: GridPos,
|
||||
) {
|
||||
let leftover = inventory.add(&stack.item, stack.count, data);
|
||||
if leftover > 0 {
|
||||
spawn_ground_item(commands, ItemStack::new(stack.item, leftover), 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 {
|
||||
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;
|
||||
};
|
||||
if pos.distance(target) > REACH {
|
||||
|
|
@ -168,8 +180,8 @@ pub fn on_pick_up(
|
|||
}
|
||||
face_towards(&mut facing, *pos, target);
|
||||
let mut picked = Vec::new();
|
||||
for (item_entity, mut item, item_pos) in &mut items {
|
||||
if *item_pos != target {
|
||||
for (item_entity, mut item, item_pos, item_level) in &mut items {
|
||||
if *item_pos != target || item_level != level {
|
||||
continue;
|
||||
}
|
||||
let leftover = inventory.add(&item.0.item, item.0.count, &data);
|
||||
|
|
@ -184,7 +196,7 @@ pub fn on_pick_up(
|
|||
}
|
||||
}
|
||||
if picked.is_empty() {
|
||||
let full = items.iter().any(|(_, _, p)| *p == target);
|
||||
let full = items.iter().any(|(_, _, p, l)| *p == target && l == level);
|
||||
tell(
|
||||
&mut commands,
|
||||
client,
|
||||
|
|
@ -215,7 +227,7 @@ pub fn on_drop_item(
|
|||
let Some(&entity) = players.by_client.get(&client) else {
|
||||
return;
|
||||
};
|
||||
let Ok((_, pos, _, mut inventory, _, _)) = characters.get_mut(entity) else {
|
||||
let Ok((_, pos, _, mut inventory, _, _, level)) = characters.get_mut(entity) else {
|
||||
return;
|
||||
};
|
||||
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.
|
||||
if let Some((_, mut existing, _)) = items
|
||||
if let Some((_, mut existing, _, _)) = items
|
||||
.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;
|
||||
} 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 {
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
|
|
@ -287,7 +300,7 @@ pub fn on_use_item(
|
|||
inventory.remove(&item_id, 1);
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
else {
|
||||
return;
|
||||
|
|
@ -316,10 +329,10 @@ pub fn on_interact(
|
|||
return;
|
||||
}
|
||||
face_towards(&mut facing, *pos, target);
|
||||
let Some((building_entity, building, _, mut state)) = buildings
|
||||
let Some((building_entity, building, _, mut state, _)) = buildings
|
||||
.iter_mut()
|
||||
.filter(|(_, _, p, _)| **p == target)
|
||||
.max_by_key(|(_, _, _, state)| state.layer)
|
||||
.filter(|(_, _, p, _, l)| **p == target && *l == level)
|
||||
.max_by_key(|(_, _, _, state, _)| state.layer)
|
||||
else {
|
||||
tell(&mut commands, client, "There is nothing there.");
|
||||
return;
|
||||
|
|
@ -367,7 +380,7 @@ pub fn on_interact(
|
|||
}
|
||||
}
|
||||
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>,
|
||||
engine: Res<ScriptEngine>,
|
||||
mut characters: Characters,
|
||||
buildings: Query<(&Building, &GridPos), Without<Character>>,
|
||||
buildings: Query<(&Building, &GridPos, &Level), Without<Character>>,
|
||||
) {
|
||||
let client = ev.client_id;
|
||||
let Some(&entity) = players.by_client.get(&client) else {
|
||||
return;
|
||||
};
|
||||
let Ok((character, pos, _, mut inventory, mut vitals, mut skills)) = characters.get_mut(entity)
|
||||
let Ok((character, pos, _, mut inventory, mut vitals, mut skills, level)) =
|
||||
characters.get_mut(entity)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let stations: Vec<String> = buildings
|
||||
.iter()
|
||||
.filter(|(_, p)| p.distance(*pos) <= REACH)
|
||||
.filter_map(|(b, _)| data.0.buildings.get(&b.proto))
|
||||
.filter(|(_, p, l)| p.distance(*pos) <= REACH && *l == level)
|
||||
.filter_map(|(b, _, _)| data.0.buildings.get(&b.proto))
|
||||
.flat_map(|def| def.stations.iter().cloned())
|
||||
.collect();
|
||||
|
||||
|
|
@ -428,7 +442,7 @@ pub fn on_craft(
|
|||
)
|
||||
{
|
||||
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,
|
||||
players: Res<Players>,
|
||||
data: Res<GameData>,
|
||||
layout: Res<WorldLayout>,
|
||||
levels: Res<Levels>,
|
||||
mut characters: Characters,
|
||||
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
||||
occupants: Query<&Position, (With<Character>, Without<Building>)>,
|
||||
buildings: Query<(&GridPos, &BuildingState, &Level), (With<Building>, Without<Character>)>,
|
||||
occupants: Query<(&Position, &Level), (With<Character>, Without<Building>)>,
|
||||
) {
|
||||
let client = ev.client_id;
|
||||
let target = ev.message.target;
|
||||
let Some(&entity) = players.by_client.get(&client) else {
|
||||
return;
|
||||
};
|
||||
// Checked before borrowing the actor mutably.
|
||||
let occupied = occupants
|
||||
.iter()
|
||||
.any(|p| movement::overlaps_tile(p.0, target));
|
||||
let Ok((_, pos, mut facing, mut inventory, _, mut skills)) = characters.get_mut(entity) else {
|
||||
let Ok((_, pos, mut facing, mut inventory, _, mut skills, level)) = characters.get_mut(entity)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let level = level.clone();
|
||||
let Some(layout) = levels.layout(&level) else {
|
||||
return;
|
||||
};
|
||||
let occupied = occupants
|
||||
.iter()
|
||||
.any(|(p, l)| *l == level && movement::overlaps_tile(p.0, target));
|
||||
if pos.distance(target) > REACH {
|
||||
tell(&mut commands, client, "That is too far away.");
|
||||
return;
|
||||
|
|
@ -468,14 +486,17 @@ pub fn on_construct(
|
|||
return;
|
||||
};
|
||||
let floor = def.layer == game_core::data::BuildingLayer::Floor;
|
||||
let has_floor = buildings
|
||||
.iter()
|
||||
.any(|(p, state)| *p == target && state.layer == game_core::data::BuildingLayer::Floor);
|
||||
if layout.0.is_solid_with_floor(target, floor || has_floor)
|
||||
|| buildings.iter().any(|(p, state)| {
|
||||
*p == target
|
||||
&& (state.layer == def.layer
|
||||
|| (floor && state.layer == game_core::data::BuildingLayer::Structure))
|
||||
let here = || {
|
||||
buildings
|
||||
.iter()
|
||||
.filter(|(p, _, l)| **p == target && **l == level)
|
||||
};
|
||||
let has_floor =
|
||||
here().any(|(_, state, _)| state.layer == game_core::data::BuildingLayer::Floor);
|
||||
if layout.is_solid_with_floor(target, floor || has_floor)
|
||||
|| here().any(|(_, state, _)| {
|
||||
state.layer == def.layer
|
||||
|| (floor && state.layer == game_core::data::BuildingLayer::Structure)
|
||||
})
|
||||
{
|
||||
tell(&mut commands, client, "There is no room to build there.");
|
||||
|
|
@ -491,7 +512,14 @@ pub fn on_construct(
|
|||
Ok(level_up) => {
|
||||
*inventory = new_inventory;
|
||||
*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));
|
||||
announce_level_up(&mut commands, client, &data, level_up);
|
||||
}
|
||||
|
|
@ -512,6 +540,7 @@ pub fn on_deconstruct(
|
|||
&GridPos,
|
||||
&BuildingState,
|
||||
Option<&Machine>,
|
||||
&Level,
|
||||
),
|
||||
Without<Character>,
|
||||
>,
|
||||
|
|
@ -521,7 +550,7 @@ pub fn on_deconstruct(
|
|||
let Some(&entity) = players.by_client.get(&client) else {
|
||||
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;
|
||||
};
|
||||
if pos.distance(target) > REACH {
|
||||
|
|
@ -529,10 +558,10 @@ pub fn on_deconstruct(
|
|||
return;
|
||||
}
|
||||
face_towards(&mut facing, *pos, target);
|
||||
let Some((building_entity, building, _, _, machine)) = buildings
|
||||
let Some((building_entity, building, _, _, machine, _)) = buildings
|
||||
.iter()
|
||||
.filter(|(_, _, p, _, _)| **p == target)
|
||||
.max_by_key(|(_, _, _, state, _)| state.layer)
|
||||
.filter(|(_, _, p, _, _, l)| **p == target && *l == level)
|
||||
.max_by_key(|(_, _, _, state, _, _)| state.layer)
|
||||
else {
|
||||
tell(&mut commands, client, "There is nothing to deconstruct.");
|
||||
return;
|
||||
|
|
@ -569,12 +598,13 @@ pub fn on_deconstruct(
|
|||
&data,
|
||||
&mut inventory,
|
||||
ItemStack::new(&material.item, material.count),
|
||||
level,
|
||||
*pos,
|
||||
);
|
||||
}
|
||||
}
|
||||
for stack in machine.into_iter().flat_map(Machine::contents) {
|
||||
spawn_ground_item(&mut commands, stack.clone(), target);
|
||||
spawn_ground_item(&mut commands, stack.clone(), level, target);
|
||||
}
|
||||
commands.entity(building_entity).despawn();
|
||||
tell(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ pub mod world;
|
|||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_replicon::server::visibility::AppVisibilityExt;
|
||||
use game_core::GameData;
|
||||
|
||||
pub use network::NetworkServerPlugin;
|
||||
|
|
@ -68,6 +69,7 @@ impl Plugin for GameServerPlugin {
|
|||
.init_resource::<players::CharacterStore>()
|
||||
.init_resource::<players::RejectedLogins>()
|
||||
.init_resource::<survival::SurvivalTimer>()
|
||||
.add_visibility_filter::<world::Level>()
|
||||
.add_systems(
|
||||
Startup,
|
||||
(
|
||||
|
|
@ -81,10 +83,11 @@ impl Plugin for GameServerPlugin {
|
|||
(
|
||||
(movement::move_characters, survival::tick_survival),
|
||||
movement::sync_tiles,
|
||||
movement::use_teleports,
|
||||
machines::tick_machines,
|
||||
)
|
||||
.chain()
|
||||
.run_if(resource_exists::<world::WorldLayout>),
|
||||
.run_if(resource_exists::<world::Levels>),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use crate::{
|
|||
actions::REACH,
|
||||
crafting::item_name,
|
||||
players::{Players, tell},
|
||||
world::Level,
|
||||
};
|
||||
|
||||
/// 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>(
|
||||
level: &Level,
|
||||
pos: 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> {
|
||||
if pos.distance(target) > REACH {
|
||||
return Err("That is too far away.");
|
||||
}
|
||||
machines
|
||||
.iter_mut()
|
||||
.find(|(p, _, _)| **p == target)
|
||||
.map(|(_, b, m)| (b, m))
|
||||
.find(|(p, _, _, l)| **p == target && *l == level)
|
||||
.map(|(_, b, m, _)| (b, m))
|
||||
.ok_or("There is no machine there.")
|
||||
}
|
||||
|
||||
|
|
@ -80,17 +93,17 @@ pub fn on_machine_insert(
|
|||
players: Res<Players>,
|
||||
data: Res<GameData>,
|
||||
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 msg = &ev.message;
|
||||
let Some(&entity) = players.by_client.get(&client) else {
|
||||
return;
|
||||
};
|
||||
let Ok((pos, mut inventory)) = characters.get_mut(entity) else {
|
||||
let Ok((pos, mut inventory, level)) = characters.get_mut(entity) else {
|
||||
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,
|
||||
Err(reason) => return tell(&mut commands, client, reason),
|
||||
};
|
||||
|
|
@ -119,17 +132,17 @@ pub fn on_machine_take(
|
|||
players: Res<Players>,
|
||||
data: Res<GameData>,
|
||||
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 msg = &ev.message;
|
||||
let Some(&entity) = players.by_client.get(&client) else {
|
||||
return;
|
||||
};
|
||||
let Ok((pos, mut inventory)) = characters.get_mut(entity) else {
|
||||
let Ok((pos, mut inventory, level)) = characters.get_mut(entity) else {
|
||||
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,
|
||||
Err(reason) => return tell(&mut commands, client, reason),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ use std::{
|
|||
collections::{BTreeMap, BTreeSet},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use game_core::{GameData, components::*, map::MapLayout};
|
||||
use game_core::{
|
||||
GameData,
|
||||
components::*,
|
||||
map::{MapLayout, MapObject},
|
||||
};
|
||||
use quick_xml::{
|
||||
Writer,
|
||||
events::{BytesEnd, BytesStart, BytesText, Event},
|
||||
|
|
@ -17,10 +20,14 @@ use serde::{Serialize, de::DeserializeOwned};
|
|||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
persistence::{self, SaveGame, SavedBuilding, SavedItem, Storage},
|
||||
world::{SpawnPoint, WorldLayout, spawn_building_with_state, spawn_ground_item},
|
||||
persistence::{self, SaveGame, SavedBuilding, SavedCharacter, SavedItem, Storage},
|
||||
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)]
|
||||
pub enum MapIoError {
|
||||
#[error("{0}")]
|
||||
|
|
@ -49,8 +56,15 @@ pub fn decode(layout: MapLayout, data: &GameData) -> Result<SaveGame, MapIoError
|
|||
if !layout.in_bounds(object.pos) {
|
||||
return Err(invalid("position is outside the map".into()));
|
||||
}
|
||||
if object.class == "spawn" {
|
||||
continue;
|
||||
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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let proto = object
|
||||
.properties
|
||||
|
|
@ -125,6 +139,7 @@ pub fn decode(layout: MapLayout, data: &GameData) -> Result<SaveGame, MapIoError
|
|||
pos: object.pos,
|
||||
state,
|
||||
machine,
|
||||
level: None,
|
||||
});
|
||||
}
|
||||
"item" => {
|
||||
|
|
@ -137,17 +152,195 @@ pub fn decode(layout: MapLayout, data: &GameData) -> Result<SaveGame, MapIoError
|
|||
save.items.push(SavedItem {
|
||||
stack,
|
||||
pos: object.pos,
|
||||
level: None,
|
||||
});
|
||||
}
|
||||
class => return Err(invalid(format!("unsupported class `{class}`"))),
|
||||
}
|
||||
}
|
||||
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);
|
||||
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> {
|
||||
if !data.items.contains_key(&stack.item) || stack.count == 0 {
|
||||
return Err(format!("unknown item or zero count: `{}`", stack.item));
|
||||
|
|
@ -174,8 +367,6 @@ fn overlay<T: Serialize + DeserializeOwned>(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Map file inside an exported project directory.
|
||||
pub const PROJECT_MAP: &str = "world.tmx";
|
||||
pub const PROJECT_FILE: &str = "station.tiled-project";
|
||||
const PROJECT: &str = r#"{
|
||||
"automappingRulesFile": "",
|
||||
|
|
@ -191,7 +382,9 @@ const PROJECT_README: &str = "\
|
|||
Station world export
|
||||
|
||||
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
|
||||
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
|
||||
|
|
@ -200,7 +393,7 @@ Station world export
|
|||
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 {
|
||||
world.get_resource::<Storage>().map_or_else(
|
||||
|| 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.
|
||||
fn project_paths(path: &Path) -> (PathBuf, PathBuf) {
|
||||
/// A `.tmx` path names the main map file directly; anything else is the project
|
||||
/// directory, with the main map named after its level id.
|
||||
fn project_paths(path: &Path, main: &str) -> (PathBuf, PathBuf) {
|
||||
if path
|
||||
.extension()
|
||||
.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("."));
|
||||
(dir.to_path_buf(), path.to_path_buf())
|
||||
} 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.
|
||||
pub fn export(world: &mut World, path: &Path) -> Result<PathBuf, MapIoError> {
|
||||
let main = main_level(world)?;
|
||||
let mut save = persistence::snapshot(world);
|
||||
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 tile in tileset.gameplay_tiles.values_mut() {
|
||||
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.
|
||||
pub fn write_project(save: &SaveGame, path: &Path) -> Result<PathBuf, MapIoError> {
|
||||
let (dir, map) = project_paths(path);
|
||||
let layout = save
|
||||
.layout
|
||||
.as_ref()
|
||||
.ok_or_else(|| MapIoError::Invalid("world has no terrain".into()))?;
|
||||
/// Writes the maps of all levels with the tileset images, Station extension and
|
||||
/// project file next to them.
|
||||
pub fn write_project(save: &SaveGame, main: &str, path: &Path) -> Result<PathBuf, MapIoError> {
|
||||
let (dir, map) = project_paths(path, main);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let assets = game_core::assets_dir();
|
||||
for ts in &layout.tilesets {
|
||||
let target = dir.join(&ts.image);
|
||||
let images: BTreeSet<&str> = save
|
||||
.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() {
|
||||
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");
|
||||
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(dir.join("README.txt"), PROJECT_README)?;
|
||||
write_map(save, &map, ImageSources::Relative)?;
|
||||
write_levels(save, main, &map, ImageSources::Relative)?;
|
||||
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> {
|
||||
let main = main_level(world)?;
|
||||
let path = if path.is_dir() {
|
||||
path.join(PROJECT_MAP)
|
||||
path.join(&main)
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
let path = path.canonicalize()?;
|
||||
let mirror = path.parent().unwrap_or(Path::new("."));
|
||||
let layout = MapLayout::load_with_image_mirror(
|
||||
&game_core::assets_dir(),
|
||||
&path.to_string_lossy(),
|
||||
mirror,
|
||||
)?;
|
||||
let mut save = decode(layout, world.resource::<GameData>())?;
|
||||
let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
|
||||
let main_file = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| main.clone());
|
||||
let assets = game_core::assets_dir();
|
||||
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;
|
||||
for character in save.characters.values_mut() {
|
||||
relocate_from_removed_level(&levels, character);
|
||||
}
|
||||
if let Some(mut storage) = world.get_resource_mut::<Storage>() {
|
||||
// Wait for any old autosave before committing the replacement.
|
||||
storage.save_blocking(&save)?;
|
||||
}
|
||||
let layout = save.layout.take().expect("decoded map has terrain");
|
||||
let spawn = spawn_point(&layout);
|
||||
let mut query = world.query_filtered::<Entity, Or<(With<Building>, With<GroundItem>)>>();
|
||||
for character in world.resource_mut::<CharacterStore>().0.values_mut() {
|
||||
relocate_from_removed_level(&levels, character);
|
||||
}
|
||||
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();
|
||||
for entity in entities {
|
||||
world.despawn(entity);
|
||||
}
|
||||
let mut maps = world.query::<&mut WorldMap>();
|
||||
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 data = world.resource::<GameData>().clone();
|
||||
let mut commands = world.commands();
|
||||
for b in save.buildings {
|
||||
spawn_building_with_state(&mut commands, &b.proto, b.pos, b.state, b.machine);
|
||||
}
|
||||
for item in save.items {
|
||||
spawn_ground_item(&mut commands, item.stack, item.pos);
|
||||
for (entity, client) in stranded {
|
||||
let (level, pos) = levels.spawn();
|
||||
move_character(&mut commands, entity, client, level, pos);
|
||||
}
|
||||
persistence::restore_entities(&mut commands, &data, &levels, save);
|
||||
install_levels(&mut commands, levels);
|
||||
world.flush();
|
||||
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 {
|
||||
layout.objects_of_class("spawn").next().map_or_else(
|
||||
|| GridPos::new(layout.width as i32 / 2, layout.height as i32 / 2),
|
||||
|
|
@ -408,17 +654,85 @@ enum ImageSources {
|
|||
Relative,
|
||||
}
|
||||
|
||||
/// Writes a standalone map file that references the assets directory.
|
||||
pub fn write(save: &SaveGame, path: &Path) -> Result<PathBuf, MapIoError> {
|
||||
write_map(save, path, ImageSources::Assets)
|
||||
/// Writes standalone map files that reference the assets directory: the main
|
||||
/// level at `path`, the other levels next to it. Returns the main map's path.
|
||||
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 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
|
||||
.layout
|
||||
.as_ref()
|
||||
.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 mut xml = Writer::new_with_indent(Vec::new(), b' ', 1);
|
||||
tag(
|
||||
|
|
@ -474,7 +788,7 @@ fn write_map(save: &SaveGame, path: &Path, images: ImageSources) -> Result<PathB
|
|||
ImageSources::Assets => {
|
||||
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()),
|
||||
|
|
@ -580,7 +894,7 @@ fn write_map(save: &SaveGame, path: &Path, images: ImageSources) -> Result<PathB
|
|||
],
|
||||
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 {
|
||||
*name == "Floors"
|
||||
} else {
|
||||
|
|
@ -596,7 +910,7 @@ fn write_map(save: &SaveGame, path: &Path, images: ImageSources) -> Result<PathB
|
|||
id += 1;
|
||||
}
|
||||
if *name == "Ground Items" {
|
||||
for item in &save.items {
|
||||
for item in &level.items {
|
||||
let fields = BTreeMap::from([
|
||||
("proto".into(), Value::String(item.stack.item.clone())),
|
||||
("count".into(), Value::from(item.stack.count)),
|
||||
|
|
@ -626,6 +940,20 @@ fn write_map(save: &SaveGame, path: &Path, images: ImageSources) -> Result<PathB
|
|||
)?;
|
||||
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")?;
|
||||
}
|
||||
|
|
@ -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()));
|
||||
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) {
|
||||
warn!("failed to remove {}: {cleanup}", temporary.display());
|
||||
}
|
||||
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(
|
||||
|
|
@ -681,6 +1055,8 @@ fn write_object(
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MAIN: &str = "station.tmx";
|
||||
|
||||
fn seeded() -> (GameData, SaveGame) {
|
||||
let assets = game_core::assets_dir();
|
||||
let data = GameData::load_dir(&assets.join("data")).unwrap();
|
||||
|
|
@ -699,7 +1075,7 @@ mod tests {
|
|||
let dir = path().with_extension("project");
|
||||
std::fs::create_dir_all(&dir).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!(
|
||||
std::fs::read_to_string(&project).unwrap(),
|
||||
|
|
@ -735,7 +1111,7 @@ mod tests {
|
|||
let (data, save) = seeded();
|
||||
for extension in ["", "backup", "TMX", "tmx"] {
|
||||
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!(saved.is_file());
|
||||
if requested != saved {
|
||||
|
|
@ -871,7 +1247,9 @@ mod tests {
|
|||
save.layout
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.objects_of_class("spawn")
|
||||
.objects
|
||||
.iter()
|
||||
.filter(|o| o.class == "spawn" || o.class == "teleport")
|
||||
.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> {
|
||||
let file = path();
|
||||
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, 0), &painted.buildings));
|
||||
let output = path();
|
||||
write(&painted, &output).unwrap();
|
||||
write(&painted, MAIN, &output).unwrap();
|
||||
let loaded = decode(
|
||||
MapLayout::load(&game_core::assets_dir(), output.to_str().unwrap()).unwrap(),
|
||||
&data,
|
||||
|
|
@ -1032,7 +1465,7 @@ mod tests {
|
|||
flip_d: true,
|
||||
});
|
||||
let file = path();
|
||||
write(&save, &file).unwrap();
|
||||
write(&save, MAIN, &file).unwrap();
|
||||
let loaded = decode(
|
||||
MapLayout::load(&game_core::assets_dir(), file.to_str().unwrap()).unwrap(),
|
||||
&data,
|
||||
|
|
@ -1081,7 +1514,7 @@ mod tests {
|
|||
cell.flip_v = true;
|
||||
cell.flip_d = true;
|
||||
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 mut roundtrip = decode(loaded, &data).unwrap();
|
||||
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.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_replicon::prelude::*;
|
||||
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.
|
||||
pub const MOVE_SPEED: f32 = 4.5;
|
||||
|
|
@ -64,29 +65,22 @@ fn facing_for(input: Vec2, current: Dir) -> Option<Dir> {
|
|||
|
||||
pub fn move_characters(
|
||||
time: Res<Time>,
|
||||
layout: Option<Res<WorldLayout>>,
|
||||
buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
||||
mut characters: Query<(&MoveIntent, &mut Position), With<Character>>,
|
||||
levels: Res<Levels>,
|
||||
buildings: Query<(&Level, &GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
||||
mut characters: Query<(&Level, &MoveIntent, &mut Position), With<Character>>,
|
||||
) {
|
||||
let Some(layout) = layout else { return };
|
||||
let solid: HashSet<GridPos> = buildings
|
||||
.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 occupancy = Occupancy::by_level(buildings.iter());
|
||||
let empty = Occupancy::default();
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
let Some(layout) = levels.layout(level) else {
|
||||
continue;
|
||||
};
|
||||
let occupancy = occupancy.get(level.0.as_str()).unwrap_or(&empty);
|
||||
let blocked = |tile: GridPos| occupancy.is_blocked(tile, layout);
|
||||
let next = slide(pos.0, intent.0 * MOVE_SPEED * dt, &blocked);
|
||||
if next != pos.0 {
|
||||
pos.0 = next;
|
||||
|
|
@ -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`.
|
||||
fn box_tiles(pos: Vec2) -> impl Iterator<Item = GridPos> {
|
||||
let min = (pos - CHARACTER_RADIUS).floor().as_ivec2();
|
||||
|
|
|
|||
|
|
@ -23,11 +23,11 @@ use uuid::Uuid;
|
|||
|
||||
use crate::{
|
||||
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`].
|
||||
const SCHEMA_VERSION: i64 = 2;
|
||||
const SCHEMA_VERSION: i64 = 3;
|
||||
|
||||
/// Schema migrations, applied in order. Index `i` upgrades from version `i` to `i + 1`.
|
||||
/// 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 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 ground_items WHERE world_id = $1",
|
||||
"DELETE FROM characters WHERE world_id = $1",
|
||||
|
|
@ -99,6 +110,9 @@ pub struct SavedCharacter {
|
|||
pub vitals: Vitals,
|
||||
pub inventory: Inventory,
|
||||
pub skills: Skills,
|
||||
/// `None` is the main level.
|
||||
#[serde(default)]
|
||||
pub level: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -108,18 +122,27 @@ pub struct SavedBuilding {
|
|||
pub state: BuildingState,
|
||||
#[serde(default)]
|
||||
pub machine: Option<Machine>,
|
||||
/// `None` is the main level.
|
||||
#[serde(default)]
|
||||
pub level: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SavedItem {
|
||||
pub stack: ItemStack,
|
||||
pub pos: GridPos,
|
||||
/// `None` is the main level.
|
||||
#[serde(default)]
|
||||
pub level: Option<String>,
|
||||
}
|
||||
|
||||
/// Full state of a world.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SaveGame {
|
||||
/// Terrain of the main level.
|
||||
pub layout: Option<MapLayout>,
|
||||
/// Terrain of the other levels, by level id.
|
||||
pub levels: BTreeMap<String, MapLayout>,
|
||||
pub buildings: Vec<SavedBuilding>,
|
||||
pub items: Vec<SavedItem>,
|
||||
/// All characters (online and offline), by player id.
|
||||
|
|
@ -359,11 +382,22 @@ impl Database {
|
|||
.ok_or(StorageError::NoSuchWorld(id))?;
|
||||
let layout: Option<String> = row.try_get(0)?;
|
||||
save.layout = layout.as_deref().map(serde_json::from_str).transpose()?;
|
||||
let rows =
|
||||
sqlx::query("SELECT proto, x, y, state, machine FROM buildings WHERE world_id = $1")
|
||||
.bind(world.clone())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let rows = sqlx::query("SELECT name, layout FROM levels 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())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
for row in rows {
|
||||
let machine: Option<String> = row.try_get(4)?;
|
||||
save.buildings.push(SavedBuilding {
|
||||
|
|
@ -371,17 +405,20 @@ impl Database {
|
|||
pos: grid_pos(row.try_get(1)?, row.try_get(2)?),
|
||||
state: serde_json::from_str(row.try_get::<&str, _>(3)?)?,
|
||||
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")
|
||||
.bind(world.clone())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let rows =
|
||||
sqlx::query("SELECT item, count, x, y, level FROM ground_items WHERE world_id = $1")
|
||||
.bind(world.clone())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
for row in rows {
|
||||
let count: i64 = row.try_get(1)?;
|
||||
save.items.push(SavedItem {
|
||||
stack: ItemStack::new(row.try_get::<String, _>(0)?, count as u32),
|
||||
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")
|
||||
|
|
@ -423,10 +460,22 @@ impl Database {
|
|||
.execute(&mut *tx)
|
||||
.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) {
|
||||
let sql = insert_sql(
|
||||
"buildings (world_id, proto, x, y, state, machine)",
|
||||
6,
|
||||
"buildings (world_id, proto, x, y, state, machine, level)",
|
||||
7,
|
||||
chunk.len(),
|
||||
);
|
||||
let mut query = sqlx::query(AssertSqlSafe(sql));
|
||||
|
|
@ -437,12 +486,17 @@ impl Database {
|
|||
.bind(i64::from(b.pos.x))
|
||||
.bind(i64::from(b.pos.y))
|
||||
.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?;
|
||||
}
|
||||
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));
|
||||
for item in chunk {
|
||||
query = query
|
||||
|
|
@ -450,7 +504,8 @@ impl Database {
|
|||
.bind(item.stack.item.clone())
|
||||
.bind(i64::from(item.stack.count))
|
||||
.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?;
|
||||
}
|
||||
|
|
@ -622,18 +677,38 @@ impl Drop for Storage {
|
|||
#[derive(Resource)]
|
||||
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 {
|
||||
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) {
|
||||
// Machine definitions may have changed since the save was written.
|
||||
let machine = data.machines.get(&b.proto).map(|def| {
|
||||
let mut machine = b.machine.unwrap_or_else(|| Machine::new(def));
|
||||
for stack in machine.fit_to(def) {
|
||||
spawn_ground_item(commands, stack, b.pos);
|
||||
spawn_ground_item(commands, stack, &level, b.pos);
|
||||
}
|
||||
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 {
|
||||
warn!(
|
||||
"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 {
|
||||
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) {
|
||||
spawn_ground_item(commands, item.stack, item.pos);
|
||||
spawn_ground_item(commands, item.stack, &level, item.pos);
|
||||
} else {
|
||||
warn!(
|
||||
"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.
|
||||
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 q = world.query::<(
|
||||
&Character,
|
||||
|
|
@ -666,8 +750,9 @@ pub fn snapshot(world: &mut World) -> SaveGame {
|
|||
&Vitals,
|
||||
&Inventory,
|
||||
&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(
|
||||
id.0.clone(),
|
||||
SavedCharacter {
|
||||
|
|
@ -678,31 +763,50 @@ pub fn snapshot(world: &mut World) -> SaveGame {
|
|||
vitals: vitals.clone(),
|
||||
inventory: inventory.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
|
||||
.iter(world)
|
||||
.map(|(b, pos, state, machine)| SavedBuilding {
|
||||
.map(|(b, pos, state, machine, level)| SavedBuilding {
|
||||
proto: b.proto.clone(),
|
||||
pos: *pos,
|
||||
state: state.clone(),
|
||||
machine: machine.cloned(),
|
||||
level: saved_level(level),
|
||||
})
|
||||
.collect();
|
||||
let mut q = world.query::<(&GroundItem, &GridPos)>();
|
||||
let mut q = world.query::<(&GroundItem, &GridPos, Option<&Level>)>();
|
||||
let items = q
|
||||
.iter(world)
|
||||
.map(|(item, pos)| SavedItem {
|
||||
.map(|(item, pos, level)| SavedItem {
|
||||
stack: item.0.clone(),
|
||||
pos: *pos,
|
||||
level: saved_level(level),
|
||||
})
|
||||
.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 {
|
||||
layout: world
|
||||
.get_resource::<crate::world::WorldLayout>()
|
||||
.map(|l| (*l.0).clone()),
|
||||
layout,
|
||||
levels,
|
||||
buildings,
|
||||
items,
|
||||
characters,
|
||||
|
|
@ -763,6 +867,10 @@ mod tests {
|
|||
),
|
||||
..default()
|
||||
};
|
||||
save.levels.insert(
|
||||
"cellar.tmx".into(),
|
||||
MapLayout::load(&game_core::assets_dir(), "maps/cellar.tmx").unwrap(),
|
||||
);
|
||||
let mut inventory = Inventory::default();
|
||||
inventory.slots.push(ItemStack::new("apple", 3));
|
||||
let mut skills = Skills::default();
|
||||
|
|
@ -780,6 +888,7 @@ mod tests {
|
|||
},
|
||||
inventory,
|
||||
skills,
|
||||
level: Some("cellar.tmx".into()),
|
||||
},
|
||||
);
|
||||
save.buildings.push(SavedBuilding {
|
||||
|
|
@ -793,6 +902,7 @@ mod tests {
|
|||
..default()
|
||||
},
|
||||
machine: None,
|
||||
level: None,
|
||||
});
|
||||
save.buildings.push(SavedBuilding {
|
||||
proto: "floor".into(),
|
||||
|
|
@ -804,6 +914,7 @@ mod tests {
|
|||
..default()
|
||||
},
|
||||
machine: None,
|
||||
level: None,
|
||||
});
|
||||
save.buildings.push(SavedBuilding {
|
||||
proto: "furnace".into(),
|
||||
|
|
@ -815,12 +926,14 @@ mod tests {
|
|||
recipe: Some("smelt_iron".into()),
|
||||
progress: 1.5,
|
||||
}),
|
||||
level: Some("cellar.tmx".into()),
|
||||
});
|
||||
// More rows than one insert batch.
|
||||
for i in 0..250 {
|
||||
save.items.push(SavedItem {
|
||||
stack: ItemStack::new("log", 2),
|
||||
pos: GridPos::new(i, 5),
|
||||
level: (i % 2 == 0).then(|| "cellar.tmx".into()),
|
||||
});
|
||||
}
|
||||
save
|
||||
|
|
@ -929,10 +1042,14 @@ mod tests {
|
|||
let rt = runtime();
|
||||
rt.block_on(async {
|
||||
let db = Database::connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::query("ALTER TABLE worlds DROP COLUMN layout")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
for undo in [
|
||||
"ALTER TABLE worlds DROP COLUMN layout",
|
||||
"ALTER TABLE buildings DROP COLUMN level",
|
||||
"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")
|
||||
.execute(&db.pool)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use crate::{
|
|||
config::ServerConfig,
|
||||
movement::MoveIntent,
|
||||
persistence::SavedCharacter,
|
||||
world::{SpawnPoint, WorldLayout, find_free_tile},
|
||||
world::{Level, Levels, ViewLevel, free_tile_on},
|
||||
};
|
||||
|
||||
/// Which client controls a character. Server-only.
|
||||
|
|
@ -242,20 +242,20 @@ fn handle_login(world: &mut World, client: ClientId, login: Login) {
|
|||
return;
|
||||
}
|
||||
|
||||
let levels = world.resource::<Levels>().clone();
|
||||
let saved = match world.resource_mut::<CharacterStore>().0.remove(&player_id) {
|
||||
Some(saved) => saved,
|
||||
None => {
|
||||
let spawn = world.resource::<SpawnPoint>().0;
|
||||
new_character_data(world.resource::<GameData>(), &name, spawn)
|
||||
}
|
||||
None => new_character_data(world.resource::<GameData>(), &name, levels.spawn().1),
|
||||
};
|
||||
let mut buildings =
|
||||
world.query_filtered::<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>();
|
||||
let pos = find_free_tile(
|
||||
saved.pos,
|
||||
&world.resource::<WorldLayout>().0,
|
||||
&buildings.query(world),
|
||||
);
|
||||
let (level, start) = match levels.from_saved(saved.level.as_deref()) {
|
||||
level if levels.layout(&level).is_some() => (level, saved.pos),
|
||||
// The level was removed from the world.
|
||||
_ => levels.spawn(),
|
||||
};
|
||||
let mut buildings = world
|
||||
.query_filtered::<(&Level, &GridPos, &BuildingState), (With<Building>, Without<Character>)>(
|
||||
);
|
||||
let pos = free_tile_on(&level, start, &levels, &buildings.query(world));
|
||||
// The saved spot is only kept if the tile is still free.
|
||||
let position = saved
|
||||
.position
|
||||
|
|
@ -276,11 +276,15 @@ fn handle_login(world: &mut World, client: ClientId, login: Login) {
|
|||
Controller(client),
|
||||
PlayerId(player_id.clone()),
|
||||
MoveIntent::default(),
|
||||
level.clone(),
|
||||
));
|
||||
if admin {
|
||||
entity.insert(Admin);
|
||||
}
|
||||
let entity = entity.id();
|
||||
if let ClientId::Client(client) = client {
|
||||
world.entity_mut(client).insert(ViewLevel(level.0));
|
||||
}
|
||||
world
|
||||
.resource_mut::<Players>()
|
||||
.by_client
|
||||
|
|
@ -435,6 +439,7 @@ pub fn new_character_data(data: &GameData, name: &str, spawn: GridPos) -> SavedC
|
|||
vitals: Vitals::default(),
|
||||
inventory,
|
||||
skills: Skills::default(),
|
||||
level: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -444,8 +449,10 @@ pub fn on_disconnect(
|
|||
mut commands: Commands,
|
||||
mut players: ResMut<Players>,
|
||||
mut store: ResMut<CharacterStore>,
|
||||
levels: Option<Res<Levels>>,
|
||||
characters: Query<(
|
||||
&Character,
|
||||
&Level,
|
||||
&PlayerId,
|
||||
&GridPos,
|
||||
&Position,
|
||||
|
|
@ -458,7 +465,9 @@ pub fn on_disconnect(
|
|||
let Some(entity) = players.by_client.remove(&ClientId::Client(remove.entity)) else {
|
||||
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);
|
||||
store.0.insert(
|
||||
id.0.clone(),
|
||||
|
|
@ -470,6 +479,7 @@ pub fn on_disconnect(
|
|||
vitals: vitals.clone(),
|
||||
inventory: inventory.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::{
|
||||
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).
|
||||
|
|
@ -26,12 +26,13 @@ pub fn tick_survival(
|
|||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut timer: ResMut<SurvivalTimer>,
|
||||
spawn: Option<Res<SpawnPoint>>,
|
||||
levels: Res<Levels>,
|
||||
mut characters: Query<(
|
||||
Entity,
|
||||
&Character,
|
||||
&Controller,
|
||||
&Level,
|
||||
&GridPos,
|
||||
&mut Position,
|
||||
&mut Vitals,
|
||||
&mut Inventory,
|
||||
)>,
|
||||
|
|
@ -40,7 +41,7 @@ pub fn tick_survival(
|
|||
return;
|
||||
}
|
||||
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);
|
||||
if vitals.nutrition <= 0.0 {
|
||||
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.
|
||||
info!("{} died", character.name);
|
||||
for stack in std::mem::take(&mut inventory.slots) {
|
||||
spawn_ground_item(&mut commands, stack, *pos);
|
||||
}
|
||||
if let Some(spawn) = &spawn {
|
||||
*position = Position::center_of(spawn.0);
|
||||
spawn_ground_item(&mut commands, stack, level, *pos);
|
||||
}
|
||||
let (spawn_level, spawn) = levels.spawn();
|
||||
move_character(
|
||||
&mut commands,
|
||||
entity,
|
||||
Some(controller.0),
|
||||
spawn_level,
|
||||
spawn,
|
||||
);
|
||||
*vitals = Vitals {
|
||||
nutrition: 50.0,
|
||||
..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_replicon::prelude::*;
|
||||
use game_core::{
|
||||
GameData,
|
||||
components::{
|
||||
Building, BuildingState, Character, GridPos, GroundItem, ItemStack, Machine, WorldMap,
|
||||
Building, BuildingState, Character, GridPos, GroundItem, ItemStack, Machine, Position,
|
||||
WorldMap,
|
||||
},
|
||||
data::BuildingLayer,
|
||||
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.
|
||||
#[derive(Resource, Clone)]
|
||||
pub struct WorldLayout(pub Arc<MapLayout>);
|
||||
pub use game_core::components::{Level, ViewLevel};
|
||||
|
||||
#[derive(Resource, Clone, Copy)]
|
||||
pub struct SpawnPoint(pub GridPos);
|
||||
/// Where a teleport trigger sends characters.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Destination {
|
||||
pub level: String,
|
||||
pub pos: GridPos,
|
||||
}
|
||||
|
||||
/// All levels of the world. Every level is simulated, whether players are on it or not.
|
||||
#[derive(Resource, Clone, Debug)]
|
||||
pub struct Levels {
|
||||
/// Id of the level new characters spawn on.
|
||||
pub main: String,
|
||||
pub layouts: BTreeMap<String, Arc<MapLayout>>,
|
||||
pub teleports: HashMap<(String, GridPos), Destination>,
|
||||
}
|
||||
|
||||
impl Levels {
|
||||
/// Validates every teleport trigger.
|
||||
pub fn new(
|
||||
main: String,
|
||||
main_layout: MapLayout,
|
||||
others: BTreeMap<String, MapLayout>,
|
||||
) -> Result<Self, String> {
|
||||
let mut layouts: BTreeMap<String, Arc<MapLayout>> = others
|
||||
.into_iter()
|
||||
.map(|(id, layout)| (id, Arc::new(layout)))
|
||||
.collect();
|
||||
layouts.insert(main.clone(), Arc::new(main_layout));
|
||||
let mut teleports = HashMap::new();
|
||||
for (id, layout) in &layouts {
|
||||
for object in layout.objects_of_class("teleport") {
|
||||
let invalid = |message: String| {
|
||||
format!(
|
||||
"level `{id}`, teleport {} (`{}`): {message}",
|
||||
object.id, object.name
|
||||
)
|
||||
};
|
||||
let level = map_io::teleport_level(id, object).map_err(invalid)?;
|
||||
let target_layout = layouts
|
||||
.get(&level)
|
||||
.ok_or_else(|| invalid(format!("unknown map `{level}`")))?;
|
||||
let target = object
|
||||
.properties
|
||||
.get("target")
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or_else(|| invalid("missing `target`".into()))?;
|
||||
let pos = target_layout
|
||||
.objects
|
||||
.iter()
|
||||
.find(|o| o.name == *target)
|
||||
.ok_or_else(|| invalid(format!("no object named `{target}` in `{level}`")))?
|
||||
.pos;
|
||||
teleports.insert((id.clone(), object.pos), Destination { level, pos });
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
main,
|
||||
layouts,
|
||||
teleports,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn layout(&self, level: &Level) -> Option<&MapLayout> {
|
||||
self.layouts.get(&level.0).map(Arc::as_ref)
|
||||
}
|
||||
|
||||
pub fn main_level(&self) -> Level {
|
||||
Level(self.main.clone())
|
||||
}
|
||||
|
||||
pub fn main_layout(&self) -> &MapLayout {
|
||||
&self.layouts[&self.main]
|
||||
}
|
||||
|
||||
/// Where new and respawning characters appear.
|
||||
pub fn spawn(&self) -> (Level, GridPos) {
|
||||
(self.main_level(), map_io::spawn_point(self.main_layout()))
|
||||
}
|
||||
|
||||
/// Saves store `None` for the main level, so it can be renamed.
|
||||
pub fn from_saved(&self, saved: Option<&str>) -> Level {
|
||||
Level(saved.unwrap_or(&self.main).to_owned())
|
||||
}
|
||||
|
||||
pub fn to_saved(&self, level: &Level) -> Option<String> {
|
||||
(level.0 != self.main).then(|| level.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Level id of a map path: its file name.
|
||||
pub fn main_level_id(map: &str) -> String {
|
||||
Path::new(map)
|
||||
.file_name()
|
||||
.map_or_else(|| map.to_owned(), |n| n.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Path of a level's map, given the main map's path.
|
||||
pub fn level_map_path(main_map: &str, level: &str) -> String {
|
||||
Path::new(main_map)
|
||||
.parent()
|
||||
.unwrap_or(Path::new(""))
|
||||
.join(level)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
|
||||
pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data: Res<GameData>) {
|
||||
let seed = || {
|
||||
MapLayout::load(&game_core::assets_dir(), &settings.map)
|
||||
let assets = game_core::assets_dir();
|
||||
let main = main_level_id(&settings.map);
|
||||
let load = |level: &str| {
|
||||
MapLayout::load(&assets, &level_map_path(&settings.map, level))
|
||||
.map_err(map_io::MapIoError::from)
|
||||
.and_then(|layout| map_io::decode(layout, &data))
|
||||
};
|
||||
let seed = || map_io::decode_world(&main, &data, load);
|
||||
let mut save = None;
|
||||
if let Some(storage) = &settings.storage {
|
||||
let (storage, loaded) = match persistence::Storage::open(storage, &settings.map, || {
|
||||
|
|
@ -42,53 +150,89 @@ pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data:
|
|||
}
|
||||
};
|
||||
info!("world `{}` ({})", storage.world.name, storage.world.id);
|
||||
let mut loaded = loaded;
|
||||
if storage.world.map != settings.map {
|
||||
warn!(
|
||||
"world was created on map `{}`, now running `{}`",
|
||||
storage.world.map, settings.map
|
||||
);
|
||||
map_io::rename_main_level(&mut loaded, &main_level_id(&storage.world.map), &main);
|
||||
}
|
||||
save = Some(loaded);
|
||||
commands.insert_resource(storage);
|
||||
}
|
||||
let (layout, save) = match save {
|
||||
let mut save = match save {
|
||||
Some(mut save) => {
|
||||
info!(
|
||||
"restoring persistent world: {} buildings, {} items, {} characters",
|
||||
"restoring persistent world: {} levels, {} buildings, {} items, {} characters",
|
||||
save.levels.len() + 1,
|
||||
save.buildings.len(),
|
||||
save.items.len(),
|
||||
save.characters.len()
|
||||
);
|
||||
let layout = match save.layout.take() {
|
||||
Some(layout) => layout,
|
||||
None => {
|
||||
match MapLayout::load_legacy_terrain(&game_core::assets_dir(), &settings.map) {
|
||||
Ok(layout) => layout,
|
||||
Err(e) => return crate::fail(&mut commands, e.to_string()),
|
||||
}
|
||||
if save.layout.is_none() {
|
||||
match MapLayout::load_legacy_terrain(&assets, &settings.map) {
|
||||
Ok(layout) => save.layout = Some(layout),
|
||||
Err(e) => return crate::fail(&mut commands, e.to_string()),
|
||||
}
|
||||
};
|
||||
(layout, save)
|
||||
}
|
||||
// Levels referenced since the world was saved start from their map.
|
||||
if let Err(e) = map_io::add_referenced_levels(&mut save, &main, &data, load) {
|
||||
return crate::fail(&mut commands, e.to_string());
|
||||
}
|
||||
save
|
||||
}
|
||||
None => {
|
||||
info!("creating a new world from `{}`", settings.map);
|
||||
let mut save = match seed() {
|
||||
match seed() {
|
||||
Ok(save) => save,
|
||||
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)));
|
||||
commands.spawn((
|
||||
Replicated,
|
||||
WorldMap {
|
||||
path: settings.map.clone(),
|
||||
layout: layout.clone(),
|
||||
},
|
||||
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((
|
||||
Replicated,
|
||||
Level(id.clone()),
|
||||
WorldMap {
|
||||
path: id.clone(),
|
||||
layout: (**layout).clone(),
|
||||
},
|
||||
));
|
||||
}
|
||||
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,
|
||||
));
|
||||
persistence::restore(&mut commands, &data, save);
|
||||
commands.insert_resource(WorldLayout(Arc::new(layout)));
|
||||
}
|
||||
|
||||
/// Spawns a building with its default state. `vars` override the prototype's variables.
|
||||
|
|
@ -96,6 +240,7 @@ pub fn spawn_building(
|
|||
commands: &mut Commands,
|
||||
data: &GameData,
|
||||
proto: &str,
|
||||
level: &Level,
|
||||
pos: GridPos,
|
||||
vars: BTreeMap<String, i64>,
|
||||
) -> Option<Entity> {
|
||||
|
|
@ -104,13 +249,14 @@ pub fn spawn_building(
|
|||
state.vars.extend(vars);
|
||||
let machine = data.machines.get(proto).map(Machine::new);
|
||||
Some(spawn_building_with_state(
|
||||
commands, proto, pos, state, machine,
|
||||
commands, proto, level, pos, state, machine,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn spawn_building_with_state(
|
||||
commands: &mut Commands,
|
||||
proto: &str,
|
||||
level: &Level,
|
||||
pos: GridPos,
|
||||
state: BuildingState,
|
||||
machine: Option<Machine>,
|
||||
|
|
@ -120,6 +266,7 @@ pub fn spawn_building_with_state(
|
|||
Building {
|
||||
proto: proto.to_owned(),
|
||||
},
|
||||
level.clone(),
|
||||
pos,
|
||||
state,
|
||||
));
|
||||
|
|
@ -129,8 +276,49 @@ pub fn spawn_building_with_state(
|
|||
entity.id()
|
||||
}
|
||||
|
||||
pub fn spawn_ground_item(commands: &mut Commands, stack: ItemStack, pos: GridPos) -> Entity {
|
||||
commands.spawn((Replicated, GroundItem(stack), pos)).id()
|
||||
pub fn spawn_ground_item(
|
||||
commands: &mut Commands,
|
||||
stack: ItemStack,
|
||||
level: &Level,
|
||||
pos: GridPos,
|
||||
) -> Entity {
|
||||
commands
|
||||
.spawn((Replicated, GroundItem(stack), level.clone(), pos))
|
||||
.id()
|
||||
}
|
||||
|
||||
/// Solid and floor buildings of one level.
|
||||
#[derive(Default)]
|
||||
pub struct Occupancy {
|
||||
solid: HashSet<GridPos>,
|
||||
floors: HashSet<GridPos>,
|
||||
}
|
||||
|
||||
impl Occupancy {
|
||||
pub fn add(&mut self, pos: GridPos, state: &BuildingState) {
|
||||
if state.solid {
|
||||
self.solid.insert(pos);
|
||||
}
|
||||
if state.layer == BuildingLayer::Floor {
|
||||
self.floors.insert(pos);
|
||||
}
|
||||
}
|
||||
|
||||
/// Occupancy of every level.
|
||||
pub fn by_level<'a>(
|
||||
buildings: impl IntoIterator<Item = (&'a Level, &'a GridPos, &'a BuildingState)>,
|
||||
) -> HashMap<&'a str, Self> {
|
||||
let mut levels: HashMap<&str, Self> = HashMap::new();
|
||||
for (level, pos, state) in buildings {
|
||||
levels.entry(&level.0).or_default().add(*pos, state);
|
||||
}
|
||||
levels
|
||||
}
|
||||
|
||||
/// Whether a character can not enter `pos`.
|
||||
pub fn is_blocked(&self, pos: GridPos, layout: &MapLayout) -> bool {
|
||||
self.solid.contains(&pos) || layout.is_solid_with_floor(pos, self.floors.contains(&pos))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a character can not enter `pos`.
|
||||
|
|
@ -151,12 +339,8 @@ pub fn is_blocked<'a>(
|
|||
layout.is_solid_with_floor(pos, has_floor)
|
||||
}
|
||||
|
||||
/// Finds the closest free tile around `start` (spiral search), for spawning characters.
|
||||
pub fn find_free_tile(
|
||||
start: GridPos,
|
||||
layout: &MapLayout,
|
||||
buildings: &Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
|
||||
) -> GridPos {
|
||||
/// Finds the closest free tile around `start` (spiral search), for placing characters.
|
||||
pub fn find_free_tile(start: GridPos, blocked: impl Fn(GridPos) -> bool) -> GridPos {
|
||||
for radius in 0..16i32 {
|
||||
for dy in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
|
|
@ -164,7 +348,7 @@ pub fn find_free_tile(
|
|||
continue;
|
||||
}
|
||||
let pos = GridPos::new(start.x + dx, start.y + dy);
|
||||
if !is_blocked(pos, layout, buildings.iter()) {
|
||||
if !blocked(pos) {
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
|
|
@ -172,3 +356,20 @@ pub fn find_free_tile(
|
|||
}
|
||||
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> {
|
||||
// Server events also go through a local resend before the client observer sees them.
|
||||
app.update();
|
||||
|
|
@ -62,7 +84,7 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
|
|||
use game_server::{
|
||||
commands::{self, CommandSource},
|
||||
map_io, persistence,
|
||||
world::WorldLayout,
|
||||
world::{Level, Levels},
|
||||
};
|
||||
|
||||
let mut app = app();
|
||||
|
|
@ -74,7 +96,7 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
|
|||
.single(app.world())
|
||||
.unwrap();
|
||||
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 ordinary_player = CommandSource::Player {
|
||||
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();
|
||||
edited.items.truncate(1);
|
||||
edited.buildings.retain(|b| b.proto != "tree");
|
||||
map_io::write(&edited, &file).unwrap();
|
||||
edited = map_io::decode(
|
||||
game_core::map::MapLayout::load(&game_core::assets_dir(), filename).unwrap(),
|
||||
app.world().resource::<game_core::GameData>(),
|
||||
)
|
||||
.unwrap();
|
||||
map_io::write(&edited, MAIN, &file).unwrap();
|
||||
edited = decode_project(&dir, app.world().resource::<game_core::GameData>());
|
||||
assert!(
|
||||
edited.levels.contains_key("cellar.tmx"),
|
||||
"levels next to the main map are imported"
|
||||
);
|
||||
let reply = commands::execute(
|
||||
app.world_mut(),
|
||||
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());
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<WorldLayout>()
|
||||
.0
|
||||
.resource::<Levels>()
|
||||
.main_layout()
|
||||
.is_solid(GridPos::new(0, 0))
|
||||
);
|
||||
assert_eq!(imported.buildings, edited.buildings);
|
||||
assert_eq!(imported.items, edited.items);
|
||||
let key = |b: &persistence::SavedBuilding| (b.level.clone(), b.state.layer, b.pos.x, b.pos.y);
|
||||
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!(
|
||||
app.world().get::<Character>(character).is_some(),
|
||||
"online entity must survive"
|
||||
);
|
||||
assert_eq!(
|
||||
app.world().resource::<WorldLayout>().0.as_ref(),
|
||||
edited.layout.as_ref().unwrap()
|
||||
);
|
||||
let mut maps = app.world_mut().query::<&WorldMap>();
|
||||
assert_eq!(
|
||||
&maps.single(app.world()).unwrap().layout,
|
||||
app.world().resource::<Levels>().main_layout(),
|
||||
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(
|
||||
&file,
|
||||
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()))
|
||||
.unwrap();
|
||||
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();
|
||||
let storage = app.world().resource::<persistence::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))
|
||||
.unwrap();
|
||||
expected.characters = imported.characters;
|
||||
let key = |b: &persistence::SavedBuilding| (b.state.layer, b.pos.x, b.pos.y);
|
||||
persisted.buildings.sort_by_key(key);
|
||||
expected.buildings.sort_by_key(key);
|
||||
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.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 storage = app.world().resource::<persistence::Storage>();
|
||||
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_eq!(persistence::snapshot(app.world_mut()), before_failure);
|
||||
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_server::{
|
||||
map_io, persistence,
|
||||
world::{self, WorldLayout},
|
||||
world::{self, Level, Levels},
|
||||
};
|
||||
|
||||
let mut app = app();
|
||||
|
|
@ -243,8 +294,9 @@ fn floors_and_structures_build_and_deconstruct_independently_over_default_terrai
|
|||
.as_mut()
|
||||
.unwrap()
|
||||
.id = 4;
|
||||
let file = std::env::temp_dir().join(format!("station-floors-{}.tmx", uuid::Uuid::new_v4()));
|
||||
map_io::write(&save, &file).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("station-floors-{}", uuid::Uuid::new_v4()));
|
||||
let file = dir.join("edited.tmx");
|
||||
map_io::write(&save, MAIN, &file).unwrap();
|
||||
map_io::import(app.world_mut(), &file).unwrap();
|
||||
|
||||
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);
|
||||
}
|
||||
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(
|
||||
target,
|
||||
&app.world().resource::<WorldLayout>().0,
|
||||
q.iter(app.world()),
|
||||
app.world().resource::<Levels>().main_layout(),
|
||||
q.iter(app.world())
|
||||
.filter(|(_, _, l)| **l == main_level())
|
||||
.map(|(p, s, _)| (p, s)),
|
||||
)
|
||||
};
|
||||
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
|
||||
.iter(app.world())
|
||||
.filter(|(p, _)| **p == target)
|
||||
.map(|(_, state)| state.layer)
|
||||
.filter(|(p, _, l)| **p == target && **l == main_level())
|
||||
.map(|(_, state, _)| state.layer)
|
||||
.collect();
|
||||
layers.sort();
|
||||
layers
|
||||
|
|
@ -326,7 +384,7 @@ fn floors_and_structures_build_and_deconstruct_independently_over_default_terrai
|
|||
send(&mut app, SetMovement { x: 0, y: 0 });
|
||||
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();
|
||||
assert_eq!(project_file, project.join(map_io::PROJECT_FILE));
|
||||
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!(project.join("extensions/station.js").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!(
|
||||
map.contains(r#"source="tilesets/"#),
|
||||
"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 {
|
||||
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<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
images(&app.world().resource::<WorldLayout>().0.tilesets),
|
||||
images(&app.world().resource::<Levels>().main_layout().tilesets),
|
||||
images(&tilesets),
|
||||
"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 });
|
||||
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(project).unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -387,10 +449,10 @@ fn painted_walls_are_deconstructible_without_leaving_static_collision() {
|
|||
let target = GridPos::new(0, 0);
|
||||
let wall = app
|
||||
.world_mut()
|
||||
.query::<(Entity, &Building, &GridPos)>()
|
||||
.query::<(Entity, &Building, &GridPos, &game_server::world::Level)>()
|
||||
.iter(app.world())
|
||||
.find(|(_, b, p)| b.proto == "wall" && **p == target)
|
||||
.map(|(entity, _, _)| entity)
|
||||
.find(|(_, b, p, l)| b.proto == "wall" && **p == target && **l == main_level())
|
||||
.map(|(entity, _, _, _)| entity)
|
||||
.unwrap();
|
||||
assert_eq!(app.world().get::<BuildingState>(wall).unwrap().health, 300);
|
||||
app.world_mut()
|
||||
|
|
@ -403,11 +465,18 @@ fn painted_walls_are_deconstructible_without_leaving_static_collision() {
|
|||
.add("wrench", 1, &data);
|
||||
send(&mut app, Deconstruct { target });
|
||||
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(
|
||||
target,
|
||||
&app.world().resource::<game_server::world::WorldLayout>().0,
|
||||
buildings.iter(app.world())
|
||||
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);
|
||||
}
|
||||
|
|
@ -521,6 +590,7 @@ fn login_move_and_interact() {
|
|||
proto: "door".into(),
|
||||
},
|
||||
door_pos,
|
||||
main_level(),
|
||||
BuildingState {
|
||||
solid: true,
|
||||
..default()
|
||||
|
|
@ -572,6 +642,128 @@ fn login_move_and_interact() {
|
|||
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]
|
||||
fn machines_process_items() {
|
||||
let mut app = app();
|
||||
|
|
@ -803,8 +995,21 @@ mod network {
|
|||
join(&mut host, HOST, "Host", "");
|
||||
let mut guest = client(addr);
|
||||
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 file = dir.join(map_io::PROJECT_MAP);
|
||||
let file = dir.join(super::MAIN);
|
||||
host.world_mut().client_trigger(Chat {
|
||||
text: format!("/export {}", dir.display()),
|
||||
});
|
||||
|
|
@ -828,7 +1033,7 @@ mod network {
|
|||
flip_d: false,
|
||||
});
|
||||
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 {
|
||||
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([18, 18, 23, 23], fill=(180, 110, 90)))
|
||||
|
||||
def stairs(down):
|
||||
def f(d):
|
||||
d.rectangle([0, 0, T - 1, T - 1], fill=(60, 55, 50), outline=(30, 28, 25), width=2)
|
||||
for i in range(4):
|
||||
# Steps shrink and darken towards the far end of the flight.
|
||||
inset = 3 + i * 3 if down else 12 - i * 3
|
||||
shade = 150 - i * 25 if down else 75 + i * 25
|
||||
y = 3 + i * 7
|
||||
d.rectangle([inset, y, T - 1 - inset, y + 5], fill=(shade, shade - 10, shade - 25))
|
||||
tip, base = (27, 20) if down else (5, 12)
|
||||
d.polygon([(13, base), (19, base), (16, tip)], fill=(230, 200, 80))
|
||||
return f
|
||||
|
||||
sprite("sprites/buildings/stairs_down.png", stairs(True))
|
||||
sprite("sprites/buildings/stairs_up.png", stairs(False))
|
||||
|
||||
# Characters
|
||||
sprite("sprites/characters/player.png",
|
||||
lambda d: d.ellipse([10, 3, 22, 14], fill=(240, 200, 160), outline=(60, 40, 30)),
|
||||
|
|
@ -282,26 +298,8 @@ def gen_map():
|
|||
for y in range(1, 26):
|
||||
ground[y][37] = DIRT
|
||||
|
||||
def csv(layer):
|
||||
return ",\n".join(",".join("0" if t is None else str(t + 1) for t in row) for row in layer)
|
||||
|
||||
objects = []
|
||||
next_id = [1]
|
||||
|
||||
def obj(cls, x, y, name="", **props):
|
||||
oid = next_id[0]
|
||||
next_id[0] += 1
|
||||
prop_xml = ""
|
||||
if props:
|
||||
items = []
|
||||
for k, v in props.items():
|
||||
typ = ' type="int"' if isinstance(v, int) else ""
|
||||
items.append(f' <property name="{k}"{typ} value="{v}"/>')
|
||||
prop_xml = "\n <properties>\n" + "\n".join(items) + "\n </properties>\n "
|
||||
objects.append(
|
||||
f' <object id="{oid}" name="{name}" class="{cls}" x="{x * T + T // 2}" y="{y * T + T // 2}">{prop_xml}<point/>\n </object>'
|
||||
)
|
||||
|
||||
objects, triggers = MapObjects(), MapObjects()
|
||||
obj = objects.point
|
||||
obj("spawn", 10, 8, "arrivals")
|
||||
obj("building", 18, 8, proto="door")
|
||||
obj("building", 8, 14, proto="door")
|
||||
|
|
@ -325,28 +323,83 @@ def gen_map():
|
|||
obj("building", x, y, proto="ore_rock")
|
||||
obj("item", 25, 22, proto="apple", count=3)
|
||||
obj("item", 14, 25, proto="log", count=4)
|
||||
obj("building", 16, 12, proto="stairs_down")
|
||||
triggers.teleport(16, 12, "stairs_down", "cellar.tmx", "stairs_up")
|
||||
write_map("station.tmx", W, H, ground, walls, objects, triggers)
|
||||
|
||||
|
||||
def gen_cellar():
|
||||
w, h = 14, 10
|
||||
ground = [[DARK] * w for _ in range(h)]
|
||||
walls = [[WALL if x in (0, w - 1) or y in (0, h - 1) else None for x in range(w)] for y in range(h)]
|
||||
objects, triggers = MapObjects(), MapObjects()
|
||||
objects.point("building", 2, 2, proto="stairs_up")
|
||||
objects.point("building", 11, 2, proto="furnace")
|
||||
objects.point("building", 11, 7, proto="table")
|
||||
objects.point("item", 10, 7, proto="iron_ore", count=4)
|
||||
objects.point("item", 6, 6, proto="log", count=2)
|
||||
triggers.teleport(2, 2, "stairs_up", "station.tmx", "stairs_down")
|
||||
write_map("cellar.tmx", w, h, ground, walls, objects, triggers)
|
||||
|
||||
|
||||
class MapObjects:
|
||||
"""Objects of one object layer; ids are unique per map via the shared counter."""
|
||||
|
||||
next_id = 1
|
||||
|
||||
def __init__(self):
|
||||
self.xml = []
|
||||
|
||||
def _add(self, cls, name, geometry, props, body=""):
|
||||
oid = MapObjects.next_id
|
||||
MapObjects.next_id += 1
|
||||
prop_xml = ""
|
||||
if props:
|
||||
items = []
|
||||
for k, (typ, v) in props.items():
|
||||
typ = f' type="{typ}"' if typ else ""
|
||||
items.append(f' <property name="{k}"{typ} value="{v}"/>')
|
||||
prop_xml = "\n <properties>\n" + "\n".join(items) + "\n </properties>"
|
||||
self.xml.append(f' <object id="{oid}" name="{name}" class="{cls}" {geometry}>{prop_xml}{body}\n </object>')
|
||||
|
||||
def point(self, cls, x, y, name="", **props):
|
||||
props = {k: ("int" if isinstance(v, int) else None, v) for k, v in props.items()}
|
||||
self._add(cls, name, f'x="{x * T + T // 2}" y="{y * T + T // 2}"', props, "\n <point/>")
|
||||
|
||||
def teleport(self, x, y, name, map_file, target):
|
||||
props = {"map": ("file", map_file), "target": (None, target)}
|
||||
self._add("teleport", name, f'x="{x * T}" y="{y * T}" width="{T}" height="{T}"', props)
|
||||
|
||||
|
||||
def write_map(file, w, h, ground, walls, objects, triggers):
|
||||
def csv(layer):
|
||||
return ",\n".join(",".join("0" if t is None else str(t + 1) for t in row) for row in layer)
|
||||
|
||||
tmx = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="{W}" height="{H}" tilewidth="{T}" tileheight="{T}" infinite="0" nextlayerid="4" nextobjectid="{next_id[0]}">
|
||||
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="{w}" height="{h}" tilewidth="{T}" tileheight="{T}" infinite="0" nextlayerid="5" nextobjectid="{MapObjects.next_id}">
|
||||
<properties><property name="default_tile" type="int" value="6"/></properties>
|
||||
<tileset firstgid="1" source="../tilesets/station.tsx"/>
|
||||
<layer id="1" name="Ground" width="{W}" height="{H}">
|
||||
<layer id="1" name="Ground" width="{w}" height="{h}">
|
||||
<data encoding="csv">
|
||||
{csv(ground)}
|
||||
</data>
|
||||
</layer>
|
||||
<layer id="2" name="Walls" width="{W}" height="{H}">
|
||||
<layer id="2" name="Walls" width="{w}" height="{h}">
|
||||
<data encoding="csv">
|
||||
{csv(walls)}
|
||||
</data>
|
||||
</layer>
|
||||
<objectgroup id="3" name="Objects">
|
||||
{chr(10).join(objects)}
|
||||
{chr(10).join(objects.xml)}
|
||||
</objectgroup>
|
||||
<objectgroup id="4" name="Triggers">
|
||||
{chr(10).join(triggers.xml)}
|
||||
</objectgroup>
|
||||
</map>
|
||||
"""
|
||||
(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__":
|
||||
|
|
@ -354,4 +407,5 @@ if __name__ == "__main__":
|
|||
gen_sprites()
|
||||
gen_ui()
|
||||
gen_map()
|
||||
gen_cellar()
|
||||
print("placeholder assets written to", ASSETS)
|
||||
|
|
|
|||
Loading…
Reference in New Issue