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:
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}},
|
||||
|
||||
@@ -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
|
||||
@@ -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."}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user