class_name DamageEmitter extends Area2D @export var damage := 10 @export var hit_type: StringName = &"normal" @export var base_knockback := Vector2(120.0, 304.056) var action_context: Resource var judgement_context: Dictionary = {} ## Weak-state minion attacks only deal damage; interrupt authority snapshots at ## action start so phase changes mid-swing do not rewrite the hit. var attacker_interrupts := true var _default_position := Vector2.ZERO var _default_shape_size := Vector2.ZERO # With per-frame hit windows (FrameCollisionDriver) the same receiver can # enter the area more than once within one swing; each swing hits once. var _already_hit_ids: Dictionary = {} func _ready() -> void: _default_position = position var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D if shape_node != null and shape_node.shape is RectangleShape2D: _default_shape_size = (shape_node.shape as RectangleShape2D).size area_entered.connect(_on_area_entered) func configure_hit(action: Resource, judgement: Dictionary) -> void: action_context = action judgement_context = judgement.duplicate() attacker_interrupts = _owner_interrupt_authority() _already_hit_ids.clear() if action != null: hit_type = StringName(str(action.get("hit_type"))) _update_hitbox_geometry(action) monitoring = true func clear_hit() -> void: monitoring = false action_context = null judgement_context = {} attacker_interrupts = true _already_hit_ids.clear() func _owner_interrupt_authority() -> bool: var owner := get_parent() if owner != null and owner.has_method("is_strong_in_current_time_phase"): return bool(owner.call("is_strong_in_current_time_phase")) return true func has_already_hit(receiver: Node) -> bool: return receiver != null and _already_hit_ids.has(receiver.get_instance_id()) func _update_hitbox_geometry(action: Resource) -> void: if action == null: position = _default_position return var range := float(action.get("range")) if range <= 0.0: position = _default_position return var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D if shape_node != null and shape_node.shape is RectangleShape2D: var rectangle := shape_node.shape as RectangleShape2D var height := _default_shape_size.y if _default_shape_size != Vector2.ZERO else rectangle.size.y rectangle.size = Vector2(maxf(8.0, range), maxf(8.0, height)) var owner := get_parent() var direction := 1.0 if owner != null: var heading = owner.get("heading") if heading is Vector2 and absf((heading as Vector2).x) > 0.0: direction = -1.0 if (heading as Vector2).x < 0.0 else 1.0 var base_y := _default_position.y position = Vector2(direction * (range * 0.5 + 10.0), base_y) func _on_area_entered(receiver: Area2D) -> void: if receiver.is_in_group("damage_receivers"): if _already_hit_ids.has(receiver.get_instance_id()): return _already_hit_ids[receiver.get_instance_id()] = true var combat := _combat_manager_or_null() if combat != null and combat.has_method("resolve_hit"): var result: Dictionary = combat.call("resolve_hit", self, receiver) _event_bus().emit_signal("damage_dealt", receiver, int(result.get("damage", 0)), hit_type) func _event_bus() -> Node: var root := get_tree().root var bus := root.get_node_or_null("EventBus") if bus == null: bus = load("res://autoload/event_bus.gd").new() bus.name = "EventBus" root.add_child(bus) return bus func _combat_manager_or_null() -> Node: if not is_inside_tree(): return null return get_tree().root.get_node_or_null("CombatManager")