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.
- package/.claude-plugin/plugin.json +18 -10
- package/.codex-plugin/plugin.json +36 -8
- package/.cursor-plugin/plugin.json +19 -11
- package/.opencode/INSTALL.md +69 -0
- package/.opencode/plugins/summer.js +9 -4
- package/CHANGELOG.md +31 -1
- package/README.md +268 -86
- package/dist/bin/summer.js +0 -0
- package/dist/commands/assets.d.ts +2 -0
- package/dist/commands/assets.js +189 -0
- package/dist/commands/create.js +55 -17
- package/dist/commands/debug.d.ts +2 -0
- package/dist/commands/debug.js +127 -0
- package/dist/commands/list.js +24 -5
- package/dist/commands/project.d.ts +2 -0
- package/dist/commands/project.js +170 -0
- package/dist/commands/scene.d.ts +2 -0
- package/dist/commands/scene.js +290 -0
- package/dist/lib/banner.js +2 -2
- package/dist/lib/log-filters.d.ts +53 -0
- package/dist/lib/log-filters.js +130 -0
- package/dist/lib/plugin-manifests.test.d.ts +1 -0
- package/dist/lib/plugin-manifests.test.js +134 -0
- package/dist/lib/remote-templates.d.ts +25 -0
- package/dist/lib/remote-templates.js +86 -0
- package/dist/lib/skills-registry.d.ts +33 -1
- package/dist/lib/skills-registry.js +56 -1
- package/dist/lib/telemetry.d.ts +13 -0
- package/dist/lib/telemetry.js +63 -0
- package/dist/lib/with-engine.d.ts +14 -0
- package/dist/lib/with-engine.js +109 -0
- package/dist/mcp/tools/asset-tools.js +12 -13
- package/dist/mcp/tools/debug-tools.js +32 -9
- package/dist/mcp/tools/with-engine.js +4 -0
- package/gemini-extension.json +2 -2
- package/hooks/pre-commit-doctor.sh +0 -0
- package/hooks/run-hook.cmd +0 -0
- package/hooks/session-start.sh +0 -0
- package/marketplace.json +2 -2
- package/package.json +3 -3
- package/skills/README.md +1 -1
- package/skills/_meta/skill-create/SKILL.md +1 -1
- package/skills/_meta/skill-improve/SKILL.md +1 -1
- package/skills/_meta/skill-test/SKILL.md +1 -1
- package/skills/_meta/using-summer/SKILL.md +130 -0
- package/skills/_tests/specs/design-npc.md +43 -12
- package/skills/_tests/specs/fps-controller.md +33 -20
- package/skills/_tests/specs/host-authoritative-state.md +104 -0
- package/skills/_tests/specs/vfx.md +56 -38
- package/skills/ai-and-npcs/design-npc/SKILL.md +264 -122
- package/skills/asset-pipeline/asset-strategy/SKILL.md +1 -1
- package/skills/catalog.yaml +21 -4
- package/skills/character-controllers/fps-controller/SKILL.md +260 -79
- package/skills/debugging/debug/SKILL.md +102 -82
- package/skills/multiplayer-and-networking/host-authoritative-state/SKILL.md +332 -0
- package/skills/rendering-and-lighting/3d-lighting/SKILL.md +1 -1
- package/skills/scene-and-project/browse-templates/SKILL.md +105 -0
- package/skills/scene-and-project/make-game/SKILL.md +159 -346
- package/skills/scene-and-project/new-project/SKILL.md +106 -0
- package/skills/scene-and-project/scene-composition/SKILL.md +1 -1
- package/skills/scripting-patterns/gdscript-patterns/SKILL.md +1 -1
- package/skills/ui-and-ux/ui-basics/SKILL.md +1 -1
- 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", "
|
|
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 —
|
|
12
|
+
# /vfx — The Trio That Makes Games Feel Punchy
|
|
13
13
|
|
|
14
|
-
|
|
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
|
-
##
|
|
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 /
|
|
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** —
|
|
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
|
-
|
|
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/
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
40
|
+
If the only bus is `Master`, ducking will do nothing useful. See *Common mistakes* below.
|
|
46
41
|
|
|
47
|
-
|
|
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
|
-
|
|
44
|
+
## Step 3 — Propose the bundle
|
|
50
45
|
|
|
51
|
-
|
|
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
|
-
|
|
50
|
+
## Step 4 — Install Section 1 (Hit Flash)
|
|
62
51
|
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
56
|
+
**Node tree:**
|
|
68
57
|
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
+
## Step 5 — Install Section 2 (Trauma Camera Shake)
|
|
118
116
|
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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"
|
|
124
|
+
summer_inspect_node "./World/Player/Camera3D"
|
|
125
|
+
# attach scripts/vfx/camera_shake.gd
|
|
129
126
|
```
|
|
130
127
|
|
|
131
|
-
|
|
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 =
|
|
138
|
-
@export var
|
|
139
|
-
@export var
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
162
|
-
_trauma = max(_trauma - trauma_decay * delta
|
|
162
|
+
rotation += _shake_offset
|
|
163
|
+
_trauma = max(0.0, _trauma - trauma_decay * delta)
|
|
163
164
|
```
|
|
164
165
|
|
|
165
|
-
|
|
166
|
+
**Calling it:**
|
|
166
167
|
|
|
167
168
|
```gdscript
|
|
168
|
-
$Camera3D.add_trauma(0.
|
|
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:** `
|
|
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
|
-
|
|
176
|
+
## Step 6 — Install Section 3 (Audio Ducking)
|
|
174
177
|
|
|
175
|
-
|
|
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:**
|
|
180
|
+
**Approach:** an autoload (`AudioDucker`) so any system can call `AudioDucker.duck(0.5, 0.4)` from anywhere. Internally:
|
|
178
181
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
255
|
+
Register it as an autoload:
|
|
188
256
|
|
|
189
257
|
```
|
|
190
|
-
|
|
258
|
+
Project Settings → Autoload → Add scripts/vfx/audio_ducker.gd as "AudioDucker"
|
|
191
259
|
```
|
|
192
260
|
|
|
193
|
-
|
|
261
|
+
**Calling it:**
|
|
194
262
|
|
|
195
263
|
```gdscript
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
+
Add the camera to a group so any actor can find it:
|
|
209
302
|
|
|
210
303
|
```
|
|
211
|
-
summer_add_node(
|
|
212
|
-
summer_set_prop(path="./World/Player/
|
|
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
|
-
|
|
308
|
+
Or wire the signal explicitly via MCP if you prefer:
|
|
218
309
|
|
|
219
|
-
```
|
|
220
|
-
|
|
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
|
-
|
|
314
|
+
## Step 8 — Scaling intensity per hit weight
|
|
230
315
|
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
345
|
+
## Common mistakes
|
|
250
346
|
|
|
251
347
|
| Don't | Do | Why |
|
|
252
348
|
|---|---|---|
|
|
253
|
-
|
|
|
254
|
-
|
|
|
255
|
-
|
|
|
256
|
-
|
|
|
257
|
-
|
|
|
258
|
-
|
|
|
259
|
-
|
|
|
260
|
-
|
|
|
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
|
|
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
|
-
##
|
|
363
|
+
## When NOT to use this skill
|
|
267
364
|
|
|
268
|
-
|
|
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
|
-
- `
|
|
277
|
-
- `
|
|
278
|
-
- `visual-effects/
|
|
279
|
-
- `visual-effects/
|
|
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
|