Add Use Spot Overlay and Target Capture Bar functionality
- Introduced `StageUseTargetCaptureBar` class for managing user interactions with props and their use spots. - Implemented UI elements including buttons for changing props and canceling actions. - Added `PropUseSpotData` class for validation and serialization of prop use spots. - Created tests for validating use spot data migration, serialization, and rule matching. - Ensured proper handling of edge cases in use spot data, including unique IDs and finite positions.
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user