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
@@ -71,6 +71,9 @@ const LEG_COLLISION_RADIUS: float = 14.0
const HEAD_COLLISION_FALLBACK_CENTER: Vector2 = Vector2(0.0, -72.0)
const COLLISION_LAYER: int = 1
const WALK_BLOCKED_TIMEOUT: float = 0.45
## Temporary, throttled diagnostics for actor-blocking prop sweep queries.
const ACTOR_PROP_DEBUG_ENABLED: bool = true
const ACTOR_PROP_DEBUG_INTERVAL_FRAMES: int = 30
const COLLISION_PARTS: Dictionary = {
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS},
@@ -83,6 +86,14 @@ const COLLISION_PARTS: Dictionary = {
"right_upper_leg": {"visual": "RightUpperLeg", "bone": "Torso/RightUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
"right_lower_leg": {"visual": "RightLowerLeg", "bone": "Torso/RightUpperLeg/RightLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
}
const ACTOR_BLOCKING_SWEEP_PARTS: Array[String] = [
"torso",
"head",
"left_upper_leg",
"left_lower_leg",
"right_upper_leg",
"right_lower_leg",
]
const REACTION_BASE_ANGLE: float = 0.35
const REACTION_REFERENCE_SPEED: float = 400.0
const REACTION_MAX_ANGLE: float = 0.6
@@ -101,6 +112,7 @@ const REACTION_TARGETS: Dictionary = {
}
signal arrived(target: Vector2)
signal blocking_prop_sweep_hit(prop: PropBlock, normal: Vector2, world_position: Vector2, requested_velocity: Vector2)
var _rig: StickmanRig = null
var foot_contacts: Dictionary = {"Left_Leg": {"valid": false, "locked": false}, "Right_Leg": {"valid": false, "locked": false}}
@@ -146,6 +158,11 @@ var _blocked_walk_elapsed: float = 0.0
var _walk_failed: bool = false
var collision_bodies: Dictionary = {}
var collision_lengths: Dictionary = {}
var collision_proxy_velocities: Dictionary = {}
var _collision_proxy_positions: Dictionary = {}
var _last_sweep_hit_tick: Dictionary = {}
var _actor_prop_debug_last_sample_frame: int = -1
var _actor_prop_debug_last_hit_tick: Dictionary = {}
var _collision_visuals: Dictionary = {}
var reaction_bones: Dictionary = {}
var impact_reactions: Dictionary = {}
@@ -176,6 +193,9 @@ func configure(rig: StickmanRig) -> void:
func setup_collision_bodies() -> void:
collision_bodies.clear()
collision_lengths.clear()
collision_proxy_velocities.clear()
_collision_proxy_positions.clear()
_last_sweep_hit_tick.clear()
_collision_visuals.clear()
reaction_bones.clear()
if _rig == null or _rig._body_container == null or _rig._skeleton == null:
@@ -226,14 +246,42 @@ func setup_collision_bodies() -> void:
proxy.global_transform = visual.global_transform
collision_bodies[key] = proxy
_collision_visuals[key] = visual
_collision_proxy_positions[proxy.get_instance_id()] = proxy.global_position
collision_proxy_velocities[proxy.get_instance_id()] = Vector2.ZERO
func sync_collision_bodies() -> void:
var physics_delta: float = 1.0 / maxf(1.0, float(Engine.physics_ticks_per_second))
for key: String in collision_bodies:
var proxy: AnimatableBody2D = collision_bodies.get(key) as AnimatableBody2D
var visual: Node2D = _collision_visuals.get(key) as Node2D
if proxy != null and visual != null and is_instance_valid(proxy) and is_instance_valid(visual):
var proxy_id: int = proxy.get_instance_id()
var target_position: Vector2 = visual.global_position
var previous_position: Vector2 = _collision_proxy_positions.get(proxy_id, target_position) as Vector2
var velocity: Vector2 = (target_position - previous_position) / physics_delta
collision_proxy_velocities[proxy_id] = velocity if velocity.is_finite() else Vector2.ZERO
proxy.global_transform = visual.global_transform
_collision_proxy_positions[proxy_id] = proxy.global_position
func collision_proxy_velocity(body: PhysicsBody2D) -> Vector2:
if body == null or not is_instance_valid(body):
return Vector2.ZERO
var velocity: Vector2 = collision_proxy_velocities.get(body.get_instance_id(), Vector2.ZERO) as Vector2
return velocity if velocity.is_finite() else Vector2.ZERO
func reset_collision_proxy_velocity_baselines() -> void:
_collision_proxy_positions.clear()
collision_proxy_velocities.clear()
for proxy_value: Variant in collision_bodies.values():
var proxy: AnimatableBody2D = proxy_value as AnimatableBody2D
if proxy == null or not is_instance_valid(proxy):
continue
var proxy_id: int = proxy.get_instance_id()
_collision_proxy_positions[proxy_id] = proxy.global_position
collision_proxy_velocities[proxy_id] = Vector2.ZERO
func mounted_visual_bounds_local(part: Node2D) -> Dictionary:
@@ -362,6 +410,9 @@ func walk_to(target: Vector2, speed: float = -1.0) -> bool:
func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_override: float = -1.0) -> bool:
if ACTOR_PROP_DEBUG_ENABLED:
_actor_prop_debug_last_sample_frame = -1
_actor_prop_debug_last_hit_tick.clear()
if _walk_debug_enabled():
print("[WalkDebug] MOVE_TO style=%s target=%s root=%s" % [style, target, _rig.global_position if _rig != null else Vector2.ZERO])
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED:
@@ -557,8 +608,17 @@ func _update_blocked_walk(delta: float) -> void:
func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
if _rig == null or is_zero_approx(motion.x):
return {"fraction": 1.0}
var debug_frame: int = Engine.get_physics_frames()
var debug_sample: bool = ACTOR_PROP_DEBUG_ENABLED and (
_actor_prop_debug_last_sample_frame < 0
or debug_frame - _actor_prop_debug_last_sample_frame >= ACTOR_PROP_DEBUG_INTERVAL_FRAMES
)
if debug_sample:
_actor_prop_debug_last_sample_frame = debug_frame
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
if space_state == null:
if debug_sample:
print("[PropCollisionDebug][Sweep] no_space_state rig=%s motion=%s" % [_rig.name, str(motion)])
return {"fraction": 1.0}
var excluded: Array[RID] = []
for own_proxy: AnimatableBody2D in collision_bodies.values():
@@ -568,12 +628,24 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
if held != null and is_instance_valid(held):
excluded.append(held.get_rid())
var safe_fraction: float = 1.0
for part_name: String in ["torso", "head"]:
var best_hit_fraction: float = INF
var best_hit: Dictionary = {}
var requested_velocity: Vector2 = motion * float(Engine.physics_ticks_per_second)
if debug_sample:
print(
"[PropCollisionDebug][Sweep] rig=%s root=%s motion=%s mask=%d excluded=%d"
% [_rig.name, str(_rig.global_position), str(motion), STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER, excluded.size()]
)
for part_name: String in ACTOR_BLOCKING_SWEEP_PARTS:
var proxy: AnimatableBody2D = collision_bodies.get(part_name) as AnimatableBody2D
if proxy == null or not is_instance_valid(proxy):
if debug_sample:
print("[PropCollisionDebug][SweepPart] part=%s missing_proxy" % part_name)
continue
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node == null or shape_node.shape == null:
if debug_sample:
print("[PropCollisionDebug][SweepPart] part=%s missing_shape" % part_name)
continue
var query := PhysicsShapeQueryParameters2D.new()
query.shape = shape_node.shape
@@ -581,11 +653,143 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
query.motion = motion
query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
query.exclude = excluded
# cast_motion() ignores shapes that already overlap at the start. A
# stickman can begin PLAY partly inside a placed prop, so inspect the
# starting pose and block only motion that pushes farther into it. Motion
# away from the contact normal remains free so the stickman can get out.
var start_hit: Dictionary = space_state.get_rest_info(query)
var start_prop: PropBlock = _prop_from_rest_info(start_hit)
var start_normal: Vector2 = start_hit.get("normal", Vector2.ZERO) as Vector2
var start_dot: float = start_normal.dot(motion)
if debug_sample:
print(
"[PropCollisionDebug][SweepPart] part=%s shape=%s origin=%s disabled=%s start_id=%d start_prop=%s start_blocking=%s start_normal=%s dot=%s"
% [
part_name,
shape_node.shape.get_class(),
str(query.transform.origin),
str(shape_node.disabled),
int(start_hit.get("collider_id", 0)),
start_prop.name if start_prop != null and is_instance_valid(start_prop) else "<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)
if cast.size() >= 1:
safe_fraction = minf(safe_fraction, cast[0])
if cast.size() < 1:
if debug_sample:
print("[PropCollisionDebug][SweepPart] part=%s cast returned no fractions" % part_name)
continue
safe_fraction = minf(safe_fraction, cast[0])
if debug_sample:
print(
"[PropCollisionDebug][SweepPart] part=%s cast_safe=%.4f cast_unsafe=%.4f"
% [part_name, cast[0], cast[1] if cast.size() > 1 else cast[0]]
)
if cast[0] >= 0.999:
continue
var unsafe_fraction: float = cast[1] if cast.size() > 1 else cast[0]
var rest_query: PhysicsShapeQueryParameters2D = PhysicsShapeQueryParameters2D.new()
rest_query.shape = shape_node.shape
rest_query.transform = shape_node.global_transform
rest_query.transform.origin += motion * minf(1.0, unsafe_fraction + 0.001)
rest_query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
rest_query.exclude = excluded
var hit: Dictionary = space_state.get_rest_info(rest_query)
var prop: PropBlock = _prop_from_rest_info(hit)
if debug_sample:
print(
"[PropCollisionDebug][SweepHit] part=%s unsafe=%.4f id=%d prop=%s blocking=%s normal=%s point=%s"
% [
part_name,
unsafe_fraction,
int(hit.get("collider_id", 0)),
prop.name if prop != null and is_instance_valid(prop) else "<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}
func _prop_from_rest_info(rest_info: Dictionary) -> PropBlock:
# PhysicsDirectSpaceState2D.get_rest_info() returns collider_id rather than
# the `collider` object key used by ray/shape intersection queries.
var collider_id: int = int(rest_info.get("collider_id", 0))
if collider_id <= 0:
return null
var collider: PropBlock = instance_from_id(collider_id) as PropBlock
if collider == null or not is_instance_valid(collider):
return null
return collider
func _settle_walk_markers(delta: float) -> void:
if not _settling or _rig == null:
return
@@ -826,6 +1030,7 @@ func prepare_for_ragdoll() -> void:
set_terrain_following_enabled(false)
func reset_for_ragdoll_handoff() -> void:
reset_collision_proxy_velocity_baselines()
_cancel_landing_impact(false)
air_pose_base.clear()
air_pose_weight = 0.0
@@ -177,6 +177,7 @@ signal state_changed(new_state: int)
# ---------------------------------------------------------------------------
signal arrived(target: Vector2) # walk_to reached its destination
signal blocking_prop_sweep_hit(rig: StickmanRig, prop: PropBlock, normal: Vector2, world_position: Vector2, requested_velocity: Vector2)
signal action_started(action: Dictionary, index: int)
signal action_finished(action: Dictionary, index: int)
signal queue_finished # queue ran to completion (not on stop)
@@ -230,6 +231,7 @@ var source_stk_path: String = ""
## boundaries while this facade preserves the long-standing public API.
var _locomotion_component: StickmanLocomotion = null
var _ragdoll_component: StickmanRagdoll = null
var _ragdoll_velocity_samples: Dictionary = {}
var _action_runner_component: StickmanActionRunner = null
var _prop_interactor_component: Node = null
var _simulation_paused: bool = false
@@ -257,6 +259,7 @@ func _ready() -> void:
if _locomotion_component != null:
_locomotion_component.configure(self)
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target))
_locomotion_component.blocking_prop_sweep_hit.connect(_on_blocking_prop_sweep_hit)
if _ragdoll_component != null:
_ragdoll_component.configure(self, _locomotion_component)
_ragdoll_component.recovery_requested.connect(func() -> void: request_recovery())
@@ -326,6 +329,7 @@ func _ready() -> void:
func _physics_process(delta: float) -> void:
if _simulation_paused:
return
_sample_ragdoll_velocity_baselines()
if _ragdoll_component != null:
_ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover)
if _locomotion_component != null:
@@ -347,6 +351,32 @@ func get_collision_bodies() -> Dictionary:
return _locomotion_component.collision_bodies if _locomotion_component != null else {}
func get_ragdoll_bodies() -> Array[RigidBody2D]:
if _ragdoll_component == null:
var empty: Array[RigidBody2D] = []
return empty
return _ragdoll_component.get_body_nodes()
func collision_proxy_velocity(body: PhysicsBody2D) -> Vector2:
return _locomotion_component.collision_proxy_velocity(body) if _locomotion_component != null else Vector2.ZERO
func ragdoll_body_velocity(body: RigidBody2D) -> Vector2:
if body == null or not is_instance_valid(body):
return Vector2.ZERO
var velocity: Vector2 = _ragdoll_velocity_samples.get(body.get_instance_id(), body.linear_velocity) as Vector2
return velocity if velocity.is_finite() else Vector2.ZERO
func _sample_ragdoll_velocity_baselines() -> void:
if _ragdoll_component == null:
return
for body: RigidBody2D in _ragdoll_component.get_body_nodes():
if body != null and is_instance_valid(body):
_ragdoll_velocity_samples[body.get_instance_id()] = body.linear_velocity
func get_collision_body_rids() -> Array[RID]:
var result: Array[RID] = []
for proxy: AnimatableBody2D in get_collision_bodies().values():
@@ -492,6 +522,7 @@ func _set_state(next_state: int) -> void:
if int(state) == next_state:
return
state = next_state
reset_collision_proxy_velocity_baselines()
state_changed.emit(int(state))
@@ -595,6 +626,7 @@ func snap_to_standing() -> void:
_action_runner_component.clear_pause_state()
if _ragdoll_component != null:
_ragdoll_component.clear_pause_snapshot()
reset_collision_proxy_velocity_baselines()
## Restores the spawn-marker pose through the ragdoll component. Kept on the
@@ -687,6 +719,23 @@ func _resolve_limb_bones() -> void:
func set_collision_bodies_enabled(enabled: bool) -> void:
if _locomotion_component != null:
_locomotion_component.set_collision_bodies_enabled(enabled)
reset_collision_proxy_velocity_baselines()
func reset_collision_proxy_velocity_baselines() -> void:
if _locomotion_component != null:
_locomotion_component.reset_collision_proxy_velocity_baselines()
_ragdoll_velocity_samples.clear()
_sample_ragdoll_velocity_baselines()
func _on_blocking_prop_sweep_hit(
prop: PropBlock,
normal: Vector2,
world_position: Vector2,
requested_velocity: Vector2
) -> void:
blocking_prop_sweep_hit.emit(self, prop, normal, world_position, requested_velocity)
func apply_collision_reaction(
+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_DIRECTOR_CONTROLLER_SCRIPT := preload("res://scripts/editors/stage/stage_director_controller.gd")
const STAGE_RUN_SESSION_SCRIPT := preload("res://scripts/editors/stage/stage_run_session.gd")
const STAGE_IMPACT_RESOLVER_SCRIPT := preload("res://scripts/stage/stage_impact_resolver.gd")
const STAGE_ASSET_BROWSER_SCRIPT := preload("res://scripts/editors/stage/stage_asset_browser.gd")
const STAGE_MODAL_VIEWS_SCRIPT := preload("res://scripts/editors/stage/stage_modal_views.gd")
const STAGE_THEME_CONTROLLER_SCRIPT := preload("res://scripts/editors/stage/stage_theme_controller.gd")
@@ -241,6 +242,7 @@ var _grid: StageGrid
var _spatial_index: StageSpatialIndex
var _terrain_painter: StageTerrainPainter
var _rule_engine: StageRuleEngine
var _impact_resolver: StageImpactResolver
var _rule_authoring: StageRuleAuthoringController
var _action_queue_controller: StageActionQueueController
var _speech_session: StageSpeechSession
@@ -248,6 +250,7 @@ var _asset_browser: StageAssetBrowser
var _modal_views: StageModalViews
var _chrome: StageChrome
var _prop_settings_view: StagePropSettingsView
var _edit_wobble_preview_prop: PropBlock
var _use_spot_overlay: StageUseSpotOverlay
var _use_target_capture_bar: StageUseTargetCaptureBar
var _placement_overlay = null # StagePlacementOverlay (preloaded)
@@ -1181,6 +1184,7 @@ func _ready() -> void:
get_tree().auto_accept_quit = false
_camera.make_current()
_run_session = STAGE_RUN_SESSION_SCRIPT.new() as StageRunSession
_impact_resolver = STAGE_IMPACT_RESOLVER_SCRIPT.new() as StageImpactResolver
_document = STAGE_DOCUMENT_CONTROLLER_SCRIPT.new() as StageDocumentController
_document.open_path_requested.connect(_on_document_open_path_requested)
_document.save_path_selected.connect(_on_scene_save_path_selected)
@@ -1311,6 +1315,8 @@ func _process(delta: float) -> void:
func _physics_process(delta: float) -> void:
if _impact_resolver != null:
_impact_resolver.finish_sweep_tick(Engine.get_physics_frames())
_update_throw_preview_cache()
_update_use_target_preview_cache()
var elapsed_timestamp: float = _run_session.advance_elapsed(delta) if _run_session != null else -1.0
@@ -1676,6 +1682,7 @@ func set_mode(mode: StageMode) -> void:
_sync_mode_buttons()
return
var previous_mode: StageMode = current_mode
_cancel_edit_wobble_preview()
if mode == StageMode.PLAY and _run_state == RunState.STOPPED and not _cli_startup:
if not _save_scene_to_path(AUTOSAVE_PATH, true):
push_warning("SandboxStage: autosave before Play failed.")
@@ -1781,6 +1788,9 @@ func _enter_play_mode() -> void:
func _start_run() -> void:
_cancel_edit_wobble_preview()
if _impact_resolver != null:
_impact_resolver.reset()
# STOP reasserts the authored transforms for a few physics frames. If the
# child immediately presses PLAY, those delayed writes must not put an
# authored prop scale back on the root after begin_play_instance_scale()
@@ -1795,6 +1805,9 @@ func _start_run() -> void:
func _pause_run() -> void:
if not _run_session.pause_run(_world_children_selectable()):
return
if _impact_resolver != null:
_impact_resolver.reset()
_seed_impact_contact_baseline()
if _use_spot_overlay != null:
_use_spot_overlay.set_paused(true)
_refresh_event_edge_baseline()
@@ -1813,6 +1826,9 @@ func _resume_run() -> void:
_clear_director_pending()
if not _run_session.resume_run(_world_children_selectable(), _rule_engine):
return
if _impact_resolver != null:
_impact_resolver.reset()
_seed_impact_contact_baseline()
if _use_spot_overlay != null:
_use_spot_overlay.set_paused(false)
_sync_run_controls()
@@ -1820,6 +1836,9 @@ func _resume_run() -> void:
func _reset_run() -> void:
_cancel_edit_wobble_preview()
if _impact_resolver != null:
_impact_resolver.reset()
if PROP_TRACE_ENABLED:
print("[PropTrace] stage reset_run mode=%s run_state=%s" % [StageMode.keys()[int(current_mode)], RunState.keys()[int(_run_state)]])
_run_session.begin_reset()
@@ -1856,6 +1875,7 @@ func _sync_run_controls() -> void:
func _finish_resume_baseline() -> void:
if _run_state == RunState.RUNNING and current_mode == StageMode.PLAY:
_seed_impact_contact_baseline()
_event_dispatch_ready = true
@@ -1876,6 +1896,8 @@ func _refresh_event_edge_baseline() -> void:
_rule_engine.refresh_event_edge_baseline(_world_children_selectable())
func _save_object_state(node: Node2D) -> void:
if node is StickmanRig:
(node as StickmanRig).reset_collision_proxy_velocity_baselines()
_run_session.save_object_state(node)
@@ -1957,12 +1979,19 @@ func _finalize_placed_node(node: Node2D) -> void:
rig.action_finished.connect(_on_rig_action_finished.bind(rig))
rig.speech_finished.connect(_on_rig_speech_finished.bind(rig))
rig.state_changed.connect(_on_rig_state_changed.bind(rig))
rig.blocking_prop_sweep_hit.connect(_on_blocking_prop_sweep_hit)
rig.prop_interaction_event.connect(_on_rig_prop_interaction_event.bind(rig))
rig.use_completed.connect(_on_rig_use_completed.bind(rig))
rig.use_failed.connect(_on_rig_use_failed.bind(rig))
_director_visuals.mark_dirty()
if node is PropBlock:
var prop := node as PropBlock
if _sandbox_theme != null:
prop.set_wobble_style(
_sandbox_theme.number("effects.impact_wobble.duration", PropBlock.DEFAULT_WOBBLE_DURATION, 0.05, 2.0),
_sandbox_theme.number("effects.impact_wobble.maximum_angle_degrees", PropBlock.DEFAULT_WOBBLE_ANGLE_DEGREES, 0.0, 30.0),
_sandbox_theme.number("effects.impact_wobble.sideways_offset", PropBlock.DEFAULT_WOBBLE_OFFSET_LOCAL, 0.0, 24.0)
)
prop.collided.connect(_on_prop_collided.bind(prop))
if not prop.physics_profile_changed.is_connected(_on_prop_physics_profile_changed):
prop.physics_profile_changed.connect(_on_prop_physics_profile_changed)
@@ -2864,6 +2893,7 @@ func _build_prop_settings_view(ui: CanvasLayer) -> void:
_prop_settings_view.configure(_sandbox_theme as SandboxTheme, TOP_BAR_HEIGHT, STATUS_BAR_HEIGHT)
_prop_settings_view.role_choice_requested.connect(_on_prop_role_choice)
_prop_settings_view.role_toggle_requested.connect(_on_prop_role_toggle)
_prop_settings_view.wobble_toggled.connect(_on_prop_wobble_toggled)
_prop_settings_view.spot_add_requested.connect(_on_spot_add_requested)
_prop_settings_view.spot_move_requested.connect(_on_spot_move_requested)
_prop_settings_view.spot_rename_requested.connect(_on_spot_rename_requested)
@@ -3002,6 +3032,29 @@ func _on_prop_role_toggle(enabled: bool, key: String) -> void:
_refresh_prop_settings_card()
func _on_prop_wobble_toggled(enabled: bool) -> void:
var prop: PropBlock = _selection.get_primary() as PropBlock if _selection != null else null
if prop == null or not is_instance_valid(prop) or current_mode != StageMode.EDIT:
return
prop.set_wobble_on_impact(enabled)
if enabled:
_cancel_edit_wobble_preview()
prop.preview_wobble()
_edit_wobble_preview_prop = prop
else:
_edit_wobble_preview_prop = null
_save_object_state(prop)
_mark_scene_dirty()
_refresh_prop_settings_card()
func _cancel_edit_wobble_preview() -> void:
var prop: PropBlock = _edit_wobble_preview_prop
_edit_wobble_preview_prop = null
if prop != null and is_instance_valid(prop):
prop.cancel_wobble()
func _on_spot_add_requested() -> void:
var prop: PropBlock = _selection.get_primary() as PropBlock if _selection != null else null
if current_mode == StageMode.EDIT and prop != null and is_instance_valid(prop):
@@ -3846,6 +3899,7 @@ func _on_grid_size_changed(value: float) -> void:
func _on_selection_changed(nodes: Array[Node2D]) -> void:
_cancel_edit_wobble_preview()
if _spot_edit_active and (_spot_edit_prop == null or not is_instance_valid(_spot_edit_prop) or not nodes.has(_spot_edit_prop)):
_cancel_spot_work()
_gizmos.set_targets(nodes)
@@ -4654,7 +4708,10 @@ func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: Pr
# authoritative snap_to_standing() cleanup has run.
if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready:
return
if other == null or not is_instance_valid(other) or _rule_engine == null:
if other == null or not is_instance_valid(other):
return
_route_impact_contact(prop, other, normal, position)
if _rule_engine == null:
return
if not _rule_engine.register_contact(prop, other):
return
@@ -4679,6 +4736,8 @@ func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: Pr
func _on_prop_separated(other: Node, prop: PropBlock) -> void:
if _impact_resolver != null and other != null and is_instance_valid(other):
_impact_resolver.end_contact(prop, other)
if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready:
return
if other == null or not is_instance_valid(other) or _rule_engine == null:
@@ -4692,12 +4751,157 @@ func _on_prop_separated(other: Node, prop: PropBlock) -> void:
_rule_engine.unregister_rig_body_contact(rig, prop, other)
func _route_impact_contact(prop: PropBlock, other: Node, normal: Vector2, position: Vector2) -> void:
if _impact_resolver == null or prop == null or other == null:
return
var receiver_velocity: Vector2 = prop.get_impact_velocity()
var other_velocity: Vector2 = _impact_other_velocity(other)
var sample: Dictionary = {
"receiver": prop,
"other": other,
"normal": normal,
"position": position,
"receiver_velocity": receiver_velocity,
"other_velocity": other_velocity,
"source": &"body_contact",
}
if other.has_meta("stickman_rig"):
sample["rig"] = other.get_meta("stickman_rig")
if _impact_resolver.begin_contact(sample, Engine.get_physics_frames()):
prop.play_impact_wobble()
if other is PropBlock:
(other as PropBlock).play_impact_wobble()
func _on_blocking_prop_sweep_hit(
rig: StickmanRig,
prop: PropBlock,
normal: Vector2,
position: Vector2,
requested_velocity: Vector2
) -> void:
if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready:
return
if rig == null or prop == null or not is_instance_valid(rig) or not is_instance_valid(prop):
return
var sample: Dictionary = {
"receiver": prop,
"other": rig,
"rig": rig,
"normal": normal,
"position": position,
"receiver_velocity": prop.get_impact_velocity(),
"other_velocity": requested_velocity,
"source": &"blocking_sweep",
}
if _impact_resolver != null and _impact_resolver.note_sweep(sample, Engine.get_physics_frames()):
prop.play_impact_wobble()
func _impact_other_velocity(other: Node) -> Vector2:
if other is PropBlock:
return (other as PropBlock).get_impact_velocity()
var rig: StickmanRig = other.get_meta("stickman_rig") as StickmanRig if other.has_meta("stickman_rig") else null
if other.has_meta("stickman_ragdoll_body") and bool(other.get_meta("stickman_ragdoll_body")):
return rig.ragdoll_body_velocity(other as RigidBody2D) if rig != null and other is RigidBody2D else Vector2.ZERO
if rig != null and other is PhysicsBody2D:
return rig.collision_proxy_velocity(other as PhysicsBody2D)
if other is RigidBody2D and not (other as RigidBody2D).freeze:
var velocity: Vector2 = (other as RigidBody2D).linear_velocity
return velocity if velocity.is_finite() else Vector2.ZERO
return Vector2.ZERO
func _seed_impact_contact_baseline() -> void:
if _impact_resolver == null or _world == null:
return
var tick: int = Engine.get_physics_frames()
for node: Node in _world.get_children():
if not node is PropBlock or not is_instance_valid(node):
continue
var prop: PropBlock = node as PropBlock
var direct_state: PhysicsDirectBodyState2D = PhysicsServer2D.body_get_direct_state(prop.get_rid())
if direct_state == null:
continue
for contact_index: int in direct_state.get_contact_count():
var collider_id: int = direct_state.get_contact_collider_id(contact_index)
var collider_object: Object = instance_from_id(collider_id)
if not collider_object is Node:
continue
var other: Node = collider_object as Node
var contact_normal: Vector2 = direct_state.get_contact_local_normal(contact_index)
if contact_normal.is_zero_approx():
contact_normal = other.global_position - prop.global_position if other is Node2D else Vector2.RIGHT
var sample: Dictionary = {
"receiver": prop,
"other": other,
"normal": contact_normal,
"position": direct_state.get_contact_local_position(contact_index),
"receiver_velocity": Vector2.ZERO,
"other_velocity": Vector2.ZERO,
"source": &"baseline",
}
if other.has_meta("stickman_rig"):
sample["rig"] = other.get_meta("stickman_rig")
_impact_resolver.begin_contact(sample, tick)
func _on_rig_state_changed(new_state: int, rig: StickmanRig) -> void:
if _impact_resolver != null:
_impact_resolver.end_rig_contacts(rig)
if new_state == StickmanRig.RigState.RAGDOLL:
_connect_ragdoll_impact_contacts(rig)
var rig_ids: Array[int] = [rig.get_instance_id()]
_purge_collision_pairs_for_ids(rig_ids)
func _connect_ragdoll_impact_contacts(rig: StickmanRig) -> void:
if rig == null or not is_instance_valid(rig):
return
for body: RigidBody2D in rig.get_ragdoll_bodies():
if body == null or not is_instance_valid(body):
continue
body.contact_monitor = true
body.max_contacts_reported = maxi(body.max_contacts_reported, 8)
var entered_callback: Callable = Callable(self, "_on_ragdoll_body_entered").bind(rig, body)
if not body.body_entered.is_connected(entered_callback):
body.body_entered.connect(entered_callback)
var exited_callback: Callable = Callable(self, "_on_ragdoll_body_exited").bind(rig, body)
if not body.body_exited.is_connected(exited_callback):
body.body_exited.connect(exited_callback)
func _on_ragdoll_body_entered(other: Node, rig: StickmanRig, body: RigidBody2D) -> void:
if current_mode != StageMode.PLAY or _run_state != RunState.RUNNING or not _event_dispatch_ready:
return
if rig == null or body == null or other == null:
return
if not is_instance_valid(rig) or not is_instance_valid(body) or not is_instance_valid(other):
return
if not other is PropBlock:
return
var prop: PropBlock = other as PropBlock
var normal: Vector2 = body.global_position - prop.global_position
if normal.is_zero_approx():
normal = Vector2.UP
var position: Vector2 = (body.global_position + prop.global_position) * 0.5
var direct_state: PhysicsDirectBodyState2D = PhysicsServer2D.body_get_direct_state(body.get_rid())
if direct_state != null:
for contact_index: int in direct_state.get_contact_count():
if direct_state.get_contact_collider_id(contact_index) != prop.get_instance_id():
continue
position = body.to_global(direct_state.get_contact_local_position(contact_index))
break
_route_impact_contact(prop, body, normal.normalized(), position)
func _on_ragdoll_body_exited(other: Node, _rig: StickmanRig, body: RigidBody2D) -> void:
if _impact_resolver == null or other == null or not is_instance_valid(other):
return
if other is PropBlock:
_impact_resolver.end_contact(other as PropBlock, body)
func _purge_collision_pairs_for_ids(ids: Array[int]) -> void:
if _rule_engine != null:
_rule_engine.purge_collision_pairs_for_ids(ids)
@@ -10,6 +10,7 @@ const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
signal role_choice_requested(key: String, value: String)
signal role_toggle_requested(enabled: bool, key: String)
signal wobble_toggled(enabled: bool)
signal spot_add_requested
signal spot_move_requested(spot_id: String)
signal spot_rename_requested(spot_id: String, name: String)
@@ -26,6 +27,7 @@ var _fields: VBoxContainer
var _notice: Label
var _depth_group: StagePanelChoiceGroup
var _motion_group: StagePanelChoiceGroup
var _wobble_row: StagePanelToggleRow
var _controls: Dictionary = {}
var _spot_rows: VBoxContainer
var _spot_empty_label: Label
@@ -193,6 +195,7 @@ func _build() -> void:
_controls["blocks_stickmen"] = _make_toggle_row(_fields, "blocks_stickmen", "Blocks stickmen")
_controls["can_pick_up"] = _make_toggle_row(_fields, "can_pick_up", "Stickmen can pick it up")
_add_release_options()
_add_wobble_section()
_add_use_spots_section()
_reopen_button = Button.new()
@@ -239,6 +242,19 @@ func _add_use_spots_section() -> void:
section.add_child(_spot_rows)
func _add_wobble_section() -> void:
_add_heading(_fields, "When bumped")
_wobble_row = STAGE_PANEL_TOGGLE_ROW.new() as StagePanelToggleRow
_wobble_row.name = "WobbleWhenBumped"
_wobble_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_wobble_row.configure("Wobble when bumped")
_wobble_row.set_help_text("A hard bump makes this prop wobble once in PLAY.")
_wobble_row.apply_theme(_stage_theme)
_fields.add_child(_wobble_row)
_wobble_row.preferred_height_changed.connect(_on_row_height_changed)
_wobble_row.toggled.connect(_on_wobble_toggled)
func _sync_spots(spots: Array[Dictionary], selected_spot_id: String) -> void:
if _spot_rows == null:
return
@@ -390,6 +406,9 @@ func _sync_roles(prop: PropBlock, roles: Dictionary) -> void:
else:
_depth_group.select_no_signal(StringName(String(roles.get("depth", PROP_STAGE_ROLES.DEPTH_BEHIND))))
_motion_group.select_no_signal(StringName(String(roles.get("start_motion", PROP_STAGE_ROLES.START_STILL))))
if _wobble_row != null:
_wobble_row.set_checked_no_signal(prop.wobble_on_impact)
_wobble_row.set_help_text("A hard bump makes this prop wobble once in PLAY.")
for key: String in ["holds_other_props", "blocks_stickmen", "can_pick_up", "released_blocks_stickmen", "released_bumps_other_props"]:
var control := _controls.get(key) as StagePanelToggleRow
if control == null:
@@ -453,6 +472,10 @@ func _on_role_toggled(enabled: bool, key: String) -> void:
role_toggle_requested.emit(enabled, key)
func _on_wobble_toggled(enabled: bool) -> void:
wobble_toggled.emit(enabled)
func _on_row_height_changed() -> void:
if _card != null:
_card.request_fit()
+65 -4
View File
@@ -10,6 +10,8 @@ const STATE_RUNNING: int = 1
const STATE_PAUSED: int = 2
## Set true temporarily when diagnosing authored/play prop scale transfer.
const PROP_SCALE_TRACE_ENABLED: bool = false
## Temporary, low-volume diagnostics for props that should block stickmen.
const ACTOR_PROP_DEBUG_ENABLED: bool = true
var run_state: int = STATE_STOPPED
var elapsed_seconds: float = 0.0
@@ -44,14 +46,18 @@ func start_run(
for node: Node2D in world_nodes:
if node is PropBlock:
var prop: PropBlock = node as PropBlock
prop.cancel_wobble()
trace_prop_scale(prop, "before_play_scale_transfer", stage_mode_name, "RUNNING", camera_zoom)
prop.begin_play_instance_scale()
trace_prop_scale(prop, "after_play_scale_transfer", stage_mode_name, "RUNNING", camera_zoom)
var start_role: PropBlock.RuntimeRole = PropBlock.RuntimeRole.START_STILL if String(prop.stage_roles.get("start_motion", PROP_STAGE_ROLES.START_STILL)) == PROP_STAGE_ROLES.START_STILL else PropBlock.RuntimeRole.START_PHYSICS
prop.apply_runtime_role(start_role)
if ACTOR_PROP_DEBUG_ENABLED:
_debug_actor_blocking_prop(prop)
trace_prop_scale(prop, "after_play_role_applied", stage_mode_name, "RUNNING", camera_zoom)
elif node is STICKMAN_RIG_SCRIPT:
var rig: StickmanRig = node as StickmanRig
rig.reset_collision_proxy_velocity_baselines()
rig.reset_simulation_pause()
rig.stop_queue()
rig.clear_reactive_actions()
@@ -60,6 +66,44 @@ func start_run(
rig.start_queue()
elif node is RigidBody2D:
(node as RigidBody2D).freeze = false
if node is PropBlock:
(node as PropBlock).reset_impact_velocity_baseline()
func _debug_actor_blocking_prop(prop: PropBlock) -> void:
if prop == null or not is_instance_valid(prop):
return
var role_index: int = clampi(int(prop.runtime_role), 0, PropBlock.RuntimeRole.keys().size() - 1)
var collision_nodes := PackedStringArray()
for child: Node in prop.get_children():
if child is CollisionPolygon2D:
var polygon: CollisionPolygon2D = child as CollisionPolygon2D
collision_nodes.append(
"CollisionPolygon2D(disabled=%s,points=%d,global=%s)"
% [str(polygon.disabled), polygon.polygon.size(), str(polygon.global_transform)]
)
elif child is CollisionShape2D:
var shape: CollisionShape2D = child as CollisionShape2D
collision_nodes.append(
"CollisionShape2D(disabled=%s,type=%s,global=%s)"
% [str(shape.disabled), shape.shape.get_class() if shape.shape != null else "null", str(shape.global_transform)]
)
print(
"[PropCollisionDebug][PlayProp] id=%s asset=%s role=%s blocking=%s roles=%s layer=%d mask=%d freeze=%s pos=%s scale=%s collision=%s"
% [
prop.stage_object_id,
prop.spawn_id,
PropBlock.RuntimeRole.keys()[role_index],
str(prop.is_actor_blocking()),
str(prop.stage_roles),
prop.collision_layer,
prop.collision_mask,
str(prop.freeze),
str(prop.global_position),
str(prop.global_scale),
"; ".join(collision_nodes),
]
)
func pause_run(world_nodes: Array[Node2D]) -> bool:
@@ -69,7 +113,11 @@ func pause_run(world_nodes: Array[Node2D]) -> bool:
run_state = STATE_PAUSED
resume_baseline_frames = 0
for node: Node2D in world_nodes:
if node is STICKMAN_RIG_SCRIPT:
if node is PropBlock:
var prop: PropBlock = node as PropBlock
prop.set_wobble_paused(true)
capture_prop_pause_state(prop)
elif node is STICKMAN_RIG_SCRIPT:
(node as StickmanRig).pause_simulation()
elif node is RigidBody2D:
capture_prop_pause_state(node as RigidBody2D)
@@ -91,8 +139,13 @@ func resume_run(world_nodes: Array[Node2D], rule_engine: StageRuleEngine) -> boo
body.sleeping = bool(snapshot.get("sleeping", false))
prop_pause_states.clear()
for node: Node2D in world_nodes:
if node is STICKMAN_RIG_SCRIPT:
(node as StickmanRig).resume_simulation()
if node is PropBlock:
(node as PropBlock).set_wobble_paused(false)
(node as PropBlock).reset_impact_velocity_baseline()
elif node is STICKMAN_RIG_SCRIPT:
var rig: StickmanRig = node as StickmanRig
rig.reset_collision_proxy_velocity_baselines()
rig.resume_simulation()
run_state = STATE_RUNNING
event_dispatch_ready = false
resume_baseline_frames = 2
@@ -127,6 +180,7 @@ func reset_world(
var body: RigidBody2D = node as RigidBody2D
if node is PropBlock:
var prop: PropBlock = node as PropBlock
prop.cancel_wobble()
var authored: Dictionary = authored_state.get(prop.get_instance_id(), {})
trace_prop_scale(prop, "before_stop_scale_restore", stage_mode_name, "STOPPED", camera_zoom)
prop.set_stage_roles(authored.get("roles", prop.stage_roles))
@@ -185,7 +239,9 @@ func save_object_state(node: Node2D) -> void:
if not node is STICKMAN_RIG_SCRIPT:
entry["scale"] = node.scale
if node is PropBlock:
entry["roles"] = (node as PropBlock).stage_roles.duplicate(true)
var prop: PropBlock = node as PropBlock
entry["roles"] = prop.stage_roles.duplicate(true)
entry["wobble_on_impact"] = prop.wobble_on_impact
authored_state[node.get_instance_id()] = entry
@@ -210,13 +266,18 @@ func restore_authored_state(stage_mode_name: String, run_state_name: String, cam
trace_prop_scale(node as PropBlock, "after_authored_scale_write", stage_mode_name, run_state_name, camera_zoom)
if node is STICKMAN_RIG_SCRIPT and entry.has("facing_profile"):
(node as STICKMAN_RIG_SCRIPT).set_facing_profile(int(entry["facing_profile"]))
if node is STICKMAN_RIG_SCRIPT:
(node as StickmanRig).reset_collision_proxy_velocity_baselines()
if node is RigidBody2D:
var body: RigidBody2D = node as RigidBody2D
body.linear_velocity = Vector2.ZERO
body.angular_velocity = 0.0
if node is PropBlock:
var prop: PropBlock = node as PropBlock
prop.cancel_wobble()
prop.set_stage_roles(entry.get("roles", prop.stage_roles))
prop.set_wobble_on_impact(bool(entry.get("wobble_on_impact", prop.wobble_on_impact)))
prop.reset_impact_velocity_baseline()
prop.apply_runtime_role(PropBlock.RuntimeRole.EDIT)
@@ -60,6 +60,7 @@ func collect(
entry["roles"] = prop.stage_roles.duplicate(true)
entry["use_spots"] = PROP_USE_SPOT_DATA.encode(prop.get_use_spots())
entry["next_use_spot_number"] = prop.next_use_spot_number
entry["wobble_on_impact"] = prop.wobble_on_impact
var prop_scale: Vector2 = authored_state.get("scale", prop.scale)
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale)
entry["material_preset"] = prop.material_preset
@@ -175,6 +176,7 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
if node is PropBlock:
var prop: PropBlock = node as PropBlock
prop.stage_object_id = String(entry.get("stage_object_id", ""))
prop.set_wobble_on_impact(bool(entry.get("wobble_on_impact", false)))
var decoded_spots: Array[Dictionary] = PROP_USE_SPOT_DATA.decode(entry.get("use_spots", []) as Array)
prop.set_use_spots(decoded_spots, int(entry.get("next_use_spot_number", 1)))
var default_roles: Dictionary = PROP_STAGE_ROLES.new_placement(prop.has_usable_grip())
@@ -212,6 +214,7 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
(node as StickmanRig).queue_action(action)
else:
warnings.append("%s: invalid action queue. Open this character's queue and review its actions." % file_id)
(node as StickmanRig).reset_collision_proxy_velocity_baselines()
loaded_nodes.append(node)
file_to_runtime[file_id] = node.get_instance_id()
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable)
+154
View File
@@ -38,6 +38,7 @@ var use_spots: Array[Dictionary] = []
var next_use_spot_number: int = 1
var stage_roles: Dictionary = {}
var runtime_role: RuntimeRole = RuntimeRole.EDIT
var wobble_on_impact: bool = false
# ---------------------------------------------------------------------------
# Child node names
@@ -55,6 +56,9 @@ const DEFAULT_FILL_COLOR := Color(0.55, 0.35, 0.15, 1.0)
const DEFAULT_OUTLINE_COLOR := Color(0.15, 0.08, 0.02, 1.0)
const DEFAULT_OUTLINE_WIDTH: float = 2.0
const MAX_CONTACTS_REPORTED := 32
const DEFAULT_WOBBLE_DURATION: float = 0.48
const DEFAULT_WOBBLE_ANGLE_DEGREES: float = 7.0
const DEFAULT_WOBBLE_OFFSET_LOCAL: float = 4.0
# ---------------------------------------------------------------------------
# Exported properties
@@ -114,6 +118,16 @@ var _visual_local_bounds := Rect2()
var _custom_collision_hull: PackedVector2Array = PackedVector2Array()
var _play_instance_scale: Vector2 = Vector2.ONE
var _play_scale_active: bool = false
var _wobble_duration: float = DEFAULT_WOBBLE_DURATION
var _wobble_angle_degrees: float = DEFAULT_WOBBLE_ANGLE_DEGREES
var _wobble_offset_local: float = DEFAULT_WOBBLE_OFFSET_LOCAL
var _wobble_elapsed: float = 0.0
var _wobble_active: bool = false
var _wobble_paused: bool = false
var _wobble_center: Vector2 = Vector2.ZERO
var _wobble_baselines: Dictionary = {}
var _last_physics_velocity: Vector2 = Vector2.ZERO
var _has_physics_velocity_sample: bool = false
# ---------------------------------------------------------------------------
# Signals
@@ -144,6 +158,144 @@ func _ready() -> void:
body_entered.connect(_on_body_entered)
if not body_exited.is_connected(_on_body_exited):
body_exited.connect(_on_body_exited)
reset_impact_velocity_baseline()
func _process(delta: float) -> void:
if Engine.is_editor_hint() or not _wobble_active or _wobble_paused:
return
_wobble_elapsed = minf(_wobble_elapsed + maxf(delta, 0.0), _wobble_duration)
var progress: float = clampf(_wobble_elapsed / _wobble_duration, 0.0, 1.0)
if progress >= 1.0:
cancel_wobble()
return
var envelope: float = (1.0 - progress) * (1.0 - progress)
var phase: float = TAU * 2.5 * progress
var angle: float = deg_to_rad(_wobble_angle_degrees) * sin(phase) * envelope
var offset: float = _wobble_offset_local * cos(phase) * envelope
_apply_wobble_pose(angle, offset)
func _physics_process(_delta: float) -> void:
if not freeze and runtime_role in [RuntimeRole.START_PHYSICS, RuntimeRole.RELEASED]:
_last_physics_velocity = linear_velocity
_has_physics_velocity_sample = _last_physics_velocity.is_finite()
func _exit_tree() -> void:
cancel_wobble()
func set_wobble_on_impact(enabled: bool) -> void:
wobble_on_impact = enabled
if not enabled:
cancel_wobble()
func set_wobble_style(duration: float, angle_degrees: float, offset_local: float) -> void:
if not is_finite(duration) or not is_finite(angle_degrees) or not is_finite(offset_local):
return
_wobble_duration = maxf(0.05, duration)
_wobble_angle_degrees = maxf(0.0, angle_degrees)
_wobble_offset_local = maxf(0.0, offset_local)
func preview_wobble() -> void:
_start_wobble()
func play_impact_wobble() -> void:
if wobble_on_impact:
_start_wobble()
func cancel_wobble() -> void:
for raw_node: Variant in _wobble_baselines.keys():
var node: Node2D = raw_node as Node2D
if node == null or not is_instance_valid(node):
continue
var baseline: Dictionary = _wobble_baselines[raw_node]
node.position = baseline["position"] as Vector2
node.rotation = float(baseline["rotation"])
node.scale = baseline["scale"] as Vector2
_wobble_baselines.clear()
_wobble_active = false
_wobble_paused = false
_wobble_elapsed = 0.0
func set_wobble_paused(paused: bool) -> void:
_wobble_paused = paused and _wobble_active
func get_impact_velocity() -> Vector2:
if runtime_role in [RuntimeRole.EDIT, RuntimeRole.START_STILL, RuntimeRole.HELD] or freeze:
return Vector2.ZERO
if _has_physics_velocity_sample and _last_physics_velocity.is_finite():
return _last_physics_velocity
return linear_velocity if linear_velocity.is_finite() else Vector2.ZERO
func reset_impact_velocity_baseline() -> void:
_last_physics_velocity = linear_velocity if linear_velocity.is_finite() else Vector2.ZERO
_has_physics_velocity_sample = true
func _start_wobble() -> void:
cancel_wobble()
_capture_wobble_baselines()
if _wobble_baselines.is_empty():
return
_wobble_active = true
_wobble_elapsed = 0.0
_apply_wobble_pose(0.0, _wobble_offset_local)
func _capture_wobble_baselines() -> void:
var bounds: Dictionary = {"min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF}
for child_name: String in [POLYGON_NODE_NAME, OUTLINE_NODE_NAME, "Visuals"]:
var node: Node2D = get_node_or_null(NodePath(child_name)) as Node2D
if node == null or not node.visible:
continue
_wobble_baselines[node] = {"position": node.position, "rotation": node.rotation, "scale": node.scale}
_collect_artwork_bounds(node, bounds)
if float(bounds["min_x"]) <= float(bounds["max_x"]):
_wobble_center = Vector2(
(float(bounds["min_x"]) + float(bounds["max_x"])) * 0.5,
(float(bounds["min_y"]) + float(bounds["max_y"])) * 0.5
)
else:
_wobble_center = get_use_spot_local_bounds().get_center()
func _collect_artwork_bounds(node: Node, bounds: Dictionary) -> void:
if node is Polygon2D:
for point: Vector2 in (node as Polygon2D).polygon:
_accumulate_artwork_point(to_local((node as Polygon2D).to_global(point)), bounds)
elif node is Line2D:
for point: Vector2 in (node as Line2D).points:
_accumulate_artwork_point(to_local((node as Line2D).to_global(point)), bounds)
for child: Node in node.get_children():
_collect_artwork_bounds(child, bounds)
func _accumulate_artwork_point(point: Vector2, bounds: Dictionary) -> void:
bounds["min_x"] = minf(float(bounds["min_x"]), point.x)
bounds["min_y"] = minf(float(bounds["min_y"]), point.y)
bounds["max_x"] = maxf(float(bounds["max_x"]), point.x)
bounds["max_y"] = maxf(float(bounds["max_y"]), point.y)
func _apply_wobble_pose(angle: float, offset: float) -> void:
for raw_node: Variant in _wobble_baselines:
var node: Node2D = raw_node as Node2D
if node == null or not is_instance_valid(node):
continue
var baseline: Dictionary = _wobble_baselines[raw_node]
var base_position: Vector2 = baseline["position"] as Vector2
node.position = _wobble_center + (base_position - _wobble_center).rotated(angle) + Vector2(offset, 0.0)
node.rotation = float(baseline["rotation"]) + angle
node.scale = baseline["scale"] as Vector2
func set_stage_roles(roles: Dictionary) -> void:
@@ -507,6 +659,7 @@ func get_interaction_scale() -> Vector2:
## the authored uniform scale into child visuals and collision geometry for
## PLAY, then restore the root scale on return to EDIT for scene persistence.
func begin_play_instance_scale() -> void:
cancel_wobble()
if _play_scale_active:
return
_play_instance_scale = scale
@@ -518,6 +671,7 @@ func begin_play_instance_scale() -> void:
_sync_transform_to_physics_server()
func end_play_instance_scale() -> void:
cancel_wobble()
if not _play_scale_active:
return
_play_scale_active = false
+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},
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}, "comic": {"label": "Comic", "path": "res://resources/fonts/comic.ttf", "speech_base_size": 28}, "pixel": {"label": "Pixel", "path": "res://resources/fonts/pixel.ttf", "speech_base_size": 40}, "scary": {"label": "Scary", "path": "res://resources/fonts/scary.ttf", "speech_base_size": 28}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "timestamp_badge": 22, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
"mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"},
"effects": {"impact_wobble": {"duration": 0.48, "maximum_angle_degrees": 7.0, "sideways_offset": 4.0}},
"grid": {"snap_size": 15.0},
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
+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
## scene-tree dependencies, which makes rejected documents non-destructive.
const FILE_VERSION: String = "1.3"
const FILE_VERSION: String = "1.4"
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
@@ -167,9 +167,11 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
prop_entry["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
prop_entry["use_spots"] = prop_entry.get("use_spots", [])
prop_entry["next_use_spot_number"] = prop_entry.get("next_use_spot_number", 1)
if not prop_entry.has("wobble_on_impact"):
prop_entry["wobble_on_impact"] = false
_assign_legacy_depths(current.get("objects", []))
return current
if version not in ["1.0", "1.1", "1.2"]:
if version not in ["1.0", "1.1", "1.2", "1.3"]:
return {}
var migrated: Dictionary = data.duplicate(true)
for raw: Variant in migrated.get("objects", []):
@@ -183,6 +185,8 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
object["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
object["use_spots"] = object.get("use_spots", [])
object["next_use_spot_number"] = object.get("next_use_spot_number", 1)
if not object.has("wobble_on_impact"):
object["wobble_on_impact"] = false
migrated["version"] = FILE_VERSION
_assign_legacy_depths(migrated.get("objects", []))
return migrated
@@ -222,6 +226,8 @@ static func _validate_root(data: Dictionary) -> Dictionary:
if file_id.is_empty() or seen.has(file_id): return {"ok": false, "error": "Object file IDs must be unique and non-empty."}
seen[file_id] = true
if String(entry.get("type", "")) == "prop":
if entry.has("wobble_on_impact") and not entry["wobble_on_impact"] is bool:
return {"ok": false, "error": "Prop wobble_on_impact must be true or false."}
var raw_stage_id: Variant = entry.get("stage_object_id", "")
if not raw_stage_id is String:
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty strings."}
+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"]))
var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
if poly != null and not poly.polygon.is_empty():
var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO)
var rect := Rect2(poly.to_global(poly.polygon[0]), Vector2.ZERO)
for p: Vector2 in poly.polygon:
rect = rect.expand(node.to_global(p))
rect = rect.expand(poly.to_global(p))
return rect
var body := node.get_node_or_null(NodePath("Body")) as Node2D
if body != null: