Combat overhaul: charge/gore, bear ram, matador taunt reactions

Bull charge damage:
- Tunnel-proof swept detection (prev→now segment) for matador gore and bear
  ram, so a 66 m/s dash no longer skips clean over the thin HitArea.
- Horns-first cone (bull_gore_arc): running into an enemy only wounds it when
  charging roughly at it, not a sideways brush.
- Dash is a committed lunge: bypasses the horns cone AND the survival roll, so a
  dash connect is a guaranteed kill. Cruise charges keep a speed-scaled roll
  (mat_charge_pierce). Roll ability defers to its own pop (is_rolling guard).

Bear:
- Horns-first swept ram detection (edge-triggered: one charge = one wound);
  a tunnelling or point-blank charge now lands where body_entered wouldn't.
- Overhead smash lane extends faster (0.1s) and further (20 m).
- Leap slam launches the bull from anywhere in the circle (ring_frac 0).
- Removed stray-quote syntax errors that broke the script.

Matador:
- Collider-driven stab (BladeHitbox) with recovery beat + taunt-after-hit so it
  stops machine-gunning the bull.
- A taunting matador answers a charge: spear from range, sword up close.

Spear projectile masks the BULL layer so throws connect.

Tests: matador_charge/stab/spear/taunt_react, bear_ram/hitbox/fx (+ unified
_fake_bull stand-in). Trimmed verbose comments.

Note: bear claw-emitter/death work is still WIP (3 bear_boss_test failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 00:59:23 +03:00
parent 4efc6763f0
commit 5c1e881a53
39 changed files with 1555 additions and 376 deletions
+63 -39
View File
@@ -9,8 +9,11 @@ extends Node
## bodies get picked up within one interval.
##
## Toggle from the console:
## show_collisions · show_hitboxes · show_bones · show_raycasts (true / false)
## show_hitboxes also surfaces the bull's and bear's body capsules, not just Area3Ds
## show_hitboxes · show_attacks · show_bones · show_raycasts (true / false)
## show_hitboxes — the combatants' hurt volumes: each HitArea + the player/enemy body
## capsules, drawn as calm translucent-blue volumes (what abilities/blades hit)
## show_attacks — the attack hitboxes (bear claw/smash/leap, matador blade, thrown
## spear), filled translucent red and shown ONLY while the swing is actually live
## show_states — floating AI-state tags + velocity vectors over each matador
## show_animation_name — floating current-clip tag over every AnimationPlayer host
## show_stats — 2D corner readout: fps / frame ms / draw calls / matador tally
@@ -21,13 +24,14 @@ extends Node
const REBUILD_INTERVAL := 0.5
const STATS_INTERVAL := 0.2 # stats text is re-composed 5×/sec, not every frame
const COLOR_BODY := Color(0.30, 1.0, 0.45) # collision wireframes (green)
const COLOR_AREA := Color(0.0, 0.9, 1.0) # hit-area volumes (cyan, translucent)
const COLOR_AREA := Color(0.0, 0.9, 1.0) # combatant hurt volumes (cyan, translucent)
const COLOR_ATTACK := Color(1.0, 0.30, 0.15) # attack hitboxes (red, translucent, overlaid)
const COLOR_BONE := Color(1.0, 0.35, 0.9) # skeleton bones (magenta)
const COLOR_VEL := Color(1.0, 0.85, 0.2) # matador velocity vectors (amber)
const COLOR_ANIM := Color(0.55, 0.85, 1.0) # animation-name tags (sky blue)
const COLOR_GRID := Color(0.45, 0.45, 0.45, 0.5)
const AREA_ALPHA := 0.25
const AREA_ALPHA := 0.25
const ATTACK_ALPHA := 0.32
const GRID_HALF := 20 # grid extends ±GRID_HALF cells from the origin
const GRID_STEP := 1.0 # metres between grid lines
@@ -81,6 +85,7 @@ func ray(from: Vector3, to: Vector3, color: Color = Color.YELLOW) -> void:
func _process(delta: float) -> void:
_reconcile(delta)
_pulse_attacks()
if DP.b("show_bones"):
_draw_bones()
if DP.b("show_states"):
@@ -360,11 +365,11 @@ func _si(n: int) -> String:
# ── Collision / hit-area overlays (incremental) ────────────────────────────────
func _reconcile(delta: float) -> void:
var col := DP.b("show_collisions")
var hit := DP.b("show_hitboxes")
var atk := DP.b("show_attacks")
var bone := DP.b("show_bones")
var anim := DP.b("show_animation_name")
if not (col or hit or bone or anim):
if not (hit or atk or bone or anim):
if not _overlays.is_empty():
_clear_overlays()
_skeletons.clear()
@@ -372,7 +377,7 @@ func _reconcile(delta: float) -> void:
_prev_flags = ""
return
var flags := "%d%d%d%d" % [int(col), int(hit), int(bone), int(anim)]
var flags := "%d%d%d%d" % [int(hit), int(atk), int(bone), int(anim)]
_reconcile_t -= delta
if flags != _prev_flags: # a toggle just changed — refresh immediately
_prev_flags = flags
@@ -386,7 +391,7 @@ func _reconcile(delta: float) -> void:
var wanted: Dictionary = {}
var scene := get_tree().current_scene
if scene != null:
_collect(scene, col, hit, bone, anim, wanted)
_collect(scene, hit, atk, bone, anim, wanted)
# Drop overlays whose shape is gone or no longer wanted. The overlay is a child of
# the collision shape, so it's freed with it — hold it as Variant, since assigning a
@@ -402,10 +407,11 @@ func _reconcile(delta: float) -> void:
for id: int in wanted:
if not _overlays.has(id):
var entry: Array = wanted[id]
_overlays[id] = _attach(entry[0] as CollisionShape3D, entry[1] as bool)
_overlays[id] = _attach(entry[0] as CollisionShape3D, entry[1] as String)
func _collect(node: Node, col: bool, hit: bool, bone: bool, anim: bool, wanted: Dictionary) -> void:
func _collect(node: Node, hit: bool, atk: bool, bone: bool, anim: bool,
wanted: Dictionary) -> void:
# The audience ("public") is ambient decoration, not a debug target — skip the whole
# crowd subtree so its clapping AnimationPlayers/skeletons don't flood the overlays.
if node.is_in_group(&"public"):
@@ -417,18 +423,28 @@ func _collect(node: Node, col: bool, hit: bool, bone: bool, anim: bool, wanted:
if node is CollisionShape3D:
var cs := node as CollisionShape3D
if cs.shape != null and not cs.disabled and _drawable(cs.shape):
var parent := cs.get_parent()
var is_area := parent is Area3D
# Ragdoll bone colliders (PhysicalBone3D) are excluded — use show_bones for
# the skeleton; otherwise every corpse floods the view with capsules.
var is_body := parent is PhysicsBody3D and not (parent is PhysicalBone3D)
# The bull's and bear's body capsules are combat volumes too, so they also
# surface under show_hitboxes (not just show_collisions).
var is_combatant := is_body and (parent.is_in_group(&"player") or parent.is_in_group(&"bear"))
if (is_area and hit) or (is_body and col) or (is_combatant and hit):
wanted[cs.get_instance_id()] = [cs, is_area]
var kind := _classify(cs.get_parent())
var show := (kind == "attack" and atk) or (kind == "hitbox" and hit)
if show:
wanted[cs.get_instance_id()] = [cs, kind]
for child in node.get_children():
_collect(child, col, hit, bone, anim, wanted)
_collect(child, hit, atk, bone, anim, wanted)
# Sort a shape's owning body/area into an overlay bucket, or "" to skip.
# attack — a swing volume (a Hitbox area, or a thrown-spear projectile body)
# hitbox — a combatant's hurt volume: its HitArea, or its own body capsule
func _classify(parent: Node) -> String:
if parent is Hitbox or parent is SpearProjectile:
return "attack"
if parent is Area3D:
return "hitbox"
# Ragdoll bone colliders (PhysicalBone3D) are excluded — use show_bones for the
# skeleton; otherwise every corpse floods the view with capsules.
if parent is PhysicsBody3D and not (parent is PhysicalBone3D):
if parent.is_in_group(&"player") or parent.is_in_group(&"matador"):
return "hitbox"
return ""
# Skip only the unbounded shapes whose debug mesh is meaningless/huge; concave arena
@@ -437,20 +453,35 @@ func _drawable(shape: Shape3D) -> bool:
return not (shape is WorldBoundaryShape3D or shape is HeightMapShape3D)
func _attach(cs: CollisionShape3D, is_area: bool) -> MeshInstance3D:
func _attach(cs: CollisionShape3D, kind: String) -> MeshInstance3D:
var mi := MeshInstance3D.new()
if is_area:
var solid := _solid_mesh(cs.shape)
mi.mesh = solid if solid != null else cs.shape.get_debug_mesh()
mi.material_override = _translucent_material(COLOR_AREA)
var solid := _solid_mesh(cs.shape)
mi.mesh = solid if solid != null else cs.shape.get_debug_mesh()
if kind == "attack":
# Overlaid so a brief swing volume reads clearly on top of the action; its
# visibility is pulsed per-frame in _pulse_attacks so it only shows while live.
mi.material_override = _translucent_material(COLOR_ATTACK, ATTACK_ALPHA, true)
else:
mi.mesh = cs.shape.get_debug_mesh()
mi.material_override = _wire_material(COLOR_BODY)
# The calm translucent-blue volume — depth-tested so it sits in the world.
mi.material_override = _translucent_material(COLOR_AREA, AREA_ALPHA, false)
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
cs.add_child(mi)
return mi
# Attack overlays exist for as long as show_attacks is on, but a swing hitbox is only a
# hazard while its Hitbox is monitoring — so show each attack volume only when it's live.
# (A thrown spear has no monitoring gate; it stays visible for its whole flight.)
func _pulse_attacks() -> void:
for id: int in _overlays:
var mi: Variant = _overlays[id]
if not is_instance_valid(mi):
continue
var owner := (mi as Node).get_parent().get_parent()
if owner is Hitbox:
(mi as MeshInstance3D).visible = (owner as Area3D).monitoring
func _clear_overlays() -> void:
for id: int in _overlays:
var mi: Variant = _overlays[id]
@@ -495,19 +526,12 @@ func _line_material() -> StandardMaterial3D:
return mat
func _wire_material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = color
mat.no_depth_test = true
return mat
func _translucent_material(color: Color) -> StandardMaterial3D:
func _translucent_material(color: Color, alpha: float, overlay: bool) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = Color(color.r, color.g, color.b, AREA_ALPHA)
mat.albedo_color = Color(color.r, color.g, color.b, alpha)
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
mat.no_depth_test = overlay
return mat