Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / src/states/game_state.cpp

sdl_runtime_compiler git · main

SDL3 game for running and compiling code at runtime

sdl3 c++ compiler dlopen cmake · first commit 2026-04-19 · last commit 2026-07-03 (3 months ago) · synced 3 days ago · upstream: git.ide3.de/hsnr/sdl-runtime-compiler

C++ 72.3% C 26.2%
git clone https://git.christianimmanuel.de/sdl-graphics/sdl_runtime_compiler.gitwget https://git.christianimmanuel.de/sdl-graphics/sdl_runtime_compiler/archive/sdl_runtime_compiler.tar.gz
src/states/game_state.cpp 52.8 KB · 1556 lines raw
#include <algorithm>
#include <print>
#include <unordered_set>


#include "core/game.hpp"
#include "entities/solid_box.hpp"
#include "entities/world.hpp"
#include "game_state.hpp"
#include "rendering/gl_renderer.hpp"
#include "systems/aim.hpp"
#include "systems/carry_lex.hpp"
#include "systems/celebration_system.hpp"
#include "systems/cpp_highlight_system.hpp"
#include "systems/game_mod_abi.h"
#include "systems/input_system.hpp"
#include "systems/physics_system.hpp"
#include "systems/pick_system.hpp"
#include "systems/source_tokens.hpp"
#include "text/token.hpp"
#include "utils/config.hpp"
#include "utils/frame_profiler.hpp"
#include "wall/label.hpp"
#include "wall/wall.hpp"

namespace
Nimbin
{

GameState::GameState(size_t starting_level)
{
    _ctx.player = cpputils::make_unique<Player>();
    _ctx.world  = cpputils::make_unique<World> ();
    _ctx.camera = cpputils::make_unique<Camera>();
    _ctx.light  = cpputils::make_unique<Light> ();

    _input_sys       = cpputils::make_unique<InputSystem>       ();
    _freelook_sys    = cpputils::make_unique<FreelookSystem>    ();

    _compiler_sys    = cpputils::make_unique<CompilerSystem>    ();
    _program_sys     = cpputils::make_unique<ProgramSystem>     ();
    _dyn_loader_sys  = cpputils::make_unique<DynLoaderSystem>   ();

    _physics_sys     = cpputils::make_unique<PhysicsSystem>     ();
    _celebration_sys = cpputils::make_unique<CelebrationSystem> ();

    _projectiles_sys = cpputils::make_unique<ProjectileSystem>  ();
    _particle_sys    = cpputils::make_unique<ParticleSystem>    ();
    _word_anim_sys   = cpputils::make_unique<WordAnimSystem>    ();
    _highlight_sys   = cpputils::make_unique<CppHighlightSystem>();

    _wall_labels     = Wall::Label::generate_All(World::get_BoxScale());

    _wall_editor     = Text::Handler(Wall::Role::Editor, World::get_BoxScale(),
                                     Text::LayoutMode::Editing);
    _wall_editor.set_ShowNumbers(true, true);

    _text_walls   = { &_wall_editor };

    _wall_program = Text::Handler    (Wall::Role::Program, World::get_BoxScale(),
                                      Text::LayoutMode::Display);
    _wall_program.set_Lexing         (false);
    _wall_program.set_ShowNumbers    (false, false);
    _wall_program.set_DefaultStyle   (Text::Token::depth_wall, 0.003f,
                                      static_cast<double>(ProgramSystem::line_height),
                                      Config::Wall::free_space_dist);
    _wall_program.set_AppendBaseline (static_cast<double>(ProgramSystem::start_y));
    _wall_program.set_FaceMode       (Text::FaceMode::Pitch);

    _wall_compiler = Text::Handler   (Wall::Role::Compiler, World::get_BoxScale(),
                                      Text::LayoutMode::Display);
    _wall_compiler.set_Lexing        (false);
    _wall_compiler.set_ShowNumbers   (false, false);
    _wall_compiler.set_DefaultStyle  (Text::Token::depth_compiler, 0.003f,
                                      static_cast<double>(CompilerSystem::line_height),
                                      Config::Wall::free_space_dist);
    _wall_compiler.set_AppendBaseline(static_cast<double>(CompilerSystem::start_y));
    _wall_compiler.set_AppendColor   (Config::Color::white);
    _wall_compiler.set_FaceMode      (Text::FaceMode::Pitch);

    _wall_info = Text::Handler   (Wall::Role::Info, World::get_BoxScale(),
                                  Text::LayoutMode::Display);
    _wall_info.set_Lexing        (false);
    _wall_info.set_ShowNumbers   (false, false);
    _wall_info.set_DefaultStyle  (Text::Token::depth_wall, 0.003f,
                                  static_cast<double>(Config::Wall::text_line_spacing),
                                  Config::Wall::free_space_dist);
    _wall_info.set_AppendBaseline(World::get_FloorY<double>() + 1.0);
    _wall_info.set_AppendColor   (Config::Color::white);
    _wall_info.set_FaceMode      (Text::FaceMode::Pitch);

    _text_walls = { &_wall_editor, &_wall_program, &_wall_compiler, &_wall_info };

    _ctx.editor_wall = &_wall_editor;
    _ctx.info_wall   = &_wall_info;

    _pult_sys        = cpputils::make_unique<PultSystem>  ();
    _pult_sys->init(_ctx.scenery, _ctx.buttons, World::get_BoxScale());

    if (starting_level >= Level::get_Count()) starting_level = 0;
    _level_director  = cpputils::make_unique<Level::Director>(Level::get_Data(starting_level));
    _level_director->enter_Step(_ctx);

    _word_ai_sys     = cpputils::make_unique<WordAiSystem>();
    _word_ai_sys->set_MaxActiveAi(_level_director->get_MaxAiForStep());

    _hint_sys = cpputils::make_unique<HintSystem>();
    if (!_level_director->is_LevelComplete())
        _hint_sys->set_Hint(_level_director->get_CurrentHint());

    if (_level_director->get_Runner().is_DynamicLib())
        sandbox_LoadFileToWall();
}


void
GameState::debug_SpawnWords([[maybe_unused]] size_t n)
{
    DynArray<std::string> tokens = Text::Token::get_TestArrayAllKeywords();

    static constexpr float spacing = 3.0f;
    static constexpr int   per_row = 8;
    static constexpr float row_gap = 3.0f;
    const double hover_y = World::get_FloorY<double>() + 1.5;

    for (size_t i = 0; i < tokens.size(); i++) {
        Vec3 pos { -static_cast<float>(per_row) * spacing * 0.5f
                   + static_cast<float>(i % per_row) * spacing,
                   hover_y,
                   static_cast<float>(i / per_row) * row_gap };

        Text::TokenPtr obj = Text::make_Token(tokens[i],
                                              Text::Token::depth_floor,
                                              0.003f, pos);
        obj->body.hover_height = hover_y;
        _ctx.words.push_back(std::move(obj));
    }
}


void
GameState::carriedline_Update()
{
    if (_carried.empty()) return;

    const FreelookState& fs = _freelook_sys->state;

    if (fs.active) return;

    const Vec3   anchor    = CarriedLine::get_LineAnchor(*_ctx.camera, Config::Carry::dist);
    const Vec3   right     = _ctx.camera->right;
    const double glyph_yaw = SDL_atan2(-_ctx.camera->fwd.x, -_ctx.camera->fwd.z);

    DynArray<Vec3> slots   = _carried.get_SlotPosition(anchor, right, Config::Carry::gap);
    DynArray<CarriedWordNode*>
                   nodes   = _carried.reverse_nodes();

    const bool easing = fs.lerping && !_freelook_return_from.empty();
    double s = 1.0;
    if (easing) {
        double t = fs.lerp_t;
        t = (t < 0.0) ? 0.0 : (t > 1.0 ? 1.0 : t);
        s = t * t * (3.0 - 2.0 * t);
    }

    for (size_t i = 0; i < nodes.size() && i < slots.size(); i++) {
        if (!nodes[i]->word) continue;

        Vec3 target = slots[i];
        if (easing && i < _freelook_return_from.size()) {
            const Vec3& from = _freelook_return_from[i];
            nodes[i]->word->pos = { from.x + (target.x - from.x) * s,
                                    from.y + (target.y - from.y) * s,
                                    from.z + (target.z - from.z) * s };
        }
        else {
            nodes[i]->word->pos = target;
        }
        nodes[i]->word->yaw = static_cast<float>(glyph_yaw);
    }
}


void
GameState::pult_RunAction(PultAction act)
{
    _level_director->record_Button(act);

    switch (act) {
        case PultAction::Compile:          on_Compile     ();          break;
        case PultAction::RunProgram:       on_Run         ();          break;
        case PultAction::ScrollEditorUp:   on_EditorScroll( 1);        break;
        case PultAction::ScrollEditorDown: on_EditorScroll(-1);        break;
        case PultAction::ClearEditor:      on_EditorClear ();          break;
        case PultAction::ConnectEditor:    wallconnect_BeginEditor (); break;
        case PultAction::ConnectProgram:   wallconnect_BeginProgram(); break;
    }
}


void
GameState::on_Click()
{
    if (_focus_active) {
        const float ws = World::get_BoxScale<float>();
        const Wall::Transform
                    transform = Wall::get_Transform(_focus_role, ws);
        const Vec3  normal    = Wall::inward_Normal(transform.wall_position);
        const Vec3& cam_pos   = _ctx.camera->pos;
        const Vec3& cam_fwd   = _ctx.camera->fwd;

        const double denom = cam_fwd.x*normal.x + cam_fwd.y*normal.y + cam_fwd.z*normal.z;
        if (denom < -1e-6) {
            const double t = ((transform.position.x-cam_pos.x)*normal.x
                           + (transform.position.y-cam_pos.y)*normal.y
                           + (transform.position.z-cam_pos.z)*normal.z) / denom;
            if (t > 0.0) { // crosshair points on wall
                const Vec3 impact { cam_pos.x + cam_fwd.x*t, cam_pos.y + cam_fwd.y*t, cam_pos.z + cam_fwd.z*t };

                if (_focus_role == Wall::Role::Editor) {
                    const Text::Handler::CursorHit hit =
                        _wall_editor.resolve_Impact(impact);
                    wallconnect_BeginEditor();
                    _wall_editor.place_CursorInsert(hit);
                }
                else if (_focus_role == Wall::Role::Program) {
                    wallconnect_BeginProgram();
                }
            }
        }
        return;
    }
    if (_wall_editor.edit_Active()) {
        const Wall::Transform 
                     wall_transform = Wall::get_Transform(Wall::Role::Editor, World::get_BoxScale());
        const double wall_tans_x    = wall_transform.position.x;
        const Vec3&  cam_pos        = _ctx.camera->pos;
        const Vec3&  cam_fwd        = _ctx.camera->fwd;

        if (SDL_fabs(cam_fwd.x) > 1e-6) {
            const double t = (wall_tans_x - cam_pos.x) / cam_fwd.x;
            if (t > 0.0) {
                Vec3 impact { wall_tans_x, cam_pos.y + cam_fwd.y * t, cam_pos.z + cam_fwd.z * t };
                if (_wall_editor.cursor_AtImpact(impact)) return;
            }
        }
        if (Text::Token::Data* tk = PickSystem::pick_WallToken(*_ctx.camera, _wall_editor))
            _wall_editor.cursor_ToToken(tk);
        return;
    }

    PickResult result = PickSystem::pick(*_ctx.camera, _ctx.words, _ctx.scenery, _text_walls);
    if (!result.hit()) return;

    if (result.kind == PickKind::Scenery && result.scenery_obj) {
        if (std::optional<PultAction> act =
                _pult_sys->try_Activate(result.scenery_obj, _ctx.buttons); act)
            pult_RunAction(*act);
    }
}


bool
GameState::on_Pick()
{
    PickResult result = PickSystem::pick(*_ctx.camera, _ctx.words, _ctx.scenery, _text_walls);
    if (!result.hit()) return false;

    if (result.kind == PickKind::WallWord && result.wall_handler && result.wall_token) {
        Text::TokenPtr picked = result.wall_handler->detach(result.wall_token);
        if (!picked) return false;

        _level_director->record_PickedUp(picked->text);
        picked->set_Look     (Config::Color::keep, Text::Token::depth_floor);
        Wall::show_TokenInfo (_wall_info, picked->text);
        carry_BeginAnim      (std::move(picked), true);
        return true;
    }

    if (result.kind == PickKind::Word) {
        Text::TokenPtr picked = std::move(_ctx.words[result.word_index]);
        _ctx.words.erase(_ctx.words.begin() + static_cast<std::ptrdiff_t>(result.word_index));
        if (!picked) return false;

        _level_director->record_PickedUp(picked->text);
        picked->set_Look    (Config::Color::keep, Text::Token::depth_floor);
        Wall::show_TokenInfo(_wall_info, picked->text);
        carry_BeginAnim     (std::move(picked), false);
        return true;
    }
    return false;
}


void
GameState::on_DropLast()
{
    if (_carried.empty()) return;

    Text::TokenPtr dropped = _carried.pop_back();
    if (!dropped) return;

    CarryLex::revert_ToSingle(dropped.get());

    dropped->body.mode = PhysicsMode::Dynamic;
    dropped->body.vel  = { _ctx.camera->fwd.x * 1.5, 0.8, _ctx.camera->fwd.z * 1.5 };

    WordAnim_Drop drop;
    drop.word = std::move(dropped);
    _anims_drop.push_back(std::move(drop));

    if (!_carried.empty()) {
        Wall::show_TokenInfo(_wall_info, _carried.last->word->text);
    }
    else _level_director->show_Details(_ctx);

    _level_director->record_Dropped();
}


bool
GameState::on_ShootOne()
{
    if (_carried.empty()) return false;

    Text::TokenPtr word = _carried.pop_back();
    if (!word) return false;
    CarryLex::revert_ToSingle(word.get());

    _freelook_sys->state.active  = false;
    _freelook_sys->state.lerping = false;

    DynArray<Text::TokenPtr> single;
    single.push_back(std::move(word));

    Aim::Result aim = Aim::crosshair_Target(*_ctx.camera, _ctx.words, _ctx.scenery,
                                             _text_walls, World::get_BoxScale());
    _projectiles_sys->launch(_projectiles, std::move(single),
                             _ctx.camera->fwd, aim);

    if (!_carried.empty() && _carried.last && _carried.last->word) {
        Wall::show_TokenInfo(_wall_info, _carried.last->word->text);
    }
    else _level_director->show_Details(_ctx);

    _level_director->record_Shot();
    return true;
}


void
GameState::on_ShootAll()
{
    if (_carried.empty()) return;

    _freelook_sys->state.active  = false;
    _freelook_sys->state.lerping = false;

    DynArray<Text::TokenPtr> all_words;
    for (CarriedWordNode* node : _carried.nodes())
        if (node->word) {
            CarryLex::revert_ToSingle    (node->word.get());
            all_words.push_back(std::move(node->word));
        }

    _carried = CarriedLine();

    Aim::Result aim;
    if (all_words.empty())
        return;
    aim = Aim::crosshair_Target(*_ctx.camera, _ctx.words, _ctx.scenery,
                                 _text_walls, World::get_BoxScale());

    _projectiles_sys->launch(_projectiles, std::move(all_words),
                             _ctx.camera->fwd, aim);

    _level_director->show_Details(_ctx);
    _level_director->record_Shot ();
}


std::string
GameState::get_EditorSource() const
{
    std::string src;
    for (const std::string& l : _wall_editor.as_Lines())
        { src += l; src += '\n'; }
    return src;
}


void GameState::on_Compile() {
    std::string src = get_EditorSource();

    if ( !_level_director->is_LevelComplete() &&
          _level_director->get_Runner().is_DynamicLib()) {
        const std::string path = sandbox_GetSourcePath();
        std::string stripped;
        for (char c : src)
            if (!std::isspace((unsigned char)c))
                { stripped = src; break; }

        if (!path.empty() && !stripped.empty())
            SourceTokens::write_File(path, src);

        _dyn_loader_sys->build(_wall_compiler, src, true);
    }
    else {
        _compiler_sys->compile(_wall_compiler, src);
    }
}


void
GameState::on_Run()
{
    _program_sys->run(_wall_program);
}


void
GameState::on_DebugPrint()
{
    std::print("TEXT::: {0}\n", get_EditorSource());
}

void
GameState::on_FocusToggle() 
{
    _focus_on = !_focus_on;
}

void
GameState::on_Jump()
{
    if (_focus_active) {
        _ctx.player->pos       = _ctx.camera->pos;
        _ctx.player->prev_pos  = _ctx.camera->pos;
        _ctx.player->vel       = Vec3{0, 0, 0};
        _ctx.player->on_ground = false;
        return;
    }
    if (_ctx.player->on_ground) {
        _ctx.player->vel.y     = Config::Physics::player_jump_velocity;
        _ctx.player->on_ground = false;
    }
}


void
GameState::on_FreelookStart()
{
    _freelook_sys->begin();
}

void
GameState::on_FreelookEnd()
{
    if (!_freelook_sys->state.active) return;

    _freelook_return_from.clear();
    for (CarriedWordNode* node : _carried.reverse_nodes())
        if (node->word) _freelook_return_from.push_back(node->word->pos);

    _freelook_sys->end();
}

void
GameState::on_NumberAdjust(int delta)
{
    if (_carried.empty() || !_carried.last || !_carried.last->word)
        return;

    Text::Token::Data* word = _carried.last->word.get();

    const std::string& text = word->text;
    if (text.empty()) return;

    size_t start = (text[0] == '-') ? 1 : 0;
    if (start == text.size()) return;

    for (size_t i = start; i < text.size(); i++)
        if (!std::isdigit(static_cast<unsigned char>(text[i])))
            return;

    long val = std::strtol(text.c_str(), nullptr, 10);
    val += delta;

    word->text = std::to_string(val);
    word->rebuild_Mesh();
}


void
GameState::on_TypingStart(FrameInput& input)
{
    input.typing_mode = true;
    input.typing_buffer.clear();
    _typing_words.clear();
    _typing_last .clear();
}
void
GameState::on_TypingCommit(FrameInput& input)
{
    input.typing_mode = false;
    input.typing_buffer.clear();
    _typing_words.clear();
    _typing_last .clear();
}
void
GameState::on_TypingCancel(FrameInput& input)
{
    typing_ClearPreview();
    input.typing_mode = false;
    input.typing_buffer.clear();
    _typing_last.clear();
}


void
GameState::projectile_LandOnProgramInput(ProjectileResult& result)
{
    std::string line;
    for (const Text::TokenPtr& word : result.words) {
        if (!word || word->type != ObjectType::Text) continue;

        if (!line.empty()) line += ' ';
        line += word->text;
    }
    if (!line.empty())
        _program_sys->feed_Input(line);

    _program_sys->echo_Words(_wall_program, result.words);
}


void
GameState::projectile_LandOnFloor(ProjectileResult& result)
{
    for (Text::TokenPtr& word : result.words) {
        if (!word || word->type != ObjectType::Text) continue;

        word->set_Look(Config::Color::keep, Text::Token::depth_floor);
        word->body.mode = PhysicsMode::Dynamic;
        word->body.vel  = result.vel;
        _ctx.words.push_back(std::move(word));
    }
}


void
GameState::projectile_LandOnWall(ProjectileResult& result)
{
    DynArray<Text::TokenPtr> edit_block, prog_block, comp_block, info_block;

    for (Text::TokenPtr& word : result.words) {
        if (!word) continue;

        word->body.mode = PhysicsMode::Static;
        word->body.vel  = Vec3{0, 0, 0};

        Wall::Drop drop = Wall::attach_ToNearestWall(std::move(word));
        if (!drop.token) continue;

        switch (drop.role) {
            case Wall::Role::Editor:   edit_block.push_back(std::move(drop.token)); break;
            case Wall::Role::Program:  prog_block.push_back(std::move(drop.token)); break;
            case Wall::Role::Compiler: comp_block.push_back(std::move(drop.token)); break;
            case Wall::Role::Info:     info_block.push_back(std::move(drop.token)); break;
        }
    }

    if (!edit_block.empty()) {
        Vec3 impact{0, 0, 0};

        for (const Text::TokenPtr& t : edit_block)
            { impact.x += t->pos.x; impact.y += t->pos.y; impact.z += t->pos.z; }

        const double inv = 1.0 / static_cast<double>(edit_block.size());
        impact.x *= inv;
        impact.y *= inv;
        impact.z *= inv;

        _wall_editor.attach(std::move(edit_block), impact);
    }

    { FP_ZONE("flush.append");
        if (!prog_block.empty()) _wall_program .append_Tokens(std::move(prog_block));
        if (!comp_block.empty()) _wall_compiler.append_Tokens(std::move(comp_block));
        if (!info_block.empty()) _wall_info    .append_Tokens(std::move(info_block));
    }
}


void
GameState::projectile_LandOnWallText(ProjectileResult& result)
{
    const size_t idx = result.wall_text_index;

    if (idx >= LABEL_HIT_BASE) {
        for (Text::TokenPtr& word : result.words)
            if (word) _ctx.drop_WordToFloor(std::move(word), result.vel);

        return;
    }

    if (idx >= WALL_TOK_BASE) {
        const size_t rel = idx - WALL_TOK_BASE;
        const size_t handler_id =  rel >> 28;
        const size_t line_idx   = (rel >> 14) & 0x3FFF;
        const size_t token_idx  =  rel        & 0x3FFF;

        Text::Handler* handler = (handler_id < _text_walls.size()) 
                               ? _text_walls[handler_id]
                               : nullptr;

        if (handler) {
            const auto& lines = handler->get_Lines();
            if ( line_idx < lines.size()
                 && token_idx < lines[line_idx].tokens.size()
                 && lines[line_idx].tokens[token_idx]) {

                if (handler->is_Editing()) {
                    Vec3 impact = lines[line_idx].tokens[token_idx]->pos;
                    for (const Text::TokenPtr& word : result.words)
                        if (word) { impact = word->pos; break; }
                    if (result.use_aim) impact = result.aim_point;

                    const Text::Handler::EditorHit hit = handler->classify_Impact(impact);

                    if (hit.kind == Text::Handler::HitKind::DropToken) {
                        const auto& ls = handler->get_Lines();
                        if ( hit.line_idx < ls.size() 
                             && hit.token_idx < ls[hit.line_idx].tokens.size()
                             && ls[hit.line_idx].tokens[hit.token_idx]) {
                            if (Text::TokenPtr orphan = handler->detach(ls[hit.line_idx].tokens[hit.token_idx].get())) {
                                orphan->body.hover_height = World::get_FloorY<double>() + 1.5;
                                orphan->yaw = 0.0f;
                                _ctx.drop_WordToFloor(std::move(orphan), Vec3{0.0, 1.5, 0.0});
                            }
                        }
                        for (Text::TokenPtr& word : result.words)
                            if (word) _ctx.drop_WordToFloor(std::move(word), result.vel);
                    }
                    else {
                        DynArray<Text::TokenPtr> block;
                        for (Text::TokenPtr& word : result.words)
                            if (word) block.push_back(std::move(word));
                        handler->attach(std::move(block), impact);
                    }
                    return;
                }

                if (Text::TokenPtr orphan = handler->detach(lines[line_idx].tokens[token_idx].get())) {
                    orphan->body.hover_height = World::get_FloorY<double>() + 1.5;
                    orphan->yaw = 0.0f;
                    _ctx.drop_WordToFloor(std::move(orphan), Vec3{0.0, 1.5, 0.0});
                }
            }
        }
        for (Text::TokenPtr& word : result.words)
            if (word) _ctx.drop_WordToFloor(std::move(word), result.vel);
        return;
    }

    if (idx >= Text::Handler::HIT_BASE) {
        const size_t lid   = Text::Handler::line_Of(idx);
        const double delta = _wall_editor.scroll_DeltaToEye(lid, _ctx.camera->pos.y);
        _wall_editor.scroll(delta);
        _wall_editor.bounce(lid);
        projectile_LandOnFloor(result);
        return;
    }

    for (Text::TokenPtr& word : result.words)
        if (word) _ctx.drop_WordToFloor(std::move(word), result.vel);
}


void
GameState::handle_Event(Game& game, const SDL_Event& event)
{
    _input_sys->handle_Event(game, event, _frame_input);
}


void
GameState::update(double dt, const GlRenderer* gl)
{
    if (gl) { _fov_y = gl->get_Fov_y(); _aspect = gl->get_Aspect(); }

    FrameInput& input = _frame_input;

    { FP_ZONE("update.input"      ); update_Input       (input, dt); }
    { FP_ZONE("update.world_sim"  ); update_Physics     (dt); }
    { FP_ZONE("update.present"    ); update_Objects     (dt, gl);    }
    { FP_ZONE("update.subsystems" ); update_Compiler    ();   }
    { FP_ZONE("update.progression"); update_Progression (dt); }
    { FP_ZONE("update.highlight"  ); update_Highlighting();   }

    input.look_x   = 0;
    input.look_y   = 0;
    input.scroll_x = 0;
    input.scroll_y = 0;
    input.commands.clear();
}


void
GameState::update_Input(FrameInput& input, double dt)
{
    for (InputCommand cmd : input.commands) {
        switch (cmd) {
            case InputCommand::Jump:              on_Jump();              break;
            case InputCommand::Click:             on_Click();             break;
            case InputCommand::Drop:              on_DropLast();          break;
            case InputCommand::ShootAll:          on_ShootAll();          break;
            case InputCommand::Compile:           on_Compile();           break;
            case InputCommand::Run:               on_Run();               break;
            case InputCommand::DebugPrint:        on_DebugPrint();        break;
            case InputCommand::FocusToggle:       on_FocusToggle();       break;
            case InputCommand::FreelookStart:     on_FreelookStart();     break;
            case InputCommand::FreelookEnd:       on_FreelookEnd();       break;
            case InputCommand::NumberInc:         on_NumberAdjust(+1);    break;
            case InputCommand::NumberDec:         on_NumberAdjust(-1);    break;
            case InputCommand::TypingStart:       on_TypingStart (input); break;
            case InputCommand::TypingCommit:      on_TypingCommit(input); break;
            case InputCommand::TypingCancel:      on_TypingCancel(input); break;
            case InputCommand::SandboxRestore:    on_SandboxRestore();    break;
            case InputCommand::ConnectPultSubmit: on_WallConnectSubmit(); break;
            case InputCommand::ConnectPultExit:   on_WallConnectExit();   break;
        }
    }

    Wall::Connection::Target
        target = _wall_connection.get_Target();

    if (target == Wall::Connection::Target::Editor) {
        { FP_ZONE("handle_input.Editor");
            Text::Handler::EditReq req = _wall_editor.feed_Keys(input.vim_keys, _ctx.camera->pos.y);
            input.vim_keys.clear();
            switch (req) {
                case Text::Handler::EditReq::Write:
                    _wall_editor.mark_Saved();
                    break;
                case Text::Handler::EditReq::WriteQuit:
                case Text::Handler::EditReq::Exit:
                case Text::Handler::EditReq::Discard:
                    _wall_editor.end_Edit();
                    _wall_connection.end();
                    _frame_input.capture_vim = false;
                    _focus_locked            = false;
                    break;
                case Text::Handler::EditReq::None:
                    break;
            }
        }
    }
    else if (target == Wall::Connection::Target::Program) {
        { FP_ZONE("handle_input.Program");
            _wall_connection.set_ProgramBuffer(input.typing_buffer);
        }
    }
    else if (input.typing_mode) {
        { FP_ZONE("handle_input.typing_mode");
            input.forward = input.backward = input.left = input.right = false;
            if (input.typing_buffer != _typing_last) {
                typing_RebuildPreview(input.typing_buffer);
                _typing_last = input.typing_buffer;
            }
        }
    }

    if (_pick_cooldown > 0.0) _pick_cooldown -= dt;
    if (input.pick && _pick_cooldown <= 0.0)
        if (on_Pick()) _pick_cooldown = 0.15;

    if (_shoot_cooldown > 0.0) _shoot_cooldown -= dt;
    if (input.shoot && _shoot_cooldown <= 0.0)
        { FP_ZONE("handle_input.onShootO"); if (on_ShootOne()) _shoot_cooldown = 0.15; }

    const bool want_focus = _focus_on || _focus_locked;

    if (!_freelook_sys->state.active && !want_focus)
        _ctx.player->add_Look(input.look_x, input.look_y);

    _freelook_sys->update(dt);

    if (want_focus) {
        const bool f = input.forward, b = input.backward,
                   l = input.left,    r = input.right;

        input.forward = input.backward = input.left = input.right = false;
        _ctx.player->compute_Movement(input);
        input.forward = f; input.backward = b; input.left = l; input.right = r;
    }
    else {
        _ctx.player->compute_Movement(input);
    }

    _ctx.camera->sync_FromPlayer(*_ctx.player);
    focus_UpdateWall(input, dt);
}


void
GameState::update_Objects(double dt, const GlRenderer* gl)
{
    for (Wall::Label& label : _wall_labels)
        label.update(dt, *_ctx.camera);

    carriedline_Update();

    _word_anim_sys->update(_anims_add, _anims_drop, _carried, _ctx.words, dt);

    if (_carried.size != _carried_last_size) {
        if (_carried.size > 0)
            CarryLex::lex_CarriedLine(_carried);
        _carried_last_size = _carried.size;
    }

    _pult_sys ->update(dt, *_ctx.camera);
    _plate_sys->update(dt, *_ctx.player, _level_director->get_StepPlates());

    for (Text::Handler* handler : _text_walls)
        if (handler) handler->update(dt, *_ctx.camera);

    _wall_connection.update(dt, *_ctx.camera, gl->get_Aspect(), gl->get_Fov_y());
    focus_UpdateLabel      (dt, *_ctx.camera, gl->get_Aspect(), gl->get_Fov_y());

    DynArray<GameObject*> particle_colliders;
    particle_colliders.reserve(_ctx.scenery.size());
    for (const GameObjectPtr& o : _ctx.scenery)
        if (o) particle_colliders.push_back(o.get());
    _particle_sys->update(dt, particle_colliders, World::get_BoxScale());
}


void
GameState::update_Compiler()
{
    _compiler_sys->update(_wall_compiler);
    if (_compiler_sys->last_compile_ok) {
        _compiler_sys->last_compile_ok = false;
        _level_director->record_CompileOk();
    }

    // TODO: the dyn path may still blocks the frame in dynlib_Apply
    _dyn_loader_sys->update(_wall_compiler);
    if (_dyn_loader_sys->last_build_ok) {
        _dyn_loader_sys->last_build_ok = false;
        dynlib_Apply(_dyn_loader_sys->load_Objects());
        SourceTokens::copy_File(sandbox_GetSourcePath(), sandbox_BackupPath());
    }

    _program_sys->update(_wall_program);
}


void
GameState::update_Highlighting()
{
    if (_wall_editor.is_Dirty())
        _highlight_sys->request(_wall_editor.render_Source(), _wall_editor.generation());

    DynArray<Text::HiSpan> spans;
    uint64_t gen;
    if (_highlight_sys->poll(spans, gen))
        _wall_editor.apply_Highlight(spans, gen);
}


void
GameState::update_Progression(double dt)
{
    _celebration_sys->tick(dt, *_particle_sys);
    level_TryAdvance(dt);
}


void
GameState::render(SDL_Window& window, GlRenderer& renderer)
{
    int w, h;
    SDL_GetWindowSizeInPixels(&window, &w, &h);

    { FP_ZONE("render.light_setup");
        const double yaw_r   = Config::Physics::binrad * static_cast<double>(_ctx.camera->yaw);
        const double pitch_r = Config::Physics::binrad * static_cast<double>(_ctx.camera->pitch);
        const double cy = SDL_cos(yaw_r),   sy = SDL_sin(yaw_r);
        const double cp = SDL_cos(pitch_r), sp = SDL_sin(pitch_r);
        _ctx.light->flashlight_pos = _ctx.camera->pos;
        _ctx.light->flashlight_dir = Vec3{ -sy*cp, sp, -cy*cp };
    }

    { FP_ZONE("render.begin_Frame"); renderer.begin_Frame(w, h); }
    { FP_ZONE("render.set_Light"  ); renderer.set_Light(*_ctx.light); }
    { FP_ZONE("render.world"      ); _ctx.world ->draw(renderer, *_ctx.camera, *_ctx.light); }

    { FP_ZONE("render.text_walls" );
        for (Text::Handler* handler : _text_walls)
            if (handler) handler->render(renderer, *_ctx.camera, *_ctx.light);
    }

    { FP_ZONE("render.scenery"    );
        for (const GameObjectPtr& scenery : _ctx.scenery)
            if (scenery) scenery->draw(renderer, *_ctx.camera, *_ctx.light);
    }

    { FP_ZONE("render.words"      );
        for (const Text::TokenPtr& word : _ctx.words)
            if (word) word->draw(renderer, *_ctx.camera, *_ctx.light);
    }

    { FP_ZONE("render.wall_labels");
        for (const Wall::Label& label : _wall_labels)
            label.draw(renderer, *_ctx.camera, *_ctx.light);
    }

    { FP_ZONE("render._carried"   );
        DynArray<CarriedWordNode*> nodes = _carried.nodes();
        for (const CarriedWordNode* node : nodes)
            if (node && node->word)
                node->word->draw(renderer, *_ctx.camera, *_ctx.light);
    }

    { FP_ZONE("render.anims"      );
        for (const WordAnim_Add& anim : _anims_add)
            if (anim.word) anim.word->draw(renderer, *_ctx.camera, *_ctx.light);
        for (const WordAnim_Drop& drop : _anims_drop)
            if (drop.word) drop.word->draw(renderer, *_ctx.camera, *_ctx.light);
    }

    { FP_ZONE("render.ai.stolen"  );
        for (const Text::TokenPtr& word : _ctx.words) {
            if (!word) continue;
            for (const Text::TokenPtr& s : word->ai.stolen)
                if (s) s->draw(renderer, *_ctx.camera, *_ctx.light);
        }
    }

    { FP_ZONE("render._projectiles");
        for (const Projectile& proj : _projectiles)
            for (const Text::TokenPtr& word : proj.words)
                if (word) word->draw(renderer, *_ctx.camera, *_ctx.light);
    }

    { FP_ZONE("render.hint"       ); _hint_sys        ->draw(renderer, *_ctx.camera, *_ctx.light); }
    { FP_ZONE("render.pult_labels"); _pult_sys        ->draw_Labels(renderer, *_ctx.camera, *_ctx.light); }
    { FP_ZONE("render.particles"  ); _particle_sys    ->draw(renderer, *_ctx.camera, *_ctx.light); }
    { FP_ZONE("render.celebration"); _celebration_sys ->draw(renderer, *_ctx.camera, *_ctx.light); }
    { FP_ZONE("render.wall_edit"  ); _wall_connection.render(renderer, *_ctx.camera, *_ctx.light); }
    { FP_ZONE("render.focus_label");
        if (_focus_active && _focus_label) {
            const double hw = static_cast<double>(_focus_label->half_extent);
            _focus_label->pos = Vec3{
                _focus_label_pos.x + _ctx.camera->right.x * hw,
                _focus_label_pos.y + _ctx.camera->right.y * hw,
                _focus_label_pos.z + _ctx.camera->right.z * hw,
            };
            _focus_label->yaw   = _focus_label_yaw;
            _focus_label->pitch = 0.0f;
            renderer.clear_Depth();
            _focus_label->draw(renderer, *_ctx.camera, *_ctx.light);
        }
    }
    { FP_ZONE("render.end_Frame"  ); renderer.end_Frame(&window); }
}


void
GameState::update_Physics(double dt)
{
    _ctx.player->prev_pos = _ctx.player->pos;

    { FP_ZONE("updateworldsim.phys_player" );
        _physics_sys->update_Player(*_ctx.player,
                                     _ctx.player->get_AccX(),
                                     _ctx.player->get_AccZ(),
                                     dt);
    }

    DynArray<GameObject*> colliders;
    colliders.reserve(_ctx.scenery.size() + _ctx.words.size());
    for (const GameObjectPtr&  o : _ctx.scenery) if (o) colliders.push_back(o.get());
    for (const Text::TokenPtr& w : _ctx.words)   if (w) colliders.push_back(w.get());

    { FP_ZONE("updateworldsim.phys_objects");
        _physics_sys->update_Objects(colliders, dt, 25.0);
    }

    { FP_ZONE("updateworldsim.word_ai"     );
        DynArray<Text::TokenPtr> ai_dropped;
        _word_ai_sys->update(_ctx.words, *_ctx.player, _carried, ai_dropped, dt);

        for (Text::TokenPtr& drop : ai_dropped)
            if (drop) _ctx.words.push_back(std::move(drop));
    }

    { FP_ZONE("updateworldsim.phys_projectiles");
        _physics_sys->update_Projectiles(_projectiles, dt, _ctx.words);
    }

    DynArray<Text::Handler*> all_labels = _pult_sys->get_Labels();
    { FP_ZONE("updateworldsim.hint_and_labels" );
        _hint_sys->update(dt, *_ctx.camera);
        for (Text::Handler* label : all_labels)
            label->update(dt, *_ctx.camera);

        all_labels.push_back(&_hint_sys->get_Label());
        for (Wall::Label& label : _wall_labels)
            all_labels.push_back(&label.label);
    }

    DynArray<ProjectileContactEvent> projectile_contacts;
    { FP_ZONE("updateworldsim.resolve_projectiles");
        _physics_sys->resolve_Projectiles(_projectiles, &colliders, &all_labels, &projectile_contacts);
    }

    DynArray<WallTextHitTest> wall_text_hits;
    { FP_ZONE("updateworldsim.build_wall_hits" );
        for (size_t i = 0; i < _text_walls.size(); ++i) {
            Text::Handler* handler = _text_walls[i];
            if (!handler) continue;

            const auto& elines = handler->get_Lines();
            for (size_t line_idx = 0; line_idx < elines.size(); ++line_idx) {
                const DynArray<Text::TokenPtr>& toks = elines[line_idx].tokens;
                for (size_t token_idx = 0; token_idx < toks.size(); ++token_idx) {
                    if (!toks[token_idx]) continue;

                    wall_text_hits.push_back({
                            toks[token_idx]->aabb_Min(),
                            toks[token_idx]->aabb_Max(),
                            WALL_TOK_BASE | (i << 28) | (line_idx << 14) | token_idx,
                            Wall::Position::NegX,
                        });
                }
            }
        }

        for (const Text::Handler::NumberHit& nh : _wall_editor.number_Hitboxes())
            wall_text_hits.push_back({
                    nh.aabb_Min,
                    nh.aabb_Max,
                    nh.index,
                    Wall::Position::NegX,
                });

        for (size_t line_idx = 0; line_idx < _wall_labels.size(); ++line_idx) {
            const DynArray<Text::TokenPtr>& words = _wall_labels[line_idx].label.get_Words();
            for (size_t wi = 0; wi < words.size(); ++wi) {
                if (!words[wi]) continue;

                wall_text_hits.push_back({
                        words[wi]->aabb_Min(),
                        words[wi]->aabb_Max(),
                        LABEL_HIT_BASE | (line_idx << 16) | wi,
                        Wall::position_Of(_wall_labels[line_idx].role),
                    });
            }
        }
    }

    DynArray<ProjectileResult> proj_results;
    { FP_ZONE("updateworldsim.projectile_sys" );
        _projectiles_sys->update(_projectiles, proj_results,
                                 wall_text_hits, dt,
                                 _program_sys->is_Running());
    }

    { FP_ZONE("updateworldsim.projectile_land");
        for (ProjectileResult& result : proj_results) {
            switch (result.landing) {
                case ProjectileLanding::Floor:    projectile_LandOnFloor       (result); break;
                case ProjectileLanding::ProgramInput:
                                                  projectile_LandOnProgramInput(result); break;
                case ProjectileLanding::Wall:     projectile_LandOnWall        (result); break;
                case ProjectileLanding::WallText: projectile_LandOnWallText    (result); break;
                case ProjectileLanding::None:     break;
            }
        }

        for (const ProjectileContactEvent& contact : projectile_contacts) {
            if (std::optional<PultAction> action =
                        _pult_sys->handle_ProjectileContact(contact, _ctx.buttons); action) {
                pult_RunAction(*action);
                continue;
            }

            if (contact.other && contact.other->type == ObjectType::Text) {
                Text::Token::Data* word = static_cast<Text::Token::Data*>(contact.other);
                _level_director->record_AiDefeated(word->text);
                word->ai.state       = Text::Token::Ai::State::Stunned;
                word->ai.state_timer = 0.0;
                word->body.vel       = Vec3{0, 0, 0};
                word->set_Color      (Config::Color::stunned);
                word->set_ColorSource(Text::ColorSource::Static);
            }
        }
    }

    { FP_ZONE("updateworldsim.player_collision");
        PlayerCollision col = _ctx.world->resolve_Player(*_ctx.player);
        _ctx.player->apply_Collision(col);

        _physics_sys->resolve_PlayerVsObjects(*_ctx.player, _carried, colliders);

        if (std::optional<PultAction> act =
                _pult_sys->handle_PlayerContact(*_ctx.player, _ctx.buttons); act)
            pult_RunAction(*act);
    }
}


void
GameState::level_TryAdvance(double dt)
{
    Level::Director::AdvanceResult res =
        _level_director->try_Advance(_ctx, dt, *_celebration_sys, *_particle_sys);

    if (!res.stepped) return;

    dynlib_Clear();

    if (res.reset_carry) {
        _ctx.words  .clear();
        _projectiles.clear();
        _anims_add  .clear();
        _anims_drop .clear();
        _carried = CarriedLine();
    }

    if (res.advanced) {
        _level_director->enter_Step(_ctx);
        _word_ai_sys->set_MaxActiveAi(_level_director->get_MaxAiForStep());
        _hint_sys   ->set_Hint       (_level_director->get_CurrentHint());
    } else {
        _hint_sys      ->clear();
        _level_director->show_Details(_ctx);
    }
}


void
GameState::dynlib_Clear()
{
    if (_dyn_scenery.empty()) { return; }

    std::unordered_set<GameObject*>
        set(_dyn_scenery.begin(), _dyn_scenery.end());

    _ctx.scenery.erase( std::remove_if(_ctx.scenery.begin(), _ctx.scenery.end(),
                        [&set](const GameObjectPtr& sp) { return set.count(sp.get()) > 0; }),
                        _ctx.scenery.end() );
    _ctx.words  .erase( std::remove_if(_ctx.words.begin(), _ctx.words.end(),
                        [&set](const Text::TokenPtr& wp) { return set.count(wp.get()) > 0; }),
                        _ctx.words.end() );
    _dyn_scenery.clear();
}


void
GameState::dynlib_Apply(const DynArray<DynObject>& objs)
{
    dynlib_Clear();

    for (const DynObject& o : objs) {
        if (o.kind == DYN_BOX) {
            auto box = cpputils::make_unique<SolidBox>(
                            Vec3{ o.x , o.y , o.z  },
                            Vec3{ o.hx, o.hy, o.hz },
                            SDL_FColor{ o.r, o.g, o.b, 1.0f });

            box->body.mode = o.dynamic
                           ? PhysicsMode::Dynamic
                           : PhysicsMode::Static;

            _dyn_scenery.push_back(box.get());
            _ctx.scenery.push_back(std::move(box));
        }
        else {
            Text::TokenPtr t = Text::make_Token(o.text, Text::Token::depth_floor, 0.003f,
                                                Vec3{ o.x, o.y, o.z },
                                                SDL_FColor{ o.r, o.g, o.b, 1.0f });
            t->body.mode = PhysicsMode::Static;

            _dyn_scenery.push_back(t.get());
            _ctx.words  .push_back(std::move(t));
        }
    }
}

std::string
GameState::sandbox_GetSourcePath() const {
    return _level_director
           ? _level_director->get_Runner().get_SourceFile()
           : std::string{};
}

std::string
GameState::sandbox_BackupPath() const {
    std::string c = sandbox_GetSourcePath();
    return c.empty() ? c : (c + ".last_ok");
}

void
GameState::sandbox_LoadFileToWall()
{
    const std::string path = sandbox_GetSourcePath();
    if (path.empty()) return;

    std::string src;
    if (!SourceTokens::read_File(path, src)) {
        SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Sandbox: cannot read %s", path.c_str());
        return;
    }

    DynArray<std::string> lines;
    std::string cur;
    for (char c : src) {
        if      (c == '\n') { lines.push_back(cur); cur.clear(); }
        else if (c != '\r') { cur += c; }
    }

    if (!cur .empty()) lines.push_back(cur);
    if (lines.empty()) lines.push_back("");

    _wall_editor.set_Lines(lines);
}


void
GameState::on_SandboxRestore() {
    if (!_level_director || !_level_director->get_Runner().is_DynamicLib())
        return;
    if (SourceTokens::copy_File(sandbox_BackupPath(), sandbox_GetSourcePath()))
        sandbox_LoadFileToWall();
}


void
GameState::typing_RebuildPreview(const std::string& typed)
{
    typing_ClearPreview();

    const Vec3   anchor    = CarriedLine::get_LineAnchor(*_ctx.camera, Config::Carry::dist);
    const double glyph_yaw = SDL_atan2(-_ctx.camera->fwd.x, -_ctx.camera->fwd.z);

    std::istringstream in(typed);
    std::string        tok_str;

    while (in >> tok_str) {
        Text::TokenPtr token = Text::make_Token(tok_str, Text::Token::depth_floor, 0.003f,
                                                anchor, Config::Color::floor);
        token->yaw       = static_cast<float>(glyph_yaw);
        token->body.mode = PhysicsMode::Carried;
        _typing_words.push_back(token.get());
        _carried.push(std::move(token));
    }
    CarryLex::lex_CarriedLine(_carried);
}


void
GameState::typing_ClearPreview()
{
    for (size_t i = 0; i < _typing_words.size(); i++) {
        if (_carried.empty()) break;
        _carried.pop_back();
    }

    _typing_words.clear();
    CarryLex::lex_CarriedLine(_carried);
}


void
GameState::carry_BeginAnim(Text::TokenPtr word, bool with_pitch)
{
    WordAnim_Add anim;
    anim.start_pos   = word->pos;
    anim.yaw_start   = word->yaw;
    if (with_pitch) {
        anim.pitch_start  = word->pitch;
        anim.pitch_target = 0.0;
    }
    anim.word        = std::move(word);
    anim.t           = 0.0;
    anim.complete    = false;

    const Vec3   anchor    = CarriedLine::get_LineAnchor(*_ctx.camera, Config::Carry::dist);
    const double glyph_yaw = SDL_atan2(-_ctx.camera->fwd.x, -_ctx.camera->fwd.z);
    anim.target_pos = anchor;
    anim.yaw_target = static_cast<float>(glyph_yaw);

    _anims_add.push_back(std::move(anim));
}


void
GameState::wallconnect_BeginProgram()
{
    _frame_input.capture_typing = true;
    _frame_input.typing_buffer.clear();

    _wall_connection.begin_Program(_pult_sys->get_ConnectProgramPos());
}


void
GameState::wallconnect_BeginEditor()
{
    _wall_editor.begin_Edit(_ctx.camera->pos.y);

    _frame_input.capture_vim = true;
    _frame_input.vim_keys.clear();

    _wall_connection.begin_Editor(_pult_sys->get_ConnectEditorPos(), _wall_editor);
}


void
GameState::on_WallConnectSubmit()
{
    std::string line = _frame_input.typing_buffer;

    _frame_input.typing_buffer.clear();

    _wall_connection.clear_ProgramPreview();

    if (line == ":exit") { on_WallConnectExit(); return; }
    if (line == ":help") {
        for (const char* h : { ":exit            leave the connection",
                               "ctrl+WASD        move while connected",
                               "<text> + Enter   send a line to the program (stdin)" })
            _program_sys->echo_Input(_wall_program, h);
        return;
    }

    _program_sys->feed_Input(line);
    _program_sys->echo_Input(_wall_program, line);
}


void
GameState::on_WallConnectExit()
{
    _wall_connection.end();

    _frame_input.capture_typing = false;
    _frame_input.typing_buffer.clear();
    _focus_locked = false;
}


void
GameState::on_EditorClear()
{
    _wall_editor.clear_ToFloor(_ctx.words);
}


void
GameState::on_EditorScroll(int8_t dt)
{
    if (dt > 0) _wall_editor.scroll( Config::Wall::scroll_step);
    else        _wall_editor.scroll(-Config::Wall::scroll_step);
}


Text::Handler*
GameState::get_Handler(Wall::Role role)
{
    switch (role) {
        case Wall::Role::Editor:   return &_wall_editor;
        case Wall::Role::Program:  return &_wall_program;
        case Wall::Role::Compiler: return &_wall_compiler;
        case Wall::Role::Info:     return &_wall_info;
    }
    return nullptr;
}



bool
GameState::focus_ComputeTarget()
{
    const Camera& cam = *_ctx.camera;
    const float  ws  = World::get_BoxScale<float>();

    const Wall::Role roles[4] = { Wall::Role::Editor, Wall::Role::Program,
                                  Wall::Role::Info,   Wall::Role::Compiler };
    Wall::Role best_role = Wall::Role::Editor;
    bool   found  = false;
    double best_t = 1e30;
    for (Wall::Role role : roles) {
        const Vec3 n = Wall::inward_Normal(Wall::position_Of(role));
        const Vec3 p = Wall::get_Position(role, ws);
        const double denom = cam.fwd.x*n.x + cam.fwd.y*n.y + cam.fwd.z*n.z;
        if (denom < -1e-4) {
            const double t = ((p.x-cam.pos.x)*n.x + (p.y-cam.pos.y)*n.y + (p.z-cam.pos.z)*n.z) / denom;
            if (t > 0.1 && t < best_t) { best_t = t; best_role = role; found = true; }
        }
    }
    if (!found) return false;

    const Wall::Transform tf = Wall::get_Transform(best_role, ws);
    const Vec3 n = Wall::inward_Normal(tf.wall_position);
    const bool horiz_is_z = (tf.wall_position == Wall::Position::PosX ||
                             tf.wall_position == Wall::Position::NegX);

    double y_min=1e30, y_max=-1e30, h_min=1e30, h_max=-1e30; bool any=false;
    if (Text::Handler* hw = get_Handler(best_role))
        for (const Text::Line& line : hw->get_Lines())
            for (const Text::TokenPtr& tp : line.tokens) {
                if (!tp || tp->is_Whitespace()) continue;
                const double hc = horiz_is_z ? tp->pos.z : tp->pos.x;
                y_min = SDL_min(y_min, tp->pos.y - static_cast<double>(tp->half_extent_y));
                y_max = SDL_max(y_max, tp->pos.y + static_cast<double>(tp->half_extent_y));
                h_min = SDL_min(h_min, hc        - static_cast<double>(tp->half_extent));
                h_max = SDL_max(h_max, hc        + static_cast<double>(tp->half_extent));
                any = true;
            }

    double cy, ch, text_w, text_h;
    if (any) { cy=(y_min+y_max)*0.5; ch=(h_min+h_max)*0.5; text_w=h_max-h_min; text_h=y_max-y_min; }
    else     { cy=tf.position.y;     ch=0.0;               text_w=2.0;          text_h=2.0; }

    const double fov      = (_fov_y  > 0.01f) ? (double)_fov_y  : (SDL_PI_D / 3.0);
    const double asp      = (_aspect > 0.01f) ? (double)_aspect : 1.7;
    const double half_tan = SDL_tan(fov * 0.5);
    constexpr double FIT  = 0.85;
    const double d_v = (text_h * 0.5) / (FIT * half_tan);
    const double d_h = (text_w * 0.5) / (FIT * half_tan * asp);

    Vec3 center = tf.position;
    center.y = cy;
    if (horiz_is_z) center.z = ch; else center.x = ch;

    _focus_center = center;
    _focus_normal = n;
    _focus_dist   = SDL_max(SDL_max(d_v, d_h), 2.0);
    _focus_yaw_r  = tf.yaw;
    _focus_role   = best_role;
    return true;
}


void
GameState::focus_UpdateWall(FrameInput& input, double dt)
{
    const bool want = _focus_on || _focus_locked;
    constexpr double DUR = 0.25;

    if (want) {
        if (!_focus_active) {
            if (!focus_ComputeTarget()) return;
            _focus_active = true;
        }
        _focus_t = SDL_min(1.0, _focus_t + dt / DUR);

        const double ws = World::get_BoxScale();

        // dolly (scroll wheel): wheel up = toward the wall, down = away. Clamped
        // inside the room. Scale by distance so each notch feels proportional.
        constexpr double DOLLY = 1.2, MIN_D = 0.6;
        const double MAX_D = 2.0 * ws - 0.6;
        _focus_dist -= input.scroll_y * DOLLY * (_focus_dist * 0.15 + 1.0);
        _focus_dist  = SDL_clamp(_focus_dist, MIN_D, MAX_D);

        // pan via mouse motion — smooth, any direction (look is suppressed while
        // focused, so the mouse deltas are ours). Scale by distance so the pan
        // feels the same in screen space whether zoomed in or out.
        constexpr double PAN_SENS = 0.0016;
        const double k    = PAN_SENS * _focus_dist;
        const double rx   = SDL_cos(_focus_yaw_r);     // focus camera right axis (y = 0)
        const double rz   = -SDL_sin(_focus_yaw_r);
        const double pan_h = (double)input.look_x * k;
        const double pan_v = (double)input.look_y * k;
        const double lim   = ws - 0.5;

        _focus_center.x = SDL_clamp(_focus_center.x + rx * pan_h, -lim, lim);
        _focus_center.z = SDL_clamp(_focus_center.z + rz * pan_h, -lim, lim);
        _focus_center.y = SDL_clamp(_focus_center.y - pan_v,      -lim, lim);
    } else {
        if (!_focus_active) return;
        _focus_t -= dt / DUR;
        if (_focus_t <= 0.0) { _focus_t = 0.0; _focus_active = false; return; }
    }

    const Vec3 focus_pos = { _focus_center.x + _focus_normal.x * _focus_dist,
                             _focus_center.y + _focus_normal.y * _focus_dist,
                             _focus_center.z + _focus_normal.z * _focus_dist };

    Camera& cam = *_ctx.camera;
    const double s = _focus_t * _focus_t * (3.0 - 2.0 * _focus_t);

    double dyaw = _focus_yaw_r - cam.yaw_r;
    while (dyaw >  SDL_PI_D) dyaw -= 2.0 * SDL_PI_D;
    while (dyaw < -SDL_PI_D) dyaw += 2.0 * SDL_PI_D;

    const double yaw_r   = cam.yaw_r   + dyaw * s;
    const double pitch_r = cam.pitch_r + (0.0 - cam.pitch_r) * s;
    const Vec3   pos     = { cam.pos.x + (focus_pos.x - cam.pos.x) * s,
                             cam.pos.y + (focus_pos.y - cam.pos.y) * s,
                             cam.pos.z + (focus_pos.z - cam.pos.z) * s };

    const double binrad = Config::Physics::binrad;
    cam.pos     = pos;
    cam.yaw_r   = yaw_r;
    cam.pitch_r = pitch_r;
    cam.yaw     = (uint32_t)SDL_lround(yaw_r   / binrad);
    cam.pitch   = (int32_t) SDL_lround(pitch_r / binrad);
    cam.fwd     = { -SDL_sin(yaw_r)*SDL_cos(pitch_r), SDL_sin(pitch_r), -SDL_cos(yaw_r)*SDL_cos(pitch_r) };
    cam.right   = {  SDL_cos(yaw_r), 0.0, -SDL_sin(yaw_r) };
}


void
GameState::focus_UpdateLabel(double dt, const Camera& cam, float aspect, float fov_y)
{
    if (!_focus_active) { _focus_label_init = false; return; }

    if (!_focus_label)
        _focus_label = Text::make_Token("focus", Text::Token::depth_wall, 0.0016f,
                                        Vec3{0, 0, 0}, SDL_FColor{0.35f, 0.55f, 1.0f, 1.0f});

    const Vec3 up = Math::cross(cam.right, cam.fwd);

    const double dist   = 2.2;
    const double half_h = dist * static_cast<double>(SDL_tanf(fov_y * 0.5f));
    const double half_w = half_h * static_cast<double>(aspect);

    const double fx   = 0.82;
    const double fy   = 0.80;
    const double left = half_w * fx;
    const double upamt= half_h * fy;

    const Vec3 target {
        cam.pos.x + cam.fwd.x*dist - cam.right.x*left + up.x*upamt,
        cam.pos.y + cam.fwd.y*dist - cam.right.y*left + up.y*upamt,
        cam.pos.z + cam.fwd.z*dist - cam.right.z*left + up.z*upamt,
    };

    if (!_focus_label_init) { _focus_label_pos = target; _focus_label_init = true; }

    const double k = 1.0 - SDL_exp(-6.0 * dt);     // exponential trail = "delay in movement"
    _focus_label_pos.x += (target.x - _focus_label_pos.x) * k;
    _focus_label_pos.y += (target.y - _focus_label_pos.y) * k;
    _focus_label_pos.z += (target.z - _focus_label_pos.z) * k;

    _focus_label_yaw = static_cast<float>(SDL_atan2(-cam.fwd.x, -cam.fwd.z));
}

} // namespace Nimbin