#include "Game_NovaSurvivors.h"

#include <box3d/box3d.h>
#include <imgui.h>
#include <SDL3/SDL.h>

#include "crApp.h"

#include "crAudio.h"
#include "crCamera.h"
#include "crDevConsole.h"
#include "crEcs.h"
#include "crFontAtlas.h"
#include "crGraphics.h"
#include "crGraphicsUniforms.h"
#include "crInputSystem.h"
#include "crMath.h"
#include "crParticleSystem.h"
#include "crPhysics.h"
#include "crPostProcess.h"
#include "crRenderSystem.h"
#include "crRibbonTrailSystem.h"
#include "crUi.h"

static constexpr float HEX_RADIUS    = 56.0f;    // hex arena circumradius; apothem = R x cos(30) ~= 48.5
static constexpr float HEX_APOTHEM   = 48.5f;
static constexpr float SPAWN_CLAMP_R = 45.0f;    // enemy spawns stay inside this circle
static constexpr float SPAWN_RING    = 25.0f;    // enemies materialize just outside the view

// floor lattice — a honeycomb the ship flies over. speed is read from how much passes underneath,
// not from the speed value, and the three concentric rings this replaces put ~18 lines under the
// whole field. cells are turned onto the arena's own axes and sized so the arena apothem is a whole
// number of cell apothems — that integer is what lands the outermost edge ON the wall
static constexpr int32_t LATTICE_DIVISIONS  = 18;      // arena apothem / cell apothem. 0 disables the lattice
static constexpr float   LATTICE_NEAR       = 6.0f;    // full brightness within this of the ship
static constexpr float   LATTICE_FAR        = 30.0f;   // faded to LATTICE_FLOOR beyond it
static constexpr float   LATTICE_FLOOR      = 0.35f;   // colour multiplier at full distance — a held fade, not off
static constexpr float   LATTICE_EMISSIVE   = 0.9f;    // at the ship; falls on t^2 so the hot core stays tight
static constexpr float   LATTICE_WARP       = 0.12f;   // world units of vertex ripple — the live-wire wobble
static constexpr float   LATTICE_FLOW       = 0.95f;   // fragment current along the wire
static constexpr float   LATTICE_FLOW_SCALE = 0.65f;   // higher = finer crackle
static constexpr float   LATTICE_GAP_CHANCE = 0.12f;   // fraction of interior edges left out — a lattice
                                                       // with holes reads as structure, a perfect one as
                                                       // a UI overlay. rim edges are never dropped, so
                                                       // the snap to the wall survives. 0 = complete

// frenzy response — multipliers on the above while the swarm surge is live. the walls already turn
// red for this, so the floor leans warm only slightly and says it in ENERGY instead: a floor that
// goes fully red is a danger overlay, and this one has to stay something you want to look at.
// TINT 0 keeps the hue flat and leaves only the crackle
static constexpr float LATTICE_FRENZY_WARP     = 3.2f;
static constexpr float LATTICE_FRENZY_FLOW     = 2.0f;
static constexpr float LATTICE_FRENZY_EMISSIVE = 1.6f;
static constexpr float LATTICE_FRENZY_SCALE    = 1.5f;   // finer noise = the crackle itself, not just more of it
static constexpr float LATTICE_FRENZY_TINT     = 0.3f;   // 0 = no hue change at all

// thumb reach, virtual UI units. the stick is configured with it and drawn at it — one number, or
// the ring would promise a throw the input does not honour
static constexpr float STICK_RADIUS  = 90.0f;

// escalation timeline (seconds) — compressed: the full arc lands inside ~2 minutes
static constexpr float RUNNER_AFTER  = 22.0f;    // the fast type joins the mix
static constexpr float LUNGE_AFTER   = 30.0f;    // grunts/runners start burst-charging when close
static constexpr float ELITE_AFTER   = 44.0f;    // faceted elites appear
static constexpr float HP_RAMP_AFTER  = 80.0f;   // grunts toughen
static constexpr float PATTERN_PERIOD = 12.0f;   // formation spawn event cadence — formations carry the density now
static constexpr float FRENZY_PERIOD  = 15.0f;   // swarm-wide speed surge cadence
static constexpr float FRENZY_LEN     = 3.0f;    // surge duration

// stage-2 enemy types
static constexpr float    GUNNER_AFTER    = 55.0f;   // seconds before shooters join the mix
static constexpr float    SHOT_SPEED     = 8.5f;
static constexpr float    SHOT_DAMAGE    = 6.0f;
static constexpr float    DART_SPEED     = 13.5f;
static constexpr float    DART_DAMAGE    = 12.0f;
static constexpr float    DART_AOE       = 2.6f;
static constexpr uint64_t DART_WARN_STEPS = 42;      // 0.7 s of drawn flight line before launch

// boss timeline
static constexpr float    BOSS_AFTER        = 150.0f;   // first arrival
static constexpr float    BOSS_PERIOD      = 120.0f;    // respawn delay after a kill
static constexpr uint64_t BOSS_ARRIVE_STEPS = 90;       // 1.5 s of telegraph before it lands

// storm-nebula arena — vortices, meteor strikes, shell blockers
static constexpr float    VORTEX_RADIUS     = 9.0f;
static constexpr float    VORTEX_PULL      = 13.0f;     // velocity gain per second at dead center
static constexpr float    METEOR_PERIOD    = 12.0f;
static constexpr uint64_t METEOR_WARN_STEPS = 54;       // 0.9 s of falling rock before impact
static constexpr float    METEOR_AOE       = 3.0f;
static constexpr float    METEOR_DMG_PLAYER = 10.0f;
static constexpr int32_t  METEOR_DMG_ENEMY = 6;
static constexpr float    SHELL_AFTER      = 35.0f;     // seconds before blockers join the mix

// i-frames
static constexpr uint64_t INVULN_STEPS     = 48;        // 0.8 s of invulnerability + blink after any hit

// sweeping storm front
static constexpr float    STORM_PERIOD    = 40.0f;      // between sweeps
static constexpr float    STORM_LEN       = 18.0f;      // seconds to cross the field
static constexpr float    STORM_HALF      = 5.0f;       // half-thickness of the damaging band
static constexpr float    STORM_DMG       = 20.0f;      // per-touch chunk (i-frame gated)
static constexpr float    STORM_GAP_HALF  = 7.0f;      // half-width of the safe channel through the wall
// the front is the largest thing that crosses the arena and had no distortion at all. a HELD lens
// per live wall section, not an expanding ring: pushed fresh every render frame and gone the next,
// which is also why it cannot go through AddRipple — that queues a multi-frame shockwave, and one
// per section per sim step would put hundreds of them in flight at once
static constexpr float    STORM_LENS_RADIUS   = 9.0f;
static constexpr float    STORM_LENS_PHASE    = 0.5f;    // where on its own ring the held front sits
static constexpr float    STORM_LENS_STRENGTH = 0.022f;

// particle exaggeration — one place to dial every burst in the game. size and speed move together
// on purpose: a bigger shard that does not also travel further reads as a fatter blob, not as force
static constexpr float FX_BURST_SIZE  = 3.0f;    // one-shot debris, sparks, smoke
static constexpr float FX_BURST_SPEED = 1.6f;    // their spread and launch velocity
static constexpr float FX_HOOP_SIZE   = 1.0f;    // expanding rings — already screen-scale, kept apart
static constexpr float FX_STREAM_SIZE = 1.6f;    // continuous emitters (thruster, vents, churn)
static constexpr float FX_STREAM_SPEED = 1.2f;

// a blast's ripple is drawn wider than the blast itself. the ring is a thin shell whose width is
// fixed in quad UV, so its world thickness scales with the radius — a ripple sized to the damage
// area is both small AND thin, and lands entirely inside the particles that bloom over the impact
static constexpr float RIPPLE_BLAST_SPREAD = 2.6f;   // multiplies each blast's own AoE

static constexpr uint64_t SPAWN_WARN_STEPS = 36;   // 0.6 s between the warning marker and the arrival

// one value for the whole swarm, deliberately. the shader multiplies emissive by albedo, so the
// palette already spreads the types across a ~5x luminance range on its own — an emissive tier on
// top of that squares the difference and the field starts to flicker as the type mix changes
static constexpr float SWARM_EMISSIVE = 1.2f;

// a hit adds this on top of the enemy's resting emissive — what puts the white pop past the bloom
// threshold no matter which type was hit
static constexpr float FLASH_EMISSIVE = 2.6f;

// rad/s the grunt spins about its own nose. the SPIKE silhouette is roll-invariant within ~5%, so
// this only cycles which facet faces the light — set it to 0 to stop the roll outright
static constexpr float GRUNT_ROLL_RATE = 7.0f;

// nose weave — oscillates the aim so a straight approach still turns. tried at 0.34 and it read as
// the whole swarm shimmying: disorienting rather than alive. left wired at 0 rather than deleted,
// because a small value may still be worth having once the other motion channels settle
static constexpr float WEAVE_AMPLITUDE = 0.0f;    // rad
static constexpr float WEAVE_RATE      = 2.6f;    // rad/s

// render-clock body animation, all of it multiplied onto CEnemy.baseScale.
// throb: tried at 0.08 and it read as toy-like rather than alive — a swarm of things politely
// breathing in place. left wired at 0 rather than deleted, since the machinery is shared with the
// pops below and costs nothing while the amplitude is zero
static constexpr float THROB_AMPLITUDE = 0.0f;
static constexpr float HIT_POP         = 0.30f;   // rides the existing white flash
static constexpr float SPAWN_POP_TIME  = 0.18f;   // seconds of the birth ease
static constexpr float STRETCH_AT_SPEED = 0.35f;  // along the nose, at REFERENCE_SPEED
static constexpr float REFERENCE_SPEED  = 8.0f;

static constexpr float HUSK_SECONDS = 0.16f;

// frame treatment — the base the low-hp dread is layered on top of, never replaced by it
static constexpr float VIGNETTE_RADIUS   = 0.75f;
static constexpr float VIGNETTE_SOFTNESS = 0.45f;
static constexpr float VIGNETTE_BASE     = 0.25f;
static constexpr float SATURATION_BASE   = 1.12f;

struct UpgradeInfo
{
    const char* name;
    const char* desc[ static_cast<int32_t>( ERarity::_SIZE ) ];   // per rarity tier
};
static const UpgradeInfo UPGRADE_INFO[ static_cast<int32_t>( EUpgrade::_SIZE ) ] =
{
    { "RAPID FIRE", { "fire 18% faster",       "fire 28% faster",          "fire 40% faster" } },
    { "MULTISHOT",  { "+1 bullet, fanned",     "+2 bullets, fanned",       "+3 bullets, fanned" } },
    { "PIERCE",     { "bullets pass +1 enemy", "bullets pass +2 enemies",  "bullets pass +3 enemies" } },
    { "DAMAGE",     { "+1 damage per bullet",  "+2 damage per bullet",     "+4 damage per bullet" } },
    { "NOVA",       { "shockwave sooner, wider", "shockwave, empowered",   "shockwave storm" } },
    { "ENGINE",     { "faster ship",           "much faster ship",         "overdrive engine" } },
    { "VITALITY",   { "+20 max hp, heal 20",   "+35 max hp, heal 35",      "+60 max hp, full heal" } },
    { "MAGNET",     { "gems reach further",    "gems fly to you",          "gems cross the field" } },
    { "ORBITAL",    { "+1 orbiting blade",     "+1 blade, wider orbit",    "+2 blades, wider orbit" } },
    { "SEEKER",     { "+1 homing missile",     "+2 homing missiles",       "+3 missiles, faster" } },
    { "CARRIER",    { "+1 drone pod",          "+1 pod, faster drones",    "+2 pods, faster drones" } },
};

static const char* RARITY_NAMES[ static_cast<int32_t>( ERarity::_SIZE ) ] =
{
    "COMMON",
    "RARE",
    "EPIC",
};
static const color4 RARITY_COLORS[ static_cast<int32_t>( ERarity::_SIZE ) ] =
{
    color4( 0.62f, 0.68f, 0.75f, 1.0f ),
    color4( 0.30f, 0.72f, 1.0f, 1.0f ),
    color4( 1.0f, 0.78f, 0.22f, 1.0f ),
};

// palettes — hue lanes are absolute: EVERYTHING hostile is red/orange, and everything else is cool.
// inside the cool half each weapon owns a hue and holds it across body, trail and blast — white
// auto-bullets, indigo blades, magenta carrier, green seekers, blue pickups. no exceptions, no overlap
static const color4 LATTICE_FRENZY_COLOR = color4( 1.0f, 0.3f, 0.22f, 1.0f );
static const color4 LATTICE_COLOR = color4( 0.32f, 0.38f, 0.46f, 1.0f );   // desaturated: the floor is a reference, not a participant
static const color4 SHIP_COLOR    = color4( 0.1f, 0.88f, 1.0f, 1.0f );
static const color4 CARRIER_COLOR = color4( 1.0f, 0.15f, 0.8f, 1.0f );   // pod and drone are one weapon, so one hue
static const color4 SEEKER_COLOR  = color4( 0.15f, 1.0f, 0.42f, 1.0f );
static const color4 ORB_COLOR     = color4( 0.35f, 0.12f, 1.0f, 1.0f );
static const color4 GRUNT_COLORS[ 4 ] =
{
    color4( 1.0f, 0.55f, 0.2f, 1.0f ),
    color4( 1.0f, 0.4f, 0.12f, 1.0f ),
    color4( 0.95f, 0.65f, 0.15f, 1.0f ),
    color4( 1.0f, 0.3f, 0.15f, 1.0f ),
};
static const color4 RUNNER_COLORS[ 2 ] =
{
    color4( 1.0f, 0.18f, 0.08f, 1.0f ),
    color4( 1.0f, 0.35f, 0.05f, 1.0f ),
};
static const color4 BRUTE_COLORS[ 2 ] =
{
    color4( 0.85f, 0.1f, 0.08f, 1.0f ),
    color4( 0.7f, 0.07f, 0.1f, 1.0f ),
};
static const color4 ELITE_COLOR = color4( 1.0f, 0.62f, 0.1f, 1.0f );
// bullets live in a bright near-white family — enemies own the saturated hues, so the two never blur
static const color4 BULLET_COLORS[ 3 ] =
{
    color4( 1.0f, 0.98f, 0.85f, 1.0f ),
    color4( 0.75f, 0.95f, 1.0f, 1.0f ),
    color4( 0.9f, 0.8f, 1.0f, 1.0f ),
};
static const color4 NOVA_COLORS[ 3 ] =
{
    color4( 0.06f, 0.8f, 1.0f, 1.0f ),
    color4( 0.28f, 0.92f, 1.0f, 1.0f ),
    color4( 0.75f, 1.0f, 0.95f, 1.0f ),
};
static const color4 WALL_COLORS[ 6 ] =
{
    color4( 0.15f, 0.5f, 0.45f, 1.0f ),
    color4( 0.12f, 0.45f, 0.6f, 1.0f ),
    color4( 0.15f, 0.35f, 0.7f, 1.0f ),
    color4( 0.3f, 0.25f, 0.7f, 1.0f ),
    color4( 0.45f, 0.2f, 0.6f, 1.0f ),
    color4( 0.25f, 0.4f, 0.55f, 1.0f ),
};
static const color4 DUST_COLORS[ 4 ] =
{
    color4( 0.4f, 0.55f, 0.8f, 1.0f ),
    color4( 0.6f, 0.4f, 0.8f, 1.0f ),
    color4( 0.3f, 0.7f, 0.7f, 1.0f ),
    color4( 0.7f, 0.55f, 0.35f, 1.0f ),
};

// names for what primitive_nova_survivors reads the custom slots as
static void SetBands( CPrimitive& primitive, float frequency )
{
    primitive.custom0 = frequency;
}
static void SetFlow( CPrimitive& primitive, float strength, float noiseScale, float vertexWarp )
{
    primitive.custom1 = strength;
    primitive.custom2 = noiseScale;
    primitive.custom3 = vertexWarp;
}
static void SetRingBand( CPrimitive& primitive, float worldWidth )
{
    primitive.custom4 = worldWidth;
}

static void ScaleFx( CParticleEmitter* fx, float sizeScale, float speedScale )
{
    fx->sizeMin *= sizeScale;
    fx->sizeMax *= sizeScale;
    fx->spread  *= speedScale;
    fx->velocity = float3( ( fx->velocity.x * speedScale ), ( fx->velocity.y * speedScale ), ( fx->velocity.z * speedScale ) );
}
// the arena is a hexagon whose six walls face i * 60 deg, so a point is inside exactly when its
// projection on each of those normals stays within the apothem. three suffice — n and -n share one
static bool InsideArenaHex( float x, float y, float apothem )
{
    for( int32_t i = 0; i < 3; ++i )
    {
        const float2 n = crMath::CosSin( static_cast<float>( i ) * ( crMath::PI / 3.0f ) );
        if( crMath::Abs( ( x * n.x ) + ( y * n.y ) ) > apothem )
            return false;
    }
    return true;
}
// a trail authored as a constant makes a heavyweight drag a thread — the width has to come off the
// body it is attached to. primitive scale is a half-size for every shape used here.
// the SMALLEST axis, not the largest: a spiky or flattened body (the star elite, the spike grunt)
// is mostly air inside its bounding box, and matching its widest extent drags a slab behind it
static float TrailHalfWidth( float3 scale )
{
    float smallest = scale.x;
    if( scale.y < smallest )
        smallest = scale.y;
    if( scale.z < smallest )
        smallest = scale.z;

    return ( smallest * 0.72f );
}

// entity composition is the game's. no CVelocity here: that component is what puts an entity in
// crEcs::SyncDynamicTransforms, and a body that never moves already has its final CTransform
static entt::entity CreateStaticEntity( entt::registry& registry, b3BodyId body, b3Pos position, b3Quat rotation )
{
    const entt::entity e = registry.create();

    registry.emplace<CPhysicsBody>( e, body );

    CTransform t;
    t.current  = { position, rotation };
    t.previous = t.current;
    registry.emplace<CTransform>( e, t );

    return e;
}
static entt::entity CreateDynamicEntity( entt::registry& registry, b3BodyId body, b3Pos position, b3Quat rotation )
{
    const entt::entity e = CreateStaticEntity( registry, body, position, rotation );
    registry.emplace<CVelocity>( e );
    return e;
}

void Game::Init( crApp* app )
{
    _app = app;

    for( int32_t i = 0; i < 6; ++i )
        _walls[ i ] = entt::null;
    for( int32_t i = 0; i < ORB_CAP; ++i )
        _orbs[ i ] = entt::null;
    for( int32_t i = 0; i < POD_CAP; ++i )
        _pods[ i ] = entt::null;
    for( int32_t i = 0; i < STORM_BLOBS; ++i )
        _stormBlobs[ i ] = entt::null;

    _uiButtonSprite = app->graphics->FindSprite( crGraphics::BUILTIN_ATLAS, "roundsq64edge" );
    _uiDimSprite    = app->graphics->FindSprite( crGraphics::BUILTIN_ATLAS, "sq16" );
    _fxSprite       = app->graphics->FindSprite( crGraphics::BUILTIN_ATLAS, "circle32" );
    _fxSquare       = app->graphics->FindSprite( crGraphics::BUILTIN_ATLAS, "sq16" );
    _fxRing         = app->graphics->FindSprite( crGraphics::BUILTIN_ATLAS, "ring64" );

    _fxProgram  = app->graphics->LoadProgram( "primitive_nova_survivors.vert", "primitive_nova_survivors.frag" );
    _fxMaterial = app->renderSys->RegisterPrimitiveMaterial( _fxProgram );

    _sfxTest = app->audio->LoadSfx( "duelyst_f4_siren_attack_impact" );

    _font16 = app->graphics->FontAtlas()->LoadFont( "Orbitron-Medium", 16 );
    _font12 = app->graphics->FontAtlas()->LoadFont( "Orbitron-Medium", 12 );

    {// the ship has one input, and it is a direction. a thumb anywhere on the glass floats a stick
     // there; WSAD feeds the same value. the region is deliberately unbounded — this game has no
     // second stick to leave room for, and a region fixed at Init would go stale on a resize
        crStickConfig stick;
        stick.regionMin = float2( -10000.0f, -10000.0f );
        stick.regionMax = float2(  10000.0f,  10000.0f );
        stick.radius    = STICK_RADIUS;
        stick.keyUp     = SDL_SCANCODE_W;
        stick.keyDown   = SDL_SCANCODE_S;
        stick.keyLeft   = SDL_SCANCODE_A;
        stick.keyRight  = SDL_SCANCODE_D;
        app->inputSys->ConfigureStick( EStickId::LEFT, &stick );
    }

    app->devConsole->Register( "title", "quit to title",     CmdTitle, this );
    app->devConsole->Register( "play",  "start the session", CmdPlay,  this );

    app->physics->substeps = crPhysics::PHYSICS_SUBSTEPS;

    app->graphics->clearColor = color4( 0.01f, 0.01f, 0.02f, 1.0f );

    {// an enemy is ~35 px across here — too little area for per-object shading to carry a look, so
     // what carries it is what every pixel goes through. bloom is never touched per frame, so the
     // dev panel's sliders hold on it; the vignette's do not, because Update() rewrites it every
     // frame to layer the low-hp heartbeat over the base set here.
     // no color grade: lift raises the black floor, and on a 90%-black screen that reads as haze
        crPostProcess* pp = app->graphics->PostProcess();
        pp->fog.enabled = false;

        // hue-blind bright pass. under Rec.709 luminance the indigo blades and blue gems measured
        // 0.14 and 0.39 against a 0.6 threshold and so never bloomed at all, while the green seeker
        // measured 1.06 off a comparable emissive — blue carries 7% of luminance and green 72%.
        // the threshold rises with it because peak-channel readings run higher than luma ones
        pp->bloom.chromaBias = 1.0f;
        pp->bloom.threshold  = 1.0f;
        pp->bloom.intensity  = 0.7f;

        pp->vignette.enabled   = true;
        pp->vignette.radius    = VIGNETTE_RADIUS;
        pp->vignette.softness  = VIGNETTE_SOFTNESS;
        pp->vignette.intensity = VIGNETTE_BASE;

        pp->saturation = SATURATION_BASE;
    }

    {// deep-space key: warm light from the upper right and slightly toward the camera, cool sky
     // ambient over a near-black violet fill. the hemisphere splits on the world +Y normal, and this
     // game's play plane is XY viewed down -Z — so that split reads as a top-to-bottom gradient
     // across every body, which under a straight-down camera is most of the form there is
        crGraphicsUniforms* uniforms = app->graphics->Uniforms();
        // z kept low on purpose: under a top-down camera a light from the eye flattens every facet
        // to the same ndl — the grazing angle is what lets the chip crowns carry light and shadow
        uniforms->lightDir      = float3( 0.52f, 0.58f, 0.42f );
        uniforms->lightColor    = color4( 1.0f, 0.96f, 0.92f, 1.0f );
        uniforms->ambientSky    = color4( 0.38f, 0.45f, 0.65f, 0.34f );
        uniforms->ambientGround = color4( 0.14f, 0.11f, 0.22f, 0.20f );
        uniforms->specShininess = 48.0f;
        uniforms->specStrength  = 0.45f;
    }

    app->audio->PlayBgm( "celestial_echoes", true );
}
void Game::Cleanup()
{
    _app->devConsole->UnregisterByContext( this );
    glDeleteProgram( _fxProgram );
}

void Game::Update( crApp* app )
{
    if( _requestedState != _state )
    {
        const EGameState from = _state;
        _state = _requestedState;

        if( _state == EGameState::PLAYING )
        {
            if( ( from == EGameState::TITLE ) || ( from == EGameState::RESULT ) )
                EnterSession( app );
            else if( ( from == EGameState::LEVELUP ) || ( from == EGameState::PAUSED ) )
                app->SetSimulationActive( true );
        }
        else if( ( _state == EGameState::LEVELUP ) || ( _state == EGameState::PAUSED ) )
        {
            app->SetSimulationActive( false );
        }
        else if( _state == EGameState::TITLE )
        {
            if( from != EGameState::TITLE )
                ExitSession( app );
        }
        // RESULT: the sim stays active — the swarm keeps milling behind the panel
    }

    {// render-frame clock shared by the cosmetic passes — the sim's fixed clock is too coarse here
        _renderDt = app->FrameDelta().unscaled;

        _fxTime += _renderDt;
        if( _fxTime > ( 200.0f * crMath::PI ) )
            _fxTime -= ( 200.0f * crMath::PI );
    }

    UpdateEnemyVisuals( app );
    UpdateLattice( app );
    UpdateWallPulse( app );

    // vortices carry a standing lens — a fixed-phase ripple pushed every frame
    for( int32_t i = 0; i < _vortexCount; ++i )
        app->graphics->PostProcess()->PushRipple( _vortices[ i ].pos, 3.6f, 0.55f, 0.012f );

    if( _stormActive )
    {// the sweeping front gets the same treatment, one lens per section it actually occupies —
        // a hidden section (the safe channel, or an idle pool slot) is left at scale 0 by UpdateStorm
        entt::registry& registry = app->ecs->registry;
        for( int32_t i = 0; i < STORM_BLOBS; ++i )
        {
            if( registry.valid( _stormBlobs[ i ] ) == false )
                continue;
            if( registry.get<CPrimitive>( _stormBlobs[ i ] ).scale.x <= 0.0f )
                continue;

            app->graphics->PostProcess()->PushRipple( float3( registry.get<CTransform>( _stormBlobs[ i ] ).current.p ),
                                                      STORM_LENS_RADIUS, STORM_LENS_PHASE, STORM_LENS_STRENGTH );
        }
    }

    UpdateCamera( app );

    {// screen-damage kicks + low-hp dread — the whole frame reacts to how close death is
        _chromaPulse = crMath::Clamp( _chromaPulse - ( _renderDt * 3.0f ), 0.0f, 1.5f );
        _glitchPulse = crMath::Clamp( _glitchPulse - ( _renderDt * 2.8f ), 0.0f, 1.0f );
        _hurtFlash   = crMath::Clamp( _hurtFlash - ( _renderDt * 3.5f ), 0.0f, 1.0f );
        _radialPulse = crMath::Clamp( _radialPulse - ( _renderDt * 2.5f ), 0.0f, 1.0f );

        // low-hp: color drains, edges close in with a heartbeat, a faint tremor rides under everything
        float danger = 0.0f;
        // PAUSED counts: the low-hp dread is a property of the run, and dropping it on the pause
        // screen would snap the frame bright at exactly the moment the player stops to look at it
        if( ( ( _state == EGameState::PLAYING ) || ( _state == EGameState::PAUSED ) ) && ( _stats.hpMax > 0.0f ) )
        {
            const float frac = crMath::Clamp01( _hp / _stats.hpMax );
            if( frac < 0.3f )
                danger = ( ( 0.3f - frac ) / 0.3f );
        }
        const float chromaAll = crMath::Clamp( ( 0.012f * _chromaPulse ) + ( 0.006f * danger ), 0.0f, 0.02f );

        crPostProcess* pp = app->graphics->PostProcess();
        pp->chromatic.enabled  = ( chromaAll > 0.0005f );
        pp->chromatic.strength = chromaAll;
        pp->glitch.strength    = _glitchPulse;
        pp->saturation         = ( SATURATION_BASE - ( 0.62f * danger ) );

        const float radial = ( 0.14f * _radialPulse );
        pp->radialBlur.enabled = ( radial > 0.002f );
        if( pp->radialBlur.enabled )
        {// the blast pushes the frame outward from the ship (roughly screen center under the follow cam)
            pp->radialBlur.center   = float2( 0.5f, 0.5f );
            pp->radialBlur.strength = radial;
            pp->radialBlur.samples  = 8;
        }

        {// the frame always carries a vignette; low hp tightens it and gives it a heartbeat
            const float beat = ( 0.5f + ( 0.5f * crMath::Sin( _fxTime * 7.5f ) ) );
            pp->vignette.radius    = ( VIGNETTE_RADIUS - ( 0.03f * danger ) );
            pp->vignette.softness  = ( VIGNETTE_SOFTNESS + ( 0.05f * danger ) );
            pp->vignette.intensity = ( VIGNETTE_BASE + ( danger * ( 0.4f + ( 0.35f * beat ) ) ) );
        }
    }

    if( _state == EGameState::PLAYING )
    {// i-frame blink — the hull strobes bright while it can't be hurt
        entt::registry& registry = app->ecs->registry;
        if( ( _player != entt::null ) && registry.valid( _player ) )
        {
            CPrimitive& primitive = registry.get<CPrimitive>( _player );
            if( app->SimulationStepIndex() < _invulnUntilStep )
                primitive.emissive = ( ( ( app->SimulationStepIndex() / 3 ) % 2 ) == 0 ) ? 2.4f : 0.35f;
            else
                primitive.emissive = 1.15f;
        }
    }

    if( _sloMo > 0.0f )
    {// boss-kill slow motion — dips to 30% and eases back on the real-time clock; the final
        // write lands exactly on 1.0, so nothing fights the dev sim-speed control afterwards
        _sloMo = crMath::Clamp( _sloMo - ( _renderDt * 0.8f ), 0.0f, 1.0f );
        app->SetSimulationSpeed( ( _sloMo > 0.0f ) ? ( 0.3f + ( 0.7f * ( 1.0f - _sloMo ) ) ) : 1.0f );
    }
}
void Game::FixedUpdatePre( const crApp* app )
{
    if( _state != EGameState::PLAYING )
        return;

    UpdatePlayerControl( app );
    UpdateEnemySteering( app );
    UpdateAutoFire( app );
    UpdateOrbs( app );
    UpdatePods( app );
    UpdateMissiles( app );
    UpdateEnemyShots( app );
    UpdateBoss( app );
    UpdateVortices( app );
    UpdateMeteors( app );
    UpdateStorm( app );
    UpdateSpawner( app );
    UpdateNova( app );
    UpdateNovaRing( app );
    UpdateGems( app );
}
void Game::FixedUpdatePost( const crApp* app )
{
    if( _state == EGameState::PLAYING )
    {
        UpdateBulletHits( app );
        UpdateContactDamage( app );
    }

    UpdateExpiry( app );

    if( _state == EGameState::PLAYING )
        CheckLevelUp( app );
}
void Game::HandleEvent( const crApp* app, const SDL_Event* event )
{
    ( void )app;

    if( ( event->type == SDL_EVENT_KEY_DOWN ) &&
        ( event->key.key == SDLK_ESCAPE ) &&
        ( event->key.repeat == false ) )
    {
        // LEVELUP owns its own modal — escaping out of a pending pick would drop the upgrade
        if( _state == EGameState::PLAYING )
            RequestState( EGameState::PAUSED );
        else if( _state == EGameState::PAUSED )
            RequestState( EGameState::PLAYING );
        else if( _state == EGameState::RESULT )
            RequestState( EGameState::TITLE );
    }
}
void Game::Render( const crApp* app )
{
    ( void )app;
}
void Game::RenderUi( crApp* app )
{
    const color4 white = color4( 1.0f, 1.0f, 1.0f, 1.0f );

    crUi* ui = app->ui;
    ui->Begin( app );

    switch( _state )
    {
    case EGameState::TITLE:
    {
        {// title frame
            const float2 canvas = ui->CanvasSize();
            const float  cx     = ( canvas.x * 0.5f );
            const float  cy     = ( canvas.y * 0.5f ) + 80.0f;

            constexpr float HALF_W = 104.0f;
            constexpr float HALF_H = 24.0f;
            constexpr float CUT    = 12.0f;

            const float2 frame[] =
            {
                float2( ( cx - HALF_W ) + CUT, cy + HALF_H ),
                float2( ( cx + HALF_W ) - CUT, cy + HALF_H ),
                float2( cx + HALF_W,           ( cy + HALF_H ) - CUT ),
                float2( cx + HALF_W,           ( cy - HALF_H ) + CUT ),
                float2( ( cx + HALF_W ) - CUT, cy - HALF_H ),
                float2( ( cx - HALF_W ) + CUT, cy - HALF_H ),
                float2( cx - HALF_W,           ( cy - HALF_H ) + CUT ),
                float2( cx - HALF_W,           ( cy + HALF_H ) - CUT ),
            };
            ui->Polyline( frame, static_cast<int32_t>( SDL_arraysize( frame ) ), true, 5.0f, white );
        }
        ui->Label( EUiAnchor::CENTER, float2( 0.0f, 80.0f ), "NOVA SURVIVORS", _font16, white );
        if( _bestScore > 0 )
        {
            char best[ 32 ];
            SDL_snprintf( best, sizeof( best ), "BEST %d", _bestScore );
            ui->Label( EUiAnchor::CENTER, float2( 0.0f, 48.0f ), best, _font16, white );
        }
        if( ui->Button( 2, EUiAnchor::CENTER, float2( 0.0f, 0.0f ), float2( 200.0f, 56.0f ), _uiButtonSprite, 16.0f, "START", _font16, white ) )
            RequestState( EGameState::PLAYING );
        break;
    }

    case EGameState::PLAYING:
    {
        {// hp — discrete cells, one per ~25 hp (a chunk hit is roughly one cell); reads at a glance
            constexpr float SEG_HP = 25.0f;
            constexpr float CELL_W = 26.0f;
            constexpr float CELL_H = 16.0f;
            constexpr float PITCH  = 30.0f;

            int32_t segs = static_cast<int32_t>( crMath::Round( _stats.hpMax / SEG_HP ) );
            if( segs < 1 )
                segs = 1;
            if( segs > 16 )
                segs = 16;

            const float total = ( static_cast<float>( segs ) * PITCH );
            const float frac  = crMath::Clamp01( _hp / _stats.hpMax );

            color4 lit = crMath::LerpColor( color4( 1.0f, 0.15f, 0.1f, 0.95f ), color4( 0.25f, 1.0f, 0.5f, 0.95f ), frac );
            const float alpha = ( frac < 0.3f ) ? ( 0.5f + ( 0.4f * crMath::Sin( _fxTime * 9.0f ) ) ) : 0.95f;   // low: blink

            for( int32_t i = 0; i < segs; ++i )
            {
                const float x    = ( ( -total * 0.5f ) + ( PITCH * ( static_cast<float>( i ) + 0.5f ) ) );
                const float cell = crMath::Clamp01( ( _hp - ( static_cast<float>( i ) * SEG_HP ) ) / SEG_HP );

                ui->Image( EUiAnchor::TOP, float2( x, -16.0f ), float2( CELL_W, CELL_H ), _uiDimSprite, color4( 0.0f, 0.0f, 0.0f, 0.55f ) );

                if( cell > 0.01f )
                {// full cell fills the box; a partial (last) cell fills from the left
                    const float fw   = ( ( CELL_W - 4.0f ) * cell );
                    const float left = ( x - ( ( CELL_W - 4.0f ) * 0.5f ) );
                    color4 c = lit;
                    c.a = alpha;
                    ui->Image( EUiAnchor::TOP, float2( left + ( fw * 0.5f ), -16.0f ), float2( fw, CELL_H - 4.0f ), _uiDimSprite, c );
                }
            }
        }

        {// xp — "LV n" with a chunky tick bar beside it: taller cells, half the width
            constexpr float   BAR_X    = 90.0f;    // bar starts right of the LV label
            constexpr float   BAR_W    = 150.0f;   // half the old width
            constexpr int32_t TICK_CAP = 15;       // fewer, bigger cells

            char lv[ 16 ];
            SDL_snprintf( lv, sizeof( lv ), "LV %d", _level );
            ui->Label( EUiAnchor::BOTTOM_LEFT, float2( 16.0f, 20.0f ), lv, _font16, white );

            const int32_t ticks  = ( _xpNeed < TICK_CAP ) ? _xpNeed : TICK_CAP;
            int32_t       filled = ( ( _xp * ticks ) / _xpNeed );
            if( filled > ticks )
                filled = ticks;
            const float pitch = ( BAR_W / static_cast<float>( ticks ) );
            const float tickW = ( pitch * 0.72f );   // tight gaps — reads as a gauge filling, not dots

            ui->Image( EUiAnchor::BOTTOM_LEFT, float2( BAR_X - 4.0f, 20.0f ), float2( BAR_W + 8.0f, 30.0f ), _uiDimSprite, color4( 0.0f, 0.0f, 0.0f, 0.55f ) );

            for( int32_t i = 0; i < ticks; ++i )
            {
                color4 tick;
                if( i < filled )
                    tick = color4( 0.45f, 0.7f, 1.0f, 0.95f );
                else if( i == filled )
                    tick = color4( 0.45f, 0.7f, 1.0f, 0.3f + ( 0.25f * crMath::Sin( _fxTime * 6.0f ) ) );   // the next tick breathes
                else
                    tick = color4( 0.3f, 0.4f, 0.6f, 0.25f );

                ui->Image( EUiAnchor::BOTTOM_LEFT, float2( BAR_X + ( pitch * static_cast<float>( i ) ), 20.0f ), float2( tickW, 22.0f ), _uiDimSprite, tick );
            }
        }

        if( ( _boss != entt::null ) && app->ecs->registry.valid( _boss ) )
        {// boss hp — a red bar right under the player's
            constexpr float BAR_W = 320.0f;
            const CEnemy&   en    = app->ecs->registry.get<CEnemy>( _boss );
            const float     frac  = crMath::Clamp01( static_cast<float>( en.hp ) / static_cast<float>( _bossHpMax ) );
            const float     fillW = ( BAR_W * frac );

            ui->Image( EUiAnchor::TOP, float2( 0.0f, -40.0f ), float2( BAR_W + 6.0f, 14.0f ), _uiDimSprite, color4( 0.0f, 0.0f, 0.0f, 0.55f ) );
            ui->Image( EUiAnchor::TOP, float2( ( fillW - BAR_W ) * 0.5f, -42.0f ), float2( fillW, 8.0f ), _uiDimSprite, color4( 1.0f, 0.22f, 0.12f, 0.95f ) );
        }

        char line[ 48 ];
        SDL_snprintf( line, sizeof( line ), "SCORE %d", _score );
        ui->Label( EUiAnchor::TOP_LEFT, float2( 16.0f, -16.0f ), line, _font16, white );
        SDL_snprintf( line, sizeof( line ), "KILLS %d", _kills );
        ui->Label( EUiAnchor::TOP_RIGHT, float2( -16.0f, -16.0f ), line, _font16, white );

        if( app->inputSys->IsStickTouched( EStickId::LEFT ) )
        {// only a thumb draws one — a key press has no origin to put it at
            constexpr int32_t RING_STEPS = 24;

            // the origin comes back in pointer pixels (Y-down); the canvas is virtual units, Y-up
            const float  scale  = crUi::RasterScale( app->camera->Viewport() );
            const float2 px     = app->inputSys->StickOrigin( EStickId::LEFT );
            const float2 origin = float2( ( px.x / scale ), ( ui->CanvasSize().y - ( px.y / scale ) ) );
            const float2 value  = app->inputSys->Stick( EStickId::LEFT );

            float2 ring[ RING_STEPS ];
            for( int32_t i = 0; i < RING_STEPS; ++i )
            {
                const float2 cs = crMath::CosSin( ( static_cast<float>( i ) / RING_STEPS ) * ( 2.0f * crMath::PI ) );
                ring[ i ] = float2( ( origin.x + ( cs.x * STICK_RADIUS ) ), ( origin.y + ( cs.y * STICK_RADIUS ) ) );
            }
            ui->Polyline( ring, RING_STEPS, true, 2.0f, color4( 1.0f, 1.0f, 1.0f, 0.30f ) );

            const float2 spoke[ 2 ] =
            {
                origin,
                float2( ( origin.x + ( value.x * STICK_RADIUS ) ), ( origin.y + ( value.y * STICK_RADIUS ) ) ),
            };
            ui->Polyline( spoke, 2, false, 5.0f, color4( 1.0f, 1.0f, 1.0f, 0.85f ) );
        }

        if( _hurtFlash > 0.001f )
            ui->Image( EUiAnchor::CENTER, float2( 0.0f, 0.0f ), ui->CanvasSize(), _uiDimSprite, color4( 1.0f, 0.25f, 0.2f, ( _hurtFlash * 0.5f ) ) );
        break;
    }

    case EGameState::LEVELUP:
    {
        ui->Image( EUiAnchor::CENTER, float2( 0.0f, 0.0f ), ui->CanvasSize(), _uiDimSprite, color4( 0.0f, 0.0f, 0.0f, 0.55f ) );

        char line[ 64 ];
        SDL_snprintf( line, sizeof( line ), "LEVEL %d", _level );
        ui->Label( EUiAnchor::CENTER, float2( 0.0f, 168.0f ), line, _font16, white );

        for( int32_t i = 0; i < 3; ++i )
        {
            const UpgradeInfo& info   = UPGRADE_INFO[ static_cast<int32_t>( _choices[ i ] ) ];
            const int32_t      rarity = static_cast<int32_t>( _choiceRarity[ i ] );
            const float        y      = ( 92.0f - ( static_cast<float>( i ) * 104.0f ) );

            color4 frame = RARITY_COLORS[ rarity ];
            if( _choiceRarity[ i ] == ERarity::EPIC )
            {// epic offers shimmer
                const float pulse = ( 0.82f + ( 0.18f * crMath::Sin( _fxTime * 7.0f ) ) );
                frame = color4( ( frame.r * pulse ), ( frame.g * pulse ), ( frame.b * pulse ), 1.0f );
            }

            if( ui->Button( 10 + i, EUiAnchor::CENTER, float2( 0.0f, y ), float2( 360.0f, 64.0f ), _uiButtonSprite, 16.0f, info.name, _font16, frame ) )
            {
                ApplyUpgrade( app, _choices[ i ], _choiceRarity[ i ] );
                RequestState( EGameState::PLAYING );
            }

            SDL_snprintf( line, sizeof( line ), "%s - %s", RARITY_NAMES[ rarity ], info.desc[ rarity ] );
            ui->Label( EUiAnchor::CENTER, float2( 0.0f, y - 56.0f ), line, _font12, RARITY_COLORS[ rarity ] );
        }
        break;
    }

    case EGameState::PAUSED:
    {
        ui->Image( EUiAnchor::CENTER, float2( 0.0f, 0.0f ), ui->CanvasSize(), _uiDimSprite, color4( 0.0f, 0.0f, 0.0f, 0.55f ) );

        char line[ 48 ];
        ui->Label( EUiAnchor::CENTER, float2( 0.0f, 120.0f ), "PAUSED", _font16, white );
        SDL_snprintf( line, sizeof( line ), "SCORE %d   KILLS %d   LV %d", _score, _kills, _level );
        ui->Label( EUiAnchor::CENTER, float2( 0.0f, 84.0f ), line, _font12, white );

        if( ui->Button( 20, EUiAnchor::CENTER, float2( 0.0f, 8.0f ), float2( 200.0f, 56.0f ), _uiButtonSprite, 16.0f, "RESUME", _font16, white ) )
            RequestState( EGameState::PLAYING );
        if( ui->Button( 21, EUiAnchor::CENTER, float2( 0.0f, -72.0f ), float2( 200.0f, 56.0f ), _uiButtonSprite, 16.0f, "TITLE", _font16, white ) )
            RequestState( EGameState::TITLE );
        break;
    }

    case EGameState::RESULT:
    {
        ui->Image( EUiAnchor::CENTER, float2( 0.0f, 0.0f ), ui->CanvasSize(), _uiDimSprite, color4( 0.0f, 0.0f, 0.0f, 0.45f ) );

        char line[ 64 ];
        ui->Label( EUiAnchor::CENTER, float2( 0.0f, 120.0f ), "OVERRUN", _font16, white );
        SDL_snprintf( line, sizeof( line ), "SCORE %d   KILLS %d   LV %d", _score, _kills, _level );
        ui->Label( EUiAnchor::CENTER, float2( 0.0f, 84.0f ), line, _font16, white );
        SDL_snprintf( line, sizeof( line ), "BEST %d", _bestScore );
        ui->Label( EUiAnchor::CENTER, float2( 0.0f, 56.0f ), line, _font16, white );

        if( ui->Button( 6, EUiAnchor::CENTER, float2( 0.0f, -8.0f ), float2( 200.0f, 56.0f ), _uiButtonSprite, 16.0f, "RETRY", _font16, white ) )
            RequestState( EGameState::PLAYING );
        if( ui->Button( 7, EUiAnchor::CENTER, float2( 0.0f, -88.0f ), float2( 200.0f, 56.0f ), _uiButtonSprite, 16.0f, "TITLE", _font16, white ) )
            RequestState( EGameState::TITLE );
        break;
    }
    }

    ui->End();
}
void Game::RenderDevUi( const crApp* app )
{
    ( void )app;

    if( _state == EGameState::TITLE )
        return;

    {
        const ImGuiViewport* viewport = ImGui::GetMainViewport();

        ImVec2 pos;
        pos.x = ( viewport->WorkPos.x + 10.0f );
        pos.y = ( viewport->WorkPos.y + viewport->WorkSize.y - 10.0f );
        ImGui::SetNextWindowPos( pos, ImGuiCond_FirstUseEver, ImVec2( 0.0f, 1.0f ) );   // pivot bottom-left
        ImGui::SetNextWindowCollapsed( true, ImGuiCond_FirstUseEver );
        ImGui::SetNextWindowSize( ImVec2( 310.0f, 0.0f ), ImGuiCond_FirstUseEver );
    }

    if( ImGui::Begin( "NovaSurvivors", nullptr, ImGuiWindowFlags_NoSavedSettings ) == false )
    {
        ImGui::End();
        return;
    }

    if( ImGui::CollapsingHeader( "Ship" ) )
    {
        ImGui::SliderFloat( "Thrust", &_tune.thrust, 40.0f, 400.0f );
        ImGui::SliderFloat( "Damping", &_tune.damping, 0.0f, 6.0f );
        ImGui::SliderFloat( "Max Speed", &_tune.maxSpeed, 5.0f, 30.0f );
    }
    if( ImGui::CollapsingHeader( "Swarm", ImGuiTreeNodeFlags_DefaultOpen ) )
    {
        ImGui::SliderFloat( "Enemy Speed", &_tune.enemySpeed, 1.0f, 8.0f );
        ImGui::SliderFloat( "Speed Ramp", &_tune.speedRamp, 0.0f, 0.01f, "%.4f" );
        ImGui::SliderInt( "Enemy Cap", &_tune.enemyCap, 50, 2000 );
        ImGui::SliderFloat( "Contact Hit", &_tune.contactHit, 5.0f, 50.0f );
    }

    ImGui::Separator();
    ImGui::Text( "enemies: %d  bullets: %d  gems: %d  missiles: %d", _enemies.Size(), _bullets.Size(), _gems.Size(), _missiles.Size() );
    ImGui::Text( "hp: %.0f  xp: %d/%d  fire: %.2fs x%d p%d d%d",
                 _hp, _xp, _xpNeed, _stats.fireInterval, _stats.bulletCount, _stats.pierce, _stats.damage );
    ImGui::Text( "orbs: %d  seekers: x%d / %.1fs  pods: %d / %.1fs", _stats.orbCount, _stats.missileCount, _stats.missilePeriod, _stats.podCount, _stats.dronePeriod );

    ImGui::End();
}

void Game::RequestState( EGameState state )
{
    _requestedState = state;
}
void Game::EnterSession( crApp* app )
{
    ExitSession( app );   // RESULT -> RETRY re-enters here

    app->ResetSimulation();

    b3World_SetGravity( app->physics->World(), { 0.0f, 0.0f, 0.0f } );   // zero-g arena — ResetWorld restores the default, so set per session

    _rng.Seed( 0x50AB5EED );

    SpawnArena( app );
    SpawnPlayer( app );

    // two wells parked in opposite corners — landmarks to fight around, not centerpieces
    SpawnVortex( app, float3( 34.0f, 30.0f, 0.0f ) );
    SpawnVortex( app, float3( -32.0f, -33.0f, 0.0f ) );

    _stats.fireInterval = _tune.fireInterval;
    _stats.bulletCount  = 1;
    _stats.pierce       = 0;
    _stats.damage       = 1;
    _stats.novaPeriod   = _tune.novaPeriod;
    _stats.novaRadius   = _tune.novaRadius;
    _stats.novaKick     = _tune.novaKick;
    _stats.thrust       = _tune.thrust;
    _stats.maxSpeed     = _tune.maxSpeed;
    _stats.hpMax        = _tune.hpMax;
    _stats.magnetRadius = _tune.magnetRadius;
    _stats.orbCount      = 0;
    _stats.orbRadius     = _tune.orbRadius;
    _stats.missileCount  = 0;
    _stats.missilePeriod = _tune.missilePeriod;
    _stats.podCount      = 0;
    _stats.dronePeriod   = _tune.dronePeriod;

    _hp             = _stats.hpMax;
    _invulnUntilStep = 0;
    _shipHeading    = 0.0f;
    _novaColorIndex = 0;
    _kills          = 0;
    _score          = 0;
    _xp             = 0;
    _xpNeed         = 13;   // matches the CheckLevelUp curve at level 1
    _level          = 1;
    _novaRingAge    = 99.0f;
    _shakeMag       = 0.0f;
    _camValid       = false;
    _spawnInterval  = _tune.spawnStart;
    _nextSpawnStep  = 30;
    _nextFireStep   = 0;
    _nextNovaStep   = static_cast<uint64_t>( crMath::Round( _stats.novaPeriod / crApp::FIXED_TIMESTEP ) );
    _nextPatternStep = static_cast<uint64_t>( crMath::Round( PATTERN_PERIOD / crApp::FIXED_TIMESTEP ) );
    _nextFrenzyStep  = static_cast<uint64_t>( crMath::Round( FRENZY_PERIOD / crApp::FIXED_TIMESTEP ) );
    _frenzyEndStep   = 0;
    _nextBossStep    = static_cast<uint64_t>( crMath::Round( BOSS_AFTER / crApp::FIXED_TIMESTEP ) );
    _nextMeteorStep  = static_cast<uint64_t>( crMath::Round( 18.0f / crApp::FIXED_TIMESTEP ) );
    _nextStormStep   = static_cast<uint64_t>( crMath::Round( STORM_PERIOD / crApp::FIXED_TIMESTEP ) );
    _stormActive     = false;
    _stormEndStep    = 0;
    _nextMissileStep = 0;
    _nextOrbHitStep  = 0;

    app->SetSimulationActive( true );
}
void Game::ExitSession( crApp* app )
{
    app->ResetSession();

    _player   = entt::null;
    _novaRing = entt::null;
    for( int32_t i = 0; i < 6; ++i )
        _walls[ i ] = entt::null;
    for( int32_t i = 0; i < ORB_CAP; ++i )
        _orbs[ i ] = entt::null;
    for( int32_t i = 0; i < POD_CAP; ++i )
        _pods[ i ] = entt::null;
    _orbCount      = 0;
    _orbAngle      = 0.0f;
    _podCount      = 0;
    _podAngle      = 0.0f;
    _nextDroneStep = 0;
    _nextDronePod  = 0;
    _boss            = entt::null;
    _bossMarker      = entt::null;
    _bossArriveStep  = 0;
    _bossPhase2      = false;
    _sloMo           = 0.0f;
    for( int32_t i = 0; i < VORTEX_CAP; ++i )
        _vortices[ i ] = Vortex{};
    _vortexCount = 0;
    _meteors.Clear();
    for( int32_t i = 0; i < STORM_BLOBS; ++i )
        _stormBlobs[ i ] = entt::null;
    _stormActive = false;
    _chromaPulse   = 0.0f;
    _glitchPulse   = 0.0f;
    _hurtFlash     = 0.0f;
    _radialPulse   = 0.0f;

    {// PP effects are game-driven per frame — return them to the title screen's resting state.
        // the vignette is part of that resting state, not a session effect, so it goes back to base
        crPostProcess* pp = app->graphics->PostProcess();
        pp->chromatic.enabled  = false;
        pp->radialBlur.enabled = false;
        pp->glitch.strength    = 0.0f;
        pp->saturation         = SATURATION_BASE;
        pp->vignette.radius    = VIGNETTE_RADIUS;
        pp->vignette.softness  = VIGNETTE_SOFTNESS;
        pp->vignette.intensity = VIGNETTE_BASE;
    }

    _lattice.Clear();
    _enemies.Clear();
    _husks.Clear();
    _bullets.Clear();
    _gems.Clear();
    _missiles.Clear();
    _pendingSpawns.Clear();
    _pendingDarts.Clear();
    _enemyShots.Clear();
}

/*static*/ void Game::CmdTitle( void* context, int32_t argc, const char** argv )
{
    ( void )argc;
    ( void )argv;

    Game* game = static_cast<Game*>( context );
    game->RequestState( EGameState::TITLE );
}
/*static*/ void Game::CmdPlay( void* context, int32_t argc, const char** argv )
{
    ( void )argc;
    ( void )argv;

    Game* game = static_cast<Game*>( context );
    game->RequestState( EGameState::PLAYING );
}

void Game::SpawnArena( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    const b3WorldId world    = app->physics->World();

    {// hex walls — six slabs, each perpendicular to its outward normal
        const float wallHalfLen = ( HEX_RADIUS * 0.58f );   // slight overlap at the corners

        for( int32_t i = 0; i < 6; ++i )
        {
            const float  normalAngle = ( static_cast<float>( i ) * ( crMath::PI / 3.0f ) );
            const float2 normal      = crMath::CosSin( normalAngle );
            const float2 rotCs       = crMath::CosSin( ( normalAngle + ( crMath::PI * 0.5f ) ) * 0.5f );

            b3BodyDef bodyDef = b3DefaultBodyDef();
            bodyDef.position = { normal.x * HEX_APOTHEM, normal.y * HEX_APOTHEM, 0.0f };
            bodyDef.rotation = { { 0.0f, 0.0f, rotCs.y }, rotCs.x };

            const b3BodyId body = b3CreateBody( world, &bodyDef );

            b3ShapeDef shapeDef = b3DefaultShapeDef();
            shapeDef.density = 1.0f;
            shapeDef.filter.categoryBits = CAT_WORLD;
            const b3BoxHull hull = b3MakeBoxHull( wallHalfLen, 0.4f, 0.8f );
            b3CreateHullShape( body, &shapeDef, &hull.base );

            const entt::entity e = CreateStaticEntity( registry, body, bodyDef.position, bodyDef.rotation );

            CPrimitive primitive;
            primitive.shape     = EPrimitive::BEAM;   // subdivided — the field waves actually deform it
            primitive.flags     = EPrimitiveFlags::NONE;
            primitive.scale     = float3( wallHalfLen, 0.4f, 0.8f );
            primitive.color     = WALL_COLORS[ i ];
            primitive.emissive  = 0.9f;
            SetFlow( primitive, 0.55f, 0.35f, 0.14f );   // energy shimmering along the barrier
            primitive.material  = _fxMaterial;
            registry.emplace<CPrimitive>( e, primitive );

            _walls[ i ] = e;
        }
    }

    const b3Quat identity = { { 0.0f, 0.0f, 0.0f }, 1.0f };

    if( LATTICE_DIVISIONS > 0 )
    {// floor honeycomb — hexagons turned 30 deg so their edges run parallel to the arena walls, and
        // sized off LATTICE_DIVISIONS so the outermost edge lands on the wall rather than stopping
        // ragged short of it. each interior edge is shared by two cells, so a cell emits only its
        // first three and lets the neighbour own the rest; an edge whose neighbour falls outside the
        // arena is emitted anyway, which is what closes the rim
        const float apothem = ( HEX_APOTHEM / static_cast<float>( LATTICE_DIVISIONS ) );
        const float cell    = ( apothem / 0.8660254f );
        const float spacing = ( apothem * 2.0f );
        // cull by CENTRE, so the field has to shrink by the apothem the cell reaches past it —
        // cells are turned onto the arena's axes, so along any wall normal a cell extends exactly
        // one apothem. that puts the outermost EDGE on the wall and nothing beyond it. the epsilon
        // is float slack: centres land on exact multiples of the apothem, right on this boundary
        const float field = ( ( HEX_APOTHEM - apothem ) + 0.05f );

        const int32_t span = ( LATTICE_DIVISIONS + 2 );

        crRandom gapRng;
        gapRng.Seed( 0x1A77E );   // fixed: the holes are art, and art should not move between runs

        for( int32_t q = -span; q <= span; ++q )
        {
            for( int32_t r = -span; r <= span; ++r )
            {
                // axial -> world for a flat-top grid, then turned onto the arena's axes
                const float ax = ( cell * 1.5f * static_cast<float>( q ) );
                const float ay = ( cell * 1.7320508f * ( static_cast<float>( r ) + ( static_cast<float>( q ) * 0.5f ) ) );
                const float cx = ( ( ax * 0.8660254f ) - ( ay * 0.5f ) );
                const float cy = ( ( ax * 0.5f ) + ( ay * 0.8660254f ) );

                if( InsideArenaHex( cx, cy, field ) == false )
                    continue;

                for( int32_t k = 0; k < 6; ++k )
                {
                    const float  normalAngle = ( static_cast<float>( k ) * ( crMath::PI / 3.0f ) );
                    const float2 normal      = crMath::CosSin( normalAngle );

                    const bool rim = ( InsideArenaHex( ( cx + ( normal.x * spacing ) ), ( cy + ( normal.y * spacing ) ), field ) == false );

                    if( ( k >= 3 ) && ( rim == false ) )
                        continue;   // the neighbour on that side owns this edge

                    // drop a few interior edges. the rim is exempt: those edges are what the snap to
                    // the arena wall is made of, and a gap there reads as a mistake rather than as wear
                    if( ( rim == false ) && ( gapRng.NextFloat32( 0.0f, 1.0f ) < LATTICE_GAP_CHANCE ) )
                        continue;

                    const float2 rotCs = crMath::CosSin( ( normalAngle + ( crMath::PI * 0.5f ) ) * 0.5f );

                    const entt::entity e = registry.create();

                    CTransform t;
                    t.current  = { { cx + ( normal.x * apothem ), cy + ( normal.y * apothem ), -1.2f }, { { 0.0f, 0.0f, rotCs.y }, rotCs.x } };
                    t.previous = t.current;
                    registry.emplace<CTransform>( e, t );

                    CPrimitive primitive;
                    primitive.shape     = EPrimitive::BEAM;   // subdivided: LATTICE_WARP needs vertices to bend
                    primitive.flags     = EPrimitiveFlags::NONE;
                    primitive.scale     = float3( cell * 0.5f, 0.03f, 0.03f );
                    primitive.color     = LATTICE_COLOR;
                    primitive.emissive  = 0.0f;   // UpdateLattice owns this from here
                    SetFlow( primitive, LATTICE_FLOW, LATTICE_FLOW_SCALE, LATTICE_WARP );
                    primitive.material  = _fxMaterial;
                    registry.emplace<CPrimitive>( e, primitive );

                    _lattice.Add( e );
                }
            }
        }
    }

    {// starfield — three parallax depths behind the arena
        struct Layer
        {
            float   z;
            int32_t count;
            float   sizeMin;
            float   sizeMax;
            float   brightness;
        };
        const Layer layers[ 3 ] =
        {
            { -12.0f, 120, 0.04f, 0.09f, 0.55f },
            { -24.0f,  80, 0.07f, 0.13f, 0.40f },
            { -40.0f,  60, 0.10f, 0.18f, 0.28f },
        };

        crRandom starRng;
        starRng.Seed( 0x57A125 );

        for( int32_t l = 0; l < 3; ++l )
        {
            for( int32_t s = 0; s < layers[ l ].count; ++s )
            {
                const entt::entity e = registry.create();

                CTransform t;
                t.current  = { { starRng.NextFloat32( -62.0f, 62.0f ), starRng.NextFloat32( -62.0f, 62.0f ), layers[ l ].z }, identity };
                t.previous = t.current;
                registry.emplace<CTransform>( e, t );

                const float size = starRng.NextFloat32( layers[ l ].sizeMin, layers[ l ].sizeMax );
                const float b    = layers[ l ].brightness * starRng.NextFloat32( 0.6f, 1.0f );

                color4 starColor = color4( b, b, b * starRng.NextFloat32( 1.0f, 1.3f ), 1.0f );
                const int32_t tint = starRng.NextInt32( 0, 8 );
                if( tint == 0 )
                    starColor = color4( b * 1.2f, b * 0.7f, b * 0.45f, 1.0f );
                else if( tint == 1 )
                    starColor = color4( b * 0.5f, b * 1.1f, b * 1.05f, 1.0f );

                CPrimitive primitive;
                primitive.shape    = EPrimitive::BOX;
                primitive.flags    = EPrimitiveFlags::NONE;
                primitive.scale    = float3( size, size, size );
                primitive.color    = starColor;
                primitive.emissive = 0.8f;
                registry.emplace<CPrimitive>( e, primitive );
            }
        }
    }

    {// ambient dust motes — faint emitters scattered across the arena floor
        crRandom dustRng;
        dustRng.Seed( 0xD057 );

        for( int32_t i = 0; i < 16; ++i )
        {
            const entt::entity e = registry.create();

            CTransform t;
            t.current  = { { dustRng.NextFloat32( -36.0f, 36.0f ), dustRng.NextFloat32( -36.0f, 36.0f ), 0.0f }, identity };
            t.previous = t.current;
            registry.emplace<CTransform>( e, t );

            CParticleEmitter dust;
            dust.color        = DUST_COLORS[ i % 4 ];
            dust.gravity      = float3( 0.0f, 0.0f, 0.0f );
            dust.drag         = 0.05f;
            dust.velocity     = float3( dustRng.NextFloat32( -0.3f, 0.3f ), dustRng.NextFloat32( -0.3f, 0.3f ), 0.0f );
            dust.rate         = 1.5f;
            dust.spread       = 0.5f;
            dust.lifetimeMin  = 3.5f;
            dust.lifetimeMax  = 6.5f;
            dust.sizeMin      = 0.03f;
            dust.sizeMax      = 0.07f;
            dust.sizeEndScale = 0.3f;
            dust.intensityMin = 0.8f;
            dust.intensityMax = 1.4f;
            dust.spinMin      = -1.0f;
            dust.spinMax      = 1.0f;
            dust.spriteIndex  = _fxSprite;
            registry.emplace<CParticleEmitter>( e, dust );
        }
    }

    {// shooting stars — two slow streak emitters drifting across the back plane
        for( int32_t i = 0; i < 2; ++i )
        {
            const entt::entity e = registry.create();

            const float side = ( i == 0 ) ? 1.0f : -1.0f;

            CTransform t;
            t.current  = { { -40.0f * side, 14.0f * side, -8.0f }, identity };
            t.previous = t.current;
            registry.emplace<CTransform>( e, t );

            CParticleEmitter streak;
            streak.color        = color4( 0.7f, 0.85f, 1.0f, 1.0f );
            streak.gravity      = float3( 0.0f, 0.0f, 0.0f );
            streak.drag         = 0.0f;
            streak.rate         = 0.35f;
            streak.velocity     = float3( 9.0f * side, -2.0f * side, 0.0f );
            streak.spread       = 3.0f;
            streak.lifetimeMin  = 5.0f;
            streak.lifetimeMax  = 8.0f;
            streak.sizeMin      = 0.05f;
            streak.sizeMax      = 0.09f;
            streak.sizeEndScale = 0.4f;
            streak.intensityMin = 1.5f;
            streak.intensityMax = 2.5f;
            streak.spriteIndex  = _fxSprite;
            registry.emplace<CParticleEmitter>( e, streak );
        }
    }

    {// nova shell — one reusable expanding flat ring, hidden between blasts
        const entt::entity e = registry.create();

        CTransform t;
        t.current  = { { 0.0f, 0.0f, 0.15f }, identity };
        t.previous = t.current;
        registry.emplace<CTransform>( e, t );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::RING_THIN;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.blend    = EBlendMode::ADDITIVE;
        primitive.scale    = float3( 0.0f, 0.0f, 1.0f );
        primitive.color    = color4( 0.05f, 0.75f, 1.0f, 0.5f );
        primitive.emissive = 1.2f;
        SetRingBand( primitive, 0.3f );   // constant world-unit front, whatever the radius
        primitive.material = _fxMaterial;
        registry.emplace<CPrimitive>( e, primitive );

        _novaRing    = e;
        _novaRingAge = 99.0f;
    }

    {// storm front — pooled nebula blobs strung along the wall, hidden at scale 0 until a sweep
        for( int32_t i = 0; i < STORM_BLOBS; ++i )
        {
            const entt::entity e = registry.create();

            CTransform t;
            t.current  = { { 0.0f, 0.0f, -0.3f }, identity };
            t.previous = t.current;
            registry.emplace<CTransform>( e, t );

            CPrimitive primitive;
            primitive.shape     = EPrimitive::SPHERE;
            primitive.flags     = EPrimitiveFlags::NONE;
            primitive.blend     = EBlendMode::ADDITIVE;
            primitive.scale     = float3( 0.0f, 0.0f, 0.0f );
            primitive.color     = color4( 0.34f, 0.1f, 0.5f, 0.32f );   // deep bruised violet — a thunderhead, not pink
            primitive.emissive  = 1.4f;
            SetFlow( primitive, 0.9f, 0.14f, 1.6f );   // churning hard — the mass roils and boils
            primitive.material  = _fxMaterial;
            registry.emplace<CPrimitive>( e, primitive );

            _stormBlobs[ i ] = e;
        }
    }
}
void Game::SpawnPlayer( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    b3BodyDef bodyDef = b3DefaultBodyDef();
    bodyDef.type        = b3_dynamicBody;
    bodyDef.position    = { 0.0f, 0.0f, 0.0f };
    bodyDef.enableSleep = false;
    bodyDef.motionLocks.linearZ  = true;
    bodyDef.motionLocks.angularX = true;
    bodyDef.motionLocks.angularY = true;
    bodyDef.motionLocks.angularZ = true;

    const b3BodyId body = b3CreateBody( app->physics->World(), &bodyDef );

    b3ShapeDef shapeDef = b3DefaultShapeDef();
    shapeDef.density = 1.0f;
    shapeDef.filter.categoryBits = CAT_PLAYER;
    shapeDef.filter.maskBits     = ( CAT_WORLD | CAT_ENEMY );   // bullets never clip their own ship
    const b3Sphere sphere = { { 0.0f, 0.0f, 0.0f }, 0.4f };
    b3CreateSphereShape( body, &shapeDef, &sphere );

    {
        b3MassData massData = b3Body_GetMassData( body );
        const float f = _tune.shipMass / massData.mass;
        massData.mass    = _tune.shipMass;
        massData.inertia = b3MulSM( f, massData.inertia );
        b3Body_SetMassData( body, massData );
    }

    _player = CreateDynamicEntity( registry, body, bodyDef.position, bodyDef.rotation );

    CPrimitive primitive;
    primitive.shape    = EPrimitive::OCTAHEDRON;   // dart profile — nothing else in the arena shares it
    primitive.flags    = EPrimitiveFlags::NONE;
    primitive.scale    = float3( 0.58f, 0.26f, 0.22f );
    primitive.color    = SHIP_COLOR;
    // ACES desaturates everything it compresses, so brightness and hue trade against each other:
    // at the old 1.8 this hull rendered past the knee on two channels and washed toward white on
    // its own, before anything overlapped it. the whole friendly palette is scaled to sit under
    // that knee, and the ship is left as its brightest member — found by contrast, not by output
    primitive.emissive = 1.15f;
    registry.emplace<CPrimitive>( _player, primitive );

    CRibbonTrail rt;
    rt.color         = color4( 0.05f, 0.75f, 1.0f, 1.0f );
    // emitter left at the hull centre: the ship turns faster than anything else on screen, and an
    // offset emitter swings on an arc, which shows up as the ribbon tearing away from the tail
    rt.headHalfWidth = 0.21f;   // tail left at 0 — the ship alone keeps the tapered flame
    rt.intensity     = 1.35f;
    rt.emitDistance  = 0.1f;
    rt.fadeDuration  = 0.5f;
    rt.duration      = 0.9f;
    rt.capacity      = ERibbonCapacity::SEC_1;
    registry.emplace<CRibbonTrail>( _player, rt );

    CParticleEmitter flame;   // engine exhaust — rate/velocity driven by UpdatePlayerControl
    flame.color        = color4( 0.3f, 0.9f, 1.0f, 1.0f );
    flame.gravity      = float3( 0.0f, 0.0f, 0.0f );
    flame.drag         = 2.2f;
    flame.rate         = 0.0f;
    flame.spread       = 1.2f;
    flame.lifetimeMin  = 0.15f;
    flame.lifetimeMax  = 0.35f;
    flame.sizeMin      = 0.105f;
    flame.sizeMax      = 0.225f;
    flame.sizeEndScale = 0.3f;
    flame.intensityMin = 2.0f;
    flame.intensityMax = 3.5f;
    flame.spinMin      = -6.0f;
    flame.spinMax      = 6.0f;
    flame.spriteIndex  = _fxSprite;
    ScaleFx( &flame, FX_STREAM_SIZE, FX_STREAM_SPEED );
    registry.emplace<CParticleEmitter>( _player, flame );
}
void Game::QueueEnemySpawn( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const float3 playerPos = float3( registry.get<CTransform>( _player ).current.p );

    const float2 ringDir = crMath::CosSin( _rng.NextFloat32( 0.0f, 2.0f * crMath::PI ) );
    float x = playerPos.x + ( ringDir.x * SPAWN_RING );
    float y = playerPos.y + ( ringDir.y * SPAWN_RING );
    {// keep spawns inside the hex — clamp to a circle well within the apothem
        const float2 c = crMath::ClampLength2( float2( x, y ), SPAWN_CLAMP_R );
        x = c.x;
        y = c.y;
    }
    {// the clamp can drag an edge-of-arena spawn right next to the player — flip to the far side instead
        const float dx = ( x - playerPos.x );
        const float dy = ( y - playerPos.y );
        if( ( ( dx * dx ) + ( dy * dy ) ) < ( 12.0f * 12.0f ) )
        {
            x = playerPos.x - ( ringDir.x * SPAWN_RING );
            y = playerPos.y - ( ringDir.y * SPAWN_RING );
            const float2 c = crMath::ClampLength2( float2( x, y ), SPAWN_CLAMP_R );
            x = c.x;
            y = c.y;
        }
    }

    QueueEnemySpawnAt( app, float3( x, y, 0.0f ) );
}
void Game::QueueEnemySpawnAt( const crApp* app, float3 pos )
{
    entt::registry& registry = app->ecs->registry;

    {// formation positions may fall outside — clamp into the safe spawn circle
        const float2 c = crMath::ClampLength2( float2( pos.x, pos.y ), SPAWN_CLAMP_R );
        pos.x = c.x;
        pos.y = c.y;
    }

    const float x = pos.x;
    const float y = pos.y;

    {// warning marker — pops into place, then collapses inward until the enemy materializes
        const entt::entity marker = registry.create();

        CTransform t;
        t.current  = { { x, y, 0.05f }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
        t.previous = t.current;
        registry.emplace<CTransform>( marker, t );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::RING_THIN;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.blend    = EBlendMode::ADDITIVE;
        primitive.scale    = float3( 1.3f, 1.3f, 1.0f );   // animated by the pending sweep — collapses to the point
        primitive.color    = color4( 1.0f, 0.2f, 0.1f, 0.4f );
        primitive.emissive = 1.5f;
        SetRingBand( primitive, 0.1f );
        primitive.material = _fxMaterial;
        registry.emplace<CPrimitive>( marker, primitive );

        PendingSpawn pending;
        pending.pos       = float3( x, y, 0.0f );
        pending.spawnStep = app->SimulationStepIndex() + SPAWN_WARN_STEPS;
        pending.marker    = marker;
        _pendingSpawns.Add( pending );
    }

    {// arrival pop — the warning itself bursts open
        CParticleEmitter hoop;
        hoop.color        = color4( 1.0f, 0.25f, 0.1f, 1.0f );
        hoop.gravity      = float3( 0.0f, 0.0f, 0.0f );
        hoop.drag         = 0.0f;
        hoop.lifetimeMin  = 0.25f;
        hoop.lifetimeMax  = 0.3f;
        hoop.sizeMin      = 0.6f;
        hoop.sizeMax      = 0.75f;
        hoop.sizeEndScale = 5.0f;
        hoop.intensityMin = 2.0f;
        hoop.intensityMax = 3.0f;
        hoop.spriteIndex  = _fxRing;
        ScaleFx( &hoop, FX_HOOP_SIZE, 1.0f );
        app->particles->EmitBurst( app, hoop, float3( x, y, 1.1f ), 2 );

        CParticleEmitter sparks;
        sparks.color        = color4( 1.0f, 0.3f, 0.12f, 1.0f );
        sparks.gravity      = float3( 0.0f, 0.0f, 0.0f );
        sparks.drag         = 2.2f;
        sparks.spread       = 3.5f;
        sparks.lifetimeMin  = 0.1f;
        sparks.lifetimeMax  = 0.3f;
        sparks.sizeMin      = 0.045f;
        sparks.sizeMax      = 0.105f;
        sparks.sizeEndScale = 0.2f;
        sparks.intensityMin = 2.5f;
        sparks.intensityMax = 4.0f;
        sparks.spriteIndex  = _fxSprite;
        ScaleFx( &sparks, FX_BURST_SIZE, FX_BURST_SPEED );
        app->particles->EmitBurst( app, sparks, float3( x, y, 1.1f ), 13 );

        AddRipple( float3( x, y, 0.0f ), 2.2f, 0.2f, 0.006f );
    }
}
void Game::SpawnEnemy( const crApp* app, float3 pos )
{
    entt::registry& registry = app->ecs->registry;

    const float x = pos.x;
    const float y = pos.y;

    // type mix: brutes get more common over the run; runners join after RUNNER_AFTER, elites after ELITE_AFTER
    const float   seconds     = ( ( float )app->SimulationStepIndex() * crApp::FIXED_TIMESTEP );
    const int32_t bruteChance = static_cast<int32_t>( crMath::Clamp( 10.0f - static_cast<float>( static_cast<int32_t>( seconds ) / 30 ), 4.0f, 10.0f ) );
    const bool    elite       = ( seconds > ELITE_AFTER ) && ( _rng.NextInt32( 0, 100 ) < 6 );
    const bool    brute       = ( elite == false ) && ( _rng.NextInt32( 0, bruteChance ) == 0 );
    const bool    gunner      = ( elite == false ) && ( brute == false ) && ( seconds > GUNNER_AFTER ) && ( _rng.NextInt32( 0, 7 ) == 0 );
    const bool    shell       = ( elite == false ) && ( brute == false ) && ( gunner == false ) && ( seconds > SHELL_AFTER ) && ( _rng.NextInt32( 0, 9 ) == 0 );
    const bool    runner      = ( elite == false ) && ( brute == false ) && ( gunner == false ) && ( shell == false ) && ( seconds > RUNNER_AFTER ) && ( _rng.NextInt32( 0, 4 ) == 0 );

    b3BodyDef bodyDef = b3DefaultBodyDef();
    bodyDef.type        = b3_dynamicBody;
    bodyDef.position    = { x, y, 0.0f };
    bodyDef.enableSleep = false;
    bodyDef.motionLocks.linearZ = true;   // rotation stays free — the swarm tumbles as it shoves

    const b3BodyId body = b3CreateBody( app->physics->World(), &bodyDef );

    b3ShapeDef shapeDef = b3DefaultShapeDef();
    shapeDef.filter.categoryBits = CAT_ENEMY;

    CPrimitive  primitive;
    primitive.flags = EPrimitiveFlags::NONE;
    CEnemy enemy = { 1, 1.0f };

    if( elite )
    {// white-hot star, lead spike servoed onto the ship — tanky, fast for its size, worth a gem shower
        shapeDef.density = 1.6f;
        const b3Sphere sphere = { { 0.0f, 0.0f, 0.0f }, 0.72f };
        b3CreateSphereShape( body, &shapeDef, &sphere );

        {
            b3MotionLocks locks = bodyDef.motionLocks;
            locks.angularX = true;
            locks.angularY = true;
            b3Body_SetMotionLocks( body, locks );
        }

        primitive.shape    = EPrimitive::STAR;
        // chip z is lighting, not thickness: the vertex shader divides normals by the instance
        // scale, so a thin z crushes every crown facet toward +Z and the cut stops reading
        primitive.scale    = float3( 0.95f, 0.95f, 0.42f );
        primitive.color    = ELITE_COLOR;
        primitive.emissive = SWARM_EMISSIVE;
        enemy = { 8 + ( static_cast<int32_t>( seconds ) / 40 ), 1.12f };
        enemy.kind = EEnemyKind::ELITE;
    }
    else if( brute )
    {// heavy diamond plate, spinning in the play plane — reads as a wall of meat; toughens over the run
        shapeDef.density = 2.0f;
        const b3BoxHull hull = b3MakeBoxHull( 0.88f, 0.88f, 0.88f );
        b3CreateHullShape( body, &shapeDef, &hull.base );

        {
            b3MotionLocks locks = bodyDef.motionLocks;
            locks.angularX = true;
            locks.angularY = true;
            b3Body_SetMotionLocks( body, locks );
        }

        primitive.shape    = EPrimitive::OCTAHEDRON;
        primitive.scale    = float3( 1.05f, 1.05f, 1.05f );
        primitive.color    = BRUTE_COLORS[ _rng.NextInt32( 0, 2 ) ];
        primitive.emissive = SWARM_EMISSIVE;
        enemy = { 3 + static_cast<int32_t>( crMath::Clamp( seconds / 60.0f, 0.0f, 3.0f ) ), 0.66f };
        enemy.kind = EEnemyKind::BRUTE;
    }
    else if( gunner )
    {// turret spike — hangs back and snipes; the flash doubles as its muzzle telegraph
        shapeDef.density = 1.1f;
        const b3BoxHull hull = b3MakeBoxHull( 0.62f, 0.28f, 0.22f );
        b3CreateHullShape( body, &shapeDef, &hull.base );

        {// the nose only points anywhere while the body stays in the play plane — a free tumble
            // would let the servo aim at the ship and still show an unreadable pose
            b3MotionLocks locks = bodyDef.motionLocks;
            locks.angularX = true;
            locks.angularY = true;
            b3Body_SetMotionLocks( body, locks );
        }

        primitive.shape    = EPrimitive::SPIKE;   // visual runs wider than the hull — the miss favours the player
        primitive.scale    = float3( 0.62f, 0.34f, 0.34f );
        primitive.color    = color4( 1.0f, 0.5f, 0.35f, 1.0f );
        primitive.emissive = SWARM_EMISSIVE;
        enemy = { 3, 0.88f };
        enemy.kind = EEnemyKind::GUNNER;
    }
    else if( shell )
    {// heavy blocker plate — a moving wall chunk in the arena's own hexagon, slow plane spin
        shapeDef.density = 4.0f;
        const b3Sphere sphere = { { 0.0f, 0.0f, 0.0f }, 1.05f };
        b3CreateSphereShape( body, &shapeDef, &sphere );

        {
            b3MotionLocks locks = bodyDef.motionLocks;
            locks.angularX = true;
            locks.angularY = true;
            b3Body_SetMotionLocks( body, locks );
        }

        primitive.shape    = EPrimitive::HEXAGON;
        primitive.scale    = float3( 1.05f, 1.05f, 0.5f );
        primitive.color    = color4( 0.55f, 0.18f, 0.15f, 1.0f );   // desaturated — hostile but inert
        primitive.emissive = SWARM_EMISSIVE;
        enemy = { 20 + static_cast<int32_t>( crMath::Clamp( seconds / 20.0f, 0.0f, 10.0f ) ), 0.30f };
        enemy.kind = EEnemyKind::SHELL;
    }
    else if( runner )
    {// small, fast, hot scarlet — the one you dodge first
        shapeDef.density = 0.8f;
        const b3Sphere sphere = { { 0.0f, 0.0f, 0.0f }, 0.38f };
        b3CreateSphereShape( body, &shapeDef, &sphere );

        primitive.shape    = EPrimitive::SPHERE;
        primitive.scale    = float3( 0.38f, 0.38f, 0.38f );
        primitive.color    = RUNNER_COLORS[ _rng.NextInt32( 0, 2 ) ];
        primitive.emissive = SWARM_EMISSIVE;
        enemy = { 1, 1.95f };
        enemy.kind = EEnemyKind::RUNNER;
    }
    else
    {// grunt — a kite in one of the warm hues, nose servoed onto the ship and rolling about it;
        // a second hit point late in the run. rotation is left FREE on every axis: the servo drives
        // all three components itself, and a hard lock would forbid the roll
        shapeDef.density = 1.0f;
        const b3BoxHull hull = b3MakeBoxHull( 0.48f, 0.48f, 0.48f );
        b3CreateHullShape( body, &shapeDef, &hull.base );

        primitive.shape    = EPrimitive::DIAMOND;
        primitive.scale    = float3( 0.62f, 0.36f, 0.36f );
        primitive.color    = GRUNT_COLORS[ _rng.NextInt32( 0, 4 ) ];
        primitive.emissive = SWARM_EMISSIVE;
        enemy = { ( seconds > HP_RAMP_AFTER ) ? 2 : 1, 1.05f };
    }

    b3Body_SetAngularVelocity( body, { _rng.NextFloat32( -3.0f, 3.0f ),
                                       _rng.NextFloat32( -3.0f, 3.0f ),
                                       _rng.NextFloat32( -3.0f, 3.0f ) } );
    if( shell )
        b3Body_SetAngularVelocity( body, { 0.0f, 0.0f, _rng.NextFloat32( -0.7f, 0.7f ) } );   // a wall drifts, it does not whirl

    enemy.baseColor     = primitive.color;
    enemy.baseEmissive  = primitive.emissive;
    enemy.baseScale     = primitive.scale;
    enemy.phase         = _rng.NextFloat32( 0.0f, 2.0f * crMath::PI );
    enemy.spawnStep     = app->SimulationStepIndex();
    enemy.nextLungeStep = app->SimulationStepIndex() + static_cast<uint64_t>( _rng.NextInt32( 120, 360 ) );

    const entt::entity e = CreateDynamicEntity( registry, body, bodyDef.position, bodyDef.rotation );
    registry.emplace<CPrimitive>( e, primitive );
    registry.emplace<CEnemy>( e, enemy );

    if( ( gunner || shell ) == false )
    {// speed reads through the streak — everything that travels drags one. the two loiterers are
        // out: a near-stationary emitter smears into a blob, not a line
        const bool grunt = ( ( elite || brute || runner ) == false );

        CRibbonTrail rt;
        rt.color         = primitive.color;
        // uniform width, dim head: a full-width additive ribbon at low intensity reads as a volume
        // the body plows through, not a light streak (the ship keeps the classic tapered flame)
        rt.headHalfWidth = TrailHalfWidth( primitive.scale );
        rt.tailHalfWidth = rt.headHalfWidth;
        rt.intensity     = elite ? 1.0f : 0.85f;
        rt.emitDistance  = 0.15f;
        rt.fadeDuration  = 0.3f;
        // trail length is speed x duration, so the slow heavyweights need far more of it than the
        // runner does — under ~0.5 s theirs never clears the body it is dragged behind
        rt.duration      = elite ? 0.9f : ( brute ? 0.95f : 0.5f );
        rt.capacity      = ERibbonCapacity::SEC_1;

        if( grunt )
        {
            rt.tailHalfWidth = 0.0f;   // tapers to a point
            rt.intensity     = 0.55f;
            rt.duration      = 0.35f;
        }

        if( elite )
        {// off the tail, not the middle — the STAR's rear point sits at -0.62 in its outline, and
            // the inset keeps the emitter inside the hull so the band leaves a cross-section
            // rather than hanging off a single point. the grunt emits from its centre: it both
            // rolls and servos, and an offset emitter on a turning body swings on an arc
            rt.emitOffset = float3( ( -primitive.scale.x * 0.62f * 0.75f ), 0.0f, 0.0f );
        }

        registry.emplace<CRibbonTrail>( e, rt );
    }

    _enemies.Add( e );

    {// spawn telegraph — a small flash where it materialized
        CParticleEmitter fx;
        fx.color        = primitive.color;
        fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
        fx.drag         = 1.5f;
        fx.spread       = 2.2f;
        fx.lifetimeMin  = 0.15f;
        fx.lifetimeMax  = 0.4f;
        fx.sizeMin      = 0.075f;
        fx.sizeMax      = 0.165f;
        fx.sizeEndScale = 0.2f;
        fx.intensityMin = 2.0f;
        fx.intensityMax = 3.5f;
        fx.spinMin      = -4.0f;
        fx.spinMax      = 4.0f;
        fx.spriteIndex  = _fxSprite;
        ScaleFx( &fx, FX_BURST_SIZE, FX_BURST_SPEED );
        app->particles->EmitBurst( app, fx, float3( x, y, 1.0f ), 16 );
    }
}
void Game::SpawnDart( const crApp* app, float3 pos, float3 dir )
{
    entt::registry& registry = app->ecs->registry;

    b3BodyDef bodyDef = b3DefaultBodyDef();
    bodyDef.type        = b3_dynamicBody;
    bodyDef.position    = { pos.x, pos.y, 0.0f };
    bodyDef.enableSleep = false;
    bodyDef.motionLocks.linearZ  = true;
    bodyDef.motionLocks.angularX = true;   // no tumble — it reads as a projectile
    bodyDef.motionLocks.angularY = true;
    bodyDef.motionLocks.angularZ = true;

    {// nose along the flight line
        const float2 cs = crMath::CosSin( crMath::Atan2( dir.y, dir.x ) * 0.5f );
        bodyDef.rotation = { { 0.0f, 0.0f, cs.y }, cs.x };
    }

    const b3BodyId body = b3CreateBody( app->physics->World(), &bodyDef );

    b3ShapeDef shapeDef = b3DefaultShapeDef();
    shapeDef.density = 1.0f;
    shapeDef.filter.categoryBits = CAT_ENEMY;
    const b3Sphere sphere = { { 0.0f, 0.0f, 0.0f }, 0.34f };
    b3CreateSphereShape( body, &shapeDef, &sphere );

    b3Body_SetLinearVelocity( body, { dir.x * DART_SPEED, dir.y * DART_SPEED, 0.0f } );

    const entt::entity e = CreateDynamicEntity( registry, body, bodyDef.position, bodyDef.rotation );

    CPrimitive primitive;
    primitive.shape    = EPrimitive::OCTAHEDRON;   // stretched to a needle — the missile profile
    primitive.flags    = EPrimitiveFlags::NONE;
    primitive.scale    = float3( 0.55f, 0.16f, 0.16f );
    primitive.color    = color4( 1.0f, 0.2f, 0.05f, 1.0f );
    primitive.emissive = 2.6f;
    registry.emplace<CPrimitive>( e, primitive );

    CEnemy enemy = { 2, 1.0f };
    enemy.kind         = EEnemyKind::DART;
    enemy.dartDir      = dir;
    enemy.baseColor    = primitive.color;
    enemy.baseEmissive = primitive.emissive;
    enemy.baseScale    = primitive.scale;
    enemy.phase        = _rng.NextFloat32( 0.0f, 2.0f * crMath::PI );
    enemy.spawnStep    = app->SimulationStepIndex();
    registry.emplace<CEnemy>( e, enemy );

    CRibbonTrail rt;
    rt.color         = primitive.color;
    rt.emitOffset    = float3( ( -primitive.scale.x * 0.75f ), 0.0f, 0.0f );   // octahedron closes on the axis
    rt.headHalfWidth = 0.16f;   // the needle's cross-section, not its length — a fat wake reads as a bigger threat
    rt.tailHalfWidth = 0.16f;
    rt.intensity     = 1.3f;
    rt.emitDistance  = 0.15f;
    rt.fadeDuration  = 0.25f;
    rt.duration      = 0.35f;
    rt.capacity      = ERibbonCapacity::SEC_1;
    registry.emplace<CRibbonTrail>( e, rt );

    _enemies.Add( e );
}
void Game::SpawnBoss( const crApp* app, float3 pos )
{
    entt::registry& registry = app->ecs->registry;

    const float seconds = ( ( float )app->SimulationStepIndex() * crApp::FIXED_TIMESTEP );

    b3BodyDef bodyDef = b3DefaultBodyDef();
    bodyDef.type        = b3_dynamicBody;
    bodyDef.position    = { pos.x, pos.y, 0.0f };
    bodyDef.enableSleep = false;
    bodyDef.motionLocks.linearZ = true;

    const b3BodyId body = b3CreateBody( app->physics->World(), &bodyDef );

    b3ShapeDef shapeDef = b3DefaultShapeDef();
    shapeDef.density = 3.0f;
    shapeDef.filter.categoryBits = CAT_ENEMY;
    const b3Sphere sphere = { { 0.0f, 0.0f, 0.0f }, 2.0f };
    b3CreateSphereShape( body, &shapeDef, &sphere );

    b3Body_SetAngularVelocity( body, { 0.3f, 0.5f, 0.2f } );

    const entt::entity e = CreateDynamicEntity( registry, body, bodyDef.position, bodyDef.rotation );

    CPrimitive primitive;
    primitive.shape    = EPrimitive::ICOSAHEDRON;
    primitive.flags    = EPrimitiveFlags::NONE;
    primitive.scale    = float3( 2.2f, 2.2f, 2.2f );
    primitive.color    = color4( 0.75f, 0.08f, 0.12f, 1.0f );
    primitive.emissive = 0.9f;
    primitive.fresnel  = 2.0f;
    SetBands( primitive, 3.0f );   // the shader bands crawl over it — it reads as alive
    primitive.material = _fxMaterial;
    registry.emplace<CPrimitive>( e, primitive );

    CEnemy enemy = { 150 + static_cast<int32_t>( seconds * 0.5f ), 1.0f };
    enemy.kind         = EEnemyKind::BOSS;
    enemy.baseColor    = primitive.color;
    enemy.baseEmissive = primitive.emissive;
    enemy.baseScale    = primitive.scale;
    enemy.spawnStep    = app->SimulationStepIndex();
    registry.emplace<CEnemy>( e, enemy );

    CRibbonTrail rt;
    rt.color         = primitive.color;
    rt.headHalfWidth = TrailHalfWidth( primitive.scale );
    rt.tailHalfWidth = rt.headHalfWidth;
    rt.intensity     = 1.0f;
    rt.emitDistance  = 0.2f;
    rt.fadeDuration  = 0.4f;
    // 2 s x its chase speed is what it takes for the trail to clear a 4.4 m hull; that many points
    // no longer fits the 1 s class, and there is only ever one boss to spend a 5 s slot on
    rt.duration      = 2.0f;
    rt.capacity      = ERibbonCapacity::SEC_5;
    registry.emplace<CRibbonTrail>( e, rt );

    _enemies.Add( e );
    _boss                = e;
    _bossHpMax           = enemy.hp;
    _bossPhase2          = false;
    _nextBossVolleyStep  = app->SimulationStepIndex() + 120;

    {// landing blast
        CParticleEmitter fx;
        fx.color        = color4( 1.0f, 0.15f, 0.1f, 1.0f );
        fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
        fx.drag         = 1.2f;
        fx.spread       = 9.0f;
        fx.lifetimeMin  = 0.25f;
        fx.lifetimeMax  = 0.6f;
        fx.sizeMin      = 0.12f;
        fx.sizeMax      = 0.3f;
        fx.sizeEndScale = 0.25f;
        fx.intensityMin = 2.5f;
        fx.intensityMax = 4.5f;
        fx.spinMin      = -7.0f;
        fx.spinMax      = 7.0f;
        fx.spriteIndex  = _fxSprite;
        ScaleFx( &fx, FX_BURST_SIZE, FX_BURST_SPEED );
        app->particles->EmitBurst( app, fx, pos, 192 );
    }
    AddRipple( pos, 14.0f, 0.6f, 0.035f );
    _chromaPulse = crMath::Clamp( _chromaPulse + 0.6f, 0.0f, 1.5f );
    _shakeMag    = 1.0f;
    app->audio->PlaySfx( _sfxTest, 0.9f );
}
void Game::SpawnVortex( const crApp* app, float3 pos )
{
    if( _vortexCount >= VORTEX_CAP )
        return;

    entt::registry& registry = app->ecs->registry;

    Vortex vortex;
    vortex.pos = pos;
    {
        const float2 cs = crMath::CosSin( _rng.NextFloat32( 0.0f, 2.0f * crMath::PI ) );
        vortex.drift = float2( cs.x * 0.35f, cs.y * 0.35f );   // slow — it stays in its corner
    }

    {// nebula body — additive blob billowing with warp; carries the churn emitter
        const entt::entity e = registry.create();

        CTransform t;
        t.current  = { { pos.x, pos.y, -0.4f }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
        t.previous = t.current;
        registry.emplace<CTransform>( e, t );

        CPrimitive primitive;
        primitive.shape     = EPrimitive::SPHERE;
        primitive.flags     = EPrimitiveFlags::NONE;
        primitive.blend     = EBlendMode::ADDITIVE;
        primitive.scale     = float3( 5.2f, 5.2f, 2.0f );
        primitive.color     = color4( 0.3f, 0.25f, 0.42f, 0.14f );
        primitive.emissive  = 1.0f;
        SetFlow( primitive, 0.5f, 0.12f, 0.8f );
        registry.emplace<CPrimitive>( e, primitive );

        CParticleEmitter churn;
        churn.color        = color4( 0.5f, 0.4f, 0.75f, 1.0f );
        churn.gravity      = float3( 0.0f, 0.0f, 0.0f );
        churn.drag         = 0.6f;
        churn.rate         = 37.5f;
        churn.spread       = 2.2f;
        churn.lifetimeMin  = 1.0f;
        churn.lifetimeMax  = 2.2f;
        churn.sizeMin      = 0.075f;
        churn.sizeMax      = 0.18f;
        churn.sizeEndScale = 0.3f;
        churn.intensityMin = 1.2f;
        churn.intensityMax = 2.0f;
        churn.spinMin      = -3.0f;
        churn.spinMax      = 3.0f;
        churn.spriteIndex  = _fxSprite;
        ScaleFx( &churn, FX_STREAM_SIZE, FX_STREAM_SPEED );
        registry.emplace<CParticleEmitter>( e, churn );

        vortex.blob = e;
    }

    {// swirl ring — radial bands, visibly spinning
        const entt::entity e = registry.create();

        CTransform t;
        t.current  = { { pos.x, pos.y, 0.1f }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
        t.previous = t.current;
        registry.emplace<CTransform>( e, t );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::RING;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.blend    = EBlendMode::ADDITIVE;
        primitive.scale    = float3( 4.4f, 4.4f, 1.0f );
        primitive.color    = color4( 0.45f, 0.38f, 0.7f, 0.16f );
        primitive.emissive = 0.9f;
        SetBands( primitive, -4.0f );
        primitive.material = _fxMaterial;
        registry.emplace<CPrimitive>( e, primitive );

        vortex.swirl = e;
    }

    _vortices[ _vortexCount ] = vortex;
    ++_vortexCount;
}
void Game::SpawnGem( const crApp* app, float3 pos, int32_t value )
{
    if( _gems.Size() >= GEM_CAP )
        return;

    entt::registry& registry = app->ecs->registry;

    const entt::entity e = registry.create();

    CTransform t;
    t.current  = { { pos.x, pos.y, 0.0f }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
    t.previous = t.current;
    registry.emplace<CTransform>( e, t );

    const float size = ( value > 1 ) ? 0.21f : 0.14f;

    CPrimitive primitive;
    primitive.shape    = EPrimitive::OCTAHEDRON;   // crystals — deep blue is the pickup channel, no enemy shares it
    primitive.flags    = EPrimitiveFlags::NONE;
    primitive.scale    = float3( size, size, size );
    primitive.color    = ( value > 1 ) ? color4( 0.2f, 0.55f, 1.0f, 1.0f ) : color4( 0.08f, 0.35f, 1.0f, 1.0f );
    primitive.emissive = 1.05f;
    registry.emplace<CPrimitive>( e, primitive );
    registry.emplace<CGem>( e, CGem{ value, _rng.NextFloat32( 0.0f, 2.0f * crMath::PI ) } );

    _gems.Add( e );
}
void Game::SpawnOrb( const crApp* app )
{
    if( _orbCount >= ORB_CAP )
        return;

    entt::registry& registry = app->ecs->registry;

    const entt::entity e = registry.create();

    CTransform t;
    t.current  = { { 0.0f, 0.0f, 0.2f }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
    t.previous = t.current;
    registry.emplace<CTransform>( e, t );

    CPrimitive primitive;
    primitive.shape    = EPrimitive::OCTAHEDRON;   // indigo blade — 253 deg, 61 deg clear of the carrier's magenta
    primitive.flags    = EPrimitiveFlags::NONE;
    primitive.scale    = float3( 0.28f, 0.11f, 0.09f );
    primitive.color    = ORB_COLOR;
    primitive.emissive = 1.0f;
    registry.emplace<CPrimitive>( e, primitive );

    CRibbonTrail rt;
    rt.color         = ORB_COLOR;
    rt.headHalfWidth = 0.1f;
    rt.intensity     = 1.25f;
    rt.emitDistance  = 0.12f;
    rt.fadeDuration  = 0.3f;
    rt.duration      = 0.45f;
    rt.capacity      = ERibbonCapacity::SEC_1;
    registry.emplace<CRibbonTrail>( e, rt );

    _orbs[ _orbCount ] = e;
    ++_orbCount;
}
void Game::SpawnPod( const crApp* app )
{
    if( _podCount >= POD_CAP )
        return;

    entt::registry& registry = app->ecs->registry;

    const entt::entity e = registry.create();

    CTransform t;
    t.current  = { { 0.0f, 0.0f, 0.25f }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
    t.previous = t.current;
    registry.emplace<CTransform>( e, t );

    CPrimitive primitive;
    primitive.shape    = EPrimitive::SPHERE;   // magenta carrier pod — round, and the drone it launches shares its hue on purpose
    primitive.flags    = EPrimitiveFlags::NONE;
    primitive.scale    = float3( 0.19f, 0.19f, 0.19f );
    primitive.color    = CARRIER_COLOR;
    primitive.emissive = 0.95f;
    registry.emplace<CPrimitive>( e, primitive );

    CRibbonTrail rt;
    rt.color         = CARRIER_COLOR;
    rt.headHalfWidth = 0.07f;
    rt.intensity     = 1.15f;
    rt.emitDistance  = 0.12f;
    rt.fadeDuration  = 0.25f;
    rt.duration      = 0.35f;
    rt.capacity      = ERibbonCapacity::SEC_1;
    registry.emplace<CRibbonTrail>( e, rt );

    _pods[ _podCount ] = e;
    ++_podCount;
}
void Game::FireBullet( const crApp* app, float3 from, float3 dir, color4 color )
{
    entt::registry& registry = app->ecs->registry;

    b3BodyDef bodyDef = b3DefaultBodyDef();
    bodyDef.type        = b3_dynamicBody;
    bodyDef.position    = { from.x + ( dir.x * 0.6f ), from.y + ( dir.y * 0.6f ), 0.0f };
    bodyDef.enableSleep = false;
    bodyDef.motionLocks.linearZ  = true;
    bodyDef.motionLocks.angularX = true;
    bodyDef.motionLocks.angularY = true;
    bodyDef.motionLocks.angularZ = true;

    {// the visual is a tracer bar along the flight line — rotation locked, so set it at birth
        const float2 cs = crMath::CosSin( crMath::Atan2( dir.y, dir.x ) * 0.5f );
        bodyDef.rotation = { { 0.0f, 0.0f, cs.y }, cs.x };
    }

    const b3BodyId body = b3CreateBody( app->physics->World(), &bodyDef );

    b3ShapeDef shapeDef = b3DefaultShapeDef();
    shapeDef.density         = 2.0f;
    shapeDef.enableHitEvents = true;
    shapeDef.filter.categoryBits = CAT_BULLET;
    shapeDef.filter.maskBits     = ( CAT_WORLD | CAT_ENEMY );   // no bullet-bullet, no self-hits
    const b3Sphere sphere = { { 0.0f, 0.0f, 0.0f }, 0.1f };
    b3CreateSphereShape( body, &shapeDef, &sphere );

    b3Body_SetLinearVelocity( body, { dir.x * _tune.bulletSpeed, dir.y * _tune.bulletSpeed, 0.0f } );

    const entt::entity e = CreateDynamicEntity( registry, body, bodyDef.position, bodyDef.rotation );

    CPrimitive primitive;
    primitive.shape    = EPrimitive::BOX;
    primitive.flags    = EPrimitiveFlags::NONE;
    primitive.scale    = float3( 0.06f, 0.015f, 0.015f );
    primitive.color    = color;
    primitive.emissive = 1.05f;
    registry.emplace<CPrimitive>( e, primitive );
    registry.emplace<CBullet>( e, CBullet{ app->SimulationStepIndex() + 72, _stats.pierce } );

    CRibbonTrail rt;
    rt.color         = color;
    rt.headHalfWidth = 0.05f;
    rt.intensity     = 1.4f;
    rt.emitDistance  = 0.15f;
    rt.fadeDuration  = 0.25f;
    rt.duration      = 0.35f;
    rt.capacity      = ERibbonCapacity::SEC_1;
    registry.emplace<CRibbonTrail>( e, rt );

    _bullets.Add( e );
}
void Game::FireMissiles( const crApp* app, float3 from )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();

    for( int32_t i = 0; i < _stats.missileCount; ++i )
    {
        // fan out evenly, the whole fan rotating volley to volley — missiles curve onto targets after launch
        const float  angle = ( static_cast<float>( i ) * ( ( 2.0f * crMath::PI ) / static_cast<float>( _stats.missileCount ) ) )
                           + ( static_cast<float>( step % 97 ) * 0.13f );
        const float2 cs    = crMath::CosSin( angle );
        const float2 half  = crMath::CosSin( angle * 0.5f );

        const entt::entity e = registry.create();

        CTransform t;
        t.current  = { { from.x + ( cs.x * 0.5f ), from.y + ( cs.y * 0.5f ), 0.2f }, { { 0.0f, 0.0f, half.y }, half.x } };
        t.previous = t.current;
        registry.emplace<CTransform>( e, t );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::BOX;   // green seeker — the only green on screen
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.scale    = float3( 0.2f, 0.06f, 0.06f );
        primitive.color    = SEEKER_COLOR;
        primitive.emissive = 1.05f;
        registry.emplace<CPrimitive>( e, primitive );

        CMissile missile;
        missile.expireStep = step + 210;
        missile.vel        = float3( cs.x * ( _tune.missileSpeed * 0.45f ), cs.y * ( _tune.missileSpeed * 0.45f ), 0.0f );
        missile.speed      = _tune.missileSpeed;
        missile.aoe        = _tune.missileAoE;
        missile.kick       = _tune.missileKick;
        missile.damage     = _tune.missileDamage;
        registry.emplace<CMissile>( e, missile );

        CRibbonTrail rt;
        rt.color         = SEEKER_COLOR;
        rt.headHalfWidth = 0.09f;
        rt.intensity     = 1.4f;
        rt.emitDistance  = 0.12f;
        rt.fadeDuration  = 0.4f;
        rt.duration      = 0.6f;
        rt.capacity      = ERibbonCapacity::SEC_1;
        registry.emplace<CRibbonTrail>( e, rt );

        _missiles.Add( e );
    }

    app->audio->PlaySfx( _sfxTest, 0.3f );
}
void Game::FireDrone( const crApp* app, float3 from )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();

    // launched outward from the pod's current bearing off the ship — it curves onto a target on its own
    const float  angle = ( _podAngle * -1.0f ) + ( static_cast<float>( _nextDronePod ) * 1.7f );
    const float2 cs    = crMath::CosSin( angle );
    const float2 half  = crMath::CosSin( angle * 0.5f );

    const entt::entity e = registry.create();

    CTransform t;
    t.current  = { { from.x, from.y, 0.2f }, { { 0.0f, 0.0f, half.y }, half.x } };
    t.previous = t.current;
    registry.emplace<CTransform>( e, t );

    CPrimitive primitive;
    primitive.shape    = EPrimitive::OCTAHEDRON;   // magenta drone shard — carrier lane, not the seeker's box
    primitive.flags    = EPrimitiveFlags::NONE;
    primitive.scale    = float3( 0.14f, 0.1f, 0.1f );
    primitive.color    = CARRIER_COLOR;
    primitive.emissive = 1.0f;
    registry.emplace<CPrimitive>( e, primitive );

    CMissile missile;
    missile.expireStep = step + 150;
    missile.vel        = float3( cs.x * ( _tune.droneSpeed * 0.4f ), cs.y * ( _tune.droneSpeed * 0.4f ), 0.0f );
    missile.speed      = _tune.droneSpeed;
    missile.aoe        = _tune.droneAoE;
    missile.kick       = _tune.droneKick;
    missile.damage     = _tune.droneDamage;
    registry.emplace<CMissile>( e, missile );

    CRibbonTrail rt;
    rt.color         = CARRIER_COLOR;
    rt.headHalfWidth = 0.07f;
    rt.intensity     = 1.25f;
    rt.emitDistance  = 0.12f;
    rt.fadeDuration  = 0.3f;
    rt.duration      = 0.45f;
    rt.capacity      = ERibbonCapacity::SEC_1;
    registry.emplace<CRibbonTrail>( e, rt );

    _missiles.Add( e );
}
void Game::FireEnemyShot( const crApp* app, float3 from, float3 dir )
{
    entt::registry& registry = app->ecs->registry;

    const entt::entity e = registry.create();

    const float2 half = crMath::CosSin( crMath::Atan2( dir.y, dir.x ) * 0.5f );

    CTransform t;
    t.current  = { { from.x + ( dir.x * 0.7f ), from.y + ( dir.y * 0.7f ), 0.15f }, { { 0.0f, 0.0f, half.y }, half.x } };
    t.previous = t.current;
    registry.emplace<CTransform>( e, t );

    CPrimitive primitive;
    primitive.shape    = EPrimitive::BOX;
    primitive.flags    = EPrimitiveFlags::NONE;
    primitive.scale    = float3( 0.11f, 0.035f, 0.035f );
    primitive.color    = color4( 1.0f, 0.25f, 0.1f, 1.0f );
    primitive.emissive = 3.0f;
    registry.emplace<CPrimitive>( e, primitive );

    CEnemyShot shot;
    shot.vel        = float3( dir.x * SHOT_SPEED, dir.y * SHOT_SPEED, 0.0f );
    shot.expireStep = app->SimulationStepIndex() + 300;
    registry.emplace<CEnemyShot>( e, shot );

    CRibbonTrail rt;
    rt.color         = color4( 1.0f, 0.25f, 0.1f, 1.0f );
    rt.headHalfWidth = 0.045f;
    rt.intensity     = 1.6f;
    rt.emitDistance  = 0.15f;
    rt.fadeDuration  = 0.2f;
    rt.duration      = 0.25f;
    rt.capacity      = ERibbonCapacity::SEC_1;
    registry.emplace<CRibbonTrail>( e, rt );

    _enemyShots.Add( e );
}

void Game::UpdatePlayerControl( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    if( ( _player == entt::null ) || ( registry.valid( _player ) == false ) )
        return;

    // one stick, whichever device drove it: a thumb on the glass, or WSAD
    const float2 in = app->inputSys->Stick( EStickId::LEFT );
    const float  ix = in.x;
    const float  iy = in.y;

    const b3BodyId body = registry.get<CPhysicsBody>( _player ).bodyId;
    b3Body_SetLinearDamping( body, _tune.damping );

    bool  thrusting = false;
    float inputMag  = 0.0f;
    if( ( ix != 0.0f ) || ( iy != 0.0f ) )
    {
        const float len   = crMath::Sqrt( ( ix * ix ) + ( iy * iy ) );
        const float power = ( _stats.thrust * len );   // the stick already arrives clamped to 0..1
        b3Body_ApplyForceToCenter( body, { ( ix / len ) * power, ( iy / len ) * power, 0.0f }, true );
        thrusting = true;
        inputMag  = len;
    }

    {// the hull turns to face the thrust — pose is authored, the solver's rotation is locked
        if( thrusting )
        {
            const float target = crMath::Atan2( iy, ix );
            float diff = ( target - _shipHeading );
            if( diff > crMath::PI )
                diff -= ( 2.0f * crMath::PI );
            else if( diff < -crMath::PI )
                diff += ( 2.0f * crMath::PI );
            const float maxStep = ( 10.0f * crApp::FIXED_TIMESTEP );
            _shipHeading += crMath::Clamp( diff, -maxStep, maxStep );
            if( _shipHeading > crMath::PI )
                _shipHeading -= ( 2.0f * crMath::PI );
            else if( _shipHeading < -crMath::PI )
                _shipHeading += ( 2.0f * crMath::PI );
        }

        const b3WorldTransform tf = b3Body_GetTransform( body );
        const float2 cs = crMath::CosSin( _shipHeading * 0.5f );
        b3Body_SetTransform( body, tf.p, { { 0.0f, 0.0f, cs.y }, cs.x } );
    }

    {// speed clamp
        const b3Vec3 vel     = b3Body_GetLinearVelocity( body );
        const float2 clamped = crMath::ClampLength2( float2( vel.x, vel.y ), _stats.maxSpeed );
        b3Body_SetLinearVelocity( body, { clamped.x, clamped.y, 0.0f } );
    }

    CParticleEmitter& flame = registry.get<CParticleEmitter>( _player );
    flame.rate  = thrusting ? ( 90.0f + ( 180.0f * inputMag ) ) : 0.0f;
    flame.color = crMath::LerpColor( color4( 1.0f, 0.25f, 0.1f, 1.0f ), color4( 0.3f, 0.9f, 1.0f, 1.0f ), crMath::Clamp01( _hp / _stats.hpMax ) );
    if( thrusting )
    {
        const float2 hd = crMath::CosSin( _shipHeading );
        flame.velocity = float3( -hd.x * 5.0f, -hd.y * 5.0f, 0.0f );
    }
}
void Game::UpdateEnemySteering( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    if( ( _player == entt::null ) || ( registry.valid( _player ) == false ) )
        return;

    const float3 playerPos = float3( registry.get<CTransform>( _player ).current.p );

    const uint64_t step    = app->SimulationStepIndex();
    const float    seconds = ( ( float )step * crApp::FIXED_TIMESTEP );
    const float    speed   = _tune.enemySpeed * ( 1.0f + ( seconds * _tune.speedRamp ) );

    if( step >= _nextFrenzyStep )
    {// frenzy onset — the whole swarm blinks white, then surges
        _frenzyEndStep  = step + static_cast<uint64_t>( crMath::Round( FRENZY_LEN / crApp::FIXED_TIMESTEP ) );
        _nextFrenzyStep = step + static_cast<uint64_t>( crMath::Round( FRENZY_PERIOD / crApp::FIXED_TIMESTEP ) );

        for( int32_t i = 0; i < _enemies.Size(); ++i )
        {
            if( registry.valid( _enemies.At( i ) ) )
                registry.get<CEnemy>( _enemies.At( i ) ).flash = 1.0f;
        }
        _shakeMag    = crMath::Clamp( _shakeMag + 0.15f, 0.0f, 1.0f );
        _chromaPulse = crMath::Clamp( _chromaPulse + 0.3f, 0.0f, 1.5f );
        _radialPulse = crMath::Clamp( _radialPulse + 0.5f, 0.0f, 1.0f );
        app->audio->PlaySfx( _sfxTest, 0.45f );
    }
    const bool frenzy = ( step < _frenzyEndStep );

    // blend toward the player, keep the rest — collision shoves survive the steering, so the
    // crowd jostles instead of marching in lockstep
    for( int32_t i = 0; i < _enemies.Size(); ++i )
    {
        const entt::entity e = _enemies.At( i );
        if( registry.valid( e ) == false )
            continue;

        const b3BodyId body = registry.get<CPhysicsBody>( e ).bodyId;
        const b3Pos    pos  = registry.get<CTransform>( e ).current.p;

        CEnemy& state = registry.get<CEnemy>( e );
        if( state.flash > 0.0f )
        {// hit flash — snap to white, decay back to the base hue over ~0.12 s
            state.flash = crMath::Clamp( state.flash - ( 8.0f * crApp::FIXED_TIMESTEP ), 0.0f, 1.0f );

            CPrimitive& primitive = registry.get<CPrimitive>( e );
            primitive.color    = crMath::LerpColor( state.baseColor, color4( 1.0f, 1.0f, 1.0f, 1.0f ), state.flash );
            primitive.emissive = ( state.baseEmissive + ( state.flash * FLASH_EMISSIVE ) );
        }

        const float dx  = ( playerPos.x - ( float )pos.x );
        const float dy  = ( playerPos.y - ( float )pos.y );
        const float len = crMath::Sqrt( ( dx * dx ) + ( dy * dy ) );
        if( len < 0.0001f )
            continue;

        if( state.kind == EEnemyKind::BOSS )
            continue;   // UpdateBoss owns it

        if( state.kind == EEnemyKind::DART )
        {// straight flight — no chase; detonates when it reaches the boundary
            b3Body_SetLinearVelocity( body, { state.dartDir.x * DART_SPEED, state.dartDir.y * DART_SPEED, 0.0f } );

            const float centerSq = ( ( ( float )pos.x * ( float )pos.x ) + ( ( float )pos.y * ( float )pos.y ) );
            if( centerSq > ( ( HEX_APOTHEM - 1.6f ) * ( HEX_APOTHEM - 1.6f ) ) )
            {
                const float3 at = float3( pos );

                CParticleEmitter fx;
                fx.color        = color4( 1.0f, 0.3f, 0.08f, 1.0f );
                fx.colorEnd     = color4( 0.4f, 0.06f, 0.02f, 1.0f );
                fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
                fx.drag         = 1.6f;
                fx.spread       = 7.0f;
                fx.lifetimeMin  = 0.15f;
                fx.lifetimeMax  = 0.45f;
                fx.sizeMin      = 0.09f;
                fx.sizeMax      = 0.21f;
                fx.sizeEndScale = 0.25f;
                fx.intensityMin = 2.5f;
                fx.intensityMax = 4.5f;
                fx.spinMin      = -8.0f;
                fx.spinMax      = 8.0f;
                fx.spriteIndex  = _fxSprite;
                ScaleFx( &fx, FX_BURST_SIZE, FX_BURST_SPEED );
                app->particles->EmitBurst( app, fx, at, 64 );
                AddRipple( at, DART_AOE * RIPPLE_BLAST_SPREAD, 0.3f, 0.015f );

                if( len < DART_AOE )
                    DamagePlayer( app, DART_DAMAGE, float3( dx / len, dy / len, 0.0f ) );

                b3DestroyBody( body );
                registry.destroy( e );
            }
            continue;
        }

        if( ( state.kind == EEnemyKind::GRUNT ) || ( state.kind == EEnemyKind::ELITE ) || ( state.kind == EEnemyKind::GUNNER ) )
        {// nose servo — steer the angular velocity rather than write the pose, so a collision can
            // still jolt the heading and the servo hauls it back. yaw comes off the forward vector,
            // not the quaternion's z term: with roll mixed in, the pure-Z reading is wrong
            const b3Quat q   = registry.get<CTransform>( e ).current.q;
            const b3Vec3 fwd = b3RotateVector( q, { 1.0f, 0.0f, 0.0f } );

            // weave — the nose wanders around the aim, so a unit closing in a straight line still
            // turns, which is the only thing that moves light across its facets
            const float aim  = ( crMath::Atan2( dy, dx ) + ( WEAVE_AMPLITUDE * crMath::Sin( ( seconds * WEAVE_RATE ) + state.phase ) ) );

            float diff = ( aim - crMath::Atan2( fwd.y, fwd.x ) );
            if( diff > crMath::PI )
                diff -= ( 2.0f * crMath::PI );
            else if( diff < -crMath::PI )
                diff += ( 2.0f * crMath::PI );

            const float wz = crMath::Clamp( diff * 10.0f, -14.0f, 14.0f );

            if( state.kind == EEnemyKind::GRUNT )
            {// the spike also rolls about its own nose. that component never disturbs the heading
                // (w x f is perpendicular to f), but a collision can tip the nose out of the play
                // plane and nothing else would bring it back — hence the restoring term, which is
                // cross( fwd, fwd flattened onto the plane ) and so vanishes when it is already flat
                const float flat = crMath::Sqrt( ( fwd.x * fwd.x ) + ( fwd.y * fwd.y ) );
                float3      fix  = float3( 0.0f, 0.0f, 0.0f );
                if( flat > 0.0001f )
                    fix = float3( ( -fwd.z * fwd.y ) / flat, ( fwd.z * fwd.x ) / flat, 0.0f );

                b3Body_SetAngularVelocity( body, { ( fwd.x * GRUNT_ROLL_RATE ) + ( fix.x * 12.0f ),
                                                   ( fwd.y * GRUNT_ROLL_RATE ) + ( fix.y * 12.0f ),
                                                   ( fwd.z * GRUNT_ROLL_RATE ) + wz } );
            }
            else
            {
                b3Body_SetAngularVelocity( body, { 0.0f, 0.0f, wz } );
            }
        }

        if( state.kind == EEnemyKind::GUNNER )
        {// hold the firing band [9, 13]: back off when crowded, advance when out of reach, else strafe
            float dirX;
            float dirY;
            if( len > 13.0f )
            {
                dirX = ( dx / len );
                dirY = ( dy / len );
            }
            else if( len < 9.0f )
            {
                dirX = ( -dx / len );
                dirY = ( -dy / len );
            }
            else
            {// strafe — direction split by entity parity so gunners don't carousel in lockstep
                const float side = ( ( entt::to_integral( e ) & 1 ) != 0 ) ? 1.0f : -1.0f;
                dirX = ( ( -dy / len ) * side );
                dirY = ( ( dx / len ) * side );
            }

            float gunnerSpeed = speed * state.speedMul;
            if( frenzy )
                gunnerSpeed *= 2.0f;

            const b3Vec3 gv = b3Body_GetLinearVelocity( body );
            const float  gk = _tune.enemySteer;
            b3Body_SetLinearVelocity( body, { gv.x + ( ( ( dirX * gunnerSpeed ) - gv.x ) * gk ),
                                              gv.y + ( ( ( dirY * gunnerSpeed ) - gv.y ) * gk ), 0.0f } );

            if( ( len < 15.0f ) && ( app->SimulationStepIndex() >= state.nextLungeStep ) )
            {// two-shot fan; the white flash is the muzzle telegraph
                state.flash         = 1.0f;
                state.nextLungeStep = app->SimulationStepIndex() + static_cast<uint64_t>( _rng.NextInt32( 130, 200 ) );

                const float aim = crMath::Atan2( dy, dx );
                for( int32_t s = 0; s < 2; ++s )
                {
                    const float2 cs = crMath::CosSin( aim + ( ( static_cast<float>( s ) - 0.5f ) * 0.16f ) );
                    FireEnemyShot( app, float3( pos ), float3( cs.x, cs.y, 0.0f ) );
                }
            }
            continue;
        }

        float typeSpeed = speed * state.speedMul;
        if( frenzy )
            typeSpeed *= 2.0f;   // storm-warning surge — genuinely threatening now
        if( len > 12.0f )
        {// rubber band — stragglers close the gap, so endless kiting stops paying
            typeSpeed *= ( 1.0f + ( crMath::Clamp01( ( len - 12.0f ) / 10.0f ) * 0.45f ) );
        }

        const b3Vec3 vel = b3Body_GetLinearVelocity( body );
        const float  tx  = ( dx / len ) * typeSpeed;
        const float  ty  = ( dy / len ) * typeSpeed;
        const float  k   = _tune.enemySteer;
        b3Body_SetLinearVelocity( body, { vel.x + ( ( tx - vel.x ) * k ), vel.y + ( ( ty - vel.y ) * k ), 0.0f } );

        // escalation: past LUNGE_AFTER the mobile types burst-charge when close — the white
        // flash doubles as the telegraph
        if( ( seconds > LUNGE_AFTER ) && ( state.speedMul >= 1.0f ) && ( len < 7.0f ) &&
            ( app->SimulationStepIndex() >= state.nextLungeStep ) )
        {
            b3Body_SetLinearVelocity( body, { ( dx / len ) * ( typeSpeed * 2.8f ), ( dy / len ) * ( typeSpeed * 2.8f ), 0.0f } );
            state.flash         = 1.0f;
            state.nextLungeStep = app->SimulationStepIndex() + static_cast<uint64_t>( _rng.NextInt32( 180, 420 ) );
        }
    }
}
void Game::UpdateAutoFire( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    if( ( _player == entt::null ) || ( registry.valid( _player ) == false ) )
        return;

    const uint64_t step = app->SimulationStepIndex();
    if( step < _nextFireStep )
        return;

    const float3 playerPos = float3( registry.get<CTransform>( _player ).current.p );

    // nearest enemy in range — shells only as a last resort, so blockers don't soak the aim
    float        bestSq   = ( _tune.fireRange * _tune.fireRange );
    entt::entity target   = entt::null;
    float3       targetAt = {};
    float        shellSq  = ( _tune.fireRange * _tune.fireRange );
    entt::entity shellTgt = entt::null;
    float3       shellAt  = {};
    for( int32_t i = 0; i < _enemies.Size(); ++i )
    {
        const entt::entity e = _enemies.At( i );
        if( registry.valid( e ) == false )
            continue;

        const float3 pos = float3( registry.get<CTransform>( e ).current.p );
        const float  dx  = ( pos.x - playerPos.x );
        const float  dy  = ( pos.y - playerPos.y );
        const float  sq  = ( dx * dx ) + ( dy * dy );

        if( registry.get<CEnemy>( e ).kind == EEnemyKind::SHELL )
        {
            if( sq < shellSq )
            {
                shellSq  = sq;
                shellTgt = e;
                shellAt  = pos;
            }
            continue;
        }

        if( sq < bestSq )
        {
            bestSq   = sq;
            target   = e;
            targetAt = pos;
        }
    }
    if( target == entt::null )
    {
        target   = shellTgt;
        targetAt = shellAt;
    }
    if( target == entt::null )
        return;   // nothing in range — hold fire, no cooldown burned

    const float dx  = ( targetAt.x - playerPos.x );
    const float dy  = ( targetAt.y - playerPos.y );
    const float len = crMath::Sqrt( ( dx * dx ) + ( dy * dy ) );
    if( len < 0.0001f )
        return;

    // volley — extra bullets fan out around the aim line, colors rolling through the palette
    const float   baseAngle = crMath::Atan2( dy, dx );
    const int32_t colorBase = static_cast<int32_t>( ( step / 9 ) % 3 );
    for( int32_t s = 0; s < _stats.bulletCount; ++s )
    {
        const float  offset = ( static_cast<float>( s ) - ( static_cast<float>( _stats.bulletCount - 1 ) * 0.5f ) ) * 0.14f;
        const float2 cs     = crMath::CosSin( baseAngle + offset );
        FireBullet( app, playerPos, float3( cs.x, cs.y, 0.0f ), BULLET_COLORS[ ( colorBase + s ) % 3 ] );
    }

    {// muzzle flash
        const float2 aim = crMath::CosSin( baseAngle );

        CParticleEmitter fx;
        fx.color        = BULLET_COLORS[ colorBase ];
        fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
        fx.drag         = 3.0f;
        fx.velocity     = float3( aim.x * 7.0f, aim.y * 7.0f, 0.0f );
        fx.spread       = 2.0f;
        fx.lifetimeMin  = 0.05f;
        fx.lifetimeMax  = 0.15f;
        fx.sizeMin      = 0.075f;
        fx.sizeMax      = 0.15f;
        fx.sizeEndScale = 0.3f;
        fx.intensityMin = 2.5f;
        fx.intensityMax = 4.0f;
        fx.spinMin      = -8.0f;
        fx.spinMax      = 8.0f;
        fx.spriteIndex  = _fxSprite;
        ScaleFx( &fx, FX_BURST_SIZE, FX_BURST_SPEED );
        app->particles->EmitBurst( app, fx, float3( playerPos.x + ( aim.x * 1.7f ), playerPos.y + ( aim.y * 1.7f ), 1.0f ), 6 );
    }
    app->audio->PlaySfx( _sfxTest, 0.25f, crMath::Clamp( playerPos.x * 0.03f, -1.0f, 1.0f ) );

    _nextFireStep = step + static_cast<uint64_t>( crMath::Round( _stats.fireInterval / crApp::FIXED_TIMESTEP ) );
}
void Game::UpdateOrbs( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    if( ( _orbCount <= 0 ) || ( _player == entt::null ) || ( registry.valid( _player ) == false ) )
        return;

    const uint64_t step = app->SimulationStepIndex();

    // spin-up sweep — every few seconds the blades flare out to double radius and carve a wide circle
    constexpr uint64_t SWEEP_PERIOD = 300;   // 5 s
    constexpr uint64_t SWEEP_LEN    = 54;    // 0.9 s

    float radiusMul = 1.0f;
    float spinMul   = 1.0f;
    {
        const uint64_t phase = ( step % SWEEP_PERIOD );
        if( phase < SWEEP_LEN )
        {
            const float t = ( static_cast<float>( phase ) / static_cast<float>( SWEEP_LEN ) );
            radiusMul = ( 1.0f + crMath::Sin( t * crMath::PI ) );
            spinMul   = 1.6f;
        }
    }

    _orbAngle += ( crApp::FIXED_TIMESTEP * _tune.orbSpin * spinMul );
    if( _orbAngle > ( 2.0f * crMath::PI ) )
        _orbAngle -= ( 2.0f * crMath::PI );

    const float3 playerPos = float3( registry.get<CTransform>( _player ).current.p );
    const float  orbitR    = ( _stats.orbRadius * radiusMul );

    for( int32_t i = 0; i < _orbCount; ++i )
    {
        const entt::entity orb = _orbs[ i ];
        if( ( orb == entt::null ) || ( registry.valid( orb ) == false ) )
            continue;

        const float  angle = _orbAngle + ( static_cast<float>( i ) * ( ( 2.0f * crMath::PI ) / static_cast<float>( _orbCount ) ) );
        const float2 cs    = crMath::CosSin( angle );
        const float2 half  = crMath::CosSin( ( angle + ( crMath::PI * 0.5f ) ) * 0.5f );   // blade rides tangent to the orbit

        CTransform& t = registry.get<CTransform>( orb );
        t.current.p = { playerPos.x + ( cs.x * orbitR ), playerPos.y + ( cs.y * orbitR ), 0.2f };
        t.current.q = { { 0.0f, 0.0f, half.y }, half.x };
    }

    if( step < _nextOrbHitStep )
        return;
    _nextOrbHitStep = step + 9;   // contact damage every 0.15 s — an orb parks in the swarm, not deletes it

    for( int32_t i = 0; i < _enemies.Size(); ++i )
    {
        const entt::entity e = _enemies.At( i );
        if( registry.valid( e ) == false )
            continue;

        const b3Pos pos = registry.get<CTransform>( e ).current.p;

        for( int32_t o = 0; o < _orbCount; ++o )
        {
            const entt::entity orb = _orbs[ o ];
            if( ( orb == entt::null ) || ( registry.valid( orb ) == false ) )
                continue;

            const b3Pos op = registry.get<CTransform>( orb ).current.p;
            const float dx = ( ( float )pos.x - ( float )op.x );
            const float dy = ( ( float )pos.y - ( float )op.y );
            const float sq = ( dx * dx ) + ( dy * dy );
            if( sq > ( 0.6f * 0.6f ) )
                continue;

            {// knock the target off the blade
                const float    len  = crMath::Sqrt( crMath::Clamp( sq, 0.0001f, 1.0f ) );
                const b3BodyId body = registry.get<CPhysicsBody>( e ).bodyId;
                const b3Vec3   vel  = b3Body_GetLinearVelocity( body );
                b3Body_SetLinearVelocity( body, { vel.x + ( ( dx / len ) * 4.0f ), vel.y + ( ( dy / len ) * 4.0f ), 0.0f } );
            }

            if( DamageEnemy( app, e, _tune.orbDamage ) )
                DestroyEnemy( app, e );
            break;   // this enemy is handled — the entity may be gone
        }
    }
}
void Game::UpdatePods( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    if( ( _podCount <= 0 ) || ( _player == entt::null ) || ( registry.valid( _player ) == false ) )
        return;

    _podAngle += ( crApp::FIXED_TIMESTEP * 1.6f );   // counter-rotates against the blades
    if( _podAngle > ( 2.0f * crMath::PI ) )
        _podAngle -= ( 2.0f * crMath::PI );

    const float3 playerPos = float3( registry.get<CTransform>( _player ).current.p );
    const float  orbitR    = ( _stats.orbRadius + 1.1f );

    for( int32_t i = 0; i < _podCount; ++i )
    {
        const entt::entity pod = _pods[ i ];
        if( ( pod == entt::null ) || ( registry.valid( pod ) == false ) )
            continue;

        const float  angle = ( -_podAngle ) + ( static_cast<float>( i ) * ( ( 2.0f * crMath::PI ) / static_cast<float>( _podCount ) ) );
        const float2 cs    = crMath::CosSin( angle );

        CTransform& t = registry.get<CTransform>( pod );
        t.current.p = { playerPos.x + ( cs.x * orbitR ), playerPos.y + ( cs.y * orbitR ), 0.25f };
    }

    const uint64_t step = app->SimulationStepIndex();
    if( step < _nextDroneStep )
        return;

    {// round-robin launch — total output scales with the pod count
        _nextDronePod = ( ( _nextDronePod + 1 ) % _podCount );
        _nextDroneStep = step + static_cast<uint64_t>( crMath::Round( ( _stats.dronePeriod / static_cast<float>( _podCount ) ) / crApp::FIXED_TIMESTEP ) );

        const entt::entity pod = _pods[ _nextDronePod ];
        if( ( pod != entt::null ) && registry.valid( pod ) )
            FireDrone( app, float3( registry.get<CTransform>( pod ).current.p ) );
    }
}
void Game::UpdateMissiles( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();

    if( ( _stats.missileCount > 0 ) && ( _player != entt::null ) && registry.valid( _player ) && ( step >= _nextMissileStep ) )
    {
        FireMissiles( app, float3( registry.get<CTransform>( _player ).current.p ) );
        _nextMissileStep = step + static_cast<uint64_t>( crMath::Round( _stats.missilePeriod / crApp::FIXED_TIMESTEP ) );
    }

    for( int32_t i = _missiles.Size() - 1; i >= 0; --i )
    {
        const entt::entity m = _missiles.At( i );
        if( registry.valid( m ) == false )
        {
            _missiles.At( i ) = _missiles.At( _missiles.Size() - 1 );
            _missiles.Pop();
            continue;
        }

        CMissile&   missile = registry.get<CMissile>( m );
        CTransform& t       = registry.get<CTransform>( m );

        if( ( missile.target == entt::null ) || ( registry.valid( missile.target ) == false ) )
        {// acquire: nearest living enemy
            missile.target = entt::null;
            float bestSq = ( 30.0f * 30.0f );
            for( int32_t c = 0; c < _enemies.Size(); ++c )
            {
                const entt::entity cand = _enemies.At( c );
                if( registry.valid( cand ) == false )
                    continue;

                const b3Pos cp = registry.get<CTransform>( cand ).current.p;
                const float dx = ( ( float )cp.x - t.current.p.x );
                const float dy = ( ( float )cp.y - t.current.p.y );
                const float sq = ( dx * dx ) + ( dy * dy );
                if( sq < bestSq )
                {
                    bestSq         = sq;
                    missile.target = cand;
                }
            }
        }

        bool burst = ( step >= missile.expireStep );

        if( missile.target != entt::null )
        {
            const b3Pos tp  = registry.get<CTransform>( missile.target ).current.p;
            const float dx  = ( ( float )tp.x - t.current.p.x );
            const float dy  = ( ( float )tp.y - t.current.p.y );
            const float sq  = ( dx * dx ) + ( dy * dy );
            const float len = crMath::Sqrt( sq );
            if( len > 0.0001f )
            {
                constexpr float STEER = 0.18f;
                missile.vel.x += ( ( ( dx / len ) * missile.speed ) - missile.vel.x ) * STEER;
                missile.vel.y += ( ( ( dy / len ) * missile.speed ) - missile.vel.y ) * STEER;
            }
            if( sq < ( 0.7f * 0.7f ) )
                burst = true;
        }

        t.current.p.x += ( missile.vel.x * crApp::FIXED_TIMESTEP );
        t.current.p.y += ( missile.vel.y * crApp::FIXED_TIMESTEP );

        {// face the travel direction
            const float2 half = crMath::CosSin( crMath::Atan2( missile.vel.y, missile.vel.x ) * 0.5f );
            t.current.q = { { 0.0f, 0.0f, half.y }, half.x };
        }

        if( ( ( t.current.p.x * t.current.p.x ) + ( t.current.p.y * t.current.p.y ) ) > ( 40.0f * 40.0f ) )
            burst = true;   // left the arena — don't chase into the void

        if( burst == false )
            continue;

        const float3 at = float3( t.current.p );

        for( int32_t c = 0; c < _enemies.Size(); ++c )
        {// AoE — damage and shove everything in the blast circle
            const entt::entity cand = _enemies.At( c );
            if( registry.valid( cand ) == false )
                continue;

            const b3Pos cp = registry.get<CTransform>( cand ).current.p;
            const float dx = ( ( float )cp.x - at.x );
            const float dy = ( ( float )cp.y - at.y );
            const float sq = ( dx * dx ) + ( dy * dy );
            if( sq > ( missile.aoe * missile.aoe ) )
                continue;

            const float    len  = crMath::Sqrt( crMath::Clamp( sq, 0.0001f, 10000.0f ) );
            const b3BodyId body = registry.get<CPhysicsBody>( cand ).bodyId;
            const b3Vec3   vel  = b3Body_GetLinearVelocity( body );
            b3Body_SetLinearVelocity( body, { vel.x + ( ( dx / len ) * missile.kick ), vel.y + ( ( dy / len ) * missile.kick ), 0.0f } );

            if( DamageEnemy( app, cand, missile.damage ) )
                DestroyEnemy( app, cand );
        }

        {// blast flash — in the projectile's own hue, and fading into a dimmed version of it, so a
            // weapon's colour survives the whole burst instead of resolving to a shared blue
            const color4 hue = registry.get<CPrimitive>( m ).color;

            CParticleEmitter fx;
            fx.color        = hue;
            fx.colorEnd     = color4( ( hue.r * 0.25f ), ( hue.g * 0.25f ), ( hue.b * 0.25f ), 1.0f );
            fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
            fx.drag         = 1.4f;
            fx.spread       = 6.0f;
            fx.lifetimeMin  = 0.15f;
            fx.lifetimeMax  = 0.4f;
            fx.sizeMin      = 0.105f;
            fx.sizeMax      = 0.24f;
            fx.sizeEndScale = 0.25f;
            fx.intensityMin = 2.5f;
            fx.intensityMax = 4.5f;
            fx.spinMin      = -8.0f;
            fx.spinMax      = 8.0f;
            fx.spriteIndex  = _fxSprite;
            ScaleFx( &fx, FX_BURST_SIZE, FX_BURST_SPEED );
            app->particles->EmitBurst( app, fx, at, 80 );

            CParticleEmitter hoop;
            hoop.color        = color4( ( 0.45f + ( hue.r * 0.55f ) ), ( 0.45f + ( hue.g * 0.55f ) ), ( 0.45f + ( hue.b * 0.55f ) ), 1.0f );   // the hue, lifted toward white
            hoop.gravity      = float3( 0.0f, 0.0f, 0.0f );
            hoop.drag         = 0.0f;
            hoop.lifetimeMin  = 0.3f;
            hoop.lifetimeMax  = 0.4f;
            hoop.sizeMin      = 0.9f;
            hoop.sizeMax      = 1.35f;
            hoop.sizeEndScale = 6.0f;
            hoop.intensityMin = 1.5f;
            hoop.intensityMax = 2.5f;
            hoop.spriteIndex  = _fxRing;
            ScaleFx( &hoop, FX_HOOP_SIZE, 1.0f );
            app->particles->EmitBurst( app, hoop, at, 3 );
        }
        AddRipple( at, missile.aoe * RIPPLE_BLAST_SPREAD, 0.35f, 0.02f );
        app->audio->PlaySfx( _sfxTest, 0.35f, crMath::Clamp( at.x * 0.03f, -1.0f, 1.0f ) );
        _shakeMag = crMath::Clamp( _shakeMag + 0.08f, 0.0f, 1.0f );

        registry.destroy( m );
        _missiles.At( i ) = _missiles.At( _missiles.Size() - 1 );
        _missiles.Pop();
    }
}
void Game::UpdateEnemyShots( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();

    const bool playerAlive = ( _player != entt::null ) && registry.valid( _player );
    const float3 playerPos = playerAlive ? float3( registry.get<CTransform>( _player ).current.p ) : float3( 0.0f, 0.0f, 0.0f );

    for( int32_t i = _enemyShots.Size() - 1; i >= 0; --i )
    {
        const entt::entity e = _enemyShots.At( i );
        if( registry.valid( e ) == false )
        {
            _enemyShots.At( i ) = _enemyShots.At( _enemyShots.Size() - 1 );
            _enemyShots.Pop();
            continue;
        }

        const CEnemyShot& shot = registry.get<CEnemyShot>( e );
        CTransform&       t    = registry.get<CTransform>( e );

        t.current.p.x += ( shot.vel.x * crApp::FIXED_TIMESTEP );
        t.current.p.y += ( shot.vel.y * crApp::FIXED_TIMESTEP );

        bool dead = ( step >= shot.expireStep ) ||
                    ( ( ( t.current.p.x * t.current.p.x ) + ( t.current.p.y * t.current.p.y ) ) > ( 40.0f * 40.0f ) );

        if( ( dead == false ) && playerAlive )
        {
            const float dx = ( playerPos.x - t.current.p.x );
            const float dy = ( playerPos.y - t.current.p.y );
            if( ( ( dx * dx ) + ( dy * dy ) ) < ( 0.55f * 0.55f ) )
            {
                const float vlen = crMath::Sqrt( ( shot.vel.x * shot.vel.x ) + ( shot.vel.y * shot.vel.y ) );
                const float3 knock = ( vlen > 0.001f ) ? float3( shot.vel.x / vlen, shot.vel.y / vlen, 0.0f ) : float3( 0.0f, 0.0f, 0.0f );
                DamagePlayer( app, SHOT_DAMAGE, knock );
                dead = true;
            }
        }

        if( dead )
        {
            registry.destroy( e );
            _enemyShots.At( i ) = _enemyShots.At( _enemyShots.Size() - 1 );
            _enemyShots.Pop();
        }
    }
}
void Game::UpdateBoss( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    if( ( _player == entt::null ) || ( registry.valid( _player ) == false ) )
        return;

    const uint64_t step      = app->SimulationStepIndex();
    const float3   playerPos = float3( registry.get<CTransform>( _player ).current.p );

    const bool bossAlive = ( _boss != entt::null ) && registry.valid( _boss );

    if( ( bossAlive == false ) && ( _bossArriveStep == 0 ) && ( step >= _nextBossStep ) )
    {// announce the arrival — a giant collapsing ring where it will land
        const float2 dir = crMath::CosSin( _rng.NextFloat32( 0.0f, 2.0f * crMath::PI ) );
        float bx = ( playerPos.x + ( dir.x * 14.0f ) );
        float by = ( playerPos.y + ( dir.y * 14.0f ) );
        {
            const float2 c = crMath::ClampLength2( float2( bx, by ), 34.0f );
            bx = c.x;
            by = c.y;
        }

        const entt::entity marker = registry.create();

        CTransform t;
        t.current  = { { bx, by, 0.06f }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
        t.previous = t.current;
        registry.emplace<CTransform>( marker, t );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::RING_THIN;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.blend    = EBlendMode::ADDITIVE;
        primitive.scale    = float3( 3.6f, 3.6f, 1.0f );
        primitive.color    = color4( 1.0f, 0.1f, 0.08f, 0.6f );
        primitive.emissive = 2.0f;
        SetRingBand( primitive, 0.3f );
        primitive.material = _fxMaterial;
        registry.emplace<CPrimitive>( marker, primitive );

        _bossMarker     = marker;
        _bossArriveStep = step + BOSS_ARRIVE_STEPS;

        AddRipple( float3( bx, by, 0.0f ), 16.0f, 0.8f, 0.025f );
        _chromaPulse = crMath::Clamp( _chromaPulse + 0.5f, 0.0f, 1.5f );
        _shakeMag    = crMath::Clamp( _shakeMag + 0.3f, 0.0f, 1.0f );
    }

    if( _bossArriveStep != 0 )
    {
        if( registry.valid( _bossMarker ) )
        {
            const float3 at = float3( registry.get<CTransform>( _bossMarker ).current.p );

            if( step >= _bossArriveStep )
            {
                registry.destroy( _bossMarker );
                _bossMarker     = entt::null;
                _bossArriveStep = 0;
                SpawnBoss( app, float3( at.x, at.y, 0.0f ) );
            }
            else
            {// the ring tightens onto the landing point
                const float t2 = 1.0f - ( static_cast<float>( _bossArriveStep - step ) / static_cast<float>( BOSS_ARRIVE_STEPS ) );

                CPrimitive& primitive = registry.get<CPrimitive>( _bossMarker );
                const float r = ( 3.6f - ( t2 * 1.4f ) );
                primitive.scale    = float3( r, r, 1.0f );
                primitive.emissive = ( 2.0f + ( t2 * 2.0f ) );
            }
        }
        else
            _bossArriveStep = 0;
        return;
    }

    if( bossAlive == false )
        return;

    CEnemy&     en   = registry.get<CEnemy>( _boss );
    CTransform& bt   = registry.get<CTransform>( _boss );
    const float3 bp  = float3( bt.current.p );
    const float  dx  = ( playerPos.x - bp.x );
    const float  dy  = ( playerPos.y - bp.y );
    const float  len = crMath::Sqrt( ( dx * dx ) + ( dy * dy ) );
    if( len < 0.0001f )
        return;

    if( ( _bossPhase2 == false ) && ( en.hp <= ( _bossHpMax / 2 ) ) )
    {// phase 2 — it gets fast, angry, and brings friends
        _bossPhase2 = true;
        en.flash    = 1.0f;

        for( int32_t i = 0; i < 4; ++i )
        {
            const float2 cs = crMath::CosSin( static_cast<float>( i ) * ( crMath::PI * 0.5f ) );
            QueueEnemySpawnAt( app, float3( bp.x + ( cs.x * 4.0f ), bp.y + ( cs.y * 4.0f ), 0.0f ) );
        }
        AddRipple( bp, 10.0f, 0.5f, 0.03f );
        _chromaPulse = crMath::Clamp( _chromaPulse + 0.5f, 0.0f, 1.5f );
        _shakeMag    = crMath::Clamp( _shakeMag + 0.3f, 0.0f, 1.0f );
        app->audio->PlaySfx( _sfxTest, 0.8f );
    }

    const b3BodyId body = registry.get<CPhysicsBody>( _boss ).bodyId;

    {// heavy chase
        const float  chaseSpeed = _tune.enemySpeed * ( _bossPhase2 ? 1.2f : 0.8f );
        const b3Vec3 vel        = b3Body_GetLinearVelocity( body );
        constexpr float STEER   = 0.08f;
        b3Body_SetLinearVelocity( body, { vel.x + ( ( ( ( dx / len ) * chaseSpeed ) - vel.x ) * STEER ),
                                          vel.y + ( ( ( ( dy / len ) * chaseSpeed ) - vel.y ) * STEER ), 0.0f } );
    }

    if( _bossPhase2 && ( step >= en.nextLungeStep ) )
    {// dash — telegraphed by the flash, then a hard burst at the ship
        en.flash         = 1.0f;
        en.nextLungeStep = step + 240;
        b3Body_SetLinearVelocity( body, { ( dx / len ) * 14.0f, ( dy / len ) * 14.0f, 0.0f } );
        AddRipple( bp, 6.0f, 0.3f, 0.02f );
    }

    if( step >= _nextBossVolleyStep )
    {// P1: a fan at the ship / P2: a radial burst in every direction
        _nextBossVolleyStep = step + ( _bossPhase2 ? 150 : 180 );
        en.flash = 1.0f;

        // muzzle shove — the radial burst throws it in every direction, so it gets the wider ring
        AddRipple( bp, ( _bossPhase2 ? 9.0f : 6.0f ), 0.3f, 0.026f );

        if( _bossPhase2 )
        {
            for( int32_t s = 0; s < 10; ++s )
            {
                const float2 cs = crMath::CosSin( static_cast<float>( s ) * ( ( 2.0f * crMath::PI ) / 10.0f ) );
                FireEnemyShot( app, bp, float3( cs.x, cs.y, 0.0f ) );
            }
        }
        else
        {
            const float aim = crMath::Atan2( dy, dx );
            for( int32_t s = 0; s < 5; ++s )
            {
                const float2 cs = crMath::CosSin( aim + ( ( static_cast<float>( s ) - 2.0f ) * 0.175f ) );
                FireEnemyShot( app, bp, float3( cs.x, cs.y, 0.0f ) );
            }
        }
    }
}
void Game::UpdateVortices( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    if( _vortexCount <= 0 )
        return;

    const float dt = crApp::FIXED_TIMESTEP;

    for( int32_t v = 0; v < _vortexCount; ++v )
    {
        Vortex& vortex = _vortices[ v ];

        vortex.pos.x += ( vortex.drift.x * dt );
        vortex.pos.y += ( vortex.drift.y * dt );
        {// wandered past the rim — turn back toward the arena
            const float centerSq = ( vortex.pos.x * vortex.pos.x ) + ( vortex.pos.y * vortex.pos.y );
            if( centerSq > ( 42.0f * 42.0f ) )
            {// keep it out near the rim, not wandering into the middle
                const float len = crMath::Sqrt( centerSq );
                vortex.drift = float2( ( -vortex.pos.x / len ) * 0.35f, ( -vortex.pos.y / len ) * 0.35f );
            }
        }

        vortex.spin += ( dt * 0.9f );
        if( vortex.spin > ( 2.0f * crMath::PI ) )
            vortex.spin -= ( 2.0f * crMath::PI );

        if( registry.valid( vortex.blob ) )
            registry.get<CTransform>( vortex.blob ).current.p = { vortex.pos.x, vortex.pos.y, -0.4f };
        if( registry.valid( vortex.swirl ) )
        {
            const float2 cs = crMath::CosSin( vortex.spin * 0.5f );
            CTransform& t = registry.get<CTransform>( vortex.swirl );
            t.current.p = { vortex.pos.x, vortex.pos.y, 0.1f };
            t.current.q = { { 0.0f, 0.0f, cs.y }, cs.x };
        }

        // pull — the swarm and loose gems spiral in; the ship feels a gentler tug
        for( int32_t i = 0; i < _enemies.Size(); ++i )
        {
            const entt::entity e = _enemies.At( i );
            if( registry.valid( e ) == false )
                continue;

            const EEnemyKind kind = registry.get<CEnemy>( e ).kind;
            if( ( kind == EEnemyKind::DART ) || ( kind == EEnemyKind::BOSS ) )
                continue;   // straight-liners and the boss ignore the pull

            const b3Pos pos  = registry.get<CTransform>( e ).current.p;
            const float dx   = ( vortex.pos.x - ( float )pos.x );
            const float dy   = ( vortex.pos.y - ( float )pos.y );
            const float dist = crMath::Sqrt( ( dx * dx ) + ( dy * dy ) );
            if( ( dist > VORTEX_RADIUS ) || ( dist < 0.4f ) )
                continue;

            const float    f    = ( VORTEX_PULL * ( 1.0f - ( dist / VORTEX_RADIUS ) ) * dt );
            const b3BodyId body = registry.get<CPhysicsBody>( e ).bodyId;
            const b3Vec3   vel  = b3Body_GetLinearVelocity( body );
            b3Body_SetLinearVelocity( body, { vel.x + ( ( dx / dist ) * f ), vel.y + ( ( dy / dist ) * f ), 0.0f } );
        }

        if( ( _player != entt::null ) && registry.valid( _player ) )
        {
            const b3Pos pos  = registry.get<CTransform>( _player ).current.p;
            const float dx   = ( vortex.pos.x - ( float )pos.x );
            const float dy   = ( vortex.pos.y - ( float )pos.y );
            const float dist = crMath::Sqrt( ( dx * dx ) + ( dy * dy ) );
            if( ( dist < VORTEX_RADIUS ) && ( dist > 0.4f ) )
            {
                const float    f    = ( VORTEX_PULL * 0.4f * ( 1.0f - ( dist / VORTEX_RADIUS ) ) * dt );
                const b3BodyId body = registry.get<CPhysicsBody>( _player ).bodyId;
                const b3Vec3   vel  = b3Body_GetLinearVelocity( body );
                b3Body_SetLinearVelocity( body, { vel.x + ( ( dx / dist ) * f ), vel.y + ( ( dy / dist ) * f ), 0.0f } );
            }
        }

        for( int32_t i = 0; i < _gems.Size(); ++i )
        {
            const entt::entity e = _gems.At( i );
            if( registry.valid( e ) == false )
                continue;

            CTransform& t    = registry.get<CTransform>( e );
            const float dx   = ( vortex.pos.x - t.current.p.x );
            const float dy   = ( vortex.pos.y - t.current.p.y );
            const float dist = crMath::Sqrt( ( dx * dx ) + ( dy * dy ) );
            if( ( dist > VORTEX_RADIUS ) || ( dist < 0.4f ) )
                continue;

            const float f = ( 2.5f * ( 1.0f - ( dist / VORTEX_RADIUS ) ) * dt );
            t.current.p.x += ( ( dx / dist ) * f );
            t.current.p.y += ( ( dy / dist ) * f );
        }
    }
}
void Game::UpdateMeteors( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();

    if( ( _player != entt::null ) && registry.valid( _player ) && ( step >= _nextMeteorStep ) )
    {// schedule a strike group near the player — count grows with the threat tier
        _nextMeteorStep = step + static_cast<uint64_t>( crMath::Round( METEOR_PERIOD / crApp::FIXED_TIMESTEP ) );

        const float3  playerPos = float3( registry.get<CTransform>( _player ).current.p );
        const float   seconds   = ( ( float )step * crApp::FIXED_TIMESTEP );
        const int32_t tier      = 1 + static_cast<int32_t>( seconds / 40.0f );
        int32_t       count     = 1 + ( tier / 2 );
        if( count > 3 )
            count = 3;

        for( int32_t n = 0; n < count; ++n )
        {
            const float2 cs = crMath::CosSin( _rng.NextFloat32( 0.0f, 2.0f * crMath::PI ) );
            const float  r  = _rng.NextFloat32( 4.0f, 14.0f );

            float mx = ( playerPos.x + ( cs.x * r ) );
            float my = ( playerPos.y + ( cs.y * r ) );
            {
                const float2 c = crMath::ClampLength2( float2( mx, my ), 38.0f );
                mx = c.x;
                my = c.y;
            }

            PendingMeteor meteor;
            meteor.pos        = float3( mx, my, 0.0f );
            meteor.impactStep = step + METEOR_WARN_STEPS;

            {// impact marker
                const entt::entity marker = registry.create();

                CTransform t;
                t.current  = { { mx, my, 0.04f }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
                t.previous = t.current;
                registry.emplace<CTransform>( marker, t );

                CPrimitive primitive;
                primitive.shape    = EPrimitive::RING_THIN;
                primitive.flags    = EPrimitiveFlags::NONE;
                primitive.blend    = EBlendMode::ADDITIVE;
                primitive.scale    = float3( 1.6f, 1.6f, 1.0f );
                primitive.color    = color4( 1.0f, 0.45f, 0.1f, 0.5f );
                primitive.emissive = 1.8f;
                SetRingBand( primitive, 0.12f );
                primitive.material = _fxMaterial;
                registry.emplace<CPrimitive>( marker, primitive );

                meteor.marker = marker;
            }

            {// the rock itself, dropping in from high above the play plane
                const entt::entity rock = registry.create();

                CTransform t;
                t.current  = { { mx, my, 16.0f }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
                t.previous = t.current;
                registry.emplace<CTransform>( rock, t );

                CPrimitive primitive;
                primitive.shape    = EPrimitive::ICOSAHEDRON;
                primitive.flags    = EPrimitiveFlags::NONE;
                primitive.scale    = float3( 0.45f, 0.45f, 0.45f );
                primitive.color    = color4( 0.9f, 0.5f, 0.3f, 1.0f );
                primitive.emissive = 2.0f;
                registry.emplace<CPrimitive>( rock, primitive );

                meteor.rock = rock;
            }

            _meteors.Add( meteor );
        }
    }

    for( int32_t i = _meteors.Size() - 1; i >= 0; --i )
    {
        const PendingMeteor& meteor = _meteors.At( i );

        if( step < meteor.impactStep )
        {// descent — marker tightens, rock closes in
            const float t2 = 1.0f - ( static_cast<float>( meteor.impactStep - step ) / static_cast<float>( METEOR_WARN_STEPS ) );

            if( registry.valid( meteor.marker ) )
            {
                CPrimitive& primitive = registry.get<CPrimitive>( meteor.marker );
                const float r = ( 1.6f - ( t2 * 1.0f ) );
                primitive.scale    = float3( r, r, 1.0f );
                primitive.emissive = ( 1.8f + ( t2 * 2.0f ) );
            }
            if( registry.valid( meteor.rock ) )
                registry.get<CTransform>( meteor.rock ).current.p.z = ( 16.0f * ( 1.0f - t2 ) );
            continue;
        }

        const float3 at = meteor.pos;

        if( registry.valid( meteor.marker ) )
            registry.destroy( meteor.marker );
        if( registry.valid( meteor.rock ) )
            registry.destroy( meteor.rock );

        {// impact — shards, smoke, a hoop, and a hard ripple; hurts EVERYONE in the circle
            CParticleEmitter fx;
            fx.color           = color4( 1.0f, 0.55f, 0.2f, 1.0f );
            fx.colorEnd        = color4( 0.4f, 0.1f, 0.02f, 1.0f );
            fx.gravity         = float3( 0.0f, 0.0f, 0.0f );
            fx.drag            = 1.5f;
            fx.spread          = 8.0f;
            fx.lifetimeMin     = 0.2f;
            fx.lifetimeMax     = 0.55f;
            fx.sizeMin         = 0.09f;
            fx.sizeMax         = 0.225f;
            fx.sizeEndScale    = 0.25f;
            fx.intensityMin    = 2.5f;
            fx.intensityMax    = 4.5f;
            fx.spinMin         = -8.0f;
            fx.spinMax         = 8.0f;
            fx.alignToVelocity = true;
            fx.spriteIndex     = _fxSquare;
            ScaleFx( &fx, FX_BURST_SIZE, FX_BURST_SPEED );
            app->particles->EmitBurst( app, fx, at, 112 );

            CParticleEmitter hoop;
            hoop.color        = color4( 1.0f, 0.5f, 0.15f, 1.0f );
            hoop.gravity      = float3( 0.0f, 0.0f, 0.0f );
            hoop.drag         = 0.0f;
            hoop.lifetimeMin  = 0.3f;
            hoop.lifetimeMax  = 0.4f;
            hoop.sizeMin      = 1.05f;
            hoop.sizeMax      = 1.5f;
            hoop.sizeEndScale = 6.0f;
            hoop.intensityMin = 1.8f;
            hoop.intensityMax = 2.6f;
            hoop.spriteIndex  = _fxRing;
            ScaleFx( &hoop, FX_HOOP_SIZE, 1.0f );
            app->particles->EmitBurst( app, hoop, at, 3 );
        }
        AddRipple( at, METEOR_AOE * RIPPLE_BLAST_SPREAD, 0.4f, 0.03f );
        _shakeMag = crMath::Clamp( _shakeMag + 0.15f, 0.0f, 1.0f );
        app->audio->PlaySfx( _sfxTest, 0.5f, crMath::Clamp( at.x * 0.03f, -1.0f, 1.0f ) );

        if( ( _player != entt::null ) && registry.valid( _player ) )
        {
            const b3Pos pp = registry.get<CTransform>( _player ).current.p;
            const float dx = ( ( float )pp.x - at.x );
            const float dy = ( ( float )pp.y - at.y );
            const float ml = crMath::Sqrt( ( dx * dx ) + ( dy * dy ) );
            if( ml < METEOR_AOE )
                DamagePlayer( app, METEOR_DMG_PLAYER, ( ml > 0.001f ) ? float3( dx / ml, dy / ml, 0.0f ) : float3( 0.0f, 0.0f, 0.0f ) );
        }

        for( int32_t c = 0; c < _enemies.Size(); ++c )
        {// the swarm burns too — bait it under the rock
            const entt::entity cand = _enemies.At( c );
            if( registry.valid( cand ) == false )
                continue;

            const b3Pos cp = registry.get<CTransform>( cand ).current.p;
            const float dx = ( ( float )cp.x - at.x );
            const float dy = ( ( float )cp.y - at.y );
            const float sq = ( dx * dx ) + ( dy * dy );
            if( sq > ( METEOR_AOE * METEOR_AOE ) )
                continue;

            const float    len  = crMath::Sqrt( crMath::Clamp( sq, 0.0001f, 10000.0f ) );
            const b3BodyId body = registry.get<CPhysicsBody>( cand ).bodyId;
            const b3Vec3   vel  = b3Body_GetLinearVelocity( body );
            b3Body_SetLinearVelocity( body, { vel.x + ( ( dx / len ) * 8.0f ), vel.y + ( ( dy / len ) * 8.0f ), 0.0f } );

            if( DamageEnemy( app, cand, METEOR_DMG_ENEMY ) )
                DestroyEnemy( app, cand );
        }

        _meteors.At( i ) = _meteors.At( _meteors.Size() - 1 );
        _meteors.Pop();
    }
}
void Game::UpdateStorm( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step     = app->SimulationStepIndex();
    const uint64_t lenSteps = static_cast<uint64_t>( crMath::Round( STORM_LEN / crApp::FIXED_TIMESTEP ) );

    if( ( _stormActive == false ) && ( step >= _nextStormStep ) )
    {// a front rises off one edge and begins its crossing
        _stormActive   = true;
        _stormEndStep  = step + lenSteps;
        _nextStormStep = _stormEndStep + static_cast<uint64_t>( crMath::Round( STORM_PERIOD / crApp::FIXED_TIMESTEP ) );

        const float2 n = crMath::CosSin( _rng.NextFloat32( 0.0f, 2.0f * crMath::PI ) );
        _stormNormal = n;
        _stormGap    = _rng.NextFloat32( -HEX_RADIUS * 0.6f, HEX_RADIUS * 0.6f );   // safe channel offset

        _shakeMag    = crMath::Clamp( _shakeMag + 0.3f, 0.0f, 1.0f );
        _chromaPulse = crMath::Clamp( _chromaPulse + 0.4f, 0.0f, 1.5f );
        app->audio->PlaySfx( _sfxTest, 0.6f );
    }

    if( ( _stormActive == false ) || ( step >= _stormEndStep ) )
    {// idle or finished — hide the wall
        if( _stormActive && ( step >= _stormEndStep ) )
            _stormActive = false;
        for( int32_t i = 0; i < STORM_BLOBS; ++i )
        {
            if( registry.valid( _stormBlobs[ i ] ) )
                registry.get<CPrimitive>( _stormBlobs[ i ] ).scale = float3( 0.0f, 0.0f, 0.0f );
        }
        return;
    }

    const float progress  = 1.0f - ( static_cast<float>( _stormEndStep - step ) / static_cast<float>( lenSteps ) );
    const float startDist = ( HEX_APOTHEM + 12.0f );
    _stormOffset = ( -startDist + ( progress * ( 2.0f * startDist ) ) );

    const float2 tangent = float2( -_stormNormal.y, _stormNormal.x );
    const float  cx      = ( _stormNormal.x * _stormOffset );
    const float  cy      = ( _stormNormal.y * _stormOffset );

    // agitation drives the whole wall's brightness — it churns harder near the arena center
    const float pulse = ( 0.7f + ( 0.3f * crMath::Sin( _fxTime * 9.0f ) ) );

    for( int32_t i = 0; i < STORM_BLOBS; ++i )
    {
        if( registry.valid( _stormBlobs[ i ] ) == false )
            continue;

        const float span = ( ( static_cast<float>( i ) / static_cast<float>( STORM_BLOBS - 1 ) ) - 0.5f ) * ( 2.0f * HEX_RADIUS );

        CPrimitive& primitive = registry.get<CPrimitive>( _stormBlobs[ i ] );
        if( crMath::Abs( span - _stormGap ) < STORM_GAP_HALF )
        {// the safe channel — no wall here
            primitive.scale = float3( 0.0f, 0.0f, 0.0f );
            continue;
        }

        CTransform& t = registry.get<CTransform>( _stormBlobs[ i ] );
        t.current.p = { cx + ( tangent.x * span ), cy + ( tangent.y * span ), 0.5f };
        t.previous  = t.current;   // the whole front teleports as a unit each step — kill interp smear

        primitive.scale    = float3( 8.5f, 8.5f, 2.2f );
        primitive.emissive = ( 1.4f * pulse );
    }

    {// lightning + spume — a few bright arcs crackle along the wall each step
        for( int32_t k = 0; k < 2; ++k )
        {
            float span = _rng.NextFloat32( -HEX_RADIUS, HEX_RADIUS );
            if( crMath::Abs( span - _stormGap ) < STORM_GAP_HALF )
                continue;   // no lightning in the safe channel

            const float3 at = float3( cx + ( tangent.x * span ), cy + ( tangent.y * span ), 0.5f );

            CParticleEmitter bolt;
            bolt.color        = color4( 0.9f, 0.6f, 1.0f, 1.0f );
            bolt.colorEnd     = color4( 0.4f, 0.1f, 0.6f, 1.0f );
            bolt.gravity      = float3( 0.0f, 0.0f, 0.0f );
            bolt.drag         = 1.2f;
            bolt.spread       = 6.0f;
            bolt.lifetimeMin  = 0.1f;
            bolt.lifetimeMax  = 0.3f;
            bolt.sizeMin      = 0.075f;
            bolt.sizeMax      = 0.195f;
            bolt.sizeEndScale = 0.2f;
            bolt.intensityMin = 2.5f;
            bolt.intensityMax = 4.5f;
            bolt.spinMin      = -9.0f;
            bolt.spinMax      = 9.0f;
            bolt.spriteIndex  = _fxSprite;
            ScaleFx( &bolt, FX_BURST_SIZE, FX_BURST_SPEED );
            app->particles->EmitBurst( app, bolt, at, 6 );
        }
    }

    if( ( _player != entt::null ) && registry.valid( _player ) )
    {// inside the damaging band AND outside the safe channel? one chunk per i-frame window
        const b3Pos pp   = registry.get<CTransform>( _player ).current.p;
        const float proj = ( ( ( float )pp.x * _stormNormal.x ) + ( ( float )pp.y * _stormNormal.y ) );
        const float tan  = ( ( ( float )pp.x * tangent.x ) + ( ( float )pp.y * tangent.y ) );
        if( ( crMath::Abs( proj - _stormOffset ) < STORM_HALF ) && ( crMath::Abs( tan - _stormGap ) > STORM_GAP_HALF ) )
            DamagePlayer( app, STORM_DMG, float3( _stormNormal.x, _stormNormal.y, 0.0f ) );
    }
}
void Game::UpdateSpawner( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();

    // telegraphed arrivals — animate the countdown, then replace each due marker with the real enemy
    for( int32_t i = _pendingSpawns.Size() - 1; i >= 0; --i )
    {
        const PendingSpawn& pending = _pendingSpawns.At( i );
        if( step < pending.spawnStep )
        {
            if( registry.valid( pending.marker ) )
            {// the ring collapses onto the arrival point, heating up as it goes
                const float t = 1.0f - ( static_cast<float>( pending.spawnStep - step ) / static_cast<float>( SPAWN_WARN_STEPS ) );
                const float r = 1.3f - ( t * 0.95f );

                CPrimitive& primitive = registry.get<CPrimitive>( pending.marker );
                primitive.scale    = float3( r, r, 1.0f );
                primitive.emissive = ( 1.5f + ( t * 2.5f ) );
                primitive.color.a  = ( 0.4f + ( t * 0.5f ) );
            }
            continue;
        }

        if( registry.valid( pending.marker ) )
            registry.destroy( pending.marker );
        if( _enemies.Size() < _tune.enemyCap )
            SpawnEnemy( app, pending.pos );

        _pendingSpawns.At( i ) = _pendingSpawns.At( _pendingSpawns.Size() - 1 );
        _pendingSpawns.Pop();
    }

    for( int32_t i = _pendingDarts.Size() - 1; i >= 0; --i )
    {// dart launches — the drawn line pulses, then the dart takes it
        const PendingDart& pending = _pendingDarts.At( i );
        if( step < pending.spawnStep )
        {
            if( registry.valid( pending.marker ) )
                registry.get<CPrimitive>( pending.marker ).color.a = ( 0.35f + ( 0.25f * crMath::Sin( static_cast<float>( step % 157 ) * 0.4f ) ) );   // 157 x 0.4 ~= 10 x 2pi — near-seamless wrap
            continue;
        }

        if( registry.valid( pending.marker ) )
            registry.destroy( pending.marker );
        if( _enemies.Size() < _tune.enemyCap )
            SpawnDart( app, pending.from, pending.dir );

        _pendingDarts.At( i ) = _pendingDarts.At( _pendingDarts.Size() - 1 );
        _pendingDarts.Pop();
    }

    if( ( step >= _nextPatternStep ) && ( _player != entt::null ) && registry.valid( _player ) )
    {// formation event — a shaped arrival instead of another uniform sprinkle; markers draw the shape first
        _nextPatternStep = step + static_cast<uint64_t>( crMath::Round( PATTERN_PERIOD / crApp::FIXED_TIMESTEP ) );

        const float3 playerPos = float3( registry.get<CTransform>( _player ).current.p );

        // formations aim ahead of a moving player — the pressure lands on the escape route
        float centerAngle = _rng.NextFloat32( 0.0f, 2.0f * crMath::PI );
        {
            const b3Vec3 vel = b3Body_GetLinearVelocity( registry.get<CPhysicsBody>( _player ).bodyId );
            if( ( ( vel.x * vel.x ) + ( vel.y * vel.y ) ) > ( 3.0f * 3.0f ) )
                centerAngle = crMath::Atan2( vel.y, vel.x ) + _rng.NextFloat32( -0.5f, 0.5f );
        }

        const float   seconds = ( ( float )step * crApp::FIXED_TIMESTEP );
        const int32_t tier    = 1 + static_cast<int32_t>( seconds / 40.0f );
        const int32_t pool    = ( tier >= 3 ) ? 6 : ( ( tier >= 2 ) ? 5 : 2 );   // arc, column -> +pack, pincer, darts -> +encirclement
        const int32_t roll    = _rng.NextInt32( 0, pool );

        bool bigEvent = false;
        switch( roll )
        {
        case 0:
        {// arc — a curved front, roughly 100 degrees wide
            for( int32_t i = 0; ( i < 7 ) && ( ( _enemies.Size() + _pendingSpawns.Size() ) < _tune.enemyCap ); ++i )
            {
                const float2 cs = crMath::CosSin( centerAngle + ( ( static_cast<float>( i ) - 3.0f ) * 0.25f ) );
                QueueEnemySpawnAt( app, float3( playerPos.x + ( cs.x * 16.0f ), playerPos.y + ( cs.y * 16.0f ), 0.0f ) );
            }
            break;
        }
        case 1:
        {// column — a lance marching in from one bearing
            const float2 cs = crMath::CosSin( centerAngle );
            for( int32_t i = 0; ( i < 8 ) && ( ( _enemies.Size() + _pendingSpawns.Size() ) < _tune.enemyCap ); ++i )
            {
                const float r = ( 13.0f + ( static_cast<float>( i ) * 1.9f ) );
                QueueEnemySpawnAt( app, float3( playerPos.x + ( cs.x * r ), playerPos.y + ( cs.y * r ), 0.0f ) );
            }
            break;
        }
        case 2:
        {// pack — a tight blob
            const float2 cs = crMath::CosSin( centerAngle );
            const float  cx = playerPos.x + ( cs.x * 16.0f );
            const float  cy = playerPos.y + ( cs.y * 16.0f );
            for( int32_t i = 0; ( i < 6 ) && ( ( _enemies.Size() + _pendingSpawns.Size() ) < _tune.enemyCap ); ++i )
                QueueEnemySpawnAt( app, float3( cx + _rng.NextFloat32( -2.0f, 2.0f ), cy + _rng.NextFloat32( -2.0f, 2.0f ), 0.0f ) );
            break;
        }
        case 3:
        {// pincer — two arcs on opposite bearings
            for( int32_t side = 0; side < 2; ++side )
            {
                const float base = centerAngle + ( static_cast<float>( side ) * crMath::PI );
                for( int32_t i = 0; ( i < 5 ) && ( ( _enemies.Size() + _pendingSpawns.Size() ) < _tune.enemyCap ); ++i )
                {
                    const float2 cs = crMath::CosSin( base + ( ( static_cast<float>( i ) - 2.0f ) * 0.22f ) );
                    QueueEnemySpawnAt( app, float3( playerPos.x + ( cs.x * 16.0f ), playerPos.y + ( cs.y * 16.0f ), 0.0f ) );
                }
            }
            break;
        }
        case 4:
        {// dart volley — straight-line divers, their flight lines drawn first
            for( int32_t i = 0; i < 4; ++i )
            {
                const float2 cs = crMath::CosSin( centerAngle + ( ( static_cast<float>( i ) - 1.5f ) * 0.12f ) );

                float fx_ = ( playerPos.x + ( cs.x * 22.0f ) );
                float fy  = ( playerPos.y + ( cs.y * 22.0f ) );
                {// keep the launch point inside the arena
                    const float2 c = crMath::ClampLength2( float2( fx_, fy ), 37.0f );
                    fx_ = c.x;
                    fy  = c.y;
                }

                const float ddx = ( playerPos.x - fx_ );
                const float ddy = ( playerPos.y - fy );
                const float dl  = crMath::Sqrt( ( ddx * ddx ) + ( ddy * ddy ) );
                if( dl < 1.0f )
                    continue;   // clamp landed on the player — no fair shot from here

                const float3 dir = float3( ddx / dl, ddy / dl, 0.0f );

                entt::entity marker = entt::null;
                {// the flight line, drawn ahead of the launch
                    marker = registry.create();

                    const float2 half = crMath::CosSin( crMath::Atan2( dir.y, dir.x ) * 0.5f );

                    CTransform t;
                    t.current  = { { fx_ + ( dir.x * 14.0f ), fy + ( dir.y * 14.0f ), 0.05f }, { { 0.0f, 0.0f, half.y }, half.x } };
                    t.previous = t.current;
                    registry.emplace<CTransform>( marker, t );

                    CPrimitive primitive;
                    primitive.shape    = EPrimitive::BEAM;
                    primitive.flags    = EPrimitiveFlags::NONE;
                    primitive.blend    = EBlendMode::ADDITIVE;
                    primitive.scale    = float3( 14.0f, 0.04f, 0.04f );
                    primitive.color    = color4( 1.0f, 0.2f, 0.08f, 0.45f );
                    primitive.emissive = 1.8f;
                    registry.emplace<CPrimitive>( marker, primitive );
                }

                PendingDart pending;
                pending.from      = float3( fx_, fy, 0.0f );
                pending.dir       = dir;
                pending.spawnStep = step + DART_WARN_STEPS;
                pending.marker    = marker;
                _pendingDarts.Add( pending );
            }
            break;
        }
        default:
        {// encirclement — the full ring closes in at once
            for( int32_t i = 0; ( i < 14 ) && ( ( _enemies.Size() + _pendingSpawns.Size() ) < _tune.enemyCap ); ++i )
            {
                const float2 cs = crMath::CosSin( centerAngle + ( static_cast<float>( i ) * ( ( 2.0f * crMath::PI ) / 14.0f ) ) );
                QueueEnemySpawnAt( app, float3( playerPos.x + ( cs.x * 15.0f ), playerPos.y + ( cs.y * 15.0f ), 0.0f ) );
            }
            bigEvent = true;
            break;
        }
        }

        if( bigEvent )
        {
            AddRipple( playerPos, 18.0f, 0.6f, 0.02f );
            _chromaPulse = crMath::Clamp( _chromaPulse + 0.4f, 0.0f, 1.5f );
        }
        _shakeMag = crMath::Clamp( _shakeMag + ( bigEvent ? 0.2f : 0.1f ), 0.0f, 1.0f );
        app->audio->PlaySfx( _sfxTest, 0.5f );
    }

    if( step < _nextSpawnStep )
        return;

    if( ( _enemies.Size() + _pendingSpawns.Size() ) < _tune.enemyCap )
    {
        QueueEnemySpawn( app );
        if( ( _enemies.Size() + _pendingSpawns.Size() ) < _tune.enemyCap )
            QueueEnemySpawn( app );
    }

    _spawnInterval = crMath::Clamp( _spawnInterval * _tune.spawnFalloff, _tune.spawnMin, 10.0f );
    _nextSpawnStep = step + static_cast<uint64_t>( crMath::Round( _spawnInterval / crApp::FIXED_TIMESTEP ) );
}
void Game::UpdateNova( const crApp* app )
{
    // the shockwave's emitters are deliberately left out of the FX_BURST scaling: this is the one
    // burst that puts its whole count on a single point, and at the exaggerated size the additive
    // billboards stack until the blast is a white disc instead of a shockwave

    entt::registry& registry = app->ecs->registry;
    if( ( _player == entt::null ) || ( registry.valid( _player ) == false ) )
        return;

    const uint64_t step = app->SimulationStepIndex();
    if( step < _nextNovaStep )
        return;
    _nextNovaStep = step + static_cast<uint64_t>( crMath::Round( _stats.novaPeriod / crApp::FIXED_TIMESTEP ) );

    const float3 playerPos = float3( registry.get<CTransform>( _player ).current.p );

    for( int32_t i = 0; i < _enemies.Size(); ++i )
    {
        const entt::entity e = _enemies.At( i );
        if( registry.valid( e ) == false )
            continue;

        const b3Pos pos = registry.get<CTransform>( e ).current.p;
        const float dx  = ( ( float )pos.x - playerPos.x );
        const float dy  = ( ( float )pos.y - playerPos.y );
        const float sq  = ( dx * dx ) + ( dy * dy );
        if( ( sq > ( _stats.novaRadius * _stats.novaRadius ) ) || ( sq < 0.0001f ) )
            continue;

        const float    len  = crMath::Sqrt( sq );
        const b3BodyId body = registry.get<CPhysicsBody>( e ).bodyId;
        const b3Vec3   vel  = b3Body_GetLinearVelocity( body );
        b3Body_SetLinearVelocity( body, { vel.x + ( ( dx / len ) * _stats.novaKick ), vel.y + ( ( dy / len ) * _stats.novaKick ), 0.0f } );
    }

    const color4 novaColor = NOVA_COLORS[ _novaColorIndex ];
    _novaColorIndex = ( ( _novaColorIndex + 1 ) % 3 );

    {// center flash
        CParticleEmitter fx;
        fx.color        = novaColor;
        fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
        fx.drag         = 1.1f;
        fx.spread       = 10.0f;
        fx.lifetimeMin  = 0.25f;
        fx.lifetimeMax  = 0.6f;
        fx.sizeMin      = 0.09f;
        fx.sizeMax      = 0.2f;
        fx.sizeEndScale = 0.2f;
        fx.intensityMin = 2.0f;
        fx.intensityMax = 4.0f;
        fx.spinMin      = -5.0f;
        fx.spinMax      = 5.0f;
        fx.spriteIndex  = _fxSprite;
        app->particles->EmitBurst( app, fx, playerPos, 140 );
    }
    {// wavefront — shards launched outward from a circle around the ship, aligned to their flight
        CParticleEmitter fx;
        fx.color           = novaColor;
        fx.gravity         = float3( 0.0f, 0.0f, 0.0f );
        fx.drag            = 1.6f;
        fx.spread          = 1.5f;
        fx.lifetimeMin     = 0.3f;
        fx.lifetimeMax     = 0.55f;
        fx.sizeMin         = 0.08f;
        fx.sizeMax         = 0.16f;
        fx.sizeEndScale    = 0.15f;
        fx.intensityMin    = 2.5f;
        fx.intensityMax    = 4.0f;
        fx.alignToVelocity = true;
        fx.spriteIndex     = _fxSquare;

        for( int32_t i = 0; i < 48; ++i )
        {
            const float2 cs = crMath::CosSin( static_cast<float>( i ) * ( ( 2.0f * crMath::PI ) / 48.0f ) );
            fx.velocity = float3( cs.x * 14.0f, cs.y * 14.0f, 0.0f );
            app->particles->EmitBurst( app, fx, float3( playerPos.x + ( cs.x * 1.8f ), playerPos.y + ( cs.y * 1.8f ), 1.0f ), 2 );
        }
    }
    {// expanding hoops
        CParticleEmitter fx;
        fx.color        = novaColor;
        fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
        fx.drag         = 0.0f;
        fx.lifetimeMin  = 0.35f;
        fx.lifetimeMax  = 0.5f;
        fx.sizeMin      = 0.8f;
        fx.sizeMax      = 1.2f;
        fx.sizeEndScale = 7.0f;
        fx.intensityMin = 1.5f;
        fx.intensityMax = 2.5f;
        fx.spinMin      = -2.0f;
        fx.spinMax      = 2.0f;
        fx.spriteIndex  = _fxRing;
        app->particles->EmitBurst( app, fx, playerPos, 3 );
    }
    // double distortion front — a hard leading wave and a wider, softer echo
    AddRipple( playerPos, _stats.novaRadius, 0.35f, 0.035f );
    AddRipple( playerPos, _stats.novaRadius * 1.7f, 0.6f, 0.02f );
    _chromaPulse = crMath::Clamp( _chromaPulse + 0.5f, 0.0f, 1.5f );
    _radialPulse = crMath::Clamp( _radialPulse + 0.55f, 0.0f, 1.0f );
    app->audio->PlaySfx( _sfxTest, 0.7f );

    // launch the expanding shell from the ship
    if( ( _novaRing != entt::null ) && registry.valid( _novaRing ) )
    {
        CTransform& rt = registry.get<CTransform>( _novaRing );
        rt.current.p = { playerPos.x, playerPos.y, 0.15f };
        rt.previous  = rt.current;

        CPrimitive& ringPrimitive = registry.get<CPrimitive>( _novaRing );
        ringPrimitive.color = color4( novaColor.r, novaColor.g, novaColor.b, 0.4f );

        _novaRingAge = 0.0f;
    }
    _shakeMag = crMath::Clamp( _shakeMag + 0.25f, 0.0f, 1.0f );
}
void Game::UpdateNovaRing( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    if( ( _novaRing == entt::null ) || ( registry.valid( _novaRing ) == false ) )
        return;

    constexpr float RING_LIFE = 0.35f;

    _novaRingAge += crApp::FIXED_TIMESTEP;
    CPrimitive& primitive = registry.get<CPrimitive>( _novaRing );
    if( _novaRingAge >= RING_LIFE )
    {
        primitive.scale = float3( 0.0f, 0.0f, 1.0f );
        return;
    }

    const float t = ( _novaRingAge / RING_LIFE );
    const float r = ( _stats.novaRadius * t );
    primitive.scale    = float3( r, r, 1.0f );
    primitive.emissive = ( 1.6f * ( 1.0f - t ) );
    primitive.color.a  = ( 0.5f * ( 1.0f - t ) );
}
void Game::UpdateGems( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    if( ( _player == entt::null ) || ( registry.valid( _player ) == false ) )
        return;

    const float3 playerPos = float3( registry.get<CTransform>( _player ).current.p );

    for( int32_t i = _gems.Size() - 1; i >= 0; --i )
    {
        const entt::entity e = _gems.At( i );
        if( registry.valid( e ) == false )
        {
            _gems.At( i ) = _gems.At( _gems.Size() - 1 );
            _gems.Pop();
            continue;
        }

        CTransform& t   = registry.get<CTransform>( e );
        const CGem& gem = registry.get<CGem>( e );

        {// idle spin + size pulse + breathing glow — the field glitters even when nothing else moves
            const float  a  = ( static_cast<float>( app->SimulationStepIndex() ) * ( crApp::FIXED_TIMESTEP * 4.0f ) ) + gem.phase;
            const float2 cs = crMath::CosSin( a * 0.5f );
            t.current.q = { { 0.0f, 0.0f, cs.y }, cs.x };

            const float base  = ( gem.value > 1 ) ? 0.21f : 0.14f;
            const float pulse = base * ( 1.0f + ( 0.18f * crMath::Sin( a * 2.3f ) ) );

            CPrimitive& primitive = registry.get<CPrimitive>( e );
            primitive.scale    = float3( pulse, pulse, pulse );
            primitive.emissive = ( 2.1f + ( 0.9f * crMath::Sin( a * 3.1f ) ) );
        }

        const float dx = ( playerPos.x - t.current.p.x );
        const float dy = ( playerPos.y - t.current.p.y );
        const float sq = ( dx * dx ) + ( dy * dy );

        if( sq < ( 0.9f * 0.9f ) )
        {
            _xp += gem.value;
            app->audio->PlaySfx( _sfxTest, 0.15f, crMath::Clamp( playerPos.x * 0.03f, -1.0f, 1.0f ) );

            CParticleEmitter fx;
            fx.color        = registry.get<CPrimitive>( e ).color;
            fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
            fx.drag         = 1.8f;
            fx.spread       = 3.2f;
            fx.lifetimeMin  = 0.1f;
            fx.lifetimeMax  = 0.35f;
            fx.sizeMin      = 0.06f;
            fx.sizeMax      = 0.135f;
            fx.sizeEndScale = 0.2f;
            fx.intensityMin = 2.5f;
            fx.intensityMax = 4.5f;
            fx.spinMin      = -6.0f;
            fx.spinMax      = 6.0f;
            fx.spriteIndex  = _fxSprite;
            ScaleFx( &fx, FX_BURST_SIZE, FX_BURST_SPEED );
            app->particles->EmitBurst( app, fx, float3( t.current.p ), 22 );

            CParticleEmitter hoop;   // one green pickup ring
            hoop.color        = registry.get<CPrimitive>( e ).color;
            hoop.gravity      = float3( 0.0f, 0.0f, 0.0f );
            hoop.drag         = 0.0f;
            hoop.lifetimeMin  = 0.25f;
            hoop.lifetimeMax  = 0.3f;
            hoop.sizeMin      = 0.45f;
            hoop.sizeMax      = 0.6f;
            hoop.sizeEndScale = 4.0f;
            hoop.intensityMin = 2.0f;
            hoop.intensityMax = 3.0f;
            hoop.spriteIndex  = _fxRing;
            ScaleFx( &hoop, FX_HOOP_SIZE, 1.0f );
            app->particles->EmitBurst( app, hoop, float3( t.current.p ), 2 );

            registry.destroy( e );
            _gems.At( i ) = _gems.At( _gems.Size() - 1 );
            _gems.Pop();
            continue;
        }

        if( sq < ( _stats.magnetRadius * _stats.magnetRadius ) )
        {
            const float len = crMath::Sqrt( sq );
            t.current.p.x += ( ( dx / len ) * _tune.gemPullSpeed * crApp::FIXED_TIMESTEP );
            t.current.p.y += ( ( dy / len ) * _tune.gemPullSpeed * crApp::FIXED_TIMESTEP );

            if( registry.all_of<CRibbonTrail>( e ) == false )
            {// once the magnet grabs it, the gem streaks — a blue light being reeled in
                CRibbonTrail rt;
                rt.color         = registry.get<CPrimitive>( e ).color;
                rt.headHalfWidth = 0.08f;
                rt.intensity     = 1.25f;
                rt.emitDistance  = 0.12f;
                rt.fadeDuration  = 0.25f;
                rt.duration      = 0.3f;
                rt.capacity      = ERibbonCapacity::SEC_1;
                registry.emplace<CRibbonTrail>( e, rt );
            }
        }
    }
}
void Game::UpdateBulletHits( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const b3ContactEvents events = b3World_GetContactEvents( app->physics->World() );

    // deferred destruction — later events in this batch may reference the same shapes
    entt::entity deadBullets[ 24 ];
    entt::entity deadEnemies[ 24 ];
    int32_t      deadBulletCount = 0;
    int32_t      deadEnemyCount  = 0;

    for( int32_t i = 0; i < events.hitCount; ++i )
    {
        const b3ContactHitEvent& hit = events.hitEvents[ i ];

        // find which side is one of our bullets
        entt::entity bullet     = entt::null;
        b3ShapeId    otherShape = {};
        for( int32_t b = 0; b < _bullets.Size(); ++b )
        {
            const entt::entity candidate = _bullets.At( b );
            if( registry.valid( candidate ) == false )
                continue;

            const int32_t bodyIndex = registry.get<CPhysicsBody>( candidate ).bodyId.index1;
            if( b3Shape_GetBody( hit.shapeIdA ).index1 == bodyIndex )
            {
                bullet     = candidate;
                otherShape = hit.shapeIdB;
                break;
            }
            if( b3Shape_GetBody( hit.shapeIdB ).index1 == bodyIndex )
            {
                bullet     = candidate;
                otherShape = hit.shapeIdA;
                break;
            }
        }
        if( bullet == entt::null )
            continue;

        bool bulletDead = false;
        for( int32_t d = 0; d < deadBulletCount; ++d )
        {
            if( deadBullets[ d ] == bullet )
            {
                bulletDead = true;
                break;
            }
        }
        if( bulletDead )
            continue;

        // enemy hit? damage it and spend pierce; anything else (a wall) always stops the bullet
        const int32_t otherBodyIndex = b3Shape_GetBody( otherShape ).index1;
        bool          hitEnemy       = false;
        for( int32_t c = 0; c < _enemies.Size(); ++c )
        {
            const entt::entity candidate = _enemies.At( c );
            if( registry.valid( candidate ) == false )
                continue;
            if( registry.get<CPhysicsBody>( candidate ).bodyId.index1 != otherBodyIndex )
                continue;

            hitEnemy = true;

            if( DamageEnemy( app, candidate, _stats.damage ) )
            {// dead — deferred like the bullets: later events in this batch may reference it
                bool enemyListed = false;
                for( int32_t d = 0; d < deadEnemyCount; ++d )
                {
                    if( deadEnemies[ d ] == candidate )
                    {
                        enemyListed = true;
                        break;
                    }
                }
                if( ( enemyListed == false ) && ( deadEnemyCount < 24 ) )
                    deadEnemies[ deadEnemyCount++ ] = candidate;
            }
            break;
        }

        CBullet& state = registry.get<CBullet>( bullet );
        if( hitEnemy && ( state.pierceLeft > 0 ) )
        {
            --state.pierceLeft;   // keeps flying through
        }
        else if( deadBulletCount < 24 )
        {
            deadBullets[ deadBulletCount++ ] = bullet;
        }
    }

    for( int32_t d = 0; d < deadEnemyCount; ++d )
        DestroyEnemy( app, deadEnemies[ d ] );

    for( int32_t d = 0; d < deadBulletCount; ++d )
    {
        const entt::entity e    = deadBullets[ d ];
        const float3       pos  = float3( registry.get<CTransform>( e ).current.p );
        const b3BodyId     body = registry.get<CPhysicsBody>( e ).bodyId;
        const b3Vec3       vel  = b3Body_GetLinearVelocity( body );

        CParticleEmitter fx;   // impact shards in the bullet's own color, thrown along its flight line
        fx.color           = registry.get<CPrimitive>( e ).color;
        fx.gravity         = float3( 0.0f, 0.0f, 0.0f );
        fx.drag            = 2.5f;
        fx.velocity        = float3( vel.x * 0.18f, vel.y * 0.18f, 0.0f );
        fx.spread          = 4.5f;
        fx.lifetimeMin     = 0.08f;
        fx.lifetimeMax     = 0.3f;
        fx.sizeMin         = 0.045f;
        fx.sizeMax         = 0.135f;
        fx.sizeEndScale    = 0.2f;
        fx.intensityMin    = 2.0f;
        fx.intensityMax    = 3.5f;
        fx.spinMin         = -10.0f;
        fx.spinMax         = 10.0f;
        fx.alignToVelocity = true;
        fx.spriteIndex     = _fxSquare;
        ScaleFx( &fx, FX_BURST_SIZE, FX_BURST_SPEED );
        app->particles->EmitBurst( app, fx, pos, 26 );

        b3DestroyBody( body );
        registry.destroy( e );
    }
}
void Game::UpdateContactDamage( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    if( ( _player == entt::null ) || ( registry.valid( _player ) == false ) )
        return;

    if( app->SimulationStepIndex() < _invulnUntilStep )
        return;   // i-frames — no touch counts while blinking

    const float3 playerPos = float3( registry.get<CTransform>( _player ).current.p );

    // one chunk hit per contact, shoved away from the average of who's touching
    int32_t touching = 0;
    float   sumX     = 0.0f;
    float   sumY     = 0.0f;
    for( int32_t i = 0; i < _enemies.Size(); ++i )
    {
        const entt::entity e = _enemies.At( i );
        if( registry.valid( e ) == false )
            continue;

        const b3Pos pos = registry.get<CTransform>( e ).current.p;
        const float dx  = ( ( float )pos.x - playerPos.x );
        const float dy  = ( ( float )pos.y - playerPos.y );
        if( ( ( dx * dx ) + ( dy * dy ) ) < ( 1.55f * 1.55f ) )
        {
            ++touching;
            sumX += dx;
            sumY += dy;
        }
    }
    if( touching <= 0 )
        return;

    const float len = crMath::Sqrt( ( sumX * sumX ) + ( sumY * sumY ) );
    const float3 knock = ( len > 0.001f ) ? float3( -sumX / len, -sumY / len, 0.0f ) : float3( 0.0f, 0.0f, 0.0f );
    DamagePlayer( app, _tune.contactHit, knock );
}
void Game::UpdateExpiry( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();

    for( int32_t i = _bullets.Size() - 1; i >= 0; --i )
    {
        const entt::entity e = _bullets.At( i );
        if( registry.valid( e ) == false )
        {
            _bullets.At( i ) = _bullets.At( _bullets.Size() - 1 );
            _bullets.Pop();
            continue;
        }

        if( step >= registry.get<CBullet>( e ).expireStep )
        {
            b3DestroyBody( registry.get<CPhysicsBody>( e ).bodyId );
            registry.destroy( e );
            _bullets.At( i ) = _bullets.At( _bullets.Size() - 1 );
            _bullets.Pop();
        }
    }

    for( int32_t i = _enemies.Size() - 1; i >= 0; --i )
    {
        if( registry.valid( _enemies.At( i ) ) == false )
        {
            _enemies.At( i ) = _enemies.At( _enemies.Size() - 1 );
            _enemies.Pop();
        }
    }

    for( int32_t i = _husks.Size() - 1; i >= 0; --i )
    {
        const entt::entity e = _husks.At( i );
        if( registry.valid( e ) && ( step < registry.get<CHusk>( e ).endStep ) )
            continue;

        if( registry.valid( e ) )
            registry.destroy( e );
        _husks.At( i ) = _husks.At( _husks.Size() - 1 );
        _husks.Pop();
    }

    // missiles normally burst inside UpdateMissiles — this sweep only catches ones frozen by a
    // state change (UpdateMissiles is PLAYING-gated, this runs every step)
    for( int32_t i = _missiles.Size() - 1; i >= 0; --i )
    {
        const entt::entity e = _missiles.At( i );
        if( registry.valid( e ) && ( step < registry.get<CMissile>( e ).expireStep ) )
            continue;

        if( registry.valid( e ) )
            registry.destroy( e );
        _missiles.At( i ) = _missiles.At( _missiles.Size() - 1 );
        _missiles.Pop();
    }

    // enemy shots frozen by a state change (UpdateEnemyShots is PLAYING-gated, this runs every step)
    for( int32_t i = _enemyShots.Size() - 1; i >= 0; --i )
    {
        const entt::entity e = _enemyShots.At( i );
        if( registry.valid( e ) && ( step < registry.get<CEnemyShot>( e ).expireStep ) )
            continue;

        if( registry.valid( e ) )
            registry.destroy( e );
        _enemyShots.At( i ) = _enemyShots.At( _enemyShots.Size() - 1 );
        _enemyShots.Pop();
    }
}
void Game::CheckLevelUp( const crApp* app )
{
    if( _requestedState == EGameState::RESULT )
        return;   // died on this very step — the death screen wins
    if( ( _player == entt::null ) || ( app->ecs->registry.valid( _player ) == false ) )
        return;

    if( _xp < _xpNeed )
        return;

    _xp -= _xpNeed;
    ++_level;
    _xpNeed = 8 + ( _level * 5 ) + ( ( _level * _level ) / 2 );   // quadratic — late levels are earned

    RollChoices();
    RequestState( EGameState::LEVELUP );
}
void Game::KillPlayer( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const float3 pos = float3( registry.get<CTransform>( _player ).current.p );

    CParticleEmitter fx;   // hull shrapnel — velocity-aligned shards, cooling to a deep blue ember
    fx.color           = SHIP_COLOR;
    fx.colorEnd        = color4( 0.05f, 0.1f, 0.35f, 1.0f );
    fx.gravity         = float3( 0.0f, 0.0f, 0.0f );
    fx.drag            = 0.9f;
    fx.spread          = 8.0f;
    fx.lifetimeMin     = 0.3f;
    fx.lifetimeMax     = 1.2f;
    fx.sizeMin         = 0.12f;
    fx.sizeMax         = 0.27f;
    fx.sizeEndScale    = 0.2f;
    fx.intensityMin    = 2.0f;
    fx.intensityMax    = 4.0f;
    fx.spinMin         = -8.0f;
    fx.spinMax         = 8.0f;
    fx.alignToVelocity = true;
    fx.spriteIndex     = _fxSquare;
    ScaleFx( &fx, FX_BURST_SIZE, FX_BURST_SPEED );
    app->particles->EmitBurst( app, fx, pos, 384 );

    CParticleEmitter sparks;
    sparks.color           = color4( 1.0f, 1.0f, 1.0f, 1.0f );
    sparks.gravity         = float3( 0.0f, 0.0f, 0.0f );
    sparks.drag            = 2.5f;
    sparks.spread          = 14.0f;
    sparks.lifetimeMin     = 0.1f;
    sparks.lifetimeMax     = 0.4f;
    sparks.sizeMin         = 0.03f;
    sparks.sizeMax         = 0.09f;
    sparks.sizeEndScale    = 0.3f;
    sparks.intensityMin    = 3.0f;
    sparks.intensityMax    = 5.0f;
    sparks.alignToVelocity = true;
    sparks.spriteIndex     = _fxSprite;
    ScaleFx( &sparks, FX_BURST_SIZE, FX_BURST_SPEED );
    app->particles->EmitBurst( app, sparks, pos, 256 );

    CParticleEmitter flash;
    flash.color        = color4( 1.0f, 1.0f, 1.0f, 1.0f );
    flash.gravity      = float3( 0.0f, 0.0f, 0.0f );
    flash.drag         = 1.2f;
    flash.spread       = 1.5f;
    flash.lifetimeMin  = 0.1f;
    flash.lifetimeMax  = 0.25f;
    flash.sizeMin      = 0.3f;
    flash.sizeMax      = 0.6f;
    flash.sizeEndScale = 2.5f;
    flash.intensityMin = 3.0f;
    flash.intensityMax = 5.0f;
    flash.spriteIndex  = _fxSprite;
    ScaleFx( &flash, FX_BURST_SIZE, FX_BURST_SPEED );
    app->particles->EmitBurst( app, flash, pos, 32 );

    CParticleEmitter hoops;
    hoops.color        = color4( 0.5f, 0.9f, 1.0f, 1.0f );
    hoops.gravity      = float3( 0.0f, 0.0f, 0.0f );
    hoops.drag         = 0.0f;
    hoops.lifetimeMin  = 0.4f;
    hoops.lifetimeMax  = 0.7f;
    hoops.sizeMin      = 1.2f;
    hoops.sizeMax      = 2.1f;
    hoops.sizeEndScale = 8.0f;
    hoops.intensityMin = 1.5f;
    hoops.intensityMax = 2.5f;
    hoops.spinMin      = -2.0f;
    hoops.spinMax      = 2.0f;
    hoops.spriteIndex  = _fxRing;
    ScaleFx( &hoops, FX_HOOP_SIZE, 1.0f );
    app->particles->EmitBurst( app, hoops, pos, 8 );

    {// dying reactor — keeps erupting under the RESULT panel
        CParticleEmitter erupt;
        erupt.color        = color4( 0.4f, 0.85f, 1.0f, 1.0f );
        erupt.colorEnd     = color4( 0.05f, 0.1f, 0.3f, 1.0f );
        erupt.gravity      = float3( 0.0f, 0.0f, 0.0f );
        erupt.drag         = 1.0f;
        erupt.rate         = 525.0f;
        erupt.spread       = 9.0f;
        erupt.lifetimeMin  = 0.3f;
        erupt.lifetimeMax  = 0.9f;
        erupt.sizeMin      = 0.075f;
        erupt.sizeMax      = 0.21f;
        erupt.sizeEndScale = 0.2f;
        erupt.intensityMin = 2.5f;
        erupt.intensityMax = 4.5f;
        erupt.spinMin      = -6.0f;
        erupt.spinMax      = 6.0f;
        erupt.spriteIndex  = _fxSprite;
        ScaleFx( &erupt, FX_STREAM_SIZE, FX_STREAM_SPEED );
        SpawnTimedFx( app, erupt, pos, 0.6f );
    }

    AddRipple( pos, 18.0f, 0.8f, 0.05f );
    _chromaPulse = 1.5f;
    _glitchPulse = 1.0f;
    app->audio->PlaySfx( _sfxTest, 1.0f );

    b3DestroyBody( registry.get<CPhysicsBody>( _player ).bodyId );
    registry.destroy( _player );
    _player   = entt::null;
    _shakeMag = 1.0f;

    const int32_t seconds = ( int32_t )( ( float )app->SimulationStepIndex() * crApp::FIXED_TIMESTEP );
    _score += ( seconds * 5 );
    if( _score > _bestScore )
        _bestScore = _score;

    RequestState( EGameState::RESULT );
}
void Game::DamagePlayer( const crApp* app, float amount, float3 knockDir )
{
    entt::registry& registry = app->ecs->registry;
    if( ( _player == entt::null ) || ( registry.valid( _player ) == false ) )
        return;

    const uint64_t step = app->SimulationStepIndex();
    if( step < _invulnUntilStep )
        return;   // still in i-frames — the blow whiffs

    _hp -= amount;
    _invulnUntilStep = step + INVULN_STEPS;

    const float3 playerPos = float3( registry.get<CTransform>( _player ).current.p );

    if( ( knockDir.x != 0.0f ) || ( knockDir.y != 0.0f ) )
    {// hard shove off the threat — the hit reads through the controls
        const b3BodyId body = registry.get<CPhysicsBody>( _player ).bodyId;
        const b3Vec3   vel  = b3Body_GetLinearVelocity( body );
        b3Body_SetLinearVelocity( body, { vel.x + ( knockDir.x * _tune.contactKick ),
                                          vel.y + ( knockDir.y * _tune.contactKick ), 0.0f } );
    }

    {// hurt feedback — a chunk hit is an event: white flash, tear, sparks, hard shake
        CParticleEmitter fx;
        fx.color        = color4( 1.0f, 0.3f, 0.2f, 1.0f );
        fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
        fx.drag         = 2.0f;
        fx.spread       = 5.0f;
        fx.lifetimeMin  = 0.1f;
        fx.lifetimeMax  = 0.35f;
        fx.sizeMin      = 0.075f;
        fx.sizeMax      = 0.195f;
        fx.sizeEndScale = 0.2f;
        fx.intensityMin = 2.5f;
        fx.intensityMax = 4.0f;
        fx.spinMin      = -8.0f;
        fx.spinMax      = 8.0f;
        fx.spriteIndex  = _fxSprite;
        ScaleFx( &fx, FX_BURST_SIZE, FX_BURST_SPEED );
        app->particles->EmitBurst( app, fx, playerPos, 32 );
        AddRipple( playerPos, 6.0f, 0.3f, 0.014f );
        _chromaPulse = 1.2f;
        _glitchPulse = 1.0f;
        _hurtFlash   = crMath::Clamp( _hurtFlash + 0.85f, 0.0f, 1.0f );
        _shakeMag    = crMath::Clamp( _shakeMag + 0.55f, 0.0f, 1.0f );
    }

    if( _hp <= 0.0f )
    {
        _hp = 0.0f;
        KillPlayer( app );
    }
}
bool Game::DamageEnemy( const crApp* app, entt::entity e, int32_t damage )
{
    entt::registry& registry = app->ecs->registry;

    CEnemy& enemy = registry.get<CEnemy>( e );
    enemy.hp   -= damage;
    enemy.flash = 1.0f;

    if( enemy.hp > 0 )
    {// survived — white sparks + a pinprick distortion say "damaged, not dead"
        const float3 at = float3( registry.get<CTransform>( e ).current.p );

        CParticleEmitter fx;
        fx.color        = color4( 1.0f, 1.0f, 1.0f, 1.0f );
        fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
        fx.drag         = 2.0f;
        fx.spread       = 2.6f;
        fx.lifetimeMin  = 0.06f;
        fx.lifetimeMax  = 0.18f;
        fx.sizeMin      = 0.06f;
        fx.sizeMax      = 0.12f;
        fx.sizeEndScale = 0.3f;
        fx.intensityMin = 2.5f;
        fx.intensityMax = 4.5f;
        fx.spriteIndex  = _fxSprite;
        ScaleFx( &fx, FX_BURST_SIZE, FX_BURST_SPEED );
        app->particles->EmitBurst( app, fx, at, 10 );

        AddRipple( at, 1.5f, 0.16f, 0.005f );
        return false;
    }
    return true;
}
void Game::DestroyEnemy( const crApp* app, entt::entity e )
{
    entt::registry& registry = app->ecs->registry;

    const float3     pos   = float3( registry.get<CTransform>( e ).current.p );
    const color4     col   = registry.get<CEnemy>( e ).baseColor;   // not the primitive color — the hit flash whitens that
    const EEnemyKind kind  = registry.get<CEnemy>( e ).kind;
    const bool       elite = ( kind == EEnemyKind::ELITE );
    const bool       brute = ( kind == EEnemyKind::BRUTE );

    ++_kills;
    _score += ( kind == EEnemyKind::BOSS ) ? 1000 :
              ( elite ? 150 : ( brute ? 50 : ( ( kind == EEnemyKind::SHELL ) ? 40 :
              ( ( kind == EEnemyKind::GUNNER ) ? 25 : ( ( kind == EEnemyKind::DART ) ? 15 : 10 ) ) ) ) );

    if( kind == EEnemyKind::BOSS )
    {// jackpot shower + the reserved slow-motion moment
        for( int32_t i = 0; i < 8; ++i )
        {
            const float2 cs = crMath::CosSin( static_cast<float>( i ) * ( ( 2.0f * crMath::PI ) / 8.0f ) );
            SpawnGem( app, float3( pos.x + ( cs.x * 1.2f ), pos.y + ( cs.y * 1.2f ), 0.0f ), 5 );
        }

        _boss         = entt::null;
        _nextBossStep = app->SimulationStepIndex() + static_cast<uint64_t>( crMath::Round( BOSS_PERIOD / crApp::FIXED_TIMESTEP ) );
        _sloMo        = 1.0f;

        AddRipple( pos, 22.0f, 0.7f, 0.045f );
        _chromaPulse = 1.5f;
        _radialPulse = 1.0f;
        _shakeMag    = 1.0f;

        CParticleEmitter erupt;   // the carcass keeps venting through the slow-mo
        erupt.color        = ELITE_COLOR;
        erupt.colorEnd     = color4( 0.4f, 0.06f, 0.02f, 1.0f );
        erupt.gravity      = float3( 0.0f, 0.0f, 0.0f );
        erupt.drag         = 1.0f;
        erupt.rate         = 600.0f;
        erupt.spread       = 8.0f;
        erupt.lifetimeMin  = 0.3f;
        erupt.lifetimeMax  = 1.0f;
        erupt.sizeMin      = 0.09f;
        erupt.sizeMax      = 0.24f;
        erupt.sizeEndScale = 0.2f;
        erupt.intensityMin = 2.5f;
        erupt.intensityMax = 4.5f;
        erupt.spinMin      = -7.0f;
        erupt.spinMax      = 7.0f;
        erupt.spriteIndex  = _fxSprite;
        ScaleFx( &erupt, FX_STREAM_SIZE, FX_STREAM_SPEED );
        SpawnTimedFx( app, erupt, pos, 0.6f );
    }
    else if( elite )
    {// gem shower
        SpawnGem( app, pos, 5 );
        SpawnGem( app, float3( pos.x + 0.5f, pos.y + 0.3f, 0.0f ), 5 );
        SpawnGem( app, float3( pos.x - 0.4f, pos.y - 0.5f, 0.0f ), 5 );
    }
    else if( kind == EEnemyKind::SHELL )
    {// cracking the blocker pays out a cluster
        SpawnGem( app, float3( pos.x + 0.4f, pos.y, 0.0f ), 3 );
        SpawnGem( app, float3( pos.x - 0.3f, pos.y - 0.4f, 0.0f ), 3 );
    }
    else
        SpawnGem( app, pos, brute ? 5 : ( ( kind == EEnemyKind::GUNNER ) ? 2 : 1 ) );

    CParticleEmitter fx;   // hard shards — velocity-aligned squares, spinning, cooling through ember
    fx.color           = col;
    fx.colorEnd        = color4( 0.45f, 0.1f, 0.02f, 1.0f );
    fx.gravity         = float3( 0.0f, 0.0f, 0.0f );
    fx.drag            = 1.3f;
    fx.spread          = 5.5f;
    fx.lifetimeMin     = 0.25f;
    fx.lifetimeMax     = 0.7f;
    fx.sizeMin         = 0.075f;
    fx.sizeMax         = 0.21f;
    fx.sizeEndScale    = 0.25f;
    fx.intensityMin    = 2.0f;
    fx.intensityMax    = 4.0f;
    fx.spinMin         = -9.0f;
    fx.spinMax         = 9.0f;
    fx.alignToVelocity = true;
    fx.spriteIndex     = _fxSquare;
    ScaleFx( &fx, FX_BURST_SIZE, FX_BURST_SPEED );
    app->particles->EmitBurst( app, fx, pos, ( kind == EEnemyKind::BOSS ) ? 560 : ( elite ? 352 : ( brute ? 256 : 112 ) ) );

    CParticleEmitter sparks;   // fast pinpricks past the shards
    sparks.color           = color4( 1.0f, 0.9f, 0.7f, 1.0f );
    sparks.gravity         = float3( 0.0f, 0.0f, 0.0f );
    sparks.drag            = 3.0f;
    sparks.spread          = 11.0f;
    sparks.lifetimeMin     = 0.08f;
    sparks.lifetimeMax     = 0.25f;
    sparks.sizeMin         = 0.03f;
    sparks.sizeMax         = 0.075f;
    sparks.sizeEndScale    = 0.3f;
    sparks.intensityMin    = 3.0f;
    sparks.intensityMax    = 5.0f;
    sparks.alignToVelocity = true;
    sparks.spriteIndex     = _fxSprite;
    ScaleFx( &sparks, FX_BURST_SIZE, FX_BURST_SPEED );
    app->particles->EmitBurst( app, sparks, pos, ( kind == EEnemyKind::BOSS ) ? 288 : ( elite ? 128 : ( brute ? 96 : 48 ) ) );

    CParticleEmitter smoke;   // slow dim billows left hanging where it died
    smoke.color        = color4( col.r * 0.35f, col.g * 0.35f, col.b * 0.35f, 1.0f );
    smoke.gravity      = float3( 0.0f, 0.0f, 0.0f );
    smoke.drag         = 2.2f;
    smoke.spread       = 1.8f;
    smoke.lifetimeMin  = 0.5f;
    smoke.lifetimeMax  = 1.1f;
    smoke.sizeMin      = 0.225f;
    smoke.sizeMax      = 0.45f;
    smoke.sizeEndScale = 2.8f;
    smoke.intensityMin = 0.5f;
    smoke.intensityMax = 0.9f;
    smoke.spinMin      = -1.5f;
    smoke.spinMax      = 1.5f;
    smoke.spriteIndex  = _fxSprite;
    ScaleFx( &smoke, FX_BURST_SIZE, FX_BURST_SPEED );
    app->particles->EmitBurst( app, smoke, pos, elite ? 32 : ( brute ? 22 : 11 ) );

    CParticleEmitter flash;   // white-hot core pop under the colored shrapnel
    flash.color        = color4( 1.0f, 1.0f, 1.0f, 1.0f );
    flash.gravity      = float3( 0.0f, 0.0f, 0.0f );
    flash.drag         = 1.5f;
    flash.spread       = 1.2f;
    flash.lifetimeMin  = 0.05f;
    flash.lifetimeMax  = 0.15f;
    flash.sizeMin      = 0.18f;
    flash.sizeMax      = 0.375f;
    flash.sizeEndScale = 2.0f;
    flash.intensityMin = 3.0f;
    flash.intensityMax = 5.0f;
    flash.spriteIndex  = _fxSprite;
    ScaleFx( &flash, FX_BURST_SIZE, FX_BURST_SPEED );
    app->particles->EmitBurst( app, flash, pos, ( elite || brute ) ? 19 : 10 );

    AddRipple( pos, elite ? 7.5f : ( brute ? 5.0f : 3.9f ), elite ? 0.4f : 0.3f, elite ? 0.034f : ( brute ? 0.024f : 0.018f ) );
    if( elite )
        _chromaPulse = crMath::Clamp( _chromaPulse + 0.35f, 0.0f, 1.5f );

    if( elite )
    {// lingering ember eruption
        CParticleEmitter embers;
        embers.color        = ELITE_COLOR;
        embers.colorEnd     = color4( 0.4f, 0.08f, 0.02f, 1.0f );
        embers.gravity      = float3( 0.0f, 0.0f, 0.0f );
        embers.drag         = 1.0f;
        embers.rate         = 390.0f;
        embers.spread       = 6.0f;
        embers.lifetimeMin  = 0.3f;
        embers.lifetimeMax  = 0.8f;
        embers.sizeMin      = 0.075f;
        embers.sizeMax      = 0.18f;
        embers.sizeEndScale = 0.2f;
        embers.intensityMin = 2.5f;
        embers.intensityMax = 4.5f;
        embers.spinMin      = -6.0f;
        embers.spinMax      = 6.0f;
        embers.spriteIndex  = _fxSprite;
        ScaleFx( &embers, FX_STREAM_SIZE, FX_STREAM_SPEED );
        SpawnTimedFx( app, embers, pos, 0.35f );
    }

    if( elite || brute )
        _shakeMag = crMath::Clamp( _shakeMag + 0.12f, 0.0f, 1.0f );

    {// husk — a physics-less copy left in the death pose to blow out and fade. the enemy itself is
        // still destroyed on the spot: keeping it alive would put a corpse inside every _enemies
        // sweep. this runs inside a fixed step, so the create/destroy stays on the sim clock
        const CEnemy&     state     = registry.get<CEnemy>( e );
        const CPrimitive& primitive = registry.get<CPrimitive>( e );
        const uint64_t    step      = app->SimulationStepIndex();

        const entt::entity husk = registry.create();

        CTransform t;
        t.current  = registry.get<CTransform>( e ).current;
        t.previous = t.current;
        registry.emplace<CTransform>( husk, t );

        CPrimitive shell = primitive;
        shell.blend    = EBlendMode::ADDITIVE;
        shell.color    = state.baseColor;
        shell.emissive = 2.4f;
        shell.scale    = state.baseScale;
        registry.emplace<CPrimitive>( husk, shell );

        CHusk fade;
        fade.baseScale = state.baseScale;
        fade.baseColor = state.baseColor;
        fade.bornStep  = step;
        fade.endStep   = step + static_cast<uint64_t>( crMath::Round( HUSK_SECONDS / crApp::FIXED_TIMESTEP ) );
        registry.emplace<CHusk>( husk, fade );

        _husks.Add( husk );
    }

    b3DestroyBody( registry.get<CPhysicsBody>( e ).bodyId );
    registry.destroy( e );
}
void Game::AddRipple( float3 pos, float radius, float life, float strength )
{
    _app->graphics->PostProcess()->QueueRipple( pos, radius, life, strength );
}
void Game::SpawnTimedFx( const crApp* app, const CParticleEmitter& params, float3 pos, float seconds )
{
    entt::registry& registry = app->ecs->registry;

    const entt::entity e = registry.create();

    CTransform t;
    t.current  = { { pos.x, pos.y, pos.z }, { { 0.0f, 0.0f, 0.0f }, 1.0f } };
    t.previous = t.current;
    registry.emplace<CTransform>( e, t );

    CParticleEmitter emitter = params;
    emitter.killStep = app->SimulationStepIndex() + static_cast<uint64_t>( crMath::Round( seconds / crApp::FIXED_TIMESTEP ) );
    registry.emplace<CParticleEmitter>( e, emitter );
}
void Game::RollChoices()
{
    // three distinct upgrades from the pool, each with an independently rolled rarity
    const int32_t pool = static_cast<int32_t>( EUpgrade::_SIZE );

    int32_t picked[ 3 ] = { -1, -1, -1 };
    for( int32_t i = 0; i < 3; ++i )
    {
        int32_t roll = 0;
        do
        {
            roll = _rng.NextInt32( 0, pool );
        }
        while( ( roll == picked[ 0 ] ) || ( roll == picked[ 1 ] ) );
        picked[ i ]   = roll;
        _choices[ i ] = static_cast<EUpgrade>( roll );

        const int32_t tier = _rng.NextInt32( 0, 100 );   // 70 / 23 / 7
        if( tier < 70 )
            _choiceRarity[ i ] = ERarity::COMMON;
        else if( tier < 93 )
            _choiceRarity[ i ] = ERarity::RARE;
        else
            _choiceRarity[ i ] = ERarity::EPIC;
    }
}
void Game::ApplyUpgrade( const crApp* app, EUpgrade upgrade, ERarity rarity )
{
    const int32_t r = static_cast<int32_t>( rarity );

    entt::registry& registry = app->ecs->registry;
    if( ( _player != entt::null ) && registry.valid( _player ) )
    {// celebration burst off the ship, in the rarity's color
        const float3 pos = float3( registry.get<CTransform>( _player ).current.p );

        CParticleEmitter fx;
        fx.color        = RARITY_COLORS[ r ];
        fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
        fx.drag         = 0.8f;
        fx.spread       = 7.0f;
        fx.lifetimeMin  = 0.3f;
        fx.lifetimeMax  = 0.8f;
        fx.sizeMin      = 0.09f;
        fx.sizeMax      = 0.225f;
        fx.sizeEndScale = 0.2f;
        fx.intensityMin = 2.5f;
        fx.intensityMax = 4.5f;
        fx.spinMin      = -5.0f;
        fx.spinMax      = 5.0f;
        fx.spriteIndex  = _fxSprite;
        ScaleFx( &fx, FX_BURST_SIZE, FX_BURST_SPEED );
        app->particles->EmitBurst( app, fx, pos, 128 + ( r * 64 ) );

        CParticleEmitter hoop;
        hoop.color        = RARITY_COLORS[ r ];
        hoop.gravity      = float3( 0.0f, 0.0f, 0.0f );
        hoop.drag         = 0.0f;
        hoop.lifetimeMin  = 0.35f;
        hoop.lifetimeMax  = 0.5f;
        hoop.sizeMin      = 0.9f;
        hoop.sizeMax      = 1.5f;
        hoop.sizeEndScale = 6.0f;
        hoop.intensityMin = 1.5f;
        hoop.intensityMax = 2.5f;
        hoop.spriteIndex  = _fxRing;
        ScaleFx( &hoop, FX_HOOP_SIZE, 1.0f );
        app->particles->EmitBurst( app, hoop, pos, 3 + r );

        AddRipple( pos, 9.0f, 0.5f, 0.02f );
        _chromaPulse = crMath::Clamp( _chromaPulse + 0.3f, 0.0f, 1.5f );
        app->audio->PlaySfx( _sfxTest, 0.6f );
    }

    switch( upgrade )
    {
    case EUpgrade::RAPID:
    {
        const float mul[ 3 ] = { 0.82f, 0.72f, 0.60f };
        _stats.fireInterval = crMath::Clamp( _stats.fireInterval * mul[ r ], 0.04f, 10.0f );
        break;
    }
    case EUpgrade::MULTISHOT:
        _stats.bulletCount += ( 1 + r );
        break;
    case EUpgrade::PIERCE:
        _stats.pierce += ( 1 + r );
        break;
    case EUpgrade::DAMAGE:
    {
        const int32_t add[ 3 ] = { 1, 2, 4 };
        _stats.damage += add[ r ];
        break;
    }
    case EUpgrade::NOVA:
    {
        const float mul[ 3 ] = { 0.85f, 0.78f, 0.68f };
        const float add[ 3 ] = { 1.2f, 2.0f, 3.2f };
        _stats.novaPeriod = crMath::Clamp( _stats.novaPeriod * mul[ r ], 1.2f, 20.0f );
        _stats.novaRadius += add[ r ];
        _stats.novaKick   += add[ r ];
        break;
    }
    case EUpgrade::ENGINE:
    {
        const float thrust[ 3 ] = { 35.0f, 55.0f, 85.0f };
        const float speed[ 3 ]  = { 2.0f, 3.0f, 4.5f };
        _stats.thrust   += thrust[ r ];
        _stats.maxSpeed += speed[ r ];
        break;
    }
    case EUpgrade::VITALITY:
    {
        const float add[ 3 ] = { 20.0f, 35.0f, 60.0f };
        _stats.hpMax += add[ r ];
        if( rarity == ERarity::EPIC )
            _hp = _stats.hpMax;
        else
            _hp = crMath::Clamp( _hp + add[ r ], 0.0f, _stats.hpMax );
        break;
    }
    case EUpgrade::MAGNET:
    {
        const float add[ 3 ] = { 1.5f, 2.5f, 4.0f };
        _stats.magnetRadius += add[ r ];
        break;
    }
    case EUpgrade::ORBITAL:
    {
        const int32_t add[ 3 ]    = { 1, 1, 2 };
        const float   radius[ 3 ] = { 0.0f, 0.35f, 0.5f };
        _stats.orbCount += add[ r ];
        if( _stats.orbCount > ORB_CAP )
            _stats.orbCount = ORB_CAP;
        _stats.orbRadius += radius[ r ];
        while( _orbCount < _stats.orbCount )
            SpawnOrb( app );
        break;
    }
    case EUpgrade::SEEKER:
    {
        const int32_t add[ 3 ] = { 1, 2, 3 };
        _stats.missileCount += add[ r ];
        if( rarity == ERarity::EPIC )
            _stats.missilePeriod *= 0.85f;
        break;
    }
    case EUpgrade::CARRIER:
    {
        const int32_t add[ 3 ] = { 1, 1, 2 };
        _stats.podCount += add[ r ];
        if( _stats.podCount > POD_CAP )
            _stats.podCount = POD_CAP;
        if( rarity != ERarity::COMMON )
            _stats.dronePeriod *= ( ( rarity == ERarity::EPIC ) ? 0.8f : 0.9f );
        while( _podCount < _stats.podCount )
            SpawnPod( app );
        break;
    }
    case EUpgrade::_SIZE:
        break;
    }
}
void Game::UpdateEnemyVisuals( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();

    auto view = registry.view<const CEnemy, CPrimitive, const CPhysicsBody>();
    for( entt::entity e : view )
    {
        const CEnemy& state = view.get<const CEnemy>( e );

        float sx = state.baseScale.x;
        float sy = state.baseScale.y;
        float sz = state.baseScale.z;

        {// throb — one breath per body, phase-offset so the swarm never pulses in unison. rate
            // tracks the type's speed, which is what makes a runner read as agitated and a shell inert
            const float rate = ( 3.0f + ( 2.0f * state.speedMul ) );
            const float beat = ( 1.0f + ( THROB_AMPLITUDE * crMath::Sin( ( _fxTime * rate ) + state.phase ) ) );
            sx *= beat;
            sy *= beat;
            sz *= beat;
        }

        if( ( state.kind == EEnemyKind::GRUNT ) || ( state.kind == EEnemyKind::ELITE ) || ( state.kind == EEnemyKind::DART ) )
        {// stretch along the nose. only for the kinds whose nose IS their travel direction — the
            // gunner strafes, so stretching its bar would elongate it across its own motion
            const b3Vec3 v     = b3Body_GetLinearVelocity( view.get<const CPhysicsBody>( e ).bodyId );
            const float  speed = crMath::Sqrt( ( v.x * v.x ) + ( v.y * v.y ) );
            const float  t     = crMath::Clamp01( speed / REFERENCE_SPEED );

            sx *= ( 1.0f + ( STRETCH_AT_SPEED * t ) );
            sy *= ( 1.0f - ( STRETCH_AT_SPEED * 0.5f * t ) );
            sz *= ( 1.0f - ( STRETCH_AT_SPEED * 0.5f * t ) );
        }

        if( state.flash > 0.0f )
        {// hit pop — the white flash gets a body to go with it
            const float pop = ( 1.0f + ( HIT_POP * state.flash ) );
            sx *= pop;
            sy *= pop;
            sz *= pop;
        }

        {// birth ease — overshoots past 1 and settles, so an arrival lands instead of appearing
            const float age = ( static_cast<float>( step - state.spawnStep ) * crApp::FIXED_TIMESTEP );
            if( age < SPAWN_POP_TIME )
            {
                const float t = ( ( age / SPAWN_POP_TIME ) - 1.0f );
                const float k = ( 1.0f + ( 2.70158f * t * t * t ) + ( 1.70158f * t * t ) );
                sx *= k;
                sy *= k;
                sz *= k;
            }
        }

        CPrimitive& primitive = view.get<CPrimitive>( e );
        primitive.scale = float3( sx, sy, sz );
    }

    auto husks = registry.view<const CHusk, CPrimitive>();
    for( entt::entity e : husks )
    {// the corpse blows out and dims — an expanding shell of the shape that just died, which reads
        // as that specific unit breaking apart rather than as generic sparks
        const CHusk& husk = husks.get<const CHusk>( e );

        const float span = static_cast<float>( husk.endStep - husk.bornStep );
        const float t    = ( span > 0.0f ) ? crMath::Clamp01( static_cast<float>( step - husk.bornStep ) / span ) : 1.0f;
        const float grow = ( 1.0f + ( 0.9f * t ) );
        const float fade = ( 1.0f - t );

        CPrimitive& primitive = husks.get<CPrimitive>( e );
        primitive.scale = float3( husk.baseScale.x * grow, husk.baseScale.y * grow, husk.baseScale.z * grow );
        primitive.color = color4( husk.baseColor.r * fade, husk.baseColor.g * fade, husk.baseColor.b * fade, husk.baseColor.a );
    }
}
void Game::UpdateLattice( const crApp* app )
{
    if( _lattice.Size() == 0 )
        return;

    entt::registry& registry = app->ecs->registry;
    if( ( _player == entt::null ) || ( registry.valid( _player ) == false ) )
        return;

    const float3 playerPos = float3( registry.get<CTransform>( _player ).current.p );
    const float  span      = ( LATTICE_FAR - LATTICE_NEAR );

    {// eased so the surge arrives and leaves as a swell — a hard toggle on a screen-wide floor pops
        const float target = ( app->SimulationStepIndex() < _frenzyEndStep ) ? 1.0f : 0.0f;
        _latticeFrenzy += ( ( target - _latticeFrenzy ) * ( 1.0f - crMath::Pow( 0.5f, _renderDt / 0.22f ) ) );
    }
    const float f     = _latticeFrenzy;
    const float warp  = ( LATTICE_WARP * ( 1.0f + ( ( LATTICE_FRENZY_WARP - 1.0f ) * f ) ) );
    const float flow  = ( LATTICE_FLOW * ( 1.0f + ( ( LATTICE_FRENZY_FLOW - 1.0f ) * f ) ) );
    const float grain = ( LATTICE_FLOW_SCALE * ( 1.0f + ( ( LATTICE_FRENZY_SCALE - 1.0f ) * f ) ) );
    const float hot   = ( 1.0f + ( ( LATTICE_FRENZY_EMISSIVE - 1.0f ) * f ) );
    const color4 base = crMath::LerpColor( LATTICE_COLOR, LATTICE_FRENZY_COLOR, ( LATTICE_FRENZY_TINT * f ) );

    for( int32_t i = 0; i < _lattice.Size(); ++i )
    {
        const entt::entity e = _lattice.At( i );
        if( registry.valid( e ) == false )
            continue;

        const b3Pos pos = registry.get<CTransform>( e ).current.p;
        const float dx  = ( ( float )pos.x - playerPos.x );
        const float dy  = ( ( float )pos.y - playerPos.y );
        const float d   = crMath::Sqrt( ( dx * dx ) + ( dy * dy ) );

        // lit under the ship, gone at the far edge. a floor that is evenly on everywhere is a
        // texture; one that answers to where you are is a speedometer
        const float t = ( span > 0.0f ) ? crMath::Clamp01( 1.0f - ( ( d - LATTICE_NEAR ) / span ) ) : 1.0f;
        // colour holds a linear fade so the far field keeps a visible trace; the emissive falls on
        // t^2, which is what keeps the lit core tight around the ship instead of washing outward
        const float k = ( LATTICE_FLOOR + ( ( 1.0f - LATTICE_FLOOR ) * t ) );

        CPrimitive& primitive = registry.get<CPrimitive>( e );
        primitive.color    = color4( ( base.r * k ), ( base.g * k ), ( base.b * k ), 1.0f );
        primitive.emissive = ( LATTICE_EMISSIVE * t * t * hot );
        SetFlow( primitive, flow, grain, warp );
    }
}
void Game::UpdateWallPulse( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    if( ( _walls[ 0 ] == entt::null ) || ( registry.valid( _walls[ 0 ] ) == false ) )
        return;

    const bool frenzy = ( app->SimulationStepIndex() < _frenzyEndStep );

    for( int32_t i = 0; i < 6; ++i )
    {
        CPrimitive& primitive = registry.get<CPrimitive>( _walls[ i ] );
        if( frenzy )
        {// the arena itself turns hostile — red tint, fast agitated pulse
            primitive.color    = crMath::LerpColor( WALL_COLORS[ i ], color4( 1.0f, 0.15f, 0.1f, 1.0f ), 0.55f );
            primitive.emissive = ( 1.1f + ( 0.5f * crMath::Sin( ( _fxTime * 5.0f ) + ( static_cast<float>( i ) * 1.05f ) ) ) );
        }
        else
        {
            primitive.color    = WALL_COLORS[ i ];
            primitive.emissive = ( 0.75f + ( 0.35f * crMath::Sin( ( _fxTime * 1.3f ) + ( static_cast<float>( i ) * 1.05f ) ) ) );
        }
    }
}
void Game::UpdateCamera( crApp* app )
{
    entt::registry& registry = app->ecs->registry;
    if( ( _player == entt::null ) || ( registry.valid( _player ) == false ) )
        return;

    const float dt = _renderDt;

    const CTransform& t     = registry.get<CTransform>( _player );
    const float       alpha = app->SimulationAlpha();
    const float3      pos   = crMath::Lerp3( float3( t.previous.p ), float3( t.current.p ), alpha );

    const b3BodyId body  = registry.get<CPhysicsBody>( _player ).bodyId;
    const b3Vec3   vel   = b3Body_GetLinearVelocity( body );
    const float    speed = crMath::Sqrt( ( vel.x * vel.x ) + ( vel.y * vel.y ) );

    const float3 desired = float3( pos.x + ( vel.x * _tune.camLead ),
                                   pos.y + ( vel.y * _tune.camLead ),
                                   0.0f );

    // exponential follow (half-life ~90 ms) — soaks up the hard stop when the ship hits a wall
    const float blend = 1.0f - crMath::Pow( 0.5f, dt / 0.09f );
    if( _camValid == false )
    {
        _camPos   = desired;
        _camValid = true;
    }
    _camPos.x += ( ( desired.x - _camPos.x ) * blend );
    _camPos.y += ( ( desired.y - _camPos.y ) * blend );

    // damage shake — two incommensurate frequencies read as noise
    _shakePhase += ( dt * 40.0f );
    _shakeMag    = crMath::Clamp( _shakeMag - ( dt * 2.2f ), 0.0f, 1.0f );
    const float shakeX = crMath::Cos( _shakePhase * 1.13f ) * _shakeMag * 0.35f;
    const float shakeY = crMath::Sin( _shakePhase * 1.71f ) * _shakeMag * 0.35f;

    app->camera->SetTarget( float3( _camPos.x + shakeX, _camPos.y + shakeY, 0.0f ) );
    app->camera->SetOrbit( 0.0f, 0.0f );
    app->camera->SetDistance( _tune.camDistance + ( speed * _tune.camSpeedZoom ) );
}
