From 115872b98f594a9f73e210ac7e40d085305d0e3d Mon Sep 17 00:00:00 2001 From: Richard Aasa Date: Wed, 24 Jun 2026 23:11:19 +0300 Subject: [PATCH] sword holster, health --- Matador.tscn | 2 +- debug_params.gd | 30 +++ hud.gd | 58 ++++++ matador.gd | 496 ++++++++++++++++++++++++++++++++++++++++++------ matador_2.gd | 2 +- player.gd | 11 +- scene.tscn | 4 - 7 files changed, 541 insertions(+), 62 deletions(-) diff --git a/Matador.tscn b/Matador.tscn index 464e736..7aaaf92 100644 --- a/Matador.tscn +++ b/Matador.tscn @@ -1,7 +1,7 @@ [gd_scene format=3 uid="uid://matador_scene_v1"] [ext_resource type="Script" path="res://matador.gd" id="1_matgd"] -[ext_resource type="PackedScene" uid="uid://ch5fiscgxfmpc" path="res://Blender/matador.glb" id="2_matfbx"] +[ext_resource type="PackedScene" uid="uid://eu2lqjhowj4t" path="res://Assets/Matador.glb" id="2_matfbx"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"] radius = 0.25 diff --git a/debug_params.gd b/debug_params.gd index f0ef528..3cbab26 100644 --- a/debug_params.gd +++ b/debug_params.gd @@ -115,6 +115,36 @@ func _register_all() -> void: _reg_f("Matador", "mat_attack_min_dist", 2.5, 0.5, 6.0, 0.1) _reg_f("Matador", "mat_roll_chance", 0.35, 0.0, 1.0, 0.05) _reg_f("Matador", "mat_roll_duration", 0.45, 0.1, 1.5, 0.05) + # Draw the sword from the holster once the bull closes to within this range; + # re-sheathe when it retreats well beyond it (hysteresis in _update_sword_carry). + _reg_f("Matador", "mat_draw_range", 12.0, 2.0, 40.0) + # Cross-fade between matador animation clips — smooths Run/Attack/Taunt cuts. + _reg_f("Matador", "mat_anim_blend", 0.12, 0.0, 0.5, 0.01) + # ── Sword ───────────────────────────────────────────────────────────────── + # Local seating of the sword on its weapon bones (hand grip + hip holster). + # Defaults are identity — the bones are oriented in Blender to seat the sword + # directly; these exist to fine-tune in-game without re-exporting. + _reg_f("Sword", "sword_pos_x", 0.0, -1.0, 1.0, 0.01) + _reg_f("Sword", "sword_pos_y", 0.0, -1.0, 1.0, 0.01) + _reg_f("Sword", "sword_pos_z", 0.0, -1.0, 1.0, 0.01) + _reg_f("Sword", "sword_rot_x", 0.0, -180.0, 180.0, 1.0) + _reg_f("Sword", "sword_rot_y", 0.0, -180.0, 180.0, 1.0) + _reg_f("Sword", "sword_rot_z", 0.0, -180.0, 180.0, 1.0) + # ── Sword throw (matador ranged attack) ────────────────────────────────── + # Probability of committing to a throw each time the matador becomes eligible; + # kept low so they favour closing in for a melee Attack over throwing. + _reg_f("Sword", "mat_throw_chance", 0.15, 0.0, 1.0, 0.05) + # Delay before a fresh sword appears in the holster after one is thrown. + _reg_f("Sword", "mat_resword_delay", 2.0, 0.0, 10.0, 0.5) + _reg_f("Sword", "mat_throw_range", 18.0, 5.0, 40.0) + _reg_f("Sword", "mat_throw_min_dist", 5.0, 2.0, 15.0, 0.1) + _reg_f("Sword", "mat_throw_windup", 0.7, 0.2, 2.0, 0.05) + _reg_f("Sword", "mat_throw_release", 0.55, 0.1, 0.95, 0.01) + _reg_f("Sword", "mat_throw_speed", 18.0, 5.0, 60.0) + _reg_f("Sword", "mat_throw_spin", 14.0, 0.0, 40.0) + _reg_f("Sword", "mat_throw_arm_cock", 70.0,-180.0, 180.0, 1.0) + _reg_f("Sword", "mat_throw_arm_release", -90.0,-180.0, 180.0, 1.0) + _reg_f("Sword", "mat_throw_elbow", 80.0,-180.0, 180.0, 1.0) # ── Abilities ───────────────────────────────────────────────────────────── _reg_f("Abilities", "kick_cooldown", 1.5, 0.2, 10.0, 0.1) _reg_f("Abilities", "kick_range", 3.5, 0.5, 8.0, 0.1) diff --git a/hud.gd b/hud.gd index f52a120..17d4b2e 100644 --- a/hud.gd +++ b/hud.gd @@ -20,6 +20,8 @@ var _toggle_btn: Button var _spawner: Node var _player: Node = null var _cd_overlays: Array[ColorRect] = [] +var _health_segments: Array[ColorRect] = [] +var _health_connected: bool = false func _ready() -> void: @@ -27,6 +29,7 @@ func _ready() -> void: process_mode = Node.PROCESS_MODE_ALWAYS _build_controls_panel() _build_ability_bar() + _build_health_bar() _find_spawner.call_deferred() @@ -36,6 +39,10 @@ func _process(_delta: float) -> void: if not players.is_empty(): _player = players[0] return + if not _health_connected: + _health_connected = true + _player.health_changed.connect(_on_health_changed) + _on_health_changed(_player.health) for i: int in _cd_overlays.size(): var fraction: float = _player.call(&"ability_cooldown_fraction", i) var ov := _cd_overlays[i] @@ -68,6 +75,57 @@ func _on_reset_pressed() -> void: _spawner.reset_spawn() +func _build_health_bar() -> void: + var bar := HBoxContainer.new() + bar.add_theme_constant_override("separation", 4) + bar.anchor_left = 0.5 + bar.anchor_right = 0.5 + bar.anchor_top = 0.0 + bar.anchor_bottom = 0.0 + bar.grow_horizontal = Control.GROW_DIRECTION_BOTH + bar.offset_left = -90.0 + bar.offset_top = 16.0 + bar.offset_bottom = 16.0 + add_child(bar) + + var bg_style := StyleBoxFlat.new() + bg_style.bg_color = _BG + bg_style.corner_radius_top_left = 4 + bg_style.corner_radius_top_right = 4 + bg_style.corner_radius_bottom_left = 4 + bg_style.corner_radius_bottom_right = 4 + bg_style.border_width_left = 1 + bg_style.border_width_right = 1 + bg_style.border_width_top = 1 + bg_style.border_width_bottom = 1 + bg_style.border_color = _BORDER + bg_style.content_margin_left = 6.0 + bg_style.content_margin_right = 6.0 + bg_style.content_margin_top = 6.0 + bg_style.content_margin_bottom = 6.0 + + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", bg_style) + bar.add_child(panel) + + var inner := HBoxContainer.new() + inner.add_theme_constant_override("separation", 3) + panel.add_child(inner) + + for i: int in 10: + var seg := ColorRect.new() + seg.custom_minimum_size = Vector2(14.0, 14.0) + seg.color = Color(0.72, 0.08, 0.08) + inner.add_child(seg) + _health_segments.append(seg) + + +func _on_health_changed(new_health: int) -> void: + for i: int in _health_segments.size(): + _health_segments[i].color = Color(0.72, 0.08, 0.08) if i < new_health \ + else Color(0.20, 0.10, 0.10) + + func _build_ability_bar() -> void: var bar := HBoxContainer.new() bar.add_theme_constant_override("separation", 10) diff --git a/matador.gd b/matador.gd index cdd5ee0..c41391b 100644 --- a/matador.gd +++ b/matador.gd @@ -6,8 +6,9 @@ extends CharacterBody3D # 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 -enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, RAGDOLL } +enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL } signal killed @@ -24,27 +25,53 @@ var _step_timer: float = 0.0 var _dodge_cd: float = 0.0 var _will_dodge: bool = true var _attack_timer: float = 0.0 +var _swing_timer: float = 0.0 var _roll_timer: float = 0.0 var _roll_dir: Vector3 = Vector3.ZERO +var _throw_timer: float = 0.0 +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 _ole_player: AudioStreamPlayer = null +var _sword_hand_attach: BoneAttachment3D = null +var _sword_rest_attach: BoneAttachment3D = null +var _sword_node: Node3D = null +var _sword_in_hand: bool = false +var _drawing: 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 _blade_hit_cd: float = 0.0 var _death_player: AudioStreamPlayer = null var _blood_burst: CPUParticles3D = null @onready var _mesh: Node3D = $matador2 @onready var _hit_area: Area3D = $HitArea -@onready var _body_col: CollisionShape3D = $CollisionShape3D const _ANIM_RUN: StringName = &"Run" const _ANIM_IDLE: StringName = &"Taunt" const _ANIM_ATTACK: StringName = &"Attack" const _ANIM_ROLL: StringName = &"Roll" +const _ANIM_DRAW: StringName = &"Draw_weapon" + +# Fraction of the draw clip at which the hand grabs the sword — the sword +# 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") -# Candidate steering offsets (radians) tried in order when a wall is ahead -const _STEER_ANGLES: Array = [0.0, -0.35, 0.35, -0.7, 0.7, -1.1, 1.1, PI] const _STEER_LOOKAHEAD: float = 3.5 # longer = turns before hitting wall 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 func _ready() -> void: @@ -59,7 +86,11 @@ func _ready() -> void: if not _anim_player.has_animation(_ANIM_RUN): push_warning("Matador: expected animations not found. Available: %s" % str(_anim_player.get_animation_list())) + _anim_player.playback_default_blend_time = DP.f("mat_anim_blend") _anim_player.play(_ANIM_IDLE) + _setup_sword() + DP.any_changed.connect(_on_dp_changed) + _throw_aim_cd = randf_range(0.8, 2.5) _hit_area.body_entered.connect(_on_body_entered) _pick_wander_target() _setup_blood_burst() @@ -75,17 +106,35 @@ func _ready() -> void: add_child(_death_player) +func _on_dp_changed(_key: String, _value: Variant) -> void: + _apply_sword_grip() + if _anim_player: + _anim_player.playback_default_blend_time = DP.f("mat_anim_blend") + + +func _acquire_bull() -> void: + if _bull != null: + return + var players := get_tree().get_nodes_in_group(&"player") + if not players.is_empty(): + _bull = players[0] as CharacterBody3D + + func _physics_process(delta: float) -> void: - if _bull == null: - var players := get_tree().get_nodes_in_group(&"player") - if not players.is_empty(): - _bull = players[0] as CharacterBody3D + _acquire_bull() if not is_on_floor(): velocity += get_gravity() * delta - _dodge_cd = maxf(0.0, _dodge_cd - delta) - + _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() @@ -96,11 +145,26 @@ func _physics_process(delta: float) -> void: State.SIDESTEP: _tick_sidestep(delta) State.ATTACK: _tick_attack(delta) State.ROLL: _tick_roll(delta) + State.THROW: _tick_throw(delta) State.RAGDOLL: _tick_ragdoll(delta) + _update_sword_carry() + _advance_draw(delta) + func _update_ai_state() -> void: + if _state == State.THROW: + return 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 _sword_in_hand 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"): + _start_throw() + return + _throw_aim_cd = randf_range(2.5, 5.0) match _state: State.WANDER: if dist < DP.f("mat_flee_range"): @@ -156,7 +220,7 @@ func _steer_clear(desired_dir: Vector3, delta: float) -> Vector3: if space.intersect_ray(params).is_empty(): _steer_dir = desired_dir return desired_dir - var candidates: Array = [-0.35, 0.35, -0.7, 0.7, -1.1, 1.1, PI] + var candidates := _STEER_CANDIDATES.duplicate() candidates.shuffle() for angle: float in candidates: var dir := desired_dir.rotated(Vector3.UP, angle) @@ -169,41 +233,62 @@ func _steer_clear(desired_dir: Vector3, delta: float) -> Vector3: return desired_dir +# Ramp horizontal velocity toward dir*spd; vertical (gravity) is left untouched. +func _accelerate(dir: Vector3, spd: float, delta: float) -> void: + velocity.x = move_toward(velocity.x, dir.x * spd, spd * _ACCEL_FACTOR * delta) + velocity.z = move_toward(velocity.z, dir.z * spd, spd * _ACCEL_FACTOR * delta) + + +func _decelerate(rate: float, delta: float) -> void: + velocity.x = move_toward(velocity.x, 0.0, rate * delta) + velocity.z = move_toward(velocity.z, 0.0, rate * delta) + + +# Turn the mesh to face travel direction — skipped at near-zero speed so a +# stopping matador doesn't snap to atan2(0, 0) == facing +Z (a jitter source). +func _face_velocity(delta: float, rate: float) -> void: + if Vector2(velocity.x, velocity.z).length_squared() > 0.04: + _mesh.rotation.y = lerp_angle( + _mesh.rotation.y, atan2(velocity.x, velocity.z), delta * rate) + + +func _face_point(point: Vector3, delta: float, rate: float) -> void: + var to := point - global_position + to.y = 0.0 + if to.length_squared() > 0.01: + _mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(to.x, to.z), delta * rate) + + func _tick_wander(delta: float) -> void: - if _idle_timer > 0.0: - _idle_timer -= delta - velocity.x = move_toward(velocity.x, 0.0, 10.0 * delta) - velocity.z = move_toward(velocity.z, 0.0, 10.0 * delta) + # Plant the feet while unsheathing instead of sliding through the draw clip. + if _drawing or _idle_timer > 0.0: + _idle_timer = maxf(0.0, _idle_timer - delta) + _decelerate(10.0, delta) _play_anim(_ANIM_IDLE) move_and_slide() return - var dx: float = _wander_target.x - global_position.x - var dz: float = _wander_target.z - global_position.z - if Vector2(dx, dz).length() < 0.8: + 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")) _pick_wander_target() else: - var dir := _steer_clear(Vector3(dx, 0.0, dz).normalized(), delta) - var spd := DP.f("mat_walk_speed") - velocity.x = move_toward(velocity.x, dir.x * spd, spd * 8.0 * delta) - velocity.z = move_toward(velocity.z, dir.z * spd, spd * 8.0 * delta) - _mesh.rotation.y = lerp_angle( - _mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 8.0) + var dir := _steer_clear(to_target.normalized(), delta) + _accelerate(dir, DP.f("mat_walk_speed"), delta) + _face_velocity(delta, _TURN_MOVE) _play_anim(_ANIM_RUN) move_and_slide() func _tick_flee(delta: float) -> void: - var away := (global_position - _bull.global_position) + var away := global_position - _bull.global_position away.y = 0.0 if away.length() < 0.01: away = Vector3(randf() - 0.5, 0.0, randf() - 0.5) var dir := _steer_clear(away.normalized(), delta) - var spd := DP.f("mat_flee_speed") - velocity.x = move_toward(velocity.x, dir.x * spd, spd * 8.0 * delta) - velocity.z = move_toward(velocity.z, dir.z * spd, spd * 8.0 * delta) - _mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 8.0) + _accelerate(dir, DP.f("mat_flee_speed"), delta) + _face_velocity(delta, _TURN_MOVE) _play_anim(_ANIM_RUN) move_and_slide() @@ -227,13 +312,8 @@ func _start_brace() -> void: func _tick_brace(delta: float) -> void: - velocity.x = move_toward(velocity.x, 0.0, 18.0 * delta) - velocity.z = move_toward(velocity.z, 0.0, 18.0 * delta) - var to_bull := (_bull.global_position - global_position) - to_bull.y = 0.0 - if to_bull.length() > 0.1: - _mesh.rotation.y = lerp_angle( - _mesh.rotation.y, atan2(to_bull.x, to_bull.z), delta * 14.0) + _decelerate(18.0, delta) + _face_point(_bull.global_position, delta, _TURN_FACE) _play_anim(_ANIM_ATTACK) move_and_slide() @@ -260,7 +340,7 @@ func _tick_sidestep(delta: float) -> void: var spd := DP.f("mat_step_speed") velocity.x = _step_dir.x * spd velocity.z = _step_dir.z * spd - _mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 20.0) + _face_velocity(delta, _TURN_SHARP) _play_anim(_ANIM_ROLL) move_and_slide() @@ -268,38 +348,46 @@ func _tick_sidestep(delta: float) -> void: func _start_attack() -> void: _state = State.ATTACK _attack_timer = DP.f("mat_attack_duration") + _swing_timer = 0.0 _dodge_cd = DP.f("mat_dodge_cooldown") func _tick_attack(delta: float) -> void: _attack_timer -= delta - var to_bull := (_bull.global_position - global_position) + _swing_timer = maxf(0.0, _swing_timer - delta) + _face_point(_bull.global_position, delta, _TURN_FACE) + var to_bull := _bull.global_position - global_position to_bull.y = 0.0 var dist := to_bull.length() - if dist > 0.1: - _mesh.rotation.y = lerp_angle( - _mesh.rotation.y, atan2(to_bull.x, to_bull.z), delta * 10.0) + # 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"): + _swing_timer = _anim_length(_ANIM_ATTACK, 0.9) - if dist > DP.f("mat_attack_min_dist"): - var dir := _steer_clear(to_bull.normalized(), delta) - var spd := DP.f("mat_attack_speed") - velocity.x = move_toward(velocity.x, dir.x * spd, spd * 8.0 * delta) - velocity.z = move_toward(velocity.z, dir.z * spd, spd * 8.0 * delta) + if _swing_timer > 0.0: + _decelerate(22.0, delta) + _play_anim(_ANIM_ATTACK) else: - velocity.x = move_toward(velocity.x, 0.0, 14.0 * delta) - velocity.z = move_toward(velocity.z, 0.0, 14.0 * delta) + var dir := _steer_clear(to_bull.normalized(), delta) + _accelerate(dir, DP.f("mat_attack_speed"), delta) + _play_anim(_ANIM_RUN) - _play_anim(_ANIM_ATTACK) move_and_slide() func _enter_roll(hit_dir: Vector3) -> void: _state = State.ROLL - _roll_timer = DP.f("mat_roll_duration") + # Match the state to the actual clip so the roll plays through once instead of + # cutting to Run partway. mat_roll_duration is only a fallback. + _roll_timer = _anim_length(_ANIM_ROLL, DP.f("mat_roll_duration")) hit_dir.y = 0.0 var side := 1.0 if randf() > 0.5 else -1.0 _roll_dir = hit_dir.normalized().rotated(Vector3.UP, PI * 0.5 * side) + if _anim_player and _anim_player.has_animation(_ANIM_ROLL): + _anim_player.play(_ANIM_ROLL) + _anim_player.seek(0.0, true) func _tick_roll(delta: float) -> void: @@ -311,11 +399,143 @@ func _tick_roll(delta: float) -> void: var spd := DP.f("mat_step_speed") velocity.x = _roll_dir.x * spd velocity.z = _roll_dir.z * spd - _mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 20.0) + _face_velocity(delta, _TURN_SHARP) _play_anim(_ANIM_ROLL) move_and_slide() +func _start_throw() -> void: + _state = State.THROW + _throw_timer = DP.f("mat_throw_windup") + velocity = Vector3.ZERO + if _anim_player: + _anim_player.pause() + var to_bull := _bull.global_position - global_position + to_bull.y = 0.0 + _throw_dir = to_bull.normalized() if to_bull.length() > 0.1 else _mesh.global_transform.basis.z + + +func _tick_throw(delta: float) -> void: + _decelerate(20.0, delta) + _face_point(_bull.global_position, delta, _TURN_FACE) + + var to_bull := _bull.global_position - global_position + to_bull.y = 0.0 + if to_bull.length() > 0.1: + _throw_dir = to_bull.normalized() + + var dur := maxf(DP.f("mat_throw_windup"), 0.001) + var prev := clampf(1.0 - _throw_timer / dur, 0.0, 1.0) + _throw_timer -= delta + var phase := clampf(1.0 - _throw_timer / dur, 0.0, 1.0) + _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 _throw_timer <= 0.0: + _end_throw() + move_and_slide() + + +func _end_throw() -> void: + _state = State.FLEE + _dodge_cd = DP.f("mat_dodge_cooldown") + # 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: + _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. +func _pose_throw_arm(phase: float) -> void: + if not _skeleton: + return + var t_rel := DP.f("mat_throw_release") + var cock := DP.f("mat_throw_arm_cock") + var release := DP.f("mat_throw_arm_release") + var elbow := DP.f("mat_throw_elbow") + var arm_ang: float + var elbow_ang: float + if phase < t_rel: + var u := phase / maxf(t_rel, 0.001) + arm_ang = lerpf(0.0, cock, u) + elbow_ang = lerpf(0.0, elbow, u) + else: + var u := (phase - t_rel) / maxf(1.0 - t_rel, 0.001) + arm_ang = lerpf(cock, release, u) + elbow_ang = lerpf(elbow, 0.0, u) + _set_bone_swing("arm_R", arm_ang) + _set_bone_swing("forearm_R", elbow_ang) + + +func _set_bone_swing(bone: String, deg: float) -> void: + var idx := _skeleton.find_bone(bone) + if idx < 0: + return + var rest := _skeleton.get_bone_rest(idx).basis.get_rotation_quaternion() + _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): + 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 + + var rb := RigidBody3D.new() + rb.collision_layer = 0 + rb.collision_mask = 1 + rb.contact_monitor = true + rb.max_contacts_reported = 4 + 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. + var cap := CapsuleShape3D.new() + cap.radius = 0.06 + 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) + + var dir := (_throw_dir + Vector3.UP * 0.12).normalized() + rb.linear_velocity = dir * DP.f("mat_throw_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") + ) + + var cleanup := get_tree().create_timer(6.0) + cleanup.timeout.connect(func() -> void: + if is_instance_valid(rb): + rb.queue_free() + ) + + func _tick_ragdoll(_delta: float) -> void: velocity.x = 0.0 velocity.z = 0.0 @@ -325,10 +545,7 @@ func _tick_ragdoll(_delta: float) -> void: func apply_ability_hit(hit_dir: Vector3, strength: float) -> void: if _state == State.RAGDOLL or _state == State.ROLL: return - if _bull == null: - var players := get_tree().get_nodes_in_group(&"player") - if not players.is_empty(): - _bull = players[0] as CharacterBody3D + _acquire_bull() _enter_ragdoll(hit_dir, strength) @@ -350,8 +567,21 @@ func _on_body_entered(body: Node3D) -> void: _enter_ragdoll(hit_dir, player.velocity.length()) +func _on_blade_hit(body: Node3D) -> void: + if _state == State.RAGDOLL or _state != State.ATTACK: + 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") + + func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void: _state = State.RAGDOLL + if _sword_blade_area: + _sword_blade_area.monitoring = false killed.emit() var cleanup_timer := get_tree().create_timer(4.0) cleanup_timer.timeout.connect(func() -> void: @@ -476,9 +706,17 @@ func _ensure_loop(anim_name: StringName) -> void: anim.loop_mode = Animation.LOOP_LINEAR +func _anim_length(anim_name: StringName, fallback: float) -> float: + if _anim_player and _anim_player.has_animation(anim_name): + return _anim_player.get_animation(anim_name).length + return fallback + + func _play_anim(anim_name: StringName) -> void: if _anim_player == null: return + if _drawing: + return # let the unholster clip play through uninterrupted if _anim_player.current_animation == anim_name: return _anim_player.play(anim_name) @@ -499,6 +737,154 @@ func _pick_wander_target() -> void: _wander_target = Vector3(cos(angle) * dist, 0.0, sin(angle) * dist) +# ── 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). Both bones are oriented in +# Blender to seat the sword directly, so the local offset/rotation default to +# identity and stay DP-tunable for fine-tuning without re-exporting. +func _setup_sword() -> void: + if not _skeleton: + return + var has_hand := _skeleton.find_bone("weapon_bone") != -1 + var has_rest := _skeleton.find_bone("weapon_rest_bone") != -1 + if not has_hand and not has_rest: + return + if has_hand: + _sword_hand_attach = BoneAttachment3D.new() + _sword_hand_attach.bone_name = "weapon_bone" + _skeleton.add_child(_sword_hand_attach) + if has_rest: + _sword_rest_attach = BoneAttachment3D.new() + _sword_rest_attach.bone_name = "weapon_rest_bone" + _skeleton.add_child(_sword_rest_attach) + _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. +func _spawn_sword() -> void: + if is_instance_valid(_sword_node): + return + if _sword_rest_attach == null and _sword_hand_attach == null: + return + _sword_node = _SWORD_SCENE.instantiate() as Node3D + # Start holstered (fall back to the hand if the rig only has one of the bones). + var start_attach := _sword_rest_attach if _sword_rest_attach else _sword_hand_attach + start_attach.add_child(_sword_node) + _sword_in_hand = start_attach == _sword_hand_attach + _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). + var blade_cap := CapsuleShape3D.new() + blade_cap.radius = 0.10 + blade_cap.height = 1.10 + 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() + _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) + + +# Draw the sword as the bull approaches and re-sheathe it once the bull is far +# again — so a matador wanders with the sword holstered and pulls it out to +# engage. Drawing plays the Draw_weapon clip; re-sheathing snaps back. Hysteresis +# (1.25× on the re-sheathe) avoids flicker at the threshold. +func _update_sword_carry() -> void: + if not is_instance_valid(_sword_node): + return + if _state == State.RAGDOLL or _bull == null: + _cancel_draw() + _carry_sword(false) + return + var dist := global_position.distance_to(_bull.global_position) + var draw_r := DP.f("mat_draw_range") + if dist <= draw_r: + if not _sword_in_hand and not _drawing: + _begin_draw() + elif dist > draw_r * 1.25: + _cancel_draw() + _carry_sword(false) + + +func _begin_draw() -> void: + if _anim_player and _anim_player.has_animation(_ANIM_DRAW): + _drawing = true + _draw_len = _anim_length(_ANIM_DRAW, 1.0) + _draw_timer = 0.0 + _anim_player.play(_ANIM_DRAW) + _anim_player.seek(0.0, true) + else: + _carry_sword(true) # no draw clip on this rig — snap to hand + + +func _advance_draw(delta: float) -> void: + if not _drawing: + return + # A combat state needs the body animations back at once — finish instantly. + if _state != State.WANDER and _state != State.FLEE: + _finalize_draw() + return + _draw_timer += delta + if not _sword_in_hand and _draw_timer >= _draw_len * _DRAW_GRAB_AT: + _carry_sword(true) + if _draw_timer >= _draw_len: + _finalize_draw() + + +func _finalize_draw() -> void: + if not _drawing: + return + _drawing = false + _carry_sword(true) + + +# Abort an in-progress draw without forcing the sword into the hand, so the +# caller can re-holster (the suppressed locomotion anim resumes next frame). +func _cancel_draw() -> void: + _drawing = false + + +# 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. +func _carry_sword(in_hand: bool) -> void: + if not is_instance_valid(_sword_node): + return + if in_hand == _sword_in_hand: + return + var target := _sword_hand_attach if in_hand else _sword_rest_attach + if target == null: + return + _sword_in_hand = in_hand + _sword_node.reparent(target, false) + _apply_sword_grip() + if _sword_blade_area: + _sword_blade_area.monitoring = in_hand + + +func _apply_sword_grip() -> void: + if not is_instance_valid(_sword_node): + return + _sword_node.position = Vector3( + DP.f("sword_pos_x"), DP.f("sword_pos_y"), DP.f("sword_pos_z")) + _sword_node.rotation_degrees = Vector3( + DP.f("sword_rot_x"), DP.f("sword_rot_y"), DP.f("sword_rot_z")) + + # ── Ragdoll setup ───────────────────────────────────────────────────────────── func _setup_physical_bones() -> PhysicalBoneSimulator3D: diff --git a/matador_2.gd b/matador_2.gd index 0ac94f7..5bd3b8b 100644 --- a/matador_2.gd +++ b/matador_2.gd @@ -2,4 +2,4 @@ extends Node3D func _ready() -> void: - var anim := get_node_or_null(^"Taunt") as AnimationPlayer + var anim := get_node_or_null(^"Taunt") as AnimationPlayer diff --git a/player.gd b/player.gd index 843656e..17ec3e4 100644 --- a/player.gd +++ b/player.gd @@ -34,6 +34,10 @@ var _bull_anim: AnimationPlayer = null var _charge_ready_emitter: CPUParticles3D = null var _slam_pending: bool = false +const MAX_HEALTH: int = 10 +var health: int = MAX_HEALTH +signal health_changed(new_health: int) + var _huff_player: AudioStreamPlayer = null var _crowd_player: AudioStreamPlayer = null var _huff_timer: float = 0.0 @@ -660,7 +664,7 @@ func _physics_process(delta: float) -> void: var flat_n := Vector3(normal.x, 0.0, normal.z).normalized() var into_spd := -pre_wall_vel.dot(flat_n) var exit_spd := maxf(into_spd * DP.f("wall_bounce_restitution"), - DP.f("wall_min_bounce_speed")) + DP.f("wall_min_bounce_speed")) velocity.x = flat_n.x * exit_spd velocity.z = flat_n.z * exit_spd pre_wall_vel = Vector3(velocity.x, 0.0, velocity.z) @@ -693,3 +697,8 @@ func _physics_process(delta: float) -> void: _set_dust_state(DustState.NONE) # ── Hoof sounds ─────────────────────────────────────────────────────────── + + +func take_sword_hit() -> void: + health = maxi(0, health - 1) + health_changed.emit(health) diff --git a/scene.tscn b/scene.tscn index e63a858..fe21755 100644 --- a/scene.tscn +++ b/scene.tscn @@ -8,7 +8,6 @@ [ext_resource type="PackedScene" uid="uid://1y07gn65ktsi" path="res://Animations/ANIM_publik_mees.glb" id="7_xlvrw"] [ext_resource type="Script" uid="uid://dr6phggarm73e" path="res://Animations/anim_publik_mees.gd" id="8_73fnb"] [ext_resource type="Script" uid="uid://di0jafakftp3n" path="res://publikum_controller.gd" id="9_pubctrl"] -[ext_resource type="PackedScene" uid="uid://xyvank512l5n" path="res://Assets/sword.glb" id="10_5juve"] [ext_resource type="Script" uid="uid://xu6osmd62rj2" path="res://hud.gd" id="10_hud"] [sub_resource type="CameraAttributesPractical" id="CameraAttributesPractical_73fnb"] @@ -360,9 +359,6 @@ transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97 [node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=755853425] environment = SubResource("Environment_73fnb") -[node name="sword2" parent="." unique_id=862505781 instance=ExtResource("10_5juve")] -transform = Transform3D(-0.88628644, 0, 0.46313745, 0, 1, 0, -0.46313745, 0, -0.88628644, 0.2825427, 2.2783415, 1.9611025) - [editable path="Player"] [editable path="Player/bull"] [editable path="Node3D/arena_placeholder_v01"]