/* global tiled, File, FileInfo, TileMap, TileLayer, MapObject, ObjectGroup, Dialog, Qt */ "use strict"; function stationGameplayProperties(properties) { const result = {}; for (const name of Object.keys(properties)) { if (name === "proto" || name.startsWith("state.") || name.startsWith("machine.") || (name !== "solid" && Number.isInteger(properties[name]))) { result[name] = properties[name]; } } return result; } function stationLayer(properties, defaults) { const layer = properties["state.layer"] === undefined ? (defaults && defaults.get(properties.proto)) || "structure" : JSON.parse(properties["state.layer"]); if (layer !== "floor" && layer !== "structure") throw new Error("state.layer must be the JSON string \"floor\" or \"structure\"."); return layer; } function stationMapLayers(map) { if (!map || !map.isTileMap) throw new Error("Open a Station map first."); if (map.infinite || map.orientation !== TileMap.Orthogonal) throw new Error("Station supports finite orthogonal maps only."); if (map.width <= 0 || map.height <= 0 || map.width * map.height > 1000000) throw new Error("Station maps must contain between 1 and 1,000,000 cells."); const layers = []; function visit(parent) { for (let i = 0; i < parent.layerCount; ++i) { const layer = parent.layerAt(i); if (layer.offset.x !== 0 || layer.offset.y !== 0) throw new Error("Station does not support layer offsets: " + layer.name); if (layer.isGroupLayer) visit(layer); else layers.push(layer); } } visit(map); return layers; } function stationPrototypeLayers(map) { const prototypeLayers = new Map(); for (const tileset of map.tilesets) { for (const tile of tileset.tiles) { const properties = tile.properties(); if (properties.proto === undefined || properties["state.layer"] === undefined) continue; const layer = stationLayer(properties); if (prototypeLayers.has(properties.proto) && prototypeLayers.get(properties.proto) !== layer) throw new Error("Conflicting state.layer defaults for " + properties.proto); prototypeLayers.set(properties.proto, layer); } } 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); if (occupied.has(key)) throw new Error("Two gameplay objects occupy " + key + ". Nothing was converted."); occupied.add(key); } for (const layer of layers.filter(layer => layer.isObjectLayer)) { for (const object of layer.objects) { if (object.className !== "building") continue; const properties = Object.assign({}, object.tile ? stationGameplayProperties(object.tile.properties()) : {}, object.properties()); reserve(Math.floor(object.x / map.tileWidth), Math.floor((object.y - (object.tile ? 1 : 0)) / map.tileHeight), properties); } } const planned = []; for (const layer of layers.filter(layer => layer.isTileLayer)) { if (layer.locked) throw new Error("Unlock tile layer before converting: " + layer.name); for (let y = 0; y < map.height; ++y) { for (let x = 0; x < map.width; ++x) { const tile = layer.tileAt(x, y); if (!tile || tile.property("proto") === undefined) continue; const properties = stationGameplayProperties(tile.properties()); if (typeof properties.proto !== "string" || !properties.proto) throw new Error("Tile proto must be a nonempty building ID."); const cell = layer.cellAt(x, y); for (const [key, value] of [ ["state.flip_h", cell.flippedHorizontally], ["state.flip_v", cell.flippedVertically], ["state.flip_d", cell.flippedAntiDiagonally], ]) { if (value) properties[key] = true; } reserve(x, y, properties); planned.push({layer, x, y, properties}); } } } if (planned.length === 0) return 0; const names = ["Floors", "Wiring/Plumbing", "Triggers", "Ground Items", "Objects"]; const destinations = new Map(); for (const name of names) { const matches = layers.filter(layer => layer.isObjectLayer && layer.name === name); if (matches.length > 1) throw new Error("Multiple object layers named " + name + "; rename them before converting."); if (matches.length === 1) { if (matches[0].locked) throw new Error("Unlock object layer before converting: " + name); destinations.set(name, matches[0]); } } map.macro("Convert Station painted tiles to objects", function () { for (const name of names) { if (!destinations.has(name)) { const objects = new ObjectGroup(); objects.name = name; objects.visible = name !== "Floors" && name !== "Wiring/Plumbing"; map.addLayer(objects); destinations.set(name, objects); } } const selected = []; const edits = new Map(); for (const entry of planned) { const object = new MapObject(); object.name = entry.properties.proto; object.className = "building"; object.shape = MapObject.Point; object.x = (entry.x + 0.5) * map.tileWidth; object.y = (entry.y + 0.5) * map.tileHeight; object.setProperties(entry.properties); const name = stationLayer(entry.properties, prototypeLayers) === "floor" ? "Floors" : "Objects"; destinations.get(name).addObject(object); selected.push(object); if (!edits.has(entry.layer)) edits.set(entry.layer, entry.layer.edit()); edits.get(entry.layer).setTile(entry.x, entry.y, null); } for (const edit of edits.values()) edit.apply(); map.selectedObjects = selected; }); return planned.length; } const stationConvertAction = tiled.registerAction("StationConvertPaintedTiles", function () { try { const count = stationConvert(tiled.activeAsset); tiled.log("Station: converted " + count + " painted tiles to editable point objects."); } catch (error) { tiled.alert("Station conversion failed: " + error.message); } }); stationConvertAction.text = "Station: Convert Painted Tiles to Objects"; function stationProjectMapFiles(project) { if (!project || !project.fileName || project.folders.length === 0) throw new Error("Open a Station project first (Project > Open Project...)."); const files = new Set(); function visit(dir) { const fileFilter = File.Files | File.NoSymLinks; for (const name of File.directoryEntries(dir, fileFilter)) { if (name.toLowerCase().endsWith(".tmx")) files.add(FileInfo.cleanPath(dir + "/" + name)); } // Symlinked folders are skipped so link cycles cannot loop forever. const dirFilter = File.Dirs | File.NoDotAndDotDot | File.NoSymLinks; for (const name of File.directoryEntries(dir, dirFilter)) visit(dir + "/" + name); } for (const folder of project.folders) visit(folder); return [...files].sort(); } // Converted maps stay open and unsaved so the result can be reviewed and undone per map. function stationConvertProject() { const files = stationProjectMapFiles(tiled.project); const previous = tiled.activeAsset; const report = {maps: 0, tiles: 0, failures: []}; for (const file of files) { const wasOpen = tiled.openAssets.some(asset => FileInfo.cleanPath(asset.fileName) === file); const asset = tiled.open(file); try { if (!asset) throw new Error("could not open the map"); const count = stationConvert(asset); if (count > 0) { report.maps++; report.tiles += count; } else if (!wasOpen) { tiled.close(asset); } } catch (error) { report.failures.push(file + ": " + error.message); if (asset && !wasOpen && !asset.modified) tiled.close(asset); } } if (previous && tiled.openAssets.includes(previous)) tiled.activeAsset = previous; return report; } const stationConvertProjectAction = tiled.registerAction("StationConvertProjectMaps", function () { try { const report = stationConvertProject(); let message = "Station: converted " + report.tiles + " painted tiles in " + report.maps + " maps. Review them and use File > Save All."; if (report.failures.length > 0) message += "\n\nNot converted:\n" + report.failures.join("\n"); tiled.log(message); tiled.alert(message); } catch (error) { tiled.alert("Station conversion failed: " + error.message); } }); stationConvertProjectAction.text = "Station: Convert All Project Maps"; 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: "StationConvertProjectMaps"}, {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."; }, }); }