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.
This commit is contained in:
2026-10-17 21:00:03 -04:00
parent a0510e6405
commit 26dd9c64e1
39 changed files with 3118 additions and 142 deletions
+1
View File
@@ -7,6 +7,7 @@ addons/
.opencode/skills/playwright-cli
*.import
content/review/artist/
content/examples/
/tests/godot_test_user_data/
/tests/*_runtime.log
/tests/phase19_*.log
@@ -82,7 +82,9 @@ This document tracks known technical debt, optimization opportunities, and minor
| 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 wall panels and Scenery artwork only. | Medium | Future feature | Moving/scaling 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`. (2026-10-15) |
| 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) |
---
@@ -174,5 +176,6 @@ This document tracks known technical debt, optimization opportunities, and minor
| 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. |
_End of Log_
+18 -10
View File
@@ -8,11 +8,12 @@ Priorities are recommendations for discussion, not a committed release schedule.
1. **Move To with selectable movement styles** — **Status:** Verified · **Suggested priority:** High. **Intent:** Give a stickman one destination action with a per-action style choice, starting with Walk (default) and Run; leave room for future styles like Fly. Use a fixed pace for each style initially, matched to its animation. **Rationale:** Children can direct the same route at different paces without choosing duplicate action types. The first run cycle should read as a believable human gait on the existing stickman rig, with clear foot contact and push-off, a flight phase, alternating arm swing, and cadence matched to travel speed. **Connections:** [Phase 16 design brief](../plans/phase_16_running_stickman.md), `scripts/stage/action_registry.gd`, `scripts/editors/stage/action_editor.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/characters/stickman/stickman_locomotion.gd`, `scripts/tools/create_animations.gd`; existing scenes and rules currently store `walk_to` and should continue as Move To · Walk.
2. **Prop depth and behavior settings** — **Status:** Verified · **Suggested priority:** Medium. **Intent:** Let placed props draw behind or in front of characters, and keep every prop selectable in EDIT even when covered. Dynamic sorting with characters is deferred until the Stage has a clear front/back movement convention. **Rationale:** Children can build layered sets with independently configurable visual depth, physics/support behavior, and interaction. Some props can provide surfaces that support other props while remaining pass-through for characters; others can block characters, move under physics, be picked up, or react to events. These roles should work across prop types rather than depend on one table-and-pot example. New placements start still and use a selected-prop settings card. **Set Down** targets a clicked stage-world release point, alongside free Drop and Throw; physics determines the subsequent landing on existing collision geometry, with no support-surface authoring. Door open/close behavior and scene travel stay in the later doorway feature. **Phase 17a is implemented and user-verified.** See the [Phase 17 — Props for Storytelling plan](../plans/phase_17_props_for_storytelling.md) and approved [17a UI specification](../specs/phase_17a_prop_roles_ui.md). **Connections:** `scripts/props/prop_block.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/stage/stage_spawner.gd`, `scripts/stage/stage_selection.gd`, `scripts/editors/stage/sandbox_stage.gd`, `scripts/characters/stickman/stickman_prop_interactor.gd`, [Phase 11 prop interactions](../specs/phase_11_prop_interactions.md).
2. **Distant background surfaces** — **Status:** Planned · **Suggested priority:** High. **Plan:** [Phase 19 — Background Ground and Wall Color](../plans/phase_19_background_ground_and_wall_color.md). **Intent:** Add finite, inert Background ground panels behind buildings and walkable Ground, with textured Grass, Concrete, and colorable Carpet. Complete the Phase 19 wall Color flow for Paint, Brick, and Wood, with texture-preserving tint and eight shared recent colors. **Rationale:** Children can give a town or room a coherent setting while keeping the walkable route clear. The chooser and selected-piece inspector distinguish decorative scenery from solid Ground; backdrop and wall colors remain editable per piece. Horizon and edge treatments remain a separate idea. **Connections:** Phase 19 Scenery backlog item 5, `scripts/stage/scenery/stage_surface_renderer.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/editors/stage/sandbox_stage.gd`.
3. **Prop depth and behavior settings** — **Status:** Verified · **Suggested priority:** Medium. **Intent:** Let placed props draw behind or in front of characters, and keep every prop selectable in EDIT even when covered. Dynamic sorting with characters is deferred until the Stage has a clear front/back movement convention. **Rationale:** Children can build layered sets with independently configurable visual depth, physics/support behavior, and interaction. Some props can provide surfaces that support other props while remaining pass-through for characters; others can block characters, move under physics, be picked up, or react to events. These roles should work across prop types rather than depend on one table-and-pot example. New placements start still and use a selected-prop settings card. **Set Down** targets a clicked stage-world release point, alongside free Drop and Throw; physics determines the subsequent landing on existing collision geometry, with no support-surface authoring. Door open/close behavior and scene travel stay in the later doorway feature. **Phase 17a is implemented and user-verified.** See the [Phase 17 — Props for Storytelling plan](../plans/phase_17_props_for_storytelling.md) and approved [17a UI specification](../specs/phase_17a_prop_roles_ui.md). **Connections:** `scripts/props/prop_block.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/stage/stage_spawner.gd`, `scripts/stage/stage_selection.gd`, `scripts/editors/stage/sandbox_stage.gd`, `scripts/characters/stickman/stickman_prop_interactor.gd`, [Phase 11 prop interactions](../specs/phase_11_prop_interactions.md).
3. **Functional doorways and doors** — **Status:** Idea · **Suggested priority:** Medium. **Intent:** Let placed door artwork serve as scenery, an open passage, or a closed wall door that blocks characters until opened; an open passage may optionally link to another scene and arrival point. **Rationale:** Children can reuse door art for different stage roles. Door open/close actions and scene travel add state and depend on the basic prop blocking model and dynamic scene loading; keep this as later work. Whether a doorway activates automatically or through a directed action remains open. **Connections:** `scripts/stage/stage_scene_io.gd`, `scripts/stage/trigger_area.gd`, `scripts/stage/trigger_registry.gd`, `scripts/editors/stage/sandbox_stage.gd`.
4. **Functional doorways and doors** — **Status:** Idea · **Suggested priority:** Medium. **Intent:** Let placed door artwork serve as scenery, an open passage, or a closed wall door that blocks characters until opened; an open passage may optionally link to another scene and arrival point. **Rationale:** Children can reuse door art for different stage roles. Door open/close actions and scene travel add state and depend on the basic prop blocking model and dynamic scene loading; keep this as later work. Whether a doorway activates automatically or through a directed action remains open. **Connections:** `scripts/stage/stage_scene_io.gd`, `scripts/stage/trigger_area.gd`, `scripts/stage/trigger_registry.gd`, `scripts/editors/stage/sandbox_stage.gd`.
4. **Scenery, backgrounds, and vector surface finishes** — **Status:** Planned · **Suggested priority:** Medium. **Plan:** [Phase 19 — Scenery, Backgrounds, and Surface Finishes](../plans/phase_19_scenery_backgrounds_surface_finishes.md). **Intent:** Give the Stage a Scenery category with three distinct uses: a scene-wide Sky background, finite wall panels, and individually placed artwork. One static, detailed Sky preset fills the camera view and remains fixed throughout PLAY; the initial designed presets are Clear Day, Cloudy Day, Sunset, and Starry Night. Skies are adaptive scene themes rather than `.prop` files: the `.scene` saves a stable preset ID, while each preset composes crisp vector gradients, varied clouds or stars, and sun or moon details without visible panel edges or obvious repetition. Wall panels are resizable, colorable rectangles with finishes such as paint, brick, and wood; `.prop` artwork can be placed as inert scenery. Scenery artwork supports simple Far and Near layers so a muted skyline strip can sit behind colorful storefronts, houses, and trees while all scenery remains behind the action. New Scene offers Blank and one polished Town Street starter built from a Cloudy Day Sky, Far skyline, Near storefront/tree scenery, and sidewalk/road Ground; the starter creates an unsaved scene whose ordinary parts remain editable. User-created scenery artwork reuses the existing vector artwork editor and `.prop` format; no separate Scenery Editor or `.scenery` format is needed initially. The Stage placement category, saved in the `.scene`, determines whether the same artwork is inert Scenery or an interactive Prop. Visual floor finishes are chosen through the existing Ground tool and remain walkable terrain; initial outdoor looks include concrete/sidewalk and asphalt/pavement alongside indoor finishes. Wall panels are inert Scenery. Shared finish names, previews, and color controls make the relationship clear without merging their behavior. Choosing a Ground finish affects new placement, while an existing piece changes only when directly edited. Ground alone gains finishes in this version; Ramp and Step retain their current appearance and remain unconstrained for a possible future terrain redesign. The artwork catalog uses optional multi-select tags from a built-in list: Nature, Buildings, Walls, Roads & Paths, and Objects. The same tags may filter both Props and Scenery because they describe the drawing, while the Stage entry point determines behavior. **Rationale:** Children can build outdoor and indoor sets without changing movement or interaction. A scene-wide Sky avoids accidental edges during camera movement, wall panels can build finite rooms over it, and cutout artwork dresses either setting. Curated long vector assets and the editable Town Street starter make a layered outdoor scene immediately achievable without adding a general template system. Reusing the established multi-shape vector authoring workflow avoids duplicate editors, split libraries, and file conversion. Its child-facing presentation may evolve from Prop Editor toward Artwork Editor so prop-only physics and attachment controls do not dominate scenery creation. Scenery objects and wall panels are explicitly inert: they cannot block, support, move, be picked up or used, or participate in events. Sky sits farthest back, wall panels and Far scenery render over it, Near scenery renders over those, and terrain, props, and actors remain in front. Ground patterns align across adjacent painted pieces so a floor or road reads as one continuous surface. A dedicated scenery-editing flow should keep broad or overlapping background art from blocking normal selection. Artwork may appear in several browse categories, while older untagged artwork remains available under All Artwork and Unsorted. Solid walls remain terrain or future static structures, while loose bricks and planks remain props. Individual clouds, suns, or moons may still be `.prop` scenery cutouts, but whole adaptive skies are not. Animated weather, parallax, directed sky changes, user-authored sky themes, custom repeatable finish authoring, a larger starter gallery, inserting starters into existing scenes, and user-authored set templates are future workflows and do not require a scenery asset type now. **Connections:** `scripts/editors/prop/prop_editor.gd`, `scripts/editors/prop/prop_file.gd`, `scripts/props/prop_library.gd`, `scripts/editors/stage/stage_asset_browser.gd`, `scripts/terrain/terrain_block.gd`, `scripts/editors/stage/stage_terrain_painter.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/editors/stage/stage_scene_adapter.gd`, `scripts/stage/stage_selection.gd`, `scripts/editors/stage/sandbox_stage.gd`, [Stage panel UI system](../specs/stage_panel_ui_system.md).
5. **Scenery, backgrounds, and vector surface finishes** — **Status:** Planned · **Suggested priority:** Medium. **Plan:** [Phase 19 — Scenery, Backgrounds, and Surface Finishes](../plans/phase_19_scenery_backgrounds_surface_finishes.md). **Intent:** Give the Stage a Scenery category with three distinct uses: a scene-wide Sky background, finite wall panels, and individually placed artwork. One static, detailed Sky preset fills the camera view and remains fixed throughout PLAY; the initial designed presets are Clear Day, Cloudy Day, Sunset, and Starry Night. Skies are adaptive scene themes rather than `.prop` files: the `.scene` saves a stable preset ID, while each preset composes crisp vector gradients, varied clouds or stars, and sun or moon details without visible panel edges or obvious repetition. Wall panels are resizable, colorable rectangles with finishes such as paint, brick, and wood; `.prop` artwork can be placed as inert scenery. Scenery artwork supports simple Far and Near layers so a muted skyline strip can sit behind colorful storefronts, houses, and trees while all scenery remains behind the action. New Scene offers Blank and one polished Town Street starter built from a Cloudy Day Sky, Far skyline, Near storefront/tree scenery, and sidewalk/road Ground; the starter creates an unsaved scene whose ordinary parts remain editable. User-created scenery artwork reuses the existing vector artwork editor and `.prop` format; no separate Scenery Editor or `.scenery` format is needed initially. The Stage placement category, saved in the `.scene`, determines whether the same artwork is inert Scenery or an interactive Prop. Visual floor finishes are chosen through the existing Ground tool and remain walkable terrain; initial outdoor looks include concrete/sidewalk and asphalt/pavement alongside indoor finishes. Wall panels are inert Scenery. Shared finish names, previews, and color controls make the relationship clear without merging their behavior. Choosing a Ground finish affects new placement, while an existing piece changes only when directly edited. Ground alone gains finishes in this version; Ramp and Step retain their current appearance and remain unconstrained for a possible future terrain redesign. The artwork catalog uses optional multi-select tags from a built-in list: Nature, Buildings, Walls, Roads & Paths, and Objects. The same tags may filter both Props and Scenery because they describe the drawing, while the Stage entry point determines behavior. **Rationale:** Children can build outdoor and indoor sets without changing movement or interaction. A scene-wide Sky avoids accidental edges during camera movement, wall panels can build finite rooms over it, and cutout artwork dresses either setting. Curated long vector assets and the editable Town Street starter make a layered outdoor scene immediately achievable without adding a general template system. Reusing the established multi-shape vector authoring workflow avoids duplicate editors, split libraries, and file conversion. Its child-facing presentation may evolve from Prop Editor toward Artwork Editor so prop-only physics and attachment controls do not dominate scenery creation. Scenery objects and wall panels are explicitly inert: they cannot block, support, move, be picked up or used, or participate in events. Sky sits farthest back, wall panels and Far scenery render over it, Near scenery renders over those, and terrain, props, and actors remain in front. Ground patterns align across adjacent painted pieces so a floor or road reads as one continuous surface. A dedicated scenery-editing flow should keep broad or overlapping background art from blocking normal selection. Artwork may appear in several browse categories, while older untagged artwork remains available under All Artwork and Unsorted. Solid walls remain terrain or future static structures, while loose bricks and planks remain props. Individual clouds, suns, or moons may still be `.prop` scenery cutouts, but whole adaptive skies are not. Animated weather, parallax, directed sky changes, user-authored sky themes, custom repeatable finish authoring, a larger starter gallery, inserting starters into existing scenes, and user-authored set templates are future workflows and do not require a scenery asset type now. **Connections:** `scripts/editors/prop/prop_editor.gd`, `scripts/editors/prop/prop_file.gd`, `scripts/props/prop_library.gd`, `scripts/editors/stage/stage_asset_browser.gd`, `scripts/terrain/terrain_block.gd`, `scripts/editors/stage/stage_terrain_painter.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/editors/stage/stage_scene_adapter.gd`, `scripts/stage/stage_selection.gd`, `scripts/editors/stage/sandbox_stage.gd`, [Stage panel UI system](../specs/stage_panel_ui_system.md).
**Sky scope:** Choosing a Sky preset changes only the sky; it does not tint or recolor terrain, props, characters, or other scenery.
@@ -26,19 +27,26 @@ Priorities are recommendations for discussion, not a committed release schedule.
**Structural scope:** Multi-level traversal and structural stairways are a separate future feature. House artwork, wall panels, and decorative stairs do not create reliable walkable floors or routes between elevations.
5. **Multi-level walkable scenes** — **Status:** Exploring · **Suggested priority:** Medium. **Intent:** Let children build a two-floor cutaway scene and direct a stickman with Move To from either floor to the other through one walkable stair connection. **Rationale:** This is separate follow-up work after the scenery feature, so navigation and locomotion changes do not delay visual set building. Upstairs/downstairs stories need reliable travel, correct-floor arrival, visible route feedback, and an understandable **No path upstairs** cue when disconnected; visual scenery alone cannot provide this. A narrow first slice uses editable lower and upper solid floors plus one child-facing Stairs connector, supports travel in both directions, and offers a way to frame both floors. The Stage has Ground, Ramp, and Step terrain with short-gap navigation links, but current movement and arrival are largely horizontal, so this needs a separate navigation and locomotion design. Keep Stairs independent from today's Ramp/Step taxonomy so their future redesign remains open. Defer additional floors, ladders, elevators, jumping routes, moving platforms, automatic camera direction, functional doors, and general building generation. **Connections:** `scripts/editors/stage/sandbox_stage.gd`, `scripts/characters/stickman/stickman_locomotion.gd`, `scripts/stage/stage_spawner.gd`, Move To backlog item 1, Scenery backlog item 4, Functional doorways backlog item 3.
6. **Multi-level walkable scenes** — **Status:** Exploring · **Suggested priority:** Medium. **Intent:** Let children build a two-floor cutaway scene and direct a stickman with Move To from either floor to the other through one walkable stair connection. **Rationale:** This is separate follow-up work after the scenery feature, so navigation and locomotion changes do not delay visual set building. Upstairs/downstairs stories need reliable travel, correct-floor arrival, visible route feedback, and an understandable **No path upstairs** cue when disconnected; visual scenery alone cannot provide this. A narrow first slice uses editable lower and upper solid floors plus one child-facing Stairs connector, supports travel in both directions, and offers a way to frame both floors. The Stage has Ground, Ramp, and Step terrain with short-gap navigation links, but current movement and arrival are largely horizontal, so this needs a separate navigation and locomotion design. Keep Stairs independent from today's Ramp/Step taxonomy so their future redesign remains open. Defer additional floors, ladders, elevators, jumping routes, moving platforms, automatic camera direction, functional doors, and general building generation. **Connections:** `scripts/editors/stage/sandbox_stage.gd`, `scripts/characters/stickman/stickman_locomotion.gd`, `scripts/stage/stage_spawner.gd`, Move To backlog item 1, Scenery backlog item 5, Functional doorways backlog item 4.
6. **Whole-scene visual filters** — **Status:** Idea · **Suggested priority:** Low. **Intent:** Let children give the complete scene a unified look such as sepia or black-and-white. **Rationale:** A filter can signal a memory, old film, or mood across the set and characters together. It is distinct from Sky, which changes only the background. Explore it after the scenery system is established, with special attention to keeping characters, selection cues, and Stage controls readable. **Connections:** `scripts/editors/stage/sandbox_stage.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/editors/stage/stage_scene_adapter.gd`, Scenery backlog item 4.
7. **Whole-scene visual filters** — **Status:** Idea · **Suggested priority:** Low. **Intent:** Let children give the complete scene a unified look such as sepia or black-and-white. **Rationale:** A filter can signal a memory, old film, or mood across the set and characters together. It is distinct from Sky, which changes only the background. Explore it after the scenery system is established, with special attention to keeping characters, selection cues, and Stage controls readable. **Connections:** `scripts/editors/stage/sandbox_stage.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/editors/stage/stage_scene_adapter.gd`, Scenery backlog item 5.
7. **Use spots on props** — **Status:** Verified · **Suggested priority:** Medium. **Intent:** Let children mark a handle, button, or lever on a placed prop and direct a stickman to use it. **Rationale:** A Use spot is distinct from a grip anchor: the prop stays in place while the stickman reaches to it. Initial direction is an instance-level spot that produces a **Used prop** event after the stickman reaches and completes the interaction. Rules can respond to use of the whole prop or filter for one specific spot; the event identifies the stickman, prop, and spot. Built-in effects such as toggling a light are deferred. The Stage needs a clear standing/reach target, visible EDIT feedback, and an understandable unreachable cue. **Phase 17b is implemented and user-verified.** See the [Phase 17 — Props for Storytelling plan](../plans/phase_17_props_for_storytelling.md). **Connections:** `scripts/props/prop_anchor_data.gd`, `scripts/props/prop_block.gd`, `scripts/characters/stickman/stickman_prop_interactor.gd`, `scripts/stage/action_registry.gd`, `scripts/stage/trigger_registry.gd`.
8. **Use spots on props** — **Status:** Verified · **Suggested priority:** Medium. **Intent:** Let children mark a handle, button, or lever on a placed prop and direct a stickman to use it. **Rationale:** A Use spot is distinct from a grip anchor: the prop stays in place while the stickman reaches to it. Initial direction is an instance-level spot that produces a **Used prop** event after the stickman reaches and completes the interaction. Rules can respond to use of the whole prop or filter for one specific spot; the event identifies the stickman, prop, and spot. Built-in effects such as toggling a light are deferred. The Stage needs a clear standing/reach target, visible EDIT feedback, and an understandable unreachable cue. **Phase 17b is implemented and user-verified.** See the [Phase 17 — Props for Storytelling plan](../plans/phase_17_props_for_storytelling.md). **Connections:** `scripts/props/prop_anchor_data.gd`, `scripts/props/prop_block.gd`, `scripts/characters/stickman/stickman_prop_interactor.gd`, `scripts/stage/action_registry.gd`, `scripts/stage/trigger_registry.gd`.
8. **Additional prop impact reactions** — **Status:** Idea · **Suggested priority:** Medium. **Intent:** Explore toppling or breaking props when they are struck or thrown. **Rationale:** The wobble response in **Phase 17c** is implemented and user-verified; this idea covers further reactions such as toppling or breaking, which remain future options. The wobble implementation is documented in the [Phase 17 plan](../plans/phase_17_props_for_storytelling.md). **Connections:** `scripts/props/prop_block.gd`, `scripts/editors/stage/sandbox_stage.gd`, `scripts/stage/trigger_registry.gd`.
9. **Additional prop impact reactions** — **Status:** Idea · **Suggested priority:** Medium. **Intent:** Explore toppling or breaking props when they are struck or thrown. **Rationale:** The wobble response in **Phase 17c** is implemented and user-verified; this idea covers further reactions such as toppling or breaking, which remain future options. The wobble implementation is documented in the [Phase 17 plan](../plans/phase_17_props_for_storytelling.md). **Connections:** `scripts/props/prop_block.gd`, `scripts/editors/stage/sandbox_stage.gd`, `scripts/stage/trigger_registry.gd`.
9. **Group props into set pieces** — **Status:** Parked · **Suggested priority:** Low. **Intent:** Let children group objects for organization and moving together within one scene. **Rationale:** Deferred until later at the user's direction. Scene-only groups make arranging furnished sets faster. In PLAY, each item keeps its own physics and interaction; picking up one prop does not move the rest, and stopping PLAY returns the grouped items to their authored arrangement. Reusable set-piece assets are deferred. This is **Phase 17d** in the [Phase 17 — Props for Storytelling plan](../plans/phase_17_props_for_storytelling.md). **Connections:** `scripts/stage/stage_selection.gd`, `scripts/editors/stage/sandbox_stage.gd`, `scripts/stage/stage_scene_io.gd`.
10. **Group props into set pieces** — **Status:** Parked · **Suggested priority:** Low. **Intent:** Let children group objects for organization and moving together within one scene. **Rationale:** Deferred until later at the user's direction. Scene-only groups make arranging furnished sets faster. In PLAY, each item keeps its own physics and interaction; picking up one prop does not move the rest, and stopping PLAY returns the grouped items to their authored arrangement. Reusable set-piece assets are deferred. This is **Phase 17d** in the [Phase 17 — Props for Storytelling plan](../plans/phase_17_props_for_storytelling.md). **Connections:** `scripts/stage/stage_selection.gd`, `scripts/editors/stage/sandbox_stage.gd`, `scripts/stage/stage_scene_io.gd`.
10. **Stage-relative prop and scenery scale** — **Status:** Planned — Phase 19 addition; UI approved · **Suggested priority:** Medium. **Intent:** Establish one recognizable Stage scale based on the standard stickman, then author each asset at the size its role calls for so ordinary placements start correctly without routine resizing. **Rationale:** The Prop Editor already shows a stickman guide, but its current Normalize Size target is 96 world units against an approximately 940-unit standing figure; current artwork dimensions also vary widely. The approved Phase 19 addition replaces Normalize Size with a reversible **Size for Stage** preview: Small/Room/Big starts plus fine adjustment, a foreground 1:1 figure, and a floor guide. Artist chooses ordinary dimensions from asset intent; new placements use authored size. Individual Scenery resize commits on release, while group resize keeps its existing Keep/Cancel preview. Loading performs no automatic normalization. Ordinary Save and live-reference behavior remain, with no new copy/version prompts. Phase 19 user/editor verification remains pending. **Connections:** [Phase 10a Prop Scaling](../specs/phase_10a_prop_scaling.md), [Phase 19 Scenery, Backgrounds, and Surface Finishes](../plans/phase_19_scenery_backgrounds_surface_finishes.md), [approved Phase 19 UI](../specs/phase_19_scenery_backgrounds_ui_ux.md), `scripts/editors/prop/prop_editor.gd`, `scripts/editors/prop/prop_canvas.gd`, `scripts/editors/stickman/whole_stickman_preview.gd`.
11. **Stage-relative prop and scenery scale** — **Status:** Planned — Phase 19 addition; UI approved · **Suggested priority:** Medium. **Intent:** Establish one recognizable Stage scale based on the standard stickman, then author each asset at the size its role calls for so ordinary placements start correctly without routine resizing. **Rationale:** The Prop Editor already shows a stickman guide, but its current Normalize Size target is 96 world units against an approximately 940-unit standing figure; current artwork dimensions also vary widely. The approved Phase 19 addition replaces Normalize Size with a reversible **Size for Stage** preview: Small/Room/Big starts plus fine adjustment, a foreground 1:1 figure, and a floor guide. Artist chooses ordinary dimensions from asset intent; new placements use authored size. Individual Scenery resize commits on release, while group resize keeps its existing Keep/Cancel preview. Loading performs no automatic normalization. Ordinary Save and live-reference behavior remain, with no new copy/version prompts. Phase 19 user/editor verification remains pending. **Connections:** [Phase 10a Prop Scaling](../specs/phase_10a_prop_scaling.md), [Phase 19 Scenery, Backgrounds, and Surface Finishes](../plans/phase_19_scenery_backgrounds_surface_finishes.md), [approved Phase 19 UI](../specs/phase_19_scenery_backgrounds_ui_ux.md), `scripts/editors/prop/prop_editor.gd`, `scripts/editors/prop/prop_canvas.gd`, `scripts/editors/stickman/whole_stickman_preview.gd`.
11. **Size-aware artwork previews** — **Status:** Idea · **Suggested priority:** Low. **Intent:** Show a stickman silhouette or simple relative-size cue in artwork catalog previews so children can judge an asset before placing it. **Rationale:** Once asset dimensions are authored consistently, a size cue could reduce trial-and-error in the Stage without forcing objects to a fixed size. This depends on a settled Stage scale convention and should remain a browse-time hint, not an automatic resize. **Connections:** Stage-relative prop and scenery scale (backlog item 10), `scripts/editors/stage/stage_asset_browser.gd`, `scripts/shared/ui/asset_card.gd`.
12. **Size-aware artwork previews** — **Status:** Idea · **Suggested priority:** Low. **Intent:** Show a stickman silhouette or simple relative-size cue in artwork catalog previews so children can judge an asset before placing it. **Rationale:** Once asset dimensions are authored consistently, a size cue could reduce trial-and-error in the Stage without forcing objects to a fixed size. This depends on a settled Stage scale convention and should remain a browse-time hint, not an automatic resize. **Connections:** Stage-relative prop and scenery scale (backlog item 11), `scripts/editors/stage/stage_asset_browser.gd`, `scripts/shared/ui/asset_card.gd`.
13. **Backdrop horizon and edge treatment** — **Status:** Idea · **Suggested priority:** Medium. **Intent:** Make the transitions between Sky, broad background surfaces, scenery, and walkable Ground easy to understand and visually intentional. **Rationale:** A grass backdrop can look like a floating rectangle if its edge does not meet the scene composition cleanly. A horizon line, soft terrain contour, or deliberate overlap could help establish depth without adding collision. **Connections:** Distant background surfaces (backlog item 2), Scenery backlog item 5.
14. **Shared scenery color palette** — **Status:** Idea · **Suggested priority:** Low. **Intent:** Reuse a small set of accessible color swatches across wall finishes and recolorable background materials. **Rationale:** Familiar choices help children coordinate walls, carpet, and other set pieces without opening a full picker for every edit. This should complement a full color picker where requested and preserve material texture. **Connections:** Distant background surfaces (backlog item 2), Scenery backlog item 5.
15. **Duplicate background surface** — **Status:** Idea · **Suggested priority:** Low. **Intent:** Let children duplicate a selected distant surface to extend a backdrop while keeping its material and color. **Rationale:** Repeating a configured panel may be faster than rebuilding a long landscape or room background from scratch. **Connections:** Distant background surfaces (backlog item 2), `scripts/stage/stage_selection.gd`.
## Entry format
@@ -0,0 +1,74 @@
# Phase 19 — Background Ground and Wall Color
> **Status:** Accepted product design for a focused Phase 19 follow-up. This brief is the Designer handoff to UI/UX and architecture specifications; it does not define runtime APIs or node structure.
## 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.
@@ -0,0 +1,150 @@
# Phase 19 follow-up — Background ground and wall color: architecture specification
> **Status:** Proposed for final sign-off. Implements the accepted [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 is a Stage follow-up; the existing walkable Ground, Prop physics, and artwork formats retain their 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:** UI/UX handoff for the accepted [Background Ground and Wall Color brief](../plans/phase_19_background_ground_and_wall_color.md). This extends the [approved Phase 19 Scenery UI specification](phase_19_scenery_backgrounds_ui_ux.md); its existing Sky, artwork, Edit Scenery, and placement behavior still applies.
## 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.
+22 -2
View File
@@ -108,11 +108,19 @@
"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
},
"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": 10
}
},
"palette": {
@@ -196,6 +204,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" },
+13 -1
View File
@@ -57,10 +57,22 @@ layout_mode = 2
size_flags_horizontal = 3
visible = false
[node name="GridContainer" type="GridContainer" parent="MarginContainer/VBoxContainer"]
[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
+432 -10
View File
@@ -53,9 +53,15 @@ const STAGE_SCENERY_CONTROLLER_SCRIPT := preload("res://scripts/editors/stage/st
const STAGE_SKY_VIEW_SCRIPT := preload("res://scripts/stage/scenery/stage_sky_view.gd")
const STAGE_DRAW_ORDER_SCRIPT := preload("res://scripts/stage/stage_draw_order.gd")
const STAGE_WALL_PANEL_SCRIPT := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const STAGE_BACKGROUND_GROUND_SCRIPT := preload("res://scripts/stage/scenery/stage_background_ground.gd")
const STAGE_RECT_SURFACE_SCRIPT := preload("res://scripts/stage/scenery/stage_rect_surface.gd")
const STAGE_SCENERY_ARTWORK_SCRIPT := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
const STAGE_SURFACE_RENDERER_SCRIPT := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const STAGE_INSPECTOR_PANEL_SCRIPT := preload("res://scripts/shared/stage_ui/stage_inspector_panel.gd")
const STAGE_PANEL_CHOICE_GROUP_SCRIPT := preload("res://scripts/shared/stage_ui/stage_panel_choice_group.gd")
const STAGE_SURFACE_COLOR_CONTROLS_SCRIPT := preload("res://scripts/shared/stage_ui/stage_surface_color_controls.gd")
const STAGE_SURFACE_COLOR_DIALOG_SCRIPT := preload("res://scripts/shared/stage_ui/stage_surface_color_dialog.gd")
const STAGE_SURFACE_PREVIEW_SCRIPT := preload("res://scripts/stage/scenery/stage_surface_preview.gd")
const PROP_EDITOR_SCENE := preload("res://scenes/editors/prop/prop_editor.tscn")
# Phase 3c editor tools.
@@ -125,6 +131,7 @@ const SPEECH_DRAFT_DEBOUNCE: float = STAGE_SPEECH_SESSION_SCRIPT.DRAFT_DEBOUNCE
const SPEECH_DRAFT_SCHEMA: int = STAGE_SPEECH_SESSION_SCRIPT.DRAFT_SCHEMA
const SPEECH_BUBBLE_DRAG_THRESHOLD: float = STAGE_SPEECH_SESSION_SCRIPT.BUBBLE_DRAG_THRESHOLD
const RECENT_SCENE_LIMIT: int = STAGE_DOCUMENT_CONTROLLER_SCRIPT.RECENT_LIMIT
const RECENT_SURFACE_COLOR_LIMIT: int = 8
const DEFAULT_SCENE_NAME: String = "Untitled"
const DEFAULT_CAMERA_POSITION: Vector2 = Vector2(0.0, -400.0)
const DEFAULT_CAMERA_ZOOM: float = 0.5
@@ -248,6 +255,7 @@ var _suppressed_contact_pairs: Dictionary:
var _spawner: StageSpawner
var _selection: StageSelection
var _gizmos: StageGizmos
var _stage_input_ready: bool = false
var _grid: StageGrid
var _spatial_index: StageSpatialIndex
var _terrain_painter: StageTerrainPainter
@@ -268,6 +276,13 @@ var _scenery_group_start: Vector2 = Vector2.ZERO
var _scenery_placed_count: int = 0
var _scenery_inspector: StageInspectorPanel
var _scenery_repair_message: Label
var _scenery_surface_section: VBoxContainer
var _scenery_wall_look_heading: Label
var _scenery_wall_look_group: StagePanelChoiceGroup
var _scenery_background_ground_look_heading: Label
var _scenery_background_ground_look_group: StagePanelChoiceGroup
var _scenery_surface_helper: Label
var _scenery_surface_colors: StageSurfaceColorControls
var _scenery_depth_section: VBoxContainer
var _scenery_depth_heading: Label
var _scenery_depth_group: StagePanelChoiceGroup
@@ -276,6 +291,7 @@ var _scenery_size_row: HBoxContainer
var _scenery_smaller_button: Button
var _scenery_bigger_button: Button
var _scenery_erase_wall_area_button: Button
var _scenery_erase_piece_button: Button
var _scenery_find_button: Button
var _scenery_choose_button: Button
var _scenery_keep_button: Button
@@ -284,6 +300,11 @@ var _scenery_repair_candidate: StageSceneryArtwork
var _scenery_repair_path: String = ""
var _missing_scenery_dialog: ConfirmationDialog
var _scenery_repair_confirm_dialog: ConfirmationDialog
var _scenery_erase_piece_dialog: ConfirmationDialog
var _scenery_erase_piece_targets: Array[WeakRef] = []
var _surface_color_dialog: StageSurfaceColorDialog
var _surface_color_session: Dictionary = {}
var _recent_surface_colors: Array[Color] = []
var _group_preview_save_dialog: ConfirmationDialog
var _pending_group_save_path: String = ""
var _rule_engine: StageRuleEngine
@@ -1330,6 +1351,7 @@ func _ready() -> void:
_update_scene_chrome()
_handle_cli_startup(OS.get_cmdline_user_args())
_check_speech_recovery()
_stage_input_ready = true
func _process(delta: float) -> void:
@@ -1400,7 +1422,7 @@ func _update_throw_preview_cache() -> void:
# ---------------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if _hosted_prop_editor != null:
if _hosted_prop_editor != null or not _stage_input_ready:
return
if _speech_session != null:
_speech_session.set_camera_panning(_panning)
@@ -1530,6 +1552,17 @@ func _unhandled_key_input(event: InputEvent) -> void:
_exit_scenery_edit_mode()
get_viewport().set_input_as_handled()
return
if event.keycode in [KEY_DELETE, KEY_BACKSPACE] and _scenery_edit_mode and current_mode == StageMode.EDIT:
var selected_scenery: Array[Node2D] = _selection.get_selected() if _selection != null else []
var has_surface: bool = false
for selected_node: Node2D in selected_scenery:
if selected_node.get_script() == STAGE_WALL_PANEL_SCRIPT or selected_node.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT:
has_surface = true
break
if has_surface:
_request_erase_selected_scenery()
get_viewport().set_input_as_handled()
return
match event.keycode:
KEY_DELETE, KEY_BACKSPACE:
if current_mode == StageMode.EDIT:
@@ -1795,6 +1828,7 @@ func set_mode(mode: StageMode) -> void:
_show_toast("Finish or cancel speech editing first.")
_sync_mode_buttons()
return
_cancel_surface_color_preview()
if _scenery_controller != null:
if _scenery_controller.is_group_preview_pending():
_scenery_controller.cancel_group()
@@ -2555,6 +2589,10 @@ func _validate_queues_for_save() -> String:
func _reset_scene_content() -> void:
set_mode(StageMode.EDIT)
_cancel_surface_color_preview()
if _scenery_erase_piece_dialog != null:
_scenery_erase_piece_dialog.hide()
_clear_erase_scenery_targets()
if _scenery_repair_confirm_dialog != null:
_scenery_repair_confirm_dialog.hide()
_cancel_scenery_repair_preview()
@@ -3033,6 +3071,8 @@ func _build_ui() -> void:
_asset_browser.make_artwork_requested.connect(_open_stage_artwork_editor)
_asset_browser.build_ui(ui, _ui_font, _emoji_font, _sandbox_theme)
_asset_browser.edit_scenery_requested.connect(_enter_scenery_edit_mode)
_asset_browser.surface_color_confirmed.connect(_record_recent_surface_color)
_asset_browser.set_surface_color_history(_recent_surface_colors)
_build_ground_finish_popup(ui)
_modal_views.build_file_dialogs(ui)
@@ -3122,6 +3162,43 @@ func _build_scenery_inspector(ui: CanvasLayer) -> void:
_scenery_repair_message.name = "MissingArtworkMessage"
_scenery_repair_message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
body.add_child(_scenery_repair_message)
_scenery_surface_section = VBoxContainer.new()
_scenery_surface_section.name = "ScenerySurfaceSettings"
_scenery_surface_section.add_theme_constant_override("separation", _stage_panel_integer("shell.section_gap", 12, 0, 32))
_scenery_wall_look_heading = _make_scenery_section_heading("Look")
_scenery_surface_section.add_child(_scenery_wall_look_heading)
_scenery_wall_look_group = STAGE_PANEL_CHOICE_GROUP_SCRIPT.new() as StagePanelChoiceGroup
_scenery_wall_look_group.name = "WallPanelLooks"
_scenery_wall_look_group.add_choice(&"finish_wall_paint", "Paint", _make_surface_choice_cue(&"wall", &"finish_wall_paint"))
_scenery_wall_look_group.add_choice(&"finish_wall_brick", "Brick", _make_surface_choice_cue(&"wall", &"finish_wall_brick"))
_scenery_wall_look_group.add_choice(&"finish_wall_wood", "Wood", _make_surface_choice_cue(&"wall", &"finish_wall_wood"))
_scenery_wall_look_group.apply_theme(_sandbox_theme as SandboxTheme)
_scenery_wall_look_group.choice_selected.connect(_on_selected_surface_look_choice)
_scenery_surface_section.add_child(_scenery_wall_look_group)
_scenery_background_ground_look_heading = _make_scenery_section_heading("Look")
_scenery_surface_section.add_child(_scenery_background_ground_look_heading)
_scenery_background_ground_look_group = STAGE_PANEL_CHOICE_GROUP_SCRIPT.new() as StagePanelChoiceGroup
_scenery_background_ground_look_group.name = "BackgroundGroundLooks"
_scenery_background_ground_look_group.add_choice(&"finish_background_grass", "Grass", _make_surface_choice_cue(&"background_ground", &"finish_background_grass"))
_scenery_background_ground_look_group.add_choice(&"finish_background_concrete", "Concrete", _make_surface_choice_cue(&"background_ground", &"finish_background_concrete"))
_scenery_background_ground_look_group.add_choice(&"finish_background_carpet", "Carpet", _make_surface_choice_cue(&"background_ground", &"finish_background_carpet"))
_scenery_background_ground_look_group.apply_theme(_sandbox_theme as SandboxTheme)
_scenery_background_ground_look_group.choice_selected.connect(_on_selected_surface_look_choice)
_scenery_surface_section.add_child(_scenery_background_ground_look_group)
_scenery_surface_helper = Label.new()
_scenery_surface_helper.name = "ScenerySurfaceHelper"
_scenery_surface_helper.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_scenery_surface_helper.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
_scenery_surface_helper.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
_apply_ui_font(_scenery_surface_helper)
_scenery_surface_section.add_child(_scenery_surface_helper)
_scenery_surface_colors = STAGE_SURFACE_COLOR_CONTROLS_SCRIPT.new() as StageSurfaceColorControls
_scenery_surface_colors.name = "ScenerySurfaceColors"
_scenery_surface_colors.configure(Color.WHITE, _recent_surface_colors, _surface_color_presets(), _sandbox_theme as SandboxTheme)
_scenery_surface_colors.color_selected.connect(_on_selected_surface_color_selected)
_scenery_surface_colors.custom_color_requested.connect(_open_selected_surface_color_dialog)
_scenery_surface_section.add_child(_scenery_surface_colors)
body.add_child(_scenery_surface_section)
_scenery_depth_section = VBoxContainer.new()
_scenery_depth_section.name = "SceneryDepthSection"
_scenery_depth_section.add_theme_constant_override("separation", 6)
@@ -3162,6 +3239,9 @@ func _build_scenery_inspector(ui: CanvasLayer) -> void:
_scenery_erase_wall_area_button = _make_scenery_repair_button("EraseWallArea", "Erase wall area", true)
_scenery_erase_wall_area_button.pressed.connect(_begin_selected_wall_erase)
body.add_child(_scenery_erase_wall_area_button)
_scenery_erase_piece_button = _make_scenery_repair_button("EraseSceneryPiece", "Erase wall panel", true)
_scenery_erase_piece_button.pressed.connect(_request_erase_selected_scenery)
body.add_child(_scenery_erase_piece_button)
_scenery_find_button = _make_scenery_repair_button("FindArtwork", "Find artwork…", true)
_scenery_find_button.pressed.connect(func() -> void: _begin_scenery_repair(true))
body.add_child(_scenery_find_button)
@@ -3192,10 +3272,53 @@ func _build_scenery_inspector(ui: CanvasLayer) -> void:
_scenery_repair_confirm_dialog.canceled.connect(_cancel_scenery_repair_preview)
ui.add_child(_scenery_repair_confirm_dialog)
_apply_dialog_theme(_scenery_repair_confirm_dialog)
_scenery_erase_piece_dialog = ConfirmationDialog.new()
_scenery_erase_piece_dialog.name = "EraseSceneryPieceDialog"
_scenery_erase_piece_dialog.title = "Erase this piece?"
_scenery_erase_piece_dialog.ok_button_text = "Erase"
_scenery_erase_piece_dialog.cancel_button_text = "Keep it"
_scenery_erase_piece_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(520, 260))
_scenery_erase_piece_dialog.confirmed.connect(_confirm_erase_selected_scenery)
_scenery_erase_piece_dialog.canceled.connect(_clear_erase_scenery_targets)
ui.add_child(_scenery_erase_piece_dialog)
_apply_dialog_theme(_scenery_erase_piece_dialog)
_surface_color_dialog = STAGE_SURFACE_COLOR_DIALOG_SCRIPT.new() as StageSurfaceColorDialog
_surface_color_dialog.name = "ScenerySurfaceColorDialog"
ui.add_child(_surface_color_dialog)
_surface_color_dialog.preview_color_changed.connect(_preview_selected_surface_color)
_surface_color_dialog.color_confirmed.connect(_confirm_selected_surface_color)
_surface_color_dialog.cancelled.connect(_on_surface_color_dialog_cancelled)
if not get_viewport().size_changed.is_connected(_refresh_scenery_inspector_layout):
get_viewport().size_changed.connect(_refresh_scenery_inspector_layout)
func _make_scenery_section_heading(label_text: String) -> Label:
var heading := Label.new()
heading.text = label_text
heading.add_theme_font_size_override("font_size", _stage_panel_integer("type.section_size", 20, 12, 40))
heading.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA")))
_apply_ui_font(heading)
return heading
func _make_surface_choice_cue(kind: StringName, finish: StringName) -> StageSurfacePreview:
var cue: StageSurfacePreview = STAGE_SURFACE_PREVIEW_SCRIPT.new() as StageSurfacePreview
cue.custom_minimum_size = Vector2(64.0, 40.0)
cue.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
cue.configure(kind, finish, STAGE_SURFACE_RENDERER_SCRIPT.default_tint(finish))
return cue
func _surface_color_presets() -> Array[Color]:
var colors: Array[Color] = []
var raw_presets: Variant = _sandbox_theme.value("widgets.swatches", []) if _sandbox_theme != null else []
if raw_presets is Array:
for raw_color: Variant in raw_presets as Array:
if raw_color is String and STAGE_SCENE_IO._valid_color(raw_color):
colors.append(STAGE_SCENE_IO.color_from_data(raw_color, Color.WHITE))
return colors
func _make_scenery_repair_button(node_name: String, label: String, prominent: bool) -> Button:
var button: Button = Button.new()
button.name = node_name
@@ -4221,6 +4344,8 @@ func _open_selector(kind: String) -> void:
_scenery_group_mode = &""
for pid: String in _palette_buttons:
(_palette_buttons[pid] as Button).set_pressed_no_signal(pid == kind)
if kind == "scenery":
_asset_browser.set_surface_color_history(_recent_surface_colors)
var selector_state: Dictionary = _asset_browser.open_selector(kind, _spawner.get_selected_stickman_path(), _sky_id)
if kind == "stickman":
var selected_stickman_path: String = String(selector_state.get("selected_stickman_path", _spawner.get_selected_stickman_path()))
@@ -4260,12 +4385,25 @@ func _on_asset_selected(entry: Dictionary) -> void:
if background_kind == "wall":
_close_selector()
if _scenery_controller != null:
var finish_id: StringName = StringName(String(entry.get("finish_id", "finish_wall_paint")))
var tint_value: Variant = entry.get("color", STAGE_SURFACE_RENDERER_SCRIPT.default_tint(finish_id))
var tint: Color = tint_value if tint_value is Color else STAGE_SURFACE_RENDERER_SCRIPT.default_tint(finish_id)
_scenery_controller.begin_wall(
StringName(String(entry.get("finish_id", "finish_wall_paint"))),
_viewport_to_world(get_viewport().get_mouse_position())
finish_id,
_viewport_to_world(get_viewport().get_mouse_position()),
tint
)
_refresh_scenery_capture_bar()
return
if background_kind == "background_ground":
_close_selector()
if _scenery_controller != null:
var finish_id: StringName = StringName(String(entry.get("finish_id", "finish_background_grass")))
var tint_value: Variant = entry.get("color", STAGE_SURFACE_RENDERER_SCRIPT.default_tint(finish_id))
var tint: Color = tint_value if tint_value is Color else STAGE_SURFACE_RENDERER_SCRIPT.default_tint(finish_id)
_scenery_controller.begin_background_ground(finish_id, _viewport_to_world(get_viewport().get_mouse_position()), tint)
_refresh_scenery_capture_bar()
return
var art_path: String = String(entry.get("path", entry.get("id", "")))
_close_selector()
if _scenery_controller == null or not _scenery_controller.begin_artwork(art_path, &"near"):
@@ -4362,8 +4500,13 @@ func _refresh_scenery_inspector() -> void:
_scenery_depth_section.visible = false
if _scenery_depth_group != null:
_scenery_depth_group.clear_no_signal()
if _scenery_wall_look_group != null:
_scenery_wall_look_group.clear_no_signal()
if _scenery_background_ground_look_group != null:
_scenery_background_ground_look_group.clear_no_signal()
var selected: Array[Node2D] = _selection.get_selected() if _selection != null else []
if not _scenery_edit_mode or selected.size() != 1 or not _is_scenery_node(selected[0]):
_cancel_surface_color_preview()
if _scenery_repair_candidate != null:
_cancel_scenery_repair_preview()
_scenery_repair_target = null
@@ -4371,15 +4514,45 @@ func _refresh_scenery_inspector() -> void:
return
var node: Node2D = selected[0]
var is_artwork: bool = node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT
var is_wall: bool = node.get_script() == STAGE_WALL_PANEL_SCRIPT
var is_background_ground: bool = node.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT
var surface: StageRectSurface = node as StageRectSurface if is_wall or is_background_ground else null
var artwork: StageSceneryArtwork = node as StageSceneryArtwork if is_artwork else null
var is_missing: bool = artwork != null and artwork.missing
var surface_is_selected_dialog_target: bool = _surface_color_session_targets(surface)
if not _surface_color_session.is_empty() and not surface_is_selected_dialog_target:
_cancel_surface_color_preview()
_scenery_surface_section.visible = surface != null
_scenery_wall_look_heading.visible = is_wall
_scenery_wall_look_group.visible = is_wall
_scenery_background_ground_look_heading.visible = is_background_ground
_scenery_background_ground_look_group.visible = is_background_ground
_scenery_surface_helper.visible = surface != null
_scenery_surface_colors.visible = false
if surface != null:
if is_wall:
_scenery_wall_look_group.select_no_signal(surface.finish_id)
_scenery_surface_helper.text = "Looks like a wall · Stickmen can pass through."
else:
_scenery_background_ground_look_group.select_no_signal(surface.finish_id)
_scenery_surface_helper.text = "Decorative only · Stickmen don’t walk here."
var color_editable: bool = is_wall or surface.finish_id == &"finish_background_carpet"
_scenery_surface_colors.visible = color_editable
if color_editable:
_scenery_surface_colors.set_selected_no_signal(surface.tint)
_scenery_surface_colors.set_recent_colors(_recent_surface_colors)
_scenery_depth_section.visible = is_artwork
if artwork != null:
_scenery_depth_group.select_no_signal(artwork.depth)
_scenery_size_row.visible = not is_missing or artwork.has_known_bounds
_scenery_smaller_button.disabled = is_missing and not artwork.has_known_bounds
_scenery_bigger_button.disabled = is_missing and not artwork.has_known_bounds
_scenery_erase_wall_area_button.visible = not is_artwork
_scenery_erase_wall_area_button.visible = is_wall
_scenery_erase_piece_button.visible = surface != null
if is_background_ground:
_scenery_erase_piece_button.text = "Erase background ground"
else:
_scenery_erase_piece_button.text = "Erase wall panel"
_scenery_find_button.visible = is_missing
_scenery_choose_button.visible = is_missing
_scenery_keep_button.visible = is_missing
@@ -4390,7 +4563,7 @@ func _refresh_scenery_inspector() -> void:
_scenery_repair_message.text = "“%s” is missing its picture file. Choose artwork to put it back in this spot." % artwork.display_name
else:
_scenery_repair_target = null
var scenery_name: String = (artwork.display_name if artwork != null else "Wall panel")
var scenery_name: String = "Wall panel" if is_wall else "Background ground" if is_background_ground else artwork.display_name
_scenery_inspector.set_header(scenery_name, "🖼️" if artwork != null else "🧱")
_scenery_repair_message.text = "Make this scenery smaller or bigger. The other scenery stays where it is."
_scenery_inspector.set_safe_rect(_prop_settings_safe_rect())
@@ -4419,7 +4592,184 @@ func _on_selected_scenery_depth_choice(id: StringName) -> void:
func _is_scenery_node(node: Node2D) -> bool:
return node != null and (node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT or node.get_script() == STAGE_WALL_PANEL_SCRIPT)
return node != null and (node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT or node.get_script() == STAGE_WALL_PANEL_SCRIPT or node.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT)
func _on_selected_surface_look_choice(finish: StringName) -> void:
var surface: StageRectSurface = _selected_rect_surface()
if surface == null:
_refresh_scenery_inspector()
return
var is_wall: bool = surface.get_script() == STAGE_WALL_PANEL_SCRIPT
var valid_finish: bool = STAGE_SURFACE_RENDERER_SCRIPT.is_wall_finish(finish) if is_wall else STAGE_SURFACE_RENDERER_SCRIPT.is_background_finish(finish)
if not valid_finish:
_refresh_scenery_inspector()
return
if surface.finish_id != finish:
surface.configure(surface.panel_size, finish, surface.tint)
_mark_scene_dirty()
_refresh_scenery_inspector()
func _selected_rect_surface() -> StageRectSurface:
if not _scenery_edit_mode or current_mode != StageMode.EDIT:
return null
var selected: Array[Node2D] = _selection.get_selected() if _selection != null else []
if selected.size() != 1 or not (selected[0].get_script() == STAGE_WALL_PANEL_SCRIPT or selected[0].get_script() == STAGE_BACKGROUND_GROUND_SCRIPT):
return null
return selected[0] as StageRectSurface
func _on_selected_surface_color_selected(color: Color) -> void:
var surface: StageRectSurface = _selected_rect_surface()
if surface == null:
_refresh_scenery_inspector()
return
var color_changed: bool = not surface.tint.is_equal_approx(color)
if color_changed:
surface.clear_preview_tint()
surface.configure(surface.panel_size, surface.finish_id, color)
_mark_scene_dirty()
_record_recent_surface_color(color)
_scenery_surface_colors.set_selected_no_signal(surface.tint)
func _open_selected_surface_color_dialog() -> void:
var surface: StageRectSurface = _selected_rect_surface()
if surface == null or _surface_color_dialog == null:
return
if surface.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT and surface.finish_id != &"finish_background_carpet":
return
_cancel_surface_color_preview()
var kind: StringName = &"background_ground" if surface.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT else &"wall"
_surface_color_session = {
"target": weakref(surface),
"original_color": surface.tint,
"tentative_color": surface.tint,
"surface_kind": kind,
"finish_id": surface.finish_id
}
_surface_color_dialog.configure(kind, surface.finish_id, surface.tint, _sandbox_theme as SandboxTheme)
_surface_color_dialog.open_dialog()
func _surface_color_session_targets(surface: StageRectSurface) -> bool:
if _surface_color_session.is_empty() or surface == null:
return false
var target_ref: WeakRef = _surface_color_session.get("target") as WeakRef
return target_ref != null and target_ref.get_ref() == surface
func _preview_selected_surface_color(color: Color) -> void:
if _surface_color_session.is_empty():
return
var target_ref: WeakRef = _surface_color_session.get("target") as WeakRef
var surface: StageRectSurface = target_ref.get_ref() as StageRectSurface if target_ref != null else null
if surface == null or not _is_surface_live_world_child(surface):
_cancel_surface_color_preview()
return
_surface_color_session["tentative_color"] = color
surface.set_preview_tint(color)
func _confirm_selected_surface_color(color: Color) -> void:
if _surface_color_session.is_empty():
return
var target_ref: WeakRef = _surface_color_session.get("target") as WeakRef
var surface: StageRectSurface = target_ref.get_ref() as StageRectSurface if target_ref != null else null
if surface == null or not _is_surface_live_world_child(surface):
_cancel_surface_color_preview()
return
var color_changed: bool = not surface.tint.is_equal_approx(color)
surface.clear_preview_tint()
if color_changed:
surface.configure(surface.panel_size, surface.finish_id, color)
_surface_color_session.clear()
_record_recent_surface_color(color)
if color_changed:
_mark_scene_dirty()
_refresh_scenery_inspector()
call_deferred("_focus_surface_color_control")
func _on_surface_color_dialog_cancelled() -> void:
_cancel_surface_color_preview()
call_deferred("_focus_surface_color_control")
func _focus_surface_color_control() -> void:
if _scenery_inspector != null and _scenery_inspector.visible and _scenery_surface_colors != null and _scenery_surface_colors.visible:
_scenery_surface_colors.focus_custom_button()
func _cancel_surface_color_preview() -> void:
if _surface_color_session.is_empty():
if _surface_color_dialog != null and _surface_color_dialog.visible:
_surface_color_dialog.hide()
return
var target_ref: WeakRef = _surface_color_session.get("target") as WeakRef
var surface: StageRectSurface = target_ref.get_ref() as StageRectSurface if target_ref != null else null
if surface != null and is_instance_valid(surface):
surface.clear_preview_tint()
_surface_color_session.clear()
if _surface_color_dialog != null and _surface_color_dialog.visible:
_surface_color_dialog.hide()
if _scenery_inspector != null and is_instance_valid(_scenery_inspector):
_scenery_inspector.queue_sort()
func _is_surface_live_world_child(surface: StageRectSurface) -> bool:
return surface != null and is_instance_valid(surface) and _world != null and is_instance_valid(_world) and surface.get_parent() == _world and _scenery_edit_mode and current_mode == StageMode.EDIT
func _request_erase_selected_scenery() -> void:
var selected: Array[Node2D] = _selection.get_selected() if _selection != null else []
if selected.is_empty() or not _scenery_edit_mode or current_mode != StageMode.EDIT:
return
var targets: Array[Node2D] = []
for node: Node2D in selected:
if _is_scenery_node(node) and node.get_parent() == _world:
targets.append(node)
if targets.is_empty():
return
_clear_erase_scenery_targets()
for node: Node2D in targets:
_scenery_erase_piece_targets.append(weakref(node))
var count: int = targets.size()
if count == 1:
_scenery_erase_piece_dialog.dialog_text = "Erase this piece? You can’t undo this."
else:
_scenery_erase_piece_dialog.dialog_text = "Erase these %d scenery pieces? You can’t undo this." % count
_scenery_erase_piece_dialog.popup_centered()
func _confirm_erase_selected_scenery() -> void:
var erased: Array[Node2D] = []
if not _scenery_edit_mode or current_mode != StageMode.EDIT or _world == null or not is_instance_valid(_world):
_clear_erase_scenery_targets()
return
for target_ref: WeakRef in _scenery_erase_piece_targets:
var node: Node2D = target_ref.get_ref() as Node2D if target_ref != null else null
if node == null or not is_instance_valid(node) or node.get_parent() != _world or not _is_scenery_node(node):
continue
_clear_object_state(node)
node.queue_free()
erased.append(node)
_clear_erase_scenery_targets()
if erased.is_empty():
return
if _selection != null:
_selection.clear_selection()
_selection.clear_hover()
_director_visuals.mark_dirty()
object_deleted.emit(erased)
_mark_scene_dirty()
_refresh_scenery_inspector()
_refresh_status()
func _clear_erase_scenery_targets() -> void:
_scenery_erase_piece_targets.clear()
func _step_selected_scenery_size(factor: float) -> void:
@@ -4546,6 +4896,10 @@ func _enter_scenery_edit_mode() -> void:
func _exit_scenery_edit_mode() -> void:
_cancel_surface_color_preview()
if _scenery_erase_piece_dialog != null:
_scenery_erase_piece_dialog.hide()
_clear_erase_scenery_targets()
if _scenery_repair_confirm_dialog != null:
_scenery_repair_confirm_dialog.hide()
_cancel_scenery_repair_preview()
@@ -4605,7 +4959,16 @@ func _refresh_scenery_capture_bar() -> void:
else:
actions.append({"id": "done_tool", "label": "Done", "primary": true})
actions.append({"id": "cancel_tool", "label": "✕ Cancel"})
_use_target_capture_bar.set_capture_actions("Drag to draw wall · Stickmen can pass through", actions)
if _scenery_controller.get_active_tool() == &"background_ground":
var ground_prompt: String = "Draw background ground · No walking"
if _scenery_placed_count > 0:
ground_prompt = "Draw another · placed pieces stay"
_use_target_capture_bar.set_capture_actions(ground_prompt, actions)
else:
var wall_prompt: String = "Draw wall panel · Stickmen pass through"
if _scenery_placed_count > 0:
wall_prompt = "Draw another · placed pieces stay"
_use_target_capture_bar.set_capture_actions(wall_prompt, actions)
return
if _scenery_controller != null and _scenery_controller.is_group_preview_pending():
_use_target_capture_bar.clear_horizontal_safe_bounds()
@@ -4622,6 +4985,8 @@ func _refresh_scenery_capture_bar() -> void:
var target_name: String = "scenery"
if target != null and target.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT:
target_name = (target as StageSceneryArtwork).display_name
elif target != null and target.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT:
target_name = "background ground"
elif target != null:
target_name = "wall panel"
var is_move: bool = _scenery_controller.get_single_transform_kind() == &"move"
@@ -4737,7 +5102,7 @@ func _select_all_scenery() -> void:
_selection.clear_selection()
for child: Node in _world.get_children():
var node: Node2D = child as Node2D
if node != null and (node.get_script() == STAGE_WALL_PANEL_SCRIPT or node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT):
if node != null and (node.get_script() == STAGE_WALL_PANEL_SCRIPT or node.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT or node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT):
_selection.add_to_selection(node)
_refresh_scenery_capture_bar()
@@ -6920,7 +7285,7 @@ func _world_children_selectable() -> Array[Node2D]:
continue
if node.is_in_group("stickman_ragdoll_container") or bool(node.get_meta("stickman_ragdoll_container", false)):
continue
if node.get_script() == STAGE_WALL_PANEL_SCRIPT or node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT:
if node.get_script() == STAGE_WALL_PANEL_SCRIPT or node.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT or node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT:
continue
result.append(node)
return result
@@ -6984,8 +7349,24 @@ func _load_settings() -> void:
_grid_size = clampf(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
_snap_enabled = bool(d.get("snap_to_grid", false))
_show_grid = bool(d.get("show_grid", true))
var loaded_recent_scenes: Array[String] = []
var settings_migrated: bool = false
_recent_surface_colors.clear()
var raw_surface_colors: Variant = d.get("recent_surface_colors", [])
var normalized_surface_colors: Array[String] = []
if raw_surface_colors is Array:
for raw_color: Variant in raw_surface_colors as Array:
if not raw_color is String or not STAGE_SCENE_IO._valid_color(raw_color):
continue
var parsed_color: Color = STAGE_SCENE_IO.color_from_data(raw_color, Color.WHITE)
var canonical_color: String = _canonical_surface_color(parsed_color)
if not normalized_surface_colors.has(canonical_color):
normalized_surface_colors.append(canonical_color)
_recent_surface_colors.append(Color.from_string(canonical_color, Color.WHITE))
if normalized_surface_colors.size() >= RECENT_SURFACE_COLOR_LIMIT:
break
if normalized_surface_colors != (raw_surface_colors as Array):
settings_migrated = true
var loaded_recent_scenes: Array[String] = []
var raw_recents: Variant = d.get("recent_scenes", [])
if raw_recents is Array:
for raw: Variant in raw_recents:
@@ -7009,8 +7390,49 @@ func _save_settings() -> void:
"snap_to_grid": _snap_enabled,
"show_grid": _show_grid,
"recent_scenes": _recent_scenes,
"recent_surface_colors": _surface_color_history_strings(),
}
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(data, "\t", false))
file.close()
func _canonical_surface_color(color: Color) -> String:
var opaque: Color = Color(color.r, color.g, color.b, 1.0)
return "#" + opaque.to_html(false).to_upper()
func _surface_color_history_strings() -> Array[String]:
var result: Array[String] = []
for color: Color in _recent_surface_colors:
result.append(_canonical_surface_color(color))
return result
func _record_recent_surface_color(color: Color) -> void:
var opaque: Color = Color(color.r, color.g, color.b, 1.0)
var canonical: String = _canonical_surface_color(opaque)
var retained: Array[Color] = []
retained.append(opaque)
for existing: Color in _recent_surface_colors:
if _canonical_surface_color(existing) == canonical:
continue
retained.append(existing)
if retained.size() >= RECENT_SURFACE_COLOR_LIMIT:
break
if _surface_color_history_strings() == _colors_to_strings(retained):
return
_recent_surface_colors = retained
_save_settings()
if _asset_browser != null:
_asset_browser.set_surface_color_history(_recent_surface_colors)
if _scenery_surface_colors != null:
_scenery_surface_colors.set_recent_colors(_recent_surface_colors)
func _colors_to_strings(colors: Array[Color]) -> Array[String]:
var result: Array[String] = []
for color: Color in colors:
result.append(_canonical_surface_color(color))
return result
+9 -3
View File
@@ -8,6 +8,7 @@ signal selector_cancel_requested
signal refresh_requested
signal make_artwork_requested
signal edit_scenery_requested
signal surface_color_confirmed(color: Color)
const ASSET_SELECTOR_SCENE := preload("res://scenes/stage/asset_selector.tscn")
const STICKMAN_LIBRARY_SCRIPT := preload("res://scripts/characters/stickman/stickman_library.gd")
@@ -59,6 +60,7 @@ func build_ui(ui_layer: CanvasLayer, ui_font: Font, emoji_font: Font, sandbox_th
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))
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)
@@ -84,6 +86,11 @@ func scan_entries(kind: String) -> Array[Dictionary]:
return PROP_LIBRARY_SCRIPT.scan()
func set_surface_color_history(colors: Array[Color]) -> void:
if selector != null:
selector.set_surface_color_history(colors)
func _starter_scene_entries() -> Array[Dictionary]:
return [
{
@@ -148,9 +155,8 @@ func _scenery_background_entries() -> Array[Dictionary]:
{"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": "finish_wall_paint", "name": "Wall panel · Paint", "category": "Wall finish", "gallery_tab": "backgrounds", "background_kind": "wall", "finish_id": "finish_wall_paint", "icon": "🟩"},
{"id": "finish_wall_brick", "name": "Wall panel · Brick", "category": "Wall finish", "gallery_tab": "backgrounds", "background_kind": "wall", "finish_id": "finish_wall_brick", "icon": "🧱"},
{"id": "finish_wall_wood", "name": "Wall panel · Wood", "category": "Wall finish", "gallery_tab": "backgrounds", "background_kind": "wall", "finish_id": "finish_wall_wood", "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", "description": "Decorative · No walking", "gallery_tab": "backgrounds", "background_kind": "background_ground_setup", "icon": "🌿"}
]
+53
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
@@ -405,10 +408,13 @@ func _build_top_bar() -> void:
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:
@@ -442,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"
@@ -492,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"
+19 -1
View File
@@ -11,6 +11,7 @@ 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")
@@ -24,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, "wall_panel": 0, "scenery_artwork": 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
@@ -54,6 +55,11 @@ func collect(
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)
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)
@@ -168,6 +174,16 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
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"))
)
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", {})
@@ -285,6 +301,8 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
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:
@@ -8,6 +8,8 @@ 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")
@@ -15,6 +17,7 @@ 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 DEFAULT_BACKGROUND_SIZE: Vector2 = Vector2(800.0, 320.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
@@ -112,6 +115,12 @@ func get_active_artwork_name() -> String:
return _asset_path.get_file().get_basename()
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:
exit_tool()
@@ -144,17 +153,31 @@ func begin_artwork(path: String, depth: StringName = &"near") -> bool:
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 = &"wall"
_active_tool = tool
_wall_finish = finish
_wall_tint = tint
_wall_anchor = Vector2.INF
_wall_dragging = false
_wall_anchor_active = false
var panel := WALL_PANEL.new() as StageWallPanel
panel.configure(DEFAULT_PANEL_SIZE, _wall_finish, _wall_tint)
var panel: StageRectSurface
var initial_size: Vector2 = DEFAULT_PANEL_SIZE
if tool == &"background_ground":
panel = BACKGROUND_GROUND.new() as StageBackgroundGround
initial_size = DEFAULT_BACKGROUND_SIZE
else:
panel = WALL_PANEL.new() as StageWallPanel
panel.configure(initial_size, _wall_finish, _wall_tint)
panel.modulate = Color(1.0, 1.0, 1.0, 0.45)
_ghost_holder.add_child(panel)
_ghost = panel
@@ -204,14 +227,14 @@ 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 _active_tool == &"wall" and _wall_dragging:
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 StageWallPanel).configure(size, _wall_finish, _wall_tint)
(_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 _active_tool == &"wall":
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 _active_tool == &"wall_eraser":
@@ -234,7 +257,7 @@ func handle_mouse_button(event: InputEventMouseButton, world_position: Vector2,
exit_tool()
return true
var completes_drag: bool = not event.pressed and event.button_index == MOUSE_BUTTON_LEFT and (
(_active_tool == &"wall" and _wall_dragging)
(_is_rect_placement_tool() and _wall_dragging)
or (_active_tool == &"wall_eraser" and _wall_erase_dragging)
)
if (over_ui and not completes_drag) or event.button_index != MOUSE_BUTTON_LEFT:
@@ -243,7 +266,7 @@ func handle_mouse_button(event: InputEventMouseButton, world_position: Vector2,
if event.pressed:
_commit_artwork(world_position)
return true
if _active_tool == &"wall":
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
@@ -262,12 +285,12 @@ func handle_mouse_button(event: InputEventMouseButton, world_position: Vector2,
if size.x < MIN_PANEL_SIZE or size.y < MIN_PANEL_SIZE:
_wall_anchor_active = false
_wall_anchor = Vector2.INF
_restore_wall_ghost(world_position)
_restore_rect_surface_ghost(world_position)
return true
_commit_wall(_wall_drag_start, end)
_commit_rect_surface(_wall_drag_start, end)
_wall_anchor = Vector2.INF
_wall_anchor_active = false
_restore_wall_ghost(world_position)
_restore_rect_surface_ghost(world_position)
return true
if _active_tool == &"wall_eraser":
if event.pressed:
@@ -304,8 +327,8 @@ func cancel_active_gesture() -> void:
_wall_anchor_active = false
_wall_erase_dragging = false
_wall_erase_start = Vector2.INF
if _active_tool == &"wall" and _ghost != null and is_instance_valid(_ghost):
(_ghost as StageWallPanel).configure(DEFAULT_PANEL_SIZE, _wall_finish, _wall_tint)
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 _active_tool == &"wall_eraser" and _ghost != null and is_instance_valid(_ghost):
_set_wall_eraser_preview_rect(Rect2(_wall_erase_pointer, WALL_ERASER_CURSOR_SIZE))
@@ -334,10 +357,10 @@ func begin_group_transform(kind: StringName, members: Array[Node2D]) -> bool:
return false
var snapshot: Dictionary = {"node": member, "position": member.global_position, "rotation": member.global_rotation, "scale": member.scale}
var bounds: Rect2
if member.get_script() == WALL_PANEL:
var wall: StageWallPanel = member as StageWallPanel
snapshot["panel_size"] = wall.panel_size
bounds = member.global_transform * wall.get_local_bounds()
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)
@@ -369,7 +392,7 @@ func preview_group(delta: Vector2, factor: float) -> bool:
if member == null or not is_instance_valid(member):
return false
if _group_kind == &"resize":
if member.get_script() == WALL_PANEL:
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
@@ -389,9 +412,9 @@ func preview_group(delta: Vector2, factor: float) -> bool:
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.get_script() == WALL_PANEL:
var wall: StageWallPanel = member as StageWallPanel
wall.configure((snapshot["panel_size"] as Vector2) * factor, wall.finish_id, wall.tint)
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
@@ -410,7 +433,7 @@ func commit_group() -> void:
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 node.get_script() != WALL_PANEL and node.get_script() != SCENERY_ARTWORK:
if not (node is StageRectSurface) and node.get_script() != SCENERY_ARTWORK:
return false
if not pointer.is_finite():
return false
@@ -434,8 +457,8 @@ func begin_single_transform(node: Node2D, kind: StringName, pointer: Vector2, dr
"dragged_corner": corners[corner_index],
"pivot": _opposite_corner(bounds, corner_index),
}
if node.get_script() == WALL_PANEL:
snapshot["panel_size"] = (node as StageWallPanel).panel_size
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:
@@ -502,7 +525,7 @@ func cancel_single_transform() -> void:
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 node.get_script() != WALL_PANEL and node.get_script() != SCENERY_ARTWORK:
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:
@@ -518,7 +541,7 @@ func step_single_size(node: Node2D, factor: float) -> bool:
"scale": node.scale,
"bounds": bounds,
"pivot": bounds.position,
"panel_size": (node as StageWallPanel).panel_size if node.get_script() == WALL_PANEL else Vector2.ZERO,
"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):
@@ -540,9 +563,9 @@ func cancel_group() -> void:
member.global_position = snapshot["position"] as Vector2
member.global_rotation = float(snapshot["rotation"])
member.scale = snapshot["scale"] as Vector2
if member.get_script() == WALL_PANEL and snapshot.has("panel_size"):
var wall: StageWallPanel = member as StageWallPanel
wall.configure(snapshot["panel_size"] as Vector2, wall.finish_id, wall.tint)
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 = &""
@@ -553,7 +576,7 @@ 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 = STAGE_SELECTION.get_world_aabb(member) if member.get_script() == SCENERY_ARTWORK else (member.global_transform * (member as StageWallPanel).get_local_bounds())
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
@@ -572,9 +595,13 @@ func _commit_artwork(position: Vector2) -> void:
document_change_requested.emit()
func _commit_wall(first: Vector2, second: Vector2) -> void:
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 := WALL_PANEL.new() as StageWallPanel
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)
@@ -582,13 +609,21 @@ func _commit_wall(first: Vector2, second: Vector2) -> void:
document_change_requested.emit()
func _restore_wall_ghost(world_position: Vector2) -> void:
if _ghost == null or not is_instance_valid(_ghost) or _ghost.get_script() != WALL_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 StageWallPanel).configure(DEFAULT_PANEL_SIZE, _wall_finish, _wall_tint)
(_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 _default_size_for_active_surface() -> Vector2:
return DEFAULT_BACKGROUND_SIZE if _active_tool == &"background_ground" else DEFAULT_PANEL_SIZE
func _erase_wall_region(world_rect: Rect2) -> int:
if _world == null or world_rect.size.x <= 0.0 or world_rect.size.y <= 0.0:
return 0
@@ -695,7 +730,7 @@ func _can_apply_single_scale(snapshot: Dictionary, factor: float) -> bool:
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.get_script() == WALL_PANEL:
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
@@ -707,10 +742,10 @@ func _apply_single_scale_to_snapshot(snapshot: Dictionary, factor: float, pivot:
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.get_script() == WALL_PANEL:
var wall: StageWallPanel = node as StageWallPanel
wall.scale = snapshot["scale"] as Vector2
wall.configure((snapshot["panel_size"] as Vector2) * factor, wall.finish_id, wall.tint)
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
@@ -722,9 +757,9 @@ func _restore_single_snapshot() -> void:
node.global_position = _single_snapshot["position"] as Vector2
node.global_rotation = float(_single_snapshot["rotation"])
node.scale = _single_snapshot["scale"] as Vector2
if node.get_script() == WALL_PANEL:
var wall: StageWallPanel = node as StageWallPanel
wall.configure(_single_snapshot["panel_size"] as Vector2, wall.finish_id, wall.tint)
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:
@@ -739,14 +774,14 @@ func _snapshot_matches_current(snapshot: Dictionary) -> bool:
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.get_script() == WALL_PANEL:
return (node as StageWallPanel).panel_size.is_equal_approx(snapshot["panel_size"] as Vector2)
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.get_script() == WALL_PANEL:
return node.global_transform * (node as StageWallPanel).get_local_bounds()
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)
@@ -789,7 +824,7 @@ func _snap_visual_center(node: Node2D, desired_center: Vector2) -> Vector2:
func _move_node_visible_top_left(node: Node2D, desired_top_left: Vector2) -> Vector2:
if node.get_script() not in [WALL_PANEL, SCENERY_ARTWORK]:
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
@@ -12,9 +12,11 @@ const HORIZONTAL_LAYOUT_MIN_WIDTH: float = 760.0
const ACTION_ROW_MIN_WIDTH: float = 480.0
const ACTION_BUTTON_WIDTH: float = 200.0
const DEFAULT_BUTTON_HEIGHT: float = 56.0
const NARROW_COMPACT_WIDTH: float = 244.0
var _stage_theme: SandboxTheme
var _top_bar_height: float = 64.0
var _layout_stash: Node
var _horizontal_layout: HBoxContainer
var _vertical_layout: VBoxContainer
var _wrapped_layout: VBoxContainer
@@ -76,6 +78,7 @@ func set_capture_actions(prompt: String, actions: Array[Dictionary]) -> void:
var button := Button.new()
button.name = "Action_%s" % String(action_id)
button.text = String(action.get("label", action_id))
button.tooltip_text = button.text
button.custom_minimum_size.x = ACTION_BUTTON_WIDTH
button.focus_mode = Control.FOCUS_ALL
button.pressed.connect(func() -> void: capture_action_requested.emit(action_id))
@@ -154,15 +157,22 @@ func apply_theme() -> void:
func _build_controls() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
_layout_stash = Node.new()
_layout_stash.name = "LayoutStash"
add_child(_layout_stash)
_horizontal_layout = HBoxContainer.new()
_horizontal_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_horizontal_layout.add_theme_constant_override("separation", 20)
_vertical_layout = VBoxContainer.new()
_vertical_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_vertical_layout.add_theme_constant_override("separation", 12)
_vertical_layout.visible = false
_wrapped_layout = VBoxContainer.new()
_wrapped_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_wrapped_layout.add_theme_constant_override("separation", 10)
_wrapped_layout.visible = false
_layout_stash.add_child(_vertical_layout)
_layout_stash.add_child(_wrapped_layout)
_active_layout = _horizontal_layout
add_child(_active_layout)
_prompt = Label.new()
@@ -178,8 +188,11 @@ func _build_controls() -> void:
_actions_row.alignment = BoxContainer.ALIGNMENT_END
_actions_column = VBoxContainer.new()
_actions_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_actions_column.visible = false
_layout_stash.add_child(_actions_column)
_actions_parent = _actions_row
_active_layout.add_child(_actions_parent)
_actions_row.visible = true
_change_prop_button = Button.new()
_change_prop_button.name = "ChangeProp"
_change_prop_button.text = "Change prop"
@@ -250,8 +263,13 @@ func _layout_capture_bar() -> void:
safe_right = clampf(_safe_right, safe_left + 1.0, maxf(safe_left + 1.0, viewport_size.x - inset))
var available_width: float = maxf(1.0, safe_right - safe_left)
var width: float = minf(MAX_BAR_WIDTH, minf(viewport_size.x * VIEWPORT_WIDTH_RATIO, available_width))
var narrow_compact: bool = width < NARROW_COMPACT_WIDTH
custom_minimum_size.x = width
size.x = width
clip_contents = true
_prompt.add_theme_font_size_override("font_size", 18 if narrow_compact else clampi(_integer("stage_panels.type.title_size", 24, 18, 36) + 4, 24, 34))
_prompt.max_lines_visible = 2 if narrow_compact else 3
_set_compact_panel_style(narrow_compact)
var left: float = safe_left + (available_width - width) * 0.5 if not _has_horizontal_safe_bounds else safe_left
position = Vector2(left, _top_bar_height + 56.0)
var many_actions: bool = _custom_action_buttons.size() > 3
@@ -259,6 +277,7 @@ func _layout_capture_bar() -> void:
var wrapped: bool = (_has_horizontal_safe_bounds and width < HORIZONTAL_LAYOUT_MIN_WIDTH and width >= ACTION_ROW_MIN_WIDTH) or (many_actions and not stack_actions)
_set_layout(width < HORIZONTAL_LAYOUT_MIN_WIDTH or wrapped, stack_actions, wrapped)
_fit_custom_action_widths(width, wrapped)
_apply_compact_action_labels(narrow_compact)
call_deferred("_fit_height_to_content")
@@ -273,8 +292,12 @@ func _set_layout(compact: bool, stack_actions: bool, wrapped: bool = false) -> v
previous_actions.remove_child(_change_prop_button)
previous_actions.remove_child(_cancel_button)
_active_layout.remove_child(previous_actions)
previous_actions.visible = false
_layout_stash.add_child(previous_actions)
_actions_parent = target_actions
_layout_stash.remove_child(_actions_parent)
_active_layout.add_child(_actions_parent)
_actions_parent.visible = true
_actions_parent.add_child(_change_prop_button)
_actions_parent.add_child(_cancel_button)
for button: Button in _custom_action_buttons:
@@ -282,9 +305,13 @@ func _set_layout(compact: bool, stack_actions: bool, wrapped: bool = false) -> v
if _active_layout != target_layout:
_active_layout.remove_child(_prompt)
_active_layout.remove_child(_actions_parent)
_active_layout.visible = false
remove_child(_active_layout)
_layout_stash.add_child(_active_layout)
_active_layout = target_layout
_layout_stash.remove_child(_active_layout)
add_child(_active_layout)
_active_layout.visible = true
_active_layout.add_child(_prompt)
_active_layout.add_child(_actions_parent)
_change_prop_button.custom_minimum_size.x = 0.0 if stack_actions else ACTION_BUTTON_WIDTH
@@ -297,18 +324,60 @@ func _fit_custom_action_widths(width: float, wrapped: bool) -> void:
var button_width: float = ACTION_BUTTON_WIDTH
var body_size: int = clampi(_integer("stage_panels.type.body_size", 20, 16, 32) + 2, 20, 30)
var action_font_size: int = maxi(18, body_size - (4 if wrapped else 0))
if width < NARROW_COMPACT_WIDTH:
button_width = 0.0
action_font_size = 16
if wrapped and not _custom_action_buttons.is_empty():
var gaps: float = float(maxi(0, _custom_action_buttons.size() - 1) * 12)
button_width = minf(ACTION_BUTTON_WIDTH, maxf(144.0, (width - 44.0 - gaps) / float(_custom_action_buttons.size())))
for button: Button in _custom_action_buttons:
button.custom_minimum_size.x = button_width
button.add_theme_font_size_override("font_size", action_font_size)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL if width < NARROW_COMPACT_WIDTH else Control.SIZE_FILL
func _apply_compact_action_labels(compact: bool) -> void:
var compact_labels: Dictionary = {
"select_several": "Multi",
"select_all": "All",
"clear_selection": "Clear",
"done_edit": "Done",
"move_group": "Move",
"resize_group": "Size",
"keep_group": "Keep",
"cancel_group": "Cancel",
"done_tool": "Done",
"cancel_tool": "Cancel",
"cancel_single": "Cancel",
"depth_far": "Far",
"depth_near": "Near",
"depth_floor": "Floor",
}
for button: Button in _custom_action_buttons:
var action_id: String = button.name.trim_prefix("Action_")
var full_label: String = button.tooltip_text
button.text = String(compact_labels.get(action_id, full_label)) if compact else full_label
func _set_compact_panel_style(compact: bool) -> void:
var panel_style: StyleBoxFlat = get_theme_stylebox("panel") as StyleBoxFlat
if panel_style == null:
return
panel_style.border_width_left = 4 if compact else 8
panel_style.content_margin_left = 8.0 if compact else 20.0
panel_style.content_margin_right = 8.0 if compact else 18.0
panel_style.content_margin_top = 8.0 if compact else 14.0
panel_style.content_margin_bottom = 8.0 if compact else 14.0
func _fit_height_to_content() -> void:
if not is_inside_tree() or not visible:
return
custom_minimum_size.y = get_combined_minimum_size().y
var target_height: float = _active_layout.get_combined_minimum_size().y
var panel_style: StyleBox = get_theme_stylebox("panel")
if panel_style != null:
target_height += panel_style.get_minimum_size().y
custom_minimum_size.y = target_height
size.y = custom_minimum_size.y
@@ -166,8 +166,8 @@ func _build_shell() -> void:
func _fit_to_content() -> void:
_fit_pending = false
if not is_inside_tree() or _body == null:
_fit_pending = false
return
var rect := _safe_rect
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
@@ -179,7 +179,16 @@ func _fit_to_content() -> void:
size.x = card_width
await get_tree().process_frame
if not is_inside_tree():
_fit_pending = false
return
# A resize can arrive while the first layout pass is waiting for the body
# minimum size. Re-read the safe rectangle so this pass uses the new bounds.
rect = _safe_rect
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
rect = get_viewport_rect()
card_width = minf(preferred_width, maxf(1.0, rect.size.x - inset * 2.0))
custom_minimum_size.x = card_width
size.x = card_width
var padding := float(_integer("stage_panels.shell.padding", 12, 4, 28))
var gap := float(_integer("stage_panels.shell.header_gap", 8, 0, 32))
var header_height := maxf(44.0, _header.get_combined_minimum_size().y)
@@ -189,10 +198,13 @@ func _fit_to_content() -> void:
var natural_height := fixed_height + body_height
var card_height := minf(natural_height, available_height)
var scroll_height := maxf(0.0, card_height - fixed_height)
position = Vector2(rect.position.x + rect.size.x - inset - card_width, rect.position.y + inset)
size = Vector2(card_width, card_height)
# Update the scroll minimum before shrinking the PanelContainer. Otherwise its
# previous full-content minimum can clamp the new height until another layout.
_scroll.custom_minimum_size.y = scroll_height
_scroll.update_minimum_size()
position = Vector2(rect.position.x + rect.size.x - inset - card_width, rect.position.y + inset)
size = Vector2(card_width, card_height)
_fit_pending = false
func _style_action_button(button: Button, prominent: bool) -> void:
@@ -0,0 +1,147 @@
class_name StageSurfaceColorControls
extends VBoxContainer
## Shared wrapping Color history and preset controls for scenery surfaces.
signal color_selected(color: Color)
signal custom_color_requested
const COLOR_SWATCH_SELECTOR := preload("res://scripts/shared/ui/color_swatch_selector.gd")
var _stage_theme: SandboxTheme
var _selected: Color = Color.WHITE
var _recents: Array[Color] = []
var _presets: Array[Color] = []
var _built: bool = false
var _current_chip: Button
var _current_value: Label
var _recent_empty_label: Label
var _recent_swatches: ColorSwatchSelector
var _preset_swatches: ColorSwatchSelector
var _more_button: Button
func configure(selected: Color, recents: Array[Color], presets: Array[Color], stage_theme: SandboxTheme) -> void:
_stage_theme = stage_theme
_selected = selected
_recents = recents.duplicate()
_presets = presets.duplicate()
if not _built:
_build()
_refresh_visuals()
func set_selected_no_signal(color: Color) -> void:
_selected = color
_refresh_current_color()
_recent_swatches.set_selected(color)
_preset_swatches.set_selected(color)
func set_recent_colors(colors: Array[Color]) -> void:
if _recents == colors:
return
_recents = colors.duplicate()
_recent_empty_label.visible = _recents.is_empty()
_recent_swatches.visible = not _recents.is_empty()
_recent_swatches.configure(_recents, _selected, _stage_theme)
func focus_custom_button() -> void:
if _more_button != null:
_more_button.grab_focus()
func _build() -> void:
_built = true
add_theme_constant_override("separation", _integer("stage_surface_colors.section_gap", 8, 0, 28))
var current_heading: Label = _make_heading("Color")
add_child(current_heading)
var current_row := HBoxContainer.new()
current_row.add_theme_constant_override("separation", _integer("stage_surface_colors.section_gap", 8, 0, 28))
add_child(current_row)
_current_chip = Button.new()
var swatch_size: float = float(_integer("stage_surface_colors.swatch_size", 48, 44, 96))
_current_chip.custom_minimum_size = Vector2(swatch_size, swatch_size)
_current_chip.focus_mode = Control.FOCUS_NONE
_current_chip.mouse_filter = Control.MOUSE_FILTER_IGNORE
current_row.add_child(_current_chip)
_current_value = Label.new()
_current_value.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_current_value.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
current_row.add_child(_current_value)
add_child(_make_heading("Recently used"))
_recent_empty_label = Label.new()
_recent_empty_label.text = "Colors you use will appear here"
_recent_empty_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_recent_empty_label.add_theme_color_override("font_color", _color("global.muted_text_color", Color("#AAB2C0")))
add_child(_recent_empty_label)
_recent_swatches = COLOR_SWATCH_SELECTOR.new() as ColorSwatchSelector
_recent_swatches.color_selected.connect(_on_swatch_selected)
add_child(_recent_swatches)
add_child(_make_heading("Colors"))
_preset_swatches = COLOR_SWATCH_SELECTOR.new() as ColorSwatchSelector
_preset_swatches.color_selected.connect(_on_swatch_selected)
add_child(_preset_swatches)
_more_button = Button.new()
_more_button.text = "More colors…"
_more_button.custom_minimum_size.y = float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
_more_button.focus_mode = Control.FOCUS_ALL
_more_button.pressed.connect(func() -> void: custom_color_requested.emit())
add_child(_more_button)
if _stage_theme != null:
var font: Font = _stage_theme.font()
if font != null:
_current_value.add_theme_font_override("font", font)
_more_button.add_theme_font_override("font", font)
_current_value.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
_more_button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
_more_button.add_theme_color_override("font_color", _color("global.text_color", Color("#F5F7FA")))
_more_button.add_theme_stylebox_override("normal", _stage_theme.stylebox("stage_panels.action"))
_more_button.add_theme_stylebox_override("hover", _stage_theme.stylebox("stage_panels.action").duplicate())
func _refresh_visuals() -> void:
if not _built:
return
_refresh_current_color()
_recent_empty_label.visible = _recents.is_empty()
_recent_swatches.visible = not _recents.is_empty()
_recent_swatches.configure(_recents, _selected, _stage_theme)
_preset_swatches.configure(_presets, _selected, _stage_theme)
func _refresh_current_color() -> void:
_current_value.text = "#%s" % _selected.to_html(false).to_upper()
var chip_style := StyleBoxFlat.new()
chip_style.bg_color = _selected
chip_style.border_color = _color("stage_panels.choice.border_color", Color("#FFFFFFAA"))
chip_style.set_border_width_all(_integer("stage_surface_colors.swatch_border_width", 2, 0, 8))
chip_style.set_corner_radius_all(_integer("stage_surface_colors.swatch_corner_radius", 8, 0, 24))
_current_chip.add_theme_stylebox_override("normal", chip_style)
_current_chip.add_theme_stylebox_override("hover", chip_style.duplicate())
func _on_swatch_selected(color: Color) -> void:
_selected = color
_refresh_current_color()
_recent_swatches.set_selected(color)
_preset_swatches.set_selected(color)
color_selected.emit(color)
func _make_heading(text_value: String) -> Label:
var heading := Label.new()
heading.text = text_value
heading.add_theme_font_size_override("font_size", _integer("stage_panels.type.section_size", 20, 12, 40))
heading.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
if _stage_theme != null and _stage_theme.font() != null:
heading.add_theme_font_override("font", _stage_theme.font())
return heading
func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
func _color(path: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)
@@ -0,0 +1 @@
uid://d37coqfbju18u
@@ -0,0 +1,160 @@
class_name StageSurfaceColorDialog
extends Window
## Bounded custom-color dialog with a live Stage surface preview.
signal preview_color_changed(color: Color)
signal color_confirmed(color: Color)
signal cancelled
const SURFACE_PREVIEW := preload("res://scripts/stage/scenery/stage_surface_preview.gd")
var _surface_kind: StringName = &"wall"
var _finish_id: StringName = &"finish_wall_paint"
var _initial_color: Color = Color.WHITE
var _stage_theme: SandboxTheme
var _color_picker: ColorPicker
var _surface_preview: StageSurfacePreview
var _built: bool = false
func configure(surface_kind: StringName, finish: StringName, color: Color, stage_theme: SandboxTheme) -> void:
_surface_kind = surface_kind
_finish_id = finish
_initial_color = color
_stage_theme = stage_theme
if not _built:
_build()
_color_picker.color = color
_update_preview(color)
title = "Choose a color"
exclusive = true
transient = true
theme = stage_theme.control_theme() if stage_theme != null else null
func open_dialog() -> void:
var max_size: Vector2i = _usable_window_size()
var preferred_size := Vector2i(560, 640)
var raw_size: Variant = _stage_theme.value("popups.dialog.surface_color_size", [560, 640]) if _stage_theme != null else [560, 640]
if raw_size is Array and (raw_size as Array).size() == 2:
preferred_size = Vector2i(int((raw_size as Array)[0]), int((raw_size as Array)[1]))
var dialog_size := Vector2i(mini(preferred_size.x, max_size.x), mini(preferred_size.y, max_size.y))
dialog_size.x = maxi(1, dialog_size.x)
dialog_size.y = maxi(1, dialog_size.y)
size = dialog_size
popup_centered(dialog_size)
_color_picker.grab_focus()
func _unhandled_key_input(event: InputEvent) -> void:
if visible and event is InputEventKey and (event as InputEventKey).pressed and not (event as InputEventKey).echo and (event as InputEventKey).keycode == KEY_ESCAPE:
_cancel()
get_viewport().set_input_as_handled()
func _build() -> void:
_built = true
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
for side: String in ["left", "right", "top", "bottom"]:
margin.add_theme_constant_override("margin_" + side, 12)
add_child(margin)
var layout := VBoxContainer.new()
layout.add_theme_constant_override("separation", 8)
margin.add_child(layout)
var preview_heading := Label.new()
preview_heading.text = "Preview"
layout.add_child(preview_heading)
_surface_preview = SURFACE_PREVIEW.new() as StageSurfacePreview
var preview_size := Vector2(420.0, 140.0)
var raw_preview: Variant = _stage_theme.value("popups.dialog.surface_color_preview_size", [420, 140]) if _stage_theme != null else [420, 140]
if raw_preview is Array and (raw_preview as Array).size() == 2:
preview_size = Vector2(float((raw_preview as Array)[0]), float((raw_preview as Array)[1]))
preview_size.x = minf(preview_size.x, maxf(1.0, float(_usable_window_size().x - 56)))
_surface_preview.custom_minimum_size = preview_size
_surface_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_surface_preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
layout.add_child(_surface_preview)
var picker_scroll := ScrollContainer.new()
picker_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
picker_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
picker_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
layout.add_child(picker_scroll)
_color_picker = ColorPicker.new()
_color_picker.name = "ColorPickerControl"
var picker_min_size := Vector2(320.0, 340.0)
var raw_picker_size: Variant = _stage_theme.value("popups.dialog.surface_color_picker_min_size", [320, 340]) if _stage_theme != null else [320, 340]
if raw_picker_size is Array and (raw_picker_size as Array).size() == 2:
picker_min_size = Vector2(float((raw_picker_size as Array)[0]), float((raw_picker_size as Array)[1]))
picker_min_size.x = minf(picker_min_size.x, maxf(1.0, float(_usable_window_size().x - 56)))
_color_picker.custom_minimum_size = picker_min_size
_color_picker.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_color_picker.focus_mode = Control.FOCUS_ALL
_color_picker.color_changed.connect(_on_picker_color_changed)
picker_scroll.add_child(_color_picker)
var actions := HBoxContainer.new()
actions.alignment = BoxContainer.ALIGNMENT_END
actions.add_theme_constant_override("separation", 8)
layout.add_child(actions)
var use_button := Button.new()
use_button.text = "Use color"
var action_width: float = maxf(44.0, minf(120.0, (float(_usable_window_size().x) - 40.0) * 0.5))
use_button.custom_minimum_size = Vector2(action_width, float(_integer("popups.dialog.action_min_target_size", 48, 44, 96)))
use_button.focus_mode = Control.FOCUS_ALL
use_button.pressed.connect(_confirm_color)
actions.add_child(use_button)
var cancel_button := Button.new()
cancel_button.text = "✕ Cancel"
cancel_button.custom_minimum_size = Vector2(action_width, float(_integer("popups.dialog.action_min_target_size", 48, 44, 96)))
cancel_button.focus_mode = Control.FOCUS_ALL
cancel_button.pressed.connect(_cancel)
actions.add_child(cancel_button)
close_requested.connect(_cancel)
if _stage_theme != null:
var font: Font = _stage_theme.font()
if font != null:
preview_heading.add_theme_font_override("font", font)
use_button.add_theme_font_override("font", font)
cancel_button.add_theme_font_override("font", font)
preview_heading.add_theme_font_size_override("font_size", _stage_theme.integer("stage_panels.type.section_size", 20, 12, 40))
use_button.add_theme_stylebox_override("normal", _stage_theme.stylebox("stage_panels.action"))
cancel_button.add_theme_stylebox_override("normal", _stage_theme.stylebox("stage_panels.action"))
use_button.add_theme_color_override("font_color", _stage_theme.color("global.text_color", Color("#F5F7FA")))
cancel_button.add_theme_color_override("font_color", _stage_theme.color("global.text_color", Color("#F5F7FA")))
func _on_picker_color_changed(color: Color) -> void:
_update_preview(color)
preview_color_changed.emit(color)
func _update_preview(color: Color) -> void:
if _surface_preview != null:
_surface_preview.configure(_surface_kind, _finish_id, color)
func _confirm_color() -> void:
var confirmed_color: Color = _color_picker.color
hide()
color_confirmed.emit(confirmed_color)
func _cancel() -> void:
_update_preview(_initial_color)
hide()
cancelled.emit()
func _usable_window_size() -> Vector2i:
var available: Vector2i = Vector2i(get_tree().root.size)
var usable: Rect2i = DisplayServer.screen_get_usable_rect(DisplayServer.window_get_current_screen())
if usable.size.x > 0 and usable.size.y > 0:
available.x = mini(available.x, usable.size.x)
available.y = mini(available.y, usable.size.y)
available.x = maxi(1, available.x - 32)
available.y = maxi(1, available.y - 32)
return available
func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
@@ -0,0 +1 @@
uid://c56y4dpi7w2pu
+1 -1
View File
@@ -50,7 +50,7 @@ func configure(payload: Dictionary, icon_texture: Texture2D = null, theme: Varia
if not description.is_empty():
_description = Label.new()
_description.text = description
_description.custom_minimum_size = Vector2(0.0, 34.0)
_description.custom_minimum_size = Vector2(0.0, float(_payload.get("description_min_height", 34.0)))
_description.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_description.vertical_alignment = VERTICAL_ALIGNMENT_TOP
_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+354 -10
View File
@@ -7,16 +7,27 @@ signal browse_requested()
signal refresh_requested()
signal make_artwork_requested()
signal edit_scenery_requested()
signal surface_color_confirmed(color: Color)
const COLUMNS := 4
const ROWS := 3
const PAGE_SIZE := COLUMNS * ROWS
const GRID_H_SEPARATION: float = 8.0
const GRID_V_SEPARATION: float = 8.0
const POPUP_SIDE_PADDING: float = 40.0
const POPUP_VERTICAL_PADDING: float = 32.0
const POPUP_SAFE_SCREEN_MARGIN: float = 24.0
const CELL_MIN_SIZE := Vector2(190.0, 220.0)
const THUMB_SIZE := Vector2(156.0, 156.0)
const ASSET_CARD := preload("res://scripts/shared/ui/asset_card.gd")
const BACKGROUND_CARD_PREVIEW := preload("res://scripts/stage/scenery/stage_background_card_preview.gd")
const STARTER_CARD_PREVIEW := preload("res://scripts/stage/scenery/stage_starter_card_preview.gd")
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const SURFACE_PREVIEW := preload("res://scripts/stage/scenery/stage_surface_preview.gd")
const SURFACE_COLOR_CONTROLS := preload("res://scripts/shared/stage_ui/stage_surface_color_controls.gd")
const SURFACE_COLOR_DIALOG := preload("res://scripts/shared/stage_ui/stage_surface_color_dialog.gd")
const STAGE_PANEL_CHOICE_GROUP := preload("res://scripts/shared/stage_ui/stage_panel_choice_group.gd")
var kind: String = ""
@@ -32,9 +43,24 @@ var _gallery_tab: String = "backgrounds"
var _tag_id: String = "all"
var _current_sky_id: String = ""
var _sky_card_group: ButtonGroup
var _active_columns: int = COLUMNS
var _page_capacity: int = PAGE_SIZE
var _effective_cell_size: Vector2 = CELL_MIN_SIZE
var _surface_color_history: Array[Color] = []
var _setup_view: VBoxContainer
var _setup_kind: StringName = &""
var _setup_finish: StringName = &""
var _setup_color: Color = Color.WHITE
var _setup_original_color: Color = Color.WHITE
var _setup_color_chosen: bool = false
var _setup_preview: StageSurfacePreview
var _setup_color_controls: StageSurfaceColorControls
var _setup_color_dialog: StageSurfaceColorDialog
@onready var _title_label: Label = %TitleLabel
@onready var _grid: GridContainer = %GridContainer
@onready var _content_stack: VBoxContainer = %ContentStack
@onready var _grid_scroll: ScrollContainer = %GridScroll
@onready var _empty_label: Label = %EmptyLabel
@onready var _page_label: Label = %PageLabel
@onready var _prev_button: Button = %PrevButton
@@ -48,10 +74,13 @@ var _sky_card_group: ButtonGroup
@onready var _tag_filter: OptionButton = %TagFilter
@onready var _make_artwork_button: Button = %MakeArtworkButton
@onready var _edit_scenery_button: Button = %EditSceneryButton
@onready var _title_bar: Control = $MarginContainer/VBoxContainer/TitleBar
@onready var _footer: Control = $MarginContainer/VBoxContainer/Footer
func _ready() -> void:
exclusive = true
_build_surface_setup_view()
if _tag_filter.item_count == 0:
for label: String in ["All artwork", "Nature", "Buildings", "Walls", "Roads", "Objects"]:
_tag_filter.add_item(label)
@@ -70,11 +99,15 @@ func _ready() -> void:
func _on_root_size_changed() -> void:
if visible:
_update_popup_layout()
_rebuild()
popup_centered()
func open(p_kind: String, entries: Array[Dictionary], current_sky_id: String = "") -> void:
kind = p_kind
_setup_kind = &""
_setup_view.visible = false
_current_sky_id = current_sky_id if kind == "scenery" else ""
_title_label.text = "Choose Your Stickman" if kind == "stickman" else ("Choose Scenery" if kind == "scenery" else ("Choose a starter scene" if kind == "starters" else "Choose a Prop"))
_tabs.visible = kind == "scenery"
@@ -100,9 +133,16 @@ func set_entries(entries: Array[Dictionary]) -> void:
_thumbnails.clear()
_cell_texrects.clear()
_page = 0
_update_popup_layout()
_rebuild()
func set_surface_color_history(colors: Array[Color]) -> void:
_surface_color_history = colors.duplicate()
if _setup_color_controls != null and is_instance_valid(_setup_color_controls):
_setup_color_controls.set_recent_colors(_surface_color_history)
func set_thumbnail(entry: Dictionary, tex: Texture2D) -> void:
var key := _entry_key(entry)
_thumbnails[key] = tex
@@ -144,6 +184,221 @@ func apply_theme(theme: Variant) -> void:
var thumbnail_size: Variant = theme.value("popups.asset_selector.thumbnail_size", THUMB_SIZE)
if thumbnail_size is Array and (thumbnail_size as Array).size() == 2:
_thumbnail_size = Vector2(float(thumbnail_size[0]), float(thumbnail_size[1]))
var wall_choices: StagePanelChoiceGroup = get_meta("wall_look_group", null) as StagePanelChoiceGroup
var ground_choices: StagePanelChoiceGroup = get_meta("ground_look_group", null) as StagePanelChoiceGroup
if wall_choices != null:
wall_choices.apply_theme(theme as SandboxTheme)
if ground_choices != null:
ground_choices.apply_theme(theme as SandboxTheme)
if _setup_color_controls != null:
_setup_color_controls.configure(_setup_color, _surface_color_history, _preset_surface_colors(), theme as SandboxTheme)
func _build_surface_setup_view() -> void:
_setup_view = VBoxContainer.new()
_setup_view.name = "SurfaceSetupView"
_setup_view.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_setup_view.add_theme_constant_override("separation", _theme_integer("popups.asset_selector.setup_gap", 10, 0, 32))
_setup_view.visible = false
_content_stack.add_child(_setup_view)
var heading_row := HBoxContainer.new()
heading_row.add_theme_constant_override("separation", 8)
_setup_view.add_child(heading_row)
var back_button := Button.new()
back_button.text = "‹ Back"
back_button.custom_minimum_size = Vector2(float(_theme_integer("stage_panels.action.close_width", 92, 72, 180)), float(_theme_integer("stage_panels.action.min_target_size", 48, 44, 96)))
back_button.focus_mode = Control.FOCUS_ALL
back_button.pressed.connect(_back_from_surface_setup)
heading_row.add_child(back_button)
var heading := Label.new()
heading.name = "SetupHeading"
heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL
heading.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
heading.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.title_size", 24, 12, 48))
heading_row.add_child(heading)
var look_heading := Label.new()
look_heading.text = "Look"
look_heading.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.section_size", 20, 12, 40))
_setup_view.add_child(look_heading)
var wall_choices: StagePanelChoiceGroup = STAGE_PANEL_CHOICE_GROUP.new() as StagePanelChoiceGroup
wall_choices.name = "WallLookChoices"
wall_choices.add_choice(&"finish_wall_paint", "Paint", _make_look_cue(&"wall", &"finish_wall_paint"))
wall_choices.add_choice(&"finish_wall_brick", "Brick", _make_look_cue(&"wall", &"finish_wall_brick"))
wall_choices.add_choice(&"finish_wall_wood", "Wood", _make_look_cue(&"wall", &"finish_wall_wood"))
wall_choices.choice_selected.connect(_on_setup_look_choice)
_setup_view.add_child(wall_choices)
var ground_choices: StagePanelChoiceGroup = STAGE_PANEL_CHOICE_GROUP.new() as StagePanelChoiceGroup
ground_choices.name = "BackgroundGroundLookChoices"
ground_choices.add_choice(&"finish_background_grass", "Grass", _make_look_cue(&"background_ground", &"finish_background_grass"))
ground_choices.add_choice(&"finish_background_concrete", "Concrete", _make_look_cue(&"background_ground", &"finish_background_concrete"))
ground_choices.add_choice(&"finish_background_carpet", "Carpet", _make_look_cue(&"background_ground", &"finish_background_carpet"))
ground_choices.choice_selected.connect(_on_setup_look_choice)
_setup_view.add_child(ground_choices)
set_meta("wall_look_group", wall_choices)
set_meta("ground_look_group", ground_choices)
var preview_frame := PanelContainer.new()
preview_frame.name = "SurfacePreviewFrame"
var preview_size: Vector2 = _theme_vector2("popups.asset_selector.surface_preview_size", Vector2(420.0, 132.0))
preview_frame.custom_minimum_size.y = preview_size.y + 10.0
preview_frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_setup_preview = SURFACE_PREVIEW.new() as StageSurfacePreview
_setup_preview.custom_minimum_size = preview_size
_setup_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
preview_frame.add_child(_setup_preview)
_setup_view.add_child(preview_frame)
_setup_color_controls = SURFACE_COLOR_CONTROLS.new() as StageSurfaceColorControls
_setup_color_controls.name = "SurfaceColorControls"
_setup_color_controls.color_selected.connect(_on_setup_color_selected)
_setup_color_controls.custom_color_requested.connect(_open_setup_color_dialog)
_setup_view.add_child(_setup_color_controls)
var helper := Label.new()
helper.name = "SurfaceRoleHelper"
helper.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
helper.add_theme_color_override("font_color", _theme_color("global.muted_text_color", Color("#AAB2C0")))
_setup_view.add_child(helper)
var draw_button := Button.new()
draw_button.name = "DrawSurfaceButton"
draw_button.custom_minimum_size.y = float(_theme_integer("stage_panels.choice.minimum_height", 56, 44, 112))
draw_button.focus_mode = Control.FOCUS_ALL
draw_button.pressed.connect(_draw_selected_surface)
_setup_view.add_child(draw_button)
set_meta("setup_heading", heading)
set_meta("setup_helper", helper)
set_meta("setup_draw_button", draw_button)
_apply_font_to(back_button)
_apply_font_to(heading)
_apply_font_to(look_heading)
_apply_font_to(helper)
_apply_font_to(draw_button)
if _sandbox_theme != null:
draw_button.add_theme_stylebox_override("normal", _sandbox_theme.stylebox("stage_panels.action"))
draw_button.add_theme_stylebox_override("hover", _sandbox_theme.stylebox("stage_panels.action"))
draw_button.add_theme_color_override("font_color", _theme_color("stage_panels.type.text_color", Color("#F5F7FA")))
draw_button.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.body_size", 20, 14, 40))
for choice: StagePanelChoiceGroup in [wall_choices, ground_choices]:
choice.apply_theme(_sandbox_theme as SandboxTheme)
_apply_font_to(choice)
func _make_look_cue(kind: StringName, finish: StringName) -> Control:
var preview: StageSurfacePreview = SURFACE_PREVIEW.new() as StageSurfacePreview
preview.custom_minimum_size = _theme_vector2("popups.asset_selector.look_cue_size", Vector2(80.0, 42.0))
preview.configure(kind, finish, SURFACE_RENDERER.default_tint(finish))
return preview
func _open_surface_setup(kind: StringName) -> void:
_setup_kind = kind
_setup_color_chosen = false
_setup_finish = &"finish_wall_paint" if kind == &"wall" else &"finish_background_grass"
_setup_color = SURFACE_RENDERER.default_tint(_setup_finish)
_setup_original_color = _setup_color
var wall_choices: StagePanelChoiceGroup = get_meta("wall_look_group") as StagePanelChoiceGroup
var ground_choices: StagePanelChoiceGroup = get_meta("ground_look_group") as StagePanelChoiceGroup
wall_choices.visible = kind == &"wall"
ground_choices.visible = kind == &"background_ground"
wall_choices.select_no_signal(_setup_finish)
ground_choices.select_no_signal(_setup_finish)
(get_meta("setup_heading") as Label).text = "Wall panel" if kind == &"wall" else "Background ground"
(get_meta("setup_helper") as Label).text = "Looks like a wall · Stickmen can pass through." if kind == &"wall" else "Decorative only · Stickmen don’t walk here."
(get_meta("setup_draw_button") as Button).text = "Draw wall panel" if kind == &"wall" else "Draw background ground"
_setup_color_controls.visible = _supports_setup_color()
_setup_color_controls.configure(_setup_color, _surface_color_history, _preset_surface_colors(), _sandbox_theme as SandboxTheme)
_update_setup_preview()
_page = 0
_update_popup_layout()
_rebuild()
back_button_focus()
func back_button_focus() -> void:
var back_button: Button = _setup_view.get_child(0).get_child(0) as Button
if back_button != null:
back_button.grab_focus()
func _back_from_surface_setup() -> void:
_setup_kind = &""
_setup_view.visible = false
_update_popup_layout()
_rebuild()
_close_button.grab_focus()
func _on_setup_look_choice(id: StringName) -> void:
var supported: bool = SURFACE_RENDERER.is_wall_finish(id) if _setup_kind == &"wall" else SURFACE_RENDERER.is_background_finish(id)
if not supported:
return
_setup_finish = id
if not _setup_color_chosen:
_setup_color = SURFACE_RENDERER.default_tint(_setup_finish)
_setup_color_controls.visible = _supports_setup_color()
_setup_color_controls.set_selected_no_signal(_setup_color)
_update_setup_preview()
_update_popup_layout()
func _supports_setup_color() -> bool:
return _setup_kind == &"wall" or _setup_finish == &"finish_background_carpet"
func _on_setup_color_selected(color: Color) -> void:
_setup_color = Color(color.r, color.g, color.b, 1.0)
_setup_color_chosen = true
_update_setup_preview()
surface_color_confirmed.emit(_setup_color)
func _update_setup_preview() -> void:
if _setup_preview == null:
return
_setup_preview.configure(_setup_kind, _setup_finish, _setup_color)
_setup_color_controls.set_selected_no_signal(_setup_color)
func _preset_surface_colors() -> Array[Color]:
if _sandbox_theme == null:
return [Color.WHITE, Color.BLACK, Color("#4C9AFF"), Color("#29A36A"), Color("#FFD84D"), Color("#D94F70")]
return (_sandbox_theme as SandboxTheme).swatches()
func _open_setup_color_dialog() -> void:
if _setup_color_dialog == null or not is_instance_valid(_setup_color_dialog):
_setup_color_dialog = SURFACE_COLOR_DIALOG.new() as StageSurfaceColorDialog
add_child(_setup_color_dialog)
_setup_color_dialog.preview_color_changed.connect(_on_setup_color_preview)
_setup_color_dialog.color_confirmed.connect(_on_setup_color_confirmed)
_setup_color_dialog.cancelled.connect(_on_setup_color_cancelled)
_setup_original_color = _setup_color
_setup_color_dialog.configure(_setup_kind, _setup_finish, _setup_original_color, _sandbox_theme as SandboxTheme)
_setup_color_dialog.open_dialog()
func _on_setup_color_preview(color: Color) -> void:
_setup_color = Color(color.r, color.g, color.b, 1.0)
_update_setup_preview()
func _on_setup_color_confirmed(color: Color) -> void:
_setup_color = Color(color.r, color.g, color.b, 1.0)
_setup_color_chosen = true
_update_setup_preview()
surface_color_confirmed.emit(_setup_color)
_setup_color_controls.focus_custom_button()
func _on_setup_color_cancelled() -> void:
_setup_color = _setup_original_color
_setup_color_controls.set_selected_no_signal(_setup_color)
_update_setup_preview()
_setup_color_controls.focus_custom_button()
func _draw_selected_surface() -> void:
if _setup_kind.is_empty():
return
var background_kind: String = "wall" if _setup_kind == &"wall" else "background_ground"
item_selected.emit({"background_kind": background_kind, "finish_id": String(_setup_finish), "color": _setup_color})
var _cell_min_size: Vector2 = CELL_MIN_SIZE
var _thumbnail_size: Vector2 = THUMB_SIZE
@@ -176,10 +431,13 @@ func _on_tag_filter_selected(index: int) -> void:
var ids: Array[String] = ["all", "nature", "buildings", "walls", "roads", "objects"]
_tag_id = ids[clampi(index, 0, ids.size() - 1)]
_page = 0
_update_popup_layout()
_rebuild()
func _set_gallery_tab(tab: String) -> void:
_setup_kind = &""
_setup_view.visible = false
_gallery_tab = tab
_backgrounds_tab.set_pressed_no_signal(tab == "backgrounds")
_artwork_tab.set_pressed_no_signal(tab == "artwork")
@@ -190,6 +448,7 @@ func _set_gallery_tab(tab: String) -> void:
_refresh_button.visible = kind == "stickman" or kind == "prop" or (kind == "scenery" and tab == "artwork")
_empty_label.text = "No artwork in this group yet." if tab == "artwork" else "Choose a Sky look or wall finish."
_page = 0
_update_popup_layout()
_rebuild()
@@ -211,12 +470,14 @@ func _filtered_entries() -> Array[Dictionary]:
func _on_prev() -> void:
if _page > 0:
_page -= 1
_update_popup_layout()
_rebuild()
func _on_next() -> void:
if (_page + 1) * PAGE_SIZE < _filtered_entries().size():
if (_page + 1) * _page_capacity < _filtered_entries().size():
_page += 1
_update_popup_layout()
_rebuild()
@@ -224,7 +485,55 @@ func _page_count() -> int:
var entries: Array[Dictionary] = _filtered_entries()
if entries.is_empty():
return 1
return int(ceil(float(entries.size()) / float(PAGE_SIZE)))
return int(ceil(float(entries.size()) / float(_page_capacity)))
func _usable_screen_size() -> Vector2:
var root_window: Window = get_tree().root
var available: Vector2 = Vector2(root_window.size)
var screen_index: int = DisplayServer.window_get_current_screen()
var usable_rect: Rect2i = DisplayServer.screen_get_usable_rect(screen_index)
if usable_rect.size.x > 0 and usable_rect.size.y > 0:
available.x = minf(available.x, float(usable_rect.size.x))
available.y = minf(available.y, float(usable_rect.size.y))
return available
func _update_popup_layout() -> void:
var available: Vector2 = _usable_screen_size()
var max_width: float = maxf(1.0, minf(available.x * 0.94, available.x - POPUP_SAFE_SCREEN_MARGIN))
var max_height: float = maxf(1.0, minf(available.y * 0.90, available.y - POPUP_SAFE_SCREEN_MARGIN))
var preferred_width: float = CELL_MIN_SIZE.x * COLUMNS + GRID_H_SEPARATION * (COLUMNS - 1) + POPUP_SIDE_PADDING
var popup_width: float = minf(preferred_width, max_width)
var grid_width: float = maxf(1.0, popup_width - POPUP_SIDE_PADDING)
_active_columns = clampi(floori((grid_width + GRID_H_SEPARATION) / (CELL_MIN_SIZE.x + GRID_H_SEPARATION)), 1, COLUMNS)
_page_capacity = _active_columns * ROWS
_grid.columns = _active_columns
var card_width: float = minf(CELL_MIN_SIZE.x, (grid_width - GRID_H_SEPARATION * (_active_columns - 1)) / float(_active_columns))
var card_height_limit: float = 248.0 if kind == "starters" else CELL_MIN_SIZE.y
var card_height: float = minf(card_height_limit, card_width + (58.0 if kind == "starters" else 30.0))
_effective_cell_size = Vector2(maxf(96.0, card_width), maxf(180.0, card_height))
var filtered_count: int = _filtered_entries().size()
var page_count: int = maxi(1, int(ceil(float(filtered_count) / float(_page_capacity))))
_page = clampi(_page, 0, page_count - 1)
var page_start: int = _page * _page_capacity
var page_items: int = clampi(filtered_count - page_start, 0, _page_capacity)
var grid_rows: int = int(ceil(float(page_items) / float(_active_columns))) if page_items > 0 else 0
var body_height: float
if not _setup_kind.is_empty():
body_height = maxf(180.0, _setup_view.get_combined_minimum_size().y)
else:
body_height = maxf(120.0, float(grid_rows) * _effective_cell_size.y + float(maxi(0, grid_rows - 1)) * GRID_V_SEPARATION)
if page_items == 0:
body_height = maxf(body_height, _empty_label.get_combined_minimum_size().y)
var fixed_height: float = _title_bar.get_combined_minimum_size().y + _footer.get_combined_minimum_size().y + POPUP_VERTICAL_PADDING
var visible_sections: int = 3
if _tabs.visible:
fixed_height += _tabs.get_combined_minimum_size().y
visible_sections += 1
fixed_height += float(visible_sections - 1) * 8.0
var popup_height: float = minf(fixed_height + body_height, max_height)
size = Vector2i(maxi(1, roundi(popup_width)), maxi(1, roundi(popup_height)))
func _rebuild() -> void:
@@ -233,19 +542,30 @@ func _rebuild() -> void:
c.queue_free()
_cell_texrects.clear()
var filtered: Array[Dictionary] = _filtered_entries()
_empty_label.visible = filtered.is_empty()
_grid.visible = not filtered.is_empty()
var in_setup: bool = not _setup_kind.is_empty()
_setup_view.visible = in_setup
_empty_label.visible = filtered.is_empty() and not in_setup
_grid.visible = not filtered.is_empty() and not in_setup
_grid_scroll.visible = not filtered.is_empty() or in_setup
var total := _page_count()
var single := total <= 1
_prev_button.visible = not single
_next_button.visible = not single
_page_label.visible = not single
_prev_button.disabled = _page <= 0
_next_button.disabled = (_page + 1) * PAGE_SIZE >= filtered.size()
_next_button.disabled = (_page + 1) * _page_capacity >= filtered.size()
_page_label.text = "Page %d/%d" % [_page + 1, total]
if in_setup:
_prev_button.visible = false
_next_button.visible = false
_page_label.visible = false
_browse_button.visible = false
_refresh_button.visible = false
_make_artwork_button.visible = false
return
if filtered.is_empty():
return
var bounds := page_bounds(filtered.size(), _page)
var bounds := page_bounds(filtered.size(), _page, _page_capacity)
for i: int in range(int(bounds["start"]), int(bounds["end"])):
_grid.add_child(_build_cell(filtered[i]))
@@ -257,12 +577,16 @@ func _build_cell(entry: Dictionary) -> Control:
# opening a selector never implies that one asset is pre-selected.
var categories: Array = entry.get("tags", []) as Array
var category: String = ", ".join(PackedStringArray(categories)) if not categories.is_empty() else String(entry.get("category", entry.get("material_label", "")))
var card_size: Vector2 = _cell_min_size
var thumb_size: Vector2 = _thumbnail_size
var card_size: Vector2 = _effective_cell_size
var is_background_ground_setup: bool = String(entry.get("background_kind", "")) == "background_ground_setup"
var thumb_height_budget: float = card_size.y - (74.0 if is_background_ground_setup else 64.0)
var thumb_edge: float = minf(_thumbnail_size.x, minf(card_size.x - 20.0, thumb_height_budget))
var thumb_size := Vector2.ONE * maxf(72.0, thumb_edge)
if kind == "starters":
card_size.y = maxf(card_size.y, 248.0)
thumb_size = Vector2(128.0, 128.0)
thumb_size = Vector2.ONE * minf(128.0, maxf(72.0, thumb_edge))
var card_payload: Dictionary = {"placement_id": _entry_key(entry), "label": String(entry.get("name", entry.get("id", ""))), "icon": String(entry.get("icon", "")), "category": category, "description": String(entry.get("description", "")), "thumbnail_size": thumb_size}
if is_background_ground_setup:
card_payload["description_min_height"] = 42.0
if entry.has("background_kind"):
card_payload["background_kind"] = entry["background_kind"]
card_payload["finish_id"] = entry.get("finish_id", "")
@@ -301,7 +625,27 @@ func _theme_color(path: String, fallback: Color) -> Color:
return fallback
func _theme_integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _sandbox_theme == null else _sandbox_theme.integer(path, fallback, minimum, maximum)
func _theme_vector2(path: String, fallback: Vector2) -> Vector2:
if _sandbox_theme == null:
return fallback
var raw: Variant = _sandbox_theme.value(path, [fallback.x, fallback.y])
if not raw is Array or (raw as Array).size() != 2:
return fallback
return Vector2(float((raw as Array)[0]), float((raw as Array)[1]))
func _on_cell_pressed(entry: Dictionary) -> void:
var background_kind: String = String(entry.get("background_kind", ""))
if background_kind == "wall_setup":
_open_surface_setup(&"wall")
return
if background_kind == "background_ground_setup":
_open_surface_setup(&"background_ground")
return
item_selected.emit(entry)
+114 -9
View File
@@ -1,17 +1,122 @@
class_name ColorSwatchSelector
extends HBoxContainer
extends FlowContainer
signal color_selected(color: Color)
var _selected: Color = Color.WHITE
var _group: ButtonGroup = ButtonGroup.new()
var _buttons: Array[Button] = []
var _stage_theme: SandboxTheme
var _colors: Array[Color] = []
func configure(colors: Array[Color], selected: Color, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
_stage_theme = theme as SandboxTheme if theme is SandboxTheme else null
_selected = selected
for color: Color in colors:
var button := Button.new(); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group; button.tooltip_text = color.to_html(true)
var box := StyleBoxFlat.new(); box.bg_color = color; box.set_corner_radius_all(22); button.add_theme_stylebox_override("normal", box)
button.pressed.connect(func() -> void: set_selected(color, true)); add_child(button)
button.set_pressed_no_signal(color.is_equal_approx(selected))
_colors = colors.duplicate()
var focus_key: String = ""
var focus_owner: Control = get_viewport().gui_get_focus_owner() if is_inside_tree() else null
if focus_owner != null and focus_owner.get_parent() == self:
var focused_color: Color = focus_owner.get_meta("surface_color", Color.WHITE)
focus_key = "#" + focused_color.to_html(false).to_upper()
_group = ButtonGroup.new()
_group.allow_unpress = true
_buttons.clear()
for child: Node in get_children():
remove_child(child)
child.queue_free()
add_theme_constant_override("h_separation", _integer("stage_surface_colors.swatch_gap", 8))
add_theme_constant_override("v_separation", _integer("stage_surface_colors.swatch_gap", 8))
var seen: Dictionary = {}
for color: Color in _colors:
var color_key: String = color.to_html(true).to_upper()
if seen.has(color_key):
continue
seen[color_key] = true
var button := Button.new()
var swatch_size: float = float(_integer("stage_surface_colors.swatch_size", 48))
button.custom_minimum_size = Vector2(swatch_size, swatch_size)
button.toggle_mode = true
button.button_group = _group
button.focus_mode = Control.FOCUS_ALL
button.tooltip_text = "Color #%s" % color.to_html(false).to_upper()
button.set_meta("surface_color", color)
button.pressed.connect(_on_pressed.bind(button))
add_child(button)
_buttons.append(button)
_apply_button_style(button, color)
_sync_pressed_state()
if not focus_key.is_empty():
call_deferred("_restore_color_focus", focus_key)
func set_selected(color: Color, emit_signal: bool = false) -> void:
_selected = color
if emit_signal: color_selected.emit(color)
func get_selected() -> Color: return _selected
_sync_pressed_state()
if emit_signal:
color_selected.emit(color)
func get_selected() -> Color:
return _selected
func _restore_color_focus(color_key: String) -> void:
for button: Button in _buttons:
var button_color: Color = button.get_meta("surface_color", Color.WHITE)
if "#" + button_color.to_html(false).to_upper() == color_key:
button.grab_focus()
return
func _on_pressed(button: Button) -> void:
var color: Color = button.get_meta("surface_color", Color.WHITE)
set_selected(color, true)
func _sync_pressed_state() -> void:
for button: Button in _buttons:
var color: Color = button.get_meta("surface_color", Color.WHITE)
button.set_pressed_no_signal(color.to_html(true) == _selected.to_html(true))
button.text = "✓" if color.to_html(true) == _selected.to_html(true) else ""
var label_color: Color = Color("#111821") if color.get_luminance() > 0.58 else Color("#FFFFFF")
button.add_theme_color_override("font_color", label_color)
button.add_theme_color_override("font_hover_color", label_color)
button.add_theme_color_override("font_pressed_color", label_color)
func _apply_button_style(button: Button, color: Color) -> void:
var border: Color = _color("stage_surface_colors.swatch_border", Color("#FFFFFFB8"))
var accent: Color = _color("mode_colors.edit", Color("#22C6FF"))
var corner: int = _integer("stage_surface_colors.swatch_corner_radius", 8)
var normal := StyleBoxFlat.new()
normal.bg_color = color
normal.border_color = border
normal.set_border_width_all(_integer("stage_surface_colors.swatch_border_width", 2))
normal.set_corner_radius_all(corner)
var selected := normal.duplicate() as StyleBoxFlat
selected.border_color = accent
selected.set_border_width_all(_integer("stage_surface_colors.selected_border_width", 4))
var hover := normal.duplicate() as StyleBoxFlat
hover.border_color = Color.WHITE
hover.set_border_width_all(_integer("stage_surface_colors.focus_border_width", 3))
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", selected)
button.add_theme_stylebox_override("hover_pressed", selected.duplicate())
var focus := normal.duplicate() as StyleBoxFlat
focus.draw_center = false
focus.border_color = _color("stage_surface_colors.swatch_focus_border", Color.WHITE)
focus.set_border_width_all(_integer("stage_surface_colors.focus_border_width", 3))
button.add_theme_stylebox_override("focus", focus)
if _stage_theme != null and _stage_theme.font() != null:
button.add_theme_font_override("font", _stage_theme.font())
button.add_theme_font_size_override("font_size", _integer("stage_surface_colors.swatch_check_size", 24))
func _integer(path: String, fallback: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, 0, 64)
func _color(path: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)
+2 -1
View File
@@ -13,7 +13,7 @@ const DEFAULT_CONFIG: Dictionary = {
"effects": {"impact_wobble": {"duration": 0.48, "maximum_angle_degrees": 7.0, "sideways_offset": 4.0}},
"grid": {"snap_size": 15.0},
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {"surface_color_size":[560,640], "surface_color_preview_size":[420,140], "surface_color_picker_min_size":[320,340], "action_min_target_size":48}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156], "surface_preview_size":[420,132], "look_cue_size":[80,42], "setup_gap":10}},
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
"stage_panels": {
"shell": {"preferred_width": 380, "edge_inset": 12, "padding": 12, "header_gap": 8, "section_gap": 12, "corner_radius": 10, "surface_color": "#252B38FA", "border_color": "#FFFFFF33"},
@@ -24,6 +24,7 @@ const DEFAULT_CONFIG: Dictionary = {
"prop": {"stickman_cue_color": "#F5F7FA", "prop_cue_color": "#B7824D", "cue_outline_color": "#171B24"},
"spots": {"row_gap": 8, "pin_visual_size": 30, "pin_hit_size": 44, "pin_outline_width": 2, "focus_ring_width": 3, "name_pill_padding": 10, "pin_surface_color": "#F4F7FB", "pin_outline_color": "#171B24", "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"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "rule_connector_color": "#AAB2C0", "rule_action_color": "#7E57C2", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}, "set_down": {"unknown_color": "#C5CAD4", "reach_color": "#FFB300", "ghost_alpha": 0.36}, "use_preview": {"valid_color": "#42D887", "invalid_color": "#F06464", "uncertain_color": "#FFB300", "neutral_color": "#C5CAD4", "completion_duration": 0.6}},
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"small", "label":"Small", "value":20}, {"id":"medium", "label":"Medium", "value":28}, {"id":"huge", "label":"HUGE", "value":40}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]},
"speech": {"toolbar": {"surface":"#1C2431F7", "border":"#9EADBF", "selection_halo":"#FFD84D", "gap":10, "max_width":480, "min_hit_height":48}, "ui": {"panel_width": 440, "button_min_height": 44, "heading_size": 24}, "layout": {"max_width": 420, "stack_gap": 12, "screen_margin": 18, "bubble_padding": 18}, "animation": {"letter_interval": 0.045, "word_interval": 0.20, "pop_duration": 0.24}, "styles": {"talk": {"fill":"#FFFDF6", "outline":"#202020", "tail":"talk"}, "shout": {"fill":"#FFD642", "outline":"#D92F26", "tail":"shout"}, "think": {"fill":"#E9F5FF", "outline":"#37698B", "tail":"think"}, "whisper": {"fill":"#F2F2F2", "outline":"#6D7885", "tail":"whisper"}, "sfx": {"fill":"#FFDD39", "outline":"#D92F26", "tail":"none"}}, "text_colors": [{"name":"Ink","color":"#202020"},{"name":"Midnight","color":"#111827"},{"name":"Slate","color":"#475569"},{"name":"Snow","color":"#FFFFFF"},{"name":"Cloud","color":"#CBD5E1"},{"name":"Sky","color":"#2196F3"},{"name":"Ocean","color":"#0077B6"},{"name":"Teal","color":"#00897B"},{"name":"Leaf","color":"#2E7D32"},{"name":"Lime","color":"#8BC34A"},{"name":"Sunshine","color":"#FFC107"},{"name":"Orange","color":"#F57C00"},{"name":"Sunset red","color":"#E53935"},{"name":"Rose","color":"#D81B60"},{"name":"Violet","color":"#7E57C2"},{"name":"Brown","color":"#795548"}], "preview_ghost":"#FFFFFF99", "connector":"#FFFFFFB0"}
@@ -37,9 +37,37 @@ func configure(entry: Dictionary) -> void:
func _draw() -> void:
if _background_kind == "wall" and SURFACE_RENDERER.is_wall_finish(_preview_id):
var wall_tint: Color = Color("#7FA36C") if _preview_id == &"finish_wall_paint" else Color.WHITE
SURFACE_RENDERER.draw_wall(self, Rect2(Vector2.ZERO, size), _preview_id, wall_tint, Vector2.ZERO)
# The preview rect is already small and clipped, so draw its full pattern directly.
SURFACE_RENDERER.draw_wall(
self,
Rect2(Vector2.ZERO, size),
_preview_id,
wall_tint,
Vector2.ZERO,
Vector2.ONE,
false
)
elif _background_kind == "wall_setup":
var finishes: Array[StringName] = [&"finish_wall_paint", &"finish_wall_brick", &"finish_wall_wood"]
_draw_finish_samples(finishes, true)
elif _background_kind == "background_ground_setup":
var finishes: Array[StringName] = [&"finish_background_grass", &"finish_background_concrete", &"finish_background_carpet"]
_draw_finish_samples(finishes, false)
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED:
queue_redraw()
func _draw_finish_samples(finishes: Array[StringName], wall: bool) -> void:
var gap: float = 2.0
var sample_width: float = (size.x - gap * float(finishes.size() - 1)) / float(finishes.size())
for index: int in finishes.size():
var sample_rect := Rect2(Vector2(float(index) * (sample_width + gap), 0.0), Vector2(sample_width, size.y))
var finish: StringName = finishes[index]
var tint: Color = SURFACE_RENDERER.default_tint(finish)
if wall:
SURFACE_RENDERER.draw_wall(self, sample_rect, finish, tint, Vector2.ZERO, Vector2.ONE, false)
else:
SURFACE_RENDERER.draw_background(self, sample_rect, finish, tint, Vector2.ZERO, Vector2.ONE, false)
@@ -0,0 +1,15 @@
class_name StageBackgroundGround
extends "res://scripts/stage/scenery/stage_rect_surface.gd"
## Non-walkable rectangular scenery surface behind the set.
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
func configure(new_size: Vector2, finish: StringName, color: Color) -> void:
super.configure(new_size, finish if SURFACE_RENDERER.is_background_finish(finish) else &"finish_background_grass", color)
z_index = DRAW_ORDER.BACKGROUND_GROUND
func _draw() -> void:
SURFACE_RENDERER.draw_background(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world())
@@ -0,0 +1 @@
uid://bl3tfgsieeq5x
@@ -0,0 +1,39 @@
class_name StageRectSurface
extends Node2D
## Shared rectangular geometry and transient tint preview for inert Stage scenery.
var panel_size: Vector2 = Vector2(320.0, 400.0)
var finish_id: StringName = &""
var tint: Color = Color.WHITE
var _preview_tint: Color = Color.WHITE
var _has_preview_tint: bool = false
func configure(new_size: Vector2, finish: StringName, color: Color) -> void:
panel_size = Vector2(maxf(1.0, new_size.x), maxf(1.0, new_size.y))
finish_id = finish
tint = color
queue_redraw()
func get_local_bounds() -> Rect2:
return Rect2(-panel_size * 0.5, panel_size)
func set_preview_tint(color: Color) -> void:
_preview_tint = color
_has_preview_tint = true
queue_redraw()
func clear_preview_tint() -> void:
_has_preview_tint = false
queue_redraw()
func get_render_tint() -> Color:
return _preview_tint if _has_preview_tint else tint
func get_surface_draw_origin_world() -> Vector2:
return global_position
@@ -0,0 +1 @@
uid://sk8pkbk41ef0
@@ -0,0 +1,28 @@
class_name StageSurfacePreview
extends Control
## Small render-only preview that uses the Stage surface motif renderer.
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
var surface_kind: StringName = &"wall"
var finish_id: StringName = &"finish_wall_paint"
var tint: Color = Color("#5A9D70")
func configure(kind: StringName, finish: StringName, color: Color) -> void:
surface_kind = kind
finish_id = finish
tint = color
mouse_filter = Control.MOUSE_FILTER_IGNORE
queue_redraw()
func _draw() -> void:
var rect: Rect2 = Rect2(Vector2(3.0, 3.0), size - Vector2(6.0, 6.0))
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
return
if surface_kind == &"background_ground":
SURFACE_RENDERER.draw_background(self, rect, finish_id, tint, Vector2.ZERO, Vector2.ONE, false)
else:
SURFACE_RENDERER.draw_wall(self, rect, finish_id, tint, Vector2.ZERO, Vector2.ONE, false)
draw_rect(rect, Color("#F5F7FA"), false, 2.0)
@@ -0,0 +1 @@
uid://kyveykcipe5c
+589 -15
View File
@@ -3,17 +3,37 @@ extends RefCounted
## World-aligned, bounded vector motifs for finite walls and Ground.
const WALL_FINISHES: Array[StringName] = [&"finish_wall_paint", &"finish_wall_brick", &"finish_wall_wood"]
const BACKGROUND_FINISHES: Array[StringName] = [&"finish_background_grass", &"finish_background_concrete", &"finish_background_carpet"]
const GROUND_FINISHES: Array[StringName] = [&"finish_ground_paint", &"finish_ground_brick", &"finish_ground_wood", &"finish_ground_grass", &"finish_ground_dirt", &"finish_ground_sidewalk", &"finish_ground_asphalt"]
const MAX_CELLS: int = 1200
const BRICK_WIDTH: float = 82.0
const BRICK_HEIGHT: float = 34.0
const MAX_VISIBLE_BRICK_CELLS: int = 12000
const WALL_BRICK_WIDTH: float = 100.0
const WALL_BRICK_HEIGHT: float = 38.0
const MAX_WALL_BRICK_CELLS: int = 1200
const MAX_BACKGROUND_CELLS: int = 12000
static func is_wall_finish(id: StringName) -> bool:
return WALL_FINISHES.has(id)
static func is_background_finish(id: StringName) -> bool:
return BACKGROUND_FINISHES.has(id)
static func default_tint(finish: StringName) -> Color:
match finish:
&"finish_wall_paint": return Color("#5A9D70")
&"finish_wall_brick": return Color("#B95F3D")
&"finish_wall_wood": return Color("#C87137")
&"finish_background_grass": return Color("#72A84C")
&"finish_background_concrete": return Color("#A6AAA3")
&"finish_background_carpet": return Color("#8B537B")
_: return Color("#5A9D70")
static func is_ground_finish(id: StringName) -> bool:
return GROUND_FINISHES.has(id) or id == &"finish_ground_legacy"
@@ -24,9 +44,10 @@ static func draw_wall(
finish: StringName,
tint: Color,
world_origin: Vector2,
drawing_scale: Vector2 = Vector2.ONE
drawing_scale: Vector2 = Vector2.ONE,
cull_to_viewport: bool = true
) -> void:
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, true)
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, true, false, cull_to_viewport)
static func draw_ground(
@@ -37,7 +58,19 @@ static func draw_ground(
world_origin: Vector2,
drawing_scale: Vector2 = Vector2.ONE
) -> void:
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, false)
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, false, false, true)
static func draw_background(
canvas: CanvasItem,
rect: Rect2,
finish: StringName,
tint: Color,
world_origin: Vector2,
drawing_scale: Vector2 = Vector2.ONE,
cull_to_viewport: bool = true
) -> void:
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, false, true, cull_to_viewport)
static func _draw_surface(
@@ -47,7 +80,9 @@ static func _draw_surface(
tint: Color,
world_origin: Vector2,
drawing_scale: Vector2,
wall: bool
wall: bool,
background: bool,
cull_to_viewport: bool
) -> void:
var normalized := rect.abs()
if normalized.size.x <= 0.0 or normalized.size.y <= 0.0:
@@ -57,11 +92,15 @@ static func _draw_surface(
if finish == &"finish_ground_legacy":
base = Color("#468B42")
elif finish.ends_with("brick"):
base = Color("#B95F3D") if wall else Color("#994D35")
base = Color("#D2CBAF") if wall else Color("#994D35")
elif finish.ends_with("wood"):
base = Color("#9A673C") if wall else Color("#B88951")
elif finish == &"finish_ground_grass":
elif finish == &"finish_ground_grass" or finish == &"finish_background_grass":
base = Color("#72A84C")
elif finish == &"finish_background_concrete":
base = Color("#A6AAA3")
elif finish == &"finish_background_carpet":
base = tint
elif finish == &"finish_ground_dirt":
base = Color("#8E633E")
elif finish == &"finish_ground_sidewalk":
@@ -70,13 +109,39 @@ static func _draw_surface(
base = Color("#505A62")
canvas.draw_rect(normalized, base, true)
if finish.ends_with("brick"):
var view_rect: Rect2 = _visible_local_rect(canvas)
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
var detail_scale: float = brick_detail_scale_for_view(view_rect.size if not view_rect.size.is_zero_approx() else normalized.size, motif_scale)
_draw_bricks(canvas, pattern_rect, world_origin, base, motif_scale, detail_scale)
var detail_size: Vector2 = view_rect.size if not view_rect.size.is_zero_approx() else normalized.size
var detail_scale: float = brick_detail_scale_for_view(detail_size, motif_scale, wall)
if wall:
_draw_wall_bricks(canvas, pattern_rect, world_origin, motif_scale, detail_scale, tint)
else:
_draw_bricks(canvas, pattern_rect, world_origin, base, motif_scale, detail_scale)
elif finish.ends_with("wood"):
_draw_wood(canvas, normalized, world_origin, base, motif_scale)
if wall:
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
var detail_scale: float = wood_detail_scale_for_view(pattern_rect.size, motif_scale)
_draw_wall_wood(canvas, pattern_rect, world_origin, motif_scale, detail_scale, tint)
else:
_draw_wood(canvas, normalized, world_origin, base, motif_scale)
elif finish == &"finish_background_grass":
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
_draw_background_grass(canvas, pattern_rect, world_origin, motif_scale)
elif finish == &"finish_background_concrete":
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
_draw_background_concrete(canvas, pattern_rect, world_origin, motif_scale)
elif finish == &"finish_background_carpet":
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
_draw_background_carpet(canvas, pattern_rect, world_origin, motif_scale, tint)
elif finish == &"finish_ground_grass":
_draw_grass(canvas, normalized, world_origin, motif_scale)
elif finish == &"finish_ground_dirt":
@@ -89,20 +154,529 @@ static func _draw_surface(
push_warning("StageSurfaceRenderer: unknown finish '%s'; drawing paint." % String(finish))
static func brick_detail_scale_for_view(view_size: Vector2, motif_scale: Vector2 = Vector2.ONE) -> float:
static func brick_detail_scale_for_view(view_size: Vector2, motif_scale: Vector2 = Vector2.ONE, wall: bool = false) -> float:
var scale: float = 1.0
var safe_size: Vector2 = Vector2(maxf(1.0, view_size.x), maxf(1.0, view_size.y))
var safe_motif_scale: Vector2 = _normalized_scale(motif_scale)
var brick_width: float = WALL_BRICK_WIDTH if wall else BRICK_WIDTH
var brick_height: float = WALL_BRICK_HEIGHT if wall else BRICK_HEIGHT
var max_cells: int = MAX_WALL_BRICK_CELLS if wall else MAX_VISIBLE_BRICK_CELLS
for _iteration: int in 8:
var columns: int = int(ceil(safe_size.x * safe_motif_scale.x / (brick_width * scale))) + 2
var rows: int = int(ceil(safe_size.y * safe_motif_scale.y / (brick_height * scale))) + 2
var cell_count: int = rows * columns
if cell_count <= max_cells:
return scale
scale *= sqrt(float(cell_count) / float(max_cells)) * 1.01
return scale
static func wood_detail_scale_for_view(view_size: Vector2, motif_scale: Vector2 = Vector2.ONE) -> float:
var scale: float = 1.0
var safe_size: Vector2 = Vector2(maxf(1.0, view_size.x), maxf(1.0, view_size.y))
var safe_motif_scale: Vector2 = _normalized_scale(motif_scale)
for _iteration: int in 8:
var columns: int = int(ceil(safe_size.x * safe_motif_scale.x / (BRICK_WIDTH * scale))) + 2
var rows: int = int(ceil(safe_size.y * safe_motif_scale.y / (BRICK_HEIGHT * scale))) + 2
var columns: int = int(ceil(safe_size.x * safe_motif_scale.x / (320.0 * scale))) + 2
var rows: int = int(ceil(safe_size.y * safe_motif_scale.y / (64.0 * scale))) + 2
var cell_count: int = rows * columns
if cell_count <= MAX_VISIBLE_BRICK_CELLS:
if cell_count <= MAX_CELLS:
return scale
scale *= sqrt(float(cell_count) / float(MAX_VISIBLE_BRICK_CELLS)) * 1.01
scale *= sqrt(float(cell_count) / float(MAX_CELLS)) * 1.01
return scale
static func _draw_wall_wood(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color) -> void:
const BOARD_HEIGHT: float = 64.0
const PLANK_WIDTH: float = 320.0
var safe_scale: Vector2 = _normalized_scale(motif_scale)
var board_world_height: float = BOARD_HEIGHT * detail_scale
var plank_world_width: float = PLANK_WIDTH * detail_scale
var world_left: float = rect.position.x * safe_scale.x + origin.x
var world_right: float = rect.end.x * safe_scale.x + origin.x
var world_top: float = rect.position.y * safe_scale.y + origin.y
var world_bottom: float = rect.end.y * safe_scale.y + origin.y
var first_row: int = floori(world_top / board_world_height)
var last_row: int = floori((world_bottom - 0.001) / board_world_height)
var row_count: int = mini(last_row - first_row + 1, MAX_CELLS)
var natural: Color = default_tint(&"finish_wall_wood")
var seam_color: Color = _material_color(Color("#784421"), tint, natural)
var grain_color: Color = _material_color(Color("#A05A2C"), tint, natural)
var highlight_color: Color = _material_color(Color("#E39A59"), tint, natural)
for row_offset: int in row_count:
var row: int = first_row + row_offset
var board_world_top: float = float(row) * board_world_height
var board_local_top: float = (board_world_top - origin.y) / safe_scale.y
var stagger: float = plank_world_width * 0.5 if posmod(row, 2) == 1 else 0.0
var first_column: int = floori((world_left - stagger) / plank_world_width)
var last_column: int = floori((world_right - stagger - 0.001) / plank_world_width)
var column_count: int = mini(last_column - first_column + 1, MAX_CELLS)
for column_offset: int in column_count:
var column: int = first_column + column_offset
var plank_world_left: float = float(column) * plank_world_width + stagger
var plank_rect := Rect2(
Vector2((plank_world_left - origin.x) / safe_scale.x, board_local_top),
Vector2(plank_world_width / safe_scale.x, board_world_height / safe_scale.y)
)
var visible_plank: Rect2 = rect.intersection(plank_rect)
if visible_plank.size.x <= 0.0 or visible_plank.size.y <= 0.0:
continue
var tone_index: int = posmod(row + column, 3)
var plank_color: Color = _material_color(Color("#C87137"), tint, natural)
if tone_index == 1:
plank_color = plank_color.darkened(0.045)
elif tone_index == 2:
plank_color = plank_color.lightened(0.035)
canvas.draw_rect(visible_plank, plank_color, true)
var plank_local_left: float = (plank_world_left - origin.x) / safe_scale.x
_draw_clipped_line(
canvas, rect,
Vector2(plank_local_left, board_local_top),
Vector2(plank_local_left, board_local_top + board_world_height / safe_scale.y),
_color_with_alpha(seam_color, 0.88), 2.0 / safe_scale.x
)
_draw_wall_wood_grain(
canvas, rect, plank_world_left, board_world_top, plank_world_width, board_world_height,
origin, safe_scale, row, column, grain_color, highlight_color, detail_scale, tint, natural
)
_draw_wall_wood_knot(
canvas, rect, plank_world_left, board_world_top, plank_world_width, board_world_height,
origin, safe_scale, row, column, detail_scale, tint, natural
)
_draw_clipped_line(
canvas, rect,
Vector2(rect.position.x, board_local_top), Vector2(rect.end.x, board_local_top),
seam_color, 2.4 / safe_scale.y
)
_draw_clipped_line(
canvas, rect,
Vector2(rect.position.x, board_local_top + 2.4 / safe_scale.y),
Vector2(rect.end.x, board_local_top + 2.4 / safe_scale.y),
_color_with_alpha(highlight_color, 0.62), 1.0 / safe_scale.y
)
static func _draw_wall_wood_grain(
canvas: CanvasItem,
clip_rect: Rect2,
plank_world_left: float,
board_world_top: float,
plank_world_width: float,
board_world_height: float,
origin: Vector2,
motif_scale: Vector2,
row: int,
column: int,
grain_color: Color,
highlight_color: Color,
detail_scale: float,
tint: Color,
natural: Color
) -> void:
const TRACK_COUNT: int = 5
const SAMPLES: int = 32
var seed: int = row * 73856093 + column * 19349663
for track: int in TRACK_COUNT:
var track_seed: int = seed + track * 83492791
var lane: float = 0.16 + _wood_hash(track_seed) * 0.68
var amplitude: float = (0.55 + _wood_hash(track_seed + 11) * 2.4) * detail_scale
var cycles: float = 1.0 + _wood_hash(track_seed + 23) * 2.6
var phase: float = _wood_hash(track_seed + 37) * TAU
var points := PackedVector2Array()
for sample: int in SAMPLES + 1:
var t: float = float(sample) / float(SAMPLES)
var wave: float = sin(t * TAU * cycles + phase) * amplitude
wave += sin(t * TAU * (cycles * 0.43 + 0.4) + phase * 1.7) * amplitude * 0.32
var world_x: float = plank_world_left + t * plank_world_width
var world_y: float = board_world_top + lane * board_world_height + wave
points.append(Vector2((world_x - origin.x) / motif_scale.x, (world_y - origin.y) / motif_scale.y))
_draw_clipped_polyline(canvas, clip_rect, points, _color_with_alpha(grain_color, 0.19), 5.0 * detail_scale / motif_scale.y)
_draw_clipped_polyline(canvas, clip_rect, points, _color_with_alpha(grain_color, 0.58), 1.15 * detail_scale / motif_scale.y)
var highlight_points := PackedVector2Array()
for point: Vector2 in points:
highlight_points.append(point + Vector2(0.0, -1.0 * detail_scale / motif_scale.y))
_draw_clipped_polyline(canvas, clip_rect, highlight_points, _color_with_alpha(highlight_color, 0.32), 0.9 * detail_scale / motif_scale.y)
static func _draw_wall_wood_knot(
canvas: CanvasItem,
clip_rect: Rect2,
plank_world_left: float,
board_world_top: float,
plank_world_width: float,
board_world_height: float,
origin: Vector2,
motif_scale: Vector2,
row: int,
column: int,
detail_scale: float,
tint: Color,
natural: Color
) -> void:
var seed: int = row * 92821 + column * 68917 + 31
if _wood_hash(seed) > 0.24:
return
var center_world := Vector2(
plank_world_left + plank_world_width * (0.27 + _wood_hash(seed + 1) * 0.46),
board_world_top + board_world_height * (0.34 + _wood_hash(seed + 2) * 0.34)
)
var radius_x: float = (12.0 + _wood_hash(seed + 3) * 12.0) * detail_scale
var radius_y: float = (3.0 + _wood_hash(seed + 4) * 2.8) * detail_scale
var center := Vector2((center_world.x - origin.x) / motif_scale.x, (center_world.y - origin.y) / motif_scale.y)
for ring: int in 3:
var ring_scale: float = 1.0 - float(ring) * 0.27
var points := PackedVector2Array()
for point_index: int in 33:
var angle: float = TAU * float(point_index) / 32.0
var wobble: float = 1.0 + 0.07 * sin(angle * 3.0 + float(seed % 17))
var world_offset := Vector2(
cos(angle) * radius_x * ring_scale * wobble,
sin(angle) * radius_y * ring_scale * (1.0 + 0.08 * cos(angle * 2.0))
)
points.append(center + Vector2(world_offset.x / motif_scale.x, world_offset.y / motif_scale.y))
var ring_source: Color = Color("#784421") if ring != 1 else Color("#E39A59")
var ring_color: Color = _color_with_alpha(_material_color(ring_source, tint, natural), 0.56 if ring != 1 else 0.52)
var ring_width: float = (1.25 if ring != 1 else 0.85) * detail_scale / motif_scale.y
_draw_clipped_polyline(canvas, clip_rect, points, ring_color, ring_width)
static func _draw_clipped_polyline(canvas: CanvasItem, rect: Rect2, points: PackedVector2Array, color: Color, width: float) -> void:
if points.size() < 2:
return
var inset: float = maxf(0.0, width) * 0.5
var safe_rect := Rect2(rect.position + Vector2.ONE * inset, rect.size - Vector2.ONE * inset * 2.0)
var fully_inside: bool = safe_rect.size.x > 0.0 and safe_rect.size.y > 0.0
if fully_inside:
for point: Vector2 in points:
if not safe_rect.has_point(point):
fully_inside = false
break
if fully_inside:
canvas.draw_polyline(points, color, width, true)
return
for index: int in range(points.size() - 1):
_draw_clipped_line(canvas, rect, points[index], points[index + 1], color, width)
static func _wood_hash(seed: int) -> float:
var value: float = sin(float(seed) * 12.9898 + 78.233) * 43758.5453
return value - floorf(value)
static func _color_with_alpha(color: Color, alpha: float) -> Color:
return Color(color.r, color.g, color.b, alpha)
static func _material_color(source: Color, selected: Color, natural: Color) -> Color:
var natural_value: float = maxf(0.08, natural.v)
var source_value_ratio: float = source.v / natural_value
var value: float = clampf(selected.v * source_value_ratio, 0.035, 1.0)
var natural_saturation: float = maxf(0.08, natural.s)
var source_saturation_ratio: float = source.s / natural_saturation
var saturation: float = clampf(selected.s * source_saturation_ratio, 0.0, 1.0)
return Color.from_hsv(selected.h, saturation, value, source.a)
static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void:
const CELL_WIDTH: float = 44.0
const CELL_HEIGHT: float = 36.0
var world_left: float = rect.position.x * motif_scale.x + origin.x
var world_right: float = rect.end.x * motif_scale.x + origin.x
var world_top: float = rect.position.y * motif_scale.y + origin.y
var world_bottom: float = rect.end.y * motif_scale.y + origin.y
var first_column: int = floori(world_left / CELL_WIDTH) - 1
var last_column: int = floori(world_right / CELL_WIDTH) + 1
var first_row: int = floori(world_top / CELL_HEIGHT) - 1
var last_row: int = floori(world_bottom / CELL_HEIGHT) + 1
var remaining: int = MAX_BACKGROUND_CELLS
for row: int in range(first_row, last_row + 1):
for column: int in range(first_column, last_column + 1):
if remaining <= 0:
return
remaining -= 1
var seed: int = row * 73856093 + column * 19349663 + 197
var center_world := Vector2((float(column) + 0.5) * CELL_WIDTH, (float(row) + 0.5) * CELL_HEIGHT)
center_world += Vector2((_wood_hash(seed) - 0.5) * 34.0, (_wood_hash(seed + 1) - 0.5) * 26.0)
var center := Vector2((center_world.x - origin.x) / motif_scale.x, (center_world.y - origin.y) / motif_scale.y)
if not rect.grow(8.0).has_point(center):
continue
var tuft_color: Color = Color("#3A7837").lightened(_wood_hash(seed + 2) * 0.24)
var highlight: Color = Color("#C3DA74").darkened(_wood_hash(seed + 3) * 0.24)
var radius: float = 1.8 + _wood_hash(seed + 4) * 1.6
canvas.draw_circle(center, radius, _color_with_alpha(tuft_color, 0.56))
for blade: int in 4:
var blade_seed: int = seed + blade * 47 + 11
var x_offset: float = (_wood_hash(blade_seed) - 0.5) * 9.0 / motif_scale.x
var y_offset: float = (_wood_hash(blade_seed + 1) - 0.5) * 7.0 / motif_scale.y
var blade_height: float = (4.5 + _wood_hash(blade_seed + 2) * 4.0) / motif_scale.y
var start := center + Vector2(x_offset, y_offset)
var tip := start + Vector2((_wood_hash(blade_seed + 3) - 0.5) * 4.0 / motif_scale.x, -blade_height)
_draw_clipped_line(canvas, rect, start, tip, _color_with_alpha(highlight, 0.78), 1.25 / motif_scale.y)
static func _draw_background_concrete(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void:
const SLAB_WIDTH: float = 280.0
const SLAB_HEIGHT: float = 190.0
var world_left: float = rect.position.x * motif_scale.x + origin.x
var world_right: float = rect.end.x * motif_scale.x + origin.x
var world_top: float = rect.position.y * motif_scale.y + origin.y
var world_bottom: float = rect.end.y * motif_scale.y + origin.y
var first_column: int = floori(world_left / SLAB_WIDTH)
var last_column: int = floori(world_right / SLAB_WIDTH)
var first_row: int = floori(world_top / SLAB_HEIGHT)
var last_row: int = floori(world_bottom / SLAB_HEIGHT)
var column_gap: float = 1.4 / motif_scale.x
var row_gap: float = 1.4 / motif_scale.y
var remaining: int = MAX_BACKGROUND_CELLS
for column: int in range(first_column, mini(last_column + 1, first_column + MAX_BACKGROUND_CELLS)):
var x: float = float(column) * SLAB_WIDTH
var local_x: float = (x - origin.x) / motif_scale.x
_draw_clipped_line(canvas, rect, Vector2(local_x, rect.position.y), Vector2(local_x, rect.end.y), Color("#68757A", 0.32), column_gap)
for row: int in range(first_row, mini(last_row + 1, first_row + MAX_BACKGROUND_CELLS)):
if remaining <= 0:
return
remaining -= 1
var seed: int = column * 92821 + row * 68917 + 51
var world_point := Vector2(x + 16.0 + _wood_hash(seed) * (SLAB_WIDTH - 32.0), float(row) * SLAB_HEIGHT + 14.0 + _wood_hash(seed + 1) * (SLAB_HEIGHT - 28.0))
var point := Vector2((world_point.x - origin.x) / motif_scale.x, (world_point.y - origin.y) / motif_scale.y)
if rect.grow(3.0).has_point(point):
var speckle: Color = Color("#E2E1D8") if _wood_hash(seed + 2) > 0.48 else Color("#677173")
canvas.draw_circle(point, (0.65 + _wood_hash(seed + 3) * 0.8) / motif_scale.y, _color_with_alpha(speckle, 0.34))
for row: int in range(first_row, mini(last_row + 1, first_row + MAX_BACKGROUND_CELLS)):
var y: float = float(row) * SLAB_HEIGHT
var local_y: float = (y - origin.y) / motif_scale.y
_draw_clipped_line(canvas, rect, Vector2(rect.position.x, local_y), Vector2(rect.end.x, local_y), Color("#68757A", 0.32), row_gap)
static func _draw_background_carpet(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, tint: Color) -> void:
const CELL_WIDTH: float = 34.0
const CELL_HEIGHT: float = 26.0
var world_left: float = rect.position.x * motif_scale.x + origin.x
var world_right: float = rect.end.x * motif_scale.x + origin.x
var world_top: float = rect.position.y * motif_scale.y + origin.y
var world_bottom: float = rect.end.y * motif_scale.y + origin.y
var first_column: int = floori(world_left / CELL_WIDTH)
var last_column: int = floori(world_right / CELL_WIDTH)
var first_row: int = floori(world_top / CELL_HEIGHT)
var last_row: int = floori(world_bottom / CELL_HEIGHT)
var remaining: int = MAX_BACKGROUND_CELLS
var natural: Color = default_tint(&"finish_background_carpet")
for row: int in range(first_row, last_row + 1):
for column: int in range(first_column, last_column + 1):
if remaining <= 0:
return
remaining -= 1
var seed: int = row * 92821 + column * 68917 + 71
var cell_left: float = float(column) * CELL_WIDTH
var cell_top: float = float(row) * CELL_HEIGHT
for fiber: int in 2:
var fiber_seed: int = seed + fiber * 53
var start_world := Vector2(cell_left + 3.0 + _wood_hash(fiber_seed) * (CELL_WIDTH - 6.0), cell_top + 3.0 + _wood_hash(fiber_seed + 1) * (CELL_HEIGHT - 6.0))
var end_world := start_world + Vector2(2.0 + _wood_hash(fiber_seed + 2) * 4.0, (_wood_hash(fiber_seed + 3) - 0.5) * 2.2)
var start := Vector2((start_world.x - origin.x) / motif_scale.x, (start_world.y - origin.y) / motif_scale.y)
var finish := Vector2((end_world.x - origin.x) / motif_scale.x, (end_world.y - origin.y) / motif_scale.y)
var source: Color = Color("#E7B8D2") if _wood_hash(fiber_seed + 4) > 0.52 else Color("#3E3044")
var fiber_color: Color = _material_color(source, tint, natural)
_draw_clipped_line(canvas, rect, start, finish, _color_with_alpha(fiber_color, 0.42), 0.85 / motif_scale.y)
static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color) -> void:
var brick_world_w: float = WALL_BRICK_WIDTH * detail_scale
var brick_world_h: float = WALL_BRICK_HEIGHT * detail_scale
var brick_w: float = brick_world_w / motif_scale.x
var brick_h: float = brick_world_h / motif_scale.y
var world_left: float = rect.position.x * motif_scale.x + origin.x
var world_right: float = rect.end.x * motif_scale.x + origin.x
var world_top: float = rect.position.y * motif_scale.y + origin.y
var world_bottom: float = rect.end.y * motif_scale.y + origin.y
var first_row: int = floori(world_top / brick_world_h)
var last_row: int = floori((world_bottom - 0.001) / brick_world_h)
var mortar_gap := Vector2(3.5 * detail_scale / motif_scale.x, 3.5 * detail_scale / motif_scale.y)
var source_palette: Array[Color] = [
Color("#B65E3E"), Color("#C16B47"), Color("#A95337"),
Color("#BD704B"), Color("#AD583A"), Color("#C47A53"),
]
var palette: Array[Color] = []
for source_color: Color in source_palette:
palette.append(_material_color(source_color, tint, default_tint(&"finish_wall_brick")))
var row_count: int = mini(last_row - first_row + 1, MAX_WALL_BRICK_CELLS)
for row_offset: int in row_count:
var row: int = first_row + row_offset
var row_world_top: float = float(row) * brick_world_h
var row_local_top: float = (row_world_top - origin.y) / motif_scale.y
var stagger_world: float = brick_world_w * 0.5 if posmod(row, 2) == 1 else 0.0
var first_column: int = floori((world_left - stagger_world) / brick_world_w) - 1
var column_count: int = int(ceil(rect.size.x * motif_scale.x / brick_world_w)) + 2
for column_offset: int in column_count:
var column: int = first_column + column_offset
var seed: int = row * 92821 + column * 68917 + 31
var brick_world_left: float = float(column) * brick_world_w + stagger_world
var brick_local_left: float = (brick_world_left - origin.x) / motif_scale.x
var width_variation: float = 0.92 + _wood_hash(seed + 1) * 0.14
var height_variation: float = 0.92 + _wood_hash(seed + 2) * 0.12
var face_size := Vector2(brick_w * width_variation, brick_h * height_variation) - mortar_gap * 2.0
var center_jitter := Vector2(
(_wood_hash(seed + 3) - 0.5) * 2.0 * mortar_gap.x * 0.3,
(_wood_hash(seed + 4) - 0.5) * 2.0 * mortar_gap.y * 0.3
)
var face_rect := Rect2(
Vector2(brick_local_left, row_local_top) + mortar_gap + center_jitter,
face_size
)
if face_rect.end.x < rect.position.x or face_rect.position.x > rect.end.x:
continue
if face_rect.end.y < rect.position.y or face_rect.position.y > rect.end.y:
continue
var roughness := Vector2(1.7 * detail_scale / motif_scale.x, 1.2 * detail_scale / motif_scale.y)
var outline: PackedVector2Array = _rough_brick_outline(face_rect, seed, roughness)
var shadow_offset := Vector2(1.3 * detail_scale / motif_scale.x, 1.8 * detail_scale / motif_scale.y)
var shadow_points: PackedVector2Array = _clip_polygon_to_rect(_offset_polygon(outline, shadow_offset), rect)
if shadow_points.size() >= 3:
canvas.draw_colored_polygon(shadow_points, _color_with_alpha(_material_color(Color("#72503E"), tint, default_tint(&"finish_wall_brick")), 0.40))
var face_points: PackedVector2Array = _clip_polygon_to_rect(outline, rect)
if face_points.size() < 3:
continue
var color_index: int = int(_wood_hash(seed + 5) * float(palette.size()))
var face_color: Color = palette[color_index]
canvas.draw_colored_polygon(face_points, face_color)
var closed_outline := face_points.duplicate()
closed_outline.append(face_points[0])
_draw_clipped_polyline(
canvas, rect, closed_outline, _color_with_alpha(_material_color(Color("#784631"), tint, default_tint(&"finish_wall_brick")), 0.30), 1.2 * detail_scale / motif_scale.y
)
var upper_edge := PackedVector2Array()
for point_index: int in mini(5, outline.size()):
upper_edge.append(outline[point_index])
_draw_clipped_polyline(
canvas, rect, upper_edge, _color_with_alpha(_material_color(Color("#F0B27C"), tint, default_tint(&"finish_wall_brick")), 0.58), 1.15 * detail_scale / motif_scale.y
)
var facet := PackedVector2Array([
Vector2(face_rect.position.x + face_rect.size.x * 0.06, face_rect.position.y + face_rect.size.y * 0.20),
Vector2(face_rect.position.x + face_rect.size.x * 0.40, face_rect.position.y + face_rect.size.y * 0.12),
Vector2(face_rect.position.x + face_rect.size.x * 0.68, face_rect.position.y + face_rect.size.y * 0.22),
Vector2(face_rect.position.x + face_rect.size.x * 0.34, face_rect.position.y + face_rect.size.y * 0.40),
])
var facet_points: PackedVector2Array = _clip_polygon_to_rect(facet, rect)
if facet_points.size() >= 3:
canvas.draw_colored_polygon(facet_points, _color_with_alpha(_material_color(Color("#E49A68"), tint, default_tint(&"finish_wall_brick")), 0.20))
_draw_wall_brick_texture(canvas, rect, face_rect, seed, detail_scale, motif_scale, tint)
static func _rough_brick_outline(rect: Rect2, seed: int, roughness: Vector2) -> PackedVector2Array:
var perimeter: Array[Vector2] = [
Vector2(0.02, 0.05), Vector2(0.22, 0.00), Vector2(0.45, 0.04), Vector2(0.68, 0.00),
Vector2(0.96, 0.03), Vector2(1.00, 0.22), Vector2(0.97, 0.48), Vector2(1.00, 0.73),
Vector2(0.97, 0.95), Vector2(0.72, 1.00), Vector2(0.48, 0.96), Vector2(0.22, 1.00),
Vector2(0.03, 0.96), Vector2(0.00, 0.73), Vector2(0.04, 0.48), Vector2(0.00, 0.22),
]
var points := PackedVector2Array()
for point_index: int in perimeter.size():
var normalized_point: Vector2 = perimeter[point_index]
var jitter := Vector2(
(_wood_hash(seed + point_index * 2 + 11) - 0.5) * 2.0 * roughness.x,
(_wood_hash(seed + point_index * 2 + 12) - 0.5) * 2.0 * roughness.y
)
points.append(rect.position + normalized_point * rect.size + jitter)
return points
static func _draw_wall_brick_texture(
canvas: CanvasItem,
clip_rect: Rect2,
face_rect: Rect2,
seed: int,
detail_scale: float,
motif_scale: Vector2,
tint: Color
) -> void:
var mark_count: int = 4 + int(_wood_hash(seed + 201) * 4.0)
var natural: Color = default_tint(&"finish_wall_brick")
var dark_ochre: Color = _material_color(Color("#74412D"), tint, natural)
var warm_clay: Color = _material_color(Color("#E09A68"), tint, natural)
for mark_index: int in mark_count:
var mark_seed: int = seed + mark_index * 37 + 211
var start_x: float = face_rect.position.x + face_rect.size.x * (0.09 + _wood_hash(mark_seed) * 0.72)
var center_y: float = face_rect.position.y + face_rect.size.y * (0.20 + _wood_hash(mark_seed + 1) * 0.63)
var length: float = face_rect.size.x * (0.08 + _wood_hash(mark_seed + 2) * 0.22)
var wave: float = (0.7 + _wood_hash(mark_seed + 3) * 1.8) * detail_scale / motif_scale.y
var points := PackedVector2Array([
Vector2(start_x, center_y),
Vector2(start_x + length * 0.48, center_y + wave),
Vector2(start_x + length, center_y - wave * 0.35),
])
var light_mark: bool = _wood_hash(mark_seed + 4) > 0.72
var mark_color: Color = warm_clay if light_mark else dark_ochre
var alpha: float = 0.20 + _wood_hash(mark_seed + 5) * 0.25
var width: float = (0.8 + _wood_hash(mark_seed + 6) * 1.6) * detail_scale / motif_scale.y
_draw_clipped_polyline(canvas, clip_rect, points, _color_with_alpha(mark_color, alpha), width)
var pore_count: int = 3 + int(_wood_hash(seed + 401) * 4.0)
for pore_index: int in pore_count:
var pore_seed: int = seed + pore_index * 53 + 419
var pore := Vector2(
face_rect.position.x + face_rect.size.x * (0.10 + _wood_hash(pore_seed) * 0.80),
face_rect.position.y + face_rect.size.y * (0.15 + _wood_hash(pore_seed + 1) * 0.70)
)
var radius: float = (0.55 + _wood_hash(pore_seed + 2) * 0.8) * detail_scale / motif_scale.y
var pore_color: Color = warm_clay if _wood_hash(pore_seed + 3) > 0.62 else dark_ochre
canvas.draw_circle(pore, radius, _color_with_alpha(pore_color, 0.38))
static func _offset_polygon(points: PackedVector2Array, offset: Vector2) -> PackedVector2Array:
var result := PackedVector2Array()
for point: Vector2 in points:
result.append(point + offset)
return result
static func _clip_polygon_to_rect(points: PackedVector2Array, rect: Rect2) -> PackedVector2Array:
var clipped := points
for edge: int in 4:
clipped = _clip_polygon_to_edge(clipped, rect, edge)
if clipped.is_empty():
return clipped
return clipped
static func _clip_polygon_to_edge(points: PackedVector2Array, rect: Rect2, edge: int) -> PackedVector2Array:
var result := PackedVector2Array()
if points.is_empty():
return result
var previous: Vector2 = points[points.size() - 1]
var previous_inside: bool = _point_inside_rect_edge(previous, rect, edge)
for current: Vector2 in points:
var current_inside: bool = _point_inside_rect_edge(current, rect, edge)
if current_inside:
if not previous_inside:
result.append(_rect_edge_intersection(previous, current, rect, edge))
result.append(current)
elif previous_inside:
result.append(_rect_edge_intersection(previous, current, rect, edge))
previous = current
previous_inside = current_inside
return result
static func _point_inside_rect_edge(point: Vector2, rect: Rect2, edge: int) -> bool:
match edge:
0:
return point.x >= rect.position.x
1:
return point.x <= rect.end.x
2:
return point.y >= rect.position.y
_:
return point.y <= rect.end.y
static func _rect_edge_intersection(from: Vector2, to: Vector2, rect: Rect2, edge: int) -> Vector2:
if edge <= 1:
var boundary_x: float = rect.position.x if edge == 0 else rect.end.x
var ratio_x: float = (boundary_x - from.x) / (to.x - from.x)
return Vector2(boundary_x, lerpf(from.y, to.y, ratio_x))
var boundary_y: float = rect.position.y if edge == 2 else rect.end.y
var ratio_y: float = (boundary_y - from.y) / (to.y - from.y)
return Vector2(lerpf(from.x, to.x, ratio_y), boundary_y)
static func _draw_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2, base: Color, motif_scale: Vector2, detail_scale: float = 1.0) -> void:
var brick_world_w: float = BRICK_WIDTH * detail_scale
var brick_world_h: float = BRICK_HEIGHT * detail_scale
+3 -13
View File
@@ -1,27 +1,17 @@
class_name StageWallPanel
extends Node2D
extends "res://scripts/stage/scenery/stage_rect_surface.gd"
## Finite decorative wall panel with world-scale finish motifs.
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
var finish_id: StringName = &"finish_wall_paint"
var tint: Color = Color("#5A9D70")
var panel_size: Vector2 = Vector2(320.0, 400.0)
## Saved source-wall anchor offset, retained to preserve erased-fragment metadata.
## Motif drawing uses the fragment's current world origin so its brick courses stay aligned.
var motif_origin_offset: Vector2 = Vector2.ZERO
func configure(new_size: Vector2, finish: StringName, color: Color) -> void:
panel_size = Vector2(maxf(1.0, new_size.x), maxf(1.0, new_size.y))
finish_id = finish if SURFACE_RENDERER.is_wall_finish(finish) else &"finish_wall_paint"
tint = color
super.configure(new_size, finish if SURFACE_RENDERER.is_wall_finish(finish) else &"finish_wall_paint", color)
z_index = DRAW_ORDER.WALL
queue_redraw()
func get_local_bounds() -> Rect2:
return Rect2(-panel_size * 0.5, panel_size)
func get_effective_motif_origin_world() -> Vector2:
@@ -33,4 +23,4 @@ func get_surface_draw_origin_world() -> Vector2:
func _draw() -> void:
SURFACE_RENDERER.draw_wall(self, get_local_bounds(), finish_id, tint, get_surface_draw_origin_world())
SURFACE_RENDERER.draw_wall(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world())
+1
View File
@@ -3,6 +3,7 @@ extends RefCounted
## Semantic draw bands shared by scenery, terrain, Props, and characters.
const WALL: int = -40
const BACKGROUND_GROUND: int = -50
const FAR: int = -30
const NEAR: int = -20
const TERRAIN: int = -10
+4 -3
View File
@@ -12,6 +12,7 @@ extends Node2D
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
const StageWallPanelScript := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const StageBackgroundGroundScript := preload("res://scripts/stage/scenery/stage_background_ground.gd")
const StageSceneryArtworkScript := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
# ---------------------------------------------------------------------------
@@ -300,12 +301,12 @@ func _corners(rect: Rect2) -> Array[Vector2]:
func _is_scenery_target(node: Node2D) -> bool:
return node != null and (node.get_script() == StageWallPanelScript or node.get_script() == StageSceneryArtworkScript)
return node != null and (node.get_script() == StageWallPanelScript or node.get_script() == StageBackgroundGroundScript or node.get_script() == StageSceneryArtworkScript)
func _target_bounds(node: Node2D) -> Rect2:
if node != null and node.get_script() == StageWallPanelScript:
return node.global_transform * (node as StageWallPanel).get_local_bounds()
if node is StageRectSurface:
return node.global_transform * (node as StageRectSurface).get_local_bounds()
if node != null and node.get_script() == StageSceneryArtworkScript:
return (node as StageSceneryArtwork).get_visual_world_bounds()
return STAGE_SELECTION.get_world_aabb(node)
+34 -4
View File
@@ -3,7 +3,8 @@ extends RefCounted
## JSON boundary for Sandbox Stage documents. This class deliberately has no
## scene-tree dependencies, which makes rejected documents non-destructive.
const FILE_VERSION: String = "1.5"
const FILE_VERSION: String = "1.6"
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
@@ -11,6 +12,7 @@ const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.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 WALL_FINISHES: Array[String] = ["finish_wall_paint", "finish_wall_brick", "finish_wall_wood"]
const BACKGROUND_FINISHES: Array[String] = ["finish_background_grass", "finish_background_concrete", "finish_background_carpet"]
const GROUND_FINISHES: Array[String] = ["finish_ground_paint", "finish_ground_brick", "finish_ground_wood", "finish_ground_grass", "finish_ground_dirt", "finish_ground_sidewalk", "finish_ground_asphalt", "finish_ground_legacy"]
const SKY_IDS: Array[String] = ["sky_clear_day", "sky_cloudy_day", "sky_sunset", "sky_starry_night", "sky_neutral"]
const SCENERY_DEPTHS: Array[String] = ["far", "near", "floor"]
@@ -183,10 +185,11 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
prop_entry["wobble_on_impact"] = false
_assign_legacy_depths(current.get("objects", []))
return current
if version not in ["1.0", "1.1", "1.2", "1.3", "1.4"]:
if version not in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5"]:
return {}
var migrated: Dictionary = data.duplicate(true)
migrated["sky_id"] = "sky_neutral"
if version != "1.5":
migrated["sky_id"] = "sky_neutral"
for raw: Variant in migrated.get("objects", []):
if not raw is Dictionary:
continue
@@ -200,13 +203,32 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
object["next_use_spot_number"] = object.get("next_use_spot_number", 1)
if not object.has("wobble_on_impact"):
object["wobble_on_impact"] = false
if String(object.get("type", "")) == "terrain" and String(object.get("asset_id", "")) == "ground":
if version != "1.5" and String(object.get("type", "")) == "terrain" and String(object.get("asset_id", "")) == "ground":
object["finish_id"] = "finish_ground_legacy"
if String(object.get("type", "")) == "wall_panel":
_migrate_legacy_wall_tint(object)
migrated["version"] = FILE_VERSION
_assign_legacy_depths(migrated.get("objects", []))
return migrated
static func _migrate_legacy_wall_tint(entry: Dictionary) -> void:
var finish: StringName = StringName(String(entry.get("finish_id", "finish_wall_paint")))
if finish not in [&"finish_wall_brick", &"finish_wall_wood"]:
if not entry.has("color"):
entry["color"] = "#5A9D70"
return
if not entry.has("color") or _is_legacy_green_tint(entry.get("color")):
entry["color"] = "#" + SURFACE_RENDERER.default_tint(finish).to_html(true)
static func _is_legacy_green_tint(value: Variant) -> bool:
if not value is String:
return false
var raw: String = String(value).strip_edges().trim_prefix("#").to_upper()
return raw == "5A9D70" or raw == "5A9D70FF"
static func _assign_legacy_depths(raw_objects: Variant) -> void:
if not raw_objects is Array:
return
@@ -277,6 +299,14 @@ static func _validate_root(data: Dictionary) -> Dictionary:
return {"ok": false, "error": "Wall panel color is invalid."}
if entry.has("motif_origin_offset") and not is_vector_data(entry.get("motif_origin_offset")):
return {"ok": false, "error": "Wall panel motif origin is invalid."}
elif String(entry.get("type", "")) == "background_ground":
if not is_vector_data(entry.get("size")):
return {"ok": false, "error": "Background ground size is invalid."}
var background_size: Vector2 = data_to_vector(entry.get("size"), Vector2.ZERO)
if background_size.x <= 0.0 or background_size.y <= 0.0 or not BACKGROUND_FINISHES.has(String(entry.get("finish_id", ""))):
return {"ok": false, "error": "Background ground size or finish is invalid."}
if not _valid_color(entry.get("color", "")):
return {"ok": false, "error": "Background ground color is invalid."}
elif String(entry.get("type", "")) == "scenery_artwork":
var asset_id: Variant = entry.get("asset_id", "")
if not asset_id is String or String(asset_id).strip_edges().is_empty():
+4 -1
View File
@@ -20,6 +20,7 @@ signal selection_changed(nodes: Array[Node2D])
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
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")
enum SelectionScope { ACTION, SCENERY }
@@ -172,6 +173,8 @@ func box_select(rect: Rect2, additive: bool) -> void:
static func get_world_aabb(node: Node2D) -> Rect2:
if node == null or not is_instance_valid(node):
return Rect2()
if node is StageRectSurface:
return node.global_transform * (node as StageRectSurface).get_local_bounds()
# Duck-typed TriggerArea: expose its centered local rect through the transform.
if node.has_method("get_area_rect"):
return node.global_transform * (node.call("get_area_rect") as Rect2)
@@ -230,7 +233,7 @@ func _is_selectable(node: Node) -> bool:
return false
if node.is_in_group("stickman_ragdoll_container") or bool(node.get_meta("stickman_ragdoll_container", false)):
return false
var is_scenery: bool = node.get_script() == STAGE_WALL_PANEL or node.get_script() == STAGE_SCENERY_ARTWORK
var is_scenery: bool = node.get_script() == STAGE_WALL_PANEL or node.get_script() == STAGE_BACKGROUND_GROUND or node.get_script() == STAGE_SCENERY_ARTWORK
if _scope == SelectionScope.ACTION and is_scenery:
return false
if _scope == SelectionScope.SCENERY and not is_scenery:
+9 -4
View File
@@ -44,7 +44,7 @@ func _run() -> void:
root.add_child(_stage)
await process_frame
await process_frame
_test_grid_controls_layout()
await _test_grid_controls_layout()
var baseline_spawner: StageSpawner = _stage.get("_spawner") as StageSpawner
var baseline_ground: TerrainBlock = baseline_spawner.spawn_terrain_asset("ground", Vector2.ZERO)
_expect(baseline_ground != null, "baseline navigation probe places one ordinary Ground block")
@@ -203,7 +203,7 @@ func _run() -> void:
for preview_node: Node in card_content.get_children():
if preview_node.get_script() == BACKGROUND_CARD_PREVIEW:
background_preview_count += 1
_expect(background_grid != null and background_grid.get_child_count() == 7 and background_preview_count == 7, "Sky and wall-finish cards render a distinct production-style preview")
_expect(background_grid != null and background_grid.get_child_count() == 6 and background_preview_count == 6, "four Sky looks and both surface setup cards render production-style previews")
var cloudy_status: Label = cloudy_card.find_child("StatusLabel", true, false) as Label if cloudy_card != null else null
_expect(cloudy_card != null and cloudy_card.button_pressed and cloudy_status != null and cloudy_status.text == "Current Sky", "gallery marks the loaded Town Street Sky as Current Sky")
var clear_sky_card: AssetCard = _find_card_by_id(background_grid, "sky_clear_day")
@@ -407,8 +407,13 @@ func _test_grid_controls_layout() -> void:
_expect(control_rect.position.y >= 0.0 and control_rect.end.y <= 64.0, "%s fits within the Stage top bar" % control.name)
var file_menu: MenuButton = chrome.call("get_file_menu") as MenuButton
if file_menu != null:
await _send_stage_click(file_menu.get_global_rect().get_center(), MOUSE_BUTTON_LEFT)
_expect(file_menu.get_popup().visible, "File menu opens when clicked beside the right-aligned Grid and Snap controls")
var file_menu_rect: Rect2 = file_menu.get_global_rect()
_expect(file_menu.is_visible_in_tree() and file_menu_rect.position.x >= 0.0 and file_menu_rect.end.x <= root.size.x, "File menu remains visible beside the right-aligned Grid and Snap controls")
# Headless SceneTree mouse injection reaches Stage canvas input but does not
# synthesize a Control pointer position, so verify the real MenuButton popup.
file_menu.show_popup()
await process_frame
_expect(file_menu.get_popup().visible, "File menu popup opens through its production MenuButton control")
file_menu.get_popup().hide()
+505
View File
@@ -0,0 +1,505 @@
extends SceneTree
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
const STAGE_SCENE_IO: Script = preload("res://scripts/stage/stage_scene_io.gd")
const STAGE_SCENE_ADAPTER: Script = preload("res://scripts/editors/stage/stage_scene_adapter.gd")
const STAGE_SURFACE_RENDERER: Script = preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const STAGE_BACKGROUND_GROUND: Script = preload("res://scripts/stage/scenery/stage_background_ground.gd")
const STAGE_WALL_PANEL: Script = preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const STAGE_SURFACE_PREVIEW: Script = preload("res://scripts/stage/scenery/stage_surface_preview.gd")
var _failures: Array[String] = []
var _stage: SandboxStage
var _initial_window_size: Vector2i = Vector2i(1280, 720)
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_set_window_geometry(Vector2i(1280, 720))
_initial_window_size = root.size
if DisplayServer.get_name() != "headless":
print("GUI capture window: usable=%s position=%s size=%s" % [str(DisplayServer.screen_get_usable_rect(DisplayServer.window_get_current_screen())), str(root.position), str(root.size)])
_test_v15_migration()
await _test_grass_render()
_stage = SANDBOX_STAGE.instantiate() as SandboxStage
root.add_child(_stage)
await process_frame
await process_frame
var world: Node2D = _stage.get_node("World") as Node2D
var controller: StageSceneryController = _stage.get("_scenery_controller") as StageSceneryController
var selected_ground: StageBackgroundGround = _place_background_ground(world, controller)
_test_background_ground_model_and_serialization(world, selected_ground)
await _test_surface_inspector_and_color_transactions(selected_ground)
await _test_surface_erase_confirmation(world, selected_ground)
await _test_selector_setup_routes(controller)
if _stage != null and is_instance_valid(_stage):
_stage.queue_free()
await process_frame
await process_frame
if _failures.is_empty():
print("PASS: Phase 19 background ground, color, migration, and erase behavior")
else:
for failure: String in _failures:
push_error("FAIL: " + failure)
quit(0 if _failures.is_empty() else 1)
func _test_v15_migration() -> void:
var source: Dictionary = {
"version": "1.5",
"sky_id": "sky_cloudy_day",
"objects": [
{"file_id": "ground_1", "type": "terrain", "asset_id": "ground", "position": {"x": 0.0, "y": 0.0}, "finish_id": "finish_ground_sidewalk"},
{"file_id": "wall_1", "type": "wall_panel", "position": {"x": 0.0, "y": 0.0}, "size": {"x": 320.0, "y": 240.0}, "finish_id": "finish_wall_brick", "color": "#5A9D70FF"},
{"file_id": "wall_2", "type": "wall_panel", "position": {"x": 400.0, "y": 0.0}, "size": {"x": 320.0, "y": 240.0}, "finish_id": "finish_wall_wood", "color": "#4477AAFF"}
]
}
var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(source)
_expect(String(migrated.get("version", "")) == "1.6", "v1.5 scenes migrate to v1.6")
_expect(String(migrated.get("sky_id", "")) == "sky_cloudy_day", "v1.5 migration preserves the saved Sky")
var objects: Array = migrated.get("objects", []) as Array
_expect(String((objects[0] as Dictionary).get("finish_id", "")) == "finish_ground_sidewalk", "v1.5 migration preserves walkable terrain finishes")
var migrated_brick_color: String = String((objects[1] as Dictionary).get("color", "")).to_upper()
var expected_brick_color: String = "#" + STAGE_SURFACE_RENDERER.default_tint(&"finish_wall_brick").to_html(true).to_upper()
_expect(migrated_brick_color == expected_brick_color, "old placeholder brick tint becomes the natural brick color")
_expect(String((objects[2] as Dictionary).get("color", "")) == "#4477AAFF", "intentional v1.5 wood color is preserved")
var current: Dictionary = source.duplicate(true)
current["version"] = "1.6"
var current_objects: Array = current["objects"] as Array
(current_objects[1] as Dictionary)["color"] = "#5A9D70FF"
var current_migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(current)
_expect(String(((current_migrated["objects"] as Array)[1] as Dictionary).get("color", "")) == "#5A9D70FF", "v1.6 brick colors remain authoritative")
func _test_grass_render() -> void:
if DisplayServer.get_name() == "headless":
print("SKIP: headless dummy renderer provides no visual surface capture; run this fixture in the GUI renderer")
return
var viewport := SubViewport.new()
viewport.size = Vector2i(640, 320)
viewport.transparent_bg = true
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
var surface: StageBackgroundGround = STAGE_BACKGROUND_GROUND.new() as StageBackgroundGround
surface.configure(Vector2(10000.0, 6000.0), &"finish_background_grass", Color("#72A84C"))
surface.position = Vector2(320.0, 160.0)
viewport.add_child(surface)
await process_frame
await process_frame
var texture: Texture2D = viewport.get_texture()
if texture == null:
print("SKIP: headless dummy renderer provides no SubViewport texture; visual grass capture requires the GUI renderer")
viewport.queue_free()
await process_frame
return
if not texture.get_rid().is_valid():
print("SKIP: headless dummy renderer has no texture RID; visual grass capture requires the GUI renderer")
viewport.queue_free()
await process_frame
return
var image: Image = texture.get_image()
if image == null:
print("SKIP: headless dummy renderer cannot read a SubViewport image; visual capture requires the GUI renderer")
viewport.queue_free()
await process_frame
return
var distinct: Dictionary = {}
var detail_samples: int = 0
var base := Color("#72A84C")
for y: int in range(12, 308, 2):
for x: int in range(12, 628, 2):
var sample: Color = image.get_pixel(x, y)
distinct[sample.to_html(true)] = true
var color_distance: float = absf(sample.r - base.r) + absf(sample.g - base.g) + absf(sample.b - base.b)
if color_distance > 0.08:
detail_samples += 1
_expect(distinct.size() >= 5 and detail_samples >= 1100, "large grass ground renders dense, visible interior grass texture at normal Stage scale (samples=%d)" % detail_samples)
image.save_png("/private/tmp/phase19_background_grass.png")
viewport.queue_free()
await process_frame
func _place_background_ground(world: Node2D, controller: StageSceneryController) -> StageBackgroundGround:
controller.set_grid_snap(false, 15.0)
controller.begin_background_ground(&"finish_background_grass", Vector2(300.0, 100.0), Color("#72A84C"))
var press := InputEventMouseButton.new()
press.button_index = MOUSE_BUTTON_LEFT
press.pressed = true
controller.handle_mouse_button(press, Vector2(300.0, 100.0), false)
var release := InputEventMouseButton.new()
release.button_index = MOUSE_BUTTON_LEFT
release.pressed = false
controller.handle_mouse_button(release, Vector2(700.0, 340.0), false)
var placed: StageBackgroundGround
for child: Node in world.get_children():
if child.get_script() == STAGE_BACKGROUND_GROUND:
placed = child as StageBackgroundGround
_expect(placed != null, "production rectangle placement commits a background ground node")
var placed_node: Node = placed
_expect(placed != null and not (placed_node is CollisionObject2D), "background ground remains non-physical and non-walkable")
return placed
func _test_background_ground_model_and_serialization(world: Node2D, surface: StageBackgroundGround) -> void:
if surface == null:
return
print("[background ground size] %s" % str(surface.panel_size))
_expect(surface.panel_size.is_equal_approx(Vector2(400.0, 240.0)), "background ground size follows the drawn rectangle")
var sample_wall: StageWallPanel = STAGE_WALL_PANEL.new() as StageWallPanel
sample_wall.configure(Vector2(100.0, 100.0), &"finish_wall_paint", Color.WHITE)
_expect(surface.z_index < sample_wall.z_index, "background ground draws behind wall panels")
sample_wall.free()
var selection: StageSelection = _stage.get("_selection") as StageSelection
selection.set_scope(StageSelection.SelectionScope.SCENERY)
_expect(selection.hit_test(surface.global_position) == surface, "Edit Scenery hit-testing can select background ground")
var data: Dictionary = _stage.call("_collect_scene_data", false)
var validation: Dictionary = STAGE_SCENE_IO._validate_root(data)
_expect(String(data.get("version", "")) == "1.6" and bool(validation.get("ok", false)), "background ground serializes and validates in the current scene schema")
var found: Dictionary = {}
for entry_value: Variant in data.get("objects", []):
if entry_value is Dictionary and String((entry_value as Dictionary).get("type", "")) == "background_ground":
found = entry_value as Dictionary
_expect(String(found.get("finish_id", "")) == "finish_background_grass", "scene adapter saves the distinct background finish")
var restored_world := Node2D.new()
root.add_child(restored_world)
var adapter: StageSceneAdapter = _stage.get("_scene_adapter") as StageSceneAdapter
var isolated: Dictionary = data.duplicate(true)
isolated["objects"] = [found]
var result: Dictionary = adapter.apply(isolated, restored_world, _stage.get("_spawner") as StageSpawner)
var restored: StageBackgroundGround = null
for child: Node in restored_world.get_children():
if child.get_script() == STAGE_BACKGROUND_GROUND:
restored = child as StageBackgroundGround
_expect(restored != null and restored.panel_size == surface.panel_size and restored.tint.is_equal_approx(surface.tint), "background ground round-trips through the production adapter")
_expect((result.get("loaded_nodes", []) as Array).size() == 1, "background ground reload is a separate object")
restored_world.queue_free()
func _test_surface_inspector_and_color_transactions(surface: StageBackgroundGround) -> void:
if surface == null:
return
_stage.call("_enter_scenery_edit_mode")
var selection: StageSelection = _stage.get("_selection") as StageSelection
selection.select_only(surface)
var inspector: StageInspectorPanel = _stage.get("_scenery_inspector") as StageInspectorPanel
var colors: StageSurfaceColorControls = inspector.find_child("ScenerySurfaceColors", true, false) as StageSurfaceColorControls
var ground_looks: StagePanelChoiceGroup = inspector.find_child("BackgroundGroundLooks", true, false) as StagePanelChoiceGroup
_expect(inspector.visible and surface.get_parent() == _stage.get_node("World"), "selected background ground opens its normal scenery inspector")
_expect(not colors.visible and ground_looks.visible, "Grass hides Color while showing its Look choices")
_set_window_geometry(Vector2i(640, 480))
_stage.call("_refresh_scenery_inspector_layout")
inspector.set_safe_rect(_stage.call("_prop_settings_safe_rect"))
inspector.request_fit()
await process_frame
await process_frame
await process_frame
var inspector_rect: Rect2 = inspector.get_global_rect()
_expect(inspector_rect.position.x >= 0.0 and inspector_rect.position.y >= 0.0 and inspector_rect.end.x <= 640.0 and inspector_rect.end.y <= 480.0, "selected-piece inspector fits a narrow display")
var capture_bar: StageUseTargetCaptureBar = _stage.get("_use_target_capture_bar") as StageUseTargetCaptureBar
var capture_bar_rect: Rect2 = capture_bar.get_global_rect() if capture_bar != null else Rect2()
_expect(capture_bar != null and (not capture_bar.visible or capture_bar_rect.end.x <= inspector_rect.position.x + 1.0), "narrow scenery actions stay beside the selected-piece inspector")
var chrome: CanvasLayer = _stage.get("_chrome") as CanvasLayer
var mode_buttons: Dictionary = chrome.call("get_mode_buttons") as Dictionary if chrome != null else {}
var grid_check: CheckBox = chrome.call("get_grid_check") as CheckBox if chrome != null else null
var snap_check: CheckBox = chrome.call("get_snap_check") as CheckBox if chrome != null else null
var grid_size: SpinBox = chrome.call("get_grid_size_spin") as SpinBox if chrome != null else null
for mode_button_value: Variant in mode_buttons.values():
var mode_button: Button = mode_button_value as Button
for right_control: Control in [grid_check, snap_check, grid_size]:
if right_control != null and right_control.visible:
_expect(not mode_button.get_global_rect().intersects(right_control.get_global_rect()), "narrow Stage mode and Grid/Snap controls do not overlap")
_capture_root_viewport("phase19_inspector_narrow.png")
_set_window_geometry(_initial_window_size)
await process_frame
var original: Color = surface.tint
_stage.call("_set_scene_dirty", false)
_stage.call("_on_selected_surface_look_choice", &"finish_background_carpet")
_expect(surface.finish_id == &"finish_background_carpet" and surface.tint.is_equal_approx(original), "switching to Carpet preserves the selected piece's stored color")
_expect(colors.visible, "Carpet exposes the shared Color control")
_stage.call("_set_scene_dirty", false)
var dialog: StageSurfaceColorDialog = _stage.get("_surface_color_dialog") as StageSurfaceColorDialog
colors.custom_color_requested.emit()
var tentative := Color("#3F72B5")
var picker: ColorPicker = dialog.find_child("ColorPickerControl", true, false) as ColorPicker
if picker != null:
picker.color = tentative
if not surface.get_render_tint().is_equal_approx(tentative):
dialog.call("_on_picker_color_changed", tentative)
else:
dialog.preview_color_changed.emit(tentative)
_expect(not surface.tint.is_equal_approx(tentative) and surface.get_render_tint().is_equal_approx(tentative), "custom-color picker changes only the temporary render tint")
var dialog_preview: StageSurfacePreview = dialog.get("_surface_preview") as StageSurfacePreview
_expect(dialog_preview != null and dialog_preview.finish_id == surface.finish_id and dialog_preview.tint.is_equal_approx(tentative), "custom-color dialog preview follows the selected look and tentative color")
await process_frame
await create_timer(0.2).timeout
_capture_gui_screen("phase19_color_preview.png")
_expect(not bool((_stage.get("_document") as StageDocumentController).dirty), "tentative color does not dirty the Stage document")
var saved: Dictionary = _stage.call("_collect_scene_data", true)
var saved_surface: Dictionary = _find_object(saved, "background_ground")
_expect(String(saved_surface.get("color", "")).to_upper() == "#" + original.to_html(true).to_upper(), "autosave snapshot cannot capture a tentative color")
_stage.call("_on_surface_color_dialog_cancelled")
_expect(surface.get_render_tint().is_equal_approx(original), "Cancel restores the selected piece's prior color")
_expect(not bool((_stage.get("_document") as StageDocumentController).dirty), "Cancel leaves the Stage clean")
colors.custom_color_requested.emit()
if picker != null:
picker.color = tentative
dialog.call("_confirm_color")
await process_frame
_expect(surface.tint.is_equal_approx(tentative), "Use color commits the chosen tint to the selected piece")
_expect(bool((_stage.get("_document") as StageDocumentController).dirty), "confirmed color marks the Stage document dirty")
var recent: Array[Color] = _stage.get("_recent_surface_colors")
_expect(not recent.is_empty() and recent[0].is_equal_approx(tentative), "confirmed color enters the shared recent-color history")
for index: int in range(10):
_stage.call("_record_recent_surface_color", Color.from_hsv(float(index) / 10.0, 0.78, 0.92))
recent = _stage.get("_recent_surface_colors")
var recent_keys: Array[String] = _stage.call("_surface_color_history_strings")
_expect(recent.size() == 8 and recent_keys.size() == 8 and recent_keys[0] == _stage.call("_canonical_surface_color", Color.from_hsv(0.9, 0.78, 0.92)), "shared recent-color history deduplicates and keeps the newest eight colors")
_stage.call("_on_selected_surface_look_choice", &"finish_background_grass")
_stage.call("_on_selected_surface_look_choice", &"finish_background_carpet")
_expect(surface.tint.is_equal_approx(tentative), "Carpet keeps its color when switching to Grass and back")
func _test_surface_erase_confirmation(world: Node2D, background: StageBackgroundGround) -> void:
if background == null:
return
var wall: StageWallPanel = STAGE_WALL_PANEL.new() as StageWallPanel
wall.configure(Vector2(120.0, 120.0), &"finish_wall_wood", Color("#C87137"))
wall.position = Vector2(900.0, 400.0)
world.add_child(wall)
var selection: StageSelection = _stage.get("_selection") as StageSelection
selection.select_only(background)
_stage.call("_set_scene_dirty", false)
_stage.call("_begin_selected_wall_erase")
_expect(not (_stage.get("_scenery_controller") as StageSceneryController).is_active(), "wall-area eraser cannot start from a background ground selection")
_stage.call("_request_erase_selected_scenery")
var dialog: ConfirmationDialog = _stage.get("_scenery_erase_piece_dialog") as ConfirmationDialog
_expect(dialog.visible and dialog.dialog_text.contains("Erase this piece?"), "Erase background ground asks for confirmation")
dialog.canceled.emit()
dialog.hide()
_expect(is_instance_valid(background) and background.get_parent() == world, "Keep it leaves background ground in place")
_expect(not bool((_stage.get("_document") as StageDocumentController).dirty), "Keep it leaves the Stage clean")
selection.select_only(background)
_stage.call("_request_erase_selected_scenery")
dialog.confirmed.emit()
dialog.hide()
await process_frame
_expect(not is_instance_valid(background), "confirmed Erase removes only the captured background ground")
_expect(bool((_stage.get("_document") as StageDocumentController).dirty), "confirmed Erase dirties the Stage once")
_stage.call("_set_scene_dirty", false)
# Use a fresh background node so the Delete-key group route can verify its count.
var second_ground: StageBackgroundGround = STAGE_BACKGROUND_GROUND.new() as StageBackgroundGround
second_ground.configure(Vector2(80.0, 80.0), &"finish_background_concrete", Color("#A6AAA3"))
world.add_child(second_ground)
selection.clear_selection()
selection.select_only(wall)
selection.add_to_selection(second_ground)
var delete_event := InputEventKey.new()
delete_event.keycode = KEY_DELETE
delete_event.pressed = true
_stage.call("_unhandled_key_input", delete_event)
_expect(dialog.visible and dialog.dialog_text.contains("2 scenery pieces"), "Delete uses a counted confirmation for a scenery group")
dialog.canceled.emit()
dialog.hide()
_expect(is_instance_valid(wall) and is_instance_valid(second_ground), "Cancel leaves every group member intact")
_stage.call("_request_erase_selected_scenery")
dialog.confirmed.emit()
dialog.hide()
await process_frame
_expect(not is_instance_valid(wall) and not is_instance_valid(second_ground), "confirmed group erase removes only its captured scenery members")
func _test_selector_setup_routes(controller: StageSceneryController) -> void:
var browser: StageAssetBrowser = _stage.get("_asset_browser") as StageAssetBrowser
var selector: AssetSelector = browser.get_selector()
_expect(is_instance_valid(selector), "Scenery chooser instance is live")
if not is_instance_valid(selector):
return
var gui_render: bool = DisplayServer.get_name() != "headless"
if gui_render:
_stage.call("_open_selector", "scenery")
await process_frame
await process_frame
await process_frame
await create_timer(0.2).timeout
_capture_gui_screen("phase19_chooser.png")
else:
# Avoid PopupPanel.popup_centered() under the macOS dummy renderer while
# still exercising the production selector signal and StageAssetBrowser routing.
browser.set_selector_kind("scenery")
selector.kind = "scenery"
var entries: Array[Dictionary] = browser.scan_entries("scenery")
entries.append_array(browser.call("_scenery_background_entries") as Array[Dictionary])
selector.set_entries(entries)
var wall_card: AssetCard = _find_card(selector.find_child("GridContainer", true, false) as GridContainer, "wall_panel_setup")
_expect(wall_card != null, "Scenery gallery offers a Wall panel setup card")
if wall_card != null:
selector.call("_on_cell_pressed", wall_card.payload())
if gui_render:
await process_frame
await process_frame
await create_timer(0.2).timeout
_capture_gui_screen("phase19_wall_setup.png")
var setup: Control = selector.find_child("SurfaceSetupView", true, false) as Control
var draw: Button = selector.find_child("DrawSurfaceButton", true, false) as Button
var chooser_colors: StageSurfaceColorControls = selector.find_child("SurfaceColorControls", true, false) as StageSurfaceColorControls
_expect(setup != null and setup.visible and draw != null, "Wall panel setup shows a reachable preview and Draw action")
var wall_looks: StagePanelChoiceGroup = selector.get_meta("wall_look_group") as StagePanelChoiceGroup
if wall_looks != null:
wall_looks.choice_selected.emit(&"finish_wall_wood")
var selected_color := Color("#734E31")
if chooser_colors != null:
chooser_colors.color_selected.emit(selected_color)
if draw != null:
draw.pressed.emit()
_expect(controller.get_active_tool() == &"wall", "Draw starts wall placement from the chooser")
var selected_wall_tint: Color = controller.get("_wall_tint")
_expect(String(controller.get_active_tool()) == "wall" and String(controller.get("_wall_finish")) == "finish_wall_wood" and selected_wall_tint.is_equal_approx(selected_color), "wall placement receives the chosen material and color")
if gui_render:
Input.warp_mouse(Vector2i(640, 260))
var wall_motion := InputEventMouseMotion.new()
wall_motion.position = Vector2(640.0, 260.0)
wall_motion.global_position = wall_motion.position
_stage.get_viewport().push_input(wall_motion)
await process_frame
await process_frame
await create_timer(0.2).timeout
var wall_ghost: StageRectSurface = controller.get("_ghost") as StageRectSurface
_expect(wall_ghost != null and wall_ghost.panel_size.is_equal_approx(Vector2(240.0, 280.0)), "wall placement displays its patterned cursor ghost after production mouse motion")
_capture_root_viewport("phase19_wall_placement.png")
controller.exit_tool()
selector.hide()
if gui_render:
_stage.call("_open_selector", "scenery")
await process_frame
await create_timer(0.2).timeout
else:
browser.set_selector_kind("scenery")
selector.kind = "scenery"
var ground_card: AssetCard = _find_card(selector.find_child("GridContainer", true, false) as GridContainer, "background_ground_setup")
_expect(ground_card != null, "Scenery gallery offers a Background ground setup card")
var ground_entry_description: String = String(ground_card.payload().get("description", "")) if ground_card != null else ""
_expect(ground_entry_description.contains("Decorative") and ground_entry_description.contains("No walking"), "Background ground card explains that it is decorative and non-walkable")
if ground_card != null:
selector.call("_on_cell_pressed", ground_card.payload())
if gui_render:
await process_frame
await create_timer(0.2).timeout
_capture_gui_screen("phase19_ground_setup.png")
var ground_looks: StagePanelChoiceGroup = selector.get_meta("ground_look_group") as StagePanelChoiceGroup
if ground_looks != null:
ground_looks.choice_selected.emit(&"finish_background_carpet")
var carpet_color := Color("#9B5C83")
if chooser_colors != null:
chooser_colors.color_selected.emit(carpet_color)
var ground_draw: Button = selector.find_child("DrawSurfaceButton", true, false) as Button
if ground_draw != null:
ground_draw.pressed.emit()
_expect(controller.get_active_tool() == &"background_ground", "Draw starts background-ground placement from the chooser")
var chosen_ground_tint: Color = controller.get("_wall_tint")
_expect(String(controller.get("_wall_finish")) == "finish_background_carpet" and chosen_ground_tint.is_equal_approx(carpet_color), "background-ground placement receives the chosen material and color")
if gui_render:
Input.warp_mouse(Vector2i(500, 260))
var ground_motion := InputEventMouseMotion.new()
ground_motion.position = Vector2(500.0, 260.0)
ground_motion.global_position = ground_motion.position
_stage.get_viewport().push_input(ground_motion)
await process_frame
await process_frame
await create_timer(0.2).timeout
var ground_ghost: StageRectSurface = controller.get("_ghost") as StageRectSurface
_expect(ground_ghost != null and ground_ghost.panel_size.is_equal_approx(Vector2(800.0, 320.0)), "background-ground placement displays its patterned cursor ghost after production mouse motion")
_capture_root_viewport("phase19_ground_placement.png")
controller.exit_tool()
selector.hide()
func _capture_gui_screen(file_name: String) -> void:
if DisplayServer.get_name() == "headless":
return
var image: Image = DisplayServer.screen_get_image()
if image == null or image.is_empty():
print("SKIP: could not capture GUI screen for %s" % file_name)
return
var path: String = "/private/tmp/" + file_name
var error: Error = image.save_png(path)
print("CAPTURE: %s size=%s result=%s" % [path, str(image.get_size()), str(error)])
func _capture_root_viewport(file_name: String) -> void:
if DisplayServer.get_name() == "headless":
return
var texture: Texture2D = root.get_texture()
if texture == null:
print("SKIP: root viewport has no capture texture for %s" % file_name)
return
var image: Image = texture.get_image()
if image == null or image.is_empty():
print("SKIP: root viewport image is empty for %s" % file_name)
return
var path: String = "/private/tmp/" + file_name
var error: Error = image.save_png(path)
print("CAPTURE: %s size=%s result=%s" % [path, str(image.get_size()), str(error)])
func _set_window_geometry(requested_size: Vector2i) -> void:
var next_size: Vector2i = requested_size
if DisplayServer.get_name() != "headless":
var usable_rect: Rect2i = DisplayServer.screen_get_usable_rect(DisplayServer.window_get_current_screen())
if usable_rect.size.x > 0 and usable_rect.size.y > 0:
next_size = Vector2i(
mini(requested_size.x, maxi(1, usable_rect.size.x - 32)),
mini(requested_size.y, maxi(1, usable_rect.size.y - 32))
)
root.size = next_size
root.position = usable_rect.position + (usable_rect.size - next_size) / 2
return
root.size = next_size
func _click_control(control: Control) -> void:
if control == null or not control.is_visible_in_tree():
return
var viewport: Viewport = control.get_viewport()
var point: Vector2 = control.get_global_rect().get_center()
var press := InputEventMouseButton.new()
press.button_index = MOUSE_BUTTON_LEFT
press.position = point
press.global_position = point
press.pressed = true
viewport.push_input(press)
await process_frame
var release := InputEventMouseButton.new()
release.button_index = MOUSE_BUTTON_LEFT
release.position = point
release.global_position = point
release.pressed = false
viewport.push_input(release)
await process_frame
func _find_card(grid: GridContainer, id: String) -> AssetCard:
if grid == null:
return null
for child: Node in grid.get_children():
var card: AssetCard = child as AssetCard
if card != null and String(card.payload().get("placement_id", "")) == id:
return card
return null
func _find_object(data: Dictionary, object_type: String) -> Dictionary:
for entry_value: Variant in data.get("objects", []):
if entry_value is Dictionary and String((entry_value as Dictionary).get("type", "")) == object_type:
return entry_value as Dictionary
return {}
func _expect(condition: bool, message: String) -> void:
if condition:
print("PASS: " + message)
else:
_failures.append(message)
push_error("FAIL: " + message)
@@ -0,0 +1 @@
uid://wlpdbamdghx4