diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 08e3288..1f27a51 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -47,6 +47,13 @@ jobs: - name: Test run: cargo nextest run --workspace --locked --profile ci + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Test Tiled extension + run: node --test tools/tiled.test.cjs + - name: Upload test report if: always() uses: actions/upload-artifact@v3 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e14ded8..581ac6e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,6 +39,13 @@ jobs: - name: Test run: cargo nextest run --workspace --locked --profile ci + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Test Tiled extension + run: node --test tools/tiled.test.cjs + - name: Upload test report if: always() uses: actions/upload-artifact@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 28365a7..dc5d94c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- Separate gameplay floor and structure layers, with buildable/removable floors + and windows. Marked painted Tiled tiles become stateful gameplay entities; + removing floors exposes the map's configurable default terrain. +- A bundled Tiled extension to convert painted gameplay tiles into editable + objects, preserving properties and flips with conflict checks and undo support. - Admin `export [FILE]` and `import [FILE]` commands for editable Tiled snapshots of terrain, buildings and ground items, including health, script variables, machine contents and processing state. Imports replace the map while preserving characters. @@ -59,7 +64,7 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Save databases migrate automatically to schema version 2. - Characters move freely instead of tile by tile: diagonal movement, sliding along walls and around corners. Their exact position is saved. -- **Breaking:** network protocol version 5. Authoritative terrain is replicated, +- **Breaking:** network protocol version 6. Layered building state and authoritative terrain are replicated, logins carry a player id and password, and movement is sent as a direction vector, so older clients and servers can't connect. Characters saved by name in existing worlds are not carried over to player ids. diff --git a/README.md b/README.md index 9b49c26..0b07a2d 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ Required: `id`, `inputs`, `outputs`. Optional: `name`, `tools` (needed but not c ### Buildings -Anything placed on the grid: walls, doors, machines, trees, rocks. +Anything placed on the grid: floors, walls, doors, machines, trees, rocks. ```rsn buildings: [ @@ -282,10 +282,16 @@ buildings: [ ``` Required: `id`, `name`, `sprite`. Optional: `description`, `variants` (name → sprite), -`solid` (default true), `max_health`, `stations` (tags for recipes), `construction` +`layer` (`"structure"` by default, or `"floor"`), `solid` (default true), +`max_health`, `stations` (tags for recipes), `construction` (makes it buildable from the build menu), `deconstructible` (default true; refunds materials), `on_interact` (script hook), `vars` (integer state, persisted per building). +Each cell can hold one floor and one structure, plus ground items. Floors render +below structures and have the same health, script state and construction mechanics. +Interactions and deconstruction target the structure first, then the floor once +the structure is gone. Remove a structure before replacing the floor underneath it. + ### Machines A machine adds item slots and its own recipes to the building with the same `id`. @@ -361,23 +367,70 @@ pub fn toggle(ctx) { Maps are finite orthogonal `.tmx` files (at most 1,000,000 cells) with embedded or external tilesets. Each map cell is one game tile (drawn at 32×32; 32 px tilesets look best). +Layer/group offsets are not supported. -- Tile layers are rendered in order. A tile is solid if the tile has the boolean custom +- Unmarked tile layers are static terrain/decorations, rendered in order. A tile is solid if the tile has the boolean custom property `solid = true` in its tileset, or its layer has `solid = true`. +- A tileset tile with a string `proto` property becomes a gameplay building when + painted. Its `state.*`, `machine.*` and integer script-variable properties become + instance overrides. The painted cell is cleared from static terrain, so removing + the entity cannot leave an invisible wall or a duplicate floor behind. The + shipped floor, dark floor, plating, wall and window tiles use this convention. - Object layers spawn gameplay entities by object **class**: - `spawn` – player spawn point. - `building` – custom property `proto` = building id. `state.health`, `state.solid`, `state.variant` and `state.vars` override the prototype defaults. + `state.layer` must match the prototype (`"floor"` or `"structure"`, as JSON + strings). `state.flip_h`, `state.flip_v` and `state.flip_d` preserve tile flips. Integer custom properties without a `state.`/`machine.` prefix also override individual script variables, for compatibility with existing maps. Machines accept `machine.inputs`, `machine.outputs`, `machine.recipe` and `machine.progress`. - `item` – custom property `proto` = item id and optional `count`. - Objects are placed on the tile containing their position (point objects work well). + Tile objects inherit gameplay properties from their tiles; object properties + override them. Painted and explicit buildings share the same occupancy checks. + +Set the map's **integer** `default_tile` property to a static tile's local ID +(as shown in Tiled's tileset editor). The optional integer `default_tileset` +selects its tileset by zero-based position in the map's tileset list (default 0). +The default tile fills empty cells below gameplay floors; removing a floor exposes +it wherever no explicit static terrain is present. Its `solid` property controls +movement there, while a non-solid gameplay floor covers that default collision. +Explicit static terrain collision still applies. The default cannot reference a +tile with `proto`, and cannot be built/deconstructed itself. The starter map +uses dirt (tile 6). Maps remain finite: outside their bounds is always blocked. +Old maps without a default retain their empty, passable background. 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. +Existing saved terrain is not automatically converted or reseeded on startup: +import a marked map to convert old static walls/floors. Legacy saves without a +terrain snapshot retain static seed terrain until explicitly imported. + +#### Converting painted tiles inside Tiled + +The extension `assets/tiled/station.js` ships with the game. In Tiled 1.10 or newer, +open **Edit > Preferences > Plugins > Open** to open the extensions directory, +copy `station.js` there, then enable/reload it (or restart Tiled). + +Paint the marked tiles normally, then choose **Map > Station: Convert Painted +Tiles to Objects**. The extension replaces all marked painted cells with selected +`building` point objects in a new **Station gameplay** object layer. Select one +to edit its health, solidity or other custom properties in the Properties panel. +Unmarked terrain stays painted. Floor/structure overlaps are allowed; same-layer +overlaps abort the entire conversion. Conversion is a single undoable operation, +and running it again does not duplicate objects. + +For custom tiles, set `proto` to a building ID and add the desired `state.*` or +`machine.*` defaults on the tileset tile. Floor tiles should include `state.layer` +as the string `"floor"` (with JSON quotes) so the extension can check occupancy. +The shipped gameplay tiles include editable state defaults. Exporting a world +also fills in any missing tileset state defaults from the loaded game definitions. +Omitted properties still use prototype defaults when loaded by the game. +Conversion in Tiled and automatic conversion on seed/import use the same property +and coordinate conventions; conversion is optional before loading a map. #### Editing a running world's map @@ -418,6 +471,7 @@ positions, so avoid enclosing them in newly solid terrain. ```sh cargo nextest run --workspace # unit tests + headless full-simulation test +node --test tools/tiled.test.cjs # Tiled extension tests (Node.js 22+) cargo clippy --workspace --all-targets ``` diff --git a/assets/data/buildings.rsn b/assets/data/buildings.rsn index 39afef8..6936e03 100644 --- a/assets/data/buildings.rsn +++ b/assets/data/buildings.rsn @@ -4,6 +4,25 @@ // solid (true), max_health (100), stations (tags for recipes), construction // ({ materials, tools, skill, xp }), deconstructible (true), on_interact, vars. buildings: [ + { + id: "floor", name: "Floor", sprite: "sprites/buildings/floor.png", + layer: "floor", solid: false, + construction: Some({ materials: [("iron_plate", 1)], tools: ["wrench"], xp: 2 }), + }, + { + id: "dark_floor", name: "Dark floor", sprite: "sprites/buildings/dark_floor.png", + layer: "floor", solid: false, + construction: Some({ materials: [("iron_plate", 1)], tools: ["wrench"], xp: 2 }), + }, + { + id: "plating", name: "Plating", sprite: "sprites/buildings/plating.png", + layer: "floor", solid: false, + construction: Some({ materials: [("iron_plate", 1)], tools: ["wrench"], xp: 2 }), + }, + { + id: "window", name: "Window", sprite: "sprites/buildings/window.png", + construction: Some({ materials: [("iron_plate", 1)], tools: ["wrench"], xp: 3 }), + }, { id: "wall", name: "Wall", sprite: "sprites/buildings/wall.png", max_health: 300, diff --git a/assets/maps/station.tmx b/assets/maps/station.tmx index 7e27746..e9c354c 100644 --- a/assets/maps/station.tmx +++ b/assets/maps/station.tmx @@ -1,5 +1,6 @@ + diff --git a/assets/sprites/buildings/dark_floor.png b/assets/sprites/buildings/dark_floor.png new file mode 100644 index 0000000..dc1b1bb Binary files /dev/null and b/assets/sprites/buildings/dark_floor.png differ diff --git a/assets/sprites/buildings/floor.png b/assets/sprites/buildings/floor.png new file mode 100644 index 0000000..cebff9b Binary files /dev/null and b/assets/sprites/buildings/floor.png differ diff --git a/assets/sprites/buildings/plating.png b/assets/sprites/buildings/plating.png new file mode 100644 index 0000000..0d487ad Binary files /dev/null and b/assets/sprites/buildings/plating.png differ diff --git a/assets/sprites/buildings/window.png b/assets/sprites/buildings/window.png new file mode 100644 index 0000000..36774e2 Binary files /dev/null and b/assets/sprites/buildings/window.png differ diff --git a/assets/tiled/station.js b/assets/tiled/station.js new file mode 100644 index 0000000..fdf9eab --- /dev/null +++ b/assets/tiled/station.js @@ -0,0 +1,141 @@ +/* global tiled, TileMap, MapObject, ObjectGroup */ +"use strict"; + +function stationGameplayProperties(properties) { + const result = {}; + for (const name of Object.keys(properties)) { + if (name === "proto" || name.startsWith("state.") || name.startsWith("machine.") + || (name !== "solid" && Number.isInteger(properties[name]))) { + result[name] = properties[name]; + } + } + return result; +} + +function stationLayer(properties, defaults) { + const layer = properties["state.layer"] === undefined + ? (defaults && defaults.get(properties.proto)) || "structure" + : JSON.parse(properties["state.layer"]); + if (layer !== "floor" && layer !== "structure") + throw new Error("state.layer must be the JSON string \"floor\" or \"structure\"."); + return layer; +} + +function stationConvert(map) { + if (!map || !map.isTileMap) + throw new Error("Open a Station map first."); + if (map.infinite || map.orientation !== TileMap.Orthogonal) + throw new Error("Station supports finite orthogonal maps only."); + if (map.width <= 0 || map.height <= 0 || map.width * map.height > 1000000) + throw new Error("Station maps must contain between 1 and 1,000,000 cells."); + + const layers = []; + function visit(parent) { + for (let i = 0; i < parent.layerCount; ++i) { + const layer = parent.layerAt(i); + if (layer.offset.x !== 0 || layer.offset.y !== 0) + throw new Error("Station does not support layer offsets: " + layer.name); + if (layer.isGroupLayer) + visit(layer); + else + layers.push(layer); + } + } + visit(map); + + const prototypeLayers = new Map(); + for (const tileset of map.tilesets) { + for (const tile of tileset.tiles) { + const properties = tile.properties(); + if (properties.proto === undefined || properties["state.layer"] === undefined) + continue; + const layer = stationLayer(properties); + if (prototypeLayers.has(properties.proto) && prototypeLayers.get(properties.proto) !== layer) + throw new Error("Conflicting state.layer defaults for " + properties.proto); + prototypeLayers.set(properties.proto, layer); + } + } + const occupied = new Set(); + function reserve(x, y, properties) { + const key = x + "," + y + "," + stationLayer(properties, prototypeLayers); + if (occupied.has(key)) + throw new Error("Two gameplay objects occupy " + key + ". Nothing was converted."); + occupied.add(key); + } + for (const layer of layers.filter(layer => layer.isObjectLayer)) { + for (const object of layer.objects) { + if (object.className !== "building") + continue; + const properties = Object.assign({}, + object.tile ? stationGameplayProperties(object.tile.properties()) : {}, + object.properties()); + reserve(Math.floor(object.x / map.tileWidth), + Math.floor((object.y - (object.tile ? 1 : 0)) / map.tileHeight), properties); + } + } + + const planned = []; + for (const layer of layers.filter(layer => layer.isTileLayer)) { + if (layer.locked) + throw new Error("Unlock tile layer before converting: " + layer.name); + for (let y = 0; y < map.height; ++y) { + for (let x = 0; x < map.width; ++x) { + const tile = layer.tileAt(x, y); + if (!tile || tile.property("proto") === undefined) + continue; + const properties = stationGameplayProperties(tile.properties()); + if (typeof properties.proto !== "string" || !properties.proto) + throw new Error("Tile proto must be a nonempty building ID."); + const cell = layer.cellAt(x, y); + for (const [key, value] of [ + ["state.flip_h", cell.flippedHorizontally], + ["state.flip_v", cell.flippedVertically], + ["state.flip_d", cell.flippedAntiDiagonally], + ]) { + if (value) + properties[key] = true; + } + reserve(x, y, properties); + planned.push({layer, x, y, properties}); + } + } + } + if (planned.length === 0) + return 0; + + map.macro("Convert Station painted tiles to objects", function () { + const objects = new ObjectGroup(); + objects.name = "Station gameplay"; + map.addLayer(objects); + const edits = new Map(); + for (const entry of planned) { + const object = new MapObject(); + object.name = entry.properties.proto; + object.className = "building"; + object.shape = MapObject.Point; + object.x = (entry.x + 0.5) * map.tileWidth; + object.y = (entry.y + 0.5) * map.tileHeight; + object.setProperties(entry.properties); + objects.addObject(object); + if (!edits.has(entry.layer)) + edits.set(entry.layer, entry.layer.edit()); + edits.get(entry.layer).setTile(entry.x, entry.y, null); + } + for (const edit of edits.values()) + edit.apply(); + map.selectedObjects = objects.objects; + }); + return planned.length; +} + +const stationConvertAction = tiled.registerAction("StationConvertPaintedTiles", function () { + try { + const count = stationConvert(tiled.activeAsset); + tiled.log("Station: converted " + count + " painted tiles to editable point objects."); + } catch (error) { + tiled.alert("Station conversion failed: " + error.message); + } +}); +stationConvertAction.text = "Station: Convert Painted Tiles to Objects"; +if (tiled.mapEditor) + tiled.extendMenu("Map", [{action: "StationConvertPaintedTiles"}]); diff --git a/assets/tilesets/station.tsx b/assets/tilesets/station.tsx index 4e49269..d61b770 100644 --- a/assets/tilesets/station.tsx +++ b/assets/tilesets/station.tsx @@ -2,13 +2,13 @@ - + - + - + @@ -17,12 +17,12 @@ - + - + diff --git a/crates/game_client/src/input.rs b/crates/game_client/src/input.rs index 73b54ff..76a692b 100644 --- a/crates/game_client/src/input.rs +++ b/crates/game_client/src/input.rs @@ -241,7 +241,7 @@ fn mouse( camera: Query<(&Camera, &GlobalTransform)>, local: Res, characters: Query<&GridPos, With>, - buildings: Query<&GridPos, With>, + buildings: Query<(&GridPos, &BuildingState), With>, ) { if !buttons.just_pressed(MouseButton::Left) || ui.blocks_game() @@ -268,7 +268,10 @@ fn mouse( if me.distance(target) > REACH { return; } - if buildings.iter().any(|p| *p == target) { + if buildings + .iter() + .any(|(p, state)| *p == target && state.layer == game_core::data::BuildingLayer::Structure) + { commands.trigger(InteractAt(target)); } else { commands.client_trigger(PickUp { target }); diff --git a/crates/game_client/src/render.rs b/crates/game_client/src/render.rs index bb3b29f..00e78d7 100644 --- a/crates/game_client/src/render.rs +++ b/crates/game_client/src/render.rs @@ -85,7 +85,14 @@ fn spawn_map( commands .entity(entity) .insert((Transform::default(), Visibility::default())); - for (layer_index, layer) in layout.layers.iter().enumerate() { + let default_layer = layout + .default_tile + .map(|cell| game_core::map::TileLayerData { + name: "Default terrain".into(), + solid: false, + cells: vec![Some(cell); (layout.width * layout.height) as usize], + }); + for (layer_index, layer) in default_layer.iter().chain(layout.layers.iter()).enumerate() { let z = layer_index as f32 * 0.01; for (i, cell) in layer.cells.iter().enumerate() { let Some(cell) = cell else { continue }; @@ -174,12 +181,34 @@ fn building_visuals( continue; }; let mut entity = commands.entity(entity); - entity.insert(sprite( + let mut visual = sprite( &asset_server, def.sprite_for(state.variant.as_deref()), TILE_SIZE, - )); - let transform = Transform::from_translation(grid_to_world(*pos).extend(Z_BUILDING)); + ); + visual.flip_x = if state.flip_d { + !state.flip_v + } else { + state.flip_h + }; + visual.flip_y = if state.flip_d { + state.flip_h + } else { + state.flip_v + }; + entity.insert(visual); + let z = if state.layer == game_core::data::BuildingLayer::Floor { + 0.5 + } else { + Z_BUILDING + }; + let transform = Transform::from_translation(grid_to_world(*pos).extend(z)).with_rotation( + Quat::from_rotation_z(if state.flip_d { + -std::f32::consts::FRAC_PI_2 + } else { + 0.0 + }), + ); if has_transform { entity.insert(transform); } else { diff --git a/crates/game_core/src/components.rs b/crates/game_core/src/components.rs index 057f6a3..28ed2ac 100644 --- a/crates/game_core/src/components.rs +++ b/crates/game_core/src/components.rs @@ -6,7 +6,7 @@ use bevy::prelude::*; use serde::{Deserialize, Serialize}; use crate::{ - data::{BuildingDef, GameData, Ingredient, MachineDef, MachineRecipeDef}, + data::{BuildingDef, BuildingLayer, GameData, Ingredient, MachineDef, MachineRecipeDef}, grid::Dir, }; @@ -230,6 +230,14 @@ pub struct Building { /// Mutable per-instance building state, editable from scripts. #[derive(Component, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct BuildingState { + #[serde(default)] + pub layer: BuildingLayer, + #[serde(default)] + pub flip_h: bool, + #[serde(default)] + pub flip_v: bool, + #[serde(default)] + pub flip_d: bool, pub solid: bool, pub variant: Option, pub health: i32, @@ -239,10 +247,12 @@ pub struct BuildingState { impl BuildingState { pub fn new(def: &BuildingDef) -> Self { Self { + layer: def.layer, solid: def.solid, variant: None, health: def.max_health, vars: def.vars.clone(), + ..Default::default() } } } diff --git a/crates/game_core/src/data.rs b/crates/game_core/src/data.rs index 033064d..3f7df4f 100644 --- a/crates/game_core/src/data.rs +++ b/crates/game_core/src/data.rs @@ -119,6 +119,14 @@ pub struct Construction { pub xp: u32, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BuildingLayer { + Floor, + #[default] + Structure, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct BuildingDef { pub id: String, @@ -126,6 +134,8 @@ pub struct BuildingDef { #[serde(default)] pub description: String, pub sprite: String, + #[serde(default)] + pub layer: BuildingLayer, /// Alternative sprites selectable from scripts with `ctx.set_variant(name)`. #[serde(default)] pub variants: HashMap, diff --git a/crates/game_core/src/lib.rs b/crates/game_core/src/lib.rs index fad770f..24a6b49 100644 --- a/crates/game_core/src/lib.rs +++ b/crates/game_core/src/lib.rs @@ -16,7 +16,7 @@ pub use data::GameData; pub const DEFAULT_MAP: &str = "maps/station.tmx"; pub const DEFAULT_PORT: u16 = 5000; /// Bump when the protocol changes in incompatible ways. -pub const PROTOCOL_ID: u64 = 5; +pub const PROTOCOL_ID: u64 = 6; /// Locates the `assets` directory. /// diff --git a/crates/game_core/src/map.rs b/crates/game_core/src/map.rs index c18443e..5c02a03 100644 --- a/crates/game_core/src/map.rs +++ b/crates/game_core/src/map.rs @@ -32,6 +32,8 @@ pub struct TilesetInfo { pub spacing: u32, pub margin: u32, pub solid_tiles: BTreeSet, + #[serde(default)] + pub gameplay_tiles: BTreeMap>, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -69,6 +71,8 @@ pub struct MapLayout { pub tilesets: Vec, pub layers: Vec, pub objects: Vec, + #[serde(default)] + pub default_tile: Option, solid: Vec, } @@ -83,6 +87,19 @@ pub enum MapError { impl MapLayout { /// Loads an asset-relative or absolute map path. pub fn load(assets_dir: &Path, map_path: &str) -> Result { + Self::load_inner(assets_dir, map_path, true) + } + + /// Legacy saves have entity state but no terrain snapshot; do not reseed their entities. + pub fn load_legacy_terrain(assets_dir: &Path, map_path: &str) -> Result { + Self::load_inner(assets_dir, map_path, false) + } + + fn load_inner( + assets_dir: &Path, + map_path: &str, + convert_painted: bool, + ) -> Result { let full = assets_dir.join(map_path); let unsupported = |msg: String| MapError::Unsupported(full.clone(), msg); let map = tiled::Loader::new() @@ -91,6 +108,9 @@ impl MapLayout { if map.orientation != tiled::Orientation::Orthogonal { return Err(unsupported("only orthogonal maps are supported".into())); } + if map.infinite() { + return Err(unsupported("infinite maps are not supported".into())); + } let assets_canon = assets_dir .canonicalize() @@ -125,6 +145,11 @@ impl MapLayout { .filter(|(_, tile)| bool_prop(&tile.properties, "solid")) .map(|(id, _)| id) .collect(), + gameplay_tiles: ts + .tiles() + .filter(|(_, tile)| tile.properties.contains_key("proto")) + .map(|(id, tile)| (id, Self::gameplay_properties(&tile.properties))) + .collect(), }); } @@ -145,9 +170,16 @@ impl MapLayout { let mut layers = Vec::new(); let mut solid = vec![false; (width * height) as usize]; let mut objects = Vec::new(); + let mut painted = Vec::new(); let mut stack: Vec = map.layers().collect(); stack.reverse(); while let Some(layer) = stack.pop() { + if layer.offset_x != 0.0 || layer.offset_y != 0.0 { + return Err(unsupported(format!( + "layer `{}`: offsets are not supported", + layer.name + ))); + } let layer_solid = bool_prop(&layer.properties, "solid"); match layer.layer_type() { tiled::LayerType::Tiles(tiles) => { @@ -157,20 +189,35 @@ impl MapLayout { let mut cells = Vec::with_capacity(solid.len()); for y in 0..height as i32 { for x in 0..width as i32 { - let cell = tiles.get_tile(x, y).map(|t| { + let cell = tiles.get_tile(x, y).and_then(|t| { + if convert_painted + && let Some(properties) = + tilesets[t.tileset_index()].gameplay_tiles.get(&t.id()) + { + let mut properties = properties.clone(); + Self::add_flips(&mut properties, t.flip_h, t.flip_v, t.flip_d); + painted.push(MapObject { + id: 0, + class: "building".into(), + name: String::new(), + pos: GridPos::new(x, y), + properties, + }); + return None; + } let tile_solid = t .get_tile() .is_some_and(|d| bool_prop(&d.properties, "solid")); if tile_solid || layer_solid { solid[(y as u32 * width + x as u32) as usize] = true; } - TileCell { + Some(TileCell { tileset: t.tileset_index(), id: t.id(), flip_h: t.flip_h, flip_v: t.flip_v, flip_d: t.flip_d, - } + }) }); cells.push(cell); } @@ -191,16 +238,24 @@ impl MapLayout { } else { obj.y }; + let mut properties = BTreeMap::new(); + if let Some(tile) = obj.get_tile() { + if let Some(def) = tile.get_tile() { + properties = Self::gameplay_properties(&def.properties); + } + Self::add_flips(&mut properties, tile.flip_h, tile.flip_v, tile.flip_d); + } + properties.extend( + obj.properties + .iter() + .map(|(k, v)| (k.clone(), prop_to_string(v))), + ); objects.push(MapObject { id: obj.id(), class: obj.user_type.clone(), name: obj.name.clone(), pos: GridPos::new((obj.x / tw).floor() as i32, (y / th).floor() as i32), - properties: obj - .properties - .iter() - .map(|(k, v)| (k.clone(), prop_to_string(v))) - .collect(), + properties, }); } } @@ -212,6 +267,53 @@ impl MapLayout { } } + let mut next_id = objects.iter().map(|o| o.id).max().unwrap_or(0); + for mut object in painted { + next_id = next_id + .checked_add(1) + .ok_or_else(|| unsupported("too many objects".into()))?; + object.id = next_id; + objects.push(object); + } + let default_tile = if let Some(value) = map.properties.get("default_tile") { + let tiled::PropertyValue::IntValue(id) = value else { + return Err(unsupported( + "default_tile must be a nonnegative local tile ID".into(), + )); + }; + let index = match map.properties.get("default_tileset") { + None => 0, + Some(tiled::PropertyValue::IntValue(index)) if *index >= 0 => *index as usize, + _ => { + return Err(unsupported( + "default_tileset must be a nonnegative tileset index".into(), + )); + } + }; + let ts = tilesets + .get(index) + .ok_or_else(|| unsupported("unknown default tileset".into()))?; + if *id < 0 + || *id as u32 >= ts.tile_count + || ts.gameplay_tiles.contains_key(&(*id as u32)) + { + return Err(unsupported( + "default_tile must reference a static terrain tile".into(), + )); + } + Some(TileCell { + tileset: index, + id: *id as u32, + flip_h: false, + flip_v: false, + flip_d: false, + }) + } else { + if map.properties.contains_key("default_tileset") { + return Err(unsupported("default_tileset requires default_tile".into())); + } + None + }; Ok(Self { width, height, @@ -220,6 +322,7 @@ impl MapLayout { tilesets, layers, objects, + default_tile, solid, }) } @@ -230,7 +333,46 @@ impl MapLayout { /// Tiles outside of the map are always solid. pub fn is_solid(&self, pos: GridPos) -> bool { - !self.in_bounds(pos) || self.solid[(pos.y as u32 * self.width + pos.x as u32) as usize] + self.is_solid_with_floor(pos, false) + } + + pub fn is_solid_with_floor(&self, pos: GridPos, has_floor: bool) -> bool { + if !self.in_bounds(pos) { + return true; + } + let i = (pos.y as u32 * self.width + pos.x as u32) as usize; + self.solid[i] + || (!has_floor + && !self.layers.iter().any(|layer| layer.cells[i].is_some()) + && self + .default_tile + .is_some_and(|cell| self.tilesets[cell.tileset].solid_tiles.contains(&cell.id))) + } + + fn gameplay_properties(properties: &tiled::Properties) -> BTreeMap { + properties + .iter() + .filter(|(name, value)| { + name.as_str() == "proto" + || name.starts_with("state.") + || name.starts_with("machine.") + || (name.as_str() != "solid" + && matches!(value, tiled::PropertyValue::IntValue(_))) + }) + .map(|(key, value)| (key.clone(), prop_to_string(value))) + .collect() + } + + fn add_flips(properties: &mut BTreeMap, h: bool, v: bool, d: bool) { + for (key, value) in [ + ("state.flip_h", h), + ("state.flip_v", v), + ("state.flip_d", d), + ] { + if value { + properties.insert(key.into(), "true".into()); + } + } } pub fn objects_of_class<'a>(&'a self, class: &'a str) -> impl Iterator { @@ -265,11 +407,24 @@ mod tests { assert!(!map.layers.is_empty()); assert!(map.objects_of_class("spawn").next().is_some()); assert!(map.is_solid(GridPos::new(-1, 0))); - assert!( - map.is_solid(GridPos::new(0, 0)), - "map border should be walls" - ); + assert!(!map.is_solid(GridPos::new(0, 0))); + assert!(map.objects.iter().any(|o| o.pos == GridPos::new(0, 0) + && o.properties.get("proto").is_some_and(|p| p == "wall"))); let spawn = map.objects_of_class("spawn").next().unwrap().pos; assert!(!map.is_solid(spawn)); } + + #[test] + fn legacy_terrain_stays_static() { + let legacy = + MapLayout::load_legacy_terrain(&crate::assets_dir(), crate::DEFAULT_MAP).unwrap(); + assert!(legacy.is_solid(GridPos::new(0, 0))); + assert!(legacy.layers[1].cells[0].is_some()); + assert!(!legacy.objects.iter().any(|object| { + object + .properties + .get("proto") + .is_some_and(|proto| proto == "floor" || proto == "wall") + })); + } } diff --git a/crates/game_server/src/actions.rs b/crates/game_server/src/actions.rs index 97c64fe..34f919d 100644 --- a/crates/game_server/src/actions.rs +++ b/crates/game_server/src/actions.rs @@ -316,8 +316,10 @@ pub fn on_interact( return; } face_towards(&mut facing, *pos, target); - let Some((building_entity, building, _, mut state)) = - buildings.iter_mut().find(|(_, _, p, _)| **p == target) + let Some((building_entity, building, _, mut state)) = buildings + .iter_mut() + .filter(|(_, _, p, _)| **p == target) + .max_by_key(|(_, _, _, state)| state.layer) else { tell(&mut commands, client, "There is nothing there."); return; @@ -442,7 +444,7 @@ pub fn on_construct( data: Res, layout: Res, mut characters: Characters, - buildings: Query<&GridPos, (With, Without)>, + buildings: Query<(&GridPos, &BuildingState), (With, Without)>, occupants: Query<&Position, (With, Without)>, ) { let client = ev.client_id; @@ -465,7 +467,17 @@ pub fn on_construct( let Some(def) = data.buildings.get(&ev.message.building) else { return; }; - if layout.0.is_solid(target) || buildings.iter().any(|p| *p == target) { + 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)) + }) + { tell(&mut commands, client, "There is no room to build there."); return; } @@ -493,7 +505,16 @@ pub fn on_deconstruct( players: Res, data: Res, mut characters: Characters, - buildings: Query<(Entity, &Building, &GridPos, Option<&Machine>), Without>, + buildings: Query< + ( + Entity, + &Building, + &GridPos, + &BuildingState, + Option<&Machine>, + ), + Without, + >, ) { let client = ev.client_id; let target = ev.message.target; @@ -508,8 +529,10 @@ pub fn on_deconstruct( return; } face_towards(&mut facing, *pos, target); - let Some((building_entity, building, _, machine)) = - buildings.iter().find(|(_, _, p, _)| **p == target) + let Some((building_entity, building, _, _, machine)) = buildings + .iter() + .filter(|(_, _, p, _, _)| **p == target) + .max_by_key(|(_, _, _, state, _)| state.layer) else { tell(&mut commands, client, "There is nothing to deconstruct."); return; diff --git a/crates/game_server/src/map_io.rs b/crates/game_server/src/map_io.rs index 1197276..7f76974 100644 --- a/crates/game_server/src/map_io.rs +++ b/crates/game_server/src/map_io.rs @@ -65,6 +65,11 @@ pub fn decode(layout: MapLayout, data: &GameData) -> Result Result PathBuf { } pub fn export(world: &mut World, path: &Path) -> Result<(), MapIoError> { - let save = persistence::snapshot(world); + let mut save = persistence::snapshot(world); + let data = world.resource::(); + if let Some(layout) = &mut save.layout { + for tileset in &mut layout.tilesets { + for tile in tileset.gameplay_tiles.values_mut() { + let proto = tile + .get("proto") + .ok_or_else(|| MapIoError::Invalid("tile has no proto".into()))?; + let def = data.buildings.get(proto).ok_or_else(|| { + MapIoError::Invalid(format!("unknown painted prototype `{proto}`")) + })?; + let mut defaults = properties(&BuildingState::new(def), "state.")?; + if let Some(machine) = data.machines.get(proto) { + defaults.extend(properties(&Machine::new(machine), "machine.")?); + } + for (key, value) in defaults { + tile.entry(key).or_insert(serde_json::to_string(&value)?); + } + } + } + } write(&save, path) } @@ -306,6 +333,15 @@ pub fn write(save: &SaveGame, path: &Path) -> Result<(), MapIoError> { ], false, )?; + if let Some(cell) = layout.default_tile { + write_properties( + &mut xml, + &BTreeMap::from([ + ("default_tileset".into(), Value::from(cell.tileset)), + ("default_tile".into(), Value::from(cell.id)), + ]), + )?; + } let mut gids = Vec::new(); let mut gid = 1u32; for (i, ts) in layout.tilesets.iter().enumerate() { @@ -338,12 +374,29 @@ pub fn write(save: &SaveGame, path: &Path) -> Result<(), MapIoError> { ], true, )?; - for id in &ts.solid_tiles { + let tile_ids: BTreeSet<_> = ts + .solid_tiles + .iter() + .chain(ts.gameplay_tiles.keys()) + .copied() + .collect(); + for id in tile_ids { tag(&mut xml, "tile", &[("id", id.to_string())], false)?; - write_properties( - &mut xml, - &BTreeMap::from([("solid".into(), Value::Bool(true))]), - )?; + let mut fields = BTreeMap::new(); + if ts.solid_tiles.contains(&id) { + fields.insert("solid".into(), Value::Bool(true)); + } + if let Some(properties) = ts.gameplay_tiles.get(&id) { + for (name, text) in properties { + let value = if name == "proto" { + Value::String(text.clone()) + } else { + serde_json::from_str(text)? + }; + fields.insert(name.clone(), value); + } + } + write_properties(&mut xml, &fields)?; end(&mut xml, "tile")?; } end(&mut xml, "tileset")?; @@ -501,6 +554,173 @@ mod tests { std::env::temp_dir().join(format!("station-map-{}.tmx", uuid::Uuid::new_v4())) } + fn fixture(layers: &str, properties: &str) -> Result { + let file = path(); + let tileset = game_core::assets_dir().join("tilesets/station.tsx"); + std::fs::write(&file, format!( + r#" + {properties} + {layers}"#, tileset.display() + )).unwrap(); + let result = MapLayout::load(&game_core::assets_dir(), file.to_str().unwrap()); + std::fs::remove_file(file).unwrap(); + result + } + + #[test] + fn painted_tiles_match_editable_objects_and_floor_covers_default_collision() { + let (data, _) = seeded(); + let default = r#""#; + let layers = r#" + 3758096385,2,6,0 + 3,0,0,0"#; + let layout = fixture(layers, default).unwrap(); + assert!( + layout + .layers + .iter() + .all(|l| l.cells.iter().all(Option::is_none)) + ); + let painted = decode(layout, &data).unwrap(); + let objects = r#" + 0,0,0,0 + 0,0,0,0 + + + + + + + + + + + "#; + let converted = decode(fixture(objects, default).unwrap(), &data).unwrap(); + assert_eq!(painted, converted); + let layout = painted.layout.as_ref().unwrap(); + let blocked = |pos, buildings: &[SavedBuilding]| { + crate::world::is_blocked(pos, layout, buildings.iter().map(|b| (&b.pos, &b.state))) + }; + assert!(blocked(GridPos::new(0, 0), &painted.buildings)); + assert!(!blocked(GridPos::new(1, 0), &painted.buildings)); + assert!(blocked(GridPos::new(1, 0), &[])); + assert!(blocked(GridPos::new(1, 1), &painted.buildings)); + assert!(blocked(GridPos::new(-1, 0), &painted.buildings)); + let output = path(); + write(&painted, &output).unwrap(); + let loaded = decode( + MapLayout::load(&game_core::assets_dir(), output.to_str().unwrap()).unwrap(), + &data, + ) + .unwrap(); + assert_eq!(loaded, painted); + std::fs::remove_file(output).unwrap(); + } + + #[test] + fn conversion_rejects_same_layer_overlap_and_invalid_defaults() { + let (data, _) = seeded(); + for (first, second) in [(1, 2), (3, 8)] { + let layers = format!( + r#" + {first},0,0,0 + {second},0,0,0"# + ); + assert!(decode(fixture(&layers, "").unwrap(), &data).is_err()); + } + for properties in [ + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ] { + assert!(fixture("", properties).is_err(), "{properties}"); + } + let tile_object = r#""#; + let save = decode(fixture(tile_object, "").unwrap(), &data).unwrap(); + assert_eq!(save.buildings[0].proto, "wall"); + assert_eq!(save.buildings[0].pos, GridPos::new(0, 0)); + assert_eq!(save.buildings[0].state.health, 7); + let bad_layer = tile_object + .replace("state.health", "state.layer") + .replace(r#"type="int" value="7""#, r#"value=""floor"""#); + assert!(decode(fixture(&bad_layer, "").unwrap(), &data).is_err()); + } + + #[test] + fn shipped_tile_defaults_match_prototypes_and_legacy_state_is_readable() { + let (data, save) = seeded(); + let layout = save.layout.unwrap(); + for ts in &layout.tilesets { + for props in ts.gameplay_tiles.values() { + let def = &data.buildings[&props["proto"]]; + let mut state = BuildingState::new(def); + overlay(&mut state, "state.", props).unwrap(); + assert_eq!(state, BuildingState::new(def)); + } + } + let old: BuildingState = + serde_json::from_str(r#"{"health":7,"solid":true,"variant":null,"vars":{}}"#).unwrap(); + assert_eq!(old.layer, game_core::data::BuildingLayer::Structure); + assert!(!old.flip_h && !old.flip_v && !old.flip_d); + let mut old = serde_json::to_value(&layout).unwrap(); + old.as_object_mut().unwrap().remove("default_tile"); + for tileset in old["tilesets"].as_array_mut().unwrap() { + tileset.as_object_mut().unwrap().remove("gameplay_tiles"); + } + let restored: MapLayout = serde_json::from_value(old).unwrap(); + assert!(restored.default_tile.is_none()); + assert!( + restored + .tilesets + .iter() + .all(|ts| ts.gameplay_tiles.is_empty()) + ); + } + + #[test] + fn painted_flips_override_exported_tileset_defaults() { + let (data, mut save) = seeded(); + let floor = save + .buildings + .iter() + .find(|b| b.proto == "floor") + .unwrap() + .clone(); + save.buildings + .retain(|b| b.pos != floor.pos || b.proto != floor.proto); + let layout = save.layout.as_mut().unwrap(); + let props = layout.tilesets[0].gameplay_tiles.get_mut(&0).unwrap(); + for key in ["state.flip_h", "state.flip_v", "state.flip_d"] { + props.insert(key.into(), "false".into()); + } + layout.layers[0].cells[(floor.pos.y as u32 * layout.width + floor.pos.x as u32) as usize] = + Some(game_core::map::TileCell { + tileset: 0, + id: 0, + flip_h: true, + flip_v: true, + flip_d: true, + }); + let file = path(); + write(&save, &file).unwrap(); + let loaded = decode( + MapLayout::load(&game_core::assets_dir(), file.to_str().unwrap()).unwrap(), + &data, + ) + .unwrap(); + let floor = loaded + .buildings + .iter() + .find(|b| b.proto == "floor" && b.pos == floor.pos) + .unwrap(); + assert!(floor.state.flip_h && floor.state.flip_v && floor.state.flip_d); + std::fs::remove_file(file).unwrap(); + } + #[test] fn tiled_roundtrip_preserves_every_saved_object_property_and_terrain() { let (data, mut save) = seeded(); diff --git a/crates/game_server/src/movement.rs b/crates/game_server/src/movement.rs index 3c29f0a..8709292 100644 --- a/crates/game_server/src/movement.rs +++ b/crates/game_server/src/movement.rs @@ -74,7 +74,14 @@ pub fn move_characters( .filter(|(_, state)| state.solid) .map(|(pos, _)| *pos) .collect(); - let blocked = |tile: GridPos| layout.0.is_solid(tile) || solid.contains(&tile); + let floors: HashSet = buildings + .iter() + .filter(|(_, state)| state.layer == game_core::data::BuildingLayer::Floor) + .map(|(pos, _)| *pos) + .collect(); + let blocked = |tile: GridPos| { + layout.0.is_solid_with_floor(tile, floors.contains(&tile)) || solid.contains(&tile) + }; let dt = time.delta_secs(); for (intent, mut pos) in &mut characters { if intent.0 == Vec2::ZERO { diff --git a/crates/game_server/src/persistence.rs b/crates/game_server/src/persistence.rs index 41aa2f5..123425a 100644 --- a/crates/game_server/src/persistence.rs +++ b/crates/game_server/src/persistence.rs @@ -792,6 +792,18 @@ mod tests { variant: Some("open".into()), health: 100, vars: [("stock".to_owned(), 3)].into(), + ..default() + }, + machine: None, + }); + save.buildings.push(SavedBuilding { + proto: "floor".into(), + pos: GridPos::new(1, 2), + state: BuildingState { + layer: game_core::data::BuildingLayer::Floor, + health: 31, + flip_d: true, + ..default() }, machine: None, }); diff --git a/crates/game_server/src/world.rs b/crates/game_server/src/world.rs index 89e31e9..caa0f3b 100644 --- a/crates/game_server/src/world.rs +++ b/crates/game_server/src/world.rs @@ -9,6 +9,7 @@ use game_core::{ components::{ Building, BuildingState, Character, GridPos, GroundItem, ItemStack, Machine, WorldMap, }, + data::BuildingLayer, map::MapLayout, }; @@ -60,10 +61,12 @@ pub fn setup_world(mut commands: Commands, settings: Res, data: ); let layout = match save.layout.take() { Some(layout) => layout, - None => match MapLayout::load(&game_core::assets_dir(), &settings.map) { - Ok(layout) => layout, - Err(e) => return crate::fail(&mut commands, e.to_string()), - }, + None => { + match MapLayout::load_legacy_terrain(&game_core::assets_dir(), &settings.map) { + Ok(layout) => layout, + Err(e) => return crate::fail(&mut commands, e.to_string()), + } + } }; (layout, save) } @@ -136,7 +139,16 @@ pub fn is_blocked<'a>( layout: &MapLayout, buildings: impl IntoIterator, ) -> bool { - layout.is_solid(pos) || buildings.into_iter().any(|(p, s)| *p == pos && s.solid) + let mut has_floor = false; + for (p, state) in buildings { + if *p == pos { + if state.solid { + return true; + } + has_floor |= state.layer == BuildingLayer::Floor; + } + } + layout.is_solid_with_floor(pos, has_floor) } /// Finds the closest free tile around `start` (spiral search), for spawning characters. diff --git a/crates/game_server/tests/simulation.rs b/crates/game_server/tests/simulation.rs index 1cd1ed6..2bc8c1b 100644 --- a/crates/game_server/tests/simulation.rs +++ b/crates/game_server/tests/simulation.rs @@ -111,7 +111,13 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma .find(|o| o.class == "building" && o.properties["proto"] == "door") .unwrap(); door.properties.insert("state.health".into(), "11".into()); - map.layers[1].cells[0] = None; + map.layers[1].cells[0] = Some(game_core::map::TileCell { + tileset: 0, + id: 4, + flip_h: false, + flip_v: false, + flip_d: false, + }); let mut edited = map_io::decode(map, app.world().resource::()).unwrap(); edited.items.truncate(1); edited.buildings.retain(|b| b.proto != "tree"); @@ -129,7 +135,7 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma assert!(reply[0].contains("imported"), "{reply:?}"); let imported = persistence::snapshot(app.world_mut()); assert!( - !app.world() + app.world() .resource::() .0 .is_solid(GridPos::new(0, 0)) @@ -201,13 +207,183 @@ fn count(app: &App, me: Entity, item: &str) -> u32 { app.world().get::(me).unwrap().count(item) } +#[test] +fn floors_and_structures_build_and_deconstruct_independently_over_default_terrain() { + use game_core::data::BuildingLayer; + use game_server::{ + map_io, persistence, + world::{self, WorldLayout}, + }; + + let mut app = app(); + send(&mut app, login("Builder")); + let me = app + .world_mut() + .query_filtered::>() + .single(app.world()) + .unwrap(); + let pos = *app.world().get::(me).unwrap(); + let target = GridPos::new(pos.x + 1, pos.y); + let mut save = persistence::snapshot(app.world_mut()); + save.buildings.retain(|b| b.pos != target); + save.layout + .as_mut() + .unwrap() + .default_tile + .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(); + map_io::import(app.world_mut(), &file).unwrap(); + + let data = app.world().resource::().clone(); + { + let mut inventory = app.world_mut().get_mut::(me).unwrap(); + inventory.slots.clear(); + inventory.add("iron_plate", 8, &data); + inventory.add("wrench", 1, &data); + } + let blocked = |app: &mut App| { + let mut q = app.world_mut().query::<(&GridPos, &BuildingState)>(); + world::is_blocked( + target, + &app.world().resource::().0, + q.iter(app.world()), + ) + }; + let layers = |app: &mut App| { + let mut q = app.world_mut().query::<(&GridPos, &BuildingState)>(); + let mut layers: Vec<_> = q + .iter(app.world()) + .filter(|(p, _)| **p == target) + .map(|(_, state)| state.layer) + .collect(); + layers.sort(); + layers + }; + assert!(blocked(&mut app)); + send( + &mut app, + Construct { + building: "wall".into(), + target, + }, + ); + assert_eq!(count(&app, me, "iron_plate"), 8); + send( + &mut app, + Construct { + building: "floor".into(), + target, + }, + ); + assert_eq!(layers(&mut app), [BuildingLayer::Floor]); + assert!(!blocked(&mut app)); + send( + &mut app, + Construct { + building: "plating".into(), + target, + }, + ); + assert_eq!(count(&app, me, "iron_plate"), 7); + send( + &mut app, + Construct { + building: "wall".into(), + target, + }, + ); + assert_eq!( + layers(&mut app), + [BuildingLayer::Floor, BuildingLayer::Structure] + ); + assert!(blocked(&mut app)); + send(&mut app, Interact { target }); + assert!(take_log(&mut app).iter().any(|message| message == "Wall")); + send(&mut app, Deconstruct { target }); + assert_eq!(layers(&mut app), [BuildingLayer::Floor]); + assert!(!blocked(&mut app)); + send(&mut app, Deconstruct { target }); + assert!(layers(&mut app).is_empty()); + assert!(blocked(&mut app)); + assert_eq!(count(&app, me, "iron_plate"), 8); + send(&mut app, SetMovement { x: 1, y: 0 }); + for _ in 0..6 { + app.update(); + } + send(&mut app, SetMovement { x: 0, y: 0 }); + assert!(app.world().get::(me).unwrap().0.x < target.x as f32); + + map_io::export(app.world_mut(), &file).unwrap(); + map_io::import(app.world_mut(), &file).unwrap(); + assert!( + layers(&mut app).is_empty(), + "removed floors must not be reseeded" + ); + assert!(blocked(&mut app)); + send( + &mut app, + Construct { + building: "floor".into(), + target, + }, + ); + assert!(!blocked(&mut app)); + send(&mut app, SetMovement { x: 1, y: 0 }); + for _ in 0..4 { + app.update(); + } + send(&mut app, SetMovement { x: 0, y: 0 }); + assert!(app.world().get::(me).unwrap().0.x > target.x as f32 + 0.3); + std::fs::remove_file(file).unwrap(); +} + +#[test] +fn painted_walls_are_deconstructible_without_leaving_static_collision() { + let mut app = app(); + send(&mut app, login("Demolition")); + let me = app + .world_mut() + .query_filtered::>() + .single(app.world()) + .unwrap(); + let target = GridPos::new(0, 0); + let wall = app + .world_mut() + .query::<(Entity, &Building, &GridPos)>() + .iter(app.world()) + .find(|(_, b, p)| b.proto == "wall" && **p == target) + .map(|(entity, _, _)| entity) + .unwrap(); + assert_eq!(app.world().get::(wall).unwrap().health, 300); + app.world_mut() + .entity_mut(me) + .insert((GridPos::new(0, 1), Position::center_of(GridPos::new(0, 1)))); + let data = app.world().resource::().clone(); + app.world_mut() + .get_mut::(me) + .unwrap() + .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)>(); + assert!(!game_server::world::is_blocked( + target, + &app.world().resource::().0, + buildings.iter(app.world()) + )); + assert_eq!(count(&app, me, "iron_plate"), 2); +} + #[test] fn login_move_and_interact() { let mut app = app(); // The map defines buildings and items. let mut buildings = app.world_mut().query::<&Building>(); - assert_eq!(buildings.iter(app.world()).count(), 19); + assert!(buildings.iter(app.world()).count() > 19); send(&mut app, login("Tester")); let me = app @@ -329,7 +505,10 @@ fn login_move_and_interact() { // Deconstruction refunds the materials. send(&mut app, Deconstruct { target }); let mut q = app.world_mut().query::<(&Building, &GridPos)>(); - assert!(!q.iter(app.world()).any(|(_, p)| *p == target)); + assert!( + !q.iter(app.world()) + .any(|(b, p)| *p == target && b.proto == "door") + ); assert_eq!(count(&app, me, "plank"), 2); // Drop and pick up again. @@ -600,8 +779,14 @@ mod network { let assets = game_core::assets_dir(); let data = game_core::GameData::load_dir(&assets.join("data")).unwrap(); let mut map = game_core::map::MapLayout::load(&assets, file.to_str().unwrap()).unwrap(); - assert!(map.is_solid(GridPos::new(0, 0))); - map.layers[1].cells[0] = None; + assert!(!map.is_solid(GridPos::new(0, 0))); + map.layers[1].cells[0] = Some(game_core::map::TileCell { + tileset: 0, + id: 4, + flip_h: false, + flip_v: false, + flip_d: false, + }); let edited = map_io::decode(map, &data).unwrap(); map_io::write(&edited, &file).unwrap(); host.world_mut().client_trigger(Chat { @@ -610,7 +795,7 @@ mod network { let imported = |app: &mut App| { let mut maps = app.world_mut().query::<&WorldMap>(); maps.iter(app.world()) - .any(|map| !map.layout.is_solid(GridPos::new(0, 0))) + .any(|map| map.layout.is_solid(GridPos::new(0, 0))) }; pump(&mut [&mut host, &mut guest], imported); pump(&mut [&mut guest, &mut host], imported); diff --git a/tools/gen_placeholder_assets.py b/tools/gen_placeholder_assets.py index a8a9b5e..d7e1723 100644 --- a/tools/gen_placeholder_assets.py +++ b/tools/gen_placeholder_assets.py @@ -77,12 +77,24 @@ def gen_tileset(): for i, (_, color, _, pattern) in enumerate(TILES): draw_tile(d, (i % COLS) * T, (i // COLS) * T, color, pattern) save(img, "tilesets/station.png") + for i, (name, _, _, _) in enumerate(TILES): + if name in {"floor", "dark_floor", "plating", "window"}: + x, y = (i % COLS) * T, (i // COLS) * T + save(img.crop((x, y, x + T, y + T)), f"sprites/buildings/{name}.png") tiles_xml = [] for i, (name, _, solid, _) in enumerate(TILES): props = f'' if solid: props += '' + if name in {"floor", "dark_floor", "plating", "wall", "window"}: + props += f'' + layer = "floor" if name in {"floor", "dark_floor", "plating"} else "structure" + props += f'' + props += f'' + props += f'' + props += '' + props += '' tiles_xml.append(f' \n {props}\n ') tsx = f""" @@ -316,6 +328,7 @@ def gen_map(): tmx = f""" + diff --git a/tools/tiled.test.cjs b/tools/tiled.test.cjs new file mode 100644 index 0000000..195ef62 --- /dev/null +++ b/tools/tiled.test.cjs @@ -0,0 +1,200 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const {readFileSync} = require("node:fs"); +const {join} = require("node:path"); +const {test} = require("node:test"); +const vm = require("node:vm"); + +function harness() { + class ObjectGroup { + constructor() { + this.isObjectLayer = true; + this.offset = {x: 0, y: 0}; + this.objects = []; + } + addObject(object) { this.objects.push(object); } + } + class MapObject { + static Point = "point"; + setProperties(properties) { this.props = properties; } + properties() { return this.props; } + } + const messages = []; + const actions = {}; + const context = { + MapObject, ObjectGroup, + TileMap: {Orthogonal: "orthogonal"}, + tiled: { + mapEditor: {}, + registerAction(name, callback) { actions[name] = callback; return {}; }, + extendMenu(name, entries) { + assert.equal(name, "Map"); + assert.equal(entries[0].action, "StationConvertPaintedTiles"); + }, + log(text) { messages.push(text); }, + alert(text) { messages.push(text); }, + }, + }; + vm.createContext(context); + vm.runInContext(readFileSync(join(__dirname, "../assets/tiled/station.js"), "utf8"), context); + const map = { + isTileMap: true, orientation: "orthogonal", infinite: false, + width: 2, height: 2, tileWidth: 32, tileHeight: 32, + layers: [], tilesets: [], macros: 0, + get layerCount() { return this.layers.length; }, + layerAt(index) { return this.layers[index]; }, + addLayer(layer) { this.layers.push(layer); }, + macro(name, callback) { + assert.equal(name, "Convert Station painted tiles to objects"); + this.macros++; + const layers = [...this.layers]; + const cells = layers.filter(layer => layer.isTileLayer) + .map(layer => [layer, new Map(layer.cells)]); + callback(); + this.undo = () => { + this.layers = layers; + for (const [layer, snapshot] of cells) layer.cells = snapshot; + }; + }, + }; + context.tiled.activeAsset = map; + return {map, context, actions, messages}; +} + +function tile(properties) { + return {property: name => properties[name], properties: () => properties}; +} + +function layer(entries) { + return { + name: "Paint", offset: {x: 0, y: 0}, isTileLayer: true, locked: false, + cells: new Map(entries.map(([x, y, tile, flags = {}]) => [`${x},${y}`, {tile, flags}])), + tileAt(x, y) { return this.cells.get(`${x},${y}`)?.tile || null; }, + cellAt(x, y) { return this.cells.get(`${x},${y}`).flags; }, + edit() { + const pending = []; + return { + setTile(x, y, value) { assert.equal(value, null); pending.push(`${x},${y}`); }, + apply: () => { for (const key of pending) this.cells.delete(key); }, + }; + }, + }; +} + +test("converts floors and walls together, retaining properties, flags and decorative terrain", () => { + const {map, context} = harness(); + const floor = tile({ + proto: "floor", "state.layer": '"floor"', "state.health": 17, + "state.vars": '{"stock":2}', name: "metadata", solid: false, + "state.flip_h": false, "state.flip_v": false, "state.flip_d": false, + "machine.progress": 1.25, legacy: 3, + }); + const ground = layer([ + [0, 0, floor, {flippedHorizontally: true, flippedVertically: true, flippedAntiDiagonally: true}], + [1, 0, tile({solid: true})], + ]); + const wall = layer([[0, 0, tile({proto: "wall", "state.layer": '"structure"', "state.health": 300})]]); + map.layers.push(ground, wall); + assert.equal(context.stationConvert(map), 2); + assert.equal(map.macros, 1); + const objects = map.layers[2].objects; + assert.equal(objects[0].className, "building"); + assert.equal(objects[0].shape, "point"); + assert.equal(objects[0].x, 16); + assert.equal(objects[0].y, 16); + assert.equal(objects[0].props["state.health"], 17); + assert.equal(objects[0].props["state.vars"], '{"stock":2}'); + assert.equal(objects[0].props["machine.progress"], 1.25); + assert.equal(objects[0].props.legacy, 3); + assert.equal(objects[0].props["state.flip_h"], true); + assert.equal(objects[0].props["state.flip_v"], true); + assert.equal(objects[0].props["state.flip_d"], true); + assert.equal(objects[0].props.name, undefined); + assert.equal(objects[0].props.solid, undefined); + assert.equal(ground.tileAt(0, 0), null); + assert.notEqual(ground.tileAt(1, 0), null); + assert.equal(wall.tileAt(0, 0), null); + assert.equal(map.selectedObjects, objects); + assert.equal(context.stationConvert(map), 0); + assert.equal(map.macros, 1); + map.undo(); + assert.equal(map.layers.length, 2); + assert.equal(ground.tileAt(0, 0), floor); + assert.notEqual(wall.tileAt(0, 0), null); +}); + +test("rejects same-layer duplicates before making changes", () => { + for (const properties of [{proto: "wall"}, {proto: "floor", "state.layer": '"floor"'}]) { + const {map, context} = harness(); + map.layers.push(layer([[0, 0, tile(properties)]]), layer([[0, 0, tile(properties)]])); + assert.throws(() => context.stationConvert(map), /Two gameplay objects/); + assert.equal(map.macros, 0); + assert.notEqual(map.layers[0].tileAt(0, 0), null); + } +}); + +test("detects conflicts with existing tile objects and point objects", () => { + for (const isTile of [false, true]) { + const {map, context} = harness(); + const objects = new context.ObjectGroup(); + objects.addObject({ + className: "building", x: 16, y: isTile ? 32 : 16, + tile: isTile ? tile({proto: "wall"}) : null, + properties: () => ({proto: "wall"}), + }); + map.layers.push(objects, layer([[0, 0, tile({proto: "wall"})]])); + assert.throws(() => context.stationConvert(map), /Two gameplay objects/); + assert.equal(map.macros, 0); + } +}); + +test("uses tileset prototype layers for existing objects with omitted defaults", () => { + for (const painted of [{proto: "wall"}, {proto: "floor", "state.layer": '"floor"'}]) { + const {map, context} = harness(); + map.tilesets.push({tiles: [tile({proto: "floor", "state.layer": '"floor"'})]}); + const objects = new context.ObjectGroup(); + objects.addObject({ + className: "building", x: 16, y: 16, tile: null, + properties: () => ({proto: "floor"}), + }); + map.layers.push(objects, layer([[0, 0, tile(painted)]])); + if (painted.proto === "wall") + assert.equal(context.stationConvert(map), 1); + else + assert.throws(() => context.stationConvert(map), /Two gameplay objects/); + } +}); + +test("walks groups and refuses unsupported maps, offsets, locks and invalid layer properties", () => { + const {map, context} = harness(); + const ground = layer([[1, 1, tile({proto: "plating", "state.layer": '"floor"'})]]); + map.layers.push({isGroupLayer: true, offset: {x: 0, y: 0}, layerCount: 1, layerAt: () => ground}); + assert.equal(context.stationConvert(map), 1); + assert.equal(map.layers[1].objects[0].x, 48); + for (const mutate of [ + map => { map.infinite = true; }, + map => { map.orientation = "isometric"; }, + map => { map.width = 0; }, + map => { map.width = 1000001; }, + map => { map.layers[0].offset.x = 32; }, + map => { map.layers[0].locked = true; }, + map => { map.layers[0] = layer([[0, 0, tile({proto: "floor", "state.layer": '"wrong"'})]]); }, + map => { map.layers[0] = layer([[0, 0, tile({proto: ""})]]); }, + ]) { + const {map, context} = harness(); + map.layers.push(layer([[0, 0, tile({proto: "floor"})]])); + mutate(map); + assert.throws(() => context.stationConvert(map)); + assert.equal(map.macros, 0); + } +}); + +test("menu action reports conversion and validation failures explicitly", () => { + const {map, actions, messages} = harness(); + actions.StationConvertPaintedTiles(); + assert.match(messages[0], /converted 0/); + map.infinite = true; + actions.StationConvertPaintedTiles(); + assert.match(messages[1], /conversion failed/); +});