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