Add scene-authored FX effect library and Fx autoload
Replace code-buried CPUParticles with inspector-editable effect scenes fired through one Fx autoload: code names the effect (Fx.spawn(&"blood_burst", xform)), the .tscn owns the look. Ships four placeholder effects (blood_burst, slam_dust, spark, hit_flash) seeded from the old code params, a self-freeing one_shot_effect root helper, a live preview gallery, and an FX style guide for Siim. CPUParticles only (mobile GLES3 constraint). Existing call sites untouched -- migrated later. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# 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) # at a node
|
||||
Fx.spawn(&"slam_dust", Transform3D(Basis(), point)) # at a world point
|
||||
```
|
||||
|
||||
## 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`.
|
||||
|
||||
## 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(name, xform, parent=null)`; name→path registry |
|
||||
| `one_shot_effect.gd` | Root helper: fires the emitter(s) once, frees the scene when done |
|
||||
| `blood_burst.tscn` etc. | The effects themselves — **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 |
|
||||
@@ -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 fx/_seed_placeholders.gd
|
||||
|
||||
const _EFFECT_SCRIPT := "res://fx/one_shot_effect.gd"
|
||||
const _GALLERY_SCRIPT := "res://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://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://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://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://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://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://fx/effect_gallery.tscn")
|
||||
root_node.free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://csbp6mkg257uy
|
||||
@@ -0,0 +1,42 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://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")
|
||||
@@ -0,0 +1,30 @@
|
||||
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"]
|
||||
|
||||
@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)))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bu2r8vtpfpwe7
|
||||
@@ -0,0 +1,32 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://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"
|
||||
@@ -0,0 +1,51 @@
|
||||
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 fx/_seed_placeholders.gd.
|
||||
|
||||
const _PATHS: Dictionary = {
|
||||
&"blood_burst": "res://fx/blood_burst.tscn",
|
||||
&"slam_dust": "res://fx/slam_dust.tscn",
|
||||
&"spark": "res://fx/spark.tscn",
|
||||
&"hit_flash": "res://fx/hit_flash.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
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b223163l8yasu
|
||||
@@ -0,0 +1,41 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://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")
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkoudbnrkfiaw
|
||||
@@ -0,0 +1,44 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://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")
|
||||
@@ -0,0 +1,40 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://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")
|
||||
@@ -29,6 +29,7 @@ Settings="*res://settings.gd"
|
||||
Run="*res://levels/run_state.gd"
|
||||
OrientationGuard="*res://orientation_guard.gd"
|
||||
WebStartGate="*res://web_start_gate.gd"
|
||||
Fx="*res://fx/fx.gd"
|
||||
|
||||
[display]
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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"]:
|
||||
var inst: Node = fx.call("spawn", id, Transform3D.IDENTITY, host)
|
||||
_ok(inst != null and is_instance_valid(inst), "spawns %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://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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2utxg1g3cpjs
|
||||
Reference in New Issue
Block a user