From 973db7d8b80f983ee99063eea052b00eff8b8279 Mon Sep 17 00:00:00 2001 From: Richard Aasa Date: Sat, 5 Sep 2026 00:12:08 +0300 Subject: [PATCH] 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 --- fx/README.md | 59 +++++++++++ fx/_seed_placeholders.gd | 200 +++++++++++++++++++++++++++++++++++ fx/_seed_placeholders.gd.uid | 1 + fx/blood_burst.tscn | 42 ++++++++ fx/effect_gallery.gd | 30 ++++++ fx/effect_gallery.gd.uid | 1 + fx/effect_gallery.tscn | 32 ++++++ fx/fx.gd | 51 +++++++++ fx/fx.gd.uid | 1 + fx/hit_flash.tscn | 41 +++++++ fx/one_shot_effect.gd | 33 ++++++ fx/one_shot_effect.gd.uid | 1 + fx/slam_dust.tscn | 44 ++++++++ fx/spark.tscn | 40 +++++++ project.godot | 1 + tests/fx_gallery_test.gd | 45 ++++++++ tests/fx_gallery_test.gd.uid | 1 + 17 files changed, 623 insertions(+) create mode 100644 fx/README.md create mode 100644 fx/_seed_placeholders.gd create mode 100644 fx/_seed_placeholders.gd.uid create mode 100644 fx/blood_burst.tscn create mode 100644 fx/effect_gallery.gd create mode 100644 fx/effect_gallery.gd.uid create mode 100644 fx/effect_gallery.tscn create mode 100644 fx/fx.gd create mode 100644 fx/fx.gd.uid create mode 100644 fx/hit_flash.tscn create mode 100644 fx/one_shot_effect.gd create mode 100644 fx/one_shot_effect.gd.uid create mode 100644 fx/slam_dust.tscn create mode 100644 fx/spark.tscn create mode 100644 tests/fx_gallery_test.gd create mode 100644 tests/fx_gallery_test.gd.uid diff --git a/fx/README.md b/fx/README.md new file mode 100644 index 0000000..449a416 --- /dev/null +++ b/fx/README.md @@ -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/.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 | diff --git a/fx/_seed_placeholders.gd b/fx/_seed_placeholders.gd new file mode 100644 index 0000000..75edc04 --- /dev/null +++ b/fx/_seed_placeholders.gd @@ -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() diff --git a/fx/_seed_placeholders.gd.uid b/fx/_seed_placeholders.gd.uid new file mode 100644 index 0000000..adb77e8 --- /dev/null +++ b/fx/_seed_placeholders.gd.uid @@ -0,0 +1 @@ +uid://csbp6mkg257uy diff --git a/fx/blood_burst.tscn b/fx/blood_burst.tscn new file mode 100644 index 0000000..a5aad66 --- /dev/null +++ b/fx/blood_burst.tscn @@ -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") diff --git a/fx/effect_gallery.gd b/fx/effect_gallery.gd new file mode 100644 index 0000000..b435e17 --- /dev/null +++ b/fx/effect_gallery.gd @@ -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))) diff --git a/fx/effect_gallery.gd.uid b/fx/effect_gallery.gd.uid new file mode 100644 index 0000000..873f498 --- /dev/null +++ b/fx/effect_gallery.gd.uid @@ -0,0 +1 @@ +uid://bu2r8vtpfpwe7 diff --git a/fx/effect_gallery.tscn b/fx/effect_gallery.tscn new file mode 100644 index 0000000..1196a85 --- /dev/null +++ b/fx/effect_gallery.tscn @@ -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" diff --git a/fx/fx.gd b/fx/fx.gd new file mode 100644 index 0000000..e90def2 --- /dev/null +++ b/fx/fx.gd @@ -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 diff --git a/fx/fx.gd.uid b/fx/fx.gd.uid new file mode 100644 index 0000000..18a9eef --- /dev/null +++ b/fx/fx.gd.uid @@ -0,0 +1 @@ +uid://b223163l8yasu diff --git a/fx/hit_flash.tscn b/fx/hit_flash.tscn new file mode 100644 index 0000000..9a07913 --- /dev/null +++ b/fx/hit_flash.tscn @@ -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") diff --git a/fx/one_shot_effect.gd b/fx/one_shot_effect.gd new file mode 100644 index 0000000..0b1c81f --- /dev/null +++ b/fx/one_shot_effect.gd @@ -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) diff --git a/fx/one_shot_effect.gd.uid b/fx/one_shot_effect.gd.uid new file mode 100644 index 0000000..272108e --- /dev/null +++ b/fx/one_shot_effect.gd.uid @@ -0,0 +1 @@ +uid://dkoudbnrkfiaw diff --git a/fx/slam_dust.tscn b/fx/slam_dust.tscn new file mode 100644 index 0000000..27f73bc --- /dev/null +++ b/fx/slam_dust.tscn @@ -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") diff --git a/fx/spark.tscn b/fx/spark.tscn new file mode 100644 index 0000000..2298a50 --- /dev/null +++ b/fx/spark.tscn @@ -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") diff --git a/project.godot b/project.godot index fffe493..ab2f663 100644 --- a/project.godot +++ b/project.godot @@ -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] diff --git a/tests/fx_gallery_test.gd b/tests/fx_gallery_test.gd new file mode 100644 index 0000000..ca173f1 --- /dev/null +++ b/tests/fx_gallery_test.gd @@ -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) diff --git a/tests/fx_gallery_test.gd.uid b/tests/fx_gallery_test.gd.uid new file mode 100644 index 0000000..7380279 --- /dev/null +++ b/tests/fx_gallery_test.gd.uid @@ -0,0 +1 @@ +uid://c2utxg1g3cpjs