Compare commits

...

5 Commits

Author SHA1 Message Date
85d7211b3c Merge pull request 'Studio scene pack contract for runtime SCENE binary payload' (#7) from dev/studio-scene-pack-runtime-binary-contract into master
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/head This commit looks good
Reviewed-on: #7
2026-05-01 10:25:38 +00:00
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
1182d3ae1f
Studio scene pack contract for runtime SCENE binary payload
All checks were successful
Intrepid/Prometeu/Studio/pipeline/head This commit looks good
JaCoCo Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 58.63% (16555/28235) * Branch Coverage: 51.34% (6336/12341) * Lines of Code: 28235 * Cyclomatic Complexity: 11205 #### Quality Gates Summary Output truncated.
Test / Build skipped: 11, passed: 585
Intrepid/Prometeu/Studio/pipeline/pr-master This commit looks good
2026-04-27 06:47:18 +01:00
58 changed files with 189781 additions and 773 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,4 +1,5 @@
{"type":"meta","next_id":{"DSC":30,"AGD":32,"DEC":29,"PLN":59,"LSN":46,"CLSN":1}} {"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-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-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-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

@ -221,7 +221,8 @@ let status: int = Composer.emit_sprite(7, 0, 12, 18, 0, 2, false, false, 1);
Rules: 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. - `@sdk:gfx` remains valid for primitive and overlay-oriented graphics operations.
- `Gfx.set_sprite` is not part of the active stdlib line `1` contract. - `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 ```pbs
declare host LowComposer { 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)] [Host(module = "composer", name = "emit_sprite", version = 1)]
[Capability(name = "gfx")] [Capability(name = "gfx")]
fn emit_sprite( fn emit_sprite(
@ -160,6 +168,8 @@ declare host LowComposer {
maps to the canonical runtime identity: maps to the canonical runtime identity:
```text ```text
("composer", "bind_scene", 1)
("composer", "unbind_scene", 1)
("composer", "emit_sprite", 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`, - 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 declare `LowComposer` and public service `Composer`,
- that module MUST use runtime module `composer` and capability `gfx`, - 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`, - 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 NOT expose `Gfx.set_sprite`,
- stdlib line `1` MUST expose the low-level asset interface module at `@sdk:asset`, - 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 low-level host owner `LowComposer`.
- `@sdk:composer` MUST expose a public service facade `Composer`. - `@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.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. - `Composer.emit_sprite(...)` MUST return a raw `int` in this wave.
- `@sdk:gfx` MAY continue to expose primitive and overlay-oriented operations. - `@sdk:gfx` MAY continue to expose primitive and overlay-oriented operations.
- `@sdk:gfx` MUST NOT expose `set_sprite`. - `@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 ## 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; - `output.pipeline` carries tooling/build metadata kept separate at authoring time;
- codec must remain explicit and must not be hidden inside format naming. - 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 ### 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. 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; - 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. - 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 ## Determinism
Equivalent build inputs must produce equivalent outputs. Equivalent build inputs must produce equivalent outputs.

View File

@ -157,6 +157,25 @@ At minimum, the model should support fields equivalent to:
- `applied_actions` - `applied_actions`
- `blockers` - `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 ## Asset Event Lane
The system includes a dedicated asset workspace event/reporting 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`. - 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. - `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. - 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 such as `Exclude From Build`, `Remove`, and `Relocate` must be visually separated from routine actions.
- Sensitive actions must not look interchangeable with 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. - `Relocate` must collect an explicit user-chosen destination root before the staged preview is generated.

View File

@ -1,4 +1,16 @@
declare host LowComposer { 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)] [Host(module = "composer", name = "emit_sprite", version = 1)]
[Capability(name = "gfx")] [Capability(name = "gfx")]
fn emit_sprite( fn emit_sprite(
@ -16,6 +28,21 @@ declare host LowComposer {
declare service Composer 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( fn emit_sprite(
glyph_id: int, glyph_id: int,
palette_id: int, palette_id: int,

View File

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

View File

@ -103,6 +103,14 @@ class InterfaceModuleLoaderTest {
"main.pbs", "main.pbs",
""" """
declare host LowComposer { 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)] [Host(module = "composer", name = "emit_sprite", version = 1)]
[Capability(name = "gfx")] [Capability(name = "gfx")]
fn emit_sprite( fn emit_sprite(
@ -119,6 +127,14 @@ class InterfaceModuleLoaderTest {
} }
declare service Composer { 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( fn emit_sprite(
glyph_id: int, glyph_id: int,
palette_id: int, palette_id: int,

View File

@ -1069,6 +1069,85 @@ class PBSFrontendPhaseServiceTest {
&& h.abiMethod().equals("emit_sprite"))); && 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 @Test
void shouldRejectLegacyGfxSetSpriteAfterComposerMigration() throws IOException { void shouldRejectLegacyGfxSetSpriteAfterComposerMigration() throws IOException {
final var projectRoot = tempDir.resolve("project-bootstrap-sdk-gfx-set-sprite-removed"); 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.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import org.apache.commons.collections4.MapUtils; import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.StringUtils; 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.assets.OutputFormatCatalog;
import p.packer.messages.diagnostics.PackerDiagnosticCategory; import p.packer.messages.diagnostics.PackerDiagnosticCategory;
import p.packer.messages.diagnostics.PackerDiagnosticSeverity; import p.packer.messages.diagnostics.PackerDiagnosticSeverity;
import p.packer.models.*; import p.packer.models.*;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@ -26,11 +33,14 @@ public class PackerAssetWalker {
private static final Set<OutputFormatCatalog> SOUND_BANK_SUPPORTED_FORMATS = Set.of( private static final Set<OutputFormatCatalog> SOUND_BANK_SUPPORTED_FORMATS = Set.of(
OutputFormatCatalog.SOUND_V1 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 PackerGlyphBankWalker glyphBankWalker;
private final PackerSoundBankWalker soundBankWalker; private final PackerSoundBankWalker soundBankWalker;
public PackerAssetWalker(final ObjectMapper mapper) { public PackerAssetWalker(final ObjectMapper mapper) {
this.mapper = mapper;
this.glyphBankWalker = new PackerGlyphBankWalker(mapper); this.glyphBankWalker = new PackerGlyphBankWalker(mapper);
this.soundBankWalker = new PackerSoundBankWalker(mapper); this.soundBankWalker = new PackerSoundBankWalker(mapper);
} }
@ -101,7 +111,7 @@ public class PackerAssetWalker {
return new PackerWalkResult(walkResult.probeResults(), diagnostics); return new PackerWalkResult(walkResult.probeResults(), diagnostics);
} }
case SCENE_BANK -> { case SCENE_BANK -> {
return new PackerWalkResult(List.of(), diagnostics); return walkSceneBank(assetRoot, declaration, diagnostics);
} }
case UNKNOWN -> { case UNKNOWN -> {
diagnostics.add(new PackerDiagnostic( diagnostics.add(new PackerDiagnostic(
@ -116,6 +126,96 @@ public class PackerAssetWalker {
return PackerWalkResult.EMPTY; 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( private RequirementBuildResult<PackerGlyphBankRequirements> buildGlyphBankRequirements(
final PackerAssetDeclaration declaration, final PackerAssetDeclaration declaration,
final Map<String, String> metadata) { final Map<String, String> metadata) {

View File

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

View File

@ -40,6 +40,7 @@ import java.util.stream.Stream;
public final class FileSystemPackerWorkspaceService implements PackerWorkspaceService { public final class FileSystemPackerWorkspaceService implements PackerWorkspaceService {
private static final int GLYPH_BANK_COLOR_KEY_RGB565 = 0xF81F; private static final int GLYPH_BANK_COLOR_KEY_RGB565 = 0xF81F;
private static final String SCENE_BANK_SUPPORT_FILE = "scene-bank.studio.json"; private 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 ObjectMapper mapper;
private final PackerWorkspaceFoundation workspaceFoundation; private final PackerWorkspaceFoundation workspaceFoundation;
@ -699,8 +700,11 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
"name", "Layer " + index, "name", "Layer " + index,
"width", 16, "width", 16,
"height", 16, "height", 16,
"tilemap", "layer-" + index + ".tmx", "tilemap", "scene.tmx",
"tileset_asset_root", "")); "tileset_asset_root", "",
"parallax_factor_x", 1.0,
"parallax_factor_y", 1.0,
"default_palette_id", 0));
} }
supportFile.put("layers", layers); supportFile.put("layers", layers);
mapper.writerWithDefaultPrettyPrinter().writeValue( mapper.writerWithDefaultPrettyPrinter().writeValue(
@ -883,7 +887,8 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
if (declaration == null) { if (declaration == null) {
return List.of(); 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();
} }
return List.of(new PackerDiagnostic( return List.of(new PackerDiagnostic(
@ -1054,10 +1059,14 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
private PackedAsset packRuntimeAsset(PackerRuntimeAsset runtimeAsset) { private PackedAsset packRuntimeAsset(PackerRuntimeAsset runtimeAsset) {
final PackerAssetDeclaration declaration = runtimeAsset.parsedDeclaration().declaration(); 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."); 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) { private boolean isEffectivelyIncludedInPackSet(PackerRuntimeAsset runtimeAsset) {
@ -1065,7 +1074,13 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
return false; return false;
} }
final PackerAssetDeclaration declaration = runtimeAsset.parsedDeclaration().declaration(); 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) { 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 byte[] sheetPixels = new byte[width * height];
final Map<String, PackerRuntimeWalkFile> selectedByPath = new LinkedHashMap<>(); final Map<String, PackerRuntimeWalkFile> selectedByPath = new LinkedHashMap<>();
runtimeAsset.walkProjection().buildCandidateFiles().forEach(file -> selectedByPath.put(file.relativePath(), file)); runtimeAsset.walkProjection().buildCandidateFiles().forEach(file -> selectedByPath.put(file.relativePath(), file));
final Map<Integer, Integer> declaredPaletteIndexByRgb = declaredPaletteIndexByRgb(declaration);
declaration.artifacts().stream() declaration.artifacts().stream()
.sorted(Comparator.comparingInt(PackerAssetArtifactSelection::index)) .sorted(Comparator.comparingInt(PackerAssetArtifactSelection::index))
@ -1088,13 +1104,18 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
if (!(tileValue instanceof PackerTileIndexedV1 tile)) { if (!(tileValue instanceof PackerTileIndexedV1 tile)) {
throw new IllegalStateException("Selected tile artifact is missing normalized tile metadata: " + artifact.file()); 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 tileX = (artifact.index() % tilesPerRow) * tileSize;
final int tileY = (artifact.index() / tilesPerRow) * tileSize; final int tileY = (artifact.index() / tilesPerRow) * tileSize;
for (int localY = 0; localY < tile.height(); localY += 1) { for (int localY = 0; localY < tile.height(); localY += 1) {
for (int localX = 0; localX < tile.width(); localX += 1) { for (int localX = 0; localX < tile.width(); localX += 1) {
final int sourceOffset = localY * tile.width() + localX; final int sourceOffset = localY * tile.width() + localX;
final int targetOffset = (tileY + localY) * width + (tileX + 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()); 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) { private int parseTileSize(String value) {
return switch (Objects.requireNonNullElse(value, "")) { return switch (Objects.requireNonNullElse(value, "")) {
case "8x8" -> 8; case "8x8" -> 8;
@ -1192,6 +1316,67 @@ public final class FileSystemPackerWorkspaceService implements PackerWorkspaceSe
return palettesNode instanceof ArrayNode palettesArray ? palettesArray.size() : 0; 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) { private Map<String, Object> normalizeToolingPipelineMetadata(PackerAssetDeclaration declaration) {
return new LinkedHashMap<>(PackerReadMessageMapper.normalizeMetadata(declaration.outputPipelineMetadata())); return new LinkedHashMap<>(PackerReadMessageMapper.normalizeMetadata(declaration.outputPipelineMetadata()));
} }

View File

@ -125,6 +125,108 @@ final class PackerRuntimeAssetMaterializerTest {
assertTrue(file.contentBytes().get().length > 0); 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) { private static PackerAssetDeclaration declaration(Map<String, String> metadata) {
return declaration(metadata, List.of(), Map.of()); return declaration(metadata, List.of(), Map.of());
} }

View File

@ -1,6 +1,7 @@
package p.packer.services; package p.packer.services;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
@ -20,7 +21,9 @@ import javax.imageio.ImageIO;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.Comparator; import java.util.Comparator;
import java.util.HexFormat;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; 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()); 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 @Test
void validateAndPackIgnoreBlockingDiagnosticsFromUnselectedTileFiles() throws Exception { void validateAndPackIgnoreBlockingDiagnosticsFromUnselectedTileFiles() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("pack-ignore-unselected")); 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("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("width").asInt());
assertEquals(16, supportFile.path("layers").get(0).path("height").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 @Test
@ -1582,6 +1728,46 @@ final class FileSystemPackerWorkspaceServiceTest {
Files.writeString(assetRoot.resolve("glyph-material.json"), content); 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) { private static int readLeInt(byte[] bytes, int offset) {
return (bytes[offset] & 0xFF) return (bytes[offset] & 0xFF)
| ((bytes[offset + 1] & 0xFF) << 8) | ((bytes[offset + 1] & 0xFF) << 8)
@ -1589,6 +1775,11 @@ final class FileSystemPackerWorkspaceServiceTest {
| ((bytes[offset + 3] & 0xFF) << 24); | ((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) { private static long readLeLong(byte[] bytes, int offset) {
return ((long) bytes[offset] & 0xFF) return ((long) bytes[offset] & 0xFF)
| (((long) bytes[offset + 1] & 0xFF) << 8) | (((long) bytes[offset + 1] & 0xFF) << 8)
@ -1600,6 +1791,10 @@ final class FileSystemPackerWorkspaceServiceTest {
| (((long) bytes[offset + 7] & 0xFF) << 56); | (((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) { private static int readLeUnsignedShort(byte[] bytes, int offset) {
return (bytes[offset] & 0xFF) | ((bytes[offset + 1] & 0xFF) << 8); 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_ACTIONS_EMPTY("assets.actions.empty"),
ASSETS_ACTION_REGISTER("assets.action.register"), ASSETS_ACTION_REGISTER("assets.action.register"),
ASSETS_ACTION_ANALYSE("assets.action.analyse"), ASSETS_ACTION_ANALYSE("assets.action.analyse"),
ASSETS_ACTION_GENERATE_TSX("assets.action.generateTsx"), ASSETS_ACTION_EXPORT_TSX("assets.action.exportTsx"),
ASSETS_ACTION_GENERATE_TMX("assets.action.generateTmx"), ASSETS_ACTION_EXPORT_TMX("assets.action.exportTmx"),
ASSETS_ACTION_IMPORT_TMX("assets.action.importTmx"),
ASSETS_ACTION_VALIDATE_SCENE_BANK("assets.action.validateSceneBank"), ASSETS_ACTION_VALIDATE_SCENE_BANK("assets.action.validateSceneBank"),
ASSETS_ACTION_ACCEPT_SCENE_BANK("assets.action.acceptSceneBank"), ASSETS_ACTION_ACCEPT_SCENE_BANK("assets.action.acceptSceneBank"),
ASSETS_ACTION_VALIDATE_LAYER("assets.action.validateLayer"), ASSETS_ACTION_VALIDATE_LAYER("assets.action.validateLayer"),
@ -209,6 +210,8 @@ public enum I18n {
ASSETS_LABEL_TILEMAPS("assets.label.tilemaps"), ASSETS_LABEL_TILEMAPS("assets.label.tilemaps"),
ASSETS_LABEL_TILEMAP("assets.label.tilemap"), ASSETS_LABEL_TILEMAP("assets.label.tilemap"),
ASSETS_LABEL_TILESET("assets.label.tileset"), 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_SUPPORT_FILE("assets.label.supportFile"),
ASSETS_LABEL_USE_LAYER("assets.label.useLayer"), ASSETS_LABEL_USE_LAYER("assets.label.useLayer"),
ASSETS_VALUE_NOT_CONFIGURED("assets.value.notConfigured"), 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 -> { AddAssetWizard.showAndWait(root.getScene().getWindow(), projectReference).ifPresent(assetReference -> {
workspaceEventBus.publish(new StudioAssetsRefreshRequestedEvent(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.application.Platform;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.Button; import javafx.scene.control.*;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority; import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import org.apache.commons.collections4.CollectionUtils;
import p.packer.dtos.PackerAssetActionAvailabilityDTO; import p.packer.dtos.PackerAssetActionAvailabilityDTO;
import p.packer.dtos.PackerAssetDetailsDTO; import p.packer.dtos.PackerAssetDetailsDTO;
import p.packer.dtos.PackerDiagnosticDTO; import p.packer.dtos.PackerDiagnosticDTO;
@ -19,41 +18,37 @@ import p.studio.controls.forms.StudioSection;
import p.studio.lsp.events.StudioWorkspaceEventBus; import p.studio.lsp.events.StudioWorkspaceEventBus;
import p.studio.projects.ProjectReference; import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n; import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.assets.dialogs.AssetDiagnosticsDialog;
import p.studio.workspaces.assets.details.bank.AssetDetailsBankCompositionControl; import p.studio.workspaces.assets.details.bank.AssetDetailsBankCompositionControl;
import p.studio.workspaces.assets.details.contract.AssetDetailsContractControl; import p.studio.workspaces.assets.details.contract.AssetDetailsContractControl;
import p.studio.workspaces.assets.details.palette.AssetDetailsPaletteOverhaulingControl; import p.studio.workspaces.assets.details.palette.AssetDetailsPaletteOverhaulingControl;
import p.studio.workspaces.assets.details.schema.AssetDetailsColorSchemaGroupsControl;
import p.studio.workspaces.assets.details.scene.AssetDetailsSceneLayersControl; 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.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.AssetStudioMetadataService;
import p.studio.workspaces.assets.metadata.AssetStudioMetadataSnapshot; 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.SceneBankWorkflowResult;
import p.studio.workspaces.assets.scene.SceneBankWorkflowService; import p.studio.workspaces.assets.scene.SceneBankWorkflowService;
import p.studio.workspaces.assets.tiled.TiledAssetGenerationResult; import p.studio.workspaces.assets.tiled.TiledAssetGenerationResult;
import p.studio.workspaces.assets.tiled.TiledAssetGenerationService; 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.DeleteAssetDialog;
import p.studio.workspaces.assets.wizards.MoveAssetWizard; import p.studio.workspaces.assets.wizards.MoveAssetWizard;
import p.studio.workspaces.framework.StudioEventAware; import p.studio.workspaces.framework.StudioEventAware;
import p.studio.workspaces.framework.StudioEventBindings; import p.studio.workspaces.framework.StudioEventBindings;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import static java.util.Comparator.comparingInt;
public final class AssetDetailsControl extends VBox implements StudioEventAware { public final class AssetDetailsControl extends VBox implements StudioEventAware {
private final ProjectReference projectReference; private final ProjectReference projectReference;
private final StudioWorkspaceEventBus workspaceBus; private final StudioWorkspaceEventBus workspaceBus;
@ -93,9 +88,6 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
this.colorSchemaGroupsControl = new AssetDetailsColorSchemaGroupsControl(projectReference, workspaceBus); this.colorSchemaGroupsControl = new AssetDetailsColorSchemaGroupsControl(projectReference, workspaceBus);
this.paletteOverhaulingControl = new AssetDetailsPaletteOverhaulingControl(projectReference, workspaceBus); this.paletteOverhaulingControl = new AssetDetailsPaletteOverhaulingControl(projectReference, workspaceBus);
this.sceneLayersControl = new AssetDetailsSceneLayersControl(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(); this.actionsSection = createActionsSection();
getStyleClass().add("assets-workspace-pane"); getStyleClass().add("assets-workspace-pane");
@ -142,9 +134,7 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
loadSelection(viewState.selectedAssetReference()); loadSelection(viewState.selectedAssetReference());
return; return;
} }
if (event.preferredAssetReference() == null) { clearSelection();
clearSelection();
}
}); });
eventBindings.listen(workspaceBus, StudioAssetsWorkspaceSelectionRequestedEvent.class).handle(event -> { eventBindings.listen(workspaceBus, StudioAssetsWorkspaceSelectionRequestedEvent.class).handle(event -> {
if (event.forceUpdate() || !isCurrentSelection(event.assetReference())) { if (event.forceUpdate() || !isCurrentSelection(event.assetReference())) {
@ -375,10 +365,21 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
analyseButton.setOnAction(ignored -> openDiagnosticsDialog()); analyseButton.setOnAction(ignored -> openDiagnosticsDialog());
nodes.add(analyseButton); nodes.add(analyseButton);
if (canGenerateTsx()) { if (canGenerateTsx()) {
final Button generateTsxButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_GENERATE_TSX)); final Button exportTsxButton = AssetDetailsUiSupport.createActionButton(Container.i18n().text(I18n.ASSETS_ACTION_EXPORT_TSX));
generateTsxButton.setDisable(actionRunning); exportTsxButton.setDisable(actionRunning);
generateTsxButton.setOnAction(ignored -> generateTsx()); exportTsxButton.setOnAction(ignored -> generateTsx());
nodes.add(generateTsxButton); 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()); final Button buildParticipationButton = AssetDetailsUiSupport.createActionButton(buildParticipationActionLabel());
AssetDetailsUiSupport.applyActionTone( AssetDetailsUiSupport.applyActionTone(
@ -391,7 +392,7 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
nodes.add(buildParticipationButton); nodes.add(buildParticipationButton);
final var orderedActions = visibleActions.stream() final var orderedActions = visibleActions.stream()
.sorted(java.util.Comparator.comparingInt(action -> switch (action.action()) { .sorted(comparingInt(action -> switch (action.action()) {
case DELETE -> 2; case DELETE -> 2;
default -> 1; default -> 1;
})) }))
@ -434,7 +435,6 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
viewState.selectedAssetReference(), viewState.selectedAssetReference(),
viewState.selectedAssetDetails().summary()).ifPresent(assetReference -> { viewState.selectedAssetDetails().summary()).ifPresent(assetReference -> {
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(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; && viewState.selectedAssetDetails().summary().glyphSpecialization() == AssetStudioGlyphSpecialization.TILESET;
} }
private boolean canGenerateTmx() { private boolean canSceneTmxInteroperability() {
return false; return viewState.selectedAssetDetails() != null
} && viewState.selectedAssetDetails().summary().assetFamily() == AssetFamilyCatalog.SCENE_BANK
&& viewState.selectedAssetDetails().sceneBankMetadata() != null;
private boolean canValidateSceneBank() {
return false;
} }
private void generateTsx() { private void generateTsx() {
@ -536,22 +534,81 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
renderActions(); renderActions();
final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails(); final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails();
Container.backgroundTasks().submit(() -> { Container.backgroundTasks().submit(() -> {
final TiledAssetGenerationResult result = tiledGenerationService.generateTilesetTsx(details); final TiledAssetGenerationResult result = tiledGenerationService.exportTilesetTsx(details);
Platform.runLater(() -> applyTiledGenerationResult("glyph-bank-tileset", result)); 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) { if (actionRunning || viewState.selectedAssetDetails() == null) {
return; return;
} }
actionRunning = true; actionRunning = true;
actionFeedbackMessage = null; actionFeedbackMessage = null;
renderActions();
final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails(); final AssetWorkspaceAssetDetails details = viewState.selectedAssetDetails();
Container.backgroundTasks().submit(() -> { Container.backgroundTasks().submit(() -> {
final TiledAssetGenerationResult result = tiledGenerationService.generateSceneBankTilemap(projectReference, details, layerIndex); final TiledAssetGenerationResult result = tiledGenerationService.importSceneBankTmx(projectReference, details);
Platform.runLater(() -> applyTiledGenerationResult("scene-bank-layer-" + layerIndex, result)); Platform.runLater(() -> applyTiledGenerationResult("scene-bank-tmx-import", result));
}); });
} }
@ -559,49 +616,13 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
actionRunning = false; actionRunning = false;
actionFeedbackMessage = result.message(); actionFeedbackMessage = result.message();
workspaceBus.publish(new StudioAssetLogEvent(source, result.message())); workspaceBus.publish(new StudioAssetLogEvent(source, result.message()));
renderActions(); if (result.success()) {
if (result.success() && viewState.selectedAssetReference() != null) { if (CollectionUtils.isNotEmpty(result.updatedBindings())) {
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(viewState.selectedAssetReference())); final var sceneBankMetadata = viewState.selectedAssetDetails().sceneBankMetadata().withUpdatedBindings(result.updatedBindings());
workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(viewState.selectedAssetReference(), true)); final var updatedAssetDetails = viewState.selectedAssetDetails().withSceneBankMetadata(sceneBankMetadata);
} viewState = viewState.withSelectedAssetReference(updatedAssetDetails);
} }
workspaceBus.publish(new StudioAssetsWorkspaceDetailsSceneLayerChangedEvent(viewState));
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));
} }
} }
@ -610,7 +631,6 @@ public final class AssetDetailsControl extends VBox implements StudioEventAware
if (result.status() == p.packer.messages.PackerOperationStatus.SUCCESS && result.assetReference() != null) { if (result.status() == p.packer.messages.PackerOperationStatus.SUCCESS && result.assetReference() != null) {
actionFeedbackMessage = null; actionFeedbackMessage = null;
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(result.assetReference())); workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(result.assetReference()));
workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(result.assetReference(), false));
return; return;
} }
actionFeedbackMessage = result.summary(); 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) { if (result.status() == p.packer.messages.PackerOperationStatus.SUCCESS && result.assetReference() != null) {
actionFeedbackMessage = null; actionFeedbackMessage = null;
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(result.assetReference())); workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(result.assetReference()));
workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(result.assetReference(), false));
return; return;
} }
actionFeedbackMessage = Objects.requireNonNullElse(result.summary(), "Unable to register asset."); 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) { if (viewState == null || viewState.selectedAssetReference() == null) {
return; return;
} }
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(viewState.selectedAssetReference()));
workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(viewState.selectedAssetReference(), true)); 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.utilities.i18n.I18n;
import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState; import p.studio.workspaces.assets.messages.AssetWorkspaceDetailsViewState;
import p.studio.workspaces.assets.messages.events.StudioAssetLogEvent; 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.StudioAssetsDetailsViewStateChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceSelectionRequestedEvent;
import p.studio.workspaces.assets.messages.events.StudioGlyphMaterialMetadataChangedEvent; import p.studio.workspaces.assets.messages.events.StudioGlyphMaterialMetadataChangedEvent;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialMetadata; import p.studio.workspaces.assets.metadata.AssetStudioGlyphMaterialMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioMetadataService; import p.studio.workspaces.assets.metadata.AssetStudioMetadataService;
@ -222,7 +222,7 @@ public final class AssetDetailsPaletteOverhaulingControl extends StudioFormSecti
coordinator.apply(); coordinator.apply();
publishEditScope(workspaceBus, currentScopeKey(), null); publishEditScope(workspaceBus, currentScopeKey(), null);
renderSection(); renderSection();
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(assetReference)); workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(assetReference, true));
} else { } else {
workspaceBus.publish(new StudioAssetLogEvent("palette-overhauling", workspaceBus.publish(new StudioAssetLogEvent("palette-overhauling",
"Apply failed: " + Objects.requireNonNullElse(response.errorMessage(), "unknown error"))); "Apply failed: " + Objects.requireNonNullElse(response.errorMessage(), "unknown error")));

View File

@ -1,19 +1,16 @@
package p.studio.workspaces.assets.details.scene; package p.studio.workspaces.assets.details.scene;
import javafx.geometry.Pos;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.collections.FXCollections; import javafx.collections.FXCollections;
import javafx.scene.control.Button; import javafx.geometry.Pos;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.TextField;
import javafx.scene.Node; import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority; import javafx.scene.layout.Priority;
import javafx.scene.layout.Region; import javafx.scene.layout.Region;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import p.packer.messages.ListAssetsRequest;
import p.packer.messages.assets.AssetFamilyCatalog;
import p.studio.Container; import p.studio.Container;
import p.studio.controls.forms.StudioFormMode; import p.studio.controls.forms.StudioFormMode;
import p.studio.controls.lifecycle.StudioControlLifecycle; import p.studio.controls.lifecycle.StudioControlLifecycle;
@ -22,17 +19,13 @@ import p.studio.lsp.events.StudioWorkspaceEventBus;
import p.studio.projects.ProjectReference; import p.studio.projects.ProjectReference;
import p.studio.utilities.i18n.I18n; import p.studio.utilities.i18n.I18n;
import p.studio.workspaces.assets.details.AssetDetailsUiSupport; import p.studio.workspaces.assets.details.AssetDetailsUiSupport;
import p.studio.workspaces.assets.metadata.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.AssetWorkspaceDetailsViewState;
import p.studio.workspaces.assets.messages.events.StudioAssetLogEvent; 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.StudioAssetsDetailsViewStateChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceDetailsSceneLayerChangedEvent;
import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceSelectionRequestedEvent; import p.studio.workspaces.assets.messages.events.StudioAssetsWorkspaceSelectionRequestedEvent;
import p.studio.workspaces.assets.metadata.*;
import p.studio.workspaces.framework.StudioSubscriptionBag; import p.studio.workspaces.framework.StudioSubscriptionBag;
import p.packer.messages.ListAssetsRequest;
import p.packer.messages.assets.AssetFamilyCatalog;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
@ -41,7 +34,6 @@ import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.function.IntConsumer;
public final class AssetDetailsSceneLayersControl extends VBox implements StudioControlLifecycle { public final class AssetDetailsSceneLayersControl extends VBox implements StudioControlLifecycle {
private final ProjectReference projectReference; private final ProjectReference projectReference;
@ -50,12 +42,6 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
private final AssetStudioMetadataService metadataService = new AssetStudioMetadataService(); private final AssetStudioMetadataService metadataService = new AssetStudioMetadataService();
private AssetWorkspaceDetailsViewState viewState; private AssetWorkspaceDetailsViewState viewState;
private IntConsumer generateLayerAction = ignored -> {
};
private IntConsumer validateLayerAction = ignored -> {
};
private IntConsumer acceptLayerAction = ignored -> {
};
private Integer editingLayerIndex; private Integer editingLayerIndex;
private LayerDraft editingDraft; private LayerDraft editingDraft;
private int selectedLayerIndex = 1; private int selectedLayerIndex = 1;
@ -72,6 +58,10 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
viewState = event.viewState(); viewState = event.viewState();
render(); render();
})); }));
subscriptions.add(workspaceBus.subscribe(StudioAssetsWorkspaceDetailsSceneLayerChangedEvent.class, event -> {
viewState = event.viewState();
render();
}));
} }
@Override @Override
@ -79,23 +69,11 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
subscriptions.clear(); 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() { private void render() {
final AssetStudioSceneBankMetadata metadata = viewState == null || viewState.selectedAssetDetails() == null final AssetStudioSceneBankMetadata metadata = viewState == null || viewState.selectedAssetDetails() == null
? null ? null
: viewState.selectedAssetDetails().sceneBankMetadata(); : viewState.selectedAssetDetails().sceneBankMetadata();
if (metadata == null || viewState.selectedAssetDetails() == null) { if (metadata == null) {
getChildren().clear(); getChildren().clear();
return; return;
} }
@ -117,7 +95,8 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
selector.getStyleClass().add("assets-scene-layer-selector"); selector.getStyleClass().add("assets-scene-layer-selector");
for (int index = 1; index <= 4; index += 1) { for (int index = 1; index <= 4; index += 1) {
final int layerIndex = index; 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"); button.getStyleClass().addAll("studio-button", "assets-scene-layer-selector-button");
if (layerIndex == selectedLayerIndex) { if (layerIndex == selectedLayerIndex) {
button.getStyleClass().add("assets-scene-layer-selector-button-active"); 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); final VBox card = new VBox(8);
card.getStyleClass().addAll("assets-scene-layer-card", "assets-scene-layer-config"); 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.setSelected(draft != null && draft.enabled());
enabledCheckBox.getStyleClass().add("assets-scene-layer-enabled"); enabledCheckBox.getStyleClass().add("assets-scene-layer-enabled");
enabledCheckBox.setDisable(!editing); enabledCheckBox.setDisable(!editing);
@ -188,7 +168,13 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
: AssetDetailsUiSupport.projectRelativePath(projectReference, sceneAssetRoot.resolve(binding.tilemap()))), : AssetDetailsUiSupport.projectRelativePath(projectReference, sceneAssetRoot.resolve(binding.tilemap()))),
AssetDetailsUiSupport.createKeyValueRow( AssetDetailsUiSupport.createKeyValueRow(
Container.i18n().text(I18n.ASSETS_LABEL_TILESET), 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; return card;
} }
@ -235,27 +221,9 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
final HBox actions = new HBox(8); final HBox actions = new HBox(8);
actions.setAlignment(Pos.CENTER_LEFT); actions.setAlignment(Pos.CENTER_LEFT);
actions.getStyleClass().add("assets-scene-layer-actions"); 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(); final Region spacer = new Region();
HBox.setHgrow(spacer, Priority.ALWAYS); HBox.setHgrow(spacer, Priority.ALWAYS);
actions.getChildren().addAll(generateButton, validateButton, acceptButton, spacer); actions.getChildren().add(spacer);
return actions; return actions;
} }
@ -320,6 +288,24 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
return combo; 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) { private Node createTilesetReadOnlyValue(AssetStudioSceneLayerBinding binding, LayerDraft draft) {
final String value = binding == null || draft == null || draft.tilesetAssetRoot().isBlank() final String value = binding == null || draft == null || draft.tilesetAssetRoot().isBlank()
? Container.i18n().text(I18n.ASSETS_VALUE_NOT_CONFIGURED) ? 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())); final var response = Container.packer().workspaceService().listAssets(new ListAssetsRequest(projectReference.toPackerProjectContext()));
return response.assets().stream() return response.assets().stream()
.filter(asset -> asset.assetFamily() == AssetFamilyCatalog.GLYPH_BANK) .filter(asset -> asset.assetFamily() == AssetFamilyCatalog.GLYPH_BANK)
.filter(asset -> metadataService.read(asset.identity().assetRoot(), AssetFamilyCatalog.GLYPH_BANK).glyphSpecialization() .filter(asset -> metadataService
== p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization.TILESET) .read(asset.identity().assetRoot(), AssetFamilyCatalog.GLYPH_BANK)
.glyphSpecialization() == AssetStudioGlyphSpecialization.TILESET)
.map(asset -> { .map(asset -> {
final String relativeAssetRoot = projectReference.rootPath() final String relativeAssetRoot = projectReference.rootPath()
.resolve("assets") .resolve("assets")
@ -377,7 +364,6 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
metadataService.writeSceneBankMetadata(viewState.selectedAssetDetails().summary().assetRoot(), updatedMetadata); metadataService.writeSceneBankMetadata(viewState.selectedAssetDetails().summary().assetRoot(), updatedMetadata);
Platform.runLater(() -> { Platform.runLater(() -> {
workspaceBus.publish(new StudioAssetLogEvent("scene-bank-layer", successMessage)); workspaceBus.publish(new StudioAssetLogEvent("scene-bank-layer", successMessage));
workspaceBus.publish(new StudioAssetsRefreshRequestedEvent(viewState.selectedAssetReference()));
workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(viewState.selectedAssetReference(), true)); workspaceBus.publish(new StudioAssetsWorkspaceSelectionRequestedEvent(viewState.selectedAssetReference(), true));
}); });
} catch (IOException exception) { } catch (IOException exception) {
@ -407,10 +393,10 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
} }
private void applyEdit(int layerIndex, AssetStudioSceneLayerBinding binding) { 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( workspaceBus.publish(new StudioAssetLogEvent(
"scene-bank-layer", "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; return;
} }
final LayerDraft draft = editingDraft.copy(); final LayerDraft draft = editingDraft.copy();
@ -424,10 +410,28 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
draft.widthValue(), draft.widthValue(),
draft.heightValue(), draft.heightValue(),
current.tilemap(), current.tilemap(),
draft.tilesetAssetRoot()), draft.tilesetAssetRoot(),
draft.parallaxXValue(),
draft.parallaxYValue(),
draft.defaultPaletteValue(),
preservedTilesOrBlank(current, draft)),
"Updated layer " + layerIndex + " configuration."); "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) { private void resetEdit(AssetStudioSceneLayerBinding binding) {
editingDraft = LayerDraft.from(binding); editingDraft = LayerDraft.from(binding);
render(); render();
@ -467,27 +471,43 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
private String widthText; private String widthText;
private String heightText; private String heightText;
private String tilesetAssetRoot; 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.enabled = enabled;
this.widthText = widthText; this.widthText = widthText;
this.heightText = heightText; this.heightText = heightText;
this.tilesetAssetRoot = Objects.requireNonNullElse(tilesetAssetRoot, "").trim(); 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) { static LayerDraft from(AssetStudioSceneLayerBinding binding) {
if (binding == null) { if (binding == null) {
return new LayerDraft(false, "16", "16", ""); return new LayerDraft(false, "16", "16", "", "1.0", "1.0", "0");
} }
return new LayerDraft( return new LayerDraft(
binding.enabled(), binding.enabled(),
Integer.toString(binding.width()), Integer.toString(binding.width()),
Integer.toString(binding.height()), Integer.toString(binding.height()),
binding.tilesetAssetRoot()); binding.tilesetAssetRoot(),
trimFloat(binding.parallaxFactorX()),
trimFloat(binding.parallaxFactorY()),
Integer.toString(binding.defaultPaletteId()));
} }
LayerDraft copy() { LayerDraft copy() {
return new LayerDraft(enabled, widthText, heightText, tilesetAssetRoot); return new LayerDraft(enabled, widthText, heightText, tilesetAssetRoot, parallaxXText, parallaxYText, defaultPaletteText);
} }
boolean enabled() { boolean enabled() {
@ -522,8 +542,40 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
this.tilesetAssetRoot = Objects.requireNonNullElse(tilesetAssetRoot, "").trim(); this.tilesetAssetRoot = Objects.requireNonNullElse(tilesetAssetRoot, "").trim();
} }
boolean hasValidDimensions() { String parallaxXText() {
return parsePositiveInt(widthText) > 0 && parsePositiveInt(heightText) > 0; 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() { int widthValue() {
@ -534,6 +586,18 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
return parsePositiveInt(heightText); return parsePositiveInt(heightText);
} }
float parallaxXValue() {
return parseNonNegativeFloat(parallaxXText);
}
float parallaxYValue() {
return parseNonNegativeFloat(parallaxYText);
}
int defaultPaletteValue() {
return parseNonNegativeInt(defaultPaletteText);
}
private static int parsePositiveInt(String value) { private static int parsePositiveInt(String value) {
try { try {
return Integer.parseInt(Objects.requireNonNullElse(value, "").trim()); return Integer.parseInt(Objects.requireNonNullElse(value, "").trim());
@ -541,5 +605,30 @@ public final class AssetDetailsSceneLayersControl extends VBox implements Studio
return -1; 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, sceneBankValidation,
diagnostics); 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 { public AssetWorkspaceDetailsViewState {
Objects.requireNonNull(detailsStatus, "detailsStatus"); 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("height", binding.height());
layer.put("tilemap", binding.tilemap()); layer.put("tilemap", binding.tilemap());
layer.put("tileset_asset_root", binding.tilesetAssetRoot()); 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( mapper.writerWithDefaultPrettyPrinter().writeValue(
normalizedAssetRoot.resolve(SCENE_BANK_SUPPORT_FILE).toFile(), normalizedAssetRoot.resolve(SCENE_BANK_SUPPORT_FILE).toFile(),
@ -137,7 +151,7 @@ public final class AssetStudioMetadataService {
} }
final Path supportFile = assetRoot.resolve(SCENE_BANK_SUPPORT_FILE); final Path supportFile = assetRoot.resolve(SCENE_BANK_SUPPORT_FILE);
if (!Files.isRegularFile(supportFile)) { if (!Files.isRegularFile(supportFile)) {
return null; return defaultSceneBankMetadata(supportFile);
} }
try { try {
final JsonNode root = mapper.readTree(supportFile.toFile()); final JsonNode root = mapper.readTree(supportFile.toFile());
@ -163,10 +177,28 @@ public final class AssetStudioMetadataService {
final int height = layerNode.path("height").asInt(mapHeight); final int height = layerNode.path("height").asInt(mapHeight);
final String tilemap = layerNode.path("tilemap").asText("").trim(); final String tilemap = layerNode.path("tilemap").asText("").trim();
final String tilesetAssetRoot = layerNode.path("tileset_asset_root").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)) { if (index < 1 || index > layerCount || layerName.isBlank() || width <= 0 || height <= 0 || tilemap.isBlank() || !indexes.add(index)) {
return null; 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)); bindings.sort(Comparator.comparingInt(AssetStudioSceneLayerBinding::index));
return new AssetStudioSceneBankMetadata(mapWidth, mapHeight, layerCount, bindings, supportFile); return new AssetStudioSceneBankMetadata(mapWidth, mapHeight, layerCount, bindings, supportFile);
@ -287,4 +319,44 @@ public final class AssetStudioMetadataService {
final String normalized = value.trim().toUpperCase(); final String normalized = value.trim().toUpperCase();
return normalized.matches("^#[0-9A-F]{6}$") ? normalized : null; 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() { public long enabledLayerCount() {
return layerBindings.stream().filter(AssetStudioSceneLayerBinding::enabled).count(); 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; package p.studio.workspaces.assets.metadata;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects; import java.util.Objects;
public record AssetStudioSceneLayerBinding( public record AssetStudioSceneLayerBinding(
@ -9,11 +11,16 @@ public record AssetStudioSceneLayerBinding(
int width, int width,
int height, int height,
String tilemap, String tilemap,
String tilesetAssetRoot) { String tilesetAssetRoot,
float parallaxFactorX,
float parallaxFactorY,
int defaultPaletteId,
List<AssetStudioSceneTile> tiles) {
public AssetStudioSceneLayerBinding { public AssetStudioSceneLayerBinding {
layerName = Objects.requireNonNull(layerName, "layerName").trim(); layerName = Objects.requireNonNull(layerName, "layerName").trim();
tilemap = Objects.requireNonNull(tilemap, "tilemap").trim(); tilemap = Objects.requireNonNull(tilemap, "tilemap").trim();
tilesetAssetRoot = Objects.requireNonNull(tilesetAssetRoot, "tilesetAssetRoot").trim(); tilesetAssetRoot = Objects.requireNonNull(tilesetAssetRoot, "tilesetAssetRoot").trim();
tiles = List.copyOf(Objects.requireNonNull(tiles, "tiles"));
if (index <= 0) { if (index <= 0) {
throw new IllegalArgumentException("index must be positive"); throw new IllegalArgumentException("index must be positive");
} }
@ -26,5 +33,50 @@ public record AssetStudioSceneLayerBinding(
if (tilemap.isBlank()) { if (tilemap.isBlank()) {
throw new IllegalArgumentException("tilemap must not be blank"); 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.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import p.packer.dtos.PackerDiagnosticDTO; 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.PackerDiagnosticCategory;
import p.packer.messages.diagnostics.PackerDiagnosticSeverity; import p.packer.messages.diagnostics.PackerDiagnosticSeverity;
import p.studio.Container;
import p.studio.projects.ProjectReference; import p.studio.projects.ProjectReference;
import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata; import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioSceneLayerBinding; 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.AssetWorkspaceAssetDetails;
import p.studio.workspaces.assets.messages.AssetWorkspaceSceneBankStatus; import p.studio.workspaces.assets.messages.AssetWorkspaceSceneBankStatus;
import p.studio.workspaces.assets.messages.AssetWorkspaceSceneBankValidation; 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.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
public final class SceneBankWorkflowService { public final class SceneBankWorkflowService {
private static final String VALIDATION_FILE = "scene-bank.validation.json"; public static final String ACCEPTED_REQUEST_FILE = "scene-bank.pack-request.json";
private static final String ACCEPTANCE_FILE = "scene-bank.acceptance.json";
private final ObjectMapper mapper = new ObjectMapper(); 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) { 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()); return new SceneBankWorkflowResult(true, "", AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, List.of());
} }
final List<PackerDiagnosticDTO> diagnostics = validateDiagnostics(projectReference, details, null); final List<PackerDiagnosticDTO> diagnostics = new ArrayList<>(validateDiagnostics(projectReference, details));
final String currentFingerprint = diagnostics.stream().anyMatch(PackerDiagnosticDTO::blocking) if (diagnostics.stream().noneMatch(PackerDiagnosticDTO::blocking)) {
? "" try {
: computeFingerprint(projectReference, details); writeAcceptedRequest(
final String validatedFingerprint = readFingerprint(details.summary().assetRoot().resolve(VALIDATION_FILE)); details.summary().assetRoot().resolve(ACCEPTED_REQUEST_FILE),
final String acceptedFingerprint = readFingerprint(details.summary().assetRoot().resolve(ACCEPTANCE_FILE)); buildCanonicalRequest(projectReference, details));
final AssetWorkspaceSceneBankValidation validation = validationState( } catch (IOException | IllegalArgumentException exception) {
diagnostics, diagnostics.add(error(
currentFingerprint, "Unable to persist canonical scene-pack request: " + exception.getMessage(),
validatedFingerprint, details.summary().assetRoot().resolve(ACCEPTED_REQUEST_FILE)));
acceptedFingerprint); }
}
final AssetWorkspaceSceneBankValidation validation = validationState(diagnostics);
return new SceneBankWorkflowResult( return new SceneBankWorkflowResult(
diagnostics.stream().noneMatch(PackerDiagnosticDTO::blocking), diagnostics.stream().noneMatch(PackerDiagnosticDTO::blocking),
messageFor(validation), messageFor(validation),
validation, validation,
diagnostics); List.copyOf(diagnostics));
} }
public SceneBankWorkflowResult validate(ProjectReference projectReference, AssetWorkspaceAssetDetails details) { 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); return inspect(projectReference, details);
} }
public SceneBankWorkflowResult accept(ProjectReference projectReference, AssetWorkspaceAssetDetails 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); return inspect(projectReference, details);
} }
public SceneBankWorkflowResult inspectLayer(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) { public SceneBankWorkflowResult inspectLayer(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) {
if (details == null || details.summary().assetFamily() != p.packer.messages.assets.AssetFamilyCatalog.SCENE_BANK) { return inspect(projectReference, details);
return new SceneBankWorkflowResult(true, "", AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, List.of());
}
final List<PackerDiagnosticDTO> diagnostics = validateDiagnostics(projectReference, details, layerIndex);
final String currentFingerprint = diagnostics.stream().anyMatch(PackerDiagnosticDTO::blocking)
? ""
: computeFingerprint(projectReference, details, layerIndex);
final String validatedFingerprint = readFingerprint(details.summary().assetRoot().resolve(layerValidationFile(layerIndex)));
final String acceptedFingerprint = readFingerprint(details.summary().assetRoot().resolve(layerAcceptanceFile(layerIndex)));
final AssetWorkspaceSceneBankValidation validation = validationState(
diagnostics,
currentFingerprint,
validatedFingerprint,
acceptedFingerprint);
return new SceneBankWorkflowResult(
diagnostics.stream().noneMatch(PackerDiagnosticDTO::blocking),
messageFor(validation),
validation,
diagnostics);
} }
public SceneBankWorkflowResult validateLayer(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) { public SceneBankWorkflowResult validateLayer(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) {
final SceneBankWorkflowResult inspection = inspectLayer(projectReference, details, layerIndex); return inspect(projectReference, details);
final Path validationFile = details.summary().assetRoot().resolve(layerValidationFile(layerIndex));
try {
if (inspection.success() && !inspection.validation().currentFingerprint().isBlank()) {
writeFingerprint(validationFile, inspection.validation().currentFingerprint());
} else {
Files.deleteIfExists(validationFile);
}
} catch (IOException exception) {
return new SceneBankWorkflowResult(
false,
"Unable to persist validation state: " + exception.getMessage(),
inspection.validation(),
inspection.diagnostics());
}
return inspectLayer(projectReference, details, layerIndex);
} }
public SceneBankWorkflowResult acceptLayer(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) { public SceneBankWorkflowResult acceptLayer(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) {
final SceneBankWorkflowResult inspection = inspectLayer(projectReference, details, layerIndex); return inspect(projectReference, details);
if (!inspection.validation().canAccept()) {
return new SceneBankWorkflowResult(
false,
"Scene layer must validate successfully before acceptance.",
inspection.validation(),
inspection.diagnostics());
}
try {
writeFingerprint(details.summary().assetRoot().resolve(layerAcceptanceFile(layerIndex)), inspection.validation().currentFingerprint());
writeFingerprint(details.summary().assetRoot().resolve(layerValidationFile(layerIndex)), inspection.validation().currentFingerprint());
} catch (IOException exception) {
return new SceneBankWorkflowResult(
false,
"Unable to persist acceptance state: " + exception.getMessage(),
inspection.validation(),
inspection.diagnostics());
}
return inspectLayer(projectReference, details, layerIndex);
} }
private List<PackerDiagnosticDTO> validateDiagnostics(ProjectReference projectReference, AssetWorkspaceAssetDetails details, Integer targetLayerIndex) { private List<PackerDiagnosticDTO> validateDiagnostics(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
final List<PackerDiagnosticDTO> diagnostics = new ArrayList<>(); final List<PackerDiagnosticDTO> diagnostics = new ArrayList<>();
final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata(); final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata();
if (metadata == null) { 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; return diagnostics;
} }
final SceneLayerRuntimeDefaults sceneDefaults =
resolveSceneLayerRuntimeDefaults(projectReference, metadata.layerBindings());
for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) { for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) {
if (!binding.enabled()) { if (!binding.enabled()) {
continue; if (resolveLayerRuntimeDefaults(projectReference, binding, sceneDefaults) == null) {
} diagnostics.add(error(
if (targetLayerIndex != null && binding.index() != targetLayerIndex) { "Unable to resolve runtime glyph/tile_size defaults for inactive layer " + binding.index() + ".",
metadata.supportFile()));
}
continue; continue;
} }
if (binding.tilesetAssetRoot().isBlank()) { if (binding.tilesetAssetRoot().isBlank()) {
diagnostics.add(error("Layer " + binding.index() + " is missing tileset_asset_root.", metadata.supportFile())); diagnostics.add(error("Layer " + binding.index() + " is missing tileset_asset_root.", metadata.supportFile()));
continue; continue;
} }
final Path tmxPath = details.summary().assetRoot().resolve(binding.tilemap()).toAbsolutePath().normalize(); if (binding.tiles().size() != binding.width() * binding.height()) {
final Path tsxPath = projectReference.rootPath() diagnostics.add(error("Canonical scene tile count does not match width * height for layer " + binding.index() + ".",
.resolve("assets") metadata.supportFile()));
.resolve(binding.tilesetAssetRoot())
.resolve("tileset.tsx")
.toAbsolutePath()
.normalize();
if (!Files.isRegularFile(tmxPath)) {
diagnostics.add(error("TMX file is missing for layer " + binding.index() + ": " + binding.tilemap(), tmxPath));
continue; continue;
} }
if (!Files.isRegularFile(tsxPath)) { if (resolveGlyphAssetId(projectReference, binding.tilesetAssetRoot()) < 0) {
diagnostics.add(error("Referenced TSX file is missing for layer " + binding.index() + ": " + binding.tilesetAssetRoot(), tsxPath)); diagnostics.add(error("Unable to resolve glyph_asset_id for layer " + binding.index() + ": " + binding.tilesetAssetRoot(),
continue; metadata.supportFile()));
} }
try { if (resolveTileSize(projectReference, binding.tilesetAssetRoot()) <= 0) {
final TiledTilesetDocument tileset = codec.readTileset(tsxPath); diagnostics.add(error("Unable to resolve tile_size for layer " + binding.index() + ": " + binding.tilesetAssetRoot(),
final TiledMapDocument map = codec.readMap(tmxPath); metadata.supportFile()));
final String expectedSource = details.summary().assetRoot() }
.toAbsolutePath() for (AssetStudioSceneTile tile : binding.tiles()) {
.normalize() if (tile.glyphId() < 0) {
.relativize(tsxPath) diagnostics.add(error("Canonical scene tile glyph_id must be non-negative for layer " + binding.index() + ".",
.toString() metadata.supportFile()));
.replace('\\', '/'); break;
if (map.tilesets().size() != 1) {
diagnostics.add(error("TMX must reference exactly one TSX in wave 1: " + binding.tilemap(), tmxPath));
} else if (!expectedSource.equals(map.tilesets().getFirst().source())) {
diagnostics.add(error("TMX tileset reference does not match support metadata: " + binding.tilemap(), tmxPath));
} }
if (map.width() != binding.width() || map.height() != binding.height()) { if (tile.paletteId() < 0) {
diagnostics.add(error("TMX dimensions do not match scene-bank support metadata: " + binding.tilemap(), tmxPath)); 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); return List.copyOf(diagnostics);
} }
private String computeFingerprint(ProjectReference projectReference, AssetWorkspaceAssetDetails details) { private ScenePackRequest buildCanonicalRequest(ProjectReference projectReference, AssetWorkspaceAssetDetails details) throws IOException {
final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata(); final AssetStudioSceneBankMetadata metadata = Objects.requireNonNull(details.sceneBankMetadata(), "sceneBankMetadata");
if (metadata == null) { final SceneLayerRuntimeDefaults sceneDefaults =
return ""; resolveSceneLayerRuntimeDefaults(projectReference, metadata.layerBindings());
} final List<ScenePackLayerRequest> layers = new ArrayList<>();
try { for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) {
final MessageDigest digest = MessageDigest.getInstance("SHA-256"); if (!binding.enabled()) {
final List<Path> files = new ArrayList<>(); layers.add(inactiveLayer(projectReference, binding, sceneDefaults));
files.add(metadata.supportFile()); continue;
for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) {
if (!binding.enabled()) {
continue;
}
files.add(details.summary().assetRoot().resolve(binding.tilemap()).toAbsolutePath().normalize());
if (!binding.tilesetAssetRoot().isBlank()) {
files.add(projectReference.rootPath().resolve("assets").resolve(binding.tilesetAssetRoot()).resolve("tileset.tsx").toAbsolutePath().normalize());
}
} }
files.stream() layers.add(activeLayer(projectReference, binding));
.distinct() }
.sorted(Comparator.comparing(Path::toString)) return new ScenePackRequest(1, layers);
.forEach(path -> updateDigest(digest, path)); }
return HexFormat.of().formatHex(digest.digest());
} catch (Exception exception) { private ScenePackLayerRequest inactiveLayer(ProjectReference projectReference,
return ""; 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) { private int resolveTileSize(ProjectReference projectReference, String tilesetAssetRoot) {
final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata(); final Path declarationPath = projectReference.rootPath()
if (metadata == null) { .resolve("assets")
return ""; .resolve(tilesetAssetRoot)
.resolve("asset.json")
.toAbsolutePath()
.normalize();
if (!Files.isRegularFile(declarationPath)) {
return -1;
} }
try { try {
final MessageDigest digest = MessageDigest.getInstance("SHA-256"); final JsonNode root = mapper.readTree(declarationPath.toFile());
final List<Path> files = new ArrayList<>(); final String value = root.path("output").path("metadata").path("tile_size").asText("").trim().toLowerCase();
files.add(metadata.supportFile()); return switch (value) {
metadata.layerBindings().stream() case "8x8" -> 8;
.filter(AssetStudioSceneLayerBinding::enabled) case "16x16" -> 16;
.filter(binding -> binding.index() == layerIndex) case "32x32" -> 32;
.findFirst() default -> -1;
.ifPresent(binding -> { };
files.add(details.summary().assetRoot().resolve(binding.tilemap()).toAbsolutePath().normalize()); } catch (IOException exception) {
if (!binding.tilesetAssetRoot().isBlank()) { return -1;
files.add(projectReference.rootPath().resolve("assets").resolve(binding.tilesetAssetRoot()).resolve("tileset.tsx").toAbsolutePath().normalize());
}
});
if (files.size() == 1) {
return "";
}
files.stream()
.distinct()
.sorted(Comparator.comparing(Path::toString))
.forEach(path -> updateDigest(digest, path));
return HexFormat.of().formatHex(digest.digest());
} catch (Exception exception) {
return "";
} }
} }
private void updateDigest(MessageDigest digest, Path path) { private AssetWorkspaceSceneBankValidation validationState(List<PackerDiagnosticDTO> diagnostics) {
try {
if (!Files.isRegularFile(path)) {
return;
}
digest.update(path.toString().getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0);
digest.update(Files.readAllBytes(path));
digest.update((byte) 0);
} catch (IOException ignored) {
}
}
private AssetWorkspaceSceneBankValidation validationState(
List<PackerDiagnosticDTO> diagnostics,
String currentFingerprint,
String validatedFingerprint,
String acceptedFingerprint) {
if (diagnostics.stream().anyMatch(PackerDiagnosticDTO::blocking)) { if (diagnostics.stream().anyMatch(PackerDiagnosticDTO::blocking)) {
return new AssetWorkspaceSceneBankValidation( return new AssetWorkspaceSceneBankValidation(
AssetWorkspaceSceneBankStatus.VALIDATION_FAILED, 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,
false, false,
currentFingerprint, "",
validatedFingerprint, "",
acceptedFingerprint); "");
}
if (validated) {
return new AssetWorkspaceSceneBankValidation(
AssetWorkspaceSceneBankStatus.VALIDATED_PENDING_ACCEPTANCE,
true,
true,
currentFingerprint,
validatedFingerprint,
acceptedFingerprint);
} }
return new AssetWorkspaceSceneBankValidation( return new AssetWorkspaceSceneBankValidation(
AssetWorkspaceSceneBankStatus.PENDING_VALIDATION, AssetWorkspaceSceneBankStatus.READY,
true,
false, false,
currentFingerprint, false,
validatedFingerprint, "",
acceptedFingerprint); "",
} "");
private String layerValidationFile(int layerIndex) {
return "scene-bank.layer-" + layerIndex + ".validation.json";
}
private String layerAcceptanceFile(int layerIndex) {
return "scene-bank.layer-" + layerIndex + ".acceptance.json";
} }
private String messageFor(AssetWorkspaceSceneBankValidation validation) { private String messageFor(AssetWorkspaceSceneBankValidation validation) {
return switch (validation.status()) { return switch (validation.status()) {
case NOT_APPLICABLE -> ""; case NOT_APPLICABLE -> "";
case PENDING_VALIDATION -> "Scene Bank has pending external changes and requires validation."; case PENDING_VALIDATION, VALIDATED_PENDING_ACCEPTANCE -> "Scene Bank canonical workflow no longer uses validation/acceptance staging.";
case VALIDATED_PENDING_ACCEPTANCE -> "Scene Bank validation succeeded and is waiting for explicit acceptance."; case READY -> "Scene Bank canonical scene is ready.";
case READY -> "Scene Bank is ready.";
case VALIDATION_FAILED -> "Scene Bank validation failed."; 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) { private PackerDiagnosticDTO error(String message, Path path) {
return new PackerDiagnosticDTO( return new PackerDiagnosticDTO(
PackerDiagnosticSeverity.ERROR, PackerDiagnosticSeverity.ERROR,
@ -388,4 +338,12 @@ public final class SceneBankWorkflowService {
path, path,
true); 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; package p.studio.workspaces.assets.tiled;
import p.studio.workspaces.assets.metadata.AssetStudioSceneLayerBinding;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@ -7,6 +9,7 @@ import java.util.Objects;
public record TiledAssetGenerationResult( public record TiledAssetGenerationResult(
boolean success, boolean success,
String message, String message,
List<AssetStudioSceneLayerBinding> updatedBindings,
List<Path> writtenFiles) { List<Path> writtenFiles) {
public TiledAssetGenerationResult { public TiledAssetGenerationResult {

View File

@ -4,31 +4,52 @@ import p.packer.dtos.PackerCodecConfigurationFieldDTO;
import p.packer.messages.assets.AssetFamilyCatalog; import p.packer.messages.assets.AssetFamilyCatalog;
import p.studio.projects.ProjectReference; import p.studio.projects.ProjectReference;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization; 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.AssetStudioSceneBankMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioSceneLayerBinding; 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.AssetWorkspaceAssetDetails;
import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile; import p.studio.workspaces.assets.messages.AssetWorkspaceBankCompositionFile;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
public final class TiledAssetGenerationService { public final class TiledAssetGenerationService {
public static final String GENERATED_TSX_FILE = "tileset.tsx"; 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 TiledXmlCodec codec = new TiledXmlCodec();
private final AssetStudioMetadataService metadataService = new AssetStudioMetadataService();
public enum SceneTmxExportDisposition {
OVERWRITE,
KEEP_BOTH
}
public TiledAssetGenerationResult generateTilesetTsx(AssetWorkspaceAssetDetails details) { public TiledAssetGenerationResult generateTilesetTsx(AssetWorkspaceAssetDetails details) {
return exportTilesetTsx(details);
}
public TiledAssetGenerationResult exportTilesetTsx(AssetWorkspaceAssetDetails details) {
if (details == null if (details == null
|| details.summary().assetFamily() != AssetFamilyCatalog.GLYPH_BANK || details.summary().assetFamily() != AssetFamilyCatalog.GLYPH_BANK
|| details.summary().glyphSpecialization() != AssetStudioGlyphSpecialization.TILESET) { || 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(); final List<AssetWorkspaceBankCompositionFile> selectedFiles = details.bankComposition().selectedFiles();
if (selectedFiles.isEmpty()) { 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 int tileSize = parseTileSize(details.metadataFields());
final List<TiledTilesetTile> tiles = new ArrayList<>(); final List<TiledTilesetTile> tiles = new ArrayList<>();
@ -55,120 +76,331 @@ public final class TiledAssetGenerationService {
final Path outputPath = details.summary().assetRoot().resolve(GENERATED_TSX_FILE); final Path outputPath = details.summary().assetRoot().resolve(GENERATED_TSX_FILE);
try { try {
codec.writeTileset(outputPath, document); 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) { } 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) { public TiledAssetGenerationResult generateSceneBankTilemaps(ProjectReference projectReference, AssetWorkspaceAssetDetails details) {
if (details == null || details.summary().assetFamily() != AssetFamilyCatalog.SCENE_BANK) { return exportSceneBankTmx(projectReference, details);
return new TiledAssetGenerationResult(false, "TMX generation is available only for Scene Bank assets.", List.of());
}
final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata();
if (metadata == null) {
return new TiledAssetGenerationResult(false, "Scene Bank support metadata is missing or invalid.", List.of());
}
final List<Path> writtenFiles = new ArrayList<>();
try {
for (AssetStudioSceneLayerBinding binding : metadata.layerBindings()) {
if (!binding.enabled()) {
continue;
}
final TiledAssetGenerationResult result = generateSceneBankTilemap(projectReference, details, binding.index());
if (!result.success()) {
return result;
}
writtenFiles.addAll(result.writtenFiles());
}
if (writtenFiles.isEmpty()) {
return new TiledAssetGenerationResult(false, "Enable at least one layer before generating TMX.", List.of());
}
return new TiledAssetGenerationResult(true, "Generated TMX successfully.", writtenFiles);
} catch (RuntimeException exception) {
return new TiledAssetGenerationResult(false, "Unable to generate TMX: " + exception.getMessage(), List.of());
}
} }
public TiledAssetGenerationResult generateSceneBankTilemap(ProjectReference projectReference, AssetWorkspaceAssetDetails details, int layerIndex) { 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) { 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(); final AssetStudioSceneBankMetadata metadata = details.sceneBankMetadata();
if (metadata == null) { 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() final List<AssetStudioSceneLayerBinding> enabledLayers = metadata.layerBindings().stream()
.filter(candidate -> candidate.index() == layerIndex) .filter(AssetStudioSceneLayerBinding::enabled)
.findFirst() .toList();
.orElse(null); if (enabledLayers.isEmpty()) {
if (binding == null) { return new TiledAssetGenerationResult(false, "Enable at least one layer before exporting TMX.", List.of(), List.of());
return new TiledAssetGenerationResult(false, "Layer " + layerIndex + " is not defined in Scene Bank metadata.", List.of());
} }
if (!binding.enabled()) { final String sceneTilemap = resolveSceneTilemap(metadata);
return new TiledAssetGenerationResult(false, "Layer " + layerIndex + " is disabled.", List.of()); 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 { try {
final String tilesetAssetRoot = Objects.requireNonNullElse(binding.tilesetAssetRoot(), "").trim(); final ExportContext context = buildExportContext(projectReference, details, enabledLayers);
if (tilesetAssetRoot.isBlank()) { final List<TiledTileLayer> tileLayers = new ArrayList<>();
return new TiledAssetGenerationResult( int nextLayerId = 1;
false, for (AssetStudioSceneLayerBinding binding : enabledLayers) {
"Layer " + binding.index() + " must declare a non-blank tileset_asset_root before TMX generation.", final TilesetRef ref = context.tilesetByAssetRoot().get(binding.tilesetAssetRoot());
List.of()); final List<Long> gids = new ArrayList<>(binding.tiles().size());
} for (AssetStudioSceneTile tile : binding.tiles()) {
final Path tilesetPath = projectReference.rootPath() if (!tile.active()) {
.resolve("assets") gids.add(0L);
.resolve(tilesetAssetRoot) continue;
.resolve(GENERATED_TSX_FILE) }
.toAbsolutePath() if (tile.glyphId() >= ref.tileCount()) {
.normalize(); return new TiledAssetGenerationResult(
if (!java.nio.file.Files.isRegularFile(tilesetPath)) { false,
return new TiledAssetGenerationResult( "Layer " + binding.index() + " references glyph_id outside the exported TSX range.",
false, List.of(),
"Referenced TSX was not found for layer " + binding.index() + ": " + tilesetAssetRoot, List.of());
List.of()); }
} long gid = ref.firstGid() + tile.glyphId();
final TiledTilesetDocument tileset = codec.readTileset(tilesetPath); if (tile.flipX()) {
final String relativeTilesetPath = details.summary().assetRoot() gid |= FLIP_HORIZONTAL_FLAG;
.toAbsolutePath() }
.normalize() if (tile.flipY()) {
.relativize(tilesetPath) gid |= FLIP_VERTICAL_FLAG;
.toString() }
.replace('\\', '/'); gids.add(gid);
final int tileCount = binding.width() * binding.height(); }
final List<Long> gids = new ArrayList<>(tileCount); tileLayers.add(new TiledTileLayer(
for (int index = 0; index < tileCount; index += 1) { nextLayerId,
gids.add(0L); binding.layerName(),
binding.width(),
binding.height(),
gids,
List.of()));
nextLayerId += 1;
} }
final TiledMapDocument document = new TiledMapDocument( final TiledMapDocument document = new TiledMapDocument(
"1.10", "1.10",
"1.12.1", "1.12.1",
"orthogonal", "orthogonal",
"right-down", "right-down",
binding.width(), enabledLayers.stream().mapToInt(AssetStudioSceneLayerBinding::width).max().orElse(1),
binding.height(), enabledLayers.stream().mapToInt(AssetStudioSceneLayerBinding::height).max().orElse(1),
tileset.tileWidth(), context.tileSize(),
tileset.tileHeight(), context.tileSize(),
2, nextLayerId,
1, 1,
List.of(), List.of(),
List.of(new TiledTilesetReference(1, relativeTilesetPath)), context.tilesetRefs(),
List.of(new TiledTileLayer( tileLayers,
1, 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.layerName(),
binding.width(), binding.width(),
binding.height(), binding.height(),
gids, binding.tilemap(),
List.of())), binding.tilesetAssetRoot(),
List.of()); binding.parallaxFactorX(),
final Path outputPath = details.summary().assetRoot().resolve(binding.tilemap()); binding.parallaxFactorY(),
codec.writeMap(outputPath, document); binding.defaultPaletteId(),
return new TiledAssetGenerationResult(true, "Generated TMX for layer " + binding.index() + ".", List.of(outputPath)); binding.tiles()));
} catch (TiledUnsupportedFeatureException | IOException exception) { continue;
return new TiledAssetGenerationResult(false, "Unable to generate TMX: " + exception.getMessage(), List.of()); }
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) { private int parseTileSize(List<PackerCodecConfigurationFieldDTO> metadataFields) {
final String value = metadataFields.stream() final String value = metadataFields.stream()
.filter(field -> "tile_size".equals(field.key())) .filter(field -> "tile_size".equals(field.key()))
@ -184,4 +416,56 @@ public final class TiledAssetGenerationService {
} }
return 16; 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.actions.empty=No actions available for this asset.
assets.action.register=Register assets.action.register=Register
assets.action.analyse=Analyse assets.action.analyse=Analyse
assets.action.generateTsx=Generate TSX assets.action.exportTsx=Export TSX
assets.action.generateTmx=Generate TMX assets.action.exportTmx=Export TMX
assets.action.importTmx=Import TMX
assets.action.validateSceneBank=Validate Scene Bank assets.action.validateSceneBank=Validate Scene Bank
assets.action.acceptSceneBank=Accept Scene Bank assets.action.acceptSceneBank=Accept Scene Bank
assets.action.validateLayer=Validate Layer assets.action.validateLayer=Validate Layer
@ -194,11 +195,13 @@ assets.label.studioRole=Studio Role
assets.label.sceneStatus=Scene Status assets.label.sceneStatus=Scene Status
assets.label.sceneLayers=Scene Layers assets.label.sceneLayers=Scene Layers
assets.label.sceneDimensions=Layer Size assets.label.sceneDimensions=Layer Size
assets.label.layer=Layer assets.label.layer=Layer {0}
assets.label.layerOverview=Layer Overview assets.label.layerOverview=Layer Overview
assets.label.tilemaps=Tilemaps assets.label.tilemaps=Tilemaps
assets.label.tilemap=Tilemap assets.label.tilemap=Tilemap
assets.label.tileset=Tileset assets.label.tileset=Tileset
assets.label.parallax=Parallax
assets.label.defaultPalette=Default Palette
assets.label.supportFile=Support File assets.label.supportFile=Support File
assets.label.useLayer=Use this layer assets.label.useLayer=Use this layer
assets.type.glyphBank=Glyph Bank assets.type.glyphBank=Glyph Bank

View File

@ -99,10 +99,10 @@ final class AssetStudioMetadataServiceTest {
"map_height": 12, "map_height": 12,
"layer_count": 4, "layer_count": 4,
"layers": [ "layers": [
{ "index": 1, "enabled": true, "name": "Ground", "width": 20, "height": 12, "tilemap": "ground.tmx", "tileset_asset_root": "tilesets/ground" }, { "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" }, { "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": "" }, { "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": "" } { "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(4, snapshot.sceneBankMetadata().layerCount());
assertEquals(10, snapshot.sceneBankMetadata().layerBindings().get(1).width()); assertEquals(10, snapshot.sceneBankMetadata().layerBindings().get(1).width());
assertEquals(8, snapshot.sceneBankMetadata().layerBindings().get(1).height()); 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( assertEquals(
java.util.List.of("ground.tmx", "collision.tmx"), java.util.List.of("ground.tmx", "collision.tmx"),
snapshot.sceneBankMetadata().layerBindings().stream() snapshot.sceneBankMetadata().layerBindings().stream()
@ -134,9 +139,9 @@ final class AssetStudioMetadataServiceTest {
"map_height": 16, "map_height": 16,
"layer_count": 3, "layer_count": 3,
"layers": [ "layers": [
{ "index": 1, "enabled": true, "name": "A", "width": 16, "height": 16, "tilemap": "a.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": "" }, { "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": "" } { "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()); 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; 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.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import p.packer.dtos.PackerCodecConfigurationFieldDTO; 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.AssetStudioGlyphSpecialization;
import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata; import p.studio.workspaces.assets.metadata.AssetStudioSceneBankMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioSceneLayerBinding; 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.messages.*;
import p.studio.workspaces.assets.tiled.TiledAssetGenerationService;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
final class SceneBankWorkflowServiceTest { final class SceneBankWorkflowServiceTest {
private final TiledAssetGenerationService generationService = new TiledAssetGenerationService(); private final ObjectMapper mapper = new ObjectMapper();
private final SceneBankWorkflowService workflowService = new SceneBankWorkflowService(); private final SceneBankWorkflowService workflowService = new SceneBankWorkflowService((projectReference, tilesetAssetRoot) ->
"tilesets/overworld".equals(tilesetAssetRoot) ? 1 : -1);
@TempDir @TempDir
Path tempDir; Path tempDir;
@Test @Test
void validateThenAcceptPromotesSceneBankToReadyUntilFilesChange() throws Exception { void inspectProjectsCanonicalSceneStateIntoReadyRequest() throws Exception {
final ProjectReference project = projectReference(); final ProjectReference project = projectReference();
final AssetWorkspaceAssetDetails sceneDetails = createValidScene(project); final AssetWorkspaceAssetDetails sceneDetails = createValidScene(project);
final SceneBankWorkflowResult initial = workflowService.inspect(project, sceneDetails); final SceneBankWorkflowResult result = workflowService.inspect(project, sceneDetails);
assertEquals(AssetWorkspaceSceneBankStatus.PENDING_VALIDATION, initial.validation().status());
assertFalse(initial.validation().canAccept());
final SceneBankWorkflowResult validated = workflowService.validate(project, sceneDetails); assertEquals(AssetWorkspaceSceneBankStatus.READY, result.validation().status());
assertEquals(AssetWorkspaceSceneBankStatus.VALIDATED_PENDING_ACCEPTANCE, validated.validation().status()); assertFalse(result.validation().pendingExternalChanges());
assertTrue(validated.validation().canAccept()); assertFalse(result.validation().canAccept());
assertTrue(Files.isRegularFile(sceneDetails.summary().assetRoot().resolve(SceneBankWorkflowService.ACCEPTED_REQUEST_FILE)));
final SceneBankWorkflowResult accepted = workflowService.accept(project, sceneDetails);
assertEquals(AssetWorkspaceSceneBankStatus.READY, accepted.validation().status());
assertFalse(accepted.validation().pendingExternalChanges());
final Path tmxPath = sceneDetails.summary().assetRoot().resolve("ground.tmx");
Files.writeString(tmxPath, Files.readString(tmxPath).replaceFirst("0,0,0", "1,0,0"));
final SceneBankWorkflowResult changed = workflowService.inspect(project, sceneDetails);
assertEquals(AssetWorkspaceSceneBankStatus.PENDING_VALIDATION, changed.validation().status());
assertTrue(changed.validation().pendingExternalChanges());
} }
@Test @Test
void validationFailsWhenReferencedTsxIsMissing() throws Exception { void validationFailsWhenReferencedGlyphAssetCannotResolveTileSize() throws Exception {
final ProjectReference project = projectReference(); final ProjectReference project = projectReference();
final Path sceneRoot = project.rootPath().resolve("assets/scenes/broken"); final Path sceneRoot = project.rootPath().resolve("assets/scenes/broken");
Files.createDirectories(sceneRoot); Files.createDirectories(sceneRoot);
@ -86,29 +78,43 @@ final class SceneBankWorkflowServiceTest {
8, 8,
4, 4,
List.of( List.of(
new AssetStudioSceneLayerBinding(true, 1, "Ground", 8, 8, "ground.tmx", "tilesets/missing"), 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", ""), 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", ""), 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", "")), new AssetStudioSceneLayerBinding(false, 4, "Layer 4", 8, 8, "layer-4.tmx", "", 1.0f, 1.0f, 0)),
sceneRoot.resolve("scene-bank.studio.json")), sceneRoot.resolve("scene-bank.studio.json")),
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of()); 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); final SceneBankWorkflowResult result = workflowService.inspect(project, brokenScene);
assertEquals(AssetWorkspaceSceneBankStatus.VALIDATION_FAILED, result.validation().status()); 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() { private ProjectReference projectReference() {
@ -119,50 +125,19 @@ final class SceneBankWorkflowServiceTest {
final Path tilesetRoot = project.rootPath().resolve("assets/tilesets/overworld"); final Path tilesetRoot = project.rootPath().resolve("assets/tilesets/overworld");
Files.createDirectories(tilesetRoot); Files.createDirectories(tilesetRoot);
Files.writeString(tilesetRoot.resolve("a.png"), "fixture"); 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"); final Path sceneRoot = project.rootPath().resolve("assets/scenes/overworld");
Files.createDirectories(sceneRoot); Files.createDirectories(sceneRoot);
final AssetWorkspaceAssetDetails scene = sceneDetails(sceneRoot); return sceneDetails(sceneRoot);
assertTrue(generationService.generateSceneBankTilemaps(project, scene).success());
return scene;
}
private AssetWorkspaceAssetDetails tilesetDetails(Path assetRoot) {
return new AssetWorkspaceAssetDetails(
new AssetWorkspaceAssetSummary(
AssetReference.forAssetId(1),
"overworld_tileset",
AssetWorkspaceAssetState.REGISTERED,
AssetWorkspaceBuildParticipation.INCLUDED,
1,
AssetFamilyCatalog.GLYPH_BANK,
AssetStudioGlyphSpecialization.TILESET,
assetRoot,
false,
false),
List.of(),
OutputFormatCatalog.GLYPH_INDEXED_V1,
OutputCodecCatalog.NONE,
List.of(OutputCodecCatalog.NONE),
Map.of(OutputCodecCatalog.NONE, List.of()),
List.of(new PackerCodecConfigurationFieldDTO(
"tile_size",
"Tile Size",
PackerCodecConfigurationFieldType.ENUM,
"16x16",
true,
List.of("8x8", "16x16", "32x32"))),
Map.of(),
new AssetWorkspaceBankCompositionDetails(
List.of(),
List.of(new AssetWorkspaceBankCompositionFile("a.png", "a.png", 1L, 1L, null, Map.of())),
0L),
List.of(),
null,
null,
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of());
} }
private AssetWorkspaceAssetDetails sceneDetails(Path assetRoot) { private AssetWorkspaceAssetDetails sceneDetails(Path assetRoot) {
@ -183,7 +158,13 @@ final class SceneBankWorkflowServiceTest {
OutputCodecCatalog.NONE, OutputCodecCatalog.NONE,
List.of(OutputCodecCatalog.NONE), List.of(OutputCodecCatalog.NONE),
Map.of(OutputCodecCatalog.NONE, List.of()), 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(), Map.of(),
new AssetWorkspaceBankCompositionDetails(List.of(), List.of(), 0L), new AssetWorkspaceBankCompositionDetails(List.of(), List.of(), 0L),
List.of(), List.of(),
@ -193,12 +174,33 @@ final class SceneBankWorkflowServiceTest {
12, 12,
4, 4,
List.of( List.of(
new AssetStudioSceneLayerBinding(true, 1, "Ground", 16, 12, "ground.tmx", "tilesets/overworld"), new AssetStudioSceneLayerBinding(
new AssetStudioSceneLayerBinding(false, 2, "Layer 2", 16, 12, "layer-2.tmx", ""), true,
new AssetStudioSceneLayerBinding(false, 3, "Layer 3", 16, 12, "layer-3.tmx", ""), 1,
new AssetStudioSceneLayerBinding(false, 4, "Layer 4", 16, 12, "layer-4.tmx", "")), "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")), assetRoot.resolve("scene-bank.studio.json")),
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of()); 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.packer.messages.assets.PackerCodecConfigurationFieldType;
import p.studio.projects.ProjectReference; import p.studio.projects.ProjectReference;
import p.studio.workspaces.assets.metadata.AssetStudioGlyphSpecialization; 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.AssetStudioSceneBankMetadata;
import p.studio.workspaces.assets.metadata.AssetStudioSceneLayerBinding; 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.messages.*;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -29,13 +32,13 @@ final class TiledAssetGenerationServiceTest {
Path tempDir; Path tempDir;
@Test @Test
void generatesTsxForTilesetSpecializedGlyphBank() throws Exception { void exportsTsxForTilesetSpecializedGlyphBank() throws Exception {
final Path assetRoot = tempDir.resolve("assets/tilesets/overworld"); final Path assetRoot = tempDir.resolve("assets/tilesets/overworld");
Files.createDirectories(assetRoot); Files.createDirectories(assetRoot);
Files.writeString(assetRoot.resolve("a.png"), "fixture"); Files.writeString(assetRoot.resolve("a.png"), "fixture");
Files.writeString(assetRoot.resolve("b.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()); assertTrue(result.success());
final Path tsxPath = assetRoot.resolve(TiledAssetGenerationService.GENERATED_TSX_FILE); final Path tsxPath = assetRoot.resolve(TiledAssetGenerationService.GENERATED_TSX_FILE);
@ -47,28 +50,373 @@ final class TiledAssetGenerationServiceTest {
} }
@Test @Test
void generatesTmxFilesForSceneBankUsingReferencedTsx() throws Exception { void exportsSingleSceneTmxFromCanonicalState() throws Exception {
final Path projectRoot = tempDir.resolve("project"); final Path projectRoot = tempDir.resolve("project");
final Path tilesetRoot = projectRoot.resolve("assets/tilesets/overworld"); final Path tilesetRoot = projectRoot.resolve("assets/tilesets/overworld");
Files.createDirectories(tilesetRoot); Files.createDirectories(tilesetRoot);
Files.writeString(tilesetRoot.resolve("a.png"), "fixture"); 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"); final Path sceneRoot = projectRoot.resolve("assets/scenes/overworld");
Files.createDirectories(sceneRoot); Files.createDirectories(sceneRoot);
final AssetWorkspaceAssetDetails details = sceneDetails(sceneRoot); final AssetWorkspaceAssetDetails details = sceneDetails(sceneRoot);
final ProjectReference projectReference = new ProjectReference("main", "1", "pbs", 1, projectRoot); 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()); assertTrue(result.success());
final Path tmxPath = sceneRoot.resolve("ground.tmx"); final Path tmxPath = sceneRoot.resolve("scene.tmx");
assertTrue(Files.isRegularFile(tmxPath)); assertTrue(Files.isRegularFile(tmxPath));
final TiledMapDocument map = codec.readMap(tmxPath); final TiledMapDocument map = codec.readMap(tmxPath);
assertEquals("../../tilesets/overworld/tileset.tsx", map.tilesets().getFirst().source()); assertEquals("../../tilesets/overworld/tileset.tsx", map.tilesets().getFirst().source());
assertEquals(16, map.width()); assertEquals(2, map.tileLayers().size());
assertEquals(12, map.height());
assertEquals("Ground", map.tileLayers().getFirst().name()); 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) { private AssetWorkspaceAssetDetails tilesetDetails(Path assetRoot) {
@ -134,16 +482,25 @@ final class TiledAssetGenerationServiceTest {
List.of(), List.of(),
null, null,
new AssetStudioSceneBankMetadata( new AssetStudioSceneBankMetadata(
16, 4,
12, 4,
4, 4,
List.of( List.of(
new AssetStudioSceneLayerBinding(true, 1, "Ground", 16, 12, "ground.tmx", "tilesets/overworld"), new AssetStudioSceneLayerBinding(true, 1, "Ground", 4, 4, "scene.tmx", "tilesets/overworld", 1.0f, 1.0f, 0, canonicalTiles(4, 4, 0)),
new AssetStudioSceneLayerBinding(false, 2, "Layer 2", 16, 12, "layer-2.tmx", ""), 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", 16, 12, "layer-3.tmx", ""), new AssetStudioSceneLayerBinding(false, 3, "Layer 3", 4, 4, "scene.tmx", "", 1.0f, 1.0f, 0),
new AssetStudioSceneLayerBinding(false, 4, "Layer 4", 16, 12, "layer-4.tmx", "")), new AssetStudioSceneLayerBinding(false, 4, "Layer 4", 4, 4, "scene.tmx", "", 1.0f, 1.0f, 0)),
assetRoot.resolve("scene-bank.studio.json")), assetRoot.resolve("scene-bank.studio.json")),
AssetWorkspaceSceneBankValidation.NOT_APPLICABLE, AssetWorkspaceSceneBankValidation.NOT_APPLICABLE,
List.of()); 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, "schema_version" : 1,
"next_asset_id" : 18, "next_asset_id" : 19,
"assets" : [ { "assets" : [ {
"asset_id" : 3, "asset_id" : 3,
"asset_uuid" : "21953cb8-4101-4790-9e5e-d95f5fbc9b5a", "asset_uuid" : "21953cb8-4101-4790-9e5e-d95f5fbc9b5a",
@ -41,15 +41,15 @@
"asset_uuid" : "87396aab-337e-479e-b1f4-ec296678389e", "asset_uuid" : "87396aab-337e-479e-b1f4-ec296678389e",
"root" : "Zelda3", "root" : "Zelda3",
"included_in_build" : true "included_in_build" : true
}, {
"asset_id" : 16,
"asset_uuid" : "6f05bee1-f974-4b65-a43f-eacd46b8ec96",
"root" : "tiled",
"included_in_build" : false
}, { }, {
"asset_id" : 17, "asset_id" : 17,
"asset_uuid" : "c12f67ad-0471-4ab1-96dd-a816cfc66462", "asset_uuid" : "c12f67ad-0471-4ab1-96dd-a816cfc66462",
"root" : "tileset", "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" : { "preload" : {
"enabled" : false "enabled" : true
}, },
"artifacts" : [ { "artifacts" : [ {
"artifact_id" : "art-67db3cae2605", "artifact_id" : "art-67db3cae2605",

View File

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