From 3a245315295d88bd5ac5ea72cb4ab7034807ed0b Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Thu, 22 Oct 2026 02:13:09 +0200 Subject: [PATCH] feat!: simulate multiple map levels connected by teleports Worlds now consist of levels: the server's map plus every map reached through `teleport` triggers (`target` object name, optional `map` file property relative to the map). All levels are simulated all the time; replicon only sends clients their character's level, and singleplayer hides other levels client-side. Interactions, construction, machines and item drops are confined to the actor's level. The default map gains stairs in the main hall leading to a new cellar level and stairs back up. Exports write every level's map, naming the main map after the server's map; import validates and replaces all levels and moves characters on removed levels to the spawn point. BREAKING CHANGE: save schema version 3 stores entity levels and level terrain. Exported main maps are named after the server's map (for example `station.tmx`) instead of `world.tmx`. `Level` is a new replicated component, changing the network protocol. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 15 +- README.md | 43 +- assets/data/buildings.rsn | 10 + assets/maps/cellar.tmx | 75 +++ assets/maps/station.tmx | 19 +- assets/sprites/buildings/stairs_down.png | Bin 0 -> 264 bytes assets/sprites/buildings/stairs_up.png | Bin 0 -> 250 bytes crates/game_client/src/hud.rs | 4 +- crates/game_client/src/input.rs | 6 +- crates/game_client/src/machine.rs | 6 +- crates/game_client/src/render.rs | 37 ++ crates/game_core/src/components.rs | 22 + crates/game_core/src/map.rs | 2 + crates/game_core/src/protocol.rs | 1 + crates/game_server/src/actions.rs | 122 +++-- crates/game_server/src/lib.rs | 5 +- crates/game_server/src/machines.rs | 33 +- crates/game_server/src/map_io.rs | 563 ++++++++++++++++++++--- crates/game_server/src/movement.rs | 76 ++- crates/game_server/src/persistence.rs | 185 ++++++-- crates/game_server/src/players.rs | 36 +- crates/game_server/src/survival.rs | 22 +- crates/game_server/src/world.rs | 291 ++++++++++-- crates/game_server/tests/simulation.rs | 295 ++++++++++-- tools/gen_placeholder_assets.py | 104 ++++- 25 files changed, 1637 insertions(+), 335 deletions(-) create mode 100644 assets/maps/cellar.tmx create mode 100644 assets/sprites/buildings/stairs_down.png create mode 100644 assets/sprites/buildings/stairs_up.png diff --git a/CHANGELOG.md b/CHANGELOG.md index acb3bfc..6af64ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,25 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Breaking:** worlds consist of levels: the server's map plus every map reached + through `teleport` triggers. All levels are simulated even when nobody is on them, + and clients only receive their character's level. The save schema (version 3) + stores each entity's level and the levels' terrain. - **Breaking:** `export [DIR]` writes a ready-to-use Tiled project directory - (`world.tmx`, `station.tiled-project`, the Station extensions and relative - tileset image copies) instead of a single map file; `import` accepts that - directory or a map file. + (the main map named after the server's map, e.g. `station.tmx`, every level's + map, `station.tiled-project`, the Station extensions and relative tileset image + copies) instead of a single map file; `import` accepts that directory or the + main map file and replaces all levels. - Tiled exports and newly created conversion layers place Objects above Ground Items, Triggers, Wiring/Plumbing and Floors, with Floors and Wiring/Plumbing hidden by default in the editor without affecting gameplay. ### Added +- `teleport` triggers in Tiled maps (`target` object name, optional `map` file) + move characters between places and levels. +- Stairs in the default map's main hall lead to a new cellar level and back up. + - **Map > Station: Convert All Project Maps** in Tiled converts painted gameplay tiles in every map of the opened project at once. - A Station Object Brush in Tiled with per-tile brush property settings and diff --git a/README.md b/README.md index b5aefb9..efb9c95 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,11 @@ Layer/group offsets are not supported. Machines accept `machine.inputs`, `machine.outputs`, `machine.recipe` and `machine.progress`. - `item` – custom property `proto` = item id and optional `count`. + - `teleport` – a trigger that moves characters entering its tile to the object + named by the string property `target`. The optional **file** property `map` + names the target's map, relative to this map; without it the target is on the + same map. Arrival does not trigger the destination's own teleport; step off + and back on to use it. The arrival tile falls back to the nearest free tile. - Objects are placed on the tile containing their position (point objects work well). Tile objects inherit gameplay properties from their tiles; object properties override them. Painted and explicit buildings share the same occupancy checks. @@ -402,9 +407,24 @@ tile with `proto`, and cannot be built/deconstructed itself. The starter map uses dirt (tile 6). Maps remain finite: outside their bounds is always blocked. Old maps without a default retain their empty, passable background. +#### Levels + +The server's map (`station.tmx` by default) is the main level. Every map reached +through `teleport` triggers, directly or via other levels, is loaded as another +level, up to 64 per world. A level is identified by its path relative to the main +map's directory and must stay inside that directory. All levels are simulated +all the time, whether or not anyone is on them, so machines keep working and +scripts keep running. Clients only receive the level their character is on. +Characters, buildings and ground items remember their level in the save. +Teleports are validated when a world loads or imports: unknown maps or missing +targets are errors. The default map has stairs in the main hall leading to +`cellar.tmx` and back up. + A default map seeds a new world; after that the database stores the authoritative -terrain and entity state. Existing saves acquire their terrain snapshot on their -next save. To apply a map to an existing world, use the admin `import` command. +terrain and entity state of every level. Existing saves acquire their terrain +snapshot on their next save. Levels referenced since a world was saved (for example +the cellar, for worlds created before it existed) are seeded from their maps. To +apply a map to an existing world, use the admin `import` command. Existing saved terrain is not automatically converted or reseeded on startup: import a marked map to convert old static walls/floors. Legacy saves without a terrain snapshot retain static seed terrain until explicitly imported. @@ -479,7 +499,8 @@ and coordinate conventions; conversion is optional before loading a map. PostgreSQL it defaults to `saves//`; 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; + - `.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 diff --git a/assets/data/buildings.rsn b/assets/data/buildings.rsn index 6936e03..0fc4061 100644 --- a/assets/data/buildings.rsn +++ b/assets/data/buildings.rsn @@ -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, diff --git a/assets/maps/cellar.tmx b/assets/maps/cellar.tmx new file mode 100644 index 0000000..f63fab0 --- /dev/null +++ b/assets/maps/cellar.tmx @@ -0,0 +1,75 @@ + + + + + + +2,2,2,2,2,2,2,2,2,2,2,2,2,2, +2,2,2,2,2,2,2,2,2,2,2,2,2,2, +2,2,2,2,2,2,2,2,2,2,2,2,2,2, +2,2,2,2,2,2,2,2,2,2,2,2,2,2, +2,2,2,2,2,2,2,2,2,2,2,2,2,2, +2,2,2,2,2,2,2,2,2,2,2,2,2,2, +2,2,2,2,2,2,2,2,2,2,2,2,2,2, +2,2,2,2,2,2,2,2,2,2,2,2,2,2, +2,2,2,2,2,2,2,2,2,2,2,2,2,2, +2,2,2,2,2,2,2,2,2,2,2,2,2,2 + + + + +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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/maps/station.tmx b/assets/maps/station.tmx index e9c354c..0d5334c 100644 --- a/assets/maps/station.tmx +++ b/assets/maps/station.tmx @@ -1,5 +1,5 @@ - + @@ -67,7 +67,8 @@ - + + @@ -242,5 +243,19 @@ + + + + + + + + + + + + + + diff --git a/assets/sprites/buildings/stairs_down.png b/assets/sprites/buildings/stairs_down.png new file mode 100644 index 0000000000000000000000000000000000000000..33a4629192474308cfb71252203ea03cb123ab3e GIT binary patch literal 264 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJ^PVn_Ar*6yr%ays=e$8fL#Bdm zdIAtM8a`Q~`ZDX|;^pT9We)&>faoh<@zc}S-s4K=uMp|6O1Q$xZ9l_zvnEN#n|7(Sk4d_*uXdIQ$*pZ ztG>T{Cq**LF{M~COxO4PYRk+#F|fhb^t9kgK6?dAmLEG6EVh0U?5fCms?1iWH|eeX zqD%(1goO=qQYFOj@^CE@^ykJt8e8RmCoS1j8|Axrnm3_ zLFweW;IoX(a!kzc7#1yRm~q9Yl-=T%MBvp$f8Ea|ipqb`kSIN{g1O)sQ_RX~%lJCD wzAo!tdO9q>bu+sSmz%7?D~4l&_pR9&HiW6^xvt%j2lNbsr>mdKI;Vst0JlwI^8f$< literal 0 HcmV?d00001 diff --git a/crates/game_client/src/hud.rs b/crates/game_client/src/hud.rs index bcd9dc7..c22e03a 100644 --- a/crates/game_client/src/hud.rs +++ b/crates/game_client/src/hud.rs @@ -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, local: Res, characters: Query<(&GridPos, &Inventory, &Skills)>, - buildings: Query<(&Building, &GridPos)>, + buildings: Query<(&Building, &GridPos), Without>, mut slots: Query<(Entity, &mut WidgetSlot)>, ) { let Some((pos, inventory, skills)) = local.0.and_then(|e| characters.get(e).ok()) else { diff --git a/crates/game_client/src/input.rs b/crates/game_client/src/input.rs index 76a692b..de1c4c1 100644 --- a/crates/game_client/src/input.rs +++ b/crates/game_client/src/input.rs @@ -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, local: Res, characters: Query<(&GridPos, &Facing, &Inventory), With>, - items: Query<&GridPos, With>, + items: Query<&GridPos, (With, Without)>, ) { if keys.just_pressed(KeyCode::KeyH) { ui.toggle(Menu::Help); @@ -241,7 +241,7 @@ fn mouse( camera: Query<(&Camera, &GlobalTransform)>, local: Res, characters: Query<&GridPos, With>, - buildings: Query<(&GridPos, &BuildingState), With>, + buildings: Query<(&GridPos, &BuildingState), (With, Without)>, ) { if !buttons.just_pressed(MouseButton::Left) || ui.blocks_game() diff --git a/crates/game_client/src/machine.rs b/crates/game_client/src/machine.rs index 8e926d3..7faf69b 100644 --- a/crates/game_client/src/machine.rs +++ b/crates/game_client/src/machine.rs @@ -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, mut ui: ResMut, - machines: Query<(Entity, &GridPos), With>, + machines: Query<(Entity, &GridPos), (With, Without)>, ) { match machines.iter().find(|(_, p)| **p == ev.0) { Some((entity, _)) => { @@ -77,7 +77,7 @@ fn close_when_invalid( mut open: ResMut, local: Res, positions: Query<&GridPos>, - machines: Query<&GridPos, With>, + machines: Query<&GridPos, (With, Without)>, ) { let Some(machine) = open.0 else { return }; let me = local.0.and_then(|e| positions.get(e).ok()); diff --git a/crates/game_client/src/render.rs b/crates/game_client/src/render.rs index 00e78d7..a84bba2 100644 --- a/crates/game_client/src/render.rs +++ b/crates/game_client/src/render.rs @@ -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, + levels: Query<&Level>, + mut entities: Query<(Entity, &Level, Option<&mut Visibility>, Has)>, +) { + 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::(); + } else if !on_level && !off { + commands.entity(entity).insert(OffLevel); + } + } +} + fn update_marker( local: Res, 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); } diff --git a/crates/game_core/src/components.rs b/crates/game_core/src/components.rs index 28ed2ac..9f71827 100644 --- a/crates/game_core/src/components.rs +++ b/crates/game_core/src/components.rs @@ -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::*; diff --git a/crates/game_core/src/map.rs b/crates/game_core/src/map.rs index f4c0859..28c7130 100644 --- a/crates/game_core/src/map.rs +++ b/crates/game_core/src/map.rs @@ -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. diff --git a/crates/game_core/src/protocol.rs b/crates/game_core/src/protocol.rs index b0c412b..a8e9988 100644 --- a/crates/game_core/src/protocol.rs +++ b/crates/game_core/src/protocol.rs @@ -135,6 +135,7 @@ impl Plugin for ProtocolPlugin { .replicate::() .replicate::() .replicate::() + .replicate::() .add_client_event::(Channel::Ordered) .add_client_event::(Channel::Ordered) .add_client_event::(Channel::Ordered) diff --git a/crates/game_server/src/actions.rs b/crates/game_server/src/actions.rs index 34f919d..e527f9c 100644 --- a/crates/game_server/src/actions.rs +++ b/crates/game_server/src/actions.rs @@ -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, +>; +type GroundItems<'w, 's> = Query< + 'w, + 's, + ( + Entity, + &'static mut GroundItem, + &'static GridPos, + &'static Level, ), Without, >; -type GroundItems<'w, 's> = - Query<'w, 's, (Entity, &'static mut GroundItem, &'static GridPos), Without>; fn face_towards(facing: &mut Mut, 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, engine: Res, mut characters: Characters, - buildings: Query<(&Building, &GridPos), Without>, + buildings: Query<(&Building, &GridPos, &Level), Without>, ) { 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 = 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, data: Res, - layout: Res, + levels: Res, mut characters: Characters, - buildings: Query<(&GridPos, &BuildingState), (With, Without)>, - occupants: Query<&Position, (With, Without)>, + buildings: Query<(&GridPos, &BuildingState, &Level), (With, Without)>, + occupants: Query<(&Position, &Level), (With, Without)>, ) { let client = ev.client_id; let target = ev.message.target; let Some(&entity) = players.by_client.get(&client) else { return; }; - // Checked before borrowing the actor mutably. - let occupied = occupants - .iter() - .any(|p| movement::overlaps_tile(p.0, target)); - let Ok((_, pos, mut facing, mut inventory, _, mut skills)) = characters.get_mut(entity) else { + let Ok((_, pos, mut facing, mut inventory, _, mut skills, level)) = characters.get_mut(entity) + else { return; }; + let level = level.clone(); + let Some(layout) = levels.layout(&level) else { + return; + }; + let occupied = occupants + .iter() + .any(|(p, l)| *l == level && movement::overlaps_tile(p.0, target)); if pos.distance(target) > REACH { tell(&mut commands, client, "That is too far away."); return; @@ -468,14 +486,17 @@ pub fn on_construct( return; }; let floor = def.layer == game_core::data::BuildingLayer::Floor; - let has_floor = buildings - .iter() - .any(|(p, state)| *p == target && state.layer == game_core::data::BuildingLayer::Floor); - if layout.0.is_solid_with_floor(target, floor || has_floor) - || buildings.iter().any(|(p, state)| { - *p == target - && (state.layer == def.layer - || (floor && state.layer == game_core::data::BuildingLayer::Structure)) + let here = || { + buildings + .iter() + .filter(|(p, _, l)| **p == target && **l == level) + }; + let has_floor = + here().any(|(_, state, _)| state.layer == game_core::data::BuildingLayer::Floor); + if layout.is_solid_with_floor(target, floor || has_floor) + || here().any(|(_, state, _)| { + state.layer == def.layer + || (floor && state.layer == game_core::data::BuildingLayer::Structure) }) { tell(&mut commands, client, "There is no room to build there."); @@ -491,7 +512,14 @@ pub fn on_construct( Ok(level_up) => { *inventory = new_inventory; *skills = new_skills; - spawn_building(&mut commands, &data, &def.id, target, Default::default()); + spawn_building( + &mut commands, + &data, + &def.id, + &level, + target, + Default::default(), + ); tell(&mut commands, client, format!("You build a {}.", def.name)); announce_level_up(&mut commands, client, &data, level_up); } @@ -512,6 +540,7 @@ pub fn on_deconstruct( &GridPos, &BuildingState, Option<&Machine>, + &Level, ), Without, >, @@ -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( diff --git a/crates/game_server/src/lib.rs b/crates/game_server/src/lib.rs index de0e3e0..28c2bda 100644 --- a/crates/game_server/src/lib.rs +++ b/crates/game_server/src/lib.rs @@ -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::() .init_resource::() .init_resource::() + .add_visibility_filter::() .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::), + .run_if(resource_exists::), ) .add_systems( Update, diff --git a/crates/game_server/src/machines.rs b/crates/game_server/src/machines.rs index a2d1307..6cff7ea 100644 --- a/crates/game_server/src/machines.rs +++ b/crates/game_server/src/machines.rs @@ -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, +>; fn machine_in_reach<'a>( + level: &Level, pos: GridPos, target: GridPos, - machines: &'a mut Query<(&GridPos, &Building, &mut Machine), Without>, + 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, data: Res, mut characters: Query, With>, - mut machines: Query<(&GridPos, &Building, &mut Machine), Without>, + 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, data: Res, mut characters: Query, With>, - mut machines: Query<(&GridPos, &Building, &mut Machine), Without>, + 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), }; diff --git a/crates/game_server/src/map_io.rs b/crates/game_server/src/map_io.rs index 77674c8..2e16153 100644 --- a/crates/game_server/src/map_io.rs +++ b/crates/game_server/src/map_io.rs @@ -4,11 +4,14 @@ use std::{ collections::{BTreeMap, BTreeSet}, io, path::{Path, PathBuf}, - sync::Arc, }; use bevy::prelude::*; -use game_core::{GameData, components::*, map::MapLayout}; +use game_core::{ + GameData, + components::*, + map::{MapLayout, MapObject}, +}; use quick_xml::{ Writer, events::{BytesEnd, BytesStart, BytesText, Event}, @@ -17,10 +20,14 @@ use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; use crate::{ - persistence::{self, SaveGame, SavedBuilding, SavedItem, Storage}, - world::{SpawnPoint, WorldLayout, spawn_building_with_state, spawn_ground_item}, + persistence::{self, SaveGame, SavedBuilding, SavedCharacter, SavedItem, Storage}, + players::{CharacterStore, Controller}, + world::{Level, Levels, install_levels, move_character}, }; +/// Upper bound for levels reachable through teleports, against runaway references. +pub const MAX_LEVELS: usize = 64; + #[derive(Debug, thiserror::Error)] pub enum MapIoError { #[error("{0}")] @@ -49,8 +56,15 @@ pub fn decode(layout: MapLayout, data: &GameData) -> Result continue, + "teleport" => { + if object.properties.get("target").is_none_or(|t| t.is_empty()) { + return Err(invalid("teleport without `target`".into())); + } + continue; + } + _ => {} } let proto = object .properties @@ -125,6 +139,7 @@ pub fn decode(layout: MapLayout, data: &GameData) -> Result { @@ -137,17 +152,195 @@ pub fn decode(layout: MapLayout, data: &GameData) -> Result 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 { + 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 { + 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, 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, +) -> Result { + 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, +) -> Result { + 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, +) -> 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, +) -> Result<(), MapIoError> { + let mut queue = vec![main.to_owned()]; + queue.extend(save.levels.keys().cloned()); + let mut known: BTreeSet = 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( 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 ` (or `import ` 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::().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 { + world + .get_resource::() + .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 { + let main = main_level(world)?; let mut save = persistence::snapshot(world); let data = world.resource::(); - 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 { } } } - 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 { - 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 { + 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 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::())?; + 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::(), |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::() { // 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::, With)>>(); + for character in world.resource_mut::().0.values_mut() { + relocate_from_removed_level(&levels, character); + } + let mut query = + world.query_filtered::<(Entity, &Level, Option<&Controller>), With>(); + 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::, With, With)>>(); 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::().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 { - 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 { + write_levels(save, main, path, ImageSources::Assets) } -fn write_map(save: &SaveGame, path: &Path, images: ImageSources) -> Result { +fn write_levels( + save: &SaveGame, + main: &str, + path: &Path, + images: ImageSources, +) -> Result { 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 { 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 Result Result Result 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 { 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); diff --git a/crates/game_server/src/movement.rs b/crates/game_server/src/movement.rs index 8709292..ca65749 100644 --- a/crates/game_server/src/movement.rs +++ b/crates/game_server/src/movement.rs @@ -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 { pub fn move_characters( time: Res