class_name KickSystem # Deduplication: each (A,B) pair resolved once per physics frame static var _frame: int = -1 static var _pairs: Dictionary = {} # Called by a flying kickable when it detects another kickable. # Returns true if a merge happened (both objects will despawn). static func resolve(owner: Node3D, other: Node3D, owner_vel: Vector3) -> bool: var f := Engine.get_physics_frames() if f != _frame: _frame = f _pairs.clear() var id_lo := mini(owner.get_instance_id(), other.get_instance_id()) var id_hi := maxi(owner.get_instance_id(), other.get_instance_id()) var key := str(id_lo) + "_" + str(id_hi) if _pairs.has(key): return false _pairs[key] = true var other_vel: Vector3 = other.get("fly_vel") if other.get("fly_vel") != null else Vector3.ZERO var speed_a := Vector2(owner_vel.x, owner_vel.z).length() var speed_b := Vector2(other_vel.x, other_vel.z).length() var collision_speed := speed_a + speed_b # ── 1. Recipe merge (MergeRecipes) ──────────────────────────────────────── var kt_a: String = owner.get("kickable_type") if owner.get("kickable_type") != null else "" var kt_b: String = other.get("kickable_type") if other.get("kickable_type") != null else "" if kt_a != "" and kt_b != "": var recipe: Dictionary = MergeRecipes.find(kt_a, kt_b, collision_speed) if not recipe.is_empty(): _execute_recipe(owner, other, recipe) return true # ── 2. Merge (enemy-to-enemy) ───────────────────────────────────────────── if owner.has_method("can_merge_with") and owner.call("can_merge_with", other, collision_speed): owner.call("do_merge_with", other) return true # ── 3. Damage ───────────────────────────────────────────────────────────── var mod_a: float = owner.get("damage_modifier") if owner.get("damage_modifier") != null else 0.0 var mod_b: float = other.get("damage_modifier") if other.get("damage_modifier") != null else 0.0 if is_instance_valid(other) and other.has_method("apply_collision_damage") and mod_a > 0.0: other.call("apply_collision_damage", collision_speed * mod_a) if is_instance_valid(owner) and owner.has_method("apply_collision_damage") and mod_b > 0.0: owner.call("apply_collision_damage", collision_speed * mod_b) return false static func _execute_recipe(a: Node3D, b: Node3D, recipe: Dictionary) -> void: var pos := (a.global_position + b.global_position) * 0.5 var parent := a.get_parent() # Emit before freeing so respawn counters in Main decrement correctly. if a.has_signal("destroyed"): a.emit_signal("destroyed") if b.has_signal("destroyed"): b.emit_signal("destroyed") a.queue_free() b.queue_free() var scene: PackedScene = load(recipe["result_scene"]) if scene == null or parent == null: return var result := scene.instantiate() as Node3D parent.add_child(result) result.global_position = pos FX.merge_smoke(pos + Vector3(0, 0.3, 0), parent) SFX.merge(parent) parent.get_tree().create_timer(30.0).connect("timeout", result.queue_free)