diff --git a/discussion/index.ndjson b/discussion/index.ndjson index 31c3065c..7cbe2d2e 100644 --- a/discussion/index.ndjson +++ b/discussion/index.ndjson @@ -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"}]} diff --git a/discussion/lessons/DSC-0002-palette-management-in-studio/LSN-0042-schema-driven-palette-authoring-and-local-metadata-events.md b/discussion/lessons/DSC-0002-palette-management-in-studio/LSN-0042-schema-driven-palette-authoring-and-local-metadata-events.md new file mode 100644 index 00000000..60a42920 --- /dev/null +++ b/discussion/lessons/DSC-0002-palette-management-in-studio/LSN-0042-schema-driven-palette-authoring-and-local-metadata-events.md @@ -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--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. diff --git a/discussion/lessons/DSC-0022-studio-play-stop-and-debugger-integration/LSN-0022-studio-play-stop-and-debugger-integration.md b/discussion/lessons/DSC-0022-studio-play-stop-and-debugger-integration/LSN-0043-studio-play-stop-and-debugger-integration.md similarity index 99% rename from discussion/lessons/DSC-0022-studio-play-stop-and-debugger-integration/LSN-0022-studio-play-stop-and-debugger-integration.md rename to discussion/lessons/DSC-0022-studio-play-stop-and-debugger-integration/LSN-0043-studio-play-stop-and-debugger-integration.md index cd73bdad..85c9d858 100644 --- a/discussion/lessons/DSC-0022-studio-play-stop-and-debugger-integration/LSN-0022-studio-play-stop-and-debugger-integration.md +++ b/discussion/lessons/DSC-0022-studio-play-stop-and-debugger-integration/LSN-0043-studio-play-stop-and-debugger-integration.md @@ -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 diff --git a/discussion/lessons/DSC-0024-jacoco-reports-consolidation/LSN-0039-jenkins-jacoco-alignment.md b/discussion/lessons/DSC-0024-jacoco-reports-consolidation/LSN-0039-jenkins-jacoco-alignment.md deleted file mode 100644 index 9ec6fa03..00000000 --- a/discussion/lessons/DSC-0024-jacoco-reports-consolidation/LSN-0039-jenkins-jacoco-alignment.md +++ /dev/null @@ -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`. diff --git a/discussion/lessons/DSC-0024-jacoco-reports-consolidation/LSN-0044-jenkins-jacoco-alignment.md b/discussion/lessons/DSC-0024-jacoco-reports-consolidation/LSN-0044-jenkins-jacoco-alignment.md new file mode 100644 index 00000000..d37dda41 --- /dev/null +++ b/discussion/lessons/DSC-0024-jacoco-reports-consolidation/LSN-0044-jenkins-jacoco-alignment.md @@ -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. diff --git a/discussion/lessons/DSC-0028-studio-tiled-parser-assets-scene-asset-type/LSN-0045-asset-owned-scene-bank-and-tiled-xml-workflow.md b/discussion/lessons/DSC-0028-studio-tiled-parser-assets-scene-asset-type/LSN-0045-asset-owned-scene-bank-and-tiled-xml-workflow.md new file mode 100644 index 00000000..03b14758 --- /dev/null +++ b/discussion/lessons/DSC-0028-studio-tiled-parser-assets-scene-asset-type/LSN-0045-asset-owned-scene-bank-and-tiled-xml-workflow.md @@ -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. diff --git a/discussion/workflow/agendas/AGD-0002-palette-management-in-studio.md b/discussion/workflow/agendas/AGD-0002-palette-management-in-studio.md deleted file mode 100644 index 35eafc3a..00000000 --- a/discussion/workflow/agendas/AGD-0002-palette-management-in-studio.md +++ /dev/null @@ -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. diff --git a/discussion/workflow/agendas/AGD-0030-tiled-parser-and-scene-asset-type.md b/discussion/workflow/agendas/AGD-0030-tiled-parser-and-scene-asset-type.md deleted file mode 100644 index d4532eb4..00000000 --- a/discussion/workflow/agendas/AGD-0030-tiled-parser-and-scene-asset-type.md +++ /dev/null @@ -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. diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/assets/AssetFamilyCatalog.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/assets/AssetFamilyCatalog.java index 4ae04333..3271ce0a 100644 --- a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/assets/AssetFamilyCatalog.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/assets/AssetFamilyCatalog.java @@ -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"); diff --git a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/assets/OutputFormatCatalog.java b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/assets/OutputFormatCatalog.java index 5b5904b8..4ed5264c 100644 --- a/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/assets/OutputFormatCatalog.java +++ b/prometeu-packer/prometeu-packer-api/src/main/java/p/packer/messages/assets/OutputFormatCatalog.java @@ -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; } diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetArtifactSelection.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetArtifactSelection.java index 5b0234e4..c1a0dab9 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetArtifactSelection.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/models/PackerAssetArtifactSelection.java @@ -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"); } diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerAssetWalker.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerAssetWalker.java index da9d0e10..309a9870 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerAssetWalker.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerAssetWalker.java @@ -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, diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerRuntimeAssetMaterializer.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerRuntimeAssetMaterializer.java index 1019afa3..967ab976 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerRuntimeAssetMaterializer.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/repositories/PackerRuntimeAssetMaterializer.java @@ -8,6 +8,10 @@ import java.nio.file.Path; import java.util.*; public final class PackerRuntimeAssetMaterializer { + private static final Set 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 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, diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java index df8335d2..3fea3f8b 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/FileSystemPackerWorkspaceService.java @@ -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 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 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 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> 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 walkFilesByPath = new LinkedHashMap<>(); + runtimeAsset.walkProjection().buildCandidateFiles().forEach(file -> walkFilesByPath.put(file.relativePath(), file)); + final Set 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 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 runtimeAsset) { manifest.remove("inputs"); + final Map existingArtifactsByFile = existingArtifactsByFile(manifest.path("artifacts")); + final Map 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 existingArtifactsByFile(JsonNode artifactsNode) { + if (!(artifactsNode instanceof ArrayNode artifactsArray)) { + return Map.of(); + } + final Map 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 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"); diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDeclarationParser.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDeclarationParser.java index 1e8ab3a5..fbabf0e6 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDeclarationParser.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDeclarationParser.java @@ -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); diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDetailsService.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDetailsService.java index fd54f0e4..61e52851 100644 --- a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDetailsService.java +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerAssetDetailsService.java @@ -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 selectedFiles = selectedPathsFor(declaration).stream() - .map(path -> resolveSelectedBankFile(runtimeAsset.assetRoot(), path, walkFilesByPath, assetCacheEntry)) + final List 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 selectedPathsFor(PackerAssetDeclaration declaration) { - return declaration.artifacts().stream() - .sorted(Comparator.comparingInt(PackerAssetArtifactSelection::index)) - .map(PackerAssetArtifactSelection::file) - .toList(); - } - private List> 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 resolveSelectedBankFile( Path assetRoot, - String relativePath, + PackerAssetArtifactSelection artifact, Map walkFilesByPath, Optional 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 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 selectedArtifactMetadata( + PackerAssetArtifactSelection artifact, + Map metadata) { + final Map 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 metadataFields( List definitions, Map outputMetadata) { diff --git a/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerGlyphMaterialSupport.java b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerGlyphMaterialSupport.java new file mode 100644 index 00000000..3afdac48 --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/main/java/p/packer/services/PackerGlyphMaterialSupport.java @@ -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 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 diagnostics = new ArrayList<>(); + final Optional parsed = readSupportFile(supportFile, diagnostics); + if (parsed.isEmpty()) { + return List.copyOf(diagnostics); + } + + final Map selectedArtifactsById = new LinkedHashMap<>(); + for (PackerAssetArtifactSelection artifact : declaration.artifacts()) { + if (artifact.artifactId() != null) { + selectedArtifactsById.put(artifact.artifactId(), artifact); + } + } + final Map selectedFilesByPath = new LinkedHashMap<>(); + runtimeAsset.walkProjection().buildCandidateFiles().forEach(file -> selectedFilesByPath.put(file.relativePath(), file)); + + final Map 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 readSupportFile(Path supportFile, List 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 schemas = new ArrayList<>(); + final Set schemaIds = new HashSet<>(); + for (JsonNode schemaNode : schemasNode) { + final Optional 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 palettes = new ArrayList<>(); + final Set paletteIds = new HashSet<>(); + for (JsonNode paletteNode : palettesNode) { + final Optional 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 parseSchema( + JsonNode schemaNode, + Path supportFile, + List 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 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 bindings = new ArrayList<>(); + final Set 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 parsePalette( + JsonNode paletteNode, + Set schemaIds, + Path supportFile, + List 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 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 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 schemas, + List palettes) { + } + + private record GlyphMaterialSchema( + String schemaId, + GlyphMaterialSchemaStatus status, + int version, + Map materialIndices, + List artifactBindings) { + } + + private record GlyphMaterialPalette( + String paletteId, + String schemaId, + int version, + List 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; + } + } +} diff --git a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/PackerRuntimeAssetMaterializerTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/PackerRuntimeAssetMaterializerTest.java index 9eac95b1..d5604e2f 100644 --- a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/PackerRuntimeAssetMaterializerTest.java +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/repositories/PackerRuntimeAssetMaterializerTest.java @@ -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, diff --git a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java index 6311b4c9..f29d96ec 100644 --- a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/FileSystemPackerWorkspaceServiceTest.java @@ -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) diff --git a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerAssetDeclarationParserTest.java b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerAssetDeclarationParserTest.java index c650a87a..a66c90ee 100644 --- a/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerAssetDeclarationParserTest.java +++ b/prometeu-packer/prometeu-packer-v1/src/test/java/p/packer/services/PackerAssetDeclarationParserTest.java @@ -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"); diff --git a/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/managed-basic/assets/ui/atlas/studio.asset.json b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/managed-basic/assets/ui/atlas/studio.asset.json new file mode 100644 index 00000000..0da96cd5 --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/managed-basic/assets/ui/atlas/studio.asset.json @@ -0,0 +1,4 @@ +{ + "schema_version" : 1, + "glyph_bank_specialization" : "sprites" +} diff --git a/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-mixed/assets/ui/atlas/studio.asset.json b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-mixed/assets/ui/atlas/studio.asset.json new file mode 100644 index 00000000..0da96cd5 --- /dev/null +++ b/prometeu-packer/prometeu-packer-v1/src/test/resources/fixtures/workspaces/read-mixed/assets/ui/atlas/studio.asset.json @@ -0,0 +1,4 @@ +{ + "schema_version" : 1, + "glyph_bank_specialization" : "sprites" +} diff --git a/prometeu-studio/src/main/java/p/studio/controls/banks/StudioDualListView.java b/prometeu-studio/src/main/java/p/studio/controls/banks/StudioDualListView.java index 6c0e35e7..730a875c 100644 --- a/prometeu-studio/src/main/java/p/studio/controls/banks/StudioDualListView.java +++ b/prometeu-studio/src/main/java/p/studio/controls/banks/StudioDualListView.java @@ -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 extends HBox { private Consumer> onMoveToLeft = ignored -> { }; private Consumer onLeftSelectionChanged = ignored -> { }; private Consumer onRightSelectionChanged = ignored -> { }; + private Consumer onLeftItemActivated = ignored -> { }; + private Consumer onRightItemActivated = ignored -> { }; private IntConsumer onMoveUp = ignored -> { }; private IntConsumer onMoveDown = ignored -> { }; private boolean interactionEnabled = true; @@ -145,6 +148,14 @@ public abstract class StudioDualListView extends HBox { this.onRightSelectionChanged = Objects.requireNonNull(onRightSelectionChanged, "onRightSelectionChanged"); } + public void setOnLeftItemActivated(Consumer onLeftItemActivated) { + this.onLeftItemActivated = Objects.requireNonNull(onLeftItemActivated, "onLeftItemActivated"); + } + + public void setOnRightItemActivated(Consumer 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 extends HBox { private ListCell 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); diff --git a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java index beaf422e..45059c6e 100644 --- a/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java +++ b/prometeu-studio/src/main/java/p/studio/utilities/i18n/I18n.java @@ -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"), diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java index 504902fb..4cb3d368 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsControl.java @@ -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 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 buildActionNodes() { final java.util.List 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 diagnostics, java.util.List actions) { - final java.util.List 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 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 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); } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java index ada19cd0..2e45cf86 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetDetailsUiSupport.java @@ -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); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetListPackerMappings.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetListPackerMappings.java index 3e77d669..168f3e6a 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetListPackerMappings.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/AssetListPackerMappings.java @@ -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()); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/bank/AssetBankCapacityService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/bank/AssetBankCapacityService.java index 10415592..64bc922b 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/bank/AssetBankCapacityService.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/bank/AssetBankCapacityService.java @@ -21,6 +21,12 @@ public final class AssetBankCapacityService { final Map 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, diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/bank/AssetDetailsBankCompositionControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/bank/AssetDetailsBankCompositionControl.java index 390bb496..53e875b9 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/bank/AssetDetailsBankCompositionControl.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/bank/AssetDetailsBankCompositionControl.java @@ -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(); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingControl.java index 2aa56dad..eec488e9 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingControl.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingControl.java @@ -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 palettePayload(Map metadata) { final Object palette = metadata.get("palette"); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingCoordinator.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingCoordinator.java index 5b8af8dc..eea3885d 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingCoordinator.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingCoordinator.java @@ -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 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 allFiles = paletteFiles(details); final List 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 files) { if (ready() && formSession.mode() == StudioFormMode.EDITING) { - formSession.updateDraft(current -> current.moveToSelected(files)); + final List 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 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 readySchemas() { + return materialMetadata().schemas().stream() + .filter(schema -> schema.status() == AssetStudioGlyphMaterialSchemaStatus.OK) + .toList(); + } + + public String nextPaletteId() { + int next = 1; + final Set 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 colors) { + final List 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 colors) { + final AssetStudioGlyphMaterialMetadata current = materialMetadata(); + final List 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 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 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 nestedMap(Map metadata, String key) { + static Map nestedMap(Map metadata, String key) { final Object value = metadata.get(key); if (value instanceof Map nested) { return (Map) nested; @@ -133,37 +256,236 @@ public final class AssetDetailsPaletteOverhaulingCoordinator { return null; } - private static List paletteFiles(AssetWorkspaceAssetDetails details) { - final Map 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 paletteFiles(AssetWorkspaceAssetDetails details) { + final Map 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 metadata = new LinkedHashMap<>(file.metadata()); - metadata.put("paletteHash", hash); + private List allPaletteRecords() { + final AssetStudioGlyphMaterialMetadata metadata = materialMetadata(); + final List records = new ArrayList<>(); + for (AssetStudioGlyphMaterialSchema schema : metadata.schemas()) { + final AssetStudioGlyphMaterialSchemaStatus effectiveSchemaStatus = AssetDetailsColorSchemaCoordinator.effectiveStatus( + schema, + details.bankComposition().selectedFiles()); + final List 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 argb = record.colors().stream() + .map(AssetDetailsPaletteOverhaulingCoordinator::htmlToArgb8888) + .toList(); + final List rgb565 = record.colors().stream() + .map(AssetDetailsPaletteOverhaulingCoordinator::htmlToRgb565) + .toList(); + final Map palette = Map.of( + "originalArgb8888", argb, + "convertedRgb565", rgb565); + final Map 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 initialSelectedPaletteFiles( + static String generatedDisplayName(List colors) { + final List 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 initialSelectedPaletteFiles( AssetWorkspaceAssetDetails details, List allFiles) { final List 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 palette = nestedMap(file.metadata(), "palette"); - final Object convertedValue = palette == null ? null : palette.get("convertedRgb565"); - final List colors = convertedValue instanceof List converted - ? (List) 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 files, String path) { @@ -223,4 +534,93 @@ public final class AssetDetailsPaletteOverhaulingCoordinator { .findFirst() .orElse(null); } + + private static AssetDetailsPaletteOverhaulingDraft rebindDraft( + AssetDetailsPaletteOverhaulingDraft draft, + AssetDetailsPaletteOverhaulingDraft source) { + final Set selectedPaths = draft.selectedFiles().stream() + .map(AssetWorkspaceBankCompositionFile::path) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + final List selected = source.allFiles().stream() + .filter(file -> selectedPaths.contains(file.path())) + .toList(); + final List 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 schemaArtifactIds(AssetStudioGlyphMaterialSchema schema) { + return schema.artifactBindings().stream() + .map(AssetStudioGlyphMaterialArtifactBinding::artifactId) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + } + + private static List previewTileFilesForPalette( + List previewTileFiles, + AssetWorkspaceBankCompositionFile previewPaletteFile) { + if (previewPaletteFile == null) { + return previewTileFiles; + } + final Set schemaArtifactIds = schemaArtifactIds(previewPaletteFile); + if (schemaArtifactIds.isEmpty()) { + return previewTileFiles; + } + return previewTileFiles.stream() + .filter(file -> schemaArtifactIds.contains(artifactId(file))) + .toList(); + } + + private static Set schemaArtifactIds(AssetWorkspaceBankCompositionFile previewPaletteFile) { + final Object artifactIds = previewPaletteFile.metadata().get("schemaArtifactIds"); + if (!(artifactIds instanceof Iterable iterable)) { + return Set.of(); + } + final Set 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 colors, + Map materialIndices, + Set artifactIds, + boolean derivedOriginal, + boolean valid, + String invalidReason) { + } + + private record ColorSample(double hue, double saturation, double lightness) { + } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingDualListView.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingDualListView.java index 00fee37e..da6f89e3 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingDualListView.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingDualListView.java @@ -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; } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingPreviewPane.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingPreviewPane.java index 5f08d2f1..03a60ef4 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingPreviewPane.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingPreviewPane.java @@ -146,6 +146,8 @@ public final class AssetDetailsPaletteOverhaulingPreviewPane extends VBox { final Integer height = asInteger(tile.get("height")); final List indices = paletteIndices(tile.get("paletteIndices")); final List colors = paletteDisplayColors(paletteFile.metadata()); + final List sourceColors = tileDisplayColors(tileFile.metadata()); + final Map 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 tileDisplayColors(Map metadata) { + final Map palette = nestedMap(metadata, "palette"); + if (palette == null) { + return List.of(); + } + final Object original = palette.get("originalArgb8888"); + if (original instanceof List list) { + return (List) list; + } + return List.of(); + } + + private Map schemaIndicesByColor(Map metadata) { + final Object materialIndices = metadata.get("schemaMaterialIndices"); + if (!(materialIndices instanceof Map map)) { + return Map.of(); + } + final Map 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 sourceColors, Map 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 colors, int index) { if (index < 0 || index >= colors.size()) { return 0x00000000; diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteWizardDialog.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteWizardDialog.java new file mode 100644 index 00000000..f7d86c6b --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteWizardDialog.java @@ -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 schemaComboBox = new ComboBox<>(); + private final TextField nameField = new TextField(); + private final ObservableList editableColors = FXCollections.observableArrayList(); + private final ObservableList indexItems = FXCollections.observableArrayList(); + private final ListView 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 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 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 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 initialEditableColors( + AssetWorkspaceBankCompositionFile paletteFile, + AssetStudioGlyphMaterialSchema initialSchema) { + if (paletteFile != null) { + @SuppressWarnings("unchecked") + final Map palette = (Map) paletteFile.metadata().get("palette"); + final Object original = palette == null ? null : palette.get("originalArgb8888"); + if (original instanceof Iterable iterable) { + final List 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 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 colors) { + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/scene/AssetDetailsSceneLayersControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/scene/AssetDetailsSceneLayersControl.java new file mode 100644 index 00000000..8f0ac363 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/scene/AssetDetailsSceneLayersControl.java @@ -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 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 createTilesetCombo(LayerDraft draft, boolean editing) { + final ComboBox combo = new ComboBox<>(); + combo.setMaxWidth(Double.MAX_VALUE); + combo.getStyleClass().add("assets-details-combo"); + final List 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 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 mutator, + String successMessage) { + if (viewState == null || viewState.selectedAssetDetails() == null || viewState.selectedAssetDetails().sceneBankMetadata() == null) { + return; + } + final AssetStudioSceneBankMetadata metadata = viewState.selectedAssetDetails().sceneBankMetadata(); + final java.util.List 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; + } + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/schema/AssetDetailsColorSchemaCoordinator.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/schema/AssetDetailsColorSchemaCoordinator.java new file mode 100644 index 00000000..bcaf75a4 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/schema/AssetDetailsColorSchemaCoordinator.java @@ -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 schemas() { + return metadata().schemas(); + } + + public List selectedTiles() { + if (!ready()) { + return List.of(); + } + return details.bankComposition().selectedFiles().stream() + .filter(AssetDetailsColorSchemaCoordinator::supportsSchemaAuthoring) + .filter(file -> artifactId(file) != null) + .toList(); + } + + public List availableTilesForCreate() { + final Set 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 availableTilesForEdit(String schemaId) { + final AssetStudioGlyphMaterialSchema schema = findSchema(schemaId).orElse(null); + if (schema == null) { + return availableTilesForCreate(); + } + final Set current = schema.artifactBindings().stream() + .map(AssetStudioGlyphMaterialArtifactBinding::artifactId) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + final Set 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 findSchema(String schemaId) { + return schemas().stream().filter(schema -> schema.schemaId().equals(schemaId)).findFirst(); + } + + public AssetStudioGlyphMaterialMetadata upsertSchema( + String schemaId, + List assignedTiles, + List orderedColors) { + final AssetStudioGlyphMaterialMetadata current = metadata(); + final AssetStudioGlyphMaterialSchema existing = current.findSchema(schemaId).orElse(null); + final AssetStudioGlyphMaterialSchema next = buildSchema(schemaId, existing, assignedTiles, orderedColors); + final List 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 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 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 selectedTiles) { + if (schema.status() == AssetStudioGlyphMaterialSchemaStatus.INCONSISTENT) { + return AssetStudioGlyphMaterialSchemaStatus.INCONSISTENT; + } + final Map 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 assignedTiles, + List orderedColors) { + final Map materialIndices = new LinkedHashMap<>(); + for (int index = 0; index < orderedColors.size(); index += 1) { + materialIndices.put(index + 1, normalizeHtmlColor(orderedColors.get(index))); + } + final List 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 tileColors(AssetWorkspaceBankCompositionFile file) { + final Map 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 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 nestedMap(Map metadata, String key) { + final Object value = metadata.get(key); + if (value instanceof Map nested) { + return (Map) nested; + } + return null; + } + + private static String normalizeHtmlColor(String color) { + return Objects.requireNonNull(color, "color").trim().toUpperCase(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/schema/AssetDetailsColorSchemaDraft.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/schema/AssetDetailsColorSchemaDraft.java new file mode 100644 index 00000000..d176ed04 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/schema/AssetDetailsColorSchemaDraft.java @@ -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 availableTiles, + List assignedTiles, + List 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 files) { + final List nextAvailable = new ArrayList<>(availableTiles); + final List 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 files) { + final List nextAvailable = new ArrayList<>(availableTiles); + final List 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 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 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 orderedColors() { + if (!deriveColorsFromTiles) { + return preferredColors; + } + final LinkedHashSet active = new LinkedHashSet<>(); + for (AssetWorkspaceBankCompositionFile tile : assignedTiles) { + AssetDetailsColorSchemaCoordinator.tileColors(tile).stream() + .map(AssetDetailsColorSchemaDraft::normalize) + .forEach(active::add); + } + final List 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 nextAvailable, + List nextAssigned) { + return new AssetDetailsColorSchemaDraft(schemaId, existingSchema, false, nextAvailable, nextAssigned, orderedColors(), true); + } + + private static List normalize(List colors) { + final List 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(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/schema/AssetDetailsColorSchemaGroupsControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/schema/AssetDetailsColorSchemaGroupsControl.java new file mode 100644 index 00000000..01b28b35 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/schema/AssetDetailsColorSchemaGroupsControl.java @@ -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 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 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 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 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 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 selectableTiles = schema == null + ? coordinator.availableTilesForCreate() + : coordinator.availableTilesForEdit(schema.schemaId()); + final Set assignedArtifactIds = schema == null + ? Set.of() + : schema.artifactBindings().stream() + .map(AssetStudioGlyphMaterialArtifactBinding::artifactId) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + final List assignedTiles = selectableTiles.stream() + .filter(file -> assignedArtifactIds.contains(AssetDetailsColorSchemaCoordinator.artifactId(file))) + .toList(); + final List availableTiles = selectableTiles.stream() + .filter(file -> !assignedArtifactIds.contains(AssetDetailsColorSchemaCoordinator.artifactId(file))) + .toList(); + final List 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 schemaListItems() { + final List 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 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 { + 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 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()); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/summary/AssetDetailsSummaryControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/summary/AssetDetailsSummaryControl.java index ce1bad59..89d9d402 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/summary/AssetDetailsSummaryControl.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/details/summary/AssetDetailsSummaryControl.java @@ -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( diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListItemControl.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListItemControl.java index 06a4aae2..4687cfbe 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListItemControl.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/list/AssetListItemControl.java @@ -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"; }; diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetDetails.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetDetails.java index d6c3cf43..fa0fe461 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetDetails.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetDetails.java @@ -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 outputPipeline, AssetWorkspaceBankCompositionDetails bankComposition, List> pipelinePalettes, + AssetStudioGlyphMaterialMetadata glyphMaterialMetadata, + AssetStudioSceneBankMetadata sceneBankMetadata, + AssetWorkspaceSceneBankValidation sceneBankValidation, List 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); + } } diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetSummary.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetSummary.java index af445af7..bca9b9ca 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetSummary.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceAssetSummary.java @@ -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"); diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceSceneBankStatus.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceSceneBankStatus.java new file mode 100644 index 00000000..72090756 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceSceneBankStatus.java @@ -0,0 +1,9 @@ +package p.studio.workspaces.assets.messages; + +public enum AssetWorkspaceSceneBankStatus { + NOT_APPLICABLE, + PENDING_VALIDATION, + VALIDATED_PENDING_ACCEPTANCE, + READY, + VALIDATION_FAILED +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceSceneBankValidation.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceSceneBankValidation.java new file mode 100644 index 00000000..df80e247 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/AssetWorkspaceSceneBankValidation.java @@ -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, ""); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioBankCompositionChangedEvent.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioBankCompositionChangedEvent.java new file mode 100644 index 00000000..ae6ff2f5 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioBankCompositionChangedEvent.java @@ -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"); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioGlyphMaterialMetadataChangedEvent.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioGlyphMaterialMetadataChangedEvent.java new file mode 100644 index 00000000..554a86cf --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/messages/events/StudioGlyphMaterialMetadataChangedEvent.java @@ -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"); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialArtifactBinding.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialArtifactBinding.java new file mode 100644 index 00000000..df485e37 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialArtifactBinding.java @@ -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"); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialMetadata.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialMetadata.java new file mode 100644 index 00000000..43cbdca1 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialMetadata.java @@ -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 schemas, + List 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 findSchema(String schemaId) { + return schemas.stream().filter(schema -> schema.schemaId().equals(schemaId)).findFirst(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialPalette.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialPalette.java new file mode 100644 index 00000000..23bf4591 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialPalette.java @@ -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 colors) { + + public AssetStudioGlyphMaterialPalette(String paletteId, String schemaId, int version, List 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 convertedRgb565Colors() { + final List 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); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialSchema.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialSchema.java new file mode 100644 index 00000000..033ef10d --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialSchema.java @@ -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 materialIndices, + List 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")); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialSchemaStatus.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialSchemaStatus.java new file mode 100644 index 00000000..be3c7277 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphMaterialSchemaStatus.java @@ -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; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphSpecialization.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphSpecialization.java new file mode 100644 index 00000000..1115d28d --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioGlyphSpecialization.java @@ -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; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataService.java new file mode 100644 index 00000000..48d75297 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataService.java @@ -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 bindings = new ArrayList<>(); + final Set 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 schemas = new ArrayList<>(); + final Set 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 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 bindings = new ArrayList<>(); + final Set 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 palettes = new ArrayList<>(); + final Set 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 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 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; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataSnapshot.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataSnapshot.java new file mode 100644 index 00000000..18cce5b5 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataSnapshot.java @@ -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); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioSceneBankMetadata.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioSceneBankMetadata.java new file mode 100644 index 00000000..3d43feac --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioSceneBankMetadata.java @@ -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 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(); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioSceneLayerBinding.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioSceneLayerBinding.java new file mode 100644 index 00000000..a2d536bc --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/metadata/AssetStudioSceneLayerBinding.java @@ -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"); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/scene/SceneBankWorkflowResult.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/scene/SceneBankWorkflowResult.java new file mode 100644 index 00000000..9fd6d718 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/scene/SceneBankWorkflowResult.java @@ -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 diagnostics) { + + public SceneBankWorkflowResult { + message = Objects.requireNonNullElse(message, ""); + validation = Objects.requireNonNullElse(validation, AssetWorkspaceSceneBankValidation.NOT_APPLICABLE); + diagnostics = List.copyOf(Objects.requireNonNullElse(diagnostics, List.of())); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/scene/SceneBankWorkflowService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/scene/SceneBankWorkflowService.java new file mode 100644 index 00000000..7584a543 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/scene/SceneBankWorkflowService.java @@ -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 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 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 validateDiagnostics(ProjectReference projectReference, AssetWorkspaceAssetDetails details, Integer targetLayerIndex) { + final List 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 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 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 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); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationResult.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationResult.java new file mode 100644 index 00000000..7ddc7c85 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationResult.java @@ -0,0 +1,16 @@ +package p.studio.workspaces.assets.tiled; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +public record TiledAssetGenerationResult( + boolean success, + String message, + List writtenFiles) { + + public TiledAssetGenerationResult { + message = Objects.requireNonNullElse(message, ""); + writtenFiles = List.copyOf(Objects.requireNonNullElse(writtenFiles, List.of())); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationService.java new file mode 100644 index 00000000..6b6211a6 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationService.java @@ -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 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 tiles = new ArrayList<>(); + for (int index = 0; index < selectedFiles.size(); index += 1) { + final AssetWorkspaceBankCompositionFile file = selectedFiles.get(index); + tiles.add(new TiledTilesetTile( + index, + file.path(), + tileSize, + tileSize, + List.of(new TiledProperty("glyph_id", "int", Integer.toString(index))), + null)); + } + final TiledTilesetDocument document = new TiledTilesetDocument( + "1.10", + "1.12.1", + details.summary().assetName(), + tileSize, + tileSize, + tiles.size(), + 0, + List.of(), + tiles); + final Path outputPath = details.summary().assetRoot().resolve(GENERATED_TSX_FILE); + try { + codec.writeTileset(outputPath, document); + return new TiledAssetGenerationResult(true, "Generated TSX successfully.", List.of(outputPath)); + } catch (IOException exception) { + return new TiledAssetGenerationResult(false, "Unable to generate TSX: " + exception.getMessage(), List.of()); + } + } + + public TiledAssetGenerationResult generateSceneBankTilemaps(ProjectReference projectReference, AssetWorkspaceAssetDetails details) { + if (details == null || details.summary().assetFamily() != AssetFamilyCatalog.SCENE_BANK) { + return new TiledAssetGenerationResult(false, "TMX generation is available only for Scene Bank assets.", List.of()); + } + final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata(); + if (metadata == null) { + return new TiledAssetGenerationResult(false, "Scene Bank support metadata is missing or invalid.", List.of()); + } + final List writtenFiles = new ArrayList<>(); + try { + for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) { + 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 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 metadataFields) { + final String value = metadataFields.stream() + .filter(field -> "tile_size".equals(field.key())) + .map(PackerCodecConfigurationFieldDTO::value) + .findFirst() + .orElse("16x16"); + final String normalized = value.trim().toLowerCase(); + if (normalized.startsWith("8x8")) { + return 8; + } + if (normalized.startsWith("32x32")) { + return 32; + } + return 16; + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledMapDocument.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledMapDocument.java new file mode 100644 index 00000000..c95ebece --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledMapDocument.java @@ -0,0 +1,32 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.List; +import java.util.Objects; + +public record TiledMapDocument( + String version, + String tiledVersion, + String orientation, + String renderOrder, + int width, + int height, + int tileWidth, + int tileHeight, + int nextLayerId, + int nextObjectId, + List properties, + List tilesets, + List tileLayers, + List objectLayers) { + + public TiledMapDocument { + version = Objects.requireNonNullElse(version, "1.10"); + tiledVersion = Objects.requireNonNullElse(tiledVersion, "1.12.1"); + orientation = Objects.requireNonNullElse(orientation, "orthogonal"); + renderOrder = Objects.requireNonNullElse(renderOrder, "right-down"); + properties = List.copyOf(Objects.requireNonNull(properties, "properties")); + tilesets = List.copyOf(Objects.requireNonNull(tilesets, "tilesets")); + tileLayers = List.copyOf(Objects.requireNonNull(tileLayers, "tileLayers")); + objectLayers = List.copyOf(Objects.requireNonNull(objectLayers, "objectLayers")); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledObjectData.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledObjectData.java new file mode 100644 index 00000000..3831db2f --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledObjectData.java @@ -0,0 +1,21 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.List; +import java.util.Objects; + +public record TiledObjectData( + int id, + String name, + double x, + double y, + double width, + double height, + List polygonPoints, + List properties) { + + public TiledObjectData { + name = Objects.requireNonNullElse(name, "").trim(); + polygonPoints = List.copyOf(Objects.requireNonNull(polygonPoints, "polygonPoints")); + properties = List.copyOf(Objects.requireNonNull(properties, "properties")); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledObjectLayer.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledObjectLayer.java new file mode 100644 index 00000000..f08dc562 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledObjectLayer.java @@ -0,0 +1,17 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.List; +import java.util.Objects; + +public record TiledObjectLayer( + int id, + String name, + List objects, + List properties) { + + public TiledObjectLayer { + name = Objects.requireNonNullElse(name, "").trim(); + objects = List.copyOf(Objects.requireNonNull(objects, "objects")); + properties = List.copyOf(Objects.requireNonNull(properties, "properties")); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledPoint.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledPoint.java new file mode 100644 index 00000000..2fb4561f --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledPoint.java @@ -0,0 +1,4 @@ +package p.studio.workspaces.assets.tiled; + +public record TiledPoint(double x, double y) { +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledProperty.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledProperty.java new file mode 100644 index 00000000..02ecd4f8 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledProperty.java @@ -0,0 +1,17 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.Objects; + +public record TiledProperty(String name, String type, String value) { + public TiledProperty { + name = Objects.requireNonNull(name, "name").trim(); + type = Objects.requireNonNullElse(type, "string").trim(); + value = Objects.requireNonNullElse(value, "").trim(); + if (name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + if (type.isBlank()) { + type = "string"; + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTileLayer.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTileLayer.java new file mode 100644 index 00000000..fd5fba06 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTileLayer.java @@ -0,0 +1,19 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.List; +import java.util.Objects; + +public record TiledTileLayer( + int id, + String name, + int width, + int height, + List gids, + List properties) { + + public TiledTileLayer { + name = Objects.requireNonNullElse(name, "").trim(); + gids = List.copyOf(Objects.requireNonNull(gids, "gids")); + properties = List.copyOf(Objects.requireNonNull(properties, "properties")); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetDocument.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetDocument.java new file mode 100644 index 00000000..5cd0efb1 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetDocument.java @@ -0,0 +1,27 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.List; +import java.util.Objects; + +public record TiledTilesetDocument( + String version, + String tiledVersion, + String name, + int tileWidth, + int tileHeight, + int tileCount, + int columns, + List properties, + List tiles) { + + public TiledTilesetDocument { + version = Objects.requireNonNullElse(version, "1.10"); + tiledVersion = Objects.requireNonNullElse(tiledVersion, "1.12.1"); + name = Objects.requireNonNull(name, "name").trim(); + properties = List.copyOf(Objects.requireNonNull(properties, "properties")); + tiles = List.copyOf(Objects.requireNonNull(tiles, "tiles")); + if (name.isBlank()) { + throw new IllegalArgumentException("name must not be blank"); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetReference.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetReference.java new file mode 100644 index 00000000..579460cf --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetReference.java @@ -0,0 +1,15 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.Objects; + +public record TiledTilesetReference(int firstGid, String source) { + public TiledTilesetReference { + source = Objects.requireNonNull(source, "source").trim(); + if (firstGid <= 0) { + throw new IllegalArgumentException("firstGid must be positive"); + } + if (source.isBlank()) { + throw new IllegalArgumentException("source must not be blank"); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetTile.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetTile.java new file mode 100644 index 00000000..468241da --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledTilesetTile.java @@ -0,0 +1,21 @@ +package p.studio.workspaces.assets.tiled; + +import java.util.List; +import java.util.Objects; + +public record TiledTilesetTile( + int id, + String imageSource, + int imageWidth, + int imageHeight, + List properties, + TiledObjectLayer collisionLayer) { + + public TiledTilesetTile { + imageSource = Objects.requireNonNull(imageSource, "imageSource").trim(); + properties = List.copyOf(Objects.requireNonNull(properties, "properties")); + if (imageSource.isBlank()) { + throw new IllegalArgumentException("imageSource must not be blank"); + } + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledUnsupportedFeatureException.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledUnsupportedFeatureException.java new file mode 100644 index 00000000..a9ac37ea --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledUnsupportedFeatureException.java @@ -0,0 +1,7 @@ +package p.studio.workspaces.assets.tiled; + +public final class TiledUnsupportedFeatureException extends RuntimeException { + public TiledUnsupportedFeatureException(String message) { + super(message); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledXmlCodec.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledXmlCodec.java new file mode 100644 index 00000000..afaa4202 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/tiled/TiledXmlCodec.java @@ -0,0 +1,460 @@ +package p.studio.workspaces.assets.tiled; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +public final class TiledXmlCodec { + public TiledMapDocument readMap(Path path) throws IOException { + final Document document = parseDocument(path); + final Element root = document.getDocumentElement(); + if (!"map".equals(root.getTagName())) { + throw new IOException("TMX root element must be ."); + } + if (!"0".equals(root.getAttribute("infinite")) && !root.getAttribute("infinite").isBlank()) { + throw new TiledUnsupportedFeatureException("Infinite maps are not supported in wave 1."); + } + rejectMapUnsupportedChildren(root); + return new TiledMapDocument( + root.getAttribute("version"), + root.getAttribute("tiledversion"), + root.getAttribute("orientation"), + root.getAttribute("renderorder"), + intAttribute(root, "width", 0), + intAttribute(root, "height", 0), + intAttribute(root, "tilewidth", 0), + intAttribute(root, "tileheight", 0), + intAttribute(root, "nextlayerid", 0), + intAttribute(root, "nextobjectid", 0), + readProperties(child(root, "properties")), + readTilesets(root), + readTileLayers(root), + readObjectLayers(root)); + } + + public void writeMap(Path path, TiledMapDocument map) throws IOException { + final Document document = newDocument(); + final Element root = document.createElement("map"); + document.appendChild(root); + root.setAttribute("version", map.version()); + root.setAttribute("tiledversion", map.tiledVersion()); + root.setAttribute("orientation", map.orientation()); + root.setAttribute("renderorder", map.renderOrder()); + root.setAttribute("width", Integer.toString(map.width())); + root.setAttribute("height", Integer.toString(map.height())); + root.setAttribute("tilewidth", Integer.toString(map.tileWidth())); + root.setAttribute("tileheight", Integer.toString(map.tileHeight())); + root.setAttribute("infinite", "0"); + root.setAttribute("nextlayerid", Integer.toString(map.nextLayerId())); + root.setAttribute("nextobjectid", Integer.toString(map.nextObjectId())); + appendProperties(document, root, map.properties()); + for (TiledTilesetReference tileset : map.tilesets()) { + final Element tilesetElement = document.createElement("tileset"); + tilesetElement.setAttribute("firstgid", Integer.toString(tileset.firstGid())); + tilesetElement.setAttribute("source", tileset.source()); + root.appendChild(tilesetElement); + } + for (TiledTileLayer layer : map.tileLayers()) { + final Element layerElement = document.createElement("layer"); + layerElement.setAttribute("id", Integer.toString(layer.id())); + layerElement.setAttribute("name", layer.name()); + layerElement.setAttribute("width", Integer.toString(layer.width())); + layerElement.setAttribute("height", Integer.toString(layer.height())); + appendProperties(document, layerElement, layer.properties()); + final Element dataElement = document.createElement("data"); + dataElement.setAttribute("encoding", "csv"); + dataElement.setTextContent(csv(layer.gids(), layer.width())); + layerElement.appendChild(dataElement); + root.appendChild(layerElement); + } + for (TiledObjectLayer layer : map.objectLayers()) { + root.appendChild(writeObjectLayer(document, layer, false)); + } + writeDocument(path, document); + } + + public TiledTilesetDocument readTileset(Path path) throws IOException { + final Document document = parseDocument(path); + final Element root = document.getDocumentElement(); + if (!"tileset".equals(root.getTagName())) { + throw new IOException("TSX root element must be ."); + } + rejectTilesetUnsupportedChildren(root); + final List tiles = new ArrayList<>(); + for (Element tileElement : children(root, "tile")) { + if (child(tileElement, "animation") != null) { + throw new TiledUnsupportedFeatureException("Tiled animations are not supported in wave 1."); + } + final Element imageElement = child(tileElement, "image"); + if (imageElement == null) { + throw new IOException("TSX tile must contain an element."); + } + tiles.add(new TiledTilesetTile( + intAttribute(tileElement, "id", 0), + imageElement.getAttribute("source"), + intAttribute(imageElement, "width", 0), + intAttribute(imageElement, "height", 0), + readProperties(child(tileElement, "properties")), + readEmbeddedObjectLayer(tileElement))); + } + tiles.sort(Comparator.comparingInt(TiledTilesetTile::id)); + return new TiledTilesetDocument( + root.getAttribute("version"), + root.getAttribute("tiledversion"), + root.getAttribute("name"), + intAttribute(root, "tilewidth", 0), + intAttribute(root, "tileheight", 0), + intAttribute(root, "tilecount", tiles.size()), + intAttribute(root, "columns", 0), + readProperties(child(root, "properties")), + tiles); + } + + public void writeTileset(Path path, TiledTilesetDocument tileset) throws IOException { + final Document document = newDocument(); + final Element root = document.createElement("tileset"); + document.appendChild(root); + root.setAttribute("version", tileset.version()); + root.setAttribute("tiledversion", tileset.tiledVersion()); + root.setAttribute("name", tileset.name()); + root.setAttribute("tilewidth", Integer.toString(tileset.tileWidth())); + root.setAttribute("tileheight", Integer.toString(tileset.tileHeight())); + root.setAttribute("tilecount", Integer.toString(tileset.tileCount())); + root.setAttribute("columns", Integer.toString(tileset.columns())); + final Element grid = document.createElement("grid"); + grid.setAttribute("orientation", "orthogonal"); + grid.setAttribute("width", "1"); + grid.setAttribute("height", "1"); + root.appendChild(grid); + appendProperties(document, root, tileset.properties()); + for (TiledTilesetTile tile : tileset.tiles()) { + final Element tileElement = document.createElement("tile"); + tileElement.setAttribute("id", Integer.toString(tile.id())); + appendProperties(document, tileElement, tile.properties()); + final Element imageElement = document.createElement("image"); + imageElement.setAttribute("source", tile.imageSource()); + imageElement.setAttribute("width", Integer.toString(tile.imageWidth())); + imageElement.setAttribute("height", Integer.toString(tile.imageHeight())); + tileElement.appendChild(imageElement); + if (tile.collisionLayer() != null) { + tileElement.appendChild(writeObjectLayer(document, tile.collisionLayer(), true)); + } + root.appendChild(tileElement); + } + writeDocument(path, document); + } + + private Document parseDocument(Path path) throws IOException { + try { + final var factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + return factory.newDocumentBuilder().parse(path.toFile()); + } catch (TiledUnsupportedFeatureException exception) { + throw exception; + } catch (Exception exception) { + throw new IOException("Unable to parse Tiled XML: " + exception.getMessage(), exception); + } + } + + private Document newDocument() throws IOException { + try { + return DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + } catch (Exception exception) { + throw new IOException("Unable to create XML document: " + exception.getMessage(), exception); + } + } + + private void writeDocument(Path path, Document document) throws IOException { + try { + Files.createDirectories(Objects.requireNonNull(path, "path").toAbsolutePath().normalize().getParent()); + final var transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2"); + transformer.transform(new DOMSource(document), new StreamResult(path.toFile())); + } catch (Exception exception) { + throw new IOException("Unable to write Tiled XML: " + exception.getMessage(), exception); + } + } + + private void rejectMapUnsupportedChildren(Element root) { + for (Element child : childElements(root)) { + final String tag = child.getTagName(); + if ("imagelayer".equals(tag) || "group".equals(tag) || "template".equals(tag)) { + throw new TiledUnsupportedFeatureException("Unsupported TMX feature in wave 1: " + tag); + } + } + } + + private void rejectTilesetUnsupportedChildren(Element root) { + for (Element child : childElements(root)) { + final String tag = child.getTagName(); + if ("wangsets".equals(tag) || "tileoffset".equals(tag) || "transformations".equals(tag)) { + throw new TiledUnsupportedFeatureException("Unsupported TSX feature in wave 1: " + tag); + } + } + } + + private List readTilesets(Element root) { + final List tilesets = new ArrayList<>(); + for (Element tilesetElement : children(root, "tileset")) { + if (!tilesetElement.hasAttribute("source")) { + throw new TiledUnsupportedFeatureException("Inline tilesets are not supported in wave 1."); + } + tilesets.add(new TiledTilesetReference( + intAttribute(tilesetElement, "firstgid", 1), + tilesetElement.getAttribute("source"))); + } + return List.copyOf(tilesets); + } + + private List readTileLayers(Element root) { + final List layers = new ArrayList<>(); + for (Element layerElement : children(root, "layer")) { + final Element dataElement = child(layerElement, "data"); + if (dataElement == null || !"csv".equalsIgnoreCase(dataElement.getAttribute("encoding"))) { + throw new TiledUnsupportedFeatureException("Wave 1 supports only CSV tile layer encoding."); + } + layers.add(new TiledTileLayer( + intAttribute(layerElement, "id", 0), + layerElement.getAttribute("name"), + intAttribute(layerElement, "width", 0), + intAttribute(layerElement, "height", 0), + parseCsvData(dataElement.getTextContent()), + readProperties(child(layerElement, "properties")))); + } + return List.copyOf(layers); + } + + private List readObjectLayers(Element root) { + final List layers = new ArrayList<>(); + for (Element objectGroup : children(root, "objectgroup")) { + layers.add(readObjectLayer(objectGroup)); + } + return List.copyOf(layers); + } + + private TiledObjectLayer readEmbeddedObjectLayer(Element parent) { + final Element objectGroup = child(parent, "objectgroup"); + return objectGroup == null ? null : readObjectLayer(objectGroup); + } + + private TiledObjectLayer readObjectLayer(Element objectGroup) { + final List objects = new ArrayList<>(); + for (Element objectElement : children(objectGroup, "object")) { + final Element polygon = child(objectElement, "polygon"); + if (child(objectElement, "ellipse") != null || child(objectElement, "point") != null || child(objectElement, "polyline") != null) { + throw new TiledUnsupportedFeatureException("Unsupported object-layer geometry in wave 1."); + } + objects.add(new TiledObjectData( + intAttribute(objectElement, "id", 0), + objectElement.getAttribute("name"), + doubleAttribute(objectElement, "x", 0.0d), + doubleAttribute(objectElement, "y", 0.0d), + doubleAttribute(objectElement, "width", 0.0d), + doubleAttribute(objectElement, "height", 0.0d), + polygon == null ? List.of() : parsePolygon(polygon.getAttribute("points")), + readProperties(child(objectElement, "properties")))); + } + return new TiledObjectLayer( + intAttribute(objectGroup, "id", 0), + objectGroup.getAttribute("name"), + objects, + readProperties(child(objectGroup, "properties"))); + } + + private Element writeObjectLayer(Document document, TiledObjectLayer layer, boolean embedded) { + final Element objectGroup = document.createElement("objectgroup"); + if (layer.id() > 0) { + objectGroup.setAttribute("id", Integer.toString(layer.id())); + } + if (!layer.name().isBlank()) { + objectGroup.setAttribute("name", layer.name()); + } + if (embedded) { + objectGroup.setAttribute("draworder", "index"); + } + appendProperties(document, objectGroup, layer.properties()); + for (TiledObjectData object : layer.objects()) { + final Element objectElement = document.createElement("object"); + if (object.id() > 0) { + objectElement.setAttribute("id", Integer.toString(object.id())); + } + if (!object.name().isBlank()) { + objectElement.setAttribute("name", object.name()); + } + objectElement.setAttribute("x", formatDecimal(object.x())); + objectElement.setAttribute("y", formatDecimal(object.y())); + if (object.width() > 0.0d) { + objectElement.setAttribute("width", formatDecimal(object.width())); + } + if (object.height() > 0.0d) { + objectElement.setAttribute("height", formatDecimal(object.height())); + } + appendProperties(document, objectElement, object.properties()); + if (!object.polygonPoints().isEmpty()) { + final Element polygon = document.createElement("polygon"); + polygon.setAttribute("points", polygonPoints(object.polygonPoints())); + objectElement.appendChild(polygon); + } + objectGroup.appendChild(objectElement); + } + return objectGroup; + } + + private void appendProperties(Document document, Element parent, List properties) { + if (properties.isEmpty()) { + return; + } + final Element propertiesElement = document.createElement("properties"); + for (TiledProperty property : properties) { + final Element propertyElement = document.createElement("property"); + propertyElement.setAttribute("name", property.name()); + if (!"string".equals(property.type())) { + propertyElement.setAttribute("type", property.type()); + } + propertyElement.setAttribute("value", property.value()); + propertiesElement.appendChild(propertyElement); + } + parent.appendChild(propertiesElement); + } + + private List readProperties(Element propertiesElement) { + if (propertiesElement == null) { + return List.of(); + } + final List properties = new ArrayList<>(); + for (Element propertyElement : children(propertiesElement, "property")) { + properties.add(new TiledProperty( + propertyElement.getAttribute("name"), + propertyElement.getAttribute("type"), + propertyElement.hasAttribute("value") + ? propertyElement.getAttribute("value") + : propertyElement.getTextContent())); + } + return List.copyOf(properties); + } + + private List parseCsvData(String text) { + final List gids = new ArrayList<>(); + for (String token : Objects.requireNonNullElse(text, "").split(",")) { + final String normalized = token.trim(); + if (normalized.isBlank()) { + continue; + } + gids.add(Long.parseLong(normalized)); + } + return List.copyOf(gids); + } + + private List parsePolygon(String points) { + final List parsed = new ArrayList<>(); + for (String segment : Objects.requireNonNullElse(points, "").trim().split(" ")) { + final String normalized = segment.trim(); + if (normalized.isBlank()) { + continue; + } + final String[] pair = normalized.split(","); + if (pair.length != 2) { + throw new TiledUnsupportedFeatureException("Invalid polygon point format in Tiled XML."); + } + parsed.add(new TiledPoint(Double.parseDouble(pair[0]), Double.parseDouble(pair[1]))); + } + return List.copyOf(parsed); + } + + private String csv(List gids, int width) { + final StringBuilder builder = new StringBuilder(); + for (int index = 0; index < gids.size(); index += 1) { + if (index > 0) { + builder.append(index % Math.max(width, 1) == 0 ? ",\n" : ","); + } + builder.append(Long.toUnsignedString(gids.get(index))); + } + return builder.toString(); + } + + private String polygonPoints(List points) { + final StringBuilder builder = new StringBuilder(); + for (int index = 0; index < points.size(); index += 1) { + if (index > 0) { + builder.append(' '); + } + builder.append(formatDecimal(points.get(index).x())) + .append(',') + .append(formatDecimal(points.get(index).y())); + } + return builder.toString(); + } + + private int intAttribute(Element element, String attribute, int fallback) { + final String value = element.getAttribute(attribute); + if (value == null || value.isBlank()) { + return fallback; + } + return Integer.parseInt(value.trim()); + } + + private double doubleAttribute(Element element, String attribute, double fallback) { + final String value = element.getAttribute(attribute); + if (value == null || value.isBlank()) { + return fallback; + } + return Double.parseDouble(value.trim()); + } + + private String formatDecimal(double value) { + if (Math.rint(value) == value) { + return Long.toString(Math.round(value)); + } + return String.format(Locale.ROOT, "%.6f", value) + .replaceAll("0+$", "") + .replaceAll("\\.$", ""); + } + + private Element child(Element parent, String name) { + for (Element child : childElements(parent)) { + if (name.equals(child.getTagName())) { + return child; + } + } + return null; + } + + private List children(Element parent, String name) { + final List elements = new ArrayList<>(); + for (Element child : childElements(parent)) { + if (name.equals(child.getTagName())) { + elements.add(child); + } + } + return List.copyOf(elements); + } + + private List childElements(Element parent) { + final NodeList children = parent.getChildNodes(); + final List elements = new ArrayList<>(); + for (int index = 0; index < children.getLength(); index += 1) { + final Node node = children.item(index); + if (node instanceof Element element) { + elements.add(element); + } + } + return List.copyOf(elements); + } +} diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/wizards/AddAssetWizard.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/wizards/AddAssetWizard.java index f32b2b82..9f98d430 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/wizards/AddAssetWizard.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/wizards/AddAssetWizard.java @@ -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 assetFamilyCombo = new ComboBox<>(); + private final ComboBox glyphSpecializationCombo = new ComboBox<>(); private final ComboBox outputFormatCombo = new ComboBox<>(); private final ComboBox 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()) { diff --git a/prometeu-studio/src/main/resources/i18n/messages.properties b/prometeu-studio/src/main/resources/i18n/messages.properties index d3f0dd0e..dc049dad 100644 --- a/prometeu-studio/src/main/resources/i18n/messages.properties +++ b/prometeu-studio/src/main/resources/i18n/messages.properties @@ -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. diff --git a/prometeu-studio/src/main/resources/themes/default-prometeu.css b/prometeu-studio/src/main/resources/themes/default-prometeu.css index 1b05e790..3e5910ab 100644 --- a/prometeu-studio/src/main/resources/themes/default-prometeu.css +++ b/prometeu-studio/src/main/resources/themes/default-prometeu.css @@ -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; diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/bank/AssetDetailsBankCompositionCoordinatorTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/bank/AssetDetailsBankCompositionCoordinatorTest.java index b632b9e9..bf27fbfd 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/bank/AssetDetailsBankCompositionCoordinatorTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/bank/AssetDetailsBankCompositionCoordinatorTest.java @@ -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); diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingCoordinatorTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingCoordinatorTest.java index e8e3bc54..b50d68d5 100644 --- a/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingCoordinatorTest.java +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/palette/AssetDetailsPaletteOverhaulingCoordinatorTest.java @@ -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 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 palette = (Map) 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 selectedFiles, - List availableFiles) { + AssetStudioGlyphMaterialMetadata metadata, + List> 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) file.metadata().get("palette")).toList(), + new AssetWorkspaceBankCompositionDetails(List.of(), selectedFiles, 0L), + pipelinePalettes, + metadata, + null, + AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, List.of()); } - private List 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 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)))); } } diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/schema/AssetDetailsColorSchemaCoordinatorTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/schema/AssetDetailsColorSchemaCoordinatorTest.java new file mode 100644 index 00000000..932749db --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/details/schema/AssetDetailsColorSchemaCoordinatorTest.java @@ -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 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 colors) { + final List 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)); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataServiceTest.java new file mode 100644 index 00000000..817c0806 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/metadata/AssetStudioMetadataServiceTest.java @@ -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()); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/scene/SceneBankWorkflowServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/scene/SceneBankWorkflowServiceTest.java new file mode 100644 index 00000000..67f9d4de --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/scene/SceneBankWorkflowServiceTest.java @@ -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"), """ + + + + + 0 + + + """); + + 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()); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationServiceTest.java new file mode 100644 index 00000000..36892416 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledAssetGenerationServiceTest.java @@ -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()); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledXmlCodecTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledXmlCodecTest.java new file mode 100644 index 00000000..b7401de8 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/tiled/TiledXmlCodecTest.java @@ -0,0 +1,55 @@ +package p.studio.workspaces.assets.tiled; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +final class TiledXmlCodecTest { + private final TiledXmlCodec codec = new TiledXmlCodec(); + + @TempDir + Path tempDir; + + @Test + void readsFixtureTmxAndPreservesWaveOneSurfaces() throws Exception { + final TiledMapDocument map = codec.readMap(Path.of("..", "test-projects", "main", "assets", "scenes", "primeiro mapa.tmx").toAbsolutePath().normalize()); + + assertEquals(25, map.width()); + assertEquals(25, map.height()); + assertEquals(1, map.tilesets().size()); + assertEquals("../Zelda3/primeiro tileset.tsx", map.tilesets().getFirst().source()); + assertEquals(1, map.tileLayers().size()); + assertEquals(625, map.tileLayers().getFirst().gids().size()); + assertEquals(1, map.objectLayers().size()); + assertEquals(9, map.objectLayers().getFirst().objects().size()); + } + + @Test + void readsFixtureTsxWithPropertiesAndCollisionObjects() throws Exception { + final TiledTilesetDocument tileset = codec.readTileset(Path.of("..", "test-projects", "main", "assets", "Zelda3", "primeiro tileset.tsx").toAbsolutePath().normalize()); + + assertEquals(32, tileset.tileWidth()); + assertEquals(32, tileset.tileHeight()); + assertEquals(13, tileset.tileCount()); + assertEquals(13, tileset.tiles().size()); + assertEquals("glyph_id", tileset.tiles().getFirst().properties().getFirst().name()); + assertNotNull(tileset.tiles().getFirst().collisionLayer()); + assertEquals(2, tileset.tiles().getFirst().collisionLayer().objects().size()); + } + + @Test + void rejectsUnsupportedInfiniteMaps() throws Exception { + final Path path = tempDir.resolve("infinite.tmx"); + Files.writeString(path, """ + + + """); + + assertThrows(TiledUnsupportedFeatureException.class, () -> codec.readMap(path)); + } +} diff --git a/test-projects/main/assets/.prometeu/cache.json b/test-projects/main/assets/.prometeu/cache.json index 429a81d8..3bbf1bd3 100644 --- a/test-projects/main/assets/.prometeu/cache.json +++ b/test-projects/main/assets/.prometeu/cache.json @@ -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" : [ ] + } ] } ] } \ No newline at end of file diff --git a/test-projects/main/assets/.prometeu/index.json b/test-projects/main/assets/.prometeu/index.json index 468bd983..98e3ab87 100644 --- a/test-projects/main/assets/.prometeu/index.json +++ b/test-projects/main/assets/.prometeu/index.json @@ -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 } ] } \ No newline at end of file diff --git a/test-projects/main/assets/Zelda3/asset.json b/test-projects/main/assets/Zelda3/asset.json index bb68d1a5..4c681869 100644 --- a/test-projects/main/assets/Zelda3/asset.json +++ b/test-projects/main/assets/Zelda3/asset.json @@ -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" } ] } \ No newline at end of file diff --git a/test-projects/main/assets/Zelda3/glyph-material.json b/test-projects/main/assets/Zelda3/glyph-material.json new file mode 100644 index 00000000..f46273ce --- /dev/null +++ b/test-projects/main/assets/Zelda3/glyph-material.json @@ -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" : [ ] +} \ No newline at end of file diff --git a/test-projects/main/assets/Zelda3/primeiro tileset.tsx b/test-projects/main/assets/Zelda3/primeiro tileset.tsx new file mode 100644 index 00000000..8c6d5f60 --- /dev/null +++ b/test-projects/main/assets/Zelda3/primeiro tileset.tsx @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-projects/main/assets/Zelda3/studio.asset.json b/test-projects/main/assets/Zelda3/studio.asset.json new file mode 100644 index 00000000..0da96cd5 --- /dev/null +++ b/test-projects/main/assets/Zelda3/studio.asset.json @@ -0,0 +1,4 @@ +{ + "schema_version" : 1, + "glyph_bank_specialization" : "sprites" +} diff --git a/test-projects/main/assets/bigode/studio.asset.json b/test-projects/main/assets/bigode/studio.asset.json new file mode 100644 index 00000000..0da96cd5 --- /dev/null +++ b/test-projects/main/assets/bigode/studio.asset.json @@ -0,0 +1,4 @@ +{ + "schema_version" : 1, + "glyph_bank_specialization" : "sprites" +} diff --git a/test-projects/main/assets/recovered/atlas2/asset.json b/test-projects/main/assets/recovered/atlas2/asset.json index 0d1e4ef6..8583b5b2 100644 --- a/test-projects/main/assets/recovered/atlas2/asset.json +++ b/test-projects/main/assets/recovered/atlas2/asset.json @@ -16,6 +16,8 @@ }, "artifacts" : [ { "file" : "confirm.png", - "index" : 0 + "index" : 0, + "artifact_id" : "art-8c6bdad24eb1", + "fingerprint" : "aa7d241deabcebe29a6096e14eaf16fdc06cf06380c11a507620b00fc7bff094" } ] } \ No newline at end of file diff --git a/test-projects/main/assets/recovered/atlas2/studio.asset.json b/test-projects/main/assets/recovered/atlas2/studio.asset.json new file mode 100644 index 00000000..0da96cd5 --- /dev/null +++ b/test-projects/main/assets/recovered/atlas2/studio.asset.json @@ -0,0 +1,4 @@ +{ + "schema_version" : 1, + "glyph_bank_specialization" : "sprites" +} diff --git a/test-projects/main/assets/recovered/bbb2/studio.asset.json b/test-projects/main/assets/recovered/bbb2/studio.asset.json new file mode 100644 index 00000000..0da96cd5 --- /dev/null +++ b/test-projects/main/assets/recovered/bbb2/studio.asset.json @@ -0,0 +1,4 @@ +{ + "schema_version" : 1, + "glyph_bank_specialization" : "sprites" +} diff --git a/test-projects/main/assets/recovered/one-more-atlas/studio.asset.json b/test-projects/main/assets/recovered/one-more-atlas/studio.asset.json new file mode 100644 index 00000000..0da96cd5 --- /dev/null +++ b/test-projects/main/assets/recovered/one-more-atlas/studio.asset.json @@ -0,0 +1,4 @@ +{ + "schema_version" : 1, + "glyph_bank_specialization" : "sprites" +} diff --git a/test-projects/main/assets/scenes/primeiro mapa.tmx b/test-projects/main/assets/scenes/primeiro mapa.tmx index 7e9d8842..b9e08573 100644 --- a/test-projects/main/assets/scenes/primeiro mapa.tmx +++ b/test-projects/main/assets/scenes/primeiro mapa.tmx @@ -1,6 +1,6 @@ - - + + 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 @@ + + + + + + + + + + diff --git a/test-projects/main/assets/scenes/primeiro tileset.tsx b/test-projects/main/assets/scenes/primeiro tileset.tsx deleted file mode 100644 index 897df20e..00000000 --- a/test-projects/main/assets/scenes/primeiro tileset.tsx +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/test-projects/main/assets/scenes/scene1.tiled-project b/test-projects/main/assets/scenes/scene1.tiled-project deleted file mode 100644 index d0eb5920..00000000 --- a/test-projects/main/assets/scenes/scene1.tiled-project +++ /dev/null @@ -1,14 +0,0 @@ -{ - "automappingRulesFile": "", - "commands": [ - ], - "compatibilityVersion": 1100, - "extensionsPath": "extensions", - "folders": [ - "." - ], - "properties": [ - ], - "propertyTypes": [ - ] -} diff --git a/test-projects/main/assets/scenes/scene1.tiled-session b/test-projects/main/assets/scenes/scene1.tiled-session deleted file mode 100644 index 79d10b59..00000000 --- a/test-projects/main/assets/scenes/scene1.tiled-session +++ /dev/null @@ -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 -} diff --git a/test-projects/main/assets/tiled/asset.json b/test-projects/main/assets/tiled/asset.json new file mode 100644 index 00000000..b3bb9e9d --- /dev/null +++ b/test-projects/main/assets/tiled/asset.json @@ -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 + } +} diff --git a/test-projects/main/assets/tiled/layer-1.tmx b/test-projects/main/assets/tiled/layer-1.tmx new file mode 100644 index 00000000..f7fe6b88 --- /dev/null +++ b/test-projects/main/assets/tiled/layer-1.tmx @@ -0,0 +1,24 @@ + + + + + +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 + + + diff --git a/test-projects/main/assets/tiled/scene-bank.studio.json b/test-projects/main/assets/tiled/scene-bank.studio.json new file mode 100644 index 00000000..83539516 --- /dev/null +++ b/test-projects/main/assets/tiled/scene-bank.studio.json @@ -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" : "" + } ] +} \ No newline at end of file diff --git a/test-projects/main/assets/tileset/asset.json b/test-projects/main/assets/tileset/asset.json new file mode 100644 index 00000000..9f325bcf --- /dev/null +++ b/test-projects/main/assets/tileset/asset.json @@ -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" + } ] +} \ No newline at end of file diff --git a/test-projects/main/assets/tileset/glyph-material.json b/test-projects/main/assets/tileset/glyph-material.json new file mode 100644 index 00000000..1409f486 --- /dev/null +++ b/test-projects/main/assets/tileset/glyph-material.json @@ -0,0 +1,76 @@ +{ + "schema_version" : 1, + "schemas" : [ { + "schema_id" : "schema-1", + "status" : "ok", + "version" : 5, + "material_indices" : { + "1" : "#489848", + "2" : "#407840", + "3" : "#286840", + "4" : "#285830", + "5" : "#282828", + "6" : "#887040", + "7" : "#A89060", + "8" : "#605030" + }, + "artifact_bindings" : [ { + "artifact_id" : "art-67db3cae2605", + "fingerprint" : "8771c3e294d0edbc8dd3aaa6995a6db32166ae6bfb3e063170cc79b298d17e71" + }, { + "artifact_id" : "art-ee733c010b67", + "fingerprint" : "5d81eef8be13043330b42b546e10beed8ba00dfaa42df67477a8778c2b9484d7" + }, { + "artifact_id" : "art-ab1efa5013f0", + "fingerprint" : "3250840c4fe08c134ea966ce7c8fcaccae2c2a45660d7173c5e262a2e5076548" + }, { + "artifact_id" : "art-453fed2836ce", + "fingerprint" : "fe3ab2b7eb3b10a042de14484d53e718e6b505edb1ad19053668565bfe3a8f25" + }, { + "artifact_id" : "art-52434bb77d67", + "fingerprint" : "7fcbf90e1e9a69d6ce3be80ea1901d66c9992f68dc72e4c636dcd2ad52fa2898" + }, { + "artifact_id" : "art-edc19227b5c8", + "fingerprint" : "451fc8d646049944d5aa7c7dc254636eec0151aaff6e2051059304b9d6ebc31e" + }, { + "artifact_id" : "art-a1131a305e06", + "fingerprint" : "aa757df4600823ec6655235a1081061777124ccee74b31481bbe402e53635f53" + }, { + "artifact_id" : "art-e8a9cff3fb90", + "fingerprint" : "38162157f5685cce25b835d3a2caee3465fd2ec3ee5db2a5089d7d5ae5ea3bb7" + }, { + "artifact_id" : "art-b1513b30e866", + "fingerprint" : "8cab5b3d553af9ed371bd19fc624763accc90e5d926d300ba31dd8d9d6bab803" + }, { + "artifact_id" : "art-ff5ad76c4e5e", + "fingerprint" : "d9ff5a6df0f65f5fcfb8f22376beeb266d429e2c6e9c82f51c12bc6ee4c3fcdc" + }, { + "artifact_id" : "art-704961ee43df", + "fingerprint" : "b9ff45c77f7779a6acebcc537c2ddac0e27e91162b656a04dc4772ec5f24fc26" + }, { + "artifact_id" : "art-895e14841cc2", + "fingerprint" : "43f370324fedccda888d49409e2a64f182f08b4143b4bdd5bafaf498feebd21a" + }, { + "artifact_id" : "art-21bacf6b47b9", + "fingerprint" : "cb047e655f4e196f22ca0048149df5dcb959f895faeaeb94920182d4a2d16b38" + }, { + "artifact_id" : "art-4c80bb3fc4ba", + "fingerprint" : "25eba17b67dd5159f5fb9d3edd531d54a44d1c76f997385840dac53517b98886" + }, { + "artifact_id" : "art-58970a0dffaf", + "fingerprint" : "32ca96c14290de8009fd2db623f55514bed2345b7d5881a80dd3fe6235012854" + }, { + "artifact_id" : "art-8d83870d938b", + "fingerprint" : "7a093fe00c805bcf38d4facff14a6d4c2b8baeaa2d553fa8d3db9f2d76b32f3b" + }, { + "artifact_id" : "art-b94f1f9218ea", + "fingerprint" : "77c5277220421226d16f58b1bcfb4eaf157fa4f6c740c5aaebefaf88fb91c22e" + } ] + } ], + "palettes" : [ { + "palette_id" : "schema-schema-1-original", + "schema_id" : "schema-1", + "version" : 5, + "colors" : [ "#489848", "#407840", "#286840", "#285830", "#282828", "#887040", "#A89060", "#605030" ] + } ] +} \ No newline at end of file diff --git a/test-projects/main/assets/tileset/studio.asset.json b/test-projects/main/assets/tileset/studio.asset.json new file mode 100644 index 00000000..6cb32c30 --- /dev/null +++ b/test-projects/main/assets/tileset/studio.asset.json @@ -0,0 +1,4 @@ +{ + "schema_version" : 1, + "glyph_bank_specialization" : "tileset" +} \ No newline at end of file diff --git a/test-projects/main/assets/tileset/tileset-001.png b/test-projects/main/assets/tileset/tileset-001.png new file mode 100644 index 00000000..54348549 Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-001.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.001.png b/test-projects/main/assets/tileset/tileset-002.001.png new file mode 100644 index 00000000..4d695187 Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.001.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.002.png b/test-projects/main/assets/tileset/tileset-002.002.png new file mode 100644 index 00000000..7eb501eb Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.002.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.003.png b/test-projects/main/assets/tileset/tileset-002.003.png new file mode 100644 index 00000000..ae489bfb Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.003.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.004.png b/test-projects/main/assets/tileset/tileset-002.004.png new file mode 100644 index 00000000..d183bb90 Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.004.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.005.png b/test-projects/main/assets/tileset/tileset-002.005.png new file mode 100644 index 00000000..cdc0ecac Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.005.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.006.png b/test-projects/main/assets/tileset/tileset-002.006.png new file mode 100644 index 00000000..0df0f815 Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.006.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.007.png b/test-projects/main/assets/tileset/tileset-002.007.png new file mode 100644 index 00000000..e0b1d1cc Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.007.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.008.png b/test-projects/main/assets/tileset/tileset-002.008.png new file mode 100644 index 00000000..a22da4dd Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.008.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.009.png b/test-projects/main/assets/tileset/tileset-002.009.png new file mode 100644 index 00000000..7d26c963 Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.009.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.010.png b/test-projects/main/assets/tileset/tileset-002.010.png new file mode 100644 index 00000000..6ab60f70 Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.010.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.011.png b/test-projects/main/assets/tileset/tileset-002.011.png new file mode 100644 index 00000000..8f076a02 Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.011.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.012.png b/test-projects/main/assets/tileset/tileset-002.012.png new file mode 100644 index 00000000..7221f608 Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.012.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.013.png b/test-projects/main/assets/tileset/tileset-002.013.png new file mode 100644 index 00000000..fd388547 Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.013.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.014.png b/test-projects/main/assets/tileset/tileset-002.014.png new file mode 100644 index 00000000..c0270a08 Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.014.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.015.png b/test-projects/main/assets/tileset/tileset-002.015.png new file mode 100644 index 00000000..a4b14698 Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.015.png differ diff --git a/test-projects/main/assets/tileset/tileset-002.016.png b/test-projects/main/assets/tileset/tileset-002.016.png new file mode 100644 index 00000000..17eb30db Binary files /dev/null and b/test-projects/main/assets/tileset/tileset-002.016.png differ diff --git a/test-projects/main/assets/tileset/tileset.tsx b/test-projects/main/assets/tileset/tileset.tsx new file mode 100644 index 00000000..fa17fe0b --- /dev/null +++ b/test-projects/main/assets/tileset/tileset.tsx @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test-projects/main/assets/tileset/toco.aseprite b/test-projects/main/assets/tileset/toco.aseprite new file mode 100644 index 00000000..3cf96c2f Binary files /dev/null and b/test-projects/main/assets/tileset/toco.aseprite differ diff --git a/test-projects/main/assets/ui/atlas2/asset.json b/test-projects/main/assets/ui/atlas2/asset.json index 9a56a709..4e3d2877 100644 --- a/test-projects/main/assets/ui/atlas2/asset.json +++ b/test-projects/main/assets/ui/atlas2/asset.json @@ -25,6 +25,8 @@ }, "artifacts" : [ { "file" : "right-palette.png", - "index" : 0 + "index" : 0, + "artifact_id" : "art-92ac40fe2160", + "fingerprint" : "0526996900bdeef23c72fe987fb45800f11895946dcd5f0f7f8390c879069a37" } ] } \ No newline at end of file diff --git a/test-projects/main/assets/ui/atlas2/glyph-material.json b/test-projects/main/assets/ui/atlas2/glyph-material.json new file mode 100644 index 00000000..fdfe0ffc --- /dev/null +++ b/test-projects/main/assets/ui/atlas2/glyph-material.json @@ -0,0 +1,21 @@ +{ + "schema_version" : 1, + "schemas" : [ { + "schema_id" : "schema-1", + "status" : "ok", + "version" : 1, + "material_indices" : { + "1" : "#FBF236", + "2" : "#E58198", + "3" : "#2BD142", + "4" : "#503DD7", + "5" : "#23AB67", + "6" : "#DA594B" + }, + "artifact_bindings" : [ { + "artifact_id" : "art-92ac40fe2160", + "fingerprint" : "0526996900bdeef23c72fe987fb45800f11895946dcd5f0f7f8390c879069a37" + } ] + } ], + "palettes" : [ ] +} \ No newline at end of file diff --git a/test-projects/main/assets/ui/atlas2/studio.asset.json b/test-projects/main/assets/ui/atlas2/studio.asset.json new file mode 100644 index 00000000..0da96cd5 --- /dev/null +++ b/test-projects/main/assets/ui/atlas2/studio.asset.json @@ -0,0 +1,4 @@ +{ + "schema_version" : 1, + "glyph_bank_specialization" : "sprites" +} diff --git a/test-projects/main/assets/ui/one-more-atlas/studio.asset.json b/test-projects/main/assets/ui/one-more-atlas/studio.asset.json new file mode 100644 index 00000000..0da96cd5 --- /dev/null +++ b/test-projects/main/assets/ui/one-more-atlas/studio.asset.json @@ -0,0 +1,4 @@ +{ + "schema_version" : 1, + "glyph_bank_specialization" : "sprites" +}