Files
stickman/docs/specs/phase_17b_use_spots.md
T
ryan d8b1b1164b 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.
2026-10-13 11:23:44 -04:00

25 KiB
Raw Blame History

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 and approved Use spots UI/UX specification. 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

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:

{
  "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:

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. 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 and AGENTS.md.

Action, rule, and event wire contracts

Flat in-memory queue action:

{"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:

{
    "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:

{
    "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.

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.