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