• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orion-ecs / keen-eye / 30134259809

24 Jul 2026 11:37PM UTC coverage: 63.876% (-0.9%) from 64.782%
30134259809

Pull #1255

github

web-flow
Merge ceba7d2f5 into 073a2682b
Pull Request #1255: feat(samples): Add NOVAFALL Phase C — floor personalities, Flashover, Adrenaline Save, modes, persistence, TestBridge

8871 of 13117 branches covered (67.63%)

Branch coverage included in aggregate %.

305 of 852 new or added lines in 23 files covered. (35.8%)

6 existing lines in 4 files now uncovered.

52559 of 83053 relevant lines covered (63.28%)

1.0 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

3.14
/samples/KeenEyes.Sample.NovaFall/Systems/NovaFallAudioSystem.cs
1
using KeenEyes.Audio.Abstractions;
2

3
namespace KeenEyes.Sample.NovaFall;
4

5
/// <summary>
6
/// Owns every sound in the game: four synced music stems cross-faded by heat
7
/// tier and the Flashover Surge, a fall-speed-pitched wind loop, a
8
/// crush-proximity rumble, event one-shots (graze ting, tier-up swell, smash
9
/// crunch, brittle crackle, bumper boing, death impact), the Adrenaline Save's
10
/// muffled duck, and the 400 ms of true silence in the death beat.
11
/// </summary>
12
/// <remarks>
13
/// <para>
14
/// STEM MIXING — the four loops (<c>music-pad</c>, <c>music-pulse</c>,
15
/// <c>music-lead</c>, <c>music-lead-surge</c>) are exactly the same length and
16
/// tempo and are started on the Music channel in the same frame, so they stay
17
/// sample-locked forever. Intensity comes from mixing, not switching: the pad
18
/// always plays, the pulse fades in at Flame, the lead at Plasma — and a
19
/// Flashover Surge cross-fades the ordinary lead OUT and its surge variant IN,
20
/// so even the event swap is a mix move, never a restart. Ember Garden's
21
/// <see cref="StemMix.PadOnly"/> setting mutes everything but a ducked pad.
22
/// </para>
23
/// <para>
24
/// PITCH AS PARAMETER — with WAV-only audio, per-sound pitch is the expressive
25
/// axis: wind pitch maps to fall speed, the graze ting steps up per consecutive
26
/// graze, and the smash crunch scales with impact speed.
27
/// </para>
28
/// <para>
29
/// All fades use real delta time. Missing asset files or a missing audio
30
/// context disable the system gracefully (one console warning, then silence).
31
/// </para>
32
/// </remarks>
33
public sealed class NovaFallAudioSystem : SystemBase
34
{
35
    private IAudioContext? audio;
36

37
    private AudioClipHandle padClip;
38
    private AudioClipHandle pulseClip;
39
    private AudioClipHandle leadClip;
40
    private AudioClipHandle surgeLeadClip;
41
    private AudioClipHandle windClip;
42
    private AudioClipHandle rumbleClip;
43
    private AudioClipHandle tingClip;
44
    private AudioClipHandle swellClip;
45
    private AudioClipHandle crunchClip;
46
    private AudioClipHandle crackleClip;
47
    private AudioClipHandle boingClip;
48
    private AudioClipHandle impactClip;
49

50
    private SoundHandle padSound;
51
    private SoundHandle pulseSound;
52
    private SoundHandle leadSound;
53
    private SoundHandle surgeLeadSound;
54
    private SoundHandle windSound;
55
    private SoundHandle rumbleSound;
56

57
    private float padVolume;
58
    private float pulseVolume;
59
    private float leadVolume;
60
    private float surgeLeadVolume;
61

62
    private bool loadAttempted;
63
    private bool loaded;
64
    private bool loopsActive;
65
    private bool deathImpactPlayed;
66
    private bool silenced;
67

68
    /// <inheritdoc />
69
    public override void Update(float deltaTime)
70
    {
71
        var juice = World.GetSingleton<JuiceConfig>();
1✔
72
        if (!juice.PresentationAvailable
1✔
73
            || !World.TryGetExtension<IAudioContext>(out var context) || context is null)
1✔
74
        {
75
            return;
1✔
76
        }
77

78
        audio = context;
×
79
        if (!EnsureClipsLoaded())
×
80
        {
81
            return;
×
82
        }
83

84
        var phase = World.GetSingleton<GameState>().Phase;
×
85
        ref readonly var death = ref World.GetSingleton<DeathSequenceState>();
×
86

87
        if (phase == GamePhase.Dead)
×
88
        {
89
            UpdateDeathBeat(in death, juice.Enabled);
×
90
            return;
×
91
        }
92

93
        deathImpactPlayed = false;
×
94
        silenced = false;
×
95

96
        if (phase != GamePhase.Playing)
×
97
        {
98
            return;
×
99
        }
100

101
        if (!loopsActive)
×
102
        {
103
            StartLoops();
×
104
        }
105

106
        UpdateStemMix(deltaTime, juice.Enabled);
×
107
        UpdateWindAndRumble(juice.Enabled);
×
108
        PlayEventOneShots(juice.Enabled);
×
109
    }
×
110

111
    private bool EnsureClipsLoaded()
112
    {
113
        if (loadAttempted)
×
114
        {
115
            return loaded;
×
116
        }
117

118
        loadAttempted = true;
×
119
        try
120
        {
121
            padClip = LoadClip("music-pad.wav");
×
122
            pulseClip = LoadClip("music-pulse.wav");
×
123
            leadClip = LoadClip("music-lead.wav");
×
NEW
124
            surgeLeadClip = LoadClip("music-lead-surge.wav");
×
125
            windClip = LoadClip("wind-loop.wav");
×
126
            rumbleClip = LoadClip("crush-rumble.wav");
×
127
            tingClip = LoadClip("graze-ting.wav");
×
128
            swellClip = LoadClip("tier-up-swell.wav");
×
129
            crunchClip = LoadClip("smash-crunch.wav");
×
NEW
130
            crackleClip = LoadClip("brittle-crackle.wav");
×
NEW
131
            boingClip = LoadClip("bumper-boing.wav");
×
132
            impactClip = LoadClip("death-impact.wav");
×
133
            loaded = true;
×
134
        }
×
135
        catch (Exception ex) when (ex is IOException or AudioLoadException)
×
136
        {
137
            Console.WriteLine($"Audio disabled - could not load sound assets: {ex.Message}");
×
138
        }
×
139

140
        return loaded;
×
141
    }
142

143
    private AudioClipHandle LoadClip(string fileName)
144
        => audio!.LoadClip(Path.Combine(AppContext.BaseDirectory, "Assets", "Audio", fileName));
×
145

146
    /// <summary>
147
    /// Starts every loop in the same frame so the stems stay sample-locked.
148
    /// Everything except the pad starts at zero volume and is mixed in later.
149
    /// </summary>
150
    private void StartLoops()
151
    {
152
        var music = PlaybackOptions.Default with { Loop = true, Channel = AudioChannel.Music };
×
153
        padSound = audio!.Play(padClip, music with { Volume = 0.75f });
×
154
        pulseSound = audio.Play(pulseClip, music with { Volume = 0f });
×
155
        leadSound = audio.Play(leadClip, music with { Volume = 0f });
×
NEW
156
        surgeLeadSound = audio.Play(surgeLeadClip, music with { Volume = 0f });
×
157

158
        var ambient = PlaybackOptions.Default with { Loop = true, Channel = AudioChannel.Ambient, Volume = 0f };
×
159
        windSound = audio.Play(windClip, ambient);
×
160
        rumbleSound = audio.Play(rumbleClip, ambient);
×
161

162
        padVolume = 0.75f;
×
163
        pulseVolume = 0f;
×
164
        leadVolume = 0f;
×
NEW
165
        surgeLeadVolume = 0f;
×
166
        loopsActive = true;
×
167
    }
×
168

169
    private void UpdateStemMix(float deltaTime, bool juiceEnabled)
170
    {
171
        var tier = World.GetSingleton<HeatState>().Tier;
×
NEW
172
        var mix = World.GetSingleton<RunConfig>().Settings.Music;
×
NEW
173
        var surging = World.GetSingleton<SurgeState>().Active;
×
174

175
        float padTarget;
176
        float pulseTarget;
177
        float leadTarget;
178
        float surgeLeadTarget;
179

NEW
180
        if (mix == StemMix.PadOnly)
×
181
        {
182
            // Ember Garden: a ducked pad and nothing else. The other stems keep
183
            // looping silently, so switching modes never breaks the sample lock.
NEW
184
            padTarget = Tuning.EmberPadVolume;
×
NEW
185
            pulseTarget = 0f;
×
NEW
186
            leadTarget = 0f;
×
NEW
187
            surgeLeadTarget = 0f;
×
188
        }
189
        else
190
        {
191
            // Pad always (swelling slightly with tier); pulse from Flame; lead
192
            // from Plasma; Nova pushes everything up. A Flashover Surge swaps
193
            // the lead for its surge variant — as a cross-fade, never a restart.
NEW
194
            padTarget = 0.75f + 0.10f * tier / 3f;
×
NEW
195
            pulseTarget = juiceEnabled && tier >= 1 ? (tier >= 3 ? 0.70f : 0.55f) : 0f;
×
NEW
196
            leadTarget = juiceEnabled && tier >= 2 && !surging ? (tier >= 3 ? 0.65f : 0.50f) : 0f;
×
NEW
197
            surgeLeadTarget = juiceEnabled && surging ? 0.70f : 0f;
×
198
        }
199

200
        padVolume = MoveTowards(padVolume, padTarget, Tuning.StemFadePerSecond * deltaTime);
×
201
        pulseVolume = MoveTowards(pulseVolume, pulseTarget, Tuning.StemFadePerSecond * deltaTime);
×
202
        leadVolume = MoveTowards(leadVolume, leadTarget, Tuning.StemFadePerSecond * deltaTime);
×
NEW
203
        surgeLeadVolume = MoveTowards(surgeLeadVolume, surgeLeadTarget, Tuning.StemFadePerSecond * deltaTime);
×
204

205
        // The Adrenaline Save muffles the music channel — the world holds its
206
        // breath — while the ambient danger cues (wind, rumble) stay honest.
NEW
207
        var duck = World.GetSingleton<AdrenalineState>().Active ? Tuning.AdrenalineMusicDuck : 1f;
×
208

NEW
209
        audio!.SetVolume(padSound, padVolume * duck);
×
NEW
210
        audio.SetVolume(pulseSound, pulseVolume * duck);
×
NEW
211
        audio.SetVolume(leadSound, leadVolume * duck);
×
NEW
212
        audio.SetVolume(surgeLeadSound, surgeLeadVolume * duck);
×
213
    }
×
214

215
    private void UpdateWindAndRumble(bool juiceEnabled)
216
    {
217
        if (!juiceEnabled)
×
218
        {
219
            audio!.SetVolume(windSound, 0f);
×
220
            audio.SetVolume(rumbleSound, 0f);
×
221
            return;
×
222
        }
223

224
        foreach (var entity in World.Query<Ball, Position2D, Velocity2D>())
×
225
        {
226
            ref readonly var position = ref World.Get<Position2D>(entity);
×
227
            ref readonly var velocity = ref World.Get<Velocity2D>(entity);
×
228

229
            // Wind pitch and volume follow fall speed.
230
            var speedFraction = Math.Clamp(velocity.Y / Tuning.MaxFallSpeed, 0f, 1f);
×
231
            audio!.SetPitch(windSound, float.Lerp(Tuning.WindPitchMin, Tuning.WindPitchMax, speedFraction));
×
232
            audio.SetVolume(windSound, 0.05f + 0.55f * speedFraction);
×
233

234
            // Rumble volume follows Furnace proximity.
235
            var ceilingDistance = position.Y - Tuning.CeilingY;
×
236
            var danger = 1f - Math.Clamp(ceilingDistance / Tuning.CrushProximityRange, 0f, 1f);
×
237
            audio.SetVolume(rumbleSound, 0.85f * danger);
×
238
            break;
×
239
        }
240
    }
×
241

242
    private void PlayEventOneShots(bool juiceEnabled)
243
    {
244
        if (!juiceEnabled)
×
245
        {
246
            return;
×
247
        }
248

249
        ref readonly var events = ref World.GetSingleton<FrameEvents>();
×
250

251
        if (events.Grazes > 0)
×
252
        {
253
            // The ting climbs with the graze chain — the audible combo ladder.
254
            var chain = Math.Min(World.GetSingleton<ComboState>().ConsecutiveGrazes, Tuning.GrazePitchCap);
×
255
            audio!.Play(tingClip, PlaybackOptions.Default with
×
256
            {
×
257
                Volume = 0.7f,
×
258
                Pitch = 1f + Tuning.GrazePitchStep * chain,
×
259
            });
×
260
        }
261

262
        if (events.TierChanged && events.TierTo > events.TierFrom)
×
263
        {
264
            audio!.Play(swellClip, PlaybackOptions.Default with { Volume = 0.8f });
×
265
        }
266

267
        if (events.Smashed)
×
268
        {
269
            // Crunch scales with the kinetic energy of the impact.
270
            var impact = Math.Clamp(events.SmashImpactSpeed / Tuning.MaxFallSpeed, 0f, 1f);
×
271
            audio!.Play(crunchClip, PlaybackOptions.Default with
×
272
            {
×
273
                Volume = 0.55f + 0.45f * impact,
×
274
                Pitch = 0.85f + 0.4f * impact,
×
275
            });
×
276
        }
277

NEW
278
        if (events.CrackStarted)
×
279
        {
280
            // The audible half of the Brittle telegraph: this starts a full
281
            // crumble delay (>= 0.6 s) before the floor lets go.
NEW
282
            audio!.Play(crackleClip, PlaybackOptions.Default with { Volume = 0.8f });
×
283
        }
284

NEW
285
        if (events.Crumbled)
×
286
        {
287
            // The crumble reuses the smash crunch, smaller and drier — same
288
            // vocabulary, lower stakes.
NEW
289
            audio!.Play(crunchClip, PlaybackOptions.Default with { Volume = 0.4f, Pitch = 1.2f });
×
290
        }
291

NEW
292
        if (events.Bumped)
×
293
        {
NEW
294
            var impact = Math.Clamp(events.BumpImpactSpeed / Tuning.MaxFallSpeed, 0f, 1f);
×
NEW
295
            audio!.Play(boingClip, PlaybackOptions.Default with
×
NEW
296
            {
×
NEW
297
                Volume = 0.6f + 0.3f * impact,
×
NEW
298
                Pitch = 0.9f + 0.25f * impact,
×
NEW
299
            });
×
300
        }
301
    }
×
302

303
    private void UpdateDeathBeat(in DeathSequenceState death, bool juiceEnabled)
304
    {
305
        if (!deathImpactPlayed && juiceEnabled)
×
306
        {
307
            audio!.Play(impactClip, PlaybackOptions.Default with { Volume = 0.9f });
×
308
            deathImpactPlayed = true;
×
309
        }
310

311
        // True silence: every loop and every ringing one-shot stops at once.
312
        if (death.AudioSilenced && !silenced)
×
313
        {
314
            audio!.StopAll();
×
315
            loopsActive = false;
×
316
            silenced = true;
×
317
        }
318
    }
×
319

320
    private static float MoveTowards(float current, float target, float maxDelta)
321
    {
322
        var delta = target - current;
×
323
        return Math.Abs(delta) <= maxDelta ? target : current + Math.Sign(delta) * maxDelta;
×
324
    }
325
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc