Skip to content

Commit

Permalink
Added object support to Godot exporter (#3615)
Browse files Browse the repository at this point in the history
  • Loading branch information
Skrapion committed Feb 21, 2024
1 parent 9d0165f commit b184313
Show file tree
Hide file tree
Showing 3 changed files with 144 additions and 14 deletions.
3 changes: 2 additions & 1 deletion NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* Scripting: Made Tileset.margin and Tileset.tileSpacing writable
* Scripting: Restored compatibility for MapObject.polygon (#3845)
* JSON format: Fixed tile order when loading a tileset using the old format
* Godot export: Added support for exporting objects (by Rick Yorgason, #3615)
* Godot export: Fixed positioning of tile collision shapes (by Ryan Petrie, #3862)
* tmxrasterizer: Added --hide-object and --show-object arguments (by Lars Luz, #3819)
* tmxrasterizer: Added --frames and --frame-duration arguments to export animated maps as multiple images (#3868)
Expand Down Expand Up @@ -64,7 +65,7 @@

* Restored Tiled 1.8 file format compatibility by default (#3560)
* Added action search popup on Ctrl+Shift+P (with dogboydog, #3449)
* Added Godot 4 export plugin (#3550)
* Added Godot 4 export plugin (by Rick Yorgason, #3550)
* Added file system actions also for tileset image based tilesets (#3448)
* Added custom class option to disable drawing fill for objects (with dogboydog, #3312)
* Added option to choose a custom interface font (#3589)
Expand Down
23 changes: 19 additions & 4 deletions docs/manual/export-tscn.rst
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Maps support the following custom property:
* string ``tilesetResPath`` (default: blank)

The ``tilesetResPath`` property saves the tileset to an external .tres file,
allowing it to be shared between multiple maps more efficiently. This path
allowing it to be shared between multiple maps more efficiently. This path
must be in the form of 'res://<path>.tres'. The tileset file will be
overwritten every time the map is exported.

Expand All @@ -89,13 +89,28 @@ overwritten every time the map is exported.
*all* of the same tilesets. You may wish to create a layer with the
``tilesetOnly`` property to ensure the correct tilesets are exported.

.. raw:: html

<div class="new">Since Tiled 1.10.3</div>

Object Properties
~~~~~~~~~~~~~~~~~

Objects support the following property:

* string ``resPath`` (required)

The ``resPath`` property takes the form of 'res://<pbject path>.tscn' and must
be set to the path of the Godot object you wish to replace the object with.
Objects without this property set will not be exported.

Limitations
~~~~~~~~~~~

* The Godot 4 exporter does not currently support collection of images
tilesets, object layers, or image layers.
* The Godot 4 exporter does not currently support collection of images
tilesets or image layers.
* Godot's hexagonal maps only support :ref:`hex side lengths <tmx-map>`
that are exactly half the tile height. So if, for example, your tile
that are exactly half the tile height. So if, for example, your tile
height is 16, then your hex side length must be 8.
* Godot's hexagonal maps do not support 120° tile rotations.
* Animations frames must strictly go from left-to-right and top-to-bottom,
Expand Down
132 changes: 123 additions & 9 deletions src/plugins/tscn/tscnplugin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,13 @@ struct CustomDataLayer
int index = 0;
};

// Remove any special chars from a string
static QString sanitizeSpecialChars(QString str)
{
static QRegularExpression sanitizer("[^a-zA-Z0-9]");
return str.replace(sanitizer, "");
}

// For collecting information about the tilesets we're using
struct TilesetInfo
{
Expand All @@ -253,6 +260,8 @@ struct AssetInfo
QMap<QString, TilesetInfo> tilesetInfo;
QList<const TileLayer*> layers;
QSet<QString> tilesetIds;
QMap<QString, QString> objectIds; // Map resPaths to unique IDs
QList<const MapObject*> objects;
QString resRoot;
std::map<QString, CustomDataLayer> customDataLayers;
};
Expand Down Expand Up @@ -329,6 +338,57 @@ static void findUsedTilesets(const TileLayer *layer, AssetInfo &assetInfo)
}
}

// Search an object layer for all object resources and save them in assetInfo
static void findUsedObjects(const ObjectGroup *objectLayer, AssetInfo &assetInfo)
{
static QRegularExpression resPathValidator("^res://(.*)\\.tscn$");

for (const MapObject *object : objectLayer->objects()) {
const auto resPath = object->resolvedProperty("resPath").toString();

if (resPath.isEmpty()) {
Tiled::WARNING(TscnPlugin::tr("Only objects with the resPath property will be exported"),
Tiled::JumpToObject { object });
continue;
}

QRegularExpressionMatch match;
if (!resPath.contains(resPathValidator, &match)) {
Tiled::ERROR(TscnPlugin::tr("resPath must be in the form of 'res://<filename>.tscn'."),
Tiled::JumpToObject { object });
continue;
}

const QString baseName = sanitizeSpecialChars(match.captured(1));
int uniqueifier = 1;
QString id = baseName;

// Create the objectId map such that every resPath has a unique ID.
while (true) {
// keys() is slow. If this becomes a problem, we can create a reverse map.
auto keys = assetInfo.objectIds.keys(id);

if (keys.empty()) {
assetInfo.objectIds[resPath] = id;
break;
}

// The key already exists with the right value
if (keys[0] == resPath)
break;

// The baseName is based off of a file path, which is unique by definition,
// but because we santized it, paths like res://ab/c.tscn and res://a/bc.tscn
// would both get santitized into the non-unique name of abc, so we need to
// add a uniqueifier and try again.
++uniqueifier;
id = baseName + QString::number(uniqueifier);
}

assetInfo.objects.append(object);
}
}

// Used by collectAssets() to search all layers and layer groups
static void collectAssetsRecursive(const QList<Layer*> &layers, AssetInfo &assetInfo)
{
Expand All @@ -346,10 +406,11 @@ static void collectAssetsRecursive(const QList<Layer*> &layers, AssetInfo &asset

break;
}
case Layer::ObjectGroupType:
Tiled::WARNING(TscnPlugin::tr("The Godot exporter does not yet support objects"),
Tiled::SelectLayer { layer });
case Layer::ObjectGroupType: {
auto objectLayer = static_cast<const ObjectGroup*>(layer);
findUsedObjects(objectLayer, assetInfo);
break;
}
case Layer::ImageLayerType:
Tiled::WARNING(TscnPlugin::tr("The Godot exporter does not yet support image layers"),
Tiled::SelectLayer { layer });
Expand Down Expand Up @@ -548,17 +609,30 @@ static bool writeProperties(QFileDevice *device, const QVariantMap &properties,
return first;
}

// Write the ext_resource lines for any objects exported
static void writeExtObjects(QFileDevice *device, const AssetInfo &assetInfo)
{
for (auto it = assetInfo.objectIds.begin(); it != assetInfo.objectIds.end(); ++it) {
device->write(formatByteString(
"[ext_resource type=\"PackedScene\" path=\"%1\" id=\"%2\"]\n",
sanitizeQuotedString(it.key()),
it.value()));
}

device->write("\n");
}

// Write the tileset
// If you're creating a reusable tileset file, pass in a new file device and
// set isExternal to true, otherwise, reuse the device from the tscn file.
static void writeTileset(const Map *map, QFileDevice *device, bool isExternal, AssetInfo &assetInfo)
{
bool foundCollisions = false;

// One Texture2D and one TileSetAtlasSource per tileset, plus a resource node
auto loadSteps = assetInfo.tilesetInfo.size() * 2 + 1;

if (isExternal) {
// One Texture2D and one TileSetAtlasSource per tileset, plus a resource node
auto loadSteps = assetInfo.tilesetInfo.size() * 2 + 1;

device->write(formatByteString(
"[gd_resource type=\"TileSet\" load_steps=%1 format=3]\n\n",
loadSteps));
Expand All @@ -574,6 +648,7 @@ static void writeTileset(const Map *map, QFileDevice *device, bool isExternal, A
sanitizeQuotedString(it.key()),
sanitizeQuotedString(it->id)));
}

device->write("\n");

// TileSetAtlasSource nodes
Expand Down Expand Up @@ -799,9 +874,14 @@ bool TscnPlugin::write(const Map *map, const QString &fileName, Options options)
// (unless we're writing the tileset to an external .tres file)
auto loadSteps = !tilesetResPath.isEmpty() ? 2 : assetInfo.tilesetInfo.size() * 2 + 2;

// And an extra load step per object resource
loadSteps += assetInfo.objectIds.size();

// gdscene node
device->write(formatByteString("[gd_scene load_steps=%1 format=3]\n\n", loadSteps));

writeExtObjects(device, assetInfo);

// tileset, either inline, or as an external file
if (tilesetResPath.isEmpty()) {
writeTileset(map, device, false, assetInfo);
Expand All @@ -811,7 +891,7 @@ bool TscnPlugin::write(const Map *map, const QString &fileName, Options options)
throw tscnError(tr("tilesetResPath must be in the form of 'res://<filename>.tres'."));

device->write(formatByteString(
"[ext_resource type=\"TileSet\" path=\"%1\" id=\"TileSet_0\"]\n\n",
"[ext_resource type=\"TileSet\" path=\"%1\" id=\"TileSet_0\"]\n",
sanitizeQuotedString(tilesetResPath)));

QString resFileName = assetInfo.resRoot + '/' + match.captured(1);
Expand All @@ -837,10 +917,13 @@ bool TscnPlugin::write(const Map *map, const QString &fileName, Options options)
}
}

// TileMap node
device->write(formatByteString("[node name=\"%1\" type=\"TileMap\"]\n",
// Root node
device->write(formatByteString("[node name=\"%1\" type=\"Node2D\"]\n\n",
sanitizeQuotedString(fi.baseName())));

// TileMap node
device->write("[node name=\"TileMap\" type=\"TileMap\" parent=\".\"]\n");

if (tilesetResPath.isEmpty())
device->write("tile_set = SubResource(\"TileSet_0\")\n");
else
Expand Down Expand Up @@ -932,6 +1015,37 @@ bool TscnPlugin::write(const Map *map, const QString &fileName, Options options)

layerIndex++;
}

device->write("\n");

// Object scene nodes
for (const MapObject *object : assetInfo.objects) {
device->write("\n");

auto name = object->name();
if (name.isEmpty())
name = "Object" + QString::number(object->id());

const auto resPath = object->resolvedProperty("resPath").toString();

device->write(formatByteString(
"[node name=\"%1\" parent=\".\" instance=ExtResource(\"%2\")]\n",
sanitizeQuotedString(name),
sanitizeQuotedString(assetInfo.objectIds[resPath]))
);

// Convert Tiled's alignment position to Godot's centre-aligned position.
QPointF pos =
object->position() -
Tiled::alignmentOffset(object->size(), object->alignment()) +
QPointF(object->width()/2, object->height()/2);

device->write(formatByteString(
"position = Vector2(%1, %2)\n",
pos.x(),
pos.y()
));
}
} catch (std::exception& e) {
mError = e.what();
return false;
Expand Down

0 comments on commit b184313

Please sign in to comment.