Win, lose screen. Game version info. Matador a lot more aggressive/wins easier (tweaks needed). No more score/combo/waves

This commit is contained in:
2026-07-30 12:39:51 +03:00
parent ebb6ec6335
commit bf7a454ad0
28 changed files with 1229 additions and 451 deletions
+183 -64
View File
@@ -10,6 +10,10 @@ extends CharacterBody3D
# RAGDOLL → hit by bull, no roll chance
enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL }
# The matador's high-level *want*, chosen by a weighted utility vote (see
# _choose_intent). Each intent maps onto one or more concrete States below.
enum Intent { WANDER, FLEE, ATTACK, DODGE, THROW }
signal killed
var _state: State = State.WANDER
@@ -35,6 +39,8 @@ var _throw_aim_cd: float = 0.0
var _throw_dir: Vector3 = Vector3.ZERO
var _steer_dir: Vector3 = Vector3.ZERO
var _steer_cd: float = 0.0
var _intent: Intent = Intent.WANDER
var _decide_cd: float = 0.0
var _ole_player: AudioStreamPlayer = null
var _sword_hand_attach: BoneAttachment3D = null
var _sword_rest_attach: BoneAttachment3D = null
@@ -74,11 +80,12 @@ const _STEER_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec
# Side angles (radians) tried when the straight-ahead path is blocked.
const _STEER_CANDIDATES: Array = [-0.35, 0.35, -0.7, 0.7, -1.1, 1.1, PI]
# Movement tuning
const _ACCEL_FACTOR: float = 8.0 # velocity ramp = speed * factor * delta
const _TURN_MOVE: float = 8.0 # face the travel direction while roaming
const _TURN_FACE: float = 12.0 # face the bull while engaging
const _TURN_SHARP: float = 20.0 # snap onto a sidestep / roll direction
# Movement tuning. Ramp/turn rates are deliberately snappy so the matador reads as
# a quick, dangerous opponent rather than a sluggish one.
const _ACCEL_FACTOR: float = 13.0 # velocity ramp = speed * factor * delta
const _TURN_MOVE: float = 11.0 # face the travel direction while roaming
const _TURN_FACE: float = 16.0 # face the bull while engaging
const _TURN_SHARP: float = 24.0 # snap onto a sidestep / roll direction
func _ready() -> void:
@@ -97,7 +104,7 @@ func _ready() -> void:
_anim_player.play(_ANIM_IDLE)
_setup_sword()
DP.any_changed.connect(_on_dp_changed)
_throw_aim_cd = randf_range(0.8, 2.5)
_throw_aim_cd = randf_range(0.3, 1.0)
_hit_area.body_entered.connect(_on_body_entered)
_pick_wander_target()
_blood_burst = preload("res://blood_burst.gd").new()
@@ -148,7 +155,7 @@ func _physics_process(delta: float) -> void:
if _resword_timer <= 0.0:
_spawn_sword()
if _state != State.RAGDOLL and _bull != null:
_update_ai_state()
_update_ai_state(delta)
match _state:
State.WANDER: _tick_wander(delta)
@@ -164,42 +171,110 @@ func _physics_process(delta: float) -> void:
_advance_draw(delta)
func _update_ai_state() -> void:
if _state == State.THROW:
# 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.
func _update_ai_state(delta: float) -> void:
if _state == State.BRACE or _state == State.SIDESTEP \
or _state == State.ROLL or _state == State.THROW:
return
_decide_cd -= delta
if _decide_cd > 0.0:
return
_decide_cd = DP.f("mat_decide_interval")
var intent := _choose_intent()
_intent = intent
if not _realizes_intent(intent):
_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.
func _choose_intent() -> Intent:
var dist := global_position.distance_to(_bull.global_position)
if (_state == State.WANDER or _state == State.FLEE or _state == State.ATTACK) \
and _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"):
# Roll for a throw; on a miss, wait before becoming eligible again so the
# matador commits to chasing for a melee Attack instead of throwing early.
if randf() < DP.f("mat_throw_chance"):
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
var bull_speed := bull_flat.length()
# close = 1 right on top of us, 0 at attack range or beyond. `beyond` measures
# how far past throw range the bull is (0 point-blank, 1 at/outside throw range):
# the matador only idles when the bull is out there, and engages otherwise.
var reach := maxf(DP.f("mat_attack_range"), 0.5)
var throw_range := maxf(DP.f("mat_throw_range"), 1.0)
var close := clampf(1.0 - dist / reach, 0.0, 1.0)
var beyond := clampf(dist / throw_range, 0.0, 1.0)
# danger blends "the bull is fast and near" with "a charge is aimed at me".
var danger := clampf(bull_speed / maxf(DP.f("mat_charge_speed"), 0.1), 0.0, 1.0) * close
if _is_charge_incoming():
danger = clampf(danger + 0.7, 0.0, 1.0)
# 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
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")
var score := {
# Only drift when the bull is out past throw range; up close this is ~0.
Intent.WANDER: DP.f("mat_w_showmanship") * 0.3 * beyond,
# Retreat only when the bull is close AND actually dangerous.
Intent.FLEE: DP.f("mat_w_flee") * close * (0.25 + 0.75 * danger),
# Press the attack; ease off a touch when a charge is barrelling in.
Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \
* (1.0 - 0.55 * danger) * dodge_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 whenever there's a clean lane at range — the ranged threat
# that punishes a bull for hanging back out of horn reach.
Intent.THROW: ((DP.f("mat_w_showmanship") * 0.55 + DP.f("mat_w_aggression") * 0.6) \
* (0.35 + 0.65 * (1.0 - close))) if throw_ok else -1.0,
}
score[_intent] = float(score[_intent]) + DP.f("mat_w_commit")
var best: Intent = Intent.WANDER
var best_score := -INF
for intent: int in score:
if float(score[intent]) > best_score:
best_score = float(score[intent])
best = intent
return best
# True when the current State already carries out the intent, so we needn't restart
# it (restarting ATTACK every tick would reset its swing and stutter the animation).
func _realizes_intent(intent: Intent) -> bool:
match intent:
Intent.WANDER:
return _state == State.WANDER
Intent.FLEE:
return _state == State.FLEE
Intent.ATTACK:
return _state == State.ATTACK
Intent.DODGE:
return _state == State.BRACE or _state == State.SIDESTEP
Intent.THROW:
return _state == State.THROW
return false
func _enter_intent(intent: Intent) -> void:
match intent:
Intent.WANDER:
_state = State.WANDER
_steer_cd = 0.0
_pick_wander_target()
Intent.FLEE:
_state = State.FLEE
_steer_cd = 0.0
Intent.ATTACK:
_steer_cd = 0.0
_start_attack()
Intent.DODGE:
_start_brace()
Intent.THROW:
_start_throw()
return
_throw_aim_cd = randf_range(2.5, 5.0)
match _state:
State.WANDER:
if dist < DP.f("mat_flee_range"):
_state = State.FLEE
elif _dodge_cd <= 0.0 and dist < DP.f("mat_attack_range") and not _is_charge_incoming():
_steer_cd = 0.0
_start_attack()
State.FLEE:
if _dodge_cd <= 0.0 and (dist < DP.f("mat_commit_dist") or _is_charge_incoming()):
_start_brace()
elif dist > DP.f("mat_flee_range") * 1.3:
_state = State.WANDER
_steer_cd = 0.0
_pick_wander_target()
State.BRACE:
pass # brace timer drives transition
State.SIDESTEP:
pass # step timer drives transition
State.ATTACK:
if _attack_timer <= 0.0 or dist > DP.f("mat_attack_range") * 1.5:
_state = State.FLEE
State.ROLL:
pass # roll timer drives transition
# Bull is moving fast and aimed within ~30° of the matador
@@ -339,16 +414,21 @@ func _tick_brace(delta: float) -> void:
_brace_timer -= delta
var dist := global_position.distance_to(_bull.global_position)
if dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0:
if _will_dodge:
if _will_dodge:
# Dodger: wait for the bull to commit, then whip into the lateral pass.
if dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0:
_state = State.SIDESTEP
_step_timer = DP.f("mat_step_duration")
if _ole_player:
_ole_player.pitch_scale = randf_range(0.88, 1.12)
_ole_player.play()
else:
_state = State.FLEE
else:
# Planter (estocada): the bull only dies to an active, presented thrust, not to
# brushing a braced matador — so when it commits in, break into a real attack
# SWING (which lunges the blade forward and stabs), otherwise hold then press.
if dist < DP.f("mat_commit_dist") or (_brace_timer <= 0.0 and not _is_charge_incoming()):
_dodge_cd = DP.f("mat_dodge_cooldown")
_start_attack()
# Pase phase: sharp lateral step biased toward the bull so the drawn blade sweeps
@@ -368,6 +448,7 @@ func _tick_sidestep(delta: float) -> void:
velocity.z = dir.z * spd
_face_point(_bull.global_position, delta, _TURN_SHARP)
_play_anim(_ANIM_ATTACK)
_try_melee_hit(DP.f("mat_stab_reach"))
move_and_slide()
@@ -406,6 +487,7 @@ func _tick_attack(delta: float) -> void:
else:
_decelerate(22.0, delta)
_play_anim(_ANIM_ATTACK)
_try_melee_hit(DP.f("mat_stab_reach"))
else:
var dir := _steer_clear(to_bull.normalized(), delta)
_accelerate(dir, DP.f("mat_attack_speed"), delta)
@@ -481,6 +563,10 @@ func _tick_throw(delta: float) -> void:
func _end_throw() -> void:
_state = State.FLEE
_dodge_cd = DP.f("mat_dodge_cooldown")
# Space throws out so the utility brain doesn't immediately vote another one,
# but keep them frequent enough to pressure a bull that hangs back at range.
_throw_aim_cd = randf_range(1.6, 3.2)
_intent = Intent.FLEE
# Blend straight into locomotion so the manually-posed throw arm eases out
# (a bare resume() would snap and leave the player on whatever it paused on).
if _anim_player:
@@ -539,15 +625,20 @@ func _release_sword() -> void:
rb.collision_layer = 0
rb.collision_mask = 1
rb.contact_monitor = true
rb.max_contacts_reported = 4
rb.max_contacts_reported = 6
# Fly nearly straight to the aim point instead of lobbing — the old horizontal
# throw sailed clean over the bull's low body. A small gravity_scale keeps a
# touch of drop for feel without dropping short.
rb.gravity_scale = DP.f("mat_throw_gravity")
get_tree().current_scene.add_child(rb)
rb.global_transform = gx
sword.reparent(rb, true)
# Physics collider along the blade (GLB +Z), which equals rb-local +Z because
# the mesh kept its global transform and rb adopted it.
# the mesh kept its global transform and rb adopted it. Fattened so a fast throw
# reliably overlaps the bull's collision spheres instead of tunnelling past.
var cap := CapsuleShape3D.new()
cap.radius = 0.06
cap.radius = 0.14
cap.height = 1.4
var cs := CollisionShape3D.new()
cs.shape = cap
@@ -555,19 +646,25 @@ func _release_sword() -> void:
cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
rb.add_child(cs)
# Lead the target: aim where the bull will be when the sword arrives, so a
# moving bull isn't simply behind the throw by the time it lands.
var aim := _throw_dir
# Aim at the bull's BODY (its collision spheres ride low, ~0.5 m below the
# origin), leading a moving target so it arrives where the bull will be. The aim
# keeps its vertical component so the blade drives into the body, not over it.
var speed := maxf(DP.f("mat_throw_speed"), 0.1)
var dir := (_throw_dir + Vector3.UP * 0.0)
if is_instance_valid(_bull):
var speed := maxf(DP.f("mat_throw_speed"), 0.1)
var flat := _bull.global_position - gx.origin
var target := _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0)
var flat := target - gx.origin
flat.y = 0.0
var lead := (_bull.global_position + _bull.velocity * (flat.length() / speed)) - gx.origin
lead.y = 0.0
if lead.length() > 0.1:
aim = lead.normalized()
var dir := (aim + Vector3.UP * 0.12).normalized()
rb.linear_velocity = dir * DP.f("mat_throw_speed")
var flight := flat.length() / speed
target += _bull.velocity * flight
var to_target := target - gx.origin
if to_target.length() > 0.1:
dir = to_target.normalized()
# A little aim scatter so throws are a threat to READ and dodge, not a hitscan —
# a bull that keeps moving can slip them, a stationary one gets pinned.
var spread := deg_to_rad(DP.f("mat_throw_spread"))
dir = dir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized()
rb.linear_velocity = dir * speed
rb.angular_velocity = dir.cross(Vector3.UP).normalized() * -DP.f("mat_throw_spin")
var hit_done := [false]
@@ -576,7 +673,7 @@ func _release_sword() -> void:
return
if body.is_in_group(&"player"):
hit_done[0] = true
body.call(&"take_sword_hit")
body.call(&"take_sword_hit", "thrown")
)
var cleanup := get_tree().create_timer(6.0)
@@ -623,14 +720,36 @@ func _on_body_entered(body: Node3D) -> void:
func _on_blade_hit(body: Node3D) -> void:
if _state != State.ATTACK and _state != State.SIDESTEP:
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 = 0.5
body.call(&"take_sword_hit")
_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.
func _try_melee_hit(reach: float) -> void:
if _blade_hit_cd > 0.0 or not _sword_in_hand or _bull == null:
return
var to_bull := _bull.global_position - global_position
to_bull.y = 0.0
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
_blade_hit_cd = DP.f("mat_stab_cooldown")
_bull.call(&"take_sword_hit", "gored")
func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0) -> void:
@@ -833,8 +952,8 @@ func _spawn_sword() -> void:
# Blade collider spans the steel (hilt at local origin, blade running +Z out
# to ~1.4 m). Only monitors while the sword is in hand (see _carry_sword).
var blade_cap := CapsuleShape3D.new()
blade_cap.radius = 0.10
blade_cap.height = 1.10
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)