#include "Game_IdleBreakout.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 "crUi.h"

// the play field is the XY plane at z = 0 with the camera looking down -Z (CLAUDE.md § coordinate
// system). depth is real — bricks and the core have Z extent — but nothing moves in it
static constexpr float CORE_RADIUS      = 5.0f;
static constexpr float SHELL_GAP        = 1.8f;    // core surface to the innermost shell's centre line
static constexpr float BRICK_PITCH      = 2.0f;
static constexpr float BRICK_HALF_RAD   = 0.82f;
static constexpr float BRICK_HALF_DEPTH = 0.55f;
static constexpr float BRICK_TARGET_ARC = 1.95f;   // wanted tangential width; the sector count follows from it
// fraction of its sector a brick occupies. the leftover seam is 0.27 m at the widest shell against a
// ball 0.68 m across — that is what makes the crust peel ONE shell at a time instead of leaking
static constexpr float BRICK_FILL       = 0.86f;

// discrete collision, no CCD. box3d's own guidance is that bullet bodies are for sparing use, and
// four hundred of them is the opposite of sparing. what makes discrete safe is the speed cap:
// 40 m/s covers 0.67 m in a step, against a brick 1.64 m thick, so nothing can be passed through in
// one step. raising this cap, or thinning BRICK_HALF_RAD, breaks it silently
static constexpr float BALL_SPEED_MAX = 40.0f;

static constexpr float OVERDRIVE_HEAT = 0.85f;

static constexpr int32_t CRACK_STEPS = 105;   // spectacle between the core dying and the next planet
static constexpr int32_t TEXT_STEPS  = 7;     // gold popups are banked and released on this cadence

// ABSOLUTE sim steps, not per-planet: the step index never resets, so these fire once per run and
// stay comparable across platforms
static const uint64_t HASH_STAMP_STEPS[] = { 100, 250, 500, 3000 };

// one planet's inks. inner is the lightest wash, sitting against the core, outer is the crust — so
// the shell a brick belongs to is legible without a number, and the planet's identity changes with
// depth. these are FLAT: the material bands them and draws over them, and anything authored to glow
// reads as radioactive the moment the page stops being black
struct Palette
{
    color4 inner;
    color4 mid;
    color4 outer;
    color4 core;
    color4 accent;
};

static const Palette PALETTES[] =
{
    {// SEPIA — walnut ink, one warm red allowed
        color4( 0.91f, 0.85f, 0.73f, 1.0f ), color4( 0.73f, 0.58f, 0.40f, 1.0f ), color4( 0.43f, 0.31f, 0.21f, 1.0f ),
        color4( 0.25f, 0.17f, 0.12f, 1.0f ), color4( 0.62f, 0.27f, 0.13f, 1.0f ),
    },
    {// INDIGO — blue-black, the red is a seal
        color4( 0.87f, 0.90f, 0.94f, 1.0f ), color4( 0.53f, 0.62f, 0.78f, 1.0f ), color4( 0.23f, 0.29f, 0.45f, 1.0f ),
        color4( 0.13f, 0.16f, 0.27f, 1.0f ), color4( 0.72f, 0.31f, 0.28f, 1.0f ),
    },
    {// CINNABAR — vermilion over a graphite accent
        color4( 0.94f, 0.88f, 0.82f, 1.0f ), color4( 0.80f, 0.47f, 0.36f, 1.0f ), color4( 0.47f, 0.19f, 0.16f, 1.0f ),
        color4( 0.27f, 0.11f, 0.10f, 1.0f ), color4( 0.22f, 0.23f, 0.27f, 1.0f ),
    },
    {// VERDIGRIS — copper green wash, ochre accent
        color4( 0.89f, 0.91f, 0.85f, 1.0f ), color4( 0.53f, 0.68f, 0.58f, 1.0f ), color4( 0.21f, 0.37f, 0.33f, 1.0f ),
        color4( 0.12f, 0.21f, 0.19f, 1.0f ), color4( 0.67f, 0.43f, 0.17f, 1.0f ),
    },
    {// CHARCOAL — a plate with no hue at all, so value alone has to carry it
        color4( 0.91f, 0.91f, 0.90f, 1.0f ), color4( 0.59f, 0.59f, 0.60f, 1.0f ), color4( 0.29f, 0.29f, 0.31f, 1.0f ),
        color4( 0.11f, 0.11f, 0.13f, 1.0f ), color4( 0.56f, 0.21f, 0.17f, 1.0f ),
    },
};

// the page and what sits on it. clearColor is authored sRGB and linearized on upload, so these read
// as the paper does
static const color4 PAPER       = color4( 0.940f, 0.925f, 0.895f, 1.0f );
static const color4 RULE_COLOR  = color4( 0.870f, 0.880f, 0.900f, 1.0f );   // MULTIPLY: a ruled line barely darkens
static const color4 FLECK_COLOR = color4( 0.360f, 0.340f, 0.340f, 1.0f );
static const color4 LINE_COLOR  = color4( 0.300f, 0.310f, 0.350f, 1.0f );   // drawn arena furniture — rings, halos
// additive is the only blend the particle and ripple systems have, and on paper additive means
// LIGHTER than the page. so a burst is not a spark: it is the bare sheet showing through where the
// pigment was knocked off
static const color4 PAPER_FLECK = color4( 1.000f, 0.990f, 0.950f, 1.0f );

// kind colours are palette-independent on purpose: the shell wash says how deep a brick is, the kind
// says what it does, and one must not be readable as the other
static const color4 TOUGH_COLOR    = color4( 0.47f, 0.51f, 0.59f, 1.0f );
static const color4 RICH_COLOR     = color4( 0.83f, 0.65f, 0.21f, 1.0f );
static const color4 VOLATILE_COLOR = color4( 0.78f, 0.23f, 0.15f, 1.0f );
static const color4 STRIKE_COLOR   = color4( 0.17f, 0.19f, 0.25f, 1.0f );

// four inks, all of them near-black. a nib is a dot of pigment, and a bright one on a white page is
// a hole rather than a mark
static const color4 BALL_COLORS[ 4 ] =
{
    color4( 0.15f, 0.16f, 0.21f, 1.0f ),
    color4( 0.25f, 0.17f, 0.13f, 1.0f ),
    color4( 0.16f, 0.23f, 0.21f, 1.0f ),
    color4( 0.21f, 0.19f, 0.17f, 1.0f ),
};

static const color4 UI_WHITE = color4( 0.13f, 0.13f, 0.16f, 1.0f );   // "white" is the page; the type is ink
static const color4 UI_DIM   = color4( 0.47f, 0.47f, 0.51f, 1.0f );
static const color4 UI_GOLD  = color4( 0.70f, 0.30f, 0.16f, 1.0f );   // the seal — the one warm mark the page allows

static const char* UPGRADE_NAMES[ static_cast<int32_t>( EUpgrade::_SIZE ) ] =
{
    "NIBS", "PRESS", "SPEED", "GREED",
};
static const char* UPGRADE_EFFECTS[ static_cast<int32_t>( EUpgrade::_SIZE ) ] =
{
    "+1 nib", "+1 damage", "+5% speed", "+25% gold",
};
static const double UPGRADE_COST_BASE[ static_cast<int32_t>( EUpgrade::_SIZE ) ] =
{
    18.0, 30.0, 80.0, 120.0,
};
static const double UPGRADE_COST_GROWTH[ static_cast<int32_t>( EUpgrade::_SIZE ) ] =
{
    1.075, 1.110, 1.280, 1.170,
};

// per kind: hp multiplier then gold multiplier. index-aligned with EBrickKind
static const int32_t KIND_HP_MUL[ static_cast<int32_t>( EBrickKind::_SIZE ) ]   = { 1, 4, 1, 2 };
static const double  KIND_GOLD_MUL[ static_cast<int32_t>( EBrickKind::_SIZE ) ] = { 1.0, 3.0, 12.0, 2.0 };

static uint64_t ToSteps( float seconds )
{
    return static_cast<uint64_t>( crMath::Round( seconds / crApp::FIXED_TIMESTEP ) );
}

// the body carries its entity in b3 userData, so a contact event resolves in O(1) instead of a scan.
// +1 because entity id 0 is legal (crEcs's dummy) — a null userData has to mean "untagged" and
// nothing else. the handle carries entt's version too, so a stale one still fails valid()
static void* ToUserData( entt::entity e )
{
    return reinterpret_cast<void*>( static_cast<uintptr_t>( static_cast<uint32_t>( e ) ) + 1 );
}
static entt::entity FromShape( b3ShapeId shape )
{
    const uintptr_t raw = reinterpret_cast<uintptr_t>( b3Body_GetUserData( b3Shape_GetBody( shape ) ) );
    if( raw == 0 )
        return entt::null;

    return static_cast<entt::entity>( static_cast<uint32_t>( raw - 1 ) );
}

// 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();

    b3Body_SetUserData( body, ToUserData( e ) );
    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;
}

// a decoration the sim never reads: no body, no hash, free to be animated on the render clock
static entt::entity CreateDecorEntity( entt::registry& registry, float3 position )
{
    const entt::entity e = registry.create();
    registry.emplace<CTag_NonHashTarget>( e );

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

    CTransform t;
    t.current  = { { position.x, position.y, position.z }, identity };
    t.previous = t.current;
    registry.emplace<CTransform>( e, t );

    return e;
}

// rotation taking the box's local +X onto the tangent at `angle` — every brick is laid along its own
// sector
static b3Quat TangentRotation( float angle )
{
    const float2 cs = crMath::CosSin( ( angle + ( crMath::PI * 0.5f ) ) * 0.5f );
    return { { 0.0f, 0.0f, cs.y }, cs.x };
}

static int32_t SectorCount( float radius )
{
    int32_t count = static_cast<int32_t>( crMath::Round( ( 2.0f * crMath::PI * radius ) / BRICK_TARGET_ARC ) );
    if( count < 14 )
        count = 14;
    if( count > 96 )
        count = 96;
    return count;
}

static const Palette& DepthPalette( int32_t depth )
{
    const int32_t count = static_cast<int32_t>( SDL_arraysize( PALETTES ) );
    int32_t       index = ( ( depth - 1 ) % count );
    if( index < 0 )
        index = 0;
    return PALETTES[ index ];
}

static color4 ShellColor( const Palette& palette, int32_t shell, int32_t shellCount )
{
    const float t = ( shellCount > 1 ) ? ( static_cast<float>( shell ) / static_cast<float>( shellCount - 1 ) ) : 0.0f;
    if( t < 0.5f )
        return crMath::LerpColor( palette.inner, palette.mid, ( t * 2.0f ) );

    return crMath::LerpColor( palette.mid, palette.outer, ( ( t - 0.5f ) * 2.0f ) );
}

// idle numbers outgrow %f long before the game is over
static void FormatAmount( char* out, size_t size, double value )
{
    if( value < 0.0 )
        value = 0.0;

    if( value < 1000.0 )
    {
        SDL_snprintf( out, size, "%d", static_cast<int32_t>( value ) );
        return;
    }

    static const char* SUFFIX[] = { "", "K", "M", "B", "T", "Qa", "Qi", "Sx" };
    int32_t tier = 0;
    while( ( value >= 1000.0 ) && ( tier < 7 ) )
    {
        value /= 1000.0;
        ++tier;
    }

    SDL_snprintf( out, size, "%.2f%s", value, SUFFIX[ tier ] );
}

void Game::Init( crApp* app )
{
    _app = app;
    _rng.Seed( 0xB8EAC401 );
    _decorRng.Seed( 0x5741B0DE );

    for( int32_t i = 0; i < HALO_COUNT; ++i )
        _halos[ i ] = entt::null;
    for( int32_t i = 0; i < CONTAIN_RINGS; ++i )
        _containRings[ i ] = entt::null;

    _balls.Reserve( BALL_CAP );
    _dead.Reserve( 128 );
    for( int32_t i = 0; i < SHELL_MAX; ++i )
        _shells[ i ].Reserve( SHELL_RESERVE );

    // a monospace face, not the display one the rest of this family uses: the type on a printed
    // plate is SET, and a sci-fi face is a screen reading itself back
    _font24 = app->graphics->FontAtlas()->LoadFont( "DejaVuSansMono", 24 );
    _font16 = app->graphics->FontAtlas()->LoadFont( "DejaVuSansMono", 16 );
    _font12 = app->graphics->FontAtlas()->LoadFont( "DejaVuSansMono", 12 );

    _uiButton = app->graphics->FindSprite( crGraphics::BUILTIN_ATLAS, "roundsq64edge" );
    _uiRound  = app->graphics->FindSprite( crGraphics::BUILTIN_ATLAS, "circle32" );
    _uiBar    = app->graphics->FindSprite( crGraphics::BUILTIN_ATLAS, "sq16" );
    _fxSpark  = app->graphics->FindSprite( crGraphics::BUILTIN_ATLAS, "sq16" );

    // the pen. everything in the world draws through it — bricks, nibs, the core, and the page
    // furniture, which asks for the flat branch instead of the shaded one
    _fxProgram  = app->graphics->LoadProgram( "primitive_idle_breakout.vert", "primitive_idle_breakout.frag" );
    _fxMaterial = app->renderSys->RegisterPrimitiveMaterial( _fxProgram );

    _sfxBreak  = app->audio->LoadSfx( "duelyst_f3_orbweaver_impact" );
    _sfxBlast  = app->audio->LoadSfx( "duelyst_f4_siren_attack_impact" );
    _sfxBuy    = app->audio->LoadSfx( "duelyst_ui_select" );
    _sfxShell  = app->audio->LoadSfx( "duelyst_ui_panel_swoosh_enter" );
    _sfxCrack  = app->audio->LoadSfx( "duelyst_ui_modalwindow_swoosh_enter" );
    _sfxDenied = app->audio->LoadSfx( "duelyst_ui_error" );

    {// the blot is aimed by tapping the field; key and pad fire it at wherever the pointer last was
        crButtonConfig strike;
        strike.key = SDL_SCANCODE_SPACE;
        strike.pad = SDL_GAMEPAD_BUTTON_SOUTH;
        app->inputSys->ConfigureButton( STRIKE_SLOT, &strike );
    }

    app->devConsole->Register( "depth", "jump to a planet depth", CmdDepth, this );
    app->devConsole->Register( "gold",  "grant gold",             CmdGold,  this );

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

    app->graphics->clearColor = PAPER;

    {
        crPostProcess* pp = app->graphics->PostProcess();
        pp->fog.enabled        = false;   // one flat plane at one depth — fog would only dim it uniformly

        // near off. nothing on a page glows, and the only values that clear the threshold at all are
        // the paper flecks a break throws up — those get a wet halo and nothing else does
        pp->bloom.threshold    = 1.40f;
        pp->bloom.intensity    = 0.10f;

        // ACES pulls a bright page a long way down, and a grey page is not paper
        pp->tonemap.exposure   = 1.15f;

        // not a vignette: the shadow a sheet has at its own edge. wide, soft, and only just there
        pp->vignette.enabled   = true;
        pp->vignette.radius    = 0.95f;
        pp->vignette.softness  = 0.90f;
        pp->vignette.intensity = 0.16f;

        pp->saturation         = 0.90f;   // washes, not dyes
        pp->chromatic.enabled  = false;   // ink does not fringe
        pp->glitch.strength    = 0.0f;
    }

    {// a broad, warm, near-shadowless key. the material bands whatever it gets, so what matters here
        // is only WHERE the three bands fall: the camera-facing brick faces have to land in the middle
        // one, or the whole crust shades identically and the hatching has nothing to say
        crGraphicsUniforms* uniforms = app->graphics->Uniforms();
        uniforms->lightDir       = float3( 0.45f, 0.70f, 0.55f );
        uniforms->lightColor     = color4( 1.00f, 0.97f, 0.90f, 0.42f );
        uniforms->ambientSky     = color4( 1.00f, 0.99f, 0.97f, 0.42f );
        uniforms->ambientGround  = color4( 0.72f, 0.70f, 0.68f, 0.34f );
        uniforms->specStrength   = 0.0f;   // pigment has no highlight
        uniforms->shadowStrength = 0.0f;   // top-down: a shadow map would only draw the field onto itself
    }

    b3World_SetGravity( app->physics->World(), { 0.0f, 0.0f, 0.0f } );

    SpawnArena( app );
    ApplyProgress( app );
    SpawnPlanet( app );

    _wallRadius  = ContainmentTarget();
    _wallTarget  = _wallRadius;
    _camDistance = ( _wallRadius * _tune.camFill );

    app->camera->SetTarget( float3( 0.0f, ( _camDistance * _tune.camTargetFrac ), 0.0f ) );
    app->camera->SetOrbit( 0.0f, _tune.camPitch );
    app->camera->SetDistance( _camDistance );

    app->SetSimulationActive( true );   // idle: the sim runs from boot and never stops

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

    glDeleteProgram( _fxProgram );
}

void Game::Update( crApp* app )
{
    _renderDt = app->FrameDelta().unscaled;

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

    if( app->SimulationSpeed() != _tune.simSpeed )
        app->SetSimulationSpeed( _tune.simSpeed );

    {// income readout — measured off gold EARNED, so spending it never reads as the rate collapsing
        _incomeTimer += _renderDt;
        if( _incomeTimer >= 0.5f )
        {
            const float rate = static_cast<float>( ( _goldEarnedTotal - _incomeMark ) / static_cast<double>( _incomeTimer ) );
            _incomeMark      = _goldEarnedTotal;
            _incomeTimer     = 0.0f;
            _incomeDisplay  += ( ( rate - _incomeDisplay ) * 0.45f );
        }
    }

    {// the strike is placed with the pointer. the tap is latched here and spent in a fixed step —
        // reading an edge from inside the step loop drops it on frames that run no step at all
        const crPointerInput& pointer = app->inputSys->Pointer();
        const crPlaneHit      hit     = app->camera->ScreenToPlaneZ0( pointer.posPx );
        if( hit.valid )
            _pointerWorld = hit.pos;

        if( pointer.pressed && ( app->ui->PointerCaptured() == false ) && hit.valid )
        {
            const float  scale  = crUi::RasterScale( app->camera->Viewport() );
            const float2 canvas = float2( ( pointer.posPx.x / scale ),
                                          ( ( static_cast<float>( app->camera->Viewport().y ) - pointer.posPx.y ) / scale ) );

            if( app->ui->IsOverWidget( canvas ) == false )
            {
                _queuedStrike    = true;
                _queuedStrikePos = hit.pos;
            }
        }
    }

    for( int32_t i = 0; i < FLOAT_TEXTS; ++i )
    {
        FloatText& text = _floatTexts[ i ];
        if( text.life <= 0.0f )
            continue;

        text.age += _renderDt;
        text.pos.y += ( text.rise * _renderDt );
        if( text.age >= text.life )
            text.life = 0.0f;
    }

    UpdatePresentation( app );
    UpdateCamera( app );
}
void Game::FixedUpdatePre( const crApp* app )
{
    UpdatePlayerActions( app );

    // the planet is rebuilt here and nowhere else: every registry create/destroy has to land on the
    // sim clock or the transform fingerprint becomes frame-rate dependent (CLAUDE.determinism.md)
    if( _queuedRebuild )
    {
        _queuedRebuild = false;
        ApplyProgress( app );
        SpawnPlanet( app );
        _phase            = EPhase::SHELLS;
        _wallRadius       = ContainmentTarget();
        _planetStartStep  = app->SimulationStepIndex();
    }

    UpdateContainment( app );
    UpdateBalls( app );
}
void Game::FixedUpdatePost( const crApp* app )
{
    UpdateBrickHits( app );

    _heat = crMath::Clamp01( _heat - ( _tune.heatDecay * crApp::FIXED_TIMESTEP ) );

    UpdatePhase( app );

    {// one legible number every TEXT_STEPS beats a hundred illegible ones — the bank is what makes the
        // popup mean something once the swarm is breaking bricks faster than the eye can read them
        const uint64_t step = app->SimulationStepIndex();
        if( ( _textGoldAccum > 0.0 ) && ( step >= _nextTextStep ) )
        {
            _nextTextStep = ( step + TEXT_STEPS );

            char amount[ 24 ];
            char line[ 24 ];
            FormatAmount( amount, sizeof( amount ), _textGoldAccum );
            SDL_snprintf( line, sizeof( line ), "+%s", amount );

            PushFloatText( line, _textGoldPos, IsOverdrive() ? UI_GOLD : UI_WHITE, 0.85f, 1.0f, 5.0f );
            _textGoldAccum = 0.0;
        }
    }

    StampDeterminism( app );
}
void Game::HandleEvent( const crApp* app, const SDL_Event* event )
{
    ( void )app;

    if( ( event->type != SDL_EVENT_KEY_DOWN ) || event->key.repeat )
        return;

    const int32_t digit = ( static_cast<int32_t>( event->key.key ) - static_cast<int32_t>( SDLK_1 ) );
    if( ( digit >= 0 ) && ( digit < UPGRADE_COUNT ) )
        _queuedBuy = digit;
}
void Game::Render( const crApp* app )
{
    for( int32_t i = 0; i < FLOAT_TEXTS; ++i )
    {
        const FloatText& text = _floatTexts[ i ];
        if( text.life <= 0.0f )
            continue;

        // alpha alone, no premultiply: type on paper thins out as the ink runs short, it does not
        // walk towards black — that only reads on a dark screen
        const float  fade = crMath::Clamp01( 1.0f - ( text.age / text.life ) );
        const color4 tint = color4( text.color.r, text.color.g, text.color.b, fade );

        app->renderSys->RenderTextWorld( app, text.text, text.pos, _font16, tint, text.scale, float2( 0.5f, 0.5f ) );
    }
}
void Game::RenderUi( crApp* app )
{
    crUi* ui = app->ui;
    ui->Begin( app );

    RenderHud( app );
    RenderShop( app );

    ui->End();
}
void Game::RenderDevUi( const crApp* app )
{
    {
        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 ) );
        ImGui::SetNextWindowCollapsed( true, ImGuiCond_FirstUseEver );
    }

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

    static const char* PHASE_NAMES[] = { "SHELLS", "CORE", "CRACK" };

    const float elapsed = ( static_cast<float>( app->SimulationStepIndex() - _planetStartStep ) * crApp::FIXED_TIMESTEP );
    ImGui::Text( "depth %d (best %d)   %s   %.0fs", _progress.depth, _progress.bestDepth, PHASE_NAMES[ static_cast<int32_t>( _phase ) ], elapsed );
    ImGui::Text( "shell %d/%d   bricks %d   core %d/%d",
                 ( _liveShell + 1 ), _shellCount, ( _liveShell >= 0 ) ? _shells[ _liveShell ].Size() : 0, _coreHp, _coreHpMax );
    ImGui::Text( "balls %d/%d   damage %d   speed %.1f", _balls.Size(), BallTarget(), BallDamage(), BallSpeed() );
    ImGui::Text( "gold %.0f   income %.1f/s   broken %d", _progress.gold, _incomeDisplay, _bricksBroken );
    ImGui::Text( "flow %.2f%s   wall %.1f -> %.1f", _heat, IsOverdrive() ? "  FULL" : "", _wallRadius, _wallTarget );
    ImGui::Text( "levels  %d / %d / %d / %d",
                 _progress.level[ 0 ], _progress.level[ 1 ], _progress.level[ 2 ], _progress.level[ 3 ] );

    ImGui::Separator();

    if( ImGui::Button( "+1M gold" ) )
        _progress.gold += 1000000.0;
    ImGui::SameLine();
    if( ImGui::Button( "next planet" ) )
    {
        ++_progress.depth;
        if( _progress.depth > _progress.bestDepth )
            _progress.bestDepth = _progress.depth;
        _queuedRebuild = true;
    }
    ImGui::SameLine();
    if( ImGui::Button( "wipe" ) )
    {
        _progress      = Progress();
        _queuedRebuild = true;
    }

    ImGui::Separator();

    ImGui::SliderFloat( "sim speed", &_tune.simSpeed, 0.25f, 4.0f );
    ImGui::SliderFloat( "ball speed", &_tune.ballSpeed, 6.0f, 30.0f );
    ImGui::SliderFloat( "ball radius", &_tune.ballRadius, 0.15f, 0.7f );
    ImGui::SliderFloat( "shell hp base", &_tune.shellHpBase, 1.0f, 20.0f );
    ImGui::SliderFloat( "shell hp growth", &_tune.shellHpGrowth, 1.0f, 1.6f );
    ImGui::SliderFloat( "shell hp inner", &_tune.shellHpInner, 0.0f, 1.5f );
    ImGui::SliderFloat( "core hp base", &_tune.coreHpBase, 10.0f, 800.0f );
    ImGui::SliderFloat( "core hp growth", &_tune.coreHpGrowth, 1.0f, 1.6f );
    ImGui::SliderFloat( "gold / depth", &_tune.goldPerDepth, 1.0f, 1.6f );
    ImGui::SliderFloat( "shell bonus", &_tune.shellBonus, 0.0f, 200.0f );
    ImGui::SliderFloat( "core bonus", &_tune.coreBonus, 0.0f, 2000.0f );
    ImGui::SliderFloat( "flow / brick", &_tune.heatPerBrick, 0.0f, 0.3f );
    ImGui::SliderFloat( "flow decay", &_tune.heatDecay, 0.05f, 3.0f );
    ImGui::SliderFloat( "flow gold", &_tune.heatGold, 0.0f, 6.0f );
    ImGui::SliderFloat( "blot cooldown", &_tune.strikeCooldown, 1.0f, 40.0f );
    ImGui::SliderFloat( "blot radius", &_tune.strikeRadius, 2.0f, 20.0f );
    ImGui::SliderInt( "blot power", &_tune.strikePower, 1, 200 );
    ImGui::SliderFloat( "ink floor", &_tune.inkFloor, 0.0f, 0.5f );
    ImGui::SliderFloat( "ink min", &_tune.inkMin, 0.0f, 1.0f );
    ImGui::SliderFloat( "ink gain", &_tune.inkGain, 0.0f, 5.0f );
    ImGui::SliderFloat( "annulus", &_tune.annulus, 1.5f, 12.0f );
    ImGui::SliderFloat( "wall lerp", &_tune.wallLerp, 0.01f, 0.5f );
    ImGui::SliderFloat( "cam fill", &_tune.camFill, 1.5f, 6.0f );
    ImGui::SliderFloat( "cam target frac", &_tune.camTargetFrac, -0.5f, 0.2f );
    ImGui::SliderFloat( "cam pitch", &_tune.camPitch, 0.0f, 1.2f );
    ImGui::SliderFloat( "cam shake", &_tune.camShake, 0.0f, 3.0f );

    ImGui::End();
}

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

    {// the core body — a plain static sphere the balls bounce off. it outlives every planet, because
        // its pose and radius never change; only its hp does
        b3BodyDef bodyDef = b3DefaultBodyDef();
        bodyDef.position = { 0.0f, 0.0f, 0.0f };

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

        b3ShapeDef shapeDef = b3DefaultShapeDef();
        shapeDef.density                  = 1.0f;
        shapeDef.baseMaterial.friction    = 0.0f;
        shapeDef.baseMaterial.restitution = 1.0f;
        shapeDef.enableContactEvents      = true;
        shapeDef.filter.categoryBits      = CAT_BRICK;
        shapeDef.filter.maskBits          = CAT_BALL;
        const b3Sphere sphere = { { 0.0f, 0.0f, 0.0f }, CORE_RADIUS };
        b3CreateSphereShape( body, &shapeDef, &sphere );

        _core = CreateStaticEntity( registry, body, bodyDef.position, bodyDef.rotation );
    }

    {// what the core LOOKS like is a separate entity: it spins and takes the wet blot on the render
        // clock, which a body-backed transform may never do
        _coreShell = CreateDecorEntity( registry, float3( 0.0f, 0.0f, 0.0f ) );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::ICOSAHEDRON;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.material = _fxMaterial;
        primitive.scale    = float3( CORE_RADIUS, CORE_RADIUS, CORE_RADIUS );
        primitive.color    = PALETTES[ 0 ].core;
        primitive.emissive = 0.0f;
        primitive.fresnel  = 1.0f;    // the heaviest contour on the sheet — every other line is drawn around it
        primitive.custom0  = 0.0f;    // ink — chased in UpdatePresentation
        primitive.custom2  = 0.31f;   // paper seed
        registry.emplace<CPrimitive>( _coreShell, primitive );
    }

    for( int32_t i = 0; i < HALO_COUNT; ++i )
    {// crossed orbit lines — flat annuli tipped out of the play plane, so the spin reads from above.
        // MULTIPLY, because that is the only blend on paper that means anything: ink darkens
        const entt::entity e = CreateDecorEntity( registry, float3( 0.0f, 0.0f, 0.0f ) );

        const float radius = ( CORE_RADIUS + 1.1f + ( 1.0f * static_cast<float>( i ) ) );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::RING_THIN;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.blend    = EBlendMode::MULTIPLY;
        primitive.material = _fxMaterial;
        primitive.scale    = float3( radius, radius, radius );
        primitive.color    = PALETTES[ 0 ].core;
        primitive.custom3  = 1.0f;   // line art: flat, no shading, no hatch
        registry.emplace<CPrimitive>( e, primitive );

        _halos[ i ] = e;
    }

    {// the planet's own outline. a near-white shell on MULTIPLY leaves the interior alone — only its
        // contour lands, so what the player sees is the circle a pen would have drawn round the whole
        // thing. it hugs whatever is left of the crust, so the silhouette holds down to one shell
        _atmosphere = CreateDecorEntity( registry, float3( 0.0f, 0.0f, 0.0f ) );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::SPHERE_SHELL;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.blend    = EBlendMode::MULTIPLY;
        primitive.material = _fxMaterial;
        primitive.scale    = float3( CORE_RADIUS, CORE_RADIUS, CORE_RADIUS );
        primitive.color    = color4( 1.0f, 1.0f, 1.0f, 1.0f );
        primitive.fresnel  = 0.35f;   // SPHERE_SHELL draws both faces, so the contour lands twice
        primitive.custom3  = 1.0f;
        registry.emplace<CPrimitive>( _atmosphere, primitive );
    }

    for( int32_t i = 0; i < CONTAIN_RINGS; ++i )
    {// the containment is analytic — these bands are only where it IS, so the player can read the
        // arena closing in behind every shell that falls
        const entt::entity e = CreateDecorEntity( registry, float3( 0.0f, 0.0f, 0.0f ) );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::RING_THIN;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.blend    = EBlendMode::MULTIPLY;
        primitive.material = _fxMaterial;
        primitive.scale    = float3( 1.0f, 1.0f, 1.0f );
        primitive.color    = LINE_COLOR;
        primitive.custom3  = 1.0f;
        registry.emplace<CPrimitive>( e, primitive );

        _containRings[ i ] = e;
    }

    for( int32_t i = 0; i < PULSE_RINGS; ++i )
    {// shockwave pool — parked at scale zero, never created or destroyed while the game runs
        const entt::entity e = CreateDecorEntity( registry, float3( 0.0f, 0.0f, 0.0f ) );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::RING_THIN;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.blend    = EBlendMode::MULTIPLY;
        primitive.material = _fxMaterial;
        primitive.scale    = float3( 0.0f, 0.0f, 0.0f );
        primitive.color    = STRIKE_COLOR;
        primitive.custom3  = 1.0f;
        registry.emplace<CPrimitive>( e, primitive );

        _pulseRings[ i ].entity = e;
        _pulseRings[ i ].life   = 0.0f;
    }

    for( int32_t i = 0; i < RULE_COUNT; ++i )
    {// the page itself. a starfield is what a black screen puts behind a game; a sheet has RULES on
        // it, and the field being drawn on top of them is what says this is paper and not space
        const float y = ( ( static_cast<float>( i ) - ( 0.5f * static_cast<float>( RULE_COUNT - 1 ) ) ) * 15.0f );

        const entt::entity e = CreateDecorEntity( registry, float3( 0.0f, y, -26.0f ) );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::BOX;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.blend    = EBlendMode::MULTIPLY;
        primitive.material = _fxMaterial;
        primitive.scale    = float3( 150.0f, 0.075f, 0.02f );
        primitive.color    = RULE_COLOR;
        primitive.custom3  = 1.0f;
        registry.emplace<CPrimitive>( e, primitive );
    }

    {// the margin — the one rule the drawing is allowed to cross, which is what makes it a margin
        const entt::entity e = CreateDecorEntity( registry, float3( -58.0f, 0.0f, -26.0f ) );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::BOX;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.blend    = EBlendMode::MULTIPLY;
        primitive.material = _fxMaterial;
        primitive.scale    = float3( 0.10f, 120.0f, 0.02f );
        primitive.color    = color4( 0.92f, 0.82f, 0.82f, 1.0f );
        primitive.custom3  = 1.0f;
        registry.emplace<CPrimitive>( e, primitive );
    }

    for( int32_t i = 0; i < ( FLECK_COUNT + BLOT_COUNT ); ++i )
    {// spatter — the pen was shaken out over the sheet before anything was drawn on it. the last few
        // are the fat ones, which is what stops the rest reading as noise
        const bool  fat    = ( i >= FLECK_COUNT );
        const float z      = _decorRng.NextFloat32( -62.0f, -22.0f );
        const float spread = ( 52.0f + ( -z * 0.80f ) );
        const float radius = fat ? _decorRng.NextFloat32( 0.9f, 2.1f ) : _decorRng.NextFloat32( 0.12f, 0.48f );
        const float wash   = _decorRng.NextFloat32( 0.30f, 0.85f );

        const entt::entity e = CreateDecorEntity( registry, float3( _decorRng.NextFloat32( -spread, spread ),
                                                                    _decorRng.NextFloat32( -spread, spread ),
                                                                    z ) );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::SPHERE;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.blend    = EBlendMode::MULTIPLY;
        primitive.material = _fxMaterial;
        primitive.scale    = float3( radius, radius, radius );
        primitive.color    = crMath::LerpColor( FLECK_COLOR, PAPER, wash );
        primitive.custom3  = 1.0f;
        registry.emplace<CPrimitive>( e, primitive );
    }
}
void Game::SpawnPlanet( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    ClearPlanet( app );

    for( int32_t shell = 0; shell < _shellCount; ++shell )
        SpawnShell( app, shell );

    _liveShell = ( _shellCount - 1 );
    _coreHp    = _coreHpMax;
    _coreFlash = 0.0f;
    _phase     = EPhase::SHELLS;

    {// the palette is the planet's identity — recolour everything the arena keeps across planets
        const Palette& palette = DepthPalette( _progress.depth );

        if( registry.valid( _coreShell ) )
            registry.get<CPrimitive>( _coreShell ).color = palette.core;

        for( int32_t i = 0; i < HALO_COUNT; ++i )
        {
            if( registry.valid( _halos[ i ] ) )
                registry.get<CPrimitive>( _halos[ i ] ).color = palette.core;
        }

        if( registry.valid( _atmosphere ) )
            registry.get<CPrimitive>( _atmosphere ).color = crMath::LerpColor( color4( 1.0f, 1.0f, 1.0f, 1.0f ), palette.accent, 0.10f );

        for( int32_t i = 0; i < CONTAIN_RINGS; ++i )
        {
            if( registry.valid( _containRings[ i ] ) )
                registry.get<CPrimitive>( _containRings[ i ] ).color = crMath::LerpColor( LINE_COLOR, palette.accent, 0.40f );
        }
    }

    {// the swarm re-forms around the new planet. without this the balls would be left standing inside
        // the crust that just materialized around them
        const float target = ContainmentTarget();
        const float launch = ( target - _tune.ballRadius - 0.4f );
        const float speed  = BallSpeed();

        _wallRadius = target;
        _wallTarget = target;

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

            const float  angle = _rng.NextFloat32( 0.0f, ( 2.0f * crMath::PI ) );
            const float2 cs    = crMath::CosSin( angle );
            const float2 head  = crMath::CosSin( angle + crMath::PI + _rng.NextFloat32( -0.9f, 0.9f ) );

            const b3BodyId body = registry.get<CPhysicsBody>( e ).bodyId;
            b3Body_SetTransform( body, { ( cs.x * launch ), ( cs.y * launch ), 0.0f }, registry.get<CTransform>( e ).current.q );
            b3Body_SetLinearVelocity( body, { ( head.x * speed ), ( head.y * speed ), 0.0f } );
        }
    }
}
void Game::SpawnShell( const crApp* app, int32_t shell )
{
    const float   radius  = ShellRadius( shell );
    const int32_t sectors = SectorCount( radius );
    const float   halfArc = ( ( ( crMath::PI * radius ) / static_cast<float>( sectors ) ) * BRICK_FILL );
    const float   offset  = ( ( shell % 2 ) == 0 ) ? 0.0f : ( crMath::PI / static_cast<float>( sectors ) );

    for( int32_t s = 0; s < sectors; ++s )
    {
        const float angle = ( offset + ( ( 2.0f * crMath::PI * static_cast<float>( s ) ) / static_cast<float>( sectors ) ) );
        SpawnBrick( app, shell, radius, angle, halfArc );
    }
}
void Game::SpawnBrick( const crApp* app, int32_t shell, float radius, float angle, float halfArc )
{
    entt::registry& registry = app->ecs->registry;

    // treats stay rare everywhere; the tough shells live further in, where the crust is oldest
    EBrickKind kind = EBrickKind::NORMAL;
    {
        const float roll  = _rng.NextFloat32();
        const float inner = static_cast<float>( _shellCount - 1 - shell );
        if( roll < 0.035f )
            kind = EBrickKind::RICH;
        else if( roll < 0.070f )
            kind = EBrickKind::VOLATILE;
        else if( roll < ( 0.10f + ( 0.05f * inner ) ) )
            kind = EBrickKind::TOUGH;
    }

    const float2 normal = crMath::CosSin( angle );

    b3BodyDef bodyDef = b3DefaultBodyDef();
    bodyDef.position = { ( normal.x * radius ), ( normal.y * radius ), 0.0f };
    bodyDef.rotation = TangentRotation( angle );

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

    b3ShapeDef shapeDef = b3DefaultShapeDef();
    shapeDef.density                  = 1.0f;
    shapeDef.baseMaterial.friction    = 0.0f;
    shapeDef.baseMaterial.restitution = 1.0f;
    shapeDef.enableContactEvents      = true;
    shapeDef.filter.categoryBits      = CAT_BRICK;
    shapeDef.filter.maskBits          = CAT_BALL;
    const b3BoxHull hull = b3MakeBoxHull( halfArc, BRICK_HALF_RAD, BRICK_HALF_DEPTH );
    b3CreateHullShape( body, &shapeDef, &hull.base );

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

    const int32_t kindIndex = static_cast<int32_t>( kind );

    CBrick brick;
    brick.hpMax = ( _shellHp[ shell ] * KIND_HP_MUL[ kindIndex ] );
    brick.hp    = brick.hpMax;
    brick.seed  = _rng.NextFloat32();
    brick.flash = 0.0f;
    brick.kind  = kind;
    switch( kind )
    {
    case EBrickKind::TOUGH:
        brick.baseColor = TOUGH_COLOR;
        break;
    case EBrickKind::RICH:
        brick.baseColor = RICH_COLOR;
        break;
    case EBrickKind::VOLATILE:
        brick.baseColor = VOLATILE_COLOR;
        break;
    default:
        brick.baseColor = ShellColor( DepthPalette( _progress.depth ), shell, _shellCount );
        break;
    }
    registry.emplace<CBrick>( e, brick );

    CPrimitive primitive;
    primitive.shape    = EPrimitive::BOX;
    primitive.flags    = EPrimitiveFlags::NONE;
    primitive.material = _fxMaterial;
    primitive.scale    = float3( halfArc, BRICK_HALF_RAD, BRICK_HALF_DEPTH );
    primitive.color    = brick.baseColor;
    primitive.emissive = 0.0f;
    primitive.fresnel  = 1.0f;             // contour weight, not a rim glow — the material narrows it to a line
    primitive.custom0  = _tune.inkFloor;   // ink — chased in UpdatePresentation
    primitive.custom1  = 0.0f;             // wet
    primitive.custom2  = brick.seed;       // paper tooth offset; set once and never touched again
    registry.emplace<CPrimitive>( e, primitive );

    _shells[ shell ].Add( e );
}
void Game::SpawnBall( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const float  angle  = _rng.NextFloat32( 0.0f, ( 2.0f * crMath::PI ) );
    const float2 cs     = crMath::CosSin( angle );
    const float  launch = ( _wallRadius - _tune.ballRadius - 0.4f );
    const float  speed  = BallSpeed();

    // headed inward, but off-centre: a swarm that all aimed at the middle would collapse into one
    // pumping line instead of an orbit
    const float2 head = crMath::CosSin( angle + crMath::PI + _rng.NextFloat32( -0.9f, 0.9f ) );

    b3BodyDef bodyDef = b3DefaultBodyDef();
    bodyDef.type                 = b3_dynamicBody;
    bodyDef.position             = { ( cs.x * launch ), ( cs.y * launch ), 0.0f };
    bodyDef.linearVelocity       = { ( head.x * speed ), ( head.y * speed ), 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.baseMaterial.friction    = 0.0f;
    shapeDef.baseMaterial.restitution = 1.0f;
    // box3d ORs the flag across the pair, and documents it as applying to kinematic and dynamic
    // bodies — so this dynamic side is the load-bearing one. the brick sets it too: a static shape
    // whose flag is ignored costs nothing, and a whole game that silently registers no hits does
    shapeDef.enableContactEvents      = true;
    shapeDef.filter.categoryBits      = CAT_BALL;
    shapeDef.filter.maskBits          = CAT_BRICK;
    const b3Sphere sphere = { { 0.0f, 0.0f, 0.0f }, _tune.ballRadius };
    b3CreateSphereShape( body, &shapeDef, &sphere );

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

    CBall ball;
    ball.colorIndex = _rng.NextInt32( 0, static_cast<int32_t>( SDL_arraysize( BALL_COLORS ) ) );
    registry.emplace<CBall>( e, ball );

    const color4 tint = BALL_COLORS[ ball.colorIndex ];

    // no ribbon trail. the trail system is additive only, and additive on a white page means LIGHTER
    // than the paper — an eraser, not a pen. a drawn nib is a dot with a line round it and nothing
    // dragging behind it
    CPrimitive primitive;
    primitive.shape    = EPrimitive::SPHERE;
    primitive.flags    = EPrimitiveFlags::NONE;
    primitive.material = _fxMaterial;
    primitive.scale    = float3( _tune.ballRadius, _tune.ballRadius, _tune.ballRadius );
    primitive.color    = tint;
    primitive.emissive = 0.0f;
    primitive.fresnel  = 1.0f;
    primitive.custom0  = 0.30f;
    primitive.custom2  = ( static_cast<float>( ball.colorIndex ) * 0.23f );
    registry.emplace<CPrimitive>( e, primitive );

    _balls.Add( e );

    {// a purchase has to be visible the instant it is made — the ball arriving IS the receipt
        CParticleEmitter spark;
        spark.color        = PAPER_FLECK;
        spark.colorEnd     = color4( 0.0f, 0.0f, 0.0f, 1.0f );
        spark.gravity      = float3( 0.0f, 0.0f, 0.0f );
        spark.drag         = 3.0f;
        spark.spread       = 9.0f;
        spark.lifetimeMin  = 0.12f;
        spark.lifetimeMax  = 0.28f;
        spark.sizeMin      = 0.08f;
        spark.sizeMax      = 0.22f;
        spark.sizeEndScale = 0.0f;
        spark.intensityMin = 1.0f;
        spark.intensityMax = 1.9f;
        spark.spriteIndex  = _fxSpark;
        app->particles->EmitBurst( app, spark, float3( bodyDef.position.x, bodyDef.position.y, 0.0f ), 14 );
    }
}
void Game::ClearPlanet( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

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

            b3DestroyBody( registry.get<CPhysicsBody>( e ).bodyId );
            registry.destroy( e );
        }
        _shells[ shell ].Clear();
    }

    _dead.Clear();
}

void Game::UpdateBalls( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    {// servo the live count onto the purchased one — growth is rationed, because a hundred bodies
        // created in one step is a visible hitch. it shrinks too, so a wipe actually takes effect
        const int32_t target = BallTarget();

        int32_t budget = 8;
        while( ( _balls.Size() < target ) && ( budget > 0 ) )
        {
            SpawnBall( app );
            --budget;
        }

        while( _balls.Size() > target )
        {
            const entt::entity e = _balls.At( _balls.Size() - 1 );
            if( registry.valid( e ) )
            {
                b3DestroyBody( registry.get<CPhysicsBody>( e ).bodyId );
                registry.destroy( e );
            }
            _balls.Pop();
        }
    }

    const float speed = BallSpeed();
    const float limit = ( _wallRadius - _tune.ballRadius );

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

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

        // constant-speed servo — restitution alone neither conserves the speed exactly nor lets the
        // SPEED upgrade mean anything
        b3Vec3      v  = b3Body_GetLinearVelocity( body );
        const float sq = ( ( v.x * v.x ) + ( v.y * v.y ) );
        if( sq < 0.0001f )
        {
            const float2 cs = crMath::CosSin( _rng.NextFloat32( 0.0f, ( 2.0f * crMath::PI ) ) );
            v.x = ( cs.x * speed );
            v.y = ( cs.y * speed );
        }
        else
        {
            const float scale = ( speed / crMath::Sqrt( sq ) );
            v.x = ( v.x * scale );
            v.y = ( v.y * scale );
        }
        v.z = 0.0f;

        // the containment: a circle reflects a sphere analytically, so this IS the exact collision a
        // ring of wall bodies would only approximate — and a radius that is just a number can be
        // animated every step, which is what lets the arena close in behind a fallen shell
        const b3Pos position = b3Body_GetPosition( body );
        const float distSq   = ( ( position.x * position.x ) + ( position.y * position.y ) );
        if( distSq > ( limit * limit ) )
        {
            const float dist = crMath::Sqrt( distSq );
            const float nx   = ( position.x / dist );
            const float ny   = ( position.y / dist );

            const float vn = ( ( v.x * nx ) + ( v.y * ny ) );
            if( vn > 0.0f )
            {
                v.x -= ( 2.0f * vn * nx );
                v.y -= ( 2.0f * vn * ny );
            }

            // put it back ON the circle — a contracting containment would otherwise strand it outside
            b3Body_SetTransform( body, { ( nx * limit ), ( ny * limit ), 0.0f }, registry.get<CTransform>( e ).current.q );
        }

        b3Body_SetLinearVelocity( body, v );
    }
}
void Game::UpdateBrickHits( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

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

    int32_t coreHits = 0;

    for( int32_t i = 0; i < events.beginCount; ++i )
    {
        const b3ContactBeginTouchEvent& touch = events.beginEvents[ i ];

        // contact events are enabled on the ball side, so one of the two is always a ball and the
        // other is a brick or the core
        const entt::entity a = FromShape( touch.shapeIdA );
        const entt::entity b = FromShape( touch.shapeIdB );

        entt::entity brick = entt::null;
        if( registry.valid( a ) && registry.all_of<CBrick>( a ) )
            brick = a;
        else if( registry.valid( b ) && registry.all_of<CBrick>( b ) )
            brick = b;

        if( brick != entt::null )
        {
            if( DamageBrick( app, brick, damage ) )
                _dead.Add( brick );
        }
        else if( ( a == _core ) || ( b == _core ) )
        {
            ++coreHits;
        }
    }

    // the core only answers once its crust is gone. a ball that slipped in through a strike breach
    // still bounces off it, but it cannot bank damage against a phase that has not started
    if( ( coreHits > 0 ) && ( _phase == EPhase::CORE ) )
        DamageCore( app, ( damage * coreHits ) );

    // index-walked, not popped: DestroyBrick can append (a volatile brick kills its neighbours), so
    // the chain resolves in this same drain instead of recursing
    for( int32_t i = 0; i < _dead.Size(); ++i )
    {
        const entt::entity e = _dead.At( i );
        if( registry.valid( e ) == false )
            continue;

        DestroyBrick( app, e );
    }
    _dead.Clear();

    // every standing shell, not just the live one: a strike can open a breach, and balls that pour
    // through it kill bricks further in. a shell left holding dead handles never reports itself empty,
    // which would stall the whole planet on a phase that can no longer advance
    for( int32_t shell = 0; shell <= _liveShell; ++shell )
    {
        crArray<entt::entity>& bricks = _shells[ shell ];

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

            bricks.At( write ) = e;
            ++write;
        }
        while( bricks.Size() > write )
            bricks.Pop();
    }
}
void Game::UpdatePlayerActions( const crApp* app )
{
    if( _queuedBuy >= 0 )
    {
        BuyUpgrade( app, static_cast<EUpgrade>( _queuedBuy ) );
        _queuedBuy = -1;
    }

    if( _queuedStrike )
    {
        _queuedStrike = false;
        FireStrike( app, _queuedStrikePos );
    }

    // key and pad route through the input slot, which already scoped the edge to this one sim step.
    // they have no aim of their own, so they take the pointer's last resting place
    if( app->inputSys->ButtonPressed( STRIKE_SLOT ) )
        FireStrike( app, _pointerWorld );
}
void Game::UpdateContainment( const crApp* app )
{
    ( void )app;

    _wallTarget = ContainmentTarget();
    _wallRadius += ( ( _wallTarget - _wallRadius ) * _tune.wallLerp );
}
void Game::UpdatePhase( const crApp* app )
{
    const uint64_t step = app->SimulationStepIndex();

    switch( _phase )
    {
    case EPhase::SHELLS:
    {
        if( ( _liveShell < 0 ) || ( _shells[ _liveShell ].Size() > 0 ) )
            return;

        const double bonus = ( GoldPerBrick() * static_cast<double>( _tune.shellBonus ) );
        AddGold( bonus );

        _heat = crMath::Clamp01( _heat + 0.25f );

        {
            char amount[ 24 ];
            char line[ 24 ];
            FormatAmount( amount, sizeof( amount ), bonus );
            SDL_snprintf( line, sizeof( line ), "SHELL  +%s", amount );
            PushFloatText( line, float3( 0.0f, ( ShellRadius( _liveShell ) + 2.5f ), 0.0f ), UI_GOLD, 1.4f, 1.5f, 3.0f );
        }

        PushPulseRing( float3( 0.0f, 0.0f, 0.0f ), ( ShellRadius( _liveShell ) + 3.0f ), 0.7f, DepthPalette( _progress.depth ).accent );
        app->graphics->PostProcess()->QueueRipple( float3( 0.0f, 0.0f, 0.0f ), ( ShellRadius( _liveShell ) + 3.0f ), 0.6f, 0.016f );
        app->audio->PlaySfx( _sfxShell, 0.6f, 0.0f );

        _shakeMag = crMath::Clamp( ( _shakeMag + 0.45f ), 0.0f, 1.0f );

        --_liveShell;
        if( _liveShell >= 0 )
            return;

        _phase = EPhase::CORE;

        PushFloatText( "CORE EXPOSED", float3( 0.0f, ( CORE_RADIUS + 3.5f ), 0.0f ), DepthPalette( _progress.depth ).core, 1.8f, 1.8f, 2.0f );
        PushPulseRing( float3( 0.0f, 0.0f, 0.0f ), 20.0f, 0.9f, DepthPalette( _progress.depth ).core );
        app->graphics->PostProcess()->QueueRipple( float3( 0.0f, 0.0f, 0.0f ), 22.0f, 0.8f, 0.022f );
        app->audio->PlaySfx( _sfxCrack, 0.7f, 0.0f );

        _flashPulse = 1.0f;
        _shakeMag   = 1.0f;
        break;
    }

    case EPhase::CORE:
    {
        if( _coreHp > 0 )
            return;

        const double bonus = ( GoldPerBrick() * static_cast<double>( _tune.coreBonus ) );
        AddGold( bonus );

        _phase        = EPhase::CRACK;
        _crackEndStep = ( step + CRACK_STEPS );
        _heat         = 1.0f;

        {
            char amount[ 24 ];
            char line[ 24 ];
            FormatAmount( amount, sizeof( amount ), bonus );
            SDL_snprintf( line, sizeof( line ), "CRACKED  +%s", amount );
            PushFloatText( line, float3( 0.0f, 6.0f, 0.0f ), UI_GOLD, 2.2f, 2.0f, 2.5f );
        }

        {
            const Palette& palette = DepthPalette( _progress.depth );

            CParticleEmitter debris;
            debris.color        = PAPER_FLECK;
            debris.colorEnd     = color4( 0.0f, 0.0f, 0.0f, 1.0f );
            debris.gravity      = float3( 0.0f, 0.0f, 0.0f );
            debris.drag         = 0.7f;
            debris.spread       = 26.0f;
            debris.lifetimeMin  = 0.5f;
            debris.lifetimeMax  = 1.5f;
            debris.sizeMin      = 0.14f;
            debris.sizeMax      = 0.6f;
            debris.sizeEndScale = 0.05f;
            debris.intensityMin = 1.0f;
            debris.intensityMax = 2.2f;
            debris.spinMin      = -10.0f;
            debris.spinMax      = 10.0f;
            debris.spriteIndex  = _fxSpark;
            app->particles->EmitBurst( app, debris, float3( 0.0f, 0.0f, 0.0f ), 420 );

            // both rings are ink: a white one would be a no-op against the page under MULTIPLY
            PushPulseRing( float3( 0.0f, 0.0f, 0.0f ), 34.0f, 1.3f, palette.core );
            PushPulseRing( float3( 0.0f, 0.0f, 0.0f ), 20.0f, 0.8f, LINE_COLOR );
        }

        app->graphics->PostProcess()->QueueRipple( float3( 0.0f, 0.0f, 0.0f ), 34.0f, 1.1f, 0.03f );
        app->audio->PlaySfx( _sfxBlast, 1.0f, 0.0f );

        _flashPulse = 1.0f;
        _soakPulse  = 1.0f;
        _shakeMag   = 1.0f;
        break;
    }

    case EPhase::CRACK:
    {
        if( step < _crackEndStep )
            return;

        ++_progress.depth;
        if( _progress.depth > _progress.bestDepth )
            _progress.bestDepth = _progress.depth;

        ApplyProgress( app );
        SpawnPlanet( app );

        _planetStartStep = step;
        _stampPending    = true;

        {
            char line[ 24 ];
            SDL_snprintf( line, sizeof( line ), "DEPTH %d", _progress.depth );
            PushFloatText( line, float3( 0.0f, ( PlanetRadius() + 3.0f ), 0.0f ), DepthPalette( _progress.depth ).accent, 1.6f, 1.8f, 2.0f );
        }

        PushPulseRing( float3( 0.0f, 0.0f, 0.0f ), ( _wallRadius + 2.0f ), 0.9f, DepthPalette( _progress.depth ).accent );
        app->graphics->PostProcess()->QueueRipple( float3( 0.0f, 0.0f, 0.0f ), _wallRadius, 0.7f, 0.018f );
        app->audio->PlaySfx( _sfxShell, 0.8f, 0.0f );
        break;
    }

    default:
        break;
    }
}
void Game::StampDeterminism( const crApp* app )
{
    const uint64_t step = app->SimulationStepIndex();

    bool stamp = _stampPending;
    for( size_t i = 0; i < SDL_arraysize( HASH_STAMP_STEPS ); ++i )
    {
        if( step == HASH_STAMP_STEPS[ i ] )
        {
            stamp = true;
            break;
        }
    }

    if( stamp == false )
        return;

    const crRandomState rngState = _rng.GetState();

    // covers only what CHANGES WHAT A STEP DOES — the depth (shell hp, core hp, payout) and the
    // upgrade levels (ball count, damage, speed). gold moves constantly without the sim branching on
    // it, except at a purchase, and a purchase shows up as a level. two runs are comparable only when
    // this matches: this is an idle game, so buying at a different moment is a different sim
    uint64_t shape = crMath::HashFnv1a( &_progress.depth, sizeof( _progress.depth ), crMath::HASH_FNV1A_SEED );
    shape = crMath::HashFnv1a( _progress.level, sizeof( _progress.level ), shape );

    char tail[ 32 ] = "";
    if( _stampPending )
    {
        SDL_snprintf( tail, sizeof( tail ), " depth:%d", _progress.depth );
        _stampPending = false;
    }

    SDL_LogInfo( SDL_LOG_CATEGORY_APPLICATION, "[step:%" SDL_PRIu64 "] hash:%016" SDL_PRIx64 " rng:%016" SDL_PRIx64 " shape:%016" SDL_PRIx64 "%s",
                 step,
                 app->ecs->HashTransforms(),
                 crMath::HashFnv1a( &rngState, sizeof( rngState ), crMath::HASH_FNV1A_SEED ),
                 shape,
                 tail );
}

bool Game::DamageBrick( const crApp* app, entt::entity e, int32_t damage )
{
    entt::registry& registry = app->ecs->registry;
    if( registry.valid( e ) == false )
        return false;

    CBrick& brick = registry.get<CBrick>( e );
    if( brick.hp <= 0 )
        return false;   // already queued for teardown earlier in this batch

    brick.hp   -= damage;
    brick.flash = 1.0f;

    return ( brick.hp <= 0 );
}
void Game::DestroyBrick( const crApp* app, entt::entity e )
{
    entt::registry& registry = app->ecs->registry;

    const CBrick& brick = registry.get<CBrick>( e );
    const float3  pos   = float3( registry.get<CTransform>( e ).current.p );
    const int32_t kind  = static_cast<int32_t>( brick.kind );
    const color4  tint  = brick.baseColor;
    const bool    blast = ( brick.kind == EBrickKind::VOLATILE );

    const double payout = ( GoldPerBrick() * KIND_GOLD_MUL[ kind ] );
    AddGold( payout );
    ++_bricksBroken;

    _heat = crMath::Clamp01( _heat + _tune.heatPerBrick );

    _textGoldAccum += payout;
    _textGoldPos    = pos;

    if( brick.kind == EBrickKind::RICH )
    {// a treat is worth its own number — folding it into the bank would hide the one payout the
        // player was hoping to see
        char amount[ 24 ];
        char line[ 24 ];
        FormatAmount( amount, sizeof( amount ), payout );
        SDL_snprintf( line, sizeof( line ), "+%s", amount );
        PushFloatText( line, pos, RICH_COLOR, 1.1f, 1.3f, 6.0f );
    }

    {
        CParticleEmitter shards;
        shards.color        = PAPER_FLECK;
        shards.colorEnd     = color4( 0.0f, 0.0f, 0.0f, 1.0f );
        shards.gravity      = float3( 0.0f, 0.0f, 0.0f );
        shards.drag         = 2.4f;
        shards.spread       = 5.5f;
        shards.lifetimeMin  = 0.14f;
        shards.lifetimeMax  = 0.36f;
        shards.sizeMin      = 0.07f;
        shards.sizeMax      = 0.2f;
        shards.sizeEndScale = 0.1f;
        shards.intensityMin = 0.9f;
        shards.intensityMax = 1.8f;
        shards.spinMin      = -8.0f;
        shards.spinMax      = 8.0f;
        shards.spriteIndex  = _fxSpark;
        app->particles->EmitBurst( app, shards, pos, blast ? 44 : 10 );
    }

    if( app->SimulationStepIndex() >= _nextBreakSfxStep )
    {
        _nextBreakSfxStep = ( app->SimulationStepIndex() + 5 );
        app->audio->PlaySfx( _sfxBreak, 0.25f, crMath::Clamp( ( pos.x / _wallRadius ), -1.0f, 1.0f ) );
    }

    b3DestroyBody( registry.get<CPhysicsBody>( e ).bodyId );
    registry.destroy( e );

    if( blast )
    {
        Detonate( app, pos, 4.6f, ( _shellHp[ ( _liveShell >= 0 ) ? _liveShell : 0 ] * 3 ), 0.35f, VOLATILE_COLOR, false );
        app->audio->PlaySfx( _sfxBlast, 0.4f, crMath::Clamp( ( pos.x / _wallRadius ), -1.0f, 1.0f ) );
    }
}
void Game::DamageCore( const crApp* app, int32_t damage )
{
    _coreHp   -= damage;
    _coreFlash = 1.0f;

    // the core pays per hit, which is why the climax is also the richest moment of the planet: the
    // arena is at its tightest here, so every ball in the run is landing on it
    const double payout = ( GoldPerBrick() * 0.30 * static_cast<double>( damage ) );
    AddGold( payout );

    _heat = crMath::Clamp01( _heat + ( _tune.heatPerBrick * 0.5f ) );

    _textGoldAccum += payout;
    _textGoldPos    = float3( 0.0f, ( CORE_RADIUS + 1.0f ), 0.0f );

    if( app->SimulationStepIndex() >= _nextBreakSfxStep )
    {
        _nextBreakSfxStep = ( app->SimulationStepIndex() + 5 );
        app->audio->PlaySfx( _sfxBreak, 0.3f, 0.0f );
    }

    if( _coreHp < 0 )
        _coreHp = 0;
}
void Game::Detonate( const crApp* app, float3 pos, float radius, int32_t damage, float kick, color4 color, bool inkScaled )
{
    entt::registry& registry = app->ecs->registry;

    const float sqRadius = ( radius * radius );

    // the live shell only: everything further in is shielded behind it, so a blast that reached those
    // bricks would chip something the player cannot see happening
    if( _liveShell >= 0 )
    {
        crArray<entt::entity>& live = _shells[ _liveShell ];

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

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

            // INK. a blot is worth exactly what the swarm has already put into the brick under it:
            // a fresh patch barely marks, a worked one goes through. this is the whole reason the
            // line weight is drawn — it is the aim, not decoration on top of one
            int32_t dealt = damage;
            if( inkScaled )
            {
                const CBrick& worked = registry.get<CBrick>( e );
                dealt = static_cast<int32_t>( crMath::Round( static_cast<float>( damage ) *
                                                             ( _tune.inkMin + ( _tune.inkGain * InkWeight( worked.hp, worked.hpMax ) ) ) ) );
                if( dealt < 1 )
                    dealt = 1;
            }

            if( DamageBrick( app, e, dealt ) )
                _dead.Add( e );
        }
    }
    else if( _phase == EPhase::CORE )
    {
        const float reach = ( radius + CORE_RADIUS );
        if( ( ( pos.x * pos.x ) + ( pos.y * pos.y ) ) <= ( reach * reach ) )
        {
            int32_t dealt = damage;
            if( inkScaled )
            {
                dealt = static_cast<int32_t>( crMath::Round( static_cast<float>( damage ) *
                                                             ( _tune.inkMin + ( _tune.inkGain * InkWeight( _coreHp, _coreHpMax ) ) ) ) );
                if( dealt < 1 )
                    dealt = 1;
            }

            DamageCore( app, dealt );
        }
    }

    if( kick > 0.0f )
    {
        const float speed = BallSpeed();

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

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

            const b3BodyId body = registry.get<CPhysicsBody>( e ).bodyId;
            const b3Vec3   v    = b3Body_GetLinearVelocity( body );

            // the speed servo would eat an impulse, so the blast steers instead: the ball keeps its
            // speed and takes an outward heading
            const float3 out = crMath::Normalize3( float3( dx, dy, 0.0f ) );
            const float3 dir = crMath::Normalize3( float3( crMath::Lerp( v.x, ( out.x * speed ), kick ),
                                                           crMath::Lerp( v.y, ( out.y * speed ), kick ),
                                                           0.0f ) );
            b3Body_SetLinearVelocity( body, { ( dir.x * speed ), ( dir.y * speed ), 0.0f } );
        }
    }

    PushPulseRing( pos, radius, 0.45f, color );
    app->graphics->PostProcess()->QueueRipple( pos, radius, 0.4f, 0.02f );
}

void Game::BuyUpgrade( const crApp* app, EUpgrade upgrade )
{
    const int32_t index = static_cast<int32_t>( upgrade );

    const bool capped = ( ( ( upgrade == EUpgrade::NIBS ) && ( BallTarget() >= BALL_CAP ) ) ||
                          ( ( upgrade == EUpgrade::SPEED ) && ( BallSpeed() >= BALL_SPEED_MAX ) ) );

    const double cost = UpgradeCost( upgrade, _progress.level[ index ] );
    if( capped || ( _progress.gold < cost ) )
    {
        app->audio->PlaySfx( _sfxDenied, 0.35f, 0.0f );
        return;
    }

    _progress.gold -= cost;
    ++_progress.level[ index ];

    app->audio->PlaySfx( _sfxBuy, 0.5f, 0.0f );

    {// every purchase gets a number of its own — an idle where the shop is the whole verb cannot
        // afford a purchase that only moves a counter
        char line[ 24 ];
        SDL_snprintf( line, sizeof( line ), "%s  LV%d", UPGRADE_NAMES[ index ], _progress.level[ index ] );
        PushFloatText( line, float3( 0.0f, -( _wallRadius * 0.55f ), 0.0f ), STRIKE_COLOR, 0.9f, 1.2f, 4.0f );
    }
}
void Game::FireStrike( const crApp* app, float3 pos )
{
    if( app->SimulationStepIndex() < _nextStrikeStep )
        return;

    _nextStrikeStep = ( app->SimulationStepIndex() + ToSteps( _tune.strikeCooldown ) );

    // clamped into the arena: a tap on the backdrop should still land on the planet, not vanish
    const float distSq = ( ( pos.x * pos.x ) + ( pos.y * pos.y ) );
    const float reach  = _wallRadius;
    if( distSq > ( reach * reach ) )
    {
        const float dist = crMath::Sqrt( distSq );
        pos.x = ( ( pos.x / dist ) * reach );
        pos.y = ( ( pos.y / dist ) * reach );
    }
    pos.z = 0.0f;

    const int32_t damage = ( BallDamage() * _tune.strikePower );
    Detonate( app, pos, _tune.strikeRadius, damage, 0.8f, STRIKE_COLOR, true );

    {
        CParticleEmitter lance;
        lance.color        = PAPER_FLECK;
        lance.colorEnd     = color4( 0.0f, 0.0f, 0.0f, 1.0f );
        lance.gravity      = float3( 0.0f, 0.0f, 0.0f );
        lance.drag         = 1.6f;
        lance.spread       = 16.0f;
        lance.lifetimeMin  = 0.2f;
        lance.lifetimeMax  = 0.6f;
        lance.sizeMin      = 0.1f;
        lance.sizeMax      = 0.4f;
        lance.sizeEndScale = 0.0f;
        lance.intensityMin = 1.2f;
        lance.intensityMax = 2.4f;
        lance.spinMin      = -6.0f;
        lance.spinMax      = 6.0f;
        lance.spriteIndex  = _fxSpark;
        app->particles->EmitBurst( app, lance, pos, 120 );
    }

    PushFloatText( "BLOT", float3( pos.x, ( pos.y + 2.5f ), 0.0f ), STRIKE_COLOR, 1.0f, 1.4f, 5.0f );

    _shakeMag   = 1.0f;
    _flashPulse = 1.0f;
    _soakPulse  = 0.55f;

    app->audio->PlaySfx( _sfxBlast, 0.8f, crMath::Clamp( ( pos.x / _wallRadius ), -1.0f, 1.0f ) );
}
void Game::ApplyProgress( const crApp* app )
{
    ( void )app;

    if( _progress.depth < 1 )
        _progress.depth = 1;

    // depth adds a SHELL before it adds hp: a planet that is visibly fatter reads as progress from
    // across the room, where a bigger number on the same planet does not
    _shellCount = ( 3 + ( ( _progress.depth - 1 ) / 2 ) );
    if( _shellCount > SHELL_MAX )
        _shellCount = SHELL_MAX;

    // repeated multiply, not pow(): these feed every payout and every hit inside the sim, and
    // IEEE-754 pins multiplication where it does not pin pow (see crMath)
    float base = _tune.shellHpBase;
    float core = _tune.coreHpBase;

    _brickGold = static_cast<double>( _tune.goldBase );

    for( int32_t i = 1; i < _progress.depth; ++i )
    {
        base       *= _tune.shellHpGrowth;
        core       *= _tune.coreHpGrowth;
        _brickGold *= static_cast<double>( _tune.goldPerDepth );
    }

    for( int32_t shell = 0; shell < SHELL_MAX; ++shell )
    {
        const float inner = static_cast<float>( ( _shellCount - 1 ) - shell );
        _shellHp[ shell ] = static_cast<int32_t>( crMath::Round( base * ( 1.0f + ( _tune.shellHpInner * inner ) ) ) );
        if( _shellHp[ shell ] < 1 )
            _shellHp[ shell ] = 1;
    }

    _coreHpMax = static_cast<int32_t>( crMath::Round( core ) );
    if( _coreHpMax < 1 )
        _coreHpMax = 1;
}
void Game::AddGold( double amount )
{
    _progress.gold   += amount;
    _goldEarnedTotal += amount;
}
void Game::PushPulseRing( float3 pos, float radius, float life, color4 color )
{
    int32_t slot  = -1;
    float   worst = -1.0f;

    for( int32_t i = 0; i < PULSE_RINGS; ++i )
    {
        if( _pulseRings[ i ].life <= 0.0f )
        {
            slot = i;
            break;
        }

        const float progress = ( _pulseRings[ i ].age / _pulseRings[ i ].life );
        if( progress > worst )
        {
            worst = progress;
            slot  = i;   // pool full — the oldest ring gives way
        }
    }

    PulseRing& ring = _pulseRings[ slot ];
    ring.pos    = pos;
    ring.color  = color;
    ring.age    = 0.0f;
    ring.life   = life;
    ring.radius = radius;
}
void Game::PushFloatText( const char* text, float3 pos, color4 color, float life, float scale, float rise )
{
    int32_t slot  = -1;
    float   worst = -1.0f;

    for( int32_t i = 0; i < FLOAT_TEXTS; ++i )
    {
        if( _floatTexts[ i ].life <= 0.0f )
        {
            slot = i;
            break;
        }

        const float progress = ( _floatTexts[ i ].age / _floatTexts[ i ].life );
        if( progress > worst )
        {
            worst = progress;
            slot  = i;
        }
    }

    FloatText& entry = _floatTexts[ slot ];
    SDL_snprintf( entry.text, sizeof( entry.text ), "%s", text );
    entry.color = color;
    entry.pos   = pos;
    entry.age   = 0.0f;
    entry.life  = life;
    entry.scale = scale;
    entry.rise  = rise;
}

/*static*/ void Game::CmdDepth( void* context, int32_t argc, const char** argv )
{
    Game* game = static_cast<Game*>( context );

    if( argc < 2 )
        return;

    game->_progress.depth = SDL_atoi( argv[ 1 ] );
    if( game->_progress.depth > game->_progress.bestDepth )
        game->_progress.bestDepth = game->_progress.depth;

    game->_queuedRebuild = true;
}
/*static*/ void Game::CmdGold( void* context, int32_t argc, const char** argv )
{
    Game* game = static_cast<Game*>( context );

    if( argc < 2 )
        return;

    game->_progress.gold += SDL_atof( argv[ 1 ] );
}

void Game::UpdatePresentation( crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const Palette& palette = DepthPalette( _progress.depth );

    _heatSmooth += ( ( _heat - _heatSmooth ) * crMath::Clamp01( _renderDt * 8.0f ) );

    if( _liveShell >= 0 )
    {
        crArray<entt::entity>& live = _shells[ _liveShell ];

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

            CBrick&     brick     = registry.get<CBrick>( e );
            CPrimitive& primitive = registry.get<CPrimitive>( e );

            brick.flash = crMath::Clamp( brick.flash - ( _renderDt * 6.0f ), 0.0f, 1.0f );

            // wear is NOT read off brightness here. the wash stays exactly what the palette says and
            // the pen carries the whole state, so the crust is a line-weight map of where the swarm
            // has been — which is the thing the blot is aimed by
            primitive.color   = brick.baseColor;
            primitive.custom0 = InkWeight( brick.hp, brick.hpMax );
            primitive.custom1 = brick.flash;
        }
    }

    {// the core reads its own health: it spins faster and is inked in heavier as it goes
        _coreFlash = crMath::Clamp( _coreFlash - ( _renderDt * 6.0f ), 0.0f, 1.0f );

        const float wear = ( _coreHpMax > 0 ) ? crMath::Clamp01( static_cast<float>( _coreHp ) / static_cast<float>( _coreHpMax ) ) : 0.0f;
        const float rage = ( 1.0f - wear );

        if( registry.valid( _coreShell ) )
        {
            CTransform& t = registry.get<CTransform>( _coreShell );
            t.current.q = crMath::IntegrateRotation( t.current.q, float3( 0.21f, ( 0.35f + ( 1.5f * rage ) ), 0.13f ), _renderDt );
            t.previous  = t.current;

            const float breathe = ( 1.0f + ( 0.03f * crMath::Sin( _fxTime * ( 2.0f + ( 6.0f * rage ) ) ) ) );

            // the core gets INKED IN as it goes: the wash walks from the mid tone to the blackest one
            // the palette has, and the pen thickens over it at the same rate
            CPrimitive& primitive = registry.get<CPrimitive>( _coreShell );
            primitive.color   = crMath::LerpColor( palette.mid, palette.core, rage );
            primitive.custom0 = InkWeight( _coreHp, _coreHpMax );
            primitive.custom1 = _coreFlash;
            primitive.scale   = float3( ( CORE_RADIUS * breathe ), ( CORE_RADIUS * breathe ), ( CORE_RADIUS * breathe ) );
        }

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

            // tipped about two different in-plane axes, so from straight above they cross rather than
            // sit flat — a ring spun about Z would be rotationally symmetric and look frozen
            const float3 spin = ( i == 0 ) ? float3( ( 0.85f + rage ), 0.0f, 0.0f ) : float3( 0.0f, -( 0.62f + rage ), 0.0f );

            CTransform& t = registry.get<CTransform>( _halos[ i ] );
            t.current.q = crMath::IntegrateRotation( t.current.q, spin, _renderDt );
            t.previous  = t.current;

            registry.get<CPrimitive>( _halos[ i ] ).color =
                crMath::LerpColor( PAPER, palette.core, crMath::Clamp01( 0.40f + ( 0.40f * rage ) + ( 0.35f * _coreFlash ) ) );
        }
    }

    if( registry.valid( _atmosphere ) )
    {// chases the planet so the drawn outline still fits when the crust is down to one shell
        const float wanted = ( ( _phase == EPhase::CRACK ) ? ( _wallRadius * 0.98f ) : ( PlanetRadius() + 1.1f ) );

        CPrimitive& primitive = registry.get<CPrimitive>( _atmosphere );
        const float radius    = crMath::Lerp( primitive.scale.x, wanted, crMath::Clamp01( _renderDt * 3.0f ) );

        // the outline is the only thing this instance contributes, so flow is spent on how heavily
        // it is drawn rather than on how brightly it burns
        primitive.scale   = float3( radius, radius, radius );
        primitive.fresnel = ( 0.30f + ( 0.25f * _heatSmooth ) );
    }

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

        const float radius = ( _wallRadius * ( 1.0f + ( 0.016f * static_cast<float>( i ) ) ) );
        const float phase  = ( ( _fxTime * 1.4f ) + ( static_cast<float>( i ) * 1.1f ) );

        // MULTIPLY inverts the intuition the rest of this family is written in: a heavier line is a
        // DARKER colour, not a brighter one. the outer bands are drawn lighter so the three read as
        // one hand-drawn band rather than as three separate circles
        const float weight = crMath::Clamp01( ( 0.55f + ( 0.35f * _heatSmooth ) + ( 0.08f * crMath::Sin( phase ) ) ) /
                                              ( 1.0f + static_cast<float>( i ) ) );

        CPrimitive& primitive = registry.get<CPrimitive>( _containRings[ i ] );
        primitive.scale = float3( radius, radius, radius );
        primitive.color = crMath::LerpColor( PAPER, crMath::LerpColor( LINE_COLOR, palette.accent, 0.40f ), weight );
    }

    {// the swarm answers to flow too — at full flow every nib is drawn heavier, so the field reads as
        // one mass of pigment instead of four hundred separate dots
        const float weight = ( 0.30f + ( 0.45f * _heatSmooth ) );

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

            registry.get<CPrimitive>( e ).custom0 = weight;
        }
    }

    for( int32_t i = 0; i < PULSE_RINGS; ++i )
    {
        PulseRing& ring = _pulseRings[ i ];
        if( registry.valid( ring.entity ) == false )
            continue;

        CPrimitive& primitive = registry.get<CPrimitive>( ring.entity );

        if( ring.life <= 0.0f )
        {
            primitive.scale = float3( 0.0f, 0.0f, 0.0f );
            continue;
        }

        ring.age += _renderDt;
        const float progress = crMath::Clamp01( ring.age / ring.life );
        if( progress >= 1.0f )
        {
            ring.life       = 0.0f;
            primitive.scale = float3( 0.0f, 0.0f, 0.0f );
            continue;
        }

        CTransform& t = registry.get<CTransform>( ring.entity );
        t.current.p = { ring.pos.x, ring.pos.y, ring.pos.z };
        t.previous  = t.current;

        // under MULTIPLY a ring fades by walking towards WHITE, where it stops darkening anything
        const float radius = ( ring.radius * progress );
        primitive.scale = float3( radius, radius, radius );
        primitive.color = crMath::LerpColor( ring.color, color4( 1.0f, 1.0f, 1.0f, 1.0f ), progress );
    }

    {// the whole page answers to flow within about a second — this is the fastest feedback the game
        // has, and the only one that reads without looking at any particular thing
        crPostProcess* pp = app->graphics->PostProcess();

        _flashPulse = crMath::Clamp( _flashPulse - ( _renderDt * 2.2f ), 0.0f, 1.0f );
        _soakPulse  = crMath::Clamp( _soakPulse - ( _renderDt * 1.6f ), 0.0f, 1.0f );

        // a whisper whatever the flow does. the only values on this page that can clear the threshold
        // are the bare-paper flecks a break throws up, and a wet halo on those is the entire budget
        pp->bloom.intensity = ( 0.08f + ( 0.10f * _heatSmooth ) );

        // the sheet drinks a big beat: the washes go grey for a moment and come back
        pp->saturation = crMath::Clamp( ( 0.90f + ( 0.10f * _heatSmooth ) - ( 0.45f * _soakPulse ) ), 0.0f, 1.2f );

        // the page edge, not a vignette closing in — it only ever moves by a few percent
        const float coreEdge = ( _phase == EPhase::CORE ) ? ( 0.05f + ( 0.03f * crMath::Sin( _fxTime * 4.0f ) ) ) : 0.0f;
        pp->vignette.intensity = ( 0.16f - ( 0.06f * _heatSmooth ) + coreEdge );

        // a radial blur over wet ink is a hand dragged across the sheet, which is the one smear a
        // printed illustration is allowed
        pp->radialBlur.enabled = ( _flashPulse > 0.01f );
        if( _flashPulse > 0.01f )
        {
            pp->radialBlur.center   = float2( 0.5f, 0.5f );
            pp->radialBlur.strength = ( 0.10f * _flashPulse );
            pp->radialBlur.samples  = 8;
        }
    }
}
void Game::UpdateCamera( crApp* app )
{
    _shakePhase += ( _renderDt * 38.0f );
    _shakeMag    = crMath::Clamp( _shakeMag - ( _renderDt * 2.0f ), 0.0f, 1.0f );

    // the camera rides the containment, so the field fills the frame at every stage — the planet
    // shrinking must not read as the game receding
    const float wanted = ( _wallRadius * _tune.camFill );
    _camDistance += ( ( wanted - _camDistance ) * crMath::Clamp01( _renderDt * 3.0f ) );

    const float amount = ( _shakeMag * _tune.camShake * 0.5f );
    const float shakeX = ( crMath::Cos( _shakePhase * 1.13f ) * amount );
    const float shakeY = ( crMath::Sin( _shakePhase * 1.71f ) * amount );

    app->camera->SetTarget( float3( shakeX, ( ( _camDistance * _tune.camTargetFrac ) + shakeY ), 0.0f ) );
    app->camera->SetOrbit( 0.0f, _tune.camPitch );
    app->camera->SetDistance( _camDistance );
}

void Game::RenderHud( crApp* app )
{
    crUi* ui = app->ui;

    const float2 canvas = ui->CanvasSize();

    char line[ 64 ];
    char amount[ 32 ];

    FormatAmount( amount, sizeof( amount ), _progress.gold );
    SDL_snprintf( line, sizeof( line ), "GOLD %s", amount );
    ui->Label( EUiAnchor::TOP_LEFT, float2( 16.0f, -16.0f ), line, _font16, UI_WHITE );

    FormatAmount( amount, sizeof( amount ), static_cast<double>( _incomeDisplay ) );
    SDL_snprintf( line, sizeof( line ), "%s / s", amount );
    ui->Label( EUiAnchor::TOP_LEFT, float2( 16.0f, -44.0f ), line, _font12, color4( 0.24f, 0.42f, 0.30f, 1.0f ) );

    SDL_snprintf( line, sizeof( line ), "DEPTH %d", _progress.depth );
    ui->Label( EUiAnchor::TOP, float2( 0.0f, -12.0f ), line, _font24, UI_WHITE );

    if( _liveShell >= 0 )
        SDL_snprintf( line, sizeof( line ), "SHELL %d / %d", ( _liveShell + 1 ), _shellCount );
    else if( _phase == EPhase::CORE )
        SDL_snprintf( line, sizeof( line ), "CORE" );
    else
        SDL_snprintf( line, sizeof( line ), "BEST %d", _progress.bestDepth );
    ui->Label( EUiAnchor::TOP, float2( 0.0f, -42.0f ), line, _font12, UI_DIM );

    SDL_snprintf( line, sizeof( line ), "NIBS %d", _balls.Size() );
    ui->Label( EUiAnchor::TOP_RIGHT, float2( -16.0f, -16.0f ), line, _font16, UI_WHITE );
    SDL_snprintf( line, sizeof( line ), "DMG %d", BallDamage() );
    ui->Label( EUiAnchor::TOP_RIGHT, float2( -16.0f, -44.0f ), line, _font12, UI_DIM );

    {// FLOW — the meter the player actually watches, because it is the one that moves every second.
        // the empty part of it is bare paper, so the bar is the mark and not the frame
        const float barWidth = ( canvas.x - 64.0f );
        const float fill     = ( barWidth * crMath::Clamp01( _heatSmooth ) );

        ui->Image( EUiAnchor::TOP, float2( 0.0f, -78.0f ), float2( barWidth, 9.0f ), _uiBar, color4( 0.84f, 0.83f, 0.80f, 0.85f ) );

        if( fill > 1.0f )
        {
            const color4 wet = IsOverdrive() ? UI_GOLD
                                             : crMath::LerpColor( color4( 0.44f, 0.48f, 0.54f, 1.0f ), color4( 0.20f, 0.21f, 0.26f, 1.0f ), _heatSmooth );
            ui->Image( EUiAnchor::TOP, float2( ( ( fill - barWidth ) * 0.5f ), -78.0f ), float2( fill, 9.0f ), _uiBar, wet );
        }

        if( IsOverdrive() )
            ui->Label( EUiAnchor::TOP, float2( 0.0f, -92.0f ), "FULL FLOW", _font12, UI_GOLD );
    }

    if( _phase == EPhase::CORE )
    {// the core bar only exists while the core does — a bar that is empty half the time reads as noise
        const float barWidth = ( canvas.x - 120.0f );
        const float wear     = ( _coreHpMax > 0 ) ? crMath::Clamp01( static_cast<float>( _coreHp ) / static_cast<float>( _coreHpMax ) ) : 0.0f;
        const float fill     = ( barWidth * wear );

        ui->Image( EUiAnchor::TOP, float2( 0.0f, -118.0f ), float2( barWidth, 14.0f ), _uiBar, color4( 0.86f, 0.82f, 0.80f, 0.85f ) );

        if( fill > 1.0f )
        {
            const color4 tint = crMath::LerpColor( color4( 0.72f, 0.24f, 0.18f, 1.0f ), DepthPalette( _progress.depth ).core, wear );
            ui->Image( EUiAnchor::TOP, float2( ( ( fill - barWidth ) * 0.5f ), -118.0f ), float2( fill, 14.0f ), _uiBar, tint );
        }

        SDL_snprintf( line, sizeof( line ), "CORE  %d", _coreHp );
        ui->Label( EUiAnchor::TOP, float2( 0.0f, -136.0f ), line, _font12, UI_WHITE );
    }
}
void Game::RenderShop( crApp* app )
{
    crUi* ui = app->ui;

    // portrait (crScreenRef, 9:16): the four slots stack two by two rather than in a row, and the
    // offsets are the BOTTOM edge of each button — the anchor pivot puts them there
    constexpr float COL_X      = 128.0f;
    constexpr float ROW_Y[ 2 ] = { 262.0f, 146.0f };
    constexpr float WIDTH      = 244.0f;
    constexpr float HEIGHT     = 66.0f;

    char line[ 64 ];
    char amount[ 32 ];

    for( int32_t i = 0; i < UPGRADE_COUNT; ++i )
    {
        const EUpgrade upgrade = static_cast<EUpgrade>( i );
        const float    x       = ( ( ( i % 2 ) == 0 ) ? -COL_X : COL_X );
        const float    baseY   = ROW_Y[ i / 2 ];

        const bool capped = ( ( ( upgrade == EUpgrade::NIBS ) && ( BallTarget() >= BALL_CAP ) ) ||
                              ( ( upgrade == EUpgrade::SPEED ) && ( BallSpeed() >= BALL_SPEED_MAX ) ) );

        const double cost       = UpgradeCost( upgrade, _progress.level[ i ] );
        const bool   affordable = ( ( capped == false ) && ( _progress.gold >= cost ) );

        SDL_snprintf( line, sizeof( line ), "%d  %s", ( i + 1 ), UPGRADE_NAMES[ i ] );
        if( ui->Button( static_cast<uint32_t>( 10 + i ), EUiAnchor::BOTTOM, float2( x, baseY ), float2( WIDTH, HEIGHT ), _uiButton, 16.0f,
                        line, _font16, affordable ? UI_WHITE : UI_DIM ) )
        {
            _queuedBuy = i;
        }

        SDL_snprintf( line, sizeof( line ), "LV %d   %s", _progress.level[ i ], UPGRADE_EFFECTS[ i ] );
        ui->Label( EUiAnchor::BOTTOM, float2( x, ( baseY - 24.0f ) ), line, _font12, UI_DIM );

        if( capped )
        {
            ui->Label( EUiAnchor::BOTTOM, float2( x, ( baseY - 46.0f ) ), "MAX", _font12, UI_GOLD );
        }
        else
        {
            FormatAmount( amount, sizeof( amount ), cost );
            ui->Label( EUiAnchor::BOTTOM, float2( x, ( baseY - 46.0f ) ), amount, _font12, affordable ? UI_GOLD : UI_DIM );
        }
    }

    {// the blot has no on-screen face on purpose: it is aimed by tapping the field, and a face would
        // claim the very touches that are supposed to place it. this is only its readiness
        const uint64_t step  = app->SimulationStepIndex();
        const bool     ready = ( step >= _nextStrikeStep );

        ui->Image( EUiAnchor::BOTTOM, float2( 0.0f, 344.0f ), float2( 34.0f, 34.0f ), _uiRound,
                   ready ? STRIKE_COLOR : color4( 0.78f, 0.77f, 0.75f, 0.9f ) );

        if( ready )
        {
            ui->Label( EUiAnchor::BOTTOM, float2( 0.0f, 326.0f ), "TAP THE HEAVY INK TO BLOT", _font12, STRIKE_COLOR );
        }
        else
        {
            char cooldown[ 16 ];
            SDL_snprintf( cooldown, sizeof( cooldown ), "%.0fs", ( static_cast<float>( _nextStrikeStep - step ) * crApp::FIXED_TIMESTEP ) );
            ui->Label( EUiAnchor::BOTTOM, float2( 0.0f, 326.0f ), cooldown, _font12, UI_DIM );
        }
    }
}

float Game::BallSpeed() const
{
    const float speed = ( _tune.ballSpeed * ( 1.0f + ( 0.05f * static_cast<float>( _progress.level[ static_cast<int32_t>( EUpgrade::SPEED ) ] ) ) ) );
    return ( speed > BALL_SPEED_MAX ) ? BALL_SPEED_MAX : speed;
}
int32_t Game::BallDamage() const
{
    const int32_t base = ( 1 + _progress.level[ static_cast<int32_t>( EUpgrade::PRESS ) ] );

    // full flow is a step, not a curve: a smooth bonus would be invisible, and the whole point of the
    // band is that the player can tell they are in it
    if( IsOverdrive() == false )
        return base;

    return ( base + ( base / 2 ) + 1 );
}
int32_t Game::BallTarget() const
{
    const int32_t target = ( BALL_BASE + _progress.level[ static_cast<int32_t>( EUpgrade::NIBS ) ] );
    return ( target > BALL_CAP ) ? BALL_CAP : target;
}
double Game::GoldPerBrick() const
{
    const double greed = ( 1.0 + ( 0.25 * static_cast<double>( _progress.level[ static_cast<int32_t>( EUpgrade::GREED ) ] ) ) );
    const double heat  = ( 1.0 + ( static_cast<double>( _heat ) * static_cast<double>( _tune.heatGold ) ) );

    return ( _brickGold * greed * heat );
}
bool Game::IsOverdrive() const
{
    return ( _heat >= OVERDRIVE_HEAT );
}

float Game::InkWeight( int32_t hp, int32_t hpMax ) const
{
    if( hpMax <= 0 )
        return 1.0f;

    // one number for two jobs on purpose. it is what the material draws the brick's line weight from
    // AND what the blot multiplies its damage by, so what the player reads off the crust and what
    // the sim charges for are the same quantity — two would drift the moment either was tuned
    const float spent = crMath::Clamp01( 1.0f - ( static_cast<float>( hp ) / static_cast<float>( hpMax ) ) );
    return ( _tune.inkFloor + ( ( 1.0f - _tune.inkFloor ) * spent ) );
}

float Game::ShellRadius( int32_t shell ) const
{
    return ( CORE_RADIUS + SHELL_GAP + ( BRICK_PITCH * static_cast<float>( shell ) ) );
}
float Game::PlanetRadius() const
{
    if( _liveShell < 0 )
        return CORE_RADIUS;

    return ( ShellRadius( _liveShell ) + BRICK_HALF_RAD );
}
float Game::ContainmentTarget() const
{
    // the crack beat is the one time the arena opens instead of closing — a breath before the next
    // planet lands
    if( _phase == EPhase::CRACK )
        return ( CORE_RADIUS + ( _tune.annulus * 2.6f ) );

    return ( PlanetRadius() + _tune.annulus );
}

/*static*/ double Game::UpgradeCost( EUpgrade upgrade, int32_t level )
{
    const int32_t index = static_cast<int32_t>( upgrade );

    // repeated multiply rather than pow — the purchase test runs inside a sim step, and pow is the
    // one operation IEEE-754 leaves free to differ between platforms (see crMath)
    double cost = UPGRADE_COST_BASE[ index ];
    for( int32_t i = 0; i < level; ++i )
        cost *= UPGRADE_COST_GROWTH[ index ];

    return cost;
}
