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.
This commit is contained in:
2026-10-21 17:46:58 -04:00
parent c1758efd3b
commit 0b1dda2e2f
18 changed files with 1574 additions and 141 deletions
@@ -1,11 +1,11 @@
# 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. This specification follows the [accepted product brief](../plans/phase_19_scenery_backgrounds_surface_finishes.md), the [approved UI/UX specification](phase_19_scenery_backgrounds_ui_ux.md), and the [Stage panel UI system](stage_panel_ui_system.md). Full Phase 19 user/editor verification remains pending. The earlier sections describe the original implementation contract; the sizing amendment starts from the current `.scene` v1.5, `.prop` v1.1, Stage controller, and shared UI.
> **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](../plans/phase_19_scenery_backgrounds_surface_finishes.md), the [approved UI/UX specification](phase_19_scenery_backgrounds_ui_ux.md), and the [Stage panel UI system](stage_panel_ui_system.md). 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 details stay fixed during camera movement; 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.
2. Sky is one adaptive, viewport-filling, screen-space vector drawing behind the world. Its gradient stays fixed while its details respond gently to camera pan and zoom under the [camera-aware Sky addendum](#phase-19-addition-camera-aware-sky-details); it does not tint other objects. Existing scenes and Blank Scene use an internal neutral Sky. The four child-facing Sky presets are Clear Day, Cloudy Day, Sunset, and Starry Night.
3. The existing Prop Editor opens **inside the live Stage session** for Make artwork. The Stage and its unsaved document remain in memory, with Stage input suspended until the editor returns. The standalone F6 route remains valid.
4. Select several creates a temporary Scenery selection and snapshot-based transform preview. It does not create a persistent group or a parent that changes the saved hierarchy. Sky, Ground, characters, and functional Props are outside this transform.
5. Phase 19 adds neither Stage Undo/Redo nor a whole-set transform. Group preview has its own exact Cancel/Keep boundary. Stage-wide Undo/Redo is a separately planned phase and is logged in [technical debt](../architecture/tech_debt_and_optimizations.md).
@@ -31,7 +31,7 @@ flowchart TD
| 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` | Full-rect child of a dedicated `CanvasLayer` with `layer = -1`, mouse ignored. Deterministic vector `_draw()` on viewport resize/theme/choice only. No world hit test or physics. |
| `scripts/stage/scenery/stage_sky_view.gd`, `class_name StageSkyView extends Control` | `func set_sky(id: StringName) -> void`; `func get_sky() -> StringName`; `func apply_theme(theme: SandboxTheme) -> void`; camera binding specified in the [Sky addendum](#phase-19-addition-camera-aware-sky-details) | Full-rect child of a dedicated `CanvasLayer` with `layer = -1`, mouse ignored. Deterministic vector drawing on preset, viewport, or effective camera-framing changes. No world hit test or physics. |
| `scripts/stage/scenery/stage_wall_panel.gd`, `class_name StageWallPanel extends Node2D` | `var finish_id: StringName`; `var tint: Color`; `var panel_size: Vector2`; optional `var motif_origin_offset: Vector2`; `func configure(size: Vector2, finish: StringName, color: Color) -> void`; `func get_local_bounds() -> Rect2` | Finite world rectangle centered at `position`. No `CollisionObject2D`, navigation, support, or interaction registration. Resize changes `panel_size`, never root scale, so motifs retain their world pitch. The renderer lays the pattern out from each panel's current world transform, so eraser-created fragments keep continuous brick courses; the optional offset preserves source-wall lineage in saved data. |
| `scripts/stage/scenery/stage_scenery_artwork.gd`, `class_name StageSceneryArtwork extends Node2D` | `var asset_path: String`; `var display_name: String`; `var depth: StringName`; `var last_known_local_bounds: Rect2`; `var missing: bool`; `func load_visual(path: String) -> bool`; `func set_depth(value: StringName) -> void`; `func get_visual_world_bounds() -> Rect2`; `func get_saved_footprint() -> Rect2` | Build a `Visuals` child via `PropVisuals.build()` from the complete shape stack. No `PropBlock`, collision hull, physics, Use spots, anchors, or event identity. If loading fails, render a neutral marker/known footprint from saved data and preserve path/name/transform. |
| `scripts/stage/scenery/stage_surface_renderer.gd`, `class_name StageSurfaceRenderer extends RefCounted` | `static func is_wall_finish(id: StringName) -> bool`; `static func is_ground_finish(id: StringName) -> bool`; `static func draw_wall(canvas: CanvasItem, rect: Rect2, finish: StringName, tint: Color, world_origin: Vector2) -> void`; `static func draw_ground(canvas: CanvasItem, rect: Rect2, finish: StringName, tint: Color, world_origin: Vector2) -> void` | Shared vector definitions for Paint/Brick/Wood, with Grass/Dirt/Sidewalk/Asphalt for Ground. One motif vocabulary and world-coordinate phase; no raster scale blur. Drawing helpers clip motifs to finite panel/block bounds and bound work to visible tiles. |
@@ -75,7 +75,7 @@ The manual **Erase wall area** tool adds one optional wall field, `motif_origin_
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. Rendering should be static between sky or viewport changes; no per-frame random placement, animation, parallax, or tint propagation. Sky thumbnails use the same drawing definitions at card size so the choice predicts the Stage.
`StageSkyView` lives in its own `CanvasLayer` behind `World`, not under the camera or a wall. Its draw rectangle follows viewport size, so panning/zooming cannot reveal an edge. Use deterministic vector gradient bands, stars, clouds, sun/moon and shapes with bounded point counts. The preset controls only this view. The gradient stays screen-fixed; the [camera-aware Sky addendum](#phase-19-addition-camera-aware-sky-details) defines bounded, camera-responsive details and redraw invalidation. There is no per-frame random placement, autonomous animation, or tint propagation. Sky thumbnails use the same drawing definitions at card size in their unbound default composition.
`StageSurfaceRenderer` defines finite vector motifs with a world-aligned origin and fixed world-unit pitch. Wall and Ground may share Brick/Wood definitions but use context-aware fill/outline colors and scale. `StageWallPanel._draw()` uses its current finite rectangle as a clip and asks the renderer for only intersecting motif cells. For brick walls, cull to the camera-visible local rectangle; if that visible area exceeds 12,000 cells, increase the brick pitch uniformly for all visible courses instead of truncating rows or columns. This keeps large walls fully patterned and fragments continuous without unbounded draw work. Ground renders within its existing rectangle geometry; existing `CollisionPolygon2D` and navigation surfaces are never rebuilt merely for a finish change. The selected **New Ground look** is passed into both `StageTerrainPainter` ghost creation and each committed Ground block; it never repaints earlier blocks. During group resize, wall motif lines retain their pitch and phase. Ground blocks adjacent on a shared edge use the same world phase; shared same-finish interior borders are suppressed or visually joined so a painted run reads as one surface. The neighbor check refreshes on place, delete, move, load, and per-piece finish change, and considers only nearby Ground rectangles; it does not change occupancy/collision. Other motif types remain bounded by the camera-visible region or a maximum cell count per finite panel so `_draw()` work stays limited.
@@ -185,6 +185,8 @@ On scene open, load every live artwork reference. A missing/invalid source leave
Keep versioned starter documents under `content/stages/starters/` with stable IDs for Blank, Town Street, and Living Room. They are ordinary `.scene` snapshots, not locked nested instances. Blank may be assembled from the current neutral empty-scene defaults; Town Street and Living Room data use the same wall, artwork, terrain, Prop, Sky, and camera schema as a saved child scene. The bundled files reference only explicitly approved production art. A window is a genuine gap between wall rectangles with a transparent frame and Sky behind it; no cutting or glass behavior is inferred. Furniture Props receive deliberate still/block/support roles from the normal Prop contract, without promising Sit.
The focused [Phase 19 starter-scene composition specification](phase_19_starter_scene_composition.md) defines the approved pan-safe Town Street and Living Room surfaces and the opening-shot bounds used by starter-card previews. It keeps the ordinary v1.5 scene records and camera free of limits.
The current `PendingFileAction.NEW_SCENE` unsaved Save/Discard/Cancel gate remains authoritative. After it resolves, show the shared starter gallery **before** replacing the current document. Canceling the gallery leaves the existing scene and post-gate dirty state. Selecting a starter preflights its scene data, loads ordinary editable objects, resets `current_path` to empty, stamps a fresh creation time, proposes its editable name, and marks the new document unsaved. Failed starter validation leaves the prior scene intact and shows one themed error. No starter is inserted into another scene. A first-save path follows the existing Save As flow. CLI scene playback continues to load `.scene` directly, with no starter-specific runtime branch.
## UI component and theme reuse
@@ -237,7 +239,7 @@ Each row names a child path and a repeatable observation. Automated checks may c
| 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 | Sky fills screen without an edge or flicker; 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. |
| Select each Sky, pan/zoom, PLAY/stop, reopen | The fixed gradient fills the screen without an edge or flicker; details follow the bounded response in the [Sky addendum](#phase-19-addition-camera-aware-sky-details). Foreground pixel colors are unchanged by Sky choice. Layer order remains Sky/wall/Far/Near/Ground/floor/actors through camera movement, simulation, and reload; ghosts/handles/capture bars disappear in PLAY. |
Visual acceptance requires opening the affected scene in a render-capable Godot GUI, capturing the named chooser, normal/zoomed Stage, missing-art, group-preview, **Prop Editor sizing guide at Small/Big and narrow width, active single-piece handles/capture bar**, and PLAY states, and inspecting the pixels. Record capture location, scene/state, and observation. Interactive acceptance requires exercising the actual controls and input events, including RMB/Esc/touch cancellation, button sizing, grid-aligned move, commit on release, and a late release after cancellation. If GUI or input automation is unavailable after an attempted route, name the unverified outcomes and hand the user a focused reproducible check; a headless parse or screenshot file alone is insufficient. Phase 19 user/editor verification remains open until the child checks the integrated behavior.
@@ -250,3 +252,58 @@ Visual acceptance requires opening the affected scene in a render-capable Godot
- Rebased terrain drawing enables the required floor-art tier without changing Prop-to-character depth semantics. It changes terrain's relative visual position beneath Props; physics and navigation stay unchanged and must be regression checked.
- Vector motifs retain clarity and world alignment without repeated raster textures, at the cost of careful clipping/culling and seam management. Keep motif definitions centralized so wall/Ground previews match runtime drawing.
- Stage-wide Undo/Redo deserves one command history across placement, deletion, style, repair, and group commits. The Phase 19 Keep/Cancel preview is a local transaction only. Whole-set transform needs its own design because Ground physics, character/Prop placement, rules, and camera all have different transform invariants.
## Phase 19 addition: camera-aware Sky details
This focused addition implements the accepted [product addendum](../plans/phase_19_scenery_backgrounds_surface_finishes.md#camera-aware-sky-details-addendum) and [UI/UX contract](phase_19_scenery_backgrounds_ui_ux.md#camera-aware-sky-details). The original clauses above that describe Sky details as static are superseded here. **Only the four existing child-facing Sky presets gain automatic camera response.** The internal neutral Sky may use a zero-response profile. There is no motion setting, additional Sky card, theme token, scene field, or change to Background ground behavior.
### Ownership, nodes, and typed contract
```mermaid
flowchart LR
Camera[SandboxStage/Camera2D\nworld position + zoom] -->|bind once; sample framing| Sky[StageSkyLayer: CanvasLayer -1\nStageSkyView: Control]
Sky --> Gradient[full-rect gradient\nscreen fixed]
Sky --> Motifs[clouds / sun / moon / stars\nbounded camera response]
World[World: Background ground + walkable Ground + actors] --> Camera
Cards[Sky chooser and starter card previews] -->|SkyView without camera binding| Still[default still composition]
```
`SandboxStage` (`class_name SandboxStage extends Node2D`) owns the one live `Camera2D` and passes it to the already-built `StageSkyView`; it does not dispatch a separate Sky update from every mouse, touchpad, scene-load, mode, or PLAY path. `StageSkyView` (`class_name StageSkyView extends Control`) owns all Sky drawing and the response calculation. Its narrow typed API is:
```gdscript
func bind_camera(camera: Camera2D) -> void # A null camera restores the default still composition.
func set_sky(id: StringName) -> void
func get_sky() -> StringName
func apply_theme(theme: SandboxTheme) -> void
```
`StageSkyView` samples the bound camera's finite `global_position` and positive uniform `zoom.x` in its own `_process(delta: float) -> void`, compares the **effective** framing against the last drawn values, and calls `queue_redraw()` only when the visible composition changes. Its `NOTIFICATION_RESIZED` path also invalidates layout. Invalid or unavailable camera data falls back to the fixed default composition rather than propagating NaN into drawing. The first binding, preset change, and scene load invalidate immediately so a newly shown view does not display a previous frame's motifs. No signal or runtime state is serialized; the last sampled values exist only to avoid redundant redraws. The chooser's `StageBackgroundCardPreview` and starter's `StageStarterCardPreview` create unbound `StageSkyView` instances and remain representative still images. No new UI component, Stage inspector control, or `SandboxTheme.stage_panels` token is needed.
### Deterministic response and layout
Use the built-in Stage camera framing, `REF_POSITION := Vector2(0.0, -400.0)` and `REF_ZOOM := 0.5`, as the fixed reference for **every** scene. These values match `SandboxStage`'s current defaults and `sandbox_stage.tscn`. Evaluate each frame from absolute camera position and zoom; never accumulate input deltas, seed from elapsed time, or capture a per-session origin. Thus identical `{sky_id, camera.position, camera.zoom, viewport.size}` yields identical drawing after mode changes, PLAY/stop, and save/reopen. The existing `.scene` camera position/zoom and `sky_id` fields are sufficient; scene version and migration stay unchanged.
Each preset has one internal response profile, shared by all its motifs. A profile contains bounded horizontal/vertical pan limits in viewport fractions, a pan response span, and a bounded logarithmic zoom strength/span. These are renderer constants, not saved data or child-facing controls. A suitable starting envelope is at most about **5% of viewport width and 3% of height** for motif displacement and about **0.85–1.15×** for uniform motif size over the Stage's supported zoom range; tune each preset by rendered comparison at ordinary, close, and wide shots. Derive pan from the camera-position difference expressed in reference-frame screen pixels and pass it through a smooth odd saturating function (for example `tanh`). Derive zoom from `log(camera_zoom / REF_ZOOM)` through a smooth bounded function, then exponentiate to keep the size positive and reciprocal around the reference zoom. Avoid hard clamps in the visible response range, which can create a conspicuous change in motion at the limit. Camera movement of world objects must visibly exceed Sky-detail movement at ordinary framing. At the reference camera framing, displacement is zero and scale is one.
For clarity, one possible pure calculation is `shift.x = -W * limit_x * tanh(((camera_x - REF_POSITION.x) * REF_ZOOM / W) / span_x)` (and likewise for y), with `size_factor = exp(gain * tanh(log(camera_zoom / REF_ZOOM) / zoom_span))`; choose `limit/span` and `gain` so the detail response remains gentle. The final spec requirement is bounded, continuous, deterministic behavior, not these exact coefficients. Apply the same profile to all motifs of a preset as one visual group; individual stars may retain different authored sizes and positions, but do not acquire independent motion layers. Keep `sky_neutral` deterministic; a zero-response profile is acceptable for that internal default.
The gradient remains a full `Rect2(Vector2.ZERO, size)` draw in the screen-space `CanvasLayer`; **do not apply the motif transform to the gradient**. Place motif centers using normalized viewport anchors and scale their authored vector shapes **uniformly** with a single viewport-derived scalar, rather than separate x/y stretch. Apply the camera-induced size factor about the motif's own anchor, then add the bounded shift. Let the viewport clip any motif portion outside the visible rectangle; the gradient still fills those pixels, so no Sky edge appears. On resize, recompute anchors and the scalar from the new dimensions and reevaluate the same absolute camera frame. Retune the current low and oversized clouds, especially Cloudy Day and Sunset, so the ordinary lower action area remains comparatively open. This is art composition within the renderer, not a hard mask that hides clouds over arbitrary character positions.
The current star loop uses `fposmod(..., size.x/y)`, so changing viewport size can wrap a star abruptly to a distant position. Replace that layout with a fixed, deterministic set of normalized star anchors (or a fixed seeded list mapped to normalized coordinates); never seed it from the frame clock or call `randf()` while drawing. Existing cloud/moon curves should be sampled or cached once per shape/preset where practical, because camera movement now invalidates the drawing more often. Keep motif count and vector point count bounded; retain the gradient texture until the preset actually changes. There is no autonomous cloud drift, weather animation, horizon line, selection geometry, collision, or world tint.
`StageBackgroundGround` remains a world-anchored `Node2D` surface behind walkable Ground. Its landscape edge and the walkable Ground therefore move and scale together through the normal `Camera2D` transform. The Sky makes no ground-line claim and does not inspect terrain or character positions.
### Data flow and observable acceptance
1. The child chooses Clear Day, Cloudy Day, Sunset, or Starry Night. `SandboxStage` calls the existing `set_sky(id)` path; the Sky draws its fixed gradient and reference-frame motif composition. The chooser card remains a still preview.
2. A pan or zoom changes `Camera2D` through any existing input route. The bound `StageSkyView` samples the resulting absolute framing, derives its bounded motif transform, and redraws only when that framing changes. No mode or PLAY-specific Sky branch runs.
3. Save writes the existing `sky_id` and camera fields. Reopen loads those values; the next Sky draw derives the same composition from them and the current viewport size. A resize derives a coherent composition at the new size without changing saved state.
Repeat this in a rendered Stage: choose each preset; place a stickman on walkable Ground with a broad Background ground panel behind it; frame an ordinary shot, a close shot, and a wide shot; pan in EDIT and DIRECT, switch to PLAY and pan/zoom again, then stop. At an unchanged camera framing, motif anchors and apparent size must match across those mode and PLAY transitions. Resize the viewport twice and verify the gradient remains full-screen, motif shapes keep their proportions, and stars do not jump across the screen. Save at a nondefault camera framing, reopen at the same viewport size, and compare the motif composition; it must match without a separate Sky setting. Throughout, Sky displacement and scaling are perceptible but restrained, no edge or flicker is exposed, the lower ordinary action area remains usable, Ground and Background ground stay aligned, and foreground colors/physics remain unchanged. A screenshot or headless parse alone cannot establish the camera-response and composition outcomes; inspect rendered frames at the named camera states.
### Architectural trade-offs
- Keeping one `CanvasLayer` and one `StageSkyView` reuses current drawing and preview paths. The renderer must now redraw vector motifs while the camera moves; bounded geometry, geometry caching, and change-only invalidation keep that cost proportional to visible camera changes.
- Fixed reference framing avoids a new `.scene` field and makes save/reopen exact. Very distant camera positions can sit near the response limit, so additional panning there may change the Sky little; that is the accepted cost of a bounded composition.
- One profile per preset is simple to tune and preserves visual unity. It does not simulate separate cloud distances. True multilayer parallax and autonomous Sky animation remain outside this Phase 19 addition.
- Normalized anchors and uniform motif size preserve proportions across viewport shapes. Wide or narrow aspect ratios change the available spacing between details, requiring visual checks on both shapes; exact pixel placement across resizes is not promised.