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

orion-ecs / keen-eye / 30127990464

24 Jul 2026 09:30PM UTC coverage: 64.781% (-0.007%) from 64.788%
30127990464

push

github

tyevco
feat(samples): Add NOVAFALL Phase A - playable core of the Fall Down reimagining

New flagship 2D arcade sample: a gravity-bound ball steered LEFT/RIGHT only,
dropping through gaps in floors that scroll upward toward a lethal Furnace
ceiling. Clean gap-throughs stoke a Heat resource through four tiers
(Ember/Flame/Plasma/Nova) gating the score multiplier (x1/x2/x4/x8); landing
halves heat, resting bleeds it, only death resets it. Score is meters fallen
times the live multiplier, integrated per frame.

- One system per concern with explicit Update-phase orders; render via
  I2DRenderer resolved lazily through TryGetExtension (UIRenderSystem pattern)
- Simulation runs in a fixed 720x1080 design space, fully decoupled from the
  window; TimeScale singleton hook for later hitstop
- Floor layout is a pure function of (seed, floorIndex) via a self-contained
  SplitMix64 generator (KEEN030-compliant), shared by gameplay and the
  headless '--simulate <frames>' determinism harness (byte-identical output
  across runs, verified for two seeds)
- Teaching comment in CollisionSystem on when a spatial index would (not)
  earn its keep

8750 of 12782 branches covered (68.46%)

Branch coverage included in aggregate %.

51895 of 80833 relevant lines covered (64.2%)

1.0 hits per line

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

95.77
/src/KeenEyes.Particles/Systems/ParticleRenderSystem.cs
1
using System.Numerics;
2
using KeenEyes.Common;
3
using KeenEyes.Graphics.Abstractions;
4
using KeenEyes.Particles.Components;
5
using KeenEyes.Particles.Data;
6

7
namespace KeenEyes.Particles.Systems;
8

9
/// <summary>
10
/// System that renders all particles.
11
/// </summary>
12
/// <remarks>
13
/// <para>
14
/// This system runs in the Render phase and draws all active particles
15
/// using the 2D renderer. Particles are grouped by blend mode for efficient
16
/// batching.
17
/// </para>
18
/// <para>
19
/// If a particle has a valid texture, it uses
20
/// <see cref="I2DRenderer.DrawTextureRotated(TextureHandle, in Rectangle, float, Vector2, Vector4?)"/>
21
/// (or the sprite-sheet overload when a texture sheet is configured).
22
/// Otherwise, it falls back to <see cref="I2DRenderer.FillCircle"/>.
23
/// </para>
24
/// </remarks>
25
public sealed class ParticleRenderSystem : SystemBase
26
{
27
    private ParticleManager? manager;
28
    private I2DRenderer? renderer;
29

30
    /// <inheritdoc/>
31
    protected override void OnInitialize()
32
    {
33
        if (World.TryGetExtension<ParticleManager>(out var pm))
1✔
34
        {
35
            manager = pm;
1✔
36
        }
37

38
        if (World.TryGetExtension<I2DRenderer>(out var r))
1✔
39
        {
40
            renderer = r;
1✔
41
        }
42
    }
1✔
43

44
    /// <inheritdoc/>
45
    public override void Update(float deltaTime)
46
    {
47
        var pm = manager;
1✔
48
        if (pm == null)
1✔
49
        {
50
            if (!World.TryGetExtension<ParticleManager>(out pm) || pm is null)
1✔
51
            {
52
                return;
1✔
53
            }
54
            manager = pm;
1✔
55
        }
56

57
        var r = renderer;
1✔
58
        if (r == null)
1✔
59
        {
60
            if (!World.TryGetExtension<I2DRenderer>(out r) || r is null)
1✔
61
            {
62
                return;
1✔
63
            }
64
            renderer = r;
1✔
65
        }
66

67
        // Group emitters by blend mode for efficient batching
68
        // We'll render alpha-blended first, then additive (so glow effects overlay)
69
        var alphaEmitters = new List<RenderEntry>();
1✔
70
        var additiveEmitters = new List<RenderEntry>();
1✔
71
        var multiplyEmitters = new List<RenderEntry>();
1✔
72
        var premultipliedEmitters = new List<RenderEntry>();
1✔
73

74
        foreach (var entity in World.Query<ParticleEmitter>())
1✔
75
        {
76
            ref readonly var emitter = ref World.Get<ParticleEmitter>(entity);
1✔
77
            var pool = pm.GetPool(entity);
1✔
78
            if (pool == null || pool.ActiveCount == 0)
1✔
79
            {
80
                continue;
81
            }
82

83
            // Local-space particles are stored relative to the emitter, so translate them
84
            // by the emitter's current position at render time. World-space particles use
85
            // absolute coordinates and need no offset.
86
            var offset = emitter.Space == ParticleSpace.Local
1✔
87
                ? ResolveEmitterPosition(entity)
1✔
88
                : Vector2.Zero;
1✔
89
            var entry = new RenderEntry(pool, emitter, offset);
1✔
90

91
            switch (emitter.BlendMode)
1✔
92
            {
93
                case BlendMode.Alpha:
94
                    alphaEmitters.Add(entry);
1✔
95
                    break;
1✔
96
                case BlendMode.Additive:
97
                    additiveEmitters.Add(entry);
1✔
98
                    break;
1✔
99
                case BlendMode.Multiply:
100
                    multiplyEmitters.Add(entry);
1✔
101
                    break;
1✔
102
                case BlendMode.Premultiplied:
103
                    premultipliedEmitters.Add(entry);
1✔
104
                    break;
105
            }
106
        }
107

108
        // Render in order: multiply -> alpha -> premultiplied -> additive
109
        // (This is a common ordering but can be adjusted based on desired visual results)
110
        RenderBatch(r, multiplyEmitters, BlendMode.Multiply);
1✔
111
        RenderBatch(r, alphaEmitters, BlendMode.Alpha);
1✔
112
        RenderBatch(r, premultipliedEmitters, BlendMode.Premultiplied);
1✔
113
        RenderBatch(r, additiveEmitters, BlendMode.Additive);
1✔
114
    }
1✔
115

116
    private static void RenderBatch(I2DRenderer renderer, List<RenderEntry> emitters, BlendMode blendMode)
117
    {
118
        if (emitters.Count == 0)
1✔
119
        {
120
            return;
1✔
121
        }
122

123
        // Calculate total particles for batch hint
124
        var totalParticles = 0;
1✔
125
        foreach (var entry in emitters)
1✔
126
        {
127
            totalParticles += entry.Pool.ActiveCount;
1✔
128
        }
129

130
        renderer.Begin();
1✔
131
        renderer.SetBlendMode(blendMode);
1✔
132
        renderer.SetBatchHint(totalParticles);
1✔
133

134
        foreach (var entry in emitters)
1✔
135
        {
136
            RenderPool(renderer, entry.Pool, in entry.Emitter, entry.Offset);
1✔
137
        }
138

139
        renderer.End();
1✔
140

141
        // Leave the renderer in the default state for whoever draws next.
142
        if (blendMode != BlendMode.Alpha)
1✔
143
        {
144
            renderer.SetBlendMode(BlendMode.Alpha);
1✔
145
        }
146
    }
1✔
147

148
    private static void RenderPool(I2DRenderer renderer, ParticlePool pool, in ParticleEmitter emitter, Vector2 offset)
149
    {
150
        var texture = emitter.Texture;
1✔
151
        var hasTexture = texture.IsValid;
1✔
152

153
        // A texture sheet is only active when the grid describes more than one frame.
154
        var columns = Math.Max(1, emitter.TextureSheetColumns);
1✔
155
        var rows = Math.Max(1, emitter.TextureSheetRows);
1✔
156
        var frameCount = columns * rows;
1✔
157
        var animated = hasTexture && frameCount > 1;
1✔
158

159
        for (var i = 0; i < pool.Capacity; i++)
1✔
160
        {
161
            if (!pool.Alive[i])
1✔
162
            {
163
                continue;
164
            }
165

166
            var color = new Vector4(
1✔
167
                pool.ColorsR[i],
1✔
168
                pool.ColorsG[i],
1✔
169
                pool.ColorsB[i],
1✔
170
                pool.ColorsA[i]);
1✔
171

172
            var size = pool.Sizes[i];
1✔
173
            var halfSize = size / 2f;
1✔
174
            var posX = pool.PositionsX[i] + offset.X;
1✔
175
            var posY = pool.PositionsY[i] + offset.Y;
1✔
176

177
            if (hasTexture)
1✔
178
            {
179
                var destRect = new Rectangle(
1✔
180
                    posX - halfSize,
1✔
181
                    posY - halfSize,
1✔
182
                    size,
1✔
183
                    size);
1✔
184

185
                if (animated)
1✔
186
                {
187
                    var sourceRect = FrameSourceRect(pool.NormalizedAges[i], columns, rows, frameCount);
1✔
188
                    renderer.DrawTextureRotated(
1✔
189
                        texture,
1✔
190
                        in destRect,
1✔
191
                        in sourceRect,
1✔
192
                        pool.Rotations[i],
1✔
193
                        new Vector2(0.5f, 0.5f),
1✔
194
                        color);
1✔
195
                }
196
                else
197
                {
198
                    renderer.DrawTextureRotated(
1✔
199
                        texture,
1✔
200
                        in destRect,
1✔
201
                        pool.Rotations[i],
1✔
202
                        new Vector2(0.5f, 0.5f),
1✔
203
                        color);
1✔
204
                }
205
            }
206
            else
207
            {
208
                // Fallback: draw as filled circle
209
                renderer.FillCircle(
1✔
210
                    posX,
1✔
211
                    posY,
1✔
212
                    halfSize,
1✔
213
                    color,
1✔
214
                    segments: 8);
1✔
215
            }
216
        }
217
    }
1✔
218

219
    /// <summary>
220
    /// Computes the UV sub-rectangle for the sprite-sheet frame at the given normalized age.
221
    /// </summary>
222
    private static Rectangle FrameSourceRect(float normalizedAge, int columns, int rows, int frameCount)
223
    {
224
        var frame = (int)(normalizedAge * frameCount);
1✔
225
        if (frame < 0)
1✔
226
        {
227
            frame = 0;
×
228
        }
229
        else if (frame >= frameCount)
1✔
230
        {
231
            frame = frameCount - 1;
1✔
232
        }
233

234
        var col = frame % columns;
1✔
235
        var row = frame / columns;
1✔
236
        var frameWidth = 1f / columns;
1✔
237
        var frameHeight = 1f / rows;
1✔
238

239
        return new Rectangle(col * frameWidth, row * frameHeight, frameWidth, frameHeight);
1✔
240
    }
241

242
    private Vector2 ResolveEmitterPosition(Entity entity)
243
    {
244
        if (World.Has<Transform2D>(entity))
1✔
245
        {
246
            ref readonly var transform = ref World.Get<Transform2D>(entity);
1✔
247
            return transform.Position;
1✔
248
        }
249

250
        if (World.Has<Transform3D>(entity))
×
251
        {
252
            ref readonly var transform3D = ref World.Get<Transform3D>(entity);
×
253
            return new Vector2(transform3D.Position.X, transform3D.Position.Y);
×
254
        }
255

256
        return Vector2.Zero;
×
257
    }
258

259
    private readonly struct RenderEntry(ParticlePool pool, ParticleEmitter emitter, Vector2 offset)
260
    {
261
        public readonly ParticlePool Pool = pool;
1✔
262
        public readonly ParticleEmitter Emitter = emitter;
1✔
263
        public readonly Vector2 Offset = offset;
1✔
264
    }
265
}
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