feat!: simulate multiple map levels connected by teleports
Test / Clippy and tests (nextest) (push) Failing after 1m2s Details
Windows package / Build Windows package (push) Failing after 53s Details

Worlds now consist of levels: the server's map plus every map reached
through `teleport` triggers (`target` object name, optional `map` file
property relative to the map). All levels are simulated all the time;
replicon only sends clients their character's level, and singleplayer
hides other levels client-side. Interactions, construction, machines and
item drops are confined to the actor's level.

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

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-22 02:13:09 +02:00
parent 823f0f76e6
commit 3a24531529
25 changed files with 1637 additions and 335 deletions

View File

@ -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

View File

@ -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

View File

@ -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,

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

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

View File

@ -1,5 +1,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

View File

@ -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 {

View File

@ -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()

View File

@ -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());

View File

@ -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);
}

View File

@ -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::*;

View File

@ -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.

View File

@ -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)

View File

@ -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
let here = || {
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))
.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(

View File

@ -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,

View File

@ -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),
};

View File

@ -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,9 +56,16 @@ 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" {
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
.get("proto")
@ -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);

View File

@ -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(&current) {
continue;
}
let destination = last
.is_some()
.then(|| levels.teleports.get(&(level.0.clone(), *tile)))
.flatten();
let Some(destination) = destination else {
commands.entity(entity).insert(current);
continue;
};
let target = Level(destination.level.clone());
let pos = free_tile_on(&target, destination.pos, &levels, &buildings);
move_character(&mut commands, entity, controller.map(|c| c.0), target, pos);
}
}
/// Tiles overlapped by a character's collision box at `pos`.
fn box_tiles(pos: Vec2) -> impl Iterator<Item = GridPos> {
let min = (pos - CHARACTER_RADIUS).floor().as_ivec2();

View File

@ -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,8 +382,19 @@ 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")
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?;
@ -371,9 +405,11 @@ 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")
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?;
@ -382,6 +418,7 @@ impl Database {
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

View File

@ -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)),
},
);
}

View File

@ -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()

View File

@ -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,
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)));
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: settings.map.clone(),
layout: layout.clone(),
path: id.clone(),
layout: (**layout).clone(),
},
));
persistence::restore(&mut commands, &data, save);
commands.insert_resource(WorldLayout(Arc::new(layout)));
}
commands.insert_resource(levels);
}
/// Moves a character to a tile, possibly on another level, and makes its
/// client follow. Teleport triggers on the arrival tile only fire after the
/// character stepped off it.
pub fn move_character(
commands: &mut Commands,
entity: Entity,
client: Option<ClientId>,
level: Level,
tile: GridPos,
) {
if let Some(ClientId::Client(client)) = client {
commands.entity(client).insert(ViewLevel(level.0.clone()));
}
commands.entity(entity).insert((
LastTile(level.clone(), tile),
level,
Position::center_of(tile),
tile,
));
}
/// Spawns a building with its default state. `vars` override the prototype's variables.
@ -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))
}

View File

@ -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()),
});

View File

@ -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)