Compare commits

...

3 Commits

Author SHA1 Message Date
1fa9d13044
housekeeping e clean up
All checks were successful
JaCoCo Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 59.32% (17061/28759) * Branch Coverage: 51.84% (6536/12607) * Lines of Code: 28759 * Cyclomatic Complexity: 11399 #### Quality Gates Summary Output truncated.
Test / Build skipped: 11, passed: 601
Intrepid/Prometeu/Studio/pipeline/pr-master This commit looks good
2026-05-01 11:19:54 +01:00
913b44c8dd
(fixes) changes on commit behaviour 2026-05-01 11:15:36 +01:00
822e892c82
Studio scene pack contract for runtime SCENE binary payload 2026-05-01 06:59:42 +01:00
59 changed files with 189781 additions and 1016 deletions

View File

@ -1,12 +0,0 @@
{"type":"meta","next_id":{"DSC":12,"AGD":12,"DEC":9,"PLN":12,"LSN":26,"CLSN":1}}
{"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-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":"open","ticket":"studio-debugger-workspace-integration","title":"Integrate ../debugger into Studio as a dedicated workspace","created_at":"2026-03-30","updated_at":"2026-03-30","tags":["studio","debugger","workspace","integration","shell"],"agendas":[{"id":"AGD-0009","file":"AGD-0009-studio-debugger-workspace-integration.md","status":"open","created_at":"2026-03-30","updated_at":"2026-03-30"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"discussion","id":"DSC-0010","status":"review","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-30","tags":["studio","editor","workspace","multi-frontend","lsp-deferred"],"agendas":[{"id":"AGD-0010","file":"AGD-0010-studio-code-editor-workspace-foundations.md","status":"accepted","created_at":"2026-03-30","updated_at":"2026-03-30"}],"decisions":[{"id":"DEC-0008","file":"DEC-0008-studio-code-editor-read-only-workspace-foundations.md","status":"review","created_at":"2026-03-30","updated_at":"2026-03-30","ref_agenda":"AGD-0010"}],"plans":[],"lessons":[]}
{"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"}]}

View File

@ -0,0 +1,31 @@
{"type":"meta","next_id":{"DSC":31,"AGD":34,"DEC":31,"PLN":62,"LSN":47,"CLSN":1}}
{"type":"discussion","id":"DSC-0030","status":"done","ticket":"studio-scene-pack-runtime-binary-contract","title":"Studio scene pack contract for runtime SCENE binary payload","created_at":"2026-04-24","updated_at":"2026-05-01","tags":["studio","packer","runtime","scene","asset-pack","binary-format","tiled"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0046","file":"discussion/lessons/DSC-0030-studio-scene-pack-runtime-binary-contract/LSN-0046-canonical-scene-owns-editorial-truth-while-pack-stays-request-driven.md","status":"done","created_at":"2026-05-01","updated_at":"2026-05-01"}]}
{"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":"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"},{"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":"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":"abandoned","ticket":"tilemap-and-metatile-runtime-binary-layout","title":"Tilemap and Metatile Runtime Binary Layout","created_at":"2026-03-26","updated_at":"2026-04-24","tags":["packer","legacy-import","tilemap","metatile","runtime-layout"],"agendas":[{"id":"AGD-0004","file":"AGD-0004-tilemap-and-metatile-runtime-binary-layout.md","status":"abandoned","created_at":"2026-03-26","updated_at":"2026-04-24","_override_reason":"Explicit user request on 2026-04-24 to abandon AGD-0004 because the agenda is no longer valid."}],"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":[]}
{"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"}]}
{"type":"discussion","id":"DSC-0013","status":"done","ticket":"studio-editor-write-wave-supported-non-frontend-files","title":"Definir a wave inicial de edicao no Code Editor apenas para arquivos aceitos e nao relacionados ao FE","created_at":"2026-03-31","updated_at":"2026-04-02","tags":["studio","editor","workspace","write","read-only","vfs","frontend-boundary"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0028","file":"discussion/lessons/DSC-0013-studio-editor-write-wave-supported-non-frontend-files/LSN-0028-controlled-editor-write-wave-and-read-only-frontend-semantic-phase.md","status":"done","created_at":"2026-04-02","updated_at":"2026-04-02"}]}
{"type":"discussion","id":"DSC-0014","status":"done","ticket":"studio-frontend-owned-semantic-editor-presentation","title":"Definir ownership do schema visual semantico do editor por frontend","created_at":"2026-04-02","updated_at":"2026-04-02","tags":["studio","editor","frontend","presentation","semantic-highlighting","compiler","pbs"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0029","file":"discussion/lessons/DSC-0014-studio-frontend-owned-semantic-editor-presentation/LSN-0029-frontend-owned-semantic-presentation-descriptor-and-host-consumption.md","status":"done","created_at":"2026-04-02","updated_at":"2026-04-02"}]}
{"type":"discussion","id":"DSC-0015","status":"done","ticket":"pbs-service-facade-reserved-metadata","title":"SDK Service Bodies Calling Builtin/Intrinsic Proxies as Ordinary PBS Code","created_at":"2026-04-03","updated_at":"2026-04-03","tags":["compiler","pbs","sdk","stdlib","lowering","service","intrinsic","sdk-interface"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0030","file":"discussion/lessons/DSC-0015-pbs-service-facade-reserved-metadata/LSN-0030-sdk-service-bodies-over-private-reserved-proxies.md","status":"done","created_at":"2026-04-03","updated_at":"2026-04-03"}]}
{"type":"discussion","id":"DSC-0016","status":"done","ticket":"studio-editor-scope-guides-and-brace-anchoring","title":"Scope Guides do Code Editor com ancoragem exata em braces e destaque do escopo ativo","created_at":"2026-04-03","updated_at":"2026-04-06","tags":["studio","editor","scope-guides","braces","semantic-read","frontend-contract"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0034","file":"discussion/lessons/DSC-0016-studio-editor-scope-guides-and-brace-anchoring/LSN-0034-active-scope-gutter-and-frontend-owned-structural-anchors.md","status":"done","created_at":"2026-04-06","updated_at":"2026-04-06"}]}
{"type":"discussion","id":"DSC-0017","status":"done","ticket":"studio-editor-inline-type-hints-for-let-bindings","title":"Inline Type Hints for Let Bindings in the Studio Editor","created_at":"2026-04-03","updated_at":"2026-04-06","tags":["studio","editor","inline-hints","inlay-hints","lsp","pbs","type-inference"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0035","file":"discussion/lessons/DSC-0017-studio-editor-inline-type-hints-for-let-bindings/LSN-0035-frontend-owned-inline-hints-with-decorative-inline-rendering.md","status":"done","created_at":"2026-04-06","updated_at":"2026-04-06"}]}
{"type":"discussion","id":"DSC-0018","status":"done","ticket":"studio-project-local-studio-state-under-dot-studio","title":"Persist project-local Studio state under .studio","created_at":"2026-04-04","updated_at":"2026-04-04","tags":["studio","project-session","project-state","persistence","dot-studio","shell","layout","setup"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0031","file":"discussion/lessons/DSC-0018-studio-project-local-studio-state-under-dot-studio/LSN-0031-project-local-studio-state-and-lifecycle-safe-layout-restoration.md","status":"done","created_at":"2026-04-04","updated_at":"2026-04-04"}]}
{"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-0043","file":"discussion/lessons/DSC-0022-studio-play-stop-and-debugger-integration/LSN-0043-studio-play-stop-and-debugger-integration.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}
{"type":"discussion","id":"DSC-0023","status":"done","ticket":"gradle-version-catalog-migration","title":"Move dependency version management to Version Catalog (TOML)","created_at":"2026-04-07","updated_at":"2026-04-07","tags":["gradle","buildSrc","dependency-management","version-catalog"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0037","file":"discussion/lessons/DSC-0023-gradle-version-catalog-migration/LSN-0037-gradle-version-catalog-adoption.md","status":"done","created_at":"2026-04-07","updated_at":"2026-04-07"}]}

View File

@ -1,5 +1,5 @@
{"type":"meta","next_id":{"DSC":31,"AGD":33,"DEC":29,"PLN":59,"LSN":46,"CLSN":1}}
{"type":"discussion","id":"DSC-0030","status":"accepted","ticket":"studio-scene-pack-runtime-binary-contract","title":"Studio scene pack contract for runtime SCENE binary payload","created_at":"2026-04-24","updated_at":"2026-04-27","tags":["studio","packer","runtime","scene","asset-pack","binary-format","tiled"],"agendas":[{"id":"AGD-0032","file":"AGD-0032-studio-scene-pack-runtime-binary-contract.md","status":"accepted","created_at":"2026-04-24","updated_at":"2026-04-27"}],"decisions":[],"plans":[],"lessons":[]}
{"type":"meta","next_id":{"DSC":31,"AGD":34,"DEC":31,"PLN":62,"LSN":47,"CLSN":1}}
{"type":"discussion","id":"DSC-0030","status":"done","ticket":"studio-scene-pack-runtime-binary-contract","title":"Studio scene pack contract for runtime SCENE binary payload","created_at":"2026-04-24","updated_at":"2026-05-01","tags":["studio","packer","runtime","scene","asset-pack","binary-format","tiled"],"agendas":[],"decisions":[],"plans":[],"lessons":[{"id":"LSN-0046","file":"discussion/lessons/DSC-0030-studio-scene-pack-runtime-binary-contract/LSN-0046-canonical-scene-owns-editorial-truth-while-pack-stays-request-driven.md","status":"done","created_at":"2026-05-01","updated_at":"2026-05-01"}]}
{"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":"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":[]}

View File

@ -0,0 +1,60 @@
---
id: LSN-0046
ticket: studio-scene-pack-runtime-binary-contract
title: Canonical scene owns editorial truth while pack stays request-driven
created: 2026-05-01
tags: [studio, packer, runtime, scene, tiled, import-export]
---
## Context
`Scene Bank` needed a stable path from Studio authoring to runtime `SCENE` bytes without letting editor-specific files become the product contract. The first accepted direction closed the `studio -> packer -> runtime` boundary correctly, but the initial editorial implementation still treated `TMX` as if it were the unit of truth. That created friction because the supported external editor (`Tiled`) was being modeled around the wrong ownership boundary.
The final state of `DSC-0030` keeps the publication contract from `DEC-0029` and replaces only the mistaken editorial model through `DEC-0030`.
## Key Decisions
### Request-driven publication boundary
**What:** Publication of `Scene Bank` assets uses a canonical `studio -> packer` request that packer compiles directly into runtime `SCENE` payload bytes.
**Why:** This keeps `prometeu-packer` isolated from `TMX`, `TSX`, and any future editor-specific format. It also preserves runtime ownership of `glyph_asset_id -> slot` resolution instead of leaking runtime operational details back into Studio.
**Trade-offs:** Studio must project editorial state into a separate publication representation, so the system carries more than one representation of the same scene on purpose.
### Studio-owned canonical scene entity
**What:** The primary editorial source of truth is a Studio-native canonical scene entity, not `TMX`, not `TSX`, and not the pack request itself.
**Why:** The external editor format should remain replaceable. If `TMX` becomes the canonical model, Studio evolution gets trapped by Tiled semantics and every future interoperability choice becomes harder.
**Trade-offs:** Studio now owns more editorial modeling and validation logic internally instead of delegating shape decisions to external files.
### Tiled as compatibility, not authority
**What:** `TMX (Tiled)` scene import/export and `TSX (Tiled)` glyph export are compatibility workflows around the canonical scene entity.
**Why:** This preserves interoperability with the supported wave-1 tooling while making it explicit that external files are adapters, not authoritative persistence.
**Trade-offs:** Import must reject unsupported structural mutations instead of silently absorbing arbitrary external edits.
## Patterns and Algorithms
- Keep three representations distinct: Studio canonical scene entity, canonical pack request, and runtime `SCENE` bytes.
- Validate canonical scene integrity in `Analyse` and/or `Pack`, not by treating editor files as the lifecycle owner.
- Use import compatibility checks before mutating canonical state, and fail without partial rewrite on unsupported structural divergence.
- Keep packer request-driven so tests can prove scene packing succeeds without parsing `TMX` or `TSX`.
## Pitfalls
- Do not let a supported editor file become a hidden canonical model just because it is easy to serialize.
- Do not collapse the canonical Studio scene entity into the pack request; publication shape and editorial shape serve different purposes.
- Do not reintroduce `TMX per layer` or any equivalent workflow that makes scene cohesion secondary.
- Do not relax the packer boundary by teaching packer to read editor-native files directly.
## Takeaways
- Scene editorial truth belongs to Studio.
- Scene publication truth belongs to the canonical request.
- Runtime operational truth belongs to the runtime `SCENE` contract and slot resolution logic.
- Import/export support should stay replaceable as long as it materializes the same request boundary.

View File

@ -1,242 +0,0 @@
---
id: AGD-0032
ticket: studio-scene-pack-runtime-binary-contract
title: Studio scene pack contract for runtime SCENE binary payload
status: accepted
created: 2026-04-24
resolved: 2026-04-27
decision:
tags: [studio, packer, runtime, scene, asset-pack, binary-format, tiled]
---
## Pain
O runtime ja possui um payload binario `SCENE` versionado e com decoder real, mas o Studio/paker ainda para em `TMX`/`TSX` e nao fecha a publicacao desse asset para `assets.pa`.
Isso deixa um gap claro entre:
- authoring/editorial workflow no dominio `studio`;
- materializacao e packing no dominio `packer`;
- contrato efetivamente consumido no dominio `runtime`.
Dominio owner desta agenda: `studio`.
Boundary explicito: `studio -> packer -> runtime`.
## Context
- O runtime define `SCENE` v1 com magic `SCNE`, version `1`, `4` layers fixas, metadata vazio no header do asset table e payload binario por layer.
- O decoder de `SCENE` ja valida:
version;
layer count;
tile size;
parallax factor;
`width * height == tile_count`;
`decoded_size`;
ausencia de trailing bytes.
- O runtime tambem ja instala `SceneBank` em scene slots, mas a dependencia de glyph por layer agora eh `glyph_asset_id`, nao mais `glyph_bank_id`.
- O runtime resolve `glyph_asset_id -> glyph slot` internamente durante `bind_scene` e `render_frame`, mantendo a alocacao de slot como concern operacional de runtime.
- O Studio hoje trata `Scene Bank` como asset owner de authoring/validation, com `scene-bank.studio.json`, `TMX` por layer e `TSX` gerado por glyph bank especializado em tileset.
- A decision consolidada em `DSC-0028` fechou o modelo wave 1 de authoring em `TMX`/`TSX`, mas deixou explicitamente o scene packing runtime como concern posterior.
- O packer atual reconhece `SCENE_BANK` como familia e `SCENE/tilemap_v1` como output format, mas ainda nao faz walk/materialization de scene e falha deliberadamente no pack execution para familias fora de `GLYPH_BANK`.
Findings tecnicos relevantes desta agenda:
1. O formato binario ja existe no runtime e nao esta “a decidir”.
2. O packer atual nao produz payload `SCENE`.
3. O maior buraco contratual hoje nao eh o layout binario em si, mas a traducao editorial de `TMX`/`TSX` para os campos exigidos pelo runtime.
4. O campo mais sensivel continua sendo a dependencia de glyph por layer, mas o runtime atual passou a usar `glyph_asset_id` no `SCENE` e resolver `asset_id -> slot` internamente.
5. Isso remove do Studio/paker a necessidade de conhecer previamente o glyph slot operacional do runtime para serializar a scene.
## Open Questions
- [x] Quem deve ser o owner da compilacao `TMX`/`TSX -> SCENE`: `prometeu-packer` embutido no Studio, uma camada editorial do Studio, ou outro componente?
Resposta atual da discussao:
o `Asset Workspace` gera e usa `TMX`/`TSX` apenas como support/editorial surfaces;
esses formatos nao devem ser empurrados para o `prometeu-packer`;
o `prometeu-packer` deve receber um protocolo proprio de requisicao;
o `Scene Bank` no Studio deve validar e extrair, no passo de `Accept`, os dados necessarios dos artefatos editoriais para esse request.
- [x] Qual deve ser o contrato canonico da dependencia de glyph por layer?
Resposta validada contra o runtime atual:
o payload `SCENE` deve carregar `glyph_asset_id`;
a resolucao `asset_id -> slot` pertence ao runtime;
Studio e packer nao precisam serializar slot de glyph no contrato da scene.
- [x] O `Scene Bank` deve persistir `glyph_asset_id` explicitamente no metadata/request do Studio ou derivar isso sempre a partir do asset referenciado na layer?
Resposta atual da discussao:
`glyph_asset_id` eh derivado do tileset/glyph asset selecionado no form da layer, mantendo uma unica fonte da verdade.
- [x] Onde devem viver os `parallax_factor` por layer, que o runtime exige mas o metadata atual do Studio nao modela?
Resposta atual da discussao:
`parallax_factor` deve ser adicionado ao form da layer no Studio.
- [x] De onde vem o `palette_id` por layer/tile no request atual?
Resposta atual da discussao:
`palette_id` ainda nao existe no workflow atual e deve ser adicionado ao form da layer no Studio, ou em surface editorial equivalente no mesmo nivel.
- [x] Como o workflow editorial indica se uma layer entra ou nao na publicacao?
Resposta atual da discussao:
o checkbox existente no form da layer indica se a layer esta ativa/sera usada.
- [x] Qual eh a regra oficial de traducao de tiles do Tiled para records do payload runtime:
`gid` base;
flip flags;
`glyph_id`;
`palette_id`?
Resposta atual da discussao:
cada tile eh mapeado pelo `TSX`, e o `TSX` aponta para o glyph asset;
`flip` e `palette_id` por tile podem vir como properties no `TMX`, por serem dados do tile especifico;
`glyph_id` ja existe como property no `TSX`.
- [x] O wave 1 continua estritamente limitado a um `TSX` por `TMX`/layer tambem no scene pack runtime, ou precisamos abrir espaco para multi-tileset antes de fechar o contrato?
Resposta atual da discussao:
sim, wave 1 continua com exatamente `1 TSX` por `TMX`/layer.
- [x] A aceitacao no Studio deve ser gate obrigatorio para publicar `SCENE` em pack, ou apenas um estado editorial recomendado?
Resposta atual da discussao:
o criterio continua sendo o usual do workflow: nenhum diagnostic error bloqueante.
- [x] O `SCENE/tilemap_v1` no manifest representa o payload binario runtime final, ou ainda esta sendo usado ambiguamente para o estado editorial baseado em `TMX`?
Resposta atual da discussao:
vamos manter `SCENE/tilemap_v1` por enquanto;
o runtime nao exige mudanca desse nome neste momento.
- [x] Qual deve ser o shape exato do request `studio -> packer`?
Resposta atual da discussao:
o request deve ser construido backwards a partir dos dados do payload binario `SCENE`, como um data class o mais proximo possivel do binario final.
Shape atual assentado na agenda:
```text
ScenePackRequest
- version: int
- layers: [ScenePackLayerRequest; 4]
ScenePackLayerRequest
- active: bool
- glyph_asset_id: int
- tile_size: int
- parallax_factor_x: float
- parallax_factor_y: float
- width: int
- height: int
- tiles: List<ScenePackTileRequest>
ScenePackTileRequest
- active: bool
- glyph_id: int
- palette_id: int
- flip_x: bool
- flip_y: bool
```
Observacoes do shape assentado:
`request -> SCENE` deve ser quase isomorfico;
`tile_count` pode ser derivado de `width * height`;
campos reservados do binario nao precisam existir no request.
## Options
### Option A - Studio Accepts Tiled and Emits a Packer-Owned Scene Request
- **Approach:** manter o Studio como owner de authoring, validation e acceptance de `TMX`/`TSX`, e fazer o `Scene Bank` aceito extrair um request proprio e estavel para o `prometeu-packer`; o packer compila esse request para o payload binario `SCENE` sem conhecer `TMX`/`TSX`.
- **Pro:** preserva `TMX`/`TSX` como concern editorial do Studio e evita contaminar o packer com detalhes de tooling externo.
- **Con:** exige fechar um boundary formal novo entre Studio e packer, incluindo o schema desse request.
- **Maintainability:** boa, desde que o contrato editorial/runtime fique explicito e versionado.
### Option B - Add a Studio-Owned Intermediate Scene Representation First
- **Approach:** antes de empacotar, introduzir um modelo normalizado intermediario no Studio ou em shared tooling, e so depois converter esse modelo para o payload binario runtime.
- **Pro:** pode simplificar validacao e testes sem depender diretamente de parsing de `TMX` no packer.
- **Con:** reabre o risco de criar uma segunda surface de scene alem de `TMX`, contrariando a direcao recente de evitar schema paralelo cedo demais.
- **Maintainability:** media; melhora isolamento interno, mas adiciona outra camada contratual para manter.
### Option C - Make Packer Parse TMX/TSX Directly
- **Approach:** fazer o `prometeu-packer` ler `TMX`/`TSX` diretamente e produzir o payload runtime a partir dessas surfaces editoriais.
- **Pro:** reduz o numero aparente de etapas entre authoring e pack final.
- **Con:** acopla o packer a uma surface editorial do Studio e ao tooling externo, o que contraria a direcao explicitada nesta discussao.
- **Maintainability:** fraca; qualquer evolucao do authoring/editorial tende a vazar para o contrato operacional do packer.
## Discussion
O estado atual sugere que o layout binario do runtime ja esta suficientemente maduro para servir como contrato de destino. A discussao principal nao deveria voltar para “qual eh o formato binario da scene”, e sim para “como o Studio/paker chegam nele de forma disciplinada”.
Ha uma restricao importante aqui: a decision anterior sobre `Scene Bank` em Assets e `TMX`/`TSX` como surfaces wave 1 continua valida. Portanto esta agenda nao deve reinterpretar `DSC-0028` como se o Studio precisasse ganhar um scene workspace, um editor pesado ou um schema proprietario paralelo por conveniencia.
Direcao ja explicitada nesta thread:
- `TMX` e `TSX` ficam confinados ao dominio editorial do Studio;
- o `Asset Workspace` deve conseguir validar e extrair do estado aceito os dados relevantes para pack;
- o `prometeu-packer` nao deve conhecer Tiled nem depender de `TMX`/`TSX`;
- o boundary Studio -> packer deve ser um protocolo proprio, estavel e independente das surfaces editoriais.
Os pontos mais arriscados no boundary sao:
1. `glyph_asset_id`:
o runtime agora aceita identidade estavel de asset na scene e resolve `asset_id -> slot` internamente; a discussao ja convergiu que esse valor eh derivado do tileset/glyph asset selecionado no form da layer.
2. `parallax_factor`:
o runtime exige esse campo por layer; a direcao atual eh adiciona-lo ao form da layer, no mesmo nivel editorial de configuracoes como dimensao do mapa.
3. traducao de tiles:
a direcao atual ja convergiu que cada tile eh resolvido pelo `TSX`, com `flip` e `palette_id` podendo vir do `TMX`; o request do packer deve preservar esses campos de forma quase isomorfica ao record runtime.
4. `palette_id`:
a direcao atual eh adiciona-lo ao form da layer, com possibilidade de override por tile via properties editoriais no `TMX`.
5. gate editorial:
o checkbox de layer ativa ja cobre o uso editorial da layer, e o gate atual para publicar continua sendo ausencia de diagnostic error bloqueante.
## Settled So Far
Os pontos abaixo ficam cravados nesta agenda como direcao atual da discussao:
1. `TMX` e `TSX` permanecem surfaces editoriais do Studio e nao entram no protocolo operacional do packer.
2. O `Asset Workspace` deve extrair, no passo de `Accept`, um request proprio para o `prometeu-packer`.
3. O payload runtime `SCENE` carrega `glyph_asset_id`, e a resolucao `asset_id -> slot` pertence ao runtime.
4. `glyph_asset_id` eh derivado do tileset/glyph asset selecionado no form da layer.
5. `parallax_factor` deve existir no form da layer.
6. `palette_id` deve existir no form da layer, ou em surface editorial equivalente no mesmo nivel.
7. O checkbox da layer define se ela esta ativa/sera usada.
8. Cada tile eh resolvido via `TSX`; `glyph_id` ja existe como property no `TSX`.
9. `flip` e `palette_id` por tile podem vir como properties no `TMX`.
10. Wave 1 continua com exatamente `1 TSX` por `TMX`/layer.
11. O gate atual para publicar continua sendo ausencia de diagnostic error bloqueante.
12. O request `studio -> packer` deve ser derivado backwards do payload binario e `request -> SCENE` deve ser o mais direto possivel.
13. O shape atual assentado do request e:
`ScenePackRequest(version, layers[4])`;
`ScenePackLayerRequest(active, glyph_asset_id, tile_size, parallax_factor_x, parallax_factor_y, width, height, tiles)`;
`ScenePackTileRequest(active, glyph_id, palette_id, flip_x, flip_y)`.
14. O manifest permanece com `SCENE/tilemap_v1` por enquanto.
## Recommendation
Seguir com a `Option A`.
Direcao recomendada:
1. manter `TMX`/`TSX` como surfaces editoriais wave 1 no dominio `studio`;
2. fechar uma decision nova para o boundary `studio/packer/runtime` do `Scene Bank`;
3. fazer o `Asset Workspace` aceitar e extrair do estado editorial um request proprio para o packer;
4. fazer o `prometeu-packer` ser owner apenas da compilacao desse request para o payload binario `SCENE`;
5. explicitar na decision:
modelagem da dependencia `glyph_asset_id`;
modelagem de `parallax_factor`;
modelagem de `palette_id`;
semantica da layer ativa no request;
gate baseado em diagnostics bloqueantes;
formato do request `studio -> packer`, derivado backwards do payload binario;
semantica oficial de `SCENE/tilemap_v1`.
Essa direcao reaproveita o que ja existe, mantem o packer livre das ferramentas editoriais do Studio e concentra a publicacao runtime no subsistema que ja monta `assets.pa`.
## Next Step Suggestion
Converter esta agenda em uma decision focada no contrato de compilacao/publicacao de `Scene Bank`, cobrindo:
1. ownership por dominio;
2. formato do request `studio -> packer`, backwards a partir do payload binario;
3. mapping editorial -> request -> runtime, com `request -> SCENE` o mais direto possivel;
4. modelagem e validacao de `glyph_asset_id` por layer;
5. gating de diagnostics para pack;
6. propagation targets em `prometeu-packer`, `prometeu-studio` e specs relevantes.
## Resolution
Agenda aceita.
Direcao consolidada:
1. `TMX`/`TSX` permanecem estritamente editoriais no dominio `studio`.
2. O `Scene Bank` aceito deve extrair um request proprio e estavel para o `prometeu-packer`.
3. O request `studio -> packer` deve ser derivado backwards do payload binario `SCENE` e `request -> SCENE` deve ser o mais direto possivel.
4. O contrato runtime de dependencia de glyph por layer usa `glyph_asset_id`, com resolucao `asset_id -> slot` pertencendo ao runtime.
5. O workflow editorial do Studio deve prover `glyph_asset_id` derivado do tileset selecionado, `parallax_factor`, `palette_id`, estado ativo da layer e a traducao por tile a partir de `TSX`/`TMX`.
6. Wave 1 permanece com exatamente `1 TSX` por `TMX`/layer.
7. O gate para publicacao continua sendo ausencia de diagnostic error bloqueante.
8. O manifest permanece com `SCENE/tilemap_v1` por enquanto.
Proximo passo sugerido: converter esta agenda em uma `decision` normativa para o boundary `studio -> packer -> runtime`.

View File

@ -221,7 +221,8 @@ let status: int = Composer.emit_sprite(7, 0, 12, 18, 0, 2, false, false, 1);
Rules:
- `@sdk:composer` is the canonical sprite-composition module in this wave.
- `@sdk:composer` is the canonical frame-composition module in this wave.
- `Composer.bind_scene(...)`, `Composer.unbind_scene()`, and `Composer.emit_sprite(...)` belong to that module.
- `@sdk:gfx` remains valid for primitive and overlay-oriented graphics operations.
- `Gfx.set_sprite` is not part of the active stdlib line `1` contract.

View File

@ -141,6 +141,14 @@ For example, the PBS-facing declaration:
```pbs
declare host LowComposer {
[Host(module = "composer", name = "bind_scene", version = 1)]
[Capability(name = "gfx")]
fn bind_scene(scene_bank_id: int) -> int;
[Host(module = "composer", name = "unbind_scene", version = 1)]
[Capability(name = "gfx")]
fn unbind_scene() -> int;
[Host(module = "composer", name = "emit_sprite", version = 1)]
[Capability(name = "gfx")]
fn emit_sprite(
@ -160,6 +168,8 @@ declare host LowComposer {
maps to the canonical runtime identity:
```text
("composer", "bind_scene", 1)
("composer", "unbind_scene", 1)
("composer", "emit_sprite", 1)
```

View File

@ -254,6 +254,8 @@ Rules:
- stdlib line `1` MUST expose the sprite-composition interface module at `@sdk:composer`,
- that module MUST declare `LowComposer` and public service `Composer`,
- that module MUST use runtime module `composer` and capability `gfx`,
- that module MUST expose `bind_scene(scene_bank_id: int) -> int`,
- that module MUST expose `unbind_scene() -> int`,
- that module MUST expose `emit_sprite(glyph_id: int, palette_id: int, x: int, y: int, layer: int, bank_id: int, flip_x: bool, flip_y: bool, priority: int) -> int`,
- stdlib line `1` MUST NOT expose `Gfx.set_sprite`,
- stdlib line `1` MUST expose the low-level asset interface module at `@sdk:asset`,

View File

@ -172,11 +172,15 @@ Rules:
- `@sdk:composer` MUST expose a low-level host owner `LowComposer`.
- `@sdk:composer` MUST expose a public service facade `Composer`.
- `Composer.bind_scene(scene_bank_id: int)` MUST lower through canonical host identity `("composer", "bind_scene", 1)`.
- `Composer.unbind_scene()` MUST lower through canonical host identity `("composer", "unbind_scene", 1)`.
- `Composer.emit_sprite(...)` MUST lower through canonical host identity `("composer", "emit_sprite", 1)`.
- `Composer.bind_scene(...)` MUST return a raw `int` in stdlib line `1`.
- `Composer.unbind_scene()` MUST return a raw `int` in stdlib line `1`.
- `Composer.emit_sprite(...)` MUST return a raw `int` in this wave.
- `@sdk:gfx` MAY continue to expose primitive and overlay-oriented operations.
- `@sdk:gfx` MUST NOT expose `set_sprite`.
- `bind_scene`, `unbind_scene`, and `set_camera` are deferred and are not required members of `@sdk:composer` in this wave.
- `set_camera` may remain deferred if not yet exposed by the active stdlib line.
## 13. Stdlib Contract Expectations for Builtin MVP

View File

@ -97,6 +97,18 @@ Rules:
- `output.pipeline` carries tooling/build metadata kept separate at authoring time;
- codec must remain explicit and must not be hidden inside format naming.
### Scene Bank Contract Boundary
For `Scene Bank` assets, `output.format = SCENE/tilemap_v1` identifies the runtime-facing scene contract.
Rules:
- `SCENE/tilemap_v1` is a runtime/publication format, not an editor-file format;
- `TMX`, `TSX`, and other editor-specific files may exist as tooling/editorial surfaces, but they are not the canonical publication contract;
- the canonical packer input for `Scene Bank` publication is the editor-agnostic Scene Bank request emitted by Studio from canonical scene state;
- packer must not reinterpret the presence of editorial files as authorization to bypass the request boundary;
- packer must not assume any external scene file is canonical persistence, even when Studio supports that file as import/export compatibility.
### Explicit Index Collections
When `output.pipeline` carries ordered collections whose members need stable semantic identity, that identity must not depend on raw list position alone.

View File

@ -212,6 +212,65 @@ Rules:
- any tile in the bank may be rendered with any palette in the bank at runtime;
- palette selection is a runtime draw concern, not a tile-payload embedding concern.
## Format-Specific Baseline: `SCENE/tilemap_v1`
`SCENE/tilemap_v1` is the wave-1 Scene Bank publication format.
### Canonical Build Input
Rules:
- packer receives a canonical editor-agnostic Scene Bank request as its publication input;
- packer must not parse `TMX`, `TSX`, or other editor-specific source files directly as part of runtime scene publication;
- the request is produced by Studio from canonical scene state after canonical validation;
- equivalent requests must produce equivalent emitted `SCENE` payloads.
Baseline request shape:
```text
ScenePackRequest
- version: int
- layers: [ScenePackLayerRequest; 4]
ScenePackLayerRequest
- active: bool
- glyph_asset_id: int
- tile_size: int
- parallax_factor_x: float
- parallax_factor_y: float
- width: int
- height: int
- tiles: List<ScenePackTileRequest>
ScenePackTileRequest
- active: bool
- glyph_id: int
- palette_id: int
- flip_x: bool
- flip_y: bool
```
### Request Semantics
Rules:
- the request always materializes exactly `4` layer entries in wave 1;
- `tile_count` is derived from `width * height`;
- `glyph_asset_id` is the runtime-facing glyph dependency identity carried by the scene payload;
- `active` at layer scope determines whether the layer participates in emitted scene output;
- `active` at tile scope determines whether the tile contributes an active tile record to the emitted scene payload;
- `palette_id` uses a layer-owned editorial default with optional per-tile override before the request reaches packer;
- packer consumes only the resolved per-tile `palette_id` values present in the canonical request;
- request-to-binary projection should remain as close to isomorphic as possible.
### Wave-1 Constraints
Rules:
- wave 1 remains limited to exactly one logical tileset/glyph source per layer before request emission;
- packer does not own editor-format interoperability rules; it trusts the canonical request boundary once the request is structurally valid;
- binary reserved fields must not require corresponding request fields unless a later decision gives them editorial meaning.
## Determinism
Equivalent build inputs must produce equivalent outputs.

View File

@ -157,6 +157,25 @@ At minimum, the model should support fields equivalent to:
- `applied_actions`
- `blockers`
## Scene Bank Studio Integration Boundary
Scene Bank publication is a split workflow across Studio and packer.
Rules:
- Studio owns canonical scene validation and projection of the canonical Scene Bank request;
- packer owns compilation of the canonical Scene Bank request into runtime `SCENE` output;
- packer must report structural diagnostics against the canonical Scene Bank request artifact when that artifact is missing, malformed, or inconsistent with the locked format rules;
- packer must not report editor-file parsing diagnostics as if `TMX` or `TSX` were direct runtime build inputs.
### Request Boundary Visibility
Rules:
- the Studio-to-packer handoff for Scene Bank publication must be a canonical request artifact or equivalent request surface derived from canonical Studio scene state;
- if external compatibility files change without corresponding Studio import/update, packer must continue to treat the canonical request boundary as authoritative rather than silently reparsing editor files;
- future supported editors may replace or coexist with Tiled-backed interoperability only if Studio continues to emit the same canonical request boundary.
## Asset Event Lane
The system includes a dedicated asset workspace event/reporting lane.

View File

@ -258,6 +258,101 @@ Rules:
- Primary actions for unregistered assets include `Register`.
- `Register` is a direct action in Studio UX. If registration is valid, clicking it must register immediately without an extra acknowledgement step.
- If registration is blocked, Studio may surface the blocking preview inline so the user can inspect why registration cannot proceed.
## Scene Bank Editorial and Publication Rules
`Scene Bank` is an asset-owned workflow inside `Assets`.
Rules:
- `Scene Bank` authoring, diagnostics, canonical validation, and external-format interoperability are owned by the `Assets` workspace;
- the primary editorial surface for `Scene Bank` is a Studio-owned canonical scene entity;
- wave-1 `TMX` and `TSX` integration is a supported import/export compatibility surface, not the canonical asset model and not the canonical publication contract;
- supported external formats may be replaced or coexist with other editors in the future only if Studio continues to materialize the same canonical Scene Bank publication request from canonical scene state;
- `Assets` must not treat Tiled files as the canonical asset model by accident.
### Scene Bank Ownership Boundary
The `Assets` workspace owns the editorial side of `Scene Bank` publication.
Rules:
- Studio owns the canonical Scene Bank scene entity and its editorial persistence;
- Studio owns layer enablement, dimensions, linked glyph/tileset asset binding, layer default palette data, layer parallax data, and equivalent scene-owned editorial state;
- Studio owns import from supported external formats into canonical scene state;
- Studio owns export from canonical scene state into supported external formats;
- Studio owns projection from canonical scene state into the canonical publication request;
- packer owns only compilation of that canonical request into runtime `SCENE` bytes.
### Canonical Publication Request
The canonical publication contract from Studio to packer is the Scene Bank request.
Baseline shape:
```text
ScenePackRequest
- version: int
- layers: [ScenePackLayerRequest; 4]
ScenePackLayerRequest
- active: bool
- glyph_asset_id: int
- tile_size: int
- parallax_factor_x: float
- parallax_factor_y: float
- width: int
- height: int
- tiles: List<ScenePackTileRequest>
ScenePackTileRequest
- active: bool
- glyph_id: int
- palette_id: int
- flip_x: bool
- flip_y: bool
```
Rules:
- the request is derived from canonical scene state, not from editor-native files directly;
- the request must remain editor-agnostic and must not embed `TMX`, `TSX`, or other editor-specific source structures as the publication contract;
- wave 1 materializes exactly `4` layer entries;
- `tile_count` is derived from `width * height`;
- request fields reserved only for binary layout must not be surfaced unless they gain explicit editorial meaning.
### Scene Bank Editorial Mapping Rules
Rules:
- `glyph_asset_id` is derived from the glyph asset selected for the layer;
- `parallax_factor_x` and `parallax_factor_y` are layer-owned editorial fields in Studio;
- `palette_id` has a layer-owned default and may be overridden per tile when the supported editorial surface provides an explicit override;
- layer `active` determines whether the layer participates in publication output;
- tile `active` determines whether the tile contributes an active tile record to the published request;
- canonical scene persistence must not depend on `TMX`, `TSX`, or any other external file format remaining present on disk.
### External Format Interoperability Rules
Rules:
- Studio must expose supported scene and glyph-bank interoperability through explicit import/export workflow;
- wave-1 Tiled interoperability consists of `TMX (Tiled)` scene import/export and `TSX (Tiled)` glyph tileset export;
- scene import must validate minimum structural compatibility before mutating canonical scene state;
- scene import must fail explicitly, with logs or diagnostics, when an external file contains unsupported structural divergence such as unexpected layers, unexpected tilesets, or equivalent unsupported mutations;
- failed scene import must not silently partially rewrite canonical scene state;
- scene `TMX` export must overwrite the destination file if present, or create it if absent;
- glyph tileset `TSX` export must overwrite the destination file if present, or create it if absent;
- exported external files are compatibility artifacts and must not become the canonical source of truth.
### Canonical Validation Rules
Rules:
- publication-ready Scene Bank state requires absence of blocking diagnostics against canonical scene state;
- Studio must validate canonical scene state during `Analyse` and/or `Pack`;
- Studio may validate external files for import compatibility during import, but that validation does not replace canonical scene validation;
- the old `validate layer`, `accept layer`, `validate scene from TMX`, and `accept scene from TMX` workflow must not define Scene Bank readiness.
- Sensitive actions such as `Exclude From Build`, `Remove`, and `Relocate` must be visually separated from routine actions.
- Sensitive actions must not look interchangeable with routine actions.
- `Relocate` must collect an explicit user-chosen destination root before the staged preview is generated.

View File

@ -1,4 +1,16 @@
declare host LowComposer {
[Host(module = "composer", name = "set_camera", version = 1)]
[Capability(name = "gfx")]
fn set_camera(x: int, y: int) -> void;
[Host(module = "composer", name = "bind_scene", version = 1)]
[Capability(name = "gfx")]
fn bind_scene(scene_bank_id: int) -> int;
[Host(module = "composer", name = "unbind_scene", version = 1)]
[Capability(name = "gfx")]
fn unbind_scene() -> int;
[Host(module = "composer", name = "emit_sprite", version = 1)]
[Capability(name = "gfx")]
fn emit_sprite(
@ -16,6 +28,21 @@ declare host LowComposer {
declare service Composer
{
fn set_camera(x: int, y: int) -> void
{
LowComposer.set_camera(x, y);
}
fn bind_scene(scene_bank_id: int) -> int
{
return LowComposer.bind_scene(scene_bank_id);
}
fn unbind_scene() -> int
{
return LowComposer.unbind_scene();
}
fn emit_sprite(
glyph_id: int,
palette_id: int,

View File

@ -116,6 +116,16 @@ class PbsGateUSdkInterfaceConformanceTest {
&& h.abiModule().equals("gfx")
&& h.abiMethod().equals("clear")
&& h.abiVersion() == 1));
assertTrue(positive.irBackend().getReservedMetadata().hostMethodBindings().stream()
.anyMatch(h -> h.ownerName().equals("LowComposer")
&& h.abiModule().equals("composer")
&& h.abiMethod().equals("bind_scene")
&& h.abiVersion() == 1));
assertTrue(positive.irBackend().getReservedMetadata().hostMethodBindings().stream()
.anyMatch(h -> h.ownerName().equals("LowComposer")
&& h.abiModule().equals("composer")
&& h.abiMethod().equals("unbind_scene")
&& h.abiVersion() == 1));
assertTrue(positive.irBackend().getReservedMetadata().hostMethodBindings().stream()
.anyMatch(h -> h.ownerName().equals("LowComposer")
&& h.abiModule().equals("composer")

View File

@ -103,6 +103,14 @@ class InterfaceModuleLoaderTest {
"main.pbs",
"""
declare host LowComposer {
[Host(module = "composer", name = "bind_scene", version = 1)]
[Capability(name = "gfx")]
fn bind_scene(scene_bank_id: int) -> int;
[Host(module = "composer", name = "unbind_scene", version = 1)]
[Capability(name = "gfx")]
fn unbind_scene() -> int;
[Host(module = "composer", name = "emit_sprite", version = 1)]
[Capability(name = "gfx")]
fn emit_sprite(
@ -119,6 +127,14 @@ class InterfaceModuleLoaderTest {
}
declare service Composer {
fn bind_scene(scene_bank_id: int) -> int {
return LowComposer.bind_scene(scene_bank_id);
}
fn unbind_scene() -> int {
return LowComposer.unbind_scene();
}
fn emit_sprite(
glyph_id: int,
palette_id: int,

View File

@ -1069,6 +1069,85 @@ class PBSFrontendPhaseServiceTest {
&& h.abiMethod().equals("emit_sprite")));
}
@Test
void shouldLowerSdkComposerSceneBindingFacadesUsingCanonicalComposerContract() throws IOException {
final var projectRoot = tempDir.resolve("project-bootstrap-sdk-composer-scene-binding");
final var sourceRoot = projectRoot.resolve("src");
final var modulePath = sourceRoot.resolve("app");
Files.createDirectories(modulePath);
final var sourceFile = modulePath.resolve("source.pbs");
final var modBarrel = modulePath.resolve("mod.barrel");
Files.writeString(sourceFile, """
import { Composer } from @sdk:composer;
fn bind_scene_now() -> int
{
return Composer.bind_scene(4);
}
fn unbind_scene_now() -> int
{
return Composer.unbind_scene();
}
[Frame]
fn frame() -> void
{
bind_scene_now();
unbind_scene_now();
return;
}
""");
Files.writeString(modBarrel, """
pub fn bind_scene_now() -> int;
pub fn unbind_scene_now() -> int;
pub fn frame() -> void;
""");
final var projectTable = new ProjectTable();
final var fileTable = new FileTable(1);
final var projectId = projectTable.register(ProjectDescriptor.builder()
.rootPath(projectRoot)
.name("app")
.version("1.0.0")
.sourceRoots(ReadOnlyList.wrap(List.of(sourceRoot)))
.build());
registerFile(projectId, projectRoot, sourceFile, fileTable);
registerFile(projectId, projectRoot, modBarrel, fileTable);
final var ctx = new FrontendPhaseContext(
projectTable,
fileTable,
new BuildStack(ReadOnlyList.wrap(List.of(projectId))),
1);
final var diagnostics = DiagnosticSink.empty();
final var irBackend = new PBSFrontendPhaseService().compile(
ctx,
diagnostics,
LogAggregator.empty(),
BuildingIssueSink.empty());
assertFalse(diagnostics.hasErrors(), diagnostics.stream()
.map(d -> d.getCode() + ":" + d.getMessage())
.toList()
.toString());
assertTrue(diagnostics.stream().noneMatch(d ->
d.getCode().equals(PbsSemanticsErrors.E_SEM_EXEC_LOWERING_UNRESOLVED_CALLEE.name())));
assertTrue(irBackend.getReservedMetadata().hostMethodBindings().stream()
.anyMatch(h -> h.ownerName().equals("LowComposer")
&& h.sourceMethodName().equals("bind_scene")
&& h.abiModule().equals("composer")
&& h.abiMethod().equals("bind_scene")));
assertTrue(irBackend.getReservedMetadata().hostMethodBindings().stream()
.anyMatch(h -> h.ownerName().equals("LowComposer")
&& h.sourceMethodName().equals("unbind_scene")
&& h.abiModule().equals("composer")
&& h.abiMethod().equals("unbind_scene")));
}
@Test
void shouldRejectLegacyGfxSetSpriteAfterComposerMigration() throws IOException {
final var projectRoot = tempDir.resolve("project-bootstrap-sdk-gfx-set-sprite-removed");

View File

@ -0,0 +1,37 @@
package p.packer.messages.assets;
import java.util.List;
import java.util.Objects;
public record ScenePackLayerRequest(
boolean active,
int glyphAssetId,
int tileSize,
float parallaxFactorX,
float parallaxFactorY,
int width,
int height,
List<ScenePackTileRequest> tiles) {
public ScenePackLayerRequest {
tiles = List.copyOf(Objects.requireNonNull(tiles, "tiles"));
if (glyphAssetId < 0) {
throw new IllegalArgumentException("glyphAssetId must be non-negative");
}
if (tileSize < 0) {
throw new IllegalArgumentException("tileSize must be non-negative");
}
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException("width and height must be positive");
}
if (!Float.isFinite(parallaxFactorX) || parallaxFactorX < 0.0f) {
throw new IllegalArgumentException("parallaxFactorX must be finite and non-negative");
}
if (!Float.isFinite(parallaxFactorY) || parallaxFactorY < 0.0f) {
throw new IllegalArgumentException("parallaxFactorY must be finite and non-negative");
}
if (tiles.size() != width * height) {
throw new IllegalArgumentException("tiles size must match width * height");
}
}
}

View File

@ -0,0 +1,19 @@
package p.packer.messages.assets;
import java.util.List;
import java.util.Objects;
public record ScenePackRequest(
int version,
List<ScenePackLayerRequest> layers) {
public ScenePackRequest {
layers = List.copyOf(Objects.requireNonNull(layers, "layers"));
if (version <= 0) {
throw new IllegalArgumentException("version must be positive");
}
if (layers.size() != 4) {
throw new IllegalArgumentException("layers must contain exactly 4 entries");
}
}
}

View File

@ -0,0 +1,18 @@
package p.packer.messages.assets;
public record ScenePackTileRequest(
boolean active,
int glyphId,
int paletteId,
boolean flipX,
boolean flipY) {
public ScenePackTileRequest {
if (glyphId < 0) {
throw new IllegalArgumentException("glyphId must be non-negative");
}
if (paletteId < 0) {
throw new IllegalArgumentException("paletteId must be non-negative");
}
}
}

View File

@ -2,15 +2,22 @@ package p.packer.repositories;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.StringUtils;
import p.packer.messages.assets.ScenePackLayerRequest;
import p.packer.messages.assets.ScenePackRequest;
import p.packer.messages.assets.ScenePackTileRequest;
import p.packer.messages.assets.OutputFormatCatalog;
import p.packer.messages.diagnostics.PackerDiagnosticCategory;
import p.packer.messages.diagnostics.PackerDiagnosticSeverity;
import p.packer.models.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ -26,11 +33,14 @@ public class PackerAssetWalker {
private static final Set<OutputFormatCatalog> SOUND_BANK_SUPPORTED_FORMATS = Set.of(
OutputFormatCatalog.SOUND_V1
);
private static final String SCENE_PACK_REQUEST_FILE = "scene-bank.pack-request.json";
private final ObjectMapper mapper;
private final PackerGlyphBankWalker glyphBankWalker;
private final PackerSoundBankWalker soundBankWalker;
public PackerAssetWalker(final ObjectMapper mapper) {
this.mapper = mapper;
this.glyphBankWalker = new PackerGlyphBankWalker(mapper);
this.soundBankWalker = new PackerSoundBankWalker(mapper);
}
@ -101,7 +111,7 @@ public class PackerAssetWalker {
return new PackerWalkResult(walkResult.probeResults(), diagnostics);
}
case SCENE_BANK -> {
return new PackerWalkResult(List.of(), diagnostics);
return walkSceneBank(assetRoot, declaration, diagnostics);
}
case UNKNOWN -> {
diagnostics.add(new PackerDiagnostic(
@ -116,6 +126,96 @@ public class PackerAssetWalker {
return PackerWalkResult.EMPTY;
}
private PackerWalkResult walkSceneBank(
Path assetRoot,
PackerAssetDeclaration declaration,
List<PackerDiagnostic> diagnostics) {
if (declaration.outputFormat() != OutputFormatCatalog.SCENE_TILED_V1) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Unsupported output format for Scene Bank: " + declaration.outputFormat(),
assetRoot,
true));
return new PackerWalkResult(List.of(), diagnostics);
}
final Path requestPath = assetRoot.resolve(SCENE_PACK_REQUEST_FILE).toAbsolutePath().normalize();
if (!Files.isRegularFile(requestPath)) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Accepted Scene Bank request artifact is missing: " + SCENE_PACK_REQUEST_FILE,
requestPath,
true));
return new PackerWalkResult(List.of(), diagnostics);
}
try {
final byte[] bytes = Files.readAllBytes(requestPath);
final ScenePackRequest request = parseScenePackRequest(bytes);
final PackerFileProbe probe = new PackerFileProbe(
requestPath,
"application/json",
bytes.length,
Math.max(0L, Files.getLastModifiedTime(requestPath).toMillis()),
bytes);
return new PackerWalkResult(
List.of(new PackerProbeResult(
probe,
Map.of("scenePackRequest", request),
List.of())),
diagnostics);
} catch (IOException | IllegalArgumentException exception) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Unable to read accepted Scene Bank request artifact: " + exception.getMessage(),
requestPath,
true));
return new PackerWalkResult(List.of(), diagnostics);
}
}
private ScenePackRequest parseScenePackRequest(byte[] bytes) throws IOException {
final JsonNode root = mapper.readTree(bytes);
if (root == null || !root.isObject()) {
throw new IllegalArgumentException("request root must be a JSON object");
}
final String format = root.path("format").asText("SCENE/tilemap_v1").trim();
if (!"SCENE/tilemap_v1".equals(format)) {
throw new IllegalArgumentException("request format must be SCENE/tilemap_v1");
}
if (!(root.path("layers") instanceof ArrayNode layersNode) || layersNode.size() != 4) {
throw new IllegalArgumentException("request must contain exactly 4 layers");
}
final List<ScenePackLayerRequest> layers = new ArrayList<>();
for (JsonNode layerNode : layersNode) {
final int width = layerNode.path("width").asInt(0);
final int height = layerNode.path("height").asInt(0);
if (!(layerNode.path("tiles") instanceof ArrayNode tilesNode)) {
throw new IllegalArgumentException("each layer must contain a tiles array");
}
final List<ScenePackTileRequest> tiles = new ArrayList<>();
for (JsonNode tileNode : tilesNode) {
tiles.add(new ScenePackTileRequest(
tileNode.path("active").asBoolean(false),
tileNode.path("glyph_id").asInt(0),
tileNode.path("palette_id").asInt(0),
tileNode.path("flip_x").asBoolean(false),
tileNode.path("flip_y").asBoolean(false)));
}
layers.add(new ScenePackLayerRequest(
layerNode.path("active").asBoolean(false),
layerNode.path("glyph_asset_id").asInt(0),
layerNode.path("tile_size").asInt(0),
(float) layerNode.path("parallax_factor_x").asDouble(0.0d),
(float) layerNode.path("parallax_factor_y").asDouble(0.0d),
width,
height,
tiles));
}
return new ScenePackRequest(root.path("version").asInt(0), layers);
}
private RequirementBuildResult<PackerGlyphBankRequirements> buildGlyphBankRequirements(
final PackerAssetDeclaration declaration,
final Map<String, String> metadata) {

View File

@ -161,6 +161,9 @@ public final class PackerRuntimeAssetMaterializer {
if (materializationConfig.mode() != PackerWalkMode.PACKING) {
return true;
}
if (declaration.assetFamily() == p.packer.messages.assets.AssetFamilyCatalog.SCENE_BANK) {
return true;
}
if (declaration.artifacts().isEmpty()) {
return false;
}

View File

@ -40,6 +40,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 static final String SCENE_PACK_REQUEST_FILE = "scene-bank.pack-request.json";
private final ObjectMapper mapper;
private final PackerWorkspaceFoundation workspaceFoundation;
@ -699,8 +700,11 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
"name", "Layer " + index,
"width", 16,
"height", 16,
"tilemap", "layer-" + index + ".tmx",
"tileset_asset_root", ""));
"tilemap", "scene.tmx",
"tileset_asset_root", "",
"parallax_factor_x", 1.0,
"parallax_factor_y", 1.0,
"default_palette_id", 0));
}
supportFile.put("layers", layers);
mapper.writerWithDefaultPrettyPrinter().writeValue(
@ -883,7 +887,8 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
if (declaration == null) {
return List.of();
}
if (declaration.assetFamily() == AssetFamilyCatalog.GLYPH_BANK) {
if (declaration.assetFamily() == AssetFamilyCatalog.GLYPH_BANK
|| declaration.assetFamily() == AssetFamilyCatalog.SCENE_BANK) {
return List.of();
}
return List.of(new PackerDiagnostic(
@ -1054,10 +1059,14 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
private PackedAsset packRuntimeAsset(PackerRuntimeAsset runtimeAsset) {
final PackerAssetDeclaration declaration = runtimeAsset.parsedDeclaration().declaration();
if (declaration == null || declaration.assetFamily() != AssetFamilyCatalog.GLYPH_BANK) {
if (declaration == null) {
throw new IllegalStateException("Unsupported pack output family for current implementation.");
}
return packGlyphBank(runtimeAsset, declaration);
return switch (declaration.assetFamily()) {
case GLYPH_BANK -> packGlyphBank(runtimeAsset, declaration);
case SCENE_BANK -> packSceneBank(runtimeAsset, declaration);
default -> throw new IllegalStateException("Unsupported pack output family for current implementation.");
};
}
private boolean isEffectivelyIncludedInPackSet(PackerRuntimeAsset runtimeAsset) {
@ -1065,7 +1074,13 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
return false;
}
final PackerAssetDeclaration declaration = runtimeAsset.parsedDeclaration().declaration();
return declaration == null || !declaration.artifacts().isEmpty();
if (declaration == null) {
return true;
}
if (declaration.assetFamily() == AssetFamilyCatalog.SCENE_BANK) {
return true;
}
return !declaration.artifacts().isEmpty();
}
private PackedAsset packGlyphBank(PackerRuntimeAsset runtimeAsset, PackerAssetDeclaration declaration) {
@ -1076,6 +1091,7 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
final byte[] sheetPixels = new byte[width * height];
final Map<String, PackerRuntimeWalkFile> selectedByPath = new LinkedHashMap<>();
runtimeAsset.walkProjection().buildCandidateFiles().forEach(file -> selectedByPath.put(file.relativePath(), file));
final Map<Integer, Integer> declaredPaletteIndexByRgb = declaredPaletteIndexByRgb(declaration);
declaration.artifacts().stream()
.sorted(Comparator.comparingInt(PackerAssetArtifactSelection::index))
@ -1088,13 +1104,18 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
if (!(tileValue instanceof PackerTileIndexedV1 tile)) {
throw new IllegalStateException("Selected tile artifact is missing normalized tile metadata: " + artifact.file());
}
final byte[] paletteIndices = remapTilePaletteIndices(
tile,
walkFile.metadata().get("palette"),
artifact.file(),
declaredPaletteIndexByRgb);
final int tileX = (artifact.index() % tilesPerRow) * tileSize;
final int tileY = (artifact.index() / tilesPerRow) * tileSize;
for (int localY = 0; localY < tile.height(); localY += 1) {
for (int localX = 0; localX < tile.width(); localX += 1) {
final int sourceOffset = localY * tile.width() + localX;
final int targetOffset = (tileY + localY) * width + (tileX + localX);
sheetPixels[targetOffset] = tile.paletteIndices()[sourceOffset];
sheetPixels[targetOffset] = paletteIndices[sourceOffset];
}
}
});
@ -1135,6 +1156,109 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
declaration.preloadEnabled());
}
private byte[] remapTilePaletteIndices(
PackerTileIndexedV1 tile,
Object paletteMetadata,
String artifactFile,
Map<Integer, Integer> declaredPaletteIndexByRgb) {
final int[] originalArgb8888 = originalPaletteColors(paletteMetadata);
if (originalArgb8888.length == 0) {
throw new IllegalStateException("Selected tile artifact is missing palette metadata required for palette remapping: " + artifactFile);
}
final byte[] remapped = Arrays.copyOf(tile.paletteIndices(), tile.paletteIndices().length);
for (int index = 0; index < remapped.length; index += 1) {
final int paletteIndex = Byte.toUnsignedInt(remapped[index]);
if (paletteIndex == 0) {
continue;
}
if (paletteIndex > originalArgb8888.length) {
throw new IllegalStateException("Tile palette index exceeds discovered palette size for artifact: " + artifactFile);
}
final int originalArgb = originalArgb8888[paletteIndex - 1];
final Integer materialIndex = declaredPaletteIndexByRgb.get(rgb24(originalArgb));
if (materialIndex == null || materialIndex < 1 || materialIndex > 15) {
throw new IllegalStateException("Unable to resolve canonical palette index for artifact color in " + artifactFile);
}
remapped[index] = (byte) (materialIndex & 0x0F);
}
return remapped;
}
private Map<Integer, Integer> declaredPaletteIndexByRgb(PackerAssetDeclaration declaration) {
final JsonNode palettesNode = declaration.outputPipelineMetadata().get("palettes");
if (!(palettesNode instanceof ArrayNode palettesArray) || palettesArray.isEmpty()) {
throw new IllegalStateException("Glyph packing requires at least one declared pipeline palette.");
}
final JsonNode paletteNode = palettesArray.get(0).path("palette").path("originalArgb8888");
if (!paletteNode.isArray() || paletteNode.isEmpty()) {
throw new IllegalStateException("Glyph packing requires originalArgb8888 entries in the declared pipeline palette.");
}
final LinkedHashMap<Integer, Integer> mapping = new LinkedHashMap<>();
for (int index = 0; index < paletteNode.size(); index += 1) {
final JsonNode colorNode = paletteNode.get(index);
if (!colorNode.canConvertToInt()) {
throw new IllegalStateException("Declared pipeline palette contains a non-integer originalArgb8888 entry.");
}
mapping.put(rgb24(colorNode.intValue()), index + 1);
}
return Map.copyOf(mapping);
}
private int[] originalPaletteColors(Object paletteMetadata) {
if (paletteMetadata instanceof PackerPaletteV1 palette) {
return palette.originalArgb8888();
}
if (!(paletteMetadata instanceof Map<?, ?> map)) {
return new int[0];
}
final Object colors = map.get("originalArgb8888");
if (!(colors instanceof List<?> list)) {
return new int[0];
}
final int[] values = new int[list.size()];
for (int index = 0; index < list.size(); index += 1) {
final Object color = list.get(index);
if (!(color instanceof Number number)) {
return new int[0];
}
values[index] = number.intValue();
}
return values;
}
private int rgb24(int argb) {
return argb & 0x00FFFFFF;
}
private PackedAsset packSceneBank(PackerRuntimeAsset runtimeAsset, PackerAssetDeclaration declaration) {
final ScenePackRequest request = runtimeAsset.walkProjection().buildCandidateFiles().stream()
.map(file -> file.metadata().get("scenePackRequest"))
.filter(ScenePackRequest.class::isInstance)
.map(ScenePackRequest.class::cast)
.findFirst()
.orElseThrow(() -> new IllegalStateException("Accepted Scene Bank request artifact is missing from the packing snapshot: " + SCENE_PACK_REQUEST_FILE));
final byte[] payload = encodeScenePackRequest(request);
final LinkedHashMap<String, Object> runtimeMetadata = new LinkedHashMap<>();
runtimeMetadata.put("version", request.version());
runtimeMetadata.put("layer_count", request.layers().size());
runtimeMetadata.put("codec", Map.of());
final LinkedHashMap<String, Object> toolingMetadata = new LinkedHashMap<>(runtimeMetadata);
final PackerRegistryEntry registryEntry = runtimeAsset.registryEntry()
.orElseThrow(() -> new IllegalStateException("Packed runtime asset must be registered"));
return new PackedAsset(
registryEntry.assetId(),
declaration.name(),
"SCENE",
"NONE",
payload,
sceneDecodedSize(request),
runtimeMetadata,
toolingMetadata,
declaration.preloadEnabled());
}
private int parseTileSize(String value) {
return switch (Objects.requireNonNullElse(value, "")) {
case "8x8" -> 8;
@ -1192,6 +1316,67 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
return palettesNode instanceof ArrayNode palettesArray ? palettesArray.size() : 0;
}
private byte[] encodeScenePackRequest(ScenePackRequest request) {
final ByteArrayOutputStream output = new ByteArrayOutputStream();
writeBytes(output, new byte[]{'S', 'C', 'N', 'E'});
writeShortLE(output, request.version());
writeShortLE(output, request.layers().size());
writeIntLE(output, 0);
for (ScenePackLayerRequest layer : request.layers()) {
writeByte(output, layer.active() ? 0b0000_0001 : 0);
writeIntLE(output, layer.glyphAssetId());
writeByte(output, layer.tileSize());
writeShortLE(output, 0);
writeFloatLE(output, layer.parallaxFactorX());
writeFloatLE(output, layer.parallaxFactorY());
writeIntLE(output, layer.width());
writeIntLE(output, layer.height());
writeIntLE(output, layer.width() * layer.height());
writeIntLE(output, 0);
for (ScenePackTileRequest tile : layer.tiles()) {
int flags = tile.active() ? 0b0000_0001 : 0;
if (tile.flipX()) {
flags |= 0b0000_0010;
}
if (tile.flipY()) {
flags |= 0b0000_0100;
}
writeByte(output, flags);
writeByte(output, tile.paletteId());
writeShortLE(output, tile.glyphId());
}
}
return output.toByteArray();
}
private int sceneDecodedSize(ScenePackRequest request) {
int decodedSize = 0;
for (ScenePackLayerRequest layer : request.layers()) {
decodedSize += 16 + (layer.tiles().size() * 8);
}
return decodedSize;
}
private void writeIntLE(ByteArrayOutputStream output, int value) {
writeBytes(output, ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array());
}
private void writeShortLE(ByteArrayOutputStream output, int value) {
writeBytes(output, ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort((short) value).array());
}
private void writeFloatLE(ByteArrayOutputStream output, float value) {
writeBytes(output, ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putFloat(value).array());
}
private void writeByte(ByteArrayOutputStream output, int value) {
output.write(value & 0xFF);
}
private void writeBytes(ByteArrayOutputStream output, byte[] bytes) {
output.writeBytes(bytes);
}
private Map<String, Object> normalizeToolingPipelineMetadata(PackerAssetDeclaration declaration) {
return new LinkedHashMap<>(PackerReadMessageMapper.normalizeMetadata(declaration.outputPipelineMetadata()));
}

View File

@ -125,6 +125,108 @@ final class PackerRuntimeAssetMaterializerTest {
assertTrue(file.contentBytes().get().length > 0);
}
@Test
void sceneBankPackingProjectionIncludesAcceptedRequestArtifact() throws Exception {
final var materializer = new PackerRuntimeAssetMaterializer(new PackerAssetWalker(MAPPER));
final Path assetRoot = Files.createDirectories(tempDir.resolve("scene-packing"));
final Path manifestPath = assetRoot.resolve("asset.json");
Files.writeString(manifestPath, "{}");
Files.writeString(assetRoot.resolve("scene-bank.pack-request.json"), """
{
"schema_version": 1,
"format": "SCENE/tilemap_v1",
"version": 1,
"layers": [
{
"active": true,
"glyph_asset_id": 7,
"tile_size": 16,
"parallax_factor_x": 1.0,
"parallax_factor_y": 1.0,
"width": 2,
"height": 2,
"tiles": [
{ "active": true, "glyph_id": 1, "palette_id": 0, "flip_x": false, "flip_y": false },
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false },
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false },
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false }
]
},
{
"active": false,
"glyph_asset_id": 0,
"tile_size": 0,
"parallax_factor_x": 1.0,
"parallax_factor_y": 1.0,
"width": 2,
"height": 2,
"tiles": [
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false },
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false },
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false },
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false }
]
},
{
"active": false,
"glyph_asset_id": 0,
"tile_size": 0,
"parallax_factor_x": 1.0,
"parallax_factor_y": 1.0,
"width": 2,
"height": 2,
"tiles": [
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false },
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false },
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false },
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false }
]
},
{
"active": false,
"glyph_asset_id": 0,
"tile_size": 0,
"parallax_factor_x": 1.0,
"parallax_factor_y": 1.0,
"width": 2,
"height": 2,
"tiles": [
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false },
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false },
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false },
{ "active": false, "glyph_id": 0, "palette_id": 0, "flip_x": false, "flip_y": false }
]
}
]
}
""");
final PackerAssetDeclaration declaration = new PackerAssetDeclaration(
1,
"uuid",
"scene_asset",
AssetFamilyCatalog.SCENE_BANK,
List.of(),
OutputFormatCatalog.SCENE_TILED_V1,
OutputCodecCatalog.NONE,
Map.of(),
Map.of(),
true);
final var materialized = materializer.materialize(
assetRoot,
manifestPath,
Optional.of(new PackerRegistryEntry(1, "uuid", "scene_asset", true)),
new PackerAssetDeclarationParseResult(declaration, List.of()),
Optional.empty(),
PackerRuntimeMaterializationConfig.packingBuild());
assertEquals(1, materialized.runtimeAsset().walkProjection().buildCandidateFiles().size());
final var file = materialized.runtimeAsset().walkProjection().buildCandidateFiles().getFirst();
assertEquals("scene-bank.pack-request.json", file.relativePath());
assertTrue(file.contentBytes().isPresent());
assertTrue(file.metadata().get("scenePackRequest") instanceof p.packer.messages.assets.ScenePackRequest);
}
private static PackerAssetDeclaration declaration(Map<String, String> metadata) {
return declaration(metadata, List.of(), Map.of());
}

View File

@ -1,6 +1,7 @@
package p.packer.services;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@ -20,7 +21,9 @@ import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@ -351,6 +354,72 @@ final class FileSystemPackerWorkspaceServiceTest {
assertEquals(0, assetTableMetadata.get(0).path("metadata").path("pipeline").path("palettes").get(0).path("index").asInt());
}
@Test
void packWorkspaceRemapsGlyphTileIndicesUsingGlyphMaterialSchema() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-workspace-remap-material"));
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
final Path manifestPath = assetRoot.resolve("asset.json");
writeTwoColorTilePng(assetRoot.resolve("confirm.png"), 16);
final ObjectNode manifest = (ObjectNode) MAPPER.readTree(manifestPath.toFile());
final ObjectNode output = (ObjectNode) manifest.path("output");
output.putObject("metadata").put("tile_size", "16x16");
final ObjectNode pipeline = output.putObject("pipeline");
final var palettes = pipeline.putArray("palettes");
final ObjectNode palette = palettes.addObject();
palette.put("index", 0);
palette.putObject("palette")
.putArray("originalArgb8888").add(0xFF00FF00).add(0xFFFF0000);
((ObjectNode) palette.path("palette"))
.putArray("convertedRgb565").add(0x07E0).add(0xF800);
final var artifacts = manifest.putArray("artifacts");
artifacts.addObject()
.put("artifact_id", "art-confirm")
.put("file", "confirm.png")
.put("index", 0);
MAPPER.writerWithDefaultPrettyPrinter().writeValue(manifestPath.toFile(), manifest);
writeGlyphMaterialJson(assetRoot, """
{
"schema_version": 1,
"schemas": [
{
"schema_id": "schema-1",
"status": "ok",
"version": 1,
"material_indices": {
"1": "#00FF00",
"2": "#FF0000"
},
"artifact_bindings": [
{
"artifact_id": "art-confirm",
"fingerprint": "%s"
}
]
}
],
"palettes": [
{
"palette_id": "palette-1",
"schema_id": "schema-1",
"version": 1,
"colors": [ "#00FF00", "#FF0000" ]
}
]
}
""".formatted(fingerprint(assetRoot.resolve("confirm.png"))));
final FileSystemPackerWorkspaceService service = service();
final var result = service.packWorkspace(new PackWorkspaceRequest(project(projectRoot)));
assertEquals(PackerOperationStatus.SUCCESS, result.status());
final byte[] assetsPa = Files.readAllBytes(projectRoot.resolve("build/assets.pa"));
final int payloadOffset = (int) readLeLong(assetsPa, 12);
final var assetTable = MAPPER.readTree(projectRoot.resolve("build/asset_table.json").toFile());
final int assetOffset = assetTable.get(0).path("offset").asInt();
assertEquals(0x21, assetsPa[payloadOffset + assetOffset] & 0xFF);
}
@Test
void validateAndPackIgnoreBlockingDiagnosticsFromUnselectedTileFiles() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-ignore-unselected"));
@ -538,7 +607,84 @@ final class FileSystemPackerWorkspaceServiceTest {
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());
assertEquals("scene.tmx", supportFile.path("layers").get(0).path("tilemap").asText());
assertEquals(1.0d, supportFile.path("layers").get(0).path("parallax_factor_x").asDouble());
assertEquals(1.0d, supportFile.path("layers").get(0).path("parallax_factor_y").asDouble());
assertEquals(0, supportFile.path("layers").get(0).path("default_palette_id").asInt());
}
@Test
void packValidationBlocksSceneBankWhenAcceptedRequestArtifactIsMissing() throws Exception {
final Path projectRoot = tempDir.resolve("scene-pack-missing-request");
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());
enableSceneLayer(projectRoot.resolve("assets/scenes/overworld/scene-bank.studio.json"));
final var result = service.validatePackWorkspace(new ValidatePackWorkspaceRequest(project(projectRoot)));
assertEquals(PackerOperationStatus.PARTIAL, result.status());
assertFalse(result.canPack());
assertTrue(result.assets().stream()
.flatMap(asset -> asset.diagnostics().stream())
.anyMatch(diagnostic -> diagnostic.message().contains("Accepted Scene Bank request artifact is missing")));
}
@Test
void packsSceneBankFromAcceptedRequestArtifactWithoutParsingEditorFiles() throws Exception {
final Path projectRoot = tempDir.resolve("scene-pack-success");
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,
true));
assertEquals(PackerOperationStatus.SUCCESS, createResult.status());
final Path assetRoot = projectRoot.resolve("assets/scenes/overworld");
enableSceneLayer(assetRoot.resolve("scene-bank.studio.json"));
Files.writeString(assetRoot.resolve("layer-1.tmx"), "editor surface should be ignored by packer");
writeScenePackRequest(assetRoot.resolve("scene-bank.pack-request.json"));
final var result = service.packWorkspace(new PackWorkspaceRequest(project(projectRoot)));
assertEquals(PackerOperationStatus.SUCCESS, result.status());
final byte[] assetsPa = Files.readAllBytes(projectRoot.resolve("build/assets.pa"));
final int headerLength = readLeInt(assetsPa, 8);
final long payloadOffset = readLeLong(assetsPa, 12);
final byte[] payload = java.util.Arrays.copyOfRange(assetsPa, (int) payloadOffset, assetsPa.length);
assertEquals('S', payload[0]);
assertEquals('C', payload[1]);
assertEquals('N', payload[2]);
assertEquals('E', payload[3]);
final var assetTable = MAPPER.readTree(projectRoot.resolve("build/asset_table.json").toFile());
assertEquals(1, assetTable.size());
assertEquals("SCENE", assetTable.get(0).path("bank_type").asText());
assertEquals("NONE", assetTable.get(0).path("codec").asText());
assertEquals(expectedSceneDecodedSize(4, 256), assetTable.get(0).path("decoded_size").asInt());
assertEquals(1, readLeShort(payload, 4));
assertEquals(4, readLeShort(payload, 6));
assertEquals(0b0000_0001, payload[12] & 0xFF);
assertEquals(7, readLeInt(payload, 13));
assertEquals(16, payload[17] & 0xFF);
assertEquals(16, readLeInt(payload, 28));
assertEquals(16, readLeInt(payload, 32));
assertEquals(256, readLeInt(payload, 36));
assertEquals(0b0000_0011, payload[44] & 0xFF);
assertEquals(2, payload[45] & 0xFF);
assertEquals(11, readLeShort(payload, 46));
assertTrue(headerLength > 0);
}
@Test
@ -1582,6 +1728,46 @@ final class FileSystemPackerWorkspaceServiceTest {
Files.writeString(assetRoot.resolve("glyph-material.json"), content);
}
private String fingerprint(Path path) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path)));
}
private void enableSceneLayer(Path supportFilePath) throws Exception {
final ObjectNode supportFile = (ObjectNode) MAPPER.readTree(supportFilePath.toFile());
((ObjectNode) supportFile.path("layers").get(0)).put("enabled", true);
MAPPER.writerWithDefaultPrettyPrinter().writeValue(supportFilePath.toFile(), supportFile);
}
private void writeScenePackRequest(Path path) throws Exception {
final ObjectNode root = MAPPER.createObjectNode();
root.put("schema_version", 1);
root.put("format", "SCENE/tilemap_v1");
root.put("version", 1);
final ArrayNode layers = root.putArray("layers");
for (int layerIndex = 0; layerIndex < 4; layerIndex += 1) {
final ObjectNode layer = layers.addObject();
final boolean active = layerIndex == 0;
layer.put("active", active);
layer.put("glyph_asset_id", active ? 7 : 0);
layer.put("tile_size", active ? 16 : 0);
layer.put("parallax_factor_x", 1.0);
layer.put("parallax_factor_y", 1.0);
layer.put("width", 16);
layer.put("height", 16);
final ArrayNode tiles = layer.putArray("tiles");
for (int tileIndex = 0; tileIndex < 256; tileIndex += 1) {
final ObjectNode tile = tiles.addObject();
final boolean tileActive = active && tileIndex == 0;
tile.put("active", tileActive);
tile.put("glyph_id", tileActive ? 11 : 0);
tile.put("palette_id", tileActive ? 2 : 0);
tile.put("flip_x", tileActive);
tile.put("flip_y", false);
}
}
MAPPER.writerWithDefaultPrettyPrinter().writeValue(path.toFile(), root);
}
private static int readLeInt(byte[] bytes, int offset) {
return (bytes[offset] & 0xFF)
| ((bytes[offset + 1] & 0xFF) << 8)
@ -1589,6 +1775,11 @@ final class FileSystemPackerWorkspaceServiceTest {
| ((bytes[offset + 3] & 0xFF) << 24);
}
private static int readLeShort(byte[] bytes, int offset) {
return (bytes[offset] & 0xFF)
| ((bytes[offset + 1] & 0xFF) << 8);
}
private static long readLeLong(byte[] bytes, int offset) {
return ((long) bytes[offset] & 0xFF)
| (((long) bytes[offset + 1] & 0xFF) << 8)
@ -1600,6 +1791,10 @@ final class FileSystemPackerWorkspaceServiceTest {
| (((long) bytes[offset + 7] & 0xFF) << 56);
}
private static int expectedSceneDecodedSize(int layerCount, int tilesPerLayer) {
return layerCount * (16 + (tilesPerLayer * 8));
}
private static int readLeUnsignedShort(byte[] bytes, int offset) {
return (bytes[offset] & 0xFF) | ((bytes[offset + 1] & 0xFF) << 8);
}

View File

@ -157,8 +157,9 @@ 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_EXPORT_TSX("assets.action.exportTsx"),
ASSETS_ACTION_EXPORT_TMX("assets.action.exportTmx"),
ASSETS_ACTION_IMPORT_TMX("assets.action.importTmx"),
ASSETS_ACTION_VALIDATE_SCENE_BANK("assets.action.validateSceneBank"),
ASSETS_ACTION_ACCEPT_SCENE_BANK("assets.action.acceptSceneBank"),
ASSETS_ACTION_VALIDATE_LAYER("assets.action.validateLayer"),
@ -209,6 +210,8 @@ public enum I18n {
ASSETS_LABEL_TILEMAPS("assets.label.tilemaps"),
ASSETS_LABEL_TILEMAP("assets.label.tilemap"),
ASSETS_LABEL_TILESET("assets.label.tileset"),
ASSETS_LABEL_PARALLAX("assets.label.parallax"),
ASSETS_LABEL_DEFAULT_PALETTE("assets.label.defaultPalette"),
ASSETS_LABEL_SUPPORT_FILE("assets.label.supportFile"),
ASSETS_LABEL_USE_LAYER("assets.label.useLayer"),
ASSETS_VALUE_NOT_CONFIGURED("assets.value.notConfigured"),

View File

@ -170,7 +170,6 @@ public final class AssetWorkspace extends Workspace {
}
AddAssetWizard.showAndWait(root.getScene().getWindow(), projectReference).ifPresent(assetReference -> {
workspaceEventBus.publish(new StudioAssetsRefreshRequestedEvent(assetReference));
workspaceEventBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(assetReference, false));
});
}

View File

@ -2,12 +2,11 @@ package p.studio.workspaces.assets.details;
import javafx.application.Platform;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.apache.commons.collections4.CollectionUtils;
import p.packer.dtos.PackerAssetActionAvailabilityDTO;
import p.packer.dtos.PackerAssetDetailsDTO;
import p.packer.dtos.PackerDiagnosticDTO;
@ -19,41 +18,37 @@ import p.studio.controls.forms.StudioSection;
import p.studio.lsp.events.StudioWorkspaceEventBus;
import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
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.schema.AssetDetailsColorSchemaGroupsControl;
import p.studio.workspaces.assets.details.summary.AssetDetailsSummaryControl;
import p.studio.workspaces.assets.dialogs.AssetDiagnosticsDialog;
import p.studio.workspaces.assets.messages.*;
import p.studio.workspaces.assets.messages.events.*;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization;
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.tiled.TiledAssetGenerationService.SceneTmxExportDisposition;
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.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
import static java.util.Comparator.comparingInt;
public final class AssetDetailsControl extends VBox implements StudioEventAware {
private final ProjectReference projectReference;
private final StudioWorkspaceEventBus workspaceBus;
@ -93,9 +88,6 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
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");
@ -142,9 +134,7 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
loadSelection(viewState.selectedAssetReference());
return;
}
if (event.preferredAssetReference() == null) {
clearSelection();
}
clearSelection();
});
eventBindings.listen(workspaceBus, StudioAssetsWorkspaceSelectionRequestedEvent.class).handle(event -> {
if (event.forceUpdate() || !isCurrentSelection(event.assetReference())) {
@ -375,10 +365,21 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
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 exportTsxButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_EXPORT_TSX));
exportTsxButton.setDisable(actionRunning);
exportTsxButton.setOnAction(ignored -> generateTsx());
nodes.add(exportTsxButton);
}
if (canSceneTmxInteroperability()) {
final Button importTmxButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_IMPORT_TMX));
importTmxButton.setDisable(actionRunning);
importTmxButton.setOnAction(ignored -> importTmx());
nodes.add(importTmxButton);
final Button exportTmxButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_EXPORT_TMX));
exportTmxButton.setDisable(actionRunning);
exportTmxButton.setOnAction(ignored -> exportTmx());
nodes.add(exportTmxButton);
}
final Button buildParticipationButton = AssetDetailsUiSupport.createActionButton(buildParticipationActionLabel());
AssetDetailsUiSupport.applyActionTone(
@ -391,7 +392,7 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
nodes.add(buildParticipationButton);
final var orderedActions = visibleActions.stream()
.sorted(java.util.Comparator.comparingInt(action -> switch (action.action()) {
.sorted(comparingInt(action -> switch (action.action()) {
case DELETE -> 2;
default -> 1;
}))
@ -434,7 +435,6 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
viewState.selectedAssetReference(),
viewState.selectedAssetDetails().summary()).ifPresent(assetReference -> {
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(assetReference));
workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(assetReference, true));
});
}
@ -519,12 +519,10 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
&& viewState.selectedAssetDetails().summary().glyphSpecialization() == AssetStudioGlyphSpecialization.TILESET;
}
private boolean canGenerateTmx() {
return false;
}
private boolean canValidateSceneBank() {
return false;
private boolean canSceneTmxInteroperability() {
return viewState.selectedAssetDetails() != null
&& viewState.selectedAssetDetails().summary().assetFamily() == AssetFamilyCatalog.SCENE_BANK
&& viewState.selectedAssetDetails().sceneBankMetadata() != null;
}
private void generateTsx() {
@ -536,22 +534,81 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
renderActions();
final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails();
Container.backgroundTasks().submit(() -> {
final TiledAssetGenerationResult result = tiledGenerationService.generateTilesetTsx(details);
Platform.runLater(() -> applyTiledGenerationResult("glyph-bank-tileset", result));
final TiledAssetGenerationResult result = tiledGenerationService.exportTilesetTsx(details);
Platform.runLater(() -> applyTiledGenerationResult("glyph-bank-tileset-export", result));
});
}
private void generateTmx(int layerIndex) {
private void exportTmx() {
if (actionRunning || viewState.selectedAssetDetails() == null) {
return;
}
final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails();
final SceneTmxExportDisposition disposition = chooseSceneTmxExportDisposition(details);
if (disposition == null) {
return;
}
actionRunning = true;
actionFeedbackMessage = null;
Container.backgroundTasks().submit(() -> {
final TiledAssetGenerationResult result = tiledGenerationService.exportSceneBankTmx(projectReference, details, disposition);
Platform.runLater(() -> applyTiledGenerationResult("scene-bank-tmx-export", result));
});
}
private SceneTmxExportDisposition chooseSceneTmxExportDisposition(AssetWorkspaceAssetDetails details) {
final Path sceneTmxPath = resolveSceneTmxPath(details);
if (sceneTmxPath == null || !Files.isRegularFile(sceneTmxPath)) {
return SceneTmxExportDisposition.OVERWRITE;
}
final Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
if (getScene() != null) {
alert.initOwner(getScene().getWindow());
}
final ButtonType overwriteButton = new ButtonType("Overwrite", ButtonBar.ButtonData.YES);
final ButtonType keepBothButton = new ButtonType("Keep Both", ButtonBar.ButtonData.NO);
final ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
alert.setTitle("Export TMX");
alert.setHeaderText(null);
alert.setContentText("A scene.tmx file already exists. Choose how to proceed.");
alert.getButtonTypes().setAll(overwriteButton, keepBothButton, cancelButton);
final ButtonType result = alert.showAndWait().orElse(cancelButton);
if (result == overwriteButton) {
return SceneTmxExportDisposition.OVERWRITE;
}
if (result == keepBothButton) {
return SceneTmxExportDisposition.KEEP_BOTH;
}
return null;
}
private Path resolveSceneTmxPath(AssetWorkspaceAssetDetails details) {
if (details == null || details.sceneBankMetadata() == null) {
return null;
}
final LinkedHashSet<String> tilemaps = new LinkedHashSet<>();
details.sceneBankMetadata().layerBindings().forEach(binding -> {
final String tilemap = Objects.requireNonNullElse(binding.tilemap(), "").trim();
if (!tilemap.isBlank()) {
tilemaps.add(tilemap);
}
});
if (tilemaps.size() != 1) {
return null;
}
return details.summary().assetRoot().resolve(tilemaps.getFirst());
}
private void importTmx() {
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));
final TiledAssetGenerationResult result = tiledGenerationService.importSceneBankTmx(projectReference, details);
Platform.runLater(() -> applyTiledGenerationResult("scene-bank-tmx-import", result));
});
}
@ -559,49 +616,13 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
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));
if (result.success()) {
if (CollectionUtils.isNotEmpty(result.updatedBindings())) {
final var sceneBankMetadata = viewState.selectedAssetDetails().sceneBankMetadata().withUpdatedBindings(result.updatedBindings());
final var updatedAssetDetails = viewState.selectedAssetDetails().withSceneBankMetadata(sceneBankMetadata);
viewState = viewState.withSelectedAssetReference(updatedAssetDetails);
}
workspaceBus.publish(new StudioAssetsWorkspaceDetailsSceneLayerChangedEvent(viewState));
}
}
@ -610,7 +631,6 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
if (result.status() == p.packer.messages.PackerOperationStatus.SUCCESS && result.assetReference() != null) {
actionFeedbackMessage = null;
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(result.assetReference()));
workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(result.assetReference(), false));
return;
}
actionFeedbackMessage = result.summary();
@ -641,7 +661,6 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
if (result.status() == p.packer.messages.PackerOperationStatus.SUCCESS && result.assetReference() != null) {
actionFeedbackMessage = null;
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(result.assetReference()));
workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(result.assetReference(), false));
return;
}
actionFeedbackMessage = Objects.requireNonNullElse(result.summary(), "Unable to register asset.");

View File

@ -98,7 +98,6 @@ public final class AssetDetailsContractControl extends HBox implements StudioCon
if (viewState == null || viewState.selectedAssetReference() == null) {
return;
}
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(viewState.selectedAssetReference()));
workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(viewState.selectedAssetReference(), true));
}

View File

@ -17,8 +17,8 @@ import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n;
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.assets.messages.events.StudioGlyphMaterialMetadataChangedEvent;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioMetadataService;
@ -222,7 +222,7 @@ public final class AssetDetailsPaletteOverhaulingControl extends StudioFormSecti
coordinator.apply();
publishEditScope(workspaceBus, currentScopeKey(), null);
renderSection();
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(assetReference));
workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(assetReference, true));
} else {
workspaceBus.publish(new StudioAssetLogEvent("palette-overhauling",
"Apply failed: " + Objects.requireNonNullElse(response.errorMessage(), "unknown error")));

View File

@ -1,19 +1,16 @@
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.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import javafx.scene.layout.VBox;
import p.packer.messages.ListAssetsRequest;
import p.packer.messages.assets.AssetFamilyCatalog;
import p.studio.Container;
import p.studio.controls.forms.StudioFormMode;
import p.studio.controls.lifecycle.StudioControlLifecycle;
@ -22,17 +19,13 @@ 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.StudioAssetsWorkspaceDetailsSceneLayerChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceSelectionRequestedEvent;
import p.studio.workspaces.assets.metadata.*;
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;
@ -41,7 +34,6 @@ 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;
@ -50,12 +42,6 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
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;
@ -72,6 +58,10 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
viewState = event.viewState();
render();
}));
subscriptions.add(workspaceBus.subscribe(StudioAssetsWorkspaceDetailsSceneLayerChangedEvent.class, event -> {
viewState = event.viewState();
render();
}));
}
@Override
@ -79,23 +69,11 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
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) {
if (metadata == null) {
getChildren().clear();
return;
}
@ -117,7 +95,8 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
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);
final Button button = new Button();
button.textProperty().bind(Container.i18n().bind(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");
@ -166,7 +145,8 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
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));
final CheckBox enabledCheckBox = new CheckBox();
enabledCheckBox.textProperty().bind(Container.i18n().bind(I18n.ASSETS_LABEL_USE_LAYER));
enabledCheckBox.setSelected(draft != null && draft.enabled());
enabledCheckBox.getStyleClass().add("assets-scene-layer-enabled");
enabledCheckBox.setDisable(!editing);
@ -188,7 +168,13 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
: AssetDetailsUiSupport.projectRelativePath(projectReference, sceneAssetRoot.resolve(binding.tilemap()))),
AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_TILESET),
editing ? createTilesetCombo(draft, true) : createTilesetReadOnlyValue(binding, draft)));
editing ? createTilesetCombo(draft, true) : createTilesetReadOnlyValue(binding, draft)),
AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_PARALLAX),
editing ? createParallaxEditor(draft) : new Label(draft.parallaxText())),
AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_DEFAULT_PALETTE),
editing ? createDefaultPaletteEditor(draft) : new Label(draft.defaultPaletteText())));
return card;
}
@ -235,27 +221,9 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
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);
actions.getChildren().add(spacer);
return actions;
}
@ -320,6 +288,24 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
return combo;
}
private Node createParallaxEditor(LayerDraft draft) {
final HBox editor = new HBox(8);
editor.getStyleClass().add("assets-scene-layer-size-editor");
final TextField xField = createSizeField(draft.parallaxXText(), draft::setParallaxXText);
final Label separator = new Label(",");
separator.getStyleClass().add("assets-scene-layer-size-separator");
final TextField yField = createSizeField(draft.parallaxYText(), draft::setParallaxYText);
editor.getChildren().addAll(xField, separator, yField);
return editor;
}
private Node createDefaultPaletteEditor(LayerDraft draft) {
final TextField textField = new TextField(draft.defaultPaletteText());
textField.getStyleClass().add("assets-scene-layer-size-field");
textField.textProperty().addListener((ignored, oldValue, newValue) -> draft.setDefaultPaletteText(newValue));
return textField;
}
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)
@ -332,8 +318,9 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
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)
.filter(asset -> metadataService
.read(asset.identity().assetRoot(), AssetFamilyCatalog.GLYPH_BANK)
.glyphSpecialization() == AssetStudioGlyphSpecialization.TILESET)
.map(asset -> {
final String relativeAssetRoot = projectReference.rootPath()
.resolve("assets")
@ -377,7 +364,6 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
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) {
@ -407,10 +393,10 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
}
private void applyEdit(int layerIndex, AssetStudioSceneLayerBinding binding) {
if (binding == null || editingDraft == null || !editingDraft.hasValidDimensions()) {
if (binding == null || editingDraft == null || !editingDraft.hasValidState()) {
workspaceBus.publish(new StudioAssetLogEvent(
"scene-bank-layer",
"Layer size must use positive integer values before applying changes."));
"Layer configuration must use positive dimensions, non-negative parallax, and a non-negative default palette before applying changes."));
return;
}
final LayerDraft draft = editingDraft.copy();
@ -424,10 +410,28 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
draft.widthValue(),
draft.heightValue(),
current.tilemap(),
draft.tilesetAssetRoot()),
draft.tilesetAssetRoot(),
draft.parallaxXValue(),
draft.parallaxYValue(),
draft.defaultPaletteValue(),
preservedTilesOrBlank(current, draft)),
"Updated layer " + layerIndex + " configuration.");
}
private List<AssetStudioSceneTile> preservedTilesOrBlank(
AssetStudioSceneLayerBinding current,
LayerDraft draft) {
if (current.width() == draft.widthValue() && current.height() == draft.heightValue()) {
return current.tiles();
}
final int tileCount = draft.widthValue() * draft.heightValue();
final List<AssetStudioSceneTile> tiles = new ArrayList<>(tileCount);
for (int index = 0; index < tileCount; index += 1) {
tiles.add(AssetStudioSceneTile.inactive(draft.defaultPaletteValue()));
}
return List.copyOf(tiles);
}
private void resetEdit(AssetStudioSceneLayerBinding binding) {
editingDraft = LayerDraft.from(binding);
render();
@ -467,27 +471,43 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
private String widthText;
private String heightText;
private String tilesetAssetRoot;
private String parallaxXText;
private String parallaxYText;
private String defaultPaletteText;
private LayerDraft(boolean enabled, String widthText, String heightText, String tilesetAssetRoot) {
private LayerDraft(
boolean enabled,
String widthText,
String heightText,
String tilesetAssetRoot,
String parallaxXText,
String parallaxYText,
String defaultPaletteText) {
this.enabled = enabled;
this.widthText = widthText;
this.heightText = heightText;
this.tilesetAssetRoot = Objects.requireNonNullElse(tilesetAssetRoot, "").trim();
this.parallaxXText = Objects.requireNonNullElse(parallaxXText, "").trim();
this.parallaxYText = Objects.requireNonNullElse(parallaxYText, "").trim();
this.defaultPaletteText = Objects.requireNonNullElse(defaultPaletteText, "").trim();
}
static LayerDraft from(AssetStudioSceneLayerBinding binding) {
if (binding == null) {
return new LayerDraft(false, "16", "16", "");
return new LayerDraft(false, "16", "16", "", "1.0", "1.0", "0");
}
return new LayerDraft(
binding.enabled(),
Integer.toString(binding.width()),
Integer.toString(binding.height()),
binding.tilesetAssetRoot());
binding.tilesetAssetRoot(),
trimFloat(binding.parallaxFactorX()),
trimFloat(binding.parallaxFactorY()),
Integer.toString(binding.defaultPaletteId()));
}
LayerDraft copy() {
return new LayerDraft(enabled, widthText, heightText, tilesetAssetRoot);
return new LayerDraft(enabled, widthText, heightText, tilesetAssetRoot, parallaxXText, parallaxYText, defaultPaletteText);
}
boolean enabled() {
@ -522,8 +542,40 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
this.tilesetAssetRoot = Objects.requireNonNullElse(tilesetAssetRoot, "").trim();
}
boolean hasValidDimensions() {
return parsePositiveInt(widthText) > 0 && parsePositiveInt(heightText) > 0;
String parallaxXText() {
return parallaxXText;
}
void setParallaxXText(String parallaxXText) {
this.parallaxXText = Objects.requireNonNullElse(parallaxXText, "").trim();
}
String parallaxYText() {
return parallaxYText;
}
void setParallaxYText(String parallaxYText) {
this.parallaxYText = Objects.requireNonNullElse(parallaxYText, "").trim();
}
String parallaxText() {
return parallaxXText + ", " + parallaxYText;
}
String defaultPaletteText() {
return defaultPaletteText;
}
void setDefaultPaletteText(String defaultPaletteText) {
this.defaultPaletteText = Objects.requireNonNullElse(defaultPaletteText, "").trim();
}
boolean hasValidState() {
return parsePositiveInt(widthText) > 0
&& parsePositiveInt(heightText) > 0
&& parseNonNegativeFloat(parallaxXText) >= 0.0f
&& parseNonNegativeFloat(parallaxYText) >= 0.0f
&& parseNonNegativeInt(defaultPaletteText) >= 0;
}
int widthValue() {
@ -534,6 +586,18 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
return parsePositiveInt(heightText);
}
float parallaxXValue() {
return parseNonNegativeFloat(parallaxXText);
}
float parallaxYValue() {
return parseNonNegativeFloat(parallaxYText);
}
int defaultPaletteValue() {
return parseNonNegativeInt(defaultPaletteText);
}
private static int parsePositiveInt(String value) {
try {
return Integer.parseInt(Objects.requireNonNullElse(value, "").trim());
@ -541,5 +605,30 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
return -1;
}
}
private static int parseNonNegativeInt(String value) {
try {
final int parsed = Integer.parseInt(Objects.requireNonNullElse(value, "").trim());
return parsed >= 0 ? parsed : -1;
} catch (NumberFormatException exception) {
return -1;
}
}
private static float parseNonNegativeFloat(String value) {
try {
final float parsed = Float.parseFloat(Objects.requireNonNullElse(value, "").trim());
return Float.isFinite(parsed) && parsed >= 0.0f ? parsed : -1.0f;
} catch (NumberFormatException exception) {
return -1.0f;
}
}
private static String trimFloat(float value) {
if (value == (long) value) {
return Long.toString((long) value) + ".0";
}
return Float.toString(value);
}
}
}

View File

@ -77,4 +77,22 @@ public record AssetWorkspaceAssetDetails(
sceneBankValidation,
diagnostics);
}
public AssetWorkspaceAssetDetails withSceneBankMetadata(AssetStudioSceneBankMetadata sceneBankMetadata) {
return new AssetWorkspaceAssetDetails(
summary,
actions,
outputFormat,
outputCodec,
availableOutputCodecs,
codecConfigurationFieldsByCodec,
metadataFields,
outputPipeline,
bankComposition,
pipelinePalettes,
glyphMaterialMetadata,
sceneBankMetadata,
sceneBankValidation,
diagnostics);
}
}

View File

@ -13,4 +13,8 @@ public record AssetWorkspaceDetailsViewState(
public AssetWorkspaceDetailsViewState {
Objects.requireNonNull(detailsStatus, "detailsStatus");
}
public AssetWorkspaceDetailsViewState withSelectedAssetReference(AssetWorkspaceAssetDetails selectedAssetDetails) {
return new AssetWorkspaceDetailsViewState(detailsStatus, selectedAssetReference, selectedAssetDetails, detailsErrorMessage);
}
}

View File

@ -0,0 +1,8 @@
package p.studio.workspaces.assets.messages.events;
import p.studio.lsp.events.StudioEvent;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState;
public record StudioAssetsWorkspaceDetailsSceneLayerChangedEvent(
AssetWorkspaceDetailsViewState viewState) implements StudioEvent {
}

View File

@ -49,6 +49,20 @@ public final class AssetStudioMetadataService {
layer.put("height", binding.height());
layer.put("tilemap", binding.tilemap());
layer.put("tileset_asset_root", binding.tilesetAssetRoot());
layer.put("parallax_factor_x", binding.parallaxFactorX());
layer.put("parallax_factor_y", binding.parallaxFactorY());
layer.put("default_palette_id", binding.defaultPaletteId());
if (binding.tiles().stream().anyMatch(AssetStudioSceneTile::active)) {
final ArrayNode tiles = layer.putArray("tiles");
for (AssetStudioSceneTile tile : binding.tiles()) {
final var tileNode = tiles.addObject();
tileNode.put("active", tile.active());
tileNode.put("glyph_id", tile.glyphId());
tileNode.put("palette_id", tile.paletteId());
tileNode.put("flip_x", tile.flipX());
tileNode.put("flip_y", tile.flipY());
}
}
}
mapper.writerWithDefaultPrettyPrinter().writeValue(
normalizedAssetRoot.resolve(SCENE_BANK_SUPPORT_FILE).toFile(),
@ -137,7 +151,7 @@ public final class AssetStudioMetadataService {
}
final Path supportFile = assetRoot.resolve(SCENE_BANK_SUPPORT_FILE);
if (!Files.isRegularFile(supportFile)) {
return null;
return defaultSceneBankMetadata(supportFile);
}
try {
final JsonNode root = mapper.readTree(supportFile.toFile());
@ -163,10 +177,28 @@ public final class AssetStudioMetadataService {
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();
final float parallaxFactorX = (float) layerNode.path("parallax_factor_x").asDouble(1.0d);
final float parallaxFactorY = (float) layerNode.path("parallax_factor_y").asDouble(1.0d);
final int defaultPaletteId = layerNode.path("default_palette_id").asInt(0);
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));
final List<AssetStudioSceneTile> tiles = readSceneTiles(layerNode.path("tiles"), width, height, defaultPaletteId);
if (tiles == null) {
return null;
}
bindings.add(new AssetStudioSceneLayerBinding(
enabled,
index,
layerName,
width,
height,
tilemap,
tilesetAssetRoot,
parallaxFactorX,
parallaxFactorY,
defaultPaletteId,
tiles));
}
bindings.sort(Comparator.comparingInt(AssetStudioSceneLayerBinding::index));
return new AssetStudioSceneBankMetadata(mapWidth, mapHeight, layerCount, bindings, supportFile);
@ -287,4 +319,44 @@ public final class AssetStudioMetadataService {
final String normalized = value.trim().toUpperCase();
return normalized.matches("^#[0-9A-F]{6}$") ? normalized : null;
}
private AssetStudioSceneBankMetadata defaultSceneBankMetadata(Path supportFile) {
return new AssetStudioSceneBankMetadata(
16,
16,
4,
List.of(
new AssetStudioSceneLayerBinding(false, 1, "Layer 1", 16, 16, "scene.tmx", "", 1.0f, 1.0f, 0),
new AssetStudioSceneLayerBinding(false, 2, "Layer 2", 16, 16, "scene.tmx", "", 1.0f, 1.0f, 0),
new AssetStudioSceneLayerBinding(false, 3, "Layer 3", 16, 16, "scene.tmx", "", 1.0f, 1.0f, 0),
new AssetStudioSceneLayerBinding(false, 4, "Layer 4", 16, 16, "scene.tmx", "", 1.0f, 1.0f, 0)),
supportFile);
}
private List<AssetStudioSceneTile> readSceneTiles(JsonNode tilesNode, int width, int height, int defaultPaletteId) {
final int tileCount = width * height;
if (!(tilesNode instanceof ArrayNode arrayNode)) {
final List<AssetStudioSceneTile> defaults = new ArrayList<>(tileCount);
for (int index = 0; index < tileCount; index += 1) {
defaults.add(AssetStudioSceneTile.inactive(defaultPaletteId));
}
return List.copyOf(defaults);
}
if (arrayNode.size() != tileCount) {
return null;
}
final List<AssetStudioSceneTile> tiles = new ArrayList<>(tileCount);
for (JsonNode tileNode : arrayNode) {
final boolean active = tileNode.path("active").asBoolean(false);
final int glyphId = tileNode.path("glyph_id").asInt(0);
final int paletteId = tileNode.path("palette_id").asInt(defaultPaletteId);
final boolean flipX = tileNode.path("flip_x").asBoolean(false);
final boolean flipY = tileNode.path("flip_y").asBoolean(false);
if (glyphId < 0 || paletteId < 0) {
return null;
}
tiles.add(new AssetStudioSceneTile(active, glyphId, paletteId, flipX, flipY));
}
return List.copyOf(tiles);
}
}

View File

@ -28,4 +28,13 @@ public record AssetStudioSceneBankMetadata(
public long enabledLayerCount() {
return layerBindings.stream().filter(AssetStudioSceneLayerBinding::enabled).count();
}
public AssetStudioSceneBankMetadata withUpdatedBindings(final List<AssetStudioSceneLayerBinding> assetStudioSceneLayerBindings) {
return new AssetStudioSceneBankMetadata(
mapWidth,
mapHeight,
layerCount,
assetStudioSceneLayerBindings,
supportFile);
}
}

View File

@ -1,5 +1,7 @@
package p.studio.workspaces.assets.metadata;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public record AssetStudioSceneLayerBinding(
@ -9,11 +11,16 @@ public record AssetStudioSceneLayerBinding(
int width,
int height,
String tilemap,
String tilesetAssetRoot) {
String tilesetAssetRoot,
float parallaxFactorX,
float parallaxFactorY,
int defaultPaletteId,
List<AssetStudioSceneTile> tiles) {
public AssetStudioSceneLayerBinding {
layerName = Objects.requireNonNull(layerName, "layerName").trim();
tilemap = Objects.requireNonNull(tilemap, "tilemap").trim();
tilesetAssetRoot = Objects.requireNonNull(tilesetAssetRoot, "tilesetAssetRoot").trim();
tiles = List.copyOf(Objects.requireNonNull(tiles, "tiles"));
if (index <= 0) {
throw new IllegalArgumentException("index must be positive");
}
@ -26,5 +33,50 @@ public record AssetStudioSceneLayerBinding(
if (tilemap.isBlank()) {
throw new IllegalArgumentException("tilemap must not be blank");
}
if (!Float.isFinite(parallaxFactorX) || parallaxFactorX < 0.0f) {
throw new IllegalArgumentException("parallaxFactorX must be finite and non-negative");
}
if (!Float.isFinite(parallaxFactorY) || parallaxFactorY < 0.0f) {
throw new IllegalArgumentException("parallaxFactorY must be finite and non-negative");
}
if (defaultPaletteId < 0) {
throw new IllegalArgumentException("defaultPaletteId must be non-negative");
}
if (tiles.size() != width * height) {
throw new IllegalArgumentException("tiles must match width * height");
}
}
public AssetStudioSceneLayerBinding(
boolean enabled,
int index,
String layerName,
int width,
int height,
String tilemap,
String tilesetAssetRoot,
float parallaxFactorX,
float parallaxFactorY,
int defaultPaletteId) {
this(
enabled,
index,
layerName,
width,
height,
tilemap,
tilesetAssetRoot,
parallaxFactorX,
parallaxFactorY,
defaultPaletteId,
blankTiles(width, height, defaultPaletteId));
}
private static List<AssetStudioSceneTile> blankTiles(int width, int height, int defaultPaletteId) {
final List<AssetStudioSceneTile> tiles = new ArrayList<>(width * height);
for (int index = 0; index < width * height; index += 1) {
tiles.add(AssetStudioSceneTile.inactive(defaultPaletteId));
}
return List.copyOf(tiles);
}
}

View File

@ -0,0 +1,22 @@
package p.studio.workspaces.assets.metadata;
public record AssetStudioSceneTile(
boolean active,
int glyphId,
int paletteId,
boolean flipX,
boolean flipY) {
public AssetStudioSceneTile {
if (glyphId < 0) {
throw new IllegalArgumentException("glyphId must be non-negative");
}
if (paletteId < 0) {
throw new IllegalArgumentException("paletteId must be non-negative");
}
}
public static AssetStudioSceneTile inactive(int paletteId) {
return new AssetStudioSceneTile(false, 0, Math.max(0, paletteId), false, false);
}
}

View File

@ -3,383 +3,333 @@ 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.ListAssetsRequest;
import p.packer.messages.assets.AssetFamilyCatalog;
import p.packer.messages.diagnostics.PackerDiagnosticCategory;
import p.packer.messages.diagnostics.PackerDiagnosticSeverity;
import p.studio.Container;
import p.studio.projects.ProjectReference;
import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioSceneLayerBinding;
import p.studio.workspaces.assets.metadata.AssetStudioSceneTile;
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";
public static final String ACCEPTED_REQUEST_FILE = "scene-bank.pack-request.json";
private final ObjectMapper mapper = new ObjectMapper();
private final TiledXmlCodec codec = new TiledXmlCodec();
private final SceneBankGlyphAssetResolver glyphAssetResolver;
public SceneBankWorkflowService() {
this((projectReference, tilesetAssetRoot) -> Container.packer().workspaceService()
.listAssets(new ListAssetsRequest(projectReference.toPackerProjectContext()))
.assets()
.stream()
.filter(asset -> asset.assetFamily() == AssetFamilyCatalog.GLYPH_BANK)
.filter(asset -> projectReference.rootPath()
.resolve("assets")
.resolve(tilesetAssetRoot)
.toAbsolutePath()
.normalize()
.equals(asset.identity().assetRoot().toAbsolutePath().normalize()))
.findFirst()
.map(asset -> asset.identity().assetId() == null ? -1 : asset.identity().assetId())
.orElse(-1));
}
SceneBankWorkflowService(SceneBankGlyphAssetResolver glyphAssetResolver) {
this.glyphAssetResolver = Objects.requireNonNull(glyphAssetResolver, "glyphAssetResolver");
}
public SceneBankWorkflowResult inspect(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
if (details == null || details.summary().assetFamily() != p.packer.messages.assets.AssetFamilyCatalog.SCENE_BANK) {
if (details == null || details.summary().assetFamily() != AssetFamilyCatalog.SCENE_BANK) {
return new SceneBankWorkflowResult(true, "", AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, List.of());
}
final List<PackerDiagnosticDTO> diagnostics = validateDiagnostics(projectReference, details, null);
final String currentFingerprint = diagnostics.stream().anyMatch(PackerDiagnosticDTO::blocking)
? ""
: computeFingerprint(projectReference, details);
final String validatedFingerprint = readFingerprint(details.summary().assetRoot().resolve(VALIDATION_FILE));
final String acceptedFingerprint = readFingerprint(details.summary().assetRoot().resolve(ACCEPTANCE_FILE));
final AssetWorkspaceSceneBankValidation validation = validationState(
diagnostics,
currentFingerprint,
validatedFingerprint,
acceptedFingerprint);
final List<PackerDiagnosticDTO> diagnostics = new ArrayList<>(validateDiagnostics(projectReference, details));
if (diagnostics.stream().noneMatch(PackerDiagnosticDTO::blocking)) {
try {
writeAcceptedRequest(
details.summary().assetRoot().resolve(ACCEPTED_REQUEST_FILE),
buildCanonicalRequest(projectReference, details));
} catch (IOException | IllegalArgumentException exception) {
diagnostics.add(error(
"Unable to persist canonical scene-pack request: " + exception.getMessage(),
details.summary().assetRoot().resolve(ACCEPTED_REQUEST_FILE)));
}
}
final AssetWorkspaceSceneBankValidation validation = validationState(diagnostics);
return new SceneBankWorkflowResult(
diagnostics.stream().noneMatch(PackerDiagnosticDTO::blocking),
messageFor(validation),
validation,
diagnostics);
List.copyOf(diagnostics));
}
public SceneBankWorkflowResult validate(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
final SceneBankWorkflowResult inspection = inspect(projectReference, details);
final Path validationFile = details.summary().assetRoot().resolve(VALIDATION_FILE);
try {
if (inspection.success() && !inspection.validation().currentFingerprint().isBlank()) {
writeFingerprint(validationFile, inspection.validation().currentFingerprint());
} else {
Files.deleteIfExists(validationFile);
}
} catch (IOException exception) {
return new SceneBankWorkflowResult(
false,
"Unable to persist validation state: " + exception.getMessage(),
inspection.validation(),
inspection.diagnostics());
}
return inspect(projectReference, details);
}
public SceneBankWorkflowResult accept(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
final SceneBankWorkflowResult inspection = inspect(projectReference, details);
if (!inspection.validation().canAccept()) {
return new SceneBankWorkflowResult(
false,
"Scene Bank must validate successfully before acceptance.",
inspection.validation(),
inspection.diagnostics());
}
try {
writeFingerprint(details.summary().assetRoot().resolve(ACCEPTANCE_FILE), inspection.validation().currentFingerprint());
writeFingerprint(details.summary().assetRoot().resolve(VALIDATION_FILE), inspection.validation().currentFingerprint());
} catch (IOException exception) {
return new SceneBankWorkflowResult(
false,
"Unable to persist acceptance state: " + exception.getMessage(),
inspection.validation(),
inspection.diagnostics());
}
return inspect(projectReference, details);
}
public SceneBankWorkflowResult inspectLayer(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) {
if (details == null || details.summary().assetFamily() != p.packer.messages.assets.AssetFamilyCatalog.SCENE_BANK) {
return new SceneBankWorkflowResult(true, "", AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, List.of());
}
final List<PackerDiagnosticDTO> diagnostics = validateDiagnostics(projectReference, details, layerIndex);
final String currentFingerprint = diagnostics.stream().anyMatch(PackerDiagnosticDTO::blocking)
? ""
: computeFingerprint(projectReference, details, layerIndex);
final String validatedFingerprint = readFingerprint(details.summary().assetRoot().resolve(layerValidationFile(layerIndex)));
final String acceptedFingerprint = readFingerprint(details.summary().assetRoot().resolve(layerAcceptanceFile(layerIndex)));
final AssetWorkspaceSceneBankValidation validation = validationState(
diagnostics,
currentFingerprint,
validatedFingerprint,
acceptedFingerprint);
return new SceneBankWorkflowResult(
diagnostics.stream().noneMatch(PackerDiagnosticDTO::blocking),
messageFor(validation),
validation,
diagnostics);
return inspect(projectReference, details);
}
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);
return inspect(projectReference, details);
}
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);
return inspect(projectReference, details);
}
private List<PackerDiagnosticDTO> validateDiagnostics(ProjectReference projectReference, AssetWorkspaceAssetDetails details, Integer targetLayerIndex) {
private List<PackerDiagnosticDTO> validateDiagnostics(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
final List<PackerDiagnosticDTO> diagnostics = new ArrayList<>();
final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata();
if (metadata == null) {
diagnostics.add(error("Scene Bank support metadata is missing or invalid.", details.summary().assetRoot().resolve("scene-bank.studio.json")));
diagnostics.add(error("Scene Bank support metadata is missing or invalid.",
details.summary().assetRoot().resolve("scene-bank.studio.json")));
return diagnostics;
}
final SceneLayerRuntimeDefaults sceneDefaults =
resolveSceneLayerRuntimeDefaults(projectReference, metadata.layerBindings());
for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) {
if (!binding.enabled()) {
continue;
}
if (targetLayerIndex != null && binding.index() != targetLayerIndex) {
if (resolveLayerRuntimeDefaults(projectReference, binding, sceneDefaults) == null) {
diagnostics.add(error(
"Unable to resolve runtime glyph/tile_size defaults for inactive layer " + binding.index() + ".",
metadata.supportFile()));
}
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));
if (binding.tiles().size() != binding.width() * binding.height()) {
diagnostics.add(error("Canonical scene tile count does not match width * height for layer " + binding.index() + ".",
metadata.supportFile()));
continue;
}
if (!Files.isRegularFile(tsxPath)) {
diagnostics.add(error("Referenced TSX file is missing for layer " + binding.index() + ": " + binding.tilesetAssetRoot(), tsxPath));
continue;
if (resolveGlyphAssetId(projectReference, binding.tilesetAssetRoot()) < 0) {
diagnostics.add(error("Unable to resolve glyph_asset_id for layer " + binding.index() + ": " + binding.tilesetAssetRoot(),
metadata.supportFile()));
}
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 (resolveTileSize(projectReference, binding.tilesetAssetRoot()) <= 0) {
diagnostics.add(error("Unable to resolve tile_size for layer " + binding.index() + ": " + binding.tilesetAssetRoot(),
metadata.supportFile()));
}
for (AssetStudioSceneTile tile : binding.tiles()) {
if (tile.glyphId() < 0) {
diagnostics.add(error("Canonical scene tile glyph_id must be non-negative for layer " + binding.index() + ".",
metadata.supportFile()));
break;
}
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 (tile.paletteId() < 0) {
diagnostics.add(error("Canonical scene tile palette_id must be non-negative for layer " + binding.index() + ".",
metadata.supportFile()));
break;
}
if (map.tileWidth() != tileset.tileWidth() || map.tileHeight() != tileset.tileHeight()) {
diagnostics.add(error("TMX tile size does not match referenced TSX: " + binding.tilemap(), tmxPath));
}
if (map.tileLayers().size() != 1 || !binding.layerName().equals(map.tileLayers().getFirst().name())) {
diagnostics.add(error("TMX layer mapping does not match scene-bank support metadata: " + binding.tilemap(), tmxPath));
}
} catch (TiledUnsupportedFeatureException exception) {
diagnostics.add(error(exception.getMessage(), tmxPath));
} catch (IOException exception) {
diagnostics.add(error("Unable to validate Scene Bank XML: " + exception.getMessage(), tmxPath));
}
}
return List.copyOf(diagnostics);
}
private String computeFingerprint(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata();
if (metadata == null) {
return "";
}
try {
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
final List<Path> files = new ArrayList<>();
files.add(metadata.supportFile());
for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) {
if (!binding.enabled()) {
continue;
}
files.add(details.summary().assetRoot().resolve(binding.tilemap()).toAbsolutePath().normalize());
if (!binding.tilesetAssetRoot().isBlank()) {
files.add(projectReference.rootPath().resolve("assets").resolve(binding.tilesetAssetRoot()).resolve("tileset.tsx").toAbsolutePath().normalize());
}
private ScenePackRequest buildCanonicalRequest(ProjectReference projectReference, AssetWorkspaceAssetDetails details) throws IOException {
final AssetStudioSceneBankMetadata metadata = Objects.requireNonNull(details.sceneBankMetadata(), "sceneBankMetadata");
final SceneLayerRuntimeDefaults sceneDefaults =
resolveSceneLayerRuntimeDefaults(projectReference, metadata.layerBindings());
final List<ScenePackLayerRequest> layers = new ArrayList<>();
for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) {
if (!binding.enabled()) {
layers.add(inactiveLayer(projectReference, binding, sceneDefaults));
continue;
}
files.stream()
.distinct()
.sorted(Comparator.comparing(Path::toString))
.forEach(path -> updateDigest(digest, path));
return HexFormat.of().formatHex(digest.digest());
} catch (Exception exception) {
return "";
layers.add(activeLayer(projectReference, binding));
}
return new ScenePackRequest(1, layers);
}
private ScenePackLayerRequest inactiveLayer(ProjectReference projectReference,
AssetStudioSceneLayerBinding binding,
SceneLayerRuntimeDefaults sceneDefaults) {
final SceneLayerRuntimeDefaults defaults = resolveLayerRuntimeDefaults(projectReference, binding, sceneDefaults);
if (defaults == null) {
throw new IllegalArgumentException("Unable to resolve runtime defaults for inactive layer " + binding.index());
}
final List<ScenePackTileRequest> tiles = new ArrayList<>(binding.tiles().size());
for (AssetStudioSceneTile tile : binding.tiles()) {
tiles.add(new ScenePackTileRequest(false, 0, tile.paletteId(), false, false));
}
return new ScenePackLayerRequest(
false,
defaults.glyphAssetId(),
defaults.tileSize(),
binding.parallaxFactorX(),
binding.parallaxFactorY(),
binding.width(),
binding.height(),
tiles);
}
private ScenePackLayerRequest activeLayer(ProjectReference projectReference, AssetStudioSceneLayerBinding binding) throws IOException {
final int glyphAssetId = resolveGlyphAssetId(projectReference, binding.tilesetAssetRoot());
if (glyphAssetId < 0) {
throw new IllegalArgumentException("Unable to resolve glyph_asset_id for " + binding.tilesetAssetRoot());
}
final int tileSize = resolveTileSize(projectReference, binding.tilesetAssetRoot());
if (tileSize <= 0) {
throw new IllegalArgumentException("Unable to resolve tile_size for " + binding.tilesetAssetRoot());
}
final List<ScenePackTileRequest> tiles = new ArrayList<>(binding.tiles().size());
for (AssetStudioSceneTile tile : binding.tiles()) {
if (!tile.active()) {
tiles.add(new ScenePackTileRequest(false, 0, tile.paletteId(), false, false));
continue;
}
tiles.add(new ScenePackTileRequest(true, tile.glyphId(), tile.paletteId(), tile.flipX(), tile.flipY()));
}
return new ScenePackLayerRequest(
true,
glyphAssetId,
tileSize,
binding.parallaxFactorX(),
binding.parallaxFactorY(),
binding.width(),
binding.height(),
tiles);
}
private SceneLayerRuntimeDefaults resolveSceneLayerRuntimeDefaults(ProjectReference projectReference,
List<AssetStudioSceneLayerBinding> bindings) {
for (AssetStudioSceneLayerBinding binding : bindings) {
final SceneLayerRuntimeDefaults defaults = resolveLayerOwnRuntimeDefaults(projectReference, binding);
if (defaults != null) {
return defaults;
}
}
return null;
}
private SceneLayerRuntimeDefaults resolveLayerRuntimeDefaults(ProjectReference projectReference,
AssetStudioSceneLayerBinding binding,
SceneLayerRuntimeDefaults sceneDefaults) {
final SceneLayerRuntimeDefaults ownDefaults = resolveLayerOwnRuntimeDefaults(projectReference, binding);
return ownDefaults != null ? ownDefaults : sceneDefaults;
}
private SceneLayerRuntimeDefaults resolveLayerOwnRuntimeDefaults(ProjectReference projectReference,
AssetStudioSceneLayerBinding binding) {
if (binding.tilesetAssetRoot().isBlank()) {
return null;
}
final int glyphAssetId = resolveGlyphAssetId(projectReference, binding.tilesetAssetRoot());
final int tileSize = resolveTileSize(projectReference, binding.tilesetAssetRoot());
if (glyphAssetId < 0 || tileSize <= 0) {
return null;
}
return new SceneLayerRuntimeDefaults(glyphAssetId, tileSize);
}
private void writeAcceptedRequest(Path path, ScenePackRequest request) throws IOException {
final var root = mapper.createObjectNode();
root.put("schema_version", 1);
root.put("format", "SCENE/tilemap_v1");
root.put("version", request.version());
final var layersNode = root.putArray("layers");
for (ScenePackLayerRequest layer : request.layers()) {
final var layerNode = layersNode.addObject();
layerNode.put("active", layer.active());
layerNode.put("glyph_asset_id", layer.glyphAssetId());
layerNode.put("tile_size", layer.tileSize());
layerNode.put("parallax_factor_x", layer.parallaxFactorX());
layerNode.put("parallax_factor_y", layer.parallaxFactorY());
layerNode.put("width", layer.width());
layerNode.put("height", layer.height());
final var tilesNode = layerNode.putArray("tiles");
for (ScenePackTileRequest tile : layer.tiles()) {
final var tileNode = tilesNode.addObject();
tileNode.put("active", tile.active());
tileNode.put("glyph_id", tile.glyphId());
tileNode.put("palette_id", tile.paletteId());
tileNode.put("flip_x", tile.flipX());
tileNode.put("flip_y", tile.flipY());
}
}
mapper.writerWithDefaultPrettyPrinter().writeValue(path.toFile(), root);
}
private int resolveGlyphAssetId(ProjectReference projectReference, String tilesetAssetRoot) {
try {
return glyphAssetResolver.resolveGlyphAssetId(projectReference, tilesetAssetRoot);
} catch (IOException exception) {
return -1;
}
}
private String computeFingerprint(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) {
final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata();
if (metadata == null) {
return "";
private int resolveTileSize(ProjectReference projectReference, String tilesetAssetRoot) {
final Path declarationPath = projectReference.rootPath()
.resolve("assets")
.resolve(tilesetAssetRoot)
.resolve("asset.json")
.toAbsolutePath()
.normalize();
if (!Files.isRegularFile(declarationPath)) {
return -1;
}
try {
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
final List<Path> files = new ArrayList<>();
files.add(metadata.supportFile());
metadata.layerBindings().stream()
.filter(AssetStudioSceneLayerBinding::enabled)
.filter(binding -> binding.index() == layerIndex)
.findFirst()
.ifPresent(binding -> {
files.add(details.summary().assetRoot().resolve(binding.tilemap()).toAbsolutePath().normalize());
if (!binding.tilesetAssetRoot().isBlank()) {
files.add(projectReference.rootPath().resolve("assets").resolve(binding.tilesetAssetRoot()).resolve("tileset.tsx").toAbsolutePath().normalize());
}
});
if (files.size() == 1) {
return "";
}
files.stream()
.distinct()
.sorted(Comparator.comparing(Path::toString))
.forEach(path -> updateDigest(digest, path));
return HexFormat.of().formatHex(digest.digest());
} catch (Exception exception) {
return "";
final JsonNode root = mapper.readTree(declarationPath.toFile());
final String value = root.path("output").path("metadata").path("tile_size").asText("").trim().toLowerCase();
return switch (value) {
case "8x8" -> 8;
case "16x16" -> 16;
case "32x32" -> 32;
default -> -1;
};
} catch (IOException exception) {
return -1;
}
}
private void updateDigest(MessageDigest digest, Path path) {
try {
if (!Files.isRegularFile(path)) {
return;
}
digest.update(path.toString().getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0);
digest.update(Files.readAllBytes(path));
digest.update((byte) 0);
} catch (IOException ignored) {
}
}
private AssetWorkspaceSceneBankValidation validationState(
List<PackerDiagnosticDTO> diagnostics,
String currentFingerprint,
String validatedFingerprint,
String acceptedFingerprint) {
private AssetWorkspaceSceneBankValidation validationState(List<PackerDiagnosticDTO> diagnostics) {
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,
AssetWorkspaceSceneBankStatus.READY,
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";
false,
"",
"",
"");
}
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 PENDING_VALIDATION, VALIDATED_PENDING_ACCEPTANCE -> "Scene Bank canonical workflow no longer uses validation/acceptance staging.";
case READY -> "Scene Bank canonical scene 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,
@ -388,4 +338,12 @@ public final class SceneBankWorkflowService {
path,
true);
}
@FunctionalInterface
interface SceneBankGlyphAssetResolver {
int resolveGlyphAssetId(ProjectReference projectReference, String tilesetAssetRoot) throws IOException;
}
private record SceneLayerRuntimeDefaults(int glyphAssetId, int tileSize) {
}
}

View File

@ -0,0 +1,37 @@
package p.studio.workspaces.assets.scene;
import java.util.List;
import java.util.Objects;
public record ScenePackLayerRequest(
boolean active,
int glyphAssetId,
int tileSize,
float parallaxFactorX,
float parallaxFactorY,
int width,
int height,
List<ScenePackTileRequest> tiles) {
public ScenePackLayerRequest {
tiles = List.copyOf(Objects.requireNonNull(tiles, "tiles"));
if (glyphAssetId < 0) {
throw new IllegalArgumentException("glyphAssetId must be non-negative");
}
if (tileSize < 0) {
throw new IllegalArgumentException("tileSize must be non-negative");
}
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException("width and height must be positive");
}
if (!Float.isFinite(parallaxFactorX) || parallaxFactorX < 0.0f) {
throw new IllegalArgumentException("parallaxFactorX must be finite and non-negative");
}
if (!Float.isFinite(parallaxFactorY) || parallaxFactorY < 0.0f) {
throw new IllegalArgumentException("parallaxFactorY must be finite and non-negative");
}
if (tiles.size() != width * height) {
throw new IllegalArgumentException("tiles size must match width * height");
}
}
}

View File

@ -0,0 +1,19 @@
package p.studio.workspaces.assets.scene;
import java.util.List;
import java.util.Objects;
public record ScenePackRequest(
int version,
List<ScenePackLayerRequest> layers) {
public ScenePackRequest {
layers = List.copyOf(Objects.requireNonNull(layers, "layers"));
if (version <= 0) {
throw new IllegalArgumentException("version must be positive");
}
if (layers.size() != 4) {
throw new IllegalArgumentException("layers must contain exactly 4 entries");
}
}
}

View File

@ -0,0 +1,18 @@
package p.studio.workspaces.assets.scene;
public record ScenePackTileRequest(
boolean active,
int glyphId,
int paletteId,
boolean flipX,
boolean flipY) {
public ScenePackTileRequest {
if (glyphId < 0) {
throw new IllegalArgumentException("glyphId must be non-negative");
}
if (paletteId < 0) {
throw new IllegalArgumentException("paletteId must be non-negative");
}
}
}

View File

@ -1,5 +1,7 @@
package p.studio.workspaces.assets.tiled;
import p.studio.workspaces.assets.metadata.AssetStudioSceneLayerBinding;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
@ -7,6 +9,7 @@ import java.util.Objects;
public record TiledAssetGenerationResult(
boolean success,
String message,
List<AssetStudioSceneLayerBinding> updatedBindings,
List<Path> writtenFiles) {
public TiledAssetGenerationResult {

View File

@ -4,31 +4,52 @@ 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.AssetStudioMetadataService;
import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioSceneLayerBinding;
import p.studio.workspaces.assets.metadata.AssetStudioSceneTile;
import p.studio.workspaces.assets.messages.AssetWorkspaceAssetDetails;
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class TiledAssetGenerationService {
public static final String GENERATED_TSX_FILE = "tileset.tsx";
private static final long FLIP_HORIZONTAL_FLAG = 0x80000000L;
private static final long FLIP_VERTICAL_FLAG = 0x40000000L;
private static final long FLIP_DIAGONAL_FLAG = 0x20000000L;
private static final long GID_MASK = ~(FLIP_HORIZONTAL_FLAG | FLIP_VERTICAL_FLAG | FLIP_DIAGONAL_FLAG);
private final TiledXmlCodec codec = new TiledXmlCodec();
private final AssetStudioMetadataService metadataService = new AssetStudioMetadataService();
public enum SceneTmxExportDisposition {
OVERWRITE,
KEEP_BOTH
}
public TiledAssetGenerationResult generateTilesetTsx(AssetWorkspaceAssetDetails details) {
return exportTilesetTsx(details);
}
public TiledAssetGenerationResult exportTilesetTsx(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());
return new TiledAssetGenerationResult(false, "TSX export is available only for Tileset-specialized glyph banks.", List.of(), List.of());
}
final List<AssetWorkspaceBankCompositionFile> selectedFiles = details.bankComposition().selectedFiles();
if (selectedFiles.isEmpty()) {
return new TiledAssetGenerationResult(false, "The selected tileset bank has no included glyph artifacts.", List.of());
return new TiledAssetGenerationResult(false, "The selected tileset bank has no included glyph artifacts.", List.of(), List.of());
}
final int tileSize = parseTileSize(details.metadataFields());
final List<TiledTilesetTile> tiles = new ArrayList<>();
@ -55,120 +76,331 @@ public final class TiledAssetGenerationService {
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));
return new TiledAssetGenerationResult(true, "Exported TSX successfully.", List.of(), List.of(outputPath));
} catch (IOException exception) {
return new TiledAssetGenerationResult(false, "Unable to generate TSX: " + exception.getMessage(), List.of());
return new TiledAssetGenerationResult(false, "Unable to export TSX: " + exception.getMessage(), List.of(), List.of());
}
}
public TiledAssetGenerationResult generateSceneBankTilemaps(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
if (details == null || details.summary().assetFamily() != AssetFamilyCatalog.SCENE_BANK) {
return new TiledAssetGenerationResult(false, "TMX generation is available only for Scene Bank assets.", List.of());
}
final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata();
if (metadata == null) {
return new TiledAssetGenerationResult(false, "Scene Bank support metadata is missing or invalid.", List.of());
}
final List<Path> writtenFiles = new ArrayList<>();
try {
for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) {
if (!binding.enabled()) {
continue;
}
final TiledAssetGenerationResult result = generateSceneBankTilemap(projectReference, details, binding.index());
if (!result.success()) {
return result;
}
writtenFiles.addAll(result.writtenFiles());
}
if (writtenFiles.isEmpty()) {
return new TiledAssetGenerationResult(false, "Enable at least one layer before generating TMX.", List.of());
}
return new TiledAssetGenerationResult(true, "Generated TMX successfully.", writtenFiles);
} catch (RuntimeException exception) {
return new TiledAssetGenerationResult(false, "Unable to generate TMX: " + exception.getMessage(), List.of());
}
return exportSceneBankTmx(projectReference, details);
}
public TiledAssetGenerationResult generateSceneBankTilemap(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) {
return exportSceneBankTmx(projectReference, details);
}
public TiledAssetGenerationResult exportSceneBankTmx(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
return exportSceneBankTmx(projectReference, details, SceneTmxExportDisposition.OVERWRITE);
}
public TiledAssetGenerationResult exportSceneBankTmx(
ProjectReference projectReference,
AssetWorkspaceAssetDetails details,
SceneTmxExportDisposition disposition) {
if (details == null || details.summary().assetFamily() != AssetFamilyCatalog.SCENE_BANK) {
return new TiledAssetGenerationResult(false, "TMX generation is available only for Scene Bank assets.", List.of());
return new TiledAssetGenerationResult(false, "TMX export is available only for Scene Bank assets.", List.of(), List.of());
}
final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata();
if (metadata == null) {
return new TiledAssetGenerationResult(false, "Scene Bank support metadata is missing or invalid.", List.of());
return new TiledAssetGenerationResult(false, "Scene Bank support metadata is missing or invalid.", List.of(), 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());
final List<AssetStudioSceneLayerBinding> enabledLayers = metadata.layerBindings().stream()
.filter(AssetStudioSceneLayerBinding::enabled)
.toList();
if (enabledLayers.isEmpty()) {
return new TiledAssetGenerationResult(false, "Enable at least one layer before exporting TMX.", List.of(), List.of());
}
if (!binding.enabled()) {
return new TiledAssetGenerationResult(false, "Layer " + layerIndex + " is disabled.", List.of());
final String sceneTilemap = resolveSceneTilemap(metadata);
if (sceneTilemap == null) {
return new TiledAssetGenerationResult(false, "Scene TMX export requires a single shared tilemap binding across Scene Bank layers.", List.of(), List.of());
}
try {
final String tilesetAssetRoot = Objects.requireNonNullElse(binding.tilesetAssetRoot(), "").trim();
if (tilesetAssetRoot.isBlank()) {
return new TiledAssetGenerationResult(
false,
"Layer " + binding.index() + " must declare a non-blank tileset_asset_root before TMX generation.",
List.of());
}
final Path tilesetPath = projectReference.rootPath()
.resolve("assets")
.resolve(tilesetAssetRoot)
.resolve(GENERATED_TSX_FILE)
.toAbsolutePath()
.normalize();
if (!java.nio.file.Files.isRegularFile(tilesetPath)) {
return new TiledAssetGenerationResult(
false,
"Referenced TSX was not found for layer " + binding.index() + ": " + tilesetAssetRoot,
List.of());
}
final TiledTilesetDocument tileset = codec.readTileset(tilesetPath);
final String relativeTilesetPath = details.summary().assetRoot()
.toAbsolutePath()
.normalize()
.relativize(tilesetPath)
.toString()
.replace('\\', '/');
final int tileCount = binding.width() * binding.height();
final List<Long> gids = new ArrayList<>(tileCount);
for (int index = 0; index < tileCount; index += 1) {
gids.add(0L);
final ExportContext context = buildExportContext(projectReference, details, enabledLayers);
final List<TiledTileLayer> tileLayers = new ArrayList<>();
int nextLayerId = 1;
for (AssetStudioSceneLayerBinding binding : enabledLayers) {
final TilesetRef ref = context.tilesetByAssetRoot().get(binding.tilesetAssetRoot());
final List<Long> gids = new ArrayList<>(binding.tiles().size());
for (AssetStudioSceneTile tile : binding.tiles()) {
if (!tile.active()) {
gids.add(0L);
continue;
}
if (tile.glyphId() >= ref.tileCount()) {
return new TiledAssetGenerationResult(
false,
"Layer " + binding.index() + " references glyph_id outside the exported TSX range.",
List.of(),
List.of());
}
long gid = ref.firstGid() + tile.glyphId();
if (tile.flipX()) {
gid |= FLIP_HORIZONTAL_FLAG;
}
if (tile.flipY()) {
gid |= FLIP_VERTICAL_FLAG;
}
gids.add(gid);
}
tileLayers.add(new TiledTileLayer(
nextLayerId,
binding.layerName(),
binding.width(),
binding.height(),
gids,
List.of()));
nextLayerId += 1;
}
final TiledMapDocument document = new TiledMapDocument(
"1.10",
"1.12.1",
"orthogonal",
"right-down",
binding.width(),
binding.height(),
tileset.tileWidth(),
tileset.tileHeight(),
2,
enabledLayers.stream().mapToInt(AssetStudioSceneLayerBinding::width).max().orElse(1),
enabledLayers.stream().mapToInt(AssetStudioSceneLayerBinding::height).max().orElse(1),
context.tileSize(),
context.tileSize(),
nextLayerId,
1,
List.of(),
List.of(new TiledTilesetReference(1, relativeTilesetPath)),
List.of(new TiledTileLayer(
1,
context.tilesetRefs(),
tileLayers,
List.of());
final Path outputPath = details.summary().assetRoot().resolve(sceneTilemap);
final ArrayList<Path> writtenFiles = new ArrayList<>();
if (disposition == SceneTmxExportDisposition.KEEP_BOTH && Files.isRegularFile(outputPath)) {
final Path backupPath = nextSceneTmxBackupPath(outputPath);
Files.copy(outputPath, backupPath);
writtenFiles.add(backupPath);
}
codec.writeMap(outputPath, document);
writtenFiles.add(outputPath);
return new TiledAssetGenerationResult(true, "Exported TMX successfully.", enabledLayers, List.copyOf(writtenFiles));
} catch (IOException | TiledUnsupportedFeatureException exception) {
return new TiledAssetGenerationResult(false, "Unable to export TMX: " + exception.getMessage(), List.of(), List.of());
}
}
public TiledAssetGenerationResult importSceneBankTmx(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
if (details == null || details.summary().assetFamily() != AssetFamilyCatalog.SCENE_BANK) {
return new TiledAssetGenerationResult(false, "TMX import is available only for Scene Bank assets.", List.of(), List.of());
}
final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata();
if (metadata == null) {
return new TiledAssetGenerationResult(false, "Scene Bank support metadata is missing or invalid.", List.of(), List.of());
}
final String sceneTilemap = resolveSceneTilemap(metadata);
if (sceneTilemap == null) {
return new TiledAssetGenerationResult(false, "Scene TMX import requires a single shared tilemap binding across Scene Bank layers.", List.of(), List.of());
}
final Path inputPath = details.summary().assetRoot().resolve(sceneTilemap);
if (!Files.isRegularFile(inputPath)) {
return new TiledAssetGenerationResult(false, "TMX import source is missing: " + sceneTilemap, List.of(), List.of());
}
try {
final List<AssetStudioSceneLayerBinding> enabledLayers = metadata.layerBindings().stream()
.filter(AssetStudioSceneLayerBinding::enabled)
.toList();
final ExportContext context = buildExportContext(projectReference, details, enabledLayers);
final TiledMapDocument map = codec.readMap(inputPath);
if (map.tileLayers().isEmpty()) {
return new TiledAssetGenerationResult(false, "TMX import rejected: at least one tile layer is required.", List.of(), List.of());
}
if (map.tileLayers().size() > metadata.layerCount()) {
return new TiledAssetGenerationResult(false, "TMX import rejected: more than four tile layers are not supported.", List.of(), List.of());
}
if (map.tilesets().size() != context.tilesetRefs().size()) {
return new TiledAssetGenerationResult(false, "TMX import rejected: unexpected tileset count.", List.of(), List.of());
}
for (int index = 0; index < context.tilesetRefs().size(); index += 1) {
if (!context.tilesetRefs().get(index).source().equals(map.tilesets().get(index).source())) {
return new TiledAssetGenerationResult(false, "TMX import rejected: unexpected tileset reference.", List.of(), List.of());
}
}
final List<AssetStudioSceneLayerBinding> updatedBindings = new ArrayList<>();
for (int bindingIndex = 0; bindingIndex < metadata.layerBindings().size(); bindingIndex += 1) {
final AssetStudioSceneLayerBinding binding = metadata.layerBindings().get(bindingIndex);
if (bindingIndex >= map.tileLayers().size()) {
updatedBindings.add(new AssetStudioSceneLayerBinding(
false,
binding.index(),
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());
binding.tilemap(),
binding.tilesetAssetRoot(),
binding.parallaxFactorX(),
binding.parallaxFactorY(),
binding.defaultPaletteId(),
binding.tiles()));
continue;
}
final TiledTileLayer importedLayer = map.tileLayers().get(bindingIndex);
if (importedLayer.gids().size() != importedLayer.width() * importedLayer.height()) {
return new TiledAssetGenerationResult(false, "TMX import rejected: layer tile data does not match layer dimensions.", List.of(), List.of());
}
final TilesetRef ref = resolveImportedLayerTilesetRef(importedLayer, binding, context);
if (ref == null) {
return new TiledAssetGenerationResult(false, "TMX import rejected: layer uses an unexpected tileset.", List.of(), List.of());
}
final List<AssetStudioSceneTile> importedTiles = new ArrayList<>(importedLayer.width() * importedLayer.height());
for (long rawGid : importedLayer.gids()) {
if ((rawGid & FLIP_DIAGONAL_FLAG) != 0L) {
return new TiledAssetGenerationResult(false, "TMX import rejected: diagonal flips are not supported.", List.of(), List.of());
}
final boolean flipX = (rawGid & FLIP_HORIZONTAL_FLAG) != 0L;
final boolean flipY = (rawGid & FLIP_VERTICAL_FLAG) != 0L;
final long gid = rawGid & GID_MASK;
if (gid == 0L) {
importedTiles.add(AssetStudioSceneTile.inactive(binding.defaultPaletteId()));
continue;
}
if (gid < ref.firstGid() || gid >= ref.firstGid() + ref.tileCount()) {
return new TiledAssetGenerationResult(false, "TMX import rejected: tile uses an unexpected tileset.", List.of(), List.of());
}
final int tilesetTileId = (int) (gid - ref.firstGid());
final Integer glyphId = ref.glyphIdByTileId().get(tilesetTileId);
if (glyphId == null) {
return new TiledAssetGenerationResult(false, "TMX import rejected: tileset tile is missing glyph_id mapping.", List.of(), List.of());
}
importedTiles.add(new AssetStudioSceneTile(
true,
glyphId,
binding.defaultPaletteId(),
flipX,
flipY));
}
updatedBindings.add(new AssetStudioSceneLayerBinding(
true,
binding.index(),
binding.layerName(),
importedLayer.width(),
importedLayer.height(),
binding.tilemap(),
ref.assetRoot(),
binding.parallaxFactorX(),
binding.parallaxFactorY(),
binding.defaultPaletteId(),
importedTiles));
}
final int mapWidth = Math.max(map.width(), updatedBindings.stream()
.filter(AssetStudioSceneLayerBinding::enabled)
.mapToInt(AssetStudioSceneLayerBinding::width)
.max()
.orElse(metadata.mapWidth()));
final int mapHeight = Math.max(map.height(), updatedBindings.stream()
.filter(AssetStudioSceneLayerBinding::enabled)
.mapToInt(AssetStudioSceneLayerBinding::height)
.max()
.orElse(metadata.mapHeight()));
metadataService.writeSceneBankMetadata(details.summary().assetRoot(), new AssetStudioSceneBankMetadata(
mapWidth,
mapHeight,
metadata.layerCount(),
updatedBindings,
metadata.supportFile()));
return new TiledAssetGenerationResult(true, "Imported TMX successfully.", updatedBindings, List.of(inputPath, metadata.supportFile()));
} catch (IOException | TiledUnsupportedFeatureException exception) {
return new TiledAssetGenerationResult(false, "Unable to import TMX: " + exception.getMessage(), List.of(), List.of());
}
}
private TilesetRef resolveImportedLayerTilesetRef(
TiledTileLayer importedLayer,
AssetStudioSceneLayerBinding binding,
ExportContext context) {
TilesetRef resolved = null;
for (long rawGid : importedLayer.gids()) {
final long gid = rawGid & GID_MASK;
if (gid == 0L) {
continue;
}
final TilesetRef ref = context.tilesetByAssetRoot().values().stream()
.filter(candidate -> gid >= candidate.firstGid() && gid < candidate.firstGid() + candidate.tileCount())
.findFirst()
.orElse(null);
if (ref == null) {
return null;
}
if (resolved != null && !resolved.assetRoot().equals(ref.assetRoot())) {
return null;
}
resolved = ref;
}
if (resolved != null) {
return resolved;
}
final String declaredAssetRoot = Objects.requireNonNullElse(binding.tilesetAssetRoot(), "").trim();
if (!declaredAssetRoot.isBlank()) {
return context.tilesetByAssetRoot().get(declaredAssetRoot);
}
if (context.tilesetByAssetRoot().size() == 1) {
return context.tilesetByAssetRoot().values().iterator().next();
}
return null;
}
private ExportContext buildExportContext(
ProjectReference projectReference,
AssetWorkspaceAssetDetails details,
List<AssetStudioSceneLayerBinding> enabledLayers) throws IOException {
final LinkedHashMap<String, TilesetRef> refs = new LinkedHashMap<>();
final List<TiledTilesetReference> tilesetRefs = new ArrayList<>();
int tileSize = -1;
int nextFirstGid = 1;
for (AssetStudioSceneLayerBinding binding : enabledLayers) {
if (refs.containsKey(binding.tilesetAssetRoot())) {
continue;
}
final Path tilesetPath = projectReference.rootPath()
.resolve("assets")
.resolve(binding.tilesetAssetRoot())
.resolve(GENERATED_TSX_FILE)
.toAbsolutePath()
.normalize();
if (!Files.isRegularFile(tilesetPath)) {
throw new IOException("Referenced TSX was not found for layer " + binding.index() + ": " + binding.tilesetAssetRoot());
}
final TiledTilesetDocument tileset = codec.readTileset(tilesetPath);
if (tileSize < 0) {
tileSize = tileset.tileWidth();
} else if (tileSize != tileset.tileWidth() || tileSize != tileset.tileHeight()) {
throw new IOException("All exported Scene Bank tilesets must share the same tile size.");
}
final String relativeTilesetPath = details.summary().assetRoot()
.toAbsolutePath()
.normalize()
.relativize(tilesetPath)
.toString()
.replace('\\', '/');
refs.put(binding.tilesetAssetRoot(), new TilesetRef(
nextFirstGid,
binding.tilesetAssetRoot(),
relativeTilesetPath,
tileset.tileCount(),
glyphIdByTileId(tileset)));
tilesetRefs.add(new TiledTilesetReference(nextFirstGid, relativeTilesetPath));
nextFirstGid += tileset.tileCount();
}
if (tileSize <= 0) {
throw new IOException("Unable to resolve tile size for Scene Bank export.");
}
return new ExportContext(tileSize, List.copyOf(tilesetRefs), Map.copyOf(refs));
}
private String resolveSceneTilemap(AssetStudioSceneBankMetadata metadata) {
final LinkedHashSet<String> tilemaps = new LinkedHashSet<>();
for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) {
final String tilemap = Objects.requireNonNullElse(binding.tilemap(), "").trim();
if (!tilemap.isBlank()) {
tilemaps.add(tilemap);
}
}
return tilemaps.size() == 1 ? tilemaps.iterator().next() : null;
}
private int parseTileSize(List<PackerCodecConfigurationFieldDTO> metadataFields) {
final String value = metadataFields.stream()
.filter(field -> "tile_size".equals(field.key()))
@ -184,4 +416,56 @@ public final class TiledAssetGenerationService {
}
return 16;
}
private Path nextSceneTmxBackupPath(Path outputPath) {
final String filename = outputPath.getFileName().toString();
final int extensionOffset = filename.lastIndexOf('.');
final String stem = extensionOffset >= 0 ? filename.substring(0, extensionOffset) : filename;
final String extension = extensionOffset >= 0 ? filename.substring(extensionOffset) : "";
final Path parent = outputPath.getParent();
int sequence = 1;
while (true) {
final Path candidate = parent.resolve(stem + ".bkp-" + sequence + extension);
if (!Files.exists(candidate)) {
return candidate;
}
sequence += 1;
}
}
private Map<Integer, Integer> glyphIdByTileId(TiledTilesetDocument tileset) throws IOException {
final LinkedHashMap<Integer, Integer> glyphs = new LinkedHashMap<>();
for (TiledTilesetTile tile : tileset.tiles()) {
final Integer glyphId = tile.properties().stream()
.filter(property -> "glyph_id".equals(property.name()))
.findFirst()
.map(TiledProperty::value)
.map(String::trim)
.filter(value -> !value.isBlank())
.map(Integer::parseInt)
.orElse(null);
if (glyphId == null || glyphId < 0) {
throw new IOException("TSX tile " + tile.id() + " is missing a valid glyph_id property.");
}
glyphs.put(tile.id(), glyphId);
}
if (glyphs.size() != tileset.tileCount()) {
throw new IOException("TSX tile count does not match declared tile_count.");
}
return Map.copyOf(glyphs);
}
private record ExportContext(
int tileSize,
List<TiledTilesetReference> tilesetRefs,
Map<String, TilesetRef> tilesetByAssetRoot) {
}
private record TilesetRef(
int firstGid,
String assetRoot,
String source,
int tileCount,
Map<Integer, Integer> glyphIdByTileId) {
}
}

View File

@ -147,8 +147,9 @@ 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.exportTsx=Export TSX
assets.action.exportTmx=Export TMX
assets.action.importTmx=Import TMX
assets.action.validateSceneBank=Validate Scene Bank
assets.action.acceptSceneBank=Accept Scene Bank
assets.action.validateLayer=Validate Layer
@ -194,11 +195,13 @@ 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.layer=Layer {0}
assets.label.layerOverview=Layer Overview
assets.label.tilemaps=Tilemaps
assets.label.tilemap=Tilemap
assets.label.tileset=Tileset
assets.label.parallax=Parallax
assets.label.defaultPalette=Default Palette
assets.label.supportFile=Support File
assets.label.useLayer=Use this layer
assets.type.glyphBank=Glyph Bank

View File

@ -99,10 +99,10 @@ final class AssetStudioMetadataServiceTest {
"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": "" }
{ "index": 1, "enabled": true, "name": "Ground", "width": 20, "height": 12, "tilemap": "ground.tmx", "tileset_asset_root": "tilesets/ground", "parallax_factor_x": 1.0, "parallax_factor_y": 1.0, "default_palette_id": 2 },
{ "index": 2, "enabled": true, "name": "Collision", "width": 10, "height": 8, "tilemap": "collision.tmx", "tileset_asset_root": "tilesets/ground", "parallax_factor_x": 0.5, "parallax_factor_y": 0.75, "default_palette_id": 3 },
{ "index": 3, "enabled": false, "name": "Layer 3", "width": 20, "height": 12, "tilemap": "layer-3.tmx", "tileset_asset_root": "", "default_palette_id": 0 },
{ "index": 4, "enabled": false, "name": "Layer 4", "width": 20, "height": 12, "tilemap": "layer-4.tmx", "tileset_asset_root": "", "default_palette_id": 0 }
]
}
""");
@ -115,6 +115,11 @@ final class AssetStudioMetadataServiceTest {
assertEquals(4, snapshot.sceneBankMetadata().layerCount());
assertEquals(10, snapshot.sceneBankMetadata().layerBindings().get(1).width());
assertEquals(8, snapshot.sceneBankMetadata().layerBindings().get(1).height());
assertEquals(0.5f, snapshot.sceneBankMetadata().layerBindings().get(1).parallaxFactorX());
assertEquals(0.75f, snapshot.sceneBankMetadata().layerBindings().get(1).parallaxFactorY());
assertEquals(3, snapshot.sceneBankMetadata().layerBindings().get(1).defaultPaletteId());
assertEquals(20 * 12, snapshot.sceneBankMetadata().layerBindings().getFirst().tiles().size());
assertFalse(snapshot.sceneBankMetadata().layerBindings().getFirst().tiles().getFirst().active());
assertEquals(
java.util.List.of("ground.tmx", "collision.tmx"),
snapshot.sceneBankMetadata().layerBindings().stream()
@ -134,9 +139,9 @@ final class AssetStudioMetadataServiceTest {
"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": "" }
{ "index": 1, "enabled": true, "name": "A", "width": 16, "height": 16, "tilemap": "a.tmx", "tileset_asset_root": "", "default_palette_id": 0 },
{ "index": 2, "enabled": false, "name": "B", "width": 16, "height": 16, "tilemap": "b.tmx", "tileset_asset_root": "", "default_palette_id": 0 },
{ "index": 3, "enabled": false, "name": "C", "width": 16, "height": 16, "tilemap": "c.tmx", "tileset_asset_root": "", "default_palette_id": 0 }
]
}
""");
@ -145,4 +150,76 @@ final class AssetStudioMetadataServiceTest {
assertNull(snapshot.sceneBankMetadata());
}
@Test
void writesSceneBankSupportMetadataWithCanonicalLayerFields() throws Exception {
final Path assetRoot = tempDir.resolve("scene-written");
Files.createDirectories(assetRoot);
final AssetStudioSceneBankMetadata metadata = new AssetStudioSceneBankMetadata(
16,
12,
4,
List.of(
new AssetStudioSceneLayerBinding(true, 1, "Ground", 16, 12, "ground.tmx", "tilesets/ground", 1.0f, 1.0f, 2),
new AssetStudioSceneLayerBinding(false, 2, "Layer 2", 16, 12, "layer-2.tmx", "", 1.0f, 1.0f, 0),
new AssetStudioSceneLayerBinding(false, 3, "Layer 3", 16, 12, "layer-3.tmx", "", 1.0f, 1.0f, 0),
new AssetStudioSceneLayerBinding(false, 4, "Layer 4", 16, 12, "layer-4.tmx", "", 1.0f, 1.0f, 0)),
assetRoot.resolve(AssetStudioMetadataService.SCENE_BANK_SUPPORT_FILE));
service.writeSceneBankMetadata(assetRoot, metadata);
final String json = Files.readString(assetRoot.resolve(AssetStudioMetadataService.SCENE_BANK_SUPPORT_FILE));
assertTrue(json.contains("\"parallax_factor_x\""));
assertTrue(json.contains("\"parallax_factor_y\""));
assertTrue(json.contains("\"default_palette_id\""));
}
@Test
void writesSceneBankSupportMetadataWithCanonicalTilesWhenPresent() throws Exception {
final Path assetRoot = tempDir.resolve("scene-tiles");
Files.createDirectories(assetRoot);
final AssetStudioSceneBankMetadata metadata = new AssetStudioSceneBankMetadata(
2,
2,
4,
List.of(
new AssetStudioSceneLayerBinding(
true,
1,
"Ground",
2,
2,
"ground.tmx",
"tilesets/ground",
1.0f,
1.0f,
2,
List.of(
new AssetStudioSceneTile(true, 7, 2, false, false),
AssetStudioSceneTile.inactive(2),
AssetStudioSceneTile.inactive(2),
AssetStudioSceneTile.inactive(2))),
new AssetStudioSceneLayerBinding(false, 2, "Layer 2", 2, 2, "layer-2.tmx", "", 1.0f, 1.0f, 0),
new AssetStudioSceneLayerBinding(false, 3, "Layer 3", 2, 2, "layer-3.tmx", "", 1.0f, 1.0f, 0),
new AssetStudioSceneLayerBinding(false, 4, "Layer 4", 2, 2, "layer-4.tmx", "", 1.0f, 1.0f, 0)),
assetRoot.resolve(AssetStudioMetadataService.SCENE_BANK_SUPPORT_FILE));
service.writeSceneBankMetadata(assetRoot, metadata);
final String json = Files.readString(assetRoot.resolve(AssetStudioMetadataService.SCENE_BANK_SUPPORT_FILE));
assertTrue(json.contains("\"tiles\""));
assertTrue(json.contains("\"glyph_id\""));
}
@Test
void providesDefaultSceneBankMetadataWhenSupportFileIsMissing() throws Exception {
final Path assetRoot = tempDir.resolve("scene-missing");
Files.createDirectories(assetRoot);
final AssetStudioMetadataSnapshot snapshot = service.read(assetRoot, AssetFamilyCatalog.SCENE_BANK);
assertNotNull(snapshot.sceneBankMetadata());
assertEquals(16, snapshot.sceneBankMetadata().mapWidth());
assertEquals("scene.tmx", snapshot.sceneBankMetadata().layerBindings().getFirst().tilemap());
}
}

View File

@ -1,5 +1,7 @@
package p.studio.workspaces.assets.scene;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.packer.dtos.PackerCodecConfigurationFieldDTO;
@ -12,50 +14,40 @@ 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.metadata.AssetStudioSceneTile;
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.ArrayList;
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();
private final ObjectMapper mapper = new ObjectMapper();
private final SceneBankWorkflowService workflowService = new SceneBankWorkflowService((projectReference, tilesetAssetRoot) ->
"tilesets/overworld".equals(tilesetAssetRoot) ? 1 : -1);
@TempDir
Path tempDir;
@Test
void validateThenAcceptPromotesSceneBankToReadyUntilFilesChange() throws Exception {
void inspectProjectsCanonicalSceneStateIntoReadyRequest() 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 result = workflowService.inspect(project, sceneDetails);
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());
assertEquals(AssetWorkspaceSceneBankStatus.READY, result.validation().status());
assertFalse(result.validation().pendingExternalChanges());
assertFalse(result.validation().canAccept());
assertTrue(Files.isRegularFile(sceneDetails.summary().assetRoot().resolve(SceneBankWorkflowService.ACCEPTED_REQUEST_FILE)));
}
@Test
void validationFailsWhenReferencedTsxIsMissing() throws Exception {
void validationFailsWhenReferencedGlyphAssetCannotResolveTileSize() throws Exception {
final ProjectReference project = projectReference();
final Path sceneRoot = project.rootPath().resolve("assets/scenes/broken");
Files.createDirectories(sceneRoot);
@ -86,29 +78,43 @@ final class SceneBankWorkflowServiceTest {
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", "")),
new AssetStudioSceneLayerBinding(true, 1, "Ground", 8, 8, "ground.tmx", "tilesets/missing", 1.0f, 1.0f, 0),
new AssetStudioSceneLayerBinding(false, 2, "Layer 2", 8, 8, "layer-2.tmx", "", 1.0f, 1.0f, 0),
new AssetStudioSceneLayerBinding(false, 3, "Layer 3", 8, 8, "layer-3.tmx", "", 1.0f, 1.0f, 0),
new AssetStudioSceneLayerBinding(false, 4, "Layer 4", 8, 8, "layer-4.tmx", "", 1.0f, 1.0f, 0)),
sceneRoot.resolve("scene-bank.studio.json")),
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
Files.writeString(sceneRoot.resolve("ground.tmx"), """
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.1" orientation="orthogonal" renderorder="right-down"
width="8" height="8" tilewidth="16" tileheight="16" infinite="0" nextlayerid="2" nextobjectid="1">
<tileset firstgid="1" source="../../tilesets/missing/tileset.tsx"/>
<layer id="1" name="Ground" width="8" height="8">
<data encoding="csv">0</data>
</layer>
</map>
""");
final SceneBankWorkflowResult result = workflowService.inspect(project, brokenScene);
assertEquals(AssetWorkspaceSceneBankStatus.VALIDATION_FAILED, result.validation().status());
assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("Referenced TSX file is missing")));
assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("tile_size")));
}
@Test
void inspectPersistsCanonicalScenePackRequestUsingCanonicalTiles() throws Exception {
final ProjectReference project = projectReference();
final AssetWorkspaceAssetDetails sceneDetails = createValidScene(project);
final SceneBankWorkflowResult result = workflowService.inspect(project, sceneDetails);
assertEquals(AssetWorkspaceSceneBankStatus.READY, result.validation().status());
final JsonNode request = mapper.readTree(sceneDetails.summary().assetRoot().resolve(SceneBankWorkflowService.ACCEPTED_REQUEST_FILE).toFile());
assertEquals("SCENE/tilemap_v1", request.path("format").asText());
assertEquals(1, request.path("version").asInt());
final JsonNode firstLayer = request.path("layers").get(0);
assertTrue(firstLayer.path("active").asBoolean());
assertEquals(1, firstLayer.path("glyph_asset_id").asInt());
assertEquals(2, firstLayer.path("tiles").get(0).path("palette_id").asInt());
assertEquals(7, firstLayer.path("tiles").get(0).path("glyph_id").asInt());
assertEquals(3, firstLayer.path("tiles").get(1).path("palette_id").asInt());
assertEquals(9, firstLayer.path("tiles").get(1).path("glyph_id").asInt());
assertTrue(firstLayer.path("tiles").get(1).path("flip_x").asBoolean());
final JsonNode secondLayer = request.path("layers").get(1);
assertFalse(secondLayer.path("active").asBoolean());
assertEquals(1, secondLayer.path("glyph_asset_id").asInt());
assertEquals(16, secondLayer.path("tile_size").asInt());
}
private ProjectReference projectReference() {
@ -119,50 +125,19 @@ final class SceneBankWorkflowServiceTest {
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());
Files.writeString(tilesetRoot.resolve("asset.json"), """
{
"output": {
"metadata": {
"tile_size": "16x16"
}
}
}
""");
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());
return sceneDetails(sceneRoot);
}
private AssetWorkspaceAssetDetails sceneDetails(Path assetRoot) {
@ -183,7 +158,13 @@ final class SceneBankWorkflowServiceTest {
OutputCodecCatalog.NONE,
List.of(OutputCodecCatalog.NONE),
Map.of(OutputCodecCatalog.NONE, List.of()),
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(), 0L),
List.of(),
@ -193,12 +174,33 @@ final class SceneBankWorkflowServiceTest {
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", "")),
new AssetStudioSceneLayerBinding(
true,
1,
"Ground",
16,
12,
"ground.tmx",
"tilesets/overworld",
1.0f,
1.0f,
2,
canonicalTiles(16, 12)),
new AssetStudioSceneLayerBinding(false, 2, "Layer 2", 16, 12, "layer-2.tmx", "", 1.0f, 1.0f, 0),
new AssetStudioSceneLayerBinding(false, 3, "Layer 3", 16, 12, "layer-3.tmx", "", 1.0f, 1.0f, 0),
new AssetStudioSceneLayerBinding(false, 4, "Layer 4", 16, 12, "layer-4.tmx", "", 1.0f, 1.0f, 0)),
assetRoot.resolve("scene-bank.studio.json")),
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
}
private List<AssetStudioSceneTile> canonicalTiles(int width, int height) {
final ArrayList<AssetStudioSceneTile> tiles = new ArrayList<>(width * height);
tiles.add(new AssetStudioSceneTile(true, 7, 2, false, false));
tiles.add(new AssetStudioSceneTile(true, 9, 3, true, false));
while (tiles.size() < width * height) {
tiles.add(AssetStudioSceneTile.inactive(2));
}
return List.copyOf(tiles);
}
}

View File

@ -10,12 +10,15 @@ 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.AssetStudioMetadataService;
import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioSceneLayerBinding;
import p.studio.workspaces.assets.metadata.AssetStudioSceneTile;
import p.studio.workspaces.assets.messages.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@ -29,13 +32,13 @@ final class TiledAssetGenerationServiceTest {
Path tempDir;
@Test
void generatesTsxForTilesetSpecializedGlyphBank() throws Exception {
void exportsTsxForTilesetSpecializedGlyphBank() 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));
final TiledAssetGenerationResult result = service.exportTilesetTsx(tilesetDetails(assetRoot));
assertTrue(result.success());
final Path tsxPath = assetRoot.resolve(TiledAssetGenerationService.GENERATED_TSX_FILE);
@ -47,28 +50,373 @@ final class TiledAssetGenerationServiceTest {
}
@Test
void generatesTmxFilesForSceneBankUsingReferencedTsx() throws Exception {
void exportsSingleSceneTmxFromCanonicalState() 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());
Files.writeString(tilesetRoot.resolve("b.png"), "fixture");
Files.writeString(tilesetRoot.resolve("asset.json"), """
{
"output": {
"metadata": {
"tile_size": "16x16"
}
}
}
""");
assertTrue(service.exportTilesetTsx(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);
final TiledAssetGenerationResult result = service.exportSceneBankTmx(projectReference, details);
assertTrue(result.success());
final Path tmxPath = sceneRoot.resolve("ground.tmx");
final Path tmxPath = sceneRoot.resolve("scene.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(2, map.tileLayers().size());
assertEquals("Ground", map.tileLayers().getFirst().name());
assertEquals(1L, map.tileLayers().getFirst().gids().getFirst());
}
@Test
void exportsSceneTmxKeepingExistingFileAsBackup() throws Exception {
final Path projectRoot = tempDir.resolve("project-export-backup");
final Path tilesetRoot = projectRoot.resolve("assets/tilesets/overworld");
Files.createDirectories(tilesetRoot);
Files.writeString(tilesetRoot.resolve("a.png"), "fixture");
Files.writeString(tilesetRoot.resolve("b.png"), "fixture");
Files.writeString(tilesetRoot.resolve("asset.json"), """
{
"output": {
"metadata": {
"tile_size": "16x16"
}
}
}
""");
assertTrue(service.exportTilesetTsx(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 Path tmxPath = sceneRoot.resolve("scene.tmx");
Files.writeString(tmxPath, "legacy");
final TiledAssetGenerationResult result = service.exportSceneBankTmx(
projectReference,
details,
TiledAssetGenerationService.SceneTmxExportDisposition.KEEP_BOTH);
assertTrue(result.success());
final Path backupPath = sceneRoot.resolve("scene.bkp-1.tmx");
assertTrue(Files.isRegularFile(backupPath));
assertEquals("legacy", Files.readString(backupPath));
assertNotEquals("legacy", Files.readString(tmxPath));
assertTrue(result.writtenFiles().contains(backupPath));
assertTrue(result.writtenFiles().contains(tmxPath));
}
@Test
void importsSceneTmxIntoCanonicalTiles() throws Exception {
final Path projectRoot = tempDir.resolve("project-import");
final Path tilesetRoot = projectRoot.resolve("assets/tilesets/overworld");
Files.createDirectories(tilesetRoot);
Files.writeString(tilesetRoot.resolve("a.png"), "fixture");
Files.writeString(tilesetRoot.resolve("b.png"), "fixture");
Files.writeString(tilesetRoot.resolve("asset.json"), """
{
"output": {
"metadata": {
"tile_size": "16x16"
}
}
}
""");
assertTrue(service.exportTilesetTsx(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);
assertTrue(service.exportSceneBankTmx(projectReference, details).success());
final Path tmxPath = sceneRoot.resolve("scene.tmx");
Files.writeString(tmxPath, Files.readString(tmxPath).replaceFirst("1,0,0,0", "2147483650,0,0,0"));
final TiledAssetGenerationResult result = service.importSceneBankTmx(projectReference, details);
assertTrue(result.success());
final AssetStudioMetadataService metadataService = new AssetStudioMetadataService();
final var snapshot = metadataService.read(sceneRoot, AssetFamilyCatalog.SCENE_BANK);
assertNotNull(snapshot.sceneBankMetadata());
assertTrue(snapshot.sceneBankMetadata().layerBindings().getFirst().tiles().getFirst().active());
assertEquals(1, snapshot.sceneBankMetadata().layerBindings().getFirst().tiles().getFirst().glyphId());
assertTrue(snapshot.sceneBankMetadata().layerBindings().getFirst().tiles().getFirst().flipX());
}
@Test
void importsSceneTmxUsingGlyphIdPropertyFromTsx() throws Exception {
final Path projectRoot = tempDir.resolve("project-import-glyph-property");
final Path tilesetRoot = projectRoot.resolve("assets/tilesets/overworld");
Files.createDirectories(tilesetRoot);
Files.writeString(tilesetRoot.resolve("a.png"), "fixture");
Files.writeString(tilesetRoot.resolve("b.png"), "fixture");
Files.writeString(tilesetRoot.resolve("asset.json"), """
{
"output": {
"metadata": {
"tile_size": "16x16"
}
}
}
""");
assertTrue(service.exportTilesetTsx(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);
assertTrue(service.exportSceneBankTmx(projectReference, details).success());
final Path tsxPath = tilesetRoot.resolve(TiledAssetGenerationService.GENERATED_TSX_FILE);
Files.writeString(tsxPath, Files.readString(tsxPath)
.replace("value=\"0\"", "value=\"7\"")
.replace("value=\"1\"", "value=\"3\""));
final Path tmxPath = sceneRoot.resolve("scene.tmx");
Files.writeString(tmxPath, Files.readString(tmxPath).replaceFirst("1,0,0,0", "2,0,0,0"));
final TiledAssetGenerationResult result = service.importSceneBankTmx(projectReference, details);
assertTrue(result.success());
final AssetStudioMetadataService metadataService = new AssetStudioMetadataService();
final var snapshot = metadataService.read(sceneRoot, AssetFamilyCatalog.SCENE_BANK);
assertNotNull(snapshot.sceneBankMetadata());
assertEquals(3, snapshot.sceneBankMetadata().layerBindings().getFirst().tiles().getFirst().glyphId());
}
@Test
void importsSceneTmxAndPropagatesLayerDimensionsFromTmx() throws Exception {
final Path projectRoot = tempDir.resolve("project-import-resize");
final Path tilesetRoot = projectRoot.resolve("assets/tilesets/overworld");
Files.createDirectories(tilesetRoot);
Files.writeString(tilesetRoot.resolve("a.png"), "fixture");
Files.writeString(tilesetRoot.resolve("b.png"), "fixture");
Files.writeString(tilesetRoot.resolve("asset.json"), """
{
"output": {
"metadata": {
"tile_size": "16x16"
}
}
}
""");
assertTrue(service.exportTilesetTsx(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);
assertTrue(service.exportSceneBankTmx(projectReference, details).success());
final Path tmxPath = sceneRoot.resolve("scene.tmx");
final String resizedTmx = """
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.1" orientation="orthogonal" renderorder="right-down" width="6" height="5" tilewidth="16" tileheight="16" infinite="0" nextlayerid="3" nextobjectid="1">
<tileset firstgid="1" source="../../tilesets/overworld/tileset.tsx"/>
<layer id="1" name="Ground" width="6" height="5">
<data encoding="csv">
1,0,0,0,0,0,
0,0,0,0,0,0,
0,0,0,0,0,0,
0,0,0,0,0,0,
0,0,0,0,0,0
</data>
</layer>
<layer id="2" name="Decor" width="3" height="2">
<data encoding="csv">
2,0,0,
0,0,0
</data>
</layer>
</map>
""";
Files.writeString(tmxPath, resizedTmx);
final TiledAssetGenerationResult result = service.importSceneBankTmx(projectReference, details);
assertTrue(result.success());
final AssetStudioMetadataService metadataService = new AssetStudioMetadataService();
final var snapshot = metadataService.read(sceneRoot, AssetFamilyCatalog.SCENE_BANK);
assertNotNull(snapshot.sceneBankMetadata());
assertEquals(6, snapshot.sceneBankMetadata().mapWidth());
assertEquals(5, snapshot.sceneBankMetadata().mapHeight());
assertEquals(6, snapshot.sceneBankMetadata().layerBindings().get(0).width());
assertEquals(5, snapshot.sceneBankMetadata().layerBindings().get(0).height());
assertEquals(3, snapshot.sceneBankMetadata().layerBindings().get(1).width());
assertEquals(2, snapshot.sceneBankMetadata().layerBindings().get(1).height());
assertEquals(30, snapshot.sceneBankMetadata().layerBindings().get(0).tiles().size());
assertEquals(6, snapshot.sceneBankMetadata().layerBindings().get(1).tiles().size());
assertEquals("Ground", snapshot.sceneBankMetadata().layerBindings().get(0).layerName());
assertEquals("Decor", snapshot.sceneBankMetadata().layerBindings().get(1).layerName());
}
@Test
void importsSceneTmxAndPromotesNewLayerIntoDisabledStudioSlot() throws Exception {
final Path projectRoot = tempDir.resolve("project-import-new-layer");
final Path tilesetRoot = projectRoot.resolve("assets/tilesets/overworld");
Files.createDirectories(tilesetRoot);
Files.writeString(tilesetRoot.resolve("a.png"), "fixture");
Files.writeString(tilesetRoot.resolve("b.png"), "fixture");
Files.writeString(tilesetRoot.resolve("asset.json"), """
{
"output": {
"metadata": {
"tile_size": "16x16"
}
}
}
""");
assertTrue(service.exportTilesetTsx(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);
assertTrue(service.exportSceneBankTmx(projectReference, details).success());
final Path tmxPath = sceneRoot.resolve("scene.tmx");
final String expandedTmx = """
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.1" orientation="orthogonal" renderorder="right-down" width="4" height="4" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="1">
<tileset firstgid="1" source="../../tilesets/overworld/tileset.tsx"/>
<layer id="1" name="Ground" width="4" height="4">
<data encoding="csv">
1,0,0,0,
0,0,0,0,
0,0,0,0,
0,0,0,0
</data>
</layer>
<layer id="2" name="Decor" width="4" height="4">
<data encoding="csv">
2,0,0,0,
0,0,0,0,
0,0,0,0,
0,0,0,0
</data>
</layer>
<layer id="3" name="Collision" width="2" height="2">
<data encoding="csv">
1,0,
0,0
</data>
</layer>
</map>
""";
Files.writeString(tmxPath, expandedTmx);
final TiledAssetGenerationResult result = service.importSceneBankTmx(projectReference, details);
assertTrue(result.success());
final AssetStudioMetadataService metadataService = new AssetStudioMetadataService();
final var snapshot = metadataService.read(sceneRoot, AssetFamilyCatalog.SCENE_BANK);
assertNotNull(snapshot.sceneBankMetadata());
assertEquals(3, snapshot.sceneBankMetadata().enabledLayerCount());
assertTrue(snapshot.sceneBankMetadata().layerBindings().get(2).enabled());
assertEquals("Layer 3", snapshot.sceneBankMetadata().layerBindings().get(2).layerName());
assertEquals("tilesets/overworld", snapshot.sceneBankMetadata().layerBindings().get(2).tilesetAssetRoot());
assertEquals(2, snapshot.sceneBankMetadata().layerBindings().get(2).width());
assertEquals(2, snapshot.sceneBankMetadata().layerBindings().get(2).height());
assertEquals(0, snapshot.sceneBankMetadata().layerBindings().get(2).tiles().getFirst().glyphId());
assertFalse(snapshot.sceneBankMetadata().layerBindings().get(3).enabled());
}
@Test
void rejectsSceneTmxImportWithoutTileLayers() throws Exception {
final Path projectRoot = tempDir.resolve("project-import-no-layers");
final Path tilesetRoot = projectRoot.resolve("assets/tilesets/overworld");
Files.createDirectories(tilesetRoot);
Files.writeString(tilesetRoot.resolve("a.png"), "fixture");
Files.writeString(tilesetRoot.resolve("b.png"), "fixture");
Files.writeString(tilesetRoot.resolve("asset.json"), """
{
"output": {
"metadata": {
"tile_size": "16x16"
}
}
}
""");
assertTrue(service.exportTilesetTsx(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 Path tmxPath = sceneRoot.resolve("scene.tmx");
Files.writeString(tmxPath, """
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.1" orientation="orthogonal" renderorder="right-down" width="4" height="4" tilewidth="16" tileheight="16" infinite="0" nextlayerid="1" nextobjectid="1">
<tileset firstgid="1" source="../../tilesets/overworld/tileset.tsx"/>
</map>
""");
final TiledAssetGenerationResult result = service.importSceneBankTmx(projectReference, details);
assertFalse(result.success());
assertEquals("TMX import rejected: at least one tile layer is required.", result.message());
}
@Test
void rejectsSceneTmxImportWithMoreThanFourTileLayers() throws Exception {
final Path projectRoot = tempDir.resolve("project-import-too-many-layers");
final Path tilesetRoot = projectRoot.resolve("assets/tilesets/overworld");
Files.createDirectories(tilesetRoot);
Files.writeString(tilesetRoot.resolve("a.png"), "fixture");
Files.writeString(tilesetRoot.resolve("b.png"), "fixture");
Files.writeString(tilesetRoot.resolve("asset.json"), """
{
"output": {
"metadata": {
"tile_size": "16x16"
}
}
}
""");
assertTrue(service.exportTilesetTsx(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 Path tmxPath = sceneRoot.resolve("scene.tmx");
Files.writeString(tmxPath, """
<?xml version="1.0" encoding="UTF-8"?>
<map version="1.10" tiledversion="1.12.1" orientation="orthogonal" renderorder="right-down" width="4" height="4" tilewidth="16" tileheight="16" infinite="0" nextlayerid="6" nextobjectid="1">
<tileset firstgid="1" source="../../tilesets/overworld/tileset.tsx"/>
<layer id="1" name="A" width="4" height="4"><data encoding="csv">1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0</data></layer>
<layer id="2" name="B" width="4" height="4"><data encoding="csv">1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0</data></layer>
<layer id="3" name="C" width="4" height="4"><data encoding="csv">1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0</data></layer>
<layer id="4" name="D" width="4" height="4"><data encoding="csv">1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0</data></layer>
<layer id="5" name="E" width="4" height="4"><data encoding="csv">1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0</data></layer>
</map>
""");
final TiledAssetGenerationResult result = service.importSceneBankTmx(projectReference, details);
assertFalse(result.success());
assertEquals("TMX import rejected: more than four tile layers are not supported.", result.message());
}
private AssetWorkspaceAssetDetails tilesetDetails(Path assetRoot) {
@ -134,16 +482,25 @@ final class TiledAssetGenerationServiceTest {
List.of(),
null,
new AssetStudioSceneBankMetadata(
16,
12,
4,
4,
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", "")),
new AssetStudioSceneLayerBinding(true, 1, "Ground", 4, 4, "scene.tmx", "tilesets/overworld", 1.0f, 1.0f, 0, canonicalTiles(4, 4, 0)),
new AssetStudioSceneLayerBinding(true, 2, "Decor", 4, 4, "scene.tmx", "tilesets/overworld", 1.0f, 1.0f, 0, canonicalTiles(4, 4, 1)),
new AssetStudioSceneLayerBinding(false, 3, "Layer 3", 4, 4, "scene.tmx", "", 1.0f, 1.0f, 0),
new AssetStudioSceneLayerBinding(false, 4, "Layer 4", 4, 4, "scene.tmx", "", 1.0f, 1.0f, 0)),
assetRoot.resolve("scene-bank.studio.json")),
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
}
private List<AssetStudioSceneTile> canonicalTiles(int width, int height, int glyphId) {
final ArrayList<AssetStudioSceneTile> tiles = new ArrayList<>(width * height);
tiles.add(new AssetStudioSceneTile(true, glyphId, 0, false, false));
while (tiles.size() < width * height) {
tiles.add(AssetStudioSceneTile.inactive(0));
}
return List.copyOf(tiles);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"schema_version" : 1,
"next_asset_id" : 18,
"next_asset_id" : 19,
"assets" : [ {
"asset_id" : 3,
"asset_uuid" : "21953cb8-4101-4790-9e5e-d95f5fbc9b5a",
@ -41,15 +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
"included_in_build" : true
}, {
"asset_id" : 18,
"asset_uuid" : "5084999c-66c6-4860-85e4-0f76dd0ea193",
"root" : "tiled2",
"included_in_build" : true
} ]
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,14 @@
{
"schema_version" : 1,
"asset_uuid" : "5084999c-66c6-4860-85e4-0f76dd0ea193",
"name" : "Tiled2",
"type" : "scene_bank",
"output" : {
"format" : "SCENE/tilemap_v1",
"pipeline" : { },
"codec" : "NONE"
},
"preload" : {
"enabled" : true
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -21,7 +21,7 @@
}
},
"preload" : {
"enabled" : false
"enabled" : true
},
"artifacts" : [ {
"artifact_id" : "art-67db3cae2605",

View File

@ -10,7 +10,10 @@ declare global loading_handle: int = -1;
declare global loading_status: int = -1;
declare global frame_counter: int = 0;
declare global tile_id: int = 0;
declare global camera_x: int = 0;
declare global camera_mult: int = 1;
declare const MAX_FRAMES: int = 4;
declare const CAMERA_SPEED: int = 1;
[Init]
fn init() -> void
@ -19,13 +22,14 @@ fn init() -> void
loading_status = -1;
frame_counter = 0;
tile_id = 0;
camera_x = 0;
camera_mult = 1;
Composer.bind_scene(0);
}
[Frame]
fn frame() -> void
{
Gfx.clear(new Color(6577));
if (loading_handle == -1) {
let t : (status: int, loading_handle: int) = Assets.load(assets.ui.atlas2, 3);
if (t.status != 0) {
@ -75,8 +79,15 @@ fn frame() -> void
}
}
Composer.emit_sprite(tile_id, 0, touch.x() - 16, touch.y() + 8, 0, 0, true, false, 0);
Composer.emit_sprite(tile_id, 0, touch.x() + 16, touch.y() + 8, 0, 1, false, false, 0);
camera_x = camera_x + (camera_mult * CAMERA_SPEED);
if (camera_x >= 240 || camera_x <= 0)
{
camera_mult = -1 * camera_mult;
}
Log.info("camera_x:" + camera_x);
Composer.set_camera(camera_x, 0);
Composer.emit_sprite(tile_id, 0, touch.x() - 16, touch.y() + 8, 2, 0, true, false, 0);
let a : int = 10;
let b : int = 15;