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:
+183
-64
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user