Reorganize into role-based folders and consolidate art

Move every script and scene into role-based directories (actors/, systems/, ui/,
levels/, props/) and all raw art under art/ (grouped by role, snake_case,
Extract-Textures GLBs), dropping Blender version-suffix noise. Filenames are
snake_case (PascalCase reserved for class_name) to avoid case-insensitive
filesystem aliasing on the web/macOS targets. Routes the bull's hits through the
typed DamageEvent pipeline and wires the bull's abilities to the Fx library.
Updates CLAUDE.md's folder-layout overview to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
This commit is contained in:
2026-09-08 14:56:22 +03:00
parent c0a022fed4
commit 09d4a60f43
244 changed files with 415 additions and 19337 deletions
+1098
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://cn7fhjkro7637
+44
View File
@@ -0,0 +1,44 @@
[gd_scene format=3 uid="uid://o8ocjfprxa7u"]
[ext_resource type="Script" uid="uid://cn7fhjkro7637" path="res://actors/bear/bear.gd" id="1_beargd"]
[ext_resource type="PackedScene" uid="uid://cofv5dbavesis" path="res://art/characters/bear.glb" id="2_bearglb"]
[ext_resource type="PackedScene" uid="uid://bdbx0wwa73rhp" path="res://art/fx/bear_fx.glb" id="3_bearfx"]
[ext_resource type="Material" uid="uid://blpkmyikjnhbo" path="res://art/materials/bear_claw_fx.tres" id="4_clawfx"]
[ext_resource type="PackedScene" path="res://actors/bear/bear_attack_hitboxes.tscn" id="5_hitboxes"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
radius = 1.0
height = 2.4
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hit"]
radius = 1.3
height = 2.7
[node name="Bear" type="CharacterBody3D" unique_id=1573237287]
script = ExtResource("1_beargd")
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=830443382]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2, 0)
shape = SubResource("CapsuleShape3D_body")
[node name="HitArea" type="Area3D" parent="." unique_id=365327397]
monitorable = false
[node name="CollisionShape3D" type="CollisionShape3D" parent="HitArea" unique_id=2111867348]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.35, 0)
shape = SubResource("CapsuleShape3D_hit")
[node name="bear_model" parent="." unique_id=359217823 instance=ExtResource("2_bearglb")]
[node name="Bear_FX" parent="bear_model" unique_id=2070553519 instance=ExtResource("3_bearfx")]
[node name="Bear_fx_claw_attack_R" parent="bear_model/Bear_FX" index="2" unique_id=1939248432]
surface_material_override/0 = ExtResource("4_clawfx")
[node name="Bear_fx_claw_attack_L" parent="bear_model/Bear_FX" index="3" unique_id=834640978]
surface_material_override/0 = ExtResource("4_clawfx")
[node name="AttackHitboxes" parent="bear_model" instance=ExtResource("5_hitboxes")]
[editable path="bear_model"]
[editable path="bear_model/Bear_FX"]
+70
View File
@@ -0,0 +1,70 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://systems/combat/hitbox.gd" id="1_qxn4a"]
[sub_resource type="CylinderShape3D" id="CylinderShape3D_6dow8"]
height = 3.0
radius = 4.0
[sub_resource type="BoxShape3D" id="BoxShape3D_tmmfg"]
size = Vector3(2.6, 2.5, 20)
[sub_resource type="Animation" id="Animation_wcwtv"]
length = 0.25
tracks/0/type = "scale_3d"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("LeapSlamHitbox/CollisionShape3D")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = PackedFloat32Array(0, 1, 0.12, 1, 0.12, 0.06, 1, 1, 1, 1, 0.25, 1, 1, 1, 1)
[sub_resource type="Animation" id="Animation_bg5u7"]
length = 0.3
tracks/0/type = "scale_3d"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("SmashHitbox/CollisionShape3D")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = PackedFloat32Array(0, 1, 1, 1, 0.02, 0.1, 1, 1, 1, 1, 0.3, 1, 1, 1, 1)
tracks/1/type = "position_3d"
tracks/1/imported = false
tracks/1/enabled = true
tracks/1/path = NodePath("SmashHitbox/CollisionShape3D")
tracks/1/interp = 1
tracks/1/loop_wrap = true
tracks/1/keys = PackedFloat32Array(0, 1, 0, 0.9, 0.2, 0.1, 1, 0, 0.9, 10, 0.3, 1, 0, 0.9, 10)
[sub_resource type="AnimationLibrary" id="AnimationLibrary_u1id2"]
_data = {
&"leap_slam": SubResource("Animation_wcwtv"),
&"smash_travel": SubResource("Animation_bg5u7")
}
[node name="AttackHitboxes" type="Node3D" unique_id=2010404706]
[node name="LeapSlamHitbox" type="Area3D" parent="." unique_id=1751893032]
collision_layer = 0
collision_mask = 2
monitoring = false
monitorable = false
script = ExtResource("1_qxn4a")
[node name="CollisionShape3D" type="CollisionShape3D" parent="LeapSlamHitbox" unique_id=1320172052]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.5, 0)
shape = SubResource("CylinderShape3D_6dow8")
[node name="SmashHitbox" type="Area3D" parent="." unique_id=1579648726]
collision_layer = 0
collision_mask = 2
monitoring = false
monitorable = false
script = ExtResource("1_qxn4a")
[node name="CollisionShape3D" type="CollisionShape3D" parent="SmashHitbox" unique_id=528637962]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 10)
shape = SubResource("BoxShape3D_tmmfg")
[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=2139876757]
libraries/ = SubResource("AnimationLibrary_u1id2")
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://bvfa7xi0luysk
+28
View File
@@ -0,0 +1,28 @@
[gd_scene format=3 uid="uid://d0te4mwbh33ag"]
[ext_resource type="Script" uid="uid://bvfa7xi0luysk" path="res://actors/matador/matador.gd" id="1_matgd"]
[ext_resource type="PackedScene" uid="uid://eu2lqjhowj4t" path="res://art/characters/matador.glb" id="2_matfbx"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
radius = 0.25
height = 1.2
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hit"]
radius = 0.45
height = 1.4
[node name="Matador" type="CharacterBody3D" unique_id=1781857630]
script = ExtResource("1_matgd")
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=2101651042]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
shape = SubResource("CapsuleShape3D_body")
[node name="HitArea" type="Area3D" parent="." unique_id=745037545]
monitorable = false
[node name="CollisionShape3D" type="CollisionShape3D" parent="HitArea" unique_id=1497356275]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
shape = SubResource("CapsuleShape3D_hit")
[node name="matador2" parent="." unique_id=1487256381 instance=ExtResource("2_matfbx")]
+92
View File
@@ -0,0 +1,92 @@
class_name MatadorRagdoll
extends RefCounted
## Builds the matador's physical-bone ragdoll. Body joints are cone-twist with
## limited swing/twist (DP-tunable) so the corpse keeps its silhouette instead of
## folding into a flat "deflated" pile; the cape stays free (PIN) so it drapes.
const PhysicsLayers = preload("res://systems/combat/physics_layers.gd")
# [bone, radius] → sphere collider; [bone, radius, height] → capsule.
const _BONES: Array = [
["matador", 0.18],
["COG", 0.12],
["chest", 0.13, 0.30],
["head", 0.14],
["collarbone_L", 0.06, 0.18],
["collarbone_R", 0.06, 0.18],
["arm_L", 0.07, 0.22],
["arm_R", 0.07, 0.22],
["forearm_L", 0.06, 0.22],
["forearm_R", 0.06, 0.22],
["hand_L", 0.05, 0.12],
["hand_R", 0.05, 0.12],
["leg_L", 0.10, 0.32],
["leg_R", 0.10, 0.32],
["shin_L", 0.08, 0.28],
["shin_R", 0.08, 0.28],
["foot_L", 0.07, 0.18],
["foot_R", 0.07, 0.18],
["Bone", 0.04, 0.30],
["Bone.001", 0.04, 0.34],
["Bone.002", 0.04, 0.30],
]
# Reuse an existing simulator if the rig already has one, else build the bodies.
static func build(skeleton: Skeleton3D) -> PhysicalBoneSimulator3D:
for child: Node in skeleton.get_children():
if child is PhysicalBoneSimulator3D:
(child as PhysicalBoneSimulator3D).active = false
return child as PhysicalBoneSimulator3D
var sim := PhysicalBoneSimulator3D.new()
sim.active = false
skeleton.add_child(sim)
var swing := DP.f("mat_joint_swing")
var twist := DP.f("mat_joint_twist")
var damp := DP.f("mat_ragdoll_damp")
for entry: Array in _BONES:
var bname: String = entry[0]
if skeleton.find_bone(bname) == -1:
continue
var pb := PhysicalBone3D.new()
pb.bone_name = bname
pb.mass = 0.4
pb.linear_damp = 0.1
# The ragdoll only simulates once the matador is dead, so it lives on the CORPSE
# layer and masks to CORPSE alone: it rests on the ground/walls (which also carry
# CORPSE) but the bull — which collides only with WORLD — passes straight through
# the bodies instead of tripping over them.
pb.collision_layer = PhysicsLayers.CORPSE
pb.collision_mask = PhysicsLayers.CORPSE
if bname.begins_with("Bone"):
# Cape: floppy cloth — free swing, light, draggy, one-way collision (layer 0
# so nothing collides with it; it still drapes on the CORPSE-masked ground).
pb.joint_type = PhysicalBone3D.JOINT_TYPE_PIN
pb.mass = 0.15
pb.linear_damp = 0.5
pb.angular_damp = 0.7
pb.collision_layer = 0
else:
# Body: cone-twist joints cap how far each bone bends from its rest
# pose, so the silhouette holds; extra angular damp settles the flail.
pb.joint_type = PhysicalBone3D.JOINT_TYPE_CONE
pb.angular_damp = damp
pb.set(&"joint_constraints/swing_span", swing)
pb.set(&"joint_constraints/twist_span", twist)
var cs := CollisionShape3D.new()
if entry.size() >= 3:
var cap := CapsuleShape3D.new()
cap.radius = entry[1]
cap.height = entry[2]
cs.shape = cap
else:
var sph := SphereShape3D.new()
sph.radius = entry[1]
cs.shape = sph
pb.add_child(cs)
sim.add_child(pb)
return sim
+1
View File
@@ -0,0 +1 @@
uid://dmf04b5exnejn
+15
View File
@@ -0,0 +1,15 @@
[gd_scene format=3 uid="uid://c8spearwrap01"]
[ext_resource type="PackedScene" uid="uid://jepc74w8opbu" path="res://art/weapons/spear.glb" id="1_spearglb"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_shaft"]
radius = 0.13
height = 2.2
[node name="Spear" type="Node3D"]
[node name="spear" parent="." instance=ExtResource("1_spearglb")]
[node name="HitShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, -1, 0, 1, -4.371139e-08, 0, 0, 0.79)
shape = SubResource("CapsuleShape3D_shaft")
+253
View File
@@ -0,0 +1,253 @@
class_name SpearProjectile
extends RigidBody3D
# A thrown spear. It flies a ballistic arc (gravity does the work — the launch
# velocity is solved by the matador to land on the bull) and keeps its shaft aimed
# along its own velocity, so it noses over at the apex and drives in point-first.
# On impact it embeds where it struck: the tip buried just past the contact point
# with the shaft trailing back along the flight line, so it reads as stuck-in rather
# than floating alongside. A hit on the bull leaves the spear riding it like a spine
# (see shed_all, which the bull's slam calls to fling them all off); other hits pin
# it in place until the despawn timer clears it.
const PhysicsLayers = preload("res://systems/combat/physics_layers.gd")
const _DESPAWN_SEC: float = 8.0
const _STUCK_GROUP: StringName = &"stuck_spear"
# Shaft geometry in spear.glb local space: tip at +Z, butt behind the origin.
const _TIP_Z: float = 1.915
const _EMBED_DEPTH: float = 0.72 # how far past the surface the tip buries
# Bone the bull's spears ride on. The collision spheres sit outside the hide, so a
# spear stuck to the body root reads as floating; anchoring to COG also lets it bob
# and rotate with the animated body instead of only the character root.
const _COG_BONE: String = "COG"
const _ANCHOR_NAME: String = "SpearAnchor"
var _mesh: Node3D = null
var _stopped: bool = false
var _flight_dir: Vector3 = Vector3.ZERO # last pre-impact travel direction
# Build, seat and launch a spear from an already-instantiated mesh (the one that was
# in the matador's hand). `origin` is the release point; `velocity` is the full
# ballistic launch vector; `gravity` is the RigidBody gravity_scale for the arc.
static func launch(parent: Node, mesh: Node3D, origin: Vector3, velocity: Vector3,
gravity: float) -> SpearProjectile:
var s := SpearProjectile.new()
s.gravity_scale = gravity
s.collision_layer = 0
# Mask WORLD so a miss sticks in the arena, and BULL so it can actually hit the
# bull — the bull's body is on layer BULL, not WORLD, so a WORLD-only mask flew
# straight through it (every throw whiffed, even against a stationary bull).
s.collision_mask = PhysicsLayers.WORLD | PhysicsLayers.BULL
s.contact_monitor = true
s.max_contacts_reported = 6
parent.add_child(s)
# Seat the body so its local +Z (the shaft, see spear.glb) points along the throw.
s.global_transform = Transform3D(_aim_basis(velocity), origin)
# Take the editor-tweakable collider from the spear wrapper (Spear.tscn's HitShape)
# before reparenting, then drop the now-nested template.
var collider := _collider_from(mesh)
mesh.reparent(s, false)
mesh.transform = Transform3D.IDENTITY
s._mesh = mesh
var tmpl := mesh.get_node_or_null("HitShape")
if tmpl != null:
tmpl.queue_free()
s.add_child(collider)
s.linear_velocity = velocity
_despawn(s)
return s
# Shed every spear stuck in `bull` — pop each off as a tumbling body that flies clear
# and clears itself on the despawn timer. Called by the bull's slam.
static func shed_all(bull: Node) -> void:
if not (bull is Node3D):
return
var scene: Node = bull.get_tree().current_scene
if scene == null:
scene = bull
var center: Vector3 = (bull as Node3D).global_position
# Stuck spears now live under a BoneAttachment3D deep in the skeleton, so walk the
# whole subtree by group rather than only the bull's direct children.
for node in bull.get_tree().get_nodes_in_group(_STUCK_GROUP):
if not (bull as Node).is_ancestor_of(node):
continue
var mesh := node as Node3D
var gx := mesh.global_transform
var rb := RigidBody3D.new()
rb.collision_layer = 0
rb.collision_mask = 1
scene.add_child(rb)
rb.global_transform = gx
mesh.reparent(rb, false)
mesh.transform = Transform3D.IDENTITY
mesh.remove_from_group(_STUCK_GROUP)
rb.add_child(_shaft_collider())
# Fling outward from the bull with a bit of lift and spin.
var out := gx.origin - center
out.y = 0.0
out = out.normalized() if out.length() > 0.01 else Vector3(randf() - 0.5, 0.0, randf() - 0.5)
rb.linear_velocity = out * randf_range(3.0, 6.0) + Vector3.UP * randf_range(3.0, 5.0)
rb.angular_velocity = Vector3(randf_range(-10.0, 10.0),
randf_range(-10.0, 10.0), randf_range(-10.0, 10.0))
_despawn(rb)
# Clone the wrapper's editor-tweakable HitShape (Spear.tscn) into a fresh CollisionShape3D
# for the projectile body; falls back to the built-in shaft collider if it's missing.
static func _collider_from(source: Node) -> CollisionShape3D:
var tmpl := source.get_node_or_null("HitShape") as CollisionShape3D
if tmpl != null and tmpl.shape != null:
var cs := CollisionShape3D.new()
cs.shape = tmpl.shape
cs.transform = tmpl.transform
return cs
return _shaft_collider()
static func _shaft_collider() -> CollisionShape3D:
# Collider along local +Z (mesh runs z≈-0.34 … 1.92), fattened so a fast throw
# can't tunnel past the bull's collision spheres.
var cap := CapsuleShape3D.new()
cap.radius = 0.13
cap.height = 2.2
var cs := CollisionShape3D.new()
cs.shape = cap
cs.position = Vector3(0.0, 0.0, 0.79)
cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
return cs
# Continuous bleed from a spear lodged in the bull: a steady spurt of dark-red gobs out
# of the entry wound. Parented to the stuck mesh so it tracks the wound as the bull
# moves; world-space particles (local_coords off) so they arc off and fall behind as a
# trail. Dies with the mesh when the spear is shed or despawned.
static func _wound_emitter() -> CPUParticles3D:
var ramp := Gradient.new()
ramp.set_color(0, Color(0.5, 0.02, 0.02, 1.0))
ramp.set_color(1, Color(0.22, 0.0, 0.0, 0.0))
var sc := Curve.new()
sc.add_point(Vector2(0.0, 1.0))
sc.add_point(Vector2(0.6, 0.7))
sc.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
# Seat at the wound (where the shaft meets the hide) and spray back out along the
# shaft: mesh local -Z is the exit direction, rotated into world by the node basis.
p.position = Vector3(0.0, 0.0, _TIP_Z - _EMBED_DEPTH)
p.emitting = true
p.amount = 22
p.lifetime = 0.6
p.explosiveness = 0.0
p.randomness = 0.6
p.local_coords = false
p.direction = Vector3(0.0, 0.55, -1.0).normalized()
p.spread = 32.0
p.gravity = Vector3(0.0, -12.0, 0.0)
p.initial_velocity_min = 1.6
p.initial_velocity_max = 4.0
p.scale_amount_min = 0.5
p.scale_amount_max = 1.4
p.mesh = _blood_mesh()
p.color_ramp = ramp
p.scale_amount_curve = sc
return p
# Small unshaded, vertex-coloured sphere for blood gobs.
static func _blood_mesh() -> SphereMesh:
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 s := SphereMesh.new()
s.radius = 0.045
s.height = 0.09
s.radial_segments = 4
s.rings = 2
s.material = mat
return s
static func _despawn(node: Node) -> void:
node.get_tree().create_timer(_DESPAWN_SEC).timeout.connect(func() -> void:
if is_instance_valid(node):
node.queue_free()
)
# Basis whose local +Z points along `v` (looking_at points -Z at its target).
static func _aim_basis(v: Vector3) -> Basis:
var dir := v.normalized()
if dir.length_squared() < 0.0001:
return Basis.IDENTITY
var up := Vector3.UP if absf(dir.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
return Basis.looking_at(-dir, up)
# While flying, keep the shaft aligned with the velocity; on the first contact freeze
# and hand off to _embed so the spear sticks where it struck instead of bouncing off.
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
if _stopped:
return
if state.get_contact_count() > 0:
_stopped = true
# Use the remembered pre-impact heading — state.linear_velocity is already the
# bounced (post-collision) velocity by now, which would aim the shaft wrong.
var contact := state.get_contact_collider_position(0)
var other := state.get_contact_collider_object(0)
state.linear_velocity = Vector3.ZERO
state.angular_velocity = Vector3.ZERO
_embed.call_deferred(other, contact, _flight_dir)
return
var v := state.linear_velocity
if v.length_squared() > 0.04:
_flight_dir = v.normalized()
var t := state.transform
t.basis = _aim_basis(v)
state.transform = t
state.angular_velocity = Vector3.ZERO
# Compose the stuck pose (tip buried _EMBED_DEPTH past the contact, shaft along the
# flight line) and plant the spear. A bull hit hands the mesh to the bull so it rides
# along as a spine; anything else pins the body in place for the despawn timer.
func _embed(other: Object, contact: Vector3, flight: Vector3) -> void:
var f := flight.normalized()
if f.length_squared() < 0.0001:
f = global_transform.basis.z.normalized()
var pose := Transform3D(_aim_basis(f), contact + f * (_EMBED_DEPTH - _TIP_Z))
var bull := other as Node
if bull != null and bull.is_in_group(&"player"):
DamageEvent.deal(bull, DamageEvent.Cause.THROWN, contact, self)
if is_instance_valid(_mesh) and is_instance_valid(bull):
_mesh.reparent(_spear_anchor(bull), false)
_mesh.global_transform = pose
_mesh.add_to_group(_STUCK_GROUP)
if Settings.gore:
_mesh.add_child(_wound_emitter()) # keep bleeding from the wound while lodged
queue_free()
return
freeze = true
global_transform = pose
# Node embedded spears ride on the bull: a BoneAttachment3D pinned to the COG bone so
# they follow the animated body (bob, turn) rather than only the character root. One
# anchor is shared by every spear; falls back to the bull root if there's no skeleton.
static func _spear_anchor(bull: Node) -> Node3D:
var existing := bull.find_child(_ANCHOR_NAME, true, false)
if existing is Node3D:
return existing as Node3D
var skel := bull.find_child("Skeleton3D", true, false) as Skeleton3D
if skel == null:
return bull as Node3D
var ba := BoneAttachment3D.new()
ba.name = _ANCHOR_NAME
skel.add_child(ba)
ba.bone_name = _COG_BONE
return ba
+1
View File
@@ -0,0 +1 @@
uid://dwjakh1is1jtj
+20
View File
@@ -0,0 +1,20 @@
[gd_scene format=3 uid="uid://c8swordwrap01"]
[ext_resource type="PackedScene" uid="uid://xyvank512l5n" path="res://art/weapons/sword.glb" id="1_swordglb"]
[ext_resource type="Script" path="res://systems/combat/hitbox.gd" id="2_hitbox"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_blade"]
radius = 0.18
height = 1.3
[node name="Sword" type="Node3D"]
[node name="sword" parent="." instance=ExtResource("1_swordglb")]
[node name="BladeHitbox" type="Area3D" parent="."]
script = ExtResource("2_hitbox")
damage_cause = "gored"
[node name="CollisionShape3D" type="CollisionShape3D" parent="BladeHitbox"]
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, -1, 0, 1, -4.371139e-08, 0, 0, 0.8)
shape = SubResource("CapsuleShape3D_blade")
+295
View File
@@ -0,0 +1,295 @@
extends Node
## Body wobble and tail simulation. Legs stay in rest pose (no IK, no ragdoll).
## Created at runtime by player.gd; call setup() before the first _process tick.
var _player: CharacterBody3D
var _bull: Node3D
var _skeleton: Skeleton3D
# Body wobble
var _prev_vel: Vector3 = Vector3.ZERO
var _tilt_x: float = 0.0
var _tilt_z: float = 0.0
var _aerial_pitch: float = 0.0
var charge_pitch_target: float = 0.0
var _charge_pitch_curr: float = 0.0
# When true, another system (the roll's ball-tumble) owns the bull's full
# orientation — skip writing body wobble so we don't fight it.
var external_rotation: bool = false
# Tail — Verlet chain simulation
const TAIL_BONES: Array = [
&"tail_1", &"tail_2", &"tail_3", &"tail_4",
&"tail_5", &"tail_6",
&"tail_tip_1", &"tail_tip_2", &"tail_tip_2_end",
]
var _tail_idx: Array[int] = []
var _tail_world: Array[Vector3] = []
var _tail_prev: Array[Vector3] = []
var _tail_lengths: Array[float] = []
var _tail_rest_dir: Array[Vector3] = []
var _tail_parent_idx: int = -1
var tail_ragdoll: bool = false
var _prev_on_floor: bool = true
var _dbg_on: bool = false
var _dbg_nodes: Array[MeshInstance3D] = []
func setup(player: CharacterBody3D, bull: Node3D) -> void:
_player = player
_bull = bull
_skeleton = _find_skeleton(bull)
if not _skeleton:
push_error("BullLegs: Skeleton3D not found under bull")
return
# Disable any physical bone simulator
var phys_sim := _find_physical_bones(_skeleton)
if phys_sim:
phys_sim.active = false
_setup_tail()
func _setup_tail() -> void:
for bone_name: StringName in TAIL_BONES:
var idx: int = _skeleton.find_bone(bone_name)
if idx == -1:
push_warning("BullLegs: tail bone '%s' not found" % bone_name)
continue
_tail_idx.append(idx)
if _tail_idx.size() < 2:
push_error("BullLegs: not enough tail bones found")
return
for idx: int in _tail_idx:
var world: Vector3 = _skeleton.to_global(_skeleton.get_bone_global_rest(idx).origin)
_tail_world.append(world)
_tail_prev.append(world)
var max_sep: float = 0.0
for i in range(_tail_world.size() - 1):
max_sep = maxf(max_sep, _tail_world[i].distance_to(_tail_world[i + 1]))
if max_sep < 0.01:
push_warning("BullLegs: tail bones have zero separation in rest pose, distributing along spine")
var root: Vector3 = _tail_world[0]
var back: Vector3 = (_skeleton.global_transform.basis.z + Vector3.DOWN * 0.3).normalized()
for i in range(_tail_world.size()):
_tail_world[i] = root + back * (i * 0.09)
_tail_prev[i] = _tail_world[i]
const MIN_SEG: float = 0.06
for i in range(_tail_world.size() - 1):
var seg_len: float = _tail_world[i].distance_to(_tail_world[i + 1])
_tail_lengths.append(maxf(seg_len, MIN_SEG))
for i in range(_tail_world.size() - 1):
var p1_skel: Vector3 = _skeleton.to_local(_tail_world[i])
var p2_skel: Vector3 = _skeleton.to_local(_tail_world[i + 1])
var seg_skel: Vector3 = p2_skel - p1_skel
_tail_rest_dir.append(seg_skel.normalized() if seg_skel.length() > 0.001 else Vector3(0, 0, 1))
_tail_rest_dir.append(_tail_rest_dir[-1])
_tail_parent_idx = _skeleton.get_bone_parent(_tail_idx[0])
func _process(delta: float) -> void:
if not _skeleton:
return
_wobble_body(delta)
_update_tail(delta)
_apply_tail()
if DP.b("show_tail") != _dbg_on:
_dbg_toggle()
if _dbg_on:
_dbg_update()
# ── Body Wobble ───────────────────────────────────────────────────────────────
func _wobble_body(delta: float) -> void:
var vel: Vector3 = _player.velocity
var accel: Vector3 = (vel - _prev_vel) / maxf(delta, 0.001)
_prev_vel = vel
var la: Vector3 = _bull.global_transform.basis.inverse() * accel
var tx := clampf(-la.z * DP.f("body_tilt_fwd"), -0.35, 0.35)
var tz := clampf(-la.x * DP.f("body_tilt_side"), -0.25, 0.25)
_tilt_x = lerpf(_tilt_x, tx, delta * 10.0)
_tilt_z = lerpf(_tilt_z, tz, delta * 10.0)
var flat_spd: float = Vector2(vel.x, vel.z).length()
var target_pitch: float = 0.0
if not _player.is_on_floor() and flat_spd > 0.5:
target_pitch = clampf(-atan2(vel.y, flat_spd) * DP.f("aerial_pitch_scale"), -1.4, 1.4)
_aerial_pitch = lerpf(_aerial_pitch, target_pitch, delta * DP.f("aerial_pitch_speed"))
_charge_pitch_curr = lerpf(_charge_pitch_curr, charge_pitch_target, delta * DP.f("charge_head_speed"))
if external_rotation:
return
_bull.rotation.x = _tilt_x + _aerial_pitch + _charge_pitch_curr
_bull.rotation.z = _tilt_z
# ── Tail ──────────────────────────────────────────────────────────────────────
func _update_tail(delta: float) -> void:
if _tail_world.is_empty():
return
var gravity := Vector3(0.0, -DP.f("tail_gravity"), 0.0)
var damping := 1.0 - clampf(DP.f("tail_damping"), 0.0, 0.99)
var anchor: Vector3
if _tail_parent_idx >= 0:
var parent_t := _skeleton.get_bone_global_pose(_tail_parent_idx)
anchor = _skeleton.to_global((parent_t * _skeleton.get_bone_rest(_tail_idx[0])).origin)
else:
anchor = _skeleton.to_global(_skeleton.get_bone_global_rest(_tail_idx[0]).origin)
_tail_world[0] = anchor
_tail_prev[0] = anchor
var on_floor := _player.is_on_floor()
if not on_floor and _prev_on_floor:
# Bull just left the ground — push all tail particles upward with it
for i: int in range(1, _tail_world.size()):
_tail_prev[i] = _tail_world[i] - _player.velocity * delta
_prev_on_floor = on_floor
var gravity_mult: float
if tail_ragdoll:
gravity_mult = 2.5
elif not on_floor:
gravity_mult = 0.15
else:
gravity_mult = 1.0
gravity *= gravity_mult
var flat_speed: float = Vector2(_player.velocity.x, _player.velocity.z).length()
var wag_strength: float = lerpf(DP.f("tail_wag_idle"), 0.0, clampf(flat_speed / 3.0, 0.0, 1.0))
var wag_freq: float = DP.f("tail_wag_freq")
var time: float = Time.get_ticks_msec() / 1000.0
var basis_x: Vector3 = _bull.global_transform.basis.x
for i in range(1, _tail_world.size()):
var vel: Vector3 = (_tail_world[i] - _tail_prev[i]) * damping
_tail_prev[i] = _tail_world[i]
var wag := Vector3.ZERO if tail_ragdoll \
else basis_x * sin(time * wag_freq + i * 0.5) * wag_strength
_tail_world[i] = _tail_world[i] + vel + gravity * (delta * delta) + wag * delta
for i in range(1, _tail_world.size()):
var dir: Vector3 = _tail_world[i] - _tail_world[i - 1]
var len: float = dir.length()
var target_len: float = _tail_lengths[i - 1]
if len > 0.0001:
_tail_world[i] = _tail_world[i - 1] + dir * (target_len / len)
else:
_tail_world[i] = _tail_world[i - 1] + Vector3(0.0, -target_len, 0.0)
var body_radius: float = DP.f("tail_body_radius")
for i in range(1, _tail_world.size()):
var p := _bull.to_local(_tail_world[i])
var closest := Vector3(0.0, 0.0, clampf(p.z, -0.3, 0.5))
var diff := p - closest
var dist := diff.length()
if dist < body_radius and dist > 0.001:
p = closest + diff.normalized() * body_radius
_tail_world[i] = _bull.to_global(p)
func _apply_tail() -> void:
if _tail_world.is_empty():
return
var n := _tail_world.size()
var local_pos: Array[Vector3] = []
local_pos.resize(n)
for i in n:
local_pos[i] = _skeleton.to_local(_tail_world[i])
for i in n:
var idx: int = _tail_idx[i]
var pos_skel: Vector3 = local_pos[i]
var point_dir_skel: Vector3
if i < n - 1:
point_dir_skel = (local_pos[i + 1] - pos_skel).normalized()
else:
point_dir_skel = (pos_skel - local_pos[i - 1]).normalized()
var rest_dir: Vector3 = _tail_rest_dir[i]
var rest_t: Transform3D = _skeleton.get_bone_global_rest(idx)
var new_basis: Basis
if rest_dir.length_squared() > 0.0001 and point_dir_skel.length_squared() > 0.0001:
new_basis = Basis(Quaternion(rest_dir, point_dir_skel)) * rest_t.basis
else:
new_basis = rest_t.basis
_skeleton.set_bone_global_pose_override(idx, Transform3D(new_basis, pos_skel), 1.0, false)
# ── Debug Visualization ───────────────────────────────────────────────────────
func _dbg_toggle() -> void:
if _dbg_on:
for n: MeshInstance3D in _dbg_nodes:
if is_instance_valid(n):
n.queue_free()
_dbg_nodes.clear()
_dbg_on = false
else:
_dbg_build()
_dbg_on = true
func _dbg_build() -> void:
var root: Node = _player.get_parent()
var n: int = _tail_world.size()
for i: int in n:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.render_priority = 1
mat.albedo_color = Color.RED if i == 0 \
else Color(1.0 - float(i) / n, 0.4 + 0.6 * float(i) / n, 0.0)
var sm := SphereMesh.new()
sm.radius = 0.10 if i == 0 else 0.06
sm.height = sm.radius * 2.0
sm.radial_segments = 6
sm.rings = 3
sm.material = mat
var mi := MeshInstance3D.new()
mi.mesh = sm
root.add_child(mi)
_dbg_nodes.append(mi)
func _dbg_update() -> void:
for i: int in _dbg_nodes.size():
_dbg_nodes[i].global_position = _tail_world[i]
# ── Utility ───────────────────────────────────────────────────────────────────
func _find_skeleton(node: Node) -> Skeleton3D:
if node is Skeleton3D:
return node as Skeleton3D
for child in node.get_children():
var r := _find_skeleton(child)
if r:
return r
return null
func _find_physical_bones(node: Node) -> PhysicalBoneSimulator3D:
for child in node.get_children():
if child is PhysicalBoneSimulator3D:
return child as PhysicalBoneSimulator3D
return null
+1
View File
@@ -0,0 +1 @@
uid://bvtw8s41wrrx
+140
View File
@@ -0,0 +1,140 @@
class_name IsoCamera
extends Camera3D
@export var height: float = 25.0
@export var horizontal_distance: float = 25.0
@export var horizontal_angle_deg: float = 45.0
# Perspective zoom (FOV)
@export var fov_min: float = 20.0
@export var fov_max: float = 75.0
@export var default_fov: float = 30.0
@export var focus_slice_thickness: float = 6.0
@export var blur_transition: float = 15.0
var _shake_amount: float = 0.0
var _shake_decay: float = 0.0
var _user_fov: float = 0.0
# Per-level framing multiplier: 1.0 = the designer's base shot, >1 dollies the camera
# back for a wider view (the lobby uses this). Set by game_shell when a level loads;
# kept separate from the scroll-wheel FOV zoom so the two don't fight.
var _framing_scale: float = 1.0
# Trailing-camera state: _focus is the smoothed point the camera frames (it lags
# the bull), _drag_offset is the velocity-driven trail that slides it behind.
var _focus: Vector3 = Vector3.ZERO
var _drag_offset: Vector3 = Vector3.ZERO
var _focus_init: bool = false
func _ready() -> void:
set_as_top_level(true)
projection = Camera3D.PROJECTION_PERSPECTIVE
fov = default_fov
_user_fov = default_fov
near = 0.1
far = 150.0
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("zoom_in"):
_user_fov = clamp(_user_fov / 1.1, fov_min, fov_max)
if event.is_action_pressed("zoom_out"):
_user_fov = clamp(_user_fov * 1.1, fov_min, fov_max)
# Pinch-to-zoom hook for the touch UI. `ratio` is the two-finger spread this frame over
# last frame: >1 (fingers parting) narrows the FOV to zoom in, <1 widens it to pull back,
# mirroring the scroll-wheel zoom_in/zoom_out feel.
func adjust_zoom(ratio: float) -> void:
if ratio <= 0.0:
return
_user_fov = clampf(_user_fov / ratio, fov_min, fov_max)
## Dolly the camera in/out for the level being loaded. 1.0 is the base framing; >1 pulls
## back for a wider shot (the lobby hub uses this). Independent of the scroll-wheel zoom.
func set_framing_scale(scale: float) -> void:
_framing_scale = maxf(scale, 0.1)
func _process(delta: float) -> void:
fov = lerpf(fov, _user_fov, delta * 8.0)
var angle_rad := deg_to_rad(horizontal_angle_deg)
var offset := Vector3(
sin(angle_rad) * horizontal_distance * _framing_scale,
height * _framing_scale,
cos(angle_rad) * horizontal_distance * _framing_scale
)
var target := get_parent() as Node3D
var focus := _drag_focus(target, delta)
global_position = focus + offset
look_at(focus, Vector3.UP)
_update_dof(focus)
if _shake_amount > 0.005:
global_position += Vector3(
randf_range(-_shake_amount, _shake_amount),
randf_range(-_shake_amount * 0.25, _shake_amount * 0.25),
randf_range(-_shake_amount, _shake_amount)
)
_shake_amount = move_toward(_shake_amount, 0.0, _shake_decay * delta)
# Smoothed follow point that drags behind the bull. The target trail sits opposite
# the bull's flat velocity (cam_drag metres per m/s); the offset and the focus both
# ease toward their targets so the camera lags and settles rather than snapping.
func _drag_focus(target: Node3D, delta: float) -> Vector3:
var pos := target.global_position
if not _focus_init:
_focus = pos
_focus_init = true
var vel := Vector3.ZERO
if target is CharacterBody3D:
vel = (target as CharacterBody3D).velocity
vel.y = 0.0
var drag_target := -vel * DP.f("cam_drag")
_drag_offset = _drag_offset.lerp(drag_target, clampf(DP.f("cam_drag_speed") * delta, 0.0, 1.0))
_focus = _focus.lerp(pos + _drag_offset, clampf(DP.f("cam_follow_lerp") * delta, 0.0, 1.0))
return _focus
func trigger_hit(strength: float) -> void:
if not Settings.screen_shake:
return
_shake_amount = strength * 0.5
_shake_decay = _shake_amount / 0.4
func _update_dof(target_pos: Vector3) -> void:
var cam_attr: CameraAttributesPractical = null
if attributes is CameraAttributesPractical:
cam_attr = attributes
if cam_attr == null:
return
var distance := global_position.distance_to(target_pos)
cam_attr.dof_blur_near_distance = max(distance - focus_slice_thickness * 0.5, near + 0.1)
cam_attr.dof_blur_far_distance = distance + focus_slice_thickness * 0.5
cam_attr.dof_blur_near_transition = blur_transition
cam_attr.dof_blur_far_transition = blur_transition
cam_attr.dof_blur_near_enabled = false
cam_attr.dof_blur_far_enabled = false
+1
View File
@@ -0,0 +1 @@
uid://cpyq2r5cq653g
+944
View File
@@ -0,0 +1,944 @@
extends CharacterBody3D
# Hoof positions in bull local space — dust emitter offsets
const HOOF_OFFSETS: Array = [
Vector3(-0.30, -0.25, -0.50),
Vector3( 0.30, -0.25, -0.50),
Vector3(-0.30, -0.25, 0.60),
Vector3( 0.30, -0.25, 0.60),
]
# Bull animation clips (Armature|* action names in art/characters/bull.fbx). IDLE is the
# looping base pose; the others are one-shot ability clips that return to it.
const _ANIM_IDLE: StringName = &"Armature|IDLE"
const _ANIM_KICK: StringName = &"Armature|KICK"
const _ANIM_SLAM: StringName = &"Armature|SLAM"
const _ANIM_DASH: StringName = &"Armature|DASH"
const _ANIM_WALK: StringName = &"Armature|WALK"
const _ANIM_ROLL: StringName = &"Armature|ROLL"
const _ANIM_DEATH: StringName = &"Armature|DEATH"
@onready var camera_pivot: Node3D = $Camera3D
@onready var cube_guy: Node3D = $bull
var _hoof_emitters: Array[CPUParticles3D] = []
var _legs: Node
var _was_on_floor: bool = false
var _pre_slide_vel_y: float = 0.0
# Built once and reused: a fresh StandardMaterial3D/SphereMesh per spawn makes a mobile WebGL
# driver recompile the shader + re-upload the mesh — a hitch "when attacking" absent on desktop.
var _unshaded_mat: StandardMaterial3D = null
var _particle_sphere_cache: Dictionary = {} # "radius:seg:ring" -> SphereMesh (shared)
var _impact_fx: Node = null # bull_impact_fx.gd — the coded (non-particle) slam rings + kick flash
# Ability system — kick=0, dash=1, slam=2, roll=3
var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0]
var _ability_active: bool = false
var _ability_timer: float = 0.0
var _active_ability: int = -1
var _dash_dir: Vector3 = Vector3.ZERO
var _dash_speed_cur: float = 0.0
var _roll_dir: Vector3 = Vector3.ZERO
var _roll_ramp: float = 0.0
var _roll_spin: float = 0.0
var _roll_target: Node3D = null
var _roll_chains_left: int = 0
var _roll_hit: Array[Node] = []
var _bull_anim: AnimationPlayer = null
# Locomotion animation state: true while the looping DASH clip is driving WASD
# movement, false while idling. Flipping it cross-fades between the two clips.
var _locomotion_moving: bool = false
var _slam_pending: bool = false
var _charge_trail_emitter: CPUParticles3D = null
var _roll_spark_emitter: CPUParticles3D = null
var _cube_scale: Vector3 = Vector3.ONE
# One clean hit ends the run — see take_sword_hit(). The HUD listens for `died`
# to raise the lose screen (the `cause` tags how it happened, for the death notice);
# _dead latches so a flurry of hits fires it only once.
signal died(cause: String)
var _dead: bool = false
# HP system — the bull soaks several clean hits instead of dying to the first.
# Each landed blade drops one pip and starts an i-frame window (so one pass can't
# empty the bar); at 0 the run ends via `died`. The HUD listens on `health_changed`.
signal health_changed(current: int, max_hp: int)
# Fired on every landed hit (after i-frames pass) so the HUD can flash the damage
# vignette; `cause` mirrors take_sword_hit ("gored" / "thrown").
signal hit_taken(cause: String)
var _max_hp: int = 10
var _hp: int = 10
# Bonus pips granted by lobby rewards (e.g. smashing every barrel). Tacked on top of the
# base bar; the HUD paints these top pips yellow instead of red.
var _bonus_hp: int = 0
var _hit_iframes: float = 0.0
var _huff_player: AudioStreamPlayer = null
var _crowd_player: AudioStreamPlayer = null
var _huff_timer: float = 0.0
enum DustState { NONE, WALK, CHARGE }
var _dust_state: DustState = DustState.NONE
# Hoof-dust scale/velocity per state (the look — mesh, colour, curve — lives in bull_hoof_dust.tscn).
# A charge sprint kicks up bigger, faster dust than a walk.
const _WALK_DUST_SCALE: Vector2 = Vector2(0.20, 0.40)
const _WALK_DUST_VEL: Vector2 = Vector2(0.8, 1.8)
const _CHARGE_DUST_SCALE: Vector2 = Vector2(0.35, 0.70)
const _CHARGE_DUST_VEL: Vector2 = Vector2(2.8, 5.5)
const RigidSkin = preload("res://systems/render/rigid_skin.gd")
var _hit_flash_tween: Tween
func _ready() -> void:
add_to_group(&"player")
# Native GPU skinning by default (the old Mali/ANGLE invisible-skinning bug is fixed in the
# 4.7 web templates). rigid_skin is now an opt-in fallback — see Controls.rigid_skin_enabled().
if Controls.rigid_skin_enabled():
RigidSkin.convert_tree(self)
_max_hp = maxi(1, int(DP.f("bull_max_hp")))
_hp = _max_hp
# Carry any run-long bonus pips (barrel reward) into this level so they survive the
# scene reload between fights; the HUD paints these top pips yellow.
_bonus_hp = Run.bonus_hp
_max_hp += _bonus_hp
_hp += _bonus_hp
_cube_scale = cube_guy.scale
_setup_impact_fx()
_setup_hoof_dust()
_setup_legs()
_setup_charge_trail()
_setup_roll_sparks()
_setup_audio()
_bull_anim = _find_anim_player(cube_guy)
_setup_bull_idle()
func _setup_legs() -> void:
_legs = Node.new()
_legs.set_script(load("res://actors/player/bull_legs.gd"))
add_child(_legs)
_legs.call(&"setup", self, cube_guy)
func _setup_impact_fx() -> void:
_impact_fx = Node.new()
_impact_fx.set_script(load("res://actors/player/bull_impact_fx.gd"))
add_child(_impact_fx)
_impact_fx.call(&"setup", self)
# The bull's ability/movement particle effects are authored as scenes in systems/fx/ (so Siim can
# tune them in the inspector). The continuous ones follow the bull — instanced once here as
# children, then toggled via `emitting`. The one-shots are fired on demand through Fx.spawn().
func _setup_hoof_dust() -> void:
for offset: Vector3 in HOOF_OFFSETS:
var p := Fx.attach(&"bull_hoof_dust", cube_guy) as CPUParticles3D
if p == null:
continue
p.position = offset
_hoof_emitters.append(p)
func _setup_charge_trail() -> void:
_charge_trail_emitter = Fx.attach(&"bull_charge_trail", cube_guy) as CPUParticles3D
if _charge_trail_emitter != null:
_charge_trail_emitter.position = Vector3(0.0, 0.25, 0.3)
func _setup_roll_sparks() -> void:
_roll_spark_emitter = Fx.attach(&"bull_roll_sparks", cube_guy) as CPUParticles3D
if _roll_spark_emitter != null:
_roll_spark_emitter.position = Vector3(0.0, 0.10, 0.0)
func _setup_audio() -> void:
if ResourceLoader.exists("res://sounds/bull_huff.ogg"):
_huff_player = AudioStreamPlayer.new()
_huff_player.stream = load("res://sounds/bull_huff.ogg")
_huff_player.volume_db = 0.0
_huff_player.bus = &"Effects"
add_child(_huff_player)
if ResourceLoader.exists("res://sounds/crowd_ambient.ogg"):
_crowd_player = AudioStreamPlayer.new()
var s := load("res://sounds/crowd_ambient.ogg") as AudioStreamOggVorbis
if s:
s.loop = true
_crowd_player.stream = s
_crowd_player.volume_db = -14.0
_crowd_player.autoplay = true
_crowd_player.bus = &"Ambient"
add_child(_crowd_player)
# Toggle the hoof emitters and swap their scale/velocity for the walk vs charge feel. The blue
# charge trail is the roll/dash ability's, driven from the roll loop, not by dust state.
func _set_dust_state(new_state: DustState) -> void:
if new_state == _dust_state:
return
_dust_state = new_state
var scale := _CHARGE_DUST_SCALE if new_state == DustState.CHARGE else _WALK_DUST_SCALE
var vel := _CHARGE_DUST_VEL if new_state == DustState.CHARGE else _WALK_DUST_VEL
for p: CPUParticles3D in _hoof_emitters:
p.scale_amount_min = scale.x
p.scale_amount_max = scale.y
p.initial_velocity_min = vel.x
p.initial_velocity_max = vel.y
p.emitting = new_state != DustState.NONE
func _find_anim_player(node: Node) -> AnimationPlayer:
if node is AnimationPlayer:
return node as AnimationPlayer
for child: Node in node.get_children():
var r := _find_anim_player(child)
if r:
return r
return null
# IDLE ships one-shot; make it loop and start it so the bull breathes at rest
# instead of freezing on a bind pose. It's the base every ability blends back to.
func _setup_bull_idle() -> void:
if _bull_anim == null:
return
# IDLE, DASH and ROLL loop as continuous states (DASH doubles as the WASD
# locomotion cycle); the remaining ability clips are one-shot.
for clip: StringName in [_ANIM_IDLE, _ANIM_DASH, _ANIM_ROLL]:
if _bull_anim.has_animation(clip):
_bull_anim.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
_play_idle()
# Return to the looping idle (and undo any ability speed-scale). Cross-fades so a
# finishing ability clip eases back to idle instead of snapping.
func _play_idle() -> void:
_locomotion_moving = false
if _bull_anim == null:
return
_bull_anim.speed_scale = 1.0
if _bull_anim.has_animation(_ANIM_IDLE):
_bull_anim.play(_ANIM_IDLE, 0.05)
# Drive the WASD locomotion clip: the looping DASH doubles as the run cycle while a
# direction is held, easing back to idle when the bull coasts to a stop. Cross-fades
# on each transition so idle↔run (and steering into it after an ability) blend rather
# than snap. Abilities own the animation while active, so this yields to them.
func _update_locomotion_anim(moving: bool) -> void:
if _ability_active or _bull_anim == null or moving == _locomotion_moving:
return
_locomotion_moving = moving
if moving:
if _bull_anim.has_animation(_ANIM_WALK):
_bull_anim.speed_scale = 2.0
_bull_anim.play(_ANIM_WALK, 1)
var anim = _bull_anim.get_animation(_ANIM_WALK)
anim.loop_mode = Animation.LOOP_LINEAR
else:
_play_idle()
func _unhandled_input(event: InputEvent) -> void:
if _dead:
return
# Abilities can be chained: a new one interrupts whatever is currently
# active, gated only by each ability's own cooldown.
if event.is_action_pressed(&"ability_kick"):
try_activate_ability(0)
elif event.is_action_pressed(&"ability_dash"):
try_activate_ability(1)
elif event.is_action_pressed(&"ability_slam"):
try_activate_ability(2)
elif event.is_action_pressed(&"ability_roll"):
try_activate_ability(3)
# Public ability entry point shared by keyboard/gamepad input and the on-screen
# touch buttons. Slot order matches ability_cd — kick=0, dash=1, slam=2, roll=3.
# No-ops if the bull is dead or the slot is still cooling down.
func try_activate_ability(slot: int) -> void:
if _dead or slot < 0 or slot >= ability_cd.size() or ability_cd[slot] > 0.0:
return
match slot:
0: _activate_kick()
1: _activate_dash()
2: _activate_slam()
3: _activate_roll()
func ability_cooldown_fraction(slot: int) -> float:
var maxcd := 0.0
match slot:
0: maxcd = DP.f("kick_cooldown")
1: maxcd = DP.f("dash_cooldown")
2: maxcd = DP.f("slam_cooldown")
3: maxcd = DP.f("roll_cooldown")
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
_active_ability = 0
_ability_timer = 0.3
if _bull_anim:
_bull_anim.speed_scale = 2.0
if _bull_anim and _bull_anim.has_animation(_ANIM_KICK):
_bull_anim.play(_ANIM_KICK)
_ability_timer = _bull_anim.get_animation(_ANIM_KICK).length
var raw := cube_guy.global_transform.basis.z
var back := -Vector3(raw.x, 0.0, raw.z).normalized()
_spawn_kick_fx(back)
get_tree().create_timer(0.2).timeout.connect(
func() -> void: _hit_matadors_cone(DP.f("kick_range"), deg_to_rad(85.0), DP.f("kick_strength"), back)
)
func _activate_slam() -> void:
ability_cd[2] = DP.f("slam_cooldown")
_ability_active = true
_active_ability = 2
_legs.set(&"tail_ragdoll", true)
_slam_pending = true
SpearProjectile.shed_all.call_deferred(self) # the leap shakes off any stuck spears
var jump_vel := DP.f("slam_jump_velocity")
velocity.y = jump_vel
# 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(_ANIM_SLAM):
var anim_len := _bull_anim.get_animation(_ANIM_SLAM).length
_bull_anim.speed_scale = anim_len / maxf(air_time, 0.01)
_bull_anim.play(_ANIM_SLAM)
func _activate_dash() -> void:
ability_cd[1] = DP.f("dash_cooldown")
_ability_active = true
_active_ability = 1
_ability_timer = 0.45
if _bull_anim:
_bull_anim.speed_scale = 1.0
if _bull_anim and _bull_anim.has_animation(_ANIM_DASH):
_bull_anim.play(_ANIM_DASH)
_ability_timer = _bull_anim.get_animation(_ANIM_DASH).length
var raw := cube_guy.global_transform.basis.z
_dash_dir = Vector3(raw.x, 0.0, raw.z).normalized()
_dash_speed_cur = DP.f("dash_speed")
velocity.x = _dash_dir.x * _dash_speed_cur
velocity.z = _dash_dir.z * _dash_speed_cur
_spawn_dash_burst()
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch"))
if _huff_player:
_huff_player.pitch_scale = randf_range(0.9, 1.1)
_huff_player.play()
func _activate_roll() -> void:
ability_cd[3] = DP.f("roll_cooldown")
_ability_active = true
_active_ability = 3
_ability_timer = DP.f("roll_duration")
_roll_ramp = 0.0
_roll_spin = 0.0
_roll_chains_left = int(DP.f("roll_chain_count"))
_roll_hit.clear()
# Lock onto the closest matador and curl into the ball aimed at it; if the arena
# is empty, launch along the current heading (or facing when standing still).
_roll_target = _roll_pick_target()
var flat := Vector3(velocity.x, 0.0, velocity.z)
if _roll_target != null:
var to_t := _roll_target.global_position - global_position
_roll_dir = Vector3(to_t.x, 0.0, to_t.z).normalized()
elif flat.length() > 1.0:
_roll_dir = flat.normalized()
else:
var f := cube_guy.global_transform.basis.z
_roll_dir = Vector3(f.x, 0.0, f.z).normalized()
var launch := DP.f("roll_base_speed")
velocity.x = _roll_dir.x * launch
velocity.z = _roll_dir.z * launch
if _bull_anim and _bull_anim.has_animation(_ANIM_ROLL):
_bull_anim.speed_scale = 1.0
_bull_anim.play(_ANIM_ROLL)
_legs.set(&"tail_ragdoll", true) # tail streams out behind
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch")) # horns down, plowing
_legs.set(&"external_rotation", true) # roll owns the model's full orientation (tumble)
if _roll_spark_emitter:
_roll_spark_emitter.emitting = true
_spawn_dash_burst()
camera_pivot.call(&"trigger_hit", 0.35)
if _huff_player:
_huff_player.pitch_scale = randf_range(0.8, 1.0)
_huff_player.play()
# One rolling frame: home on the current target matador, accelerate the longer the
# roll lasts, move (walls just slide), then pop the ball on contact and chain to the
# next closest. Fully replaces the normal locomotion path while active. `steer` is
# ignored — the roll auto-targets — but kept so the caller stays uniform.
func _tick_roll(delta: float, _steer: Vector3) -> void:
# Reacquire if the target ragdolled or was freed before we reached it.
if not _roll_target_valid():
_roll_target = _roll_pick_target()
if _roll_target != null:
var to_t := _roll_target.global_position - global_position
var flat := Vector3(to_t.x, 0.0, to_t.z)
if flat.length() > 0.1:
var t := clampf(DP.f("roll_turn") * delta, 0.0, 1.0)
_roll_dir = _roll_dir.slerp(flat.normalized(), t).normalized()
# Speed builds over time: base → max across roll_rampup_time, so a long roll
# winds up into a runaway boulder while a quick tap barely gets going.
_roll_ramp += delta
var ramp := clampf(_roll_ramp / maxf(DP.f("roll_rampup_time"), 0.01), 0.0, 1.0)
var top := DP.f("roll_max_speed")
var speed := lerpf(DP.f("roll_base_speed"), top, ramp)
velocity.x = _roll_dir.x * speed
velocity.z = _roll_dir.z * speed
# Tumble the whole model like a Powerball: face the travel direction (yaw) then
# spin forward about the horizontal axis perpendicular to it, angular speed = v/r
# so it "rolls" without slipping. Legs yield rotation control while active.
var radius := maxf(DP.f("roll_spin_radius"), 0.1)
_roll_spin = wrapf(_roll_spin + (speed / radius) * delta, 0.0, TAU)
var facing := Basis(Vector3.UP, atan2(_roll_dir.x, _roll_dir.z))
var tumble_axis := Vector3.UP.cross(_roll_dir).normalized()
cube_guy.basis = (Basis(tumble_axis, _roll_spin) * facing).scaled(_cube_scale)
if _bull_anim and _bull_anim.has_animation(_ANIM_ROLL):
_bull_anim.speed_scale = clampf(speed / maxf(top, 1.0), 0.6, 2.4)
_set_dust_state(DustState.CHARGE)
if _charge_trail_emitter:
_charge_trail_emitter.emitting = true
_charge_trail_emitter.direction = (-_roll_dir + Vector3(0.0, 0.25, 0.0)).normalized()
move_and_slide()
_was_on_floor = is_on_floor()
_smash_barrels_in_radius(DP.f("roll_hit_radius"))
_roll_try_pop(speed)
# Pop the ball on ramming a matador: launch every matador in the hit radius up and
# outward, then either CHAIN to the next closest (if chains remain and a target is
# left) or end the roll. Returns true if it popped so the caller can stop touching
# roll state this frame.
func _roll_try_pop(speed: float) -> bool:
var radius := DP.f("roll_hit_radius")
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
if _roll_hit.has(mat) or not _mat_active(mat):
continue
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
to_mat.y = 0.0
if to_mat.length() > radius:
continue
# Record everyone this pop reaches so the chain never re-picks a downed body.
for m: Node in get_tree().get_nodes_in_group(&"matador"):
var d: Vector3 = (m as Node3D).global_position - global_position
d.y = 0.0
if d.length() <= radius and not _roll_hit.has(m):
_roll_hit.append(m)
_hit_matadors_radius(radius, DP.f("roll_hit_strength"), DP.f("roll_knockup"))
camera_pivot.call(&"trigger_hit", clampf(speed / 90.0, 0.35, 1.0))
if _huff_player:
_huff_player.pitch_scale = randf_range(1.05, 1.35)
_huff_player.play()
if _roll_chains_left > 0:
var next := _roll_pick_target()
if next != null:
_roll_chains_left -= 1
_roll_target = next
return true # keep rolling — the ball carries on to the next target
_end_roll()
return true
return false
# Closest thing the roll should home on — an active matador (arena) or an intact barrel
# (lobby) we haven't already smashed this roll. Null if nothing is left to hit.
func _roll_pick_target() -> Node3D:
var best: Node3D = null
var best_d := INF
for cand: Node3D in _roll_targets():
var d: float = cand.global_position.distance_squared_to(global_position)
if d < best_d:
best_d = d
best = cand
return best
# Live roll targets: active matadors plus standing barrels, minus anything already popped.
func _roll_targets() -> Array[Node3D]:
var out: Array[Node3D] = []
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
if not _roll_hit.has(mat) and _mat_active(mat):
out.append(mat as Node3D)
for barrel: Node in get_tree().get_nodes_in_group(&"barrel"):
if _barrel_alive(barrel):
out.append(barrel as Node3D)
return out
func _roll_target_valid() -> bool:
if not is_instance_valid(_roll_target):
return false
if _roll_target.is_in_group(&"barrel"):
return _barrel_alive(_roll_target)
return _mat_active(_roll_target)
# A matador still in play — valid, not ragdolled/consumed. Ragdolled bodies report
# is_active() == false, so a popped target is skipped on the next chain search.
func _mat_active(mat: Node) -> bool:
return is_instance_valid(mat) and mat.has_method(&"is_active") and mat.call(&"is_active")
# A lobby barrel still standing — valid and not already queued for removal by an earlier hit.
func _barrel_alive(barrel: Node) -> bool:
return is_instance_valid(barrel) and not barrel.is_queued_for_deletion()
# Burst every barrel whose centre sits within range_m on the ground plane. Shared by the
# boulder roll (which ploughs straight through them) and the slam shockwave. Each barrel
# self-removes and leaves the &"barrel" group, so the HUD's clear-the-lobby tally counts it
# exactly once.
func _smash_barrels_in_radius(range_m: float) -> void:
for barrel: Node in get_tree().get_nodes_in_group(&"barrel"):
if not _barrel_alive(barrel):
continue
var to_b: Vector3 = (barrel as Node3D).global_position - global_position
to_b.y = 0.0
if to_b.length() <= range_m:
barrel.call(&"destroy_barrel")
# True while the boulder roll is live — matadors defer their charge-gore to the roll's own pop
# (is_rolling guard in matador._take_bull_charge) so the ball pops instead of quietly goring.
func is_rolling() -> bool:
return _active_ability == 3 and _ability_active
# True while the dash ability is live. A dash is a committed lunge — it gores/rams on contact
# regardless of angle (enemies bypass their horns-first cone while it's set).
func is_dashing() -> bool:
return _active_ability == 1 and _ability_active
# End the Powerball early: brake hard (the ball "pops" and unrolls) and drop back to the normal
# locomotion path next frame.
func _end_roll() -> void:
_ability_active = false
_active_ability = -1
_ability_timer = 0.0
_roll_ramp = 0.0
_roll_target = null
_roll_chains_left = 0
_roll_hit.clear()
_legs.set(&"charge_pitch_target", 0.0)
_legs.set(&"tail_ragdoll", false)
_legs.set(&"external_rotation", false)
if _roll_spark_emitter:
_roll_spark_emitter.emitting = false
if _charge_trail_emitter:
_charge_trail_emitter.emitting = false
velocity.x *= 0.15
velocity.z *= 0.15
_play_idle()
func _hit_matadors_cone(range_m: float, half_angle_rad: float, strength: float,
dir: Vector3 = Vector3.ZERO) -> void:
var raw := cube_guy.global_transform.basis.z
var forward := dir if dir.length_squared() > 0.01 else Vector3(raw.x, 0.0, raw.z).normalized()
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
var dist := to_mat.length()
if dist > range_m:
continue
if dist > 0.01 and forward.dot(to_mat / dist) < cos(half_angle_rad):
continue
mat.call(&"apply_ability_hit", (forward + Vector3.UP * 0.4).normalized(), strength)
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
var dist := to_mat.length()
if dist > range_m:
continue
var away := to_mat / dist if dist > 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, up_boost)
# ── Blood-spurt particle helpers ──────────────────────────────────────────────
# The ability effects now live in systems/fx/; only the spear-wound blood below is still built in
# code. Shared material, built once so the mobile driver compiles this particle shader a single
# time (colour comes per-particle from the colour_ramp via vertex colour, not this material).
func _unshaded_material() -> StandardMaterial3D:
if _unshaded_mat == null:
_unshaded_mat = StandardMaterial3D.new()
_unshaded_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
_unshaded_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
_unshaded_mat.vertex_color_use_as_albedo = true
_unshaded_mat.cull_mode = BaseMaterial3D.CULL_DISABLED
return _unshaded_mat
# Low-poly particle-sphere mesh (height = diameter), cached per size so repeats reuse one GPU upload.
func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh:
var key := "%.4f:%d:%d" % [radius, segments, rings]
var cached: SphereMesh = _particle_sphere_cache.get(key)
if cached != null:
return cached
var sphere := SphereMesh.new()
sphere.radius = radius
sphere.height = radius * 2.0
sphere.radial_segments = segments
sphere.rings = rings
sphere.material = mat
_particle_sphere_cache[key] = sphere
return sphere
# Queue-free a transient FX node once its particles have finished.
func _free_after(node: Node, delay: float) -> void:
get_tree().create_timer(delay).timeout.connect(func() -> void:
if is_instance_valid(node):
node.queue_free()
)
func _spawn_slam_fx() -> void:
var origin := global_position + Vector3(0.0, -0.5, 0.0)
_impact_fx.call(&"slam_rings", origin, DP.f("slam_range"))
Fx.spawn(&"bull_slam", Transform3D(Basis(), origin))
camera_pivot.call(&"trigger_hit", 0.60)
func _spawn_kick_fx(back_dir: Vector3) -> void:
var origin := global_position + back_dir * 0.55 + Vector3(0.0, -0.25, 0.0)
Fx.spawn(&"bull_kick", _aimed_xform(origin, back_dir))
_impact_fx.call(&"kick_flash", origin)
camera_pivot.call(&"trigger_hit", 0.35)
func _spawn_dash_burst() -> void:
Fx.spawn(&"bull_dash", _aimed_xform(global_position + Vector3(0.0, 0.2, 0.0), _dash_dir))
# A transform at `origin` whose local -Z faces `forward` (flattened to horizontal). The kick/dash
# effects bake their spray in local space, so aiming the spawn transform points the burst the
# right way. Falls back to an unrotated transform when `forward` has no horizontal component.
func _aimed_xform(origin: Vector3, forward: Vector3) -> Transform3D:
var flat := Vector3(forward.x, 0.0, forward.z)
if flat.length_squared() < 0.0001:
return Transform3D(Basis(), origin)
return Transform3D(Basis(), origin).looking_at(origin + flat.normalized(), Vector3.UP)
func _physics_process(delta: float) -> void:
# ── Dead bull ─────────────────────────────────────────────────────────────
# The run is over the instant HP hits zero, but the lose screen holds off a
# beat before it appears (see HUD._show_game_over). Freeze the carcass through
# that delay — no input, no abilities, just coast to a stop under gravity — so a
# dead bull can't keep charging around while the screen is pending.
if _dead:
velocity.x = move_toward(velocity.x, 0.0, 40.0 * delta)
velocity.z = move_toward(velocity.z, 0.0, 40.0 * delta)
if not is_on_floor():
velocity += get_gravity() * delta
move_and_slide()
return
# ── Gravity ───────────────────────────────────────────────────────────────
if not is_on_floor():
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")
var cam_basis := camera_pivot.global_transform.basis
var flat_forward := -Vector3(cam_basis.z.x, 0.0, cam_basis.z.z).normalized()
var flat_right := Vector3(cam_basis.x.x, 0.0, cam_basis.x.z).normalized()
var direction := (flat_forward * -input_dir.y + flat_right * input_dir.x).normalized()
var on_floor := is_on_floor()
# ── Ability cooldowns ────────────────────────────────────────────────────────
_hit_iframes = maxf(0.0, _hit_iframes - delta)
for i: int in 4:
ability_cd[i] = maxf(0.0, ability_cd[i] - delta)
if _ability_active:
_ability_timer -= delta
if _ability_timer <= 0.0:
if _active_ability == 3:
_end_roll() # tears down roll-specific state (sparks, tumble control)
else:
_ability_active = false
_active_ability = -1
_legs.set(&"charge_pitch_target", 0.0)
_legs.set(&"tail_ragdoll", false)
_slam_pending = false
_play_idle()
# ── Boulder roll ──────────────────────────────────────────────────────────
# While rolling, the ability owns movement entirely (its own accel, low friction
# and wall ricochet) — bypass the normal input/kick/damping path below.
if _active_ability == 3 and _ability_active:
_tick_roll(delta, direction)
return
_was_on_floor = on_floor
# ── Ground locomotion — smooth accelerate / friction ──────────────────────
# WASD/arrows drive the bull directly: hold a direction to ramp up to max_speed
# and steer, release to coast to a stop. Ability bursts (dash) and wall bounces
# can exceed max_speed — while a direction is held we steer that momentum without
# braking it (cap tracks the current speed); friction only bleeds the excess once
# input is released.
var flat_vel := Vector3(velocity.x, 0.0, velocity.z)
var max_speed := DP.f("max_speed")
if _active_ability == 1 and _ability_active:
# Dash: leave the barrel fast like a bullet, then bleed exponentially back
# down to cruise speed (fastest drop right at the start), so it settles into
# normal running by the time the clip ends instead of holding top speed.
_dash_speed_cur = lerpf(_dash_speed_cur, max_speed, 1.0 - exp(-DP.f("dash_decel") * delta))
flat_vel = _dash_dir * _dash_speed_cur
elif direction.length_squared() > 0.01:
var cap := maxf(max_speed, flat_vel.length())
flat_vel = flat_vel.move_toward(direction * cap, DP.f("move_accel") * delta)
else:
flat_vel = flat_vel.move_toward(Vector3.ZERO, DP.f("move_friction") * delta)
velocity.x = flat_vel.x
velocity.z = flat_vel.z
# ── Visual rotation follows velocity direction ─────────────────────────────
var flat_speed := Vector2(velocity.x, velocity.z).length()
if flat_speed > 0.4:
var target_angle := atan2(velocity.x, velocity.z)
cube_guy.rotation.y = lerp_angle(
cube_guy.rotation.y, target_angle, delta * DP.f("visual_turn_speed"))
# Looping WALK clip while genuinely moving on the ground, idle otherwise.
_update_locomotion_anim(on_floor and flat_speed > 1.0)
_pre_slide_vel_y = velocity.y
var pre_wall_vel := Vector3(velocity.x, 0.0, velocity.z)
move_and_slide()
# ── Wall bounce — no sliding, guaranteed exit ────────────────────────────
# pre_wall_vel is captured before move_and_slide strips the into-wall component,
# so into_spd correctly reflects how hard the bull hit the wall even when stationary.
for i in get_slide_collision_count():
var col := get_slide_collision(i)
var normal := col.get_normal()
if abs(normal.y) < 0.5:
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"))
velocity.x = flat_n.x * exit_spd
velocity.z = flat_n.z * exit_spd
pre_wall_vel = Vector3(velocity.x, 0.0, velocity.z)
# Ram straight through a barrel that formed the wall we bounced off.
var hit := col.get_collider()
if hit != null and hit.has_method(&"destroy_barrel"):
hit.call(&"destroy_barrel")
# ── Landing. Don't bounce ─────────────────────────────────────────────────────
if is_on_floor() and not _was_on_floor:
velocity.y = 0.0
# ── Slam landing ──────────────────────────────────────────────────────────
if _slam_pending and is_on_floor() and not _was_on_floor:
_slam_pending = false
_ability_active = false
_active_ability = -1
_ability_timer = 0.0
_legs.set(&"charge_pitch_target", 0.0)
_legs.set(&"tail_ragdoll", false)
_play_idle()
_spawn_slam_fx()
_hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength"), DP.f("slam_launch_up"))
_smash_barrels_in_radius(DP.f("slam_range"))
# ── Dust ──────────────────────────────────────────────────────────────────
if flat_speed > DP.f("dust_charge_spd") and on_floor:
_set_dust_state(DustState.CHARGE)
elif flat_speed > DP.f("dust_walk_spd") and on_floor:
_set_dust_state(DustState.WALK)
else:
_set_dust_state(DustState.NONE)
# ── Charge trail direction ────────────────────────────────────────────────
if _dust_state == DustState.CHARGE and _charge_trail_emitter:
var vel_flat := Vector3(velocity.x, 0.0, velocity.z)
if vel_flat.length_squared() > 0.1:
_charge_trail_emitter.direction = (-vel_flat.normalized() + Vector3(0.0, 0.25, 0.0)).normalized()
func get_hp() -> int:
return _hp
func get_max_hp() -> int:
return _max_hp
# Bonus pips currently on the bar — the HUD colours this many top pips yellow.
func get_bonus_hp() -> int:
return _bonus_hp
# Award extra health (from a lobby reward): grows both the cap and the current bar so the
# new pips arrive full, and marks them as bonus so the HUD paints them yellow.
func grant_bonus_hp(amount: int) -> void:
if amount <= 0:
return
_bonus_hp += amount
_max_hp += amount
_hp += amount
health_changed.emit(_hp, _max_hp)
# A clean hit — thrown or swung blade — costs the bull one HP pip; the run ends only
# when the last pip is gone. `cause` ("gored" / "thrown") tags how it happened for the
# death notice. I-frames absorb a flurry so several blades in one pass drop one pip,
# not the whole bar; `_dead` latches so the lose screen is raised exactly once.
func take_sword_hit(cause: String = "", hit_pos: Vector3 = Vector3.ZERO) -> void:
if _dead or _hit_iframes > 0.0:
return
_hp -= 1
_hit_flash()
_hit_iframes = DP.f("bull_hit_iframes")
health_changed.emit(_hp, _max_hp)
hit_taken.emit(cause)
camera_pivot.call(&"trigger_hit", 0.7)
# Vibrate controller 0 with half weak strength, full strong strength, for 0.5 seconds
if DP.b("gamepad_vibration"):
Input.start_joy_vibration(0, 0.5, 1, 0.2)
# A spear gores a spot — blood spurts from the entry wound (where it struck the
# bull). Melee gores pass no position and skip the spurt; gore can be turned off.
if cause == "thrown" and Settings.gore:
_spawn_blood(hit_pos if hit_pos != Vector3.ZERO else global_position)
if _huff_player:
_huff_player.pitch_scale = randf_range(0.7, 0.9)
_huff_player.play()
if _hp <= 0:
if _bull_anim.current_animation != _ANIM_DEATH:
_bull_anim.speed_scale = 0.8
_bull_anim.play(_ANIM_DEATH)
_dead = true
died.emit(cause)
# Pop the bull upward — a smash hitbox knocks it off its feet. Only ever raises the
# vertical speed so a downward-moving bull still gets launched.
func apply_knock_up(v: float) -> void:
velocity.y = maxf(velocity.y, v)
# Typed entry point for the unified hit pipeline: a Hitbox (or any attacker) hands the bull a
# DamageEvent instead of calling take_sword_hit directly. A thin adapter over the existing pip /
# knock-up logic, so behaviour is unchanged while callers migrate off the stringly path.
func receive_damage(event: DamageEvent) -> void:
take_sword_hit(event.cause_name(), event.hit_position)
if event.knock_up > 0.0:
apply_knock_up(event.knock_up)
# Blood spurt from a spear wound: a short-lived spray of dark-red gobs flung out of the
# entry point, biased outward from the bull's centre (so it reads as bursting from the
# hide) with a touch of lift before gravity drags it down.
func _spawn_blood(hit_pos: Vector3) -> void:
var out := hit_pos - global_position
out.y = 0.0
out = out.normalized() if out.length() > 0.05 else Vector3(0.0, 0.0, 1.0)
var dir := (out + Vector3.UP * 0.8).normalized()
var sphere := _particle_sphere(0.05, 4, 2, _unshaded_material())
var ramp := Gradient.new()
ramp.set_color(0, Color(0.55, 0.02, 0.02, 1.0))
ramp.set_color(1, Color(0.28, 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.7))
scale_curve.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.9
p.amount = 34
p.lifetime = 0.7
p.randomness = 0.5
p.local_coords = false
p.direction = dir
p.spread = 42.0
p.gravity = Vector3(0.0, -14.0, 0.0)
p.initial_velocity_min = 3.5
p.initial_velocity_max = 8.0
p.scale_amount_min = 0.5
p.scale_amount_max = 1.6
p.mesh = sphere
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = hit_pos
p.restart()
_free_after(p, 1.5)
func _hit_flash() -> void:
var meshes := find_children("*", "MeshInstance3D", true, false)
for node in meshes:
var mesh := node as MeshInstance3D
if mesh:
mesh.material_overlay = _make_hit_overlay()
if _hit_flash_tween:
_hit_flash_tween.kill()
_hit_flash_tween = create_tween()
_hit_flash_tween.tween_interval(0.08)
_hit_flash_tween.tween_callback(_clear_hit_flash)
func _make_hit_overlay() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = Color(1.0, 0.03, 0.03, 1.0)
return mat
func _clear_hit_flash() -> void:
var meshes := find_children("*", "MeshInstance3D", true, false)
for node in meshes:
var mesh := node as MeshInstance3D
if mesh:
mesh.material_overlay = null
+1
View File
@@ -0,0 +1 @@
uid://bn777gguclud5
+52
View File
@@ -0,0 +1,52 @@
[gd_scene format=3 uid="uid://crtm0xtiql3r8"]
[ext_resource type="Script" uid="uid://bn777gguclud5" path="res://actors/player/player.gd" id="1_xhfnw"]
[ext_resource type="Script" uid="uid://cpyq2r5cq653g" path="res://actors/player/camera_iso.gd" id="2_camera_iso"]
[ext_resource type="PackedScene" uid="uid://bun14xmk20pcj" path="res://art/characters/bull.fbx" id="4_e80uo"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_kne1u"]
radius = 0.7495117
height = 1.845398
[node name="Player" type="CharacterBody3D" unique_id=165510044]
collision_layer = 2
collision_mask = 9
script = ExtResource("1_xhfnw")
[node name="CollisionBull" type="CollisionShape3D" parent="." unique_id=571780801]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.29183745, 0.3197594)
shape = SubResource("CapsuleShape3D_kne1u")
[node name="Camera3D" type="Camera3D" parent="." unique_id=1120161087]
script = ExtResource("2_camera_iso")
[node name="bull" parent="." unique_id=1386495166 instance=ExtResource("4_e80uo")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.655, 0.23)
[node name="Skeleton3D" parent="bull/Armature" parent_id_path=PackedInt32Array(1386495166, 1942410469) index="0" unique_id=550138996]
bones/0/position = Vector3(0, 0, 0.009963839)
bones/0/rotation = Quaternion(0.7071068, 0, 0, 0.7071067)
bones/0/scale = Vector3(1, 1, 1)
bones/1/position = Vector3(0.003526625, -0.0043296535, 0.0040827994)
bones/1/rotation = Quaternion(-0.00038647873, 0.06909673, 0.9868542, -0.14609687)
bones/2/rotation = Quaternion(-0.06929122, 3.2175163e-07, -0.112670615, 0.99121344)
bones/4/position = Vector3(0.0045358227, -0.002137695, -0.004238624)
bones/4/rotation = Quaternion(-0.24948503, 0.19590352, 0.9416978, -0.11217985)
bones/4/scale = Vector3(1.0000001, 0.9999999, 1.0000001)
bones/5/rotation = Quaternion(-0.58943665, -0.3660725, -0.22564478, 0.683842)
bones/5/scale = Vector3(1, 1.0000001, 0.99999994)
bones/6/rotation = Quaternion(0.52910197, 0.15997827, 0.120245, 0.8246207)
bones/6/scale = Vector3(1, 1, 1.0000001)
bones/17/position = Vector3(0, 0.0009373814, 0.007552617)
bones/17/rotation = Quaternion(-9.4156974e-08, 0.7071068, 0.7071067, -9.4156974e-08)
bones/19/position = Vector3(-0.003526625, -0.0043296535, 0.0040827994)
bones/19/rotation = Quaternion(0.00038647873, 0.06909673, 0.9868542, 0.14609687)
bones/20/rotation = Quaternion(-0.06929122, -3.2227587e-07, 0.11267061, 0.99121344)
bones/22/position = Vector3(-0.0045358227, -0.002137695, -0.004238624)
bones/22/rotation = Quaternion(0.24948503, 0.19590352, 0.9416978, 0.11217985)
bones/23/rotation = Quaternion(-0.58943665, 0.3660725, 0.22564478, 0.683842)
bones/23/scale = Vector3(1, 1.0000001, 1)
bones/24/rotation = Quaternion(0.529102, -0.15997836, -0.120245025, 0.82462066)
bones/24/scale = Vector3(1, 1.0000001, 1)
[editable path="bull"]