diff --git a/.gdlintrc b/.gdlintrc index 615dc0e..f05a073 100644 --- a/.gdlintrc +++ b/.gdlintrc @@ -2,3 +2,4 @@ disable: - class-definitions-order - no-else-return max-line-length: 120 +max-file-lines: 1200 diff --git a/blood_burst.gd b/blood_burst.gd new file mode 100644 index 0000000..7f34529 --- /dev/null +++ b/blood_burst.gd @@ -0,0 +1,49 @@ +extends CPUParticles3D +## One-shot blood spray. Add as a child of whatever bleeds, then call burst(). + +func _ready() -> void: + var mat := StandardMaterial3D.new() + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.vertex_color_use_as_albedo = true + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + + var sphere := SphereMesh.new() + sphere.radius = 0.07 + sphere.height = 0.14 + sphere.radial_segments = 4 + sphere.rings = 2 + sphere.material = mat + + var ramp := Gradient.new() + ramp.set_color(0, Color(1.0, 0.02, 0.02, 1.0)) + ramp.set_color(1, Color(0.25, 0.0, 0.0, 0.0)) + + var scale_curve := Curve.new() + scale_curve.add_point(Vector2(0.0, 1.0)) + scale_curve.add_point(Vector2(0.6, 0.5)) + scale_curve.add_point(Vector2(1.0, 0.0)) + + mesh = sphere + color_ramp = ramp + scale_amount_curve = scale_curve + one_shot = true + explosiveness = 0.9 + amount = 25 + lifetime = 1.1 + randomness = 0.4 + local_coords = false + spread = 80.0 + gravity = Vector3(0.0, -7.0, 0.0) + initial_velocity_min = 4.0 + initial_velocity_max = 11.0 + scale_amount_min = 0.5 + scale_amount_max = 1.6 + emitting = false + + +# Fire a burst at a world position, sprayed along dir (biased upward). +func burst(at: Vector3, dir: Vector3) -> void: + global_position = at + direction = (dir * 0.6 + Vector3.UP * 0.4).normalized() + restart() diff --git a/debug_params.gd b/debug_params.gd index 3cbab26..cbc0cf8 100644 --- a/debug_params.gd +++ b/debug_params.gd @@ -115,14 +115,14 @@ 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) + # Playback rate of the Draw_weapon clip used for both drawing and sheathing — + # higher = snappier unholster/holster. + _reg_f("Matador", "mat_draw_speed", 1.25, 0.25, 3.0, 0.05) # 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 + # Local seating of the sword in the right hand (drawn / fighting). + # Defaults are identity — the bone is 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) @@ -130,6 +130,15 @@ func _register_all() -> void: _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) + # Local seating of the sword in the hip holster (sheathed / wandering). + # Independent of the hand grip so each pose can be tuned without affecting the + # other. Defaults angle the blade down along the matador's side. + _reg_f("Sword", "sword_rest_pos_x", 0.0, -1.0, 1.0, 0.01) + _reg_f("Sword", "sword_rest_pos_y", 0.0, -1.0, 1.0, 0.01) + _reg_f("Sword", "sword_rest_pos_z", 0.0, -1.0, 1.0, 0.01) + _reg_f("Sword", "sword_rest_rot_x", -120.0, -180.0, 180.0, 1.0) + _reg_f("Sword", "sword_rest_rot_y", 0.0, -180.0, 180.0, 1.0) + _reg_f("Sword", "sword_rest_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. @@ -151,6 +160,11 @@ func _register_all() -> void: _reg_f("Abilities", "kick_strength", 12.0, 1.0, 30.0) _reg_f("Abilities", "slam_cooldown", 4.0, 0.5, 15.0, 0.1) _reg_f("Abilities", "slam_jump_velocity", 7.0, 2.0, 20.0) + # Descent gravity multiplier — higher = faster crash, less airtime. + _reg_f("Abilities", "slam_fall_mult", 3.0, 1.0, 8.0, 0.5) + # Upward launch velocity (m/s) given to a slammed matador's ragdoll — pops them + # skyward. ~7 ≈ a 2.5 m hop; 0 disables the launch. + _reg_f("Abilities", "slam_launch_up", 7.0, 0.0, 15.0, 0.5) _reg_f("Abilities", "slam_range", 4.0, 1.0, 10.0, 0.1) _reg_f("Abilities", "slam_strength", 14.0, 1.0, 30.0) _reg_f("Abilities", "dash_cooldown", 2.0, 0.5, 10.0, 0.1) diff --git a/hud.gd b/hud.gd index 17d4b2e..fa6be2b 100644 --- a/hud.gd +++ b/hud.gd @@ -20,6 +20,7 @@ var _toggle_btn: Button var _spawner: Node var _player: Node = null var _cd_overlays: Array[ColorRect] = [] +var _cd_labels: Array[Label] = [] var _health_segments: Array[ColorRect] = [] var _health_connected: bool = false @@ -48,6 +49,13 @@ func _process(_delta: float) -> void: var ov := _cd_overlays[i] ov.anchor_top = 1.0 - fraction ov.anchor_bottom = 1.0 + var remaining: float = _player.call(&"ability_cooldown_remaining", i) + var lbl := _cd_labels[i] + if remaining > 0.0: + lbl.text = "%.1fs" % remaining + lbl.visible = true + else: + lbl.visible = false func _find_spawner() -> void: @@ -188,6 +196,17 @@ func _build_ability_slot(idx: int) -> Control: stack.add_child(cd_overlay) _cd_overlays.append(cd_overlay) + var cd_lbl := Label.new() + cd_lbl.set_anchors_preset(Control.PRESET_FULL_RECT) + cd_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + cd_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + cd_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + cd_lbl.visible = false + cd_lbl.add_theme_color_override("font_color", Color.WHITE) + cd_lbl.add_theme_font_size_override("font_size", 18) + stack.add_child(cd_lbl) + _cd_labels.append(cd_lbl) + var key_lbl := Label.new() key_lbl.text = _ABILITY_KEYS[idx] key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER diff --git a/matador.gd b/matador.gd index c41391b..401171a 100644 --- a/matador.gd +++ b/matador.gd @@ -39,6 +39,7 @@ var _sword_rest_attach: BoneAttachment3D = null var _sword_node: Node3D = null var _sword_in_hand: bool = false 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 @@ -93,7 +94,8 @@ func _ready() -> void: _throw_aim_cd = randf_range(0.8, 2.5) _hit_area.body_entered.connect(_on_body_entered) _pick_wander_target() - _setup_blood_burst() + _blood_burst = preload("res://blood_burst.gd").new() + add_child(_blood_burst) if ResourceLoader.exists("res://sounds/ole.ogg"): _ole_player = AudioStreamPlayer.new() _ole_player.stream = load("res://sounds/ole.ogg") @@ -157,7 +159,7 @@ func _update_ai_state() -> void: 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 _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. @@ -260,8 +262,8 @@ func _face_point(point: Vector3, delta: float, rate: float) -> void: func _tick_wander(delta: float) -> void: - # Plant the feet while unsheathing instead of sliding through the draw clip. - if _drawing or _idle_timer > 0.0: + # Plant the feet while drawing / sheathing instead of sliding through the clip. + if _drawing or _sheathing or _idle_timer > 0.0: _idle_timer = maxf(0.0, _idle_timer - delta) _decelerate(10.0, delta) _play_anim(_ANIM_IDLE) @@ -282,6 +284,13 @@ func _tick_wander(delta: float) -> void: func _tick_flee(delta: float) -> void: + # Plant the feet while sheathing instead of sliding away through the clip. + if _drawing or _sheathing: + _decelerate(18.0, delta) + _face_point(_bull.global_position, delta, _TURN_FACE) + move_and_slide() + return + var away := global_position - _bull.global_position away.y = 0.0 if away.length() < 0.01: @@ -355,6 +364,12 @@ func _start_attack() -> void: func _tick_attack(delta: float) -> void: _attack_timer -= delta _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: + _decelerate(22.0, delta) + _face_point(_bull.global_position, delta, _TURN_FACE) + move_and_slide() + return _face_point(_bull.global_position, delta, _TURN_FACE) var to_bull := _bull.global_position - global_position to_bull.y = 0.0 @@ -363,7 +378,7 @@ func _tick_attack(delta: float) -> void: # 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"): + if _swing_timer <= 0.0 and dist <= DP.f("mat_attack_min_dist") and _sword_in_hand: _swing_timer = _anim_length(_ANIM_ATTACK, 0.9) if _swing_timer > 0.0: @@ -408,6 +423,8 @@ func _start_throw() -> void: _state = State.THROW _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 if _anim_player: _anim_player.pause() var to_bull := _bull.global_position - global_position @@ -542,11 +559,11 @@ func _tick_ragdoll(_delta: float) -> void: move_and_slide() -func apply_ability_hit(hit_dir: Vector3, strength: float) -> void: +func apply_ability_hit(hit_dir: Vector3, strength: float, up_boost: float = 0.0) -> void: if _state == State.RAGDOLL or _state == State.ROLL: return _acquire_bull() - _enter_ragdoll(hit_dir, strength) + _enter_ragdoll(hit_dir, strength, up_boost) func _on_body_entered(body: Node3D) -> void: @@ -578,7 +595,7 @@ func _on_blade_hit(body: Node3D) -> void: body.call(&"take_sword_hit") -func _enter_ragdoll(hit_dir: Vector3, bull_speed: 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 @@ -592,7 +609,7 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void: hit_dir = hit_dir.normalized() var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized() - _spawn_blood_burst(hit_dir) + _blood_burst.burst(global_position + Vector3(0.0, 0.9, 0.0), hit_dir) if _death_player: _death_player.pitch_scale = randf_range(0.9, 1.1) _death_player.play() @@ -619,6 +636,7 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void: pb.global_transform = _skeleton.global_transform * _skeleton.get_bone_global_pose(bone_idx) _sim.active = true _sim.physical_bones_start_simulation() + _pin_cape_to_body() var strength := DP.f("mat_ragdoll_impulse") * clampf(bull_speed / 15.0, 0.4, 1.3) var right := throw_dir.cross(Vector3.UP).normalized() for child: Node in _sim.get_children(): @@ -641,61 +659,46 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void: impulse = (throw_dir * 0.3 + right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5 "arm_R": impulse = (throw_dir * 0.3 - right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5 + "Bone", "Bone.001", "Bone.002": + # Scale by mass so the light cape gets the same velocity as the body + # (Δv = impulse/mass) and flies along with it instead of rocketing off. + impulse = (throw_dir + Vector3.UP * 0.35).normalized() * strength * 2.0 * pb.mass _: continue var noise := Vector3(randf_range(-0.15, 0.15), randf_range(-0.08, 0.15), randf_range(-0.15, 0.15)) pb.apply_central_impulse(impulse + noise * strength * 0.2) - -# ── Blood burst ─────────────────────────────────────────────────────────────── - -func _setup_blood_burst() -> void: - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.vertex_color_use_as_albedo = true - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - - var sphere := SphereMesh.new() - sphere.radius = 0.07 - sphere.height = 0.14 - sphere.radial_segments = 4 - sphere.rings = 2 - sphere.material = mat - - var ramp := Gradient.new() - ramp.set_color(0, Color(1.0, 0.02, 0.02, 1.0)) - ramp.set_color(1, Color(0.25, 0.0, 0.0, 0.0)) - - var scale_curve := Curve.new() - scale_curve.add_point(Vector2(0.0, 1.0)) - scale_curve.add_point(Vector2(0.6, 0.5)) - scale_curve.add_point(Vector2(1.0, 0.0)) - - _blood_burst = CPUParticles3D.new() - _blood_burst.mesh = sphere - _blood_burst.color_ramp = ramp - _blood_burst.scale_amount_curve = scale_curve - _blood_burst.one_shot = true - _blood_burst.explosiveness = 0.9 - _blood_burst.amount = 25 - _blood_burst.lifetime = 1.1 - _blood_burst.randomness = 0.4 - _blood_burst.local_coords = false - _blood_burst.spread = 80.0 - _blood_burst.gravity = Vector3(0.0, -7.0, 0.0) - _blood_burst.initial_velocity_min = 4.0 - _blood_burst.initial_velocity_max = 11.0 - _blood_burst.scale_amount_min = 0.5 - _blood_burst.scale_amount_max = 1.6 - _blood_burst.emitting = false - add_child(_blood_burst) + # A floor slam launches the whole ragdoll straight up: a uniform per-bone Δv + # (impulse = mass · speed) so the joints carry every bone up together. + if up_boost > 0.0: + for child: Node in _sim.get_children(): + if child is PhysicalBone3D: + var pbb := child as PhysicalBone3D + pbb.apply_central_impulse(Vector3.UP * up_boost * pbb.mass) -func _spawn_blood_burst(hit_dir: Vector3) -> void: - _blood_burst.global_position = global_position + Vector3(0.0, 0.9, 0.0) - _blood_burst.direction = (hit_dir * 0.6 + Vector3.UP * 0.4).normalized() - _blood_burst.restart() +# Pin the cape's root link to the chest so it stays attached at the shoulders +# while ragdolling (its bone chain is a skeleton root, so it has no joint to the +# body otherwise). The chain hangs and swings from this point. +func _pin_cape_to_body() -> void: + var cape := _physical_bone(&"Bone") + var chest := _physical_bone(&"chest") + if cape == null or chest == null: + return + var joint := PinJoint3D.new() + _sim.add_child(joint) + joint.global_position = cape.global_position + joint.node_a = chest.get_path() + joint.node_b = cape.get_path() + + +func _physical_bone(bone: StringName) -> PhysicalBone3D: + if not _sim: + return null + for child: Node in _sim.get_children(): + if child is PhysicalBone3D and (child as PhysicalBone3D).bone_name == bone: + return child as PhysicalBone3D + return null # ── Animation helpers ───────────────────────────────────────────────────────── @@ -715,8 +718,8 @@ func _anim_length(anim_name: StringName, fallback: float) -> float: func _play_anim(anim_name: StringName) -> void: if _anim_player == null: return - if _drawing: - return # let the unholster clip play through uninterrupted + if _drawing or _sheathing: + return # let the draw / sheathe clip play through uninterrupted if _anim_player.current_animation == anim_name: return _anim_player.play(anim_name) @@ -743,9 +746,9 @@ func _pick_wander_target() -> void: # 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. +# 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. func _setup_sword() -> void: if not _skeleton: return @@ -799,63 +802,83 @@ func _spawn_sword() -> void: _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. +# 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. +func _wants_sword_drawn() -> bool: + return _state == State.ATTACK or _state == State.THROW + + func _update_sword_carry() -> void: - if not is_instance_valid(_sword_node): + if not is_instance_valid(_sword_node) or _state == State.RAGDOLL: 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 _wants_sword_drawn(): if not _sword_in_hand and not _drawing: _begin_draw() - elif dist > draw_r * 1.25: - _cancel_draw() - _carry_sword(false) + elif _sword_in_hand or _drawing: + # Animate the sheathe only into the unhurried locomotion states; snap for + # a dodge / roll / brace where the body clip can't spare the time. + if _state == State.WANDER or _state == State.FLEE: + if not _sheathing: + _begin_sheathe() + else: + _cancel_transitions() + _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: + if not (_anim_player and _anim_player.has_animation(_ANIM_DRAW)): _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() + _sheathing = false + _drawing = true + _draw_timer = 0.0 + _draw_len = _anim_length(_ANIM_DRAW, 1.0) / _draw_speed() + _anim_player.play(_ANIM_DRAW, -1.0, _draw_speed()) + _anim_player.seek(0.0, true) + + +func _begin_sheathe() -> void: + if not (_anim_player and _anim_player.has_animation(_ANIM_DRAW)): + _carry_sword(false) # no clip — snap to holster + return + _drawing = false + _sheathing = true + _draw_timer = 0.0 + _draw_len = _anim_length(_ANIM_DRAW, 1.0) / _draw_speed() + # Draw_weapon reversed from its end frame = a put-the-sword-away motion. + _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. +func _advance_draw(delta: float) -> void: + if not _drawing and not _sheathing: 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() + var t := _draw_timer / maxf(_draw_len, 0.001) + if _drawing: + if not _sword_in_hand and t >= _DRAW_GRAB_AT: + _carry_sword(true) + if t >= 1.0: + _drawing = false + _carry_sword(true) + else: + if _sword_in_hand and t >= 1.0 - _DRAW_GRAB_AT: + _carry_sword(false) + if t >= 1.0: + _sheathing = false + _carry_sword(false) -func _finalize_draw() -> void: - if not _drawing: - return +func _cancel_transitions() -> void: _drawing = false - _carry_sword(true) + _sheathing = false -# 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 +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 @@ -879,10 +902,16 @@ func _carry_sword(in_hand: bool) -> void: 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")) + if _sword_in_hand: + _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")) + else: + _sword_node.position = Vector3( + DP.f("sword_rest_pos_x"), DP.f("sword_rest_pos_y"), DP.f("sword_rest_pos_z")) + _sword_node.rotation_degrees = Vector3( + DP.f("sword_rest_rot_x"), DP.f("sword_rest_rot_y"), DP.f("sword_rest_rot_z")) # ── Ragdoll setup ───────────────────────────────────────────────────────────── @@ -916,6 +945,11 @@ func _setup_physical_bones() -> PhysicalBoneSimulator3D: ["shin_R", 0.08, 0.28], ["foot_L", 0.07, 0.18], ["foot_R", 0.07, 0.18], + # Muleta (cape) spine — a free 3-link chain so the cape ragdolls with the + # body instead of hanging frozen in the air. See the cape tweak below. + ["Bone", 0.04, 0.30], + ["Bone.001", 0.04, 0.34], + ["Bone.002", 0.04, 0.30], ] for entry: Array in BONES: @@ -930,6 +964,13 @@ func _setup_physical_bones() -> PhysicalBoneSimulator3D: pb.angular_damp = 0.2 pb.collision_layer = 1 pb.collision_mask = 1 + if bname.begins_with("Bone"): + # Cape: light and draggy so it flutters like cloth; layer 0 makes its + # collision one-way (drapes onto body/floor without shoving the corpse). + pb.mass = 0.15 + pb.linear_damp = 0.5 + pb.angular_damp = 0.7 + pb.collision_layer = 0 var cs := CollisionShape3D.new() var use_capsule: bool = entry.size() >= 3 if use_capsule: diff --git a/player.gd b/player.gd index 17ec3e4..83208df 100644 --- a/player.gd +++ b/player.gd @@ -45,6 +45,8 @@ var _roll_damping: float = 0.15 enum DustState { NONE, WALK, CHARGE } var _dust_state: DustState = DustState.NONE +var _walk_dust_ramp: Gradient = null +var _charge_dust_ramp: Gradient = null func _ready() -> void: @@ -79,9 +81,14 @@ func _setup_hoof_dust() -> void: sphere.rings = 2 sphere.material = mat - var ramp := Gradient.new() - ramp.set_color(0, Color(0.80, 0.69, 0.46, 1.0)) - ramp.set_color(1, Color(0.80, 0.69, 0.46, 0.0)) + _walk_dust_ramp = Gradient.new() + _walk_dust_ramp.set_color(0, Color(0.80, 0.69, 0.46, 1.0)) + _walk_dust_ramp.set_color(1, Color(0.80, 0.69, 0.46, 0.0)) + + # Light blue dust while charging + _charge_dust_ramp = Gradient.new() + _charge_dust_ramp.set_color(0, Color(0.20, 0.40, 0.85, 1.0)) + _charge_dust_ramp.set_color(1, Color(0.20, 0.40, 0.85, 0.0)) var scale_curve := Curve.new() scale_curve.add_point(Vector2(0.0, 0.2)) @@ -105,7 +112,7 @@ func _setup_hoof_dust() -> void: p.scale_amount_min = DP.f("walk_scale_min") p.scale_amount_max = DP.f("walk_scale_max") p.mesh = sphere - p.color_ramp = ramp + p.color_ramp = _walk_dust_ramp p.scale_amount_curve = scale_curve cube_guy.add_child(p) _hoof_emitters.append(p) @@ -185,12 +192,14 @@ func _set_dust_state(new_state: DustState) -> void: DustState.NONE: p.emitting = false DustState.WALK: + p.color_ramp = _walk_dust_ramp p.scale_amount_min = DP.f("walk_scale_min") p.scale_amount_max = DP.f("walk_scale_max") p.initial_velocity_min = DP.f("walk_vel_min") p.initial_velocity_max = DP.f("walk_vel_max") p.emitting = true DustState.CHARGE: + p.color_ramp = _charge_dust_ramp p.scale_amount_min = DP.f("charge_scale_min") p.scale_amount_max = DP.f("charge_scale_max") p.initial_velocity_min = DP.f("charge_vel_min") @@ -237,6 +246,10 @@ func ability_cooldown_fraction(slot: int) -> float: return ability_cd[slot] / maxcd if maxcd > 0.0 else 0.0 +func ability_cooldown_remaining(slot: int) -> float: + return ability_cd[slot] + + func _activate_kick() -> void: ability_cd[0] = DP.f("kick_cooldown") _ability_active = true @@ -261,8 +274,10 @@ func _activate_slam() -> void: _slam_pending = true var jump_vel := DP.f("slam_jump_velocity") velocity.y = jump_vel - var air_time := jump_vel / 9.8 * 2.0 - _ability_timer = air_time + 0.5 + # Airtime with an accelerated descent: rise (v/g) + fall (v / (g·√mult)). + var fall_mult := maxf(DP.f("slam_fall_mult"), 0.01) + var air_time := (jump_vel / 9.8) * (1.0 + 1.0 / sqrt(fall_mult)) + _ability_timer = air_time + 0.3 if _bull_anim and _bull_anim.has_animation(&"Armature|SLAM"): var anim_len := _bull_anim.get_animation(&"Armature|SLAM").length _bull_anim.speed_scale = anim_len / maxf(air_time, 0.01) @@ -301,7 +316,7 @@ func _hit_matadors_cone(range_m: float, half_angle_rad: float, strength: float, mat.call(&"apply_ability_hit", (forward + Vector3.UP * 0.4).normalized(), strength) -func _hit_matadors_radius(range_m: float, strength: float) -> void: +func _hit_matadors_radius(range_m: float, strength: float, up_boost: float = 0.0) -> void: for mat: Node in get_tree().get_nodes_in_group(&"matador"): var to_mat: Vector3 = (mat as Node3D).global_position - global_position to_mat.y = 0.0 @@ -309,7 +324,7 @@ func _hit_matadors_radius(range_m: float, strength: float) -> void: continue var away := to_mat.normalized() if to_mat.length() > 0.01 \ else Vector3(randf() - 0.5, 0.0, randf() - 0.5).normalized() - mat.call(&"apply_ability_hit", (away + Vector3.UP * 0.6).normalized(), strength) + mat.call(&"apply_ability_hit", (away + Vector3.UP * 0.6).normalized(), strength, up_boost) func _spawn_slam_fx() -> void: @@ -424,8 +439,8 @@ func _spawn_kick_dust(origin: Vector3, back_dir: Vector3) -> void: sphere.material = mat var ramp := Gradient.new() - ramp.set_color(0, Color(0.80, 0.68, 0.42, 0.9)) - ramp.set_color(1, Color(0.80, 0.68, 0.42, 0.0)) + ramp.set_color(0, Color(1.0, 0.78, 0.18, 0.95)) + ramp.set_color(1, Color(1.0, 0.45, 0.05, 0.0)) var scale_curve := Curve.new() scale_curve.add_point(Vector2(0.0, 0.3)) @@ -566,7 +581,12 @@ func _apply_kick(direction: Vector3) -> void: func _physics_process(delta: float) -> void: # ── Gravity ─────────────────────────────────────────────────────────────── if not is_on_floor(): - velocity += get_gravity() * delta + var gravity := get_gravity() + # Crash a slam down hard: full leap on the way up, accelerated descent so + # the bull spends less time floating before the impact. + if _slam_pending and velocity.y < 0.0: + gravity *= DP.f("slam_fall_mult") + velocity += gravity * delta # ── Camera-relative input direction ─────────────────────────────────────── var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back") @@ -686,7 +706,7 @@ func _physics_process(delta: float) -> void: if _bull_anim: _bull_anim.speed_scale = 1.0 _spawn_slam_fx() - _hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength")) + _hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength"), DP.f("slam_launch_up")) # ── Dust ────────────────────────────────────────────────────────────────── if flat_speed > DP.f("dust_charge_spd") and on_floor: