Files
stickman/docs/specs/phase_17c_impact_wobble.md
ryan 5c3b056a13 Add new scene files and implement stage impact resolver
- Created three new scene files: `wobble.scene`, `wobble2.scene`, and `wobble3.scene` with various terrain and prop configurations.
- Introduced `stage_impact_resolver.gd` script to manage impact detection and response between props and stickmen.
- Added unit tests for actor blocking overlap to ensure proper interaction with stationary props.
2026-10-14 13:08:34 -04:00

18 KiB
Raw Permalink Blame History

Phase 17c — Impact wobble: architecture and implementation specification

Status: Architecture approved and implementation present; automated behavior verification and in-editor user verification are pending. Product/UI contract: Phase 17 plan and Impact wobble UI/UX specification. 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

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:

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

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