@tool extends Area3D class_name Hitbox ## A designer-placeable attack hitbox: drop this into a scene, give it a CollisionShape3D ## child, and drag the shape to size/position the attack's reach in the editor. The shape ## itself decides what got hit — no distance/facing math in code. ## ## Inactive by default; the owning script calls activate() for the swing's active window and ## deactivate() when it ends. Each activation only hits a given body once (the bull's own ## i-frames also collapse a flurry, but this keeps a single swing from re-triggering). const PhysicsLayers = preload("res://physics_layers.gd") ## How the hit is tagged on the bull's take_sword_hit ("mauled" / "gored" / …). @export var damage_cause: String = "mauled" ## Upward launch (m/s) applied to the bull on hit — e.g. an overhead smash that pops it up. @export var knock_up: float = 0.0 ## Knock-up distance shaping, measured planar (XZ) from this hitbox's own origin to the body: ## below `knock_up_min_dist` no pop is applied (leap slam: only the outer ring launches). If ## `knock_up_max_dist` > min, the pop ramps 0→`knock_up` from min to max (overhead smash: the ## further out the hit lands, the higher the bull flies); at/again 0 it's full past the min. @export var knock_up_min_dist: float = 0.0 @export var knock_up_max_dist: float = 0.0 var _active: bool = false var _hit_this_swing: Dictionary = {} # Whether the most recent active window connected. Cleared on activate(), NOT deactivate(), so the # owner can read it after the window closes (e.g. a landed stab breaks off to taunt). var _did_hit: bool = false func _ready() -> void: # Detect the bull's body (layer BULL) only; never collide or push physics. collision_layer = 0 collision_mask = PhysicsLayers.BULL monitorable = false monitoring = false if Engine.is_editor_hint(): return body_entered.connect(_on_body_entered) func activate() -> void: if _active: return _active = true _hit_this_swing.clear() _did_hit = false monitoring = true func deactivate() -> void: _active = false monitoring = false # Did the window that just closed connect? Valid until the next activate() clears it. func did_hit() -> bool: return _did_hit func _on_body_entered(body: Node3D) -> void: _apply_hit(body) # For AoE slams the bull is usually already standing inside the shape when it activates, so # body_entered never fires. The owner calls sweep() each active frame to catch current overlaps # (still deduped per activation, so the bull is only hit once per slam). func sweep() -> void: if not _active: return for body in get_overlapping_bodies(): _apply_hit(body) func _apply_hit(body: Node3D) -> void: if not _active or not body.is_in_group(&"player"): return if _hit_this_swing.has(body): return _hit_this_swing[body] = true _did_hit = true body.call(&"take_sword_hit", damage_cause) if knock_up > 0.0: var v := _knock_up_for(body) if v > 0.0: body.call(&"apply_knock_up", v) # Scale the pop by how far the body is from this hitbox's origin (planar). See the export docs. func _knock_up_for(body: Node3D) -> float: if knock_up_min_dist <= 0.0 and knock_up_max_dist <= 0.0: return knock_up var to := body.global_position - global_position var d := Vector2(to.x, to.z).length() if d < knock_up_min_dist: return 0.0 if knock_up_max_dist > knock_up_min_dist: var t := clampf((d - knock_up_min_dist) / (knock_up_max_dist - knock_up_min_dist), 0.0, 1.0) return knock_up * t return knock_up