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
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
60
README.md
60
README.md
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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">
|
<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=""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>
|
||||||
<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=""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>
|
||||||
<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=""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>
|
||||||
<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=""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>
|
||||||
<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=""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>
|
</tile>
|
||||||
</tileset>
|
</tileset>
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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=""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]
|
#[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();
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
match MapLayout::load_legacy_terrain(&game_core::assets_dir(), &settings.map) {
|
||||||
Ok(layout) => layout,
|
Ok(layout) => layout,
|
||||||
Err(e) => return crate::fail(&mut commands, e.to_string()),
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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=""{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>')
|
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">
|
||||||
|
|
|
||||||
|
|
@ -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