Add scene-authored FX library and bull ability effects

An Fx autoload + one_shot_effect base so every effect is an inspector-editable
.tscn (the systems/fx/ authoring dir): blood/slam/spark/flash primitives and the
bull's kick/dash/slam/roll + movement effects (bull_*.tscn), fired via
Fx.spawn() / Fx.attach(). Plus a preview gallery + smoke test, and
bull_impact_fx.gd for the coded, non-particle slam rings + kick flash-pop.

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:01 +03:00
parent 9458085abb
commit c0a022fed4
26 changed files with 1174 additions and 0 deletions
+110
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://sf5etma4kjt0
+88
View File
@@ -0,0 +1,88 @@
# FX — the effect library
Gameplay code fires a **named** effect; the **scene** decides how it looks. That split is the whole
point: a programmer writes `Fx.spawn(&"blood_burst", hit_transform)` once, and from then on every
change to how a hit *feels* is a `.tscn` Siim edits in the inspector — no code, no recompile.
```gdscript
# anywhere in gameplay code
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
1. **Open the gallery.** Run `fx/effect_gallery.tscn`. It loops through every effect on a sand-lit
stage with the effect's name on screen, so you see each one the way it reads in play.
2. **Pick an effect** `.tscn` in `fx/` (`blood_burst`, `slam_dust`, `spark`, `hit_flash`) and open it.
3. **Tune it in the inspector.** Everything lives on the `CPUParticles3D` node and its material:
- **Colour** → the node's *Color Ramp* (start colour → faded/transparent end).
- **Shape of the burst over time** → *Scale Amount Curve*.
- **Amount / Lifetime / Explosiveness / Spread / Velocity / Gravity** → the node's emission group.
- The viewport previews particles live as you drag — no play needed.
4. **Save.** That's the whole edit. Re-run the gallery to compare.
New effect? Add a `fx/<name>.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:
| Role | Start | End (fades to alpha 0) |
|-------|---------------------------|-----------------------------|
| Blood | `#FF0505` | `#400000` |
| Dust | `#B89966` (tan) | `#8C7326` |
| Ember | `#FFF299` → `#FF8C1A` | transparent |
| Flash | `#FFFFFF` | transparent |
**Timing budgets** — hits read best short: strike/flash effects ≤ **0.4 s**, dust/settle ≤ **1.1 s**.
A one-shot effect frees itself `lifetime + 0.5 s` after spawn (`one_shot_effect.gd`), so keep
`lifetime` honest.
**PS1 look** — materials are **unshaded**, **alpha-blended**, **vertex-colour** (colour comes from the
ramp), cull disabled. Prefer few, chunky, low-poly billboarded quads over dense fine particles; the
game runs the PS1 filter over everything, so subtlety is lost anyway.
**Mobile** — effects are **`CPUParticles3D`, never `GPUParticles3D`**. The web/GLES3/ANGLE target
compiles particle shaders badly (see the notes in `player.gd`); CPU particles are just as tunable in
the inspector and safe on-device. Keep this constraint when adding effects.
## Files
| File | What it is |
|---|---|
| `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 |
+49
View File
@@ -0,0 +1,49 @@
extends CPUParticles3D
## One-shot blood spray. Add as a child of whatever bleeds, then call burst().
func _ready() -> void:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.vertex_color_use_as_albedo = true
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
var sphere := SphereMesh.new()
sphere.radius = 0.07
sphere.height = 0.14
sphere.radial_segments = 4
sphere.rings = 2
sphere.material = mat
var ramp := Gradient.new()
ramp.set_color(0, Color(1.0, 0.02, 0.02, 1.0))
ramp.set_color(1, Color(0.25, 0.0, 0.0, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(0.6, 0.5))
scale_curve.add_point(Vector2(1.0, 0.0))
mesh = sphere
color_ramp = ramp
scale_amount_curve = scale_curve
one_shot = true
explosiveness = 0.9
amount = 25
lifetime = 1.1
randomness = 0.4
local_coords = false
spread = 80.0
gravity = Vector3(0.0, -7.0, 0.0)
initial_velocity_min = 4.0
initial_velocity_max = 11.0
scale_amount_min = 0.5
scale_amount_max = 1.6
emitting = false
# Fire a burst at a world position, sprayed along dir (biased upward).
func burst(at: Vector3, dir: Vector3) -> void:
global_position = at
direction = (dir * 0.6 + Vector3.UP * 0.4).normalized()
restart()
+1
View File
@@ -0,0 +1 @@
uid://cnhmhom4w5o3i
+42
View File
@@ -0,0 +1,42 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="1_l68fm"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_okatq"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="SphereMesh_n7uw4"]
material = SubResource("StandardMaterial3D_okatq")
radius = 0.07
height = 0.14
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="Curve_a71nq"]
_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(0.6, 0.5), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="Gradient_mhqc0"]
colors = PackedColorArray(1, 0.02, 0.02, 1, 0.25, 0, 0, 0)
[node name="blood_burst" type="CPUParticles3D" unique_id=1553421910]
emitting = false
amount = 25
lifetime = 1.1
one_shot = true
explosiveness = 0.9
randomness = 0.4
mesh = SubResource("SphereMesh_n7uw4")
direction = Vector3(0, 1, 0)
spread = 80.0
gravity = Vector3(0, -7, 0)
initial_velocity_min = 4.0
initial_velocity_max = 11.0
scale_amount_min = 0.5
scale_amount_max = 1.6
scale_amount_curve = SubResource("Curve_a71nq")
color_ramp = SubResource("Gradient_mhqc0")
script = ExtResource("1_l68fm")
+42
View File
@@ -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")
+44
View File
@@ -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")
+43
View File
@@ -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")
+115
View File
@@ -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")
+42
View File
@@ -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")
+47
View File
@@ -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")
+33
View File
@@ -0,0 +1,33 @@
extends Node3D
## Live preview stage for the FX library. Cycles through every registered effect on a loop against
## arena-ish lighting so Siim sees each one as it reads in-game. Open fx/effect_gallery.tscn and
## 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",
&"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
# script headless (autoload globals aren't resolvable during an on-demand compile in a --script run).
@onready var _fx: Node = get_node("/root/Fx")
var _index := 0
func _ready() -> void:
var timer := Timer.new()
timer.wait_time = _CYCLE_SEC
timer.autostart = true
add_child(timer)
timer.timeout.connect(_fire_next)
_fire_next()
func _fire_next() -> void:
var effect := _EFFECTS[_index % _EFFECTS.size()]
_index += 1
_label.text = "%s (%d/%d)" % [effect, (_index - 1) % _EFFECTS.size() + 1, _EFFECTS.size()]
_fx.call("spawn", effect, global_transform.translated_local(Vector3(0.0, 0.25, 0.0)))
+1
View File
@@ -0,0 +1 @@
uid://bu2r8vtpfpwe7
+32
View File
@@ -0,0 +1,32 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://systems/fx/effect_gallery.gd" id="1_opqwa"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_lt3bb"]
albedo_color = Color(0.62, 0.53, 0.36, 1)
[sub_resource type="PlaneMesh" id="PlaneMesh_2rj6n"]
material = SubResource("StandardMaterial3D_lt3bb")
size = Vector2(12, 12)
[node name="EffectGallery" type="Node3D" unique_id=530454952]
script = ExtResource("1_opqwa")
[node name="Camera3D" type="Camera3D" parent="." unique_id=1818153475]
transform = Transform3D(1, 0, 0, 0, 0.95105654, 0.309017, 0, -0.309017, 0.95105654, 0, 2, 4.5)
[node name="Sun" type="DirectionalLight3D" parent="." unique_id=1218164313]
transform = Transform3D(0.8660254, 0.38302222, -0.32139382, 0, 0.64278764, 0.76604444, 0.5, -0.66341394, 0.5566704, 0, 0, 0)
[node name="Ground" type="MeshInstance3D" parent="." unique_id=178415463]
mesh = SubResource("PlaneMesh_2rj6n")
[node name="UI" type="CanvasLayer" parent="." unique_id=1848100773]
[node name="Label" type="Label" parent="UI" unique_id=1347149836]
offset_left = 24.0
offset_top = 20.0
offset_right = 24.0
offset_bottom = 20.0
theme_override_font_sizes/font_size = 28
text = "FX gallery"
+71
View File
@@ -0,0 +1,71 @@
extends Node
## Fx — the one place gameplay code fires a visual effect. Code names WHICH effect and WHERE;
## the .tscn owns HOW it looks (colour, curves, emission — all inspector-editable, no recompile).
## Autoload singleton. Usage: Fx.spawn(&"blood_burst", hit_transform)
##
## Scenes are loaded lazily (not preloaded) so this autoload compiles and boots even before the
## 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 = {}
# Spawn a named effect at `xform`, parented to `parent` (or the current scene). Returns the
# instance, or null if the effect is unknown or its scene is missing.
func spawn(effect: StringName, xform: Transform3D, parent: Node = null) -> Node:
var scene := _scene(effect)
if scene == null:
return null
var host := parent if parent != null else _default_host()
if host == null:
push_warning("Fx.spawn(%s): no parent and no current scene to attach to" % effect)
return null
var inst := scene.instantiate() as Node3D
host.add_child(inst)
inst.global_transform = xform
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]
if not _PATHS.has(effect):
push_warning("Fx.spawn: unknown effect '%s' (add it to fx/fx.gd)" % effect)
return null
var scene := load(_PATHS[effect]) as PackedScene
if scene == null:
push_warning("Fx.spawn: scene missing for '%s' at %s" % [effect, _PATHS[effect]])
return null
_cache[effect] = scene
return scene
func _default_host() -> Node:
var tree := get_tree()
return tree.current_scene if tree != null else null
+1
View File
@@ -0,0 +1 @@
uid://b223163l8yasu
+41
View File
@@ -0,0 +1,41 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="1_6wb3q"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_n65j7"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="SphereMesh_ih2se"]
material = SubResource("StandardMaterial3D_n65j7")
radius = 0.05
height = 0.1
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="Curve_kfjqk"]
_data = [Vector2(0, 0.2), 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="Gradient_8o2om"]
colors = PackedColorArray(1, 1, 1, 1, 1, 1, 1, 0)
[node name="hit_flash" type="CPUParticles3D" unique_id=454164430]
emitting = false
amount = 6
lifetime = 0.18
one_shot = true
explosiveness = 1.0
randomness = 0.2
mesh = SubResource("SphereMesh_ih2se")
spread = 180.0
gravity = Vector3(0, 0, 0)
initial_velocity_min = 0.5
initial_velocity_max = 1.5
scale_amount_min = 0.8
scale_amount_max = 1.4
scale_amount_curve = SubResource("Curve_kfjqk")
color_ramp = SubResource("Gradient_8o2om")
script = ExtResource("1_6wb3q")
+33
View File
@@ -0,0 +1,33 @@
extends CPUParticles3D
## Root helper for a one-shot effect scene: fires this emitter (and any CPUParticles3D children)
## once when spawned, then frees the whole scene after the longest-lived particle has died. This
## is what lets an effect be nothing but a tunable CPUParticles3D scene — no per-effect code, so
## Siim owns the look entirely in the inspector. Fired by the Fx autoload; see fx/README.md.
const _FREE_MARGIN := 0.5
func _ready() -> void:
var ttl := _arm(self)
for emitter: CPUParticles3D in _child_emitters():
ttl = maxf(ttl, _arm(emitter))
get_tree().create_timer(ttl + _FREE_MARGIN).timeout.connect(queue_free)
func _arm(p: CPUParticles3D) -> float:
p.one_shot = true
p.emitting = true
return p.lifetime
func _child_emitters() -> Array[CPUParticles3D]:
var out: Array[CPUParticles3D] = []
_gather(self, out)
return out
func _gather(node: Node, out: Array[CPUParticles3D]) -> void:
for child: Node in node.get_children():
if child is CPUParticles3D:
out.append(child)
_gather(child, out)
+1
View File
@@ -0,0 +1 @@
uid://dkoudbnrkfiaw
+44
View File
@@ -0,0 +1,44 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="1_88uyl"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_1tr47"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="SphereMesh_bw8nk"]
material = SubResource("StandardMaterial3D_1tr47")
radius = 0.16
height = 0.32
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="Curve_xgm72"]
_data = [Vector2(0, 0.4), 0.0, 0.0, 0, 0, Vector2(0.35, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="Gradient_l8m5g"]
colors = PackedColorArray(0.72, 0.6, 0.4, 0.9, 0.55, 0.45, 0.3, 0)
[node name="slam_dust" type="CPUParticles3D" unique_id=82833428]
emitting = false
amount = 36
lifetime = 0.9
one_shot = true
explosiveness = 1.0
randomness = 0.5
mesh = SubResource("SphereMesh_bw8nk")
emission_shape = 1
emission_sphere_radius = 0.5
direction = Vector3(0, 1, 0)
spread = 75.0
gravity = Vector3(0, -6, 0)
initial_velocity_min = 2.5
initial_velocity_max = 6.5
scale_amount_min = 0.6
scale_amount_max = 1.4
scale_amount_curve = SubResource("Curve_xgm72")
color_ramp = SubResource("Gradient_l8m5g")
script = ExtResource("1_88uyl")
+40
View File
@@ -0,0 +1,40 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="1_kk3a7"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_tjifq"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="SphereMesh_pv2r6"]
material = SubResource("StandardMaterial3D_tjifq")
radius = 0.03
height = 0.06
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="Curve_l33bb"]
_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="Gradient_pnkyn"]
colors = PackedColorArray(1, 0.95, 0.6, 1, 1, 0.55, 0.1, 0)
[node name="spark" type="CPUParticles3D" unique_id=69954641]
emitting = false
amount = 14
lifetime = 0.35
one_shot = true
explosiveness = 1.0
randomness = 0.6
mesh = SubResource("SphereMesh_pv2r6")
direction = Vector3(0, 1, 0)
gravity = Vector3(0, -9, 0)
initial_velocity_min = 3.0
initial_velocity_max = 7.0
scale_amount_min = 0.4
scale_amount_curve = SubResource("Curve_l33bb")
color_ramp = SubResource("Gradient_pnkyn")
script = ExtResource("1_kk3a7")
+51
View File
@@ -0,0 +1,51 @@
extends SceneTree
## Smoke test for the FX library: every registered effect instantiates and attaches without error,
## and an unknown name fails soft (returns null, no crash). Catches a broken effect .tscn before it
## ships. Run: godot --headless --script tests/fx_gallery_test.gd
var _pass := 0
var _fail := 0
func _init() -> void:
_run.call_deferred()
func _ok(cond: bool, msg: String) -> void:
if cond:
_pass += 1
print(" PASS: ", msg)
else:
_fail += 1
print(" FAIL: ", msg)
func _run() -> void:
var fx: Node = root.get_node("/root/Fx")
var host := Node3D.new()
root.add_child(host)
current_scene = host
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")
# The preview gallery scene is wired (has its controller script + label).
var gallery: Node = load("res://systems/fx/effect_gallery.tscn").instantiate()
_ok(gallery.get_script() != null, "gallery scene keeps its controller script")
host.add_child(gallery)
await process_frame
_ok(gallery.get_node_or_null("UI/Label") != null, "gallery scene has its name label")
await process_frame
print("Results: %d passed, %d failed" % [_pass, _fail])
quit(1 if _fail > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://c2utxg1g3cpjs
+200
View File
@@ -0,0 +1,200 @@
extends SceneTree
## Seeds the placeholder effect .tscn (and the gallery scene) once, headless. Those .tscn are the
## source of truth Siim edits in the inspector — this script only builds their first-draft defaults
## (ported from the old code-built particles). Re-run only to reset a placeholder from scratch:
## godot --headless --script tools/seed_fx_placeholders.gd
const _EFFECT_SCRIPT := "res://systems/fx/one_shot_effect.gd"
const _GALLERY_SCRIPT := "res://systems/fx/effect_gallery.gd"
func _init() -> void:
_seed_blood_burst()
_seed_slam_dust()
_seed_spark()
_seed_hit_flash()
_seed_gallery()
print("Seeded FX placeholder scenes into res://systems/fx/")
quit()
# ── shared builders ─────────────────────────────────────────────────────────
func _unshaded() -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.vertex_color_use_as_albedo = true
m.cull_mode = BaseMaterial3D.CULL_DISABLED
return m
func _sphere(radius: float) -> SphereMesh:
var s := SphereMesh.new()
s.radius = radius
s.height = radius * 2.0
s.radial_segments = 4
s.rings = 2
s.material = _unshaded()
return s
func _ramp(c0: Color, c1: Color) -> Gradient:
var g := Gradient.new()
g.set_color(0, c0)
g.set_color(1, c1)
return g
func _curve(points: Array) -> Curve:
var c := Curve.new()
for pt: Vector2 in points:
c.add_point(pt)
return c
func _save(p: CPUParticles3D, path: String) -> void:
p.set_script(load(_EFFECT_SCRIPT))
var packed := PackedScene.new()
packed.pack(p)
ResourceSaver.save(packed, path)
p.free()
# ── the placeholders (defaults ported from the old code-built particles) ─────
func _seed_blood_burst() -> void:
var p := CPUParticles3D.new()
p.name = "blood_burst"
p.mesh = _sphere(0.07)
p.color_ramp = _ramp(Color(1.0, 0.02, 0.02, 1.0), Color(0.25, 0.0, 0.0, 0.0))
p.scale_amount_curve = _curve([Vector2(0.0, 1.0), Vector2(0.6, 0.5), Vector2(1.0, 0.0)])
p.emitting = false
p.one_shot = true
p.explosiveness = 0.9
p.amount = 25
p.lifetime = 1.1
p.randomness = 0.4
p.local_coords = false
p.direction = Vector3(0.0, 1.0, 0.0)
p.spread = 80.0
p.gravity = Vector3(0.0, -7.0, 0.0)
p.initial_velocity_min = 4.0
p.initial_velocity_max = 11.0
p.scale_amount_min = 0.5
p.scale_amount_max = 1.6
_save(p, "res://systems/fx/blood_burst.tscn")
func _seed_slam_dust() -> void:
var p := CPUParticles3D.new()
p.name = "slam_dust"
p.mesh = _sphere(0.16)
p.color_ramp = _ramp(Color(0.72, 0.60, 0.40, 0.9), Color(0.55, 0.45, 0.30, 0.0))
p.scale_amount_curve = _curve([Vector2(0.0, 0.4), Vector2(0.35, 1.0), Vector2(1.0, 0.0)])
p.emitting = false
p.one_shot = true
p.explosiveness = 1.0
p.amount = 36
p.lifetime = 0.9
p.randomness = 0.5
p.local_coords = false
p.emission_shape = CPUParticles3D.EMISSION_SHAPE_SPHERE
p.emission_sphere_radius = 0.5
p.direction = Vector3(0.0, 1.0, 0.0)
p.spread = 75.0
p.gravity = Vector3(0.0, -6.0, 0.0)
p.initial_velocity_min = 2.5
p.initial_velocity_max = 6.5
p.scale_amount_min = 0.6
p.scale_amount_max = 1.4
_save(p, "res://systems/fx/slam_dust.tscn")
func _seed_spark() -> void:
var p := CPUParticles3D.new()
p.name = "spark"
p.mesh = _sphere(0.03)
p.color_ramp = _ramp(Color(1.0, 0.95, 0.6, 1.0), Color(1.0, 0.55, 0.1, 0.0))
p.scale_amount_curve = _curve([Vector2(0.0, 1.0), Vector2(1.0, 0.0)])
p.emitting = false
p.one_shot = true
p.explosiveness = 1.0
p.amount = 14
p.lifetime = 0.35
p.randomness = 0.6
p.local_coords = false
p.direction = Vector3(0.0, 1.0, 0.0)
p.spread = 45.0
p.gravity = Vector3(0.0, -9.0, 0.0)
p.initial_velocity_min = 3.0
p.initial_velocity_max = 7.0
p.scale_amount_min = 0.4
p.scale_amount_max = 1.0
_save(p, "res://systems/fx/spark.tscn")
func _seed_hit_flash() -> void:
var p := CPUParticles3D.new()
p.name = "hit_flash"
p.mesh = _sphere(0.05)
p.color_ramp = _ramp(Color(1.0, 1.0, 1.0, 1.0), Color(1.0, 1.0, 1.0, 0.0))
p.scale_amount_curve = _curve([Vector2(0.0, 0.2), Vector2(0.3, 1.2), Vector2(1.0, 0.0)])
p.emitting = false
p.one_shot = true
p.explosiveness = 1.0
p.amount = 6
p.lifetime = 0.18
p.randomness = 0.2
p.local_coords = false
p.spread = 180.0
p.gravity = Vector3.ZERO
p.initial_velocity_min = 0.5
p.initial_velocity_max = 1.5
p.scale_amount_min = 0.8
p.scale_amount_max = 1.4
_save(p, "res://systems/fx/hit_flash.tscn")
func _seed_gallery() -> void:
var root_node := Node3D.new()
root_node.name = "EffectGallery"
var cam := Camera3D.new()
cam.name = "Camera3D"
cam.transform = Transform3D(Basis.from_euler(Vector3(deg_to_rad(-18.0), 0.0, 0.0)), Vector3(0.0, 2.0, 4.5))
root_node.add_child(cam)
cam.owner = root_node
var light := DirectionalLight3D.new()
light.name = "Sun"
light.rotation_degrees = Vector3(-50.0, -30.0, 0.0)
root_node.add_child(light)
light.owner = root_node
var ground := MeshInstance3D.new()
ground.name = "Ground"
var plane := PlaneMesh.new()
plane.size = Vector2(12.0, 12.0)
var gmat := StandardMaterial3D.new()
gmat.albedo_color = Color(0.62, 0.53, 0.36)
plane.material = gmat
ground.mesh = plane
root_node.add_child(ground)
ground.owner = root_node
var ui := CanvasLayer.new()
ui.name = "UI"
root_node.add_child(ui)
ui.owner = root_node
var label := Label.new()
label.name = "Label"
label.position = Vector2(24.0, 20.0)
label.text = "FX gallery"
label.add_theme_font_size_override("font_size", 28)
ui.add_child(label)
label.owner = root_node
root_node.set_script(load(_GALLERY_SCRIPT))
var packed := PackedScene.new()
packed.pack(root_node)
ResourceSaver.save(packed, "res://systems/fx/effect_gallery.tscn")
root_node.free()
+1
View File
@@ -0,0 +1 @@
uid://csbp6mkg257uy