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:
parent
fadd159de6
commit
8f24528304
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
60
README.md
60
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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?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">
|
||||
<properties><property name="default_tile" type="int" value="6"/></properties>
|
||||
<tileset firstgid="1" source="../tilesets/station.tsx"/>
|
||||
<layer id="1" name="Ground" width="40" height="28">
|
||||
<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 |
|
|
@ -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"}]);
|
||||
|
|
@ -2,13 +2,13 @@
|
|||
<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"/>
|
||||
<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=""floor""/><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 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=""floor""/><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 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=""structure""/><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 id="3">
|
||||
<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>
|
||||
</tile>
|
||||
<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=""floor""/><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 id="6">
|
||||
<properties><property name="name" value="dirt"/></properties>
|
||||
</tile>
|
||||
<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=""structure""/><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>
|
||||
</tileset>
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ fn mouse(
|
|||
camera: Query<(&Camera, &GlobalTransform)>,
|
||||
local: Res<LocalCharacter>,
|
||||
characters: Query<&GridPos, With<Character>>,
|
||||
buildings: Query<&GridPos, With<Building>>,
|
||||
buildings: Query<(&GridPos, &BuildingState), With<Building>>,
|
||||
) {
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, String>,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ pub struct TilesetInfo {
|
|||
pub spacing: u32,
|
||||
pub margin: u32,
|
||||
pub solid_tiles: BTreeSet<u32>,
|
||||
#[serde(default)]
|
||||
pub gameplay_tiles: BTreeMap<u32, BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -69,6 +71,8 @@ pub struct MapLayout {
|
|||
pub tilesets: Vec<TilesetInfo>,
|
||||
pub layers: Vec<TileLayerData>,
|
||||
pub objects: Vec<MapObject>,
|
||||
#[serde(default)]
|
||||
pub default_tile: Option<TileCell>,
|
||||
solid: Vec<bool>,
|
||||
}
|
||||
|
||||
|
|
@ -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, 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 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<tiled::Layer> = 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<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> {
|
||||
|
|
@ -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")
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GameData>,
|
||||
layout: Res<WorldLayout>,
|
||||
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>)>,
|
||||
) {
|
||||
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<Players>,
|
||||
data: Res<GameData>,
|
||||
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 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;
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ pub fn decode(layout: MapLayout, data: &GameData) -> Result<SaveGame, MapIoError
|
|||
let mut state = BuildingState::new(def);
|
||||
overlay(&mut state, "state.", &object.properties)
|
||||
.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 {
|
||||
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
|
||||
};
|
||||
if !occupied.insert((object.pos.x, object.pos.y)) {
|
||||
return Err(invalid("two buildings occupy the same tile".into()));
|
||||
if !occupied.insert((object.pos.x, object.pos.y, state.layer)) {
|
||||
return Err(invalid(
|
||||
"two buildings occupy the same tile and layer".into(),
|
||||
));
|
||||
}
|
||||
save.buildings.push(SavedBuilding {
|
||||
proto: proto.clone(),
|
||||
|
|
@ -175,7 +182,27 @@ pub fn default_path(world: &World) -> 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::<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)
|
||||
}
|
||||
|
||||
|
|
@ -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<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=""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();
|
||||
|
|
|
|||
|
|
@ -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<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();
|
||||
for (intent, mut pos) in &mut characters {
|
||||
if intent.0 == Vec2::ZERO {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ServerSettings>, data:
|
|||
);
|
||||
let layout = match save.layout.take() {
|
||||
Some(layout) => layout,
|
||||
None => match MapLayout::load(&game_core::assets_dir(), &settings.map) {
|
||||
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<Item = (&'a GridPos, &'a BuildingState)>,
|
||||
) -> 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.
|
||||
|
|
|
|||
|
|
@ -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::<game_core::GameData>()).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::<WorldLayout>()
|
||||
.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)
|
||||
}
|
||||
|
||||
#[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]
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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'<property name="name" value="{name}"/>'
|
||||
if solid:
|
||||
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=""{layer}""/>'
|
||||
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>')
|
||||
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}">
|
||||
|
|
@ -316,6 +328,7 @@ def gen_map():
|
|||
|
||||
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]}">
|
||||
<properties><property name="default_tile" type="int" value="6"/></properties>
|
||||
<tileset firstgid="1" source="../tilesets/station.tsx"/>
|
||||
<layer id="1" name="Ground" width="{W}" height="{H}">
|
||||
<data encoding="csv">
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
Loading…
Reference in New Issue