Release 0.2.0: device-aware controls, spear, settings, shaders

Show touch controls only on touch platforms and keyboard hints only on
desktop, via a shared Controls.use_touch_ui() gate (is_touchscreen_available
is unreliable with emulate_touch_from_mouse). Bumps version to 0.2.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 19:39:37 +03:00
parent ce6f3d7075
commit 56593c58cf
35 changed files with 1885 additions and 242 deletions
+185 -124
View File
@@ -22,6 +22,7 @@ var _sim: PhysicalBoneSimulator3D = null
var _anim_player: AnimationPlayer = null
var _wander_target: Vector3 = Vector3.ZERO
var _idle_timer: float = 0.0
var _taunt_swap: float = 0.0
var _idle_anim: StringName = _ANIM_IDLE
var _bull: CharacterBody3D = null
var _step_dir: Vector3 = Vector3.ZERO
@@ -50,8 +51,8 @@ var _drawing: bool = false
var _sheathing: bool = false
var _draw_timer: float = 0.0
var _draw_len: float = 0.0
var _resword_timer: float = 0.0
var _sword_blade_area: Area3D = null
var _spear_node: Node3D = null
var _blade_hit_cd: float = 0.0
var _death_player: AudioStreamPlayer = null
var _blood_burst: CPUParticles3D = null
@@ -74,6 +75,9 @@ const _ANIM_TAUNTS: Array[StringName] = [&"Taunt", &"Taunt_B", &"Taunt_C"]
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")
const _GRAVITY: float = 9.8 # arena gravity, for the spear's ballistic arc
const _STEER_LOOKAHEAD: float = 3.5 # longer = turns before hitting wall
const _STEER_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec
@@ -95,8 +99,9 @@ func _ready() -> void:
if _skeleton:
_sim = MatadorRagdoll.build(_skeleton)
if _anim_player:
for anim in [_ANIM_RUN, _ANIM_ATTACK, _ANIM_ROLL] + _ANIM_TAUNTS:
for anim in [_ANIM_RUN, _ANIM_ATTACK] + _ANIM_TAUNTS:
_ensure_loop(anim)
_flatten_roll_travel()
if not _anim_player.has_animation(_ANIM_RUN):
push_warning("Matador: expected animations not found. Available: %s" %
str(_anim_player.get_animation_list()))
@@ -104,7 +109,7 @@ func _ready() -> void:
_anim_player.play(_ANIM_IDLE)
_setup_sword()
DP.any_changed.connect(_on_dp_changed)
_throw_aim_cd = randf_range(0.3, 1.0)
_throw_aim_cd = randf_range(2.0, 5.0)
_hit_area.body_entered.connect(_on_body_entered)
_pick_wander_target()
_blood_burst = preload("res://blood_burst.gd").new()
@@ -148,12 +153,6 @@ 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)
# Pull a replacement sword from the holster a beat after throwing the last one.
if _state != State.RAGDOLL and not is_instance_valid(_sword_node) \
and (_sword_rest_attach != null or _sword_hand_attach != null):
_resword_timer -= delta
if _resword_timer <= 0.0:
_spawn_sword()
if _state != State.RAGDOLL and _bull != null:
_update_ai_state(delta)
@@ -216,20 +215,30 @@ func _choose_intent() -> Intent:
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")
# Only the nearest few matadors work the bull at once; the rest hang back and
# taunt (see _engagement_rank). This turns a crowd into a bullring — matadors
# spreading out and taking turns — instead of a swarm all piling on together.
var is_engager := _engagement_rank() < maxi(1, int(DP.f("mat_engage_slots")))
# Supporting matadors strut and circle constantly; the lead only struts between
# passes, once the bull is back out past horn reach.
var strut := 0.65 if not is_engager else 0.3 * beyond
var engage_mult := 1.0 if is_engager else 0.15
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,
# Showmanship: circle the bull at a taunting distance and pose for the crowd.
Intent.WANDER: DP.f("mat_w_showmanship") * strut,
# 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.
# 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,
* (1.0 - 0.55 * danger) * dodge_ready * engage_mult,
# 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,
# Hurl the blade — an occasional ranged flourish from an engaging matador when
# there's a clean lane, not a barrage (see mat_w_throw). Supporters never pelt.
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")
@@ -277,6 +286,40 @@ 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.
func _engagement_rank() -> int:
if _bull == null:
return 0
var my_d := global_position.distance_squared_to(_bull.global_position)
var rank := 0
for node: Node in get_tree().get_nodes_in_group(&"matador"):
if node == self:
continue
var other := node as Node3D
if other == null or not is_instance_valid(other) or not other.call(&"is_active"):
continue
if other.global_position.distance_squared_to(_bull.global_position) < my_d:
rank += 1
return rank
# Live and vying for the bull's attention — a ragdolled corpse doesn't hold a slot.
func is_active() -> bool:
return _state != State.RAGDOLL
# True when this matador is safely out of the action — not one of the engaging few,
# and with the bull far enough off to strike a long, unhurried taunt for the crowd.
func _out_of_harm() -> bool:
if _bull == null:
return true
if _engagement_rank() < maxi(1, int(DP.f("mat_engage_slots"))):
return false
return global_position.distance_to(_bull.global_position) >= DP.f("mat_taunt_ring") * 0.6
# Bull is moving fast and aimed within ~30° of the matador
func _is_charge_incoming() -> bool:
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
@@ -351,6 +394,14 @@ func _tick_wander(delta: float) -> void:
if _drawing or _sheathing or _idle_timer > 0.0:
_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.
if _bull != null and _idle_timer > 0.0:
_face_point(_bull.global_position, delta, _TURN_MOVE)
# Cycle gestures through a long hold so a posing matador stays lively.
_taunt_swap -= delta
if _taunt_swap <= 0.0 and not _drawing and not _sheathing:
_idle_anim = _pick_taunt()
_taunt_swap = randf_range(1.5, 3.0)
_play_anim(_idle_anim)
move_and_slide()
return
@@ -358,8 +409,12 @@ func _tick_wander(delta: float) -> void:
var to_target := Vector3(
_wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z)
if to_target.length() < 0.8:
_idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max"))
# A matador out of harm's way settles into a long taunt for the crowd; one still
# in the mix only pauses briefly between passes.
_idle_timer = randf_range(DP.f("mat_taunt_hold") * 0.7, DP.f("mat_taunt_hold") * 1.3) \
if _out_of_harm() else randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max"))
_idle_anim = _pick_taunt()
_taunt_swap = randf_range(1.5, 3.0)
_pick_wander_target()
else:
var dir := _steer_clear(to_target.normalized(), delta)
@@ -528,7 +583,8 @@ func _start_throw() -> void:
_throw_timer = DP.f("mat_throw_windup")
velocity = Vector3.ZERO
_cancel_transitions()
_carry_sword(true) # blade must be in hand for the wind-up and release
_carry_sword(false) # the sword is melee-only — holster it for the throw
_spawn_spear_in_hand() # a fresh spear is drawn for the overhand throw
if _anim_player:
_anim_player.pause()
var to_bull := _bull.global_position - global_position
@@ -552,8 +608,8 @@ func _tick_throw(delta: float) -> void:
_pose_throw_arm(phase)
var t_rel := DP.f("mat_throw_release")
if prev < t_rel and phase >= t_rel and is_instance_valid(_sword_node):
_release_sword()
if prev < t_rel and phase >= t_rel and is_instance_valid(_spear_node):
_release_spear()
if _throw_timer <= 0.0:
_end_throw()
@@ -562,20 +618,18 @@ func _tick_throw(delta: float) -> void:
func _end_throw() -> void:
_state = State.FLEE
_clear_spear() # drop any spear still in hand if the wind-up was cut short
_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)
# Space throws well apart so a hurl stays a flourish, not a barrage.
_throw_aim_cd = randf_range(4.0, 7.0)
_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).
# Blend to locomotion so the manually-posed throw arm eases out (resume() snaps).
if _anim_player:
_anim_player.play(_ANIM_RUN)
# Drive arm_R + forearm_R through a cock-back → release arc while the
# AnimationPlayer is paused. Swing is on bone-local X (see CLAUDE.md rig notes);
# angles are DP-tunable so the arc can be dialled in per rig.
# AnimationPlayer is paused. Swing is on bone-local X (see CLAUDE.md), DP-tunable.
func _pose_throw_arm(phase: float) -> void:
if not _skeleton:
return
@@ -605,82 +659,59 @@ func _set_bone_swing(bone: String, deg: float) -> void:
_skeleton.set_bone_pose_rotation(idx, rest * Quaternion(Vector3.RIGHT, deg_to_rad(deg)))
# Detach the sword from the hand and launch it as a spinning physics projectile
# that damages the bull on contact, then settles and despawns.
func _release_sword() -> void:
if not is_instance_valid(_sword_node):
# Draw a fresh spear into the throwing hand for the wind-up (no melee collider).
func _spawn_spear_in_hand() -> void:
if is_instance_valid(_spear_node) or _sword_hand_attach == null:
return
var sword := _sword_node
var blade := _sword_blade_area
var gx := sword.global_transform
_sword_node = null
_sword_blade_area = null
_sword_in_hand = false
_resword_timer = DP.f("mat_resword_delay")
# The RigidBody owns collisions now — silence the melee blade Area it carries.
if is_instance_valid(blade):
blade.monitoring = false
_spear_node = _SPEAR_SCENE.instantiate() as Node3D
_sword_hand_attach.add_child(_spear_node)
_spear_node.position = Vector3(
DP.f("spear_pos_x"), DP.f("spear_pos_y"), DP.f("spear_pos_z"))
_spear_node.rotation_degrees = Vector3(
DP.f("spear_rot_x"), DP.f("spear_rot_y"), DP.f("spear_rot_z"))
var rb := RigidBody3D.new()
rb.collision_layer = 0
rb.collision_mask = 1
rb.contact_monitor = true
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. Fattened so a fast throw
# reliably overlaps the bull's collision spheres instead of tunnelling past.
var cap := CapsuleShape3D.new()
cap.radius = 0.14
cap.height = 1.4
var cs := CollisionShape3D.new()
cs.shape = cap
cs.position = Vector3(0.0, 0.0, 0.7)
cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
rb.add_child(cs)
func _clear_spear() -> void:
if is_instance_valid(_spear_node):
_spear_node.queue_free()
_spear_node = null
# 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)
# Hurl the held spear on a ballistic arc that lands on the bull's low body. The
# launch velocity is solved so gravity (mat_throw_gravity) carries it down onto the
# lead point; SpearProjectile aims the shaft along the arc and embeds it on a hit.
func _release_spear() -> void:
if not is_instance_valid(_spear_node):
return
var spear := _spear_node
var origin := spear.global_position
_spear_node = null
var hs := maxf(DP.f("mat_throw_speed"), 0.1) # horizontal speed
var g := _GRAVITY * maxf(DP.f("mat_throw_gravity"), 0.05)
# Aim low (the bull's collision spheres ride ~0.5 m below its origin) and lead it
# across the arc's flight time.
var target := origin + _throw_dir * 5.0
if is_instance_valid(_bull):
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 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.
target = _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0)
var lead := target - origin
lead.y = 0.0
target += _bull.velocity * (lead.length() / hs)
var flat := target - origin
var rise := flat.y
flat.y = 0.0
var dist := flat.length()
var hdir := flat.normalized() if dist > 0.01 else _throw_dir
# Aim scatter so the arc can be read and sidestepped, not a hitscan.
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]
rb.body_entered.connect(func(body: Node) -> void:
if hit_done[0]:
return
if body.is_in_group(&"player"):
hit_done[0] = true
body.call(&"take_sword_hit", "thrown")
)
var cleanup := get_tree().create_timer(6.0)
cleanup.timeout.connect(func() -> void:
if is_instance_valid(rb):
rb.queue_free()
)
hdir = hdir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized()
# Ballistic solve: with horizontal speed hs over distance dist, the flight lasts t;
# pick the vertical launch speed that lands the arc on the aim point's height.
var t := dist / hs
var vy := (rise + 0.5 * g * t * t) / t
var vel := hdir * hs + Vector3.UP * vy
SpearProjectile.launch(get_tree().current_scene, spear, origin, vel,
maxf(DP.f("mat_throw_gravity"), 0.05))
func _tick_ragdoll(_delta: float) -> void:
@@ -756,6 +787,7 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0)
_state = State.RAGDOLL
if _sword_blade_area:
_sword_blade_area.monitoring = false
_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)
cleanup_timer.timeout.connect(func() -> void:
@@ -866,6 +898,32 @@ func _ensure_loop(anim_name: StringName) -> void:
anim.loop_mode = Animation.LOOP_LINEAR
# Roll was authored *travelling* (COG drives ~4.5 m forward on Z), which snapped the
# mesh back when the clip ended. _tick_roll moves the dodge, so strip that COG travel.
func _flatten_roll_travel() -> void:
if _anim_player == null or not _anim_player.has_animation(_ANIM_ROLL):
return
var anim := _anim_player.get_animation(_ANIM_ROLL)
if anim.has_meta(&"_travel_flattened"):
return
anim.set_meta(&"_travel_flattened", true)
var cog := anim.find_track(NodePath("Armature/Skeleton3D:COG"), Animation.TYPE_POSITION_3D)
if cog < 0:
return
# Snapshot the COG travel before mutating (flattening COG first would zero it).
var travel: Dictionary = {}
for i in anim.get_track_count():
if anim.track_get_type(i) == Animation.TYPE_POSITION_3D:
for k in anim.track_get_key_count(i):
var t := anim.track_get_key_time(i, k)
travel[t] = anim.position_track_interpolate(cog, t).z
for i in anim.get_track_count():
if anim.track_get_type(i) == Animation.TYPE_POSITION_3D:
for k in anim.track_get_key_count(i):
var v: Vector3 = anim.track_get_key_value(i, k)
anim.track_set_key_value(i, k, v - Vector3(0.0, 0.0, travel[anim.track_get_key_time(i, k)]))
func _pick_taunt() -> StringName:
var choices: Array[StringName] = []
for anim in _ANIM_TAUNTS:
@@ -893,13 +951,25 @@ func _play_anim(anim_name: StringName) -> void:
func _pick_wander_target() -> void:
# 35% chance: pick a point orbiting close to the bull for a daring pass
if _bull != null and randf() < 0.35:
var angle := randf() * TAU
var r := randf_range(1.5, 4.0)
# Circle the bull for showmanship: mostly settle on the taunt ring (spreading the
# crowd out around it), with an occasional close pass for daring flair.
if _bull != null:
var bull_flat := _bull.global_position
bull_flat.y = 0.0
var r: float
if randf() < 0.2:
r = randf_range(1.5, 4.0) # close daring pass
else:
var ring := DP.f("mat_taunt_ring")
r = randf_range(ring * 0.7, ring * 1.3) # the showmanship ring
var angle := randf() * TAU
_wander_target = bull_flat + Vector3(cos(angle) * r, 0.0, sin(angle) * r)
# Keep the pose inside the fighting floor rather than backing onto the benches.
var arena_r := DP.f("arena_spawn_radius")
var flat := Vector2(_wander_target.x, _wander_target.z)
if flat.length() > arena_r:
flat = flat.normalized() * arena_r
_wander_target = Vector3(flat.x, 0.0, flat.y)
return
var radius := DP.f("mat_wander_radius")
var angle := randf() * TAU
@@ -909,13 +979,10 @@ func _pick_wander_target() -> void:
# ── Sword attachment ──────────────────────────────────────────────────────────
# The rig (matador_v03) carries two purpose-built bones for the sword:
# weapon_bone — the grip pose in the right hand (drawn / fighting)
# weapon_rest_bone — the holstered pose at the hip (sheathed / wandering)
# The sword lives in the holster by default and is carried to the hand while the
# matador is in a combat state (see _carry_sword). Each bone has its own
# DP-tunable local offset/rotation (sword_* for the hand, sword_rest_* for the
# holster) so the two poses can be seated independently without re-exporting.
# 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.
func _setup_sword() -> void:
if not _skeleton:
return
@@ -934,8 +1001,7 @@ func _setup_sword() -> void:
_spawn_sword()
# Instantiate a fresh sword into the holster, with its own blade collider. Called
# at spawn and again after a throw, so a matador can keep drawing replacements.
# Instantiate the matador's sword into the holster, with its own blade collider.
func _spawn_sword() -> void:
if is_instance_valid(_sword_node):
return
@@ -949,15 +1015,14 @@ func _spawn_sword() -> void:
_drawing = false
_apply_sword_grip()
# 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).
# 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)
# CapsuleShape3D runs along Y by default; tip it onto +Z to follow the blade.
blade_cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
_sword_blade_area = Area3D.new()
@@ -969,12 +1034,11 @@ func _spawn_sword() -> void:
_sword_blade_area.body_entered.connect(_on_blade_hit)
# The sword is carried by ACTION, not distance: drawn only while striking
# (ATTACK / THROW) and holstered while running, dodging, bracing or taunting.
# THROW snaps the blade out (its arm cock-back covers the motion); everything
# else animates with the Draw_weapon clip — forward to draw, reversed to sheathe.
# 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.
func _wants_sword_drawn() -> bool:
return _state == State.ATTACK or _state == State.THROW \
return _state == State.ATTACK \
or _state == State.BRACE or _state == State.SIDESTEP
@@ -1019,8 +1083,7 @@ func _begin_sheathe() -> void:
_anim_player.play(_ANIM_DRAW, -1.0, -_draw_speed(), true)
# Progress a draw or sheathe; the blade reparents at the grab point (mirrored for
# the reverse sheathe), and the transition ends when the clip's runtime elapses.
# Progress a draw or sheathe; the blade reparents at the grab point (mirrored on the reverse).
func _advance_draw(delta: float) -> void:
if not _drawing and not _sheathing:
return
@@ -1049,9 +1112,7 @@ func _draw_speed() -> float:
return maxf(DP.f("mat_draw_speed"), 0.1)
# Move the sword between the hand grip and the hip holster. Reparenting follows
# whichever bone the BoneAttachment tracks; the local grip transform is reapplied
# so the seating is identical in both sockets.
# Move the sword between hand grip and hip holster, reapplying the local seating.
func _carry_sword(in_hand: bool) -> void:
if not is_instance_valid(_sword_node):
return