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__/
|
||||
/.NOCOMMIT
|
||||
dist
|
||||
*.tiled-session
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||
|
||||
### 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
|
||||
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,
|
||||
|
|
|
|||
33
README.md
33
README.md
|
|
@ -411,9 +411,36 @@ 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).
|
||||
Open the bundled `assets/station.tiled-project` in Tiled 1.10 or newer. It loads
|
||||
`assets/tiled/station.js` as a project extension; accept Tiled's **Enable
|
||||
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
|
||||
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";
|
||||
|
||||
function stationGameplayProperties(properties) {
|
||||
|
|
@ -21,7 +21,7 @@ function stationLayer(properties, defaults) {
|
|||
return layer;
|
||||
}
|
||||
|
||||
function stationConvert(map) {
|
||||
function stationMapLayers(map) {
|
||||
if (!map || !map.isTileMap)
|
||||
throw new Error("Open a Station map first.");
|
||||
if (map.infinite || map.orientation !== TileMap.Orthogonal)
|
||||
|
|
@ -42,7 +42,10 @@ function stationConvert(map) {
|
|||
}
|
||||
}
|
||||
visit(map);
|
||||
return layers;
|
||||
}
|
||||
|
||||
function stationPrototypeLayers(map) {
|
||||
const prototypeLayers = new Map();
|
||||
for (const tileset of map.tilesets) {
|
||||
for (const tile of tileset.tiles) {
|
||||
|
|
@ -55,6 +58,12 @@ function stationConvert(map) {
|
|||
prototypeLayers.set(properties.proto, layer);
|
||||
}
|
||||
}
|
||||
return prototypeLayers;
|
||||
}
|
||||
|
||||
function stationConvert(map) {
|
||||
const layers = stationMapLayers(map);
|
||||
const prototypeLayers = stationPrototypeLayers(map);
|
||||
const occupied = new Set();
|
||||
function reserve(x, y, properties) {
|
||||
const key = x + "," + y + "," + stationLayer(properties, prototypeLayers);
|
||||
|
|
@ -158,5 +167,235 @@ const stationConvertAction = tiled.registerAction("StationConvertPaintedTiles",
|
|||
}
|
||||
});
|
||||
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 {
|
||||
static Point = "point";
|
||||
setProperties(properties) { this.props = properties; }
|
||||
setProperty(name, value) {
|
||||
this.props ||= {};
|
||||
this.props[name] = value;
|
||||
}
|
||||
properties() { return this.props; }
|
||||
}
|
||||
const messages = [];
|
||||
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 = {
|
||||
MapObject, ObjectGroup,
|
||||
TileMap: {Orthogonal: "orthogonal"},
|
||||
MapObject, ObjectGroup, TileMap, TileLayer, Dialog,
|
||||
Qt: {LeftButton: 1, RightButton: 2, Key_Escape: 27},
|
||||
tiled: {
|
||||
mapEditor: {},
|
||||
mapEditor: {tilesetsView: {selectedTiles: []}},
|
||||
registerAction(name, callback) { actions[name] = callback; return {}; },
|
||||
registerTool(name, tool) { tools[name] = tool; return tool; },
|
||||
extendMenu(name, entries) {
|
||||
assert.equal(name, "Map");
|
||||
assert.equal(entries[0].action, "StationConvertPaintedTiles");
|
||||
|
|
@ -46,7 +89,7 @@ function harness() {
|
|||
layerAt(index) { return this.layers[index]; },
|
||||
addLayer(layer) { this.layers.push(layer); },
|
||||
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++;
|
||||
const layers = [...this.layers];
|
||||
const cells = layers.filter(layer => layer.isTileLayer)
|
||||
|
|
@ -62,7 +105,19 @@ function harness() {
|
|||
},
|
||||
};
|
||||
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) {
|
||||
|
|
@ -252,3 +307,157 @@ test("menu action reports conversion and validation failures explicitly", () =>
|
|||
actions.StationConvertPaintedTiles();
|
||||
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