From 33fd7c485e181ca71a8763de64b18bb4917a638d Mon Sep 17 00:00:00 2001 From: bQUARKz Date: Sat, 18 Apr 2026 17:54:00 +0100 Subject: [PATCH] implements PLN-0054 --- .../FileSystemPackerWorkspaceService.java | 6 +- .../FileSystemPackerWorkspaceServiceTest.java | 3 + .../java/p/studio/utilities/i18n/I18n.java | 2 + .../assets/details/AssetDetailsControl.java | 65 +++ .../metadata/AssetStudioMetadataService.java | 12 +- .../AssetStudioSceneBankMetadata.java | 5 + .../AssetStudioSceneLayerBinding.java | 11 +- .../tiled/TiledAssetGenerationResult.java | 16 + .../tiled/TiledAssetGenerationService.java | 152 ++++++ .../assets/tiled/TiledMapDocument.java | 32 ++ .../assets/tiled/TiledObjectData.java | 21 + .../assets/tiled/TiledObjectLayer.java | 17 + .../workspaces/assets/tiled/TiledPoint.java | 4 + .../assets/tiled/TiledProperty.java | 17 + .../assets/tiled/TiledTileLayer.java | 19 + .../assets/tiled/TiledTilesetDocument.java | 27 + .../assets/tiled/TiledTilesetReference.java | 15 + .../assets/tiled/TiledTilesetTile.java | 21 + .../TiledUnsupportedFeatureException.java | 7 + .../assets/tiled/TiledXmlCodec.java | 460 ++++++++++++++++++ .../main/resources/i18n/messages.properties | 2 + .../AssetStudioMetadataServiceTest.java | 20 +- .../TiledAssetGenerationServiceTest.java | 141 ++++++ .../assets/tiled/TiledXmlCodecTest.java | 55 +++ 24 files changed, 1117 insertions(+), 13 deletions(-) create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationResult.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationService.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledMapDocument.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledObjectData.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledObjectLayer.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledPoint.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledProperty.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTileLayer.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetDocument.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetReference.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetTile.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledUnsupportedFeatureException.java create mode 100644 prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledXmlCodec.java create mode 100644 prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationServiceTest.java create mode 100644 prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledXmlCodecTest.java diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java index f9f464c5..b88aae88 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java @@ -675,10 +675,14 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe } final Map supportFile = new LinkedHashMap<>(); supportFile.put("schema_version", 1); + supportFile.put("map_width", 16); + supportFile.put("map_height", 16); supportFile.put("layer_count", 1); supportFile.put("layers", List.of(Map.of( "index", 1, - "tilemap", "layer-1.tmx"))); + "name", "Layer 1", + "tilemap", "layer-1.tmx", + "tileset_asset_root", ""))); mapper.writerWithDefaultPrettyPrinter().writeValue( assetRoot.resolve(SCENE_BANK_SUPPORT_FILE).toFile(), supportFile); diff --git a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java index 07a0d29b..5b16cf34 100644 --- a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java @@ -531,7 +531,10 @@ final class FileSystemPackerWorkspaceServiceTest { assertTrue(Files.isRegularFile(assetRoot.resolve("asset.json"))); assertTrue(Files.isRegularFile(assetRoot.resolve("scene-bank.studio.json"))); final var supportFile = MAPPER.readTree(assetRoot.resolve("scene-bank.studio.json").toFile()); + assertEquals(16, supportFile.path("map_width").asInt()); + assertEquals(16, supportFile.path("map_height").asInt()); assertEquals(1, supportFile.path("layer_count").asInt()); + assertEquals("Layer 1", supportFile.path("layers").get(0).path("name").asText()); assertEquals("layer-1.tmx", supportFile.path("layers").get(0).path("tilemap").asText()); } diff --git a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java index acd59d23..afa38129 100644 --- a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java +++ b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java @@ -154,6 +154,8 @@ public enum I18n { ASSETS_ACTIONS_EMPTY("assets.actions.empty"), ASSETS_ACTION_REGISTER("assets.action.register"), ASSETS_ACTION_ANALYSE("assets.action.analyse"), + ASSETS_ACTION_GENERATE_TSX("assets.action.generateTsx"), + ASSETS_ACTION_GENERATE_TMX("assets.action.generateTmx"), ASSETS_ACTION_DELETE("assets.action.delete"), ASSETS_ACTION_INCLUDE_IN_BUILD("assets.action.includeInBuild"), ASSETS_ACTION_EXCLUDE_FROM_BUILD("assets.action.excludeFromBuild"), diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java index 8a1c24f1..4b9cdc76 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java @@ -25,6 +25,7 @@ import p.studio.workspaces.assets.details.palette.AssetDetailsPaletteOverhauling import p.studio.workspaces.assets.details.summary.AssetDetailsSummaryControl; import p.studio.workspaces.assets.metadata.AssetStudioMetadataService; import p.studio.workspaces.assets.metadata.AssetStudioMetadataSnapshot; +import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization; import p.studio.workspaces.assets.messages.AssetWorkspaceAssetAction; import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionDetails; import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile; @@ -35,6 +36,8 @@ import p.studio.workspaces.assets.messages.AssetWorkspaceBuildParticipation; import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent; import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent; import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceSelectionRequestedEvent; +import p.studio.workspaces.assets.tiled.TiledAssetGenerationResult; +import p.studio.workspaces.assets.tiled.TiledAssetGenerationService; import p.studio.workspaces.assets.wizards.DeleteAssetDialog; import p.studio.workspaces.assets.wizards.MoveAssetWizard; import p.studio.workspaces.framework.StudioEventAware; @@ -56,6 +59,7 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware private final AssetDetailsBankCompositionControl bankCompositionControl; private final AssetDetailsPaletteOverhaulingControl paletteOverhaulingControl; private final AssetStudioMetadataService studioMetadataService = new AssetStudioMetadataService(); + private final TiledAssetGenerationService tiledGenerationService = new TiledAssetGenerationService(); private final VBox actionsContent = new VBox(10); private final ScrollPane actionsScroll = new ScrollPane(); private final VBox actionsSection; @@ -327,6 +331,18 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware analyseButton.setDisable(actionRunning || viewState.selectedAssetDetails().diagnostics().isEmpty()); analyseButton.setOnAction(ignored -> openDiagnosticsDialog()); nodes.add(analyseButton); + if (canGenerateTsx()) { + final Button generateTsxButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_GENERATE_TSX)); + generateTsxButton.setDisable(actionRunning); + generateTsxButton.setOnAction(ignored -> generateTsx()); + nodes.add(generateTsxButton); + } + if (canGenerateTmx()) { + final Button generateTmxButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_GENERATE_TMX)); + generateTmxButton.setDisable(actionRunning); + generateTmxButton.setOnAction(ignored -> generateTmx()); + nodes.add(generateTmxButton); + } final Button buildParticipationButton = AssetDetailsUiSupport.createActionButton(buildParticipationActionLabel()); AssetDetailsUiSupport.applyActionTone( @@ -452,6 +468,55 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware }); } + private boolean canGenerateTsx() { + return viewState.selectedAssetDetails() != null + && viewState.selectedAssetDetails().summary().assetFamily() == p.packer.messages.assets.AssetFamilyCatalog.GLYPH_BANK + && viewState.selectedAssetDetails().summary().glyphSpecialization() == AssetStudioGlyphSpecialization.TILESET; + } + + private boolean canGenerateTmx() { + return viewState.selectedAssetDetails() != null + && viewState.selectedAssetDetails().summary().assetFamily() == p.packer.messages.assets.AssetFamilyCatalog.SCENE_BANK; + } + + private void generateTsx() { + if (actionRunning || viewState.selectedAssetDetails() == null) { + return; + } + actionRunning = true; + actionFeedbackMessage = null; + renderActions(); + final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails(); + Container.backgroundTasks().submit(() -> { + final TiledAssetGenerationResult result = tiledGenerationService.generateTilesetTsx(details); + Platform.runLater(() -> applyTiledGenerationResult(result)); + }); + } + + private void generateTmx() { + if (actionRunning || viewState.selectedAssetDetails() == null) { + return; + } + actionRunning = true; + actionFeedbackMessage = null; + renderActions(); + final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails(); + Container.backgroundTasks().submit(() -> { + final TiledAssetGenerationResult result = tiledGenerationService.generateSceneBankTilemaps(projectReference, details); + Platform.runLater(() -> applyTiledGenerationResult(result)); + }); + } + + private void applyTiledGenerationResult(TiledAssetGenerationResult result) { + actionRunning = false; + actionFeedbackMessage = result.message(); + renderActions(); + if (result.success() && viewState.selectedAssetReference() != null) { + workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(viewState.selectedAssetReference())); + workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(viewState.selectedAssetReference(), true)); + } + } + private void applyBuildParticipationResult(UpdateAssetBuildParticipationResult result) { actionRunning = false; if (result.status() == p.packer.messages.PackerOperationStatus.SUCCESS && result.assetReference() != null) { diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataService.java index 9792708d..fd821f06 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataService.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataService.java @@ -78,8 +78,10 @@ public final class AssetStudioMetadataService { if (root == null || !root.isObject()) { return null; } + final int mapWidth = root.path("map_width").asInt(0); + final int mapHeight = root.path("map_height").asInt(0); final int layerCount = root.path("layer_count").asInt(0); - if (layerCount < 1 || layerCount > 4) { + if (mapWidth <= 0 || mapHeight <= 0 || layerCount < 1 || layerCount > 4) { return null; } if (!(root.path("layers") instanceof ArrayNode layersNode) || layersNode.size() != layerCount) { @@ -89,14 +91,16 @@ public final class AssetStudioMetadataService { final Set indexes = new HashSet<>(); for (JsonNode layerNode : layersNode) { final int index = layerNode.path("index").asInt(0); + final String layerName = layerNode.path("name").asText("").trim(); final String tilemap = layerNode.path("tilemap").asText("").trim(); - if (index < 1 || index > layerCount || tilemap.isBlank() || !indexes.add(index)) { + final String tilesetAssetRoot = layerNode.path("tileset_asset_root").asText("").trim(); + if (index < 1 || index > layerCount || layerName.isBlank() || tilemap.isBlank() || !indexes.add(index)) { return null; } - bindings.add(new AssetStudioSceneLayerBinding(index, tilemap)); + bindings.add(new AssetStudioSceneLayerBinding(index, layerName, tilemap, tilesetAssetRoot)); } bindings.sort(Comparator.comparingInt(AssetStudioSceneLayerBinding::index)); - return new AssetStudioSceneBankMetadata(layerCount, bindings, supportFile); + return new AssetStudioSceneBankMetadata(mapWidth, mapHeight, layerCount, bindings, supportFile); } catch (IOException ignored) { return null; } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioSceneBankMetadata.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioSceneBankMetadata.java index cf74536f..4d205479 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioSceneBankMetadata.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioSceneBankMetadata.java @@ -5,6 +5,8 @@ import java.util.List; import java.util.Objects; public record AssetStudioSceneBankMetadata( + int mapWidth, + int mapHeight, int layerCount, List layerBindings, Path supportFile) { @@ -12,6 +14,9 @@ public record AssetStudioSceneBankMetadata( public AssetStudioSceneBankMetadata { layerBindings = List.copyOf(Objects.requireNonNull(layerBindings, "layerBindings")); supportFile = Objects.requireNonNull(supportFile, "supportFile").toAbsolutePath().normalize(); + if (mapWidth <= 0 || mapHeight <= 0) { + throw new IllegalArgumentException("map dimensions must be positive"); + } if (layerCount < 1 || layerCount > 4) { throw new IllegalArgumentException("layerCount must stay between 1 and 4"); } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioSceneLayerBinding.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioSceneLayerBinding.java index 1eecc4dd..b8e95b21 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioSceneLayerBinding.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioSceneLayerBinding.java @@ -2,12 +2,21 @@ package p.studio.workspaces.assets.metadata; import java.util.Objects; -public record AssetStudioSceneLayerBinding(int index, String tilemap) { +public record AssetStudioSceneLayerBinding( + int index, + String layerName, + String tilemap, + String tilesetAssetRoot) { public AssetStudioSceneLayerBinding { + layerName = Objects.requireNonNull(layerName, "layerName").trim(); tilemap = Objects.requireNonNull(tilemap, "tilemap").trim(); + tilesetAssetRoot = Objects.requireNonNull(tilesetAssetRoot, "tilesetAssetRoot").trim(); if (index <= 0) { throw new IllegalArgumentException("index must be positive"); } + if (layerName.isBlank()) { + throw new IllegalArgumentException("layerName must not be blank"); + } if (tilemap.isBlank()) { throw new IllegalArgumentException("tilemap must not be blank"); } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationResult.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationResult.java new file mode 100644 index 00000000..7ddc7c85 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationResult.java @@ -0,0 +1,16 @@ +package p.studio.workspaces.assets.tiled; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +public record TiledAssetGenerationResult( + boolean success, + String message, + List writtenFiles) { + + public TiledAssetGenerationResult { + message = Objects.requireNonNullElse(message, ""); + writtenFiles = List.copyOf(Objects.requireNonNullElse(writtenFiles, List.of())); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationService.java new file mode 100644 index 00000000..6f307e2b --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationService.java @@ -0,0 +1,152 @@ +package p.studio.workspaces.assets.tiled; + +import p.packer.dtos.PackerCodecConfigurationFieldDTO; +import p.packer.messages.assets.AssetFamilyCatalog; +import p.studio.projects.ProjectReference; +import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization; +import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata; +import p.studio.workspaces.assets.metadata.AssetStudioSceneLayerBinding; +import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails; +import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class TiledAssetGenerationService { + public static final String GENERATED_TSX_FILE = "tileset.tsx"; + + private final TiledXmlCodec codec = new TiledXmlCodec(); + + public TiledAssetGenerationResult generateTilesetTsx(AssetWorkspaceAssetDetails details) { + if (details == null + || details.summary().assetFamily() != AssetFamilyCatalog.GLYPH_BANK + || details.summary().glyphSpecialization() != AssetStudioGlyphSpecialization.TILESET) { + return new TiledAssetGenerationResult(false, "TSX generation is available only for Tileset-specialized glyph banks.", List.of()); + } + final List selectedFiles = details.bankComposition().selectedFiles(); + if (selectedFiles.isEmpty()) { + return new TiledAssetGenerationResult(false, "Select at least one glyph artifact before generating TSX.", List.of()); + } + final int tileSize = parseTileSize(details.metadataFields()); + final List tiles = new ArrayList<>(); + for (int index = 0; index < selectedFiles.size(); index += 1) { + final AssetWorkspaceBankCompositionFile file = selectedFiles.get(index); + tiles.add(new TiledTilesetTile( + index, + file.path(), + tileSize, + tileSize, + List.of(new TiledProperty("glyph_id", "int", Integer.toString(index))), + null)); + } + final TiledTilesetDocument document = new TiledTilesetDocument( + "1.10", + "1.12.1", + details.summary().assetName(), + tileSize, + tileSize, + tiles.size(), + 0, + List.of(), + tiles); + final Path outputPath = details.summary().assetRoot().resolve(GENERATED_TSX_FILE); + try { + codec.writeTileset(outputPath, document); + return new TiledAssetGenerationResult(true, "Generated TSX successfully.", List.of(outputPath)); + } catch (IOException exception) { + return new TiledAssetGenerationResult(false, "Unable to generate TSX: " + exception.getMessage(), List.of()); + } + } + + public TiledAssetGenerationResult generateSceneBankTilemaps(ProjectReference projectReference, AssetWorkspaceAssetDetails details) { + if (details == null || details.summary().assetFamily() != AssetFamilyCatalog.SCENE_BANK) { + return new TiledAssetGenerationResult(false, "TMX generation is available only for Scene Bank assets.", List.of()); + } + final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata(); + if (metadata == null) { + return new TiledAssetGenerationResult(false, "Scene Bank support metadata is missing or invalid.", List.of()); + } + final List writtenFiles = new ArrayList<>(); + try { + for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) { + final String tilesetAssetRoot = Objects.requireNonNullElse(binding.tilesetAssetRoot(), "").trim(); + if (tilesetAssetRoot.isBlank()) { + return new TiledAssetGenerationResult( + false, + "Layer " + binding.index() + " must declare a non-blank tileset_asset_root before TMX generation.", + List.of()); + } + final Path tilesetPath = projectReference.rootPath() + .resolve("assets") + .resolve(tilesetAssetRoot) + .resolve(GENERATED_TSX_FILE) + .toAbsolutePath() + .normalize(); + if (!java.nio.file.Files.isRegularFile(tilesetPath)) { + return new TiledAssetGenerationResult( + false, + "Referenced TSX was not found for layer " + binding.index() + ": " + tilesetAssetRoot, + List.of()); + } + final TiledTilesetDocument tileset = codec.readTileset(tilesetPath); + final String relativeTilesetPath = details.summary().assetRoot() + .toAbsolutePath() + .normalize() + .relativize(tilesetPath) + .toString() + .replace('\\', '/'); + final int tileCount = metadata.mapWidth() * metadata.mapHeight(); + final List gids = new ArrayList<>(tileCount); + for (int index = 0; index < tileCount; index += 1) { + gids.add(0L); + } + final TiledMapDocument document = new TiledMapDocument( + "1.10", + "1.12.1", + "orthogonal", + "right-down", + metadata.mapWidth(), + metadata.mapHeight(), + tileset.tileWidth(), + tileset.tileHeight(), + 2, + 1, + List.of(), + List.of(new TiledTilesetReference(1, relativeTilesetPath)), + List.of(new TiledTileLayer( + 1, + binding.layerName(), + metadata.mapWidth(), + metadata.mapHeight(), + gids, + List.of())), + List.of()); + final Path outputPath = details.summary().assetRoot().resolve(binding.tilemap()); + codec.writeMap(outputPath, document); + writtenFiles.add(outputPath); + } + return new TiledAssetGenerationResult(true, "Generated TMX successfully.", writtenFiles); + } catch (TiledUnsupportedFeatureException | IOException exception) { + return new TiledAssetGenerationResult(false, "Unable to generate TMX: " + exception.getMessage(), List.of()); + } + } + + private int parseTileSize(List metadataFields) { + final String value = metadataFields.stream() + .filter(field -> "tile_size".equals(field.key())) + .map(PackerCodecConfigurationFieldDTO::value) + .findFirst() + .orElse("16x16"); + final String normalized = value.trim().toLowerCase(); + if (normalized.startsWith("8x8")) { + return 8; + } + if (normalized.startsWith("32x32")) { + return 32; + } + return 16; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledMapDocument.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledMapDocument.java new file mode 100644 index 00000000..c95ebece --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledMapDocument.java @@ -0,0 +1,32 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.List; +import java.util.Objects; + +public record TiledMapDocument( + String version, + String tiledVersion, + String orientation, + String renderOrder, + int width, + int height, + int tileWidth, + int tileHeight, + int nextLayerId, + int nextObjectId, + List properties, + List tilesets, + List tileLayers, + List objectLayers) { + + public TiledMapDocument { + version = Objects.requireNonNullElse(version, "1.10"); + tiledVersion = Objects.requireNonNullElse(tiledVersion, "1.12.1"); + orientation = Objects.requireNonNullElse(orientation, "orthogonal"); + renderOrder = Objects.requireNonNullElse(renderOrder, "right-down"); + properties = List.copyOf(Objects.requireNonNull(properties, "properties")); + tilesets = List.copyOf(Objects.requireNonNull(tilesets, "tilesets")); + tileLayers = List.copyOf(Objects.requireNonNull(tileLayers, "tileLayers")); + objectLayers = List.copyOf(Objects.requireNonNull(objectLayers, "objectLayers")); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledObjectData.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledObjectData.java new file mode 100644 index 00000000..3831db2f --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledObjectData.java @@ -0,0 +1,21 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.List; +import java.util.Objects; + +public record TiledObjectData( + int id, + String name, + double x, + double y, + double width, + double height, + List polygonPoints, + List properties) { + + public TiledObjectData { + name = Objects.requireNonNullElse(name, "").trim(); + polygonPoints = List.copyOf(Objects.requireNonNull(polygonPoints, "polygonPoints")); + properties = List.copyOf(Objects.requireNonNull(properties, "properties")); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledObjectLayer.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledObjectLayer.java new file mode 100644 index 00000000..f08dc562 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledObjectLayer.java @@ -0,0 +1,17 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.List; +import java.util.Objects; + +public record TiledObjectLayer( + int id, + String name, + List objects, + List properties) { + + public TiledObjectLayer { + name = Objects.requireNonNullElse(name, "").trim(); + objects = List.copyOf(Objects.requireNonNull(objects, "objects")); + properties = List.copyOf(Objects.requireNonNull(properties, "properties")); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledPoint.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledPoint.java new file mode 100644 index 00000000..2fb4561f --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledPoint.java @@ -0,0 +1,4 @@ +package p.studio.workspaces.assets.tiled; + +public record TiledPoint(double x, double y) { +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledProperty.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledProperty.java new file mode 100644 index 00000000..02ecd4f8 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledProperty.java @@ -0,0 +1,17 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.Objects; + +public record TiledProperty(String name, String type, String value) { + public TiledProperty { + name = Objects.requireNonNull(name, "name").trim(); + type = Objects.requireNonNullElse(type, "string").trim(); + value = Objects.requireNonNullElse(value, "").trim(); + if (name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + if (type.isBlank()) { + type = "string"; + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTileLayer.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTileLayer.java new file mode 100644 index 00000000..fd5fba06 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTileLayer.java @@ -0,0 +1,19 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.List; +import java.util.Objects; + +public record TiledTileLayer( + int id, + String name, + int width, + int height, + List gids, + List properties) { + + public TiledTileLayer { + name = Objects.requireNonNullElse(name, "").trim(); + gids = List.copyOf(Objects.requireNonNull(gids, "gids")); + properties = List.copyOf(Objects.requireNonNull(properties, "properties")); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetDocument.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetDocument.java new file mode 100644 index 00000000..5cd0efb1 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetDocument.java @@ -0,0 +1,27 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.List; +import java.util.Objects; + +public record TiledTilesetDocument( + String version, + String tiledVersion, + String name, + int tileWidth, + int tileHeight, + int tileCount, + int columns, + List properties, + List tiles) { + + public TiledTilesetDocument { + version = Objects.requireNonNullElse(version, "1.10"); + tiledVersion = Objects.requireNonNullElse(tiledVersion, "1.12.1"); + name = Objects.requireNonNull(name, "name").trim(); + properties = List.copyOf(Objects.requireNonNull(properties, "properties")); + tiles = List.copyOf(Objects.requireNonNull(tiles, "tiles")); + if (name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetReference.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetReference.java new file mode 100644 index 00000000..579460cf --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetReference.java @@ -0,0 +1,15 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.Objects; + +public record TiledTilesetReference(int firstGid, String source) { + public TiledTilesetReference { + source = Objects.requireNonNull(source, "source").trim(); + if (firstGid <= 0) { + throw new IllegalArgumentException("firstGid must be positive"); + } + if (source.isBlank()) { + throw new IllegalArgumentException("source must not be blank"); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetTile.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetTile.java new file mode 100644 index 00000000..468241da --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetTile.java @@ -0,0 +1,21 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.List; +import java.util.Objects; + +public record TiledTilesetTile( + int id, + String imageSource, + int imageWidth, + int imageHeight, + List properties, + TiledObjectLayer collisionLayer) { + + public TiledTilesetTile { + imageSource = Objects.requireNonNull(imageSource, "imageSource").trim(); + properties = List.copyOf(Objects.requireNonNull(properties, "properties")); + if (imageSource.isBlank()) { + throw new IllegalArgumentException("imageSource must not be blank"); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledUnsupportedFeatureException.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledUnsupportedFeatureException.java new file mode 100644 index 00000000..a9ac37ea --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledUnsupportedFeatureException.java @@ -0,0 +1,7 @@ +package p.studio.workspaces.assets.tiled; + +public final class TiledUnsupportedFeatureException extends RuntimeException { + public TiledUnsupportedFeatureException(String message) { + super(message); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledXmlCodec.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledXmlCodec.java new file mode 100644 index 00000000..afaa4202 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledXmlCodec.java @@ -0,0 +1,460 @@ +package p.studio.workspaces.assets.tiled; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +public final class TiledXmlCodec { + public TiledMapDocument readMap(Path path) throws IOException { + final Document document = parseDocument(path); + final Element root = document.getDocumentElement(); + if (!"map".equals(root.getTagName())) { + throw new IOException("TMX root element must be ."); + } + if (!"0".equals(root.getAttribute("infinite")) && !root.getAttribute("infinite").isBlank()) { + throw new TiledUnsupportedFeatureException("Infinite maps are not supported in wave 1."); + } + rejectMapUnsupportedChildren(root); + return new TiledMapDocument( + root.getAttribute("version"), + root.getAttribute("tiledversion"), + root.getAttribute("orientation"), + root.getAttribute("renderorder"), + intAttribute(root, "width", 0), + intAttribute(root, "height", 0), + intAttribute(root, "tilewidth", 0), + intAttribute(root, "tileheight", 0), + intAttribute(root, "nextlayerid", 0), + intAttribute(root, "nextobjectid", 0), + readProperties(child(root, "properties")), + readTilesets(root), + readTileLayers(root), + readObjectLayers(root)); + } + + public void writeMap(Path path, TiledMapDocument map) throws IOException { + final Document document = newDocument(); + final Element root = document.createElement("map"); + document.appendChild(root); + root.setAttribute("version", map.version()); + root.setAttribute("tiledversion", map.tiledVersion()); + root.setAttribute("orientation", map.orientation()); + root.setAttribute("renderorder", map.renderOrder()); + root.setAttribute("width", Integer.toString(map.width())); + root.setAttribute("height", Integer.toString(map.height())); + root.setAttribute("tilewidth", Integer.toString(map.tileWidth())); + root.setAttribute("tileheight", Integer.toString(map.tileHeight())); + root.setAttribute("infinite", "0"); + root.setAttribute("nextlayerid", Integer.toString(map.nextLayerId())); + root.setAttribute("nextobjectid", Integer.toString(map.nextObjectId())); + appendProperties(document, root, map.properties()); + for (TiledTilesetReference tileset : map.tilesets()) { + final Element tilesetElement = document.createElement("tileset"); + tilesetElement.setAttribute("firstgid", Integer.toString(tileset.firstGid())); + tilesetElement.setAttribute("source", tileset.source()); + root.appendChild(tilesetElement); + } + for (TiledTileLayer layer : map.tileLayers()) { + final Element layerElement = document.createElement("layer"); + layerElement.setAttribute("id", Integer.toString(layer.id())); + layerElement.setAttribute("name", layer.name()); + layerElement.setAttribute("width", Integer.toString(layer.width())); + layerElement.setAttribute("height", Integer.toString(layer.height())); + appendProperties(document, layerElement, layer.properties()); + final Element dataElement = document.createElement("data"); + dataElement.setAttribute("encoding", "csv"); + dataElement.setTextContent(csv(layer.gids(), layer.width())); + layerElement.appendChild(dataElement); + root.appendChild(layerElement); + } + for (TiledObjectLayer layer : map.objectLayers()) { + root.appendChild(writeObjectLayer(document, layer, false)); + } + writeDocument(path, document); + } + + public TiledTilesetDocument readTileset(Path path) throws IOException { + final Document document = parseDocument(path); + final Element root = document.getDocumentElement(); + if (!"tileset".equals(root.getTagName())) { + throw new IOException("TSX root element must be ."); + } + rejectTilesetUnsupportedChildren(root); + final List tiles = new ArrayList<>(); + for (Element tileElement : children(root, "tile")) { + if (child(tileElement, "animation") != null) { + throw new TiledUnsupportedFeatureException("Tiled animations are not supported in wave 1."); + } + final Element imageElement = child(tileElement, "image"); + if (imageElement == null) { + throw new IOException("TSX tile must contain an element."); + } + tiles.add(new TiledTilesetTile( + intAttribute(tileElement, "id", 0), + imageElement.getAttribute("source"), + intAttribute(imageElement, "width", 0), + intAttribute(imageElement, "height", 0), + readProperties(child(tileElement, "properties")), + readEmbeddedObjectLayer(tileElement))); + } + tiles.sort(Comparator.comparingInt(TiledTilesetTile::id)); + return new TiledTilesetDocument( + root.getAttribute("version"), + root.getAttribute("tiledversion"), + root.getAttribute("name"), + intAttribute(root, "tilewidth", 0), + intAttribute(root, "tileheight", 0), + intAttribute(root, "tilecount", tiles.size()), + intAttribute(root, "columns", 0), + readProperties(child(root, "properties")), + tiles); + } + + public void writeTileset(Path path, TiledTilesetDocument tileset) throws IOException { + final Document document = newDocument(); + final Element root = document.createElement("tileset"); + document.appendChild(root); + root.setAttribute("version", tileset.version()); + root.setAttribute("tiledversion", tileset.tiledVersion()); + root.setAttribute("name", tileset.name()); + root.setAttribute("tilewidth", Integer.toString(tileset.tileWidth())); + root.setAttribute("tileheight", Integer.toString(tileset.tileHeight())); + root.setAttribute("tilecount", Integer.toString(tileset.tileCount())); + root.setAttribute("columns", Integer.toString(tileset.columns())); + final Element grid = document.createElement("grid"); + grid.setAttribute("orientation", "orthogonal"); + grid.setAttribute("width", "1"); + grid.setAttribute("height", "1"); + root.appendChild(grid); + appendProperties(document, root, tileset.properties()); + for (TiledTilesetTile tile : tileset.tiles()) { + final Element tileElement = document.createElement("tile"); + tileElement.setAttribute("id", Integer.toString(tile.id())); + appendProperties(document, tileElement, tile.properties()); + final Element imageElement = document.createElement("image"); + imageElement.setAttribute("source", tile.imageSource()); + imageElement.setAttribute("width", Integer.toString(tile.imageWidth())); + imageElement.setAttribute("height", Integer.toString(tile.imageHeight())); + tileElement.appendChild(imageElement); + if (tile.collisionLayer() != null) { + tileElement.appendChild(writeObjectLayer(document, tile.collisionLayer(), true)); + } + root.appendChild(tileElement); + } + writeDocument(path, document); + } + + private Document parseDocument(Path path) throws IOException { + try { + final var factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + return factory.newDocumentBuilder().parse(path.toFile()); + } catch (TiledUnsupportedFeatureException exception) { + throw exception; + } catch (Exception exception) { + throw new IOException("Unable to parse Tiled XML: " + exception.getMessage(), exception); + } + } + + private Document newDocument() throws IOException { + try { + return DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + } catch (Exception exception) { + throw new IOException("Unable to create XML document: " + exception.getMessage(), exception); + } + } + + private void writeDocument(Path path, Document document) throws IOException { + try { + Files.createDirectories(Objects.requireNonNull(path, "path").toAbsolutePath().normalize().getParent()); + final var transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); + transformer.transform(new DOMSource(document), new StreamResult(path.toFile())); + } catch (Exception exception) { + throw new IOException("Unable to write Tiled XML: " + exception.getMessage(), exception); + } + } + + private void rejectMapUnsupportedChildren(Element root) { + for (Element child : childElements(root)) { + final String tag = child.getTagName(); + if ("imagelayer".equals(tag) || "group".equals(tag) || "template".equals(tag)) { + throw new TiledUnsupportedFeatureException("Unsupported TMX feature in wave 1: " + tag); + } + } + } + + private void rejectTilesetUnsupportedChildren(Element root) { + for (Element child : childElements(root)) { + final String tag = child.getTagName(); + if ("wangsets".equals(tag) || "tileoffset".equals(tag) || "transformations".equals(tag)) { + throw new TiledUnsupportedFeatureException("Unsupported TSX feature in wave 1: " + tag); + } + } + } + + private List readTilesets(Element root) { + final List tilesets = new ArrayList<>(); + for (Element tilesetElement : children(root, "tileset")) { + if (!tilesetElement.hasAttribute("source")) { + throw new TiledUnsupportedFeatureException("Inline tilesets are not supported in wave 1."); + } + tilesets.add(new TiledTilesetReference( + intAttribute(tilesetElement, "firstgid", 1), + tilesetElement.getAttribute("source"))); + } + return List.copyOf(tilesets); + } + + private List readTileLayers(Element root) { + final List layers = new ArrayList<>(); + for (Element layerElement : children(root, "layer")) { + final Element dataElement = child(layerElement, "data"); + if (dataElement == null || !"csv".equalsIgnoreCase(dataElement.getAttribute("encoding"))) { + throw new TiledUnsupportedFeatureException("Wave 1 supports only CSV tile layer encoding."); + } + layers.add(new TiledTileLayer( + intAttribute(layerElement, "id", 0), + layerElement.getAttribute("name"), + intAttribute(layerElement, "width", 0), + intAttribute(layerElement, "height", 0), + parseCsvData(dataElement.getTextContent()), + readProperties(child(layerElement, "properties")))); + } + return List.copyOf(layers); + } + + private List readObjectLayers(Element root) { + final List layers = new ArrayList<>(); + for (Element objectGroup : children(root, "objectgroup")) { + layers.add(readObjectLayer(objectGroup)); + } + return List.copyOf(layers); + } + + private TiledObjectLayer readEmbeddedObjectLayer(Element parent) { + final Element objectGroup = child(parent, "objectgroup"); + return objectGroup == null ? null : readObjectLayer(objectGroup); + } + + private TiledObjectLayer readObjectLayer(Element objectGroup) { + final List objects = new ArrayList<>(); + for (Element objectElement : children(objectGroup, "object")) { + final Element polygon = child(objectElement, "polygon"); + if (child(objectElement, "ellipse") != null || child(objectElement, "point") != null || child(objectElement, "polyline") != null) { + throw new TiledUnsupportedFeatureException("Unsupported object-layer geometry in wave 1."); + } + objects.add(new TiledObjectData( + intAttribute(objectElement, "id", 0), + objectElement.getAttribute("name"), + doubleAttribute(objectElement, "x", 0.0d), + doubleAttribute(objectElement, "y", 0.0d), + doubleAttribute(objectElement, "width", 0.0d), + doubleAttribute(objectElement, "height", 0.0d), + polygon == null ? List.of() : parsePolygon(polygon.getAttribute("points")), + readProperties(child(objectElement, "properties")))); + } + return new TiledObjectLayer( + intAttribute(objectGroup, "id", 0), + objectGroup.getAttribute("name"), + objects, + readProperties(child(objectGroup, "properties"))); + } + + private Element writeObjectLayer(Document document, TiledObjectLayer layer, boolean embedded) { + final Element objectGroup = document.createElement("objectgroup"); + if (layer.id() > 0) { + objectGroup.setAttribute("id", Integer.toString(layer.id())); + } + if (!layer.name().isBlank()) { + objectGroup.setAttribute("name", layer.name()); + } + if (embedded) { + objectGroup.setAttribute("draworder", "index"); + } + appendProperties(document, objectGroup, layer.properties()); + for (TiledObjectData object : layer.objects()) { + final Element objectElement = document.createElement("object"); + if (object.id() > 0) { + objectElement.setAttribute("id", Integer.toString(object.id())); + } + if (!object.name().isBlank()) { + objectElement.setAttribute("name", object.name()); + } + objectElement.setAttribute("x", formatDecimal(object.x())); + objectElement.setAttribute("y", formatDecimal(object.y())); + if (object.width() > 0.0d) { + objectElement.setAttribute("width", formatDecimal(object.width())); + } + if (object.height() > 0.0d) { + objectElement.setAttribute("height", formatDecimal(object.height())); + } + appendProperties(document, objectElement, object.properties()); + if (!object.polygonPoints().isEmpty()) { + final Element polygon = document.createElement("polygon"); + polygon.setAttribute("points", polygonPoints(object.polygonPoints())); + objectElement.appendChild(polygon); + } + objectGroup.appendChild(objectElement); + } + return objectGroup; + } + + private void appendProperties(Document document, Element parent, List properties) { + if (properties.isEmpty()) { + return; + } + final Element propertiesElement = document.createElement("properties"); + for (TiledProperty property : properties) { + final Element propertyElement = document.createElement("property"); + propertyElement.setAttribute("name", property.name()); + if (!"string".equals(property.type())) { + propertyElement.setAttribute("type", property.type()); + } + propertyElement.setAttribute("value", property.value()); + propertiesElement.appendChild(propertyElement); + } + parent.appendChild(propertiesElement); + } + + private List readProperties(Element propertiesElement) { + if (propertiesElement == null) { + return List.of(); + } + final List properties = new ArrayList<>(); + for (Element propertyElement : children(propertiesElement, "property")) { + properties.add(new TiledProperty( + propertyElement.getAttribute("name"), + propertyElement.getAttribute("type"), + propertyElement.hasAttribute("value") + ? propertyElement.getAttribute("value") + : propertyElement.getTextContent())); + } + return List.copyOf(properties); + } + + private List parseCsvData(String text) { + final List gids = new ArrayList<>(); + for (String token : Objects.requireNonNullElse(text, "").split(",")) { + final String normalized = token.trim(); + if (normalized.isBlank()) { + continue; + } + gids.add(Long.parseLong(normalized)); + } + return List.copyOf(gids); + } + + private List parsePolygon(String points) { + final List parsed = new ArrayList<>(); + for (String segment : Objects.requireNonNullElse(points, "").trim().split(" ")) { + final String normalized = segment.trim(); + if (normalized.isBlank()) { + continue; + } + final String[] pair = normalized.split(","); + if (pair.length != 2) { + throw new TiledUnsupportedFeatureException("Invalid polygon point format in Tiled XML."); + } + parsed.add(new TiledPoint(Double.parseDouble(pair[0]), Double.parseDouble(pair[1]))); + } + return List.copyOf(parsed); + } + + private String csv(List gids, int width) { + final StringBuilder builder = new StringBuilder(); + for (int index = 0; index < gids.size(); index += 1) { + if (index > 0) { + builder.append(index % Math.max(width, 1) == 0 ? ",\n" : ","); + } + builder.append(Long.toUnsignedString(gids.get(index))); + } + return builder.toString(); + } + + private String polygonPoints(List points) { + final StringBuilder builder = new StringBuilder(); + for (int index = 0; index < points.size(); index += 1) { + if (index > 0) { + builder.append(' '); + } + builder.append(formatDecimal(points.get(index).x())) + .append(',') + .append(formatDecimal(points.get(index).y())); + } + return builder.toString(); + } + + private int intAttribute(Element element, String attribute, int fallback) { + final String value = element.getAttribute(attribute); + if (value == null || value.isBlank()) { + return fallback; + } + return Integer.parseInt(value.trim()); + } + + private double doubleAttribute(Element element, String attribute, double fallback) { + final String value = element.getAttribute(attribute); + if (value == null || value.isBlank()) { + return fallback; + } + return Double.parseDouble(value.trim()); + } + + private String formatDecimal(double value) { + if (Math.rint(value) == value) { + return Long.toString(Math.round(value)); + } + return String.format(Locale.ROOT, "%.6f", value) + .replaceAll("0+$", "") + .replaceAll("\\.$", ""); + } + + private Element child(Element parent, String name) { + for (Element child : childElements(parent)) { + if (name.equals(child.getTagName())) { + return child; + } + } + return null; + } + + private List children(Element parent, String name) { + final List elements = new ArrayList<>(); + for (Element child : childElements(parent)) { + if (name.equals(child.getTagName())) { + elements.add(child); + } + } + return List.copyOf(elements); + } + + private List childElements(Element parent) { + final NodeList children = parent.getChildNodes(); + final List elements = new ArrayList<>(); + for (int index = 0; index < children.getLength(); index += 1) { + final Node node = children.item(index); + if (node instanceof Element element) { + elements.add(element); + } + } + return List.copyOf(elements); + } +} diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index ed96d566..9678c314 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -144,6 +144,8 @@ assets.section.actions=Actions assets.actions.empty=No actions available for this asset. assets.action.register=Register assets.action.analyse=Analyse +assets.action.generateTsx=Generate TSX +assets.action.generateTmx=Generate TMX assets.action.delete=Delete assets.deleteDialog.title=Delete Asset assets.deleteDialog.description=Type the asset name below to confirm deletion of {0}. diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataServiceTest.java index 51301395..958a5d73 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataServiceTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataServiceTest.java @@ -34,10 +34,12 @@ final class AssetStudioMetadataServiceTest { Files.writeString(assetRoot.resolve(AssetStudioMetadataService.SCENE_BANK_SUPPORT_FILE), """ { "schema_version": 1, + "map_width": 20, + "map_height": 12, "layer_count": 2, "layers": [ - { "index": 1, "tilemap": "ground.tmx" }, - { "index": 2, "tilemap": "collision.tmx" } + { "index": 1, "name": "Ground", "tilemap": "ground.tmx", "tileset_asset_root": "tilesets/ground" }, + { "index": 2, "name": "Collision", "tilemap": "collision.tmx", "tileset_asset_root": "tilesets/ground" } ] } """); @@ -45,6 +47,8 @@ final class AssetStudioMetadataServiceTest { final AssetStudioMetadataSnapshot snapshot = service.read(assetRoot, AssetFamilyCatalog.SCENE_BANK); assertNotNull(snapshot.sceneBankMetadata()); + assertEquals(20, snapshot.sceneBankMetadata().mapWidth()); + assertEquals(12, snapshot.sceneBankMetadata().mapHeight()); assertEquals(2, snapshot.sceneBankMetadata().layerCount()); assertEquals( java.util.List.of("ground.tmx", "collision.tmx"), @@ -58,13 +62,15 @@ final class AssetStudioMetadataServiceTest { Files.writeString(assetRoot.resolve(AssetStudioMetadataService.SCENE_BANK_SUPPORT_FILE), """ { "schema_version": 1, + "map_width": 16, + "map_height": 16, "layer_count": 5, "layers": [ - { "index": 1, "tilemap": "a.tmx" }, - { "index": 2, "tilemap": "b.tmx" }, - { "index": 3, "tilemap": "c.tmx" }, - { "index": 4, "tilemap": "d.tmx" }, - { "index": 5, "tilemap": "e.tmx" } + { "index": 1, "name": "A", "tilemap": "a.tmx", "tileset_asset_root": "" }, + { "index": 2, "name": "B", "tilemap": "b.tmx", "tileset_asset_root": "" }, + { "index": 3, "name": "C", "tilemap": "c.tmx", "tileset_asset_root": "" }, + { "index": 4, "name": "D", "tilemap": "d.tmx", "tileset_asset_root": "" }, + { "index": 5, "name": "E", "tilemap": "e.tmx", "tileset_asset_root": "" } ] } """); diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationServiceTest.java new file mode 100644 index 00000000..dfa3cec7 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationServiceTest.java @@ -0,0 +1,141 @@ +package p.studio.workspaces.assets.tiled; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.packer.dtos.PackerCodecConfigurationFieldDTO; +import p.packer.messages.AssetReference; +import p.packer.messages.assets.AssetFamilyCatalog; +import p.packer.messages.assets.OutputCodecCatalog; +import p.packer.messages.assets.OutputFormatCatalog; +import p.packer.messages.assets.PackerCodecConfigurationFieldType; +import p.studio.projects.ProjectReference; +import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization; +import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata; +import p.studio.workspaces.assets.metadata.AssetStudioSceneLayerBinding; +import p.studio.workspaces.assets.messages.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +final class TiledAssetGenerationServiceTest { + private final TiledAssetGenerationService service = new TiledAssetGenerationService(); + private final TiledXmlCodec codec = new TiledXmlCodec(); + + @TempDir + Path tempDir; + + @Test + void generatesTsxForTilesetSpecializedGlyphBank() throws Exception { + final Path assetRoot = tempDir.resolve("assets/tilesets/overworld"); + Files.createDirectories(assetRoot); + Files.writeString(assetRoot.resolve("a.png"), "fixture"); + Files.writeString(assetRoot.resolve("b.png"), "fixture"); + + final TiledAssetGenerationResult result = service.generateTilesetTsx(tilesetDetails(assetRoot)); + + assertTrue(result.success()); + final Path tsxPath = assetRoot.resolve(TiledAssetGenerationService.GENERATED_TSX_FILE); + assertTrue(Files.isRegularFile(tsxPath)); + final TiledTilesetDocument tileset = codec.readTileset(tsxPath); + assertEquals(2, tileset.tiles().size()); + assertEquals("a.png", tileset.tiles().get(0).imageSource()); + assertEquals("glyph_id", tileset.tiles().get(0).properties().getFirst().name()); + } + + @Test + void generatesTmxFilesForSceneBankUsingReferencedTsx() throws Exception { + final Path projectRoot = tempDir.resolve("project"); + final Path tilesetRoot = projectRoot.resolve("assets/tilesets/overworld"); + Files.createDirectories(tilesetRoot); + Files.writeString(tilesetRoot.resolve("a.png"), "fixture"); + assertTrue(service.generateTilesetTsx(tilesetDetails(tilesetRoot)).success()); + + final Path sceneRoot = projectRoot.resolve("assets/scenes/overworld"); + Files.createDirectories(sceneRoot); + final AssetWorkspaceAssetDetails details = sceneDetails(sceneRoot); + final ProjectReference projectReference = new ProjectReference("main", "1", "pbs", 1, projectRoot); + + final TiledAssetGenerationResult result = service.generateSceneBankTilemaps(projectReference, details); + + assertTrue(result.success()); + final Path tmxPath = sceneRoot.resolve("ground.tmx"); + assertTrue(Files.isRegularFile(tmxPath)); + final TiledMapDocument map = codec.readMap(tmxPath); + assertEquals("../../tilesets/overworld/tileset.tsx", map.tilesets().getFirst().source()); + assertEquals(16, map.width()); + assertEquals(12, map.height()); + assertEquals("Ground", map.tileLayers().getFirst().name()); + } + + private AssetWorkspaceAssetDetails tilesetDetails(Path assetRoot) { + return new AssetWorkspaceAssetDetails( + new AssetWorkspaceAssetSummary( + AssetReference.forAssetId(1), + "overworld_tileset", + AssetWorkspaceAssetState.REGISTERED, + AssetWorkspaceBuildParticipation.INCLUDED, + 1, + AssetFamilyCatalog.GLYPH_BANK, + AssetStudioGlyphSpecialization.TILESET, + assetRoot, + false, + false), + List.of(), + OutputFormatCatalog.GLYPH_INDEXED_V1, + OutputCodecCatalog.NONE, + List.of(OutputCodecCatalog.NONE), + Map.of(OutputCodecCatalog.NONE, List.of()), + List.of(new PackerCodecConfigurationFieldDTO( + "tile_size", + "Tile Size", + PackerCodecConfigurationFieldType.ENUM, + "16x16", + true, + List.of("8x8", "16x16", "32x32"))), + Map.of(), + new AssetWorkspaceBankCompositionDetails( + List.of(), + List.of( + new AssetWorkspaceBankCompositionFile("a.png", "a.png", 1L, 1L, null, Map.of()), + new AssetWorkspaceBankCompositionFile("b.png", "b.png", 1L, 1L, null, Map.of())), + 0L), + List.of(), + null, + List.of()); + } + + private AssetWorkspaceAssetDetails sceneDetails(Path assetRoot) { + return new AssetWorkspaceAssetDetails( + new AssetWorkspaceAssetSummary( + AssetReference.forAssetId(2), + "overworld_scene", + AssetWorkspaceAssetState.REGISTERED, + AssetWorkspaceBuildParticipation.EXCLUDED, + 2, + AssetFamilyCatalog.SCENE_BANK, + AssetStudioGlyphSpecialization.NONE, + assetRoot, + false, + false), + List.of(), + OutputFormatCatalog.SCENE_TILED_V1, + OutputCodecCatalog.NONE, + List.of(OutputCodecCatalog.NONE), + Map.of(OutputCodecCatalog.NONE, List.of()), + List.of(), + Map.of(), + new AssetWorkspaceBankCompositionDetails(List.of(), List.of(), 0L), + List.of(), + new AssetStudioSceneBankMetadata( + 16, + 12, + 1, + List.of(new AssetStudioSceneLayerBinding(1, "Ground", "ground.tmx", "tilesets/overworld")), + assetRoot.resolve("scene-bank.studio.json")), + List.of()); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledXmlCodecTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledXmlCodecTest.java new file mode 100644 index 00000000..b7401de8 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledXmlCodecTest.java @@ -0,0 +1,55 @@ +package p.studio.workspaces.assets.tiled; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +final class TiledXmlCodecTest { + private final TiledXmlCodec codec = new TiledXmlCodec(); + + @TempDir + Path tempDir; + + @Test + void readsFixtureTmxAndPreservesWaveOneSurfaces() throws Exception { + final TiledMapDocument map = codec.readMap(Path.of("..", "test-projects", "main", "assets", "scenes", "primeiro mapa.tmx").toAbsolutePath().normalize()); + + assertEquals(25, map.width()); + assertEquals(25, map.height()); + assertEquals(1, map.tilesets().size()); + assertEquals("../Zelda3/primeiro tileset.tsx", map.tilesets().getFirst().source()); + assertEquals(1, map.tileLayers().size()); + assertEquals(625, map.tileLayers().getFirst().gids().size()); + assertEquals(1, map.objectLayers().size()); + assertEquals(9, map.objectLayers().getFirst().objects().size()); + } + + @Test + void readsFixtureTsxWithPropertiesAndCollisionObjects() throws Exception { + final TiledTilesetDocument tileset = codec.readTileset(Path.of("..", "test-projects", "main", "assets", "Zelda3", "primeiro tileset.tsx").toAbsolutePath().normalize()); + + assertEquals(32, tileset.tileWidth()); + assertEquals(32, tileset.tileHeight()); + assertEquals(13, tileset.tileCount()); + assertEquals(13, tileset.tiles().size()); + assertEquals("glyph_id", tileset.tiles().getFirst().properties().getFirst().name()); + assertNotNull(tileset.tiles().getFirst().collisionLayer()); + assertEquals(2, tileset.tiles().getFirst().collisionLayer().objects().size()); + } + + @Test + void rejectsUnsupportedInfiniteMaps() throws Exception { + final Path path = tempDir.resolve("infinite.tmx"); + Files.writeString(path, """ + + + """); + + assertThrows(TiledUnsupportedFeatureException.class, () -> codec.readMap(path)); + } +}