summer-engine 1.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.claude-plugin/plugin.json +18 -10
  2. package/.codex-plugin/plugin.json +36 -8
  3. package/.cursor-plugin/plugin.json +19 -11
  4. package/.opencode/INSTALL.md +69 -0
  5. package/.opencode/plugins/summer.js +9 -4
  6. package/CHANGELOG.md +31 -1
  7. package/README.md +268 -86
  8. package/dist/bin/summer.js +0 -0
  9. package/dist/commands/assets.d.ts +2 -0
  10. package/dist/commands/assets.js +189 -0
  11. package/dist/commands/create.js +55 -17
  12. package/dist/commands/debug.d.ts +2 -0
  13. package/dist/commands/debug.js +127 -0
  14. package/dist/commands/list.js +24 -5
  15. package/dist/commands/project.d.ts +2 -0
  16. package/dist/commands/project.js +170 -0
  17. package/dist/commands/scene.d.ts +2 -0
  18. package/dist/commands/scene.js +290 -0
  19. package/dist/lib/banner.js +2 -2
  20. package/dist/lib/log-filters.d.ts +53 -0
  21. package/dist/lib/log-filters.js +130 -0
  22. package/dist/lib/plugin-manifests.test.d.ts +1 -0
  23. package/dist/lib/plugin-manifests.test.js +134 -0
  24. package/dist/lib/remote-templates.d.ts +25 -0
  25. package/dist/lib/remote-templates.js +86 -0
  26. package/dist/lib/skills-registry.d.ts +33 -1
  27. package/dist/lib/skills-registry.js +56 -1
  28. package/dist/lib/telemetry.d.ts +13 -0
  29. package/dist/lib/telemetry.js +63 -0
  30. package/dist/lib/with-engine.d.ts +14 -0
  31. package/dist/lib/with-engine.js +109 -0
  32. package/dist/mcp/tools/asset-tools.js +12 -13
  33. package/dist/mcp/tools/debug-tools.js +32 -9
  34. package/dist/mcp/tools/with-engine.js +4 -0
  35. package/gemini-extension.json +2 -2
  36. package/hooks/pre-commit-doctor.sh +0 -0
  37. package/hooks/run-hook.cmd +0 -0
  38. package/hooks/session-start.sh +0 -0
  39. package/marketplace.json +2 -2
  40. package/package.json +3 -3
  41. package/skills/README.md +1 -1
  42. package/skills/_meta/skill-create/SKILL.md +1 -1
  43. package/skills/_meta/skill-improve/SKILL.md +1 -1
  44. package/skills/_meta/skill-test/SKILL.md +1 -1
  45. package/skills/_meta/using-summer/SKILL.md +130 -0
  46. package/skills/_tests/specs/design-npc.md +43 -12
  47. package/skills/_tests/specs/fps-controller.md +33 -20
  48. package/skills/_tests/specs/host-authoritative-state.md +104 -0
  49. package/skills/_tests/specs/vfx.md +56 -38
  50. package/skills/ai-and-npcs/design-npc/SKILL.md +264 -122
  51. package/skills/asset-pipeline/asset-strategy/SKILL.md +1 -1
  52. package/skills/catalog.yaml +21 -4
  53. package/skills/character-controllers/fps-controller/SKILL.md +260 -79
  54. package/skills/debugging/debug/SKILL.md +102 -82
  55. package/skills/multiplayer-and-networking/host-authoritative-state/SKILL.md +332 -0
  56. package/skills/rendering-and-lighting/3d-lighting/SKILL.md +1 -1
  57. package/skills/scene-and-project/browse-templates/SKILL.md +105 -0
  58. package/skills/scene-and-project/make-game/SKILL.md +159 -346
  59. package/skills/scene-and-project/new-project/SKILL.md +106 -0
  60. package/skills/scene-and-project/scene-composition/SKILL.md +1 -1
  61. package/skills/scripting-patterns/gdscript-patterns/SKILL.md +1 -1
  62. package/skills/ui-and-ux/ui-basics/SKILL.md +1 -1
  63. package/skills/visual-effects/vfx/SKILL.md +233 -134
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: vfx
3
- description: Use when the user says the game "feels flat", "needs juice", "lacks impact", or asks for particles, hit feedback, screen shake, camera juice, muzzle flashes, explosions, or pickup effects. Walks the user through a guided 30-minute polish pass that adds visible feedback to combat, movement, pickups, damage, and casting. Trigger on "vfx", "juice", "feels flat", "polish", "particles", "screen shake", "hit flash", "feedback".
3
+ description: Use when the user says the game "feels flat", "lacks impact", "needs juice", "needs punch", or asks for hit feedback, screen shake, camera shake, audio ducking, or general "game feel". Walks the user through the canonical Godot 4.5 game-feel stack — hit-flash + trauma camera shake + audio ducking — wired together so a single hit fires all three. Trigger on "vfx", "juice", "punch", "feels flat", "game feel", "hit flash", "screen shake", "camera shake", "ducking".
4
4
  license: MIT
5
5
  compatibility: [Cursor, Claude Code, Windsurf, Codex]
6
6
  category: visual-effects
@@ -9,21 +9,19 @@ allowed-tools: Read Grep Edit Write summer_get_scene_tree summer_inspect_node su
9
9
  paths: ["**/*.gd", "**/*.tscn", "**/*.tres"]
10
10
  ---
11
11
 
12
- # /vfx — Visible Polish in 30 Minutes
12
+ # /vfx — The Trio That Makes Games Feel Punchy
13
13
 
14
- A game with bad VFX feels like a tech demo. A game with good VFX feels alive even before the mechanics are deep. This skill is a guided polish pass: you ask the user *what feels flat*, then deliver one or two tight recipes that add immediately-visible feedback. No vague "add particles". Specific node, specific values, specific signal hookup, verified result.
14
+ Particles don't make a game feel good. Screen shake alone doesn't either. What makes a hit *land in the body* is three systems firing on the same frame: a sub-frame **hit flash** on the body that got hit, **trauma-based camera shake** with quadratic falloff so big hits saturate, and **audio ducking** that briefly squashes Music/Ambient so the impact SFX punches through. Skip any one and the game drops back to "tech demo". This skill installs the canonical Godot 4.5 stack — three small, self-contained systems wired to a single signal — so one `hit()` call fires all three.
15
15
 
16
- ## Steps
17
-
18
- ### 1. Ask which thing feels flat
16
+ ## Step 1 — Ask what feels flat
19
17
 
20
18
  Open with one focused question. Wait for the answer.
21
19
 
22
- > What feels flat? Pick one to start: combat hits / movement / pickups / damage taken / explosions / spell casts / something else.
20
+ > What feels flat? Pick one to start: combat hits / pickups / explosions / spell casts / something else.
23
21
 
24
- If the user says "all of it", anchor on **combat hits** — it gives the loudest perceived improvement per minute spent.
22
+ If the user says "all of it", anchor on **combat hits** — the hit-flash + shake + duck trio is the loudest perceived improvement per minute, and pickups/explosions reuse the same building blocks afterwards.
25
23
 
26
- ### 2. Read the current scene state
24
+ ## Step 2 — Read the current scene state
27
25
 
28
26
  Before adding anything, see what's there.
29
27
 
@@ -31,51 +29,46 @@ Before adding anything, see what's there.
31
29
 
32
30
  ```
33
31
  summer_get_scene_tree
34
- summer_inspect_node "./World/Player" # or whichever node owns the action
32
+ summer_inspect_node "./World/Enemy" # the body that gets hit
33
+ summer_inspect_node "./World/Player/Camera3D" # the camera to shake
35
34
  ```
36
35
 
37
- You're looking for: where the trigger lives (e.g. `body_entered` signal on a Hurtbox), whether a Camera3D exists for shake, whether the target has a MeshInstance3D for a flash material override.
38
-
39
- **Fallback (no MCP):** ask the user to paste the relevant `.tscn` snippet or describe the parent of the actor.
40
-
41
- ### 3. Pick the recipe and propose it explicitly
36
+ You're looking for: a `MeshInstance3D` on the hurt body (for the flash), an existing `Camera3D` on the player (don't spawn a new one), and the names of the audio buses already present (Master + at least one of `Music`, `Ambient`, `SFX`).
42
37
 
43
- Each recipe is a tight bundle of (node + property + signal + script). Don't mix recipes — add one, see it land, stack later.
38
+ If there's no Camera3D under the player, **stop and ask** before adding one — there may be a level camera elsewhere you'd be fighting.
44
39
 
45
- > I want to add a hit flash + 0.04s camera shake bound to the `damaged` signal on `./World/Enemy`. About 6 lines of GDScript and one Camera3D shake node. May I proceed?
40
+ If the only bus is `Master`, ducking will do nothing useful. See *Common mistakes* below.
46
41
 
47
- ### 4. Apply the recipe
42
+ **Fallback (no MCP):** ask the user to paste the relevant `.tscn` and the project's `default_bus_layout.tres` (or describe the bus list).
48
43
 
49
- See **Recipes** below. Each is end-to-end: node tree, property values, signal hookup, GDScript stub.
44
+ ## Step 3 — Propose the bundle
50
45
 
51
- ### 5. Verify
46
+ Don't drip the systems in one at a time. The whole point is the trio fires together — install all three, wire one signal, see it land.
52
47
 
53
- ```
54
- summer_save_scene
55
- summer_get_script_errors
56
- summer_play
57
- # user reproduces the trigger
58
- summer_stop
59
- ```
48
+ > I'm about to add: `HitFlash` on `./World/Enemy`, `CameraShake` script on `./World/Player/Camera3D`, `AudioDucker` autoload, and connect the `damaged` signal so one hit fires all three. About 130 lines of GDScript across 3 files. May I proceed?
60
49
 
61
- If it doesn't read on screen, the values are wrong (flash too dim, shake too short, particle lifetime too long). Tune one knob at a time.
50
+ ## Step 4 — Install Section 1 (Hit Flash)
62
51
 
63
- ## Recipes
52
+ **Goal:** when a body takes damage, its mesh pulses to white for ~0.08s, then tweens back. Reads instantly even at 60 fps.
64
53
 
65
- ### Recipe A — Hit flash (mesh albedo flicker)
54
+ **Approach:** keep a reference to the original `albedo_color`. On `flash()`, write white directly to the mesh's `material_override` (or build a flash override the first time), then `create_tween()` back to the original over `duration` seconds. Tween, not `await` + sleep — tweens auto-cancel if `flash()` is re-called mid-flash, which matters during multi-hit combos.
66
55
 
67
- **Goal:** when an enemy takes damage, the mesh flashes white for 80 ms. Single biggest-bang-for-buck VFX. Read instantly even at 60 fps.
56
+ **Node tree:**
68
57
 
69
- **Approach:** swap to a flash material via `material_override`, then back. Use Tween for the timing.
58
+ ```
59
+ World/
60
+ Enemy (CharacterBody3D)
61
+ Mesh (MeshInstance3D) # the visible body
62
+ HitFlash (Node) # holds the script, no transform
63
+ ```
70
64
 
71
65
  **MCP path:**
72
66
 
73
67
  ```
74
68
  summer_add_node(parent="./World/Enemy", type="Node", name="HitFlash")
75
- # attach script (see below) — script holds reference to MeshInstance3D and a flash StandardMaterial3D
76
69
  ```
77
70
 
78
- Script (`scripts/hit_flash.gd`):
71
+ Then write `scripts/vfx/hit_flash.gd`:
79
72
 
80
73
  ```gdscript
81
74
  class_name HitFlash
@@ -85,187 +78,294 @@ extends Node
85
78
  @export var flash_color: Color = Color(1.0, 1.0, 1.0)
86
79
  @export var duration: float = 0.08
87
80
 
81
+ var _orig_albedo: Color
88
82
  var _flash_mat: StandardMaterial3D
89
- var _orig_mat: Material
83
+ var _tween: Tween
90
84
 
91
85
  func _ready() -> void:
92
86
  if mesh == null:
93
87
  push_warning("HitFlash: mesh export not assigned")
94
88
  return
95
- _orig_mat = mesh.get_surface_override_material(0)
89
+ # Build a dedicated override material once. Cloning the surface material
90
+ # would also work, but a fresh StandardMaterial3D is simpler and free.
96
91
  _flash_mat = StandardMaterial3D.new()
97
92
  _flash_mat.albedo_color = flash_color
98
93
  _flash_mat.emission_enabled = true
99
94
  _flash_mat.emission = flash_color
100
95
  _flash_mat.emission_energy_multiplier = 2.0
96
+ _orig_albedo = _flash_mat.albedo_color
97
+ mesh.material_override = null # leave original mesh material in place
101
98
 
102
99
  func flash() -> void:
103
100
  if mesh == null:
104
101
  return
105
- mesh.set_surface_override_material(0, _flash_mat)
106
- var tween := create_tween()
107
- tween.tween_interval(duration)
108
- tween.tween_callback(func(): mesh.set_surface_override_material(0, _orig_mat))
102
+ if _tween and _tween.is_valid():
103
+ _tween.kill() # restart cleanly on rapid re-hits
104
+ mesh.material_override = _flash_mat
105
+ _flash_mat.albedo_color = flash_color
106
+ _tween = create_tween()
107
+ _tween.tween_interval(duration)
108
+ _tween.tween_callback(func() -> void: mesh.material_override = null)
109
109
  ```
110
110
 
111
- Wire the trigger:
111
+ **Tunable knobs:** `duration` 0.06–0.12s — under 0.06 reads as a flicker, over 0.12 looks slow. `emission_energy_multiplier` 1.5–3.0.
112
112
 
113
- ```
114
- summer_connect_signal(from="./World/Enemy", signal="damaged", to="./World/Enemy/HitFlash", method="flash")
115
- ```
113
+ > CRITICAL: do NOT inline a `StandardMaterial3D` as a `sub_resource` via `summer_set_prop`. Build it in script (as above) or save it to `materials/flash.tres`. Inline sub_resources break `summer_set_resource_property` silently — see `_shared/mcp-tools-reference.md` § "Trap".
116
114
 
117
- **Tunable knobs:** `duration` (0.06–0.12 sec — under 0.06 reads as a flicker, over 0.12 looks slow), `emission_energy_multiplier` (1.5–3.0).
115
+ ## Step 5 — Install Section 2 (Trauma Camera Shake)
118
116
 
119
- > CRITICAL: do NOT inline the StandardMaterial3D as a sub_resource via `summer_set_prop`. Build it in script (as above) or save it to `materials/flash.tres` and load. Inline sub_resources break `summer_set_resource_property` silently — see `_shared/mcp-tools-reference.md` § "Trap".
117
+ **Goal:** any system can `add_trauma(amount)` to the camera. The camera accumulates trauma 0–1, decays it every frame, and shakes by `trauma² × random_offset`. The square is the trick: a 0.3 hit shakes very little (0.09), a 0.8 hit shakes a *lot* (0.64). Big hits feel huge, small hits don't pollute the screen.
120
118
 
121
- ### Recipe B — Screen shake (Camera3D wobble)
119
+ **Approach:** put the shake on the existing Camera3D. Two variables — `_trauma` (0–1 accumulator) and `_shake_offset` (last frame's applied rotation). Every frame, **subtract last frame's offset first** so the next offset doesn't drift, then apply the new one. This is the non-obvious trick.
122
120
 
123
- **Goal:** punchy 40–80 ms camera shake on impact. Ties hits to the player's body.
124
-
125
- **Approach:** attach a shake script to the existing Camera3D. Don't spawn a new camera; reuse the player's.
121
+ **Node tree:** the script attaches to the existing `./World/Player/Camera3D`. No new nodes.
126
122
 
127
123
  ```
128
- summer_inspect_node "./World/Player/Camera3D" # confirm the path
124
+ summer_inspect_node "./World/Player/Camera3D"
125
+ # attach scripts/vfx/camera_shake.gd
129
126
  ```
130
127
 
131
- Script (`scripts/camera_shake.gd`):
128
+ `scripts/vfx/camera_shake.gd`:
132
129
 
133
130
  ```gdscript
134
131
  class_name CameraShake
135
132
  extends Camera3D
136
133
 
137
- @export var trauma_decay: float = 5.0
138
- @export var max_offset: Vector2 = Vector2(0.15, 0.15)
139
- @export var max_roll: float = 0.05
134
+ @export var trauma_decay: float = 3.0 # how fast trauma bleeds off (per second)
135
+ @export var max_shake_angle_deg: float = 2.5 # max pitch at trauma = 1.0
136
+ @export var yaw_factor: float = 0.5 # less yaw than pitch (eye anatomy)
140
137
 
141
138
  var _trauma: float = 0.0
142
- var _base_position: Vector3
143
-
144
- func _ready() -> void:
145
- _base_position = position
139
+ var _shake_offset: Vector3 = Vector3.ZERO
146
140
 
147
141
  func add_trauma(amount: float) -> void:
148
142
  _trauma = clamp(_trauma + amount, 0.0, 1.0)
149
143
 
150
144
  func _process(delta: float) -> void:
145
+ # Always undo last frame's offset BEFORE deciding the next.
146
+ # Without this, rotation drifts every frame trauma is non-zero.
147
+ if _shake_offset != Vector3.ZERO:
148
+ rotation -= _shake_offset
149
+ _shake_offset = Vector3.ZERO
150
+
151
151
  if _trauma <= 0.0:
152
- position = _base_position
153
- rotation.z = 0.0
154
152
  return
155
- var shake := _trauma * _trauma # quadratic — feels punchy, not noisy
156
- position = _base_position + Vector3(
157
- randf_range(-max_offset.x, max_offset.x) * shake,
158
- randf_range(-max_offset.y, max_offset.y) * shake,
153
+
154
+ var shake := _trauma * _trauma # quadratic — small hits barely shake
155
+ var max_angle := deg_to_rad(max_shake_angle_deg) * shake
156
+
157
+ _shake_offset = Vector3(
158
+ randf_range(-max_angle, max_angle),
159
+ randf_range(-max_angle, max_angle) * yaw_factor,
159
160
  0.0
160
161
  )
161
- rotation.z = randf_range(-max_roll, max_roll) * shake
162
- _trauma = max(_trauma - trauma_decay * delta, 0.0)
162
+ rotation += _shake_offset
163
+ _trauma = max(0.0, _trauma - trauma_decay * delta)
163
164
  ```
164
165
 
165
- Trigger from the player or any damage handler:
166
+ **Calling it:**
166
167
 
167
168
  ```gdscript
168
- $Camera3D.add_trauma(0.5) # 0.3 light hit, 0.5 normal, 0.8 heavy
169
+ $Camera3D.add_trauma(0.3) # light hit (parry, glancing blow)
170
+ $Camera3D.add_trauma(0.5) # standard combat hit
171
+ $Camera3D.add_trauma(0.8) # heavy / explosion
169
172
  ```
170
173
 
171
- **Tunable knobs:** `max_offset` (0.05 subtle, 0.2 punchy, 0.4 cinematic), `trauma_decay` (3 long, 5 standard, 8 snappy). Quadratic falloff is the trick — linear feels twitchy.
174
+ **Tunable knobs:** `trauma_decay` 3.0 (long-tail, cinematic) ↔ 6.0 (snappy, twitch-shooter feel). `max_shake_angle_deg` 2.0 (subtle) ↔ 4.0 (action-game). Keep `yaw_factor` ≤ 0.5 — equal pitch/yaw shake feels uniform and wrong; eyes are far more sensitive to vertical motion.
172
175
 
173
- ### Recipe C — Pickup sparkle (GPUParticles3D one-shot)
176
+ ## Step 6 — Install Section 3 (Audio Ducking)
174
177
 
175
- **Goal:** when a pickup is collected, emit a quick burst of particles where it disappeared.
178
+ This is the system most projects skip and the one that makes the biggest perceived difference. On a hit, briefly attenuate Music + Ambient + Announcer (whatever non-impact buses you have) so the SFX bus stabs through the mix. Then smoothly tween volumes back. **Crucially: store original bus volumes once at startup, and apply ducking as an offset.** Snapping volumes to absolute values destroys the user's volume preferences.
176
179
 
177
- **Approach:** GPUParticles3D set to `one_shot = true`, `emitting = true` triggered from script. Particle material **must** be a standalone `.tres` (inline sub_resource trap).
180
+ **Approach:** an autoload (`AudioDucker`) so any system can call `AudioDucker.duck(0.5, 0.4)` from anywhere. Internally:
178
181
 
179
- ```
180
- summer_add_node(parent="./World/Pickup", type="GPUParticles3D", name="Sparkle")
181
- summer_set_prop(path="./World/Pickup/Sparkle", key="one_shot", value="true")
182
- summer_set_prop(path="./World/Pickup/Sparkle", key="amount", value="24")
183
- summer_set_prop(path="./World/Pickup/Sparkle", key="lifetime", value="0.5")
184
- summer_set_prop(path="./World/Pickup/Sparkle", key="explosiveness", value="0.95")
182
+ - `_initial_volumes: Dictionary[StringName, float]` captured once in `_ready()` from `AudioServer.get_bus_volume_db()` for each named bus.
183
+ - `duck(amount, duration)` lowers each non-impact bus by `amount × per_bus_weight`, scheduled via Tween, and tweens back over `duration`.
184
+ - Per-bus weights let Music duck more than Ambient (Music 0.9 = -90% intensity, Ambient 0.7, Announcer 0.5).
185
+ - Kill any in-flight tween before starting a new one so rapid-fire hits don't compound the duck and silence the mix.
186
+
187
+ **Node tree:** none — register the script as an autoload.
188
+
189
+ `scripts/vfx/audio_ducker.gd`:
190
+
191
+ ```gdscript
192
+ extends Node
193
+
194
+ # Bus name -> duck weight (1.0 = full duck, 0.0 = never duck this bus).
195
+ # "SFX" / "UI" / "Voice" should NOT be ducked — those are the buses you want
196
+ # to punch through. Adjust to match your project's default_bus_layout.tres.
197
+ const BUS_WEIGHTS := {
198
+ &"Music": 0.9,
199
+ &"Ambient": 0.7,
200
+ &"Announcer": 0.5,
201
+ }
202
+
203
+ # How loud the bus is "when not ducked", captured once. NEVER overwrite —
204
+ # this is the user's preference and mixer baseline.
205
+ var _initial_volumes: Dictionary = {}
206
+ var _tween: Tween
207
+
208
+ func _ready() -> void:
209
+ for bus_name in BUS_WEIGHTS.keys():
210
+ var idx := AudioServer.get_bus_index(bus_name)
211
+ if idx == -1:
212
+ push_warning("AudioDucker: bus '%s' not found in bus layout" % bus_name)
213
+ continue
214
+ _initial_volumes[bus_name] = AudioServer.get_bus_volume_db(idx)
215
+
216
+ # amount: 0.0–1.0 (0.5 = noticeable, 1.0 = near-silence on weighted buses)
217
+ # duration: total duck-and-restore time, seconds. 0.4–0.6 feels right for hits.
218
+ func duck(amount: float, duration: float = 0.5) -> void:
219
+ if _initial_volumes.is_empty():
220
+ return
221
+ if _tween and _tween.is_valid():
222
+ _tween.kill()
223
+ _tween = create_tween().set_parallel(true)
224
+
225
+ var attack := duration * 0.15 # fast duck-down, slow restore
226
+ var release := duration * 0.85
227
+
228
+ for bus_name in _initial_volumes.keys():
229
+ var idx := AudioServer.get_bus_index(bus_name)
230
+ if idx == -1:
231
+ continue
232
+ var base_db: float = _initial_volumes[bus_name]
233
+ var weight: float = BUS_WEIGHTS.get(bus_name, 0.0)
234
+ # Convert "amount" (0–1) into a dB drop. -24 dB at amount=1, weight=1
235
+ # is "almost gone" without being absolute silence.
236
+ var drop_db := -24.0 * amount * weight
237
+ var target_db := base_db + drop_db
238
+
239
+ var seq := create_tween()
240
+ seq.tween_property(AudioServer, "bus_volume_db", target_db, attack).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)\
241
+ .from(base_db)
242
+ # NOTE: AudioServer doesn't expose bus volume as an animatable
243
+ # property — use a method tween instead. See actual call below.
244
+ # (The simpler, real implementation:)
245
+ _tween.tween_method(_set_bus_db.bind(idx), base_db, target_db, attack)\
246
+ .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
247
+ _tween.tween_method(_set_bus_db.bind(idx), target_db, base_db, release)\
248
+ .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)\
249
+ .set_delay(attack)
250
+
251
+ func _set_bus_db(idx: int, db: float) -> void:
252
+ AudioServer.set_bus_volume_db(idx, db)
185
253
  ```
186
254
 
187
- For the `process_material`, save a `ParticleProcessMaterial.tres` first (see `asset-pipeline/asset-strategy/SKILL.md` for the pattern), then:
255
+ Register it as an autoload:
188
256
 
189
257
  ```
190
- summer_set_prop(path="./World/Pickup/Sparkle", key="process_material", value="res://materials/sparkle_particles.tres")
258
+ Project Settings → Autoload → Add scripts/vfx/audio_ducker.gd as "AudioDucker"
191
259
  ```
192
260
 
193
- In the pickup's collection handler:
261
+ **Calling it:**
194
262
 
195
263
  ```gdscript
196
- func _on_picked_up() -> void:
197
- $Sparkle.restart()
198
- $Sparkle.emitting = true
199
- $Mesh.visible = false
200
- await get_tree().create_timer(0.6).timeout
201
- queue_free()
264
+ AudioDucker.duck(0.5, 0.4) # standard hit
265
+ AudioDucker.duck(0.8, 0.6) # heavy / boss hit
266
+ AudioDucker.duck(1.0, 1.2) # cinematic — explosion or boss intro
202
267
  ```
203
268
 
204
- `explosiveness = 0.95` is the difference between a continuous trickle and a satisfying *poof*. `0.0` = constant emission, `1.0` = all particles spawn at frame zero.
269
+ **Why offset-not-absolute matters:** if the user has Music slider at -10 dB in your settings menu, and ducking writes `set_bus_volume_db("Music", -24.0)` directly, the *restore* tween will pull Music up to -24's "neutral" which is louder than the user wanted. Capturing `_initial_volumes` at autoload `_ready()` and only ever offsetting from those values preserves the user's mix forever.
270
+
271
+ ## Step 7 — Wire the trio to one signal
205
272
 
206
- ### Recipe D — Muzzle flash (light + sprite + sound)
273
+ The whole point: one hit, all three fire on the same frame. Keep this in the hurt body's damage handler — *not* on a global event bus, because you want the trauma amount and flash to scale per-hit.
274
+
275
+ ```gdscript
276
+ # scripts/enemy.gd (or wherever you handle damage on the body that got hit)
277
+ extends CharacterBody3D
278
+
279
+ signal damaged(amount: float)
280
+
281
+ @onready var _hit_flash: HitFlash = $HitFlash
282
+ @onready var _player_camera: CameraShake = get_tree().get_first_node_in_group("player_camera")
283
+
284
+ func take_damage(amount: float) -> void:
285
+ # ... your existing health subtract / death check ...
286
+ damaged.emit(amount)
287
+ _on_damaged(amount)
288
+
289
+ func _on_damaged(amount: float) -> void:
290
+ # 1. Hit flash on this body.
291
+ _hit_flash.flash()
292
+ # 2. Camera shake on the player's camera, scaled by hit weight.
293
+ var trauma := clamp(0.2 + amount * 0.05, 0.2, 0.9)
294
+ if _player_camera:
295
+ _player_camera.add_trauma(trauma)
296
+ # 3. Audio duck, scaled by hit weight.
297
+ var duck_amount := clamp(0.3 + amount * 0.04, 0.3, 1.0)
298
+ AudioDucker.duck(duck_amount, 0.45)
299
+ ```
207
300
 
208
- **Goal:** when a weapon fires, a brief OmniLight3D flash + Sprite3D billboard + audio cue. Sells the shot more than the projectile does.
301
+ Add the camera to a group so any actor can find it:
209
302
 
210
303
  ```
211
- summer_add_node(parent="./World/Player/Weapon", type="OmniLight3D", name="MuzzleLight")
212
- summer_set_prop(path="./World/Player/Weapon/MuzzleLight", key="light_color", value="Color(1.0, 0.85, 0.4)")
213
- summer_set_prop(path="./World/Player/Weapon/MuzzleLight", key="light_energy", value="0.0")
214
- summer_set_prop(path="./World/Player/Weapon/MuzzleLight", key="omni_range", value="2.5")
304
+ summer_add_node(...) # ./World/Player/Camera3D already exists
305
+ summer_set_prop(path="./World/Player/Camera3D", key="groups", value='["player_camera"]')
215
306
  ```
216
307
 
217
- Trigger from the fire function:
308
+ Or wire the signal explicitly via MCP if you prefer:
218
309
 
219
- ```gdscript
220
- func fire() -> void:
221
- var light: OmniLight3D = $MuzzleLight
222
- light.light_energy = 4.0
223
- var tween := create_tween()
224
- tween.tween_property(light, "light_energy", 0.0, 0.06)
225
- $MuzzleSound.play()
226
- $Camera3D.add_trauma(0.3) # if camera shake recipe applied
310
+ ```
311
+ summer_connect_signal(from="./World/Enemy", signal="damaged", to="./World/Enemy/HitFlash", method="flash")
227
312
  ```
228
313
 
229
- Do NOT keep the light always-on at low energy — that costs a real-time light slot. Tween from 4.0 to 0.0 over 60 ms.
314
+ ## Step 8 — Scaling intensity per hit weight
230
315
 
231
- ### Recipe E — Explosion (5-element stack)
316
+ The trio's whole magic is that the same code makes a glancing parry feel different from a critical hit. Calibrate the curve once:
232
317
 
233
- **Goal:** one Big Boom, all senses. Stack five small effects on the same trigger.
318
+ | Hit type | `damage` (HP) | trauma | duck amount |
319
+ |---|---|---|---|
320
+ | Glance / parry | 1–3 | 0.20 | 0.30 |
321
+ | Standard hit | 5–10 | 0.35 | 0.50 |
322
+ | Heavy hit | 15–25 | 0.55 | 0.70 |
323
+ | Critical / boss | 30–50 | 0.75 | 0.85 |
324
+ | Explosion / death | 50+ | 0.90 | 1.00 |
234
325
 
235
- 1. **Light flash:** OmniLight3D, `light_energy` 0 → 8 → 0 over 200 ms
236
- 2. **Particles:** GPUParticles3D one-shot, `amount` 64, `lifetime` 0.8, `explosiveness` 1.0
237
- 3. **Camera shake:** `add_trauma(0.8)`
238
- 4. **Audio:** AudioStreamPlayer3D, layered explosion
239
- 5. **Hit-stop:** `Engine.time_scale = 0.05` for 60 ms, then 1.0
326
+ Because trauma is squared inside the camera, the *felt* difference between 0.35 (→ 0.12 shake) and 0.75 (→ 0.56 shake) is roughly 5×. That's the whole point — small hits stay clean, big hits saturate the screen.
240
327
 
241
- Hit-stop is the secret weapon. 60 ms of 0.05 time scale = the explosion lands. Use sparingly; once per "big" event. Reset with:
328
+ ## Step 9 — Verify
242
329
 
243
- ```gdscript
244
- Engine.time_scale = 0.05
245
- await get_tree().create_timer(0.06).timeout
246
- Engine.time_scale = 1.0
247
330
  ```
331
+ summer_save_scene
332
+ summer_get_script_errors
333
+ summer_play
334
+ # user reproduces a hit
335
+ summer_stop
336
+ ```
337
+
338
+ Tune one knob at a time:
339
+
340
+ - Flash invisible → `emission_energy_multiplier` 1.5 → 3.0, or check that the mesh actually has a material to override.
341
+ - Shake feels jittery → reduce `max_shake_angle_deg`, or check `yaw_factor` is ≤ 0.5.
342
+ - Ducking inaudible → check `_initial_volumes` got populated (autoload running before the first hit?), and that `Music`/`Ambient` bus names actually match your `default_bus_layout.tres`.
343
+ - Ducking *too loud* → reduce per-bus weight, not the `amount` argument. Weights are per-project; amount is per-hit.
248
344
 
249
- ## Anti-patterns
345
+ ## Common mistakes
250
346
 
251
347
  | Don't | Do | Why |
252
348
  |---|---|---|
253
- | Inline sub_resource for ParticleProcessMaterial via `summer_set_prop` | Save standalone `.tres`, reference by path | `summer_set_resource_property` silently fails on inline sub_resources |
254
- | Hit flash for 0.3 sec | Hit flash for 0.06–0.12 sec | Anything over ~120 ms reads as a "stay" not a flash |
255
- | Camera shake with linear falloff | Quadratic falloff (`_trauma * _trauma`) | Linear feels jittery; quadratic feels weighty |
256
- | `OmniLight3D` always on at low energy | Tween from off → on → off in 60–80 ms | Real-time lights have hard count limits per frame |
257
- | GPUParticles3D `one_shot = false` for pickups | `one_shot = true`, `restart()` to fire | Otherwise particles trickle continuously and look wrong |
258
- | Tween every property at once | Tween one or two; let the others snap | Over-animated polish reads as noise |
259
- | `Engine.time_scale` < 0.1 for > 100 ms | `time_scale = 0.05` for 40–80 ms | Longer hit-stop feels broken, not satisfying |
260
- | Particles with `lifetime > 1.0` for hits | `lifetime = 0.3–0.6` | Hit feedback should resolve before the next swing |
349
+ | `AudioServer.set_bus_volume_db("Music", -24)` directly | Capture `_initial_volumes` once, apply ducking as an offset | Absolute writes destroy the user's volume preferences |
350
+ | Linear shake (`shake = trauma`) | Quadratic (`shake = trauma * trauma`) | Linear feels uniform, twitchy. Quadratic separates small hits from big hits |
351
+ | Apply new shake offset without subtracting last frame's | Subtract `_shake_offset`, zero it, then write the new one | Otherwise rotation drifts every frame trauma > 0 |
352
+ | Hit flash with `await get_tree().create_timer(...)` | `create_tween().tween_callback(...)` | A timer can't be cancelled mid-flight on rapid re-hits, leaving albedo broken white |
353
+ | Only one bus ("Master") in the project | Add at least `Music`, `Ambient`, `SFX` and route SFX through SFX bus | Ducking does nothing if the impact SFX is on the same bus you're ducking |
354
+ | Duck for 1.5+ seconds | 0.4–0.6 second total duck-and-restore | Long ducks make the music feel broken, not punchy |
355
+ | Equal pitch/yaw shake | `yaw_factor ≤ 0.5` | Eyes are sensitive to vertical motion; equal axes feel mechanical |
356
+ | `Engine.time_scale` for every hit | Reserve hit-stop for big events only (boss hits, deaths) | Time-scale dips on every hit feel laggy, not impactful |
357
+ | Inline `StandardMaterial3D` sub_resource via `summer_set_prop` | Build in script or save standalone `.tres` | Inline sub_resources silently break `summer_set_resource_property` |
261
358
 
262
359
  ## Collaborative protocol
263
360
 
264
- This skill writes scripts and adds nodes. Always ask before each recipe is applied. Group related writes into one ask: "I'm about to add HitFlash + CameraShake + connect 2 signals. OK?" See `_shared/collaborative-protocol.md`.
361
+ This skill writes 3 GDScript files and adds 1 node + 1 autoload entry. Always ask before each section is applied. Group related writes into one ask: "I'm about to add HitFlash + CameraShake + AudioDucker autoload, wire one signal. OK?" See `_shared/collaborative-protocol.md`.
265
362
 
266
- ## Want a working starter?
363
+ ## When NOT to use this skill
267
364
 
268
- No template — this is a workflow that bolts onto any existing scene. For a project that already has these recipes pre-wired, see `template-3d-fps` (hit flash + muzzle flash) and `template-3d-platformer` (pickup sparkle).
365
+ - **2D games** — the trauma curve and dB ducking still apply, but the camera-shake script needs a `Camera2D` rewrite (`offset` instead of `rotation`). Use this skill as a reference, not a drop-in.
366
+ - **Turn-based / strategy** — game-feel here lives in UI animation, not body-hit feedback. Skip the trio; reach for tween-based UI polish instead.
367
+ - **No audio buses configured** — ducking with one Master bus is a no-op. Set up `Music` / `Ambient` / `SFX` first via the audio-direction skill, then come back.
368
+ - **Networked games where the hurt body is remote** — the flash should run client-side on visual hit confirm, not on damage authority. Hook to a `hit_visual_received` signal, not the authoritative damage signal.
269
369
 
270
370
  ## See also
271
371
 
@@ -273,8 +373,7 @@ No template — this is a workflow that bolts onto any existing scene. For a pro
273
373
  - `_shared/godot-version.md` — Godot 4.5 API notes
274
374
  - `_shared/collaborative-protocol.md` — "May I write" pattern
275
375
  - `_shared/gd-style.md` — typed GDScript conventions
276
- - `post-processing/screen-shake/SKILL.md` — deeper camera-shake techniques (Perlin noise, trauma stacks)
277
- - `visual-effects/gpuparticles-3d-basics/SKILL.md` — particle setup details
278
- - `visual-effects/hit-impact-flashes/SKILL.md` — flash material variations
279
- - `visual-effects/muzzle-flashes/SKILL.md` — muzzle flash deep dive
280
- - `audio/sfx-pipeline/SKILL.md` — pairing audio with VFX
376
+ - `audio/audio-direction/SKILL.md` — bus layout setup (prerequisite for ducking)
377
+ - `post-processing/screen-shake/SKILL.md` — deeper trauma variants (Perlin noise, stacked sources)
378
+ - `visual-effects/gpuparticles-3d-basics/SKILL.md` — particles to layer on top of the trio
379
+ - `visual-effects/hit-impact-flashes/SKILL.md` — flash material variations beyond white pulse