diff --git a/.gitignore b/.gitignore index 562dfaf..322e7d2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ /.NOCOMMIT dist +*.tiled-session diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a714ba..0c2dbc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/README.md b/README.md index c02db8f..853cc31 100644 --- a/README.md +++ b/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 diff --git a/assets/station.tiled-project b/assets/station.tiled-project new file mode 100644 index 0000000..22a5404 --- /dev/null +++ b/assets/station.tiled-project @@ -0,0 +1,9 @@ +{ + "extensionsPath": "tiled", + "folders": [ + "maps", + "tilesets", + "sprites" + ], + "propertyTypes": [] +} diff --git a/assets/tiled/brush.svg b/assets/tiled/brush.svg new file mode 100644 index 0000000..59e62f4 --- /dev/null +++ b/assets/tiled/brush.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/tiled/station.js b/assets/tiled/station.js index 9ca743b..3b63170 100644 --- a/assets/tiled/station.js +++ b/assets/tiled/station.js @@ -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."; + }, + }); +} diff --git a/tools/tiled.test.cjs b/tools/tiled.test.cjs index 5bc59fe..f4cd938 100644 --- a/tools/tiled.test.cjs +++ b/tools/tiled.test.cjs @@ -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()); +});