From 15e7d32f4393d09bff9281c8876b4e69be47b7c3 Mon Sep 17 00:00:00 2001
From: Konstantin Pavlov <1517853+kpavlov@users.noreply.github.com>
Date: Fri, 2 Jan 2026 17:56:00 +0200
Subject: [PATCH] Example: Add tileset generation script in Kotlin
Example: This kotlin script generates a tileset from PNG images in a specified directory, creating both an atlas image and a Tiled-compatible JSON metadata file.
Run:
```shell
jbang generate-tileset.kt
```
---
examples/kotlin/generate-tileset.kt | 149 ++++++++++++++++++++++++++++
1 file changed, 149 insertions(+)
create mode 100644 examples/kotlin/generate-tileset.kt
diff --git a/examples/kotlin/generate-tileset.kt b/examples/kotlin/generate-tileset.kt
new file mode 100644
index 0000000..98161a1
--- /dev/null
+++ b/examples/kotlin/generate-tileset.kt
@@ -0,0 +1,149 @@
+///usr/bin/env jbang "$0" "$@" ; exit $?
+//KOTLIN 2.3.0
+//DEPS org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.9.0
+
+import java.awt.image.BufferedImage
+import java.nio.file.Path
+import javax.imageio.ImageIO
+import kotlin.io.path.*
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.*
+
+// -------- config --------
+/** Directory where the input PNG images are located. */
+const val INPUT_DIR = "./"
+
+/** Filename of the generated tileset atlas image. */
+const val OUTPUT_IMAGE = "tileset.png"
+
+/** Filename of the generated Tiled-compatible JSON metadata file. */
+const val OUTPUT_META = "tileset.json"
+
+/** The fixed width of each tile in pixels. */
+const val TILE_WIDTH = 96
+
+/** The fixed height of each tile in pixels. */
+const val TILE_HEIGHT = 96
+
+/** The spacing (padding) between tiles in the atlas, in pixels. */
+const val PADDING = 2
+
+/** The number of tiles to arrange in each row of the atlas. */
+const val COLUMNS = 8 // tiles per row
+// ------------------------
+
+/**
+ * This is a JBang script to generate a tileset from a directory of images.
+ * It combines multiple PNG files into a single atlas image and generates
+ * a corresponding Tiled-compatible JSON metadata file.
+ *
+ * Configuration is handled via constants at the top of the file.
+ * The script performs the following actions:
+ * 1. Scans the input directory for PNG files (excluding the output image itself, and files starting with "." or "_").
+ * 2. Skips any unreadable or corrupted images.
+ * 3. Arranges tiles into rows and columns with optional padding.
+ * 4. Generates a JSON tileset metadata file in Tiled JSON format (.tsj).
+ *
+ * @see Tiled JSON Map Format - Tileset
+ */
+fun main() {
+ val inputDir = Path(INPUT_DIR)
+ if (!inputDir.exists() || !inputDir.isDirectory()) {
+ println("Input directory $INPUT_DIR does not exist or is not a directory.")
+ System.exit(-1)
+ }
+
+ val files = inputDir.listDirectoryEntries("*.png")
+ .filter { it.isRegularFile() && it.name != OUTPUT_IMAGE && !it.name.startsWith(".") && !it.name.startsWith("_") }
+ .sortedBy { it.name }
+
+ if (files.isEmpty()) {
+ println("No PNG files found in $INPUT_DIR")
+ System.exit(0)
+ }
+
+ println("Found ${files.size} PNG files. Generating tileset...")
+
+ val rows = (files.size + COLUMNS - 1) / COLUMNS
+
+ val atlasWidth = COLUMNS * TILE_WIDTH + (COLUMNS - 1) * PADDING
+ val atlasHeight = rows * TILE_HEIGHT + (rows - 1) * PADDING
+
+ val atlas = BufferedImage(atlasWidth, atlasHeight, BufferedImage.TYPE_INT_ARGB)
+ val graphics = atlas.createGraphics()
+ graphics.setRenderingHint(
+ java.awt.RenderingHints.KEY_INTERPOLATION,
+ java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR
+ )
+
+ val tilesMetadata = mutableListOf()
+
+ var processedCount = 0
+ files.forEachIndexed { index, path ->
+ val x = processedCount % COLUMNS
+ val y = processedCount / COLUMNS
+
+ val px = x * (TILE_WIDTH + PADDING)
+ val py = y * (TILE_HEIGHT + PADDING)
+
+ val tile = try {
+ ImageIO.read(path.toFile())
+ } catch (e: Exception) {
+ null
+ } ?: run {
+ println("Skipping unreadable image: ${path.name}")
+ return@forEachIndexed
+ }
+ graphics.drawImage(tile, px, py, TILE_WIDTH, TILE_HEIGHT, null)
+
+ tilesMetadata.add(buildJsonObject {
+ put("id", processedCount)
+ put("properties", buildJsonArray {
+ add(buildJsonObject {
+ put("name", "name")
+ put("type", "string")
+ put("value", path.name.substringBeforeLast('.'))
+ })
+ add(buildJsonObject {
+ put("name", "filename")
+ put("type", "string")
+ put("value", path.name)
+ })
+ })
+ })
+ processedCount++
+ if (processedCount % 10 == 0 || processedCount == files.size) {
+ print("\rProcessed $processedCount/${files.size} images...")
+ }
+ }
+ println("\rProcessed $processedCount images. Finished processing.")
+
+ graphics.dispose()
+
+ val outputImageFile = Path(OUTPUT_IMAGE)
+ ImageIO.write(atlas, "png", outputImageFile.toFile())
+
+ val metadata = buildJsonObject {
+ put("columns", COLUMNS)
+ put("image", OUTPUT_IMAGE)
+ put("imageheight", atlasHeight)
+ put("imagewidth", atlasWidth)
+ put("margin", 0)
+ put("name", OUTPUT_IMAGE.substringBeforeLast("."))
+ put("objectalignment", "unspecified")
+ put("spacing", PADDING)
+ put("tilecount", tilesMetadata.size)
+ put("tileheight", TILE_HEIGHT)
+ put("tilewidth", TILE_WIDTH)
+ put("tiles", JsonArray(tilesMetadata))
+ put("type", "tileset")
+ put("version", "1.10")
+ put("tiledversion", "1.11.0")
+ }
+
+ val json = Json { prettyPrint = true }
+ val outputMetaFile = Path(OUTPUT_META)
+ outputMetaFile.writeText(json.encodeToString(metadata))
+
+ println("Created ${outputImageFile.toAbsolutePath()} and ${outputMetaFile.toAbsolutePath()}")
+}