Merge pull request 'dev/studio-tiled-parser-assets-scene-asset-type' (#6) from dev/studio-tiled-parser-assets-scene-asset-type into master
All checks were successful
JaCoCo Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 58.63% (16555/28235) * Branch Coverage: 51.34% (6336/12341) * Lines of Code: 28235 * Cyclomatic Complexity: 11205 #### Quality Gates Summary Output truncated.
Test / Build skipped: 11, passed: 585
Intrepid/Prometeu/Studio/pipeline/head This commit looks good

Reviewed-on: #6
This commit is contained in:
bquarkz 2026-04-23 17:25:24 +00:00
commit 5eb8749a90
125 changed files with 8247 additions and 518 deletions

View File

@ -1,19 +1,19 @@
{"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":29,"PLN":59,"LSN":46,"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-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":"done","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-23","tags":["studio","assets","scene","tiled","parser","asset-type"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0045","file":"discussion/lessons/DSC-0028-studio-tiled-parser-assets-scene-asset-type/LSN-0045-asset-owned-scene-bank-and-tiled-xml-workflow.md","status":"done","created_at":"2026-04-23","updated_at":"2026-04-23"}]}
{"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-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-0024","status":"done","ticket":"jacoco-reports-consolidation","title":"JaCoCo Reports Consolidation in Gradle","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["infra","gradle","jacoco","coverage","jenkins"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0038","file":"discussion/lessons/DSC-0024-jacoco-reports-consolidation/LSN-0038-jacoco-reports-consolidation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}
{"type":"discussion","id":"DSC-0024","status":"done","ticket":"jacoco-reports-consolidation","title":"JaCoCo Reports Consolidation in Gradle","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["infra","gradle","jacoco","coverage","jenkins"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0038","file":"discussion/lessons/DSC-0024-jacoco-reports-consolidation/LSN-0038-jacoco-reports-consolidation.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"},{"id":"LSN-0044","file":"discussion/lessons/DSC-0024-jacoco-reports-consolidation/LSN-0044-jenkins-jacoco-alignment.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}
{"type":"discussion","id":"DSC-0001","status":"done","ticket":"studio-docs-import","title":"Import docs/studio into discussion-framework artifacts","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["studio","migration","discussion-framework","docs-import"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0001","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0001-assets-workspace-execution-wave-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0002","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0002-bank-composition-editor-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0003","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0003-mental-model-asset-mutations-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0004","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0004-mental-model-assets-workspace-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0005","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0005-mental-model-studio-events-and-components-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0006","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0006-mental-model-studio-shell-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0007","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0007-pack-wizard-shell-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0008","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0008-project-scoped-state-and-activity-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0016","file":"discussion/lessons/DSC-0001-studio-docs-import/LSN-0016-studio-docs-import-pattern.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"}]}
{"type":"discussion","id":"DSC-0002","status":"open","ticket":"palette-management-in-studio","title":"Palette Management in Studio","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["studio","legacy-import","palette-management","tile-bank","packer-boundary"],"agendas":[{"id":"AGD-0002","file":"AGD-0002-palette-management-in-studio.md","status":"open","created_at":"2026-03-26","updated_at":"2026-03-26"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0002","status":"done","ticket":"palette-management-in-studio","title":"Palette Management in Studio","created_at":"2026-03-26","updated_at":"2026-04-23","tags":["studio","legacy-import","palette-management","tile-bank","packer-boundary"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0042","file":"discussion/lessons/DSC-0002-palette-management-in-studio/LSN-0042-schema-driven-palette-authoring-and-local-metadata-events.md","status":"done","created_at":"2026-04-23","updated_at":"2026-04-23"}]}
{"type":"discussion","id":"DSC-0003","status":"done","ticket":"packer-docs-import","title":"Import docs/packer into discussion-framework artifacts","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["packer","migration","discussion-framework","docs-import"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0009","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0009-mental-model-packer-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0010","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0010-asset-identity-and-runtime-contract-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0011","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0011-foundations-workspace-runtime-and-build-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0012","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0012-runtime-ownership-and-studio-boundary-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0013","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0013-metadata-convergence-and-runtime-sink-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0014","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0014-pack-wizard-summary-validation-and-pack-execution-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0015","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0015-tile-bank-packing-contract-legacy.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"},{"id":"LSN-0017","file":"discussion/lessons/DSC-0003-packer-docs-import/LSN-0017-packer-docs-import-pattern.md","status":"done","created_at":"2026-03-26","updated_at":"2026-03-26"}]}
{"type":"discussion","id":"DSC-0004","status":"open","ticket":"tilemap-and-metatile-runtime-binary-layout","title":"Tilemap and Metatile Runtime Binary Layout","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["packer","legacy-import","tilemap","metatile","runtime-layout"],"agendas":[{"id":"AGD-0004","file":"AGD-0004-tilemap-and-metatile-runtime-binary-layout.md","status":"open","created_at":"2026-03-26","updated_at":"2026-03-26"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0005","status":"open","ticket":"variable-tile-bank-palette-serialization","title":"Variable Tile Bank Palette Serialization","created_at":"2026-03-26","updated_at":"2026-03-26","tags":["packer","legacy-import","tile-bank","palette-serialization","versioning"],"agendas":[{"id":"AGD-0005","file":"AGD-0005-variable-tile-bank-palette-serialization.md","status":"open","created_at":"2026-03-26","updated_at":"2026-03-26"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0006","status":"done","ticket":"pbs-game-facing-asset-refs-and-call-result-discard","title":"PBS Game-Facing Asset References and Ignored Call Result Lowering","created_at":"2026-03-27","updated_at":"2026-03-30","tags":["compiler","pbs","ergonomics","lowering","runtime","asset-identity","expression-statements"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0024","file":"discussion/lessons/DSC-0006-pbs-game-facing-asset-refs-and-call-result-discard/LSN-0024-addressable-surface-host-metadata-and-ignored-value-discipline.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30"}]}
{"type":"discussion","id":"DSC-0007","status":"done","ticket":"pbs-learn-to-discussion-lessons-migration","title":"Migrate PBS Learn Documents into Discussion Lessons","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["compiler","pbs","migration","discussion-framework","lessons","learn-prune"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0018","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0018-pbs-ast-and-parser-contract-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0019","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0019-pbs-name-resolution-and-linking-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0020","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0020-pbs-runtime-values-identity-memory-boundaries-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0021","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0021-pbs-diagnostics-and-conformance-governance-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"},{"id":"LSN-0022","file":"discussion/lessons/DSC-0007-pbs-learn-to-discussion-lessons-migration/LSN-0022-pbs-globals-lifecycle-and-published-entrypoint-legacy.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
{"type":"discussion","id":"DSC-0008","status":"done","ticket":"pbs-low-level-asset-manager-surface","title":"PBS Low-Level Asset Manager Surface for Runtime AssetManager","created_at":"2026-03-27","updated_at":"2026-03-27","tags":["compiler","pbs","runtime","asset-manager","host-abi","stdlib","asset"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0023","file":"discussion/lessons/DSC-0008-pbs-low-level-asset-manager-surface/LSN-0023-lowassets-runtime-aligned-sdk-surface.md","status":"done","created_at":"2026-03-27","updated_at":"2026-03-27"}]}
{"type":"discussion","id":"DSC-0009","status":"done","ticket":"studio-debugger-workspace-integration","title":"Integrate ../debugger into Studio as a dedicated workspace","created_at":"2026-03-30","updated_at":"2026-04-07","tags":["studio","debugger","workspace","integration","shell"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0022","file":"discussion/lessons/DSC-0022-studio-play-stop-and-debugger-integration/LSN-0022-studio-play-stop-and-debugger-integration.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}
{"type":"discussion","id":"DSC-0009","status":"done","ticket":"studio-debugger-workspace-integration","title":"Integrate ../debugger into Studio as a dedicated workspace","created_at":"2026-03-30","updated_at":"2026-04-07","tags":["studio","debugger","workspace","integration","shell"],"agendas":[],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0010","status":"done","ticket":"studio-code-editor-workspace-foundations","title":"Establish Code Editor workspace foundations in Studio without LSP","created_at":"2026-03-30","updated_at":"2026-03-31","tags":["studio","editor","workspace","multi-frontend","lsp-deferred"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0026","file":"discussion/lessons/DSC-0010-studio-code-editor-workspace-foundations/LSN-0026-read-only-editor-foundations-and-semantic-deferral.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31"}]}
{"type":"discussion","id":"DSC-0011","status":"done","ticket":"compiler-analyze-compile-build-pipeline-split","title":"Split compiler pipeline into analyze, compile, and build entrypoints","created_at":"2026-03-30","updated_at":"2026-03-30","tags":["compiler","pipeline","artifacts","build","analysis"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0025","file":"discussion/lessons/DSC-0011-compiler-analyze-compile-build-pipeline-split/LSN-0025-compiler-pipeline-entrypoints-and-result-boundaries.md","status":"done","created_at":"2026-03-30","updated_at":"2026-03-30"}]}
{"type":"discussion","id":"DSC-0012","status":"done","ticket":"studio-editor-document-vfs-boundary","title":"Definir um boundary de VFS documental para tree/view/open files no Code Editor do Studio","created_at":"2026-03-31","updated_at":"2026-03-31","tags":["studio","editor","workspace","vfs","filesystem","boundary"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0027","file":"discussion/lessons/DSC-0012-studio-editor-document-vfs-boundary/LSN-0027-project-document-vfs-and-session-owned-editor-boundary.md","status":"done","created_at":"2026-03-31","updated_at":"2026-03-31"}]}
@ -26,5 +26,5 @@
{"type":"discussion","id":"DSC-0019","status":"done","ticket":"studio-project-local-setup-separate-from-main-studio-state","title":"Separate project-local setup from the main Studio state under .studio","created_at":"2026-04-04","updated_at":"2026-04-04","tags":["studio","project-session","project-state","persistence","dot-studio","setup","boundary"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0032","file":"discussion/lessons/DSC-0019-studio-project-local-setup-separate-from-main-studio-state/LSN-0032-separate-project-config-from-session-restoration-state.md","status":"done","created_at":"2026-04-04","updated_at":"2026-04-04"}]}
{"type":"discussion","id":"DSC-0020","status":"done","ticket":"studio-editor-indentation-policy-and-project-setup","title":"Indentation Policy, Status-Bar Semantics, and Project-Local Editor Setup","created_at":"2026-04-04","updated_at":"2026-04-04","tags":["studio","editor","indentation","tabs","setup","dot-studio","configuration","ux"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0033","file":"discussion/lessons/DSC-0020-studio-editor-indentation-policy-and-project-setup/LSN-0033-setup-owned-indentation-policy-and-project-bootstrap-defaults.md","status":"done","created_at":"2026-04-04","updated_at":"2026-04-04"}]}
{"type":"discussion","id":"DSC-0021","status":"done","ticket":"studio-frontend-editor-write-and-save-wave","title":"Enable Frontend Editing and Save in the Studio Code Editor","created_at":"2026-04-04","updated_at":"2026-04-06","tags":["studio","editor","frontend","write","save","vfs","lsp","pbs","access-policy"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0036","file":"discussion/lessons/DSC-0021-studio-frontend-editor-write-and-save-wave/LSN-0036-vfs-owned-frontend-edit-and-save-wave.md","status":"done","created_at":"2026-04-06","updated_at":"2026-04-06"}]}
{"type":"discussion","id":"DSC-0022","status":"done","ticket":"studio-play-stop-cartridge-flow","title":"Wire the Studio Play/Stop button to shipper phase 1 cartridge preparation and runtime execution","created_at":"2026-04-06","updated_at":"2026-04-07","tags":["studio","play-stop","shipper","runtime","cartridge","shell"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0022","file":"discussion/lessons/DSC-0022-studio-play-stop-and-debugger-integration/LSN-0022-studio-play-stop-and-debugger-integration.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}
{"type":"discussion","id":"DSC-0022","status":"done","ticket":"studio-play-stop-cartridge-flow","title":"Wire the Studio Play/Stop button to shipper phase 1 cartridge preparation and runtime execution","created_at":"2026-04-06","updated_at":"2026-04-07","tags":["studio","play-stop","shipper","runtime","cartridge","shell"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0043","file":"discussion/lessons/DSC-0022-studio-play-stop-and-debugger-integration/LSN-0043-studio-play-stop-and-debugger-integration.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}
{"type":"discussion","id":"DSC-0023","status":"done","ticket":"gradle-version-catalog-migration","title":"Move dependency version management to Version Catalog (TOML)","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["gradle","buildSrc","dependency-management","version-catalog"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0037","file":"discussion/lessons/DSC-0023-gradle-version-catalog-migration/LSN-0037-gradle-version-catalog-adoption.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}

View File

@ -0,0 +1,106 @@
---
id: LSN-0042
ticket: palette-management-in-studio
title: Schema-Driven Palette Authoring and Local Metadata Events
created: 2026-04-23
tags:
- studio
- glyph-bank
- palette-management
- color-schema
- local-events
---
## Context
The glyph-bank palette workflow originally treated palette candidates as extracted tile artifacts. That made grouping, recoloring, and final bank palette selection compete in the same surface.
The completed palette-management work split the workflow into two authored layers:
1. `Color Schema Groups`, where selected tiles are grouped into a shared index structure.
2. `Palette Overhauling`, where palettes are authored and selected against already-ready schemas.
The durable implementation lesson is that schema edits, palette edits, and build selection are all local glyph-material metadata operations first. They should update local metadata projections directly instead of forcing a full asset-details reload.
## Key Decisions
### Schema Owns Index Semantics
**What:** A schema is the authored index structure for a group of selected glyph-bank tiles. It owns `material_indices`, artifact bindings, fingerprint snapshots, version, and status.
**Why:** The source PNG stays unchanged, while Studio records how tile colors map into stable bank-local indices. This keeps tile grouping separate from recoloring.
**Trade-offs:** Schema invalidation must be explicit. When selected tile fingerprints drift, the schema can become inconsistent and dependent palettes become invalid or stale.
### Palettes Are Schema-Scoped
**What:** A palette always belongs to exactly one schema. It stores concrete `#RRGGBB` colors for that schema's visible indices and carries the schema version it was authored against.
**Why:** Multiple recolors can safely exist for one index structure without duplicating tile grouping metadata.
**Trade-offs:** Duplicate detection must use the tuple `schemaId + colors + order`, not display name or source tile identity.
### Original Palettes Are Synthetic and Read-Only
**What:** The original palette is the synthetic `schema-<schemaId>-original` projection derived from the current schema colors. It is not a persisted user palette.
**Why:** Updating the schema should update the original palette automatically. The original represents the schema's source color meaning, not a user-authored recolor.
**Trade-offs:** Users cannot edit or delete the original palette directly. If they want a changed palette, they create or edit a separate persisted palette for the same schema.
### Local Metadata Events Beat Global Reloads
**What:** Schema and palette metadata mutations should publish `StudioGlyphMaterialMetadataChangedEvent` and let interested sections rebind local state.
**Why:** Global asset refresh and selection events can close edit mode, reset form state, and make small metadata edits feel destructive.
**Trade-offs:** Controls must keep their local coordinators robust: stale button handlers should read the current view model at click time, and metadata replacement must preserve edit-mode drafts where possible.
## Patterns and Algorithms
### `glyph-material.json`
`glyph-material.json` is the Studio-owned support file for glyph-bank material authoring. It persists schemas and user-authored palettes, while derived original palettes remain a view projection.
Schema records store:
- stable `schema_id`
- `status`
- `version`
- dense visible `material_indices`
- artifact bindings with fingerprint snapshots
Palette records store:
- stable `palette_id`
- persisted `name`
- parent `schema_id`
- schema `version`
- concrete `colors`
### Palette Names
Palette names are persisted even when the user leaves the name field blank. The blank-name path resolves to a deterministic, simple generated name such as `snow white`, `fog twist`, or `ember bloom`.
The generated name is a convenience label only. Palette identity remains `palette_id`, and duplicate detection remains schema plus ordered colors.
### Original Palette Projection
Every ready schema projects a synthetic original palette from the schema's current `material_indices`.
This projection must always exist in the palette view, even if a user-authored palette has the same colors. A saved palette with the same schema and colors is still user-authored and can be deleted.
## Pitfalls
- Do not treat same colors as proof that a palette is original. Original means "synthetic schema projection", not "matches current schema colors".
- Do not publish global asset refresh events for local schema or palette metadata changes. Use local metadata events unless pack/runtime state truly changed.
- Do not let button handlers capture stale preview objects. Fetch the current coordinator view model at click time for destructive actions.
- Do not let blank palette names remain blank on disk. Resolve the generated name before persisting.
- Do not open the palette wizard for synthetic original palettes. They are read-only projections.
## Takeaways
- Schema formation and palette authoring are separate lifecycle stages.
- Original palettes are derived views, not persisted editable records.
- User-created palettes remain editable and removable even when they share a schema or colors with an original.
- Local metadata events preserve form continuity better than full asset refreshes.

View File

@ -1,5 +1,5 @@
---
id: LSN-0022
id: LSN-0043
ticket: studio-play-stop-cartridge-flow
title: Studio Play/Stop and Debugger Workspace Integration
status: done

View File

@ -1,16 +0,0 @@
# LSN-0039: Jenkins + JaCoCo Integration with Multi-Module Gradle
- **Problem:** O Jenkinsfile estava desatualizado, tentando rodar uma task de cobertura inexistente (`testCodeCoverageReport`) em um subprojeto específico, ignorando a cobertura global e causando falhas no pipeline.
- **Decision:** Alinhar o `Jenkinsfile` com a task de agregação customizada no root project (`jacocoTestReport`), removendo a dependência de subprojetos específicos para métricas globais.
- **Implementation:**
- Atualizado o comando de execução no Jenkins: `./gradlew clean test jacocoTestReport`.
- Ajustado o `recordCoverage` para ler o XML consolidado em `build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml`.
- Ajustado o `publishHTML` para exibir o relatório consolidado em `build/reports/jacoco/jacocoTestReport/html`.
- **Example:**
- Antes: `SRS_APP/build/reports/...` (específico por app).
- Depois: `build/reports/...` (global do projeto).
- **Pitfalls:**
- Ao usar relatórios consolidados no root, deve-se garantir que o Gradle rode a task de agregação *após* os testes de todos os subprojetos.
- O Jenkins precisa de acesso ao workspace root para encontrar esses arquivos; caminhos relativos no `Jenkinsfile` geralmente partem do root do checkout.
- **References:**
- `DSC-0024`, `DEC-0025`, `PLN-0048`.

View File

@ -0,0 +1,45 @@
---
id: LSN-0044
ticket: jacoco-reports-consolidation
title: Jenkins Alignment with Consolidated JaCoCo Reports
created: 2026-04-07
tags:
- infra
- gradle
- jacoco
- jenkins
---
## Context
The Jenkins pipeline was still wired to an outdated coverage task and module-specific report paths. That made CI brittle after the project moved coverage reporting to the root-level consolidated `jacocoTestReport` task.
## Key Decisions
### Jenkins Reads Root-Level Coverage
**What:** Jenkins should run `./gradlew clean test jacocoTestReport` and publish coverage from the root project report directory.
**Why:** Coverage is aggregated across modules, so CI should not depend on a single application subproject path.
**Trade-offs:** The root task must run after all relevant tests. Jenkins paths must be rooted at the checkout workspace rather than a submodule directory.
## Patterns and Algorithms
The canonical report locations are:
- XML: `build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml`
- HTML: `build/reports/jacoco/jacocoTestReport/html`
Jenkins `recordCoverage` should consume the XML report. Jenkins `publishHTML` should expose the HTML directory.
## Pitfalls
- Do not point Jenkins at `SRS_APP/build/reports/...` or another module-local report when the project expects consolidated root coverage.
- Do not run only the reporting task without ensuring tests have executed first.
- Do not assume Jenkins relative paths start inside a Gradle subproject; treat the checkout root as the base.
## Takeaways
- Root-level coverage aggregation needs root-level CI report paths.
- The Jenkinsfile and Gradle coverage task names must move together.

View File

@ -0,0 +1,103 @@
---
id: LSN-0045
ticket: studio-tiled-parser-assets-scene-asset-type
title: Asset-Owned Scene Bank and Tiled XML Workflow
created: 2026-04-23
tags:
- studio
- assets
- scene-bank
- tiled
- tmx
- tsx
---
## Context
The abandoned scene-workspace direction was replaced by an asset-first model. Scene authoring and validation now belong to the `Assets` workspace, while heavy map editing remains external in Tiled.
The final model keeps runtime asset families disciplined:
- `Scene Bank` is the scene-facing asset managed by Studio.
- `Glyph Bank` remains the base family for tilesets.
- `Tileset`, `Sprites`, and `UI` are Studio-only glyph-bank specializations, not new runtime families.
## Key Decisions
### Scene Banks Are Assets, Not Workspaces
**What:** Scene-bank lifecycle is owned by the `Assets` workspace: creation, metadata, layer configuration, validation, acceptance, and diagnostics.
**Why:** The product direction explicitly discarded a dedicated scene workspace. Asset ownership keeps scene work aligned with existing asset actions, summaries, diagnostics, and refresh flows.
**Trade-offs:** Studio does not provide a heavy map editor in this wave. Users edit `TMX` and `TSX` externally in Tiled, then return to Studio for validation and acceptance.
### TMX and TSX Are the Wave-1 XML Surfaces
**What:** Wave 1 supports XML read/write for `TMX` map documents and `TSX` tileset documents.
**Why:** Tiled is the interoperability surface. Keeping `TMX` as the maintained scene document avoids inventing a parallel normalized scene schema too early.
**Trade-offs:** Unsupported Tiled features must fail explicitly rather than being silently normalized away.
### Tileset Specialization Stays Studio-Only
**What:** A `Glyph Bank / Tileset` specialization generates the Tiled-facing `TSX` document for that glyph bank.
**Why:** Tileset generation is editorial Studio behavior over a glyph bank. It should not leak into runtime family identity.
**Trade-offs:** The asset summary must show both the base type and specialization so users understand the role without confusing it for a new runtime asset family.
### Scene Validation Requires Explicit Acceptance
**What:** A scene bank is not ready merely because files exist. Studio validates the current `TMX`/`TSX` state and requires explicit acceptance of the converged result.
**Why:** External editing can change files behind Studio's back. Acceptance makes the boundary deliberate and auditable.
**Trade-offs:** Users must perform an extra validation/accept step after external edits, but Studio avoids silently blessing invalid or unsupported scene state.
## Patterns and Algorithms
### Scene-Bank Support Metadata
Scene-bank support metadata lives with the scene asset and records the wave-1 layer model:
- fixed `1..4` scene layers;
- per-layer enabled state;
- per-layer `tilemap` filename;
- per-layer linked `tileset_asset_root`;
- map dimensions and explicit layer-to-tilemap binding.
This support file is the Studio-owned convention that lets a scene bank contain multiple maintained `TMX` documents while preserving deterministic layer mapping.
### Tiled File Generation
`Tileset` glyph banks generate `TSX` files. Scene banks generate or maintain layer-specific `TMX` files that reference those generated `TSX` files.
Each wave-1 tilemap references exactly one generated `TSX`. Multi-tileset maps remain outside this wave.
### Validation and Acceptance Files
Scene validation records the inspected file state and diagnostics. Acceptance records the validated state that Studio has explicitly approved.
This creates a useful external-edit loop:
1. generate or edit `TMX`/`TSX`;
2. validate in Studio;
3. inspect diagnostics;
4. accept only after validation succeeds.
## Pitfalls
- Do not reintroduce a dedicated scene workspace for wave 1.
- Do not turn `Tileset` specialization into a runtime asset family.
- Do not silently auto-accept external Tiled edits after refresh.
- Do not treat `TMX`/`TSX` generation as pack publication. Runtime scene packing remains a separate later concern.
- Do not allow unsupported Tiled constructs to disappear quietly. They must surface as diagnostics.
## Takeaways
- Scene Bank belongs in Assets, not in a separate authoring workspace.
- `TMX` remains the maintained scene document for wave 1.
- `TSX` generation belongs to `Glyph Bank / Tileset`.
- Validation plus explicit acceptance is the core safety gate for external Tiled editing.

View File

@ -1,116 +0,0 @@
---
id: AGD-0002
ticket: palette-management-in-studio
title: Palette Management in Studio
status: open
created: 2026-03-26
resolved:
decision:
tags:
- studio
- legacy-import
- palette-management
- tile-bank
- packer-boundary
---
## Pain
Studio already has a retained agenda for palette management in the `glyph bank` form session, but that agenda exists only under `docs/studio/agendas/` and therefore sits outside the discussion-framework lifecycle.
Without importing it into `discussion/`, the canonical discussion surface would lose the still-open Studio question about:
- how candidate palettes are presented;
- how `1..64` palette capacity is enforced and explained;
- how preview and selection behave inside the asset workflow;
- how Studio UX stays separate from packer-owned extraction, validation, identity, and persistence.
## Context
Legacy source: `docs/studio/agendas/Palette Management in Studio Agenda.md`
Domain owner: `docs/studio`
Cross-domain impact: `docs/packer`
The retained agenda is intentionally scoped to `glyph bank`.
The imported context keeps the same baseline:
- every `*.png` input of a glyph bank is one tile;
- each tile yields one extracted palette during cache construction;
- developers may also work with favorite palettes and asset palettes;
- each bank may integrate at most `64` palettes;
- each palette contains `16` colors with a default color-key baseline;
- Studio owns curation UX, while packer remains authoritative for extraction, identity, validation, and persistence.
The current UI shape already converged on:
- a left-side transfer list for candidate and selected palettes;
- custom palette rendering with swatches and source indicators;
- a right-side preview panel with tile/file selector, tile preview, and applied palette strip.
## Open Questions
- [ ] How should Studio detect and present duplicate or near-equivalent palettes across tile-derived, favorite, and asset-level sources?
- [ ] Are favorites project-scoped references to existing palettes, or can they diverge into editable copies before bank admission?
- [ ] What is the canonical user-facing identity of a candidate palette in Studio: source bucket plus logical name, file path, extraction origin tile, stable id, or a combination?
- [ ] Should extracted tile palettes always enter the candidate set automatically, or can the developer suppress some of them before curation?
- [ ] Should clicking a palette on the `selected` side be the only interaction needed to change the previewed palette in the first wave?
- [ ] Which palette operations are read-only in the first wave, and which require staged preview/apply treatment?
- [ ] Which parts of this interaction model should remain explicitly `glyph bank`-specific, and which parts are reusable Studio form-session primitives?
- [ ] Does the first release need only an asset-scoped `glyph bank` section, or also a project-level palette browser for discovery and favorites management?
## Options
### Option A - Automatic Integration
- **Approach:** Treat extracted tile palettes as implicit inputs and integrate them automatically without explicit user curation.
- **Pro:** Lowest immediate UX cost.
- **Con:** Hides palette-budget reasoning, weakens deliberate composition, and makes source distinctions harder to understand.
- **Maintainability:** Weak. It pushes important bank rules into implicit behavior.
### Option B - Asset-Scoped Curated Selection
- **Approach:** Treat bank palettes as an asset-scoped curated selection built from tile-derived palettes, favorites, and asset palettes, with Studio owning the curation UX and packer owning persistence.
- **Pro:** Matches the concrete `glyph bank` workflow and keeps authority boundaries clear.
- **Con:** Requires explicit UX for capacity, identity, deduplication, and preview semantics.
- **Maintainability:** Strong. It makes responsibilities explicit and keeps family-specific rules close to the workflow that needs them.
### Option C - Studio-Owned Palette Library
- **Approach:** Treat palettes as a broader Studio-owned library/workspace with direct local editing and persistence behavior.
- **Pro:** Potentially flexible for future palette-heavy features.
- **Con:** Duplicates packer ownership and risks creating a second authority for bank composition.
- **Maintainability:** Weak. It expands Studio responsibility before the persistence contract is closed.
## Discussion
The imported agenda keeps the original recommendation intact: palette management should be first-class for `glyph bank`, but Studio must not become the persistence authority.
That means the key open problem is not whether palettes exist. It is how Studio explains and curates them safely:
- candidate aggregation from multiple sources;
- clear source and identity display;
- explicit `1..64` capacity feedback;
- a preview model that stays tied to the selected bank palettes;
- a boundary between `glyph bank`-specific UX and reusable Studio primitives.
The retained source already converged on a pragmatic first wave:
- transfer-list selection on the left;
- palette-focused preview on the right;
- first selected palette as default preview;
- clicking on a selected palette changes the previewed palette;
- removing the current preview palette falls back to the first remaining selected palette.
## Resolution
Recommended direction: adopt **Option B**.
The imported agenda preserves the retained Studio direction:
1. palette management belongs to the `glyph bank` asset workflow;
2. Studio aggregates candidate palettes from tile-derived extraction, favorites, and asset palettes;
3. the first wave uses a custom-rendered transfer list with an explicit `1..64` selected range;
4. the right-side preview panel stays tied to the current selected palette set;
5. packer remains authoritative for extraction, identity, validation, and persistence;
6. broader standalone palette-authoring workflows remain deferred.
Next step suggestion: convert this imported agenda into a decision that closes the ownership boundary, candidate-source model, first-wave transfer-list UX, preview behavior, and reusable-vs-family-specific primitive boundary.

View File

@ -1,115 +0,0 @@
---
id: AGD-0030
ticket: studio-tiled-parser-assets-scene-asset-type
title: Tiled Parser and Scene Asset-Type Ownership in Assets Workspace
status: open
created: 2026-04-15
resolved:
decision:
tags:
- studio
- assets
- scene
- tiled
- parser
- asset-type
---
## Pain
The current abandoned direction assumed a dedicated `Scene Workspace` as the primary authoring owner for scenes.
That no longer matches the intended product direction.
Without a new direction:
- the Studio has no agreed path for scene authoring;
- the `Assets` workspace still lacks a defined ownership model for scene-like content;
- there is no agreed contract for importing or interpreting `Tiled` data;
- implementation risks rebuilding a custom scene editor model that the team has already decided to discard.
## Context
Domain owner: `studio`
Expected documentation owner:
- `docs/studio/specs`
Cross-domain impact:
- asset typing and creation flows in the `Assets` workspace;
- Studio-side parsing/import rules for `Tiled`;
- possible downstream alignment with runtime-facing `SCENE` publication, depending on the chosen import boundary.
User direction explicitly provided on 2026-04-15:
- abandon `DEC-0026` and its derived plans;
- do not proceed with a dedicated `Scene Workspace`;
- instead, write a parser for `Tiled`;
- create a new asset type managed from the `Assets` workspace.
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
- [ ] 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?
- [ ] Should `Assets` own only creation/import and metadata management, or also the main editing/reimport flow for this new asset type?
- [ ] How should tileset references inside `Tiled` map onto existing glyph-bank assets and asset identity rules in Studio?
- [ ] 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?
- [ ] What is the canonical failure model when a `Tiled` document references unsupported constructs or broken external assets?
## Options
### Option A - Import `Tiled` into a new Studio-owned scene asset type in `Assets`
- **Approach:** Introduce a new asset type in `Assets`; parse supported `Tiled` files into a Studio-owned asset model managed from that workspace.
- **Pro:** Aligns with the requested ownership shift, keeps authoring/import centered in `Assets`, and allows Studio to define a strict supported subset.
- **Con:** Requires defining a clean normalized asset contract and a mapping layer from `Tiled` concepts to Studio/runtime concepts.
- **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
- **Approach:** Keep `Tiled` as the primary authoring format and let Studio mostly import, validate, and publish from it.
- **Pro:** Minimizes Studio-owned scene-model invention and stays close to existing `Tiled` authoring workflows.
- **Con:** Couples Studio behavior heavily to `Tiled`'s full document model and makes partial support/error handling harder to reason about.
- **Maintainability:** Medium, because unsupported `Tiled` breadth can leak into every implementation phase.
### Option C - Create a new Studio asset type unrelated to `Tiled` and only offer one-time conversion
- **Approach:** Use `Tiled` only as an optional bootstrap/import tool, then move fully into a separate Studio-native asset format.
- **Pro:** Gives Studio maximum control over the final asset model and future editing behavior.
- **Con:** Moves away from the user's stated direction of writing a `Tiled` parser as a central path and may reintroduce the same custom-model burden the previous direction had.
- **Maintainability:** Medium, but only if the conversion boundary is very clearly justified.
## Discussion
The key architectural change here is not only "use `Tiled`".
It is also a change in ownership:
- scene-related authoring/import should no longer justify a dedicated `Scene Workspace`;
- 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.
That creates several decisions that should be made deliberately:
1. what exact `Tiled` contract Studio supports in wave 1;
2. whether Studio stores `Tiled` data directly or through a normalized asset form;
3. how glyph banks and tilesets map without creating identity ambiguity;
4. how much of `Tiled` fidelity is preserved versus normalized away;
5. whether editing is a reimport/update flow, a direct managed-asset flow, or some hybrid.
There is already one strong constraint from the user:
- the previous `Scene Workspace` split should be considered discarded;
- 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.
## Resolution
Recommended next step:
- converge this agenda around a first-wave `Tiled` subset;
- define whether the new asset type is raw-imported or normalized;
- then write a replacement decision for asset ownership, parser boundary, and wave-1 scope.

View File

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

View File

@ -5,6 +5,7 @@ import java.util.Locale;
public enum OutputFormatCatalog {
GLYPH_INDEXED_V1(AssetFamilyCatalog.GLYPH_BANK, "GLYPH/indexed_v1", "GLYPH/indexed_v1"),
SCENE_TILED_V1(AssetFamilyCatalog.SCENE_BANK, "SCENE/tilemap_v1", "SCENE/tilemap_v1"),
SOUND_V1(AssetFamilyCatalog.SOUND_BANK, "SOUND/v1", "SOUND/v1"),
UNKNOWN(AssetFamilyCatalog.UNKNOWN, "unknown", "Unknown");
@ -53,6 +54,9 @@ public enum OutputFormatCatalog {
return UNKNOWN;
}
final String normalized = manifestValue.trim().toLowerCase(Locale.ROOT);
if ("scene/tiled_v1".equals(normalized)) {
return SCENE_TILED_V1;
}
if ("sound/bank_v1".equals(normalized)) {
return SOUND_V1;
}

View File

@ -3,11 +3,15 @@ package p.packer.models;
import java.util.Objects;
public record PackerAssetArtifactSelection(
String artifactId,
String file,
int index) {
int index,
String fingerprint) {
public PackerAssetArtifactSelection {
artifactId = artifactId == null || artifactId.isBlank() ? null : artifactId.trim();
file = Objects.requireNonNull(file, "file").trim();
fingerprint = fingerprint == null || fingerprint.isBlank() ? null : fingerprint.trim();
if (file.isBlank()) {
throw new IllegalArgumentException("file must not be blank");
}

View File

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

View File

@ -8,6 +8,10 @@ import java.nio.file.Path;
import java.util.*;
public final class PackerRuntimeAssetMaterializer {
private static final Set<String> RUNTIME_RESERVED_FILE_NAMES = Set.of(
"asset.json",
"studio.asset.json");
private final PackerAssetWalker assetWalker;
public PackerRuntimeAssetMaterializer(PackerAssetWalker assetWalker) {
@ -114,7 +118,7 @@ public final class PackerRuntimeAssetMaterializer {
private List<String> listAvailableFiles(Path assetRoot) {
try (var paths = Files.list(assetRoot)
.filter(Files::isRegularFile)
.filter(path -> !path.getFileName().toString().equalsIgnoreCase("asset.json"))
.filter(path -> !isRuntimeReservedFile(path))
.map(path -> assetRoot.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/'))
.sorted(String.CASE_INSENSITIVE_ORDER)) {
return paths.toList();
@ -123,6 +127,10 @@ public final class PackerRuntimeAssetMaterializer {
}
}
private boolean isRuntimeReservedFile(Path path) {
return RUNTIME_RESERVED_FILE_NAMES.contains(path.getFileName().toString().toLowerCase(Locale.ROOT));
}
private PackerRuntimeWalkFile toRuntimeWalkFile(
Path assetRoot,
PackerProbeResult probeResult,

View File

@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import p.packer.PackerWorkspacePaths;
import p.packer.PackerWorkspaceService;
import p.packer.events.PackerEventKind;
@ -38,6 +39,7 @@ import java.util.stream.Stream;
public final class FileSystemPackerWorkspaceService implements PackerWorkspaceService {
private static final int GLYPH_BANK_COLOR_KEY_RGB565 = 0xF81F;
private static final String SCENE_BANK_SUPPORT_FILE = "scene-bank.studio.json";
private final ObjectMapper mapper;
private final PackerWorkspaceFoundation workspaceFoundation;
@ -48,6 +50,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
private final FileSystemPackerCacheRepository cacheRepository;
private final PackerProjectWriteCoordinator writeCoordinator;
private final PackerEventSink eventSink;
private final PackerGlyphMaterialSupport glyphMaterialSupport;
public FileSystemPackerWorkspaceService(
ObjectMapper mapper,
@ -68,6 +71,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
this.cacheRepository = Objects.requireNonNull(cacheRepository, "cacheRepository");
this.writeCoordinator = Objects.requireNonNull(writeCoordinator, "writeCoordinator");
this.eventSink = Objects.requireNonNull(eventSink, "eventSink");
this.glyphMaterialSupport = new PackerGlyphMaterialSupport(mapper);
}
@Override
@ -81,7 +85,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
public ListAssetsResult listAssets(ListAssetsRequest request) {
final var project = Objects.requireNonNull(request, "request").project();
final var snapshot = request.deepSync()
? runtimeRegistry.refresh(project).snapshot()
? writeCoordinator.execute(project, () -> refreshAfterArtifactIdentityBackfill(project))
: runtimeRegistry.getOrLoad(project).snapshot();
final var events = new PackerOperationEventEmitter(project, eventSink);
final var registry = snapshot.registry();
@ -145,7 +149,11 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
@Override
public GetAssetDetailsResult getAssetDetails(GetAssetDetailsRequest request) {
return detailsService.getAssetDetails(request);
final GetAssetDetailsRequest safeRequest = Objects.requireNonNull(request, "request");
return writeCoordinator.execute(safeRequest.project(), () -> {
backfillLoadedArtifactIdentity(safeRequest.project());
return detailsService.getAssetDetails(safeRequest);
});
}
@Override
@ -171,7 +179,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
@Override
public ValidatePackWorkspaceResult validatePackWorkspace(ValidatePackWorkspaceRequest request) {
final PackerProjectContext project = Objects.requireNonNull(request, "request").project();
final PackerRuntimeSnapshot snapshot = runtimeRegistry.getOrLoad(project).snapshot();
final PackerRuntimeSnapshot snapshot = writeCoordinator.execute(project, () -> refreshAfterArtifactIdentityBackfill(project));
final List<PackerPackValidationAssetDTO> blockedAssets = snapshot.assets().stream()
.filter(this::isEffectivelyIncludedInPackSet)
.map(this::toPackValidationAssetDTO)
@ -287,6 +295,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
final PackerRegistryEntry entry = workspaceFoundation.allocateIdentity(project, registry, assetRoot);
Files.createDirectories(assetRoot);
writeManifest(manifestPath, request, entry.assetUuid());
writeStudioSupportFiles(assetRoot, request);
final PackerRegistryState updated = workspaceFoundation.appendAllocatedEntry(registry, entry);
workspaceFoundation.saveRegistry(project, updated);
final var runtime = runtimeRegistry.update(project, (snapshot, generation) -> runtimePatchService.afterCreateAsset(
@ -320,7 +329,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
final long startedAt = System.currentTimeMillis();
workspaceFoundation.initWorkspace(new InitWorkspaceRequest(project));
final PackerRuntimeSnapshot refreshedSnapshot = runtimeRegistry.refresh(project).snapshot();
final PackerRuntimeSnapshot refreshedSnapshot = refreshAfterArtifactIdentityBackfill(project);
final PackerRuntimeSnapshot frozenSnapshot = createFrozenPackingSnapshot(refreshedSnapshot);
final List<PackerPackValidationAssetDTO> blockedAssets = frozenSnapshot.assets().stream()
.filter(this::isEffectivelyIncludedInPackSet)
@ -498,6 +507,12 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
}
try {
if (request.includedInBuild() && !sceneBankCanEnterBuild(assetRoot)) {
return new UpdateAssetBuildParticipationResult(
PackerOperationStatus.FAILED,
"Scene Bank must enable at least one layer before entering the build.",
null);
}
final PackerRegistryEntry currentEntry = evaluation.resolved().registryEntry().orElseThrow();
final PackerRegistryEntry updatedEntry = new PackerRegistryEntry(
currentEntry.assetId(),
@ -667,6 +682,57 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
mapper.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
}
private void writeStudioSupportFiles(Path assetRoot, CreateAssetRequest request) throws IOException {
if (request.assetFamily() != AssetFamilyCatalog.SCENE_BANK) {
return;
}
final Map<String, Object> supportFile = new LinkedHashMap<>();
supportFile.put("schema_version", 1);
supportFile.put("map_width", 16);
supportFile.put("map_height", 16);
supportFile.put("layer_count", 4);
final List<Map<String, Object>> layers = new ArrayList<>();
for (int index = 1; index <= 4; index += 1) {
layers.add(Map.of(
"index", index,
"enabled", false,
"name", "Layer " + index,
"width", 16,
"height", 16,
"tilemap", "layer-" + index + ".tmx",
"tileset_asset_root", ""));
}
supportFile.put("layers", layers);
mapper.writerWithDefaultPrettyPrinter().writeValue(
assetRoot.resolve(SCENE_BANK_SUPPORT_FILE).toFile(),
supportFile);
}
private boolean sceneBankCanEnterBuild(Path assetRoot) {
final Path supportFilePath = assetRoot.resolve(SCENE_BANK_SUPPORT_FILE);
if (!Files.isRegularFile(supportFilePath)) {
return true;
}
try {
final JsonNode root = mapper.readTree(supportFilePath.toFile());
if (!(root instanceof ObjectNode objectNode)) {
return false;
}
final JsonNode layersNode = objectNode.get("layers");
if (!(layersNode instanceof ArrayNode layers)) {
return false;
}
for (JsonNode layer : layers) {
if (layer.path("enabled").asBoolean(false)) {
return true;
}
}
return false;
} catch (IOException exception) {
return false;
}
}
private String normalizeRelativeAssetRoot(String candidate) {
final String raw = Objects.requireNonNullElse(candidate, "").trim().replace('\\', '/');
if (raw.isBlank()) {
@ -781,6 +847,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
parsed.diagnostics(),
runtimeAsset.walkDiagnostics(),
collectPackRelevantWalkFileDiagnostics(runtimeAsset),
glyphMaterialSupport.validate(runtimeAsset),
packExecutionSupportDiagnostics(parsed.declaration(), runtimeAsset.manifestPath()),
identityMismatchDiagnostics(registryEntry, parsed, runtimeAsset.manifestPath()))
.flatMap(Collection::stream)
@ -831,6 +898,84 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
cacheRepository.save(project, snapshot.cacheState());
}
private PackerRuntimeSnapshot refreshAfterArtifactIdentityBackfill(PackerProjectContext project) {
final PackerRuntimeSnapshot snapshot = runtimeRegistry.refresh(project).snapshot();
return backfillArtifactIdentity(snapshot)
? runtimeRegistry.refresh(project).snapshot()
: snapshot;
}
private PackerRuntimeSnapshot backfillLoadedArtifactIdentity(PackerProjectContext project) {
final PackerRuntimeSnapshot snapshot = runtimeRegistry.getOrLoad(project).snapshot();
return backfillArtifactIdentity(snapshot)
? runtimeRegistry.refresh(project).snapshot()
: snapshot;
}
private boolean backfillArtifactIdentity(PackerRuntimeSnapshot snapshot) {
boolean changed = false;
for (PackerRuntimeAsset runtimeAsset : snapshot.assets()) {
try {
changed = backfillArtifactIdentity(runtimeAsset) || changed;
} catch (IOException exception) {
throw new IllegalStateException("Unable to update artifact fingerprints for " + runtimeAsset.manifestPath(), exception);
}
}
return changed;
}
private boolean backfillArtifactIdentity(PackerRuntimeAsset runtimeAsset) throws IOException {
if (runtimeAsset.parsedDeclaration().declaration() == null || runtimeAsset.parsedDeclaration().declaration().artifacts().isEmpty()) {
return false;
}
final Path manifestPath = runtimeAsset.manifestPath();
if (!Files.isRegularFile(manifestPath)) {
return false;
}
final JsonNode rawManifest = mapper.readTree(manifestPath.toFile());
if (!(rawManifest instanceof ObjectNode manifest) || !(manifest.path("artifacts") instanceof ArrayNode artifactsNode)) {
return false;
}
final Map<String, PackerRuntimeWalkFile> walkFilesByPath = new LinkedHashMap<>();
runtimeAsset.walkProjection().buildCandidateFiles().forEach(file -> walkFilesByPath.put(file.relativePath(), file));
final Set<String> artifactIds = new HashSet<>();
for (JsonNode artifactNode : artifactsNode) {
if (artifactNode.isObject()) {
final String artifactId = artifactNode.path("artifact_id").asText("").trim();
if (!artifactId.isBlank()) {
artifactIds.add(artifactId);
}
}
}
boolean changed = false;
for (JsonNode artifactNode : artifactsNode) {
if (!(artifactNode instanceof ObjectNode artifactObject)) {
continue;
}
final String file = artifactObject.path("file").asText("").trim();
if (file.isBlank()) {
continue;
}
if (artifactObject.path("artifact_id").asText("").isBlank()) {
artifactObject.put("artifact_id", nextArtifactId(artifactIds));
changed = true;
}
final String fingerprint = Optional.ofNullable(walkFilesByPath.get(file))
.map(PackerRuntimeWalkFile::fingerprint)
.orElse(null);
if (fingerprint != null && !fingerprint.isBlank() && !fingerprint.equals(artifactObject.path("fingerprint").asText(null))) {
artifactObject.put("fingerprint", fingerprint);
changed = true;
}
}
if (changed) {
mapper.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
}
return changed;
}
private PackerRuntimeSnapshot createFrozenPackingSnapshot(PackerRuntimeSnapshot snapshot) {
final PackerRuntimeAssetMaterializer materializer = new PackerRuntimeAssetMaterializer(new PackerAssetWalker(mapper));
final List<PackerRuntimeAsset> assets = snapshot.assets().stream()
@ -1256,7 +1401,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
}
try {
patchManifestArtifacts(manifest, request);
patchManifestArtifacts(manifest, request, evaluation.resolved().runtimeAsset());
mapper.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
final var runtime = runtimeRegistry.update(project, (currentSnapshot, generation) -> runtimePatchService.afterUpdateAssetContract(
currentSnapshot,
@ -1367,21 +1512,81 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
});
}
private void patchManifestArtifacts(ObjectNode manifest, ApplyBankCompositionRequest request) {
private void patchManifestArtifacts(
ObjectNode manifest,
ApplyBankCompositionRequest request,
Optional<PackerRuntimeAsset> runtimeAsset) {
manifest.remove("inputs");
final Map<String, ExistingArtifactEntry> existingArtifactsByFile = existingArtifactsByFile(manifest.path("artifacts"));
final Map<String, PackerRuntimeWalkFile> walkFilesByPath = new LinkedHashMap<>();
runtimeAsset.ifPresent(asset -> asset.walkProjection().buildCandidateFiles().forEach(file -> walkFilesByPath.put(file.relativePath(), file)));
final var artifactsNode = manifest.putArray("artifacts");
for (int index = 0; index < request.selectedFiles().size(); index += 1) {
final String file = Objects.requireNonNullElse(request.selectedFiles().get(index), "").trim();
if (file.isBlank() || !isTrustedRelativePath(file)) {
throw new IllegalArgumentException("Selected artifact file paths must stay inside the asset root.");
}
final ExistingArtifactEntry existing = existingArtifactsByFile.get(file);
final var artifactNode = artifactsNode.addObject();
artifactNode.put("artifact_id", existing != null && existing.artifactId() != null ? existing.artifactId() : nextArtifactId());
artifactNode.put("file", file);
artifactNode.put("index", index);
final String fingerprint = Optional.ofNullable(walkFilesByPath.get(file))
.map(PackerRuntimeWalkFile::fingerprint)
.orElse(existing == null ? null : existing.fingerprint());
if (fingerprint != null && !fingerprint.isBlank()) {
artifactNode.put("fingerprint", fingerprint);
}
}
}
private Map<String, ExistingArtifactEntry> existingArtifactsByFile(JsonNode artifactsNode) {
if (!(artifactsNode instanceof ArrayNode artifactsArray)) {
return Map.of();
}
final Map<String, ExistingArtifactEntry> existing = new LinkedHashMap<>();
for (JsonNode entry : artifactsArray) {
if (!entry.isObject()) {
continue;
}
final String file = entry.path("file").asText("").trim();
if (file.isBlank()) {
continue;
}
existing.put(file, new ExistingArtifactEntry(
blankToNull(entry.path("artifact_id").asText(null)),
blankToNull(entry.path("fingerprint").asText(null))));
}
return Map.copyOf(existing);
}
private String nextArtifactId() {
return "art-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
}
private String nextArtifactId(Set<String> existingArtifactIds) {
String artifactId = nextArtifactId();
while (existingArtifactIds.contains(artifactId)) {
artifactId = nextArtifactId();
}
existingArtifactIds.add(artifactId);
return artifactId;
}
private String blankToNull(String value) {
if (value == null) {
return null;
}
final String trimmed = value.trim();
return trimmed.isBlank() ? null : trimmed;
}
private record ExistingArtifactEntry(
String artifactId,
String fingerprint) {
}
private void patchManifestPipelinePalettes(ObjectNode manifest, ApplyPaletteOverhaulingRequest request) {
final ObjectNode outputNode = mutableObject(manifest, "output");
final ObjectNode pipelineNode = mutableObject(outputNode, "pipeline");

View File

@ -121,7 +121,7 @@ public final class PackerAssetDeclarationParser {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Field 'type' must be one of: glyph_bank, palette_bank, sound_bank.",
"Field 'type' must be one of: glyph_bank, scene_bank, sound_bank.",
manifestPath,
true));
return null;
@ -404,8 +404,10 @@ public final class PackerAssetDeclarationParser {
true));
continue;
}
final JsonNode artifactIdNode = entry.path("artifact_id");
final JsonNode fileNode = entry.path("file");
final JsonNode indexNode = entry.path("index");
final JsonNode fingerprintNode = entry.path("fingerprint");
if (!fileNode.isTextual() || fileNode.asText().isBlank() || !indexNode.isInt() || indexNode.asInt() < 0) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
@ -425,7 +427,13 @@ public final class PackerAssetDeclarationParser {
true));
continue;
}
result.add(new PackerAssetArtifactSelection(relativePath, indexNode.asInt()));
final String artifactId = artifactIdNode.isTextual() && !artifactIdNode.asText().isBlank()
? artifactIdNode.asText().trim()
: null;
final String fingerprint = fingerprintNode.isTextual() && !fingerprintNode.asText().isBlank()
? fingerprintNode.asText().trim()
: null;
result.add(new PackerAssetArtifactSelection(artifactId, relativePath, indexNode.asInt(), fingerprint));
}
result.sort(Comparator.comparingInt(PackerAssetArtifactSelection::index));
return List.copyOf(result);

View File

@ -1,5 +1,6 @@
package p.packer.services;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
@ -16,6 +17,8 @@ import java.nio.file.Path;
import java.util.*;
public final class PackerAssetDetailsService {
private static final ObjectMapper GLYPH_MATERIAL_MAPPER = new ObjectMapper();
private final PackerRuntimeRegistry runtimeRegistry;
private final PackerAssetReferenceResolver assetReferenceResolver;
@ -48,6 +51,7 @@ public final class PackerAssetDetailsService {
diagnostics.addAll(parsed.diagnostics());
diagnostics.addAll(runtimeAsset.walkDiagnostics());
appendWalkFileDiagnostics(runtimeAsset, diagnostics);
diagnostics.addAll(new PackerGlyphMaterialSupport(GLYPH_MATERIAL_MAPPER).validate(runtimeAsset));
if (!parsed.valid()) {
return failureResult(project, request.assetReference(), resolved, diagnostics);
}
@ -149,8 +153,9 @@ public final class PackerAssetDetailsService {
.map(this::toBankCompositionFile)
.toList();
final List<PackerBankCompositionFile> selectedFiles = selectedPathsFor(declaration).stream()
.map(path -> resolveSelectedBankFile(runtimeAsset.assetRoot(), path, walkFilesByPath, assetCacheEntry))
final List<PackerBankCompositionFile> selectedFiles = declaration.artifacts().stream()
.sorted(Comparator.comparingInt(PackerAssetArtifactSelection::index))
.map(artifact -> resolveSelectedBankFile(runtimeAsset.assetRoot(), artifact, walkFilesByPath, assetCacheEntry))
.flatMap(Optional::stream)
.toList();
@ -160,13 +165,6 @@ public final class PackerAssetDetailsService {
walkProjection.measuredBankSizeBytes());
}
private List<String> selectedPathsFor(PackerAssetDeclaration declaration) {
return declaration.artifacts().stream()
.sorted(Comparator.comparingInt(PackerAssetArtifactSelection::index))
.map(PackerAssetArtifactSelection::file)
.toList();
}
private List<Map<String, Object>> resolvePipelinePalettes(PackerAssetDeclaration declaration) {
final JsonNode palettesNode = declaration.outputPipelineMetadata().get("palettes");
if (!(palettesNode instanceof ArrayNode palettesArray)) {
@ -219,16 +217,17 @@ public final class PackerAssetDetailsService {
private Optional<PackerBankCompositionFile> resolveSelectedBankFile(
Path assetRoot,
String relativePath,
PackerAssetArtifactSelection artifact,
Map<String, PackerRuntimeWalkFile> walkFilesByPath,
Optional<PackerAssetCacheEntry> assetCacheEntry) {
final String relativePath = artifact.file();
final PackerRuntimeWalkFile runtimeWalkFile = walkFilesByPath.get(relativePath);
if (runtimeWalkFile != null) {
return Optional.of(toBankCompositionFile(runtimeWalkFile));
return Optional.of(toSelectedBankCompositionFile(runtimeWalkFile, artifact));
}
final Optional<PackerFileCacheEntry> cachedFile = assetCacheEntry.flatMap(entry -> entry.findFile(relativePath));
if (cachedFile.isPresent()) {
return Optional.of(toBankCompositionFile(cachedFile.get()));
return Optional.of(toSelectedBankCompositionFile(cachedFile.get(), artifact));
}
final Path filePath = assetRoot.resolve(relativePath).toAbsolutePath().normalize();
@ -242,8 +241,8 @@ public final class PackerAssetDetailsService {
filePath.getFileName().toString(),
Files.size(filePath),
Files.getLastModifiedTime(filePath).toMillis(),
null,
Map.of()));
artifact.fingerprint(),
selectedArtifactMetadata(artifact, Map.of())));
} catch (Exception exception) {
return Optional.empty();
}
@ -269,6 +268,43 @@ public final class PackerAssetDetailsService {
file.metadata());
}
private PackerBankCompositionFile toSelectedBankCompositionFile(
PackerRuntimeWalkFile file,
PackerAssetArtifactSelection artifact) {
return new PackerBankCompositionFile(
file.relativePath(),
Path.of(file.relativePath()).getFileName().toString(),
file.size(),
file.lastModified(),
file.fingerprint(),
selectedArtifactMetadata(artifact, file.metadata()));
}
private PackerBankCompositionFile toSelectedBankCompositionFile(
PackerFileCacheEntry file,
PackerAssetArtifactSelection artifact) {
return new PackerBankCompositionFile(
file.relativePath(),
Path.of(file.relativePath()).getFileName().toString(),
file.size(),
file.lastModified(),
file.fingerprint(),
selectedArtifactMetadata(artifact, file.metadata()));
}
private Map<String, Object> selectedArtifactMetadata(
PackerAssetArtifactSelection artifact,
Map<String, Object> metadata) {
final Map<String, Object> decorated = new LinkedHashMap<>(metadata);
if (artifact.artifactId() != null) {
decorated.put("artifactId", artifact.artifactId());
}
if (artifact.fingerprint() != null) {
decorated.put("selectedFingerprint", artifact.fingerprint());
}
return Map.copyOf(decorated);
}
private List<PackerCodecConfigurationField> metadataFields(
List<PackerCodecConfigurationField> definitions,
Map<String, String> outputMetadata) {

View File

@ -0,0 +1,333 @@
package p.packer.services;
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 p.packer.messages.diagnostics.PackerDiagnosticCategory;
import p.packer.messages.diagnostics.PackerDiagnosticSeverity;
import p.packer.models.PackerAssetArtifactSelection;
import p.packer.models.PackerAssetDeclaration;
import p.packer.models.PackerDiagnostic;
import p.packer.models.PackerRuntimeAsset;
import p.packer.models.PackerRuntimeWalkFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
final class PackerGlyphMaterialSupport {
static final String GLYPH_MATERIAL_SUPPORT_FILE = "glyph-material.json";
private final ObjectMapper mapper;
PackerGlyphMaterialSupport(ObjectMapper mapper) {
this.mapper = Objects.requireNonNull(mapper, "mapper");
}
List<PackerDiagnostic> validate(PackerRuntimeAsset runtimeAsset) {
final PackerAssetDeclaration declaration = runtimeAsset.parsedDeclaration().declaration();
if (declaration == null || declaration.assetFamily() != AssetFamilyCatalog.GLYPH_BANK) {
return List.of();
}
final Path supportFile = runtimeAsset.assetRoot().resolve(GLYPH_MATERIAL_SUPPORT_FILE);
if (!Files.isRegularFile(supportFile)) {
return List.of();
}
final List<PackerDiagnostic> diagnostics = new ArrayList<>();
final Optional<GlyphMaterialFile> parsed = readSupportFile(supportFile, diagnostics);
if (parsed.isEmpty()) {
return List.copyOf(diagnostics);
}
final Map<String, PackerAssetArtifactSelection> selectedArtifactsById = new LinkedHashMap<>();
for (PackerAssetArtifactSelection artifact : declaration.artifacts()) {
if (artifact.artifactId() != null) {
selectedArtifactsById.put(artifact.artifactId(), artifact);
}
}
final Map<String, PackerRuntimeWalkFile> selectedFilesByPath = new LinkedHashMap<>();
runtimeAsset.walkProjection().buildCandidateFiles().forEach(file -> selectedFilesByPath.put(file.relativePath(), file));
final Map<String, SchemaEvaluation> schemaEvaluations = new LinkedHashMap<>();
for (GlyphMaterialSchema schema : parsed.get().schemas()) {
final boolean declaredInconsistent = schema.status() == GlyphMaterialSchemaStatus.INCONSISTENT;
boolean drifted = false;
for (GlyphMaterialArtifactBinding binding : schema.artifactBindings()) {
final PackerAssetArtifactSelection artifact = selectedArtifactsById.get(binding.artifactId());
if (artifact == null) {
diagnostics.add(blocking(
"Glyph material schema '" + schema.schemaId() + "' references unknown selected artifact id '" + binding.artifactId() + "'.",
supportFile));
drifted = true;
continue;
}
final PackerRuntimeWalkFile currentFile = selectedFilesByPath.get(artifact.file());
final String currentFingerprint = currentFile != null
? currentFile.fingerprint()
: artifact.fingerprint();
if (currentFingerprint == null || !currentFingerprint.equals(binding.fingerprint())) {
diagnostics.add(blocking(
"Glyph material schema '" + schema.schemaId() + "' is inconsistent because artifact '" + binding.artifactId() + "' drifted.",
supportFile));
drifted = true;
}
}
if (declaredInconsistent) {
diagnostics.add(blocking(
"Glyph material schema '" + schema.schemaId() + "' is marked inconsistent.",
supportFile));
}
schemaEvaluations.put(schema.schemaId(), new SchemaEvaluation(schema, declaredInconsistent || drifted));
}
for (GlyphMaterialPalette palette : parsed.get().palettes()) {
final SchemaEvaluation schema = schemaEvaluations.get(palette.schemaId());
if (schema == null) {
diagnostics.add(blocking(
"Glyph material palette '" + palette.paletteId() + "' references unknown schema '" + palette.schemaId() + "'.",
supportFile));
continue;
}
if (palette.colors().size() != schema.schema().materialIndices().size()) {
diagnostics.add(blocking(
"Glyph material palette '" + palette.paletteId() + "' must define exactly "
+ schema.schema().materialIndices().size() + " colors for schema '" + palette.schemaId() + "'.",
supportFile));
}
if (palette.version() < schema.schema().version()) {
diagnostics.add(blocking(
"Glyph material palette '" + palette.paletteId() + "' is stale because version "
+ palette.version() + " is older than schema '" + palette.schemaId() + "' version "
+ schema.schema().version() + ".",
supportFile));
}
if (schema.inconsistent()) {
diagnostics.add(blocking(
"Glyph material palette '" + palette.paletteId() + "' is invalid because schema '"
+ palette.schemaId() + "' is inconsistent.",
supportFile));
}
}
return List.copyOf(diagnostics);
}
private Optional<GlyphMaterialFile> readSupportFile(Path supportFile, List<PackerDiagnostic> diagnostics) {
final JsonNode root;
try {
root = mapper.readTree(supportFile.toFile());
} catch (IOException exception) {
diagnostics.add(blocking("Unable to parse glyph-material.json: " + exception.getMessage(), supportFile));
return Optional.empty();
}
if (root == null || !root.isObject()) {
diagnostics.add(blocking("glyph-material.json must contain a JSON object at the root.", supportFile));
return Optional.empty();
}
if (!(root.path("schemas") instanceof ArrayNode schemasNode) || !(root.path("palettes") instanceof ArrayNode palettesNode)) {
diagnostics.add(blocking("glyph-material.json must define array fields 'schemas' and 'palettes'.", supportFile));
return Optional.empty();
}
final List<GlyphMaterialSchema> schemas = new ArrayList<>();
final Set<String> schemaIds = new HashSet<>();
for (JsonNode schemaNode : schemasNode) {
final Optional<GlyphMaterialSchema> schema = parseSchema(schemaNode, supportFile, diagnostics);
if (schema.isEmpty()) {
return Optional.empty();
}
if (!schemaIds.add(schema.get().schemaId())) {
diagnostics.add(blocking("glyph-material.json contains duplicate schema_id '" + schema.get().schemaId() + "'.", supportFile));
return Optional.empty();
}
schemas.add(schema.get());
}
final List<GlyphMaterialPalette> palettes = new ArrayList<>();
final Set<String> paletteIds = new HashSet<>();
for (JsonNode paletteNode : palettesNode) {
final Optional<GlyphMaterialPalette> palette = parsePalette(paletteNode, schemaIds, supportFile, diagnostics);
if (palette.isEmpty()) {
return Optional.empty();
}
if (!paletteIds.add(palette.get().paletteId())) {
diagnostics.add(blocking("glyph-material.json contains duplicate palette_id '" + palette.get().paletteId() + "'.", supportFile));
return Optional.empty();
}
palettes.add(palette.get());
}
return Optional.of(new GlyphMaterialFile(List.copyOf(schemas), List.copyOf(palettes)));
}
private Optional<GlyphMaterialSchema> parseSchema(
JsonNode schemaNode,
Path supportFile,
List<PackerDiagnostic> diagnostics) {
if (!schemaNode.isObject()) {
diagnostics.add(blocking("Each glyph material schema must be an object.", supportFile));
return Optional.empty();
}
final String schemaId = schemaNode.path("schema_id").asText("").trim();
final GlyphMaterialSchemaStatus status = GlyphMaterialSchemaStatus.fromManifestValue(schemaNode.path("status").asText(null));
final int version = schemaNode.path("version").asInt(0);
if (schemaId.isBlank() || status == null || version <= 0) {
diagnostics.add(blocking("Each glyph material schema must define non-blank 'schema_id', valid 'status', and positive 'version'.", supportFile));
return Optional.empty();
}
final JsonNode materialIndicesNode = schemaNode.path("material_indices");
if (!materialIndicesNode.isObject()) {
diagnostics.add(blocking("Schema '" + schemaId + "' must define object 'material_indices'.", supportFile));
return Optional.empty();
}
final Map<Integer, String> materialIndices = new TreeMap<>();
materialIndicesNode.fields().forEachRemaining(entry -> {
try {
final int index = Integer.parseInt(entry.getKey());
final String color = normalizeHtmlColor(entry.getValue().asText(null));
if (index >= 1 && index <= 15 && color != null) {
materialIndices.put(index, color);
}
} catch (NumberFormatException ignored) {
// validated below
}
});
if (materialIndices.isEmpty()
|| materialIndices.size() != materialIndicesNode.size()
|| !denseIndices(materialIndices.keySet())) {
diagnostics.add(blocking("Schema '" + schemaId + "' must define dense material_indices in range 1..15 using #RRGGBB values.", supportFile));
return Optional.empty();
}
if (!(schemaNode.path("artifact_bindings") instanceof ArrayNode bindingsNode) || bindingsNode.isEmpty()) {
diagnostics.add(blocking("Schema '" + schemaId + "' must define non-empty 'artifact_bindings'.", supportFile));
return Optional.empty();
}
final List<GlyphMaterialArtifactBinding> bindings = new ArrayList<>();
final Set<String> artifactIds = new HashSet<>();
for (JsonNode bindingNode : bindingsNode) {
final String artifactId = bindingNode.path("artifact_id").asText("").trim();
final String fingerprint = bindingNode.path("fingerprint").asText("").trim();
if (artifactId.isBlank() || fingerprint.isBlank() || !artifactIds.add(artifactId)) {
diagnostics.add(blocking("Schema '" + schemaId + "' must define unique artifact bindings with 'artifact_id' and 'fingerprint'.", supportFile));
return Optional.empty();
}
bindings.add(new GlyphMaterialArtifactBinding(artifactId, fingerprint));
}
return Optional.of(new GlyphMaterialSchema(schemaId, status, version, Map.copyOf(materialIndices), List.copyOf(bindings)));
}
private Optional<GlyphMaterialPalette> parsePalette(
JsonNode paletteNode,
Set<String> schemaIds,
Path supportFile,
List<PackerDiagnostic> diagnostics) {
if (!paletteNode.isObject()) {
diagnostics.add(blocking("Each glyph material palette must be an object.", supportFile));
return Optional.empty();
}
final String paletteId = paletteNode.path("palette_id").asText("").trim();
final String schemaId = paletteNode.path("schema_id").asText("").trim();
final int version = paletteNode.path("version").asInt(0);
if (paletteId.isBlank() || schemaId.isBlank() || version <= 0 || !schemaIds.contains(schemaId)) {
diagnostics.add(blocking("Each glyph material palette must define non-blank 'palette_id', valid 'schema_id', and positive 'version'.", supportFile));
return Optional.empty();
}
if (!(paletteNode.path("colors") instanceof ArrayNode colorsNode)) {
diagnostics.add(blocking("Palette '" + paletteId + "' must define array 'colors'.", supportFile));
return Optional.empty();
}
final List<String> colors = new ArrayList<>();
for (JsonNode colorNode : colorsNode) {
final String color = normalizeHtmlColor(colorNode.asText(null));
if (color == null) {
diagnostics.add(blocking("Palette '" + paletteId + "' must use #RRGGBB colors.", supportFile));
return Optional.empty();
}
colors.add(color);
}
return Optional.of(new GlyphMaterialPalette(paletteId, schemaId, version, List.copyOf(colors)));
}
private static PackerDiagnostic blocking(String message, Path path) {
return new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
message,
path,
true);
}
private boolean denseIndices(Set<Integer> indices) {
int expected = 1;
for (Integer index : new TreeSet<>(indices)) {
if (index == null || index != expected) {
return false;
}
expected += 1;
}
return !indices.isEmpty();
}
private String normalizeHtmlColor(String value) {
if (value == null) {
return null;
}
final String normalized = value.trim().toUpperCase();
return normalized.matches("^#[0-9A-F]{6}$") ? normalized : null;
}
private record GlyphMaterialFile(
List<GlyphMaterialSchema> schemas,
List<GlyphMaterialPalette> palettes) {
}
private record GlyphMaterialSchema(
String schemaId,
GlyphMaterialSchemaStatus status,
int version,
Map<Integer, String> materialIndices,
List<GlyphMaterialArtifactBinding> artifactBindings) {
}
private record GlyphMaterialPalette(
String paletteId,
String schemaId,
int version,
List<String> colors) {
}
private record GlyphMaterialArtifactBinding(
String artifactId,
String fingerprint) {
}
private record SchemaEvaluation(
GlyphMaterialSchema schema,
boolean inconsistent) {
}
private enum GlyphMaterialSchemaStatus {
OK("ok"),
INCONSISTENT("inconsistent");
private final String manifestValue;
GlyphMaterialSchemaStatus(String manifestValue) {
this.manifestValue = manifestValue;
}
static GlyphMaterialSchemaStatus fromManifestValue(String value) {
if (value == null) {
return null;
}
for (GlyphMaterialSchemaStatus candidate : values()) {
if (candidate.manifestValue.equalsIgnoreCase(value.trim())) {
return candidate;
}
}
return null;
}
}
}

View File

@ -76,12 +76,13 @@ final class PackerRuntimeAssetMaterializerTest {
final Path assetRoot = Files.createDirectories(tempDir.resolve("runtime-default"));
final Path manifestPath = assetRoot.resolve("asset.json");
Files.writeString(manifestPath, "{}");
Files.writeString(assetRoot.resolve("studio.asset.json"), "{}");
writeTile(assetRoot.resolve("selected.png"), 16);
writeTile(assetRoot.resolve("extra.png"), 16);
final PackerAssetDeclaration declaration = declaration(
Map.of("tile_size", "16x16"),
List.of(new PackerAssetArtifactSelection("selected.png", 0)),
List.of(new PackerAssetArtifactSelection(null, "selected.png", 0, null)),
palettePipeline());
final var materialized = materializer.materialize(
assetRoot,
@ -91,6 +92,7 @@ final class PackerRuntimeAssetMaterializerTest {
Optional.empty());
assertEquals(2, materialized.runtimeAsset().walkProjection().buildCandidateFiles().size());
assertEquals(List.of("extra.png", "selected.png"), materialized.runtimeAsset().walkProjection().availableFiles());
assertTrue(materialized.runtimeAsset().walkProjection().buildCandidateFiles().stream()
.allMatch(file -> file.contentBytes().isEmpty()));
}
@ -106,7 +108,7 @@ final class PackerRuntimeAssetMaterializerTest {
final PackerAssetDeclaration declaration = declaration(
Map.of("tile_size", "16x16"),
List.of(new PackerAssetArtifactSelection("selected.png", 0)),
List.of(new PackerAssetArtifactSelection(null, "selected.png", 0, null)),
palettePipeline());
final var materialized = materializer.materialize(
assetRoot,

View File

@ -512,6 +512,35 @@ final class FileSystemPackerWorkspaceServiceTest {
assertTrue(events.stream().anyMatch(event -> event.kind() == PackerEventKind.ACTION_APPLIED));
}
@Test
void createsSceneBankAssetAndWritesStudioSupportFile() throws Exception {
final Path projectRoot = tempDir.resolve("created-scene-bank");
final FileSystemPackerWorkspaceService service = service();
final var result = service.createAsset(new CreateAssetRequest(
project(projectRoot),
"scenes/overworld",
"overworld",
AssetFamilyCatalog.SCENE_BANK,
OutputFormatCatalog.SCENE_TILED_V1,
OutputCodecCatalog.NONE,
false));
assertEquals(PackerOperationStatus.SUCCESS, result.status());
final Path assetRoot = projectRoot.resolve("assets/scenes/overworld");
assertTrue(Files.isRegularFile(assetRoot.resolve("asset.json")));
assertTrue(Files.isRegularFile(assetRoot.resolve("scene-bank.studio.json")));
final var supportFile = MAPPER.readTree(assetRoot.resolve("scene-bank.studio.json").toFile());
assertEquals(16, supportFile.path("map_width").asInt());
assertEquals(16, supportFile.path("map_height").asInt());
assertEquals(4, supportFile.path("layer_count").asInt());
assertEquals(false, supportFile.path("layers").get(0).path("enabled").asBoolean());
assertEquals("Layer 1", supportFile.path("layers").get(0).path("name").asText());
assertEquals(16, supportFile.path("layers").get(0).path("width").asInt());
assertEquals(16, supportFile.path("layers").get(0).path("height").asInt());
assertEquals("layer-1.tmx", supportFile.path("layers").get(0).path("tilemap").asText());
}
@Test
void returnsCreatedAssetThroughRuntimeBackedDetailsWithoutRescanMismatch() throws Exception {
final Path projectRoot = tempDir.resolve("created-details");
@ -696,6 +725,31 @@ final class FileSystemPackerWorkspaceServiceTest {
assertFalse(registry.path("assets").get(0).path("included_in_build").asBoolean(true));
}
@Test
void updateAssetBuildParticipationBlocksSceneBankWithoutEnabledLayers() throws Exception {
final Path projectRoot = tempDir.resolve("update-build-participation-scene");
final FileSystemPackerWorkspaceService service = service();
final CreateAssetResult createResult = service.createAsset(new CreateAssetRequest(
project(projectRoot),
"scenes/overworld",
"overworld",
AssetFamilyCatalog.SCENE_BANK,
OutputFormatCatalog.SCENE_TILED_V1,
OutputCodecCatalog.NONE,
false));
assertEquals(PackerOperationStatus.SUCCESS, createResult.status());
final var result = service.updateAssetBuildParticipation(new UpdateAssetBuildParticipationRequest(
project(projectRoot),
createResult.assetReference(),
true));
assertEquals(PackerOperationStatus.FAILED, result.status());
assertTrue(result.summary().contains("enable at least one layer"));
}
@Test
void updateAssetContractDropsPersistedBankCompositionWhenContractFingerprintChanges() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("update-contract-drops-bank"));
@ -734,8 +788,8 @@ final class FileSystemPackerWorkspaceServiceTest {
void applyBankCompositionWritesArtifactsAndRefreshesSnapshotWithoutWholeProjectReload() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("apply-bank-composition"));
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
Files.writeString(assetRoot.resolve("confirm.png"), "fixture");
Files.writeString(assetRoot.resolve("cancel.png"), "fixture");
writeTilePng(assetRoot.resolve("confirm.png"), 16);
writeTilePng(assetRoot.resolve("cancel.png"), 16);
final CountingLoader loader = countingLoader();
final FileSystemPackerWorkspaceService service = service(ignored -> { }, loader);
@ -753,10 +807,14 @@ final class FileSystemPackerWorkspaceServiceTest {
final var manifest = MAPPER.readTree(assetRoot.resolve("asset.json").toFile());
assertTrue(manifest.path("inputs").isMissingNode());
assertTrue(manifest.path("artifacts").get(0).hasNonNull("artifact_id"));
assertEquals("cancel.png", manifest.path("artifacts").get(0).path("file").asText());
assertEquals(0, manifest.path("artifacts").get(0).path("index").asInt());
assertTrue(manifest.path("artifacts").get(0).hasNonNull("fingerprint"));
assertTrue(manifest.path("artifacts").get(1).hasNonNull("artifact_id"));
assertEquals("confirm.png", manifest.path("artifacts").get(1).path("file").asText());
assertEquals(1, manifest.path("artifacts").get(1).path("index").asInt());
assertTrue(manifest.path("artifacts").get(1).hasNonNull("fingerprint"));
final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest(
project(projectRoot),
@ -767,9 +825,154 @@ final class FileSystemPackerWorkspaceServiceTest {
assertEquals(
List.of("cancel.png", "confirm.png"),
detailsResult.details().bankComposition().selectedFiles().stream().map(file -> file.path()).toList());
assertNotNull(detailsResult.details().bankComposition().selectedFiles().getFirst().metadata().get("artifactId"));
assertEquals(1, loader.loadCount());
}
@Test
void getAssetDetailsBackfillsLegacyArtifactIdsAndFingerprints() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("legacy-artifact-identity"));
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
writeTilePng(assetRoot.resolve("confirm.png"), 16);
final ObjectNode manifest = (ObjectNode) MAPPER.readTree(assetRoot.resolve("asset.json").toFile());
final var artifacts = manifest.putArray("artifacts");
artifacts.addObject()
.put("file", "confirm.png")
.put("index", 0);
MAPPER.writerWithDefaultPrettyPrinter().writeValue(assetRoot.resolve("asset.json").toFile(), manifest);
final FileSystemPackerWorkspaceService service = service();
final var detailsResult = service.getAssetDetails(new GetAssetDetailsRequest(
project(projectRoot),
AssetReference.forAssetId(1)));
final var migrated = MAPPER.readTree(assetRoot.resolve("asset.json").toFile());
final var migratedArtifact = migrated.path("artifacts").get(0);
assertTrue(migratedArtifact.hasNonNull("artifact_id"));
assertTrue(migratedArtifact.hasNonNull("fingerprint"));
final var selectedFile = detailsResult.details().bankComposition().selectedFiles().getFirst();
assertEquals(migratedArtifact.path("artifact_id").asText(), selectedFile.metadata().get("artifactId"));
assertEquals(migratedArtifact.path("fingerprint").asText(), selectedFile.metadata().get("selectedFingerprint"));
}
@Test
void packValidationBlocksGlyphMaterialPaletteWhenSchemaVersionIsNewer() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("glyph-material-stale-palette"));
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
writeTilePng(assetRoot.resolve("confirm.png"), 16);
final FileSystemPackerWorkspaceService service = service();
assertTrue(service.applyBankComposition(new ApplyBankCompositionRequest(
project(projectRoot),
AssetReference.forAssetId(1),
List.of("confirm.png"))).success());
assertTrue(service.applyPaletteOverhauling(new ApplyPaletteOverhaulingRequest(
project(projectRoot),
AssetReference.forAssetId(1),
List.of(Map.of(
"originalArgb8888", List.of(0xFFFF0000),
"convertedRgb565", List.of(0xF800))))).success());
final var manifest = MAPPER.readTree(assetRoot.resolve("asset.json").toFile());
final var selectedArtifact = manifest.path("artifacts").get(0);
writeGlyphMaterialJson(
assetRoot,
"""
{
"schema_version": 1,
"schemas": [
{
"schema_id": "schema-1",
"status": "ok",
"version": 2,
"material_indices": { "1": "#FF0000" },
"artifact_bindings": [
{ "artifact_id": "%s", "fingerprint": "%s" }
]
}
],
"palettes": [
{
"palette_id": "palette-1",
"schema_id": "schema-1",
"version": 1,
"colors": ["#FF0000"]
}
]
}
""".formatted(
selectedArtifact.path("artifact_id").asText(),
selectedArtifact.path("fingerprint").asText()));
final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));
assertEquals(PackerOperationStatus.PARTIAL, result.status());
assertFalse(result.canPack());
assertTrue(result.assets().getFirst().diagnostics().stream()
.anyMatch(diagnostic -> diagnostic.message().contains("is stale because version 1 is older than schema 'schema-1' version 2")));
}
@Test
void packValidationBlocksGlyphMaterialSchemaWhenArtifactFingerprintDrifts() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("glyph-material-drift"));
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
final Path tilePath = assetRoot.resolve("confirm.png");
writeTilePng(tilePath, 16);
final FileSystemPackerWorkspaceService service = service();
assertTrue(service.applyBankComposition(new ApplyBankCompositionRequest(
project(projectRoot),
AssetReference.forAssetId(1),
List.of("confirm.png"))).success());
assertTrue(service.applyPaletteOverhauling(new ApplyPaletteOverhaulingRequest(
project(projectRoot),
AssetReference.forAssetId(1),
List.of(Map.of(
"originalArgb8888", List.of(0xFFFF0000),
"convertedRgb565", List.of(0xF800))))).success());
final var manifest = MAPPER.readTree(assetRoot.resolve("asset.json").toFile());
final var selectedArtifact = manifest.path("artifacts").get(0);
writeGlyphMaterialJson(
assetRoot,
"""
{
"schema_version": 1,
"schemas": [
{
"schema_id": "schema-1",
"status": "ok",
"version": 1,
"material_indices": { "1": "#FF0000" },
"artifact_bindings": [
{ "artifact_id": "%s", "fingerprint": "%s" }
]
}
],
"palettes": [
{
"palette_id": "palette-1",
"schema_id": "schema-1",
"version": 1,
"colors": ["#FF0000"]
}
]
}
""".formatted(
selectedArtifact.path("artifact_id").asText(),
selectedArtifact.path("fingerprint").asText()));
writeSolidTilePng(tilePath, 16, 0xFF00FF00);
final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));
assertEquals(PackerOperationStatus.PARTIAL, result.status());
assertFalse(result.canPack());
assertTrue(result.assets().getFirst().diagnostics().stream()
.anyMatch(diagnostic -> diagnostic.message().contains("artifact '" + selectedArtifact.path("artifact_id").asText() + "' drifted")));
assertTrue(result.assets().getFirst().diagnostics().stream()
.anyMatch(diagnostic -> diagnostic.message().contains("palette 'palette-1' is invalid because schema 'schema-1' is inconsistent")));
}
@Test
void applyPaletteOverhaulingWritesPipelinePalettesInOrder() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("apply-palette-overhauling"));
@ -1350,11 +1553,15 @@ final class FileSystemPackerWorkspaceServiceTest {
}
private void writeTilePng(Path path, int tileSize) throws Exception {
writeSolidTilePng(path, tileSize, 0xFFFF0000);
}
private void writeSolidTilePng(Path path, int tileSize, int argb) throws Exception {
Files.createDirectories(path.getParent());
final BufferedImage image = new BufferedImage(tileSize, tileSize, BufferedImage.TYPE_INT_ARGB);
for (int y = 0; y < tileSize; y += 1) {
for (int x = 0; x < tileSize; x += 1) {
image.setRGB(x, y, 0xFFFF0000);
image.setRGB(x, y, argb);
}
}
ImageIO.write(image, "png", path.toFile());
@ -1371,6 +1578,10 @@ final class FileSystemPackerWorkspaceServiceTest {
ImageIO.write(image, "png", path.toFile());
}
private void writeGlyphMaterialJson(Path assetRoot, String content) throws Exception {
Files.writeString(assetRoot.resolve("glyph-material.json"), content);
}
private static int readLeInt(byte[] bytes, int offset) {
return (bytes[offset] & 0xFF)
| ((bytes[offset + 1] & 0xFF) << 8)

View File

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

View File

@ -0,0 +1,4 @@
{
"schema_version" : 1,
"glyph_bank_specialization" : "sprites"
}

View File

@ -0,0 +1,4 @@
{
"schema_version" : 1,
"glyph_bank_specialization" : "sprites"
}

View File

@ -4,6 +4,7 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.input.MouseButton;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
@ -35,6 +36,8 @@ public abstract class StudioDualListView<T> extends HBox {
private Consumer<List<T>> onMoveToLeft = ignored -> { };
private Consumer<T> onLeftSelectionChanged = ignored -> { };
private Consumer<T> onRightSelectionChanged = ignored -> { };
private Consumer<T> onLeftItemActivated = ignored -> { };
private Consumer<T> onRightItemActivated = ignored -> { };
private IntConsumer onMoveUp = ignored -> { };
private IntConsumer onMoveDown = ignored -> { };
private boolean interactionEnabled = true;
@ -145,6 +148,14 @@ public abstract class StudioDualListView<T> extends HBox {
this.onRightSelectionChanged = Objects.requireNonNull(onRightSelectionChanged, "onRightSelectionChanged");
}
public void setOnLeftItemActivated(Consumer<T> onLeftItemActivated) {
this.onLeftItemActivated = Objects.requireNonNull(onLeftItemActivated, "onLeftItemActivated");
}
public void setOnRightItemActivated(Consumer<T> onRightItemActivated) {
this.onRightItemActivated = Objects.requireNonNull(onRightItemActivated, "onRightItemActivated");
}
public void setOnMoveUp(IntConsumer onMoveUp) {
this.onMoveUp = Objects.requireNonNull(onMoveUp, "onMoveUp");
}
@ -188,6 +199,19 @@ public abstract class StudioDualListView<T> extends HBox {
private ListCell<T> createCell(boolean indexed) {
return new ListCell<>() {
{
setOnMouseClicked(event -> {
if (event.getButton() != MouseButton.PRIMARY || event.getClickCount() != 2 || getItem() == null) {
return;
}
if (indexed) {
onRightItemActivated.accept(getItem());
} else {
onLeftItemActivated.accept(getItem());
}
});
}
@Override
protected void updateItem(T item, boolean empty) {
super.updateItem(item, empty);

View File

@ -145,6 +145,9 @@ public enum I18n {
ASSETS_SECTION_SUMMARY("assets.section.summary"),
ASSETS_SECTION_RUNTIME_CONTRACT("assets.section.runtimeContract"),
ASSETS_SECTION_BANK_COMPOSITION("assets.section.bankComposition"),
ASSETS_SECTION_SCENE_LAYERS("assets.section.sceneLayers"),
ASSETS_SECTION_COLOR_SCHEMA_GROUPS("assets.section.colorSchemaGroups"),
ASSETS_SECTION_LAYER_CONFIGURATION("assets.section.layerConfiguration"),
ASSETS_SECTION_PALETTE_OVERHAULING("assets.section.paletteOverhauling"),
ASSETS_SUBSECTION_CODEC_CONFIGURATION("assets.subsection.codecConfiguration"),
ASSETS_SUBSECTION_METADATA("assets.subsection.metadata"),
@ -154,10 +157,17 @@ public enum I18n {
ASSETS_ACTIONS_EMPTY("assets.actions.empty"),
ASSETS_ACTION_REGISTER("assets.action.register"),
ASSETS_ACTION_ANALYSE("assets.action.analyse"),
ASSETS_ACTION_GENERATE_TSX("assets.action.generateTsx"),
ASSETS_ACTION_GENERATE_TMX("assets.action.generateTmx"),
ASSETS_ACTION_VALIDATE_SCENE_BANK("assets.action.validateSceneBank"),
ASSETS_ACTION_ACCEPT_SCENE_BANK("assets.action.acceptSceneBank"),
ASSETS_ACTION_VALIDATE_LAYER("assets.action.validateLayer"),
ASSETS_ACTION_ACCEPT_LAYER("assets.action.acceptLayer"),
ASSETS_ACTION_DELETE("assets.action.delete"),
ASSETS_ACTION_INCLUDE_IN_BUILD("assets.action.includeInBuild"),
ASSETS_ACTION_EXCLUDE_FROM_BUILD("assets.action.excludeFromBuild"),
ASSETS_ACTION_RELOCATE("assets.action.relocate"),
ASSETS_ACTION_REMOVE("assets.action.remove"),
ASSETS_DELETE_DIALOG_TITLE("assets.deleteDialog.title"),
ASSETS_DELETE_DIALOG_DESCRIPTION("assets.deleteDialog.description"),
ASSETS_DELETE_DIALOG_PROMPT("assets.deleteDialog.prompt"),
@ -190,10 +200,32 @@ public enum I18n {
ASSETS_LABEL_BUILD_PARTICIPATION("assets.label.buildParticipation"),
ASSETS_LABEL_ASSET_ID("assets.label.assetId"),
ASSETS_LABEL_TYPE("assets.label.type"),
ASSETS_LABEL_STUDIO_ROLE("assets.label.studioRole"),
ASSETS_LABEL_SCENE_STATUS("assets.label.sceneStatus"),
ASSETS_LABEL_SCENE_LAYERS("assets.label.sceneLayers"),
ASSETS_LABEL_SCENE_DIMENSIONS("assets.label.sceneDimensions"),
ASSETS_LABEL_LAYER("assets.label.layer"),
ASSETS_LABEL_LAYER_OVERVIEW("assets.label.layerOverview"),
ASSETS_LABEL_TILEMAPS("assets.label.tilemaps"),
ASSETS_LABEL_TILEMAP("assets.label.tilemap"),
ASSETS_LABEL_TILESET("assets.label.tileset"),
ASSETS_LABEL_SUPPORT_FILE("assets.label.supportFile"),
ASSETS_LABEL_USE_LAYER("assets.label.useLayer"),
ASSETS_VALUE_NOT_CONFIGURED("assets.value.notConfigured"),
ASSETS_VALUE_LAYER_OVERVIEW_PENDING("assets.value.layerOverviewPending"),
ASSETS_TYPE_GLYPH_BANK("assets.type.glyphBank"),
ASSETS_TYPE_SCENE_BANK("assets.type.sceneBank"),
ASSETS_TYPE_PALETTE_BANK("assets.type.paletteBank"),
ASSETS_TYPE_SOUND_BANK("assets.type.soundBank"),
ASSETS_TYPE_UNKNOWN("assets.type.unknown"),
ASSETS_SPECIALIZATION_NONE("assets.specialization.none"),
ASSETS_SPECIALIZATION_TILESET("assets.specialization.tileset"),
ASSETS_SPECIALIZATION_SPRITES("assets.specialization.sprites"),
ASSETS_SPECIALIZATION_UI("assets.specialization.ui"),
ASSETS_SCENE_STATUS_PENDING_VALIDATION("assets.sceneStatus.pendingValidation"),
ASSETS_SCENE_STATUS_VALIDATED_PENDING_ACCEPTANCE("assets.sceneStatus.validatedPendingAcceptance"),
ASSETS_SCENE_STATUS_READY("assets.sceneStatus.ready"),
ASSETS_SCENE_STATUS_VALIDATION_FAILED("assets.sceneStatus.validationFailed"),
ASSETS_LABEL_LOCATION("assets.label.location"),
ASSETS_LABEL_BANK("assets.label.bank"),
ASSETS_LABEL_TARGET_LOCATION("assets.label.targetLocation"),
@ -241,6 +273,22 @@ public enum I18n {
ASSETS_PALETTE_OVERHAULING_TILE_SELECTOR("assets.paletteOverhauling.tileSelector"),
ASSETS_PALETTE_OVERHAULING_APPLIED_PALETTE("assets.paletteOverhauling.appliedPalette"),
ASSETS_PALETTE_OVERHAULING_PREVIEW_EMPTY("assets.paletteOverhauling.previewEmpty"),
ASSETS_PALETTE_WIZARD_CREATE_TITLE("assets.paletteWizard.createTitle"),
ASSETS_PALETTE_WIZARD_UPDATE_TITLE("assets.paletteWizard.updateTitle"),
ASSETS_PALETTE_WIZARD_NAME("assets.paletteWizard.name"),
ASSETS_PALETTE_WIZARD_SCHEMA("assets.paletteWizard.schema"),
ASSETS_PALETTE_WIZARD_ORIGINAL("assets.paletteWizard.original"),
ASSETS_PALETTE_WIZARD_EDITABLE("assets.paletteWizard.editable"),
ASSETS_PALETTE_WIZARD_APPLY_COLOR("assets.paletteWizard.applyColor"),
ASSETS_PALETTE_WIZARD_DUPLICATE("assets.paletteWizard.duplicate"),
ASSETS_PALETTE_WIZARD_READY("assets.paletteWizard.ready"),
ASSETS_COLOR_SCHEMA_EMPTY("assets.colorSchema.empty"),
ASSETS_COLOR_SCHEMA_SUMMARY("assets.colorSchema.summary"),
ASSETS_COLOR_SCHEMA_REMOVE_BLOCKED("assets.colorSchema.removeBlocked"),
ASSETS_COLOR_SCHEMA_DIALOG_AVAILABLE_TILES("assets.colorSchema.dialog.availableTiles"),
ASSETS_COLOR_SCHEMA_DIALOG_ASSIGNED_TILES("assets.colorSchema.dialog.assignedTiles"),
ASSETS_COLOR_SCHEMA_DIALOG_COLOR_COUNT("assets.colorSchema.dialog.colorCount"),
ASSETS_COLOR_SCHEMA_DIALOG_TOO_MANY_COLORS("assets.colorSchema.dialog.tooManyColors"),
ASSETS_DETAILS_CODEC_CONFIGURATION_EMPTY("assets.details.codecConfiguration.empty"),
ASSETS_DETAILS_METADATA_EMPTY("assets.details.metadata.empty"),
ASSETS_ADD_WIZARD_TITLE("assets.addWizard.title"),
@ -254,10 +302,12 @@ public enum I18n {
ASSETS_ADD_WIZARD_LABEL_NAME("assets.addWizard.label.name"),
ASSETS_ADD_WIZARD_LABEL_ROOT("assets.addWizard.label.root"),
ASSETS_ADD_WIZARD_LABEL_TYPE("assets.addWizard.label.type"),
ASSETS_ADD_WIZARD_LABEL_SPECIALIZATION("assets.addWizard.label.specialization"),
ASSETS_ADD_WIZARD_LABEL_FORMAT("assets.addWizard.label.format"),
ASSETS_ADD_WIZARD_LABEL_CODEC("assets.addWizard.label.codec"),
ASSETS_ADD_WIZARD_LABEL_PRELOAD("assets.addWizard.label.preload"),
ASSETS_ADD_WIZARD_PROMPT_TYPE("assets.addWizard.prompt.type"),
ASSETS_ADD_WIZARD_PROMPT_SPECIALIZATION("assets.addWizard.prompt.specialization"),
ASSETS_ADD_WIZARD_PROMPT_FORMAT("assets.addWizard.prompt.format"),
ASSETS_ADD_WIZARD_PROMPT_CODEC("assets.addWizard.prompt.codec"),
ASSETS_ADD_WIZARD_ASSETS_ROOT_HINT("assets.addWizard.assetsRootHint"),
@ -270,6 +320,7 @@ public enum I18n {
ASSETS_ADD_WIZARD_ERROR_ROOT_OUTSIDE_ASSETS("assets.addWizard.error.rootOutsideAssets"),
ASSETS_ADD_WIZARD_ERROR_ROOT_ALREADY_ASSET("assets.addWizard.error.rootAlreadyAsset"),
ASSETS_ADD_WIZARD_ERROR_TYPE("assets.addWizard.error.type"),
ASSETS_ADD_WIZARD_ERROR_SPECIALIZATION("assets.addWizard.error.specialization"),
ASSETS_ADD_WIZARD_ERROR_FORMAT("assets.addWizard.error.format"),
ASSETS_ADD_WIZARD_ERROR_CODEC("assets.addWizard.error.codec"),
ASSETS_ADD_WIZARD_ERROR_UNSUPPORTED_COMBINATION("assets.addWizard.error.unsupportedCombination"),

View File

@ -12,6 +12,7 @@ import p.packer.dtos.PackerAssetActionAvailabilityDTO;
import p.packer.dtos.PackerAssetDetailsDTO;
import p.packer.dtos.PackerDiagnosticDTO;
import p.packer.messages.*;
import p.packer.messages.assets.AssetFamilyCatalog;
import p.studio.Container;
import p.studio.controls.forms.StudioFormEditScopeChangedEvent;
import p.studio.controls.forms.StudioSection;
@ -22,22 +23,34 @@ import p.studio.workspaces.assets.dialogs.AssetDiagnosticsDialog;
import p.studio.workspaces.assets.details.bank.AssetDetailsBankCompositionControl;
import p.studio.workspaces.assets.details.contract.AssetDetailsContractControl;
import p.studio.workspaces.assets.details.palette.AssetDetailsPaletteOverhaulingControl;
import p.studio.workspaces.assets.details.schema.AssetDetailsColorSchemaGroupsControl;
import p.studio.workspaces.assets.details.scene.AssetDetailsSceneLayersControl;
import p.studio.workspaces.assets.details.summary.AssetDetailsSummaryControl;
import p.studio.workspaces.assets.metadata.AssetStudioMetadataService;
import p.studio.workspaces.assets.metadata.AssetStudioMetadataSnapshot;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization;
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetAction;
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionDetails;
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails;
import p.studio.workspaces.assets.messages.AssetWorkspaceSceneBankValidation;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsStatus;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState;
import p.studio.workspaces.assets.messages.AssetWorkspaceBuildParticipation;
import p.studio.workspaces.assets.messages.events.StudioAssetLogEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceSelectionRequestedEvent;
import p.studio.workspaces.assets.scene.SceneBankWorkflowResult;
import p.studio.workspaces.assets.scene.SceneBankWorkflowService;
import p.studio.workspaces.assets.tiled.TiledAssetGenerationResult;
import p.studio.workspaces.assets.tiled.TiledAssetGenerationService;
import p.studio.workspaces.assets.wizards.DeleteAssetDialog;
import p.studio.workspaces.assets.wizards.MoveAssetWizard;
import p.studio.workspaces.framework.StudioEventAware;
import p.studio.workspaces.framework.StudioEventBindings;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
@ -52,7 +65,12 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
private final AssetDetailsSummaryControl summaryControl;
private final AssetDetailsContractControl contractControl;
private final AssetDetailsBankCompositionControl bankCompositionControl;
private final AssetDetailsColorSchemaGroupsControl colorSchemaGroupsControl;
private final AssetDetailsPaletteOverhaulingControl paletteOverhaulingControl;
private final AssetDetailsSceneLayersControl sceneLayersControl;
private final AssetStudioMetadataService studioMetadataService = new AssetStudioMetadataService();
private final TiledAssetGenerationService tiledGenerationService = new TiledAssetGenerationService();
private final SceneBankWorkflowService sceneBankWorkflowService = new SceneBankWorkflowService();
private final VBox actionsContent = new VBox(10);
private final ScrollPane actionsScroll = new ScrollPane();
private final VBox actionsSection;
@ -72,7 +90,12 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
this.summaryControl = new AssetDetailsSummaryControl(projectReference, workspaceBus);
this.contractControl = new AssetDetailsContractControl(projectReference, workspaceBus);
this.bankCompositionControl = new AssetDetailsBankCompositionControl(projectReference, workspaceBus);
this.colorSchemaGroupsControl = new AssetDetailsColorSchemaGroupsControl(projectReference, workspaceBus);
this.paletteOverhaulingControl = new AssetDetailsPaletteOverhaulingControl(projectReference, workspaceBus);
this.sceneLayersControl = new AssetDetailsSceneLayersControl(projectReference, workspaceBus);
this.sceneLayersControl.setGenerateLayerAction(this::generateTmx);
this.sceneLayersControl.setValidateLayerAction(this::validateSceneBank);
this.sceneLayersControl.setAcceptLayerAction(this::acceptSceneBank);
this.actionsSection = createActionsSection();
getStyleClass().add("assets-workspace-pane");
@ -256,13 +279,43 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
renderActions();
if (!readyMounted) {
readyMounted = true;
detailsContent.getChildren().setAll(primarySectionsRow, contractControl, bankCompositionControl, paletteOverhaulingControl);
detailsContent.getChildren().setAll(buildDetailsSections());
}
syncActionsSectionHeight(summaryControl.getHeight());
}
}
}
private Node[] buildDetailsSections() {
final java.util.List<Node> sections = new java.util.ArrayList<>();
sections.add(primarySectionsRow);
sections.add(contractControl);
if (showsSceneLayers(viewState.selectedAssetDetails())) {
sections.add(sceneLayersControl);
} else if (!hidesBankComposition(viewState.selectedAssetDetails())) {
sections.add(bankCompositionControl);
}
if (showsColorSchemaGroups(viewState.selectedAssetDetails())) {
sections.add(colorSchemaGroupsControl);
}
sections.add(paletteOverhaulingControl);
return sections.toArray(Node[]::new);
}
private boolean hidesBankComposition(AssetWorkspaceAssetDetails details) {
return details != null && details.summary().assetFamily() == AssetFamilyCatalog.SCENE_BANK;
}
private boolean showsSceneLayers(AssetWorkspaceAssetDetails details) {
return details != null
&& details.summary().assetFamily() == AssetFamilyCatalog.SCENE_BANK
&& details.sceneBankMetadata() != null;
}
private boolean showsColorSchemaGroups(AssetWorkspaceAssetDetails details) {
return details != null && details.summary().assetFamily() == AssetFamilyCatalog.GLYPH_BANK;
}
private VBox createActionsSection() {
actionsContent.getStyleClass().add("assets-details-actions-content");
actionsScroll.setContent(actionsContent);
@ -303,9 +356,6 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
private java.util.List<Node> buildActionNodes() {
final java.util.List<Node> nodes = new java.util.ArrayList<>();
if (actionFeedbackMessage != null && !actionFeedbackMessage.isBlank()) {
nodes.add(AssetDetailsUiSupport.createSectionMessage(actionFeedbackMessage));
}
if (viewState.selectedAssetDetails() == null) {
nodes.add(AssetDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_ACTIONS_EMPTY)));
return nodes;
@ -324,7 +374,12 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
analyseButton.setDisable(actionRunning || viewState.selectedAssetDetails().diagnostics().isEmpty());
analyseButton.setOnAction(ignored -> openDiagnosticsDialog());
nodes.add(analyseButton);
if (canGenerateTsx()) {
final Button generateTsxButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_GENERATE_TSX));
generateTsxButton.setDisable(actionRunning);
generateTsxButton.setOnAction(ignored -> generateTsx());
nodes.add(generateTsxButton);
}
final Button buildParticipationButton = AssetDetailsUiSupport.createActionButton(buildParticipationActionLabel());
AssetDetailsUiSupport.applyActionTone(
buildParticipationButton,
@ -413,7 +468,16 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
private boolean canToggleBuildParticipation() {
return viewState.selectedAssetDetails() != null
&& viewState.selectedAssetDetails().summary().state() == p.studio.workspaces.assets.messages.AssetWorkspaceAssetState.REGISTERED;
&& viewState.selectedAssetDetails().summary().state() == p.studio.workspaces.assets.messages.AssetWorkspaceAssetState.REGISTERED
&& (!isSceneBankAwaitingActiveLayerForBuild());
}
private boolean isSceneBankAwaitingActiveLayerForBuild() {
return viewState.selectedAssetDetails() != null
&& viewState.selectedAssetDetails().summary().assetFamily() == AssetFamilyCatalog.SCENE_BANK
&& currentBuildParticipation() != AssetWorkspaceBuildParticipation.INCLUDED
&& (viewState.selectedAssetDetails().sceneBankMetadata() == null
|| viewState.selectedAssetDetails().sceneBankMetadata().enabledLayerCount() == 0);
}
private AssetWorkspaceBuildParticipation currentBuildParticipation() {
@ -449,6 +513,98 @@ 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 false;
}
private boolean canValidateSceneBank() {
return false;
}
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("glyph-bank-tileset", result));
});
}
private void generateTmx(int layerIndex) {
if (actionRunning || viewState.selectedAssetDetails() == null) {
return;
}
actionRunning = true;
actionFeedbackMessage = null;
renderActions();
final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails();
Container.backgroundTasks().submit(() -> {
final TiledAssetGenerationResult result = tiledGenerationService.generateSceneBankTilemap(projectReference, details, layerIndex);
Platform.runLater(() -> applyTiledGenerationResult("scene-bank-layer-" + layerIndex, result));
});
}
private void applyTiledGenerationResult(String source, TiledAssetGenerationResult result) {
actionRunning = false;
actionFeedbackMessage = result.message();
workspaceBus.publish(new StudioAssetLogEvent(source, 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(int layerIndex) {
if (actionRunning || viewState.selectedAssetDetails() == null) {
return;
}
actionRunning = true;
actionFeedbackMessage = null;
renderActions();
final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails();
Container.backgroundTasks().submit(() -> {
final SceneBankWorkflowResult result = sceneBankWorkflowService.validateLayer(projectReference, details, layerIndex);
Platform.runLater(() -> applySceneBankWorkflowResult("scene-bank-validate-layer-" + layerIndex, result));
});
}
private void acceptSceneBank(int layerIndex) {
if (actionRunning || viewState.selectedAssetDetails() == null) {
return;
}
actionRunning = true;
actionFeedbackMessage = null;
renderActions();
final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails();
Container.backgroundTasks().submit(() -> {
final SceneBankWorkflowResult result = sceneBankWorkflowService.acceptLayer(projectReference, details, layerIndex);
Platform.runLater(() -> applySceneBankWorkflowResult("scene-bank-accept-layer-" + layerIndex, result));
});
}
private void applySceneBankWorkflowResult(String source, SceneBankWorkflowResult result) {
actionRunning = false;
actionFeedbackMessage = result.message();
workspaceBus.publish(new StudioAssetLogEvent(source, result.message()));
renderActions();
if (viewState.selectedAssetReference() != null) {
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(viewState.selectedAssetReference()));
workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(viewState.selectedAssetReference(), true));
}
}
private void applyBuildParticipationResult(UpdateAssetBuildParticipationResult result) {
actionRunning = false;
if (result.status() == p.packer.messages.PackerOperationStatus.SUCCESS && result.assetReference() != null) {
@ -534,29 +690,71 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
PackerAssetDetailsDTO details,
java.util.List<PackerDiagnosticDTO> diagnostics,
java.util.List<PackerAssetActionAvailabilityDTO> actions) {
final java.util.List<PackerDiagnosticDTO> mergedDiagnostics = new java.util.ArrayList<>(details.diagnostics());
for (PackerDiagnosticDTO diagnostic : diagnostics) {
if (!mergedDiagnostics.contains(diagnostic)) {
mergedDiagnostics.add(diagnostic);
}
}
return new AssetWorkspaceAssetDetails(
AssetListPackerMappings.mapSummary(details.summary()),
actions.stream()
.map(action -> new AssetWorkspaceAssetAction(
action.action(),
action.enabled(),
action.visible(),
action.reason()))
.toList(),
final var baseSummary = AssetListPackerMappings.mapSummary(details.summary());
final AssetStudioMetadataSnapshot studioMetadata = studioMetadataService.read(
baseSummary.assetRoot(),
baseSummary.assetFamily());
final java.util.List<AssetWorkspaceAssetAction> mappedActions = actions.stream()
.map(action -> new AssetWorkspaceAssetAction(
action.action(),
action.enabled(),
action.visible(),
action.reason()))
.toList();
final p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary mappedSummary =
new p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary(
baseSummary.assetReference(),
baseSummary.assetName(),
baseSummary.state(),
baseSummary.buildParticipation(),
baseSummary.assetId(),
baseSummary.assetFamily(),
studioMetadata.glyphSpecialization(),
baseSummary.assetRoot(),
baseSummary.preload(),
baseSummary.hasDiagnostics());
final AssetWorkspaceBankCompositionDetails bankComposition = mapBankComposition(details.bankComposition());
final AssetWorkspaceAssetDetails draftDetails = new AssetWorkspaceAssetDetails(
mappedSummary,
mappedActions,
details.outputFormat(),
details.outputCodec(),
details.availableOutputCodecs(),
details.codecConfigurationFieldsByCodec(),
details.metadataFields(),
details.outputPipeline(),
mapBankComposition(details.bankComposition()),
bankComposition,
details.pipelinePalettes(),
studioMetadata.glyphMaterialMetadata(),
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.glyphMaterialMetadata(),
studioMetadata.sceneBankMetadata(),
sceneBankWorkflow.validation(),
mergedDiagnostics);
}

View File

@ -16,6 +16,8 @@ import p.studio.controls.forms.StudioFormSection;
import p.studio.controls.forms.StudioSection;
import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization;
import p.studio.workspaces.assets.messages.AssetWorkspaceSceneBankStatus;
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetState;
import p.studio.workspaces.assets.messages.AssetWorkspaceBuildParticipation;
@ -29,6 +31,10 @@ public final class AssetDetailsUiSupport {
return StudioSection.createSection(title, content);
}
public static VBox createSectionWithoutHeader(Node content) {
return StudioSection.createSectionWithoutHeader(content);
}
public static VBox createSection(String title, Node headerActions, Node content) {
return StudioFormSection.createEditableSection(title, headerActions, content, StudioFormMode.READ_ONLY);
}
@ -74,6 +80,14 @@ public final class AssetDetailsUiSupport {
return chip;
}
public static Label createChip(String toneClass, String text, String... extraClasses) {
final Label chip = createChip(toneClass, text);
if (extraClasses != null && extraClasses.length > 0) {
chip.getStyleClass().addAll(extraClasses);
}
return chip;
}
public static Button createActionButton(String text) {
final Button button = new Button(text);
button.getStyleClass().addAll("studio-button", "studio-button-primary", "assets-details-action-button");
@ -114,6 +128,7 @@ public final class AssetDetailsUiSupport {
public static String typeLabel(AssetFamilyCatalog assetFamily) {
return switch (assetFamily) {
case GLYPH_BANK -> Container.i18n().text(I18n.ASSETS_TYPE_GLYPH_BANK);
case SCENE_BANK -> Container.i18n().text(I18n.ASSETS_TYPE_SCENE_BANK);
case SOUND_BANK -> Container.i18n().text(I18n.ASSETS_TYPE_SOUND_BANK);
case UNKNOWN -> Container.i18n().text(I18n.ASSETS_TYPE_UNKNOWN);
};
@ -122,11 +137,31 @@ public final class AssetDetailsUiSupport {
public static String typeChipTone(AssetFamilyCatalog assetFamily) {
return switch (assetFamily) {
case GLYPH_BANK -> "assets-details-chip-image";
case SCENE_BANK -> "assets-details-chip-scene";
case SOUND_BANK -> "assets-details-chip-audio";
case UNKNOWN -> "assets-details-chip-generic";
};
}
public static String specializationLabel(AssetStudioGlyphSpecialization specialization) {
return switch (specialization) {
case NONE -> Container.i18n().text(I18n.ASSETS_SPECIALIZATION_NONE);
case TILESET -> Container.i18n().text(I18n.ASSETS_SPECIALIZATION_TILESET);
case SPRITES -> Container.i18n().text(I18n.ASSETS_SPECIALIZATION_SPRITES);
case UI -> Container.i18n().text(I18n.ASSETS_SPECIALIZATION_UI);
};
}
public static String sceneBankStatusLabel(AssetWorkspaceSceneBankStatus status) {
return switch (status) {
case NOT_APPLICABLE -> "";
case PENDING_VALIDATION -> Container.i18n().text(I18n.ASSETS_SCENE_STATUS_PENDING_VALIDATION);
case VALIDATED_PENDING_ACCEPTANCE -> Container.i18n().text(I18n.ASSETS_SCENE_STATUS_VALIDATED_PENDING_ACCEPTANCE);
case READY -> Container.i18n().text(I18n.ASSETS_SCENE_STATUS_READY);
case VALIDATION_FAILED -> Container.i18n().text(I18n.ASSETS_SCENE_STATUS_VALIDATION_FAILED);
};
}
public static String actionLabel(AssetAction action) {
return switch (action) {
case REGISTER -> Container.i18n().text(I18n.ASSETS_ACTION_REGISTER);

View File

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

View File

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

View File

@ -5,7 +5,10 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import p.packer.dtos.PackerBankCompositionDetailsDTO;
import p.packer.dtos.PackerBankCompositionFileDTO;
import p.packer.messages.ApplyBankCompositionRequest;
import p.packer.messages.GetAssetDetailsRequest;
import p.studio.Container;
import p.studio.controls.banks.StudioAssetCapacityMeter;
import p.studio.controls.forms.StudioFormEditScopeChangedEvent;
@ -15,12 +18,14 @@ import p.studio.lsp.events.StudioWorkspaceEventBus;
import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState;
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionDetails;
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
import p.studio.workspaces.assets.messages.events.StudioAssetBankCompositionAppliedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetBankCompositionApplyFailedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetBankCompositionApplyRequestedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetBankCompositionCapacityChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetBankCompositionDraftChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent;
import p.studio.workspaces.assets.messages.events.StudioBankCompositionChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent;
import p.studio.workspaces.framework.StudioSubscriptionBag;
@ -76,6 +81,19 @@ public final class AssetDetailsBankCompositionControl extends StudioFormSection
}));
subscriptions.add(workspaceBus.subscribe(StudioFormEditScopeChangedEvent.class,
event -> handleEditScopeChanged(event, currentScopeKey())));
subscriptions.add(workspaceBus.subscribe(StudioBankCompositionChangedEvent.class, event -> {
if (viewState == null || !Objects.equals(viewState.selectedAssetReference(), event.assetReference())) {
return;
}
viewState = new AssetWorkspaceDetailsViewState(
viewState.detailsStatus(),
viewState.selectedAssetReference(),
viewState.selectedAssetDetails().withBankComposition(event.bankComposition()),
viewState.detailsErrorMessage());
coordinator.replaceDetails(viewState.selectedAssetDetails());
renderSection();
publishStateNotifications();
}));
}
@Override
@ -181,12 +199,9 @@ public final class AssetDetailsBankCompositionControl extends StudioFormSection
final var response = Container.packer().workspaceService().applyBankComposition(request);
Platform.runLater(() -> {
if (response.success()) {
coordinator.apply();
publishEditScope(workspaceBus, currentScopeKey(), null);
renderSection();
publishStateNotifications();
workspaceBus.publish(new StudioAssetBankCompositionAppliedEvent(assetReference));
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(assetReference));
Container.backgroundTasks().submit(() -> publishAppliedBankComposition(assetReference));
} else {
workspaceBus.publish(new StudioAssetBankCompositionApplyFailedEvent(
assetReference,
@ -201,6 +216,37 @@ public final class AssetDetailsBankCompositionControl extends StudioFormSection
});
}
private void publishAppliedBankComposition(p.packer.messages.AssetReference assetReference) {
try {
final var detailsResult = Container.packer().workspaceService().getAssetDetails(new GetAssetDetailsRequest(
projectReference.toPackerProjectContext(),
assetReference));
final AssetWorkspaceBankCompositionDetails bankComposition = mapBankComposition(detailsResult.details().bankComposition());
Platform.runLater(() -> workspaceBus.publish(new StudioBankCompositionChangedEvent(assetReference, bankComposition)));
} catch (Exception exception) {
Platform.runLater(() -> workspaceBus.publish(new StudioAssetBankCompositionApplyFailedEvent(
assetReference,
exception.getMessage())));
}
}
private AssetWorkspaceBankCompositionDetails mapBankComposition(PackerBankCompositionDetailsDTO details) {
return new AssetWorkspaceBankCompositionDetails(
details.availableFiles().stream().map(this::mapBankCompositionFile).toList(),
details.selectedFiles().stream().map(this::mapBankCompositionFile).toList(),
details.measuredBankSizeBytes());
}
private AssetWorkspaceBankCompositionFile mapBankCompositionFile(PackerBankCompositionFileDTO file) {
return new AssetWorkspaceBankCompositionFile(
file.path(),
file.displayName(),
file.size(),
file.lastModified(),
file.fingerprint(),
file.metadata());
}
@Override
protected void reset() {
coordinator.reset();

View File

@ -1,6 +1,7 @@
package p.studio.workspaces.assets.details.palette;
import javafx.application.Platform;
import javafx.scene.control.Button;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
@ -18,8 +19,12 @@ import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState;
import p.studio.workspaces.assets.messages.events.StudioAssetLogEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioGlyphMaterialMetadataChangedEvent;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioMetadataService;
import p.studio.workspaces.framework.StudioSubscriptionBag;
import java.io.IOException;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
@ -31,9 +36,12 @@ public final class AssetDetailsPaletteOverhaulingControl extends StudioFormSecti
private final StudioWorkspaceEventBus workspaceBus;
private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag();
private final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
private final AssetStudioMetadataService metadataService = new AssetStudioMetadataService();
private final AssetDetailsPaletteOverhaulingDualListView dualListView = new AssetDetailsPaletteOverhaulingDualListView();
private final AssetDetailsPaletteOverhaulingPreviewPane previewPane = new AssetDetailsPaletteOverhaulingPreviewPane();
private final HBox body = new HBox(16, dualListView, previewPane);
private final VBox paletteActions = new VBox(6);
private final HBox listWithActions = new HBox(8, paletteActions, dualListView);
private final HBox body = new HBox(16, listWithActions, previewPane);
private final VBox content = new VBox(12, body);
private AssetWorkspaceDetailsViewState viewState;
@ -42,9 +50,12 @@ public final class AssetDetailsPaletteOverhaulingControl extends StudioFormSecti
this.projectReference = Objects.requireNonNull(projectReference, "projectReference");
this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus");
body.getStyleClass().add("assets-details-palette-overhauling-body");
paletteActions.getStyleClass().add("assets-details-palette-overhauling-toolbar");
listWithActions.getStyleClass().add("assets-details-list-with-actions");
content.setFillWidth(true);
HBox.setHgrow(dualListView, Priority.ALWAYS);
HBox.setHgrow(listWithActions, Priority.ALWAYS);
HBox.setHgrow(previewPane, Priority.ALWAYS);
HBox.setHgrow(dualListView, Priority.ALWAYS);
dualListView.setMaxWidth(Double.MAX_VALUE);
previewPane.setMaxWidth(Double.MAX_VALUE);
}
@ -61,6 +72,13 @@ public final class AssetDetailsPaletteOverhaulingControl extends StudioFormSecti
}));
subscriptions.add(workspaceBus.subscribe(StudioFormEditScopeChangedEvent.class,
event -> handleEditScopeChanged(event, currentScopeKey())));
subscriptions.add(workspaceBus.subscribe(StudioGlyphMaterialMetadataChangedEvent.class, event -> {
if (viewState == null || !Objects.equals(viewState.selectedAssetReference(), event.assetReference())) {
return;
}
coordinator.replaceMaterialMetadata(event.metadata());
renderSection();
}));
}
@Override
@ -110,6 +128,8 @@ public final class AssetDetailsPaletteOverhaulingControl extends StudioFormSecti
coordinator.viewModel().renderableFileCount());
}
});
dualListView.setOnLeftItemActivated(item -> openPaletteWizard(item));
dualListView.setOnRightItemActivated(item -> openPaletteWizard(item));
if (viewModel.previewPaletteFile() != null) {
if (viewModel.selectedFiles().contains(viewModel.previewPaletteFile())) {
dualListView.selectRightItem(viewModel.previewPaletteFile());
@ -134,6 +154,17 @@ public final class AssetDetailsPaletteOverhaulingControl extends StudioFormSecti
viewModel.previewPaletteFile(),
viewModel.renderableFileCount());
final Button addButton = new Button("+");
addButton.getStyleClass().addAll("studio-button", "studio-button-secondary", "assets-details-mini-action-button");
addButton.setDisable(!viewModel.editing() || viewState == null || viewState.selectedAssetDetails() == null);
addButton.setOnAction(ignored -> openPaletteWizard(null));
final Button removeButton = new Button("-");
removeButton.getStyleClass().addAll("studio-button", "studio-button-secondary", "assets-details-mini-action-button");
removeButton.setDisable(!viewModel.editing() || viewModel.previewPaletteFile() == null);
removeButton.setOnAction(ignored -> removePalette(coordinator.viewModel().previewPaletteFile()));
paletteActions.getChildren().setAll(addButton, removeButton);
renderFormSection(content);
}
@ -232,6 +263,67 @@ public final class AssetDetailsPaletteOverhaulingControl extends StudioFormSecti
: "asset-details:" + viewState.selectedAssetReference();
}
private void openPaletteWizard(p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile paletteFile) {
if (getScene() == null || !coordinator.ready() || viewState == null || viewState.selectedAssetDetails() == null) {
return;
}
if (AssetDetailsPaletteOverhaulingCoordinator.paletteIsOriginal(paletteFile)) {
return;
}
final var result = AssetDetailsPaletteWizardDialog.showAndWait(
getScene().getWindow(),
coordinator,
paletteFile);
result.ifPresent(edit -> Container.backgroundTasks().submit(() -> {
try {
final AssetStudioGlyphMaterialMetadata metadata = coordinator.upsertPalette(
edit.paletteId(),
edit.displayName(),
edit.schemaId(),
edit.colors());
metadataService.writeGlyphMaterialMetadata(viewState.selectedAssetDetails().summary().assetRoot(), metadata);
Platform.runLater(() -> {
workspaceBus.publish(new StudioAssetLogEvent("palette-overhauling", "Palette saved: " + edit.paletteId()));
workspaceBus.publish(new StudioGlyphMaterialMetadataChangedEvent(viewState.selectedAssetReference(), metadata));
});
} catch (IOException exception) {
Platform.runLater(() -> workspaceBus.publish(new StudioAssetLogEvent(
"palette-overhauling",
"Unable to save palette: " + Objects.requireNonNullElse(exception.getMessage(), "unknown error"))));
}
}));
}
private void removePalette(p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile paletteFile) {
if (paletteFile == null || viewState == null || viewState.selectedAssetDetails() == null || viewState.selectedAssetReference() == null) {
return;
}
if (AssetDetailsPaletteOverhaulingCoordinator.paletteIsOriginal(paletteFile)) {
workspaceBus.publish(new StudioAssetLogEvent(
"palette-overhauling",
"Original schema palettes cannot be removed."));
return;
}
final Object paletteIdValue = paletteFile.metadata().get("paletteId");
if (!(paletteIdValue instanceof String paletteId) || paletteId.isBlank()) {
return;
}
Container.backgroundTasks().submit(() -> {
try {
final AssetStudioGlyphMaterialMetadata metadata = coordinator.removePalette(paletteId);
metadataService.writeGlyphMaterialMetadata(viewState.selectedAssetDetails().summary().assetRoot(), metadata);
Platform.runLater(() -> {
workspaceBus.publish(new StudioAssetLogEvent("palette-overhauling", "Palette removed: " + paletteId));
workspaceBus.publish(new StudioGlyphMaterialMetadataChangedEvent(viewState.selectedAssetReference(), metadata));
});
} catch (Exception exception) {
Platform.runLater(() -> workspaceBus.publish(new StudioAssetLogEvent(
"palette-overhauling",
"Unable to remove palette: " + Objects.requireNonNullElse(exception.getMessage(), "unknown error"))));
}
});
}
@SuppressWarnings("unchecked")
private static Map<String, Object> palettePayload(Map<String, Object> metadata) {
final Object palette = metadata.get("palette");

View File

@ -5,24 +5,40 @@ import p.studio.controls.forms.StudioFormMode;
import p.studio.controls.forms.StudioFormSession;
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails;
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialArtifactBinding;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialPalette;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialSchema;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialSchemaStatus;
import p.studio.workspaces.assets.details.schema.AssetDetailsColorSchemaCoordinator;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.*;
public final class AssetDetailsPaletteOverhaulingCoordinator {
private StudioFormSession<AssetDetailsPaletteOverhaulingDraft> formSession;
private AssetWorkspaceAssetDetails details;
private AssetStudioGlyphMaterialMetadata localMaterialMetadata;
public void replaceDetails(AssetWorkspaceAssetDetails details) {
this.details = details;
if (details == null || details.summary().assetFamily() != AssetFamilyCatalog.GLYPH_BANK) {
formSession = null;
localMaterialMetadata = null;
return;
}
localMaterialMetadata = details.glyphMaterialMetadata();
rebuildSession();
}
public void replaceMaterialMetadata(AssetStudioGlyphMaterialMetadata metadata) {
if (details == null || details.summary().assetFamily() != AssetFamilyCatalog.GLYPH_BANK) {
return;
}
localMaterialMetadata = Objects.requireNonNull(metadata, "metadata");
rebuildSession();
}
private void rebuildSession() {
final List<AssetWorkspaceBankCompositionFile> allFiles = paletteFiles(details);
final List<AssetWorkspaceBankCompositionFile> previewTileFiles = details.bankComposition().selectedFiles().stream()
.filter(AssetDetailsPaletteOverhaulingCoordinator::supportsPalettePreview)
@ -37,12 +53,21 @@ public final class AssetDetailsPaletteOverhaulingCoordinator {
available,
initialSelected,
previewTileFiles.isEmpty() ? null : previewTileFiles.getFirst().path(),
null);
initialSelected.isEmpty()
? allFiles.isEmpty() ? null : allFiles.getFirst().path()
: initialSelected.getFirst().path());
if (formSession == null) {
formSession = new StudioFormSession<>(source);
return;
}
if (!Objects.equals(formSession.source(), source)) {
if (formSession.mode() == StudioFormMode.EDITING) {
final AssetDetailsPaletteOverhaulingDraft currentDraft = formSession.draft();
formSession = new StudioFormSession<>(source);
formSession.beginEdit();
formSession.setDraft(rebindDraft(currentDraft, source));
return;
}
formSession.replaceSource(source);
}
}
@ -51,6 +76,10 @@ public final class AssetDetailsPaletteOverhaulingCoordinator {
return formSession != null;
}
public AssetWorkspaceAssetDetails details() {
return details;
}
public void beginEdit() {
if (ready()) {
formSession.beginEdit();
@ -77,7 +106,10 @@ public final class AssetDetailsPaletteOverhaulingCoordinator {
public void moveToSelected(List<AssetWorkspaceBankCompositionFile> files) {
if (ready() && formSession.mode() == StudioFormMode.EDITING) {
formSession.updateDraft(current -> current.moveToSelected(files));
final List<AssetWorkspaceBankCompositionFile> validFiles = files.stream()
.filter(AssetDetailsPaletteOverhaulingCoordinator::paletteIsValid)
.toList();
formSession.updateDraft(current -> current.moveToSelected(validFiles));
}
}
@ -105,27 +137,118 @@ public final class AssetDetailsPaletteOverhaulingCoordinator {
}
final AssetDetailsPaletteOverhaulingDraft draft = formSession.draft();
final AssetWorkspaceBankCompositionFile previewPaletteFile = fileByPath(draft.allFiles(), draft.previewPalettePath());
final List<AssetWorkspaceBankCompositionFile> schemaPreviewTileFiles = previewTileFilesForPalette(
draft.previewTileFiles(),
previewPaletteFile);
final AssetWorkspaceBankCompositionFile previewTileFile = Optional
.ofNullable(fileByPath(schemaPreviewTileFiles, draft.previewTilePath()))
.orElse(schemaPreviewTileFiles.isEmpty() ? null : schemaPreviewTileFiles.getFirst());
return new AssetDetailsPaletteOverhaulingViewModel(
formSession.mode() == StudioFormMode.EDITING,
formSession.isDirty(),
draft.previewTileFiles(),
schemaPreviewTileFiles,
draft.allFiles(),
draft.availableFiles(),
draft.selectedFiles(),
fileByPath(draft.previewTileFiles(), draft.previewTilePath()),
fileByPath(draft.allFiles(), draft.previewPalettePath()),
previewTileFile,
previewPaletteFile,
draft.allFiles().size());
}
static boolean supportsPalettePreview(AssetWorkspaceBankCompositionFile file) {
public List<AssetStudioGlyphMaterialSchema> readySchemas() {
return materialMetadata().schemas().stream()
.filter(schema -> schema.status() == AssetStudioGlyphMaterialSchemaStatus.OK)
.toList();
}
public String nextPaletteId() {
int next = 1;
final Set<String> ids = materialMetadata().palettes().stream()
.map(AssetStudioGlyphMaterialPalette::paletteId)
.collect(java.util.stream.Collectors.toSet());
while (ids.contains("palette-" + next)) {
next += 1;
}
return "palette-" + next;
}
public boolean duplicatePaletteExists(String paletteId, String schemaId, List<String> colors) {
final List<String> normalized = colors.stream().map(AssetDetailsPaletteOverhaulingCoordinator::normalizeHtmlColor).toList();
return allPaletteRecords().stream()
.filter(record -> !Objects.equals(record.paletteId(), paletteId))
.anyMatch(record -> record.schemaId().equals(schemaId) && record.colors().equals(normalized));
}
public AssetStudioGlyphMaterialMetadata upsertPalette(String paletteId, String displayName, String schemaId, List<String> colors) {
final AssetStudioGlyphMaterialMetadata current = materialMetadata();
final List<String> normalized = colors.stream().map(AssetDetailsPaletteOverhaulingCoordinator::normalizeHtmlColor).toList();
final int schemaVersion = current.findSchema(schemaId).map(AssetStudioGlyphMaterialSchema::version).orElse(1);
final String resolvedDisplayName = displayName == null || displayName.trim().isBlank()
? generatedDisplayName(normalized)
: displayName.trim();
final AssetStudioGlyphMaterialPalette next = new AssetStudioGlyphMaterialPalette(
paletteId,
resolvedDisplayName,
schemaId,
schemaVersion,
normalized);
final List<AssetStudioGlyphMaterialPalette> palettes = new ArrayList<>();
boolean replaced = false;
for (AssetStudioGlyphMaterialPalette palette : current.palettes()) {
if (palette.paletteId().equals(paletteId)) {
palettes.add(next);
replaced = true;
} else {
palettes.add(palette);
}
}
if (!replaced) {
palettes.add(next);
}
palettes.sort(Comparator.comparing(AssetStudioGlyphMaterialPalette::paletteId, String.CASE_INSENSITIVE_ORDER));
return new AssetStudioGlyphMaterialMetadata(current.schemas(), palettes, current.supportFile());
}
public AssetStudioGlyphMaterialMetadata removePalette(String paletteId) {
final AssetWorkspaceBankCompositionFile paletteFile = paletteFileById(paletteId);
if (paletteFile != null && Boolean.TRUE.equals(paletteFile.metadata().get("derivedOriginal"))) {
throw new IllegalStateException("Original schema palettes are derived and cannot be removed.");
}
final AssetStudioGlyphMaterialMetadata current = materialMetadata();
final List<AssetStudioGlyphMaterialPalette> palettes = current.palettes().stream()
.filter(palette -> !palette.paletteId().equals(paletteId))
.toList();
return new AssetStudioGlyphMaterialMetadata(current.schemas(), palettes, current.supportFile());
}
public AssetWorkspaceBankCompositionFile paletteFileById(String paletteId) {
if (paletteId == null || !ready()) {
return null;
}
return paletteFiles(details).stream()
.filter(file -> paletteId.equals(file.metadata().get("paletteId")))
.findFirst()
.orElse(null);
}
public static boolean supportsPalettePreview(AssetWorkspaceBankCompositionFile file) {
if (file == null) {
return false;
}
return nestedMap(file.metadata(), "tile") != null && nestedMap(file.metadata(), "palette") != null;
}
public static boolean paletteIsValid(AssetWorkspaceBankCompositionFile file) {
return !Boolean.FALSE.equals(file.metadata().get("valid"));
}
public static boolean paletteIsOriginal(AssetWorkspaceBankCompositionFile file) {
return file != null && Boolean.TRUE.equals(file.metadata().get("derivedOriginal"));
}
@SuppressWarnings("unchecked")
private static Map<String, Object> nestedMap(Map<String, Object> metadata, String key) {
static Map<String, Object> nestedMap(Map<String, Object> metadata, String key) {
final Object value = metadata.get(key);
if (value instanceof Map<?, ?> nested) {
return (Map<String, Object>) nested;
@ -133,37 +256,236 @@ public final class AssetDetailsPaletteOverhaulingCoordinator {
return null;
}
private static List<AssetWorkspaceBankCompositionFile> paletteFiles(AssetWorkspaceAssetDetails details) {
final Map<String, AssetWorkspaceBankCompositionFile> ordered = new LinkedHashMap<>();
// `bankComposition` files are already projected by the packer read model from the current cache/walk state.
for (AssetWorkspaceBankCompositionFile file : details.bankComposition().availableFiles()) {
if (supportsPalettePreview(file)) {
ordered.putIfAbsent(paletteHash(file), canonicalPaletteFile(file));
}
private AssetStudioGlyphMaterialMetadata materialMetadata() {
if (details == null) {
return new AssetStudioGlyphMaterialMetadata(List.of(), List.of(), null);
}
for (AssetWorkspaceBankCompositionFile file : details.bankComposition().selectedFiles()) {
if (supportsPalettePreview(file)) {
ordered.putIfAbsent(paletteHash(file), canonicalPaletteFile(file));
}
return localMaterialMetadata == null
? new AssetStudioGlyphMaterialMetadata(List.of(), List.of(), null)
: localMaterialMetadata;
}
private List<AssetWorkspaceBankCompositionFile> paletteFiles(AssetWorkspaceAssetDetails details) {
final Map<String, AssetWorkspaceBankCompositionFile> ordered = new LinkedHashMap<>();
for (PaletteRecord record : allPaletteRecords()) {
final AssetWorkspaceBankCompositionFile file = toPaletteFile(record);
ordered.putIfAbsent(file.path(), file);
}
return List.copyOf(ordered.values());
}
@SuppressWarnings("unchecked")
private static AssetWorkspaceBankCompositionFile canonicalPaletteFile(AssetWorkspaceBankCompositionFile file) {
final String hash = paletteHash(file);
final Map<String, Object> metadata = new LinkedHashMap<>(file.metadata());
metadata.put("paletteHash", hash);
private List<PaletteRecord> allPaletteRecords() {
final AssetStudioGlyphMaterialMetadata metadata = materialMetadata();
final List<PaletteRecord> records = new ArrayList<>();
for (AssetStudioGlyphMaterialSchema schema : metadata.schemas()) {
final AssetStudioGlyphMaterialSchemaStatus effectiveSchemaStatus = AssetDetailsColorSchemaCoordinator.effectiveStatus(
schema,
details.bankComposition().selectedFiles());
final List<String> originalColors = schema.materialIndices().entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(Map.Entry::getValue)
.toList();
records.add(new PaletteRecord(
"schema-" + schema.schemaId() + "-original",
null,
schema.schemaId(),
schema.version(),
originalColors,
schema.materialIndices(),
schemaArtifactIds(schema),
true,
effectiveSchemaStatus == AssetStudioGlyphMaterialSchemaStatus.OK,
effectiveSchemaStatus == AssetStudioGlyphMaterialSchemaStatus.OK
? null
: "Schema is inconsistent."));
}
for (AssetStudioGlyphMaterialPalette palette : metadata.palettes()) {
final AssetStudioGlyphMaterialSchema schema = metadata.findSchema(palette.schemaId()).orElse(null);
final AssetStudioGlyphMaterialSchemaStatus effectiveSchemaStatus = schema == null
? AssetStudioGlyphMaterialSchemaStatus.INCONSISTENT
: AssetDetailsColorSchemaCoordinator.effectiveStatus(schema, details.bankComposition().selectedFiles());
final boolean valid = schema != null
&& effectiveSchemaStatus == AssetStudioGlyphMaterialSchemaStatus.OK
&& palette.version() >= schema.version();
final String invalidReason;
if (schema == null) {
invalidReason = "Schema is missing.";
} else if (effectiveSchemaStatus != AssetStudioGlyphMaterialSchemaStatus.OK) {
invalidReason = "Schema is inconsistent.";
} else if (palette.version() < schema.version()) {
invalidReason = "Palette is stale and must be updated.";
} else {
invalidReason = null;
}
records.add(new PaletteRecord(
palette.paletteId(),
palette.displayName(),
palette.schemaId(),
palette.version(),
palette.colors(),
schema == null ? Map.of() : schema.materialIndices(),
schema == null ? Set.of() : schemaArtifactIds(schema),
false,
valid,
invalidReason));
}
records.sort(Comparator.comparing(PaletteRecord::paletteId, String.CASE_INSENSITIVE_ORDER));
return List.copyOf(records);
}
private AssetWorkspaceBankCompositionFile toPaletteFile(PaletteRecord record) {
final List<Integer> argb = record.colors().stream()
.map(AssetDetailsPaletteOverhaulingCoordinator::htmlToArgb8888)
.toList();
final List<Integer> rgb565 = record.colors().stream()
.map(AssetDetailsPaletteOverhaulingCoordinator::htmlToRgb565)
.toList();
final Map<String, Object> palette = Map.of(
"originalArgb8888", argb,
"convertedRgb565", rgb565);
final Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("palette", palette);
metadata.put("paletteId", record.paletteId());
metadata.put("paletteDisplayName", displayName(record));
if (record.displayName() != null) {
metadata.put("explicitPaletteDisplayName", record.displayName());
}
metadata.put("schemaId", record.schemaId());
metadata.put("schemaMaterialIndices", record.materialIndices());
metadata.put("schemaArtifactIds", List.copyOf(record.artifactIds()));
metadata.put("paletteVersion", record.version());
metadata.put("derivedOriginal", record.derivedOriginal());
metadata.put("valid", record.valid());
if (record.invalidReason() != null) {
metadata.put("invalidReason", record.invalidReason());
}
return new AssetWorkspaceBankCompositionFile(
hash,
hash,
file.size(),
file.lastModified(),
file.fingerprint(),
record.paletteId(),
displayName(record),
0L,
0L,
null,
metadata);
}
private static List<AssetWorkspaceBankCompositionFile> initialSelectedPaletteFiles(
static String generatedDisplayName(List<String> colors) {
final List<ColorSample> samples = colors.stream()
.map(AssetDetailsPaletteOverhaulingCoordinator::toColorSample)
.toList();
if (samples.isEmpty()) {
return "quiet shade";
}
final ColorSample anchor = samples.stream()
.min(Comparator.comparingDouble(sample -> sample.saturation < 0.16 ? sample.lightness : -sample.saturation))
.orElse(samples.getFirst());
final double averageLightness = samples.stream()
.mapToDouble(sample -> sample.lightness)
.average()
.orElse(anchor.lightness);
final double averageSaturation = samples.stream()
.mapToDouble(sample -> sample.saturation)
.average()
.orElse(anchor.saturation);
final double lightnessRange = samples.stream().mapToDouble(sample -> sample.lightness).max().orElse(anchor.lightness)
- samples.stream().mapToDouble(sample -> sample.lightness).min().orElse(anchor.lightness);
return baseName(anchor, averageLightness, averageSaturation) + " " + moodName(averageLightness, averageSaturation, lightnessRange, samples.size());
}
private static String baseName(ColorSample sample, double averageLightness, double averageSaturation) {
if (averageSaturation < 0.12) {
if (averageLightness > 0.88) {
return "snow";
}
if (averageLightness > 0.68) {
return "fog";
}
if (averageLightness < 0.24) {
return "ink";
}
return "ash";
}
final double hue = sample.hue;
if (hue < 18 || hue >= 345) {
return sample.lightness > 0.62 ? "rose" : "ember";
}
if (hue < 42) {
return sample.lightness > 0.62 ? "honey" : "copper";
}
if (hue < 72) {
return sample.lightness > 0.68 ? "sun" : "ochre";
}
if (hue < 155) {
return sample.lightness > 0.58 ? "moss" : "pine";
}
if (hue < 195) {
return sample.lightness > 0.62 ? "mint" : "jade";
}
if (hue < 245) {
return sample.lightness > 0.62 ? "sky" : "cobalt";
}
if (hue < 290) {
return sample.lightness > 0.62 ? "lilac" : "violet";
}
return sample.lightness > 0.62 ? "orchid" : "plum";
}
private static String moodName(double averageLightness, double averageSaturation, double lightnessRange, int colorCount) {
if (averageSaturation < 0.12 && averageLightness > 0.86) {
return "white";
}
if (colorCount > 1 && lightnessRange > 0.42) {
return "twist";
}
if (averageLightness > 0.78) {
return averageSaturation > 0.45 ? "glow" : "veil";
}
if (averageLightness < 0.26) {
return averageSaturation > 0.34 ? "deep" : "shade";
}
if (averageSaturation > 0.62) {
return "bloom";
}
if (averageSaturation < 0.22) {
return "haze";
}
return "drift";
}
private static ColorSample toColorSample(String htmlColor) {
final int rgb = Integer.parseInt(normalizeHtmlColor(htmlColor).substring(1), 16);
final double red = ((rgb >> 16) & 0xFF) / 255.0;
final double green = ((rgb >> 8) & 0xFF) / 255.0;
final double blue = (rgb & 0xFF) / 255.0;
final double max = Math.max(red, Math.max(green, blue));
final double min = Math.min(red, Math.min(green, blue));
final double delta = max - min;
final double lightness = (max + min) / 2.0;
final double saturation = delta == 0.0
? 0.0
: delta / (1.0 - Math.abs((2.0 * lightness) - 1.0));
final double hue;
if (delta == 0.0) {
hue = 0.0;
} else if (max == red) {
hue = 60.0 * (((green - blue) / delta) % 6.0);
} else if (max == green) {
hue = 60.0 * (((blue - red) / delta) + 2.0);
} else {
hue = 60.0 * (((red - green) / delta) + 4.0);
}
return new ColorSample(hue < 0.0 ? hue + 360.0 : hue, saturation, lightness);
}
private static String displayName(PaletteRecord record) {
if (record.derivedOriginal()) {
return record.schemaId() + " / original";
}
return record.displayName() == null || record.displayName().isBlank()
? generatedDisplayName(record.colors())
: record.displayName();
}
private List<AssetWorkspaceBankCompositionFile> initialSelectedPaletteFiles(
AssetWorkspaceAssetDetails details,
List<AssetWorkspaceBankCompositionFile> allFiles) {
final List<AssetWorkspaceBankCompositionFile> selected = new ArrayList<>();
@ -186,32 +508,21 @@ public final class AssetDetailsPaletteOverhaulingCoordinator {
&& Objects.equals(left.get("convertedRgb565"), right.get("convertedRgb565"));
}
@SuppressWarnings("unchecked")
private static String paletteHash(AssetWorkspaceBankCompositionFile file) {
final Map<String, Object> palette = nestedMap(file.metadata(), "palette");
final Object convertedValue = palette == null ? null : palette.get("convertedRgb565");
final List<Integer> colors = convertedValue instanceof List<?> converted
? (List<Integer>) converted
: List.of();
try {
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
for (int index = 0; index < 15; index++) {
final int color = index < colors.size() && colors.get(index) != null ? colors.get(index) : 0;
digest.update(ByteBuffer.allocate(4).putInt(color).array());
}
long value = 0L;
final byte[] bytes = digest.digest();
for (int index = 0; index < 8; index++) {
value = (value << 8) | Byte.toUnsignedLong(bytes[index]);
}
value &= 0x0FFFFFFFFFFFFFFFL;
final String base36 = Long.toUnsignedString(value, 36).toUpperCase();
return base36.length() >= 10
? base36.substring(0, 10)
: "0".repeat(10 - base36.length()) + base36;
} catch (Exception exception) {
throw new IllegalStateException("Unable to hash palette colors", exception);
}
private static int htmlToArgb8888(String htmlColor) {
final int rgb = Integer.parseInt(normalizeHtmlColor(htmlColor).substring(1), 16);
return 0xFF000000 | rgb;
}
private static int htmlToRgb565(String htmlColor) {
final int rgb = Integer.parseInt(normalizeHtmlColor(htmlColor).substring(1), 16);
final int red = (rgb >> 16) & 0xFF;
final int green = (rgb >> 8) & 0xFF;
final int blue = rgb & 0xFF;
return ((red >> 3) << 11) | ((green >> 2) << 5) | (blue >> 3);
}
private static String normalizeHtmlColor(String color) {
return Objects.requireNonNull(color, "color").trim().toUpperCase();
}
private static AssetWorkspaceBankCompositionFile fileByPath(List<AssetWorkspaceBankCompositionFile> files, String path) {
@ -223,4 +534,93 @@ public final class AssetDetailsPaletteOverhaulingCoordinator {
.findFirst()
.orElse(null);
}
private static AssetDetailsPaletteOverhaulingDraft rebindDraft(
AssetDetailsPaletteOverhaulingDraft draft,
AssetDetailsPaletteOverhaulingDraft source) {
final Set<String> selectedPaths = draft.selectedFiles().stream()
.map(AssetWorkspaceBankCompositionFile::path)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
final List<AssetWorkspaceBankCompositionFile> selected = source.allFiles().stream()
.filter(file -> selectedPaths.contains(file.path()))
.toList();
final List<AssetWorkspaceBankCompositionFile> available = source.allFiles().stream()
.filter(file -> !selectedPaths.contains(file.path()))
.toList();
final String previewPalettePath = fileByPath(source.allFiles(), draft.previewPalettePath()) == null
? source.previewPalettePath()
: draft.previewPalettePath();
final String previewTilePath = fileByPath(source.previewTileFiles(), draft.previewTilePath()) == null
? source.previewTilePath()
: draft.previewTilePath();
return new AssetDetailsPaletteOverhaulingDraft(
source.previewTileFiles(),
source.allFiles(),
available,
selected,
previewTilePath,
previewPalettePath);
}
private static Set<String> schemaArtifactIds(AssetStudioGlyphMaterialSchema schema) {
return schema.artifactBindings().stream()
.map(AssetStudioGlyphMaterialArtifactBinding::artifactId)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
}
private static List<AssetWorkspaceBankCompositionFile> previewTileFilesForPalette(
List<AssetWorkspaceBankCompositionFile> previewTileFiles,
AssetWorkspaceBankCompositionFile previewPaletteFile) {
if (previewPaletteFile == null) {
return previewTileFiles;
}
final Set<String> schemaArtifactIds = schemaArtifactIds(previewPaletteFile);
if (schemaArtifactIds.isEmpty()) {
return previewTileFiles;
}
return previewTileFiles.stream()
.filter(file -> schemaArtifactIds.contains(artifactId(file)))
.toList();
}
private static Set<String> schemaArtifactIds(AssetWorkspaceBankCompositionFile previewPaletteFile) {
final Object artifactIds = previewPaletteFile.metadata().get("schemaArtifactIds");
if (!(artifactIds instanceof Iterable<?> iterable)) {
return Set.of();
}
final Set<String> ids = new LinkedHashSet<>();
for (Object artifactId : iterable) {
if (artifactId instanceof String value && !value.isBlank()) {
ids.add(value.trim());
}
}
return Set.copyOf(ids);
}
private static String artifactId(AssetWorkspaceBankCompositionFile file) {
if (file == null) {
return null;
}
final Object artifactId = file.metadata().get("artifactId");
if (artifactId instanceof String value && !value.isBlank()) {
return value.trim();
}
return null;
}
private record PaletteRecord(
String paletteId,
String displayName,
String schemaId,
int version,
List<String> colors,
Map<Integer, String> materialIndices,
Set<String> artifactIds,
boolean derivedOriginal,
boolean valid,
String invalidReason) {
}
private record ColorSample(double hue, double saturation, double lightness) {
}
}

View File

@ -4,6 +4,7 @@ import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import p.studio.Container;
import p.studio.controls.banks.StudioDualListView;
import p.studio.utilities.i18n.I18n;
@ -28,8 +29,15 @@ public final class AssetDetailsPaletteOverhaulingDualListView extends StudioDual
final Label hashLabel = new Label(item.displayName());
hashLabel.getStyleClass().add("assets-details-palette-hash");
final HBox strip = createPaletteStrip(item);
final HBox root = new HBox(8, hashLabel, strip);
root.getStyleClass().add("assets-details-palette-item");
final HBox top = new HBox(8, hashLabel, strip);
top.getStyleClass().add("assets-details-palette-item");
final Object invalidReason = item.metadata().get("invalidReason");
if (!(invalidReason instanceof String reason) || reason.isBlank()) {
return top;
}
final Label invalidLabel = new Label(reason);
invalidLabel.getStyleClass().add("assets-details-palette-invalid");
final VBox root = new VBox(4, top, invalidLabel);
return root;
}

View File

@ -146,6 +146,8 @@ public final class AssetDetailsPaletteOverhaulingPreviewPane extends VBox {
final Integer height = asInteger(tile.get("height"));
final List<Integer> indices = paletteIndices(tile.get("paletteIndices"));
final List<Integer> colors = paletteDisplayColors(paletteFile.metadata());
final List<Integer> sourceColors = tileDisplayColors(tileFile.metadata());
final Map<String, Integer> schemaIndicesByColor = schemaIndicesByColor(paletteFile.metadata());
if (width == null || height == null || width <= 0 || height <= 0 || indices.isEmpty()) {
return;
}
@ -153,7 +155,7 @@ public final class AssetDetailsPaletteOverhaulingPreviewPane extends VBox {
final double pixelWidth = PREVIEW_SIZE / width;
final double pixelHeight = PREVIEW_SIZE / height;
for (int index = 0; index < indices.size(); index++) {
final int paletteIndex = indices.get(index);
final int paletteIndex = schemaPaletteIndex(indices.get(index), sourceColors, schemaIndicesByColor);
final int x = index % width;
final int y = index / width;
graphics.setFill(toFxColor(paletteIndexToArgb(colors, paletteIndex)));
@ -206,6 +208,35 @@ public final class AssetDetailsPaletteOverhaulingPreviewPane extends VBox {
return List.of();
}
@SuppressWarnings("unchecked")
private List<Integer> tileDisplayColors(Map<String, Object> metadata) {
final Map<String, Object> palette = nestedMap(metadata, "palette");
if (palette == null) {
return List.of();
}
final Object original = palette.get("originalArgb8888");
if (original instanceof List<?> list) {
return (List<Integer>) list;
}
return List.of();
}
private Map<String, Integer> schemaIndicesByColor(Map<String, Object> metadata) {
final Object materialIndices = metadata.get("schemaMaterialIndices");
if (!(materialIndices instanceof Map<?, ?> map)) {
return Map.of();
}
final Map<String, Integer> indicesByColor = new java.util.LinkedHashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
final Integer index = asInteger(entry.getKey());
if (index == null || index <= 0 || !(entry.getValue() instanceof String color) || color.isBlank()) {
continue;
}
indicesByColor.put(color.trim().toUpperCase(), index);
}
return Map.copyOf(indicesByColor);
}
private Integer asInteger(Object value) {
if (value instanceof Number number) {
return number.intValue();
@ -220,6 +251,17 @@ public final class AssetDetailsPaletteOverhaulingPreviewPane extends VBox {
return colorAt(colors, paletteIndex - 1);
}
private int schemaPaletteIndex(int tilePaletteIndex, List<Integer> sourceColors, Map<String, Integer> schemaIndicesByColor) {
if (tilePaletteIndex <= 0 || sourceColors.isEmpty() || schemaIndicesByColor.isEmpty()) {
return tilePaletteIndex;
}
final int sourceColorIndex = tilePaletteIndex - 1;
if (sourceColorIndex < 0 || sourceColorIndex >= sourceColors.size()) {
return 0;
}
return schemaIndicesByColor.getOrDefault(cssColor(sourceColors.get(sourceColorIndex)), 0);
}
private int colorAt(List<Integer> colors, int index) {
if (index < 0 || index >= colors.size()) {
return 0x00000000;

View File

@ -0,0 +1,281 @@
package p.studio.workspaces.assets.details.palette;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ColorPicker;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListView;
import javafx.scene.control.TextField;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.stage.Modality;
import javafx.stage.Stage;
import javafx.stage.Window;
import p.studio.Container;
import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialSchema;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public final class AssetDetailsPaletteWizardDialog {
private final Stage stage = new Stage();
private final ComboBox<AssetStudioGlyphMaterialSchema> schemaComboBox = new ComboBox<>();
private final TextField nameField = new TextField();
private final ObservableList<String> editableColors = FXCollections.observableArrayList();
private final ObservableList<String> indexItems = FXCollections.observableArrayList();
private final ListView<String> indexListView = new ListView<>(indexItems);
private final ColorPicker colorPicker = new ColorPicker(Color.WHITE);
private final Label summaryLabel = new Label();
private final HBox originalStrip = new HBox(4);
private final HBox editableStrip = new HBox(4);
private PaletteEditResult result;
private final String paletteId;
private final boolean editing;
private AssetDetailsPaletteWizardDialog(
Window owner,
AssetDetailsPaletteOverhaulingCoordinator coordinator,
AssetWorkspaceBankCompositionFile paletteFile) {
this.editing = paletteFile != null;
this.paletteId = editing && paletteFile.metadata().get("paletteId") instanceof String id && !id.isBlank()
? id
: coordinator.nextPaletteId();
stage.initOwner(owner);
stage.initModality(Modality.WINDOW_MODAL);
stage.setTitle(Container.i18n().text(editing
? I18n.ASSETS_PALETTE_WIZARD_UPDATE_TITLE
: I18n.ASSETS_PALETTE_WIZARD_CREATE_TITLE));
final List<AssetStudioGlyphMaterialSchema> schemas = coordinator.readySchemas();
schemaComboBox.getItems().setAll(schemas);
schemaComboBox.setCellFactory(ignored -> new javafx.scene.control.ListCell<>() {
@Override
protected void updateItem(AssetStudioGlyphMaterialSchema item, boolean empty) {
super.updateItem(item, empty);
setText(empty || item == null ? null : item.schemaId());
}
});
schemaComboBox.setButtonCell(schemaComboBox.getCellFactory().call(null));
final AssetStudioGlyphMaterialSchema initialSchema = resolveInitialSchema(coordinator, paletteFile, schemas);
if (initialSchema != null) {
schemaComboBox.getSelectionModel().select(initialSchema);
}
editableColors.setAll(initialEditableColors(paletteFile, initialSchema));
nameField.setText(initialDisplayName(paletteFile));
nameField.setPromptText(AssetDetailsPaletteOverhaulingCoordinator.generatedDisplayName(editableColors));
schemaComboBox.valueProperty().addListener((ignored, previous, current) -> {
if (current == null) {
editableColors.clear();
} else if (!editing || previous == null || !Objects.equals(previous.schemaId(), current.schemaId())) {
editableColors.setAll(current.materialIndices().entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(Map.Entry::getValue)
.toList());
}
refreshPreview(coordinator);
});
nameField.textProperty().addListener((ignored, previous, current) -> refreshPreview(coordinator));
indexListView.getSelectionModel().selectedIndexProperty().addListener((ignored, previous, current) -> {
final int selected = current == null ? -1 : current.intValue();
if (selected >= 0 && selected < editableColors.size()) {
colorPicker.setValue(Color.web(editableColors.get(selected)));
}
});
final Button applyColorButton = new Button(Container.i18n().text(I18n.ASSETS_PALETTE_WIZARD_APPLY_COLOR));
applyColorButton.getStyleClass().addAll("studio-button", "studio-button-secondary");
applyColorButton.setOnAction(ignored -> {
final int index = indexListView.getSelectionModel().getSelectedIndex();
if (index < 0 || index >= editableColors.size()) {
return;
}
editableColors.set(index, toHtmlColor(colorPicker.getValue()));
refreshPreview(coordinator);
indexListView.getSelectionModel().select(index);
});
final Button cancelButton = new Button(Container.i18n().text(I18n.FORM_ACTION_CANCEL));
cancelButton.getStyleClass().addAll("studio-button", "studio-button-secondary");
cancelButton.setOnAction(ignored -> {
result = null;
stage.close();
});
final Button applyButton = new Button(Container.i18n().text(I18n.FORM_ACTION_APPLY));
applyButton.getStyleClass().addAll("studio-button", "studio-button-primary");
applyButton.setOnAction(ignored -> {
final AssetStudioGlyphMaterialSchema selectedSchema = schemaComboBox.getSelectionModel().getSelectedItem();
if (selectedSchema == null || coordinator.duplicatePaletteExists(paletteId, selectedSchema.schemaId(), editableColors)) {
return;
}
result = new PaletteEditResult(paletteId, normalizedDisplayName(), selectedSchema.schemaId(), List.copyOf(editableColors));
stage.close();
});
final Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
final HBox actions = new HBox(8, spacer, cancelButton, applyButton);
actions.getStyleClass().add("assets-details-palette-wizard-actions");
final Label schemaLabel = fieldLabel(I18n.ASSETS_PALETTE_WIZARD_SCHEMA);
final Label nameLabel = fieldLabel(I18n.ASSETS_PALETTE_WIZARD_NAME);
final Label originalLabel = fieldLabel(I18n.ASSETS_PALETTE_WIZARD_ORIGINAL);
final Label editableLabel = fieldLabel(I18n.ASSETS_PALETTE_WIZARD_EDITABLE);
final VBox colorEditor = new VBox(8, colorPicker, applyColorButton);
colorEditor.getStyleClass().add("assets-details-palette-wizard-color-editor");
final HBox editorBody = new HBox(12, indexListView, colorEditor);
editorBody.getStyleClass().add("assets-details-palette-wizard-editor");
final VBox root = new VBox(
12,
nameLabel,
nameField,
schemaLabel,
schemaComboBox,
originalLabel,
originalStrip,
editableLabel,
editableStrip,
editorBody,
summaryLabel,
actions);
root.setPadding(new Insets(16));
root.getStyleClass().add("assets-details-palette-wizard");
originalStrip.getStyleClass().add("assets-details-palette-wizard-strip");
editableStrip.getStyleClass().add("assets-details-palette-wizard-strip");
summaryLabel.getStyleClass().add("assets-details-palette-wizard-summary");
HBox.setHgrow(indexListView, Priority.ALWAYS);
HBox.setHgrow(schemaComboBox, Priority.ALWAYS);
HBox.setHgrow(nameField, Priority.ALWAYS);
refreshPreview(coordinator);
final Scene scene = new Scene(root, 620, 570);
scene.getStylesheets().add(Container.theme().getDefaultTheme());
stage.setScene(scene);
stage.setOnShown(ignored -> stage.centerOnScreen());
}
public static Optional<PaletteEditResult> showAndWait(
Window owner,
AssetDetailsPaletteOverhaulingCoordinator coordinator,
AssetWorkspaceBankCompositionFile paletteFile) {
final AssetDetailsPaletteWizardDialog dialog = new AssetDetailsPaletteWizardDialog(owner, coordinator, paletteFile);
dialog.stage.showAndWait();
return Optional.ofNullable(dialog.result);
}
private Label fieldLabel(I18n label) {
final Label node = new Label(Container.i18n().text(label));
node.getStyleClass().add("assets-details-palette-wizard-label");
return node;
}
private AssetStudioGlyphMaterialSchema resolveInitialSchema(
AssetDetailsPaletteOverhaulingCoordinator coordinator,
AssetWorkspaceBankCompositionFile paletteFile,
List<AssetStudioGlyphMaterialSchema> schemas) {
if (paletteFile != null && paletteFile.metadata().get("schemaId") instanceof String schemaId) {
return coordinator.readySchemas().stream()
.filter(schema -> schema.schemaId().equals(schemaId))
.findFirst()
.orElse(null);
}
return schemas.isEmpty() ? null : schemas.getFirst();
}
private List<String> initialEditableColors(
AssetWorkspaceBankCompositionFile paletteFile,
AssetStudioGlyphMaterialSchema initialSchema) {
if (paletteFile != null) {
@SuppressWarnings("unchecked")
final Map<String, Object> palette = (Map<String, Object>) paletteFile.metadata().get("palette");
final Object original = palette == null ? null : palette.get("originalArgb8888");
if (original instanceof Iterable<?> iterable) {
final List<String> colors = new ArrayList<>();
for (Object value : iterable) {
if (value instanceof Number number) {
colors.add(String.format("#%06X", number.intValue() & 0x00FFFFFF));
}
}
return List.copyOf(colors);
}
}
if (initialSchema == null) {
return List.of();
}
return initialSchema.materialIndices().entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(Map.Entry::getValue)
.toList();
}
private String initialDisplayName(AssetWorkspaceBankCompositionFile paletteFile) {
if (paletteFile == null) {
return "";
}
final Object explicitName = paletteFile.metadata().get("explicitPaletteDisplayName");
return explicitName instanceof String value ? value : "";
}
private void refreshPreview(AssetDetailsPaletteOverhaulingCoordinator coordinator) {
final AssetStudioGlyphMaterialSchema schema = schemaComboBox.getSelectionModel().getSelectedItem();
final List<String> originalColors = schema == null
? List.of()
: schema.materialIndices().entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(Map.Entry::getValue)
.toList();
originalStrip.getChildren().setAll(originalColors.stream().map(this::swatch).toList());
editableStrip.getChildren().setAll(editableColors.stream().map(this::swatch).toList());
nameField.setPromptText(AssetDetailsPaletteOverhaulingCoordinator.generatedDisplayName(editableColors));
indexItems.setAll(java.util.stream.IntStream.range(0, editableColors.size())
.mapToObj(index -> (index + 1) + " -> " + editableColors.get(index))
.toList());
final boolean duplicate = schema != null && coordinator.duplicatePaletteExists(paletteId, schema.schemaId(), editableColors);
summaryLabel.setText(duplicate
? Container.i18n().text(I18n.ASSETS_PALETTE_WIZARD_DUPLICATE)
: Container.i18n().text(I18n.ASSETS_PALETTE_WIZARD_READY));
}
private String normalizedDisplayName() {
final String value = nameField.getText();
return value == null || value.trim().isBlank()
? AssetDetailsPaletteOverhaulingCoordinator.generatedDisplayName(editableColors)
: value.trim();
}
private Region swatch(String color) {
final Region swatch = new Region();
swatch.setMinSize(22, 22);
swatch.setPrefSize(22, 22);
swatch.setMaxSize(22, 22);
swatch.getStyleClass().add("assets-details-palette-wizard-swatch");
swatch.setStyle("-fx-background-color: " + color + ";");
return swatch;
}
private String toHtmlColor(Color color) {
return String.format(
"#%02X%02X%02X",
(int) Math.round(color.getRed() * 255),
(int) Math.round(color.getGreen() * 255),
(int) Math.round(color.getBlue() * 255));
}
public record PaletteEditResult(
String paletteId,
String displayName,
String schemaId,
List<String> colors) {
}
}

View File

@ -0,0 +1,545 @@
package p.studio.workspaces.assets.details.scene;
import javafx.geometry.Pos;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.TextField;
import javafx.scene.Node;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import p.studio.Container;
import p.studio.controls.forms.StudioFormMode;
import p.studio.controls.lifecycle.StudioControlLifecycle;
import p.studio.controls.lifecycle.StudioControlLifecycleSupport;
import p.studio.lsp.events.StudioWorkspaceEventBus;
import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.assets.details.AssetDetailsUiSupport;
import p.studio.workspaces.assets.metadata.AssetStudioMetadataService;
import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioSceneLayerBinding;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState;
import p.studio.workspaces.assets.messages.events.StudioAssetLogEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsRefreshRequestedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceSelectionRequestedEvent;
import p.studio.workspaces.framework.StudioSubscriptionBag;
import p.packer.messages.ListAssetsRequest;
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.List;
import java.util.Objects;
import java.util.function.IntConsumer;
public final class AssetDetailsSceneLayersControl extends VBox implements StudioControlLifecycle {
private final ProjectReference projectReference;
private final StudioWorkspaceEventBus workspaceBus;
private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag();
private final AssetStudioMetadataService metadataService = new AssetStudioMetadataService();
private AssetWorkspaceDetailsViewState viewState;
private IntConsumer generateLayerAction = ignored -> {
};
private IntConsumer validateLayerAction = ignored -> {
};
private IntConsumer acceptLayerAction = ignored -> {
};
private Integer editingLayerIndex;
private LayerDraft editingDraft;
private int selectedLayerIndex = 1;
public AssetDetailsSceneLayersControl(ProjectReference projectReference, StudioWorkspaceEventBus workspaceBus) {
StudioControlLifecycleSupport.install(this, this);
this.projectReference = Objects.requireNonNull(projectReference, "projectReference");
this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus");
}
@Override
public void subscribe() {
subscriptions.add(workspaceBus.subscribe(StudioAssetsDetailsViewStateChangedEvent.class, event -> {
viewState = event.viewState();
render();
}));
}
@Override
public void unsubscribe() {
subscriptions.clear();
}
public void setGenerateLayerAction(IntConsumer generateLayerAction) {
this.generateLayerAction = Objects.requireNonNull(generateLayerAction, "generateLayerAction");
}
public void setValidateLayerAction(IntConsumer validateLayerAction) {
this.validateLayerAction = Objects.requireNonNull(validateLayerAction, "validateLayerAction");
}
public void setAcceptLayerAction(IntConsumer acceptLayerAction) {
this.acceptLayerAction = Objects.requireNonNull(acceptLayerAction, "acceptLayerAction");
}
private void render() {
final AssetStudioSceneBankMetadata metadata = viewState == null || viewState.selectedAssetDetails() == null
? null
: viewState.selectedAssetDetails().sceneBankMetadata();
if (metadata == null || viewState.selectedAssetDetails() == null) {
getChildren().clear();
return;
}
final VBox content = new VBox(10);
content.getChildren().add(createLayerSelector(
viewState.selectedAssetDetails().summary().assetRoot(),
metadata));
content.getStyleClass().add("assets-scene-layer-shell");
getChildren().setAll(AssetDetailsUiSupport.createSectionWithoutHeader(content));
}
private Node createLayerSelector(Path sceneAssetRoot, AssetStudioSceneBankMetadata metadata) {
if (selectedLayerIndex < 1 || selectedLayerIndex > metadata.layerCount()) {
selectedLayerIndex = 1;
}
final HBox selector = new HBox(8);
selector.getStyleClass().add("assets-scene-layer-selector");
for (int index = 1; index <= 4; index += 1) {
final int layerIndex = index;
final Button button = new Button(Container.i18n().text(I18n.ASSETS_LABEL_LAYER) + " " + layerIndex);
button.getStyleClass().addAll("studio-button", "assets-scene-layer-selector-button");
if (layerIndex == selectedLayerIndex) {
button.getStyleClass().add("assets-scene-layer-selector-button-active");
}
button.setOnAction(ignored -> {
selectedLayerIndex = layerIndex;
render();
});
selector.getChildren().add(button);
}
final AssetStudioSceneLayerBinding binding = metadata.layerBindings().stream()
.filter(candidate -> candidate.index() == selectedLayerIndex)
.findFirst()
.orElse(null);
final VBox container = new VBox(10);
container.getChildren().addAll(selector, createLayerCard(sceneAssetRoot, selectedLayerIndex, binding));
return container;
}
private Node createLayerCard(Path sceneAssetRoot, int layerIndex, AssetStudioSceneLayerBinding binding) {
final boolean editing = Objects.equals(editingLayerIndex, layerIndex) && binding != null && editingDraft != null;
final LayerDraft draft = editing ? editingDraft : LayerDraft.from(binding);
final VBox formContent = buildLayerFormContent(sceneAssetRoot, binding, draft, editing);
final VBox formSection = AssetDetailsUiSupport.createEditableSection(
Container.i18n().text(I18n.ASSETS_SECTION_LAYER_CONFIGURATION),
createLayerFormHeaderActions(layerIndex, binding, editing),
formContent,
editing ? StudioFormMode.EDITING : StudioFormMode.READ_ONLY);
formSection.getStyleClass().add("assets-scene-layer-config-section");
final VBox leftColumn = new VBox(10);
leftColumn.getStyleClass().add("assets-scene-layer-config-column");
leftColumn.getChildren().addAll(
formSection,
createLayerActions(sceneAssetRoot, layerIndex, draft));
final VBox overview = createLayerOverviewPlaceholder();
final HBox layout = new HBox(12, leftColumn, overview);
layout.getStyleClass().add("assets-scene-layer-layout");
HBox.setHgrow(leftColumn, Priority.ALWAYS);
return layout;
}
private VBox buildLayerFormContent(Path sceneAssetRoot, AssetStudioSceneLayerBinding binding, LayerDraft draft, boolean editing) {
final VBox card = new VBox(8);
card.getStyleClass().addAll("assets-scene-layer-card", "assets-scene-layer-config");
final CheckBox enabledCheckBox = new CheckBox(Container.i18n().text(I18n.ASSETS_LABEL_USE_LAYER));
enabledCheckBox.setSelected(draft != null && draft.enabled());
enabledCheckBox.getStyleClass().add("assets-scene-layer-enabled");
enabledCheckBox.setDisable(!editing);
enabledCheckBox.setOnAction(ignored -> {
if (draft != null) {
draft.setEnabled(enabledCheckBox.isSelected());
}
});
card.getChildren().add(enabledCheckBox);
card.getChildren().addAll(
AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_SCENE_DIMENSIONS),
editing ? createLayerSizeEditor(draft) : new Label(draft.widthText() + " x " + draft.heightText())),
AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_TILEMAP),
binding == null
? Container.i18n().text(I18n.ASSETS_VALUE_NOT_CONFIGURED)
: AssetDetailsUiSupport.projectRelativePath(projectReference, sceneAssetRoot.resolve(binding.tilemap()))),
AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_TILESET),
editing ? createTilesetCombo(draft, true) : createTilesetReadOnlyValue(binding, draft)));
return card;
}
private VBox createLayerOverviewPlaceholder() {
final VBox overview = new VBox(10);
overview.getStyleClass().add("assets-scene-layer-overview");
final Label title = new Label(Container.i18n().text(I18n.ASSETS_LABEL_LAYER_OVERVIEW));
title.getStyleClass().add("assets-scene-layer-overview-title");
final Region surface = new Region();
surface.getStyleClass().add("assets-scene-layer-overview-surface");
VBox.setVgrow(surface, Priority.ALWAYS);
overview.getChildren().addAll(
title,
surface,
AssetDetailsUiSupport.createSectionMessage(Container.i18n().text(I18n.ASSETS_VALUE_LAYER_OVERVIEW_PENDING)));
return overview;
}
private Node createLayerFormHeaderActions(int layerIndex, AssetStudioSceneLayerBinding binding, boolean editing) {
final HBox actions = new HBox(8);
actions.getStyleClass().add("assets-scene-layer-form-actions");
if (!editing) {
final Button changeButton = createFormActionButton(I18n.FORM_ACTION_CHANGE);
changeButton.setDisable(binding == null);
changeButton.setOnAction(ignored -> beginEdit(layerIndex, binding));
actions.getChildren().add(changeButton);
return actions;
}
final Button applyButton = createFormActionButton(I18n.FORM_ACTION_APPLY);
applyButton.setOnAction(ignored -> applyEdit(layerIndex, binding));
final Button resetButton = createFormActionButton(I18n.FORM_ACTION_RESET);
resetButton.setOnAction(ignored -> resetEdit(binding));
final Button cancelButton = createFormActionButton(I18n.FORM_ACTION_CANCEL);
cancelButton.setOnAction(ignored -> cancelEdit());
actions.getChildren().addAll(applyButton, resetButton, cancelButton);
return actions;
}
private Node createLayerActions(Path sceneAssetRoot, int layerIndex, LayerDraft draft) {
final HBox actions = new HBox(8);
actions.setAlignment(Pos.CENTER_LEFT);
actions.getStyleClass().add("assets-scene-layer-actions");
final boolean enabled = draft != null && draft.enabled();
final boolean hasTmx = hasLayerTilemap(sceneAssetRoot, layerIndex);
final Button generateButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_GENERATE_TMX));
generateButton.getStyleClass().add("assets-scene-layer-action");
generateButton.setDisable(!enabled || hasTmx);
generateButton.setOnAction(ignored -> generateLayerAction.accept(layerIndex));
final Button validateButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_VALIDATE_LAYER));
validateButton.getStyleClass().add("assets-scene-layer-action");
validateButton.setDisable(!enabled || !hasTmx);
validateButton.setOnAction(ignored -> validateLayerAction.accept(layerIndex));
final Button acceptButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_ACCEPT_LAYER));
acceptButton.getStyleClass().add("assets-scene-layer-action");
acceptButton.setDisable(!enabled || !hasTmx);
acceptButton.setOnAction(ignored -> acceptLayerAction.accept(layerIndex));
final Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
actions.getChildren().addAll(generateButton, validateButton, acceptButton, spacer);
return actions;
}
private Node createLayerSizeEditor(LayerDraft draft) {
final HBox editor = new HBox(8);
editor.getStyleClass().add("assets-scene-layer-size-editor");
final TextField widthField = createSizeField(draft.widthText(), draft::setWidthText);
final Label separator = new Label("x");
separator.getStyleClass().add("assets-scene-layer-size-separator");
final TextField heightField = createSizeField(draft.heightText(), draft::setHeightText);
editor.getChildren().addAll(widthField, separator, heightField);
return editor;
}
private TextField createSizeField(String value, java.util.function.Consumer<String> updater) {
final TextField textField = new TextField(value);
textField.getStyleClass().add("assets-scene-layer-size-field");
textField.textProperty().addListener((ignored, oldValue, newValue) -> updater.accept(newValue));
return textField;
}
private ComboBox<TilesetOption> createTilesetCombo(LayerDraft draft, boolean editing) {
final ComboBox<TilesetOption> combo = new ComboBox<>();
combo.setMaxWidth(Double.MAX_VALUE);
combo.getStyleClass().add("assets-details-combo");
final List<TilesetOption> options = loadTilesetOptions();
combo.setItems(FXCollections.observableArrayList(options));
combo.setCellFactory(ignored -> new ListCell<>() {
@Override
protected void updateItem(TilesetOption item, boolean empty) {
super.updateItem(item, empty);
setText(empty || item == null ? null : item.label());
}
});
combo.setButtonCell(new ListCell<>() {
@Override
protected void updateItem(TilesetOption item, boolean empty) {
super.updateItem(item, empty);
setText(empty || item == null ? null : item.label());
}
});
final String currentTileset = draft == null ? "" : draft.tilesetAssetRoot();
options.stream()
.filter(option -> option.relativeAssetRoot().equals(currentTileset))
.findFirst()
.ifPresentOrElse(
combo.getSelectionModel()::select,
() -> combo.getSelectionModel().clearSelection());
combo.setDisable(!editing || draft == null);
if (draft == null) {
combo.setDisable(true);
return combo;
}
combo.valueProperty().addListener((ignored, oldValue, newValue) -> {
if (Objects.equals(oldValue, newValue)) {
return;
}
draft.setTilesetAssetRoot(newValue == null ? "" : newValue.relativeAssetRoot());
});
return combo;
}
private Node createTilesetReadOnlyValue(AssetStudioSceneLayerBinding binding, LayerDraft draft) {
final String value = binding == null || draft == null || draft.tilesetAssetRoot().isBlank()
? Container.i18n().text(I18n.ASSETS_VALUE_NOT_CONFIGURED)
: tilesetPath(draft.tilesetAssetRoot());
return new Label(value);
}
private List<TilesetOption> loadTilesetOptions() {
try {
final var response = Container.packer().workspaceService().listAssets(new ListAssetsRequest(projectReference.toPackerProjectContext()));
return response.assets().stream()
.filter(asset -> asset.assetFamily() == AssetFamilyCatalog.GLYPH_BANK)
.filter(asset -> metadataService.read(asset.identity().assetRoot(), AssetFamilyCatalog.GLYPH_BANK).glyphSpecialization()
== p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization.TILESET)
.map(asset -> {
final String relativeAssetRoot = projectReference.rootPath()
.resolve("assets")
.relativize(asset.identity().assetRoot())
.toString()
.replace('\\', '/');
return new TilesetOption(asset.identity().assetName(), relativeAssetRoot);
})
.sorted(Comparator.comparing(TilesetOption::label, String.CASE_INSENSITIVE_ORDER))
.toList();
} catch (RuntimeException exception) {
return List.of();
}
}
private void updateLayerBinding(
int layerIndex,
java.util.function.Function<AssetStudioSceneLayerBinding, AssetStudioSceneLayerBinding> mutator,
String successMessage) {
if (viewState == null || viewState.selectedAssetDetails() == null || viewState.selectedAssetDetails().sceneBankMetadata() == null) {
return;
}
final AssetStudioSceneBankMetadata metadata = viewState.selectedAssetDetails().sceneBankMetadata();
final java.util.List<AssetStudioSceneLayerBinding> updatedBindings = new ArrayList<>();
for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) {
if (binding.index() == layerIndex) {
updatedBindings.add(mutator.apply(binding));
} else {
updatedBindings.add(binding);
}
}
final AssetStudioSceneLayerBinding firstBinding = updatedBindings.getFirst();
final AssetStudioSceneBankMetadata updatedMetadata = new AssetStudioSceneBankMetadata(
firstBinding.width(),
firstBinding.height(),
metadata.layerCount(),
updatedBindings,
metadata.supportFile());
Container.backgroundTasks().submit(() -> {
try {
metadataService.writeSceneBankMetadata(viewState.selectedAssetDetails().summary().assetRoot(), updatedMetadata);
Platform.runLater(() -> {
workspaceBus.publish(new StudioAssetLogEvent("scene-bank-layer", successMessage));
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(viewState.selectedAssetReference()));
workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(viewState.selectedAssetReference(), true));
});
} catch (IOException exception) {
Platform.runLater(() -> workspaceBus.publish(
new StudioAssetLogEvent("scene-bank-layer", "Unable to update layer " + layerIndex + ": " + exception.getMessage())));
}
});
}
private String tilesetPath(AssetStudioSceneLayerBinding binding) {
return tilesetPath(binding.tilesetAssetRoot());
}
private String tilesetPath(String tilesetAssetRoot) {
if (tilesetAssetRoot.isBlank()) {
return "";
}
return AssetDetailsUiSupport.projectRelativePath(
projectReference,
projectReference.rootPath().resolve("assets").resolve(tilesetAssetRoot));
}
private void beginEdit(int layerIndex, AssetStudioSceneLayerBinding binding) {
editingLayerIndex = layerIndex;
editingDraft = LayerDraft.from(binding);
render();
}
private void applyEdit(int layerIndex, AssetStudioSceneLayerBinding binding) {
if (binding == null || editingDraft == null || !editingDraft.hasValidDimensions()) {
workspaceBus.publish(new StudioAssetLogEvent(
"scene-bank-layer",
"Layer size must use positive integer values before applying changes."));
return;
}
final LayerDraft draft = editingDraft.copy();
editingLayerIndex = null;
editingDraft = null;
render();
updateLayerBinding(layerIndex, current -> new AssetStudioSceneLayerBinding(
draft.enabled(),
current.index(),
current.layerName(),
draft.widthValue(),
draft.heightValue(),
current.tilemap(),
draft.tilesetAssetRoot()),
"Updated layer " + layerIndex + " configuration.");
}
private void resetEdit(AssetStudioSceneLayerBinding binding) {
editingDraft = LayerDraft.from(binding);
render();
}
private void cancelEdit() {
editingLayerIndex = null;
editingDraft = null;
render();
}
private Button createFormActionButton(I18n textKey) {
final Button button = new Button(Container.i18n().text(textKey));
button.getStyleClass().addAll("studio-button", "studio-button-secondary");
return button;
}
private boolean hasLayerTilemap(Path sceneAssetRoot, int layerIndex) {
if (viewState == null || viewState.selectedAssetDetails() == null || viewState.selectedAssetDetails().sceneBankMetadata() == null) {
return false;
}
return viewState.selectedAssetDetails().sceneBankMetadata().layerBindings().stream()
.filter(binding -> binding.index() == layerIndex)
.findFirst()
.map(binding -> Files.isRegularFile(sceneAssetRoot.resolve(binding.tilemap())))
.orElse(false);
}
private record TilesetOption(String assetName, String relativeAssetRoot) {
String label() {
return assetName + " [" + relativeAssetRoot + "]";
}
}
private static final class LayerDraft {
private boolean enabled;
private String widthText;
private String heightText;
private String tilesetAssetRoot;
private LayerDraft(boolean enabled, String widthText, String heightText, String tilesetAssetRoot) {
this.enabled = enabled;
this.widthText = widthText;
this.heightText = heightText;
this.tilesetAssetRoot = Objects.requireNonNullElse(tilesetAssetRoot, "").trim();
}
static LayerDraft from(AssetStudioSceneLayerBinding binding) {
if (binding == null) {
return new LayerDraft(false, "16", "16", "");
}
return new LayerDraft(
binding.enabled(),
Integer.toString(binding.width()),
Integer.toString(binding.height()),
binding.tilesetAssetRoot());
}
LayerDraft copy() {
return new LayerDraft(enabled, widthText, heightText, tilesetAssetRoot);
}
boolean enabled() {
return enabled;
}
void setEnabled(boolean enabled) {
this.enabled = enabled;
}
String widthText() {
return widthText;
}
void setWidthText(String widthText) {
this.widthText = Objects.requireNonNullElse(widthText, "").trim();
}
String heightText() {
return heightText;
}
void setHeightText(String heightText) {
this.heightText = Objects.requireNonNullElse(heightText, "").trim();
}
String tilesetAssetRoot() {
return tilesetAssetRoot;
}
void setTilesetAssetRoot(String tilesetAssetRoot) {
this.tilesetAssetRoot = Objects.requireNonNullElse(tilesetAssetRoot, "").trim();
}
boolean hasValidDimensions() {
return parsePositiveInt(widthText) > 0 && parsePositiveInt(heightText) > 0;
}
int widthValue() {
return parsePositiveInt(widthText);
}
int heightValue() {
return parsePositiveInt(heightText);
}
private static int parsePositiveInt(String value) {
try {
return Integer.parseInt(Objects.requireNonNullElse(value, "").trim());
} catch (NumberFormatException exception) {
return -1;
}
}
}
}

View File

@ -0,0 +1,250 @@
package p.studio.workspaces.assets.details.schema;
import p.packer.messages.assets.AssetFamilyCatalog;
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails;
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialArtifactBinding;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialPalette;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialSchema;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialSchemaStatus;
import java.util.*;
public final class AssetDetailsColorSchemaCoordinator {
private AssetWorkspaceAssetDetails details;
private AssetStudioGlyphMaterialMetadata localMaterialMetadata;
public void replaceDetails(AssetWorkspaceAssetDetails details) {
this.details = details;
this.localMaterialMetadata = details == null ? null : details.glyphMaterialMetadata();
}
public void replaceMaterialMetadata(AssetStudioGlyphMaterialMetadata metadata) {
this.localMaterialMetadata = Objects.requireNonNull(metadata, "metadata");
}
public boolean ready() {
return details != null && details.summary().assetFamily() == AssetFamilyCatalog.GLYPH_BANK;
}
public AssetStudioGlyphMaterialMetadata metadata() {
if (!ready() || localMaterialMetadata == null) {
return new AssetStudioGlyphMaterialMetadata(List.of(), List.of(), null);
}
return localMaterialMetadata;
}
public List<AssetStudioGlyphMaterialSchema> schemas() {
return metadata().schemas();
}
public List<AssetWorkspaceBankCompositionFile> selectedTiles() {
if (!ready()) {
return List.of();
}
return details.bankComposition().selectedFiles().stream()
.filter(AssetDetailsColorSchemaCoordinator::supportsSchemaAuthoring)
.filter(file -> artifactId(file) != null)
.toList();
}
public List<AssetWorkspaceBankCompositionFile> availableTilesForCreate() {
final Set<String> reservedArtifactIds = new HashSet<>();
for (AssetStudioGlyphMaterialSchema schema : schemas()) {
schema.artifactBindings().forEach(binding -> reservedArtifactIds.add(binding.artifactId()));
}
return selectedTiles().stream()
.filter(file -> !reservedArtifactIds.contains(artifactId(file)))
.toList();
}
public List<AssetWorkspaceBankCompositionFile> availableTilesForEdit(String schemaId) {
final AssetStudioGlyphMaterialSchema schema = findSchema(schemaId).orElse(null);
if (schema == null) {
return availableTilesForCreate();
}
final Set<String> current = schema.artifactBindings().stream()
.map(AssetStudioGlyphMaterialArtifactBinding::artifactId)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
final Set<String> reservedElsewhere = new HashSet<>();
for (AssetStudioGlyphMaterialSchema candidate : schemas()) {
if (candidate.schemaId().equals(schemaId)) {
continue;
}
candidate.artifactBindings().forEach(binding -> reservedElsewhere.add(binding.artifactId()));
}
return selectedTiles().stream()
.filter(file -> current.contains(artifactId(file)) || !reservedElsewhere.contains(artifactId(file)))
.toList();
}
public boolean canRemove(String schemaId) {
return metadata().palettes().stream().noneMatch(palette -> palette.schemaId().equals(schemaId));
}
public Optional<AssetStudioGlyphMaterialSchema> findSchema(String schemaId) {
return schemas().stream().filter(schema -> schema.schemaId().equals(schemaId)).findFirst();
}
public AssetStudioGlyphMaterialMetadata upsertSchema(
String schemaId,
List<AssetWorkspaceBankCompositionFile> assignedTiles,
List<String> orderedColors) {
final AssetStudioGlyphMaterialMetadata current = metadata();
final AssetStudioGlyphMaterialSchema existing = current.findSchema(schemaId).orElse(null);
final AssetStudioGlyphMaterialSchema next = buildSchema(schemaId, existing, assignedTiles, orderedColors);
final List<AssetStudioGlyphMaterialSchema> schemas = new ArrayList<>();
boolean replaced = false;
for (AssetStudioGlyphMaterialSchema schema : current.schemas()) {
if (schema.schemaId().equals(schemaId)) {
schemas.add(next);
replaced = true;
} else {
schemas.add(schema);
}
}
if (!replaced) {
schemas.add(next);
}
schemas.sort(Comparator.comparing(AssetStudioGlyphMaterialSchema::schemaId, String.CASE_INSENSITIVE_ORDER));
return new AssetStudioGlyphMaterialMetadata(schemas, current.palettes(), current.supportFile());
}
public AssetStudioGlyphMaterialMetadata removeSchema(String schemaId) {
if (!canRemove(schemaId)) {
throw new IllegalStateException("Schema is still referenced by one or more palettes.");
}
final AssetStudioGlyphMaterialMetadata current = metadata();
final List<AssetStudioGlyphMaterialSchema> schemas = current.schemas().stream()
.filter(schema -> !schema.schemaId().equals(schemaId))
.toList();
return new AssetStudioGlyphMaterialMetadata(schemas, current.palettes(), current.supportFile());
}
public String nextSchemaId() {
int next = 1;
final Set<String> ids = schemas().stream().map(AssetStudioGlyphMaterialSchema::schemaId).collect(java.util.stream.Collectors.toSet());
while (ids.contains("schema-" + next)) {
next += 1;
}
return "schema-" + next;
}
public int unassignedTileCount() {
return availableTilesForCreate().size();
}
public static AssetStudioGlyphMaterialSchemaStatus effectiveStatus(
AssetStudioGlyphMaterialSchema schema,
List<AssetWorkspaceBankCompositionFile> selectedTiles) {
if (schema.status() == AssetStudioGlyphMaterialSchemaStatus.INCONSISTENT) {
return AssetStudioGlyphMaterialSchemaStatus.INCONSISTENT;
}
final Map<String, AssetWorkspaceBankCompositionFile> selectedByArtifactId = new HashMap<>();
for (AssetWorkspaceBankCompositionFile tile : selectedTiles) {
final String artifactId = artifactId(tile);
if (artifactId != null) {
selectedByArtifactId.put(artifactId, tile);
}
}
for (AssetStudioGlyphMaterialArtifactBinding binding : schema.artifactBindings()) {
final AssetWorkspaceBankCompositionFile selectedTile = selectedByArtifactId.get(binding.artifactId());
if (selectedTile == null || !Objects.equals(binding.fingerprint(), fingerprint(selectedTile))) {
return AssetStudioGlyphMaterialSchemaStatus.INCONSISTENT;
}
}
return AssetStudioGlyphMaterialSchemaStatus.OK;
}
private AssetStudioGlyphMaterialSchema buildSchema(
String schemaId,
AssetStudioGlyphMaterialSchema existing,
List<AssetWorkspaceBankCompositionFile> assignedTiles,
List<String> orderedColors) {
final Map<Integer, String> materialIndices = new LinkedHashMap<>();
for (int index = 0; index < orderedColors.size(); index += 1) {
materialIndices.put(index + 1, normalizeHtmlColor(orderedColors.get(index)));
}
final List<AssetStudioGlyphMaterialArtifactBinding> bindings = assignedTiles.stream()
.map(file -> new AssetStudioGlyphMaterialArtifactBinding(artifactId(file), fingerprint(file)))
.toList();
final boolean unchanged = existing != null
&& existing.materialIndices().equals(materialIndices)
&& existing.artifactBindings().equals(bindings);
final int version = existing == null
? 1
: unchanged ? existing.version() : existing.version() + 1;
final AssetStudioGlyphMaterialSchemaStatus status = existing == null || !unchanged
? AssetStudioGlyphMaterialSchemaStatus.OK
: existing.status();
return new AssetStudioGlyphMaterialSchema(schemaId, status, version, materialIndices, bindings);
}
static boolean supportsSchemaAuthoring(AssetWorkspaceBankCompositionFile file) {
return file != null
&& artifactId(file) != null
&& fingerprint(file) != null;
}
static String artifactId(AssetWorkspaceBankCompositionFile file) {
if (file == null) {
return null;
}
final Object artifactId = file.metadata().get("artifactId");
if (artifactId instanceof String value && !value.isBlank()) {
return value.trim();
}
return null;
}
static String fingerprint(AssetWorkspaceBankCompositionFile file) {
if (file == null) {
return null;
}
if (file.fingerprint() != null && !file.fingerprint().isBlank()) {
return file.fingerprint().trim();
}
final Object selectedFingerprint = file.metadata().get("selectedFingerprint");
if (selectedFingerprint instanceof String value && !value.isBlank()) {
return value.trim();
}
return null;
}
static List<String> tileColors(AssetWorkspaceBankCompositionFile file) {
final Map<String, Object> palette = nestedMap(file.metadata(), "palette");
if (palette == null) {
return List.of();
}
final Object originalColors = palette.get("originalArgb8888");
if (!(originalColors instanceof Iterable<?> iterable)) {
return List.of();
}
final List<String> colors = new ArrayList<>();
for (Object item : iterable) {
if (!(item instanceof Number number)) {
continue;
}
colors.add(String.format(
"#%02X%02X%02X",
(number.intValue() >> 16) & 0xFF,
(number.intValue() >> 8) & 0xFF,
number.intValue() & 0xFF));
}
return List.copyOf(colors);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> nestedMap(Map<String, Object> metadata, String key) {
final Object value = metadata.get(key);
if (value instanceof Map<?, ?> nested) {
return (Map<String, Object>) nested;
}
return null;
}
private static String normalizeHtmlColor(String color) {
return Objects.requireNonNull(color, "color").trim().toUpperCase();
}
}

View File

@ -0,0 +1,129 @@
package p.studio.workspaces.assets.details.schema;
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
record AssetDetailsColorSchemaDraft(
String schemaId,
boolean existingSchema,
boolean removeRequested,
List<AssetWorkspaceBankCompositionFile> availableTiles,
List<AssetWorkspaceBankCompositionFile> assignedTiles,
List<String> preferredColors,
boolean deriveColorsFromTiles) {
AssetDetailsColorSchemaDraft {
availableTiles = List.copyOf(availableTiles);
assignedTiles = List.copyOf(assignedTiles);
preferredColors = normalize(preferredColors);
}
static AssetDetailsColorSchemaDraft noSelection() {
return new AssetDetailsColorSchemaDraft(null, false, false, List.of(), List.of(), List.of(), false);
}
boolean selected() {
return schemaId != null && !schemaId.isBlank();
}
AssetDetailsColorSchemaDraft moveToAssigned(List<AssetWorkspaceBankCompositionFile> files) {
final List<AssetWorkspaceBankCompositionFile> nextAvailable = new ArrayList<>(availableTiles);
final List<AssetWorkspaceBankCompositionFile> nextAssigned = new ArrayList<>(assignedTiles);
for (AssetWorkspaceBankCompositionFile file : files) {
if (nextAvailable.remove(file) && !nextAssigned.contains(file)) {
nextAssigned.add(file);
}
}
return withTiles(nextAvailable, nextAssigned);
}
AssetDetailsColorSchemaDraft moveToAvailable(List<AssetWorkspaceBankCompositionFile> files) {
final List<AssetWorkspaceBankCompositionFile> nextAvailable = new ArrayList<>(availableTiles);
final List<AssetWorkspaceBankCompositionFile> nextAssigned = new ArrayList<>(assignedTiles);
for (AssetWorkspaceBankCompositionFile file : files) {
if (nextAssigned.remove(file) && !nextAvailable.contains(file)) {
nextAvailable.add(file);
}
}
return withTiles(nextAvailable, nextAssigned);
}
AssetDetailsColorSchemaDraft moveAssignedUp(int index) {
if (index <= 0 || index >= assignedTiles.size()) {
return this;
}
final List<AssetWorkspaceBankCompositionFile> nextAssigned = new ArrayList<>(assignedTiles);
final AssetWorkspaceBankCompositionFile file = nextAssigned.remove(index);
nextAssigned.add(index - 1, file);
return withTiles(availableTiles, nextAssigned);
}
AssetDetailsColorSchemaDraft moveAssignedDown(int index) {
if (index < 0 || index >= assignedTiles.size() - 1) {
return this;
}
final List<AssetWorkspaceBankCompositionFile> nextAssigned = new ArrayList<>(assignedTiles);
final AssetWorkspaceBankCompositionFile file = nextAssigned.remove(index);
nextAssigned.add(index + 1, file);
return withTiles(availableTiles, nextAssigned);
}
AssetDetailsColorSchemaDraft markForRemoval() {
if (!selected()) {
return this;
}
return new AssetDetailsColorSchemaDraft(schemaId, existingSchema, true, availableTiles, assignedTiles, preferredColors, deriveColorsFromTiles);
}
AssetDetailsColorSchemaDraft asUpdateDraft() {
return new AssetDetailsColorSchemaDraft(schemaId, existingSchema, false, availableTiles, assignedTiles, orderedColors(), true);
}
List<String> orderedColors() {
if (!deriveColorsFromTiles) {
return preferredColors;
}
final LinkedHashSet<String> active = new LinkedHashSet<>();
for (AssetWorkspaceBankCompositionFile tile : assignedTiles) {
AssetDetailsColorSchemaCoordinator.tileColors(tile).stream()
.map(AssetDetailsColorSchemaDraft::normalize)
.forEach(active::add);
}
final List<String> ordered = new ArrayList<>();
for (String preferredColor : preferredColors) {
if (active.contains(preferredColor) && !ordered.contains(preferredColor)) {
ordered.add(preferredColor);
}
}
for (String color : active) {
if (!ordered.contains(color)) {
ordered.add(color);
}
}
return List.copyOf(ordered);
}
private AssetDetailsColorSchemaDraft withTiles(
List<AssetWorkspaceBankCompositionFile> nextAvailable,
List<AssetWorkspaceBankCompositionFile> nextAssigned) {
return new AssetDetailsColorSchemaDraft(schemaId, existingSchema, false, nextAvailable, nextAssigned, orderedColors(), true);
}
private static List<String> normalize(List<String> colors) {
final List<String> normalized = new ArrayList<>();
for (String color : colors) {
if (color == null || color.isBlank()) {
continue;
}
normalized.add(normalize(color));
}
return List.copyOf(normalized);
}
private static String normalize(String color) {
return color.trim().toUpperCase();
}
}

View File

@ -0,0 +1,521 @@
package p.studio.workspaces.assets.details.schema;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import p.studio.Container;
import p.studio.controls.banks.StudioDualListView;
import p.studio.controls.forms.StudioFormEditScopeChangedEvent;
import p.studio.controls.forms.StudioFormMode;
import p.studio.controls.forms.StudioFormSection;
import p.studio.controls.forms.StudioFormSession;
import p.studio.lsp.events.StudioWorkspaceEventBus;
import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState;
import p.studio.workspaces.assets.messages.events.StudioAssetLogEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioGlyphMaterialMetadataChangedEvent;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialArtifactBinding;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialSchema;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialSchemaStatus;
import p.studio.workspaces.assets.metadata.AssetStudioMetadataService;
import p.studio.workspaces.framework.StudioSubscriptionBag;
import java.io.IOException;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public final class AssetDetailsColorSchemaGroupsControl extends StudioFormSection {
private static final String SECTION_ID = "asset-details.color-schema-groups";
private final StudioWorkspaceEventBus workspaceBus;
private final StudioSubscriptionBag subscriptions = new StudioSubscriptionBag();
private final AssetStudioMetadataService metadataService = new AssetStudioMetadataService();
private final AssetDetailsColorSchemaCoordinator coordinator = new AssetDetailsColorSchemaCoordinator();
private final ListView<SchemaListItem> schemaListView = new ListView<>();
private final SchemaTileDualListView tileDualListView = new SchemaTileDualListView();
private final VBox schemaActions = new VBox(6);
private final HBox schemaListPane = new HBox(8, schemaActions, schemaListView);
private final HBox paletteStrip = new HBox(4);
private final Label summaryLabel = new Label();
private final Label schemaTitleLabel = new Label();
private final Label paletteCounterLabel = new Label();
private final Label warningLabel = new Label();
private final VBox editorPane = new VBox(10);
private final HBox body = new HBox(16);
private final VBox content = new VBox(12, body, summaryLabel);
private AssetWorkspaceDetailsViewState viewState;
private StudioFormSession<AssetDetailsColorSchemaDraft> formSession;
private String selectedSchemaId;
private boolean selectingSchema;
public AssetDetailsColorSchemaGroupsControl(ProjectReference projectReference, StudioWorkspaceEventBus workspaceBus) {
Objects.requireNonNull(projectReference, "projectReference");
this.workspaceBus = Objects.requireNonNull(workspaceBus, "workspaceBus");
schemaListView.getStyleClass().add("assets-details-color-schema-list");
schemaListView.setCellFactory(ignored -> createSchemaCell());
schemaListView.getSelectionModel().selectedItemProperty().addListener((ignored, previous, current) -> {
if (selectingSchema) {
return;
}
selectedSchemaId = current == null ? null : current.schemaId();
if (formMode() == StudioFormMode.EDITING) {
formSession.setDraft(selectedSchemaId == null
? AssetDetailsColorSchemaDraft.noSelection()
: sourceDraft(selectedSchemaId).asUpdateDraft());
} else {
replaceFormSource();
}
renderSection();
});
schemaActions.getStyleClass().add("assets-details-color-schema-toolbar");
schemaListPane.getStyleClass().add("assets-details-list-with-actions");
content.getStyleClass().add("assets-details-color-schema-content");
body.getStyleClass().add("assets-details-color-schema-body");
paletteStrip.getStyleClass().add("assets-details-color-schema-strip");
schemaTitleLabel.getStyleClass().add("assets-details-color-schema-title");
paletteCounterLabel.getStyleClass().add("assets-details-color-schema-summary");
summaryLabel.getStyleClass().add("assets-details-color-schema-summary");
warningLabel.getStyleClass().add("assets-details-color-schema-warning");
HBox.setHgrow(schemaListPane, Priority.ALWAYS);
HBox.setHgrow(schemaListView, Priority.ALWAYS);
HBox.setHgrow(editorPane, Priority.ALWAYS);
schemaListView.setMaxWidth(Double.MAX_VALUE);
editorPane.setMaxWidth(Double.MAX_VALUE);
}
@Override
public void subscribe() {
subscriptions.add(workspaceBus.subscribe(StudioAssetsDetailsViewStateChangedEvent.class, event -> {
viewState = event.viewState();
coordinator.replaceDetails(viewState == null ? null : viewState.selectedAssetDetails());
normalizeSelectedSchema();
replaceFormSource();
if (viewState == null || viewState.selectedAssetReference() == null) {
publishEditScope(workspaceBus, currentScopeKey(), null);
}
renderSection();
}));
subscriptions.add(workspaceBus.subscribe(StudioFormEditScopeChangedEvent.class,
event -> handleEditScopeChanged(event, currentScopeKey())));
subscriptions.add(workspaceBus.subscribe(StudioGlyphMaterialMetadataChangedEvent.class, event -> {
if (viewState == null || !Objects.equals(viewState.selectedAssetReference(), event.assetReference())) {
return;
}
coordinator.replaceMaterialMetadata(event.metadata());
if (formMode() != StudioFormMode.EDITING) {
replaceFormSource();
}
renderSection();
}));
}
@Override
public void unsubscribe() {
subscriptions.clear();
}
@Override
protected void renderSection() {
if (viewState == null || viewState.selectedAssetDetails() == null || !coordinator.ready()) {
formSession = null;
clearRenderedSection();
return;
}
if (formSession == null) {
replaceFormSource();
}
final AssetDetailsColorSchemaDraft draft = formSession.draft();
renderSchemaList();
renderSchemaActions(draft);
renderEditor(draft);
summaryLabel.setText(Container.i18n().format(
I18n.ASSETS_COLOR_SCHEMA_SUMMARY,
coordinator.schemas().size(),
coordinator.unassignedTileCount()));
body.getChildren().setAll(schemaListPane, editorPane);
renderFormSection(content);
}
@Override
protected String sectionTitle() {
return Container.i18n().text(I18n.ASSETS_SECTION_COLOR_SCHEMA_GROUPS);
}
@Override
protected StudioFormMode formMode() {
return formSession == null ? StudioFormMode.READ_ONLY : formSession.mode();
}
@Override
protected boolean isDirty() {
return formSession != null && formSession.isDirty();
}
@Override
protected void beginEdit() {
if (formSession == null) {
replaceFormSource();
}
selectedSchemaId = null;
formSession.replaceSource(AssetDetailsColorSchemaDraft.noSelection());
formSession.beginEdit();
publishEditScope(workspaceBus, currentScopeKey(), SECTION_ID);
}
@Override
protected void apply() {
if (viewState == null || viewState.selectedAssetDetails() == null || viewState.selectedAssetReference() == null || formSession == null) {
return;
}
final AssetDetailsColorSchemaDraft draft = formSession.draft();
if (!draft.selected()) {
workspaceBus.publish(new StudioAssetLogEvent("color-schema-groups", "Apply failed: select or create a schema first."));
return;
}
if (!draft.removeRequested()) {
final List<String> orderedColors = draft.orderedColors();
if (draft.assignedTiles().isEmpty() || orderedColors.isEmpty()) {
workspaceBus.publish(new StudioAssetLogEvent("color-schema-groups", "Apply failed: schema must contain at least one tile."));
return;
}
if (orderedColors.size() > 15) {
workspaceBus.publish(new StudioAssetLogEvent(
"color-schema-groups",
Container.i18n().text(I18n.ASSETS_COLOR_SCHEMA_DIALOG_TOO_MANY_COLORS)));
return;
}
}
final var assetDetails = viewState.selectedAssetDetails();
final var assetReference = viewState.selectedAssetReference();
Container.backgroundTasks().submit(() -> {
try {
final AssetStudioGlyphMaterialMetadata metadata = draft.removeRequested()
? coordinator.removeSchema(draft.schemaId())
: coordinator.upsertSchema(draft.schemaId(), draft.assignedTiles(), draft.orderedColors());
metadataService.writeGlyphMaterialMetadata(assetDetails.summary().assetRoot(), metadata);
Platform.runLater(() -> {
formSession.apply();
selectedSchemaId = draft.removeRequested() ? null : draft.schemaId();
publishEditScope(workspaceBus, currentScopeKey(), null);
workspaceBus.publish(new StudioAssetLogEvent(
"color-schema-groups",
draft.removeRequested() ? "Schema removed: " + draft.schemaId() : "Schema saved: " + draft.schemaId()));
workspaceBus.publish(new StudioGlyphMaterialMetadataChangedEvent(assetReference, metadata));
renderSection();
});
} catch (IOException | RuntimeException exception) {
Platform.runLater(() -> workspaceBus.publish(new StudioAssetLogEvent(
"color-schema-groups",
"Unable to save schema: " + Objects.requireNonNullElse(exception.getMessage(), "unknown error"))));
}
});
}
@Override
protected void reset() {
if (formSession == null) {
return;
}
formSession.resetDraft();
selectedSchemaId = formSession.draft().selected() ? formSession.draft().schemaId() : null;
}
@Override
protected void cancel() {
if (formSession == null) {
return;
}
formSession.cancelEdit();
selectedSchemaId = formSession.draft().selected() ? formSession.draft().schemaId() : null;
publishEditScope(workspaceBus, currentScopeKey(), null);
}
@Override
protected String sectionStyleClass() {
return "assets-details-color-schema-section";
}
@Override
protected String formSectionId() {
return SECTION_ID;
}
private void renderSchemaList() {
selectingSchema = true;
schemaListView.getItems().setAll(schemaListItems());
schemaListView.setDisable(false);
if (selectedSchemaId != null) {
schemaListView.getItems().stream()
.filter(schema -> schema.schemaId().equals(selectedSchemaId))
.findFirst()
.ifPresentOrElse(
schema -> schemaListView.getSelectionModel().select(schema),
() -> schemaListView.getSelectionModel().clearSelection());
} else {
schemaListView.getSelectionModel().clearSelection();
}
selectingSchema = false;
}
private void renderSchemaActions(AssetDetailsColorSchemaDraft draft) {
final Button newButton = new Button("+");
newButton.getStyleClass().addAll("studio-button", "studio-button-secondary", "assets-details-mini-action-button");
newButton.setDisable(formMode() != StudioFormMode.EDITING);
newButton.setOnAction(ignored -> {
selectedSchemaId = coordinator.nextSchemaId();
formSession.setDraft(sourceDraft(selectedSchemaId).asUpdateDraft());
rerenderPreservingScrollPosition();
});
final Button removeButton = new Button("-");
removeButton.getStyleClass().addAll("studio-button", "studio-button-secondary", "assets-details-mini-action-button");
removeButton.setDisable(formMode() != StudioFormMode.EDITING || !draft.selected() || (draft.existingSchema() && !coordinator.canRemove(draft.schemaId())));
removeButton.setOnAction(ignored -> {
if (draft.existingSchema()) {
formSession.updateDraft(AssetDetailsColorSchemaDraft::markForRemoval);
} else {
selectedSchemaId = null;
formSession.setDraft(AssetDetailsColorSchemaDraft.noSelection());
}
rerenderPreservingScrollPosition();
});
if (draft.existingSchema() && !coordinator.canRemove(draft.schemaId())) {
removeButton.setTooltip(new javafx.scene.control.Tooltip(
Container.i18n().format(I18n.ASSETS_COLOR_SCHEMA_REMOVE_BLOCKED, draft.schemaId())));
}
schemaActions.getChildren().setAll(newButton, removeButton);
}
private void renderEditor(AssetDetailsColorSchemaDraft draft) {
editorPane.getChildren().clear();
if (!draft.selected()) {
tileDualListView.setLeftItems(List.of());
tileDualListView.setRightItems(List.of());
tileDualListView.setInteractionEnabled(false);
paletteStrip.getChildren().clear();
paletteCounterLabel.setText(Container.i18n().format(I18n.ASSETS_COLOR_SCHEMA_DIALOG_COLOR_COUNT, 0));
editorPane.getChildren().addAll(
emptyMessage("Select a schema or create a new one to edit its tile group."),
tileDualListView,
paletteCounterLabel,
paletteStrip);
return;
}
if (draft.removeRequested()) {
editorPane.getChildren().add(emptyMessage("Schema " + draft.schemaId() + " will be removed when you apply this form."));
return;
}
final List<String> orderedColors = draft.orderedColors();
schemaTitleLabel.setText(draft.schemaId() + (draft.existingSchema() ? "" : " (new)"));
paletteCounterLabel.setText(Container.i18n().format(I18n.ASSETS_COLOR_SCHEMA_DIALOG_COLOR_COUNT, orderedColors.size()));
warningLabel.setText(orderedColors.size() > 15
? Container.i18n().text(I18n.ASSETS_COLOR_SCHEMA_DIALOG_TOO_MANY_COLORS)
: "");
paletteStrip.getChildren().setAll(orderedColors.stream().map(this::swatch).toList());
tileDualListView.setLeftItems(draft.availableTiles());
tileDualListView.setRightItems(draft.assignedTiles());
tileDualListView.setInteractionEnabled(formMode() == StudioFormMode.EDITING && draft.selected());
tileDualListView.setMoveToRightAllowed(true);
tileDualListView.setOnMoveToRight(items -> updateDraft(current -> current.moveToAssigned(items)));
tileDualListView.setOnMoveToLeft(items -> updateDraft(current -> current.moveToAvailable(items)));
tileDualListView.setOnMoveUp(index -> updateDraft(current -> current.moveAssignedUp(index)));
tileDualListView.setOnMoveDown(index -> updateDraft(current -> current.moveAssignedDown(index)));
editorPane.getChildren().addAll(schemaTitleLabel, tileDualListView, paletteCounterLabel, paletteStrip, warningLabel);
VBox.setVgrow(tileDualListView, Priority.ALWAYS);
}
private void updateDraft(java.util.function.UnaryOperator<AssetDetailsColorSchemaDraft> update) {
if (formMode() != StudioFormMode.EDITING || formSession == null) {
return;
}
formSession.updateDraft(update);
rerenderPreservingScrollPosition();
}
private AssetDetailsColorSchemaDraft sourceDraft(String schemaId) {
final AssetStudioGlyphMaterialSchema schema = coordinator.findSchema(schemaId).orElse(null);
final List<AssetWorkspaceBankCompositionFile> selectableTiles = schema == null
? coordinator.availableTilesForCreate()
: coordinator.availableTilesForEdit(schema.schemaId());
final Set<String> assignedArtifactIds = schema == null
? Set.of()
: schema.artifactBindings().stream()
.map(AssetStudioGlyphMaterialArtifactBinding::artifactId)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
final List<AssetWorkspaceBankCompositionFile> assignedTiles = selectableTiles.stream()
.filter(file -> assignedArtifactIds.contains(AssetDetailsColorSchemaCoordinator.artifactId(file)))
.toList();
final List<AssetWorkspaceBankCompositionFile> availableTiles = selectableTiles.stream()
.filter(file -> !assignedArtifactIds.contains(AssetDetailsColorSchemaCoordinator.artifactId(file)))
.toList();
final List<String> preferredColors = schema == null
? List.of()
: schema.materialIndices().entrySet().stream()
.sorted(java.util.Map.Entry.comparingByKey())
.map(java.util.Map.Entry::getValue)
.toList();
return new AssetDetailsColorSchemaDraft(schemaId, schema != null, false, availableTiles, assignedTiles, preferredColors, schema == null);
}
private void replaceFormSource() {
if (!coordinator.ready()) {
formSession = null;
return;
}
if (formSession != null && formSession.mode() == StudioFormMode.EDITING) {
return;
}
final String schemaId = selectedSchemaId != null ? selectedSchemaId : defaultSchemaId();
selectedSchemaId = schemaId;
final AssetDetailsColorSchemaDraft source = schemaId == null
? AssetDetailsColorSchemaDraft.noSelection()
: sourceDraft(schemaId);
if (formSession == null) {
formSession = new StudioFormSession<>(source);
} else {
formSession.replaceSource(source);
}
}
private void normalizeSelectedSchema() {
if (selectedSchemaId == null || coordinator.findSchema(selectedSchemaId).isPresent()) {
return;
}
selectedSchemaId = null;
}
private String defaultSchemaId() {
return coordinator.schemas().stream()
.map(AssetStudioGlyphMaterialSchema::schemaId)
.findFirst()
.orElse(null);
}
private List<SchemaListItem> schemaListItems() {
final List<SchemaListItem> items = new java.util.ArrayList<>(coordinator.schemas().stream()
.map(schema -> SchemaListItem.fromSchema(schema, coordinator.selectedTiles()))
.toList());
if (formSession != null
&& formSession.draft().selected()
&& !formSession.draft().existingSchema()
&& items.stream().noneMatch(item -> item.schemaId().equals(formSession.draft().schemaId()))) {
items.add(SchemaListItem.fromDraft(formSession.draft()));
}
return List.copyOf(items);
}
private ListCell<SchemaListItem> createSchemaCell() {
return new ListCell<>() {
@Override
protected void updateItem(SchemaListItem item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setGraphic(null);
setText(null);
return;
}
final Label title = new Label(item.schemaId());
title.getStyleClass().add("assets-details-color-schema-title");
final Label version = new Label(item.existing() ? "v" + item.version() : "new");
version.getStyleClass().add("assets-details-color-schema-version");
final Label status = new Label(item.status());
status.getStyleClass().addAll("studio-chip", Objects.equals(item.status(), AssetStudioGlyphMaterialSchemaStatus.OK.manifestValue())
? "assets-details-color-schema-status-ok"
: "assets-details-color-schema-status-inconsistent");
final Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS);
final HBox top = new HBox(8, title, version, spacer, status);
final Label meta = new Label(item.tileCount() + " tiles · " + item.indexCount() + " indices");
meta.getStyleClass().add("assets-details-color-schema-meta");
final VBox root = new VBox(6, top, meta);
root.getStyleClass().add("assets-details-color-schema-item");
setGraphic(root);
setText(null);
}
};
}
private Region swatch(String color) {
final Region swatch = new Region();
swatch.getStyleClass().add("assets-details-color-schema-swatch");
swatch.setMinSize(20, 20);
swatch.setPrefSize(20, 20);
swatch.setMaxSize(20, 20);
swatch.setStyle("-fx-background-color: " + color + ";");
return swatch;
}
private Node emptyMessage(String text) {
final Label label = new Label(text);
label.getStyleClass().add("assets-details-color-schema-empty");
return label;
}
private String currentScopeKey() {
return viewState == null || viewState.selectedAssetReference() == null
? null
: "asset-details:" + viewState.selectedAssetReference();
}
private static final class SchemaTileDualListView extends StudioDualListView<AssetWorkspaceBankCompositionFile> {
private SchemaTileDualListView() {
setLeftTitle(Container.i18n().text(I18n.ASSETS_COLOR_SCHEMA_DIALOG_AVAILABLE_TILES));
setRightTitle(Container.i18n().text(I18n.ASSETS_COLOR_SCHEMA_DIALOG_ASSIGNED_TILES));
}
@Override
protected String itemText(AssetWorkspaceBankCompositionFile item) {
return item.displayName();
}
}
private record SchemaListItem(
String schemaId,
boolean existing,
int version,
String status,
int tileCount,
int indexCount) {
private static SchemaListItem fromSchema(AssetStudioGlyphMaterialSchema schema, List<AssetWorkspaceBankCompositionFile> selectedTiles) {
return new SchemaListItem(
schema.schemaId(),
true,
schema.version(),
AssetDetailsColorSchemaCoordinator.effectiveStatus(schema, selectedTiles).manifestValue(),
schema.artifactBindings().size(),
schema.materialIndices().size());
}
private static SchemaListItem fromDraft(AssetDetailsColorSchemaDraft draft) {
return new SchemaListItem(
draft.schemaId(),
false,
0,
AssetStudioGlyphMaterialSchemaStatus.OK.manifestValue(),
draft.assignedTiles().size(),
draft.orderedColors().size());
}
}
}

View File

@ -1,5 +1,6 @@
package p.studio.workspaces.assets.details.summary;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import p.studio.Container;
import p.studio.controls.lifecycle.StudioControlLifecycle;
@ -8,6 +9,8 @@ import p.studio.lsp.events.StudioWorkspaceEventBus;
import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.assets.details.AssetDetailsUiSupport;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization;
import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata;
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetSummary;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState;
import p.studio.workspaces.assets.messages.events.StudioAssetsDetailsViewStateChangedEvent;
@ -56,11 +59,20 @@ public final class AssetDetailsSummaryControl extends VBox implements StudioCont
content.getChildren().setAll(
AssetDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_ASSET_ID), summary.assetId() == null ? "" : String.valueOf(summary.assetId())),
AssetDetailsUiSupport.createKeyValueRow(Container.i18n().text(I18n.ASSETS_LABEL_LOCATION), AssetDetailsUiSupport.projectRelativePath(projectReference, summary.assetRoot())));
final HBox typeBox = new HBox(8);
typeBox.getStyleClass().add("assets-details-chips");
typeBox.getChildren().add(AssetDetailsUiSupport.createChip(
AssetDetailsUiSupport.typeChipTone(summary.assetFamily()),
AssetDetailsUiSupport.typeLabel(summary.assetFamily())));
if (summary.glyphSpecialization() != AssetStudioGlyphSpecialization.NONE) {
typeBox.getChildren().add(AssetDetailsUiSupport.createChip(
AssetDetailsUiSupport.typeChipTone(summary.assetFamily()),
AssetDetailsUiSupport.specializationLabel(summary.glyphSpecialization()),
"assets-details-chip-fixed"));
}
content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_TYPE),
AssetDetailsUiSupport.createChip(
AssetDetailsUiSupport.typeChipTone(summary.assetFamily()),
AssetDetailsUiSupport.typeLabel(summary.assetFamily()))));
typeBox));
content.getChildren().add(AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_REGISTRATION),
AssetDetailsUiSupport.createChip(

View File

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

View File

@ -4,6 +4,8 @@ import p.packer.dtos.PackerCodecConfigurationFieldDTO;
import p.packer.dtos.PackerDiagnosticDTO;
import p.packer.messages.assets.OutputCodecCatalog;
import p.packer.messages.assets.OutputFormatCatalog;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata;
import java.util.List;
import java.util.Map;
@ -20,6 +22,9 @@ public record AssetWorkspaceAssetDetails(
Map<String, Object> outputPipeline,
AssetWorkspaceBankCompositionDetails bankComposition,
List<Map<String, Object>> pipelinePalettes,
AssetStudioGlyphMaterialMetadata glyphMaterialMetadata,
AssetStudioSceneBankMetadata sceneBankMetadata,
AssetWorkspaceSceneBankValidation sceneBankValidation,
List<PackerDiagnosticDTO> diagnostics) {
public AssetWorkspaceAssetDetails {
@ -33,6 +38,43 @@ public record AssetWorkspaceAssetDetails(
outputPipeline = Map.copyOf(Objects.requireNonNull(outputPipeline, "outputPipeline"));
bankComposition = Objects.requireNonNull(bankComposition, "bankComposition");
pipelinePalettes = List.copyOf(Objects.requireNonNull(pipelinePalettes, "pipelinePalettes"));
sceneBankValidation = Objects.requireNonNullElse(sceneBankValidation, AssetWorkspaceSceneBankValidation.NOT_APPLICABLE);
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
}
public AssetWorkspaceAssetDetails withBankComposition(AssetWorkspaceBankCompositionDetails bankComposition) {
return new AssetWorkspaceAssetDetails(
summary,
actions,
outputFormat,
outputCodec,
availableOutputCodecs,
codecConfigurationFieldsByCodec,
metadataFields,
outputPipeline,
bankComposition,
pipelinePalettes,
glyphMaterialMetadata,
sceneBankMetadata,
sceneBankValidation,
diagnostics);
}
public AssetWorkspaceAssetDetails withGlyphMaterialMetadata(AssetStudioGlyphMaterialMetadata glyphMaterialMetadata) {
return new AssetWorkspaceAssetDetails(
summary,
actions,
outputFormat,
outputCodec,
availableOutputCodecs,
codecConfigurationFieldsByCodec,
metadataFields,
outputPipeline,
bankComposition,
pipelinePalettes,
glyphMaterialMetadata,
sceneBankMetadata,
sceneBankValidation,
diagnostics);
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,17 @@
package p.studio.workspaces.assets.messages.events;
import p.packer.messages.AssetReference;
import p.studio.lsp.events.StudioEvent;
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionDetails;
import java.util.Objects;
public record StudioBankCompositionChangedEvent(
AssetReference assetReference,
AssetWorkspaceBankCompositionDetails bankComposition) implements StudioEvent {
public StudioBankCompositionChangedEvent {
assetReference = Objects.requireNonNull(assetReference, "assetReference");
bankComposition = Objects.requireNonNull(bankComposition, "bankComposition");
}
}

View File

@ -0,0 +1,17 @@
package p.studio.workspaces.assets.messages.events;
import p.packer.messages.AssetReference;
import p.studio.lsp.events.StudioEvent;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialMetadata;
import java.util.Objects;
public record StudioGlyphMaterialMetadataChangedEvent(
AssetReference assetReference,
AssetStudioGlyphMaterialMetadata metadata) implements StudioEvent {
public StudioGlyphMaterialMetadataChangedEvent {
assetReference = Objects.requireNonNull(assetReference, "assetReference");
metadata = Objects.requireNonNull(metadata, "metadata");
}
}

View File

@ -0,0 +1,19 @@
package p.studio.workspaces.assets.metadata;
import java.util.Objects;
public record AssetStudioGlyphMaterialArtifactBinding(
String artifactId,
String fingerprint) {
public AssetStudioGlyphMaterialArtifactBinding {
artifactId = Objects.requireNonNull(artifactId, "artifactId").trim();
fingerprint = Objects.requireNonNull(Objects.requireNonNullElse(fingerprint, ""), "fingerprint").trim();
if (artifactId.isBlank()) {
throw new IllegalArgumentException("artifactId must not be blank");
}
if (fingerprint.isBlank()) {
throw new IllegalArgumentException("fingerprint must not be blank");
}
}
}

View File

@ -0,0 +1,22 @@
package p.studio.workspaces.assets.metadata;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public record AssetStudioGlyphMaterialMetadata(
List<AssetStudioGlyphMaterialSchema> schemas,
List<AssetStudioGlyphMaterialPalette> palettes,
Path supportFile) {
public AssetStudioGlyphMaterialMetadata {
schemas = List.copyOf(Objects.requireNonNull(schemas, "schemas"));
palettes = List.copyOf(Objects.requireNonNull(palettes, "palettes"));
supportFile = supportFile == null ? null : supportFile.toAbsolutePath().normalize();
}
public Optional<AssetStudioGlyphMaterialSchema> findSchema(String schemaId) {
return schemas.stream().filter(schema -> schema.schemaId().equals(schemaId)).findFirst();
}
}

View File

@ -0,0 +1,53 @@
package p.studio.workspaces.assets.metadata;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public record AssetStudioGlyphMaterialPalette(
String paletteId,
String displayName,
String schemaId,
int version,
List<String> colors) {
public AssetStudioGlyphMaterialPalette(String paletteId, String schemaId, int version, List<String> colors) {
this(paletteId, null, schemaId, version, colors);
}
public AssetStudioGlyphMaterialPalette {
paletteId = Objects.requireNonNull(paletteId, "paletteId").trim();
displayName = displayName == null || displayName.trim().isBlank() ? null : displayName.trim();
schemaId = Objects.requireNonNull(schemaId, "schemaId").trim();
if (paletteId.isBlank()) {
throw new IllegalArgumentException("paletteId must not be blank");
}
if (schemaId.isBlank()) {
throw new IllegalArgumentException("schemaId must not be blank");
}
if (version <= 0) {
throw new IllegalArgumentException("version must be positive");
}
colors = List.copyOf(Objects.requireNonNull(colors, "colors"));
}
public List<Integer> convertedRgb565Colors() {
final List<Integer> converted = new ArrayList<>(colors.size());
for (String color : colors) {
converted.add(convertHtmlRgbToRgb565(color));
}
return List.copyOf(converted);
}
static int convertHtmlRgbToRgb565(String htmlRgb) {
final String value = Objects.requireNonNull(htmlRgb, "htmlRgb").trim();
if (!value.matches("^#[0-9A-Fa-f]{6}$")) {
throw new IllegalArgumentException("htmlRgb must use #RRGGBB format");
}
final int rgb = Integer.parseInt(value.substring(1), 16);
final int red = (rgb >> 16) & 0xFF;
final int green = (rgb >> 8) & 0xFF;
final int blue = rgb & 0xFF;
return ((red >> 3) << 11) | ((green >> 2) << 5) | (blue >> 3);
}
}

View File

@ -0,0 +1,27 @@
package p.studio.workspaces.assets.metadata;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public record AssetStudioGlyphMaterialSchema(
String schemaId,
AssetStudioGlyphMaterialSchemaStatus status,
int version,
Map<Integer, String> materialIndices,
List<AssetStudioGlyphMaterialArtifactBinding> artifactBindings) {
public AssetStudioGlyphMaterialSchema {
schemaId = Objects.requireNonNull(schemaId, "schemaId").trim();
status = Objects.requireNonNull(status, "status");
if (schemaId.isBlank()) {
throw new IllegalArgumentException("schemaId must not be blank");
}
if (version <= 0) {
throw new IllegalArgumentException("version must be positive");
}
materialIndices = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(materialIndices, "materialIndices")));
artifactBindings = List.copyOf(Objects.requireNonNull(artifactBindings, "artifactBindings"));
}
}

View File

@ -0,0 +1,28 @@
package p.studio.workspaces.assets.metadata;
public enum AssetStudioGlyphMaterialSchemaStatus {
OK("ok"),
INCONSISTENT("inconsistent");
private final String manifestValue;
AssetStudioGlyphMaterialSchemaStatus(String manifestValue) {
this.manifestValue = manifestValue;
}
public String manifestValue() {
return manifestValue;
}
public static AssetStudioGlyphMaterialSchemaStatus fromManifestValue(String value) {
if (value == null) {
return null;
}
for (AssetStudioGlyphMaterialSchemaStatus candidate : values()) {
if (candidate.manifestValue.equalsIgnoreCase(value.trim())) {
return candidate;
}
}
return null;
}
}

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,290 @@
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.Map;
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";
public static final String GLYPH_MATERIAL_SUPPORT_FILE = "glyph-material.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),
readGlyphMaterialMetadata(normalizedAssetRoot, assetFamily));
}
public void writeSceneBankMetadata(Path assetRoot, AssetStudioSceneBankMetadata metadata) throws IOException {
final Path normalizedAssetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize();
final AssetStudioSceneBankMetadata normalized = Objects.requireNonNull(metadata, "metadata");
final var root = mapper.createObjectNode();
root.put("schema_version", 1);
root.put("map_width", normalized.mapWidth());
root.put("map_height", normalized.mapHeight());
root.put("layer_count", normalized.layerCount());
final ArrayNode layers = root.putArray("layers");
for (AssetStudioSceneLayerBinding binding : normalized.layerBindings()) {
final var layer = layers.addObject();
layer.put("index", binding.index());
layer.put("enabled", binding.enabled());
layer.put("name", binding.layerName());
layer.put("width", binding.width());
layer.put("height", binding.height());
layer.put("tilemap", binding.tilemap());
layer.put("tileset_asset_root", binding.tilesetAssetRoot());
}
mapper.writerWithDefaultPrettyPrinter().writeValue(
normalizedAssetRoot.resolve(SCENE_BANK_SUPPORT_FILE).toFile(),
root);
}
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);
}
public void writeGlyphMaterialMetadata(Path assetRoot, AssetStudioGlyphMaterialMetadata metadata) throws IOException {
final Path normalizedAssetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize();
final AssetStudioGlyphMaterialMetadata normalized = Objects.requireNonNull(metadata, "metadata");
final var root = mapper.createObjectNode();
root.put("schema_version", 1);
final ArrayNode schemas = root.putArray("schemas");
for (AssetStudioGlyphMaterialSchema schema : normalized.schemas()) {
final var schemaNode = schemas.addObject();
schemaNode.put("schema_id", schema.schemaId());
schemaNode.put("status", schema.status().manifestValue());
schemaNode.put("version", schema.version());
final var materialIndicesNode = schemaNode.putObject("material_indices");
schema.materialIndices().entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> materialIndicesNode.put(String.valueOf(entry.getKey()), entry.getValue()));
final ArrayNode bindingsNode = schemaNode.putArray("artifact_bindings");
for (AssetStudioGlyphMaterialArtifactBinding binding : schema.artifactBindings()) {
final var bindingNode = bindingsNode.addObject();
bindingNode.put("artifact_id", binding.artifactId());
bindingNode.put("fingerprint", binding.fingerprint());
}
}
final ArrayNode palettes = root.putArray("palettes");
for (AssetStudioGlyphMaterialPalette palette : normalized.palettes()) {
final var paletteNode = palettes.addObject();
paletteNode.put("palette_id", palette.paletteId());
if (palette.displayName() != null) {
paletteNode.put("name", palette.displayName());
}
paletteNode.put("schema_id", palette.schemaId());
paletteNode.put("version", palette.version());
final ArrayNode colorsNode = paletteNode.putArray("colors");
palette.colors().forEach(colorsNode::add);
}
mapper.writerWithDefaultPrettyPrinter().writeValue(
normalizedAssetRoot.resolve(GLYPH_MATERIAL_SUPPORT_FILE).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 != 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 boolean enabled = layerNode.path("enabled").asBoolean(false);
final String layerName = layerNode.path("name").asText("").trim();
final int width = layerNode.path("width").asInt(mapWidth);
final int height = layerNode.path("height").asInt(mapHeight);
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() || width <= 0 || height <= 0 || tilemap.isBlank() || !indexes.add(index)) {
return null;
}
bindings.add(new AssetStudioSceneLayerBinding(enabled, index, layerName, width, height, tilemap, tilesetAssetRoot));
}
bindings.sort(Comparator.comparingInt(AssetStudioSceneLayerBinding::index));
return new AssetStudioSceneBankMetadata(mapWidth, mapHeight, layerCount, bindings, supportFile);
} catch (IOException ignored) {
return null;
}
}
private AssetStudioGlyphMaterialMetadata readGlyphMaterialMetadata(Path assetRoot, AssetFamilyCatalog assetFamily) {
if (assetFamily != AssetFamilyCatalog.GLYPH_BANK) {
return null;
}
final Path supportFile = assetRoot.resolve(GLYPH_MATERIAL_SUPPORT_FILE);
if (!Files.isRegularFile(supportFile)) {
return null;
}
try {
final JsonNode root = mapper.readTree(supportFile.toFile());
if (root == null || !root.isObject()) {
return null;
}
if (!(root.path("schemas") instanceof ArrayNode schemasNode) || !(root.path("palettes") instanceof ArrayNode palettesNode)) {
return null;
}
final List<AssetStudioGlyphMaterialSchema> schemas = new ArrayList<>();
final Set<String> schemaIds = new HashSet<>();
for (JsonNode schemaNode : schemasNode) {
final String schemaId = schemaNode.path("schema_id").asText("").trim();
final AssetStudioGlyphMaterialSchemaStatus status = AssetStudioGlyphMaterialSchemaStatus.fromManifestValue(
schemaNode.path("status").asText(null));
final int version = schemaNode.path("version").asInt(0);
if (schemaId.isBlank() || status == null || version <= 0 || !schemaIds.add(schemaId)) {
return null;
}
final JsonNode materialIndicesNode = schemaNode.path("material_indices");
if (!materialIndicesNode.isObject()) {
return null;
}
final Map<Integer, String> materialIndices = new java.util.TreeMap<>();
materialIndicesNode.fields().forEachRemaining(entry -> {
try {
final int index = Integer.parseInt(entry.getKey());
final String color = normalizeHtmlColor(entry.getValue().asText(null));
if (index >= 1 && index <= 15 && color != null) {
materialIndices.put(index, color);
}
} catch (NumberFormatException ignored) {
// handled below through validation
}
});
if (materialIndices.isEmpty()
|| materialIndices.size() != materialIndicesNode.size()
|| !denseIndices(materialIndices.keySet())) {
return null;
}
if (!(schemaNode.path("artifact_bindings") instanceof ArrayNode bindingsNode) || bindingsNode.isEmpty()) {
return null;
}
final List<AssetStudioGlyphMaterialArtifactBinding> bindings = new ArrayList<>();
final Set<String> artifactIds = new HashSet<>();
for (JsonNode bindingNode : bindingsNode) {
final String artifactId = bindingNode.path("artifact_id").asText("").trim();
final String fingerprint = bindingNode.path("fingerprint").asText("").trim();
if (artifactId.isBlank() || fingerprint.isBlank() || !artifactIds.add(artifactId)) {
return null;
}
bindings.add(new AssetStudioGlyphMaterialArtifactBinding(artifactId, fingerprint));
}
schemas.add(new AssetStudioGlyphMaterialSchema(schemaId, status, version, materialIndices, bindings));
}
final List<AssetStudioGlyphMaterialPalette> palettes = new ArrayList<>();
final Set<String> paletteIds = new HashSet<>();
for (JsonNode paletteNode : palettesNode) {
final String paletteId = paletteNode.path("palette_id").asText("").trim();
final String displayName = paletteNode.path("name").asText(null);
final String schemaId = paletteNode.path("schema_id").asText("").trim();
final int version = paletteNode.path("version").asInt(0);
if (paletteId.isBlank() || schemaId.isBlank() || version <= 0 || !paletteIds.add(paletteId) || !schemaIds.contains(schemaId)) {
return null;
}
if (!(paletteNode.path("colors") instanceof ArrayNode colorsNode)) {
return null;
}
final List<String> colors = new ArrayList<>();
for (JsonNode colorNode : colorsNode) {
final String color = normalizeHtmlColor(colorNode.asText(null));
if (color == null) {
return null;
}
colors.add(color);
}
palettes.add(new AssetStudioGlyphMaterialPalette(paletteId, displayName, schemaId, version, colors));
}
return new AssetStudioGlyphMaterialMetadata(schemas, palettes, supportFile);
} catch (IOException ignored) {
return null;
}
}
private boolean denseIndices(Set<Integer> indices) {
if (indices.isEmpty()) {
return false;
}
int expected = 1;
for (Integer index : new java.util.TreeSet<>(indices)) {
if (index == null || index != expected) {
return false;
}
expected += 1;
}
return true;
}
private String normalizeHtmlColor(String value) {
if (value == null) {
return null;
}
final String normalized = value.trim().toUpperCase();
return normalized.matches("^#[0-9A-F]{6}$") ? normalized : null;
}
}

View File

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

View File

@ -0,0 +1,31 @@
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 != 4) {
throw new IllegalArgumentException("layerCount must stay fixed at 4");
}
if (layerBindings.size() != layerCount) {
throw new IllegalArgumentException("layerBindings must match layerCount");
}
}
public long enabledLayerCount() {
return layerBindings.stream().filter(AssetStudioSceneLayerBinding::enabled).count();
}
}

View File

@ -0,0 +1,30 @@
package p.studio.workspaces.assets.metadata;
import java.util.Objects;
public record AssetStudioSceneLayerBinding(
boolean enabled,
int index,
String layerName,
int width,
int height,
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 (width <= 0 || height <= 0) {
throw new IllegalArgumentException("layer dimensions must be positive");
}
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,391 @@
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, null);
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);
}
public SceneBankWorkflowResult inspectLayer(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) {
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, layerIndex);
final String currentFingerprint = diagnostics.stream().anyMatch(PackerDiagnosticDTO::blocking)
? ""
: computeFingerprint(projectReference, details, layerIndex);
final String validatedFingerprint = readFingerprint(details.summary().assetRoot().resolve(layerValidationFile(layerIndex)));
final String acceptedFingerprint = readFingerprint(details.summary().assetRoot().resolve(layerAcceptanceFile(layerIndex)));
final AssetWorkspaceSceneBankValidation validation = validationState(
diagnostics,
currentFingerprint,
validatedFingerprint,
acceptedFingerprint);
return new SceneBankWorkflowResult(
diagnostics.stream().noneMatch(PackerDiagnosticDTO::blocking),
messageFor(validation),
validation,
diagnostics);
}
public SceneBankWorkflowResult validateLayer(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) {
final SceneBankWorkflowResult inspection = inspectLayer(projectReference, details, layerIndex);
final Path validationFile = details.summary().assetRoot().resolve(layerValidationFile(layerIndex));
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 inspectLayer(projectReference, details, layerIndex);
}
public SceneBankWorkflowResult acceptLayer(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) {
final SceneBankWorkflowResult inspection = inspectLayer(projectReference, details, layerIndex);
if (!inspection.validation().canAccept()) {
return new SceneBankWorkflowResult(
false,
"Scene layer must validate successfully before acceptance.",
inspection.validation(),
inspection.diagnostics());
}
try {
writeFingerprint(details.summary().assetRoot().resolve(layerAcceptanceFile(layerIndex)), inspection.validation().currentFingerprint());
writeFingerprint(details.summary().assetRoot().resolve(layerValidationFile(layerIndex)), inspection.validation().currentFingerprint());
} catch (IOException exception) {
return new SceneBankWorkflowResult(
false,
"Unable to persist acceptance state: " + exception.getMessage(),
inspection.validation(),
inspection.diagnostics());
}
return inspectLayer(projectReference, details, layerIndex);
}
private List<PackerDiagnosticDTO> validateDiagnostics(ProjectReference projectReference, AssetWorkspaceAssetDetails details, Integer targetLayerIndex) {
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.enabled()) {
continue;
}
if (targetLayerIndex != null && binding.index() != targetLayerIndex) {
continue;
}
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() != binding.width() || map.height() != binding.height()) {
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()) {
if (!binding.enabled()) {
continue;
}
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 String computeFingerprint(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) {
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());
metadata.layerBindings().stream()
.filter(AssetStudioSceneLayerBinding::enabled)
.filter(binding -> binding.index() == layerIndex)
.findFirst()
.ifPresent(binding -> {
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());
}
});
if (files.size() == 1) {
return "";
}
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 layerValidationFile(int layerIndex) {
return "scene-bank.layer-" + layerIndex + ".validation.json";
}
private String layerAcceptanceFile(int layerIndex) {
return "scene-bank.layer-" + layerIndex + ".acceptance.json";
}
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,187 @@
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, "The selected tileset bank has no included glyph artifacts.", 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()) {
if (!binding.enabled()) {
continue;
}
final TiledAssetGenerationResult result = generateSceneBankTilemap(projectReference, details, binding.index());
if (!result.success()) {
return result;
}
writtenFiles.addAll(result.writtenFiles());
}
if (writtenFiles.isEmpty()) {
return new TiledAssetGenerationResult(false, "Enable at least one layer before generating TMX.", List.of());
}
return new TiledAssetGenerationResult(true, "Generated TMX successfully.", writtenFiles);
} catch (RuntimeException exception) {
return new TiledAssetGenerationResult(false, "Unable to generate TMX: " + exception.getMessage(), List.of());
}
}
public TiledAssetGenerationResult generateSceneBankTilemap(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) {
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 AssetStudioSceneLayerBinding binding = metadata.layerBindings().stream()
.filter(candidate -> candidate.index() == layerIndex)
.findFirst()
.orElse(null);
if (binding == null) {
return new TiledAssetGenerationResult(false, "Layer " + layerIndex + " is not defined in Scene Bank metadata.", List.of());
}
if (!binding.enabled()) {
return new TiledAssetGenerationResult(false, "Layer " + layerIndex + " is disabled.", List.of());
}
try {
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 = binding.width() * binding.height();
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",
binding.width(),
binding.height(),
tileset.tileWidth(),
tileset.tileHeight(),
2,
1,
List.of(),
List.of(new TiledTilesetReference(1, relativeTilesetPath)),
List.of(new TiledTileLayer(
1,
binding.layerName(),
binding.width(),
binding.height(),
gids,
List.of())),
List.of());
final Path outputPath = details.summary().assetRoot().resolve(binding.tilemap());
codec.writeMap(outputPath, document);
return new TiledAssetGenerationResult(true, "Generated TMX for layer " + binding.index() + ".", List.of(outputPath));
} catch (TiledUnsupportedFeatureException | IOException exception) {
return new TiledAssetGenerationResult(false, "Unable to generate TMX: " + exception.getMessage(), List.of());
}
}
private int parseTileSize(List<PackerCodecConfigurationFieldDTO> metadataFields) {
final String value = metadataFields.stream()
.filter(field -> "tile_size".equals(field.key()))
.map(PackerCodecConfigurationFieldDTO::value)
.findFirst()
.orElse("16x16");
final String normalized = value.trim().toLowerCase();
if (normalized.startsWith("8x8")) {
return 8;
}
if (normalized.startsWith("32x32")) {
return 32;
}
return 16;
}
}

View File

@ -0,0 +1,32 @@
package p.studio.workspaces.assets.tiled;
import java.util.List;
import java.util.Objects;
public record TiledMapDocument(
String version,
String tiledVersion,
String orientation,
String renderOrder,
int width,
int height,
int tileWidth,
int tileHeight,
int nextLayerId,
int nextObjectId,
List<TiledProperty> properties,
List<TiledTilesetReference> tilesets,
List<TiledTileLayer> tileLayers,
List<TiledObjectLayer> objectLayers) {
public TiledMapDocument {
version = Objects.requireNonNullElse(version, "1.10");
tiledVersion = Objects.requireNonNullElse(tiledVersion, "1.12.1");
orientation = Objects.requireNonNullElse(orientation, "orthogonal");
renderOrder = Objects.requireNonNullElse(renderOrder, "right-down");
properties = List.copyOf(Objects.requireNonNull(properties, "properties"));
tilesets = List.copyOf(Objects.requireNonNull(tilesets, "tilesets"));
tileLayers = List.copyOf(Objects.requireNonNull(tileLayers, "tileLayers"));
objectLayers = List.copyOf(Objects.requireNonNull(objectLayers, "objectLayers"));
}
}

View File

@ -0,0 +1,21 @@
package p.studio.workspaces.assets.tiled;
import java.util.List;
import java.util.Objects;
public record TiledObjectData(
int id,
String name,
double x,
double y,
double width,
double height,
List<TiledPoint> polygonPoints,
List<TiledProperty> properties) {
public TiledObjectData {
name = Objects.requireNonNullElse(name, "").trim();
polygonPoints = List.copyOf(Objects.requireNonNull(polygonPoints, "polygonPoints"));
properties = List.copyOf(Objects.requireNonNull(properties, "properties"));
}
}

View File

@ -0,0 +1,17 @@
package p.studio.workspaces.assets.tiled;
import java.util.List;
import java.util.Objects;
public record TiledObjectLayer(
int id,
String name,
List<TiledObjectData> objects,
List<TiledProperty> properties) {
public TiledObjectLayer {
name = Objects.requireNonNullElse(name, "").trim();
objects = List.copyOf(Objects.requireNonNull(objects, "objects"));
properties = List.copyOf(Objects.requireNonNull(properties, "properties"));
}
}

View File

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

View File

@ -0,0 +1,17 @@
package p.studio.workspaces.assets.tiled;
import java.util.Objects;
public record TiledProperty(String name, String type, String value) {
public TiledProperty {
name = Objects.requireNonNull(name, "name").trim();
type = Objects.requireNonNullElse(type, "string").trim();
value = Objects.requireNonNullElse(value, "").trim();
if (name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
if (type.isBlank()) {
type = "string";
}
}
}

View File

@ -0,0 +1,19 @@
package p.studio.workspaces.assets.tiled;
import java.util.List;
import java.util.Objects;
public record TiledTileLayer(
int id,
String name,
int width,
int height,
List<Long> gids,
List<TiledProperty> properties) {
public TiledTileLayer {
name = Objects.requireNonNullElse(name, "").trim();
gids = List.copyOf(Objects.requireNonNull(gids, "gids"));
properties = List.copyOf(Objects.requireNonNull(properties, "properties"));
}
}

View File

@ -0,0 +1,27 @@
package p.studio.workspaces.assets.tiled;
import java.util.List;
import java.util.Objects;
public record TiledTilesetDocument(
String version,
String tiledVersion,
String name,
int tileWidth,
int tileHeight,
int tileCount,
int columns,
List<TiledProperty> properties,
List<TiledTilesetTile> tiles) {
public TiledTilesetDocument {
version = Objects.requireNonNullElse(version, "1.10");
tiledVersion = Objects.requireNonNullElse(tiledVersion, "1.12.1");
name = Objects.requireNonNull(name, "name").trim();
properties = List.copyOf(Objects.requireNonNull(properties, "properties"));
tiles = List.copyOf(Objects.requireNonNull(tiles, "tiles"));
if (name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
}
}

View File

@ -0,0 +1,15 @@
package p.studio.workspaces.assets.tiled;
import java.util.Objects;
public record TiledTilesetReference(int firstGid, String source) {
public TiledTilesetReference {
source = Objects.requireNonNull(source, "source").trim();
if (firstGid <= 0) {
throw new IllegalArgumentException("firstGid must be positive");
}
if (source.isBlank()) {
throw new IllegalArgumentException("source must not be blank");
}
}
}

View File

@ -0,0 +1,21 @@
package p.studio.workspaces.assets.tiled;
import java.util.List;
import java.util.Objects;
public record TiledTilesetTile(
int id,
String imageSource,
int imageWidth,
int imageHeight,
List<TiledProperty> properties,
TiledObjectLayer collisionLayer) {
public TiledTilesetTile {
imageSource = Objects.requireNonNull(imageSource, "imageSource").trim();
properties = List.copyOf(Objects.requireNonNull(properties, "properties"));
if (imageSource.isBlank()) {
throw new IllegalArgumentException("imageSource must not be blank");
}
}
}

View File

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

View File

@ -0,0 +1,460 @@
package p.studio.workspaces.assets.tiled;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
public final class TiledXmlCodec {
public TiledMapDocument readMap(Path path) throws IOException {
final Document document = parseDocument(path);
final Element root = document.getDocumentElement();
if (!"map".equals(root.getTagName())) {
throw new IOException("TMX root element must be <map>.");
}
if (!"0".equals(root.getAttribute("infinite")) && !root.getAttribute("infinite").isBlank()) {
throw new TiledUnsupportedFeatureException("Infinite maps are not supported in wave 1.");
}
rejectMapUnsupportedChildren(root);
return new TiledMapDocument(
root.getAttribute("version"),
root.getAttribute("tiledversion"),
root.getAttribute("orientation"),
root.getAttribute("renderorder"),
intAttribute(root, "width", 0),
intAttribute(root, "height", 0),
intAttribute(root, "tilewidth", 0),
intAttribute(root, "tileheight", 0),
intAttribute(root, "nextlayerid", 0),
intAttribute(root, "nextobjectid", 0),
readProperties(child(root, "properties")),
readTilesets(root),
readTileLayers(root),
readObjectLayers(root));
}
public void writeMap(Path path, TiledMapDocument map) throws IOException {
final Document document = newDocument();
final Element root = document.createElement("map");
document.appendChild(root);
root.setAttribute("version", map.version());
root.setAttribute("tiledversion", map.tiledVersion());
root.setAttribute("orientation", map.orientation());
root.setAttribute("renderorder", map.renderOrder());
root.setAttribute("width", Integer.toString(map.width()));
root.setAttribute("height", Integer.toString(map.height()));
root.setAttribute("tilewidth", Integer.toString(map.tileWidth()));
root.setAttribute("tileheight", Integer.toString(map.tileHeight()));
root.setAttribute("infinite", "0");
root.setAttribute("nextlayerid", Integer.toString(map.nextLayerId()));
root.setAttribute("nextobjectid", Integer.toString(map.nextObjectId()));
appendProperties(document, root, map.properties());
for (TiledTilesetReference tileset : map.tilesets()) {
final Element tilesetElement = document.createElement("tileset");
tilesetElement.setAttribute("firstgid", Integer.toString(tileset.firstGid()));
tilesetElement.setAttribute("source", tileset.source());
root.appendChild(tilesetElement);
}
for (TiledTileLayer layer : map.tileLayers()) {
final Element layerElement = document.createElement("layer");
layerElement.setAttribute("id", Integer.toString(layer.id()));
layerElement.setAttribute("name", layer.name());
layerElement.setAttribute("width", Integer.toString(layer.width()));
layerElement.setAttribute("height", Integer.toString(layer.height()));
appendProperties(document, layerElement, layer.properties());
final Element dataElement = document.createElement("data");
dataElement.setAttribute("encoding", "csv");
dataElement.setTextContent(csv(layer.gids(), layer.width()));
layerElement.appendChild(dataElement);
root.appendChild(layerElement);
}
for (TiledObjectLayer layer : map.objectLayers()) {
root.appendChild(writeObjectLayer(document, layer, false));
}
writeDocument(path, document);
}
public TiledTilesetDocument readTileset(Path path) throws IOException {
final Document document = parseDocument(path);
final Element root = document.getDocumentElement();
if (!"tileset".equals(root.getTagName())) {
throw new IOException("TSX root element must be <tileset>.");
}
rejectTilesetUnsupportedChildren(root);
final List<TiledTilesetTile> tiles = new ArrayList<>();
for (Element tileElement : children(root, "tile")) {
if (child(tileElement, "animation") != null) {
throw new TiledUnsupportedFeatureException("Tiled animations are not supported in wave 1.");
}
final Element imageElement = child(tileElement, "image");
if (imageElement == null) {
throw new IOException("TSX tile must contain an <image> element.");
}
tiles.add(new TiledTilesetTile(
intAttribute(tileElement, "id", 0),
imageElement.getAttribute("source"),
intAttribute(imageElement, "width", 0),
intAttribute(imageElement, "height", 0),
readProperties(child(tileElement, "properties")),
readEmbeddedObjectLayer(tileElement)));
}
tiles.sort(Comparator.comparingInt(TiledTilesetTile::id));
return new TiledTilesetDocument(
root.getAttribute("version"),
root.getAttribute("tiledversion"),
root.getAttribute("name"),
intAttribute(root, "tilewidth", 0),
intAttribute(root, "tileheight", 0),
intAttribute(root, "tilecount", tiles.size()),
intAttribute(root, "columns", 0),
readProperties(child(root, "properties")),
tiles);
}
public void writeTileset(Path path, TiledTilesetDocument tileset) throws IOException {
final Document document = newDocument();
final Element root = document.createElement("tileset");
document.appendChild(root);
root.setAttribute("version", tileset.version());
root.setAttribute("tiledversion", tileset.tiledVersion());
root.setAttribute("name", tileset.name());
root.setAttribute("tilewidth", Integer.toString(tileset.tileWidth()));
root.setAttribute("tileheight", Integer.toString(tileset.tileHeight()));
root.setAttribute("tilecount", Integer.toString(tileset.tileCount()));
root.setAttribute("columns", Integer.toString(tileset.columns()));
final Element grid = document.createElement("grid");
grid.setAttribute("orientation", "orthogonal");
grid.setAttribute("width", "1");
grid.setAttribute("height", "1");
root.appendChild(grid);
appendProperties(document, root, tileset.properties());
for (TiledTilesetTile tile : tileset.tiles()) {
final Element tileElement = document.createElement("tile");
tileElement.setAttribute("id", Integer.toString(tile.id()));
appendProperties(document, tileElement, tile.properties());
final Element imageElement = document.createElement("image");
imageElement.setAttribute("source", tile.imageSource());
imageElement.setAttribute("width", Integer.toString(tile.imageWidth()));
imageElement.setAttribute("height", Integer.toString(tile.imageHeight()));
tileElement.appendChild(imageElement);
if (tile.collisionLayer() != null) {
tileElement.appendChild(writeObjectLayer(document, tile.collisionLayer(), true));
}
root.appendChild(tileElement);
}
writeDocument(path, document);
}
private Document parseDocument(Path path) throws IOException {
try {
final var factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(false);
return factory.newDocumentBuilder().parse(path.toFile());
} catch (TiledUnsupportedFeatureException exception) {
throw exception;
} catch (Exception exception) {
throw new IOException("Unable to parse Tiled XML: " + exception.getMessage(), exception);
}
}
private Document newDocument() throws IOException {
try {
return DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument();
} catch (Exception exception) {
throw new IOException("Unable to create XML document: " + exception.getMessage(), exception);
}
}
private void writeDocument(Path path, Document document) throws IOException {
try {
Files.createDirectories(Objects.requireNonNull(path, "path").toAbsolutePath().normalize().getParent());
final var transformer = TransformerFactory.newInstance().newTransformer();
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
transformer.transform(new DOMSource(document), new StreamResult(path.toFile()));
} catch (Exception exception) {
throw new IOException("Unable to write Tiled XML: " + exception.getMessage(), exception);
}
}
private void rejectMapUnsupportedChildren(Element root) {
for (Element child : childElements(root)) {
final String tag = child.getTagName();
if ("imagelayer".equals(tag) || "group".equals(tag) || "template".equals(tag)) {
throw new TiledUnsupportedFeatureException("Unsupported TMX feature in wave 1: " + tag);
}
}
}
private void rejectTilesetUnsupportedChildren(Element root) {
for (Element child : childElements(root)) {
final String tag = child.getTagName();
if ("wangsets".equals(tag) || "tileoffset".equals(tag) || "transformations".equals(tag)) {
throw new TiledUnsupportedFeatureException("Unsupported TSX feature in wave 1: " + tag);
}
}
}
private List<TiledTilesetReference> readTilesets(Element root) {
final List<TiledTilesetReference> tilesets = new ArrayList<>();
for (Element tilesetElement : children(root, "tileset")) {
if (!tilesetElement.hasAttribute("source")) {
throw new TiledUnsupportedFeatureException("Inline tilesets are not supported in wave 1.");
}
tilesets.add(new TiledTilesetReference(
intAttribute(tilesetElement, "firstgid", 1),
tilesetElement.getAttribute("source")));
}
return List.copyOf(tilesets);
}
private List<TiledTileLayer> readTileLayers(Element root) {
final List<TiledTileLayer> layers = new ArrayList<>();
for (Element layerElement : children(root, "layer")) {
final Element dataElement = child(layerElement, "data");
if (dataElement == null || !"csv".equalsIgnoreCase(dataElement.getAttribute("encoding"))) {
throw new TiledUnsupportedFeatureException("Wave 1 supports only CSV tile layer encoding.");
}
layers.add(new TiledTileLayer(
intAttribute(layerElement, "id", 0),
layerElement.getAttribute("name"),
intAttribute(layerElement, "width", 0),
intAttribute(layerElement, "height", 0),
parseCsvData(dataElement.getTextContent()),
readProperties(child(layerElement, "properties"))));
}
return List.copyOf(layers);
}
private List<TiledObjectLayer> readObjectLayers(Element root) {
final List<TiledObjectLayer> layers = new ArrayList<>();
for (Element objectGroup : children(root, "objectgroup")) {
layers.add(readObjectLayer(objectGroup));
}
return List.copyOf(layers);
}
private TiledObjectLayer readEmbeddedObjectLayer(Element parent) {
final Element objectGroup = child(parent, "objectgroup");
return objectGroup == null ? null : readObjectLayer(objectGroup);
}
private TiledObjectLayer readObjectLayer(Element objectGroup) {
final List<TiledObjectData> objects = new ArrayList<>();
for (Element objectElement : children(objectGroup, "object")) {
final Element polygon = child(objectElement, "polygon");
if (child(objectElement, "ellipse") != null || child(objectElement, "point") != null || child(objectElement, "polyline") != null) {
throw new TiledUnsupportedFeatureException("Unsupported object-layer geometry in wave 1.");
}
objects.add(new TiledObjectData(
intAttribute(objectElement, "id", 0),
objectElement.getAttribute("name"),
doubleAttribute(objectElement, "x", 0.0d),
doubleAttribute(objectElement, "y", 0.0d),
doubleAttribute(objectElement, "width", 0.0d),
doubleAttribute(objectElement, "height", 0.0d),
polygon == null ? List.of() : parsePolygon(polygon.getAttribute("points")),
readProperties(child(objectElement, "properties"))));
}
return new TiledObjectLayer(
intAttribute(objectGroup, "id", 0),
objectGroup.getAttribute("name"),
objects,
readProperties(child(objectGroup, "properties")));
}
private Element writeObjectLayer(Document document, TiledObjectLayer layer, boolean embedded) {
final Element objectGroup = document.createElement("objectgroup");
if (layer.id() > 0) {
objectGroup.setAttribute("id", Integer.toString(layer.id()));
}
if (!layer.name().isBlank()) {
objectGroup.setAttribute("name", layer.name());
}
if (embedded) {
objectGroup.setAttribute("draworder", "index");
}
appendProperties(document, objectGroup, layer.properties());
for (TiledObjectData object : layer.objects()) {
final Element objectElement = document.createElement("object");
if (object.id() > 0) {
objectElement.setAttribute("id", Integer.toString(object.id()));
}
if (!object.name().isBlank()) {
objectElement.setAttribute("name", object.name());
}
objectElement.setAttribute("x", formatDecimal(object.x()));
objectElement.setAttribute("y", formatDecimal(object.y()));
if (object.width() > 0.0d) {
objectElement.setAttribute("width", formatDecimal(object.width()));
}
if (object.height() > 0.0d) {
objectElement.setAttribute("height", formatDecimal(object.height()));
}
appendProperties(document, objectElement, object.properties());
if (!object.polygonPoints().isEmpty()) {
final Element polygon = document.createElement("polygon");
polygon.setAttribute("points", polygonPoints(object.polygonPoints()));
objectElement.appendChild(polygon);
}
objectGroup.appendChild(objectElement);
}
return objectGroup;
}
private void appendProperties(Document document, Element parent, List<TiledProperty> properties) {
if (properties.isEmpty()) {
return;
}
final Element propertiesElement = document.createElement("properties");
for (TiledProperty property : properties) {
final Element propertyElement = document.createElement("property");
propertyElement.setAttribute("name", property.name());
if (!"string".equals(property.type())) {
propertyElement.setAttribute("type", property.type());
}
propertyElement.setAttribute("value", property.value());
propertiesElement.appendChild(propertyElement);
}
parent.appendChild(propertiesElement);
}
private List<TiledProperty> readProperties(Element propertiesElement) {
if (propertiesElement == null) {
return List.of();
}
final List<TiledProperty> properties = new ArrayList<>();
for (Element propertyElement : children(propertiesElement, "property")) {
properties.add(new TiledProperty(
propertyElement.getAttribute("name"),
propertyElement.getAttribute("type"),
propertyElement.hasAttribute("value")
? propertyElement.getAttribute("value")
: propertyElement.getTextContent()));
}
return List.copyOf(properties);
}
private List<Long> parseCsvData(String text) {
final List<Long> gids = new ArrayList<>();
for (String token : Objects.requireNonNullElse(text, "").split(",")) {
final String normalized = token.trim();
if (normalized.isBlank()) {
continue;
}
gids.add(Long.parseLong(normalized));
}
return List.copyOf(gids);
}
private List<TiledPoint> parsePolygon(String points) {
final List<TiledPoint> parsed = new ArrayList<>();
for (String segment : Objects.requireNonNullElse(points, "").trim().split(" ")) {
final String normalized = segment.trim();
if (normalized.isBlank()) {
continue;
}
final String[] pair = normalized.split(",");
if (pair.length != 2) {
throw new TiledUnsupportedFeatureException("Invalid polygon point format in Tiled XML.");
}
parsed.add(new TiledPoint(Double.parseDouble(pair[0]), Double.parseDouble(pair[1])));
}
return List.copyOf(parsed);
}
private String csv(List<Long> gids, int width) {
final StringBuilder builder = new StringBuilder();
for (int index = 0; index < gids.size(); index += 1) {
if (index > 0) {
builder.append(index % Math.max(width, 1) == 0 ? ",\n" : ",");
}
builder.append(Long.toUnsignedString(gids.get(index)));
}
return builder.toString();
}
private String polygonPoints(List<TiledPoint> points) {
final StringBuilder builder = new StringBuilder();
for (int index = 0; index < points.size(); index += 1) {
if (index > 0) {
builder.append(' ');
}
builder.append(formatDecimal(points.get(index).x()))
.append(',')
.append(formatDecimal(points.get(index).y()));
}
return builder.toString();
}
private int intAttribute(Element element, String attribute, int fallback) {
final String value = element.getAttribute(attribute);
if (value == null || value.isBlank()) {
return fallback;
}
return Integer.parseInt(value.trim());
}
private double doubleAttribute(Element element, String attribute, double fallback) {
final String value = element.getAttribute(attribute);
if (value == null || value.isBlank()) {
return fallback;
}
return Double.parseDouble(value.trim());
}
private String formatDecimal(double value) {
if (Math.rint(value) == value) {
return Long.toString(Math.round(value));
}
return String.format(Locale.ROOT, "%.6f", value)
.replaceAll("0+$", "")
.replaceAll("\\.$", "");
}
private Element child(Element parent, String name) {
for (Element child : childElements(parent)) {
if (name.equals(child.getTagName())) {
return child;
}
}
return null;
}
private List<Element> children(Element parent, String name) {
final List<Element> elements = new ArrayList<>();
for (Element child : childElements(parent)) {
if (name.equals(child.getTagName())) {
elements.add(child);
}
}
return List.copyOf(elements);
}
private List<Element> childElements(Element parent) {
final NodeList children = parent.getChildNodes();
final List<Element> elements = new ArrayList<>();
for (int index = 0; index < children.getLength(); index += 1) {
final Node node = children.item(index);
if (node instanceof Element element) {
elements.add(element);
}
}
return List.copyOf(elements);
}
}

View File

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

View File

@ -135,6 +135,9 @@ assets.badge.diagnostics=Diagnostics
assets.section.summary=Summary
assets.section.runtimeContract=Runtime Contract
assets.section.bankComposition=Bank Composition
assets.section.sceneLayers=Scene Layers
assets.section.colorSchemaGroups=Color Schema Groups
assets.section.layerConfiguration=Layer Configuration
assets.section.paletteOverhauling=Palette Overhauling
assets.subsection.codecConfiguration=Codec Configuration
assets.subsection.metadata=Metadata
@ -144,6 +147,12 @@ assets.section.actions=Actions
assets.actions.empty=No actions available for this asset.
assets.action.register=Register
assets.action.analyse=Analyse
assets.action.generateTsx=Generate TSX
assets.action.generateTmx=Generate TMX
assets.action.validateSceneBank=Validate Scene Bank
assets.action.acceptSceneBank=Accept Scene Bank
assets.action.validateLayer=Validate Layer
assets.action.acceptLayer=Accept Layer
assets.action.delete=Delete
assets.deleteDialog.title=Delete Asset
assets.deleteDialog.description=Type the asset name below to confirm deletion of {0}.
@ -181,10 +190,30 @@ assets.label.registration=Registration
assets.label.buildParticipation=Build Participation
assets.label.assetId=Asset ID
assets.label.type=Type
assets.label.studioRole=Studio Role
assets.label.sceneStatus=Scene Status
assets.label.sceneLayers=Scene Layers
assets.label.sceneDimensions=Layer Size
assets.label.layer=Layer
assets.label.layerOverview=Layer Overview
assets.label.tilemaps=Tilemaps
assets.label.tilemap=Tilemap
assets.label.tileset=Tileset
assets.label.supportFile=Support File
assets.label.useLayer=Use this layer
assets.type.glyphBank=Glyph Bank
assets.type.sceneBank=Scene Bank
assets.type.paletteBank=Palette Bank
assets.type.soundBank=Sound Bank
assets.type.unknown=Unknown
assets.specialization.none=None
assets.specialization.tileset=Tileset
assets.specialization.sprites=Sprites
assets.specialization.ui=UI
assets.sceneStatus.pendingValidation=Pending Validation
assets.sceneStatus.validatedPendingAcceptance=Validated / Pending Acceptance
assets.sceneStatus.ready=Ready
assets.sceneStatus.validationFailed=Validation Failed
assets.label.location=Location
assets.label.bank=Bank
assets.label.targetLocation=Target Location
@ -197,6 +226,8 @@ assets.value.included=Included
assets.value.excluded=Excluded
assets.value.yes=Yes
assets.value.no=No
assets.value.notConfigured=Not configured
assets.value.layerOverviewPending=Overview reserved for this layer. Preview data will appear here when the layer pipeline is connected.
assets.progress.idle=Assets workspace idle.
assets.progress.refreshing=Refreshing assets...
assets.progress.loadingDetails=Loading selected asset details...
@ -232,6 +263,22 @@ assets.paletteOverhauling.selected=Bank Palettes
assets.paletteOverhauling.tileSelector=Preview Tile
assets.paletteOverhauling.appliedPalette=Applied Palette
assets.paletteOverhauling.previewEmpty=Select at least one palette to inspect it on a tile.
assets.paletteWizard.createTitle=Create Palette
assets.paletteWizard.updateTitle=Update Palette
assets.paletteWizard.name=Name
assets.paletteWizard.schema=Schema
assets.paletteWizard.original=Original Palette
assets.paletteWizard.editable=Editable Palette
assets.paletteWizard.applyColor=Apply Color
assets.paletteWizard.duplicate=An identical palette already exists for this schema.
assets.paletteWizard.ready=Palette is ready to be saved.
assets.colorSchema.empty=No schemas have been authored for this glyph bank yet.
assets.colorSchema.summary={0} schema groups ready, {1} selected tiles still unassigned.
assets.colorSchema.removeBlocked=Schema {0} cannot be removed while palettes still reference it.
assets.colorSchema.dialog.availableTiles=Available Tiles
assets.colorSchema.dialog.assignedTiles=Assigned Tiles
assets.colorSchema.dialog.colorCount=Color count: {0}
assets.colorSchema.dialog.tooManyColors=Color Schema Groups support at most 15 visible indices.
assets.details.codecConfiguration.empty=This codec does not expose configuration fields yet.
assets.details.metadata.empty=This asset does not expose metadata fields yet.
assets.addWizard.title=Add Asset
@ -245,10 +292,12 @@ assets.addWizard.step.summary.description=Confirm the registered asset you are a
assets.addWizard.label.name=Asset Name
assets.addWizard.label.root=Asset Root
assets.addWizard.label.type=Asset Type
assets.addWizard.label.specialization=Studio Specialization
assets.addWizard.label.format=Output Format
assets.addWizard.label.codec=Output Codec
assets.addWizard.label.preload=Preload on startup
assets.addWizard.prompt.type=Choose asset type
assets.addWizard.prompt.specialization=Choose Studio specialization
assets.addWizard.prompt.format=Choose output format
assets.addWizard.prompt.codec=Choose output codec
assets.addWizard.assetsRootHint=Assets root: {0}
@ -261,6 +310,7 @@ assets.addWizard.error.rootRelative=Asset root must be a relative path inside as
assets.addWizard.error.rootOutsideAssets=The selected directory must stay inside assets/.
assets.addWizard.error.rootAlreadyAsset=The selected root already contains asset.json.
assets.addWizard.error.type=Choose an asset type before continuing.
assets.addWizard.error.specialization=Choose a Studio specialization before continuing.
assets.addWizard.error.format=Choose an output format before continuing.
assets.addWizard.error.codec=Choose an output codec before continuing.
assets.addWizard.error.unsupportedCombination=The selected asset type, output format, and codec combination is not supported.

View File

@ -114,6 +114,20 @@
-fx-padding: 8;
}
.studio-button.assets-details-mini-action-button {
-fx-min-width: 26;
-fx-pref-width: 26;
-fx-max-width: 26;
-fx-min-height: 24;
-fx-pref-height: 24;
-fx-max-height: 24;
-fx-padding: 0;
-fx-background-radius: 6;
-fx-border-radius: 6;
-fx-font-size: 12px;
-fx-font-weight: bold;
}
.studio-button.studio-button-primary {
-fx-background-color: #27507a;
-fx-border-color: #5ea0de;
@ -1213,6 +1227,15 @@
-fx-background-color: #162a20;
}
.assets-workspace-asset-row-tone-scene {
-fx-background-color: #1d1610;
-fx-border-color: #b58931;
}
.assets-workspace-asset-row-tone-scene:hover {
-fx-background-color: #2a2015;
}
.assets-workspace-asset-row-tone-generic {
-fx-background-color: #11151b;
-fx-border-color: #334050;
@ -1240,6 +1263,10 @@
-fx-text-fill: #aee8c5;
}
.assets-workspace-asset-name-tone-scene {
-fx-text-fill: #f5d47a;
}
.assets-workspace-asset-name-tone-generic {
-fx-text-fill: #f6fbff;
}
@ -1457,6 +1484,12 @@
-fx-max-height: 446;
}
.assets-details-color-schema-section {
-fx-min-height: 446;
-fx-pref-height: 446;
-fx-max-height: 446;
}
.assets-details-bank-composition-body {
-fx-alignment: top-left;
}
@ -1465,6 +1498,76 @@
-fx-alignment: top-left;
}
.assets-details-palette-overhauling-toolbar {
-fx-alignment: top-left;
-fx-padding: 25 0 0 0;
}
.assets-details-color-schema-content {
-fx-alignment: top-left;
}
.assets-details-color-schema-toolbar {
-fx-alignment: top-left;
-fx-padding: 25 0 0 0;
}
.assets-details-list-with-actions {
-fx-alignment: top-left;
}
.assets-details-color-schema-body {
-fx-alignment: top-left;
}
.assets-details-color-schema-list {
-fx-pref-width: 280;
-fx-max-width: 320;
}
.assets-details-color-schema-item {
-fx-padding: 8 10 8 10;
}
.assets-details-color-schema-title {
-fx-font-size: 12px;
-fx-font-weight: bold;
-fx-text-fill: #f2f6fb;
}
.assets-details-color-schema-version {
-fx-font-size: 11px;
-fx-text-fill: #8fa4b8;
}
.assets-details-color-schema-meta,
.assets-details-color-schema-summary,
.assets-details-color-schema-empty,
.assets-details-color-schema-warning,
.assets-details-color-schema-preview-title {
-fx-font-size: 11px;
-fx-text-fill: #9bb0c3;
}
.assets-details-color-schema-status-ok {
-fx-background-color: #203a2b;
-fx-text-fill: #8ee0ac;
}
.assets-details-color-schema-status-inconsistent {
-fx-background-color: #4a261f;
-fx-text-fill: #ffb29d;
}
.assets-details-color-schema-strip {
-fx-padding: 0 0 4 0;
}
.assets-details-color-schema-swatch {
-fx-border-color: #334150;
-fx-border-width: 1;
}
.assets-details-bank-file-cell {
-fx-alignment: center-left;
}
@ -1500,6 +1603,43 @@
-fx-alignment: center-left;
}
.assets-details-palette-invalid {
-fx-font-size: 10px;
-fx-text-fill: #d9a58f;
}
.assets-details-palette-wizard {
-fx-background-color: #111821;
-fx-border-color: #263545;
-fx-border-width: 1;
}
.assets-details-palette-wizard-label {
-fx-font-size: 11px;
-fx-font-weight: bold;
-fx-text-fill: #9fc3e7;
}
.assets-details-palette-wizard-strip,
.assets-details-palette-wizard-editor,
.assets-details-palette-wizard-color-editor {
-fx-alignment: center-left;
}
.assets-details-palette-wizard-summary {
-fx-font-size: 11px;
-fx-text-fill: #c0d0e0;
}
.assets-details-palette-wizard-actions {
-fx-alignment: center-right;
}
.assets-details-palette-wizard-swatch {
-fx-border-color: #334150;
-fx-border-width: 1;
}
.assets-details-palette-hash {
-fx-text-fill: #9fc3e7;
-fx-font-size: 10px;
@ -1594,6 +1734,124 @@
-fx-border-width: 1;
}
.assets-details-chip-fixed {
-fx-alignment: center;
-fx-min-width: 88;
-fx-pref-width: 88;
-fx-max-width: 88;
}
.assets-scene-layer-shell {
-fx-spacing: 0;
}
.assets-scene-layer-layout {
-fx-alignment: top-left;
}
.assets-scene-layer-config-column {
-fx-spacing: 10;
}
.assets-scene-layer-config {
-fx-pref-width: 0;
-fx-spacing: 8;
}
.assets-scene-layer-config-section {
-fx-pref-width: 0;
}
.assets-scene-layer-chip {
-fx-min-width: 70;
-fx-pref-width: 70;
-fx-max-width: 70;
-fx-alignment: center;
}
.assets-scene-layer-selector {
-fx-alignment: center-left;
}
.assets-scene-layer-selector-button {
-fx-background-color: #17120d;
-fx-border-color: #634d22;
-fx-text-fill: #d9c28f;
-fx-font-weight: bold;
-fx-padding: 8 14 8 14;
}
.assets-scene-layer-selector-button:hover {
-fx-background-color: #221a10;
-fx-border-color: #8f6f33;
-fx-text-fill: #f5ddb0;
}
.assets-scene-layer-selector-button-active {
-fx-background-color: #2a1f12;
-fx-border-color: #d4a94a;
-fx-text-fill: #ffe5a6;
}
.assets-scene-layer-enabled {
-fx-text-fill: #d6e0ea;
}
.assets-scene-layer-form-actions {
-fx-alignment: center-right;
-fx-spacing: 8;
}
.assets-scene-layer-actions {
-fx-padding: 6 0 0 0;
}
.assets-scene-layer-action {
-fx-max-width: 140;
}
.assets-scene-layer-size-editor {
-fx-alignment: center-left;
}
.assets-scene-layer-size-field {
-fx-pref-width: 88;
}
.assets-scene-layer-size-separator {
-fx-text-fill: #d6e0ea;
-fx-font-weight: bold;
}
.assets-scene-layer-overview {
-fx-background-color: #11151b;
-fx-background-radius: 10;
-fx-border-color: #26313d;
-fx-border-radius: 10;
-fx-border-width: 1;
-fx-min-width: 260;
-fx-pref-width: 260;
-fx-max-width: 260;
-fx-min-height: 220;
-fx-padding: 10;
-fx-spacing: 10;
}
.assets-scene-layer-overview-title {
-fx-text-fill: #d6e0ea;
-fx-font-size: 12px;
-fx-font-weight: bold;
}
.assets-scene-layer-overview-surface {
-fx-background-color: #0f141a;
-fx-background-radius: 8;
-fx-border-color: #26313d;
-fx-border-radius: 8;
-fx-border-width: 1;
-fx-min-height: 148;
}
.assets-details-chip-image {
-fx-background-color: #11283a;
-fx-border-color: #5cb6ff;
@ -1606,6 +1864,12 @@
-fx-text-fill: #dde6ff;
}
.assets-details-chip-scene {
-fx-background-color: #2a1f12;
-fx-border-color: #d4a94a;
-fx-text-fill: #ffe5a6;
}
.assets-details-chip-generic {
-fx-background-color: #1e242c;
-fx-border-color: #5f7388;

View File

@ -7,6 +7,7 @@ import p.packer.messages.assets.AssetFamilyCatalog;
import p.packer.messages.assets.OutputCodecCatalog;
import p.packer.messages.assets.OutputFormatCatalog;
import p.packer.messages.assets.PackerCodecConfigurationFieldType;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization;
import p.studio.workspaces.assets.messages.*;
import java.nio.file.Path;
@ -114,6 +115,9 @@ final class AssetDetailsBankCompositionCoordinatorTest {
List.of(),
0L),
List.of(),
null,
null,
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
}
@ -136,6 +140,9 @@ final class AssetDetailsBankCompositionCoordinatorTest {
List.of(),
0L),
List.of(),
null,
null,
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
}
@ -159,6 +166,7 @@ final class AssetDetailsBankCompositionCoordinatorTest {
AssetWorkspaceBuildParticipation.INCLUDED,
1,
family,
AssetStudioGlyphSpecialization.NONE,
Path.of("/tmp/bank"),
false,
false);

View File

@ -5,6 +5,7 @@ import p.packer.messages.AssetReference;
import p.packer.messages.assets.AssetFamilyCatalog;
import p.packer.messages.assets.OutputCodecCatalog;
import p.packer.messages.assets.OutputFormatCatalog;
import p.studio.workspaces.assets.metadata.*;
import p.studio.workspaces.assets.messages.*;
import java.nio.file.Path;
@ -15,64 +16,388 @@ import static org.junit.jupiter.api.Assertions.*;
final class AssetDetailsPaletteOverhaulingCoordinatorTest {
@Test
void defaultsToFirstTilePaletteWhenNoSelectedFilesExist() {
void derivesOriginalPaletteFromReadySchemaWhenNoPaletteRecordExists() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
coordinator.replaceDetails(tileDetails(List.of(), files("tile", 3)));
coordinator.replaceDetails(details(
List.of(tile("tile-a.png", "artifact-a")),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000", 2, "#00FF00"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(),
null),
List.of()));
assertTrue(coordinator.ready());
assertEquals(0, coordinator.viewModel().selectedFiles().size());
assertNull(coordinator.viewModel().previewTileFile());
assertNull(coordinator.viewModel().previewPaletteFile());
assertEquals(1, coordinator.viewModel().allFiles().size());
assertEquals("schema-1 / original", coordinator.viewModel().allFiles().getFirst().displayName());
}
@Test
void clickingSelectedPaletteChangesOnlyPreviewPalette() {
void initialSelectionStillMatchesBuildFacingPipelinePalettePayload() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
coordinator.replaceDetails(tileDetails(List.of(files("tile", 3).get(0), files("tile", 3).get(1)), List.of(files("tile", 3).get(2))));
coordinator.beginEdit();
final AssetWorkspaceBankCompositionFile first = coordinator.viewModel().availableFiles().get(0);
coordinator.moveToSelected(List.of(first));
coordinator.selectPreviewPalette(first);
assertEquals(first.path(), coordinator.viewModel().previewPaletteFile().path());
assertEquals(1, coordinator.viewModel().selectedFiles().size());
}
@Test
void removingPreviewedPaletteFallsBackToFirstRemainingSelectedPalette() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
final List<AssetWorkspaceBankCompositionFile> generated = files("tile", 3);
coordinator.replaceDetails(tileDetails(List.of(generated.get(0), generated.get(1)), List.of(generated.get(2))));
coordinator.beginEdit();
final AssetWorkspaceBankCompositionFile first = coordinator.viewModel().availableFiles().get(0);
coordinator.moveToSelected(List.of(first));
coordinator.selectPreviewPalette(first);
coordinator.moveToAvailable(List.of(first));
final AssetStudioGlyphMaterialPalette persisted = new AssetStudioGlyphMaterialPalette(
"palette-1",
"schema-1",
1,
List.of("#FF0000", "#00FF00"));
coordinator.replaceDetails(details(
List.of(tile("tile-a.png", "artifact-a")),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000", 2, "#00FF00"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(persisted),
null),
List.of(Map.of(
"originalArgb8888", List.of(0xFFFF0000, 0xFF00FF00),
"convertedRgb565", List.of(0xF800, 0x07E0)))));
assertEquals(1, coordinator.viewModel().selectedFiles().size());
assertEquals(first.path(), coordinator.viewModel().previewPaletteFile().path());
assertEquals("palette-1", coordinator.viewModel().selectedFiles().getFirst().metadata().get("paletteId"));
}
@Test
void deduplicatesPalettesByColorHash() {
void previewPaletteDefaultsToAvailablePaletteWhenBuildSelectionIsEmpty() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
final AssetWorkspaceBankCompositionFile first = file("tile-a.png", List.of(0xFFFF0000, 0xFF00FF00));
final AssetWorkspaceBankCompositionFile duplicate = file("tile-b.png", List.of(0xFFFF0000, 0xFF00FF00));
final AssetWorkspaceBankCompositionFile unique = file("tile-c.png", List.of(0xFF0000FF));
coordinator.replaceDetails(details(
List.of(tile("tile-a.png", "artifact-a")),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000", 2, "#00FF00"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(new AssetStudioGlyphMaterialPalette(
"palette-1",
"schema-1",
1,
List.of("#112233", "#445566"))),
null),
List.of()));
coordinator.replaceDetails(tileDetails(List.of(first), List.of(duplicate, unique)));
assertNotNull(coordinator.viewModel().previewPaletteFile());
assertTrue(coordinator.viewModel().availableFiles().contains(coordinator.viewModel().previewPaletteFile()));
}
@Test
void originalPaletteRemainsSyntheticEvenWhenUserPaletteHasSameColors() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
coordinator.replaceDetails(details(
List.of(tile("tile-a.png", "artifact-a")),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000", 2, "#00FF00"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(new AssetStudioGlyphMaterialPalette(
"palette-1",
"schema-1",
1,
List.of("#FF0000", "#00FF00"))),
null),
List.of()));
assertEquals(2, coordinator.viewModel().allFiles().size());
assertEquals(10, coordinator.viewModel().allFiles().getFirst().displayName().length());
assertEquals(coordinator.viewModel().allFiles().stream().map(AssetWorkspaceBankCompositionFile::displayName).distinct().count(),
coordinator.viewModel().allFiles().size());
final AssetWorkspaceBankCompositionFile original = coordinator.viewModel().allFiles().stream()
.filter(AssetDetailsPaletteOverhaulingCoordinator::paletteIsOriginal)
.findFirst()
.orElseThrow();
final AssetWorkspaceBankCompositionFile userPalette = coordinator.viewModel().allFiles().stream()
.filter(file -> "palette-1".equals(file.metadata().get("paletteId")))
.findFirst()
.orElseThrow();
assertEquals("schema-schema-1-original", original.metadata().get("paletteId"));
assertFalse(AssetDetailsPaletteOverhaulingCoordinator.paletteIsOriginal(userPalette));
}
private AssetWorkspaceAssetDetails tileDetails(
@Test
void originalPaletteTracksSchemaMaterialColorsAfterMetadataUpdate() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
coordinator.replaceDetails(details(
List.of(tile("tile-a.png", "artifact-a")),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000", 2, "#00FF00"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(),
null),
List.of()));
coordinator.replaceMaterialMetadata(new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
2,
Map.of(1, "#112233", 2, "#445566"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(),
null));
final AssetWorkspaceBankCompositionFile original = coordinator.viewModel().allFiles().stream()
.filter(AssetDetailsPaletteOverhaulingCoordinator::paletteIsOriginal)
.findFirst()
.orElseThrow();
@SuppressWarnings("unchecked")
final Map<String, Object> palette = (Map<String, Object>) original.metadata().get("palette");
assertEquals(List.of(0xFF112233, 0xFF445566), palette.get("originalArgb8888"));
}
@Test
void duplicateDetectionUsesSameSchemaColorsAndOrder() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
coordinator.replaceDetails(details(
List.of(tile("tile-a.png", "artifact-a")),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000", 2, "#00FF00"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(new AssetStudioGlyphMaterialPalette(
"palette-1",
"schema-1",
1,
List.of("#FF0000", "#00FF00"))),
null),
List.of()));
assertTrue(coordinator.duplicatePaletteExists(null, "schema-1", List.of("#FF0000", "#00FF00")));
assertFalse(coordinator.duplicatePaletteExists(null, "schema-1", List.of("#00FF00", "#FF0000")));
}
@Test
void paletteDisplayNameUsesExplicitNameWhenPresent() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
coordinator.replaceDetails(details(
List.of(tile("tile-a.png", "artifact-a")),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000", 2, "#00FF00"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(new AssetStudioGlyphMaterialPalette(
"palette-1",
"Forest",
"schema-1",
1,
List.of("#FF0000", "#00FF00"))),
null),
List.of()));
final AssetWorkspaceBankCompositionFile palette = coordinator.viewModel().allFiles().getFirst();
assertEquals("Forest", palette.displayName());
assertEquals("Forest", palette.metadata().get("explicitPaletteDisplayName"));
}
@Test
void paletteDisplayNameFallsBackToColorsWhenNameIsBlank() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
coordinator.replaceDetails(details(
List.of(tile("tile-a.png", "artifact-a")),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000", 2, "#00FF00"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(new AssetStudioGlyphMaterialPalette(
"palette-1",
"schema-1",
1,
List.of("#FF0000", "#00FF00"))),
null),
List.of()));
final AssetWorkspaceBankCompositionFile palette = coordinator.viewModel().allFiles().getFirst();
assertEquals("ember bloom", palette.displayName());
assertFalse(palette.metadata().containsKey("explicitPaletteDisplayName"));
}
@Test
void generatedPaletteDisplayNameUsesPoeticColorWords() {
assertEquals("snow white", AssetDetailsPaletteOverhaulingCoordinator.generatedDisplayName(List.of("#FFFFFF")));
assertEquals("fog twist", AssetDetailsPaletteOverhaulingCoordinator.generatedDisplayName(List.of("#F2F4F4", "#707878")));
}
@Test
void upsertPalettePersistsGeneratedDisplayNameWhenNameIsBlank() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
coordinator.replaceDetails(details(
List.of(tile("tile-a.png", "artifact-a")),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#F2F4F4", 2, "#707878"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(),
null),
List.of()));
final AssetStudioGlyphMaterialMetadata metadata = coordinator.upsertPalette(
"palette-1",
" ",
"schema-1",
List.of("#F2F4F4", "#707878"));
assertEquals("fog twist", metadata.palettes().getFirst().displayName());
}
@Test
void removePaletteDeletesUserPaletteEvenWhenSelectedForBuild() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
coordinator.replaceDetails(details(
List.of(tile("tile-a.png", "artifact-a")),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000", 2, "#00FF00"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(new AssetStudioGlyphMaterialPalette(
"palette-1",
"schema-1",
1,
List.of("#112233", "#445566"))),
null),
List.of(Map.of(
"originalArgb8888", List.of(0xFF112233, 0xFF445566),
"convertedRgb565", List.of(0x1106, 0x42AC)))));
final AssetStudioGlyphMaterialMetadata metadata = coordinator.removePalette("palette-1");
assertTrue(metadata.palettes().isEmpty());
}
@Test
void removePaletteRejectsSyntheticOriginalPalette() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
coordinator.replaceDetails(details(
List.of(tile("tile-a.png", "artifact-a")),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000", 2, "#00FF00"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(),
null),
List.of()));
assertThrows(IllegalStateException.class, () -> coordinator.removePalette("schema-schema-1-original"));
}
@Test
void stalePaletteIsProjectedAsInvalid() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
coordinator.replaceDetails(details(
List.of(tile("tile-a.png", "artifact-a")),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
2,
Map.of(1, "#FF0000"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(new AssetStudioGlyphMaterialPalette(
"palette-1",
"schema-1",
1,
List.of("#FF0000"))),
null),
List.of()));
final AssetWorkspaceBankCompositionFile palette = coordinator.viewModel().allFiles().getFirst();
assertEquals(Boolean.FALSE, palette.metadata().get("valid"));
assertTrue(((String) palette.metadata().get("invalidReason")).contains("stale"));
}
@Test
void paletteIsInvalidWhenSchemaArtifactFingerprintDrifts() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
coordinator.replaceDetails(details(
List.of(tile("tile-a.png", "artifact-a")),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-old")))),
List.of(new AssetStudioGlyphMaterialPalette(
"palette-1",
"schema-1",
1,
List.of("#FF0000"))),
null),
List.of()));
final AssetWorkspaceBankCompositionFile palette = coordinator.viewModel().allFiles().getFirst();
assertEquals(Boolean.FALSE, palette.metadata().get("valid"));
assertTrue(((String) palette.metadata().get("invalidReason")).contains("inconsistent"));
}
@Test
void previewTilesAreScopedToSelectedPaletteSchema() {
final AssetDetailsPaletteOverhaulingCoordinator coordinator = new AssetDetailsPaletteOverhaulingCoordinator();
coordinator.replaceDetails(details(
List.of(tile("tile-a.png", "artifact-a"), tile("tile-b.png", "artifact-b")),
new AssetStudioGlyphMaterialMetadata(
List.of(
new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000", 2, "#00FF00"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a"))),
new AssetStudioGlyphMaterialSchema(
"schema-2",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#0000FF"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-b", "fp-b")))),
List.of(),
null),
List.of()));
final AssetWorkspaceBankCompositionFile schema2Palette = coordinator.viewModel().allFiles().stream()
.filter(file -> "schema-2".equals(file.metadata().get("schemaId")))
.findFirst()
.orElseThrow();
coordinator.selectPreviewPalette(schema2Palette);
assertEquals(List.of("tile-b.png"), coordinator.viewModel().previewTileFiles().stream().map(AssetWorkspaceBankCompositionFile::path).toList());
assertEquals("schema-2", coordinator.viewModel().previewPaletteFile().metadata().get("schemaId"));
assertEquals(Map.of(1, "#0000FF"), coordinator.viewModel().previewPaletteFile().metadata().get("schemaMaterialIndices"));
}
private AssetWorkspaceAssetDetails details(
List<AssetWorkspaceBankCompositionFile> selectedFiles,
List<AssetWorkspaceBankCompositionFile> availableFiles) {
AssetStudioGlyphMaterialMetadata metadata,
List<Map<String, Object>> pipelinePalettes) {
return new AssetWorkspaceAssetDetails(
new AssetWorkspaceAssetSummary(
AssetReference.forAssetId(1),
@ -81,6 +406,7 @@ final class AssetDetailsPaletteOverhaulingCoordinatorTest {
AssetWorkspaceBuildParticipation.INCLUDED,
1,
AssetFamilyCatalog.GLYPH_BANK,
AssetStudioGlyphSpecialization.SPRITES,
Path.of("/tmp/bank"),
false,
false),
@ -91,46 +417,29 @@ final class AssetDetailsPaletteOverhaulingCoordinatorTest {
Map.of(OutputCodecCatalog.NONE, List.of()),
List.of(),
Map.of(),
new AssetWorkspaceBankCompositionDetails(availableFiles, selectedFiles, 0L),
selectedFiles.stream().map(file -> (Map<String, Object>) file.metadata().get("palette")).toList(),
new AssetWorkspaceBankCompositionDetails(List.of(), selectedFiles, 0L),
pipelinePalettes,
metadata,
null,
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
}
private List<AssetWorkspaceBankCompositionFile> files(String prefix, int count) {
return java.util.stream.IntStream.range(0, count)
.mapToObj(index -> file(
prefix + "-" + index + ".png",
List.of(
0xFF000000 | ((index + 1) << 16),
0xFF000000 | ((index + 1) << 8),
0xFF000000 | (index + 1))))
.toList();
}
private AssetWorkspaceBankCompositionFile file(String path, List<Integer> colors) {
private AssetWorkspaceBankCompositionFile tile(String path, String artifactId) {
return new AssetWorkspaceBankCompositionFile(
path,
path,
256L,
1L,
null,
"artifact-a".equals(artifactId) ? "fp-a" : "fp-b",
Map.of(
"artifactId", artifactId,
"tile", Map.of(
"width", 2,
"height", 2,
"paletteIndices", List.of(0, 1, 2, 0)),
"palette", Map.of(
"originalArgb8888", colors,
"convertedRgb565", colors.stream().map(this::toRgb565).toList())));
}
private int toRgb565(int argb8888) {
final int red = (argb8888 >> 16) & 0xFF;
final int green = (argb8888 >> 8) & 0xFF;
final int blue = argb8888 & 0xFF;
final int r5 = (red >> 3) & 0x1F;
final int g6 = (green >> 2) & 0x3F;
final int b5 = (blue >> 3) & 0x1F;
return (r5 << 11) | (g6 << 5) | b5;
"originalArgb8888", List.of(0xFFFF0000, 0xFF00FF00),
"convertedRgb565", List.of(0xF800, 0x07E0))));
}
}

View File

@ -0,0 +1,219 @@
package p.studio.workspaces.assets.details.schema;
import org.junit.jupiter.api.Test;
import p.packer.messages.AssetReference;
import p.packer.messages.assets.AssetFamilyCatalog;
import p.packer.messages.assets.OutputCodecCatalog;
import p.packer.messages.assets.OutputFormatCatalog;
import p.studio.workspaces.assets.metadata.*;
import p.studio.workspaces.assets.messages.*;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
final class AssetDetailsColorSchemaCoordinatorTest {
@Test
void createFlowExcludesTilesAlreadyBoundToAnotherSchema() {
final AssetDetailsColorSchemaCoordinator coordinator = new AssetDetailsColorSchemaCoordinator();
final AssetWorkspaceBankCompositionFile tileA = tile("a.png", "artifact-a", "fp-a", List.of("#FF0000"));
final AssetWorkspaceBankCompositionFile tileB = tile("b.png", "artifact-b", "fp-b", List.of("#00FF00"));
coordinator.replaceDetails(details(
List.of(tileA, tileB),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(),
null)));
assertEquals(List.of(tileB), coordinator.availableTilesForCreate());
}
@Test
void createFlowListsSelectedTilesBeforePaletteMetadataExists() {
final AssetDetailsColorSchemaCoordinator coordinator = new AssetDetailsColorSchemaCoordinator();
final AssetWorkspaceBankCompositionFile tile = selectedArtifactOnly("a.png", "artifact-a", "fp-a");
coordinator.replaceDetails(details(
List.of(tile),
new AssetStudioGlyphMaterialMetadata(List.of(), List.of(), null)));
assertEquals(List.of(tile), coordinator.availableTilesForCreate());
assertEquals(List.of(), AssetDetailsColorSchemaCoordinator.tileColors(tile));
}
@Test
void createFlowUsesSelectedFingerprintWhenCurrentFileFingerprintIsMissing() {
final AssetDetailsColorSchemaCoordinator coordinator = new AssetDetailsColorSchemaCoordinator();
final AssetWorkspaceBankCompositionFile tile = new AssetWorkspaceBankCompositionFile(
"a.png",
"a.png",
64L,
1L,
null,
Map.of(
"artifactId", "artifact-a",
"selectedFingerprint", "fp-a"));
coordinator.replaceDetails(details(
List.of(tile),
new AssetStudioGlyphMaterialMetadata(List.of(), List.of(), null)));
assertEquals(List.of(tile), coordinator.availableTilesForCreate());
final AssetStudioGlyphMaterialMetadata metadata = coordinator.upsertSchema(
"schema-1",
List.of(tile),
List.of("#FF0000"));
assertEquals("fp-a", metadata.schemas().getFirst().artifactBindings().getFirst().fingerprint());
}
@Test
void updateFlowKeepsCurrentTilesAndIncrementsVersionWhenSchemaChanges() {
final AssetDetailsColorSchemaCoordinator coordinator = new AssetDetailsColorSchemaCoordinator();
final AssetWorkspaceBankCompositionFile tileA = tile("a.png", "artifact-a", "fp-a", List.of("#FF0000"));
final AssetWorkspaceBankCompositionFile tileB = tile("b.png", "artifact-b", "fp-b", List.of("#00FF00"));
coordinator.replaceDetails(details(
List.of(tileA, tileB),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(),
null)));
final AssetStudioGlyphMaterialMetadata updated = coordinator.upsertSchema(
"schema-1",
List.of(tileA, tileB),
List.of("#FF0000", "#00FF00"));
final AssetStudioGlyphMaterialSchema schema = updated.schemas().getFirst();
assertEquals(2, schema.version());
assertEquals(Map.of(1, "#FF0000", 2, "#00FF00"), schema.materialIndices());
assertEquals(List.of("artifact-a", "artifact-b"), schema.artifactBindings().stream().map(AssetStudioGlyphMaterialArtifactBinding::artifactId).toList());
}
@Test
void removeIsBlockedWhenPalettesStillReferenceSchema() {
final AssetDetailsColorSchemaCoordinator coordinator = new AssetDetailsColorSchemaCoordinator();
coordinator.replaceDetails(details(
List.of(tile("a.png", "artifact-a", "fp-a", List.of("#FF0000"))),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-a")))),
List.of(new AssetStudioGlyphMaterialPalette("palette-1", "schema-1", 1, List.of("#FF0000"))),
null)));
assertFalse(coordinator.canRemove("schema-1"));
assertThrows(IllegalStateException.class, () -> coordinator.removeSchema("schema-1"));
}
@Test
void effectiveStatusBecomesInconsistentWhenArtifactFingerprintDrifts() {
final AssetStudioGlyphMaterialSchema schema = new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-old")));
assertEquals(
AssetStudioGlyphMaterialSchemaStatus.INCONSISTENT,
AssetDetailsColorSchemaCoordinator.effectiveStatus(
schema,
List.of(tile("a.png", "artifact-a", "fp-new", List.of("#FF0000", "#FF0001")))));
}
@Test
void schemaUpdateCanRefreshOnlyFingerprintAndIncrementVersion() {
final AssetDetailsColorSchemaCoordinator coordinator = new AssetDetailsColorSchemaCoordinator();
final AssetWorkspaceBankCompositionFile changedTile = tile("a.png", "artifact-a", "fp-new", List.of("#FF0000"));
coordinator.replaceDetails(details(
List.of(changedTile),
new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
1,
Map.of(1, "#FF0000"),
List.of(new AssetStudioGlyphMaterialArtifactBinding("artifact-a", "fp-old")))),
List.of(),
null)));
final AssetStudioGlyphMaterialSchema updated = coordinator.upsertSchema(
"schema-1",
List.of(changedTile),
List.of("#FF0000")).schemas().getFirst();
assertEquals(2, updated.version());
assertEquals(AssetStudioGlyphMaterialSchemaStatus.OK, updated.status());
assertEquals("fp-new", updated.artifactBindings().getFirst().fingerprint());
}
private AssetWorkspaceAssetDetails details(
List<AssetWorkspaceBankCompositionFile> selectedTiles,
AssetStudioGlyphMaterialMetadata metadata) {
return new AssetWorkspaceAssetDetails(
new AssetWorkspaceAssetSummary(
AssetReference.forAssetId(1),
"glyph_bank",
AssetWorkspaceAssetState.REGISTERED,
AssetWorkspaceBuildParticipation.INCLUDED,
1,
AssetFamilyCatalog.GLYPH_BANK,
AssetStudioGlyphSpecialization.SPRITES,
Path.of("/tmp/glyph"),
false,
false),
List.of(),
OutputFormatCatalog.GLYPH_INDEXED_V1,
OutputCodecCatalog.NONE,
List.of(OutputCodecCatalog.NONE),
Map.of(OutputCodecCatalog.NONE, List.of()),
List.of(),
Map.of(),
new AssetWorkspaceBankCompositionDetails(List.of(), selectedTiles, 0L),
List.of(),
metadata,
null,
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
}
private AssetWorkspaceBankCompositionFile tile(String path, String artifactId, String fingerprint, List<String> colors) {
final List<Integer> argb = colors.stream()
.map(color -> Integer.parseUnsignedInt("FF" + color.substring(1), 16))
.map(Integer::valueOf)
.toList();
return new AssetWorkspaceBankCompositionFile(
path,
path,
64L,
1L,
fingerprint,
Map.of(
"artifactId", artifactId,
"tile", Map.of("width", 8, "height", 8, "paletteIndices", List.of(1, 1, 1)),
"palette", Map.of("originalArgb8888", argb, "convertedRgb565", List.of())));
}
private AssetWorkspaceBankCompositionFile selectedArtifactOnly(String path, String artifactId, String fingerprint) {
return new AssetWorkspaceBankCompositionFile(
path,
path,
64L,
1L,
fingerprint,
Map.of("artifactId", artifactId));
}
}

View File

@ -0,0 +1,148 @@
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 java.util.List;
import java.util.Map;
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());
assertNull(snapshot.glyphMaterialMetadata());
}
@Test
void keepsGlyphBankWithoutStudioMetadataAsNone() throws Exception {
final Path assetRoot = tempDir.resolve("legacy-glyph");
Files.createDirectories(assetRoot);
final AssetStudioMetadataSnapshot snapshot = service.read(assetRoot, AssetFamilyCatalog.GLYPH_BANK);
assertEquals(AssetStudioGlyphSpecialization.NONE, snapshot.glyphSpecialization());
assertNull(snapshot.sceneBankMetadata());
assertNull(snapshot.glyphMaterialMetadata());
}
@Test
void readsAndWritesGlyphMaterialSupportMetadata() throws Exception {
final Path assetRoot = tempDir.resolve("glyph");
Files.createDirectories(assetRoot);
final AssetStudioGlyphMaterialMetadata metadata = new AssetStudioGlyphMaterialMetadata(
List.of(new AssetStudioGlyphMaterialSchema(
"schema-1",
AssetStudioGlyphMaterialSchemaStatus.OK,
2,
Map.of(1, "#8B4513", 2, "#00AA00"),
List.of(
new AssetStudioGlyphMaterialArtifactBinding("artifact-1", "fp-1"),
new AssetStudioGlyphMaterialArtifactBinding("artifact-2", "fp-2")))),
List.of(new AssetStudioGlyphMaterialPalette(
"palette-1",
"Forest",
"schema-1",
2,
List.of("#8B4513", "#00AA00"))),
null);
service.writeGlyphMaterialMetadata(assetRoot, metadata);
final AssetStudioMetadataSnapshot snapshot = service.read(assetRoot, AssetFamilyCatalog.GLYPH_BANK);
assertNotNull(snapshot.glyphMaterialMetadata());
assertEquals(1, snapshot.glyphMaterialMetadata().schemas().size());
assertEquals("schema-1", snapshot.glyphMaterialMetadata().schemas().getFirst().schemaId());
assertEquals("#8B4513", snapshot.glyphMaterialMetadata().schemas().getFirst().materialIndices().get(1));
assertEquals(1, snapshot.glyphMaterialMetadata().palettes().size());
assertEquals("Forest", snapshot.glyphMaterialMetadata().palettes().getFirst().displayName());
assertEquals(List.of("#8B4513", "#00AA00"), snapshot.glyphMaterialMetadata().palettes().getFirst().colors());
}
@Test
void keepsHtmlRgbAsCanonicalGlyphMaterialColorAndDerivesRgb565() {
final AssetStudioGlyphMaterialPalette palette = new AssetStudioGlyphMaterialPalette(
"palette-1",
"schema-1",
1,
List.of("#FF0000", "#00FF00", "#0000FF"));
assertEquals(List.of("#FF0000", "#00FF00", "#0000FF"), palette.colors());
assertEquals(List.of(0xF800, 0x07E0, 0x001F), palette.convertedRgb565Colors());
}
@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": 4,
"layers": [
{ "index": 1, "enabled": true, "name": "Ground", "width": 20, "height": 12, "tilemap": "ground.tmx", "tileset_asset_root": "tilesets/ground" },
{ "index": 2, "enabled": true, "name": "Collision", "width": 10, "height": 8, "tilemap": "collision.tmx", "tileset_asset_root": "tilesets/ground" },
{ "index": 3, "enabled": false, "name": "Layer 3", "width": 20, "height": 12, "tilemap": "layer-3.tmx", "tileset_asset_root": "" },
{ "index": 4, "enabled": false, "name": "Layer 4", "width": 20, "height": 12, "tilemap": "layer-4.tmx", "tileset_asset_root": "" }
]
}
""");
final AssetStudioMetadataSnapshot snapshot = service.read(assetRoot, AssetFamilyCatalog.SCENE_BANK);
assertNotNull(snapshot.sceneBankMetadata());
assertEquals(20, snapshot.sceneBankMetadata().mapWidth());
assertEquals(12, snapshot.sceneBankMetadata().mapHeight());
assertEquals(4, snapshot.sceneBankMetadata().layerCount());
assertEquals(10, snapshot.sceneBankMetadata().layerBindings().get(1).width());
assertEquals(8, snapshot.sceneBankMetadata().layerBindings().get(1).height());
assertEquals(
java.util.List.of("ground.tmx", "collision.tmx"),
snapshot.sceneBankMetadata().layerBindings().stream()
.filter(AssetStudioSceneLayerBinding::enabled)
.map(AssetStudioSceneLayerBinding::tilemap)
.toList());
}
@Test
void rejectsSceneBankSupportMetadataWhenLayerCountIsNotFixedAtFour() 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": 3,
"layers": [
{ "index": 1, "enabled": true, "name": "A", "width": 16, "height": 16, "tilemap": "a.tmx", "tileset_asset_root": "" },
{ "index": 2, "enabled": false, "name": "B", "width": 16, "height": 16, "tilemap": "b.tmx", "tileset_asset_root": "" },
{ "index": 3, "enabled": false, "name": "C", "width": 16, "height": 16, "tilemap": "c.tmx", "tileset_asset_root": "" }
]
}
""");
final AssetStudioMetadataSnapshot snapshot = service.read(assetRoot, AssetFamilyCatalog.SCENE_BANK);
assertNull(snapshot.sceneBankMetadata());
}
}

View File

@ -0,0 +1,204 @@
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(),
null,
new AssetStudioSceneBankMetadata(
8,
8,
4,
List.of(
new AssetStudioSceneLayerBinding(true, 1, "Ground", 8, 8, "ground.tmx", "tilesets/missing"),
new AssetStudioSceneLayerBinding(false, 2, "Layer 2", 8, 8, "layer-2.tmx", ""),
new AssetStudioSceneLayerBinding(false, 3, "Layer 3", 8, 8, "layer-3.tmx", ""),
new AssetStudioSceneLayerBinding(false, 4, "Layer 4", 8, 8, "layer-4.tmx", "")),
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,
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(),
null,
new AssetStudioSceneBankMetadata(
16,
12,
4,
List.of(
new AssetStudioSceneLayerBinding(true, 1, "Ground", 16, 12, "ground.tmx", "tilesets/overworld"),
new AssetStudioSceneLayerBinding(false, 2, "Layer 2", 16, 12, "layer-2.tmx", ""),
new AssetStudioSceneLayerBinding(false, 3, "Layer 3", 16, 12, "layer-3.tmx", ""),
new AssetStudioSceneLayerBinding(false, 4, "Layer 4", 16, 12, "layer-4.tmx", "")),
assetRoot.resolve("scene-bank.studio.json")),
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
}
}

View File

@ -0,0 +1,149 @@
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,
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(),
null,
new AssetStudioSceneBankMetadata(
16,
12,
4,
List.of(
new AssetStudioSceneLayerBinding(true, 1, "Ground", 16, 12, "ground.tmx", "tilesets/overworld"),
new AssetStudioSceneLayerBinding(false, 2, "Layer 2", 16, 12, "layer-2.tmx", ""),
new AssetStudioSceneLayerBinding(false, 3, "Layer 3", 16, 12, "layer-3.tmx", ""),
new AssetStudioSceneLayerBinding(false, 4, "Layer 4", 16, 12, "layer-4.tmx", "")),
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));
}
}

View File

@ -1592,5 +1592,319 @@
"blocking" : true
} ]
} ]
}, {
"asset_id" : 16,
"contract_fingerprint" : "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"files" : [ ]
}, {
"asset_id" : 17,
"contract_fingerprint" : "058b188cc68f9b9780825f8072d7ab316b042d66c80a76a344e06313cb49ad01",
"files" : [ {
"relative_path" : "tileset-001.png",
"mime_type" : "image/png",
"size" : 92,
"last_modified" : 1776880392312,
"fingerprint" : "8771c3e294d0edbc8dd3aaa6995a6db32166ae6bfb3e063170cc79b298d17e71",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQ=="
},
"palette" : {
"originalArgb8888" : [ -12019640 ],
"convertedRgb565" : [ 19657 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.001.png",
"mime_type" : "image/png",
"size" : 184,
"last_modified" : 1776880490117,
"fingerprint" : "5d81eef8be13043330b42b546e10beed8ba00dfaa42df67477a8778c2b9484d7",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQEBAQEBAQEBAQEBAQICAgECAgICAgICAgMEBQUFBQQDBAUGBgYGBQMEBQYGBwcIAwQFCAYGCAgBAwUFCAgIBw=="
},
"palette" : {
"originalArgb8888" : [ -12019640, -12552128, -14129088, -14133200, -14145496, -7835584, -5730208, -10465232 ],
"convertedRgb565" : [ 19657, 17352, 11080, 10950, 10565, -29816, -21364, 25222 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.002.png",
"mime_type" : "image/png",
"size" : 172,
"last_modified" : 1776962843559,
"fingerprint" : "3250840c4fe08c134ea966ce7c8fcaccae2c2a45660d7173c5e262a2e5076548",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQEBAQECAwMBAQEBAgMEBQEBAQIDBAUFAQIDAwQEBAQDAwYFBQUFBQQGBQUGBgYGBQUFBgYFBQUFBgYGBQUGBg=="
},
"palette" : {
"originalArgb8888" : [ -12552128, -14133200, -14145496, -10465232, -5730208, -7835584 ],
"convertedRgb565" : [ 17352, 10950, 10565, 25222, -21364, -29816 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.003.png",
"mime_type" : "image/png",
"size" : 191,
"last_modified" : 1776880575940,
"fingerprint" : "fe3ab2b7eb3b10a042de14484d53e718e6b505edb1ad19053668565bfe3a8f25",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQECAwMDAwMEBQECAwMDAwQEBQECAwMDBQUFBQEBAgMEBAQEBAYBAQYGBgYEBAYFBAQEBgYEBAQGBgQEBgYGBA=="
},
"palette" : {
"originalArgb8888" : [ -14145496, -14133200, -12552128, -5730208, -10465232, -7835584 ],
"convertedRgb565" : [ 10565, 10950, 17352, -21364, 25222, -29816 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.004.png",
"mime_type" : "image/png",
"size" : 193,
"last_modified" : 1776880597961,
"fingerprint" : "7fcbf90e1e9a69d6ce3be80ea1901d66c9992f68dc72e4c636dcd2ad52fa2898",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQEBAQEBAQECAgIBAQEBAQICAgICAgIBAwQEBAQDBQIEBgYGBgQDBQcICAYGBAMFBwcGBgcEAwUIBwcHBAQFAQ=="
},
"palette" : {
"originalArgb8888" : [ -12019640, -12552128, -14133200, -14145496, -14129088, -7835584, -10465232, -5730208 ],
"convertedRgb565" : [ 19657, 17352, 10950, 10565, 11080, -29816, 25222, -21364 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.005.png",
"mime_type" : "image/png",
"size" : 171,
"last_modified" : 1776880624257,
"fingerprint" : "451fc8d646049944d5aa7c7dc254636eec0151aaff6e2051059304b9d6ebc31e",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQIDBAQFBgYBAQIDBAYGBwEBAQMEBgcHAQEBBAQGBwcBAQQFBAYHBwEEBwUEBgcHBAcGBQQGBwcEBwYFBAYHBw=="
},
"palette" : {
"originalArgb8888" : [ -12019640, -14129088, -14133200, -14145496, -10465232, -5730208, -7835584 ],
"convertedRgb565" : [ 19657, 11080, 10950, 10565, 25222, -21364, -29816 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.006.png",
"mime_type" : "image/png",
"size" : 174,
"last_modified" : 1776880647207,
"fingerprint" : "aa757df4600823ec6655235a1081061777124ccee74b31481bbe402e53635f53",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQECAwMBAQEBAgIBAwMBAgICAQEBAwMCAgIBAQIDBAMCAgEBAgMEBAICAQECAwQEAgIBAQIDBAMCAgIBAwQDAg=="
},
"palette" : {
"originalArgb8888" : [ -7835584, -5730208, -10465232, -14145496 ],
"convertedRgb565" : [ -29816, -21364, 25222, 10565 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.007.png",
"mime_type" : "image/png",
"size" : 171,
"last_modified" : 1776880669446,
"fingerprint" : "38162157f5685cce25b835d3a2caee3465fd2ec3ee5db2a5089d7d5ae5ea3bb7",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQEBAgIDAQEDAQICAQMDAQMCAgEBAQMDAgQCAwEBAwMEBAIDAQEDAwQEAgMBAQMDAgQCAwEBAwMDAgQCAQMDAw=="
},
"palette" : {
"originalArgb8888" : [ -7835584, -10465232, -5730208, -14145496 ],
"convertedRgb565" : [ -29816, 25222, -21364, 10565 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.008.png",
"mime_type" : "image/png",
"size" : 170,
"last_modified" : 1776880691747,
"fingerprint" : "8cab5b3d553af9ed371bd19fc624763accc90e5d926d300ba31dd8d9d6bab803",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQECAwMEBQYHAQEDBAUGBgcHAQMEBgYGBwcBAwMGBgYHBwEDAgMGBgcHAQMCBwMGBwcBAwIBBwMHBwEDAgEHAw=="
},
"palette" : {
"originalArgb8888" : [ -5730208, -10465232, -14145496, -14133200, -14129088, -12019640, -7835584 ],
"convertedRgb565" : [ -21364, 25222, 10565, 10950, 11080, 19657, -29816 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.009.png",
"mime_type" : "image/png",
"size" : 201,
"last_modified" : 1776881019843,
"fingerprint" : "d9ff5a6df0f65f5fcfb8f22376beeb266d429e2c6e9c82f51c12bc6ee4c3fcdc",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQIDAgIBBAMFAQECAgEEBAUGBQECAgEEBwYFBQECAQEHBwYGBQEBAgcHBwYFAQMDBwcHBwEBAwMHBwcFAQICAw=="
},
"palette" : {
"originalArgb8888" : [ -14145496, -10465232, -7835584, -5730208, -14133200, -14129088, -12019640 ],
"convertedRgb565" : [ 10565, 25222, -29816, -21364, 10950, 11080, 19657 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.010.png",
"mime_type" : "image/png",
"size" : 195,
"last_modified" : 1776881039443,
"fingerprint" : "b9ff45c77f7779a6acebcc537c2ddac0e27e91162b656a04dc4772ec5f24fc26",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQICAwMBAQIBAQMDAgIBAQIBAQEBAgIBAgICAgEBAgIEAwMCAgEBAQMEAwMCAgEBAQMEAwMDAgIBAwMEAwMDAw=="
},
"palette" : {
"originalArgb8888" : [ -7835584, -5730208, -10465232, -14145496 ],
"convertedRgb565" : [ -29816, -21364, 25222, 10565 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.011.png",
"mime_type" : "image/png",
"size" : 188,
"last_modified" : 1776881059396,
"fingerprint" : "43f370324fedccda888d49409e2a64f182f08b4143b4bdd5bafaf498feebd21a",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQICAwMBAQICAgEBAwMCAgIBAQICAgIBAQECAgEBAQECAgIBAQMDBAICAQEDAwQDAQEDAwMEAwIDAwMDBAMDAg=="
},
"palette" : {
"originalArgb8888" : [ -5730208, -7835584, -10465232, -14145496 ],
"convertedRgb565" : [ -21364, -29816, 25222, 10565 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.012.png",
"mime_type" : "image/png",
"size" : 203,
"last_modified" : 1776881081851,
"fingerprint" : "cb047e655f4e196f22ca0048149df5dcb959f895faeaeb94920182d4a2d16b38",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQIDBAQBBAMCAgMEBAMDBQIDBAQDBQYFAwMEAwUFBgcEAwMFBgYHBwEBAwUGBwcHAQEDAwcHBwcBBAQDBQcHBw=="
},
"palette" : {
"originalArgb8888" : [ -7835584, -5730208, -14145496, -10465232, -14133200, -14129088, -12019640 ],
"convertedRgb565" : [ -29816, -21364, 10565, 25222, 10950, 11080, 19657 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.013.png",
"mime_type" : "image/png",
"size" : 186,
"last_modified" : 1776881102592,
"fingerprint" : "25eba17b67dd5159f5fb9d3edd531d54a44d1c76f997385840dac53517b98886",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQEBAgMDBAQBAQIDAwMDBAEBAgMDAwMEAQECAwMEAgQBAQIEAgUFAgEBBQUGBQYFAQEBAQEGAQYBAQEBAQEBAQ=="
},
"palette" : {
"originalArgb8888" : [ -12019640, -14145496, -7835584, -10465232, -14133200, -14129088 ],
"convertedRgb565" : [ 19657, 10565, -29816, 25222, 10950, 11080 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.014.png",
"mime_type" : "image/png",
"size" : 184,
"last_modified" : 1776881122741,
"fingerprint" : "32ca96c14290de8009fd2db623f55514bed2345b7d5881a80dd3fe6235012854",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQEBAgEBAgICAQICAQIBAQICAwICAQEEAgMDAwIBAQQDAwUDAwIBAQUFBgUFAgEEBgYGBgYDAgEGBgYGBgYDAg=="
},
"palette" : {
"originalArgb8888" : [ -10465232, -14145496, -14133200, -7835584, -14129088, -12019640 ],
"convertedRgb565" : [ 25222, 10565, 10950, -29816, 11080, 19657 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.015.png",
"mime_type" : "image/png",
"size" : 175,
"last_modified" : 1776881143481,
"fingerprint" : "7a093fe00c805bcf38d4facff14a6d4c2b8baeaa2d553fa8d3db9f2d76b32f3b",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQECAgECAgICAgECAQECAQMCAgEBBAEBAwICAQQEBAECAgEEBAUEBAMCAQUFBgUFAgEEBgYGBgYBBAYGBgYGBg=="
},
"palette" : {
"originalArgb8888" : [ -14145496, -10465232, -7835584, -14133200, -14129088, -12019640 ],
"convertedRgb565" : [ 10565, 25222, -29816, 10950, 11080, 19657 ]
}
},
"diagnostics" : [ ]
}, {
"relative_path" : "tileset-002.016.png",
"mime_type" : "image/png",
"size" : 192,
"last_modified" : 1776881170765,
"fingerprint" : "77c5277220421226d16f58b1bcfb4eaf157fa4f6c740c5aaebefaf88fb91c22e",
"metadata" : {
"tile" : {
"width" : 8,
"height" : 8,
"paletteIndices" : "AQECAgMEBAQBAgICAgMEBAECAgICAwQEAQMBAgIDBAQDBQUDAQMEBAUGBQYFBQQEBgQGBAQEBAQEBAQEBAQEBA=="
},
"palette" : {
"originalArgb8888" : [ -10465232, -7835584, -14145496, -12019640, -14133200, -14129088 ],
"convertedRgb565" : [ 25222, -29816, 10565, 19657, 10950, 11080 ]
}
},
"diagnostics" : [ ]
} ]
} ]
}

View File

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

View File

@ -25,27 +25,43 @@
},
"artifacts" : [ {
"file" : "link00.png",
"index" : 0
"index" : 0,
"artifact_id" : "art-b82d5371eb9c",
"fingerprint" : "c363f3ef7f32d9b249c7dc8babdb3eb5aae68c524099e5e97290202b054dda71"
}, {
"file" : "link01.png",
"index" : 1
"index" : 1,
"artifact_id" : "art-acf4398779de",
"fingerprint" : "e2130efbb9643b41a4fb62619f2713402f9622c7db040fe18d929609ef70b89a"
}, {
"file" : "link02.png",
"index" : 2
"index" : 2,
"artifact_id" : "art-88d5de66ceea",
"fingerprint" : "9216dfc6a6226fac398e9f781550d23fbfaa65bd377cdda69187149beca114ec"
}, {
"file" : "link03.png",
"index" : 3
"index" : 3,
"artifact_id" : "art-18a3b61a9561",
"fingerprint" : "ebc4b7ca7bb1455288681c5d71424f60f658a44cb343d0c3934329cec676c867"
}, {
"file" : "link04.png",
"index" : 4
"index" : 4,
"artifact_id" : "art-231ecb85b204",
"fingerprint" : "c363f3ef7f32d9b249c7dc8babdb3eb5aae68c524099e5e97290202b054dda71"
}, {
"file" : "link05.png",
"index" : 5
"index" : 5,
"artifact_id" : "art-f36b8455db22",
"fingerprint" : "1ba8ce75c445396334737143e0cfa56c87a1fb64cca8ad0962192594849249eb"
}, {
"file" : "link06.png",
"index" : 6
"index" : 6,
"artifact_id" : "art-884d6b34d741",
"fingerprint" : "5e0954447699c6d5eac7550f1a10cf520efcddad0b9fbd9a716434da9d9550d4"
}, {
"file" : "link07.png",
"index" : 7
"index" : 7,
"artifact_id" : "art-ca5e39fb056c",
"fingerprint" : "6104ba7f216a937994d478ed07766ec28f48ad72fdd6620508535a1f877b8ddb"
} ]
}

View File

@ -0,0 +1,47 @@
{
"schema_version" : 1,
"schemas" : [ {
"schema_id" : "schema-1",
"status" : "ok",
"version" : 1,
"material_indices" : {
"1" : "#202020",
"2" : "#519210",
"3" : "#71B220",
"4" : "#F3C320",
"5" : "#C38200",
"6" : "#B26120",
"7" : "#F3F3F3",
"8" : "#F3A261",
"9" : "#E39251",
"10" : "#614120",
"11" : "#B28251"
},
"artifact_bindings" : [ {
"artifact_id" : "art-b82d5371eb9c",
"fingerprint" : "c363f3ef7f32d9b249c7dc8babdb3eb5aae68c524099e5e97290202b054dda71"
}, {
"artifact_id" : "art-acf4398779de",
"fingerprint" : "e2130efbb9643b41a4fb62619f2713402f9622c7db040fe18d929609ef70b89a"
}, {
"artifact_id" : "art-88d5de66ceea",
"fingerprint" : "9216dfc6a6226fac398e9f781550d23fbfaa65bd377cdda69187149beca114ec"
}, {
"artifact_id" : "art-18a3b61a9561",
"fingerprint" : "ebc4b7ca7bb1455288681c5d71424f60f658a44cb343d0c3934329cec676c867"
}, {
"artifact_id" : "art-231ecb85b204",
"fingerprint" : "c363f3ef7f32d9b249c7dc8babdb3eb5aae68c524099e5e97290202b054dda71"
}, {
"artifact_id" : "art-f36b8455db22",
"fingerprint" : "1ba8ce75c445396334737143e0cfa56c87a1fb64cca8ad0962192594849249eb"
}, {
"artifact_id" : "art-884d6b34d741",
"fingerprint" : "5e0954447699c6d5eac7550f1a10cf520efcddad0b9fbd9a716434da9d9550d4"
}, {
"artifact_id" : "art-ca5e39fb056c",
"fingerprint" : "6104ba7f216a937994d478ed07766ec28f48ad72fdd6620508535a1f877b8ddb"
} ]
} ],
"palettes" : [ ]
}

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

@ -0,0 +1,4 @@
{
"schema_version" : 1,
"glyph_bank_specialization" : "sprites"
}

View File

@ -0,0 +1,4 @@
{
"schema_version" : 1,
"glyph_bank_specialization" : "sprites"
}

View File

@ -16,6 +16,8 @@
},
"artifacts" : [ {
"file" : "confirm.png",
"index" : 0
"index" : 0,
"artifact_id" : "art-8c6bdad24eb1",
"fingerprint" : "aa7d241deabcebe29a6096e14eaf16fdc06cf06380c11a507620b00fc7bff094"
} ]
}

View File

@ -0,0 +1,4 @@
{
"schema_version" : 1,
"glyph_bank_specialization" : "sprites"
}

View File

@ -0,0 +1,4 @@
{
"schema_version" : 1,
"glyph_bank_specialization" : "sprites"
}

View File

@ -0,0 +1,4 @@
{
"schema_version" : 1,
"glyph_bank_specialization" : "sprites"
}

View File

@ -1,6 +1,6 @@
<?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">
<tileset firstgid="1" source="primeiro tileset.tsx"/>
<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="../Zelda3/primeiro tileset.tsx"/>
<layer id="1" name="Tile Layer 1" width="25" height="25">
<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,
@ -32,5 +32,15 @@
</layer>
<objectgroup id="3" name="Object Layer 1">
<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>
</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/tilemap_v1"
},
"preload" : {
"enabled" : false
}
}

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.1" orientation="orthogonal" renderorder="right-down" width="16" height="16" tilewidth="8" tileheight="8" infinite="0" nextlayerid="2" nextobjectid="1">
<tileset firstgid="1" source="../tileset/tileset.tsx"/>
<layer id="1" name="Layer 1" width="16" height="16">
<data encoding="csv">
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
1,2,3,4,5,1,1,1,1,1,1,1,1,1,1,1,
1,6,7,8,9,1,1,1,1,1,1,1,1,1,1,1,
1,10,11,12,13,1,1,1,1,1,1,1,1,1,1,1,
1,14,15,16,17,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,1,2,3,4,5,1,1,1,1,
1,1,1,1,1,1,1,1,6,7,8,9,1,1,1,1,
1,1,1,1,1,1,1,1,10,11,12,13,1,1,1,1,
1,1,1,1,1,1,1,1,14,15,16,17,1,1,1,1,
1,1,1,2,3,4,5,1,1,1,1,1,1,1,1,1,
1,1,1,6,7,8,9,1,1,1,1,1,1,1,1,1,
1,1,1,10,11,12,13,1,1,1,1,1,1,1,1,1,
1,1,1,14,15,16,17,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
</data>
</layer>
</map>

View File

@ -0,0 +1,39 @@
{
"schema_version" : 1,
"map_width" : 50,
"map_height" : 50,
"layer_count" : 4,
"layers" : [ {
"index" : 1,
"enabled" : true,
"name" : "Layer 1",
"width" : 50,
"height" : 50,
"tilemap" : "layer-1.tmx",
"tileset_asset_root" : "tileset"
}, {
"index" : 2,
"enabled" : false,
"name" : "Layer 2",
"width" : 16,
"height" : 16,
"tilemap" : "layer-2.tmx",
"tileset_asset_root" : ""
}, {
"index" : 3,
"enabled" : false,
"name" : "Layer 3",
"width" : 16,
"height" : 16,
"tilemap" : "layer-3.tmx",
"tileset_asset_root" : ""
}, {
"index" : 4,
"enabled" : false,
"name" : "Layer 4",
"width" : 16,
"height" : 16,
"tilemap" : "layer-4.tmx",
"tileset_asset_root" : ""
} ]
}

View File

@ -0,0 +1,112 @@
{
"schema_version" : 1,
"asset_uuid" : "c12f67ad-0471-4ab1-96dd-a816cfc66462",
"name" : "tileset",
"type" : "glyph_bank",
"output" : {
"codec" : "NONE",
"pipeline" : {
"palettes" : [ {
"index" : 0,
"palette" : {
"originalArgb8888" : [ -12019640, -12552128, -14129088, -14133200, -14145496, -7835584, -5730208, -10465232 ],
"convertedRgb565" : [ 19657, 17352, 11080, 10950, 10565, 35720, 44172, 25222 ]
}
} ]
},
"format" : "GLYPH/indexed_v1",
"codec_configuration" : { },
"metadata" : {
"tile_size" : "8x8"
}
},
"preload" : {
"enabled" : false
},
"artifacts" : [ {
"artifact_id" : "art-67db3cae2605",
"file" : "tileset-001.png",
"index" : 0,
"fingerprint" : "8771c3e294d0edbc8dd3aaa6995a6db32166ae6bfb3e063170cc79b298d17e71"
}, {
"artifact_id" : "art-ee733c010b67",
"file" : "tileset-002.001.png",
"index" : 1,
"fingerprint" : "5d81eef8be13043330b42b546e10beed8ba00dfaa42df67477a8778c2b9484d7"
}, {
"artifact_id" : "art-ab1efa5013f0",
"file" : "tileset-002.002.png",
"index" : 2,
"fingerprint" : "3250840c4fe08c134ea966ce7c8fcaccae2c2a45660d7173c5e262a2e5076548"
}, {
"artifact_id" : "art-453fed2836ce",
"file" : "tileset-002.003.png",
"index" : 3,
"fingerprint" : "fe3ab2b7eb3b10a042de14484d53e718e6b505edb1ad19053668565bfe3a8f25"
}, {
"artifact_id" : "art-52434bb77d67",
"file" : "tileset-002.004.png",
"index" : 4,
"fingerprint" : "7fcbf90e1e9a69d6ce3be80ea1901d66c9992f68dc72e4c636dcd2ad52fa2898"
}, {
"artifact_id" : "art-edc19227b5c8",
"file" : "tileset-002.005.png",
"index" : 5,
"fingerprint" : "451fc8d646049944d5aa7c7dc254636eec0151aaff6e2051059304b9d6ebc31e"
}, {
"artifact_id" : "art-a1131a305e06",
"file" : "tileset-002.006.png",
"index" : 6,
"fingerprint" : "aa757df4600823ec6655235a1081061777124ccee74b31481bbe402e53635f53"
}, {
"artifact_id" : "art-e8a9cff3fb90",
"file" : "tileset-002.007.png",
"index" : 7,
"fingerprint" : "38162157f5685cce25b835d3a2caee3465fd2ec3ee5db2a5089d7d5ae5ea3bb7"
}, {
"artifact_id" : "art-b1513b30e866",
"file" : "tileset-002.008.png",
"index" : 8,
"fingerprint" : "8cab5b3d553af9ed371bd19fc624763accc90e5d926d300ba31dd8d9d6bab803"
}, {
"artifact_id" : "art-ff5ad76c4e5e",
"file" : "tileset-002.009.png",
"index" : 9,
"fingerprint" : "d9ff5a6df0f65f5fcfb8f22376beeb266d429e2c6e9c82f51c12bc6ee4c3fcdc"
}, {
"artifact_id" : "art-704961ee43df",
"file" : "tileset-002.010.png",
"index" : 10,
"fingerprint" : "b9ff45c77f7779a6acebcc537c2ddac0e27e91162b656a04dc4772ec5f24fc26"
}, {
"artifact_id" : "art-895e14841cc2",
"file" : "tileset-002.011.png",
"index" : 11,
"fingerprint" : "43f370324fedccda888d49409e2a64f182f08b4143b4bdd5bafaf498feebd21a"
}, {
"artifact_id" : "art-21bacf6b47b9",
"file" : "tileset-002.012.png",
"index" : 12,
"fingerprint" : "cb047e655f4e196f22ca0048149df5dcb959f895faeaeb94920182d4a2d16b38"
}, {
"artifact_id" : "art-4c80bb3fc4ba",
"file" : "tileset-002.013.png",
"index" : 13,
"fingerprint" : "25eba17b67dd5159f5fb9d3edd531d54a44d1c76f997385840dac53517b98886"
}, {
"artifact_id" : "art-58970a0dffaf",
"file" : "tileset-002.014.png",
"index" : 14,
"fingerprint" : "32ca96c14290de8009fd2db623f55514bed2345b7d5881a80dd3fe6235012854"
}, {
"artifact_id" : "art-8d83870d938b",
"file" : "tileset-002.015.png",
"index" : 15,
"fingerprint" : "7a093fe00c805bcf38d4facff14a6d4c2b8baeaa2d553fa8d3db9f2d76b32f3b"
}, {
"artifact_id" : "art-b94f1f9218ea",
"file" : "tileset-002.016.png",
"index" : 16,
"fingerprint" : "77c5277220421226d16f58b1bcfb4eaf157fa4f6c740c5aaebefaf88fb91c22e"
} ]
}

Some files were not shown because too many files have changed in this diff Show More