Add Use Spot Overlay and Target Capture Bar functionality

- Introduced `StageUseTargetCaptureBar` class for managing user interactions with props and their use spots.
- Implemented UI elements including buttons for changing props and canceling actions.
- Added `PropUseSpotData` class for validation and serialization of prop use spots.
- Created tests for validating use spot data migration, serialization, and rule matching.
- Ensured proper handling of edge cases in use spot data, including unique IDs and finite positions.
This commit is contained in:
2026-10-13 11:23:44 -04:00
parent 235c6aa6a6
commit d8b1b1164b
42 changed files with 3847 additions and 50 deletions
@@ -77,6 +77,9 @@ This document tracks known technical debt, optimization opportunities, and minor
| 65 | **Large custom prop switches need state parity with native buttons** — a drawn 64 × 34 px switch inside a full-row `Button` replaces the stock small `CheckButton` icon. | Low | Planned | The proposed `PropSettingsToggleRow` should keep native focus, keyboard activation, disabled state, and one `toggled` emission per press; its child labels must ignore pointer input. A compact depth cue lives inside each choice button, while the stage remains the live preview. See `docs/specs/prop_settings_card_refinement.md`. (2026-10-09) |
| 66 | **Stage selection panels duplicate layout and visual rules** — the prop card owns its own shell sizing, choice styles, and `stage_prop_settings` tokens, making later inspector cards prone to drift. | Low | Planned | Promote the current prop card to a small shared Stage inspector shell, choice group, and toggle row with `stage_panels` theme tokens. Keep domain handlers in `SandboxStage`; preserve the existing prop role schema. During migration, resolve old prop tokens as fallback only temporarily and remove duplicate reads once the prop card uses the shared controls. See `docs/specs/stage_panel_ui_system.md`. (2026-10-09) |
| 67 | **SandboxStage controller is too large for comfortable Godot IDE editing** — stage UI, terrain painting, document workflow, runtime rules, and speech coordination shared one roughly 7,600-line script. | High | In Progress | Approved slices 1–16 extracted reusable stage components and reduced `sandbox_stage.gd` to 5,009 lines (about 34%); the below-3,800-line target remains unmet by 1,209 lines. Root input/selection, authored queue/rule writes, recovery application, and save/commit ordering remain coordinated there. Root `--check-only` parse passes; behavior and in-editor responsiveness checks are pending. See `docs/specs/sandbox_stage_composition_refactor.md`. (2026-10-10) |
| 68 | **Use preview cannot predict all prior rules or moving props** — DIRECT can see the current actor and prop plus a limited deterministic queue prefix, but reactive rules and rigid-body motion may change either before a pending Use action runs. | Low | Planned | Phase 17b displays an amber conditional preview when the future pose is uncertain and always recalculates reach during PLAY. One bounded target-movement replan handles ordinary drift without freezing physics. A future predictive timeline may improve authoring accuracy, but must not change persistent prop/spot IDs or claim certainty for simulated rule consequences. See `docs/specs/phase_17b_use_spots.md`. (2026-10-10) |
| 69 | **Contact entry alone can miss a stickman hitting a fixed prop** — grounded locomotion stops at a shape-sweep safe fraction, sometimes before `body_entered` fires; resting contacts can also chatter after pause or quick separation. | Medium | Planned | Phase 17c routes identified blocking-prop sweep hits and actual body contacts through one relative approach-speed scorer and pair gate. Collapse multiple stickman limbs into one rig/prop pair, require separation/rearm plus a short cooldown, and clear baselines at PLAY/reset boundaries. Keep existing collision-rule dispatch independent. See `docs/specs/phase_17c_impact_wobble.md`. (2026-10-13) |
| 70 | **Impact strength is initially speed based while prop mass is preset based** — material presets set `RigidBody2D.mass`, but fixed props are frozen and mass is not a child-facing setting. | Low | Planned | Keep the Phase 17c Wobble boolean and visual-only response independent of mass. Centralize impact scoring so a future editable-mass or impulse design can be evaluated without altering the Stage switch or migrating old scene choices. Do not infer a physical impulse from a frozen prop's preset mass; verify any future mass effect separately. See `docs/specs/phase_17c_impact_wobble.md`. (2026-10-13) |
---
@@ -160,5 +163,7 @@ This document tracks known technical debt, optimization opportunities, and minor
| 2026-10-09 | Proposed Stage selection panel UI system drafted for user approval (`docs/specs/stage_panel_ui_system.md`). The current prop card is the reference; a shared shell, choice group, toggle row, and theme tokens support later Stage inspectors. Logged #66 for duplicated panel presentation. No implementation code changed. |
| 2026-10-10 | Sandbox Stage composition refactor specified for developer handoff (`docs/specs/sandbox_stage_composition_refactor.md`). Defines state ownership, explicit component APIs, compatibility facades, and an ordered migration beginning with spatial indexing and terrain painting. Logged #67; implementation followed in the entries below. |
| 2026-10-10 | Sandbox Stage composition refactor slices 1–16 implemented (`docs/specs/sandbox_stage_composition_refactor.md`): extracted spatial indexing, terrain painting, stage chrome and views, document/scene adapters, run/director/rule/action/speech sessions, asset browsing, theme loading, and target capture. `sandbox_stage.gd` is 5,009 lines, about 34% smaller than the approximate 7,600-line baseline; the below-3,800-line target remains open. Godot 4.7.1 root `--check-only` parse passes. No behavior tests or in-editor checks were run; #67 remains In Progress. |
| 2026-10-10 | Phase 17b Use spots implemented from the approved architecture/UI specs (`docs/specs/phase_17b_use_spots.md`, `docs/specs/phase_17b_use_spots_ui.md`). Instance-local numbered spots persist in `.scene` v1.3; Use requires empty hands, targets keep normal physics with one bounded approach replan, and reach stays grounded. `prop_used` fires only after hand contact; missing references remain repairable after save/load. Phase 17b suite passes 27/27, Phase 17a regression passes 46/46, and edited scripts parse; ready for in-editor user verification. Logged #68 for conditional preview of earlier rules/moving physics; #12, #19, #24, and #67 already cover AABB selection, popup host visibility, session rule IDs, and root controller size. |
| 2026-10-13 | Phase 17c Impact wobble architecture specification drafted for final user sign-off (`docs/specs/phase_17c_impact_wobble.md`). A placed prop's saved boolean controls a visual-only wobble in PLAY and a brief EDIT preview. Contact entry plus identified stickman blocking sweeps feed a shared approach-speed gate, including ragdolls and both members of a prop-prop hit. Mass stays independent of the switch, with impact scoring isolated for future mass or impulse work. Scene version advances to 1.4 with older scenes defaulting Off. Logged #69–#70. No implementation code changed. |
_End of Log_
@@ -1,6 +1,6 @@
# Phase 17 — Props for Storytelling
> **Status:** Accepted product brief. User approval: Phase 17a implementation and UI accepted. Phases 17b–17d remain deferred.
> **Status:** Accepted product brief. Phase 17a is user-approved. Phase 17b implementation is present and awaiting in-editor user verification; Phases 17c–17d remain deferred.
> **Audience:** UI/UX, architect, and implementation team.
> **Purpose:** Product and design brief; technical contracts belong in later specifications.
@@ -17,7 +17,7 @@ Phase 17 is one product milestone with four ordered parts. Each part should be i
| Part | Child-facing outcome | Priority | Status |
| --- | --- | --- |
| **17a — Prop roles and placement** | Arrange props behind or in front of characters; choose independent physical and interaction capabilities; build pass-through support props; select covered props in EDIT; use **Set Down** to release a held prop at a chosen stage spot. | First and highest | Implemented and user-approved |
| **17b — Use spots** | Mark a handle, button, or lever on a placed prop, direct a stickman to use it, and respond through a rule. | Next | Deferred |
| **17b — Use spots** | Mark a handle, button, or lever on a placed prop, direct a stickman to use it, and respond through a rule. | Next | Implemented; awaiting user verification |
| **17c — Impact wobble** | Make a prop briefly wobble after a meaningful hit. | Next | Deferred |
| **17d — Scene groups** | Organize and move several placed props together while editing a scene. | Last; lower priority | Deferred |
@@ -35,9 +35,9 @@ Behavior is expressed through independent capabilities, because a prop may suppo
### 17b — Use spots
In EDIT, the child adds visible Use spots to a **placed prop instance** and moves each marker onto a handle, button, or lever. A Use spot differs from a pickup grip: the stickman reaches to the spot while the target prop stays in place. In DIRECT, the child chooses **Use**, selects the prop and intended spot, and sees where the stickman will approach and reach. If the spot cannot be reached, the stage explains the problem at the target rather than silently skipping the action.
In EDIT, the child adds visible Use spots to a **placed prop instance** and moves each marker onto a handle, button, or lever. A Use spot differs from a pickup grip: the stickman reaches to the spot while the target prop stays in place. In DIRECT, the child chooses **Use**, selects the prop and intended spot, and sees whether the spot is reachable. If it cannot be reached, the stage explains the problem at the target rather than silently skipping the action.
A successfully completed interaction emits a child-facing **Used prop** event that identifies the stickman, prop, and spot. A rule can respond to use of the whole prop or only one specific spot. The spot gets a brief, visible completion cue. Built-in results such as opening a door or toggling a light are later work; the rule system supplies story consequences in this phase.
A successfully completed interaction emits a child-facing **Used prop** event that identifies the stickman, prop, and spot. New rules target one specific named spot; saved whole-prop (`spot_scope = "any"`) rules remain supported for compatibility. The spot gets a brief, visible completion cue. Built-in results such as opening a door or toggling a light are later work; the rule system supplies story consequences in this phase.
### 17c — Impact wobble
@@ -55,7 +55,7 @@ Depth lets children stage a character behind a table or in front of scenery. Ind
- A child can save, reload, and replay a scene in which a fixed table is visually behind a stickman, supports a pot, and lets the stickman pass through. The pot can be picked up, set down onto the table with a smooth reach, released collision-free until that moment, and then fall onto it through physics. It can also be dropped or thrown and picked up again. PLAY reset restores the authored setup.
- Visual depth and physical/interaction capabilities remain independent on placed copies of the same prop asset. A covered prop remains discoverable and editable in EDIT.
- A child can place more than one Use spot on a prop instance, direct a stickman to a chosen spot, and make a rule fire for either the whole prop or that specific spot only after the interaction completes. Failed reach attempts give useful feedback.
- A child can place more than one Use spot on a prop instance, direct a stickman to a chosen spot, and make a newly authored rule fire for that specific spot only after the interaction completes. Failed reach attempts give useful feedback. Legacy whole-prop rules continue to load and match.
- A meaningful collision produces one readable wobble when enabled; resting contact does not produce continuous wobbling, and stopping PLAY clears the effect.
- A saved scene group can be moved together in EDIT, opened to edit a member, and ungrouped. Members behave independently in PLAY and return to their authored arrangement on reset.
- Older scenes and reusable `.prop` assets continue to open with sensible behavior. A child does not have to re-author a scene to use the new controls.
@@ -72,7 +72,8 @@ Deferred: dynamic Y sorting by character and prop ground points; built-in Use ef
- `scripts/characters/stickman/stickman_prop_interactor.gd` handles pickup, Drop, Throw, and Set Down. It clears collisions while a prop is held and applies its authored release profile afterward. Set Down previews and runtime checks share the target solver; complex queued/rule sequences and changed runtime conditions remain useful regression cases.
- `scripts/characters/stickman/stickman_locomotion.gd` and `scripts/stage/stage_physics_policy.gd` include actor-blocking prop collision bits and movement sweeps. Props set to block stickmen are not walkable platforms; terrain remains the movement surface. Keep obstruction, pause/reset, and ragdoll interactions in the regression checklist.
- `scripts/stage/stage_selection.gd` provides `hit_test_all()` and the stage shows an **Objects here** chooser for overlapping candidates. The current hit test uses world-space AABBs rather than exact vector-shape containment, so the chooser may include an object when the pointer is inside its bounds but outside its artwork.
- Props use fixed behind/in-front draw tiers. Dynamic ground-point Y sorting remains deferred. `scripts/props/prop_anchor_data.gd` continues to describe reusable grip anchors; 17b Use spots will be placed-instance data and must remain distinct from those grips.
- Props use fixed behind/in-front draw tiers. Dynamic ground-point Y sorting remains deferred. `scripts/props/prop_anchor_data.gd` continues to describe reusable grip anchors; 17b Use spots are placed-instance data and remain distinct from those grips.
- Phase 17b implementation is present. The architecture and UI contracts are documented in [Use spots: architecture](../specs/phase_17b_use_spots.md) and [Use spots: UI/UX](../specs/phase_17b_use_spots_ui.md). New Used prop rules choose one named spot; legacy whole-prop rules remain compatible. In-editor user verification is pending.
- Stage collision events remain contact based. Phase 17c still needs a dependable hit-strength rule for a moving stickman striking a stationary prop without causing resting-contact jitter or requiring a child-facing force slider.
- Stage multiselection can move several objects together in one edit, but it does not save persistent group membership. Phase 17d must preserve independent member identities and prop-targeted actions when adding saved groups.
- Relevant prior context: [Phase 11 prop interactions](../specs/phase_11_prop_interactions.md) and the live [feature backlog](../ideas/feature_backlog.md).
@@ -83,6 +84,5 @@ Deferred: dynamic Y sorting by character and prop ground points; built-in Use ef
The following design questions remain for the deferred parts of Phase 17:
- **17b — Use spots:** How should EDIT show and target multiple instance spots without confusing them with reusable grip anchors, and how should DIRECT explain an unreachable spot?
- **17c — Impact wobble:** What impact measure and visual treatment make a wobble consistent across stationary props, moving props, and stickmen without repeated jitter?
- **17d — Scene groups:** What EDIT gesture makes it clear whether the child is selecting a whole group or one member? How should group membership be shown without covering the set artwork?
+177
View File
@@ -0,0 +1,177 @@
# Phase 17b — Use spots: architecture and implementation specification
> **Status:** Approved design; implementation is present and awaiting in-editor user verification.
> **Product/UI contract:** [Phase 17 plan](../plans/phase_17_props_for_storytelling.md) and [approved Use spots UI/UX specification](phase_17b_use_spots_ui.md).
> **Scope:** Placed-prop Use spots, a queued Use action, a completed-use rule trigger, and their EDIT/DIRECT/PLAY presentation in the Sandbox Stage.
## Decisions and boundaries
1. **Empty hands are required.** A stickman carrying a prop cannot begin Use. Using another prop while carrying is future work. A target currently held or reserved by any stickman is also unavailable for Use.
2. **Target physics remains normal.** A moving target is never frozen, teleported, or reserved for Use. Its local spot follows its live transform. The approach may be recalculated once within a bounded displacement; continued movement or an unreachable new position fails with a visible reason.
3. **Grounded locomotion only.** Use may walk, turn, reach, and crouch. Automatic jumping and climbing are future work.
4. Use does not move the target prop, fire an invented built-in effect, or treat a pickup grip as a Use spot. Only the successful touch emits `prop_used`; rules provide the story consequence. The reusable `.prop` asset schema and `PropAnchorData` remain unchanged.
### Shape of the design
```mermaid
flowchart LR
Card[StagePropSettingsView] -->|Add, rename, move, erase| Stage[SandboxStage]
Stage -->|validated edit| Prop[PropBlock instance spots]
Stage -->|save/load| Adapter[StageSceneAdapter + StageSceneIO 1.3]
Editor[ActionEditor + RuleEditor] -->|draft target| Capture[StageTargetCaptureSession]
Capture -->|preview| Solver[StickmanUseReachSolver]
Runner[StickmanActionRunner] --> Interactor[StickmanPropInteractor Use phases]
Interactor --> Solver
Interactor -->|completed prop + spot ID| Event[SandboxStage event dispatch]
Event --> Matcher[StageRuleEngine prop_used matcher]
```
`SandboxStage` remains the coordinator. Small data, overlay, and reach helpers carry their own logic; none becomes a global event bus or a new prop physics body.
## File responsibilities and typed contracts
| File | Responsibility and intended API |
| --- | --- |
| `scripts/props/prop_use_spot_data.gd` (new), `class_name PropUseSpotData extends RefCounted` | Pure schema helper: `static func validate_saved(raw_spots: Variant, raw_next: Variant) -> String`, `static func decode(raw_spots: Array) -> Array[Dictionary]`, `static func encode(spots: Array[Dictionary]) -> Array[Dictionary]`, `static func fallback_name(number: int) -> String`, `static func find(spots: Array[Dictionary], spot_id: String) -> Dictionary`. No scene-tree or physics access. |
| `scripts/props/prop_block.gd`, `class_name PropBlock extends RigidBody2D` | Own `use_spots: Array[Dictionary]` and `next_use_spot_number: int`; expose `get_use_spots() -> Array[Dictionary]`, `get_use_spot(id: String) -> Dictionary`, `get_use_spot_local_bounds() -> Rect2`, `add_use_spot(local_position: Vector2) -> String`, `rename_use_spot(id: String, name: String) -> bool`, `move_use_spot(id: String, local_position: Vector2) -> bool`, `erase_use_spot(id: String) -> bool`, `use_spot_world_position(id: String) -> Vector2`, and EDIT-only `use_spot_local_from_world(world_position: Vector2) -> Vector2`. Return copies from getters. No grip-anchor mutation. |
| `scripts/editors/stage/stage_prop_settings_view.gd`, `class_name StagePropSettingsView extends Control` | Append the UI-spec Use spots section to the existing `StageInspectorPanel` body. Emit `spot_add_requested()`, `spot_move_requested(spot_id: String)`, `spot_rename_requested(spot_id: String, name: String)`, `spot_erase_requested(spot_id: String)`, and `spot_selected(spot_id: String)`. Update through `show_prop`/a dedicated `sync_spots`, without writing to `PropBlock`. Keep multi-selection notice and dismissed-card behavior. |
| `scripts/editors/stage/stage_use_spot_overlay.gd`, `class_name StageUseSpotOverlay extends Control` | Screen-space numbered pins and name labels, EDIT cursor ghost, per-spot reach status, success pulse, and failure cue. Exposes `show_edit(...)`, `show_targeting(...)`, `show_success(prop, spot_id)`, `show_failure(prop, spot_id, reason)`, `hit_spots(screen_position)`, `closest_hit_spot(screen_position)`, `clear_session()`, and `set_paused(paused)`. It draws cached preview status beside the pin without approach or hand-path lines; it does not own spot data or run reach physics queries. Overlapping pin hits are disambiguated by the stage popup. |
| `scripts/editors/stage/stage_use_target_capture_bar.gd`, `class_name StageUseTargetCaptureBar extends PanelContainer` | DIRECT Use and Use-related rule capture prompt and actions. `set_capture_state(prop_selected, prop_has_use_spots)` switches between prop, spot, and no-spots prompts; `hide_capture()` closes it. Emits `change_prop_requested` and `cancel_requested`; it does not commit a target. |
| `scripts/editors/stage/stage_target_capture_session.gd`, `class_name StageTargetCaptureSession extends RefCounted` | Owns the active `USE_PROP` / `USE_SPOT` and EDIT `SPOT_ADD` / `SPOT_MOVE` capture states, provisional prop/spot IDs, actor/context, queue prefix, and cached preview. `begin_use_target(...)`, `select_use_target_prop(...)`, `reset_use_target_prop()`, `begin_spot_edit(...)`, and `finish()` maintain the single-capture invariant. Rule capture uses the same Use-target capture context; there is no separate `USED_RULE_PROP` kind. |
| `scripts/characters/stickman/stickman_use_reach_solver.gd` (new), `class_name StickmanUseReachSolver extends RefCounted` | One nonmutating `solve(rig: StickmanRig, prop: PropBlock, spot_id: String, actor_root: Vector2, predicted_facing_left: bool, options: Dictionary = {}) -> Dictionary`. It uses rig arm geometry, grounded terrain probes, route/body sweep, and current prop transform. Both DIRECT preview and PLAY call it. Direct-space physics calls occur only in `_physics_process`/rig physics tick; UI draws cached results. |
| `scripts/characters/stickman/stickman_prop_interactor.gd`, existing rig scene, `scripts/characters/stickman/stickman_rig.gd`, `scripts/characters/stickman/stickman_action_runner.gd` | Add explicit `USE_APPROACHING`, `USE_REACHING`, `USE_RECOVERING` phases under the existing one-action/one-hand ownership boundary. The interactor owns marker restoration, one replan count, completion and failure. Rig adds `preview_prop_use(...) -> Dictionary`; runner includes `use_prop` in the existing `PROP_INTERACTION` path and waits for `action_completed`. The existing interactor node path stays intact. |
| `scripts/stage/action_registry.gd`, `scripts/stage/trigger_registry.gd`, `scripts/stage/stage_rule_engine.gd` | Register `use_prop` and `prop_used`; implement flat/rule conversion and summaries, explicit event matching, and only-success dispatch. A failed Use still safely completes its queue slot, but emits no `prop_used` event. |
| `scripts/editors/stage/action_editor.gd`, `stage_action_queue_controller.gd`, `rule_editor.gd`, `stage_rule_authoring_controller.gd`, `queue_panel.gd`, `rule_panel.gd`, `sandbox_stage.gd` | Wire Use through the action menus, editors, capture callback, queue/rule lists, summaries, and repair state. Keep editor drafts separate from authored queue/rules until Done. Named spot labels are drawn on pins; overlapping hit regions open a named-choice popup. |
| `scripts/editors/stage/stage_scene_adapter.gd`, `scripts/stage/stage_scene_io.gd`, `scripts/editors/stage/stage_run_session.gd` | Collect/apply per-instance spots; migrate/validate scene v1.3; preserve missing Use references; clear Use animation/pulse on STOP while authored spots survive reset. |
| `scripts/stage/sandbox_theme.gd`, `sandbox_theme.json` | Add `stage_panels.spots` and Use preview tokens in defaults and JSON. The UI spec owns exact copy and minimum target sizes. |
`StageInspectorPanel` provides the fixed header, body scroll, safe fit, close, and action-button styling. `StagePanelToggleRow` and `StagePanelSwitchVisual` remain in the role section only. New `prop_used` rules target one named spot; the authoring flow has no **Any spot on this prop** option. Saved `spot_scope = "any"` rules remain readable and match any successfully used spot on their referenced prop for compatibility. Rule and action dialogs use `SandboxTheme.control_theme()` directly; they do not nest a `StageInspectorPanel`.
## Spot schema, identity, and coordinate contract
An in-memory spot is `{ "id": String, "number": int, "name": String, "position": Vector2 }`. `id` is `spot_<number>`; `number` is positive and unique within one placed prop. `next_use_spot_number` begins at 1, increments only on successful Add, and remains greater than every existing number. Erase never reduces it. Blank trimmed names normalize to `Spot <number>`; duplicate names are valid, and the number is always displayed. Names are presentation only; queue/rule references use IDs.
Scene version **1.3** adds the following keys to each real prop entry, with no `.prop` asset change:
```json
{
"file_id": "prop_1",
"type": "prop",
"stage_object_id": "prop_unchanging_instance_id",
"use_spots": [
{"id": "spot_1", "number": 1, "name": "Button", "position": {"x": 24.0, "y": -18.0}}
],
"next_use_spot_number": 2
}
```
`StageSceneIO.FILE_VERSION` becomes `"1.3"`. Migrate 1.0/1.1/1.2 through the existing 1.2 role/depth migration, then set missing `use_spots: []` and `next_use_spot_number: 1`. Current 1.3 documents lacking the additive keys receive the same defaults. A **present** malformed collection, duplicate/noncanonical ID or number, nonfinite point, or counter not greater than the highest stored number fails root validation before replacing the loaded world. Decode saved `{x,y}` dictionaries with `data_to_vector`, never by assigning dictionaries to typed vector arrays. Missing-prop placeholders retain their original entry, including spots and counter, through resave. `ContentPaths.canonicalize` continues to handle only path fields.
`file_id` is regenerated on collection; it is not a persistent prop identity. `stage_object_id` survives save/load and is the action/rule prop key. A second placement of the same `.prop` begins with no Use spots and its own counter. If a future placement-copy command is added, it must allocate a new prop ID while deep-copying spots and preserving their local IDs within that new prop.
Coordinates are authored in **unscaled prop asset-local space** and follow the prop's translation and rotation. A spot may be in a transparent gap inside the prop artwork bounds. `get_use_spot_local_bounds()` returns `get_visual_local_bounds()` for custom layered props, a radius rect for built-in circles, and the point AABB for built-in polygons; `get_visual_local_bounds()` alone is empty for the built-in primitives. On EDIT placement, first check that the prop transform is finite and invertible; then use `prop.to_local(world_position)` and those local bounds. During EDIT, root `global_transform` already contains the authored scale. During PLAY, `PropBlock.begin_play_instance_scale()` moves the signed authored scale into children and resets root scale. Thus the sole world conversion is:
```gdscript
func use_spot_world_position(spot_id: String) -> Vector2:
var spot: Dictionary = get_use_spot(spot_id)
if spot.is_empty(): return Vector2.INF
var local_point: Vector2 = spot["position"]
var baked_scale: Vector2 = _play_instance_scale if _play_scale_active else Vector2.ONE
return global_transform * (local_point * baked_scale)
```
Use the **signed** `_play_instance_scale`; `get_interaction_scale()` currently returns an absolute value for pickup grips and would lose mirrored spot positions. No caller multiplies by root scale again. Reset restores root authored scale through the existing `StageRunSession` path. Overlay hit regions convert the resulting world point to viewport pixels and maintain a minimum 44 × 44 screen-pixel target at any camera zoom.
## Authoring and capture transactions
EDIT Add/Move starts only for one selected, live `PropBlock`. The capture stores its `stage_object_id`, mode, and (for Move) existing spot ID; it does not mutate the spot collection while following the cursor. A click is accepted only inside that prop's visual-local bounds after inverse transform. The selected prop remains visible; clicking elsewhere leaves the session active. A valid click commits one Add/Move, marks the scene dirty, refreshes the card/pins and summaries, and ends capture. Esc, right-click, visible Cancel, prop-selection change, or mode change discards the provisional position. Rename commits on Enter/focus exit; blank returns to the same fallback. The row/pin selection affects emphasis and camera reveal only.
Erase shows the UI-spec confirmation, including the reference warning if any queue action, rule action, or specific `prop_used` trigger uses that exact prop/spot pair. On confirmation, erase only the spot. Keep the referring payloads and mark them as needing repair; do not retarget, broaden, or silently remove them. A prop deletion likewise retains Use-specific queue/rule references for repair, while the current behavior for unrelated rule types and deleted actor sources remains in place. Avoid the current `_cleanup_rules_for_nodes()` blanket pruning for a `prop_used` target by representing its target with stable IDs in trigger params.
DIRECT Use target capture is two steps: select a prop through `StageSelection.hit_test_all()` / **Objects here**, then click one of its labeled pins. Overlapping hit regions open a named-choice popup instead of selecting arbitrarily. The persistent capture bar sits below the top toolbar and mode pill; it shows a step-aware prompt, visible **Cancel**, and, after prop selection, **Change prop**. It uses a compact dark rounded panel with a rounded-edge DIRECT amber accent; its shared visual contract is in the [Stage panel UI system spec](stage_panel_ui_system.md). A known-invalid target leaves capture active. An uncertain future actor state yields the UI-spec amber cue and permits a structurally valid target; PLAY checks it again. Choosing another prop clears only the provisional spot. Cancel restores the invoking editor and its prior draft. A DIRECT-mode selection queues the Use action immediately; a selection made inside an editor updates its draft, which is committed through that editor's Done flow.
Rule capture selects the acting stickman, a prop with spots, and one specific spot ID. Rule capture does **not** run the reach solver. Changing prop clears the provisional spot so the child must choose a spot on the replacement prop. Existing saved rules with `spot_scope = "any"` remain supported for compatibility, but new rules are authored for one named spot and expose no Any-spot authoring button. A missing saved reference appears as **Choose a new spot** and cannot be recommitted until repaired.
The current exclusive-popup host hide/restore convention applies to all new captures and their Cancel/resolve paths; see existing debt #19. The capture bar keeps a visible Cancel control available in both stages of action and rule targeting. The separate EDIT Add/Move spot-placement flow retains its existing stage instruction and Cancel control; the shared capture-bar pattern is the approved standard for future EDIT/DIRECT capture flows, as recorded in the [Stage panel UI system spec](stage_panel_ui_system.md) and [AGENTS.md](../../AGENTS.md).
## Action, rule, and event wire contracts
Flat in-memory queue action:
```gdscript
{"type": "use_prop", "target_prop_id": "prop_...", "target_spot_id": "spot_2"}
```
`StageSceneIO.encode_queue/decode_queue` saves the same strings, validating nonempty IDs and a known action type. It must **not** require the referenced prop/spot to exist during decode; the reference may be awaiting repair. `ActionRegistry.to_rule_action/from_rule_action` preserves both keys in the nested `params` of a rule consequence, and `_encode_rule_action/_decode_rule_action` use the same flat codec. `ActionRegistry.summarize()` provides a safe fallback, while Stage-owned presentation resolves live names and replaces missing ones with **Choose a new spot**. Queue list, action editor, rule consequence list, and rule summary refresh after rename/erase/load.
`prop_used` trigger in memory and on disk:
```gdscript
{
"type": "prop_used",
"source": actor_instance_id,
"target": -1,
"params": {
"target_prop_id": "prop_...",
"spot_scope": "specific", # or "any"
"target_spot_id": "spot_2" # omitted for "any"
}
}
```
The saved rule uses the existing mapped `source_id` and null `target_id`; `target_prop_id` remains the stable string. Its matcher **must require** `target_prop_id` equality with the event's live `PropBlock.stage_object_id` even though `target == -1`; otherwise the current wildcard semantics could make a missing prop match every prop. For `specific`, require exact nonempty `target_spot_id` equality; `any` matches every successfully touched spot on that one prop. Validate the enum and conditional key in `StageSceneIO._encode_trigger/_decode_trigger`. Missing target objects/spots remain structurally valid saved references. Do not resolve them through `_unmap_ref`, which currently drops missing instance-ID rule targets. Existing source actor mapping and deletion semantics remain intact.
At verified touch the interactor emits a **separate typed** signal, `use_completed(prop: PropBlock, spot_id: String, world_position: Vector2)`. `StickmanRig` forwards it; `SandboxStage` calls `_handle_event({"type": "prop_used", "source": rig, "target": prop, "spot_id": spot_id, "position": world_position, "action": ...})`, displays the pulse, then lets `StageRuleEngine` match rules. Keep the existing three-argument `prop_event` signal for pickup/drop/throw/Set Down so its consumers need no ambiguous reason/spot overloading. Failure emits `use_failed(reason_code: String, prop: PropBlock, spot_id: String)` for a local cue, with no `prop_used` or pulse. The runner still advances its queue safely; generic `action_finished` retains its existing meaning.
## Shared reach calculation and runtime behavior
The solver returns a typed-key `Dictionary`:
```gdscript
{
"known": true,
"valid": true,
"reason_code": "",
"spot_world": Vector2(420, -160),
"approach_root": Vector2(330, -385),
"approach_feet": Vector2(330, 0),
"facing_left": false,
"hand": StickmanPropInteractor.Hand.RIGHT,
"reach_start": Vector2(355, -210),
"reach_end": Vector2(420, -160)
}
```
Invalid results retain relevant preview geometry and use one of `prop_missing`, `spot_missing`, `hands_full`, `prop_held`, `no_standing_room`, `route_blocked`, `out_of_reach`, or `target_moved`. The UI maps these to its short child-facing strings. A future-position preview returns `known = false` with `reason_code = "earlier_actions"`; it is amber, never green. Only a known invalid result blocks DIRECT commit. Invalid/unknown source actor or nonfinite transforms fail explicitly.
The solver tests the current root first, then grounded standing candidates on both sides of the spot. It derives shoulder-to-wrist reach from live upper/lower arm lengths and rig facing, chooses an available hand only when empty handed, checks terrain support and route through the same navigation/actor-blocking sweeps as `StickmanLocomotion`, and checks standing torso/head clearance. It may choose a crouch pose for a low point. It does not invent a platform on a support prop, move a pinned point, or permit a jump. Hand-path obstruction checks exclude the target prop's own collider; configured blocking geometry along the reach still invalidates a path. Candidate ranking prefers the valid shortest grounded route with a reachable wrist. Validate the actual solved wrist against the live spot with a small contact tolerance at completion; the existing pickup `MAX_REACH = 390` wrist-distance heuristic alone does not prove a touch.
DIRECT prediction receives the current actor and the unconsumed queue prefix from `StageTargetCaptureSession` (generalize `throw_preview_prefix_for` without breaking its current callers). Earlier Move To actions shift the predicted root from their ground target to the rig root; Speak and Wait preserve the predicted position. An earlier Use on an existing stationary prop shifts the predicted root to its known approach when valid, while a known failed Use leaves the actor in place. Active motion, ragdoll/recovery, pickup/drop/throw/set-down interactions, unknown actions, and moving target physics make the prediction conditional. Preview is advisory; runtime uses live positions and repeats the check. Perform physics queries in `_physics_process`, cache the result, and draw it in `_process`/overlay without querying the physics space there.
```text
IDLE ── valid Use ──> USE_APPROACHING ── arrived ──> USE_REACHING
^ │ blocked/moved │ touch verified
│ └─────────────── fail ──────────┤
│ v
└──── restored marker/torso <── USE_RECOVERING <── prop_used once
```
At action start, resolve the stable prop and spot IDs, require empty hands and an animated/grounded actor, and solve. `USE_APPROACHING` walks toward the chosen feet point with normal prop collision policy. Keep the target's live spot world position under observation. If its displacement from the planned position exceeds a named threshold, recompute once from the current actor root and current prop transform; update the approach target. A second meaningful drift, blocked path, or newly invalid solution fails promptly. A small stationary jitter does not spend the replan. `USE_REACHING` drives one free IK hand marker toward the **current** spot; if it drifts beyond a separate contact tolerance after the approach, fail rather than teleport the arm. Recheck reach/obstruction and actual wrist contact before emitting success once. `USE_RECOVERING` eases hand/torso/legs back, then emits `action_completed` to the runner. Failures also restore the pose, emit a specific cue once, and finish safely. The target keeps its existing freeze/velocity/collision profile throughout.
Pause stops the rig physics tick and procedural Use timer. The Stage explicitly pauses the overlay's completion-pulse timer too, since a `Control._process` clock otherwise continues while the rig is paused. Resume continues from the same phase, revalidates the live spot, and observes the normal event baseline gate. Takeover/ragdoll cancels Use, restores the saved marker/pose, and emits no success; a completed Use event is never replayed by resume. STOP/reset calls the existing interactor cleanup, cancels the pulse/preview, and restores the authored world through `StageRunSession`. Repeated PLAY runs start with zero Use replan count and no stale marker or success latch.
## Implementation order and verification boundaries
1. Add schema validation/normalization and `PropBlock` local/world APIs; migrate StageSceneIO/StageSceneAdapter to 1.3, including missing-prop placeholders and strict present-key validation.
2. Add action/trigger registry and codec branches, runtime matching, stable-reference repair checks, and name-resolving summaries. Protect `prop_used` from deletion pruning and decode-time loss.
3. Add shared reach solver and interactor phases/signals; wire runner, rig facade, stage event dispatch, pause/reset cleanup, and failure reasons.
4. Extend the existing prop card and Stage UI theme, then build EDIT and DIRECT capture/overlay/dialog flows, overlap choices, preview, and keyboard list alternatives.
5. Review the completed implementation against the UI acceptance criteria in the editor. This document records the intended and current interaction contract; it does not claim that every visual or runtime behavior has been user-verified. Automated results, where available, are maintained separately in the architecture log.
## Trade-offs and open debt
- Persistent spot IDs on the placed `PropBlock` fit the current JSON scene model. New `Resource` files or child `Marker2D` nodes per spot would add scene-tree/persistence machinery without improving this small authored collection.
- `prop_used` uses stable prop identity in its params while keeping actor source mapping in the existing rule system. This targeted exception preserves missing references without changing every rule type; broader session-ID limitations remain tracked in architecture debt #24.
- Existing popup host visibility, broad AABB object targeting, and the large root stage controller remain tracked under debt #19, #12, and #67. This phase reuses their boundaries and adds no duplicate issue for them.
- Preview cannot simulate every earlier rule or moving rigid body. It reports uncertainty and rechecks in PLAY; a future predictive timeline can improve this without changing saved action targets. This distinct limitation is logged as debt #68; existing throw-prefix behavior remains tracked by resolved #52.
+109
View File
@@ -0,0 +1,109 @@
# Phase 17b — Use spots: UI/UX specification
> **Status:** Approved design; the core UI implementation is present and awaiting in-editor user verification. New rule authoring chooses one named Use spot; saved `spot_scope = "any"` rules remain supported for compatibility, with no new Any-spot authoring button.
> **Source:** [Phase 17 — Props for Storytelling](../plans/phase_17_props_for_storytelling.md).
> **Scope:** Sandbox Stage EDIT, DIRECT, and PLAY presentation for Use spots on placed prop instances. This document defines the child-facing interaction, not the saved-data or reach-solver contracts.
## Child flow
1. In **EDIT**, the child selects one placed prop. The existing right-side card keeps the prop name in its fixed header. A new **Use spots** section explains **Stickmen can touch these places without picking up the prop.** The child presses **+ Add a spot**, moves a hand-shaped preview over the chosen handle, button, or lever, and clicks. The placed pin appears on this copy of the prop. The child may name it, move it, or erase it from a large row in the card.
2. In **DIRECT**, the child chooses a stickman and **Use…** from the same action entry points that offer Pick Up and Set Down. A capture bar below the top toolbar asks the child to choose a prop, then a spot on that prop. Hovering a spot shows its reach result beside the pin. A valid click records the exact prop and spot in the action; **Change spot** lets the child revise it. A blocked choice stays on screen with a short reason at the spot.
3. In **PLAY**, a completed reach gives that spot a short visible pulse while the prop remains in place. A **Used prop** rule can react to one named spot. An attempted interaction that cannot finish shows a brief reason near the target and does not show the success pulse.
## EDIT card and control layout
```text
Existing StageInspectorPanel
├─ Fixed header: prop icon / selected prop name / ✕ Close
└─ ScrollContainer / VBoxContainer
├─ Existing prop role controls
└─ Use spots section: VBoxContainer
├─ Heading / helper / empty-state label
├─ + Add a spot: Button
└─ Spot rows: VBoxContainer
└─ Number + Spot name: LineEdit / Move + Erase: FlowContainer
Stage overlay
├─ Visible selected-prop pins with number and name labels, plus ghost pin in EDIT
└─ Reach status beside the spot in DIRECT; no predicted approach or hand-path lines
DIRECT Use capture bar
├─ Step-aware prompt below the top toolbar and mode pill
└─ Change prop (after prop selection) + ✕ Cancel
```
- Extend the existing selected-prop `StageInspectorPanel` body after the prop role controls. Keep its icon, selected prop name, fixed **✕ Close**, body-only scroll, safe-rectangle fitting, dismissal behavior, and compact **Settings** reopen button. The card applies to a single selected placed prop. With multiple objects selected, keep the current **Select one prop to change its settings.** notice; do not add spots to all selections.
- Use a styled **Use spots** heading, one short helper sentence, a full-width **+ Add a spot** button, and a `VBoxContainer` of spot rows. The empty state says **No Use spots yet. Add one where a stickman should reach.** Rows show a stable number, the spot's editable name, and **Move** and **Erase** buttons. Each control has a pointer target of at least 44 × 44 logical pixels and uses the greater of that size and the applicable theme `button_min_height`. On a narrow card or larger font, the row wraps into a `VBoxContainer` with actions in a `FlowContainer`; it never clips the name or makes buttons smaller.
- New names are **Spot 1**, **Spot 2**, and so on until edited. The child can enter a short name such as **Button** or **Lever**. A blank edited name returns to its existing fallback; duplicate names are allowed but every row and pin retains a visible number so identical names remain distinguishable. Existing numbers stay with their spots when another spot is erased. The name field has the visible label **Spot name** for keyboard and assistive use; pressing Enter or leaving the field saves the edit. Name changes update action and rule summaries without changing which spot they target.
- Selecting a row emphasizes only that pin on the stage and scrolls or pans only enough to reveal it if off screen; it does not silently move the prop. The row's selected border and the pin's number match. The selected-row treatment follows `stage_panels.choice` states; the row's actions follow `stage_panels.action`. A spot row is an individual placed-instance control, not a reusable `.prop` asset setting.
- **+ Add a spot** and **Move** enter a focused stage-placement workflow. Keep the selected prop highlighted, show a cursor-attached pill (**Click a place on this prop** or **Click the spot's new place**), show a numbered pin ghost at the pointer, and provide a prominent **✕ Cancel** control. A valid click commits one change and returns to the card. Clicking outside the selected prop gives **Choose a place on this prop** next to the cursor and keeps placement active. Cancel, Esc, or right-click returns to the card; canceling a move keeps the old position. A spot may sit over a transparent gap within the prop's artwork bounds when it represents a handle; the visible preview and selected prop outline make the intended owner clear. The current EDIT flow uses its existing placement prompt and Cancel control; for future EDIT capture work, use the shared capture-bar pattern in the [Stage panel UI system spec](stage_panel_ui_system.md), also referenced by [AGENTS.md](../../AGENTS.md).
- **Erase** opens a themed confirmation with the spot's name and number: **Erase Button (spot 2)?** with **Keep spot** and **Erase spot** buttons. If an action or rule names that spot, the dialog also says **This spot is used in your story. Those steps will need a new spot.** After a confirmed erase, affected action and rule editors display **Choose a new spot** instead of silently using another spot or the whole prop. An edited draft with a missing spot cannot be committed; PLAY reports the missing target without firing **Used prop**. Canceling the dialog leaves the spot and references intact.
- Moving, naming, and erasing belong to the selected placement and mark the scene as changed. Moving or rotating the prop carries its pins with it. A prop-selection or mode change cancels an active Add/Move preview without committing it. The existing **Objects here** chooser remains available to select a prop covered by another object; once selected, its card rows also provide a reliable way to reach obscured pins.
## Pin appearance and hit targets
- A pin is a compact high-contrast outlined circle with its stable number and an adjacent labeled pill showing the spot name. Its pointer and touch hit region is at least 44 × 44 logical pixels at every stage zoom. Pin and label are both targetable. Color and number/text both carry meaning; do not depend on color alone.
- EDIT pins appear for the selected prop and during Add/Move, then disappear when selection moves elsewhere. DIRECT pins appear only for the prop currently being targeted by a Use action or Used prop rule. PLAY hides authored pins except for the brief completion pulse and a short failure cue. This keeps the movie view clean and distinguishes Use spots from pickup grips.
- Pin drawing sits above selected prop artwork and remains visible if a stickman or front-tier prop overlaps it. A pin has its own hit priority during the focused Use workflow; otherwise normal stage selection and the **Objects here** chooser continue to work. When nearby 44 px hit regions overlap, present named choices rather than guessing. The pin labels are the normal spot-selection affordance; do not add a second always-visible spot picker beside the prop.
- Reusable pickup grip anchors are not shown or edited in this section. Use pins use a numbered outlined circle and adjacent name label; grip anchors keep their existing purpose and appearance. Child-facing copy says **Use spot** and **Pick up** in their respective workflows, never **anchor** or **grip**.
## DIRECT Use action and feedback
- Add **Use…** alongside the existing prop actions in the stickman action menu and Action Editor. In the editor, provide a large **Choose prop and spot** button; after selection, show **Using: Button (spot 2) on Door** with **Change spot** and the existing **Done** / **✕ Cancel** pattern. A new Use action cannot be committed without both a valid prop and an exact spot. Canceling a change to an existing action preserves its previous target.
- Stage capture uses the persistent `StageUseTargetCaptureBar` below the top toolbar and **DIRECTING** pill. It starts with **Choose a prop to use** and **✕ Cancel**. After prop selection, the prompt becomes **Choose a spot on the prop**, with **Change prop** and **✕ Cancel**. If the prop has no spots, the prompt explains **This prop has no Use spots. Add one in EDIT.** The compact dark rounded panel uses a DIRECT amber accent that follows the rounded left edge; its shared visual contract is in the [Stage panel UI system spec](stage_panel_ui_system.md). Changing the prop returns to the first step and preserves the action/rule draft. If the prop is covered, the existing **Objects here** chooser offers prop candidates.
- Hovering a pin shows its reach result beside the spot, without approach or hand-path lines. The stickman remains in its authored position, so a line from a future pose would appear detached. Use DIRECT amber for capture, a green check plus **Ready to use** for a reachable preview, and a red warning plus a specific message at the pin for a known problem, such as **Can't walk there**, **Too far to reach**, or **No room to stand**. Validity uses a glyph and text as well as color.
- A known invalid pin cannot be committed; the child can select another pin or change the prop without losing the action draft. If prior queued actions make the future actor position uncertain, show one amber **?** beside the spot with **We'll check when you play.** and no guide lines. Allow the target to be recorded; PLAY rechecks and reports any failure. The preview is an aid, not a promise that later moving props or changed actions cannot affect the reach.
- The capture bar's visible **✕ Cancel** button is available throughout both capture steps, including rule targeting; **Change prop** appears after a prop is selected. Esc and right-click are optional shortcuts. Cancel returns to the invoking action or rule editor. When editing an existing target, the earlier target remains until a valid replacement is chosen and committed.
## Used prop rule authoring
- Add **Used prop** to the rule trigger choices. The rule builder first shows the acting stickman, then asks the child to choose a prop and one of its labeled Use spots. New rules always name one specific spot; there is no **Any spot on this prop** authoring button. The rule responds only after that Use reach completes. Renaming a spot updates the summary. A missing or erased referenced spot shows **Choose a new spot** in the rule editor and a visible needs-attention cue in the rules list; it never silently broadens the rule to another spot. Saved `spot_scope = "any"` rules remain supported for compatibility and are not offered as a new choice.
- Draw rule links with a quiet gray dashed connector, a green ⚡ trigger badge, and a purple → action badge. This keeps rule links visually distinct from Use reach previews and leaves amber for uncertain reach.
- Selecting a prop with no spots shows **This prop has no Use spots. Add one in EDIT.** and leaves rule target selection active. Rule spot choices do not run reach validation: they describe an event, rather than command a reach. The child can use **Change prop** to replace the target.
- Rule dialogs remain popup workflows and receive `SandboxTheme.control_theme()` explicitly. Their layout may reuse the shared choice visuals and theme tokens where suitable, but the in-stage `StageInspectorPanel` shell is not imposed on a modal editor.
## Theme and accessibility
- Reuse `StageInspectorPanel` for card shell, header, scroll, and action styling. `StagePanelToggleRow` and `StagePanelSwitchVisual` are not needed because Use spots are a collection of named points, not On/Off settings. Extend `stage_panels` tokens for spot row spacing, pin size/hit radius, pin colors, hover/focus rings, name pill, and completion pulse. Keep shared shell, type, and action tokens as the source for sizes, fonts, and surfaces. Use `SandboxTheme` `mode_colors.edit` / `mode_colors.direct` and overlay valid/invalid colors; add named tokens for any new visual state in both the JSON contract and defaults during implementation.
- All card controls, action/rule choices, overlap choices, confirmation buttons, and active Cancel controls meet the 44 × 44 logical-pixel minimum and applicable `button_min_height`. Dynamic rows wrap or scroll at larger fonts. Dialogs and popup windows explicitly use `SandboxTheme.control_theme()`.
- Keyboard users can Tab through Add, name, Move, Erase, Cancel, and Done. Space/Enter activates a focused button; Enter commits a name. A selected card row can invoke **Move** without drag and drop. Focus remains visible over selected styling. Overlap-choice popups remain keyboard-operable. A separate always-visible spot list is not part of the stage targeting UI; if keyboard-only stage target capture is required, it remains a follow-up accessibility gap.
- If a prop or spot disappears before PLAY or while an action is queued, show **Prop is gone** or **Use spot is gone** at the action/actor and mark its queue entry for repair. No completion pulse or **Used prop** rule fires. If reach fails during PLAY, show a short reason at the prop; the child can return to DIRECT and use **Change spot**. Stopping PLAY restores the authored pin presentation and positions.
- The completion cue is a short ring expanding from the used spot, fading after about 0.6 seconds. It signals the completed touch without suggesting an invented door, switch, or light effect. It follows the spot if the prop is moving, does not cover the movie with labels, and can repeat for a later successful interaction. Respect the project's reduced-animation behavior if provided; a brief static highlight can carry the same cue.
### Proposed theme extensions
| Token group | Values to add or reuse |
| --- | --- |
| `stage_panels.spots` | `row_gap`, `pin_visual_size`, `pin_hit_size` (minimum 44), `pin_outline_width`, `focus_ring_width`, `name_pill_padding`, `pin_surface_color`, `pin_outline_color`, `completion_duration` |
| `stage_panels.shell`, `.type`, `.choice`, `.action` | Existing card geometry, section/body/helper fonts, selected/hover/focus surfaces, row and button minima; no copied parallel panel styles |
| `mode_colors.edit`, `mode_colors.direct`, `overlays.valid`, `overlays.invalid` | Existing mode accents and preview state colors; neutral/uncertain state derives from a named Use-preview token if no existing token fits |
The architecture spec may split the new token group into shared row and Use-preview subgroups where another consumer exists. New values must remain theme-owned, including fallback values.
## Exact copy map
| Context | Child-facing copy |
| --- | --- |
| Card section | **Use spots** · **Stickmen can touch these places without picking up the prop.** · **+ Add a spot** |
| Empty card | **No Use spots yet. Add one where a stickman should reach.** |
| Spot row | **Spot name** · **Spot 1** (editable fallback) · **Move** · **Erase** |
| EDIT capture | **Click a place on this prop** · **Click the spot's new place** · **Choose a place on this prop** · **✕ Cancel** |
| Erase dialog | **Erase Button (spot 2)?** · **This spot is used in your story. Those steps will need a new spot.** · **Keep spot** · **Erase spot** |
| DIRECT action/editor | **Use…** · **Choose prop and spot** · **Using: Button (spot 2) on Door** · **Change spot** · **Done** · **✕ Cancel** |
| DIRECT capture bar | **Choose a prop to use** · **Choose a spot on the prop** · **Change prop** · **✕ Cancel** · **This prop has no Use spots. Add one in EDIT.** |
| Reach feedback | **Ready to use** · **Can't walk there** · **Too far to reach** · **No room to stand** · **We'll check when you play.** |
| Rule editor | **Used prop** · **Choose prop** · **Choose a labeled spot** · **Choose a new spot** |
| PLAY failure | **Prop is gone** · **Use spot is gone** · **Can't reach this spot** |
## Acceptance criteria for UI review
1. A child can add, name, move, erase, save, reload, and find at least two Use spots on one placed prop; a second copy of the same asset has independent spots. Moving the prop keeps its pins aligned with the artwork. Pickup grip editing and `.prop` asset controls are unchanged.
2. The selected prop card remains usable on short or narrow stages and with enlarged fonts. Its header and Close stay visible, every spot row and action is reachable, and a covered prop's spots can be selected through **Objects here** and the card list.
3. Add/Move capture shows the correct selected owner, a cursor ghost, a nearby instruction, and a visible Cancel. Invalid clicks leave the draft active; canceling Move restores the prior location. A visually compact pin still has a 44 × 44 hit target across zoom levels.
4. A DIRECT Use action can target either of two named spots on the same prop. The per-spot preview identifies known blocked choices beside the target without drawing detached approach/reach paths, and lets the child correct them. Action Editor and stickman action menu agree on the target and support changing it.
5. A **Used prop** rule names one specific spot and fires only after a completed interaction. A successful PLAY reach has a brief pulse; failures give local feedback and do not imitate completion.
6. Deleting a referenced spot never silently retargets an action or broadens a rule. Repair prompts identify the missing target. Selection, focus, and valid/invalid states remain understandable without color alone, and pointer, touch, and keyboard users can complete the workflow.
## Implementation and verification status
- The core implementation is present: instance pins and labels, EDIT Add/Move capture, the DIRECT Use capture bar, per-spot preview status, Use actions, named-spot rule targeting, runtime completion/failure cues, and saved-scene persistence. The capture bar currently serves DIRECT Use and Use-related rule capture. EDIT spot placement retains its current instruction and Cancel control; future EDIT capture workflows should follow the shared bar pattern linked above.
- This specification records the approved behavior and current presentation. The presence of implementation does not mean the visual details or runtime behavior have been verified in-editor. User verification remains pending.
+112
View File
@@ -0,0 +1,112 @@
# Phase 17c — Impact wobble: architecture and implementation specification
> **Status:** Architecture specified for final user sign-off; implementation has not begun.
> **Product/UI contract:** [Phase 17 plan](../plans/phase_17_props_for_storytelling.md) and [Impact wobble UI/UX specification](phase_17c_impact_wobble_ui.md).
> **Scope:** A per-placement Wobble setting, visual sample in EDIT, and a short visual response to meaningful physical hits in PLAY.
## Approved decisions
1. **One placed prop owns one On/Off choice.** The setting is independent of depth, starting motion, collision permissions, material preset, and the reusable `.prop` asset. Newly placed and older saved props default to Off. A pass-through prop can keep the choice On even if its current collision permissions give it no qualifying hit.
2. **A hard hit can wobble a fixed or moving prop.** An animated stickman, falling ragdoll body, another prop, or terrain can supply the hit when actual collision policy permits it. If two enabled props hit each other, both wobble once. A blocked stickman's locomotion sweep counts as a hit when it identifies the struck prop and meets the same threshold.
3. **The effect is visual only.** A modest, damped tilt and slight sideways give affect prop artwork and the EDIT selection outline. Root `RigidBody2D` transform, collision geometry, velocity, role, draw tier, and authored placement do not change because of wobble. Collision rules remain their existing contact events; wobble emits no new rule event.
4. **Mass is separate.** Today `PropBlock._apply_material_preset()` sets `mass` through `mass_for()` (None 1.0, Wood 3.0, Rubber 0.5, Cardboard 0.4, Metal 8.0); the Prop Editor displays these read-only values. Phase 17c uses approach speed as its impact strength, so the child's Wobble switch has no mass control or material-dependent threshold. Keep impact scoring behind one helper so future editable mass or measured impulse can be considered without changing this scene boolean or old scenes. Fixed props use a frozen body role; do not treat their preset mass as a finite moving-body mass when deciding whether they can visibly wobble. The exact future mass response remains a later design decision.
## Runtime and data flow
```mermaid
flowchart LR
Switch[StagePanelToggleRow] -->|wobble_toggled bool| Stage[SandboxStage]
Stage -->|author choice + preview| Prop[PropBlock]
Prop --> Visuals[Polygon2D + Outline or Visuals]
Contact[PropBlock collided/separated] --> Stage
Sweep[StickmanLocomotion blocking-prop sweep] --> Stage
Stage --> Resolver[StageImpactResolver]
Resolver -->|qualified pair edge| Prop
Stage --> Adapter[StageSceneAdapter]
Adapter --> IO[StageSceneIO scene v1.4]
Run[StageRunSession] -->|clear transient effect| Prop
```
`StageImpactResolver` owns hit scoring, pair rearming, and suppression of duplicate contacts. `SandboxStage` coordinates scene mode, active prop selection, source signals, and rule dispatch. `PropBlock` owns authored choice and transient artwork motion. The Stage card presents and emits input only.
### Target classes and typed contracts
| File / class | New responsibility and intended typed API |
| --- | --- |
| `scripts/props/prop_block.gd` — `class_name PropBlock extends RigidBody2D` | `var wobble_on_impact: bool = false`; `func set_wobble_on_impact(enabled: bool) -> void`; `func preview_wobble() -> void`; `func play_impact_wobble() -> void`; `func cancel_wobble() -> void`; `func set_wobble_style(duration: float, angle_degrees: float, offset_local: float) -> void`. Only the first value is authored/persisted. Preview works in EDIT even when collisions are disabled; runtime method is called only by Stage after a qualifying hit. Cancel restores visual children's baseline local transforms exactly. |
| `scripts/stage/stage_impact_resolver.gd` (new) — `class_name StageImpactResolver extends RefCounted` | `static func approach_speed(receiver_velocity: Vector2, other_velocity: Vector2, receiver_to_other: Vector2) -> float`; `func begin_contact(sample: Dictionary, physics_tick: int) -> bool`; `func end_contact(receiver: PropBlock, other: Node) -> void`; `func note_sweep(sample: Dictionary, physics_tick: int) -> bool`; `func finish_sweep_tick(physics_tick: int) -> void`; `func reset() -> void`. Returns whether a newly armed pair passes the threshold. `sample` has typed values described below. The helper does not animate or dispatch rules. |
| `scripts/characters/stickman/stickman_locomotion.gd` — `class_name StickmanLocomotion extends Node` | Cache per-proxy position deltas in `sync_collision_bodies()` and expose `func collision_proxy_velocity(body: PhysicsBody2D) -> Vector2`. Add `signal blocking_prop_sweep_hit(prop: PropBlock, normal: Vector2, world_position: Vector2, requested_velocity: Vector2)` for a resolved, blocking shape sweep. Emit a candidate at most once per rig/prop per physics tick; StageImpactResolver handles pair arming. Keep actual movement fraction and blocked-walk behavior intact. |
| `scripts/characters/stickman/stickman_rig.gd` — `class_name StickmanRig extends Node2D` | Forward the locomotion sweep signal with its rig identity, or expose a typed getter to the Stage. Reset proxy velocity baselines on PLAY start, resume, teleport/scene restore, and ragdoll handoff so a single transform jump cannot look like a hit. Existing `get_collision_bodies()` and collision-reaction APIs remain. |
| `scripts/editors/stage/sandbox_stage.gd` — `class_name SandboxStage extends Node2D` | Own one resolver. Route `PropBlock.collided`/`separated` and blocking-sweep candidates to it under the existing PLAY/RUNNING gate; preserve the existing collision-rule path. A qualifying prop-prop pair calls `play_impact_wobble()` on each enabled member once. A qualifying rig/prop pair calls it on the prop. Gate stale callbacks after STOP, clear pair state on PLAY start/STOP and pause/resume, and cancel EDIT sample on selection/mode changes. |
| `scripts/editors/stage/stage_prop_settings_view.gd` — `class_name StagePropSettingsView extends Control` | Add `signal wobble_toggled(enabled: bool)` and a `StagePanelToggleRow` under **When bumped**, before Use spots. `show_prop()` syncs from `prop.wobble_on_impact` with `set_checked_no_signal()`. Keep the existing one-prop and multiple-selection behavior, fixed header, and scroll fit. |
| `scripts/shared/stage_ui/stage_inspector_panel.gd` — `class_name StageInspectorPanel extends PanelContainer`; `stage_panel_toggle_row.gd` — `class_name StagePanelToggleRow extends Button`; `stage_panel_switch_visual.gd` — `class_name StagePanelSwitchVisual extends Control` | Reuse existing shell, full-row button, drawn switch, ON/OFF label, focus, and wrapped help. Extend only if a concrete layout defect appears. `StagePanelChoiceGroup` remains for depth and starting motion; wobble is one independent switch. The UI emits no role-dictionary write. |
| `scripts/stage/stage_selection.gd` — `class_name StageSelection extends RefCounted` | In the basic-polygon AABB branch, transform each point through the `Polygon2D` child (`poly.to_global(point)`), since the current root transform misses a local artwork wobble. The custom `Visuals` branch already walks each visual node's global transform. `StageGizmos` keeps its existing per-frame selection drawing; no separate outline animation state is saved. |
| `scripts/editors/stage/stage_scene_adapter.gd` — `class_name StageSceneAdapter extends RefCounted`; `scripts/stage/stage_scene_io.gd` — `class_name StageSceneIO extends RefCounted` | Write/read the per-prop `wobble_on_impact: bool`; bump `.scene` version from 1.3 to 1.4, accept/migrate 1.0–1.3, default absent values to false, and reject a present non-boolean value during pre-load validation. Preserve original entries on missing-prop placeholders. `.prop` schema stays unchanged. |
| `scripts/editors/stage/stage_run_session.gd` — `class_name StageRunSession extends RefCounted` | Snapshot the authored boolean with prop state and restore it on STOP; cancel transient wobble before/while returning the prop to EDIT. Pause freezes the effect clock; resume continues it without a jump. Starting PLAY cancels any EDIT sample before root-scale transfer. |
| `scripts/stage/sandbox_theme.gd` — `class_name SandboxTheme extends RefCounted`; `sandbox_theme.json` | Reuse `stage_panels.shell`, `.type`, `.toggle`, and `.action` plus `mode_colors.edit`. Add matching visual-effect tokens under `effects.impact_wobble` in defaults and JSON for sample duration, maximum angle, and sideways offset. Keep hit threshold/cooldown as resolver constants, since they control physics interpretation rather than presentation. |
### Impact sample and score
The resolver receives one dictionary per candidate. Validate and normalize its contents at the boundary; never persist it:
```gdscript
var sample: Dictionary = {
"receiver": prop, # PropBlock
"other": other_body, # PhysicsBody2D
"normal": receiver_to_other, # Vector2, normalized; points out of receiver
"position": contact_world, # Vector2
"receiver_velocity": prop_speed, # Vector2 in world units per second
"other_velocity": hitter_speed, # Vector2 in world units per second
"source": &"body_contact", # or &"blocking_sweep"
}
```
For a receiver-to-other unit normal `n`, the initial score is `maxf(0.0, -(other_velocity - receiver_velocity).dot(n))`. Use a single project constant for a qualifying approach speed (initial tuning target: about **140 world units/second**) and a short per-receiver cooldown (initial target: about **0.25 seconds**). These numbers are implementation tuning targets, not child-facing settings. Reject nonfinite values and a near-zero normal. A tangential slide, separating contact, or slow settling yields a low score. Use pre-contact velocity samples when available because a physics response may already have reduced current velocity by the time `body_entered` is delivered.
Velocity sources:
- Moving or released prop: the last valid physics-step velocity sample from its `RigidBody2D`; include current velocity only as a fallback. Frozen start-still prop and terrain: zero. For a prop-prop hit, evaluate the same relative pair once, then notify both enabled props.
- Animated stickman: the contacted `AnimatableBody2D` proxy's measured position change divided by the physics step, including limb motion. The cache is initialized without creating a speed spike. The blocking locomotion sweep uses **requested motion / delta before the sweep clamps movement**, since actual movement can be zero at the prop face.
- Ragdoll limb: its `RigidBody2D` linear velocity, with a previous-step sample when available. Ragdoll contacts can qualify for wobble even though the existing collision-rule/flinch path intentionally ignores them.
`StageImpactResolver` is the one place that turns motion into a hit score. A future mass setting or engine impulse reading can revise this calculation, after separate product and physics validation, without adding a second wobble field or changing the switch. Godot's [direct body state contact data](https://docs.godotengine.org/en/stable/classes/class_physicsdirectbodystate2d.html) offers contact velocities and impulse if that later revision is warranted.
### Contact edges, movement sweeps, and jitter
`PropBlock.collided` fires on `body_entered`, and `separated` on `body_exited`. Keep the existing `StageRuleEngine.register_contact()` semantics for authored rules. The wobble resolver has its own pair gate because it must also accept sweep hits and ragdoll bodies without changing rule events.
Use a canonical pair key: sorted prop IDs for prop/prop, prop ID plus rig ID for animated/ragdoll stickmen, and prop ID plus body ID for terrain/other bodies. Track the set of contacting body IDs for a rig so multiple limbs on the same prop remain one active pair. A pair arms again only after all member contacts separate, or after a sweep-only pair has no hit on a later physics tick. The per-prop cooldown prevents a quick separation/recontact from chattering. Do not retrigger from sustained contact, continuous sliding, or repeated sweep results while a walker presses into a blocked prop. Reset all pair/baseline state at PLAY start and STOP; after pause/resume, rebuild contact baselines before accepting new edges so resting objects do not wobble again.
The existing grounded movement code uses `_sweep_actor_blocking_props()` with `cast_motion()` and stops the rig at the safe fraction. This can prevent a physical `body_entered` callback for a stationary blocking prop. Extend the query only when a cast reports a blocked fraction: identify the nearest hit and normal using a matching physics-space rest query, verify the collider is that blocking `PropBlock`, and send one candidate to Stage. Godot's [PhysicsDirectSpaceState2D](https://docs.godotengine.org/en/stable/classes/class_physicsdirectspacestate2d.html) documents both cast motion and rest info. Preserve the current sweep's movement result and exclusion list. If a hit cannot be resolved to a valid prop, omit the wobble candidate rather than guessing a target. The resolver's rig/prop key merges a later physical callback with the same hit.
For prop-prop contacts, the first callback for the canonical pair can start both enabled effects; the reverse callback is already latched. For ragdoll contacts, route the effect candidate before the current early return that suppresses rule/flinch behavior. The resolver never calls `_handle_event()`, so existing collision rules keep their present event timing and targets.
## Visual motion and lifecycle
Keep `Polygon2D`, `Outline`, `Visuals`, `CollisionPolygon2D`, and `CollisionShape2D` as direct `PropBlock` children; several current code paths use those node names. Do not reparent the artwork or rotate the root. Cache the baseline local `position`, `rotation`, and `scale` of whichever artwork children exist. Apply one common small rotation and sideways displacement around the current artwork bounds center, composed with the baseline transforms. Use a deterministic damped envelope that reaches zero exactly at the configured end, then restore the cached transforms. New hits during the active effect may restart it only after pair rearming and cooldown; never stack transforms or spawn unbounded Tweens.
The EDIT preview calls this same effect on the selected prop, even with pass-through settings. It does not mark the scene dirty beyond the switch edit. `StageSelection.get_world_aabb()` makes the selected outline follow the artwork; `StageGizmos` already redraws while selected. Camera pan/zoom naturally carries both in world space. The prop keeps its behind/in-front draw tier. Turning Off, changing selection, leaving EDIT, starting PLAY, deleting the prop, or STOP calls `cancel_wobble()` and restores the artwork exactly. During PLAY the child transforms are composed after `begin_play_instance_scale()` has moved authored scale into artwork and collision geometry; the wobble only touches artwork and never overwrites the scale-transfer baseline. Pause freezes the animation time; STOP restores the authored prop transform, role, and visual baseline.
## Persistence and compatibility
One real prop entry in a `.scene` v1.4 document gains an explicit boolean:
```json
{
"type": "prop",
"stage_object_id": "prop_unchanging_instance_id",
"asset_id": "res://content/props/pot.prop",
"wobble_on_impact": true
}
```
The example omits the existing position, rotation, scale, roles, Use spots, and material fields. `StageSceneIO` migrates v1.0–v1.3 entries through their existing role, depth, and Use spot defaults, then adds `wobble_on_impact: false` where absent. Current v1.4 entries without this additive key also default to false. A present non-boolean value is invalid before the world is replaced. A missing asset's placeholder retains its entire original prop entry when resaved, including the new key. Two placements of the same `.prop` can save opposite choices. `ContentPaths.canonicalize` still applies only to path fields.
## Validation and trade-offs
1. In EDIT, turn On for a visible and a partly covered prop; check one brief preview, moving selection outline, unchanged collision/body transform and save state. Turn Off and change selection mid-preview; check exact restoration. Check narrow card, enlarged font, keyboard focus/activation, and pass-through setting.
2. In PLAY, check a stationary blocking prop hit by a walking/running stickman (including the sweep path), a falling ragdoll limb, a moving prop hitting terrain, and two enabled props hitting each other. Verify one wobble per meaningful hit, both prop effects when applicable, and normal rigid-body movement during the effect.
3. Check prop resting on support, slow sliding, sustained actor push, multiple limbs touching one prop, rebound/recontact, paused/resumed contact, and a STOP followed immediately by PLAY. None should cause ongoing jitter or a false startup wobble. A later separate hard hit should rearm.
4. Save/reload two same-asset placements with opposite choices; load v1.0–v1.3 scenes; reject a present malformed boolean without replacing the current world; resave a missing-asset placeholder; confirm `.prop` files and existing collision-rule behavior are unchanged.
The selected approach favors a small, testable score/gate helper over changes to the physics body or global event architecture. The limitation is that a fixed approach-speed threshold is a readable first behavior, not a complete physical impulse model. If later user testing finds material-dependent strength necessary, adjust the resolver with explicit fixed-body handling; retain the per-instance boolean and visual-only effect contract.
+68
View File
@@ -0,0 +1,68 @@
# Phase 17c — Impact wobble: UI/UX specification
> **Status:** UI/UX design selected; architecture and implementation follow.
> **Source:** [Phase 17 — Props for Storytelling](../plans/phase_17_props_for_storytelling.md).
> **Scope:** The child-facing setting and visual feedback for impact wobble on a placed prop in the Sandbox Stage. This document leaves hit detection, persistence, and animation ownership to the architecture specification.
## Child flow
1. In **EDIT**, the child selects one placed prop. Its existing settings card opens with the prop's name in the fixed header. Under **When bumped**, the child turns on **Wobble when bumped**. The selected prop shows one brief sample wobble in place, then settles. Turning the switch off stops a sample that is still playing.
2. The child starts **PLAY**. A noticeable physical hit makes the enabled prop wobble briefly and settle. It can wobble whether it began PLAY in place or **Can fall and move**. A moving prop continues its normal physical movement during the visual wobble. Ordinary resting contact does not make it shake repeatedly.
3. Stopping **PLAY** ends any active wobble and restores the prop's authored appearance and placement. The saved On/Off choice remains on this placed copy of the prop.
## EDIT card layout and states
```text
Existing StageInspectorPanel
├─ Fixed header: prop icon / selected prop name / ✕ Close
└─ ScrollContainer / VBoxContainer
├─ Existing depth, starting motion, and collision controls
├─ Existing After letting go section, when applicable
├─ When bumped: section heading
│ └─ Wobble when bumped: StagePanelToggleRow
│ ├─ Short helper line
│ └─ Fixed-position switch + ON/OFF text
└─ Existing Use spots section
```
- Add the heading and one full-width `StagePanelToggleRow` to the existing `StagePropSettingsView` body. Keep `StageInspectorPanel`'s selected-name header, fixed **✕ Close**, scrolling body, safe-area fit, selection dismissal, and compact **Settings** reopen button. The switch belongs to one selected placed prop; it does not edit the reusable `.prop` asset or every prop of the same type.
- The row is available for **Stays in place** and **Can fall and move** props, including a prop currently configured to let stickmen or other props pass through it. A pass-through prop may have no qualifying hit in its current setup, but the child may change its behavior later or release it with different collision settings. Keep the choice and its preview available without a disabled state or an extra prerequisite control.
- The switch starts **OFF** for a placement with no authored wobble choice. Its ON/OFF text, knob position, selected surface, and border show the state without relying on color. Keep the caption stable when toggled. The row stays at least 44 logical pixels high and follows the larger applicable `stage_panels.toggle.minimum_height` value. All of the row is one pointer/touch target; keyboard focus is visible and Space/Enter activates it once.
- Show the same short helper in both states: **A hard bump makes this prop wobble once in PLAY.** It sets an expectation for the effect without exposing impact strength or a force slider. Help wraps within the row; the switch and ON/OFF label remain anchored at the top when the card width or font size changes. On a short stage, only the body scrolls, so the header and Close remain reachable.
- Pressing the switch applies the choice immediately and marks the scene as changed. Closing the card keeps the choice. Reopening the card displays its current state without replaying the sample. Selecting another prop displays that prop's own choice. Multiple selection continues to show the existing **Select one prop to change its settings.** notice.
## On-stage preview and PLAY feedback
- Turning **ON** in EDIT plays one short, controlled sample on the selected prop and its selection outline, then returns both to their authored appearance. The sample is visual only: it does not move the prop's authored position, rotate its collision shape, start physics, or change the saved scene. It also works on a pass-through prop, since it demonstrates the selected setting rather than claiming a collision has occurred.
- A sample remains attached to its prop while the stage view pans or zooms. Preserve the prop's behind/in-front draw tier; do not temporarily move it in front of other artwork. If another object partly covers it, the selected outline makes the sample easier to spot. Use a modest amplitude and a quick settle so the prop still looks stable. Do not add an instructional cursor, ghost path, or capture bar: this is a direct switch, with no stage click to complete.
- Turning **OFF**, changing selection, leaving EDIT, or starting PLAY cancels an EDIT sample cleanly. Switching ON again plays one new sample. The switch's state remains the durable feedback even when the selected artwork is covered.
- In PLAY, each qualifying hit yields one readable wobble that settles. A prop using **Can fall and move** must wobble while it is falling, sliding, or otherwise moving; the wobble rides along with that movement instead of freezing, snapping, or replacing it. Wobble is a visual response and does not imply toppling, breaking, a change of collision behavior, or a rule firing. Existing collision rules remain the way to author more elaborate consequences.
- Resting contact and ongoing contact must not produce a constant shake. A later separate meaningful hit can produce another wobble. The visible result should be similar for a stationary prop hit by a moving stickman and a moving prop that strikes another object; the architecture spec will define the hit-strength rule.
- **PLAY** uses the normal clean green stage presentation. No persistent wobble icon, instruction pill, or effect label covers the movie. Stopping PLAY removes any active wobble before EDIT returns and restores the authored prop appearance and placement. If the project later offers reduced animation, use a brief static emphasis as the equivalent hit cue.
## Shared controls, theme, and accessibility
- Reuse `StageInspectorPanel`, `StagePanelToggleRow`, and its `StagePanelSwitchVisual` from `scripts/shared/stage_ui/`. The existing `StagePanelChoiceGroup` still handles depth and starting motion; wobble is one independent switch. Extend `StagePropSettingsView` rather than building a second inspector or a floating settings widget.
- Use `SandboxTheme`'s `stage_panels.shell`, `.type`, `.toggle`, and `.action` tokens for layout, fonts, row surfaces, borders, hover, selected, disabled, and focus states. The EDIT accent comes from `mode_colors.edit`; PLAY presentation follows the existing `mode_colors.play`. If the visual sample or wobble needs configurable duration, angle, or offset, add named effect tokens to both `sandbox_theme.json` and `SandboxTheme` defaults during implementation. Do not hardcode UI colors or font sizes.
- The card must remain usable at narrow widths and enlarged fonts. The helper wraps without clipping or moving the switch out of reach. Focus remains visible over the ON state. The control can be used with pointer, touch, or keyboard, and its ON/OFF text communicates state without color.
- The existing **✕ Close** and **Settings** controls remain at least 44 × 44 logical pixels. The switch does not enter a capture workflow, so it needs no separate Cancel button. EDIT selection and the existing **Objects here** chooser continue to make covered props reachable.
## Exact child-facing copy
| Context | Copy |
| --- | --- |
| Card heading | **When bumped** |
| Switch caption | **Wobble when bumped** |
| Switch state | **ON** / **OFF** |
| Helper, both states | **A hard bump makes this prop wobble once in PLAY.** |
| Existing card actions | **✕ Close** / **Settings** |
There is no child-facing force, impact threshold, frequency, or strength control.
## UI review criteria
1. Selecting one placed prop shows the new switch in the existing card. Its state is readable without color, and it remains reachable on a short or narrow stage with enlarged fonts.
2. Switching ON previews one brief wobble on that selected prop, including when it is set to pass through others. Switching OFF or leaving EDIT cancels the sample and leaves the authored placement unchanged.
3. Two placed copies of the same prop can show different ON/OFF choices. Closing and reopening the card, saving and reloading the scene, and entering and stopping PLAY display the correct choice for each copy.
4. A qualifying hit produces one short visible wobble on both stationary and currently moving physics props. Normal movement continues, resting contact does not shake repeatedly, and stopping PLAY clears the visual effect.
5. Pointer, touch, and keyboard activation each change the switch once. The focus state remains visible, and the helper and switch do not clip or jump when the card reflows.
+12
View File
@@ -10,6 +10,18 @@ Today `SandboxStage` builds and styles that card in `sandbox_stage.gd`. `PropSet
The goal is one shared look and interaction contract for the next Stage inspector, without moving prop rules, selection, or saved data into UI widgets.
## Adjacent pattern: transient stage capture bar
Targeting and placement steps in EDIT and DIRECT use a temporary capture bar, separate from the selected-object inspector card. The Use-target capture bar is the first implementation and visual reference. Reuse this pattern when a stage task asks the child to click a prop, spot, or position; when another workflow needs it, reuse the component or extract a shared `StageCaptureBar` rather than copying its styling.
- Keep the bar in a stable screen-space position below the top toolbar and mode pill. It must never cover the **EDITING** or **DIRECTING** indicator.
- Use a dark, rounded panel with a narrow mode-colored accent that follows the left edge and curves slightly around the left corners. Avoid an accent outline around the whole panel.
- Put a short, step-aware instruction in the bar. Keep the available actions grouped with it: a task-specific secondary action such as **Change prop**, and a clear, prominent **✕ Cancel**. When only one action is available, arrange it with the prompt so the bar does not leave a large empty button row.
- Keep the panel compact: around two-thirds of the viewport width, capped near 800 logical pixels, with safe edge margins. On narrow viewports, wrap the prompt and stack actions only when needed. Use comfortable spacing and at least a 44 px target for each action.
- Use `SandboxTheme` colors, fonts, and action sizing. Consume pointer input only inside the bar so the stage remains clickable outside it. Keep Esc and right-click as optional shortcuts; the visible Cancel remains available throughout capture.
`StageUseTargetCaptureBar` in `scripts/editors/stage/stage_use_target_capture_bar.gd` currently implements this pattern for DIRECT Use targeting and Use-related rule targeting. A future EDIT capture workflow may share the same visual contract while supplying its own prompt and actions.
## Child-facing contract
- A selection card has a recognizable icon, the selected object's name, and a large Close button. Long names wrap to at most two lines and expose their full name in a tooltip; Close never overlaps the title.