feat(tiled): paint objects with independent brush properties
Add a grid-snapped object brush with configurable per-tile defaults, drag previews, independently editable building properties and one-step stroke undo. Validate placement before changing the map, preserve integer properties and report mouse-callback errors without blocking the editor. Ship a Tiled project that loads the bundled extensions after the standard trust prompt, ignore local editor sessions and document the painting workflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
3c568dc6f3
commit
232305490d
|
|
@ -3,3 +3,4 @@
|
||||||
__pycache__/
|
__pycache__/
|
||||||
/.NOCOMMIT
|
/.NOCOMMIT
|
||||||
dist
|
dist
|
||||||
|
*.tiled-session
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- A Station Object Brush in Tiled with per-tile brush property settings and
|
||||||
|
independently editable wall/floor objects, drag previews and stroke undo.
|
||||||
|
Open the bundled `assets/station.tiled-project` to load Station extensions
|
||||||
|
without installing them globally (Tiled requests permission on first use).
|
||||||
- `cargo xtask watch --serve` serves the packaging output with directory listings
|
- `cargo xtask watch --serve` serves the packaging output with directory listings
|
||||||
and downloads at `http://127.0.0.1:9932/`, including while rebuilds are paused.
|
and downloads at `http://127.0.0.1:9932/`, including while rebuilds are paused.
|
||||||
- `cargo xtask watch` rebuilds and packs after a resettable 10-second quiet period,
|
- `cargo xtask watch` rebuilds and packs after a resettable 10-second quiet period,
|
||||||
|
|
|
||||||
33
README.md
33
README.md
|
|
@ -411,9 +411,36 @@ terrain snapshot retain static seed terrain until explicitly imported.
|
||||||
|
|
||||||
#### Converting painted tiles inside Tiled
|
#### Converting painted tiles inside Tiled
|
||||||
|
|
||||||
The extension `assets/tiled/station.js` ships with the game. In Tiled 1.10 or newer,
|
Open the bundled `assets/station.tiled-project` in Tiled 1.10 or newer. It loads
|
||||||
open **Edit > Preferences > Plugins > Open** to open the extensions directory,
|
`assets/tiled/station.js` as a project extension; accept Tiled's **Enable
|
||||||
copy `station.js` there, then enable/reload it (or restart Tiled).
|
Extensions** prompt the first time. No copying or global plugin installation is
|
||||||
|
needed. The project and extension are also included in packaged downloads.
|
||||||
|
For use without the project, open **Edit > Preferences > Plugins > Open**, copy
|
||||||
|
the contents of `assets/tiled/` into that extensions directory, then enable/reload it.
|
||||||
|
|
||||||
|
To draw walls or floors with independent properties:
|
||||||
|
|
||||||
|
1. Select a single gameplay tile in the **Tilesets** panel.
|
||||||
|
2. Choose **Map > Station: Brush Properties** (also available in the Station
|
||||||
|
brush toolbar). Set health, solidity, script variables or other tile-defined
|
||||||
|
state. JSON-valued fields such as `state.vars` and `state.variant` keep their
|
||||||
|
JSON syntax. Prototype and gameplay layer are fixed by the selected tile.
|
||||||
|
3. Select **Station Object Brush** in the tools toolbar and click or drag to
|
||||||
|
draw. Each stroke places visual `building` tile objects, snapping to the grid,
|
||||||
|
with a separate copy of the brush properties on every object. A stroke is one
|
||||||
|
undo step; Escape cancels it before placement.
|
||||||
|
4. Use **Select Objects** to edit individual placed objects in the Properties
|
||||||
|
panel. Floors go into the initially hidden **Floors** layer; enable its eye
|
||||||
|
icon to inspect them.
|
||||||
|
|
||||||
|
Brush settings are remembered per tile until extensions reload or Tiled closes.
|
||||||
|
Changing them affects only future strokes, not tileset defaults or existing
|
||||||
|
objects. Floor/structure overlap is allowed, but a stroke cannot overwrite an
|
||||||
|
existing building in the same gameplay layer, overlap unconverted gameplay
|
||||||
|
tiles, or write to a locked destination. Edit/delete the existing object or
|
||||||
|
convert the painted tiles first. The normal Tiled Stamp Brush still paints
|
||||||
|
shared tileset defaults, not per-cell properties. Rejected strokes leave the map
|
||||||
|
unchanged and report the reason in the status bar and **Console** view.
|
||||||
|
|
||||||
Paint the marked tiles normally, then choose **Map > Station: Convert Painted
|
Paint the marked tiles normally, then choose **Map > Station: Convert Painted
|
||||||
Tiles to Objects**. The extension replaces all marked painted cells with selected
|
Tiles to Objects**. The extension replaces all marked painted cells with selected
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extensionsPath": "tiled",
|
||||||
|
"folders": [
|
||||||
|
"maps",
|
||||||
|
"tilesets",
|
||||||
|
"sprites"
|
||||||
|
],
|
||||||
|
"propertyTypes": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||||
|
<path fill="#90a4ae" stroke="#37474f" d="M2 11h9v9H2zm11 0h9v9h-9zM2 1h9v8H2z"/>
|
||||||
|
<path fill="#ffca28" stroke="#795548" d="m20 1 3 3-9 9-3-3z"/>
|
||||||
|
<path fill="#5c6bc0" stroke="#283593" d="M10 11c-4 0-2 6-6 6 5 3 10-1 9-4z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 319 B |
|
|
@ -1,4 +1,4 @@
|
||||||
/* global tiled, TileMap, MapObject, ObjectGroup */
|
/* global tiled, TileMap, TileLayer, MapObject, ObjectGroup, Dialog, Qt */
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
function stationGameplayProperties(properties) {
|
function stationGameplayProperties(properties) {
|
||||||
|
|
@ -21,7 +21,7 @@ function stationLayer(properties, defaults) {
|
||||||
return layer;
|
return layer;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stationConvert(map) {
|
function stationMapLayers(map) {
|
||||||
if (!map || !map.isTileMap)
|
if (!map || !map.isTileMap)
|
||||||
throw new Error("Open a Station map first.");
|
throw new Error("Open a Station map first.");
|
||||||
if (map.infinite || map.orientation !== TileMap.Orthogonal)
|
if (map.infinite || map.orientation !== TileMap.Orthogonal)
|
||||||
|
|
@ -42,7 +42,10 @@ function stationConvert(map) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
visit(map);
|
visit(map);
|
||||||
|
return layers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stationPrototypeLayers(map) {
|
||||||
const prototypeLayers = new Map();
|
const prototypeLayers = new Map();
|
||||||
for (const tileset of map.tilesets) {
|
for (const tileset of map.tilesets) {
|
||||||
for (const tile of tileset.tiles) {
|
for (const tile of tileset.tiles) {
|
||||||
|
|
@ -55,6 +58,12 @@ function stationConvert(map) {
|
||||||
prototypeLayers.set(properties.proto, layer);
|
prototypeLayers.set(properties.proto, layer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return prototypeLayers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stationConvert(map) {
|
||||||
|
const layers = stationMapLayers(map);
|
||||||
|
const prototypeLayers = stationPrototypeLayers(map);
|
||||||
const occupied = new Set();
|
const occupied = new Set();
|
||||||
function reserve(x, y, properties) {
|
function reserve(x, y, properties) {
|
||||||
const key = x + "," + y + "," + stationLayer(properties, prototypeLayers);
|
const key = x + "," + y + "," + stationLayer(properties, prototypeLayers);
|
||||||
|
|
@ -158,5 +167,235 @@ const stationConvertAction = tiled.registerAction("StationConvertPaintedTiles",
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
stationConvertAction.text = "Station: Convert Painted Tiles to Objects";
|
stationConvertAction.text = "Station: Convert Painted Tiles to Objects";
|
||||||
if (tiled.mapEditor)
|
|
||||||
tiled.extendMenu("Map", [{action: "StationConvertPaintedTiles"}]);
|
const stationBrushProperties = new Map();
|
||||||
|
|
||||||
|
function stationBrushSettings(tile) {
|
||||||
|
if (!tile || typeof tile.property("proto") !== "string" || !tile.property("proto"))
|
||||||
|
throw new Error("Select one gameplay wall or floor tile in the Tilesets panel first.");
|
||||||
|
return Object.assign({}, stationGameplayProperties(tile.properties()), stationBrushProperties.get(tile));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stationConfigureBrush(tile) {
|
||||||
|
const properties = stationBrushSettings(tile);
|
||||||
|
const dialog = new Dialog("Station Brush Properties");
|
||||||
|
dialog.newRowMode = Dialog.SingleWidgetRows;
|
||||||
|
dialog.addLabel("Properties apply only to newly drawn objects; tileset defaults are unchanged.");
|
||||||
|
const inputs = new Map();
|
||||||
|
for (const [name, value] of Object.entries(properties)) {
|
||||||
|
if (name === "proto" || name === "state.layer") {
|
||||||
|
dialog.addLabel(name + ": " + value);
|
||||||
|
} else if (typeof value === "boolean") {
|
||||||
|
const input = dialog.addCheckBox(name);
|
||||||
|
input.checked = value;
|
||||||
|
inputs.set(name, input);
|
||||||
|
} else {
|
||||||
|
inputs.set(name, dialog.addTextInput(name, String(value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.addButton("Apply").clicked.connect(function () {
|
||||||
|
try {
|
||||||
|
const edited = Object.assign({}, properties);
|
||||||
|
for (const [name, input] of inputs) {
|
||||||
|
const original = properties[name];
|
||||||
|
let value = typeof original === "boolean" ? input.checked : input.text;
|
||||||
|
if (typeof original === "number") {
|
||||||
|
const integer = Number.isInteger(original) && name !== "machine.progress";
|
||||||
|
if (!value.trim() || !Number.isFinite(Number(value))
|
||||||
|
|| (integer && (!Number.isInteger(Number(value))
|
||||||
|
|| Number(value) < -2147483648 || Number(value) > 2147483647)))
|
||||||
|
throw new Error(name + " must be a valid " + (integer ? "32-bit integer." : "number."));
|
||||||
|
value = Number(value);
|
||||||
|
} else if (typeof original === "string"
|
||||||
|
&& (name.startsWith("state.") || name.startsWith("machine."))) {
|
||||||
|
JSON.parse(value);
|
||||||
|
}
|
||||||
|
edited[name] = value;
|
||||||
|
}
|
||||||
|
stationLayer(edited);
|
||||||
|
stationBrushProperties.set(tile, edited);
|
||||||
|
dialog.accept();
|
||||||
|
} catch (error) {
|
||||||
|
tiled.alert("Invalid brush properties: " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.addButton("Cancel").clicked.connect(function () { dialog.reject(); });
|
||||||
|
dialog.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stationPaint(map, tile, properties, cells) {
|
||||||
|
const layers = stationMapLayers(map);
|
||||||
|
const defaults = stationPrototypeLayers(map);
|
||||||
|
const kind = stationLayer(properties, defaults);
|
||||||
|
const name = kind === "floor" ? "Floors" : "Objects";
|
||||||
|
const destinations = layers.filter(layer => layer.isObjectLayer && layer.name === name);
|
||||||
|
if (destinations.length > 1)
|
||||||
|
throw new Error("Multiple object layers named " + name + "; rename them before painting.");
|
||||||
|
if (destinations.length && destinations[0].locked)
|
||||||
|
throw new Error("Unlock object layer before painting: " + name);
|
||||||
|
const wanted = new Set(cells.map(cell => cell.x + "," + cell.y));
|
||||||
|
for (const cell of cells) {
|
||||||
|
if (cell.x < 0 || cell.y < 0 || cell.x >= map.width || cell.y >= map.height)
|
||||||
|
throw new Error("Cannot paint outside the map.");
|
||||||
|
}
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.isObjectLayer) {
|
||||||
|
for (const object of layer.objects) {
|
||||||
|
if (object.className !== "building")
|
||||||
|
continue;
|
||||||
|
const state = Object.assign({},
|
||||||
|
object.tile ? stationGameplayProperties(object.tile.properties()) : {},
|
||||||
|
object.properties());
|
||||||
|
const key = Math.floor(object.x / map.tileWidth) + ","
|
||||||
|
+ Math.floor((object.y - (object.tile ? 1 : 0)) / map.tileHeight);
|
||||||
|
if (wanted.has(key) && stationLayer(state, defaults) === kind)
|
||||||
|
throw new Error("A " + kind + " already occupies " + key + ". Edit that object or delete it first.");
|
||||||
|
}
|
||||||
|
} else if (layer.isTileLayer) {
|
||||||
|
for (const cell of cells) {
|
||||||
|
const painted = layer.tileAt(cell.x, cell.y);
|
||||||
|
if (painted && painted.property("proto") !== undefined
|
||||||
|
&& stationLayer(painted.properties(), defaults) === kind)
|
||||||
|
throw new Error("Painted gameplay tiles in " + layer.name + " at "
|
||||||
|
+ cell.x + "," + cell.y + " overlap this stroke. Convert them to objects first.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cells.length)
|
||||||
|
return;
|
||||||
|
map.macro("Paint Station objects", function () {
|
||||||
|
let destination = destinations[0];
|
||||||
|
if (!destination) {
|
||||||
|
destination = new ObjectGroup();
|
||||||
|
destination.name = name;
|
||||||
|
destination.visible = kind !== "floor";
|
||||||
|
map.addLayer(destination);
|
||||||
|
}
|
||||||
|
const selected = [];
|
||||||
|
for (const cell of cells) {
|
||||||
|
const object = new MapObject();
|
||||||
|
object.name = properties.proto;
|
||||||
|
object.className = "building";
|
||||||
|
object.tile = tile;
|
||||||
|
object.width = map.tileWidth;
|
||||||
|
object.height = map.tileHeight;
|
||||||
|
object.x = cell.x * map.tileWidth;
|
||||||
|
object.y = (cell.y + 1) * map.tileHeight;
|
||||||
|
for (const [key, value] of Object.entries(properties))
|
||||||
|
object.setProperty(key, value);
|
||||||
|
destination.addObject(object);
|
||||||
|
selected.push(object);
|
||||||
|
}
|
||||||
|
map.selectedObjects = selected;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stationBrushMove(tool) {
|
||||||
|
if (!tool.stroke)
|
||||||
|
return;
|
||||||
|
const map = tool.map;
|
||||||
|
const end = {
|
||||||
|
x: Math.max(0, Math.min(map.width - 1, tool.tilePosition.x)),
|
||||||
|
y: Math.max(0, Math.min(map.height - 1, tool.tilePosition.y)),
|
||||||
|
};
|
||||||
|
const start = tool.lastCell || end;
|
||||||
|
const steps = Math.max(Math.abs(end.x - start.x), Math.abs(end.y - start.y));
|
||||||
|
for (let i = 0; i <= steps; ++i) {
|
||||||
|
const cell = {
|
||||||
|
x: Math.round(start.x + (end.x - start.x) * (steps ? i / steps : 0)),
|
||||||
|
y: Math.round(start.y + (end.y - start.y) * (steps ? i / steps : 0)),
|
||||||
|
};
|
||||||
|
tool.stroke.set(cell.x + "," + cell.y, cell);
|
||||||
|
}
|
||||||
|
tool.lastCell = end;
|
||||||
|
const preview = new TileMap();
|
||||||
|
preview.setSize(map.width, map.height);
|
||||||
|
preview.setTileSize(map.tileWidth, map.tileHeight);
|
||||||
|
preview.addTileset(tool.brushTile.tileset);
|
||||||
|
const layer = new TileLayer();
|
||||||
|
preview.addLayer(layer);
|
||||||
|
const edit = layer.edit();
|
||||||
|
for (const cell of tool.stroke.values())
|
||||||
|
edit.setTile(cell.x, cell.y, tool.brushTile);
|
||||||
|
edit.apply();
|
||||||
|
tool.preview = preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stationBrushError(tool, error) {
|
||||||
|
tool.errorMessage = "Station brush: " + error.message;
|
||||||
|
tool.updateStatusInfo();
|
||||||
|
// Modal dialogs inside mouse callbacks keep the map's mouse grab until they close.
|
||||||
|
tiled.log(tool.errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stationPropertiesAction = tiled.registerAction("StationBrushProperties", function () {
|
||||||
|
try {
|
||||||
|
const tiles = tiled.mapEditor.tilesetsView.selectedTiles;
|
||||||
|
if (tiles.length !== 1)
|
||||||
|
throw new Error("Select exactly one gameplay tile in the Tilesets panel.");
|
||||||
|
stationConfigureBrush(tiles[0]);
|
||||||
|
} catch (error) {
|
||||||
|
tiled.alert("Station brush: " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stationPropertiesAction.text = "Station: Brush Properties";
|
||||||
|
|
||||||
|
if (tiled.mapEditor) {
|
||||||
|
tiled.extendMenu("Map", [
|
||||||
|
{action: "StationConvertPaintedTiles"},
|
||||||
|
{action: "StationBrushProperties"},
|
||||||
|
]);
|
||||||
|
tiled.registerTool("StationObjectBrush", {
|
||||||
|
name: "Station Object Brush",
|
||||||
|
icon: "brush.svg",
|
||||||
|
usesSelectedTiles: true,
|
||||||
|
toolBarActions: ["StationBrushProperties"],
|
||||||
|
mousePressed: function (button) {
|
||||||
|
if (button !== Qt.LeftButton)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
this.errorMessage = "";
|
||||||
|
this.updateStatusInfo();
|
||||||
|
stationMapLayers(this.map);
|
||||||
|
if (this.tilePosition.x < 0 || this.tilePosition.y < 0
|
||||||
|
|| this.tilePosition.x >= this.map.width || this.tilePosition.y >= this.map.height)
|
||||||
|
throw new Error("Cannot paint outside the map.");
|
||||||
|
this.brushTile = this.selectedTile;
|
||||||
|
this.brushProperties = stationBrushSettings(this.brushTile);
|
||||||
|
this.stroke = new Map();
|
||||||
|
this.lastCell = null;
|
||||||
|
stationBrushMove(this);
|
||||||
|
} catch (error) {
|
||||||
|
this.deactivated();
|
||||||
|
stationBrushError(this, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mouseMoved: function () { stationBrushMove(this); },
|
||||||
|
mouseReleased: function (button) {
|
||||||
|
if (button !== Qt.LeftButton || !this.stroke)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
stationBrushMove(this);
|
||||||
|
const cells = [...this.stroke.values()];
|
||||||
|
this.deactivated();
|
||||||
|
stationPaint(this.map, this.brushTile, this.brushProperties, cells);
|
||||||
|
} catch (error) {
|
||||||
|
this.deactivated();
|
||||||
|
stationBrushError(this, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deactivated: function () {
|
||||||
|
this.stroke = null;
|
||||||
|
this.lastCell = null;
|
||||||
|
this.preview = new TileMap();
|
||||||
|
},
|
||||||
|
mapChanged: function () { this.deactivated(); },
|
||||||
|
keyPressed: function (key) {
|
||||||
|
if (key === Qt.Key_Escape)
|
||||||
|
this.deactivated();
|
||||||
|
},
|
||||||
|
updateStatusInfo: function () {
|
||||||
|
this.statusInfo = this.errorMessage || "Station Object Brush: drag to paint; Escape cancels.";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,59 @@ function harness() {
|
||||||
class MapObject {
|
class MapObject {
|
||||||
static Point = "point";
|
static Point = "point";
|
||||||
setProperties(properties) { this.props = properties; }
|
setProperties(properties) { this.props = properties; }
|
||||||
|
setProperty(name, value) {
|
||||||
|
this.props ||= {};
|
||||||
|
this.props[name] = value;
|
||||||
|
}
|
||||||
properties() { return this.props; }
|
properties() { return this.props; }
|
||||||
}
|
}
|
||||||
const messages = [];
|
const messages = [];
|
||||||
const actions = {};
|
const actions = {};
|
||||||
|
const tools = {};
|
||||||
|
const dialogState = {values: {}, apply: true};
|
||||||
|
class Dialog {
|
||||||
|
static SingleWidgetRows = 1;
|
||||||
|
constructor() { this.inputs = {}; this.buttons = {}; }
|
||||||
|
addLabel() {}
|
||||||
|
addCheckBox(name) { return this.inputs[name] = {checked: false}; }
|
||||||
|
addTextInput(name, text) { return this.inputs[name] = {text}; }
|
||||||
|
addButton(name) {
|
||||||
|
return {clicked: {connect: callback => { this.buttons[name] = callback; }}};
|
||||||
|
}
|
||||||
|
accept() { this.accepted = true; }
|
||||||
|
reject() { this.accepted = false; }
|
||||||
|
exec() {
|
||||||
|
for (const [name, value] of Object.entries(dialogState.values)) {
|
||||||
|
if (typeof value === "boolean") this.inputs[name].checked = value;
|
||||||
|
else this.inputs[name].text = value;
|
||||||
|
}
|
||||||
|
this.buttons[dialogState.apply ? "Apply" : "Cancel"]();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class TileMap {
|
||||||
|
static Orthogonal = "orthogonal";
|
||||||
|
constructor() { this.layers = []; }
|
||||||
|
setSize(width, height) { this.width = width; this.height = height; }
|
||||||
|
setTileSize(width, height) { this.tileWidth = width; this.tileHeight = height; }
|
||||||
|
addLayer(layer) { this.layers.push(layer); }
|
||||||
|
addTileset() {}
|
||||||
|
}
|
||||||
|
class TileLayer {
|
||||||
|
constructor() { this.cells = new Map(); }
|
||||||
|
edit() {
|
||||||
|
return {
|
||||||
|
setTile: (x, y, tile) => { this.cells.set(`${x},${y}`, tile); },
|
||||||
|
apply() {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
const context = {
|
const context = {
|
||||||
MapObject, ObjectGroup,
|
MapObject, ObjectGroup, TileMap, TileLayer, Dialog,
|
||||||
TileMap: {Orthogonal: "orthogonal"},
|
Qt: {LeftButton: 1, RightButton: 2, Key_Escape: 27},
|
||||||
tiled: {
|
tiled: {
|
||||||
mapEditor: {},
|
mapEditor: {tilesetsView: {selectedTiles: []}},
|
||||||
registerAction(name, callback) { actions[name] = callback; return {}; },
|
registerAction(name, callback) { actions[name] = callback; return {}; },
|
||||||
|
registerTool(name, tool) { tools[name] = tool; return tool; },
|
||||||
extendMenu(name, entries) {
|
extendMenu(name, entries) {
|
||||||
assert.equal(name, "Map");
|
assert.equal(name, "Map");
|
||||||
assert.equal(entries[0].action, "StationConvertPaintedTiles");
|
assert.equal(entries[0].action, "StationConvertPaintedTiles");
|
||||||
|
|
@ -46,7 +89,7 @@ function harness() {
|
||||||
layerAt(index) { return this.layers[index]; },
|
layerAt(index) { return this.layers[index]; },
|
||||||
addLayer(layer) { this.layers.push(layer); },
|
addLayer(layer) { this.layers.push(layer); },
|
||||||
macro(name, callback) {
|
macro(name, callback) {
|
||||||
assert.equal(name, "Convert Station painted tiles to objects");
|
assert.ok(["Convert Station painted tiles to objects", "Paint Station objects"].includes(name));
|
||||||
this.macros++;
|
this.macros++;
|
||||||
const layers = [...this.layers];
|
const layers = [...this.layers];
|
||||||
const cells = layers.filter(layer => layer.isTileLayer)
|
const cells = layers.filter(layer => layer.isTileLayer)
|
||||||
|
|
@ -62,7 +105,19 @@ function harness() {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
context.tiled.activeAsset = map;
|
context.tiled.activeAsset = map;
|
||||||
return {map, context, actions, messages};
|
tools.StationObjectBrush.map = map;
|
||||||
|
for (const name of ["mousePressed", "mouseReleased", "mouseMoved"]) {
|
||||||
|
const callback = tools.StationObjectBrush[name];
|
||||||
|
tools.StationObjectBrush[name] = function (...args) {
|
||||||
|
const index = name === "mouseMoved" ? 0 : 1;
|
||||||
|
this.tilePosition = {
|
||||||
|
x: Math.floor(args[index] / map.tileWidth),
|
||||||
|
y: Math.floor(args[index + 1] / map.tileHeight),
|
||||||
|
};
|
||||||
|
return callback.apply(this, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {map, context, actions, messages, tools, dialogState};
|
||||||
}
|
}
|
||||||
|
|
||||||
function tile(properties) {
|
function tile(properties) {
|
||||||
|
|
@ -252,3 +307,157 @@ test("menu action reports conversion and validation failures explicitly", () =>
|
||||||
actions.StationConvertPaintedTiles();
|
actions.StationConvertPaintedTiles();
|
||||||
assert.match(messages[1], /conversion failed/);
|
assert.match(messages[1], /conversion failed/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("brush configures future objects without changing tileset or previous object properties", () => {
|
||||||
|
const {map, context, actions, dialogState, tools} = harness();
|
||||||
|
const defaults = {proto: "wall", "state.layer": '"structure"', "state.health": 300,
|
||||||
|
"state.solid": true, "state.vars": "{}", "state.variant": "null"};
|
||||||
|
const wall = tile(defaults);
|
||||||
|
const brush = tools.StationObjectBrush;
|
||||||
|
brush.selectedTile = wall;
|
||||||
|
context.tiled.mapEditor.tilesetsView.selectedTiles = [wall];
|
||||||
|
dialogState.values = {"state.health": "17", "state.solid": false, "state.vars": '{"stock":3}'};
|
||||||
|
actions.StationBrushProperties();
|
||||||
|
brush.mousePressed(1, 1, 1);
|
||||||
|
assert.equal(map.macros, 0);
|
||||||
|
assert.equal(brush.preview.layers[0].cells.size, 1);
|
||||||
|
brush.mouseReleased(1, 1, 1);
|
||||||
|
const object = map.layers[0].objects[0];
|
||||||
|
assert.equal(object.props["state.health"], 17);
|
||||||
|
assert.equal(object.props["state.solid"], false);
|
||||||
|
assert.equal(object.props["state.vars"], '{"stock":3}');
|
||||||
|
assert.equal(object.tile, wall);
|
||||||
|
assert.equal(object.className, "building");
|
||||||
|
assert.equal(object.x, 0);
|
||||||
|
assert.equal(object.y, 32);
|
||||||
|
assert.equal(object.width, 32);
|
||||||
|
assert.equal(defaults["state.health"], 300);
|
||||||
|
dialogState.values = {"state.health": "42"};
|
||||||
|
actions.StationBrushProperties();
|
||||||
|
brush.mousePressed(1, 33, 1);
|
||||||
|
brush.mouseReleased(1, 33, 1);
|
||||||
|
assert.equal(map.layers[0].objects[1].props["state.health"], 42);
|
||||||
|
assert.equal(object.props["state.health"], 17);
|
||||||
|
map.undo();
|
||||||
|
assert.equal(map.layers[0].objects.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("brush interpolates fast drags, deduplicates cells and undoes the entire stroke", () => {
|
||||||
|
const {map, tools} = harness();
|
||||||
|
map.width = 8;
|
||||||
|
const brush = tools.StationObjectBrush;
|
||||||
|
brush.selectedTile = tile({proto: "floor", "state.layer": '"floor"', "state.health": 100});
|
||||||
|
brush.mousePressed(1, 1, 1);
|
||||||
|
brush.mouseMoved(225, 1);
|
||||||
|
brush.mouseMoved(1, 1);
|
||||||
|
brush.mouseReleased(1, 225, 1);
|
||||||
|
assert.equal(map.macros, 1);
|
||||||
|
assert.equal(map.layers[0].name, "Floors");
|
||||||
|
assert.equal(map.layers[0].visible, false);
|
||||||
|
assert.equal(map.layers[0].objects.length, 8);
|
||||||
|
assert.equal(map.selectedObjects.length, 8);
|
||||||
|
assert.equal(brush.stroke, null);
|
||||||
|
map.undo();
|
||||||
|
assert.equal(map.layers.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("brush refuses occupied, painted, locked, duplicate and invalid maps without partial edits", () => {
|
||||||
|
for (const scenario of ["object", "painted", "locked", "duplicate", "offset", "infinite"]) {
|
||||||
|
const {map, context, tools, messages} = harness();
|
||||||
|
const wall = tile({proto: "wall", "state.layer": '"structure"'});
|
||||||
|
const group = new context.ObjectGroup();
|
||||||
|
group.name = "Objects";
|
||||||
|
map.layers.push(group);
|
||||||
|
if (scenario === "object") {
|
||||||
|
const object = new context.MapObject();
|
||||||
|
Object.assign(object, {className: "building", x: 16, y: 16});
|
||||||
|
object.setProperties({proto: "wall"});
|
||||||
|
group.addObject(object);
|
||||||
|
} else if (scenario === "painted") map.layers.push(layer([[1, 0, wall]]));
|
||||||
|
else if (scenario === "locked") group.locked = true;
|
||||||
|
else if (scenario === "duplicate") map.layers.push(Object.assign(new context.ObjectGroup(), {name: "Objects"}));
|
||||||
|
else if (scenario === "offset") group.offset.x = 10;
|
||||||
|
else if (scenario === "infinite") map.infinite = true;
|
||||||
|
const brush = tools.StationObjectBrush;
|
||||||
|
brush.selectedTile = wall;
|
||||||
|
context.tiled.alert = () => { assert.fail("Mouse callbacks must not show modal dialogs"); };
|
||||||
|
context.tiled.log = text => {
|
||||||
|
assert.equal(brush.stroke, null);
|
||||||
|
brush.mouseReleased(1, 33, 1);
|
||||||
|
messages.push(text);
|
||||||
|
};
|
||||||
|
brush.mousePressed(1, 1, 1);
|
||||||
|
brush.mouseReleased(1, 33, 1);
|
||||||
|
assert.equal(map.macros, 0, scenario);
|
||||||
|
assert.equal(messages.length, 1, scenario);
|
||||||
|
assert.match(messages[0], /Station brush:/);
|
||||||
|
assert.equal(brush.statusInfo, messages[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("brush allows floor and structure overlaps and cancels on Escape or map change", () => {
|
||||||
|
const {map, tools} = harness();
|
||||||
|
const brush = tools.StationObjectBrush;
|
||||||
|
brush.selectedTile = tile({proto: "floor", "state.layer": '"floor"'});
|
||||||
|
brush.mousePressed(1, 1, 1);
|
||||||
|
brush.keyPressed(27);
|
||||||
|
brush.mouseReleased(1, 1, 1);
|
||||||
|
assert.equal(map.macros, 0);
|
||||||
|
brush.mousePressed(1, 1, 1);
|
||||||
|
brush.mapChanged();
|
||||||
|
brush.mouseReleased(1, 1, 1);
|
||||||
|
assert.equal(map.macros, 0);
|
||||||
|
brush.mousePressed(2, 1, 1);
|
||||||
|
assert.equal(map.macros, 0);
|
||||||
|
brush.mousePressed(1, 1, 1);
|
||||||
|
brush.mouseReleased(1, 1, 1);
|
||||||
|
brush.selectedTile = tile({proto: "wall", "state.layer": '"structure"'});
|
||||||
|
brush.mousePressed(1, 1, 1);
|
||||||
|
brush.mouseReleased(1, 1, 1);
|
||||||
|
assert.equal(map.layers[0].objects.length, 1);
|
||||||
|
assert.equal(map.layers[1].objects.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("brush reports invalid input and cancellation preserves configured defaults", () => {
|
||||||
|
const {context, actions, tools, messages, dialogState} = harness();
|
||||||
|
actions.StationBrushProperties();
|
||||||
|
assert.match(messages.pop(), /Select exactly one/);
|
||||||
|
const wall = tile({proto: "wall", "state.health": 300, "state.vars": "{}"});
|
||||||
|
context.tiled.mapEditor.tilesetsView.selectedTiles = [wall];
|
||||||
|
for (const value of ["", "NaN", "1.5", "9007199254740992", "2147483648", "-2147483649"]) {
|
||||||
|
dialogState.values = {"state.health": value};
|
||||||
|
actions.StationBrushProperties();
|
||||||
|
assert.match(messages.pop(), /valid 32-bit integer/);
|
||||||
|
}
|
||||||
|
dialogState.values = {"state.vars": "invalid json"};
|
||||||
|
actions.StationBrushProperties();
|
||||||
|
assert.match(messages.pop(), /Invalid brush properties/);
|
||||||
|
dialogState.values = {"state.health": "17"};
|
||||||
|
dialogState.apply = false;
|
||||||
|
actions.StationBrushProperties();
|
||||||
|
assert.equal(context.stationBrushSettings(wall)["state.health"], 300);
|
||||||
|
dialogState.apply = true;
|
||||||
|
dialogState.values = {"state.health": "2147483647"};
|
||||||
|
actions.StationBrushProperties();
|
||||||
|
assert.equal(context.stationBrushSettings(wall)["state.health"], 2147483647);
|
||||||
|
tools.StationObjectBrush.selectedTile = tile({name: "water"});
|
||||||
|
tools.StationObjectBrush.mousePressed(1, 1, 1);
|
||||||
|
assert.match(messages.pop(), /Select one gameplay/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("brush supports fractional machine progress even when its default is zero", () => {
|
||||||
|
const {context, actions, dialogState} = harness();
|
||||||
|
const machine = tile({proto: "furnace", "machine.progress": 0});
|
||||||
|
context.tiled.mapEditor.tilesetsView.selectedTiles = [machine];
|
||||||
|
dialogState.values = {"machine.progress": "1.25"};
|
||||||
|
actions.StationBrushProperties();
|
||||||
|
assert.equal(context.stationBrushSettings(machine)["machine.progress"], 1.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bundled project loads the shipped extension directory", () => {
|
||||||
|
const project = JSON.parse(readFileSync(join(__dirname, "../assets/station.tiled-project"), "utf8"));
|
||||||
|
const extension = join(__dirname, "../assets", project.extensionsPath, "station.js");
|
||||||
|
assert.match(readFileSync(extension, "utf8"), /StationObjectBrush/);
|
||||||
|
for (const folder of project.folders)
|
||||||
|
assert.ok(require("node:fs").statSync(join(__dirname, "../assets", folder)).isDirectory());
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue