Add new scene files and implement stage impact resolver

- Created three new scene files: `wobble.scene`, `wobble2.scene`, and `wobble3.scene` with various terrain and prop configurations.
- Introduced `stage_impact_resolver.gd` script to manage impact detection and response between props and stickmen.
- Added unit tests for actor blocking overlap to ensure proper interaction with stationary props.
This commit is contained in:
2026-10-14 13:08:34 -04:00
parent 628c07e70e
commit 5c3b056a13
21 changed files with 1891 additions and 22 deletions
+322
View File
@@ -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": ""
}
}
+303
View File
@@ -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": ""
}
}
+269
View File
@@ -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": ""
}
}
@@ -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) | | 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) | | 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) | | 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) | | 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 | 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) | | 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 | 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-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-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_ _End of Log_
@@ -1,6 +1,6 @@
# Phase 17 — Props for Storytelling # 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. > **Audience:** UI/UX, architect, and implementation team.
> **Purpose:** Product and design brief; technical contracts belong in later specifications. > **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 | | **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 | | **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 | | **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. 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 ### 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 ### 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. - `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. - 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. - 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. - 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). - 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 ## 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? - **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?
+1 -1
View File
@@ -1,6 +1,6 @@
# Phase 17c — Impact wobble: architecture and implementation specification # 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). > **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. > **Scope:** A per-placement Wobble setting, visual sample in EDIT, and a short visual response to meaningful physical hits in PLAY.
+7
View File
@@ -70,6 +70,13 @@
"play": "#33DD77", "play": "#33DD77",
"guide": "#22C6FF" "guide": "#22C6FF"
}, },
"effects": {
"impact_wobble": {
"duration": 0.48,
"maximum_angle_degrees": 7.0,
"sideways_offset": 4.0
}
},
"grid": { "snap_size": 15.0 }, "grid": { "snap_size": 15.0 },
"chrome": { "chrome": {
"style_defaults": { "bg_color": "#171B24F2", "corner_radius": 10 }, "style_defaults": { "bg_color": "#171B24F2", "corner_radius": 10 },
@@ -71,6 +71,9 @@ const LEG_COLLISION_RADIUS: float = 14.0
const HEAD_COLLISION_FALLBACK_CENTER: Vector2 = Vector2(0.0, -72.0) const HEAD_COLLISION_FALLBACK_CENTER: Vector2 = Vector2(0.0, -72.0)
const COLLISION_LAYER: int = 1 const COLLISION_LAYER: int = 1
const WALK_BLOCKED_TIMEOUT: float = 0.45 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 = { const COLLISION_PARTS: Dictionary = {
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS}, "head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_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_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}, "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_BASE_ANGLE: float = 0.35
const REACTION_REFERENCE_SPEED: float = 400.0 const REACTION_REFERENCE_SPEED: float = 400.0
const REACTION_MAX_ANGLE: float = 0.6 const REACTION_MAX_ANGLE: float = 0.6
@@ -101,6 +112,7 @@ const REACTION_TARGETS: Dictionary = {
} }
signal arrived(target: Vector2) signal arrived(target: Vector2)
signal blocking_prop_sweep_hit(prop: PropBlock, normal: Vector2, world_position: Vector2, requested_velocity: Vector2)
var _rig: StickmanRig = null var _rig: StickmanRig = null
var foot_contacts: Dictionary = {"Left_Leg": {"valid": false, "locked": false}, "Right_Leg": {"valid": false, "locked": false}} 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 _walk_failed: bool = false
var collision_bodies: Dictionary = {} var collision_bodies: Dictionary = {}
var collision_lengths: 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 _collision_visuals: Dictionary = {}
var reaction_bones: Dictionary = {} var reaction_bones: Dictionary = {}
var impact_reactions: Dictionary = {} var impact_reactions: Dictionary = {}
@@ -176,6 +193,9 @@ func configure(rig: StickmanRig) -> void:
func setup_collision_bodies() -> void: func setup_collision_bodies() -> void:
collision_bodies.clear() collision_bodies.clear()
collision_lengths.clear() collision_lengths.clear()
collision_proxy_velocities.clear()
_collision_proxy_positions.clear()
_last_sweep_hit_tick.clear()
_collision_visuals.clear() _collision_visuals.clear()
reaction_bones.clear() reaction_bones.clear()
if _rig == null or _rig._body_container == null or _rig._skeleton == null: 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 proxy.global_transform = visual.global_transform
collision_bodies[key] = proxy collision_bodies[key] = proxy
_collision_visuals[key] = visual _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: 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: for key: String in collision_bodies:
var proxy: AnimatableBody2D = collision_bodies.get(key) as AnimatableBody2D var proxy: AnimatableBody2D = collision_bodies.get(key) as AnimatableBody2D
var visual: Node2D = _collision_visuals.get(key) as Node2D 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): 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 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: 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: 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(): 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]) 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: 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: func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
if _rig == null or is_zero_approx(motion.x): if _rig == null or is_zero_approx(motion.x):
return {"fraction": 1.0} 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 var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
if space_state == null: 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} return {"fraction": 1.0}
var excluded: Array[RID] = [] var excluded: Array[RID] = []
for own_proxy: AnimatableBody2D in collision_bodies.values(): 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): if held != null and is_instance_valid(held):
excluded.append(held.get_rid()) excluded.append(held.get_rid())
var safe_fraction: float = 1.0 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 var proxy: AnimatableBody2D = collision_bodies.get(part_name) as AnimatableBody2D
if proxy == null or not is_instance_valid(proxy): if proxy == null or not is_instance_valid(proxy):
if debug_sample:
print("[PropCollisionDebug][SweepPart] part=%s missing_proxy" % part_name)
continue continue
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node == null or shape_node.shape == null: if shape_node == null or shape_node.shape == null:
if debug_sample:
print("[PropCollisionDebug][SweepPart] part=%s missing_shape" % part_name)
continue continue
var query := PhysicsShapeQueryParameters2D.new() var query := PhysicsShapeQueryParameters2D.new()
query.shape = shape_node.shape query.shape = shape_node.shape
@@ -581,11 +653,143 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
query.motion = motion query.motion = motion
query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
query.exclude = excluded 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 "<unresolved>",
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) var cast: PackedFloat32Array = space_state.cast_motion(query)
if cast.size() >= 1: if cast.size() < 1:
safe_fraction = minf(safe_fraction, cast[0]) 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 "<unresolved>",
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 "<none>"]
)
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} 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: func _settle_walk_markers(delta: float) -> void:
if not _settling or _rig == null: if not _settling or _rig == null:
return return
@@ -826,6 +1030,7 @@ func prepare_for_ragdoll() -> void:
set_terrain_following_enabled(false) set_terrain_following_enabled(false)
func reset_for_ragdoll_handoff() -> void: func reset_for_ragdoll_handoff() -> void:
reset_collision_proxy_velocity_baselines()
_cancel_landing_impact(false) _cancel_landing_impact(false)
air_pose_base.clear() air_pose_base.clear()
air_pose_weight = 0.0 air_pose_weight = 0.0
@@ -177,6 +177,7 @@ signal state_changed(new_state: int)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
signal arrived(target: Vector2) # walk_to reached its destination 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_started(action: Dictionary, index: int)
signal action_finished(action: Dictionary, index: int) signal action_finished(action: Dictionary, index: int)
signal queue_finished # queue ran to completion (not on stop) 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. ## boundaries while this facade preserves the long-standing public API.
var _locomotion_component: StickmanLocomotion = null var _locomotion_component: StickmanLocomotion = null
var _ragdoll_component: StickmanRagdoll = null var _ragdoll_component: StickmanRagdoll = null
var _ragdoll_velocity_samples: Dictionary = {}
var _action_runner_component: StickmanActionRunner = null var _action_runner_component: StickmanActionRunner = null
var _prop_interactor_component: Node = null var _prop_interactor_component: Node = null
var _simulation_paused: bool = false var _simulation_paused: bool = false
@@ -257,6 +259,7 @@ func _ready() -> void:
if _locomotion_component != null: if _locomotion_component != null:
_locomotion_component.configure(self) _locomotion_component.configure(self)
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target)) _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: if _ragdoll_component != null:
_ragdoll_component.configure(self, _locomotion_component) _ragdoll_component.configure(self, _locomotion_component)
_ragdoll_component.recovery_requested.connect(func() -> void: request_recovery()) _ragdoll_component.recovery_requested.connect(func() -> void: request_recovery())
@@ -326,6 +329,7 @@ func _ready() -> void:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if _simulation_paused: if _simulation_paused:
return return
_sample_ragdoll_velocity_baselines()
if _ragdoll_component != null: if _ragdoll_component != null:
_ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover) _ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover)
if _locomotion_component != null: if _locomotion_component != null:
@@ -347,6 +351,32 @@ func get_collision_bodies() -> Dictionary:
return _locomotion_component.collision_bodies if _locomotion_component != null else {} 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]: func get_collision_body_rids() -> Array[RID]:
var result: Array[RID] = [] var result: Array[RID] = []
for proxy: AnimatableBody2D in get_collision_bodies().values(): for proxy: AnimatableBody2D in get_collision_bodies().values():
@@ -492,6 +522,7 @@ func _set_state(next_state: int) -> void:
if int(state) == next_state: if int(state) == next_state:
return return
state = next_state state = next_state
reset_collision_proxy_velocity_baselines()
state_changed.emit(int(state)) state_changed.emit(int(state))
@@ -595,6 +626,7 @@ func snap_to_standing() -> void:
_action_runner_component.clear_pause_state() _action_runner_component.clear_pause_state()
if _ragdoll_component != null: if _ragdoll_component != null:
_ragdoll_component.clear_pause_snapshot() _ragdoll_component.clear_pause_snapshot()
reset_collision_proxy_velocity_baselines()
## Restores the spawn-marker pose through the ragdoll component. Kept on the ## 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: func set_collision_bodies_enabled(enabled: bool) -> void:
if _locomotion_component != null: if _locomotion_component != null:
_locomotion_component.set_collision_bodies_enabled(enabled) _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( func apply_collision_reaction(
+205 -1
View File
@@ -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_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_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_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_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_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") 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 _spatial_index: StageSpatialIndex
var _terrain_painter: StageTerrainPainter var _terrain_painter: StageTerrainPainter
var _rule_engine: StageRuleEngine var _rule_engine: StageRuleEngine
var _impact_resolver: StageImpactResolver
var _rule_authoring: StageRuleAuthoringController var _rule_authoring: StageRuleAuthoringController
var _action_queue_controller: StageActionQueueController var _action_queue_controller: StageActionQueueController
var _speech_session: StageSpeechSession var _speech_session: StageSpeechSession
@@ -248,6 +250,7 @@ var _asset_browser: StageAssetBrowser
var _modal_views: StageModalViews var _modal_views: StageModalViews
var _chrome: StageChrome var _chrome: StageChrome
var _prop_settings_view: StagePropSettingsView var _prop_settings_view: StagePropSettingsView
var _edit_wobble_preview_prop: PropBlock
var _use_spot_overlay: StageUseSpotOverlay var _use_spot_overlay: StageUseSpotOverlay
var _use_target_capture_bar: StageUseTargetCaptureBar var _use_target_capture_bar: StageUseTargetCaptureBar
var _placement_overlay = null # StagePlacementOverlay (preloaded) var _placement_overlay = null # StagePlacementOverlay (preloaded)
@@ -1181,6 +1184,7 @@ func _ready() -> void:
get_tree().auto_accept_quit = false get_tree().auto_accept_quit = false
_camera.make_current() _camera.make_current()
_run_session = STAGE_RUN_SESSION_SCRIPT.new() as StageRunSession _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 = STAGE_DOCUMENT_CONTROLLER_SCRIPT.new() as StageDocumentController
_document.open_path_requested.connect(_on_document_open_path_requested) _document.open_path_requested.connect(_on_document_open_path_requested)
_document.save_path_selected.connect(_on_scene_save_path_selected) _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: func _physics_process(delta: float) -> void:
if _impact_resolver != null:
_impact_resolver.finish_sweep_tick(Engine.get_physics_frames())
_update_throw_preview_cache() _update_throw_preview_cache()
_update_use_target_preview_cache() _update_use_target_preview_cache()
var elapsed_timestamp: float = _run_session.advance_elapsed(delta) if _run_session != null else -1.0 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() _sync_mode_buttons()
return return
var previous_mode: StageMode = current_mode var previous_mode: StageMode = current_mode
_cancel_edit_wobble_preview()
if mode == StageMode.PLAY and _run_state == RunState.STOPPED and not _cli_startup: if mode == StageMode.PLAY and _run_state == RunState.STOPPED and not _cli_startup:
if not _save_scene_to_path(AUTOSAVE_PATH, true): if not _save_scene_to_path(AUTOSAVE_PATH, true):
push_warning("SandboxStage: autosave before Play failed.") push_warning("SandboxStage: autosave before Play failed.")
@@ -1781,6 +1788,9 @@ func _enter_play_mode() -> void:
func _start_run() -> 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 # STOP reasserts the authored transforms for a few physics frames. If the
# child immediately presses PLAY, those delayed writes must not put an # child immediately presses PLAY, those delayed writes must not put an
# authored prop scale back on the root after begin_play_instance_scale() # authored prop scale back on the root after begin_play_instance_scale()
@@ -1795,6 +1805,9 @@ func _start_run() -> void:
func _pause_run() -> void: func _pause_run() -> void:
if not _run_session.pause_run(_world_children_selectable()): if not _run_session.pause_run(_world_children_selectable()):
return return
if _impact_resolver != null:
_impact_resolver.reset()
_seed_impact_contact_baseline()
if _use_spot_overlay != null: if _use_spot_overlay != null:
_use_spot_overlay.set_paused(true) _use_spot_overlay.set_paused(true)
_refresh_event_edge_baseline() _refresh_event_edge_baseline()
@@ -1813,6 +1826,9 @@ func _resume_run() -> void:
_clear_director_pending() _clear_director_pending()
if not _run_session.resume_run(_world_children_selectable(), _rule_engine): if not _run_session.resume_run(_world_children_selectable(), _rule_engine):
return return
if _impact_resolver != null:
_impact_resolver.reset()
_seed_impact_contact_baseline()
if _use_spot_overlay != null: if _use_spot_overlay != null:
_use_spot_overlay.set_paused(false) _use_spot_overlay.set_paused(false)
_sync_run_controls() _sync_run_controls()
@@ -1820,6 +1836,9 @@ func _resume_run() -> void:
func _reset_run() -> void: func _reset_run() -> void:
_cancel_edit_wobble_preview()
if _impact_resolver != null:
_impact_resolver.reset()
if PROP_TRACE_ENABLED: if PROP_TRACE_ENABLED:
print("[PropTrace] stage reset_run mode=%s run_state=%s" % [StageMode.keys()[int(current_mode)], RunState.keys()[int(_run_state)]]) print("[PropTrace] stage reset_run mode=%s run_state=%s" % [StageMode.keys()[int(current_mode)], RunState.keys()[int(_run_state)]])
_run_session.begin_reset() _run_session.begin_reset()
@@ -1856,6 +1875,7 @@ func _sync_run_controls() -> void:
func _finish_resume_baseline() -> void: func _finish_resume_baseline() -> void:
if _run_state == RunState.RUNNING and current_mode == StageMode.PLAY: if _run_state == RunState.RUNNING and current_mode == StageMode.PLAY:
_seed_impact_contact_baseline()
_event_dispatch_ready = true _event_dispatch_ready = true
@@ -1876,6 +1896,8 @@ func _refresh_event_edge_baseline() -> void:
_rule_engine.refresh_event_edge_baseline(_world_children_selectable()) _rule_engine.refresh_event_edge_baseline(_world_children_selectable())
func _save_object_state(node: Node2D) -> void: 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) _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.action_finished.connect(_on_rig_action_finished.bind(rig))
rig.speech_finished.connect(_on_rig_speech_finished.bind(rig)) rig.speech_finished.connect(_on_rig_speech_finished.bind(rig))
rig.state_changed.connect(_on_rig_state_changed.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.prop_interaction_event.connect(_on_rig_prop_interaction_event.bind(rig))
rig.use_completed.connect(_on_rig_use_completed.bind(rig)) rig.use_completed.connect(_on_rig_use_completed.bind(rig))
rig.use_failed.connect(_on_rig_use_failed.bind(rig)) rig.use_failed.connect(_on_rig_use_failed.bind(rig))
_director_visuals.mark_dirty() _director_visuals.mark_dirty()
if node is PropBlock: if node is PropBlock:
var prop := node as 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)) prop.collided.connect(_on_prop_collided.bind(prop))
if not prop.physics_profile_changed.is_connected(_on_prop_physics_profile_changed): if not prop.physics_profile_changed.is_connected(_on_prop_physics_profile_changed):
prop.physics_profile_changed.connect(_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.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_choice_requested.connect(_on_prop_role_choice)
_prop_settings_view.role_toggle_requested.connect(_on_prop_role_toggle) _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_add_requested.connect(_on_spot_add_requested)
_prop_settings_view.spot_move_requested.connect(_on_spot_move_requested) _prop_settings_view.spot_move_requested.connect(_on_spot_move_requested)
_prop_settings_view.spot_rename_requested.connect(_on_spot_rename_requested) _prop_settings_view.spot_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() _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: func _on_spot_add_requested() -> void:
var prop: PropBlock = _selection.get_primary() as PropBlock if _selection != null else null 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): 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: 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)): 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() _cancel_spot_work()
_gizmos.set_targets(nodes) _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. # authoritative snap_to_standing() cleanup has run.
if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready: if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready:
return 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 return
if not _rule_engine.register_contact(prop, other): if not _rule_engine.register_contact(prop, other):
return 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: 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: if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready:
return return
if other == null or not is_instance_valid(other) or _rule_engine == null: 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) _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: 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: if new_state == StickmanRig.RigState.RAGDOLL:
_connect_ragdoll_impact_contacts(rig)
var rig_ids: Array[int] = [rig.get_instance_id()] var rig_ids: Array[int] = [rig.get_instance_id()]
_purge_collision_pairs_for_ids(rig_ids) _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: func _purge_collision_pairs_for_ids(ids: Array[int]) -> void:
if _rule_engine != null: if _rule_engine != null:
_rule_engine.purge_collision_pairs_for_ids(ids) _rule_engine.purge_collision_pairs_for_ids(ids)
@@ -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_choice_requested(key: String, value: String)
signal role_toggle_requested(enabled: bool, key: String) signal role_toggle_requested(enabled: bool, key: String)
signal wobble_toggled(enabled: bool)
signal spot_add_requested signal spot_add_requested
signal spot_move_requested(spot_id: String) signal spot_move_requested(spot_id: String)
signal spot_rename_requested(spot_id: String, name: String) signal spot_rename_requested(spot_id: String, name: String)
@@ -26,6 +27,7 @@ var _fields: VBoxContainer
var _notice: Label var _notice: Label
var _depth_group: StagePanelChoiceGroup var _depth_group: StagePanelChoiceGroup
var _motion_group: StagePanelChoiceGroup var _motion_group: StagePanelChoiceGroup
var _wobble_row: StagePanelToggleRow
var _controls: Dictionary = {} var _controls: Dictionary = {}
var _spot_rows: VBoxContainer var _spot_rows: VBoxContainer
var _spot_empty_label: Label var _spot_empty_label: Label
@@ -193,6 +195,7 @@ func _build() -> void:
_controls["blocks_stickmen"] = _make_toggle_row(_fields, "blocks_stickmen", "Blocks stickmen") _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") _controls["can_pick_up"] = _make_toggle_row(_fields, "can_pick_up", "Stickmen can pick it up")
_add_release_options() _add_release_options()
_add_wobble_section()
_add_use_spots_section() _add_use_spots_section()
_reopen_button = Button.new() _reopen_button = Button.new()
@@ -239,6 +242,19 @@ func _add_use_spots_section() -> void:
section.add_child(_spot_rows) 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: func _sync_spots(spots: Array[Dictionary], selected_spot_id: String) -> void:
if _spot_rows == null: if _spot_rows == null:
return return
@@ -390,6 +406,9 @@ func _sync_roles(prop: PropBlock, roles: Dictionary) -> void:
else: else:
_depth_group.select_no_signal(StringName(String(roles.get("depth", PROP_STAGE_ROLES.DEPTH_BEHIND)))) _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)))) _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"]: 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 var control := _controls.get(key) as StagePanelToggleRow
if control == null: if control == null:
@@ -453,6 +472,10 @@ func _on_role_toggled(enabled: bool, key: String) -> void:
role_toggle_requested.emit(enabled, key) role_toggle_requested.emit(enabled, key)
func _on_wobble_toggled(enabled: bool) -> void:
wobble_toggled.emit(enabled)
func _on_row_height_changed() -> void: func _on_row_height_changed() -> void:
if _card != null: if _card != null:
_card.request_fit() _card.request_fit()
+65 -4
View File
@@ -10,6 +10,8 @@ const STATE_RUNNING: int = 1
const STATE_PAUSED: int = 2 const STATE_PAUSED: int = 2
## Set true temporarily when diagnosing authored/play prop scale transfer. ## Set true temporarily when diagnosing authored/play prop scale transfer.
const PROP_SCALE_TRACE_ENABLED: bool = false 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 run_state: int = STATE_STOPPED
var elapsed_seconds: float = 0.0 var elapsed_seconds: float = 0.0
@@ -44,14 +46,18 @@ func start_run(
for node: Node2D in world_nodes: for node: Node2D in world_nodes:
if node is PropBlock: if node is PropBlock:
var prop: PropBlock = node as PropBlock var prop: PropBlock = node as PropBlock
prop.cancel_wobble()
trace_prop_scale(prop, "before_play_scale_transfer", stage_mode_name, "RUNNING", camera_zoom) trace_prop_scale(prop, "before_play_scale_transfer", stage_mode_name, "RUNNING", camera_zoom)
prop.begin_play_instance_scale() prop.begin_play_instance_scale()
trace_prop_scale(prop, "after_play_scale_transfer", stage_mode_name, "RUNNING", camera_zoom) 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 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) 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) trace_prop_scale(prop, "after_play_role_applied", stage_mode_name, "RUNNING", camera_zoom)
elif node is STICKMAN_RIG_SCRIPT: elif node is STICKMAN_RIG_SCRIPT:
var rig: StickmanRig = node as StickmanRig var rig: StickmanRig = node as StickmanRig
rig.reset_collision_proxy_velocity_baselines()
rig.reset_simulation_pause() rig.reset_simulation_pause()
rig.stop_queue() rig.stop_queue()
rig.clear_reactive_actions() rig.clear_reactive_actions()
@@ -60,6 +66,44 @@ func start_run(
rig.start_queue() rig.start_queue()
elif node is RigidBody2D: elif node is RigidBody2D:
(node as RigidBody2D).freeze = false (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: func pause_run(world_nodes: Array[Node2D]) -> bool:
@@ -69,7 +113,11 @@ func pause_run(world_nodes: Array[Node2D]) -> bool:
run_state = STATE_PAUSED run_state = STATE_PAUSED
resume_baseline_frames = 0 resume_baseline_frames = 0
for node: Node2D in world_nodes: 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() (node as StickmanRig).pause_simulation()
elif node is RigidBody2D: elif node is RigidBody2D:
capture_prop_pause_state(node as 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)) body.sleeping = bool(snapshot.get("sleeping", false))
prop_pause_states.clear() prop_pause_states.clear()
for node: Node2D in world_nodes: for node: Node2D in world_nodes:
if node is STICKMAN_RIG_SCRIPT: if node is PropBlock:
(node as StickmanRig).resume_simulation() (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 run_state = STATE_RUNNING
event_dispatch_ready = false event_dispatch_ready = false
resume_baseline_frames = 2 resume_baseline_frames = 2
@@ -127,6 +180,7 @@ func reset_world(
var body: RigidBody2D = node as RigidBody2D var body: RigidBody2D = node as RigidBody2D
if node is PropBlock: if node is PropBlock:
var prop: PropBlock = node as PropBlock var prop: PropBlock = node as PropBlock
prop.cancel_wobble()
var authored: Dictionary = authored_state.get(prop.get_instance_id(), {}) var authored: Dictionary = authored_state.get(prop.get_instance_id(), {})
trace_prop_scale(prop, "before_stop_scale_restore", stage_mode_name, "STOPPED", camera_zoom) trace_prop_scale(prop, "before_stop_scale_restore", stage_mode_name, "STOPPED", camera_zoom)
prop.set_stage_roles(authored.get("roles", prop.stage_roles)) 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: if not node is STICKMAN_RIG_SCRIPT:
entry["scale"] = node.scale entry["scale"] = node.scale
if node is PropBlock: 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 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) 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"): if node is STICKMAN_RIG_SCRIPT and entry.has("facing_profile"):
(node as STICKMAN_RIG_SCRIPT).set_facing_profile(int(entry["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: if node is RigidBody2D:
var body: RigidBody2D = node as RigidBody2D var body: RigidBody2D = node as RigidBody2D
body.linear_velocity = Vector2.ZERO body.linear_velocity = Vector2.ZERO
body.angular_velocity = 0.0 body.angular_velocity = 0.0
if node is PropBlock: if node is PropBlock:
var prop: PropBlock = node as PropBlock var prop: PropBlock = node as PropBlock
prop.cancel_wobble()
prop.set_stage_roles(entry.get("roles", prop.stage_roles)) 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) prop.apply_runtime_role(PropBlock.RuntimeRole.EDIT)
@@ -60,6 +60,7 @@ func collect(
entry["roles"] = prop.stage_roles.duplicate(true) entry["roles"] = prop.stage_roles.duplicate(true)
entry["use_spots"] = PROP_USE_SPOT_DATA.encode(prop.get_use_spots()) entry["use_spots"] = PROP_USE_SPOT_DATA.encode(prop.get_use_spots())
entry["next_use_spot_number"] = prop.next_use_spot_number 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) var prop_scale: Vector2 = authored_state.get("scale", prop.scale)
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale) entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale)
entry["material_preset"] = prop.material_preset entry["material_preset"] = prop.material_preset
@@ -175,6 +176,7 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
if node is PropBlock: if node is PropBlock:
var prop: PropBlock = node as PropBlock var prop: PropBlock = node as PropBlock
prop.stage_object_id = String(entry.get("stage_object_id", "")) 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) 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))) 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()) 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) (node as StickmanRig).queue_action(action)
else: else:
warnings.append("%s: invalid action queue. Open this character's queue and review its actions." % file_id) 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) loaded_nodes.append(node)
file_to_runtime[file_id] = node.get_instance_id() 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) var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable)
+154
View File
@@ -38,6 +38,7 @@ var use_spots: Array[Dictionary] = []
var next_use_spot_number: int = 1 var next_use_spot_number: int = 1
var stage_roles: Dictionary = {} var stage_roles: Dictionary = {}
var runtime_role: RuntimeRole = RuntimeRole.EDIT var runtime_role: RuntimeRole = RuntimeRole.EDIT
var wobble_on_impact: bool = false
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Child node names # 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_COLOR := Color(0.15, 0.08, 0.02, 1.0)
const DEFAULT_OUTLINE_WIDTH: float = 2.0 const DEFAULT_OUTLINE_WIDTH: float = 2.0
const MAX_CONTACTS_REPORTED := 32 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 # Exported properties
@@ -114,6 +118,16 @@ var _visual_local_bounds := Rect2()
var _custom_collision_hull: PackedVector2Array = PackedVector2Array() var _custom_collision_hull: PackedVector2Array = PackedVector2Array()
var _play_instance_scale: Vector2 = Vector2.ONE var _play_instance_scale: Vector2 = Vector2.ONE
var _play_scale_active: bool = false 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 # Signals
@@ -144,6 +158,144 @@ func _ready() -> void:
body_entered.connect(_on_body_entered) body_entered.connect(_on_body_entered)
if not body_exited.is_connected(_on_body_exited): if not body_exited.is_connected(_on_body_exited):
body_exited.connect(_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: 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 ## the authored uniform scale into child visuals and collision geometry for
## PLAY, then restore the root scale on return to EDIT for scene persistence. ## PLAY, then restore the root scale on return to EDIT for scene persistence.
func begin_play_instance_scale() -> void: func begin_play_instance_scale() -> void:
cancel_wobble()
if _play_scale_active: if _play_scale_active:
return return
_play_instance_scale = scale _play_instance_scale = scale
@@ -518,6 +671,7 @@ func begin_play_instance_scale() -> void:
_sync_transform_to_physics_server() _sync_transform_to_physics_server()
func end_play_instance_scale() -> void: func end_play_instance_scale() -> void:
cancel_wobble()
if not _play_scale_active: if not _play_scale_active:
return return
_play_scale_active = false _play_scale_active = false
+1
View File
@@ -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}, "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}}, "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"}, "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}, "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}}, "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]}}, "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]}},
+190
View File
@@ -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())
@@ -0,0 +1 @@
uid://cceutk52dceey
+8 -2
View File
@@ -3,7 +3,7 @@ extends RefCounted
## JSON boundary for Sandbox Stage documents. This class deliberately has no ## JSON boundary for Sandbox Stage documents. This class deliberately has no
## scene-tree dependencies, which makes rejected documents non-destructive. ## 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 CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_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["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
prop_entry["use_spots"] = prop_entry.get("use_spots", []) prop_entry["use_spots"] = prop_entry.get("use_spots", [])
prop_entry["next_use_spot_number"] = prop_entry.get("next_use_spot_number", 1) 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", [])) _assign_legacy_depths(current.get("objects", []))
return current 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 {} return {}
var migrated: Dictionary = data.duplicate(true) var migrated: Dictionary = data.duplicate(true)
for raw: Variant in migrated.get("objects", []): 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["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
object["use_spots"] = object.get("use_spots", []) object["use_spots"] = object.get("use_spots", [])
object["next_use_spot_number"] = object.get("next_use_spot_number", 1) 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 migrated["version"] = FILE_VERSION
_assign_legacy_depths(migrated.get("objects", [])) _assign_legacy_depths(migrated.get("objects", []))
return migrated 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."} 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 seen[file_id] = true
if String(entry.get("type", "")) == "prop": 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", "") var raw_stage_id: Variant = entry.get("stage_object_id", "")
if not raw_stage_id is String: if not raw_stage_id is String:
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty strings."} return {"ok": false, "error": "Prop stage IDs must be unique and non-empty strings."}
+2 -2
View File
@@ -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"])) 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 var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
if poly != null and not poly.polygon.is_empty(): 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: for p: Vector2 in poly.polygon:
rect = rect.expand(node.to_global(p)) rect = rect.expand(poly.to_global(p))
return rect return rect
var body := node.get_node_or_null(NodePath("Body")) as Node2D var body := node.get_node_or_null(NodePath("Body")) as Node2D
if body != null: if body != null:
+70
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://du57472qg5yw2