dev/studio-tiled-parser-assets-scene-asset-type #4

Open
bquarkz wants to merge 6 commits from dev/studio-tiled-parser-assets-scene-asset-type into master
61 changed files with 3425 additions and 428 deletions

View File

@ -1,6 +1,6 @@
{"type":"meta","next_id":{"DSC":30,"AGD":32,"DEC":28,"PLN":56,"LSN":42,"CLSN":1}} {"type":"meta","next_id":{"DSC":30,"AGD":32,"DEC":28,"PLN":56,"LSN":42,"CLSN":1}}
{"type":"discussion","id":"DSC-0029","status":"done","ticket":"studio-frame-composer-syscall-and-sprite-alignment","title":"Studio Alignment with Runtime FrameComposer Syscalls and Sprite Composition","created_at":"2026-04-18","updated_at":"2026-04-18","tags":["studio","compiler","pbs","stdlib","runtime-alignment","abi","syscall","frame-composer","sprites"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0041","file":"discussion/lessons/DSC-0029-studio-frame-composer-syscall-and-sprite-alignment/LSN-0041-composer-must-own-public-sprite-composition.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]} {"type":"discussion","id":"DSC-0029","status":"done","ticket":"studio-frame-composer-syscall-and-sprite-alignment","title":"Studio Alignment with Runtime FrameComposer Syscalls and Sprite Composition","created_at":"2026-04-18","updated_at":"2026-04-18","tags":["studio","compiler","pbs","stdlib","runtime-alignment","abi","syscall","frame-composer","sprites"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0041","file":"discussion/lessons/DSC-0029-studio-frame-composer-syscall-and-sprite-alignment/LSN-0041-composer-must-own-public-sprite-composition.md","status":"done","created_at":"2026-04-18","updated_at":"2026-04-18"}]}
{"type":"discussion","id":"DSC-0028","status":"open","ticket":"studio-tiled-parser-assets-scene-asset-type","title":"Tiled Parser and Scene Asset-Type Ownership in Assets Workspace","created_at":"2026-04-15","updated_at":"2026-04-15","tags":["studio","assets","scene","tiled","parser","asset-type"],"agendas":[{"id":"AGD-0030","file":"AGD-0030-tiled-parser-and-scene-asset-type.md","status":"open","created_at":"2026-04-15","updated_at":"2026-04-15"}],"decisions":[],"plans":[],"lessons":[]} {"type":"discussion","id":"DSC-0028","status":"open","ticket":"studio-tiled-parser-assets-scene-asset-type","title":"Tiled Parser and Scene Asset-Type Ownership in Assets Workspace","created_at":"2026-04-15","updated_at":"2026-04-17","tags":["studio","assets","scene","tiled","parser","asset-type"],"agendas":[{"id":"AGD-0030","file":"AGD-0030-tiled-parser-and-scene-asset-type.md","status":"accepted","created_at":"2026-04-15","updated_at":"2026-04-17"}],"decisions":[{"id":"DEC-0027","file":"DEC-0027-tiled-parser-and-scene-bank-asset-ownership.md","status":"accepted","created_at":"2026-04-17","updated_at":"2026-04-17","ref_agenda":"AGD-0030"}],"plans":[{"id":"PLN-0053","file":"PLN-0053-scene-bank-asset-contract-and-studio-metadata-foundations.md","status":"open","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0027"]},{"id":"PLN-0054","file":"PLN-0054-tiled-xml-io-and-scene-bank-file-orchestration.md","status":"open","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0027"]},{"id":"PLN-0055","file":"PLN-0055-assets-workspace-scene-bank-validation-and-acceptance-flow.md","status":"open","created_at":"2026-04-17","updated_at":"2026-04-17","ref_decisions":["DEC-0027"]}],"lessons":[]}
{"type":"discussion","id":"DSC-0027","status":"abandoned","ticket":"studio-scene-workspace","title":"Scene Workspace for SCENE Authoring","created_at":"2026-04-14","updated_at":"2026-04-15","tags":["studio","workspace","scene","tilemap","asset","runtime-alignment"],"agendas":[{"id":"AGD-0029","file":"AGD-0029-studio-scene-workspace.md","status":"abandoned","created_at":"2026-04-14","updated_at":"2026-04-15","_override_reason":"Explicit user request on 2026-04-15 to abandon the accepted agenda and its downstream work."}],"decisions":[{"id":"DEC-0026","file":"DEC-0026-studio-scene-workspace.md","status":"abandoned","created_at":"2026-04-14","updated_at":"2026-04-15","ref_agenda":"AGD-0029","_override_reason":"Explicit user request on 2026-04-15 to abandon the accepted decision and stop using it as normative guidance."}],"plans":[{"id":"PLN-0049","file":"PLN-0049-scene-workspace-spec-and-boundary-propagation.md","status":"abandoned","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0026"],"_override_reason":"Explicit user request on 2026-04-15 to abandon all plans derived from DEC-0026."},{"id":"PLN-0050","file":"PLN-0050-scene-workspace-shell-and-project-state-foundations.md","status":"abandoned","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0026"],"_override_reason":"Explicit user request on 2026-04-15 to abandon all plans derived from DEC-0026."},{"id":"PLN-0051","file":"PLN-0051-scene-artifact-and-assets-handoff-contract.md","status":"abandoned","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0026"],"_override_reason":"Explicit user request on 2026-04-15 to abandon all plans derived from DEC-0026."},{"id":"PLN-0052","file":"PLN-0052-scene-workspace-wave-1-tilemap-editor.md","status":"abandoned","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0026"],"_override_reason":"Explicit user request on 2026-04-15 to abandon all plans derived from DEC-0026."}],"lessons":[]} {"type":"discussion","id":"DSC-0027","status":"abandoned","ticket":"studio-scene-workspace","title":"Scene Workspace for SCENE Authoring","created_at":"2026-04-14","updated_at":"2026-04-15","tags":["studio","workspace","scene","tilemap","asset","runtime-alignment"],"agendas":[{"id":"AGD-0029","file":"AGD-0029-studio-scene-workspace.md","status":"abandoned","created_at":"2026-04-14","updated_at":"2026-04-15","_override_reason":"Explicit user request on 2026-04-15 to abandon the accepted agenda and its downstream work."}],"decisions":[{"id":"DEC-0026","file":"DEC-0026-studio-scene-workspace.md","status":"abandoned","created_at":"2026-04-14","updated_at":"2026-04-15","ref_agenda":"AGD-0029","_override_reason":"Explicit user request on 2026-04-15 to abandon the accepted decision and stop using it as normative guidance."}],"plans":[{"id":"PLN-0049","file":"PLN-0049-scene-workspace-spec-and-boundary-propagation.md","status":"abandoned","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0026"],"_override_reason":"Explicit user request on 2026-04-15 to abandon all plans derived from DEC-0026."},{"id":"PLN-0050","file":"PLN-0050-scene-workspace-shell-and-project-state-foundations.md","status":"abandoned","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0026"],"_override_reason":"Explicit user request on 2026-04-15 to abandon all plans derived from DEC-0026."},{"id":"PLN-0051","file":"PLN-0051-scene-artifact-and-assets-handoff-contract.md","status":"abandoned","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0026"],"_override_reason":"Explicit user request on 2026-04-15 to abandon all plans derived from DEC-0026."},{"id":"PLN-0052","file":"PLN-0052-scene-workspace-wave-1-tilemap-editor.md","status":"abandoned","created_at":"2026-04-14","updated_at":"2026-04-15","ref_decisions":["DEC-0026"],"_override_reason":"Explicit user request on 2026-04-15 to abandon all plans derived from DEC-0026."}],"lessons":[]}
{"type":"discussion","id":"DSC-0026","status":"done","ticket":"glyph-bank-naming-alignment-with-runtime","title":"Glyph Bank Naming Alignment with Runtime DEC-0006","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["packer","studio","naming","asset-contract","runtime-alignment","glyph-bank"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0040","file":"discussion/lessons/DSC-0026-glyph-bank-naming-alignment-with-runtime/LSN-0040-glyph-bank-artifact-naming-alignment.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]} {"type":"discussion","id":"DSC-0026","status":"done","ticket":"glyph-bank-naming-alignment-with-runtime","title":"Glyph Bank Naming Alignment with Runtime DEC-0006","created_at":"2026-04-10","updated_at":"2026-04-10","tags":["packer","studio","naming","asset-contract","runtime-alignment","glyph-bank"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0040","file":"discussion/lessons/DSC-0026-glyph-bank-naming-alignment-with-runtime/LSN-0040-glyph-bank-artifact-naming-alignment.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}
{"type":"discussion","id":"DSC-0025","status":"done","ticket":"packer-pipeline-metadata-ownership","title":"Pipeline Metadata Ownership and Runtime Contract","created_at":"2026-04-09","updated_at":"2026-04-10","tags":["packer","metadata","runtime-contract","tooling","studio"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0039","file":"discussion/lessons/DSC-0025-packer-pipeline-metadata-ownership/LSN-0039-runtime-header-boundary-and-tooling-owned-pipeline-metadata.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]} {"type":"discussion","id":"DSC-0025","status":"done","ticket":"packer-pipeline-metadata-ownership","title":"Pipeline Metadata Ownership and Runtime Contract","created_at":"2026-04-09","updated_at":"2026-04-10","tags":["packer","metadata","runtime-contract","tooling","studio"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0039","file":"discussion/lessons/DSC-0025-packer-pipeline-metadata-ownership/LSN-0039-runtime-header-boundary-and-tooling-owned-pipeline-metadata.md","status":"done","created_at":"2026-04-10","updated_at":"2026-04-10"}]}

View File

@ -2,10 +2,10 @@
id: AGD-0030 id: AGD-0030
ticket: studio-tiled-parser-assets-scene-asset-type ticket: studio-tiled-parser-assets-scene-asset-type
title: Tiled Parser and Scene Asset-Type Ownership in Assets Workspace title: Tiled Parser and Scene Asset-Type Ownership in Assets Workspace
status: open status: accepted
created: 2026-04-15 created: 2026-04-15
resolved: resolved: 2026-04-17
decision: decision: DEC-0027
tags: tags:
- studio - studio
- assets - assets
@ -41,6 +41,7 @@ Cross-domain impact:
- asset typing and creation flows in the `Assets` workspace; - asset typing and creation flows in the `Assets` workspace;
- Studio-side parsing/import rules for `Tiled`; - Studio-side parsing/import rules for `Tiled`;
- possible downstream alignment with runtime-facing `SCENE` publication, depending on the chosen import boundary. - possible downstream alignment with runtime-facing `SCENE` publication, depending on the chosen import boundary.
- Studio-only specialization of glyph-bank assets into map, sprite, and UI authoring roles.
User direction explicitly provided on 2026-04-15: User direction explicitly provided on 2026-04-15:
@ -49,24 +50,39 @@ User direction explicitly provided on 2026-04-15:
- instead, write a parser for `Tiled`; - instead, write a parser for `Tiled`;
- create a new asset type managed from the `Assets` workspace. - create a new asset type managed from the `Assets` workspace.
Additional direction explicitly provided on 2026-04-15:
- Studio-specific asset roles such as `Tileset`, `Sprites`, and `UI` remain glyph banks internally;
- those roles are editorial specializations in Studio rather than distinct runtime asset families;
- the specialization is stored as a Studio-only flag attached to glyph-bank asset creation and management;
- specialization does not need to lock general glyph-bank editing fields in this wave;
- specialization should instead enable role-specific Studio actions and flows;
- the asset summary should continue to show the base bank type and add the specialization as a complementary chip, such as `Type: Glyph Bank / Tileset`;
- Studio will expose role-specific commands, such as building a `tileset.tsx` from a `Tileset` glyph bank;
- validation of map/tileset consistency should remain centered on the `Scene Bank` rather than on hard-locking the `Glyph Bank`.
- specialization-specific actions should be stacked with the existing asset actions instead of introducing a separate action region between summary and actions.
This agenda exists to turn that direction into a disciplined technical recommendation rather than jumping directly into implementation with undefined ownership and file-contract assumptions. This agenda exists to turn that direction into a disciplined technical recommendation rather than jumping directly into implementation with undefined ownership and file-contract assumptions.
## Open Questions ## Open Questions
- [ ] Which `Tiled` surface is the primary input contract: TMX XML, JSON export, TSX, or a constrained subset of those? - [x] Which `Tiled` surface is the primary input contract: TMX XML, JSON export, TSX, or a constrained subset of those?
- [ ] Is the new asset type a raw imported `Tiled` document, a normalized Studio-owned intermediate asset, or a published runtime-facing `SCENE` derivative? - [x] Is the new scene-facing asset a raw imported `Tiled` document, a normalized Studio-owned intermediate asset, or a published runtime-facing `SCENE` derivative?
- [ ] Should `Assets` own only creation/import and metadata management, or also the main editing/reimport flow for this new asset type? - [x] Should `Assets` own only creation/import and metadata management, or also the main editing/reimport flow for `Tiled`-backed scene assets?
- [ ] How should tileset references inside `Tiled` map onto existing glyph-bank assets and asset identity rules in Studio? - [x] How should `Tiled` tileset references map onto Studio-owned `Tileset` glyph banks and their identity rules?
- [ ] What parts of `Tiled` are intentionally out of scope for the first wave: object layers, properties, flips, collisions, infinite maps, templates, Wang sets, animations? - [x] What parts of `Tiled` are intentionally out of scope for the first wave: object layers, properties, flips, collisions, infinite maps, templates, Wang sets, animations?
- [ ] Does the parser need to preserve full round-trip fidelity with `Tiled`, or only import the supported subset into Studio-owned structures? - [x] Does the parser need to preserve full round-trip fidelity with `Tiled`, or only import the supported subset into Studio-owned structures?
- [ ] What is the canonical failure model when a `Tiled` document references unsupported constructs or broken external assets? - [x] What is the canonical failure model when a `Tiled` document references unsupported constructs or broken external assets?
- [x] How should Studio persist the editorial role flag of a glyph bank (`Tileset`, `Sprites`, `UI`) without inventing a separate runtime family?
- [x] Which defaults become mandatory or locked for each Studio specialization at creation time?
- [x] Where do Studio role-specific commands such as `build tileset.tsx` live in the `Assets` UX?
## Options ## Options
### Option A - Import `Tiled` into a new Studio-owned scene asset type in `Assets` ### Option A - Import `Tiled` into a Studio-owned scene asset model linked to flagged glyph banks
- **Approach:** Introduce a new asset type in `Assets`; parse supported `Tiled` files into a Studio-owned asset model managed from that workspace. - **Approach:** Keep `Glyph Bank` as the canonical asset type, add a Studio-only specialization flag such as `Tileset`, `Sprites`, or `UI`, and parse supported `Tiled` files into a Studio-owned scene asset managed from `Assets`.
- **Pro:** Aligns with the requested ownership shift, keeps authoring/import centered in `Assets`, and allows Studio to define a strict supported subset. - **Pro:** Aligns with the requested ownership shift, preserves the existing glyph-bank runtime model, and lets Studio attach role-specific rules, chips, defaults, and commands without multiplying runtime asset families.
- **Con:** Requires defining a clean normalized asset contract and a mapping layer from `Tiled` concepts to Studio/runtime concepts. - **Con:** Requires a precise Studio metadata model for the specialization flag and a clear mapping from `Tiled` tilesets to flagged glyph-bank assets.
- **Maintainability:** Strong, if the supported subset and ownership boundary are made explicit early. - **Maintainability:** Strong, if the supported subset and ownership boundary are made explicit early.
### Option B - Treat `Tiled` files as external source documents and keep Studio as a thin importer/viewer ### Option B - Treat `Tiled` files as external source documents and keep Studio as a thin importer/viewer
@ -90,26 +106,224 @@ It is also a change in ownership:
- scene-related authoring/import should no longer justify a dedicated `Scene Workspace`; - scene-related authoring/import should no longer justify a dedicated `Scene Workspace`;
- the `Assets` workspace becomes the management surface for the relevant asset type; - the `Assets` workspace becomes the management surface for the relevant asset type;
- any parser choice must therefore fit an asset-first workflow instead of a separate editor-first workflow. - any parser choice must therefore fit an asset-first workflow instead of a separate editor-first workflow.
- asset specialization such as `Tileset`, `Sprites`, and `UI` is a Studio concern layered over the existing glyph-bank runtime family.
That creates several decisions that should be made deliberately: That creates several decisions that should be made deliberately:
1. what exact `Tiled` contract Studio supports in wave 1; 1. what exact `Tiled` contract Studio supports in wave 1;
2. whether Studio stores `Tiled` data directly or through a normalized asset form; 2. whether Studio stores `Tiled` data directly or through a normalized asset form;
3. how glyph banks and tilesets map without creating identity ambiguity; 3. how `Tileset` glyph banks map to `Tiled` tilesets without creating identity ambiguity;
4. how much of `Tiled` fidelity is preserved versus normalized away; 4. how Studio persists the specialization flag and summary-chip presentation without changing the runtime family model;
5. whether editing is a reimport/update flow, a direct managed-asset flow, or some hybrid. 5. which creation defaults become locked by each specialization;
6. how much of `Tiled` fidelity is preserved versus normalized away;
7. whether editing is a reimport/update flow, a direct managed-asset flow, or some hybrid.
There is already one strong constraint from the user: There is already one strong constraint from the user:
- the previous `Scene Workspace` split should be considered discarded; - the previous `Scene Workspace` split should be considered discarded;
- the new work should be organized around `Assets` plus a `Tiled` parser. - the new work should be organized around `Assets` plus a `Tiled` parser.
That makes Option A the current best fit for convergence, but it still needs the agenda to narrow the supported `Tiled` subset and the resulting asset contract before a new decision can be written. The current direction also suggests an important simplification:
- Prometeu can assume exactly one tileset per tilemap in its first wave;
- a `Scene Bank` may contain more than one tilemap asset in the same bank scope;
- each scene may contain up to `4` layers in wave 1;
- each scene layer is associated with one tilemap, so a scene bank may keep more than one `TMX` file in the same asset directory;
- the parser still needs to understand `Tiled` identifiers correctly, but the Studio-side scene model can intentionally narrow to one linked `Tileset` glyph bank per map;
- this keeps the runtime/editor model simpler while still using `Tiled` as the interchange and tooling format.
Another important clarification is now explicit:
- the `Assets` workspace remains the owner of asset management;
- `Glyph Bank` remains the base type shown in the asset summary;
- the Studio-only specialization appears as a complementary suffix or chip such as `Glyph Bank / Tileset`;
- specialization-specific behavior such as locked dimensions or generated `TSX` output belongs strictly to Studio behavior rather than the runtime asset contract.
- specialization-specific actions should reuse the existing actions surface rather than creating a new intermediate panel between summary and actions.
### Convergence pass - accepted Tiled surfaces and tileset handoff
#### 1. Supported Tiled surfaces for wave 1
Direction: the Studio integration surface for wave 1 uses both `TMX` and `TSX`.
Implications:
- `TMX` is the map-side surface used for tilemap generation and interchange;
- `TSX` is the tileset-side surface generated from a Studio `Glyph Bank` specialized as `Tileset`;
- JSON export is not the primary interoperability surface for this wave.
#### 2. Mapping from `Tileset` glyph banks to Tiled tilesets
Direction: a `Glyph Bank` flagged as `Tileset` generates the `TSX` representation for that bank.
Implications:
- the generated `TSX` is the Tiled-facing tileset surface for that glyph bank;
- the new `Scene Bank` may point to that generated `TSX` when generating a tilemap;
- this coupling is scoped to tilemap generation and consumption, not to a runtime asset-family split;
- `TSX` generation remains owned by the `Assets` workspace as Studio behavior over a `Glyph Bank`.
### Convergence pass - accepted Scene Bank and parser direction
#### 1. Persistence model of the new `Scene Bank`
Direction: the new `Scene Bank` MUST persist full `TMX` documents as its maintained Studio asset format, plus any additional support files needed by the asset.
Implications:
- the `Scene Bank` keeps `TMX` as the authoritative map-side document format rather than normalizing into a separate Studio-owned scene schema;
- one scene-facing asset directory may contain more than one `TMX` file, because a scene may bind distinct tilemaps to distinct layers;
- wave 1 limits each scene to at most `4` layers;
- the asset may include additional Studio support files alongside the maintained `TMX`;
- those support files own the Studio-side convention that maps scene layers to the corresponding tilemaps;
- the `Assets` workspace may expose a dedicated area to inspect and edit that scene-bank support data;
- `TMX` generation remains part of the Studio asset flow, but the generated `TMX` is also the canonical maintained map document for the asset bank;
- final pack materialization into a dedicated `assets.pa` representation is explicitly outside this wave.
Implementation note accepted during agenda convergence:
- the exact shape of the support files is intentionally deferred and may be discovered during implementation, as long as the final direction preserves an explicit layer-to-tilemap mapping owned by the scene-facing asset.
#### 2. Ownership of scene-asset management in `Assets`
Direction: the `Assets` workspace MUST fully manage the scene-facing asset lifecycle.
Implications:
- `Assets` owns creation, metadata, validation, consistency checks, and asset-facing operations for the `Scene Bank`;
- the heavy map-editing interaction is expected to occur in `Tiled`;
- Studio-side asset management remains responsible for consistency and integration around that external editing loop.
#### 3. Wave-1 parser requirement
Direction: wave 1 MUST include an XML parser capable of both reading and writing `TMX` and `TSX`.
Implications:
- XML is the required implementation surface for this wave;
- the Studio must be able to ingest existing `TMX` and `TSX` documents;
- the Studio must also emit `TMX` and `TSX` documents from its owned asset state.
#### 3a. Asset-file placement and reference model
Direction: `TSX` files are generated by the `Glyph Bank` asset specialized as `Tileset`, and `TMX` files are generated inside the scene-facing asset and must reference those generated `TSX` files in the tileset asset directories.
Implications:
- a `Tileset` specialization on a `Glyph Bank` owns the generation of the Tiled-facing `TSX` files for that asset;
- the scene-facing asset owns the generation and maintenance of the corresponding `TMX` files;
- each tilemap inside the scene-facing asset must reference exactly one generated `TSX` in wave 1;
- the reference path model between `TMX` and `TSX` must remain deterministic so the asset bank can be regenerated and validated safely.
Open precision still to be normalized in the future:
- the exact canonical naming convention for multiple `TMX` files in the same scene-bank directory is not yet fixed, but it must be canonical and must remain compatible with the layer-to-tilemap support-file convention.
#### 4. Pack boundary for the current wave
Direction: pack integration is NOT part of the current wave.
Implications:
- the current work should stay inside Studio until the asset model and Tiled interop are stabilized;
- no packer-facing publication or final asset-pack contract is required to move this agenda forward;
- later pack work will materialize the `TMX`/`TSX` asset mass plus support files into a dedicated binary representation.
#### 5. Persistence of the Studio-only specialization flag
Direction: the editorial specialization should be represented internally as an enum and stored on the Studio-controlled asset entity that already owns asset metadata.
Implications:
- the flag remains Studio-only and does not redefine the runtime asset family;
- the enum should encode membership such as `Tileset (GlyphBank)`, making filtering and applicability rules explicit;
- persistence should reuse the existing Studio/asset metadata home rather than inventing a separate document just for the specialization flag;
- the current working assumption is that this persistence may belong alongside the asset entity already controlled by Studio tooling, pending code-level confirmation of the exact home.
#### 6. Validation ownership and specialization limits in wave 1
Direction: specialization should unlock Studio-specific actions, but it should NOT hard-lock general glyph-bank editing in this wave.
Implications:
- a specialization such as `Tileset`, `Sprites`, or `UI` primarily controls which Studio actions and flows are available for that glyph bank;
- the base glyph-bank runtime/editor model remains flexible in wave 1;
- consistency checks that depend on how the glyph bank is used in a map should be enforced by the `Scene Bank`;
- this keeps wave-1 constraints aligned with the runtime flexibility instead of prematurely freezing the glyph-bank editor surface.
#### 7. Diagnostic and blocking model
Direction: scene and Tiled consistency issues should use the same diagnostic pattern already used by glyph assets.
Implications:
- invalid or unsupported references should surface as Studio diagnostics;
- the same diagnostic discipline should block pack when inconsistencies remain;
- this keeps the failure model aligned with the existing asset-validation experience rather than introducing a second error system.
#### 7a. External-edit acceptance workflow
Direction: wave 1 MUST use an explicit acceptance workflow between Studio-owned scene assets and external `Tiled` edits.
Implications:
- the initial workflow is: create the scene asset in Studio, edit the generated `TMX`/`TSX` documents in `Tiled`, then return to Studio to validate and explicitly accept the resulting changes;
- a scene bank is considered ready only after Studio validation succeeds and the user explicitly accepts the converged state;
- Studio remains the place where external edits are reviewed and accepted, rather than auto-merging them silently.
#### 8. Supported Tiled feature subset in wave 1
Direction: wave 1 should support `Object Layers`, properties, flips, and collisions from the start.
Implications:
- collisions should be supported in both places that Tiled can represent them for this workflow;
- tile-owned collision may live in the `TSX` side of a specialized `Tileset` glyph bank;
- map-owned collision must be represented through `Object Layer` content in the `TMX` side of the `Scene Bank`;
- map-side `Object Group` support is useful because the pipeline will gradually absorb additional features such as collision, camera boundaries, lights, and similar scene annotations;
- the remaining unsupported Tiled features for this wave are the broader feature set outside this accepted subset, such as infinite maps, templates, Wang sets, and animations, unless later narrowed by a follow-up decision.
### Future note - possible collision-pipeline consolidation
This agenda also records a possible future pipeline direction that is explicitly outside the current wave:
- once collision support becomes mature enough, the pipeline may generate a canonical map-side `objectgroup` dedicated to collision, such as `collision`;
- that generated layer could read or derive effective collision information from tile-owned collision definitions and map-authored collision content;
- the pack pipeline could then consume that consolidated collision layer as the immediate source for collision materialization in the packed asset output.
Important boundary:
- this is a future optimization and publication-direction note, not a wave-1 authoring rule;
- authored collision and generated collision layers must remain conceptually separate until a later decision formalizes that pipeline stage.
### Convergence pass - accepted local UX direction
#### 1. Placement of specialization-specific actions in `Assets`
Recommendation: specialization-specific actions should be stacked together with the existing asset actions.
Reasoning:
- the current `Assets` workspace already has an established actions surface;
- introducing a second action region between summary and actions would fragment the UX without adding a clearer ownership model;
- specialization-specific commands remain asset actions and do not need a separate structural host.
Accepted local direction for this agenda:
- the `Assets` workspace MUST NOT introduce a separate action region between summary and actions for specialization-specific commands;
- commands such as `build tileset.tsx` SHOULD appear in the same action stack as the existing asset actions;
- visibility and enablement of those commands SHOULD be driven by the Studio-only specialization flag of the glyph bank;
- the summary remains responsible for identity and type presentation, including the complementary specialization chip.
That makes Option A the current best fit for convergence, and the latest discussion now also closes the first-wave one-tileset-per-tilemap rule, `TMX`/`TSX` asset ownership, and the explicit Studio-side acceptance workflow for external `Tiled` edits. The remaining work is to turn those accepted directions into a precise decision text while marking the support-file shape and multi-`TMX` naming convention as intentionally deferred implementation details.
## Resolution ## Resolution
Recommended next step: Recommended next step:
- converge this agenda around a first-wave `Tiled` subset; - write a replacement decision for `Scene Bank` ownership in `Assets`;
- define whether the new asset type is raw-imported or normalized; - codify `TMX` as the maintained map document format and `TSX` as the tileset-facing generated format;
- then write a replacement decision for asset ownership, parser boundary, and wave-1 scope. - codify the first-wave rule of exactly one tileset per tilemap, while allowing more than one tilemap inside a `Scene Bank`;
- codify the Studio-only glyph-bank specialization-flag model for `Tileset`, `Sprites`, and `UI`;
- codify the wave-1 supported `Tiled` subset, validation model, and explicit acceptance flow for external edits;
- carry forward the support-file shape and multi-`TMX` naming convention as deferred implementation details rather than pretending they are already closed.

View File

@ -0,0 +1,139 @@
---
id: DEC-0027
ticket: studio-tiled-parser-assets-scene-asset-type
title: Tiled Parser and Scene Bank Asset Ownership in Assets Workspace
status: accepted
created: 2026-04-17
accepted: 2026-04-17
agenda: AGD-0030
plans: []
tags:
- studio
- assets
- scene
- tiled
- parser
- asset-type
---
## Context
The previous `Scene Workspace` direction was explicitly abandoned and MUST NOT be reused as the basis for new planning or implementation.
The replacement direction is asset-first:
- scene-facing ownership belongs to the `Assets` workspace;
- `Tiled` is the external authoring and interchange surface for wave 1;
- `Glyph Bank` remains the base runtime asset family for bank assets;
- Studio-specific roles such as `Tileset`, `Sprites`, and `UI` are editorial specializations only and MUST NOT create new runtime asset families.
This decision closes the ownership, persistence, interoperability, and validation baseline needed to plan and implement the first wave.
## Decision
The Studio SHALL adopt an asset-first `Scene Bank` model managed from the `Assets` workspace.
Wave 1 SHALL use `TMX` and `TSX` as the Tiled-facing asset formats:
- a `Scene Bank` MUST maintain full `TMX` documents as its canonical map-side asset documents;
- a `Glyph Bank` specialized as `Tileset` MUST generate the `TSX` files used by scene tilemaps;
- each tilemap MUST reference exactly one generated `TSX` file in wave 1;
- a `Scene Bank` MAY contain more than one tilemap and therefore more than one `TMX` file in the same asset directory;
- each scene MAY contain up to `4` layers in wave 1;
- each scene layer MUST map to exactly one tilemap;
- the layer-to-tilemap convention MUST be owned by Studio support files that live with the scene-facing asset.
The Studio SHALL own creation, metadata, validation, consistency checks, and asset-facing operations for `Scene Bank` assets.
Heavy map editing for wave 1 SHALL occur in `Tiled`, not in a dedicated Studio scene editor workspace.
## Rationale
This direction preserves the explicit user request to replace the discarded scene-workspace model with:
- a parser for `Tiled`;
- a new scene-facing asset managed from `Assets`;
- Studio-only specialization over existing `Glyph Bank` assets instead of runtime-family multiplication.
Keeping `TMX` as the maintained scene document avoids inventing a parallel normalized scene schema before the Tiled integration boundary is proven in practice.
Keeping `TSX` generation attached to `Tileset`-specialized glyph banks preserves a clear ownership split:
- tileset-facing generation belongs to the specialized glyph-bank asset;
- scene-facing tilemap maintenance belongs to the scene asset;
- runtime publication remains deferred until the Studio-side asset model stabilizes.
The explicit acceptance workflow also prevents silent drift between Studio-owned asset state and externally edited Tiled documents.
## Technical Specification
### 1. Asset ownership and specialization
- The `Assets` workspace MUST remain the management surface for `Scene Bank` assets.
- `Glyph Bank` MUST remain the base asset type shown in summaries.
- The Studio-only specialization flag MUST be stored on Studio-controlled asset metadata.
- The specialization flag MUST be represented as an internal enum or equivalent closed set.
- The specialization flag MUST NOT redefine the runtime asset family.
- Specialization-specific actions MUST appear in the existing asset actions surface.
- The summary SHOULD present the base type plus specialization chip, for example `Glyph Bank / Tileset`.
### 2. Scene Bank persistence
- A scene-facing asset directory MAY contain multiple `TMX` files.
- The maintained scene-bank format MUST keep `TMX` in full rather than translating the asset into a separate normalized Studio scene format.
- The asset MAY contain additional support files owned by Studio.
- Those support files MUST encode the mapping between scene layers and tilemaps.
- The exact support-file schema is intentionally deferred to implementation and MAY be discovered on the fly, but the resulting contract MUST remain explicit and deterministic.
### 3. Tiled surfaces and parser contract
- Wave 1 MUST support XML read/write for both `TMX` and `TSX`.
- JSON export MUST NOT be treated as the primary interoperability surface in wave 1.
- Studio MUST be able to ingest supported `TMX` and `TSX` documents.
- Studio MUST be able to emit supported `TMX` and `TSX` documents from its owned asset state.
### 4. Tilemap, tileset, and layer rules
- Each tilemap MUST reference exactly one `Tileset` glyph-bank generated `TSX`.
- One scene MAY contain multiple tilemaps.
- Each scene MAY contain at most `4` layers in wave 1.
- Each layer MUST point to one tilemap through the scene-bank support files.
- The naming convention for multiple `TMX` files in one scene-bank directory is deferred, but the final convention MUST be canonical and MUST remain compatible with the layer-to-tilemap mapping.
### 5. Supported Tiled subset in wave 1
- Wave 1 MUST support `Object Layers`, properties, flips, and collisions.
- Tile-owned collision MAY be represented on the `TSX` side of a `Tileset` glyph bank.
- Map-owned collision MUST be represented through `Object Layer` content on the `TMX` side of the `Scene Bank`.
- Infinite maps, templates, Wang sets, and animations are outside wave 1 unless revised by a later decision.
### 6. Validation and acceptance workflow
- The initial workflow SHALL be:
1. create the scene-facing asset in Studio;
2. edit the generated `TMX` and `TSX` documents in `Tiled`;
3. return to Studio;
4. validate the resulting state;
5. explicitly accept the converged changes.
- Studio MUST surface invalid, unsupported, or broken references as diagnostics using the same general diagnostic discipline already used for glyph assets.
- Studio MUST NOT silently auto-accept external `Tiled` edits.
- A `Scene Bank` is ready only after Studio validation succeeds and the user explicitly accepts the resulting state.
- The same diagnostic discipline SHOULD block later pack publication when inconsistencies remain.
### 7. Pack boundary
- Pack integration is explicitly outside this wave.
- Wave-1 work MUST stay inside Studio asset management and Tiled interoperability.
- Later pack work MAY materialize the `TMX`/`TSX` asset mass plus support files into a dedicated binary representation.
## Constraints
- This decision supersedes the abandoned `Scene Workspace` direction and MUST be treated as the normative replacement for wave-1 planning.
- Plans derived from this decision MUST NOT reintroduce a dedicated scene workspace or a separate normalized Studio-owned scene format unless the user explicitly requests a revised decision.
- Plans derived from this decision MUST preserve the external-edit acceptance step as a required gate.
- Plans derived from this decision MUST treat the support-file schema and multi-`TMX` naming convention as deferred implementation details, not as permission to weaken the layer-to-tilemap contract.
- Any future move toward automatic convergence, multi-tileset tilemaps, or pack-facing publication contracts requires a new decision or explicit revision of this one.
## Revision Log
- 2026-04-17: Initial accepted decision from AGD-0030.

View File

@ -0,0 +1,121 @@
---
id: PLN-0053
ticket: studio-tiled-parser-assets-scene-asset-type
title: Scene Bank Asset Contract and Studio Metadata Foundations
status: open
created: 2026-04-17
completed:
tags:
- studio
- assets
- scene
- tiled
- metadata
---
## Objective
Establish the asset contract and Studio-owned metadata foundations required by `DEC-0027` so `Scene Bank` assets and specialized `Glyph Bank` assets can exist in the `Assets` workspace without reintroducing a dedicated scene workspace or a separate normalized scene schema.
## Background
`DEC-0027` locks the following requirements that must be reflected in the asset contract before parser and UX work can be implemented safely:
- `Scene Bank` is an asset owned by the `Assets` workspace.
- `Glyph Bank` remains the base runtime asset family, with Studio-only specialization flags such as `Tileset`, `Sprites`, and `UI`.
- `TMX` remains the maintained scene document format.
- `Scene Bank` support files own the explicit `layer -> tilemap` mapping.
- one scene may contain multiple tilemaps, with up to `4` layers in wave 1.
The current repo already has the asset creation and detail surfaces under `prometeu-studio/src/main/java/p/studio/workspaces/assets` and the asset manifest/runtime contract under `prometeu-packer/prometeu-packer-v1`.
## Scope
### Included
- Add `Scene Bank` as a first-class asset option in the Studio asset creation and details pipeline.
- Add the Studio-only specialization model for `Glyph Bank` assets, including `Tileset`, `Sprites`, and `UI`.
- Define where Studio-owned scene support files and specialization metadata live relative to existing asset roots.
- Add summary/details projection support so the `Assets` workspace can display base family plus specialization chip.
- Define the wave-1 metadata contract for scene-layer mapping and scene-bank limits.
### Excluded
- TMX/TSX XML parsing and serialization logic.
- External-edit acceptance UI or file-diff validation flows.
- Pack publication of scene assets into runtime binary formats.
## Execution Steps
### Step 1 - Extend asset family and metadata models
**What:** Introduce the data models needed for `Scene Bank` assets and Studio-only glyph-bank specialization.
**How:** Update the packer/studio shared asset declarations and detail projection pipeline so `Scene Bank` can be created, read, and surfaced by the `Assets` workspace while keeping `Glyph Bank` as the underlying runtime family for specializations. Define the Studio-only specialization enum and the scene-bank support metadata surface that will later carry `layer -> tilemap` mapping and wave-1 constraints.
**File(s):**
- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java`
- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDeclarationParser.java`
- `prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDetailsService.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetSummary.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetDetails.java`
- additional shared DTOs or catalogs discovered from the asset declaration flow
### Step 2 - Add creation and details support in Assets workspace
**What:** Make `Scene Bank` and glyph-bank specialization available in the actual Studio asset workflow.
**How:** Extend the add-asset wizard to expose `Scene Bank` as an asset type and add the minimum specialization capture or follow-up action path for `Glyph Bank`. Update details/summary rendering to show `Glyph Bank / Tileset`-style presentation and to surface scene-bank metadata ownership in the details view without inventing a separate workspace region.
**File(s):**
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/wizards/AddAssetWizard.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/details/summary/AssetDetailsSummaryControl.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java`
- `prometeu-studio/src/main/resources/i18n/messages.properties`
### Step 3 - Define on-disk scene-bank support-file baseline
**What:** Establish the first implementation-owned support-file baseline for scene banks.
**How:** Introduce a concrete but implementation-scoped support-file format that records scene layer count, `layer -> tilemap` mapping, and references needed by the `Assets` workspace. The plan MUST preserve the decision rule that the exact shape can be discovered during implementation, but the result MUST be explicit, deterministic, and colocated with the scene asset root.
**File(s):**
- asset-root support files under `test-projects/main/assets/...` fixtures
- implementation targets discovered while wiring Studio asset persistence
- related tests in `prometeu-studio/src/test/java/p/studio/workspaces/assets/...` and `prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/...`
## Test Requirements
### Unit Tests
- Add parser/service tests proving `Scene Bank` assets can be created and read through the asset declaration pipeline.
- Add Studio projection tests proving summary/details surfaces can represent base family plus specialization.
- Add tests for the support-file metadata model, including `1..4` layer constraints and explicit `layer -> tilemap` mapping requirements.
### Integration Tests
- Extend asset workspace service tests to verify end-to-end creation of a `Scene Bank` asset root and detail retrieval.
- Add or update test fixtures under `test-projects/main/assets` to cover specialized glyph banks and scene-bank roots with multiple tilemaps.
### Manual Verification
- Create a new `Scene Bank` from the Assets workspace and confirm the asset appears in the list/details flow.
- Create or mark a `Glyph Bank` as `Tileset` and confirm the summary shows base family plus specialization chip.
## Acceptance Criteria
- [ ] `Scene Bank` exists as a first-class asset choice in the Studio asset flow.
- [ ] Studio-only glyph-bank specialization is represented by a closed metadata set and does not redefine the runtime asset family.
- [ ] The Assets workspace can display base family and specialization together in the summary/details flow.
- [ ] Scene-bank support metadata explicitly records `layer -> tilemap` mapping and enforces the wave-1 `4`-layer limit.
- [ ] No plan step introduces a dedicated scene workspace or a normalized Studio-owned scene format.
## Dependencies
- Depends on `DEC-0027`.
- Unblocks `PLN-0054` and `PLN-0055`.
## Risks
- The current asset declaration pipeline may assume existing family/output combinations too rigidly, which can force deeper changes in packer services than the UI suggests.
- If specialization metadata is embedded in the wrong layer, Studio-only editorial flags may leak into runtime-facing contracts.
- If the support-file baseline is too vague, later parser and validation work will drift on naming and ownership.

View File

@ -0,0 +1,120 @@
---
id: PLN-0054
ticket: studio-tiled-parser-assets-scene-asset-type
title: Tiled XML IO and Scene Bank File Orchestration
status: open
created: 2026-04-17
completed:
tags:
- studio
- assets
- scene
- tiled
- parser
---
## Objective
Implement the wave-1 XML read/write pipeline for `TMX` and `TSX`, including deterministic file placement and reference orchestration between `Scene Bank` tilemaps and `Tileset`-specialized glyph-bank assets.
## Background
`DEC-0027` requires wave-1 XML read/write support for both `TMX` and `TSX`, keeps `TMX` as the maintained scene document, and requires each tilemap to reference exactly one generated `TSX`. It also keeps naming details implementation-owned, but requires the resulting convention to be canonical and compatible with explicit `layer -> tilemap` mapping.
The repository already contains example `TMX` and `TSX` files under `test-projects/main/assets/scenes` and `test-projects/main/assets/Zelda3`, which can be promoted into fixture coverage.
## Scope
### Included
- Add XML read/write support for supported `TMX` and `TSX` surfaces.
- Generate `TSX` from `Tileset`-specialized glyph-bank assets.
- Generate and maintain one or more `TMX` files inside a `Scene Bank` asset root.
- Define and implement a canonical naming/reference convention for multiple `TMX` files compatible with scene support metadata.
- Preserve supported wave-1 Tiled features: object layers, properties, flips, and collisions.
### Excluded
- Studio UX for validating and accepting external edits.
- Pack/runtime materialization of scene data.
- Support for infinite maps, templates, Wang sets, or animations.
## Execution Steps
### Step 1 - Introduce Tiled XML domain models and codecs
**What:** Create the XML-side models and serialization services for supported `TMX` and `TSX`.
**How:** Add parser/writer services that cover the accepted wave-1 subset, preserving round-trip stability for supported fields and producing explicit diagnostics for unsupported constructs. Keep the implementation aligned to XML as the primary interoperability surface and avoid JSON-export dependencies.
**File(s):**
- new `prometeu-studio/src/main/java/p/studio/.../tiled/...` package(s) discovered during implementation
- related tests under `prometeu-studio/src/test/java/p/studio/.../tiled/...`
### Step 2 - Generate TSX from Tileset-specialized glyph banks
**What:** Add the generation path that emits `TSX` for `Glyph Bank` assets specialized as `Tileset`.
**How:** Extend the asset action/details flow so a `Tileset` glyph bank can materialize its Tiled-facing `TSX` file in the asset root. The generated `TSX` MUST include the supported tileset-side collision/properties surface needed by wave 1 and MUST remain deterministic for later scene references.
**File(s):**
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/details/...`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetAction.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetDetails.java`
- implementation packages created for Tiled file generation
### Step 3 - Generate and maintain TMX files inside Scene Bank assets
**What:** Add the file orchestration path for one scene with up to `4` layer-linked tilemaps.
**How:** Use the scene-bank support metadata from `PLN-0053` to generate and maintain one or more canonical `TMX` files in the scene asset root. Each `TMX` MUST reference exactly one generated `TSX` from a `Tileset` glyph-bank asset, and the chosen naming convention MUST remain stable across regenerate/validate cycles.
**File(s):**
- scene-bank persistence/orchestration services added under `prometeu-studio/src/main/java/p/studio/...`
- test fixtures under `test-projects/main/assets/scenes` and related asset roots
### Step 4 - Enforce supported subset and failure behavior
**What:** Formalize the wave-1 supported subset and rejection behavior for unsupported input.
**How:** Add diagnostic-producing validation around XML ingest so unsupported Tiled constructs fail explicitly and do not silently normalize away unsupported content. Cover tile-owned collision in `TSX` and map-owned collision via `Object Layer` in `TMX`.
**File(s):**
- Tiled parser/validator packages introduced in this plan
- diagnostics surfaces reused by the Assets workspace
## Test Requirements
### Unit Tests
- Add TMX parser/writer round-trip tests for supported map metadata, layers, object layers, properties, flips, and collision cases.
- Add TSX parser/writer round-trip tests for supported tileset metadata and tile-owned collision/properties.
- Add tests for rejection/diagnostics on unsupported features such as infinite maps, templates, Wang sets, and animations.
### Integration Tests
- Add end-to-end fixture tests proving a `Tileset` glyph bank can generate `TSX` and a `Scene Bank` can generate one or more `TMX` files referencing it.
- Add tests proving canonical `TMX` naming remains stable across regeneration using the same support metadata.
### Manual Verification
- Generate `TSX` from a `Tileset` glyph bank and open it in Tiled.
- Generate a multi-tilemap scene-bank root and confirm each `TMX` opens in Tiled and points to the expected `TSX`.
## Acceptance Criteria
- [ ] Studio can read and write the supported XML subset for both `TMX` and `TSX`.
- [ ] `Tileset`-specialized glyph banks can generate deterministic `TSX` files.
- [ ] `Scene Bank` assets can maintain multiple canonical `TMX` files in one asset root.
- [ ] Every generated tilemap references exactly one generated `TSX` in wave 1.
- [ ] Unsupported Tiled features fail with explicit diagnostics instead of silent degradation.
## Dependencies
- Depends on `DEC-0027`.
- Depends on `PLN-0053` for asset metadata and support-file ownership.
- Unblocks `PLN-0055`.
## Risks
- XML round-trip fidelity can drift if parser and writer models are not designed from the same canonical structure.
- Canonical naming and path resolution may become unstable if they are derived from mutable UI labels instead of explicit metadata.
- TSX generation can accidentally encode Studio-only specialization state into runtime-facing data if the boundary is not kept strict.

View File

@ -0,0 +1,120 @@
---
id: PLN-0055
ticket: studio-tiled-parser-assets-scene-asset-type
title: Assets Workspace Scene Bank Validation and Acceptance Flow
status: open
created: 2026-04-17
completed:
tags:
- studio
- assets
- scene
- validation
- ux
---
## Objective
Implement the `Assets` workspace flow that validates external `Tiled` edits, surfaces diagnostics, and requires explicit Studio-side acceptance before a `Scene Bank` is considered ready.
## Background
`DEC-0027` requires the initial workflow to be:
1. create the scene asset in Studio;
2. edit generated `TMX`/`TSX` in `Tiled`;
3. return to Studio;
4. validate;
5. explicitly accept the converged changes.
The decision explicitly forbids silent auto-acceptance and requires the same general diagnostic discipline already used for glyph assets. The current `Assets` workspace already contains refresh, detail, action, and diagnostics surfaces that can host this flow without creating a dedicated scene workspace.
## Scope
### Included
- Add scene-bank validation state to the `Assets` workspace projections.
- Add explicit actions to validate and accept external scene-bank changes.
- Surface scene/Tiled diagnostics in the existing asset details/logging patterns.
- Ensure readiness/build participation reflects acceptance state for scene banks.
### Excluded
- Automatic merge/convergence of external edits.
- A dedicated scene-editing workspace.
- Pack/runtime publication.
## Execution Steps
### Step 1 - Extend asset details state for scene-bank validation
**What:** Add the projection/state needed to represent scene-bank validation and acceptance status in the assets UI.
**How:** Extend the asset workspace message and detail-state models so scene-bank assets can expose pending external changes, validation result, acceptance readiness, and scene-specific actions while reusing the current details/action host rather than adding a separate workspace.
**File(s):**
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetDetails.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceDetailsViewState.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetAction.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/dialogs/AssetDiagnosticsDialog.java`
### Step 2 - Add validate/accept actions to existing assets action surface
**What:** Add the concrete user actions for scene-bank validation and explicit acceptance.
**How:** Extend the existing asset actions surface so scene-bank assets can trigger validation against current `TMX`/`TSX` and then explicitly accept the converged result. Keep these actions stacked with existing asset actions, in line with `DEC-0027`, and ensure availability is driven by asset family/state rather than a new intermediate panel.
**File(s):**
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/details/summary/AssetDetailsSummaryControl.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/details/...` action-host controls discovered during implementation
- `prometeu-studio/src/main/resources/i18n/messages.properties`
### Step 3 - Connect validation results to diagnostics and readiness gates
**What:** Make validation outcome meaningful to the workflow.
**How:** Reuse the current diagnostic/log/event patterns so unsupported references, broken paths, invalid tilemap-to-layer mapping, and unsupported Tiled constructs surface as diagnostics. Mark the scene bank as not ready until validation succeeds and explicit acceptance is recorded. Reflect this status in build participation or equivalent readiness signals without implementing pack publication itself.
**File(s):**
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/...`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetLogsPane.java`
- `prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java`
- any validation coordinator/services created for scene-bank acceptance
## Test Requirements
### Unit Tests
- Add tests for scene-bank details state transitions: created, externally changed, validation failed, validation passed, accepted.
- Add tests proving accept is unavailable when validation diagnostics remain.
- Add tests proving scene-bank actions are hosted in the existing asset actions surface.
### Integration Tests
- Add assets workspace tests covering refresh after external file edits, validation, diagnostics surfacing, and explicit acceptance.
- Add fixture-backed tests covering invalid TSX references, unsupported TMX features, and broken layer-to-tilemap mappings.
### Manual Verification
- Create a scene bank in Studio, edit its `TMX`/`TSX` externally in Tiled, return to Studio, validate, and explicitly accept.
- Confirm the asset stays unready when diagnostics remain and becomes ready only after successful validation plus explicit acceptance.
## Acceptance Criteria
- [ ] Scene-bank assets expose validate/accept actions in the existing asset action surface.
- [ ] Studio surfaces scene-bank diagnostics using the existing general diagnostic discipline.
- [ ] Studio does not silently auto-accept external `Tiled` edits.
- [ ] A scene bank is not considered ready until validation succeeds and the user explicitly accepts the result.
- [ ] No part of the workflow introduces a dedicated scene workspace.
## Dependencies
- Depends on `DEC-0027`.
- Depends on `PLN-0053` for asset/state foundations.
- Depends on `PLN-0054` for TMX/TSX ingest and validation inputs.
## Risks
- If refresh and validation state are coupled too loosely, the UI can show stale readiness after external edits.
- If acceptance is represented only in transient UI state, Studio may lose the required gate after reload.
- If diagnostics are not normalized with existing asset flows, scene-bank errors will feel like a separate subsystem.

View File

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

View File

@ -5,6 +5,7 @@ import java.util.Locale;
public enum OutputFormatCatalog { public enum OutputFormatCatalog {
GLYPH_INDEXED_V1(AssetFamilyCatalog.GLYPH_BANK, "GLYPH/indexed_v1", "GLYPH/indexed_v1"), 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"), SOUND_V1(AssetFamilyCatalog.SOUND_BANK, "SOUND/v1", "SOUND/v1"),
UNKNOWN(AssetFamilyCatalog.UNKNOWN, "unknown", "Unknown"); UNKNOWN(AssetFamilyCatalog.UNKNOWN, "unknown", "Unknown");

View File

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

View File

@ -38,6 +38,7 @@ import java.util.stream.Stream;
public final class FileSystemPackerWorkspaceService implements PackerWorkspaceService { public final class FileSystemPackerWorkspaceService implements PackerWorkspaceService {
private static final int GLYPH_BANK_COLOR_KEY_RGB565 = 0xF81F; 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 ObjectMapper mapper;
private final PackerWorkspaceFoundation workspaceFoundation; private final PackerWorkspaceFoundation workspaceFoundation;
@ -287,6 +288,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
final PackerRegistryEntry entry = workspaceFoundation.allocateIdentity(project, registry, assetRoot); final PackerRegistryEntry entry = workspaceFoundation.allocateIdentity(project, registry, assetRoot);
Files.createDirectories(assetRoot); Files.createDirectories(assetRoot);
writeManifest(manifestPath, request, entry.assetUuid()); writeManifest(manifestPath, request, entry.assetUuid());
writeStudioSupportFiles(assetRoot, request);
final PackerRegistryState updated = workspaceFoundation.appendAllocatedEntry(registry, entry); final PackerRegistryState updated = workspaceFoundation.appendAllocatedEntry(registry, entry);
workspaceFoundation.saveRegistry(project, updated); workspaceFoundation.saveRegistry(project, updated);
final var runtime = runtimeRegistry.update(project, (snapshot, generation) -> runtimePatchService.afterCreateAsset( 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); 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) { private String normalizeRelativeAssetRoot(String candidate) {
final String raw = Objects.requireNonNullElse(candidate, "").trim().replace('\\', '/'); final String raw = Objects.requireNonNullElse(candidate, "").trim().replace('\\', '/');
if (raw.isBlank()) { if (raw.isBlank()) {

View File

@ -121,7 +121,7 @@ public final class PackerAssetDeclarationParser {
diagnostics.add(new PackerDiagnostic( diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR, PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL, 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, manifestPath,
true)); true));
return null; return null;

View File

@ -512,6 +512,32 @@ final class FileSystemPackerWorkspaceServiceTest {
assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.ACTION_APPLIED)); 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 @Test
void returnsCreatedAssetThroughRuntimeBackedDetailsWithoutRescanMismatch() throws Exception { void returnsCreatedAssetThroughRuntimeBackedDetailsWithoutRescanMismatch() throws Exception {
final Path projectRoot = tempDir.resolve("created-details"); 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()); 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 @Test
void rejectsNonObjectPipelineMetadata() throws Exception { void rejectsNonObjectPipelineMetadata() throws Exception {
final Path manifest = tempDir.resolve("asset.json"); final Path manifest = tempDir.resolve("asset.json");

View File

@ -154,6 +154,10 @@ public enum I18n {
ASSETS_ACTIONS_EMPTY("assets.actions.empty"), ASSETS_ACTIONS_EMPTY("assets.actions.empty"),
ASSETS_ACTION_REGISTER("assets.action.register"), ASSETS_ACTION_REGISTER("assets.action.register"),
ASSETS_ACTION_ANALYSE("assets.action.analyse"), 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_DELETE("assets.action.delete"),
ASSETS_ACTION_INCLUDE_IN_BUILD("assets.action.includeInBuild"), ASSETS_ACTION_INCLUDE_IN_BUILD("assets.action.includeInBuild"),
ASSETS_ACTION_EXCLUDE_FROM_BUILD("assets.action.excludeFromBuild"), ASSETS_ACTION_EXCLUDE_FROM_BUILD("assets.action.excludeFromBuild"),
@ -190,10 +194,24 @@ public enum I18n {
ASSETS_LABEL_BUILD_PARTICIPATION("assets.label.buildParticipation"), ASSETS_LABEL_BUILD_PARTICIPATION("assets.label.buildParticipation"),
ASSETS_LABEL_ASSET_ID("assets.label.assetId"), ASSETS_LABEL_ASSET_ID("assets.label.assetId"),
ASSETS_LABEL_TYPE("assets.label.type"), 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_GLYPH_BANK("assets.type.glyphBank"),
ASSETS_TYPE_SCENE_BANK("assets.type.sceneBank"),
ASSETS_TYPE_PALETTE_BANK("assets.type.paletteBank"), ASSETS_TYPE_PALETTE_BANK("assets.type.paletteBank"),
ASSETS_TYPE_SOUND_BANK("assets.type.soundBank"), ASSETS_TYPE_SOUND_BANK("assets.type.soundBank"),
ASSETS_TYPE_UNKNOWN("assets.type.unknown"), 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_LOCATION("assets.label.location"),
ASSETS_LABEL_BANK("assets.label.bank"), ASSETS_LABEL_BANK("assets.label.bank"),
ASSETS_LABEL_TARGET_LOCATION("assets.label.targetLocation"), 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_NAME("assets.addWizard.label.name"),
ASSETS_ADD_WIZARD_LABEL_ROOT("assets.addWizard.label.root"), ASSETS_ADD_WIZARD_LABEL_ROOT("assets.addWizard.label.root"),
ASSETS_ADD_WIZARD_LABEL_TYPE("assets.addWizard.label.type"), 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_FORMAT("assets.addWizard.label.format"),
ASSETS_ADD_WIZARD_LABEL_CODEC("assets.addWizard.label.codec"), ASSETS_ADD_WIZARD_LABEL_CODEC("assets.addWizard.label.codec"),
ASSETS_ADD_WIZARD_LABEL_PRELOAD("assets.addWizard.label.preload"), ASSETS_ADD_WIZARD_LABEL_PRELOAD("assets.addWizard.label.preload"),
ASSETS_ADD_WIZARD_PROMPT_TYPE("assets.addWizard.prompt.type"), 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_FORMAT("assets.addWizard.prompt.format"),
ASSETS_ADD_WIZARD_PROMPT_CODEC("assets.addWizard.prompt.codec"), ASSETS_ADD_WIZARD_PROMPT_CODEC("assets.addWizard.prompt.codec"),
ASSETS_ADD_WIZARD_ASSETS_ROOT_HINT("assets.addWizard.assetsRootHint"), 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.contract.AssetDetailsContractControl;
import p.studio.workspaces.assets.details.palette.AssetDetailsPaletteOverhaulingControl; import p.studio.workspaces.assets.details.palette.AssetDetailsPaletteOverhaulingControl;
import p.studio.workspaces.assets.details.summary.AssetDetailsSummaryControl; 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.AssetWorkspaceAssetAction;
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionDetails; import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionDetails;
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile; import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails; 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.AssetWorkspaceDetailsStatus;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState; import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState;
import p.studio.workspaces.assets.messages.AssetWorkspaceBuildParticipation; 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.StudioAssetsDetailsViewStateChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent; import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceSelectionRequestedEvent; 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.DeleteAssetDialog;
import p.studio.workspaces.assets.wizards.MoveAssetWizard; import p.studio.workspaces.assets.wizards.MoveAssetWizard;
import p.studio.workspaces.framework.StudioEventAware; import p.studio.workspaces.framework.StudioEventAware;
import p.studio.workspaces.framework.StudioEventBindings; import p.studio.workspaces.framework.StudioEventBindings;
import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
@ -53,6 +63,9 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
private final AssetDetailsContractControl contractControl; private final AssetDetailsContractControl contractControl;
private final AssetDetailsBankCompositionControl bankCompositionControl; private final AssetDetailsBankCompositionControl bankCompositionControl;
private final AssetDetailsPaletteOverhaulingControl paletteOverhaulingControl; 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 VBox actionsContent = new VBox(10);
private final ScrollPane actionsScroll = new ScrollPane(); private final ScrollPane actionsScroll = new ScrollPane();
private final VBox actionsSection; private final VBox actionsSection;
@ -324,6 +337,29 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
analyseButton.setDisable(actionRunning || viewState.selectedAssetDetails().diagnostics().isEmpty()); analyseButton.setDisable(actionRunning || viewState.selectedAssetDetails().diagnostics().isEmpty());
analyseButton.setOnAction(ignored -> openDiagnosticsDialog()); analyseButton.setOnAction(ignored -> openDiagnosticsDialog());
nodes.add(analyseButton); 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()); final Button buildParticipationButton = AssetDetailsUiSupport.createActionButton(buildParticipationActionLabel());
AssetDetailsUiSupport.applyActionTone( 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) { private void applyBuildParticipationResult(UpdateAssetBuildParticipationResult result) {
actionRunning = false; actionRunning = false;
if (result.status() == p.packer.messages.PackerOperationStatus.SUCCESS && result.assetReference() != null) { 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, PackerAssetDetailsDTO details,
java.util.List<PackerDiagnosticDTO> diagnostics, java.util.List<PackerDiagnosticDTO> diagnostics,
java.util.List<PackerAssetActionAvailabilityDTO> actions) { java.util.List<PackerAssetActionAvailabilityDTO> actions) {
final java.util.List<PackerDiagnosticDTO> mergedDiagnostics = new java.util.ArrayList<>(details.diagnostics()); final var baseSummary = AssetListPackerMappings.mapSummary(details.summary());
for (PackerDiagnosticDTO diagnostic : diagnostics) { final AssetStudioMetadataSnapshot studioMetadata = studioMetadataService.read(
if (!mergedDiagnostics.contains(diagnostic)) { baseSummary.assetRoot(),
mergedDiagnostics.add(diagnostic); baseSummary.assetFamily());
} final java.util.List<AssetWorkspaceAssetAction> mappedActions = actions.stream()
} .map(action -> new AssetWorkspaceAssetAction(
return new AssetWorkspaceAssetDetails( action.action(),
AssetListPackerMappings.mapSummary(details.summary()), action.enabled(),
actions.stream() action.visible(),
.map(action -> new AssetWorkspaceAssetAction( action.reason()))
action.action(), .toList();
action.enabled(), final p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary mappedSummary =
action.visible(), new p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary(
action.reason())) baseSummary.assetReference(),
.toList(), 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.outputFormat(),
details.outputCodec(), details.outputCodec(),
details.availableOutputCodecs(), details.availableOutputCodecs(),
details.codecConfigurationFieldsByCodec(), details.codecConfigurationFieldsByCodec(),
details.metadataFields(), details.metadataFields(),
details.outputPipeline(), details.outputPipeline(),
mapBankComposition(details.bankComposition()), bankComposition,
details.pipelinePalettes(), 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); mergedDiagnostics);
} }

View File

@ -16,6 +16,8 @@ import p.studio.controls.forms.StudioFormSection;
import p.studio.controls.forms.StudioSection; import p.studio.controls.forms.StudioSection;
import p.studio.projects.ProjectReference; import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n; 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.AssetWorkspaceAssetState;
import p.studio.workspaces.assets.messages.AssetWorkspaceBuildParticipation; import p.studio.workspaces.assets.messages.AssetWorkspaceBuildParticipation;
@ -114,6 +116,7 @@ public final class AssetDetailsUiSupport {
public static String typeLabel(AssetFamilyCatalog assetFamily) { public static String typeLabel(AssetFamilyCatalog assetFamily) {
return switch (assetFamily) { return switch (assetFamily) {
case GLYPH_BANK -> Container.i18n().text(I18n.ASSETS_TYPE_GLYPH_BANK); 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 SOUND_BANK -> Container.i18n().text(I18n.ASSETS_TYPE_SOUND_BANK);
case UNKNOWN -> Container.i18n().text(I18n.ASSETS_TYPE_UNKNOWN); case UNKNOWN -> Container.i18n().text(I18n.ASSETS_TYPE_UNKNOWN);
}; };
@ -122,11 +125,31 @@ public final class AssetDetailsUiSupport {
public static String typeChipTone(AssetFamilyCatalog assetFamily) { public static String typeChipTone(AssetFamilyCatalog assetFamily) {
return switch (assetFamily) { return switch (assetFamily) {
case GLYPH_BANK -> "assets-details-chip-image"; case GLYPH_BANK -> "assets-details-chip-image";
case SCENE_BANK -> "assets-details-chip-generic";
case SOUND_BANK -> "assets-details-chip-audio"; case SOUND_BANK -> "assets-details-chip-audio";
case UNKNOWN -> "assets-details-chip-generic"; 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) { public static String actionLabel(AssetAction action) {
return switch (action) { return switch (action) {
case REGISTER -> Container.i18n().text(I18n.ASSETS_ACTION_REGISTER); case REGISTER -> Container.i18n().text(I18n.ASSETS_ACTION_REGISTER);

View File

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

View File

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

View File

@ -8,6 +8,9 @@ import p.studio.lsp.events.StudioWorkspaceEventBus;
import p.studio.projects.ProjectReference; import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n; import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.assets.details.AssetDetailsUiSupport; 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.AssetWorkspaceAssetSummary;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState; import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState;
import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent; 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 VBox content = new VBox(8);
final AssetStudioSceneBankMetadata sceneBankMetadata = viewState.selectedAssetDetails().sceneBankMetadata();
content.getChildren().setAll( 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_ASSET_ID), summary.assetId() == null ? "" : String.valueOf(summary.assetId())),
AssetDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), AssetDetailsUiSupport.projectRelativePath(projectReference, summary.assetRoot()))); 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( content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_TYPE), Container.i18n().text(I18n.ASSETS_LABEL_TYPE),
AssetDetailsUiSupport.createChip( typeBox));
AssetDetailsUiSupport.typeChipTone(summary.assetFamily()), if (summary.glyphSpecialization() != AssetStudioGlyphSpecialization.NONE) {
AssetDetailsUiSupport.typeLabel(summary.assetFamily())))); 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( content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_REGISTRATION), Container.i18n().text(I18n.ASSETS_LABEL_REGISTRATION),
AssetDetailsUiSupport.createChip( AssetDetailsUiSupport.createChip(

View File

@ -113,6 +113,7 @@ public final class AssetListItemControl extends VBox {
private String assetRowToneClass(AssetFamilyCatalog assetFamily) { private String assetRowToneClass(AssetFamilyCatalog assetFamily) {
return switch (assetFamily) { return switch (assetFamily) {
case GLYPH_BANK -> "assets-workspace-asset-row-tone-image"; 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"; case SOUND_BANK -> "assets-workspace-asset-row-tone-audio";
default -> "assets-workspace-asset-row-tone-generic"; default -> "assets-workspace-asset-row-tone-generic";
}; };
@ -121,6 +122,7 @@ public final class AssetListItemControl extends VBox {
private String assetNameToneClass(AssetFamilyCatalog assetFamily) { private String assetNameToneClass(AssetFamilyCatalog assetFamily) {
return switch (assetFamily) { return switch (assetFamily) {
case GLYPH_BANK -> "assets-workspace-asset-name-tone-image"; 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"; case SOUND_BANK -> "assets-workspace-asset-name-tone-audio";
default -> "assets-workspace-asset-name-tone-generic"; 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.dtos.PackerDiagnosticDTO;
import p.packer.messages.assets.OutputCodecCatalog; import p.packer.messages.assets.OutputCodecCatalog;
import p.packer.messages.assets.OutputFormatCatalog; import p.packer.messages.assets.OutputFormatCatalog;
import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -20,6 +21,8 @@ public record AssetWorkspaceAssetDetails(
Map<String, Object> outputPipeline, Map<String, Object> outputPipeline,
AssetWorkspaceBankCompositionDetails bankComposition, AssetWorkspaceBankCompositionDetails bankComposition,
List<Map<String, Object>> pipelinePalettes, List<Map<String, Object>> pipelinePalettes,
AssetStudioSceneBankMetadata sceneBankMetadata,
AssetWorkspaceSceneBankValidation sceneBankValidation,
List<PackerDiagnosticDTO> diagnostics) { List<PackerDiagnosticDTO> diagnostics) {
public AssetWorkspaceAssetDetails { public AssetWorkspaceAssetDetails {
@ -33,6 +36,7 @@ public record AssetWorkspaceAssetDetails(
outputPipeline = Map.copyOf(Objects.requireNonNull(outputPipeline, "outputPipeline")); outputPipeline = Map.copyOf(Objects.requireNonNull(outputPipeline, "outputPipeline"));
bankComposition = Objects.requireNonNull(bankComposition, "bankComposition"); bankComposition = Objects.requireNonNull(bankComposition, "bankComposition");
pipelinePalettes = List.copyOf(Objects.requireNonNull(pipelinePalettes, "pipelinePalettes")); pipelinePalettes = List.copyOf(Objects.requireNonNull(pipelinePalettes, "pipelinePalettes"));
sceneBankValidation = Objects.requireNonNullElse(sceneBankValidation, AssetWorkspaceSceneBankValidation.NOT_APPLICABLE);
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); 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.AssetReference;
import p.packer.messages.assets.AssetFamilyCatalog; import p.packer.messages.assets.AssetFamilyCatalog;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Objects; import java.util.Objects;
@ -13,6 +14,7 @@ public record AssetWorkspaceAssetSummary(
AssetWorkspaceBuildParticipation buildParticipation, AssetWorkspaceBuildParticipation buildParticipation,
Integer assetId, Integer assetId,
AssetFamilyCatalog assetFamily, AssetFamilyCatalog assetFamily,
AssetStudioGlyphSpecialization glyphSpecialization,
Path assetRoot, Path assetRoot,
boolean preload, boolean preload,
boolean hasDiagnostics) { boolean hasDiagnostics) {
@ -23,6 +25,7 @@ public record AssetWorkspaceAssetSummary(
Objects.requireNonNull(state, "state"); Objects.requireNonNull(state, "state");
Objects.requireNonNull(buildParticipation, "buildParticipation"); Objects.requireNonNull(buildParticipation, "buildParticipation");
assetFamily = Objects.requireNonNullElse(assetFamily, AssetFamilyCatalog.UNKNOWN); assetFamily = Objects.requireNonNullElse(assetFamily, AssetFamilyCatalog.UNKNOWN);
glyphSpecialization = Objects.requireNonNullElse(glyphSpecialization, AssetStudioGlyphSpecialization.NONE);
assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize();
if (assetName.isBlank()) { if (assetName.isBlank()) {
throw new IllegalArgumentException("assetName must not be blank"); 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.projects.ProjectReference;
import p.studio.utilities.i18n.I18n; import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.assets.details.AssetDetailsUiSupport; 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.File;
import java.io.IOException; import java.io.IOException;
@ -47,9 +49,11 @@ public final class AddAssetWizard {
private final TextField assetRootField = new TextField(); private final TextField assetRootField = new TextField();
private final TextField assetNameField = new TextField(); private final TextField assetNameField = new TextField();
private final ComboBox<AssetFamilyCatalog> assetFamilyCombo = new ComboBox<>(); private final ComboBox<AssetFamilyCatalog> assetFamilyCombo = new ComboBox<>();
private final ComboBox<AssetStudioGlyphSpecialization> glyphSpecializationCombo = new ComboBox<>();
private final ComboBox<OutputFormatCatalog> outputFormatCombo = new ComboBox<>(); private final ComboBox<OutputFormatCatalog> outputFormatCombo = new ComboBox<>();
private final ComboBox<OutputCodecCatalog> outputCodecCombo = new ComboBox<>(); private final ComboBox<OutputCodecCatalog> outputCodecCombo = new ComboBox<>();
private final CheckBox preloadCheckBox = new CheckBox(); private final CheckBox preloadCheckBox = new CheckBox();
private final AssetStudioMetadataService studioMetadataService = new AssetStudioMetadataService();
private int stepIndex; private int stepIndex;
private boolean creating; private boolean creating;
@ -65,6 +69,7 @@ public final class AddAssetWizard {
preloadCheckBox.setSelected(false); preloadCheckBox.setSelected(false);
configureAssetFamilyCombo(); configureAssetFamilyCombo();
configureGlyphSpecializationCombo();
configureOutputFormatCombo(); configureOutputFormatCombo();
configureOutputCodecCombo(); configureOutputCodecCombo();
renderStep(); renderStep();
@ -131,7 +136,34 @@ public final class AddAssetWizard {
}); });
assetFamilyCombo.valueProperty().addListener((ignored, oldValue, newValue) -> { assetFamilyCombo.valueProperty().addListener((ignored, oldValue, newValue) -> {
if (!Objects.equals(oldValue, newValue)) { if (!Objects.equals(oldValue, newValue)) {
if (newValue != AssetFamilyCatalog.GLYPH_BANK) {
glyphSpecializationCombo.getSelectionModel().select(AssetStudioGlyphSpecialization.NONE);
}
refreshOutputFormats(); 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 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 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 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 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 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)); final Label noteLabel = new Label(Container.i18n().text(I18n.ASSETS_ADD_WIZARD_NOTE));
noteLabel.setWrapText(true); noteLabel.setWrapText(true);
noteLabel.getStyleClass().add("studio-launcher-subtitle"); 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(""); preloadCheckBox.setText("");
stepBody.getChildren().setAll( stepBody.getChildren().setAll(
new VBox(6, nameLabel, assetNameField), new VBox(6, nameLabel, assetNameField),
new VBox(6, typeLabel, assetFamilyCombo), new VBox(6, typeLabel, assetFamilyCombo),
specializationBox,
new VBox(6, formatLabel, outputFormatCombo), new VBox(6, formatLabel, outputFormatCombo),
new VBox(6, codecLabel, outputCodecCombo), new VBox(6, codecLabel, outputCodecCombo),
new VBox(6, preloadLabel, preloadCheckBox), new VBox(6, preloadLabel, preloadCheckBox),
@ -360,6 +398,15 @@ public final class AddAssetWizard {
private void applyCreateResult(CreateAssetResult createResult) { private void applyCreateResult(CreateAssetResult createResult) {
creating = false; creating = false;
if (createResult.status() == PackerOperationStatus.SUCCESS && createResult.assetReference() != null) { 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()); result.set(createResult.assetReference());
stage.close(); stage.close();
return; return;
@ -409,6 +456,17 @@ public final class AddAssetWizard {
return outputCodecCombo.getValue(); 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) { private String normalizedRelativeRoot(String candidate) {
final String raw = Objects.requireNonNullElse(candidate, "").trim().replace('\\', '/'); final String raw = Objects.requireNonNullElse(candidate, "").trim().replace('\\', '/');
if (raw.isBlank()) { if (raw.isBlank()) {

View File

@ -144,6 +144,10 @@ assets.section.actions=Actions
assets.actions.empty=No actions available for this asset. assets.actions.empty=No actions available for this asset.
assets.action.register=Register assets.action.register=Register
assets.action.analyse=Analyse 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.action.delete=Delete
assets.deleteDialog.title=Delete Asset assets.deleteDialog.title=Delete Asset
assets.deleteDialog.description=Type the asset name below to confirm deletion of {0}. 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.buildParticipation=Build Participation
assets.label.assetId=Asset ID assets.label.assetId=Asset ID
assets.label.type=Type 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.glyphBank=Glyph Bank
assets.type.sceneBank=Scene Bank
assets.type.paletteBank=Palette Bank assets.type.paletteBank=Palette Bank
assets.type.soundBank=Sound Bank assets.type.soundBank=Sound Bank
assets.type.unknown=Unknown 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.location=Location
assets.label.bank=Bank assets.label.bank=Bank
assets.label.targetLocation=Target Location 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.name=Asset Name
assets.addWizard.label.root=Asset Root assets.addWizard.label.root=Asset Root
assets.addWizard.label.type=Asset Type assets.addWizard.label.type=Asset Type
assets.addWizard.label.specialization=Studio Specialization
assets.addWizard.label.format=Output Format assets.addWizard.label.format=Output Format
assets.addWizard.label.codec=Output Codec assets.addWizard.label.codec=Output Codec
assets.addWizard.label.preload=Preload on startup assets.addWizard.label.preload=Preload on startup
assets.addWizard.prompt.type=Choose asset type assets.addWizard.prompt.type=Choose asset type
assets.addWizard.prompt.specialization=Choose Studio specialization
assets.addWizard.prompt.format=Choose output format assets.addWizard.prompt.format=Choose output format
assets.addWizard.prompt.codec=Choose output codec assets.addWizard.prompt.codec=Choose output codec
assets.addWizard.assetsRootHint=Assets root: {0} 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.OutputCodecCatalog;
import p.packer.messages.assets.OutputFormatCatalog; import p.packer.messages.assets.OutputFormatCatalog;
import p.packer.messages.assets.PackerCodecConfigurationFieldType; import p.packer.messages.assets.PackerCodecConfigurationFieldType;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization;
import p.studio.workspaces.assets.messages.*; import p.studio.workspaces.assets.messages.*;
import java.nio.file.Path; import java.nio.file.Path;
@ -114,6 +115,8 @@ final class AssetDetailsBankCompositionCoordinatorTest {
List.of(), List.of(),
0L), 0L),
List.of(), List.of(),
null,
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of()); List.of());
} }
@ -136,6 +139,8 @@ final class AssetDetailsBankCompositionCoordinatorTest {
List.of(), List.of(),
0L), 0L),
List.of(), List.of(),
null,
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of()); List.of());
} }
@ -159,6 +164,7 @@ final class AssetDetailsBankCompositionCoordinatorTest {
AssetWorkspaceBuildParticipation.INCLUDED, AssetWorkspaceBuildParticipation.INCLUDED,
1, 1,
family, family,
AssetStudioGlyphSpecialization.NONE,
Path.of("/tmp/bank"), Path.of("/tmp/bank"),
false, false,
false); false);

View File

@ -5,6 +5,7 @@ import p.packer.messages.AssetReference;
import p.packer.messages.assets.AssetFamilyCatalog; import p.packer.messages.assets.AssetFamilyCatalog;
import p.packer.messages.assets.OutputCodecCatalog; import p.packer.messages.assets.OutputCodecCatalog;
import p.packer.messages.assets.OutputFormatCatalog; import p.packer.messages.assets.OutputFormatCatalog;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization;
import p.studio.workspaces.assets.messages.*; import p.studio.workspaces.assets.messages.*;
import java.nio.file.Path; import java.nio.file.Path;
@ -81,6 +82,7 @@ final class AssetDetailsPaletteOverhaulingCoordinatorTest {
AssetWorkspaceBuildParticipation.INCLUDED, AssetWorkspaceBuildParticipation.INCLUDED,
1, 1,
AssetFamilyCatalog.GLYPH_BANK, AssetFamilyCatalog.GLYPH_BANK,
AssetStudioGlyphSpecialization.NONE,
Path.of("/tmp/bank"), Path.of("/tmp/bank"),
false, false,
false), false),
@ -93,6 +95,8 @@ final class AssetDetailsPaletteOverhaulingCoordinatorTest {
Map.of(), Map.of(),
new AssetWorkspaceBankCompositionDetails(availableFiles, selectedFiles, 0L), new AssetWorkspaceBankCompositionDetails(availableFiles, selectedFiles, 0L),
selectedFiles.stream().map(file -> (Map<String, Object>) file.metadata().get("palette")).toList(), selectedFiles.stream().map(file -> (Map<String, Object>) file.metadata().get("palette")).toList(),
null,
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of()); 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, "schema_version" : 1,
"next_asset_id" : 16, "next_asset_id" : 17,
"assets" : [ { "assets" : [ {
"asset_id" : 3, "asset_id" : 3,
"asset_uuid" : "21953cb8-4101-4790-9e5e-d95f5fbc9b5a", "asset_uuid" : "21953cb8-4101-4790-9e5e-d95f5fbc9b5a",
@ -41,5 +41,10 @@
"asset_uuid" : "87396aab-337e-479e-b1f4-ec296678389e", "asset_uuid" : "87396aab-337e-479e-b1f4-ec296678389e",
"root" : "Zelda3", "root" : "Zelda3",
"included_in_build" : true "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,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<tileset version="1.10" tiledversion="1.12.1" name="primeiro tileset" tilewidth="32" tileheight="32" tilecount="13" columns="0">
<grid orientation="orthogonal" width="1" height="1"/>
<tile id="1">
<image source="flag00.png" width="32" height="32"/>
</tile>
<tile id="2">
<image source="flag01.png" width="32" height="32"/>
</tile>
<tile id="3">
<image source="flag02.png" width="32" height="32"/>
</tile>
<tile id="4">
<image source="flag03.png" width="32" height="32"/>
</tile>
<tile id="5">
<image source="link00.png" width="32" height="32"/>
</tile>
<tile id="6">
<image source="link01.png" width="32" height="32"/>
</tile>
<tile id="7">
<image source="link02.png" width="32" height="32"/>
</tile>
<tile id="8">
<image source="link03.png" width="32" height="32"/>
</tile>
<tile id="9">
<image source="link04.png" width="32" height="32"/>
</tile>
<tile id="10">
<image source="link05.png" width="32" height="32"/>
</tile>
<tile id="11">
<image source="link06.png" width="32" height="32"/>
</tile>
<tile id="12">
<image source="link07.png" width="32" height="32"/>
</tile>
<tile id="0">
<properties>
<property name="glyph_id" type="int" value="0"/>
</properties>
<image source="../recovered/atlas2/confirm.png" width="16" height="16"/>
<objectgroup draworder="index" id="2">
<object id="1" x="4" y="4" width="8" height="8"/>
<object id="2" x="1.59375" y="0.15625" width="2.75" height="2.90625"/>
</objectgroup>
</tile>
</tileset>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.1" orientation="orthogonal" renderorder="right-down" width="25" height="25" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="12"> <map version="1.10" tiledversion="1.12.1" orientation="orthogonal" renderorder="right-down" width="25" height="25" tilewidth="16" tileheight="16" infinite="0" nextlayerid="6" nextobjectid="20">
<tileset firstgid="1" source="primeiro tileset.tsx"/> <tileset firstgid="1" source="../Zelda3/primeiro tileset.tsx"/>
<layer id="1" name="Tile Layer 1" width="25" height="25"> <layer id="1" name="Tile Layer 1" width="25" height="25">
<data encoding="csv"> <data encoding="csv">
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
@ -32,5 +32,15 @@
</layer> </layer>
<objectgroup id="3" name="Object Layer 1"> <objectgroup id="3" name="Object Layer 1">
<object id="11" x="16" y="16" width="64" height="64"/> <object id="11" x="16" y="16" width="64" height="64"/>
<object id="12" x="179.608" y="48.6275">
<polygon points="0,0 1.17647,50.1961 46.2745,51.3725 44.7059,19.2157 12.9412,19.6078 12.549,-0.784314"/>
</object>
<object id="13" x="116.078" y="26.2745" width="34.902" height="39.2157"/>
<object id="14" x="96" y="96" width="52.2353" height="45.9608"/>
<object id="15" x="160" y="112" width="32" height="32"/>
<object id="16" x="16" y="96" width="64" height="32"/>
<object id="17" x="96" y="0" width="80" height="64"/>
<object id="18" x="153.725" y="92.1569"/>
<object id="19" x="98.0392" y="-48.2353" height="23.5294"/>
</objectgroup> </objectgroup>
</map> </map>

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<tileset version="1.10" tiledversion="1.12.1" name="primeiro tileset" tilewidth="16" tileheight="16" tilecount="1" columns="0">
<grid orientation="orthogonal" width="1" height="1"/>
<tile id="0">
<image source="../recovered/atlas2/confirm.png" width="16" height="16"/>
</tile>
</tileset>

View File

@ -1,14 +0,0 @@
{
"automappingRulesFile": "",
"commands": [
],
"compatibilityVersion": 1100,
"extensionsPath": "extensions",
"folders": [
"."
],
"properties": [
],
"propertyTypes": [
]
}

View File

@ -1,38 +0,0 @@
{
"activeFile": "primeiro tileset.tsx",
"expandedProjectPaths": [
"."
],
"fileStates": {
"primeiro mapa.tmx": {
"scale": 2.55,
"selectedLayer": 1,
"viewCenter": {
"x": 200,
"y": 200.58823529411768
}
},
"primeiro tileset.tsx": {
"dynamicWrapping": true,
"scaleInDock": 1,
"scaleInEditor": 1
}
},
"last.imagePath": "/Users/niltonconstantino/personal/workspace.personal/intrepid/prometeu/studio/test-projects/main/assets/recovered/atlas2",
"map.height": 25,
"map.lastUsedFormat": "tmx",
"map.tileHeight": 16,
"map.tileWidth": 16,
"map.width": 25,
"openFiles": [
"primeiro mapa.tmx",
"primeiro tileset.tsx"
],
"project": "scene1.tiled-project",
"recentFiles": [
"primeiro mapa.tmx",
"primeiro tileset.tsx"
],
"tileset.lastUsedFormat": "tsx",
"tileset.type": 1
}

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"
} ]
}