Compare commits

..

4 Commits

Author SHA1 Message Date
a6b0760657
Tiled Parser and Scene Asset-Type Ownership in Assets Workspace
Some checks failed
Intrepid/Prometeu/Studio/pipeline/pr-master There was a failure building this commit
2026-04-18 18:11:18 +01:00
b77a24c57b
implements PLN-0055 2026-04-18 18:01:25 +01:00
831e419c80
implements PLN-0054 2026-04-18 17:54:00 +01:00
bbd588eed4
implements PLN-0053 2026-04-18 17:46:04 +01:00
50 changed files with 2627 additions and 345 deletions

View File

@ -4,6 +4,7 @@ import java.util.Locale;
public enum AssetFamilyCatalog {
GLYPH_BANK("glyph_bank"),
SCENE_BANK("scene_bank"),
SOUND_BANK("sound_bank"),
UNKNOWN("unknown");

View File

@ -5,6 +5,7 @@ import java.util.Locale;
public enum OutputFormatCatalog {
GLYPH_INDEXED_V1(AssetFamilyCatalog.GLYPH_BANK, "GLYPH/indexed_v1", "GLYPH/indexed_v1"),
SCENE_TILED_V1(AssetFamilyCatalog.SCENE_BANK, "SCENE/tiled_v1", "SCENE/tiled_v1"),
SOUND_V1(AssetFamilyCatalog.SOUND_BANK, "SOUND/v1", "SOUND/v1"),
UNKNOWN(AssetFamilyCatalog.UNKNOWN, "unknown", "Unknown");

View File

@ -100,6 +100,9 @@ public class PackerAssetWalker {
diagnostics.addAll(walkResult.diagnostics());
return new PackerWalkResult(walkResult.probeResults(), diagnostics);
}
case SCENE_BANK -> {
return new PackerWalkResult(List.of(), diagnostics);
}
case UNKNOWN -> {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.WARNING,

View File

@ -38,6 +38,7 @@ import java.util.stream.Stream;
public final class FileSystemPackerWorkspaceService implements PackerWorkspaceService {
private static final int GLYPH_BANK_COLOR_KEY_RGB565 = 0xF81F;
private static final String SCENE_BANK_SUPPORT_FILE = "scene-bank.studio.json";
private final ObjectMapper mapper;
private final PackerWorkspaceFoundation workspaceFoundation;
@ -287,6 +288,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
final PackerRegistryEntry entry = workspaceFoundation.allocateIdentity(project, registry, assetRoot);
Files.createDirectories(assetRoot);
writeManifest(manifestPath, request, entry.assetUuid());
writeStudioSupportFiles(assetRoot, request);
final PackerRegistryState updated = workspaceFoundation.appendAllocatedEntry(registry, entry);
workspaceFoundation.saveRegistry(project, updated);
final var runtime = runtimeRegistry.update(project, (snapshot, generation) -> runtimePatchService.afterCreateAsset(
@ -667,6 +669,25 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
mapper.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
}
private void writeStudioSupportFiles(Path assetRoot, CreateAssetRequest request) throws IOException {
if (request.assetFamily() != AssetFamilyCatalog.SCENE_BANK) {
return;
}
final Map<String, Object> 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,
"name", "Layer 1",
"tilemap", "layer-1.tmx",
"tileset_asset_root", "")));
mapper.writerWithDefaultPrettyPrinter().writeValue(
assetRoot.resolve(SCENE_BANK_SUPPORT_FILE).toFile(),
supportFile);
}
private String normalizeRelativeAssetRoot(String candidate) {
final String raw = Objects.requireNonNullElse(candidate, "").trim().replace('\\', '/');
if (raw.isBlank()) {

View File

@ -121,7 +121,7 @@ public final class PackerAssetDeclarationParser {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Field 'type' must be one of: glyph_bank, palette_bank, sound_bank.",
"Field 'type' must be one of: glyph_bank, scene_bank, sound_bank.",
manifestPath,
true));
return null;

View File

@ -512,6 +512,32 @@ final class FileSystemPackerWorkspaceServiceTest {
assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.ACTION_APPLIED));
}
@Test
void createsSceneBankAssetAndWritesStudioSupportFile() throws Exception {
final Path projectRoot = tempDir.resolve("created-scene-bank");
final FileSystemPackerWorkspaceService service = service();
final var result = service.createAsset(new CreateAssetRequest(
project(projectRoot),
"scenes/overworld",
"overworld",
AssetFamilyCatalog.SCENE_BANK,
OutputFormatCatalog.SCENE_TILED_V1,
OutputCodecCatalog.NONE,
false));
assertEquals(PackerOperationStatus.SUCCESS, result.status());
final Path assetRoot = projectRoot.resolve("assets/scenes/overworld");
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());
}
@Test
void returnsCreatedAssetThroughRuntimeBackedDetailsWithoutRescanMismatch() throws Exception {
final Path projectRoot = tempDir.resolve("created-details");

View File

@ -67,6 +67,31 @@ final class PackerAssetDeclarationParserTest {
assertEquals(128, result.declaration().outputPipelineMetadata().get("samples").path("1").path("length").asInt());
}
@Test
void parsesSceneBankDeclaration() throws Exception {
final Path manifest = tempDir.resolve("scene-asset.json");
Files.writeString(manifest, """
{
"schema_version": 1,
"asset_uuid": "uuid-scene",
"name": "overworld_scene",
"type": "scene_bank",
"output": {
"format": "SCENE/tiled_v1",
"codec": "NONE"
},
"preload": { "enabled": false }
}
""");
final var result = parser.parse(manifest);
assertTrue(result.valid());
assertEquals(AssetFamilyCatalog.SCENE_BANK, result.declaration().assetFamily());
assertEquals("SCENE/tiled_v1", result.declaration().outputFormat().displayName());
assertEquals(OutputCodecCatalog.NONE, result.declaration().outputCodec());
}
@Test
void rejectsNonObjectPipelineMetadata() throws Exception {
final Path manifest = tempDir.resolve("asset.json");

View File

@ -154,6 +154,10 @@ 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_VALIDATE_SCENE_BANK("assets.action.validateSceneBank"),
ASSETS_ACTION_ACCEPT_SCENE_BANK("assets.action.acceptSceneBank"),
ASSETS_ACTION_DELETE("assets.action.delete"),
ASSETS_ACTION_INCLUDE_IN_BUILD("assets.action.includeInBuild"),
ASSETS_ACTION_EXCLUDE_FROM_BUILD("assets.action.excludeFromBuild"),
@ -190,10 +194,24 @@ public enum I18n {
ASSETS_LABEL_BUILD_PARTICIPATION("assets.label.buildParticipation"),
ASSETS_LABEL_ASSET_ID("assets.label.assetId"),
ASSETS_LABEL_TYPE("assets.label.type"),
ASSETS_LABEL_STUDIO_ROLE("assets.label.studioRole"),
ASSETS_LABEL_SCENE_STATUS("assets.label.sceneStatus"),
ASSETS_LABEL_SCENE_LAYERS("assets.label.sceneLayers"),
ASSETS_LABEL_TILEMAPS("assets.label.tilemaps"),
ASSETS_LABEL_SUPPORT_FILE("assets.label.supportFile"),
ASSETS_TYPE_GLYPH_BANK("assets.type.glyphBank"),
ASSETS_TYPE_SCENE_BANK("assets.type.sceneBank"),
ASSETS_TYPE_PALETTE_BANK("assets.type.paletteBank"),
ASSETS_TYPE_SOUND_BANK("assets.type.soundBank"),
ASSETS_TYPE_UNKNOWN("assets.type.unknown"),
ASSETS_SPECIALIZATION_NONE("assets.specialization.none"),
ASSETS_SPECIALIZATION_TILESET("assets.specialization.tileset"),
ASSETS_SPECIALIZATION_SPRITES("assets.specialization.sprites"),
ASSETS_SPECIALIZATION_UI("assets.specialization.ui"),
ASSETS_SCENE_STATUS_PENDING_VALIDATION("assets.sceneStatus.pendingValidation"),
ASSETS_SCENE_STATUS_VALIDATED_PENDING_ACCEPTANCE("assets.sceneStatus.validatedPendingAcceptance"),
ASSETS_SCENE_STATUS_READY("assets.sceneStatus.ready"),
ASSETS_SCENE_STATUS_VALIDATION_FAILED("assets.sceneStatus.validationFailed"),
ASSETS_LABEL_LOCATION("assets.label.location"),
ASSETS_LABEL_BANK("assets.label.bank"),
ASSETS_LABEL_TARGET_LOCATION("assets.label.targetLocation"),
@ -254,10 +272,12 @@ public enum I18n {
ASSETS_ADD_WIZARD_LABEL_NAME("assets.addWizard.label.name"),
ASSETS_ADD_WIZARD_LABEL_ROOT("assets.addWizard.label.root"),
ASSETS_ADD_WIZARD_LABEL_TYPE("assets.addWizard.label.type"),
ASSETS_ADD_WIZARD_LABEL_SPECIALIZATION("assets.addWizard.label.specialization"),
ASSETS_ADD_WIZARD_LABEL_FORMAT("assets.addWizard.label.format"),
ASSETS_ADD_WIZARD_LABEL_CODEC("assets.addWizard.label.codec"),
ASSETS_ADD_WIZARD_LABEL_PRELOAD("assets.addWizard.label.preload"),
ASSETS_ADD_WIZARD_PROMPT_TYPE("assets.addWizard.prompt.type"),
ASSETS_ADD_WIZARD_PROMPT_SPECIALIZATION("assets.addWizard.prompt.specialization"),
ASSETS_ADD_WIZARD_PROMPT_FORMAT("assets.addWizard.prompt.format"),
ASSETS_ADD_WIZARD_PROMPT_CODEC("assets.addWizard.prompt.codec"),
ASSETS_ADD_WIZARD_ASSETS_ROOT_HINT("assets.addWizard.assetsRootHint"),

View File

@ -23,21 +23,31 @@ import p.studio.workspaces.assets.details.bank.AssetDetailsBankCompositionContro
import p.studio.workspaces.assets.details.contract.AssetDetailsContractControl;
import p.studio.workspaces.assets.details.palette.AssetDetailsPaletteOverhaulingControl;
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;
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails;
import p.studio.workspaces.assets.messages.AssetWorkspaceSceneBankValidation;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsStatus;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState;
import p.studio.workspaces.assets.messages.AssetWorkspaceBuildParticipation;
import p.studio.workspaces.assets.messages.events.StudioAssetLogEvent;
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.scene.SceneBankWorkflowResult;
import p.studio.workspaces.assets.scene.SceneBankWorkflowService;
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;
import p.studio.workspaces.framework.StudioEventBindings;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
@ -53,6 +63,9 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
private final AssetDetailsContractControl contractControl;
private final AssetDetailsBankCompositionControl bankCompositionControl;
private final AssetDetailsPaletteOverhaulingControl paletteOverhaulingControl;
private final AssetStudioMetadataService studioMetadataService = new AssetStudioMetadataService();
private final TiledAssetGenerationService tiledGenerationService = new TiledAssetGenerationService();
private final SceneBankWorkflowService sceneBankWorkflowService = new SceneBankWorkflowService();
private final VBox actionsContent = new VBox(10);
private final ScrollPane actionsScroll = new ScrollPane();
private final VBox actionsSection;
@ -324,6 +337,29 @@ 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);
}
if (canValidateSceneBank()) {
final Button validateSceneButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_VALIDATE_SCENE_BANK));
validateSceneButton.setDisable(actionRunning);
validateSceneButton.setOnAction(ignored -> validateSceneBank());
nodes.add(validateSceneButton);
final Button acceptSceneButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_ACCEPT_SCENE_BANK));
acceptSceneButton.setDisable(actionRunning || !viewState.selectedAssetDetails().sceneBankValidation().canAccept());
acceptSceneButton.setOnAction(ignored -> acceptSceneBank());
nodes.add(acceptSceneButton);
}
final Button buildParticipationButton = AssetDetailsUiSupport.createActionButton(buildParticipationActionLabel());
AssetDetailsUiSupport.applyActionTone(
@ -449,6 +485,100 @@ 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 boolean canValidateSceneBank() {
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();
workspaceBus.publish(new StudioAssetLogEvent("scene-bank", result.message()));
renderActions();
if (result.success() && viewState.selectedAssetReference() != null) {
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(viewState.selectedAssetReference()));
workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(viewState.selectedAssetReference(), true));
}
}
private void validateSceneBank() {
if (actionRunning || viewState.selectedAssetDetails() == null) {
return;
}
actionRunning = true;
actionFeedbackMessage = null;
renderActions();
final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails();
Container.backgroundTasks().submit(() -> {
final SceneBankWorkflowResult result = sceneBankWorkflowService.validate(projectReference, details);
Platform.runLater(() -> applySceneBankWorkflowResult("scene-bank-validate", result));
});
}
private void acceptSceneBank() {
if (actionRunning || viewState.selectedAssetDetails() == null) {
return;
}
actionRunning = true;
actionFeedbackMessage = null;
renderActions();
final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails();
Container.backgroundTasks().submit(() -> {
final SceneBankWorkflowResult result = sceneBankWorkflowService.accept(projectReference, details);
Platform.runLater(() -> applySceneBankWorkflowResult("scene-bank-accept", result));
});
}
private void applySceneBankWorkflowResult(String source, SceneBankWorkflowResult result) {
actionRunning = false;
actionFeedbackMessage = result.message();
workspaceBus.publish(new StudioAssetLogEvent(source, result.message()));
renderActions();
if (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) {
@ -534,29 +664,69 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
PackerAssetDetailsDTO details,
java.util.List<PackerDiagnosticDTO> diagnostics,
java.util.List<PackerAssetActionAvailabilityDTO> actions) {
final java.util.List<PackerDiagnosticDTO> mergedDiagnostics = new java.util.ArrayList<>(details.diagnostics());
for (PackerDiagnosticDTO diagnostic : diagnostics) {
if (!mergedDiagnostics.contains(diagnostic)) {
mergedDiagnostics.add(diagnostic);
}
}
return new AssetWorkspaceAssetDetails(
AssetListPackerMappings.mapSummary(details.summary()),
actions.stream()
.map(action -> new AssetWorkspaceAssetAction(
action.action(),
action.enabled(),
action.visible(),
action.reason()))
.toList(),
final var baseSummary = AssetListPackerMappings.mapSummary(details.summary());
final AssetStudioMetadataSnapshot studioMetadata = studioMetadataService.read(
baseSummary.assetRoot(),
baseSummary.assetFamily());
final java.util.List<AssetWorkspaceAssetAction> mappedActions = actions.stream()
.map(action -> new AssetWorkspaceAssetAction(
action.action(),
action.enabled(),
action.visible(),
action.reason()))
.toList();
final p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary mappedSummary =
new p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary(
baseSummary.assetReference(),
baseSummary.assetName(),
baseSummary.state(),
baseSummary.buildParticipation(),
baseSummary.assetId(),
baseSummary.assetFamily(),
studioMetadata.glyphSpecialization(),
baseSummary.assetRoot(),
baseSummary.preload(),
baseSummary.hasDiagnostics());
final AssetWorkspaceBankCompositionDetails bankComposition = mapBankComposition(details.bankComposition());
final AssetWorkspaceAssetDetails draftDetails = new AssetWorkspaceAssetDetails(
mappedSummary,
mappedActions,
details.outputFormat(),
details.outputCodec(),
details.availableOutputCodecs(),
details.codecConfigurationFieldsByCodec(),
details.metadataFields(),
details.outputPipeline(),
mapBankComposition(details.bankComposition()),
bankComposition,
details.pipelinePalettes(),
studioMetadata.sceneBankMetadata(),
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
final SceneBankWorkflowResult sceneBankWorkflow = sceneBankWorkflowService.inspect(projectReference, draftDetails);
final java.util.List<PackerDiagnosticDTO> mergedDiagnostics = new java.util.ArrayList<>(details.diagnostics());
for (PackerDiagnosticDTO diagnostic : diagnostics) {
if (!mergedDiagnostics.contains(diagnostic)) {
mergedDiagnostics.add(diagnostic);
}
}
for (PackerDiagnosticDTO diagnostic : sceneBankWorkflow.diagnostics()) {
if (!mergedDiagnostics.contains(diagnostic)) {
mergedDiagnostics.add(diagnostic);
}
}
return new AssetWorkspaceAssetDetails(
mappedSummary,
mappedActions,
details.outputFormat(),
details.outputCodec(),
details.availableOutputCodecs(),
details.codecConfigurationFieldsByCodec(),
details.metadataFields(),
details.outputPipeline(),
bankComposition,
details.pipelinePalettes(),
studioMetadata.sceneBankMetadata(),
sceneBankWorkflow.validation(),
mergedDiagnostics);
}

View File

@ -16,6 +16,8 @@ import p.studio.controls.forms.StudioFormSection;
import p.studio.controls.forms.StudioSection;
import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization;
import p.studio.workspaces.assets.messages.AssetWorkspaceSceneBankStatus;
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetState;
import p.studio.workspaces.assets.messages.AssetWorkspaceBuildParticipation;
@ -114,6 +116,7 @@ public final class AssetDetailsUiSupport {
public static String typeLabel(AssetFamilyCatalog assetFamily) {
return switch (assetFamily) {
case GLYPH_BANK -> Container.i18n().text(I18n.ASSETS_TYPE_GLYPH_BANK);
case SCENE_BANK -> Container.i18n().text(I18n.ASSETS_TYPE_SCENE_BANK);
case SOUND_BANK -> Container.i18n().text(I18n.ASSETS_TYPE_SOUND_BANK);
case UNKNOWN -> Container.i18n().text(I18n.ASSETS_TYPE_UNKNOWN);
};
@ -122,11 +125,31 @@ public final class AssetDetailsUiSupport {
public static String typeChipTone(AssetFamilyCatalog assetFamily) {
return switch (assetFamily) {
case GLYPH_BANK -> "assets-details-chip-image";
case SCENE_BANK -> "assets-details-chip-generic";
case SOUND_BANK -> "assets-details-chip-audio";
case UNKNOWN -> "assets-details-chip-generic";
};
}
public static String specializationLabel(AssetStudioGlyphSpecialization specialization) {
return switch (specialization) {
case NONE -> Container.i18n().text(I18n.ASSETS_SPECIALIZATION_NONE);
case TILESET -> Container.i18n().text(I18n.ASSETS_SPECIALIZATION_TILESET);
case SPRITES -> Container.i18n().text(I18n.ASSETS_SPECIALIZATION_SPRITES);
case UI -> Container.i18n().text(I18n.ASSETS_SPECIALIZATION_UI);
};
}
public static String sceneBankStatusLabel(AssetWorkspaceSceneBankStatus status) {
return switch (status) {
case NOT_APPLICABLE -> "";
case PENDING_VALIDATION -> Container.i18n().text(I18n.ASSETS_SCENE_STATUS_PENDING_VALIDATION);
case VALIDATED_PENDING_ACCEPTANCE -> Container.i18n().text(I18n.ASSETS_SCENE_STATUS_VALIDATED_PENDING_ACCEPTANCE);
case READY -> Container.i18n().text(I18n.ASSETS_SCENE_STATUS_READY);
case VALIDATION_FAILED -> Container.i18n().text(I18n.ASSETS_SCENE_STATUS_VALIDATION_FAILED);
};
}
public static String actionLabel(AssetAction action) {
return switch (action) {
case REGISTER -> Container.i18n().text(I18n.ASSETS_ACTION_REGISTER);

View File

@ -1,6 +1,7 @@
package p.studio.workspaces.assets.details;
import p.packer.dtos.PackerAssetSummaryDTO;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization;
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetState;
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary;
import p.studio.workspaces.assets.messages.AssetWorkspaceBuildParticipation;
@ -25,6 +26,7 @@ public final class AssetListPackerMappings {
buildParticipation,
summary.identity().assetId(),
summary.assetFamily(),
AssetStudioGlyphSpecialization.NONE,
summary.identity().assetRoot(),
summary.preloadEnabled(),
summary.hasDiagnostics());

View File

@ -21,6 +21,12 @@ public final class AssetBankCapacityService {
final Map<String, Object> safePipeline = Map.copyOf(Objects.requireNonNull(outputPipeline, "outputPipeline"));
return switch (safeFamily) {
case GLYPH_BANK -> evaluateGlyphBank(artifactCount, safeMetadata);
case SCENE_BANK -> new AssetDetailsBankCompositionCapacityState(
0.0d,
StudioAssetCapacitySeverity.GREEN,
false,
artifactCount + " support files",
"");
case SOUND_BANK -> evaluateSoundBank(resolveSoundBankUsedBytes(safePipeline, usedBytes));
case UNKNOWN -> new AssetDetailsBankCompositionCapacityState(
0.0d,

View File

@ -8,6 +8,9 @@ import p.studio.lsp.events.StudioWorkspaceEventBus;
import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.assets.details.AssetDetailsUiSupport;
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.AssetWorkspaceAssetSummary;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState;
import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent;
@ -53,14 +56,43 @@ public final class AssetDetailsSummaryControl extends VBox implements StudioCont
}
final VBox content = new VBox(8);
final AssetStudioSceneBankMetadata sceneBankMetadata = viewState.selectedAssetDetails().sceneBankMetadata();
content.getChildren().setAll(
AssetDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_ASSET_ID), summary.assetId() == null ? "" : String.valueOf(summary.assetId())),
AssetDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), AssetDetailsUiSupport.projectRelativePath(projectReference, summary.assetRoot())));
final VBox typeBox = new VBox(6);
typeBox.getChildren().add(AssetDetailsUiSupport.createChip(
AssetDetailsUiSupport.typeChipTone(summary.assetFamily()),
AssetDetailsUiSupport.typeLabel(summary.assetFamily())));
if (summary.glyphSpecialization() != AssetStudioGlyphSpecialization.NONE) {
typeBox.getChildren().add(AssetDetailsUiSupport.createChip(
"assets-details-chip-generic",
AssetDetailsUiSupport.specializationLabel(summary.glyphSpecialization())));
}
content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_TYPE),
AssetDetailsUiSupport.createChip(
AssetDetailsUiSupport.typeChipTone(summary.assetFamily()),
AssetDetailsUiSupport.typeLabel(summary.assetFamily()))));
typeBox));
if (summary.glyphSpecialization() != AssetStudioGlyphSpecialization.NONE) {
content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_STUDIO_ROLE),
AssetDetailsUiSupport.specializationLabel(summary.glyphSpecialization())));
}
if (sceneBankMetadata != null) {
content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_SCENE_STATUS),
AssetDetailsUiSupport.sceneBankStatusLabel(viewState.selectedAssetDetails().sceneBankValidation().status())));
content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_SCENE_LAYERS),
String.valueOf(sceneBankMetadata.layerCount())));
content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_TILEMAPS),
sceneBankMetadata.layerBindings().stream()
.map(AssetStudioSceneLayerBinding::tilemap)
.collect(java.util.stream.Collectors.joining(", "))));
content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_SUPPORT_FILE),
AssetDetailsUiSupport.projectRelativePath(projectReference, sceneBankMetadata.supportFile())));
}
content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_REGISTRATION),
AssetDetailsUiSupport.createChip(

View File

@ -113,6 +113,7 @@ public final class AssetListItemControl extends VBox {
private String assetRowToneClass(AssetFamilyCatalog assetFamily) {
return switch (assetFamily) {
case GLYPH_BANK -> "assets-workspace-asset-row-tone-image";
case SCENE_BANK -> "assets-workspace-asset-row-tone-generic";
case SOUND_BANK -> "assets-workspace-asset-row-tone-audio";
default -> "assets-workspace-asset-row-tone-generic";
};
@ -121,6 +122,7 @@ public final class AssetListItemControl extends VBox {
private String assetNameToneClass(AssetFamilyCatalog assetFamily) {
return switch (assetFamily) {
case GLYPH_BANK -> "assets-workspace-asset-name-tone-image";
case SCENE_BANK -> "assets-workspace-asset-name-tone-generic";
case SOUND_BANK -> "assets-workspace-asset-name-tone-audio";
default -> "assets-workspace-asset-name-tone-generic";
};

View File

@ -4,6 +4,7 @@ import p.packer.dtos.PackerCodecConfigurationFieldDTO;
import p.packer.dtos.PackerDiagnosticDTO;
import p.packer.messages.assets.OutputCodecCatalog;
import p.packer.messages.assets.OutputFormatCatalog;
import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata;
import java.util.List;
import java.util.Map;
@ -20,6 +21,8 @@ public record AssetWorkspaceAssetDetails(
Map<String, Object> outputPipeline,
AssetWorkspaceBankCompositionDetails bankComposition,
List<Map<String, Object>> pipelinePalettes,
AssetStudioSceneBankMetadata sceneBankMetadata,
AssetWorkspaceSceneBankValidation sceneBankValidation,
List<PackerDiagnosticDTO> diagnostics) {
public AssetWorkspaceAssetDetails {
@ -33,6 +36,7 @@ public record AssetWorkspaceAssetDetails(
outputPipeline = Map.copyOf(Objects.requireNonNull(outputPipeline, "outputPipeline"));
bankComposition = Objects.requireNonNull(bankComposition, "bankComposition");
pipelinePalettes = List.copyOf(Objects.requireNonNull(pipelinePalettes, "pipelinePalettes"));
sceneBankValidation = Objects.requireNonNullElse(sceneBankValidation, AssetWorkspaceSceneBankValidation.NOT_APPLICABLE);
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
}
}

View File

@ -2,6 +2,7 @@ package p.studio.workspaces.assets.messages;
import p.packer.messages.AssetReference;
import p.packer.messages.assets.AssetFamilyCatalog;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization;
import java.nio.file.Path;
import java.util.Objects;
@ -13,6 +14,7 @@ public record AssetWorkspaceAssetSummary(
AssetWorkspaceBuildParticipation buildParticipation,
Integer assetId,
AssetFamilyCatalog assetFamily,
AssetStudioGlyphSpecialization glyphSpecialization,
Path assetRoot,
boolean preload,
boolean hasDiagnostics) {
@ -23,6 +25,7 @@ public record AssetWorkspaceAssetSummary(
Objects.requireNonNull(state, "state");
Objects.requireNonNull(buildParticipation, "buildParticipation");
assetFamily = Objects.requireNonNullElse(assetFamily, AssetFamilyCatalog.UNKNOWN);
glyphSpecialization = Objects.requireNonNullElse(glyphSpecialization, AssetStudioGlyphSpecialization.NONE);
assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize();
if (assetName.isBlank()) {
throw new IllegalArgumentException("assetName must not be blank");

View File

@ -0,0 +1,9 @@
package p.studio.workspaces.assets.messages;
public enum AssetWorkspaceSceneBankStatus {
NOT_APPLICABLE,
PENDING_VALIDATION,
VALIDATED_PENDING_ACCEPTANCE,
READY,
VALIDATION_FAILED
}

View File

@ -0,0 +1,27 @@
package p.studio.workspaces.assets.messages;
import java.util.Objects;
public record AssetWorkspaceSceneBankValidation(
AssetWorkspaceSceneBankStatus status,
boolean pendingExternalChanges,
boolean canAccept,
String currentFingerprint,
String validatedFingerprint,
String acceptedFingerprint) {
public static final AssetWorkspaceSceneBankValidation NOT_APPLICABLE = new AssetWorkspaceSceneBankValidation(
AssetWorkspaceSceneBankStatus.NOT_APPLICABLE,
false,
false,
"",
"",
"");
public AssetWorkspaceSceneBankValidation {
status = Objects.requireNonNullElse(status, AssetWorkspaceSceneBankStatus.NOT_APPLICABLE);
currentFingerprint = Objects.requireNonNullElse(currentFingerprint, "");
validatedFingerprint = Objects.requireNonNullElse(validatedFingerprint, "");
acceptedFingerprint = Objects.requireNonNullElse(acceptedFingerprint, "");
}
}

View File

@ -0,0 +1,33 @@
package p.studio.workspaces.assets.metadata;
import java.util.Locale;
public enum AssetStudioGlyphSpecialization {
NONE("none"),
TILESET("tileset"),
SPRITES("sprites"),
UI("ui");
private final String manifestValue;
AssetStudioGlyphSpecialization(String manifestValue) {
this.manifestValue = manifestValue;
}
public String manifestValue() {
return manifestValue;
}
public static AssetStudioGlyphSpecialization fromManifestValue(String value) {
if (value == null) {
return NONE;
}
final String normalized = value.trim().toLowerCase(Locale.ROOT);
for (AssetStudioGlyphSpecialization candidate : values()) {
if (candidate.manifestValue.equals(normalized)) {
return candidate;
}
}
return NONE;
}
}

View File

@ -0,0 +1,108 @@
package p.studio.workspaces.assets.metadata;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import p.packer.messages.assets.AssetFamilyCatalog;
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.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public final class AssetStudioMetadataService {
public static final String STUDIO_ASSET_METADATA_FILE = "studio.asset.json";
public static final String SCENE_BANK_SUPPORT_FILE = "scene-bank.studio.json";
private final ObjectMapper mapper = new ObjectMapper();
public AssetStudioMetadataSnapshot read(Path assetRoot, AssetFamilyCatalog assetFamily) {
final Path normalizedAssetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize();
return new AssetStudioMetadataSnapshot(
readGlyphSpecialization(normalizedAssetRoot, assetFamily),
readSceneBankMetadata(normalizedAssetRoot, assetFamily));
}
public void writeGlyphSpecialization(Path assetRoot, AssetStudioGlyphSpecialization specialization) throws IOException {
final Path metadataPath = Objects.requireNonNull(assetRoot, "assetRoot")
.toAbsolutePath()
.normalize()
.resolve(STUDIO_ASSET_METADATA_FILE);
final AssetStudioGlyphSpecialization normalized = Objects.requireNonNullElse(
specialization,
AssetStudioGlyphSpecialization.NONE);
if (normalized == AssetStudioGlyphSpecialization.NONE) {
Files.deleteIfExists(metadataPath);
return;
}
final var root = mapper.createObjectNode();
root.put("schema_version", 1);
root.put("glyph_bank_specialization", normalized.manifestValue());
mapper.writerWithDefaultPrettyPrinter().writeValue(metadataPath.toFile(), root);
}
private AssetStudioGlyphSpecialization readGlyphSpecialization(Path assetRoot, AssetFamilyCatalog assetFamily) {
if (assetFamily != AssetFamilyCatalog.GLYPH_BANK) {
return AssetStudioGlyphSpecialization.NONE;
}
final Path metadataPath = assetRoot.resolve(STUDIO_ASSET_METADATA_FILE);
if (!Files.isRegularFile(metadataPath)) {
return AssetStudioGlyphSpecialization.NONE;
}
try {
final JsonNode root = mapper.readTree(metadataPath.toFile());
if (root == null || !root.isObject()) {
return AssetStudioGlyphSpecialization.NONE;
}
return AssetStudioGlyphSpecialization.fromManifestValue(root.path("glyph_bank_specialization").asText(null));
} catch (IOException ignored) {
return AssetStudioGlyphSpecialization.NONE;
}
}
private AssetStudioSceneBankMetadata readSceneBankMetadata(Path assetRoot, AssetFamilyCatalog assetFamily) {
if (assetFamily != AssetFamilyCatalog.SCENE_BANK) {
return null;
}
final Path supportFile = assetRoot.resolve(SCENE_BANK_SUPPORT_FILE);
if (!Files.isRegularFile(supportFile)) {
return null;
}
try {
final JsonNode root = mapper.readTree(supportFile.toFile());
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 (mapWidth <= 0 || mapHeight <= 0 || layerCount < 1 || layerCount > 4) {
return null;
}
if (!(root.path("layers") instanceof ArrayNode layersNode) || layersNode.size() != layerCount) {
return null;
}
final List<AssetStudioSceneLayerBinding> bindings = new ArrayList<>();
final Set<Integer> 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();
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, layerName, tilemap, tilesetAssetRoot));
}
bindings.sort(Comparator.comparingInt(AssetStudioSceneLayerBinding::index));
return new AssetStudioSceneBankMetadata(mapWidth, mapHeight, layerCount, bindings, supportFile);
} catch (IOException ignored) {
return null;
}
}
}

View File

@ -0,0 +1,12 @@
package p.studio.workspaces.assets.metadata;
import java.util.Objects;
public record AssetStudioMetadataSnapshot(
AssetStudioGlyphSpecialization glyphSpecialization,
AssetStudioSceneBankMetadata sceneBankMetadata) {
public AssetStudioMetadataSnapshot {
glyphSpecialization = Objects.requireNonNullElse(glyphSpecialization, AssetStudioGlyphSpecialization.NONE);
}
}

View File

@ -0,0 +1,27 @@
package p.studio.workspaces.assets.metadata;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
public record AssetStudioSceneBankMetadata(
int mapWidth,
int mapHeight,
int layerCount,
List<AssetStudioSceneLayerBinding> layerBindings,
Path supportFile) {
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");
}
if (layerBindings.size() != layerCount) {
throw new IllegalArgumentException("layerBindings must match layerCount");
}
}
}

View File

@ -0,0 +1,24 @@
package p.studio.workspaces.assets.metadata;
import java.util.Objects;
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");
}
}
}

View File

@ -0,0 +1,20 @@
package p.studio.workspaces.assets.scene;
import p.packer.dtos.PackerDiagnosticDTO;
import p.studio.workspaces.assets.messages.AssetWorkspaceSceneBankValidation;
import java.util.List;
import java.util.Objects;
public record SceneBankWorkflowResult(
boolean success,
String message,
AssetWorkspaceSceneBankValidation validation,
List<PackerDiagnosticDTO> diagnostics) {
public SceneBankWorkflowResult {
message = Objects.requireNonNullElse(message, "");
validation = Objects.requireNonNullElse(validation, AssetWorkspaceSceneBankValidation.NOT_APPLICABLE);
diagnostics = List.copyOf(Objects.requireNonNullElse(diagnostics, List.of()));
}
}

View File

@ -0,0 +1,279 @@
package p.studio.workspaces.assets.scene;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import p.packer.dtos.PackerDiagnosticDTO;
import p.packer.messages.diagnostics.PackerDiagnosticCategory;
import p.packer.messages.diagnostics.PackerDiagnosticSeverity;
import p.studio.projects.ProjectReference;
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.AssetWorkspaceSceneBankStatus;
import p.studio.workspaces.assets.messages.AssetWorkspaceSceneBankValidation;
import p.studio.workspaces.assets.tiled.TiledMapDocument;
import p.studio.workspaces.assets.tiled.TiledTilesetDocument;
import p.studio.workspaces.assets.tiled.TiledUnsupportedFeatureException;
import p.studio.workspaces.assets.tiled.TiledXmlCodec;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
public final class SceneBankWorkflowService {
private static final String VALIDATION_FILE = "scene-bank.validation.json";
private static final String ACCEPTANCE_FILE = "scene-bank.acceptance.json";
private final ObjectMapper mapper = new ObjectMapper();
private final TiledXmlCodec codec = new TiledXmlCodec();
public SceneBankWorkflowResult inspect(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
if (details == null || details.summary().assetFamily() != p.packer.messages.assets.AssetFamilyCatalog.SCENE_BANK) {
return new SceneBankWorkflowResult(true, "", AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, List.of());
}
final List<PackerDiagnosticDTO> diagnostics = validateDiagnostics(projectReference, details);
final String currentFingerprint = diagnostics.stream().anyMatch(PackerDiagnosticDTO::blocking)
? ""
: computeFingerprint(projectReference, details);
final String validatedFingerprint = readFingerprint(details.summary().assetRoot().resolve(VALIDATION_FILE));
final String acceptedFingerprint = readFingerprint(details.summary().assetRoot().resolve(ACCEPTANCE_FILE));
final AssetWorkspaceSceneBankValidation validation = validationState(
diagnostics,
currentFingerprint,
validatedFingerprint,
acceptedFingerprint);
return new SceneBankWorkflowResult(
diagnostics.stream().noneMatch(PackerDiagnosticDTO::blocking),
messageFor(validation),
validation,
diagnostics);
}
public SceneBankWorkflowResult validate(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
final SceneBankWorkflowResult inspection = inspect(projectReference, details);
final Path validationFile = details.summary().assetRoot().resolve(VALIDATION_FILE);
try {
if (inspection.success() && !inspection.validation().currentFingerprint().isBlank()) {
writeFingerprint(validationFile, inspection.validation().currentFingerprint());
} else {
Files.deleteIfExists(validationFile);
}
} catch (IOException exception) {
return new SceneBankWorkflowResult(
false,
"Unable to persist validation state: " + exception.getMessage(),
inspection.validation(),
inspection.diagnostics());
}
return inspect(projectReference, details);
}
public SceneBankWorkflowResult accept(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
final SceneBankWorkflowResult inspection = inspect(projectReference, details);
if (!inspection.validation().canAccept()) {
return new SceneBankWorkflowResult(
false,
"Scene Bank must validate successfully before acceptance.",
inspection.validation(),
inspection.diagnostics());
}
try {
writeFingerprint(details.summary().assetRoot().resolve(ACCEPTANCE_FILE), inspection.validation().currentFingerprint());
writeFingerprint(details.summary().assetRoot().resolve(VALIDATION_FILE), inspection.validation().currentFingerprint());
} catch (IOException exception) {
return new SceneBankWorkflowResult(
false,
"Unable to persist acceptance state: " + exception.getMessage(),
inspection.validation(),
inspection.diagnostics());
}
return inspect(projectReference, details);
}
private List<PackerDiagnosticDTO> validateDiagnostics(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
final List<PackerDiagnosticDTO> diagnostics = new ArrayList<>();
final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata();
if (metadata == null) {
diagnostics.add(error("Scene Bank support metadata is missing or invalid.", details.summary().assetRoot().resolve("scene-bank.studio.json")));
return diagnostics;
}
for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) {
if (binding.tilesetAssetRoot().isBlank()) {
diagnostics.add(error("Layer " + binding.index() + " is missing tileset_asset_root.", metadata.supportFile()));
continue;
}
final Path tmxPath = details.summary().assetRoot().resolve(binding.tilemap()).toAbsolutePath().normalize();
final Path tsxPath = projectReference.rootPath()
.resolve("assets")
.resolve(binding.tilesetAssetRoot())
.resolve("tileset.tsx")
.toAbsolutePath()
.normalize();
if (!Files.isRegularFile(tmxPath)) {
diagnostics.add(error("TMX file is missing for layer " + binding.index() + ": " + binding.tilemap(), tmxPath));
continue;
}
if (!Files.isRegularFile(tsxPath)) {
diagnostics.add(error("Referenced TSX file is missing for layer " + binding.index() + ": " + binding.tilesetAssetRoot(), tsxPath));
continue;
}
try {
final TiledTilesetDocument tileset = codec.readTileset(tsxPath);
final TiledMapDocument map = codec.readMap(tmxPath);
final String expectedSource = details.summary().assetRoot()
.toAbsolutePath()
.normalize()
.relativize(tsxPath)
.toString()
.replace('\\', '/');
if (map.tilesets().size() != 1) {
diagnostics.add(error("TMX must reference exactly one TSX in wave 1: " + binding.tilemap(), tmxPath));
} else if (!expectedSource.equals(map.tilesets().getFirst().source())) {
diagnostics.add(error("TMX tileset reference does not match support metadata: " + binding.tilemap(), tmxPath));
}
if (map.width() != metadata.mapWidth() || map.height() != metadata.mapHeight()) {
diagnostics.add(error("TMX dimensions do not match scene-bank support metadata: " + binding.tilemap(), tmxPath));
}
if (map.tileWidth() != tileset.tileWidth() || map.tileHeight() != tileset.tileHeight()) {
diagnostics.add(error("TMX tile size does not match referenced TSX: " + binding.tilemap(), tmxPath));
}
if (map.tileLayers().size() != 1 || !binding.layerName().equals(map.tileLayers().getFirst().name())) {
diagnostics.add(error("TMX layer mapping does not match scene-bank support metadata: " + binding.tilemap(), tmxPath));
}
} catch (TiledUnsupportedFeatureException exception) {
diagnostics.add(error(exception.getMessage(), tmxPath));
} catch (IOException exception) {
diagnostics.add(error("Unable to validate Scene Bank XML: " + exception.getMessage(), tmxPath));
}
}
return List.copyOf(diagnostics);
}
private String computeFingerprint(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata();
if (metadata == null) {
return "";
}
try {
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
final List<Path> files = new ArrayList<>();
files.add(metadata.supportFile());
for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) {
files.add(details.summary().assetRoot().resolve(binding.tilemap()).toAbsolutePath().normalize());
if (!binding.tilesetAssetRoot().isBlank()) {
files.add(projectReference.rootPath().resolve("assets").resolve(binding.tilesetAssetRoot()).resolve("tileset.tsx").toAbsolutePath().normalize());
}
}
files.stream()
.distinct()
.sorted(Comparator.comparing(Path::toString))
.forEach(path -> updateDigest(digest, path));
return HexFormat.of().formatHex(digest.digest());
} catch (Exception exception) {
return "";
}
}
private void updateDigest(MessageDigest digest, Path path) {
try {
if (!Files.isRegularFile(path)) {
return;
}
digest.update(path.toString().getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0);
digest.update(Files.readAllBytes(path));
digest.update((byte) 0);
} catch (IOException ignored) {
}
}
private AssetWorkspaceSceneBankValidation validationState(
List<PackerDiagnosticDTO> diagnostics,
String currentFingerprint,
String validatedFingerprint,
String acceptedFingerprint) {
if (diagnostics.stream().anyMatch(PackerDiagnosticDTO::blocking)) {
return new AssetWorkspaceSceneBankValidation(
AssetWorkspaceSceneBankStatus.VALIDATION_FAILED,
true,
false,
currentFingerprint,
validatedFingerprint,
acceptedFingerprint);
}
final boolean validated = !currentFingerprint.isBlank() && currentFingerprint.equals(validatedFingerprint);
final boolean accepted = validated && currentFingerprint.equals(acceptedFingerprint);
if (accepted) {
return new AssetWorkspaceSceneBankValidation(
AssetWorkspaceSceneBankStatus.READY,
false,
false,
currentFingerprint,
validatedFingerprint,
acceptedFingerprint);
}
if (validated) {
return new AssetWorkspaceSceneBankValidation(
AssetWorkspaceSceneBankStatus.VALIDATED_PENDING_ACCEPTANCE,
true,
true,
currentFingerprint,
validatedFingerprint,
acceptedFingerprint);
}
return new AssetWorkspaceSceneBankValidation(
AssetWorkspaceSceneBankStatus.PENDING_VALIDATION,
true,
false,
currentFingerprint,
validatedFingerprint,
acceptedFingerprint);
}
private String messageFor(AssetWorkspaceSceneBankValidation validation) {
return switch (validation.status()) {
case NOT_APPLICABLE -> "";
case PENDING_VALIDATION -> "Scene Bank has pending external changes and requires validation.";
case VALIDATED_PENDING_ACCEPTANCE -> "Scene Bank validation succeeded and is waiting for explicit acceptance.";
case READY -> "Scene Bank is ready.";
case VALIDATION_FAILED -> "Scene Bank validation failed.";
};
}
private String readFingerprint(Path path) {
if (!Files.isRegularFile(path)) {
return "";
}
try {
final JsonNode root = mapper.readTree(path.toFile());
return root.path("fingerprint").asText("").trim();
} catch (IOException ignored) {
return "";
}
}
private void writeFingerprint(Path path, String fingerprint) throws IOException {
final var root = mapper.createObjectNode();
root.put("schema_version", 1);
root.put("fingerprint", Objects.requireNonNullElse(fingerprint, ""));
root.put("recorded_at", Instant.now().toString());
mapper.writerWithDefaultPrettyPrinter().writeValue(path.toFile(), root);
}
private PackerDiagnosticDTO error(String message, Path path) {
return new PackerDiagnosticDTO(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
message,
path,
true);
}
}

View File

@ -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<Path> writtenFiles) {
public TiledAssetGenerationResult {
message = Objects.requireNonNullElse(message, "");
writtenFiles = List.copyOf(Objects.requireNonNullElse(writtenFiles, List.of()));
}
}

View File

@ -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<AssetWorkspaceBankCompositionFile> 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<TiledTilesetTile> 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<Path> 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<Long> 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<PackerCodecConfigurationFieldDTO> 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;
}
}

View File

@ -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<TiledProperty> properties,
List<TiledTilesetReference> tilesets,
List<TiledTileLayer> tileLayers,
List<TiledObjectLayer> 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"));
}
}

View File

@ -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<TiledPoint> polygonPoints,
List<TiledProperty> properties) {
public TiledObjectData {
name = Objects.requireNonNullElse(name, "").trim();
polygonPoints = List.copyOf(Objects.requireNonNull(polygonPoints, "polygonPoints"));
properties = List.copyOf(Objects.requireNonNull(properties, "properties"));
}
}

View File

@ -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<TiledObjectData> objects,
List<TiledProperty> properties) {
public TiledObjectLayer {
name = Objects.requireNonNullElse(name, "").trim();
objects = List.copyOf(Objects.requireNonNull(objects, "objects"));
properties = List.copyOf(Objects.requireNonNull(properties, "properties"));
}
}

View File

@ -0,0 +1,4 @@
package p.studio.workspaces.assets.tiled;
public record TiledPoint(double x, double y) {
}

View File

@ -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";
}
}
}

View File

@ -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<Long> gids,
List<TiledProperty> properties) {
public TiledTileLayer {
name = Objects.requireNonNullElse(name, "").trim();
gids = List.copyOf(Objects.requireNonNull(gids, "gids"));
properties = List.copyOf(Objects.requireNonNull(properties, "properties"));
}
}

View File

@ -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<TiledProperty> properties,
List<TiledTilesetTile> 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");
}
}
}

View File

@ -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");
}
}
}

View File

@ -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<TiledProperty> 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");
}
}
}

View File

@ -0,0 +1,7 @@
package p.studio.workspaces.assets.tiled;
public final class TiledUnsupportedFeatureException extends RuntimeException {
public TiledUnsupportedFeatureException(String message) {
super(message);
}
}

View File

@ -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 <map>.");
}
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 <tileset>.");
}
rejectTilesetUnsupportedChildren(root);
final List<TiledTilesetTile> 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 <image> 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<TiledTilesetReference> readTilesets(Element root) {
final List<TiledTilesetReference> 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<TiledTileLayer> readTileLayers(Element root) {
final List<TiledTileLayer> 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<TiledObjectLayer> readObjectLayers(Element root) {
final List<TiledObjectLayer> 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<TiledObjectData> 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<TiledProperty> 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<TiledProperty> readProperties(Element propertiesElement) {
if (propertiesElement == null) {
return List.of();
}
final List<TiledProperty> 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<Long> parseCsvData(String text) {
final List<Long> 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<TiledPoint> parsePolygon(String points) {
final List<TiledPoint> 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<Long> 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<TiledPoint> 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<Element> children(Element parent, String name) {
final List<Element> elements = new ArrayList<>();
for (Element child : childElements(parent)) {
if (name.equals(child.getTagName())) {
elements.add(child);
}
}
return List.copyOf(elements);
}
private List<Element> childElements(Element parent) {
final NodeList children = parent.getChildNodes();
final List<Element> 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);
}
}

View File

@ -24,6 +24,8 @@ import p.studio.Container;
import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.assets.details.AssetDetailsUiSupport;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization;
import p.studio.workspaces.assets.metadata.AssetStudioMetadataService;
import java.io.File;
import java.io.IOException;
@ -47,9 +49,11 @@ public final class AddAssetWizard {
private final TextField assetRootField = new TextField();
private final TextField assetNameField = new TextField();
private final ComboBox<AssetFamilyCatalog> assetFamilyCombo = new ComboBox<>();
private final ComboBox<AssetStudioGlyphSpecialization> glyphSpecializationCombo = new ComboBox<>();
private final ComboBox<OutputFormatCatalog> outputFormatCombo = new ComboBox<>();
private final ComboBox<OutputCodecCatalog> outputCodecCombo = new ComboBox<>();
private final CheckBox preloadCheckBox = new CheckBox();
private final AssetStudioMetadataService studioMetadataService = new AssetStudioMetadataService();
private int stepIndex;
private boolean creating;
@ -65,6 +69,7 @@ public final class AddAssetWizard {
preloadCheckBox.setSelected(false);
configureAssetFamilyCombo();
configureGlyphSpecializationCombo();
configureOutputFormatCombo();
configureOutputCodecCombo();
renderStep();
@ -131,7 +136,34 @@ public final class AddAssetWizard {
});
assetFamilyCombo.valueProperty().addListener((ignored, oldValue, newValue) -> {
if (!Objects.equals(oldValue, newValue)) {
if (newValue != AssetFamilyCatalog.GLYPH_BANK) {
glyphSpecializationCombo.getSelectionModel().select(AssetStudioGlyphSpecialization.NONE);
}
refreshOutputFormats();
if (stepIndex == 1) {
renderStep();
}
}
});
}
private void configureGlyphSpecializationCombo() {
glyphSpecializationCombo.setItems(FXCollections.observableArrayList(AssetStudioGlyphSpecialization.values()));
glyphSpecializationCombo.setMaxWidth(Double.MAX_VALUE);
glyphSpecializationCombo.setPromptText(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_PROMPT_SPECIALIZATION));
glyphSpecializationCombo.getSelectionModel().select(AssetStudioGlyphSpecialization.NONE);
glyphSpecializationCombo.setCellFactory(ignored -> new javafx.scene.control.ListCell<>() {
@Override
protected void updateItem(AssetStudioGlyphSpecialization item, boolean empty) {
super.updateItem(item, empty);
setText(empty || item == null ? null : AssetDetailsUiSupport.specializationLabel(item));
}
});
glyphSpecializationCombo.setButtonCell(new javafx.scene.control.ListCell<>() {
@Override
protected void updateItem(AssetStudioGlyphSpecialization item, boolean empty) {
super.updateItem(item, empty);
setText(empty || item == null ? null : AssetDetailsUiSupport.specializationLabel(item));
}
});
}
@ -224,17 +256,23 @@ public final class AddAssetWizard {
final Label nameLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_NAME));
final Label typeLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_TYPE));
final Label specializationLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_SPECIALIZATION));
final Label formatLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_FORMAT));
final Label codecLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_CODEC));
final Label preloadLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_LABEL_PRELOAD));
final Label noteLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_NOTE));
noteLabel.setWrapText(true);
noteLabel.getStyleClass().add("studio-launcher-subtitle");
final VBox specializationBox = new VBox(6, specializationLabel, glyphSpecializationCombo);
final boolean specializationVisible = selectedFamily() == AssetFamilyCatalog.GLYPH_BANK;
specializationBox.setVisible(specializationVisible);
specializationBox.setManaged(specializationVisible);
preloadCheckBox.setText("");
stepBody.getChildren().setAll(
new VBox(6, nameLabel, assetNameField),
new VBox(6, typeLabel, assetFamilyCombo),
specializationBox,
new VBox(6, formatLabel, outputFormatCombo),
new VBox(6, codecLabel, outputCodecCombo),
new VBox(6, preloadLabel, preloadCheckBox),
@ -360,6 +398,15 @@ public final class AddAssetWizard {
private void applyCreateResult(CreateAssetResult createResult) {
creating = false;
if (createResult.status() == PackerOperationStatus.SUCCESS && createResult.assetReference() != null) {
try {
persistStudioMetadata(createResult.assetRoot());
} catch (IOException exception) {
feedbackLabel.setText(exception.getMessage() == null || exception.getMessage().isBlank()
? "Unable to persist Studio metadata."
: exception.getMessage());
renderStep();
return;
}
result.set(createResult.assetReference());
stage.close();
return;
@ -409,6 +456,17 @@ public final class AddAssetWizard {
return outputCodecCombo.getValue();
}
private AssetStudioGlyphSpecialization selectedGlyphSpecialization() {
return glyphSpecializationCombo.getValue();
}
private void persistStudioMetadata(Path assetRoot) throws IOException {
if (selectedFamily() != AssetFamilyCatalog.GLYPH_BANK) {
return;
}
studioMetadataService.writeGlyphSpecialization(assetRoot, selectedGlyphSpecialization());
}
private String normalizedRelativeRoot(String candidate) {
final String raw = Objects.requireNonNullElse(candidate, "").trim().replace('\\', '/');
if (raw.isBlank()) {

View File

@ -144,6 +144,10 @@ 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.validateSceneBank=Validate Scene Bank
assets.action.acceptSceneBank=Accept Scene Bank
assets.action.delete=Delete
assets.deleteDialog.title=Delete Asset
assets.deleteDialog.description=Type the asset name below to confirm deletion of {0}.
@ -181,10 +185,24 @@ assets.label.registration=Registration
assets.label.buildParticipation=Build Participation
assets.label.assetId=Asset ID
assets.label.type=Type
assets.label.studioRole=Studio Role
assets.label.sceneStatus=Scene Status
assets.label.sceneLayers=Scene Layers
assets.label.tilemaps=Tilemaps
assets.label.supportFile=Support File
assets.type.glyphBank=Glyph Bank
assets.type.sceneBank=Scene Bank
assets.type.paletteBank=Palette Bank
assets.type.soundBank=Sound Bank
assets.type.unknown=Unknown
assets.specialization.none=None
assets.specialization.tileset=Tileset
assets.specialization.sprites=Sprites
assets.specialization.ui=UI
assets.sceneStatus.pendingValidation=Pending Validation
assets.sceneStatus.validatedPendingAcceptance=Validated / Pending Acceptance
assets.sceneStatus.ready=Ready
assets.sceneStatus.validationFailed=Validation Failed
assets.label.location=Location
assets.label.bank=Bank
assets.label.targetLocation=Target Location
@ -245,10 +263,12 @@ assets.addWizard.step.summary.description=Confirm the registered asset you are a
assets.addWizard.label.name=Asset Name
assets.addWizard.label.root=Asset Root
assets.addWizard.label.type=Asset Type
assets.addWizard.label.specialization=Studio Specialization
assets.addWizard.label.format=Output Format
assets.addWizard.label.codec=Output Codec
assets.addWizard.label.preload=Preload on startup
assets.addWizard.prompt.type=Choose asset type
assets.addWizard.prompt.specialization=Choose Studio specialization
assets.addWizard.prompt.format=Choose output format
assets.addWizard.prompt.codec=Choose output codec
assets.addWizard.assetsRootHint=Assets root: {0}

View File

@ -7,6 +7,7 @@ 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.workspaces.assets.metadata.AssetStudioGlyphSpecialization;
import p.studio.workspaces.assets.messages.*;
import java.nio.file.Path;
@ -114,6 +115,8 @@ final class AssetDetailsBankCompositionCoordinatorTest {
List.of(),
0L),
List.of(),
null,
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
}
@ -136,6 +139,8 @@ final class AssetDetailsBankCompositionCoordinatorTest {
List.of(),
0L),
List.of(),
null,
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
}
@ -159,6 +164,7 @@ final class AssetDetailsBankCompositionCoordinatorTest {
AssetWorkspaceBuildParticipation.INCLUDED,
1,
family,
AssetStudioGlyphSpecialization.NONE,
Path.of("/tmp/bank"),
false,
false);

View File

@ -5,6 +5,7 @@ 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.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization;
import p.studio.workspaces.assets.messages.*;
import java.nio.file.Path;
@ -81,6 +82,7 @@ final class AssetDetailsPaletteOverhaulingCoordinatorTest {
AssetWorkspaceBuildParticipation.INCLUDED,
1,
AssetFamilyCatalog.GLYPH_BANK,
AssetStudioGlyphSpecialization.NONE,
Path.of("/tmp/bank"),
false,
false),
@ -93,6 +95,8 @@ final class AssetDetailsPaletteOverhaulingCoordinatorTest {
Map.of(),
new AssetWorkspaceBankCompositionDetails(availableFiles, selectedFiles, 0L),
selectedFiles.stream().map(file -> (Map<String, Object>) file.metadata().get("palette")).toList(),
null,
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
}

View File

@ -0,0 +1,82 @@
package p.studio.workspaces.assets.metadata;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.packer.messages.assets.AssetFamilyCatalog;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
final class AssetStudioMetadataServiceTest {
@TempDir
Path tempDir;
private final AssetStudioMetadataService service = new AssetStudioMetadataService();
@Test
void readsGlyphSpecializationFromStudioMetadataFile() throws Exception {
final Path assetRoot = tempDir.resolve("tileset");
Files.createDirectories(assetRoot);
service.writeGlyphSpecialization(assetRoot, AssetStudioGlyphSpecialization.TILESET);
final AssetStudioMetadataSnapshot snapshot = service.read(assetRoot, AssetFamilyCatalog.GLYPH_BANK);
assertEquals(AssetStudioGlyphSpecialization.TILESET, snapshot.glyphSpecialization());
assertNull(snapshot.sceneBankMetadata());
}
@Test
void readsSceneBankSupportMetadataWhenContractIsValid() throws Exception {
final Path assetRoot = tempDir.resolve("scene");
Files.createDirectories(assetRoot);
Files.writeString(assetRoot.resolve(AssetStudioMetadataService.SCENE_BANK_SUPPORT_FILE), """
{
"schema_version": 1,
"map_width": 20,
"map_height": 12,
"layer_count": 2,
"layers": [
{ "index": 1, "name": "Ground", "tilemap": "ground.tmx", "tileset_asset_root": "tilesets/ground" },
{ "index": 2, "name": "Collision", "tilemap": "collision.tmx", "tileset_asset_root": "tilesets/ground" }
]
}
""");
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"),
snapshot.sceneBankMetadata().layerBindings().stream().map(AssetStudioSceneLayerBinding::tilemap).toList());
}
@Test
void rejectsSceneBankSupportMetadataBeyondWaveOneLayerLimit() throws Exception {
final Path assetRoot = tempDir.resolve("scene-invalid");
Files.createDirectories(assetRoot);
Files.writeString(assetRoot.resolve(AssetStudioMetadataService.SCENE_BANK_SUPPORT_FILE), """
{
"schema_version": 1,
"map_width": 16,
"map_height": 16,
"layer_count": 5,
"layers": [
{ "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": "" }
]
}
""");
final AssetStudioMetadataSnapshot snapshot = service.read(assetRoot, AssetFamilyCatalog.SCENE_BANK);
assertNull(snapshot.sceneBankMetadata());
}
}

View File

@ -0,0 +1,193 @@
package p.studio.workspaces.assets.scene;
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 p.studio.workspaces.assets.tiled.TiledAssetGenerationService;
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 SceneBankWorkflowServiceTest {
private final TiledAssetGenerationService generationService = new TiledAssetGenerationService();
private final SceneBankWorkflowService workflowService = new SceneBankWorkflowService();
@TempDir
Path tempDir;
@Test
void validateThenAcceptPromotesSceneBankToReadyUntilFilesChange() throws Exception {
final ProjectReference project = projectReference();
final AssetWorkspaceAssetDetails sceneDetails = createValidScene(project);
final SceneBankWorkflowResult initial = workflowService.inspect(project, sceneDetails);
assertEquals(AssetWorkspaceSceneBankStatus.PENDING_VALIDATION, initial.validation().status());
assertFalse(initial.validation().canAccept());
final SceneBankWorkflowResult validated = workflowService.validate(project, sceneDetails);
assertEquals(AssetWorkspaceSceneBankStatus.VALIDATED_PENDING_ACCEPTANCE, validated.validation().status());
assertTrue(validated.validation().canAccept());
final SceneBankWorkflowResult accepted = workflowService.accept(project, sceneDetails);
assertEquals(AssetWorkspaceSceneBankStatus.READY, accepted.validation().status());
assertFalse(accepted.validation().pendingExternalChanges());
final Path tmxPath = sceneDetails.summary().assetRoot().resolve("ground.tmx");
Files.writeString(tmxPath, Files.readString(tmxPath).replaceFirst("0,0,0", "1,0,0"));
final SceneBankWorkflowResult changed = workflowService.inspect(project, sceneDetails);
assertEquals(AssetWorkspaceSceneBankStatus.PENDING_VALIDATION, changed.validation().status());
assertTrue(changed.validation().pendingExternalChanges());
}
@Test
void validationFailsWhenReferencedTsxIsMissing() throws Exception {
final ProjectReference project = projectReference();
final Path sceneRoot = project.rootPath().resolve("assets/scenes/broken");
Files.createDirectories(sceneRoot);
final AssetWorkspaceAssetDetails brokenScene = new AssetWorkspaceAssetDetails(
new AssetWorkspaceAssetSummary(
AssetReference.forAssetId(2),
"broken_scene",
AssetWorkspaceAssetState.REGISTERED,
AssetWorkspaceBuildParticipation.EXCLUDED,
2,
AssetFamilyCatalog.SCENE_BANK,
AssetStudioGlyphSpecialization.NONE,
sceneRoot,
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(
8,
8,
1,
List.of(new AssetStudioSceneLayerBinding(1, "Ground", "ground.tmx", "tilesets/missing")),
sceneRoot.resolve("scene-bank.studio.json")),
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
Files.writeString(sceneRoot.resolve("ground.tmx"), """
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.1" orientation="orthogonal" renderorder="right-down"
width="8" height="8" tilewidth="16" tileheight="16" infinite="0" nextlayerid="2" nextobjectid="1">
<tileset firstgid="1" source="../../tilesets/missing/tileset.tsx"/>
<layer id="1" name="Ground" width="8" height="8">
<data encoding="csv">0</data>
</layer>
</map>
""");
final SceneBankWorkflowResult result = workflowService.inspect(project, brokenScene);
assertEquals(AssetWorkspaceSceneBankStatus.VALIDATION_FAILED, result.validation().status());
assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("Referenced TSX file is missing")));
}
private ProjectReference projectReference() {
return new ProjectReference("main", "1", "pbs", 1, tempDir.resolve("project"));
}
private AssetWorkspaceAssetDetails createValidScene(ProjectReference project) throws Exception {
final Path tilesetRoot = project.rootPath().resolve("assets/tilesets/overworld");
Files.createDirectories(tilesetRoot);
Files.writeString(tilesetRoot.resolve("a.png"), "fixture");
assertTrue(generationService.generateTilesetTsx(tilesetDetails(tilesetRoot)).success());
final Path sceneRoot = project.rootPath().resolve("assets/scenes/overworld");
Files.createDirectories(sceneRoot);
final AssetWorkspaceAssetDetails scene = sceneDetails(sceneRoot);
assertTrue(generationService.generateSceneBankTilemaps(project, scene).success());
return scene;
}
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())),
0L),
List.of(),
null,
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
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")),
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
}
}

View File

@ -0,0 +1,143 @@
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,
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
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")),
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
}
}

View File

@ -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, """
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.1" orientation="orthogonal" renderorder="right-down"
width="10" height="10" tilewidth="16" tileheight="16" infinite="1"/>
""");
assertThrows(TiledUnsupportedFeatureException.class, () -> codec.readMap(path));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"schema_version" : 1,
"next_asset_id" : 16,
"next_asset_id" : 17,
"assets" : [ {
"asset_id" : 3,
"asset_uuid" : "21953cb8-4101-4790-9e5e-d95f5fbc9b5a",
@ -41,5 +41,10 @@
"asset_uuid" : "87396aab-337e-479e-b1f4-ec296678389e",
"root" : "Zelda3",
"included_in_build" : true
}, {
"asset_id" : 16,
"asset_uuid" : "6f05bee1-f974-4b65-a43f-eacd46b8ec96",
"root" : "tiled",
"included_in_build" : true
} ]
}

View File

@ -0,0 +1,14 @@
{
"schema_version" : 1,
"asset_uuid" : "6f05bee1-f974-4b65-a43f-eacd46b8ec96",
"name" : "Tiled Test",
"type" : "scene_bank",
"output" : {
"pipeline" : { },
"codec" : "NONE",
"format" : "SCENE/tiled_v1"
},
"preload" : {
"enabled" : false
}
}

View File

@ -0,0 +1,12 @@
{
"schema_version" : 1,
"map_width" : 16,
"map_height" : 16,
"layer_count" : 1,
"layers" : [ {
"index" : 1,
"tilemap" : "layer-1.tmx",
"tileset_asset_root" : "",
"name" : "Layer 1"
} ]
}