feat(world)!: add layered floors and painted-tile conversion

Separate floors from structures for occupancy, rendering, interaction and deconstruction. Convert marked Tiled tiles to stateful gameplay buildings, retain a configurable default terrain tile, and preserve legacy saved terrain without reseeding it.

Bundle a Tiled conversion extension with editable per-object properties, conflict checks and single-step undo. Cover conversion, persistence, collision and gameplay in Rust tests and run the extension tests in CI.

BREAKING CHANGE: network protocol version 6 adds layered building state and default terrain metadata; clients and servers must be upgraded together.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-10-21 21:40:20 +02:00
parent fadd159de6
commit 8f24528304
26 changed files with 1171 additions and 58 deletions

View File

@ -47,6 +47,13 @@ jobs:
- name: Test - name: Test
run: cargo nextest run --workspace --locked --profile ci 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 - name: Upload test report
if: always() if: always()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3

View File

@ -39,6 +39,13 @@ jobs:
- name: Test - name: Test
run: cargo nextest run --workspace --locked --profile ci 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 - name: Upload test report
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@ -7,6 +7,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added ### 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 - Admin `export [FILE]` and `import [FILE]` commands for editable Tiled snapshots of
terrain, buildings and ground items, including health, script variables, machine terrain, buildings and ground items, including health, script variables, machine
contents and processing state. Imports replace the map while preserving characters. 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. Save databases migrate automatically to schema version 2.
- Characters move freely instead of tile by tile: diagonal movement, sliding - Characters move freely instead of tile by tile: diagonal movement, sliding
along walls and around corners. Their exact position is saved. 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, 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 so older clients and servers can't connect. Characters saved by name
in existing worlds are not carried over to player ids. in existing worlds are not carried over to player ids.

View File

@ -268,7 +268,7 @@ Required: `id`, `inputs`, `outputs`. Optional: `name`, `tools` (needed but not c
### Buildings ### Buildings
Anything placed on the grid: walls, doors, machines, trees, rocks. Anything placed on the grid: floors, walls, doors, machines, trees, rocks.
```rsn ```rsn
buildings: [ buildings: [
@ -282,10 +282,16 @@ buildings: [
``` ```
Required: `id`, `name`, `sprite`. Optional: `description`, `variants` (name → sprite), 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 (makes it buildable from the build menu), `deconstructible` (default true; refunds
materials), `on_interact` (script hook), `vars` (integer state, persisted per building). 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 ### Machines
A machine adds item slots and its own recipes to the building with the same `id`. 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 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). 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`. 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**: - Object layers spawn gameplay entities by object **class**:
- `spawn` – player spawn point. - `spawn` – player spawn point.
- `building` – custom property `proto` = building id. `state.health`, - `building` – custom property `proto` = building id. `state.health`,
`state.solid`, `state.variant` and `state.vars` override the prototype defaults. `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 Integer custom properties without a `state.`/`machine.` prefix also override
individual script variables, for compatibility with existing maps. individual script variables, for compatibility with existing maps.
Machines accept `machine.inputs`, `machine.outputs`, `machine.recipe` and Machines accept `machine.inputs`, `machine.outputs`, `machine.recipe` and
`machine.progress`. `machine.progress`.
- `item` – custom property `proto` = item id and optional `count`. - `item` – custom property `proto` = item id and optional `count`.
- Objects are placed on the tile containing their position (point objects work well). - Objects are placed on the tile containing their position (point objects work well).
Tile objects inherit gameplay properties from their tiles; object properties
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 A default map seeds a new world; after that the database stores the authoritative
terrain and entity state. Existing saves acquire their terrain snapshot on their terrain and entity state. Existing saves acquire their terrain snapshot on their
next save. To apply a map to an existing world, use the admin `import` command. 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 #### Editing a running world's map
@ -418,6 +471,7 @@ positions, so avoid enclosing them in newly solid terrain.
```sh ```sh
cargo nextest run --workspace # unit tests + headless full-simulation test 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 cargo clippy --workspace --all-targets
``` ```

View File

@ -4,6 +4,25 @@
// solid (true), max_health (100), stations (tags for recipes), construction // solid (true), max_health (100), stations (tags for recipes), construction
// ({ materials, tools, skill, xp }), deconstructible (true), on_interact, vars. // ({ materials, tools, skill, xp }), deconstructible (true), on_interact, vars.
buildings: [ 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", id: "wall", name: "Wall", sprite: "sprites/buildings/wall.png",
max_health: 300, max_health: 300,

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="40" height="28" tilewidth="32" tileheight="32" infinite="0" nextlayerid="4" nextobjectid="30"> <map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="40" height="28" tilewidth="32" tileheight="32" infinite="0" nextlayerid="4" nextobjectid="30">
<properties><property name="default_tile" type="int" value="6"/></properties>
<tileset firstgid="1" source="../tilesets/station.tsx"/> <tileset firstgid="1" source="../tilesets/station.tsx"/>
<layer id="1" name="Ground" width="40" height="28"> <layer id="1" name="Ground" width="40" height="28">
<data encoding="csv"> <data encoding="csv">

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 B

141
assets/tiled/station.js Normal file
View File

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

View File

@ -2,13 +2,13 @@
<tileset version="1.10" tiledversion="1.10.2" name="station" tilewidth="32" tileheight="32" tilecount="8" columns="4"> <tileset version="1.10" tiledversion="1.10.2" name="station" tilewidth="32" tileheight="32" tilecount="8" columns="4">
<image source="station.png" width="128" height="64"/> <image source="station.png" width="128" height="64"/>
<tile id="0"> <tile id="0">
<properties><property name="name" value="floor"/></properties> <properties><property name="name" value="floor"/><property name="proto" value="floor"/><property name="state.layer" value="&quot;floor&quot;"/><property name="state.health" type="int" value="100"/><property name="state.solid" type="bool" value="false"/><property name="state.variant" value="null"/><property name="state.vars" value="{}"/></properties>
</tile> </tile>
<tile id="1"> <tile id="1">
<properties><property name="name" value="dark_floor"/></properties> <properties><property name="name" value="dark_floor"/><property name="proto" value="dark_floor"/><property name="state.layer" value="&quot;floor&quot;"/><property name="state.health" type="int" value="100"/><property name="state.solid" type="bool" value="false"/><property name="state.variant" value="null"/><property name="state.vars" value="{}"/></properties>
</tile> </tile>
<tile id="2"> <tile id="2">
<properties><property name="name" value="wall"/><property name="solid" type="bool" value="true"/></properties> <properties><property name="name" value="wall"/><property name="solid" type="bool" value="true"/><property name="proto" value="wall"/><property name="state.layer" value="&quot;structure&quot;"/><property name="state.health" type="int" value="300"/><property name="state.solid" type="bool" value="true"/><property name="state.variant" value="null"/><property name="state.vars" value="{}"/></properties>
</tile> </tile>
<tile id="3"> <tile id="3">
<properties><property name="name" value="grass"/></properties> <properties><property name="name" value="grass"/></properties>
@ -17,12 +17,12 @@
<properties><property name="name" value="water"/><property name="solid" type="bool" value="true"/></properties> <properties><property name="name" value="water"/><property name="solid" type="bool" value="true"/></properties>
</tile> </tile>
<tile id="5"> <tile id="5">
<properties><property name="name" value="plating"/></properties> <properties><property name="name" value="plating"/><property name="proto" value="plating"/><property name="state.layer" value="&quot;floor&quot;"/><property name="state.health" type="int" value="100"/><property name="state.solid" type="bool" value="false"/><property name="state.variant" value="null"/><property name="state.vars" value="{}"/></properties>
</tile> </tile>
<tile id="6"> <tile id="6">
<properties><property name="name" value="dirt"/></properties> <properties><property name="name" value="dirt"/></properties>
</tile> </tile>
<tile id="7"> <tile id="7">
<properties><property name="name" value="window"/><property name="solid" type="bool" value="true"/></properties> <properties><property name="name" value="window"/><property name="solid" type="bool" value="true"/><property name="proto" value="window"/><property name="state.layer" value="&quot;structure&quot;"/><property name="state.health" type="int" value="100"/><property name="state.solid" type="bool" value="true"/><property name="state.variant" value="null"/><property name="state.vars" value="{}"/></properties>
</tile> </tile>
</tileset> </tileset>

View File

@ -241,7 +241,7 @@ fn mouse(
camera: Query<(&Camera, &GlobalTransform)>, camera: Query<(&Camera, &GlobalTransform)>,
local: Res<LocalCharacter>, local: Res<LocalCharacter>,
characters: Query<&GridPos, With<Character>>, characters: Query<&GridPos, With<Character>>,
buildings: Query<&GridPos, With<Building>>, buildings: Query<(&GridPos, &BuildingState), With<Building>>,
) { ) {
if !buttons.just_pressed(MouseButton::Left) if !buttons.just_pressed(MouseButton::Left)
|| ui.blocks_game() || ui.blocks_game()
@ -268,7 +268,10 @@ fn mouse(
if me.distance(target) > REACH { if me.distance(target) > REACH {
return; 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)); commands.trigger(InteractAt(target));
} else { } else {
commands.client_trigger(PickUp { target }); commands.client_trigger(PickUp { target });

View File

@ -85,7 +85,14 @@ fn spawn_map(
commands commands
.entity(entity) .entity(entity)
.insert((Transform::default(), Visibility::default())); .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; let z = layer_index as f32 * 0.01;
for (i, cell) in layer.cells.iter().enumerate() { for (i, cell) in layer.cells.iter().enumerate() {
let Some(cell) = cell else { continue }; let Some(cell) = cell else { continue };
@ -174,12 +181,34 @@ fn building_visuals(
continue; continue;
}; };
let mut entity = commands.entity(entity); let mut entity = commands.entity(entity);
entity.insert(sprite( let mut visual = sprite(
&asset_server, &asset_server,
def.sprite_for(state.variant.as_deref()), def.sprite_for(state.variant.as_deref()),
TILE_SIZE, 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 { if has_transform {
entity.insert(transform); entity.insert(transform);
} else { } else {

View File

@ -6,7 +6,7 @@ use bevy::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
data::{BuildingDef, GameData, Ingredient, MachineDef, MachineRecipeDef}, data::{BuildingDef, BuildingLayer, GameData, Ingredient, MachineDef, MachineRecipeDef},
grid::Dir, grid::Dir,
}; };
@ -230,6 +230,14 @@ pub struct Building {
/// Mutable per-instance building state, editable from scripts. /// Mutable per-instance building state, editable from scripts.
#[derive(Component, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Component, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildingState { 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 solid: bool,
pub variant: Option<String>, pub variant: Option<String>,
pub health: i32, pub health: i32,
@ -239,10 +247,12 @@ pub struct BuildingState {
impl BuildingState { impl BuildingState {
pub fn new(def: &BuildingDef) -> Self { pub fn new(def: &BuildingDef) -> Self {
Self { Self {
layer: def.layer,
solid: def.solid, solid: def.solid,
variant: None, variant: None,
health: def.max_health, health: def.max_health,
vars: def.vars.clone(), vars: def.vars.clone(),
..Default::default()
} }
} }
} }

View File

@ -119,6 +119,14 @@ pub struct Construction {
pub xp: u32, 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)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BuildingDef { pub struct BuildingDef {
pub id: String, pub id: String,
@ -126,6 +134,8 @@ pub struct BuildingDef {
#[serde(default)] #[serde(default)]
pub description: String, pub description: String,
pub sprite: String, pub sprite: String,
#[serde(default)]
pub layer: BuildingLayer,
/// Alternative sprites selectable from scripts with `ctx.set_variant(name)`. /// Alternative sprites selectable from scripts with `ctx.set_variant(name)`.
#[serde(default)] #[serde(default)]
pub variants: HashMap<String, String>, pub variants: HashMap<String, String>,

View File

@ -16,7 +16,7 @@ pub use data::GameData;
pub const DEFAULT_MAP: &str = "maps/station.tmx"; pub const DEFAULT_MAP: &str = "maps/station.tmx";
pub const DEFAULT_PORT: u16 = 5000; pub const DEFAULT_PORT: u16 = 5000;
/// Bump when the protocol changes in incompatible ways. /// Bump when the protocol changes in incompatible ways.
pub const PROTOCOL_ID: u64 = 5; pub const PROTOCOL_ID: u64 = 6;
/// Locates the `assets` directory. /// Locates the `assets` directory.
/// ///

View File

@ -32,6 +32,8 @@ pub struct TilesetInfo {
pub spacing: u32, pub spacing: u32,
pub margin: u32, pub margin: u32,
pub solid_tiles: BTreeSet<u32>, pub solid_tiles: BTreeSet<u32>,
#[serde(default)]
pub gameplay_tiles: BTreeMap<u32, BTreeMap<String, String>>,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -69,6 +71,8 @@ pub struct MapLayout {
pub tilesets: Vec<TilesetInfo>, pub tilesets: Vec<TilesetInfo>,
pub layers: Vec<TileLayerData>, pub layers: Vec<TileLayerData>,
pub objects: Vec<MapObject>, pub objects: Vec<MapObject>,
#[serde(default)]
pub default_tile: Option<TileCell>,
solid: Vec<bool>, solid: Vec<bool>,
} }
@ -83,6 +87,19 @@ pub enum MapError {
impl MapLayout { impl MapLayout {
/// Loads an asset-relative or absolute map path. /// Loads an asset-relative or absolute map path.
pub fn load(assets_dir: &Path, map_path: &str) -> Result<Self, MapError> { pub fn load(assets_dir: &Path, map_path: &str) -> Result<Self, MapError> {
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, MapError> {
Self::load_inner(assets_dir, map_path, false)
}
fn load_inner(
assets_dir: &Path,
map_path: &str,
convert_painted: bool,
) -> Result<Self, MapError> {
let full = assets_dir.join(map_path); let full = assets_dir.join(map_path);
let unsupported = |msg: String| MapError::Unsupported(full.clone(), msg); let unsupported = |msg: String| MapError::Unsupported(full.clone(), msg);
let map = tiled::Loader::new() let map = tiled::Loader::new()
@ -91,6 +108,9 @@ impl MapLayout {
if map.orientation != tiled::Orientation::Orthogonal { if map.orientation != tiled::Orientation::Orthogonal {
return Err(unsupported("only orthogonal maps are supported".into())); 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 let assets_canon = assets_dir
.canonicalize() .canonicalize()
@ -125,6 +145,11 @@ impl MapLayout {
.filter(|(_, tile)| bool_prop(&tile.properties, "solid")) .filter(|(_, tile)| bool_prop(&tile.properties, "solid"))
.map(|(id, _)| id) .map(|(id, _)| id)
.collect(), .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 layers = Vec::new();
let mut solid = vec![false; (width * height) as usize]; let mut solid = vec![false; (width * height) as usize];
let mut objects = Vec::new(); let mut objects = Vec::new();
let mut painted = Vec::new();
let mut stack: Vec<tiled::Layer> = map.layers().collect(); let mut stack: Vec<tiled::Layer> = map.layers().collect();
stack.reverse(); stack.reverse();
while let Some(layer) = stack.pop() { 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"); let layer_solid = bool_prop(&layer.properties, "solid");
match layer.layer_type() { match layer.layer_type() {
tiled::LayerType::Tiles(tiles) => { tiled::LayerType::Tiles(tiles) => {
@ -157,20 +189,35 @@ impl MapLayout {
let mut cells = Vec::with_capacity(solid.len()); let mut cells = Vec::with_capacity(solid.len());
for y in 0..height as i32 { for y in 0..height as i32 {
for x in 0..width 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 let tile_solid = t
.get_tile() .get_tile()
.is_some_and(|d| bool_prop(&d.properties, "solid")); .is_some_and(|d| bool_prop(&d.properties, "solid"));
if tile_solid || layer_solid { if tile_solid || layer_solid {
solid[(y as u32 * width + x as u32) as usize] = true; solid[(y as u32 * width + x as u32) as usize] = true;
} }
TileCell { Some(TileCell {
tileset: t.tileset_index(), tileset: t.tileset_index(),
id: t.id(), id: t.id(),
flip_h: t.flip_h, flip_h: t.flip_h,
flip_v: t.flip_v, flip_v: t.flip_v,
flip_d: t.flip_d, flip_d: t.flip_d,
} })
}); });
cells.push(cell); cells.push(cell);
} }
@ -191,16 +238,24 @@ impl MapLayout {
} else { } else {
obj.y 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 { objects.push(MapObject {
id: obj.id(), id: obj.id(),
class: obj.user_type.clone(), class: obj.user_type.clone(),
name: obj.name.clone(), name: obj.name.clone(),
pos: GridPos::new((obj.x / tw).floor() as i32, (y / th).floor() as i32), pos: GridPos::new((obj.x / tw).floor() as i32, (y / th).floor() as i32),
properties: obj properties,
.properties
.iter()
.map(|(k, v)| (k.clone(), prop_to_string(v)))
.collect(),
}); });
} }
} }
@ -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 { Ok(Self {
width, width,
height, height,
@ -220,6 +322,7 @@ impl MapLayout {
tilesets, tilesets,
layers, layers,
objects, objects,
default_tile,
solid, solid,
}) })
} }
@ -230,7 +333,46 @@ impl MapLayout {
/// Tiles outside of the map are always solid. /// Tiles outside of the map are always solid.
pub fn is_solid(&self, pos: GridPos) -> bool { 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<String, String> {
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<String, String>, 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<Item = &'a MapObject> { pub fn objects_of_class<'a>(&'a self, class: &'a str) -> impl Iterator<Item = &'a MapObject> {
@ -265,11 +407,24 @@ mod tests {
assert!(!map.layers.is_empty()); assert!(!map.layers.is_empty());
assert!(map.objects_of_class("spawn").next().is_some()); assert!(map.objects_of_class("spawn").next().is_some());
assert!(map.is_solid(GridPos::new(-1, 0))); assert!(map.is_solid(GridPos::new(-1, 0)));
assert!( assert!(!map.is_solid(GridPos::new(0, 0)));
map.is_solid(GridPos::new(0, 0)), assert!(map.objects.iter().any(|o| o.pos == GridPos::new(0, 0)
"map border should be walls" && o.properties.get("proto").is_some_and(|p| p == "wall")));
);
let spawn = map.objects_of_class("spawn").next().unwrap().pos; let spawn = map.objects_of_class("spawn").next().unwrap().pos;
assert!(!map.is_solid(spawn)); 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")
}));
}
} }

View File

@ -316,8 +316,10 @@ pub fn on_interact(
return; return;
} }
face_towards(&mut facing, *pos, target); face_towards(&mut facing, *pos, target);
let Some((building_entity, building, _, mut state)) = let Some((building_entity, building, _, mut state)) = buildings
buildings.iter_mut().find(|(_, _, p, _)| **p == target) .iter_mut()
.filter(|(_, _, p, _)| **p == target)
.max_by_key(|(_, _, _, state)| state.layer)
else { else {
tell(&mut commands, client, "There is nothing there."); tell(&mut commands, client, "There is nothing there.");
return; return;
@ -442,7 +444,7 @@ pub fn on_construct(
data: Res<GameData>, data: Res<GameData>,
layout: Res<WorldLayout>, layout: Res<WorldLayout>,
mut characters: Characters, mut characters: Characters,
buildings: Query<&GridPos, (With<Building>, Without<Character>)>, buildings: Query<(&GridPos, &BuildingState), (With<Building>, Without<Character>)>,
occupants: Query<&Position, (With<Character>, Without<Building>)>, occupants: Query<&Position, (With<Character>, Without<Building>)>,
) { ) {
let client = ev.client_id; let client = ev.client_id;
@ -465,7 +467,17 @@ pub fn on_construct(
let Some(def) = data.buildings.get(&ev.message.building) else { let Some(def) = data.buildings.get(&ev.message.building) else {
return; 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."); tell(&mut commands, client, "There is no room to build there.");
return; return;
} }
@ -493,7 +505,16 @@ pub fn on_deconstruct(
players: Res<Players>, players: Res<Players>,
data: Res<GameData>, data: Res<GameData>,
mut characters: Characters, mut characters: Characters,
buildings: Query<(Entity, &Building, &GridPos, Option<&Machine>), Without<Character>>, buildings: Query<
(
Entity,
&Building,
&GridPos,
&BuildingState,
Option<&Machine>,
),
Without<Character>,
>,
) { ) {
let client = ev.client_id; let client = ev.client_id;
let target = ev.message.target; let target = ev.message.target;
@ -508,8 +529,10 @@ pub fn on_deconstruct(
return; return;
} }
face_towards(&mut facing, *pos, target); face_towards(&mut facing, *pos, target);
let Some((building_entity, building, _, machine)) = let Some((building_entity, building, _, _, machine)) = buildings
buildings.iter().find(|(_, _, p, _)| **p == target) .iter()
.filter(|(_, _, p, _, _)| **p == target)
.max_by_key(|(_, _, _, state, _)| state.layer)
else { else {
tell(&mut commands, client, "There is nothing to deconstruct."); tell(&mut commands, client, "There is nothing to deconstruct.");
return; return;

View File

@ -65,6 +65,11 @@ pub fn decode(layout: MapLayout, data: &GameData) -> Result<SaveGame, MapIoError
let mut state = BuildingState::new(def); let mut state = BuildingState::new(def);
overlay(&mut state, "state.", &object.properties) overlay(&mut state, "state.", &object.properties)
.map_err(|e| invalid(e.to_string()))?; .map_err(|e| invalid(e.to_string()))?;
if state.layer != def.layer {
return Err(invalid(
"state.layer must match the prototype's layer".into(),
));
}
for (key, value) in &object.properties { for (key, value) in &object.properties {
if key != "proto" && !key.starts_with("state.") && !key.starts_with("machine.") if key != "proto" && !key.starts_with("state.") && !key.starts_with("machine.")
{ {
@ -110,8 +115,10 @@ pub fn decode(layout: MapLayout, data: &GameData) -> Result<SaveGame, MapIoError
} }
None None
}; };
if !occupied.insert((object.pos.x, object.pos.y)) { if !occupied.insert((object.pos.x, object.pos.y, state.layer)) {
return Err(invalid("two buildings occupy the same tile".into())); return Err(invalid(
"two buildings occupy the same tile and layer".into(),
));
} }
save.buildings.push(SavedBuilding { save.buildings.push(SavedBuilding {
proto: proto.clone(), proto: proto.clone(),
@ -175,7 +182,27 @@ pub fn default_path(world: &World) -> PathBuf {
} }
pub fn export(world: &mut World, path: &Path) -> Result<(), MapIoError> { 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::<GameData>();
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) write(&save, path)
} }
@ -306,6 +333,15 @@ pub fn write(save: &SaveGame, path: &Path) -> Result<(), MapIoError> {
], ],
false, 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 gids = Vec::new();
let mut gid = 1u32; let mut gid = 1u32;
for (i, ts) in layout.tilesets.iter().enumerate() { for (i, ts) in layout.tilesets.iter().enumerate() {
@ -338,12 +374,29 @@ pub fn write(save: &SaveGame, path: &Path) -> Result<(), MapIoError> {
], ],
true, 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)?; tag(&mut xml, "tile", &[("id", id.to_string())], false)?;
write_properties( let mut fields = BTreeMap::new();
&mut xml, if ts.solid_tiles.contains(&id) {
&BTreeMap::from([("solid".into(), Value::Bool(true))]), 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, "tile")?;
} }
end(&mut xml, "tileset")?; end(&mut xml, "tileset")?;
@ -501,6 +554,173 @@ mod tests {
std::env::temp_dir().join(format!("station-map-{}.tmx", uuid::Uuid::new_v4())) std::env::temp_dir().join(format!("station-map-{}.tmx", uuid::Uuid::new_v4()))
} }
fn fixture(layers: &str, properties: &str) -> Result<MapLayout, game_core::map::MapError> {
let file = path();
let tileset = game_core::assets_dir().join("tilesets/station.tsx");
std::fs::write(&file, format!(
r#"<map version="1.10" orientation="orthogonal" width="2" height="2" tilewidth="32" tileheight="32">
<properties>{properties}</properties>
<tileset firstgid="1" source="{}"/>{layers}</map>"#, 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#"<property name="default_tile" type="int" value="4"/>"#;
let layers = r#"
<layer name="Floors" width="2" height="2"><data encoding="csv">3758096385,2,6,0</data></layer>
<layer name="Structures" width="2" height="2"><data encoding="csv">3,0,0,0</data></layer>"#;
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#"
<layer name="Floors" width="2" height="2"><data encoding="csv">0,0,0,0</data></layer>
<layer name="Structures" width="2" height="2"><data encoding="csv">0,0,0,0</data></layer>
<objectgroup>
<object id="1" type="building" x="16" y="16"><properties>
<property name="proto" value="floor"/>
<property name="state.flip_h" type="bool" value="true"/>
<property name="state.flip_v" type="bool" value="true"/>
<property name="state.flip_d" type="bool" value="true"/>
</properties><point/></object>
<object id="2" type="building" x="48" y="16"><properties><property name="proto" value="dark_floor"/></properties><point/></object>
<object id="3" type="building" x="16" y="48"><properties><property name="proto" value="plating"/></properties><point/></object>
<object id="4" type="building" x="16" y="16"><properties><property name="proto" value="wall"/></properties><point/></object>
</objectgroup>"#;
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#"
<layer width="2" height="2"><data encoding="csv">{first},0,0,0</data></layer>
<layer width="2" height="2"><data encoding="csv">{second},0,0,0</data></layer>"#
);
assert!(decode(fixture(&layers, "").unwrap(), &data).is_err());
}
for properties in [
r#"<property name="default_tile" type="int" value="0"/>"#,
r#"<property name="default_tile" type="int" value="8"/>"#,
r#"<property name="default_tile" type="int" value="-1"/>"#,
r#"<property name="default_tile" value="4"/>"#,
r#"<property name="default_tileset" type="int" value="0"/>"#,
r#"<property name="default_tile" type="int" value="4"/><property name="default_tileset" type="int" value="1"/>"#,
] {
assert!(fixture("", properties).is_err(), "{properties}");
}
let tile_object = r#"<objectgroup><object id="1" type="building" gid="3" x="0" y="32"><properties><property name="state.health" type="int" value="7"/></properties></object></objectgroup>"#;
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="&quot;floor&quot;""#);
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] #[test]
fn tiled_roundtrip_preserves_every_saved_object_property_and_terrain() { fn tiled_roundtrip_preserves_every_saved_object_property_and_terrain() {
let (data, mut save) = seeded(); let (data, mut save) = seeded();

View File

@ -74,7 +74,14 @@ pub fn move_characters(
.filter(|(_, state)| state.solid) .filter(|(_, state)| state.solid)
.map(|(pos, _)| *pos) .map(|(pos, _)| *pos)
.collect(); .collect();
let blocked = |tile: GridPos| layout.0.is_solid(tile) || solid.contains(&tile); let floors: HashSet<GridPos> = buildings
.iter()
.filter(|(_, state)| state.layer == game_core::data::BuildingLayer::Floor)
.map(|(pos, _)| *pos)
.collect();
let blocked = |tile: GridPos| {
layout.0.is_solid_with_floor(tile, floors.contains(&tile)) || solid.contains(&tile)
};
let dt = time.delta_secs(); let dt = time.delta_secs();
for (intent, mut pos) in &mut characters { for (intent, mut pos) in &mut characters {
if intent.0 == Vec2::ZERO { if intent.0 == Vec2::ZERO {

View File

@ -792,6 +792,18 @@ mod tests {
variant: Some("open".into()), variant: Some("open".into()),
health: 100, health: 100,
vars: [("stock".to_owned(), 3)].into(), 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, machine: None,
}); });

View File

@ -9,6 +9,7 @@ use game_core::{
components::{ components::{
Building, BuildingState, Character, GridPos, GroundItem, ItemStack, Machine, WorldMap, Building, BuildingState, Character, GridPos, GroundItem, ItemStack, Machine, WorldMap,
}, },
data::BuildingLayer,
map::MapLayout, map::MapLayout,
}; };
@ -60,10 +61,12 @@ pub fn setup_world(mut commands: Commands, settings: Res<ServerSettings>, data:
); );
let layout = match save.layout.take() { let layout = match save.layout.take() {
Some(layout) => layout, Some(layout) => layout,
None => match MapLayout::load(&game_core::assets_dir(), &settings.map) { None => {
Ok(layout) => layout, match MapLayout::load_legacy_terrain(&game_core::assets_dir(), &settings.map) {
Err(e) => return crate::fail(&mut commands, e.to_string()), Ok(layout) => layout,
}, Err(e) => return crate::fail(&mut commands, e.to_string()),
}
}
}; };
(layout, save) (layout, save)
} }
@ -136,7 +139,16 @@ pub fn is_blocked<'a>(
layout: &MapLayout, layout: &MapLayout,
buildings: impl IntoIterator<Item = (&'a GridPos, &'a BuildingState)>, buildings: impl IntoIterator<Item = (&'a GridPos, &'a BuildingState)>,
) -> bool { ) -> 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. /// Finds the closest free tile around `start` (spiral search), for spawning characters.

View File

@ -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") .find(|o| o.class == "building" && o.properties["proto"] == "door")
.unwrap(); .unwrap();
door.properties.insert("state.health".into(), "11".into()); 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::<game_core::GameData>()).unwrap(); let mut edited = map_io::decode(map, app.world().resource::<game_core::GameData>()).unwrap();
edited.items.truncate(1); edited.items.truncate(1);
edited.buildings.retain(|b| b.proto != "tree"); edited.buildings.retain(|b| b.proto != "tree");
@ -129,7 +135,7 @@ fn admin_tiled_import_replaces_world_preserves_characters_and_rejects_invalid_ma
assert!(reply[0].contains("imported"), "{reply:?}"); assert!(reply[0].contains("imported"), "{reply:?}");
let imported = persistence::snapshot(app.world_mut()); let imported = persistence::snapshot(app.world_mut());
assert!( assert!(
!app.world() app.world()
.resource::<WorldLayout>() .resource::<WorldLayout>()
.0 .0
.is_solid(GridPos::new(0, 0)) .is_solid(GridPos::new(0, 0))
@ -201,13 +207,183 @@ fn count(app: &App, me: Entity, item: &str) -> u32 {
app.world().get::<Inventory>(me).unwrap().count(item) app.world().get::<Inventory>(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::<Entity, With<Character>>()
.single(app.world())
.unwrap();
let pos = *app.world().get::<GridPos>(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::<game_core::GameData>().clone();
{
let mut inventory = app.world_mut().get_mut::<Inventory>(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::<WorldLayout>().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::<Position>(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::<Position>(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::<Entity, With<Character>>()
.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::<BuildingState>(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::<game_core::GameData>().clone();
app.world_mut()
.get_mut::<Inventory>(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::<game_server::world::WorldLayout>().0,
buildings.iter(app.world())
));
assert_eq!(count(&app, me, "iron_plate"), 2);
}
#[test] #[test]
fn login_move_and_interact() { fn login_move_and_interact() {
let mut app = app(); let mut app = app();
// The map defines buildings and items. // The map defines buildings and items.
let mut buildings = app.world_mut().query::<&Building>(); 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")); send(&mut app, login("Tester"));
let me = app let me = app
@ -329,7 +505,10 @@ fn login_move_and_interact() {
// Deconstruction refunds the materials. // Deconstruction refunds the materials.
send(&mut app, Deconstruct { target }); send(&mut app, Deconstruct { target });
let mut q = app.world_mut().query::<(&Building, &GridPos)>(); 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); assert_eq!(count(&app, me, "plank"), 2);
// Drop and pick up again. // Drop and pick up again.
@ -600,8 +779,14 @@ mod network {
let assets = game_core::assets_dir(); let assets = game_core::assets_dir();
let data = game_core::GameData::load_dir(&assets.join("data")).unwrap(); 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(); let mut map = game_core::map::MapLayout::load(&assets, file.to_str().unwrap()).unwrap();
assert!(map.is_solid(GridPos::new(0, 0))); assert!(!map.is_solid(GridPos::new(0, 0)));
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 edited = map_io::decode(map, &data).unwrap(); let edited = map_io::decode(map, &data).unwrap();
map_io::write(&edited, &file).unwrap(); map_io::write(&edited, &file).unwrap();
host.world_mut().client_trigger(Chat { host.world_mut().client_trigger(Chat {
@ -610,7 +795,7 @@ mod network {
let imported = |app: &mut App| { let imported = |app: &mut App| {
let mut maps = app.world_mut().query::<&WorldMap>(); let mut maps = app.world_mut().query::<&WorldMap>();
maps.iter(app.world()) 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 host, &mut guest], imported);
pump(&mut [&mut guest, &mut host], imported); pump(&mut [&mut guest, &mut host], imported);

View File

@ -77,12 +77,24 @@ def gen_tileset():
for i, (_, color, _, pattern) in enumerate(TILES): for i, (_, color, _, pattern) in enumerate(TILES):
draw_tile(d, (i % COLS) * T, (i // COLS) * T, color, pattern) draw_tile(d, (i % COLS) * T, (i // COLS) * T, color, pattern)
save(img, "tilesets/station.png") 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 = [] tiles_xml = []
for i, (name, _, solid, _) in enumerate(TILES): for i, (name, _, solid, _) in enumerate(TILES):
props = f'<property name="name" value="{name}"/>' props = f'<property name="name" value="{name}"/>'
if solid: if solid:
props += '<property name="solid" type="bool" value="true"/>' props += '<property name="solid" type="bool" value="true"/>'
if name in {"floor", "dark_floor", "plating", "wall", "window"}:
props += f'<property name="proto" value="{name}"/>'
layer = "floor" if name in {"floor", "dark_floor", "plating"} else "structure"
props += f'<property name="state.layer" value="&quot;{layer}&quot;"/>'
props += f'<property name="state.health" type="int" value="{300 if name == "wall" else 100}"/>'
props += f'<property name="state.solid" type="bool" value="{str(solid).lower()}"/>'
props += '<property name="state.variant" value="null"/>'
props += '<property name="state.vars" value="{}"/>'
tiles_xml.append(f' <tile id="{i}">\n <properties>{props}</properties>\n </tile>') tiles_xml.append(f' <tile id="{i}">\n <properties>{props}</properties>\n </tile>')
tsx = f"""<?xml version="1.0" encoding="UTF-8"?> tsx = f"""<?xml version="1.0" encoding="UTF-8"?>
<tileset version="1.10" tiledversion="1.10.2" name="station" tilewidth="{T}" tileheight="{T}" tilecount="{len(TILES)}" columns="{COLS}"> <tileset version="1.10" tiledversion="1.10.2" name="station" tilewidth="{T}" tileheight="{T}" tilecount="{len(TILES)}" columns="{COLS}">
@ -316,6 +328,7 @@ def gen_map():
tmx = f"""<?xml version="1.0" encoding="UTF-8"?> tmx = f"""<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="{W}" height="{H}" tilewidth="{T}" tileheight="{T}" infinite="0" nextlayerid="4" nextobjectid="{next_id[0]}"> <map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="{W}" height="{H}" tilewidth="{T}" tileheight="{T}" infinite="0" nextlayerid="4" nextobjectid="{next_id[0]}">
<properties><property name="default_tile" type="int" value="6"/></properties>
<tileset firstgid="1" source="../tilesets/station.tsx"/> <tileset firstgid="1" source="../tilesets/station.tsx"/>
<layer id="1" name="Ground" width="{W}" height="{H}"> <layer id="1" name="Ground" width="{W}" height="{H}">
<data encoding="csv"> <data encoding="csv">

200
tools/tiled.test.cjs Normal file
View File

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