#include "Game_MassEntityDefense.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 "crInputSystem.h"
#include "crMath.h"
#include "crParticleSystem.h"
#include "crPhysics.h"
#include "crPostProcess.h"
#include "crRenderSystem.h"
#include "crTasks.h"
#include "crUi.h"

// ── map ──────────────────────────────────────────────────────────────────────
static constexpr int32_t MAP_SEED        = 71349682;   // fixed: the map is sim state, both platforms must carve the same canyon
static constexpr uint64_t SESSION_SEED   = 0xDEFE45E5EEDULL;
static constexpr float   CELL            = 1.0f;
static constexpr int32_t MIN_ISLAND      = 4;      // speck cleanup only — braid spines and pillars are intentional islands
static constexpr int32_t BORDER          = 2;      // cells of forced high ground on the map rim
static constexpr int32_t SMOOTH_PASSES   = 0;      // off — disk stamps are already round, and the filter would eat small pillars
static constexpr int32_t CONNECTOR_COUNT = 5;      // tunnels between neighboring lanes
static constexpr int32_t CHAMBER_COUNT   = 6;      // bulge arenas stamped onto lane points

// chamfer slab: covers the half-cell behind the 45 deg cut. it necessarily overlaps the two solid
// neighbors — a rectangle cannot cover a triangle without spilling — which is why these draw
// through a depth-biased material
// generous margins on purpose: at 0.72 the along-cut half-extent cleared the 0.707 it must cover by
// only 0.013, and rotation rounding opened hairline gaps at the seams. overlap costs nothing here
static constexpr float CHAMFER_LONG   = 0.80f;   // half-extent along the cut line (cell diagonal / 2 = 0.707)
static constexpr float CHAMFER_SHORT  = 0.48f;   // covers the solid half (deepest point 0.707) with room to spare
static constexpr float CHAMFER_OFFSET = 0.48f;   // == SHORT, so the slab face lands exactly ON the cut line

// the slab sits a hair BELOW the blocks it cuts. that one number replaces the whole depth-bias
// scheme this started with: a clip-space bias pulled the slab's buried back faces forward too, and
// those faces are unlit, which is what painted dark wedges across the plateau. sinking the slab
// instead breaks the coplanar tie (no z-fight) AND lets the buried faces lose the depth test the
// way they should. 0.015 is well under a tenth of a pixel at any zoom this game allows
static constexpr float CHAMFER_SINK = 0.015f;

// ── pinned scenario ──────────────────────────────────────────────────────────
// paste what `dumpmap` puts on the clipboard over this array to pin the terrain; while it holds a
// single null the game keeps generating from MAP_SEED. row 0 is the NORTH edge, so the block reads
// the same way up as the screen does. `seed` switches a session back to generating either way
static constexpr const char* FIXED_MAP[] =
{
    "################################################################################################################################################################################################",
    "################################################################################################################################################################################################",
    "################################################################################################################################################################################################",
    "#####################################################.....###############################.....####################################################.....#########################################",
    "#####################################################.....###############################.....####################################################.....#########################################",
    "#####################################################..P..##############################...P...##################################################...P..#########################################",
    "#####################################################.....###############################.....#################################################........#########################################",
    "#####################################################......##......######################......##############################################..........#########################################",
    "#######################################################..............####################......############################################.........############################################",
    "########################################################..............###################......##########################################.........##############################################",
    "#########################################################.............###################......########################################..........###############################################",
    "#########################################################..............##################.......###################################.............################################################",
    "#########################################################..##..........##################.......##################################............##################################################",
    "########################################################...####.........#################.......################################.............###################################################",
    "########################################################......#####.....##################......##############################............######################################################",
    "########################################################......####......##################.......###########################..........##########################################################",
    "########################################################......###......###################.......##########################.........############################################################",
    "#########################################################.....###......####################......#######################.........###############################################################",
    "#########################################################......##......#####################......###############..............#################################################################",
    "##########################################################...............###################......##############..............##################################################################",
    "###########################################################................##################......############....#.........###################################################################",
    "############################################################........#........################.......##########....##........####################################################################",
    "##############################################################.....####.......###############.......##########.....#........####################################################################",
    "#############################################################.....#######......###############......#########...............####################################################################",
    "#########################################################.........#########.....##############.......########...............####################################################################",
    "########################################################.........###########.....##############......########...............####################################################################",
    "########################################################.........############.....#############.......########..............####################################################################",
    "#######################################################.........##############....#############.......########.............#####################################################################",
    "#######################################################.........###############.....############......########.............#####################################################################",
    "######################################################....#.......#############.....############.......#######............######################################################################",
    "#####################################################....###.......#############.....###########.......#######...........#######################################################################",
    "####################################################.....#####.....##############.....###########......######...........########################################################################",
    "###################################################.....#####.....################....###########......######.....##############################################################################",
    "##################################################.....######....##################....##########......#####.....###############################################################################",
    "################################################.......#####....###################....##########......####......###############################################################################",
    "###############################################.......#####....#####################....#########.......##......################################################################################",
    "##############################################.......######....#####################....#########.............##################################################################################",
    "##############################################......######....#######################....#########............##################################################################################",
    "#############################################......######.....#######################....#########...........###################################################################################",
    "#############################################........###.....#########################....########.........#####################################################################################",
    "##############################################........#......########################.....########........######################################################################################",
    "################################################.............#######################.........#####.......#######################################################################################",
    "###############################################.............########################.........######......###############################################################################.....###",
    "#############################################..............#########################...........###......##############################################################################.......###",
    "############################################...............#########################............##......###########################################################################.......P..###",
    "###########################################.........#....###########################.............#......##################################################################.....##............###",
    "##########################################.........###...##########################.....................#################################################################....................###",
    "########################################..........#################################....................#################################################################..................######",
    "#######################################.............#################..................................###############################################################.............#############",
    "####.....##############################..............###########......................................###############################################################.....###....###############",
    "####.......###########################...............###.....##.......................................##############################################################......####...###############",
    "####..P.............################.................................................................##############################################################.....######....##############",
    "####...................############.....................................######.......###...........###########......##################################..........###.....#####.....##############",
    "####.....................#########...........................##.............#####....#...............####...............#############################....................###.....###############",
    "#########..................######...........................#####............####..........................................##########################..........................#################",
    "#############................##...........................#########............##............................................################.................................##################",
    "#####################...................................###############...............................###......................############..............#######..............##################",
    "######################...................................##############......................G........###.....#######...........########.................#######............####################",
    "########################............###....................##############.......................#.....###....##########.................................########......##########################",
    "#######################################......................#############..........##..........##............#############.....................#####................###########################",
    "########################################..............#........#############........####.......###.............###############.............############..............###########################",
    "##########################################...############.......##############......####.......###.............#################......###################...........############################",
    "##########################################################..........#########......####.........................################################################################################",
    "############################################################...........######....#####..............#............###############################################################################",
    "##############################################################....................###...............##............##############################################################################",
    "###############################################################.....................................###...........##############################################################################",
    "#################################################################.......................###.........####...........#############################################################################",
    "###################################################################....................#######......#####..........#############################################################################",
    "#####################################################################.................#########.....######..........############################################################################",
    "#######################################################################.........##...##########........#####.........###########################################################################",
    "######################################################################.........###############..........#####........###########################################################################",
    "######################################################################..........#############..........#######........##########################################################################",
    "#####################################################################....##.....#############...........######........##########################################################################",
    "#####################################################################....###....#############......#....#######........#########################################################################",
    "####################################################################....####....##############...####...########.......#########################################################################",
    "####################################################################....###.....##############...####...#########.......########################################################################",
    "####################################################################...####....###############...####...#########........#######################################################################",
    "###################################################################....###....################...###....##########........######################################################################",
    "###################################################################....###...################....###....###########.............################################################################",
    "##################################################################....####...################....###...#############..............##############################################################",
    "##################################################################....###...################....####....#############..............#############################################################",
    "#################################################################......#....################....###....###############..............############################################################",
    "#################################################################...........###############....####....###############................##########################################################",
    "#################################################################...........################...####...#################......###.......#########################################################",
    "###################################################################........################....###....#################......####......#########################################################",
    "###################################################################......##################....###....#################.......####......########################################################",
    "##################################################################.....####################....###....#################.......#####.....########################################################",
    "#################################################################.....#####################......#....##################.......#####.....#######################################################",
    "################################################################......#####################..........#####################.......###......######################################################",
    "################################################################.....######################..........######################...............######################################################",
    "###############################################################......#######################.........#######################..............######################################################",
    "###############################################################.....##########################....##########################..............######################################################",
    "###############################################################....############################....#########################.............#######################################################",
    "##############################################################.....############################....##########################............#######################################################",
    "##############################################################.....############################....##########################......................#############################################",
    "##############################################################.....############################....###########################......................############################################",
    "#############################################################.....#############################.....##################################..............############################################",
    "############################################################......#############################.....###################################..............###########################################",
    "###########################################################......###############################....#####################################............###########################################",
    "#########################################################.......################################.....####################################............###########################################",
    "#########################################################.......################################.....####################################............###########################################",
    "#########################################################..P...#################################..P..####################################.........P..###########################################",
    "#########################################################.....##################################.....####################################............###########################################",
    "#########################################################.....##################################.....#####################################...........###########################################",
    "###########################################################################################################################################........#############################################",
    "#############################################################################################################################################....###############################################",
    "################################################################################################################################################################################################",
    "################################################################################################################################################################################################",
};

// likewise from `dumptowers` — a non-empty list turns on the PRESET entry point on the title screen
static constexpr PresetTower PRESET_TOWERS[] =
{
    { ETowerKind::TURRET, 86, 47 },
    { ETowerKind::TURRET, 96, 49 },
    { ETowerKind::TURRET, 97, 62 },
    { ETowerKind::LASER, 87, 55 },
    { ETowerKind::MORTAR, 96, 47 },
    { ETowerKind::TESLA, 85, 54 },
    { ETowerKind::FROST, 99, 55 },
    { ETowerKind::TURRET, 100, 55 },
    { ETowerKind::LASER, 94, 65 },
    { ETowerKind::LASER, 100, 44 },
    { ETowerKind::FROST, 100, 41 },
    { ETowerKind::FROST, 85, 45 },
    { ETowerKind::FROST, 81, 60 },
    { ETowerKind::MORTAR, 79, 53 },
    { ETowerKind::TURRET, 76, 46 },
    { ETowerKind::TURRET, 74, 46 },
    { ETowerKind::TURRET, 83, 63 },
    { ETowerKind::TURRET, 79, 62 },
    { ETowerKind::TURRET, 96, 64 },
    { ETowerKind::LASER, 84, 46 },
    { ETowerKind::TESLA, 83, 44 },
    { ETowerKind::TESLA, 78, 54 },
    { ETowerKind::TESLA, 106, 55 },
    { ETowerKind::MORTAR, 101, 42 },
    { ETowerKind::TURRET, 91, 40 },
    { ETowerKind::TURRET, 91, 39 },
    { ETowerKind::FROST, 90, 40 },
    { ETowerKind::TESLA, 89, 40 },
    { ETowerKind::MORTAR, 88, 39 },
};

static constexpr uint64_t PRESET_STEPS      = 3600;   // 60 s of battle wound forward before you look at it
static constexpr float    PREPARE_BUDGET_MS = 24.0f;  // per render frame, so the wind-up still draws its progress

// ── flow field (eikonal) ─────────────────────────────────────────────────────
static constexpr float    FLOW_INF        = 1.0e18f;
static constexpr int32_t  EIKONAL_ROUNDS  = 3;      // each round = 4 sweeps; winding lanes need the repeats
static constexpr float    WALL_BIAS       = 0.6f;   // walls read as uphill, so the gradient itself pushes off them
static constexpr uint64_t REFLOW_STEPS    = 90;     // rebuild cadence (1.5 s)
static constexpr float    DENSITY_BLEND   = 0.35f;  // decay toward the new count — instantaneous density makes the crowd slosh
static constexpr float    DENSITY_COST    = 0.50f;  // slowness added per unit of occupancy
static constexpr float    DENSITY_COST_MAX = 3.0f;  // capped: an uncapped penalty abandons whole lanes and oscillates

static constexpr float FLOOR_TOP  = -0.35f;
static constexpr float CLIFF_TOP  = 1.5f;
static constexpr float CLIFF_HALF = ( ( CLIFF_TOP - ( FLOOR_TOP - 0.25f ) ) * 0.5f );   // slabs sink below the floor — no seams
static constexpr float CLIFF_MID  = ( CLIFF_TOP - CLIFF_HALF );

// ── camera (pure top-down — placement is schematic; heights read from palette + shadows) ──
static constexpr float CAM_DIST_INIT = 100.0f;
static constexpr float CAM_DIST_MIN  = 8.0f;   // close enough to appreciate individual walkers in the crush
static constexpr float CAM_DIST_MAX  = 150.0f;
static constexpr float CAM_ZOOM_STEP = 1.15f;   // wheel notch factor
static constexpr float CAM_PAN_RATE  = 0.9f;    // keyboard pan: world units/s per unit of distance

// ── economy / gate ───────────────────────────────────────────────────────────
static constexpr int32_t  START_GOLD     = 130;
static constexpr uint64_t TRICKLE_STEPS  = 30;    // +1 gold — 2/s
static constexpr int32_t  GATE_HP        = 100;   // generous — the showcase should outlive a few floods
static constexpr float    GATE_REACH     = 2.0f;  // straight-line range at which an enemy counts as through

// ── waves ────────────────────────────────────────────────────────────────────
struct WaveDef
{
    int32_t grunts;
    int32_t brutes;
    float   duration;   // seconds the wave's spawns are spread over
};
// continuous siege: stages, not waves — each holds a spawn RATE for longer than the portal->gate
// transit (~35-40 s), so the lanes stay FULL pipes instead of carrying pulses. counts = rate x 30 s
static constexpr WaveDef WAVES[ 8 ] =
{
    {  1300,   0, 25.0f },
    {  1700,  60, 25.0f },
    {  2200, 100, 25.0f },
    {  3000, 150, 25.0f },
    {  4200, 220, 25.0f },
    {  5800, 300, 25.0f },
    {  8000, 400, 25.0f },
    { 10000, 520, 25.0f },
};
static constexpr uint64_t FIRST_WAVE_STEPS = 360;   // 6 s to place the opening towers
static constexpr uint64_t WAVE_GAP_STEPS   = 60;    // 1 s stage change — the siege never lets up
static constexpr int32_t  ENEMY_CAP        = 30000;  // spawner backs off at the cap, budget is deferred not dropped

// continuous emission: the wave budget streams out at a steady rate, one walker at a time across
// the portals — an unbroken column, not gushes. queue drain still caps bodies per portal per step
// and skips crowded pockets, because BIRTH OVERLAP is what blows the solver's step cost up
static constexpr int32_t SPAWN_PER_STEP_GRUNT = 4;
static constexpr int32_t SPAWN_PER_STEP_BRUTE = 1;
static constexpr int32_t PORTAL_CROWD_CAP     = 55;   // scaled to the small bodies — same pocket, more of them fit
static constexpr float   PORTAL_CROWD_RADIUS  = 2.6f;

// ── enemies ──────────────────────────────────────────────────────────────────
static constexpr float   STEER_BLEND   = 0.14f;   // velocity lerp toward the flow — collision shoves survive it

// traffic compression: walkers leave the portals fast and decelerate as the gate nears, so the
// rear catches the front and the column packs itself — density = inflow / speed
static constexpr float SLOW_RAMP_CELLS = 55.0f;   // beyond this flow distance: full base speed
static constexpr float SLOW_NEAR       = 0.25f;   // speed fraction remaining at the gate — 4x compression
static constexpr float LANE_SPREAD     = 0.4f;    // laneBias steering weight — fills the lane width into a carpet

// congestion awareness. a walker reads the occupancy of its own cell and eases off — the reason a
// jam locks solid is that everyone behind keeps pushing at full speed, so the wedge at the front can
// never unload. traffic flow, not pathing: it clears the jam from the BACK
static constexpr float CROWD_EASE = 0.22f;   // speed divisor weight per unit of local density
static constexpr float CROWD_FLOOR = 0.35f;  // never fully stop — a frozen walker reads as a bug

// per-entity size rolls — polydisperse bodies pack tighter AND read as individuals, both of which
// are the showcase. spawn z = FLOOR_TOP + radius (z is locked per body), so every hull sits on the floor
static constexpr float   GRUNT_RADIUS_MIN = 0.17f;
static constexpr float   GRUNT_RADIUS_MAX = 0.23f;
static constexpr float   BRUTE_RADIUS_MIN = 0.42f;
static constexpr float   BRUTE_RADIUS_MAX = 0.48f;   // capped: a wider brute wedges itself into corner pockets and the lane dies

// the visual overhangs its collider, so neighbors overlap slightly on screen and the crowd reads as
// one packed mass. at 4-7 px a walker nobody sees the clipping, everybody sees the density
static constexpr float   VISUAL_OVERSIZE  = 1.22f;

static constexpr int32_t GRUNT_HP      = 14;
static constexpr int32_t BRUTE_HP      = 37;
static constexpr int32_t GRUNT_GOLD    = 3;
static constexpr int32_t BRUTE_GOLD    = 10;
static constexpr int32_t GRUNT_LEAK    = 1;
static constexpr int32_t BRUTE_LEAK    = 4;

static constexpr color4 GRUNT_COLORS[ 4 ] =
{
    color4( 0.35f, 0.52f, 0.18f, 1.0f ),
    color4( 0.30f, 0.46f, 0.14f, 1.0f ),
    color4( 0.45f, 0.55f, 0.20f, 1.0f ),
    color4( 0.40f, 0.44f, 0.16f, 1.0f ),
};
static constexpr color4 BRUTE_COLOR = color4( 0.16f, 0.36f, 0.12f, 1.0f );

// ── towers ───────────────────────────────────────────────────────────────────
struct TowerParams
{
    float   range;
    float   minRange;
    int32_t cost;
    int32_t cooldownSteps;   // TURRET/MORTAR: refire; LASER: retarget
};
static constexpr TowerParams TOWER_PARAMS[ static_cast<int32_t>( ETowerKind::_SIZE ) ] =
{
    {  9.0f, 0.0f,  40,  90 },   // TURRET — burst cycle
    { 16.0f, 4.5f,  90, 150 },   // MORTAR
    { 13.0f, 0.0f, 140,  30 },   // LASER  — retarget
    { 11.0f, 0.0f, 180,  66 },   // TESLA
    {  7.0f, 0.0f, 120,  15 },   // FROST  — re-apply pulse
};
static constexpr int32_t RETRY_STEPS = 6;   // re-scan delay when a ready tower finds no target

static constexpr int32_t TURRET_DAMAGE       = 1;     // per round
static constexpr int32_t TURRET_BURST_ROUNDS = 5;
static constexpr int32_t TURRET_ROUND_STEPS  = 5;     // the burst stitches across the crowd
// a short bolt travelling the shot line, not the whole line lit at once — the latter reads as a
// second laser. five of these in a burst is the minigun
static constexpr uint64_t TURRET_BOLT_STEPS  = 3;
static constexpr float    TURRET_BOLT_LEN    = 1.5f;
static constexpr uint64_t TRACER_LIFE_STEPS  = TURRET_BOLT_STEPS;
static constexpr color4  TRACER_COLOR        = color4( 1.00f, 0.90f, 0.50f, 1.0f );

// particle scale floor. at overview zoom one world unit is ~10 px, so anything under ~0.3 is
// sub-pixel and simply does not exist on screen — every impact gets a soft core on top of its shards
static constexpr float FX_CORE       = 0.34f;
static constexpr float FX_CORE_SMALL = 0.22f;

static constexpr float    TESLA_JUMP_RANGE  = 4.5f;   // arc reach body-to-body — density IS the chain length
static constexpr int32_t  TESLA_DAMAGE      = 2;      // per link
static constexpr uint64_t TESLA_FLASH_STEPS = 14;     // bolt lifetime — it thins and dims out rather than cutting
static constexpr float    TESLA_BEAM_WIDTH  = 0.055f;

static constexpr float    FROST_FACTOR     = 0.45f;   // speed multiplier inside the aura
static constexpr uint64_t FROST_SLOW_STEPS = 60;      // refreshed while inside — lapses on its own after leaving

static constexpr uint64_t RING_LIFE_STEPS = 24;       // mortar shockwave hoop expand-and-fade

static constexpr uint64_t SHELL_FLIGHT_STEPS = 54;
static constexpr float    SHELL_ARC          = 3.2f;
static constexpr float    MORTAR_AOE         = 3.2f;
static constexpr int32_t  MORTAR_DAMAGE      = 5;
static constexpr float    MORTAR_KICK        = 9.0f;

static constexpr float    BEAM_Z          = 0.18f;   // grazes the carpet — the beam must read as touching the mass
static constexpr float    BEAM_MAX_LEN    = 34.0f;
static constexpr float    BEAM_HIT_WIDTH  = 0.16f;
static constexpr uint64_t LASER_TICK      = 9;
static constexpr int32_t  LASER_DAMAGE    = 1;

// palette grammar: KINETIC weapons are warm, ENERGY weapons are cool. past emissive ~3 every core
// saturates to white and only the bloom halo carries hue, so three cyan-ish weapons read as one —
// splitting the axis is what keeps them apart at overview zoom. frost deliberately sits BELOW the
// bloom threshold so its pastel survives instead of blowing out
static constexpr color4 TURRET_COLOR     = color4( 1.00f, 0.85f, 0.35f, 1.0f );   // tracer yellow
static constexpr color4 MORTAR_COLOR     = color4( 1.00f, 0.60f, 0.20f, 1.0f );
static constexpr color4 LASER_COLOR      = color4( 0.75f, 0.35f, 1.00f, 1.0f );
static constexpr color4 BEAM_COLOR       = color4( 0.80f, 0.45f, 1.00f, 1.0f );
static constexpr color4 TESLA_COLOR      = color4( 0.40f, 0.90f, 1.00f, 1.0f );   // sole owner of cyan now
static constexpr color4 TESLA_BEAM_COLOR = color4( 0.75f, 0.95f, 1.00f, 1.0f );
static constexpr color4 FROST_COLOR      = color4( 0.35f, 0.60f, 1.00f, 1.0f );   // deep blue, low emissive
static constexpr color4 FROST_TINT       = color4( 0.40f, 0.65f, 1.00f, 1.0f );   // status overlay on slowed walkers
static constexpr color4 BASE_COLOR       = color4( 0.33f, 0.30f, 0.33f, 1.0f );

// ── terrain palette ──────────────────────────────────────────────────────────
static constexpr color4 FLOOR_COLOR   = color4( 0.10f, 0.085f, 0.075f, 1.0f );
static constexpr color4 PLATEAU_COLOR = color4( 0.55f, 0.44f, 0.36f, 1.0f );
static constexpr color4 GATE_COLOR    = color4( 0.30f, 0.95f, 1.00f, 1.0f );
static constexpr color4 PORTAL_COLOR  = color4( 1.00f, 0.25f, 0.15f, 1.0f );
static constexpr color4 STAIN_COLOR   = color4( 0.55f, 0.30f, 0.28f, 1.0f );   // MULTIPLY — darkens the floor to dried blood

// entity composition is the game's. nothing here carries CPhysicsBody or CVelocity any more: the
// crowd solver owns every moving position and publishes CTransform itself, and the terrain never
// moves, so crEcs::SyncDynamicTransforms has nothing of ours to do
static entt::entity CreateVisualEntity( entt::registry& registry, float3 pos )
{
    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 );

    return e;
}

static constexpr uint32_t WORK_GRAIN = 512;   // enki partition grain for every agent fan-out

// ── crowd solver tuning ──────────────────────────────────────────────────────
static constexpr int32_t RELAX_ITERATIONS  = 3;      // Jacobi passes per step — 3 is firm without being springy
static constexpr float   CONTACT_STIFFNESS = 0.55f;  // fraction of an overlap removed per pass
static constexpr float   CROWD_DAMPING     = 0.96f;  // bleeds the shove energy so jams settle instead of boiling
static constexpr float   CROWD_MAX_SPEED   = 7.0f;   // clamp on the derived velocity: a deep spawn overlap would otherwise fling
static constexpr float   AGENT_EPSILON     = 1.0e-6f;

void Game::SteerTask( int32_t begin, int32_t end, uint32_t threadIndex, void* context )
{
    ( void )threadIndex;
    static_cast<Game*>( context )->SteerRange( begin, end );
}
void Game::IntegrateTask( int32_t begin, int32_t end, uint32_t threadIndex, void* context )
{
    ( void )threadIndex;
    static_cast<Game*>( context )->IntegrateRange( begin, end );
}
void Game::RelaxTask( int32_t begin, int32_t end, uint32_t threadIndex, void* context )
{
    static_cast<Game*>( context )->RelaxRange( begin, end, threadIndex );
}
void Game::ApplyTask( int32_t begin, int32_t end, uint32_t threadIndex, void* context )
{
    ( void )threadIndex;
    static_cast<Game*>( context )->ApplyRange( begin, end );
}
void Game::FinishTask( int32_t begin, int32_t end, uint32_t threadIndex, void* context )
{
    ( void )threadIndex;
    static_cast<Game*>( context )->FinishRange( begin, end );
}

static float PointSegmentDistSq( float2 p, float2 a, float2 b )
{
    const float2 ab = float2( b.x - a.x, b.y - a.y );
    const float2 ap = float2( p.x - a.x, p.y - a.y );

    const float abSq = ( ab.x * ab.x ) + ( ab.y * ab.y );
    float t = 0.0f;
    if( abSq > 0.000001f )
        t = crMath::Clamp01( ( ( ap.x * ab.x ) + ( ap.y * ab.y ) ) / abSq );

    const float cx = ( a.x + ( ab.x * t ) ) - p.x;
    const float cy = ( a.y + ( ab.y * t ) ) - p.y;
    return ( cx * cx ) + ( cy * cy );
}

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

    _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" );

    _frostProgram  = app->graphics->LoadProgram( "primitive_mass_frost.vert", "primitive_mass_frost.frag" );
    _frostMaterial = app->renderSys->RegisterPrimitiveMaterial( _frostProgram );

    // the default 0.1 near plane wastes depth precision on a scene whose closest geometry is metres
    // away; 1.0 buys ~10x, which the coplanar terrain surfaces need at full zoom-out
    app->camera->SetClip( 1.0f, 300.0f );

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

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

    {// WASD feeds the camera pan through the stick slot; a zero-area region keeps touches out of it
        crStickConfig stick;
        stick.regionMin = float2( 0.0f, 0.0f );
        stick.regionMax = float2( 0.0f, 0.0f );
        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 );
    }

    _mapSeed       = MAP_SEED;
    _profMsPerTick = ( 1000.0 / static_cast<double>( SDL_GetPerformanceFrequency() ) );

    app->devConsole->Register( "title", "quit to title",      CmdTitle, this );
    app->devConsole->Register( "play",  "start the session",  CmdPlay,  this );
    app->devConsole->Register( "gold",  "gold [n] — add n (default 500)", CmdGold, this );
    app->devConsole->Register( "seed",  "seed [n] — regenerate the map (default: next seed)", CmdSeed, this );
    app->devConsole->Register( "dumpmap",    "copy the current map as a FIXED_MAP block",       CmdDumpMap,    this );
    app->devConsole->Register( "dumptowers", "copy the current towers as a PRESET_TOWERS block", CmdDumpTowers, this );

    // this game creates no bodies at all — the crowd solver replaced box3d for the swarm and the
    // terrain collides against the map mask directly. the world still exists and still steps; it is
    // simply empty, so the substep count no longer buys or costs anything here
    app->physics->substeps = 1;

    app->graphics->clearColor = color4( 0.015f, 0.012f, 0.010f, 1.0f );
    app->graphics->PostProcess()->fog.enabled = false;

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

void Game::Update( crApp* app )
{
    if( _reseed )
    {// console `seed` — rebuild the world in place; TITLE just keeps the new seed for START
        _reseed = false;
        if( _state != EGameState::TITLE )
        {
            _state          = EGameState::PLAYING;
            _requestedState = EGameState::PLAYING;
            EnterSession( app );
        }
    }

    if( _requestedState != _state )
    {
        const EGameState from = _state;
        _state = _requestedState;

        if( _state == EGameState::PLAYING )
        {
            if( ( from == EGameState::TITLE ) || ( from == EGameState::RESULT ) )
            {
                EnterSession( app );

                if( _pendingPreset )
                    StartPreset( app );
                _pendingPreset = false;
            }
            else if( from == EGameState::PAUSED )
                app->SetSimulationActive( true );
        }
        else if( _state == EGameState::PAUSED )
        {
            app->SetSimulationActive( false );
        }
        else if( ( _state == EGameState::TITLE ) && ( from != EGameState::TITLE ) )
        {
            ExitSession( app );
        }
        // RESULT: the sim stays active — the swarm keeps churning behind the panel
    }

    if( _prepareUntilStep != 0 )
    {// wind the scenario forward under a frame budget. blocking until it finished would freeze the
     // window with nothing to show; spending a slice per frame keeps the progress bar (and the
     // battle itself) drawing while it winds
        const uint64_t deadline = ( SDL_GetPerformanceCounter()
                                  + static_cast<uint64_t>( PREPARE_BUDGET_MS / _profMsPerTick ) );

        while( ( app->SimulationStepIndex() < _prepareUntilStep ) && ( SDL_GetPerformanceCounter() < deadline ) )
            app->FixedUpdateOnce();

        if( app->SimulationStepIndex() >= _prepareUntilStep )
        {
            _prepareUntilStep = 0;
            app->SetSimulationActive( true );
        }
    }

    if( app->inputSys->devCamera == false )
    {// keyboard pan — rate follows the zoom so travel feels constant at any height
        const float2 stick = app->inputSys->Stick( EStickId::LEFT );
        const float  rate  = ( CAM_PAN_RATE * _camDist * app->FrameDelta().unscaled );
        _camTarget.x += ( stick.x * rate );
        _camTarget.y += ( stick.y * rate );
    }

    UpdateGhost( app );
    UpdateCamera( app );
}
void Game::FixedUpdatePre( const crApp* app )
{
    if( _state == EGameState::TITLE )
        return;

    _profSpanStart = SDL_GetPerformanceCounter();

    ProfileBegin( EProfSection::COMMANDS );
    if( _state == EGameState::PLAYING )
    {
        UpdateCommands( app );
        UpdateEconomy( app );
    }
    else
        _pendingBuilds.Clear();
    ProfileEnd( EProfSection::COMMANDS );

    if( _state == EGameState::PLAYING )
    {
        ProfileBegin( EProfSection::SPAWNER );
        UpdateSpawner( app );
        ProfileEnd( EProfSection::SPAWNER );
    }

    if( app->SimulationStepIndex() >= _nextReflowStep )
    {// periodic reflow — the field learns where the jams are and steers the tail around them.
     // a static field keeps shoving everyone into an already-blocked branch, which is what wedges
     // bodies into wall notches under crowd pressure
        ProfileBegin( EProfSection::REFLOW );
        _nextReflowStep = ( app->SimulationStepIndex() + REFLOW_STEPS );
        UpdateDensityField( app );
        BuildFlowField( true );
        ProfileEnd( EProfSection::REFLOW );
    }

    UpdateEnemies( app );
    CrowdStep( app );   // publishes CTransform, so everything below sees this step's positions

    ProfileBegin( EProfSection::TOWERS );
    UpdateTowers( app );
    ProfileEnd( EProfSection::TOWERS );

    ProfileBegin( EProfSection::SHELLS_FX );
    UpdateShells( app );
    UpdateFxRings( app );
    ProfileEnd( EProfSection::SHELLS_FX );

    if( _state == EGameState::PLAYING )
        CheckEndConditions( app );
}
void Game::FixedUpdatePost( const crApp* app )
{
    ( void )app;

    if( _state != EGameState::TITLE )
        ProfileStepEnd();
}
void Game::HandleEvent( const crApp* app, const SDL_Event* event )
{
    if( ( event->type == SDL_EVENT_KEY_DOWN ) && ( event->key.repeat == false ) )
    {
        if( event->key.key == SDLK_ESCAPE )
        {
            if( ( _state == EGameState::PLAYING ) && ( _buildKind >= 0 ) )
                _buildKind = -1;               // a held selection is what ESC cancels first
            else if( _state == EGameState::PAUSED )
                RequestState( EGameState::PLAYING );
            else if( ( _state == EGameState::PLAYING ) && ( _prepareUntilStep == 0 ) )
                RequestState( EGameState::PAUSED );
            else if( _state == EGameState::RESULT )
                RequestState( EGameState::TITLE );
        }
        else if( ( _state == EGameState::PLAYING ) && ( _prepareUntilStep == 0 ) )
        {
            int32_t kind = -1;
            if( event->key.key == SDLK_1 )
                kind = static_cast<int32_t>( ETowerKind::TURRET );
            else if( event->key.key == SDLK_2 )
                kind = static_cast<int32_t>( ETowerKind::MORTAR );
            else if( event->key.key == SDLK_3 )
                kind = static_cast<int32_t>( ETowerKind::LASER );
            else if( event->key.key == SDLK_4 )
                kind = static_cast<int32_t>( ETowerKind::TESLA );
            else if( event->key.key == SDLK_5 )
                kind = static_cast<int32_t>( ETowerKind::FROST );

            if( kind >= 0 )
                _buildKind = ( ( _buildKind == kind ) ? -1 : kind );
        }
    }

    if( ( event->type == SDL_EVENT_MOUSE_BUTTON_DOWN ) && ( _state == EGameState::PLAYING ) && ( _prepareUntilStep == 0 ) )
    {
        if( ( event->button.button == SDL_BUTTON_LEFT ) && ( _buildKind >= 0 ) )
        {
            _lastPaintCell = { -1, -1 };

            int2 cell;
            if( PickBuildCell( app, app->inputSys->Pointer().posPx, &cell ) )
            {
                PendingBuild build;
                build.kind = static_cast<ETowerKind>( _buildKind );
                build.cell = cell;
                _pendingBuilds.Add( build );
                _lastPaintCell = cell;
            }
        }
        else if( event->button.button == SDL_BUTTON_RIGHT )
            _buildKind = -1;
    }

    if( app->inputSys->devCamera )
        return;   // the dev free-camera owns the view; the game must not fight it for the same input

    if( event->type == SDL_EVENT_MOUSE_MOTION )
    {
        if( ( event->motion.state & SDL_BUTTON_MMASK ) != 0 )
        {// MMB drag pan — pixel delta converted at the current zoom so the map tracks the cursor
            const float wpp = ( ( 2.0f * _camDist * crMath::Tan( app->camera->EffectiveFovY() * 0.5f ) )
                                / static_cast<float>( app->camera->Viewport().y ) );
            _camTarget.x -= ( event->motion.xrel * wpp );
            _camTarget.y += ( event->motion.yrel * wpp );   // SDL Y-down -> engine Y-up
        }
        else if( ( _state == EGameState::PLAYING ) && ( _buildKind >= 0 ) &&
                 ( ( event->motion.state & SDL_BUTTON_LMASK ) != 0 ) )
        {// drag paint — queue every new cell the held pointer crosses; the sim validates each
            int2 cell;
            if( PickBuildCell( app, app->inputSys->Pointer().posPx, &cell ) &&
                ( ( cell.x != _lastPaintCell.x ) || ( cell.y != _lastPaintCell.y ) ) )
            {
                PendingBuild build;
                build.kind = static_cast<ETowerKind>( _buildKind );
                build.cell = cell;
                _pendingBuilds.Add( build );
                _lastPaintCell = cell;
            }
        }
    }

    if( event->type == SDL_EVENT_MOUSE_WHEEL )
    {
        _camDist = crMath::Clamp( _camDist * crMath::Pow( CAM_ZOOM_STEP, -event->wheel.y ),
                                  CAM_DIST_MIN, CAM_DIST_MAX );
    }
}
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 );

    if( _state == EGameState::TITLE )
    {
        ui->Label( EUiAnchor::CENTER, float2( 0.0f, 110.0f ), "MASS ENTITY DEFENSE", _font16, white );

        // the preset entry only appears once a dumped scenario has been pasted in — the placeholder
        // list holds one out-of-range cell precisely so this reads as "empty"
        const bool hasPreset = ( PRESET_TOWERS[ 0 ].x >= 0 );

        if( ui->Button( 1, EUiAnchor::CENTER, float2( ( hasPreset ? -115.0f : 0.0f ), 10.0f ), float2( 220.0f, 56.0f ), _uiButtonSprite, 16.0f, "START", _font16, white ) )
        {
            _pendingPreset = false;
            RequestState( EGameState::PLAYING );
        }

        if( hasPreset && ui->Button( 10, EUiAnchor::CENTER, float2( 115.0f, 10.0f ), float2( 220.0f, 56.0f ), _uiButtonSprite, 16.0f, "PRESET", _font16, white ) )
        {
            _pendingPreset = true;
            RequestState( EGameState::PLAYING );
        }

        ui->Label( EUiAnchor::CENTER, float2( 0.0f, -70.0f ), "1-5 SELECT TOWER   LMB PLACE/DRAG   RMB CANCEL", _font12, color4( 0.6f, 0.6f, 0.65f, 1.0f ) );
        ui->Label( EUiAnchor::CENTER, float2( 0.0f, -94.0f ), "WASD/MMB PAN   WHEEL ZOOM   ESC PAUSE", _font12, color4( 0.6f, 0.6f, 0.65f, 1.0f ) );
    }
    else
    {
        char buf[ 64 ];

        if( ( _state == EGameState::PLAYING ) || ( _state == EGameState::PAUSED ) )
        {// HUD — text batches always land on top of sprites, so RESULT's dim cannot cover these
            SDL_snprintf( buf, sizeof( buf ), "GOLD %d", _gold );
            ui->Label( EUiAnchor::TOP_LEFT, float2( 16.0f, -18.0f ), buf, _font16, color4( 1.0f, 0.85f, 0.4f, 1.0f ) );

            SDL_snprintf( buf, sizeof( buf ), "ALIVE %d", _agentCount );   // the showcase metric — keep it loud
            ui->Label( EUiAnchor::TOP_LEFT, float2( 16.0f, -48.0f ), buf, _font16, color4( 0.9f, 0.9f, 0.95f, 1.0f ) );

            {// gate bar
                const float frac = crMath::Clamp01( static_cast<float>( _gateHp ) / static_cast<float>( GATE_HP ) );
                ui->Image( EUiAnchor::TOP, float2( 0.0f, -16.0f ), float2( 268.0f, 26.0f ), _uiDimSprite, color4( 0.0f, 0.0f, 0.0f, 0.55f ) );
                ui->Image( EUiAnchor::TOP, float2( 0.0f, -20.0f ), float2( 260.0f * frac, 18.0f ), _uiDimSprite,
                           crMath::LerpColor( color4( 0.9f, 0.2f, 0.15f, 1.0f ), color4( 0.3f, 0.95f, 0.5f, 1.0f ), frac ) );
                SDL_snprintf( buf, sizeof( buf ), "GATE %d/%d", _gateHp, GATE_HP );
                ui->Label( EUiAnchor::TOP, float2( 0.0f, -50.0f ), buf, _font12, color4( 0.7f, 0.9f, 0.95f, 1.0f ) );
            }

            {// wave status
                const uint64_t step = app->SimulationStepIndex();
                if( _waveActive )
                    SDL_snprintf( buf, sizeof( buf ), "WAVE %d/%d", ( _wave + 1 ), WAVE_COUNT );
                else if( ( _wave + 1 ) < WAVE_COUNT )
                    SDL_snprintf( buf, sizeof( buf ), "NEXT WAVE IN %d", static_cast<int32_t>( ( ( _nextWaveStep > step ) ? ( _nextWaveStep - step ) : 0 ) / 60 ) + 1 );
                else
                    SDL_snprintf( buf, sizeof( buf ), "CLEAR THE FIELD" );
                ui->Label( EUiAnchor::TOP_RIGHT, float2( -16.0f, -18.0f ), buf, _font16, white );
            }
        }

        if( ( _state == EGameState::PLAYING ) && ( _prepareUntilStep == 0 ) )
        {// build bar
            static constexpr const char* NAMES[ 5 ] = { "1 TURRET", "2 MORTAR", "3 LASER", "4 TESLA", "5 FROST" };
            const float2 size = float2( 128.0f, 54.0f );

            for( int32_t k = 0; k < static_cast<int32_t>( ETowerKind::_SIZE ); ++k )
            {
                const float xoff = ( static_cast<float>( k - 2 ) * 136.0f );
                SDL_snprintf( buf, sizeof( buf ), "%s %d", NAMES[ k ], TOWER_PARAMS[ k ].cost );

                color4 tint = color4( 0.85f, 0.85f, 0.9f, 1.0f );
                if( _gold < TOWER_PARAMS[ k ].cost )
                    tint = color4( 0.45f, 0.45f, 0.5f, 1.0f );
                if( _buildKind == k )
                    tint = white;

                if( ui->Button( static_cast<uint32_t>( 2 + k ), EUiAnchor::BOTTOM, float2( xoff, 16.0f ), size, _uiButtonSprite, 16.0f, buf, _font12, tint ) )
                    _buildKind = ( ( _buildKind == k ) ? -1 : k );

                if( _buildKind == k )
                {// selection frame
                    const float2 canvas = ui->CanvasSize();
                    const float  cx     = ( ( canvas.x * 0.5f ) + xoff );
                    const float2 frame[ 4 ] =
                    {
                        float2( cx - ( size.x * 0.5f ), 16.0f ),
                        float2( cx + ( size.x * 0.5f ), 16.0f ),
                        float2( cx + ( size.x * 0.5f ), 16.0f + size.y ),
                        float2( cx - ( size.x * 0.5f ), 16.0f + size.y ),
                    };
                    ui->Polyline( frame, 4, true, 3.0f, white );
                }
            }
        }

        if( _prepareUntilStep != 0 )
        {// wind-up progress — the battle is visibly fast-forwarding behind this
            const float frac = crMath::Clamp01( static_cast<float>( app->SimulationStepIndex() ) / static_cast<float>( _prepareUntilStep ) );

            ui->Image( EUiAnchor::CENTER, float2( 0.0f, 0.0f ), ui->CanvasSize(), _uiDimSprite, color4( 0.0f, 0.0f, 0.0f, 0.35f ) );

            SDL_snprintf( buf, sizeof( buf ), "PREPARING  %d%%", static_cast<int32_t>( frac * 100.0f ) );
            ui->Label( EUiAnchor::CENTER, float2( 0.0f, 40.0f ), buf, _font16, white );

            ui->Image( EUiAnchor::CENTER, float2( 0.0f, 0.0f ), float2( 408.0f, 22.0f ), _uiDimSprite, color4( 0.0f, 0.0f, 0.0f, 0.7f ) );
            ui->Image( EUiAnchor::CENTER, float2( ( -200.0f + ( 200.0f * frac ) ), 0.0f ), float2( ( 400.0f * frac ), 14.0f ), _uiDimSprite, color4( 0.4f, 0.85f, 1.0f, 1.0f ) );

            SDL_snprintf( buf, sizeof( buf ), "step %d / %d", static_cast<int32_t>( app->SimulationStepIndex() ), static_cast<int32_t>( _prepareUntilStep ) );
            ui->Label( EUiAnchor::CENTER, float2( 0.0f, -32.0f ), buf, _font12, color4( 0.7f, 0.7f, 0.75f, 1.0f ) );
        }

        if( _state == EGameState::PAUSED )
        {
            ui->Image( EUiAnchor::CENTER, float2( 0.0f, 0.0f ), ui->CanvasSize(), _uiDimSprite, color4( 0.0f, 0.0f, 0.0f, 0.6f ) );
            ui->Label( EUiAnchor::CENTER, float2( 0.0f, 90.0f ), "PAUSED", _font16, white );

            if( ui->Button( 11, EUiAnchor::CENTER, float2( 0.0f, 20.0f ), float2( 200.0f, 54.0f ), _uiButtonSprite, 16.0f, "RESUME", _font16, white ) )
                RequestState( EGameState::PLAYING );
            if( ui->Button( 12, EUiAnchor::CENTER, float2( 0.0f, -46.0f ), float2( 200.0f, 54.0f ), _uiButtonSprite, 16.0f, "TITLE", _font16, white ) )
                RequestState( EGameState::TITLE );
        }

        if( _state == EGameState::RESULT )
        {
            ui->Image( EUiAnchor::CENTER, float2( 0.0f, 0.0f ), ui->CanvasSize(), _uiDimSprite, color4( 0.0f, 0.0f, 0.0f, 0.65f ) );

            if( _victory )
                ui->Label( EUiAnchor::CENTER, float2( 0.0f, 110.0f ), "VICTORY", _font16, color4( 0.4f, 1.0f, 0.5f, 1.0f ) );
            else
                ui->Label( EUiAnchor::CENTER, float2( 0.0f, 110.0f ), "GATE DESTROYED", _font16, color4( 1.0f, 0.35f, 0.3f, 1.0f ) );

            SDL_snprintf( buf, sizeof( buf ), "WAVES %d/%d", ( _wave + 1 ), WAVE_COUNT );
            ui->Label( EUiAnchor::CENTER, float2( 0.0f, 66.0f ), buf, _font12, white );
            SDL_snprintf( buf, sizeof( buf ), "KILLS %d", _kills );
            ui->Label( EUiAnchor::CENTER, float2( 0.0f, 42.0f ), buf, _font12, white );
            SDL_snprintf( buf, sizeof( buf ), "LEAKS %d", _leaks );
            ui->Label( EUiAnchor::CENTER, float2( 0.0f, 18.0f ), buf, _font12, white );
            SDL_snprintf( buf, sizeof( buf ), "TOWERS %d", _built );
            ui->Label( EUiAnchor::CENTER, float2( 0.0f, -6.0f ), buf, _font12, white );

            if( ui->Button( 9, EUiAnchor::CENTER, float2( 0.0f, -70.0f ), float2( 200.0f, 54.0f ), _uiButtonSprite, 16.0f, "TITLE", _font16, white ) )
                RequestState( EGameState::TITLE );
        }
    }

    ui->End();
}
void Game::RenderDevUi( const crApp* app )
{
    static constexpr const char* NAMES[ static_cast<int32_t>( EProfSection::_SIZE ) ] =
    {
        "commands", "spawner", "reflow", "steer.par", "steer.apply",
        "crowd.grid", "crowd.relax", "crowd.apply", "crowd.finish", "towers", "shells+fx",
    };

    if( ImGui::Begin( "MASS PROFILE" ) == false )
    {
        ImGui::End();
        return;
    }

    ImGui::Checkbox( "measure", &_profile );

    ImGui::Text( "alive %d   towers %d   step %.2f ms", _agentCount, _towers.Size(), _profSpanEma );
    ImGui::TextDisabled( "span = FixedUpdatePre -> FixedUpdatePost (game + physics + ecs sync)" );
    ImGui::Separator();

    float sum = 0.0f;
    for( int32_t i = 0; i < static_cast<int32_t>( EProfSection::_SIZE ); ++i )
        sum += _profEma[ i ];

    const float scale = ( ( _profSpanEma > 0.0001f ) ? ( 100.0f / _profSpanEma ) : 0.0f );

    for( int32_t i = 0; i < static_cast<int32_t>( EProfSection::_SIZE ); ++i )
    {
        ImGui::Text( "%-12s %6.3f ms  %4.1f%%", NAMES[ i ], _profEma[ i ], ( _profEma[ i ] * scale ) );
        ImGui::SameLine();
        ImGui::ProgressBar( ( ( _profSpanEma > 0.0001f ) ? ( _profEma[ i ] / _profSpanEma ) : 0.0f ), ImVec2( 120.0f, 0.0f ), "" );

        if( i == static_cast<int32_t>( EProfSection::REFLOW ) )
        {// amortized above; this is what the frame it actually lands on pays
            ImGui::SameLine();
            ImGui::TextDisabled( "spike %.2f ms / %d steps", _profLast[ i ], static_cast<int32_t>( REFLOW_STEPS ) );
        }
        else if( i == static_cast<int32_t>( EProfSection::CROWD_RELAX ) )
        {// the ratio that decides what is worth optimising next: work looked at vs work that mattered
            const float wasted = ( ( _avgCandidates > 0.001f )
                                 ? ( 100.0f * ( 1.0f - ( _avgContacts / _avgCandidates ) ) )
                                 : 0.0f );
            ImGui::SameLine();
            ImGui::TextDisabled( "%.1f cand / %.1f hit  (%.0f%% rejected)", _avgCandidates, _avgContacts, wasted );
        }
    }

    ImGui::Separator();

    {// what the span cost that the sections did not account for. box3d used to be almost all of it;
     // with an empty world this should now be close to nothing
        const float residual = ( ( _profSpanEma > sum ) ? ( _profSpanEma - sum ) : 0.0f );
        ImGui::Text( "%-12s %6.3f ms  %4.1f%%", "unaccounted", residual, ( residual * scale ) );
        ImGui::SameLine();
        ImGui::ProgressBar( ( ( _profSpanEma > 0.0001f ) ? ( residual / _profSpanEma ) : 0.0f ), ImVec2( 120.0f, 0.0f ), "" );
    }

    ImGui::Separator();
    ImGui::Text( "frame %.2f ms   sim x%.2f", ( app->FrameDelta().unscaled * 1000.0f ), app->SimulationSpeed() );

    ImGui::End();
}
void Game::ProfileBegin( EProfSection section )
{
    if( _profile == false )
        return;

    _profStart[ static_cast<int32_t>( section ) ] = SDL_GetPerformanceCounter();
}
void Game::ProfileEnd( EProfSection section )
{
    if( _profile == false )
        return;

    const int32_t  index = static_cast<int32_t>( section );
    const uint64_t ticks = ( SDL_GetPerformanceCounter() - _profStart[ index ] );
    _profStep[ index ] += static_cast<float>( static_cast<double>( ticks ) * _profMsPerTick );
}
void Game::ProfileStepEnd()
{
    if( _profile == false )
        return;

    static constexpr float BLEND = 0.05f;   // per-step values swing wildly; only the trend is readable

    const uint64_t ticks = ( SDL_GetPerformanceCounter() - _profSpanStart );
    const float    span  = static_cast<float>( static_cast<double>( ticks ) * _profMsPerTick );
    _profSpanEma += ( ( span - _profSpanEma ) * BLEND );

    for( int32_t i = 0; i < static_cast<int32_t>( EProfSection::_SIZE ); ++i )
    {
        if( _profStep[ i ] > 0.0f )
            _profLast[ i ] = _profStep[ i ];   // periodic work: keep the raw hit, not just its average

        _profEma[ i ] += ( ( _profStep[ i ] - _profEma[ i ] ) * BLEND );
        _profStep[ i ] = 0.0f;
    }
}

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

    app->ResetSimulation();

    _rng.Seed( SESSION_SEED );

    GenerateMap();
    for( int32_t i = 0; i < CELL_COUNT; ++i )
        _density[ i ] = 0.0f;
    BuildFlowField( false );   // no crowd yet — the first field is the pure geometry solve
    SpawnTerrain( app );
    SpawnGhost( app );
    SpawnStainPool( app );

    for( int32_t i = 0; i < CELL_COUNT; ++i )
        _towerAt[ i ] = -1;

    _camTarget = float2( 0.0f, 0.0f );
    _camDist   = CAM_DIST_INIT;

    _nextReflowStep = REFLOW_STEPS;


    _gold   = START_GOLD;
    _gateHp = GATE_HP;
    _nextTrickleStep = TRICKLE_STEPS;

    _wave          = -1;
    _waveActive    = false;
    _nextWaveStep  = FIRST_WAVE_STEPS;
    _gruntsLeft    = 0;
    _brutesLeft    = 0;
    _spawnRR       = 0;
    for( int32_t s = 0; s < MAX_SPAWNS; ++s )
    {
        _gruntPending[ s ] = 0;
        _brutePending[ s ] = 0;
    }
    _gruntAcc = 0.0f;
    _bruteAcc = 0.0f;

    _kills   = 0;
    _leaks   = 0;
    _built   = 0;
    _victory = false;

    _buildKind        = -1;
    _pendingCheatGold = 0;

    app->SetSimulationActive( true );
}
void Game::StartPreset( crApp* app )
{
    // placed before step 0 and free of charge: this is a test fixture, not a play. entity creation
    // order is fixed by the list, so the wound-up scene is identical every time it is entered
    for( int32_t i = 0; i < static_cast<int32_t>( SDL_arraysize( PRESET_TOWERS ) ); ++i )
    {
        const PresetTower& preset = PRESET_TOWERS[ i ];
        const int2         cell   = { preset.x, preset.y };

        if( ValidBuildCell( cell ) == false )
        {
            SDL_LogWarn( SDL_LOG_CATEGORY_APPLICATION, "preset tower %d at (%d,%d) is not a buildable cell — skipped", i, preset.x, preset.y );
            continue;
        }

        BuildTower( app, preset.kind, cell, false );
    }

    _prepareUntilStep = PRESET_STEPS;
    app->SetSimulationActive( false );   // Update drives FixedUpdateOnce from here; both clocks must not step it
}
void Game::ExitSession( crApp* app )
{
    app->ResetSession();

    _agentCount = 0;
    _towers.Clear();
    _shells.Clear();
    _fxRings.Clear();
    _pendingBuilds.Clear();

    _ghostBox  = entt::null;
    _ghostRing = entt::null;
    _buildKind = -1;
    _lastPaintCell    = { -1, -1 };
    _prepareUntilStep = 0;

    for( int32_t i = 0; i < STAIN_POOL; ++i )
        _stains[ i ] = entt::null;
    _stainNext = 0;
}

void Game::CmdTitle( void* context, int32_t argc, const char** argv )
{
    ( void )argc;
    ( void )argv;
    static_cast<Game*>( context )->RequestState( EGameState::TITLE );
}
void Game::CmdPlay( void* context, int32_t argc, const char** argv )
{
    ( void )argc;
    ( void )argv;
    static_cast<Game*>( context )->RequestState( EGameState::PLAYING );
}
void Game::CmdGold( void* context, int32_t argc, const char** argv )
{
    Game* game = static_cast<Game*>( context );
    game->_pendingCheatGold += ( ( argc > 1 ) ? SDL_atoi( argv[ 1 ] ) : 500 );
}
void Game::CmdSeed( void* context, int32_t argc, const char** argv )
{
    Game* game = static_cast<Game*>( context );
    game->_mapSeed = ( ( argc > 1 ) ? SDL_atoi( argv[ 1 ] ) : ( game->_mapSeed + 1 ) );
    game->_usePcg  = true;   // hunting shapes — leave the pinned map behind for this session
    game->_reseed  = true;
    SDL_Log( "map seed %d", game->_mapSeed );
}
void Game::CmdDumpMap( void* context, int32_t argc, const char** argv )
{
    ( void )argc;
    ( void )argv;
    static_cast<Game*>( context )->DumpMap();
}
void Game::CmdDumpTowers( void* context, int32_t argc, const char** argv )
{
    ( void )argc;
    ( void )argv;
    static_cast<Game*>( context )->DumpTowers();
}

void Game::DumpMap()
{
    // the chamfer pass OPENED the cells it cut, so dumping _high as-is would lose those steps and
    // the reload would find different corners. put them back first: the dump is the pre-chamfer map,
    // and loading re-derives the cuts exactly
    static constexpr int32_t LINE   = ( MAP_W + 8 );
    const int32_t            budget = ( ( MAP_H + 8 ) * LINE );

    char* out = static_cast<char*>( SDL_malloc( static_cast<size_t>( budget ) ) );
    if( out == nullptr )
        return;

    int32_t at = 0;
    at += SDL_snprintf( out + at, static_cast<size_t>( budget - at ),
                        "static constexpr const char* FIXED_MAP[] =\n{\n" );

    char row[ MAP_W + 1 ];
    row[ MAP_W ] = '\0';

    for( int32_t r = 0; r < MAP_H; ++r )
    {
        const int32_t y = ( MAP_H - 1 - r );

        for( int32_t x = 0; x < MAP_W; ++x )
        {
            const int32_t c = CellIndex( x, y );
            row[ x ] = ( ( ( _high[ c ] != 0 ) || ( _chamfer[ c ] != 0 ) ) ? '#' : '.' );
        }

        if( _gateCell.y == y )
            row[ _gateCell.x ] = 'G';
        for( int32_t s = 0; s < _spawnCount; ++s )
        {
            if( _spawnCells[ s ].y == y )
                row[ _spawnCells[ s ].x ] = 'P';
        }

        at += SDL_snprintf( out + at, static_cast<size_t>( budget - at ), "    \"%s\",\n", row );
    }

    at += SDL_snprintf( out + at, static_cast<size_t>( budget - at ), "};\n" );

    SDL_SetClipboardText( out );
    SDL_free( out );

    SDL_Log( "dumpmap: %dx%d (seed %d) copied to the clipboard", MAP_W, MAP_H, _mapSeed );
}
void Game::DumpTowers()
{
    static constexpr const char* KIND_NAMES[ static_cast<int32_t>( ETowerKind::_SIZE ) ] =
    {
        "TURRET", "MORTAR", "LASER", "TESLA", "FROST",
    };

    const int32_t budget = ( ( _towers.Size() + 8 ) * 64 );

    char* out = static_cast<char*>( SDL_malloc( static_cast<size_t>( budget ) ) );
    if( out == nullptr )
        return;

    int32_t at = 0;
    at += SDL_snprintf( out + at, static_cast<size_t>( budget - at ),
                        "static constexpr PresetTower PRESET_TOWERS[] =\n{\n" );

    for( int32_t i = 0; i < _towers.Size(); ++i )
    {
        const Tower& tower = _towers.At( i );
        at += SDL_snprintf( out + at, static_cast<size_t>( budget - at ),
                            "    { ETowerKind::%s, %d, %d },\n",
                            KIND_NAMES[ static_cast<int32_t>( tower.kind ) ], tower.cell.x, tower.cell.y );
    }

    at += SDL_snprintf( out + at, static_cast<size_t>( budget - at ), "};\n" );

    SDL_SetClipboardText( out );
    SDL_free( out );

    SDL_Log( "dumptowers: %d towers copied to the clipboard", _towers.Size() );
}

void Game::GenerateMap()
{
    if( ( _usePcg == false ) && LoadFixedMap() )
    {// a pinned map is already settled terrain — only the derived chamfer pass still has to run
        ChamferPass();
        return;
    }

    // tunneler PCG: start solid, then CARVE the level — connectivity and passage width are
    // properties of the carve itself, never patched on afterward. own rng stream: the session
    // stream must not shift when map authoring changes
    crRandom rng;
    rng.Seed( static_cast<uint64_t>( static_cast<uint32_t>( _mapSeed ) ) );

    for( int32_t i = 0; i < CELL_COUNT; ++i )
        _high[ i ] = 1;

    {// gate at the center, portals ringed around the rim — the siege comes from everywhere
        _gateCell = { ( ( MAP_W / 2 ) + rng.NextInt32( -5, 6 ) ), ( ( MAP_H / 2 ) + rng.NextInt32( -5, 6 ) ) };
        _gatePos  = CellCenter( _gateCell );

        const float cx  = ( static_cast<float>( MAP_W ) * 0.5f );
        const float cy  = ( static_cast<float>( MAP_H ) * 0.5f );
        const float exX = ( cx - static_cast<float>( BORDER + 4 ) );   // inset half extents the portal ring sits on
        const float exY = ( cy - static_cast<float>( BORDER + 4 ) );

        _spawnCount = MAX_SPAWNS;
        for( int32_t s = 0; s < MAX_SPAWNS; ++s )
        {
            const float  angle = ( ( ( 2.0f * crMath::PI ) * static_cast<float>( s ) ) / static_cast<float>( MAX_SPAWNS ) ) + rng.NextFloat32( -0.15f, 0.15f );
            const float2 dir   = crMath::CosSin( angle );

            // project the direction onto the inset rectangle border
            float t = 1000.0f;
            if( crMath::Abs( dir.x ) > 0.0001f )
                t = ( exX / crMath::Abs( dir.x ) );
            if( crMath::Abs( dir.y ) > 0.0001f )
            {
                const float ty = ( exY / crMath::Abs( dir.y ) );
                if( ty < t )
                    t = ty;
            }

            int32_t px = static_cast<int32_t>( cx + ( dir.x * t ) );
            int32_t py = static_cast<int32_t>( cy + ( dir.y * t ) );
            if( px < ( BORDER + 3 ) )
                px = ( BORDER + 3 );
            if( px > ( MAP_W - BORDER - 4 ) )
                px = ( MAP_W - BORDER - 4 );
            if( py < ( BORDER + 3 ) )
                py = ( BORDER + 3 );
            if( py > ( MAP_H - BORDER - 4 ) )
                py = ( MAP_H - BORDER - 4 );

            _spawnCells[ s ] = { px, py };
        }
    }

    const float2 gateAt = float2( ( static_cast<float>( _gateCell.x ) + 0.5f ),
                                  ( static_cast<float>( _gateCell.y ) + 0.5f ) );

    int32_t laneStart[ MAX_SPAWNS ] = {};
    int32_t laneCount[ MAX_SPAWNS ] = {};
    int32_t sampleTotal = 0;

    for( int32_t s = 0; s < _spawnCount; ++s )
    {// one winding lane per portal
        const float2 from = float2( ( static_cast<float>( _spawnCells[ s ].x ) + 0.5f ),
                                    ( static_cast<float>( _spawnCells[ s ].y ) + 0.5f ) );

        laneStart[ s ] = sampleTotal;
        laneCount[ s ] = CarvePath( &rng, from, gateAt, 0.9f, sampleTotal );
        sampleTotal   += laneCount[ s ];
    }

    for( int32_t k = 0; k < CONNECTOR_COUNT; ++k )
    {// rungs between neighboring lanes — junctions, alternate routes, watershed splits
        const int32_t a = rng.NextInt32( 0, MAX_SPAWNS );
        const int32_t b = ( ( a + 1 ) % MAX_SPAWNS );   // radial layout — the ring wraps
        if( ( laneCount[ a ] == 0 ) || ( laneCount[ b ] == 0 ) )
            continue;

        const float2 pa = _workDir[ laneStart[ a ] + rng.NextInt32( 0, laneCount[ a ] ) ];
        const float2 pb = _workDir[ laneStart[ b ] + rng.NextInt32( 0, laneCount[ b ] ) ];
        CarvePath( &rng, pa, pb, 0.35f, sampleTotal );   // scratch samples — overwritten by the next carve
    }

    for( int32_t k = 0; k < CHAMBER_COUNT; ++k )
    {// bulge arenas on random lane points — mortar kill zones, room for the mass to pool
        const int32_t lane = rng.NextInt32( 0, MAX_SPAWNS );
        if( laneCount[ lane ] == 0 )
            continue;

        const float2 at       = _workDir[ laneStart[ lane ] + rng.NextInt32( 0, laneCount[ lane ] ) ];
        const float  chamberR = rng.NextFloat32( 5.0f, 8.5f );
        CarveDisk( at, chamberR );

        const int32_t pillars = rng.NextInt32( 0, 3 );
        for( int32_t pi = 0; pi < pillars; ++pi )
        {// freestanding rocks inside the arena — cover the crowd splits around
            const float2 cs = crMath::CosSin( rng.NextFloat32( 0.0f, 2.0f * crMath::PI ) );
            const float  d  = rng.NextFloat32( 1.5f, ( chamberR - 2.5f ) );
            FillDisk( float2( at.x + ( cs.x * d ), at.y + ( cs.y * d ) ), rng.NextFloat32( 0.9f, 1.6f ) );
        }
    }

    {// majority smoothing — melts single-cell notches off the scalloped stamp outline.
     // non-border cells always have all 8 neighbors in bounds (BORDER >= 1), so no edge checks
        for( int32_t pass = 0; pass < SMOOTH_PASSES; ++pass )
        {
            for( int32_t y = 0; y < MAP_H; ++y )
            {
                for( int32_t x = 0; x < MAP_W; ++x )
                {
                    const int32_t c = CellIndex( x, y );
                    const bool border = ( x < BORDER ) || ( y < BORDER ) || ( x >= ( MAP_W - BORDER ) ) || ( y >= ( MAP_H - BORDER ) );
                    if( border )
                    {
                        _workLabel[ c ] = 1;
                        continue;
                    }

                    const int32_t highCount =
                        _high[ CellIndex( x - 1, y - 1 ) ] + _high[ CellIndex( x, y - 1 ) ] + _high[ CellIndex( x + 1, y - 1 ) ] +
                        _high[ CellIndex( x - 1, y     ) ] +                                  _high[ CellIndex( x + 1, y     ) ] +
                        _high[ CellIndex( x - 1, y + 1 ) ] + _high[ CellIndex( x, y + 1 ) ] + _high[ CellIndex( x + 1, y + 1 ) ];

                    if( highCount > 4 )
                        _workLabel[ c ] = 1;
                    else if( highCount < 4 )
                        _workLabel[ c ] = 0;
                    else
                        _workLabel[ c ] = _high[ c ];
                }
            }

            for( int32_t i = 0; i < CELL_COUNT; ++i )
                _high[ i ] = static_cast<uint8_t>( _workLabel[ i ] );
        }
    }

    {// melt small high islands into the floor — they read as debris, not terrain
        SDL_memset( _workLabel, 0, sizeof( _workLabel ) );

        for( int32_t i = 0; i < CELL_COUNT; ++i )
        {
            if( ( _high[ i ] == 0 ) || ( _workLabel[ i ] != 0 ) )
                continue;

            int32_t head = 0;
            int32_t tail = 0;
            _workQueue[ tail++ ] = i;
            _workLabel[ i ] = 1;

            while( head < tail )
            {
                const int32_t c  = _workQueue[ head++ ];
                const int32_t cx = ( c % MAP_W );
                const int32_t cy = ( c / MAP_W );

                const int32_t nx[ 4 ] = { cx - 1, cx + 1, cx, cx };
                const int32_t ny[ 4 ] = { cy, cy, cy - 1, cy + 1 };
                for( int32_t d = 0; d < 4; ++d )
                {
                    if( ( nx[ d ] < 0 ) || ( nx[ d ] >= MAP_W ) || ( ny[ d ] < 0 ) || ( ny[ d ] >= MAP_H ) )
                        continue;
                    const int32_t n = CellIndex( nx[ d ], ny[ d ] );
                    if( ( _high[ n ] != 0 ) && ( _workLabel[ n ] == 0 ) )
                    {
                        _workLabel[ n ] = 1;
                        _workQueue[ tail++ ] = n;
                    }
                }
            }

            if( tail < MIN_ISLAND )
            {
                for( int32_t k = 0; k < tail; ++k )
                    _high[ _workQueue[ k ] ] = 0;
            }
        }
    }

    {// clear pockets around the gate and every portal — spawn jitter must never land inside a slab,
     // and pillar stamps must never wall either endpoint in. last word on these cells
        CarvePocket( _gateCell );
        for( int32_t s = 0; s < _spawnCount; ++s )
            CarvePocket( _spawnCells[ s ] );
    }

    {// diagonal-gap fill: a checkerboard 2x2 — floor cells touching only at a corner — is a passage
     // the field may route toward but no body can pass. OPEN one rock cell (never fill a floor one:
     // filling could disconnect a lane, opening cannot). repeat until stable
        for( int32_t pass = 0; pass < 6; ++pass )
        {
            bool changed = false;
            for( int32_t y = BORDER; y < ( MAP_H - BORDER - 1 ); ++y )
            {
                for( int32_t x = BORDER; x < ( MAP_W - BORDER - 1 ); ++x )
                {
                    const int32_t c00 = CellIndex( x, y );
                    const int32_t c10 = CellIndex( x + 1, y );
                    const int32_t c01 = CellIndex( x, y + 1 );
                    const int32_t c11 = CellIndex( x + 1, y + 1 );

                    if( ( _high[ c00 ] != 0 ) && ( _high[ c11 ] != 0 ) && ( _high[ c10 ] == 0 ) && ( _high[ c01 ] == 0 ) )
                    {
                        _high[ c00 ] = 0;
                        changed = true;
                    }
                    else if( ( _high[ c10 ] != 0 ) && ( _high[ c01 ] != 0 ) && ( _high[ c00 ] == 0 ) && ( _high[ c11 ] == 0 ) )
                    {
                        _high[ c10 ] = 0;
                        changed = true;
                    }
                }
            }
            if( changed == false )
                break;
        }
    }

    {// keep only the largest floor region — pure safety net: the carve connects everything by
     // construction, but smoothing could in principle pinch off a sliver
        SDL_memset( _workLabel, 0, sizeof( _workLabel ) );

        int32_t label     = 0;
        int32_t bestSize  = 0;
        int32_t mainLabel = 0;

        for( int32_t i = 0; i < CELL_COUNT; ++i )
        {
            if( ( _high[ i ] != 0 ) || ( _workLabel[ i ] != 0 ) )
                continue;

            ++label;

            int32_t head = 0;
            int32_t tail = 0;
            _workQueue[ tail++ ] = i;
            _workLabel[ i ] = label;

            while( head < tail )
            {
                const int32_t c  = _workQueue[ head++ ];
                const int32_t cx = ( c % MAP_W );
                const int32_t cy = ( c / MAP_W );

                const int32_t nx[ 4 ] = { cx - 1, cx + 1, cx, cx };
                const int32_t ny[ 4 ] = { cy, cy, cy - 1, cy + 1 };
                for( int32_t d = 0; d < 4; ++d )
                {
                    if( ( nx[ d ] < 0 ) || ( nx[ d ] >= MAP_W ) || ( ny[ d ] < 0 ) || ( ny[ d ] >= MAP_H ) )
                        continue;
                    const int32_t n = CellIndex( nx[ d ], ny[ d ] );
                    if( ( _high[ n ] == 0 ) && ( _workLabel[ n ] == 0 ) )
                    {
                        _workLabel[ n ] = label;
                        _workQueue[ tail++ ] = n;
                    }
                }
            }

            if( tail > bestSize )
            {
                bestSize  = tail;
                mainLabel = label;
            }
        }

        for( int32_t i = 0; i < CELL_COUNT; ++i )
        {
            if( ( _high[ i ] == 0 ) && ( _workLabel[ i ] != mainLabel ) )
                _high[ i ] = 1;
        }
    }

    ChamferPass();
}
void Game::ChamferPass()
{
    // a staircase step (a solid cell with exactly two ADJACENT sides open) gets its protruding
    // corner cut to 45 deg. the cell is then marked OPEN: the field must route across the new
    // diagonal, and SpawnTerrain rebuilds it as a rotated slab instead of an axis-aligned block.
    // detection reads a settled map and writes only the mark, so no pass sees a half-cut map
    SDL_memset( _chamfer, 0, sizeof( _chamfer ) );

    for( int32_t y = BORDER; y < ( MAP_H - BORDER ); ++y )
    {
        for( int32_t x = BORDER; x < ( MAP_W - BORDER ); ++x )
        {
            const int32_t c = CellIndex( x, y );
            if( _high[ c ] == 0 )
                continue;

            const bool west  = ( _high[ CellIndex( x - 1, y ) ] == 0 );
            const bool east  = ( _high[ CellIndex( x + 1, y ) ] == 0 );
            const bool south = ( _high[ CellIndex( x, y - 1 ) ] == 0 );
            const bool north = ( _high[ CellIndex( x, y + 1 ) ] == 0 );

            // exactly one adjacent pair open. three open sides is a nub, not a step — cutting
            // one would leave a slab floating with nothing behind it
            if( north && east && ( west == false ) && ( south == false ) )
                _chamfer[ c ] = 1;
            else if( north && west && ( east == false ) && ( south == false ) )
                _chamfer[ c ] = 2;
            else if( south && west && ( east == false ) && ( north == false ) )
                _chamfer[ c ] = 3;
            else if( south && east && ( west == false ) && ( north == false ) )
                _chamfer[ c ] = 4;
        }
    }

    for( int32_t i = 0; i < CELL_COUNT; ++i )
    {
        if( _chamfer[ i ] != 0 )
            _high[ i ] = 0;
    }

    BuildNearSolid();   // last edit to the terrain — everything derived from it goes after this
}
void Game::BuildNearSolid()
{
    for( int32_t y = 0; y < MAP_H; ++y )
    {
        for( int32_t x = 0; x < MAP_W; ++x )
        {
            uint8_t solid = 0;

            for( int32_t oy = -1; ( oy <= 1 ) && ( solid == 0 ); ++oy )
            {
                const int32_t cy = ( y + oy );
                if( ( cy < 0 ) || ( cy >= MAP_H ) )
                    continue;

                for( int32_t ox = -1; ox <= 1; ++ox )
                {
                    const int32_t cx = ( x + ox );
                    if( ( cx < 0 ) || ( cx >= MAP_W ) )
                        continue;

                    const int32_t n = CellIndex( cx, cy );
                    if( ( _high[ n ] != 0 ) || ( _chamfer[ n ] != 0 ) )
                    {
                        solid = 1;
                        break;
                    }
                }
            }

            _nearSolid[ CellIndex( x, y ) ] = solid;
        }
    }
}
bool Game::LoadFixedMap()
{
    static constexpr int32_t ROWS = static_cast<int32_t>( SDL_arraysize( FIXED_MAP ) );

    if( ( ROWS != MAP_H ) || ( FIXED_MAP[ 0 ] == nullptr ) )
        return false;

    for( int32_t r = 0; r < MAP_H; ++r )
    {
        if( SDL_strlen( FIXED_MAP[ r ] ) != static_cast<size_t>( MAP_W ) )
        {
            SDL_LogWarn( SDL_LOG_CATEGORY_APPLICATION, "FIXED_MAP row %d is not %d wide — generating instead", r, MAP_W );
            return false;
        }
    }

    _spawnCount = 0;
    bool haveGate = false;

    for( int32_t r = 0; r < MAP_H; ++r )
    {
        const int32_t y   = ( MAP_H - 1 - r );   // row 0 is the north edge, matching how it reads
        const char*   row = FIXED_MAP[ r ];

        for( int32_t x = 0; x < MAP_W; ++x )
        {
            const char glyph = row[ x ];

            _high[ CellIndex( x, y ) ] = ( ( glyph == '#' ) ? 1 : 0 );

            if( glyph == 'G' )
            {
                _gateCell = { x, y };
                haveGate  = true;
            }
            else if( ( glyph == 'P' ) && ( _spawnCount < MAX_SPAWNS ) )
                _spawnCells[ _spawnCount++ ] = { x, y };
        }
    }

    if( ( haveGate == false ) || ( _spawnCount == 0 ) )
    {
        SDL_LogWarn( SDL_LOG_CATEGORY_APPLICATION, "FIXED_MAP has no gate or no portals — generating instead" );
        return false;
    }

    _gatePos = CellCenter( _gateCell );
    return true;
}
void Game::CarveDisk( float2 cellPos, float radius )
{
    const int32_t x0 = static_cast<int32_t>( cellPos.x - radius );
    const int32_t x1 = static_cast<int32_t>( cellPos.x + radius );
    const int32_t y0 = static_cast<int32_t>( cellPos.y - radius );
    const int32_t y1 = static_cast<int32_t>( cellPos.y + radius );

    for( int32_t y = y0; y <= y1; ++y )
    {
        for( int32_t x = x0; x <= x1; ++x )
        {
            if( ( x < BORDER ) || ( x >= ( MAP_W - BORDER ) ) || ( y < BORDER ) || ( y >= ( MAP_H - BORDER ) ) )
                continue;

            const float dx = ( ( static_cast<float>( x ) + 0.5f ) - cellPos.x );
            const float dy = ( ( static_cast<float>( y ) + 0.5f ) - cellPos.y );
            if( ( ( dx * dx ) + ( dy * dy ) ) <= ( radius * radius ) )
                _high[ CellIndex( x, y ) ] = 0;
        }
    }
}
void Game::FillDisk( float2 cellPos, float radius )
{
    const int32_t x0 = static_cast<int32_t>( cellPos.x - radius );
    const int32_t x1 = static_cast<int32_t>( cellPos.x + radius );
    const int32_t y0 = static_cast<int32_t>( cellPos.y - radius );
    const int32_t y1 = static_cast<int32_t>( cellPos.y + radius );

    for( int32_t y = y0; y <= y1; ++y )
    {
        for( int32_t x = x0; x <= x1; ++x )
        {
            if( ( x < BORDER ) || ( x >= ( MAP_W - BORDER ) ) || ( y < BORDER ) || ( y >= ( MAP_H - BORDER ) ) )
                continue;

            const float dx = ( ( static_cast<float>( x ) + 0.5f ) - cellPos.x );
            const float dy = ( ( static_cast<float>( y ) + 0.5f ) - cellPos.y );
            if( ( ( dx * dx ) + ( dy * dy ) ) <= ( radius * radius ) )
                _high[ CellIndex( x, y ) ] = 1;
        }
    }
}
int32_t Game::CarvePath( crRandom* rng, float2 from, float2 to, float wanderMax, int32_t sampleBase )
{
    float2 p      = from;
    float  wander = 0.0f;
    float  radius = rng->NextFloat32( 1.8f, 3.2f );

    int32_t samples   = 0;
    int32_t splitLeft = 0;
    float   splitGap  = 0.0f;

    for( int32_t step = 0; step < 900; ++step )
    {
        if( ( ( step % 3 ) == 0 ) && ( ( sampleBase + samples ) < CELL_COUNT ) )
        {
            _workDir[ sampleBase + samples ] = p;
            ++samples;
        }

        const float dx = ( to.x - p.x );
        const float dy = ( to.y - p.y );
        const float sq = ( ( dx * dx ) + ( dy * dy ) );
        if( sq < 9.0f )
        {
            CarveDisk( p, radius );
            return samples;
        }

        const float len = crMath::Sqrt( sq );

        // wander: accumulated random turn, decayed and clamped — the lane meanders but always
        // keeps pulling toward the target
        wander += rng->NextFloat32( -0.22f, 0.22f );
        wander  = crMath::Clamp( ( wander * 0.93f ), -wanderMax, wanderMax );

        const float2 cs  = crMath::CosSin( wander );
        const float2 dir = float2( ( ( dx / len ) * cs.x ) - ( ( dy / len ) * cs.y ),
                                   ( ( dx / len ) * cs.y ) + ( ( dy / len ) * cs.x ) );

        bool entering = false;
        if( ( splitLeft == 0 ) && rng->NextBool( 0.02f ) )
        {// braid: fork around a spine of rock for a stretch — obstacles whose side widths are
         // guaranteed by the stamps themselves
            splitLeft = rng->NextInt32( 8, 15 );
            splitGap  = rng->NextFloat32( 1.2f, 2.2f );
            entering  = true;
        }

        if( splitLeft > 0 )
        {
            --splitLeft;

            const float2 perp = float2( -dir.y, dir.x );
            const float  side = crMath::Clamp( ( radius * 0.85f ), 1.7f, 2.6f );
            CarveDisk( float2( p.x + ( perp.x * ( side + splitGap ) ), p.y + ( perp.y * ( side + splitGap ) ) ), side );
            CarveDisk( float2( p.x - ( perp.x * ( side + splitGap ) ), p.y - ( perp.y * ( side + splitGap ) ) ), side );

            if( entering || ( splitLeft == 0 ) )
                CarveDisk( p, radius );   // junction — the fork mouths must meet the main bore
        }
        else
            CarveDisk( p, radius );

        p.x = crMath::Clamp( ( p.x + ( dir.x * 1.4f ) ), static_cast<float>( BORDER + 2 ), static_cast<float>( MAP_W - BORDER - 3 ) );
        p.y = crMath::Clamp( ( p.y + ( dir.y * 1.4f ) ), static_cast<float>( BORDER + 2 ), static_cast<float>( MAP_H - BORDER - 3 ) );

        radius = crMath::Clamp( ( radius + rng->NextFloat32( -0.3f, 0.3f ) ), 1.8f, 3.2f );
    }

    for( int32_t step = 0; step < 400; ++step )
    {// wander budget exhausted — drive straight home; a lane that does not reach must not exist
        const float dx = ( to.x - p.x );
        const float dy = ( to.y - p.y );
        const float sq = ( ( dx * dx ) + ( dy * dy ) );
        if( sq < 4.0f )
            break;

        const float len = crMath::Sqrt( sq );
        p.x += ( ( dx / len ) * 1.2f );
        p.y += ( ( dy / len ) * 1.2f );
        CarveDisk( p, radius );
    }

    return samples;
}
void Game::CarvePocket( int2 center )
{
    for( int32_t oy = -2; oy <= 2; ++oy )
    {
        for( int32_t ox = -2; ox <= 2; ++ox )
        {
            const int32_t x = ( center.x + ox );
            const int32_t y = ( center.y + oy );
            if( ( x < BORDER ) || ( x >= ( MAP_W - BORDER ) ) || ( y < BORDER ) || ( y >= ( MAP_H - BORDER ) ) )
                continue;
            _high[ CellIndex( x, y ) ] = 0;
        }
    }
}
void Game::BuildFlowField( bool withDensity )
{
    const int32_t gate = CellIndex( _gateCell.x, _gateCell.y );

    for( int32_t i = 0; i < CELL_COUNT; ++i )
    {
        _flowDist[ i ] = FLOW_INF;
        _flowDir[ i ]  = float2( 0.0f, 0.0f );
    }
    _flowDist[ gate ] = 0.0f;

    {// fast sweeping — solve |grad U| = F with Godunov updates, alternating which corner each sweep
     // starts from so characteristics can turn around the bends of a winding lane.
     //
     // this is what makes the field EUCLIDEAN. a plain BFS yields a manhattan metric whose gradient
     // is diamond-shaped, and no amount of smoothing repairs the angle error that follows from it.
     // F is the local SLOWNESS, so crowding a cell simply makes routes through it cost more.
     //
     // every cell reached here is interior (the rim is forced solid), so the four neighbor reads
     // need no bounds test
        for( int32_t round = 0; round < EIKONAL_ROUNDS; ++round )
        {
            for( int32_t sweep = 0; sweep < 4; ++sweep )
            {
                const bool xForward = ( ( sweep & 1 ) == 0 );
                const bool yForward = ( ( sweep & 2 ) == 0 );

                for( int32_t yi = 0; yi < MAP_H; ++yi )
                {
                    const int32_t y = ( yForward ? yi : ( MAP_H - 1 - yi ) );

                    for( int32_t xi = 0; xi < MAP_W; ++xi )
                    {
                        const int32_t x = ( xForward ? xi : ( MAP_W - 1 - xi ) );
                        const int32_t c = CellIndex( x, y );

                        if( ( _high[ c ] != 0 ) || ( c == gate ) )
                            continue;

                        const float xm = _flowDist[ c - 1 ];
                        const float xp = _flowDist[ c + 1 ];
                        const float ym = _flowDist[ c - MAP_W ];
                        const float yp = _flowDist[ c + MAP_W ];

                        const float a = ( ( xm < xp ) ? xm : xp );
                        const float b = ( ( ym < yp ) ? ym : yp );
                        if( ( a >= FLOW_INF ) && ( b >= FLOW_INF ) )
                            continue;

                        float f = 1.0f;
                        if( withDensity )
                            f += crMath::Clamp( ( _density[ c ] * DENSITY_COST ), 0.0f, DENSITY_COST_MAX );

                        float u;
                        if( a >= FLOW_INF )
                            u = ( b + f );
                        else if( b >= FLOW_INF )
                            u = ( a + f );
                        else
                        {
                            const float diff = ( a - b );
                            if( ( diff >= f ) || ( diff <= -f ) )
                                u = ( ( ( a < b ) ? a : b ) + f );
                            else
                                u = ( ( a + b + crMath::Sqrt( ( 2.0f * f * f ) - ( diff * diff ) ) ) * 0.5f );
                        }

                        if( u < _flowDist[ c ] )
                            _flowDist[ c ] = u;
                    }
                }
            }
        }
    }

    {// direction = -normalize(grad U), central differences. a solid or unreachable neighbor reads as
     // uphill by WALL_BIAS, so the push-off from walls falls out of the gradient itself — there is
     // no separate repulsion term to tune against the steering any more
        for( int32_t y = BORDER; y < ( MAP_H - BORDER ); ++y )
        {
            for( int32_t x = BORDER; x < ( MAP_W - BORDER ); ++x )
            {
                const int32_t c = CellIndex( x, y );
                if( ( _high[ c ] != 0 ) || ( _flowDist[ c ] >= FLOW_INF ) )
                    continue;

                const float uphill = ( _flowDist[ c ] + WALL_BIAS );

                float uxm = _flowDist[ c - 1 ];
                float uxp = _flowDist[ c + 1 ];
                float uym = _flowDist[ c - MAP_W ];
                float uyp = _flowDist[ c + MAP_W ];

                if( ( _high[ c - 1 ] != 0 ) || ( uxm >= FLOW_INF ) )
                    uxm = uphill;
                if( ( _high[ c + 1 ] != 0 ) || ( uxp >= FLOW_INF ) )
                    uxp = uphill;
                if( ( _high[ c - MAP_W ] != 0 ) || ( uym >= FLOW_INF ) )
                    uym = uphill;
                if( ( _high[ c + MAP_W ] != 0 ) || ( uyp >= FLOW_INF ) )
                    uyp = uphill;

                const float gx  = ( ( uxp - uxm ) * 0.5f );
                const float gy  = ( ( uyp - uym ) * 0.5f );
                const float len = crMath::Sqrt( ( gx * gx ) + ( gy * gy ) );
                if( len > 0.00001f )
                    _flowDir[ c ] = float2( ( -gx / len ), ( -gy / len ) );
            }
        }
    }
}

void Game::UpdateDensityField( const crApp* app )
{
    ( void )app;

    SDL_memset( _workLabel, 0, sizeof( _workLabel ) );

    for( int32_t i = 0; i < _agentCount; ++i )
    {
        const int2 cell = WorldToCell( _hot[ i ].pos.x, _hot[ i ].pos.y );
        ++_workLabel[ CellIndex( cell.x, cell.y ) ];
    }

    // decayed, never instantaneous: a field rebuilt from one frame's counts flips between two
    // routes on consecutive rebuilds and the whole crowd sloshes with it
    for( int32_t i = 0; i < CELL_COUNT; ++i )
        _density[ i ] = crMath::Lerp( _density[ i ], static_cast<float>( _workLabel[ i ] ), DENSITY_BLEND );
}
float2 Game::SampleFlow( float wx, float wy ) const
{
    // cell centers land on integers in this space, so the four taps bracket the query point
    const float gx = crMath::Clamp( ( ( wx / CELL ) + ( static_cast<float>( MAP_W ) * 0.5f ) - 0.5f ), 0.0f, ( static_cast<float>( MAP_W ) - 1.001f ) );
    const float gy = crMath::Clamp( ( ( wy / CELL ) + ( static_cast<float>( MAP_H ) * 0.5f ) - 0.5f ), 0.0f, ( static_cast<float>( MAP_H ) - 1.001f ) );

    const int32_t i0 = static_cast<int32_t>( gx );   // clamped non-negative, so truncation IS floor
    const int32_t j0 = static_cast<int32_t>( gy );
    const float   tx = ( gx - static_cast<float>( i0 ) );
    const float   ty = ( gy - static_cast<float>( j0 ) );

    const int32_t cells[ 4 ]   = { CellIndex( i0, j0 ), CellIndex( i0 + 1, j0 ), CellIndex( i0, j0 + 1 ), CellIndex( i0 + 1, j0 + 1 ) };
    const float   weights[ 4 ] = { ( ( 1.0f - tx ) * ( 1.0f - ty ) ), ( tx * ( 1.0f - ty ) ), ( ( 1.0f - tx ) * ty ), ( tx * ty ) };

    float sx = 0.0f;
    float sy = 0.0f;
    float sw = 0.0f;

    for( int32_t k = 0; k < 4; ++k )
    {// solid taps are dropped from the blend rather than counted as zero — counting them would sap
     // the direction to nothing exactly where a walker is hugging a wall
        if( _high[ cells[ k ] ] != 0 )
            continue;

        sx += ( _flowDir[ cells[ k ] ].x * weights[ k ] );
        sy += ( _flowDir[ cells[ k ] ].y * weights[ k ] );
        sw += weights[ k ];
    }

    if( sw < 0.0001f )
        return float2( 0.0f, 0.0f );

    const float len = crMath::Sqrt( ( sx * sx ) + ( sy * sy ) );
    if( len < 0.0001f )
        return float2( 0.0f, 0.0f );

    return float2( ( sx / len ), ( sy / len ) );
}
void Game::SpawnTerrain( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    {// canyon floor — one visual slab, no body (enemy z is locked, nothing can fall)
        const entt::entity floor = CreateVisualEntity( registry, float3( 0.0f, 0.0f, FLOOR_TOP - 0.25f ) );

        CPrimitive primitive;
        primitive.shape = EPrimitive::BOX;
        primitive.flags = EPrimitiveFlags::RECEIVE_SHADOW;
        primitive.scale = float3( ( static_cast<float>( MAP_W ) * CELL * 0.5f ) + 2.0f,
                                  ( static_cast<float>( MAP_H ) * CELL * 0.5f ) + 2.0f,
                                  0.25f );
        primitive.color = FLOOR_COLOR;
        registry.emplace<CPrimitive>( floor, primitive );
        registry.emplace<CTag_NonHashTarget>( floor );
    }

    {// cliff slabs — horizontal runs of high cells merged into single static boxes
        for( int32_t y = 0; y < MAP_H; ++y )
        {
            int32_t x = 0;
            while( x < MAP_W )
            {
                if( _high[ CellIndex( x, y ) ] == 0 )
                {
                    ++x;
                    continue;
                }

                int32_t runEnd = x;
                while( ( runEnd < MAP_W ) && ( _high[ CellIndex( runEnd, y ) ] != 0 ) )
                    ++runEnd;

                const float halfX = ( static_cast<float>( runEnd - x ) * CELL * 0.5f );
                const float cx    = ( ( static_cast<float>( x ) - ( static_cast<float>( MAP_W ) * 0.5f ) ) * CELL ) + halfX;
                const float cy    = ( ( static_cast<float>( y ) - ( static_cast<float>( MAP_H ) * 0.5f ) ) * CELL ) + ( CELL * 0.5f );

                // visual only — the crowd solver collides against the _high mask directly, so the
                // terrain needs no shapes at all any more
                const entt::entity e = CreateVisualEntity( registry, float3( cx, cy, CLIFF_MID ) );

                CPrimitive primitive;
                primitive.shape = EPrimitive::BOX;
                // cast, never receive. the plateau is hundreds of separate slabs whose tops are all
                // at one height, so every slab samples a shadow texel its NEIGHBOUR wrote at the same
                // depth — the comparison splits and the tops speckle along every seam. no receive,
                // no comparison, no acne. nothing is lost: equal-height slabs cannot shade each other,
                // and the canyon floor still receives, which is where the depth cue actually reads
                primitive.flags = EPrimitiveFlags::CAST_SHADOW;
                primitive.scale = float3( halfX, ( CELL * 0.5f ), CLIFF_HALF );
                primitive.color = PLATEAU_COLOR;
                registry.emplace<CPrimitive>( e, primitive );

                x = runEnd;
            }
        }
    }

    {// chamfer slabs — the 45 deg cut across each staircase step. GenerateMap already marked these
     // cells open, so the run merge above skipped them and this is the only geometry there.
     // the slab overlaps the two solid neighbours behind it; CHAMFER_SINK is what keeps that overlap
     // from fighting, and it needs no special material to do it
        for( int32_t y = BORDER; y < ( MAP_H - BORDER ); ++y )
        {
            for( int32_t x = BORDER; x < ( MAP_W - BORDER ); ++x )
            {
                const uint8_t kind = _chamfer[ CellIndex( x, y ) ];
                if( kind == 0 )
                    continue;

                // which corner protruded: +x = east side open, +y = north side open
                const float cornerX = ( ( ( kind == 1 ) || ( kind == 4 ) ) ? 1.0f : -1.0f );
                const float cornerY = ( ( ( kind == 1 ) || ( kind == 2 ) ) ? 1.0f : -1.0f );

                // the cut runs along the cell diagonal the corner does NOT sit on; the slab is
                // pushed back from it, into the solid half
                const float  angle = ( ( ( cornerX * cornerY ) > 0.0f ) ? ( 0.75f * crMath::PI ) : ( 0.25f * crMath::PI ) );
                const float3 center = CellCenter( { x, y } );
                const float  px = ( center.x - ( cornerX * 0.7071f * CHAMFER_OFFSET ) );
                const float  py = ( center.y - ( cornerY * 0.7071f * CHAMFER_OFFSET ) );

                // visual only; the solver's own half-plane test in PushOutOfTerrain is what agents
                // actually slide along here
                const entt::entity e = CreateVisualEntity( registry, float3( px, py, ( CLIFF_MID - CHAMFER_SINK ) ) );
                registry.get<CTransform>( e ).current.q = crMath::QuatFromAxisAngle( float3( 0.0f, 0.0f, 1.0f ), angle );
                registry.get<CTransform>( e ).previous  = registry.get<CTransform>( e ).current;

                CPrimitive primitive;
                primitive.shape    = EPrimitive::BOX;
                // no shadow at all. the slab is a rotated box that deliberately overhangs the cell it
                // cuts, so as a caster it throws a diagonal shadow the terrain does not actually have.
                // caster and visual are the same geometry here, so the only fix is to stop casting —
                // the neighbouring blocks carry the silhouette
                primitive.flags    = EPrimitiveFlags::NONE;
                primitive.scale    = float3( CHAMFER_LONG, CHAMFER_SHORT, CLIFF_HALF );
                primitive.color    = PLATEAU_COLOR;
                registry.emplace<CPrimitive>( e, primitive );
            }
        }
    }

    {// the gate — an emissive pillar the flow field drains toward
        const entt::entity pillar = CreateVisualEntity( registry, float3( _gatePos.x, _gatePos.y, FLOOR_TOP + 1.3f ) );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::OCTAHEDRON;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.scale    = float3( 0.8f, 0.8f, 1.3f );
        primitive.color    = GATE_COLOR;
        primitive.emissive = 2.4f;
        registry.emplace<CPrimitive>( pillar, primitive );
        registry.emplace<CTag_NonHashTarget>( pillar );

        const entt::entity ring = CreateVisualEntity( registry, float3( _gatePos.x, _gatePos.y, FLOOR_TOP + 0.03f ) );

        CPrimitive ringPrim;
        ringPrim.shape    = EPrimitive::RING_THIN;
        ringPrim.flags    = EPrimitiveFlags::NONE;
        ringPrim.blend    = EBlendMode::ADDITIVE;
        ringPrim.scale    = float3( 3.0f, 3.0f, 1.0f );
        ringPrim.color    = GATE_COLOR;
        ringPrim.emissive = 2.0f;
        registry.emplace<CPrimitive>( ring, ringPrim );
        registry.emplace<CTag_NonHashTarget>( ring );
    }

    for( int32_t s = 0; s < _spawnCount; ++s )
    {// spawn portals — floor rings with a slow ember churn
        const float3 pos = CellCenter( _spawnCells[ s ] );

        const entt::entity ring = CreateVisualEntity( registry, float3( pos.x, pos.y, FLOOR_TOP + 0.03f ) );

        CPrimitive primitive;
        primitive.shape    = EPrimitive::RING_THIN;
        primitive.flags    = EPrimitiveFlags::NONE;
        primitive.blend    = EBlendMode::ADDITIVE;
        primitive.scale    = float3( 2.2f, 2.2f, 1.0f );
        primitive.color    = PORTAL_COLOR;
        primitive.emissive = 2.2f;
        registry.emplace<CPrimitive>( ring, primitive );
        registry.emplace<CTag_NonHashTarget>( ring );

        CParticleEmitter ember;
        ember.color        = color4( 1.0f, 0.35f, 0.12f, 1.0f );
        ember.colorEnd     = color4( 0.3f, 0.05f, 0.02f, 1.0f );
        ember.velocity     = float3( 0.0f, 0.0f, 0.9f );
        ember.gravity      = float3( 0.0f, 0.0f, 0.0f );
        ember.drag         = 0.8f;
        ember.spread       = 0.7f;
        ember.rate         = 14.0f;
        ember.lifetimeMin  = 0.5f;
        ember.lifetimeMax  = 1.1f;
        ember.sizeMin      = 0.10f;
        ember.sizeMax      = 0.24f;
        ember.sizeEndScale = 0.3f;
        ember.intensityMin = 1.6f;
        ember.intensityMax = 2.8f;
        ember.spriteIndex  = _fxSprite;
        registry.emplace<CParticleEmitter>( ring, ember );
    }
}
void Game::SpawnGhost( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    _ghostBox = CreateVisualEntity( registry, float3( 0.0f, 0.0f, CLIFF_TOP + 0.45f ) );
    CPrimitive box;
    box.shape = EPrimitive::BOX;
    box.flags = EPrimitiveFlags::NONE;
    box.blend = EBlendMode::ALPHA;
    box.scale = float3( 0.0f, 0.0f, 0.0f );   // hidden until a build kind is selected
    box.color = color4( 0.3f, 1.0f, 0.5f, 0.42f );
    registry.emplace<CPrimitive>( _ghostBox, box );
    registry.emplace<CTag_NonHashTarget>( _ghostBox );   // pointer-driven — render clock

    _ghostRing = CreateVisualEntity( registry, float3( 0.0f, 0.0f, CLIFF_TOP + 0.06f ) );
    CPrimitive ring;
    ring.shape    = EPrimitive::RING_THIN;
    ring.flags    = EPrimitiveFlags::NONE;
    ring.blend    = EBlendMode::ADDITIVE;
    ring.scale    = float3( 0.0f, 0.0f, 1.0f );
    ring.color    = color4( 0.3f, 1.0f, 0.5f, 0.5f );
    ring.emissive = 1.4f;
    registry.emplace<CPrimitive>( _ghostRing, ring );
    registry.emplace<CTag_NonHashTarget>( _ghostRing );
}
void Game::SpawnStainPool( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    for( int32_t i = 0; i < STAIN_POOL; ++i )
    {// hidden until a death claims the slot; per-slot z offset keeps overlapping stains from z-fighting
        _stains[ i ] = CreateVisualEntity( registry, float3( 0.0f, 0.0f, FLOOR_TOP + 0.012f + ( static_cast<float>( i ) * 0.0002f ) ) );

        CPrimitive stain;
        stain.shape = EPrimitive::BOX;
        stain.flags = EPrimitiveFlags::NONE;
        stain.blend = EBlendMode::MULTIPLY;
        stain.scale = float3( 0.0f, 0.0f, 0.01f );
        stain.color = STAIN_COLOR;
        registry.emplace<CPrimitive>( _stains[ i ], stain );
        registry.emplace<CTag_NonHashTarget>( _stains[ i ] );
    }
    _stainNext = 0;
}

void Game::SpawnEnemy( const crApp* app, EEnemyKind kind, float2 pos )
{
    entt::registry& registry = app->ecs->registry;

    const bool  brute  = ( kind == EEnemyKind::BRUTE );
    const float radius = ( brute ? _rng.NextFloat32( BRUTE_RADIUS_MIN, BRUTE_RADIUS_MAX )
                                 : _rng.NextFloat32( GRUNT_RADIUS_MIN, GRUNT_RADIUS_MAX ) );

    // the tumble is cosmetic now — nothing reads the orientation back, so it is drawn from the sim
    // rng purely to keep the carpet from looking like a grid of clones
    const float3 spin = float3( _rng.NextFloat32( -2.5f, 2.5f ),
                                _rng.NextFloat32( -2.5f, 2.5f ),
                                _rng.NextFloat32( -2.5f, 2.5f ) );

    const entt::entity e    = CreateVisualEntity( registry, float3( pos.x, pos.y, ( FLOOR_TOP + radius ) ) );
    const int32_t      slot = CrowdAlloc( e, pos, radius, spin );

    if( slot < 0 )
    {// solver full — undo the entity rather than leave one with no agent behind it
        registry.destroy( e );
        return;
    }

    CPrimitive primitive;
    primitive.flags = EPrimitiveFlags::NONE;
    CEnemy enemy;
    enemy.slot   = slot;
    enemy.kind   = kind;
    enemy.radius = radius;

    if( brute )
    {
        const float half = ( radius * VISUAL_OVERSIZE );

        primitive.shape    = EPrimitive::ICOSAHEDRON;
        primitive.scale    = float3( half, half, half );
        primitive.color    = BRUTE_COLOR;
        primitive.emissive = 1.0f;
        enemy.hp    = BRUTE_HP;
        enemy.speed = 0.55f;
    }
    else
    {// a quarter roll as octahedra — species variety keeps the carpet reading as individuals
        const bool  octa = ( _rng.NextInt32( 0, 4 ) == 0 );
        const float half = ( radius * VISUAL_OVERSIZE * ( octa ? 1.15f : 1.0f ) );   // the octa silhouette is thinner at the same half-extent

        primitive.shape    = ( octa ? EPrimitive::OCTAHEDRON : EPrimitive::BOX );
        primitive.scale    = float3( half, half, half );
        primitive.color    = GRUNT_COLORS[ _rng.NextInt32( 0, 4 ) ];
        primitive.emissive = 0.8f;
        enemy.hp    = GRUNT_HP;
        enemy.speed = _rng.NextFloat32( 1.0f, 1.3f );
    }
    enemy.baseColor = primitive.color;
    enemy.laneBias  = _rng.NextFloat32( -1.0f, 1.0f );

    registry.emplace<CPrimitive>( e, primitive );
    registry.emplace<CEnemy>( e, enemy );
}
void Game::BuildTower( const crApp* app, ETowerKind kind, int2 cell, bool announce )
{
    entt::registry& registry = app->ecs->registry;

    const float3 center = CellCenter( cell );

    Tower tower;
    tower.kind = kind;
    tower.cell = cell;
    tower.pos  = float3( center.x, center.y, CLIFF_TOP + 0.98f );

    tower.base = CreateVisualEntity( registry, float3( center.x, center.y, CLIFF_TOP + 0.32f ) );
    CPrimitive base;
    base.shape = EPrimitive::BOX;
    base.scale = float3( 0.42f, 0.42f, 0.32f );
    base.color = BASE_COLOR;
    registry.emplace<CPrimitive>( tower.base, base );

    tower.head = CreateVisualEntity( registry, tower.pos );
    CPrimitive head;
    head.emissive = 1.7f;
    if( kind == ETowerKind::TURRET )
    {
        head.shape = EPrimitive::BOX;
        head.scale = float3( 0.32f, 0.32f, 0.26f );
        head.color = TURRET_COLOR;
    }
    else if( kind == ETowerKind::MORTAR )
    {
        head.shape = EPrimitive::SPHERE;
        head.scale = float3( 0.32f, 0.32f, 0.32f );
        head.color = MORTAR_COLOR;
    }
    else if( kind == ETowerKind::LASER )
    {
        head.shape = EPrimitive::ICOSAHEDRON;
        head.scale = float3( 0.34f, 0.34f, 0.34f );
        head.color = LASER_COLOR;
        head.emissive = 1.9f;
    }
    else if( kind == ETowerKind::TESLA )
    {
        head.shape = EPrimitive::OCTAHEDRON;
        head.scale = float3( 0.38f, 0.38f, 0.44f );
        head.color = TESLA_COLOR;
        head.emissive = 2.2f;
    }
    else
    {
        head.shape = EPrimitive::SPHERE;
        head.scale = float3( 0.34f, 0.34f, 0.34f );
        head.color = FROST_COLOR;
        head.emissive = 1.5f;
    }
    registry.emplace<CPrimitive>( tower.head, head );

    if( kind == ETowerKind::TURRET )
    {// one beam slot as the tracer flash — a particle at this body scale is sub-pixel from overview
        tower.beams[ 0 ] = CreateVisualEntity( registry, float3( tower.pos.x, tower.pos.y, BEAM_Z ) );

        CPrimitive tracer;
        tracer.shape    = EPrimitive::BEAM;
        tracer.flags    = EPrimitiveFlags::NONE;
        tracer.blend    = EBlendMode::ADDITIVE;
        tracer.scale    = float3( 0.0f, 0.0f, 0.0f );
        tracer.color    = TRACER_COLOR;
        tracer.emissive = 5.0f;
        registry.emplace<CPrimitive>( tower.beams[ 0 ], tracer );
        registry.emplace<CTag_NonHashTarget>( tower.beams[ 0 ] );
    }

    if( ( kind == ETowerKind::LASER ) || ( kind == ETowerKind::TESLA ) )
    {
        for( int32_t s = 0; s < TOWER_BEAMS; ++s )
        {
            tower.beams[ s ] = CreateVisualEntity( registry, float3( tower.pos.x, tower.pos.y, BEAM_Z ) );

            CPrimitive beam;
            beam.shape    = EPrimitive::BEAM;
            beam.flags    = EPrimitiveFlags::NONE;
            beam.blend    = EBlendMode::ADDITIVE;
            beam.scale    = float3( 0.0f, 0.0f, 0.0f );
            beam.color    = ( ( kind == ETowerKind::TESLA ) ? TESLA_BEAM_COLOR : BEAM_COLOR );
            beam.emissive = ( ( kind == ETowerKind::TESLA ) ? 3.8f : 3.4f );
            registry.emplace<CPrimitive>( tower.beams[ s ], beam );
            registry.emplace<CTag_NonHashTarget>( tower.beams[ s ] );   // cosmetic — the damage tick is the sim
        }
    }

    if( kind == ETowerKind::FROST )
    {
        const float range = TOWER_PARAMS[ static_cast<int32_t>( ETowerKind::FROST ) ].range;

        {// rime crust — a flat quad the frost shader carves into a mottled disc
            tower.patch = CreateVisualEntity( registry, float3( center.x, center.y, FLOOR_TOP + 0.03f ) );

            CPrimitive patch;
            patch.shape    = EPrimitive::BOX;
            patch.flags    = EPrimitiveFlags::NONE;
            patch.blend    = EBlendMode::ADDITIVE;   // a custom material is ignored on the ALPHA path
            patch.scale    = float3( range, range, 0.02f );
            patch.color    = FROST_COLOR;
            patch.emissive = 0.55f;
            patch.material = _frostMaterial;
            registry.emplace<CPrimitive>( tower.patch, patch );
            registry.emplace<CTag_NonHashTarget>( tower.patch );
        }

        {// aura ring on the floor — the reach made visible, pulsed by UpdateFrost
            tower.aura = CreateVisualEntity( registry, float3( center.x, center.y, FLOOR_TOP + 0.05f ) );

            CPrimitive aura;
            aura.shape    = EPrimitive::RING_THIN;
            aura.flags    = EPrimitiveFlags::NONE;
            aura.blend    = EBlendMode::ADDITIVE;
            aura.scale    = float3( range, range, 1.0f );
            aura.color    = FROST_COLOR;
            aura.emissive = 1.0f;
            registry.emplace<CPrimitive>( tower.aura, aura );
            registry.emplace<CTag_NonHashTarget>( tower.aura );

            // cold mist settling through the field — slow, wide, barely there
            CParticleEmitter mist;
            mist.color        = color4( 0.65f, 0.85f, 1.0f, 1.0f );
            mist.colorEnd     = color4( 0.10f, 0.20f, 0.35f, 1.0f );
            mist.velocity     = float3( 0.0f, 0.0f, -0.35f );
            mist.gravity      = float3( 0.0f, 0.0f, 0.0f );
            mist.drag         = 0.5f;
            mist.spread       = ( range * 0.55f );
            mist.rate         = 22.0f;
            mist.lifetimeMin  = 0.9f;
            mist.lifetimeMax  = 1.9f;
            mist.sizeMin      = 0.22f;
            mist.sizeMax      = 0.5f;
            mist.sizeEndScale = 1.6f;
            mist.intensityMin = 0.7f;
            mist.intensityMax = 1.3f;   // under the bloom threshold — mist, not a lamp
            mist.spriteIndex  = _fxSprite;
            registry.emplace<CParticleEmitter>( tower.aura, mist );
        }
    }

    _towerAt[ CellIndex( cell.x, cell.y ) ] = static_cast<int16_t>( _towers.Size() );
    _towers.Add( tower );
    ++_built;

    if( announce )
    {// arrival puff — suppressed for preset placement, where a dozen at once is just noise
        CParticleEmitter fx;
        fx.color        = head.color;
        fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
        fx.drag         = 2.0f;
        fx.spread       = 3.5f;
        fx.lifetimeMin  = 0.2f;
        fx.lifetimeMax  = 0.5f;
        fx.sizeMin      = 0.12f;
        fx.sizeMax      = 0.26f;
        fx.sizeEndScale = 0.2f;
        fx.intensityMin = 2.0f;
        fx.intensityMax = 3.5f;
        fx.spriteIndex  = _fxSprite;
        app->particles->EmitBurst( app, fx, tower.pos, 22 );
        app->audio->PlaySfx( _sfxImpact, 0.25f );
    }
}
void Game::PlaceStain( const crApp* app, float3 at, bool brute )
{
    entt::registry& registry = app->ecs->registry;

    const entt::entity e = _stains[ _stainNext ];
    _stainNext = ( ( _stainNext + 1 ) % STAIN_POOL );
    if( registry.valid( e ) == false )
        return;

    const float base = ( brute ? _rng.NextFloat32( 0.9f, 1.3f ) : _rng.NextFloat32( 0.35f, 0.65f ) );

    CPrimitive& primitive = registry.get<CPrimitive>( e );
    primitive.scale = float3( base, ( base * _rng.NextFloat32( 0.7f, 1.3f ) ), 0.01f );

    CTransform& transform = registry.get<CTransform>( e );
    transform.current.p.x = at.x;
    transform.current.p.y = at.y;
    transform.current.q   = crMath::QuatFromAxisAngle( float3( 0.0f, 0.0f, 1.0f ), _rng.NextFloat32( 0.0f, 2.0f * crMath::PI ) );
    transform.previous    = transform.current;
}

void Game::UpdateCommands( const crApp* app )
{
    if( _pendingCheatGold != 0 )
    {
        _gold += _pendingCheatGold;
        _pendingCheatGold = 0;
    }

    for( int32_t i = 0; i < _pendingBuilds.Size(); ++i )
    {
        const PendingBuild& build = _pendingBuilds.At( i );
        const int32_t cost = TOWER_PARAMS[ static_cast<int32_t>( build.kind ) ].cost;

        if( ( ValidBuildCell( build.cell ) == false ) || ( _gold < cost ) )
            continue;

        _gold -= cost;
        BuildTower( app, build.kind, build.cell );
    }
    _pendingBuilds.Clear();
}
void Game::UpdateEconomy( const crApp* app )
{
    const uint64_t step = app->SimulationStepIndex();
    if( step >= _nextTrickleStep )
    {
        _gold += 1;
        _nextTrickleStep = ( step + TRICKLE_STEPS );
    }
}
void Game::UpdateSpawner( const crApp* app )
{
    const uint64_t step = app->SimulationStepIndex();

    if( ( _waveActive == false ) && ( ( _wave + 1 ) < WAVE_COUNT ) && ( step >= _nextWaveStep ) )
    {
        ++_wave;
        _waveActive = true;

        const WaveDef& wave     = WAVES[ _wave ];
        const uint64_t durSteps = static_cast<uint64_t>( crMath::Round( wave.duration / crApp::FIXED_TIMESTEP ) );

        _gruntsLeft = wave.grunts;
        _brutesLeft = wave.brutes;
        _gruntRate  = ( static_cast<float>( wave.grunts ) / static_cast<float>( durSteps ) );
        _bruteRate  = ( static_cast<float>( wave.brutes ) / static_cast<float>( durSteps ) );
    }

    if( _waveActive )
    {
        _gruntAcc += _gruntRate;
        while( ( _gruntAcc >= 1.0f ) && ( _gruntsLeft > 0 ) )
        {// steady stream — one walker at a time, portals in rotation
            _gruntAcc -= 1.0f;
            ++_gruntPending[ _spawnRR++ % _spawnCount ];
            --_gruntsLeft;
        }

        _bruteAcc += _bruteRate;
        while( ( _bruteAcc >= 1.0f ) && ( _brutesLeft > 0 ) )
        {
            _bruteAcc -= 1.0f;
            ++_brutePending[ _spawnRR++ % _spawnCount ];
            --_brutesLeft;
        }

        if( ( _gruntsLeft == 0 ) && ( _brutesLeft == 0 ) )
        {
            _waveActive   = false;
            _nextWaveStep = ( step + WAVE_GAP_STEPS );
        }
    }

    for( int32_t s = 0; s < _spawnCount; ++s )
    {// drain the queues a few bodies per step, none into a pocket without room — materializing a
     // whole burst at once interpenetrates the pile, and THAT is what blows the solver's step cost
        if( ( _gruntPending[ s ] == 0 ) && ( _brutePending[ s ] == 0 ) )
            continue;
        if( _agentCount >= ENEMY_CAP )
            break;

        const float3 at = CellCenter( _spawnCells[ s ] );
        if( PortalCrowded( app, at ) )
            continue;

        int32_t emit = ( ( _gruntPending[ s ] < SPAWN_PER_STEP_GRUNT ) ? _gruntPending[ s ] : SPAWN_PER_STEP_GRUNT );
        for( int32_t k = 0; ( k < emit ) && ( _agentCount < ENEMY_CAP ); ++k )
        {
            SpawnEnemy( app, EEnemyKind::GRUNT, float2( at.x + _rng.NextFloat32( -2.0f, 2.0f ), at.y + _rng.NextFloat32( -2.0f, 2.0f ) ) );
            --_gruntPending[ s ];
        }

        emit = ( ( _brutePending[ s ] < SPAWN_PER_STEP_BRUTE ) ? _brutePending[ s ] : SPAWN_PER_STEP_BRUTE );
        for( int32_t k = 0; ( k < emit ) && ( _agentCount < ENEMY_CAP ); ++k )
        {
            SpawnEnemy( app, EEnemyKind::BRUTE, float2( at.x + _rng.NextFloat32( -1.4f, 1.4f ), at.y + _rng.NextFloat32( -1.4f, 1.4f ) ) );
            --_brutePending[ s ];
        }
    }
}
bool Game::PortalCrowded( const crApp* app, float3 at )
{
    ( void )app;
    return ( CrowdQuery( float2( at.x, at.y ), PORTAL_CROWD_RADIUS ) >= PORTAL_CROWD_CAP );
}
void Game::UpdateEnemies( const crApp* app )
{
    if( _agentCount == 0 )
        return;

    ProfileBegin( EProfSection::STEER_PAR );
    {// parallel — each range touches only its own agents' velocity and their own components, so the
     // outcome does not depend on how the pool split the work. nothing structural happens here
        crTasks::ParallelFor( _agentCount, WORK_GRAIN, SteerTask, this );
    }
    ProfileEnd( EProfSection::STEER_PAR );

    ProfileBegin( EProfSection::STEER_APPLY );
    for( int32_t i = _agentCount - 1; i >= 0; --i )
    {// serial tail — the only place agents are destroyed. backward, so the swap-remove inside
     // CrowdFree can never move an entry past the cursor
        if( _agentFlags[ i ] == 0 )
            continue;

        const entt::entity e     = _agentOwner[ i ];
        const CEnemy&      enemy = app->ecs->registry.get<CEnemy>( e );

        DamageGate( app, ( ( enemy.kind == EEnemyKind::BRUTE ) ? BRUTE_LEAK : GRUNT_LEAK ) );
        ++_leaks;
        DestroyEnemy( app, e, false );
    }
    ProfileEnd( EProfSection::STEER_APPLY );
}
void Game::SteerRange( int32_t begin, int32_t end )
{
    entt::registry& registry = _app->ecs->registry;

    const uint64_t step = _app->SimulationStepIndex();

    for( int32_t i = begin; i < end; ++i )
    {
        const entt::entity e = _agentOwner[ i ];

        CEnemy& enemy = registry.get<CEnemy>( e );

        // status overlay grammar: base hue -> frost tint while slowed -> white flash on hits.
        // the primitive is repainted only when something changed — 12k unconditional writes add up
        const uint8_t frosted     = ( ( step < enemy.slowUntilStep ) ? 1 : 0 );
        const color4  displayBase = ( frosted ? crMath::LerpColor( enemy.baseColor, FROST_TINT, 0.6f ) : enemy.baseColor );

        if( enemy.flash > 0.0f )
        {// hit flash — snap to white, decay back to the display hue
            enemy.flash = crMath::Clamp( enemy.flash - ( 8.0f * crApp::FIXED_TIMESTEP ), 0.0f, 1.0f );
            registry.get<CPrimitive>( e ).color = crMath::LerpColor( displayBase, color4( 1.0f, 1.0f, 1.0f, 1.0f ), enemy.flash );
        }
        else if( frosted != enemy.frosted )
            registry.get<CPrimitive>( e ).color = displayBase;
        enemy.frosted = frosted;

        const float2 pos = _hot[ i ].pos;

        // straight-line range to the gate, NOT the flow field: the field is a travel TIME inflated
        // by crowding, so reading the taper or the leak test off it would make both drift with the
        // jams. on a radial map the euclidean range tracks the path length closely enough
        const float gdx    = ( pos.x - _gatePos.x );
        const float gdy    = ( pos.y - _gatePos.y );
        const float gRange = crMath::Sqrt( ( gdx * gdx ) + ( gdy * gdy ) );

        if( gRange <= GATE_REACH )
        {
            _agentFlags[ i ] = 1;
            continue;
        }
        _agentFlags[ i ] = 0;

        // bilinear, not a per-cell lookup: neighbors standing in one cell would otherwise share a
        // single vector and the column steps between cells instead of flowing
        const float2 dir = SampleFlow( pos.x, pos.y );

        // fast out of the portal, slow near the gate — the rear catches the front and the
        // column compresses; the lane bias fans the file into a band that fills the width
        const float far = crMath::Clamp( ( gRange / SLOW_RAMP_CELLS ), 0.0f, 1.0f );

        // ease off where it is already packed. the density field is rebuilt anyway, so this costs
        // one lookup and it is what stops a jam from setting like concrete
        const int2    cell  = WorldToCell( pos.x, pos.y );
        const float   crowd = crMath::Clamp( ( 1.0f / ( 1.0f + ( _density[ CellIndex( cell.x, cell.y ) ] * CROWD_EASE ) ) ), CROWD_FLOOR, 1.0f );

        const float speed = ( enemy.speed
                            * ( SLOW_NEAR + ( ( 1.0f - SLOW_NEAR ) * far ) )
                            * ( frosted ? FROST_FACTOR : 1.0f )
                            * crowd );

        const float dx = ( dir.x - ( dir.y * enemy.laneBias * LANE_SPREAD ) );
        const float dy = ( dir.y + ( dir.x * enemy.laneBias * LANE_SPREAD ) );

        // blend toward the flow, keep the rest. the kept part is what the solver wrote back last
        // step, so shoves survive the steering and the crowd jostles instead of marching
        _agentVel[ i ] = float2( crMath::Lerp( _agentVel[ i ].x, ( dx * speed ), STEER_BLEND ),
                                 crMath::Lerp( _agentVel[ i ].y, ( dy * speed ), STEER_BLEND ) );
    }
}
void Game::CrowdStep( const crApp* app )
{
    if( _agentCount == 0 )
        return;

    crTasks::ParallelFor( _agentCount, WORK_GRAIN, IntegrateTask, this );

    ProfileBegin( EProfSection::CROWD_GRID );
    CrowdBuildGrid();
    ProfileEnd( EProfSection::CROWD_GRID );

    SDL_memset( _relaxCandidates, 0, sizeof( _relaxCandidates ) );
    SDL_memset( _relaxContacts,   0, sizeof( _relaxContacts ) );

    for( int32_t iter = 0; iter < RELAX_ITERATIONS; ++iter )
    {// Jacobi: every agent reads the shared positions and writes only its own delta, so the pass is
     // order-free. the grid is NOT rebuilt between iterations — a pass moves an agent by far less
     // than a cell, so the 3x3 neighbourhood it was sorted into still holds every possible contact
        ProfileBegin( EProfSection::CROWD_RELAX );
        crTasks::ParallelFor( _agentCount, WORK_GRAIN, RelaxTask, this );
        ProfileEnd( EProfSection::CROWD_RELAX );

        ProfileBegin( EProfSection::CROWD_APPLY );
        crTasks::ParallelFor( _agentCount, WORK_GRAIN, ApplyTask, this );
        ProfileEnd( EProfSection::CROWD_APPLY );
    }

    ProfileBegin( EProfSection::CROWD_FINISH );
    crTasks::ParallelFor( _agentCount, WORK_GRAIN, FinishTask, this );
    ProfileEnd( EProfSection::CROWD_FINISH );

    {// fold the per-worker tallies down to "per agent, per iteration" — the ratio between these two
     // is what says whether the loop is dominated by rejects or by real contacts
        uint64_t candidates = 0;
        uint64_t contacts   = 0;

        for( int32_t t = 0; t < MAX_WORK_THREADS; ++t )
        {
            candidates += _relaxCandidates[ t ];
            contacts   += _relaxContacts  [ t ];
        }

        static constexpr float BLEND = 0.05f;

        const float divisor = ( static_cast<float>( _agentCount ) * static_cast<float>( RELAX_ITERATIONS ) );
        _avgCandidates += ( ( ( static_cast<float>( candidates ) / divisor ) - _avgCandidates ) * BLEND );
        _avgContacts   += ( ( ( static_cast<float>( contacts )   / divisor ) - _avgContacts )   * BLEND );
    }

    ( void )app;
}
void Game::IntegrateRange( int32_t begin, int32_t end )
{
    for( int32_t i = begin; i < end; ++i )
    {
        _agentPrev[ i ] = _hot[ i ].pos;
        _hot[ i ].pos  = float2( ( _hot[ i ].pos.x + ( _agentVel[ i ].x * crApp::FIXED_TIMESTEP ) ),
                                  ( _hot[ i ].pos.y + ( _agentVel[ i ].y * crApp::FIXED_TIMESTEP ) ) );
    }
}
void Game::RelaxRange( int32_t begin, int32_t end, uint32_t threadIndex )
{
    // tallied per CELL and per ACCEPTED contact, never per rejected candidate — the innermost
    // comparison is the thing being measured, so it must not grow an instruction
    uint32_t candidates = 0;
    uint32_t contacts   = 0;

    // NOTE: the range here indexes the SORTED mirror, not agent slots. everything inside stays in
    // that space — the scatter back to slots happens in APPLY
    for( int32_t a = begin; a < end; ++a )
    {
        const CrowdHot& ha = _hotSorted[ a ];

        const float2 pi = ha.pos;
        const float  ri = ha.radius;
        const float  wi = ha.invMass;

        float dx = 0.0f;
        float dy = 0.0f;

        const int2 cell = WorldToCell( pi.x, pi.y );

        for( int32_t oy = -1; oy <= 1; ++oy )
        {
            const int32_t cy = ( cell.y + oy );
            if( ( cy < 0 ) || ( cy >= MAP_H ) )
                continue;

            for( int32_t ox = -1; ox <= 1; ++ox )
            {
                const int32_t cx = ( cell.x + ox );
                if( ( cx < 0 ) || ( cx >= MAP_W ) )
                    continue;

                const int32_t c     = CellIndex( cx, cy );
                const int32_t first = _gridStart[ c ];
                const int32_t last  = _gridStart[ c + 1 ];

                candidates += static_cast<uint32_t>( last - first );

                // one cell's agents are CONSECUTIVE in the mirror, so this walk is sequential
                for( int32_t b = first; b < last; ++b )
                {
                    if( b == a )
                        continue;

                    const CrowdHot& hb = _hotSorted[ b ];

                    const float ex  = ( pi.x - hb.pos.x );
                    const float ey  = ( pi.y - hb.pos.y );
                    const float sum = ( ri + hb.radius );
                    const float sq  = ( ( ex * ex ) + ( ey * ey ) );
                    if( sq >= ( sum * sum ) )
                        continue;

                    ++contacts;   // past the reject: this pair really is touching

                    if( sq < AGENT_EPSILON )
                    {// exactly coincident: no normal exists. split them along X by mirror order,
                     // which gives the two agents opposite signs and stays reproducible
                        const float share = ( wi / ( wi + hb.invMass ) );
                        dx += ( ( ( a < b ) ? 1.0f : -1.0f ) * sum * CONTACT_STIFFNESS * share );
                        continue;
                    }

                    const float d = crMath::Sqrt( sq );

                    // the whole correction folded onto ONE divide. written out it is
                    //   ( (sum - d) * stiffness ) * ( wi / (wi + wj) ) * ( e / d )
                    // which is three divides if taken literally; multiplying the two denominators
                    // first gives the same value for one. divides are ~10-15 cycles and barely
                    // pipeline, and this line runs once per actual contact — it is the hot spot
                    const float denom = ( ( wi + hb.invMass ) * d );
                    const float k     = ( ( ( sum - d ) * CONTACT_STIFFNESS * wi ) / denom );

                    dx += ( ex * k );
                    dy += ( ey * k );
                }
            }
        }

        _deltaSorted[ a ] = float2( dx, dy );
    }

    if( threadIndex < static_cast<uint32_t>( MAX_WORK_THREADS ) )
    {
        _relaxCandidates[ threadIndex ] += candidates;
        _relaxContacts  [ threadIndex ] += contacts;
    }
}
void Game::ApplyRange( int32_t begin, int32_t end )
{
    // also mirror-indexed: apply where the delta was computed, and keep the mirror in step so the
    // next relax iteration reads the moved positions without re-sorting
    for( int32_t a = begin; a < end; ++a )
    {
        CrowdHot& ha = _hotSorted[ a ];

        const float2 moved = float2( ( ha.pos.x + _deltaSorted[ a ].x ),
                                     ( ha.pos.y + _deltaSorted[ a ].y ) );

        // terrain last, every iteration: contacts are soft and negotiable, the canyon wall is not
        ha.pos = PushOutOfTerrain( moved, ha.radius );

        _hot[ _gridAgents[ a ] ].pos = ha.pos;
    }
}
void Game::FinishRange( int32_t begin, int32_t end )
{
    entt::registry& registry = _app->ecs->registry;

    static constexpr float INV_DT = ( 1.0f / crApp::FIXED_TIMESTEP );

    for( int32_t i = begin; i < end; ++i )
    {
        // velocity comes back out of the motion that actually happened, which is how a shove
        // propagates: an agent stopped by the pile in front carries that into the next step
        float vx = ( ( _hot[ i ].pos.x - _agentPrev[ i ].x ) * INV_DT );
        float vy = ( ( _hot[ i ].pos.y - _agentPrev[ i ].y ) * INV_DT );

        const float sq = ( ( vx * vx ) + ( vy * vy ) );
        if( sq > ( CROWD_MAX_SPEED * CROWD_MAX_SPEED ) )
        {// a deep spawn overlap unwinds in one step and would otherwise launch the agent
            const float scale = ( CROWD_MAX_SPEED / crMath::Sqrt( sq ) );
            vx *= scale;
            vy *= scale;
        }

        _agentVel[ i ] = float2( ( vx * CROWD_DAMPING ), ( vy * CROWD_DAMPING ) );

        CTransform& transform = registry.get<CTransform>( _agentOwner[ i ] );
        transform.current.p.x = _hot[ i ].pos.x;
        transform.current.p.y = _hot[ i ].pos.y;
        transform.current.q   = crMath::IntegrateRotation( transform.current.q, _agentSpin[ i ], crApp::FIXED_TIMESTEP );
    }
}
float2 Game::PushOutOfTerrain( float2 pos, float radius ) const
{
    const int2 cell = WorldToCell( pos.x, pos.y );

    // one byte answers what the nine-cell walk below would spend nine loads discovering, and out in
    // the open lanes — where most of the swarm is — the answer is no
    if( _nearSolid[ CellIndex( cell.x, cell.y ) ] == 0 )
        return pos;

    for( int32_t oy = -1; oy <= 1; ++oy )
    {
        const int32_t cy = ( cell.y + oy );
        if( ( cy < 0 ) || ( cy >= MAP_H ) )
            continue;

        for( int32_t ox = -1; ox <= 1; ++ox )
        {
            const int32_t cx = ( cell.x + ox );
            if( ( cx < 0 ) || ( cx >= MAP_W ) )
                continue;

            const int32_t c = CellIndex( cx, cy );
            if( ( _high[ c ] == 0 ) && ( _chamfer[ c ] == 0 ) )
                continue;

            const float minX = ( ( static_cast<float>( cx ) - ( static_cast<float>( MAP_W ) * 0.5f ) ) * CELL );
            const float minY = ( ( static_cast<float>( cy ) - ( static_cast<float>( MAP_H ) * 0.5f ) ) * CELL );
            const float maxX = ( minX + CELL );
            const float maxY = ( minY + CELL );

            const float qx = crMath::Clamp( pos.x, minX, maxX );
            const float qy = crMath::Clamp( pos.y, minY, maxY );
            const float ex = ( pos.x - qx );
            const float ey = ( pos.y - qy );
            const float sq = ( ( ex * ex ) + ( ey * ey ) );

            if( sq >= ( radius * radius ) )
                continue;   // does not reach this cell at all

            if( _chamfer[ c ] != 0 )
            {// only half of this cell is solid — the 45 deg cut through its centre. treat that cut
             // as a plane whose normal points at the corner the chamfer opened up
                const float cornerX = ( ( ( _chamfer[ c ] == 1 ) || ( _chamfer[ c ] == 4 ) ) ? 0.7071f : -0.7071f );
                const float cornerY = ( ( ( _chamfer[ c ] == 1 ) || ( _chamfer[ c ] == 2 ) ) ? 0.7071f : -0.7071f );

                const float cxw = ( minX + ( CELL * 0.5f ) );
                const float cyw = ( minY + ( CELL * 0.5f ) );
                const float s   = ( ( ( pos.x - cxw ) * cornerX ) + ( ( pos.y - cyw ) * cornerY ) );

                if( s < radius )
                {
                    pos.x += ( cornerX * ( radius - s ) );
                    pos.y += ( cornerY * ( radius - s ) );
                }
                continue;
            }

            if( sq > AGENT_EPSILON )
            {
                const float d = crMath::Sqrt( sq );
                pos.x += ( ( ex / d ) * ( radius - d ) );
                pos.y += ( ( ey / d ) * ( radius - d ) );
                continue;
            }

            {// centre is inside the block — leave by the nearest face, or it stays trapped forever
                const float toMinX = ( pos.x - minX );
                const float toMaxX = ( maxX - pos.x );
                const float toMinY = ( pos.y - minY );
                const float toMaxY = ( maxY - pos.y );

                float best = toMinX;
                int32_t face = 0;
                if( toMaxX < best ) { best = toMaxX; face = 1; }
                if( toMinY < best ) { best = toMinY; face = 2; }
                if( toMaxY < best ) { best = toMaxY; face = 3; }

                if( face == 0 )
                    pos.x = ( minX - radius );
                else if( face == 1 )
                    pos.x = ( maxX + radius );
                else if( face == 2 )
                    pos.y = ( minY - radius );
                else
                    pos.y = ( maxY + radius );
            }
        }
    }

    return pos;
}
void Game::CrowdBuildGrid()
{
    // counting sort into the map grid. cells are 1 m and the widest agent is under that, so every
    // possible contact lives in the 3x3 neighbourhood — no other broadphase is needed
    SDL_memset( _gridStart, 0, sizeof( _gridStart ) );

    for( int32_t i = 0; i < _agentCount; ++i )
    {
        const int2 cell = WorldToCell( _hot[ i ].pos.x, _hot[ i ].pos.y );
        ++_gridStart[ CellIndex( cell.x, cell.y ) + 1 ];
    }

    for( int32_t c = 0; c < CELL_COUNT; ++c )
        _gridStart[ c + 1 ] += _gridStart[ c ];

    // scatter in index order, so a cell's agents always come out ascending — same input, same
    // ordering, on any platform. _gridStart doubles as the write cursor and is repaired below
    for( int32_t i = 0; i < _agentCount; ++i )
    {
        const int2 cell = WorldToCell( _hot[ i ].pos.x, _hot[ i ].pos.y );
        _gridAgents[ _gridStart[ CellIndex( cell.x, cell.y ) ]++ ] = i;
    }

    for( int32_t c = CELL_COUNT; c > 0; --c )
        _gridStart[ c ] = _gridStart[ c - 1 ];
    _gridStart[ 0 ] = 0;

    // pack the mirror: one sequential write pass buys RELAX sequential reads for the rest of the step
    for( int32_t k = 0; k < _agentCount; ++k )
        _hotSorted[ k ] = _hot[ _gridAgents[ k ] ];
}
int32_t Game::CrowdAlloc( entt::entity owner, float2 pos, float radius, float3 spin )
{
    if( _agentCount >= MAX_AGENTS )
        return -1;

    const int32_t slot = _agentCount++;

    _hot[ slot ].pos     = pos;
    _hot[ slot ].radius  = radius;
    _hot[ slot ].invMass = ( 1.0f / ( radius * radius ) );   // mass goes with area — brutes plough, grunts scatter

    _agentPrev  [ slot ] = pos;
    _agentVel   [ slot ] = float2( 0.0f, 0.0f );
    _agentSpin  [ slot ] = spin;
    _agentFlags [ slot ] = 0;
    _agentOwner [ slot ] = owner;

    return slot;
}
void Game::CrowdFree( int32_t slot )
{
    const int32_t last = --_agentCount;

    if( slot != last )
    {
        _hot        [ slot ] = _hot        [ last ];
        _agentPrev  [ slot ] = _agentPrev  [ last ];
        _agentVel   [ slot ] = _agentVel   [ last ];
        _agentSpin  [ slot ] = _agentSpin  [ last ];
        _agentFlags [ slot ] = _agentFlags [ last ];
        _agentOwner [ slot ] = _agentOwner [ last ];

        // the moved agent's entity still points at the old slot — that link is the only way back
        _app->ecs->registry.get<CEnemy>( _agentOwner[ slot ] ).slot = slot;
    }
}
int32_t Game::CrowdQuery( float2 center, float radius )
{
    const int2  lo = WorldToCell( ( center.x - radius ), ( center.y - radius ) );
    const int2  hi = WorldToCell( ( center.x + radius ), ( center.y + radius ) );
    const float sq = ( radius * radius );

    int32_t found = 0;

    for( int32_t cy = lo.y; cy <= hi.y; ++cy )
    {
        for( int32_t cx = lo.x; cx <= hi.x; ++cx )
        {
            const int32_t c    = CellIndex( cx, cy );
            const int32_t last = _gridStart[ c + 1 ];

            for( int32_t k = _gridStart[ c ]; k < last; ++k )
            {
                if( found >= QUERY_MAX )
                    return found;   // capped — callers treat this as "enough of them"

                const int32_t j  = _gridAgents[ k ];
                const float   ex = ( _hot[ j ].pos.x - center.x );
                const float   ey = ( _hot[ j ].pos.y - center.y );
                if( ( ( ex * ex ) + ( ey * ey ) ) <= sq )
                    _queryHits[ found++ ] = j;
            }
        }
    }

    return found;
}
bool Game::RaycastTerrain( float2 from, float2 dir, float maxDist, float2* hitPos, float2* hitNormal ) const
{
    // Amanatides-Woo traversal of the plateau mask. cell size is 1 m, so grid units are world units
    float gx = ( from.x + ( static_cast<float>( MAP_W ) * 0.5f ) );
    float gy = ( from.y + ( static_cast<float>( MAP_H ) * 0.5f ) );

    gx = crMath::Clamp( gx, 0.0f, ( static_cast<float>( MAP_W ) - 0.001f ) );
    gy = crMath::Clamp( gy, 0.0f, ( static_cast<float>( MAP_H ) - 0.001f ) );

    int32_t ix = static_cast<int32_t>( gx );
    int32_t iy = static_cast<int32_t>( gy );

    const int32_t stepX = ( ( dir.x >= 0.0f ) ? 1 : -1 );
    const int32_t stepY = ( ( dir.y >= 0.0f ) ? 1 : -1 );

    static constexpr float FAR_AWAY = 1.0e18f;

    const float absX = crMath::Abs( dir.x );
    const float absY = crMath::Abs( dir.y );

    const float deltaX = ( ( absX > 0.00001f ) ? ( 1.0f / absX ) : FAR_AWAY );
    const float deltaY = ( ( absY > 0.00001f ) ? ( 1.0f / absY ) : FAR_AWAY );

    float nextX = ( ( absX > 0.00001f )
                  ? ( ( ( dir.x >= 0.0f ) ? ( static_cast<float>( ix + 1 ) - gx ) : ( gx - static_cast<float>( ix ) ) ) * deltaX )
                  : FAR_AWAY );
    float nextY = ( ( absY > 0.00001f )
                  ? ( ( ( dir.y >= 0.0f ) ? ( static_cast<float>( iy + 1 ) - gy ) : ( gy - static_cast<float>( iy ) ) ) * deltaY )
                  : FAR_AWAY );

    for( int32_t guard = 0; guard < ( MAP_W + MAP_H ); ++guard )
    {
        float  travelled = 0.0f;
        float2 normal    = float2( 0.0f, 0.0f );

        if( nextX < nextY )
        {
            ix        += stepX;
            travelled  = nextX;
            nextX     += deltaX;
            normal     = float2( -static_cast<float>( stepX ), 0.0f );
        }
        else
        {
            iy        += stepY;
            travelled  = nextY;
            nextY     += deltaY;
            normal     = float2( 0.0f, -static_cast<float>( stepY ) );
        }

        if( travelled > maxDist )
            return false;

        if( ( ix < 0 ) || ( ix >= MAP_W ) || ( iy < 0 ) || ( iy >= MAP_H ) )
            return false;   // the rim is solid, so this is unreachable in practice

        if( _high[ CellIndex( ix, iy ) ] != 0 )
        {
            *hitPos    = float2( ( from.x + ( dir.x * travelled ) ), ( from.y + ( dir.y * travelled ) ) );
            *hitNormal = normal;
            return true;
        }
    }

    return false;
}
void Game::UpdateTowers( const crApp* app )
{
    const uint64_t step = app->SimulationStepIndex();

    for( int32_t i = 0; i < _towers.Size(); ++i )
    {
        Tower* tower = &_towers.At( i );

        if( tower->kind == ETowerKind::LASER )
        {
            UpdateLaser( app, tower );
            continue;
        }
        if( tower->kind == ETowerKind::FROST )
        {
            UpdateFrost( app, tower );
            continue;
        }
        if( tower->kind == ETowerKind::TESLA )
        {// the bolt decays instead of cutting out: it thins, dims, and flickers as it dies —
         // a hard switch-off reads as a bug, a fade reads as electricity earthing itself
            entt::registry& registry = app->ecs->registry;
            const uint64_t  age      = ( step - tower->tracerStep );

            if( age >= TESLA_FLASH_STEPS )
                HideBeams( app, tower, 0 );
            else
            {
                const float k       = ( 1.0f - ( static_cast<float>( age ) / static_cast<float>( TESLA_FLASH_STEPS ) ) );
                const float flicker = ( 0.55f + ( 0.45f * crMath::Sin( ( static_cast<float>( step ) * 2.7f ) + static_cast<float>( tower->cell.x ) ) ) );

                for( int32_t s = 0; s < TOWER_BEAMS; ++s )
                {
                    if( registry.valid( tower->beams[ s ] ) == false )
                        continue;

                    CPrimitive& beam = registry.get<CPrimitive>( tower->beams[ s ] );
                    if( beam.scale.x <= 0.0f )
                        continue;   // slot unused by the last chain

                    beam.scale.y  = ( TESLA_BEAM_WIDTH * k );
                    beam.scale.z  = ( TESLA_BEAM_WIDTH * k );
                    beam.emissive = ( 3.8f * k * flicker );
                }
            }

            if( step >= tower->nextFireStep )
                FireTesla( app, tower );
            continue;
        }
        if( tower->kind == ETowerKind::TURRET )
        {// fly the bolt down the shot line — a short slug advancing per step, not a lit line
            entt::registry& registry = app->ecs->registry;
            if( registry.valid( tower->beams[ 0 ] ) )
            {
                const uint64_t age = ( step - tower->tracerStep );
                if( age >= TURRET_BOLT_STEPS )
                    registry.get<CPrimitive>( tower->beams[ 0 ] ).scale = float3( 0.0f, 0.0f, 0.0f );
                else
                {
                    const float t = ( static_cast<float>( age + 1 ) / static_cast<float>( TURRET_BOLT_STEPS ) );

                    const float3 head = crMath::Lerp3( tower->boltFrom, tower->boltTo, t );
                    const float3 span = float3( ( tower->boltTo.x - tower->boltFrom.x ),
                                                ( tower->boltTo.y - tower->boltFrom.y ),
                                                ( tower->boltTo.z - tower->boltFrom.z ) );
                    const float  len  = crMath::Sqrt( ( span.x * span.x ) + ( span.y * span.y ) + ( span.z * span.z ) );

                    float tailBack = ( TURRET_BOLT_LEN / ( ( len > 0.001f ) ? len : 1.0f ) );
                    if( tailBack > t )
                        tailBack = t;   // the slug does not stick out behind the muzzle

                    const float3 tail = crMath::Lerp3( tower->boltFrom, tower->boltTo, ( t - tailBack ) );
                    SetBeam( app, tower->beams[ 0 ], tail, head, 0.09f );
                    registry.get<CPrimitive>( tower->beams[ 0 ] ).emissive = 5.0f;
                }
            }
        }

        if( ( tower->kind == ETowerKind::TURRET ) && ( tower->burstLeft > 0 ) )
        {// mid-burst — rounds run on their own faster clock
            if( step >= tower->nextTickStep )
                FireTurret( app, tower );
            continue;
        }

        if( step < tower->nextFireStep )
            continue;

        if( tower->kind == ETowerKind::TURRET )
            FireTurret( app, tower );
        else
            FireMortar( app, tower );
    }
}
void Game::UpdateShells( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();

    for( int32_t i = _shells.Size() - 1; i >= 0; --i )
    {
        const PendingShell& shell = _shells.At( i );

        if( step < shell.impactStep )
        {// flight — parametric arc from the muzzle to the marked floor point
            const float t = ( 1.0f - ( static_cast<float>( shell.impactStep - step ) / static_cast<float>( SHELL_FLIGHT_STEPS ) ) );

            if( registry.valid( shell.shell ) )
            {
                CTransform& transform = registry.get<CTransform>( shell.shell );
                transform.current.p.x = crMath::Lerp( shell.from.x, shell.to.x, t );
                transform.current.p.y = crMath::Lerp( shell.from.y, shell.to.y, t );
                transform.current.p.z = crMath::Lerp( shell.from.z, shell.to.z, t ) + ( SHELL_ARC * 4.0f * t * ( 1.0f - t ) );
            }
            if( registry.valid( shell.marker ) )
            {// marker tightens as the shell closes in
                CPrimitive& marker = registry.get<CPrimitive>( shell.marker );
                marker.scale    = float3( ( MORTAR_AOE - ( t * 1.4f ) ), ( MORTAR_AOE - ( t * 1.4f ) ), 1.0f );
                marker.emissive = ( 1.6f + ( t * 2.0f ) );
            }
            continue;
        }

        const float3 at = shell.to;

        if( registry.valid( shell.shell ) )
            registry.destroy( shell.shell );
        if( registry.valid( shell.marker ) )
            registry.destroy( shell.marker );

        {// AoE: damage plus a radial shove — the crowd craters open.
         // the hit list is snapshotted BEFORE any of it dies, because killing an agent swap-removes
         // a slot and every index after it would shift under the loop
            const int32_t hits = CrowdQuery( float2( at.x, at.y ), MORTAR_AOE );

            for( int32_t k = 0; k < hits; ++k )
            {
                const int32_t slot = _queryHits[ k ];

                _queryOwners[ k ] = _agentOwner[ slot ];

                const float dx = ( _hot[ slot ].pos.x - at.x );
                const float dy = ( _hot[ slot ].pos.y - at.y );
                const float sq = ( ( dx * dx ) + ( dy * dy ) );
                if( sq < 0.0001f )
                    continue;

                const float len = crMath::Sqrt( sq );
                _agentVel[ slot ] = float2( ( _agentVel[ slot ].x + ( ( dx / len ) * MORTAR_KICK ) ),
                                            ( _agentVel[ slot ].y + ( ( dy / len ) * MORTAR_KICK ) ) );
            }

            for( int32_t k = 0; k < hits; ++k )
                DamageEnemy( app, _queryOwners[ k ], MORTAR_DAMAGE );
        }

        {// impact — a bright core, then shards and a hard ripple
            EmitFlash( app, float3( at.x, at.y, 0.35f ), color4( 1.0f, 0.75f, 0.35f, 1.0f ), ( FX_CORE * 1.8f ), 5 );

            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, -6.0f );
            fx.drag            = 1.5f;
            fx.spread          = 8.0f;
            fx.lifetimeMin     = 0.2f;
            fx.lifetimeMax     = 0.55f;
            fx.sizeMin         = 0.11f;
            fx.sizeMax         = 0.26f;
            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;
            app->particles->EmitBurst( app, fx, float3( at.x, at.y, 0.3f ), 50 );

            _app->graphics->PostProcess()->QueueRipple( float3( at.x, at.y, 0.0f ), ( MORTAR_AOE * 1.3f ), 0.35f, 0.016f );
            app->audio->PlaySfx( _sfxImpact, 0.5f );
        }

        {// shockwave hoop — expands and burns out over RING_LIFE_STEPS (UpdateFxRings drives it)
            FxRing fx;
            fx.bornStep = step;
            fx.ring     = CreateVisualEntity( registry, float3( at.x, at.y, 0.12f ) );

            CPrimitive ring;
            ring.shape    = EPrimitive::RING_THIN;
            ring.flags    = EPrimitiveFlags::NONE;
            ring.blend    = EBlendMode::ADDITIVE;
            ring.scale    = float3( 0.8f, 0.8f, 1.0f );
            ring.color    = MORTAR_COLOR;
            ring.emissive = 3.0f;
            registry.emplace<CPrimitive>( fx.ring, ring );
            registry.emplace<CTag_NonHashTarget>( fx.ring );

            _fxRings.Add( fx );
        }

        PlaceStain( app, float3( at.x, at.y, 0.0f ), true );   // scorch — the bombardment marks the field

        _shells.At( i ) = _shells.At( _shells.Size() - 1 );
        _shells.Pop();
    }
}
void Game::CheckEndConditions( const crApp* app )
{
    ( void )app;

    if( _gateHp <= 0 )
    {
        _victory = false;
        RequestState( EGameState::RESULT );
        return;
    }

    if( ( _wave == ( WAVE_COUNT - 1 ) ) && ( _waveActive == false ) &&
        ( _agentCount == 0 ) && ( _shells.Size() == 0 ) )
    {
        for( int32_t s = 0; s < _spawnCount; ++s )
        {// queued spawns still owed — the field is not clear yet
            if( ( _gruntPending[ s ] != 0 ) || ( _brutePending[ s ] != 0 ) )
                return;
        }

        _victory = true;
        RequestState( EGameState::RESULT );
    }
}

void Game::FireTurret( const crApp* app, Tower* tower )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();
    const TowerParams& params = TOWER_PARAMS[ static_cast<int32_t>( ETowerKind::TURRET ) ];

    if( tower->burstLeft == 0 )
    {// burst start — acquire once, then hold the mark for the whole magazine
        tower->target = FindTarget( app, float2( tower->pos.x, tower->pos.y ), params.range, params.minRange );
        if( tower->target == entt::null )
        {
            tower->nextFireStep = ( step + RETRY_STEPS );
            return;
        }
        tower->burstLeft = TURRET_BURST_ROUNDS;
    }
    else if( registry.valid( tower->target ) == false )
    {// the mark died mid-burst — the remaining rounds walk onto the next body in the press
        tower->target = FindTarget( app, float2( tower->pos.x, tower->pos.y ), params.range, params.minRange );
        if( tower->target == entt::null )
        {
            tower->burstLeft    = 0;
            tower->nextFireStep = ( step + RETRY_STEPS );
            return;
        }
    }

    const entt::entity target = tower->target;
    const b3Pos  tp   = registry.get<CTransform>( target ).current.p;
    const float3 to   = float3( static_cast<float>( tp.x ), static_cast<float>( tp.y ), static_cast<float>( tp.z ) );
    const float3 line = float3( to.x - tower->pos.x, to.y - tower->pos.y, to.z - tower->pos.z );
    const float3 dir  = crMath::Normalize3( line );

    {// aim the head — plunging fire reads from the tilt
        const float yaw = crMath::Atan2( dir.y, dir.x );
        registry.get<CTransform>( tower->head ).current.q = crMath::QuatFromAxisAngle( float3( 0.0f, 0.0f, 1.0f ), yaw );
    }

    // no knockback: shoving a walker sideways is what wedges it into a wall notch, and the burst
    // already sells the hit through its own flashes
    DamageEnemy( app, target, TURRET_DAMAGE );

    {// launch the bolt — UpdateTowers advances it down this segment over the next few steps
        tower->boltFrom   = tower->pos;
        tower->boltTo     = float3( to.x, to.y, BEAM_Z );
        tower->tracerStep = step;
    }

    {// impact spark — the round visibly lands
        EmitFlash( app, float3( to.x, to.y, 0.3f ), TRACER_COLOR, FX_CORE_SMALL, 1 );

        CParticleEmitter fx;
        fx.color        = TRACER_COLOR;
        fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
        fx.drag         = 2.5f;
        fx.spread       = 2.6f;
        fx.lifetimeMin  = 0.08f;
        fx.lifetimeMax  = 0.18f;
        fx.sizeMin      = 0.09f;
        fx.sizeMax      = 0.17f;
        fx.sizeEndScale = 0.3f;
        fx.intensityMin = 3.0f;
        fx.intensityMax = 4.5f;
        fx.spriteIndex  = _fxSquare;
        app->particles->EmitBurst( app, fx, float3( to.x, to.y, 0.3f ), 3 );
    }

    --tower->burstLeft;
    if( tower->burstLeft > 0 )
        tower->nextTickStep = ( step + TURRET_ROUND_STEPS );
    else
        tower->nextFireStep = ( step + static_cast<uint64_t>( params.cooldownSteps ) );
}
void Game::FireMortar( const crApp* app, Tower* tower )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();
    const TowerParams& params = TOWER_PARAMS[ static_cast<int32_t>( ETowerKind::MORTAR ) ];

    const entt::entity target = FindTarget( app, float2( tower->pos.x, tower->pos.y ), params.range, params.minRange );
    if( target == entt::null )
    {
        tower->nextFireStep = ( step + RETRY_STEPS );
        return;
    }

    const b3Pos tp = registry.get<CTransform>( target ).current.p;

    PendingShell shell;
    shell.from       = tower->pos;
    shell.to         = float3( static_cast<float>( tp.x ), static_cast<float>( tp.y ), 0.0f );
    shell.impactStep = ( step + SHELL_FLIGHT_STEPS );

    shell.shell = CreateVisualEntity( registry, shell.from );
    CPrimitive rock;
    rock.shape    = EPrimitive::SPHERE;
    rock.flags    = EPrimitiveFlags::NONE;
    rock.scale    = float3( 0.22f, 0.22f, 0.22f );
    rock.color    = MORTAR_COLOR;
    rock.emissive = 2.0f;
    registry.emplace<CPrimitive>( shell.shell, rock );
    registry.emplace<CTag_NonHashTarget>( shell.shell );

    {// smoke trail — the emitter rides the shell and dies with it
        CParticleEmitter trail;
        trail.color        = color4( 1.0f, 0.55f, 0.25f, 1.0f );
        trail.colorEnd     = color4( 0.25f, 0.22f, 0.20f, 1.0f );
        trail.gravity      = float3( 0.0f, 0.0f, 0.0f );
        trail.drag         = 1.6f;
        trail.spread       = 0.5f;
        trail.rate         = 26.0f;
        trail.lifetimeMin  = 0.25f;
        trail.lifetimeMax  = 0.6f;
        trail.sizeMin      = 0.12f;
        trail.sizeMax      = 0.26f;
        trail.sizeEndScale = 2.2f;    // puffs swell as they cool
        trail.intensityMin = 1.2f;
        trail.intensityMax = 2.2f;
        trail.spriteIndex  = _fxSprite;
        registry.emplace<CParticleEmitter>( shell.shell, trail );
    }

    shell.marker = CreateVisualEntity( registry, float3( shell.to.x, shell.to.y, FLOOR_TOP + 0.04f ) );
    CPrimitive marker;
    marker.shape    = EPrimitive::RING_THIN;
    marker.flags    = EPrimitiveFlags::NONE;
    marker.blend    = EBlendMode::ADDITIVE;
    marker.scale    = float3( MORTAR_AOE, MORTAR_AOE, 1.0f );
    marker.color    = MORTAR_COLOR;
    marker.emissive = 1.6f;
    registry.emplace<CPrimitive>( shell.marker, marker );
    registry.emplace<CTag_NonHashTarget>( shell.marker );

    _shells.Add( shell );

    {// launch puff
        CParticleEmitter fx;
        fx.color        = color4( 1.0f, 0.7f, 0.3f, 1.0f );
        fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
        fx.drag         = 2.5f;
        fx.spread       = 2.0f;
        fx.lifetimeMin  = 0.1f;
        fx.lifetimeMax  = 0.3f;
        fx.sizeMin      = 0.12f;
        fx.sizeMax      = 0.26f;
        fx.sizeEndScale = 0.2f;
        fx.intensityMin = 2.0f;
        fx.intensityMax = 3.0f;
        fx.spriteIndex  = _fxSprite;
        app->particles->EmitBurst( app, fx, tower->pos, 8 );
    }

    tower->nextFireStep = ( step + static_cast<uint64_t>( params.cooldownSteps ) );
}
void Game::UpdateLaser( const crApp* app, Tower* tower )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();
    const TowerParams& params = TOWER_PARAMS[ static_cast<int32_t>( ETowerKind::LASER ) ];

    if( step >= tower->nextFireStep )
    {// retarget — between scans the beam keeps burning along its last line
        tower->nextFireStep = ( step + static_cast<uint64_t>( params.cooldownSteps ) );

        const entt::entity target = FindTarget( app, float2( tower->pos.x, tower->pos.y ), params.range, params.minRange );
        if( target == entt::null )
            tower->aimDir = float2( 0.0f, 0.0f );
        else
        {
            const b3Pos tp = registry.get<CTransform>( target ).current.p;
            const float dx = ( static_cast<float>( tp.x ) - tower->pos.x );
            const float dy = ( static_cast<float>( tp.y ) - tower->pos.y );
            const float len = crMath::Sqrt( ( dx * dx ) + ( dy * dy ) );
            if( len > 0.001f )
                tower->aimDir = float2( dx / len, dy / len );
        }
    }

    const bool idle = ( ( ( tower->aimDir.x * tower->aimDir.x ) + ( tower->aimDir.y * tower->aimDir.y ) ) < 0.5f );

    float2  segA[ LASER_SEGMENTS ];
    float2  segB[ LASER_SEGMENTS ];
    int32_t segCount = 0;

    if( idle == false )
    {// trace the beam: straight to a canyon wall, then reflected — the terrain is the weapon
        float2 p = float2( tower->pos.x, tower->pos.y );
        float2 d = tower->aimDir;

        {// the tower stands INSIDE its plateau's collision slab — march the origin along the aim
         // line to open floor first, or the ray dies on the neighboring slab of its own plateau
            bool open = false;
            for( int32_t guard = 0; guard < 40; ++guard )
            {
                const int2 cell = WorldToCell( p.x, p.y );
                if( _high[ CellIndex( cell.x, cell.y ) ] == 0 )
                {
                    open = true;
                    break;
                }
                p = float2( p.x + ( d.x * 0.5f ), p.y + ( d.y * 0.5f ) );
            }
            if( open == false )
                d = float2( 0.0f, 0.0f );   // aimed into the plateau interior — hold fire
        }

        for( int32_t s = 0; ( s < LASER_SEGMENTS ) && ( ( ( d.x * d.x ) + ( d.y * d.y ) ) > 0.5f ); ++s )
        {
            float2 hit    = float2( 0.0f, 0.0f );
            float2 normal = float2( 0.0f, 0.0f );

            // grid DDA over the plateau mask — cell faces are axis aligned, so the normal comes out
            // of which axis the walk crossed, exact and free
            const bool blocked = RaycastTerrain( p, d, BEAM_MAX_LEN, &hit, &normal );

            const float2 end = ( blocked ? hit : float2( ( p.x + ( d.x * BEAM_MAX_LEN ) ), ( p.y + ( d.y * BEAM_MAX_LEN ) ) ) );

            segA[ segCount ] = p;
            segB[ segCount ] = end;
            ++segCount;

            if( blocked == false )
                break;

            const float dot = ( ( d.x * normal.x ) + ( d.y * normal.y ) );
            d = float2( ( d.x - ( 2.0f * dot * normal.x ) ), ( d.y - ( 2.0f * dot * normal.y ) ) );
            p = float2( ( end.x + ( d.x * 0.03f ) ), ( end.y + ( d.y * 0.03f ) ) );
        }
    }

    {// beam visuals — pulsing width and a slight sideways shiver make it read as energy, not a rod.
     // the damage segments stay the un-jittered lines: the noise is paint, never gameplay
        const float phase = static_cast<float>( ( tower->cell.x * 7 ) + ( tower->cell.y * 13 ) );
        const float t     = ( static_cast<float>( step ) * 0.35f ) + phase;
        const float width = ( 0.07f * ( 1.0f + ( 0.45f * crMath::Sin( t ) ) ) );

        for( int32_t s = 0; s < segCount; ++s )
        {
            const float2 perp   = float2( -tower->aimDir.y, tower->aimDir.x );
            const float  shiver = ( 0.06f * crMath::Sin( ( static_cast<float>( step ) * 0.9f ) + phase + ( static_cast<float>( s ) * 2.1f ) ) );
            const float3 a      = float3( segA[ s ].x + ( perp.x * shiver ), segA[ s ].y + ( perp.y * shiver ), BEAM_Z );
            const float3 b      = float3( segB[ s ].x + ( perp.x * shiver ), segB[ s ].y + ( perp.y * shiver ), BEAM_Z );

            SetBeam( app, tower->beams[ s ], a, b, width );

            if( registry.valid( tower->beams[ s ] ) )
                registry.get<CPrimitive>( tower->beams[ s ] ).emissive = ( 3.4f * ( 0.8f + ( 0.4f * crMath::Sin( t + static_cast<float>( s ) ) ) ) );
        }
        HideBeams( app, tower, segCount );

        if( segCount > 0 )
        {// end sparkle — the burn point
            CParticleEmitter fx;
            fx.color        = BEAM_COLOR;
            fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
            fx.drag         = 2.0f;
            fx.spread       = 1.6f;
            fx.lifetimeMin  = 0.08f;
            fx.lifetimeMax  = 0.2f;
            fx.sizeMin      = 0.10f;
            fx.sizeMax      = 0.22f;
            fx.sizeEndScale = 0.3f;
            fx.intensityMin = 3.0f;
            fx.intensityMax = 4.5f;
            fx.spriteIndex  = _fxSprite;
            app->particles->EmitBurst( app, fx, float3( segB[ segCount - 1 ].x, segB[ segCount - 1 ].y, BEAM_Z ), 2 );
        }
    }

    if( ( segCount == 0 ) || ( step < tower->nextTickStep ) )
        return;
    tower->nextTickStep = ( step + LASER_TICK );

    {// burn everything the line crosses — one tick per agent even where segments overlap.
     // resolved to entities first: the first kill would otherwise renumber every slot behind it
        int32_t burned = 0;

        for( int32_t i = 0; ( i < _agentCount ) && ( burned < QUERY_MAX ); ++i )
        {
            const float2 p   = _hot[ i ].pos;
            const float  hit = ( _hot[ i ].radius + BEAM_HIT_WIDTH );

            for( int32_t s = 0; s < segCount; ++s )
            {
                if( PointSegmentDistSq( p, segA[ s ], segB[ s ] ) < ( hit * hit ) )
                {
                    _queryOwners[ burned++ ] = _agentOwner[ i ];
                    break;
                }
            }
        }

        for( int32_t k = 0; k < burned; ++k )
            DamageEnemy( app, _queryOwners[ k ], LASER_DAMAGE );
    }
}
void Game::FireTesla( const crApp* app, Tower* tower )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();
    const TowerParams& params = TOWER_PARAMS[ static_cast<int32_t>( ETowerKind::TESLA ) ];

    const entt::entity first = FindTarget( app, float2( tower->pos.x, tower->pos.y ), params.range, params.minRange );
    if( first == entt::null )
    {
        tower->nextFireStep = ( step + RETRY_STEPS );
        return;
    }

    // walk the chain: nearest unvisited body from each arc end — the denser the press, the
    // farther the bolt travels. one beam slot per link
    entt::entity visited[ TOWER_BEAMS ];
    int32_t      visitedCount = 0;

    float2       from    = float2( tower->pos.x, tower->pos.y );
    entt::entity current = first;
    int32_t      links   = 0;

    while( ( current != entt::null ) && ( links < TOWER_BEAMS ) )
    {
        const b3Pos  cp     = registry.get<CTransform>( current ).current.p;
        const float2 curPos = float2( static_cast<float>( cp.x ), static_cast<float>( cp.y ) );

        // the first link leaves the tower HEAD and plunges; later hops crawl at body height
        const float fromZ = ( ( links == 0 ) ? tower->pos.z : BEAM_Z );
        SetBeam( app, tower->beams[ links ], float3( from.x, from.y, fromZ ), float3( curPos.x, curPos.y, BEAM_Z ), TESLA_BEAM_WIDTH );
        if( registry.valid( tower->beams[ links ] ) )
            registry.get<CPrimitive>( tower->beams[ links ] ).emissive = 3.8f;
        ++links;

        // every node in the chain sparks — the bolt should be seen landing on each body it takes
        EmitFlash( app, float3( curPos.x, curPos.y, 0.35f ), TESLA_BEAM_COLOR, FX_CORE_SMALL, 2 );

        {// crackle shards along the link just drawn
            CParticleEmitter fx;
            fx.color           = TESLA_BEAM_COLOR;
            fx.colorEnd        = color4( 0.10f, 0.35f, 0.55f, 1.0f );
            fx.gravity         = float3( 0.0f, 0.0f, 0.0f );
            fx.drag            = 2.0f;
            fx.spread          = 4.5f;
            fx.lifetimeMin     = 0.10f;
            fx.lifetimeMax     = 0.26f;
            fx.sizeMin         = 0.10f;
            fx.sizeMax         = 0.20f;
            fx.sizeEndScale    = 0.25f;
            fx.intensityMin    = 3.0f;
            fx.intensityMax    = 5.0f;
            fx.alignToVelocity = true;
            fx.spriteIndex     = _fxSquare;
            app->particles->EmitBurst( app, fx, float3( ( ( from.x + curPos.x ) * 0.5f ), ( ( from.y + curPos.y ) * 0.5f ), 0.3f ), 3 );
        }

        visited[ visitedCount++ ] = current;
        DamageEnemy( app, current, TESLA_DAMAGE );   // may kill — the hop query below just skips the corpse

        {// hop to the nearest agent the arc has not taken yet. the id tie-break keeps the pick
         // independent of how the grid happened to be ordered
            const int32_t hits = CrowdQuery( curPos, TESLA_JUMP_RANGE );

            float        bestSq = ( TESLA_JUMP_RANGE * TESLA_JUMP_RANGE );
            uint32_t     bestId = UINT32_MAX;
            entt::entity best   = entt::null;

            for( int32_t k = 0; k < hits; ++k )
            {
                const int32_t      slot = _queryHits[ k ];
                const entt::entity e    = _agentOwner[ slot ];

                bool skip = false;
                for( int32_t v = 0; v < visitedCount; ++v )
                {
                    if( visited[ v ] == e )
                        skip = true;
                }
                if( skip )
                    continue;

                const float    dx = ( _hot[ slot ].pos.x - curPos.x );
                const float    dy = ( _hot[ slot ].pos.y - curPos.y );
                const float    sq = ( ( dx * dx ) + ( dy * dy ) );
                const uint32_t id = static_cast<uint32_t>( e );

                if( ( sq < bestSq ) || ( ( sq == bestSq ) && ( id < bestId ) ) )
                {
                    bestSq = sq;
                    bestId = id;
                    best   = e;
                }
            }

            from    = curPos;
            current = best;
        }
    }

    HideBeams( app, tower, links );

    EmitFlash( app, float3( from.x, from.y, 0.4f ), TESLA_BEAM_COLOR, FX_CORE, 3 );   // the tail of the chain burns brightest

    tower->tracerStep   = step;   // the fade in UpdateTowers measures from here
    tower->nextFireStep = ( step + static_cast<uint64_t>( params.cooldownSteps ) );
}
void Game::UpdateFrost( const crApp* app, Tower* tower )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();
    const TowerParams& params = TOWER_PARAMS[ static_cast<int32_t>( ETowerKind::FROST ) ];

    if( registry.valid( tower->aura ) )
    {// slow breathing ring — alive, not a decal
        const float t = ( static_cast<float>( step ) * 0.08f ) + static_cast<float>( tower->cell.x );
        const float s = ( params.range * ( 0.94f + ( 0.06f * crMath::Sin( t ) ) ) );

        CPrimitive& aura = registry.get<CPrimitive>( tower->aura );
        aura.scale    = float3( s, s, 1.0f );
        aura.emissive = ( 1.3f + ( 0.5f * crMath::Sin( t * 1.7f ) ) );
    }

    if( step < tower->nextFireStep )
        return;
    tower->nextFireStep = ( step + static_cast<uint64_t>( params.cooldownSteps ) );

    {// refresh the slow on everything standing in the field. nothing dies here, so the slots stay
     // valid for the whole loop and no entity resolution is needed
        const uint64_t until = ( step + FROST_SLOW_STEPS );
        const int32_t  hits  = CrowdQuery( float2( tower->pos.x, tower->pos.y ), params.range );

        for( int32_t k = 0; k < hits; ++k )
        {
            CEnemy& enemy = registry.get<CEnemy>( _agentOwner[ _queryHits[ k ] ] );
            if( until > enemy.slowUntilStep )
                enemy.slowUntilStep = until;
        }
    }
}
void Game::UpdateFxRings( const crApp* app )
{
    entt::registry& registry = app->ecs->registry;

    const uint64_t step = app->SimulationStepIndex();

    for( int32_t i = _fxRings.Size() - 1; i >= 0; --i )
    {
        const FxRing& fx  = _fxRings.At( i );
        const float   age = ( static_cast<float>( step - fx.bornStep ) / static_cast<float>( RING_LIFE_STEPS ) );

        if( ( age >= 1.0f ) || ( registry.valid( fx.ring ) == false ) )
        {
            if( registry.valid( fx.ring ) )
                registry.destroy( fx.ring );
            _fxRings.At( i ) = _fxRings.At( _fxRings.Size() - 1 );
            _fxRings.Pop();
            continue;
        }

        const float s = crMath::Lerp( 0.8f, ( MORTAR_AOE * 1.6f ), age );

        CPrimitive& ring = registry.get<CPrimitive>( fx.ring );
        ring.scale    = float3( s, s, 1.0f );
        ring.emissive = ( 3.0f * ( 1.0f - age ) );
    }
}
void Game::EmitFlash( const crApp* app, float3 at, color4 color, float size, int32_t count )
{
    CParticleEmitter fx;
    fx.color        = color;
    fx.colorEnd     = color4( ( color.r * 0.15f ), ( color.g * 0.15f ), ( color.b * 0.15f ), 1.0f );
    fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
    fx.drag         = 3.2f;
    fx.spread       = ( size * 4.0f );
    fx.lifetimeMin  = 0.10f;
    fx.lifetimeMax  = 0.24f;
    fx.sizeMin      = size;
    fx.sizeMax      = ( size * 1.7f );
    fx.sizeEndScale = 0.2f;    // a bright dot that collapses — the eye reads it as a flash, not a puff
    fx.intensityMin = 3.0f;
    fx.intensityMax = 5.0f;
    fx.spriteIndex  = _fxSprite;   // soft round; the shards elsewhere use the hard square
    app->particles->EmitBurst( app, fx, at, count );
}
void Game::SetBeam( const crApp* app, entt::entity beam, float3 a, float3 b, float halfWidth )
{
    entt::registry& registry = app->ecs->registry;
    if( registry.valid( beam ) == false )
        return;

    const float dx    = ( b.x - a.x );
    const float dy    = ( b.y - a.y );
    const float dz    = ( b.z - a.z );
    const float flat  = crMath::Sqrt( ( dx * dx ) + ( dy * dy ) );
    const float len   = crMath::Sqrt( ( flat * flat ) + ( dz * dz ) );

    registry.get<CPrimitive>( beam ).scale = float3( ( len * 0.5f ), halfWidth, halfWidth );

    // yaw about Z, then pitch about the yawed Y — the local +X axis ends up along the segment
    const float  yaw   = crMath::Atan2( dy, dx );
    const float  pitch = crMath::Atan2( dz, flat );
    const b3Quat qYaw   = crMath::QuatFromAxisAngle( float3( 0.0f, 0.0f, 1.0f ), yaw );
    const b3Quat qPitch = crMath::QuatFromAxisAngle( float3( 0.0f, 1.0f, 0.0f ), -pitch );

    CTransform& transform = registry.get<CTransform>( beam );
    transform.current.p.x = ( ( a.x + b.x ) * 0.5f );
    transform.current.p.y = ( ( a.y + b.y ) * 0.5f );
    transform.current.p.z = ( ( a.z + b.z ) * 0.5f );
    transform.current.q   = b3MulQuat( qYaw, qPitch );
}
void Game::HideBeams( const crApp* app, Tower* tower, int32_t fromIndex )
{
    entt::registry& registry = app->ecs->registry;

    for( int32_t s = fromIndex; s < TOWER_BEAMS; ++s )
    {
        if( registry.valid( tower->beams[ s ] ) )
            registry.get<CPrimitive>( tower->beams[ s ] ).scale = float3( 0.0f, 0.0f, 0.0f );
    }
}

entt::entity Game::FindTarget( const crApp* app, float2 from, float range, float minRange )
{
    ( void )app;

    const int32_t hits       = CrowdQuery( from, range );
    const float   minRangeSq = ( minRange * minRange );

    float        bestDist = FLOW_INF;
    uint32_t     bestId   = UINT32_MAX;
    entt::entity best     = entt::null;

    for( int32_t k = 0; k < hits; ++k )
    {
        const int32_t slot = _queryHits[ k ];

        const float dx = ( _hot[ slot ].pos.x - from.x );
        const float dy = ( _hot[ slot ].pos.y - from.y );
        if( ( ( dx * dx ) + ( dy * dy ) ) < minRangeSq )
            continue;

        // priority: closest to the core by WALK distance, which the flow field already knows —
        // no euclidean ranking needed. the id tie-break keeps the pick order-independent
        const int2     cell = WorldToCell( _hot[ slot ].pos.x, _hot[ slot ].pos.y );
        const float    dist = _flowDist[ CellIndex( cell.x, cell.y ) ];
        const uint32_t id   = static_cast<uint32_t>( _agentOwner[ slot ] );

        if( ( dist < bestDist ) || ( ( dist == bestDist ) && ( id < bestId ) ) )
        {
            bestDist = dist;
            bestId   = id;
            best     = _agentOwner[ slot ];
        }
    }

    return best;
}

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

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

    _gold  += ( ( enemy.kind == EEnemyKind::BRUTE ) ? BRUTE_GOLD : GRUNT_GOLD );
    ++_kills;
    DestroyEnemy( app, e, true );
    return true;
}
void Game::DestroyEnemy( const crApp* app, entt::entity e, bool reward )
{
    entt::registry& registry = app->ecs->registry;

    const CEnemy& enemy = registry.get<CEnemy>( e );
    const b3Pos   pos   = registry.get<CTransform>( e ).current.p;
    const float3  at    = float3( static_cast<float>( pos.x ), static_cast<float>( pos.y ), static_cast<float>( pos.z ) );

    if( reward )
    {// death burst in the walker's own hue — the gore that stains the swarm's path
        const bool brute = ( enemy.kind == EEnemyKind::BRUTE );

        CParticleEmitter fx;
        fx.color        = enemy.baseColor;
        fx.colorEnd     = color4( ( enemy.baseColor.r * 0.3f ), ( enemy.baseColor.g * 0.3f ), ( enemy.baseColor.b * 0.3f ), 1.0f );
        fx.gravity      = float3( 0.0f, 0.0f, -7.0f );
        fx.drag         = 1.2f;
        fx.spread       = ( brute ? 6.0f : 3.5f );
        fx.lifetimeMin  = 0.2f;
        fx.lifetimeMax  = 0.5f;
        fx.sizeMin      = 0.09f;
        fx.sizeMax      = ( brute ? 0.28f : 0.18f );
        fx.sizeEndScale = 0.3f;
        fx.intensityMin = 1.5f;
        fx.intensityMax = 2.8f;
        fx.spinMin      = -6.0f;
        fx.spinMax      = 6.0f;
        fx.spriteIndex  = _fxSquare;
        app->particles->EmitBurst( app, fx, at, ( brute ? 26 : 10 ) );

        // one soft core so a kill still registers at overview zoom, where the shards are ~1 px
        EmitFlash( app, at, enemy.baseColor, ( brute ? FX_CORE : FX_CORE_SMALL ), ( brute ? 4 : 1 ) );

        if( brute )
            _app->graphics->PostProcess()->QueueRipple( at, 2.4f, 0.3f, 0.012f );
    }

    PlaceStain( app, at, ( enemy.kind == EEnemyKind::BRUTE ) );

    CrowdFree( enemy.slot );
    registry.destroy( e );
}
void Game::DamageGate( const crApp* app, int32_t amount )
{
    _gateHp -= amount;
    if( _gateHp < 0 )
        _gateHp = 0;

    CParticleEmitter fx;
    fx.color        = color4( 1.0f, 0.3f, 0.15f, 1.0f );
    fx.colorEnd     = color4( 0.4f, 0.05f, 0.02f, 1.0f );
    fx.gravity      = float3( 0.0f, 0.0f, 0.0f );
    fx.drag         = 1.8f;
    fx.spread       = 5.0f;
    fx.lifetimeMin  = 0.15f;
    fx.lifetimeMax  = 0.4f;
    fx.sizeMin      = 0.11f;
    fx.sizeMax      = 0.24f;
    fx.sizeEndScale = 0.2f;
    fx.intensityMin = 2.5f;
    fx.intensityMax = 4.0f;
    fx.spriteIndex  = _fxSprite;
    app->particles->EmitBurst( app, fx, float3( _gatePos.x, _gatePos.y, 0.6f ), 24 );

    EmitFlash( app, float3( _gatePos.x, _gatePos.y, 0.7f ), color4( 1.0f, 0.4f, 0.2f, 1.0f ), FX_CORE, 4 );

    _app->graphics->PostProcess()->QueueRipple( _gatePos, 2.8f, 0.4f, 0.018f );
    app->audio->PlaySfx( _sfxImpact, 0.4f );
}

bool Game::ValidBuildCell( int2 cell ) const
{
    if( ( cell.x < 0 ) || ( cell.x >= MAP_W ) || ( cell.y < 0 ) || ( cell.y >= MAP_H ) )
        return false;

    const int32_t i = CellIndex( cell.x, cell.y );
    return ( _high[ i ] != 0 ) && ( _towerAt[ i ] < 0 );
}
bool Game::PickBuildCell( const crApp* app, float2 posPx, int2* cell ) const
{
    const crRay ray = app->camera->ScreenToRay( posPx );
    if( crMath::Abs( ray.dir.z ) < 0.0001f )
        return false;

    const float t = ( ( CLIFF_TOP - ray.origin.z ) / ray.dir.z );
    if( t < 0.0f )
        return false;

    const float wx = ( ray.origin.x + ( ray.dir.x * t ) );
    const float wy = ( ray.origin.y + ( ray.dir.y * t ) );
    if( ( crMath::Abs( wx ) > ( static_cast<float>( MAP_W ) * CELL * 0.5f ) ) ||
        ( crMath::Abs( wy ) > ( static_cast<float>( MAP_H ) * CELL * 0.5f ) ) )
        return false;

    *cell = WorldToCell( wx, wy );
    return true;
}

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

    CPrimitive& box  = registry.get<CPrimitive>( _ghostBox );
    CPrimitive& ring = registry.get<CPrimitive>( _ghostRing );

    int2 cell;
    if( ( _state != EGameState::PLAYING ) || ( _buildKind < 0 ) ||
        ( PickBuildCell( app, app->inputSys->Pointer().posPx, &cell ) == false ) )
    {
        box.scale  = float3( 0.0f, 0.0f, 0.0f );
        ring.scale = float3( 0.0f, 0.0f, 1.0f );
        return;
    }

    const TowerParams& params = TOWER_PARAMS[ _buildKind ];
    const float3 center = CellCenter( cell );
    const bool   valid  = ( ValidBuildCell( cell ) && ( _gold >= params.cost ) );

    const color4 tint = ( valid ? color4( 0.3f, 1.0f, 0.5f, 0.42f ) : color4( 1.0f, 0.25f, 0.2f, 0.42f ) );

    box.scale = float3( 0.45f, 0.45f, 0.45f );
    box.color = tint;

    ring.scale = float3( params.range, params.range, 1.0f );
    ring.color = color4( tint.r, tint.g, tint.b, 0.5f );

    // pointer-driven — set both keys so the interpolated pose never lags a fast mouse
    CTransform& boxT = registry.get<CTransform>( _ghostBox );
    boxT.current.p  = { center.x, center.y, ( CLIFF_TOP + 0.45f ) };
    boxT.previous.p = boxT.current.p;

    CTransform& ringT = registry.get<CTransform>( _ghostRing );
    ringT.current.p  = { center.x, center.y, ( CLIFF_TOP + 0.06f ) };
    ringT.previous.p = ringT.current.p;
}
void Game::UpdateCamera( crApp* app )
{
    if( app->inputSys->devCamera )
        return;   // debugging beats the game's framing — leave the rig exactly where the dev put it

    _camTarget.x = crMath::Clamp( _camTarget.x, -( static_cast<float>( MAP_W ) * CELL * 0.5f ), ( static_cast<float>( MAP_W ) * CELL * 0.5f ) );
    _camTarget.y = crMath::Clamp( _camTarget.y, -( static_cast<float>( MAP_H ) * CELL * 0.5f ), ( static_cast<float>( MAP_H ) * CELL * 0.5f ) );

    app->camera->SetTarget( float3( _camTarget.x, _camTarget.y, 0.0f ) );
    app->camera->SetOrbit( 0.0f, 0.0f );
    app->camera->SetDistance( _camDist );
}

float3 Game::CellCenter( int2 cell )
{
    return float3( ( ( static_cast<float>( cell.x ) - ( static_cast<float>( MAP_W ) * 0.5f ) ) * CELL ) + ( CELL * 0.5f ),
                   ( ( static_cast<float>( cell.y ) - ( static_cast<float>( MAP_H ) * 0.5f ) ) * CELL ) + ( CELL * 0.5f ),
                   0.0f );
}
int2 Game::WorldToCell( float wx, float wy )
{
    // offset keeps the operand positive, so integer truncation IS floor — no libm needed in the sim
    int32_t x = static_cast<int32_t>( ( wx / CELL ) + ( static_cast<float>( MAP_W ) * 0.5f ) );
    int32_t y = static_cast<int32_t>( ( wy / CELL ) + ( static_cast<float>( MAP_H ) * 0.5f ) );

    if( x < 0 )
        x = 0;
    if( x >= MAP_W )
        x = ( MAP_W - 1 );
    if( y < 0 )
        y = 0;
    if( y >= MAP_H )
        y = ( MAP_H - 1 );

    return { x, y };
}
