How to replace tileset in layer?

I’m building a dynamically lit game – which means, every tileset has several layers: the diffuse layer, the normal layer, specular layer, and the emissive layer.

This makes it take a long time to set up automapping rules for these tilesets, as right now I’m manually placing every tile in the tileset 4 times to set up the automapping rule, once for each of these “layers”.

It would be much faster if I could just place the first layer down manually, and then clone the layer, and replace the “diffuse” tileset on the cloned layer with the “normal” tileset, so that I now have the automapping rules finished for a second “layer” in an instant. (hopefully that makes sense?)

Is something like this possible?

I figured out a solution for this today. Didn’t want to spend a whole lot of time on this (my game dev time is very limited!), so I had ChatGPT generate a good amount of it… but it sucked at following correct syntax, so I had to fix most of it and re-write a good amount of it anyways :stuck_out_tongue:

It’s really hackish – it replaces ALL tilesets on the current selected layer. But, that’s perfect for what I’m doing. It also only works if the tileset you’re using to replace with has the same number of tiles in the same positions as the tilesets being replaced. Really messy script, but this is going to save me hours, since I’m mainly going to use it to layer my normal, specular, emissive, etc. maps on top of my diffuse maps for my automapping rules.

Anyway, here’s the code in case it helps anyone:

const action = tiled.registerAction("ReplaceTilesets", function () {
    const map = tiled.activeAsset;
    if (!map || !map.isTileMap) {
        tiled.alert("No active tile map.");
        return;
    }

    const layer = map.currentLayer;
    if (!layer || !layer.isTileLayer) {
        tiled.alert("Active layer is not a tile layer.");
        return;
    }

    const tilesets = map.tilesets;
    if (tilesets.length === 0) {
        tiled.alert("This map has no tilesets.");
        return;
    }

    const dialog = new Dialog("Select Tileset");
    dialog.addLabel("Replace all tiles on this layer with tiles from:");

    const comboBox = dialog.addComboBox("", tilesets.map(ts => ts.name));

    const okButton = dialog.addButton("OK");
    const cancelButton = dialog.addButton("Cancel");

    okButton.clicked.connect(() => {
        const selectedIndex = comboBox.currentIndex;
        const tileset = tilesets[selectedIndex];

        if (!tileset) {
            tiled.alert("Invalid tileset selected.");
            return;
        }

        let replacedCount = 0;
		let tryCount = 0;	
		let editableLayer = layer.edit();		
        map.macro("Replace tiles with selected tileset", () => {
            for (let y = 0; y < layer.height; y++) {
                for (let x = 0; x < layer.width; x++) {
                    const cell = layer.cellAt(x, y);
					
                    if (cell) {
						tryCount++;
                        const originalTileId = cell.tileId;
						if(originalTileId == -1){
							continue;
						}
						
                        tiled.log(`Checking tile at (${x}, ${y}) - ID ${originalTileId}`);

                        const newTile = tileset.tile(originalTileId);
                        if (newTile) {
							editableLayer.setTile(x, y, newTile); 
							
                            replacedCount++;
                        } else {
                            tiled.log(`No tile ID ${originalTileId} in tileset '${tileset.name}'`);
                        }
                    }
                }
            }
			editableLayer.apply();
        });
		
		let layerName = layer.name;

        tiled.alert(`Tile replacement complete for layer ${layerName}. ${replacedCount} tiles replaced.`);
        dialog.accept();
    });

    cancelButton.clicked.connect(() => {
       dialog.reject();
    });

    dialog.show();
});

action.text = "Replace Tilesets";

tiled.extendMenu("Map", [
    { action: "ReplaceTilesets", before: "MapProperties" }
]);


Another, more manual, way to do this is to copy (e.g. Save As) the map, and then do a map-wide Replace Tileset (either manually with the button, or via the scripting API’s map.replaceTileset. If the layers need to be in the original map (i.e. are extra layers in the same rules rather than another set of rules), their contents can be copied from the copy to the original - if you’re copying from multiple layers at once, Tiled will paste (or rather, stamp) into the same-named layers, favouring the selected layers in case of conflict (IIRC). When the goal is to make another set of rules rather than just modify a subset of layers in existing rules, this method is likely faster, but for modifying a subset of layers, your script is probably the way to go.

I noticed a minor problem in your code: you’re hard-coding the parameter for dialog.done(), but

  1. you should be using Dialog.Accepted or Dialog.Rejected for readability instead of hard-coding it
  2. you’re using the wrong values, you’re using 0 for accept when that’s equivalent to Rejected, and 1 for reject when that’s equivalent to Accepted.

This doesn’t affect the functionality, but it’s poor practice. You may also want to consider using dialog.reject() and dialog.accept() instead of dialog.done(). done() is meant for situations where the value comes from a variable.


Something else to consider for your specific need: since it sounds like it’s always the same tileset correspondences, you may want to do this work engine-side instead of Tiled-side. Instead of bloating your maps with a bunch of trivially computable tile data, you could have just the diffuse layer(s), and then create the other layers after loading the map in-engine - they should be the same meshes, just with a different tileset texture assigned. In fact, depending on your engine’s structure, you might even be able to avoid having separate meshes for each layer, by using the same mesh each time, with a different tileset’s texture. If you have multiple “sets” of tilesets (diffuse, normal, emissive, etc), you can either use the file name to figure out the tileset, or use custom properties on the main (probably diffuse) tileset that specify which other tilesets to use.
Of course, a downside to this method is that you won’t be able to preview your non-main tilesets in Tiled. This is unlikely to be an issue since in Tiled you’d only see the raw tiles and not their effects, so any real previewing would have to be done in-engine anyway. The major downside is that this approach requires some engine-side tweaks, but for some projects, they’re worthwhile.

If you want to do this Tiled-side, another option is to make a script that does these replacements instead of Automapping, since the logic is so simple. For example, you can make a script that runs every time a working map is saved, creates any missing layers, and adds/updates any incorrect tiles in them based on the diffuse layer. The additional layers can be created hidden so that they don’t get in the way until they’re needed. The downsides to this approach are there’s currently no good scripting alternative to Automap While Drawing so if you’re using that you should keep using it, and that it will do this work every time you save (or whenever you set it), which may be more frequent than you want it (if you’re just Automapping once at the end, for example) or less frequent (if you’re Automapping While Drawing or save very infrequently).

1 Like

Thank you for your thoughtful and detailed reply. You were spot on - the engine should handle the normal, specular, emissive, etc. layers, and tiled should simply handle the diffuse layer.

I went ahead and made those changes in my engine and tiled map - my tiled map is now much more manageable! Thanks for the tip. I hadn’t thought to do that earlier because I was using monogame.extended to render the map, so I didn’t have that level of control. But, I recently dropped monogame.extended and switched to DotTiled with custom rendering, so I was able to do this.

That said, the script I wrote will still be helpful, because I will still use it to create tile variations within my rule sheets (using output2, etc.)

I’ve edited the script to correctly use dialog.accept() and dialog.reject().

Thanks!

1 Like