467 lines
19 KiB
JavaScript
467 lines
19 KiB
JavaScript
/* 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.";
|
|
},
|
|
});
|
|
}
|