diff --git a/AGENTS.md b/AGENTS.md index 9d2ec16..8a536fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,10 @@ the configured `run/main_scene`. - This kit currently targets in-stage selection/inspector cards. For modal dialogs, editor inspectors, or other distinct UI workflows, review the spec for reusable visual rules but decide whether the components' layout and interaction model fit before adopting them. +- For temporary in-stage target or placement capture in **EDIT** or **DIRECT**, follow the + transient capture-bar pattern in the [Stage panel UI system spec](docs/specs/stage_panel_ui_system.md). + Keep the prompt and visible Cancel together, leave the mode pill unobstructed, and reuse or + generalize `StageUseTargetCaptureBar` instead of copying its styling. ## Required addon diff --git a/content/stages/spot_test.scene b/content/stages/spot_test.scene new file mode 100644 index 0000000..a56a2b0 --- /dev/null +++ b/content/stages/spot_test.scene @@ -0,0 +1,342 @@ +{ + "version": "1.3", + "scene_name": "Untitled", + "camera": { + "position": { + "x": -312.75048828125, + "y": -243.914947509766 + }, + "zoom": 0.885780572891235 + }, + "objects": [ + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "x": -960.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "x": -768.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "x": -576.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "x": -384.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "x": -192.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "x": 0.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "x": 192.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "x": 384.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "x": 576.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "x": 768.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "x": 960.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_12", + "type": "terrain", + "position": { + "x": 1152.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_13", + "type": "terrain", + "position": { + "x": 1344.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "stickman_1", + "type": "stickman", + "position": { + "x": -756.0, + "y": -241.0 + }, + "rotation": 0.0, + "stk_path": "res://content/stickmen/break.stk", + "facing_profile": "FORWARD", + "action_queue": [ + { + "type": "use_prop", + "target_prop_id": "prop_20906175_2821870920_1", + "target_spot_id": "spot_1" + } + ] + }, + { + "file_id": "prop_1", + "type": "prop", + "position": { + "x": -144.0, + "y": -732.0 + }, + "rotation": 0.993539273629036, + "asset_id": "res://content/props/championship_belt.prop", + "stage_object_id": "prop_20906175_2821870920_1", + "roles": { + "depth": "behind", + "start_motion": "still", + "holds_other_props": false, + "blocks_stickmen": false, + "can_pick_up": true, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [ + { + "id": "spot_1", + "number": 1, + "name": "Button", + "position": { + "x": -179.254028320313, + "y": -63.7373542785645 + } + }, + { + "id": "spot_2", + "number": 2, + "name": "Lever", + "position": { + "x": 182.606811523438, + "y": -29.3857421875 + } + } + ], + "next_use_spot_number": 5, + "scale": { + "x": 0.999997615814209, + "y": 0.999997615814209 + }, + "material_preset": 4 + }, + { + "file_id": "prop_2", + "type": "prop", + "position": { + "x": -1008.0, + "y": -408.0 + }, + "rotation": 0.0, + "asset_id": "res://content/props/baseball.prop", + "stage_object_id": "prop_109116485_2472952225_1", + "roles": { + "depth": "behind", + "start_motion": "still", + "holds_other_props": false, + "blocks_stickmen": false, + "can_pick_up": true, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [], + "next_use_spot_number": 1, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + } + ], + "event_rules": [ + { + "id": 0, + "trigger": { + "type": "prop_used", + "source_id": "stickman_1", + "target_id": null, + "params": { + "target_prop_id": "prop_20906175_2821870920_1", + "spot_scope": "specific", + "target_spot_id": "spot_1" + } + }, + "actions": [ + { + "type": "speak", + "target_id": "stickman_1", + "params": { + "text": "I pushed it!", + "duration": 1.6, + "stop_current": false, + "keep_acting": true, + "arrangement": "together", + "bubble_layout": "stack", + "connect_bubbles": false, + "reveal": "instant", + "pop_in": false, + "duration_mode": "just_enough", + "bubbles": [ + { + "text": "I pushed it!", + "visual": { + "bubble_style": "talk", + "font_preset": "comic", + "size_preset": "medium", + "color": "#202020", + "effect": "none", + "reveal": "instant", + "pop_in": false, + "word_styles": [] + } + } + ] + } + } + ] + } + ], + "metadata": { + "created_unix": 1791684250, + "modified_unix": 1791849418.99, + "autosave": false, + "source_path": "" + } +} \ No newline at end of file diff --git a/docs/architecture/tech_debt_and_optimizations.md b/docs/architecture/tech_debt_and_optimizations.md index 5478247..3aaea7d 100644 --- a/docs/architecture/tech_debt_and_optimizations.md +++ b/docs/architecture/tech_debt_and_optimizations.md @@ -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_ diff --git a/docs/plans/phase_17_props_for_storytelling.md b/docs/plans/phase_17_props_for_storytelling.md index 786f8d9..b59ba1d 100644 --- a/docs/plans/phase_17_props_for_storytelling.md +++ b/docs/plans/phase_17_props_for_storytelling.md @@ -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? diff --git a/docs/specs/phase_17b_use_spots.md b/docs/specs/phase_17b_use_spots.md new file mode 100644 index 0000000..95cec26 --- /dev/null +++ b/docs/specs/phase_17b_use_spots.md @@ -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` 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 `; 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. + diff --git a/docs/specs/phase_17b_use_spots_ui.md b/docs/specs/phase_17b_use_spots_ui.md new file mode 100644 index 0000000..7762a49 --- /dev/null +++ b/docs/specs/phase_17b_use_spots_ui.md @@ -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. + diff --git a/docs/specs/phase_17c_impact_wobble.md b/docs/specs/phase_17c_impact_wobble.md new file mode 100644 index 0000000..fa19d6f --- /dev/null +++ b/docs/specs/phase_17c_impact_wobble.md @@ -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. diff --git a/docs/specs/phase_17c_impact_wobble_ui.md b/docs/specs/phase_17c_impact_wobble_ui.md new file mode 100644 index 0000000..38c78e1 --- /dev/null +++ b/docs/specs/phase_17c_impact_wobble_ui.md @@ -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. diff --git a/docs/specs/stage_panel_ui_system.md b/docs/specs/stage_panel_ui_system.md index 8a150da..e549687 100644 --- a/docs/specs/stage_panel_ui_system.md +++ b/docs/specs/stage_panel_ui_system.md @@ -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. diff --git a/sandbox_theme.json b/sandbox_theme.json index a18ab08..5a9d727 100644 --- a/sandbox_theme.json +++ b/sandbox_theme.json @@ -176,6 +176,17 @@ "stickman_cue_color": "#F5F7FA", "prop_cue_color": "#B7824D", "cue_outline_color": "#171B24" + }, + "spots": { + "row_gap": 8, + "pin_visual_size": 30, + "pin_hit_size": 44, + "pin_outline_width": 2, + "focus_ring_width": 3, + "name_pill_padding": 10, + "pin_surface_color": "#F4F7FB", + "pin_outline_color": "#171B24", + "completion_duration": 0.6 } }, "overlays": { @@ -183,6 +194,8 @@ "terrain_guide": { "color": "#22C6FF" }, "completed_path_alpha": 0.2, "completed_path_color": "#959EAD", + "rule_connector_color": "#AAB2C0", + "rule_action_color": "#7E57C2", "valid": { "color": "#4DE66680" }, "same_type": { "color": "#FFFFFF26" }, "invalid": { "color": "#FF4D4D80" }, @@ -190,6 +203,13 @@ "unknown_color": "#C5CAD4", "reach_color": "#FFB300", "ghost_alpha": 0.36 + }, + "use_preview": { + "valid_color": "#42D887", + "invalid_color": "#F06464", + "uncertain_color": "#FFB300", + "neutral_color": "#C5CAD4", + "completion_duration": 0.6 } }, "widgets": { diff --git a/scripts/characters/stickman/stickman_action_runner.gd b/scripts/characters/stickman/stickman_action_runner.gd index 9722f25..fa15911 100644 --- a/scripts/characters/stickman/stickman_action_runner.gd +++ b/scripts/characters/stickman/stickman_action_runner.gd @@ -399,7 +399,7 @@ func _advance_to_next_action() -> void: action.erase("speed") _current_run_item["action"] = action var action_type := String(action.get("type", "")) - var requires_animated := action_type == ACTION_REGISTRY.MOVE_TO or action_type == "pick_up_prop" or action_type == "drop_prop" or action_type == "throw_prop" or action_type == "set_down_prop" + var requires_animated := action_type == ACTION_REGISTRY.MOVE_TO or action_type == "pick_up_prop" or action_type == "drop_prop" or action_type == "throw_prop" or action_type == "set_down_prop" or action_type == "use_prop" if requires_animated and _rig != null: if _rig.state == StickmanRig.RigState.RECOVERING: _run_queue.insert(_run_cursor, _current_run_item.duplicate(true)) @@ -443,7 +443,7 @@ func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> v "recover": _action_phase = ActionPhase.RECOVERING recovery_requested.emit() - "pick_up_prop", "drop_prop", "throw_prop", "set_down_prop": + "pick_up_prop", "drop_prop", "throw_prop", "set_down_prop", "use_prop": if _rig == null or not _rig.start_prop_action(action): _finish_action() else: diff --git a/scripts/characters/stickman/stickman_prop_interactor.gd b/scripts/characters/stickman/stickman_prop_interactor.gd index fea95ff..55a18aa 100644 --- a/scripts/characters/stickman/stickman_prop_interactor.gd +++ b/scripts/characters/stickman/stickman_prop_interactor.gd @@ -6,9 +6,10 @@ const ANCHOR_DATA := preload("res://scripts/props/prop_anchor_data.gd") const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd") const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd") +const USE_REACH_SOLVER := preload("res://scripts/characters/stickman/stickman_use_reach_solver.gd") enum Hand { LEFT, RIGHT } -enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING, SET_DOWN_APPROACHING, SET_DOWN_REACHING, SET_DOWN_RECOVERING } +enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING, SET_DOWN_APPROACHING, SET_DOWN_REACHING, SET_DOWN_RECOVERING, USE_APPROACHING, USE_REACHING, USE_RECOVERING } const MAX_REACH: float = 390.0 const APPROACH_GAP: float = 90.0 @@ -16,6 +17,12 @@ const APPROACH_REPLAN_LIMIT: int = 1 const REACH_DURATION: float = 0.34 const SET_DOWN_REACH_DURATION: float = 0.22 const SET_DOWN_RECOVER_DURATION: float = 0.24 +const USE_REACH_DURATION: float = 0.28 +const USE_RECOVER_DURATION: float = 0.20 +const USE_MAX_REPLAN_DISPLACEMENT: float = 180.0 +const USE_REPLAN_LIMIT: int = 1 +## Temporary diagnostics for the Use reach failure report. Remove after diagnosis. +const USE_REACH_TRACE_ENABLED: bool = true const PICKUP_RECOVER_DURATION: float = 0.32 const LOW_PICKUP_THRESHOLD: float = 110.0 const LOW_PICKUP_MAX_DROP: float = 110.0 @@ -38,6 +45,8 @@ const PROP_TRACE_ENABLED: bool = false signal action_completed signal prop_event(event_type: String, prop: PropBlock, reason: String) +signal use_completed(prop: PropBlock, spot_id: String, world_position: Vector2) +signal use_failed(reason_code: String, prop: PropBlock, spot_id: String) var _rig: Node2D var _locomotion: Node @@ -91,6 +100,16 @@ var _set_down_recover_hand_start: Vector2 = Vector2.ZERO var _trace_released_prop: PropBlock var _trace_released_elapsed: float = 0.0 var _trace_released_watch_remaining: float = 0.0 +var _use_prop: PropBlock +var _use_spot_id: String = "" +var _use_planned_spot_world: Vector2 = Vector2.INF +var _use_approach_root: Vector2 = Vector2.ZERO +var _use_approach_replans: int = 0 +var _use_reach_elapsed: float = 0.0 +var _use_recover_elapsed: float = 0.0 +var _use_marker_start: Vector2 = Vector2.ZERO +var _use_marker_recover_start: Vector2 = Vector2.ZERO +var _use_hand: Hand = Hand.RIGHT func _process(delta: float) -> void: @@ -167,6 +186,8 @@ func start_action(action: Dictionary) -> bool: return true "set_down_prop": return _begin_set_down(action) + "use_prop": + return _begin_use(action) return false @@ -193,6 +214,25 @@ func tick(delta: float) -> void: _update_set_down_reach(delta) Phase.SET_DOWN_RECOVERING: _update_set_down_recovery(delta) + Phase.USE_APPROACHING: + if not _validate_use_target(): + return + var live_spot: Vector2 = _use_prop.use_spot_world_position(_use_spot_id) + if not live_spot.is_finite(): + _fail_use("spot_missing") + return + if live_spot.distance_to(_use_planned_spot_world) > USE_MAX_REPLAN_DISPLACEMENT: + _request_use_replan("target_moved") + return + if _locomotion != null and bool(_locomotion.call("consume_walk_failed")): + _fail_use("route_blocked") + return + if _locomotion != null and bool(_locomotion.call("consume_walk_completed")): + _on_use_approach_arrived() + Phase.USE_REACHING: + _update_use_reach(delta) + Phase.USE_RECOVERING: + _update_use_recovery(delta) Phase.PICKUP_RECOVERING: _update_pickup_recovery(delta) Phase.HELD: @@ -202,7 +242,7 @@ func tick(delta: float) -> void: func cancel_action() -> void: - if (_phase == Phase.APPROACHING or _phase == Phase.SET_DOWN_APPROACHING) and _locomotion != null: + if (_phase == Phase.APPROACHING or _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.USE_APPROACHING) and _locomotion != null: _locomotion.call("cancel_walk") if _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.SET_DOWN_REACHING or _phase == Phase.SET_DOWN_RECOVERING: _restore_hand_target() @@ -232,6 +272,10 @@ func cancel_action() -> void: _throw_elapsed = 0.0 _restore_hand_target() _restore_throw_torso() + if _phase == Phase.USE_APPROACHING or _phase == Phase.USE_REACHING or _phase == Phase.USE_RECOVERING: + _restore_hand_target() + _restore_pickup_torso() + _clear_use_state() if _phase != Phase.HELD: _phase = Phase.IDLE _active_action.clear() @@ -560,27 +604,327 @@ func _invalid_throw_preview(effective_target: Vector2, start_position: Vector2) func disarm_for_ragdoll() -> void: var interrupted_pickup := _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING var interrupted_set_down := _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.SET_DOWN_REACHING or _phase == Phase.SET_DOWN_RECOVERING + var interrupted_use := _phase == Phase.USE_APPROACHING or _phase == Phase.USE_REACHING or _phase == Phase.USE_RECOVERING var interrupted_windup := _phase == Phase.THROWING and not _throw_released - if interrupted_pickup or interrupted_set_down: + if interrupted_pickup or interrupted_set_down or interrupted_use: cancel_action() elif interrupted_windup: cancel_action() if _held_prop != null and is_instance_valid(_held_prop): _release_held("ragdoll", false) - if interrupted_pickup or interrupted_set_down or interrupted_windup: + if interrupted_pickup or interrupted_set_down or interrupted_use or interrupted_windup: action_completed.emit() func reset_for_scene_exit() -> void: if PROP_TRACE_ENABLED: print("[PropTrace] reset_for_scene_exit actor=%s phase=%s held=%s" % [_trace_actor_name(), Phase.keys()[int(_phase)], _trace_prop_id(_held_prop)]) - if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING or _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.SET_DOWN_REACHING or _phase == Phase.SET_DOWN_RECOVERING or (_phase == Phase.THROWING and not _throw_released): + if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING or _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.SET_DOWN_REACHING or _phase == Phase.SET_DOWN_RECOVERING or _phase == Phase.USE_APPROACHING or _phase == Phase.USE_REACHING or _phase == Phase.USE_RECOVERING or (_phase == Phase.THROWING and not _throw_released): cancel_action() if _held_prop != null and is_instance_valid(_held_prop): _release_held("scene_reset", false) _phase = Phase.IDLE _active_action.clear() _restore_hand_target() + _clear_use_state() + + +func get_held_prop() -> PropBlock: + return _held_prop if _held_prop != null and is_instance_valid(_held_prop) else null + + +func preview_use(prop: PropBlock, spot_id: String, actor_root: Vector2 = Vector2.INF, predicted_facing_left: bool = false) -> Dictionary: + if _rig == null or not is_instance_valid(_rig): + return {"known": true, "valid": false, "reason_code": "out_of_reach"} + var root: Vector2 = _rig.global_position if not actor_root.is_finite() else actor_root + return USE_REACH_SOLVER.solve(_rig, prop, spot_id, root, predicted_facing_left) + + +func _begin_use(action: Dictionary) -> bool: + var prop_id: String = String(action.get("target_prop_id", "")) + var spot_id: String = String(action.get("target_spot_id", "")) + if _phase != Phase.IDLE or _rig == null or int(_rig.get("state")) != StickmanRig.RigState.ANIMATED: + use_failed.emit("out_of_reach", null, spot_id) + return false + if _held_prop != null and is_instance_valid(_held_prop): + use_failed.emit("hands_full", null, spot_id) + return false + if prop_id.is_empty() or not _resolver.is_valid(): + use_failed.emit("prop_missing", null, spot_id) + return false + var resolved: Variant = _resolver.call(prop_id) + if not resolved is PropBlock or not is_instance_valid(resolved as PropBlock) or (resolved as PropBlock).is_queued_for_deletion(): + use_failed.emit("prop_missing", null, spot_id) + return false + var prop: PropBlock = resolved as PropBlock + var preview: Dictionary = USE_REACH_SOLVER.solve(_rig, prop, spot_id, _rig.global_position, _is_facing_left()) + if not bool(preview.get("valid", false)): + use_failed.emit(String(preview.get("reason_code", "out_of_reach")), prop, spot_id) + return false + _use_prop = prop + _use_spot_id = spot_id + _use_planned_spot_world = preview.get("spot_world", Vector2.INF) + _use_approach_root = preview.get("approach_root", _rig.global_position) + _use_approach_replans = 0 + _use_hand = int(preview.get("hand", Hand.RIGHT)) + _hand = _use_hand + _active_action = action.duplicate(true) + _trace_use_reach("begin", "", preview) + var marker: Marker2D = _hand_marker() + if marker == null: + _clear_use_state() + use_failed.emit("out_of_reach", prop, spot_id) + return false + _marker_start_position = marker.position + if absf(_use_approach_root.x - _rig.global_position.x) > 8.0: + return _start_use_approach(preview) + _start_use_reach(preview) + return _phase == Phase.USE_REACHING + + +func _start_use_approach(preview: Dictionary) -> bool: + if _locomotion == null or _use_prop == null or not is_instance_valid(_use_prop): + _fail_use("prop_missing") + return false + _use_approach_root = preview.get("approach_root", _use_approach_root) + _use_planned_spot_world = preview.get("spot_world", _use_planned_spot_world) + _use_hand = int(preview.get("hand", _use_hand)) + _hand = _use_hand + var facing_left: bool = bool(preview.get("facing_left", _is_facing_left())) + if facing_left != _is_facing_left(): + _rig.call("set_facing_profile", StickmanRig.FacingProfile.LEFT if facing_left else StickmanRig.FacingProfile.RIGHT) + var target_feet: Vector2 = _use_approach_root - _locomotion.call("get_foot_offset") + if not bool(_locomotion.call("walk_to", target_feet, -1.0)): + _fail_use("route_blocked") + return false + _phase = Phase.USE_APPROACHING + return true + + +func _on_use_approach_arrived() -> void: + if not _validate_use_target(): + return + var current_spot: Vector2 = _use_prop.use_spot_world_position(_use_spot_id) + if not current_spot.is_finite(): + _fail_use("spot_missing") + return + if current_spot.distance_to(_use_planned_spot_world) > USE_MAX_REPLAN_DISPLACEMENT: + _request_use_replan("target_moved") + return + var preview: Dictionary = USE_REACH_SOLVER.solve(_rig, _use_prop, _use_spot_id, _rig.global_position, _is_facing_left()) + _trace_use_reach("approach_arrived", String(preview.get("reason_code", "")), preview) + if not bool(preview.get("valid", false)): + _request_use_replan(String(preview.get("reason_code", "out_of_reach"))) + return + if absf(Vector2(preview.get("approach_root", _rig.global_position)).x - _rig.global_position.x) > 14.0: + _request_use_replan("target_moved", preview) + return + _use_planned_spot_world = current_spot + _start_use_reach(preview) + + +func _request_use_replan(failure_reason: String, preview: Dictionary = {}) -> void: + if _phase == Phase.USE_APPROACHING and _locomotion != null: + _locomotion.call("cancel_walk") + if _use_approach_replans >= USE_REPLAN_LIMIT: + _fail_use("target_moved" if failure_reason == "target_moved" else failure_reason) + return + _use_approach_replans += 1 + if preview.is_empty() and _use_prop != null and is_instance_valid(_use_prop): + preview = USE_REACH_SOLVER.solve(_rig, _use_prop, _use_spot_id, _rig.global_position, _is_facing_left()) + if preview.is_empty() or not bool(preview.get("valid", false)): + _fail_use(failure_reason) + return + var new_spot: Vector2 = preview.get("spot_world", Vector2.INF) + if not new_spot.is_finite() or new_spot.distance_to(_use_planned_spot_world) > USE_MAX_REPLAN_DISPLACEMENT: + _fail_use("target_moved") + return + _use_planned_spot_world = new_spot + _use_approach_root = preview.get("approach_root", _rig.global_position) + _use_hand = int(preview.get("hand", _use_hand)) + _hand = _use_hand + if absf(_use_approach_root.x - _rig.global_position.x) > 14.0: + _start_use_approach(preview) + else: + _start_use_reach(preview) + + +func _start_use_reach(preview: Dictionary) -> void: + _use_hand = int(preview.get("hand", _use_hand)) + _hand = _use_hand + var marker: Marker2D = _hand_marker() + if marker == null: + _fail_use("out_of_reach") + return + # Locomotion replaces hand targets with its standing pose at arrival. Save + # that current pose as the recovery target instead of restoring the stale + # marker captured before the approach walk. + _marker_start_position = marker.position + _use_marker_start = marker.global_position + _use_reach_elapsed = 0.0 + var torso_marker: Marker2D = _torso_marker() + _pickup_uses_crouch = false + if torso_marker != null: + _pickup_torso_start = torso_marker.position + _pickup_torso_start_rotation = torso_marker.rotation + _pickup_torso_crouched = _pickup_torso_start + _pickup_torso_crouched_rotation = _pickup_torso_start_rotation + var spot_world: Vector2 = preview.get("spot_world", _use_prop.use_spot_world_position(_use_spot_id)) + var spot_local: Vector2 = _rig.to_local(spot_world) + var low_amount: float = clampf((spot_local.y - _pickup_torso_start.y - LOW_PICKUP_THRESHOLD) / LOW_PICKUP_MAX_DROP, 0.0, 1.0) + if low_amount > 0.0: + var toward_prop: float = signf(spot_local.x - _pickup_torso_start.x) + if is_zero_approx(toward_prop): + toward_prop = -1.0 if _is_facing_left() else 1.0 + _pickup_torso_crouched += Vector2(toward_prop * LOW_PICKUP_LEAN * low_amount, LOW_PICKUP_MAX_DROP * low_amount) + _pickup_torso_crouched_rotation -= toward_prop * 0.10 * low_amount + _pickup_uses_crouch = true + _rig.call("_set_limb_mods_enabled", true) + _phase = Phase.USE_REACHING + _trace_use_reach("reach_started", "", preview) + + +func _update_use_reach(delta: float) -> void: + if not _validate_use_target(): + return + var marker: Marker2D = _hand_marker() + if marker == null: + _fail_use("out_of_reach") + return + var spot_world: Vector2 = _use_prop.use_spot_world_position(_use_spot_id) + if not spot_world.is_finite(): + _fail_use("spot_missing") + return + # Once reaching starts, do not drag the hand across a moving target. The + # approach gets one bounded replan; the reach phase only tolerates contact + # scale motion and then fails so DIRECT can be corrected. + if spot_world.distance_to(_use_planned_spot_world) > USE_REACH_SOLVER.CONTACT_TOLERANCE: + _fail_use("target_moved") + return + _use_reach_elapsed += delta + var weight: float = clampf(_use_reach_elapsed / USE_REACH_DURATION, 0.0, 1.0) + var eased_weight: float = smoothstep(0.0, 1.0, weight) + marker.global_position = _use_marker_start.lerp(spot_world, eased_weight) + _update_pickup_crouch(eased_weight) + if weight < 1.0: + return + var final_preview: Dictionary = USE_REACH_SOLVER.solve(_rig, _use_prop, _use_spot_id, _rig.global_position, _is_facing_left()) + if not bool(final_preview.get("valid", false)) or int(final_preview.get("hand", -1)) != _use_hand: + _trace_use_reach("reach_check_failed", String(final_preview.get("reason_code", "out_of_reach")), final_preview) + _fail_use(String(final_preview.get("reason_code", "out_of_reach"))) + return + var wrist_position: Vector2 = _solved_wrist_transform().origin + if wrist_position.distance_to(spot_world) > USE_REACH_SOLVER.CONTACT_TOLERANCE: + _trace_use_reach("contact_failed", "out_of_reach", final_preview) + _fail_use("out_of_reach") + return + use_completed.emit(_use_prop, _use_spot_id, spot_world) + _use_marker_recover_start = marker.global_position + _use_recover_elapsed = 0.0 + _phase = Phase.USE_RECOVERING + + +func _update_use_recovery(delta: float) -> void: + _use_recover_elapsed += delta + var weight: float = smoothstep(0.0, 1.0, clampf(_use_recover_elapsed / USE_RECOVER_DURATION, 0.0, 1.0)) + var marker: Marker2D = _hand_marker() + if marker != null: + marker.global_position = _use_marker_recover_start.lerp(_use_marker_start, weight) + _update_pickup_crouch(1.0 - weight) + if weight < 1.0: + return + _restore_hand_target() + _restore_pickup_torso() + _clear_use_state() + _phase = Phase.IDLE + _active_action.clear() + action_completed.emit() + + +func _validate_use_target() -> bool: + if _use_prop == null or not is_instance_valid(_use_prop) or _use_prop.is_queued_for_deletion(): + _fail_use("prop_missing") + return false + if _use_prop.get_use_spot(_use_spot_id).is_empty(): + _fail_use("spot_missing") + return false + if _use_prop.interaction_owner_id != 0 or _use_prop.interaction_reserved_by != 0: + _fail_use("prop_held") + return false + if _held_prop != null and is_instance_valid(_held_prop): + _fail_use("hands_full") + return false + return true + + +func _fail_use(reason_code: String) -> void: + var action_was_active: bool = _phase == Phase.USE_APPROACHING or _phase == Phase.USE_REACHING or _phase == Phase.USE_RECOVERING + _trace_use_reach("failed", reason_code) + if _phase == Phase.USE_APPROACHING and _locomotion != null: + _locomotion.call("cancel_walk") + var prop: PropBlock = _use_prop if _use_prop != null and is_instance_valid(_use_prop) else null + var spot_id: String = _use_spot_id + _restore_hand_target() + _restore_pickup_torso() + _clear_use_state() + _phase = Phase.IDLE + _active_action.clear() + use_failed.emit(reason_code, prop, spot_id) + if action_was_active: + action_completed.emit() + + +func _trace_use_reach(event_name: String, reason_code: String = "", preview: Dictionary = {}) -> void: + if not USE_REACH_TRACE_ENABLED: + return + var details: Dictionary = { + "event": event_name, + "reason": reason_code, + "phase": Phase.keys()[int(_phase)], + "actor": String(_rig.name) if _rig != null and is_instance_valid(_rig) else "", + "root": _rig.global_position if _rig != null and is_instance_valid(_rig) else Vector2.INF, + "rig_transform": _rig.global_transform if _rig != null and is_instance_valid(_rig) else Transform2D.IDENTITY, + "rig_scale": _rig.global_scale if _rig != null and is_instance_valid(_rig) else Vector2.ZERO, + "facing_left": _is_facing_left(), + "hand": _use_hand, + "approach_root": _use_approach_root, + "planned_spot": _use_planned_spot_world, + "preview": preview, + } + var marker: Marker2D = _hand_marker() + if marker != null: + details["hand_marker_world"] = marker.global_position + var wrist_position: Vector2 = _solved_wrist_transform().origin if _rig != null and is_instance_valid(_rig) else Vector2.INF + details["wrist_world"] = wrist_position + if _use_prop != null and is_instance_valid(_use_prop): + var spot: Dictionary = _use_prop.get_use_spot(_use_spot_id) + var live_spot: Vector2 = _use_prop.use_spot_world_position(_use_spot_id) + details["prop_id"] = _use_prop.stage_object_id + details["prop_transform"] = _use_prop.global_transform + details["spot_id"] = _use_spot_id + details["spot_local"] = spot.get("position", Vector2.INF) + details["spot_world"] = live_spot + details["wrist_to_spot"] = wrist_position.distance_to(live_spot) if wrist_position.is_finite() and live_spot.is_finite() else INF + var suffix: String = "Left" if _use_hand == Hand.LEFT else "Right" + var upper: Bone2D = _rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm" % suffix)) as Bone2D + var lower: Bone2D = _rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm/%sLowerArm" % [suffix, suffix])) as Bone2D + if upper != null: + details["shoulder_world"] = upper.global_position + if lower != null: + details["elbow_world"] = lower.global_position + print("[UseReachTrace] %s" % str(details)) + + +func _clear_use_state() -> void: + _use_prop = null + _use_spot_id = "" + _use_planned_spot_world = Vector2.INF + _use_approach_root = Vector2.ZERO + _use_approach_replans = 0 + _use_reach_elapsed = 0.0 + _use_recover_elapsed = 0.0 + _use_marker_start = Vector2.ZERO + _use_marker_recover_start = Vector2.ZERO func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary) -> bool: diff --git a/scripts/characters/stickman/stickman_rig.gd b/scripts/characters/stickman/stickman_rig.gd index dab4b62..e576492 100644 --- a/scripts/characters/stickman/stickman_rig.gd +++ b/scripts/characters/stickman/stickman_rig.gd @@ -183,6 +183,8 @@ signal queue_finished # queue ran to completion (not signal queue_changed # any queue mutation signal speech_finished # bubble auto-hid after speak() signal prop_interaction_event(event_type: String, prop: PropBlock, reason: String) +signal use_completed(prop: PropBlock, spot_id: String, world_position: Vector2) +signal use_failed(reason_code: String, prop: PropBlock, spot_id: String) # --------------------------------------------------------------------------- # Internal state @@ -262,6 +264,8 @@ func _ready() -> void: if _prop_interactor_component != null: _prop_interactor_component.call("configure", self, _locomotion_component, Callable()) _prop_interactor_component.connect("prop_event", _on_prop_interaction_event) + _prop_interactor_component.connect("use_completed", _on_prop_use_completed) + _prop_interactor_component.connect("use_failed", _on_prop_use_failed) if _action_runner_component != null: _action_runner_component.configure(self, _locomotion_component, _prop_interactor_component) _action_runner_component.action_queue = _pending_action_queue @@ -532,6 +536,12 @@ func cancel_prop_action() -> void: _prop_interactor_component.call("cancel_action") +func get_held_prop() -> PropBlock: + if _prop_interactor_component == null: + return null + return _prop_interactor_component.call("get_held_prop") as PropBlock + + func preview_prop_throw(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dictionary: if _prop_interactor_component == null: return {} @@ -544,6 +554,12 @@ func preview_prop_set_down(target: Vector2, queue_prefix: Array[Dictionary] = [] return _prop_interactor_component.call("preview_set_down", target, queue_prefix) +func preview_prop_use(prop: PropBlock, spot_id: String, actor_root: Vector2 = Vector2.INF, predicted_facing_left: bool = false) -> Dictionary: + if _prop_interactor_component == null: + return {"known": true, "valid": false, "reason_code": "out_of_reach"} + return _prop_interactor_component.call("preview_use", prop, spot_id, actor_root, predicted_facing_left) + + func reset_prop_interactions_for_scene_exit() -> void: if _prop_interactor_component != null: _prop_interactor_component.call("reset_for_scene_exit") @@ -553,6 +569,14 @@ func _on_prop_interaction_event(event_type: String, prop: PropBlock, reason: Str prop_interaction_event.emit(event_type, prop, reason) +func _on_prop_use_completed(prop: PropBlock, spot_id: String, world_position: Vector2) -> void: + use_completed.emit(prop, spot_id, world_position) + + +func _on_prop_use_failed(reason_code: String, prop: PropBlock, spot_id: String) -> void: + use_failed.emit(reason_code, prop, spot_id) + + ## Public stand-up request. No-op unless the rig is in RAGDOLL. func request_recovery() -> void: if state == RigState.RAGDOLL and _ragdoll_component != null: diff --git a/scripts/characters/stickman/stickman_use_reach_solver.gd b/scripts/characters/stickman/stickman_use_reach_solver.gd new file mode 100644 index 0000000..3bc611f --- /dev/null +++ b/scripts/characters/stickman/stickman_use_reach_solver.gd @@ -0,0 +1,285 @@ +class_name StickmanUseReachSolver +extends RefCounted +## Shared, nonmutating reach estimate for DIRECT previews and PLAY actions. + +const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd") +const APPROACH_GAP: float = 110.0 +const CONTACT_TOLERANCE: float = 34.0 +const MAX_ROUTE_LENGTH: float = 5000.0 + + +static func solve(rig: StickmanRig, prop: PropBlock, spot_id: String, actor_root: Vector2, predicted_facing_left: bool, _options: Dictionary = {}) -> Dictionary: + var result: Dictionary = {"known": true, "valid": false, "reason_code": "out_of_reach", "actor_root": actor_root, "spot_world": Vector2.INF, "approach_root": actor_root, "approach_feet": Vector2.INF, "facing_left": predicted_facing_left, "hand": 1, "reach_start": actor_root, "reach_end": Vector2.INF} + if rig == null or not is_instance_valid(rig) or not actor_root.is_finite(): + result["reason_code"] = "out_of_reach" + return result + if not _transform_is_finite_invertible(rig.global_transform): + result["reason_code"] = "out_of_reach" + return result + if not _has_ground_support(rig, actor_root): + result["reason_code"] = "route_blocked" + return result + if prop == null or not is_instance_valid(prop) or prop.is_queued_for_deletion(): + result["reason_code"] = "prop_missing" + return result + if not _transform_is_finite_invertible(prop.global_transform): + result["reason_code"] = "spot_missing" + return result + if prop.get_use_spot(spot_id).is_empty(): + result["reason_code"] = "spot_missing" + return result + var held_prop: PropBlock = rig.get_held_prop() if rig.has_method("get_held_prop") else null + if held_prop != null and is_instance_valid(held_prop): + result["reason_code"] = "hands_full" + return result + if prop.interaction_owner_id != 0 or prop.interaction_reserved_by != 0: + result["reason_code"] = "prop_held" + return result + var spot_world: Vector2 = prop.use_spot_world_position(spot_id) + if not spot_world.is_finite(): + result["reason_code"] = "spot_missing" + return result + result["spot_world"] = spot_world + result["reach_end"] = spot_world + var candidates: Array[Dictionary] = [] + var saw_ground_route: bool = false + var saw_clear_standing: bool = false + for hand_value: int in [0, 1]: + var current_wrist: Vector2 = _wrist_world(rig, hand_value) + var shoulder: Vector2 = _shoulder_world(rig, hand_value) + var arm_reach: float = _arm_reach(rig, hand_value) + if not current_wrist.is_finite() or not shoulder.is_finite() or arm_reach <= 0.0: + continue + # Reach is measured from the shoulder joint. The wrist's current pose may + # be far from the spot even though IK can rotate the arm to touch it; this + # is especially important after locomotion changes the hand-marker pose. + if shoulder.distance_to(spot_world) <= arm_reach and _hand_path_is_clear(rig, prop, shoulder, spot_world) and _has_ground_support(rig, actor_root) and _standing_clear(rig, actor_root): + candidates.append({"root": actor_root, "hand": hand_value, "facing_left": predicted_facing_left, "wrist": current_wrist, "distance": 0.0}) + continue + for side: float in [-1.0, 1.0]: + var approach_root: Vector2 = Vector2(spot_world.x + side * APPROACH_GAP, actor_root.y) + var facing_left: bool = approach_root.x > spot_world.x + var shoulder_offset: Vector2 = _shoulder_offset(rig, hand_value, predicted_facing_left) + var wrist_offset: Vector2 = _wrist_offset(rig, hand_value, predicted_facing_left) + if predicted_facing_left != facing_left: + shoulder_offset.x = -shoulder_offset.x + wrist_offset.x = -wrist_offset.x + var candidate_shoulder: Vector2 = approach_root + shoulder_offset + var candidate_wrist: Vector2 = approach_root + wrist_offset + if candidate_shoulder.distance_to(spot_world) > arm_reach or not _hand_path_is_clear(rig, prop, candidate_shoulder, spot_world): + continue + var route: Dictionary = _grounded_route(rig, actor_root, approach_root) + if not bool(route.get("valid", false)): + continue + saw_ground_route = true + if not _has_ground_support(rig, approach_root) or not _standing_clear(rig, approach_root): + continue + saw_clear_standing = true + candidates.append({"root": approach_root, "hand": hand_value, "facing_left": facing_left, "wrist": candidate_wrist, "distance": float(route.get("distance", INF))}) + if candidates.is_empty(): + if not saw_ground_route and not _has_any_ground_route(rig, actor_root, spot_world): + result["reason_code"] = "route_blocked" + elif saw_ground_route and not saw_clear_standing: + result["reason_code"] = "no_standing_room" + else: + result["reason_code"] = "out_of_reach" + return result + candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return float(a.get("distance", INF)) < float(b.get("distance", INF)) + ) + var chosen: Dictionary = candidates[0] + var approach_root: Vector2 = chosen["root"] + result["valid"] = true + result["reason_code"] = "" + result["approach_root"] = approach_root + result["approach_feet"] = approach_root - StickmanLocomotion.FOOT_OFFSET + result["facing_left"] = bool(chosen["facing_left"]) + result["hand"] = int(chosen["hand"]) + result["reach_start"] = chosen["wrist"] + return result + + +static func _wrist_world(rig: StickmanRig, hand_value: int) -> Vector2: + var suffix: String = "Left" if hand_value == 0 else "Right" + var lower_visual: Node2D = rig.get_node_or_null(NodePath("Body/%sLowerArm" % suffix)) as Node2D + var lower_bone: Bone2D = rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm/%sLowerArm" % [suffix, suffix])) as Bone2D + if lower_visual == null or lower_bone == null: + var hand_marker: Marker2D = rig.get_node_or_null(NodePath("IK_Targets/%s_Hand" % suffix)) as Marker2D + return hand_marker.global_position if hand_marker != null else Vector2.INF + return lower_visual.to_global(Vector2(0.0, lower_bone.length)) + + +static func _shoulder_world(rig: StickmanRig, hand_value: int) -> Vector2: + var suffix: String = "Left" if hand_value == 0 else "Right" + var upper: Bone2D = rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm" % suffix)) as Bone2D + return upper.global_position if upper != null else rig.global_position + + +static func _arm_reach(rig: StickmanRig, hand_value: int) -> float: + var suffix: String = "Left" if hand_value == 0 else "Right" + var upper: Bone2D = rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm" % suffix)) as Bone2D + var lower: Bone2D = rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm/%sLowerArm" % [suffix, suffix])) as Bone2D + if upper == null or lower == null: + return 0.0 + if not is_finite(upper.length) or not is_finite(lower.length): + return 0.0 + # Bone lengths are authored in local space. Measure their world-space + # endpoints so resized, mirrored, and nonuniformly scaled rigs use the same + # reach that the IK solver can actually produce. + var shoulder: Vector2 = upper.global_position + var elbow: Vector2 = lower.global_position + var wrist: Vector2 = _wrist_world(rig, hand_value) + if not shoulder.is_finite() or not elbow.is_finite() or not wrist.is_finite(): + return 0.0 + return shoulder.distance_to(elbow) + elbow.distance_to(wrist) + + +static func _transform_is_finite_invertible(value: Transform2D) -> bool: + return value.origin.is_finite() and value.x.is_finite() and value.y.is_finite() and is_finite(value.determinant()) and absf(value.determinant()) > 0.0001 + + +static func _shoulder_offset(rig: StickmanRig, hand_value: int, facing_left: bool) -> Vector2: + var local: Vector2 = rig.to_local(_shoulder_world(rig, hand_value)) + if rig.get_facing_profile() == StickmanRig.FacingProfile.LEFT: + local.x = -local.x + if facing_left: + local.x = -local.x + return rig.global_transform.basis_xform(local) + + +static func _wrist_offset(rig: StickmanRig, hand_value: int, facing_left: bool) -> Vector2: + var local: Vector2 = rig.to_local(_wrist_world(rig, hand_value)) + if rig.get_facing_profile() == StickmanRig.FacingProfile.LEFT: + local.x = -local.x + if facing_left: + local.x = -local.x + return rig.global_transform.basis_xform(local) + + +static func _hand_path_is_clear(rig: StickmanRig, prop: PropBlock, start: Vector2, finish: Vector2) -> bool: + if not start.is_finite() or not finish.is_finite(): + return false + var state: PhysicsDirectSpaceState2D = rig.get_world_2d().direct_space_state + if state == null: + return true + var query: PhysicsRayQueryParameters2D = PhysicsRayQueryParameters2D.create(start, finish) + query.collision_mask = STAGE_PHYSICS.TERRAIN_LAYER | STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER + var excluded: Array[RID] = rig.get_collision_body_rids() + if prop.get_rid().is_valid(): + excluded.append(prop.get_rid()) + query.exclude = excluded + return state.intersect_ray(query).is_empty() + + +static func _grounded_route(rig: StickmanRig, from_root: Vector2, to_root: Vector2) -> Dictionary: + var locomotion: StickmanLocomotion = rig.get_locomotion() + if locomotion == null or locomotion._nav_agent == null: + return {"valid": false, "distance": INF} + var nav: NavigationAgent2D = locomotion._nav_agent + var map: RID = nav.get_navigation_map() + if not map.is_valid() or NavigationServer2D.map_get_iteration_id(map) == 0: + return {"valid": false, "distance": INF} + var from_feet: Vector2 = from_root - StickmanLocomotion.FOOT_OFFSET + var to_feet: Vector2 = to_root - StickmanLocomotion.FOOT_OFFSET + var path: PackedVector2Array = NavigationServer2D.map_get_path(map, from_feet, to_feet, true) + if path.is_empty() or path[0].distance_to(from_feet) > 180.0 or path[path.size() - 1].distance_to(to_feet) > 180.0: + return {"valid": false, "distance": INF} + if not _actor_path_is_clear(rig, from_feet, path, from_root - rig.global_position): + return {"valid": false, "distance": INF} + var distance: float = 0.0 + for index: int in range(1, path.size()): + distance += path[index - 1].distance_to(path[index]) + if distance > MAX_ROUTE_LENGTH: + return {"valid": false, "distance": INF} + return {"valid": true, "distance": distance} + + +static func _actor_path_is_clear(rig: StickmanRig, from_feet: Vector2, path: PackedVector2Array, initial_offset: Vector2) -> bool: + var state: PhysicsDirectSpaceState2D = rig.get_world_2d().direct_space_state + if state == null: + return false + var excluded: Array[RID] = rig.get_collision_body_rids() + var bodies: Dictionary = rig.get_collision_bodies() + var route_points: PackedVector2Array = PackedVector2Array() + route_points.append(from_feet) + route_points.append_array(path) + var offset: Vector2 = initial_offset + for index: int in range(1, route_points.size()): + var motion: Vector2 = route_points[index] - route_points[index - 1] + if absf(motion.x) <= 0.001: + offset += motion + continue + for part_name: String in ["torso", "head"]: + var proxy: AnimatableBody2D = bodies.get(part_name) as AnimatableBody2D + if proxy == null or not is_instance_valid(proxy): + continue + var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D + if shape_node == null or shape_node.shape == null: + continue + var query := PhysicsShapeQueryParameters2D.new() + query.shape = shape_node.shape + query.transform = shape_node.global_transform + query.transform.origin += offset + query.motion = motion + query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER + query.exclude = excluded + var cast: PackedFloat32Array = state.cast_motion(query) + if cast.size() > 0 and cast[0] < 0.999: + return false + offset += motion + return true + + +static func _has_ground_support(rig: StickmanRig, root_position: Vector2) -> bool: + var state: PhysicsDirectSpaceState2D = rig.get_world_2d().direct_space_state + if state == null: + return false + var feet: Vector2 = root_position - StickmanLocomotion.FOOT_OFFSET + var query: PhysicsRayQueryParameters2D = PhysicsRayQueryParameters2D.create(feet + Vector2(0.0, -24.0), feet + Vector2(0.0, 88.0)) + query.collision_mask = STAGE_PHYSICS.TERRAIN_LAYER + query.exclude = rig.get_collision_body_rids() + var hit: Dictionary = state.intersect_ray(query) + return not hit.is_empty() and (hit.get("normal", Vector2.UP) as Vector2).dot(Vector2.UP) >= 0.7 + + +static func _standing_clear(rig: StickmanRig, root_position: Vector2) -> bool: + var state: PhysicsDirectSpaceState2D = rig.get_world_2d().direct_space_state + if state == null: + return false + var motion: Vector2 = root_position - rig.global_position + var excluded: Array[RID] = rig.get_collision_body_rids() + for proxy: AnimatableBody2D in rig.get_collision_bodies().values(): + if proxy == null or not is_instance_valid(proxy): + continue + var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D + if shape_node == null or shape_node.shape == null: + continue + var query: PhysicsShapeQueryParameters2D = PhysicsShapeQueryParameters2D.new() + query.shape = shape_node.shape + var candidate_transform: Transform2D = shape_node.global_transform + candidate_transform.origin += motion + query.transform = candidate_transform + query.collision_mask = STAGE_PHYSICS.ACTOR_LAYER | STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER + query.exclude = excluded + query.margin = 2.0 + if not state.intersect_shape(query, 8).is_empty(): + return false + return true + + +static func _has_any_ground_route(rig: StickmanRig, actor_root: Vector2, spot_world: Vector2) -> bool: + for side: float in [-1.0, 1.0]: + var candidate: Vector2 = Vector2(spot_world.x + side * APPROACH_GAP, actor_root.y) + var locomotion: StickmanLocomotion = rig.get_locomotion() + if locomotion == null or locomotion._nav_agent == null: + continue + var nav: NavigationAgent2D = locomotion._nav_agent + var map: RID = nav.get_navigation_map() + if not map.is_valid() or NavigationServer2D.map_get_iteration_id(map) == 0: + continue + var from_feet: Vector2 = actor_root - StickmanLocomotion.FOOT_OFFSET + var to_feet: Vector2 = candidate - StickmanLocomotion.FOOT_OFFSET + var path: PackedVector2Array = NavigationServer2D.map_get_path(map, from_feet, to_feet, true) + if not path.is_empty() and path[0].distance_to(from_feet) <= 180.0 and path[path.size() - 1].distance_to(to_feet) <= 180.0 and _actor_path_is_clear(rig, from_feet, path, actor_root - rig.global_position): + return true + return false diff --git a/scripts/characters/stickman/stickman_use_reach_solver.gd.uid b/scripts/characters/stickman/stickman_use_reach_solver.gd.uid new file mode 100644 index 0000000..e293f78 --- /dev/null +++ b/scripts/characters/stickman/stickman_use_reach_solver.gd.uid @@ -0,0 +1 @@ +uid://doq1r0f3qgr2y diff --git a/scripts/editors/stage/action_editor.gd b/scripts/editors/stage/action_editor.gd index 12c2dc0..a812337 100644 --- a/scripts/editors/stage/action_editor.gd +++ b/scripts/editors/stage/action_editor.gd @@ -31,12 +31,18 @@ var _surface_color: Color = Color("#252B38") var _border_color: Color = Color("#FFFFFF33") var _prop_target_label: Label = null var _set_prop_target_btn: Button = null +var _use_target_label: Label = null +var _set_use_target_btn: Button = null var _hand_option: OptionButton = null var _stop_current_check: CheckBox = null var _show_reactive_options: bool = false var _has_target: bool = false var _target_pos: Vector2 = Vector2.ZERO var _prop_target_id: String = "" +var _use_prop_target_id: String = "" +var _use_spot_target_id: String = "" +var _use_target_summary: String = "" +var _use_target_summary_resolver: Callable = Callable() var _has_throw_target: bool = false var _throw_target_pos: Vector2 = Vector2.ZERO @@ -68,6 +74,9 @@ func open_new(show_reactive_options: bool = false) -> void: _target_pos = Vector2.ZERO _move_style = ACTION_REGISTRY.WALK _prop_target_id = "" + _use_prop_target_id = "" + _use_spot_target_id = "" + _use_target_summary = "" _has_throw_target = false _throw_target_pos = Vector2.ZERO _select_type(ACTION_REGISTRY.MOVE_TO) @@ -88,6 +97,9 @@ func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void: _has_target = action.has("target") and action["target"] is Vector2 _target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO _prop_target_id = String(action.get("target_prop_id", "")) + _use_prop_target_id = String(action.get("target_prop_id", "")) if type == "use_prop" else "" + _use_spot_target_id = String(action.get("target_spot_id", "")) if type == "use_prop" else "" + _use_target_summary = "Use spot chosen" if not _use_spot_target_id.is_empty() else "" _has_throw_target = action.has("target_position") and action["target_position"] is Vector2 _throw_target_pos = action.get("target_position", Vector2.ZERO) if _has_throw_target else Vector2.ZERO _move_style = ACTION_REGISTRY.checked_style(action.get("style", null)) @@ -113,6 +125,25 @@ func set_prop_target(prop_id: String) -> void: popup_centered() +func set_use_target(prop_id: String, spot_id: String, summary: String) -> void: + _use_prop_target_id = prop_id + _use_spot_target_id = spot_id + _use_target_summary = summary + _update_use_target_label() + popup_centered() + + +func set_use_target_summary_resolver(resolver: Callable) -> void: + _use_target_summary_resolver = resolver + refresh_use_target_summary() + + +func refresh_use_target_summary() -> void: + if _use_target_summary_resolver.is_valid() and not _use_prop_target_id.is_empty() and not _use_spot_target_id.is_empty(): + _use_target_summary = String(_use_target_summary_resolver.call(_use_prop_target_id, "specific", _use_spot_target_id)) + _update_use_target_label() + + func set_throw_target(pos: Vector2) -> void: _has_throw_target = true _throw_target_pos = pos @@ -250,6 +281,8 @@ func _rebuild_params(type: String) -> void: _set_target_btn = null _prop_target_label = null _set_prop_target_btn = null + _use_target_label = null + _set_use_target_btn = null _hand_option = null _stop_current_check = null _walk_style_btn = null @@ -301,6 +334,19 @@ func _rebuild_params(type: String) -> void: var hand_idx := ["auto", "left", "right"].find(String(_initial.get("hand", "auto"))) _hand_option.select(maxi(hand_idx, 0)) hand_row.add_child(_hand_option) + "use_prop": + var use_row := VBoxContainer.new() + use_row.add_theme_constant_override("separation", 6) + _params_box.add_child(use_row) + _use_target_label = Label.new() + _use_target_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + use_row.add_child(_use_target_label) + _set_use_target_btn = Button.new() + _set_use_target_btn.text = "Choose prop and spot" + _set_use_target_btn.custom_minimum_size.y = 48.0 + _set_use_target_btn.pressed.connect(func() -> void: target_requested.emit()) + use_row.add_child(_set_use_target_btn) + _update_use_target_label() "throw_prop", "set_down_prop": var throw_row := HBoxContainer.new() _params_box.add_child(throw_row) @@ -414,6 +460,21 @@ func _update_prop_target_label() -> void: _prop_target_label.text = "Prop: %s" % (_prop_target_id if not _prop_target_id.is_empty() else "not set") +func _update_use_target_label() -> void: + if _use_target_label == null: + return + if _use_target_summary_resolver.is_valid() and not _use_prop_target_id.is_empty() and not _use_spot_target_id.is_empty(): + _use_target_summary = String(_use_target_summary_resolver.call(_use_prop_target_id, "specific", _use_spot_target_id)) + if not _use_prop_target_id.is_empty() and not _use_spot_target_id.is_empty(): + _use_target_label.text = "Using: %s" % (_use_target_summary if not _use_target_summary.is_empty() else "Use spot chosen") + if _set_use_target_btn != null: + _set_use_target_btn.text = "Change spot" + else: + _use_target_label.text = "Choose a prop and one exact Use spot." + if _set_use_target_btn != null: + _set_use_target_btn.text = "Choose prop and spot" + + func _update_throw_target_label() -> void: if _target_label == null: return @@ -442,6 +503,15 @@ func _on_ok_pressed() -> void: var hands: Array[String] = ["auto", "left", "right"] action["target_prop_id"] = _prop_target_id action["hand"] = hands[_hand_option.selected] if _hand_option != null and _hand_option.selected >= 0 else "auto" + "use_prop": + if _use_prop_target_id.is_empty() or _use_spot_target_id.is_empty(): + target_requested.emit() + return + if _use_target_summary == "Choose a new spot": + target_requested.emit() + return + action["target_prop_id"] = _use_prop_target_id + action["target_spot_id"] = _use_spot_target_id "throw_prop", "set_down_prop": if not _has_throw_target: target_requested.emit() diff --git a/scripts/editors/stage/queue_panel.gd b/scripts/editors/stage/queue_panel.gd index 377eb14..47354ad 100644 --- a/scripts/editors/stage/queue_panel.gd +++ b/scripts/editors/stage/queue_panel.gd @@ -36,6 +36,7 @@ var _base_font_size: int = 0 var _row_font_size: int = 0 var _title_font_size: int = 0 var _title_font: Font = null +var _use_target_summary_resolver: Callable = Callable() func _ready() -> void: @@ -56,6 +57,11 @@ func setup(r: StickmanRig) -> void: refresh() +func set_use_target_summary_resolver(resolver: Callable) -> void: + _use_target_summary_resolver = resolver + refresh() + + func refresh() -> void: if _list == null: return @@ -145,7 +151,14 @@ func _make_row(index: int, action: Dictionary, status: int) -> PanelContainer: var summary := Label.new() summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL - summary.text = "%s %s" % [ACTION_REGISTRY.icon(String(action.get("type", ""))), ACTION_REGISTRY.summarize(action)] + var action_summary: String = ACTION_REGISTRY.summarize(action) + if String(action.get("type", "")) == "use_prop" and _use_target_summary_resolver.is_valid(): + action_summary = String(_use_target_summary_resolver.call( + String(action.get("target_prop_id", "")), + "specific", + String(action.get("target_spot_id", "")) + )) + summary.text = "%s %s" % [ACTION_REGISTRY.icon(String(action.get("type", ""))), action_summary] row.add_child(summary) var status_label := Label.new() status_label.text = _status_label(status) diff --git a/scripts/editors/stage/rule_editor.gd b/scripts/editors/stage/rule_editor.gd index 0006c31..9febb14 100644 --- a/scripts/editors/stage/rule_editor.gd +++ b/scripts/editors/stage/rule_editor.gd @@ -34,6 +34,7 @@ var _action_type_option: OptionButton = null var _trigger_readonly_label: Label = null var _actions_box: VBoxContainer = null var _empty_actions_label: Label = null +var _use_target_summary_resolver: Callable = Callable() ## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no ## override (engine default); Font null = no override. @@ -79,6 +80,28 @@ func set_trigger_target(target_id: int, params: Dictionary) -> void: popup_centered() +func set_trigger_use_target(target_prop_id: String, spot_scope: String, target_spot_id: String = "") -> void: + var params: Dictionary = {"target_prop_id": target_prop_id, "spot_scope": spot_scope} + if spot_scope == "specific": + params["target_spot_id"] = target_spot_id + _trigger["type"] = "prop_used" + _trigger["target"] = -1 + _trigger["params"] = params + _refresh_trigger() + popup_centered() + + +func set_use_target_summary_resolver(resolver: Callable) -> void: + _use_target_summary_resolver = resolver + _refresh_trigger() + _refresh_actions() + + +func refresh_use_target_summaries() -> void: + _refresh_trigger() + _refresh_actions() + + ## Called by the stage after an action is collected. `index` < 0 appends. func set_action(index: int, action: Dictionary) -> void: if index < 0: @@ -272,7 +295,15 @@ func _refresh_trigger() -> void: _target_row.visible = full if not full: - _trigger_readonly_label.text = "When: %s %s" % [_actor_name(int(_trigger.get("source", -1))), TRIGGER_REGISTRY.summarize(_trigger)] + var read_only_summary: String = TRIGGER_REGISTRY.summarize(_trigger) + if type == "prop_used": + var params: Dictionary = _trigger.get("params", {}) + read_only_summary = "uses %s" % _resolve_use_target_summary( + String(params.get("target_prop_id", "")), + String(params.get("spot_scope", "any")), + String(params.get("target_spot_id", "")) + ) + _trigger_readonly_label.text = "When: %s %s" % [_actor_name(int(_trigger.get("source", -1))), read_only_summary] return # Show only the target control relevant to this trigger type. @@ -296,6 +327,13 @@ func _refresh_trigger() -> void: "prop": _target_label.text = "Target: %s" % _node_name(int(_trigger.get("target", -1))) _set_target_btn.visible = true + "use_prop": + var use_params: Dictionary = _trigger.get("params", {}) + var prop_id: String = String(use_params.get("target_prop_id", "")) + var scope: String = String(use_params.get("spot_scope", "any")) + _target_label.text = _resolve_use_target_summary(prop_id, scope, String(use_params.get("target_spot_id", ""))) + _set_target_btn.text = "Choose prop" + _set_target_btn.visible = true _: pass @@ -346,10 +384,14 @@ func _make_action_row(index: int, action: Dictionary) -> Control: var summary := Label.new() summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var action_summary: String = ACTION_REGISTRY.summarize(action) + if String(action.get("type", "")) == "use_prop": + var flat: Dictionary = ACTION_REGISTRY.from_rule_action(action) + action_summary = _resolve_use_target_summary(String(flat.get("target_prop_id", "")), "specific", String(flat.get("target_spot_id", ""))) summary.text = "%s %s %s" % [ ACTION_REGISTRY.icon(String(action.get("type", ""))), _actor_name(int(action.get("target", -1))), - ACTION_REGISTRY.summarize(action), + action_summary, ] row.add_child(summary) @@ -378,6 +420,17 @@ func _on_ok_pressed() -> void: if _current_type() == "action_finished": _sync_action_type() _trigger["type"] = _current_type() + if String(_trigger.get("type", "")) == "prop_used": + var use_params: Dictionary = _trigger.get("params", {}) + var target_prop_id: String = String(use_params.get("target_prop_id", "")) + var spot_scope: String = String(use_params.get("spot_scope", "")) + if target_prop_id.is_empty() or spot_scope not in ["any", "specific"] or (spot_scope == "specific" and String(use_params.get("target_spot_id", "")).is_empty()) or _resolve_use_target_summary(target_prop_id, spot_scope, String(use_params.get("target_spot_id", ""))) == "Choose a new spot": + return + for action: Dictionary in _actions: + if String(action.get("type", "")) == "use_prop": + var flat: Dictionary = ACTION_REGISTRY.from_rule_action(action) + if _resolve_use_target_summary(String(flat.get("target_prop_id", "")), "specific", String(flat.get("target_spot_id", ""))) == "Choose a new spot": + return var rule := { "id": _rule_id, "trigger": _trigger.duplicate(true), @@ -386,6 +439,14 @@ func _on_ok_pressed() -> void: committed.emit(rule) +func _resolve_use_target_summary(prop_id: String, scope: String, spot_id: String) -> String: + if prop_id.is_empty(): + return "Choose a new spot" + if not _use_target_summary_resolver.is_valid(): + return "Choose a new spot" + return String(_use_target_summary_resolver.call(prop_id, scope, spot_id)) + + func _actor_name(id: int) -> String: if id <= 0: # -1 is the "no source / no actor" sentinel; instance_from_id(-1) would diff --git a/scripts/editors/stage/rule_panel.gd b/scripts/editors/stage/rule_panel.gd index 01a05e7..369a360 100644 --- a/scripts/editors/stage/rule_panel.gd +++ b/scripts/editors/stage/rule_panel.gd @@ -34,6 +34,7 @@ var _base_font_size: int = 0 var _row_font_size: int = 0 var _title_font_size: int = 0 var _title_font: Font = null +var _use_target_summary_resolver: Callable = Callable() func _ready() -> void: @@ -55,6 +56,11 @@ func set_rules(rules: Array[Dictionary], title_hint: String) -> void: refresh() +func set_use_target_summary_resolver(resolver: Callable) -> void: + _use_target_summary_resolver = resolver + refresh() + + ## Attaches a filtered rule list + a source hint, rebuilds the rows and pops up. func show_rules(rules: Array[Dictionary], title_hint: String) -> void: set_rules(rules, title_hint) @@ -231,6 +237,14 @@ func _make_label(text: String, dim: bool = false) -> Label: func _trigger_summary(rule: Dictionary) -> String: var trigger: Dictionary = rule.get("trigger", {}) var type := String(trigger.get("type", "")) + if type == "prop_used": + var params: Dictionary = trigger.get("params", {}) + var target_summary: String = _resolve_use_target_summary( + String(params.get("target_prop_id", "")), + String(params.get("spot_scope", "any")), + String(params.get("target_spot_id", "")) + ) + return "%s When %s uses %s" % [TRIGGER_REGISTRY.icon(type), _actor_name(int(trigger.get("source", -1))), target_summary] return "%s %s %s" % [ TRIGGER_REGISTRY.icon(type), _actor_name(int(trigger.get("source", -1))), @@ -241,14 +255,28 @@ func _trigger_summary(rule: Dictionary) -> String: func _action_summaries(rule: Dictionary) -> Array[String]: var out: Array[String] = [] for a: Dictionary in rule.get("actions", []): + var action_summary: String = ACTION_REGISTRY.summarize(a) + if String(a.get("type", "")) == "use_prop": + var flat: Dictionary = ACTION_REGISTRY.from_rule_action(a) + action_summary = _resolve_use_target_summary( + String(flat.get("target_prop_id", "")), + "specific", + String(flat.get("target_spot_id", "")) + ) out.append("→ %s %s %s" % [ ACTION_REGISTRY.icon(String(a.get("type", ""))), _actor_name(int(a.get("target", -1))), - ACTION_REGISTRY.summarize(a), + action_summary, ]) return out +func _resolve_use_target_summary(prop_id: String, scope: String, spot_id: String) -> String: + if not _use_target_summary_resolver.is_valid(): + return "Choose a new spot" + return String(_use_target_summary_resolver.call(prop_id, scope, spot_id)) + + func _row_style(bg: Color) -> StyleBoxFlat: var sb := StyleBoxFlat.new() sb.bg_color = bg diff --git a/scripts/editors/stage/sandbox_stage.gd b/scripts/editors/stage/sandbox_stage.gd index 0036ff2..06fe0ca 100644 --- a/scripts/editors/stage/sandbox_stage.gd +++ b/scripts/editors/stage/sandbox_stage.gd @@ -34,6 +34,8 @@ const STAGE_SPATIAL_INDEX := preload("res://scripts/stage/stage_spatial_index.gd const STAGE_TERRAIN_PAINTER := preload("res://scripts/editors/stage/stage_terrain_painter.gd") const STAGE_CHROME_SCRIPT := preload("res://scripts/editors/stage/stage_chrome.gd") const STAGE_PROP_SETTINGS_VIEW_SCRIPT := preload("res://scripts/editors/stage/stage_prop_settings_view.gd") +const STAGE_USE_SPOT_OVERLAY_SCRIPT := preload("res://scripts/editors/stage/stage_use_spot_overlay.gd") +const STAGE_USE_TARGET_CAPTURE_BAR_SCRIPT := preload("res://scripts/editors/stage/stage_use_target_capture_bar.gd") const STAGE_SCENE_ADAPTER_SCRIPT := preload("res://scripts/editors/stage/stage_scene_adapter.gd") const STAGE_DOCUMENT_CONTROLLER_SCRIPT := preload("res://scripts/editors/stage/stage_document_controller.gd") const STAGE_RULE_ENGINE_SCRIPT := preload("res://scripts/stage/stage_rule_engine.gd") @@ -139,6 +141,7 @@ const TRIG_BACK: int = STAGE_RULE_AUTHORING_SCRIPT.TRIG_BACK const TRIG_PROP_PICKED_UP: int = STAGE_RULE_AUTHORING_SCRIPT.TRIG_PROP_PICKED_UP const TRIG_PROP_DROPPED: int = STAGE_RULE_AUTHORING_SCRIPT.TRIG_PROP_DROPPED const TRIG_PROP_THROWN: int = STAGE_RULE_AUTHORING_SCRIPT.TRIG_PROP_THROWN +const TRIG_PROP_USED: int = STAGE_RULE_AUTHORING_SCRIPT.TRIG_PROP_USED ## Phase 4 "add another / done" popup item ids. const RULE_MORE_ADD: int = STAGE_RULE_AUTHORING_SCRIPT.RULE_MORE_ADD @@ -155,6 +158,8 @@ const ACT_PICK_UP_PROP := 9 const ACT_DROP_PROP := 10 const ACT_THROW_PROP := 11 const ACT_SET_DOWN_PROP := 12 +const ACT_USE_PROP := 13 +const USE_PREVIEW_DEBUG_INTERVAL_MS: int = 900 ## Phase 3c stickman right-click context-menu item ids. const RIG_CTX_EDIT_QUEUE := 0 @@ -243,6 +248,8 @@ var _asset_browser: StageAssetBrowser var _modal_views: StageModalViews var _chrome: StageChrome var _prop_settings_view: StagePropSettingsView +var _use_spot_overlay: StageUseSpotOverlay +var _use_target_capture_bar: StageUseTargetCaptureBar var _placement_overlay = null # StagePlacementOverlay (preloaded) var _placement_id: String = "" @@ -1009,7 +1016,7 @@ var _rule_panel_filter_ids: Array[int]: var _target_capture: StageTargetCaptureSession = STAGE_TARGET_CAPTURE_SCRIPT.new() as StageTargetCaptureSession var _capture_kind: CaptureKind: get: - return _target_capture.kind + return CaptureKind.NONE if _target_capture.is_stage_custom_capture() else _target_capture.kind set(value): _target_capture.kind = int(value) var _capture_hint: String: @@ -1028,6 +1035,79 @@ var _capture_cancel: Callable: set(value): _target_capture.cancel_callback = value var _set_down_cancel_button: Button = null +var _capture_controls_left: float = 128.0 +var _spot_edit_prop: PropBlock: + get: + return _target_capture.spot_edit_prop + set(value): + _target_capture.spot_edit_prop = value +var _spot_edit_id: String: + get: + return _target_capture.spot_edit_id + set(value): + _target_capture.spot_edit_id = value +var _spot_edit_active: bool: + get: + return _target_capture.spot_edit_active + set(value): + _target_capture.spot_edit_active = value +var _spot_edit_ghost_world: Vector2: + get: + return _target_capture.spot_edit_ghost_world + set(value): + _target_capture.spot_edit_ghost_world = value +var _spot_selected_id: String = "" +var _spot_erase_dialog: ConfirmationDialog +var _spot_erase_prop_id: String = "" +var _spot_erase_id: String = "" +var _use_target_active: bool: + get: + return _target_capture.use_target_active + set(value): + _target_capture.use_target_active = value +var _use_target_prop: PropBlock: + get: + return _target_capture.use_target_prop + set(value): + _target_capture.use_target_prop = value +var _use_target_actor: StickmanRig: + get: + return _target_capture.use_target_actor + set(value): + _target_capture.use_target_actor = value +var _use_target_context: String: + get: + return _target_capture.use_target_context + set(value): + _target_capture.use_target_context = value +var _use_target_step: int: + get: + return _target_capture.use_target_step + set(value): + _target_capture.use_target_step = value +var _use_target_choices: Array[Dictionary]: + get: + return _target_capture.use_target_choices + set(value): + _target_capture.use_target_choices = value +var _use_target_preview_cache: Dictionary: + get: + return _target_capture.use_target_preview_cache + set(value): + _target_capture.use_target_preview_cache = value +var _use_target_preview_prefix: Array[Dictionary]: + get: + return _target_capture.use_target_preview_prefix + set(value): + _target_capture.use_target_preview_prefix = value +var _use_target_prefix_known: bool: + get: + return _target_capture.use_target_prefix_known + set(value): + _target_capture.use_target_prefix_known = value +var _use_choice_popup: PopupMenu +var _use_choice_values: Array[Dictionary] = [] +var _use_preview_debug_last_msec_by_key: Dictionary = {} ## ActionEditor context compatibility facades; the queue controller owns it. var _action_editor_kind: String: @@ -1170,6 +1250,13 @@ func _ready() -> void: _build_ui() _rule_authoring.configure(_action_popup, _speak_stop_current_check, get_viewport()) _action_queue_controller.configure(_queue_panel, _action_editor, _rule_editor) + var use_summary_resolver: Callable = Callable(self, "_resolve_use_target_summary") + if _queue_panel != null: + _queue_panel.set_use_target_summary_resolver(use_summary_resolver) + if _action_editor != null: + _action_editor.set_use_target_summary_resolver(use_summary_resolver) + if _rule_authoring != null: + _rule_authoring.set_use_target_summary_resolver(use_summary_resolver) _speech_session.configure(_world, _speech_composer, _speech_style_toolbar, _speech_show_toolbar, _camera, get_viewport(), _chrome.get_canvas_layer()) _speech_session.camera_input_requested.connect(_on_speech_camera_input_requested) _speech_session.camera_pan_cancel_requested.connect(_on_speech_camera_pan_cancel_requested) @@ -1204,6 +1291,9 @@ func _process(delta: float) -> void: if current_mode == StageMode.EDIT: if _terrain_dragging: _update_terrain_drag(_camera.get_global_mouse_position()) + elif _spot_edit_active and _spot_edit_prop != null and is_instance_valid(_spot_edit_prop): + _spot_edit_ghost_world = _camera.get_global_mouse_position() + _use_spot_overlay.show_edit(_spot_edit_prop, _spot_edit_ghost_world, "Click a place on this prop" if _spot_edit_id.is_empty() else "Click the spot's new place", _spot_selected_id, _spot_edit_id) elif _ghost != null and is_instance_valid(_ghost): _update_ghost_position() _update_cursor_coords() @@ -1222,6 +1312,7 @@ func _process(delta: float) -> void: func _physics_process(delta: float) -> void: _update_throw_preview_cache() + _update_use_target_preview_cache() var elapsed_timestamp: float = _run_session.advance_elapsed(delta) if _run_session != null else -1.0 if elapsed_timestamp >= 0.0: _set_timestamp(elapsed_timestamp) @@ -1257,6 +1348,10 @@ func _input(event: InputEvent) -> void: if _speech_session.should_mark_input_handled(): get_viewport().set_input_as_handled() return + if event is InputEventKey and (event as InputEventKey).pressed and not (event as InputEventKey).echo and (event as InputEventKey).keycode == KEY_ESCAPE and _spot_edit_active: + _cancel_spot_work() + get_viewport().set_input_as_handled() + return if event is InputEventKey and (event as InputEventKey).keycode == KEY_SPACE: _handle_space_key_event(event as InputEventKey) return @@ -1277,7 +1372,9 @@ func _input(event: InputEvent) -> void: _set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor) elif event is InputEventPanGesture: # Touchpad two-finger pan (delta is screen px; ×3 for speed parity). - _camera.position -= (event as InputEventPanGesture).delta * 3.0 / _camera.zoom.x + # Let scrollable controls own gestures while the pointer is over the UI. + if not _is_mouse_over_ui(): + _camera.position -= (event as InputEventPanGesture).delta * 3.0 / _camera.zoom.x elif event is InputEventMouseButton: _handle_mouse_button(event) elif event is InputEventMouseMotion: @@ -1351,7 +1448,10 @@ func _unhandled_key_input(event: InputEvent) -> void: if current_mode == StageMode.EDIT: delete_selected() KEY_ESCAPE: - if _speech_placing: + if _spot_edit_active or _use_target_active: + _cancel_spot_work() + get_viewport().set_input_as_handled() + elif _speech_placing: if _speech_session != null: _speech_session.cancel_placement() _refresh_status() @@ -1433,10 +1533,10 @@ func _space_should_pass_to_gui() -> bool: func _handle_mouse_button(mb: InputEventMouseButton) -> void: match mb.button_index: MOUSE_BUTTON_WHEEL_UP: - if mb.pressed: + if mb.pressed and not _is_mouse_over_ui(): _set_zoom(_camera.zoom.x * ZOOM_STEP) MOUSE_BUTTON_WHEEL_DOWN: - if mb.pressed: + if mb.pressed and not _is_mouse_over_ui(): _set_zoom(_camera.zoom.x / ZOOM_STEP) MOUSE_BUTTON_MIDDLE: _panning = mb.pressed @@ -1463,7 +1563,9 @@ func _handle_world_click(mb: InputEventMouseButton) -> void: return if mb.button_index == MOUSE_BUTTON_RIGHT: if mb.pressed: - if _capture_kind != CaptureKind.NONE: + if _spot_edit_active or _use_target_active: + _cancel_spot_work() + elif _capture_kind != CaptureKind.NONE: _cancel_capture() elif _rule_step != RuleStep.IDLE: _cancel_rule_build() @@ -1478,10 +1580,18 @@ func _handle_world_click(mb: InputEventMouseButton) -> void: # here can lag behind an unhandled press/release, leaving the gizmo with an # unchanged drag origin even though the pointer has moved. var world_pos := _viewport_to_world(mb.position) + if _spot_edit_active: + if mb.pressed: + _commit_spot_position(world_pos) + return + if _use_target_active: + if mb.pressed: + _handle_use_target_click(world_pos, mb.position) + return # Rule-builder click routing is highest priority (Phase 4). if _rule_step != RuleStep.IDLE: if mb.pressed: - _handle_rule_click(world_pos) + _handle_rule_click(world_pos, mb.position) return # Phase 3c editor target capture. if _capture_kind != CaptureKind.NONE: @@ -1506,7 +1616,11 @@ func _handle_world_click(mb: InputEventMouseButton) -> void: _commit_terrain_drag() return if mb.pressed: - if _gizmos.hit_test(world_pos) != STAGE_GIZMOS.Handle.NONE: + var spot_id: String = _use_spot_overlay.closest_hit_spot(mb.position) if _placement_id.is_empty() and not _is_mouse_over_ui() and _use_spot_overlay != null else "" + if not spot_id.is_empty(): + _on_spot_selected(spot_id) + get_viewport().set_input_as_handled() + elif _gizmos.hit_test(world_pos) != STAGE_GIZMOS.Handle.NONE: _gizmos.begin_drag(world_pos) elif _placement_id != "": if _spawner.is_terrain_id(_placement_id): @@ -1604,6 +1718,8 @@ func _enter_edit_mode(_previous_mode: StageMode) -> void: ## (a pending walk target or a half-built When->Then rule must not survive into ## another mode where its click routing would mis-fire). func _clear_director_pending() -> void: + if _spot_edit_active or _use_target_active: + _cancel_spot_work() if _speech_composer != null and _speech_composer.visible: _finish_speech_composition(false) _clear_popup_anchor() @@ -1679,6 +1795,8 @@ func _start_run() -> void: func _pause_run() -> void: if not _run_session.pause_run(_world_children_selectable()): return + if _use_spot_overlay != null: + _use_spot_overlay.set_paused(true) _refresh_event_edge_baseline() _gizmos.set_enabled(false) _selection.clear_selection() @@ -1695,6 +1813,8 @@ func _resume_run() -> void: _clear_director_pending() if not _run_session.resume_run(_world_children_selectable(), _rule_engine): return + if _use_spot_overlay != null: + _use_spot_overlay.set_paused(false) _sync_run_controls() simulation_resumed.emit(_elapsed_seconds) @@ -1708,6 +1828,9 @@ func _reset_run() -> void: _run_session.arm_authored_restore() if _rule_engine != null: _rule_engine.reset_runtime_state() + if _use_spot_overlay != null: + _use_spot_overlay.set_paused(false) + _use_spot_overlay.clear_session() _run_session.reset_elapsed() _set_timestamp(0.0) _director_visuals.set_live_session(false) @@ -1835,6 +1958,8 @@ func _finalize_placed_node(node: Node2D) -> void: rig.speech_finished.connect(_on_rig_speech_finished.bind(rig)) rig.state_changed.connect(_on_rig_state_changed.bind(rig)) rig.prop_interaction_event.connect(_on_rig_prop_interaction_event.bind(rig)) + rig.use_completed.connect(_on_rig_use_completed.bind(rig)) + rig.use_failed.connect(_on_rig_use_failed.bind(rig)) _director_visuals.mark_dirty() if node is PropBlock: var prop := node as PropBlock @@ -1881,6 +2006,44 @@ func _on_rig_prop_interaction_event(event_type: String, prop: PropBlock, reason: _handle_event({ "type": event_type, "source": rig, "target": prop, "position": prop.global_position, "reason": reason, "action": {} }) +func _on_rig_use_completed(prop: PropBlock, spot_id: String, world_position: Vector2, rig: StickmanRig) -> void: + if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or prop == null or not is_instance_valid(prop) or rig == null or not is_instance_valid(rig): + return + if _use_spot_overlay != null: + _use_spot_overlay.show_success(prop, spot_id) + _handle_event({"type": "prop_used", "source": rig, "target": prop, "spot_id": spot_id, "position": world_position, "action": {}}) + + +func _on_rig_use_failed(reason_code: String, prop: PropBlock, spot_id: String, _rig: StickmanRig) -> void: + if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING: + return + var message: String = _use_failure_message(reason_code) + if _use_spot_overlay != null and prop != null and is_instance_valid(prop): + _use_spot_overlay.show_failure(prop, spot_id, message) + _show_toast(message) + + +func _use_failure_message(reason_code: String) -> String: + match reason_code: + + "prop_missing": + return "Prop is gone" + "spot_missing": + return "Use spot is gone" + "hands_full": + return "Hands must be empty" + "prop_held": + return "Prop is busy" + "no_standing_room": + return "No room to stand" + "route_blocked": + return "Can't walk there" + "target_moved": + return "Target moved" + _: + return "Too far to reach" + + func _spawn_ghost() -> void: _free_ghost() if _placement_id == "": @@ -2115,6 +2278,7 @@ func delete_selected() -> void: _cleanup_rules_for_nodes(selected) _selection.clear_selection() _selection.clear_hover() + _refresh_repair_summaries() if nav_changed: _nav_dirty = true _director_visuals.mark_dirty() @@ -2261,6 +2425,7 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) _nav_dirty = true _suppress_dirty = was_suppressed _set_scene_dirty(is_recovery) + _refresh_repair_summaries() if not warnings.is_empty() or int(decoded_rules.get("skipped", 0)) > 0 or missing_stickmen > 0: _show_toast("Loaded with %d warning(s), %d missing stickman, %d skipped rule(s)" % [warnings.size(), missing_stickmen, int(decoded_rules.get("skipped", 0))]) if not warnings.is_empty(): @@ -2561,6 +2726,8 @@ func _build_ui() -> void: _modal_views.object_selected.connect(_on_objects_here_selected) _build_prop_settings_view(ui) _build_set_down_cancel_button(ui) + _build_use_target_capture_bar(ui) + _build_use_spot_overlay(ui) _refresh_prop_settings_card() _sync_run_controls() _set_timestamp(0.0) @@ -2583,6 +2750,7 @@ func _build_ui() -> void: "drop_prop": ACT_DROP_PROP, "throw_prop": ACT_THROW_PROP, "set_down_prop": ACT_SET_DOWN_PROP, + "use_prop": ACT_USE_PROP, "when": ACT_WHEN, "edit_queue": ACT_EDIT_QUEUE, "edit_rules": ACT_EDIT_RULES @@ -2598,6 +2766,7 @@ func _build_ui() -> void: "trigger_prop_picked_up": TRIG_PROP_PICKED_UP, "trigger_prop_dropped": TRIG_PROP_DROPPED, "trigger_prop_thrown": TRIG_PROP_THROWN, + "trigger_prop_used": TRIG_PROP_USED, "trigger_back": TRIG_BACK, "action_walk": ACT_WALK, "action_speak": ACT_SPEAK, @@ -2608,6 +2777,7 @@ func _build_ui() -> void: "action_drop_prop": ACT_DROP_PROP, "action_throw_prop": ACT_THROW_PROP, "action_set_down_prop": ACT_SET_DOWN_PROP, + "action_use_prop": ACT_USE_PROP, "more_add": RULE_MORE_ADD, "more_done": RULE_MORE_DONE }, _ui_font, _emoji_font, _font_sizes, _theme_dialog_size("rule_size", Vector2i(720, 560))) @@ -2694,26 +2864,106 @@ func _build_prop_settings_view(ui: CanvasLayer) -> void: _prop_settings_view.configure(_sandbox_theme as SandboxTheme, TOP_BAR_HEIGHT, STATUS_BAR_HEIGHT) _prop_settings_view.role_choice_requested.connect(_on_prop_role_choice) _prop_settings_view.role_toggle_requested.connect(_on_prop_role_toggle) + _prop_settings_view.spot_add_requested.connect(_on_spot_add_requested) + _prop_settings_view.spot_move_requested.connect(_on_spot_move_requested) + _prop_settings_view.spot_rename_requested.connect(_on_spot_rename_requested) + _prop_settings_view.spot_erase_requested.connect(_on_spot_erase_requested) + _prop_settings_view.spot_selected.connect(_on_spot_selected) _prop_settings_view.close_requested.connect(_on_prop_settings_close_requested) ui.add_child(_prop_settings_view) _prop_settings_view.update_layout(_prop_settings_safe_rect()) func _build_set_down_cancel_button(ui: CanvasLayer) -> void: var button_height: float = maxf(44.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96))) + var palette_config: Dictionary = _theme.get("palette", {}) as Dictionary + _capture_controls_left = float(palette_config.get("dock_width", 116.0)) + 12.0 + var capture_top: float = TOP_BAR_HEIGHT + 56.0 _set_down_cancel_button = Button.new() _set_down_cancel_button.name = "SetDownCancel" _set_down_cancel_button.text = "✕ Cancel" _set_down_cancel_button.custom_minimum_size = Vector2(132.0, button_height) - _set_down_cancel_button.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT) - _set_down_cancel_button.offset_left = -148.0 - _set_down_cancel_button.offset_right = -12.0 - _set_down_cancel_button.offset_top = TOP_BAR_HEIGHT + 116.0 - _set_down_cancel_button.offset_bottom = TOP_BAR_HEIGHT + 164.0 + _set_down_cancel_button.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT) + _set_down_cancel_button.offset_left = _capture_controls_left + _set_down_cancel_button.offset_right = _capture_controls_left + 132.0 + _set_down_cancel_button.offset_top = capture_top + _set_down_cancel_button.offset_bottom = capture_top + button_height _set_down_cancel_button.visible = false _set_down_cancel_button.pressed.connect(_cancel_capture) ui.add_child(_set_down_cancel_button) +func _build_use_target_capture_bar(ui: CanvasLayer) -> void: + _use_target_capture_bar = STAGE_USE_TARGET_CAPTURE_BAR_SCRIPT.new() as StageUseTargetCaptureBar + _use_target_capture_bar.name = "UseTargetCaptureBar" + var palette_config: Dictionary = _theme.get("palette", {}) as Dictionary + _capture_controls_left = float(palette_config.get("dock_width", 116.0)) + 12.0 + _use_target_capture_bar.configure(_sandbox_theme as SandboxTheme, TOP_BAR_HEIGHT) + _use_target_capture_bar.cancel_requested.connect(_cancel_capture) + _use_target_capture_bar.change_prop_requested.connect(_choose_different_use_prop) + ui.add_child(_use_target_capture_bar) + + +func _build_use_spot_overlay(ui: CanvasLayer) -> void: + _use_spot_overlay = STAGE_USE_SPOT_OVERLAY_SCRIPT.new() as StageUseSpotOverlay + _use_spot_overlay.name = "UseSpotOverlay" + _use_spot_overlay.configure(_sandbox_theme as SandboxTheme) + ui.add_child(_use_spot_overlay) + _use_choice_popup = PopupMenu.new() + _use_choice_popup.name = "UseSpotChoices" + _use_choice_popup.id_pressed.connect(_on_use_choice_selected) + _use_choice_popup.theme = _sandbox_theme.control_theme() if _sandbox_theme != null else null + ui.add_child(_use_choice_popup) + _spot_erase_dialog = ConfirmationDialog.new() + _spot_erase_dialog.name = "EraseUseSpotDialog" + _spot_erase_dialog.title = "⚠️ Erase Use spot?" + _spot_erase_dialog.ok_button_text = "Erase spot" + _spot_erase_dialog.cancel_button_text = "Keep spot" + _spot_erase_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300)) + _spot_erase_dialog.theme = _sandbox_theme.control_theme() if _sandbox_theme != null else null + _spot_erase_dialog.confirmed.connect(_on_spot_erase_confirmed) + ui.add_child(_spot_erase_dialog) + _apply_dialog_theme(_spot_erase_dialog) + _style_spot_erase_dialog_buttons() + + +func _style_spot_erase_dialog_buttons() -> void: + if _spot_erase_dialog == null: + return + var danger: Color = _sandbox_theme.color("overlays.use_preview.invalid_color", Color("#F06464")) if _sandbox_theme != null else Color("#F06464") + var safe: Color = _sandbox_theme.color("mode_colors.edit", Color("#22C6FF")) if _sandbox_theme != null else Color("#22C6FF") + _style_spot_erase_titlebar(danger.darkened(0.58)) + _style_spot_erase_action_button(_spot_erase_dialog.get_ok_button(), danger.darkened(0.55), danger) + _style_spot_erase_action_button(_spot_erase_dialog.get_cancel_button(), safe.darkened(0.72), safe) + + +func _style_spot_erase_titlebar(color: Color) -> void: + for style_name: String in ["embedded_border", "embedded_unfocused_border"]: + var original: StyleBoxFlat = _spot_erase_dialog.get_theme_stylebox(style_name) as StyleBoxFlat + if original == null: + continue + var tinted: StyleBoxFlat = original.duplicate() as StyleBoxFlat + tinted.bg_color = color + _spot_erase_dialog.add_theme_stylebox_override(style_name, tinted) + _spot_erase_dialog.add_theme_color_override("title_color", Color("#FFFFFF")) + + +func _style_spot_erase_action_button(button: Button, surface: Color, accent: Color) -> void: + if button == null: + return + button.custom_minimum_size = Vector2(156.0, 56.0) + button.add_theme_color_override("font_color", Color("#FFFFFF")) + button.add_theme_color_override("font_hover_color", Color("#FFFFFF")) + button.add_theme_color_override("font_pressed_color", Color("#FFFFFF")) + for state: String in ["normal", "hover", "pressed", "hover_pressed"]: + var style := StyleBoxFlat.new() + style.bg_color = surface.lightened(0.10) if state == "hover" or state == "hover_pressed" else surface + style.border_color = accent.lightened(0.18) if state == "hover" or state == "pressed" or state == "hover_pressed" else accent + style.set_border_width_all(2 if state == "hover" or state == "pressed" or state == "hover_pressed" else 1) + style.set_corner_radius_all(10) + style.set_content_margin_all(12.0) + button.add_theme_stylebox_override(state, style) + + func _stage_panel_integer(key: String, fallback: int, minimum: int, maximum: int) -> int: return fallback if _sandbox_theme == null else _sandbox_theme.integer("stage_panels." + key, fallback, minimum, maximum) @@ -2752,6 +3002,520 @@ func _on_prop_role_toggle(enabled: bool, key: String) -> void: _refresh_prop_settings_card() +func _on_spot_add_requested() -> void: + var prop: PropBlock = _selection.get_primary() as PropBlock if _selection != null else null + if current_mode == StageMode.EDIT and prop != null and is_instance_valid(prop): + _begin_spot_edit(prop, "") + + +func _on_spot_move_requested(spot_id: String) -> void: + var prop: PropBlock = _selection.get_primary() as PropBlock if _selection != null else null + if current_mode == StageMode.EDIT and prop != null and is_instance_valid(prop) and not prop.get_use_spot(spot_id).is_empty(): + _begin_spot_edit(prop, spot_id) + + +func _begin_spot_edit(prop: PropBlock, spot_id: String) -> void: + if prop == null or not is_instance_valid(prop) or current_mode != StageMode.EDIT: + return + _spot_selected_id = spot_id + _position_cancel_button_after_prop_choice(false) + _target_capture.begin_spot_edit(prop, spot_id, _camera.get_global_mouse_position()) + _set_down_cancel_button.text = "✕ Cancel" + _set_down_cancel_button.visible = true + _use_spot_overlay.show_edit(prop, _spot_edit_ghost_world, "Click a place on this prop" if spot_id.is_empty() else "Click the spot's new place", _spot_selected_id, _spot_edit_id) + _apply_cursor() + _refresh_status() + + +func _commit_spot_position(world_position: Vector2) -> void: + if _spot_edit_prop == null or not is_instance_valid(_spot_edit_prop): + _cancel_spot_work() + return + var local_position: Vector2 = _spot_edit_prop.use_spot_local_from_world(world_position) + if not local_position.is_finite(): + _show_toast("Choose a place on this prop") + return + var changed: bool = false + if _spot_edit_id.is_empty(): + var added_id: String = _spot_edit_prop.add_use_spot(local_position) + changed = not added_id.is_empty() + if changed: + _spot_selected_id = added_id + else: + changed = _spot_edit_prop.move_use_spot(_spot_edit_id, local_position) + _spot_selected_id = _spot_edit_id + if not changed: + _show_toast("Choose a place on this prop") + return + _save_object_state(_spot_edit_prop) + _mark_scene_dirty() + _target_capture.finish() + _set_down_cancel_button.visible = false + _refresh_prop_settings_card() + _apply_cursor() + + +func _on_spot_rename_requested(spot_id: String, spot_name: String) -> void: + var prop: PropBlock = _selection.get_primary() as PropBlock if _selection != null else null + if current_mode != StageMode.EDIT or prop == null or not is_instance_valid(prop): + return + if prop.rename_use_spot(spot_id, spot_name): + _spot_selected_id = spot_id + _mark_scene_dirty() + _refresh_prop_settings_card() + _refresh_repair_summaries() + + +func _on_spot_selected(spot_id: String) -> void: + _spot_selected_id = spot_id + var prop: PropBlock = _selection.get_primary() as PropBlock if _selection != null else null + if prop != null and is_instance_valid(prop) and _use_spot_overlay != null: + _use_spot_overlay.show_edit(prop, Vector2.INF, "", spot_id) + _refresh_prop_settings_card() + + +func _on_spot_erase_requested(spot_id: String) -> void: + var prop: PropBlock = _selection.get_primary() as PropBlock if _selection != null else null + if current_mode != StageMode.EDIT or prop == null or not is_instance_valid(prop): + return + var spot: Dictionary = prop.get_use_spot(spot_id) + if spot.is_empty(): + return + _spot_erase_prop_id = prop.stage_object_id + _spot_erase_id = spot_id + var warning: String = "\nThis spot is used in your story. Those steps will need a new spot." if _use_spot_is_referenced(prop.stage_object_id, spot_id) else "" + _spot_erase_dialog.dialog_text = "Erase %s (spot %d)?%s" % [String(spot.get("name", "Spot")), int(spot.get("number", 0)), warning] + _spot_erase_dialog.popup_centered(_spot_erase_dialog.min_size) + + +func _on_spot_erase_confirmed() -> void: + var prop: PropBlock = _resolve_prop_by_stage_id(_spot_erase_prop_id) + if prop != null and is_instance_valid(prop) and prop.erase_use_spot(_spot_erase_id): + if _spot_selected_id == _spot_erase_id: + _spot_selected_id = "" + _save_object_state(prop) + _mark_scene_dirty() + _refresh_prop_settings_card() + _refresh_repair_summaries() + _spot_erase_prop_id = "" + _spot_erase_id = "" + + +func _use_spot_is_referenced(prop_id: String, spot_id: String) -> bool: + for child: Node in _world.get_children(): + if child is StickmanRig: + for action: Dictionary in (child as StickmanRig).get_queue(): + if String(action.get("type", "")) == "use_prop" and String(action.get("target_prop_id", "")) == prop_id and String(action.get("target_spot_id", "")) == spot_id: + return true + for rule: Dictionary in _event_rules: + var trigger: Dictionary = rule.get("trigger", {}) + if String(trigger.get("type", "")) == "prop_used": + var trigger_params: Dictionary = trigger.get("params", {}) + if String(trigger_params.get("target_prop_id", "")) == prop_id and String(trigger_params.get("spot_scope", "")) == "specific" and String(trigger_params.get("target_spot_id", "")) == spot_id: + return true + for action: Dictionary in rule.get("actions", []): + var flat: Dictionary = ACTION_REGISTRY.from_rule_action(action) + if String(flat.get("type", "")) == "use_prop" and String(flat.get("target_prop_id", "")) == prop_id and String(flat.get("target_spot_id", "")) == spot_id: + return true + return false + + +func _begin_use_target_capture(context: String, actor: StickmanRig = null, preview_prefix: Array[Dictionary] = [], prefix_known: bool = false) -> void: + _target_capture.begin_use_target(context, actor, preview_prefix, prefix_known) + _set_down_cancel_button.visible = false + _use_target_capture_bar.set_capture_state(false) + _use_spot_overlay.show_targeting(null, {}, "Click a prop to use") + _apply_cursor() + _refresh_status() + + +func _handle_use_target_click(world_position: Vector2, screen_position: Vector2) -> void: + if _use_target_step == 0: + var candidates: Array[Node2D] = _selection.hit_test_all(world_position) + var prop_candidates: Array[Node2D] = [] + for candidate: Node2D in candidates: + if candidate is PropBlock: + prop_candidates.append(candidate) + if prop_candidates.is_empty(): + _show_toast("Click a prop to use") + return + if prop_candidates.size() > 1: + _show_objects_here_popup(prop_candidates) + return + _select_use_target_prop(prop_candidates[0] as PropBlock, screen_position) + return + if _use_target_prop == null or not is_instance_valid(_use_target_prop): + _show_toast("Prop is gone") + return + var hits: Array[String] = _use_spot_overlay.hit_spots(screen_position) + if hits.is_empty(): + _show_toast("Choose one of the numbered Use spots") + return + if _use_target_context in ["direct", "action_editor", "rule_builder_action"] and hits.size() == 1: + var cached: Dictionary = _use_target_preview_cache.get(hits[0], {}) + if bool(cached.get("known", false)) and not bool(cached.get("valid", false)): + _show_toast(_use_failure_message(String(cached.get("reason_code", "out_of_reach")))) + _use_spot_overlay.show_targeting(_use_target_prop, cached, "Choose a reachable spot") + return + _use_target_choices.clear() + for spot_id: String in hits: + var spot: Dictionary = _use_target_prop.get_use_spot(spot_id) + if not spot.is_empty(): + _use_target_choices.append({"prop_id": _use_target_prop.stage_object_id, "spot_scope": "specific", "spot_id": spot_id, "label": "%d %s" % [int(spot.get("number", 0)), String(spot.get("name", ""))]}) + if _use_target_choices.size() == 1: + _finish_use_target_choice(_use_target_choices[0]) + else: + _show_use_target_choices(screen_position) + + +func _show_use_target_choices(screen_position: Vector2) -> void: + _use_choice_popup.clear() + _use_choice_values.clear() + for index: int in _use_target_choices.size(): + var choice: Dictionary = _use_target_choices[index] + _use_choice_popup.add_item(String(choice.get("label", "Use spot")), index) + _use_choice_values.append(choice.duplicate(true)) + _use_choice_popup.popup(Rect2i(Vector2i(screen_position), Vector2i(1, 1))) + + +func _choose_different_use_prop() -> void: + if not _use_target_active: + return + if _use_choice_popup != null and _use_choice_popup.visible: + _use_choice_popup.hide() + _target_capture.reset_use_target_prop() + _use_spot_overlay.show_targeting(null, {}, "Click a prop to use") + _use_target_capture_bar.set_capture_state(false) + _apply_cursor() + _refresh_status() + + +func _on_use_choice_selected(id: int) -> void: + if id < 0 or id >= _use_choice_values.size(): + return + _finish_use_target_choice(_use_choice_values[id]) + + +func _finish_use_target_choice(choice: Dictionary) -> void: + var completed_context: String = _use_target_context + var actor: StickmanRig = _use_target_actor if _use_target_actor != null and is_instance_valid(_use_target_actor) else null + var prop_id: String = String(choice.get("prop_id", "")) + var scope: String = String(choice.get("spot_scope", "specific")) + var spot_id: String = String(choice.get("spot_id", "")) + var prop: PropBlock = _resolve_prop_by_stage_id(prop_id) + if prop == null or not is_instance_valid(prop) or (scope == "specific" and prop.get_use_spot(spot_id).is_empty()): + _show_toast("Use spot is gone") + return + if completed_context in ["direct", "action_editor", "rule_builder_action"] and scope == "specific": + var cached: Dictionary = _use_target_preview_cache.get(spot_id, {}) + if bool(cached.get("known", false)) and not bool(cached.get("valid", false)): + _show_toast(_use_failure_message(String(cached.get("reason_code", "out_of_reach")))) + _use_spot_overlay.show_targeting(prop, cached, "Choose a reachable spot") + return + var summary: String = _use_target_summary(prop, spot_id) if scope == "specific" else "Any spot on %s" % _stage_prop_display_name(prop) + match completed_context: + "action_editor": + if _action_queue_controller != null: + _action_queue_controller.set_use_target(prop_id, spot_id, summary) + _action_queue_controller.show_action_editor() + "direct": + if actor != null: + if scope == "specific": + actor.queue_action({"type": "use_prop", "target_prop_id": prop_id, "target_spot_id": spot_id}) + else: + _show_toast("Choose a stickman first") + "rule_editor": + if _rule_authoring != null: + _rule_authoring.set_rule_editor_trigger_use_target(prop_id, scope, spot_id) + "rule_builder": + if _rule_authoring != null: + _rule_authoring.accept_trigger_use_prop(prop_id, scope, spot_id) + "rule_builder_action": + if _rule_authoring != null and scope == "specific": + _rule_authoring.set_last_action_params({"target_prop_id": prop_id, "target_spot_id": spot_id}) + _open_rule_more_popup() + _clear_use_target_capture() + if completed_context == "direct": + _mark_scene_dirty() + _refresh_prop_settings_card() + + +func _use_target_summary(prop: PropBlock, spot_id: String) -> String: + var spot: Dictionary = prop.get_use_spot(spot_id) + return "%s (spot %d) on %s" % [String(spot.get("name", "Use spot")), int(spot.get("number", 0)), _stage_prop_display_name(prop)] + + +func _resolve_use_target_summary(prop_id: String, scope: String, spot_id: String) -> String: + var prop: PropBlock = _resolve_prop_by_stage_id(prop_id) + if prop == null or not is_instance_valid(prop): + return "Choose a new spot" + if scope == "any": + return "Any spot on %s" % _stage_prop_display_name(prop) + if scope != "specific" or prop.get_use_spot(spot_id).is_empty(): + return "Choose a new spot" + return _use_target_summary(prop, spot_id) + + +func _stage_prop_display_name(prop: PropBlock) -> String: + var fallback: String = prop.spawn_id if not prop.spawn_id.is_empty() else String(prop.name) + var entry: Dictionary = PROP_LIBRARY.get_entry(prop.spawn_id) + return String(entry.get("name", entry.get("label", fallback))) + + +func _clear_use_target_capture() -> void: + _target_capture.finish() + _use_preview_debug_last_msec_by_key.clear() + _set_down_cancel_button.visible = false + _use_target_capture_bar.hide_capture() + _position_cancel_button_after_prop_choice(false) + _use_spot_overlay.clear_session() + _apply_cursor() + _refresh_status() + + +func _update_use_target_preview_cache() -> void: + if not _use_target_active or _use_target_step != 1 or _use_target_actor == null or not is_instance_valid(_use_target_actor) or _use_target_prop == null or not is_instance_valid(_use_target_prop): + _use_target_preview_cache.clear() + _use_preview_debug_last_msec_by_key.clear() + return + if _use_target_prop.get_use_spots().is_empty(): + _use_target_preview_cache.clear() + _use_spot_overlay.show_targeting(_use_target_prop, {}, "This prop has no Use spots. Add one in EDIT.") + return + var spot_ids: Array[String] = [] + if _use_choice_popup != null and _use_choice_popup.visible: + for spot: Dictionary in _use_target_prop.get_use_spots(): + spot_ids.append(String(spot.get("id", ""))) + else: + spot_ids = _use_spot_overlay.hit_spots(get_viewport().get_mouse_position()) + _use_target_preview_cache.clear() + if spot_ids.is_empty(): + _log_use_preview_miss(_use_target_actor, _use_target_prop) + _use_spot_overlay.show_targeting(_use_target_prop, {}, "Click the spot to use") + return + var preview_state: Dictionary = _use_preview_actor_state(_use_target_actor, _use_target_prop) + for spot_id: String in spot_ids: + var solver_preview: Dictionary = _use_target_actor.preview_prop_use( + _use_target_prop, + spot_id, + preview_state.get("root", _use_target_actor.global_position), + bool(preview_state.get("facing_left", false)) + ) + var preview: Dictionary = solver_preview.duplicate(true) + if not bool(preview_state.get("known", false)): + preview["known"] = false + preview["reason_code"] = "earlier_actions" + _use_target_preview_cache[spot_id] = preview + _log_use_preview_hit(_use_target_actor, _use_target_prop, spot_id, preview_state, solver_preview, preview) + var selected_preview: Dictionary = _use_target_preview_cache.get(spot_ids[0], {}) + _use_spot_overlay.show_targeting(_use_target_prop, selected_preview, "Choose a reachable spot") + + +func _use_preview_actor_state(actor: StickmanRig, prop: PropBlock) -> Dictionary: + var root_position: Vector2 = actor.global_position + var facing_left: bool = actor.get_facing_profile() == StickmanRig.FacingProfile.LEFT + var known: bool = _use_target_prefix_known + var uncertain_reasons: Array[String] = [] + if not known: + uncertain_reasons.append("context_position_unknown") + if actor.is_walking(): + known = false + uncertain_reasons.append("actor_is_walking") + if prop.runtime_role == PropBlock.RuntimeRole.START_PHYSICS or prop.runtime_role == PropBlock.RuntimeRole.RELEASED: + known = false + uncertain_reasons.append("physics_target") + for entry: Dictionary in _use_target_preview_prefix: + var action: Dictionary = entry.get("action", {}) + var action_type: String = String(action.get("type", "")) + if bool(entry.get("active", false)): + known = false + uncertain_reasons.append("active_action:%s" % action_type) + break + if ACTION_REGISTRY.is_move_action(action): + var target_value: Variant = action.get("target", null) + if not target_value is Vector2 or not (target_value as Vector2).is_finite(): + known = false + uncertain_reasons.append("invalid_move_target") + break + var next_root: Vector2 = (target_value as Vector2) + StickmanLocomotion.FOOT_OFFSET + if absf(next_root.x - root_position.x) > 8.0: + facing_left = next_root.x < root_position.x + root_position = next_root + elif action_type in ["speak", "wait"]: + # These queued actions can delay Use, but they do not change the + # stickman's predicted position. + continue + elif action_type == "use_prop": + var previous_prop: PropBlock = _resolve_prop_by_stage_id(String(action.get("target_prop_id", ""))) + var previous_spot_id: String = String(action.get("target_spot_id", "")) + if previous_prop == null or previous_prop.runtime_role == PropBlock.RuntimeRole.START_PHYSICS or previous_prop.runtime_role == PropBlock.RuntimeRole.RELEASED: + known = false + uncertain_reasons.append("previous_use_target_moving_or_missing") + break + var previous_use: Dictionary = actor.preview_prop_use(previous_prop, previous_spot_id, root_position, facing_left) + if not bool(previous_use.get("known", false)): + known = false + uncertain_reasons.append("previous_use_preview_unknown") + break + if bool(previous_use.get("valid", false)): + var use_approach: Vector2 = previous_use.get("approach_root", Vector2.INF) + if not use_approach.is_finite(): + known = false + uncertain_reasons.append("previous_use_approach_unknown") + break + root_position = use_approach + facing_left = bool(previous_use.get("facing_left", facing_left)) + # A known invalid Use fails without changing the actor's position. + else: + # Interactions, recovery, ragdoll, and unknown actions can change the + # actor's pose, held item, or final position. + known = false + uncertain_reasons.append("unpredictable_action:%s" % action_type) + break + return {"known": known, "root": root_position, "facing_left": facing_left, "uncertain_reasons": uncertain_reasons} + + +func _log_use_preview_hit(actor: StickmanRig, prop: PropBlock, spot_id: String, preview_state: Dictionary, solver_preview: Dictionary, preview: Dictionary) -> void: + if not OS.is_debug_build(): + return + var spot_world: Vector2 = prop.use_spot_world_position(spot_id) + var actor_root: Vector2 = actor.global_position + var predicted_root: Vector2 = preview_state.get("root", actor_root) + var reach_start: Vector2 = solver_preview.get("reach_start", Vector2.INF) + var trace_key: String = "%s|%s|%s|%s|%s" % [prop.stage_object_id, spot_id, str(preview.get("known", false)), str(preview.get("valid", false)), String(preview.get("reason_code", ""))] + if not _use_preview_debug_due(trace_key): + return + var shoulder_distances: Dictionary = {} + for side: String in ["Left", "Right"]: + var shoulder: Bone2D = actor.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm" % side)) as Bone2D + if shoulder != null: + shoulder_distances[side] = { + "world": shoulder.global_position, + "to_spot": shoulder.global_position.distance_to(spot_world) if spot_world.is_finite() else INF, + } + var trace := { + "event": "hover_preview", + "prop_id": prop.stage_object_id, + "spot_id": spot_id, + "cursor_screen": get_viewport().get_mouse_position(), + "actor_screen": get_viewport().get_canvas_transform() * actor_root, + "spot_screen": get_viewport().get_canvas_transform() * spot_world if spot_world.is_finite() else Vector2.INF, + "actor_root": actor_root, + "predicted_root": predicted_root, + "spot_world": spot_world, + "actor_to_spot": actor_root.distance_to(spot_world) if spot_world.is_finite() else INF, + "predicted_root_to_spot": predicted_root.distance_to(spot_world) if spot_world.is_finite() else INF, + "shoulders": shoulder_distances, + "reach_start": reach_start, + "reach_start_to_spot": reach_start.distance_to(spot_world) if reach_start.is_finite() and spot_world.is_finite() else INF, + "known": preview.get("known", false), + "valid": preview.get("valid", false), + "reason_code": preview.get("reason_code", ""), + "solver_known": solver_preview.get("known", false), + "solver_valid": solver_preview.get("valid", false), + "solver_reason_code": solver_preview.get("reason_code", ""), + "approach_root": solver_preview.get("approach_root", Vector2.INF), + "prop_runtime_role": int(prop.runtime_role), + "uncertain_reasons": preview_state.get("uncertain_reasons", []), + "queue_prefix": _use_preview_prefix_debug_summary(), + } + print("[UsePreviewTrace] ", trace) + + +func _log_use_preview_miss(actor: StickmanRig, prop: PropBlock) -> void: + if not OS.is_debug_build(): + return + var cursor_screen: Vector2 = get_viewport().get_mouse_position() + var nearest_id: String = "" + var nearest_screen_distance: float = INF + var nearest_world: Vector2 = Vector2.INF + for spot: Dictionary in prop.get_use_spots(): + var spot_id: String = String(spot.get("id", "")) + var spot_world: Vector2 = prop.use_spot_world_position(spot_id) + if not spot_world.is_finite(): + continue + var spot_screen: Vector2 = get_viewport().get_canvas_transform() * spot_world + var distance: float = cursor_screen.distance_to(spot_screen) + if distance < nearest_screen_distance: + nearest_id = spot_id + nearest_screen_distance = distance + nearest_world = spot_world + if nearest_screen_distance > 96.0: + return + var trace_key: String = "%s|no_hit|%s" % [prop.stage_object_id, nearest_id] + if not _use_preview_debug_due(trace_key): + return + print("[UsePreviewTrace] ", { + "event": "near_spot_without_hit", + "prop_id": prop.stage_object_id, + "nearest_spot_id": nearest_id, + "cursor_screen": cursor_screen, + "nearest_spot_screen_distance": nearest_screen_distance, + "hit_radius": float(_stage_panel_integer("spots.pin_hit_size", 44, 44, 128)) * 0.5, + "spot_world": nearest_world, + "actor_root": actor.global_position, + "actor_to_spot": actor.global_position.distance_to(nearest_world) if nearest_world.is_finite() else INF, + }) + + +func _use_preview_debug_due(trace_key: String) -> bool: + var now_msec: int = Time.get_ticks_msec() + var last_msec: int = int(_use_preview_debug_last_msec_by_key.get(trace_key, 0)) + if last_msec > 0 and now_msec - last_msec < USE_PREVIEW_DEBUG_INTERVAL_MS: + return false + _use_preview_debug_last_msec_by_key[trace_key] = now_msec + return true + + +func _use_preview_prefix_debug_summary() -> Array[String]: + var result: Array[String] = [] + for entry: Dictionary in _use_target_preview_prefix: + var action: Dictionary = entry.get("action", {}) + var label: String = String(action.get("type", "unknown")) + if ACTION_REGISTRY.is_move_action(action): + label += "@%s" % str(action.get("target", Vector2.INF)) + elif label == "use_prop": + label += "@%s/%s" % [String(action.get("target_prop_id", "")), String(action.get("target_spot_id", ""))] + if bool(entry.get("active", false)): + label += "(active)" + result.append(label) + return result + + +func _cancel_spot_work() -> void: + if _spot_edit_active: + _target_capture.finish() + _set_down_cancel_button.visible = false + _refresh_prop_settings_card() + elif _use_target_active: + var context: String = _use_target_context + _clear_use_target_capture() + match context: + "action_editor": + if _action_queue_controller != null: + _action_queue_controller.show_action_editor() + "rule_editor": + if _rule_authoring != null: + _rule_authoring.cancel_trigger_target_capture() + "rule_builder": + _cancel_rule_build() + "rule_builder_action": + if _rule_authoring != null: + _rule_authoring.cancel_last_action_target_capture() + _rule_authoring.open_rule_action_popup() + _apply_cursor() + _refresh_status() + + +func _refresh_repair_summaries() -> void: + if _queue_panel != null: + _queue_panel.refresh() + if _action_editor != null: + _action_editor.refresh_use_target_summary() + if _rule_authoring != null: + _rule_authoring.refresh_use_target_summaries(_event_rules) + + func _refresh_prop_settings_card() -> void: if _prop_settings_view == null or _selection == null: return @@ -2763,20 +3527,28 @@ func _refresh_prop_settings_card() -> void: break if not has_prop: _prop_settings_view.hide_card(true) + if not _spot_edit_active and not _use_target_active and _use_spot_overlay != null: + _use_spot_overlay.clear_session() return if current_mode != StageMode.EDIT: _prop_settings_view.hide_card() + if not _spot_edit_active and not _use_target_active and _use_spot_overlay != null: + _use_spot_overlay.clear_session() return var safe_rect: Rect2 = _prop_settings_safe_rect() if selected.size() != 1 or not selected[0] is PropBlock: _prop_settings_view.show_multiple_props(selected, safe_rect) + if not _spot_edit_active and _use_spot_overlay != null: + _use_spot_overlay.clear_session() return var prop: PropBlock = selected[0] as PropBlock var roles: Dictionary = PROP_STAGE_ROLES.normalize(prop.stage_roles) var display_name: String = prop.spawn_id if not prop.spawn_id.is_empty() else "Prop" var entry: Dictionary = PROP_LIBRARY.get_entry(prop.spawn_id) display_name = String(entry.get("name", entry.get("label", display_name))) - _prop_settings_view.show_prop(prop, display_name, roles, safe_rect, selected) + _prop_settings_view.show_prop(prop, display_name, roles, safe_rect, selected, _spot_selected_id) + if not _spot_edit_active and not _use_target_active and _use_spot_overlay != null: + _use_spot_overlay.show_edit(prop, Vector2.INF, "", _spot_selected_id) func _prop_settings_safe_rect() -> Rect2: @@ -2787,6 +3559,14 @@ func _prop_settings_safe_rect() -> Rect2: return Rect2(Vector2(0.0, top_offset - inset), Vector2(viewport.size.x, bottom_limit - top_offset + inset * 2.0)) +func _position_cancel_button_after_prop_choice(after_choice: bool) -> void: + if _set_down_cancel_button == null: + return + var left: float = _capture_controls_left + (184.0 if after_choice else 0.0) + _set_down_cancel_button.offset_left = left + _set_down_cancel_button.offset_right = left + 132.0 + + func _on_prop_settings_close_requested() -> void: if _prop_settings_view == null or _prop_settings_active_key.is_empty(): return @@ -3066,6 +3846,8 @@ func _on_grid_size_changed(value: float) -> void: func _on_selection_changed(nodes: Array[Node2D]) -> void: + if _spot_edit_active and (_spot_edit_prop == null or not is_instance_valid(_spot_edit_prop) or not nodes.has(_spot_edit_prop)): + _cancel_spot_work() _gizmos.set_targets(nodes) if nodes.is_empty(): object_deselected.emit() @@ -3123,6 +3905,23 @@ func _show_objects_here_popup(candidates: Array[Node2D]) -> void: func _on_objects_here_selected(node: Node2D) -> void: if node != null and is_instance_valid(node) and node.get_parent() == _world: _selection.select_only(node) + if _use_target_active and _use_target_step == 0 and node is PropBlock: + call_deferred("_select_use_target_prop", node, get_viewport().get_mouse_position()) + + +func _select_use_target_prop(prop: PropBlock, _screen_position: Vector2) -> void: + if not _use_target_active or _use_target_step != 0 or prop == null or not is_instance_valid(prop): + return + var has_use_spots: bool = not prop.get_use_spots().is_empty() + _target_capture.select_use_target_prop(prop) + _use_target_capture_bar.set_capture_state(true, has_use_spots) + var instruction: String + if not has_use_spots: + instruction = "This prop has no Use spots. Add one in EDIT." + else: + instruction = "Click a labeled spot to use" + _use_spot_overlay.show_targeting(prop, {}, instruction) + _use_target_choices.clear() func _on_transform_committed(nodes: Array[Node2D]) -> void: @@ -3422,6 +4221,8 @@ func _on_action_popup_id_pressed(id: int) -> void: _cb_direct_capture_cancel, _throw_preview_prefix_for(_context_rig) ) + ACT_USE_PROP: + _begin_use_target_capture("direct", _context_rig, _throw_preview_prefix_for(_context_rig), true) ACT_WHEN: _begin_rule_build(_context_rig.get_instance_id()) ACT_EDIT_QUEUE: @@ -3625,17 +4426,17 @@ func _action_verb(id: int) -> String: return "act" if _rule_authoring == null else _rule_authoring.action_verb(id) -func _handle_rule_click(world_pos: Vector2) -> void: +func _handle_rule_click(world_pos: Vector2, screen_position: Vector2) -> void: match _rule_step: RuleStep.TRIGGER_TARGET: - _handle_trigger_target_click(world_pos) + _handle_trigger_target_click(world_pos, screen_position) RuleStep.ACTION_TARGET: _handle_action_target_click(world_pos) RuleStep.ACTION_POSITION: _handle_action_position_click(world_pos) -func _handle_trigger_target_click(world_pos: Vector2) -> void: +func _handle_trigger_target_click(world_pos: Vector2, screen_position: Vector2) -> void: var trigger: Dictionary = _rule_builder.get("trigger", {}) var trigger_type: String = String(trigger.get("type", "")) match trigger_type: @@ -3666,6 +4467,11 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void: if _rule_authoring != null: _rule_authoring.set_trigger_target_miss_hint(trigger_type) _refresh_status() + "prop_used": + _begin_use_target_capture("rule_builder") + # The click that chose this trigger already points at its prop. Reuse + # it as the first Use-target click instead of making the user click twice. + _handle_use_target_click(world_pos, screen_position) func _handle_action_target_click(world_pos: Vector2) -> void: @@ -3693,6 +4499,8 @@ func _handle_action_target_click(world_pos: Vector2) -> void: _begin_throw_position_capture(hit as StickmanRig, "🏹 Click where to throw (Esc or RMB to cancel)", _cb_rule_action_throw_target, _cb_rule_action_capture_cancel) "set_down_prop": _begin_set_down_position_capture(hit as StickmanRig, "Click where to let go (Esc or RMB to cancel)", _cb_rule_action_set_down_target, _cb_rule_action_capture_cancel, _throw_preview_prefix_for(hit as StickmanRig)) + "use_prop": + _begin_use_target_capture("rule_builder_action", hit as StickmanRig) _: # Ragdoll, recover, and drop need no extra parameters. _open_rule_more_popup() @@ -4052,6 +4860,7 @@ func _begin_capture(kind: CaptureKind, hint: String, on_resolve: Callable, on_ca _target_capture.begin(int(kind), hint, on_resolve, on_cancel) if _set_down_cancel_button != null: _set_down_cancel_button.visible = kind == CaptureKind.SET_DOWN_POSITION + _position_cancel_button_after_prop_choice(false) _apply_cursor() _refresh_status() @@ -4081,6 +4890,9 @@ func _throw_preview_prefix_for(rig: StickmanRig, before_action_id: int = -1) -> func _cancel_capture() -> void: + if _spot_edit_active or _use_target_active: + _cancel_spot_work() + return var callback: Callable = _capture_cancel _end_capture() if callback.is_valid(): @@ -4314,6 +5126,8 @@ func _on_rule_editor_trigger_target_requested(type: String) -> void: _begin_capture(CaptureKind.AREA, hint, _cb_rule_trigger_area, _cb_rule_trigger_cancel) "prop": _begin_capture(CaptureKind.PROP, hint, _cb_rule_trigger_prop, _cb_rule_trigger_cancel) + "use_prop": + _begin_use_target_capture("rule_editor") func _on_rule_editor_action_add_requested() -> void: @@ -4832,6 +5646,9 @@ func _on_action_editor_capture_request( _begin_capture(CaptureKind.POSITION, hint, on_resolve, on_cancel) &"prop": _begin_capture(CaptureKind.PROP, hint, on_resolve, on_cancel) + &"use_prop": + var queue_prefix: Array[Dictionary] = _throw_preview_prefix_for(preview_rig, exclude_action_id) if use_queue_prefix else [] + _begin_use_target_capture("action_editor", preview_rig, queue_prefix, use_queue_prefix) &"throw_position": var queue_prefix: Array[Dictionary] = _throw_preview_prefix_for(preview_rig, exclude_action_id) if use_queue_prefix else [] _begin_throw_position_capture(preview_rig, hint, on_resolve, on_cancel, queue_prefix) @@ -4954,6 +5771,8 @@ func _apply_theme_to_visuals() -> void: _director_visuals.set_style(_theme) _director_visuals.completed_path_alpha = _sandbox_theme.number("overlays.completed_path_alpha", 0.20, 0.0, 1.0) if _sandbox_theme != null else 0.20 _director_visuals.completed_path_color = _sandbox_theme.color("overlays.completed_path_color", Color("#959EAD")) if _sandbox_theme != null else Color("#959EAD") + _director_visuals.rule_connector_color = _sandbox_theme.color("overlays.rule_connector_color", Color("#AAB2C0")) if _sandbox_theme != null else Color("#AAB2C0") + _director_visuals.rule_action_color = _sandbox_theme.color("overlays.rule_action_color", Color("#7E57C2")) if _sandbox_theme != null else Color("#7E57C2") _director_visuals.emoji_font = _emoji_font _director_visuals.ui_font = _ui_font _director_visuals.bold_font = _ui_font_bold diff --git a/scripts/editors/stage/stage_action_queue_controller.gd b/scripts/editors/stage/stage_action_queue_controller.gd index fdc075f..15ff4f6 100644 --- a/scripts/editors/stage/stage_action_queue_controller.gd +++ b/scripts/editors/stage/stage_action_queue_controller.gd @@ -266,6 +266,11 @@ func set_prop_target(stage_id: String) -> void: _action_editor.set_prop_target(stage_id) +func set_use_target(prop_id: String, spot_id: String, summary: String) -> void: + if _action_editor != null: + _action_editor.set_use_target(prop_id, spot_id, summary) + + func set_throw_target(position: Vector2) -> void: if _action_editor != null: _action_editor.set_throw_target(position) @@ -286,7 +291,7 @@ func request_action_target_capture() -> void: return _action_editor.hide() var action_type: String = _action_editor.current_action_type() - if editor_kind == "rule_add" and editor_actor_id < 0 and action_type in [ACTION_REGISTRY.MOVE_TO, "pick_up_prop", "throw_prop", "set_down_prop"]: + if editor_kind == "rule_add" and editor_actor_id < 0 and action_type in [ACTION_REGISTRY.MOVE_TO, "pick_up_prop", "throw_prop", "set_down_prop", "use_prop"]: _emit_capture_request( &"actor", "🎯 Click the stickman who will act (Esc or RMB to cancel)", @@ -300,6 +305,8 @@ func request_action_target_capture() -> void: _emit_capture_request(&"position", "🚩 Click where they should %s (Esc or RMB to cancel)" % pace, Callable(self, "set_walk_target"), Callable(self, "cancel_target_capture")) "pick_up_prop": _emit_capture_request(&"prop", "🫴 Click the prop to pick up (Esc or RMB to cancel)", Callable(self, "accept_prop_target"), Callable(self, "cancel_target_capture")) + "use_prop": + _emit_capture_request(&"use_prop", "Click a prop, then choose its Use spot", Callable(self, "set_use_target_from_capture"), Callable(self, "cancel_target_capture")) "throw_prop": _emit_capture_request(&"throw_position", "🏹 Click where to throw (Esc or RMB to cancel)", Callable(self, "set_throw_target"), Callable(self, "cancel_target_capture")) "set_down_prop": @@ -330,5 +337,14 @@ func accept_prop_target(value: Node2D) -> void: show_action_editor() +func set_use_target_from_capture(value: Variant) -> void: + if not value is Dictionary: + show_action_editor() + return + var target: Dictionary = value + set_use_target(String(target.get("prop_id", "")), String(target.get("spot_id", "")), String(target.get("summary", ""))) + show_action_editor() + + func cancel_target_capture() -> void: show_action_editor() diff --git a/scripts/editors/stage/stage_director_controller.gd b/scripts/editors/stage/stage_director_controller.gd index cc6f833..dd11b23 100644 --- a/scripts/editors/stage/stage_director_controller.gd +++ b/scripts/editors/stage/stage_director_controller.gd @@ -68,6 +68,7 @@ func build_ui(parent: Node, sandbox_theme: SandboxTheme, ui_font: Font, emoji_fo _action_popup.add_item("💥 Ragdoll", int(action_ids.get("ragdoll", 3))) _action_popup.add_item("🔄 Recover", int(action_ids.get("recover", 4))) _action_popup.add_item("🫴 Pick Up Prop…", int(action_ids.get("pick_up_prop", 9))) + _action_popup.add_item("🖐️ Use…", int(action_ids.get("use_prop", 13))) _action_popup.add_item("⬇️ Drop Prop", int(action_ids.get("drop_prop", 10))) _action_popup.add_item("🏹 Throw Prop…", int(action_ids.get("throw_prop", 11))) _action_popup.add_item("🫳 Set Down…", int(action_ids.get("set_down_prop", 12))) diff --git a/scripts/editors/stage/stage_prop_settings_view.gd b/scripts/editors/stage/stage_prop_settings_view.gd index ecfb247..12e3a2c 100644 --- a/scripts/editors/stage/stage_prop_settings_view.gd +++ b/scripts/editors/stage/stage_prop_settings_view.gd @@ -10,6 +10,11 @@ const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd") signal role_choice_requested(key: String, value: String) signal role_toggle_requested(enabled: bool, key: String) +signal spot_add_requested +signal spot_move_requested(spot_id: String) +signal spot_rename_requested(spot_id: String, name: String) +signal spot_erase_requested(spot_id: String) +signal spot_selected(spot_id: String) signal close_requested var _stage_theme: SandboxTheme @@ -22,6 +27,8 @@ var _notice: Label var _depth_group: StagePanelChoiceGroup var _motion_group: StagePanelChoiceGroup var _controls: Dictionary = {} +var _spot_rows: VBoxContainer +var _spot_empty_label: Label var _active_selection_key: String = "" var _dismissed_selection_key: String = "" @@ -46,7 +53,8 @@ func show_prop( title: String, roles: Dictionary, safe_rect: Rect2, - selection: Array[Node2D] + selection: Array[Node2D], + selected_spot_id: String = "" ) -> void: if prop == null or not is_instance_valid(prop): hide_card() @@ -62,6 +70,7 @@ func show_prop( _fields.visible = true _card.set_header(title, "📦") _sync_roles(prop, roles) + _sync_spots(prop.get_use_spots(), selected_spot_id) func show_multiple_props(selection: Array[Node2D], safe_rect: Rect2) -> void: @@ -184,6 +193,7 @@ func _build() -> void: _controls["blocks_stickmen"] = _make_toggle_row(_fields, "blocks_stickmen", "Blocks stickmen") _controls["can_pick_up"] = _make_toggle_row(_fields, "can_pick_up", "Stickmen can pick it up") _add_release_options() + _add_use_spots_section() _reopen_button = Button.new() _reopen_button.name = "ReopenPropSettings" @@ -196,6 +206,105 @@ func _build() -> void: add_child(_reopen_button) +func _add_use_spots_section() -> void: + var section := VBoxContainer.new() + section.name = "UseSpotsSection" + section.add_theme_constant_override("separation", _stage_panel_integer("spots.row_gap", 8, 4, 24)) + _fields.add_child(section) + _add_heading(section, "Use spots") + var helper := Label.new() + helper.text = "Stickmen can touch these places without picking up the prop." + helper.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + helper.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32)) + helper.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0"))) + _apply_ui_font(helper) + section.add_child(helper) + var add_button := Button.new() + add_button.name = "AddUseSpot" + add_button.text = "+ Add a spot" + add_button.custom_minimum_size.y = maxf(44.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96))) + _card.style_action_button(add_button, true) + add_button.pressed.connect(func() -> void: spot_add_requested.emit()) + section.add_child(add_button) + _spot_empty_label = Label.new() + _spot_empty_label.text = "No Use spots yet. Add one where a stickman should reach." + _spot_empty_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _spot_empty_label.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0"))) + _apply_ui_font(_spot_empty_label) + section.add_child(_spot_empty_label) + _spot_rows = VBoxContainer.new() + _spot_rows.name = "UseSpotRows" + _spot_rows.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _spot_rows.add_theme_constant_override("separation", _stage_panel_integer("spots.row_gap", 8, 4, 24)) + section.add_child(_spot_rows) + + +func _sync_spots(spots: Array[Dictionary], selected_spot_id: String) -> void: + if _spot_rows == null: + return + for child: Node in _spot_rows.get_children(): + child.queue_free() + _spot_empty_label.visible = spots.is_empty() + var button_height: float = maxf(44.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96))) + for spot: Dictionary in spots: + var spot_id: String = String(spot.get("id", "")) + var number: int = int(spot.get("number", 0)) + var row := VBoxContainer.new() + row.name = "UseSpot_%d" % number + row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_theme_constant_override("separation", 4) + _spot_rows.add_child(row) + var header := HBoxContainer.new() + row.add_child(header) + var select_button := Button.new() + select_button.text = "%d" % number + select_button.tooltip_text = "Show spot %d on the prop" % number + select_button.custom_minimum_size = Vector2(button_height, button_height) + select_button.pressed.connect(func() -> void: spot_selected.emit(spot_id)) + _card.style_action_button(select_button, spot_id == selected_spot_id) + header.add_child(select_button) + var name_column := VBoxContainer.new() + name_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header.add_child(name_column) + var name_label := Label.new() + name_label.text = "Spot name" + name_label.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32)) + _apply_ui_font(name_label) + name_column.add_child(name_label) + var name_edit := LineEdit.new() + name_edit.text = String(spot.get("name", "Spot %d" % number)) + name_edit.placeholder_text = "Spot %d" % number + name_edit.custom_minimum_size.y = button_height + name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _apply_ui_font(name_edit) + name_edit.text_submitted.connect(func(_text: String) -> void: _commit_spot_name(name_edit, spot_id)) + name_edit.focus_exited.connect(func() -> void: _commit_spot_name(name_edit, spot_id)) + name_column.add_child(name_edit) + var actions := FlowContainer.new() + actions.add_theme_constant_override("h_separation", 8) + actions.add_theme_constant_override("v_separation", 4) + row.add_child(actions) + var move_button := Button.new() + move_button.text = "Move" + move_button.custom_minimum_size = Vector2(88.0, button_height) + move_button.pressed.connect(func() -> void: spot_move_requested.emit(spot_id)) + _card.style_action_button(move_button, false) + actions.add_child(move_button) + var erase_button := Button.new() + erase_button.text = "Erase" + erase_button.custom_minimum_size = Vector2(88.0, button_height) + erase_button.pressed.connect(func() -> void: spot_erase_requested.emit(spot_id)) + _card.style_action_button(erase_button, false) + actions.add_child(erase_button) + _card.request_fit() + + +func _commit_spot_name(edit: LineEdit, spot_id: String) -> void: + if edit == null or not is_instance_valid(edit): + return + spot_rename_requested.emit(spot_id, edit.text) + + func _add_legacy_order_cue() -> void: var legacy_cue := Label.new() legacy_cue.name = "LegacyOrderCue" diff --git a/scripts/editors/stage/stage_rule_authoring_controller.gd b/scripts/editors/stage/stage_rule_authoring_controller.gd index 4a78653..b0fa4de 100644 --- a/scripts/editors/stage/stage_rule_authoring_controller.gd +++ b/scripts/editors/stage/stage_rule_authoring_controller.gd @@ -18,6 +18,7 @@ const TRIG_BACK: int = 5 const TRIG_PROP_PICKED_UP: int = 6 const TRIG_PROP_DROPPED: int = 7 const TRIG_PROP_THROWN: int = 8 +const TRIG_PROP_USED: int = 9 const RULE_MORE_ADD: int = 0 const RULE_MORE_DONE: int = 1 const RULE_ACTION_DONE: int = 6 @@ -90,6 +91,7 @@ func build_ui( _trigger_popup.add_item("🫴 Picks up a prop", int(popup_ids.get("trigger_prop_picked_up", TRIG_PROP_PICKED_UP))) _trigger_popup.add_item("⬇️ Drops a prop", int(popup_ids.get("trigger_prop_dropped", TRIG_PROP_DROPPED))) _trigger_popup.add_item("🏹 Throws a prop", int(popup_ids.get("trigger_prop_thrown", TRIG_PROP_THROWN))) + _trigger_popup.add_item("🖐️ Used prop", int(popup_ids.get("trigger_prop_used", TRIG_PROP_USED))) _trigger_popup.add_separator() _trigger_popup.add_item("⬅ Back to actions", int(popup_ids.get("trigger_back", TRIG_BACK))) _trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed) @@ -105,6 +107,7 @@ func build_ui( _action_popup.add_item("⬇️ Drop Prop", int(popup_ids.get("action_drop_prop", 10))) _action_popup.add_item("🏹 Throw Prop…", int(popup_ids.get("action_throw_prop", 11))) _action_popup.add_item("🫳 Set Down…", int(popup_ids.get("action_set_down_prop", 12))) + _action_popup.add_item("🖐️ Use…", int(popup_ids.get("action_use_prop", 13))) _action_popup.id_pressed.connect(_on_rule_action_popup_id_pressed) parent.add_child(_action_popup) @@ -155,6 +158,19 @@ func get_rule_editor() -> RuleEditor: return _rule_editor +func set_use_target_summary_resolver(resolver: Callable) -> void: + if _rule_panel != null: + _rule_panel.set_use_target_summary_resolver(resolver) + if _rule_editor != null: + _rule_editor.set_use_target_summary_resolver(resolver) + + +func refresh_use_target_summaries(rules: Array[Dictionary]) -> void: + refresh_rule_panel(rules) + if _rule_editor != null: + _rule_editor.refresh_use_target_summaries() + + func edit_rule_by_id(rules: Array[Dictionary], rule_id: int) -> bool: for rule: Dictionary in rules: if int(rule.get("id", -1)) == rule_id: @@ -186,6 +202,8 @@ func trigger_target_capture_hint(target_type: String) -> String: return "🎯 Click the trigger area (Esc or RMB to cancel)" "prop": return "💥 Click the prop (Esc or RMB to cancel)" + "use_prop": + return "Click a prop, then click a named spot (Esc or RMB to cancel)" return "" @@ -302,7 +320,7 @@ func on_trigger_selected(id: int) -> void: var trigger: Dictionary = builder.get("trigger", {}) trigger["type"] = trigger_type_string(id) builder["trigger"] = trigger - if id in [TRIG_ARRIVED, TRIG_ENTERED_AREA, TRIG_COLLIDED, TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN]: + if id in [TRIG_ARRIVED, TRIG_ENTERED_AREA, TRIG_COLLIDED, TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN, TRIG_PROP_USED]: step = Step.TRIGGER_TARGET match id: TRIG_ARRIVED: @@ -313,6 +331,8 @@ func on_trigger_selected(id: int) -> void: hint = "Click the prop that will be collided with (Esc or RMB to cancel)" TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN: hint = "Click the prop to watch (Esc or RMB to cancel)" + TRIG_PROP_USED: + hint = "Click a prop, then click a named spot" else: step = Step.SELECT_ACTION open_rule_action_popup() @@ -359,6 +379,8 @@ func trigger_type_string(id: int) -> String: return "prop_dropped" TRIG_PROP_THROWN: return "prop_thrown" + TRIG_PROP_USED: + return "prop_used" _: return "" @@ -383,6 +405,8 @@ func action_type_string(id: int) -> String: return "throw_prop" 12: return "set_down_prop" + 13: + return "use_prop" _: return "" @@ -407,6 +431,8 @@ func action_verb(id: int) -> String: return "throw a prop" 12: return "set down a prop" + 13: + return "use a prop" _: return "act" @@ -438,6 +464,23 @@ func accept_trigger_prop(prop: PropBlock) -> void: open_rule_action_popup() +func accept_trigger_use_prop(target_prop_id: String, spot_scope: String, target_spot_id: String = "") -> void: + var trigger: Dictionary = builder.get("trigger", {}) + trigger["target"] = -1 + var params: Dictionary = {"target_prop_id": target_prop_id, "spot_scope": spot_scope} + if spot_scope == "specific": + params["target_spot_id"] = target_spot_id + trigger["params"] = params + builder["trigger"] = trigger + step = Step.SELECT_ACTION + open_rule_action_popup() + + +func set_rule_editor_trigger_use_target(target_prop_id: String, spot_scope: String, target_spot_id: String = "") -> void: + if _rule_editor != null: + _rule_editor.set_trigger_use_target(target_prop_id, spot_scope, target_spot_id) + + func set_trigger_target_miss_hint(trigger_type: String) -> void: match trigger_type: "arrived_at_waypoint": diff --git a/scripts/editors/stage/stage_scene_adapter.gd b/scripts/editors/stage/stage_scene_adapter.gd index a9e5ea3..5ae2c6a 100644 --- a/scripts/editors/stage/stage_scene_adapter.gd +++ b/scripts/editors/stage/stage_scene_adapter.gd @@ -9,6 +9,7 @@ const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/characters/stickman const MISSING_PROP_PLACEHOLDER := preload("res://scripts/props/missing_prop_placeholder.gd") const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd") const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd") +const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd") func collect( @@ -57,6 +58,8 @@ func collect( entry["asset_id"] = prop.spawn_id entry["stage_object_id"] = prop.stage_object_id entry["roles"] = prop.stage_roles.duplicate(true) + entry["use_spots"] = PROP_USE_SPOT_DATA.encode(prop.get_use_spots()) + entry["next_use_spot_number"] = prop.next_use_spot_number var prop_scale: Vector2 = authored_state.get("scale", prop.scale) entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale) entry["material_preset"] = prop.material_preset @@ -172,6 +175,8 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary if node is PropBlock: var prop: PropBlock = node as PropBlock prop.stage_object_id = String(entry.get("stage_object_id", "")) + var decoded_spots: Array[Dictionary] = PROP_USE_SPOT_DATA.decode(entry.get("use_spots", []) as Array) + prop.set_use_spots(decoded_spots, int(entry.get("next_use_spot_number", 1))) var default_roles: Dictionary = PROP_STAGE_ROLES.new_placement(prop.has_usable_grip()) if entry.get("roles", null) is Dictionary: prop.set_stage_roles(PROP_STAGE_ROLES.normalize(entry["roles"], default_roles)) diff --git a/scripts/editors/stage/stage_target_capture_session.gd b/scripts/editors/stage/stage_target_capture_session.gd index 343f0d3..2cb3f88 100644 --- a/scripts/editors/stage/stage_target_capture_session.gd +++ b/scripts/editors/stage/stage_target_capture_session.gd @@ -4,7 +4,7 @@ extends RefCounted const STICKMAN_ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd") -enum Kind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION } +enum Kind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION, SPOT_ADD, SPOT_MOVE, USE_PROP, USE_SPOT } var kind: int = Kind.NONE var hint: String = "" @@ -16,9 +16,62 @@ var throw_preview_cache: Dictionary = {} var set_down_capture_rig: StickmanRig var set_down_preview_prefix: Array[Dictionary] = [] var set_down_preview_cache: Dictionary = {} +var spot_edit_prop: PropBlock +var spot_edit_id: String = "" +var spot_edit_active: bool = false +var spot_edit_ghost_world: Vector2 = Vector2.INF +var use_target_active: bool = false +var use_target_prop: PropBlock +var use_target_actor: StickmanRig +var use_target_context: String = "" +var use_target_step: int = 0 +var use_target_choices: Array[Dictionary] = [] +var use_target_preview_cache: Dictionary = {} +var use_target_preview_prefix: Array[Dictionary] = [] +var use_target_prefix_known: bool = false + + +func is_stage_custom_capture() -> bool: + return kind in [Kind.SPOT_ADD, Kind.SPOT_MOVE, Kind.USE_PROP, Kind.USE_SPOT] + + +func begin_spot_edit(prop: PropBlock, spot_id: String, ghost_world: Vector2) -> void: + finish() + spot_edit_prop = prop if prop != null and is_instance_valid(prop) else null + spot_edit_id = spot_id + spot_edit_active = spot_edit_prop != null + spot_edit_ghost_world = ghost_world + kind = Kind.SPOT_MOVE if not spot_id.is_empty() else Kind.SPOT_ADD + + +func begin_use_target(context: String, actor: StickmanRig, queue_prefix: Array[Dictionary], prefix_known: bool) -> void: + finish() + use_target_active = true + use_target_context = context + use_target_actor = actor if actor != null and is_instance_valid(actor) else null + use_target_preview_prefix = queue_prefix.duplicate(true) + use_target_prefix_known = prefix_known + kind = Kind.USE_PROP + + +func select_use_target_prop(prop: PropBlock) -> void: + use_target_prop = prop if prop != null and is_instance_valid(prop) else null + use_target_step = 1 if use_target_prop != null else 0 + use_target_choices.clear() + use_target_preview_cache.clear() + kind = Kind.USE_SPOT if use_target_step == 1 else Kind.USE_PROP + + +func reset_use_target_prop() -> void: + use_target_prop = null + use_target_step = 0 + use_target_choices.clear() + use_target_preview_cache.clear() + kind = Kind.USE_PROP if use_target_active else Kind.NONE func begin(kind_value: int, hint_value: String, on_resolve: Callable, on_cancel: Callable) -> void: + finish() kind = kind_value hint = hint_value resolve_callback = on_resolve @@ -54,6 +107,19 @@ func finish() -> void: set_down_capture_rig = null set_down_preview_prefix.clear() set_down_preview_cache.clear() + spot_edit_prop = null + spot_edit_id = "" + spot_edit_active = false + spot_edit_ghost_world = Vector2.INF + use_target_active = false + use_target_prop = null + use_target_actor = null + use_target_context = "" + use_target_step = 0 + use_target_choices.clear() + use_target_preview_cache.clear() + use_target_preview_prefix.clear() + use_target_prefix_known = false func update_preview(camera: Camera2D, snap_enabled: bool, grid_size: float) -> void: diff --git a/scripts/editors/stage/stage_use_spot_overlay.gd b/scripts/editors/stage/stage_use_spot_overlay.gd new file mode 100644 index 0000000..59d84c6 --- /dev/null +++ b/scripts/editors/stage/stage_use_spot_overlay.gd @@ -0,0 +1,344 @@ +class_name StageUseSpotOverlay +extends Control +## Screen-space presentation for authored Use spots and short runtime cues. + +const DEFAULT_PIN_HIT_SIZE: float = 44.0 +const DEFAULT_PIN_RADIUS: float = 15.0 +const DEFAULT_PULSE_DURATION: float = 0.6 + +var _theme: SandboxTheme +var _pin_hit_size: float = DEFAULT_PIN_HIT_SIZE +var _pin_radius: float = DEFAULT_PIN_RADIUS +var _pin_outline_width: float = 2.0 +var _pill_padding: float = 10.0 +var _pulse_duration: float = DEFAULT_PULSE_DURATION +var _prop: PropBlock +var _visible_spots: Array[Dictionary] = [] +var _selected_spot_id: String = "" +var _moving_spot_id: String = "" +var _ghost_world: Vector2 = Vector2.INF +var _instruction: String = "" +var _show_ghost: bool = false +var _guide: Dictionary = {} +var _success_prop: PropBlock +var _success_spot_id: String = "" +var _success_remaining: float = 0.0 +var _failure_text: String = "" +var _failure_prop: PropBlock +var _failure_spot_id: String = "" +var _failure_remaining: float = 0.0 + + +func configure(theme: SandboxTheme) -> void: + _theme = theme + _pin_hit_size = float(_theme.integer("stage_panels.spots.pin_hit_size", 44, 44, 128)) if _theme != null else DEFAULT_PIN_HIT_SIZE + _pin_radius = float(_theme.integer("stage_panels.spots.pin_visual_size", 30, 12, 64)) * 0.5 if _theme != null else DEFAULT_PIN_RADIUS + _pin_outline_width = float(_theme.integer("stage_panels.spots.pin_outline_width", 2, 1, 8)) if _theme != null else 2.0 + _pill_padding = float(_theme.integer("stage_panels.spots.name_pill_padding", 10, 4, 32)) if _theme != null else 10.0 + _pulse_duration = _theme.number("overlays.use_preview.completion_duration", 0.6, 0.1, 2.0) if _theme != null else DEFAULT_PULSE_DURATION + queue_redraw() + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + set_process(true) + + +func show_edit(prop: PropBlock, ghost_world: Vector2 = Vector2.INF, instruction: String = "", selected_spot_id: String = "", moving_spot_id: String = "") -> void: + _set_visible_prop(prop) + _selected_spot_id = selected_spot_id + _moving_spot_id = moving_spot_id + _ghost_world = ghost_world + _instruction = instruction + _show_ghost = instruction != "" + _guide.clear() + queue_redraw() + + +func show_targeting(prop: PropBlock, guide: Dictionary = {}, instruction: String = "") -> void: + _set_visible_prop(prop) + _selected_spot_id = "" + _moving_spot_id = "" + _ghost_world = Vector2.INF + _instruction = instruction + _show_ghost = false + _guide = guide.duplicate(true) + queue_redraw() + + +func _set_visible_prop(prop: PropBlock) -> void: + _prop = prop if prop != null and is_instance_valid(prop) else null + _visible_spots.clear() + if _prop != null: + _visible_spots.assign(_prop.get_use_spots()) + + +func show_success(prop: PropBlock, spot_id: String) -> void: + _success_prop = prop if prop != null and is_instance_valid(prop) else null + _success_spot_id = spot_id + _success_remaining = _pulse_duration + queue_redraw() + + +func show_failure(prop: PropBlock, spot_id: String, reason: String) -> void: + _failure_prop = prop if prop != null and is_instance_valid(prop) else null + _failure_spot_id = spot_id + _failure_text = reason + _failure_remaining = 1.4 + queue_redraw() + + +func hit_spots(screen_position: Vector2) -> Array[String]: + var label_hits: Array[String] = [] + if _prop == null or not is_instance_valid(_prop): + return label_hits + for spot: Dictionary in _visible_spots: + var spot_id: String = String(spot.get("id", "")) + var world_position: Vector2 = _prop.use_spot_world_position(spot_id) + if world_position.is_finite() and _spot_label_rect(_prop, spot).has_point(screen_position): + label_hits.append(spot_id) + if not label_hits.is_empty(): + return label_hits + var result: Array[String] = [] + for spot: Dictionary in _visible_spots: + var spot_id: String = String(spot.get("id", "")) + var world_position: Vector2 = _prop.use_spot_world_position(spot_id) + if world_position.is_finite() and _screen_position(world_position).distance_to(screen_position) <= _pin_hit_size * 0.5: + result.append(spot_id) + return result + + +func closest_hit_spot(screen_position: Vector2) -> String: + if _prop == null or not is_instance_valid(_prop): + return "" + var closest_label_id: String = "" + var closest_label_distance: float = INF + var closest_pin_id: String = "" + var closest_pin_distance: float = _pin_hit_size * 0.5 + for spot: Dictionary in _visible_spots: + var spot_id: String = String(spot.get("id", "")) + var world_position: Vector2 = _prop.use_spot_world_position(spot_id) + if not world_position.is_finite(): + continue + var distance: float = _screen_position(world_position).distance_to(screen_position) + if _spot_label_rect(_prop, spot).has_point(screen_position): + if distance < closest_label_distance: + closest_label_id = spot_id + closest_label_distance = distance + elif distance <= closest_pin_distance: + closest_pin_id = spot_id + closest_pin_distance = distance + return closest_label_id if not closest_label_id.is_empty() else closest_pin_id + + +func clear_session() -> void: + _prop = null + _visible_spots.clear() + _selected_spot_id = "" + _moving_spot_id = "" + _ghost_world = Vector2.INF + _instruction = "" + _show_ghost = false + _guide.clear() + _success_prop = null + _success_spot_id = "" + _success_remaining = 0.0 + _failure_prop = null + _failure_spot_id = "" + _failure_text = "" + _failure_remaining = 0.0 + queue_redraw() + + +func set_paused(paused: bool) -> void: + set_process(not paused) + + +func _process(delta: float) -> void: + if _success_remaining > 0.0: + _success_remaining = maxf(0.0, _success_remaining - delta) + if _failure_remaining > 0.0: + _failure_remaining = maxf(0.0, _failure_remaining - delta) + queue_redraw() + + +func _draw() -> void: + if _prop != null and is_instance_valid(_prop): + for spot: Dictionary in _visible_spots: + var spot_id: String = String(spot.get("id", "")) + if spot_id == _moving_spot_id: + _draw_moving_source(_prop, spot) + else: + _draw_pin(_prop, spot, spot_id == _selected_spot_id) + if _visible_spots.is_empty() and not _instruction.is_empty(): + var local_bounds: Rect2 = _prop.get_visual_local_bounds() + var prompt_world: Vector2 = _prop.global_transform * local_bounds.get_center() + _draw_pill(_screen_position(prompt_world) + Vector2(0.0, -42.0), _instruction, _theme_color("mode_colors.direct", Color("#FFB300"))) + if _show_ghost and _ghost_world.is_finite(): + var ghost_position: Vector2 = _screen_position(_ghost_world) + var moving_spot: Dictionary = _find_visible_spot(_moving_spot_id) + if not moving_spot.is_empty() and _prop != null and is_instance_valid(_prop): + var source_position: Vector2 = _screen_position(_prop.use_spot_world_position(_moving_spot_id)) + var guide_color := Color("#FFB300") + guide_color.a = 0.72 + draw_line(source_position, ghost_position, guide_color, 2.0, true) + _draw_pin_at(ghost_position, int(moving_spot.get("number", 0)), Color("#FFB300"), true) + else: + _draw_pin_at(ghost_position, 0, Color("#FFB300"), false) + if not _instruction.is_empty(): + _draw_pill(ghost_position + Vector2(26.0, -24.0), _instruction, Color("#FFB300")) + if bool(_guide.get("known", false)) or _guide.has("reason_code"): + var start: Vector2 = _guide.get("approach_root", Vector2.INF) + var end: Vector2 = _guide.get("spot_world", Vector2.INF) + if start.is_finite() and end.is_finite(): + var known: bool = bool(_guide.get("known", true)) + var valid: bool = bool(_guide.get("valid", false)) + var end_screen: Vector2 = _screen_position(end) + var guide_color: Color + if not known: + guide_color = _theme_color("overlays.use_preview.uncertain_color", Color("#FFB300")) + elif valid: + guide_color = _theme_color("overlays.use_preview.valid_color", Color("#42D887")) + else: + guide_color = _theme_color("overlays.use_preview.invalid_color", Color("#F06464")) + if not known: + var uncertain_reason: String = String(_guide.get("reason", "We'll check when you play.")) + _draw_reach_marker(end_screen + Vector2(24.0, 0.0), "?", guide_color) + _draw_pill(end_screen + Vector2(0.0, 40.0), uncertain_reason, guide_color) + else: + var reason: String = String(_guide.get("reason", _reason_copy(String(_guide.get("reason_code", "")), valid))) + _draw_reach_marker(end_screen + Vector2(24.0, 0.0), "✓" if valid else "!", guide_color) + _draw_pill(end_screen + Vector2(0.0, -58.0), reason, guide_color) + if _success_remaining > 0.0 and _success_prop != null and is_instance_valid(_success_prop): + var success_spot: Vector2 = _success_prop.use_spot_world_position(_success_spot_id) + if success_spot.is_finite(): + var progress: float = 1.0 - _success_remaining / _pulse_duration + var alpha: float = clampf(_success_remaining / _pulse_duration, 0.0, 1.0) + var pulse_color: Color = _theme_color("overlays.use_preview.valid_color", Color("#42D887")) + pulse_color.a = alpha + draw_arc(_screen_position(success_spot), 15.0 + progress * 24.0, 0.0, TAU, 32, pulse_color, 4.0, true) + if _failure_remaining > 0.0 and _failure_prop != null and is_instance_valid(_failure_prop): + var failure_spot: Vector2 = _failure_prop.use_spot_world_position(_failure_spot_id) + if failure_spot.is_finite(): + _draw_pill(_screen_position(failure_spot) + Vector2(0.0, -42.0), _failure_text, Color("#F06464")) + + +func _draw_pin(prop: PropBlock, spot: Dictionary, selected: bool) -> void: + var spot_id: String = String(spot.get("id", "")) + var point: Vector2 = prop.use_spot_world_position(spot_id) + if not point.is_finite(): + return + var color: Color = _theme_color("mode_colors.direct", Color("#FFB300")) if selected else _theme_color("stage_panels.spots.pin_surface_color", Color("#F4F7FB")) + _draw_pin_at(_screen_position(point), int(spot.get("number", 0)), color, selected) + _draw_pill(_spot_label_center(_screen_position(point)), _spot_label_text(spot), _theme_color("mode_colors.direct", Color("#FFB300"))) + + +func _spot_label_text(spot: Dictionary) -> String: + var number: int = int(spot.get("number", 0)) + var spot_name: String = String(spot.get("name", "Spot %d" % number)).strip_edges() + if spot_name.is_empty(): + spot_name = "Spot %d" % number + return "%d %s" % [number, spot_name] + + +func _spot_label_center(screen_point: Vector2) -> Vector2: + return screen_point + Vector2(0.0, -28.0) + + +func _spot_label_rect(prop: PropBlock, spot: Dictionary) -> Rect2: + var world_position: Vector2 = prop.use_spot_world_position(String(spot.get("id", ""))) + if not world_position.is_finite(): + return Rect2() + var font: Font = ThemeDB.fallback_font + var text_size: Vector2 = font.get_string_size(_spot_label_text(spot), HORIZONTAL_ALIGNMENT_LEFT, -1, 16) + var center: Vector2 = _spot_label_center(_screen_position(world_position)) + return Rect2(center + Vector2(-text_size.x * 0.5 - _pill_padding, -13.0), Vector2(text_size.x + _pill_padding * 2.0, 26.0)) + + +func _draw_moving_source(prop: PropBlock, spot: Dictionary) -> void: + var spot_id: String = String(spot.get("id", "")) + var point: Vector2 = prop.use_spot_world_position(spot_id) + if not point.is_finite(): + return + var screen_point: Vector2 = _screen_position(point) + var amber := Color("#FFB300") + var ghost_fill := amber + ghost_fill.a = 0.30 + draw_circle(screen_point, _pin_radius + 4.0, _theme_color("stage_panels.spots.pin_outline_color", Color("#171B24"))) + draw_circle(screen_point, _pin_radius, ghost_fill) + draw_arc(screen_point, _pin_radius, 0.0, TAU, 32, amber, maxf(_pin_outline_width, 3.0), true) + draw_arc(screen_point, _pin_radius + 5.0, 0.0, TAU, 32, amber, 2.0, true) + draw_circle(screen_point, 3.0, amber) + var font: Font = ThemeDB.fallback_font + var number_text: String = str(int(spot.get("number", 0))) + var text_size: Vector2 = font.get_string_size(number_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 15) + draw_string(font, screen_point + Vector2(-text_size.x * 0.5, 5.0), number_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 15, Color("#171B24")) + _draw_pill(screen_point + Vector2(0.0, -28.0), "Moving " + String(spot.get("name", "Spot")), amber) + + +func _find_visible_spot(spot_id: String) -> Dictionary: + if spot_id.is_empty(): + return {} + for spot: Dictionary in _visible_spots: + if String(spot.get("id", "")) == spot_id: + return spot + return {} + + +func _draw_pin_at(point: Vector2, number: int, accent: Color, selected: bool) -> void: + draw_circle(point, _pin_radius + 3.0, _theme_color("stage_panels.spots.pin_outline_color", Color("#171B24"))) + draw_circle(point, _pin_radius, accent) + draw_arc(point, _pin_radius, 0.0, TAU, 32, Color.WHITE, _pin_outline_width, true) + draw_circle(point, 3.0, Color("#171B24")) + if number > 0: + var font: Font = ThemeDB.fallback_font + var label: String = str(number) + var label_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 15) + draw_string(font, point + Vector2(-label_size.x * 0.5, 5.0), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 15, Color("#171B24")) + + +func _draw_pill(center: Vector2, text_value: String, accent: Color) -> void: + if text_value.is_empty(): + return + var font: Font = ThemeDB.fallback_font + var text_size: Vector2 = font.get_string_size(text_value, HORIZONTAL_ALIGNMENT_LEFT, -1, 16) + var rect := Rect2(center + Vector2(-text_size.x * 0.5 - _pill_padding, -13.0), Vector2(text_size.x + _pill_padding * 2.0, 26.0)) + draw_rect(rect, Color("#171B24"), true) + draw_rect(rect, accent, false, 2.0) + draw_string(font, rect.position + Vector2(10.0, 18.0), text_value, HORIZONTAL_ALIGNMENT_LEFT, -1, 16, Color.WHITE) + + +func _draw_reach_marker(center: Vector2, glyph: String, accent: Color) -> void: + draw_circle(center, 13.0, Color("#171B24")) + draw_circle(center, 10.0, accent) + var font: Font = ThemeDB.fallback_font + var font_size := 16 + var glyph_size: Vector2 = font.get_string_size(glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) + draw_string(font, center + Vector2(-glyph_size.x * 0.5, glyph_size.y * 0.35), glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color("#171B24")) + + +func _screen_position(world_position: Vector2) -> Vector2: + return get_viewport().get_canvas_transform() * world_position + + +func _reason_copy(reason_code: String, valid: bool) -> String: + if valid: + return "Ready to use" + match reason_code: + "route_blocked": + return "Can't walk there" + "no_standing_room": + return "No room to stand" + "hands_full": + return "Hands must be empty" + "prop_held": + return "Prop is busy" + "earlier_actions": + return "We'll check when you play." + _: + return "Too far to reach" + + +func _theme_color(path: String, fallback: Color) -> Color: + return fallback if _theme == null else _theme.color(path, fallback) diff --git a/scripts/editors/stage/stage_use_spot_overlay.gd.uid b/scripts/editors/stage/stage_use_spot_overlay.gd.uid new file mode 100644 index 0000000..51932cc --- /dev/null +++ b/scripts/editors/stage/stage_use_spot_overlay.gd.uid @@ -0,0 +1 @@ +uid://d1ocurcl8fpiu diff --git a/scripts/editors/stage/stage_use_target_capture_bar.gd b/scripts/editors/stage/stage_use_target_capture_bar.gd new file mode 100644 index 0000000..d6ec369 --- /dev/null +++ b/scripts/editors/stage/stage_use_target_capture_bar.gd @@ -0,0 +1,227 @@ +class_name StageUseTargetCaptureBar +extends PanelContainer +## Clear, screen-space instructions and actions for choosing a Use target. + +signal cancel_requested +signal change_prop_requested + +const MAX_BAR_WIDTH: float = 800.0 +const VIEWPORT_WIDTH_RATIO: float = 0.68 +const HORIZONTAL_LAYOUT_MIN_WIDTH: float = 760.0 +const ACTION_ROW_MIN_WIDTH: float = 480.0 +const ACTION_BUTTON_WIDTH: float = 200.0 +const DEFAULT_BUTTON_HEIGHT: float = 56.0 + +var _stage_theme: SandboxTheme +var _top_bar_height: float = 64.0 +var _horizontal_layout: HBoxContainer +var _vertical_layout: VBoxContainer +var _active_layout: Container +var _actions_row: HBoxContainer +var _actions_column: VBoxContainer +var _actions_parent: BoxContainer +var _prompt: Label +var _change_prop_button: Button +var _cancel_button: Button + + +func configure(stage_theme: SandboxTheme, top_bar_height: float) -> void: + _stage_theme = stage_theme + _top_bar_height = maxf(0.0, top_bar_height) + + +func _ready() -> void: + _build_controls() + apply_theme() + z_index = 100 + visible = false + set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT) + get_viewport().size_changed.connect(_layout_capture_bar) + _layout_capture_bar() + + +func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) -> void: + if _prompt == null: + return + if not prop_selected: + _prompt.text = "Choose a prop to use" + elif prop_has_use_spots: + _prompt.text = "Choose a spot on the prop" + else: + _prompt.text = "This prop has no Use spots. Add one in EDIT." + _change_prop_button.visible = prop_selected + visible = true + _layout_capture_bar() + + +func hide_capture() -> void: + visible = false + + +func apply_theme() -> void: + if not is_node_ready() or _prompt == null: + return + theme = _stage_theme.control_theme() if _stage_theme != null else null + var accent: Color = _color("mode_colors.direct", Color("#FFB300")) + var shell: StyleBoxFlat = StyleBoxFlat.new() + shell.bg_color = _color("stage_panels.shell.surface_color", Color("#202633FA")) + shell.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 12, 10, 24)) + shell.border_color = accent + shell.border_width_left = 8 + shell.border_width_right = 0 + shell.border_width_top = 0 + shell.border_width_bottom = 0 + shell.content_margin_left = 20.0 + shell.content_margin_right = 18.0 + shell.content_margin_top = 14.0 + shell.content_margin_bottom = 14.0 + shell.shadow_color = Color(0.0, 0.0, 0.0, 0.38) + shell.shadow_size = 8 + shell.shadow_offset = Vector2(0.0, 4.0) + add_theme_stylebox_override("panel", shell) + var prompt_size: int = clampi(_integer("stage_panels.type.title_size", 24, 18, 36) + 4, 24, 34) + _prompt.add_theme_font_size_override("font_size", prompt_size) + _prompt.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA"))) + var body_font: Font = _stage_theme.font() if _stage_theme != null else ThemeDB.fallback_font + if body_font != null: + _prompt.add_theme_font_override("font", body_font) + _change_prop_button.add_theme_font_override("font", body_font) + _cancel_button.add_theme_font_override("font", body_font) + var body_size: int = clampi(_integer("stage_panels.type.body_size", 20, 16, 32) + 2, 20, 30) + _change_prop_button.add_theme_font_size_override("font_size", body_size) + _cancel_button.add_theme_font_size_override("font_size", body_size) + _style_button(_change_prop_button, false) + _style_button(_cancel_button, true) + var button_height: float = maxf(DEFAULT_BUTTON_HEIGHT, float(_integer("stage_panels.action.min_target_size", 56, 48, 96))) + _change_prop_button.custom_minimum_size.y = button_height + _cancel_button.custom_minimum_size.y = button_height + _actions_row.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 12, 8, 24)) + _actions_column.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 10, 8, 24)) + _layout_capture_bar() + + +func _build_controls() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + _horizontal_layout = HBoxContainer.new() + _horizontal_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _horizontal_layout.add_theme_constant_override("separation", 20) + _vertical_layout = VBoxContainer.new() + _vertical_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _vertical_layout.add_theme_constant_override("separation", 12) + _active_layout = _horizontal_layout + add_child(_active_layout) + _prompt = Label.new() + _prompt.name = "Prompt" + _prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _prompt.max_lines_visible = 2 + _prompt.mouse_filter = Control.MOUSE_FILTER_IGNORE + _prompt.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _prompt.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _active_layout.add_child(_prompt) + _actions_row = HBoxContainer.new() + _actions_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _actions_row.alignment = BoxContainer.ALIGNMENT_END + _actions_column = VBoxContainer.new() + _actions_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _actions_parent = _actions_row + _active_layout.add_child(_actions_parent) + _change_prop_button = Button.new() + _change_prop_button.name = "ChangeProp" + _change_prop_button.text = "Change prop" + _change_prop_button.custom_minimum_size.x = ACTION_BUTTON_WIDTH + _change_prop_button.focus_mode = Control.FOCUS_ALL + _change_prop_button.pressed.connect(func() -> void: change_prop_requested.emit()) + _actions_row.add_child(_change_prop_button) + _cancel_button = Button.new() + _cancel_button.name = "Cancel" + _cancel_button.text = "✕ Cancel" + _cancel_button.custom_minimum_size.x = ACTION_BUTTON_WIDTH + _cancel_button.focus_mode = Control.FOCUS_ALL + _cancel_button.pressed.connect(func() -> void: cancel_requested.emit()) + _actions_row.add_child(_cancel_button) + + +func _style_button(button: Button, prominent: bool) -> void: + var accent: Color = _color("mode_colors.direct", Color("#FFB300")) + var text_color: Color = Color("#171B24") if prominent else _color("stage_panels.type.text_color", Color("#F5F7FA")) + var normal_bg: Color = accent if prominent else _color("stage_panels.action.surface_color", Color("#171B24")) + var hover_bg: Color = accent.lightened(0.08) if prominent else _color("stage_panels.action.hover_surface", Color("#303A4B")) + var pressed_bg: Color = accent.darkened(0.12) if prominent else _color("stage_panels.action.selected_surface", Color("#12394A")) + var border: Color = accent if prominent else _color("stage_panels.choice.border_color", Color("#FFFFFF55")) + button.add_theme_stylebox_override("normal", _button_style(normal_bg, border, 1)) + button.add_theme_stylebox_override("hover", _button_style(hover_bg, accent, 2)) + button.add_theme_stylebox_override("pressed", _button_style(pressed_bg, accent, 2)) + button.add_theme_stylebox_override("hover_pressed", _button_style(hover_bg, accent, 2)) + button.add_theme_stylebox_override("focus", _button_style(Color(0.0, 0.0, 0.0, 0.0), accent, 2)) + button.add_theme_color_override("font_color", text_color) + button.add_theme_color_override("font_hover_color", text_color) + button.add_theme_color_override("font_pressed_color", text_color) + button.add_theme_color_override("font_focus_color", text_color) + + +func _button_style(background: Color, border: Color, border_width: int) -> StyleBoxFlat: + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = background + style.border_color = border + style.set_border_width_all(border_width) + style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 10, 0, 32)) + style.content_margin_left = 10.0 + style.content_margin_right = 10.0 + style.content_margin_top = 6.0 + style.content_margin_bottom = 6.0 + return style + + +func _layout_capture_bar() -> void: + if not is_node_ready() or _prompt == null: + return + var viewport_size: Vector2 = get_viewport_rect().size + var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48)) + var available_width: float = maxf(1.0, viewport_size.x - inset * 2.0) + var width: float = minf(MAX_BAR_WIDTH, minf(viewport_size.x * VIEWPORT_WIDTH_RATIO, available_width)) + custom_minimum_size.x = width + size.x = width + position = Vector2((viewport_size.x - width) * 0.5, _top_bar_height + 56.0) + _set_layout(width < HORIZONTAL_LAYOUT_MIN_WIDTH, width < ACTION_ROW_MIN_WIDTH) + call_deferred("_fit_height_to_content") + + +func _set_layout(compact: bool, stack_actions: bool) -> void: + var target_layout: Container = _vertical_layout if compact else _horizontal_layout + var target_actions: BoxContainer = _actions_column if stack_actions else _actions_row + if _actions_parent != target_actions: + var previous_actions: BoxContainer = _actions_parent + previous_actions.remove_child(_change_prop_button) + previous_actions.remove_child(_cancel_button) + _active_layout.remove_child(previous_actions) + _actions_parent = target_actions + _active_layout.add_child(_actions_parent) + _actions_parent.add_child(_change_prop_button) + _actions_parent.add_child(_cancel_button) + if _active_layout != target_layout: + _active_layout.remove_child(_prompt) + _active_layout.remove_child(_actions_parent) + remove_child(_active_layout) + _active_layout = target_layout + add_child(_active_layout) + _active_layout.add_child(_prompt) + _active_layout.add_child(_actions_parent) + _change_prop_button.custom_minimum_size.x = 0.0 if stack_actions else ACTION_BUTTON_WIDTH + _cancel_button.custom_minimum_size.x = 0.0 if stack_actions else ACTION_BUTTON_WIDTH + _change_prop_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL if stack_actions else Control.SIZE_FILL + _cancel_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL if stack_actions else Control.SIZE_FILL + + +func _fit_height_to_content() -> void: + if not is_inside_tree() or not visible: + return + custom_minimum_size.y = get_combined_minimum_size().y + size.y = custom_minimum_size.y + + +func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int: + return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum) + + +func _color(path: String, fallback: Color) -> Color: + return fallback if _stage_theme == null else _stage_theme.color(path, fallback) diff --git a/scripts/editors/stage/stage_use_target_capture_bar.gd.uid b/scripts/editors/stage/stage_use_target_capture_bar.gd.uid new file mode 100644 index 0000000..1e920d8 --- /dev/null +++ b/scripts/editors/stage/stage_use_target_capture_bar.gd.uid @@ -0,0 +1 @@ +uid://gnnheadxlx6r diff --git a/scripts/props/prop_block.gd b/scripts/props/prop_block.gd index 6945c2e..8c92adc 100644 --- a/scripts/props/prop_block.gd +++ b/scripts/props/prop_block.gd @@ -13,6 +13,7 @@ extends RigidBody2D const PropVisuals := preload("res://scripts/props/prop_visuals.gd") const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd") const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd") +const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd") const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd") # --------------------------------------------------------------------------- @@ -33,6 +34,8 @@ var stage_object_id: String = "" var interaction_anchors: Dictionary = {} var interaction_reserved_by: int = 0 var interaction_owner_id: int = 0 +var use_spots: Array[Dictionary] = [] +var next_use_spot_number: int = 1 var stage_roles: Dictionary = {} var runtime_role: RuntimeRole = RuntimeRole.EDIT @@ -400,6 +403,103 @@ func get_visual_local_bounds() -> Rect2: return _visual_local_bounds +func get_use_spots() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for spot: Dictionary in use_spots: + result.append(spot.duplicate(true)) + return result + + +func get_use_spot(spot_id: String) -> Dictionary: + return PROP_USE_SPOT_DATA.find(use_spots, spot_id) + + +func get_use_spot_local_bounds() -> Rect2: + if _custom_shapes: + return _visual_local_bounds + if shape_type == ShapeType.CIRCLE: + return Rect2(Vector2(-radius, -radius), Vector2(radius * 2.0, radius * 2.0)) + if polygon_points.is_empty(): + return Rect2() + var minimum: Vector2 = polygon_points[0] + var maximum: Vector2 = polygon_points[0] + for point: Vector2 in polygon_points: + minimum = minimum.min(point) + maximum = maximum.max(point) + return Rect2(minimum, maximum - minimum) + + +func add_use_spot(local_position: Vector2) -> String: + if not local_position.is_finite() or not get_use_spot_local_bounds().has_point(local_position): + return "" + var number: int = maxi(1, next_use_spot_number) + var spot_id: String = "spot_%d" % number + if not get_use_spot(spot_id).is_empty(): + return "" + use_spots.append({"id": spot_id, "number": number, "name": PROP_USE_SPOT_DATA.fallback_name(number), "position": local_position}) + next_use_spot_number = number + 1 + return spot_id + + +func rename_use_spot(spot_id: String, spot_name: String) -> bool: + for index: int in use_spots.size(): + if String(use_spots[index].get("id", "")) != spot_id: + continue + var number: int = int(use_spots[index].get("number", 1)) + var normalized_name: String = spot_name.strip_edges() + use_spots[index]["name"] = normalized_name if not normalized_name.is_empty() else PROP_USE_SPOT_DATA.fallback_name(number) + return true + return false + + +func move_use_spot(spot_id: String, local_position: Vector2) -> bool: + if not local_position.is_finite() or not get_use_spot_local_bounds().has_point(local_position): + return false + for index: int in use_spots.size(): + if String(use_spots[index].get("id", "")) == spot_id: + use_spots[index]["position"] = local_position + return true + return false + + +func erase_use_spot(spot_id: String) -> bool: + for index: int in use_spots.size(): + if String(use_spots[index].get("id", "")) == spot_id: + use_spots.remove_at(index) + return true + return false + + +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_position: Vector2 = spot.get("position", Vector2.ZERO) + var baked_scale: Vector2 = _play_instance_scale if _play_scale_active else Vector2.ONE + return global_transform * (local_position * baked_scale) + + +func use_spot_local_from_world(world_position: Vector2) -> Vector2: + if not world_position.is_finite() or not _transform_is_invertible(global_transform): + return Vector2.INF + var local_position: Vector2 = to_local(world_position) + return local_position if get_use_spot_local_bounds().has_point(local_position) else Vector2.INF + + +func set_use_spots(spots: Array[Dictionary], next_number: int) -> void: + use_spots.clear() + for spot: Dictionary in spots: + use_spots.append(spot.duplicate(true)) + var highest: int = 0 + for spot: Dictionary in use_spots: + highest = maxi(highest, int(spot.get("number", 0))) + next_use_spot_number = maxi(highest + 1, next_number) + + +func _transform_is_invertible(value: Transform2D) -> bool: + return value.x.is_finite() and value.y.is_finite() and value.origin.is_finite() and is_finite(value.determinant()) and absf(value.determinant()) > 0.000001 + + func get_interaction_scale() -> Vector2: return _play_instance_scale if _play_scale_active else scale.abs() diff --git a/scripts/props/prop_use_spot_data.gd b/scripts/props/prop_use_spot_data.gd new file mode 100644 index 0000000..3a6c3b6 --- /dev/null +++ b/scripts/props/prop_use_spot_data.gd @@ -0,0 +1,109 @@ +class_name PropUseSpotData +extends RefCounted +## Pure validation and conversion helpers for placed-prop Use spots. + +static func validate_saved(raw_spots: Variant, raw_next: Variant) -> String: + if not raw_spots is Array: + return "Prop Use spots must be an array." + if not _is_integer_number(raw_next): + return "Prop Use spot counter must be an integer." + var next_value: float = float(raw_next) + if not is_finite(next_value) or next_value < 1.0: + return "Prop Use spot counter must be a positive integer." + var ids: Dictionary = {} + var numbers: Dictionary = {} + var highest: int = 0 + for raw: Variant in raw_spots as Array: + if not raw is Dictionary: + return "Prop Use spot entries must be objects." + var spot: Dictionary = raw + var raw_id: Variant = spot.get("id", null) + var raw_number: Variant = spot.get("number", null) + var raw_name: Variant = spot.get("name", null) + if not raw_id is String or not _is_integer_number(raw_number) or not raw_name is String: + return "Prop Use spot fields are invalid." + var numeric_number: float = float(raw_number) + if not is_finite(numeric_number) or numeric_number < 1.0: + return "Prop Use spot numbers must be positive integers." + var number: int = int(numeric_number) + var spot_id: String = String(raw_id) + if spot_id != "spot_%d" % number or ids.has(spot_id) or numbers.has(number): + return "Prop Use spot IDs and numbers must be unique and canonical." + if not _is_finite_point(spot.get("position", null)): + return "Prop Use spot positions must be finite points." + ids[spot_id] = true + numbers[number] = true + highest = maxi(highest, number) + if int(next_value) <= highest: + return "Prop Use spot counter must exceed every existing spot number." + return "" + + +static func decode(raw_spots: Array) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for raw: Variant in raw_spots: + if not raw is Dictionary: + continue + var spot: Dictionary = raw + var number: int = int(spot.get("number", 0)) + var name: String = String(spot.get("name", "")).strip_edges() + result.append({ + "id": String(spot.get("id", "spot_%d" % number)), + "number": number, + "name": name if not name.is_empty() else fallback_name(number), + "position": _point_from_data(spot.get("position", null)), + }) + return result + + +static func encode(spots: Array[Dictionary]) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for spot: Dictionary in spots: + var number: int = int(spot.get("number", 0)) + var position: Variant = spot.get("position", Vector2.ZERO) + if not position is Vector2: + continue + var name: String = String(spot.get("name", "")).strip_edges() + result.append({ + "id": String(spot.get("id", "spot_%d" % number)), + "number": number, + "name": name if not name.is_empty() else fallback_name(number), + "position": {"x": position.x, "y": position.y}, + }) + return result + + +static func fallback_name(number: int) -> String: + return "Spot %d" % maxi(1, number) + + +static func find(spots: Array[Dictionary], spot_id: String) -> Dictionary: + for spot: Dictionary in spots: + if String(spot.get("id", "")) == spot_id: + return spot.duplicate(true) + return {} + + +static func _is_finite_point(value: Variant) -> bool: + if not value is Dictionary: + return false + var point: Dictionary = value + var x: Variant = point.get("x", null) + var y: Variant = point.get("y", null) + return (x is int or x is float) and (y is int or y is float) and is_finite(float(x)) and is_finite(float(y)) + + +static func _is_integer_number(value: Variant) -> bool: + if value is int: + return true + if not value is float: + return false + var number: float = value + return is_finite(number) and absf(number) <= 9007199254740991.0 and floorf(number) == number + + +static func _point_from_data(value: Variant) -> Vector2: + if not _is_finite_point(value): + return Vector2.ZERO + var point: Dictionary = value + return Vector2(float(point["x"]), float(point["y"])) diff --git a/scripts/props/prop_use_spot_data.gd.uid b/scripts/props/prop_use_spot_data.gd.uid new file mode 100644 index 0000000..8247141 --- /dev/null +++ b/scripts/props/prop_use_spot_data.gd.uid @@ -0,0 +1 @@ +uid://vdr0sanaei05 diff --git a/scripts/stage/action_registry.gd b/scripts/stage/action_registry.gd index c223b7d..ac05f29 100644 --- a/scripts/stage/action_registry.gd +++ b/scripts/stage/action_registry.gd @@ -83,6 +83,15 @@ const ACTION_TEMPLATES := { ], "reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false, }, + "use_prop": { + "label": "Use…", + "icon": "🖐️", + "params": [ + { "key": "target_prop_id", "type": "prop", "required": true }, + { "key": "target_spot_id", "type": "spot", "required": true }, + ], + "reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false, + }, } @@ -167,6 +176,9 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary: params["target_position"] = action.get("target_position", Vector2.ZERO) "set_down_prop": params["target_position"] = action.get("target_position", Vector2.ZERO) + "use_prop": + params["target_prop_id"] = String(action.get("target_prop_id", "")) + params["target_spot_id"] = String(action.get("target_spot_id", "")) return { "type": type, "target": target_id, @@ -199,6 +211,8 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary: return { "type": "throw_prop", "target_position": params.get("target_position", Vector2.ZERO) } "set_down_prop": return { "type": "set_down_prop", "target_position": params.get("target_position", Vector2.ZERO) } + "use_prop": + return { "type": "use_prop", "target_prop_id": String(params.get("target_prop_id", "")), "target_spot_id": String(params.get("target_spot_id", "")) } _: return { "type": type } @@ -240,6 +254,10 @@ static func summarize(action: Dictionary) -> String: var raw_release: Variant = action.get("target_position", params.get("target_position", Vector2.ZERO)) var release_position: Vector2 = raw_release as Vector2 if raw_release is Vector2 else Vector2.ZERO return "Set Down Prop (%d, %d)" % [int(roundf(release_position.x)), int(roundf(release_position.y))] + "use_prop": + var prop_id: String = String(action.get("target_prop_id", params.get("target_prop_id", ""))) + var spot_id: String = String(action.get("target_spot_id", params.get("target_spot_id", ""))) + return "Use (%s · %s)" % [prop_id, spot_id] if not prop_id.is_empty() and not spot_id.is_empty() else "Use (Choose a new spot)" _: return type diff --git a/scripts/stage/sandbox_theme.gd b/scripts/stage/sandbox_theme.gd index 0d84409..04d9470 100644 --- a/scripts/stage/sandbox_theme.gd +++ b/scripts/stage/sandbox_theme.gd @@ -20,9 +20,10 @@ const DEFAULT_CONFIG: Dictionary = { "choice": {"minimum_height": 56, "row_gap": 6, "neutral_surface": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A", "disabled_surface": "#252B38", "border_color": "#FFFFFF33"}, "toggle": {"minimum_height": 64, "help_minimum_height": 72, "row_padding": 12, "switch_width": 64, "switch_height": 34, "knob_size": 26, "neutral_surface": "#202633", "hover_surface": "#2B3442", "selected_surface": "#173443", "disabled_surface": "#252B38", "border_color": "#FFFFFF33", "switch_on_color": "#22C6FF", "switch_off_color": "#4A5362", "switch_disabled_color": "#3B414D", "knob_color": "#F5F7FA"}, "action": {"min_target_size": 48, "close_width": 88, "surface_color": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A"}, - "prop": {"stickman_cue_color": "#F5F7FA", "prop_cue_color": "#B7824D", "cue_outline_color": "#171B24"} + "prop": {"stickman_cue_color": "#F5F7FA", "prop_cue_color": "#B7824D", "cue_outline_color": "#171B24"}, + "spots": {"row_gap": 8, "pin_visual_size": 30, "pin_hit_size": 44, "pin_outline_width": 2, "focus_ring_width": 3, "name_pill_padding": 10, "pin_surface_color": "#F4F7FB", "pin_outline_color": "#171B24", "completion_duration": 0.6} }, - "overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}, "set_down": {"unknown_color": "#C5CAD4", "reach_color": "#FFB300", "ghost_alpha": 0.36}}, + "overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "rule_connector_color": "#AAB2C0", "rule_action_color": "#7E57C2", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}, "set_down": {"unknown_color": "#C5CAD4", "reach_color": "#FFB300", "ghost_alpha": 0.36}, "use_preview": {"valid_color": "#42D887", "invalid_color": "#F06464", "uncertain_color": "#FFB300", "neutral_color": "#C5CAD4", "completion_duration": 0.6}}, "widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"small", "label":"Small", "value":20}, {"id":"medium", "label":"Medium", "value":28}, {"id":"huge", "label":"HUGE", "value":40}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]}, "speech": {"toolbar": {"surface":"#1C2431F7", "border":"#9EADBF", "selection_halo":"#FFD84D", "gap":10, "max_width":480, "min_hit_height":48}, "ui": {"panel_width": 440, "button_min_height": 44, "heading_size": 24}, "layout": {"max_width": 420, "stack_gap": 12, "screen_margin": 18, "bubble_padding": 18}, "animation": {"letter_interval": 0.045, "word_interval": 0.20, "pop_duration": 0.24}, "styles": {"talk": {"fill":"#FFFDF6", "outline":"#202020", "tail":"talk"}, "shout": {"fill":"#FFD642", "outline":"#D92F26", "tail":"shout"}, "think": {"fill":"#E9F5FF", "outline":"#37698B", "tail":"think"}, "whisper": {"fill":"#F2F2F2", "outline":"#6D7885", "tail":"whisper"}, "sfx": {"fill":"#FFDD39", "outline":"#D92F26", "tail":"none"}}, "text_colors": [{"name":"Ink","color":"#202020"},{"name":"Midnight","color":"#111827"},{"name":"Slate","color":"#475569"},{"name":"Snow","color":"#FFFFFF"},{"name":"Cloud","color":"#CBD5E1"},{"name":"Sky","color":"#2196F3"},{"name":"Ocean","color":"#0077B6"},{"name":"Teal","color":"#00897B"},{"name":"Leaf","color":"#2E7D32"},{"name":"Lime","color":"#8BC34A"},{"name":"Sunshine","color":"#FFC107"},{"name":"Orange","color":"#F57C00"},{"name":"Sunset red","color":"#E53935"},{"name":"Rose","color":"#D81B60"},{"name":"Violet","color":"#7E57C2"},{"name":"Brown","color":"#795548"}], "preview_ghost":"#FFFFFF99", "connector":"#FFFFFFB0"} } diff --git a/scripts/stage/stage_director_visuals.gd b/scripts/stage/stage_director_visuals.gd index ed65def..54e6eb3 100644 --- a/scripts/stage/stage_director_visuals.gd +++ b/scripts/stage/stage_director_visuals.gd @@ -26,7 +26,8 @@ const BADGE_STACK_STEP := Vector2(0.0, -28.0) ## Phase 4 rule rendering constants. const WAYPOINT_HIT_RADIUS_PX := 14.0 const RULE_TRIGGER_COLOR := Color(0.2, 0.85, 0.3) -const RULE_ACTION_COLOR := Color(1.0, 0.55, 0.15) +const RULE_ACTION_COLOR := Color("#7E57C2") +const RULE_CONNECTOR_COLOR := Color("#AAB2C0") const RULE_LABEL_BG := Color(0.0, 0.0, 0.0, 0.7) const RULE_LABEL_BORDER := Color(1.0, 1.0, 1.0, 0.25) const RULE_LABEL_FONT_SIZE_PX := 14.0 @@ -47,6 +48,8 @@ var badge_radius: float = RULE_BADGE_RADIUS_PX var completed_path_alpha: float = 0.20 var completed_path_color: Color = Color(0.58, 0.62, 0.68) var rule_label_font_size: float = RULE_LABEL_FONT_SIZE_PX +var rule_action_color: Color = RULE_ACTION_COLOR +var rule_connector_color: Color = RULE_CONNECTOR_COLOR var emoji_font: Font = null ## Phase 3c style fonts/flags (set by the stage from sandbox_theme.json). Bold is @@ -341,13 +344,13 @@ func _draw_rule(rule: Dictionary, zoom: float) -> void: if not action_anchor.is_finite(): action_anchor = trigger_anchor - # Dashed white connector (trigger -> action). + # Quiet gray connector keeps the rule relationship distinct from reach guides. if trigger_anchor.distance_to(action_anchor) > 0.5: - _draw_dashed(trigger_anchor, action_anchor, zoom) + _draw_dashed(trigger_anchor, action_anchor, zoom, rule_connector_color) # Badges. _draw_rule_badge(trigger_anchor, zoom, "⚡", RULE_TRIGGER_COLOR) - _draw_rule_badge(action_anchor, zoom, "→", RULE_ACTION_COLOR) + _draw_rule_badge(action_anchor, zoom, "→", rule_action_color) # Label at the line midpoint on a dark rounded rect. var summary := rule_summary(rule) diff --git a/scripts/stage/stage_rule_engine.gd b/scripts/stage/stage_rule_engine.gd index 15ddef7..b013e16 100644 --- a/scripts/stage/stage_rule_engine.gd +++ b/scripts/stage/stage_rule_engine.gd @@ -50,6 +50,21 @@ func matches_rule(rule: Dictionary, event: Dictionary) -> bool: return ids_match(source_id, source) "entered_area", "collided", "prop_picked_up", "prop_dropped", "prop_thrown": return ids_match(source_id, source) and ids_match(target_id, target) + "prop_used": + if not ids_match(source_id, source) or not target is PropBlock: + return false + var params: Dictionary = trigger.get("params", {}) + var target_prop_id: String = String(params.get("target_prop_id", "")) + var prop: PropBlock = target as PropBlock + if target_prop_id.is_empty() or prop.stage_object_id != target_prop_id: + return false + var scope: String = String(params.get("spot_scope", "")) + if scope == "any": + return not String(event.get("spot_id", "")).is_empty() + if scope != "specific": + return false + var spot_id: String = String(params.get("target_spot_id", "")) + return not spot_id.is_empty() and spot_id == String(event.get("spot_id", "")) _: return false diff --git a/scripts/stage/stage_scene_io.gd b/scripts/stage/stage_scene_io.gd index fb61388..2c5b594 100644 --- a/scripts/stage/stage_scene_io.gd +++ b/scripts/stage/stage_scene_io.gd @@ -3,12 +3,13 @@ extends RefCounted ## JSON boundary for Sandbox Stage documents. This class deliberately has no ## scene-tree dependencies, which makes rejected documents non-destructive. -const FILE_VERSION: String = "1.2" +const FILE_VERSION: String = "1.3" const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd") const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd") const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd") const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd") +const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd") static func vector_to_data(value: Vector2) -> Dictionary: return {"x": value.x, "y": value.y} @@ -160,11 +161,15 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary: if version == FILE_VERSION: var current: Dictionary = data.duplicate(true) for raw: Variant in current.get("objects", []): - if raw is Dictionary and String((raw as Dictionary).get("type", "")) == "prop" and not (raw as Dictionary).has("roles"): - (raw as Dictionary)["roles"] = PROP_STAGE_ROLES.legacy_placement(true) + if raw is Dictionary and String((raw as Dictionary).get("type", "")) == "prop": + var prop_entry: Dictionary = raw + if not prop_entry.has("roles"): + prop_entry["roles"] = PROP_STAGE_ROLES.legacy_placement(true) + prop_entry["use_spots"] = prop_entry.get("use_spots", []) + prop_entry["next_use_spot_number"] = prop_entry.get("next_use_spot_number", 1) _assign_legacy_depths(current.get("objects", [])) return current - if version not in ["1.0", "1.1"]: + if version not in ["1.0", "1.1", "1.2"]: return {} var migrated: Dictionary = data.duplicate(true) for raw: Variant in migrated.get("objects", []): @@ -176,6 +181,8 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary: object["stage_object_id"] = String(object.get("file_id", "")) if not object.has("roles"): object["roles"] = PROP_STAGE_ROLES.legacy_placement(true) + object["use_spots"] = object.get("use_spots", []) + object["next_use_spot_number"] = object.get("next_use_spot_number", 1) migrated["version"] = FILE_VERSION _assign_legacy_depths(migrated.get("objects", [])) return migrated @@ -222,6 +229,9 @@ static func _validate_root(data: Dictionary) -> Dictionary: if stage_id.strip_edges().is_empty() or seen_stage_ids.has(stage_id): return {"ok": false, "error": "Prop stage IDs must be unique and non-empty."} seen_stage_ids[stage_id] = true + var use_spot_error: String = PROP_USE_SPOT_DATA.validate_saved(entry.get("use_spots", []), entry.get("next_use_spot_number", 1)) + if not use_spot_error.is_empty(): + return {"ok": false, "error": use_spot_error} if entry.has("roles"): var roles_value: Variant = entry["roles"] if not roles_value is Dictionary: @@ -271,6 +281,11 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary: var release_position: Vector2 = action["target_position"] if not release_position.is_finite(): return {} return {"type": type, "target_position": vector_to_data(release_position)} + "use_prop": + var prop_id: String = String(action.get("target_prop_id", "")) + var spot_id: String = String(action.get("target_spot_id", "")) + if prop_id.strip_edges().is_empty() or spot_id.strip_edges().is_empty(): return {} + return {"type": type, "target_prop_id": prop_id, "target_spot_id": spot_id} _: return {"type": type} static func _decode_flat_action(action: Dictionary) -> Dictionary: @@ -297,6 +312,11 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary: "set_down_prop": if not is_vector_data(action.get("target_position")): return {} return {"type": type, "target_position": data_to_vector(action["target_position"], Vector2.ZERO)} + "use_prop": + var prop_id: String = String(action.get("target_prop_id", "")) + var spot_id: String = String(action.get("target_spot_id", "")) + if prop_id.strip_edges().is_empty() or spot_id.strip_edges().is_empty(): return {} + return {"type": type, "target_prop_id": prop_id, "target_spot_id": spot_id} _: return {"type": type} static func _map_ref(value: Variant, map: Dictionary) -> Variant: @@ -417,6 +437,11 @@ static func _encode_trigger(trigger: Dictionary, refs: Dictionary) -> Dictionary var source: Variant = _map_ref(trigger.get("source", -1), refs); var target: Variant = _map_ref(trigger.get("target", -1), refs) if source == null and int(trigger.get("source", -1)) != -1: return {} if target == null and int(trigger.get("target", -1)) != -1: return {} + if type == "prop_used": + if not trigger.has("source") or int(trigger.get("source", -1)) == -1 or source == null: + return {} + if not trigger.has("target") or int(trigger.get("target", -1)) != -1 or target != null: + return {} var params: Dictionary = (trigger.get("params", {}) as Dictionary).duplicate(true) if type == "arrived_at_waypoint": if not params.get("waypoint_pos") is Vector2: return {} @@ -425,10 +450,23 @@ static func _encode_trigger(trigger: Dictionary, refs: Dictionary) -> Dictionary var filter: String = String(params.get("action_type", "")) if not filter.is_empty(): params["action_type"] = ACTION_REGISTRY.canonical_type(filter) + elif type == "prop_used": + var prop_id: String = String(params.get("target_prop_id", "")) + var scope: String = String(params.get("spot_scope", "")) + if prop_id.strip_edges().is_empty() or scope not in ["any", "specific"]: return {} + if scope == "specific": + var spot_id: String = String(params.get("target_spot_id", "")) + if spot_id.strip_edges().is_empty(): return {} + params = {"target_prop_id": prop_id, "spot_scope": scope, "target_spot_id": spot_id} + else: + if params.has("target_spot_id"): return {} + params = {"target_prop_id": prop_id, "spot_scope": scope} return {"type": type, "source_id": source, "target_id": target, "params": params} static func _decode_trigger(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary: var type := String(value.get("type", "")); if not TRIGGER_REGISTRY.has_type(type): return {} + if type == "prop_used" and (not value.has("source_id") or value.get("source_id") == null or not value.has("target_id") or value.get("target_id") != null): + return {} var source := _unmap_ref(value.get("source_id"), refs, unavailable); var target := _unmap_ref(value.get("target_id"), refs, unavailable) if source == -2 or target == -2: return {} var params: Dictionary = (value.get("params", {}) as Dictionary).duplicate(true) @@ -439,6 +477,18 @@ static func _decode_trigger(value: Dictionary, refs: Dictionary, unavailable: Di var filter: String = String(params.get("action_type", "")) if not filter.is_empty(): params["action_type"] = ACTION_REGISTRY.canonical_type(filter) + elif type == "prop_used": + var prop_id: String = String(params.get("target_prop_id", "")) + var scope: String = String(params.get("spot_scope", "")) + if prop_id.strip_edges().is_empty() or scope not in ["any", "specific"]: return {} + if scope == "specific": + var spot_id: String = String(params.get("target_spot_id", "")) + if spot_id.strip_edges().is_empty(): return {} + params = {"target_prop_id": prop_id, "spot_scope": scope, "target_spot_id": spot_id} + else: + if params.has("target_spot_id"): return {} + params = {"target_prop_id": prop_id, "spot_scope": scope} + if source == -1 or target != -1: return {} return {"type": type, "source": source, "target": target, "params": params} static func _encode_rule_action(action: Dictionary, refs: Dictionary) -> Dictionary: diff --git a/scripts/stage/trigger_registry.gd b/scripts/stage/trigger_registry.gd index e7b2c41..b52a386 100644 --- a/scripts/stage/trigger_registry.gd +++ b/scripts/stage/trigger_registry.gd @@ -47,6 +47,11 @@ const TRIGGER_TEMPLATES := { "icon": "🏹", "target_type": "prop", }, + "prop_used": { + "label": "Used prop", + "icon": "🖐️", + "target_type": "use_prop", + }, } diff --git a/tests/test_phase17a_prop_roles.gd b/tests/test_phase17a_prop_roles.gd index 8346e9b..ac67549 100644 --- a/tests/test_phase17a_prop_roles.gd +++ b/tests/test_phase17a_prop_roles.gd @@ -76,7 +76,7 @@ func _test_legacy_migration() -> void: var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(legacy) var prop_entry: Dictionary = migrated["objects"][1] var roles: Dictionary = prop_entry["roles"] - _expect(migrated["version"] == "1.2", "old stage version migrates to 1.2") + _expect(migrated["version"] == "1.3", "old stage version migrates to 1.3") _expect(prop_entry["stage_object_id"] == "prop_a", "1.0 prop IDs migrate from file IDs") _expect(String(roles["start_motion"]) == PROP_STAGE_ROLES.START_PHYSICS, "legacy prop keeps dynamic start behavior") _expect(bool(roles["blocks_stickmen"]) and bool(roles["legacy_draw_order"]), "legacy prop keeps collisions and draw order") diff --git a/tests/test_phase17b_use_spots.gd b/tests/test_phase17b_use_spots.gd new file mode 100644 index 0000000..320e9e6 --- /dev/null +++ b/tests/test_phase17b_use_spots.gd @@ -0,0 +1,187 @@ +extends SceneTree + +const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd") +const PROP_BLOCK_SCRIPT := preload("res://scripts/props/prop_block.gd") +const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd") +const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") +const STAGE_RULE_ENGINE := preload("res://scripts/stage/stage_rule_engine.gd") +const PROP_INTERACTOR_SCRIPT := preload("res://scripts/characters/stickman/stickman_prop_interactor.gd") +const STICKMAN_RIG_SCENE := preload("res://scenes/characters/stickman/stickman_rig.tscn") + +var _checks: int = 0 +var _failures: int = 0 +var _last_use_failure: String = "" + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _test_migration_and_schema() + _test_spot_round_trip() + _test_action_and_trigger_references() + _test_rule_matching() + await _test_coordinates_and_empty_hands() + print("----------------------------------------") + if _failures == 0: + print("RESULT: ALL PASSED (%d checks)" % _checks) + quit(0) + else: + print("RESULT: %d FAILURE(S) out of %d checks" % [_failures, _checks]) + quit(1) + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if condition: + print("PASS: " + message) + else: + _failures += 1 + push_error("Phase 17b check failed: " + message) + + +func _test_migration_and_schema() -> void: + for version: String in ["1.0", "1.1", "1.2"]: + var legacy: Dictionary = {"version": version, "objects": [{"file_id": "prop_a", "type": "prop"}]} + var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(legacy) + var entry: Dictionary = migrated["objects"][0] + _expect(String(migrated["version"]) == "1.3" and entry.get("use_spots") == [] and int(entry.get("next_use_spot_number", 0)) == 1, "%s prop receives v1.3 empty Use spot defaults" % version) + var valid_root: Dictionary = _valid_root() + _expect(bool(STAGE_SCENE_IO._validate_root(valid_root).get("ok", false)), "valid Use spot data passes root validation") + var duplicate: Dictionary = _valid_root() + duplicate["objects"][0]["use_spots"] = [ + {"id": "spot_1", "number": 1, "name": "A", "position": {"x": 2.0, "y": 3.0}}, + {"id": "spot_1", "number": 1, "name": "B", "position": {"x": 4.0, "y": 5.0}}, + ] + duplicate["objects"][0]["next_use_spot_number"] = 2 + _expect(not bool(STAGE_SCENE_IO._validate_root(duplicate).get("ok", false)), "duplicate Use spot IDs and numbers are rejected") + var nonfinite: Dictionary = _valid_root() + nonfinite["objects"][0]["use_spots"][0]["position"] = {"x": INF, "y": 0.0} + _expect(not bool(STAGE_SCENE_IO._validate_root(nonfinite).get("ok", false)), "nonfinite Use spot coordinates are rejected") + var bad_counter: Dictionary = _valid_root() + bad_counter["objects"][0]["next_use_spot_number"] = 1 + _expect(not bool(STAGE_SCENE_IO._validate_root(bad_counter).get("ok", false)), "Use spot counter must be greater than stored numbers") + + +func _test_spot_round_trip() -> void: + var spots: Array[Dictionary] = [{"id": "spot_2", "number": 2, "name": "Button", "position": Vector2(24.5, -18.25)}] + var encoded: Array[Dictionary] = PROP_USE_SPOT_DATA.encode(spots) + var decoded: Array[Dictionary] = PROP_USE_SPOT_DATA.decode(encoded) + _expect(encoded[0]["position"] == {"x": 24.5, "y": -18.25}, "spot serializer writes local point coordinates") + _expect(decoded[0]["position"] == Vector2(24.5, -18.25), "spot decoder restores nonzero vector coordinates") + _expect(String(decoded[0]["id"]) == "spot_2" and int(decoded[0]["number"]) == 2, "spot serializer preserves stable identity") + + +func _test_action_and_trigger_references() -> void: + var action: Dictionary = {"type": "use_prop", "target_prop_id": "prop_missing", "target_spot_id": "spot_9"} + var encoded_queue: Dictionary = STAGE_SCENE_IO.encode_queue([action]) + var decoded_queue: Dictionary = STAGE_SCENE_IO.decode_queue(encoded_queue["actions"]) + var queue_action: Dictionary = decoded_queue["actions"][0] + _expect(queue_action == action, "Use queue action preserves missing stable prop and spot IDs for repair") + var flat_action: Dictionary = action.duplicate(true) + var consequence: Dictionary = ACTION_REGISTRY.to_rule_action(flat_action, 42) + var rule: Dictionary = { + "id": 3, + "trigger": {"type": "prop_used", "source": 42, "target": -1, "params": {"target_prop_id": "prop_missing", "spot_scope": "specific", "target_spot_id": "spot_9"}}, + "actions": [consequence], + } + var encoded_rules: Dictionary = STAGE_SCENE_IO.encode_rules([rule], {42: "actor_a"}) + var encoded_trigger: Dictionary = encoded_rules["rules"][0]["trigger"] + _expect(encoded_trigger["source_id"] == "actor_a" and encoded_trigger["target_id"] == null, "Used prop saves actor source and null target") + var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(encoded_rules["rules"], {"actor_a": 42}, {}) + var decoded_trigger: Dictionary = decoded_rules["rules"][0]["trigger"] + _expect(String(decoded_trigger["params"]["target_prop_id"]) == "prop_missing" and String(decoded_trigger["params"]["target_spot_id"]) == "spot_9", "Used prop round-trip preserves repairable stable target IDs") + var invalid_target: Dictionary = rule.duplicate(true) + invalid_target["trigger"]["target"] = 88 + _expect(STAGE_SCENE_IO.encode_rules([invalid_target], {42: "actor_a"})["rules"].is_empty(), "Used prop in-memory target must be exactly -1") + var saved_bad_target: Dictionary = encoded_rules["rules"][0].duplicate(true) + saved_bad_target["trigger"]["target_id"] = "other_prop" + _expect(STAGE_SCENE_IO.decode_rules([saved_bad_target], {"actor_a": 42, "other_prop": 99}, {}).get("rules", []).is_empty(), "Used prop saved target_id must be null") + var any_with_spot: Dictionary = rule.duplicate(true) + any_with_spot["trigger"]["params"] = {"target_prop_id": "prop_missing", "spot_scope": "any", "target_spot_id": "spot_9"} + _expect(STAGE_SCENE_IO.encode_rules([any_with_spot], {42: "actor_a"})["rules"].is_empty(), "Any Used prop trigger omits a specific spot ID") + + +func _test_rule_matching() -> void: + var actor: Node2D = Node2D.new() + var prop: PropBlock = PROP_BLOCK_SCRIPT.new() + actor.name = "UseActor" + prop.stage_object_id = "prop_live" + root.add_child(actor) + root.add_child(prop) + var engine: StageRuleEngine = STAGE_RULE_ENGINE.new() + var base: Dictionary = {"type": "prop_used", "source": actor.get_instance_id(), "target": -1, "params": {"target_prop_id": "prop_live", "spot_scope": "specific", "target_spot_id": "spot_2"}} + var rule: Dictionary = {"id": 1, "trigger": base, "actions": []} + var event: Dictionary = {"type": "prop_used", "source": actor, "target": prop, "spot_id": "spot_2"} + _expect(engine.matches_rule(rule, event), "specific Used prop rule matches exact live prop and spot") + event["spot_id"] = "spot_1" + _expect(not engine.matches_rule(rule, event), "specific Used prop rule rejects a different spot") + var any_rule: Dictionary = rule.duplicate(true) + any_rule["trigger"]["params"] = {"target_prop_id": "prop_live", "spot_scope": "any"} + event["spot_id"] = "spot_1" + _expect(engine.matches_rule(any_rule, event), "Any Used prop rule matches another spot on the same prop") + any_rule["trigger"]["params"]["target_prop_id"] = "prop_missing" + _expect(not engine.matches_rule(any_rule, event), "Used prop wildcard target does not match a different prop") + actor.queue_free() + prop.queue_free() + + +func _test_coordinates_and_empty_hands() -> void: + var prop: PropBlock = PROP_BLOCK_SCRIPT.new() + prop.shape_type = PropBlock.ShapeType.POLYGON + prop.polygon_points = PackedVector2Array([Vector2(-40.0, -30.0), Vector2(40.0, -30.0), Vector2(40.0, 30.0), Vector2(-40.0, 30.0)]) + prop.position = Vector2(180.0, 90.0) + prop.rotation = deg_to_rad(27.0) + prop.scale = Vector2(-1.5, 1.25) + root.add_child(prop) + await process_frame + var spot_id: String = prop.add_use_spot(Vector2(12.0, -8.0)) + var expected_world: Vector2 = prop.global_transform * Vector2(12.0, -8.0) + _expect(spot_id == "spot_1" and prop.use_spot_world_position(spot_id).is_equal_approx(expected_world), "spot follows prop position, rotation, and signed EDIT scale") + var inverse_point: Vector2 = prop.use_spot_local_from_world(expected_world) + _expect(inverse_point.is_equal_approx(Vector2(12.0, -8.0)), "world authoring coordinate converts back to prop-local space") + prop.begin_play_instance_scale() + var play_expected: Vector2 = prop.global_transform * (Vector2(12.0, -8.0) * Vector2(-1.5, 1.25)) + _expect(prop.use_spot_world_position(spot_id).is_equal_approx(play_expected), "signed authored scale is baked into PLAY spot position") + prop.end_play_instance_scale() + _expect(prop.scale.is_equal_approx(Vector2(-1.5, 1.25)), "STOP restores authored prop scale") + + var rig: StickmanRig = STICKMAN_RIG_SCENE.instantiate() as StickmanRig + root.add_child(rig) + await process_frame + var interactor: Node = rig.get_node("RigComponents/PropInteractor") + var held: PropBlock = PROP_BLOCK_SCRIPT.new() + root.add_child(held) + interactor.set("_held_prop", held) + prop.apply_runtime_role(PropBlock.RuntimeRole.START_PHYSICS) + prop.linear_velocity = Vector2(31.0, -4.0) + var start_freeze: bool = prop.freeze + var start_layer: int = prop.collision_layer + var start_mask: int = prop.collision_mask + interactor.connect("use_failed", Callable(self, "_on_use_failed")) + var started: bool = bool(interactor.call("start_action", {"type": "use_prop", "target_prop_id": prop.stage_object_id, "target_spot_id": spot_id})) + _expect(not started and _last_use_failure == "hands_full", "Use rejects an actor with full hands") + _expect(prop.runtime_role == PropBlock.RuntimeRole.START_PHYSICS and prop.freeze == start_freeze and prop.collision_layer == start_layer and prop.collision_mask == start_mask, "failed Use leaves target physics profile unchanged") + _expect(prop.linear_velocity.is_equal_approx(Vector2(31.0, -4.0)), "failed Use does not alter target velocity") + held.queue_free() + rig.queue_free() + prop.queue_free() + await process_frame + + +func _on_use_failed(reason_code: String, _prop: PropBlock, _spot_id: String) -> void: + _last_use_failure = reason_code + + +func _valid_root() -> Dictionary: + return { + "version": "1.3", + "objects": [{ + "file_id": "prop_file", + "type": "prop", + "stage_object_id": "prop_live", + "use_spots": [{"id": "spot_1", "number": 1, "name": "Button", "position": {"x": 24.0, "y": -18.0}}], + "next_use_spot_number": 2, + }], + } diff --git a/tests/test_phase17b_use_spots.gd.uid b/tests/test_phase17b_use_spots.gd.uid new file mode 100644 index 0000000..81d52e8 --- /dev/null +++ b/tests/test_phase17b_use_spots.gd.uid @@ -0,0 +1 @@ +uid://ckpf6m2c4knov