5c1e881a53
Bull charge damage: - Tunnel-proof swept detection (prev→now segment) for matador gore and bear ram, so a 66 m/s dash no longer skips clean over the thin HitArea. - Horns-first cone (bull_gore_arc): running into an enemy only wounds it when charging roughly at it, not a sideways brush. - Dash is a committed lunge: bypasses the horns cone AND the survival roll, so a dash connect is a guaranteed kill. Cruise charges keep a speed-scaled roll (mat_charge_pierce). Roll ability defers to its own pop (is_rolling guard). Bear: - Horns-first swept ram detection (edge-triggered: one charge = one wound); a tunnelling or point-blank charge now lands where body_entered wouldn't. - Overhead smash lane extends faster (0.1s) and further (20 m). - Leap slam launches the bull from anywhere in the circle (ring_frac 0). - Removed stray-quote syntax errors that broke the script. Matador: - Collider-driven stab (BladeHitbox) with recovery beat + taunt-after-hit so it stops machine-gunning the bull. - A taunting matador answers a charge: spear from range, sword up close. Spear projectile masks the BULL layer so throws connect. Tests: matador_charge/stab/spear/taunt_react, bear_ram/hitbox/fx (+ unified _fake_bull stand-in). Trimmed verbose comments. Note: bear claw-emitter/death work is still WIP (3 bear_boss_test failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
254 lines
9.7 KiB
GDScript
254 lines
9.7 KiB
GDScript
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://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"):
|
|
bull.call(&"take_sword_hit", "thrown", contact)
|
|
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
|