Files
stickman/docs/specs/phase_19_scenery_backgrounds_surface_finishes.md
T
ryan 0b1dda2e2f Add Phase 19 Starter Scene Composition UI/UX Specification and Tests
- Introduced a new UI/UX specification document for the Phase 19 Starter Scene Composition, detailing user paths, composition rules for Town Street and Living Room, camera framing, and observable acceptance outcomes.
- Implemented a visual capture test for different sky states in the sandbox stage, ensuring proper rendering and visual consistency.
- Created a comprehensive test suite for the Phase 19 starter scene composition, validating scene loading, geometry, navigation, and interaction behaviors for both Town Street and Living Room.
- Added checks for visual alignment, object placements, and scene transitions to ensure adherence to the new UI/UX specifications.
2026-10-21 17:46:58 -04:00

66 KiB
Raw Blame History

Phase 19 — Scenery, backgrounds, and surface finishes: architecture and implementation specification

Status: Original Phase 19 scenery architecture and the approved Stage-relative sizing amendment are integrated; automated and render-capable checks pass. The camera-aware Sky architecture addendum below is specified for user sign-off and is not yet implemented. This specification follows the accepted product brief, the approved UI/UX specification, and the Stage panel UI system. Full Phase 19 user/editor verification remains pending. The camera-aware Sky addendum supersedes the original static-detail statements in this document.

Decisions and scope

  1. Use independent, inert Node2D placements for wall panels and .prop Scenery artwork. The source .prop is a live reference: its current vector art is used on reopen. Missing art remains a saved, visible, repairable placement. A given .prop may be used as inert Scenery and, when it has valid collision geometry, as a functional Prop.
  2. Sky is one adaptive, viewport-filling, screen-space vector drawing behind the world. Its gradient stays fixed while its details respond gently to camera pan and zoom under the camera-aware Sky addendum; it does not tint other objects. Existing scenes and Blank Scene use an internal neutral Sky. The four child-facing Sky presets are Clear Day, Cloudy Day, Sunset, and Starry Night.
  3. The existing Prop Editor opens inside the live Stage session for Make artwork. The Stage and its unsaved document remain in memory, with Stage input suspended until the editor returns. The standalone F6 route remains valid.
  4. Select several creates a temporary Scenery selection and snapshot-based transform preview. It does not create a persistent group or a parent that changes the saved hierarchy. Sky, Ground, characters, and functional Props are outside this transform.
  5. Phase 19 adds neither Stage Undo/Redo nor a whole-set transform. Group preview has its own exact Cancel/Keep boundary. Stage-wide Undo/Redo is a separately planned phase and is logged in technical debt.

Existing constraints and ownership

scenes/stage/sandbox_stage.tscn contains a SandboxStage root, Camera2D, and World. The scene adapter serializes direct World children; StageSceneIO currently writes v1.4. StageSelection currently considers every direct world child selectable by world AABB. StageSpatialIndex indexes AABBs into 16 px cells and StageTerrainPainter treats any indexed overlap as an occupancy conflict. PropBlock currently draws at z_index -1, 0 (legacy), or 1, while terrain is 0. These are explicit integration points, not implicit behavior to rely on.

flowchart TD
    Chrome[StageChrome / shared gallery] --> Intent[SandboxStage input arbitration]
    Intent --> Decor[StageSceneryController]
    Intent --> Terrain[StageTerrainPainter]
    Decor --> World[World: WallPanel + SceneryArtwork]
    Terrain --> World
    World --> Adapter[StageSceneAdapter]
    Sky[StageSkyView] --> Adapter
    Adapter --> IO[StageSceneIO v1.5]
    Art[PropFile + PropLibrary visual scan] --> Decor
    Art --> Editor[Stage-hosted PropEditor]
Owner / location Typed contract or change Boundary
scripts/stage/stage_draw_order.gd, class_name StageDrawOrder extends RefCounted Constants WALL := -40, FAR := -30, NEAR := -20, TERRAIN := -10, FLOOR := -5; static func scenery_z(depth: StringName) -> int; static func apply_terrain(node: TerrainBlock) -> void One drawing policy. Leave the existing Prop -1/0/1 and character relative order intact; rebase terrain drawing only. Never serialize raw z_index in place of a semantic depth/finish.
scripts/stage/scenery/stage_sky_view.gd, class_name StageSkyView extends Control func set_sky(id: StringName) -> void; func get_sky() -> StringName; func apply_theme(theme: SandboxTheme) -> void; camera binding specified in the Sky addendum Full-rect child of a dedicated CanvasLayer with layer = -1, mouse ignored. Deterministic vector drawing on preset, viewport, or effective camera-framing changes. No world hit test or physics.
scripts/stage/scenery/stage_wall_panel.gd, class_name StageWallPanel extends Node2D var finish_id: StringName; var tint: Color; var panel_size: Vector2; optional var motif_origin_offset: Vector2; func configure(size: Vector2, finish: StringName, color: Color) -> void; func get_local_bounds() -> Rect2 Finite world rectangle centered at position. No CollisionObject2D, navigation, support, or interaction registration. Resize changes panel_size, never root scale, so motifs retain their world pitch. The renderer lays the pattern out from each panel's current world transform, so eraser-created fragments keep continuous brick courses; the optional offset preserves source-wall lineage in saved data.
scripts/stage/scenery/stage_scenery_artwork.gd, class_name StageSceneryArtwork extends Node2D var asset_path: String; var display_name: String; var depth: StringName; var last_known_local_bounds: Rect2; var missing: bool; func load_visual(path: String) -> bool; func set_depth(value: StringName) -> void; func get_visual_world_bounds() -> Rect2; func get_saved_footprint() -> Rect2 Build a Visuals child via PropVisuals.build() from the complete shape stack. No PropBlock, collision hull, physics, Use spots, anchors, or event identity. If loading fails, render a neutral marker/known footprint from saved data and preserve path/name/transform.
scripts/stage/scenery/stage_surface_renderer.gd, class_name StageSurfaceRenderer extends RefCounted static func is_wall_finish(id: StringName) -> bool; static func is_ground_finish(id: StringName) -> bool; static func draw_wall(canvas: CanvasItem, rect: Rect2, finish: StringName, tint: Color, world_origin: Vector2) -> void; static func draw_ground(canvas: CanvasItem, rect: Rect2, finish: StringName, tint: Color, world_origin: Vector2) -> void Shared vector definitions for Paint/Brick/Wood, with Grass/Dirt/Sidewalk/Asphalt for Ground. One motif vocabulary and world-coordinate phase; no raster scale blur. Drawing helpers clip motifs to finite panel/block bounds and bound work to visible tiles.
scripts/terrain/terrain_block.gd, class_name TerrainBlock extends StaticBody2D Add var finish_id: StringName and func set_finish(id: StringName) -> void for spawn_id == "ground"; preserve polygon_points, fill, outline, and CollisionPolygon2D Ground appearance changes without altering collision geometry, navigation, or terrain occupancy. Ramp/Step retain the old look.
scripts/editors/stage/stage_scenery_controller.gd, class_name StageSceneryController extends Node signal placement_committed(node: Node2D); signal document_change_requested; signal preview_state_changed(active: bool); func begin_artwork(path: String, depth: StringName) -> bool; func begin_wall(finish: StringName, initial_world_position: Vector2, tint: Color) -> void; func begin_wall_eraser(initial_world_position: Vector2) -> bool; func cancel_active_gesture() -> void; func exit_tool() -> void; func begin_group_transform(kind: StringName, members: Array[Node2D]) -> bool; func preview_group(delta: Vector2, factor: float) -> bool; func commit_group() -> void; func cancel_group() -> void Owns Scenery-only tool/substate and snapshots. A wall-eraser release subtracts its snapped rectangle from every intersected wall panel and emits one document change when anything changed. SandboxStage retains top-level mode, Save, dirty, and input priority. Typed intent signals do not pass UI controls into persistence.
scripts/editors/stage/sandbox_stage.gd, class_name SandboxStage extends Node2D Route Scenery tools before ordinary selection/placement; gate save/PLAY while a group preview needs resolution; apply controller commits through existing _mark_scene_dirty() and _save_object_state() as appropriate Do not append all Phase 19 interaction state to the root controller. Cancel pending drafts on tool/mode changes before existing direct/play transitions.
scripts/stage/stage_selection.gd, class_name StageSelection extends RefCounted Add a typed selection context, for example enum SelectionScope { ACTION, SCENERY } plus func set_scope(scope: SelectionScope) -> void; make hit/hover/box use the same predicate Ordinary EDIT excludes wall/artwork, including missing broad bounds. Edit Scenery includes only those; smaller cutouts outrank panels, with the overlap chooser for remaining ambiguity. Sky is never selectable.
scripts/stage/stage_scene_io.gd, class_name StageSceneIO extends RefCounted; scripts/editors/stage/stage_scene_adapter.gd, class_name StageSceneAdapter extends RefCounted v1.5 migration/validation, sky and new object encode/decode, known footprint, Ground finish Validate document before _reset_scene_content(). Invalid new fields cannot silently erase the current scene. Preserve missing Scenery entries across save cycles.

StageSpawner remains the factory for physical terrain, Props, stickmen, and Areas. A small Scenery factory may live in StageSceneryController or scripts/stage/scenery/ to create inert nodes; it must not route visual artwork through spawn_prop_asset(). StageRunSession and the event/rule engine must treat Scenery nodes as nonparticipants even in PLAY and paused EDIT. Keep Scenery outside the physical spatial index and terrain-paint candidate set, but include it in World serialization and Scenery-only selection.

Scene v1.5 contract

All scene objects remain independent entries in the existing objects array. file_id is unique within a file, allocated by the adapter; it is not a durable group ID. Scenery object transforms use world coordinates. The Sky ID is a root property. The examples omit unrelated current fields.

{
  "version": "1.5",
  "scene_name": "Living Room",
  "sky_id": "sky_clear_day",
  "camera": {"position": {"x": 0, "y": -400}, "zoom": 0.5},
  "objects": [
    {"file_id": "wall_panel_1", "type": "wall_panel", "position": {"x": -280, "y": -420}, "size": {"x": 320, "y": 400}, "finish_id": "finish_wall_paint", "color": "#5A9D70"},
    {"file_id": "scenery_artwork_1", "type": "scenery_artwork", "asset_id": "res://content/props/window_frame.prop", "display_name": "Window frame", "depth": "near", "position": {"x": 0, "y": -450}, "rotation": 0, "scale": {"x": 1, "y": 1}, "last_known_local_bounds": {"position": {"x": -80, "y": -70}, "size": {"x": 160, "y": 140}}},
    {"file_id": "terrain_1", "type": "terrain", "asset_id": "ground", "position": {"x": 0, "y": 0}, "rotation": 0, "scale": {"x": 1, "y": 1}, "finish_id": "finish_ground_wood"}
  ],
  "event_rules": [],
  "metadata": {}
}

The document may contain internal sky_neutral and finish_ground_legacy IDs to reproduce the prior blank/background and green Ground. They are valid for loading/migration but are not extra child-facing picker cards. For wall panels, position is the rectangle center, size has strictly positive finite x/y, and rotation is fixed at zero. Group resize writes the new size and center while leaving wall root scale at (1, 1). For artwork, scale is finite, positive, and uniform for Phase 19 editing; rotation is finite and preserved for existing/scripted entries and repair previews, though the Phase 19 UI offers no rotation control. Depth IDs are exactly far, near, floor; Near is the placement default.

The manual Erase wall area tool adds one optional wall field, motif_origin_offset: {x, y}, recording the vector from a fragment's saved position to its source wall's origin; absent means (0, 0), so existing v1.5 scenes remain valid and no version bump or migration is needed. When a rectangle intersects a wall panel, transform its four corners into that panel's local space, intersect with the finite local bounds, and replace the original with up to four non-overlapping rectangular fragments outside the intersection. Copy the finish, tint, placement ordering, and source-wall lineage to every fragment. Render each fragment's surface pattern using its current world origin, not the inherited source origin, so brick courses remain on the same world-space lattice across the new fragment boundaries. Remove the source panel when the intersection covers it fully; do not touch artwork or non-wall objects. This is visual editing only: no glass, collision, doorway, Ground, or physics behavior is inferred. Each valid release commits immediately through the existing document-dirty signal; Done/Cancel/RMB/Esc exits the tool, and canceling a held drag discards only that uncommitted drag. There is no Stage Undo in Phase 19.

last_known_local_bounds is cached geometry metadata, updated when a live asset loads and retained unchanged if it later goes missing. Include stroke width when deriving it, so horizontal/vertical line-only artwork still has a positive visible footprint without inventing collision geometry. It supports a repair footprint and safe group movement; it is not embedded artwork. Missing or malformed optional bounds fall back to a compact world marker and disable group resize until repaired. display_name is a short saved fallback label. A repaired placement changes only its selected asset_id, name, and known bounds after explicit confirmation; its position, scale, rotation, depth, and unrelated placements remain. The default repair is one placement. A future or optional explicit “Use for all matching places” path must show a count and ask separately before changing other references.

StageSceneIO._migrate_to_current() accepts v1.0 through v1.4 and adds sky_neutral plus finish_ground_legacy to old Ground entries, without changing old Prop role and draw-order migrations. v1.5 validation checks top-level Sky ID, object type payloads, finite vectors and numbers, positive wall sizes and artwork scales, allowed finish/depth IDs, type-safe color strings, unique file_id, and canonicalized asset paths. Malformed optional cached art bounds produce a warning and the no-footprint repair marker, rather than rejecting otherwise valid scene content. The adapter creates a missing Scenery node when the source cannot load; missing source is a warning and repair state, not document rejection. Unknown object types follow the existing warning/skip convention only after structural validation. The existing safe write and autosave paths write the same v1.5 snapshot. Manual Save asks to Keep or Cancel a pending group preview; background autosave waits until the preview is resolved. On mode switch, cancel the preview before the current PLAY-entry autosave. Draft values are never serialized.

Visual layers, Sky, and finishes

The semantic draw order is Sky → wall panels → Far art → Near art → terrain → floor art → characters and Props. StageDrawOrder applies bands to direct World children. Set all existing terrain varieties to the terrain band for consistent physical-surface drawing; their collision and input remain unchanged. Prop -1/0/1 choices and World child order among legacy depth-zero Props and stickmen are preserved. Reapply the policy on spawn, load, depth change, and reset. Selection must use the same semantic tier order plus smaller actual art footprint preference, rather than assuming child insertion order expresses depth.

StageSkyView lives in its own CanvasLayer behind World, not under the camera or a wall. Its draw rectangle follows viewport size, so panning/zooming cannot reveal an edge. Use deterministic vector gradient bands, stars, clouds, sun/moon and shapes with bounded point counts. The preset controls only this view. The gradient stays screen-fixed; the camera-aware Sky addendum defines bounded, camera-responsive details and redraw invalidation. There is no per-frame random placement, autonomous animation, or tint propagation. Sky thumbnails use the same drawing definitions at card size in their unbound default composition.

StageSurfaceRenderer defines finite vector motifs with a world-aligned origin and fixed world-unit pitch. Wall and Ground may share Brick/Wood definitions but use context-aware fill/outline colors and scale. StageWallPanel._draw() uses its current finite rectangle as a clip and asks the renderer for only intersecting motif cells. For brick walls, cull to the camera-visible local rectangle; if that visible area exceeds 12,000 cells, increase the brick pitch uniformly for all visible courses instead of truncating rows or columns. This keeps large walls fully patterned and fragments continuous without unbounded draw work. Ground renders within its existing rectangle geometry; existing CollisionPolygon2D and navigation surfaces are never rebuilt merely for a finish change. The selected New Ground look is passed into both StageTerrainPainter ghost creation and each committed Ground block; it never repaints earlier blocks. During group resize, wall motif lines retain their pitch and phase. Ground blocks adjacent on a shared edge use the same world phase; shared same-finish interior borders are suppressed or visually joined so a painted run reads as one surface. The neighbor check refreshes on place, delete, move, load, and per-piece finish change, and considers only nearby Ground rectangles; it does not change occupancy/collision. Other motif types remain bounded by the camera-visible region or a maximum cell count per finite panel so _draw() work stays limited.

Selection, input, and transform state

stateDiagram-v2
    [*] --> OrdinaryEdit
    OrdinaryEdit --> Chooser: Scenery palette
    Chooser --> PlaceArt: artwork chosen
    Chooser --> DrawWall: wall look chosen
    Chooser --> EditScenery: Edit placed scenery
    PlaceArt --> OrdinaryEdit: Done / Cancel / RMB / Esc
    DrawWall --> OrdinaryEdit: Done / Cancel / RMB / Esc
    EditScenery --> GroupPreview: Move together / Resize together
    GroupPreview --> EditScenery: Keep changes / Cancel / RMB / Esc
    EditScenery --> OrdinaryEdit: Done editing / Esc

The controller tracks one active Scenery tool at a time, the number of committed copies this session, and whether a wall/touch draft is active. A passive artwork ghost is never a committed edit. Right-click/Done/Cancel follow the approved exit matrix: committed pieces stay; an active draft is discarded; a late release cannot commit it. Existing terrain and Prop placement are canceled when a Scenery tool takes input. DIRECT/PLAY clears Scenery ghosts and Edit Scenery selection.

StageSelection filters both click and box paths. Ordinary EDIT includes existing actionable objects and excludes Scenery broad bounds. In Edit Scenery, candidate geometry comes from a wall rectangle or the full transformed vector art: filled polygons use point-in-polygon, open strokes use a screen-scaled segment tolerance, and missing art uses its marker or known footprint. A small cutout wins over a broad wall even if both are candidates; unresolved overlaps use the shared “Pieces here” popup. The small explicit missing-art Find artwork badge is a separate 44 px screen-space hit target that can open Edit Scenery without making the entire missing footprint selectable in ordinary EDIT. Sky never enters either selection scope.

For one selected artwork, a uniform scale changes root scale within finite positive bounds. For a wall, resizing changes panel_size, with a minimum visible world size and no mirrored/negative rectangle. Pointer/touch handle hit areas are at least 44 logical pixels after camera zoom compensation. StageGizmos can share outline/handle drawing helpers; a Scenery-specific adapter is justified for rectangle sizing, no rotation control, and the pending group preview. It must still use SandboxTheme.overlays selection colors and the Stage halo style, rather than duplicating visual tokens.

Group preview snapshots global_position, rotation, artwork scale or wall panel_size, and the initial world bounds of each selected member. For move, derive one delta from the selected union anchor, apply Snap once to that anchor, then add the same delta to every snapshot position. For resize, compute one positive factor around the opposite union corner P; for each artwork center x, set x' = P + factor * (x - P) and scale' = factor * scale; for each wall center/size, use the same center formula and size' = factor * size. Derive every preview frame from the snapshots, never the previous preview frame. Reject factor <= 0, any collapsed member, or any out-of-range art scale. Buttons use the same formula with top-left fixed. Missing members with no known footprint may move but block group resize with the approved explanation. Mouse release leaves the candidate preview pending. Keep changes saves each authored transform, marks dirty once, and returns to Edit Scenery. Cancel, RMB, or Esc restores all snapshots exactly and leaves the same selection. A Save request opens a Keep/Cancel decision for the preview and resumes Save only after explicit resolution; tool/mode changes cancel the preview. A temporary group selection and preview are never written to .scene.

Phase 19 addition: authoring size and single-placement transforms

This addition implements the approved Stage-relative sizing flow inside Phase 19. The standard standing guide spans 939.5 world units from head-circle top to ankle line; write H := 939.5 as one Prop Editor reference constant derived from WholeStickmanPreview.GUIDE_JOINTS and GUIDE_HEAD_RADIUS, so the UI never assumes the old 96-unit Normalize Size target. The presets are starting points based on the artwork's longest visible dimension, including stroke: Small = 0.35 H, Room = 1.0 H, Big = 2.0 H. This covers both tall and wide drawings without forcing a wide rug or skyline to be one stickman tall. Each Make bigger step multiplies by 1.1; Make smaller multiplies by 1 / 1.1, so opposite steps return to the same size within floating-point tolerance. The labels are approximate comparison choices, not an inferred real-world category or a maximum size. Artist size inference is part of authoring/review instructions; no runtime name parsing, asset size tag, or automatic normalization is added.

Prop Editor authoring transaction

PropEditor (class_name PropEditor extends Control) replaces the Arrange toolbar's immediate _on_normalize_size_pressed() action and NORMALIZED_MAX_EXTENT with Size for Stage. The action is disabled when the document has no drawable geometry or its longest visible extent is not finite and positive; its tooltip says “Add visible artwork to use Size for Stage.” It owns the toolbar controls, Keep/Cancel routing, and the existing _dirty/autosave boundary. PropCanvas (class_name PropCanvas extends Control) owns a temporary deep copy of its complete shape stack and the supplied editable-anchor dictionary; its normal shapes and the editor's _anchors remain the committed document while previewing. A small typed API is sufficient:

func begin_stage_size_preview(anchors: Dictionary) -> bool
func preview_stage_size(factor: float) -> bool
func commit_stage_size_preview() -> Dictionary # {"changed": bool, "anchors": Dictionary}
func cancel_stage_size_preview() -> void
func is_stage_size_preview_active() -> bool
func get_visible_shapes_bounds() -> Rect2 # Includes outline/stroke width.
func fit_stage_size_view(bounds: Rect2, padding_px: float) -> void

The preview records original internal shape dictionaries, anchors, canvas pan/zoom, and the entry bounds once. Preset factor is target_longest_edge / entry_longest_edge; fine buttons adjust the current absolute factor, but every rendered candidate is derived from the entry snapshot, never scaled from the previous candidate. The shared origin anchor remains at (0, 0). Scale all polygon/line points, circle points and radius, each shape's outline_width (including line-only art), and every editable anchor position by the same factor about the document origin. Preserve colors, shape order, vertex flags, reshaped, anchor roles, and anchor angles. This is the established whole-document pivot, so Size for Stage does not silently recenter the saved origin or detach grips from their art. Reject a candidate atomically if factor, coordinate, radius, stroke width, anchor position, or visible extent is nonfinite or outside the existing representable coordinate/size limits; leave the last valid preview visible and the committed document untouched. Do not clamp one shape or anchor independently. Zero-width strokes remain zero. A no-op factor of 1 leaves the document clean when it was clean on entry.

Draw the size candidate in PropCanvas._on_canvas_draw() and then draw the guide after all artwork, with dark halo and light core for the bones, head, and floor line. Derive the comparison floor from the candidate's current visual bottom; align both guide ankles to that y and place the figure just to the artwork's left (or over its left portion when width is tight), so offset and centered authoring coordinates compare correctly without translating the artwork. With an empty canvas, draw the guide at its normal origin and ankle line; Size for Stage remains disabled until artwork exists. The floor cue can follow a changed candidate bottom. On entry, compute a union of candidate artwork plus the guide/floor and frame it with a bounded margin. Refit that union after a preset choice and when a fine step would clip it; do not reframe on every draw or override a child's deliberate pan/zoom while the union remains visible. The sizing view may temporarily fit below the canvas's normal manual MIN_ZOOM on narrow viewports and restores the prior pan/zoom on exit. Outside the sizing view, show the same foreground guide and floor whenever Show Stickman Guide is enabled. During sizing, force guide drawing on without changing or persisting that checkbox; restore its user preference on exit. The guide is canvas drawing only, never a saved shape, anchor, or Stage object.

Keep size validates the full candidate, swaps the complete shapes/anchors once, emits one document change, refreshes inspector/thumbnail state, and starts the existing dirty/autosave timer once if the result differs from the snapshot. Cancel, Esc, mode switch, New/Load, return to Stage, or closing the editor discards the candidate before another document action; no preview geometry enters Save/autosave. A Save request while the sizing view is open asks the child in the existing sizing controls to Keep size or ✕ Cancel first, then the child can use ordinary Save/Save As; no new save dialog or asset-copy policy is introduced. Transform Whole Prop, Attach, Reshape, and canvas editing cannot run concurrently with this sizing transaction. The present whole-prop Transform action remains available in Arrange after sizing.

stateDiagram-v2
    Arrange --> SizePreview: Size for Stage / snapshot
    SizePreview --> SizePreview: Small, Room, Big, smaller, bigger / derive from snapshot
    SizePreview --> Arrange: Keep size / validate, commit once, dirty once
    SizePreview --> Arrange: Cancel or Esc / discard, no dirty
    SizePreview --> Arrange: leave editor or switch mode / discard, no dirty

Direct Stage move and resize of one Scenery piece

StageSceneryController (class_name StageSceneryController extends Node) takes ownership of the currently selected Scenery drag, replacing SandboxStage's local _scenery_drag_snapshots/_scenery_dragging move path. It reuses its existing group snapshot math and document_change_requested signal but keeps the single gesture's commit boundary separate: release commits immediately; group release still leaves a candidate requiring Keep changes. Suggested typed controller contract:

signal single_gesture_changed(active: bool)
func begin_single_transform(node: Node2D, kind: StringName, pointer: Vector2, dragged_corner: Vector2 = Vector2.INF) -> bool
func preview_single_transform(pointer: Vector2) -> bool
func commit_single_transform() -> bool
func cancel_single_transform() -> void
func is_single_transform_active() -> bool
func step_single_size(node: Node2D, factor: float) -> bool

kind is exactly &"move" or &"resize"; the controller accepts only one selected StageWallPanel or StageSceneryArtwork. Snapshot {node: Node2D, position: Vector2, rotation: float, scale: Vector2, panel_size: Vector2 when wall, bounds: Rect2, pointer: Vector2, dragged_corner: Vector2}. This is transient in-memory state, never a .scene payload. A missing artwork without known bounds can move but cannot resize. Before applying any candidate, validate the whole result: finite position and scale, positive artwork scale components within the current Stage range, positive wall dimensions above MIN_PANEL_SIZE, valid node still in Edit Scenery, and a nondegenerate starting AABB for resize. A move preserves any existing positive nonuniform scale. A resize multiplies both scale components by one uniform factor, preserving the ratio of an imported/scripted placement without normalizing it. Rejecting a frame retains the last valid preview. An unchanged release emits no document change.

For move, derive raw_delta = pointer - pointer0 from the snapshot. With Snap on, align the visible world AABB's top-left corner rather than the node's often offset origin: delta = snap(bounds0.position + raw_delta) - bounds0.position; apply position0 + delta. This preserves the existing top-left grid-alignment promise for placed wall panels and artwork. For uniform resize, take the world AABB corner opposite the dragged corner as the fixed point P. With D = dragged_corner - P, use factor = 1 + (pointer - pointer0).dot(D) / D.length_squared(); reject nonpositive/invalid factors. For artwork with saved local geometry, position' = P + factor * (position0 - P) and scale' = scale0 * factor, preserving rotation and depth. For walls, use the same position formula and panel_size' = panel_size0 * factor, leaving root scale at 1 so Brick/Wood motif pitch does not stretch. The fixed world corner stays fixed even when artwork's authored origin is offset. Smaller/Bigger in the selected-piece card call step_single_size() with reciprocal factors 1/1.1 and 1.1, top-left pinned; each valid press is an immediate independent commit for that placement only. Other artwork instances, walls, Ground, Props, characters, and their interaction state are untouched.

StageGizmos supplies selection outline and corner drawing/hit geometry, using existing SandboxTheme.overlays colors and dark-halo/light-core selection language. Extend its Scenery adapter with at least 44 × 44 logical-pixel hit regions converted through camera zoom, prioritizing a corner hit over the body; do not reuse its current PropBlock-only SCALE gesture or origin-pivot formula. The Stage root arbitrates input once: selected corner starts resize, selected body starts move, and group preview has priority while active. StageSceneryController previews directly on the selected node, then emits document_change_requested exactly once on changed release; SandboxStage updates its authored-state/save path and card. Esc, right-click, visible capture-bar ✕ Cancel, mode/tool switch, selection removal, or node deletion restores the snapshot and clears the gesture. An input-generation token or equivalent active-state guard must consume a mouse/touch release after cancellation so it cannot commit an old draft. Manual Save first cancels an active single gesture and saves only prior commits; background autosave waits for it to end. Group preview retains its existing separate Keep/Cancel save gate.

flowchart LR
    Pointer[Corner/body press] --> Hit[StageGizmos hit: corner first]
    Hit --> Begin[SceneryController snapshot]
    Begin --> Preview[Pointer motion from snapshot]
    Preview --> Release[Release: commit once]
    Preview --> Cancel[Esc/RMB/capture Cancel: restore]
    Release --> Dirty[Stage dirty + scene v1.5 placement transform]
    Cancel --> Idle[No dirty; stale release ignored]

The selected artwork card adopts StageInspectorPanel for fixed header/scrolling body, StagePanelChoiceGroup for Far/Near/On the floor, and StageInspectorPanel.style_action_button() for Smaller/Bigger and Erase. The selected wall card uses the same shell and action styling. Both call set_safe_rect() and preserve the compact Settings reopen behavior. StagePanelToggleRow is reserved for actual booleans; no new panel control is needed for size. The already shared StageUseTargetCaptureBar.set_capture_actions() shows ✕ Cancel only while a single drag is held; group Keep/Cancel remains unchanged. New sizing controls in Prop Editor use its existing Arrange toolbar and PropTheme, because that editor is a distinct workflow from an in-stage selected-object card. No duplicate dialog, capture-bar, choice, or button styling is introduced.

The existing .scene v1.5 position, scale, rotation, and wall size fields already persist these per-placement changes; .prop v1.1 already stores points, circle radius, outline width, and anchors. This addition requires no schema bump and performs no load-time resizing or migration of asset geometry or placement scale. Saving an explicitly revised .prop at its existing path intentionally updates all live-reference Scenery and Props that use that path when reloaded; ordinary Save and Save As remain the only asset save actions. A scene's saved placement scale continues to multiply its current referenced art. There is no new asset-version enforcement, copy/update prompt, automatic compensation factor, or runtime name-based size inference.

Asset catalog, tags, thumbnails, and missing-art repair

PropFile advances to v1.1 with optional top-level tags: Array[String]. It accepts v1.0 files with tags = [], normalizes a fixed set nature, buildings, walls, roads, objects, deduplicates, and writes only known IDs. Tags describe appearance, never physics. The same file remains the only vector authoring format; no .scenery file or new asset library root appears. Keep cutouts as immediate children of content/props/ for existing scans; unapproved art stays under ignored content/review/artist/<slug>/ and must not enter browser results. ContentPaths.canonicalize() handles saved paths, including old props/ paths, while preserving external paths.

Split library eligibility by role without duplicating parsing: a shared parse/cache step returns {id: String, path: String, name: String, shapes: Array[Dictionary], tags: Array[String], visual_bounds: Rect2, material_preset: int, collision_hull: PackedVector2Array}. Scenery accepts nonempty visible vector geometry, including open lines and transparent-window frames, via PropVisuals.build()/shape bounds. A closed filled shape needs three noncollinear points; an open stroke needs two distinct points, positive width, and visible color. These rules accept line art without accepting a blank .prop. Props retain the existing valid closed-hull requirement. The existing StageAssetBrowser scan, asynchronous thumbnail queue, ThumbnailCache, and PropThumbnail.render_shapes() are reused for either role; a line-only artwork thumbnail must not go through flatten_to_payload() as a fake collision shape. Scenery keeps authored per-shape colors rather than applying a Prop material tint. All artwork includes untagged assets; Unsorted appears when appropriate. The same tag filters are available in Props, but its entries remain physically eligible only. Do not scan review directories or recursively change the existing immediate-child library behavior.

On scene open, load every live artwork reference. A missing/invalid source leaves a StageSceneryArtwork node with missing = true, saved transform/depth, remembered name, and last known footprint. Render a neutral translucent footprint with dashed light/dark edge when bounds exist, otherwise a compact world marker, with a constant-screen-size picture/question icon and “Artwork not found” label. It stays subdued in DIRECT/PLAY and is never solid. Show one themed missing-assets summary for the load with the UI spec’s Show scenery / Later actions, while retaining separate Prop/stickman warning semantics. The Scenery piece list has a Needs artwork section.

Find artwork… uses the established .prop chooser; Choose another artwork uses the common Artwork gallery. The candidate must pass Scenery visual validation before a ghost appears at the saved position/size/orientation/depth. The explicit Use this artwork here? confirmation commits only the selected placement. Cancel/failure leaves the original path, marker, and scene dirty state untouched. Replacing art marks dirty once and refreshes the known bounds, name, and visual tree; the original position, scale, rotation, and depth remain. A changed source file is picked up on reopen for every live reference; there is no embedded snapshot. The optional File details disclosure may show the path for an adult. The simple one-placement repair is required; an all-matching bulk repair is optional and must be explicitly confirmed with its count.

Starter scenes and document lifecycle

Keep versioned starter documents under content/stages/starters/ with stable IDs for Blank, Town Street, and Living Room. They are ordinary .scene snapshots, not locked nested instances. Blank may be assembled from the current neutral empty-scene defaults; Town Street and Living Room data use the same wall, artwork, terrain, Prop, Sky, and camera schema as a saved child scene. The bundled files reference only explicitly approved production art. A window is a genuine gap between wall rectangles with a transparent frame and Sky behind it; no cutting or glass behavior is inferred. Furniture Props receive deliberate still/block/support roles from the normal Prop contract, without promising Sit.

The focused Phase 19 starter-scene composition specification defines the approved pan-safe Town Street and Living Room surfaces and the opening-shot bounds used by starter-card previews. It keeps the ordinary v1.5 scene records and camera free of limits.

The current PendingFileAction.NEW_SCENE unsaved Save/Discard/Cancel gate remains authoritative. After it resolves, show the shared starter gallery before replacing the current document. Canceling the gallery leaves the existing scene and post-gate dirty state. Selecting a starter preflights its scene data, loads ordinary editable objects, resets current_path to empty, stamps a fresh creation time, proposes its editable name, and marks the new document unsaved. Failed starter validation leaves the prior scene intact and shows one themed error. No starter is inserted into another scene. A first-save path follows the existing Save As flow. CLI scene playback continues to load .scene directly, with no starter-specific runtime branch.

UI component and theme reuse

Surface Reuse or shared extension Feature-local work / justified exception
File → New gate StageChrome File menu; StageModalViews unsaved dialog; StageDocumentController pending action Starter-choice result and fresh-document handoff only.
Starter, Scenery, Sky, finish and replacement galleries Extract a responsive shared card-gallery body from AssetSelector/scenes/stage/asset_selector.tscn; retain AssetCard, StageAssetBrowser, thumbnail pipeline Tabs, descriptive text, and filter meaning are feature-specific. Replace the current fixed four-column/12-per-page and FOCUS_NONE card limitation in the shared path: adaptive columns, scroll, native focus, visible focus ring, and keyboard activation. Non-placeable cards disable card drag.
Browse tags and authoring tags One shared wrapping chip/choice treatment using native Buttons and FlowContainer/HFlowContainer, SandboxTheme popup tokens Single-select browser filter versus multi-select authoring tags have distinct state rules. Do not use vertical StagePanelChoiceGroup as a chip row.
Wall color Extend ColorSwatchSelector for wrap/selected/focus; native color picker in a StageModalViews themed window Chosen wall color remains local to the placement/selected panel.
Selected artwork, missing art, wall, Ground, multi-selection Direct use of StageInspectorPanel, StagePanelChoiceGroup, stage_panels tokens and StagePropSettingsView dismissal/compact Settings precedent Domain rows and commands stay in a Stage Scenery/Ground view adapter. StagePanelToggleRow is used only for a real boolean, not depth or finish.
Member list / overlap popup / wall-area eraser StageInspectorPanel action styling; generalize StageModalViews.show_objects_here() labels and candidate filter; reuse StageUseTargetCaptureBar for the eraser prompt and Done/Cancel Membership, piece labels, the red rectangle preview, and wall subtraction are Scenery-specific. The selected wall card starts the tool; no new dialog, overlap popup, or capture-bar styling.
Placement/group capture Extract themed responsive shell from StageUseTargetCaptureBar, preserving mode-pill-safe position, prompt/Cancel grouping, action stacking Use-target and Scenery wording/actions stay in callers. No copied capture-bar CSS/StyleBox code.
Cursor and ghosts StageChrome tooltip safe placement, StagePlacementOverlay, terrain ghost presentation, SandboxTheme.overlays The draft geometry, validity, and missing marker are feature-specific.
Make artwork Existing PropEditor, PropCanvas, .prop Save/recovery and PropThumbnail Add a typed AuthoringContext { PROP, SCENERY }, signal artwork_saved(path: String), and signal return_requested. In Scenery context hide material/mass/bounce, attachment, and other action settings; keep shape/color/name/tags/Save.

The shared UI contracts should remain small and typed. Extract scripts/shared/ui/asset_gallery_popup.gd, class_name AssetGalleryPopup extends PopupPanel, as the fixed header/Close and scrollable responsive AssetCard grid used by the current AssetSelector and Phase 19 choosers. It emits item_selected(entry: Dictionary) and cancelled, and exposes open_gallery(title: String, entries: Array[Dictionary]) -> void, set_thumbnail(entry_id: String, texture: Texture2D) -> void, and focus_entry(entry_id: String) -> void. A card entry has {id: String, label: String, thumbnail: Texture2D|null, helper: String, draggable: bool}; the caller maps id to its domain item. AssetSelector retains its public open(kind, entries) facade and uses the shared gallery, so existing Prop/Stickman callers are not broken. StageAssetBrowser retains scanning/caching and adds a role-safe filtered query such as scan_entries(kind: String, tag: StringName = &"") -> Array[Dictionary]; no gallery owns asset parsing.

Extract scripts/shared/stage_ui/stage_capture_bar.gd, class_name StageCaptureBar extends PanelContainer, from the StageUseTargetCaptureBar shell. Its intended interface is signal action_pressed(id: StringName), func configure(stage_theme: SandboxTheme, mode: int, top_bar_height: float) -> void, func set_prompt(text: String) -> void, func set_actions(actions: Array[Dictionary]) -> void, and func hide_capture() -> void; each action dictionary has {id: StringName, label: String, prominent: bool}. Keep StageUseTargetCaptureBar as a thin Use-specific wrapper or composition client preserving cancel_requested and change_prop_requested. Generalize StageModalViews.show_objects_here() with a typed title/label provider while retaining its existing facade and object_selected(node: Node2D) signal. Existing StageInspectorPanel/StagePanelChoiceGroup APIs need no new domain knowledge: caller-owned cards call set_header, body, set_safe_rect, request_fit, and add_choice/select_no_signal directly. ColorSwatchSelector may change its layout container to FlowContainer while preserving configure(colors: Array[Color], selected: Color, theme: Variant), set_selected, and color_selected(color: Color).

All new popup/native-window instances receive SandboxTheme.control_theme() explicitly, then StageModalViews.apply_dialog_theme() for child font overrides. The latter does not assign a window theme. Extend SandboxTheme.DEFAULT_CONFIG and sandbox_theme.json only for shared popup/gallery/chip/capture/selection measurements and colors. Use local StyleBox copies; do not mutate the cached shared Theme. Selected, hover, focus, and disabled remain distinguishable. All actionable controls and handle hit regions meet the approved minimum 44 × 44 logical pixels. Viewport-safe rectangles keep the mode pill and Close reachable; long content scrolls under a fixed header. The existing queue/rule/speech popups are distinct workflows and are not recast as gallery or inspector shells.

Stage-hosted Prop Editor lifecycle

Instantiate the existing scenes/editors/prop/prop_editor.tscn in a full-viewport Stage-hosted UI layer, set Scenery context before its first user interaction, then focus its first drawing/name control. While active, SandboxStage routes no world selection, placement, camera mode, or shortcuts to the hidden Stage; dialogs and editor input remain usable. The underlying Stage document, dirty flag, selection, and current scene path remain in memory. The editor keeps its own .prop autosave and unsaved-work gate. Its F6 standalone exit still quits its own run; in hosted context Close/Cancel emits return_requested to remove the editor view and restore Stage input/focus without quitting the Stage. A successful Save emits artwork_saved(path) after the file write; Place it as Scenery returns to the Artwork chooser with a fresh scan/thumbnail generation and the saved asset focused. Merely opening/canceling the editor never changes the Stage document. A saved new .prop outside the Stage is a separate asset write, so returning without placement leaves the Stage unchanged.

Implementation and art gates

  1. Extend data boundaries first: .prop tags and role-safe visual scanning; scene v1.5 migration/validation; semantic draw order; inert visual nodes and shared finish/Sky definitions. Validate old scene/Prop compatibility before UI wiring.
  2. Generalize the existing card gallery, tag treatment, capture bar, overlap popup, color treatment, confirmation style, and theme tokens. Build the Phase 19 chooser and inspectors from those shared controls. Avoid a parallel popup/card system.
  3. Connect placement, selection scopes, missing repair, Ground finish, and group snapshot previews through StageSceneryController and the existing Stage save/mode gates. Add the starter gallery and Stage-hosted Prop Editor return path.
  4. The original Phase 19 art manifest used $asset-creation and approved production assets. This sizing amendment requires no new vector/raster deliverables: it draws the existing stickman guide and floor cue in Prop Editor and extends existing Stage handles. An artist may deliberately revise a particular .prop through the ordinary asset-review workflow, but implementation does not batch resize built-ins or reopen the original art approval gate merely because this amendment is signed off.
  5. Run syntax/import checks and focused existing suites after each integration slice. Add focused behavior checks where actual world state is at issue. Perform render-capable Stage/editor captures and real input-path checks before reporting visual/interactive outcomes as agent-verified.

Relevant existing regression starting points are tests/test_phase19_scenery.gd, tests/test_phase10_prop_integration.gd, tests/test_prop_editor_file.gd, tests/test_prop_editor_canvas.gd, tests/test_prop_editor_round3.gd, tests/test_content_paths.gd, and tests/test_phase17a_prop_roles.gd; run focused affected suites rather than the full historical matrix for each sizing edit. The original Phase 19 fixture covers scenery persistence and group behavior; extend focused coverage for Size for Stage's atomic Keep/Cancel and a real one-piece Stage resize, including late release after cancellation. Exercise production input and save/reopen paths, not only helper calculations.

Observable acceptance and verification routes

Each row names a child path and a repeatable observation. Automated checks may create in-memory fixtures, but should use actual Stage spawning, document, input, and PLAY paths where practical; node/signal existence alone does not establish behavior.

Child path / scenario Observable outcome and measurement
File → New → cancel starter, then Town Street; save/reopen; repeat Living Room Existing scene survives chooser Cancel. Each chosen starter is unsaved, individually editable, and preserves Sky, separate panels/art/Props/Ground and positions after save/reopen. Living Room Sky is visible through an actual wall gap.
Scenery → Artwork → Nature → Tree; place twice, change depth, RMB; reopen Two distinct inert copies persist at saved transforms/depths. RMB removes ghost, preserves committed copies, returns to ordinary EDIT. Check PhysicsDirectSpaceState2D contacts, navigation candidates, and event participation remain absent for scenery while a normal Prop remains functional.
Make line-only artwork in Stage-hosted editor; Save → Place it as Scenery .prop with open strokes appears in Scenery and has a valid thumbnail; it does not enter Props without valid closed collision geometry. Stage unsaved state and focus survive authoring return. Tags filter it without changing its behavior.
Draw Brick wall, resize it; paint a Wood Ground run, restyle one block Wall is finite/inert; brick pitch stays constant. Ground collision and walking path remain valid; wood motifs line up across neighbors, shared seams do not interrupt the run, and only the selected block changes to the new finish. Measure character feet/contact over several physics ticks before and after restyling.
Ordinary EDIT click/box in front of broad wall; enter Edit Scenery; select art and wall through Pieces here Gameplay objects remain reachable in ordinary EDIT. Scenery-only selection finds art ahead of broad panels, includes missing art in Edit Scenery, and never selects Sky. Real pointer/touch/keyboard activation produces the expected selection and popup dismissal.
Living Room: select two walls, window frame, rug; move preview then RMB; resize preview then Keep; Save/reopen RMB restores each exact snapshot transform. Keep applies one positive factor around the opposite fixed corner, preserves artwork proportions/depth and wall motif pitch, leaves Ground/furniture untouched, and persists member transforms without a saved group. Save while pending asks Keep/Cancel and never writes draft values.
Prop Editor → Arrange → Size for Stage on a tall refrigerator, wide rug, and small item; try each preset/fine buttons, Cancel once and Keep once The artwork and standard guide remain fully visible at ordinary and narrow viewport sizes. Longest visible edge reaches approximately 0.35H, 1H, or 2H while proportions, circle radii, line widths, and editable anchor positions share the same factor; the foreground halo guide's ankles meet the floor cue at the candidate bottom. Cancel restores byte-equivalent authored geometry/anchors and the prior dirty state; Keep commits once. A Big preset cannot clip the guide merely because opening began on small artwork. Save/reopen the .prop through the normal path and compare geometry/anchors.
Edit Scenery → select two copies of one artwork; drag a corner on one, release; then cancel another drag with Esc/RMB/touch Cancel and release the pointer The opposite visible corner stays at the same world position; only the edited placement changes, and it commits on release without Keep. A canceled drag restores its snapshot and the late release makes no change. With Snap on, a moved artwork or wall's visible top-left lands on a grid intersection. Smaller then Bigger returns near the initial bounds. Save/reopen and check the two copies retain distinct scales, while Props/Ground/PLAY behavior remains unchanged. A missing artwork without known bounds shows no resize handle.
Open a pre-amendment .prop and .scene, then explicitly overwrite one referenced .prop through ordinary Save Opening alone does not rescale artwork or placement values. The explicit source overwrite changes every live reference to that source on reopen, while each scene placement retains its saved scale; there is no hidden compensation, copy prompt, or version migration.
Move a referenced .prop aside, reopen, repair selected missing artwork; cancel once then confirm One neutral marker remains at saved position/depth, summary appears once, ordinary EDIT targets are not stolen, cancel keeps path/dirty state, and confirmed repair changes only that placement. Missing footprint/no-footprint group resize rules and PLAY marker presentation hold. Restore source and reopen to confirm live-reference visual update.
Select each Sky, pan/zoom, PLAY/stop, reopen The fixed gradient fills the screen without an edge or flicker; details follow the bounded response in the Sky addendum. Foreground pixel colors are unchanged by Sky choice. Layer order remains Sky/wall/Far/Near/Ground/floor/actors through camera movement, simulation, and reload; ghosts/handles/capture bars disappear in PLAY.

Visual acceptance requires opening the affected scene in a render-capable Godot GUI, capturing the named chooser, normal/zoomed Stage, missing-art, group-preview, Prop Editor sizing guide at Small/Big and narrow width, active single-piece handles/capture bar, and PLAY states, and inspecting the pixels. Record capture location, scene/state, and observation. Interactive acceptance requires exercising the actual controls and input events, including RMB/Esc/touch cancellation, button sizing, grid-aligned move, commit on release, and a late release after cancellation. If GUI or input automation is unavailable after an attempted route, name the unverified outcomes and hand the user a focused reproducible check; a headless parse or screenshot file alone is insufficient. Phase 19 user/editor verification remains open until the child checks the integrated behavior.

Trade-offs and future work

  • Independent nodes preserve the current editable direct-child document model and avoid using PropBlock as a fake inert body. The cost is a separate artwork visual node and explicit scene-object decoding.
  • Live .prop references keep scenes compact and make art edits visible on reopen. They can change a scene's appearance after the source file changes; the saved name/footprint and repair flow cover missing files, not version pinning. A future immutable asset-version policy would need a separate decision.
  • Stage-relative authoring size is geometry stored in .prop, while a Stage Scenery resize is an instance transform stored in .scene. This keeps legacy loads stable and lets two placements of one source have different sizes. It also means an intentional overwrite of a live .prop updates all references; the user chose to retain ordinary Save/Save As without a new copy/update decision. The authoring preview scales outline width and editable anchors with geometry and uses origin as its pivot; its floor guide follows visual bounds solely for comparison.
  • A one-piece Stage drag commits on release for speed; a multi-piece group retains Keep/Cancel because its wider effect is harder to judge during a gesture. Both use exact snapshots before commit, while Stage-wide Undo remains deferred to the separate phase logged in technical debt.
  • Rebased terrain drawing enables the required floor-art tier without changing Prop-to-character depth semantics. It changes terrain's relative visual position beneath Props; physics and navigation stay unchanged and must be regression checked.
  • Vector motifs retain clarity and world alignment without repeated raster textures, at the cost of careful clipping/culling and seam management. Keep motif definitions centralized so wall/Ground previews match runtime drawing.
  • Stage-wide Undo/Redo deserves one command history across placement, deletion, style, repair, and group commits. The Phase 19 Keep/Cancel preview is a local transaction only. Whole-set transform needs its own design because Ground physics, character/Prop placement, rules, and camera all have different transform invariants.

Phase 19 addition: camera-aware Sky details

This focused addition implements the accepted product addendum and UI/UX contract. The original clauses above that describe Sky details as static are superseded here. Only the four existing child-facing Sky presets gain automatic camera response. The internal neutral Sky may use a zero-response profile. There is no motion setting, additional Sky card, theme token, scene field, or change to Background ground behavior.

Ownership, nodes, and typed contract

flowchart LR
    Camera[SandboxStage/Camera2D\nworld position + zoom] -->|bind once; sample framing| Sky[StageSkyLayer: CanvasLayer -1\nStageSkyView: Control]
    Sky --> Gradient[full-rect gradient\nscreen fixed]
    Sky --> Motifs[clouds / sun / moon / stars\nbounded camera response]
    World[World: Background ground + walkable Ground + actors] --> Camera
    Cards[Sky chooser and starter card previews] -->|SkyView without camera binding| Still[default still composition]

SandboxStage (class_name SandboxStage extends Node2D) owns the one live Camera2D and passes it to the already-built StageSkyView; it does not dispatch a separate Sky update from every mouse, touchpad, scene-load, mode, or PLAY path. StageSkyView (class_name StageSkyView extends Control) owns all Sky drawing and the response calculation. Its narrow typed API is:

func bind_camera(camera: Camera2D) -> void # A null camera restores the default still composition.
func set_sky(id: StringName) -> void
func get_sky() -> StringName
func apply_theme(theme: SandboxTheme) -> void

StageSkyView samples the bound camera's finite global_position and positive uniform zoom.x in its own _process(delta: float) -> void, compares the effective framing against the last drawn values, and calls queue_redraw() only when the visible composition changes. Its NOTIFICATION_RESIZED path also invalidates layout. Invalid or unavailable camera data falls back to the fixed default composition rather than propagating NaN into drawing. The first binding, preset change, and scene load invalidate immediately so a newly shown view does not display a previous frame's motifs. No signal or runtime state is serialized; the last sampled values exist only to avoid redundant redraws. The chooser's StageBackgroundCardPreview and starter's StageStarterCardPreview create unbound StageSkyView instances and remain representative still images. No new UI component, Stage inspector control, or SandboxTheme.stage_panels token is needed.

Deterministic response and layout

Use the built-in Stage camera framing, REF_POSITION := Vector2(0.0, -400.0) and REF_ZOOM := 0.5, as the fixed reference for every scene. These values match SandboxStage's current defaults and sandbox_stage.tscn. Evaluate each frame from absolute camera position and zoom; never accumulate input deltas, seed from elapsed time, or capture a per-session origin. Thus identical {sky_id, camera.position, camera.zoom, viewport.size} yields identical drawing after mode changes, PLAY/stop, and save/reopen. The existing .scene camera position/zoom and sky_id fields are sufficient; scene version and migration stay unchanged.

Each preset has one internal response profile, shared by all its motifs. A profile contains bounded horizontal/vertical pan limits in viewport fractions, a pan response span, and a bounded logarithmic zoom strength/span. These are renderer constants, not saved data or child-facing controls. A suitable starting envelope is at most about 5% of viewport width and 3% of height for motif displacement and about 0.85–1.15× for uniform motif size over the Stage's supported zoom range; tune each preset by rendered comparison at ordinary, close, and wide shots. Derive pan from the camera-position difference expressed in reference-frame screen pixels and pass it through a smooth odd saturating function (for example tanh). Derive zoom from log(camera_zoom / REF_ZOOM) through a smooth bounded function, then exponentiate to keep the size positive and reciprocal around the reference zoom. Avoid hard clamps in the visible response range, which can create a conspicuous change in motion at the limit. Camera movement of world objects must visibly exceed Sky-detail movement at ordinary framing. At the reference camera framing, displacement is zero and scale is one.

For clarity, one possible pure calculation is shift.x = -W * limit_x * tanh(((camera_x - REF_POSITION.x) * REF_ZOOM / W) / span_x) (and likewise for y), with size_factor = exp(gain * tanh(log(camera_zoom / REF_ZOOM) / zoom_span)); choose limit/span and gain so the detail response remains gentle. The final spec requirement is bounded, continuous, deterministic behavior, not these exact coefficients. Apply the same profile to all motifs of a preset as one visual group; individual stars may retain different authored sizes and positions, but do not acquire independent motion layers. Keep sky_neutral deterministic; a zero-response profile is acceptable for that internal default.

The gradient remains a full Rect2(Vector2.ZERO, size) draw in the screen-space CanvasLayer; do not apply the motif transform to the gradient. Place motif centers using normalized viewport anchors and scale their authored vector shapes uniformly with a single viewport-derived scalar, rather than separate x/y stretch. Apply the camera-induced size factor about the motif's own anchor, then add the bounded shift. Let the viewport clip any motif portion outside the visible rectangle; the gradient still fills those pixels, so no Sky edge appears. On resize, recompute anchors and the scalar from the new dimensions and reevaluate the same absolute camera frame. Retune the current low and oversized clouds, especially Cloudy Day and Sunset, so the ordinary lower action area remains comparatively open. This is art composition within the renderer, not a hard mask that hides clouds over arbitrary character positions.

The current star loop uses fposmod(..., size.x/y), so changing viewport size can wrap a star abruptly to a distant position. Replace that layout with a fixed, deterministic set of normalized star anchors (or a fixed seeded list mapped to normalized coordinates); never seed it from the frame clock or call randf() while drawing. Existing cloud/moon curves should be sampled or cached once per shape/preset where practical, because camera movement now invalidates the drawing more often. Keep motif count and vector point count bounded; retain the gradient texture until the preset actually changes. There is no autonomous cloud drift, weather animation, horizon line, selection geometry, collision, or world tint.

StageBackgroundGround remains a world-anchored Node2D surface behind walkable Ground. Its landscape edge and the walkable Ground therefore move and scale together through the normal Camera2D transform. The Sky makes no ground-line claim and does not inspect terrain or character positions.

Data flow and observable acceptance

  1. The child chooses Clear Day, Cloudy Day, Sunset, or Starry Night. SandboxStage calls the existing set_sky(id) path; the Sky draws its fixed gradient and reference-frame motif composition. The chooser card remains a still preview.
  2. A pan or zoom changes Camera2D through any existing input route. The bound StageSkyView samples the resulting absolute framing, derives its bounded motif transform, and redraws only when that framing changes. No mode or PLAY-specific Sky branch runs.
  3. Save writes the existing sky_id and camera fields. Reopen loads those values; the next Sky draw derives the same composition from them and the current viewport size. A resize derives a coherent composition at the new size without changing saved state.

Repeat this in a rendered Stage: choose each preset; place a stickman on walkable Ground with a broad Background ground panel behind it; frame an ordinary shot, a close shot, and a wide shot; pan in EDIT and DIRECT, switch to PLAY and pan/zoom again, then stop. At an unchanged camera framing, motif anchors and apparent size must match across those mode and PLAY transitions. Resize the viewport twice and verify the gradient remains full-screen, motif shapes keep their proportions, and stars do not jump across the screen. Save at a nondefault camera framing, reopen at the same viewport size, and compare the motif composition; it must match without a separate Sky setting. Throughout, Sky displacement and scaling are perceptible but restrained, no edge or flicker is exposed, the lower ordinary action area remains usable, Ground and Background ground stay aligned, and foreground colors/physics remain unchanged. A screenshot or headless parse alone cannot establish the camera-response and composition outcomes; inspect rendered frames at the named camera states.

Architectural trade-offs

  • Keeping one CanvasLayer and one StageSkyView reuses current drawing and preview paths. The renderer must now redraw vector motifs while the camera moves; bounded geometry, geometry caching, and change-only invalidation keep that cost proportional to visible camera changes.
  • Fixed reference framing avoids a new .scene field and makes save/reopen exact. Very distant camera positions can sit near the response limit, so additional panning there may change the Sky little; that is the accepted cost of a bounded composition.
  • One profile per preset is simple to tune and preserves visual unity. It does not simulate separate cloud distances. True multilayer parallax and autonomous Sky animation remain outside this Phase 19 addition.
  • Normalized anchors and uniform motif size preserve proportions across viewport shapes. Wide or narrow aspect ratios change the available spacing between details, requiring visual checks on both shapes; exact pixel placement across resizes is not promised.