station/assets/tiled/station.js

467 lines
19 KiB
JavaScript
Raw Normal View History

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