Skip to content

Commit b184313

Browse files
authored
Added object support to Godot exporter (mapeditor#3615)
1 parent 9d0165f commit b184313

File tree

3 files changed

+144
-14
lines changed

3 files changed

+144
-14
lines changed

NEWS.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* Scripting: Made Tileset.margin and Tileset.tileSpacing writable
88
* Scripting: Restored compatibility for MapObject.polygon (#3845)
99
* JSON format: Fixed tile order when loading a tileset using the old format
10+
* Godot export: Added support for exporting objects (by Rick Yorgason, #3615)
1011
* Godot export: Fixed positioning of tile collision shapes (by Ryan Petrie, #3862)
1112
* tmxrasterizer: Added --hide-object and --show-object arguments (by Lars Luz, #3819)
1213
* tmxrasterizer: Added --frames and --frame-duration arguments to export animated maps as multiple images (#3868)
@@ -64,7 +65,7 @@
6465

6566
* Restored Tiled 1.8 file format compatibility by default (#3560)
6667
* Added action search popup on Ctrl+Shift+P (with dogboydog, #3449)
67-
* Added Godot 4 export plugin (#3550)
68+
* Added Godot 4 export plugin (by Rick Yorgason, #3550)
6869
* Added file system actions also for tileset image based tilesets (#3448)
6970
* Added custom class option to disable drawing fill for objects (with dogboydog, #3312)
7071
* Added option to choose a custom interface font (#3589)

docs/manual/export-tscn.rst

+19-4
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Maps support the following custom property:
7878
* string ``tilesetResPath`` (default: blank)
7979

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

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

92+
.. raw:: html
93+
94+
<div class="new">Since Tiled 1.10.3</div>
95+
96+
Object Properties
97+
~~~~~~~~~~~~~~~~~
98+
99+
Objects support the following property:
100+
101+
* string ``resPath`` (required)
102+
103+
The ``resPath`` property takes the form of 'res://<pbject path>.tscn' and must
104+
be set to the path of the Godot object you wish to replace the object with.
105+
Objects without this property set will not be exported.
106+
92107
Limitations
93108
~~~~~~~~~~~
94109

95-
* The Godot 4 exporter does not currently support collection of images
96-
tilesets, object layers, or image layers.
110+
* The Godot 4 exporter does not currently support collection of images
111+
tilesets or image layers.
97112
* Godot's hexagonal maps only support :ref:`hex side lengths <tmx-map>`
98-
that are exactly half the tile height. So if, for example, your tile
113+
that are exactly half the tile height. So if, for example, your tile
99114
height is 16, then your hex side length must be 8.
100115
* Godot's hexagonal maps do not support 120° tile rotations.
101116
* Animations frames must strictly go from left-to-right and top-to-bottom,

src/plugins/tscn/tscnplugin.cpp

+123-9
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,13 @@ struct CustomDataLayer
237237
int index = 0;
238238
};
239239

240+
// Remove any special chars from a string
241+
static QString sanitizeSpecialChars(QString str)
242+
{
243+
static QRegularExpression sanitizer("[^a-zA-Z0-9]");
244+
return str.replace(sanitizer, "");
245+
}
246+
240247
// For collecting information about the tilesets we're using
241248
struct TilesetInfo
242249
{
@@ -253,6 +260,8 @@ struct AssetInfo
253260
QMap<QString, TilesetInfo> tilesetInfo;
254261
QList<const TileLayer*> layers;
255262
QSet<QString> tilesetIds;
263+
QMap<QString, QString> objectIds; // Map resPaths to unique IDs
264+
QList<const MapObject*> objects;
256265
QString resRoot;
257266
std::map<QString, CustomDataLayer> customDataLayers;
258267
};
@@ -329,6 +338,57 @@ static void findUsedTilesets(const TileLayer *layer, AssetInfo &assetInfo)
329338
}
330339
}
331340

341+
// Search an object layer for all object resources and save them in assetInfo
342+
static void findUsedObjects(const ObjectGroup *objectLayer, AssetInfo &assetInfo)
343+
{
344+
static QRegularExpression resPathValidator("^res://(.*)\\.tscn$");
345+
346+
for (const MapObject *object : objectLayer->objects()) {
347+
const auto resPath = object->resolvedProperty("resPath").toString();
348+
349+
if (resPath.isEmpty()) {
350+
Tiled::WARNING(TscnPlugin::tr("Only objects with the resPath property will be exported"),
351+
Tiled::JumpToObject { object });
352+
continue;
353+
}
354+
355+
QRegularExpressionMatch match;
356+
if (!resPath.contains(resPathValidator, &match)) {
357+
Tiled::ERROR(TscnPlugin::tr("resPath must be in the form of 'res://<filename>.tscn'."),
358+
Tiled::JumpToObject { object });
359+
continue;
360+
}
361+
362+
const QString baseName = sanitizeSpecialChars(match.captured(1));
363+
int uniqueifier = 1;
364+
QString id = baseName;
365+
366+
// Create the objectId map such that every resPath has a unique ID.
367+
while (true) {
368+
// keys() is slow. If this becomes a problem, we can create a reverse map.
369+
auto keys = assetInfo.objectIds.keys(id);
370+
371+
if (keys.empty()) {
372+
assetInfo.objectIds[resPath] = id;
373+
break;
374+
}
375+
376+
// The key already exists with the right value
377+
if (keys[0] == resPath)
378+
break;
379+
380+
// The baseName is based off of a file path, which is unique by definition,
381+
// but because we santized it, paths like res://ab/c.tscn and res://a/bc.tscn
382+
// would both get santitized into the non-unique name of abc, so we need to
383+
// add a uniqueifier and try again.
384+
++uniqueifier;
385+
id = baseName + QString::number(uniqueifier);
386+
}
387+
388+
assetInfo.objects.append(object);
389+
}
390+
}
391+
332392
// Used by collectAssets() to search all layers and layer groups
333393
static void collectAssetsRecursive(const QList<Layer*> &layers, AssetInfo &assetInfo)
334394
{
@@ -346,10 +406,11 @@ static void collectAssetsRecursive(const QList<Layer*> &layers, AssetInfo &asset
346406

347407
break;
348408
}
349-
case Layer::ObjectGroupType:
350-
Tiled::WARNING(TscnPlugin::tr("The Godot exporter does not yet support objects"),
351-
Tiled::SelectLayer { layer });
409+
case Layer::ObjectGroupType: {
410+
auto objectLayer = static_cast<const ObjectGroup*>(layer);
411+
findUsedObjects(objectLayer, assetInfo);
352412
break;
413+
}
353414
case Layer::ImageLayerType:
354415
Tiled::WARNING(TscnPlugin::tr("The Godot exporter does not yet support image layers"),
355416
Tiled::SelectLayer { layer });
@@ -548,17 +609,30 @@ static bool writeProperties(QFileDevice *device, const QVariantMap &properties,
548609
return first;
549610
}
550611

612+
// Write the ext_resource lines for any objects exported
613+
static void writeExtObjects(QFileDevice *device, const AssetInfo &assetInfo)
614+
{
615+
for (auto it = assetInfo.objectIds.begin(); it != assetInfo.objectIds.end(); ++it) {
616+
device->write(formatByteString(
617+
"[ext_resource type=\"PackedScene\" path=\"%1\" id=\"%2\"]\n",
618+
sanitizeQuotedString(it.key()),
619+
it.value()));
620+
}
621+
622+
device->write("\n");
623+
}
624+
551625
// Write the tileset
552626
// If you're creating a reusable tileset file, pass in a new file device and
553627
// set isExternal to true, otherwise, reuse the device from the tscn file.
554628
static void writeTileset(const Map *map, QFileDevice *device, bool isExternal, AssetInfo &assetInfo)
555629
{
556630
bool foundCollisions = false;
557631

558-
// One Texture2D and one TileSetAtlasSource per tileset, plus a resource node
559-
auto loadSteps = assetInfo.tilesetInfo.size() * 2 + 1;
560-
561632
if (isExternal) {
633+
// One Texture2D and one TileSetAtlasSource per tileset, plus a resource node
634+
auto loadSteps = assetInfo.tilesetInfo.size() * 2 + 1;
635+
562636
device->write(formatByteString(
563637
"[gd_resource type=\"TileSet\" load_steps=%1 format=3]\n\n",
564638
loadSteps));
@@ -574,6 +648,7 @@ static void writeTileset(const Map *map, QFileDevice *device, bool isExternal, A
574648
sanitizeQuotedString(it.key()),
575649
sanitizeQuotedString(it->id)));
576650
}
651+
577652
device->write("\n");
578653

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

877+
// And an extra load step per object resource
878+
loadSteps += assetInfo.objectIds.size();
879+
802880
// gdscene node
803881
device->write(formatByteString("[gd_scene load_steps=%1 format=3]\n\n", loadSteps));
804882

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

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

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

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

924+
// TileMap node
925+
device->write("[node name=\"TileMap\" type=\"TileMap\" parent=\".\"]\n");
926+
844927
if (tilesetResPath.isEmpty())
845928
device->write("tile_set = SubResource(\"TileSet_0\")\n");
846929
else
@@ -932,6 +1015,37 @@ bool TscnPlugin::write(const Map *map, const QString &fileName, Options options)
9321015

9331016
layerIndex++;
9341017
}
1018+
1019+
device->write("\n");
1020+
1021+
// Object scene nodes
1022+
for (const MapObject *object : assetInfo.objects) {
1023+
device->write("\n");
1024+
1025+
auto name = object->name();
1026+
if (name.isEmpty())
1027+
name = "Object" + QString::number(object->id());
1028+
1029+
const auto resPath = object->resolvedProperty("resPath").toString();
1030+
1031+
device->write(formatByteString(
1032+
"[node name=\"%1\" parent=\".\" instance=ExtResource(\"%2\")]\n",
1033+
sanitizeQuotedString(name),
1034+
sanitizeQuotedString(assetInfo.objectIds[resPath]))
1035+
);
1036+
1037+
// Convert Tiled's alignment position to Godot's centre-aligned position.
1038+
QPointF pos =
1039+
object->position() -
1040+
Tiled::alignmentOffset(object->size(), object->alignment()) +
1041+
QPointF(object->width()/2, object->height()/2);
1042+
1043+
device->write(formatByteString(
1044+
"position = Vector2(%1, %2)\n",
1045+
pos.x(),
1046+
pos.y()
1047+
));
1048+
}
9351049
} catch (std::exception& e) {
9361050
mError = e.what();
9371051
return false;

0 commit comments

Comments
 (0)