feat(tiled): paint objects with independent brush properties
Test / Clippy and tests (nextest) (push) Failing after 1m33s Details
Windows package / Build Windows package (push) Failing after 41s Details

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:
Steve Biedermann 2026-10-21 23:36:34 +02:00
parent 3c568dc6f3
commit 232305490d
7 changed files with 506 additions and 12 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@
__pycache__/ __pycache__/
/.NOCOMMIT /.NOCOMMIT
dist dist
*.tiled-session

View File

@ -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,

View File

@ -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

View File

@ -0,0 +1,9 @@
{
"extensionsPath": "tiled",
"folders": [
"maps",
"tilesets",
"sprites"
],
"propertyTypes": []
}

5
assets/tiled/brush.svg Normal file
View File

@ -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

View File

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

View File

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