Author SHA1 Message Date
ryan ddb298f631 Add visual harmonization tests for editor UI components and ground finish chooser
- Implemented `test_editor_visual_harmonization.gd` to validate the Stickman editor and prop editor UI layouts, ensuring proper functionality and visual consistency.
- Created `test_editor_visual_theme.gd` to verify the loading and application of visual themes in the editor, including semantic overrides and default values.
- Introduced `test_phase19_ground_finish_chooser.gd` to test the ground finish chooser's functionality, including layout responsiveness, selection behavior, and interaction with the stage.
- Added new scripts for ground finish preview and chooser UI components to enhance the editor's visual representation of ground finishes.
2026-10-23 15:20:12 -04:00
ryan 0a5ef0a61e feat: Add StageDialogStyler for enhanced dialog management
- Introduced StageDialogStyler class to render child-facing dialogs as shared in-stage cards.
- Preserved native AcceptDialog/ConfirmationDialog signals for stage workflows.
- Implemented methods for applying styles, managing visibility, and handling button actions.
- Added support for dynamic theming and responsive design based on viewport size.
- Created utility functions for styling buttons and managing dialog content.
2026-10-23 01:16:22 -04:00
ryan c6f3930dcb Implement Phase 19 updates and user/editor verification
- Updated architectural specifications for Phase 19 scenery, backgrounds, and surface finishes to reflect implementation status and user verification results.
- Revised UI/UX specifications for background ground and wall color, plain background, and starter scene composition to indicate successful implementation and verification.
- Enhanced scenery controller and sandbox stage scripts to improve eraser functionality and user interactions.
- Adjusted test cases to align with the latest changes in scenery and background implementations, ensuring all checks pass successfully.
- Added new buttons for wall area erasure in the scenery capture bar and updated layout handling for better user experience.
2026-10-22 17:02:39 -04:00
ryan 59f1817063 Add StageBackgroundView and related tests for plain background functionality
- Implemented StageBackgroundView class for managing sky and plain backgrounds in the stage.
- Added functionality to set background type and color, bind camera, and apply themes.
- Created test_phase19_plain_background.gd to validate plain background behavior and migration.
- Developed test_phase19_starter_chooser_layout.gd to ensure proper layout and functionality of the starter chooser.
- Included visual capture tests for UI elements and interactions.
2026-10-22 16:18:25 -04:00
ryan db677dc194 Refactor code structure for improved readability and maintainability 2026-10-22 00:52:36 -04:00
ryan 0b1dda2e2f Add Phase 19 Starter Scene Composition UI/UX Specification and Tests
- Introduced a new UI/UX specification document for the Phase 19 Starter Scene Composition, detailing user paths, composition rules for Town Street and Living Room, camera framing, and observable acceptance outcomes.
- Implemented a visual capture test for different sky states in the sandbox stage, ensuring proper rendering and visual consistency.
- Created a comprehensive test suite for the Phase 19 starter scene composition, validating scene loading, geometry, navigation, and interaction behaviors for both Town Street and Living Room.
- Added checks for visual alignment, object placements, and scene transitions to ensure adherence to the new UI/UX specifications.
2026-10-21 17:46:58 -04:00
ryan c1758efd3b Add tests for scenery surface caching and preview behavior
- Introduced `test_scenery_brick_preview.gd` to validate focused placement and CPU draw-cost for large brick walls.
- Added `test_scenery_surface_cache.gd` to assess texture budget, visual equivalence, and cache invalidation.
- Created `test_scenery_surface_preview.gd` to evaluate adaptive preview selection and placement behavior.
- Implemented various helper functions to measure drawing performance and validate expected behaviors during tests.
- Added unique identifiers for new test files to ensure proper tracking and management.
2026-10-21 11:06:57 -04:00
ryan 80ba83b703 feat: Enhance surface setup chooser with compact mode and cancel functionality 2026-10-19 09:46:51 -04:00
ryan 26dd9c64e1 Add Stage Surface Color Dialog and Background Ground Functionality
- Implemented StageSurfaceColorDialog for custom color selection with live preview.
- Created StageBackgroundGround class for non-walkable scenery behind the set.
- Introduced StageRectSurface for shared rectangular geometry and tint preview.
- Added StageSurfacePreview for rendering surface previews.
- Developed comprehensive tests for surface color selection, rendering, and serialization.
2026-10-17 21:00:03 -04:00
ryan a0510e6405 Add new test file for phase 19 scenery with unique identifier 2026-10-17 12:53:38 -04:00
191 changed files with 70807 additions and 9484 deletions
+3
View File
@@ -12,6 +12,7 @@ You are the art and animation specialist for Stickman Studio, a kid-friendly God
- Create native `.prop` and `.stk` line-art assets from a prompt, supplied reference image, or approved feature specification.
- Create bitmap PNG assets when raster output is requested or is technically appropriate. Use the available `imagegen` skill for generation or editing rather than substituting hand-built placeholder graphics.
- Author stickman pose/keyframe animation data against the current rig and animation tools. Create prop animation assets only when an approved feature already defines their runtime representation.
- For Stage-usable `.prop` artwork, infer a sensible everyday size from the named subject and intended use, using the current standard stickman guide as the ruler. For example, an ordinary refrigerator is roughly 0.85–1 standard stickman height; a toy, giant, or distant version is an intentional exception. Author the geometry at its intended Stage size so a new instance reads correctly at scale 1; do not treat the Prop Editor's fixed 96-unit Normalize Size target as a general Stage size. Ask about scale only when the intent is ambiguous or deliberately unusual.
- Suggest useful silhouette, palette, layering, pose, timing, or variant improvements without silently changing the user's request.
- Browse for additional visual references when useful. Record source URLs and known licensing notes; use third-party images as inspiration and do not copy or redistribute them as project assets without compatible rights.
@@ -40,6 +41,8 @@ Each review bundle must contain:
- `review.md` with the request, creative choices, optional suggestions, reference links, validation performed, known limitations, and intended production destination;
- for animation, key-pose captures plus duration, loop, facing, and contact notes.
For Stage-usable `.prop` artwork, the representative preview must include a scale check against the current standard stickman guide, composited visibly in front of the art, plus a ground/contact reference where relevant. Record the inferred intended size in `review.md` as an approximate fraction of standard stickman height, along with any assumptions or intentional perspective/oversize choices. Check outline and detail readability at normal Stage zoom after sizing; geometry scaling alone may leave line weights too thin or too heavy.
Do not delete rejected variants automatically. Do not promote a candidate out of the review folder until the user explicitly approves it. On approval, promote `.prop` to `content/props/`, `.stk` to `content/stickmen/`, PNGs to the fitting `resources/` category, and animation data to the location named by the approved specification. Use a new filename unless replacement was explicitly approved.
## Boundaries and validation
+19 -4
View File
@@ -15,6 +15,19 @@ developer_instructions = """
for dialogs or other editor workflows, reuse its visual principles only when their layout
and interaction model fit, and call out any exception.
* **Dialogs & Popups:** Before designing a child-facing dialog, read
`docs/specs/dialog_design_principles.md`. Carry its rounded controls, thicker borders, blue
primary actions with bold white labels, comfortable button spacing, and large readable type
into the proposed UI. Prefer visible, labeled dismissal actions such as Cancel, Done, or Back;
do not default to a small corner × as the only way to close a dialog. Use the Choose Scenery
dialog and its theme tokens as the current implementation reference.
* **Stable Dialog Anchoring:** When tabs, filters, or other in-place choices change a dialog's
size, keep its current anchor so the clicked control stays under the pointer. Grow from a
stable edge while space allows; when the safe viewport is reached, cap the dialog and scroll
its content. Nudge it only as much as needed to keep essential controls visible. Do not
recenter after every content change.
* **Touch & Click Target Rules:**
* Minimum pointer hit-target: **44×44 logical pixels** (never build tiny 16px/24px buttons).
* Control heights (buttons, dropdowns, rows) must obey `button_min_height` tokens from the theme.
@@ -48,15 +61,17 @@ developer_instructions = """
* **Accessible Inputs:**
* Prefer simple explicit buttons (e.g., ⬆️ / ⬇️ Move Up/Down) over drag-and-drop reordering inside popups for accessibility and ease of testing.
* Always provide prominent, clickable `[X Cancel]` buttons on active workflows so users don't need to know the `Esc` key.
* Provide a prominent, clickable, visibly labeled action such as **Cancel** on active workflows so users do not need to know the `Esc` key. Do not rely on a corner × as the sole dismissal control.
## 3. Pipeline Output Protocol
When invoked during Phase 0a (UI/UX Proposal & Alternatives):
1. Analyze the initial proposal for usability hurdles, tiny click targets, or hidden menus.
2. Pitch 1 to 3 alternate visual/interaction approaches (e.g., compare floating cursor badges vs. top notification bars).
3. Mark your recommended choice with [Recommended] and ask up to 3 simple clarification questions.
4. STOP and wait for user feedback.
2. Present about three distinct UI concepts by default (usually exactly three). Apply the same default when the user directly asks you for UI ideas or mockups outside a formal spec-pipeline run.
3. Give each concept a visual mockup, not only a prose description. Use one comparison board when all three remain legible; otherwise create a separate mockup for each. Use supplied screenshots and the established product style as visual references when available. Make the concepts differ in interaction or information hierarchy, not only colors or decoration. If visual mockup generation is unavailable, provide annotated wireframes and say so.
4. Pair each mockup with a short title and concise tradeoff. Mark the recommended option with [Recommended] and explain why. Ask up to 3 focused clarification questions after presenting the concepts.
5. Do not require alternatives or mockups for implementation-only requests, narrow bug fixes, or critique-only questions unless the user asks. Follow any user-specified number or format.
6. STOP and wait for user feedback before writing the UI spec.
When invoked during Phase 0b (UI Spec Generation):
Output a structured spec covering:
+1 -1
View File
@@ -11,7 +11,7 @@ Use the `artist` subagent for project graphical-asset and animation work. This s
Spawn `artist` with `agent_type: "artist"`. Reuse the same Artist for revisions while it is available. Tell it that other agents may share the workspace and that it must preserve unrelated changes.
Pass the user's request, intended use, supplied image roles (reference versus edit target), relevant approved specification, requested format, and any style or technical constraints. Do not require the user to repeat information already available in the conversation or repository.
Pass the user's request, intended use, supplied image roles (reference versus edit target), relevant approved specification, requested format, and any style or technical constraints. For Stage-usable `.prop` art, include a requested scale when one is specified; otherwise have Artist infer a conventional size from the named subject and intended use against the standard stickman guide, and record that estimate in the review bundle. Do not require the user to repeat ordinary size information or clarify unless the requested scale is ambiguous or intentionally unusual.
Before delegating, read [references/project-formats.md](references/project-formats.md) for routing and source-of-truth locations. For production or revision work, also read [references/review-workflow.md](references/review-workflow.md).
+1 -2
View File
@@ -8,8 +8,7 @@ description: "Stickman Studio feature workflow: UI/UX and architecture specs, im
1. **Phase 0a: UI/UX Proposal & Alternative Ideas (User-Facing Features Only)**
- `@uiux` analyzes the user's initial idea for cognitive friction, accessibility, and kid-friendliness.
- For an in-stage selected-object inspector, `@uiux` first reviews `docs/specs/stage_panel_ui_system.md` and the current shared controls under `scripts/shared/stage_ui/`; proposals should build on that system unless the interaction needs justify a specific exception.
- `@uiux` pitches **1–3 alternative interaction approaches** (e.g., comparing floating tooltips vs. top-center banners, custom cursor reticles, or streamlined dialogs).
- `@uiux` highlights the most intuitive option with `[Recommended]` and asks up to 3 clarification questions.
- Follow the concept-count and visual-mockup protocol in `.codex/agents/uiux.toml`; it also applies when the user asks `@uiux` for ideas outside a formal pipeline run.
- `@uiux` **STOP:** Waits for user feedback/selection before writing the UI spec.
- _Bypass Rule:_ If the feature is strictly internal engine/backend logic (e.g., physics, serialization, scanning), skip directly to Phase 1a.
+10
View File
@@ -6,4 +6,14 @@ addons/
*.tmp
.opencode/skills/playwright-cli
*.import
# Local scratch captures, logs, and temporary browser profiles
/tmp/
/work/
content/review/artist/
content/examples/
/tests/godot_test_user_data/
/tests/*_runtime.log
/tests/phase19_*.log
/tests/phase19_visual_*.png
/tests/phase19_selected_artwork_depth_inspector.png
/.codex-remote-attachments/
+19
View File
@@ -72,6 +72,25 @@ the configured `run/main_scene`.
Keep the prompt and visible Cancel together, leave the mode pill unobstructed, and reuse or
generalize `StageUseTargetCaptureBar` instead of copying its styling.
## Child-facing dialogs and popups
- When a user prefixes a UI/UX question or request with `uiux:` or `uiux,`, route it to the
custom UI/UX agent defined in `.codex/agents/uiux.toml`. Treat that prefix as an explicit
delegation request. The UI/UX agent is read-only; carry its design guidance into implementation
separately when implementation is requested.
- For new or revised child-facing dialogs, popups, and short setup flows, follow the
[Dialog Design Principles](docs/specs/dialog_design_principles.md). Use rounded containers
and controls, clear borders, blue primary actions with bold white labels, comfortable button
spacing, and large readable type. Prefer a visible, labeled action such as Cancel, Done, or
Back; do not make a small corner × the default or only way to close a dialog.
- When a dialog resizes after a tab, filter, or other in-place choice, preserve its current
anchor so the clicked control does not jump away from the pointer. Keep the dialog within the
safe viewport by limiting its height and scrolling content; nudge it only as much as needed
to keep essential controls visible. Avoid recentering after every content change.
- Keep reusable values in `SandboxTheme` or `PropTheme`. The Choose Scenery dialog in
`scripts/shared/ui/asset_selector.gd` and the `popups.asset_selector` tokens are the current
implementation reference. Adapt its layout to each workflow.
## Required addon
- **Scalable Vector Shapes 2D** (v2.27.7) at `addons/curved_lines_2d/`
+715
View File
@@ -0,0 +1,715 @@
{
"anchors": {
"origin": {
"allowed_hands": "both",
"role": "pivot",
"rotation_deg": 0.0,
"x": 0.0,
"y": 0.0,
"z_index_offset": 0
}
},
"material_preset": "wood",
"metadata": {
"created_at": "2026-10-21T23:11:16",
"modified_at": "2026-10-21T23:11:16"
},
"prop_name": "Bookshelf",
"shapes": [
{
"closed": true,
"fill_color": "#AB795E",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": -320.119689941406,
"y": -488.025634765625
},
{
"x": 320.119689941406,
"y": -488.025634765625
},
{
"x": 320.119689941406,
"y": 488.025604248047
},
{
"x": -320.119689941406,
"y": 488.025604248047
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#F5D9AA",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": -273.043273925781,
"y": -431.533935546875
},
{
"x": 273.043273925781,
"y": -431.533935546875
},
{
"x": 273.043273925781,
"y": 412.703308105469
},
{
"x": -273.043273925781,
"y": 412.703308105469
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#AB795E",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": -273.043273925781,
"y": -171.044372558594
},
{
"x": 273.043273925781,
"y": -171.044372558594
},
{
"x": 273.043273925781,
"y": -133.383239746094
},
{
"x": -273.043273925781,
"y": -133.383239746094
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#AB795E",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": -273.043273925781,
"y": 108.275756835938
},
{
"x": 273.043273925781,
"y": 108.275756835938
},
{
"x": 273.043273925781,
"y": 145.936904907227
},
{
"x": -273.043273925781,
"y": 145.936904907227
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#C87375",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": -238.520568847656,
"y": -409.56494140625
},
{
"x": -182.028854370117,
"y": -409.56494140625
},
{
"x": -182.028854370117,
"y": -183.598083496094
},
{
"x": -238.520568847656,
"y": -183.598083496094
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#77A7B2",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": -169.475143432617,
"y": -409.56494140625
},
{
"x": -112.983421325684,
"y": -409.56494140625
},
{
"x": -112.983421325684,
"y": -183.598083496094
},
{
"x": -169.475143432617,
"y": -183.598083496094
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#E3B765",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": -100.429710388184,
"y": -409.56494140625
},
{
"x": -43.9379997253418,
"y": -409.56494140625
},
{
"x": -43.9379997253418,
"y": -183.598083496094
},
{
"x": -100.429710388184,
"y": -183.598083496094
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#82B38D",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": -28.2458553314209,
"y": -409.56494140625
},
{
"x": 28.2458553314209,
"y": -409.56494140625
},
{
"x": 28.2458553314209,
"y": -183.598083496094
},
{
"x": -28.2458553314209,
"y": -183.598083496094
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#C87375",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": 53.3532829284668,
"y": -409.56494140625
},
{
"x": 109.844993591309,
"y": -409.56494140625
},
{
"x": 109.844993591309,
"y": -183.598083496094
},
{
"x": 53.3532829284668,
"y": -183.598083496094
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#77A7B2",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": 134.952423095703,
"y": -409.56494140625
},
{
"x": 191.444137573242,
"y": -409.56494140625
},
{
"x": 191.444137573242,
"y": -183.598083496094
},
{
"x": 134.952423095703,
"y": -183.598083496094
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#78A7B2",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": -232.243698120117,
"y": -114.552658081055
},
{
"x": -178.890426635742,
"y": -114.552658081055
},
{
"x": -178.890426635742,
"y": 86.3067626953125
},
{
"x": -232.243698120117,
"y": 86.3067626953125
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#E5B869",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": -166.336700439453,
"y": -114.552658081055
},
{
"x": -112.983421325684,
"y": -114.552658081055
},
{
"x": -112.983421325684,
"y": 86.3067626953125
},
{
"x": -166.336700439453,
"y": 86.3067626953125
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#C97375",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": -100.429710388184,
"y": -114.552658081055
},
{
"x": -47.0764274597168,
"y": -114.552658081055
},
{
"x": -47.0764274597168,
"y": 86.3067626953125
},
{
"x": -100.429710388184,
"y": 86.3067626953125
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#9BB586",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": 12.5537137985229,
"y": -114.552658081055
},
{
"x": 65.9069976806641,
"y": -114.552658081055
},
{
"x": 65.9069976806641,
"y": 86.3067626953125
},
{
"x": 12.5537137985229,
"y": 86.3067626953125
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#83AABB",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": 84.7375717163086,
"y": -114.552658081055
},
{
"x": 138.090850830078,
"y": -114.552658081055
},
{
"x": 138.090850830078,
"y": 86.3067626953125
},
{
"x": 84.7375717163086,
"y": 86.3067626953125
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#DEAA72",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": 150.644561767578,
"y": -114.552658081055
},
{
"x": 203.997848510742,
"y": -114.552658081055
},
{
"x": 203.997848510742,
"y": 86.3067626953125
},
{
"x": 150.644561767578,
"y": 86.3067626953125
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#8CB98E",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": -213.413131713867,
"y": 199.290191650391
},
{
"x": -37.6611404418945,
"y": 199.290191650391
},
{
"x": -37.6611404418945,
"y": 371.903747558594
},
{
"x": -213.413131713867,
"y": 371.903747558594
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#79AF81",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": -28.2458553314209,
"y": 211.843902587891
},
{
"x": -35.6525459289551,
"y": 240.654678344727
},
{
"x": -56.7427864074707,
"y": 265.10302734375
},
{
"x": -88.3153762817383,
"y": 281.4228515625
},
{
"x": -125.537139892578,
"y": 287.166198730469
},
{
"x": -162.758895874023,
"y": 281.4228515625
},
{
"x": -194.331481933594,
"y": 265.10302734375
},
{
"x": -215.421722412109,
"y": 240.654678344727
},
{
"x": -222.828414916992,
"y": 211.843902587891
},
{
"x": -215.421722412109,
"y": 183.033111572266
},
{
"x": -194.331481933594,
"y": 158.584777832031
},
{
"x": -162.758895874023,
"y": 142.264938354492
},
{
"x": -125.537139892578,
"y": 136.521606445313
},
{
"x": -88.3153762817383,
"y": 142.264938354492
},
{
"x": -56.7427864074707,
"y": 158.584777832031
},
{
"x": -35.6525459289551,
"y": 183.033111572266
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#C17476",
"outline_color": "#332B45",
"outline_width": 12.553713506884,
"points": [
{
"x": 31.3842849731445,
"y": 252.643463134766
},
{
"x": 207.136276245117,
"y": 252.643463134766
},
{
"x": 207.136276245117,
"y": 393.872741699219
},
{
"x": 31.3842849731445,
"y": 393.872741699219
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#F4CE9E",
"outline_width": 9.41528513016301,
"points": [
{
"x": 31.3842849731445,
"y": 312.273620605469
},
{
"x": 207.136276245117,
"y": 312.273620605469
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
}
],
"tags": [
"objects",
"walls"
],
"version": "1.1"
}
+368
View File
@@ -0,0 +1,368 @@
{
"anchors": {
"origin": {
"allowed_hands": "both",
"role": "pivot",
"rotation_deg": 0.0,
"x": 0.0,
"y": 0.0,
"z_index_offset": 0
}
},
"material_preset": "none",
"metadata": {
"created_at": "2026-10-21T21:43:32",
"modified_at": "2026-10-21T21:43:32"
},
"prop_name": "City Skyline",
"shapes": [
{
"closed": true,
"fill_color": "#879DB0",
"outline_color": "#687F97",
"outline_width": 8.76671850699844,
"points": [
{
"x": -935.116638183594,
"y": 284.918365478516
},
{
"x": -935.116638183594,
"y": 7.30561065673828
},
{
"x": -812.382568359375,
"y": 7.30561065673828
},
{
"x": -812.382568359375,
"y": -138.806365966797
},
{
"x": -642.892700195313,
"y": -138.806365966797
},
{
"x": -642.892700195313,
"y": -7.30558013916016
},
{
"x": -537.692077636719,
"y": -7.30558013916016
},
{
"x": -537.692077636719,
"y": -246.929229736328
},
{
"x": -397.424591064453,
"y": -246.929229736328
},
{
"x": -397.424591064453,
"y": -80.361572265625
},
{
"x": -251.312606811523,
"y": -80.361572265625
},
{
"x": -251.312606811523,
"y": -176.795471191406
},
{
"x": -111.045104980469,
"y": -176.795471191406
},
{
"x": -111.045104980469,
"y": -21.9167785644531
},
{
"x": 43.8335914611816,
"y": -21.9167785644531
},
{
"x": 43.8335914611816,
"y": -284.918334960938
},
{
"x": 181.178848266602,
"y": -284.918334960938
},
{
"x": 181.178848266602,
"y": -71.5948486328125
},
{
"x": 321.446350097656,
"y": -71.5948486328125
},
{
"x": 321.446350097656,
"y": -188.484436035156
},
{
"x": 467.558319091797,
"y": -188.484436035156
},
{
"x": 467.558319091797,
"y": -18.9945449829102
},
{
"x": 604.903564453125,
"y": -18.9945449829102
},
{
"x": 604.903564453125,
"y": -124.195159912109
},
{
"x": 756.860046386719,
"y": -124.195159912109
},
{
"x": 756.860046386719,
"y": 21.9168128967285
},
{
"x": 935.116638183594,
"y": 21.9168128967285
},
{
"x": 935.116638183594,
"y": 284.918365478516
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#DDE7E8",
"outline_color": "#DDE7E8",
"outline_width": 0.0,
"points": [
{
"x": -195.827392578125,
"y": -154.283813476563
},
{
"x": -160.760498046875,
"y": -154.283813476563
},
{
"x": -160.760498046875,
"y": -98.7612609863281
},
{
"x": -195.827392578125,
"y": -98.7612609863281
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#DDE7E8",
"outline_color": "#DDE7E8",
"outline_width": 0.0,
"points": [
{
"x": -753.937805175781,
"y": -71.5948486328125
},
{
"x": -718.870910644531,
"y": -71.5948486328125
},
{
"x": -718.870910644531,
"y": -18.9945449829102
},
{
"x": -753.937805175781,
"y": -18.9945449829102
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#DDE7E8",
"outline_color": "#DDE7E8",
"outline_width": 0.0,
"points": [
{
"x": -470.480560302734,
"y": -191.406677246094
},
{
"x": -435.413696289063,
"y": -191.406677246094
},
{
"x": -435.413696289063,
"y": -138.806365966797
},
{
"x": -470.480560302734,
"y": -138.806365966797
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#DDE7E8",
"outline_color": "#DDE7E8",
"outline_width": 0.0,
"points": [
{
"x": 87.6671829223633,
"y": -232.318023681641
},
{
"x": 122.734062194824,
"y": -232.318023681641
},
{
"x": 122.734062194824,
"y": -173.873229980469
},
{
"x": 87.6671829223633,
"y": -173.873229980469
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#DDE7E8",
"outline_color": "#DDE7E8",
"outline_width": 0.0,
"points": [
{
"x": 371.124420166016,
"y": -135.884124755859
},
{
"x": 406.191284179688,
"y": -135.884124755859
},
{
"x": 406.191284179688,
"y": -83.2838134765625
},
{
"x": 371.124420166016,
"y": -83.2838134765625
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#DDE7E8",
"outline_color": "#DDE7E8",
"outline_width": 0.0,
"points": [
{
"x": 660.426147460938,
"y": -77.4393310546875
},
{
"x": 695.492980957031,
"y": -77.4393310546875
},
{
"x": 695.492980957031,
"y": -27.7612609863281
},
{
"x": 660.426147460938,
"y": -27.7612609863281
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
}
],
"tags": [
"buildings"
],
"version": "1.1"
}
File diff suppressed because it is too large Load Diff
+277
View File
@@ -0,0 +1,277 @@
{
"anchors": {
"origin": {
"allowed_hands": "both",
"role": "pivot",
"rotation_deg": 0.0,
"x": 0.0,
"y": 0.0,
"z_index_offset": 0
}
},
"material_preset": "none",
"metadata": {
"created_at": "2026-10-21T23:11:48",
"modified_at": "2026-10-21T23:11:48"
},
"prop_name": "Framed Picture",
"shapes": [
{
"closed": true,
"fill_color": "#B68165",
"outline_color": "#332B45",
"outline_width": 5.324,
"points": [
{
"x": -146.410003662109,
"y": -109.141998291016
},
{
"x": 146.410003662109,
"y": -109.141998291016
},
{
"x": 146.410003662109,
"y": 109.141998291016
},
{
"x": -146.410003662109,
"y": 109.141998291016
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#F4DEAF",
"outline_color": "#332B45",
"outline_width": 5.324,
"points": [
{
"x": -121.120994567871,
"y": -85.1839981079102
},
{
"x": 121.120994567871,
"y": -85.1839981079102
},
{
"x": 121.120994567871,
"y": 85.1839981079102
},
{
"x": -121.120994567871,
"y": 85.1839981079102
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#A4CFCD",
"outline_color": "#332B45",
"outline_width": 5.324,
"points": [
{
"x": -106.479995727539,
"y": -71.8740005493164
},
{
"x": 106.479995727539,
"y": -71.8740005493164
},
{
"x": 106.479995727539,
"y": 71.8740005493164
},
{
"x": -106.479995727539,
"y": 71.8740005493164
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#F3D68C",
"outline_color": "#F3D68C",
"outline_width": 0.0,
"points": [
{
"x": 77.197998046875,
"y": -39.9300003051758
},
{
"x": 75.6806564331055,
"y": -32.2900581359863
},
{
"x": 71.3549118041992,
"y": -25.8080883026123
},
{
"x": 64.8729400634766,
"y": -21.4823379516602
},
{
"x": 57.2329978942871,
"y": -19.9650001525879
},
{
"x": 49.5930557250977,
"y": -21.4823379516602
},
{
"x": 43.1110877990723,
"y": -25.8080883026123
},
{
"x": 38.7853393554688,
"y": -32.2900581359863
},
{
"x": 37.2679977416992,
"y": -39.9300003051758
},
{
"x": 38.7853393554688,
"y": -47.5699424743652
},
{
"x": 43.1110877990723,
"y": -54.0519104003906
},
{
"x": 49.5930557250977,
"y": -58.3776588439941
},
{
"x": 57.2329978942871,
"y": -59.8950004577637
},
{
"x": 64.8729400634766,
"y": -58.3776588439941
},
{
"x": 71.3549118041992,
"y": -54.0519104003906
},
{
"x": 75.6806564331055,
"y": -47.5699424743652
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#88B798",
"outline_color": "#88B798",
"outline_width": 0.0,
"points": [
{
"x": -106.479995727539,
"y": 71.8740005493164
},
{
"x": -26.6199989318848,
"y": -14.6409997940063
},
{
"x": 13.3099994659424,
"y": 30.6129989624023
},
{
"x": 53.2399978637695,
"y": -23.9580001831055
},
{
"x": 106.479995727539,
"y": 51.9090003967285
},
{
"x": 106.479995727539,
"y": 71.8740005493164
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0,
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#F0E1BC",
"outline_width": 5.324,
"points": [
{
"x": -85.1839981079102,
"y": 55.9020004272461
},
{
"x": -10.6479997634888,
"y": 55.9020004272461
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
}
],
"tags": [
"objects",
"walls"
],
"version": "1.1"
}
+236
View File
@@ -0,0 +1,236 @@
{
"version": "1.1",
"prop_name": "Potted Plant",
"material_preset": "none",
"tags": ["nature", "objects"],
"shapes": [
{
"shape_type": "polygon",
"points": [
{
"x": -45,
"y": 16
},
{
"x": -55,
"y": -6
},
{
"x": -39,
"y": -74
},
{
"x": -4,
"y": -28
},
{
"x": 8,
"y": 11
}
],
"fill_color": "#70B481",
"outline_color": "#332B45",
"outline_width": 4,
"closed": true,
"vertex_flags": [
0,
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": 3,
"y": 8
},
{
"x": 1,
"y": -31
},
{
"x": 38,
"y": -91
},
{
"x": 48,
"y": -48
},
{
"x": 36,
"y": 4
}
],
"fill_color": "#83C290",
"outline_color": "#332B45",
"outline_width": 4,
"closed": true,
"vertex_flags": [
0,
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": -18,
"y": 14
},
{
"x": -4,
"y": -112
},
{
"x": 18,
"y": -56
},
{
"x": 16,
"y": 15
}
],
"fill_color": "#9CCF90",
"outline_color": "#332B45",
"outline_width": 4,
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "line",
"points": [
{
"x": 0,
"y": 30
},
{
"x": 0,
"y": -69
}
],
"fill_color": "#00000000",
"outline_color": "#5C946F",
"outline_width": 5,
"closed": false,
"vertex_flags": [
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": -55,
"y": 17
},
{
"x": 55,
"y": 17
},
{
"x": 43,
"y": 102
},
{
"x": -43,
"y": 102
}
],
"fill_color": "#C47761",
"outline_color": "#332B45",
"outline_width": 4,
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": -60,
"y": 14
},
{
"x": 60,
"y": 14
},
{
"x": 60,
"y": 31
},
{
"x": -60,
"y": 31
}
],
"fill_color": "#DC9670",
"outline_color": "#332B45",
"outline_width": 4,
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "line",
"points": [
{
"x": -21,
"y": 59
},
{
"x": 21,
"y": 59
}
],
"fill_color": "#00000000",
"outline_color": "#EFC09A",
"outline_width": 4,
"closed": false,
"vertex_flags": [
0,
0
],
"reshaped": false
}
],
"anchors": {
"origin": {
"role": "pivot",
"x": 0,
"y": 0,
"rotation_deg": 0,
"z_index_offset": 0,
"allowed_hands": "both"
}
},
"metadata": {
"created_at": "2026-10-15T00:00:00",
"modified_at": "2026-10-15T00:00:00"
}
}
+306
View File
@@ -0,0 +1,306 @@
{
"anchors": {
"origin": {
"allowed_hands": "both",
"role": "pivot",
"rotation_deg": 0.0,
"x": 0.0,
"y": 0.0,
"z_index_offset": 0
}
},
"material_preset": "none",
"metadata": {
"created_at": "2026-10-21T23:12:17",
"modified_at": "2026-10-21T23:12:17"
},
"prop_name": "Patterned Rug",
"shapes": [
{
"closed": true,
"fill_color": "#C77377",
"outline_color": "#332B45",
"outline_width": 5.324,
"points": [
{
"x": -210.297988891602,
"y": -89.8424987792969
},
{
"x": 210.297988891602,
"y": -89.8424987792969
},
{
"x": 235.586990356445,
"y": 71.20849609375
},
{
"x": -235.586990356445,
"y": 71.20849609375
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#F0D29A",
"outline_color": "#F0D29A",
"outline_width": 2.662,
"points": [
{
"x": -175.692001342773,
"y": -63.2224998474121
},
{
"x": 175.692001342773,
"y": -63.2224998474121
},
{
"x": 195.656997680664,
"y": 44.5884971618652
},
{
"x": -195.656997680664,
"y": 44.5884971618652
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#7E9B9C",
"outline_color": "#7E9B9C",
"outline_width": 2.662,
"points": [
{
"x": -129.106994628906,
"y": -44.5885009765625
},
{
"x": 129.106994628906,
"y": -44.5885009765625
},
{
"x": 141.085998535156,
"y": 25.9544982910156
},
{
"x": -141.085998535156,
"y": 25.9544982910156
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#F6D69A",
"outline_color": "#F6D69A",
"outline_width": 1.331,
"points": [
{
"x": 0.0,
"y": -37.9335021972656
},
{
"x": 46.5849990844727,
"y": -8.6515007019043
},
{
"x": 0.0,
"y": 19.2994995117188
},
{
"x": -46.5849990844727,
"y": -8.6515007019043
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#DBA389",
"outline_width": 3.993,
"points": [
{
"x": -183.677993774414,
"y": 72.5394973754883
},
{
"x": -183.677993774414,
"y": 89.8424987792969
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#DBA389",
"outline_width": 3.993,
"points": [
{
"x": -122.451995849609,
"y": 72.5394973754883
},
{
"x": -122.451995849609,
"y": 89.8424987792969
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#DBA389",
"outline_width": 3.993,
"points": [
{
"x": -61.2259979248047,
"y": 72.5394973754883
},
{
"x": -61.2259979248047,
"y": 89.8424987792969
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#DBA389",
"outline_width": 3.993,
"points": [
{
"x": 0.0,
"y": 72.5394973754883
},
{
"x": 0.0,
"y": 89.8424987792969
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#DBA389",
"outline_width": 3.993,
"points": [
{
"x": 61.2259979248047,
"y": 72.5394973754883
},
{
"x": 61.2259979248047,
"y": 89.8424987792969
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#DBA389",
"outline_width": 3.993,
"points": [
{
"x": 122.451995849609,
"y": 72.5394973754883
},
{
"x": 122.451995849609,
"y": 89.8424987792969
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#DBA389",
"outline_width": 3.993,
"points": [
{
"x": 183.677993774414,
"y": 72.5394973754883
},
{
"x": 183.677993774414,
"y": 89.8424987792969
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
}
],
"tags": [
"objects"
],
"version": "1.1"
}
+548
View File
@@ -0,0 +1,548 @@
{
"anchors": {
"origin": {
"allowed_hands": "both",
"role": "pivot",
"rotation_deg": 0.0,
"x": 0.0,
"y": 0.0,
"z_index_offset": 0
}
},
"material_preset": "none",
"metadata": {
"created_at": "2026-10-21T21:40:26",
"modified_at": "2026-10-21T21:40:26"
},
"prop_name": "Sunny Storefront",
"shapes": [
{
"closed": true,
"fill_color": "#E8B571",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": -783.44287109375,
"y": -596.427490234375
},
{
"x": 783.44287109375,
"y": -596.427490234375
},
{
"x": 783.44287109375,
"y": 798.606262207031
},
{
"x": -783.44287109375,
"y": 798.606262207031
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#6C4E69",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": -859.259887695313,
"y": -677.299011230469
},
{
"x": 859.259887695313,
"y": -677.299011230469
},
{
"x": 859.259887695313,
"y": -535.773864746094
},
{
"x": -859.259887695313,
"y": -535.773864746094
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#FAE6AF",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": -581.264038085938,
"y": -813.769653320313
},
{
"x": 581.264038085938,
"y": -813.769653320313
},
{
"x": 581.264038085938,
"y": -682.353454589844
},
{
"x": -581.264038085938,
"y": -682.353454589844
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#B97768",
"outline_width": 25.2723514249647,
"points": [
{
"x": -252.723510742188,
"y": -743.007141113281
},
{
"x": 252.723510742188,
"y": -743.007141113281
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": true,
"fill_color": "#F3D77A",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": -717.734741210938,
"y": -520.610412597656
},
{
"x": 717.734741210938,
"y": -520.610412597656
},
{
"x": 717.734741210938,
"y": -313.377197265625
},
{
"x": -717.734741210938,
"y": -313.377197265625
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#E37C70",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": -717.734741210938,
"y": -520.610412597656
},
{
"x": -480.174652099609,
"y": -520.610412597656
},
{
"x": -520.610412597656,
"y": -318.431640625
},
{
"x": -677.299011230469,
"y": -318.431640625
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#FFF2D4",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": -480.174652099609,
"y": -520.610412597656
},
{
"x": -242.614562988281,
"y": -520.610412597656
},
{
"x": -283.050323486328,
"y": -318.431640625
},
{
"x": -439.738891601563,
"y": -318.431640625
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#E37C70",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": -242.614562988281,
"y": -520.610412597656
},
{
"x": -5.05447006225586,
"y": -520.610412597656
},
{
"x": -45.4902305603027,
"y": -318.431640625
},
{
"x": -202.178802490234,
"y": -318.431640625
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#FFF2D4",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": -5.05447006225586,
"y": -520.610412597656
},
{
"x": 232.505615234375,
"y": -520.610412597656
},
{
"x": 192.069854736328,
"y": -318.431640625
},
{
"x": 35.381290435791,
"y": -318.431640625
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#E37C70",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": 232.505615234375,
"y": -520.610412597656
},
{
"x": 470.065704345703,
"y": -520.610412597656
},
{
"x": 429.629943847656,
"y": -318.431640625
},
{
"x": 272.941375732422,
"y": -318.431640625
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#FFF2D4",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": 470.065704345703,
"y": -520.610412597656
},
{
"x": 707.625793457031,
"y": -520.610412597656
},
{
"x": 667.190063476563,
"y": -318.431640625
},
{
"x": 510.50146484375,
"y": -318.431640625
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#81B5BD",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": -636.863220214844,
"y": -247.669067382813
},
{
"x": -101.089401245117,
"y": -247.669067382813
},
{
"x": -101.089401245117,
"y": 722.789184570313
},
{
"x": -636.863220214844,
"y": 722.789184570313
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#D4F1E8",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": -566.100646972656,
"y": -171.851989746094
},
{
"x": -171.851989746094,
"y": -171.851989746094
},
{
"x": -171.851989746094,
"y": 591.372985839844
},
{
"x": -566.100646972656,
"y": 591.372985839844
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#835B73",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": 111.198341369629,
"y": -247.669067382813
},
{
"x": 611.590881347656,
"y": -247.669067382813
},
{
"x": 611.590881347656,
"y": 722.789184570313
},
{
"x": 111.198341369629,
"y": 722.789184570313
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#C4DCDD",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": 171.851989746094,
"y": -181.9609375
},
{
"x": 545.882751464844,
"y": -181.9609375
},
{
"x": 545.882751464844,
"y": 596.427429199219
},
{
"x": 171.851989746094,
"y": 596.427429199219
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#835B73",
"outline_width": 25.2723514249647,
"points": [
{
"x": 358.867370605469,
"y": -181.9609375
},
{
"x": 358.867370605469,
"y": 596.427429199219
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": true,
"fill_color": "#C99672",
"outline_color": "#332B45",
"outline_width": 20.2178811399718,
"points": [
{
"x": -808.715209960938,
"y": 722.789184570313
},
{
"x": 808.715209960938,
"y": 722.789184570313
},
{
"x": 808.715209960938,
"y": 813.769653320313
},
{
"x": -808.715209960938,
"y": 813.769653320313
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
}
],
"tags": [
"buildings"
],
"version": "1.1"
}
+475
View File
@@ -0,0 +1,475 @@
{
"anchors": {
"origin": {
"allowed_hands": "both",
"role": "pivot",
"rotation_deg": 0.0,
"x": 0.0,
"y": 0.0,
"z_index_offset": 0
}
},
"material_preset": "none",
"metadata": {
"created_at": "2026-10-21T21:40:03",
"modified_at": "2026-10-21T21:40:03"
},
"prop_name": "Town House",
"shapes": [
{
"closed": true,
"fill_color": "#B3C99C",
"outline_color": "#332B45",
"outline_width": 27.3735711442786,
"points": [
{
"x": -807.520385742188,
"y": -732.243041992188
},
{
"x": 807.520385742188,
"y": -732.243041992188
},
{
"x": 807.520385742188,
"y": 1307.08801269531
},
{
"x": -807.520385742188,
"y": 1307.08801269531
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#CF735F",
"outline_color": "#332B45",
"outline_width": 27.3735711442786,
"points": [
{
"x": -944.38818359375,
"y": -663.80908203125
},
{
"x": 0.0,
"y": -1361.83520507813
},
{
"x": 944.38818359375,
"y": -663.80908203125
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#F3E3B2",
"outline_color": "#332B45",
"outline_width": 27.3735711442786,
"points": [
{
"x": -164.241424560547,
"y": -1197.59375
},
{
"x": 164.241424560547,
"y": -1197.59375
},
{
"x": 164.241424560547,
"y": -725.399658203125
},
{
"x": -164.241424560547,
"y": -725.399658203125
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#85ADBC",
"outline_color": "#332B45",
"outline_width": 27.3735711442786,
"points": [
{
"x": -136.867858886719,
"y": -1170.22021484375
},
{
"x": 136.867858886719,
"y": -1170.22021484375
},
{
"x": 136.867858886719,
"y": -752.773193359375
},
{
"x": -136.867858886719,
"y": -752.773193359375
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#E9D6A9",
"outline_color": "#332B45",
"outline_width": 27.3735711442786,
"points": [
{
"x": -602.218566894531,
"y": -362.699829101563
},
{
"x": -191.615005493164,
"y": -362.699829101563
},
{
"x": -191.615005493164,
"y": 225.831939697266
},
{
"x": -602.218566894531,
"y": 225.831939697266
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#E9D6A9",
"outline_color": "#332B45",
"outline_width": 27.3735711442786,
"points": [
{
"x": 191.615005493164,
"y": -362.699829101563
},
{
"x": 602.218566894531,
"y": -362.699829101563
},
{
"x": 602.218566894531,
"y": 225.831939697266
},
{
"x": 191.615005493164,
"y": 225.831939697266
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#B9DFE3",
"outline_color": "#332B45",
"outline_width": 27.3735711442786,
"points": [
{
"x": -533.78466796875,
"y": -287.422546386719
},
{
"x": -260.048919677734,
"y": -287.422546386719
},
{
"x": -260.048919677734,
"y": 150.554595947266
},
{
"x": -533.78466796875,
"y": 150.554595947266
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#B9DFE3",
"outline_color": "#332B45",
"outline_width": 27.3735711442786,
"points": [
{
"x": 260.048919677734,
"y": -287.422546386719
},
{
"x": 533.78466796875,
"y": -287.422546386719
},
{
"x": 533.78466796875,
"y": 150.554595947266
},
{
"x": 260.048919677734,
"y": 150.554595947266
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#E9D6A9",
"outline_width": 27.3735711442786,
"points": [
{
"x": -396.916778564453,
"y": -287.422546386719
},
{
"x": -396.916778564453,
"y": 150.554595947266
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#E9D6A9",
"outline_width": 27.3735711442786,
"points": [
{
"x": 396.916778564453,
"y": -287.422546386719
},
{
"x": 396.916778564453,
"y": 150.554595947266
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": true,
"fill_color": "#8A6273",
"outline_color": "#332B45",
"outline_width": 27.3735711442786,
"points": [
{
"x": -246.36213684082,
"y": 417.446929931641
},
{
"x": 246.36213684082,
"y": 417.446929931641
},
{
"x": 246.36213684082,
"y": 1307.08801269531
},
{
"x": -246.36213684082,
"y": 1307.08801269531
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#F5D98B",
"outline_color": "#F5D98B",
"outline_width": 6.84339278606966,
"points": [
{
"x": 184.771606445313,
"y": 903.327819824219
},
{
"x": 182.171127319336,
"y": 916.398681640625
},
{
"x": 174.780258178711,
"y": 927.553466796875
},
{
"x": 163.625518798828,
"y": 934.9443359375
},
{
"x": 150.554641723633,
"y": 937.544799804688
},
{
"x": 137.483764648438,
"y": 934.9443359375
},
{
"x": 126.329025268555,
"y": 927.553466796875
},
{
"x": 118.938163757324,
"y": 916.398681640625
},
{
"x": 116.337677001953,
"y": 903.327819824219
},
{
"x": 118.938163757324,
"y": 890.256958007813
},
{
"x": 126.329025268555,
"y": 879.102172851563
},
{
"x": 137.483764648438,
"y": 871.711364746094
},
{
"x": 150.554641723633,
"y": 869.11083984375
},
{
"x": 163.625518798828,
"y": 871.711364746094
},
{
"x": 174.780258178711,
"y": 879.102172851563
},
{
"x": 182.171127319336,
"y": 890.256958007813
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#E8B38A",
"outline_color": "#332B45",
"outline_width": 27.3735711442786,
"points": [
{
"x": -875.954284667969,
"y": 1231.81066894531
},
{
"x": 875.954284667969,
"y": 1231.81066894531
},
{
"x": 875.954284667969,
"y": 1361.83520507813
},
{
"x": -875.954284667969,
"y": 1361.83520507813
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
}
],
"tags": [
"buildings"
],
"version": "1.1"
}
File diff suppressed because it is too large Load Diff
+433
View File
@@ -0,0 +1,433 @@
{
"anchors": {
"origin": {
"allowed_hands": "both",
"role": "pivot",
"rotation_deg": 0.0,
"x": 0.0,
"y": 0.0,
"z_index_offset": 0
}
},
"material_preset": "none",
"metadata": {
"created_at": "2026-10-21T23:12:39",
"modified_at": "2026-10-21T23:12:39"
},
"prop_name": "Wall Clock",
"shapes": [
{
"closed": true,
"fill_color": "#DCA678",
"outline_color": "#332B45",
"outline_width": 7.3205,
"points": [
{
"x": 102.486999511719,
"y": 0.0
},
{
"x": 94.6833419799805,
"y": 39.2232398986816
},
{
"x": 72.4729537963867,
"y": 72.4729537963867
},
{
"x": 39.2232398986816,
"y": 94.6833419799805
},
{
"x": 0.0,
"y": 102.486999511719
},
{
"x": -39.2232398986816,
"y": 94.6833419799805
},
{
"x": -72.4729537963867,
"y": 72.4729537963867
},
{
"x": -94.6833419799805,
"y": 39.2232398986816
},
{
"x": -102.486999511719,
"y": 0.0
},
{
"x": -94.6833419799805,
"y": -39.2232398986816
},
{
"x": -72.4729537963867,
"y": -72.4729537963867
},
{
"x": -39.2232398986816,
"y": -94.6833419799805
},
{
"x": 0.0,
"y": -102.486999511719
},
{
"x": 39.2232398986816,
"y": -94.6833419799805
},
{
"x": 72.4729537963867,
"y": -72.4729537963867
},
{
"x": 94.6833419799805,
"y": -39.2232398986816
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#FCF2D8",
"outline_color": "#332B45",
"outline_width": 4.3923,
"points": [
{
"x": 79.0614013671875,
"y": 0.0
},
{
"x": 73.0439453125,
"y": 30.2483062744141
},
{
"x": 55.8993377685547,
"y": 55.8993377685547
},
{
"x": 30.2483062744141,
"y": 73.0439453125
},
{
"x": 0.0,
"y": 79.0614013671875
},
{
"x": -30.2483062744141,
"y": 73.0439453125
},
{
"x": -55.8993377685547,
"y": 55.8993377685547
},
{
"x": -73.0439453125,
"y": 30.2483062744141
},
{
"x": -79.0614013671875,
"y": 0.0
},
{
"x": -73.0439453125,
"y": -30.2483062744141
},
{
"x": -55.8993377685547,
"y": -55.8993377685547
},
{
"x": -30.2483062744141,
"y": -73.0439453125
},
{
"x": 0.0,
"y": -79.0614013671875
},
{
"x": 30.2483062744141,
"y": -73.0439453125
},
{
"x": 55.8993377685547,
"y": -55.8993377685547
},
{
"x": 73.0439453125,
"y": -30.2483062744141
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#75586C",
"outline_width": 5.8564,
"points": [
{
"x": 0.0,
"y": -65.8844985961914
},
{
"x": 0.0,
"y": -45.3871002197266
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#75586C",
"outline_width": 5.8564,
"points": [
{
"x": 64.4204025268555,
"y": 0.0
},
{
"x": 43.9230003356934,
"y": 0.0
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#75586C",
"outline_width": 5.8564,
"points": [
{
"x": 0.0,
"y": 65.8844985961914
},
{
"x": 0.0,
"y": 45.3871002197266
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#75586C",
"outline_width": 5.8564,
"points": [
{
"x": -64.4204025268555,
"y": 0.0
},
{
"x": -43.9230003356934,
"y": 0.0
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#75586C",
"outline_width": 7.3205,
"points": [
{
"x": 0.0,
"y": 0.0
},
{
"x": 0.0,
"y": -35.1384010314941
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#75586C",
"outline_width": 7.3205,
"points": [
{
"x": 0.0,
"y": 0.0
},
{
"x": 35.1384010314941,
"y": 21.9615001678467
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": true,
"fill_color": "#75586C",
"outline_color": "#332B45",
"outline_width": 1.4641,
"points": [
{
"x": 7.32049989700317,
"y": 0.0
},
{
"x": 6.76414203643799,
"y": 2.79643106460571
},
{
"x": 5.1829137802124,
"y": 5.1829137802124
},
{
"x": 2.79643106460571,
"y": 6.76414203643799
},
{
"x": 0.0,
"y": 7.32049989700317
},
{
"x": -2.79643106460571,
"y": 6.76414203643799
},
{
"x": -5.1829137802124,
"y": 5.1829137802124
},
{
"x": -6.76414203643799,
"y": 2.79643106460571
},
{
"x": -7.32049989700317,
"y": 0.0
},
{
"x": -6.76414203643799,
"y": -2.79643106460571
},
{
"x": -5.1829137802124,
"y": -5.1829137802124
},
{
"x": -2.79643106460571,
"y": -6.76414203643799
},
{
"x": 0.0,
"y": -7.32049989700317
},
{
"x": 2.79643106460571,
"y": -6.76414203643799
},
{
"x": 5.1829137802124,
"y": -5.1829137802124
},
{
"x": 6.76414203643799,
"y": -2.79643106460571
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
}
],
"tags": [
"walls",
"objects"
],
"version": "1.1"
}
+687
View File
@@ -0,0 +1,687 @@
{
"version": "1.1",
"prop_name": "Wall TV",
"material_preset": "none",
"tags": [
"objects",
"walls"
],
"shapes": [
{
"shape_type": "polygon",
"points": [
{
"x": -335,
"y": -195
},
{
"x": 335,
"y": -195
},
{
"x": 340,
"y": -190
},
{
"x": 340,
"y": 190
},
{
"x": 335,
"y": 195
},
{
"x": -335,
"y": 195
},
{
"x": -340,
"y": 190
},
{
"x": -340,
"y": -190
}
],
"fill_color": "#1A202A",
"outline_color": "#090D13",
"outline_width": 3.8,
"closed": true,
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": -333,
"y": -188
},
{
"x": 333,
"y": -188
},
{
"x": 324,
"y": -178
},
{
"x": -324,
"y": -178
}
],
"fill_color": "#3C4652",
"outline_color": "#3C4652",
"outline_width": 0,
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": -333,
"y": -187
},
{
"x": -324,
"y": -178
},
{
"x": -324,
"y": 168
},
{
"x": -333,
"y": 185
}
],
"fill_color": "#323C47",
"outline_color": "#323C47",
"outline_width": 0,
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": 333,
"y": -187
},
{
"x": 333,
"y": 185
},
{
"x": 324,
"y": 168
},
{
"x": 324,
"y": -178
}
],
"fill_color": "#101720",
"outline_color": "#101720",
"outline_width": 0,
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": -332,
"y": 168
},
{
"x": 332,
"y": 168
},
{
"x": 332,
"y": 188
},
{
"x": -332,
"y": 188
}
],
"fill_color": "#232B36",
"outline_color": "#232B36",
"outline_width": 0,
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": -327,
"y": -177
},
{
"x": 327,
"y": -177
},
{
"x": 327,
"y": 169
},
{
"x": -327,
"y": 169
}
],
"fill_color": "#10151D",
"outline_color": "#080C12",
"outline_width": 2.3,
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": -318,
"y": -167
},
{
"x": 318,
"y": -167
},
{
"x": 318,
"y": 158
},
{
"x": -318,
"y": 158
}
],
"fill_color": "#242932",
"outline_color": "#10141B",
"outline_width": 2.4,
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": -309,
"y": -158
},
{
"x": -67,
"y": -158
},
{
"x": -151,
"y": 149
},
{
"x": -309,
"y": 149
}
],
"fill_color": "#36404A",
"outline_color": "#36404A",
"outline_width": 0,
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": -309,
"y": -158
},
{
"x": -230,
"y": -158
},
{
"x": -299,
"y": 149
},
{
"x": -309,
"y": 149
}
],
"fill_color": "#3C4651",
"outline_color": "#3C4651",
"outline_width": 0,
"closed": true,
"vertex_flags": [
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "line",
"points": [
{
"x": -296,
"y": -154
},
{
"x": -90,
"y": -154
}
],
"fill_color": "#00000000",
"outline_color": "#66717B",
"outline_width": 1.4,
"closed": false,
"vertex_flags": [
0,
0
],
"reshaped": false
},
{
"shape_type": "line",
"points": [
{
"x": -309,
"y": 151
},
{
"x": 309,
"y": 151
}
],
"fill_color": "#00000000",
"outline_color": "#1A2029",
"outline_width": 4.0,
"closed": false,
"vertex_flags": [
0,
0
],
"reshaped": false
},
{
"shape_type": "line",
"points": [
{
"x": -320,
"y": 171
},
{
"x": 320,
"y": 171
}
],
"fill_color": "#00000000",
"outline_color": "#4A535F",
"outline_width": 1.2,
"closed": false,
"vertex_flags": [
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": -7.0,
"y": 183.0
},
{
"x": -7.94,
"y": 186.5
},
{
"x": -10.5,
"y": 189.06
},
{
"x": -14.0,
"y": 190.0
},
{
"x": -17.5,
"y": 189.06
},
{
"x": -20.06,
"y": 186.5
},
{
"x": -21.0,
"y": 183.0
},
{
"x": -20.06,
"y": 179.5
},
{
"x": -17.5,
"y": 176.94
},
{
"x": -14.0,
"y": 176.0
},
{
"x": -10.5,
"y": 176.94
},
{
"x": -7.94,
"y": 179.5
}
],
"fill_color": "#39232A",
"outline_color": "#111720",
"outline_width": 1.0,
"closed": true,
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": -10.0,
"y": 183.0
},
{
"x": -10.54,
"y": 185.0
},
{
"x": -12.0,
"y": 186.46
},
{
"x": -14.0,
"y": 187.0
},
{
"x": -16.0,
"y": 186.46
},
{
"x": -17.46,
"y": 185.0
},
{
"x": -18.0,
"y": 183.0
},
{
"x": -17.46,
"y": 181.0
},
{
"x": -16.0,
"y": 179.54
},
{
"x": -14.0,
"y": 179.0
},
{
"x": -12.0,
"y": 179.54
},
{
"x": -10.54,
"y": 181.0
}
],
"fill_color": "#E05A60",
"outline_color": "#E05A60",
"outline_width": 0,
"closed": true,
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": 21.0,
"y": 183.0
},
{
"x": 20.06,
"y": 186.5
},
{
"x": 17.5,
"y": 189.06
},
{
"x": 14.0,
"y": 190.0
},
{
"x": 10.5,
"y": 189.06
},
{
"x": 7.94,
"y": 186.5
},
{
"x": 7.0,
"y": 183.0
},
{
"x": 7.94,
"y": 179.5
},
{
"x": 10.5,
"y": 176.94
},
{
"x": 14.0,
"y": 176.0
},
{
"x": 17.5,
"y": 176.94
},
{
"x": 20.06,
"y": 179.5
}
],
"fill_color": "#243A30",
"outline_color": "#111720",
"outline_width": 1.0,
"closed": true,
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"reshaped": false
},
{
"shape_type": "polygon",
"points": [
{
"x": 18.0,
"y": 183.0
},
{
"x": 17.46,
"y": 185.0
},
{
"x": 16.0,
"y": 186.46
},
{
"x": 14.0,
"y": 187.0
},
{
"x": 12.0,
"y": 186.46
},
{
"x": 10.54,
"y": 185.0
},
{
"x": 10.0,
"y": 183.0
},
{
"x": 10.54,
"y": 181.0
},
{
"x": 12.0,
"y": 179.54
},
{
"x": 14.0,
"y": 179.0
},
{
"x": 16.0,
"y": 179.54
},
{
"x": 17.46,
"y": 181.0
}
],
"fill_color": "#68CB8D",
"outline_color": "#68CB8D",
"outline_width": 0,
"closed": true,
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"reshaped": false
}
],
"anchors": {
"origin": {
"role": "pivot",
"x": 0,
"y": 0,
"rotation_deg": 0,
"z_index_offset": 0,
"allowed_hands": "both"
}
},
"metadata": {
"created_at": "2026-10-21T20:07:34-04:00",
"modified_at": "2026-10-21T20:07:34-04:00"
}
}
File diff suppressed because it is too large Load Diff
+249
View File
@@ -0,0 +1,249 @@
{
"anchors": {
"origin": {
"allowed_hands": "both",
"role": "pivot",
"rotation_deg": 0.0,
"x": 0.0,
"y": 0.0,
"z_index_offset": 0
}
},
"material_preset": "wood",
"metadata": {
"created_at": "2026-10-21T23:13:10",
"modified_at": "2026-10-21T23:13:10"
},
"prop_name": "Open Window Frame",
"shapes": [
{
"closed": true,
"fill_color": "#EBCF9E",
"outline_color": "#332B45",
"outline_width": 5.324,
"points": [
{
"x": -171.698989868164,
"y": -186.339996337891
},
{
"x": -145.078994750977,
"y": -186.339996337891
},
{
"x": -145.078994750977,
"y": 165.043991088867
},
{
"x": -171.698989868164,
"y": 165.043991088867
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#EBCF9E",
"outline_color": "#332B45",
"outline_width": 5.324,
"points": [
{
"x": 145.078994750977,
"y": -186.339996337891
},
{
"x": 171.698989868164,
"y": -186.339996337891
},
{
"x": 171.698989868164,
"y": 165.043991088867
},
{
"x": 145.078994750977,
"y": 165.043991088867
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#EBCF9E",
"outline_color": "#332B45",
"outline_width": 5.324,
"points": [
{
"x": -171.698989868164,
"y": -186.339996337891
},
{
"x": 171.698989868164,
"y": -186.339996337891
},
{
"x": 171.698989868164,
"y": -159.720001220703
},
{
"x": -171.698989868164,
"y": -159.720001220703
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#EBCF9E",
"outline_color": "#332B45",
"outline_width": 5.324,
"points": [
{
"x": -171.698989868164,
"y": 138.42399597168
},
{
"x": 171.698989868164,
"y": 138.42399597168
},
{
"x": 171.698989868164,
"y": 165.043991088867
},
{
"x": -171.698989868164,
"y": 165.043991088867
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#EBCF9E",
"outline_color": "#332B45",
"outline_width": 5.324,
"points": [
{
"x": -13.3099994659424,
"y": -159.720001220703
},
{
"x": 13.3099994659424,
"y": -159.720001220703
},
{
"x": 13.3099994659424,
"y": 138.42399597168
},
{
"x": -13.3099994659424,
"y": 138.42399597168
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#EBCF9E",
"outline_color": "#332B45",
"outline_width": 5.324,
"points": [
{
"x": -145.078994750977,
"y": -23.9580001831055
},
{
"x": 145.078994750977,
"y": -23.9580001831055
},
{
"x": 145.078994750977,
"y": 2.66199994087219
},
{
"x": -145.078994750977,
"y": 2.66199994087219
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#C89670",
"outline_color": "#332B45",
"outline_width": 5.324,
"points": [
{
"x": -192.994995117188,
"y": 165.043991088867
},
{
"x": 192.994995117188,
"y": 165.043991088867
},
{
"x": 192.994995117188,
"y": 186.339996337891
},
{
"x": -192.994995117188,
"y": 186.339996337891
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
}
],
"tags": [
"walls",
"buildings"
],
"version": "1.1"
}
File diff suppressed because it is too large Load Diff
+8033 -8032
View File
File diff suppressed because it is too large Load Diff
+278
View File
@@ -0,0 +1,278 @@
{
"anchors": {
"origin": {
"allowed_hands": "both",
"role": "pivot",
"rotation_deg": 0.0,
"x": 0.0,
"y": 0.0,
"z_index_offset": 0
}
},
"material_preset": "wood",
"metadata": {
"created_at": "2026-10-21T23:15:32",
"modified_at": "2026-10-21T23:15:32"
},
"prop_name": "Cozy Armchair",
"shapes": [
{
"closed": true,
"fill_color": "#80A9AF",
"outline_color": "#332B45",
"outline_width": 10.3749698404,
"points": [
{
"x": -243.811782836914,
"y": 185.452575683594
},
{
"x": -243.811782836914,
"y": -208.796264648438
},
{
"x": -184.155700683594,
"y": -281.421051025391
},
{
"x": 184.155700683594,
"y": -281.421051025391
},
{
"x": 243.811782836914,
"y": -208.796264648438
},
{
"x": 243.811782836914,
"y": 185.452575683594
},
{
"x": 197.124420166016,
"y": 239.921173095703
},
{
"x": -197.124420166016,
"y": 239.921173095703
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#A8CED0",
"outline_color": "#332B45",
"outline_width": 10.3749698404,
"points": [
{
"x": -168.59326171875,
"y": -213.983749389648
},
{
"x": 168.59326171875,
"y": -213.983749389648
},
{
"x": 168.59326171875,
"y": 60.9529457092285
},
{
"x": -168.59326171875,
"y": 60.9529457092285
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#C3DDCF",
"outline_color": "#332B45",
"outline_width": 10.3749698404,
"points": [
{
"x": -189.343200683594,
"y": 50.5779762268066
},
{
"x": 189.343200683594,
"y": 50.5779762268066
},
{
"x": 212.686874389648,
"y": 182.858840942383
},
{
"x": -212.686874389648,
"y": 182.858840942383
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#80A9AF",
"outline_color": "#332B45",
"outline_width": 10.3749698404,
"points": [
{
"x": -246.405517578125,
"y": -3.8906135559082
},
{
"x": -186.749450683594,
"y": -40.2030067443848
},
{
"x": -153.030792236328,
"y": 9.07809829711914
},
{
"x": -153.030792236328,
"y": 208.796264648438
},
{
"x": -246.405517578125,
"y": 208.796264648438
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#80A9AF",
"outline_color": "#332B45",
"outline_width": 10.3749698404,
"points": [
{
"x": 246.405517578125,
"y": -3.8906135559082
},
{
"x": 186.749450683594,
"y": -40.2030067443848
},
{
"x": 153.030792236328,
"y": 9.07809829711914
},
{
"x": 153.030792236328,
"y": 208.796264648438
},
{
"x": 246.405517578125,
"y": 208.796264648438
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#755369",
"outline_color": "#332B45",
"outline_width": 10.3749698404,
"points": [
{
"x": -184.155700683594,
"y": 224.35871887207
},
{
"x": -124.499633789063,
"y": 224.35871887207
},
{
"x": -124.499633789063,
"y": 281.421051025391
},
{
"x": -184.155700683594,
"y": 281.421051025391
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#755369",
"outline_color": "#332B45",
"outline_width": 10.3749698404,
"points": [
{
"x": 124.499633789063,
"y": 224.35871887207
},
{
"x": 184.155700683594,
"y": 224.35871887207
},
{
"x": 184.155700683594,
"y": 281.421051025391
},
{
"x": 124.499633789063,
"y": 281.421051025391
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
}
],
"tags": [
"objects"
],
"version": "1.1"
}
+206
View File
@@ -0,0 +1,206 @@
{
"anchors": {
"origin": {
"allowed_hands": "both",
"role": "pivot",
"rotation_deg": 0.0,
"x": 0.0,
"y": 0.0,
"z_index_offset": 0
}
},
"material_preset": "wood",
"metadata": {
"created_at": "2026-10-21T23:15:55",
"modified_at": "2026-10-21T23:15:55"
},
"prop_name": "Coffee Table",
"shapes": [
{
"closed": true,
"fill_color": "#B9805D",
"outline_color": "#332B45",
"outline_width": 6.44204,
"points": [
{
"x": -241.57649230957,
"y": -112.735702514648
},
{
"x": 241.57649230957,
"y": -112.735702514648
},
{
"x": 241.57649230957,
"y": -67.6414184570313
},
{
"x": -241.57649230957,
"y": -67.6414184570313
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#D69E70",
"outline_color": "#332B45",
"outline_width": 6.44204,
"points": [
{
"x": -206.145278930664,
"y": -67.6414184570313
},
{
"x": 206.145278930664,
"y": -67.6414184570313
},
{
"x": 206.145278930664,
"y": -48.315299987793
},
{
"x": -206.145278930664,
"y": -48.315299987793
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#A66F58",
"outline_color": "#332B45",
"outline_width": 6.44204,
"points": [
{
"x": -199.703231811523,
"y": -49.9258079528809
},
{
"x": -157.829986572266,
"y": -49.9258079528809
},
{
"x": -144.945892333984,
"y": 112.735702514648
},
{
"x": -186.819152832031,
"y": 112.735702514648
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#A66F58",
"outline_color": "#332B45",
"outline_width": 6.44204,
"points": [
{
"x": 157.829986572266,
"y": -49.9258079528809
},
{
"x": 199.703231811523,
"y": -49.9258079528809
},
{
"x": 186.819152832031,
"y": 112.735702514648
},
{
"x": 144.945892333984,
"y": 112.735702514648
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#C38C68",
"outline_color": "#332B45",
"outline_width": 6.44204,
"points": [
{
"x": -162.661514282227,
"y": 35.4312210083008
},
{
"x": 162.661514282227,
"y": 35.4312210083008
},
{
"x": 162.661514282227,
"y": 54.7573394775391
},
{
"x": -162.661514282227,
"y": 54.7573394775391
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#E8B98A",
"outline_width": 4.83153,
"points": [
{
"x": -185.208648681641,
"y": -99.8516159057617
},
{
"x": 185.208648681641,
"y": -99.8516159057617
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
}
],
"tags": [
"objects"
],
"version": "1.1"
}
+300
View File
@@ -0,0 +1,300 @@
{
"anchors": {
"origin": {
"allowed_hands": "both",
"role": "pivot",
"rotation_deg": 0.0,
"x": 0.0,
"y": 0.0,
"z_index_offset": 0
}
},
"material_preset": "wood",
"metadata": {
"created_at": "2026-10-21T23:16:12",
"modified_at": "2026-10-21T23:16:12"
},
"prop_name": "Cozy Couch",
"shapes": [
{
"closed": true,
"fill_color": "#B7767B",
"outline_color": "#332B45",
"outline_width": 10.3749698404,
"points": [
{
"x": -435.748718261719,
"y": 165.99951171875
},
{
"x": -435.748718261719,
"y": -171.186996459961
},
{
"x": -376.092651367188,
"y": -261.967987060547
},
{
"x": 376.092651367188,
"y": -261.967987060547
},
{
"x": 435.748718261719,
"y": -171.186996459961
},
{
"x": 435.748718261719,
"y": 165.99951171875
},
{
"x": 378.686401367188,
"y": 217.874359130859
},
{
"x": -378.686401367188,
"y": 217.874359130859
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#D9908A",
"outline_color": "#332B45",
"outline_width": 10.3749698404,
"points": [
{
"x": -355.342712402344,
"y": -186.749450683594
},
{
"x": 355.342712402344,
"y": -186.749450683594
},
{
"x": 355.342712402344,
"y": 41.4998779296875
},
{
"x": -355.342712402344,
"y": 41.4998779296875
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#985C71",
"outline_width": 10.3749698404,
"points": [
{
"x": 0.0,
"y": -184.155700683594
},
{
"x": 0.0,
"y": 38.906135559082
}
],
"reshaped": false,
"shape_type": "line",
"vertex_flags": [
0,
0
]
},
{
"closed": true,
"fill_color": "#EDB09A",
"outline_color": "#332B45",
"outline_width": 10.3749698404,
"points": [
{
"x": -368.311401367188,
"y": 23.3436813354492
},
{
"x": 368.311401367188,
"y": 23.3436813354492
},
{
"x": 391.655090332031,
"y": 160.812026977539
},
{
"x": -391.655090332031,
"y": 160.812026977539
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#B7767B",
"outline_color": "#332B45",
"outline_width": 10.3749698404,
"points": [
{
"x": -433.154968261719,
"y": -59.6560745239258
},
{
"x": -373.498901367188,
"y": -88.1872406005859
},
{
"x": -339.780242919922,
"y": -51.8748474121094
},
{
"x": -339.780242919922,
"y": 186.749450683594
},
{
"x": -435.748718261719,
"y": 186.749450683594
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#B7767B",
"outline_color": "#332B45",
"outline_width": 10.3749698404,
"points": [
{
"x": 433.154968261719,
"y": -59.6560745239258
},
{
"x": 373.498901367188,
"y": -88.1872406005859
},
{
"x": 339.780242919922,
"y": -51.8748474121094
},
{
"x": 339.780242919922,
"y": 186.749450683594
},
{
"x": 435.748718261719,
"y": 186.749450683594
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#755369",
"outline_color": "#332B45",
"outline_width": 10.3749698404,
"points": [
{
"x": -360.530181884766,
"y": 202.311904907227
},
{
"x": -293.092895507813,
"y": 202.311904907227
},
{
"x": -293.092895507813,
"y": 261.967987060547
},
{
"x": -360.530181884766,
"y": 261.967987060547
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
"fill_color": "#755369",
"outline_color": "#332B45",
"outline_width": 10.3749698404,
"points": [
{
"x": 293.092895507813,
"y": 202.311904907227
},
{
"x": 360.530181884766,
"y": 202.311904907227
},
{
"x": 360.530181884766,
"y": 261.967987060547
},
{
"x": 293.092895507813,
"y": 261.967987060547
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [
0,
0,
0,
0
]
}
],
"tags": [
"objects"
],
"version": "1.1"
}
+372
View File
@@ -0,0 +1,372 @@
{
"version": "1.6",
"scene_name": "Blank Scene",
"sky_id": "sky_neutral",
"camera": {
"position": {
"x": 1863.90759277344,
"y": -391.476318359375
},
"zoom": 0.665500044822693
},
"objects": [
{
"file_id": "wall_panel_1",
"type": "wall_panel",
"position": {
"x": 448.0,
"y": -320.0
},
"rotation": 0.0,
"size": {
"x": 5120.0,
"y": 2816.0
},
"finish_id": "finish_wall_brick",
"color": "#b95f3dff"
},
{
"file_id": "stickman_1",
"type": "stickman",
"position": {
"x": -128.0,
"y": -129.0
},
"rotation": 0.0,
"stk_path": "res://content/stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "move_to",
"target": {
"x": 2404.91528320313,
"y": 141.710083007813
},
"style": "walk"
}
]
},
{
"file_id": "terrain_1",
"type": "terrain",
"position": {
"x": -384.0,
"y": 256.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_2",
"type": "terrain",
"position": {
"x": -384.0,
"y": 288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_3",
"type": "terrain",
"position": {
"x": 192.0,
"y": 256.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_4",
"type": "terrain",
"position": {
"x": 384.0,
"y": 256.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_5",
"type": "terrain",
"position": {
"x": 576.0,
"y": 256.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_6",
"type": "terrain",
"position": {
"x": 768.0,
"y": 256.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_7",
"type": "terrain",
"position": {
"x": 960.0,
"y": 256.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_8",
"type": "terrain",
"position": {
"x": 1152.0,
"y": 224.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_9",
"type": "terrain",
"position": {
"x": 1344.0,
"y": 224.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_10",
"type": "terrain",
"position": {
"x": 1536.0,
"y": 224.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_11",
"type": "terrain",
"position": {
"x": 1728.0,
"y": 224.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_12",
"type": "terrain",
"position": {
"x": 1920.0,
"y": 224.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_13",
"type": "terrain",
"position": {
"x": 2112.0,
"y": 224.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_14",
"type": "terrain",
"position": {
"x": 2304.0,
"y": 224.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_15",
"type": "terrain",
"position": {
"x": 2496.0,
"y": 224.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_16",
"type": "terrain",
"position": {
"x": -192.0,
"y": 320.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_17",
"type": "terrain",
"position": {
"x": 0.0,
"y": 320.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_18",
"type": "terrain",
"position": {
"x": 192.0,
"y": 288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_19",
"type": "terrain",
"position": {
"x": 384.0,
"y": 288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "terrain_20",
"type": "terrain",
"position": {
"x": 576.0,
"y": 288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_asphalt"
},
{
"file_id": "wall_panel_2",
"type": "wall_panel",
"position": {
"x": 1152.0,
"y": -64.0
},
"rotation": 0.0,
"size": {
"x": 9984.0,
"y": 5504.0
},
"finish_id": "finish_wall_brick",
"color": "#b95f3dff"
}
],
"event_rules": [],
"metadata": {
"created_unix": 1792022400,
"modified_unix": 1792470929.846,
"autosave": false,
"source_path": ""
}
}
+641
View File
@@ -0,0 +1,641 @@
{
"version": "1.5",
"scene_name": "Living Room",
"sky_id": "sky_clear_day",
"camera": {
"position": {
"x": 194.703018188477,
"y": -486.092163085938
},
"zoom": 0.665500044822693
},
"objects": [
{
"file_id": "wall_panel_1",
"type": "wall_panel",
"position": {
"x": 0.0,
"y": -820.0
},
"rotation": 0.0,
"size": {
"x": 1120.0,
"y": 170.0
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_2",
"type": "wall_panel",
"position": {
"x": -360.0,
"y": -535.0
},
"rotation": 0.0,
"size": {
"x": 440.0,
"y": 400.0
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_3",
"type": "wall_panel",
"position": {
"x": 360.0,
"y": -535.0
},
"rotation": 0.0,
"size": {
"x": 440.0,
"y": 400.0
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "scenery_artwork_1",
"type": "scenery_artwork",
"position": {
"x": 0.0,
"y": -530.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_window_frame-v1.prop",
"display_name": "Window frame",
"depth": "near",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -147.0,
"y": -154.0
},
"size": {
"x": 294.0,
"y": 284.0
}
}
},
{
"file_id": "scenery_artwork_2",
"type": "scenery_artwork",
"position": {
"x": -430.0,
"y": -570.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_wall_clock-v1.prop",
"display_name": "Wall clock",
"depth": "near",
"scale": {
"x": 0.800000011920929,
"y": 0.800000011920929
},
"last_known_local_bounds": {
"position": {
"x": -72.5,
"y": -72.5
},
"size": {
"x": 145.0,
"y": 145.0
}
}
},
{
"file_id": "scenery_artwork_3",
"type": "scenery_artwork",
"position": {
"x": 365.0,
"y": -460.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_fireplace_mantel-v2.prop",
"display_name": "Fireplace",
"depth": "near",
"scale": {
"x": 0.819999992847443,
"y": 0.819999992847443
},
"last_known_local_bounds": {
"position": {
"x": -153.0,
"y": -116.0
},
"size": {
"x": 306.0,
"y": 244.0
}
}
},
{
"file_id": "scenery_artwork_4",
"type": "scenery_artwork",
"position": {
"x": -475.0,
"y": -310.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_bookshelf-v1.prop",
"display_name": "Bookshelf",
"depth": "near",
"scale": {
"x": 0.819999992847443,
"y": 0.819999992847443
},
"last_known_local_bounds": {
"position": {
"x": -104.0,
"y": -190.0
},
"size": {
"x": 208.0,
"y": 315.0
}
}
},
{
"file_id": "scenery_artwork_5",
"type": "scenery_artwork",
"position": {
"x": 185.0,
"y": -575.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_framed_picture-v1.prop",
"display_name": "Framed picture",
"depth": "near",
"scale": {
"x": 0.75,
"y": 0.75
},
"last_known_local_bounds": {
"position": {
"x": -112.0,
"y": -84.0
},
"size": {
"x": 224.0,
"y": 168.0
}
}
},
{
"file_id": "terrain_1",
"type": "terrain",
"position": {
"x": -774.0,
"y": 832.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_2",
"type": "terrain",
"position": {
"x": -588.0,
"y": 840.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_3",
"type": "terrain",
"position": {
"x": -374.0,
"y": 832.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_4",
"type": "terrain",
"position": {
"x": -174.0,
"y": 832.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_5",
"type": "terrain",
"position": {
"x": 26.0,
"y": 832.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_6",
"type": "terrain",
"position": {
"x": 226.0,
"y": 832.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_7",
"type": "terrain",
"position": {
"x": 426.0,
"y": 832.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "scenery_artwork_6",
"type": "scenery_artwork",
"position": {
"x": 0.0,
"y": 112.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_rug-v1.prop",
"display_name": "Floor rug",
"depth": "floor",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -179.0,
"y": -60.0
},
"size": {
"x": 358.0,
"y": 139.0
}
}
},
{
"file_id": "scenery_artwork_7",
"type": "scenery_artwork",
"position": {
"x": 500.0,
"y": 84.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_potted_plant-v1.prop",
"display_name": "Potted plant",
"depth": "floor",
"scale": {
"x": 0.800000011920929,
"y": 0.800000011920929
},
"last_known_local_bounds": {
"position": {
"x": -62.5,
"y": -114.5
},
"size": {
"x": 125.0,
"y": 219.0
}
}
},
{
"file_id": "prop_1",
"type": "prop",
"position": {
"x": -364.0,
"y": 680.0
},
"rotation": 0.0,
"asset_id": "res://content/props/prop_couch-v1.prop",
"stage_object_id": "starter_couch",
"roles": {
"depth": "behind",
"start_motion": "still",
"holds_other_props": false,
"blocks_stickmen": false,
"can_pick_up": false,
"released_blocks_stickmen": true,
"released_bumps_other_props": true,
"legacy_draw_order": false
},
"use_spots": [],
"next_use_spot_number": 1,
"wobble_on_impact": false,
"scale": {
"x": 0.819999992847443,
"y": 0.819999992847443
},
"material_preset": 1
},
{
"file_id": "prop_2",
"type": "prop",
"position": {
"x": 36.0,
"y": 684.0
},
"rotation": 0.0,
"asset_id": "res://content/props/prop_armchair-v1.prop",
"stage_object_id": "starter_armchair",
"roles": {
"depth": "behind",
"start_motion": "still",
"holds_other_props": false,
"blocks_stickmen": false,
"can_pick_up": false,
"released_blocks_stickmen": true,
"released_bumps_other_props": true,
"legacy_draw_order": false
},
"use_spots": [],
"next_use_spot_number": 1,
"wobble_on_impact": false,
"scale": {
"x": 0.779999971389771,
"y": 0.779999971389771
},
"material_preset": 1
},
{
"file_id": "prop_3",
"type": "prop",
"position": {
"x": -159.0,
"y": 748.0
},
"rotation": 0.0,
"asset_id": "res://content/props/prop_coffee_table-v1.prop",
"stage_object_id": "starter_coffee_table",
"roles": {
"depth": "front",
"start_motion": "still",
"holds_other_props": true,
"blocks_stickmen": false,
"can_pick_up": false,
"released_blocks_stickmen": true,
"released_bumps_other_props": true,
"legacy_draw_order": false
},
"use_spots": [],
"next_use_spot_number": 1,
"wobble_on_impact": false,
"scale": {
"x": 0.75,
"y": 0.75
},
"material_preset": 1
},
{
"file_id": "stickman_1",
"type": "stickman",
"position": {
"x": -690.0,
"y": 152.0
},
"rotation": 0.0,
"stk_path": "res://content/stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "move_to",
"target": {
"x": 588.195678710938,
"y": 144.184204101563
},
"style": "walk"
}
]
},
{
"file_id": "wall_panel_4",
"type": "wall_panel",
"position": {
"x": 206.112670898438,
"y": -758.285827636719
},
"rotation": 0.0,
"size": {
"x": 634.58154296875,
"y": 125.426147460938
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_5",
"type": "wall_panel",
"position": {
"x": 16.1107177734375,
"y": -777.534423828125
},
"rotation": 0.0,
"size": {
"x": 281.898193359375,
"y": 181.309020996094
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_6",
"type": "wall_panel",
"position": {
"x": -37.2885131835938,
"y": -739.658203125
},
"rotation": 0.0,
"size": {
"x": 276.930786132813,
"y": 100.589233398438
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_7",
"type": "wall_panel",
"position": {
"x": 218.531005859375,
"y": -634.1015625
},
"rotation": 0.0,
"size": {
"x": 180.067138671875,
"y": 257.061401367188
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_8",
"type": "wall_panel",
"position": {
"x": -193.760681152344,
"y": -507.433654785156
},
"rotation": 0.0,
"size": {
"x": 142.811889648438,
"y": 428.435668945313
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_9",
"type": "wall_panel",
"position": {
"x": -5.0006103515625,
"y": -762.63232421875
},
"rotation": 0.0,
"size": {
"x": 321.63720703125,
"y": 211.113220214844
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_10",
"type": "wall_panel",
"position": {
"x": 29.7709350585938,
"y": -198.21484375
},
"rotation": 0.0,
"size": {
"x": 736.412475585938,
"y": 403.598876953125
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_11",
"type": "wall_panel",
"position": {
"x": 458.206604003906,
"y": -332.954742431641
},
"rotation": 0.0,
"size": {
"x": 661.901977539063,
"y": 986.023010253906
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_12",
"type": "wall_panel",
"position": {
"x": -522.22802734375,
"y": -360.275299072266
},
"rotation": 0.0,
"size": {
"x": 789.811767578125,
"y": 1020.79449462891
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_13",
"type": "wall_panel",
"position": {
"x": 44.6731567382813,
"y": -16.9058227539063
},
"rotation": 0.0,
"size": {
"x": 1493.93664550781,
"y": 430.919311523438
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_14",
"type": "wall_panel",
"position": {
"x": -199.348937988281,
"y": 81.19970703125
},
"rotation": 0.0,
"size": {
"x": 1577.14001464844,
"y": 259.545166015625
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_15",
"type": "wall_panel",
"position": {
"x": 61.60986328125,
"y": 297.579467773438
},
"rotation": 0.0,
"size": {
"x": 1281.58154296875,
"y": 192.485595703125
},
"finish_id": "finish_wall_wood",
"color": "#5a9d70ff"
}
],
"event_rules": [],
"metadata": {
"created_unix": 1792022400,
"modified_unix": 1792163812.927,
"autosave": false,
"source_path": ""
}
}
+659
View File
@@ -0,0 +1,659 @@
{
"version": "1.5",
"scene_name": "Living Room",
"sky_id": "sky_clear_day",
"camera": {
"position": {
"x": -43.7599487304688,
"y": 247.024810791016
},
"zoom": 0.665500164031982
},
"objects": [
{
"file_id": "wall_panel_1",
"type": "wall_panel",
"position": {
"x": 0.0,
"y": -820.0
},
"rotation": 0.0,
"size": {
"x": 1120.0,
"y": 170.0
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_2",
"type": "wall_panel",
"position": {
"x": -360.0,
"y": -535.0
},
"rotation": 0.0,
"size": {
"x": 440.0,
"y": 400.0
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_3",
"type": "wall_panel",
"position": {
"x": 360.0,
"y": -535.0
},
"rotation": 0.0,
"size": {
"x": 440.0,
"y": 400.0
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "scenery_artwork_1",
"type": "scenery_artwork",
"position": {
"x": 0.0,
"y": -530.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_window_frame-v1.prop",
"display_name": "Window frame",
"depth": "near",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -147.0,
"y": -154.0
},
"size": {
"x": 294.0,
"y": 284.0
}
}
},
{
"file_id": "scenery_artwork_2",
"type": "scenery_artwork",
"position": {
"x": -430.0,
"y": -570.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_wall_clock-v1.prop",
"display_name": "Wall clock",
"depth": "near",
"scale": {
"x": 0.800000011920929,
"y": 0.800000011920929
},
"last_known_local_bounds": {
"position": {
"x": -72.5,
"y": -72.5
},
"size": {
"x": 145.0,
"y": 145.0
}
}
},
{
"file_id": "scenery_artwork_3",
"type": "scenery_artwork",
"position": {
"x": 365.0,
"y": -460.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_fireplace_mantel-v2.prop",
"display_name": "Fireplace",
"depth": "near",
"scale": {
"x": 0.819999992847443,
"y": 0.819999992847443
},
"last_known_local_bounds": {
"position": {
"x": -153.0,
"y": -116.0
},
"size": {
"x": 306.0,
"y": 244.0
}
}
},
{
"file_id": "scenery_artwork_4",
"type": "scenery_artwork",
"position": {
"x": -475.0,
"y": -310.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_bookshelf-v1.prop",
"display_name": "Bookshelf",
"depth": "near",
"scale": {
"x": 0.819999992847443,
"y": 0.819999992847443
},
"last_known_local_bounds": {
"position": {
"x": -104.0,
"y": -190.0
},
"size": {
"x": 208.0,
"y": 315.0
}
}
},
{
"file_id": "scenery_artwork_5",
"type": "scenery_artwork",
"position": {
"x": 185.0,
"y": -575.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_framed_picture-v1.prop",
"display_name": "Framed picture",
"depth": "near",
"scale": {
"x": 0.75,
"y": 0.75
},
"last_known_local_bounds": {
"position": {
"x": -112.0,
"y": -84.0
},
"size": {
"x": 224.0,
"y": 168.0
}
}
},
{
"file_id": "scenery_artwork_6",
"type": "scenery_artwork",
"position": {
"x": 0.0,
"y": 112.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_rug-v1.prop",
"display_name": "Floor rug",
"depth": "floor",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -179.0,
"y": -60.0
},
"size": {
"x": 358.0,
"y": 139.0
}
}
},
{
"file_id": "scenery_artwork_7",
"type": "scenery_artwork",
"position": {
"x": 500.0,
"y": 84.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_potted_plant-v1.prop",
"display_name": "Potted plant",
"depth": "floor",
"scale": {
"x": 0.800000011920929,
"y": 0.800000011920929
},
"last_known_local_bounds": {
"position": {
"x": -62.5,
"y": -114.5
},
"size": {
"x": 125.0,
"y": 219.0
}
}
},
{
"file_id": "stickman_1",
"type": "stickman",
"position": {
"x": -690.0,
"y": 152.0
},
"rotation": 0.0,
"stk_path": "res://content/stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "move_to",
"target": {
"x": 588.195678710938,
"y": 144.184204101563
},
"style": "walk"
}
]
},
{
"file_id": "wall_panel_4",
"type": "wall_panel",
"position": {
"x": 206.112670898438,
"y": -758.285827636719
},
"rotation": 0.0,
"size": {
"x": 634.58154296875,
"y": 125.426147460938
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_5",
"type": "wall_panel",
"position": {
"x": 16.1107177734375,
"y": -777.534423828125
},
"rotation": 0.0,
"size": {
"x": 281.898193359375,
"y": 181.309020996094
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_6",
"type": "wall_panel",
"position": {
"x": -37.2885131835938,
"y": -739.658203125
},
"rotation": 0.0,
"size": {
"x": 276.930786132813,
"y": 100.589233398438
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_7",
"type": "wall_panel",
"position": {
"x": 218.531005859375,
"y": -634.1015625
},
"rotation": 0.0,
"size": {
"x": 180.067138671875,
"y": 257.061401367188
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_8",
"type": "wall_panel",
"position": {
"x": -193.760681152344,
"y": -507.433654785156
},
"rotation": 0.0,
"size": {
"x": 142.811889648438,
"y": 428.435668945313
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_9",
"type": "wall_panel",
"position": {
"x": -5.0006103515625,
"y": -762.63232421875
},
"rotation": 0.0,
"size": {
"x": 321.63720703125,
"y": 211.113220214844
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_10",
"type": "wall_panel",
"position": {
"x": 29.7709350585938,
"y": -198.21484375
},
"rotation": 0.0,
"size": {
"x": 736.412475585938,
"y": 403.598876953125
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_11",
"type": "wall_panel",
"position": {
"x": 458.206604003906,
"y": -332.954742431641
},
"rotation": 0.0,
"size": {
"x": 661.901977539063,
"y": 986.023010253906
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_12",
"type": "wall_panel",
"position": {
"x": -522.22802734375,
"y": -360.275299072266
},
"rotation": 0.0,
"size": {
"x": 789.811767578125,
"y": 1020.79449462891
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_13",
"type": "wall_panel",
"position": {
"x": 44.6731567382813,
"y": -16.9058227539063
},
"rotation": 0.0,
"size": {
"x": 1493.93664550781,
"y": 430.919311523438
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_14",
"type": "wall_panel",
"position": {
"x": -199.348937988281,
"y": 81.19970703125
},
"rotation": 0.0,
"size": {
"x": 1577.14001464844,
"y": 259.545166015625
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_15",
"type": "wall_panel",
"position": {
"x": 61.60986328125,
"y": 297.579467773438
},
"rotation": 0.0,
"size": {
"x": 1281.58154296875,
"y": 192.485595703125
},
"finish_id": "finish_wall_wood",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_16",
"type": "wall_panel",
"position": {
"x": -1040.0,
"y": -848.0
},
"rotation": 0.0,
"size": {
"x": 32.0,
"y": 32.0
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_17",
"type": "wall_panel",
"position": {
"x": -64.0,
"y": -176.0
},
"rotation": 0.0,
"size": {
"x": 2048.0,
"y": 1504.0
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_18",
"type": "wall_panel",
"position": {
"x": -64.0,
"y": 592.0
},
"rotation": 0.0,
"size": {
"x": 2048.0,
"y": 32.0
},
"finish_id": "finish_wall_wood",
"color": "#5a9d70ff"
},
{
"file_id": "terrain_1",
"type": "terrain",
"position": {
"x": -960.0,
"y": 640.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_2",
"type": "terrain",
"position": {
"x": -768.0,
"y": 640.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_3",
"type": "terrain",
"position": {
"x": -576.0,
"y": 640.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_4",
"type": "terrain",
"position": {
"x": -384.0,
"y": 640.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_5",
"type": "terrain",
"position": {
"x": -192.0,
"y": 640.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_6",
"type": "terrain",
"position": {
"x": 0.0,
"y": 640.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_7",
"type": "terrain",
"position": {
"x": 192.0,
"y": 640.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_8",
"type": "terrain",
"position": {
"x": 384.0,
"y": 640.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_9",
"type": "terrain",
"position": {
"x": 576.0,
"y": 640.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_10",
"type": "terrain",
"position": {
"x": 768.0,
"y": 640.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_11",
"type": "terrain",
"position": {
"x": 960.0,
"y": 640.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
}
],
"event_rules": [],
"metadata": {
"created_unix": 1792022400,
"modified_unix": 1792165751.76,
"autosave": false,
"source_path": ""
}
}
+319
View File
@@ -0,0 +1,319 @@
{
"version": "1.5",
"scene_name": "Untitled",
"sky_id": "sky_neutral",
"camera": {
"position": {
"x": 143.607360839844,
"y": -79.9739379882813
},
"zoom": 0.732050061225891
},
"objects": [
{
"file_id": "terrain_1",
"type": "terrain",
"position": {
"x": -768.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_2",
"type": "terrain",
"position": {
"x": -576.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_3",
"type": "terrain",
"position": {
"x": -384.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_4",
"type": "terrain",
"position": {
"x": -192.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_5",
"type": "terrain",
"position": {
"x": 0.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_6",
"type": "terrain",
"position": {
"x": 192.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_7",
"type": "terrain",
"position": {
"x": 384.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_8",
"type": "terrain",
"position": {
"x": 576.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_9",
"type": "terrain",
"position": {
"x": 768.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "terrain_10",
"type": "terrain",
"position": {
"x": 960.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_wood"
},
{
"file_id": "scenery_artwork_1",
"type": "scenery_artwork",
"position": {
"x": -93.5323791503906,
"y": -461.938385009766
},
"rotation": 0.0,
"asset_id": "res://content/props/art_fireplace_mantel-v2.prop",
"display_name": "Cozy Brick Fireplace Mantel",
"depth": "near",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -386.467620849609,
"y": -306.061584472656
},
"size": {
"x": 772.935241699219,
"y": 612.123168945313
}
}
},
{
"file_id": "stickman_1",
"type": "stickman",
"position": {
"x": 600.0,
"y": -457.0
},
"rotation": 0.0,
"stk_path": "res://content/stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "move_to",
"target": {
"x": -711.525268554688,
"y": -142.811157226563
},
"style": "run"
}
]
},
{
"file_id": "wall_panel_1",
"type": "wall_panel",
"position": {
"x": 36.0,
"y": -612.0
},
"rotation": 0.0,
"size": {
"x": 1560.0,
"y": 840.0
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_2",
"type": "wall_panel",
"position": {
"x": -36.0,
"y": -528.0
},
"rotation": 0.0,
"size": {
"x": 1656.0,
"y": 960.0
},
"finish_id": "finish_wall_paint",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_3",
"type": "wall_panel",
"position": {
"x": 120.0,
"y": -84.0
},
"rotation": 0.0,
"size": {
"x": 1728.0,
"y": 168.0
},
"finish_id": "finish_wall_wood",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_4",
"type": "wall_panel",
"position": {
"x": -684.0,
"y": -156.0
},
"rotation": 0.0,
"size": {
"x": 360.0,
"y": 24.0
},
"finish_id": "finish_wall_wood",
"color": "#5a9d70ff"
},
{
"file_id": "wall_panel_5",
"type": "wall_panel",
"position": {
"x": -792.0,
"y": -84.0
},
"rotation": 0.0,
"size": {
"x": 144.0,
"y": 168.0
},
"finish_id": "finish_wall_wood",
"color": "#5a9d70ff"
},
{
"file_id": "scenery_artwork_2",
"type": "scenery_artwork",
"position": {
"x": -109.0,
"y": -108.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_rug-v1.prop",
"display_name": "Patterned Rug",
"depth": "near",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -179.0,
"y": -60.0
},
"size": {
"x": 358.0,
"y": 139.0
}
}
}
],
"event_rules": [],
"metadata": {
"created_unix": 1792178012.798,
"modified_unix": 1792178012.798,
"autosave": false,
"source_path": ""
}
}
+140
View File
@@ -0,0 +1,140 @@
{
"version": "1.6",
"scene_name": "Untitled",
"sky_id": "sky_neutral",
"camera": {
"position": {
"x": 0.0,
"y": -400.0
},
"zoom": 0.5
},
"objects": [
{
"file_id": "wall_panel_1",
"type": "wall_panel",
"position": {
"x": -768.0,
"y": -608.0
},
"rotation": 0.0,
"size": {
"x": 1152.0,
"y": 832.0
},
"finish_id": "finish_wall_brick",
"color": "#b95f3dff"
},
{
"file_id": "scenery_artwork_1",
"type": "scenery_artwork",
"position": {
"x": -64.0,
"y": -868.5
},
"rotation": 0.0,
"asset_id": "res://content/props/art_city_skyline-v1.prop",
"display_name": "City Skyline",
"depth": "far",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -321.5,
"y": -126.5
},
"size": {
"x": 643.0,
"y": 198.0
}
}
},
{
"file_id": "scenery_artwork_2",
"type": "scenery_artwork",
"position": {
"x": 320.0,
"y": -804.5
},
"rotation": 0.0,
"asset_id": "res://content/props/art_city_skyline-v1.prop",
"display_name": "City Skyline",
"depth": "floor",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -321.5,
"y": -126.5
},
"size": {
"x": 643.0,
"y": 198.0
}
}
},
{
"file_id": "scenery_artwork_3",
"type": "scenery_artwork",
"position": {
"x": 768.0,
"y": -804.5
},
"rotation": 0.0,
"asset_id": "res://content/props/art_city_skyline-v1.prop",
"display_name": "City Skyline",
"depth": "near",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -321.5,
"y": -126.5
},
"size": {
"x": 643.0,
"y": 198.0
}
}
},
{
"file_id": "scenery_artwork_4",
"type": "scenery_artwork",
"position": {
"x": -448.0,
"y": -1600.0
},
"rotation": 0.0,
"asset_id": "res://content/props/baseball.prop",
"display_name": "Baseball",
"depth": "near",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -26.1999988555908,
"y": -26.1999988555908
},
"size": {
"x": 52.4000015258789,
"y": 52.4000015258789
}
}
}
],
"event_rules": [],
"metadata": {
"created_unix": 1792555445.055,
"modified_unix": 1792555445.055,
"autosave": false,
"source_path": ""
}
}
+419
View File
@@ -0,0 +1,419 @@
{
"version": "1.7",
"scene_name": "Untitled",
"sky_id": "sky_sunset",
"background_kind": "plain",
"plain_color": "#FFFFFF",
"camera": {
"position": {
"x": 1676.29174804688,
"y": -39.946044921875
},
"zoom": 0.310460686683655
},
"objects": [
{
"file_id": "wall_panel_1",
"type": "wall_panel",
"position": {
"x": 288.0,
"y": -224.0
},
"rotation": 0.0,
"size": {
"x": 4160.0,
"y": 2368.0
},
"finish_id": "finish_wall_brick",
"color": "#b95f3dff"
},
{
"file_id": "wall_panel_2",
"type": "wall_panel",
"position": {
"x": 288.0,
"y": -352.0
},
"rotation": 0.0,
"size": {
"x": 5568.0,
"y": 3136.0
},
"finish_id": "finish_wall_wood",
"color": "#c87137ff"
},
{
"file_id": "background_ground_1",
"type": "background_ground",
"position": {
"x": 256.0,
"y": -352.0
},
"rotation": 0.0,
"size": {
"x": 6144.0,
"y": 3392.0
},
"finish_id": "finish_background_concrete",
"color": "#a6aaa3ff"
},
{
"file_id": "wall_panel_3",
"type": "wall_panel",
"position": {
"x": 128.0,
"y": -320.0
},
"rotation": 0.0,
"size": {
"x": 6016.0,
"y": 3456.0
},
"finish_id": "finish_wall_brick",
"color": "#22c6ffff"
},
{
"file_id": "stickman_1",
"type": "stickman",
"position": {
"x": -1088.0,
"y": -641.0
},
"rotation": 0.0,
"stk_path": "res://content/stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "move_to",
"target": {
"x": 1562.8154296875,
"y": -145.01513671875
},
"style": "walk"
}
]
},
{
"file_id": "terrain_1",
"type": "terrain",
"position": {
"x": -2112.0,
"y": 0.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_2",
"type": "terrain",
"position": {
"x": -1920.0,
"y": 0.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_3",
"type": "terrain",
"position": {
"x": -1728.0,
"y": 0.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_4",
"type": "terrain",
"position": {
"x": -1536.0,
"y": 0.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_5",
"type": "terrain",
"position": {
"x": -1344.0,
"y": 0.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_6",
"type": "terrain",
"position": {
"x": -1152.0,
"y": 0.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_7",
"type": "terrain",
"position": {
"x": -960.0,
"y": 0.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_8",
"type": "terrain",
"position": {
"x": -768.0,
"y": 0.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_9",
"type": "terrain",
"position": {
"x": -576.0,
"y": 0.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_10",
"type": "terrain",
"position": {
"x": -384.0,
"y": 0.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_11",
"type": "terrain",
"position": {
"x": -192.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_12",
"type": "terrain",
"position": {
"x": 0.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_13",
"type": "terrain",
"position": {
"x": 192.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_14",
"type": "terrain",
"position": {
"x": 384.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_15",
"type": "terrain",
"position": {
"x": 576.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_16",
"type": "terrain",
"position": {
"x": 768.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_17",
"type": "terrain",
"position": {
"x": 960.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_18",
"type": "terrain",
"position": {
"x": 1152.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_19",
"type": "terrain",
"position": {
"x": 1344.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_20",
"type": "terrain",
"position": {
"x": 1536.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
},
{
"file_id": "terrain_21",
"type": "terrain",
"position": {
"x": 1728.0,
"y": -32.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_grass"
}
],
"event_rules": [],
"metadata": {
"created_unix": 1792467978,
"modified_unix": 1792700708.001,
"autosave": false,
"source_path": ""
}
}
+857
View File
@@ -0,0 +1,857 @@
{
"version": "1.7",
"scene_name": "Plain Background",
"sky_id": "sky_starry_night",
"background_kind": "sky",
"plain_color": "#F5EFE0",
"camera": {
"position": {
"x": -43.8016738891602,
"y": -691.851379394531
},
"zoom": 0.605000019073486
},
"objects": [
{
"file_id": "background_ground_1",
"type": "background_ground",
"position": {
"x": 96.0,
"y": 672.0
},
"rotation": 0.0,
"size": {
"x": 6592.0,
"y": 192.0
},
"finish_id": "finish_background_grass",
"color": "#72a84cff"
},
{
"file_id": "scenery_artwork_1",
"type": "scenery_artwork",
"position": {
"x": -1472.0,
"y": -320.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_storefront-v1.prop",
"display_name": "Sunny Storefront",
"depth": "near",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -871.896057128906,
"y": -826.405822753906
},
"size": {
"x": 1743.79211425781,
"y": 1652.81164550781
}
}
},
{
"file_id": "scenery_artwork_2",
"type": "scenery_artwork",
"position": {
"x": -321.925048828125,
"y": -864.47802734375
},
"rotation": 0.0,
"asset_id": "res://content/props/art_town_house-v1.prop",
"display_name": "Town House",
"depth": "near",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -958.074951171875,
"y": -1375.52197265625
},
"size": {
"x": 1916.14990234375,
"y": 2751.0439453125
}
}
},
{
"file_id": "scenery_artwork_3",
"type": "scenery_artwork",
"position": {
"x": 1408.0,
"y": -320.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_storefront-v1.prop",
"display_name": "Sunny Storefront",
"depth": "near",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -871.896057128906,
"y": -826.405822753906
},
"size": {
"x": 1743.79211425781,
"y": 1652.81164550781
}
}
},
{
"file_id": "scenery_artwork_4",
"type": "scenery_artwork",
"position": {
"x": -2368.0,
"y": -256.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_city_skyline-v1.prop",
"display_name": "City Skyline",
"depth": "far",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -939.5,
"y": -289.301696777344
},
"size": {
"x": 1879.0,
"y": 578.603393554688
}
}
},
{
"file_id": "scenery_artwork_5",
"type": "scenery_artwork",
"position": {
"x": 704.0,
"y": -256.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_city_skyline-v1.prop",
"display_name": "City Skyline",
"depth": "far",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -939.5,
"y": -289.301696777344
},
"size": {
"x": 1879.0,
"y": 578.603393554688
}
}
},
{
"file_id": "scenery_artwork_6",
"type": "scenery_artwork",
"position": {
"x": 2304.0,
"y": -256.0
},
"rotation": 0.0,
"asset_id": "res://content/props/art_city_skyline-v1.prop",
"display_name": "City Skyline",
"depth": "far",
"scale": {
"x": 1.0,
"y": 1.0
},
"last_known_local_bounds": {
"position": {
"x": -939.5,
"y": -289.301696777344
},
"size": {
"x": 1879.0,
"y": 578.603393554688
}
}
},
{
"file_id": "background_ground_2",
"type": "background_ground",
"position": {
"x": 96.0,
"y": 352.0
},
"rotation": 0.0,
"size": {
"x": 6848.0,
"y": 832.0
},
"finish_id": "finish_background_grass",
"color": "#72a84cff"
},
{
"file_id": "terrain_1",
"type": "terrain",
"position": {
"x": -3264.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_2",
"type": "terrain",
"position": {
"x": -3072.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_3",
"type": "terrain",
"position": {
"x": -2880.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_4",
"type": "terrain",
"position": {
"x": -2688.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_5",
"type": "terrain",
"position": {
"x": -2496.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_6",
"type": "terrain",
"position": {
"x": -2304.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_7",
"type": "terrain",
"position": {
"x": -2112.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_8",
"type": "terrain",
"position": {
"x": -1920.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_9",
"type": "terrain",
"position": {
"x": -1728.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_10",
"type": "terrain",
"position": {
"x": -1536.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_11",
"type": "terrain",
"position": {
"x": -1344.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_12",
"type": "terrain",
"position": {
"x": -1152.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_13",
"type": "terrain",
"position": {
"x": -960.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_14",
"type": "terrain",
"position": {
"x": -768.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_15",
"type": "terrain",
"position": {
"x": -576.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_16",
"type": "terrain",
"position": {
"x": -384.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_17",
"type": "terrain",
"position": {
"x": -192.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_18",
"type": "terrain",
"position": {
"x": 0.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_19",
"type": "terrain",
"position": {
"x": 192.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_20",
"type": "terrain",
"position": {
"x": 384.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_21",
"type": "terrain",
"position": {
"x": 576.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_22",
"type": "terrain",
"position": {
"x": 768.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_23",
"type": "terrain",
"position": {
"x": 960.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_24",
"type": "terrain",
"position": {
"x": 1152.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_25",
"type": "terrain",
"position": {
"x": 1344.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_26",
"type": "terrain",
"position": {
"x": 1536.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_27",
"type": "terrain",
"position": {
"x": 1728.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_28",
"type": "terrain",
"position": {
"x": 1920.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_29",
"type": "terrain",
"position": {
"x": 2112.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_30",
"type": "terrain",
"position": {
"x": 2304.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_31",
"type": "terrain",
"position": {
"x": 2496.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_32",
"type": "terrain",
"position": {
"x": 2688.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_33",
"type": "terrain",
"position": {
"x": 2880.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_34",
"type": "terrain",
"position": {
"x": 3072.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_35",
"type": "terrain",
"position": {
"x": 3264.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "terrain_36",
"type": "terrain",
"position": {
"x": 3456.0,
"y": 768.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
},
"finish_id": "finish_ground_sidewalk"
},
{
"file_id": "background_ground_3",
"type": "background_ground",
"position": {
"x": 160.0,
"y": 864.0
},
"rotation": 0.0,
"size": {
"x": 6912.0,
"y": 192.0
},
"finish_id": "finish_background_concrete",
"color": "#a6aaa3ff"
},
{
"file_id": "background_ground_4",
"type": "background_ground",
"position": {
"x": -2896.0,
"y": 848.0
},
"rotation": 0.0,
"size": {
"x": 800.0,
"y": 160.0
},
"finish_id": "finish_background_concrete",
"color": "#a6aaa3ff"
},
{
"file_id": "background_ground_5",
"type": "background_ground",
"position": {
"x": -3264.0,
"y": 864.0
},
"rotation": 0.0,
"size": {
"x": 320.0,
"y": 192.0
},
"finish_id": "finish_background_concrete",
"color": "#a6aaa3ff"
},
{
"file_id": "stickman_1",
"type": "stickman",
"position": {
"x": -2784.0,
"y": 351.0
},
"rotation": 0.0,
"stk_path": "res://content/stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "move_to",
"target": {
"x": 2027.27270507813,
"y": 683.355224609375
},
"style": "walk"
}
]
},
{
"file_id": "stickman_2",
"type": "stickman",
"position": {
"x": -672.0,
"y": 352.0
},
"rotation": 0.0,
"stk_path": "res://content/stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "move_to",
"target": {
"x": -3161.15698242188,
"y": 701.537109375
},
"style": "walk"
}
]
},
{
"file_id": "stickman_3",
"type": "stickman",
"position": {
"x": 3392.0,
"y": 351.0
},
"rotation": 0.0,
"stk_path": "res://content/stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "move_to",
"target": {
"x": -3333.0576171875,
"y": 726.330444335938
},
"style": "run"
}
]
}
],
"event_rules": [],
"metadata": {
"created_unix": 1792022400,
"modified_unix": 1792732500.154,
"autosave": false,
"source_path": ""
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "1.7",
"scene_name": "Open Sky",
"sky_id": "sky_neutral",
"background_kind": "sky",
"plain_color": "#F5EFE0",
"camera": {"position": {"x": 0.0, "y": -400.0}, "zoom": 0.5},
"objects": [],
"event_rules": [],
"metadata": {"created_unix": 1792022400, "modified_unix": 1792022400, "autosave": false, "source_path": ""}
}
+50
View File
@@ -0,0 +1,50 @@
{
"version": "1.6",
"scene_name": "Living Room",
"sky_id": "sky_clear_day",
"camera": {"position": {"x": 118.0, "y": -432.0}, "zoom": 0.38},
"objects": [
{"file_id":"wall_top","type":"wall_panel","position":{"x":0.0,"y":-941.0},"rotation":0.0,"size":{"x":4760.0,"y":518.0},"finish_id":"finish_wall_paint","color":"#5A9D70"},
{"file_id":"wall_left","type":"wall_panel","position":{"x":-1262.5,"y":-256.0},"rotation":0.0,"size":{"x":2235.0,"y":852.0},"finish_id":"finish_wall_paint","color":"#5A9D70"},
{"file_id":"wall_right","type":"wall_panel","position":{"x":1262.5,"y":-256.0},"rotation":0.0,"size":{"x":2235.0,"y":852.0},"finish_id":"finish_wall_paint","color":"#5A9D70"},
{"file_id":"wall_under_window","type":"wall_panel","position":{"x":0.0,"y":-116.0},"rotation":0.0,"size":{"x":290.0,"y":572.0},"finish_id":"finish_wall_paint","color":"#5A9D70"},
{"file_id":"wall_baseboard","type":"wall_panel","position":{"x":0.0,"y":177.0},"rotation":0.0,"size":{"x":4760.0,"y":14.0},"finish_id":"finish_wall_paint","color":"#E4C496"},
{"file_id":"window_frame","type":"scenery_artwork","asset_id":"res://content/props/art_window_frame-v1.prop","display_name":"Window frame","depth":"near","position":{"x":0.0,"y":-530.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"wall_clock","type":"scenery_artwork","asset_id":"res://content/props/art_wall_clock-v1.prop","display_name":"Wall clock","depth":"near","position":{"x":-450.0,"y":-540.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"fireplace","type":"scenery_artwork","asset_id":"res://content/props/art_fireplace_mantel-v2.prop","display_name":"Fireplace","depth":"near","position":{"x":1280.0,"y":-126.5},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"bookshelf","type":"scenery_artwork","asset_id":"res://content/props/art_bookshelf-v1.prop","display_name":"Bookshelf","depth":"near","position":{"x":-930.0,"y":-311.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"picture","type":"scenery_artwork","asset_id":"res://content/props/art_framed_picture-v1.prop","display_name":"Framed picture","depth":"near","position":{"x":1000.0,"y":-530.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"ground_wood_left_12","type":"terrain","asset_id":"ground","position":{"x":-2304.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_left_11","type":"terrain","asset_id":"ground","position":{"x":-2112.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_left_10","type":"terrain","asset_id":"ground","position":{"x":-1920.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_left_09","type":"terrain","asset_id":"ground","position":{"x":-1728.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_left_08","type":"terrain","asset_id":"ground","position":{"x":-1536.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_left_07","type":"terrain","asset_id":"ground","position":{"x":-1344.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_left_06","type":"terrain","asset_id":"ground","position":{"x":-1152.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_left_05","type":"terrain","asset_id":"ground","position":{"x":-960.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_left_04","type":"terrain","asset_id":"ground","position":{"x":-768.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_left_03","type":"terrain","asset_id":"ground","position":{"x":-576.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_left_02","type":"terrain","asset_id":"ground","position":{"x":-384.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_left_01","type":"terrain","asset_id":"ground","position":{"x":-192.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood","type":"terrain","asset_id":"ground","position":{"x":0.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_right_01","type":"terrain","asset_id":"ground","position":{"x":192.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_right_02","type":"terrain","asset_id":"ground","position":{"x":384.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_right_03","type":"terrain","asset_id":"ground","position":{"x":576.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_right_04","type":"terrain","asset_id":"ground","position":{"x":768.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_right_05","type":"terrain","asset_id":"ground","position":{"x":960.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_right_06","type":"terrain","asset_id":"ground","position":{"x":1152.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_right_07","type":"terrain","asset_id":"ground","position":{"x":1344.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_right_08","type":"terrain","asset_id":"ground","position":{"x":1536.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_right_09","type":"terrain","asset_id":"ground","position":{"x":1728.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_right_10","type":"terrain","asset_id":"ground","position":{"x":1920.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_right_11","type":"terrain","asset_id":"ground","position":{"x":2112.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"ground_wood_right_12","type":"terrain","asset_id":"ground","position":{"x":2304.0,"y":200.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_wood"},
{"file_id":"rug","type":"scenery_artwork","asset_id":"res://content/props/art_rug-v1.prop","display_name":"Floor rug","depth":"floor","position":{"x":0.0,"y":87.2},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"plant","type":"scenery_artwork","asset_id":"res://content/props/art_potted_plant-v1.prop","display_name":"Potted plant","depth":"floor","position":{"x":1715.0,"y":75.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"couch","type":"prop","asset_id":"res://content/props/prop_couch-v1.prop","position":{"x":-690.0,"y":-85.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"stage_object_id":"starter_couch","roles":{"depth":"behind","start_motion":"still","holds_other_props":false,"blocks_stickmen":true,"can_pick_up":false,"released_blocks_stickmen":true,"released_bumps_other_props":true,"legacy_draw_order":false},"use_spots":[],"next_use_spot_number":1,"wobble_on_impact":false,"material_preset":"WOOD"},
{"file_id":"armchair","type":"prop","asset_id":"res://content/props/prop_armchair-v1.prop","position":{"x":620.0,"y":-104.4},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"stage_object_id":"starter_armchair","roles":{"depth":"behind","start_motion":"still","holds_other_props":false,"blocks_stickmen":true,"can_pick_up":false,"released_blocks_stickmen":true,"released_bumps_other_props":true,"legacy_draw_order":false},"use_spots":[],"next_use_spot_number":1,"wobble_on_impact":false,"material_preset":"WOOD"},
{"file_id":"coffee_table","type":"prop","asset_id":"res://content/props/prop_coffee_table-v1.prop","position":{"x":-515.0,"y":64.3},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"stage_object_id":"starter_coffee_table","roles":{"depth":"front","start_motion":"still","holds_other_props":true,"blocks_stickmen":true,"can_pick_up":false,"released_blocks_stickmen":true,"released_bumps_other_props":true,"legacy_draw_order":false},"use_spots":[],"next_use_spot_number":1,"wobble_on_impact":false,"material_preset":"WOOD"}
],
"event_rules": [],
"metadata": {"created_unix": 1792022400, "modified_unix": 1792022400, "autosave": false, "source_path": ""}
}
@@ -0,0 +1,11 @@
{
"version": "1.7",
"scene_name": "Plain Background",
"sky_id": "sky_neutral",
"background_kind": "plain",
"plain_color": "#F5EFE0",
"camera": {"position": {"x": 0.0, "y": -400.0}, "zoom": 0.5},
"objects": [],
"event_rules": [],
"metadata": {"created_unix": 1792022400, "modified_unix": 1792022400, "autosave": false, "source_path": ""}
}
+102
View File
@@ -0,0 +1,102 @@
{
"version": "1.6",
"scene_name": "Town Street",
"sky_id": "sky_cloudy_day",
"camera": {"position": {"x": 0.0, "y": -1200.0}, "zoom": 0.16},
"objects": [
{"file_id":"town_landscape_ground","type":"background_ground","position":{"x":0.0,"y":660.0},"size":{"x":8400.0,"y":2000.0},"finish_id":"finish_background_grass","color":"#72A84C"},
{"file_id":"scenery_skyline_left_outer","type":"scenery_artwork","asset_id":"res://content/props/art_city_skyline-v1.prop","display_name":"City skyline","depth":"far","position":{"x":-3700.0,"y":-604.918},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"scenery_skyline_left","type":"scenery_artwork","asset_id":"res://content/props/art_city_skyline-v1.prop","display_name":"City skyline","depth":"far","position":{"x":-1850.0,"y":-604.918},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"scenery_skyline","type":"scenery_artwork","asset_id":"res://content/props/art_city_skyline-v1.prop","display_name":"City skyline","depth":"far","position":{"x":0.0,"y":-604.918},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"scenery_skyline_right","type":"scenery_artwork","asset_id":"res://content/props/art_city_skyline-v1.prop","display_name":"City skyline","depth":"far","position":{"x":1850.0,"y":-604.918},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"scenery_skyline_right_outer","type":"scenery_artwork","asset_id":"res://content/props/art_city_skyline-v1.prop","display_name":"City skyline","depth":"far","position":{"x":3700.0,"y":-604.918},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"scenery_tree_left","type":"scenery_artwork","asset_id":"res://content/props/art_tree-v2.prop","display_name":"Tree","depth":"near","position":{"x":-2600.0,"y":-1271.498},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"scenery_town_house","type":"scenery_artwork","asset_id":"res://content/props/art_town_house-v1.prop","display_name":"Town house","depth":"near","position":{"x":-1400.0,"y":-1361.835},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"scenery_storefront","type":"scenery_artwork","asset_id":"res://content/props/art_storefront-v1.prop","display_name":"Storefront","depth":"near","position":{"x":1400.0,"y":-813.77},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"scenery_tree_right","type":"scenery_artwork","asset_id":"res://content/props/art_tree-v2.prop","display_name":"Tree","depth":"near","position":{"x":2600.0,"y":-1271.498},"rotation":0.0,"scale":{"x":1.0,"y":1.0}},
{"file_id":"ground_sidewalk_left_12","type":"terrain","asset_id":"ground","position":{"x":-2304.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_11","type":"terrain","asset_id":"ground","position":{"x":-2112.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_10","type":"terrain","asset_id":"ground","position":{"x":-1920.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_09","type":"terrain","asset_id":"ground","position":{"x":-1728.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_08","type":"terrain","asset_id":"ground","position":{"x":-1536.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_07","type":"terrain","asset_id":"ground","position":{"x":-1344.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_06","type":"terrain","asset_id":"ground","position":{"x":-1152.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_05","type":"terrain","asset_id":"ground","position":{"x":-960.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_04","type":"terrain","asset_id":"ground","position":{"x":-768.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_03","type":"terrain","asset_id":"ground","position":{"x":-576.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_02","type":"terrain","asset_id":"ground","position":{"x":-384.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_01","type":"terrain","asset_id":"ground","position":{"x":-192.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk","type":"terrain","asset_id":"ground","position":{"x":0.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_01","type":"terrain","asset_id":"ground","position":{"x":192.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_02","type":"terrain","asset_id":"ground","position":{"x":384.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_03","type":"terrain","asset_id":"ground","position":{"x":576.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_04","type":"terrain","asset_id":"ground","position":{"x":768.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_05","type":"terrain","asset_id":"ground","position":{"x":960.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_06","type":"terrain","asset_id":"ground","position":{"x":1152.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_07","type":"terrain","asset_id":"ground","position":{"x":1344.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_08","type":"terrain","asset_id":"ground","position":{"x":1536.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_09","type":"terrain","asset_id":"ground","position":{"x":1728.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_10","type":"terrain","asset_id":"ground","position":{"x":1920.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_11","type":"terrain","asset_id":"ground","position":{"x":2112.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_12","type":"terrain","asset_id":"ground","position":{"x":2304.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_13","type":"terrain","asset_id":"ground","position":{"x":-2496.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_14","type":"terrain","asset_id":"ground","position":{"x":-2688.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_15","type":"terrain","asset_id":"ground","position":{"x":-2880.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_16","type":"terrain","asset_id":"ground","position":{"x":-3072.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_17","type":"terrain","asset_id":"ground","position":{"x":-3264.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_18","type":"terrain","asset_id":"ground","position":{"x":-3456.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_19","type":"terrain","asset_id":"ground","position":{"x":-3648.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_left_20","type":"terrain","asset_id":"ground","position":{"x":-3840.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_13","type":"terrain","asset_id":"ground","position":{"x":2496.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_14","type":"terrain","asset_id":"ground","position":{"x":2688.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_15","type":"terrain","asset_id":"ground","position":{"x":2880.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_16","type":"terrain","asset_id":"ground","position":{"x":3072.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_17","type":"terrain","asset_id":"ground","position":{"x":3264.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_18","type":"terrain","asset_id":"ground","position":{"x":3456.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_19","type":"terrain","asset_id":"ground","position":{"x":3648.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_sidewalk_right_20","type":"terrain","asset_id":"ground","position":{"x":3840.0,"y":0.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_sidewalk"},
{"file_id":"ground_asphalt_left_12","type":"terrain","asset_id":"ground","position":{"x":-2304.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_11","type":"terrain","asset_id":"ground","position":{"x":-2112.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_10","type":"terrain","asset_id":"ground","position":{"x":-1920.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_09","type":"terrain","asset_id":"ground","position":{"x":-1728.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_08","type":"terrain","asset_id":"ground","position":{"x":-1536.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_07","type":"terrain","asset_id":"ground","position":{"x":-1344.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_06","type":"terrain","asset_id":"ground","position":{"x":-1152.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_05","type":"terrain","asset_id":"ground","position":{"x":-960.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_04","type":"terrain","asset_id":"ground","position":{"x":-768.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_03","type":"terrain","asset_id":"ground","position":{"x":-576.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_02","type":"terrain","asset_id":"ground","position":{"x":-384.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_01","type":"terrain","asset_id":"ground","position":{"x":-192.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt","type":"terrain","asset_id":"ground","position":{"x":0.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_01","type":"terrain","asset_id":"ground","position":{"x":192.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_02","type":"terrain","asset_id":"ground","position":{"x":384.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_03","type":"terrain","asset_id":"ground","position":{"x":576.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_04","type":"terrain","asset_id":"ground","position":{"x":768.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_05","type":"terrain","asset_id":"ground","position":{"x":960.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_06","type":"terrain","asset_id":"ground","position":{"x":1152.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_07","type":"terrain","asset_id":"ground","position":{"x":1344.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_08","type":"terrain","asset_id":"ground","position":{"x":1536.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_09","type":"terrain","asset_id":"ground","position":{"x":1728.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_10","type":"terrain","asset_id":"ground","position":{"x":1920.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_11","type":"terrain","asset_id":"ground","position":{"x":2112.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_12","type":"terrain","asset_id":"ground","position":{"x":2304.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_extension","type":"terrain","asset_id":"ground","position":{"x":-2496.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_extension","type":"terrain","asset_id":"ground","position":{"x":2496.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_14","type":"terrain","asset_id":"ground","position":{"x":-2688.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_15","type":"terrain","asset_id":"ground","position":{"x":-2880.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_16","type":"terrain","asset_id":"ground","position":{"x":-3072.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_17","type":"terrain","asset_id":"ground","position":{"x":-3264.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_18","type":"terrain","asset_id":"ground","position":{"x":-3456.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_19","type":"terrain","asset_id":"ground","position":{"x":-3648.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_left_20","type":"terrain","asset_id":"ground","position":{"x":-3840.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_14","type":"terrain","asset_id":"ground","position":{"x":2688.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_15","type":"terrain","asset_id":"ground","position":{"x":2880.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_16","type":"terrain","asset_id":"ground","position":{"x":3072.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_17","type":"terrain","asset_id":"ground","position":{"x":3264.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_18","type":"terrain","asset_id":"ground","position":{"x":3456.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_19","type":"terrain","asset_id":"ground","position":{"x":3648.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"},
{"file_id":"ground_asphalt_right_20","type":"terrain","asset_id":"ground","position":{"x":3840.0,"y":32.0},"rotation":0.0,"scale":{"x":1.0,"y":1.0},"finish_id":"finish_ground_asphalt"}
],
"event_rules": [],
"metadata": {"created_unix": 1792022400, "modified_unix": 1792022400, "autosave": false, "source_path": ""}
}
@@ -0,0 +1,87 @@
# Patterned scenery rendering
A patterned wall or background uses one small repeating image for each finish and tint. The camera samples that same image wherever the surface appears. A pan or zoom changes the visible rectangle and sampling scale; it does not create a new image for each camera position.
![Patterned scenery render and cache pipeline](diagrams/background_pipeline.svg)
## Render pipeline
1. A visible, committed patterned surface requests its style from the scene's `StageSurfaceBakePool`.
2. The cache checks the style key. A ready entry is reused; a missing entry is queued for the shared baker.
3. At most one queued style is baked per process frame. The baker draws a four-world-unit overscan around the material period so edge details can be cropped without gaps.
4. After the submitted drawing reaches `frame_post_draw`, the pool captures the image, crops the period, generates mipmaps, and publishes an `ImageTexture`.
5. The temporary vector commands are cleared. The surface draws one camera-clipped quad using the shared image, repeat sampling, and mipmapped filtering.
The GPU still renders the textured rectangle every frame. When the view changes, the CPU also updates that rectangle and its texture coordinates. This is a small drawing command; it does not rebuild brick faces, grain, pores, or other vector details. The cache counters distinguish image builds and readbacks from ordinary redraws.
## Shared style images
The cache is owned by a scene-scoped pool. Its key includes finish, tint, wall/background kind, material version, repeat period, pixel density, and mipmap configuration. It excludes surface position, camera position, zoom, rectangle size, and erase fragment. Thus same-style surfaces and fragments share one image, while a tint change correctly requests a different image.
![Many surfaces sharing one finished material image](diagrams/background_reuse.svg)
The current periods are finite, seamless repeat units baked at two bitmap pixels per world unit:
| Finish | Repeat period in world units | Bitmap size |
| --- | ---: | ---: |
| Brick | 800 × 304 | 1600 × 608 |
| Wood | 960 × 384 | 1920 × 768 |
| Grass | 704 × 576 | 1408 × 1152 |
| Carpet | 544 × 416 | 1088 × 832 |
| Concrete | 1120 × 760 | 2240 × 1520 |
For example, a 100-world-unit brick remains 100 world units wide. At 0.4× zoom it covers about 40 screen pixels; zoom changes how the GPU samples the image, not the brick geometry. The finite repeat unit makes detail bounded, so landmarks can become noticeable on very large surfaces.
## Zoom and mipmaps
The renderer captures the material once at fixed density, then builds smaller mip levels from that image. During zoom-out the GPU samples a smaller level to reduce shimmer; during zoom-in it samples a larger level. This replaces separately drawing the material at several CPU-selected zoom levels. At the Stage's maximum 6× zoom, the source image is magnified three times and can look softer. The current quality/performance tradeoff is fixed in the cache configuration.
![Fixed material image with mipmap levels](diagrams/background_mipmaps.svg)
## Erasing and pattern alignment
The eraser still cuts a rectangle into up to four rectangular fragments. Each fragment draws only its own bounds, but requests the same style image; it does not bake a fragment-specific copy. Cancel restores the original geometry, and Commit retains the new fragments. Both use the existing material sampling rules.
The sampling origin is significant: walls use `global_position`; background ground uses `global_position + motif_origin_offset`. Each surface computes its source UV from its local visible position plus that draw origin, wrapped by the material period. This preserves the current alignment convention across cuts. Avoid changing the origin rule as a generic transform correction: unusual transformed fragments need separate verification before making a broader alignment claim.
![Erase fragments clipping the same repeating image](diagrams/background_erase.svg)
## Memory and rebuild boundaries
The scene cache has a 64 MiB resident texture budget. Accounting includes mip levels, textures waiting for safe retirement, and reserved space for a capture. Unused entries are evicted by least-recent use when needed. A style held by active surfaces can prevent admission of another style; the pool reports this as budget pressure rather than exceeding the cap. Under ordinary use, returning to a recently used style should reuse its warm image. Many tint variants can force eviction and a later rebuild.
A style's first capture still costs CPU/GPU work and can briefly affect a frame. Once ready, moving the camera across a large wall does not enqueue position tiles or read back new images. The shared baker also clears its vector command list after capture, preventing the native drawing resources that previously accumulated in many per-tile viewports.
These images are temporary runtime data. A saved `.scene` keeps surface settings and geometry; the cache recreates its images when needed in a new Stage session. Closing the scene releases its pool.
## What causes another bake?
| Action | New image work? |
| --- | --- |
| Pan left, then return right | No, while the style remains cached. |
| Zoom in or out | No. The GPU samples existing mipmaps. |
| Resize a wall | No. Change the rectangle and texture coordinates. |
| Erase, Cancel, or Commit | No, if the style is ready. Change geometry and clipping. |
| Use the same finish and tint on another wall | No, if the style is ready. Share its image. |
| Choose a new finish or tint | Bake once if that style is missing. |
| Return to an evicted style, or open a fresh session | Recreate the missing image. |
## Terms used here
- **Bake:** render the vector artwork once to make an image.
- **Texture:** the image the GPU samples when drawing.
- **Quad:** one rectangle, represented by two triangles.
- **UVs:** coordinates that select which part of a texture to sample.
- **Mipmaps:** smaller copies of an image, prepared in advance for zooming out.
- **Readback:** copy the rendered image from the GPU so the cache can keep an independent texture.
## Scope and code map
The bitmap cache is for committed patterned walls and backgrounds in render-capable runs. Plain paint surfaces, interactive placement previews, and headless runs retain the direct renderer path. Fully opaque scenery can suppress a fully hidden surface's texture request; partial coverage remains conservative.
- [StageSurfaceCache](../../scripts/stage/scenery/stage_surface_cache.gd) owns style handles, the scene pool, bake queue, image capture, mipmaps, cache keys, and budget statistics.
- [StageRectSurface](../../scripts/stage/scenery/stage_rect_surface.gd) requests the style, clips the surface to the visible camera area, and draws the repeated texture.
- [StageSurfaceRenderer](../../scripts/stage/scenery/stage_surface_renderer.gd) defines patterned artwork, material periods, and the direct-drawing path.
- [Repeating scenery material cache spec](../specs/scenery_bitmap_tile_cache.md) records implementation constraints and acceptance checks.
In one observed warm run of `fps.scene`, four style entries used 36,399,144 bytes (about 34.71 MiB); 720 warm camera frames recorded zero builds, readbacks, or evictions. This is a run snapshot, not a guarantee for other scenes, tint combinations, or hardware.
@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="640" viewBox="0 0 1200 640" role="img" aria-labelledby="title desc">
<title id="title">Erasing changes rectangles while sharing the same background texture</title>
<desc id="desc">A rectangular cut turns one wall into top, bottom, left and right fragments. All fragments sample the existing image using their pattern coordinates. The image itself is not painted over or copied.</desc>
<defs>
<pattern id="brick" width="80" height="48" patternUnits="userSpaceOnUse"><rect width="80" height="48" fill="#d2cbaf"/><rect x="2" y="2" width="36" height="19" fill="#25b8df"/><rect x="42" y="2" width="36" height="19" fill="#39c7ee"/><rect x="-18" y="26" width="36" height="19" fill="#32c2e8"/><rect x="22" y="26" width="36" height="19" fill="#21b4da"/><rect x="62" y="26" width="36" height="19" fill="#32c2e8"/></pattern>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0 0L10 5L0 10Z" fill="#475569"/></marker>
<style>text{font-family:Segoe UI,Arial,sans-serif;fill:#172033}.title{font-size:30px;font-weight:700}.heading{font-size:22px;font-weight:700}.body{font-size:18px}.small{font-size:16px}.arrow{fill:none;stroke:#475569;stroke-width:3;marker-end:url(#arrow)}</style>
</defs>
<rect width="1200" height="640" rx="16" fill="#f8fafc"/>
<text x="40" y="53" class="title">Erase the wall geometry. Keep the shared image.</text>
<text x="40" y="86" class="body">Fragments crop their own rectangles and use the existing pattern coordinates.</text>
<text x="50" y="136" class="heading">Before: one wall rectangle</text>
<g transform="translate(50 160)">
<rect width="460" height="300" fill="url(#brick)" stroke="#94a3b8" stroke-width="2"/>
<rect x="155" y="90" width="150" height="120" fill="#fff" fill-opacity=".3" stroke="#b45309" stroke-width="4" stroke-dasharray="10 6"/>
<rect x="174" y="131" width="112" height="36" rx="6" fill="#fff7ed"/>
<text x="230" y="155" class="body" text-anchor="middle">Cut here</text>
</g>
<path d="M532 310H648" class="arrow"/>
<text x="590" y="283" class="small" text-anchor="middle">Erase area</text>
<text x="680" y="136" class="heading">After: up to four fragments</text>
<g transform="translate(680 160)">
<rect width="460" height="300" fill="#dbeafe"/>
<g fill="url(#brick)" stroke="#0f766e" stroke-width="3">
<rect width="460" height="90"/>
<rect x="0" y="210" width="460" height="90"/>
<rect x="0" y="90" width="155" height="120"/>
<rect x="305" y="90" width="155" height="120"/>
</g>
<g fill="#fff"><rect x="195" y="26" width="70" height="36" rx="6"/><rect x="182" y="242" width="96" height="36" rx="6"/><rect x="43" y="131" width="70" height="36" rx="6"/><rect x="342" y="131" width="78" height="36" rx="6"/></g>
<text x="230" y="51" class="body" text-anchor="middle">Top</text>
<text x="230" y="267" class="body" text-anchor="middle">Bottom</text>
<text x="78" y="155" class="body" text-anchor="middle">Left</text>
<text x="381" y="155" class="body" text-anchor="middle">Right</text>
<text x="230" y="143" class="body" text-anchor="middle">Empty area</text>
<text x="230" y="173" class="small" text-anchor="middle">Scene behind</text>
</g>
<rect x="40" y="510" width="1120" height="95" rx="12" fill="#eff6ff" stroke="#2563eb" stroke-width="2"/>
<text x="62" y="546" class="heading">All remaining pieces still use the same cached texture.</text>
<text x="62" y="580" class="body">Cancel restores the original geometry. Commit keeps the fragments. Neither paints into the image.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
<title id="title">Zoom uses smaller copies already stored in the material texture</title>
<desc id="desc">The brick material starts at 1600 by 608 bitmap pixels. Its mip chain includes 800 by 304, 400 by 152 and 200 by 76, continuing down to one pixel. These copies are prepared once. Brick world dimensions stay fixed.</desc>
<defs>
<pattern id="brick" width="100" height="38" patternUnits="userSpaceOnUse"><rect width="100" height="38" fill="#d2cbaf"/><rect x="2" y="2" width="46" height="15" fill="#26b8df"/><rect x="52" y="2" width="46" height="15" fill="#39c7ee"/><rect x="-23" y="21" width="46" height="15" fill="#32c2e8"/><rect x="27" y="21" width="46" height="15" fill="#21b4da"/><rect x="77" y="21" width="46" height="15" fill="#32c2e8"/></pattern>
<pattern id="half" xlink:href="#brick" patternTransform="scale(.5)"/>
<pattern id="quarter" xlink:href="#brick" patternTransform="scale(.25)"/>
<pattern id="eighth" xlink:href="#brick" patternTransform="scale(.125)"/>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0 0L10 5L0 10Z" fill="#7c3aed"/></marker>
<style>text{font-family:Segoe UI,Arial,sans-serif;fill:#172033}.title{font-size:30px;font-weight:700}.heading{font-size:22px;font-weight:700}.body{font-size:18px}.small{font-size:16px}.arrow{fill:none;stroke:#7c3aed;stroke-width:3;marker-end:url(#arrow)}</style>
</defs>
<rect width="1200" height="630" rx="16" fill="#f8fafc"/>
<text x="40" y="53" class="title">Zoom samples ready-made sizes of the same image.</text>
<text x="40" y="86" class="body">Mipmaps are generated once with the material. The GPU selects and blends them during rendering.</text>
<rect x="60" y="150" width="400" height="152" fill="url(#brick)" stroke="#94a3b8"/>
<path d="M471 228H508" class="arrow"/>
<rect x="530" y="188" width="200" height="76" fill="url(#half)" stroke="#94a3b8"/>
<path d="M741 228H778" class="arrow"/>
<rect x="800" y="207" width="100" height="38" fill="url(#quarter)" stroke="#94a3b8"/>
<path d="M911 228H948" class="arrow"/>
<rect x="970" y="217" width="50" height="19" fill="url(#eighth)" stroke="#94a3b8"/>
<text x="1043" y="234" class="heading">…</text>
<text x="260" y="342" class="heading" text-anchor="middle">1600 × 608</text>
<text x="260" y="373" class="small" text-anchor="middle">Base image: 2 pixels / world unit</text>
<text x="630" y="342" class="heading" text-anchor="middle">800 × 304</text>
<text x="630" y="373" class="small" text-anchor="middle">Half size</text>
<text x="850" y="342" class="heading" text-anchor="middle">400 × 152</text>
<text x="850" y="373" class="small" text-anchor="middle">Quarter size</text>
<text x="1000" y="342" class="heading" text-anchor="middle">200 × 76</text>
<text x="1000" y="373" class="small" text-anchor="middle">Eighth size</text>
<text x="60" y="414" class="small">The actual chain continues down to 1 × 1. Diagram sizes are scaled for illustration.</text>
<rect x="40" y="450" width="1120" height="143" rx="12" fill="#ecfdf5" stroke="#0f766e" stroke-width="2"/>
<text x="62" y="486" class="heading">World size, image size and screen size are different things.</text>
<text x="62" y="521" class="body">A brick stays 100 world units wide. At 40% zoom, it covers about 40 screen pixels.</text>
<text x="62" y="556" class="body">Camera zoom changes sampling detail; it does not bake another image or resize the brick geometry.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="640" viewBox="0 0 1200 640" role="img" aria-labelledby="title desc">
<title id="title">Background rendering: prepare a style once, reuse it while moving the camera</title>
<desc id="desc">A visible surface requests its finish and tint from the scene cache. A cache hit goes directly to the GPU repeat sampler. A cache miss uses one temporary baker, captures an image and mipmaps, and releases vector commands.</desc>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0 0L10 5L0 10Z" fill="#475569"/></marker>
<style>text{font-family:Segoe UI,Arial,sans-serif;fill:#172033}.title{font-size:30px;font-weight:700}.heading{font-size:22px;font-weight:700}.body{font-size:18px}.small{font-size:16px}.arrow{fill:none;stroke:#475569;stroke-width:3;marker-end:url(#arrow)}</style>
</defs>
<rect width="1200" height="640" rx="16" fill="#f8fafc"/>
<text x="40" y="53" class="title">Prepare a material once. Reuse it as the camera moves.</text>
<text x="40" y="86" class="body">Only visible, committed patterned scenery requests a cached material.</text>
<rect x="40" y="140" width="250" height="126" rx="12" fill="#eff6ff" stroke="#2563eb" stroke-width="2"/>
<text x="60" y="177" class="heading">Visible surface</text>
<text x="60" y="210" class="body">Finish + tint</text>
<text x="60" y="239" class="small">Example: blue brick</text>
<path d="M292 203H370" class="arrow"/>
<rect x="390" y="140" width="260" height="126" rx="12" fill="#eff6ff" stroke="#2563eb" stroke-width="2"/>
<text x="410" y="177" class="heading">Scene style cache</text>
<text x="410" y="210" class="body">Already have this image?</text>
<text x="410" y="239" class="small">Shared by all matching surfaces</text>
<path d="M652 203H740" class="arrow"/>
<text x="674" y="182" class="small">Hit</text>
<rect x="760" y="140" width="400" height="126" rx="12" fill="#ecfdf5" stroke="#0f766e" stroke-width="2"/>
<text x="782" y="177" class="heading">GPU draws the visible rectangle</text>
<text x="782" y="210" class="body">Repeat texture + sample its mipmaps</text>
<text x="782" y="239" class="small">Pan / zoom updates sampling coordinates</text>
<path d="M520 268V324" class="arrow"/>
<text x="540" y="304" class="small">Miss: prepare this style</text>
<rect x="390" y="344" width="260" height="158" rx="12" fill="#f5f3ff" stroke="#7c3aed" stroke-width="2"/>
<text x="410" y="379" class="heading">One temporary baker</text>
<text x="410" y="412" class="body">Draw one pattern period</text>
<text x="410" y="441" class="body">Wait for GPU; capture image</text>
<text x="410" y="476" class="small">Then clear vector commands</text>
<path d="M652 423H740" class="arrow"/>
<rect x="760" y="344" width="400" height="158" rx="12" fill="#f5f3ff" stroke="#7c3aed" stroke-width="2"/>
<text x="782" y="379" class="heading">Store the reusable texture</text>
<text x="782" y="412" class="body">Crop overscan; generate mipmaps</text>
<text x="782" y="441" class="body">Keep the image, not the vector artwork</text>
<text x="782" y="476" class="small">Ready styles stay warm within the cache budget</text>
<path d="M960 342V282" class="arrow"/>
<text x="980" y="309" class="small">Ready</text>
<rect x="40" y="548" width="540" height="56" rx="10" fill="#dbeafe"/>
<text x="59" y="582" class="body">Blue route: reuse during ordinary pan and zoom</text>
<rect x="604" y="548" width="556" height="56" rx="10" fill="#ede9fe"/>
<text x="623" y="582" class="body">Purple route: bake only when a style is missing</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="650" viewBox="0 0 1200 650" role="img" aria-labelledby="title desc">
<title id="title">Many wall rectangles share one material image</title>
<desc id="desc">One blue-brick image supplies Wall A, Wall B, and an erased fragment. Different sizes, positions, and camera views do not create more images. A different tint or finish is a different cached style.</desc>
<defs>
<pattern id="brick" width="64" height="40" patternUnits="userSpaceOnUse">
<rect width="64" height="40" fill="#d2cbaf"/>
<g><rect x="2" y="2" width="28" height="15" fill="#25b8df"/><rect x="34" y="2" width="28" height="15" fill="#39c7ee"/><rect x="-14" y="22" width="28" height="15" fill="#32c2e8"/><rect x="18" y="22" width="28" height="15" fill="#21b4da"/><rect x="50" y="22" width="28" height="15" fill="#32c2e8"/></g>
</pattern>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto"><path d="M0 0L10 5L0 10Z" fill="#2563eb"/></marker>
<style>text{font-family:Segoe UI,Arial,sans-serif;fill:#172033}.title{font-size:30px;font-weight:700}.heading{font-size:22px;font-weight:700}.body{font-size:18px}.small{font-size:16px}.arrow{fill:none;stroke:#2563eb;stroke-width:3;marker-end:url(#arrow)}</style>
</defs>
<rect width="1200" height="650" rx="16" fill="#f8fafc"/>
<text x="40" y="53" class="title">Wall area can grow. The number of style images stays the same.</text>
<text x="40" y="86" class="body">Position, wall size, camera position and zoom are not part of the style key.</text>
<rect x="400" y="117" width="400" height="163" rx="12" fill="#eff6ff" stroke="#2563eb" stroke-width="2"/>
<rect x="420" y="140" width="160" height="112" fill="url(#brick)" stroke="#94a3b8"/>
<text x="602" y="162" class="heading">One shared image</text>
<text x="602" y="194" class="body">Blue brick</text>
<text x="602" y="224" class="small">Varied brick faces</text>
<text x="602" y="250" class="small">Repeats both ways</text>
<path d="M600 282V311H210V341" class="arrow"/>
<path d="M600 311V341" class="arrow"/>
<path d="M600 311H990V341" class="arrow"/>
<rect x="40" y="360" width="340" height="192" rx="12" fill="#fff" stroke="#cbd5e1" stroke-width="2"/>
<text x="62" y="396" class="heading">Wall A</text>
<rect x="62" y="416" width="296" height="103" fill="url(#brick)"/>
<rect x="430" y="360" width="340" height="192" rx="12" fill="#fff" stroke="#cbd5e1" stroke-width="2"/>
<text x="452" y="396" class="heading">Wall B</text>
<rect x="452" y="416" width="190" height="103" fill="url(#brick)"/>
<text x="659" y="455" class="small">Same</text>
<text x="659" y="480" class="small">style</text>
<rect x="820" y="360" width="340" height="192" rx="12" fill="#fff" stroke="#cbd5e1" stroke-width="2"/>
<text x="842" y="396" class="heading">Erased fragment</text>
<rect x="842" y="416" width="90" height="103" fill="url(#brick)"/>
<text x="952" y="455" class="small">Only the visible</text>
<text x="952" y="480" class="small">rectangle changes</text>
<text x="40" y="591" class="body">A different finish or tint is a separate style. A matching warm style reuses the existing image.</text>
<text x="40" y="621" class="small" fill="#64748b">Patterns shown here are schematic; the actual brick image contains 8 columns × 8 rows of varied artwork.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,42 @@
# Scenery surface caching
`StageRectSurface` renders committed patterned scenery through reusable texture tiles.
Drawing previews use the adaptive lightweight renderer. Paint remains a single rectangle.
## Camera movement
Tiles use a fixed grid in the surface's local coordinates. Texture resolution changes in
powers of two, so adjacent wheel steps reuse existing tiles. Panning requests only newly
exposed tiles. Existing textures stay visible while missing tiles are prepared, with a
lightweight surface motif beneath them to prevent blank areas.
`StageSurfaceCache` keeps queued tiles as metadata. A shared queue creates and renders at
most one tile per process frame across all surfaces. It also retires at most two obsolete
render targets per frame. This avoids large allocations, vector rebuilds, and batches of
GPU resource releases during camera input.
Each tile has a 256 × 128 pixel core and a four pixel gutter. The gutter is cropped during
compositing to prevent seams. Each surface requests at most 192 visible tiles and retains
at most 224 tiles, evicting older unused entries. Half-resolution tiles retain full brick
texture and wood grain. Only quarter-resolution tiles and below omit fine texture, for
distant views. Brick and plank positions remain fixed. Detail can fill in progressively
after movement, including when returning from a distant view to a normal zoom level.
## Invalidation and coverage
Geometry, finish, tint, and motif-origin changes invalidate that surface's tiles. Camera
movement selects tiles without invalidating their artwork. Render targets belong to the
surface node; queued references are weak so deleted surfaces cannot keep jobs alive.
Fully opaque rectangular sibling surfaces can suppress artwork entirely hidden behind
them. Coverage checks account for transforms, draw order, and visibility. Transparent
surfaces, custom materials, differing relative-Z conventions, and Y-sorted parents disable
this optimization conservatively. Revealing a surface resumes its pending tiles.
## Verification
`tests/test_scenery_surface_cache.gd` checks rendered appearance against the direct vector
renderer, tile reuse and invalidation, the shared build limit, allocation and retention
bounds, and covered-surface behavior. Run it with a graphical Godot renderer for pixel
comparisons. The adaptive preview regressions remain in
`tests/test_scenery_surface_preview.gd` and `tests/test_scenery_brick_preview.gd`.
@@ -81,6 +81,16 @@ This document tracks known technical debt, optimization opportunities, and minor
| 69 | **Contact entry alone can miss a stickman hitting a fixed prop** — grounded locomotion stops at a shape-sweep safe fraction, sometimes before `body_entered` fires; resting contacts can also chatter after pause or quick separation. | Medium | Implemented; user-verified; automated result undocumented | Phase 17c routes identified blocking-prop sweep hits and actual body contacts through one relative approach-speed scorer and pair gate. Multiple stickman limbs collapse into one rig/prop pair; separation/rearm and cooldown gates suppress repeat hits. The user has verified Phase 17c; no automated behavior verification result is documented. Keep existing collision-rule dispatch independent. See `docs/specs/phase_17c_impact_wobble.md`. (2026-10-14) |
| 70 | **Impact strength is initially speed based while prop mass is preset based** — material presets set `RigidBody2D.mass`, but fixed props are frozen and mass is not a child-facing setting. | Low | Implemented; future design | Phase 17c keeps the Wobble boolean and visual-only response independent of mass. Impact scoring is centralized in `StageImpactResolver` so a future editable-mass or impulse design can be evaluated without altering the Stage switch or migrating old scene choices. Do not infer a physical impulse from a frozen prop's preset mass; verify any future mass effect separately. See `docs/specs/phase_17c_impact_wobble.md`. (2026-10-14) |
| 71 | **Palette art is supplied at much higher resolution than its display size** — six 1254 × 1254 PNGs appear in 64 × 64 card image areas. | Low | ✅ Resolved | Phase 18 is implemented and user-verified. The original source PNGs remain 1254 × 1254, Godot imports are capped at 256 px, and `StageChrome` maps the six images by placement ID. The focused palette suite passed; the headless Sandbox Stage loaded, and `git diff --check` passed. Keep the map local to the fixed Stage palette; revisit a data-driven icon descriptor only if tools or themed art become extensible. See `docs/specs/phase_18_palette_icons.md`. (2026-10-14) |
| 72 | **Stage-wide Undo/Redo needs its own phase** — Phase 19 placement, erase, style, repair, selected-artwork depth, direct single-piece moves/resizes, and group Keep actions change the document without an integrated command history. | Medium | Deferred to separate phase | Design one Stage-level history with coherent commands across Scenery, Ground, Props, terrain, characters, scene naming, and directed content, with safe boundaries around PLAY/paused edits and save/recovery. The Phase 19 sizing amendment keeps single-piece gestures commit-on-release with cancellable active snapshots and retains group Keep/Cancel; neither is an Undo implementation or shortcut. Selected-artwork depth choices also commit immediately. User explicitly deferred Stage Undo/Redo to its own phase. See `docs/specs/phase_19_scenery_backgrounds_surface_finishes.md` and `docs/specs/phase_19_selected_artwork_depth_inspector.md`. (2026-10-17) |
| 73 | **Whole-set transform requires cross-system invariants** — Phase 19's temporary group transform covers only inert Scenery pieces: wall panels, artwork, and the planned Background ground. | Medium | Future feature | Moving/scaling walkable Ground, characters, interactive Props, Sky, camera composition, waypoints, and rules together needs separate physics, navigation, event-target, and save contracts. Do not expand the Scenery group tool into a whole-set transform without that design. See `docs/specs/phase_19_scenery_backgrounds_surface_finishes.md` and `docs/specs/phase_19_background_ground_and_wall_color.md`. (2026-10-17) |
| 74 | **Background surface motifs need a shared zoom-consistent draw budget** — a pattern detail scale derived from each panel's size can shift seams or texture density where adjacent pieces meet, while uncapped large rectangles can issue excessive draw calls. | Medium | Planned | Phase 19 follow-up uses world-cell coordinates, visible-region culling, and any level-of-detail choice derived from camera zoom for all pieces in the same view. Keep Grass/Concrete/Carpet detail deterministic and bounded; consider a texture cache only if measured scenes remain expensive. See `docs/specs/phase_19_background_ground_and_wall_color.md`. (2026-10-17) |
| 75 | **Old Brick/Wood wall colors were saved but not rendered** — v1.5 walls can carry the old default green while their brick and wood palettes were hard-coded. | Low | Planned migration | Scene v1.6 migration replaces only the old green placeholder on pre-v1.6 Brick/Wood entries with natural finish defaults; Paint and non-placeholder colors remain. The exact placeholder could have been intentionally authored in an older file, which cannot be distinguished retrospectively. New v1.6 colors are authoritative, including green Brick/Wood. See `docs/specs/phase_19_background_ground_and_wall_color.md`. (2026-10-17) |
| 76 | **Camera-aware Sky redraws vector motifs while framing changes** — the former static Sky drew motifs only when the preset or viewport changed. | Low | Planned | The Phase 19 Sky addition keeps one screen-space `StageSkyView`, redraws only when a bound camera's effective framing changes, and caches reusable cloud/moon curve samples where practical. Measure sustained pan/zoom cost in a rendered Stage before adding a raster or shader path. The fixed reference frame and bounded response can saturate at distant camera positions; that is an accepted composition trade-off, not scene state to persist. See `docs/specs/phase_19_scenery_backgrounds_surface_finishes.md#phase-19-addition-camera-aware-sky-details`. (2026-10-21) |
| 77 | **Starter surfaces guarantee a finite pan envelope, not arbitrary camera framing** — the two bundled sets use a fixed authored width to keep their scene data simple. | Low | Implemented; visual review pending | The authored envelope is a 1,280-logical-pixel viewport at zoom 0.5, ±1,000 world units of travel, and 100 units of margin per side. User review prompted an opaque Town Grass Background ground and separate scale-1 tile rows. Twenty-five 192-unit tiles cover the 4,760-unit minimum; Asphalt has 27 tiles (5,184 units) so the road extends one tile beyond the sidewalk at both ends. Navigation merges contiguous tile tops and routes the 32-unit curb step at the exposed ends without merging Ground objects or colliders. The focused suite passes 515 assertions, including the curb step and PLAY landing. The camera remains unrestricted, so wider viewports, farther pans, and zoomed-out shots can reveal finite ends. Rendered seam review remains pending. See docs/specs/phase_19_starter_scene_composition.md. (2026-10-21)
| 78 | **Plain Background adds a second backdrop kind while Sky remains a separate renderer** — live Stage, starter previews, and chooser cards must agree on kind and remembered color. | Medium | Spec prepared; implementation pending | Phase 19 composes one shared `StageBackgroundView` from the existing Sky view and an opaque fill. Scene v1.7 stores active kind, last Sky ID, and confirmed plain color; all older scenes migrate as Sky. `SandboxStage` owns the transient custom-picker draft, which must never enter Save or autosave. The existing chooser's four-Sky layout and 42 px compact swatches need targeted extension, and grid/gizmo contrast needs theme-backed treatment for light and dark fills. Rendered pan/zoom/resize, legacy migration, and every draft-cancel exit remain verification gates. See `docs/specs/phase_19_plain_background.md`. (2026-10-22) |
| 79 | **Editor chrome has two existing token sources and scene-level literals** — PropTheme owns current Prop UI values while Stickman scenes hardcode bars, labels, and borders. | Medium | Spec prepared; implementation pending | Add one validated `EditorVisualTheme` source for shared semantic chrome and editor-local `Theme` instances. Retain PropTheme for prop material/primitive/canvas values and keep Stickman canvas drawing tokens in a separate namespace. Migrate legacy Prop `ui` accessors deliberately so a chrome role has one final authority; use SandboxTheme values as a visual reference without a runtime dependency or Stage inspector component reuse. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
| 80 | **Stickman panel nodes currently own both document shapes and local view state** — rebuilding a panel for a focused layout would reset selection, zoom, and pan and risk save/preview drift. | Medium | Spec prepared; implementation pending | Move the same ten BodyPartPanel instances between Overview and Focus hosts, retain one permanent WholeStickmanPreview sibling, and defer switching while a drag or popup is active. The controller owns session-only view choice and active part; manually chosen views survive resize, and narrow Overview remains scrollable. A future document-model extraction is warranted only if new editor features make live-node ownership limiting. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
| 81 | **Stickman authored transforms lack a complete dirty-state boundary** — preview translation emits `part_moved`, while rotation and scale currently have no commit signal; Save/Load/Clear do not track unsaved work. | Medium | Spec prepared; implementation pending | Add a controller-observable transform commit path for position, rotation, and scale, plus `_dirty`/`_current_path` and Save/Discard Changes/Cancel transactions for replacement and close. Keep Overview/Focus, guide, grid, selection, and zoom/pan as session UI state that never marks dirty. Preserve the `.stk` format. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) |
---
@@ -169,5 +179,19 @@ This document tracks known technical debt, optimization opportunities, and minor
| 2026-10-14 | Phase 17c Impact wobble implementation is present against the approved UI and architecture contracts. The prop card has a per-placement switch and EDIT sample; PLAY routes contacts and identified blocking sweeps through the approach-speed resolver, including ragdoll and prop pairs, while wobbling artwork only. Scene v1.4 stores the setting and migrates older scenes to Off; STOP/reset clears transient motion. Automated behavior verification and in-editor user verification have not been completed. Updated #69–#70; mass-aware scoring remains a future design seam. |
| 2026-10-14 | User verified Phase 17c Impact wobble. Updated #69 and the Phase 17 plan; no automated behavior verification result was supplied. |
| 2026-10-14 | Phase 18 palette image icons implemented and user-verified (`docs/specs/phase_18_palette_icons.md`). `StageChrome` maps the six palette placement IDs to preloaded images; the three plural filenames are now singular, original 1254 × 1254 PNGs are preserved, and Godot imports are capped at 256 px for display in the existing 64 × 64 card areas. The focused palette suite passed, the headless Sandbox Stage loaded, and `git diff --check` passed. Godot import exited 0 with Windows certificate-store and global editor-settings warnings. User verified the palette appearance and placement behavior in the Editor; #71 is resolved. |
| 2026-10-15 | Phase 19 architecture specification drafted for final user sign-off (`docs/specs/phase_19_scenery_backgrounds_surface_finishes.md`) after approval of live `.prop` references with a repairable missing-art placement, adaptive viewport Sky, and a Stage-hosted Prop Editor route. The design adds inert wall/artwork nodes, vector finishes, scene v1.5 migration, `.prop` v1.1 tags, Scenery-only selection and snapshot group transforms, shared Stage UI generalizations, and ordinary editable starter scenes. Logged #72 for a separate Stage Undo/Redo phase and #73 for a separately designed whole-set transform. No runtime code or assets changed. |
| 2026-10-16 | Added the Phase 19 Stage-relative sizing architecture amendment to `docs/specs/phase_19_scenery_backgrounds_surface_finishes.md` for final sign-off. Prop Editor Size for Stage uses a reversible snapshot, longest-edge presets of 0.35/1/2 standing-stickman heights, origin-based uniform geometry/stroke/editable-anchor scaling, and an editor-only foreground figure/floor comparison. A selected Scenery piece uses its own snapshot transaction and commits on release; a group still uses Keep/Cancel. Existing `.scene` v1.5 placement scale and `.prop` v1.1 geometry need no schema change or load-time normalization. Explicit Save at an existing `.prop` path retains live-reference updates for all users of that source; no asset-version enforcement or new save prompt was requested. Updated #72 for the deferred Stage Undo boundary. The amendment needs no new art and Phase 19 user/editor verification remains pending. No runtime code changed by this specification entry. |
| 2026-10-17 | Wrote the Phase 19 selected-artwork depth inspector specification (`docs/specs/phase_19_selected_artwork_depth_inspector.md`) for final sign-off. It reuses the existing `StageInspectorPanel` and `StagePanelChoiceGroup`, applies Far/Near/On the floor immediately to one live or missing Scenery placement through `StageSceneryArtwork.set_depth()`, and follows the Stage's existing dirty/save path. The current `.scene` depth field, `StageDrawOrder`, and `stage_panels` tokens need no changes. Updated #72 to include immediate depth edits in a future Stage-wide Undo phase. No runtime code changed by this specification entry. |
| 2026-10-17 | Wrote the Phase 19 Background ground and wall color architecture specification (`docs/specs/phase_19_background_ground_and_wall_color.md`) for final sign-off. It defines distinct inert wall/backdrop scene types over a small shared rectangle contract, world-aligned bounded vector materials, selected-piece color preview without mutating saved tint, one eight-color Stage settings list, scene v1.6 migration of old Brick/Wood placeholder green, shared Stage inspector controls, and confirmed single-piece backdrop erasure. Logged #74 for zoom-consistent motif budgets and #75 for the old unused wall-color ambiguity. No runtime code changed. |
| 2026-10-20 | Approved the scenery bitmap tile cache architecture (`docs/specs/scenery_bitmap_tile_cache.md`). Lightweight `StageSurfaceCache` handles share immutable bitmap tiles through one `StageSurfaceBakePool` under the current scene, with a 64 MiB resident bitmap budget and one reusable offscreen baker. Camera-visible tiles use four fixed zoom levels (0.125, 0.5, 1, 2); the coarsest tile bakes first and refined detail overlays it. At maximum 6× zoom, the 2× bitmap may look softer because it is magnified threefold. Erased fragments clip shared tiles only when their complete pattern sampling phase matches. The previous per-surface viewport cache retained vector commands and had no scene-wide resource limit, a likely contributor to `Element limit reached`; the exact RID pool remains unconfirmed. GPU readback during bitmap capture and regeneration after budget eviction are accepted costs to measure during implementation. |
| 2026-10-20 | Replaced the positional scenery tile design after the user observed continuous buffering when panning after zoom. The accepted revision in `docs/specs/scenery_bitmap_tile_cache.md` bakes one seamless, 2× bitmap per finish/tint period and uses GPU repeat plus generated mipmaps for all camera positions and zooms. Erased fragments share the style texture and crop it using their existing sample coordinate. The scene-wide 64 MiB limit includes mipmaps; inactive style variants may be evicted, while recently used styles stay warm when space allows. This trades unique random detail across an unlimited wall for finite repeated variation and keeps the existing possible 3× bitmap enlargement at maximum zoom. A style change can bake once; repeated camera movement must not increment build or readback counts after styles are ready. |
| 2026-10-21 | Drafted the camera-aware Sky architecture addendum in `docs/specs/phase_19_scenery_backgrounds_surface_finishes.md` for user sign-off. Accepted a fixed built-in camera reference, one bounded response profile per preset, and normalized motif anchors with uniform scaling. The gradient remains a full-screen `CanvasLayer` draw; motifs derive deterministic bounded pan/zoom response from absolute `Camera2D` framing. Chooser and starter previews stay unbound and still; existing Sky/camera scene fields suffice. The trade-offs are response saturation far from the reference and more frequent vector redraws during camera movement; logged #76 for measured performance follow-up. No runtime code or tests changed. |
| 2026-10-21 | Wrote the Phase 19 starter-scene composition UI/UX and architecture specifications (`docs/specs/phase_19_starter_scene_composition_ui_ux.md`, `docs/specs/phase_19_starter_scene_composition.md`) after approval of the Everyday Storybook brief and the 1,280-pixel / ±1,000-world-unit pan defaults. The edited starter snapshots use current scene v1.6 and existing Ground, wall, Background ground, Scenery, and Prop types, and keep the camera unrestricted; the starter card derives a central opening preview without new scene metadata. No code or art changed. Added #77 for the finite authored-envelope and broad-selection trade-off; the architecture spec is approved and implementation/agent verification are underway.
| 2026-10-21 | Implemented the approved starter-scene composition spec. Town Street and Living Room now use continuous 4,760-unit support surfaces, aligned bases, and camera-derived starter-card framing; both edited scene files use current v1.6. The focused production-load/save-reopen/PLAY composition test passed 177 assertions, with measured visible-contact geometry within 0.225 logical pixels. The broader scenery suite exits 1 on nine other background-card and wall-erasure/capture-bar assertions, while the new surface assertions pass. GUI capture exited 134 and headless capture produced no image, so rendered-pixel checks, pan-endpoint seam inspection, narrow viewport/zoom-in framing, and user/editor verification remain pending. Updated #77 from planned to implemented with the finite-camera limitation accepted. See `docs/specs/phase_19_starter_scene_composition.md`.
| 2026-10-21 | Revised the starters after the user's Town Street screenshot exposed Sky clouds below the skyline and the user rejected oversized scaled Ground blocks. Added an opaque Grass Background ground, replaced the sidewalk and both starter floors with scale-1 tile rows, and extended Asphalt by one tile at each end so the road is accessible. The focused production-path test passed 515 assertions, including navigation across tile seams, sidewalk-to-road routing, and a PLAY actor landing grounded on Asphalt. The broader scenery suite still exits 1 on the same nine Sky-preview/gallery and Scenery-edit UI assertions observed before this revision; its new surface assertions pass. A bounded GUI capture exited 134 without an image, so rendered pixels, pan endpoints, card previews, narrow viewport/zoom-in states, and user/editor review remain pending. See `docs/specs/phase_19_starter_scene_composition.md`.
| 2026-10-22 | Prepared and revised the Phase 19 Plain Background architecture specification (`docs/specs/phase_19_plain_background.md`) for final user sign-off. The accepted composed Stage backdrop view serves live Stage and previews; scene v1.7 persists active kind, last Sky ID, and confirmed plain color while migrating all older scenes as Sky. Color drafts stay out of Save/autosave, and theme-backed grid and two-tone gizmo overlays address light fills without changing art. Per user feedback, the current-color chip is the sole custom-picker entry, the separate More colors button is removed, and the custom picker uses a simple Preview label without a temporary-state helper message. Logged #78 for the chooser extension and rendered verification gates. No runtime code or tests changed. |
_End of Log_
+367
View File
@@ -0,0 +1,367 @@
# Editor Visual Style Guide
**Scope:** Establish a practical visual and interaction direction for the Prop Editor and Stickman Editor, using the Sandbox Stage UI as a product reference.
**Status:** Guidance for future editor design and implementation. It documents current patterns and gaps; it does not assert that the editors already share the Stage theme or components.
## 1. Product direction
Both editors should feel like approachable creative workspaces in the same Stickman Studio product:
- Keep the canvas or preview as the main work surface. Put tools and settings around it, not on top of the artwork unless a temporary task requires an overlay.
- Make the current task and mode easy to recognize. Use direct labels such as **Arrange**, **Reshape**, **Attach**, **Save**, and **Cancel**.
- Prefer visible feedback on the object over abstract state: selection outlines, vertex handles, previews, mode coloring, and status text.
- Preserve a stable layout while the child works. Opening an inspector section, showing help, or changing a mode should not make the active control jump away from the pointer.
- Make recovery and destructive actions predictable. Show what is about to happen and keep a visible way to cancel.
- Share a visual language, not necessarily identical panel layouts. A vector canvas, a modal dialog, an in-stage inspector card, and a stickman-part panel have different jobs.
The Sandbox Stage establishes a useful baseline for hierarchy, contrast, child-facing language, control states, safe viewport behavior, and theme tokens. Its in-stage inspector and capture bar are Stage patterns; they should be adapted only when an editor workflow has the same interaction needs.
## 2. Current implementation map
| Surface | Current implementation | Reuse implication |
| --- | --- | --- |
| Sandbox Stage | SandboxTheme loads validated tokens from [sandbox_theme.json](../../sandbox_theme.json). Stage inspector controls live under [scripts/shared/stage_ui](../../scripts/shared/stage_ui/). | Use as the product reference for semantic colors, readable type, focus, action roles, and reusable theme access. |
| Stage inspector | The approved [Stage panel UI system](../specs/stage_panel_ui_system.md) targets in-stage selected-object cards. | Do not insert StageInspectorPanel into editor inspectors by default. Borrow fitting rules and state behavior; use its components only if the layout and ownership match. |
| Stage dialogs | [StageDialogStyler](../../scripts/shared/stage_ui/stage_dialog_styler.gd) styles child-facing dialogs hosted by the Stage overlay. | Reuse its visual contract. Its overlay host and lifecycle are Stage-specific unless deliberately generalized. |
| Prop Editor | [PropTheme](../../scripts/editors/prop/prop_theme.gd) reads [prop_theme.json](../../prop_theme.json); the controller creates a local inspector Theme. The main scene uses split containers, a scrollable inspector, modes, and a nested Attach preview. | Keep values token-driven and scoped. Current Prop colors and target sizes differ from Stage tokens. |
| Stickman Editor | [stickman_editor.gd](../../scripts/editors/stickman/stickman_editor.gd) coordinates ten BodyPartPanels and the Whole Stickman preview. The scene currently uses fixed top-bar offsets and a three-column workspace. | The canvas/preview model is established, but it has no apparent SandboxTheme or PropTheme integration. Harmonization is future work. |
| Child-facing dialogs | [Dialog Design Principles](../specs/dialog_design_principles.md) documents the rounded, labeled-action pattern and stable resizing behavior. | Apply these rules to editor dialogs, adapting ownership and host behavior to each editor. |
The Prop Editor user guide at [prop_editor_user_guide.md](prop_editor_user_guide.md) describes its current controls and layout. Use it to avoid documenting proposed changes as existing behavior.
## 3. Shared visual system
### 3.1 Semantic color roles
Use color to reinforce meaning consistently. Keep labels, shapes, icons, or state text alongside color so meaning survives grayscale, low contrast, and color-vision differences.
| Role | Sandbox Stage reference | Editor guidance |
| --- | --- | --- |
| Main background | global.bg_color #171B24F2 | Use a dark neutral around the work area; avoid competing with authored art. |
| Raised surface | global.surface_color #252B38FA | Use for toolbars, inspector shells, and cards. Distinguish adjacent surfaces by value, border, or spacing. |
| Primary text | global.text_color #F5F7FA | Use for titles, control names, and important values. |
| Secondary text | global.muted_text_color #AAB2C0 | Use for helper text and secondary status; keep readable at the chosen size. |
| EDIT accent | mode_colors.edit #22C6FF | Suitable for active edit mode, selection accents, and editor guidance. It is not the current PropTheme accent. |
| DIRECT accent | mode_colors.direct #FFB300 | Reserved for the Stage's directing mode. Do not use as a generic warning color in editors. |
| PLAY accent | mode_colors.play #33DD77 | Reserved for Stage playback state. |
| Primary action | Stage dialog/action blue #087DBA | Use for the single most important forward action in a dialog. |
| Destructive action | Stage danger #E66B75 with dark red surfaces | Use only for destructive actions; pair with explicit wording and a cancel path. |
| Focus | Stage dialog focus #8BD9FF | Give keyboard focus an outline that remains visible over selected, hovered, and dark states. |
| Canvas grid | Stage tokens vary with canvas treatment; Prop canvas currently uses #FFFFFF26. | Keep grid lines subtle. The work, selection, and handles must dominate the grid. |
These values are current Stage reference values, not a mandate to paste literal hex values into editor controls. Resolve colors through validated theme accessors. If an editor needs a warmer or quieter local accent, define a semantic editor token and document its mapping to Stage meaning.
### 3.2 Current token differences to resolve during harmonization
The current palettes are related but not at parity:
- PropTheme uses ui.colors.accent **#4FA3FF**, while SandboxTheme EDIT uses **#22C6FF**.
- PropTheme defaults its button and row minimum heights to **36 px** and its mode button minimum height to **56 px**. Stage action targets are **48 px**, and child-facing UI guidance calls for at least **44 × 44 logical pixels**.
- PropTheme's canvas is #1F1F1F; its inspector token is #262C39. The Stage uses #171B24F2 / #252B38FA for global background and surface.
- The older Stickman Editor has no apparent editor theme integration. Its scene uses 32 px menu/top bars, a 28 px status bar, 4 px preview corners, and a 14 px preview title; body-part headings are around 13–14 px. It defines preview surface and borders directly in the scene.
Treat these as migration work, not evidence that the editors already match the Stage. Before implementation, decide whether to harmonize the accent, minimum targets, border/focus states, and surface roles. Preserve canvas contrast where it serves drawing readability.
### 3.3 Type, spacing, and shape
Use these Stage examples as starting points, then tune them for the denser editor workspace:
- Stage inspector hierarchy: title **24 px**, section **20 px**, body **20 px**, helper **16 px**.
- Stage inspector shell: **12 px** padding, **8 px** header gap, **12 px** section gap, **10 px** corner radius.
- Stage choice row: **56 px** minimum height; Stage toggle row: **64 px** minimum, increasing when helper text is visible.
- Large child-facing dialogs: title about **28–30 px**, body about **20 px**, button about **20 px**, **2 px** borders, **12–18 px** corner radii, and generous footer spacing.
- Stage generally uses dark surfaces with a restrained border. The dialog guide's stronger borders and rounder controls are for modal emphasis, not a requirement to put every editor widget inside a thick outline.
For compact inspectors, shorten the hierarchy only after checking it at the intended window size. Prefer a clear label and value over shrinking type until it becomes difficult to read. Use container spacing and alignment rather than per-control position nudges.
### 3.4 State styling contract
Every interactive control should have distinct, inspectable states:
| State | Expected feedback |
| --- | --- |
| Neutral | Stable surface and legible label; no implied selection. |
| Hover | Small surface or border change. Must not resemble selected or focused. |
| Selected / active | Persistent fill, border, pressed state, or explicit status. Keep the label unchanged when possible. |
| Keyboard focus | High-contrast focus ring or border that is visible even over selected fill. |
| Disabled | Control cannot activate, remains readable, and explains why when the reason is not obvious. |
| Dragging / live preview | Immediate object feedback; indicate the active handle or preview and make cancellation discoverable. |
| Error / invalid | Explain the problem near the affected task and offer a concrete next step. Do not rely on red alone. |
| Saved / dirty | Show a concise, persistent status such as **Saved** / **Unsaved changes** where it helps prevent lost work. |
For drawing handles, keep a contrasting backing/halo where the canvas can contain arbitrary colors. The Prop canvas's two-tone selection halo and the Stage gizmo backing are useful precedents. Selection should be readable at all zoom levels; keep handle sizes in screen space when scaling the canvas.
## 4. Layout and reuse rules
### 4.1 Use the right pattern for the job
- **In-stage selected-object inspector:** follow StageInspectorPanel, choice groups, and toggle rows in the [Stage UI spec](../specs/stage_panel_ui_system.md). Its title/Close header, fixed header with scrolling body, and safe-rect fitting are specific to a floating selection card.
- **Editor inspector:** favor a persistent column beside the canvas, with labeled groups and vertical scrolling. The Prop Editor already uses a split container and scrollable inspector. Reuse spacing, hierarchy, colors, and control states; do not force the floating Stage card structure into this column.
- **Temporary targeting step:** the Stage capture bar is for a short-lived click-on-stage task. An editor can borrow the prompt-plus-visible-Cancel contract when it has a similar temporary canvas capture step, but should not copy the bar just because it is visible in Stage.
- **Modal dialog:** follow [Dialog Design Principles](../specs/dialog_design_principles.md). Keep a labeled **Cancel**, **Done**, or **Back** action; use one visually clear primary action; preserve the clicked control's anchor when content grows; cap height and scroll content on short viewports.
- **Canvas tools:** keep mode and document controls outside the draw area where possible. Put contextual handles and temporary prompts near the object only if they do not obscure the task.
### 4.2 Reuse through tokens and small components
1. Put repeated colors, font sizes, minimum sizes, spacing, radii, border widths, and state colors in a validated theme source.
2. Use typed accessors with sensible fallbacks. Avoid repeating raw values across script-built controls and .tscn files.
3. Apply per-control StyleBox overrides. Do not mutate a cached/shared Theme to style one editor or one accent variant.
4. Extract a reusable control only after the behavior or presentation appears in at least two appropriate contexts. Keep domain rules and document mutations in the editor controller.
5. Prefer native Button, ButtonGroup, CheckBox, OptionButton, and LineEdit semantics. Custom-drawn controls should separate visual drawing from input and expose a clear typed API.
6. Keep canvas-specific rendering tokens separate from chrome tokens. Grid, artwork, shape fill, selection halo, vertex handles, and preview ghosts have different contrast jobs.
7. Do not serialize UI state unless it is part of the authored document. Tool preference, document content, preview-only state, and persistence should be deliberately separated.
The current code already demonstrates local theme ownership in Prop Editor (_ui_theme) and Stage token validation/cache behavior in SandboxTheme. This guide recommends extending those strengths; it does not claim the editor themes are currently unified.
### 4.3 Responsive workspace behavior
- Preserve canvas priority. When space is constrained, inspector content scrolls before drawing tools or essential canvas controls are clipped.
- Keep splitters draggable and give both sides a useful minimum. Prop Editor has the outer canvas/inspector split and an Attach preview split; these are good workspace patterns to retain.
- Use container-driven rows (FlowContainer, HBoxContainer, VBoxContainer) instead of fixed offsets for control groups that can wrap or grow with theme fonts.
- Keep top actions and mode controls reachable at smaller window sizes. Wrap or stack controls only when necessary, and preserve their order and mode meaning.
- Keep toolbars, status bars, and dialogs inside safe margins. A dynamic dialog should grow from a stable anchor and scroll rather than jump or recenter after each choice.
- For Stickman Editor, the current scene uses fixed top bar offsets and a three-column layout. A responsive pass should specifically validate narrower windows, custom fonts, longer translations, and panel resizing before claiming parity with Prop Editor's split workspace.
## 5. Editor flows and state maps
The following maps describe the current interaction architecture at a level useful for future visual work. They are not proposed additions to file formats.
### 5.1 Prop Editor flow
Current primary flow, based on [prop_editor.gd](../../scripts/editors/prop/prop_editor.gd) and [prop_canvas.gd](../../scripts/editors/prop/prop_canvas.gd):
~~~mermaid
flowchart TD
Open[Open Prop Editor] --> Recovery{Recover autosave?}
Recovery -->|Restore| Dirty[Restore document as dirty]
Recovery -->|Discard / none| New[New or loaded document]
Dirty --> Arrange[Arrange shapes]
New --> Arrange
Arrange --> Reshape[Reshape vertices]
Arrange --> Attach[Attach anchors and preview]
Reshape --> Arrange
Attach --> Arrange
Attach --> Reshape
Arrange --> Save[Save / Save As]
Reshape --> Save
Attach --> Save
Save --> Clean[Saved status; autosave cleared]
Arrange --> Exit{New / close with changes?}
Reshape --> Exit
Attach --> Exit
Exit -->|Save| Save
Exit -->|Discard| Close[Close or start new]
Exit -->|Cancel| CurrentMode[Return to current mode]
~~~
Presentation rules for this flow:
- Arrange, Reshape, and Attach are persistent tool modes. Keep the active mode visually selected and keep mode labels stable.
- Mode changes may change available controls and inspector sections. Explain missing controls through a short label or helper only when the reason is not obvious from the mode.
- Shape/anchor edits change the document and should visibly change dirty status. Preview motion, visibility toggles, and other session-only controls should not imply the document was edited.
- Saving should confirm completion in the status area or title state. Errors should identify the failed file action and retain the child's work.
- New and close with unsaved changes offer explicit **Save**, **Discard**, and **Cancel** outcomes. For actions that could replace work, name the consequence in the prompt.
- In Attach mode, show the selected anchor and its editable handles clearly on the canvas, and keep the private stickman/prop preview visually separate from the authoring surface.
### 5.2 Stickman Editor flow
Current primary flow, based on [stickman_editor.gd](../../scripts/editors/stickman/stickman_editor.gd), [body_part_panel.gd](../../scripts/editors/stickman/body_part_panel.gd), and [whole_stickman_preview.gd](../../scripts/editors/stickman/whole_stickman_preview.gd):
~~~mermaid
flowchart LR
LoadOrStart[Load or start stickman] --> Parts[Choose a body-part drawing panel]
Parts --> Draw[Draw and color one or more shapes]
Draw --> Select[Select shape or vertex]
Select --> Preview[Inspect whole-stickman assembly]
Preview --> Guide[Toggle pose guide / reset views]
Guide --> Parts
Parts --> Save[Save stickman]
Save --> Continue[Continue editing]
LoadOrStart --> Clear[Clear with confirmation]
Clear --> Parts
~~~
Presentation rules for this flow:
- Make the active body-part panel and active shape unambiguous. Selecting a shape in one panel clears selection in the others.
- Keep local drawing context and the assembled Whole Stickman preview distinct but visually related. A change in one body part should have a prompt, visible effect in the assembly preview.
- Use the pose guide as quiet construction help beneath selection gizmos. Guide colors distinguish left/right limbs; the guide must not capture pointer input or obscure active edits.
- Keep color editing and recent colors close to the relevant part, and confirm a color only through the explicit dialog action.
- Keep grid/snap and guide visibility available as tool preferences, visually separated from authored shape color and geometry.
### 5.3 Reusable dialog transaction
For either editor, the expected child-facing sequence is:
1. State the action in the title and one short sentence.
2. Show the effect or consequence when it may be surprising.
3. Present one primary action, any needed destructive action, and a visible **Cancel**.
4. Keep focus in the dialog, preserve the current anchor during resize, and return focus to the invoking control after close where practical.
5. On success, close the dialog and update the affected editor state once. On cancel, leave the document unchanged.
This is a target interaction contract for editor dialogs. Existing controls should be checked individually before being treated as compliant.
## 6. Interaction, feedback, and child-facing language
### 6.1 Input and feedback
- Support mouse and touch-size targets; target at least **44 × 44 logical pixels** for child-facing actions. Prefer Stage's **48 px** action target where the layout allows.
- Keep keyboard navigation visible and complete: Tab reaches meaningful controls, Enter/Space activates them, Escape cancels temporary tasks, and focus does not disappear behind a selected style.
- Keep right-click and keyboard shortcuts as optional accelerators. A visible menu item or button should remain available for important operations.
- For dragging, preview changes continuously, show which handle is active, commit on release, and provide a clear cancel path when the operation is consequential or multi-step.
- Avoid hit-test surprises: decorative labels/icons ignore mouse input; a large row toggle has one activation target; a canvas guide is visual only.
- Preserve the world point under the cursor during zoom. Keep selected gizmos and handles visible above artwork and grid.
- Use both visual and textual feedback for snap, mode, selected state, disabled reason, save state, and errors.
The Stage panel's full-row toggle and stable help layout are useful behaviors when a row switch is appropriate. In an editor inspector, apply the same principles without copying the Stage card's floating shell.
### 6.2 Copy and labels
Prefer concise, concrete verbs and familiar terms:
| Prefer | Avoid |
| --- | --- |
| **Arrange**, **Reshape**, **Attach** | Technical labels such as “Transform mode” or “Edit points mode” as the main child-facing label |
| **Add Shape**, **Erase Shape**, **Create Point**, **Remove Point** | Ambiguous labels such as “Apply” when the result can be named |
| **Save**, **Save As…**, **Load**, **Cancel** | Icon-only actions with no tooltip or accessible label |
| “This shape was reshaped.” | A disabled Radius field with no explanation |
| “Choose where this prop attaches.” | Internal terms such as “anchor payload” |
| “Unsaved changes. Save before starting a new prop?” | Generic “Are you sure?” without naming the consequence |
Emoji can help distinguish common actions, as used by Prop Editor menus, but should be an optional cue rather than the only meaning. Keep the text label understandable if an emoji font is unavailable. Use the same wording for the same action in menu, dialog, toolbar, tooltip, and user guide.
### 6.3 Icons and emoji
- Use one icon or emoji to help scan a familiar action; do not put emoji on every label by default.
- Keep the text label as the source of meaning. Provide a tooltip or accessible name for icon-only canvas tools.
- Use icons consistently for the same action across menus, toolbars, empty states, and dialogs. Avoid changing icon meaning between editors.
- Avoid using an emoji's color as the only state cue; fonts and platforms may render it differently.
- The Prop Editor already uses emoji in File and mode labels. The Stickman Editor currently uses plain menu labels. Treat a visual icon pass as a recommendation that should be applied consistently, not as a current shared convention.
### 6.4 Empty, loading, and error states
The current editors have different levels of state guidance. The following table separates known behavior from recommended presentation:
| State | Current reference | Recommended presentation |
| --- | --- | --- |
| New/empty Prop canvas | Arrange has an add-shape context menu and an Arrange-only empty-canvas hint; Reshape does not offer adding a shape. | Keep the canvas mostly clear. Show a short Arrange hint only when it helps discover the next action; hide it as soon as artwork exists. Do not show a dead-end hint in Reshape or Attach. |
| Empty Prop inspector selection | Inspector sections depend on mode and selected shape/anchor. | State what to select or create when the empty inspector would otherwise look broken. Keep the message short and do not add a second competing empty-canvas prompt. |
| Prop load/recovery | Load failures use an error dialog; autosave recovery offers Restore or Discard. | Name the file/action in the message, preserve the current artwork on failure, and keep Restore/Discard outcomes distinct. Loading feedback should not imply that the document is already saved. |
| Stickman panel before drawing | The ten body-part panels are the main workspace; the panel script owns drawing and selection behavior. | Give each blank panel a concise, low-contrast drawing cue if discovery testing shows it is needed. Remove it on first shape so it does not compete with artwork. |
| Stickman load/clear | Load and save use file dialogs; Clear has a confirmation dialog; error dialogs report failures. | Keep the current document intact on a failed load. State the consequence in Clear confirmation and offer a visible cancel path. |
| Save / dirty state | Prop Editor has status/title and autosave behavior; Stickman Editor status bar reports cursor coordinates and snap state. | Make save status easy to find in both editors. Do not treat autosave as a user-visible successful save unless the file was actually saved to the chosen destination. |
Do not add spinners or blocking overlays for synchronous work without evidence that the operation takes long enough to need them. If an operation becomes asynchronous, keep progress near the task and preserve access to cancellation where safe.
### 6.5 Motion and reduced motion
- Use motion to show cause and effect: a brief selection flash, a panel entering, or a successful action cue. Keep it short and do not delay the next input.
- Do not animate every hover or value change. Motion should help the child understand an edit, not decorate idle controls.
- Keep persistent mode and selection state visible after any transition ends.
- Respect a reduced-motion preference for nonessential transitions and flashes. Provide an equally clear static state.
- The current code has short visual effects such as the Prop canvas's new-point flash and Stage completion effects. A unified reduced-motion preference is not established by the referenced theme/specs; treat it as a future product-level decision.
## 7. Theme and implementation checklist
Before adding or revising an editor control:
### Design
- [ ] Is the control's job clear and is it in the correct workspace area?
- [ ] Does it follow the correct pattern: persistent inspector, modal dialog, canvas tool, or temporary capture?
- [ ] Are neutral, hover, selected, focused, disabled, dragging, and error states defined?
- [ ] Does selected state remain distinct from hover and focus?
- [ ] Are colors backed by labels, icons, position, or shape cues where appropriate?
- [ ] Are labels child-readable and consistent with the existing action vocabulary?
- [ ] Can the layout handle smaller windows, longer names, and larger font presets?
### Theme
- [ ] Are colors, type sizes, spacing, radii, borders, and target sizes tokens rather than repeated literals?
- [ ] Does the token use a semantic name rather than a control-specific unexplained hex?
- [ ] Does it have a validated accessor, fallback, and range clamp where applicable?
- [ ] Are per-control style changes local and isolated from a shared cached Theme?
- [ ] Is the difference from Stage tokens intentional and documented?
### Interaction
- [ ] Can pointer, touch, and keyboard users reach and activate the control?
- [ ] Does a disabled control explain why, if not self-evident?
- [ ] Does temporary work have an obvious Cancel/undo path?
- [ ] Does a control's position remain stable while help, validation, or mode content changes?
- [ ] Does changing a tool preference avoid marking the document dirty unless that is intentional?
- [ ] Does success/failure produce visible feedback without hiding the canvas or losing authored work?
### Architecture
- [ ] Does the editor controller own document mutation, persistence, and dirty state?
- [ ] Does a reusable control emit a typed intent and display a supplied value?
- [ ] Is new reuse actually shared by more than one appropriate UI context?
- [ ] Are UI preferences, preview-only state, and document data kept separate?
- [ ] Are new theme values represented in both default config and committed JSON?
- [ ] Are existing user guides/specs updated only after the behavior is verified?
### Implementation recipe
For a new editor UI element, use this sequence:
1. Name its job and state owner. Decide whether the editor controller, canvas, or reusable view owns the value and mutation.
2. Identify the closest reference pattern: Stage inspector, Stage dialog, Prop Editor mode control, Prop canvas selection, or Stickman body-part panel. Record what should carry over and what should not.
3. Define the semantic tokens and states before styling. Reuse existing token names where their meaning matches; add new names to the theme defaults and JSON with validation/fallback behavior.
4. Build with Godot containers and native controls first. Add a custom control only when it provides repeated behavior or custom canvas drawing.
5. Wire typed intent signals to the owning controller. Refresh controls from document state without emitting a second mutation.
6. Check the smallest supported window, larger type, long labels, all modes, selected/disabled/focused states, and save/error/cancel paths.
7. Capture before/after screenshots or a short manual checklist. Update the user guide after the behavior is accepted.
This is recommended implementation order, not a claim that every current editor control follows it.
### Recommended harmonization order
1. **Agree on token ownership and names.** Define shared semantic roles for background, surface, text, muted text, EDIT accent, focus, primary/destructive action, spacing, radii, borders, and minimum targets. Keep canvas-specific colors separate.
2. **Resolve token drift.** Decide whether Prop's #4FA3FF accent and 36 px button/row minima should move toward the Stage reference, or remain intentional editor overrides. Record the choice and add an accessibility rationale for target sizes.
3. **Bring Stickman chrome into token ownership.** Move its scene-level colors, font sizes, spacing, and bar sizing into a validated theme source while preserving the panel/preview composition that supports drawing.
4. **Improve responsive layout.** Replace brittle fixed offsets where font or window growth can clip controls; establish minimum column widths and wrapping behavior for both editors.
5. **Unify states and dialogs.** Verify focus, disabled reasons, save/error status, destructive confirmation, and cancel behavior. Reuse dialog styling only after its host/lifecycle works in the editor.
6. **Validate visually at agreed sizes.** Compare the editor screenshots with the Stage reference and adjust intentional differences explicitly.
This order is a recommendation. It does not select a new shared-theme class or authorize a source migration; choose the theme architecture when implementation scope is defined.
### Visual QA checklist
- [ ] Compare Prop and Stickman editor startup screens side by side with the Stage's dark surface and EDIT accent references.
- [ ] Check every mode with no selection, with a selected object, during drag, and after release/cancel.
- [ ] Check primary action, destructive action, disabled control, hover, keyboard focus, and persistent selected states.
- [ ] Check prop autosave recovery, load failure, unsaved New/Close, and successful save feedback.
- [ ] Check Stickman Clear confirmation, load failure, snap status, pose guide, and cross-panel selection.
- [ ] Resize to the smallest supported viewport and increase font size. Confirm canvas access, scroll access, and nonoverlapping controls.
- [ ] Verify visible keyboard order and Return/Space/Escape behavior; confirm focus returns to the invoking control where appropriate.
- [ ] Check grid, artwork, selection halo, vertex handles, pose guide, anchor markers, and preview ghosts against light and dark artwork.
- [ ] Check text contrast and state distinction with a contrast measurement and color-vision simulation; the current specs do not provide measured results.
- [ ] Repeat the checks with emoji rendering unavailable or replaced; action meaning must remain clear from text.
## 8. Recommended additional coverage
To make this guide a stronger long-term design reference, add these pieces as the editor UI evolves:
1. **Annotated screenshots at canonical window sizes.** Include each editor's initial screen, every mode, selection/reshape states, Attach preview, and one modal. Mark which visuals are current and which are target.
2. **A shared token inventory.** Decide semantic names and authoritative ownership for surfaces, accents, focus, destructive actions, canvas grid, selection halo, typography, spacing, and minimum target sizes. Reconcile SandboxTheme, PropTheme, and Stickman Editor scene-level values in a planned migration.
3. **Responsive breakpoints and minimum workspace sizes.** Record minimum usable canvas/inspector widths, when controls wrap, how the Attach preview stacks, and how Stickman Editor's three columns adapt.
4. **Accessibility and contrast acceptance checks.** Specify contrast targets, keyboard tab order, focus visibility, touch target checks, color-vision-safe cues, and high-font-size behavior. The current references provide useful states and target sizes but do not establish a measured accessibility audit.
5. **Motion and sound rules.** Define when transitions, selection flashes, save feedback, and error feedback animate or use sound; keep them brief, informative, and respectful of reduced-motion preferences.
6. **Content and localization rules.** Include label length budgets, wrapping/truncation behavior, tooltip standards, pluralization, and translation expansion checks.
7. **Editor-specific control patterns.** Document mode bars, drawing canvases, color pickers, anchor lists, selection gizmos, empty states, and destructive file flows as reusable examples with their owning state and signals.
8. **Verification matrix.** Link each visual contract to a scene/test/manual check so future changes can distinguish an implemented pattern from a recommendation.
## 9. Reference files
- [AGENTS.md](../../AGENTS.md) — repository layout, editor architecture, shared Stage UI, and child-facing dialog guardrails.
- [Stage panel UI system](../specs/stage_panel_ui_system.md) — Stage inspector card, transient capture bar, component contracts, fitting, and acceptance criteria.
- [Dialog Design Principles](../specs/dialog_design_principles.md) — child-facing modal shell, action hierarchy, and stable resize behavior.
- [SandboxTheme implementation](../../scripts/stage/sandbox_theme.gd) and [SandboxTheme config](../../sandbox_theme.json).
- [PropTheme implementation](../../scripts/editors/prop/prop_theme.gd) and [PropTheme config](../../prop_theme.json).
- [Prop Editor controller](../../scripts/editors/prop/prop_editor.gd), [Prop Editor scene](../../scenes/editors/prop/prop_editor.tscn), and [Prop Editor guide](prop_editor_user_guide.md).
- [Prop canvas](../../scripts/editors/prop/prop_canvas.gd) — mode behavior, canvas gestures, selection handles, and context menus.
- [Stickman Editor controller](../../scripts/editors/stickman/stickman_editor.gd), [Stickman Editor scene](../../scenes/editors/stickman/stickman_editor.tscn), [BodyPartPanel](../../scripts/editors/stickman/body_part_panel.gd), and [Whole Stickman preview](../../scripts/editors/stickman/whole_stickman_preview.gd).
- [Stage dialog style helper](../../scripts/shared/stage_ui/stage_dialog_styler.gd) and [Stage capture bar](../../scripts/editors/stage/stage_use_target_capture_bar.gd).
- [Prop Editor UX specification](../specs/phase_9c_prop_editor_ux.md) — current approved Prop Editor mode, Attach preview, marker, and layout behavior.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,191 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1800" height="1260" viewBox="0 0 1800 1260" role="img" aria-labelledby="title desc">
<title id="title">Editor visual harmonization: three design directions</title>
<desc id="desc">A comparison board showing Familiar Studio, Adaptive Studio, and Focused Maker concepts for the Prop and Stickman Editors.</desc>
<defs>
<style>
.bg { fill: #171b24; }
.card { fill: #202633; stroke: #465064; stroke-width: 2; }
.card-rec { fill: #202b39; stroke: #22c6ff; stroke-width: 3; }
.surface { fill: #252b38; stroke: #596477; stroke-width: 1.5; }
.work { fill: #171d28; stroke: #657184; stroke-width: 1.5; }
.canvas { fill: #20232b; stroke: #7f8ca0; stroke-width: 1.5; }
.cyan { fill: #22c6ff; }
.cyan-stroke { stroke: #22c6ff; }
.blue { fill: #087dba; }
.muted { fill: #aab2c0; }
.text { fill: #f5f7fa; font-family: Arial, sans-serif; }
.small { font-size: 17px; }
.body { font-size: 20px; }
.label { font-size: 18px; font-weight: 700; }
.h2 { font-size: 30px; font-weight: 700; }
.title { font-size: 40px; font-weight: 700; }
.chip { fill: #2d3748; stroke: #596477; stroke-width: 1; }
.active-chip { fill: #103b4c; stroke: #22c6ff; stroke-width: 2; }
.line { stroke: #667286; stroke-width: 1.5; }
.accent-line { stroke: #22c6ff; stroke-width: 3; }
.selected { fill: none; stroke: #22c6ff; stroke-width: 3; }
.outline { fill: none; stroke: #f5f7fa; stroke-width: 2; }
.figure { fill: none; stroke: #aab2c0; stroke-width: 4; stroke-linecap: round; }
</style>
</defs>
<rect class="bg" width="1800" height="1260"/>
<text class="text title" x="48" y="62">Two editors, one visual language</text>
<text class="text body muted" x="48" y="98">Phase 0a · Three layout directions · Shared tokens and accessible states in every option</text>
<!-- Concept A -->
<g transform="translate(42,132)">
<rect class="card" width="556" height="1080" rx="20"/>
<text class="text h2" x="26" y="46">A. Familiar Studio</text>
<text class="text small muted" x="26" y="76">Keep today’s workspace arrangement</text>
<rect class="chip" x="26" y="94" width="176" height="34" rx="17"/>
<text class="text label small" x="42" y="117">LOWEST CHANGE</text>
<text class="text label" x="26" y="166">PROP EDITOR · WIDE</text>
<rect class="surface" x="26" y="180" width="504" height="260" rx="12"/>
<rect class="surface" x="36" y="190" width="484" height="38" rx="8"/>
<text class="text small" x="48" y="215">File Save <tspan fill="#aab2c0">Unsaved changes</tspan></text>
<rect class="active-chip" x="319" y="197" width="82" height="26" rx="13"/>
<text class="text small" x="330" y="216">Arrange</text>
<rect class="chip" x="405" y="197" width="68" height="26" rx="13"/>
<text class="text small" x="415" y="216">Reshape</text>
<rect class="chip" x="477" y="197" width="36" height="26" rx="13"/>
<text class="text small" x="484" y="216">…</text>
<rect class="canvas" x="36" y="237" width="322" height="192" rx="8"/>
<path class="figure" d="M196 278 L196 363 M196 297 L151 337 M196 297 L242 337 M196 363 L163 406 M196 363 L230 406"/>
<circle class="selected" cx="196" cy="278" r="16"/>
<rect class="surface" x="368" y="237" width="152" height="192" rx="8"/>
<text class="text small" x="381" y="263">Inspector</text>
<rect class="chip" x="380" y="275" width="128" height="34" rx="7"/>
<text class="text small" x="393" y="298">Fill color</text>
<rect class="chip" x="380" y="317" width="128" height="34" rx="7"/>
<text class="text small" x="393" y="340">Outline</text>
<rect class="blue" x="380" y="365" width="128" height="42" rx="9"/>
<text class="text small" x="403" y="392">Add Shape</text>
<text class="text label" x="26" y="484">STICKMAN EDITOR · WIDE</text>
<rect class="surface" x="26" y="498" width="504" height="330" rx="12"/>
<rect class="surface" x="36" y="508" width="484" height="35" rx="8"/>
<text class="text small" x="48" y="532">File Save View <tspan fill="#aab2c0">Saved</tspan></text>
<rect class="work" x="36" y="552" width="148" height="264" rx="8"/>
<rect class="work" x="194" y="552" width="148" height="264" rx="8"/>
<rect class="work" x="352" y="552" width="158" height="264" rx="8"/>
<text class="text small" x="48" y="577">Head · Torso</text>
<text class="text small" x="206" y="577">Arms · Legs</text>
<text class="text small" x="364" y="577">Whole Stickman</text>
<g fill="#252b38" stroke="#586477">
<rect x="46" y="591" width="58" height="62" rx="6"/><rect x="113" y="591" width="58" height="62" rx="6"/>
<rect x="46" y="662" width="58" height="62" rx="6"/><rect x="113" y="662" width="58" height="62" rx="6"/>
<rect x="46" y="733" width="58" height="62" rx="6"/><rect x="113" y="733" width="58" height="62" rx="6"/>
<rect x="204" y="591" width="58" height="62" rx="6"/><rect x="271" y="591" width="58" height="62" rx="6"/>
<rect x="204" y="662" width="58" height="62" rx="6"/><rect x="271" y="662" width="58" height="62" rx="6"/>
</g>
<rect class="selected" x="45" y="590" width="60" height="64" rx="7"/>
<path class="figure" d="M431 603 L431 708 M431 623 L405 651 M431 623 L457 651 M431 708 L411 760 M431 708 L451 760"/>
<text class="text small muted" x="26" y="858">Update styling, targets, focus, and dialogs;</text>
<text class="text small muted" x="26" y="882">keep all ten drawing panels visible.</text>
<line class="line" x1="26" y1="918" x2="530" y2="918"/>
<text class="text label" x="26" y="955">Tradeoff</text>
<text class="text body" x="26" y="986">Least workflow change. The ten small canvases</text>
<text class="text body" x="26" y="1014">stay dense; narrow windows may hide the preview.</text>
</g>
<!-- Concept B -->
<g transform="translate(622,132)">
<rect class="card-rec" width="556" height="1080" rx="20"/>
<text class="text h2" x="26" y="46">B. Adaptive Studio</text>
<text class="text small muted" x="26" y="76">Keep overview; add a workspace that can grow</text>
<rect class="active-chip" x="26" y="94" width="198" height="34" rx="17"/>
<text class="text label small" x="42" y="117">RECOMMENDED</text>
<text class="text label" x="26" y="166">PROP EDITOR · WIDE</text>
<rect class="surface" x="26" y="180" width="504" height="220" rx="12"/>
<rect class="surface" x="36" y="190" width="484" height="38" rx="8"/>
<text class="text small" x="48" y="215">File Save <tspan fill="#aab2c0">Saved</tspan></text>
<rect class="active-chip" x="312" y="197" width="82" height="26" rx="13"/>
<text class="text small" x="323" y="216">Arrange</text>
<rect class="chip" x="398" y="197" width="68" height="26" rx="13"/>
<text class="text small" x="408" y="216">Reshape</text>
<rect class="chip" x="470" y="197" width="42" height="26" rx="13"/>
<text class="text small" x="478" y="216">Attach</text>
<rect class="canvas" x="36" y="237" width="318" height="152" rx="8"/>
<path class="figure" d="M192 265 L192 337 M192 281 L154 312 M192 281 L230 312 M192 337 L164 375 M192 337 L221 375"/>
<circle class="selected" cx="192" cy="265" r="14"/>
<rect class="surface" x="364" y="237" width="156" height="152" rx="8"/>
<text class="text small" x="376" y="263">Scroll settings</text>
<rect class="chip" x="376" y="276" width="132" height="30" rx="7"/>
<rect class="chip" x="376" y="314" width="132" height="30" rx="7"/>
<rect class="blue" x="376" y="352" width="132" height="28" rx="7"/>
<text class="text small" x="399" y="372">Add Shape</text>
<text class="text label" x="26" y="446">STICKMAN · FOCUS ON PART</text>
<rect class="surface" x="26" y="460" width="504" height="354" rx="12"/>
<rect class="surface" x="36" y="470" width="484" height="36" rx="8"/>
<text class="text small" x="48" y="495">File Save <tspan fill="#aab2c0">Unsaved changes</tspan></text>
<rect class="chip" x="36" y="514" width="224" height="34" rx="8"/>
<text class="text small" x="95" y="537">Overview · 10 parts</text>
<rect class="active-chip" x="268" y="514" width="252" height="34" rx="8"/>
<text class="text small" x="330" y="537">Focus on Part</text>
<rect class="active-chip" x="36" y="556" width="484" height="38" rx="8"/>
<text class="text label" x="51" y="581">Body part: Head ▾</text>
<rect class="canvas" x="36" y="602" width="294" height="170" rx="8"/>
<text class="text small muted" x="50" y="625">Head drawing canvas</text>
<circle class="outline" cx="181" cy="688" r="43"/>
<circle class="selected" cx="181" cy="688" r="48"/>
<rect class="surface" x="340" y="602" width="180" height="170" rx="8"/>
<text class="text small" x="354" y="627">Whole Stickman</text>
<path class="figure" d="M430 643 L430 707 M430 656 L410 674 M430 656 L450 674 M430 707 L414 750 M430 707 L446 750"/>
<rect class="surface" x="36" y="780" width="484" height="26" rx="8"/>
<text class="text small" x="48" y="800">Add Shape Arrange Reshape Color + Future tools</text>
<text class="text small muted" x="26" y="848">Overview keeps all ten panels available; Focus makes</text>
<text class="text small muted" x="26" y="874">room for new tools and is useful at any window size.</text>
<line class="line" x1="26" y1="918" x2="530" y2="918"/>
<text class="text label" x="26" y="955">Tradeoff</text>
<text class="text body" x="26" y="986">More workspace and state work; switching views</text>
<text class="text body" x="26" y="1014">must preserve selection and drawing context.</text>
</g>
<!-- Concept C -->
<g transform="translate(1202,132)">
<rect class="card" width="556" height="1080" rx="20"/>
<text class="text h2" x="26" y="46">C. Focused Maker</text>
<text class="text small muted" x="26" y="76">Make one task and canvas the center of both editors</text>
<rect class="chip" x="26" y="94" width="205" height="34" rx="17"/>
<text class="text label small" x="42" y="117">LARGEST REDESIGN</text>
<text class="text label" x="26" y="166">ONE SHARED WORKSPACE PATTERN</text>
<rect class="surface" x="26" y="180" width="504" height="552" rx="12"/>
<rect class="surface" x="36" y="190" width="484" height="38" rx="8"/>
<text class="text small" x="48" y="215">File Save <tspan fill="#aab2c0">Saved</tspan></text>
<rect class="surface" x="36" y="238" width="484" height="50" rx="8"/>
<text class="text label" x="52" y="270">Choose task or body part ▾</text>
<rect class="canvas" x="36" y="298" width="318" height="338" rx="8"/>
<text class="text small muted" x="51" y="324">Large active canvas</text>
<path class="figure" d="M195 382 L195 500 M195 405 L151 444 M195 405 L239 444 M195 500 L160 570 M195 500 L230 570"/>
<circle class="selected" cx="195" cy="382" r="26"/>
<rect class="surface" x="364" y="298" width="156" height="338" rx="8"/>
<text class="text small" x="377" y="325">For this task</text>
<rect class="chip" x="376" y="340" width="132" height="39" rx="7"/>
<text class="text small" x="389" y="366">Color</text>
<rect class="chip" x="376" y="388" width="132" height="39" rx="7"/>
<text class="text small" x="389" y="414">Shape</text>
<rect class="blue" x="376" y="440" width="132" height="42" rx="8"/>
<text class="text small" x="392" y="467">Add Shape</text>
<rect class="chip" x="376" y="492" width="132" height="39" rx="7"/>
<text class="text small" x="389" y="518">Erase Shape</text>
<rect class="active-chip" x="36" y="646" width="484" height="72" rx="9"/>
<text class="text label" x="53" y="675">Preview drawer</text>
<text class="text small" x="53" y="702">Whole Stickman · Attach preview · Open</text>
<text class="text small muted" x="26" y="770">The same task-first frame in Prop and Stickman.</text>
<text class="text small muted" x="26" y="796">The preview opens only when needed.</text>
<line class="line" x1="26" y1="918" x2="530" y2="918"/>
<text class="text label" x="26" y="955">Tradeoff</text>
<text class="text body" x="26" y="986">Strongest focus and consistency, but most users</text>
<text class="text body" x="26" y="1014">must relearn navigation; all ten parts are no</text>
<text class="text body" x="26" y="1042">longer visible at once.</text>
</g>
<text class="text small muted" x="48" y="1240">For discussion only · Palette and labels follow the Editor Visual Style Guide; this is not final UI styling.</text>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

@@ -0,0 +1,78 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000">
<defs>
<pattern id="grid" width="64" height="64" patternUnits="userSpaceOnUse">
<path d="M64 0H0V64" fill="none" stroke="#FFFFFF" stroke-opacity=".055" stroke-width="1"/>
</pattern>
<linearGradient id="sky" x2="0" y2="1"><stop stop-color="#626E7E"/><stop offset="1" stop-color="#D3DEE5"/></linearGradient>
<linearGradient id="day" x2="0" y2="1"><stop stop-color="#52ADE8"/><stop offset="1" stop-color="#DFF5FF"/></linearGradient>
<linearGradient id="room" x2="0" y2="1"><stop stop-color="#DAD0B7"/><stop offset="1" stop-color="#F4E8CF"/></linearGradient>
</defs>
<rect width="1600" height="1000" fill="#2A313B"/>
<rect width="1600" height="1000" fill="url(#grid)"/>
<rect x="84" y="54" width="1432" height="892" rx="30" fill="#202735" stroke="#566173" stroke-width="2"/>
<text x="136" y="123" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="42" font-weight="700">Choose a starter scene</text>
<text x="137" y="160" fill="#AEB8C8" font-family="Arial, sans-serif" font-size="21">Pick a place to begin. You can change the background later.</text>
<!-- Plain Background card -->
<g>
<rect x="134" y="190" width="638" height="302" rx="16" fill="#171C25" stroke="#22C6FF" stroke-width="4"/>
<rect x="146" y="202" width="614" height="170" rx="10" fill="#F5EFE0"/>
<rect x="146" y="202" width="614" height="170" rx="10" fill="none" stroke="#FFFFFF" stroke-opacity=".35" stroke-width="2"/>
<text x="453" y="416" text-anchor="middle" fill="#F8FAFC" font-family="Arial, sans-serif" font-size="28" font-weight="700">Plain Background</text>
<text x="453" y="449" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="20">An empty stage with one color.</text>
<text x="453" y="476" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="20">Choose a new color whenever you like.</text>
</g>
<!-- Open Sky card -->
<g>
<rect x="798" y="190" width="638" height="302" rx="16" fill="#171C25" stroke="#465061" stroke-width="2"/>
<rect x="810" y="202" width="614" height="170" rx="10" fill="url(#sky)"/>
<circle cx="1286" cy="250" r="22" fill="#FFF0A2" stroke="#FFE27A" stroke-width="6"/>
<g fill="#F7FBFF" stroke="#DCE7F0" stroke-width="3">
<ellipse cx="921" cy="275" rx="37" ry="15"/><circle cx="902" cy="268" r="15"/><circle cx="925" cy="261" r="19"/><circle cx="946" cy="271" r="14"/>
<ellipse cx="1115" cy="316" rx="39" ry="14"/><circle cx="1094" cy="309" r="14"/><circle cx="1117" cy="302" r="18"/><circle cx="1140" cy="313" r="13"/>
</g>
<text x="1117" y="416" text-anchor="middle" fill="#F8FAFC" font-family="Arial, sans-serif" font-size="28" font-weight="700">Open Sky</text>
<text x="1117" y="449" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="20">An empty stage with a soft sky.</text>
<text x="1117" y="476" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="20">Sun and clouds, ready for your story.</text>
</g>
<!-- Town Street card -->
<g>
<rect x="134" y="522" width="638" height="302" rx="16" fill="#171C25" stroke="#465061" stroke-width="2"/>
<rect x="146" y="534" width="614" height="170" rx="10" fill="url(#day)"/>
<circle cx="695" cy="566" r="17" fill="#FFE99A"/>
<g fill="#FFFFFF" stroke="#DCE7F0" stroke-width="2">
<ellipse cx="228" cy="574" rx="29" ry="11"/><circle cx="215" cy="568" r="11"/><circle cx="231" cy="563" r="14"/><circle cx="247" cy="572" r="10"/>
<ellipse cx="430" cy="600" rx="32" ry="11"/><circle cx="414" cy="594" r="11"/><circle cx="432" cy="588" r="14"/><circle cx="450" cy="598" r="10"/>
</g>
<rect x="146" y="643" width="614" height="39" fill="#58996B"/>
<rect x="146" y="682" width="614" height="22" fill="#5C626A"/>
<path d="M322 642L393 586L466 642Z" fill="#D86F58" stroke="#282D35" stroke-width="4"/>
<rect x="338" y="642" width="108" height="40" fill="#EAD6AE" stroke="#282D35" stroke-width="4"/>
<rect x="373" y="650" width="28" height="32" fill="#55A6D0" stroke="#282D35" stroke-width="3"/>
<rect x="394" y="612" width="24" height="27" fill="#68B9E2" stroke="#282D35" stroke-width="3"/>
<text x="453" y="752" text-anchor="middle" fill="#F8FAFC" font-family="Arial, sans-serif" font-size="28" font-weight="700">Town Street</text>
<text x="453" y="785" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="20">Cloudy sky, shops, trees,</text>
<text x="453" y="812" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="20">sidewalk, and road.</text>
</g>
<!-- Living Room card -->
<g>
<rect x="798" y="522" width="638" height="302" rx="16" fill="#171C25" stroke="#465061" stroke-width="2"/>
<rect x="810" y="534" width="614" height="170" rx="10" fill="url(#room)"/>
<rect x="810" y="534" width="614" height="67" fill="url(#day)"/>
<circle cx="1306" cy="567" r="17" fill="#FFE99A"/>
<rect x="1038" y="570" width="103" height="98" fill="#91C5E3" stroke="#704E3D" stroke-width="8"/>
<path d="M1089 574V664M1042 620H1137" stroke="#FFF8E9" stroke-width="7"/>
<rect x="810" y="676" width="614" height="28" fill="#926545"/>
<rect x="876" y="630" width="135" height="54" rx="14" fill="#6D8E67" stroke="#4E5E49" stroke-width="4"/>
<rect x="890" y="614" width="107" height="36" rx="12" fill="#7FA17A" stroke="#4E5E49" stroke-width="4"/>
<text x="1117" y="752" text-anchor="middle" fill="#F8FAFC" font-family="Arial, sans-serif" font-size="28" font-weight="700">Living Room</text>
<text x="1117" y="785" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="20">A cozy room with a window,</text>
<text x="1117" y="812" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="20">rug, and furniture.</text>
</g>
<rect x="692" y="864" width="216" height="58" rx="18" fill="#171C25" stroke="#586273" stroke-width="2"/>
<text x="800" y="902" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="24" font-weight="700">Close</text>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -0,0 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000">
<defs>
<pattern id="grid" width="64" height="64" patternUnits="userSpaceOnUse"><path d="M64 0H0V64" fill="none" stroke="#FFFFFF" stroke-opacity=".055" stroke-width="1"/></pattern>
<linearGradient id="clear" x2="0" y2="1"><stop stop-color="#52ADE8"/><stop offset="1" stop-color="#DFF5FF"/></linearGradient>
<linearGradient id="cloudy" x2="0" y2="1"><stop stop-color="#72839A"/><stop offset="1" stop-color="#D4E0E4"/></linearGradient>
<linearGradient id="sunset" x2="0" y2="1"><stop stop-color="#765A91"/><stop offset=".55" stop-color="#DF8E9A"/><stop offset="1" stop-color="#FFC980"/></linearGradient>
<linearGradient id="night" x2="0" y2="1"><stop stop-color="#101A3B"/><stop offset="1" stop-color="#536F97"/></linearGradient>
</defs>
<rect width="1600" height="1000" fill="#2A313B"/><rect width="1600" height="1000" fill="url(#grid)"/>
<rect x="72" y="38" width="1456" height="924" rx="30" fill="#202735" stroke="#566173" stroke-width="2"/>
<text x="120" y="102" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="38" font-weight="700">Scenery</text>
<rect x="120" y="124" width="230" height="54" rx="16" fill="#12394A" stroke="#22C6FF" stroke-width="3"/>
<text x="235" y="160" text-anchor="middle" fill="#F6FAFF" font-family="Arial, sans-serif" font-size="22" font-weight="700">Backgrounds</text>
<rect x="365" y="124" width="190" height="54" rx="16" fill="#171C25" stroke="#566173" stroke-width="2"/>
<text x="460" y="160" text-anchor="middle" fill="#DCE3EC" font-family="Arial, sans-serif" font-size="22">Artwork</text>
<text x="120" y="224" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="25" font-weight="700">Choose the scene background</text>
<!-- Plain Background -->
<g>
<rect x="120" y="250" width="420" height="246" rx="16" fill="#171C25" stroke="#22C6FF" stroke-width="4"/>
<rect x="132" y="262" width="396" height="157" rx="10" fill="#F5EFE0"/>
<rect x="145" y="274" width="165" height="32" rx="12" fill="#12394A" stroke="#22C6FF" stroke-width="2"/>
<text x="227" y="297" text-anchor="middle" fill="#F7FBFF" font-family="Arial, sans-serif" font-size="16" font-weight="700">Current background</text>
<text x="330" y="458" text-anchor="middle" fill="#F6F8FC" font-family="Arial, sans-serif" font-size="23" font-weight="700">Plain Background</text>
</g>
<!-- Clear Day -->
<g>
<rect x="590" y="250" width="420" height="246" rx="16" fill="#171C25" stroke="#465061" stroke-width="2"/>
<rect x="602" y="262" width="396" height="157" rx="10" fill="url(#clear)"/>
<circle cx="910" cy="304" r="23" fill="#FFE99A" stroke="#FFF3BD" stroke-width="5"/>
<g fill="#FFFFFF" stroke="#DFEAF1" stroke-width="2"><ellipse cx="714" cy="354" rx="43" ry="14"/><circle cx="694" cy="348" r="13"/><circle cx="717" cy="341" r="18"/><circle cx="741" cy="352" r="12"/></g>
<text x="800" y="458" text-anchor="middle" fill="#F6F8FC" font-family="Arial, sans-serif" font-size="23" font-weight="700">Clear Day</text>
</g>
<!-- Cloudy Day -->
<g>
<rect x="1060" y="250" width="420" height="246" rx="16" fill="#171C25" stroke="#465061" stroke-width="2"/>
<rect x="1072" y="262" width="396" height="157" rx="10" fill="url(#cloudy)"/>
<g fill="#F8FBFE" stroke="#E2EBF1" stroke-width="2"><ellipse cx="1190" cy="326" rx="42" ry="14"/><circle cx="1170" cy="320" r="14"/><circle cx="1194" cy="312" r="18"/><circle cx="1216" cy="324" r="12"/><ellipse cx="1360" cy="372" rx="42" ry="13"/><circle cx="1342" cy="366" r="12"/><circle cx="1362" cy="358" r="17"/><circle cx="1384" cy="369" r="12"/></g>
<text x="1270" y="458" text-anchor="middle" fill="#F6F8FC" font-family="Arial, sans-serif" font-size="23" font-weight="700">Cloudy Day</text>
</g>
<!-- Sunset -->
<g>
<rect x="120" y="520" width="420" height="220" rx="16" fill="#171C25" stroke="#465061" stroke-width="2"/>
<rect x="132" y="532" width="396" height="135" rx="10" fill="url(#sunset)"/>
<circle cx="330" cy="623" r="23" fill="#FFE09B"/><path d="M132 640Q330 600 528 640V667H132Z" fill="#5C6D76" opacity=".7"/>
<text x="330" y="713" text-anchor="middle" fill="#F6F8FC" font-family="Arial, sans-serif" font-size="23" font-weight="700">Sunset</text>
</g>
<!-- Starry Night -->
<g>
<rect x="590" y="520" width="420" height="220" rx="16" fill="#171C25" stroke="#465061" stroke-width="2"/>
<rect x="602" y="532" width="396" height="135" rx="10" fill="url(#night)"/>
<g fill="#FFE8A5"><circle cx="664" cy="563" r="3"/><circle cx="740" cy="604" r="3"/><circle cx="806" cy="555" r="4"/><circle cx="927" cy="580" r="3"/><circle cx="889" cy="635" r="3"/></g>
<path d="M875 560a28 28 0 1 0 26 46a22 22 0 1 1-26-46" fill="#FFF0BE"/>
<text x="800" y="713" text-anchor="middle" fill="#F6F8FC" font-family="Arial, sans-serif" font-size="23" font-weight="700">Starry Night</text>
</g>
<text x="1060" y="558" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="22" font-weight="700">More backgrounds</text>
<rect x="1060" y="578" width="195" height="100" rx="15" fill="#171C25" stroke="#566173" stroke-width="2"/>
<rect x="1077" y="596" width="52" height="56" rx="8" fill="#C6A36C" stroke="#E7D0A6" stroke-width="2"/>
<path d="M1083 606h40M1083 618h40M1083 630h40M1083 642h40" stroke="#896644" stroke-width="3"/>
<text x="1183" y="635" text-anchor="middle" fill="#F6F8FC" font-family="Arial, sans-serif" font-size="19" font-weight="700">Wall panel</text>
<rect x="1274" y="578" width="206" height="100" rx="15" fill="#171C25" stroke="#566173" stroke-width="2"/>
<rect x="1290" y="596" width="52" height="56" rx="8" fill="#7AA474" stroke="#A5C39C" stroke-width="2"/>
<path d="M1297 609h38M1297 622h38M1297 635h38" stroke="#D7E6C5" stroke-width="2" opacity=".7"/>
<text x="1402" y="622" text-anchor="middle" fill="#F6F8FC" font-family="Arial, sans-serif" font-size="16" font-weight="700">Background</text>
<text x="1402" y="644" text-anchor="middle" fill="#F6F8FC" font-family="Arial, sans-serif" font-size="16" font-weight="700">ground</text>
<rect x="692" y="874" width="216" height="58" rx="18" fill="#171C25" stroke="#586273" stroke-width="2"/>
<text x="800" y="912" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="24" font-weight="700">Close</text>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -0,0 +1,45 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000">
<defs>
<pattern id="grid" width="64" height="64" patternUnits="userSpaceOnUse"><path d="M64 0H0V64" fill="none" stroke="#FFFFFF" stroke-opacity=".055" stroke-width="1"/></pattern>
</defs>
<rect width="1600" height="1000" fill="#2A313B"/><rect width="1600" height="1000" fill="url(#grid)"/>
<rect x="76" y="46" width="1448" height="908" rx="30" fill="#202735" stroke="#566173" stroke-width="2"/>
<rect x="116" y="82" width="250" height="54" rx="16" fill="#171C25" stroke="#566173" stroke-width="2"/>
<text x="241" y="117" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="21" font-weight="700">‹ Backgrounds</text>
<text x="116" y="194" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="38" font-weight="700">Plain Background</text>
<text x="118" y="231" fill="#B8C2D0" font-family="Arial, sans-serif" font-size="21">Tap a color to use it on the Stage.</text>
<text x="120" y="279" fill="#EAF0F8" font-family="Arial, sans-serif" font-size="22" font-weight="700">Preview</text>
<rect x="120" y="296" width="1360" height="190" rx="22" fill="#F5EFE0" stroke="#F4F7FB" stroke-width="4"/>
<rect x="120" y="520" width="310" height="280" rx="18" fill="#171C25" stroke="#3C4656" stroke-width="2"/>
<text x="150" y="564" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="22" font-weight="700">Color</text>
<rect x="150" y="584" width="68" height="68" rx="12" fill="#F5EFE0" stroke="#22C6FF" stroke-width="4"/>
<circle cx="212" cy="646" r="16" fill="#087DBA" stroke="#44CCFF" stroke-width="2"/>
<path d="M205 652 L207 646 L215 638 L220 643 L212 651 Z M213 640 L218 645 M205 652 L211 650" fill="none" stroke="#FFFFFF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<text x="234" y="625" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="23" font-weight="700">#F5EFE0</text>
<text x="150" y="696" fill="#AEB8C8" font-family="Arial, sans-serif" font-size="18">Tap swatch to pick a custom color</text>
<rect x="454" y="520" width="470" height="280" rx="18" fill="#171C25" stroke="#3C4656" stroke-width="2"/>
<text x="484" y="564" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="22" font-weight="700">Recently used</text>
<g stroke="#DCE3EC" stroke-width="3">
<rect x="484" y="590" width="64" height="64" rx="12" fill="#FFB703"/><rect x="560" y="590" width="64" height="64" rx="12" fill="#24B9E8"/>
<rect x="636" y="590" width="64" height="64" rx="12" fill="#00A542"/><rect x="712" y="590" width="64" height="64" rx="12" fill="#0798EA"/>
<rect x="788" y="590" width="64" height="64" rx="12" fill="#F5EFE0" stroke="#22C6FF" stroke-width="5"/>
</g>
<g stroke="#DCE3EC" stroke-width="3">
<rect x="484" y="682" width="64" height="64" rx="12" fill="#F04C53"/>
<rect x="560" y="682" width="64" height="64" rx="12" fill="#A855F7"/>
<rect x="636" y="682" width="64" height="64" rx="12" fill="#FFFFFF"/>
</g>
<rect x="948" y="520" width="532" height="280" rx="18" fill="#171C25" stroke="#3C4656" stroke-width="2"/>
<text x="978" y="564" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="22" font-weight="700">Colors</text>
<g stroke="#DCE3EC" stroke-width="3">
<rect x="978" y="590" width="64" height="64" rx="12" fill="#FFFFFF"/><rect x="1054" y="590" width="64" height="64" rx="12" fill="#171717"/>
<rect x="1130" y="590" width="64" height="64" rx="12" fill="#22B8E6"/><rect x="1206" y="590" width="64" height="64" rx="12" fill="#FFB703"/>
<rect x="1282" y="590" width="64" height="64" rx="12" fill="#2AD67B"/><rect x="1358" y="590" width="64" height="64" rx="12" fill="#F04C53"/>
</g>
<rect x="670" y="858" width="260" height="62" rx="18" fill="#171C25" stroke="#586273" stroke-width="2"/>
<text x="800" y="898" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="22" font-weight="700">Back to Backgrounds</text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -0,0 +1,49 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000">
<defs>
<pattern id="grid" width="64" height="64" patternUnits="userSpaceOnUse"><path d="M64 0H0V64" fill="none" stroke="#FFFFFF" stroke-opacity=".055" stroke-width="1"/></pattern>
<linearGradient id="fieldBase" x1="0" x2="1"><stop stop-color="#F2BF48"/><stop offset="1" stop-color="#F2BF48"/></linearGradient>
<linearGradient id="whiteFade" x1="0" x2="1"><stop stop-color="#FFFFFF"/><stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/></linearGradient>
<linearGradient id="blackFade" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#000000" stop-opacity="0"/><stop offset="1" stop-color="#000000"/></linearGradient>
<linearGradient id="hues" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#FF0000"/><stop offset=".17" stop-color="#FFFF00"/><stop offset=".34" stop-color="#00FF00"/><stop offset=".51" stop-color="#00FFFF"/><stop offset=".68" stop-color="#0000FF"/><stop offset=".84" stop-color="#FF00FF"/><stop offset="1" stop-color="#FF0000"/></linearGradient>
</defs>
<rect width="1600" height="1000" fill="#2A313B"/><rect width="1600" height="1000" fill="url(#grid)"/>
<rect x="175" y="56" width="1250" height="888" rx="30" fill="#202735" stroke="#566173" stroke-width="2"/>
<text x="800" y="119" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="40" font-weight="700">Choose background color</text>
<text x="230" y="165" fill="#EAF0F8" font-family="Arial, sans-serif" font-size="22" font-weight="700">Preview</text>
<rect x="230" y="184" width="1140" height="145" rx="22" fill="#F5EFE0" stroke="#F4F7FB" stroke-width="4"/>
<!-- saturation and value field -->
<rect x="230" y="356" width="890" height="365" rx="10" fill="url(#fieldBase)"/>
<rect x="230" y="356" width="890" height="365" rx="10" fill="url(#whiteFade)"/>
<rect x="230" y="356" width="890" height="365" rx="10" fill="url(#blackFade)"/>
<rect x="230" y="356" width="890" height="365" rx="10" fill="none" stroke="#AEB8C8" stroke-width="2"/>
<circle cx="328" cy="412" r="12" fill="none" stroke="#111827" stroke-width="5"/><circle cx="328" cy="412" r="8" fill="none" stroke="#FFFFFF" stroke-width="3"/>
<!-- hue bar -->
<rect x="1144" y="356" width="76" height="365" rx="12" fill="url(#hues)" stroke="#AEB8C8" stroke-width="2"/>
<path d="M1136 397H1228" stroke="#10151E" stroke-width="8"/><path d="M1136 397H1228" stroke="#FFFFFF" stroke-width="4"/>
<!-- mode tabs -->
<rect x="230" y="744" width="990" height="56" rx="14" fill="#171C25" stroke="#566173" stroke-width="2"/>
<rect x="232" y="746" width="326" height="52" rx="12" fill="#2D3543" stroke="#22C6FF" stroke-width="2"/>
<text x="395" y="781" text-anchor="middle" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="21" font-weight="700">RGB</text>
<path d="M560 750V795M890 750V795" stroke="#566173" stroke-width="2"/>
<text x="725" y="781" text-anchor="middle" fill="#DAE1EA" font-family="Arial, sans-serif" font-size="21">HSV</text>
<text x="1055" y="781" text-anchor="middle" fill="#DAE1EA" font-family="Arial, sans-serif" font-size="21">Linear</text>
<!-- selected value -->
<rect x="1245" y="356" width="125" height="365" rx="16" fill="#171C25" stroke="#3C4656" stroke-width="2"/>
<text x="1307" y="391" text-anchor="middle" fill="#AEB8C8" font-family="Arial, sans-serif" font-size="16">COLOR</text>
<rect x="1268" y="416" width="79" height="79" rx="14" fill="#F5EFE0" stroke="#22C6FF" stroke-width="3"/>
<text x="1307" y="531" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="16">Hex</text>
<rect x="1260" y="546" width="94" height="44" rx="9" fill="#252D3A" stroke="#657184" stroke-width="2"/>
<text x="1307" y="575" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="18">F5EFE0</text>
<text x="1307" y="634" text-anchor="middle" fill="#AEB8C8" font-family="Arial, sans-serif" font-size="15">R 245</text>
<text x="1307" y="662" text-anchor="middle" fill="#AEB8C8" font-family="Arial, sans-serif" font-size="15">G 239</text>
<text x="1307" y="690" text-anchor="middle" fill="#AEB8C8" font-family="Arial, sans-serif" font-size="15">B 224</text>
<rect x="835" y="850" width="230" height="62" rx="18" fill="#171C25" stroke="#586273" stroke-width="2"/>
<text x="950" y="890" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="22" font-weight="700">Back</text>
<rect x="1085" y="850" width="285" height="62" rx="18" fill="#087DBA" stroke="#44CCFF" stroke-width="3"/>
<text x="1227" y="890" text-anchor="middle" fill="#FFFFFF" font-family="Arial, sans-serif" font-size="22" font-weight="700">Use color</text>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

@@ -0,0 +1,92 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1800" height="1200" viewBox="0 0 1800 1200">
<defs>
<pattern id="grid" width="64" height="64" patternUnits="userSpaceOnUse">
<path d="M64 0H0V64" fill="none" stroke="#FFFFFF" stroke-opacity=".055" stroke-width="1"/>
</pattern>
<linearGradient id="open-sky" x2="0" y2="1"><stop stop-color="#626E7E"/><stop offset="1" stop-color="#D3DEE5"/></linearGradient>
<linearGradient id="town-sky" x2="0" y2="1"><stop stop-color="#52ADE8"/><stop offset="1" stop-color="#DFF5FF"/></linearGradient>
<linearGradient id="room-wall" x2="0" y2="1"><stop stop-color="#E6DABD"/><stop offset="1" stop-color="#F4E8CF"/></linearGradient>
<clipPath id="plain-clip"><rect x="134" y="198" width="742" height="276" rx="12"/></clipPath>
<clipPath id="sky-clip"><rect x="924" y="198" width="742" height="276" rx="12"/></clipPath>
<clipPath id="town-clip"><rect x="134" y="638" width="742" height="276" rx="12"/></clipPath>
<clipPath id="room-clip"><rect x="924" y="638" width="742" height="276" rx="12"/></clipPath>
</defs>
<rect width="1800" height="1200" fill="#2A313B"/>
<rect width="1800" height="1200" fill="url(#grid)"/>
<rect x="65" y="35" width="1670" height="1130" rx="30" fill="#202735" stroke="#566173" stroke-width="2"/>
<text x="122" y="111" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="46" font-weight="700">Choose a starter scene</text>
<text x="124" y="151" fill="#AEB8C8" font-family="Arial, sans-serif" font-size="23">Pick a place to begin. You can change the background later.</text>
<!-- The larger cards devote almost twice the original area to the scene preview. -->
<rect x="120" y="184" width="770" height="418" rx="20" fill="#171C25" stroke="#22C6FF" stroke-width="4"/>
<rect x="910" y="184" width="770" height="418" rx="20" fill="#171C25" stroke="#596474" stroke-width="2"/>
<rect x="120" y="624" width="770" height="418" rx="20" fill="#171C25" stroke="#596474" stroke-width="2"/>
<rect x="910" y="624" width="770" height="418" rx="20" fill="#171C25" stroke="#596474" stroke-width="2"/>
<g clip-path="url(#plain-clip)">
<rect x="134" y="198" width="742" height="276" fill="#F5EFE0"/>
</g>
<rect x="134" y="198" width="742" height="276" rx="12" fill="none" stroke="#FFFFFF" stroke-opacity=".38" stroke-width="2"/>
<g clip-path="url(#sky-clip)">
<rect x="924" y="198" width="742" height="276" fill="url(#open-sky)"/>
<circle cx="1525" cy="267" r="26" fill="#FFF1A8" stroke="#FFE27A" stroke-width="7"/>
<g fill="#F7FBFF" stroke="#DBE6EF" stroke-width="3">
<ellipse cx="1042" cy="297" rx="49" ry="19"/><circle cx="1014" cy="287" r="20"/><circle cx="1043" cy="278" r="25"/><circle cx="1077" cy="291" r="20"/>
<ellipse cx="1342" cy="365" rx="53" ry="18"/><circle cx="1312" cy="355" r="20"/><circle cx="1343" cy="345" r="25"/><circle cx="1378" cy="358" r="19"/>
</g>
</g>
<g clip-path="url(#town-clip)">
<rect x="134" y="638" width="742" height="276" fill="url(#town-sky)"/>
<circle cx="781" cy="699" r="24" fill="#FFE99A"/>
<g fill="#FFFFFF" stroke="#DCE7F0" stroke-width="3">
<ellipse cx="245" cy="707" rx="43" ry="16"/><circle cx="222" cy="699" r="17"/><circle cx="249" cy="690" r="21"/><circle cx="277" cy="704" r="15"/>
<ellipse cx="532" cy="737" rx="39" ry="14"/><circle cx="511" cy="730" r="15"/><circle cx="534" cy="721" r="19"/><circle cx="558" cy="733" r="14"/>
</g>
<rect x="134" y="823" width="742" height="47" fill="#5D9D70"/>
<rect x="134" y="870" width="742" height="44" fill="#575F68"/>
<path d="M329 820L432 728L534 820Z" fill="#D8705A" stroke="#28313A" stroke-width="5"/>
<rect x="349" y="820" width="166" height="51" fill="#E8D4AC" stroke="#28313A" stroke-width="5"/>
<rect x="415" y="834" width="39" height="37" fill="#58A7D4" stroke="#28313A" stroke-width="4"/>
<rect x="461" y="769" width="33" height="40" fill="#71BBDC" stroke="#28313A" stroke-width="4"/>
<path d="M647 821V746" stroke="#594D3B" stroke-width="12"/>
<circle cx="647" cy="738" r="48" fill="#4F9B67" stroke="#336D4C" stroke-width="5"/>
</g>
<g clip-path="url(#room-clip)">
<rect x="924" y="638" width="742" height="276" fill="url(#room-wall)"/>
<rect x="924" y="638" width="742" height="74" fill="url(#town-sky)"/>
<circle cx="1576" cy="680" r="23" fill="#FFE99A"/>
<rect x="1232" y="677" width="150" height="141" fill="#91C5E3" stroke="#704E3D" stroke-width="10"/>
<path d="M1307 683V812M1238 747H1376" stroke="#FFF8E9" stroke-width="8"/>
<rect x="924" y="866" width="742" height="48" fill="#926545"/>
<ellipse cx="1149" cy="865" rx="143" ry="26" fill="#BA7F5B"/>
<rect x="1041" y="792" width="226" height="81" rx="18" fill="#6D8E67" stroke="#4E5E49" stroke-width="6"/>
<rect x="1061" y="769" width="186" height="57" rx="18" fill="#80A17A" stroke="#4E5E49" stroke-width="6"/>
<rect x="1481" y="826" width="88" height="43" rx="6" fill="#C49371" stroke="#654C3E" stroke-width="5"/>
<path d="M1524 826V779" stroke="#755A43" stroke-width="6"/>
<circle cx="1524" cy="764" r="25" fill="#FFF5DF" stroke="#755A43" stroke-width="5"/>
</g>
<g fill="#F8FAFC" font-family="Arial, sans-serif" text-anchor="middle" font-weight="700" font-size="32">
<text x="505" y="521">Plain Background</text>
<text x="1295" y="521">Open Sky</text>
<text x="505" y="961">Town Street</text>
<text x="1295" y="961">Living Room</text>
</g>
<g fill="#F5F7FA" font-family="Arial, sans-serif" text-anchor="middle" font-size="22">
<text x="505" y="558">An empty stage with one color.</text>
<text x="505" y="589">Choose a new color whenever you like.</text>
<text x="1295" y="558">An empty stage with a soft sky.</text>
<text x="1295" y="589">Sun and clouds, ready for your story.</text>
<text x="505" y="998">Cloudy sky, shops, trees,</text>
<text x="505" y="1029">sidewalk, and road.</text>
<text x="1295" y="998">A cozy room with a window,</text>
<text x="1295" y="1029">rug, and furniture.</text>
</g>
<rect x="790" y="1077" width="220" height="64" rx="18" fill="#171C25" stroke="#667386" stroke-width="2"/>
<text x="900" y="1118" text-anchor="middle" fill="#F5F7FA" font-family="Arial, sans-serif" font-size="26" font-weight="700">Close</text>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -0,0 +1,74 @@
# Phase 19 — Background Ground and Wall Color
> **Status (2026-10-22):** Implemented and user/editor verified as part of Phase 19. The scenery suite passes **706 checks with 0 failures**. This brief remains the product-level record; runtime contracts are in the linked architecture specification.
## Goal and audience
Help a child make a Stage feel like a coherent place. Wall panels need a working color choice, and broad ground visible behind buildings or furniture needs its own decorative surface. A child should be able to make the grassy town backdrop in the reference scene, or an indoor carpeted backdrop, without changing where stickmen can walk.
The audience is a child arranging scenes in the Sandbox Stage's EDIT mode and then directing or playing them. The distinction remains simple: **Background ground looks like ground; walkable Ground is where stickmen walk.**
## Desired outcome and story value
- Make a town green behind its buildings and trees while the ordinary Ground tiles still define the walking route.
- Give a room a carpeted background area in a chosen color, or give an outdoor set a concrete backdrop.
- Change a painted wall's color and choose a brick or wood color that still looks textured.
- Reuse favorite colors across walls and carpet without reconstructing a color in every scene.
- Save, reopen, resize, and rearrange these pieces without losing their look or making decorative areas solid.
## Child flow and behavior
### Choose and edit wall color
In **Scenery → Backgrounds → Wall panel**, the child chooses **Paint**, **Brick**, or **Wood**, then chooses a color. Eight recently used colors appear first, newest first, followed by useful preset colors. **More colors…** opens a color dialog with **Use color** and **Cancel**. A confirmed color updates the small finish preview and the Stage placement ghost before the child draws the panel. Cancel restores the prior choice.
Dragging a rectangle places one wall panel with the chosen finish and color. In **Edit Scenery**, selecting a wall panel exposes **Look** and **Color** in its inspector. A change affects only that panel. The existing move, resize, wall-area erase, and erase-piece behaviors continue to apply.
Paint uses the chosen color as its main surface. Brick and Wood use it to shift their material palettes while retaining recognizable mortar, grain, shading, and variation. Their default choices should look like believable brick and wood; custom colors remain available for imaginative sets. Preview and placed panel must agree about the result.
### Draw Background ground
The **Scenery → Backgrounds** chooser gains **Background ground** immediately after **Wall panel**, with large **Grass**, **Concrete**, and **Carpet** cards. The cards and placement prompt say **“Decorative only · Stickmen don’t walk here.”** The choice is separate from the existing **Ground** palette tool. Carpet has the same recent and preset color choices and **More colors…** flow; confirmed color changes the carpet hue while its fabric detail remains visible.
The child chooses a look, sees a patterned ghost, drags a rectangle, and releases to place a finite decorative surface. In **Edit Scenery**, that piece can be selected, moved, resized, restyled, recolored where applicable, or erased using the same child-facing model as a wall panel. Broad surfaces must remain easy to select in Edit Scenery without taking ordinary EDIT clicks intended for Ground, Props, or characters.
Background ground sits above the Sky and behind wall panels, Far and Near scenery artwork, buildings, trees, and walkable Ground. It is fixed scenery in EDIT, DIRECT, and PLAY. It provides no collision, support, navigation, pickup, Use action, or event target. Walkable Ground keeps its existing physics and terrain tools.
Grass has restrained detail throughout the filled area, so a large green panel reads as grass at normal Stage zoom. Concrete has a distinct surface treatment, and Carpet retains a fabric-like look at chosen colors. Motifs use a stable world scale: resizing reveals or hides more pattern rather than stretching it, and adjoining panels of one look align visibly. Their previews should show the same character as the Stage rendering.
### Reuse colors
Wall Paint, Brick, Wood, and Background ground Carpet share one list of **eight recently used colors**, newest first. Choosing a preset or confirming **Use color** puts that color first and removes any duplicate; the list persists in user settings across scenes. Canceling a custom color dialog or its tentative preview does not add a color. The color history is a convenience for choosing future looks; changing it does not recolor existing pieces.
## Success criteria
- A child can color a new Paint wall and recolor one selected wall without changing another wall. Brick and Wood respond visibly to color while their material details remain legible.
- A child can place a broad Grass backdrop behind a town's buildings, then place ordinary Ground in front. A stickman follows only the walkable Ground during PLAY.
- Grass texture remains visible inside a large backdrop at common camera views; Concrete and Carpet are distinguishable in chooser cards and on Stage. A colored Carpet still looks like fabric.
- Background ground remains finite and editable. Moving or resizing a piece preserves its motif scale; adjoining matching pieces have aligned motifs.
- The eight-color history is shared across the four colorable looks, persists across scenes and restarts, and ignores canceled choices.
- Chooser and inspector controls remain reachable on smaller displays, and the visual-only role is clear in words as well as appearance. Save and reopen preserve the pieces, finishes, colors, positions, and visual order.
## Scope and exclusions
Included: the missing wall color controls and textured tint behavior; one decorative rectangular Background ground type with Grass, Concrete, and Carpet; Carpet color; shared eight-color history; responsive chooser and selected-piece editing; visual previews; scene persistence and compatibility with existing scenes.
Excluded: new walkable Ground, Ramp, or Step behavior; changes to navigation or collision; parallax or animated scenery; freehand terrain shapes; user-authored repeatable patterns; automatic horizon blending or edge generation; a general color library beyond the recent list; and a new Scenery file/editor format. The separate [backdrop horizon and edge treatment idea](../ideas/feature_backlog.md) can address deliberate transitions later.
## Current project fit and references
The accepted [Phase 19 product plan](phase_19_scenery_backgrounds_surface_finishes.md) and [UI/UX specification](../specs/phase_19_scenery_backgrounds_ui_ux.md) already call for a wall Color choice and for recoloring a selected panel. The present wall data stores a color, but Brick and Wood rendering uses fixed palettes, and the current chooser and selected-wall card do not expose color. This brief closes that visible Phase 19 gap.
Existing walkable Ground is a `TerrainBlock` with collision and finish drawing. Its Grass motif emphasizes the top edge of the block; it is not the broad interior treatment needed for a distant field. The current semantic draw order places Sky, walls, Far and Near scenery, walkable Ground, floor scenery, and action elements in separate bands. Background ground adds a visual band behind those scene pieces while keeping the Stage's established Scenery selection model.
Relevant implementation locations for the spec-pipeline to inspect are `scripts/stage/scenery/stage_wall_panel.gd`, `scripts/stage/scenery/stage_surface_renderer.gd`, `scripts/stage/stage_draw_order.gd`, `scripts/terrain/terrain_block.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/editors/stage/stage_scenery_controller.gd`, `scripts/editors/stage/sandbox_stage.gd`, `scripts/editors/stage/stage_asset_browser.gd`, `scripts/shared/ui/asset_selector.gd`, and the shared Stage panel system in `docs/specs/stage_panel_ui_system.md`.
## Dependencies, risks, and specification questions
- **Material color:** UI/UX and architecture should define a predictable relationship between the selected hue and each finish's multiple tones. Brick mortar and wood grain must keep enough contrast across very light, dark, or saturated custom colors.
- **Layering and overlap:** The recommended order is Sky → Background ground → wall panels → Far art → Near art → walkable Ground → floor art → action. The spec should confirm how a large decorative surface is selected when other scenery covers it and how it behaves in existing multi-piece scenery edits.
- **Large areas and zoom:** Repeatable vector detail needs to stay crisp and coherent without becoming visually noisy or costly across a large rectangle. The spec should settle how it previews at card size and at normal and zoomed Stage views.
- **Persistence:** Architecture should settle saved identifiers, old-scene defaults, and user-setting storage for the recent colors. The visible history is per user, while each placed piece saves its own color.
- **Copy and terminology:** UI/UX should settle the final short label for **Background ground**, the helper wording in chooser and capture bar, and the selected-piece inspector wording. The role distinction from walkable **Ground** must survive narrow screens and keyboard/touch use.
These are specification questions; the approved child behavior above should remain intact.
+28
View File
@@ -0,0 +1,28 @@
# Phase 19 handoff
**Status (2026-10-22): Complete and user/editor verified.** The user verified the integrated Phase 19 behaviors in the editor, including camera-aware Sky details, starter compositions, surface finishes, Plain Background, scenery artwork/depth, furniture behavior, and Plain Background save/reopen. The final `tests/test_phase19_scenery.gd` run passed **706 checks with 0 failures** (Godot exit code 0).
The implementation provides Sky and Plain Background choices, editable Plain color, wall panels and background ground, inert Scenery artwork with depth and resize controls, Ground finishes, and four editable New Scene starters. Stage-wide Undo/Redo remains deliberately deferred and is tracked as technical debt. No further Phase 19 implementation or user verification is outstanding.
## Verification record
- **Automated:** `tests/test_phase19_scenery.gd` — 706 PASS checks, 0 failures, exit code 0, Godot 4.7.1. The run used a temporary Windows Roaming AppData location after the default profile path failed during Godot log initialization. Godot also logged a root-certificate-store warning; it did not affect the suite result.
- **User/editor:** The user reports completion of all planned visual and interactive checks across camera-aware Sky, both starter compositions, surface finish resizing/tiling, Plain Background, scenery/artwork, and furniture behavior. The user separately confirmed Plain Background save/reopen. Do not interpret that as a manual save/reopen check for every manifest asset; persistence is additionally covered by automated paths.
- **Test command:**
```powershell
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path . --script res://tests/test_phase19_scenery.gd
```
## Start here
- [Phase 19 product plan](phase_19_scenery_backgrounds_surface_finishes.md) — approved scope, starter behavior, asset manifest, and success criteria.
- [Phase 19 architecture specification](../specs/phase_19_scenery_backgrounds_surface_finishes.md) — Stage/file contracts, camera-aware Sky, scenery editing, and acceptance routes.
- [Phase 19 UI/UX specification](../specs/phase_19_scenery_backgrounds_ui_ux.md) — Stage chooser and interaction behavior.
- [Plain Background plan](phase_19_plain_background.md) and [architecture specification](../specs/phase_19_plain_background.md) — solid backdrop, chooser flow, and persistence.
- [Starter composition brief](phase_19_starter_scene_composition.md) and [architecture specification](../specs/phase_19_starter_scene_composition.md) — Town Street and Living Room layouts.
- [Background Ground and Wall Color brief](phase_19_background_ground_and_wall_color.md) — decorative surfaces and shared color behavior.
- [Selected artwork depth specification](../specs/phase_19_selected_artwork_depth_inspector.md) — Far/Near/On the floor inspector behavior.
- [Technical debt log](../architecture/tech_debt_and_optimizations.md) — deferred Stage Undo/Redo.
Phase 19 is complete. Later work should use the linked specifications and keep Stage-wide Undo/Redo in its separate scope.
+130
View File
@@ -0,0 +1,130 @@
# Phase 19 — Plain Background
> **Status (2026-10-22):** Implemented and user/editor verified as part of Phase 19. The user explicitly verified Plain Background save/reopen; the Phase 19 scenery suite passes **706 checks with 0 failures**. Detailed presentation and file behavior are specified in the linked Plain Background UI/UX and architecture documents.
> **Relationship to Phase 19:** This brief updates only the earlier three-card **File → New** choice and the four-Sky-only **Backgrounds** choice. Other accepted Scenery, starter composition, Sky, Ground, and artwork behavior remains as described in the existing Phase 19 plans and specifications.
## Goal and audience
Give a child a genuinely empty Stage and an easy way to choose its background color. The audience is a child starting a scene through **File → New** or changing a scene's look in **EDIT**. A plain color can set a mood for an abstract story, title-like shot, or set built entirely from the child's own Ground, characters, Props, and Scenery.
The background is a scene-wide visual choice. It fills the camera view in **EDIT**, **DIRECT**, and **PLAY**, without adding a selectable Stage object or changing the colors or behavior of placed pieces.
## PNG mockups
**Review status:** The starter and Backgrounds PNGs are user-approved. The color-choices and custom-picker PNGs were revised per user feedback: tapping the current-color chip opens the picker without a separate **More colors** button, and the custom picker shows a simple **Preview** label with no temporary-state helper message. The final mockups were approved and implemented. The production initial color is warm off-white `#F5EFE0`; Plain Background previews use a uniform solid fill. Editable SVG source files sit beside each PNG.
- [New Scene starter chooser](assets/phase_19_plain_background/01_new_scene.png)
- [Larger New Scene chooser proposal](assets/phase_19_plain_background/05_large_starter_chooser.png) — expands the same four cards and gives each preview more room; retain the bounded, scrollable layout on smaller screens.
- [Scenery Backgrounds chooser](assets/phase_19_plain_background/02_background_picker.png)
- [Plain Background color choices](assets/phase_19_plain_background/03_color_choices.png)
- [Custom color picker](assets/phase_19_plain_background/04_custom_color_picker.png)
## Desired child outcome
- Start **Plain Background** and see one uninterrupted solid color, with no Sky imagery, Ground, characters, Props, or other placed objects.
- Recognize the existing empty starter as **Open Sky**: its current neutral gradient, sun, and clouds remain available.
- Find the background color in **EDIT → Scenery → Backgrounds**, beside the Sky choices, and use a familiar color preview, swatches, and picker.
- Change between a plain color and a Sky without losing the chosen plain color. Save, reopen, and see the same chosen background.
## Child flow and feedback
### File → New
The existing unsaved-work Save/Discard/Cancel gate runs before the starter chooser. Canceling the chooser leaves the open scene intact. The chooser presents four large illustrated cards; a chosen starter opens as a new, unsaved, editable scene.
```text
┌ Choose a starter scene ───────────────────────────────┐
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ [solid color preview]│ │ [soft sky preview] │ │
│ │ Plain Background │ │ Open Sky │ │
│ │ An empty stage with │ │ An empty stage with │ │
│ │ a color you can pick.│ │ a soft sky. │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ [street preview] │ │ [room preview] │ │
│ │ Town Street │ │ Living Room │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ [Close] │
└───────────────────────────────────────────────────────┘
```
The **Plain Background** card shows its actual initial solid fill. **Open Sky** is the existing sky-backed empty starter, renamed so its preview and label agree. Town Street and Living Room retain their current compositions and editability. On a narrow screen, cards wrap into one column within a bounded, scrollable chooser; the title and visible Close action remain reachable.
The initial Plain Background color is **warm off-white `#F5EFE0`**, approved as the starting look. It should read like paper and let typical character and artwork outlines remain visible. The Stage grid and selection outlines need contrast treatment so they remain legible over both light and dark child-selected colors.
### Change the background in EDIT
The child opens **Scenery → Backgrounds**. A **Plain Background** choice sits with the four existing Sky presets. The chooser shows which background is current with a clear border and words as well as color; keyboard focus remains visually separate from selection.
```text
┌ Choose Scenery ────────────────────────────────────────┐
│ [Backgrounds] [Artwork] │
│ Choose the scene background │
│ ┌───────────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ [saved plain color]│ │ Clear Day │ │ Cloudy Day │ │
│ │ Plain Background │ └────────────┘ └────────────┘ │
│ │ Current background│ ┌────────────┐ ┌────────────┐ │
│ └───────────────────┘ │ Sunset │ │ Starry Night│ │
│ └────────────┘ └────────────┘ │
│ [Wall panel] [Background ground] │
│ [Close] │
└───────────────────────────────────────────────────────┘
```
Choosing the **Plain Background** card immediately switches the Stage to its remembered plain fill and opens its color choices. This background choice is confirmed at the card tap, just as a Sky card is. The child can close or go Back from the color choices while keeping that fill. Preset and recently used swatches are quick, **confirmed** color choices: tapping one immediately updates the entire Stage, the current-color preview, and recent colors. The current-color chip is a button; tapping it opens the fuller picker. There is no separate **More colors** button.
The full picker starts from the latest confirmed plain color. Moving its controls gives a tentative preview of the Stage background and the large color sample. **Use color** confirms that custom color, records it in recent colors, and returns to the plain color choices. **Back** or Cancel discards the custom draft, restores the confirmed plain fill from before the picker opened, and returns to the color choices. Back from the color choices returns to Backgrounds without undoing the card or swatch choices. This matches the established distinction between direct swatch selection and a custom-color draft.
```text
┌ Choose background color ──────────────────────────────┐
│ Preview: [wide uninterrupted solid color] │
│ Current color [■] #F5EFE0 (tap swatch to pick custom)│
│ Recently used [■] [■] [■] ... (tap to use now) │
│ Colors [■] [■] [■] ... (tap to use now) │
│ [‹ Backgrounds] [Close] │
└───────────────────────────────────────────────────────┘
┌ Custom picker · opened from current-color swatch ────┐
│ Preview: [wide solid color; Stage previews draft] │
│ [color field / sliders] [RGB] [HSV] [Linear] │
│ [Back] [Use color] │
└───────────────────────────────────────────────────────┘
```
Keep the Stage visually undimmed while either Plain Background color view is open, while retaining the transparent, input-blocking modal layer. In the color-choices view, Back and Close stay in the fixed footer; stack them as full-width targets on narrow windows.
The color views follow the existing rounded Scenery chooser and surface color picker visual language: large preview, readable labels, spacious swatches, visible Back, and a blue **Use color** action for the custom draft. They stay within the safe viewport and scroll their body when needed; changing views preserves the chooser's anchor so the clicked control does not jump. Swatches, cards, and actions have at least 44 × 44 logical-pixel targets and usable keyboard focus. The current choice is identified by text and outline, not color alone. The RGB/HSV/Linear controls are the existing advanced color picker behavior, not a new child-facing color model to teach.
The four Sky cards retain their current one-tap behavior. Choosing any Sky changes the scene background immediately. The scene remembers its last chosen plain color, so reopening Plain Background starts with that color and allows the child to use it again. Sky and plain color remain visual backdrops: neither appears in Stage object selection, blocks movement, or recolors Ground, walls, scenery artwork, Props, characters, or speech.
## Story and mechanics considerations
A child can use a plain color as an open visual canvas or as a deliberate mood. They can place walkable Ground and a cast later, then move the camera without revealing a backdrop edge. Sky remains the choice for a designed outdoor look with clouds, sun, moon, or stars. Switching backgrounds changes appearance alone; it does not alter actions, events, camera controls, physics, or the ordinary editability of starter pieces.
## Success criteria
1. **File → New** shows four distinguishable previews and labels: Plain Background, Open Sky, Town Street, and Living Room. Cancel preserves the existing scene; choosing Plain Background opens an unsaved scene with no Ground or placed objects and no Sky imagery.
2. A child in EDIT can choose Plain Background under Scenery → Backgrounds and immediately see the remembered fill. Preset/recent swatches apply immediately. In the custom picker, **Use color** confirms the draft, while **Back** or Cancel restores the last confirmed fill.
3. The committed color fills every camera view without an edge, gradient, sun, cloud, moon, or star. It remains visually consistent through camera pan/zoom, viewport resize, EDIT/DIRECT/PLAY, and PLAY/stop.
4. Switching to any Sky and back restores the scene's chosen plain color. Save/reopen preserves both the active background choice and that remembered color. Previously saved scenes using the neutral Sky keep their established appearance.
5. Background changes do not recolor or change the behavior of placed scenery, Ground, Props, characters, or speech. The background never intercepts Stage selection.
6. At narrow viewport sizes and with keyboard navigation, the starter cards, Backgrounds choices, color controls, and visible Back/Use/Close actions remain readable and reachable.
## Scope and exclusions
**Included:** A new Plain Background starter; the **Open Sky** label for the existing empty sky-backed starter; a scene-wide solid color background choice in EDIT; existing preset/recent swatches and full color picker presentation; clear preview and commit/cancel behavior; remembered plain color across Sky switches; save/reopen support; accurate starter and chooser previews; preservation of existing neutral-Sky scenes.
**Excluded:** New Sky motifs or customization, gradients or patterns for Plain Background, transparency, animation or directed background transitions, background colors tied to time of day, automatic contrast changes to placed objects, wall/Ground recoloring, new story mechanics, and Stage Undo/Redo. The existing Background ground and wall panels remain finite Scenery surfaces with their own separate color behavior.
## Current behavior and references
The current [Phase 19 Scenery brief](phase_19_scenery_backgrounds_surface_finishes.md) and [Scenery UI/UX specification](../specs/phase_19_scenery_backgrounds_ui_ux.md) describe three New Scene cards and four Sky choices; this addition updates those two choice sets only. The [Starter Scene Composition brief](phase_19_starter_scene_composition.md) defines the Town Street and Living Room compositions, which this addition does not revise. Follow the [Dialog Design Principles](../specs/dialog_design_principles.md) for the starter chooser and color flow.
Today, the [blank starter](../../content/stages/starters/blank.scene) contains no objects and selects `sky_neutral`. The [Sky view](../../scripts/stage/scenery/stage_sky_view.gd) draws that neutral choice as a gray gradient with sun and clouds. The [Stage asset browser](../../scripts/editors/stage/stage_asset_browser.gd) supplies the three starter entries and the four Sky cards; the [Scenery chooser](../../scripts/shared/ui/asset_selector.gd) displays them. Existing [surface color controls](../../scripts/shared/stage_ui/stage_surface_color_controls.gd) and [picker view](../../scripts/shared/stage_ui/stage_surface_color_picker_view.gd) provide a familiar color interaction. The [scene file handler](../../scripts/stage/stage_scene_io.gd) currently validates a saved Sky ID, and the [starter preview](../../scripts/stage/scenery/stage_starter_card_preview.gd) renders it. UI/UX and architecture should account for active background choice, remembered plain color, previews, and older files without changing the appearance of existing `sky_neutral` scenes.
## Dependencies, risks, and open choices
- **Grid and selection contrast:** On `#F5EFE0`, current white grid and selection lines have low contrast. The UI/UX specification calls for theme-backed contrasting treatments across light, dark, and saturated backgrounds without recoloring placed artwork.
- **Picker navigation:** The current-color chip is the sole entry to the custom picker; its focus/return behavior and the bounded layout are specified in the UI/UX document. The direct swatch, tentative custom preview, **Use color**, and Back/Cancel outcomes above are settled product behavior.
- **Previews and persistence:** The starter and Backgrounds cards must reflect the selected plain color, and save/reopen must retain the active choice plus the plain color remembered behind a Sky. Architecture should resolve the file representation and legacy migration without changing older neutral Sky scenes.
- **Visual contrast:** A light or dark child-selected background may make some artwork or EDIT overlays harder to see. UI/UX should review selection, grid, mode cues, and speech readability without silently changing the child's chosen artwork colors.
@@ -1,6 +1,6 @@
# Phase 19 — Scenery, Backgrounds, and Vector Surface Finishes
> **Status:** Accepted product/design brief. Technical specifications and implementation remain to be developed through the spec-pipeline.
> **Status (2026-10-22):** Phase 19 implementation and user/editor verification are complete. The user verified the integrated Sky, starter compositions, surface finishes, Plain Background, Scenery artwork, and furniture behavior in the editor, including Plain Background save/reopen. The Phase 19 scenery suite passes **706 checks with 0 failures**. Stage-wide Undo/Redo remains deferred. The later [Plain Background addendum](phase_19_plain_background.md) adds Plain Background and renames the original neutral-Sky Blank Scene choice **Open Sky**; see the [Phase 19 handoff](phase_19_handoff.md) for the verification record.
> **Audience:** UI/UX, architecture, implementation, and art contributors.
> **Purpose:** Define the child experience and the content required for indoor and outdoor sets. Rendering, file contracts, and GDScript APIs belong in later specifications.
@@ -53,10 +53,24 @@ The existing vector Prop Editor and `.prop` assets supply user-created artwork.
### Choose a Sky
**Backgrounds → Sky** shows four designed thumbnail cards: **Clear Day**, **Cloudy Day**, **Sunset**, and **Starry Night**. One tap changes the scene's Sky, and choosing another card changes it again. A Sky fills every camera view without an exposed edge; its clouds, sun, moon, and stars remain visually stable and crisp during pan and zoom. Each preset should be a considered composition rather than a flat color with an obvious repeated icon. The Sky remains fixed throughout PLAY.
**Backgrounds → Sky** shows four designed thumbnail cards: **Clear Day**, **Cloudy Day**, **Sunset**, and **Starry Night**. One tap changes the scene's Sky, and choosing another card changes it again. A Sky fills every camera view without an exposed edge. Its gradient stays fixed to the screen while its clouds, sun, moon, and stars respond gently to camera pan and zoom as described in the [camera-aware Sky details addendum](#camera-aware-sky-details-addendum). Each preset should be a considered composition rather than a flat color with an obvious repeated icon.
Changing Sky changes **only the sky**. It never tints or recolors Ground, wall panels, scenery artwork, characters, or Props. Sky is selected through its chooser, not by clicking a giant rectangle on the Stage.
### Camera-aware Sky details addendum
**Goal and audience.** A child making an outdoor scene should see a Sky that feels distant without making their Ground and stickman appear to float among oversized clouds. This applies when a child chooses any of the four Phase 19 Sky presets, frames a shot in EDIT or DIRECT, and watches it in PLAY.
**Child flow.** The child chooses a Sky as before, places walkable Ground and a stickman, then pans or zooms the Stage camera. The Sky gradient continues to fill the entire view. Clouds and celestial details shift gently with camera movement and change size modestly with camera zoom, so the composition feels connected to the shot while staying distant. The child does not set a motion mode, slider, or per-scene option. Switching presets applies that preset's tuned response automatically. The same view is reproduced after saving and reopening the scene at its saved camera framing.
**Visual behavior.** Each preset keeps its distinctive color and motifs, with detail size and placement tuned to leave clear space around characters and Ground at ordinary framing. Camera response is bounded and smooth: no visible Sky edge, abrupt jump, distracting sweep, or cloud that grows to dominate the action during normal pan and zoom. The gradient remains screen-filling and fixed; only Sky details respond. EDIT, DIRECT, and PLAY use the same camera-relative composition. The chosen Sky remains decorative and never changes the color, collision, or movement of any world object.
**Horizon and Ground.** The Sky does not draw a horizon or a ground line. A child who wants land behind the playable area uses the existing world-anchored **Background ground** scenery surface, placed behind walkable Ground. This keeps a visible landscape edge aligned with the scene as the camera moves. Softer contours, automatic edge blending, and easier horizon construction remain the separate [backdrop horizon and edge treatment idea](../ideas/feature_backlog.md); this addendum does not define or ship those tools.
**Success criteria.** At ordinary and representative close/wide camera views, a character on Ground reads as being in front of the Sky rather than inside a cloud. Panning and zooming each preset gives a perceptible but subtle response without revealing an edge or obscuring the action. The same camera framing reproduces the same Sky composition after a mode change, PLAY/stop cycle, or save/reopen, and the composition adapts coherently to a resized viewport. A Background ground surface and walkable Ground stay aligned with each other while Sky details respond behind them. Sky chooser previews still identify the four looks and do not imply that the child must configure camera behavior.
**Scope and handoff.** This is a Phase 19 visual-behavior addition for the four existing Sky presets. It preserves the existing Sky choice and screen-filling gradient. It excludes autonomous cloud/weather animation, a child-facing motion control, a Sky-ground horizon, multi-layer parallax scenery, and changes to terrain physics or Background ground placement. UI/UX and architecture should resolve how much pan/zoom response is comfortable for each preset and how the view stays composed at viewport extremes. Relevant current references are `scripts/stage/scenery/stage_sky_view.gd`, `scripts/editors/stage/sandbox_stage.gd`, `scripts/stage/scenery/stage_background_ground.gd`, the [Phase 19 Sky UI/UX specification](../specs/phase_19_scenery_backgrounds_ui_ux.md), and the [Background Ground and Wall Color plan](phase_19_background_ground_and_wall_color.md). Those earlier specifications describe the original static behavior and need a focused follow-up before implementation.
### Draw a wall panel
**Backgrounds → Wall panel** offers **Paint**, **Brick**, and **Wood** finishes with a color choice. The child chooses a finish, drags from one corner to the other, sees a translucent preview of the intended size and pattern, and releases to place a finite rectangular panel. Snap may help edges meet. A selected panel can later be moved, resized, recolored, restyled, or erased; changing one panel does not silently alter others.
@@ -81,7 +95,7 @@ Sky
Every Scenery tier is visual only. Its depth never grants collision, support, pickup, Use spots, or event participation. An On the floor rug is visible above a wood Ground finish without covering furniture or the character's feet.
**Edit Scenery** exposes the scenery pieces for selection. A clear outline and large handles let the child move and resize art, change its depth, or erase it; a wall panel also exposes Finish and Color. Broad panels must not steal clicks or box selections from characters, Ground, and Props in ordinary EDIT. Within Edit Scenery, a cutout in front of a panel should be easy to choose, and the panel must still be reachable through its exposed area, edge, or an overlapping-items choice. Sky remains in its own chooser. A visible Cancel closes a placement or sizing gesture before commit, and leaving Edit Scenery returns to ordinary selection. Selection and focus cannot rely on color alone.
**Edit Scenery** exposes the scenery pieces for selection. A clear outline and large handles let the child move and resize art, change its depth, or erase it; a wall panel also exposes Finish and Color. Broad panels must not steal clicks or box selections from characters, Ground, and Props in ordinary EDIT. Within Edit Scenery, a cutout in front of a panel should be easy to choose, and the panel must still be reachable through its exposed area, edge, or an overlapping-items choice. A selected wall panel also offers **Erase wall area**: drag a rectangle to remove that area from one or more wall panels while keeping the surrounding pieces and their finish. This is a manual visual cutout; it does not create glass, collision, or doorway behavior. Each released cut is immediate; Cancel/Escape/right-click discard only the current drag, and Stage Undo remains deferred to its own future phase. Sky remains in its own chooser. Placement and group-resize previews have visible Cancel actions. A single-piece move or resize previews during its direct-manipulation gesture and commits on release; while the gesture is active it can be canceled through the capture bar (touch), Escape, or right-click. There is no second Keep confirmation. Leaving Edit Scenery returns to ordinary selection. Selection and focus cannot rely on color alone.
### Finish Ground and play
@@ -89,28 +103,30 @@ The existing **Ground** palette entry gains a visual finish chooser. Picture swa
Ground remains solid and walkable. This phase gives finishes only to **Ground**; it does not redesign or restyle Ramp and Step. Children can use Sidewalk and Asphalt Ground in Town Street and Wood Ground in Living Room. A Scenery wall panel or road marking never becomes a movement surface.
In **PLAY**, edit handles and placement ghosts disappear. Sky and Scenery stay still; characters move on Ground; Props follow their own saved behavior settings. Stopping PLAY restores the authored arrangement for another take. Story playback and camera movement should preserve the intended visual layer order and the child's view of the action.
In **PLAY**, edit handles and placement ghosts disappear. Scenery stays put; Sky details respond gently to camera pan and zoom under the same rules as EDIT and DIRECT. Characters move on Ground; Props follow their own saved behavior settings. Stopping PLAY restores the authored arrangement for another take. Story playback and camera movement should preserve the intended visual layer order and the child's view of the action.
## Included scope and exclusions
Included in Phase 19: one fixed scene-wide Sky preset; four initial Sky designs; finite editable wall panels; inert `.prop` Scenery placements with Far, Near, and On the floor depth; built-in multi-select browse tags; Ground-only visual finishes; editable Blank/Town Street/Living Room New Scene choices; Scenery-safe selection; scene save/reopen and legacy defaults; and the built-in vector content listed below.
Included in Phase 19: one chosen scene-wide Sky with a screen-filling fixed gradient and gentle automatic camera-aware details; four initial Sky designs; finite editable wall panels with manually erasable rectangular openings; inert `.prop` Scenery placements with Far, Near, and On the floor depth; built-in multi-select browse tags; Ground-only visual finishes; editable Blank/Town Street/Living Room New Scene choices; Scenery-safe selection; scene save/reopen and legacy defaults; built-in vector content listed below; and a stage-relative size-authoring guide/workflow shared by Props and Scenery artwork, with single Scenery artwork placements resizable independently in Edit Scenery. The new authoring convention does not silently rescale existing assets or saved placements.
Excluded from Phase 19: a new Scenery Editor or `.scenery` file; child-authored repeatable finish patterns or reusable whole-Sky themes; animated weather, parallax, or directed Sky transitions; whole-scene filters such as sepia or black-and-white; functional doors or scene travel; automatic window cutouts or glass; Ramp/Step finishes or terrain redesign; inserting a starter into an existing scene; user-authored reusable set templates; and functional stairs or multi-level travel. [Multi-level walkable scenes](../ideas/feature_backlog.md) and [whole-scene visual filters](../ideas/feature_backlog.md) are separate future ideas, not Phase 19 work.
Excluded from Phase 19: a new Scenery Editor or `.scenery` file; child-authored repeatable finish patterns or reusable whole-Sky themes; autonomous weather animation, multi-layer parallax scenery, or directed Sky transitions; whole-scene filters such as sepia or black-and-white; functional doors or scene travel; automatic window cutouts or glass; Ramp/Step finishes or terrain redesign; inserting a starter into an existing scene; user-authored reusable set templates; and functional stairs or multi-level travel. [Multi-level walkable scenes](../ideas/feature_backlog.md) and [whole-scene visual filters](../ideas/feature_backlog.md) are separate future ideas, not Phase 19 work.
## Asset production manifest
This is the authoritative content checklist for the two starters and the shared pickers. The initial status of **every** row is **Needed**. Progress through **Needed → Concept ready → Art approved → Final vector ready → Integrated → Verified**. A raster sketch may reach **Concept ready**; it is not a final `.prop`, Sky theme, or repeatable vector finish. Final cutouts must be repo-native vector/`.prop` artwork, and Sky/finish visuals must remain crisp through Stage zoom. Art contributors may use raster concept images as reference, then produce final assets in coordination with implementation.
This is the authoritative content checklist for the two starters and the shared pickers. The initial status of every row was **Needed**. Progress through **Needed → Concept ready → Art approved → Final vector ready → Integrated → Verified**. Following user approval on 2026-10-15, the approved `.prop` vectors, Sky themes, and finishes are integrated into the production Stage, shared picker, and starter scenes. The complete manifest now records production integration, automated verification, and user/editor visual verification completed on 2026-10-22. The user also verified Plain Background save/reopen; this does not claim a manual save/reopen check for every manifest item. A raster sketch may reach **Concept ready**; it is not a final `.prop`, Sky theme, or repeatable vector finish. Final cutouts must be repo-native vector/`.prop` artwork, and Sky/finish visuals must remain crisp through Stage zoom. Art contributors may use raster concept images as reference, then produce final assets in coordination with implementation.
In the tables, **TBD** in Source/rights means the creator, reference provenance, and license or permission still need recording. **TBD** in Final location and Evidence means those fields must be filled as content is produced. For each row, validation evidence should include a thumbnail or Sky/finish preview, the asset in the Stage at normal and zoomed views, and save/reopen of its starter use where applicable. Stable IDs identify deliverables; they are not prescribed file names or runtime contracts.
### Adaptive Sky themes
The **Verified** statuses below describe the production Sky artwork and picker, including the camera-aware detail behavior. The user completed editor visual verification on 2026-10-22.
| Stable ID | Class / final format | Tags / default layer | Starter use | Behavior / collision | Status | Source / rights | Visual acceptance | Final location / definition | Validation evidence |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `sky_clear_day` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Living Room; picker | Scene-wide, static; none | Needed | TBD | Appealing blue depth and varied light cloud/sun detail; window gap reads clearly; no edge on pan. | TBD | TBD |
| `sky_cloudy_day` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Town Street; picker | Scene-wide, static; none | Needed | TBD | Layered, varied clouds leave town silhouettes readable; no obvious tile or edge. | TBD | TBD |
| `sky_sunset` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Picker | Scene-wide, static; none | Needed | TBD | Warm atmospheric color and sun/cloud composition remain distinct from Clear Day without tinting scene objects. | TBD | TBD |
| `sky_starry_night` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Picker | Scene-wide, static; none | Needed | TBD | Dark graduated sky, varied stars, and moon remain legible while foreground contrast survives. | TBD | TBD |
| `sky_clear_day` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Living Room; picker | Scene-wide; camera-aware details; none | Verified | Original vector concept | Appealing blue depth and varied light cloud/sun detail; window gap reads clearly; no edge on pan. | `scripts/stage/scenery/stage_sky_view.gd` | Production card and Living Room Stage render inspected: `tests/phase19_visual_background_gallery.png`, `tests/phase19_visual_living_room.png`; user/editor visual verification completed 2026-10-22. |
| `sky_cloudy_day` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Town Street; picker | Scene-wide; camera-aware details; none | Verified | Original vector concept | Layered, varied clouds leave town silhouettes readable; no obvious tile or edge. | `scripts/stage/scenery/stage_sky_view.gd` | Production card and Town Street Stage render inspected: `tests/phase19_visual_background_gallery.png`, `tests/phase19_visual_town_street.png`; user/editor visual verification completed 2026-10-22. |
| `sky_sunset` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Picker | Scene-wide; camera-aware details; none | Verified | Original vector concept | Warm atmospheric color and sun/cloud composition remain distinct from Clear Day without tinting scene objects. | `scripts/stage/scenery/stage_sky_view.gd` | Production card and Stage render inspected: `tests/phase19_visual_background_gallery.png`, `tests/phase19_visual_finishes_and_repair.png`; user/editor visual verification completed 2026-10-22. |
| `sky_starry_night` | Adaptive vector Sky theme; not `.prop` | N/A; Sky | Picker | Scene-wide; camera-aware details; none | Verified | Original vector concept | Dark graduated sky, varied stars, and moon remain legible while foreground contrast survives. | `scripts/stage/scenery/stage_sky_view.gd` | Distinct production gallery preview inspected: `tests/phase19_visual_background_gallery.png`; full Stage/editor visual verification completed 2026-10-22. |
### Repeatable vector surface finishes
@@ -118,16 +134,16 @@ These are finish definitions and previews, not ordinary Props. Ground finishes c
| Stable ID | Class / final format | Tags / default layer | Starter use | Behavior / collision | Status | Source / rights | Visual acceptance | Final location / definition | Validation evidence |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `finish_wall_paint` | Vector wall finish | N/A; wall panel | Living Room; picker | Inert panel; none | Needed | TBD | Smooth green room wall and other chosen colors without seams at panel edges. | TBD | TBD |
| `finish_wall_brick` | Repeatable vector wall finish | N/A; wall panel | Picker | Inert panel; none | Needed | TBD | Bricks keep coherent size and joins as panel is resized. | TBD | TBD |
| `finish_wall_wood` | Repeatable vector wall finish | N/A; wall panel | Picker | Inert panel; none | Needed | TBD | Boards read as wood at common Stage zoom and do not stretch with the panel. | TBD | TBD |
| `finish_ground_paint` | Vector Ground finish | N/A; Ground | Picker | Solid, walkable Ground | Needed | TBD | Plain color is clean and visually distinct from wall-only backdrop. | TBD | TBD |
| `finish_ground_brick` | Repeatable vector Ground finish | N/A; Ground | Picker | Solid, walkable Ground | Needed | TBD | Neighboring pieces form a continuous brick surface. | TBD | TBD |
| `finish_ground_wood` | Repeatable vector Ground finish | N/A; Ground | Living Room; picker | Solid, walkable Ground | Needed | TBD | Wood plank floor reads continuously beneath rug and furniture. | TBD | TBD |
| `finish_ground_grass` | Repeatable vector Ground finish | N/A; Ground | Picker | Solid, walkable Ground | Needed | TBD | Grassy surface is readable and not noisy behind feet. | TBD | TBD |
| `finish_ground_dirt` | Repeatable vector Ground finish | N/A; Ground | Picker | Solid, walkable Ground | Needed | TBD | Earthy surface remains distinct from Grass at thumbnail and Stage size. | TBD | TBD |
| `finish_ground_sidewalk` | Repeatable vector Ground finish | N/A; Ground | Town Street; picker | Solid, walkable Ground | Needed | TBD | Light concrete sidewalk forms a broad, aligned run beneath shops. | TBD | TBD |
| `finish_ground_asphalt` | Repeatable vector Ground finish | N/A; Ground | Town Street; picker | Solid, walkable Ground | Needed | TBD | Darker road contrasts with sidewalk while preserving visible character edges. | TBD | TBD |
| `finish_wall_paint` | Vector wall finish | N/A; wall panel | Living Room; picker | Inert panel; none | Verified | Original vector concept | Smooth green room wall and other chosen colors without seams at panel edges. | `scripts/stage/scenery/stage_surface_renderer.gd` | Production gallery preview and Living Room render inspected: `tests/phase19_visual_background_gallery.png`, `tests/phase19_visual_living_room.png`; user/editor visual verification completed 2026-10-22. |
| `finish_wall_brick` | Repeatable vector wall finish | N/A; wall panel | Picker | Inert panel; none | Verified | Original vector concept; user image used as inspiration for v2 | Bricks keep coherent size and joins as panel is resized. | `scripts/stage/scenery/stage_surface_renderer.gd` | Production gallery preview and Stage wall render inspected: `tests/phase19_visual_background_gallery.png`, `tests/phase19_visual_finishes_and_repair.png`; user/editor visual verification completed 2026-10-22. |
| `finish_wall_wood` | Repeatable vector wall finish | N/A; wall panel | Picker | Inert panel; none | Verified | Original vector concept | Boards read as wood at common Stage zoom and do not stretch with the panel. | `scripts/stage/scenery/stage_surface_renderer.gd` | Production gallery preview inspected: `tests/phase19_visual_background_gallery.png`; user/editor visual verification completed 2026-10-22. |
| `finish_ground_paint` | Vector Ground finish | N/A; Ground | Picker | Solid, walkable Ground | Verified | Original vector concept | Plain color is clean and visually distinct from wall-only backdrop. | `scripts/stage/scenery/stage_surface_renderer.gd` | Production Ground renderer is integrated with Stage; Phase 19 runtime suite passed; user/editor visual verification completed 2026-10-22. |
| `finish_ground_brick` | Repeatable vector Ground finish | N/A; Ground | Picker | Solid, walkable Ground | Verified | Original vector concept | Neighboring pieces form a continuous brick surface. | `scripts/stage/scenery/stage_surface_renderer.gd` | Production Ground renderer is integrated with Stage; Phase 19 runtime suite passed; user/editor visual verification completed 2026-10-22. |
| `finish_ground_wood` | Repeatable vector Ground finish | N/A; Ground | Living Room; picker | Solid, walkable Ground | Verified | Original vector concept | Wood plank floor reads continuously beneath rug and furniture. | `scripts/stage/scenery/stage_surface_renderer.gd` | Production Living Room scene and Ground renderer integrated; Phase 19 runtime suite passed; user/editor visual verification completed 2026-10-22. |
| `finish_ground_grass` | Repeatable vector Ground finish | N/A; Ground | Picker | Solid, walkable Ground | Verified | Original vector concept | Grassy surface is readable and not noisy behind feet. | `scripts/stage/scenery/stage_surface_renderer.gd` | Production Ground render inspected: `tests/phase19_visual_finishes_and_repair.png`; runtime suite passed; user/editor visual verification completed 2026-10-22. |
| `finish_ground_dirt` | Repeatable vector Ground finish | N/A; Ground | Picker | Solid, walkable Ground | Verified | Original vector concept | Earthy surface remains distinct from Grass at thumbnail and Stage size. | `scripts/stage/scenery/stage_surface_renderer.gd` | Production Ground renderer is integrated with Stage; Phase 19 runtime suite passed; user/editor visual verification completed 2026-10-22. |
| `finish_ground_sidewalk` | Repeatable vector Ground finish | N/A; Ground | Town Street; picker | Solid, walkable Ground | Verified | Original vector concept | Light concrete sidewalk forms a broad, aligned run beneath shops. | `scripts/stage/scenery/stage_surface_renderer.gd` | Town Street starter load and runtime suite passed; user/editor visual verification completed 2026-10-22. |
| `finish_ground_asphalt` | Repeatable vector Ground finish | N/A; Ground | Town Street; picker | Solid, walkable Ground | Verified | Original vector concept | Darker road contrasts with sidewalk while preserving visible character edges. | `scripts/stage/scenery/stage_surface_renderer.gd` | Town Street starter load and runtime suite passed; user/editor visual verification completed 2026-10-22. |
### Reusable Scenery artwork
@@ -135,17 +151,17 @@ Each item below is a `.prop` vector artwork asset offered through Scenery. Its S
| Stable ID | Class / final format | Tags / default layer | Starter use | Behavior / collision | Status | Source / rights | Visual acceptance | Final location / definition | Validation evidence |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `art_city_skyline` | Vector `.prop` cutout | Buildings; Far | Town Street ×1 | Inert; none | Needed | TBD | Long, muted overlapping building silhouette reads as distant and leaves cloud sky visible. | TBD | TBD |
| `art_storefront` | Vector `.prop` cutout | Buildings; Near | Town Street ×1 or more | Inert; none | Needed | TBD | Colorful, readable shop front contrasts with skyline; repeat use does not look like one flat strip. | TBD | TBD |
| `art_town_house` | Vector `.prop` cutout | Buildings; Near | Town Street ×1 or more | Inert; none | Needed | TBD | House silhouette and palette differ clearly from storefront at normal zoom. | TBD | TBD |
| `art_tree` | Vector `.prop` cutout | Nature; Near | Town Street ×2 or more | Inert; none | Needed | TBD | Friendly, readable crown/trunk; repeated copies tolerate size and position variation. | TBD | TBD |
| `art_window_frame` | Vector `.prop` cutout | Walls, Buildings; Near | Living Room ×1 | Inert; none | Needed | TBD | Frame is transparent at its center and aligns over the wall-panel gap to reveal actual Sky. | TBD | TBD |
| `art_fireplace_mantel` | Vector `.prop` cutout | Walls, Objects; Near | Living Room ×1 | Inert; none | Needed | TBD | Fireplace and mantel read clearly as decoration without suggesting this asset itself blocks movement. | TBD | TBD |
| `art_wall_clock` | Vector `.prop` cutout | Walls, Objects; Near | Living Room ×1 | Inert; none | Needed | TBD | Clock reads at room scale without tiny illegible markings. | TBD | TBD |
| `art_bookshelf` | Vector `.prop` cutout | Objects, Walls; Near | Living Room ×1 | Inert; none | Needed | TBD | Shelf, books, and plant details remain legible without dense tracing. | TBD | TBD |
| `art_framed_picture` | Vector `.prop` cutout | Objects, Walls; Near | Living Room ×1 or more | Inert; none | Needed | TBD | Clear frame and image contrast on green paint; reuse can vary size/position. | TBD | TBD |
| `art_potted_plant` | Vector `.prop` cutout | Nature, Objects; Near | Living Room ×1 | Inert; none | Needed | TBD | Leaf and pot shapes remain distinct from wall and furniture. | TBD | TBD |
| `art_rug` | Vector `.prop` cutout | Objects; On the floor | Living Room ×1 | Inert; none | Needed | TBD | Visible over Wood Ground and below furniture/feet; border survives zoom. | TBD | TBD |
| `art_city_skyline` | Vector `.prop` cutout | Buildings; Far | Town Street ×1 | Inert; none | Verified | Original vector art | Long, muted overlapping building silhouette reads as distant and leaves cloud sky visible. | `content/props/art_city_skyline-v1.prop` | Town Street production Stage render inspected: `tests/phase19_visual_town_street.png`; save/reopen suite passed; user/editor visual verification completed 2026-10-22. |
| `art_storefront` | Vector `.prop` cutout | Buildings; Near | Town Street ×1 or more | Inert; none | Verified | Original vector art | Colorful, readable shop front contrasts with skyline; repeat use does not look like one flat strip. | `content/props/art_storefront-v1.prop` | Town Street production Stage render inspected: `tests/phase19_visual_town_street.png`; save/reopen suite passed; user/editor visual verification completed 2026-10-22. |
| `art_town_house` | Vector `.prop` cutout | Buildings; Near | Town Street ×1 or more | Inert; none | Verified | Original vector art | House silhouette and palette differ clearly from storefront at normal zoom. | `content/props/art_town_house-v1.prop` | Town Street production Stage render inspected: `tests/phase19_visual_town_street.png`; save/reopen suite passed; user/editor visual verification completed 2026-10-22. |
| `art_tree` | Vector `.prop` cutout | Nature; Near | Town Street ×2 or more | Inert; none | Verified | Original vector art; user image used as inspiration for v2 | Friendly, readable crown/trunk; repeated copies tolerate size and position variation. | `content/props/art_tree-v2.prop` | Revised v2 tree inspected in Town Street and repair view: `tests/phase19_visual_town_street.png`, `tests/phase19_visual_finishes_and_repair.png`; user/editor visual verification completed 2026-10-22. |
| `art_window_frame` | Vector `.prop` cutout | Walls, Buildings; Near | Living Room ×1 | Inert; none | Verified | Original vector art | Frame is transparent at its center and aligns over the wall-panel gap to reveal actual Sky. | `content/props/art_window_frame-v1.prop` | Living Room production render inspected: `tests/phase19_visual_living_room.png`; save/reopen suite passed; user/editor visual verification completed 2026-10-22. |
| `art_fireplace_mantel` | Vector `.prop` cutout | Walls, Objects; Near | Living Room ×1 | Inert; none | Verified | Original vector art | Fireplace and mantel read clearly as decoration without suggesting this asset itself blocks movement. | `content/props/art_fireplace_mantel-v2.prop` | Revised v2 fireplace inspected in Living Room: `tests/phase19_visual_living_room.png`; user/editor visual verification completed 2026-10-22. |
| `art_wall_clock` | Vector `.prop` cutout | Walls, Objects; Near | Living Room ×1 | Inert; none | Verified | Original vector art | Clock reads at room scale without tiny illegible markings. | `content/props/art_wall_clock-v1.prop` | Living Room production render inspected: `tests/phase19_visual_living_room.png`; user/editor visual verification completed 2026-10-22. |
| `art_bookshelf` | Vector `.prop` cutout | Objects, Walls; Near | Living Room ×1 | Inert; none | Verified | Original vector art | Shelf, books, and plant details remain legible without dense tracing. | `content/props/art_bookshelf-v1.prop` | Living Room production render inspected: `tests/phase19_visual_living_room.png`; user/editor visual verification completed 2026-10-22. |
| `art_framed_picture` | Vector `.prop` cutout | Objects, Walls; Near | Living Room ×1 or more | Inert; none | Verified | Original vector art | Clear frame and image contrast on green paint; reuse can vary size/position. | `content/props/art_framed_picture-v1.prop` | Living Room production render inspected: `tests/phase19_visual_living_room.png`; user/editor visual verification completed 2026-10-22. |
| `art_potted_plant` | Vector `.prop` cutout | Nature, Objects; Near | Living Room ×1 | Inert; none | Verified | Original vector art | Leaf and pot shapes remain distinct from wall and furniture. | `content/props/art_potted_plant-v1.prop` | Living Room production render inspected: `tests/phase19_visual_living_room.png`; user/editor visual verification completed 2026-10-22. |
| `art_rug` | Vector `.prop` cutout | Objects; On the floor | Living Room ×1 | Inert; none | Verified | Original vector art | Visible over Wood Ground and below furniture/feet; border survives zoom. | `content/props/art_rug-v1.prop` | Living Room production render inspected: `tests/phase19_visual_living_room.png`; starter runtime suite passed; user/editor visual verification completed 2026-10-22. |
### Interactive furniture Props
@@ -153,11 +169,11 @@ These are `.prop` assets placed through Props in Living Room. Their authored sha
| Stable ID | Class / final format | Tags / default layer | Starter use | Behavior / collision | Status | Source / rights | Visual acceptance | Final location / definition | Validation evidence |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `prop_couch` | Vector `.prop` furniture | Objects; Prop depth per placement | Living Room ×1 | Still, appropriately character-blocking; valid collision | Needed | TBD | Comfortable couch silhouette sized for stickman and room; no implied Sit action. | TBD | TBD |
| `prop_armchair` | Vector `.prop` furniture | Objects; Prop depth per placement | Living Room ×1 | Still, appropriately character-blocking; valid collision | Needed | TBD | Distinct chair silhouette, matching couch style and character scale. | TBD | TBD |
| `prop_coffee_table` | Vector `.prop` furniture | Objects; Prop depth per placement | Living Room ×1 | Still, can support small props; valid collision | Needed | TBD | Tabletop/support reads clearly and leaves rug visible. | TBD | TBD |
| `prop_couch` | Vector `.prop` furniture | Objects; Prop depth per placement | Living Room ×1 | Still, appropriately character-blocking; valid collision | Verified | Original vector art | Comfortable couch silhouette sized for stickman and room; no implied Sit action. | `content/props/prop_couch-v1.prop` | Living Room production render inspected: `tests/phase19_visual_living_room.png`; prop integration regression available; user/editor visual verification completed 2026-10-22. |
| `prop_armchair` | Vector `.prop` furniture | Objects; Prop depth per placement | Living Room ×1 | Still, appropriately character-blocking; valid collision | Verified | Original vector art | Distinct chair silhouette, matching couch style and character scale. | `content/props/prop_armchair-v1.prop` | Living Room production render inspected: `tests/phase19_visual_living_room.png`; prop integration regression available; user/editor visual verification completed 2026-10-22. |
| `prop_coffee_table` | Vector `.prop` furniture | Objects; Prop depth per placement | Living Room ×1 | Still, can support small props; valid collision | Verified | Original vector art | Tabletop/support reads clearly and leaves rug visible. | `content/props/prop_coffee_table-v1.prop` | Living Room production render inspected: `tests/phase19_visual_living_room.png`; prop integration regression available; user/editor visual verification completed 2026-10-22. |
Existing built-in crate and ball artwork may be used in Town Street if an interactive object benefits that starter; no new interactive town asset is required to achieve its visual reference. The artist should establish a coherent cartoon style and scale against the live stickman. Far art is quieter than Near art; outlines and silhouettes must remain clear at selector-thumbnail size and normal Stage zoom. Avoid excessive vector point counts, hard-to-read microdetail, and busy contrast behind character faces or speech bubbles. The source/rights field must distinguish original art, licensed sources, and generated raster concepts. Reference art is not automatically licensed final content.
Existing built-in crate and ball artwork may be used in Town Street if an interactive object benefits that starter; no new interactive town asset is required to achieve its visual reference. The artist should establish a coherent cartoon style and author each asset at its intended Stage size relative to the standard approximately 940-world-unit standing stickman. Infer ordinary dimensions from a recognizable object's name and use, and record approximate intended bounds in stickman units (height, plus width when useful) with a scale-check preview; ask only when the intended use makes size genuinely ambiguous. Far art is quieter than Near art; outlines and silhouettes must remain clear at selector-thumbnail size and normal Stage zoom. Avoid excessive vector point counts, hard-to-read microdetail, and busy contrast behind character faces or speech bubbles. The source/rights field must distinguish original art, licensed sources, and generated raster concepts. Reference art is not automatically licensed final content.
## Success criteria
@@ -165,10 +181,12 @@ Existing built-in crate and ball artwork may be used in Town Street if an intera
2. A child can create Living Room from New Scene, see the actual selected Sky through its window gap, change the Sky without tinting the room, restyle the wall or Wood Ground, move the rug and furniture independently, and save/reopen the revised set.
3. A child can place one `.prop` drawing through Scenery and through Props and understand that the two copies behave differently. The Scenery copy never blocks, supports, moves, is picked up or used, or triggers an event in PLAY.
4. Far skyline, Near buildings/trees, Ground, On the floor rug, and Props preserve their intended order through pan, zoom, PLAY, stop, and reload. Character and speech readability survives the four Sky styles and common finishes.
5. Ordinary EDIT selection still reaches foreground characters, Props, and Ground over broad panels. Edit Scenery makes every cutout and panel discoverable and correctable; placement and resize can be canceled before commit. Labels, icons, outlines, and focus reveal state without depending on color alone.
5. Ordinary EDIT selection still reaches foreground characters, Props, and Ground over broad panels. Edit Scenery makes every cutout and panel discoverable and correctable; placement and an active resize gesture can be canceled before commit, while a single-piece resize commits on release and group resize retains Keep/Cancel. Labels, icons, outlines, and focus reveal state without depending on color alone.
6. Wall panel and Ground finish choices preview correctly; wall patterns do not stretch as panels resize, and adjacent Ground pieces do not show distracting pattern restarts. Changing a palette finish does not repaint existing pieces; a selected piece can be changed deliberately.
7. Older `.scene` documents retain their prior neutral background and Ground appearance when opened. Older untagged `.prop` assets remain visible in All artwork, and a missing referenced Scenery asset yields a visible recovery cue rather than silently removing the child's placement.
8. Every required manifest item reaches **Verified**, with source/rights and final location recorded. A raster concept alone cannot satisfy a final vector content requirement.
8. Each chosen Sky keeps its gradient screen-filling while its details respond gently and predictably to camera pan and zoom in EDIT, DIRECT, and PLAY, as specified in the [camera-aware Sky details addendum](#camera-aware-sky-details-addendum). The action remains readable, no edge appears, and the same framing survives save/reopen.
9. Every required manifest item reaches **Verified**, with source/rights and final location recorded. A raster concept alone cannot satisfy a final vector content requirement.
10. New and revised `.prop` artwork is authored at its intended Stage size relative to the standard approximately 940-world-unit standing stickman, avoiding a mandatory resize on ordinary placement. The Prop Editor's Size for Stage guide is visible over artwork and above a floor/contact cue. Loading performs no automatic normalization; deliberate asset overwrites retain the usual live-reference behavior and existing Save workflow, without new copy/version prompts. Individual Scenery pieces resize independently, preserve proportions, and save/reopen at the committed scale.
## Current project context, risks, and dependencies
@@ -178,13 +196,14 @@ Existing built-in crate and ball artwork may be used in Town Street if an intera
- Stage selection currently uses World-object bounds and an overlapping-object chooser. Large wall panels and floor art can otherwise catch almost every click or box selection. A dedicated, forgiving Scenery edit flow and predictable hit order are product requirements. The approved [Stage panel UI system](../specs/stage_panel_ui_system.md) supplies the in-stage inspector visual contract; adapt it for selected Scenery/Ground controls where it fits.
- Finite panels, tiled vector finishes, and detailed Sky motifs must stay crisp and performant across Stage zoom and camera pan. Ground pieces need aligned world-scale patterns. The Town Street and Living Room references should guide composition and mood, not be treated as final licensed assets.
- The two starters require the manifest content as real deliverables. Missing art, inconsistent scale, or raster-only placeholders would leave a child with a visually incomplete feature even if the controls work.
- Stage-relative authoring scale must account for both tall and wide props/artwork without forcing every asset to one generic height. The stickman comparison guides intent; the artist records an approximate scale, and the Prop Editor provides reversible correction. Loading does not normalize existing assets. Deliberately saving revised geometry over a live reference updates that drawing in referencing scenes; the user chose to retain the existing Save workflow because scenes are not yet in production.
## Remaining questions for the spec-pipeline
## Specification decisions recorded
These are design and architecture tasks for the specification stage, not unresolved user choices about Phase 19's product goal:
The following design questions were resolved in the linked UI/UX and architecture specifications and verified with the integrated Phase 19 behavior:
1. What exact UI presents Backgrounds, Artwork tags, placement layer, Ground finish, and Edit Scenery without crowding the existing Stage palette? Define keyboard, touch, narrow-viewport, dismissal, and undo/cancel behavior using the established Stage panel patterns.
2. How will an adaptive Sky fill unbounded camera views while keeping motifs stable and detailed, and how will wall and Ground finishes keep repeatable pattern scale and alignment through resizing, drag painting, and zoom?
1. What exact UI presents Backgrounds, Artwork tags, placement layer, Ground finish, and Edit Scenery without crowding the existing Stage palette? Define keyboard, touch, narrow-viewport, dismissal, and cancellation behavior using the established Stage panel patterns; Stage Undo remains out of scope for Phase 19 and is tracked separately.
2. How will an adaptive Sky fill unbounded camera views while keeping its gradient fixed and its details gently camera-aware as described in the addendum, and how will wall and Ground finishes keep repeatable pattern scale and alignment through resizing, drag painting, and zoom?
3. What saved scene and optional `.prop` tag fields, validation, migration defaults, missing-art recovery, and starter creation flow preserve existing documents and the same artwork's independent Scenery/Prop placements?
4. How will selected panels, cutouts, rugs, Ground, and ordinary objects remain reachable where bounds overlap, including the window gap and a room-sized wall?
5. Which asset production workflow turns concepts into final vector content, records provenance, checks collision only for interactive Props, and advances each manifest row through integration and verification?
@@ -0,0 +1,63 @@
# Phase 19 — Starter Scene Composition
> **Status (2026-10-22):** Implemented and user/editor verified as part of Phase 19. The user verified both starter compositions in the editor; the Phase 19 scenery suite passes **706 checks with 0 failures**. The detailed composition decisions below remain the historical product and implementation brief.
## Goal and audience
Make **Town Street** and **Living Room** look like complete, usable story sets when a child opens them, moves the camera, places a stickman, and presses PLAY. The audience is a child starting from **File → New** who should see a clear place to stage action and be able to change its individual pieces.
The selected visual direction is the upper-left **Everyday Storybook** composition from the outdoor and indoor concept boards. It favors friendly shapes, a calm backdrop, a clear floor line, and open space for characters and speech.
## Desired child outcome
- **Town Street:** A child immediately recognizes a neighborhood street. Shops, houses, and trees stand on the same sidewalk line; a subdued skyline gives distance; the center has room for a character to walk or speak. The sidewalk, curb, and road remain visible as a continuous set as the child pans through the intended starter area.
- **Living Room:** A child immediately recognizes a room with a complete wall and wood floor. The window is the only opening to the Sky. Furniture rests on the floor, and a centered rug gives the room warmth while leaving a clear action lane.
- In either starter, the child can select, move, resize, restyle, or remove ordinary pieces with the existing Stage tools, then save and reopen the revised scene.
## Core behavior and composition
**Opening and editing.** Keep the existing Blank Scene, Town Street, and Living Room choices and the current unsaved-scene flow. The two revised starters should open at a useful camera frame with no setup step. They remain editable scenes assembled from the existing Sky, Scenery, wall/background surfaces, walkable Ground, and Props. Do not flatten a starter into a single picture or merge independently useful objects merely to hide seams.
**Outdoor set.** Use the current Cloudy Day Sky and a quiet Far skyline. Arrange the existing house, storefront, and tree artwork so their visible bases meet one consistent street datum. Preserve a generous, readable opening near the initial center for a stickman and speech. Carry Sidewalk and Asphalt as left-to-right rows of ordinary-size Ground tiles across the visible frame and agreed pan range. Add an opaque, non-walkable Background ground layer below the skyline datum to cover clouds showing through the lower scene. Buildings and trees may repeat or vary in size and spacing to support a longer street, while remaining individual editable placements.
**Indoor set.** Carry the painted wall and a left-to-right row of ordinary-size Wood Ground tiles across the initial frame and agreed pan range. Make the wall and floor meet cleanly with a visible baseboard or equivalent visual edge. Keep the intentional opening beneath the window frame as the only place the selected Sky shows through; no Sky should leak at outer wall edges, below the floor, or through unintended seams. Position the couch, armchair, and coffee table so they visually rest on the floor. Center the existing rug in the room, show it above the wood finish and below action, and leave enough uncluttered floor for a character to move and be seen. Wall decoration remains secondary to the window and action area.
**Camera envelope.** The approved implementation target is a **4,760-world-unit** minimum for each walkable span: a 1,280-logical-pixel viewport at saved zoom 0.5, ±1,000 world units of pan from the saved camera, and 100 world units of margin at each end. Twenty-five standard 192-unit tiles span 4,800 units and satisfy the target without oversized scaled blocks. Town Asphalt adds one normal-size tile at each end (27 total) so the lower road is physically reachable beyond the sidewalk ends. Outside the defined starter envelope, finite set edges may appear naturally because the camera remains unrestricted. The architecture spec records the exact formula and accepted trade-offs.
## Story and kid-facing feedback
The default shot should communicate where action can happen without explanatory text. A child placing a stickman should see feet on a walkable surface, room in the center for a short walk or conversation, and enough contrast to read the character and a speech bubble. Panning should reveal more of the same coherent set, with no sudden floating building, missing floor, or stray Sky gap. Existing Scenery and Ground editing affordances provide recovery if a child moves a piece; this polish adds no new child-facing controls.
## Success criteria
1. At the saved starting camera, both starters read as complete places at normal Stage size, with a clear central action area and understandable walkable surfaces.
2. In Town Street, every near building and tree appears grounded on the sidewalk/curb datum. Sidewalk and road continue across the initial view and the defined horizontal pan envelope without an exposed end or distracting join. A character can step from the sidewalk onto the road at either exposed end. The Far skyline remains subdued behind the action.
3. In Living Room, the wall, baseboard, and wood floor continue across the initial view and pan envelope. The selected Sky appears through the window opening only. Couch, chair, and table visibly contact the floor; the rug remains centered and does not consume the character's main movement lane.
4. At normal framing, a placed stickman and speech remain easy to see in both starters. PLAY preserves the intended walking/support behavior, visual order, and composition; saving and reopening preserves the revised set and camera frame.
5. The important pieces remain separately editable through their established tools. Moving or removing one house, tree, wall panel, Ground piece, rug, or furniture Prop does not require replacing a baked image.
The pre-implementation design review found the outdoor artwork bases roughly **128 screen pixels above Ground**, the **700-world-unit Ground run** ending inside a roughly **917-pixel viewport**, and the indoor couch/chair roughly **24 screen pixels above the floor**. Those measurements motivated the composition work; they are not final placement coordinates.
## Scope and exclusions
**Included:** Composition and placement polish for the existing Town Street and Living Room starter scenes; continuity of existing surfaces and backdrops over a defined pan range; alignment of visible artwork and furniture with their shared ground/floor datum; initial camera framing; preservation of scene editability and existing Prop/Scenery roles.
**Excluded:** New story mechanics, sit behavior, new controls or authoring tools, Stage-wide Undo/Redo, camera restrictions, endless scenery generation, and changes to terrain collision or a general navigation system. The existing navigation bake receives only the tile-seam and exposed 32-unit curb connectors required by these authored surfaces. New art is outside this pass unless the existing library cannot make the selected composition legible; identify any specific missing visual element before proposing an asset request.
## Current project behavior and references
The Phase 19 [scenery product plan](phase_19_scenery_backgrounds_surface_finishes.md) defines the starter choices, editable scenery layers, and the distinction between visual Scenery and walkable Ground. The [Background Ground and Wall Color brief](phase_19_background_ground_and_wall_color.md) defines decorative Background ground and editable wall finishes. The user/editor verification recorded in the [Phase 19 handoff](phase_19_handoff.md) is complete. The later [Plain Background brief](phase_19_plain_background.md) adds Plain Background and renames the former neutral-Sky Blank Scene choice **Open Sky**.
The accepted visual direction is detailed in the [starter-scene UI/UX specification](../specs/phase_19_starter_scene_composition_ui_ux.md). The implementation contract is in the [approved starter-scene architecture specification](../specs/phase_19_starter_scene_composition.md).
Before this composition pass, the [Town Street scene](../../content/stages/starters/town_street.scene) used one Far skyline, separate Near trees/buildings, and short Sidewalk/Asphalt Ground runs. The [Living Room scene](../../content/stages/starters/living_room.scene) used three wall panels, seven Wood Ground pieces, separate decorations, and three Props. Both saved zoom 0.5. The implementation now promotes these starters to current scene format v1.6, extends required surfaces to the approved envelope, and frames starter previews from the saved camera. See the focused [architecture specification](../specs/phase_19_starter_scene_composition.md) for exact placements and verification results. The existing artwork and furniture remain in [content/props](../../content/props/), with each placement independently editable.
## Verification status
- The focused composition suite passed 515 assertions, including tile bounds, seam navigation, a physical PLAY step from sidewalk to road, and Living Room floor support.
- The earlier broad-suite run recorded nine failures; that result was superseded by the 2026-10-22 retest, which passed the Phase 19 scenery suite with 706 checks and 0 failures.
- The user completed editor visual verification of Town Street and Living Room, including the composition behaviors and surfaces covered here.
## Implementation status
The initial focused composition test passed 177 assertions, but user visual review found clouds visible through the Town Street below the skyline and rejected the oversized scaled walkable blocks. The revision adds opaque Grass Background ground, ordinary-size tile rows, and an Asphalt extension so characters can reach the road. The focused composition suite passed 515 assertions, including routing across tile seams and a grounded PLAY drop onto Asphalt. The user has since completed the editor visual review; current Phase 19 test and verification results are summarized above and in the [handoff](phase_19_handoff.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+34
View File
@@ -0,0 +1,34 @@
# Child-Facing Dialog Design Principles
Use this guide when creating or revising child-facing dialogs, popups, and short setup flows. It captures the shared visual language established in the Choose Scenery dialog. In-stage selected-object inspectors continue to follow the [Stage panel UI system](stage_panel_ui_system.md).
## Visual style
- Use rounded dialog shells, buttons, choice tiles, and preview containers. A corner radius around 16–22 logical pixels is a useful starting point for large controls; use smaller radii for compact swatches.
- Give controls a clear outline. Use about a 2 px border at rest and 3 px for selected, hovered, or focused states. Keep focus visually distinct from selection.
- Style the main action with a saturated blue fill, bold white text, and a visible border. Use the theme’s action color or edit-mode blue token rather than adding a one-off color.
- Use dark neutral fills and light text for secondary actions. Leave about 12–16 logical pixels between separate footer buttons so they read as individual choices.
- Use large, readable type: roughly 30 px for a dialog title, 22–26 px for section headings, and 20–22 px for main control labels and body copy. Helper text can be smaller when it remains easy to read.
- Keep pointer targets at least 44 × 44 logical pixels. Provide generous padding around labels and icons.
## Dismissal and action labels
- Give the child a visible, labeled action that describes what happens, such as **Cancel**, **Done**, **Back**, or **Close**.
- Avoid making a small corner **×** the default or only way to dismiss a dialog. Use it only when it adds a useful secondary shortcut; the labeled action remains available.
- Make the primary action’s wording describe the result, such as **Draw wall panel** or **Use color**. Keep Cancel separate from the primary action and visually secondary.
- Escape can remain a keyboard shortcut, but it does not replace a visible action.
## Theme and implementation
- Put reusable colors, sizes, spacing, radii, and border widths in `SandboxTheme` or `PropTheme` tokens. Apply local stylebox overrides instead of mutating a shared theme resource.
- For Sandbox Stage modal windows, use `StageDialogStyler` in `scripts/shared/stage_ui/stage_dialog_styler.gd` for the shared shell, typography, and primary, secondary, or destructive button roles. Custom Control-based dialogs can reuse its `panel_style()` and `style_button()` helpers.
- Render the shared modal surface as a Control in the stage UI CanvasLayer. Keep the native `AcceptDialog` or `ConfirmationDialog` hidden as the signal source; a full-screen native Window can replace the stage with a blank surface on some render paths.
- Keep larger text from clipping: use containers that can expand or wrap, and size the dialog after its visible content has been laid out.
- Preserve clear hover, pressed, selected, focused, and disabled states. Do not use color as the only indication of a state.
- Adapt the arrangement to each workflow while keeping this shared visual language. The Choose Scenery dialog in `scripts/shared/ui/asset_selector.gd` and its `popups.asset_selector` theme tokens are the current implementation reference.
## Stable position during dynamic resizing
- When a dialog changes size after a tab, filter, or other in-place choice, preserve its current anchor so the clicked control stays under the pointer.
- Let the dialog grow from its stable edge while there is room. Before expanding, account for the usable viewport; if the full content will not fit, cap the dialog and let its content area scroll.
- Move the dialog only as much as needed to keep essential controls visible. Avoid recentering after every content change, which makes controls jump away from the pointer.
+224
View File
@@ -0,0 +1,224 @@
# Editor visual harmonization — architecture and UI specification
> **Status:** Proposed for final user sign-off (2026-10-23). **Branch:** `feature/editor-visual-harmonization`. **Scope:** Prop Editor and Stickman Editor presentation, responsive workspaces, control feedback, dialogs, and save status. This specification does not change `.prop` or `.stk` formats or Stage inspector behavior.
## 1. Outcome and approved direction
The two editors should read as parts of one Stickman Studio product while keeping their distinct authoring tasks. The canvas remains the main work surface. The Prop Editor retains its Arrange, Reshape, and Attach modes, split canvas, Attach preview, and scrolling inspector. The Stickman Editor gains **Overview**, with all ten body-part panels available, and **Focus on Part**, with one larger live panel, a body-part picker, and the Whole Stickman preview. Focus can be chosen at any window size. It is the automatic default only when the minimum widths needed by Overview do not fit. Once a child chooses a view, that choice remains in effect for the session through window resizing.
The [Editor Visual Style Guide](../guides/editor_visual_style_guide.md) supplies the visual direction, and [Dialog Design Principles](dialog_design_principles.md) supplies modal behavior. [Stage panel UI system](stage_panel_ui_system.md) remains scoped to in-stage selected-object cards. Neither `StageInspectorPanel` nor its choice/toggle controls are transplanted into the persistent editor workspaces.
The implementation proceeds in staged slices under this one contract. Each slice should be reviewable without changing the saved document schema. Final handoff waits for the behavior and rendered visual checks in §10.
## 2. Current architecture and constraints
| Area | Current fact | Consequence |
| --- | --- | --- |
| Prop theme | `PropTheme` reads `prop_theme.json` with defaults, clamping, and typed accessors. `PropEditor` builds a local `Theme`; it also hardcodes some stylebox colors and gives mode buttons `FOCUS_NONE`. | Share chrome roles through one editor token source and restore a distinct keyboard focus state. Keep prop material, primitive, and canvas values under `PropTheme`. |
| Prop layout | `prop_editor.tscn` has fixed top-row offsets, outer `HSplitContainer`, scrolling inspector, and an inner Attach split. | Replace brittle top-row positioning with container sizing; keep both splitters and the inspector scroll. |
| Stickman state | `StickmanEditor` instantiates ten `BodyPartPanel` scenes, stores them by part name, and reads their live shapes for preview and save. Each panel also owns shape selection, zoom, pan, and popup state. | A view switch must move the same nodes. Calling `set_shape_data()` would reset selection, zoom, and pan. |
| Stickman layout | The scene has two five-panel `VBoxContainer` columns and one `WholeStickmanPreview` in a fixed three-column `HBoxContainer`. Each body panel has a 230 × 160 minimum. | Overview needs scroll access; Focus needs a separate host and width-based initial choice. |
| Stickman document feedback | Save and load serialize directly; no current path or dirty status is tracked. `WholeStickmanPreview.part_moved` reports translation, while rotation and scale have no commit signal. | Dirty tracking and unsaved-work flows need an explicit controller boundary covering every authored transform. |
| Attach drawing order | Anchor `z_index_offset` is an integer clamped to −5…5. `PropAttachPreview` assigns it to the preview prop's `z_index`; held runtime props apply it relative to the rig's index. | Keep the signed value and preview result. Zero means normal ordering, not a universal “front” preset. |
## 3. Theme ownership and visual contract
### 3.1 One shared editor chrome source
Add `scripts/shared/editor_ui/editor_visual_theme.gd` as `class_name EditorVisualTheme extends RefCounted` and `res://editor_visual_theme.json`. It owns **shared editor chrome**: background and raised surfaces, primary and muted text, EDIT accent, keyboard focus, primary/destructive actions, borders, typography, spacing, radii, and minimum control targets. Its loader follows the validated `PropTheme`/`SandboxTheme` pattern: immutable defaults, deep merge of the committed JSON, type validation, clamping, a warning once per invalid key, and a safe default when a file or value is missing. Do not read `SandboxTheme` at runtime to style either editor; its values are a product reference and its popup/inspector semantics belong to the Stage.
Both editor controllers hold an `EditorVisualTheme` instance and build/apply their own local Godot `Theme` or per-control style overrides. They never mutate `ThemeDB`, the project-wide GUI theme, or a cached shared `Theme` for one selected control. `PropEditor` retains `PropTheme` for material tints, primitive defaults, outline widths, and **prop canvas** colors. Stickman canvas grid, silhouette guide, and selection colors live in a separate `stickman_canvas` namespace with typed accessors; artwork colors are never overridden by chrome styling. Existing `PropTheme.ui` getters may forward during the staged migration, but the final chrome value has one authoritative source in `EditorVisualTheme`. Remove duplicate configuration values when callers have migrated.
Suggested typed contract (precise names may change in implementation while preserving ownership):
```gdscript
class_name EditorVisualTheme
extends RefCounted
const DEFAULT_PATH: String = "res://editor_visual_theme.json"
func load_from(path: String = DEFAULT_PATH) -> bool: ...
func chrome_color(role: StringName, editor: StringName = &"") -> Color: ...
func chrome_px(role: StringName, editor: StringName = &"") -> int: ...
func stickman_canvas_color(role: StringName) -> Color: ...
func control_theme(editor: StringName) -> Theme: ... # return an editor-local instance
```
The JSON payload is organized by semantic chrome and drawing purpose, for example:
```json
{
"version": "1.0",
"chrome": {
"colors": {"background": "#171B24F2", "surface": "#252B38FA", "focus": "#8BD9FF"},
"targets": {"button_min_height": 44, "mode_button_min_height": 56}
},
"editors": {"prop": {}, "stickman": {}},
"stickman_canvas": {"grid_color": "#FFFFFF26", "selection_core": "#FFFFFF", "selection_halo": "#000000D9"}
}
```
The complete committed config and script defaults must contain all roles in §3.2, not only these example keys. Resolution order is script defaults, committed shared JSON, then an explicit editor override for the requested editor. Known roles use named accessors or validated role IDs, never arbitrary dotted strings at call sites. An optional `editors.prop` / `editors.stickman` override is allowed only where density or composition has a documented need; shared meanings stay the same. Document any legacy `PropTheme.ui` accessor forwarding and removal in the implementation slice.
### 3.2 Baseline tokens and states
| Role | Starting value or rule | Use |
| --- | --- | --- |
| Main background / raised surface | Stage references `#171B24F2` / `#252B38FA` | Editor chrome; keep drawing canvases visually distinct. |
| Primary / muted text | `#F5F7FA` / `#AAB2C0` | Titles, labels, helper copy, disabled reasons. |
| EDIT accent | `#22C6FF` | Active editor mode, part/view choice, and selection accent. |
| Keyboard focus | `#8BD9FF`, at least a distinct 2 px outline | Visible over normal, hover, and pressed styles; native Tab/Space/Enter remain usable. |
| Primary / destructive action | `#087DBA` / `#E66B75` | One forward dialog action and explicit erase/discard actions. Destructive controls retain words and a cancel path. |
| Targets | At least 44 × 44 logical pixels for child-facing interactive controls; mode buttons may stay 56 px high | Increase Prop's current 36 px button/row minima; enlarge click areas without enlarging canvas handles visually. |
| Density | Body text about 18–20 px, section headers about 20–24 px; compact Overview part title may be about 16–18 px | Confirm fit at minimum supported viewport and larger type. Focus may use a larger part title. |
| Canvas selection | Bright core plus dark backing; handle size and hit area are screen-space | Preserve Prop's existing two-tone halo. Extend the same contrast principle to Stickman panel and preview gizmos. |
The final theme must distinguish neutral, hover, pressed/selected, keyboard focus, disabled, dragging/preview, error, saved, and dirty states. Focus and selection may coexist; focus must not paint over or hide the active mode, as the current Prop button style does. Icons or emoji may add cues but each action remains understandable from text alone. The grid, pose guide, authored shape colors, and material colors remain canvas/domain tokens.
## 4. Stickman adaptive workspace
### 4.1 Node hierarchy and state owners
```text
StickmanEditor : Control (document, save/load, dirty, active part, view choice)
└─ ChromeRoot : VBoxContainer
├─ MenuBar / name and view controls
├─ WorkspaceRow : HSplitContainer
│ ├─ BodyPartWorkspace : Control
│ │ ├─ OverviewScroll : ScrollContainer
│ │ │ └─ OverviewColumns : HBoxContainer
│ │ │ ├─ LeftColumn : VBoxContainer (five original panels)
│ │ │ └─ CenterColumn : VBoxContainer (five original panels)
│ │ └─ FocusHost : VBoxContainer
│ │ ├─ PartPicker : native choices, scroll/wrap as needed
│ │ └─ FocusPanelHost : Control (one original BodyPartPanel)
│ └─ WholeStickmanPreview : PanelContainer (script: WholeStickmanPreview; permanent sibling)
└─ StatusBar
```
There is exactly one `BodyPartPanel` instance per part and one `WholeStickmanPreview` instance. The preview remains a permanent sibling of the body-part workspace in `WorkspaceRow`; it is not duplicated or serialized for a view switch. The controller records each panel's original Overview parent and index. Entering Focus reparents the active panel into `FocusPanelHost`; leaving Focus restores it at the original index. Changing focused part restores the outgoing panel before moving the incoming panel. The controller's existing panel dictionary and signal connections remain stable.
`StickmanEditor` owns `enum WorkspaceView { OVERVIEW, FOCUS }`, `_active_part_name: String`, `_view_choice_was_manual: bool`, and the pending view/part request. `BodyPartPanel` owns its shapes, selected shape, zoom, and pan; the preview owns its assembly transform and zoom/pan. The part picker changes `_active_part_name` without calling `BodyPartPanel.select()`, which currently selects the topmost shape and would overwrite the child's shape selection. Clicking a panel updates the active part through the existing selection signal. The active part gets a text label and visible border cue in Overview and Focus.
Suggested typed additions:
```gdscript
# BodyPartPanel
signal presentation_safe
func can_switch_presentation() -> bool: ... # false during drag, pan, or popup
func add_shape(kind: StringName, at_world: Vector2) -> bool: ...
func open_selected_color_picker() -> void: ...
# StickmanEditor
func request_workspace_view(view: WorkspaceView, user_chosen: bool = true) -> void: ...
func request_active_part(part_name: String) -> void: ...
func _apply_pending_presentation() -> void: ...
```
The picker and view buttons emit typed intent (`view_requested(view: int)`, `part_requested(part_name: String)` if extracted as a small reusable control). The controller validates the part ID against `BODY_PART_NAMES`. No signal bus or saved UI resource is needed for these synchronous presentation decisions.
Keep one visible action row tied to the active part in either view. **Add Shape** opens the existing Line/Rectangle/Circle choices and places the new shape at the center of that panel's visible drawing area; **Color** opens the selected shape's existing picker and is disabled with a clear reason until a shape is selected. `BodyPartPanel` exposes small typed methods for those actions and remains the owner of shape mutation. The right-click menu stays available for point editing, ordering, copy/paste, mirroring, and erase; use readable wording and a short on-canvas hint so children can discover those secondary tools. This shared row avoids repeating full 44 px toolbars inside all ten Overview panels and shrinking their drawing areas.
### 4.2 Responsive rules and safe switching
The automatic initial view is calculated after the first layout pass from available workspace width and the **Overview** content minimum: both panel columns at their useful minimum widths, container gaps, and the preview's useful minimum width. Use the same stable reference widths when the window resizes; a hidden/expanded view must not alter the threshold. If Overview fits, the default is Overview; otherwise Focus. Automatic reevaluation applies only until a child chooses either view. A manual choice remains for the session, including across later resizing and document load/clear. Do not put it in `.stk` or `user://settings.json` in this phase.
Overview keeps all ten panels reachable, with vertical and, when needed, horizontal scrolling rather than shrinking drawing areas below their usable minimums. A manual Overview choice remains available at a narrow width. Focus is available at any width. Wide Focus shows a persistent, resizable Whole Stickman preview in the split beside the enlarged selected panel. Narrow Focus starts with the preview collapsed so the drawing panel keeps its minimum width; a visible **Whole Stickman** button opens and closes it. If both pane minima cannot fit at once, opening the preview gives it the available workspace and offers a visible **Back to Part** action; no control or panel may be clipped. Keep the preview node alive so its zoom, pan, guide, selection, and part transforms survive collapse and return.
Do not reparent a panel during vertex/shape drag, pan, context menu, or color popup. Store the requested view/part and apply it when the panel reports a safe point after the gesture or popup closes. This avoids a partial geometry commit and avoids discarding a color choice. A view switch must not call `set_shape_data()`, `clear_shape()`, `reset_view()`, or `select()`; those methods change selection or view context. In particular, `set_shape_data()` currently resets zoom/pan/selection. A presentation change changes no document data, emits no shape-changed signal, and does not mark the document dirty.
### 4.3 Data and event flow
```mermaid
flowchart LR
View[Overview / Focus request] --> Guard{Panel gesture or popup active?}
Guard -->|yes| Pending[Keep request pending]
Guard -->|no| Move[Reparent one live panel]
Pending -->|presentation_safe| Move
Move --> Refresh[Update picker, active cue, preview visibility]
Refresh --> Status[Document dirty state unchanged]
Edit[Draw or transform a part] --> Owner[Live BodyPartPanel / WholeStickmanPreview]
Owner --> Controller[StickmanEditor observes commit]
Controller --> Assembly[Refresh assembled preview]
Controller --> Dirty[Mark Unsaved changes]
```
For example, when the child focuses the left lower arm, its original panel moves from `LeftColumn` to `FocusPanelHost`. Its current `shapes`, selected shape index, `_zoom`, and `_pan_offset` remain in that node. Editing still triggers the controller's existing shape-changed handler; the preview reads from the same ten-node dictionary. Returning to Overview restores that panel to its original slot with the same drawing context.
## 5. Prop Editor workspace and controls
Keep the existing `PropCanvas`, scrolling inspector, outer canvas/inspector `HSplitContainer`, and inner Attach preview split. Convert fixed `TopRow`/`TopBar` offsets into container-driven chrome so Grid/Snap, the name field, and mode controls remain visible at larger fonts and narrow sizes. Tool rows may wrap through `FlowContainer`; keep action order stable. Maintain a useful canvas minimum and allow the inspector to scroll rather than clip. Preserve the clicked control's position when a mode reveals a section or Attach preview.
Restore keyboard focus on Arrange, Reshape, and Attach mode buttons. Give the local Button theme a focus outline that composes with the pressed/selected appearance; keep selected state persistent and text stable. Programmatic refresh uses `set_pressed_no_signal()` as today. Disabled controls explain nonobvious reasons near the control or via a clear tooltip. Arrange-only and Attach-only controls retain their existing mode gating, document mutation, and preview-only behavior.
Use visible labels and 44 px targets for the primary shape/anchor operations currently hidden in context menus where layout permits. Context menus remain useful shortcuts; the common child path for adding a shape, reshaping a point, and choosing a body part should have an obvious on-screen entry. Preserve Prop's existing two-tone halo and keep its screen-space handles and hit areas distinct. Anchor and preview markers need equivalent contrast against light and dark props. Canvas artwork and material tint values remain unchanged by chrome token migration.
## 6. Copy, dialogs, and save feedback
### 6.1 Child-facing wording
| Current wording | Target copy / rule | Behavioral reason |
| --- | --- | --- |
| Prop `Friction: 0.xx` | **Surface grip: 0.xx**, with optional helper “Higher values make sliding harder.” | Keep the exact `PhysicsMaterial.friction` value and its surface-contact meaning; do not imply hand gripping or change physics presets. |
| Attach `Z Offset` | **Held prop layer** with nearby “Negative: behind · 0: normal order · Positive: farther in front.” Keep the −5…5 integer control and show its visual effect in the preview. | `z_index_offset` is a signed render-layer offset in both preview and held runtime behavior. Zero may draw in front in the preview due tree order, so do not relabel zero as a fixed front/behind category. |
| Destructive commands | Prefer **Erase Shape**, **Start Over**, and explicit **Discard Changes** in dialogs. For Start Over, state “Start over with a blank stickman?” and keep a separate visible **Cancel** action. | Say which authored work is affected; pair with a visible Cancel path. |
| Technical axis labels | Keep X/Y when they are editable coordinates; use plain helper text where the operation is unclear. | Avoid silently changing geometry or transform semantics while updating words. |
The Prop Editor's existing dirty title/status and autosave/recovery flow remain authoritative. Improve status wording to clear **Saved** / **Unsaved changes** states; a failed save/load keeps the current document and shows an actionable reason. Preview-only choices, Show Stickman Preview, marker visibility, and mode/view changes do not mark dirty.
The Stickman Editor adds controller-owned `_current_path: String` and `_dirty: bool`. Name changes, shape edits, shape order, color edits, and committed Whole Stickman position/rotation/scale changes mark dirty; grid/snap, guide, zoom/pan, selection, part picker, and Overview/Focus do not. Add one transform-commit signal or equivalent controller-observable path to `WholeStickmanPreview` that covers translation, rotation, and scale, because its current `part_moved` signal covers only translation. Only a successful save clears dirty; a failed save preserves it. Successful load/clear initializes the expected clean state, and save/load continue to use the existing `.stk` schema and `ContentPaths.canonicalize()` behavior.
Before Stickman **Load**, **Clear**, or window close replaces unsaved work, show the child-facing **Save / Discard Changes / Cancel** choice. Save with no usable path opens the existing Save dialog and resumes the pending action only after successful save. Cancel or a failed save leaves the document and active view untouched. Clear continues to name that it removes the current stickman. Keep Prop's current New/Close/recovery transactions intact while restyling them. Both editors' dialogs follow the dialog guide: rounded shell, readable type, blue primary action, distinct danger action, 44 px targets, visible labeled dismissal, safe viewport fit, and stable anchor when contents change. Adapt `StageDialogStyler` visual rules; its Stage overlay host is not part of either editor.
## 7. Static contracts and document boundaries
| Target | Contract | Owner / boundary |
| --- | --- | --- |
| `EditorVisualTheme : RefCounted` | `load_from(path: String) -> bool`; typed semantic color/size accessors; local `Theme` construction or copies | Validated shared chrome only; no Stage runtime dependency. |
| `PropTheme : RefCounted` | Existing material, primitive, outline, and canvas accessors; temporary UI forwards only while callers migrate | Prop-specific presentation and authoring defaults; never physics material mass/friction/bounce values. |
| `PropEditor : Control` | Existing mode, file, autosave, inspector signals and handlers; consume shared chrome tokens | Prop document mutation, Save/Load, dirty, mode control, local Theme. |
| `PropCanvas : Control` | Existing typed mode and shape/anchor signals; selection drawing uses canvas tokens | Authoring input and visual feedback. |
| `StickmanEditor : Control` | Add `class_name StickmanEditor`; `WorkspaceView`, `request_workspace_view`, `request_active_part`; `_dirty`/pending document action | One live panel dictionary, responsive presentation, `.stk` save/load, dirty state. |
| `BodyPartPanel : PanelContainer` | Existing `shape_changed(Array)` and `shape_selected()`; `can_switch_presentation() -> bool`, `presentation_safe`, `add_shape(kind: StringName, at_world: Vector2) -> bool`, `open_selected_color_picker() -> void` | One part's shapes, selection, zoom/pan, gesture/popup safety, and shape operations. |
| `WholeStickmanPreview : PanelContainer` | Existing `part_moved(String, Vector2)`; add typed `part_transform_committed(part_name: String, position: Vector2, rotation_deg: float, scale: Vector2)` or equivalent | One assembled preview and document transform commit boundary. |
No new global bus, serialized `Resource`, duplicate canvas instance, or `.prop`/`.stk` version bump is needed. UI preferences remain session state unless a later feature specifically approves persistence. Saving gathers from the ten live panels and the one live preview, independent of which view is visible.
## 8. Staged implementation slices
1. **Shared foundation.** Add the shared editor chrome theme/config and typed accessors. Set semantic roles, focus/selection/disabled states, minimum targets, and scoped theme application. Keep Prop-specific canvas/material tokens. Confirm both config and defaults match.
2. **Prop Editor pass.** Migrate Prop chrome to shared roles, fix mode-button keyboard focus, raise touch targets, make top controls and inspector fit, refine labels/help, and style existing dialogs. Preserve its file and autosave behavior.
3. **Stickman workspace pass.** Apply the theme to Stickman chrome, introduce Overview/Focus and the body-part picker, move live panel instances safely, add scrolling and narrow preview collapse, preserve drawing context, and expose active-part feedback.
4. **Shared state and recovery pass.** Align canvas contrast and action discovery, add complete Stickman dirty/unsaved transactions, and finish dialog and status consistency. Resolve remaining measured layout and contrast issues from rendered review.
Keep one spec so theme roles, view state, and save behavior are reviewed together. Each slice should leave its editor usable and should report which acceptance outcomes below it satisfies. User-guide changes follow the spec-pipeline documentation phase after the user verifies the feature in the editor.
## 9. Architectural trade-offs and risks
| Decision | Benefit | Cost / mitigation |
| --- | --- | --- |
| Shared semantic editor source with local editor Themes | Consistent chrome and focus states without coupling to Stage internals | Migrate Prop's old `ui` values deliberately; prevent two active authorities for one chrome role. |
| Reparent one existing BodyPartPanel into Focus | Preserves shapes, selection, zoom/pan, signals, and current save architecture | Node movement during a gesture/popup is unsafe; defer until `presentation_safe`. Keep original parent/index map. |
| Permanent WholeStickmanPreview sibling | One source of assembly transform and preview view state | Narrow layout needs collapse/return controls and a minimum-width fallback. |
| Session-only manual view choice | A child's chosen workspace does not jump on resize | Automatic layout helps only until the first manual choice; narrow Overview requires scroll access. |
| One umbrella specification, staged slices | Prevents token, mode, and state contracts from drifting across separate documents | Each slice needs explicit acceptance accounting and user review. |
| Stickman dirty state added with transform commit | Protects edits during Load/Clear/Close | All authored mutation paths must be audited; translation-only signal is insufficient. |
Known dependencies: the untracked style guide and design-direction board are source references in this checkout; unrelated dirty Stage work must remain untouched. Theme and layout work can be implemented independently of that Stage work. Prop's in-Stage authoring mode also instantiates `PropEditor`, so chrome changes must preserve its host and hide/disable behavior. Long labels, emoji fallback, larger fonts, and small viewports may expose new minimum-size pressure; resolve by wrapping/scrolling and measured fitting, not by silently shrinking controls.
## 10. Observable acceptance and verification routes
| User path | Observable outcome | Verification route |
| --- | --- | --- |
| Open each editor at a wide window; Tab through menu, primary controls, mode/view buttons, inspector, and dialogs; activate with Space/Enter. | All focusable controls show a distinct focus ring, active mode/view remains visible under focus, and controls meet the agreed target size. | Rendered GUI capture plus real keyboard navigation at ordinary and larger font settings. |
| Open Stickman wide, select a part, use Add Shape and Color, draw in a second part, choose Focus on Part, change the focused part, then return to Overview. | The visible actions affect only the active part; Overview has all ten panels; Focus works at wide width; each part retains shapes, selected shape, zoom, and pan; preview updates from edits; no view switch marks dirty. | Interact through production controls; compare panel and preview state before/after switches and inspect rendered captures. |
| Open Stickman where Overview's measured minimum does not fit; choose Overview manually, resize wider and narrower, then choose Focus. | Focus is the initial automatic view; Overview remains reachable through scrolling; manual choice remains through resize; Focus preview collapses with visible Whole Stickman access and returns without resetting preview view state. | Rendered narrow/wide GUI session with actual resize and control input. Record viewport dimensions and pane sizes. |
| Begin a panel drag or open its color popup, request another view/part, then finish or dismiss that interaction. | The switch waits until the interaction is safe; geometry/color is neither partially committed nor lost; selected shape and pan/zoom remain. | Real input path through the panel and popup; inspect shapes and resulting selection after release/dismissal. |
| In Prop, switch Arrange/Reshape/Attach, resize, edit a shape and anchor, and open its preview. | Mode state, inspector sections, top Grid/Snap, canvas, and Attach preview remain reachable without clipped labels or jumping controls; existing document and preview-only dirty rules hold. | Rendered GUI captures of all modes at wide/narrow sizes; check actual edits and status. |
| In Prop Attach, set Held prop layer to −1, 0, and +1 and view the preview; save and reopen. | Negative places the held prop behind the rig, zero follows normal ordering, positive places it farther in front; numeric −5…5 values round-trip unchanged. | Preview capture for each value plus a small repeatable save/reopen scenario using an anchor and visible overlapping prop. Confirm runtime held ordering in a Stage scenario where a rig holds the same prop. |
| In Prop, choose a material preset and read Surface grip, then switch presets. | The displayed value equals `PhysicsMaterial.friction`; wording describes sliding resistance without changing physics values. | Inspector readback against preset values and rendered text check. |
| Edit a Stickman shape, move/rotate/scale a preview part, then Save; edit again and choose Load, Clear, or close. | Every authored mutation sets Unsaved changes; successful Save clears it; Save/Discard Changes/Cancel protects the current document and resumes only the chosen action. | Small repeatable `.stk` scenario: draw and transform two parts, save, reload, compare shape/transform data, then exercise each unsaved branch. Measure status and file content before/after. |
| Open save/load/recovery/clear/error dialogs; enlarge type or shorten the viewport. | Primary/destructive actions, visible Cancel/Back/Close, message, and focused control remain accessible; content scrolls or fits without abrupt recentering after a choice. | Rendered dialog states and real pointer/keyboard interactions; capture normal, error, and destructive branches. |
| Select artwork with light and dark fills in each editor, drag handles, and use the pose guide/Attach markers. | Selection, active handle, guide, and markers remain legible over both fills and at working zoom; grid stays quieter than the artwork. | Rendered pixel inspection at named scenes/states, with a contrast/color-vision check for text and UI state. |
Headless parsing or a successful scene launch alone does not establish these visual or interaction outcomes. During implementation, run targeted Godot import/parse and relevant existing regressions, then use a render-capable editor session and real controls for the visual paths. Record scene/state, viewport size, capture location, observed result, and any outcome that could not be verified. Stop for user verification after agent evidence is complete; update user guides only after the user confirms the feature works.
@@ -0,0 +1,150 @@
# Phase 19 follow-up — Background ground and wall color: architecture specification
> **Status (2026-10-22):** Implemented and user/editor verified as part of Phase 19; the scenery suite passes **706 checks with 0 failures**. Implements the [product brief](../plans/phase_19_background_ground_and_wall_color.md) and [UI/UX specification](phase_19_background_ground_and_wall_color_ui_ux.md). This Stage follow-up preserves existing walkable Ground, Prop physics, and artwork contracts.
## Architectural decision
Use one small rectangular-surface contract for two distinct inert scenery nodes: `StageWallPanel` and `StageBackgroundGround`. Keep separate scene object types, finish allowlists, draw bands, and erase commands. A shared renderer draws the same material in the chooser, placement ghost, selected-piece preview, and finished scene. `SandboxStage` owns selection, mutations, dirty state, and dialogs; visual controls emit intent only.
The chosen order is **Sky → Background ground → wall panels → Far artwork → Near artwork → walkable Ground → On the floor artwork → action elements**. Add `StageDrawOrder.BACKGROUND_GROUND = -50` below the existing `WALL = -40`; the Sky stays in its existing background layer. Background ground remains a direct `World` child and has no `CollisionObject2D`, navigation region, support, pickup, Use target, or event identity. Its only Stage behavior outside drawing is Scenery editing.
```text
SandboxStage (scene and input owner)
├─ Sky CanvasLayer / StageSkyView
├─ World : Node2D
│ ├─ StageBackgroundGround : StageRectSurface : Node2D z = -50
│ ├─ StageWallPanel : StageRectSurface : Node2D z = -40
│ ├─ StageSceneryArtwork (Far / Near / Floor)
│ └─ TerrainBlock (walkable Ground; existing collision/navigation)
├─ StageSceneryController (placement and transform snapshots)
├─ StageSelection / StageGizmos (Scenery scope)
└─ UI CanvasLayer
├─ AssetSelector (bounded gallery and setup view)
├─ StageInspectorPanel (selected Scenery)
├─ Stage capture bar
└─ themed color and erase dialogs
```
## Typed nodes and API contracts
| Target | Contract and responsibility |
| --- | --- |
| New `scripts/stage/scenery/stage_rect_surface.gd`, `class_name StageRectSurface extends Node2D` | Holds `panel_size: Vector2`, `finish_id: StringName`, `tint: Color`, and a transient preview-color flag/value. `func get_local_bounds() -> Rect2` returns a centered positive rectangle. `func set_preview_tint(color: Color) -> void`, `func clear_preview_tint() -> void`, and `func get_render_tint() -> Color` affect drawing only; `tint` remains the saved value. Do not add collision or asset-path state. |
| Existing `scripts/stage/scenery/stage_wall_panel.gd`, `class_name StageWallPanel extends StageRectSurface` | Preserve `func configure(new_size: Vector2, finish: StringName, color: Color) -> void` and `motif_origin_offset: Vector2` for erased fragments. Validate only `finish_wall_paint`, `finish_wall_brick`, `finish_wall_wood`; set wall draw order. `_draw()` calls `StageSurfaceRenderer.draw_wall(...)` with `get_render_tint()`. Wall-area subtraction still tests the concrete wall type. |
| New `scripts/stage/scenery/stage_background_ground.gd`, `class_name StageBackgroundGround extends StageRectSurface` | `func configure(new_size: Vector2, finish: StringName, color: Color) -> void`; allow only `finish_background_grass`, `finish_background_concrete`, `finish_background_carpet`, set the backdrop draw order, and draw a bounded world-anchored motif. Grass/Concrete ignore Color controls but retain `tint` while the child switches looks; Carpet uses it. Its `get_local_bounds()` supports Stage selection and gizmos. |
| Existing `scripts/stage/scenery/stage_surface_renderer.gd`, `class_name StageSurfaceRenderer extends RefCounted` | Add `BACKGROUND_FINISHES: Array[StringName]`, `is_background_finish(id: StringName) -> bool`, `default_tint(finish: StringName) -> Color`, and `draw_background(canvas: CanvasItem, rect: Rect2, finish: StringName, tint: Color, world_origin: Vector2, drawing_scale: Vector2 = Vector2.ONE, cull_to_viewport: bool = true) -> void`. Keep `draw_wall` and `draw_ground`; route preview and Stage through the same finish functions. The default tint is finish-specific, never the old universal green wall value. |
| Existing `scripts/stage/stage_draw_order.gd`, `class_name StageDrawOrder extends RefCounted` | Add `BACKGROUND_GROUND: int = -50`. Existing semantic bands and terrain ordering remain stable. |
| Existing `scripts/editors/stage/stage_scenery_controller.gd`, `class_name StageSceneryController extends Node` | Add `begin_background_ground(finish: StringName, initial_world_position: Vector2, tint: Color) -> void`. Reuse the existing wall rectangle drag/two-corner, snap, minimum-size, ghost, repeated-placement, and cancel lifecycle through a shared rectangular placement helper. Accept both concrete rectangle types for single and group move/resize; preserve root scale at 1 and change `panel_size`. Keep wall-area eraser restricted to `StageWallPanel`. Existing `placement_committed(node: Node2D)` and `document_change_requested()` signal contracts remain. |
| Existing `scripts/stage/stage_selection.gd`, `class_name StageSelection extends RefCounted`, and `scripts/stage/stage_gizmos.gd` | Include `StageBackgroundGround` only in `SelectionScope.SCENERY`, use its transformed `get_local_bounds()` for hit tests and handles, and include it in box selection. `hit_test_all()` and **Pieces here** list overlapping wall, artwork, and backdrop pieces, frontmost first. Ordinary ACTION selection still excludes all scenery surfaces. |
| Existing `scripts/editors/stage/stage_scene_adapter.gd`, `class_name StageSceneAdapter extends RefCounted`, and `scripts/stage/stage_scene_io.gd`, `class_name StageSceneIO extends RefCounted` | Add explicit `background_ground` encode/decode/validation and an old-scene color migration (below). Do not route decorative ground through `TerrainBlock` or Ground finish serialization. |
| Existing `scripts/editors/stage/sandbox_stage.gd`, `class_name SandboxStage extends Node2D` | Owns selected-piece Look/Color/Erase handlers, color-dialog session, shared recent colors, confirmation target, dirty-state transition, and chooser-to-controller handoff. Refresh widgets with no-signal setters. It must include Background ground in all Scenery-only membership, selection, deletion, save, and mode gates. |
Keep exact concrete-type checks wherever behavior truly differs, especially wall-area erase. For common rectangle handling, use `node is StageRectSurface` or a small typed helper instead of another list of exact-script comparisons. Do not broaden physics or action selection by using a generic `Node2D` group.
### Style and color rules
The selected `Color` is a material base tone. Paint fills directly. Brick uses that tone for brick faces and derives bounded light/dark face variants; mortar remains a legible neutral with contrast against the chosen face. Wood derives plank, grain, highlight, and seam tones from the chosen base while preserving the plank pattern. Carpet derives restrained lighter/darker fibers from its chosen tone. Very light, dark, and saturated colors must still show texture; use a deterministic luminance-aware palette transform with bounded contrast, rather than `CanvasItem.modulate` or a flat overlay. Grass and Concrete have designed palettes and no Color control in this follow-up. All four colorable looks can use imaginative hues.
New choices use opaque colors. Preserve any valid eight-digit color already stored in an older scene until the user edits it. `StageSurfaceRenderer.default_tint()` supplies visually natural Brick and Wood defaults plus usable Paint and Carpet defaults. Switching Look retains the piece's saved `tint`; switching from Carpet to Grass and back reveals the same Carpet color. In the chooser, each new look starts at its own default until the child explicitly chooses a color; after that, a chosen color follows the child between colorable looks. Merely opening the chooser or drawing with an untouched default does not add history.
### World-anchored pattern and drawing cost
Map every motif sample to an integer world-grid cell, seeded only from cell coordinates and finish ID. Clip motif geometry to the panel rectangle and to the visible local viewport rectangle. Moving or resizing a piece changes which world cells are visible; it never scales or restarts a texture from that piece's top-left corner. Adjacent same-finish pieces therefore share pitch and phase. Keep a stable world-space pitch at normal zoom. If a distant zoom needs a coarser level of detail, derive the level from **camera zoom/viewport transform**, not individual panel size; every piece at the same zoom must choose the same level. At small projected size, draw the material base and a restrained subset of motifs. Cap visible-cell work per draw and avoid a full-panel loop for broad backgrounds.
The existing `StageSurfaceRenderer` brick/wood detail functions currently adapt to the visible rectangle; the follow-up must make any detail-level decision common across adjacent pieces. Existing walkable Ground's grass is concentrated at its top edge; Background ground Grass needs deterministic interior tufts/color variation distributed over its whole rectangle. Concrete needs restrained joints/speckle rather than reusing Sidewalk unchanged. Carpet uses low-contrast short fibers or weave marks at world scale. The setup preview renders a representative world-sized sample with the same renderer, fitted into its card/preview `Control`; it does not stretch an image of the final rectangle. Decorative preview controls ignore pointer input. The Stage ghost can use the same node with alpha modulation and the existing EDIT outline/corner overlay.
## Scene schema and compatibility
Advance `StageSceneIO.FILE_VERSION` from `"1.5"` to `"1.6"`; retain reads of v1.0–v1.5. New pieces use a distinct type:
```json
{
"file_id": "background_ground_1",
"type": "background_ground",
"position": {"x": 420.0, "y": 180.0},
"rotation": 0.0,
"size": {"x": 800.0, "y": 360.0},
"finish_id": "finish_background_carpet",
"color": "#8B537BFF"
}
```
`size` must be finite and positive, `finish_id` must be one of the three background IDs, and `color` must pass the existing `StageSceneIO` hex parser. Store Color for every piece, including Grass/Concrete, so changing Look preserves it. Serialize the live `tint`, never the transient preview tint. Preserve `World` child order when collecting/restoring so overlaps of pieces in the same draw band stay visually stable; the distinct draw bands determine inter-category order. Background pieces need no `scale` field: a resize edits `size`, retaining world pattern pitch. Old `wall_panel` entries keep their existing fields, including optional `motif_origin_offset`; Paint colors remain intact.
For v1.5 and earlier documents, migrate a Brick or Wood wall's color **only when its saved value is the old default `#5A9D70` or the color is absent** (case-insensitive, optional `#`, with opaque `FF` accepted). Replace that placeholder with the corresponding natural `default_tint()` before validating/loading, because old Brick/Wood drawing ignored the saved tint. Preserve Paint and every non-placeholder Brick/Wood color exactly, including alpha. Never run this rule on v1.6: a child may deliberately choose the same green for Brick or Wood. Missing Paint color keeps its old default. Migration changes the in-memory document; the new version and color are written on its next normal save. Do not alter source files merely on open. Invalid enum/color/size must remain a load error under existing validation rules, and a failed load must not replace the current scene.
The Stage's `user://sandbox_settings.json` gains `recent_surface_colors: Array[String]`, independent of `.scene` files. Canonicalize to uppercase opaque `#RRGGBB` for new history entries; validate on load, discard malformed/duplicate entries, keep at most eight newest first, and tolerate the key being absent. A valid preset/recent press or confirmed custom color moves it to the front. A canceled dialog, opening a picker, switching Look, and placing an untouched default do not change history. Extend the existing settings load/save methods so saving grid/snap/recent scenes also retains this list. A history write never marks the scene dirty or recolors placed pieces; a placed-piece confirmed color change marks that scene dirty once.
## UI composition and signal flow
`AssetSelector` keeps the existing bounded `PopupPanel`, fixed title/Close, tab shell, and scroll body. For Scenery Backgrounds, its first view shows Sky, Wall panel, and Background ground cards; the latter two navigate to an internal setup view. `StageAssetBrowser` supplies IDs/labels, while the chooser owns transient finish/color choice and emits a typed-intent payload through its existing `item_selected(entry: Dictionary)` signal only when **Draw** is pressed. Suggested payload:
```gdscript
{
"background_kind": "background_ground", # or "wall"
"finish_id": "finish_background_carpet", # String
"color": Color("#8B537B"), # in-memory Color, never a saved Variant
}
```
Sky continues its immediate-selection route. Choosing a look or color in setup updates only setup state; no Stage tool starts and no existing piece changes. `SandboxStage` closes the chooser, calls the correct `StageSceneryController.begin_*` function with the exact chosen finish/color, and displays the patterned ghost. The chooser's setup view uses native focusable look buttons, one shared wrapping Color control, a representative renderer preview, Back, and Draw inside the bounded scrolling area. It does not inherit `StageInspectorPanel`, whose layout and dismissal rules serve selected Stage objects.
For selected pieces, extend the **existing** Scenery `StageInspectorPanel` body. Use its fixed header, safe rectangle, scroll position, `style_action_button`, and compact Settings reopen behavior. `StagePanelChoiceGroup` owns **Look** choices and emits `choice_selected(id: StringName)`; its cue controls are render-only. Add one shared `scripts/shared/stage_ui/stage_surface_color_controls.gd`, `class_name StageSurfaceColorControls extends VBoxContainer`, for current color, up to eight recent swatches, presets, and **More colors…**. Its typed contract is `signal color_selected(color: Color)`, `signal custom_color_requested()`, `func configure(selected: Color, recents: Array[Color], presets: Array[Color], stage_theme: SandboxTheme) -> void`, and `func set_selected_no_signal(color: Color) -> void`. Extend the existing `ColorSwatchSelector` from a single row to a wrapping `FlowContainer`, preserving `configure(colors: Array[Color], selected: Color, theme: Variant)`, `set_selected`, and `color_selected(color: Color)`. Refresh both recent and preset groups without emission. `StagePanelToggleRow` is not used because no surface option is boolean. No second inspector shell or choice styling is introduced.
The custom color dialog uses a native `ColorPicker` in a themed, screen-bounded dialog with its own representative material preview, **Use color**, and **✕ Cancel**. It adopts `SandboxTheme.control_theme()` and popup/dialog tokens, not the Stage inspector hierarchy. For a selected piece, `SandboxStage` holds a provisional session `{target: WeakRef, original_color: Color, tentative_color: Color}`. `ColorPicker.color_changed(color: Color)` calls `target.set_preview_tint(color)` and redraws the dialog preview; it does **not** change `target.tint`, recent history, scene dirty state, or serialized snapshots. **Use color** validates the target and commits `tint = tentative_color`, clears the preview override, records history, and marks the scene dirty once if the color actually changed. **Cancel**, dialog close, Esc, target deletion, scene replacement, and mode switch clear the preview override without a document edit. Because `StageSceneAdapter.collect()` reads `tint`, ordinary Save/autosave cannot serialize a tentative color. For chooser use, the session targets only setup state and its dialog preview; Cancel restores the prior chooser color. A preset/recent press is already confirmed: update the one selected piece immediately, record history, mark dirty once if changed, and refresh the card without sending another choice signal.
```mermaid
flowchart LR
Chooser[Scenery setup: Look + Color] -->|Draw payload| Stage[SandboxStage]
Stage -->|begin_wall / begin_background_ground| Draft[StageSceneryController ghost]
Draft -->|valid release| Piece[World surface node]
Piece -->|document_change_requested| Dirty[Scene dirty + save]
Piece -->|Edit Scenery select| Card[StageInspectorPanel]
Card -->|Look or confirmed swatch| Stage
Card -->|More colors| Dialog[Provisional ColorPicker]
Dialog -->|color_changed| Render[Transient render tint]
Dialog -->|Use color| Stage
Dialog -->|Cancel| Restore[Clear render tint]
```
### Erase and selection rules
Background ground is erasable. In **Edit Scenery**, select one Background ground piece and press **Erase background ground**. The shared confirmation asks **“Erase this piece?”** with **Keep it** and **Erase**. The Delete/Backspace path for a selected Scenery surface calls the same confirmation; it must not fall through to `SandboxStage.delete_selected()`'s immediate generic deletion. Confirmation captures the intended node as a weak reference, checks it is still a live `World` child in Edit Scenery, erases only that node, clears selection/hover, and marks the scene dirty once. Keep it, Cancel, Esc, or closing the confirmation leaves the piece and dirty state unchanged. Wall panels use the corresponding **Erase wall panel** action; **Erase wall area** remains a separate wall-only rectangle subtraction and never cuts Background ground. A group delete, if exposed, must present its member count and follow the existing Phase 19 group confirmation boundary.
The backdrop's broad area must not capture normal EDIT clicks. In Scenery scope, a higher draw band wins a click; a smaller visible item at equal depth wins, and **Pieces here** lists all hits so a covered background remains reachable. Scenery box selection and group move/resize include Background ground; group preview restores exact size/position on Cancel and commits once on Keep. Selection outlines and corner hit regions follow the existing minimum 44 × 44 logical-pixel Stage gizmo contract. Mode/tool switches cancel an unfinished placement or provisional edit before changing scope. The capture bar reuses the Stage's existing EDIT capture pattern, puts prompt and visible **Done / ✕ Cancel** together below the mode pill, and names the non-walkable role.
## Theme, focus, and responsive layout
The selected-piece card adopts `stage_panels.shell`, `.type`, `.choice`, and `.action` from `SandboxTheme.DEFAULT_CONFIG` and `sandbox_theme.json`; add narrowly scoped swatch/preview tokens only for values those groups cannot express. The chooser and color dialog use `popups.asset_selector` / `popups.dialog` and `SandboxTheme.control_theme()`. Create local StyleBox copies for selected/hover/focus states; do not mutate the cached shared `Theme`. Every swatch/button target is at least 44 × 44 logical pixels, with text or a color value as well as its fill. Keep native focus and Space/Enter activation, separate focus ring and selection border, and restore focus to the dialog opener on close.
Fit both popup windows to the usable display rectangle, accounting for the current root window and OS usable screen area. Keep chooser title/Close and inspector header/Close fixed while their contents scroll. `FlowContainer` wraps colors and look cards before horizontal clipping; Draw and dialog actions remain reachable when fonts grow or the display is short. The inspector uses `set_safe_rect()` and preserves scroll position during a Look/Color change. The Stage capture bar stays below the mode pill and does not cover it.
## Implementation sequence and repeatable behavior review
1. Add the scene v1.6 migration/validation and `background_ground` adapter path; preserve old-scene visuals and verify malformed entries are rejected before replacing the live scene.
2. Add the common rectangle contract, draw band, and deterministic material rendering. Connect renderer samples to chooser cards, setup preview, ghost, and live nodes.
3. Extend Scenery placement, selection, gizmos, group transforms, inspector, erase confirmation, and dirty/save boundaries. Keep wall-area erase concrete-wall-only.
4. Extend the shared swatch control and user-settings history, then wire the bounded chooser and provisional custom-color dialog.
| Child path / runtime scenario | Required observation |
| --- | --- |
| Scenery → Backgrounds → Wall panel → Brick → default Draw; repeat with chosen color | Both ghosts and placed walls show brick mortar/detail and the chosen hue. The default looks like natural brick. Drawing with an untouched default adds no recent color. |
| Scenery → Backgrounds → Background ground → Grass → draw broad rectangle; place a walkable Ground strip in front; PLAY | Grass detail appears across the backdrop, buildings/trees appear in front, and the stickman walks only on the separate Ground strip. Backdrop adds no collision/navigation/event target. |
| Draw adjacent Grass or Carpet pieces; move/resize one; zoom in/out | Motif pitch does not stretch and matching neighbors align at the same zoom. A large partly off-screen piece draws only bounded visible detail. |
| Edit Scenery → select one wall → Look/Color; select one Carpet → Color | Only the selected piece changes. Changing Carpet to Grass and back restores its saved carpet color. Inspector scroll and selection remain stable; chooser/ghost/Stage material character agrees. |
| More colors… on selected Brick/Carpet → change picker several times → Cancel; repeat → Use color | Stage and dialog preview each tentative color. Cancel restores original and adds no recent or scene edit, even if autosave occurs. Use color commits once and moves that color to the front of recents. |
| Select eight colors across Paint, Brick, Wood, Carpet; reopen scene and restart Stage | One shared newest-first, deduplicated eight-color list survives restart. Each saved piece keeps its own color; history changes do not recolor another piece. |
| Load a v1.5 scene with old green Brick/Wood and Paint; load another with a non-placeholder Brick color; save/reopen | Placeholder Brick/Wood take natural defaults, Paint and the non-placeholder color are preserved, and v1.6 save/reopen preserves the result. An intentionally green v1.6 Brick remains green. |
| Edit Scenery → select Background ground through its exposed area or Pieces here → resize/group preview/Cancel/Keep | It is selectable only in Scenery scope, handles remain reachable, Cancel restores exact geometry, Keep commits one change, and normal EDIT clicks still reach interactive objects. |
| Select Background ground → Erase background ground → Keep it; then Delete → Erase | Keep it leaves the scene unchanged. Erase removes exactly the captured piece after confirmation and leaves walkable Ground intact. Wall-area eraser cannot cut Background ground. |
| Narrow/short display with larger fonts; chooser setup and custom color dialog | Close, Back, Draw, More colors, Use color/Cancel, inspector Close, and capture-bar Cancel remain visible or scroll-reachable by touch and keyboard; no UI spills below the usable screen or covers the mode pill. |
These scenarios are product behavior checks. They do not require a new test suite by themselves; implementers should use the project’s existing verification routes where needed to establish the behavior, especially scene loading, renderer output, and live selection.
## Trade-offs and limits
- A shared rectangle base avoids repeated size, bounds, and transient-preview behavior. Distinct concrete nodes and scene types keep semantic draw order, finish validation, and wall-only erasure explicit. A single `TerrainBlock` or one material-kind enum would blur physics and editing boundaries.
- World-grid procedural motifs keep resizing and adjoining pieces coherent without texture assets. Visible-region culling and zoom-consistent detail control limit draw work; if many giant overlapping surfaces later become costly, cache only measured hot paths rather than introducing a render-texture cache now.
- Scene v1.6 migration is necessary because v1.5 stored unused Brick/Wood green. It changes only the known placeholder on older documents; ambiguous intentional uses of that exact placeholder cannot be distinguished retrospectively. All v1.6 colors are authoritative.
- A transient render tint keeps custom-color preview visible while the saved model and autosave remain stable. It avoids copying live Stage nodes into a modal overlay or treating tentative input as an undoable document edit.
- Recent colors are per-user settings; placed colors are document data. Keeping these sources separate makes scene sharing and restart behavior predictable.
@@ -0,0 +1,111 @@
# Phase 19 follow-up — Background ground and wall color: UI/UX specification
> **Status (2026-10-22):** Implemented and user/editor verified as part of Phase 19; the scenery suite passes **706 checks with 0 failures**. This extends the [Phase 19 Scenery UI specification](phase_19_scenery_backgrounds_ui_ux.md); its Sky, artwork, Edit Scenery, and placement behavior remain applicable.
## Child promise
A child can choose what a wall or distant ground area looks like, see that look before drawing, and change one placed piece later. **Background ground is scenery:** it looks like ground, stays behind the set, and does not make a walking path. The separate **Ground** palette tool remains the way to make a path that stickmen can walk on.
The Scenery chooser supplies the same material and color choices for new pieces that the selected-piece card supplies for placed pieces. The color shown in the chooser, its preview, the placement ghost, and the finished piece must agree. Paint, Brick, Wood, and Carpet share eight recently used colors. Grass and Concrete have designed looks and no Color control in this follow-up.
## Flow and navigation
```text
Scenery → Backgrounds
Sky → existing immediate Sky cards
Wall panel → Paint / Brick / Wood → choose color → Draw wall panel
Background ground → Grass / Concrete / Carpet → choose color for Carpet → Draw background ground
Edit Scenery → select one piece → Look / Color where available / Erase
```
- **Backgrounds** first shows three large, illustrated choices in this order: **Sky**, **Wall panel**, **Background ground**. Wall panel and Background ground open a setup view *inside the existing bounded Scenery chooser*. An explicit **‹ Back** returns to the three choices; the fixed **✕ Close** dismisses the chooser. Sky keeps its approved immediate-selection behavior.
- The setup view shows one clearly selected look, a large representative preview, the Color control when that look supports color, a short role helper, and a large **Draw wall panel** or **Draw background ground** action. Entering the view selects the natural default look and its default color. **Draw** is available immediately: a child can use the default without opening Color.
- Choosing a look updates the preview in place; it does not start placement or close the chooser. Wall look choices are **Paint**, **Brick**, **Wood**. Background ground choices are **Grass**, **Concrete**, **Carpet**. Before a child chooses a color, each new look uses its believable finish-specific default; after the child chooses a color, switching looks keeps that chosen color. Grass and Concrete show their designed appearance; Carpet begins with a usable default fabric color.
- The chooser closes only after **Draw**. The Stage then arms the corresponding rectangle tool with exactly the chosen look and color. Returning to Scenery later may show the last choice for convenience, but opening the chooser must not modify an existing piece or silently start a tool.
- Artwork keeps the existing **Artwork** tab. **Edit placed scenery** remains reachable from the chooser. The chooser retains a fixed title/Close row, a scrollable content area, and its existing bounded screen layout. The setup view does not become an in-stage inspector.
## Setup view layout and controls
Use the existing Scenery chooser shell, card treatment, and theme. The contents of its scroll area should follow this structure:
```text
ScrollContainer
└─ VBoxContainer
├─ Back + category heading
├─ Look heading
├─ adaptive look cards (one native Button per look)
├─ large live material preview
├─ Color section (Paint / Brick / Wood / Carpet only)
│ ├─ selected-color swatch/button
│ ├─ Recently used + wrapping swatches (up to eight)
│ ├─ Colors + wrapping preset swatches
│ └─ More colors… button
├─ role helper
└─ Draw action
```
Look cards show material detail at card size, a stable name, a clear selected border and fill, and a separate keyboard focus ring. The large preview uses the same drawing rules as the placed surface at an appropriate scale; Brick mortar, Wood grain, Grass interior detail, Concrete treatment, and Carpet fabric remain visible. Preview decorations ignore pointer input. A selected color swatch shows the actual color as well as a text label or readable color value, so selection never depends on color alone.
The color row uses two wrapping groups: **Recently used** first, then **Colors** for common presets. Eight recents fit across multiple rows on narrow displays; the group never expands the popup horizontally. An empty history shows **“Colors you use will appear here”** rather than eight blank targets. Duplicate colors appear only once in the history. Each swatch is a single focusable button with a visible selected state, focus ring, and a readable name or color value in its accessible/tooltip text. Touch and click targets are at least 44 × 44 logical pixels. Extend the existing shared `ColorSwatchSelector` or extract a common swatch control instead of building a second swatch style for the chooser and inspector.
Choosing a recent or preset swatch changes the chosen color and preview immediately. That confirmed choice moves to the front of the shared recent list. The history is shared by Wall Paint, Brick, Wood, and Background ground Carpet, persists across scenes and restarts, and changes no already placed pieces. Merely opening the chooser or drawing with an untouched default color does not create a recent entry.
**More colors…** opens a bounded, themed color dialog. It shows the color picker and a live preview of the *current look* with the tentative color. The actions are **Use color** and **✕ Cancel**. **Use color** confirms the new color, closes the dialog, updates the setup preview, and adds that color to Recently used. **Cancel**, the dialog Close control, or Esc restores the color held before the dialog opened and leaves history unchanged. The dialog receives `SandboxTheme.control_theme()`, uses popup/dialog tokens, and clamps to the usable display area; its controls scroll if necessary while both actions stay reachable. Returning from the dialog restores focus to **More colors…** or the color control.
## Drawing on the Stage
- Drawing a wall keeps the existing **Draw wall panel** rectangle behavior. Drawing Background ground uses the same drag or two-tap corner interaction, snap feedback, minimum valid size cue, and repeated-placement tool lifecycle. The background surface is finite; releasing a valid rectangle places one piece, with **Done** and **✕ Cancel** remaining available for another placement.
- The Stage shows a translucent, **patterned** ghost under the cursor before the first corner and a continuously updating patterned rectangle while dragging. The ghost uses the selected material and color. The motif stays at a stable Stage scale as the rectangle changes size; resizing reveals more of it instead of stretching it. A bright EDIT outline and corner reticle make the intended area visible over any Sky. Invalid drafts use the existing red outline and a plain instruction: **“Make this area bigger.”**
- Keep the existing screen-space capture-bar position below the toolbar and EDIT mode pill, using the shared Stage capture-bar pattern and `mode_colors.edit` accent. The bar names the current action and keeps **Done** and **✕ Cancel** together. A short pointer pill follows the cursor: **“Drag to draw wall”** or **“Drag to draw background ground.”** The prompt stays near the working point; the header and status bar do not carry essential instructions.
- The chooser and capture bar both explain the role in words. The Background ground card and setup helper say **“Decorative only · Stickmen don’t walk here.”** Its capture bar can shorten this to **“Draw background ground · Stickmen don’t walk here.”** The Wall panel helper says **“Looks like a wall · Stickmen can pass through.”** These messages remain visible without hover and do not rely on color or icons.
- Cancel during a draft discards the draft. A completed piece stays when placement ends, matching existing Scenery behavior; after one placement, the capture bar includes **“Placed pieces stay.”** Esc and right-click remain shortcuts, while the visible buttons serve touch and keyboard users. Switching mode or tool removes the unfinished ghost.
## Editing a placed piece
**Edit Scenery** is the only ordinary selection mode for broad wall and Background ground pieces. A regular EDIT click still favors characters, Props, and walkable Ground. In Edit Scenery, clicking an exposed part or edge of a background piece selects it; a smaller visible item under the pointer wins over a broad background. The existing **Pieces here** chooser lists all matching pieces with large labeled rows when they overlap, so a covered Background ground or wall remains selectable. Background ground joins Scenery box selection and multi-piece movement/resizing under the existing group preview rules.
One selected piece uses the existing `StageInspectorPanel` shell: fixed name/icon/Close header, scrollable body, safe-screen fitting, and the compact **Settings** button to reopen a dismissed card. The body uses `StagePanelChoiceGroup` for **Look**, stable choice labels, and preview cues. It uses the same wrapping Color control as the chooser, with the selected piece's saved color as the starting value. Theme and minimum heights come from `stage_panels` and `SandboxTheme`; the card is not a separate visual system.
| Selected piece | Header and controls |
| --- | --- |
| Wall panel | **Wall panel**; **Look:** Paint / Brick / Wood; **Color**; **Erase wall area**; **Erase wall panel**. |
| Background ground | **Background ground**; **Look:** Grass / Concrete / Carpet; **Color** only for Carpet; **Erase background ground**. A short helper repeats **“Decorative only · Stickmen don’t walk here.”** |
Changing Look or choosing a recent/preset color updates **only the selected piece** immediately. A Look change preserves that piece's stored color, including when changing away from and back to Carpet; this keeps Look and Color independent. A custom color dialog previews its tentative result on that selected piece while open; **Use color** commits it and adds it to history, while **✕ Cancel** restores the piece's prior color and leaves history unchanged. The color dialog's preview and the Stage use the same material drawing rules. The card remains open, keeps its scroll position, and refreshes choices without emitting a second edit.
The existing large move/resize handles, dark-backed selection outline, button size steps where applicable, and single-piece release-to-commit behavior apply to Background ground as they do to wall panels. Moving or resizing preserves motif scale and alignment with adjacent matching pieces. **Erase** asks **“Erase this piece?”** with **Keep it** and **Erase** before removing one piece; the Delete key follows the same safeguard. **Erase wall area** affects wall panels only and retains the approved cutout behavior. Group edits and their **Keep changes / ✕ Cancel** boundary remain as specified for Phase 19.
## Theme, responsiveness, and input
- The Scenery chooser and color dialog use `SandboxTheme.control_theme()` and existing `popups.asset_selector` / `popups.dialog` presentation rules. The inspector uses `stage_panels.shell`, `.type`, `.choice`, and `.action`; EDIT accents come from `mode_colors.edit`. Introduce focused token entries for swatch spacing/selection, look preview sizing, and dialog actions only where existing tokens do not cover them. Add matching entries to `SandboxTheme.DEFAULT_CONFIG` and `sandbox_theme.json`; avoid fixed UI colors or font sizes in the new controls.
- Layout uses `VBoxContainer`/`HBoxContainer` for sections and `FlowContainer` for swatches and any wrapping look choices. The title and Close stay fixed while content scrolls. Card columns and swatches wrap before they clip; labels wrap; the Draw button stays in the scroll order and reachable on short screens. The chooser and color dialog fit inside the unobstructed display, including raised font sizes. No selected-piece card or capture bar covers the mode pill.
- Every action, swatch, card, and handle has a pointer hit region of at least 44 × 44 logical pixels; button and row heights respect theme `button_min_height` or `stage_panels` minimum target tokens. Text, outline, and focus signal choices in addition to color. Tab order follows Back → Look → Color → Draw → Close in the setup view, with Close always reachable. Space/Enter activates focused choices. The dialog traps focus while open, and closing it returns focus to its opener; closing the chooser returns focus to Scenery. Touch can use two taps for rectangle corners and can always reach Cancel without Esc.
- Opening a selected-piece card from the keyboard places focus at its first useful control. Closing it keeps the piece selected, shows the compact Settings reopen action, and returns focus to the Stage or the opener. Scrolling the card must not drag the piece underneath.
## Copy map
| Where | Exact child-facing text |
| --- | --- |
| Backgrounds category | **Wall panel**; **Background ground** |
| Background ground role | **Decorative only · Stickmen don’t walk here.** |
| Wall role | **Looks like a wall · Stickmen can pass through.** |
| Setup sections | **Look**; **Color**; **Recently used**; **Colors**; **More colors…** |
| Setup primary action | **Draw wall panel** / **Draw background ground** |
| Stage pointer pill | **Drag to draw wall** / **Drag to draw background ground** |
| Too-small draft | **Make this area bigger.** |
| Selected-piece actions | **Erase wall area**; **Erase wall panel**; **Erase background ground** |
| Color dialog | **Use color**; **✕ Cancel** |
## Observable acceptance outcomes
1. From Scenery → Backgrounds, a child can choose Wall panel or Background ground, inspect the natural default, and press Draw without visiting Color. The placed preview and final piece show the same material and color.
2. Color is available for Paint, Brick, Wood, and Carpet in both setup and selected-piece editing. Brick mortar, Wood grain, and Carpet texture stay recognizable after choosing a light, dark, or saturated color. Grass detail reads inside a large rectangle at normal Stage zoom.
3. After choosing eight distinct colors, the history shows newest first in both flows, wraps on a narrow screen, survives a scene change and restart, and has no duplicate. Canceling a tentative custom color changes neither saved color nor history.
4. A child can change one selected wall or Background ground piece without changing its neighbors. Changing Look preserves its stored color; selecting Carpet again restores its prior Carpet color. A canceled custom-color preview restores the selected piece.
5. A broad Background ground is selectable in Edit Scenery, including through **Pieces here** when covered, and stays out of ordinary EDIT selection. Move, resize, erase, and multi-piece edit controls remain accessible. The separate Ground palette remains clearly marked as walkable.
6. On a short or narrow display with larger fonts, chooser title/Close, color dialog actions, inspector header/Close, capture-bar Cancel, and every swatch remain reachable by touch and keyboard. No control spills below the visible screen or covers the mode pill.
## Remaining UI decisions
The accepted flow leaves no blocking user-facing choice. During architecture, settle the exact shared swatch-control API and token names, the setup view's retained-choice lifetime, and how the color dialog provides a provisional Stage preview without committing until **Use color**. These choices must preserve the observable behavior above.
+118
View File
@@ -0,0 +1,118 @@
# Phase 19 — Plain Background: architecture specification
> **Status (2026-10-22):** Implemented and user/editor verified. The user explicitly verified Plain Background save/reopen; the Phase 19 scenery suite passes 706 checks with 0 failures. This specification implements the [Plain Background brief](../plans/phase_19_plain_background.md) and [UI/UX specification](phase_19_plain_background_ui_ux.md). The color-choices and custom-picker PNGs reflect the current-color chip as the sole custom-picker entry and the simple **Preview** label; the solid fill and exact labels in the UI/UX specification govern implementation.
## Scope and decisions
Plain Background is a scene-wide, opaque backdrop. It is not a Stage object, a wall panel, or Background ground. The initial color is `#F5EFE0`. File → New adds a truly empty Plain Background starter and relabels the existing neutral-Sky starter **Open Sky**. EDIT → Scenery → Backgrounds offers Plain Background beside the four existing Sky cards. A scene remembers its confirmed plain color while a Sky is active.
The approved design uses a small shared view composed of the existing `StageSkyView` and a full-rect solid `ColorRect`. `SandboxStage` owns the confirmed scene values and the short-lived picker draft. No `Resource`, global signal bus, or extra selectable node is needed for three scalar document values. The Stage inspector kit (`StageInspectorPanel`, `StagePanelChoiceGroup`, `StagePanelToggleRow`) remains for selected objects; this is a modal chooser and uses `AssetSelector`, `AssetCard`, the existing color controls, and their theme tokens.
When Sandbox Stage launches as the active scene without a requested scene, `SandboxStage` opens the starter chooser once after startup recovery handling. A restored autosave remains the active scene without a chooser; discarding recovery opens the chooser. CLI scene requests and load errors keep their existing flows. Closing the startup chooser leaves the empty EDIT Stage open and does not reopen the chooser for that session.
## Node hierarchy and rendering
```text
SandboxStage : Node2D
├─ StageBackgroundLayer : CanvasLayer (layer -1)
│ └─ StageBackgroundView : Control (full viewport; ignores mouse)
│ ├─ StageSkyView : Control (camera-bound only on the live Stage)
│ └─ PlainFill : ColorRect (full rect, opaque, ignores mouse)
├─ StageGrid : Node2D (EDIT overlay)
└─ World : Node2D (existing placed objects)
StageStarterCardPreview : SubViewportContainer
└─ PreviewViewport : SubViewport
├─ PreviewBackgroundLayer : CanvasLayer (layer -1)
│ └─ StageBackgroundView (unbound still)
└─ PreviewWorld : Node2D
AssetSelector scene-wide card
└─ StageBackgroundCardPreview : Control
└─ StageBackgroundView (unbound still, clipped to card)
```
`StageBackgroundView` toggles its Sky and Plain children exclusively. A Plain view is a single opaque viewport-sized rect, with no gradient, horizon, motif, parallax, or world transform. Its anchors respond to viewport resize. `StageSkyView` continues to own all five existing Sky IDs and camera response; when hidden for Plain it should not spend frames updating Sky motifs. The view and both children ignore mouse input and never enter Stage selection or draw-order lists. The starter preview must use the same background-kind and color interpretation as the live Stage. The gallery card receives the **remembered** plain color even when the active kind is Sky. Card frames and labels are UI overlays outside the fill, not part of its sample.
## Typed contracts and ownership
| Target | Contract and change | Owner responsibility |
| --- | --- | --- |
| `scripts/stage/scenery/stage_background_view.gd`, `class_name StageBackgroundView extends Control` | `func set_background(kind: StringName, sky_id: StringName, plain_color: Color) -> void`; `func bind_camera(camera: Camera2D) -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | Only renders a validated Sky or opaque Plain fill. Reuses `StageSkyView`; has no document, history, or picker state. `bind_camera(null)` yields still previews. |
| `scripts/editors/stage/sandbox_stage.gd`, `class_name SandboxStage extends Node2D` | Confirmed `_background_kind: StringName`, `_sky_id: StringName`, `_plain_color: Color`; transient `_plain_color_draft_active: bool`, `_plain_color_draft: Color`. Private typed helpers such as `_commit_background_kind(kind: StringName, sky_id: StringName = &"") -> void`, `_commit_plain_color(color: Color) -> void`, `_preview_plain_color(color: Color) -> void`, `_cancel_plain_color_preview() -> void`, `_refresh_background_view() -> void`. | Sole authority for current scene state, dirty state, save snapshot, recent color history, and draft rollback. Switching to Plain changes the kind immediately and opens choices; switching Sky retains `_plain_color`. |
| `scripts/stage/stage_scene_io.gd`, `class_name StageSceneIO extends RefCounted` | `FILE_VERSION := "1.7"`; typed validation/migration of `background_kind`, `sky_id`, and `plain_color`. | Reject malformed new backdrop fields before applying a document; migrate older versions without changing their visible Sky. |
| `scripts/editors/stage/stage_scene_adapter.gd`, `class_name StageSceneAdapter extends RefCounted` | Pass all three fields through `collect(...) -> Dictionary` and `apply(...) -> Dictionary`. | Keep background data at the top level; no object entry is created. |
| `scripts/editors/stage/stage_asset_browser.gd` and `scripts/shared/ui/asset_selector.gd`, `class_name AssetSelector extends PopupPanel` | Supply four starter cards and five scene-wide background cards; pass `{background_kind, sky_id, plain_color}` for active state and previews. Add typed `signal plain_color_preview_requested(color: Color)`, `signal plain_color_commit_requested(color: Color)`, and `signal plain_color_preview_cancel_requested` through the browser facade. | The chooser owns view navigation, focus, current/selected card display, and picker controls; it emits intents. It never marks the scene dirty or writes scene data. Keep existing `surface_color_confirmed` for wall/Ground setup history; do not route Plain through it and double-record a color. |
| `scripts/shared/stage_ui/stage_surface_color_controls.gd`, `class_name StageSurfaceColorControls extends VBoxContainer`; `scripts/shared/ui/color_swatch_selector.gd`, `class_name ColorSwatchSelector extends FlowContainer` | Retain `color_selected(color: Color)` and `custom_color_requested`; activating the current-color chip is the sole entry to the full picker. Ensure compact swatches are at least 44 × 44 and stack sections when a horizontal row will clip. | Show current, recent, and preset colors with separate selected and focus treatment. The Stage's existing eight-color recent list is shared. |
| `scripts/shared/stage_ui/stage_surface_color_picker_view.gd`, `class_name StageSurfaceColorPickerView extends VBoxContainer` | Extend `configure(...)`, or add a typed background-specific configuration method, so `preview_color_changed(color: Color)` uses an opaque flat sample for Plain. | Native `ColorPicker` supplies controls. The view displays a draft but does not commit it. Surface motif previews keep their current path. |
| `scripts/stage/scenery/stage_starter_card_preview.gd` and `scripts/stage/scenery/stage_background_card_preview.gd` | Use `StageBackgroundView` for starter and background card imagery. | Show a true solid fill for Plain and preserve existing Sky rendering in still previews. |
| `scripts/stage/stage_grid.gd`, `class_name StageGrid extends Node2D`; `scripts/stage/stage_gizmos.gd`, `class_name StageGizmos extends Node2D`; `scripts/stage/sandbox_theme.gd`, `class_name SandboxTheme extends RefCounted`; `sandbox_theme.json` | Add theme-backed light/dark grid colors and two-tone editor outline colors. Add typed setters on drawing nodes if needed to pass the current backdrop treatment. | Adapt editor overlays to the background while leaving authored art, speech, and physics unchanged. |
The signal names above are the desired public interface between chooser, browser, and Stage. Keep the existing `item_selected(entry: Dictionary)` path for Sky and Plain card taps. A Plain card tap commits the active kind before the chooser opens its color-choices view. A Sky card tap commits the new Sky immediately and keeps the chooser open. The current-card indicator uses a stable **Current background** badge and selected border; keyboard focus is separate. The Plain color choices and its custom picker keep the Stage at full brightness while the modal input blocker remains active and transparent. Other chooser views retain the usual visual dimming.
## Saved scene format and migration
Save top-level fields in `.scene` v1.7:
```json
{
"version": "1.7",
"background_kind": "plain",
"sky_id": "sky_neutral",
"plain_color": "#F5EFE0",
"camera": {"position": {"x": 0.0, "y": -400.0}, "zoom": 0.5},
"objects": [],
"event_rules": []
}
```
`background_kind` is exactly `"sky"` or `"plain"`. `sky_id` remains one of the existing five IDs even while Plain is active, preserving the last selected Sky for a later switch. `plain_color` is an opaque canonical six-digit `#RRGGBB` string. Runtime color values are clamped to opacity before commit; invalid file values are rejected at the JSON boundary. The plain starter is a separate `.scene` file under `content/stages/starters/` with these values and empty `objects`/`event_rules`; the existing `blank.scene` can remain the Open Sky starter, with its UI label changed. Town Street and Living Room remain authored compositions and acquire migrated Sky-kind fields on their next save if their source version is older.
`StageSceneIO._migrate_to_current()` must explicitly handle **1.6** as well as 1.0–1.5. Every pre-1.7 file migrates to `background_kind: "sky"` and `plain_color: "#F5EFE0"`; it preserves its migrated `sky_id` (including `sky_neutral`). In particular, adding 1.6 to the old `version != "1.5"` branch would incorrectly overwrite its Sky choice: keep that earlier version-specific rule intact. Current-version normalization still runs for migrated data. Read and validate the complete document before replacing the live scene. Save and autosave both serialize confirmed fields only; a picker draft is never consulted. `ContentPaths.canonicalize()` continues to handle starter paths.
## Color transaction and navigation
```mermaid
stateDiagram-v2
[*] --> Backgrounds
Backgrounds --> ColorChoices: tap Plain / commit kind
Backgrounds --> Backgrounds: tap Sky / commit sky
ColorChoices --> ColorChoices: tap swatch / commit color
ColorChoices --> CustomPicker: activate current-color chip / snapshot confirmed color
CustomPicker --> ColorChoices: Use color / commit once
CustomPicker --> ColorChoices: Back or Esc / discard draft
ColorChoices --> Backgrounds: Back / keep commits
CustomPicker --> [*]: popup hide, mode switch, scene replacement / discard draft
```
`_plain_color` is the confirmed remembered value; `_plain_color_draft` exists only while the custom picker is open. Every native picker movement calls `_preview_plain_color()` to update the live `StageBackgroundView` and its flat chooser sample, without assigning `_plain_color`, changing recent history, or marking dirty. **Use color** clamps to opaque, commits the chosen color once, clears draft state, calls the existing `_record_recent_surface_color()` once, and returns to color choices. A preset/recent swatch uses the same commit method directly. An intentional selection can reorder recent history; mark the scene dirty only if the confirmed scene value actually changes. Opening choices at the untouched default does not add the default to recents.
Back, Cancel, Esc, popup hide, mode switch, and scene replacement must all clear the draft and restore the confirmed render. A replacement cancels the draft **before** resetting or loading scene state; popup-hide cleanup must be idempotent because a normal close can trigger it after a button callback. Back from color choices returns to Backgrounds without reverting confirmed card/swatches; Close retains confirmed choices. If Save/autosave occurs during a draft, the snapshot reads `_plain_color`, never the displayed draft. Focus returns to the Plain card from color choices and to the custom-picker opener from the picker; the popup keeps its screen anchor while switching views.
## Chooser layout and contrast
Use the existing `AssetSelector` responsive grid and `AssetCard` controls. File → New presents Plain Background, Open Sky, Town Street, Living Room in that order, two columns on a normal desktop and one scrollable column when narrow. Keep fixed heading and reachable Close. Backgrounds presents the five scene-wide cards under **Choose the scene background**; Wall panel and Background ground stay in a separate **More backgrounds** row. The color-choices view has a fixed **Choose background color** heading, flat confirmed preview, a focusable current-color chip with readable hex, up to eight shared recents, and presets. Place **‹ Backgrounds** at the left of the fixed footer and **Close** at the right; stack them as full-width actions on narrow layouts. Activating the current-color chip opens the custom picker; do not add a separate **More colors** button. The custom view has a flat draft preview and fixed Back/Use footer. Reuse `popups.asset_selector`, `popups.dialog`, `stage_surface_colors`, `stage_panels.action`, and `mode_colors.edit` tokens; add only narrowly needed flat-preview/backdrop-overlay tokens. Increase `popups.asset_selector.compact_swatch_size` from 42 to at least 44 in both theme sources, and clamp the actual `ColorSwatchSelector` target size to at least 44 even if a custom theme supplies a smaller number. Card, swatch, and action targets also meet 44 × 44. Scroll the content within viewport-safe bounds; do not horizontally clip compact color sections.
`StageGrid` currently uses translucent white lines. For Plain, choose theme-backed dark minor/major lines on a light fill and light minor/major lines on a dark fill using the displayed color's luminance, including a temporary draft. Minor lines stay subdued; major lines remain legible. On Sky, retain the established grid treatment. `StageGizmos` currently draws white selection and yellow hover outlines, which can disappear against light fills; add a dark backing stroke beneath a bright core for selection, hover, box selection, rotate ring, and scale handles. Keep screen-space widths and hit testing unchanged. This overlay treatment may be applied consistently for Sky and Plain. No authored object, speech bubble, placement material, or pixel color is rewritten. EDIT still owns grid/gizmos, DIRECT keeps its framing cues, and PLAY hides edit-only overlays. Review cursor pills and placement ghosts on light, dark, and saturated fills during rendered verification.
## Observable acceptance and verification route
| User path / repeatable scenario | Observable outcome |
| --- | --- |
| Fresh Sandbox Stage startup without a scene; close the chooser; compare with a CLI scene load and autosave restore/discard | The chooser opens once over an empty EDIT Stage. Close leaves the blank Stage open without reopening the chooser. A CLI-loaded or restored scene suppresses it; discarding autosave recovery opens it only after the recovery dialog closes. |
| Dirty scene → File → New → cancel unsaved gate or cancel starter chooser; then choose Plain and Open Sky in separate runs | Gates preserve the prior scene on cancel. Plain opens an unsaved empty scene with a uniform `#F5EFE0` fill and zero placed objects; Open Sky keeps the former neutral gradient, sun, and clouds. Both card previews match. |
| EDIT → Scenery → Backgrounds → Plain → swatch → Sky → Plain | Plain taps switch immediately, swatches update Stage and recent ordering, Sky remains selectable, and returning to Plain restores its confirmed color. Selected badge and focus are distinct. Wall/Ground remain separate. |
| Plain custom picker → drag RGB/HSV controls → Back; repeat with Esc, Close, mode change, popup dismissal, and scene replacement | Draft appears in Stage/sample while editing; each cancel path restores the prior confirmed fill. Dirty state, recent list, saved file, and autosave content do not change from the draft. Replacement does not carry draft into the next scene. |
| Plain color choices → activate current-color chip → change custom picker controls → Use color → Save → reopen; compare color and recent list before/after | The current-color chip opens the picker without a separate button. One confirmation stores an opaque color, records it once in shared history, and marks the scene dirty once. Reopen restores active kind and remembered color. A Sky-active save/reopen also retains the hidden remembered plain color. |
| Load representative v1.0–v1.6 files, especially a v1.6 `sky_neutral` scene and a v1.6 non-neutral Sky; save/reopen | They remain visually Sky scenes with the same Sky ID and composition; migration adds default remembered Plain color and writes v1.7 without changing objects, camera, or Sky choice. Invalid v1.7 background data fails before live-scene replacement. |
| Plain Stage with no world objects → pan, zoom, viewport resize, EDIT → DIRECT → PLAY → stop; repeat after save/reopen | Every frame stays an opaque edge-free flat fill. No gradient or motifs appear; backdrop never intercepts clicks. Mode transitions retain color; PLAY has no edit-only grid/gizmos. |
| Light `#F5EFE0`, dark, and saturated Plain colors with Ground, artwork, Props, character, speech, selection, and placement ghost | Grid major/minor lines, selection/hover/gizmos, cursor pills, and ghosts remain usable. Foreground pixel colors and object behavior remain unchanged. |
| Narrow viewport → pointer/touch/keyboard through starters, cards, swatches, picker, Back/Use/Close | Content scrolls without horizontal clipping; actions stay reachable; all targets meet 44 × 44; returning from subviews restores opener focus and popup anchor. |
Use the real Stage chooser, stage scene load/save path, and render-capable Stage for the scenarios above. Inspect captured frames at the named colors, camera positions, zoom levels, modes, and viewport sizes; a headless parse or a screenshot command alone does not establish visual correctness. Focused automated checks should cover the v1.7 migration/validation boundary and the commit/cancel state transitions where they meaningfully protect behavior. No new graphical or animation asset is required: thumbnails and previews are rendered from existing Sky drawing and the new solid fill.
## Trade-offs and implementation constraint
Composing a backdrop view adds one small node and typed facade but keeps Sky drawing focused and lets live Stage and previews share the same kind switch. Three scalar file fields are easier to validate and migrate than a custom `Resource`; the saved `sky_id` is deliberate remembered state while Plain is active. Color history remains a user setting shared with surface colors, while the confirmed plain color belongs to each scene. Theme-backed grid color can account for the flat background, but it cannot account for every placed object beneath a particular grid segment; the two-tone gizmo outline protects selection visibility across mixed art.
The existing chooser was designed around four Sky cards and a separate surface row. Its grid-row counting, active-card group, footer placement, and compact swatch width need targeted extension for five scene-wide choices and the new color views. Preserve its popup anchor and focus behavior while changing those branches; do not fork a second gallery or color picker.
@@ -0,0 +1,99 @@
# Phase 19 — Plain Background: UI/UX specification
> **Status (2026-10-22):** Implemented and user/editor verified as part of Phase 19. The current-color chip is the sole entry to the custom picker; there is no separate **More colors** button. The default color is warm off-white `#F5EFE0`; the color swatches and sample views show a flat fill.
>
> **Design references:** [Phase 19 Plain Background brief](../plans/phase_19_plain_background.md), [approved PNG mockups](../plans/assets/phase_19_plain_background/), [Phase 19 Scenery UI/UX specification](phase_19_scenery_backgrounds_ui_ux.md), and [Child-Facing Dialog Design Principles](dialog_design_principles.md).
## Child promise
A child can start with an empty stage filled by one color, choose another color in EDIT, and return to that color after trying a Sky. The color fills the whole camera view. It does not create a selectable object or change the colors and behavior of placed pieces.
## File → New
- Keep the existing unsaved-work Save/Discard/Cancel gate. Do not replace the current scene until a starter card is chosen.
- Use the existing themed `AssetSelector` shell and `AssetCard` presentation with a fixed **Choose a starter scene** heading, a scrollable responsive card body, and a reachable visible **Close** action. The card order and tab order are **Plain Background**, **Open Sky**, **Town Street**, **Living Room**.
- At desktop widths, show an equal-sized 2 × 2 card grid. At narrow widths, wrap to one column and scroll the body while keeping the title and Close action available. Preserve the popup's screen anchor when its layout changes.
- Make this chooser materially larger than the current roughly 660 × 560 logical-pixel asset selector: target about 1160 logical pixels wide on a roomy desktop, with a natural height that shows both card rows and the Close action. Cap both dimensions to the usable viewport with safe margins; a short display scrolls the card body while the title and Close stay reachable. At the preferred width, each card's preview should be about 500 pixels wide and at least 240 pixels tall, occupying roughly two-thirds of the card height. Scale the preview with its card instead of leaving a small fixed thumbnail inside a large tile. The [larger starter chooser mockup](../plans/assets/phase_19_plain_background/05_large_starter_chooser.png) shows the intended balance; its pixel dimensions are illustrative, not a fixed window size.
- Plain Background's thumbnail is a uniform opaque `#F5EFE0` fill with no horizon, gradient, Sky motif, checkerboard, or object. Its copy is: **“An empty stage with one color. Choose a new color whenever you like.”** Starting it creates a new unsaved scene with no Ground or placed objects and no additional setup step.
- Open Sky previews the existing neutral gradient, sun, and clouds. Its copy is: **“An empty stage with a soft sky. Sun and clouds, ready for your story.”** This is the existing empty neutral-Sky starter under a clearer name.
- Town Street and Living Room retain their authored compositions and existing descriptions.
- Activating a card by pointer, touch, Enter, or Space opens that starter. Close or Escape leaves the current scene intact.
## Fresh Sandbox startup
- When the Sandbox Stage launches as the active scene without a requested scene, show **Choose a starter scene** once for that Stage session. This gives children the same clear starting point as File → New.
- If an autosave is available, resolve its recovery prompt first. Restoring it opens the recovered scene without the starter chooser; discarding it opens the chooser afterward.
- A command-line scene load or load error does not open the starter chooser. The chooser must not cover recovery or another modal dialog.
- Close or Escape leaves the empty EDIT Stage available. Do not automatically reopen the chooser during that session; File → New remains available when the child wants another starter.
## EDIT → Scenery → Backgrounds
- Extend the current `AssetSelector` Backgrounds tab with a **Plain Background** card alongside **Clear Day**, **Cloudy Day**, **Sunset**, and **Starry Night**. Keep **Wall panel** and **Background ground** visually separated under a **More backgrounds** row.
- Show the heading **Choose the scene background** above the five scene-wide choices. The Plain Background card preview uses the remembered plain color even while a Sky is active.
- Mark the active choice with the existing cyan selected fill/border and a stable **Current background** badge. Keep the keyboard focus outline visually distinct from selection. Do not identify the current choice by color alone.
- Sky cards retain their immediate selection behavior and leave the chooser open. Activating Plain Background immediately restores/applies its remembered color and opens the Plain Background color choices. Back or Close from those choices keeps Plain Background active.
- Neither a Sky nor Plain Background is Stage-selectable or included in Pieces here. Switching background never starts placement or changes the current cursor mode.
## Plain Background color choices
- Reuse the bounded chooser shell, `StageSurfaceColorControls` and shared `ColorSwatchSelector` visual language; the full-stage preview and scene-specific copy are the only feature-specific presentation.
- Keep the **Choose background color** heading. Put **‹ Backgrounds** in the fixed footer, left of **Close**, instead of in the title row. Both are large, outlined secondary actions with 12–16 px separation; at narrow widths, stack them vertically as full-width targets. The scrollable body contains:
1. Helper text: **“Tap a color to use it on the Stage.”**
2. A large, flat, opaque preview of the confirmed color with no text over the color sample.
3. The current color swatch and readable hex value.
4. **Recently used** colors, using the shared recent color list (up to eight).
5. **Colors** preset swatches.
- If recents are empty, show **“Colors you use will appear here.”** Opening Plain Background at the untouched default does not add that default to recents.
- Tapping a recent or preset swatch immediately confirms it. The full Stage, preview, current swatch, selected outline/check, dirty state, and recent ordering update together. Leaving and reopening the view preserves that confirmed choice.
- Give the selected swatch an outline/check that is distinct from keyboard focus. Make all swatches at least 44 × 44 logical pixels, including compact layouts; the current 42-pixel compact swatch default is too small here.
- The current-color chip is the sole entry point to the full custom picker. It is a focusable button with clear hover, pressed, and focus states and the tooltip **“Choose a custom color.”** Activating the chip opens the picker; there is no separate **More colors** button.
- Keep the Stage at full brightness while Plain Background color choices or its custom picker are open so live color changes can be judged accurately. Keep the modal input-blocking layer active but visually transparent; clicks outside the dialog must not reach the Stage. The dialog keeps its opaque shell, outline, and footer for modal clarity. Other chooser views retain the normal dim backdrop.
## Full custom picker
- Reuse/extend `StageSurfaceColorPickerView`, its native `ColorPicker`, large preview, theme, and compact-height behavior. Use a flat, opaque preview matching the Stage with no text overlay.
- Use the heading **Choose background color** and a simple **Preview** label above the large color sample. The tentative behavior is communicated by the Stage updating during picker movement and the visible **Use color** / **Back** actions; no explanatory preview message is needed.
- On open, snapshot the last confirmed plain color. Moving native color controls updates only the temporary Stage preview and large sample. It does not change history, scene dirty state, or serialized data.
- Use a fixed, visible footer with a dark secondary **Back** (or **Cancel**) button and a blue, bold, white **Use color** primary button, separated by 12–16 logical pixels. All actions remain at least 44 × 44 logical pixels.
- **Use color** confirms once, updates the remembered color and recent history once, marks the scene dirty once, and returns to the color choices.
- Back, Cancel, Escape, or closing the picker restores the confirmed snapshot and returns to color choices with Plain Background still active.
- Mode changes, scene replacement, or unexpected popup dismissal also clear a temporary preview and restore the confirmed color. A temporary color must never enter Save or autosave.
## Focus, layout, and theme
- Use `SandboxTheme` values from `popups.asset_selector`, `popups.dialog`, `stage_surface_colors`, `stage_panels.action`, and `mode_colors.edit`. Add a small Plain-preview token group only if the existing theme has no suitable values; avoid a parallel swatch or picker system.
- Keep cards, swatches, and actions at least 44 × 44 logical pixels. Preserve the existing dialog style: rounded surfaces, visible outlines, readable type, blue primary action, and labeled Close/Back/Use actions.
- Tab order: starter cards → Close; Scenery tabs → scene-background cards → surface cards → Close; color choices: Back → current-color chip → recent swatches → preset swatches → footer; custom picker → Back → Use color.
- Opening a subview moves focus to its heading or first useful action. Returning from the color choices restores focus to Plain Background; returning from the custom picker restores focus to the current-color chip.
- Keep the title and footer actions reachable while the body scrolls. Cap the popup to the safe viewport; do not force clipping or horizontal scrolling to retain compact horizontal swatch columns. Stack the Back/Close footer actions and current/recent/preset sections at narrow widths. Preserve the popup anchor when switching gallery, color, and custom-picker views.
- Pointer, touch, and keyboard users get the same selected, hover, focus, and confirmation feedback. Clicking outside must not silently confirm a tentative custom color.
## Canvas contrast and feedback
- Review white, black, and saturated Plain Background colors. On light fills such as `#F5EFE0`, the current white EDIT grid and white selection outlines have about 1.15:1 contrast and nearly disappear. Provide a theme-backed contrasting grid treatment for light and dark fills, keeping minor lines subdued and major lines legible.
- Give Stage selection, hover, and gizmo outlines a dark backing plus bright core on Plain Backgrounds of any brightness, following the existing two-tone selection-halo practice. Do not recolor authored artwork.
- Keep EDIT chrome and mode pill dark-backed with cyan accents, DIRECT framing amber, and PLAY free of edit-only grid and gizmos. Preserve speech styles and their authored colors. Check cursor pills and placement ghosts at light, dark, and saturated backgrounds.
- A Plain Background color is a scene-wide visual setting only: it never intercepts Stage selection or changes object ordering, placement, action behavior, or camera movement.
## Acceptance scenarios
1. Open Sandbox Stage without a scene and verify the starter chooser appears. Close it and verify the empty EDIT Stage remains available without another automatic prompt in that session. Open a scene through the CLI and verify the chooser does not appear.
2. If an autosave is available at startup, restore it and verify the chooser stays closed; discard it and verify the chooser appears afterward without overlapping the recovery prompt.
3. In a dirty scene, use File → New and verify the existing unsaved gate appears first. Cancel the starter chooser and confirm the previous scene remains. Choose Plain Background and verify the new unsaved Stage is a single flat `#F5EFE0` with no Sky, Ground, or placed objects. Verify Open Sky reproduces the existing neutral Sky appearance.
4. In EDIT, open Scenery → Backgrounds. Verify all five scene-wide choices are visible, the current choice is identified with text and a border, and Wall panel/Background ground remain separate. Choose Plain Background and verify it opens with the remembered color.
5. Tap one preset and one recent swatch. For each, verify the Stage and preview update immediately, the color becomes selected, and recent ordering updates. Close and reopen the choices and verify the confirmed value remains.
6. Activate the current-color chip to open the custom picker. Move a color control, then choose Back. Verify the Stage and preview return to the last confirmed color and the scene/recent history remain unchanged. Repeat and choose Use color; verify the choice and history update once and remain after Save/reopen.
7. Repeat custom-picker cancellation through Escape, visible Cancel/Back, close, mode change, and scene replacement. No temporary preview may leak into the next Stage or saved scene.
8. With each of Sky and Plain active, pan, zoom, resize the viewport, enter/stop PLAY, and reopen a saved scene. Verify the solid fill covers the camera view with no edge and that the remembered plain color returns after switching back from Sky.
9. On light, dark, and saturated fills, inspect grid, selection, hover, gizmos, cursor pills, placement ghosts, speech, and mode chrome. Confirm the Stage remains readable and placed artwork colors remain unchanged.
10. At narrow viewport sizes, navigate each card, swatch, and action by pointer, touch, and keyboard. Verify the content scrolls without clipping; focus and selection remain distinct; Back/Use/Close remain visible; returning from subviews restores the opener focus and popup anchor.
## Reuse boundary
| Surface | Reuse direction |
| --- | --- |
| Starter and Scenery cards | Extend `AssetSelector` and `AssetCard`; use `StageBackgroundCardPreview` for a real solid-color thumbnail and add the Plain-specific label/selection state. Do not create another card gallery. |
| Color choices and recents | Reuse `StageSurfaceColorControls` and `ColorSwatchSelector`/shared history. Extend layout for 44-pixel minimum targets and narrow layouts instead of introducing a parallel swatch system. |
| Custom picker | Reuse `StageSurfaceColorPickerView` and the native `ColorPicker`; give the scene-wide background its own flat preview and explicit temporary/confirm/cancel lifecycle. |
| Stage contrast | Use `SandboxTheme` tokens and the existing two-tone selection halo pattern; do not mutate user-authored object colors or make a second editor theme. |
@@ -0,0 +1,309 @@
# Phase 19 — Scenery, backgrounds, and surface finishes: architecture and implementation specification
> **Status (2026-10-22):** Phase 19 architecture is implemented and user/editor verified. The user completed the integrated visual and interaction checks; `tests/test_phase19_scenery.gd` passes **706 checks with 0 failures**. This specification follows the [verified product plan](../plans/phase_19_scenery_backgrounds_surface_finishes.md), the [UI/UX specification](phase_19_scenery_backgrounds_ui_ux.md), and the [Stage panel UI system](stage_panel_ui_system.md). The camera-aware Sky addendum supersedes the original static-detail statements below.
## Decisions and scope
1. Use independent, inert `Node2D` placements for wall panels and `.prop` Scenery artwork. The source `.prop` is a **live reference**: its current vector art is used on reopen. Missing art remains a saved, visible, repairable placement. A given `.prop` may be used as inert Scenery and, when it has valid collision geometry, as a functional Prop.
2. Sky is one adaptive, viewport-filling, screen-space vector drawing behind the world. Its gradient stays fixed while its details respond gently to camera pan and zoom under the [camera-aware Sky addendum](#phase-19-addition-camera-aware-sky-details); it does not tint other objects. Existing scenes and Blank Scene use an internal neutral Sky. The four child-facing Sky presets are Clear Day, Cloudy Day, Sunset, and Starry Night.
3. The existing Prop Editor opens **inside the live Stage session** for Make artwork. The Stage and its unsaved document remain in memory, with Stage input suspended until the editor returns. The standalone F6 route remains valid.
4. Select several creates a temporary Scenery selection and snapshot-based transform preview. It does not create a persistent group or a parent that changes the saved hierarchy. Sky, Ground, characters, and functional Props are outside this transform.
5. Phase 19 adds neither Stage Undo/Redo nor a whole-set transform. Group preview has its own exact Cancel/Keep boundary. Stage-wide Undo/Redo is a separately planned phase and is logged in [technical debt](../architecture/tech_debt_and_optimizations.md).
## Existing constraints and ownership
`scenes/stage/sandbox_stage.tscn` contains a `SandboxStage` root, `Camera2D`, and `World`. The scene adapter serializes direct `World` children; `StageSceneIO` currently writes v1.4. `StageSelection` currently considers every direct world child selectable by world AABB. `StageSpatialIndex` indexes AABBs into 16 px cells and `StageTerrainPainter` treats any indexed overlap as an occupancy conflict. `PropBlock` currently draws at `z_index` -1, 0 (legacy), or 1, while terrain is 0. These are explicit integration points, not implicit behavior to rely on.
```mermaid
flowchart TD
Chrome[StageChrome / shared gallery] --> Intent[SandboxStage input arbitration]
Intent --> Decor[StageSceneryController]
Intent --> Terrain[StageTerrainPainter]
Decor --> World[World: WallPanel + SceneryArtwork]
Terrain --> World
World --> Adapter[StageSceneAdapter]
Sky[StageSkyView] --> Adapter
Adapter --> IO[StageSceneIO v1.5]
Art[PropFile + PropLibrary visual scan] --> Decor
Art --> Editor[Stage-hosted PropEditor]
```
| Owner / location | Typed contract or change | Boundary |
| --- | --- | --- |
| `scripts/stage/stage_draw_order.gd`, `class_name StageDrawOrder extends RefCounted` | Constants `WALL := -40`, `FAR := -30`, `NEAR := -20`, `TERRAIN := -10`, `FLOOR := -5`; `static func scenery_z(depth: StringName) -> int`; `static func apply_terrain(node: TerrainBlock) -> void` | One drawing policy. Leave the existing Prop `-1/0/1` and character relative order intact; rebase terrain drawing only. Never serialize raw `z_index` in place of a semantic depth/finish. |
| `scripts/stage/scenery/stage_sky_view.gd`, `class_name StageSkyView extends Control` | `func set_sky(id: StringName) -> void`; `func get_sky() -> StringName`; `func apply_theme(theme: SandboxTheme) -> void`; camera binding specified in the [Sky addendum](#phase-19-addition-camera-aware-sky-details) | Full-rect child of a dedicated `CanvasLayer` with `layer = -1`, mouse ignored. Deterministic vector drawing on preset, viewport, or effective camera-framing changes. No world hit test or physics. |
| `scripts/stage/scenery/stage_wall_panel.gd`, `class_name StageWallPanel extends Node2D` | `var finish_id: StringName`; `var tint: Color`; `var panel_size: Vector2`; optional `var motif_origin_offset: Vector2`; `func configure(size: Vector2, finish: StringName, color: Color) -> void`; `func get_local_bounds() -> Rect2` | Finite world rectangle centered at `position`. No `CollisionObject2D`, navigation, support, or interaction registration. Resize changes `panel_size`, never root scale, so motifs retain their world pitch. The renderer lays the pattern out from each panel's current world transform, so eraser-created fragments keep continuous brick courses; the optional offset preserves source-wall lineage in saved data. |
| `scripts/stage/scenery/stage_scenery_artwork.gd`, `class_name StageSceneryArtwork extends Node2D` | `var asset_path: String`; `var display_name: String`; `var depth: StringName`; `var last_known_local_bounds: Rect2`; `var missing: bool`; `func load_visual(path: String) -> bool`; `func set_depth(value: StringName) -> void`; `func get_visual_world_bounds() -> Rect2`; `func get_saved_footprint() -> Rect2` | Build a `Visuals` child via `PropVisuals.build()` from the complete shape stack. No `PropBlock`, collision hull, physics, Use spots, anchors, or event identity. If loading fails, render a neutral marker/known footprint from saved data and preserve path/name/transform. |
| `scripts/stage/scenery/stage_surface_renderer.gd`, `class_name StageSurfaceRenderer extends RefCounted` | `static func is_wall_finish(id: StringName) -> bool`; `static func is_ground_finish(id: StringName) -> bool`; `static func draw_wall(canvas: CanvasItem, rect: Rect2, finish: StringName, tint: Color, world_origin: Vector2) -> void`; `static func draw_ground(canvas: CanvasItem, rect: Rect2, finish: StringName, tint: Color, world_origin: Vector2) -> void` | Shared vector definitions for Paint/Brick/Wood, with Grass/Dirt/Sidewalk/Asphalt for Ground. One motif vocabulary and world-coordinate phase; no raster scale blur. Drawing helpers clip motifs to finite panel/block bounds and bound work to visible tiles. |
| `scripts/terrain/terrain_block.gd`, `class_name TerrainBlock extends StaticBody2D` | Add `var finish_id: StringName` and `func set_finish(id: StringName) -> void` for `spawn_id == "ground"`; preserve `polygon_points`, fill, outline, and `CollisionPolygon2D` | Ground appearance changes without altering collision geometry, navigation, or terrain occupancy. Ramp/Step retain the old look. |
| `scripts/editors/stage/stage_scenery_controller.gd`, `class_name StageSceneryController extends Node` | `signal placement_committed(node: Node2D)`; `signal document_change_requested`; `signal preview_state_changed(active: bool)`; `func begin_artwork(path: String, depth: StringName) -> bool`; `func begin_wall(finish: StringName, initial_world_position: Vector2, tint: Color) -> void`; `func begin_wall_eraser(initial_world_position: Vector2) -> bool`; `func cancel_active_gesture() -> void`; `func exit_tool() -> void`; `func begin_group_transform(kind: StringName, members: Array[Node2D]) -> bool`; `func preview_group(delta: Vector2, factor: float) -> bool`; `func commit_group() -> void`; `func cancel_group() -> void` | Owns Scenery-only tool/substate and snapshots. A wall-eraser release subtracts its snapped rectangle from every intersected wall panel and emits one document change when anything changed. `SandboxStage` retains top-level mode, Save, dirty, and input priority. Typed intent signals do not pass UI controls into persistence. |
| `scripts/editors/stage/sandbox_stage.gd`, `class_name SandboxStage extends Node2D` | Route Scenery tools before ordinary selection/placement; gate save/PLAY while a group preview needs resolution; apply controller commits through existing `_mark_scene_dirty()` and `_save_object_state()` as appropriate | Do not append all Phase 19 interaction state to the root controller. Cancel pending drafts on tool/mode changes before existing direct/play transitions. |
| `scripts/stage/stage_selection.gd`, `class_name StageSelection extends RefCounted` | Add a typed selection context, for example `enum SelectionScope { ACTION, SCENERY }` plus `func set_scope(scope: SelectionScope) -> void`; make hit/hover/box use the same predicate | Ordinary EDIT excludes wall/artwork, including missing broad bounds. Edit Scenery includes only those; smaller cutouts outrank panels, with the overlap chooser for remaining ambiguity. Sky is never selectable. |
| `scripts/stage/stage_scene_io.gd`, `class_name StageSceneIO extends RefCounted`; `scripts/editors/stage/stage_scene_adapter.gd`, `class_name StageSceneAdapter extends RefCounted` | v1.5 migration/validation, sky and new object encode/decode, known footprint, Ground finish | Validate document before `_reset_scene_content()`. Invalid new fields cannot silently erase the current scene. Preserve missing Scenery entries across save cycles. |
`StageSpawner` remains the factory for physical terrain, Props, stickmen, and Areas. A small Scenery factory may live in `StageSceneryController` or `scripts/stage/scenery/` to create inert nodes; it must not route visual artwork through `spawn_prop_asset()`. `StageRunSession` and the event/rule engine must treat Scenery nodes as nonparticipants even in PLAY and paused EDIT. Keep Scenery outside the physical spatial index and terrain-paint candidate set, but include it in `World` serialization and Scenery-only selection.
## Scene v1.5 contract
All scene objects remain independent entries in the existing `objects` array. `file_id` is unique within a file, allocated by the adapter; it is not a durable group ID. Scenery object transforms use world coordinates. The Sky ID is a root property. The examples omit unrelated current fields.
```json
{
"version": "1.5",
"scene_name": "Living Room",
"sky_id": "sky_clear_day",
"camera": {"position": {"x": 0, "y": -400}, "zoom": 0.5},
"objects": [
{"file_id": "wall_panel_1", "type": "wall_panel", "position": {"x": -280, "y": -420}, "size": {"x": 320, "y": 400}, "finish_id": "finish_wall_paint", "color": "#5A9D70"},
{"file_id": "scenery_artwork_1", "type": "scenery_artwork", "asset_id": "res://content/props/window_frame.prop", "display_name": "Window frame", "depth": "near", "position": {"x": 0, "y": -450}, "rotation": 0, "scale": {"x": 1, "y": 1}, "last_known_local_bounds": {"position": {"x": -80, "y": -70}, "size": {"x": 160, "y": 140}}},
{"file_id": "terrain_1", "type": "terrain", "asset_id": "ground", "position": {"x": 0, "y": 0}, "rotation": 0, "scale": {"x": 1, "y": 1}, "finish_id": "finish_ground_wood"}
],
"event_rules": [],
"metadata": {}
}
```
The document may contain internal `sky_neutral` and `finish_ground_legacy` IDs to reproduce the prior blank/background and green Ground. They are valid for loading/migration but are not extra child-facing picker cards. For wall panels, `position` is the rectangle center, `size` has strictly positive finite x/y, and rotation is fixed at zero. Group resize writes the new size and center while leaving wall root scale at `(1, 1)`. For artwork, `scale` is finite, positive, and uniform for Phase 19 editing; `rotation` is finite and preserved for existing/scripted entries and repair previews, though the Phase 19 UI offers no rotation control. Depth IDs are exactly `far`, `near`, `floor`; Near is the placement default.
The manual **Erase wall area** tool adds one optional wall field, `motif_origin_offset: {x, y}`, recording the vector from a fragment's saved `position` to its source wall's origin; absent means `(0, 0)`, so existing v1.5 scenes remain valid and no version bump or migration is needed. When a rectangle intersects a wall panel, transform its four corners into that panel's local space, intersect with the finite local bounds, and replace the original with up to four non-overlapping rectangular fragments outside the intersection. Copy the finish, tint, placement ordering, and source-wall lineage to every fragment. Render each fragment's surface pattern using its current world origin, not the inherited source origin, so brick courses remain on the same world-space lattice across the new fragment boundaries. Remove the source panel when the intersection covers it fully; do not touch artwork or non-wall objects. This is visual editing only: no glass, collision, doorway, Ground, or physics behavior is inferred. Each valid release commits immediately through the existing document-dirty signal; Done/Cancel/RMB/Esc exits the tool, and canceling a held drag discards only that uncommitted drag. There is no Stage Undo in Phase 19.
`last_known_local_bounds` is cached geometry metadata, updated when a live asset loads and retained unchanged if it later goes missing. Include stroke width when deriving it, so horizontal/vertical line-only artwork still has a positive visible footprint without inventing collision geometry. It supports a repair footprint and safe group movement; it is not embedded artwork. Missing or malformed optional bounds fall back to a compact world marker and disable group resize until repaired. `display_name` is a short saved fallback label. A repaired placement changes only its selected `asset_id`, name, and known bounds after explicit confirmation; its position, scale, rotation, depth, and unrelated placements remain. The default repair is one placement. A future or optional explicit “Use for all matching places” path must show a count and ask separately before changing other references.
`StageSceneIO._migrate_to_current()` accepts v1.0 through v1.4 and adds `sky_neutral` plus `finish_ground_legacy` to old Ground entries, without changing old Prop role and draw-order migrations. v1.5 validation checks top-level Sky ID, object type payloads, finite vectors and numbers, positive wall sizes and artwork scales, allowed finish/depth IDs, type-safe color strings, unique `file_id`, and canonicalized asset paths. Malformed optional cached art bounds produce a warning and the no-footprint repair marker, rather than rejecting otherwise valid scene content. The adapter creates a missing Scenery node when the source cannot load; missing source is a warning and repair state, not document rejection. Unknown object types follow the existing warning/skip convention only after structural validation. The existing safe write and autosave paths write the same v1.5 snapshot. Manual Save asks to Keep or Cancel a pending group preview; background autosave waits until the preview is resolved. On mode switch, cancel the preview before the current PLAY-entry autosave. Draft values are never serialized.
## Visual layers, Sky, and finishes
The semantic draw order is Sky → wall panels → Far art → Near art → terrain → floor art → characters and Props. `StageDrawOrder` applies bands to direct `World` children. Set all existing terrain varieties to the terrain band for consistent physical-surface drawing; their collision and input remain unchanged. Prop `-1/0/1` choices and World child order among legacy depth-zero Props and stickmen are preserved. Reapply the policy on spawn, load, depth change, and reset. Selection must use the same semantic tier order plus smaller actual art footprint preference, rather than assuming child insertion order expresses depth.
`StageSkyView` lives in its own `CanvasLayer` behind `World`, not under the camera or a wall. Its draw rectangle follows viewport size, so panning/zooming cannot reveal an edge. Use deterministic vector gradient bands, stars, clouds, sun/moon and shapes with bounded point counts. The preset controls only this view. The gradient stays screen-fixed; the [camera-aware Sky addendum](#phase-19-addition-camera-aware-sky-details) defines bounded, camera-responsive details and redraw invalidation. There is no per-frame random placement, autonomous animation, or tint propagation. Sky thumbnails use the same drawing definitions at card size in their unbound default composition.
`StageSurfaceRenderer` defines finite vector motifs with a world-aligned origin and fixed world-unit pitch. Wall and Ground may share Brick/Wood definitions but use context-aware fill/outline colors and scale. `StageWallPanel._draw()` uses its current finite rectangle as a clip and asks the renderer for only intersecting motif cells. For brick walls, cull to the camera-visible local rectangle; if that visible area exceeds 12,000 cells, increase the brick pitch uniformly for all visible courses instead of truncating rows or columns. This keeps large walls fully patterned and fragments continuous without unbounded draw work. Ground renders within its existing rectangle geometry; existing `CollisionPolygon2D` and navigation surfaces are never rebuilt merely for a finish change. The selected **New Ground look** is passed into both `StageTerrainPainter` ghost creation and each committed Ground block; it never repaints earlier blocks. During group resize, wall motif lines retain their pitch and phase. Ground blocks adjacent on a shared edge use the same world phase; shared same-finish interior borders are suppressed or visually joined so a painted run reads as one surface. The neighbor check refreshes on place, delete, move, load, and per-piece finish change, and considers only nearby Ground rectangles; it does not change occupancy/collision. Other motif types remain bounded by the camera-visible region or a maximum cell count per finite panel so `_draw()` work stays limited.
## Selection, input, and transform state
```mermaid
stateDiagram-v2
[*] --> OrdinaryEdit
OrdinaryEdit --> Chooser: Scenery palette
Chooser --> PlaceArt: artwork chosen
Chooser --> DrawWall: wall look chosen
Chooser --> EditScenery: Edit placed scenery
PlaceArt --> OrdinaryEdit: Done / Cancel / RMB / Esc
DrawWall --> OrdinaryEdit: Done / Cancel / RMB / Esc
EditScenery --> GroupPreview: Move together / Resize together
GroupPreview --> EditScenery: Keep changes / Cancel / RMB / Esc
EditScenery --> OrdinaryEdit: Done editing / Esc
```
The controller tracks one active Scenery tool at a time, the number of committed copies this session, and whether a wall/touch draft is active. A passive artwork ghost is never a committed edit. Right-click/Done/Cancel follow the approved [exit matrix](phase_19_scenery_backgrounds_ui_ux.md#placement-exit-cancel-and-mode-changes): committed pieces stay; an active draft is discarded; a late release cannot commit it. Existing terrain and Prop placement are canceled when a Scenery tool takes input. DIRECT/PLAY clears Scenery ghosts and Edit Scenery selection.
`StageSelection` filters both click and box paths. Ordinary EDIT includes existing actionable objects and excludes Scenery broad bounds. In Edit Scenery, candidate geometry comes from a wall rectangle or the full transformed vector art: filled polygons use point-in-polygon, open strokes use a screen-scaled segment tolerance, and missing art uses its marker or known footprint. A small cutout wins over a broad wall even if both are candidates; unresolved overlaps use the shared “Pieces here” popup. The small explicit missing-art **Find artwork** badge is a separate 44 px screen-space hit target that can open Edit Scenery without making the entire missing footprint selectable in ordinary EDIT. Sky never enters either selection scope.
For one selected artwork, a uniform scale changes root `scale` within finite positive bounds. For a wall, resizing changes `panel_size`, with a minimum visible world size and no mirrored/negative rectangle. Pointer/touch handle hit areas are at least 44 logical pixels after camera zoom compensation. `StageGizmos` can share outline/handle drawing helpers; a Scenery-specific adapter is justified for rectangle sizing, no rotation control, and the pending group preview. It must still use `SandboxTheme.overlays` selection colors and the Stage halo style, rather than duplicating visual tokens.
Group preview snapshots `global_position`, `rotation`, artwork `scale` or wall `panel_size`, and the initial world bounds of each selected member. For move, derive one delta from the selected union anchor, apply Snap once to that anchor, then add the same delta to every snapshot position. For resize, compute one positive factor around the opposite union corner `P`; for each artwork center `x`, set `x' = P + factor * (x - P)` and `scale' = factor * scale`; for each wall center/size, use the same center formula and `size' = factor * size`. Derive every preview frame from the snapshots, never the previous preview frame. Reject factor `<= 0`, any collapsed member, or any out-of-range art scale. Buttons use the same formula with top-left fixed. Missing members with no known footprint may move but block group resize with the approved explanation. Mouse release leaves the candidate preview pending. `Keep changes` saves each authored transform, marks dirty once, and returns to Edit Scenery. `Cancel`, RMB, or Esc restores all snapshots exactly and leaves the same selection. A Save request opens a Keep/Cancel decision for the preview and resumes Save only after explicit resolution; tool/mode changes cancel the preview. A temporary group selection and preview are never written to `.scene`.
## Phase 19 addition: authoring size and single-placement transforms
This addition implements the approved [Stage-relative sizing flow](phase_19_scenery_backgrounds_ui_ux.md#stage-relative-authoring-size-in-prop-editor) inside Phase 19. The standard standing guide spans **939.5 world units** from head-circle top to ankle line; write `H := 939.5` as one Prop Editor reference constant derived from `WholeStickmanPreview.GUIDE_JOINTS` and `GUIDE_HEAD_RADIUS`, so the UI never assumes the old 96-unit Normalize Size target. The presets are starting points based on the artwork's **longest visible dimension**, including stroke: Small = `0.35 H`, Room = `1.0 H`, Big = `2.0 H`. This covers both tall and wide drawings without forcing a wide rug or skyline to be one stickman tall. Each **Make bigger** step multiplies by `1.1`; **Make smaller** multiplies by `1 / 1.1`, so opposite steps return to the same size within floating-point tolerance. The labels are approximate comparison choices, not an inferred real-world category or a maximum size. Artist size inference is part of authoring/review instructions; no runtime name parsing, asset size tag, or automatic normalization is added.
### Prop Editor authoring transaction
`PropEditor` (`class_name PropEditor extends Control`) replaces the Arrange toolbar's immediate `_on_normalize_size_pressed()` action and `NORMALIZED_MAX_EXTENT` with **Size for Stage**. The action is disabled when the document has no drawable geometry or its longest visible extent is not finite and positive; its tooltip says **“Add visible artwork to use Size for Stage.”** It owns the toolbar controls, Keep/Cancel routing, and the existing `_dirty`/autosave boundary. `PropCanvas` (`class_name PropCanvas extends Control`) owns a temporary deep copy of its complete shape stack and the supplied editable-anchor dictionary; its normal `shapes` and the editor's `_anchors` remain the committed document while previewing. A small typed API is sufficient:
```gdscript
func begin_stage_size_preview(anchors: Dictionary) -> bool
func preview_stage_size(factor: float) -> bool
func commit_stage_size_preview() -> Dictionary # {"changed": bool, "anchors": Dictionary}
func cancel_stage_size_preview() -> void
func is_stage_size_preview_active() -> bool
func get_visible_shapes_bounds() -> Rect2 # Includes outline/stroke width.
func fit_stage_size_view(bounds: Rect2, padding_px: float) -> void
```
The preview records original internal shape dictionaries, anchors, canvas pan/zoom, and the entry bounds once. Preset factor is `target_longest_edge / entry_longest_edge`; fine buttons adjust the current absolute factor, but **every rendered candidate is derived from the entry snapshot**, never scaled from the previous candidate. The shared `origin` anchor remains at `(0, 0)`. Scale all polygon/line points, circle points and radius, each shape's `outline_width` (including line-only art), and every editable anchor position by the same factor about the document origin. Preserve colors, shape order, vertex flags, `reshaped`, anchor roles, and anchor angles. This is the established whole-document pivot, so Size for Stage does not silently recenter the saved origin or detach grips from their art. Reject a candidate atomically if factor, coordinate, radius, stroke width, anchor position, or visible extent is nonfinite or outside the existing representable coordinate/size limits; leave the last valid preview visible and the committed document untouched. Do not clamp one shape or anchor independently. Zero-width strokes remain zero. A no-op factor of 1 leaves the document clean when it was clean on entry.
Draw the size candidate in `PropCanvas._on_canvas_draw()` and then draw the guide **after all artwork**, with dark halo and light core for the bones, head, and floor line. Derive the comparison floor from the **candidate's current visual bottom**; align both guide ankles to that y and place the figure just to the artwork's left (or over its left portion when width is tight), so offset and centered authoring coordinates compare correctly without translating the artwork. With an empty canvas, draw the guide at its normal origin and ankle line; Size for Stage remains disabled until artwork exists. The floor cue can follow a changed candidate bottom. On entry, compute a union of candidate artwork plus the guide/floor and frame it with a bounded margin. Refit that union after a preset choice and when a fine step would clip it; do not reframe on every draw or override a child's deliberate pan/zoom while the union remains visible. The sizing view may temporarily fit below the canvas's normal manual `MIN_ZOOM` on narrow viewports and restores the prior pan/zoom on exit. Outside the sizing view, show the same foreground guide and floor whenever **Show Stickman Guide** is enabled. During sizing, force guide drawing on without changing or persisting that checkbox; restore its user preference on exit. The guide is canvas drawing only, never a saved shape, anchor, or Stage object.
**Keep size** validates the full candidate, swaps the complete shapes/anchors once, emits one document change, refreshes inspector/thumbnail state, and starts the existing dirty/autosave timer once if the result differs from the snapshot. **Cancel**, Esc, mode switch, New/Load, return to Stage, or closing the editor discards the candidate before another document action; no preview geometry enters Save/autosave. A Save request while the sizing view is open asks the child in the existing sizing controls to **Keep size** or **✕ Cancel** first, then the child can use ordinary Save/Save As; no new save dialog or asset-copy policy is introduced. `Transform Whole Prop`, Attach, Reshape, and canvas editing cannot run concurrently with this sizing transaction. The present whole-prop Transform action remains available in Arrange after sizing.
```mermaid
stateDiagram-v2
Arrange --> SizePreview: Size for Stage / snapshot
SizePreview --> SizePreview: Small, Room, Big, smaller, bigger / derive from snapshot
SizePreview --> Arrange: Keep size / validate, commit once, dirty once
SizePreview --> Arrange: Cancel or Esc / discard, no dirty
SizePreview --> Arrange: leave editor or switch mode / discard, no dirty
```
### Direct Stage move and resize of one Scenery piece
`StageSceneryController` (`class_name StageSceneryController extends Node`) takes ownership of the currently selected Scenery drag, replacing `SandboxStage`'s local `_scenery_drag_snapshots`/`_scenery_dragging` move path. It reuses its existing group snapshot math and `document_change_requested` signal but keeps the single gesture's commit boundary separate: release commits immediately; group release still leaves a candidate requiring **Keep changes**. Suggested typed controller contract:
```gdscript
signal single_gesture_changed(active: bool)
func begin_single_transform(node: Node2D, kind: StringName, pointer: Vector2, dragged_corner: Vector2 = Vector2.INF) -> bool
func preview_single_transform(pointer: Vector2) -> bool
func commit_single_transform() -> bool
func cancel_single_transform() -> void
func is_single_transform_active() -> bool
func step_single_size(node: Node2D, factor: float) -> bool
```
`kind` is exactly `&"move"` or `&"resize"`; the controller accepts only one selected `StageWallPanel` or `StageSceneryArtwork`. Snapshot `{node: Node2D, position: Vector2, rotation: float, scale: Vector2, panel_size: Vector2 when wall, bounds: Rect2, pointer: Vector2, dragged_corner: Vector2}`. This is transient in-memory state, never a `.scene` payload. A missing artwork without known bounds can move but cannot resize. Before applying any candidate, validate the whole result: finite position and scale, positive artwork scale components within the current Stage range, positive wall dimensions above `MIN_PANEL_SIZE`, valid node still in Edit Scenery, and a nondegenerate starting AABB **for resize**. A move preserves any existing positive nonuniform scale. A resize multiplies both scale components by one uniform factor, preserving the ratio of an imported/scripted placement without normalizing it. Rejecting a frame retains the last valid preview. An unchanged release emits no document change.
For move, derive `raw_delta = pointer - pointer0` from the snapshot. With Snap on, align the **visible world AABB's top-left corner** rather than the node's often offset origin: `delta = snap(bounds0.position + raw_delta) - bounds0.position`; apply `position0 + delta`. This preserves the existing top-left grid-alignment promise for placed wall panels and artwork. For uniform resize, take the world AABB corner opposite the dragged corner as the fixed point `P`. With `D = dragged_corner - P`, use `factor = 1 + (pointer - pointer0).dot(D) / D.length_squared()`; reject nonpositive/invalid factors. For artwork with saved local geometry, `position' = P + factor * (position0 - P)` and `scale' = scale0 * factor`, preserving rotation and depth. For walls, use the same position formula and `panel_size' = panel_size0 * factor`, leaving root scale at 1 so Brick/Wood motif pitch does not stretch. The fixed world corner stays fixed even when artwork's authored origin is offset. **Smaller**/**Bigger** in the selected-piece card call `step_single_size()` with reciprocal factors `1/1.1` and `1.1`, top-left pinned; each valid press is an immediate independent commit for that placement only. Other artwork instances, walls, Ground, Props, characters, and their interaction state are untouched.
`StageGizmos` supplies selection outline and corner drawing/hit geometry, using existing `SandboxTheme.overlays` colors and dark-halo/light-core selection language. Extend its Scenery adapter with at least **44 × 44 logical-pixel** hit regions converted through camera zoom, prioritizing a corner hit over the body; do not reuse its current `PropBlock`-only SCALE gesture or origin-pivot formula. The Stage root arbitrates input once: selected corner starts resize, selected body starts move, and group preview has priority while active. `StageSceneryController` previews directly on the selected node, then emits `document_change_requested` exactly once on changed release; `SandboxStage` updates its authored-state/save path and card. Esc, right-click, visible capture-bar **✕ Cancel**, mode/tool switch, selection removal, or node deletion restores the snapshot and clears the gesture. An input-generation token or equivalent active-state guard must consume a mouse/touch release after cancellation so it cannot commit an old draft. Manual Save first cancels an active single gesture and saves only prior commits; background autosave waits for it to end. Group preview retains its existing separate Keep/Cancel save gate.
```mermaid
flowchart LR
Pointer[Corner/body press] --> Hit[StageGizmos hit: corner first]
Hit --> Begin[SceneryController snapshot]
Begin --> Preview[Pointer motion from snapshot]
Preview --> Release[Release: commit once]
Preview --> Cancel[Esc/RMB/capture Cancel: restore]
Release --> Dirty[Stage dirty + scene v1.5 placement transform]
Cancel --> Idle[No dirty; stale release ignored]
```
The selected artwork card adopts `StageInspectorPanel` for fixed header/scrolling body, `StagePanelChoiceGroup` for Far/Near/On the floor, and `StageInspectorPanel.style_action_button()` for Smaller/Bigger and Erase. The selected wall card uses the same shell and action styling. Both call `set_safe_rect()` and preserve the compact Settings reopen behavior. `StagePanelToggleRow` is reserved for actual booleans; no new panel control is needed for size. The already shared `StageUseTargetCaptureBar.set_capture_actions()` shows **✕ Cancel** only while a single drag is held; group Keep/Cancel remains unchanged. New sizing controls in Prop Editor use its existing Arrange toolbar and `PropTheme`, because that editor is a distinct workflow from an in-stage selected-object card. No duplicate dialog, capture-bar, choice, or button styling is introduced.
The existing `.scene` v1.5 `position`, `scale`, `rotation`, and wall `size` fields already persist these per-placement changes; `.prop` v1.1 already stores points, circle radius, outline width, and anchors. This addition requires no schema bump and performs **no load-time resizing or migration of asset geometry or placement scale**. Saving an explicitly revised `.prop` at its existing path intentionally updates all live-reference Scenery and Props that use that path when reloaded; ordinary Save and Save As remain the only asset save actions. A scene's saved placement scale continues to multiply its current referenced art. There is no new asset-version enforcement, copy/update prompt, automatic compensation factor, or runtime name-based size inference.
## Asset catalog, tags, thumbnails, and missing-art repair
`PropFile` advances to v1.1 with optional top-level `tags: Array[String]`. It accepts v1.0 files with `tags = []`, normalizes a fixed set `nature`, `buildings`, `walls`, `roads`, `objects`, deduplicates, and writes only known IDs. Tags describe appearance, never physics. The same file remains the only vector authoring format; no `.scenery` file or new asset library root appears. Keep cutouts as immediate children of `content/props/` for existing scans; unapproved art stays under ignored `content/review/artist/<slug>/` and must not enter browser results. `ContentPaths.canonicalize()` handles saved paths, including old `props/` paths, while preserving external paths.
Split library eligibility by role without duplicating parsing: a shared parse/cache step returns `{id: String, path: String, name: String, shapes: Array[Dictionary], tags: Array[String], visual_bounds: Rect2, material_preset: int, collision_hull: PackedVector2Array}`. Scenery accepts nonempty visible vector geometry, including open lines and transparent-window frames, via `PropVisuals.build()`/shape bounds. A closed filled shape needs three noncollinear points; an open stroke needs two distinct points, positive width, and visible color. These rules accept line art without accepting a blank `.prop`. Props retain the existing valid closed-hull requirement. The existing `StageAssetBrowser` scan, asynchronous thumbnail queue, `ThumbnailCache`, and `PropThumbnail.render_shapes()` are reused for either role; a line-only artwork thumbnail must not go through `flatten_to_payload()` as a fake collision shape. Scenery keeps authored per-shape colors rather than applying a Prop material tint. All artwork includes untagged assets; Unsorted appears when appropriate. The same tag filters are available in Props, but its entries remain physically eligible only. Do not scan review directories or recursively change the existing immediate-child library behavior.
On scene open, load every live artwork reference. A missing/invalid source leaves a `StageSceneryArtwork` node with `missing = true`, saved transform/depth, remembered name, and last known footprint. Render a neutral translucent footprint with dashed light/dark edge when bounds exist, otherwise a compact world marker, with a constant-screen-size picture/question icon and “Artwork not found” label. It stays subdued in DIRECT/PLAY and is never solid. Show one themed missing-assets summary for the load with the UI spec’s **Show scenery** / **Later** actions, while retaining separate Prop/stickman warning semantics. The Scenery piece list has a **Needs artwork** section.
**Find artwork…** uses the established `.prop` chooser; **Choose another artwork** uses the common Artwork gallery. The candidate must pass Scenery visual validation before a ghost appears at the saved position/size/orientation/depth. The explicit **Use this artwork here?** confirmation commits only the selected placement. Cancel/failure leaves the original path, marker, and scene dirty state untouched. Replacing art marks dirty once and refreshes the known bounds, name, and visual tree; the original position, scale, rotation, and depth remain. A changed source file is picked up on reopen for every live reference; there is no embedded snapshot. The optional File details disclosure may show the path for an adult. The simple one-placement repair is required; an all-matching bulk repair is optional and must be explicitly confirmed with its count.
## Starter scenes and document lifecycle
Keep versioned starter documents under `content/stages/starters/` with stable IDs for Blank, Town Street, and Living Room. They are ordinary `.scene` snapshots, not locked nested instances. Blank may be assembled from the current neutral empty-scene defaults; Town Street and Living Room data use the same wall, artwork, terrain, Prop, Sky, and camera schema as a saved child scene. The bundled files reference only explicitly approved production art. A window is a genuine gap between wall rectangles with a transparent frame and Sky behind it; no cutting or glass behavior is inferred. Furniture Props receive deliberate still/block/support roles from the normal Prop contract, without promising Sit.
The focused [Phase 19 starter-scene composition specification](phase_19_starter_scene_composition.md) defines the approved pan-safe Town Street and Living Room surfaces and the opening-shot bounds used by starter-card previews. It keeps the ordinary v1.5 scene records and camera free of limits.
The current `PendingFileAction.NEW_SCENE` unsaved Save/Discard/Cancel gate remains authoritative. After it resolves, show the shared starter gallery **before** replacing the current document. Canceling the gallery leaves the existing scene and post-gate dirty state. Selecting a starter preflights its scene data, loads ordinary editable objects, resets `current_path` to empty, stamps a fresh creation time, proposes its editable name, and marks the new document unsaved. Failed starter validation leaves the prior scene intact and shows one themed error. No starter is inserted into another scene. A first-save path follows the existing Save As flow. CLI scene playback continues to load `.scene` directly, with no starter-specific runtime branch.
## UI component and theme reuse
| Surface | Reuse or shared extension | Feature-local work / justified exception |
| --- | --- | --- |
| File → New gate | `StageChrome` File menu; `StageModalViews` unsaved dialog; `StageDocumentController` pending action | Starter-choice result and fresh-document handoff only. |
| Starter, Scenery, Sky, finish and replacement galleries | Extract a responsive shared card-gallery body from `AssetSelector`/`scenes/stage/asset_selector.tscn`; retain `AssetCard`, `StageAssetBrowser`, thumbnail pipeline | Tabs, descriptive text, and filter meaning are feature-specific. Replace the current fixed four-column/12-per-page and `FOCUS_NONE` card limitation in the shared path: adaptive columns, scroll, native focus, visible focus ring, and keyboard activation. Non-placeable cards disable card drag. |
| Browse tags and authoring tags | One shared wrapping chip/choice treatment using native Buttons and `FlowContainer`/`HFlowContainer`, `SandboxTheme` popup tokens | Single-select browser filter versus multi-select authoring tags have distinct state rules. Do not use vertical `StagePanelChoiceGroup` as a chip row. |
| Wall color | Extend `ColorSwatchSelector` for wrap/selected/focus; native color picker in a `StageModalViews` themed window | Chosen wall color remains local to the placement/selected panel. |
| Selected artwork, missing art, wall, Ground, multi-selection | Direct use of `StageInspectorPanel`, `StagePanelChoiceGroup`, `stage_panels` tokens and `StagePropSettingsView` dismissal/compact Settings precedent | Domain rows and commands stay in a Stage Scenery/Ground view adapter. `StagePanelToggleRow` is used only for a real boolean, not depth or finish. |
| Member list / overlap popup / wall-area eraser | `StageInspectorPanel` action styling; generalize `StageModalViews.show_objects_here()` labels and candidate filter; reuse `StageUseTargetCaptureBar` for the eraser prompt and Done/Cancel | Membership, piece labels, the red rectangle preview, and wall subtraction are Scenery-specific. The selected wall card starts the tool; no new dialog, overlap popup, or capture-bar styling. |
| Placement/group capture | Extract themed responsive shell from `StageUseTargetCaptureBar`, preserving mode-pill-safe position, prompt/Cancel grouping, action stacking | Use-target and Scenery wording/actions stay in callers. No copied capture-bar CSS/StyleBox code. |
| Cursor and ghosts | `StageChrome` tooltip safe placement, `StagePlacementOverlay`, terrain ghost presentation, `SandboxTheme.overlays` | The draft geometry, validity, and missing marker are feature-specific. |
| Make artwork | Existing `PropEditor`, `PropCanvas`, `.prop` Save/recovery and `PropThumbnail` | Add a typed `AuthoringContext { PROP, SCENERY }`, `signal artwork_saved(path: String)`, and `signal return_requested`. In Scenery context hide material/mass/bounce, attachment, and other action settings; keep shape/color/name/tags/Save. |
The shared UI contracts should remain small and typed. Extract `scripts/shared/ui/asset_gallery_popup.gd`, `class_name AssetGalleryPopup extends PopupPanel`, as the fixed header/Close and scrollable responsive `AssetCard` grid used by the current `AssetSelector` and Phase 19 choosers. It emits `item_selected(entry: Dictionary)` and `cancelled`, and exposes `open_gallery(title: String, entries: Array[Dictionary]) -> void`, `set_thumbnail(entry_id: String, texture: Texture2D) -> void`, and `focus_entry(entry_id: String) -> void`. A card entry has `{id: String, label: String, thumbnail: Texture2D|null, helper: String, draggable: bool}`; the caller maps `id` to its domain item. `AssetSelector` retains its public `open(kind, entries)` facade and uses the shared gallery, so existing Prop/Stickman callers are not broken. `StageAssetBrowser` retains scanning/caching and adds a role-safe filtered query such as `scan_entries(kind: String, tag: StringName = &"") -> Array[Dictionary]`; no gallery owns asset parsing.
Extract `scripts/shared/stage_ui/stage_capture_bar.gd`, `class_name StageCaptureBar extends PanelContainer`, from the `StageUseTargetCaptureBar` shell. Its intended interface is `signal action_pressed(id: StringName)`, `func configure(stage_theme: SandboxTheme, mode: int, top_bar_height: float) -> void`, `func set_prompt(text: String) -> void`, `func set_actions(actions: Array[Dictionary]) -> void`, and `func hide_capture() -> void`; each action dictionary has `{id: StringName, label: String, prominent: bool}`. Keep `StageUseTargetCaptureBar` as a thin Use-specific wrapper or composition client preserving `cancel_requested` and `change_prop_requested`. Generalize `StageModalViews.show_objects_here()` with a typed title/label provider while retaining its existing facade and `object_selected(node: Node2D)` signal. Existing `StageInspectorPanel`/`StagePanelChoiceGroup` APIs need no new domain knowledge: caller-owned cards call `set_header`, `body`, `set_safe_rect`, `request_fit`, and `add_choice`/`select_no_signal` directly. `ColorSwatchSelector` may change its layout container to `FlowContainer` while preserving `configure(colors: Array[Color], selected: Color, theme: Variant)`, `set_selected`, and `color_selected(color: Color)`.
All new popup/native-window instances receive `SandboxTheme.control_theme()` explicitly, then `StageModalViews.apply_dialog_theme()` for child font overrides. The latter does not assign a window theme. Extend `SandboxTheme.DEFAULT_CONFIG` and `sandbox_theme.json` only for shared popup/gallery/chip/capture/selection measurements and colors. Use local StyleBox copies; do not mutate the cached shared `Theme`. Selected, hover, focus, and disabled remain distinguishable. All actionable controls and handle hit regions meet the approved minimum 44 × 44 logical pixels. Viewport-safe rectangles keep the mode pill and Close reachable; long content scrolls under a fixed header. The existing queue/rule/speech popups are distinct workflows and are not recast as gallery or inspector shells.
## Stage-hosted Prop Editor lifecycle
Instantiate the existing `scenes/editors/prop/prop_editor.tscn` in a full-viewport Stage-hosted UI layer, set Scenery context before its first user interaction, then focus its first drawing/name control. While active, `SandboxStage` routes no world selection, placement, camera mode, or shortcuts to the hidden Stage; dialogs and editor input remain usable. The underlying Stage document, dirty flag, selection, and current scene path remain in memory. The editor keeps its own `.prop` autosave and unsaved-work gate. Its F6 standalone exit still quits its own run; in hosted context Close/Cancel emits `return_requested` to remove the editor view and restore Stage input/focus without quitting the Stage. A successful Save emits `artwork_saved(path)` after the file write; **Place it as Scenery** returns to the Artwork chooser with a fresh scan/thumbnail generation and the saved asset focused. Merely opening/canceling the editor never changes the Stage document. A saved new `.prop` outside the Stage is a separate asset write, so returning without placement leaves the Stage unchanged.
## Implementation and art gates
1. Extend data boundaries first: `.prop` tags and role-safe visual scanning; scene v1.5 migration/validation; semantic draw order; inert visual nodes and shared finish/Sky definitions. Validate old scene/Prop compatibility before UI wiring.
2. Generalize the existing card gallery, tag treatment, capture bar, overlap popup, color treatment, confirmation style, and theme tokens. Build the Phase 19 chooser and inspectors from those shared controls. Avoid a parallel popup/card system.
3. Connect placement, selection scopes, missing repair, Ground finish, and group snapshot previews through `StageSceneryController` and the existing Stage save/mode gates. Add the starter gallery and Stage-hosted Prop Editor return path.
4. The original Phase 19 art manifest used `$asset-creation` and approved production assets. **This sizing amendment requires no new vector/raster deliverables**: it draws the existing stickman guide and floor cue in Prop Editor and extends existing Stage handles. An artist may deliberately revise a particular `.prop` through the ordinary asset-review workflow, but implementation does not batch resize built-ins or reopen the original art approval gate merely because this amendment is signed off.
5. Run syntax/import checks and focused existing suites after each integration slice. Add focused behavior checks where actual world state is at issue. Perform render-capable Stage/editor captures and real input-path checks before reporting visual/interactive outcomes as agent-verified.
Relevant existing regression starting points are `tests/test_phase19_scenery.gd`, `tests/test_phase10_prop_integration.gd`, `tests/test_prop_editor_file.gd`, `tests/test_prop_editor_canvas.gd`, `tests/test_prop_editor_round3.gd`, `tests/test_content_paths.gd`, and `tests/test_phase17a_prop_roles.gd`; run focused affected suites rather than the full historical matrix for each sizing edit. The original Phase 19 fixture covers scenery persistence and group behavior; extend focused coverage for Size for Stage's atomic Keep/Cancel and a real one-piece Stage resize, including late release after cancellation. Exercise production input and save/reopen paths, not only helper calculations.
## Observable acceptance and verification routes
Each row names a child path and a repeatable observation. Automated checks may create in-memory fixtures, but should use actual Stage spawning, document, input, and PLAY paths where practical; node/signal existence alone does not establish behavior.
| Child path / scenario | Observable outcome and measurement |
| --- | --- |
| File → New → cancel starter, then Town Street; save/reopen; repeat Living Room | Existing scene survives chooser Cancel. Each chosen starter is unsaved, individually editable, and preserves Sky, separate panels/art/Props/Ground and positions after save/reopen. Living Room Sky is visible through an actual wall gap. |
| Scenery → Artwork → Nature → Tree; place twice, change depth, RMB; reopen | Two distinct inert copies persist at saved transforms/depths. RMB removes ghost, preserves committed copies, returns to ordinary EDIT. Check `PhysicsDirectSpaceState2D` contacts, navigation candidates, and event participation remain absent for scenery while a normal Prop remains functional. |
| Make line-only artwork in Stage-hosted editor; Save → Place it as Scenery | `.prop` with open strokes appears in Scenery and has a valid thumbnail; it does not enter Props without valid closed collision geometry. Stage unsaved state and focus survive authoring return. Tags filter it without changing its behavior. |
| Draw Brick wall, resize it; paint a Wood Ground run, restyle one block | Wall is finite/inert; brick pitch stays constant. Ground collision and walking path remain valid; wood motifs line up across neighbors, shared seams do not interrupt the run, and only the selected block changes to the new finish. Measure character feet/contact over several physics ticks before and after restyling. |
| Ordinary EDIT click/box in front of broad wall; enter Edit Scenery; select art and wall through Pieces here | Gameplay objects remain reachable in ordinary EDIT. Scenery-only selection finds art ahead of broad panels, includes missing art in Edit Scenery, and never selects Sky. Real pointer/touch/keyboard activation produces the expected selection and popup dismissal. |
| Living Room: select two walls, window frame, rug; move preview then RMB; resize preview then Keep; Save/reopen | RMB restores each exact snapshot transform. Keep applies one positive factor around the opposite fixed corner, preserves artwork proportions/depth and wall motif pitch, leaves Ground/furniture untouched, and persists member transforms without a saved group. Save while pending asks Keep/Cancel and never writes draft values. |
| Prop Editor → Arrange → Size for Stage on a tall refrigerator, wide rug, and small item; try each preset/fine buttons, Cancel once and Keep once | The artwork and standard guide remain fully visible at ordinary and narrow viewport sizes. Longest visible edge reaches approximately `0.35H`, `1H`, or `2H` while proportions, circle radii, line widths, and editable anchor positions share the same factor; the foreground halo guide's ankles meet the floor cue at the candidate bottom. Cancel restores byte-equivalent authored geometry/anchors and the prior dirty state; Keep commits once. A Big preset cannot clip the guide merely because opening began on small artwork. Save/reopen the `.prop` through the normal path and compare geometry/anchors. |
| Edit Scenery → select two copies of one artwork; drag a corner on one, release; then cancel another drag with Esc/RMB/touch Cancel and release the pointer | The opposite visible corner stays at the same world position; only the edited placement changes, and it commits on release without Keep. A canceled drag restores its snapshot and the late release makes no change. With Snap on, a moved artwork or wall's visible top-left lands on a grid intersection. Smaller then Bigger returns near the initial bounds. Save/reopen and check the two copies retain distinct scales, while Props/Ground/PLAY behavior remains unchanged. A missing artwork without known bounds shows no resize handle. |
| Open a pre-amendment `.prop` and `.scene`, then explicitly overwrite one referenced `.prop` through ordinary Save | Opening alone does not rescale artwork or placement values. The explicit source overwrite changes every live reference to that source on reopen, while each scene placement retains its saved scale; there is no hidden compensation, copy prompt, or version migration. |
| Move a referenced `.prop` aside, reopen, repair selected missing artwork; cancel once then confirm | One neutral marker remains at saved position/depth, summary appears once, ordinary EDIT targets are not stolen, cancel keeps path/dirty state, and confirmed repair changes only that placement. Missing footprint/no-footprint group resize rules and PLAY marker presentation hold. Restore source and reopen to confirm live-reference visual update. |
| Select each Sky, pan/zoom, PLAY/stop, reopen | The fixed gradient fills the screen without an edge or flicker; details follow the bounded response in the [Sky addendum](#phase-19-addition-camera-aware-sky-details). Foreground pixel colors are unchanged by Sky choice. Layer order remains Sky/wall/Far/Near/Ground/floor/actors through camera movement, simulation, and reload; ghosts/handles/capture bars disappear in PLAY. |
Visual acceptance requires opening the affected scene in a render-capable Godot GUI, capturing the named chooser, normal/zoomed Stage, missing-art, group-preview, **Prop Editor sizing guide at Small/Big and narrow width, active single-piece handles/capture bar**, and PLAY states, and inspecting the pixels. Record capture location, scene/state, and observation. Interactive acceptance requires exercising the actual controls and input events, including RMB/Esc/touch cancellation, button sizing, grid-aligned move, commit on release, and a late release after cancellation. If GUI or input automation is unavailable after an attempted route, name the unverified outcomes and hand the user a focused reproducible check; a headless parse or screenshot file alone is insufficient. **Closeout:** the user reports completing the Phase 19 editor verification on 2026-10-22; the automated scenery suite passes 706 checks. Plain Background save/reopen was also explicitly verified by the user.
## Trade-offs and future work
- Independent nodes preserve the current editable direct-child document model and avoid using `PropBlock` as a fake inert body. The cost is a separate artwork visual node and explicit scene-object decoding.
- Live `.prop` references keep scenes compact and make art edits visible on reopen. They can change a scene's appearance after the source file changes; the saved name/footprint and repair flow cover missing files, not version pinning. A future immutable asset-version policy would need a separate decision.
- Stage-relative authoring size is geometry stored in `.prop`, while a Stage Scenery resize is an instance transform stored in `.scene`. This keeps legacy loads stable and lets two placements of one source have different sizes. It also means an intentional overwrite of a live `.prop` updates all references; the user chose to retain ordinary Save/Save As without a new copy/update decision. The authoring preview scales outline width and editable anchors with geometry and uses origin as its pivot; its floor guide follows visual bounds solely for comparison.
- A one-piece Stage drag commits on release for speed; a multi-piece group retains Keep/Cancel because its wider effect is harder to judge during a gesture. Both use exact snapshots before commit, while Stage-wide Undo remains deferred to the separate phase logged in technical debt.
- Rebased terrain drawing enables the required floor-art tier without changing Prop-to-character depth semantics. It changes terrain's relative visual position beneath Props; physics and navigation stay unchanged and must be regression checked.
- Vector motifs retain clarity and world alignment without repeated raster textures, at the cost of careful clipping/culling and seam management. Keep motif definitions centralized so wall/Ground previews match runtime drawing.
- Stage-wide Undo/Redo deserves one command history across placement, deletion, style, repair, and group commits. The Phase 19 Keep/Cancel preview is a local transaction only. Whole-set transform needs its own design because Ground physics, character/Prop placement, rules, and camera all have different transform invariants.
## Phase 19 addition: camera-aware Sky details
This focused addition implements the accepted [product addendum](../plans/phase_19_scenery_backgrounds_surface_finishes.md#camera-aware-sky-details-addendum) and [UI/UX contract](phase_19_scenery_backgrounds_ui_ux.md#camera-aware-sky-details). The original clauses above that describe Sky details as static are superseded here. **Only the four existing child-facing Sky presets gain automatic camera response.** The internal neutral Sky may use a zero-response profile. There is no motion setting, additional Sky card, theme token, scene field, or change to Background ground behavior.
### Ownership, nodes, and typed contract
```mermaid
flowchart LR
Camera[SandboxStage/Camera2D\nworld position + zoom] -->|bind once; sample framing| Sky[StageSkyLayer: CanvasLayer -1\nStageSkyView: Control]
Sky --> Gradient[full-rect gradient\nscreen fixed]
Sky --> Motifs[clouds / sun / moon / stars\nbounded camera response]
World[World: Background ground + walkable Ground + actors] --> Camera
Cards[Sky chooser and starter card previews] -->|SkyView without camera binding| Still[default still composition]
```
`SandboxStage` (`class_name SandboxStage extends Node2D`) owns the one live `Camera2D` and passes it to the already-built `StageSkyView`; it does not dispatch a separate Sky update from every mouse, touchpad, scene-load, mode, or PLAY path. `StageSkyView` (`class_name StageSkyView extends Control`) owns all Sky drawing and the response calculation. Its narrow typed API is:
```gdscript
func bind_camera(camera: Camera2D) -> void # A null camera restores the default still composition.
func set_sky(id: StringName) -> void
func get_sky() -> StringName
func apply_theme(theme: SandboxTheme) -> void
```
`StageSkyView` samples the bound camera's finite `global_position` and positive uniform `zoom.x` in its own `_process(delta: float) -> void`, compares the **effective** framing against the last drawn values, and calls `queue_redraw()` only when the visible composition changes. Its `NOTIFICATION_RESIZED` path also invalidates layout. Invalid or unavailable camera data falls back to the fixed default composition rather than propagating NaN into drawing. The first binding, preset change, and scene load invalidate immediately so a newly shown view does not display a previous frame's motifs. No signal or runtime state is serialized; the last sampled values exist only to avoid redundant redraws. The chooser's `StageBackgroundCardPreview` and starter's `StageStarterCardPreview` create unbound `StageSkyView` instances and remain representative still images. No new UI component, Stage inspector control, or `SandboxTheme.stage_panels` token is needed.
### Deterministic response and layout
Use the built-in Stage camera framing, `REF_POSITION := Vector2(0.0, -400.0)` and `REF_ZOOM := 0.5`, as the fixed reference for **every** scene. These values match `SandboxStage`'s current defaults and `sandbox_stage.tscn`. Evaluate each frame from absolute camera position and zoom; never accumulate input deltas, seed from elapsed time, or capture a per-session origin. Thus identical `{sky_id, camera.position, camera.zoom, viewport.size}` yields identical drawing after mode changes, PLAY/stop, and save/reopen. The existing `.scene` camera position/zoom and `sky_id` fields are sufficient; scene version and migration stay unchanged.
Each preset has one internal response profile, shared by all its motifs. A profile contains bounded horizontal/vertical pan limits in viewport fractions, a pan response span, and a bounded logarithmic zoom strength/span. These are renderer constants, not saved data or child-facing controls. A suitable starting envelope is at most about **5% of viewport width and 3% of height** for motif displacement and about **0.85–1.15×** for uniform motif size over the Stage's supported zoom range; tune each preset by rendered comparison at ordinary, close, and wide shots. Derive pan from the camera-position difference expressed in reference-frame screen pixels and pass it through a smooth odd saturating function (for example `tanh`). Derive zoom from `log(camera_zoom / REF_ZOOM)` through a smooth bounded function, then exponentiate to keep the size positive and reciprocal around the reference zoom. Avoid hard clamps in the visible response range, which can create a conspicuous change in motion at the limit. Camera movement of world objects must visibly exceed Sky-detail movement at ordinary framing. At the reference camera framing, displacement is zero and scale is one.
For clarity, one possible pure calculation is `shift.x = -W * limit_x * tanh(((camera_x - REF_POSITION.x) * REF_ZOOM / W) / span_x)` (and likewise for y), with `size_factor = exp(gain * tanh(log(camera_zoom / REF_ZOOM) / zoom_span))`; choose `limit/span` and `gain` so the detail response remains gentle. The final spec requirement is bounded, continuous, deterministic behavior, not these exact coefficients. Apply the same profile to all motifs of a preset as one visual group; individual stars may retain different authored sizes and positions, but do not acquire independent motion layers. Keep `sky_neutral` deterministic; a zero-response profile is acceptable for that internal default.
The gradient remains a full `Rect2(Vector2.ZERO, size)` draw in the screen-space `CanvasLayer`; **do not apply the motif transform to the gradient**. Place motif centers using normalized viewport anchors and scale their authored vector shapes **uniformly** with a single viewport-derived scalar, rather than separate x/y stretch. Apply the camera-induced size factor about the motif's own anchor, then add the bounded shift. Let the viewport clip any motif portion outside the visible rectangle; the gradient still fills those pixels, so no Sky edge appears. On resize, recompute anchors and the scalar from the new dimensions and reevaluate the same absolute camera frame. Retune the current low and oversized clouds, especially Cloudy Day and Sunset, so the ordinary lower action area remains comparatively open. This is art composition within the renderer, not a hard mask that hides clouds over arbitrary character positions.
The current star loop uses `fposmod(..., size.x/y)`, so changing viewport size can wrap a star abruptly to a distant position. Replace that layout with a fixed, deterministic set of normalized star anchors (or a fixed seeded list mapped to normalized coordinates); never seed it from the frame clock or call `randf()` while drawing. Existing cloud/moon curves should be sampled or cached once per shape/preset where practical, because camera movement now invalidates the drawing more often. Keep motif count and vector point count bounded; retain the gradient texture until the preset actually changes. There is no autonomous cloud drift, weather animation, horizon line, selection geometry, collision, or world tint.
`StageBackgroundGround` remains a world-anchored `Node2D` surface behind walkable Ground. Its landscape edge and the walkable Ground therefore move and scale together through the normal `Camera2D` transform. The Sky makes no ground-line claim and does not inspect terrain or character positions.
### Data flow and observable acceptance
1. The child chooses Clear Day, Cloudy Day, Sunset, or Starry Night. `SandboxStage` calls the existing `set_sky(id)` path; the Sky draws its fixed gradient and reference-frame motif composition. The chooser card remains a still preview.
2. A pan or zoom changes `Camera2D` through any existing input route. The bound `StageSkyView` samples the resulting absolute framing, derives its bounded motif transform, and redraws only when that framing changes. No mode or PLAY-specific Sky branch runs.
3. Save writes the existing `sky_id` and camera fields. Reopen loads those values; the next Sky draw derives the same composition from them and the current viewport size. A resize derives a coherent composition at the new size without changing saved state.
Repeat this in a rendered Stage: choose each preset; place a stickman on walkable Ground with a broad Background ground panel behind it; frame an ordinary shot, a close shot, and a wide shot; pan in EDIT and DIRECT, switch to PLAY and pan/zoom again, then stop. At an unchanged camera framing, motif anchors and apparent size must match across those mode and PLAY transitions. Resize the viewport twice and verify the gradient remains full-screen, motif shapes keep their proportions, and stars do not jump across the screen. Save at a nondefault camera framing, reopen at the same viewport size, and compare the motif composition; it must match without a separate Sky setting. Throughout, Sky displacement and scaling are perceptible but restrained, no edge or flicker is exposed, the lower ordinary action area remains usable, Ground and Background ground stay aligned, and foreground colors/physics remain unchanged. A screenshot or headless parse alone cannot establish the camera-response and composition outcomes; inspect rendered frames at the named camera states.
### Architectural trade-offs
- Keeping one `CanvasLayer` and one `StageSkyView` reuses current drawing and preview paths. The renderer must now redraw vector motifs while the camera moves; bounded geometry, geometry caching, and change-only invalidation keep that cost proportional to visible camera changes.
- Fixed reference framing avoids a new `.scene` field and makes save/reopen exact. Very distant camera positions can sit near the response limit, so additional panning there may change the Sky little; that is the accepted cost of a bounded composition.
- One profile per preset is simple to tune and preserves visual unity. It does not simulate separate cloud distances. True multilayer parallax and autonomous Sky animation remain outside this Phase 19 addition.
- Normalized anchors and uniform motif size preserve proportions across viewport shapes. Wide or narrow aspect ratios change the available spacing between details, requiring visual checks on both shapes; exact pixel placement across resizes is not promised.
@@ -0,0 +1,289 @@
# Phase 19 — Scenery, backgrounds, and surface finishes: UI/UX specification
> **Status (2026-10-22):** Phase 19 UI/UX is implemented and user/editor verified. The user completed the integrated visual and interaction checks; the scenery suite passes **706 checks with 0 failures**. Camera-aware Sky and Plain Background are later Phase 19 additions; see the [Plain Background plan](../plans/phase_19_plain_background.md) and [UI/UX specification](phase_19_plain_background_ui_ux.md). Based on the [Phase 19 product plan](../plans/phase_19_scenery_backgrounds_surface_finishes.md).
>
> **Decision:** Use a responsive Scenery chooser followed by canvas tools. Placement stays active for repeated copies. Right-click exits placement. Scenery artwork keeps live `.prop` references, with a visible, repairable placeholder if a referenced file cannot be found. The Phase 19 addition standardizes new artwork around a standing-stickman size guide and adds single-placement Scenery resizing; direct single-piece transforms commit on release, while the existing group preview keeps its Keep/Cancel boundary. Phase 19 does not add Undo; Stage Undo is deferred to its own future phase and should be recorded in tech debt during architecture planning.
> **Reuse gate for every new dialog or control:** Before building it, inspect the existing shared UI, Stage panel kit, Stage modal/capture controls, and `SandboxTheme` tokens. Use a matching component directly; extend or extract shared behavior when Phase 19 uses it in more than one place. Keep only content and interaction that are genuinely unique to this feature local. Do not add a parallel card grid, overlap popup, capture bar, dialog skin, swatch style, or selected-object inspector. The later architecture specification must name any justified exception before implementation.
## Child promise and visual language
The Stage teaches three roles through entry points and short, visible words: **Scenery stays put**, **Props can be part of the action**, and **Ground is where stickmen walk**. An identical drawing can be placed through Scenery or Props without implying the copies behave alike. Sky is chosen through its card, never by selecting an enormous rectangle in the Stage.
Keep the existing mode language: EDIT uses cyan/blue and a visible grid, DIRECT uses amber/gold and its camera frame, and PLAY uses green with clean artwork. Each active tool also has an icon and words; color alone never identifies it. All buttons, cards, chips, handles' pointer hit regions, and dialog actions have at least a 44 × 44 logical-pixel target. Control heights come from `SandboxTheme` tokens rather than local constants.
## Flow at a glance
```mermaid
flowchart LR
New[File → New] --> Gate[Protect unsaved scene]
Gate --> Starter[Choose Blank, Town Street, or Living Room]
Starter --> Edit[EDIT Stage]
Edit --> Scenery[Scenery chooser]
Scenery --> Sky[Choose Sky: changes immediately]
Scenery --> Wall[Choose wall look → draw panels]
Scenery --> Art[Filter artwork → choose drawing → place copies]
Scenery --> Dress[Edit Scenery → select and change pieces]
Dress --> Several[Select several → move or resize scenery together]
Several --> Review[Keep changes or Cancel preview]
Review --> Dress
Edit --> Ground[Ground → choose look → paint walkable pieces]
Wall --> Edit
Art --> Edit
Dress --> Edit
Ground --> Edit
```
The chooser is for discovery and choice. Once an artwork or wall tool is chosen, it closes so the Stage is visible. A small capture bar below the top toolbar and mode pill carries the current task, **Done**, and **✕ Cancel**. The cursor pill and ghost carry the immediate instruction at the pointer.
## Concrete screen examples
Desktop, while placing a Tree:
```text
┌ File ─────────────── ✏️ Edit | 🎬 Direct | ▶️ Play ─── Grid Snap ┐
│ [Ground] │
│ [Ramp] ✏️ EDIT │
│ [Step] ┌──────────────────────────────────────────────┐ │
│ [Props] │ 🌳 Tree stays put [Far] [● Near] [Floor] │ │
│ [People] │ Place another tree [Done] [✕ Cancel] │ │
│ [Scenery] └──────────────────────────────────────────────┘ │
│ [Areas] │
│ (translucent Tree ghost) │
│ 🌳 Tree · Near │
│ ─── ground / existing set ─── │
└────────────────────────────────────────────────────────────────┘
```
The Scenery chooser, opened from the left palette:
```text
┌ Scenery: stays put ─────────────────────────────── [✕ Close] ┐
│ [Backgrounds] [● Artwork] [Edit placed scenery] │
│ [All artwork] [Nature] [Buildings] [Walls] │
│ [Roads & paths] [Objects] [Unsorted, when needed] │
│ │
│ [ Tree preview ] [ Storefront preview ] │
│ [ Tree ] [ Storefront ] │
│ [ Skyline preview ] [ Rug preview ] │
│ [ Skyline ] [ Rug ] │
│ │
│ [Make artwork…] │
└──────────────────────────────────────────────────────────────┘
```
Edit Scenery, with several pieces selected and a group resize preview:
```text
┌ File ─────────────── ✏️ Edit | 🎬 Direct | ▶️ Play ─── Grid Snap ┐
│ [Scenery] ✏️ EDIT [Edit Scenery · 4 pieces] [Done editing] │
│ ┌──────────────────────────────────────────┐ │
│ │ ◇ fixed corner old dashed bounds │ │
│ │ [wall] [window] [clock] [rug] │ │
│ │ new bright group outline ◉ │
│ └──────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Resize 4 scenery pieces · Ground and Props stay put │ │
│ │ [Smaller] [Bigger] [Keep changes] [✕ Cancel] │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
These are relationship examples, not fixed pixel geometry. On a narrow viewport the chooser becomes a bounded, scrollable sheet with one card column if needed; filter chips wrap. It never opens wider or taller than the unobstructed screen. The top mode pill remains visible, and the chooser always has a reachable Close button. The capture bar wraps its text and stacks actions only when needed; it never hides the mode pill or the Stage point being placed.
## Control layout and navigation
### Stage palette and Scenery chooser
- Add one illustrated **Scenery** card to the existing vertically scrollable left palette. Keep **Props** and **Ground** as separate cards, with a short helper line in their chooser/tool: “Stays put”, “Can be part of the action”, or “Stickmen walk here.” The palette card opens the Scenery chooser rather than directly arming a tool.
- Keep the Scenery card pressed while its chooser and nested views are open. Back navigation keeps it pressed; closing or canceling the full chooser clears it. If a choice starts drawing, show that active state with the existing on-Stage capture bar instead of leaving the chooser card pressed.
- The chooser has a fixed title/Close row; **Backgrounds** and **Artwork** tabs; a scrollable body; and a fixed lower **Make artwork…** action when relevant. It is a chooser workflow, not a selected-object inspector. It receives `SandboxTheme.control_theme()` and its own popup tokens, while borrowing the Stage panel system's font, surface, selected, hover, focus, and target-size rules.
- **Backgrounds** shows two large category choices, **Sky** and **Wall panel**. **Artwork** shows a single-select filter row with **All artwork**, **Nature**, **Buildings**, **Walls**, **Roads & paths**, **Objects**, and conditional **Unsorted**. One drawing may carry several tags, so it appears under every matching filter. All artwork includes old, untagged drawings. Filters describe appearance, not behavior. Apply the same filters in the Props browser while keeping its title and placement role distinct.
- Use a `FlowContainer` for wrapping filter buttons and an adaptive card grid for previews. Each card has a large illustration, a readable name, and a visible keyboard focus ring. No card depends on hover to reveal its action. Changing tabs or tags preserves the chooser and moves focus to the first relevant control; closing returns focus to the Scenery palette card.
- A chooser Close or Esc dismisses only the chooser. Clicking outside may dismiss it when safe, but never chooses an asset or changes a Sky. A blank result shows **“No artwork here yet”** and offers **All artwork** and **Make artwork…** rather than an empty panel.
### File → New starter chooser
- Run the existing unsaved-work Save/Discard/Cancel gate before offering a starter. Do not replace the open scene until a starter card is chosen. If the starter chooser is canceled, the current scene remains open; its dirty state reflects whether the child saved it at the gate.
- The themed modal uses three large illustrated cards: **Blank Scene**, **Town Street**, and **Living Room**, plus **✕ Cancel**. Each card pairs a preview with one plain sentence: “Start from scratch”, “Shops, trees, sidewalk, and road”, or “A room with a window, rug, and furniture.” The cards wrap to fewer columns on narrower screens and remain scrollable. No tiny menu inside a card.
- Choosing a card creates a new unsaved scene with its suggested name in an editable name field. The name may be changed there or through the existing Rename Scene command. A small, dismissible on-Stage cue can say **“Try Scenery to change this place”** after Town Street or Living Room opens. It must not cover the Stage or block input.
- Blank starts with the existing neutral view and no Ground; the other two show editable ordinary pieces described in the accepted brief. Their cards must not promise a functional door, sitting, or automatic window cutting.
## Backgrounds and placement
### Sky
- **Backgrounds → Sky** opens four designed thumbnail cards: **Clear Day**, **Cloudy Day**, **Sunset**, **Starry Night**. The currently selected card has a strong border, filled selected state, and the text **“Current Sky”**; keyboard focus remains a separate outline.
- One tap or Enter/Space changes the Sky immediately; the chooser stays open so another card can be tried. Close means “stop choosing”, not “revert”. With no Undo in Phase 19, the child can restore the previous look by choosing its card again. Changing Sky never recolors or tints other pieces. Sky has no Stage selection handles or overlap entry.
- The thumbnail shows enough clouds, sun/moon, and color variation to predict the Stage result. It remains a representative still image; the Stage applies its automatic camera response without asking the child to configure motion.
### Camera-aware Sky details
- The four existing Sky cards and their immediate selection flow stay unchanged. The selected card still says **Current Sky**. Do not add a motion toggle, slider, new label, tutorial cue, cursor, or theme token.
- Keep the vertical gradient fixed to and filling the full viewport. Clouds, sun, moon, and stars respond automatically and subtly to Stage camera pan and zoom in **EDIT**, **DIRECT**, and **PLAY**. They move less and change size less than the stickman and Ground, so they read as distant details while connecting to a dramatic shot.
- Preset-specific composition keeps the main action area clear at ordinary framing. During close and wide views, details remain recognizable and do not grow across the character or walkable Ground. Camera response stays continuous and bounded; it does not expose an edge, pop, sweep distractingly, or reverse abruptly.
- At unchanged camera framing, switching modes, starting/stopping PLAY, and reopening a saved scene reproduce the same Sky composition. On viewport resize, the gradient continues to cover the view and the details recompose coherently.
- Keep landscape edges on world-anchored **Background ground** and scenery. Sky details do not draw a horizon, gain selection handles, enter selection, or alter the colors or behavior of world pieces.
- The visible feedback is the Sky details' restrained response to the same pan/zoom gesture already used on the Stage. No additional status message or control feedback is needed.
- Acceptance path: choose each Sky, then pan and zoom through ordinary, close, and wide framing in EDIT/DIRECT/PLAY; resize the viewport; start/stop PLAY; save/reopen with a saved camera view; and place Background ground behind walkable Ground. Confirm a subtle but perceptible Sky response, clear action area, no edge/flicker, consistent mode transitions, and Ground alignment.
### Wall panel
- **Backgrounds → Wall panel** offers picture choices **Paint**, **Brick**, and **Wood**, plus a large preview of only the selected finish (Paint is a flat color; Brick and Wood show their texture). The selected-color tile replaces the chooser content and footer with the themed custom color view; **Back to wall panel** discards its draft and returns to setup, while **Use color** applies it and returns. The chooser's **Recently used** row is the only recent-color list. In the expanded setup chooser, **Cancel** exits and **Draw wall panel** starts placement; there is no separate Back or close-X action in that state.
- Choosing a look updates its preview. Pressing **Draw wall panel** closes the chooser and arms placement. The capture bar says **“Drag across the space for a wall panel”**, with **Done** and **✕ Cancel**. The pointer has a corner reticle and a nearby pill **“Drag to draw wall”**. A press sets the first corner; dragging draws a world-anchored translucent patterned rectangle; release commits one finite panel. The pattern preview remains at sensible scale rather than stretching with the draft.
- Snap, when enabled, visibly aligns the draft to nearby grid lines. An unusably small draft shows a red outline and **“Make the wall bigger”**; release leaves no panel. A valid draft uses the EDIT accent plus a neutral outline and dimension cue. This is appearance only: a helper in the chooser says **“Looks like a wall. Stickmen can pass through.”**
- Touch can drag or tap the first and second corners. The first tap leaves a visible anchored corner and continuous rectangle preview; the second tap commits. The same visible Cancel and Done remain available. Right-click cancels a draft and exits the wall tool under the exit rules below.
### Artwork and depth
- Choosing an artwork card closes the chooser, starts a translucent ghost under the pointer, and shows a capture bar with the drawing's name, **Far**, **Near**, **On the floor**, **Done**, and **✕ Cancel**. **Near** is the default for a newly chosen drawing. Each depth choice has a simple layer illustration plus text; selected, hover, and focus states differ without relying on color. Changing depth updates the ghost's visible placement layer before commit.
- The cursor pill says **“Click to place [name]”**; on touch it becomes a small anchored Stage hint after the asset is chosen. Tap/click commits one copy. The ghost remains ready for another; after a commit the bar says **“Place another [name]”** and shows the count of copies placed in this tool session. Copies already committed remain when the tool closes. A repeat placement starts as another distinct piece.
- The selected artwork's source card may carry tag badges in the chooser, but tag words never suggest collision or interaction. The Stage preview says **“Stays put”**. Its depth affects appearance only; it does not grant blocking, pickup, support, Use, or event behavior.
### Opening a wall panel for a window or doorway
- Wall-area erasing is easy to discover from either entry point: a selected wall panel's shared Scenery inspector includes a touch-sized **Erase wall area** action, and the Edit Scenery capture bar shows **Erase wall area** whenever nothing is selected. Either starts a dedicated tool with a translucent red, crossed rectangle preview and a visible capture bar: **“Drag over the wall · release to erase”**, **Done**, and **✕ Cancel**. Snap-to-grid, when enabled, applies to both drag corners.
- Dragging a rectangle through one or more wall panels removes only the covered area. Each intersected panel is split into up to four remaining rectangles that retain its look and color. Brick/wood motifs keep a shared anchor, so the surrounding pattern does not restart at every cut edge. Existing artwork and other scenery are untouched; this is a visual opening, not a collision or doorway mechanic.
- Each valid release applies that opening immediately. **Done**, **✕ Cancel**, right-click, or Esc exits the tool; canceling an in-progress drag discards only that draft, while previously completed openings remain. Phase 19 has no Undo, so keep the capture copy clear about this commit-on-release behavior.
### Placement exit, cancel, and mode changes
| State | **Done** | **✕ Cancel** | Right-click / Esc |
| --- | --- | --- | --- |
| Chooser open, no tool | Close chooser | Close chooser | Close chooser |
| Tool armed, only passive artwork ghost or wall corner cursor, no placement yet | Exit without placing | Exit without placing | Exit without placing |
| Active uncommitted wall draw or resize | Discard the draft, then exit the tool | Discard the draft, then exit the tool | Discard the draft, then exit the tool |
| One or more pieces committed, no active gesture | Keep pieces and exit | Keep pieces; discard only the next passive ghost and exit | **Done**: keep pieces and exit |
| One or more pieces committed, another gesture active | Keep prior pieces; discard current draft and exit | Keep prior pieces; discard current draft and exit | **Cancel current draft**, keep prior pieces, then exit |
A passive artwork ghost does not count as an active gesture; a pressed wall corner, two-tap first corner, or resize drag does. Releasing a mouse button after cancellation cannot commit the old draft. The capture bar keeps a short line **“Placed pieces stay”** once any copy has committed, so Cancel does not falsely imply that all prior placements are erased. Right-click is an optional pointer shortcut; Done and Cancel stay visible for touch and keyboard users. Switching to DIRECT/PLAY or opening another Stage tool discards an active draft, preserves completed pieces, and removes all ghosts, handles, and capture prompts. No new Undo control or shortcut is introduced here.
## Edit Scenery and selected-object cards
- The Scenery chooser has a large **Edit placed scenery** action. It closes the chooser and enters a clearly labeled **“Edit Scenery”** state inside EDIT, with an on-Stage pill and prominent **Done editing** button. The global mode remains EDIT and its mode pill remains visible. Done editing, Esc when no edit gesture is active, or switching modes returns to ordinary selection. Esc during a resize or move first cancels that draft and leaves Edit Scenery active. The child can reopen Scenery to place more pieces.
- Ordinary EDIT click/box selection favors characters, Props, Ground, and existing interactive objects; broad wall panels, Far/Near artwork, and floor art do not enter those results. In Edit Scenery, only wall panels and Scenery artwork enter click/box selection. The selected piece gets a bright outline backed by a dark halo, a text name/depth label, and large move/resize handles with at least 44 × 44 logical-pixel hit regions at common zoom. A single-piece drag previews live and commits immediately on release; uniform resize keeps the opposite corner visibly pinned, and the transform affects only that placement. It has no extra Keep confirmation. While the pointer/finger is held, a compact capture bar offers **✕ Cancel** for touch users; Escape, right-click, or leaving Edit Scenery also cancels the active gesture and restores the pre-drag position/size. Releasing the gesture commits without waiting for another choice. A later Stage Undo action is deferred to its own phase and tracked in technical debt. The selected-artwork card also offers large **Smaller** and **Bigger** actions for users who prefer buttons; each applies one immediate, proportion-preserving size step to that placement only. Existing placement sizes are not automatically changed when this feature is added.
- When pieces overlap, a small cutout under the pointer takes priority over a broad panel. Clicking a panel's exposed area or edge selects it. If several pieces still match, show a themed **“Pieces here”** chooser with one large labeled row per candidate and a visible Close. A selected panel also remains reachable through that chooser when its visible area is covered. Box selection in Edit Scenery includes only Scenery pieces; ordinary box selection does not gather broad scenery.
- A selected Scenery artwork card uses `StageInspectorPanel` and `StagePanelChoiceGroup`. The header has the drawing name, icon, and Close. The body offers an illustrated **Where it appears** group with **Far**, **Near**, and **On the floor**, large **Smaller** / **Bigger** size steps, and **Erase scenery**. A selected wall card uses the same shell, with **Wall panel** header, **Look**, **Color**, and **Erase wall panel**. Each change applies only to that selected piece. These cards have a fixed Close, scrollable body, and the established compact **Settings** reopen button while the same piece remains selected. Closing the card does not exit Edit Scenery.
- An Erase action for a wall or artwork asks **“Erase this piece?”** with **Keep it** and **Erase** buttons before removing it, because Phase 19 has no Undo. The Delete key follows the same safeguard for selected Scenery.
- Sky is never listed in **Pieces here**, never box selected, and never shown in the selected-piece card. Its only edit path is **Backgrounds → Sky**.
### Artwork file missing after reopen
- A missing live `.prop` reference leaves a **real, saved Scenery placement** at its authored position and Far/Near/On the floor depth. On the Stage, draw a soft neutral translucent footprint with a dashed light-and-dark outline when the saved extent is known; otherwise draw a compact world-anchored marker at the saved point. A constant-screen-size picture/question icon and label say **“Artwork not found”**, followed by a short remembered drawing name or file name when available. Do not use a large red error block, expose a full file path on the canvas, or show an empty gap that looks like the child erased the art. Keep the marker legible on all four Skies and on common Ground/wall looks. In DIRECT and PLAY, the placement remains a subdued neutral marker at the same layer, without edit handles or repair controls, so the missing set piece is still evident without covering the action.
- On scene open, reuse the existing missing-assets summary dialog with calmer copy: **“Some artwork could not be found. Your scenery spots are still here.”** List the friendly names and offer **“Show scenery”** and **“Later”**. Show scenery enters Edit Scenery and focuses the first missing placement; Later closes the dialog and leaves every marker visible. This summary appears once for the load, not on every frame or selection. It does not replace the existing stickman/Prop warning semantics; the missing-Scenery wording is scoped to Scenery.
- In ordinary EDIT, the footprint and label are visible but the missing placement's broad bounds do **not** enter normal click or box selection. Its small, explicit **Find artwork** badge has at least a 44 × 44 hit target and can switch to Edit Scenery with that placement selected; the badge alone takes input, so a child can still select a character, Ground, or Prop in front of the marker. The Stage palette's **Edit placed scenery** path and a **Needs artwork** section in the Scenery piece list provide alternatives when the badge is covered or offscreen.
- In Edit Scenery, the missing placement participates in Scenery selection and **Pieces here** with a distinct picture/question icon and **“Artwork not found · [name]”** row. Selecting it shows the normal `StageInspectorPanel` shell headed **“Artwork not found”**, with the remembered name, its saved depth, and large actions **Find artwork…**, **Choose another artwork**, **Keep for now**, and **Erase scenery**. An optional **File details** disclosure may show the saved path for a helping adult; the path is not the primary instruction. The neutral marker remains visible when the card is closed, and the compact Settings reopen control works as for other Scenery. If the original size is unavailable, do not show misleading resize handles; explain **“Find artwork to change its size.”** Moving the marker and changing its depth remain possible without making it solid or interactive. It can join Select several for group moves; if any selected member lacks a known footprint, disable Resize together with the same plain-language reason until that artwork is repaired.
- **Find artwork…** opens the established themed `.prop` file chooser so the child can point to a moved file. **Choose another artwork** opens the same responsive Artwork gallery used for placement, explicitly labeled as a replacement choice. After either choice, show a ghost of the candidate at the saved position, size/scale, orientation, and depth where those saved values exist, and ask **“Use this artwork here?”** with **Use artwork** and **✕ Cancel**. The replacement can be adjusted afterward; do not silently alter Ground, Props, or other Scenery. Canceling the chooser, file dialog, or preview leaves the missing marker and saved reference untouched. An unsuccessful file load returns to the same marker/card with a plain **“Could not open that artwork. Try another file.”** message. Erase uses the shared **“Erase this piece?”** confirmation; only confirmed Erase removes the placement. If several places reference the same missing file, default to repairing the selected place only; an explicit **Use for all matching places** choice may be offered, with the affected count shown before confirmation.
- The placeholder, badge, piece-list row, preview actions, and dialog work by touch and keyboard: focus is visible and distinct from the missing state; Tab reaches the badge/list, actions are at least 44 × 44, Enter/Space activates them, Esc closes a chooser or cancels an uncommitted preview, and focus returns to the selected placeholder/card. On a narrow viewport, the label truncates gracefully to the remembered name, the inspector body scrolls below a fixed header/Close, and its actions wrap or stack without hiding the mode pill. A missing marker is identified by icon and words as well as outline, never by color alone.
### Move or resize several Scenery pieces
- **Select several** is a large, explicit action within Edit Scenery. While it is active, tapping pieces adds/removes them without beginning a move. A touch-friendly, scrollable piece list offers each piece's thumbnail/name/depth and a large selection row; it provides **Select all scenery** and **Clear selection**. Box selection may add Scenery pieces under the same filter. **Select all scenery** means every wall panel plus every Far, Near, and On the floor Scenery artwork placement in the open scene. Sky, Ground, characters, and interactive Props are never included. There is no automatic starter-wide group or persistent lock between pieces.
- With two or more pieces selected, show a halo-backed union outline, a separate outline on each member, and the text **“N scenery pieces selected”**. The selected-object card uses the shared `StageInspectorPanel` shell with **Move together**, **Resize together**, **Clear selection**, and a short explanation **“Ground and Props stay put.”** It does not show a single shared depth, finish, or color control, because mixed members may have different settings. The group outline and count appear on the canvas, not only in the card. Closing the card follows the normal compact Settings reopen behavior and leaves the selection visible.
- **Move together** starts a live preview. Dragging the group or using large on-screen arrow buttons applies the same world-space offset to every selected Scenery piece, preserving their relative spacing and Far/Near/On the floor choices. The original outlines remain faint/dashed while the candidate positions have bright outlines. Snap, if enabled, moves the selection as a unit from its anchor without rounding members independently. A cursor pill says **“Move N scenery pieces”**.
- **Resize together** starts a live uniform-size preview. Drag a group corner inward/outward; the opposite corner shows a fixed pin and does not move. Every selected artwork piece changes size and position by the same positive scale around that anchor, retaining its proportions and relative layout. Wall panel rectangles change size and position with the group, while Brick/Wood motifs retain their world-scale size and alignment instead of stretching. Far/Near/On the floor choices remain unchanged. Clamp or reject a preview that would collapse or flip any piece; show **“Too small”** with a blocked outline. The cursor pill says **“Resize N scenery pieces”** and identifies the fixed corner with a visible pin.
- During either group preview, the on-canvas capture bar shows **Keep changes** and **✕ Cancel**, the selection count, and **“Ground and Props stay put.”** Releasing a pointer leaves the preview pending; it does not save a change or newly mark the scene dirty. Keep changes commits the whole preview as one edit and marks the scene dirty once. Cancel, right-click, or Esc restores the exact pre-preview positions and sizes and returns to the same multi-selection in Edit Scenery. A later mouse/touch release cannot commit a canceled preview. **Done editing** or switching modes while a preview is pending first cancels it, then exits Edit Scenery. A Save request while a preview is pending asks the child to Keep changes or Cancel before saving; it never silently saves the draft.
- The group tools are operable without precise dragging: touch targets and corner hit areas are at least 44 × 44 logical pixels; large move arrows and **Smaller/Bigger** buttons give touch and keyboard users repeatable steps. Pointer users may drag any corner and see its opposite fixed pin; the button path starts with the top-left corner fixed and labels that choice. Every control is focusable, has a visible focus ring and text label, and responds to Enter/Space. On a narrow viewport, the multi-selection card scrolls beneath its fixed Close, the capture bar stacks its actions, and arrow/size buttons remain reachable even when a group corner lies offscreen. The mode pill and Stage preview remain unobstructed.
- A committed group transform saves each member's new position and size through the normal scene save; depth, finish, physics, and interaction roles do not change. The temporary multi-selection, preview, and group outline do not persist on reopen. A whole-set transform that also moves/scales Sky, Ground, characters, or interactive Props is outside Phase 19 and belongs to a separately designed future feature.
Single-artwork direct resizing and multi-piece group resizing intentionally have different commit gestures: one artwork placement commits on handle release, while a group retains the existing snapshot preview with **Keep changes** / **✕ Cancel**. Do not add a single-piece confirmation bar as a substitute for deferred Stage Undo.
### Stage-relative authoring size in Prop Editor
- In Arrange, replace the fixed-target **Normalize Size** action with **Size for Stage**. It opens a reversible sizing view with **Small**, **Room**, and **Big** starting sizes and fine **Make smaller** / **Make bigger** adjustments. The presets are rough visual starting points relative to the standard standing stickman, not rigid limits; preserve the asset's proportions and let the child inspect the actual comparison before keeping it. Exact preset ratios are an architecture/implementation detail, not new UI copy.
- Frame the complete artwork and guide together when the sizing view opens. Show a standing stickman at the standard 1:1 reference scale **in front of the artwork**, plus a clear floor/contact line near ankle level. Use a dark halo with a light core so the guide remains visible over any fill. The guide is editor-only and never becomes part of the saved artwork. The guide is visible whenever **Show Stickman Guide** is on and is forced on while **Size for Stage** is active.
- **Keep size** applies the preview to the authored shapes and returns to Arrange; **✕ Cancel** restores the exact pre-preview shape geometry. Existing whole-prop Transform behavior remains the way to move/rotate/resize all shapes and editable anchors together. Keep this sizing action within the established Prop Editor Arrange toolbar and `PropTheme`; do not reuse the Stage inspector card for a distinct editor workflow.
- Artist-created and newly authored vector artwork should already be drawn at its intended Stage size, so first placement is normally scale 1 relative to the authored artwork. The standard figure is about 940 world units tall. Prop names should guide an ordinary-object size estimate; the asset review records approximate intended bounds relative to the stickman (height, and width when useful) and includes a scale-check preview. The sizing view is a correction/authoring aid, not a required step for every asset.
- Loading existing `.prop` files and scenes performs no automatic normalization and retains saved placement transforms. **Keep size** deliberately changes the authored geometry; saving over the same `.prop` uses the existing live-reference behavior, so scenes using that drawing show its revised artwork on reopen. Keep the current Save and Save As workflow; this addition introduces no extra copy/version prompt or overwrite confirmation.
## Ground finish workflow
- The existing **Ground** palette entry gains a **New Ground look** chooser with large picture swatches: **Paint**, **Brick**, **Wood**, **Grass**, **Dirt**, **Sidewalk**, **Asphalt**. Paint, Brick, and Wood use the same names and comparable preview motif as wall finishes. A chosen swatch becomes the active look for future Ground only. The chooser states **“New Ground will look like this”** and never silently repaints existing pieces.
- Returning to the Stage shows a patterned Ground ghost for one tap or the existing drag-paint run. A small cursor pill says **“Click or drag to add Ground”** and the capture bar provides Done and Cancel under the exit rules above. Valid/blocked stroke previews use outline, symbols, and short words as well as color. Adjacent ghost pieces show a continuous-looking pattern. Ramp and Step keep their existing appearance and palette behavior.
- Selecting an existing Ground piece in ordinary EDIT opens a Ground `StageInspectorPanel` card with **This Ground's look**, the same finish swatches in a `StagePanelChoiceGroup`, and any existing Ground controls. Selecting a finish here changes only the selected piece. The different headings **New Ground look** and **This Ground's look** make scope visible before a tap.
- In PLAY, Ground keeps its walkable behavior and chosen look. Wall panels, road markings, and rugs never look like a selectable walking surface in the UI.
## Prop Editor route for Scenery artwork
- **Make artwork…** in the Scenery chooser uses the existing vector `.prop` authoring tool; there is no separate Scenery Editor or file type. When entered from Scenery, its first screen is framed as **“Make artwork for your scene”** and foregrounds vector drawing, color, shape arrangement, naming, and Save. Hide prop-only material, mass, bounce, collision, Use/attachment, and other action controls from this entry route. Do not require a pretend solid shape to save visual-only artwork.
- The Props entry route retains prop-relevant controls and its distinct **“Make a Prop”** framing. If a drawing is valid for both roles, the child may find it in both browsers; the Stage placement entry point decides what the copy does. After a successful artwork save, offer **“Place it as Scenery”** and return to the Artwork chooser focused on the saved drawing. Cancel/exit returns without changing the open Stage.
- Tag editing uses the same large multi-select choices **Nature**, **Buildings**, **Walls**, **Roads & paths**, and **Objects** in artwork authoring. The words are descriptive, optional, and never gate the All artwork view. An untagged saved asset remains discoverable under All artwork and conditional Unsorted.
The **Size for Stage** workflow above applies to both Scenery artwork and Props because they share the same `.prop` authoring canvas. It does not change the Stage role distinction: a Scenery placement remains inert and non-colliding, while a Prop retains its existing interaction and physics settings.
## Theme, components, and accessibility contract
- For selected wall, artwork, and Ground cards, reuse `StageInspectorPanel` for the fixed header, safe rect, body scroll, Close, focus, and reopened Settings button pattern. Reuse `StagePanelChoiceGroup` for exclusive depth/finish choices with stable labels and decorative preview cues. `StagePanelToggleRow` and `StagePanelSwitchVisual` remain available for true on/off settings but are not forced into this feature's depth or finish choices.
- The multi-selection card also reuses `StageInspectorPanel` and `stage_panels.action` for its large Move/Resize/Clear actions; its member list and preview controls add tokens under `stage_panels` rather than hardcoded colors or sizes. The group capture bar extends the same transient Stage capture pattern used for placement, keeping its instruction and Cancel together below the mode pill.
- Extend the `stage_panels` `SandboxTheme` tokens only where these cards need new preview-cue size, card spacing, or scenery-specific accent/selection outlines. Continue using `stage_panels.shell`, `.type`, `.choice`, and `.action` for geometry and typography, and `mode_colors.edit` for the EDIT accent. The Scenery chooser, starter modal, color dialog, and Ground chooser use popup/theme tokens and `SandboxTheme.control_theme()`; their layout is distinct from an in-stage inspector. Generalize the existing Stage capture-bar look for wall/artwork/Ground placement instead of copying fixed styling.
- On narrow or short viewports, chooser contents scroll, filter chips wrap, card columns collapse, the inspector header/Close remain fixed, and the capture bar wraps/stacks within safe margins. Never let a panel cover the EDIT/DIRECT/PLAY mode pill. Decorative thumbnails and cue drawings ignore pointer input; each actual choice is one native focusable Button with Space/Enter activation. Focus moves into an opened chooser/card and returns to the opener on Close when possible.
- Pointer, touch, and keyboard all reveal the selected state, hover where applicable, focus, disabled reasons, and confirmation. No instructional step exists only in a top header, bottom status bar, hover tooltip, or shortcut. Cursor pills remain near the pointer without covering it; they flip to stay in view. Touch users get the same instruction anchored near the draft plus always-visible Done/Cancel.
### Reuse and standardization audit
This matrix identifies current UI that already exists. “Extend/share” is a design requirement to converge repeated controls; it does not prescribe a class, signal, or file contract for the architect.
| Phase 19 surface | Existing source to use | Reuse decision and boundary |
| --- | --- | --- |
| **File → New** and unsaved-work gate | `StageChrome` File menu; `StageModalViews` unsaved `ConfirmationDialog`; Stage document flow | **Direct reuse.** Preserve Save/Discard/Cancel, focus, and the current scene until a starter is chosen. Do not make a second unsaved-work dialog. |
| Illustrated starter chooser | `AssetCard`, `AssetSelector` scene/shell, `StageModalViews` dialog theming and Rename Scene name entry | **Extend/share the card-gallery presentation.** The three starter cards need a responsive grid, scroll, Close/Cancel, and selected focus behavior also needed by Scenery/Sky/finishes. The editable suggested name follows existing name-entry styling/validation; starter composition remains feature-local. This is a modal choice, not `StageInspectorPanel`. |
| Scenery chooser and Artwork cards | `AssetSelector`, `AssetCard`, `StageAssetBrowser`, thumbnail cache/renderers, `scenes/stage/asset_selector.tscn` | **Extend/share the gallery shell and card behavior, reuse browsing/thumbnail work.** The current selector is a fixed four-column paged popup and sets card focus to `FOCUS_NONE`; these must be corrected for responsive layout and keyboard use in the common path rather than copied into a Scenery-only popup. Backgrounds/Artwork tabs use native button choice states and shared popup styling; placement-role text stays feature-local. |
| Sky and wall/Ground finish cards | `AssetCard` and the responsive gallery behavior above; `SandboxTheme` popup/card tokens | **Share the same preview-card and focus/selected-state rules.** Sky and finishes supply their own preview imagery and one-tap selection meaning; card dragging is disabled for these choices. They do not pretend to be `.prop` assets or reuse `StageAssetBrowser` asset scanning. Wall and Ground share names/preview motifs for Paint, Brick, and Wood. |
| Artwork tags, Props filters, authoring tags | No existing reusable tag-chip control; `StagePanelChoiceGroup` is a vertical exclusive inspector control; Stage speech UI demonstrates `HFlowContainer` wrapping | **Extract one shared wrapping filter/choice treatment.** Use native focusable buttons and one theme contract across Scenery browsing, Props browsing, and optional multi-tag authoring. Single-choice browser filters and multi-choice authoring use the same visual states but different selection rules. Do not clone local chip styling or force the vertical inspector group into a wrapping browser row. |
| Wall Color and custom color view | `ColorSwatchSelector`; native `ColorPicker` in the themed Stage view | **Extend/share the color-swatch treatment** so it wraps on narrow screens, has visible chosen/focus states, and uses Stage tokens. Open custom color selection from the selected-color tile, keep one recent-color row in the chooser, and provide **Back to wall panel** / **Use color** actions. The Prop Editor's `PropTheme` styling is not a Stage style source; wall/color values remain feature-local. |
| Scenery overlap **Pieces here** | `StageModalViews.show_objects_here()` and its themed, viewport-clamped, scrollable `PopupPanel` | **Generalize the existing overlap chooser.** Supply Scenery-only candidates and child-facing piece names/depth, while retaining the same placement, 44 px rows, dismiss, and focus behavior. Do not create a second overlapping-items popup. |
| Missing Scenery artwork | Existing `MissingPropPlaceholder`/`MissingStickmanPlaceholder` preserve missing placements; `StageModalViews` missing-assets dialog; `StageInspectorPanel`; `StageAssetBrowser` gallery/file chooser | **Reuse and generalize the recovery path.** Keep saved placement continuity and one themed load summary, but give Scenery its neutral labeled marker, selection-card repair actions, and the existing artwork/file choosers. Do not copy the older red marker styling or create a second asset gallery or dialog skin. |
| **Erase this piece?** | `StageModalViews` confirmation/dialog theming; existing Erase Use spot `ConfirmationDialog` and safe/danger treatment in `SandboxStage` | **Reuse the confirmation pattern.** Share the button sizing, themed window, and danger/safe styles instead of copying the spot-erase styling into another private function. The affected piece and wording are local. |
| Placement and group-preview bar | `StageUseTargetCaptureBar` and the transient capture-bar contract in `stage_panel_ui_system.md` | **Generalize/extract the capture-bar shell** for EDIT prompts, mode accent, Done/Keep changes/Cancel, responsive stacking, and a safe position below the mode pill. Keep Use-target-specific wording/actions in its caller; do not build a second bar with copied colors/geometry. |
| Cursor pill and Stage ghosts | `StageChrome` action tooltip/cursor, current placement ghost/terrain overlay, `SandboxTheme.overlays` | **Extend/share presentation and safe cursor positioning** while the wall/artwork/Ground draft visuals remain feature-local. Keep one visual language for valid/blocked, mouse/touch feedback, and cancellation; do not copy the current tooltip's hardcoded colors into Phase 19. |
| Selected artwork, wall, Ground, and multi-selection card | `StageInspectorPanel`, `StagePanelChoiceGroup`, `StagePropSettingsView` dismissal/reopen behavior, `stage_panels` tokens | **Direct reuse of inspector shell and exclusive choice group; extend shared action styling only as needed.** These are in-stage selected-object cards. Their data, headings, member list, and commands stay feature-local. `StagePanelToggleRow`/switch are reserved for real on/off values and are not substitutes for depth or finish choices. |
| **Erase wall area** tool | Selected wall's `StageInspectorPanel` or the no-selection Edit Scenery `StageUseTargetCaptureBar`; wall ghost/placement logic in `StageSceneryController` | **Reuse the selected-object action styling and existing transient capture bar.** Only the cutout rectangle preview and rectangle subtraction are feature-specific; do not add another toolbar or dialog. |
| Select-several member list | `StageInspectorPanel` body/action styling; `StageModalViews` large selectable-row precedent | **Reuse row sizing, focus, selected, and scroll rules.** Membership logic and piece labels remain local to Edit Scenery; do not invent another inspector shell or tiny checkbox list. |
| **Make artwork…** and return | Existing Prop Editor scene/controller and `.prop` authoring; `StageAssetBrowser` refresh/thumbnail path | **Reuse the existing editor and asset browser.** The Scenery entry changes the visible authoring framing and hides prop-only controls; it does not create another editor, file picker, art card system, or `.scenery` format. The return-to-Stage handoff is feature-local. |
| Stage-relative size in Prop Editor | Existing Prop Editor Arrange toolbar, whole-prop transform, Normalize Size path, stickman guide, and `PropTheme` | **Extend the existing authoring workflow.** Replace the fixed 96-unit normalization target with the reversible Size for Stage preview and front-drawn guide; keep the authoring figure/editor chrome local to Prop Editor. Do not adopt `StageInspectorPanel` for this editor-only interaction or change existing saved assets automatically. |
For every row, new dialogs/popups explicitly receive `SandboxTheme.control_theme()` and the established Stage dialog style, with their visual values read from `SandboxTheme`/`sandbox_theme.json` (`popups`, `palette`, `stage_panels`, `chrome.dialogs`, `mode_colors`, and `overlays` as appropriate). The existing `StageModalViews.apply_dialog_theme()` handles font/child overrides but is not, by itself, a substitute for assigning the control theme to a new window. Reuse local stylebox instances rather than mutating the cached shared `Theme`. Keep popup gallery behavior separate from the in-stage inspector layout while making their type, spacing, focus, and selected states consistent.
The existing `QueuePanel`, `RulePanel`, `ActionEditor`, and speech popups in `scenes/editors/stage/` solve different editing tasks. They can inform theme and responsive-container choices, but are not replacement shells for a starter or art gallery. Any new Stage popup should pass the same theme, sizing, focus, and dismissal checks as those existing workflows.
## Exact child-facing copy map
| Context | Text |
| --- | --- |
| Palette entry | **Scenery**; helper **Stays put** |
| Props distinction | **Props**; helper **Can be part of the action** |
| Ground distinction | **Ground**; helper **Stickmen walk here** |
| Scenery tabs | **Backgrounds**, **Artwork** |
| Edit action/state | **Edit placed scenery**, **Edit Scenery**, **Done editing** |
| Multi-selection | **Select several**, **Select all scenery**, **Clear selection**, **N scenery pieces selected** |
| Group action/preview | **Move together**, **Resize together**, **Ground and Props stay put**, **Keep changes**, **✕ Cancel** |
| Group adjustment | **Smaller**, **Bigger**, **Too small** |
| Wall category/helper | **Wall panel**; **Looks like a wall. Stickmen can pass through.** |
| Wall cursor/draft | **Drag to draw wall**; **Make the wall bigger** |
| Artwork cursor | **Click to place [name]**; after first copy **Place another [name]**; during direct resize **Resize [name]** |
| Placement actions | **Done**, **✕ Cancel**; after a commit **Placed pieces stay** |
| Ground palette/inspector | **New Ground look**, **This Ground's look** |
| Overlap chooser | **Pieces here** |
| Missing artwork | **Artwork not found**, **Find artwork**, **Needs artwork**, **Show scenery**, **Later** |
| Missing artwork repair | **Find artwork…**, **Choose another artwork**, **Keep for now**, **Use this artwork here?**, **Use artwork**, **File details** |
| Repair failure | **Could not open that artwork. Try another file.** |
| Erase safeguard | **Erase this piece?**, **Keep it**, **Erase** |
| Starter cards | **Blank Scene**, **Town Street**, **Living Room** |
| Stage-relative size | **Size for Stage**, **Small**, **Room**, **Big**, **Make smaller**, **Make bigger**, **Keep size**, **✕ Cancel**, **Show Stickman Guide** |
| Single Scenery resize | **Smaller**, **Bigger**; a handle drag commits on release |
| Wall opening tool | **Erase wall area**, **Drag over the wall · release to erase**; **Erase another area · earlier openings stay** |
Do not expose “entity”, “collision hull”, “z-index”, “physics layer”, “finish ID”, or “depth tier” as child-facing copy.
## Observable UI review scenarios
1. Create Town Street from File → New, cancel at the starter chooser once, then choose it. The old scene persists on cancel; the starter appears as an editable unsaved scene with its sky, Ground, and separate set pieces.
2. Choose Tree under Scenery → Artwork → Nature, change Far/Near/On the floor, place two copies, and right-click. Both copies remain, the passive ghost disappears, and ordinary EDIT selection returns. Repeat with an unfinished wall drag: right-click discards only that draft.
3. In ordinary EDIT, click and box-select a character and Prop in front of a room wall without selecting the wall. Enter Edit Scenery, select a window cutout, then choose the broad wall through its exposed area or Pieces here. Start a direct resize and cancel the still-active gesture before release; its old bounds return. Release a second resize and verify it commits immediately, with no Keep prompt.
4. Change Sky through all four cards while the Living Room window remains visible. The room colors stay unchanged; Close leaves the last chosen Sky, and keyboard focus returns to Scenery.
5. Select Wood for New Ground, paint a run, then select one existing Ground piece and change only that piece to Brick through **This Ground's look**. A selected wall panel's own Finish/Color changes only that panel.
6. At a narrow viewport and with touch/keyboard input, reach every chooser card, depth choice, Cancel, Done, inspector Close, and overlapping-piece choice without clipping or relying on hover. Enter PLAY and confirm all ghosts, handles, and edit-only bars disappear.
7. In Living Room, use Select several to choose two wall panels, the window frame, and rug. Preview a shared move, cancel with right-click, and verify all four return exactly to their original locations while Wood Ground and furniture remain still. Preview a uniform corner resize, check the opposite anchor stays fixed and wall motifs do not stretch, then Keep changes; save and reopen. The four pieces retain their new positions/sizes and original depth/finish, while the temporary group selection is gone. Repeat the preview with touch or keyboard controls in a narrow viewport, using Esc to cancel.
8. Save a scene with two copies of one live `.prop` Scenery drawing, then make the file unavailable and reopen the scene. The friendly missing-art summary appears once; both labeled markers remain at their saved positions/depths through EDIT and PLAY. In ordinary EDIT, select a character in front of a marker without selecting Scenery. Use a marker's Find artwork badge or the Edit Scenery **Needs artwork** list to open its card. Cancel a file choice and verify both markers remain. Choose a replacement for one copy, preview then cancel once, then confirm it; only that copy changes. Confirm the second still has a marker after save/reopen until it too is repaired or deliberately erased. Repeat the repair path with keyboard and on a narrow touch viewport.
9. In Prop Editor, open **Size for Stage** on representative tall, wide, and small artwork. Confirm the standard stickman is drawn in front, the floor/contact guide is visible over every fill, the preview frames both together, presets and fine adjustments preserve proportions, **Keep size** applies, and **✕ Cancel** restores the exact prior geometry. Confirm new placement uses the authored size and loading an existing file performs no automatic normalization. Deliberately save revised geometry over a referenced asset and confirm the usual Save flow and live-reference update on reopen, with scene placement transforms retained. In Edit Scenery, resize one placement and confirm it commits on release and saves/reopens; cancel a still-active drag with Escape/right-click and with the touch capture bar; verify other scenery and Props stay unchanged. Compare this with the separate group Keep/Cancel flow. Repeat sizing-guide visibility and Stage handle/button access with keyboard/touch and at a narrow viewport.
10. Open Scenery and navigate between Backgrounds, Plain Background color choices, and its custom picker. Back keeps the Scenery card pressed; Close or Escape from the full chooser clears it. Choose an artwork or surface tool and confirm the chooser card is unpressed while the on-Stage capture bar shows the active tool.
@@ -0,0 +1,82 @@
# Phase 19 addition — selected Scenery artwork depth
> **Status (2026-10-22):** Implemented and user/editor verified as part of Phase 19; the full scenery suite passes 706 checks with 0 failures. **Scope:** Edit Scenery's in-stage card for exactly one selected `StageSceneryArtwork`, including a missing-artwork marker. This is an addition to Phase 19. It does not change placement, wall panels, physical Props, or the scene schema.
## Child-facing behavior
After placing artwork, a child enters **Edit Scenery** and selects one drawing. The existing card keeps its artwork icon, name, fixed **Close** button, and **Smaller** / **Bigger** controls. Above the size controls, it shows **Where it appears** with three large, full-width choices:
1. **Far**
2. **Near**
3. **On the floor**
The selected choice matches that particular drawing's current layer. A short note below the choices says: **“Far is behind Near. On the floor is in front of the ground.”** Pressing another choice updates the Stage immediately and keeps the same card open. It changes only that selected placement; other copies of the same artwork keep their own layers. Repeatedly pressing the current choice has no effect on the document. The child's placement cursor and its Far/Near/On the floor capture-bar choices keep their current behavior.
A missing-artwork marker has the same depth section, using its saved depth. Changing it moves the marker's draw layer and is retained if the picture is repaired. A wall panel has no depth section. No selection or several selected scenery pieces has no single-artwork depth section or active depth command. Existing repair actions and the missing-artwork explanation remain visible where applicable.
The three buttons use the shared Stage inspector's selected, hover, focus, and disabled visual states; their words never gain checkmarks. Each native button meets the shared 44-pixel minimum hit height and responds to click, touch, Enter, and Space. On a short viewport, the body scrolls while the header and Close remain visible. A depth change preserves the body's scroll position. The card stays within the Stage's safe rectangle and does not cover the mode pill.
## Existing contracts and target changes
| Class / file | Typed contract | Change |
| --- | --- | --- |
| `StageInspectorPanel extends PanelContainer`, `scripts/shared/stage_ui/stage_inspector_panel.gd` | `func body() -> VBoxContainer`; `func set_header(title: String, icon_text: String = "") -> void`; `func set_safe_rect(rect: Rect2) -> void`; `func request_fit() -> void`; `func reset_scroll() -> void` | Reuse the present scenery card shell, its fixed header/Close, fit, and scroll. No new component API. |
| `StagePanelChoiceGroup extends VBoxContainer`, `scripts/shared/stage_ui/stage_panel_choice_group.gd` | `signal choice_selected(id: StringName)`; `func add_choice(id: StringName, caption: String, cue: Control = null) -> Button`; `func select_no_signal(id: StringName) -> void`; `func apply_theme(stage_theme: SandboxTheme) -> void` | Instantiate one artwork depth group with IDs `&"far"`, `&"near"`, `&"floor"`. The caller owns the mapping to child-facing captions and the domain mutation. No new component API. |
| `SandboxStage extends Node2D`, `scripts/editors/stage/sandbox_stage.gd` | Existing `_build_scenery_inspector()`, `_refresh_scenery_inspector()`, `_mark_scene_dirty()`; add a feature-local handler such as `func _on_selected_scenery_depth_choice(id: StringName) -> void` | Own the section, selection guard, refresh, and dirty call. Keep it in the existing scenery inspector body before `_scenery_size_row`. |
| `StageSceneryArtwork extends Node2D`, `scripts/stage/scenery/stage_scenery_artwork.gd` | `var depth: StringName`; `func set_depth(value: StringName) -> void` | Existing setter remains authoritative. It applies `StageDrawOrder.scenery_z(depth)` immediately to live art and missing markers. No new API. |
| `StageSceneAdapter extends RefCounted`, `scripts/editors/stage/stage_scene_adapter.gd` | Existing Scenery artwork entry's `depth: String` | Existing snapshot/load round trip persists the changed layer. No version, migration, or payload change. |
The section is feature-local composition inside the shared shell:
```text
StageInspectorPanel (existing scenery card)
├─ fixed header: artwork name or “Artwork not found” + Close
└─ scrolling body
├─ missing-artwork explanation (only when missing)
├─ Where it appears (only for one selected artwork)
│ ├─ StagePanelChoiceGroup
│ │ ├─ Far [id: far]
│ │ ├─ Near [id: near]
│ │ └─ On the floor [id: floor]
│ └─ helper: Far is behind Near. On the floor is in front of the ground.
├─ Smaller / Bigger (existing availability rules)
└─ existing wall-erase or missing-artwork actions, as applicable
```
Style the heading and helper with the existing `SandboxTheme` `stage_panels.type.section_size`, `stage_panels.type.helper_size`, and text/muted colors. Apply the theme to the choice group so it uses `stage_panels.choice` sizing and states and `mode_colors.edit` accent. `StagePanelToggleRow` is for booleans and does not represent this three-way choice. No parallel button styling, theme token, popup, or dialog is required. This follows the approved [Stage panel UI system](stage_panel_ui_system.md) and [Phase 19 UI/UX specification](phase_19_scenery_backgrounds_ui_ux.md).
## Signal and document flow
```mermaid
flowchart LR
Press[Far / Near / On the floor pressed] --> Choice[StagePanelChoiceGroup.choice_selected: StringName]
Choice --> Guard[SandboxStage validates Edit Scenery and exactly one selected artwork]
Guard --> Same{New depth?}
Same -- No --> Sync[Refresh selected button without signal]
Same -- Yes --> Setter[StageSceneryArtwork.set_depth]
Setter --> Draw[StageDrawOrder z-index changes now]
Setter --> Dirty[SandboxStage._mark_scene_dirty]
Dirty --> Sync
Dirty --> Save[Normal .scene save snapshot stores depth]
```
The handler re-reads the current selection when the signal arrives; it must not retain a previously selected artwork reference in a button callback. It returns without mutation unless Stage is in Edit Scenery, the selection has exactly one valid `StageSceneryArtwork`, and the ID belongs to `far/near/floor`. It also returns without marking dirty when the requested ID already equals `artwork.depth`. For a real change, it calls `artwork.set_depth(id)` once, calls `_mark_scene_dirty()` once, and refreshes the card. Refresh uses `select_no_signal(artwork.depth)`, avoiding a second handler call. It does not call `reset_scroll()` for this value change or close/recreate the card; normal fitting can be requested without moving the child's reading position.
For example, a selected tree with `depth == &"near"` receives `&"far"` from the button. Its `z_index` changes from `StageDrawOrder.NEAR` (`-20`) to `StageDrawOrder.FAR` (`-30`), its position and scale stay the same, and the Stage becomes dirty. Saving writes `"depth": "far"` in that tree's scenery artwork entry. Reopening selects Far for that tree; another tree placement can still be Near. A missing-art marker follows the same layer path and keeps its saved `asset_id`, footprint, and repair state.
## Acceptance and verification
1. Place two overlapping copies of a drawing, enter Edit Scenery, select one, and change Near to Far and then On the floor. The selected button and visible overlap order change immediately; the other copy keeps its former layer and neither copy moves or resizes.
2. With one drawing selected, press its already selected layer. The document's dirty state does not change and no redundant depth mutation is applied. Change to another layer; the document becomes dirty, the card stays open, and its scroll position remains stable.
3. Save the changed scene and reopen it. The same placement retains the new layer and its selected-artwork card highlights the saved choice. No scene-version migration or new field is needed.
4. Select a missing-art marker with known or unknown bounds. Its saved layer is highlighted and can be changed before repair; the marker visibly follows the chosen layer, and a later successful repair keeps that latest layer. Existing Find/Choose/Keep actions and size availability remain intact.
5. Select a wall panel, clear selection, and select multiple Scenery pieces. None offers a single-artwork depth choice. The wall's existing controls and group selection behavior remain available.
6. On a short or narrow viewport and by keyboard or touch, reach all three depth choices and Close. The fixed header remains accessible; selected/focus styles are distinct, and the Stage mode pill remains clear.
## Architectural decisions and limits
- **Reuse the existing inspector and choice group.** The UI is one exclusive three-way setting inside a card that already exists, so an additional panel class, signal bus, generic inspector schema, or `Resource` model adds indirection without simplifying this change.
- **Keep the Stage controller responsible for mutations.** The shared UI emits a `StringName` intent. `SandboxStage` verifies the live selection, changes one `StageSceneryArtwork`, and uses its existing dirty path; the UI control knows nothing about scene persistence.
- **Keep per-placement depth as the source of truth.** The `.scene` adapter already saves this field and `set_depth()` already maps it to Godot draw order. Missing-art recovery preserves the placement's depth. The placement tool's default and capture-bar setting remain separate from editing an existing piece.
- **Keep Undo in its own phase.** Each depth press is an immediate committed Stage edit. A future Stage-wide Undo command can capture old/new depth alongside other scenery actions under the existing Phase 19 debt item.
@@ -0,0 +1,157 @@
# Phase 19 — Starter Scene Composition: Architecture and Implementation Specification
> **Status (2026-10-22):** Implemented and user/editor verified. The user verified the Town Street and Living Room compositions in the editor. The Phase 19 scenery suite passes **706 checks with 0 failures**; see the [Phase 19 handoff](../plans/phase_19_handoff.md). The user-requested opaque Town Street Background ground layer and ordinary-size walkable Ground tiles are included.
>
> **Approvals:** Everyday Storybook direction approved; architecture defaults approved: **1,280×720 logical-pixel reference viewport (16:9)**, saved zoom **0.5**, horizontal pan guarantee **±1,000 world units**, and **100 world units** of end margin.
>
> **Related documents:** [Accepted composition brief](../plans/phase_19_starter_scene_composition.md), [UI/UX specification](phase_19_starter_scene_composition_ui_ux.md), [Phase 19 scenery specification](phase_19_scenery_backgrounds_surface_finishes.md), and [Background Ground and Wall Color brief](../plans/phase_19_background_ground_and_wall_color.md).
## Decisions and scope
1. Revise the existing Town Street and Living Room `.scene` snapshots using the existing editable node types. Promote the edited starter snapshots to the existing current scene format v1.6; their bundled source snapshots are v1.5. Preserve current starter IDs, File → New flow, and ordinary save/reopen behavior. The existing 1.5→1.6 migration and `background_ground` object type are already supported; this feature adds no schema field and does not bump beyond v1.6.
2. At saved zoom 0.5, author a horizontal surface span of at least **4,760 world units** for each surface that must remain present during pan. Use adjacent ordinary-size terrain tiles rather than scaling one walkable block across the whole span. The current Ground template snaps to a 192×32-world-unit polygon at scale 1; 25 adjacent tiles produce a 4,800-unit run from x=−2,400 to +2,400, covering a **1,280-logical-pixel rendered viewport**, camera center travel from the saved x-position by **±1,000 world units**, and at least **100 world units** of margin at either end:
`required_width = viewport_width / saved_zoom + 2 * pan_travel + 2 * margin`
`= 1,280 / 0.5 + 2 * 1,000 + 2 * 100 = 4,760 world units`
Each tile remains a normal-size, independently selectable Ground object. Adjacent edges must meet exactly: preserve one level collision top, avoid gaps/overlaps, and let the repeated tile edges read as intentional joints.
With the current saved camera center x=0, 25 tiles span x=−2,400 to +2,400. At camera x=±1,000, the viewport reaches ±2,280, leaving a 120-unit margin. Town Asphalt uses two additional normal-size tiles, one at each end, so a character can step down from the sidewalk onto the road at an exposed curb; its 27 tiles span 5,184 units.
3. Keep camera panning unrestricted. The authored guarantee is finite and applies at the saved zoom over the stated envelope; scene edges can appear beyond that envelope or in a wider zoomed-out shot. Zooming in continues to show the same continuous surface across the viewport.
4. Keep camera and placement state as ordinary saved `.scene` fields. Adjust the saved camera y only as needed to meet the approved opening composition; do not add camera limits or runtime surface extension.
5. Reframe `StageStarterCardPreview` around the saved opening composition. Its image must not become panoramic merely because pan-only Ground or wall extents are longer. Derive the preview focus from the saved camera and visible central composition; do not add preview metadata to `.scene`.
6. Reuse existing production artwork, surface finishes, Sky presets, and child-facing choices. This pass introduces no new controls, theme tokens, scene object types, signals, or graphical assets.
**In scope:** `content/stages/starters/town_street.scene`, `content/stages/starters/living_room.scene`, `scripts/stage/scenery/stage_starter_card_preview.gd`, and targeted navigation-ribbon coalescing plus the exposed Town curb connector in `scripts/editors/stage/sandbox_stage.gd` needed for tile seams and the 32-unit sidewalk-to-road step.
**Out of scope:** camera restrictions; runtime-generated or auto-extending scenery; a new starter or template system; new art; changes to Ground physics/collision, navigation behavior beyond adjacent tile seams and the exposed 32-unit Town curb step, wall collision, Prop roles, Sky response, chooser controls, or scene serialization; zoom-out edge guarantees beyond the stated envelope.
## Current state and architecture fit
Both bundled starter files currently use scene v1.5 with `sky_id`, saved camera `{position, zoom}`, and independently editable object records. `StageSceneIO` migrates them to the current v1.6 format on read. Both currently save zoom 0.5 and camera x=0. Town Street saves a Cloudy Day Sky, one Far skyline, four Near artworks, and 1,400-world-unit Sidewalk and Asphalt runs. The buildings and trees do not share one visible base line, and their current Ground ends inside the opening view. Living Room saves a Clear Day Sky, three painted wall panels around a real window gap, seven 200-unit Wood Ground pieces, separate wall art and rug, and three functional Props. The floor run is short; the couch and armchair are visibly above its support line.
`StageSceneIO` and `StageSceneAdapter` already persist and reload these records. `StageSpawner` creates ordinary terrain and Props. `TerrainBlock` remains the walkable surface; `StageBackgroundGround`, `StageWallPanel`, and `StageSceneryArtwork` remain inert Scenery. `StageSurfaceRenderer` already draws world-aligned surface motifs with viewport culling, and `StageRectSurface` caches wall patterns. These are existing contracts to reuse, not new abstractions.
The source art's measured visual bottoms are useful placement references: Town house about 128 local units below its origin, storefront 119, tree 113; couch 111, armchair 107, coffee table 94. These bounds are starting measurements only. Final alignment uses rendered visible contact at Stage scale, not saved origins or selection AABBs alone.
## Scene and rendering flow
```mermaid
flowchart LR
FileNew[File → New → Town Street or Living Room] --> Read[StageSceneIO migrates v1.5 input to current v1.6]
Read --> Apply[StageSceneAdapter + StageSpawner create ordinary World children]
Apply --> Camera[Saved Camera2D position and zoom]
Camera --> Sky[Screen-space StageSkyView]
Camera --> World[World surfaces, scenery, Props, actors]
World --> Opening[Everyday Storybook opening composition]
Pan[Unrestricted camera pan] --> Camera
Extent[Authored 4,760-unit surfaces] --> World
Read --> Preview[StageStarterCardPreview]
Camera --> Preview
Preview --> Card[Opening-shot preview; ignores pan-only bounds]
Opening --> Save[Existing scene save/reopen path]
Save --> Read
```
The live Sky remains the screen-space background. World surfaces and objects keep their existing semantic draw order: Sky, Background ground, wall/Far scenery, Near scenery, walkable Ground and floor scenery, then action objects and actors. Preserve the existing Sky response and keep the chooser card's Sky unbound to the live camera.
## Starter composition contracts
### Town Street
- Keep the Cloudy Day Sky, muted Far skyline, existing house/storefront/tree artwork, and normal Ground palette. Use the Far skyline and the placement/scale of existing Near art to imply more street while preserving a clear center.
- Align the visible bottoms of each building and tree to one sidewalk top line. Use source-art visible bounds to seed placement, then adjust by rendered inspection. At saved zoom 0.5, the final visible gap from an asset base to the sidewalk must be no more than 3 logical screen pixels.
- Give the initial camera frame a calm center lane at walking height: at least its middle third remains clear of Near artwork, leaving room for a standard standing stickman and a short speech bubble.
- Add an opaque `StageBackgroundGround` behind the skyline, near artwork, and walkable tiles. It begins at the skyline's lower visible datum and extends below the opening viewport, covering the clouds that otherwise show through the gap between the skyline and street. Use the existing Grass finish. It is visual only and does not create another walking line.
- Build Sidewalk as a horizontal row of 25 default-size Ground tiles, with centers from x=−2,304 to +2,304 and bounds x=−2,400…+2,400. Build Asphalt as a separate row of 27 default-size tiles, with one extra tile at either end and bounds x=−2,592…+2,592. Place Sidewalk at y=0 and Asphalt at y=32 so their edges meet and the Asphalt begins below the Sidewalk. The exposed 192-unit road ends provide a physically reachable 32-unit curb step; route navigation at those endpoints without merging the Ground blocks or colliders. Keep both rows flat and continuous; no single oversized block or deep scaled Asphalt piece.
- Keep skyline and Near artwork as separate editable placements. The Far skyline should not reveal a distracting cut edge within the pan envelope; reuse/reposition or duplicate existing skyline art only if its actual bounds require it.
### Living Room
- Keep the Clear Day Sky and the existing window frame and room artwork. Extend the painted wall from side to side across x=−2,380…+2,380, with a clean floor boundary and editable baseboard or equivalent edge.
- Build the wall as opaque editable panels around one deliberate window aperture. The opening is the only visible route to Sky. Extend the top and side panel bounds far enough to cover the full rendered viewport at pan endpoints; use slight opaque overlap at non-window joins to prevent subpixel Sky leaks. Do not overlap the aperture.
- Preserve the window as a real gap between wall panels with the existing transparent frame in front of the Sky. Do not add fake glass, collision, or a new wall-cutout behavior.
- Build `finish_ground_wood` as one horizontal row of 25 default-size Ground tiles. Centers are spaced 192 world units apart from x=−2,304 to +2,304 at y=200; at scale 1 the row's top is y=184 and its combined bounds are x=−2,400…+2,400. Keep one level collision top and the existing wood pattern scale; do not stretch one block across the room.
- Place the couch, armchair, coffee table, and floor art so visible supports/base edges sit at the wood floor line within 3 logical screen pixels at saved zoom. Preserve Prop roles and their normal collision/support. Keep the rug centered, visually above the wood, below actors, and outside the central movement lane.
- Keep the wall decorations separate and secondary to the window and action. No individual decoration or furniture piece may be merged into a backdrop image.
### Shared camera and child readability
- Retain saved zoom 0.5 unless rendered comparison shows the approved composition cannot fit. Save the chosen camera y/center in each scene so normal File → New opens directly to the intended shot.
- At the supported 1,280×720-logical-pixel reference viewport, test the saved center and camera x at −1,000 and +1,000, at zoom 0.5. At all three positions, the applicable sidewalk/road, wall/baseboard, and floor cover the full rendered viewport width with no panel or terrain end, accidental Sky seam, or distracting join.
- Tune vertical framing against the safe Stage area in a render-capable run: put the walking line about 70–80% down from the top of the usable Stage area. A standing stickman must fit from feet to head; a two-line speech bubble must remain readable with about 24 logical pixels of clearance from visible Stage chrome.
- A narrower viewport is covered by the same surfaces and must retain actor/bubble legibility. Do not assert the 1,280-pixel guarantee for a wider logical viewport without recalculating the extent formula.
## Persistence and typed contracts
No new API, signal, or serialized field is introduced.
| Target | Existing contract and required update |
| --- | --- |
| `content/stages/starters/town_street.scene` | Write current v1.6 with the existing `sky_id`, camera, and ordinary object dictionaries. Add the Grass Background ground object, and use unique `file_id`s for each default-size Sidewalk (25) and Asphalt (27) terrain tile. Keep stable IDs for retained artwork and preserve the camera. |
| `content/stages/starters/living_room.scene` | Write current v1.6. Update wall panel `position`/`size`, use unique `file_id`s for each default-size Wood Ground tile, and preserve artwork/Prop transforms and camera. Keep the window aperture represented by the spatial gap between wall panels. |
| `StageSceneIO` (`class_name StageSceneIO`) | Read v1.5 legacy snapshots and write current v1.6 documents. Do not bump beyond v1.6 or add camera-envelope/preview fields. |
| `StageSceneAdapter` (`class_name StageSceneAdapter`) | Continue mapping `terrain`, `background_ground`, `wall_panel`, `scenery_artwork`, and `prop` dictionaries into their existing node classes. No starter-specific loader branch. |
| `SandboxStage` (`class_name SandboxStage extends Node2D`) and `Camera2D` | Load the saved camera normally. Do not clamp position to starter bounds. Coalesce adjacent collinear terrain top edges into continuous navigation ribbons so pathfinding crosses tile joints; connect Town's 32-unit curb step only where the lower Asphalt extends past the upper Sidewalk. Keep the terrain objects/colliders separate. Existing zoom limits and user input remain authoritative. |
| `StageStarterCardPreview` (`class_name StageStarterCardPreview extends SubViewportContainer`) | Replace the all-object-bounds fit as the preview focus rule. Keep its private preview setup and local `_world.scale`/`_world.position`; derive an opening-shot focus from scene `camera.position`/`camera.zoom` and a stable reference aspect. Ignore bounds added solely to cover the pan envelope. Do not mutate the loaded document or live camera. No public signal or caller change. |
| `TerrainBlock`, `StageBackgroundGround`, `StageWallPanel`, `StageSceneryArtwork`, `PropBlock`, `StageSurfaceRenderer`, `StageRectSurface` | Reuse existing typed configure/save/render/collision contracts. Modify one only if visual validation finds a concrete rendering, scaling, or hit-selection defect in the required bounds. |
Representative existing object shapes (not new schema):
```json
{"file_id":"ground_sidewalk_13","type":"terrain","asset_id":"ground","position":{"x":0,"y":0},"rotation":0,"scale":{"x":1,"y":1},"finish_id":"finish_ground_sidewalk"}
{"file_id":"ground_asphalt_13","type":"terrain","asset_id":"ground","position":{"x":0,"y":32},"rotation":0,"scale":{"x":1,"y":1},"finish_id":"finish_ground_asphalt"}
{"file_id":"town_backdrop","type":"background_ground","position":{"x":0,"y":Y},"size":{"x":4800,"y":H},"finish_id":"finish_background_grass","color":"#72A84C"}
{"file_id":"wall_left","type":"wall_panel","position":{"x":X,"y":Y},"rotation":0,"size":{"x":W,"y":H},"finish_id":"finish_wall_paint","color":"#5A9D70"}
```
`N`, `X`, `Y`, `W`, and `H` are scene composition values to tune from rendered visible bounds and camera framing. They do not denote new runtime fields. Prefer scene objects whose visible geometry meets at common edges rather than hiding incorrect positions with z-order.
## Starter-card framing
Today `_fit_scene_to_preview()` unions every loaded object's world AABB and fits that union into 256×256. The 4,800-unit rows of tiles or wall would dominate that union. The replacement should:
1. Read the saved camera center and zoom from the already-loaded scene data.
2. Build a square preview-only focus centered on the saved camera position. Use the 16:9 reference viewport height and saved zoom for the square world extent: at 1,280×720 and zoom 0.5, the crop is 1,440×1,440 world units. This keeps the town's central buildings/trees or the room's window/furniture in the chooser card while ignoring long pan-only bounds. Fit the square crop into the existing 220×220 usable area of the 256×256 preview.
3. Fit that focus region inside the preview's usable bounds. Do not include pan-only surface tails when computing the frame; the live scene still renders those full surfaces.
4. Leave the Sky preview still and full-rect. Apply preview pan/scale only to the preview `_world` node; never modify saved camera data.
5. Inspect both cards at their actual chooser size. Each should show its identity and central composition without shrinking the actor/set to a tiny strip or clipping the window/road datum.
Do not add per-starter preview bounds to `.scene` unless a shared, camera-derived crop demonstrably cannot keep both cards recognizable. That would require a separately approved data/authoring need.
## User paths and observable acceptance
| Child path / repeatable scenario | Required observation |
| --- | --- |
| File → New → Town Street | At the saved shot, the child sees a readable neighborhood, opaque land behind the skyline, continuous tiled sidewalk/road, a clear center lane, and the existing cloudy Sky above the skyline. Near artwork bases rest on the same walkable line within 3 screen pixels. |
| Town Street at saved zoom; pan to camera x=−1,000, 0, +1,000 | The full rendered width remains covered by sidewalk and Asphalt tile rows. Tile joints are intentional and edges have no collision or navigation gaps; no exposed row end or Sky/clouds show through below the skyline. A PLAY actor can walk off the sidewalk end and land on the exposed road, and navigation connects both walk lines there. The decorative backdrop does not support a stickman. |
| File → New → Living Room | The child sees a complete painted room with visible baseboard and wood floor. Sky appears through the window opening only. Furniture supports meet the floor within 3 screen pixels; rug is centered below actors, with an open action lane. |
| Living Room at saved zoom; pan to camera x=−1,000, 0, +1,000 | Wall, baseboard, and floor cover the full rendered width. No Sky leak appears at panel edges, under the floor, or outside the window. Furniture stays separate and the ground remains one support level. |
| Place a standard standing stickman and a two-line speech bubble in the central lane; inspect EDIT and PLAY | Feet contact the intended surface. Head, body, and bubble are legible with the specified chrome clearance. PLAY preserves Ground support and existing Prop behavior; no decorative Background ground or wall panel gains collision. |
| Open New Scene chooser; inspect Town Street and Living Room cards | Each card remains a recognizable opening-shot preview after the live-world pan surfaces are lengthened. The camera framing of the preview does not move the saved/live scene. |
| Save each revised starter as an ordinary scene, close/reopen it | Existing object types, finish IDs, individual placements, roles, Sky ID, and saved camera framing round-trip through the current scene v1.6 format. The loaded scene remains editable and has no starter-only runtime behavior. |
| Resize to a narrower Stage viewport; zoom in for a close shot | Continuous surfaces still cover the visible width. The action lane and window remain legible. This pass does not promise full-width coverage for zoom-out views wider than the reference envelope. |
Phase 2 must inspect rendered captures from a render-capable Godot Stage at opening frame, both pan endpoints, PLAY, both starter cards, and a narrower viewport; record the capture paths and observed pixel gaps/seams. Exercise actual camera pan, save/reopen, Ground support and navigation across tile joints, and normal selection/edit routes. Bake adjacent collinear tile tops into continuous navigation ribbons and connect the exposed Town curb step without merging separate terrain objects or colliders. A successful import or headless parse alone does not verify composition. If an interactive rendering/input route is unavailable after trying it, list each unverified outcome and provide a focused user check.
### Implementation and verification record (2026-10-21)
- The initial pass promoted both snapshots to v1.6 and aligned visible bases, but used oversized scaled walkable blocks and omitted Town Background ground. The user identified those issues; that surface implementation was superseded by the tile/backdrop revision below.
- `StageStarterCardPreview` now derives a square crop from the saved camera position and zoom. At zoom 0.5, it frames a 1,440 × 1,440 world-unit opening crop in the existing 220 × 220 usable preview area.
- `/Applications/Godot.app/Contents/MacOS/Godot --path /Users/ryan/Projects/stickman --headless --rendering-method gl_compatibility --script res://tests/test_phase19_starter_scene_composition.gd` passed **515 assertions** on Godot 4.7.1. It covers production scene loading, save/reopen, surface bounds, window opening, preview transforms, base contacts, PLAY support, navigation across each tile row, and sidewalk-to-road access. Town Asphalt has 27 scale-1 tiles spanning 5,184 units; a PLAY actor moved from x=2,280 to x=2,500 and landed grounded on the road at feet y=15.31. The navigation query from center to the opposite walk line follows the exposed right curb through `(2400, -4)` and `(2400, 12)`. Measured contact gaps at the saved zoom were 0.0 logical px for Town artwork; 0.0–0.123 px for Living Room floor artwork; and 0.025–0.225 px for the three Props. These are production-loaded geometry measurements, not pixel samples from rendered captures.
- An earlier Phase 19 scenery-suite run exited 1 with nine failures in Sky/surface previews and Edit Scenery eraser/capture-bar behavior. This historical result was superseded by the 2026-10-22 retest: **706 checks passed, 0 failed**.
- Rendered-pixel inspection could not be completed here: a bounded GUI capture attempt exited 134 without output or an image, and an earlier headless compatibility capture produced no image before it was stopped after 30 seconds. The default headless Vulkan test path also crashed in shader translation. The focused compatibility test exited successfully; the environment printed a macOS CA-certificate lookup warning.
- User/editor checks listed here are complete as of 2026-10-22. The user verified the starter compositions and the remaining Phase 19 visual/interaction scenarios; see the [Phase 19 handoff](../plans/phase_19_handoff.md) for the consolidated record.
### User visual follow-up (2026-10-21)
The user's Town Street capture showed Sky clouds visible below the skyline because the initial implementation omitted Background ground. The user also rejected the oversized scaled TerrainBlocks used for walkable surfaces and requested left-to-right rows of ordinary Ground tiles in both templates. The current revision adds opaque non-walkable Grass Background ground behind Town Street and uses adjacent scale-1 tiles for Sidewalk, Asphalt, and Living Room Wood. Sidewalk uses 25 tiles; Asphalt uses 27, extending one tile beyond each sidewalk end so the road is physically reachable. The focused production-path test passed 515 assertions, including seam routing and a grounded PLAY drop onto Asphalt. The final user/editor verification and 706-check Phase 19 suite result supersede this earlier pending status.
## Trade-offs and deferred work
- Ordinary-scale tile rows avoid oversized Ground selection bounds and keep each walkable piece independently editable. Their joints should remain intentional, with adjacent collision and visible edges meeting without gaps. The navigation bake merges contiguous coplanar tile tops for routing while the Ground pieces remain separate, and connects the Town curb step only at exposed road ends.
- The 4,800-unit sidewalk/floor rows of ordinary-size Ground tiles cover the specified 4,760-unit minimum envelope and remain individually editable. Town Asphalt extends to 5,184 units for curb access. These finite surfaces do not track arbitrary viewport resizing, very wide zoom-out shots, or unbounded camera travel. The camera remains free; no edge clamp or auto-extension obscures that finite set boundary.
- Preview framing is derived from the saved opening camera instead of saved preview metadata. This avoids schema/round-trip work but gives every card the same generic crop policy; if that cannot show a particular starter clearly, document the concrete limitation before proposing metadata.
- No new art is required based on current library bounds. A curb/baseboard visual gap that cannot be composed from existing terrain finishes and wall panels should be described as one specific missing asset and sent through the existing asset-approval workflow.
- Stage-wide Undo/Redo remains deferred. Starter polishing edits saved object data through existing flows and adds no new transaction system.
@@ -0,0 +1,59 @@
# Phase 19 — Starter Scene Composition UI/UX Specification
> **Status (2026-10-22):** Implemented and user/editor verified as part of Phase 19; the scenery suite passes **706 checks with 0 failures**. This specification records scene presentation and reuses the existing controls.
>
> **Source:** [Phase 19 — Starter Scene Composition brief](../plans/phase_19_starter_scene_composition.md).
## User path
From **File → New**, the child chooses **Town Street** or **Living Room** and immediately sees a coherent set with a clear walkable area. At the saved camera frame, a standing stickman and a short speech bubble fit comfortably in the action area. The same visual relationships remain legible in EDIT, DIRECT, PLAY, and after save/reopen. A deliberate horizontal pan reveals more of the same set.
## Composition rules
### Town Street
- Keep the Cloudy Day Sky and a subdued Far skyline behind individual Near buildings and trees.
- Add an opaque, non-walkable Background ground layer below the skyline datum. It must cover the lower opening view behind the street and prevent Sky clouds from showing through the gap between the skyline and the walkable surface.
- Align all visible house, storefront, and tree bases to one sidewalk datum. Keep seams at or below 3 logical screen pixels at saved zoom.
- Carry the Sidewalk and Asphalt across the usable stage frame as horizontal rows of ordinary-size, walkable Ground tiles. Keep their tops level and their edges adjacent so the tile joints are intentional, with no collision or visual gaps.
- Leave at least the middle third of the initial frame visually calm at walking height. Near artwork may frame the action, but should not cover a character or speech placed in this area.
- Keep Background ground behind the skyline, artwork, and terrain; distinguish it from the walkable sidewalk/road. It does not create another walking line.
### Living Room
- Continue the painted wall and a horizontal row of ordinary-size, walkable Wood Ground tiles across the initial frame and agreed pan envelope. Keep adjacent tile edges level, with intentional joints and no collision gaps. A visible baseboard or equivalent edge marks where wall meets floor.
- Construct the window as a deliberate opening between editable wall pieces. The selected Sky is visible through that opening only; no Sky gaps appear at the room edges, below the floor, or along panel seams.
- Place the couch, armchair, and coffee table so their visible supports meet the floor within 3 logical screen pixels at saved zoom. Keep the rug centered, above the floor finish, below actors, and outside the main movement lane.
- Keep the middle third of the frame at walking height visually calm enough for a stickman and short speech bubble. Wall decoration stays secondary to the window and action.
## Camera framing and pan continuity
- Preserve the existing starter-choice flow and use a saved camera framing that shows the complete scene identity, walking datum, and clear action area at the actual safe Stage viewport size.
- Place the walking line around 70–80% of usable viewport height measured from the top. Show a default standing stickman from feet to head and leave about 24 logical pixels between a two-line speech bubble and visible Stage chrome.
- Define a horizontal pan envelope for each starter. At the saved zoom, the appropriate continuous surfaces must cover the full rendered viewport width at the envelope's left and right limits, with a small safety margin. Camera panning remains unrestricted beyond this authored envelope; finite set edges may appear there.
- Compute surface width from the reference viewport: `rendered_viewport_width / saved_zoom + 2 * pan_travel + 2 * safety_margin`. For a 1,280-pixel reference width, zoom 0.5, ±1,000-world-unit travel, and 100 world units of margin per side, surfaces need at least 4,760 world units. Use 25 standard 192-unit Ground tiles for the Town sidewalk and Living Room floor. Town Asphalt uses 27 standard tiles, extending one tile beyond each sidewalk end so the road is physically reachable. Do not scale one walkable block across the scene.
- Keep set parts independently editable. Tile rows use ordinary-scale pieces with adjacent edges, no collision gaps, and visible joints that read as regular surface tiles.
- Do not stretch one walkable Ground block across the full pan envelope. Use a left-to-right line of default-size terrain tiles so each surface remains an ordinary, individually editable piece.
## Starter chooser preview
The starter card should show a representative opening composition, not fit the full pan-only extent. The current preview fits the combined bounds of every object; extending the ground and walls could make the important scene too small. Bound preview framing around the saved opening shot (or an explicit authored preview frame) while still showing the Sky, walking datum, and recognizable key artwork. This is a preview-only framing decision: it must not crop or constrain the loaded scene.
## Controls, copy, and theme
- Add no controls, filters, dialogs, inspectors, cursor modes, placement overlays, or status messages.
- Keep the existing **Blank Scene**, **Town Street**, and **Living Room** choice labels.
- Add no `SandboxTheme` colors or `stage_panels` tokens. Use the existing Sky, wall, Ground, Scenery, and Prop visual styles; tune scene-art contrast so a character and speech remain easy to read.
- Reuse the existing EDIT, DIRECT, and PLAY mode treatments without adding mode-specific starter behavior.
## Observable UI/UX acceptance outcomes
1. Opening either starter at its saved frame reveals the intended place, a single readable walking datum, a calm central action area, and room for a default stickman plus short speech.
2. At saved zoom, there is no visible (>3 logical screen-pixel) gap between Near artwork and the street datum or between furniture supports and the room floor.
3. At the center and both ends of the specified pan envelope, the sidewalk/road or wall/baseboard/floor fills the usable Stage width without exposed ends, distracting seams, or stray Sky. In Living Room, Sky appears through the window opening only.
4. The chooser card remains a recognizable view of the opening composition after the world surfaces are extended for panning.
5. The same composition and visual ordering remain in EDIT, DIRECT, PLAY, and after save/reopen. Normal child edits can still move or remove individual pieces.
## Not applicable
There is no new interaction flow, so UI node/layout additions, focus behavior, cursor feedback, child-facing copy changes, dialogs, and inspector-token changes do not apply. Architecture will identify only the scene data, preview framing, or rendering changes needed to meet the observable outcomes.
+78
View File
@@ -0,0 +1,78 @@
# Repeating scenery material cache
Status: accepted architecture for the 2026-10-20 scenery cache follow-up.
## Problem and result
`content/stages/fps.scene` exposed two related costs. The old vector tile cache could accumulate enough native drawing resources to report `Element limit reached`. Its replacement bounds those resources, but panning after zoom still reveals new world-position tiles and continuously starts more bitmap captures. Brick, wood, grass, carpet, and concrete are repeated finishes. Bake **one finite, seamless material texture per finish and tint** and repeat it across every visible rectangle. Once that style is ready, camera pan and zoom update only the draw quad and sampling coordinates; they do not build or read back more imagery.
Keep the scene-scoped `StageSurfaceCache.BakePool` under `SceneTree.current_scene` as a sibling of `World`. `StageRectSurface` owns a lightweight `StageSurfaceCache` handle for its style. A single reusable `SubViewport` baker is enough. Paint and interactive placement keep their existing direct drawing paths. No `.scene` schema or child-facing control changes are needed.
```text
current_scene
├── World
│ ├── StageRectSurface ──┐
│ └── erased fragment ──┼── handles for the same finish + tint
└── StageSurfaceBakePool ─┴── one repeating ImageTexture
└── SurfaceBitmapBaker (temporary vector commands)
```
## Material periods and appearance
Bake every period at **2 bitmap pixels per world unit**. The material repeats in both directions. These dimensions are multiples of the current renderer's natural cells:
| Finish | Repeated cells | World period | Bitmap size at 2× |
| --- | ---: | ---: | ---: |
| Brick wall | 8 columns × 8 rows | 800 × 304 | 1600 × 608 |
| Wood wall | 3 columns × 6 rows | 960 × 384 | 1920 × 768 |
| Grass background | 16 × 16 | 704 × 576 | 1408 × 1152 |
| Carpet background | 16 × 16 | 544 × 416 | 1088 × 832 |
| Concrete background | 4 × 4 slabs | 1120 × 760 | 2240 × 1520 |
The 8-row brick and 6-row wood periods preserve stagger parity; the wood period also preserves its three-tone cycle. Keep brick size, plank size, color relationships, rough outlines, grain, pores, and other material features at their current world scale. Remove view-size-driven changes to brick or plank dimensions from the material bake: zoom must sample the same pattern. The resulting bitmap keeps full artwork at 40% zoom through GPU minification. At the Stage's maximum 6× zoom, the 2× bitmap is magnified threefold and may appear softer; this is an accepted quality trade-off.
The renderer uses absolute pattern cell coordinates for geometry and `posmod(cell_index, period_cell_count)` for **every decorative seed and tone choice**, including negative world coordinates. Wrap row and column before computing brick jitter, palette, marks, pores, wood tone, grain, knots, grass tufts, carpet fibers, and concrete speckles. Features crossing a period edge must be drawn from the matching wrapped neighbor, then clipped to the exact period bitmap. Check the first and last rows and columns together; a matching cell seed alone does not repair geometry or filtering at the seam. A finite period makes the former never-ending random variation visibly repeat; the chosen periods balance variety with bounded bitmap size.
## Rendering and erase coordinates
Generate mipmaps on the captured `Image` before creating its `ImageTexture`. On each patterned `StageRectSurface`, enable `CanvasItem.TEXTURE_REPEAT_ENABLED`. Draw the intersection of surface bounds and camera view as a single clipped quad with `draw_texture_rect_region(..., clip_uv = false)`; its source rectangle can span many repetitions. Compute the source position from the same renderer sample coordinate used today: **surface-local position plus `get_surface_draw_origin_world()`**, reduced with `fposmod` by the period before converting to bitmap pixels. Reducing the offset avoids large UV coordinates during long camera pans. Source width and height come from the visible local rectangle at two bitmap pixels per world unit. Godot's repeat sampler and mipmaps handle zoom without a cached zoom level.
The eraser continues splitting one rectangular surface into up to four fragments. Each fragment clips its own quad to its bounds and the camera. It requests the same style texture; the bitmap key is independent of fragment position, size, camera position, and zoom. Preserve the existing sampling convention: wall draw origin is `global_position`; background ground draw origin is `global_position + motif_origin_offset`. Moving, rotating, saving, reloading, erasing, Cancel, and Commit must keep each material's pattern aligned under those existing rules. A fragment's differing sample offset changes only its source UV, never the style bitmap.
Only request a style when some of that surface is camera-visible and not proven fully covered by later opaque scenery. Keep conservative coverage checks; partial union of multiple cover surfaces is optional. A surface with no texture yet draws its base color while its one style capture is queued. An already ready style remains warm through camera movement, including when the surface temporarily leaves the viewport.
## Cache and baker contracts
`StageSurfaceCache` remains a `RefCounted` handle with `signal updated`, `attach_to(owner_node: Node)`, `get_texture() -> Texture2D`, `dispose()`, and `static get_stats(owner_node: Node = null) -> Dictionary`. Its build request changes from a positional tile to a style request. The payload is finish (`StringName`), tint (`Color`), wall/background kind (`bool`), renderer/material revision, and the fixed period and density derived from finish. `StageRectSurface` no longer retains a dictionary of camera-position tiles. One handle may be replaced when tint or finish changes; other surfaces with the same key keep their texture.
`StageSurfaceCache.BakePool` has one entry per requested style. Entry identity includes all values that alter baked pixels, especially finish, tint, wall/background kind, and renderer revision. It excludes camera transform, surface bounds, eraser fragment, and sample phase. A queued entry with no live visible interested owner can be canceled. The pool serializes captures through one `SubViewport`, waits until the GPU has consumed its vector commands, reads the completed image, generates mipmaps, publishes an immutable `ImageTexture`, and clears baker vector commands. A failed capture leaves the base color visible and permits retry; it must not publish an empty ready texture.
Keep a **64 MiB global resident texture budget per scene**, including all mip levels, retired textures awaiting a safe frame, and reserved bytes for a build. One copy each of the five listed periods is about 32 MiB before mipmaps and 43 MiB with mipmaps at RGBA8; additional tint variants can require eviction. Evict styles without visible interested owners before styles in active use, and release canvas references safely before freeing a texture. Keep recently used styles warm so panning away and back does not trigger a new capture under normal budget. If active styles collectively exceed the budget, retain the hard cap and show base color for a style that cannot be admitted; expose this through stats rather than silently exceeding the budget. Revisit period size or density if this occurs in ordinary scenes.
`get_stats()` must expose at least resident texture bytes, entries, queued styles, active baker count, builds, readbacks, cache hits, and budget refusals. These counters make the reuse behavior observable without adding UI.
## Data flow
```mermaid
flowchart LR
Visible[Visible patterned surface] -->|finish + tint| Cache[Scene style cache]
Cache -->|ready texture| UV[Sample repeated texture at existing pattern offset]
Cache -->|first style request| Baker[One temporary bitmap baker]
Baker -->|capture + mipmaps| Cache
UV --> Clip[Clip quad to surface and camera]
Clip --> Stage[Stage canvas]
Camera[Pan or zoom] --> UV
```
## Observable acceptance and verification
1. Open `content/stages/fps.scene` in EDIT, DIRECT, and PLAY. Allow visible brick, wood, and concrete styles to become ready. Pan left and right repeatedly and zoom in and out through the same region. `builds` and `readbacks` stay unchanged after those styles are ready; only a new finish or tint may add a style capture. Frame movement does not pause for repeated tile buffering, and `Element limit reached` does not recur.
2. Inspect the left/right and top/bottom period boundaries for every finish at distant, 40%, normal, and close zoom. There are no lines, shifted brick courses, broken planks, or mismatched decorative marks. Zoom changes sampling detail without changing motif geometry.
3. Erase through a brick wall and background ground, Cancel, then erase and Commit. Inspect both sides of each fragment edge, then save and reload. The cut stays clear, surrounding patterns stay aligned, and the cache still has one entry per shared style.
4. Change a wall's tint and finish. The changed style may bake once; unchanged surfaces keep their ready texture. Return to the earlier style and pan again to check that recently used styles stay warm within the 64 MiB budget. Observe total bytes, pending styles, and baker lifetime during a long Stage session.
Use a render-capable run of the actual Stage for appearance and frame-time observations. This architecture update does not require a new test file.
## Trade-offs
A periodic material replaces unique random detail across an arbitrarily large wall with a bounded, repeating pattern. The chosen periods and seeded variation should make repetition unobtrusive at ordinary framing, but repeated landmarks can become noticeable on very large surfaces. GPU mipmaps replace four separately baked zoom levels and keep pan/zoom free of new builds. A one-time GPU readback remains when a finish/tint style first appears. Multiple color variants can evict warm styles under the 64 MiB cap, so those cases need measurement on representative scenes.
+43
View File
@@ -0,0 +1,43 @@
{
"version": "1.0",
"chrome": {
"colors": {
"background": "#171B24F2",
"surface": "#252B38FA",
"surface_hover": "#30394A",
"text": "#F5F7FA",
"text_muted": "#AAB2C0",
"edit": "#22C6FF",
"preview": "#22C6FF66",
"focus": "#8BD9FF",
"action_primary": "#087DBA",
"destructive": "#E66B75",
"border": "#FFFFFF33",
"disabled": "#77808F",
"saved": "#8FD6A4",
"dirty": "#FFD166",
"error": "#FF8A8A"
},
"fonts": {
"body": 18,
"helper": 16,
"section": 22,
"title": 26,
"menu": 20,
"status": 16,
"mode": 24,
"prop_name": 20,
"context_menu": 20
},
"spacing": { "small": 8, "medium": 12, "large": 16 },
"radii": { "control": 8, "panel": 10 },
"borders": { "normal": 1, "focus": 2 },
"targets": { "button_min_height": 44, "row_min_height": 44, "mode_button_min_height": 56 }
},
"editors": { "prop": {}, "stickman": {} },
"stickman_canvas": {
"grid_color": "#FFFFFF26",
"selection_core": "#FFFFFF",
"selection_halo": "#000000D9"
}
}
+1
View File
@@ -23,3 +23,4 @@ config/icon="res://icon.svg"
window/size/initial_position_type=0
window/size/initial_position=Vector2i(4000, 200)
window/size/initial_screen=1
-24
View File
@@ -20,29 +20,5 @@
"grid_size": 15,
"background_color": "#1F1F1F",
"grid_color": "#FFFFFF26"
},
"ui": {
"fonts": {
"body": 18,
"header": 24,
"menu": 22,
"menu_bar": 22,
"mode": 26,
"status": 18,
"prop_name": 20,
"context_menu": 22
},
"hit_targets": {
"button_min_height": 36,
"row_min_height": 36,
"mode_button_min_height": 56
},
"colors": {
"text": "#F2F2F2",
"text_muted": "#9A9A9A",
"border": "#FFFFFF33",
"accent": "#4FA3FF",
"inspector_background": "#262C39"
}
}
}
+134 -6
View File
@@ -77,7 +77,13 @@
"sideways_offset": 4.0
}
},
"grid": { "snap_size": 15.0 },
"grid": {
"snap_size": 15.0,
"plain_light_minor": "#11182120",
"plain_light_major": "#11182148",
"plain_dark_minor": "#FFFFFF25",
"plain_dark_major": "#FFFFFF58"
},
"chrome": {
"style_defaults": { "bg_color": "#171B24F2", "corner_radius": 10 },
"top_bar": { "height": 64 },
@@ -95,9 +101,9 @@
"queue_size": [680, 520],
"rule_size": [720, 560],
"action_size": [560, 390],
"confirm_size": [620, 300],
"confirm_size": [560, 300],
"font_size": 22,
"message_font_size": 26
"message_font_size": 20
}
},
"popups": {
@@ -108,11 +114,108 @@
"padding": 12
},
"action_menu": { "font_size": 24 },
"dialog": {},
"dialog": {
"surface_color_size": [560, 640],
"surface_color_preview_size": [420, 140],
"surface_color_picker_min_size": [320, 340],
"action_min_target_size": 48
},
"confirmation": {
"scenery_erase_size": [520, 208],
"padding": 22,
"body_gap": 12,
"footer_gap": 12,
"corner_radius": 18,
"border_width": 2,
"title_font_size": 28,
"message_font_size": 20,
"button_font_size": 20,
"button_height": 52,
"button_min_width": 112,
"card_width": 560,
"button_radius": 12,
"button_border_width": 2,
"remove_button_width": 170,
"keep_button_width": 160,
"surface_color": "#202633FA",
"border_color": "#667386",
"secondary_surface": "#293242",
"secondary_border": "#718096",
"focus_color": "#8BD9FF",
"backdrop_color": "#080D15B8",
"accent_color": "#8BD9FF",
"action_color": "#087DBA",
"danger_color": "#E66B75",
"danger_surface": "#44262D",
"danger_hover_surface": "#3B252C",
"danger_pressed_surface": "#4A252D"
},
"ground_finish_chooser": {
"card_width": 880,
"max_height_ratio": 0.86,
"screen_margin": 24,
"choice_height": 96,
"choice_preview_height": 48,
"choice_font_size": 18,
"choice_min_width": 104,
"choice_corner_radius": 12,
"compact_choice_height": 50,
"compact_choice_preview_height": 22,
"compact_choice_font_size": 16,
"compact_grid_gap": 6,
"compact_layout_max_width": 600,
"compact_layout_max_height": 460,
"compact_height_ratio": 0.89,
"preview_height": 128,
"compact_preview_height": 88,
"preview_min_width": 224,
"preview_corner_radius": 12,
"preview_surface": "#151C28",
"preview_border": "#39465B",
"grid_gap": 8,
"wide_layout_min_width": 760
},
"asset_selector": {
"backdrop_alpha": 0.55,
"cell_size": [190, 220],
"thumbnail_size": [156, 156]
"thumbnail_size": [156, 156],
"surface_preview_size": [420, 132],
"look_cue_size": [80, 42],
"setup_gap": 8,
"setup_title_size": 30,
"setup_heading_size": 26,
"setup_choice_font_size": 22,
"setup_choice_height": 68,
"setup_choice_corner_radius": 22,
"setup_choice_border_width": 2,
"tab_font_size": 22,
"tab_height": 60,
"filter_chip_font_size": 20,
"filter_chip_height": 48,
"filter_chip_gap": 8,
"setup_button_font_size": 20,
"button_border_width": 2,
"footer_gap": 16,
"surface_corner_radius": 14,
"look_corner_radius": 8,
"corner_radius": 16,
"compact_swatch_size": 44,
"color_group_gap": 8,
"starter_dialog_width": 1160,
"starter_viewport_height_ratio": 0.96,
"starter_card_min_width": 340,
"starter_preview_aspect": 2.0,
"starter_preview_corner_radius": 12,
"starter_preview_horizontal_inset": 10,
"starter_preview_top_inset": 6,
"starter_card_text_height": 108,
"starter_title_size": 30,
"starter_label_size": 22,
"starter_label_height": 34,
"starter_description_size": 20,
"starter_description_height": 48,
"action_color": "#087DBA",
"separator_color": "#AAB2C0A0"
}
},
"palette": {
@@ -177,7 +280,13 @@
"close_width": 88,
"surface_color": "#171B24",
"hover_surface": "#303A4B",
"selected_surface": "#12394A"
"selected_surface": "#12394A",
"primary_color": "#087DBA",
"primary_hover_color": "#1596D3",
"primary_pressed_color": "#066B9F",
"destructive_color": "#FF626B",
"destructive_hover_surface": "#3B252C",
"destructive_pressed_surface": "#4A252D"
},
"prop": {
"stickman_cue_color": "#F5F7FA",
@@ -196,6 +305,18 @@
"completion_duration": 0.6
}
},
"stage_surface_colors": {
"section_gap": 8,
"swatch_gap": 8,
"swatch_size": 48,
"swatch_border": "#FFFFFFB8",
"swatch_focus_border": "#FFFFFF",
"swatch_corner_radius": 8,
"swatch_border_width": 2,
"swatch_check_size": 24,
"selected_border_width": 4,
"focus_border_width": 3
},
"overlays": {
"style_defaults": { "line_width": 2, "dash_length": 8, "dash_gap": 5 },
"terrain_guide": { "color": "#22C6FF" },
@@ -217,6 +338,13 @@
"uncertain_color": "#FFB300",
"neutral_color": "#C5CAD4",
"completion_duration": 0.6
},
"gizmos": {
"backing_color": "#111821E8",
"selection_color": "#F5F7FAE6",
"hover_color": "#FFE66DCC",
"rotate_color": "#4DB9FFE6",
"backing_extra_width": 3.0
}
},
"widgets": {
+46 -66
View File
@@ -1,20 +1,8 @@
[gd_scene load_steps=4 format=3]
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scripts/editors/prop/prop_editor.gd" id="1_editor_script"]
[ext_resource type="Script" path="res://scripts/editors/prop/prop_canvas.gd" id="2_canvas_script"]
[sub_resource type="StyleBoxFlat" id="StyleBox_canvas"]
bg_color = Color(0.121569, 0.121569, 0.121569, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3, 0.3, 0.3, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[node name="PropEditor" type="Control"]
layout_mode = 3
anchors_preset = 15
@@ -24,91 +12,89 @@ grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_editor_script")
[node name="TopRow" type="HBoxContainer" parent="."]
[node name="ChromeRoot" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 0
anchors_preset = 15
anchor_right = 1.0
offset_bottom = 44.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 6
[node name="MenuBar" type="MenuBar" parent="TopRow"]
[node name="TopRow" type="FlowContainer" parent="ChromeRoot"]
layout_mode = 2
custom_minimum_size = Vector2(0, 44)
theme_override_constants/h_separation = 8
theme_override_constants/v_separation = 4
[node name="MenuBar" type="MenuBar" parent="ChromeRoot/TopRow"]
unique_name_in_owner = true
layout_mode = 2
[node name="TopRightControls" type="FlowContainer" parent="ChromeRoot/TopRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/h_separation = 8
theme_override_constants/v_separation = 4
[node name="TopRightControls" type="HBoxContainer" parent="TopRow"]
unique_name_in_owner = true
[node name="TopBar" type="HBoxContainer" parent="ChromeRoot"]
layout_mode = 2
theme_override_constants/separation = 8
custom_minimum_size = Vector2(0, 44)
[node name="TopBar" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 0
anchor_right = 1.0
offset_top = 44.0
offset_bottom = 88.0
grow_horizontal = 2
[node name="NameLabel" type="Label" parent="TopBar"]
[node name="NameLabel" type="Label" parent="ChromeRoot/TopBar"]
layout_mode = 2
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
text = "Prop Name:"
vertical_alignment = 1
[node name="PropNameEdit" type="LineEdit" parent="TopBar"]
[node name="PropNameEdit" type="LineEdit" parent="ChromeRoot/TopBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Unnamed Prop"
[node name="MainLayout" type="HSplitContainer" parent="."]
[node name="MainLayout" type="HSplitContainer" parent="ChromeRoot"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = 92.0
offset_bottom = -28.0
grow_horizontal = 2
grow_vertical = 2
layout_mode = 2
size_flags_vertical = 3
theme_override_constants/separation = 6
[node name="CanvasPanel" type="PanelContainer" parent="MainLayout"]
[node name="CanvasPanel" type="PanelContainer" parent="ChromeRoot/MainLayout"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
size_flags_stretch_ratio = 2.0
custom_minimum_size = Vector2(260, 0)
theme_override_styles/panel = SubResource("StyleBox_canvas")
[node name="CanvasArea" type="VBoxContainer" parent="MainLayout/CanvasPanel"]
[node name="CanvasArea" type="VBoxContainer" parent="ChromeRoot/MainLayout/CanvasPanel"]
layout_mode = 2
theme_override_constants/separation = 4
[node name="ModeBar" type="HBoxContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
[node name="ModeBar" type="FlowContainer" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/h_separation = 8
theme_override_constants/v_separation = 4
[node name="SizeTools" type="FlowContainer" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
[node name="SizeTools" type="FlowContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
[node name="AttachTools" type="FlowContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
[node name="AttachTools" type="FlowContainer" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 12
[node name="CanvasWorkspace" type="HSplitContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
[node name="CanvasWorkspace" type="HSplitContainer" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
split_offset = 520
[node name="PropCanvas" type="Control" parent="MainLayout/CanvasPanel/CanvasArea/CanvasWorkspace"]
[node name="PropCanvas" type="Control" parent="ChromeRoot/MainLayout/CanvasPanel/CanvasArea/CanvasWorkspace"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
@@ -117,18 +103,18 @@ mouse_filter = 0
clip_contents = true
script = ExtResource("2_canvas_script")
[node name="InspectorColumn" type="PanelContainer" parent="MainLayout"]
[node name="InspectorColumn" type="PanelContainer" parent="ChromeRoot/MainLayout"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
custom_minimum_size = Vector2(300, 0)
size_flags_horizontal = 3
[node name="InspectorScroll" type="ScrollContainer" parent="MainLayout/InspectorColumn"]
[node name="InspectorScroll" type="ScrollContainer" parent="ChromeRoot/MainLayout/InspectorColumn"]
layout_mode = 2
size_flags_vertical = 3
[node name="InspectorList" type="VBoxContainer" parent="MainLayout/InspectorColumn/InspectorScroll"]
[node name="InspectorList" type="VBoxContainer" parent="ChromeRoot/MainLayout/InspectorColumn/InspectorScroll"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
@@ -154,18 +140,12 @@ filters = PackedStringArray("*.prop ; Prop Files")
unique_name_in_owner = true
title = "Error"
[node name="StatusBar" type="HBoxContainer" parent="."]
[node name="StatusBar" type="HBoxContainer" parent="ChromeRoot"]
unique_name_in_owner = true
layout_mode = 1
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -28.0
grow_horizontal = 2
grow_vertical = 0
layout_mode = 2
custom_minimum_size = Vector2(0, 28)
[node name="SaveStatus" type="Label" parent="StatusBar"]
[node name="SaveStatus" type="Label" parent="ChromeRoot/StatusBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
@@ -173,7 +153,7 @@ text = ""
horizontal_alignment = 0
vertical_alignment = 1
[node name="CursorCoords" type="Label" parent="StatusBar"]
[node name="CursorCoords" type="Label" parent="ChromeRoot/StatusBar"]
unique_name_in_owner = true
layout_mode = 2
text = ""
+3 -17
View File
@@ -1,19 +1,7 @@
[gd_scene load_steps=4 format=3]
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://scripts/editors/stickman/body_part_panel.gd" id="1_panel_script"]
[sub_resource type="StyleBoxFlat" id="StyleBox_panel"]
bg_color = Color(0.15, 0.15, 0.15, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3, 0.3, 0.3, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBox_drawing"]
bg_color = Color(0.12, 0.12, 0.12, 1)
border_width_left = 1
@@ -27,7 +15,6 @@ custom_minimum_size = Vector2(230, 160)
offset_right = 230.0
offset_bottom = 160.0
size_flags_vertical = 3
theme_override_styles/panel = SubResource("StyleBox_panel")
script = ExtResource("1_panel_script")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
@@ -42,8 +29,6 @@ unique_name_in_owner = true
layout_mode = 2
text = "Body Part"
horizontal_alignment = 1
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
theme_override_font_sizes/font_size = 13
[node name="DrawingArea" type="Control" parent="VBoxContainer"]
unique_name_in_owner = true
@@ -70,6 +55,7 @@ layout_mode = 2
custom_minimum_size = Vector2(300, 300)
[node name="ButtonRow" type="HBoxContainer" parent="ColorPickerPopup/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
alignment = 2
@@ -81,4 +67,4 @@ text = "Cancel"
[node name="OKButton" type="Button" parent="ColorPickerPopup/VBoxContainer/ButtonRow"]
unique_name_in_owner = true
layout_mode = 2
text = "OK"
text = "Use Color"
+167 -89
View File
@@ -1,20 +1,8 @@
[gd_scene load_steps=4 format=3 uid="uid://6wqo0sp4eij6"]
[gd_scene load_steps=3 format=3 uid="uid://6wqo0sp4eij6"]
[ext_resource type="Script" uid="uid://dlacmxk257ro" path="res://scripts/editors/stickman/stickman_editor.gd" id="1_editor_script"]
[ext_resource type="Script" uid="uid://codv3g6qw1tef" path="res://scripts/editors/stickman/whole_stickman_preview.gd" id="2_preview_script"]
[sub_resource type="StyleBoxFlat" id="StyleBox_preview"]
bg_color = Color(0.12, 0.12, 0.12, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.3, 0.3, 0.3, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[node name="StickmanEditor" type="Control"]
layout_mode = 3
anchors_preset = 15
@@ -24,85 +12,195 @@ grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_editor_script")
[node name="MenuBar" type="MenuBar" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 0
anchor_right = 1.0
offset_bottom = 32.0
grow_horizontal = 2
[node name="TopBar" type="HBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 0
anchor_right = 1.0
offset_top = 32.0
offset_bottom = 64.0
grow_horizontal = 2
[node name="NameLabel" type="Label" parent="TopBar"]
layout_mode = 2
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
text = "Stickman Name:"
vertical_alignment = 1
[node name="StickmanNameEdit" type="LineEdit" parent="TopBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Unnamed Stickman"
[node name="MainLayout" type="HBoxContainer" parent="."]
[node name="ChromeRoot" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = 68.0
offset_bottom = -28.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 6
[node name="LeftColumn" type="VBoxContainer" parent="MainLayout"]
[node name="MenuBar" type="MenuBar" parent="ChromeRoot"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(0, 44)
[node name="TopBar" type="HBoxContainer" parent="ChromeRoot"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="NameLabel" type="Label" parent="ChromeRoot/TopBar"]
layout_mode = 2
text = "Stickman Name:"
vertical_alignment = 1
[node name="StickmanNameEdit" type="LineEdit" parent="ChromeRoot/TopBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
custom_minimum_size = Vector2(180, 44)
placeholder_text = "Unnamed Stickman"
[node name="WorkspaceToolbar" type="FlowContainer" parent="ChromeRoot"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/h_separation = 8
theme_override_constants/v_separation = 4
[node name="OverviewButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Overview"
toggle_mode = true
[node name="FocusButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Focus on Part"
toggle_mode = true
[node name="PartPicker" type="OptionButton" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
[node name="AddShapeButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Add Shape"
[node name="ColorButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Color"
disabled = true
[node name="WholeStickmanButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Whole Stickman"
visible = false
[node name="BackToPartButton" type="Button" parent="ChromeRoot/WorkspaceToolbar"]
unique_name_in_owner = true
layout_mode = 2
text = "Back to Part"
visible = false
[node name="WorkspaceRow" type="HSplitContainer" parent="ChromeRoot"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 8
[node name="BodyPartWorkspace" type="Control" parent="ChromeRoot/WorkspaceRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
custom_minimum_size = Vector2(350, 260)
[node name="OverviewScroll" type="ScrollContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
horizontal_scroll_mode = 1
vertical_scroll_mode = 1
[node name="OverviewColumns" type="HBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
[node name="LeftColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll/OverviewColumns"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 6
[node name="CenterColumn" type="VBoxContainer" parent="MainLayout"]
[node name="CenterColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/OverviewScroll/OverviewColumns"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/separation = 6
[node name="WholeStickmanPreview" type="PanelContainer" parent="MainLayout"]
[node name="FocusHost" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
visible = false
theme_override_constants/separation = 8
[node name="FocusPartLabel" type="Label" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/FocusHost"]
unique_name_in_owner = true
layout_mode = 2
text = "Head"
horizontal_alignment = 1
[node name="FocusPanelHost" type="Control" parent="ChromeRoot/WorkspaceRow/BodyPartWorkspace/FocusHost"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
clip_contents = true
[node name="WholeStickmanPreview" type="PanelContainer" parent="ChromeRoot/WorkspaceRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
size_flags_stretch_ratio = 2.0
theme_override_styles/panel = SubResource("StyleBox_preview")
custom_minimum_size = Vector2(320, 260)
script = ExtResource("2_preview_script")
[node name="VBoxContainer" type="VBoxContainer" parent="MainLayout/WholeStickmanPreview"]
[node name="PreviewColumn" type="VBoxContainer" parent="ChromeRoot/WorkspaceRow/WholeStickmanPreview"]
layout_mode = 2
[node name="PreviewTitle" type="Label" parent="MainLayout/WholeStickmanPreview/VBoxContainer"]
[node name="PreviewTitle" type="Label" parent="ChromeRoot/WorkspaceRow/WholeStickmanPreview/PreviewColumn"]
layout_mode = 2
theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
theme_override_font_sizes/font_size = 14
text = "Whole Stickman"
horizontal_alignment = 1
[node name="PreviewArea" type="Control" parent="MainLayout/WholeStickmanPreview/VBoxContainer"]
[node name="PreviewArea" type="Control" parent="ChromeRoot/WorkspaceRow/WholeStickmanPreview/PreviewColumn"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 1
clip_contents = true
[node name="StatusBar" type="HBoxContainer" parent="ChromeRoot"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(0, 32)
theme_override_constants/separation = 12
[node name="CursorCoords" type="Label" parent="ChromeRoot/StatusBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = ""
horizontal_alignment = 0
vertical_alignment = 1
[node name="SaveStatus" type="Label" parent="ChromeRoot/StatusBar"]
unique_name_in_owner = true
layout_mode = 2
text = "New stickman"
horizontal_alignment = 1
vertical_alignment = 1
[node name="SnapStatus" type="Label" parent="ChromeRoot/StatusBar"]
unique_name_in_owner = true
layout_mode = 2
text = "SNAP: OFF"
horizontal_alignment = 2
vertical_alignment = 1
[node name="SaveDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
title = "Save Stickman"
@@ -112,7 +210,7 @@ filters = PackedStringArray("*.stk ; Stickman Files")
[node name="LoadDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
title = "Open a File"
title = "Open a Stickman"
size = Vector2i(600, 450)
ok_button_text = "Open"
file_mode = 0
@@ -121,19 +219,24 @@ filters = PackedStringArray("*.stk ; Stickman Files")
[node name="ClearConfirmDialog" type="ConfirmationDialog" parent="."]
unique_name_in_owner = true
title = "Clear Stickman"
ok_button_text = "Clear"
dialog_text = "Are you sure you want to clear the current stickman?
This action cannot be undone."
title = "Start Over"
ok_button_text = "Start Over"
dialog_text = "Start over with a blank stickman?"
[node name="UnsavedChangesDialog" type="ConfirmationDialog" parent="."]
unique_name_in_owner = true
title = "Unsaved Changes"
ok_button_text = "Save"
dialog_text = "Save changes before continuing?"
[node name="ErrorDialog" type="AcceptDialog" parent="."]
unique_name_in_owner = true
title = "Error"
title = "Could Not Complete"
[node name="GridConfigDialog" type="ConfirmationDialog" parent="."]
unique_name_in_owner = true
title = "Configure Grid"
ok_button_text = "OK"
ok_button_text = "Done"
[node name="VBoxContainer" type="VBoxContainer" parent="GridConfigDialog"]
layout_mode = 2
@@ -145,34 +248,9 @@ text = "Grid Size (pixels):"
[node name="GridSizeSpinBox" type="SpinBox" parent="GridConfigDialog/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(0, 44)
min_value = 1.0
max_value = 100.0
value = 15.0
step = 1.0
rounded = true
[node name="StatusBar" type="HBoxContainer" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchor_left = 0.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = -28.0
grow_horizontal = 2
grow_vertical = 0
[node name="CursorCoords" type="Label" parent="StatusBar"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = ""
horizontal_alignment = 0
vertical_alignment = 1
[node name="SnapStatus" type="Label" parent="StatusBar"]
unique_name_in_owner = true
layout_mode = 2
text = "SNAP: OFF"
horizontal_alignment = 2
vertical_alignment = 1
+67 -3
View File
@@ -29,19 +29,65 @@ size_flags_horizontal = 3
theme_override_font_sizes/font_size = 18
text = "Choose"
[node name="CloseButton" type="Button" parent="MarginContainer/VBoxContainer/TitleBar"]
[node name="GalleryTabs" type="HFlowContainer" parent="MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "×"
visible = false
h_separation = 8
v_separation = 8
[node name="GridContainer" type="GridContainer" parent="MarginContainer/VBoxContainer"]
[node name="BackgroundsTab" type="Button" parent="MarginContainer/VBoxContainer/GalleryTabs"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = "☀️ Backgrounds"
[node name="ArtworkTab" type="Button" parent="MarginContainer/VBoxContainer/GalleryTabs"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = "🎨 Artwork"
[node name="EditSceneryButton" type="Button" parent="MarginContainer/VBoxContainer/GalleryTabs"]
unique_name_in_owner = true
layout_mode = 2
text = "✋ Edit placed scenery"
[node name="ArtworkFilters" type="HFlowContainer" parent="MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
visible = false
h_separation = 8
v_separation = 8
[node name="GridScroll" type="ScrollContainer" parent="MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
vertical_scroll_mode = 1
[node name="ContentStack" type="VBoxContainer" parent="MarginContainer/VBoxContainer/GridScroll"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="GridContainer" type="GridContainer" parent="MarginContainer/VBoxContainer/GridScroll/ContentStack"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/h_separation = 8
theme_override_constants/v_separation = 8
columns = 4
[node name="SurfaceCardsRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/GridScroll/ContentStack"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
alignment = 1
theme_override_constants/separation = 8
visible = false
[node name="EmptyLabel" type="Label" parent="MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
@@ -54,6 +100,18 @@ vertical_alignment = 1
[node name="Footer" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
alignment = 1
theme_override_constants/separation = 16
[node name="CloseButton" type="Button" parent="MarginContainer/VBoxContainer/Footer"]
unique_name_in_owner = true
layout_mode = 2
text = "Close"
[node name="FooterSpacer" type="Control" parent="MarginContainer/VBoxContainer/Footer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
visible = false
[node name="PrevButton" type="Button" parent="MarginContainer/VBoxContainer/Footer"]
unique_name_in_owner = true
@@ -79,3 +137,9 @@ text = "Browse…"
unique_name_in_owner = true
layout_mode = 2
text = "Refresh"
[node name="MakeArtworkButton" type="Button" parent="MarginContainer/VBoxContainer/Footer"]
unique_name_in_owner = true
layout_mode = 2
visible = false
text = "✏️ Make artwork…"
+287 -5
View File
@@ -33,6 +33,7 @@ signal anchor_selected(anchor_id: String)
signal whole_prop_resize_requested(factor: float)
signal whole_prop_move_requested(delta: Vector2)
signal whole_prop_rotate_requested(angle_rad: float)
signal stage_size_cancel_requested
# ---------------------------------------------------------------------------
# Enums / constants
@@ -120,6 +121,7 @@ var _pan_offset: Vector2 = Vector2.ZERO
var _grid_size: int = 15
var _snap_enabled: bool = false
var _has_clipboard: bool = false
var _create_point_armed: bool = false
var _context_menu: PopupMenu
var _edit_mode: EditMode = EditMode.TRANSFORM
var _anchor_overlay: RefCounted
@@ -141,6 +143,14 @@ var _whole_prop_delta: Vector2 = Vector2.ZERO
var _whole_prop_total_angle: float = 0.0
var _whole_prop_previous_angle: float = 0.0
var _whole_prop_preview_angle: float = 0.0
var _stage_size_preview_active: bool = false
var _stage_size_start_shapes: Array[Dictionary] = []
var _stage_size_preview_shapes: Array[Dictionary] = []
var _stage_size_start_anchors: Dictionary = {}
var _stage_size_preview_anchors: Dictionary = {}
var _stage_size_start_pan: Vector2 = Vector2.ZERO
var _stage_size_start_zoom: float = 1.0
var _stage_size_factor: float = 1.0
const SCALE_COORDINATE_LIMIT: float = 1000000.0
const WHOLE_PROP_HANDLE_HIT_RADIUS: float = 16.0
@@ -202,7 +212,11 @@ func set_edit_mode(mode: int) -> void:
var m: int = mode if mode >= EditMode.TRANSFORM and mode <= EditMode.ATTACH else EditMode.TRANSFORM
if m == _edit_mode:
return
if _stage_size_preview_active:
cancel_stage_size_preview()
_edit_mode = m
if _edit_mode != EditMode.POINTS:
_create_point_armed = false
if _edit_mode != EditMode.TRANSFORM:
set_whole_prop_resize_enabled(false)
_drag_mode = DragMode.NONE
@@ -214,6 +228,21 @@ func set_edit_mode(mode: int) -> void:
func get_edit_mode() -> int:
return _edit_mode
func arm_create_point() -> void:
set_edit_mode(EditMode.POINTS)
_create_point_armed = true
queue_redraw()
func cancel_create_point() -> void:
_create_point_armed = false
queue_redraw()
func is_create_point_armed() -> bool:
return _create_point_armed
func set_anchor_data(data: Variant) -> void:
if _anchor_overlay == null: _anchor_overlay = PropAnchorOverlayScript.new()
_anchor_overlay.set_anchor_data(data)
@@ -280,6 +309,188 @@ func set_show_stickman_guide(enabled: bool) -> void:
func get_show_stickman_guide() -> bool:
return _show_stickman_guide
func begin_stage_size_preview(anchors: Dictionary) -> bool:
if _stage_size_preview_active or _whole_prop_resize_enabled or _edit_mode != EditMode.TRANSFORM or not has_shape_geometry():
return false
var bounds: Rect2 = get_visible_shapes_bounds()
var extent: float = maxf(bounds.size.x, bounds.size.y)
if not _valid_rect(bounds) or not is_finite(extent) or extent <= 0.0:
return false
_stage_size_start_shapes = _duplicate_shapes(shapes)
_stage_size_preview_shapes = _duplicate_shapes(shapes)
_stage_size_start_anchors = anchors.duplicate(true)
_stage_size_preview_anchors = anchors.duplicate(true)
_stage_size_start_pan = _pan_offset
_stage_size_start_zoom = _zoom
_stage_size_factor = 1.0
_stage_size_preview_active = true
queue_redraw()
return true
func preview_stage_size(factor: float) -> bool:
if not _stage_size_preview_active or not is_finite(factor) or factor <= 0.0:
return false
var candidate: Array[Dictionary] = _scaled_stage_size_shapes(_stage_size_start_shapes, factor)
if candidate.is_empty() or not _shapes_are_representable(candidate):
return false
var anchor_result: Dictionary = PropAnchorDataScript.try_scale_non_origin(_stage_size_start_anchors, factor)
if not bool(anchor_result.get("ok", false)):
return false
var candidate_bounds: Rect2 = _visible_bounds_for_shapes(candidate)
if not _valid_rect(candidate_bounds):
return false
_stage_size_factor = factor
_stage_size_preview_shapes = candidate
_stage_size_preview_anchors = (anchor_result.get("anchors", {}) as Dictionary).duplicate(true)
queue_redraw()
return true
func commit_stage_size_preview() -> Dictionary:
if not _stage_size_preview_active:
return {"changed": false, "anchors": get_anchor_data()}
var changed: bool = not is_equal_approx(_stage_size_factor, 1.0)
var committed_anchors: Dictionary = _stage_size_preview_anchors.duplicate(true)
if changed:
shapes = _duplicate_shapes(_stage_size_preview_shapes)
set_anchor_data(committed_anchors)
_end_stage_size_preview()
if changed:
shape_changed.emit(get_shape_data())
return {"changed": changed, "anchors": committed_anchors}
func cancel_stage_size_preview() -> void:
if _stage_size_preview_active:
_end_stage_size_preview()
func is_stage_size_preview_active() -> bool:
return _stage_size_preview_active
func get_visible_shapes_bounds() -> Rect2:
return _visible_bounds_for_shapes(_stage_size_preview_shapes if _stage_size_preview_active else shapes)
func get_stage_size_comparison_bounds() -> Rect2:
var artwork_bounds: Rect2 = _visible_bounds_for_shapes(_stage_size_preview_shapes if _stage_size_preview_active else shapes)
if not _valid_rect(artwork_bounds):
return Rect2()
return artwork_bounds.merge(_stage_size_guide_bounds(artwork_bounds))
func fit_stage_size_view(bounds: Rect2, padding_px: float, force: bool = true) -> void:
if not _stage_size_preview_active or not _valid_rect(bounds):
return
if not force and _stage_size_view_contains(bounds, padding_px):
return
var available: Vector2 = size - Vector2.ONE * maxf(0.0, padding_px) * 2.0
if available.x <= 0.0 or available.y <= 0.0:
return
var fit_x: float = available.x / maxf(bounds.size.x, 1.0)
var fit_y: float = available.y / maxf(bounds.size.y, 1.0)
_zoom = clampf(minf(fit_x, fit_y), 0.02, MAX_ZOOM)
_pan_offset = size * 0.5 - bounds.get_center() * _zoom
queue_redraw()
func _end_stage_size_preview() -> void:
_stage_size_preview_active = false
_stage_size_start_shapes.clear()
_stage_size_preview_shapes.clear()
_stage_size_start_anchors.clear()
_stage_size_preview_anchors.clear()
_stage_size_factor = 1.0
_pan_offset = _stage_size_start_pan
_zoom = _stage_size_start_zoom
queue_redraw()
func _stage_size_view_contains(bounds: Rect2, padding_px: float) -> bool:
var padding: float = maxf(0.0, padding_px)
var available: Vector2 = size - Vector2.ONE * padding * 2.0
if available.x <= 0.0 or available.y <= 0.0:
return false
var world_origin: Vector2 = -_pan_offset / maxf(_zoom, 0.001)
var world_size: Vector2 = size / maxf(_zoom, 0.001)
var world_padding: float = padding / maxf(_zoom, 0.001)
var visible_bounds: Rect2 = Rect2(world_origin + Vector2.ONE * world_padding, world_size - Vector2.ONE * world_padding * 2.0)
return visible_bounds.encloses(bounds)
func _visible_bounds_for_shapes(source: Array[Dictionary]) -> Rect2:
var result: Rect2 = Rect2()
var found: bool = false
for shape: Dictionary in source:
if not _shape_has_geometry(shape):
continue
var bounds: Rect2 = _shape_bbox(shape).grow(maxf(0.0, float(shape.get("outline_width", 0.0))) * 0.5)
if not found:
result = bounds
found = true
else:
result = result.merge(bounds)
return result if found else Rect2()
func _stage_size_guide_bounds(artwork_bounds: Rect2) -> Rect2:
var head_center: Vector2 = WholeStickmanPreviewScript.GUIDE_JOINTS["Head"]
var ankle: Vector2 = WholeStickmanPreviewScript.GUIDE_JOINTS["LeftAnkle"]
var height: float = ankle.y - (head_center.y - WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS)
var guide_half_width: float = 170.0
var figure_center_x: float
if artwork_bounds.size.x <= guide_half_width * 2.0 + 40.0:
figure_center_x = artwork_bounds.position.x + minf(artwork_bounds.size.x * 0.25, 40.0)
else:
figure_center_x = artwork_bounds.position.x - guide_half_width - 32.0
var floor_y: float = artwork_bounds.end.y
return Rect2(Vector2(figure_center_x - guide_half_width, floor_y - height), Vector2(guide_half_width * 2.0, height))
func _shapes_are_representable(source: Array[Dictionary]) -> bool:
if source.is_empty():
return false
for shape: Dictionary in source:
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for point: Vector2 in points:
if not is_finite(point.x) or not is_finite(point.y) or absf(point.x) > SCALE_COORDINATE_LIMIT or absf(point.y) > SCALE_COORDINATE_LIMIT:
return false
var radius: float = float(shape.get("radius", 0.0))
var outline_width: float = float(shape.get("outline_width", 0.0))
if not is_finite(radius) or radius < 0.0 or radius > SCALE_COORDINATE_LIMIT:
return false
if not is_finite(outline_width) or outline_width < 0.0 or outline_width > SCALE_COORDINATE_LIMIT:
return false
return true
func _scaled_stage_size_shapes(source: Array[Dictionary], factor: float) -> Array[Dictionary]:
var result: Array[Dictionary] = []
for source_shape: Dictionary in source:
var shape: Dictionary = source_shape.duplicate(true)
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for index: int in points.size():
points[index] *= factor
shape["points"] = points
shape["radius"] = float(shape.get("radius", 0.0)) * factor
shape["outline_width"] = float(shape.get("outline_width", 0.0)) * factor
result.append(shape)
return result
func _duplicate_shapes(source: Array[Dictionary]) -> Array[Dictionary]:
var result: Array[Dictionary] = []
for shape: Dictionary in source:
result.append(shape.duplicate(true))
return result
func _valid_rect(rect: Rect2) -> bool:
return rect.position.is_finite() and rect.size.is_finite() and rect.size.x >= 0.0 and rect.size.y >= 0.0 and (rect.size.x > 0.0 or rect.size.y > 0.0)
func get_shapes_bounds() -> Rect2:
var found: bool = false; var result: Rect2 = Rect2()
for shape: Dictionary in shapes:
@@ -378,6 +589,7 @@ func set_origin_marker_color(color: Color) -> void:
func set_shape_data(data: Variant) -> void:
## Import shapes. Accepts an Array[Dictionary] (internal or plain {x,y}
## format) or a single Dictionary.
cancel_stage_size_preview()
set_whole_prop_resize_enabled(false)
shapes.clear()
if data is Array:
@@ -403,6 +615,7 @@ func get_shape_data() -> Array[Dictionary]:
func clear() -> void:
## Remove all shapes without emitting shape_changed.
cancel_stage_size_preview()
set_whole_prop_resize_enabled(false)
shapes.clear()
_selected_shape_idx = -1
@@ -788,7 +1001,6 @@ func global_to_world(global_pos: Vector2) -> Vector2:
func _on_canvas_draw() -> void:
draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
_draw_grid()
_draw_stickman_guide()
if shapes.is_empty() and _edit_mode == EditMode.TRANSFORM:
var rect := get_rect()
@@ -797,10 +1009,14 @@ func _on_canvas_draw() -> void:
if font != null:
draw_string(font, hint_pos, "Right-click to add a shape", HORIZONTAL_ALIGNMENT_CENTER, -1, 14, Color(0.5, 0.5, 0.5, 0.6))
var drawn_shapes: Array[Dictionary] = _whole_prop_preview_shapes if _whole_prop_dragging else shapes
var drawn_shapes: Array[Dictionary] = _stage_size_preview_shapes if _stage_size_preview_active else (_whole_prop_preview_shapes if _whole_prop_dragging else shapes)
for i: int in drawn_shapes.size():
var selected: bool = not _whole_prop_resize_enabled and i == _selected_shape_idx
var selected: bool = not _stage_size_preview_active and not _whole_prop_resize_enabled and i == _selected_shape_idx
_draw_shape(drawn_shapes[i], selected, _dragging_vertex if selected else -1)
if _stage_size_preview_active:
_draw_stage_size_guide(_visible_bounds_for_shapes(drawn_shapes))
else:
_draw_stickman_guide()
_draw_origin_marker()
if _whole_prop_resize_enabled:
@@ -836,6 +1052,54 @@ func _draw_stickman_guide() -> void:
draw_line(joints[first] as Vector2, joints[second] as Vector2, color, width)
if joints.has("Head"):
draw_arc(joints["Head"] as Vector2, WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS, 0.0, TAU, 48, color, width)
var ankle: Vector2 = joints["LeftAnkle"]
_draw_reference_figure(0.0, ankle.y, ankle.x - 250.0, ankle.x + 250.0)
func _draw_stage_size_guide(artwork_bounds: Rect2) -> void:
var guide_bounds: Rect2 = _stage_size_guide_bounds(artwork_bounds)
var floor_y: float = artwork_bounds.end.y
_draw_reference_figure(
guide_bounds.get_center().x,
floor_y,
minf(artwork_bounds.position.x, guide_bounds.position.x - 28.0),
maxf(artwork_bounds.end.x, guide_bounds.end.x + 28.0)
)
func _draw_reference_figure(center_x: float, floor_y: float, floor_start: float, floor_end: float) -> void:
var joints: Dictionary = WholeStickmanPreviewScript.GUIDE_JOINTS
var ankle_y: float = (joints["LeftAnkle"] as Vector2).y
var offset: Vector2 = Vector2(center_x, floor_y - ankle_y)
var inv_zoom: float = 1.0 / maxf(_zoom, 0.001)
for bone: Array in WholeStickmanPreviewScript.GUIDE_BONES:
var first: String = str(bone[0])
var second: String = str(bone[1])
if not joints.has(first) or not joints.has(second):
continue
var color: Color = WholeStickmanPreviewScript.GUIDE_COLOR_CENTRAL
if first in WholeStickmanPreviewScript.GUIDE_LEFT_JOINTS or second in WholeStickmanPreviewScript.GUIDE_LEFT_JOINTS:
color = WholeStickmanPreviewScript.GUIDE_COLOR_LEFT
elif first in WholeStickmanPreviewScript.GUIDE_RIGHT_JOINTS or second in WholeStickmanPreviewScript.GUIDE_RIGHT_JOINTS:
color = WholeStickmanPreviewScript.GUIDE_COLOR_RIGHT
var start: Vector2 = (joints[first] as Vector2) + offset
var finish: Vector2 = (joints[second] as Vector2) + offset
draw_line(start, finish, Color("#111821D9"), 8.0 * inv_zoom, true)
draw_line(start, finish, color, 3.0 * inv_zoom, true)
var head: Vector2 = (joints["Head"] as Vector2) + offset
draw_arc(head, WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS, 0.0, TAU, 48, Color("#111821E8"), 8.0 * inv_zoom, true)
draw_arc(head, WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS, 0.0, TAU, 48, WholeStickmanPreviewScript.GUIDE_COLOR_CENTRAL, 3.0 * inv_zoom, true)
for joint_name: String in joints:
var point: Vector2 = (joints[joint_name] as Vector2) + offset
var color: Color = WholeStickmanPreviewScript.GUIDE_COLOR_CENTRAL
if joint_name in WholeStickmanPreviewScript.GUIDE_LEFT_JOINTS:
color = WholeStickmanPreviewScript.GUIDE_JOINT_COLOR_LEFT
elif joint_name in WholeStickmanPreviewScript.GUIDE_RIGHT_JOINTS:
color = WholeStickmanPreviewScript.GUIDE_JOINT_COLOR_RIGHT
draw_circle(point, 6.5 * inv_zoom, Color("#111821E8"))
draw_circle(point, 3.2 * inv_zoom, color)
draw_line(Vector2(floor_start, floor_y), Vector2(floor_end, floor_y), Color("#111821E8"), 8.0 * inv_zoom, true)
draw_line(Vector2(floor_start, floor_y), Vector2(floor_end, floor_y), Color("#F8FAFC"), 2.5 * inv_zoom, true)
func _draw_whole_prop_resize_gizmos() -> void:
var bounds: Rect2 = _whole_preview_bounds() if _whole_prop_dragging else get_shapes_bounds()
@@ -852,7 +1116,7 @@ func _draw_whole_prop_resize_gizmos() -> void:
var rotation_dot: Vector2 = Vector2(bounds.get_center().x, bounds.end.y + ROTATION_CIRCLE_OFFSET)
draw_circle(rotation_dot, (ROTATION_CIRCLE_RADIUS + 2.0) / _zoom, SELECTION_HALO_COLOR)
draw_circle(rotation_dot, ROTATION_CIRCLE_RADIUS / _zoom, SELECTION_CORE_COLOR)
var anchors: Dictionary = _whole_prop_preview_anchors if _whole_prop_dragging else get_anchor_data()
var anchors: Dictionary = _stage_size_preview_anchors if _stage_size_preview_active else (_whole_prop_preview_anchors if _whole_prop_dragging else get_anchor_data())
for id: String in anchors:
if id == "origin":
continue
@@ -1008,7 +1272,10 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
return
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
_handle_left_press(world_pos)
if _create_point_armed and _edit_mode == EditMode.POINTS:
_create_point_from_toolbar(world_pos)
else:
_handle_left_press(world_pos)
else:
_handle_left_release()
@@ -1218,6 +1485,21 @@ func _handle_left_press(world_pos: Vector2) -> void:
shape_selected.emit(-1)
func _create_point_from_toolbar(world_pos: Vector2) -> void:
var shape_idx: int = hit_test_segment(world_pos)
if shape_idx < 0:
return
_selected_shape_idx = shape_idx
shape_selected.emit(shape_idx)
var new_vertex_index: int = create_point_on_edge(world_pos)
if new_vertex_index < 0:
return
_create_point_armed = false
_drag_mode = DragMode.NONE
_emit_changed()
queue_redraw()
func _handle_drag_motion(mm: InputEventMouseMotion, world_pos: Vector2, ctrl_pressed: bool) -> void:
match _drag_mode:
DragMode.VERTEX:
+466 -112
View File
@@ -12,6 +12,7 @@ extends Control
const PropFileScript := preload("res://scripts/editors/prop/prop_file.gd")
const PropThemeScript := preload("res://scripts/editors/prop/prop_theme.gd")
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
const PropCanvasScript := preload("res://scripts/editors/prop/prop_canvas.gd")
const PropThumbnailScript := preload("res://scripts/shared/thumbnails/prop_thumbnail.gd")
@@ -36,7 +37,13 @@ const SETTINGS_PATH: String = "user://prop_editor_settings.json"
const RECENT_MAX: int = 5
const OFF_ORIGIN_WARNING_MIN_DISTANCE: float = 1024.0
const OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER: float = 2.0
const NORMALIZED_MAX_EXTENT: float = 96.0
const STAGE_REFERENCE_HEIGHT: float = 939.5 # WholeStickmanPreview head top (-463.5 - 100) to ankle line (376).
const STAGE_SIZE_STEP: float = 1.1
const ART_TAG_IDS: Array[String] = ["nature", "buildings", "walls", "roads", "objects"]
const ART_TAG_LABELS: Array[String] = ["Nature", "Buildings", "Walls", "Roads", "Objects"]
signal artwork_saved(path: String)
signal return_requested
const MENU_NEW := 0
const MENU_LOAD := 2
@@ -86,10 +93,10 @@ static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
# ---------------------------------------------------------------------------
@onready var _menu_bar: MenuBar = %MenuBar
@onready var _top_right: HBoxContainer = %TopRightControls
@onready var _top_right: FlowContainer = %TopRightControls
@onready var _prop_name_edit: LineEdit = %PropNameEdit
@onready var _canvas: PropCanvas = %PropCanvas
@onready var _mode_bar: HBoxContainer = %ModeBar
@onready var _mode_bar: FlowContainer = %ModeBar
@onready var _size_tools: FlowContainer = %SizeTools
@onready var _attach_tools: FlowContainer = %AttachTools
@onready var _canvas_workspace: HSplitContainer = %CanvasWorkspace
@@ -108,11 +115,13 @@ static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
# ---------------------------------------------------------------------------
var _theme: PropTheme
var _editor_visual_theme: EditorVisualTheme
var _material_preset: int = PropBlockScript.MaterialPreset.NONE
var _current_path: String = ""
var _dirty: bool = false
var _clipboard: Dictionary = {}
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
var _tags: Array[String] = []
var _selected_anchor_id: String = ""
var _thumb: PropThumbnail
var _thumb_cache: ThumbnailCache
@@ -132,21 +141,34 @@ var _radius_row: Control
var _reshaped_note: Label
var _vertex_label: Label
var _delete_button: Button
var _create_point_button: Button
var _create_point_hint: Label
var _shape_section: VBoxContainer
var _primitive_option: OptionButton
var _params_box: VBoxContainer
var _param_spins: Dictionary = {}
var _add_button: Button
var _updating_inspector: bool = false
var _tag_checks: Dictionary = {}
var _tag_section: VBoxContainer
var _stage_authoring: bool = false
var _stage_return_pending: bool = false
var _stage_place_button: Button
# Grid settings dialog (removed Round 4 - replaced by top-right controls)
var _grid_spin: SpinBox
var _snap_check: CheckBox
var _center_prop_button: Button
var _normalize_size_button: Button
var _size_for_stage_button: Button
var _stage_size_controls: FlowContainer
var _stage_size_status: Label
var _show_stickman_guide_check: CheckBox
var _show_stickman_guide: bool = false
var _whole_prop_resize_button: Button
var _stage_size_preview_active: bool = false
var _stage_size_preview_factor: float = 1.0
var _stage_size_entry_extent: float = 0.0
var _pending_save_after_stage_size: int = -1
# Edit-mode toolbar (Round 2/3)
var _mode_group: ButtonGroup
@@ -192,7 +214,10 @@ var _recent_props: Array[String] = []
# ---------------------------------------------------------------------------
func _ready() -> void:
get_tree().auto_accept_quit = false
if not _stage_authoring:
get_tree().auto_accept_quit = false
_editor_visual_theme = EditorVisualThemeScript.new()
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
_theme = PropThemeScript.new()
_theme.load_from(PropThemeScript.DEFAULT_PATH)
_thumb_cache = ThumbnailCacheScript.new()
@@ -225,6 +250,7 @@ func _ready() -> void:
_canvas.whole_prop_resize_requested.connect(_on_whole_prop_resize_requested)
_canvas.whole_prop_move_requested.connect(_on_whole_prop_move_requested)
_canvas.whole_prop_rotate_requested.connect(_on_whole_prop_rotate_requested)
_canvas.stage_size_cancel_requested.connect(func() -> void: _finish_stage_size_preview(false))
_canvas.set_grid_size(_theme.grid_size())
_canvas.set_snap_enabled(false)
_canvas.set_has_clipboard(false)
@@ -240,7 +266,7 @@ func _ready() -> void:
_cursor_coords.add_theme_font_size_override("font_size", _theme.font_status())
_save_status.add_theme_font_size_override("font_size", _theme.font_status())
_mode_bar.alignment = BoxContainer.ALIGNMENT_CENTER
_mode_bar.alignment = FlowContainer.ALIGNMENT_CENTER
_refresh_inspector_from_selection()
_sync_mode_buttons(_canvas.get_edit_mode())
@@ -250,12 +276,24 @@ func _ready() -> void:
_canvas.call_deferred("reset_view")
call_deferred("_initialize_inspector_split")
_refresh_center_prop_button()
_check_autosave_recovery()
if _stage_authoring:
if _canvas.get_edit_mode() == PropCanvasScript.EditMode.ATTACH:
_canvas.set_edit_mode(PropCanvasScript.EditMode.TRANSFORM)
_do_new()
_build_stage_authoring_controls()
_apply_authoring_context()
else:
_check_autosave_recovery()
func configure_stage_authoring() -> void:
_stage_authoring = true
process_mode = Node.PROCESS_MODE_ALWAYS
func _process(delta: float) -> void:
_update_cursor_coords()
if _autosave_remaining > 0.0:
if not _stage_authoring and _autosave_remaining > 0.0:
_autosave_remaining -= delta
if _autosave_remaining <= 0.0:
_autosave_remaining = 0.0
@@ -266,9 +304,24 @@ func _unhandled_key_input(event: InputEvent) -> void:
if event is InputEventKey:
var key := event as InputEventKey
if key.pressed and not key.echo and key.keycode == KEY_ESCAPE:
if _stage_size_preview_active:
_finish_stage_size_preview(false)
get_viewport().set_input_as_handled()
return
if _stage_authoring:
var stage_focus: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
if stage_focus is LineEdit or stage_focus is TextEdit:
return
_request_stage_return()
get_viewport().set_input_as_handled()
return
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
if not (focus_owner is LineEdit or focus_owner is TextEdit):
if _canvas.is_whole_prop_resize_dragging():
if _canvas.is_create_point_armed():
_canvas.cancel_create_point()
_update_create_point_feedback()
get_viewport().set_input_as_handled()
elif _canvas.is_whole_prop_resize_dragging():
_canvas.cancel_whole_prop_resize()
get_viewport().set_input_as_handled()
elif _canvas.is_whole_prop_resize_enabled():
@@ -294,8 +347,11 @@ func _update_save_status() -> void:
var name: String = _prop_name_edit.text if _prop_name_edit != null else ""
if name.is_empty():
name = "Unnamed Prop"
var suffix: String = " • Modified" if _dirty else ""
_save_status.text = "%s — %s%s" % [name, PRESET_LABELS[_material_preset], suffix]
var has_saved_path: bool = not _current_path.is_empty()
var state: String = "Unsaved changes" if _dirty else ("Saved" if has_saved_path else "Not saved yet")
_save_status.text = "%s — %s — %s" % [name, PRESET_LABELS[_material_preset], state]
var status_color: Color = _editor_visual_theme.chrome_color(&"dirty", &"prop") if _dirty else (_editor_visual_theme.chrome_color(&"saved", &"prop") if has_saved_path else _editor_visual_theme.chrome_color(&"text_muted", &"prop"))
_save_status.add_theme_color_override("font_color", status_color)
_update_window_title()
@@ -329,10 +385,7 @@ func _make_mode_button(mode: int) -> Button:
button.button_group = _mode_group
button.custom_minimum_size.y = float(_theme.mode_button_min_height())
button.add_theme_font_size_override("font_size", _theme.font_mode())
# No keyboard focus: the focus stylebox otherwise draws over the pressed
# stylebox, hiding the selected (accent-bordered) state until the cursor
# moves away (Round 4 fix).
button.focus_mode = Control.FOCUS_NONE
button.focus_mode = Control.FOCUS_ALL
_mode_bar.add_child(button)
return button
@@ -349,8 +402,15 @@ func _on_attach_mode_pressed() -> void:
func _on_canvas_mode_changed(mode: int) -> void:
if _stage_size_preview_active:
_cancel_stage_size_preview()
if _stage_authoring and mode == PropCanvasScript.EditMode.ATTACH:
_canvas.set_edit_mode(PropCanvasScript.EditMode.TRANSFORM)
return
if mode != PropCanvasScript.EditMode.TRANSFORM:
_set_whole_prop_resize_enabled(false)
if mode != PropCanvasScript.EditMode.POINTS and _canvas.is_create_point_armed():
_canvas.cancel_create_point()
var entering_attach: bool = mode == PropCanvasScript.EditMode.ATTACH and _last_canvas_mode != PropCanvasScript.EditMode.ATTACH
if entering_attach and _show_preview_check != null:
_show_preview_check.set_pressed_no_signal(true)
@@ -373,21 +433,26 @@ func _sync_mode_buttons(mode: int) -> void:
func _sync_mode_controls(mode: int) -> void:
if _whole_prop_resize_button != null:
_whole_prop_resize_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _normalize_size_button != null:
_normalize_size_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
_whole_prop_resize_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM and not _stage_size_preview_active
if _size_for_stage_button != null:
_size_for_stage_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM and not _stage_size_preview_active
if _attach_tools != null:
_attach_tools.visible = mode == PropCanvasScript.EditMode.ATTACH
_attach_tools.visible = mode == PropCanvasScript.EditMode.ATTACH and not _stage_authoring
if _add_section != null:
_add_section.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _delete_button != null:
_delete_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _create_point_button != null:
_create_point_button.visible = mode == PropCanvasScript.EditMode.POINTS and _canvas.get_selected_index() >= 0
if _create_point_hint != null:
_create_point_hint.visible = mode == PropCanvasScript.EditMode.POINTS and _canvas.get_selected_index() >= 0
_update_create_point_feedback()
if _shape_section != null:
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_selected_index() >= 0
if _material_section != null:
_material_section.visible = mode != PropCanvasScript.EditMode.ATTACH
_material_section.visible = mode != PropCanvasScript.EditMode.ATTACH and not _stage_authoring
if _anchor_section != null:
_anchor_section.visible = mode == PropCanvasScript.EditMode.ATTACH
_anchor_section.visible = mode == PropCanvasScript.EditMode.ATTACH and not _stage_authoring
if _attach_preview != null:
_attach_preview.visible = mode == PropCanvasScript.EditMode.ATTACH and (_show_preview_check == null or _show_preview_check.button_pressed)
if _attach_preview.has_method("set_document"):
@@ -413,73 +478,15 @@ func _build_attach_tools() -> void:
# ---------------------------------------------------------------------------
func _build_control_theme() -> void:
_ui_theme = Theme.new()
var bg: Color = _theme.canvas_background_color()
var panel := StyleBoxFlat.new()
panel.bg_color = Color(0.12, 0.12, 0.12, 1.0)
panel.set_corner_radius_all(6)
panel.content_margin_left = 8
panel.content_margin_right = 8
panel.content_margin_top = 6
panel.content_margin_bottom = 6
panel.border_color = Color(1.0, 1.0, 1.0, 0.12)
panel.set_border_width_all(1)
var panel_dark := StyleBoxFlat.new()
panel_dark.bg_color = bg.darkened(0.25)
panel_dark.set_corner_radius_all(6)
panel_dark.content_margin_left = 8
panel_dark.content_margin_right = 8
panel_dark.content_margin_top = 6
panel_dark.content_margin_bottom = 6
panel_dark.border_color = Color(1.0, 1.0, 1.0, 0.12)
panel_dark.set_border_width_all(1)
var panel_pressed := StyleBoxFlat.new()
panel_pressed.bg_color = bg.darkened(0.25)
panel_pressed.set_corner_radius_all(6)
panel_pressed.content_margin_left = 8
panel_pressed.content_margin_right = 8
panel_pressed.content_margin_top = 6
panel_pressed.content_margin_bottom = 6
panel_pressed.border_color = _theme.accent_color()
panel_pressed.set_border_width_all(2)
_ui_theme.set_stylebox("normal", "Button", panel)
_ui_theme.set_stylebox("hover", "Button", panel_dark)
_ui_theme.set_stylebox("pressed", "Button", panel_pressed)
_ui_theme.set_stylebox("focus", "Button", panel)
var inspector_surface := StyleBoxFlat.new()
inspector_surface.bg_color = _theme.inspector_background_color()
inspector_surface.border_color = _theme.border_color()
inspector_surface.set_border_width_all(1)
inspector_surface.content_margin_left = 8
inspector_surface.content_margin_right = 8
_ui_theme = _editor_visual_theme.control_theme(&"prop")
theme = _ui_theme
var inspector_surface: StyleBoxFlat = _ui_theme.get_stylebox("panel", "PanelContainer").duplicate() as StyleBoxFlat
_inspector_column.add_theme_stylebox_override("panel", inspector_surface)
var splitter := StyleBoxFlat.new()
splitter.bg_color = _theme.border_color().lightened(0.35)
splitter.bg_color = _editor_visual_theme.chrome_color(&"border", &"prop").lightened(0.35)
splitter.content_margin_left = 3
splitter.content_margin_right = 3
_canvas_workspace.add_theme_stylebox_override("grabber", splitter)
_ui_theme.set_stylebox("normal", "OptionButton", panel)
_ui_theme.set_stylebox("hover", "OptionButton", panel_dark)
_ui_theme.set_stylebox("pressed", "OptionButton", panel_dark)
_ui_theme.set_stylebox("normal", "SpinBox", panel_dark)
_ui_theme.set_stylebox("normal", "LineEdit", panel_dark)
_ui_theme.set_stylebox("normal", "ColorPickerButton", panel)
_ui_theme.set_stylebox("hover", "ColorPickerButton", panel_dark)
_ui_theme.set_stylebox("normal", "PopupMenu", panel_dark)
_ui_theme.set_stylebox("panel", "MenuBar", panel_dark)
_ui_theme.set_stylebox("normal", "CheckBox", panel)
_ui_theme.set_stylebox("panel", "PanelContainer", panel_dark)
_ui_theme.set_color("font_color", "Label", _theme.text_color())
_ui_theme.set_color("font_color", "Button", _theme.text_color())
_ui_theme.set_color("font_hover_color", "Button", Color.WHITE)
_ui_theme.set_color("font_pressed_color", "Button", _theme.accent_color())
_ui_theme.set_font_size("font_size", "Label", _theme.font_body())
_ui_theme.set_font_size("font_size", "Button", _theme.font_body())
_ui_theme.set_font_size("font_size", "OptionButton", _theme.font_body())
_ui_theme.set_font_size("font_size", "SpinBox", _theme.font_body())
_ui_theme.set_font_size("font_size", "LineEdit", _theme.font_body())
_ui_theme.set_font_size("font_size", "PopupMenu", _theme.font_menu())
_ui_theme.set_font_size("font_size", "MenuBar", _theme.font_menu_bar())
_menu_bar.theme = _ui_theme
_top_right.theme = _ui_theme
_prop_name_edit.theme = _ui_theme
@@ -606,11 +613,18 @@ func _setup_dialogs() -> void:
_load_dialog.current_dir = content_dir
_save_dialog.file_selected.connect(_on_save_file_selected)
_load_dialog.file_selected.connect(_on_load_file_selected)
_editor_visual_theme.style_button_role(_save_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_save_dialog.get_cancel_button(), &"secondary", &"prop")
_editor_visual_theme.style_button_role(_load_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_load_dialog.get_cancel_button(), &"secondary", &"prop")
_editor_visual_theme.style_button_role(_error_dialog.get_ok_button(), &"secondary", &"prop")
_build_exit_dialog()
_rename_anchor_dialog = ConfirmationDialog.new()
_rename_anchor_dialog.title = "Rename Anchor"
_rename_anchor_dialog.ok_button_text = "Rename"
_rename_anchor_dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(_rename_anchor_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(_rename_anchor_dialog.get_cancel_button(), &"secondary", &"prop")
_rename_anchor_edit = LineEdit.new()
_rename_anchor_edit.placeholder_text = "Anchor name"
_rename_anchor_dialog.add_child(_rename_anchor_edit)
@@ -660,17 +674,18 @@ func _build_top_right_controls() -> void:
_top_right.add_child(_snap_check)
func _build_size_tools() -> void:
_whole_prop_resize_button = Button.new()
_whole_prop_resize_button.text = "✥ Transform Whole Prop"
_whole_prop_resize_button.tooltip_text = "Move, rotate, or resize the whole prop including grip markers."
_whole_prop_resize_button.text = "Move, Turn, or Resize Prop"
_whole_prop_resize_button.tooltip_text = "Move, turn, or resize the whole prop, including grip markers."
_whole_prop_resize_button.toggle_mode = true
_whole_prop_resize_button.custom_minimum_size.y = float(_theme.button_min_height())
_whole_prop_resize_button.toggled.connect(_set_whole_prop_resize_enabled)
_size_tools.add_child(_whole_prop_resize_button)
_normalize_size_button = Button.new()
_normalize_size_button.text = "📏 Normalize Size"
_normalize_size_button.custom_minimum_size.y = float(_theme.button_min_height())
_normalize_size_button.pressed.connect(_on_normalize_size_pressed)
_size_tools.add_child(_normalize_size_button)
_size_for_stage_button = Button.new()
_size_for_stage_button.text = "📏 Size for Stage"
_size_for_stage_button.tooltip_text = "Compare this drawing with a standing stickman."
_size_for_stage_button.custom_minimum_size = Vector2(148.0, maxf(44.0, float(_theme.button_min_height())))
_size_for_stage_button.pressed.connect(_on_size_for_stage_pressed)
_size_tools.add_child(_size_for_stage_button)
_center_prop_button = Button.new()
_center_prop_button.text = "🎯 Center Prop"
_center_prop_button.tooltip_text = "Move the whole prop and its anchors to (0,0)."
@@ -683,9 +698,57 @@ func _build_size_tools() -> void:
_show_stickman_guide_check.custom_minimum_size.y = float(_theme.button_min_height())
_show_stickman_guide_check.toggled.connect(_on_stickman_guide_toggled)
_size_tools.add_child(_show_stickman_guide_check)
_stage_size_controls = FlowContainer.new()
_stage_size_controls.name = "StageSizeControls"
_stage_size_controls.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_stage_size_controls.add_theme_constant_override("h_separation", 6)
_stage_size_controls.add_theme_constant_override("v_separation", 6)
_stage_size_controls.hide()
_size_tools.add_child(_stage_size_controls)
for preset: Dictionary in [
{"label": "Small", "factor": 0.35},
{"label": "Room", "factor": 1.0},
{"label": "Big", "factor": 2.0},
]:
var preset_button: Button = _make_stage_size_button(str(preset["label"]))
var preset_factor: float = float(preset["factor"])
preset_button.pressed.connect(func() -> void: _choose_stage_size(preset_factor))
_stage_size_controls.add_child(preset_button)
var smaller_button: Button = _make_stage_size_button("Make smaller")
smaller_button.pressed.connect(func() -> void: _adjust_stage_size(1.0 / STAGE_SIZE_STEP))
_stage_size_controls.add_child(smaller_button)
var bigger_button: Button = _make_stage_size_button("Make bigger")
bigger_button.pressed.connect(func() -> void: _adjust_stage_size(STAGE_SIZE_STEP))
_stage_size_controls.add_child(bigger_button)
var keep_button: Button = _make_stage_size_button("Keep size", true)
keep_button.pressed.connect(func() -> void: _finish_stage_size_preview(true))
_stage_size_controls.add_child(keep_button)
var cancel_button: Button = _make_stage_size_button("✕ Cancel")
cancel_button.pressed.connect(func() -> void: _finish_stage_size_preview(false))
_stage_size_controls.add_child(cancel_button)
_stage_size_status = Label.new()
_stage_size_status.name = "StageSizeStatus"
# This label lives in the size-tools FlowContainer. With an auto-wrapping
# label and no minimum width, FlowContainer can collapse it to a sliver and
# grow the toolbar to thousands of pixels tall when the preview opens.
_stage_size_status.custom_minimum_size.x = 360.0
_stage_size_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_stage_size_status.hide()
_size_tools.add_child(_stage_size_status)
_refresh_scale_buttons()
func _make_stage_size_button(label: String, prominent: bool = false) -> Button:
var button: Button = Button.new()
button.text = label
button.custom_minimum_size.y = maxf(44.0, float(_theme.button_min_height()))
button.focus_mode = Control.FOCUS_ALL
button.theme = _ui_theme
if prominent:
button.add_theme_color_override("font_color", _theme.accent_color())
return button
func _on_top_grid_changed(value: float) -> void:
_canvas.set_grid_size(int(value))
@@ -693,12 +756,110 @@ func _on_top_grid_changed(value: float) -> void:
func _on_top_snap_toggled(pressed: bool) -> void:
_canvas.set_snap_enabled(pressed)
func _on_normalize_size_pressed() -> void:
var bounds: Rect2 = _canvas.get_shapes_bounds()
var extent: float = maxf(bounds.size.x, bounds.size.y)
if is_finite(extent) and extent > 0.0:
_scale_document(NORMALIZED_MAX_EXTENT / extent)
func _on_size_for_stage_pressed() -> void:
if _stage_size_preview_active or _canvas.get_edit_mode() != PropCanvasScript.EditMode.TRANSFORM:
return
_set_whole_prop_resize_enabled(false)
var bounds: Rect2 = _canvas.get_visible_shapes_bounds()
_stage_size_entry_extent = maxf(bounds.size.x, bounds.size.y)
if not is_finite(_stage_size_entry_extent) or _stage_size_entry_extent <= 0.0:
return
if not _canvas.begin_stage_size_preview(_anchors):
return
_stage_size_preview_factor = 1.0
_stage_size_preview_active = true
_stage_size_status.text = "Compare the drawing with the guide, then keep it or cancel."
_update_stage_size_controls_visibility()
# Hiding the inspector and mode bar changes the canvas allocation. Wait for
# that layout pass before centering/fitting, otherwise the guide can be
# pushed outside the newly-sized viewport until a preset is clicked.
await get_tree().process_frame
if not _stage_size_preview_active:
return
_canvas.fit_stage_size_view(_canvas.get_stage_size_comparison_bounds(), 48.0, true)
func _choose_stage_size(relative_height: float) -> void:
if not _stage_size_preview_active or not is_finite(relative_height) or relative_height <= 0.0:
return
var factor: float = STAGE_REFERENCE_HEIGHT * relative_height / _stage_size_entry_extent
if _canvas.preview_stage_size(factor):
_stage_size_preview_factor = factor
_stage_size_status.text = ""
_canvas.fit_stage_size_view(_canvas.get_stage_size_comparison_bounds(), 48.0, true)
else:
_stage_size_status.text = "That size is too large to display. Try a smaller choice."
func _adjust_stage_size(multiplier: float) -> void:
if not _stage_size_preview_active:
return
var factor: float = _stage_size_preview_factor * multiplier
if _canvas.preview_stage_size(factor):
_stage_size_preview_factor = factor
_stage_size_status.text = ""
_canvas.fit_stage_size_view(_canvas.get_stage_size_comparison_bounds(), 48.0, false)
else:
_stage_size_status.text = "That size is outside the drawing area. Try a smaller change."
func _finish_stage_size_preview(keep: bool) -> void:
if not _stage_size_preview_active:
return
var pending_save: int = _pending_save_after_stage_size if keep else -1
_pending_save_after_stage_size = -1
if keep:
var result: Dictionary = _canvas.commit_stage_size_preview()
_anchors = (result.get("anchors", _anchors) as Dictionary).duplicate(true)
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
_canvas.set_anchor_data(_anchors)
_canvas.select_anchor(_selected_anchor_id)
_refresh_anchor_inspector()
_sync_attach_preview()
else:
_canvas.cancel_stage_size_preview()
_stage_size_preview_active = false
_stage_size_preview_factor = 1.0
_stage_size_entry_extent = 0.0
_stage_size_status.text = ""
_update_stage_size_controls_visibility()
_refresh_scale_buttons()
if pending_save >= 0:
_continue_save_after_stage_size(pending_save)
func _cancel_stage_size_preview() -> void:
if _stage_size_preview_active:
_pending_save_after_stage_size = -1
_finish_stage_size_preview(false)
func _request_save_after_stage_size(menu_id: int) -> void:
_pending_save_after_stage_size = menu_id
_stage_size_status.text = "Keep size before saving, or cancel to leave the drawing unchanged."
func _continue_save_after_stage_size(menu_id: int) -> void:
if menu_id == MENU_SAVE:
if _current_path.is_empty():
_save_dialog.popup_centered()
else:
_begin_save_request(_current_path, true)
elif menu_id == MENU_SAVE_AS:
_save_dialog.popup_centered()
func _update_stage_size_controls_visibility() -> void:
if _stage_size_controls == null:
return
_stage_size_controls.visible = _stage_size_preview_active
_stage_size_status.visible = _stage_size_preview_active
_size_for_stage_button.visible = not _stage_size_preview_active and _canvas.get_edit_mode() == PropCanvasScript.EditMode.TRANSFORM
_whole_prop_resize_button.visible = not _stage_size_preview_active and _canvas.get_edit_mode() == PropCanvasScript.EditMode.TRANSFORM
_center_prop_button.visible = not _stage_size_preview_active
_show_stickman_guide_check.visible = not _stage_size_preview_active and not _stage_authoring
_mode_bar.visible = not _stage_size_preview_active
_inspector_column.visible = not _stage_size_preview_active
func _on_stickman_guide_toggled(enabled: bool) -> void:
_show_stickman_guide = enabled
@@ -728,9 +889,12 @@ func _build_exit_dialog() -> void:
_exit_dialog.title = "⚠️ Unsaved Changes"
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
_exit_dialog.ok_button_text = "💾 Save"
_exit_dialog.add_button("🗑️ Discard", false, "discard")
var discard_button: Button = _exit_dialog.add_button("🗑️ Discard", false, "discard")
_exit_dialog.get_cancel_button().text = "❌ Cancel"
_exit_dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(_exit_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"prop")
_editor_visual_theme.style_button_role(_exit_dialog.get_cancel_button(), &"secondary", &"prop")
add_child(_exit_dialog)
_exit_dialog.confirmed.connect(_on_exit_save)
_exit_dialog.custom_action.connect(_on_exit_custom_action)
@@ -739,9 +903,12 @@ func _build_exit_dialog() -> void:
_off_origin_save_dialog.title = "🎯 Center Prop?"
_off_origin_save_dialog.dialog_text = "This prop is far from (0,0). Centering it keeps its shapes and anchors together.\n\nWhat would you like to do?"
_off_origin_save_dialog.ok_button_text = "🎯 Center & Save"
_off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway")
var save_anyway_button: Button = _off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway")
_off_origin_save_dialog.get_cancel_button().text = "❌ Cancel"
_off_origin_save_dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(_off_origin_save_dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(save_anyway_button, &"secondary", &"prop")
_editor_visual_theme.style_button_role(_off_origin_save_dialog.get_cancel_button(), &"secondary", &"prop")
add_child(_off_origin_save_dialog)
_off_origin_save_dialog.confirmed.connect(func() -> void: await _execute_pending_save(true))
_off_origin_save_dialog.custom_action.connect(func(action: StringName) -> void:
@@ -755,6 +922,9 @@ func _notification(what: int) -> void:
func _request_close() -> void:
if _stage_authoring:
_request_stage_return()
return
if not _dirty:
get_tree().quit()
return
@@ -765,6 +935,7 @@ func _request_close() -> void:
func _request_new() -> void:
_cancel_stage_size_preview()
if not _dirty:
_do_new()
return
@@ -774,6 +945,22 @@ func _request_new() -> void:
_exit_dialog.popup_centered()
func _request_stage_return() -> void:
if not _stage_authoring:
return
_cancel_stage_size_preview()
if not _dirty:
if not _current_path.is_empty():
artwork_saved.emit(_current_path)
return_requested.emit()
return
_stage_return_pending = true
_pending_new = false
_pending_quit = false
_exit_dialog.dialog_text = "Save your artwork before returning? Saving places it on the Stage."
_exit_dialog.popup_centered()
func _on_exit_save() -> void:
_exit_dialog.hide()
if _current_path.is_empty():
@@ -785,8 +972,12 @@ func _on_exit_save() -> void:
func _on_exit_custom_action(action: StringName) -> void:
if action == &"discard":
_exit_dialog.hide()
_clear_autosave()
if _pending_quit:
if not _stage_authoring:
_clear_autosave()
if _stage_return_pending:
_stage_return_pending = false
return_requested.emit()
elif _pending_quit:
_pending_quit = false
get_tree().quit()
elif _pending_new:
@@ -797,6 +988,7 @@ func _on_exit_custom_action(action: StringName) -> void:
func _on_exit_cancel() -> void:
_pending_quit = false
_pending_new = false
_stage_return_pending = false
_cancel_pending_save_request()
@@ -805,6 +997,8 @@ func _do_new() -> void:
_set_whole_prop_resize_enabled(false)
_prop_name_edit.text = ""
_material_preset = PropBlockScript.MaterialPreset.NONE
_tags.clear()
_refresh_tag_controls()
_preset_option.selected = _material_preset
_update_physics_labels()
_canvas.clear()
@@ -820,21 +1014,29 @@ func _do_new() -> void:
_refresh_anchor_inspector()
_refresh_center_prop_button()
_update_save_status()
_refresh_tag_controls()
func _on_menu_id_pressed(id: int) -> void:
match id:
MENU_NEW:
_request_new()
MENU_LOAD:
_load_dialog.popup_centered()
MENU_SAVE:
if _stage_size_preview_active:
_request_save_after_stage_size(MENU_SAVE)
return
if _current_path.is_empty():
_save_dialog.popup_centered()
else:
_begin_save_request(_current_path, true)
MENU_SAVE_AS:
if _stage_size_preview_active:
_request_save_after_stage_size(MENU_SAVE_AS)
return
_save_dialog.popup_centered()
MENU_LOAD:
_cancel_stage_size_preview()
_load_dialog.popup_centered()
MENU_RESET_VIEWS:
_canvas.reset_view()
@@ -948,9 +1150,22 @@ func _build_inspector() -> void:
_vertex_label = Label.new()
_shape_section.add_child(_vertex_label)
_create_point_button = Button.new()
_create_point_button.text = "📍 Create Point"
_create_point_button.tooltip_text = "Add a point to the selected shape's edge."
_create_point_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_create_point_button, &"primary", &"prop")
_create_point_button.pressed.connect(_on_create_point_pressed)
_shape_section.add_child(_create_point_button)
_create_point_hint = Label.new()
_create_point_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_create_point_hint.add_theme_color_override("font_color", _theme.text_muted_color())
_shape_section.add_child(_create_point_hint)
_delete_button = Button.new()
_delete_button.text = "Erase Shape"
_delete_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_delete_button, &"destructive", &"prop")
_delete_button.pressed.connect(_on_delete_shape_pressed)
_shape_section.add_child(_delete_button)
@@ -978,12 +1193,102 @@ func _build_inspector() -> void:
_add_button = Button.new()
_add_button.text = "Add a Shape"
_add_button.custom_minimum_size.y = float(_theme.button_min_height())
_editor_visual_theme.style_button_role(_add_button, &"primary", &"prop")
_add_button.pressed.connect(_on_add_pressed)
_add_section.add_child(_add_button)
_apply_control_theme(_inspector_list)
_rebuild_primitive_params()
_build_tag_inspector()
_build_anchor_inspector()
_refresh_tag_controls()
func _build_stage_authoring_controls() -> void:
var top_bar: HBoxContainer = get_node_or_null(NodePath("ChromeRoot/TopBar")) as HBoxContainer
if top_bar == null:
return
var name_label: Label = top_bar.get_node_or_null(NodePath("NameLabel")) as Label
if name_label != null:
name_label.text = "Artwork name:"
_prop_name_edit.placeholder_text = "Name this artwork"
_stage_place_button = Button.new()
_stage_place_button.text = "✨ Save & Place as Scenery"
_stage_place_button.tooltip_text = "Save this drawing and put a picture of it on your Stage."
_stage_place_button.custom_minimum_size.y = float(_theme.button_min_height())
_stage_place_button.size_flags_horizontal = Control.SIZE_SHRINK_END
_stage_place_button.theme = _ui_theme
_editor_visual_theme.style_button_role(_stage_place_button, &"primary", &"prop")
_stage_place_button.pressed.connect(_request_stage_return)
top_bar.add_child(_stage_place_button)
func _apply_authoring_context() -> void:
if not _stage_authoring:
return
if _material_section != null:
_material_section.hide()
if _anchor_section != null:
_anchor_section.hide()
if _attach_tools != null:
_attach_tools.hide()
if _attach_button != null:
_attach_button.hide()
if _show_stickman_guide_check != null:
_show_stickman_guide_check.hide()
if _tag_section != null:
_tag_section.show()
_sync_mode_buttons(_canvas.get_edit_mode())
_sync_mode_controls(_canvas.get_edit_mode())
_save_dialog.title = "Save Scenery Artwork"
func _build_tag_inspector() -> void:
_tag_section = VBoxContainer.new()
_tag_section.add_theme_constant_override("separation", 4)
_inspector_list.add_child(_tag_section)
var title := Label.new()
title.text = "Artwork Tags"
title.add_theme_font_size_override("font_size", _theme.font_header())
_tag_section.add_child(title)
var hint := Label.new()
hint.text = "Choose a few so this art is easy to find."
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_tag_section.add_child(hint)
var rows := FlowContainer.new()
rows.add_theme_constant_override("h_separation", 6)
rows.add_theme_constant_override("v_separation", 4)
_tag_section.add_child(rows)
for index: int in ART_TAG_IDS.size():
var tag_id: String = ART_TAG_IDS[index]
var check := CheckBox.new()
check.text = ART_TAG_LABELS[index]
check.custom_minimum_size.y = float(_theme.button_min_height())
check.toggled.connect(_on_art_tag_toggled.bind(tag_id))
rows.add_child(check)
_tag_checks[tag_id] = check
func _on_art_tag_toggled(enabled: bool, tag_id: String) -> void:
if _updating_inspector:
return
if enabled and not _tags.has(tag_id):
_tags.append(tag_id)
elif not enabled:
_tags.erase(tag_id)
_tags = PropFileScript.normalize_tags(_tags)
_mark_dirty()
func _refresh_tag_controls() -> void:
if _tag_checks.is_empty():
return
_updating_inspector = true
for tag_id: String in ART_TAG_IDS:
var check: CheckBox = _tag_checks.get(tag_id) as CheckBox
if check != null:
check.set_pressed_no_signal(_tags.has(tag_id))
_updating_inspector = false
func _build_anchor_inspector() -> void:
_anchor_section = VBoxContainer.new()
@@ -994,6 +1299,8 @@ func _build_anchor_inspector() -> void:
var actions := HBoxContainer.new(); _anchor_section.add_child(actions)
for label: String in ["Add Anchor", "Rename", "Delete"]:
var button := Button.new(); button.text = label; button.custom_minimum_size.y = float(_theme.button_min_height()); actions.add_child(button)
var role: StringName = &"primary" if label == "Add Anchor" else (&"destructive" if label == "Delete" else &"secondary")
_editor_visual_theme.style_button_role(button, role, &"prop")
if label == "Add Anchor": button.pressed.connect(_on_add_anchor_pressed)
elif label == "Rename":
_rename_anchor_button = button
@@ -1006,11 +1313,17 @@ func _build_anchor_inspector() -> void:
_anchor_x = _anchor_spin("X", -100000.0, 100000.0, _on_anchor_x_changed)
_anchor_y = _anchor_spin("Y", -100000.0, 100000.0, _on_anchor_y_changed)
_anchor_rotation = _anchor_spin("Rotation", -180.0, 179.0, _on_anchor_rotation_changed)
_anchor_z = _anchor_spin("Z Offset", -5.0, 5.0, _on_anchor_z_changed)
_anchor_z = _anchor_spin("Held prop layer", -5.0, 5.0, _on_anchor_z_changed)
_anchor_z.step = 1.0
var anchor_layer_hint := Label.new()
anchor_layer_hint.text = "Negative: behind · 0: normal order · Positive: farther in front."
anchor_layer_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
anchor_layer_hint.add_theme_font_size_override("font_size", _theme.font_status())
anchor_layer_hint.add_theme_color_override("font_color", _theme.text_muted_color())
_anchor_section.add_child(anchor_layer_hint)
var presets := FlowContainer.new(); _anchor_section.add_child(presets)
for entry: Dictionary in [{"text":"One-Handed","id":"one_handed"}, {"text":"Two-Handed","id":"two_handed"}, {"text":"Tool / Nozzle","id":"tool_nozzle"}, {"text":"Melee Edge","id":"melee_edge"}]:
var button := Button.new(); button.text = str(entry["text"]); button.pressed.connect(func() -> void: _apply_anchor_preset(str(entry["id"]))); presets.add_child(button)
var button := Button.new(); button.text = str(entry["text"]); button.custom_minimum_size.y = float(_theme.button_min_height()); button.pressed.connect(func() -> void: _apply_anchor_preset(str(entry["id"]))); presets.add_child(button)
_refresh_anchor_inspector()
func _anchor_option(label_text: String, entries: PackedStringArray, callback: Callable) -> OptionButton:
@@ -1023,6 +1336,7 @@ func _anchor_option(label_text: String, entries: PackedStringArray, callback: Ca
row.add_child(label)
var option := OptionButton.new()
option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
option.custom_minimum_size.y = float(_theme.button_min_height())
for entry: String in entries:
option.add_item(entry)
option.item_selected.connect(callback)
@@ -1042,6 +1356,7 @@ func _anchor_spin(label_text: String, minimum: float, maximum: float, callback:
spin.max_value = maximum
spin.step = 1.0
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
spin.custom_minimum_size.y = float(_theme.button_min_height())
spin.value_changed.connect(callback)
row.add_child(spin)
return spin
@@ -1149,14 +1464,15 @@ func _apply_control_theme(node: Node) -> void:
func _update_physics_labels() -> void:
var mat: PhysicsMaterial = PropBlockScript.physics_material(_material_preset)
_mass_label.text = "Mass: %.1f" % PropBlockScript.mass_for(_material_preset)
_friction_label.text = "Friction: %.2f" % mat.friction
_friction_label.text = "Surface grip: %.2f" % mat.friction
_friction_label.tooltip_text = "Higher values make sliding harder."
_bounce_label.text = "Bounce: %.2f" % mat.bounce
func _on_preset_selected(index: int) -> void:
_material_preset = clampi(index, 0, PRESET_LABELS.size() - 1)
_update_physics_labels()
_update_save_status()
_mark_dirty()
func _rebuild_primitive_params() -> void:
@@ -1286,6 +1602,7 @@ func _refresh_inspector_from_selection() -> void:
func _on_canvas_shape_changed(_shapes: Array[Dictionary]) -> void:
_mark_dirty()
_refresh_inspector_from_selection()
_sync_mode_controls(_canvas.get_edit_mode())
_refresh_anchor_inspector()
_refresh_center_prop_button()
_sync_attach_preview()
@@ -1313,11 +1630,12 @@ func _refresh_center_prop_button() -> void:
_refresh_scale_buttons()
func _refresh_scale_buttons() -> void:
if _normalize_size_button == null:
if _size_for_stage_button == null:
return
var bounds: Rect2 = _canvas.get_shapes_bounds()
var bounds: Rect2 = _canvas.get_visible_shapes_bounds()
var extent: float = maxf(bounds.size.x, bounds.size.y)
_normalize_size_button.disabled = not _canvas.has_shape_geometry() or not is_finite(extent) or extent <= 0.0
_size_for_stage_button.disabled = not _canvas.has_shape_geometry() or not is_finite(extent) or extent <= 0.0
_size_for_stage_button.tooltip_text = "Add visible artwork to use Size for Stage." if _size_for_stage_button.disabled else "Compare this drawing with a standing stickman."
if _whole_prop_resize_button != null:
_whole_prop_resize_button.disabled = _canvas.get_edit_mode() != PropCanvasScript.EditMode.TRANSFORM or not _canvas.has_shape_geometry()
@@ -1381,6 +1699,25 @@ func _center_prop_on_origin(focus_view: bool = true) -> bool:
func _on_canvas_shape_selected(_idx: int) -> void:
_refresh_inspector_from_selection()
_sync_mode_controls(_canvas.get_edit_mode())
func _on_create_point_pressed() -> void:
if _canvas.get_selected_index() < 0:
return
_canvas.arm_create_point()
_sync_mode_buttons(_canvas.get_edit_mode())
_sync_mode_controls(_canvas.get_edit_mode())
_update_create_point_feedback()
func _update_create_point_feedback() -> void:
if _create_point_button == null or _create_point_hint == null or _canvas == null:
return
var armed: bool = _canvas.is_create_point_armed()
_create_point_button.text = "Click an Edge to Place Point" if armed else "📍 Create Point"
_create_point_button.tooltip_text = "Press Esc to cancel." if armed else "Add a point to the selected shape's edge."
_create_point_hint.text = "Click near an edge to place the point. Press Esc to cancel." if armed else "Adds a point where you click on the selected edge."
func _on_canvas_copy_requested(shape: Dictionary) -> void:
@@ -1422,6 +1759,8 @@ func _write_autosave() -> void:
func _clear_autosave() -> void:
_autosave_remaining = 0.0
if _stage_authoring:
return
if FileAccess.file_exists(AUTOSAVE_PATH):
DirAccess.remove_absolute(AUTOSAVE_PATH)
@@ -1449,8 +1788,11 @@ func _check_autosave_recovery() -> void:
dialog.title = "Restore Prop?"
dialog.dialog_text = "An unsaved prop was recovered. Restore it?"
dialog.ok_button_text = "🔄 Restore"
dialog.add_button("🗑️ Discard", false, "discard")
var discard_button: Button = dialog.add_button("🗑️ Discard", false, "discard")
dialog.theme = _ui_theme
_editor_visual_theme.style_button_role(dialog.get_ok_button(), &"primary", &"prop")
_editor_visual_theme.style_button_role(discard_button, &"destructive", &"prop")
_editor_visual_theme.style_button_role(dialog.get_cancel_button(), &"secondary", &"prop")
add_child(dialog)
dialog.confirmed.connect(func() -> void:
_apply_json_data(raw, true)
@@ -1469,7 +1811,7 @@ func _check_autosave_recovery() -> void:
# ---------------------------------------------------------------------------
func _build_json_data() -> Dictionary:
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data(), _anchors)
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data(), _anchors, _tags)
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
@@ -1479,6 +1821,8 @@ func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
if model.is_empty():
return "The file could not be read: unsupported version or malformed data."
_prop_name_edit.text = str(model.get("prop_name", ""))
_tags = model.get("tags", []) as Array[String]
_refresh_tag_controls()
_material_preset = int(model.get("material_preset", PropBlockScript.MaterialPreset.NONE))
if _preset_option != null:
_preset_option.selected = _material_preset
@@ -1500,6 +1844,9 @@ func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
func _save_to(path: String, with_thumbnail: bool = true) -> bool:
if _stage_size_preview_active:
_request_save_after_stage_size(MENU_SAVE)
return false
path = ContentPathsScript.canonicalize(path)
var ok: bool = PropFileScript.save(path, _build_json_data())
if not ok:
@@ -1507,7 +1854,8 @@ func _save_to(path: String, with_thumbnail: bool = true) -> bool:
return false
_current_path = path
_dirty = false
_clear_autosave()
if not _stage_authoring:
_clear_autosave()
_push_recent(path)
_update_save_status()
if with_thumbnail:
@@ -1554,21 +1902,27 @@ func _execute_pending_save(center_first: bool) -> void:
elif _pending_new:
_pending_new = false
_do_new()
elif _stage_return_pending:
_stage_return_pending = false
artwork_saved.emit(_current_path)
return_requested.emit()
func _cancel_pending_save_request() -> void:
_pending_save_path = ""
_pending_save_with_thumbnail = true
_pending_quit = false
_pending_new = false
_stage_return_pending = false
func _on_save_file_selected(path: String) -> void:
path = ContentPathsScript.canonicalize(path)
if path.get_extension().to_lower() != "prop":
path = path.get_basename() + ".prop"
_begin_save_request(path, not (_pending_quit or _pending_new))
_begin_save_request(path, not (_pending_quit or _pending_new or _stage_return_pending))
func _load_from(path: String) -> void:
_cancel_stage_size_preview()
path = ContentPathsScript.canonicalize(path)
var raw: Dictionary = PropFileScript.load(path)
if raw.is_empty():
+19 -4
View File
@@ -2,7 +2,7 @@ class_name PropFile
extends RefCounted
## PropFile - Static schema + converters for .prop vector prop assets.
##
## The file stores the editor's full multi-shape stack (version "1.0") plus
## The file stores the editor's full multi-shape stack (version "1.1") plus
## enough provenance for later sandbox placement. flatten_to_payload() converts
## the stack into the single-shape payload PropUtils.spawn_prop() already
## consumes: frontmost closed polygon -> else circle -> else first shape.
@@ -12,10 +12,11 @@ const TerrainUtilsScript := preload("res://scripts/terrain/terrain_utils.gd")
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
const ContentPathsScript := preload("res://scripts/shared/content_paths.gd")
const FILE_VERSION: String = "1.0"
const FILE_VERSION: String = "1.1"
const FILE_EXT: String = ".prop"
const DEFAULT_DIR: String = "res://content/props"
const SUPPORTED_VERSIONS: Array[String] = ["1.0"]
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1"]
const KNOWN_TAGS: Array[String] = ["nature", "buildings", "walls", "roads", "objects"]
# index == PropBlock.MaterialPreset
const PRESET_STRINGS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"]
const SHAPE_STRINGS: PackedStringArray = ["polygon", "circle", "line"]
@@ -63,7 +64,7 @@ static func sanitize_shape(shape: Dictionary, grid_size: float = TerrainUtilsScr
return result
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary], anchors: Dictionary = {}) -> Dictionary:
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary], anchors: Dictionary = {}, tags: Array[String] = []) -> Dictionary:
var serialized: Array = []
for shape: Dictionary in shapes:
serialized.append(_serialize_shape(shape))
@@ -74,6 +75,7 @@ static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[
"material_preset": preset_to_string(material_preset),
"shapes": serialized,
"anchors": PropAnchorDataScript.serialize_anchors(anchors),
"tags": normalize_tags(tags),
"metadata": {"created_at": now, "modified_at": now},
}
@@ -98,6 +100,7 @@ static func from_file_dict(data: Variant) -> Dictionary:
return {}
var preset: int = preset_from_string(str(dict.get("material_preset", "none")))
var tags: Array[String] = normalize_tags(dict.get("tags", []))
var shapes: Array[Dictionary] = []
var raw_shapes: Variant = dict.get("shapes", [])
if raw_shapes is Array:
@@ -109,9 +112,21 @@ static func from_file_dict(data: Variant) -> Dictionary:
"material_preset": preset,
"shapes": shapes,
"anchors": PropAnchorDataScript.normalize_anchors(dict.get("anchors", {})),
"tags": tags,
}
static func normalize_tags(value: Variant) -> Array[String]:
var tags: Array[String] = []
if not value is Array:
return tags
for raw_tag: Variant in value as Array:
var tag: String = str(raw_tag).strip_edges().to_lower()
if KNOWN_TAGS.has(tag) and not tags.has(tag):
tags.append(tag)
return tags
## Deterministic conversion of the multi-shape stack into a single
## PropUtils.spawn_prop payload. Rule: frontmost closed polygon -> else circle
## -> else first shape. Returns {} when shapes is empty.
+20 -22
View File
@@ -10,6 +10,7 @@ extends RefCounted
## truth.
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
const EditorVisualThemeScript := preload("res://scripts/shared/editor_ui/editor_visual_theme.gd")
const SCHEMA_VERSION: String = "1.0"
const DEFAULT_PATH: String = "res://prop_theme.json"
@@ -39,19 +40,16 @@ const DEFAULT_CONFIG: Dictionary = {
"background_color": "#1F1F1F",
"grid_color": "#FFFFFF26",
},
"ui": {
"fonts": {"body": 18, "header": 24, "menu": 22, "menu_bar": 22, "mode": 26, "status": 18, "prop_name": 20, "context_menu": 22},
"hit_targets": {"button_min_height": 36, "row_min_height": 36, "mode_button_min_height": 56},
"colors": {"text": "#F2F2F2", "text_muted": "#9A9A9A", "border": "#FFFFFF33", "accent": "#4FA3FF", "inspector_background": "#262C39"},
},
}
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
var _warned: Dictionary = {}
var _editor_visual_theme: EditorVisualTheme = EditorVisualThemeScript.new()
func load_from(path: String = DEFAULT_PATH) -> bool:
invalidate_caches()
_editor_visual_theme.load_from(EditorVisualTheme.DEFAULT_PATH)
_config = DEFAULT_CONFIG.duplicate(true)
if not FileAccess.file_exists(path):
_warn_once(path, "PropTheme: '%s' was not found; using defaults." % path)
@@ -155,71 +153,71 @@ func canvas_grid_color() -> Color:
# ---------------------------------------------------------------------------
# Kid-friendly UI tokens (Round 2)
# Compatibility accessors: editor chrome is owned by EditorVisualTheme.
# ---------------------------------------------------------------------------
func font_body() -> int:
return integer("ui.fonts.body", 18, 8, 72)
return _editor_visual_theme.chrome_px(&"body", &"prop")
func font_header() -> int:
return integer("ui.fonts.header", 24, 8, 96)
return _editor_visual_theme.chrome_px(&"section", &"prop")
func font_menu() -> int:
return integer("ui.fonts.menu", 22, 8, 72)
return _editor_visual_theme.chrome_px(&"menu", &"prop")
func font_status() -> int:
return integer("ui.fonts.status", 18, 8, 72)
return _editor_visual_theme.chrome_px(&"status", &"prop")
func font_mode() -> int:
return integer("ui.fonts.mode", 26, 8, 96)
return _editor_visual_theme.chrome_px(&"mode", &"prop")
func font_menu_bar() -> int:
return integer("ui.fonts.menu_bar", 22, 8, 72)
return _editor_visual_theme.chrome_px(&"menu", &"prop")
func font_prop_name() -> int:
return integer("ui.fonts.prop_name", 20, 8, 96)
return _editor_visual_theme.chrome_px(&"prop_name", &"prop")
func font_context_menu() -> int:
return integer("ui.fonts.context_menu", 22, 8, 96)
return _editor_visual_theme.chrome_px(&"context_menu", &"prop")
func button_min_height() -> int:
return integer("ui.hit_targets.button_min_height", 36, 16, 200)
return _editor_visual_theme.chrome_px(&"button_min_height", &"prop")
func row_min_height() -> int:
return integer("ui.hit_targets.row_min_height", 36, 16, 200)
return _editor_visual_theme.chrome_px(&"row_min_height", &"prop")
func mode_button_min_height() -> int:
return integer("ui.hit_targets.mode_button_min_height", 56, 16, 200)
return _editor_visual_theme.chrome_px(&"mode_button_min_height", &"prop")
func text_color() -> Color:
return color("ui.colors.text", Color("#F2F2F2"))
return _editor_visual_theme.chrome_color(&"text", &"prop")
func text_muted_color() -> Color:
return color("ui.colors.text_muted", Color("#9A9A9A"))
return _editor_visual_theme.chrome_color(&"text_muted", &"prop")
func border_color() -> Color:
return color("ui.colors.border", Color(1.0, 1.0, 1.0, 0.2))
return _editor_visual_theme.chrome_color(&"border", &"prop")
func accent_color() -> Color:
return color("ui.colors.accent", Color("#4FA3FF"))
return _editor_visual_theme.chrome_color(&"edit", &"prop")
func inspector_background_color() -> Color:
return color("ui.colors.inspector_background", Color("#262C39"))
return _editor_visual_theme.chrome_color(&"surface", &"prop")
func invalidate_caches() -> void:
File diff suppressed because it is too large Load Diff
+14 -4
View File
@@ -4,6 +4,7 @@ extends Control
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
const SANDBOX_THEME := preload("res://scripts/stage/sandbox_theme.gd")
const STAGE_DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
const SPEECH_BUBBLE := preload("res://scripts/speech/stickman_speech_bubble.gd")
const FIVE_BUBBLE_HINT: String = "Long story? One at a time can be easier to follow."
const MAX_BUBBLE_HINT: String = "You can add up to eight bubbles."
@@ -510,7 +511,7 @@ func preview_elapsed() -> float:
func contains_viewport_point(point: Vector2) -> bool:
if _cancel_confirm_dialog != null and _cancel_confirm_dialog.visible:
if _cancel_confirm_dialog != null and STAGE_DIALOG_STYLER.is_open(_cancel_confirm_dialog):
return true
if is_message_dialog_open():
return true
@@ -608,7 +609,7 @@ func close_without_commit() -> void:
if _message_dialog != null:
_message_dialog.hide()
if _cancel_confirm_dialog != null:
_cancel_confirm_dialog.hide()
STAGE_DIALOG_STYLER.hide(_cancel_confirm_dialog)
visible = false
_replay_running = false
_actor = null
@@ -623,7 +624,8 @@ func set_cancel_requires_confirmation(required: bool) -> void:
func request_cancel_speech() -> void:
var changed: bool = _cancel_requires_confirmation or get_draft() != _initial_action
if changed and _cancel_confirm_dialog != null:
_cancel_confirm_dialog.popup_centered()
STAGE_DIALOG_STYLER.popup(_cancel_confirm_dialog)
STAGE_DIALOG_STYLER.visual_button(_cancel_confirm_dialog.get_cancel_button()).grab_focus()
return
cancelled.emit()
@@ -710,13 +712,21 @@ func _build_ui() -> void:
_active_editor.text_changed.connect(_on_active_text_changed)
_active_editor.caret_changed.connect(_remember_active_editor_state)
_cancel_confirm_dialog = ConfirmationDialog.new()
_cancel_confirm_dialog.title = "Discard speech changes?"
_cancel_confirm_dialog.title = "🗣️ Discard speech changes?"
_cancel_confirm_dialog.dialog_text = "Your speech edits will be lost. Do you want to discard them?"
_cancel_confirm_dialog.get_ok_button().text = "Discard changes"
_cancel_confirm_dialog.get_cancel_button().text = "Keep editing"
_cancel_confirm_dialog.min_size = Vector2i(560, 300)
_cancel_confirm_dialog.confirmed.connect(func() -> void: cancelled.emit())
_cancel_confirm_dialog.dialog_hide_on_ok = true
add_child(_cancel_confirm_dialog)
STAGE_DIALOG_STYLER.apply(
_cancel_confirm_dialog,
_theme,
_theme.font(),
_cancel_confirm_dialog.get_cancel_button(),
[_cancel_confirm_dialog.get_ok_button()]
)
_build_message_dialog()
+142 -7
View File
@@ -6,6 +6,12 @@ signal asset_selected_requested(entry: Dictionary)
signal asset_load_failed(kind: String, path: String)
signal selector_cancel_requested
signal refresh_requested
signal make_artwork_requested
signal edit_scenery_requested
signal surface_color_confirmed(color: Color)
signal plain_color_preview_requested(color: Color)
signal plain_color_commit_requested(color: Color)
signal plain_color_preview_cancel_requested()
const ASSET_SELECTOR_SCENE := preload("res://scenes/stage/asset_selector.tscn")
const STICKMAN_LIBRARY_SCRIPT := preload("res://scripts/characters/stickman/stickman_library.gd")
@@ -29,6 +35,9 @@ var browse_dialog: FileDialog
var thumbnail_queue: Array[Dictionary] = []
var thumbnail_busy: bool = false
var thumbnail_generation: int = 0
var _current_background_kind: StringName = &"sky"
var _current_sky_id: StringName = &"sky_neutral"
var _current_plain_color: Color = Color("#F5EFE0")
func _ready() -> void:
@@ -44,7 +53,8 @@ func build_ui(ui_layer: CanvasLayer, ui_font: Font, emoji_font: Font, sandbox_th
selector_dim = ColorRect.new()
selector_dim.name = "SelectorDim"
selector_dim.color = Color(0.0, 0.0, 0.0, SELECTOR_DIM_ALPHA)
selector_dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
# Keep clicks outside the modal from reaching the Stage canvas beneath it.
selector_dim.mouse_filter = Control.MOUSE_FILTER_STOP
selector_dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
selector_dim.visible = false
ui_layer.add_child(selector_dim)
@@ -52,9 +62,16 @@ func build_ui(ui_layer: CanvasLayer, ui_font: Font, emoji_font: Font, sandbox_th
selector = ASSET_SELECTOR_SCENE.instantiate() as AssetSelector
selector.item_selected.connect(func(entry: Dictionary) -> void: asset_selected_requested.emit(entry))
selector.cancelled.connect(_on_selector_cancelled)
selector.popup_hide.connect(_on_selector_cancelled)
selector.popup_hide.connect(_on_selector_popup_hide)
selector.browse_requested.connect(show_browse_dialog)
selector.refresh_requested.connect(func() -> void: refresh_requested.emit())
selector.make_artwork_requested.connect(func() -> void: make_artwork_requested.emit())
selector.edit_scenery_requested.connect(func() -> void: edit_scenery_requested.emit())
selector.surface_color_confirmed.connect(func(color: Color) -> void: surface_color_confirmed.emit(color))
selector.plain_color_preview_requested.connect(func(color: Color) -> void: plain_color_preview_requested.emit(color))
selector.plain_color_commit_requested.connect(func(color: Color) -> void: plain_color_commit_requested.emit(color))
selector.plain_color_preview_cancel_requested.connect(func() -> void: plain_color_preview_cancel_requested.emit())
selector.stage_preview_dimming_changed.connect(_on_selector_stage_preview_dimming_changed)
ui_layer.add_child(selector)
# apply_font AFTER add_child so the selector's @onready nodes resolve first.
selector.apply_font(ui_font, emoji_font)
@@ -73,24 +90,98 @@ func build_ui(ui_layer: CanvasLayer, ui_font: Font, emoji_font: Font, sandbox_th
func scan_entries(kind: String) -> Array[Dictionary]:
if kind == "stickman":
return stickman_library.scan()
if kind == "scenery":
return PROP_LIBRARY_SCRIPT.scan_visual_entries()
if kind == "starters":
return _starter_scene_entries()
return PROP_LIBRARY_SCRIPT.scan()
func set_surface_color_history(colors: Array[Color]) -> void:
if selector != null:
selector.set_surface_color_history(colors)
func set_background_state(kind: StringName, sky_id: StringName, plain_color: Color) -> void:
_current_background_kind = &"plain" if kind == &"plain" else &"sky"
_current_sky_id = sky_id
_current_plain_color = Color(plain_color.r, plain_color.g, plain_color.b, 1.0)
if selector != null:
selector.set_background_state(_current_background_kind, _current_sky_id, _current_plain_color)
func _starter_scene_entries() -> Array[Dictionary]:
return [
{
"id": "starter_plain_background",
"name": "Plain Background",
"description": "An empty stage with one color. Choose a new color whenever you like.",
"category": "Make your own",
"path": "res://content/stages/starters/plain_background.scene",
"icon": "🎨",
},
{
"id": "starter_blank_scene",
"name": "Open Sky",
"description": "An empty stage with a soft sky. Sun and clouds, ready for your story.",
"category": "Make your own",
"path": "res://content/stages/starters/blank.scene",
"icon": "✨",
},
{
"id": "starter_town_street",
"name": "Town Street",
"description": "Cloudy sky, shops, trees, sidewalk, and road.",
"category": "Ready to explore",
"path": "res://content/stages/starters/town_street.scene",
"icon": "🏘️",
},
{
"id": "starter_living_room",
"name": "Living Room",
"description": "A cozy room with a window, rug, and furniture.",
"category": "Ready to explore",
"path": "res://content/stages/starters/living_room.scene",
"icon": "🛋️",
},
]
func make_browse_entry(kind: String, path: String) -> Dictionary:
if kind == "prop":
return PROP_LIBRARY_SCRIPT.make_custom_entry(path)
if kind == "scenery":
return PROP_LIBRARY_SCRIPT.make_visual_entry(path)
return stickman_library.make_entry(path)
func open_selector(kind: String, current_stickman_path: String) -> Dictionary:
func open_selector(
kind: String,
current_stickman_path: String,
current_sky_id: StringName = &"",
background_kind: StringName = &"sky",
plain_color: Color = Color("#F5EFE0"),
preserve_scenery_browse_state: bool = false
) -> Dictionary:
var scenery_browse_state: Dictionary = {}
if preserve_scenery_browse_state and kind == "scenery" and selector != null:
scenery_browse_state = selector.capture_browse_state()
_current_background_kind = &"plain" if background_kind == &"plain" else &"sky"
_current_sky_id = current_sky_id if not current_sky_id.is_empty() else &"sky_neutral"
_current_plain_color = Color(plain_color.r, plain_color.g, plain_color.b, 1.0)
set_selector_kind(kind)
var entries: Array[Dictionary] = scan_entries(kind)
if kind == "starters":
open(kind, entries)
return {"entries": entries, "selected_stickman_path": current_stickman_path, "auto_selection": {}}
if kind == "scenery":
entries.append_array(_scenery_background_entries())
var selected_stickman_path: String = current_stickman_path
if kind == "stickman" and not entries.is_empty() and stickman_library.find_by_path(current_stickman_path).is_empty():
selected_stickman_path = String(entries[0].get("path", ""))
var auto_selection: Dictionary = entries[0] if kind == "stickman" and entries.size() == 1 else {}
if auto_selection.is_empty():
open(kind, entries)
open(kind, entries, _current_sky_id, scenery_browse_state)
return {
"entries": entries,
"selected_stickman_path": selected_stickman_path,
@@ -98,17 +189,35 @@ func open_selector(kind: String, current_stickman_path: String) -> Dictionary:
}
func _scenery_background_entries() -> Array[Dictionary]:
return [
{"id": "plain_background", "name": "Plain Background", "category": "Scene background", "gallery_tab": "backgrounds", "background_kind": "plain", "plain_color": _current_plain_color, "icon": "🎨"},
{"id": "sky_clear_day", "name": "Clear Day", "category": "Sky", "gallery_tab": "backgrounds", "background_kind": "sky", "icon": "☀️"},
{"id": "sky_cloudy_day", "name": "Cloudy Day", "category": "Sky", "gallery_tab": "backgrounds", "background_kind": "sky", "icon": "☁️"},
{"id": "sky_sunset", "name": "Sunset", "category": "Sky", "gallery_tab": "backgrounds", "background_kind": "sky", "icon": "🌅"},
{"id": "sky_starry_night", "name": "Starry Night", "category": "Sky", "gallery_tab": "backgrounds", "background_kind": "sky", "icon": "🌙"},
{"id": "wall_panel_setup", "name": "Wall panel", "category": "Scenery", "gallery_tab": "backgrounds", "background_kind": "wall_setup", "icon": "🧱"},
{"id": "background_ground_setup", "name": "Background ground", "category": "Scenery", "gallery_tab": "backgrounds", "background_kind": "background_ground_setup", "icon": "🌿"}
]
func set_selector_kind(kind: String) -> void:
selector_kind = kind
func open(kind: String, entries: Array[Dictionary]) -> void:
func open(
kind: String,
entries: Array[Dictionary],
current_sky_id: StringName = &"",
scenery_browse_state: Dictionary = {}
) -> void:
selector_kind = kind
selector_open = true
if selector_dim != null:
selector_dim.visible = true
selector_dim.color = Color(0.0, 0.0, 0.0, SELECTOR_DIM_ALPHA)
if selector != null:
selector.open(kind, entries)
selector.open(kind, entries, String(current_sky_id), String(_current_background_kind), _current_plain_color, scenery_browse_state)
load_or_enqueue_thumbnails(kind, entries)
@@ -126,7 +235,7 @@ func close() -> void:
func show_browse_dialog() -> void:
if browse_dialog == null:
return
if selector_kind == "prop":
if selector_kind == "prop" or selector_kind == "scenery":
browse_dialog.title = "Open .prop"
browse_dialog.filters = PackedStringArray(["*.prop ; Prop Files"])
else:
@@ -143,6 +252,8 @@ func set_entries(entries: Array[Dictionary]) -> void:
func refresh_selector_entries() -> Array[Dictionary]:
var entries: Array[Dictionary] = scan_entries(selector_kind)
if selector_kind == "scenery":
entries.append_array(_scenery_background_entries())
set_entries(entries)
return entries
@@ -160,6 +271,8 @@ func load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> voi
thumbnail_generation += 1
var generation: int = thumbnail_generation
for entry: Dictionary in entries:
if kind == "starters":
continue
if kind == "stickman":
var path: String = String(entry.get("path", ""))
var key: String = thumbnail_cache.stickman_key(path)
@@ -171,6 +284,8 @@ func load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> voi
else:
thumbnail_queue.append({"kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation})
else:
if kind == "starters" or String(entry.get("gallery_tab", "")) == "backgrounds":
continue
var id: String = String(entry.get("id", ""))
var png: String = thumbnail_cache.prop_file_png(id) if bool(entry.get("is_custom", false)) else thumbnail_cache.prop_png(id)
var texture: Texture2D = thumbnail_cache.get_texture(png)
@@ -215,6 +330,26 @@ func _on_selector_cancelled() -> void:
selector_cancel_requested.emit()
func _on_selector_stage_preview_dimming_changed(should_dim: bool) -> void:
if selector_dim == null:
return
selector_dim.color = Color(0.0, 0.0, 0.0, SELECTOR_DIM_ALPHA if should_dim else 0.0)
selector_dim.visible = selector_open
func _on_selector_popup_hide() -> void:
if not selector_open:
return
# PopupPanel can hide itself when it loses focus. Keep the chooser alive so an
# outside click cannot silently cancel the user's scenery setup.
call_deferred("_restore_selector_after_unexpected_hide")
func _restore_selector_after_unexpected_hide() -> void:
if selector_open and selector != null and not selector.visible:
selector.popup_centered()
func get_selector() -> AssetSelector:
return selector
+60 -2
View File
@@ -27,6 +27,7 @@ signal reset_requested
const TOP_BAR_HEIGHT: float = 64.0
const STATUS_BAR_HEIGHT: float = 38.0
const COMPACT_TOP_BAR_WIDTH: float = 900.0
var _stage_theme: SandboxTheme
var _current_mode: int = 0
@@ -43,6 +44,8 @@ var _mode_buttons: Dictionary = {}
var _palette_buttons: Dictionary = {}
var _palette_dock: PanelContainer
var _file_menu: MenuButton
var _mode_center: CenterContainer
var _right_controls: HBoxContainer
var _status_label: Label
var _status_cursor_coords: Label
var _direct_hint_label: Label
@@ -395,15 +398,23 @@ func _build_top_bar() -> void:
chrome.add_child(left_group)
var right_group := HBoxContainer.new()
right_group.name = "RightControls"
right_group.mouse_filter = Control.MOUSE_FILTER_PASS
right_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
# This row spans the top bar so it can align controls at the right edge;
# ignore empty space so it cannot steal clicks from File or Mode buttons.
right_group.mouse_filter = Control.MOUSE_FILTER_IGNORE
right_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
right_group.offset_left = 8.0
right_group.offset_right = -8.0
right_group.offset_top = 8.0
right_group.offset_bottom = TOP_BAR_HEIGHT - 8.0
right_group.alignment = BoxContainer.ALIGNMENT_END
right_group.add_theme_constant_override("separation", 8)
_right_controls = right_group
chrome.add_child(right_group)
_build_file_menu(left_group)
_build_mode_switcher(chrome)
_build_grid_controls(right_group)
get_viewport().size_changed.connect(_apply_responsive_top_bar_layout)
_apply_responsive_top_bar_layout()
func _build_file_menu(parent: HBoxContainer) -> void:
@@ -437,6 +448,7 @@ func _build_mode_switcher(parent: Control) -> void:
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
center.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
center.offset_bottom = TOP_BAR_HEIGHT
_mode_center = center
parent.add_child(center)
var switcher := HBoxContainer.new()
switcher.name = "ModeSwitcher"
@@ -487,6 +499,52 @@ func _build_grid_controls(parent: HBoxContainer) -> void:
parent.add_child(_grid_size_spin)
func _apply_responsive_top_bar_layout() -> void:
if _file_menu == null or _mode_center == null or _right_controls == null:
return
var viewport_width: float = get_viewport().get_visible_rect().size.x
var compact: bool = viewport_width <= COMPACT_TOP_BAR_WIDTH
if compact:
_file_menu.custom_minimum_size = Vector2(64.0, 48.0)
_file_menu.add_theme_font_size_override("font_size", 18)
_mode_center.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
_mode_center.offset_left = 76.0
_mode_center.offset_top = 8.0
_mode_center.offset_right = 342.0
_mode_center.offset_bottom = TOP_BAR_HEIGHT - 8.0
_right_controls.add_theme_constant_override("separation", 4)
_grid_check.add_theme_font_size_override("font_size", 16)
_snap_check.add_theme_font_size_override("font_size", 16)
_grid_size_label.visible = false
_grid_size_spin.custom_minimum_size = Vector2(56.0, 0.0)
_grid_size_spin.add_theme_font_size_override("font_size", 16)
else:
_file_menu.custom_minimum_size = Vector2(78.0, 48.0)
_file_menu.add_theme_font_size_override(
"font_size", _theme_integer("fonts.sizes.file_menu", 22, 12, 48)
)
_mode_center.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
_mode_center.offset_bottom = TOP_BAR_HEIGHT
_right_controls.add_theme_constant_override("separation", 8)
_grid_check.remove_theme_font_size_override("font_size")
_snap_check.remove_theme_font_size_override("font_size")
_grid_size_label.visible = true
_grid_size_spin.custom_minimum_size = Vector2.ZERO
_grid_size_spin.remove_theme_font_size_override("font_size")
for mode: int in _mode_buttons:
var button: Button = _mode_buttons[mode] as Button
if compact:
button.custom_minimum_size = Vector2(86.0, 48.0)
button.add_theme_font_size_override("font_size", 18)
else:
button.custom_minimum_size = Vector2(
_theme_number("chrome.mode_switcher.segment_min_width", 124.0, 90.0, 220.0), 48.0
)
button.add_theme_font_size_override(
"font_size", _theme_integer("fonts.sizes.mode_switcher", 24, 12, 48)
)
func _build_palette(entries: Array[Dictionary]) -> void:
_palette_dock = PanelContainer.new()
_palette_dock.name = "PaletteDock"
+57 -48
View File
@@ -11,6 +11,7 @@ const SPEECH_SHOW_TOOLBAR_SCRIPT := preload("res://scripts/speech/speech_show_to
const WAYPOINT_CONTEXT_SCRIPT := preload("res://scripts/stage/waypoint_context.gd")
const PROP_LIBRARY_SCRIPT := preload("res://scripts/props/prop_library.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
signal object_selected(node: Node2D)
@@ -22,11 +23,13 @@ var wait_spin: SpinBox
var scene_open_dialog: FileDialog
var scene_save_dialog: FileDialog
var speech_save_gate_dialog: ConfirmationDialog
var group_preview_save_dialog: ConfirmationDialog
var rename_scene_dialog: ConfirmationDialog
var scene_name_edit: LineEdit
var missing_assets_dialog: AcceptDialog
var cli_error_dialog: AcceptDialog
var unsaved_changes_dialog: ConfirmationDialog
var unsaved_discard_button: Button
var rig_context_popup: PopupMenu
var waypoint_context: PopupMenu
var confirm_dialog: ConfirmationDialog
@@ -50,7 +53,7 @@ func configure(sandbox_theme: SandboxTheme, ui_font: Font) -> void:
func build_action_dialogs(parent: Node) -> void:
speak_dialog = AcceptDialog.new()
speak_dialog.title = "Speak"
speak_dialog.title = "🗣️ Speak"
_speak_box = VBoxContainer.new()
speak_dialog.add_child(_speak_box)
speak_edit = LineEdit.new()
@@ -66,7 +69,7 @@ func build_action_dialogs(parent: Node) -> void:
apply_dialog_theme(speak_dialog)
wait_dialog = AcceptDialog.new()
wait_dialog.title = "Wait"
wait_dialog.title = "⏱️ Wait"
wait_spin = SpinBox.new()
wait_spin.min_value = 0.1
wait_spin.max_value = 10.0
@@ -96,49 +99,73 @@ func build_file_dialogs(parent: Node) -> void:
speech_save_gate_dialog = ConfirmationDialog.new()
speech_save_gate_dialog.name = "FinishSpeechBeforeSaveDialog"
speech_save_gate_dialog.title = "Finish speech before saving"
speech_save_gate_dialog.title = "✏️ Finish speech before saving"
speech_save_gate_dialog.dialog_text = "Choose Done speech to finish editing before the scene is saved."
speech_save_gate_dialog.ok_button_text = "Done speech"
speech_save_gate_dialog.get_cancel_button().text = "Keep editing"
parent.add_child(speech_save_gate_dialog)
apply_dialog_theme(speech_save_gate_dialog)
group_preview_save_dialog = ConfirmationDialog.new()
group_preview_save_dialog.name = "GroupPreviewSaveDialog"
group_preview_save_dialog.title = "🧱 Finish scenery preview?"
group_preview_save_dialog.dialog_text = "Keep the scenery changes before saving, or cancel this save."
group_preview_save_dialog.ok_button_text = "Keep changes"
group_preview_save_dialog.cancel_button_text = "Cancel save"
group_preview_save_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(560, 300))
parent.add_child(group_preview_save_dialog)
apply_dialog_theme(group_preview_save_dialog)
rename_scene_dialog = ConfirmationDialog.new()
rename_scene_dialog.name = "RenameSceneDialog"
rename_scene_dialog.title = "Rename Scene"
rename_scene_dialog.title = "📝 Rename Scene"
rename_scene_dialog.ok_button_text = "Save name"
rename_scene_dialog.cancel_button_text = "Cancel"
rename_scene_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
rename_scene_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(560, 300))
var scene_name_hint: Label = Label.new()
scene_name_hint.name = "SceneNameHint"
scene_name_hint.text = "Choose a short name that helps you find this scene later."
scene_name_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
scene_name_hint.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var scene_name_label: Label = Label.new()
scene_name_label.name = "SceneNameLabel"
scene_name_label.text = "Scene name"
scene_name_edit = LineEdit.new()
scene_name_edit.name = "SceneNameEdit"
scene_name_edit.placeholder_text = "Scene name"
scene_name_edit.custom_minimum_size = Vector2(460.0, 0.0)
scene_name_edit.custom_minimum_size = Vector2(460.0, 48.0)
rename_scene_dialog.add_child(scene_name_hint)
rename_scene_dialog.add_child(scene_name_label)
rename_scene_dialog.add_child(scene_name_edit)
parent.add_child(rename_scene_dialog)
apply_dialog_theme(rename_scene_dialog)
missing_assets_dialog = AcceptDialog.new()
missing_assets_dialog.name = "MissingAssetsDialog"
missing_assets_dialog.title = "Missing Scene Assets"
missing_assets_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
missing_assets_dialog.title = "⚠️ Some scene assets are missing"
missing_assets_dialog.ok_button_text = "Got it"
missing_assets_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(560, 300))
parent.add_child(missing_assets_dialog)
apply_dialog_theme(missing_assets_dialog)
cli_error_dialog = AcceptDialog.new()
cli_error_dialog.name = "CliErrorDialog"
cli_error_dialog.title = "Could Not Load Scene"
cli_error_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
cli_error_dialog.title = "⚠️ Could not load scene"
cli_error_dialog.ok_button_text = "Got it"
cli_error_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(560, 300))
parent.add_child(cli_error_dialog)
apply_dialog_theme(cli_error_dialog)
unsaved_changes_dialog = ConfirmationDialog.new()
unsaved_changes_dialog.name = "UnsavedChangesDialog"
unsaved_changes_dialog.title = "⚠️ Unsaved Changes"
unsaved_changes_dialog.title = "💾 Unsaved Changes"
unsaved_changes_dialog.dialog_text = "Would you like to save your scene before continuing?"
unsaved_changes_dialog.ok_button_text = "💾 Save"
unsaved_changes_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
unsaved_changes_dialog.add_button("🗑️ Discard", false, "discard")
unsaved_changes_dialog.ok_button_text = "Save scene"
unsaved_changes_dialog.cancel_button_text = "Cancel"
unsaved_changes_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(560, 300))
unsaved_discard_button = unsaved_changes_dialog.add_button("Discard", false, "discard")
parent.add_child(unsaved_changes_dialog)
apply_dialog_theme(unsaved_changes_dialog)
apply_dialog_theme(unsaved_changes_dialog, unsaved_changes_dialog.get_ok_button(), [unsaved_discard_button])
func build_context_and_editor_views(
@@ -158,7 +185,11 @@ func build_context_and_editor_views(
parent.add_child(waypoint_context)
confirm_dialog = ConfirmationDialog.new()
confirm_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
confirm_dialog.title = "❔ Confirm this change"
confirm_dialog.dialog_text = "This action updates the current scene. Do you want to continue?"
confirm_dialog.ok_button_text = "Continue"
confirm_dialog.cancel_button_text = "Cancel"
confirm_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(560, 300))
parent.add_child(confirm_dialog)
apply_dialog_theme(confirm_dialog)
@@ -185,18 +216,20 @@ func build_context_and_editor_views(
parent.add_child(speech_show_toolbar)
speech_recovery_dialog = ConfirmationDialog.new()
speech_recovery_dialog.title = "Speech draft found"
speech_recovery_dialog.dialog_text = "Restore your unsaved speech draft?"
speech_recovery_dialog.get_ok_button().text = "Restore"
speech_recovery_dialog.get_cancel_button().text = "Discard"
speech_recovery_dialog.title = "🗣️ Speech draft found"
speech_recovery_dialog.dialog_text = "There is an unsaved speech draft. Would you like to restore it?\n\nℹ️ Restore brings back the draft so you can continue editing."
speech_recovery_dialog.get_ok_button().text = "Restore draft"
speech_recovery_dialog.get_cancel_button().text = "Discard draft"
parent.add_child(speech_recovery_dialog)
apply_dialog_theme(speech_recovery_dialog, speech_recovery_dialog.get_ok_button(), [speech_recovery_dialog.get_cancel_button()])
speech_destination_dialog = ConfirmationDialog.new()
speech_destination_dialog.title = "Save recovered speech"
speech_destination_dialog.title = "🎭 Save recovered speech"
speech_destination_dialog.dialog_text = "Add this speech to the chosen character’s action queue?"
speech_destination_dialog.get_ok_button().text = "Add to queue"
speech_destination_dialog.get_cancel_button().text = "Keep editing"
parent.add_child(speech_destination_dialog)
apply_dialog_theme(speech_destination_dialog)
func show_objects_here(
@@ -299,35 +332,11 @@ func theme_dialog_size(key: String, fallback: Vector2i) -> Vector2i:
return fallback
## Dialogs are separate native/embedded windows, so apply local visual overrides.
func apply_dialog_theme(dialog: Window) -> void:
## Keep the native dialog objects as signal sources and draw their shared surface in the stage CanvasLayer.
func apply_dialog_theme(dialog: Window, primary_button: Button = null, destructive_buttons: Array = []) -> void:
if dialog == null:
return
var size: int = _sandbox_theme.integer("chrome.dialogs.font_size", 22, 12, 48) if _sandbox_theme != null else 22
var message_size: int = _sandbox_theme.integer("chrome.dialogs.message_font_size", 26, 12, 64) if _sandbox_theme != null else 26
dialog.add_theme_font_size_override("font_size", size)
if _ui_font != null:
dialog.add_theme_font_override("font", _ui_font)
if dialog is AcceptDialog:
var message: Label = (dialog as AcceptDialog).get_label()
if message != null:
message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
message.add_theme_font_size_override("font_size", message_size)
if _ui_font != null:
message.add_theme_font_override("font", _ui_font)
var ok: Button = (dialog as AcceptDialog).get_ok_button()
if ok != null:
ok.add_theme_font_size_override("font_size", size)
if _ui_font != null:
ok.add_theme_font_override("font", _ui_font)
if dialog is ConfirmationDialog:
var cancel: Button = (dialog as ConfirmationDialog).get_cancel_button()
if cancel != null:
cancel.add_theme_font_size_override("font_size", size)
if _ui_font != null:
cancel.add_theme_font_override("font", _ui_font)
_apply_dialog_font_to_children(dialog, size)
DIALOG_STYLER.apply(dialog, _sandbox_theme, _ui_font, primary_button, destructive_buttons)
func apply_dialog_font_to_children(node: Node, size: int) -> void:
+13 -2
View File
@@ -4,6 +4,7 @@ extends RefCounted
const STICKMAN_RIG_SCRIPT := preload("res://scripts/characters/stickman/stickman_rig.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const STAGE_SCENERY_ARTWORK := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
const STATE_STOPPED: int = 0
const STATE_RUNNING: int = 1
@@ -224,6 +225,12 @@ func capture_prop_pause_state(body: RigidBody2D) -> Dictionary:
func save_object_state(node: Node2D) -> void:
# Scenery artwork is editor-authored and does not move during simulation.
# Its live transform is the document state, so a pre-PLAY snapshot must not
# restore an old position when the user returns to EDIT.
if node is STAGE_SCENERY_ARTWORK:
authored_state.erase(node.get_instance_id())
return
var authored_rotation: float = node.global_rotation
if node is STICKMAN_RIG_SCRIPT and (node as StickmanRig).get_facing_profile() == StickmanRig.FacingProfile.LEFT:
# LEFT is a derived root reflection. Remove it before persisting the
@@ -252,8 +259,12 @@ func clear_object_state(node: Node2D) -> void:
func restore_authored_state(stage_mode_name: String, run_state_name: String, camera_zoom: Vector2) -> void:
for id: int in authored_state.keys():
var entry: Dictionary = authored_state[id]
var node: Node2D = entry.get("node") as Node2D
if node == null or not is_instance_valid(node):
var node_value: Variant = entry.get("node")
if not is_instance_valid(node_value) or not node_value is Node2D:
authored_state.erase(id)
continue
var node: Node2D = node_value as Node2D
if node is STAGE_SCENERY_ARTWORK:
authored_state.erase(id)
continue
node.global_position = entry.get("position", node.global_position)
+87 -4
View File
@@ -10,6 +10,10 @@ const MISSING_PROP_PLACEHOLDER := preload("res://scripts/props/missing_prop_plac
const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd")
const STAGE_WALL_PANEL := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const STAGE_BACKGROUND_GROUND := preload("res://scripts/stage/scenery/stage_background_ground.gd")
const STAGE_SCENERY_ARTWORK := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
const STAGE_DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
func collect(
@@ -21,7 +25,7 @@ func collect(
) -> Dictionary:
var objects: Array[Dictionary] = []
var runtime_to_file: Dictionary = {}
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0, "wall_panel": 0, "background_ground": 0, "scenery_artwork": 0}
for node: Node in world.get_children():
if not node is Node2D:
continue
@@ -33,8 +37,12 @@ func collect(
var file_id: String = "%s_%d" % [type, int(counters[type])]
runtime_to_file[stage_node.get_instance_id()] = file_id
var authored_state: Dictionary = authored.get(stage_node.get_instance_id(), {})
var position: Vector2 = authored_state.get("position", stage_node.global_position)
var rotation_degrees: float = rad_to_deg(float(authored_state.get("rotation", stage_node.global_rotation)))
# Scenery artwork is editor-authored and never receives runtime physics
# movement. Its live transform is therefore the committed document state;
# a cached pre-PLAY authored snapshot would erase later group edits.
var position: Vector2 = stage_node.global_position if type == "scenery_artwork" else authored_state.get("position", stage_node.global_position)
var rotation: float = stage_node.global_rotation if type == "scenery_artwork" else float(authored_state.get("rotation", stage_node.global_rotation))
var rotation_degrees: float = rad_to_deg(rotation)
var entry: Dictionary = {
"file_id": file_id,
"type": type,
@@ -44,6 +52,32 @@ func collect(
if type == "terrain":
entry["asset_id"] = String(stage_node.get("spawn_id"))
entry["scale"] = STAGE_SCENE_IO.vector_to_data(stage_node.scale)
if stage_node is TerrainBlock and String(stage_node.get("spawn_id")) == "ground":
var ground_finish: StringName = (stage_node as TerrainBlock).finish_id
entry["finish_id"] = String(ground_finish) if not ground_finish.is_empty() else "finish_ground_legacy"
elif stage_node.get_script() == STAGE_BACKGROUND_GROUND:
var background: StageBackgroundGround = stage_node as StageBackgroundGround
entry["size"] = STAGE_SCENE_IO.vector_to_data(background.panel_size)
entry["finish_id"] = String(background.finish_id)
entry["color"] = "#" + background.tint.to_html(true)
if not background.motif_origin_offset.is_zero_approx():
entry["motif_origin_offset"] = STAGE_SCENE_IO.vector_to_data(background.motif_origin_offset)
elif stage_node.get_script() == STAGE_WALL_PANEL:
var wall: StageWallPanel = stage_node as StageWallPanel
entry["size"] = STAGE_SCENE_IO.vector_to_data(wall.panel_size)
entry["finish_id"] = String(wall.finish_id)
entry["color"] = "#" + wall.tint.to_html(true)
if not wall.motif_origin_offset.is_zero_approx():
entry["motif_origin_offset"] = STAGE_SCENE_IO.vector_to_data(wall.motif_origin_offset)
elif stage_node.get_script() == STAGE_SCENERY_ARTWORK:
var artwork: StageSceneryArtwork = stage_node as StageSceneryArtwork
entry["asset_id"] = CONTENT_PATHS.canonicalize(artwork.asset_path)
entry["display_name"] = artwork.display_name
entry["depth"] = String(artwork.depth)
entry["scale"] = STAGE_SCENE_IO.vector_to_data(artwork.scale)
if artwork.has_known_bounds:
var known_bounds: Rect2 = artwork.get_saved_footprint()
entry["last_known_local_bounds"] = {"position": STAGE_SCENE_IO.vector_to_data(known_bounds.position), "size": STAGE_SCENE_IO.vector_to_data(known_bounds.size)}
elif type == "prop" and stage_node.get_script() == MISSING_PROP_PLACEHOLDER:
var original_entry: Dictionary = stage_node.get("original_entry") as Dictionary
entry = original_entry.duplicate(true)
@@ -95,6 +129,9 @@ func collect(
return {
"version": STAGE_SCENE_IO.FILE_VERSION,
"scene_name": scene_name,
"sky_id": String(document_metadata.get("sky_id", "sky_neutral")),
"background_kind": String(document_metadata.get("background_kind", "sky")),
"plain_color": String(document_metadata.get("plain_color", STAGE_SCENE_IO.DEFAULT_PLAIN_COLOR)),
"camera": {"position": STAGE_SCENE_IO.vector_to_data(camera_position), "zoom": camera_zoom},
"objects": objects,
"event_rules": rules_result["rules"],
@@ -131,6 +168,41 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
match type:
"terrain":
node = spawner.spawn_terrain_asset(String(entry.get("asset_id", "")), position)
if node is TerrainBlock and String(entry.get("asset_id", "")) == "ground":
(node as TerrainBlock).set_finish(StringName(String(entry.get("finish_id", "finish_ground_legacy"))))
"wall_panel":
var wall := STAGE_WALL_PANEL.new() as StageWallPanel
wall.position = position
wall.configure(STAGE_SCENE_IO.data_to_vector(entry.get("size"), Vector2(320.0, 400.0)), StringName(String(entry.get("finish_id", "finish_wall_paint"))), STAGE_SCENE_IO.color_from_data(entry.get("color", "#5A9D70"), Color("#5A9D70")))
if entry.has("motif_origin_offset") and STAGE_SCENE_IO.is_vector_data(entry.get("motif_origin_offset")):
wall.motif_origin_offset = STAGE_SCENE_IO.data_to_vector(entry.get("motif_origin_offset"), Vector2.ZERO)
world.add_child(wall)
node = wall
"background_ground":
var background := STAGE_BACKGROUND_GROUND.new() as StageBackgroundGround
background.position = position
background.configure(
STAGE_SCENE_IO.data_to_vector(entry.get("size"), Vector2(800.0, 300.0)),
StringName(String(entry.get("finish_id", "finish_background_grass"))),
STAGE_SCENE_IO.color_from_data(entry.get("color", "#72A84CFF"), Color("#72A84C"))
)
if entry.has("motif_origin_offset") and STAGE_SCENE_IO.is_vector_data(entry.get("motif_origin_offset")):
background.motif_origin_offset = STAGE_SCENE_IO.data_to_vector(entry.get("motif_origin_offset"), Vector2.ZERO)
world.add_child(background)
node = background
"scenery_artwork":
var artwork := STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
var raw_bounds: Variant = entry.get("last_known_local_bounds", {})
var footprint := Rect2()
if raw_bounds is Dictionary and STAGE_SCENE_IO._valid_rect_data(raw_bounds):
var bounds_data: Dictionary = raw_bounds
footprint = Rect2(STAGE_SCENE_IO.data_to_vector(bounds_data.get("position"), Vector2.ZERO), STAGE_SCENE_IO.data_to_vector(bounds_data.get("size"), Vector2(64.0, 64.0)))
artwork.position = position
artwork.rotation = deg_to_rad(float(entry.get("rotation", 0.0)))
artwork.scale = STAGE_SCENE_IO.data_to_vector(entry.get("scale"), Vector2.ONE)
artwork.configure_missing(String(entry.get("asset_id", "")), String(entry.get("display_name", "Scenery")), footprint, StringName(String(entry.get("depth", "near"))))
world.add_child(artwork)
node = artwork
"prop":
node = spawner.spawn_prop_asset(String(entry.get("asset_id", "")), position)
if node == null and PROP_LIBRARY.is_custom_path(String(entry.get("asset_id", ""))):
@@ -158,7 +230,7 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
warnings.append("Unknown object asset")
continue
node.global_rotation = deg_to_rad(float(entry.get("rotation", 0.0)))
if type != "stickman":
if type not in ["stickman", "wall_panel", "scenery_artwork"]:
if entry.has("scale") and not STAGE_SCENE_IO.is_vector_data(entry["scale"]):
warnings.append("Malformed object scale")
world.remove_child(node)
@@ -216,6 +288,8 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
warnings.append("%s: invalid action queue. Open this character's queue and review its actions." % file_id)
(node as StickmanRig).reset_collision_proxy_velocity_baselines()
loaded_nodes.append(node)
if node is TerrainBlock:
STAGE_DRAW_ORDER.apply_terrain(node as TerrainBlock)
file_to_runtime[file_id] = node.get_instance_id()
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable)
return {
@@ -226,10 +300,19 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
"missing_stickmen": missing_stickmen,
"missing_paths": missing_paths,
"decoded_rules": decoded_rules,
"sky_id": String(data.get("sky_id", "sky_neutral")),
"background_kind": String(data.get("background_kind", "sky")),
"plain_color": String(data.get("plain_color", STAGE_SCENE_IO.DEFAULT_PLAIN_COLOR)),
}
func _object_type(node: Node2D) -> String:
if node.get_script() == STAGE_WALL_PANEL:
return "wall_panel"
if node.get_script() == STAGE_BACKGROUND_GROUND:
return "background_ground"
if node.get_script() == STAGE_SCENERY_ARTWORK:
return "scenery_artwork"
if node is TerrainBlock:
return "terrain"
if node is PropBlock or node.get_script() == MISSING_PROP_PLACEHOLDER:
@@ -0,0 +1,998 @@
class_name StageSceneryController
extends Node
## Owns scenery placement transactions, eraser transactions, and snapshot-based transforms.
signal placement_committed(node: Node2D)
signal document_change_requested
signal preview_state_changed(active: bool)
signal single_gesture_changed(active: bool)
const WALL_PANEL := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const BACKGROUND_GROUND := preload("res://scripts/stage/scenery/stage_background_ground.gd")
const RECT_SURFACE := preload("res://scripts/stage/scenery/stage_rect_surface.gd")
const SCENERY_ARTWORK := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
const PROP_FILE := preload("res://scripts/editors/prop/prop_file.gd")
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
const MIN_PANEL_SIZE: float = 24.0
const MIN_WALL_ERASE_SIZE: float = 8.0
const DEFAULT_PANEL_SIZE: Vector2 = Vector2(240.0, 280.0)
const WALL_ERASER_CURSOR_SIZE: Vector2 = Vector2(120.0, 120.0)
const MIN_ART_SCALE: float = 0.08
const MAX_ART_SCALE: float = 12.0
var _world: Node2D
var _ghost_holder: Node2D
var _camera: Camera2D
var _ghost: Node2D
var _active_tool: StringName = &""
var _asset_path: String = ""
var _placement_nodes: Array[Node2D] = []
var _depth: StringName = &"near"
var _wall_finish: StringName = &"finish_wall_paint"
var _wall_tint: Color = Color("#5A9D70")
var _wall_anchor: Vector2 = Vector2.INF
var _wall_drag_start: Vector2 = Vector2.INF
var _wall_dragging: bool = false
var _wall_anchor_active: bool = false
var _wall_erase_start: Vector2 = Vector2.INF
var _wall_erase_dragging: bool = false
var _wall_erase_pointer: Vector2 = Vector2.ZERO
var _surface_erase_kind: StringName = &"wall"
var _surface_areas_erased: int = 0
# Keep cut originals detached until each released eraser stroke is committed.
var _surface_erase_detached: Array[Node2D] = []
var _surface_erase_changed: bool = false
var _snap_enabled: bool = false
var _grid_size: float = 15.0
var _group_kind: StringName = &""
var _group_snapshots: Array[Dictionary] = []
var _group_bounds: Rect2 = Rect2()
var _group_pending: bool = false
var _single_kind: StringName = &""
var _single_snapshot: Dictionary = {}
var _single_active: bool = false
var _single_release_guard: bool = false
func configure(world: Node2D, ghost_holder: Node2D, camera: Camera2D) -> void:
_world = world
_ghost_holder = ghost_holder
_camera = camera
func set_grid_snap(enabled: bool, grid_size: float) -> void:
_snap_enabled = enabled
_grid_size = maxf(1.0, grid_size)
func set_depth(depth: StringName) -> void:
if depth not in [&"far", &"near", &"floor"]:
return
_depth = depth
if _ghost != null and is_instance_valid(_ghost) and _ghost.get_script() == SCENERY_ARTWORK:
(_ghost as StageSceneryArtwork).set_depth(_depth)
func is_active() -> bool:
return not _active_tool.is_empty()
func is_placement_tool() -> bool:
return _is_placement_tool()
func is_group_preview_pending() -> bool:
return _group_pending
func is_single_transform_active() -> bool:
return _single_active
func get_single_transform_node() -> Node2D:
return _single_snapshot.get("node") as Node2D if _single_active else null
func get_single_transform_kind() -> StringName:
return _single_kind if _single_active else &""
func consume_cancelled_single_release() -> bool:
if not _single_release_guard:
return false
_single_release_guard = false
return true
func get_active_tool() -> StringName:
return _active_tool
func get_wall_areas_erased() -> int:
return _surface_areas_erased
func get_surface_areas_erased() -> int:
return _surface_areas_erased
func is_surface_eraser_active() -> bool:
return _is_surface_eraser()
func get_active_eraser_surface_label() -> String:
if _surface_erase_kind == &"all":
return "wall or ground"
return "background ground" if _surface_erase_kind == &"background_ground" else "wall"
func get_group_bounds() -> Rect2:
return _group_bounds
func get_active_artwork_name() -> String:
if _ghost != null and is_instance_valid(_ghost) and _ghost.get_script() == SCENERY_ARTWORK:
return String((_ghost as StageSceneryArtwork).display_name)
return _asset_path.get_file().get_basename()
func get_active_artwork_path() -> String:
return _asset_path if _active_tool == &"artwork" else ""
func get_active_artwork_preview_bounds() -> Rect2:
if _active_tool != &"artwork" or _ghost == null or not is_instance_valid(_ghost) or _ghost.get_script() != SCENERY_ARTWORK:
return Rect2()
return (_ghost as StageSceneryArtwork).get_visual_world_bounds()
func get_artwork_depth() -> StringName:
return _depth
func get_active_surface_label() -> String:
if _active_tool == &"background_ground":
return "background ground"
return "wall"
func handle_key(event: InputEventKey) -> bool:
if is_active() and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
cancel_active_tool()
return true
if _group_pending and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
cancel_group()
return true
return false
func begin_artwork(path: String, depth: StringName = &"near") -> bool:
if _world == null or _ghost_holder == null:
return false
exit_tool()
_active_tool = &"artwork"
_asset_path = path
_depth = depth if depth in [&"far", &"near", &"floor"] else &"near"
var visual := SCENERY_ARTWORK.new() as StageSceneryArtwork
if not visual.load_visual(path):
visual.queue_free()
_active_tool = &""
return false
var asset_data: Dictionary = PROP_FILE.from_file_dict(PROP_FILE.load(path))
visual.display_name = String(asset_data.get("prop_name", path.get_file().get_basename()))
visual.set_depth(_depth)
visual.modulate = Color(1.0, 1.0, 1.0, 0.52)
_ghost_holder.add_child(visual)
_ghost = visual
return true
func begin_wall(finish: StringName, initial_world_position: Vector2, tint: Color = Color("#5A9D70")) -> void:
_begin_rect_surface(&"wall", finish, initial_world_position, tint)
func begin_background_ground(finish: StringName, initial_world_position: Vector2, tint: Color) -> void:
_begin_rect_surface(&"background_ground", finish, initial_world_position, tint)
func _begin_rect_surface(tool: StringName, finish: StringName, initial_world_position: Vector2, tint: Color) -> void:
if _world == null or _ghost_holder == null:
return
exit_tool()
_active_tool = tool
_wall_finish = finish
_wall_tint = tint
_wall_anchor = Vector2.INF
_wall_dragging = false
_wall_anchor_active = false
var panel: StageRectSurface
if tool == &"background_ground":
panel = BACKGROUND_GROUND.new() as StageBackgroundGround
else:
panel = WALL_PANEL.new() as StageWallPanel
panel.interactive_preview = true
panel.configure(DEFAULT_PANEL_SIZE, _wall_finish, _wall_tint)
panel.modulate = Color(1.0, 1.0, 1.0, 0.45)
_ghost_holder.add_child(panel)
_ghost = panel
# The palette may close without generating a Stage mouse-motion event.
# Position the preview from the current cursor immediately so the visible
# ghost and the first press use the same starting corner.
update_pointer(initial_world_position)
func begin_wall_eraser(initial_world_position: Vector2) -> bool:
return begin_surface_eraser(initial_world_position, &"wall")
func begin_background_ground_eraser(initial_world_position: Vector2) -> bool:
return begin_surface_eraser(initial_world_position, &"background_ground")
func begin_surface_eraser(initial_world_position: Vector2, surface_kind: StringName) -> bool:
if _world == null or _ghost_holder == null:
return false
if surface_kind not in [&"wall", &"background_ground", &"all"]:
return false
exit_tool()
_surface_erase_kind = surface_kind
match surface_kind:
&"background_ground":
_active_tool = &"background_ground_eraser"
&"all":
_active_tool = &"scenery_eraser"
_:
_active_tool = &"wall_eraser"
_wall_erase_start = Vector2.INF
_wall_erase_dragging = false
_surface_areas_erased = 0
_surface_erase_detached.clear()
_surface_erase_changed = false
_wall_erase_pointer = initial_world_position
var preview := Node2D.new()
preview.name = "BackgroundGroundEraserPreview" if surface_kind == &"background_ground" else "WallEraserPreview"
var fill := Polygon2D.new()
fill.name = "EraseArea"
fill.color = Color("#FF5B62", 0.2)
preview.add_child(fill)
var outline := Line2D.new()
outline.name = "EraseOutline"
outline.width = 3.0
outline.default_color = Color("#FF7278")
preview.add_child(outline)
var cross_a := Line2D.new()
cross_a.name = "EraseCrossA"
cross_a.width = 2.0
cross_a.default_color = Color("#FF7278", 0.9)
preview.add_child(cross_a)
var cross_b := Line2D.new()
cross_b.name = "EraseCrossB"
cross_b.width = 2.0
cross_b.default_color = Color("#FF7278", 0.9)
preview.add_child(cross_b)
_ghost_holder.add_child(preview)
_ghost = preview
update_pointer(initial_world_position)
return true
func update_pointer(world_position: Vector2) -> void:
if not is_active() or _ghost == null or not is_instance_valid(_ghost):
return
var snapped_position: Vector2 = _snap(world_position)
if _is_rect_placement_tool() and _wall_dragging:
var size: Vector2 = Vector2(absf(snapped_position.x - _wall_drag_start.x), absf(snapped_position.y - _wall_drag_start.y))
size.x = maxf(MIN_PANEL_SIZE, size.x)
size.y = maxf(MIN_PANEL_SIZE, size.y)
(_ghost as StageRectSurface).configure(size, _wall_finish, _wall_tint)
var top_left: Vector2 = Vector2(minf(_wall_drag_start.x, snapped_position.x), minf(_wall_drag_start.y, snapped_position.y))
_ghost.global_position = top_left + size * 0.5
elif _is_rect_placement_tool():
var preview_top_left: Vector2 = _snap(world_position) if _snap_enabled else world_position
_ghost.global_position = _move_node_visible_top_left(_ghost, preview_top_left)
elif _is_surface_eraser():
_wall_erase_pointer = world_position
if _wall_erase_dragging:
_set_wall_eraser_preview_rect(_drag_rect(_wall_erase_start, _snap(world_position)))
else:
var cursor_origin: Vector2 = _snap(world_position) if _snap_enabled else world_position
_set_wall_eraser_preview_rect(Rect2(cursor_origin, WALL_ERASER_CURSOR_SIZE))
else:
_ghost.global_position = _snap_visual_center(_ghost, world_position)
func handle_mouse_button(event: InputEventMouseButton, world_position: Vector2, over_ui: bool) -> bool:
if not is_active():
return false
if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
# Right-click finishes placement and area-erasure sessions. This commits
# completed work while dropping any unfinished preview gesture. Cancel
# and Escape remain the explicit rollback paths.
if _is_placement_tool() or _is_surface_eraser():
exit_tool()
else:
cancel_active_tool()
return true
var completes_drag: bool = not event.pressed and event.button_index == MOUSE_BUTTON_LEFT and (
(_is_rect_placement_tool() and _wall_dragging)
or (_is_surface_eraser() and _wall_erase_dragging)
)
if (over_ui and not completes_drag) or event.button_index != MOUSE_BUTTON_LEFT:
return false
if _active_tool == &"artwork":
if event.pressed:
_commit_artwork(world_position)
return true
if _is_rect_placement_tool():
if event.pressed:
# Refresh from the press coordinates in case no mouse-motion event was
# delivered after the palette closed. The visible ghost corner is the
# drawing anchor, so preview and committed geometry cannot jump apart.
update_pointer(world_position)
_wall_anchor = _world_bounds(_ghost).position
_wall_anchor_active = true
_wall_drag_start = _wall_anchor
_wall_dragging = true
update_pointer(world_position)
return true
if _wall_dragging:
var end: Vector2 = _snap(world_position)
var size: Vector2 = Vector2(absf(end.x - _wall_drag_start.x), absf(end.y - _wall_drag_start.y))
_wall_dragging = false
if size.x < MIN_PANEL_SIZE or size.y < MIN_PANEL_SIZE:
_wall_anchor_active = false
_wall_anchor = Vector2.INF
_restore_rect_surface_ghost(world_position)
return true
_commit_rect_surface(_wall_drag_start, end)
_wall_anchor = Vector2.INF
_wall_anchor_active = false
_restore_rect_surface_ghost(world_position)
return true
if _is_surface_eraser():
if event.pressed:
_wall_erase_start = _snap(world_position) if _snap_enabled else world_position
_wall_erase_dragging = true
update_pointer(world_position)
return true
if _wall_erase_dragging:
var erase_end: Vector2 = _snap(world_position) if _snap_enabled else world_position
var erase_rect: Rect2 = _drag_rect(_wall_erase_start, erase_end)
_wall_erase_dragging = false
_wall_erase_start = Vector2.INF
if erase_rect.size.x >= MIN_WALL_ERASE_SIZE and erase_rect.size.y >= MIN_WALL_ERASE_SIZE:
var changed: int = _erase_surface_region(erase_rect, _surface_erase_kind)
if changed > 0:
_surface_areas_erased += 1
_surface_erase_changed = true
_commit_surface_eraser_cut()
update_pointer(world_position)
return true
return false
func handle_mouse_motion(world_position: Vector2) -> bool:
if not is_active():
return false
update_pointer(world_position)
return true
func cancel_active_gesture() -> void:
_wall_dragging = false
_wall_anchor = Vector2.INF
_wall_drag_start = Vector2.INF
_wall_anchor_active = false
_wall_erase_dragging = false
_wall_erase_start = Vector2.INF
if _is_rect_placement_tool() and _ghost != null and is_instance_valid(_ghost):
(_ghost as StageRectSurface).configure(_default_size_for_active_surface(), _wall_finish, _wall_tint)
elif _is_surface_eraser() and _ghost != null and is_instance_valid(_ghost):
_set_wall_eraser_preview_rect(Rect2(_wall_erase_pointer, WALL_ERASER_CURSOR_SIZE))
func exit_tool() -> void:
if _single_active:
cancel_single_transform()
if _is_placement_tool():
commit_placement_session()
if _is_surface_eraser():
_commit_surface_eraser()
cancel_active_gesture()
_free_ghost()
_active_tool = &""
_asset_path = ""
_group_kind = &""
_group_snapshots.clear()
_group_pending = false
preview_state_changed.emit(false)
func cancel_active_tool() -> void:
if _is_surface_eraser():
cancel_surface_eraser()
return
if _is_placement_tool():
_rollback_placement_session()
exit_tool()
func commit_placement_session() -> void:
if not _is_placement_tool():
return
var has_placed_nodes: bool = false
for node: Node2D in _placement_nodes:
if node != null and is_instance_valid(node) and node.get_parent() != null:
has_placed_nodes = true
_placement_nodes.clear()
if has_placed_nodes:
document_change_requested.emit()
func _rollback_placement_session() -> void:
for node: Node2D in _placement_nodes:
if node == null or not is_instance_valid(node):
continue
var parent: Node = node.get_parent()
if parent != null:
parent.remove_child(node)
node.queue_free()
_placement_nodes.clear()
func cancel_surface_eraser() -> void:
if not _is_surface_eraser():
return
_surface_areas_erased = 0
_clear_surface_eraser_transaction()
exit_tool()
func _commit_surface_eraser() -> void:
_commit_surface_eraser_cut()
func _commit_surface_eraser_cut() -> void:
if _surface_erase_changed:
document_change_requested.emit()
for detached: Node2D in _surface_erase_detached:
if detached != null and is_instance_valid(detached):
detached.queue_free()
_clear_surface_eraser_transaction()
func _clear_surface_eraser_transaction() -> void:
_surface_erase_detached.clear()
_surface_erase_changed = false
func begin_group_transform(kind: StringName, members: Array[Node2D]) -> bool:
if _single_active or members.is_empty() or kind not in [&"move", &"resize"]:
return false
var snapshots: Array[Dictionary] = []
var union_bounds: Rect2 = Rect2()
var has_bounds: bool = false
for member: Node2D in members:
if member == null or not is_instance_valid(member):
return false
var snapshot: Dictionary = {"node": member, "position": member.global_position, "rotation": member.global_rotation, "scale": member.scale}
var bounds: Rect2
if member is StageRectSurface:
var surface: StageRectSurface = member as StageRectSurface
snapshot["panel_size"] = surface.panel_size
bounds = member.global_transform * surface.get_local_bounds()
elif member.get_script() == SCENERY_ARTWORK:
var artwork: StageSceneryArtwork = member as StageSceneryArtwork
bounds = STAGE_SELECTION.get_world_aabb(member)
if artwork.missing and not artwork.has_known_bounds:
if kind == &"resize":
return false
else:
return false
if kind == &"resize" and (bounds.size.x <= 0.0 or bounds.size.y <= 0.0):
return false
snapshots.append(snapshot)
union_bounds = bounds if not has_bounds else union_bounds.merge(bounds)
has_bounds = true
_group_snapshots = snapshots
_group_kind = kind
_group_bounds = union_bounds
_group_pending = true
preview_state_changed.emit(true)
return true
func preview_group(delta: Vector2, factor: float) -> bool:
if not _group_pending or factor <= 0.0 or not delta.is_finite():
return false
# Validate the whole preview before moving any member, so a rejected factor
# never leaves a partially transformed group.
for snapshot: Dictionary in _group_snapshots:
var member: Node2D = snapshot["node"] as Node2D
if member == null or not is_instance_valid(member):
return false
if _group_kind == &"resize":
if member is StageRectSurface:
var next_size: Vector2 = (snapshot["panel_size"] as Vector2) * factor
if next_size.x < MIN_PANEL_SIZE or next_size.y < MIN_PANEL_SIZE:
return false
elif member.get_script() == SCENERY_ARTWORK:
var next_scale: Vector2 = (snapshot["scale"] as Vector2) * factor
if next_scale.x < 0.08 or next_scale.x > 12.0 or next_scale.y < 0.08 or next_scale.y > 12.0:
return false
var anchor: Vector2 = _group_bounds.position
for snapshot: Dictionary in _group_snapshots:
var member: Node2D = snapshot["node"] as Node2D
if member == null or not is_instance_valid(member):
return false
if _group_kind == &"move":
member.global_position = (snapshot["position"] as Vector2) + delta
continue
var art_scale: Vector2 = snapshot["scale"] as Vector2
var new_scale: Vector2 = art_scale * factor
member.global_position = anchor + ((snapshot["position"] as Vector2) - anchor) * factor
member.global_rotation = float(snapshot["rotation"])
if member is StageRectSurface:
var surface: StageRectSurface = member as StageRectSurface
surface.configure((snapshot["panel_size"] as Vector2) * factor, surface.finish_id, surface.tint)
else:
member.scale = new_scale
return true
func commit_group() -> void:
if not _group_pending:
return
_group_pending = false
_group_snapshots.clear()
_group_kind = &""
preview_state_changed.emit(false)
document_change_requested.emit()
func begin_single_transform(node: Node2D, kind: StringName, pointer: Vector2, dragged_corner: Vector2 = Vector2.INF) -> bool:
if _single_active or _group_pending or node == null or not is_instance_valid(node) or kind not in [&"move", &"resize"]:
return false
if not (node is StageRectSurface) and node.get_script() != SCENERY_ARTWORK:
return false
if not pointer.is_finite():
return false
var bounds: Rect2 = _world_bounds(node)
if kind == &"resize" and (not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.x <= 0.0 or bounds.size.y <= 0.0):
return false
if node.get_script() == SCENERY_ARTWORK:
var artwork: StageSceneryArtwork = node as StageSceneryArtwork
if artwork.missing and not artwork.has_known_bounds and kind == &"resize":
return false
var corner_index: int = _nearest_corner_index(bounds, dragged_corner if dragged_corner.is_finite() else pointer)
var corners: Array[Vector2] = _corners(bounds)
var snapshot: Dictionary = {
"node": node,
"position": node.global_position,
"rotation": node.global_rotation,
"scale": node.scale,
"bounds": bounds,
"pointer": pointer,
"corner_index": corner_index,
"dragged_corner": corners[corner_index],
"pivot": _opposite_corner(bounds, corner_index),
}
if node is StageRectSurface:
snapshot["panel_size"] = (node as StageRectSurface).panel_size
else:
var initial_scale: Vector2 = node.scale
if not initial_scale.is_finite() or initial_scale.x <= 0.0 or initial_scale.y <= 0.0:
return false
_single_snapshot = snapshot
_single_kind = kind
_single_active = true
_single_release_guard = false
single_gesture_changed.emit(true)
return true
func preview_single_transform(pointer: Vector2) -> bool:
if not _single_active or not pointer.is_finite():
return false
var node: Node2D = _single_snapshot.get("node") as Node2D
if node == null or not is_instance_valid(node):
return false
if _single_kind == &"move":
var start_bounds: Rect2 = _single_snapshot["bounds"]
var raw_delta: Vector2 = pointer - (_single_snapshot["pointer"] as Vector2)
var delta: Vector2 = raw_delta
if _snap_enabled:
delta = _snap(start_bounds.position + raw_delta) - start_bounds.position
var next_position: Vector2 = (_single_snapshot["position"] as Vector2) + delta
if not next_position.is_finite():
return false
node.global_position = next_position
return true
var dragged_corner: Vector2 = _single_snapshot["dragged_corner"]
var pivot: Vector2 = _single_snapshot["pivot"]
var diagonal: Vector2 = dragged_corner - pivot
var denominator: float = diagonal.length_squared()
if denominator <= 0.0001:
return false
var raw_factor: float = 1.0 + (pointer - (_single_snapshot["pointer"] as Vector2)).dot(diagonal) / denominator
return _apply_single_scale(raw_factor, pivot)
func commit_single_transform() -> bool:
if not _single_active:
return false
var changed: bool = _single_transform_changed()
_single_active = false
_single_kind = &""
_single_snapshot.clear()
single_gesture_changed.emit(false)
if changed:
document_change_requested.emit()
return changed
func cancel_single_transform() -> void:
if not _single_active:
return
_restore_single_snapshot()
_single_active = false
_single_kind = &""
_single_snapshot.clear()
_single_release_guard = true
single_gesture_changed.emit(false)
func step_single_size(node: Node2D, factor: float) -> bool:
if node == null or not is_instance_valid(node) or not is_finite(factor) or factor <= 0.0 or _single_active or _group_pending:
return false
if not (node is StageRectSurface) and node.get_script() != SCENERY_ARTWORK:
return false
var bounds: Rect2 = _world_bounds(node)
if bounds.size.x <= 0.0 or bounds.size.y <= 0.0:
return false
if node.get_script() == SCENERY_ARTWORK:
var artwork: StageSceneryArtwork = node as StageSceneryArtwork
if artwork.missing and not artwork.has_known_bounds:
return false
var snapshot: Dictionary = {
"node": node,
"position": node.global_position,
"rotation": node.global_rotation,
"scale": node.scale,
"bounds": bounds,
"pivot": bounds.position,
"panel_size": (node as StageRectSurface).panel_size if node is StageRectSurface else Vector2.ZERO,
}
var pivot: Vector2 = bounds.position
if not _can_apply_single_scale(snapshot, factor):
return false
_apply_single_scale_to_snapshot(snapshot, factor, pivot)
if _snapshot_matches_current(snapshot):
return false
document_change_requested.emit()
return true
func cancel_group() -> void:
if not _group_pending:
return
for snapshot: Dictionary in _group_snapshots:
var member: Node2D = snapshot["node"] as Node2D
if member == null or not is_instance_valid(member):
continue
member.global_position = snapshot["position"] as Vector2
member.global_rotation = float(snapshot["rotation"])
member.scale = snapshot["scale"] as Vector2
if member is StageRectSurface and snapshot.has("panel_size"):
var surface: StageRectSurface = member as StageRectSurface
surface.configure(snapshot["panel_size"] as Vector2, surface.finish_id, surface.tint)
_group_pending = false
_group_snapshots.clear()
_group_kind = &""
preview_state_changed.emit(false)
static func bounds_for(members: Array[Node2D]) -> Rect2:
var result: Rect2 = Rect2()
var has_rect: bool = false
for member: Node2D in members:
var rect: Rect2 = (member as StageRectSurface).global_transform * (member as StageRectSurface).get_local_bounds() if member is StageRectSurface else STAGE_SELECTION.get_world_aabb(member)
result = rect if not has_rect else result.merge(rect)
has_rect = true
return result
func _commit_artwork(position: Vector2) -> void:
var artwork := SCENERY_ARTWORK.new() as StageSceneryArtwork
if not artwork.load_visual(_asset_path):
artwork.free()
return
artwork.display_name = String(PROP_FILE.from_file_dict(PROP_FILE.load(_asset_path)).get("prop_name", artwork.display_name))
artwork.set_depth(_depth)
artwork.global_position = _snap_visual_center(artwork, position)
_world.add_child(artwork)
_placement_nodes.append(artwork)
placement_committed.emit(artwork)
func _commit_rect_surface(first: Vector2, second: Vector2) -> void:
var size: Vector2 = Vector2(maxf(MIN_PANEL_SIZE, absf(second.x - first.x)), maxf(MIN_PANEL_SIZE, absf(second.y - first.y)))
var panel: StageRectSurface
if _active_tool == &"background_ground":
panel = BACKGROUND_GROUND.new() as StageBackgroundGround
else:
panel = WALL_PANEL.new() as StageWallPanel
panel.configure(size, _wall_finish, _wall_tint)
panel.global_position = (first + second) * 0.5
_world.add_child(panel)
_placement_nodes.append(panel)
placement_committed.emit(panel)
func _restore_rect_surface_ghost(world_position: Vector2) -> void:
if _ghost == null or not is_instance_valid(_ghost) or not (_ghost is StageRectSurface):
return
(_ghost as StageRectSurface).configure(_default_size_for_active_surface(), _wall_finish, _wall_tint)
update_pointer(world_position)
func _is_rect_placement_tool() -> bool:
return _active_tool in [&"wall", &"background_ground"]
func _is_placement_tool() -> bool:
return _active_tool in [&"artwork", &"wall", &"background_ground"]
func _default_size_for_active_surface() -> Vector2:
return DEFAULT_PANEL_SIZE
func _is_surface_eraser() -> bool:
return _active_tool in [&"wall_eraser", &"background_ground_eraser", &"scenery_eraser"]
func _erase_wall_region(world_rect: Rect2) -> int:
return _erase_surface_region(world_rect, &"wall")
func _erase_surface_region(world_rect: Rect2, surface_kind: StringName) -> int:
if _world == null or world_rect.size.x <= 0.0 or world_rect.size.y <= 0.0:
return 0
var changed_panels: int = 0
var target_scripts: Array[Script] = _surface_erase_target_scripts(surface_kind)
for child: Node in _world.get_children().duplicate():
if not target_scripts.has(child.get_script()):
continue
var surface: StageRectSurface = child as StageRectSurface
if surface == null:
continue
var is_background_ground: bool = child.get_script() == BACKGROUND_GROUND
var panel_rect: Rect2 = surface.get_local_bounds()
var erase_corners: Array[Vector2] = [world_rect.position, Vector2(world_rect.end.x, world_rect.position.y), world_rect.end, Vector2(world_rect.position.x, world_rect.end.y)]
var local_erase := Rect2()
for corner_index: int in erase_corners.size():
var local_point: Vector2 = surface.to_local(erase_corners[corner_index])
if corner_index == 0:
local_erase = Rect2(local_point, Vector2.ZERO)
else:
local_erase = local_erase.expand(local_point)
if not panel_rect.intersects(local_erase):
continue
var cut: Rect2 = panel_rect.intersection(local_erase)
if cut.size.x <= 0.0 or cut.size.y <= 0.0:
continue
var fragments: Array[Rect2] = _wall_rect_fragments(panel_rect, cut)
var parent: Node = surface.get_parent()
if parent == null:
continue
var sibling_index: int = surface.get_index()
var source_transform: Transform2D = surface.transform
var source_draw_origin: Vector2 = surface.get_surface_draw_origin_world()
if surface is StageWallPanel:
source_draw_origin = (surface as StageWallPanel).get_effective_motif_origin_world()
for fragment_rect: Rect2 in fragments:
var fragment: StageRectSurface
if is_background_ground:
fragment = BACKGROUND_GROUND.new() as StageBackgroundGround
else:
fragment = WALL_PANEL.new() as StageWallPanel
fragment.transform = source_transform
fragment.position += source_transform.basis_xform(fragment_rect.get_center())
fragment.configure(fragment_rect.size, surface.finish_id, surface.tint)
parent.add_child(fragment)
if fragment is StageWallPanel:
(fragment as StageWallPanel).motif_origin_offset = source_draw_origin - fragment.global_position
elif fragment is StageBackgroundGround:
(fragment as StageBackgroundGround).motif_origin_offset = source_draw_origin - fragment.global_position
parent.move_child(fragment, sibling_index)
sibling_index += 1
parent.remove_child(surface)
if _is_surface_eraser():
_surface_erase_detached.append(surface)
else:
surface.queue_free()
changed_panels += 1
return changed_panels
func _surface_erase_target_scripts(surface_kind: StringName) -> Array[Script]:
var target_scripts: Array[Script] = []
match surface_kind:
&"background_ground":
target_scripts.append(BACKGROUND_GROUND)
&"all":
target_scripts.append(WALL_PANEL)
target_scripts.append(BACKGROUND_GROUND)
_:
target_scripts.append(WALL_PANEL)
return target_scripts
func _wall_rect_fragments(panel_rect: Rect2, cut: Rect2) -> Array[Rect2]:
var fragments: Array[Rect2] = []
_add_wall_fragment(fragments, Rect2(panel_rect.position, Vector2(panel_rect.size.x, cut.position.y - panel_rect.position.y)))
_add_wall_fragment(fragments, Rect2(Vector2(panel_rect.position.x, cut.end.y), Vector2(panel_rect.size.x, panel_rect.end.y - cut.end.y)))
_add_wall_fragment(fragments, Rect2(Vector2(panel_rect.position.x, cut.position.y), Vector2(cut.position.x - panel_rect.position.x, cut.size.y)))
_add_wall_fragment(fragments, Rect2(Vector2(cut.end.x, cut.position.y), Vector2(panel_rect.end.x - cut.end.x, cut.size.y)))
return fragments
func _add_wall_fragment(fragments: Array[Rect2], rect: Rect2) -> void:
if rect.size.x > 1.0 and rect.size.y > 1.0:
fragments.append(rect)
func _drag_rect(first: Vector2, second: Vector2) -> Rect2:
var top_left: Vector2 = Vector2(minf(first.x, second.x), minf(first.y, second.y))
var size: Vector2 = Vector2(absf(first.x - second.x), absf(first.y - second.y))
return Rect2(top_left, size)
func _set_wall_eraser_preview_rect(rect: Rect2) -> void:
if _ghost == null or not is_instance_valid(_ghost):
return
var preview: Node2D = _ghost
var normalized: Rect2 = rect.abs()
var size: Vector2 = Vector2(maxf(1.0, normalized.size.x), maxf(1.0, normalized.size.y))
var half: Vector2 = size * 0.5
preview.global_position = normalized.position + half
var points := PackedVector2Array([Vector2(-half.x, -half.y), Vector2(half.x, -half.y), Vector2(half.x, half.y), Vector2(-half.x, half.y)])
var fill: Polygon2D = preview.get_node("EraseArea") as Polygon2D
fill.polygon = points
var outline: Line2D = preview.get_node("EraseOutline") as Line2D
outline.points = PackedVector2Array([points[0], points[1], points[2], points[3], points[0]])
var inset: Vector2 = Vector2(minf(8.0, half.x * 0.35), minf(8.0, half.y * 0.35))
var cross_a: Line2D = preview.get_node("EraseCrossA") as Line2D
cross_a.points = PackedVector2Array([Vector2(-half.x + inset.x, -half.y + inset.y), Vector2(half.x - inset.x, half.y - inset.y)])
var cross_b: Line2D = preview.get_node("EraseCrossB") as Line2D
cross_b.points = PackedVector2Array([Vector2(half.x - inset.x, -half.y + inset.y), Vector2(-half.x + inset.x, half.y - inset.y)])
func _snap(position: Vector2) -> Vector2:
if not _snap_enabled:
return position
return Vector2(roundf(position.x / _grid_size) * _grid_size, roundf(position.y / _grid_size) * _grid_size)
func _apply_single_scale(factor: float, pivot: Vector2) -> bool:
if not _can_apply_single_scale(_single_snapshot, factor):
return false
_apply_single_scale_to_snapshot(_single_snapshot, factor, pivot)
return true
func _can_apply_single_scale(snapshot: Dictionary, factor: float) -> bool:
if not is_finite(factor) or factor <= 0.0:
return false
var node: Node2D = snapshot.get("node") as Node2D
if node == null or not is_instance_valid(node):
return false
var next_position: Vector2 = (snapshot["pivot"] as Vector2) + ((snapshot["position"] as Vector2) - (snapshot["pivot"] as Vector2)) * factor
if not next_position.is_finite():
return false
if node is StageRectSurface:
var next_size: Vector2 = (snapshot["panel_size"] as Vector2) * factor
return next_size.is_finite() and next_size.x >= MIN_PANEL_SIZE and next_size.y >= MIN_PANEL_SIZE
var start_scale: Vector2 = snapshot["scale"] as Vector2
var next_scale: Vector2 = start_scale * factor
return start_scale.is_finite() and start_scale.x > 0.0 and start_scale.y > 0.0 and next_scale.is_finite() and next_scale.x >= MIN_ART_SCALE and next_scale.x <= MAX_ART_SCALE and next_scale.y >= MIN_ART_SCALE and next_scale.y <= MAX_ART_SCALE
func _apply_single_scale_to_snapshot(snapshot: Dictionary, factor: float, pivot: Vector2) -> void:
var node: Node2D = snapshot["node"] as Node2D
node.global_position = pivot + ((snapshot["position"] as Vector2) - pivot) * factor
node.global_rotation = float(snapshot["rotation"])
if node is StageRectSurface:
var surface: StageRectSurface = node as StageRectSurface
surface.scale = snapshot["scale"] as Vector2
surface.configure((snapshot["panel_size"] as Vector2) * factor, surface.finish_id, surface.tint)
else:
node.scale = (snapshot["scale"] as Vector2) * factor
func _restore_single_snapshot() -> void:
var node: Node2D = _single_snapshot.get("node") as Node2D
if node == null or not is_instance_valid(node):
return
node.global_position = _single_snapshot["position"] as Vector2
node.global_rotation = float(_single_snapshot["rotation"])
node.scale = _single_snapshot["scale"] as Vector2
if node is StageRectSurface:
var surface: StageRectSurface = node as StageRectSurface
surface.configure(_single_snapshot["panel_size"] as Vector2, surface.finish_id, surface.tint)
func _single_transform_changed() -> bool:
return not _snapshot_matches_current(_single_snapshot)
func _snapshot_matches_current(snapshot: Dictionary) -> bool:
var node: Node2D = snapshot.get("node") as Node2D
if node == null or not is_instance_valid(node):
return false
if not node.global_position.is_equal_approx(snapshot["position"] as Vector2):
return false
if not is_equal_approx(node.global_rotation, float(snapshot["rotation"])) or not node.scale.is_equal_approx(snapshot["scale"] as Vector2):
return false
if node is StageRectSurface:
return (node as StageRectSurface).panel_size.is_equal_approx(snapshot["panel_size"] as Vector2)
return true
func _world_bounds(node: Node2D) -> Rect2:
if node is StageRectSurface:
return node.global_transform * (node as StageRectSurface).get_local_bounds()
if node.get_script() == SCENERY_ARTWORK:
return (node as StageSceneryArtwork).get_visual_world_bounds()
return STAGE_SELECTION.get_world_aabb(node)
func _corners(bounds: Rect2) -> Array[Vector2]:
return [bounds.position, Vector2(bounds.end.x, bounds.position.y), bounds.end, Vector2(bounds.position.x, bounds.end.y)]
func _nearest_corner_index(bounds: Rect2, point: Vector2) -> int:
var corners: Array[Vector2] = _corners(bounds)
var nearest_index: int = 0
var nearest_distance: float = INF
for index: int in corners.size():
var distance: float = corners[index].distance_squared_to(point)
if distance < nearest_distance:
nearest_distance = distance
nearest_index = index
return nearest_index
func _opposite_corner(bounds: Rect2, corner_index: int) -> Vector2:
match corner_index:
0:
return bounds.end
1:
return Vector2(bounds.position.x, bounds.end.y)
2:
return bounds.position
_:
return Vector2(bounds.end.x, bounds.position.y)
func _snap_visual_center(node: Node2D, desired_center: Vector2) -> Vector2:
var target_center: Vector2 = _snap(desired_center) if _snap_enabled and _grid_size > 0.0 else desired_center
if node.get_script() != SCENERY_ARTWORK:
return target_center
var bounds: Rect2 = _world_bounds(node)
return node.global_position + target_center - bounds.get_center()
func _move_node_visible_top_left(node: Node2D, desired_top_left: Vector2) -> Vector2:
if not (node is StageRectSurface) and node.get_script() != SCENERY_ARTWORK:
return desired_top_left
var bounds: Rect2 = _world_bounds(node)
return node.global_position + desired_top_left - bounds.position
func _free_ghost() -> void:
if _ghost != null and is_instance_valid(_ghost):
_ghost.queue_free()
_ghost = null
@@ -0,0 +1 @@
uid://gvfhmaqednxv
@@ -20,6 +20,7 @@ var _overlay: StagePlacementOverlay
var _spatial_index: StageSpatialIndex
var _placement_id: String = ""
var _ground_finish: StringName = &"finish_ground_grass"
var _dragging: bool = false
var _painting: bool = false
var _press_world: Vector2 = Vector2.ZERO
@@ -46,6 +47,14 @@ func configure(
_spatial_index = spatial_index
func set_ground_finish(finish_id: StringName) -> void:
_ground_finish = finish_id if finish_id in [
&"finish_ground_grass", &"finish_ground_dirt", &"finish_ground_sidewalk",
&"finish_ground_asphalt", &"finish_ground_brick", &"finish_ground_wood",
&"finish_ground_paint"
] else &"finish_ground_grass"
func begin(placement_id: String, world_pos: Vector2, snap_enabled: bool, grid_size: float) -> void:
if _spawner == null or not _spawner.is_terrain_id(placement_id):
return
@@ -94,6 +103,7 @@ func commit(placement_id: String, snap_enabled: bool, grid_size: float) -> void:
var click_pos: Vector2 = _snap_to_grid(_press_world, grid_size) if snap_enabled else _press_world
var click_node: Node2D = _spawner.spawn(_placement_id, click_pos)
if click_node != null:
_apply_ground_finish(click_node)
placement_requested.emit(click_node)
placed_count += 1
for cell: Vector2i in _drag_cells:
@@ -102,6 +112,7 @@ func commit(placement_id: String, snap_enabled: bool, grid_size: float) -> void:
var node: Node2D = _spawner.spawn(_placement_id, terrain_cell_center(cell, _placement_id))
if node == null:
continue
_apply_ground_finish(node)
placement_requested.emit(node)
placed_count += 1
_drag_painted[cell] = true
@@ -217,6 +228,7 @@ func refresh_terrain_ghosts() -> void:
var ghost: Node2D = _spawner.spawn(_placement_id, terrain_cell_center(cell, _placement_id))
if ghost == null:
continue
_apply_ground_finish(ghost)
if ghost.get_parent() == _world:
_world.remove_child(ghost)
_ghost_holder.add_child(ghost)
@@ -272,6 +284,11 @@ func _configure_ghost_collision(ghost: Node2D) -> void:
rig.set_collision_bodies_enabled(false)
func _apply_ground_finish(node: Node2D) -> void:
if node is TERRAIN_BLOCK and (node as TerrainBlock).spawn_id == "ground":
(node as TerrainBlock).set_finish(_ground_finish)
func _snap_to_grid(pos: Vector2, grid_size: float) -> Vector2:
if grid_size <= 0.0:
return pos

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