diff --git a/CLAUDE.md b/CLAUDE.md index afe93d3..a6cf9e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,9 @@ art/ all raw art, grouped by role (snake_case): | `levels/arena_level.tscn` | The arena **level module**: floor + wall colliders, arena meshes, `MatadorSpawner` (with child `Node3D` spawn points placed by the designer), lighting, audience (`PublikNode`). Instanced into the shell's `LevelRoot` | | `levels/bear_level.tscn` | The Bear's Den **level module**: bear-arena mesh, its own floor collider, lighting, and a `BearSpawner` (`bear_spawn.gd`) that places the bear at center. Instanced into the shell's `LevelRoot` | | `actors/player/player.tscn` | Player scene root (`CharacterBody3D`) | -| `actors/player/player.gd` | Movement, charge, turning logic | +| `actors/player/player.gd` | Movement, charge, turning, and abilities (kick/dash/slam/roll). Ability & movement particle FX are **not** built here — they're authored scenes in `systems/fx/` (`bull_*.tscn`), fired via `Fx.spawn()` (one-shots) / `Fx.attach()` (continuous) | +| `actors/player/bull_impact_fx.gd` | Bull's coded, non-particle impact FX (the slam shockwave rings + kick flash-pop — tweened meshes, not CPUParticles3D, so they can't be `systems/fx/` scenes). Created by `player.gd` | +| `actors/player/bull_legs.gd` | Bull body-wobble + tail Verlet-chain simulation. Created by `player.gd` | | `camera_spring_arm.gd` | Mouse-look pivot (`Node3D` + `SpringArm3D`) | | `camera_follow.gd` | Smooth camera follow (`Camera3D`) | | `actors/matador/matador.gd` | Matador AI (wander / ragdoll state machine) | diff --git a/actors/bear/bear.gd b/actors/bear/bear.gd index 65a7fe4..2149731 100644 --- a/actors/bear/bear.gd +++ b/actors/bear/bear.gd @@ -26,11 +26,11 @@ var _phase: Phase = Phase.WINDUP var _bull: CharacterBody3D = null var _skeleton: Skeleton3D = null var _anim_player: AnimationPlayer = null -# Separate player baked into Assets/fx/Bear_FX.glb: each clip scales one FX mesh up from 0 +# Separate player baked into art/fx/bear_fx.glb: each clip scales one FX mesh up from 0 # and back at its strike frame (self-hiding), so bear.gd just fires the matching clip. var _fx_player: AnimationPlayer = null var _fx_root: Node = null -# Shaped attack colliders live in BearAttackHitboxes.tscn (under bear_model, so they track the +# Shaped attack colliders live in bear_attack_hitboxes.tscn (under bear_model, so they track the # bear's facing): a ground-circle cylinder for the leap slam and a forward box lane for the smash. # Their sizes AND their travel/pop motion are authored in that scene's AnimationPlayer (clips # "leap_slam" / "smash_travel") — scrub them there to tweak. bear.gd only fires the clip, sets the @@ -635,7 +635,7 @@ func _try_hit(reach: float) -> bool: return true -# Grab the designer-authored collider sub-scene (BearAttackHitboxes.tscn instanced under +# Grab the designer-authored collider sub-scene (bear_attack_hitboxes.tscn instanced under # bear_model) and park both hitboxes deactivated. Shapes + travel are authored in that scene. func _setup_attack_hitboxes() -> void: var root := _mesh.get_node_or_null("AttackHitboxes") if _mesh else null @@ -920,18 +920,6 @@ func _die(hit_dir: Vector3) -> void: velocity = Vector3.ZERO _blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir) _play(_anim_death) - # No death clip on the rig — topple the beast onto its side (about the hit direction, - # so it falls the way it was struck) and sink it away, then free. - #var fall_axis := Vector3(-hit_dir.z, 0.0, hit_dir.x) - #if fall_axis.length() < 0.01: - # fall_axis = Vector3.RIGHT - #var tween := create_tween() - #tween.tween_property(_mesh, "rotation", - # _mesh.rotation + fall_axis.normalized() * deg_to_rad(88.0), 0.7)\ - # .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) - #tween.tween_interval(2.5) - #tween.tween_property(_mesh, "position:y", _mesh.position.y - 3.0, 1.0) - #tween.tween_callback(queue_free) # ── Interface shared with the matador (guarded elsewhere via has_method) ───────────── diff --git a/actors/matador/matador.gd b/actors/matador/matador.gd index 6aed08e..1987e85 100644 --- a/actors/matador/matador.gd +++ b/actors/matador/matador.gd @@ -74,7 +74,7 @@ const _ANIM_TAUNTS: Array[StringName] = [&"Taunt", &"Taunt_B", &"Taunt_C"] # reparents from holster to hand at this point in the animation. const _DRAW_GRAB_AT: float = 0.55 -# Wrapper scenes (glb + an editor-tweakable hitbox / collider) — see Sword.tscn / Spear.tscn. +# Wrapper scenes (glb + an editor-tweakable hitbox / collider) — see sword.tscn / spear.tscn. const _SWORD_SCENE: PackedScene = preload("res://actors/matador/sword.tscn") const _SPEAR_SCENE: PackedScene = preload("res://actors/matador/spear.tscn") @@ -603,7 +603,7 @@ func _tick_attack(delta: float) -> void: if _swing_timer > 0.0: _decelerate(22.0, delta) _play_anim(_ANIM_ATTACK) - # Damage off the sword's own BladeHitbox (Sword.tscn), riding the animated blade — gored only + # Damage off the sword's own BladeHitbox (sword.tscn), riding the animated blade — gored only # when the thrust reaches it. Arm at the thrust (mat_stab_strike_at) and sweep for the overlap. if _sword_blade_area != null and _swing_phase() >= DP.f("mat_stab_strike_at"): _sword_blade_area.activate() @@ -1189,7 +1189,7 @@ func _spawn_sword() -> void: _drawing = false _apply_sword_grip() - # The blade hitbox (Sword.tscn, hitbox.gd) is armed only during the ATTACK thrust (see + # The blade hitbox (sword.tscn, hitbox.gd) is armed only during the ATTACK thrust (see # _tick_attack); off while merely carried, so brushing a drawn matador never gores the bull. _sword_blade_area = _sword_node.get_node_or_null("BladeHitbox") as Hitbox if _sword_blade_area != null: diff --git a/actors/player/bull_impact_fx.gd b/actors/player/bull_impact_fx.gd new file mode 100644 index 0000000..c3a1a67 --- /dev/null +++ b/actors/player/bull_impact_fx.gd @@ -0,0 +1,110 @@ +extends Node +## Procedural (non-particle) bull impact FX: the expanding ground-slam shockwave rings and the +## kick flash-pop. These are tween-animated MeshInstance3D flashes, not CPUParticles3D, so they +## can't be authored as systems/fx/ scenes and live in code here. Created by player.gd; the +## transient meshes are parented into the level (the bull's parent) so they hold their world +## position while the bull keeps moving. + +var _player: Node3D +var _ring_mesh: ArrayMesh = null + + +func setup(player: Node3D) -> void: + _player = player + + +# Three nested shockwave rings racing outward from a ground slam, each fading as it grows. +func slam_rings(origin: Vector3, max_r: float) -> void: + if _ring_mesh == null: + _ring_mesh = _make_ring_mesh() + _launch_ring(origin, max_r, 0.00, 0.55, Color(1.0, 0.70, 0.10, 0.85)) + _launch_ring(origin, max_r, 0.10, 0.52, Color(1.0, 0.50, 0.10, 0.65)) + _launch_ring(origin, max_r, 0.20, 0.48, Color(0.9, 0.30, 0.10, 0.50)) + + +# A quick bright flash-pop at the kick's contact point. +func kick_flash(origin: Vector3) -> void: + var mat := _flat_material(Color(1.0, 0.97, 0.40, 0.0)) + var sphere := SphereMesh.new() + sphere.radius = 1.0 + sphere.height = 2.0 + sphere.radial_segments = 8 + sphere.rings = 4 + sphere.material = mat + + var mi := MeshInstance3D.new() + mi.mesh = sphere + mi.material_override = mat + mi.scale = Vector3(0.05, 0.05, 0.05) + _host().add_child(mi) + mi.global_position = origin + + var tween := create_tween() + tween.set_parallel(true) + tween.tween_property(mi, "scale", Vector3(0.7, 0.7, 0.7), 0.13)\ + .set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT) + var fade := func(a: float) -> void: mat.albedo_color.a = a + tween.tween_method(fade, 0.92, 0.0, 0.14)\ + .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN) + tween.tween_callback(mi.queue_free).set_delay(0.16) + + +func _launch_ring(origin: Vector3, max_r: float, delay: float, duration: float, col: Color) -> void: + var mat := _flat_material(Color(col.r, col.g, col.b, 0.0)) + var mi := MeshInstance3D.new() + mi.mesh = _ring_mesh + mi.material_override = mat + mi.scale = Vector3(0.02, 1.0, 0.02) + _host().add_child(mi) + mi.global_position = origin + + var tween := create_tween() + tween.set_parallel(true) + tween.tween_property(mi, "scale", Vector3(max_r, 1.0, max_r), duration)\ + .set_delay(delay).set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT) + var fade := func(a: float) -> void: mat.albedo_color.a = a + tween.tween_method(fade, col.a, 0.0, duration)\ + .set_delay(delay).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN) + tween.tween_callback(mi.queue_free).set_delay(delay + duration + 0.05) + + +func _flat_material(color: Color) -> StandardMaterial3D: + var mat := StandardMaterial3D.new() + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + mat.albedo_color = color + return mat + + +# A flat annulus in the XZ plane (unit radius), scaled up at runtime into the shockwave ring. +func _make_ring_mesh() -> ArrayMesh: + const INNER_R: float = 0.82 + const OUTER_R: float = 1.00 + const SEGMENTS: int = 48 + + var verts := PackedVector3Array() + var indices := PackedInt32Array() + for i: int in SEGMENTS: + var a0 := i / float(SEGMENTS) * TAU + var a1 := (i + 1) / float(SEGMENTS) * TAU + var vi := i * 4 + verts.append(Vector3(cos(a0) * INNER_R, 0.0, sin(a0) * INNER_R)) + verts.append(Vector3(cos(a0) * OUTER_R, 0.0, sin(a0) * OUTER_R)) + verts.append(Vector3(cos(a1) * OUTER_R, 0.0, sin(a1) * OUTER_R)) + verts.append(Vector3(cos(a1) * INNER_R, 0.0, sin(a1) * INNER_R)) + indices.append_array([vi, vi + 2, vi + 1, vi, vi + 3, vi + 2]) + + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = verts + arrays[Mesh.ARRAY_INDEX] = indices + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + return mesh + + +func _host() -> Node: + if _player != null and _player.get_parent() != null: + return _player.get_parent() + return _player diff --git a/actors/player/bull_impact_fx.gd.uid b/actors/player/bull_impact_fx.gd.uid new file mode 100644 index 0000000..3d7fb21 --- /dev/null +++ b/actors/player/bull_impact_fx.gd.uid @@ -0,0 +1 @@ +uid://sf5etma4kjt0 diff --git a/actors/player/player.gd b/actors/player/player.gd index 4319fa7..d863274 100644 --- a/actors/player/player.gd +++ b/actors/player/player.gd @@ -8,7 +8,7 @@ const HOOF_OFFSETS: Array = [ Vector3( 0.30, -0.25, 0.60), ] -# Bull animation clips (Armature|* action names in Assets/bull.fbx). IDLE is the +# 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" @@ -27,12 +27,11 @@ var _legs: Node var _was_on_floor: bool = false var _pre_slide_vel_y: float = 0.0 -var _slam_ring_mesh: ArrayMesh = null -# Attack-FX resources are identical every swing, so build them once and reuse. Recreating a fresh -# StandardMaterial3D / SphereMesh per attack makes a mobile WebGL driver recompile the shader and -# re-upload the mesh each time — a main-thread hitch "when attacking" that never shows on desktop. +# 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] @@ -83,8 +82,12 @@ var _huff_timer: float = 0.0 enum DustState { NONE, WALK, CHARGE } var _dust_state: DustState = DustState.NONE -var _walk_dust_ramp: Gradient = null -var _charge_dust_ramp: Gradient = null +# 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") @@ -106,6 +109,7 @@ func _ready() -> void: _max_hp += _bonus_hp _hp += _bonus_hp _cube_scale = cube_guy.scale + _setup_impact_fx() _setup_hoof_dust() _setup_legs() _setup_charge_trail() @@ -113,7 +117,6 @@ func _ready() -> void: _setup_audio() _bull_anim = _find_anim_player(cube_guy) _setup_bull_idle() - DP.any_changed.connect(_on_dp_changed) func _setup_legs() -> void: @@ -123,113 +126,35 @@ func _setup_legs() -> void: _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: - var sphere := _particle_sphere(0.07, 4, 2, _unshaded_material()) - - _walk_dust_ramp = Gradient.new() - _walk_dust_ramp.set_color(0, Color(0.94, 0.90, 0.83, 1.0)) - _walk_dust_ramp.set_color(1, Color(0.94, 0.90, 0.83, 0.0)) - - # White-beige dust while running - _charge_dust_ramp = Gradient.new() - _charge_dust_ramp.set_color(0, Color(0.94, 0.90, 0.83, 1.0)) - _charge_dust_ramp.set_color(1, Color(0.94, 0.90, 0.83, 0.0)) - - var scale_curve := Curve.new() - scale_curve.add_point(Vector2(0.0, 0.2)) - scale_curve.add_point(Vector2(0.4, 1.0)) - scale_curve.add_point(Vector2(1.0, 0.0)) - for offset: Vector3 in HOOF_OFFSETS: - var p := CPUParticles3D.new() + var p := Fx.attach(&"bull_hoof_dust", cube_guy) as CPUParticles3D + if p == null: + continue p.position = offset - p.emitting = false - p.amount = 12 - p.lifetime = DP.f("dust_lifetime") - p.explosiveness = DP.f("dust_explosiveness") - p.randomness = 0.6 - p.local_coords = false - p.direction = Vector3(0.0, 1.0, 0.0) - p.spread = DP.f("dust_spread") - p.gravity = Vector3(0.0, DP.f("dust_gravity_y"), 0.0) - p.initial_velocity_min = DP.f("walk_vel_min") - p.initial_velocity_max = DP.f("walk_vel_max") - p.scale_amount_min = DP.f("walk_scale_min") - p.scale_amount_max = DP.f("walk_scale_max") - p.mesh = sphere - p.color_ramp = _walk_dust_ramp - p.scale_amount_curve = scale_curve - cube_guy.add_child(p) _hoof_emitters.append(p) func _setup_charge_trail() -> void: - # Blue energy trail — streams backward during a fast run / roll - var trail_sphere := _particle_sphere(0.065, 4, 2, _unshaded_material()) - - var trail_ramp := Gradient.new() - trail_ramp.set_color(0, Color(0.30, 0.65, 1.0, 1.0)) - trail_ramp.set_color(1, Color(0.30, 0.65, 1.0, 0.0)) - - var trail_scale := Curve.new() - trail_scale.add_point(Vector2(0.0, 1.0)) - trail_scale.add_point(Vector2(0.6, 0.8)) - trail_scale.add_point(Vector2(1.0, 0.0)) - - _charge_trail_emitter = CPUParticles3D.new() - _charge_trail_emitter.position = Vector3(0.0, 0.25, 0.3) - _charge_trail_emitter.emitting = false - _charge_trail_emitter.one_shot = false - _charge_trail_emitter.amount = 18 - _charge_trail_emitter.lifetime = 0.30 - _charge_trail_emitter.randomness = 0.4 - _charge_trail_emitter.local_coords = false - _charge_trail_emitter.direction = Vector3(0.0, 0.2, 1.0).normalized() - _charge_trail_emitter.spread = 25.0 - _charge_trail_emitter.gravity = Vector3(0.0, 2.0, 0.0) - _charge_trail_emitter.initial_velocity_min = 4.0 - _charge_trail_emitter.initial_velocity_max = 9.0 - _charge_trail_emitter.scale_amount_min = 0.4 - _charge_trail_emitter.scale_amount_max = 1.2 - _charge_trail_emitter.mesh = trail_sphere - _charge_trail_emitter.color_ramp = trail_ramp - _charge_trail_emitter.scale_amount_curve = trail_scale - cube_guy.add_child(_charge_trail_emitter) + _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: - # Rammus Powerball ground spray — dirt/sparks flung outward from the spinning - # ball. World-space (local_coords off) so they peel off into a trail behind it. - var spark := _particle_sphere(0.05, 4, 2, _unshaded_material()) - - var ramp := Gradient.new() - ramp.set_color(0, Color(1.00, 0.78, 0.32, 1.0)) - ramp.set_color(1, Color(0.70, 0.42, 0.14, 0.0)) - - var sc := Curve.new() - sc.add_point(Vector2(0.0, 0.30)) - sc.add_point(Vector2(0.3, 1.00)) - sc.add_point(Vector2(1.0, 0.00)) - - _roll_spark_emitter = CPUParticles3D.new() - _roll_spark_emitter.position = Vector3(0.0, 0.10, 0.0) - _roll_spark_emitter.emitting = false - _roll_spark_emitter.one_shot = false - _roll_spark_emitter.amount = 46 - _roll_spark_emitter.lifetime = 0.45 - _roll_spark_emitter.randomness = 0.7 - _roll_spark_emitter.local_coords = false - _roll_spark_emitter.direction = Vector3(0.0, 1.0, 0.0) - _roll_spark_emitter.spread = 180.0 - _roll_spark_emitter.gravity = Vector3(0.0, -6.0, 0.0) - _roll_spark_emitter.initial_velocity_min = 3.0 - _roll_spark_emitter.initial_velocity_max = 8.0 - _roll_spark_emitter.scale_amount_min = 0.5 - _roll_spark_emitter.scale_amount_max = 1.3 - _roll_spark_emitter.mesh = spark - _roll_spark_emitter.color_ramp = ramp - _roll_spark_emitter.scale_amount_curve = sc - cube_guy.add_child(_roll_spark_emitter) + _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: @@ -251,42 +176,20 @@ func _setup_audio() -> void: 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.lifetime = DP.f("dust_lifetime") - p.explosiveness = DP.f("dust_explosiveness") - p.spread = DP.f("dust_spread") - p.gravity = Vector3(0.0, DP.f("dust_gravity_y"), 0.0) - match new_state: - 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") - p.initial_velocity_max = DP.f("charge_vel_max") - p.emitting = true - # Blue energy trail belongs to the roll/dash ability only, not to fast WASD - # running — driven explicitly in the roll loop / _end_roll, not by dust state. - - -func _on_dp_changed(_key: String, _val: Variant) -> void: - if _hoof_emitters.is_empty() or _dust_state == DustState.NONE: - return - var prev := _dust_state - _dust_state = DustState.NONE - _set_dust_state(prev) + 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: @@ -681,13 +584,10 @@ func _hit_matadors_radius(range_m: float, strength: float, up_boost: float = 0.0 mat.call(&"apply_ability_hit", (away + Vector3.UP * 0.6).normalized(), strength, up_boost) -# ── Particle helpers ────────────────────────────────────────────────────────── -# All FX in this script use unshaded, vertex-coloured, alpha-blended particles; -# these three helpers remove the boilerplate each spawn function used to repeat. - -# One shared unshaded material for every particle FX (per-particle colour comes from the -# CPUParticles3D colour_ramp via vertex colour, not the material) — built once so the mobile -# driver compiles this shader a single time instead of on every attack. +# ── 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() @@ -698,8 +598,7 @@ func _unshaded_material() -> StandardMaterial3D: return _unshaded_mat -# Low-poly sphere sized for a particle mesh (height is always the diameter). Cached per size so -# repeat attacks reuse the same mesh (one GPU upload) instead of generating + uploading a new one. +# 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) @@ -724,281 +623,31 @@ func _free_after(node: Node, delay: float) -> void: func _spawn_slam_fx() -> void: - if not _slam_ring_mesh: - _slam_ring_mesh = _make_slam_ring_mesh() - var max_r := DP.f("slam_range") var origin := global_position + Vector3(0.0, -0.5, 0.0) - _launch_slam_ring(origin, max_r, 0.00, 0.55, Color(1.00, 0.70, 0.10, 0.85)) - _launch_slam_ring(origin, max_r, 0.10, 0.52, Color(1.00, 0.50, 0.10, 0.65)) - _launch_slam_ring(origin, max_r, 0.20, 0.48, Color(0.90, 0.30, 0.10, 0.50)) - _spawn_slam_dust(origin) + _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 _launch_slam_ring(origin: Vector3, max_r: float, - delay: float, duration: float, col: Color) -> void: - var mat := StandardMaterial3D.new() - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.albedo_color = Color(col.r, col.g, col.b, 0.0) - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - - var mi := MeshInstance3D.new() - mi.mesh = _slam_ring_mesh - mi.material_override = mat - mi.scale = Vector3(0.02, 1.0, 0.02) - get_parent().add_child(mi) - mi.global_position = origin - - var tween := create_tween() - tween.set_parallel(true) - tween.tween_property(mi, "scale", - Vector3(max_r, 1.0, max_r), duration)\ - .set_delay(delay)\ - .set_trans(Tween.TRANS_EXPO)\ - .set_ease(Tween.EASE_OUT) - var fade := func(a: float) -> void: mat.albedo_color.a = a - tween.tween_method(fade, col.a, 0.0, duration)\ - .set_delay(delay)\ - .set_trans(Tween.TRANS_SINE)\ - .set_ease(Tween.EASE_IN) - tween.tween_callback(mi.queue_free).set_delay(delay + duration + 0.05) - - -func _spawn_slam_dust(origin: Vector3) -> void: - var sphere := _particle_sphere(0.09, 4, 2, _unshaded_material()) - - var ramp := Gradient.new() - ramp.set_color(0, Color(0.88, 0.74, 0.46, 1.0)) - ramp.set_color(1, Color(0.88, 0.74, 0.46, 0.0)) - - var scale_curve := Curve.new() - scale_curve.add_point(Vector2(0.0, 0.25)) - scale_curve.add_point(Vector2(0.35, 1.6)) - scale_curve.add_point(Vector2(1.00, 0.0)) - - var p := CPUParticles3D.new() - p.one_shot = true - p.explosiveness = 0.85 - p.amount = 48 - p.lifetime = 1.1 - p.randomness = 0.5 - p.local_coords = false - p.direction = Vector3.UP - p.spread = 85.0 - p.gravity = Vector3(0.0, -7.0, 0.0) - p.initial_velocity_min = 3.5 - p.initial_velocity_max = 10.0 - p.scale_amount_min = 0.5 - p.scale_amount_max = 2.0 - p.mesh = sphere - p.color_ramp = ramp - p.scale_amount_curve = scale_curve - get_parent().add_child(p) - p.global_position = origin - p.restart() - _free_after(p, 2.5) - - func _spawn_kick_fx(back_dir: Vector3) -> void: var origin := global_position + back_dir * 0.55 + Vector3(0.0, -0.25, 0.0) - _spawn_kick_dust(origin, back_dir) - _spawn_kick_stones(origin, back_dir) - _spawn_kick_sparks(origin, back_dir) - _spawn_kick_flash(origin) + Fx.spawn(&"bull_kick", _aimed_xform(origin, back_dir)) + _impact_fx.call(&"kick_flash", origin) camera_pivot.call(&"trigger_hit", 0.35) -func _spawn_kick_dust(origin: Vector3, back_dir: Vector3) -> void: - var sphere := _particle_sphere(0.12, 4, 2, _unshaded_material()) - - var ramp := Gradient.new() - 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)) - scale_curve.add_point(Vector2(0.3, 1.5)) - scale_curve.add_point(Vector2(1.0, 0.0)) - - var p := CPUParticles3D.new() - p.one_shot = true - p.explosiveness = 0.80 - p.amount = 40 - p.lifetime = 1.2 - p.randomness = 0.5 - p.local_coords = false - p.direction = (back_dir + Vector3(0.0, 0.7, 0.0)).normalized() - p.spread = 65.0 - p.gravity = Vector3(0.0, -6.0, 0.0) - p.initial_velocity_min = 3.0 - p.initial_velocity_max = 9.0 - p.scale_amount_min = 0.5 - p.scale_amount_max = 2.2 - p.mesh = sphere - p.color_ramp = ramp - p.scale_amount_curve = scale_curve - get_parent().add_child(p) - p.global_position = origin - p.restart() - _free_after(p, 2.5) - - -func _spawn_kick_stones(origin: Vector3, back_dir: Vector3) -> void: - var sphere := _particle_sphere(0.055, 3, 1, _unshaded_material()) - - var ramp := Gradient.new() - ramp.set_color(0, Color(0.62, 0.58, 0.52, 1.0)) - ramp.set_color(1, Color(0.62, 0.58, 0.52, 0.0)) - - var scale_curve := Curve.new() - scale_curve.add_point(Vector2(0.0, 1.0)) - scale_curve.add_point(Vector2(0.6, 1.0)) - scale_curve.add_point(Vector2(1.0, 0.0)) - - var p := CPUParticles3D.new() - p.one_shot = true - p.explosiveness = 0.92 - p.amount = 14 - p.lifetime = 0.65 - p.randomness = 0.6 - p.local_coords = false - p.direction = (back_dir + Vector3(0.0, 0.35, 0.0)).normalized() - p.spread = 45.0 - p.gravity = Vector3(0.0, -9.8, 0.0) - p.initial_velocity_min = 5.0 - p.initial_velocity_max = 14.0 - p.scale_amount_min = 0.5 - p.scale_amount_max = 1.8 - p.mesh = sphere - p.color_ramp = ramp - p.scale_amount_curve = scale_curve - get_parent().add_child(p) - p.global_position = origin - p.restart() - _free_after(p, 2.0) - - -func _spawn_kick_flash(origin: Vector3) -> void: - var mat := _unshaded_material() - mat.albedo_color = Color(1.0, 0.97, 0.40, 0.0) - - var sphere := _particle_sphere(1.0, 8, 4, mat) - - var mi := MeshInstance3D.new() - mi.mesh = sphere - mi.material_override = mat - mi.scale = Vector3(0.05, 0.05, 0.05) - get_parent().add_child(mi) - mi.global_position = origin - - var tween := create_tween() - tween.set_parallel(true) - tween.tween_property(mi, "scale", Vector3(0.7, 0.7, 0.7), 0.13)\ - .set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT) - var fade := func(a: float) -> void: mat.albedo_color.a = a - tween.tween_method(fade, 0.92, 0.0, 0.14)\ - .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN) - tween.tween_callback(mi.queue_free).set_delay(0.16) - - func _spawn_dash_burst() -> void: - var box := BoxMesh.new() - box.size = Vector3(0.045, 0.045, 0.30) - box.material = _unshaded_material() - - var ramp := Gradient.new() - ramp.set_color(0, Color(0.50, 0.82, 1.0, 1.0)) - ramp.set_color(1, Color(0.50, 0.82, 1.0, 0.0)) - - var scale_curve := Curve.new() - scale_curve.add_point(Vector2(0.0, 1.0)) - scale_curve.add_point(Vector2(1.0, 0.0)) - - var p := CPUParticles3D.new() - p.one_shot = true - p.explosiveness = 0.92 - p.amount = 18 - p.lifetime = 0.22 - p.randomness = 0.15 - p.local_coords = false - p.direction = _dash_dir - p.spread = 15.0 - p.gravity = Vector3.ZERO - p.initial_velocity_min = 18.0 - p.initial_velocity_max = 35.0 - p.scale_amount_min = 0.8 - p.scale_amount_max = 2.8 - p.mesh = box - p.color_ramp = ramp - p.scale_amount_curve = scale_curve - get_parent().add_child(p) - p.global_position = global_position + Vector3(0.0, 0.2, 0.0) - p.restart() - _free_after(p, 0.6) + Fx.spawn(&"bull_dash", _aimed_xform(global_position + Vector3(0.0, 0.2, 0.0), _dash_dir)) -func _spawn_kick_sparks(origin: Vector3, back_dir: Vector3) -> void: - var sphere := _particle_sphere(0.04, 3, 1, _unshaded_material()) - - var ramp := Gradient.new() - ramp.set_color(0, Color(1.0, 0.98, 0.60, 1.0)) - ramp.set_color(1, Color(1.0, 1.0, 1.0, 0.0)) - - var scale_curve := Curve.new() - scale_curve.add_point(Vector2(0.0, 1.0)) - scale_curve.add_point(Vector2(0.5, 0.6)) - scale_curve.add_point(Vector2(1.0, 0.0)) - - var p := CPUParticles3D.new() - p.one_shot = true - p.explosiveness = 0.95 - p.amount = 22 - p.lifetime = 0.28 - p.randomness = 0.4 - p.local_coords = false - p.direction = (back_dir + Vector3(0.0, 0.5, 0.0)).normalized() - p.spread = 30.0 - p.gravity = Vector3(0.0, -14.0, 0.0) - p.initial_velocity_min = 8.0 - p.initial_velocity_max = 18.0 - p.scale_amount_min = 0.6 - p.scale_amount_max = 2.0 - p.mesh = sphere - p.color_ramp = ramp - p.scale_amount_curve = scale_curve - get_parent().add_child(p) - p.global_position = origin - p.restart() - _free_after(p, 1.0) - - -func _make_slam_ring_mesh() -> ArrayMesh: - const INNER_R: float = 0.82 - const OUTER_R: float = 1.00 - const SEGMENTS: int = 48 - - var verts := PackedVector3Array() - var indices := PackedInt32Array() - - for i: int in SEGMENTS: - var a0 := i / float(SEGMENTS) * TAU - var a1 := (i + 1) / float(SEGMENTS) * TAU - var vi := i * 4 - verts.append(Vector3(cos(a0) * INNER_R, 0.0, sin(a0) * INNER_R)) - verts.append(Vector3(cos(a0) * OUTER_R, 0.0, sin(a0) * OUTER_R)) - verts.append(Vector3(cos(a1) * OUTER_R, 0.0, sin(a1) * OUTER_R)) - verts.append(Vector3(cos(a1) * INNER_R, 0.0, sin(a1) * INNER_R)) - indices.append_array([vi, vi + 2, vi + 1, vi, vi + 3, vi + 2]) - - var arrays: Array = [] - arrays.resize(Mesh.ARRAY_MAX) - arrays[Mesh.ARRAY_VERTEX] = verts - arrays[Mesh.ARRAY_INDEX] = indices - - var mesh := ArrayMesh.new() - mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) - return mesh +# 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: @@ -1112,23 +761,14 @@ func _physics_process(delta: float) -> void: velocity.z = flat_n.z * exit_spd pre_wall_vel = Vector3(velocity.x, 0.0, velocity.z) - # --- ADD THIS BREAKABLE BARREL DETECTION CODE --- - var collision = get_slide_collision(i) - var collider = collision.get_collider() - - # Check if the object we hit has the "destroy_barrel" function - if collider and collider.has_method("destroy_barrel"): - # Optional: Only break if moving fast (e.g., sprinting or dashing) - # if velocity.length() > 5.0: - collider.destroy_barrel() + # 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 - #Bouncing landing below - #var impact := -_pre_slide_vel_y - #if impact > DP.f("bounce_min_impact") and not _slam_pending: - #velocity.y = impact * DP.f("bounce_restitution") # ── Slam landing ────────────────────────────────────────────────────────── if _slam_pending and is_on_floor() and not _was_on_floor: @@ -1157,8 +797,6 @@ func _physics_process(delta: float) -> void: if vel_flat.length_squared() > 0.1: _charge_trail_emitter.direction = (-vel_flat.normalized() + Vector3(0.0, 0.25, 0.0)).normalized() - # ── Hoof sounds ─────────────────────────────────────────────────────────── - func get_hp() -> int: return _hp @@ -1274,7 +912,6 @@ func _spawn_blood(hit_pos: Vector3) -> void: _free_after(p, 1.5) -#hit flashing func _hit_flash() -> void: var meshes := find_children("*", "MeshInstance3D", true, false) diff --git a/systems/debug/debug_params.gd b/systems/debug/debug_params.gd index 9b234af..9a17625 100644 --- a/systems/debug/debug_params.gd +++ b/systems/debug/debug_params.gd @@ -46,21 +46,10 @@ func _register_all() -> void: # (The dash ability bypasses this.) _reg_f("Bull", "bull_gore_arc", 75.0, 10.0, 180.0, 5.0) # ── Dust speed thresholds ───────────────────────────────────────────────── + # The dust LOOK (colour, size, velocity) lives in systems/fx/bull_hoof_dust.tscn now; these + # two only decide when walk vs charge dust kicks in by ground speed. _reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0) _reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.0) - # ── Dust ────────────────────────────────────────────────────────────────── - _reg_f("Dust", "dust_lifetime", 0.9, 0.1, 4.0) - _reg_f("Dust", "dust_explosiveness", 0.35, 0.0, 1.0) - _reg_f("Dust", "dust_spread", 45.0, 5.0, 90.0) - _reg_f("Dust", "dust_gravity_y", -1.5,-15.0, 0.0) - _reg_f("Dust", "walk_scale_min", 0.20, 0.05, 3.0) - _reg_f("Dust", "walk_scale_max", 0.40, 0.05, 3.0) - _reg_f("Dust", "walk_vel_min", 0.8, 0.1, 12.0) - _reg_f("Dust", "walk_vel_max", 1.8, 0.1, 12.0) - _reg_f("Dust", "charge_scale_min", 0.35, 0.05, 6.0) - _reg_f("Dust", "charge_scale_max", 0.70, 0.05, 6.0) - _reg_f("Dust", "charge_vel_min", 2.8, 0.1, 20.0) - _reg_f("Dust", "charge_vel_max", 5.5, 0.1, 20.0) # ── Tail ────────────────────────────────────────────────────────────────── _reg_f("Tail", "tail_gravity", 9.8, 0.0, 30.0) _reg_f("Tail", "tail_damping", 0.08, 0.0, 0.5, 0.01) diff --git a/systems/fx/README.md b/systems/fx/README.md index 449a416..604a094 100644 --- a/systems/fx/README.md +++ b/systems/fx/README.md @@ -6,8 +6,9 @@ change to how a hit *feels* is a `.tscn` Siim edits in the inspector — no code ```gdscript # anywhere in gameplay code -Fx.spawn(&"blood_burst", global_transform) # at a node +Fx.spawn(&"blood_burst", global_transform) # one-shot: fire and forget (self-frees) Fx.spawn(&"slam_dust", Transform3D(Basis(), point)) # at a world point +var trail := Fx.attach(&"bull_charge_trail", node) # continuous: caller keeps it + toggles .emitting ``` ## Siim's workflow @@ -25,6 +26,33 @@ Fx.spawn(&"slam_dust", Transform3D(Basis(), point)) # at a world point New effect? Add a `fx/.tscn` (root = `CPUParticles3D` with `one_shot_effect.gd` attached — copy an existing one), then register its name → path in `fx/fx.gd`. +## Bull ability effects + +The bull's moves are wired to effect scenes here, so **you can retune every one in the inspector** — +open the `.tscn`, drag its colour ramp / curves / emission, save. No code. + +| Ability | Effect scene(s) | Kind | +|---|---|---| +| Ground slam | `bull_slam.tscn` (dust) | one-shot burst | +| Back-kick | `bull_kick.tscn` — dust **+** its `stones` and `sparks` child emitters | one-shot burst | +| Dash / roll launch | `bull_dash.tscn` | one-shot burst | +| Hoof dust (walking / sprinting) | `bull_hoof_dust.tscn` | continuous | +| Charge trail (the blue streak) | `bull_charge_trail.tscn` | continuous | +| Roll sparks | `bull_roll_sparks.tscn` | continuous | + +**Two kinds, edited the same way.** *One-shot* effects fire once and clean themselves up — they show +up in the gallery, so preview and tune them there. *Continuous* effects follow the bull and switch on +and off with what it's doing (dust while moving, the trail during a roll); the gallery can't show +those, so preview one by opening its `.tscn` and ticking **Emitting** on in the inspector's viewport. + +Two things stay in code and aren't yours to tune here: the slam's expanding shockwave **rings** and the +kick's white **flash-pop** — those are stretched flat meshes, not particles (`actors/player/bull_impact_fx.gd`). +The dust's *walk-vs-sprint size* is also code (a fixed multiplier); its colour and shape are the `.tscn`. + +**Composite effect** (like the kick): one scene, several `CPUParticles3D` — a root plus child emitters. +`one_shot_effect.gd` fires every emitter in the scene together, so you can build a layered hit +(dust + debris + sparks) as one file and tune each layer separately. + ## House style (keep effects reading as one game) **Palette** — pull from these, don't invent per-effect colours: @@ -52,8 +80,9 @@ the inspector and safe on-device. Keep this constraint when adding effects. | File | What it is | |---|---| -| `fx.gd` | The `Fx` autoload — `spawn(name, xform, parent=null)`; name→path registry | -| `one_shot_effect.gd` | Root helper: fires the emitter(s) once, frees the scene when done | +| `fx.gd` | The `Fx` autoload — `spawn()` (one-shot) / `attach()` (continuous); name→path registry | +| `one_shot_effect.gd` | Root helper: fires every emitter in the scene once, frees it when done | | `blood_burst.tscn` etc. | The effects themselves — **edit these** | +| `bull_*.tscn` | The bull's ability effects (see the table above) — **edit these** | | `effect_gallery.tscn` | Preview stage (run it to review) | | `_seed_placeholders.gd` | One-off generator that wrote the first-draft defaults; not used at runtime | diff --git a/systems/fx/bull_charge_trail.tscn b/systems/fx/bull_charge_trail.tscn new file mode 100644 index 0000000..83a7100 --- /dev/null +++ b/systems/fx/bull_charge_trail.tscn @@ -0,0 +1,42 @@ +[gd_scene format=3] + +; Bull charge trail — the blue energy streak that streams off the bull during a roll/dash. +; Continuous: the player instances it once under the bull, toggles `emitting`, and steers +; `direction` opposite the travel direction each frame. Tune the look here; see README.md. + +[sub_resource type="StandardMaterial3D" id="mat"] +transparency = 1 +cull_mode = 2 +shading_mode = 0 +vertex_color_use_as_albedo = true + +[sub_resource type="SphereMesh" id="mesh"] +material = SubResource("mat") +radius = 0.065 +height = 0.13 +radial_segments = 4 +rings = 2 + +[sub_resource type="Curve" id="curve"] +_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(0.6, 0.8), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0] +point_count = 3 + +[sub_resource type="Gradient" id="grad"] +colors = PackedColorArray(0.3, 0.65, 1, 1, 0.3, 0.65, 1, 0) + +[node name="bull_charge_trail" type="CPUParticles3D"] +emitting = false +amount = 18 +lifetime = 0.3 +local_coords = false +randomness = 0.4 +mesh = SubResource("mesh") +direction = Vector3(0, 0.2, 1) +spread = 25.0 +gravity = Vector3(0, 2, 0) +initial_velocity_min = 4.0 +initial_velocity_max = 9.0 +scale_amount_min = 0.4 +scale_amount_max = 1.2 +scale_amount_curve = SubResource("curve") +color_ramp = SubResource("grad") diff --git a/systems/fx/bull_dash.tscn b/systems/fx/bull_dash.tscn new file mode 100644 index 0000000..5fc004e --- /dev/null +++ b/systems/fx/bull_dash.tscn @@ -0,0 +1,44 @@ +[gd_scene format=3] + +; Bull dash burst — the one-shot streak of speed shards left when a dash/roll launches. Fired +; via Fx.spawn(&"bull_dash", xform); the spawn transform aims local -Z along the dash direction, +; so the shards streak the right way. Tune the look here; see systems/fx/README.md. + +[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="oneshot"] + +[sub_resource type="StandardMaterial3D" id="mat"] +transparency = 1 +cull_mode = 2 +shading_mode = 0 +vertex_color_use_as_albedo = true + +[sub_resource type="BoxMesh" id="mesh"] +material = SubResource("mat") +size = Vector3(0.045, 0.045, 0.3) + +[sub_resource type="Curve" id="curve"] +_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0] +point_count = 2 + +[sub_resource type="Gradient" id="grad"] +colors = PackedColorArray(0.5, 0.82, 1, 1, 0.5, 0.82, 1, 0) + +[node name="bull_dash" type="CPUParticles3D"] +emitting = false +amount = 18 +lifetime = 0.22 +one_shot = true +local_coords = false +explosiveness = 0.92 +randomness = 0.15 +mesh = SubResource("mesh") +direction = Vector3(0, 0, -1) +spread = 15.0 +gravity = Vector3(0, 0, 0) +initial_velocity_min = 18.0 +initial_velocity_max = 35.0 +scale_amount_min = 0.8 +scale_amount_max = 2.8 +scale_amount_curve = SubResource("curve") +color_ramp = SubResource("grad") +script = ExtResource("oneshot") diff --git a/systems/fx/bull_hoof_dust.tscn b/systems/fx/bull_hoof_dust.tscn new file mode 100644 index 0000000..0933c5c --- /dev/null +++ b/systems/fx/bull_hoof_dust.tscn @@ -0,0 +1,43 @@ +[gd_scene format=3] + +; Bull hoof dust — the little kick-up under each hoof while moving. Continuous (not one-shot): +; the player instances one per hoof, positions it, and toggles `emitting`. Charge sprint bumps +; scale/velocity in code. Tune the look here; see systems/fx/README.md. + +[sub_resource type="StandardMaterial3D" id="mat"] +transparency = 1 +cull_mode = 2 +shading_mode = 0 +vertex_color_use_as_albedo = true + +[sub_resource type="SphereMesh" id="mesh"] +material = SubResource("mat") +radius = 0.07 +height = 0.14 +radial_segments = 4 +rings = 2 + +[sub_resource type="Curve" id="curve"] +_data = [Vector2(0, 0.2), 0.0, 0.0, 0, 0, Vector2(0.4, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0] +point_count = 3 + +[sub_resource type="Gradient" id="grad"] +colors = PackedColorArray(0.94, 0.9, 0.83, 1, 0.94, 0.9, 0.83, 0) + +[node name="bull_hoof_dust" type="CPUParticles3D"] +emitting = false +amount = 12 +lifetime = 0.9 +local_coords = false +explosiveness = 0.35 +randomness = 0.6 +mesh = SubResource("mesh") +direction = Vector3(0, 1, 0) +spread = 45.0 +gravity = Vector3(0, -1.5, 0) +initial_velocity_min = 0.8 +initial_velocity_max = 1.8 +scale_amount_min = 0.2 +scale_amount_max = 0.4 +scale_amount_curve = SubResource("curve") +color_ramp = SubResource("grad") diff --git a/systems/fx/bull_kick.tscn b/systems/fx/bull_kick.tscn new file mode 100644 index 0000000..e768243 --- /dev/null +++ b/systems/fx/bull_kick.tscn @@ -0,0 +1,115 @@ +[gd_scene format=3] + +; Bull back-kick impact — the one-shot combined dust + stones + sparks thrown behind the kick. +; A composite effect: the root emitter is the dust; the "stones" and "sparks" child emitters fire +; with it (one_shot_effect.gd arms every CPUParticles3D in the scene). Fired via +; Fx.spawn(&"bull_kick", xform); the spawn transform aims local -Z along the kick-back direction. +; The bright flash-pop is a separate coded effect (bull_impact_fx.gd). Tune all three looks here. + +[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="oneshot"] + +[sub_resource type="StandardMaterial3D" id="mat"] +transparency = 1 +cull_mode = 2 +shading_mode = 0 +vertex_color_use_as_albedo = true + +[sub_resource type="SphereMesh" id="dust_mesh"] +material = SubResource("mat") +radius = 0.12 +height = 0.24 +radial_segments = 4 +rings = 2 + +[sub_resource type="Curve" id="dust_curve"] +_data = [Vector2(0, 0.3), 0.0, 0.0, 0, 0, Vector2(0.3, 1.5), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0] +point_count = 3 + +[sub_resource type="Gradient" id="dust_grad"] +colors = PackedColorArray(1, 0.78, 0.18, 0.95, 1, 0.45, 0.05, 0) + +[sub_resource type="SphereMesh" id="stone_mesh"] +material = SubResource("mat") +radius = 0.055 +height = 0.11 +radial_segments = 3 +rings = 1 + +[sub_resource type="Curve" id="stone_curve"] +_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(0.6, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0] +point_count = 3 + +[sub_resource type="Gradient" id="stone_grad"] +colors = PackedColorArray(0.62, 0.58, 0.52, 1, 0.62, 0.58, 0.52, 0) + +[sub_resource type="SphereMesh" id="spark_mesh"] +material = SubResource("mat") +radius = 0.04 +height = 0.08 +radial_segments = 3 +rings = 1 + +[sub_resource type="Curve" id="spark_curve"] +_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(0.5, 0.6), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0] +point_count = 3 + +[sub_resource type="Gradient" id="spark_grad"] +colors = PackedColorArray(1, 0.98, 0.6, 1, 1, 1, 1, 0) + +[node name="bull_kick" type="CPUParticles3D"] +emitting = false +amount = 40 +lifetime = 1.2 +one_shot = true +local_coords = false +explosiveness = 0.8 +randomness = 0.5 +mesh = SubResource("dust_mesh") +direction = Vector3(0, 0.7, -1) +spread = 65.0 +gravity = Vector3(0, -6, 0) +initial_velocity_min = 3.0 +initial_velocity_max = 9.0 +scale_amount_min = 0.5 +scale_amount_max = 2.2 +scale_amount_curve = SubResource("dust_curve") +color_ramp = SubResource("dust_grad") +script = ExtResource("oneshot") + +[node name="stones" type="CPUParticles3D" parent="."] +emitting = false +amount = 14 +lifetime = 0.65 +one_shot = true +local_coords = false +explosiveness = 0.92 +randomness = 0.6 +mesh = SubResource("stone_mesh") +direction = Vector3(0, 0.35, -1) +spread = 45.0 +gravity = Vector3(0, -9.8, 0) +initial_velocity_min = 5.0 +initial_velocity_max = 14.0 +scale_amount_min = 0.5 +scale_amount_max = 1.8 +scale_amount_curve = SubResource("stone_curve") +color_ramp = SubResource("stone_grad") + +[node name="sparks" type="CPUParticles3D" parent="."] +emitting = false +amount = 22 +lifetime = 0.28 +one_shot = true +local_coords = false +explosiveness = 0.95 +randomness = 0.4 +mesh = SubResource("spark_mesh") +direction = Vector3(0, 0.5, -1) +spread = 30.0 +gravity = Vector3(0, -14, 0) +initial_velocity_min = 8.0 +initial_velocity_max = 18.0 +scale_amount_min = 0.6 +scale_amount_max = 2.0 +scale_amount_curve = SubResource("spark_curve") +color_ramp = SubResource("spark_grad") diff --git a/systems/fx/bull_roll_sparks.tscn b/systems/fx/bull_roll_sparks.tscn new file mode 100644 index 0000000..211da6a --- /dev/null +++ b/systems/fx/bull_roll_sparks.tscn @@ -0,0 +1,42 @@ +[gd_scene format=3] + +; Bull roll sparks — the dirt/ember spray flung outward from the spinning roll ("Powerball"). +; Continuous: the player instances it once under the bull and toggles `emitting` during a roll. +; World-space so the spray peels off into a trail. Tune the look here; see README.md. + +[sub_resource type="StandardMaterial3D" id="mat"] +transparency = 1 +cull_mode = 2 +shading_mode = 0 +vertex_color_use_as_albedo = true + +[sub_resource type="SphereMesh" id="mesh"] +material = SubResource("mat") +radius = 0.05 +height = 0.1 +radial_segments = 4 +rings = 2 + +[sub_resource type="Curve" id="curve"] +_data = [Vector2(0, 0.3), 0.0, 0.0, 0, 0, Vector2(0.3, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0] +point_count = 3 + +[sub_resource type="Gradient" id="grad"] +colors = PackedColorArray(1, 0.78, 0.32, 1, 0.7, 0.42, 0.14, 0) + +[node name="bull_roll_sparks" type="CPUParticles3D"] +emitting = false +amount = 46 +lifetime = 0.45 +local_coords = false +randomness = 0.7 +mesh = SubResource("mesh") +direction = Vector3(0, 1, 0) +spread = 180.0 +gravity = Vector3(0, -6, 0) +initial_velocity_min = 3.0 +initial_velocity_max = 8.0 +scale_amount_min = 0.5 +scale_amount_max = 1.3 +scale_amount_curve = SubResource("curve") +color_ramp = SubResource("grad") diff --git a/systems/fx/bull_slam.tscn b/systems/fx/bull_slam.tscn new file mode 100644 index 0000000..16e7feb --- /dev/null +++ b/systems/fx/bull_slam.tscn @@ -0,0 +1,47 @@ +[gd_scene format=3] + +; Bull ground-slam dust — the one-shot dirt burst thrown up when the slam lands. Fired via +; Fx.spawn(&"bull_slam", xform) at the impact point. The expanding shockwave RINGS are a +; separate coded effect (actors/player/bull_impact_fx.gd), not this. Tune the look here. + +[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="oneshot"] + +[sub_resource type="StandardMaterial3D" id="mat"] +transparency = 1 +cull_mode = 2 +shading_mode = 0 +vertex_color_use_as_albedo = true + +[sub_resource type="SphereMesh" id="mesh"] +material = SubResource("mat") +radius = 0.09 +height = 0.18 +radial_segments = 4 +rings = 2 + +[sub_resource type="Curve" id="curve"] +_data = [Vector2(0, 0.25), 0.0, 0.0, 0, 0, Vector2(0.35, 1.6), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0] +point_count = 3 + +[sub_resource type="Gradient" id="grad"] +colors = PackedColorArray(0.88, 0.74, 0.46, 1, 0.88, 0.74, 0.46, 0) + +[node name="bull_slam" type="CPUParticles3D"] +emitting = false +amount = 48 +lifetime = 1.1 +one_shot = true +local_coords = false +explosiveness = 0.85 +randomness = 0.5 +mesh = SubResource("mesh") +direction = Vector3(0, 1, 0) +spread = 85.0 +gravity = Vector3(0, -7, 0) +initial_velocity_min = 3.5 +initial_velocity_max = 10.0 +scale_amount_min = 0.5 +scale_amount_max = 2.0 +scale_amount_curve = SubResource("curve") +color_ramp = SubResource("grad") +script = ExtResource("oneshot") diff --git a/systems/fx/effect_gallery.gd b/systems/fx/effect_gallery.gd index b435e17..bad2c97 100644 --- a/systems/fx/effect_gallery.gd +++ b/systems/fx/effect_gallery.gd @@ -4,7 +4,10 @@ extends Node3D ## press Play, then edit any effect .tscn in the inspector and re-run to compare. See fx/README.md. const _CYCLE_SEC := 1.6 -const _EFFECTS: Array[StringName] = [&"blood_burst", &"slam_dust", &"spark", &"hit_flash"] +const _EFFECTS: Array[StringName] = [ + &"blood_burst", &"slam_dust", &"spark", &"hit_flash", + &"bull_slam", &"bull_kick", &"bull_dash", +] @onready var _label: Label = $UI/Label # Reached by path rather than the Fx global so the placeholder-seeder can compile and attach this diff --git a/systems/fx/fx.gd b/systems/fx/fx.gd index 5d61de0..f5e7def 100644 --- a/systems/fx/fx.gd +++ b/systems/fx/fx.gd @@ -7,10 +7,18 @@ extends Node ## placeholder .tscn are seeded — see tools/seed_fx_placeholders.gd. const _PATHS: Dictionary = { + # Generic hit effects. &"blood_burst": "res://systems/fx/blood_burst.tscn", &"slam_dust": "res://systems/fx/slam_dust.tscn", &"spark": "res://systems/fx/spark.tscn", &"hit_flash": "res://systems/fx/hit_flash.tscn", + # Bull ability effects — one-shots fired by spawn(), continuous ones held via attach(). + &"bull_slam": "res://systems/fx/bull_slam.tscn", + &"bull_kick": "res://systems/fx/bull_kick.tscn", + &"bull_dash": "res://systems/fx/bull_dash.tscn", + &"bull_hoof_dust": "res://systems/fx/bull_hoof_dust.tscn", + &"bull_charge_trail": "res://systems/fx/bull_charge_trail.tscn", + &"bull_roll_sparks": "res://systems/fx/bull_roll_sparks.tscn", } var _cache: Dictionary = {} @@ -32,6 +40,18 @@ func spawn(effect: StringName, xform: Transform3D, parent: Node = null) -> Node: return inst +# Instance a CONTINUOUS effect as a child of `parent` and hand it back. Unlike spawn(), it neither +# positions nor auto-frees the effect — the caller keeps the reference to place it and toggle +# `emitting` (hoof dust that follows the bull, the charge trail). Returns null if unknown/missing. +func attach(effect: StringName, parent: Node) -> Node: + var scene := _scene(effect) + if scene == null or parent == null: + return null + var inst := scene.instantiate() + parent.add_child(inst) + return inst + + func _scene(effect: StringName) -> PackedScene: if _cache.has(effect): return _cache[effect] diff --git a/tests/bear_fx_test.gd b/tests/bear_fx_test.gd index c600425..3d72c15 100644 --- a/tests/bear_fx_test.gd +++ b/tests/bear_fx_test.gd @@ -1,6 +1,6 @@ extends SceneTree ## Bear attack-FX test. The four attack effects (crater, smash debris, left/right claw slash) -## live in Assets/fx/Bear_FX.glb as meshes that rest at scale 0 at Siim's baked positions. The +## live in art/fx/bear_fx.glb as meshes that rest at scale 0 at Siim's baked positions. The ## GLB carries its own AnimationPlayer whose clips scale one mesh up at its strike frame and ## back to 0 (self-hiding) while pinning the others at 0. bear.gd fires the matching clip on ## each strike (see _play_fx). This asserts each clip pops its own mesh, self-hides by the end, diff --git a/tests/fx_gallery_test.gd b/tests/fx_gallery_test.gd index 8a7afff..0ec4050 100644 --- a/tests/fx_gallery_test.gd +++ b/tests/fx_gallery_test.gd @@ -26,10 +26,16 @@ func _run() -> void: root.add_child(host) current_scene = host - for id: StringName in [&"blood_burst", &"slam_dust", &"spark", &"hit_flash"]: + for id: StringName in [&"blood_burst", &"slam_dust", &"spark", &"hit_flash", + &"bull_slam", &"bull_kick", &"bull_dash"]: var inst: Node = fx.call("spawn", id, Transform3D.IDENTITY, host) _ok(inst != null and is_instance_valid(inst), "spawns %s" % id) + # Continuous bull effects are held (attach), not fired-and-forgotten (spawn). + for id: StringName in [&"bull_hoof_dust", &"bull_charge_trail", &"bull_roll_sparks"]: + var inst: Node = fx.call("attach", id, host) + _ok(inst != null and is_instance_valid(inst), "attaches %s" % id) + var missing: Node = fx.call("spawn", &"does_not_exist", Transform3D.IDENTITY, host) _ok(missing == null, "unknown effect returns null instead of crashing")