diff --git a/content/stages/wobble.scene b/content/stages/wobble.scene new file mode 100644 index 0000000..56117bc --- /dev/null +++ b/content/stages/wobble.scene @@ -0,0 +1,322 @@ +{ + "version": "1.4", + "scene_name": "Untitled", + "camera": { + "position": { + "x": 0.0, + "y": -400.0 + }, + "zoom": 0.5 + }, + "objects": [ + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "x": -1152.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "x": -960.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "x": -768.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "x": -576.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "x": -384.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "x": -192.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "x": 0.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "x": 192.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "x": 384.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "x": 576.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "x": 768.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_12", + "type": "terrain", + "position": { + "x": 960.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_13", + "type": "terrain", + "position": { + "x": 1152.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "prop_1", + "type": "prop", + "position": { + "x": -120.0, + "y": -240.0 + }, + "rotation": 0.0, + "asset_id": "res://content/props/end_table.prop", + "stage_object_id": "prop_17571232_4221072249_1", + "roles": { + "depth": "front", + "start_motion": "physics", + "holds_other_props": false, + "blocks_stickmen": true, + "can_pick_up": false, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [], + "next_use_spot_number": 1, + "wobble_on_impact": true, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 1 + }, + { + "file_id": "stickman_1", + "type": "stickman", + "position": { + "x": -708.0, + "y": -469.0 + }, + "rotation": 0.0, + "stk_path": "res://content/stickmen/break.stk", + "facing_profile": "FORWARD", + "action_queue": [ + { + "type": "move_to", + "target": { + "x": 936.0, + "y": -120.0 + }, + "style": "run" + } + ] + }, + { + "file_id": "terrain_14", + "type": "terrain", + "position": { + "x": 1344.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_15", + "type": "terrain", + "position": { + "x": 1536.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_16", + "type": "terrain", + "position": { + "x": 1728.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_17", + "type": "terrain", + "position": { + "x": 1920.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_18", + "type": "terrain", + "position": { + "x": 2112.0, + "y": -64.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + } + ], + "event_rules": [], + "metadata": { + "created_unix": 1791912219, + "modified_unix": 1791922588.754, + "autosave": false, + "source_path": "" + } +} \ No newline at end of file diff --git a/content/stages/wobble2.scene b/content/stages/wobble2.scene new file mode 100644 index 0000000..5435bde --- /dev/null +++ b/content/stages/wobble2.scene @@ -0,0 +1,303 @@ +{ + "version": "1.4", + "scene_name": "Untitled", + "camera": { + "position": { + "x": 0.0, + "y": -400.0 + }, + "zoom": 0.5 + }, + "objects": [ + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "x": -1344.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "x": -1152.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "x": -960.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "x": -768.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "x": -576.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "x": -384.0, + "y": 96.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": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "x": 0.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "x": 192.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "x": 384.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "x": 576.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_12", + "type": "terrain", + "position": { + "x": 768.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_13", + "type": "terrain", + "position": { + "x": 960.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_14", + "type": "terrain", + "position": { + "x": 1152.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "prop_1", + "type": "prop", + "position": { + "x": -792.0, + "y": -120.0 + }, + "rotation": 0.0, + "asset_id": "res://content/props/end_table.prop", + "stage_object_id": "prop_15501231_3425066006_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, + "wobble_on_impact": true, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 1 + }, + { + "file_id": "prop_2", + "type": "prop", + "position": { + "x": 0.0, + "y": -972.0 + }, + "rotation": 0.0, + "asset_id": "res://content/props/baseball.prop", + "stage_object_id": "prop_26034886_2936602825_2", + "roles": { + "depth": "behind", + "start_motion": "physics", + "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, + "wobble_on_impact": true, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_3", + "type": "prop", + "position": { + "x": 12.0, + "y": -48.0 + }, + "rotation": 0.0, + "asset_id": "crate", + "stage_object_id": "prop_316151302_224236905_3", + "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, + "wobble_on_impact": false, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 1 + } + ], + "event_rules": [], + "metadata": { + "created_unix": 1791922794, + "modified_unix": 1791923002.879, + "autosave": false, + "source_path": "" + } +} \ No newline at end of file diff --git a/content/stages/wobble3.scene b/content/stages/wobble3.scene new file mode 100644 index 0000000..4841240 --- /dev/null +++ b/content/stages/wobble3.scene @@ -0,0 +1,269 @@ +{ + "version": "1.4", + "scene_name": "Untitled", + "camera": { + "position": { + "x": 0.0, + "y": -400.0 + }, + "zoom": 0.5 + }, + "objects": [ + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "x": -1344.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "x": -1152.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "x": -960.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "x": -768.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "x": -576.0, + "y": 96.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "x": -492.0, + "y": 708.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "x": -300.0, + "y": 708.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "x": -108.0, + "y": 708.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "x": 84.0, + "y": 708.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "x": 276.0, + "y": 708.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "x": 468.0, + "y": 708.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_12", + "type": "terrain", + "position": { + "x": 660.0, + "y": 708.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_13", + "type": "terrain", + "position": { + "x": 852.0, + "y": 708.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_14", + "type": "terrain", + "position": { + "x": 1044.0, + "y": 708.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "prop_1", + "type": "prop", + "position": { + "x": -240.0, + "y": 516.0 + }, + "rotation": 0.0, + "asset_id": "res://content/props/end_table.prop", + "stage_object_id": "prop_15501231_3425066006_1", + "roles": { + "depth": "behind", + "start_motion": "still", + "holds_other_props": false, + "blocks_stickmen": true, + "can_pick_up": true, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [], + "next_use_spot_number": 1, + "wobble_on_impact": true, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 1 + }, + { + "file_id": "stickman_1", + "type": "stickman", + "position": { + "x": -1284.0, + "y": -312.0 + }, + "rotation": 0.0, + "stk_path": "res://content/stickmen/break.stk", + "facing_profile": "FORWARD", + "action_queue": [ + { + "type": "move_to", + "target": { + "x": -540.0, + "y": 48.0 + }, + "style": "run" + }, + { + "type": "ragdoll" + } + ] + } + ], + "event_rules": [], + "metadata": { + "created_unix": 1791922794, + "modified_unix": 1791948300.039, + "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 3aaea7d..ee91986 100644 --- a/docs/architecture/tech_debt_and_optimizations.md +++ b/docs/architecture/tech_debt_and_optimizations.md @@ -78,8 +78,8 @@ This document tracks known technical debt, optimization opportunities, and minor | 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) | +| 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 | Implemented; verification pending | Phase 17c routes identified blocking-prop sweep hits and actual body contacts through one relative approach-speed scorer and pair gate. Multiple stickman limbs collapse into one rig/prop pair; separation/rearm and cooldown gates suppress repeat hits. Automated behavior verification and in-editor user verification remain pending. Keep existing collision-rule dispatch independent. See `docs/specs/phase_17c_impact_wobble.md`. (2026-10-14) | +| 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 | Implemented; future design | Phase 17c keeps the Wobble boolean and visual-only response independent of mass. Impact scoring is centralized in `StageImpactResolver` 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-14) | --- @@ -165,5 +165,6 @@ This document tracks known technical debt, optimization opportunities, and minor | 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. | +| 2026-10-14 | Phase 17c Impact wobble implementation is present against the approved UI and architecture contracts. The prop card has a per-placement switch and EDIT sample; PLAY routes contacts and identified blocking sweeps through the approach-speed resolver, including ragdoll and prop pairs, while wobbling artwork only. Scene v1.4 stores the setting and migrates older scenes to Off; STOP/reset clears transient motion. Automated behavior verification and in-editor user verification have not been completed. Updated #69–#70; mass-aware scoring remains a future design seam. | _End of Log_ diff --git a/docs/plans/phase_17_props_for_storytelling.md b/docs/plans/phase_17_props_for_storytelling.md index b59ba1d..f58a502 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. Phase 17a is user-approved. Phase 17b implementation is present and awaiting in-editor user verification; Phases 17c–17d remain deferred. +> **Status:** Accepted product brief. Phase 17a is user-approved. Phase 17b implementation is present and awaiting in-editor user verification. Phase 17c implementation is present; automated behavior verification and in-editor user verification are pending. Phase 17d remains deferred. > **Audience:** UI/UX, architect, and implementation team. > **Purpose:** Product and design brief; technical contracts belong in later specifications. @@ -18,7 +18,7 @@ Phase 17 is one product milestone with four ordered parts. Each part should be i | --- | --- | --- | | **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 | Implemented; awaiting user verification | -| **17c — Impact wobble** | Make a prop briefly wobble after a meaningful hit. | Next | Deferred | +| **17c — Impact wobble** | Make a prop briefly wobble after a meaningful hit. | Next | Implemented; awaiting automated behavior and in-editor user verification | | **17d — Scene groups** | Organize and move several placed props together while editing a scene. | Last; lower priority | Deferred | 17a's role model, saved-scene migration, fixed depth tiers, covered-object chooser, collision handling, Set Down action, and UI are implemented and user-approved. Automated test run: no execution result is documented for `tests/test_phase17a_prop_roles.gd`. The success criteria below remain regression guidance. Depth is currently a fixed behind/in-front choice; dynamic Y sorting remains deferred. @@ -41,7 +41,7 @@ A successfully completed interaction emits a child-facing **Used prop** event th ### 17c — Impact wobble -The child can choose a simple impact response for a placed prop: **None** or **Wobble**. A noticeable physical hit produces one short, controlled wobble that settles cleanly. Ordinary resting contact should not make a prop shake repeatedly. The stage's existing collision rules remain available for more elaborate consequences. Stopping PLAY cancels any active wobble and restores the authored prop appearance and placement. +The child can turn **Wobble when bumped** on or off for one placed prop. Enabling it in EDIT plays a brief sample; the choice remains available for pass-through props. In PLAY, a qualifying approach-speed hit produces a short tilt and sideways give on the artwork while the physics body and collisions continue normally. Contact and blocking-movement sweep candidates share one resolver with pair rearming and cooldown gates; animated stickmen, ragdoll bodies, other props, and other physics bodies can supply hits when collision policy allows. Two enabled props can both wobble from one qualifying prop-to-prop hit. Scene v1.4 stores the choice per placement, and older scene versions default it to Off. Stopping PLAY cancels the effect and restores the authored state. The current score is based on approach speed, not mass; the resolver keeps scoring centralized for future mass or impulse work. ### 17d — Scene groups @@ -74,15 +74,14 @@ Deferred: dynamic Y sorting by character and prop ground points; built-in Use ef - `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 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. +- Phase 17c implementation is present in `scripts/props/prop_block.gd`, `scripts/stage/stage_impact_resolver.gd`, `scripts/editors/stage/sandbox_stage.gd`, `scripts/editors/stage/stage_prop_settings_view.gd`, `scripts/editors/stage/stage_run_session.gd`, `scripts/editors/stage/stage_scene_adapter.gd`, and `scripts/stage/stage_scene_io.gd`. The prop card provides the per-placement switch and EDIT sample, including for pass-through props. PLAY applies a visual-only wobble to artwork; root physics motion and collision geometry remain under normal simulation. Body contacts and identified blocking-stickman sweeps use relative approach speed, with pair tracking and cooldown to suppress repeated resting/push contact. Ragdoll and prop-prop contacts are routed through the resolver. Scene v1.4 persists the boolean and migrates older scenes to Off. Material preset mass is not part of the score; the shared resolver is the extension seam for a future editable-mass or impulse model. Automated behavior verification and in-editor user verification are pending; do not treat the implementation as verified yet. - 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). ## Regression checks and future design questions -17a's settings controls, overlap chooser, Set Down release-point behavior, legacy scene defaults, implementation, and UI are user-approved. Keep the table/pot support and pass-through case, blocking movement, open-air and corrected Set Down targets, invalid-target recovery, covered-object selection, repeat PLAY reset, and old-scene replay as regression guidance. +17a's settings controls, overlap chooser, Set Down release-point behavior, legacy scene defaults, implementation, and UI are user-approved. Keep the table/pot support and pass-through case, blocking movement, open-air and corrected Set Down targets, invalid-target recovery, covered-object selection, repeat PLAY reset, and old-scene replay as regression guidance. For 17c, regression checks should cover EDIT preview and cancellation; pass-through availability; fixed and moving props; a walking/running stickman contact and blocked movement sweep; a falling ragdoll; prop-to-prop hits with both enabled and one disabled; slow/resting contacts and sustained pushes; pause/resume and STOP/replay; per-instance save/reload; and v1.0–v1.3 migration to Off. These are pending checks, not reported results. -The following design questions remain for the deferred parts of Phase 17: +The following design question remains for the deferred part of Phase 17: -- **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_17c_impact_wobble.md b/docs/specs/phase_17c_impact_wobble.md index fa19d6f..7530143 100644 --- a/docs/specs/phase_17c_impact_wobble.md +++ b/docs/specs/phase_17c_impact_wobble.md @@ -1,6 +1,6 @@ # Phase 17c — Impact wobble: architecture and implementation specification -> **Status:** Architecture specified for final user sign-off; implementation has not begun. +> **Status:** Architecture approved and implementation present; automated behavior verification and in-editor user verification are pending. > **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. diff --git a/sandbox_theme.json b/sandbox_theme.json index 5a9d727..657b682 100644 --- a/sandbox_theme.json +++ b/sandbox_theme.json @@ -70,6 +70,13 @@ "play": "#33DD77", "guide": "#22C6FF" }, + "effects": { + "impact_wobble": { + "duration": 0.48, + "maximum_angle_degrees": 7.0, + "sideways_offset": 4.0 + } + }, "grid": { "snap_size": 15.0 }, "chrome": { "style_defaults": { "bg_color": "#171B24F2", "corner_radius": 10 }, diff --git a/scripts/characters/stickman/stickman_locomotion.gd b/scripts/characters/stickman/stickman_locomotion.gd index 55cff1a..71becea 100644 --- a/scripts/characters/stickman/stickman_locomotion.gd +++ b/scripts/characters/stickman/stickman_locomotion.gd @@ -71,6 +71,9 @@ const LEG_COLLISION_RADIUS: float = 14.0 const HEAD_COLLISION_FALLBACK_CENTER: Vector2 = Vector2(0.0, -72.0) const COLLISION_LAYER: int = 1 const WALK_BLOCKED_TIMEOUT: float = 0.45 +## Temporary, throttled diagnostics for actor-blocking prop sweep queries. +const ACTOR_PROP_DEBUG_ENABLED: bool = true +const ACTOR_PROP_DEBUG_INTERVAL_FRAMES: int = 30 const COLLISION_PARTS: Dictionary = { "head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS}, "torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS}, @@ -83,6 +86,14 @@ const COLLISION_PARTS: Dictionary = { "right_upper_leg": {"visual": "RightUpperLeg", "bone": "Torso/RightUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS}, "right_lower_leg": {"visual": "RightLowerLeg", "bone": "Torso/RightUpperLeg/RightLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS}, } +const ACTOR_BLOCKING_SWEEP_PARTS: Array[String] = [ + "torso", + "head", + "left_upper_leg", + "left_lower_leg", + "right_upper_leg", + "right_lower_leg", +] const REACTION_BASE_ANGLE: float = 0.35 const REACTION_REFERENCE_SPEED: float = 400.0 const REACTION_MAX_ANGLE: float = 0.6 @@ -101,6 +112,7 @@ const REACTION_TARGETS: Dictionary = { } signal arrived(target: Vector2) +signal blocking_prop_sweep_hit(prop: PropBlock, normal: Vector2, world_position: Vector2, requested_velocity: Vector2) var _rig: StickmanRig = null var foot_contacts: Dictionary = {"Left_Leg": {"valid": false, "locked": false}, "Right_Leg": {"valid": false, "locked": false}} @@ -146,6 +158,11 @@ var _blocked_walk_elapsed: float = 0.0 var _walk_failed: bool = false var collision_bodies: Dictionary = {} var collision_lengths: Dictionary = {} +var collision_proxy_velocities: Dictionary = {} +var _collision_proxy_positions: Dictionary = {} +var _last_sweep_hit_tick: Dictionary = {} +var _actor_prop_debug_last_sample_frame: int = -1 +var _actor_prop_debug_last_hit_tick: Dictionary = {} var _collision_visuals: Dictionary = {} var reaction_bones: Dictionary = {} var impact_reactions: Dictionary = {} @@ -176,6 +193,9 @@ func configure(rig: StickmanRig) -> void: func setup_collision_bodies() -> void: collision_bodies.clear() collision_lengths.clear() + collision_proxy_velocities.clear() + _collision_proxy_positions.clear() + _last_sweep_hit_tick.clear() _collision_visuals.clear() reaction_bones.clear() if _rig == null or _rig._body_container == null or _rig._skeleton == null: @@ -226,14 +246,42 @@ func setup_collision_bodies() -> void: proxy.global_transform = visual.global_transform collision_bodies[key] = proxy _collision_visuals[key] = visual + _collision_proxy_positions[proxy.get_instance_id()] = proxy.global_position + collision_proxy_velocities[proxy.get_instance_id()] = Vector2.ZERO func sync_collision_bodies() -> void: + var physics_delta: float = 1.0 / maxf(1.0, float(Engine.physics_ticks_per_second)) for key: String in collision_bodies: var proxy: AnimatableBody2D = collision_bodies.get(key) as AnimatableBody2D var visual: Node2D = _collision_visuals.get(key) as Node2D if proxy != null and visual != null and is_instance_valid(proxy) and is_instance_valid(visual): + var proxy_id: int = proxy.get_instance_id() + var target_position: Vector2 = visual.global_position + var previous_position: Vector2 = _collision_proxy_positions.get(proxy_id, target_position) as Vector2 + var velocity: Vector2 = (target_position - previous_position) / physics_delta + collision_proxy_velocities[proxy_id] = velocity if velocity.is_finite() else Vector2.ZERO proxy.global_transform = visual.global_transform + _collision_proxy_positions[proxy_id] = proxy.global_position + + +func collision_proxy_velocity(body: PhysicsBody2D) -> Vector2: + if body == null or not is_instance_valid(body): + return Vector2.ZERO + var velocity: Vector2 = collision_proxy_velocities.get(body.get_instance_id(), Vector2.ZERO) as Vector2 + return velocity if velocity.is_finite() else Vector2.ZERO + + +func reset_collision_proxy_velocity_baselines() -> void: + _collision_proxy_positions.clear() + collision_proxy_velocities.clear() + for proxy_value: Variant in collision_bodies.values(): + var proxy: AnimatableBody2D = proxy_value as AnimatableBody2D + if proxy == null or not is_instance_valid(proxy): + continue + var proxy_id: int = proxy.get_instance_id() + _collision_proxy_positions[proxy_id] = proxy.global_position + collision_proxy_velocities[proxy_id] = Vector2.ZERO func mounted_visual_bounds_local(part: Node2D) -> Dictionary: @@ -362,6 +410,9 @@ func walk_to(target: Vector2, speed: float = -1.0) -> bool: func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_override: float = -1.0) -> bool: + if ACTOR_PROP_DEBUG_ENABLED: + _actor_prop_debug_last_sample_frame = -1 + _actor_prop_debug_last_hit_tick.clear() if _walk_debug_enabled(): print("[WalkDebug] MOVE_TO style=%s target=%s root=%s" % [style, target, _rig.global_position if _rig != null else Vector2.ZERO]) if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED: @@ -557,8 +608,17 @@ func _update_blocked_walk(delta: float) -> void: func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary: if _rig == null or is_zero_approx(motion.x): return {"fraction": 1.0} + var debug_frame: int = Engine.get_physics_frames() + var debug_sample: bool = ACTOR_PROP_DEBUG_ENABLED and ( + _actor_prop_debug_last_sample_frame < 0 + or debug_frame - _actor_prop_debug_last_sample_frame >= ACTOR_PROP_DEBUG_INTERVAL_FRAMES + ) + if debug_sample: + _actor_prop_debug_last_sample_frame = debug_frame var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state if space_state == null: + if debug_sample: + print("[PropCollisionDebug][Sweep] no_space_state rig=%s motion=%s" % [_rig.name, str(motion)]) return {"fraction": 1.0} var excluded: Array[RID] = [] for own_proxy: AnimatableBody2D in collision_bodies.values(): @@ -568,12 +628,24 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary: if held != null and is_instance_valid(held): excluded.append(held.get_rid()) var safe_fraction: float = 1.0 - for part_name: String in ["torso", "head"]: + var best_hit_fraction: float = INF + var best_hit: Dictionary = {} + var requested_velocity: Vector2 = motion * float(Engine.physics_ticks_per_second) + if debug_sample: + print( + "[PropCollisionDebug][Sweep] rig=%s root=%s motion=%s mask=%d excluded=%d" + % [_rig.name, str(_rig.global_position), str(motion), STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER, excluded.size()] + ) + for part_name: String in ACTOR_BLOCKING_SWEEP_PARTS: var proxy: AnimatableBody2D = collision_bodies.get(part_name) as AnimatableBody2D if proxy == null or not is_instance_valid(proxy): + if debug_sample: + print("[PropCollisionDebug][SweepPart] part=%s missing_proxy" % part_name) continue var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D if shape_node == null or shape_node.shape == null: + if debug_sample: + print("[PropCollisionDebug][SweepPart] part=%s missing_shape" % part_name) continue var query := PhysicsShapeQueryParameters2D.new() query.shape = shape_node.shape @@ -581,11 +653,143 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary: query.motion = motion query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER query.exclude = excluded + # cast_motion() ignores shapes that already overlap at the start. A + # stickman can begin PLAY partly inside a placed prop, so inspect the + # starting pose and block only motion that pushes farther into it. Motion + # away from the contact normal remains free so the stickman can get out. + var start_hit: Dictionary = space_state.get_rest_info(query) + var start_prop: PropBlock = _prop_from_rest_info(start_hit) + var start_normal: Vector2 = start_hit.get("normal", Vector2.ZERO) as Vector2 + var start_dot: float = start_normal.dot(motion) + if debug_sample: + print( + "[PropCollisionDebug][SweepPart] part=%s shape=%s origin=%s disabled=%s start_id=%d start_prop=%s start_blocking=%s start_normal=%s dot=%s" + % [ + part_name, + shape_node.shape.get_class(), + str(query.transform.origin), + str(shape_node.disabled), + int(start_hit.get("collider_id", 0)), + start_prop.name if start_prop != null and is_instance_valid(start_prop) else "", + str(start_prop.is_actor_blocking()) if start_prop != null and is_instance_valid(start_prop) else "false", + str(start_normal), + str(start_dot), + ] + ) + if not start_hit.is_empty(): + var start_position: Vector2 = start_hit.get("point", Vector2.ZERO) as Vector2 + if ( + start_prop != null + and is_instance_valid(start_prop) + and start_prop.is_actor_blocking() + and start_normal.is_finite() + and not start_normal.is_zero_approx() + and start_position.is_finite() + and start_dot < -0.001 + ): + safe_fraction = 0.0 + if best_hit_fraction > 0.0: + best_hit_fraction = 0.0 + best_hit = { + "prop": start_prop, + "normal": start_normal.normalized(), + "position": start_position, + } + continue var cast: PackedFloat32Array = space_state.cast_motion(query) - if cast.size() >= 1: - safe_fraction = minf(safe_fraction, cast[0]) + if cast.size() < 1: + if debug_sample: + print("[PropCollisionDebug][SweepPart] part=%s cast returned no fractions" % part_name) + continue + safe_fraction = minf(safe_fraction, cast[0]) + if debug_sample: + print( + "[PropCollisionDebug][SweepPart] part=%s cast_safe=%.4f cast_unsafe=%.4f" + % [part_name, cast[0], cast[1] if cast.size() > 1 else cast[0]] + ) + if cast[0] >= 0.999: + continue + var unsafe_fraction: float = cast[1] if cast.size() > 1 else cast[0] + var rest_query: PhysicsShapeQueryParameters2D = PhysicsShapeQueryParameters2D.new() + rest_query.shape = shape_node.shape + rest_query.transform = shape_node.global_transform + rest_query.transform.origin += motion * minf(1.0, unsafe_fraction + 0.001) + rest_query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER + rest_query.exclude = excluded + var hit: Dictionary = space_state.get_rest_info(rest_query) + var prop: PropBlock = _prop_from_rest_info(hit) + if debug_sample: + print( + "[PropCollisionDebug][SweepHit] part=%s unsafe=%.4f id=%d prop=%s blocking=%s normal=%s point=%s" + % [ + part_name, + unsafe_fraction, + int(hit.get("collider_id", 0)), + prop.name if prop != null and is_instance_valid(prop) else "", + str(prop.is_actor_blocking()) if prop != null and is_instance_valid(prop) else "false", + str(hit.get("normal", Vector2.ZERO)), + str(hit.get("point", Vector2.ZERO)), + ] + ) + if prop == null or not is_instance_valid(prop) or not prop.is_actor_blocking(): + continue + if unsafe_fraction >= best_hit_fraction: + continue + var normal: Vector2 = hit.get("normal", Vector2.ZERO) as Vector2 + var world_position: Vector2 = hit.get("point", Vector2.ZERO) as Vector2 + if not normal.is_finite() or normal.is_zero_approx() or not world_position.is_finite(): + continue + var relative_velocity: Vector2 = requested_velocity - prop.get_impact_velocity() + if normal.dot(relative_velocity) > 0.0: + normal = -normal + best_hit_fraction = unsafe_fraction + best_hit = {"prop": prop, "normal": normal.normalized(), "position": world_position} + if debug_sample: + var result_prop: PropBlock = best_hit.get("prop") as PropBlock + print( + "[PropCollisionDebug][SweepResult] rig=%s fraction=%.4f hit=%s" + % [_rig.name, safe_fraction, result_prop.name if result_prop != null and is_instance_valid(result_prop) else ""] + ) + if not best_hit.is_empty(): + var hit_prop: PropBlock = best_hit["prop"] as PropBlock + var hit_tick: int = Engine.get_physics_frames() + var hit_id: int = hit_prop.get_instance_id() + if int(_last_sweep_hit_tick.get(hit_id, -1)) != hit_tick: + _last_sweep_hit_tick[hit_id] = hit_tick + var last_debug_hit_tick: int = int(_actor_prop_debug_last_hit_tick.get(hit_id, -ACTOR_PROP_DEBUG_INTERVAL_FRAMES)) + if ACTOR_PROP_DEBUG_ENABLED and hit_tick - last_debug_hit_tick >= ACTOR_PROP_DEBUG_INTERVAL_FRAMES: + _actor_prop_debug_last_hit_tick[hit_id] = hit_tick + print( + "[PropCollisionDebug][Impact] rig=%s prop=%s id=%s normal=%s position=%s requested_velocity=%s" + % [ + _rig.name, + hit_prop.name, + hit_prop.stage_object_id, + str(best_hit["normal"]), + str(best_hit["position"]), + str(requested_velocity), + ] + ) + blocking_prop_sweep_hit.emit( + hit_prop, + best_hit["normal"] as Vector2, + best_hit["position"] as Vector2, + requested_velocity + ) return {"fraction": safe_fraction} + +func _prop_from_rest_info(rest_info: Dictionary) -> PropBlock: + # PhysicsDirectSpaceState2D.get_rest_info() returns collider_id rather than + # the `collider` object key used by ray/shape intersection queries. + var collider_id: int = int(rest_info.get("collider_id", 0)) + if collider_id <= 0: + return null + var collider: PropBlock = instance_from_id(collider_id) as PropBlock + if collider == null or not is_instance_valid(collider): + return null + return collider + func _settle_walk_markers(delta: float) -> void: if not _settling or _rig == null: return @@ -826,6 +1030,7 @@ func prepare_for_ragdoll() -> void: set_terrain_following_enabled(false) func reset_for_ragdoll_handoff() -> void: + reset_collision_proxy_velocity_baselines() _cancel_landing_impact(false) air_pose_base.clear() air_pose_weight = 0.0 diff --git a/scripts/characters/stickman/stickman_rig.gd b/scripts/characters/stickman/stickman_rig.gd index e576492..068be6b 100644 --- a/scripts/characters/stickman/stickman_rig.gd +++ b/scripts/characters/stickman/stickman_rig.gd @@ -177,6 +177,7 @@ signal state_changed(new_state: int) # --------------------------------------------------------------------------- signal arrived(target: Vector2) # walk_to reached its destination +signal blocking_prop_sweep_hit(rig: StickmanRig, prop: PropBlock, normal: Vector2, world_position: Vector2, requested_velocity: Vector2) signal action_started(action: Dictionary, index: int) signal action_finished(action: Dictionary, index: int) signal queue_finished # queue ran to completion (not on stop) @@ -230,6 +231,7 @@ var source_stk_path: String = "" ## boundaries while this facade preserves the long-standing public API. var _locomotion_component: StickmanLocomotion = null var _ragdoll_component: StickmanRagdoll = null +var _ragdoll_velocity_samples: Dictionary = {} var _action_runner_component: StickmanActionRunner = null var _prop_interactor_component: Node = null var _simulation_paused: bool = false @@ -257,6 +259,7 @@ func _ready() -> void: if _locomotion_component != null: _locomotion_component.configure(self) _locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target)) + _locomotion_component.blocking_prop_sweep_hit.connect(_on_blocking_prop_sweep_hit) if _ragdoll_component != null: _ragdoll_component.configure(self, _locomotion_component) _ragdoll_component.recovery_requested.connect(func() -> void: request_recovery()) @@ -326,6 +329,7 @@ func _ready() -> void: func _physics_process(delta: float) -> void: if _simulation_paused: return + _sample_ragdoll_velocity_baselines() if _ragdoll_component != null: _ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover) if _locomotion_component != null: @@ -347,6 +351,32 @@ func get_collision_bodies() -> Dictionary: return _locomotion_component.collision_bodies if _locomotion_component != null else {} +func get_ragdoll_bodies() -> Array[RigidBody2D]: + if _ragdoll_component == null: + var empty: Array[RigidBody2D] = [] + return empty + return _ragdoll_component.get_body_nodes() + + +func collision_proxy_velocity(body: PhysicsBody2D) -> Vector2: + return _locomotion_component.collision_proxy_velocity(body) if _locomotion_component != null else Vector2.ZERO + + +func ragdoll_body_velocity(body: RigidBody2D) -> Vector2: + if body == null or not is_instance_valid(body): + return Vector2.ZERO + var velocity: Vector2 = _ragdoll_velocity_samples.get(body.get_instance_id(), body.linear_velocity) as Vector2 + return velocity if velocity.is_finite() else Vector2.ZERO + + +func _sample_ragdoll_velocity_baselines() -> void: + if _ragdoll_component == null: + return + for body: RigidBody2D in _ragdoll_component.get_body_nodes(): + if body != null and is_instance_valid(body): + _ragdoll_velocity_samples[body.get_instance_id()] = body.linear_velocity + + func get_collision_body_rids() -> Array[RID]: var result: Array[RID] = [] for proxy: AnimatableBody2D in get_collision_bodies().values(): @@ -492,6 +522,7 @@ func _set_state(next_state: int) -> void: if int(state) == next_state: return state = next_state + reset_collision_proxy_velocity_baselines() state_changed.emit(int(state)) @@ -595,6 +626,7 @@ func snap_to_standing() -> void: _action_runner_component.clear_pause_state() if _ragdoll_component != null: _ragdoll_component.clear_pause_snapshot() + reset_collision_proxy_velocity_baselines() ## Restores the spawn-marker pose through the ragdoll component. Kept on the @@ -687,6 +719,23 @@ func _resolve_limb_bones() -> void: func set_collision_bodies_enabled(enabled: bool) -> void: if _locomotion_component != null: _locomotion_component.set_collision_bodies_enabled(enabled) + reset_collision_proxy_velocity_baselines() + + +func reset_collision_proxy_velocity_baselines() -> void: + if _locomotion_component != null: + _locomotion_component.reset_collision_proxy_velocity_baselines() + _ragdoll_velocity_samples.clear() + _sample_ragdoll_velocity_baselines() + + +func _on_blocking_prop_sweep_hit( + prop: PropBlock, + normal: Vector2, + world_position: Vector2, + requested_velocity: Vector2 +) -> void: + blocking_prop_sweep_hit.emit(self, prop, normal, world_position, requested_velocity) func apply_collision_reaction( diff --git a/scripts/editors/stage/sandbox_stage.gd b/scripts/editors/stage/sandbox_stage.gd index 06fe0ca..8800cb6 100644 --- a/scripts/editors/stage/sandbox_stage.gd +++ b/scripts/editors/stage/sandbox_stage.gd @@ -44,6 +44,7 @@ const STAGE_RULE_AUTHORING_SCRIPT := preload("res://scripts/editors/stage/stage_ const STAGE_ACTION_QUEUE_SCRIPT := preload("res://scripts/editors/stage/stage_action_queue_controller.gd") const STAGE_DIRECTOR_CONTROLLER_SCRIPT := preload("res://scripts/editors/stage/stage_director_controller.gd") const STAGE_RUN_SESSION_SCRIPT := preload("res://scripts/editors/stage/stage_run_session.gd") +const STAGE_IMPACT_RESOLVER_SCRIPT := preload("res://scripts/stage/stage_impact_resolver.gd") const STAGE_ASSET_BROWSER_SCRIPT := preload("res://scripts/editors/stage/stage_asset_browser.gd") const STAGE_MODAL_VIEWS_SCRIPT := preload("res://scripts/editors/stage/stage_modal_views.gd") const STAGE_THEME_CONTROLLER_SCRIPT := preload("res://scripts/editors/stage/stage_theme_controller.gd") @@ -241,6 +242,7 @@ var _grid: StageGrid var _spatial_index: StageSpatialIndex var _terrain_painter: StageTerrainPainter var _rule_engine: StageRuleEngine +var _impact_resolver: StageImpactResolver var _rule_authoring: StageRuleAuthoringController var _action_queue_controller: StageActionQueueController var _speech_session: StageSpeechSession @@ -248,6 +250,7 @@ var _asset_browser: StageAssetBrowser var _modal_views: StageModalViews var _chrome: StageChrome var _prop_settings_view: StagePropSettingsView +var _edit_wobble_preview_prop: PropBlock var _use_spot_overlay: StageUseSpotOverlay var _use_target_capture_bar: StageUseTargetCaptureBar var _placement_overlay = null # StagePlacementOverlay (preloaded) @@ -1181,6 +1184,7 @@ func _ready() -> void: get_tree().auto_accept_quit = false _camera.make_current() _run_session = STAGE_RUN_SESSION_SCRIPT.new() as StageRunSession + _impact_resolver = STAGE_IMPACT_RESOLVER_SCRIPT.new() as StageImpactResolver _document = STAGE_DOCUMENT_CONTROLLER_SCRIPT.new() as StageDocumentController _document.open_path_requested.connect(_on_document_open_path_requested) _document.save_path_selected.connect(_on_scene_save_path_selected) @@ -1311,6 +1315,8 @@ func _process(delta: float) -> void: func _physics_process(delta: float) -> void: + if _impact_resolver != null: + _impact_resolver.finish_sweep_tick(Engine.get_physics_frames()) _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 @@ -1676,6 +1682,7 @@ func set_mode(mode: StageMode) -> void: _sync_mode_buttons() return var previous_mode: StageMode = current_mode + _cancel_edit_wobble_preview() if mode == StageMode.PLAY and _run_state == RunState.STOPPED and not _cli_startup: if not _save_scene_to_path(AUTOSAVE_PATH, true): push_warning("SandboxStage: autosave before Play failed.") @@ -1781,6 +1788,9 @@ func _enter_play_mode() -> void: func _start_run() -> void: + _cancel_edit_wobble_preview() + if _impact_resolver != null: + _impact_resolver.reset() # STOP reasserts the authored transforms for a few physics frames. If the # child immediately presses PLAY, those delayed writes must not put an # authored prop scale back on the root after begin_play_instance_scale() @@ -1795,6 +1805,9 @@ func _start_run() -> void: func _pause_run() -> void: if not _run_session.pause_run(_world_children_selectable()): return + if _impact_resolver != null: + _impact_resolver.reset() + _seed_impact_contact_baseline() if _use_spot_overlay != null: _use_spot_overlay.set_paused(true) _refresh_event_edge_baseline() @@ -1813,6 +1826,9 @@ func _resume_run() -> void: _clear_director_pending() if not _run_session.resume_run(_world_children_selectable(), _rule_engine): return + if _impact_resolver != null: + _impact_resolver.reset() + _seed_impact_contact_baseline() if _use_spot_overlay != null: _use_spot_overlay.set_paused(false) _sync_run_controls() @@ -1820,6 +1836,9 @@ func _resume_run() -> void: func _reset_run() -> void: + _cancel_edit_wobble_preview() + if _impact_resolver != null: + _impact_resolver.reset() if PROP_TRACE_ENABLED: print("[PropTrace] stage reset_run mode=%s run_state=%s" % [StageMode.keys()[int(current_mode)], RunState.keys()[int(_run_state)]]) _run_session.begin_reset() @@ -1856,6 +1875,7 @@ func _sync_run_controls() -> void: func _finish_resume_baseline() -> void: if _run_state == RunState.RUNNING and current_mode == StageMode.PLAY: + _seed_impact_contact_baseline() _event_dispatch_ready = true @@ -1876,6 +1896,8 @@ func _refresh_event_edge_baseline() -> void: _rule_engine.refresh_event_edge_baseline(_world_children_selectable()) func _save_object_state(node: Node2D) -> void: + if node is StickmanRig: + (node as StickmanRig).reset_collision_proxy_velocity_baselines() _run_session.save_object_state(node) @@ -1957,12 +1979,19 @@ func _finalize_placed_node(node: Node2D) -> void: rig.action_finished.connect(_on_rig_action_finished.bind(rig)) rig.speech_finished.connect(_on_rig_speech_finished.bind(rig)) rig.state_changed.connect(_on_rig_state_changed.bind(rig)) + rig.blocking_prop_sweep_hit.connect(_on_blocking_prop_sweep_hit) 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 + if _sandbox_theme != null: + prop.set_wobble_style( + _sandbox_theme.number("effects.impact_wobble.duration", PropBlock.DEFAULT_WOBBLE_DURATION, 0.05, 2.0), + _sandbox_theme.number("effects.impact_wobble.maximum_angle_degrees", PropBlock.DEFAULT_WOBBLE_ANGLE_DEGREES, 0.0, 30.0), + _sandbox_theme.number("effects.impact_wobble.sideways_offset", PropBlock.DEFAULT_WOBBLE_OFFSET_LOCAL, 0.0, 24.0) + ) prop.collided.connect(_on_prop_collided.bind(prop)) if not prop.physics_profile_changed.is_connected(_on_prop_physics_profile_changed): prop.physics_profile_changed.connect(_on_prop_physics_profile_changed) @@ -2864,6 +2893,7 @@ 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.wobble_toggled.connect(_on_prop_wobble_toggled) _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) @@ -3002,6 +3032,29 @@ func _on_prop_role_toggle(enabled: bool, key: String) -> void: _refresh_prop_settings_card() +func _on_prop_wobble_toggled(enabled: bool) -> void: + var prop: PropBlock = _selection.get_primary() as PropBlock if _selection != null else null + if prop == null or not is_instance_valid(prop) or current_mode != StageMode.EDIT: + return + prop.set_wobble_on_impact(enabled) + if enabled: + _cancel_edit_wobble_preview() + prop.preview_wobble() + _edit_wobble_preview_prop = prop + else: + _edit_wobble_preview_prop = null + _save_object_state(prop) + _mark_scene_dirty() + _refresh_prop_settings_card() + + +func _cancel_edit_wobble_preview() -> void: + var prop: PropBlock = _edit_wobble_preview_prop + _edit_wobble_preview_prop = null + if prop != null and is_instance_valid(prop): + prop.cancel_wobble() + + 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): @@ -3846,6 +3899,7 @@ func _on_grid_size_changed(value: float) -> void: func _on_selection_changed(nodes: Array[Node2D]) -> void: + _cancel_edit_wobble_preview() 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) @@ -4654,7 +4708,10 @@ func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: Pr # authoritative snap_to_standing() cleanup has run. if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready: return - if other == null or not is_instance_valid(other) or _rule_engine == null: + if other == null or not is_instance_valid(other): + return + _route_impact_contact(prop, other, normal, position) + if _rule_engine == null: return if not _rule_engine.register_contact(prop, other): return @@ -4679,6 +4736,8 @@ func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: Pr func _on_prop_separated(other: Node, prop: PropBlock) -> void: + if _impact_resolver != null and other != null and is_instance_valid(other): + _impact_resolver.end_contact(prop, other) if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready: return if other == null or not is_instance_valid(other) or _rule_engine == null: @@ -4692,12 +4751,157 @@ func _on_prop_separated(other: Node, prop: PropBlock) -> void: _rule_engine.unregister_rig_body_contact(rig, prop, other) +func _route_impact_contact(prop: PropBlock, other: Node, normal: Vector2, position: Vector2) -> void: + if _impact_resolver == null or prop == null or other == null: + return + var receiver_velocity: Vector2 = prop.get_impact_velocity() + var other_velocity: Vector2 = _impact_other_velocity(other) + var sample: Dictionary = { + "receiver": prop, + "other": other, + "normal": normal, + "position": position, + "receiver_velocity": receiver_velocity, + "other_velocity": other_velocity, + "source": &"body_contact", + } + if other.has_meta("stickman_rig"): + sample["rig"] = other.get_meta("stickman_rig") + if _impact_resolver.begin_contact(sample, Engine.get_physics_frames()): + prop.play_impact_wobble() + if other is PropBlock: + (other as PropBlock).play_impact_wobble() + + +func _on_blocking_prop_sweep_hit( + rig: StickmanRig, + prop: PropBlock, + normal: Vector2, + position: Vector2, + requested_velocity: Vector2 +) -> void: + if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready: + return + if rig == null or prop == null or not is_instance_valid(rig) or not is_instance_valid(prop): + return + var sample: Dictionary = { + "receiver": prop, + "other": rig, + "rig": rig, + "normal": normal, + "position": position, + "receiver_velocity": prop.get_impact_velocity(), + "other_velocity": requested_velocity, + "source": &"blocking_sweep", + } + if _impact_resolver != null and _impact_resolver.note_sweep(sample, Engine.get_physics_frames()): + prop.play_impact_wobble() + + +func _impact_other_velocity(other: Node) -> Vector2: + if other is PropBlock: + return (other as PropBlock).get_impact_velocity() + var rig: StickmanRig = other.get_meta("stickman_rig") as StickmanRig if other.has_meta("stickman_rig") else null + if other.has_meta("stickman_ragdoll_body") and bool(other.get_meta("stickman_ragdoll_body")): + return rig.ragdoll_body_velocity(other as RigidBody2D) if rig != null and other is RigidBody2D else Vector2.ZERO + if rig != null and other is PhysicsBody2D: + return rig.collision_proxy_velocity(other as PhysicsBody2D) + if other is RigidBody2D and not (other as RigidBody2D).freeze: + var velocity: Vector2 = (other as RigidBody2D).linear_velocity + return velocity if velocity.is_finite() else Vector2.ZERO + return Vector2.ZERO + + +func _seed_impact_contact_baseline() -> void: + if _impact_resolver == null or _world == null: + return + var tick: int = Engine.get_physics_frames() + for node: Node in _world.get_children(): + if not node is PropBlock or not is_instance_valid(node): + continue + var prop: PropBlock = node as PropBlock + var direct_state: PhysicsDirectBodyState2D = PhysicsServer2D.body_get_direct_state(prop.get_rid()) + if direct_state == null: + continue + for contact_index: int in direct_state.get_contact_count(): + var collider_id: int = direct_state.get_contact_collider_id(contact_index) + var collider_object: Object = instance_from_id(collider_id) + if not collider_object is Node: + continue + var other: Node = collider_object as Node + var contact_normal: Vector2 = direct_state.get_contact_local_normal(contact_index) + if contact_normal.is_zero_approx(): + contact_normal = other.global_position - prop.global_position if other is Node2D else Vector2.RIGHT + var sample: Dictionary = { + "receiver": prop, + "other": other, + "normal": contact_normal, + "position": direct_state.get_contact_local_position(contact_index), + "receiver_velocity": Vector2.ZERO, + "other_velocity": Vector2.ZERO, + "source": &"baseline", + } + if other.has_meta("stickman_rig"): + sample["rig"] = other.get_meta("stickman_rig") + _impact_resolver.begin_contact(sample, tick) + + func _on_rig_state_changed(new_state: int, rig: StickmanRig) -> void: + if _impact_resolver != null: + _impact_resolver.end_rig_contacts(rig) if new_state == StickmanRig.RigState.RAGDOLL: + _connect_ragdoll_impact_contacts(rig) var rig_ids: Array[int] = [rig.get_instance_id()] _purge_collision_pairs_for_ids(rig_ids) +func _connect_ragdoll_impact_contacts(rig: StickmanRig) -> void: + if rig == null or not is_instance_valid(rig): + return + for body: RigidBody2D in rig.get_ragdoll_bodies(): + if body == null or not is_instance_valid(body): + continue + body.contact_monitor = true + body.max_contacts_reported = maxi(body.max_contacts_reported, 8) + var entered_callback: Callable = Callable(self, "_on_ragdoll_body_entered").bind(rig, body) + if not body.body_entered.is_connected(entered_callback): + body.body_entered.connect(entered_callback) + var exited_callback: Callable = Callable(self, "_on_ragdoll_body_exited").bind(rig, body) + if not body.body_exited.is_connected(exited_callback): + body.body_exited.connect(exited_callback) + + +func _on_ragdoll_body_entered(other: Node, rig: StickmanRig, body: RigidBody2D) -> void: + if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready: + return + if rig == null or body == null or other == null: + return + if not is_instance_valid(rig) or not is_instance_valid(body) or not is_instance_valid(other): + return + if not other is PropBlock: + return + var prop: PropBlock = other as PropBlock + var normal: Vector2 = body.global_position - prop.global_position + if normal.is_zero_approx(): + normal = Vector2.UP + var position: Vector2 = (body.global_position + prop.global_position) * 0.5 + var direct_state: PhysicsDirectBodyState2D = PhysicsServer2D.body_get_direct_state(body.get_rid()) + if direct_state != null: + for contact_index: int in direct_state.get_contact_count(): + if direct_state.get_contact_collider_id(contact_index) != prop.get_instance_id(): + continue + position = body.to_global(direct_state.get_contact_local_position(contact_index)) + break + _route_impact_contact(prop, body, normal.normalized(), position) + + +func _on_ragdoll_body_exited(other: Node, _rig: StickmanRig, body: RigidBody2D) -> void: + if _impact_resolver == null or other == null or not is_instance_valid(other): + return + if other is PropBlock: + _impact_resolver.end_contact(other as PropBlock, body) + + func _purge_collision_pairs_for_ids(ids: Array[int]) -> void: if _rule_engine != null: _rule_engine.purge_collision_pairs_for_ids(ids) diff --git a/scripts/editors/stage/stage_prop_settings_view.gd b/scripts/editors/stage/stage_prop_settings_view.gd index 12e3a2c..6e9d24c 100644 --- a/scripts/editors/stage/stage_prop_settings_view.gd +++ b/scripts/editors/stage/stage_prop_settings_view.gd @@ -10,6 +10,7 @@ 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 wobble_toggled(enabled: bool) signal spot_add_requested signal spot_move_requested(spot_id: String) signal spot_rename_requested(spot_id: String, name: String) @@ -26,6 +27,7 @@ var _fields: VBoxContainer var _notice: Label var _depth_group: StagePanelChoiceGroup var _motion_group: StagePanelChoiceGroup +var _wobble_row: StagePanelToggleRow var _controls: Dictionary = {} var _spot_rows: VBoxContainer var _spot_empty_label: Label @@ -193,6 +195,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_wobble_section() _add_use_spots_section() _reopen_button = Button.new() @@ -239,6 +242,19 @@ func _add_use_spots_section() -> void: section.add_child(_spot_rows) +func _add_wobble_section() -> void: + _add_heading(_fields, "When bumped") + _wobble_row = STAGE_PANEL_TOGGLE_ROW.new() as StagePanelToggleRow + _wobble_row.name = "WobbleWhenBumped" + _wobble_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _wobble_row.configure("Wobble when bumped") + _wobble_row.set_help_text("A hard bump makes this prop wobble once in PLAY.") + _wobble_row.apply_theme(_stage_theme) + _fields.add_child(_wobble_row) + _wobble_row.preferred_height_changed.connect(_on_row_height_changed) + _wobble_row.toggled.connect(_on_wobble_toggled) + + func _sync_spots(spots: Array[Dictionary], selected_spot_id: String) -> void: if _spot_rows == null: return @@ -390,6 +406,9 @@ func _sync_roles(prop: PropBlock, roles: Dictionary) -> void: else: _depth_group.select_no_signal(StringName(String(roles.get("depth", PROP_STAGE_ROLES.DEPTH_BEHIND)))) _motion_group.select_no_signal(StringName(String(roles.get("start_motion", PROP_STAGE_ROLES.START_STILL)))) + if _wobble_row != null: + _wobble_row.set_checked_no_signal(prop.wobble_on_impact) + _wobble_row.set_help_text("A hard bump makes this prop wobble once in PLAY.") for key: String in ["holds_other_props", "blocks_stickmen", "can_pick_up", "released_blocks_stickmen", "released_bumps_other_props"]: var control := _controls.get(key) as StagePanelToggleRow if control == null: @@ -453,6 +472,10 @@ func _on_role_toggled(enabled: bool, key: String) -> void: role_toggle_requested.emit(enabled, key) +func _on_wobble_toggled(enabled: bool) -> void: + wobble_toggled.emit(enabled) + + func _on_row_height_changed() -> void: if _card != null: _card.request_fit() diff --git a/scripts/editors/stage/stage_run_session.gd b/scripts/editors/stage/stage_run_session.gd index 9ea8f98..95d0d27 100644 --- a/scripts/editors/stage/stage_run_session.gd +++ b/scripts/editors/stage/stage_run_session.gd @@ -10,6 +10,8 @@ const STATE_RUNNING: int = 1 const STATE_PAUSED: int = 2 ## Set true temporarily when diagnosing authored/play prop scale transfer. const PROP_SCALE_TRACE_ENABLED: bool = false +## Temporary, low-volume diagnostics for props that should block stickmen. +const ACTOR_PROP_DEBUG_ENABLED: bool = true var run_state: int = STATE_STOPPED var elapsed_seconds: float = 0.0 @@ -44,14 +46,18 @@ func start_run( for node: Node2D in world_nodes: if node is PropBlock: var prop: PropBlock = node as PropBlock + prop.cancel_wobble() trace_prop_scale(prop, "before_play_scale_transfer", stage_mode_name, "RUNNING", camera_zoom) prop.begin_play_instance_scale() trace_prop_scale(prop, "after_play_scale_transfer", stage_mode_name, "RUNNING", camera_zoom) var start_role: PropBlock.RuntimeRole = PropBlock.RuntimeRole.START_STILL if String(prop.stage_roles.get("start_motion", PROP_STAGE_ROLES.START_STILL)) == PROP_STAGE_ROLES.START_STILL else PropBlock.RuntimeRole.START_PHYSICS prop.apply_runtime_role(start_role) + if ACTOR_PROP_DEBUG_ENABLED: + _debug_actor_blocking_prop(prop) trace_prop_scale(prop, "after_play_role_applied", stage_mode_name, "RUNNING", camera_zoom) elif node is STICKMAN_RIG_SCRIPT: var rig: StickmanRig = node as StickmanRig + rig.reset_collision_proxy_velocity_baselines() rig.reset_simulation_pause() rig.stop_queue() rig.clear_reactive_actions() @@ -60,6 +66,44 @@ func start_run( rig.start_queue() elif node is RigidBody2D: (node as RigidBody2D).freeze = false + if node is PropBlock: + (node as PropBlock).reset_impact_velocity_baseline() + + +func _debug_actor_blocking_prop(prop: PropBlock) -> void: + if prop == null or not is_instance_valid(prop): + return + var role_index: int = clampi(int(prop.runtime_role), 0, PropBlock.RuntimeRole.keys().size() - 1) + var collision_nodes := PackedStringArray() + for child: Node in prop.get_children(): + if child is CollisionPolygon2D: + var polygon: CollisionPolygon2D = child as CollisionPolygon2D + collision_nodes.append( + "CollisionPolygon2D(disabled=%s,points=%d,global=%s)" + % [str(polygon.disabled), polygon.polygon.size(), str(polygon.global_transform)] + ) + elif child is CollisionShape2D: + var shape: CollisionShape2D = child as CollisionShape2D + collision_nodes.append( + "CollisionShape2D(disabled=%s,type=%s,global=%s)" + % [str(shape.disabled), shape.shape.get_class() if shape.shape != null else "null", str(shape.global_transform)] + ) + print( + "[PropCollisionDebug][PlayProp] id=%s asset=%s role=%s blocking=%s roles=%s layer=%d mask=%d freeze=%s pos=%s scale=%s collision=%s" + % [ + prop.stage_object_id, + prop.spawn_id, + PropBlock.RuntimeRole.keys()[role_index], + str(prop.is_actor_blocking()), + str(prop.stage_roles), + prop.collision_layer, + prop.collision_mask, + str(prop.freeze), + str(prop.global_position), + str(prop.global_scale), + "; ".join(collision_nodes), + ] + ) func pause_run(world_nodes: Array[Node2D]) -> bool: @@ -69,7 +113,11 @@ func pause_run(world_nodes: Array[Node2D]) -> bool: run_state = STATE_PAUSED resume_baseline_frames = 0 for node: Node2D in world_nodes: - if node is STICKMAN_RIG_SCRIPT: + if node is PropBlock: + var prop: PropBlock = node as PropBlock + prop.set_wobble_paused(true) + capture_prop_pause_state(prop) + elif node is STICKMAN_RIG_SCRIPT: (node as StickmanRig).pause_simulation() elif node is RigidBody2D: capture_prop_pause_state(node as RigidBody2D) @@ -91,8 +139,13 @@ func resume_run(world_nodes: Array[Node2D], rule_engine: StageRuleEngine) -> boo body.sleeping = bool(snapshot.get("sleeping", false)) prop_pause_states.clear() for node: Node2D in world_nodes: - if node is STICKMAN_RIG_SCRIPT: - (node as StickmanRig).resume_simulation() + if node is PropBlock: + (node as PropBlock).set_wobble_paused(false) + (node as PropBlock).reset_impact_velocity_baseline() + elif node is STICKMAN_RIG_SCRIPT: + var rig: StickmanRig = node as StickmanRig + rig.reset_collision_proxy_velocity_baselines() + rig.resume_simulation() run_state = STATE_RUNNING event_dispatch_ready = false resume_baseline_frames = 2 @@ -127,6 +180,7 @@ func reset_world( var body: RigidBody2D = node as RigidBody2D if node is PropBlock: var prop: PropBlock = node as PropBlock + prop.cancel_wobble() var authored: Dictionary = authored_state.get(prop.get_instance_id(), {}) trace_prop_scale(prop, "before_stop_scale_restore", stage_mode_name, "STOPPED", camera_zoom) prop.set_stage_roles(authored.get("roles", prop.stage_roles)) @@ -185,7 +239,9 @@ func save_object_state(node: Node2D) -> void: if not node is STICKMAN_RIG_SCRIPT: entry["scale"] = node.scale if node is PropBlock: - entry["roles"] = (node as PropBlock).stage_roles.duplicate(true) + var prop: PropBlock = node as PropBlock + entry["roles"] = prop.stage_roles.duplicate(true) + entry["wobble_on_impact"] = prop.wobble_on_impact authored_state[node.get_instance_id()] = entry @@ -210,13 +266,18 @@ func restore_authored_state(stage_mode_name: String, run_state_name: String, cam trace_prop_scale(node as PropBlock, "after_authored_scale_write", stage_mode_name, run_state_name, camera_zoom) if node is STICKMAN_RIG_SCRIPT and entry.has("facing_profile"): (node as STICKMAN_RIG_SCRIPT).set_facing_profile(int(entry["facing_profile"])) + if node is STICKMAN_RIG_SCRIPT: + (node as StickmanRig).reset_collision_proxy_velocity_baselines() if node is RigidBody2D: var body: RigidBody2D = node as RigidBody2D body.linear_velocity = Vector2.ZERO body.angular_velocity = 0.0 if node is PropBlock: var prop: PropBlock = node as PropBlock + prop.cancel_wobble() prop.set_stage_roles(entry.get("roles", prop.stage_roles)) + prop.set_wobble_on_impact(bool(entry.get("wobble_on_impact", prop.wobble_on_impact))) + prop.reset_impact_velocity_baseline() prop.apply_runtime_role(PropBlock.RuntimeRole.EDIT) diff --git a/scripts/editors/stage/stage_scene_adapter.gd b/scripts/editors/stage/stage_scene_adapter.gd index 5ae2c6a..f22afae 100644 --- a/scripts/editors/stage/stage_scene_adapter.gd +++ b/scripts/editors/stage/stage_scene_adapter.gd @@ -60,6 +60,7 @@ func collect( 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 + entry["wobble_on_impact"] = prop.wobble_on_impact 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 @@ -175,6 +176,7 @@ 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", "")) + prop.set_wobble_on_impact(bool(entry.get("wobble_on_impact", false))) 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()) @@ -212,6 +214,7 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary (node as StickmanRig).queue_action(action) else: warnings.append("%s: invalid action queue. Open this character's queue and review its actions." % file_id) + (node as StickmanRig).reset_collision_proxy_velocity_baselines() loaded_nodes.append(node) file_to_runtime[file_id] = node.get_instance_id() var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable) diff --git a/scripts/props/prop_block.gd b/scripts/props/prop_block.gd index 8c92adc..42d4acf 100644 --- a/scripts/props/prop_block.gd +++ b/scripts/props/prop_block.gd @@ -38,6 +38,7 @@ var use_spots: Array[Dictionary] = [] var next_use_spot_number: int = 1 var stage_roles: Dictionary = {} var runtime_role: RuntimeRole = RuntimeRole.EDIT +var wobble_on_impact: bool = false # --------------------------------------------------------------------------- # Child node names @@ -55,6 +56,9 @@ const DEFAULT_FILL_COLOR := Color(0.55, 0.35, 0.15, 1.0) const DEFAULT_OUTLINE_COLOR := Color(0.15, 0.08, 0.02, 1.0) const DEFAULT_OUTLINE_WIDTH: float = 2.0 const MAX_CONTACTS_REPORTED := 32 +const DEFAULT_WOBBLE_DURATION: float = 0.48 +const DEFAULT_WOBBLE_ANGLE_DEGREES: float = 7.0 +const DEFAULT_WOBBLE_OFFSET_LOCAL: float = 4.0 # --------------------------------------------------------------------------- # Exported properties @@ -114,6 +118,16 @@ var _visual_local_bounds := Rect2() var _custom_collision_hull: PackedVector2Array = PackedVector2Array() var _play_instance_scale: Vector2 = Vector2.ONE var _play_scale_active: bool = false +var _wobble_duration: float = DEFAULT_WOBBLE_DURATION +var _wobble_angle_degrees: float = DEFAULT_WOBBLE_ANGLE_DEGREES +var _wobble_offset_local: float = DEFAULT_WOBBLE_OFFSET_LOCAL +var _wobble_elapsed: float = 0.0 +var _wobble_active: bool = false +var _wobble_paused: bool = false +var _wobble_center: Vector2 = Vector2.ZERO +var _wobble_baselines: Dictionary = {} +var _last_physics_velocity: Vector2 = Vector2.ZERO +var _has_physics_velocity_sample: bool = false # --------------------------------------------------------------------------- # Signals @@ -144,6 +158,144 @@ func _ready() -> void: body_entered.connect(_on_body_entered) if not body_exited.is_connected(_on_body_exited): body_exited.connect(_on_body_exited) + reset_impact_velocity_baseline() + + +func _process(delta: float) -> void: + if Engine.is_editor_hint() or not _wobble_active or _wobble_paused: + return + _wobble_elapsed = minf(_wobble_elapsed + maxf(delta, 0.0), _wobble_duration) + var progress: float = clampf(_wobble_elapsed / _wobble_duration, 0.0, 1.0) + if progress >= 1.0: + cancel_wobble() + return + var envelope: float = (1.0 - progress) * (1.0 - progress) + var phase: float = TAU * 2.5 * progress + var angle: float = deg_to_rad(_wobble_angle_degrees) * sin(phase) * envelope + var offset: float = _wobble_offset_local * cos(phase) * envelope + _apply_wobble_pose(angle, offset) + + +func _physics_process(_delta: float) -> void: + if not freeze and runtime_role in [RuntimeRole.START_PHYSICS, RuntimeRole.RELEASED]: + _last_physics_velocity = linear_velocity + _has_physics_velocity_sample = _last_physics_velocity.is_finite() + + +func _exit_tree() -> void: + cancel_wobble() + + +func set_wobble_on_impact(enabled: bool) -> void: + wobble_on_impact = enabled + if not enabled: + cancel_wobble() + + +func set_wobble_style(duration: float, angle_degrees: float, offset_local: float) -> void: + if not is_finite(duration) or not is_finite(angle_degrees) or not is_finite(offset_local): + return + _wobble_duration = maxf(0.05, duration) + _wobble_angle_degrees = maxf(0.0, angle_degrees) + _wobble_offset_local = maxf(0.0, offset_local) + + +func preview_wobble() -> void: + _start_wobble() + + +func play_impact_wobble() -> void: + if wobble_on_impact: + _start_wobble() + + +func cancel_wobble() -> void: + for raw_node: Variant in _wobble_baselines.keys(): + var node: Node2D = raw_node as Node2D + if node == null or not is_instance_valid(node): + continue + var baseline: Dictionary = _wobble_baselines[raw_node] + node.position = baseline["position"] as Vector2 + node.rotation = float(baseline["rotation"]) + node.scale = baseline["scale"] as Vector2 + _wobble_baselines.clear() + _wobble_active = false + _wobble_paused = false + _wobble_elapsed = 0.0 + + +func set_wobble_paused(paused: bool) -> void: + _wobble_paused = paused and _wobble_active + + +func get_impact_velocity() -> Vector2: + if runtime_role in [RuntimeRole.EDIT, RuntimeRole.START_STILL, RuntimeRole.HELD] or freeze: + return Vector2.ZERO + if _has_physics_velocity_sample and _last_physics_velocity.is_finite(): + return _last_physics_velocity + return linear_velocity if linear_velocity.is_finite() else Vector2.ZERO + + +func reset_impact_velocity_baseline() -> void: + _last_physics_velocity = linear_velocity if linear_velocity.is_finite() else Vector2.ZERO + _has_physics_velocity_sample = true + + +func _start_wobble() -> void: + cancel_wobble() + _capture_wobble_baselines() + if _wobble_baselines.is_empty(): + return + _wobble_active = true + _wobble_elapsed = 0.0 + _apply_wobble_pose(0.0, _wobble_offset_local) + + +func _capture_wobble_baselines() -> void: + var bounds: Dictionary = {"min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF} + for child_name: String in [POLYGON_NODE_NAME, OUTLINE_NODE_NAME, "Visuals"]: + var node: Node2D = get_node_or_null(NodePath(child_name)) as Node2D + if node == null or not node.visible: + continue + _wobble_baselines[node] = {"position": node.position, "rotation": node.rotation, "scale": node.scale} + _collect_artwork_bounds(node, bounds) + if float(bounds["min_x"]) <= float(bounds["max_x"]): + _wobble_center = Vector2( + (float(bounds["min_x"]) + float(bounds["max_x"])) * 0.5, + (float(bounds["min_y"]) + float(bounds["max_y"])) * 0.5 + ) + else: + _wobble_center = get_use_spot_local_bounds().get_center() + + +func _collect_artwork_bounds(node: Node, bounds: Dictionary) -> void: + if node is Polygon2D: + for point: Vector2 in (node as Polygon2D).polygon: + _accumulate_artwork_point(to_local((node as Polygon2D).to_global(point)), bounds) + elif node is Line2D: + for point: Vector2 in (node as Line2D).points: + _accumulate_artwork_point(to_local((node as Line2D).to_global(point)), bounds) + for child: Node in node.get_children(): + _collect_artwork_bounds(child, bounds) + + +func _accumulate_artwork_point(point: Vector2, bounds: Dictionary) -> void: + bounds["min_x"] = minf(float(bounds["min_x"]), point.x) + bounds["min_y"] = minf(float(bounds["min_y"]), point.y) + bounds["max_x"] = maxf(float(bounds["max_x"]), point.x) + bounds["max_y"] = maxf(float(bounds["max_y"]), point.y) + + +func _apply_wobble_pose(angle: float, offset: float) -> void: + for raw_node: Variant in _wobble_baselines: + var node: Node2D = raw_node as Node2D + if node == null or not is_instance_valid(node): + continue + var baseline: Dictionary = _wobble_baselines[raw_node] + var base_position: Vector2 = baseline["position"] as Vector2 + node.position = _wobble_center + (base_position - _wobble_center).rotated(angle) + Vector2(offset, 0.0) + node.rotation = float(baseline["rotation"]) + angle + node.scale = baseline["scale"] as Vector2 func set_stage_roles(roles: Dictionary) -> void: @@ -507,6 +659,7 @@ func get_interaction_scale() -> Vector2: ## the authored uniform scale into child visuals and collision geometry for ## PLAY, then restore the root scale on return to EDIT for scene persistence. func begin_play_instance_scale() -> void: + cancel_wobble() if _play_scale_active: return _play_instance_scale = scale @@ -518,6 +671,7 @@ func begin_play_instance_scale() -> void: _sync_transform_to_physics_server() func end_play_instance_scale() -> void: + cancel_wobble() if not _play_scale_active: return _play_scale_active = false diff --git a/scripts/stage/sandbox_theme.gd b/scripts/stage/sandbox_theme.gd index 04d9470..6f7bafa 100644 --- a/scripts/stage/sandbox_theme.gd +++ b/scripts/stage/sandbox_theme.gd @@ -10,6 +10,7 @@ const DEFAULT_CONFIG: Dictionary = { "global": {"font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8}, "fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}, "comic": {"label": "Comic", "path": "res://resources/fonts/comic.ttf", "speech_base_size": 28}, "pixel": {"label": "Pixel", "path": "res://resources/fonts/pixel.ttf", "speech_base_size": 40}, "scary": {"label": "Scary", "path": "res://resources/fonts/scary.ttf", "speech_base_size": 28}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "timestamp_badge": 22, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}}, "mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"}, + "effects": {"impact_wobble": {"duration": 0.48, "maximum_angle_degrees": 7.0, "sideways_offset": 4.0}}, "grid": {"snap_size": 15.0}, "chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}}, "popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}}, diff --git a/scripts/stage/stage_impact_resolver.gd b/scripts/stage/stage_impact_resolver.gd new file mode 100644 index 0000000..e261205 --- /dev/null +++ b/scripts/stage/stage_impact_resolver.gd @@ -0,0 +1,190 @@ +class_name StageImpactResolver +extends RefCounted +## Scores physical approach hits and keeps contact pairs from retriggering. + +const IMPACT_SPEED_THRESHOLD: float = 140.0 +const RECEIVER_COOLDOWN_SECONDS: float = 0.25 +const NORMAL_EPSILON_SQUARED: float = 0.000001 + +var _pairs: Dictionary = {} +var _receiver_cooldowns: Dictionary = {} + + +static func approach_speed(receiver_velocity: Vector2, other_velocity: Vector2, receiver_to_other: Vector2) -> float: + if not receiver_velocity.is_finite() or not other_velocity.is_finite() or not receiver_to_other.is_finite(): + return 0.0 + if receiver_to_other.length_squared() <= NORMAL_EPSILON_SQUARED: + return 0.0 + var normal: Vector2 = receiver_to_other.normalized() + return maxf(0.0, -(other_velocity - receiver_velocity).dot(normal)) + + +func begin_contact(sample: Dictionary, physics_tick: int) -> bool: + var normalized: Dictionary = _normalize_sample(sample) + if normalized.is_empty(): + return false + var receiver: PropBlock = normalized["receiver"] as PropBlock + var other: Node = normalized["other"] as Node + var pair_key: String = _pair_key(receiver, other, normalized) + if pair_key.is_empty(): + return false + var pair: Dictionary = _pair_state(pair_key) + var contacts: Dictionary = pair.get("contacts", {}) as Dictionary + var was_contact_active: bool = other != null and is_instance_valid(other) and contacts.has(other.get_instance_id()) + if other != null and is_instance_valid(other): + contacts[other.get_instance_id()] = true + pair["contacts"] = contacts + pair["sweep_only"] = false + _pairs[pair_key] = pair + if was_contact_active: + return false + return _qualifies(normalized, physics_tick) + + +func end_contact(receiver: PropBlock, other: Node) -> void: + if receiver == null or other == null or not is_instance_valid(receiver) or not is_instance_valid(other): + return + var pair_key: String = _pair_key(receiver, other, {}) + if pair_key.is_empty() or not _pairs.has(pair_key): + return + var pair: Dictionary = _pairs[pair_key] + var contacts: Dictionary = pair.get("contacts", {}) as Dictionary + contacts.erase(other.get_instance_id()) + pair["contacts"] = contacts + if contacts.is_empty() and not bool(pair.get("sweep_only", false)): + _pairs.erase(pair_key) + else: + _pairs[pair_key] = pair + + +func end_rig_contacts(rig: StickmanRig) -> void: + if rig == null or not is_instance_valid(rig): + return + var rig_suffix: String = ":%s" % str(rig.get_instance_id()) + for pair_key_value: Variant in _pairs.keys(): + var pair_key: String = str(pair_key_value) + if pair_key.begins_with("rig:") and pair_key.ends_with(rig_suffix): + _pairs.erase(pair_key_value) + + +func note_sweep(sample: Dictionary, physics_tick: int) -> bool: + var normalized: Dictionary = _normalize_sample(sample) + if normalized.is_empty(): + return false + var receiver: PropBlock = normalized["receiver"] as PropBlock + var other: Node = normalized["other"] as Node + var pair_key: String = _pair_key(receiver, other, normalized) + if pair_key.is_empty(): + return false + var pair: Dictionary = _pair_state(pair_key) + var contacts: Dictionary = pair.get("contacts", {}) as Dictionary + var already_active: bool = not contacts.is_empty() or ( + bool(pair.get("sweep_only", false)) and int(pair.get("last_sweep_tick", -1)) >= physics_tick - 1 + ) + pair["sweep_only"] = contacts.is_empty() + pair["last_sweep_tick"] = physics_tick + _pairs[pair_key] = pair + if already_active: + return false + return _qualifies(normalized, physics_tick) + + +func finish_sweep_tick(physics_tick: int) -> void: + for pair_key: String in _pairs.keys(): + var pair: Dictionary = _pairs[pair_key] + if not bool(pair.get("sweep_only", false)): + continue + # Give the next tick a chance to report its sweep before declaring that + # the walker has stopped pressing into this prop. + if int(pair.get("last_sweep_tick", -1)) < physics_tick - 1: + _pairs.erase(pair_key) + + +func reset() -> void: + _pairs.clear() + _receiver_cooldowns.clear() + + +func _normalize_sample(sample: Dictionary) -> Dictionary: + var receiver_value: Variant = sample.get("receiver") + var other_value: Variant = sample.get("other") + var normal_value: Variant = sample.get("normal") + var receiver_velocity_value: Variant = sample.get("receiver_velocity") + var other_velocity_value: Variant = sample.get("other_velocity") + var position_value: Variant = sample.get("position", Vector2.ZERO) + if not receiver_value is PropBlock or not other_value is Node: + return {} + if not normal_value is Vector2 or not receiver_velocity_value is Vector2 or not other_velocity_value is Vector2 or not position_value is Vector2: + return {} + var normal: Vector2 = normal_value as Vector2 + var receiver_velocity: Vector2 = receiver_velocity_value as Vector2 + var other_velocity: Vector2 = other_velocity_value as Vector2 + var position: Vector2 = position_value as Vector2 + if not normal.is_finite() or normal.length_squared() <= NORMAL_EPSILON_SQUARED: + return {} + if not receiver_velocity.is_finite() or not other_velocity.is_finite() or not position.is_finite(): + return {} + var normalized: Dictionary = sample.duplicate() + normalized["receiver"] = receiver_value + normalized["other"] = other_value + normalized["normal"] = normal.normalized() + normalized["receiver_velocity"] = receiver_velocity + normalized["other_velocity"] = other_velocity + normalized["position"] = position + return normalized + + +func _qualifies(sample: Dictionary, physics_tick: int) -> bool: + var speed: float = approach_speed( + sample["receiver_velocity"] as Vector2, + sample["other_velocity"] as Vector2, + sample["normal"] as Vector2 + ) + if not is_finite(speed) or speed < IMPACT_SPEED_THRESHOLD: + return false + var receiver: PropBlock = sample["receiver"] as PropBlock + var other: Node = sample["other"] as Node + var receiver_ids: Array[String] = [_prop_identity(receiver)] + if other is PropBlock: + receiver_ids.append(_prop_identity(other as PropBlock)) + var cooldown_ticks: int = maxi(1, ceili(RECEIVER_COOLDOWN_SECONDS * float(Engine.physics_ticks_per_second))) + for identity: String in receiver_ids: + var last_hit_tick: int = int(_receiver_cooldowns.get(identity, -cooldown_ticks)) + if physics_tick - last_hit_tick < cooldown_ticks: + return false + for identity: String in receiver_ids: + _receiver_cooldowns[identity] = physics_tick + return true + + +func _pair_state(pair_key: String) -> Dictionary: + if _pairs.has(pair_key): + return _pairs[pair_key] as Dictionary + return {"contacts": {}, "sweep_only": false, "last_sweep_tick": -1} + + +func _pair_key(receiver: PropBlock, other: Node, sample: Dictionary) -> String: + if receiver == null or other == null or not is_instance_valid(receiver) or not is_instance_valid(other): + return "" + if other is PropBlock: + var first_id: String = _prop_identity(receiver) + var second_id: String = _prop_identity(other as PropBlock) + if first_id > second_id: + var swap_id: String = first_id + first_id = second_id + second_id = swap_id + return "props:%s:%s" % [first_id, second_id] + var rig: StickmanRig = sample.get("rig") as StickmanRig + if rig == null and other.has_meta("stickman_rig"): + rig = other.get_meta("stickman_rig") as StickmanRig + if rig != null and is_instance_valid(rig): + return "rig:%s:%s" % [_prop_identity(receiver), str(rig.get_instance_id())] + return "body:%s:%s" % [_prop_identity(receiver), str(other.get_instance_id())] + + +func _prop_identity(prop: PropBlock) -> String: + if prop == null or not is_instance_valid(prop): + return "" + if not prop.stage_object_id.is_empty(): + return prop.stage_object_id + return str(prop.get_instance_id()) diff --git a/scripts/stage/stage_impact_resolver.gd.uid b/scripts/stage/stage_impact_resolver.gd.uid new file mode 100644 index 0000000..dd273a9 --- /dev/null +++ b/scripts/stage/stage_impact_resolver.gd.uid @@ -0,0 +1 @@ +uid://cceutk52dceey diff --git a/scripts/stage/stage_scene_io.gd b/scripts/stage/stage_scene_io.gd index 2c5b594..a30ae02 100644 --- a/scripts/stage/stage_scene_io.gd +++ b/scripts/stage/stage_scene_io.gd @@ -3,7 +3,7 @@ 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.3" +const FILE_VERSION: String = "1.4" 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") @@ -167,9 +167,11 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary: 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) + if not prop_entry.has("wobble_on_impact"): + prop_entry["wobble_on_impact"] = false _assign_legacy_depths(current.get("objects", [])) return current - if version not in ["1.0", "1.1", "1.2"]: + if version not in ["1.0", "1.1", "1.2", "1.3"]: return {} var migrated: Dictionary = data.duplicate(true) for raw: Variant in migrated.get("objects", []): @@ -183,6 +185,8 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary: 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) + if not object.has("wobble_on_impact"): + object["wobble_on_impact"] = false migrated["version"] = FILE_VERSION _assign_legacy_depths(migrated.get("objects", [])) return migrated @@ -222,6 +226,8 @@ static func _validate_root(data: Dictionary) -> Dictionary: if file_id.is_empty() or seen.has(file_id): return {"ok": false, "error": "Object file IDs must be unique and non-empty."} seen[file_id] = true if String(entry.get("type", "")) == "prop": + if entry.has("wobble_on_impact") and not entry["wobble_on_impact"] is bool: + return {"ok": false, "error": "Prop wobble_on_impact must be true or false."} var raw_stage_id: Variant = entry.get("stage_object_id", "") if not raw_stage_id is String: return {"ok": false, "error": "Prop stage IDs must be unique and non-empty strings."} diff --git a/scripts/stage/stage_selection.gd b/scripts/stage/stage_selection.gd index b98a90b..ee77c6e 100644 --- a/scripts/stage/stage_selection.gd +++ b/scripts/stage/stage_selection.gd @@ -172,9 +172,9 @@ static func get_world_aabb(node: Node2D) -> Rect2: return Rect2(Vector2(visual_acc["min_x"], visual_acc["min_y"]), Vector2(visual_acc["max_x"] - visual_acc["min_x"], visual_acc["max_y"] - visual_acc["min_y"])) var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D if poly != null and not poly.polygon.is_empty(): - var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO) + var rect := Rect2(poly.to_global(poly.polygon[0]), Vector2.ZERO) for p: Vector2 in poly.polygon: - rect = rect.expand(node.to_global(p)) + rect = rect.expand(poly.to_global(p)) return rect var body := node.get_node_or_null(NodePath("Body")) as Node2D if body != null: diff --git a/tests/test_actor_blocking_overlap.gd b/tests/test_actor_blocking_overlap.gd new file mode 100644 index 0000000..1ee72b1 --- /dev/null +++ b/tests/test_actor_blocking_overlap.gd @@ -0,0 +1,70 @@ +extends SceneTree + +const PROP_BLOCK_SCRIPT := preload("res://scripts/props/prop_block.gd") +const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd") +const RIG_SCENE := preload("res://scenes/characters/stickman/stickman_rig.tscn") + +var _failed: bool = false + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var world := Node2D.new() + root.add_child(world) + var prop: PropBlock = PROP_BLOCK_SCRIPT.new() + prop.position = Vector2(20.0, 0.0) + prop.polygon_points = PackedVector2Array([ + Vector2(-50.0, -150.0), + Vector2(50.0, -150.0), + Vector2(50.0, 150.0), + Vector2(-50.0, 150.0), + ]) + world.add_child(prop) + var rig: StickmanRig = RIG_SCENE.instantiate() as StickmanRig + rig.position = Vector2.ZERO + world.add_child(rig) + await process_frame + + var roles: Dictionary = PROP_STAGE_ROLES.new_placement(false) + roles["blocks_stickmen"] = true + prop.set_stage_roles(roles) + prop.apply_runtime_role(PROP_BLOCK_SCRIPT.RuntimeRole.START_STILL) + await physics_frame + await physics_frame + rig.get_locomotion().sync_collision_bodies() + + var observed_hits: Array[PropBlock] = [] + rig.get_locomotion().blocking_prop_sweep_hit.connect( + func(hit_prop: PropBlock, _normal: Vector2, _position: Vector2, _velocity: Vector2) -> void: + observed_hits.append(hit_prop) + ) + var into_prop: Dictionary = rig.get_locomotion()._sweep_actor_blocking_props(Vector2(20.0, 0.0)) + _expect(is_equal_approx(float(into_prop.get("fraction", 1.0)), 0.0), "overlap blocks movement deeper into a stationary prop") + _expect(not observed_hits.is_empty() and observed_hits[0] == prop, "overlap reports the struck prop") + var out_of_prop: Dictionary = rig.get_locomotion()._sweep_actor_blocking_props(Vector2(-20.0, 0.0)) + _expect(is_equal_approx(float(out_of_prop.get("fraction", 0.0)), 1.0), "overlap still allows movement away from the prop") + + rig.global_position = Vector2(-250.0, 0.0) + rig.get_locomotion().sync_collision_bodies() + await physics_frame + await physics_frame + var from_outside: Dictionary = rig.get_locomotion()._sweep_actor_blocking_props(Vector2(500.0, 0.0)) + _expect(float(from_outside.get("fraction", 1.0)) < 0.999, "sweep still blocks when approaching a stationary prop from outside") + + world.queue_free() + await process_frame + if _failed: + quit(1) + else: + print("PASS: actor blocking handles stationary props and initial overlaps") + quit(0) + + +func _expect(condition: bool, description: String) -> void: + if condition: + return + _failed = true + push_error("FAIL: %s" % description) diff --git a/tests/test_actor_blocking_overlap.gd.uid b/tests/test_actor_blocking_overlap.gd.uid new file mode 100644 index 0000000..d7a5c2e --- /dev/null +++ b/tests/test_actor_blocking_overlap.gd.uid @@ -0,0 +1 @@ +uid://du57472qg5yw2