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:
+218
-86
@@ -1,13 +1,9 @@
|
||||
extends CharacterBody3D
|
||||
|
||||
# WANDER → bull outside flee range, roaming freely
|
||||
# FLEE → bull within range, matador backs away (raycast-steered around walls)
|
||||
# BRACE → charge detected: plant feet, face the bull (cite phase)
|
||||
# SIDESTEP→ bull commits: sharp lateral step to let it pass (pase phase)
|
||||
# ATTACK → post-dodge or mid-range: chase and strike
|
||||
# ROLL → hit by bull but roll chance fires: survive and recover
|
||||
# THROW → planted, winding up an overhand throw, then releasing the sword
|
||||
# RAGDOLL → hit by bull, no roll chance
|
||||
# WANDER → roam/taunt outside flee range. FLEE → bull close: back away (raycast-steered).
|
||||
# BRACE → charge detected: plant + face (cite). SIDESTEP → bull commits: lateral pass (pase).
|
||||
# ATTACK → chase and strike. ROLL → hit but rolled: survive. THROW → wind up + hurl the spear.
|
||||
# RAGDOLL → hit, no roll.
|
||||
enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL }
|
||||
|
||||
# The matador's high-level *want*, chosen by a weighted utility vote (see
|
||||
@@ -32,7 +28,9 @@ var _dodge_cd: float = 0.0
|
||||
var _will_dodge: bool = true
|
||||
var _attack_timer: float = 0.0
|
||||
var _swing_timer: float = 0.0
|
||||
var _lunge_timer: float = 0.0
|
||||
var _attack_cd: float = 0.0
|
||||
var _bull_prev_pos: Vector3 = Vector3.ZERO
|
||||
var _bull_prev_seen: bool = false
|
||||
var _roll_timer: float = 0.0
|
||||
var _roll_dir: Vector3 = Vector3.ZERO
|
||||
var _is_spawning: bool = false
|
||||
@@ -52,7 +50,7 @@ var _drawing: bool = false
|
||||
var _sheathing: bool = false
|
||||
var _draw_timer: float = 0.0
|
||||
var _draw_len: float = 0.0
|
||||
var _sword_blade_area: Area3D = null
|
||||
var _sword_blade_area: Hitbox = null
|
||||
var _spear_node: Node3D = null
|
||||
var _blade_hit_cd: float = 0.0
|
||||
var _death_player: AudioStreamPlayer = null
|
||||
@@ -76,8 +74,9 @@ const _ANIM_TAUNTS: Array[StringName] = [&"Taunt", &"Taunt_B", &"Taunt_C"]
|
||||
# reparents from holster to hand at this point in the animation.
|
||||
const _DRAW_GRAB_AT: float = 0.55
|
||||
|
||||
const _SWORD_SCENE: PackedScene = preload("res://Assets/sword.glb")
|
||||
const _SPEAR_SCENE: PackedScene = preload("res://Assets/spear.glb")
|
||||
# Wrapper scenes (glb + an editor-tweakable hitbox / collider) — see Sword.tscn / Spear.tscn.
|
||||
const _SWORD_SCENE: PackedScene = preload("res://Sword.tscn")
|
||||
const _SPEAR_SCENE: PackedScene = preload("res://Spear.tscn")
|
||||
|
||||
const _GRAVITY: float = 9.8 # arena gravity, for the spear's ballistic arc
|
||||
|
||||
@@ -168,6 +167,9 @@ func _physics_process(delta: float) -> void:
|
||||
_dodge_cd = maxf(0.0, _dodge_cd - delta)
|
||||
_blade_hit_cd = maxf(0.0, _blade_hit_cd - delta)
|
||||
_throw_aim_cd = maxf(0.0, _throw_aim_cd - delta)
|
||||
_attack_cd = maxf(0.0, _attack_cd - delta)
|
||||
if _bull != null:
|
||||
_sweep_bull_gore()
|
||||
if _state != State.RAGDOLL and _bull != null:
|
||||
_update_ai_state(delta)
|
||||
|
||||
@@ -185,9 +187,8 @@ func _physics_process(delta: float) -> void:
|
||||
_advance_draw(delta)
|
||||
|
||||
|
||||
# Utility-driven brain. The physically committed states (a brace, a pass, a roll, a
|
||||
# throw wind-up) run to completion on their own timers; from the free states the
|
||||
# matador re-scores its options every mat_decide_interval and acts on the winner.
|
||||
# Utility-driven brain. Committed states (brace / pass / roll / throw wind-up) run to completion on
|
||||
# their own timers; from the free states the matador re-scores every mat_decide_interval.
|
||||
func _update_ai_state(delta: float) -> void:
|
||||
if _state == State.BRACE or _state == State.SIDESTEP \
|
||||
or _state == State.ROLL or _state == State.THROW:
|
||||
@@ -204,9 +205,8 @@ func _update_ai_state(delta: float) -> void:
|
||||
_enter_intent(intent)
|
||||
|
||||
|
||||
# Score each intent from weighted heuristics and return the winner. Weights (the
|
||||
# mat_w_* params) shape the personality; a small commit bonus on the current intent
|
||||
# stops it flip-flopping between two near-tied options.
|
||||
# Score each intent from weighted mat_w_* heuristics and return the winner; a small commit bonus on
|
||||
# the current intent stops it flip-flopping between two near-tied options.
|
||||
func _choose_intent() -> Intent:
|
||||
var dist := global_position.distance_to(_bull.global_position)
|
||||
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
|
||||
@@ -227,6 +227,9 @@ func _choose_intent() -> Intent:
|
||||
# A matador won't commit to a strike it can't bail out of, so a spent dodge
|
||||
# cooldown makes attacking (and dodging outright) less attractive.
|
||||
var dodge_ready := 1.0 if _dodge_cd <= 0.0 else 0.35
|
||||
# The post-stab recovery beat (mat_stab_recovery) kills ATTACK outright — the matador circles
|
||||
# or gives ground between thrusts rather than machine-gunning the bull; DODGE/FLEE stay live.
|
||||
var attack_ready := 0.0 if _attack_cd > 0.0 else 1.0
|
||||
var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \
|
||||
and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range")
|
||||
|
||||
@@ -247,7 +250,7 @@ func _choose_intent() -> Intent:
|
||||
# Press the attack; ease off a touch when a charge is barrelling in. Supporting
|
||||
# matadors barely press — they yield the pass to whoever's engaging.
|
||||
Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \
|
||||
* (1.0 - 0.55 * danger) * dodge_ready * engage_mult,
|
||||
* (1.0 - 0.55 * danger) * dodge_ready * engage_mult * attack_ready,
|
||||
# Sidestep / plant against an incoming charge.
|
||||
Intent.DODGE: DP.f("mat_w_caution") * danger * (1.0 if _dodge_cd <= 0.0 else 0.0),
|
||||
# Hurl the blade — an occasional ranged flourish from an engaging matador when
|
||||
@@ -255,7 +258,10 @@ func _choose_intent() -> Intent:
|
||||
Intent.THROW: (DP.f("mat_w_throw") * (0.35 + 0.65 * (1.0 - close))) \
|
||||
if (throw_ok and is_engager) else -1.0,
|
||||
}
|
||||
score[_intent] = float(score[_intent]) + DP.f("mat_w_commit")
|
||||
# Commit bonus keeps a near-tied choice from flip-flopping — but don't re-reinforce a
|
||||
# just-swung ATTACK we're recovering from, or the matador clings to it and spams the bull.
|
||||
if not (_intent == Intent.ATTACK and _attack_cd > 0.0):
|
||||
score[_intent] = float(score[_intent]) + DP.f("mat_w_commit")
|
||||
|
||||
var best: Intent = Intent.WANDER
|
||||
var best_score := -INF
|
||||
@@ -301,9 +307,8 @@ func _enter_intent(intent: Intent) -> void:
|
||||
_start_throw()
|
||||
|
||||
|
||||
# How many other live matadors are closer to the bull than this one. 0 = the
|
||||
# closest (the natural lead engager); the utility brain gives the nearest
|
||||
# mat_engage_slots the go-ahead to press while the rest spread out and taunt.
|
||||
# How many other live matadors are closer to the bull than this one (0 = the lead engager); the
|
||||
# brain lets the nearest mat_engage_slots press while the rest spread out and taunt.
|
||||
func _engagement_rank() -> int:
|
||||
if _bull == null:
|
||||
return 0
|
||||
@@ -347,6 +352,19 @@ func _is_charge_incoming() -> bool:
|
||||
return bull_flat.normalized().dot(to_me.normalized()) > 0.85
|
||||
|
||||
|
||||
# Bull charging fast and aimed straight at the matador, at ANY range (unlike _is_charge_incoming,
|
||||
# which is capped to brace range) — so a taunting matador can answer a charge from across the arena.
|
||||
func _charge_aimed_at_me() -> bool:
|
||||
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
|
||||
if bull_flat.length() < DP.f("mat_charge_speed"):
|
||||
return false
|
||||
var to_me := global_position - _bull.global_position
|
||||
to_me.y = 0.0
|
||||
if to_me.length() < 0.1:
|
||||
return true
|
||||
return bull_flat.normalized().dot(to_me.normalized()) > 0.9
|
||||
|
||||
|
||||
# Returns a wall-clear direction, recomputed at most every _STEER_INTERVAL seconds.
|
||||
# Randomises among available clear angles so juke direction is unpredictable.
|
||||
func _steer_clear(desired_dir: Vector3, delta: float) -> Vector3:
|
||||
@@ -407,6 +425,19 @@ func _face_point(point: Vector3, delta: float, rate: float) -> void:
|
||||
func _tick_wander(delta: float) -> void:
|
||||
# Plant the feet while drawing / sheathing instead of sliding through the clip.
|
||||
if _drawing or _sheathing or _idle_timer > 0.0:
|
||||
# A charge aimed at a taunting matador snaps it out of the pose: hurl a spear at a bull
|
||||
# barrelling in from range, or switch to the sword (brace/cite) to meet a closer rush.
|
||||
if _idle_timer > 0.0 and not _drawing and not _sheathing \
|
||||
and _bull != null and _charge_aimed_at_me():
|
||||
var dist := global_position.distance_to(_bull.global_position)
|
||||
var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \
|
||||
and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range")
|
||||
if dist > DP.f("mat_brace_range") and throw_ok:
|
||||
_start_throw() # far charge — get the spear off before it arrives
|
||||
return
|
||||
if dist <= DP.f("mat_brace_range"):
|
||||
_start_brace() # closer rush — draw the sword and cite the charge
|
||||
return
|
||||
_idle_timer = maxf(0.0, _idle_timer - delta)
|
||||
_decelerate(10.0, delta)
|
||||
# Taunt toward the bull so the posturing plays to it, not off into space.
|
||||
@@ -531,6 +562,7 @@ func _start_attack() -> void:
|
||||
|
||||
func _tick_attack(delta: float) -> void:
|
||||
_attack_timer -= delta
|
||||
var was_swinging := _swing_timer > 0.0
|
||||
_swing_timer = maxf(0.0, _swing_timer - delta)
|
||||
# Plant the feet while drawing instead of sliding toward the bull mid-clip.
|
||||
if _drawing or _sheathing:
|
||||
@@ -543,22 +575,41 @@ func _tick_attack(delta: float) -> void:
|
||||
to_bull.y = 0.0
|
||||
var dist := to_bull.length()
|
||||
|
||||
# Commit to a full swing once in range instead of flipping Run/Attack at the
|
||||
# distance boundary every frame (the old foot-slide jitter). When the swing
|
||||
# ends the matador re-closes if the bull slipped away, or stabs again if not.
|
||||
if _swing_timer <= 0.0 and dist <= DP.f("mat_attack_min_dist") and _sword_in_hand:
|
||||
# A finished stab enforces a recovery beat (no re-commit for mat_stab_recovery), then decides
|
||||
# what's next: a landed hit very likely breaks into a taunt/retreat, otherwise the usual roll
|
||||
# to give ground / circle / throw — anything but instantly drilling the same thrust again.
|
||||
if was_swinging and _swing_timer <= 0.0:
|
||||
_attack_cd = DP.f("mat_stab_recovery")
|
||||
var landed := _sword_blade_area != null and _sword_blade_area.did_hit()
|
||||
if landed and randf() < DP.f("mat_taunt_after_hit"):
|
||||
_do_taunt_retreat()
|
||||
return
|
||||
if randf() < DP.f("mat_stab_followup_chance"):
|
||||
_do_stab_followup()
|
||||
return
|
||||
|
||||
# Run in (Run clip) to mat_stab_standoff, then plant and swing on the spot — the standoff sits
|
||||
# inside the sword's ~3.75 m thrust reach; killing velocity on commit stops the feet sliding.
|
||||
# The recovery beat gates the next commit so back-to-back stabs can't machine-gun the bull.
|
||||
if _swing_timer <= 0.0 and _attack_cd <= 0.0 and dist <= DP.f("mat_stab_standoff") and _sword_in_hand:
|
||||
_swing_timer = _anim_length(_ANIM_ATTACK, 0.9)
|
||||
_lunge_timer = DP.f("mat_lunge_time")
|
||||
velocity.x = 0.0
|
||||
velocity.z = 0.0
|
||||
# Re-arm fresh each swing — back-to-back stabs commit before the else-branch deactivate runs.
|
||||
if _sword_blade_area != null:
|
||||
_sword_blade_area.deactivate()
|
||||
|
||||
if _swing_timer > 0.0:
|
||||
_lunge_timer = maxf(0.0, _lunge_timer - delta)
|
||||
if _lunge_timer > 0.0 and dist > DP.f("mat_attack_min_dist"):
|
||||
_accelerate(to_bull.normalized(), DP.f("mat_lunge_speed"), delta)
|
||||
else:
|
||||
_decelerate(22.0, delta)
|
||||
_decelerate(22.0, delta)
|
||||
_play_anim(_ANIM_ATTACK)
|
||||
_try_melee_hit(DP.f("mat_stab_reach"))
|
||||
# Damage off the sword's own BladeHitbox (Sword.tscn), riding the animated blade — gored only
|
||||
# when the thrust reaches it. Arm at the thrust (mat_stab_strike_at) and sweep for the overlap.
|
||||
if _sword_blade_area != null and _swing_phase() >= DP.f("mat_stab_strike_at"):
|
||||
_sword_blade_area.activate()
|
||||
_sword_blade_area.sweep()
|
||||
else:
|
||||
if _sword_blade_area != null:
|
||||
_sword_blade_area.deactivate()
|
||||
var dir := _steer_clear(to_bull.normalized(), delta)
|
||||
_accelerate(dir, DP.f("mat_attack_speed"), delta)
|
||||
_play_anim(_ANIM_RUN)
|
||||
@@ -566,6 +617,50 @@ func _tick_attack(delta: float) -> void:
|
||||
move_and_slide()
|
||||
|
||||
|
||||
# How far through the attack swing: 0 (just committed) → 1 (finished), tracking the clip's progress.
|
||||
func _swing_phase() -> float:
|
||||
var length := _anim_length(_ANIM_ATTACK, 0.9)
|
||||
if length <= 0.0:
|
||||
return 1.0
|
||||
return clampf(1.0 - _swing_timer / length, 0.0, 1.0)
|
||||
|
||||
|
||||
# Break off after a stab instead of drilling the same thrust: give ground, hurl a spear, or circle.
|
||||
# The brain re-engages after; _decide_cd is reset so the pick plays out before it re-scores.
|
||||
func _do_stab_followup() -> void:
|
||||
_decide_cd = DP.f("mat_decide_interval")
|
||||
_dodge_cd = DP.f("mat_dodge_cooldown")
|
||||
_steer_cd = 0.0
|
||||
var roll := randf()
|
||||
if roll < 0.2 and _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node):
|
||||
_start_throw() # ranged flourish
|
||||
elif roll < 0.6:
|
||||
_state = State.FLEE # give ground after the thrust
|
||||
_intent = Intent.FLEE
|
||||
else:
|
||||
_state = State.WANDER # circle to a fresh angle before pressing again
|
||||
_intent = Intent.WANDER
|
||||
_pick_wander_target()
|
||||
|
||||
|
||||
# A stab that LANDED: milk it. Break into a long taunt held toward the bull, then drift out to the
|
||||
# showmanship ring — and lock ATTACK out for the whole hold (_attack_cd) so the brain can't drag the
|
||||
# matador straight back into another thrust. DODGE/FLEE stay live, so a charging bull is still
|
||||
# answered. This is what stops the "stab, stab, stab" spam after the matador connects.
|
||||
func _do_taunt_retreat() -> void:
|
||||
_state = State.WANDER
|
||||
_intent = Intent.WANDER
|
||||
_idle_timer = randf_range(DP.f("mat_taunt_hold") * 0.7, DP.f("mat_taunt_hold") * 1.3)
|
||||
_idle_anim = _pick_taunt()
|
||||
_taunt_swap = randf_range(1.5, 3.0)
|
||||
_attack_cd = maxf(_attack_cd, _idle_timer)
|
||||
_dodge_cd = DP.f("mat_dodge_cooldown")
|
||||
_steer_cd = 0.0
|
||||
_pick_wander_target()
|
||||
if _sword_blade_area != null:
|
||||
_sword_blade_area.deactivate()
|
||||
|
||||
|
||||
func _enter_roll(hit_dir: Vector3) -> void:
|
||||
_state = State.ROLL
|
||||
# Match the state to the actual clip so the roll plays through once instead of
|
||||
@@ -579,9 +674,8 @@ func _enter_roll(hit_dir: Vector3) -> void:
|
||||
_anim_player.seek(0.0, true)
|
||||
|
||||
|
||||
# Called by the spawner (deferred) to make the matador leap-roll toward a target
|
||||
# point on entry — the cinematic arena entrance. After the roll it transitions to
|
||||
# normal AI via the standard ROLL → FLEE path.
|
||||
# Called (deferred) by the spawner to make the matador leap-roll toward a target on entry — the
|
||||
# cinematic arena entrance. After the roll it transitions to normal AI via the ROLL → FLEE path.
|
||||
func enter_spawn_roll(toward: Vector3) -> void:
|
||||
var dir := toward - global_position
|
||||
dir.y = 0.0
|
||||
@@ -770,34 +864,84 @@ func _on_body_entered(body: Node3D) -> void:
|
||||
if not body.is_in_group(&"player"):
|
||||
return
|
||||
var player := body as CharacterBody3D
|
||||
if player.velocity.length() < DP.f("mat_hit_threshold"):
|
||||
var bull_speed := player.velocity.length()
|
||||
if bull_speed < DP.f("mat_hit_threshold"):
|
||||
return
|
||||
if not _horns_first(player.global_position):
|
||||
return
|
||||
var flat_vel := Vector3(player.velocity.x, 0.0, player.velocity.z)
|
||||
var hit_dir := flat_vel.normalized() if flat_vel.length() > 0.5 else \
|
||||
(global_position - player.global_position).normalized()
|
||||
_take_bull_charge(bull_speed, hit_dir)
|
||||
|
||||
if randf() < DP.f("mat_roll_chance"):
|
||||
|
||||
# Tunnel-proof charge detection: at dash speed the bull skips clean over the thin HitArea between
|
||||
# ticks, so body_entered never fires. Sweep its actual travel (prev → now) and gore on any pass within
|
||||
# mat_charge_gore_radius. Runs every frame (even ragdolled) to keep the segment fresh.
|
||||
func _sweep_bull_gore() -> void:
|
||||
var now := _bull.global_position
|
||||
var seg_from := _bull_prev_pos if _bull_prev_seen else now
|
||||
_bull_prev_pos = now
|
||||
_bull_prev_seen = true
|
||||
if _state == State.RAGDOLL or _state == State.ROLL:
|
||||
return
|
||||
var flat_vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
|
||||
var bull_speed := flat_vel.length()
|
||||
if bull_speed < DP.f("mat_hit_threshold"):
|
||||
return
|
||||
var a := Vector3(seg_from.x, 0.0, seg_from.z)
|
||||
var b := Vector3(now.x, 0.0, now.z)
|
||||
var p := Vector3(global_position.x, 0.0, global_position.z)
|
||||
var closest := Geometry3D.get_closest_point_to_segment(p, a, b)
|
||||
if p.distance_to(closest) > DP.f("mat_charge_gore_radius"):
|
||||
return
|
||||
if not _horns_first(seg_from):
|
||||
return
|
||||
var hit_dir := flat_vel.normalized() if bull_speed > 0.5 \
|
||||
else (global_position - now).normalized()
|
||||
_take_bull_charge(bull_speed, hit_dir)
|
||||
|
||||
|
||||
# Horns-first gate: gore only when the bull's velocity points within bull_gore_arc of the direction
|
||||
# from `from` to the matador — not a sideways brush or backing in. The dash ability skips the gate.
|
||||
func _horns_first(from: Vector3) -> bool:
|
||||
if _bull == null:
|
||||
return false
|
||||
if _bull.has_method(&"is_dashing") and _bull.call(&"is_dashing"):
|
||||
return true # the dash ability is a committed lunge — it gores on contact, no angle gate
|
||||
var vel := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
|
||||
if vel.length() < 0.5:
|
||||
return false
|
||||
var to_me := global_position - from
|
||||
to_me.y = 0.0
|
||||
if to_me.length() < 0.01:
|
||||
return true
|
||||
return vel.normalized().dot(to_me.normalized()) > cos(deg_to_rad(DP.f("bull_gore_arc")))
|
||||
|
||||
|
||||
# Resolve a connected charge: the survival roll fades as bull speed climbs (mat_charge_pierce), so a
|
||||
# committed charge gores rather than getting shrugged off.
|
||||
func _take_bull_charge(bull_speed: float, hit_dir: Vector3) -> void:
|
||||
# The boulder roll bowls matadors over with its own launch + chain (player._roll_try_pop); don't
|
||||
# pre-empt that with a quiet gore, or the ball rolls straight through without popping.
|
||||
if _bull.has_method(&"is_rolling") and _bull.call(&"is_rolling"):
|
||||
return
|
||||
# The dash is a committed lunge — a connect is a guaranteed kill, no survival roll (it already
|
||||
# bypasses the horns cone). A passive cruise-charge still leaves a roll chance that fades with speed.
|
||||
var roll_chance := 0.0
|
||||
if not (_bull.has_method(&"is_dashing") and _bull.call(&"is_dashing")):
|
||||
var floor_spd := DP.f("mat_hit_threshold")
|
||||
var full_spd := maxf(DP.f("mat_charge_speed"), floor_spd + 0.1)
|
||||
var commit := clampf((bull_speed - floor_spd) / (full_spd - floor_spd), 0.0, 1.0)
|
||||
roll_chance = DP.f("mat_roll_chance") * (1.0 - commit * DP.f("mat_charge_pierce"))
|
||||
if randf() < roll_chance:
|
||||
_enter_roll(hit_dir)
|
||||
else:
|
||||
_enter_ragdoll(hit_dir, player.velocity.length())
|
||||
_enter_ragdoll(hit_dir, bull_speed)
|
||||
|
||||
|
||||
func _on_blade_hit(body: Node3D) -> void:
|
||||
if _state != State.ATTACK and _state != State.SIDESTEP and _state != State.BRACE:
|
||||
return
|
||||
if not body.is_in_group(&"player"):
|
||||
return
|
||||
if _blade_hit_cd > 0.0:
|
||||
return
|
||||
_blade_hit_cd = DP.f("mat_stab_cooldown")
|
||||
body.call(&"take_sword_hit", "gored")
|
||||
|
||||
|
||||
# Reach-based stab. A charging bull crosses metres per physics frame, so it easily
|
||||
# tunnels through the thin blade Area between frames — this distance check is the
|
||||
# reliable hit. Called from the offensive ticks (active swing / pass) whenever the
|
||||
# blade is drawn: if the bull is inside reach AND the matador is facing it — the
|
||||
# blade actually presented, not held off to the side — it's gored.
|
||||
# Reach-based hit for the sidestep PASS (fast lateral sweep — the thin blade Area tunnels, so a
|
||||
# distance+facing check is the reliable gore; the ATTACK stab uses the blade collider instead).
|
||||
func _try_melee_hit(reach: float) -> void:
|
||||
if _blade_hit_cd > 0.0 or not _sword_in_hand or _bull == null:
|
||||
return
|
||||
@@ -806,8 +950,6 @@ func _try_melee_hit(reach: float) -> void:
|
||||
var dist := to_bull.length()
|
||||
if dist > reach or dist < 0.01:
|
||||
return
|
||||
# Only lands within the frontal arc — you're gored on a presented blade, not by
|
||||
# brushing a matador whose sword is pointing elsewhere.
|
||||
var facing := Vector3(sin(_mesh.rotation.y), 0.0, cos(_mesh.rotation.y))
|
||||
if facing.dot(to_bull / dist) < cos(deg_to_rad(DP.f("mat_stab_arc"))):
|
||||
return
|
||||
@@ -817,8 +959,8 @@ func _try_melee_hit(reach: float) -> void:
|
||||
|
||||
func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0) -> void:
|
||||
_state = State.RAGDOLL
|
||||
if _sword_blade_area:
|
||||
_sword_blade_area.monitoring = false
|
||||
if _sword_blade_area != null:
|
||||
_sword_blade_area.deactivate()
|
||||
_clear_spear() # a spear mid-wind-up dies with the matador, not in the air
|
||||
killed.emit()
|
||||
var cleanup_timer := get_tree().create_timer(4.0)
|
||||
@@ -1011,10 +1153,9 @@ func _pick_wander_target() -> void:
|
||||
|
||||
# ── Sword attachment ──────────────────────────────────────────────────────────
|
||||
|
||||
# The rig (matador_v03) carries two purpose-built bones: weapon_bone (right-hand
|
||||
# grip) and weapon_rest_bone (hip holster). The sword rides in the holster and is
|
||||
# carried to the hand in combat (see _carry_sword); each bone has its own DP-tunable
|
||||
# local offset/rotation (sword_* / sword_rest_*) so the poses seat independently.
|
||||
# The rig (matador_v03) carries two bones: weapon_bone (right-hand grip) and weapon_rest_bone (hip
|
||||
# holster). The sword rides in the holster, carried to the hand in combat (_carry_sword); each bone
|
||||
# has its own DP-tunable offset/rotation (sword_* / sword_rest_*) so the poses seat independently.
|
||||
func _setup_sword() -> void:
|
||||
if not _skeleton:
|
||||
return
|
||||
@@ -1047,28 +1188,15 @@ func _spawn_sword() -> void:
|
||||
_drawing = false
|
||||
_apply_sword_grip()
|
||||
|
||||
# Blade collider along +Z (hilt at origin), tipped from the capsule's default Y.
|
||||
# Only monitors while the sword is in hand (see _carry_sword).
|
||||
var blade_cap := CapsuleShape3D.new()
|
||||
blade_cap.radius = 0.18
|
||||
blade_cap.height = 1.30
|
||||
var blade_cs := CollisionShape3D.new()
|
||||
blade_cs.shape = blade_cap
|
||||
blade_cs.position = Vector3(0.0, 0.0, 0.8)
|
||||
blade_cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
|
||||
|
||||
_sword_blade_area = Area3D.new()
|
||||
_sword_blade_area.collision_layer = 0
|
||||
_sword_blade_area.collision_mask = 1
|
||||
_sword_blade_area.monitoring = _sword_in_hand
|
||||
_sword_blade_area.add_child(blade_cs)
|
||||
_sword_node.add_child(_sword_blade_area)
|
||||
_sword_blade_area.body_entered.connect(_on_blade_hit)
|
||||
# The blade hitbox (Sword.tscn, hitbox.gd) is armed only during the ATTACK thrust (see
|
||||
# _tick_attack); off while merely carried, so brushing a drawn matador never gores the bull.
|
||||
_sword_blade_area = _sword_node.get_node_or_null("BladeHitbox") as Hitbox
|
||||
if _sword_blade_area != null:
|
||||
_sword_blade_area.deactivate()
|
||||
|
||||
|
||||
# The sword is MELEE-only: drawn while fighting up close (ATTACK) or guarding
|
||||
# (BRACE / SIDESTEP), holstered otherwise. Ranged throws use a spear instead
|
||||
# (see _spawn_spear_in_hand), so THROW keeps the sword holstered.
|
||||
# The sword is MELEE-only: drawn for ATTACK / BRACE / SIDESTEP, holstered otherwise. Ranged throws
|
||||
# use a spear (_spawn_spear_in_hand), so THROW keeps the sword holstered.
|
||||
func _wants_sword_drawn() -> bool:
|
||||
return _state == State.ATTACK \
|
||||
or _state == State.BRACE or _state == State.SIDESTEP
|
||||
@@ -1077,6 +1205,9 @@ func _wants_sword_drawn() -> bool:
|
||||
func _update_sword_carry() -> void:
|
||||
if not is_instance_valid(_sword_node) or _state == State.RAGDOLL:
|
||||
return
|
||||
# The blade collider lives only during the ATTACK thrust; leaving ATTACK disarms it.
|
||||
if _state != State.ATTACK and _sword_blade_area != null:
|
||||
_sword_blade_area.deactivate()
|
||||
if _wants_sword_drawn():
|
||||
if not _sword_in_hand and not _drawing:
|
||||
_begin_draw()
|
||||
@@ -1156,8 +1287,9 @@ func _carry_sword(in_hand: bool) -> void:
|
||||
_sword_in_hand = in_hand
|
||||
_sword_node.reparent(target, false)
|
||||
_apply_sword_grip()
|
||||
if _sword_blade_area:
|
||||
_sword_blade_area.monitoring = in_hand
|
||||
# Carrying never arms the blade — only the ATTACK thrust does (see _tick_attack).
|
||||
if _sword_blade_area != null:
|
||||
_sword_blade_area.deactivate()
|
||||
|
||||
|
||||
func _apply_sword_grip() -> void:
|
||||
|
||||
Reference in New Issue
Block a user