Files
Bullosseum/systems/combat/hitbox.gd
T
richard 7b6a141767 Reorganize the codebase into role-based folders (snake_case)
Move the 40-odd root-level scripts and scenes into a tree that groups by role,
per the approved roadmap (WS2/WS3): actors/{player,matador,bear}, systems/{combat,
fx,input,debug,render,crowd,config}, ui/, props/ (was tscn_s/). Rename the shell
scene.tscn -> levels/game_shell.tscn and every PascalCase scene to snake_case
(Player.tscn -> player.tscn, MainMenu.tscn -> main_menu.tscn, etc.) so the
case-insensitive web/macOS filesystems can't alias two files together.

Done as a scripted move: each file travels with its .uid; every quoted res:// path
across scenes, scripts, .tres, and project.godot's 9 autoloads is rewritten; the
.godot uid/class cache is rebuilt from scratch. Scenes still resolve by uid, so the
rewrite is belt-and-suspenders. Lint globs (run_tests.sh + both CI workflows) now
walk the new dirs; an orphan animation_tree.gd.uid and 7 pre-existing trailing-
whitespace lints (barrel/torch, never covered by the old root-only glob) are cleaned.

Full suite green on both sides. Asset consolidation (Assets/Animations/fonts ->
art/) is deferred — moving imported .glb/.png/.ttf is safer done in the editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KoYBYQgRyY1zDHXMsXY3V
2026-09-06 00:04:41 +03:00

99 lines
3.4 KiB
GDScript

@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://systems/combat/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
var ku := _knock_up_for(body) if knock_up > 0.0 else 0.0
DamageEvent.deal(body, DamageEvent.cause_from(damage_cause), body.global_position, self, ku)
# 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