Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / commits / 83acb3c

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

Refractor Text3d Ai in own struct

Christian Immanuel · 2026-06-01 08:37 · 83acb3cfe8671ffb7426497dc228d4d5abd42a9a

 src/levels/level_list.cpp      |   2 +-
 src/levels/scene.hpp           |   2 +-
 src/states/game_state.cpp      |  26 ++++----
 src/systems/pick_system.cpp    |   2 +-
 src/systems/word_ai_system.cpp | 142 ++++++++++++++++++++---------------------
 src/text_3d/text_3d.cpp        |   8 +--
 src/text_3d/text_3d.hpp        |  46 +++++++------
 7 files changed, 117 insertions(+), 111 deletions(-)

diff --git a/src/levels/level_list.cpp b/src/levels/level_list.cpp
index 6d5a3e7..e8b631a 100644
--- a/src/levels/level_list.cpp
+++ b/src/levels/level_list.cpp
@@ -179,7 +179,7 @@ Aim with the crosshair and LEFT CLICK.)",
                         {
                             .text           = "danger",
                             .pos            = { -6.0, static_cast<double>(-WORLD_BOX_SCALE + 2.0f), -6.0 },
-                            .initial_state  = Text3d::Ai::Attack,
+                            .initial_state  = Text3dAI::State::Attack,
                             .aggressiveness = 1.0,
                         },
                     },
diff --git a/src/levels/scene.hpp b/src/levels/scene.hpp
index 954f60d..cfb659a 100644
--- a/src/levels/scene.hpp
+++ b/src/levels/scene.hpp
@@ -42,7 +42,7 @@ struct PlacedPlatform {
 struct PlacedAi {
     std::string  text;
     Vec3         pos;
-    Text3d::Ai   initial_state  = Text3d::Ai::Attack;
+    Text3dAI::State initial_state  = Text3dAI::State::Attack;
     double       aggressiveness = 1.0;
 };
 
diff --git a/src/states/game_state.cpp b/src/states/game_state.cpp
index 82d9257..237a6d3 100644
--- a/src/states/game_state.cpp
+++ b/src/states/game_state.cpp
@@ -675,10 +675,10 @@ GameState::render(SDL_Window& window, GlRenderer& renderer)
             if (d.word) d.word->draw(renderer, *_ctx.camera, *_ctx.light);
     }
 
-    { FP_ZONE("render.ai_stolen");
+    { FP_ZONE("render.ai.stolen");
         for (const Text3dPtr& t : _ctx.words) {
             if (!t) continue;
-            for (const Text3dPtr& s : t->ai_stolen)
+            for (const Text3dPtr& s : t->ai.stolen)
                 if (s) s->draw(renderer, *_ctx.camera, *_ctx.light);
         }
     }
@@ -791,23 +791,23 @@ GameState::scene_Setup(const Scene& scene)
         Text3dPtr t = make_Text3d(a.text, Text3d::depth_floor, 0.003f,
                                   a.pos, Text3d::color_floor);
         t->body.hover_height = a.pos.y;
-        t->ai_aggressiveness = a.aggressiveness;
+        t->ai.aggressiveness = a.aggressiveness;
 
         switch (a.initial_state) {
-            case Text3d::Ai::Attack:
-                t->ai_state  = Text3d::Ai::Attack;
+            case Text3dAI::State::Attack:
+                t->ai.state  = Text3dAI::State::Attack;
                 t->body.mode = PhysicsMode::AiHover;
                 t->set_Color(Text3d::color_attack);
-                t->ai_state_timer = 0.0;
-                t->ai_phase_timer = 0.5;
+                t->ai.state_timer = 0.0;
+                t->ai.phase_timer = 0.5;
                 break;
-            case Text3d::Ai::Flee:
-                t->ai_state  = Text3d::Ai::Flee;
+            case Text3dAI::State::Flee:
+                t->ai.state  = Text3dAI::State::Flee;
                 t->body.mode = PhysicsMode::AiHover;
                 t->set_Color(Text3d::color_flee);
                 break;
-            case Text3d::Ai::Idle:
-            case Text3d::Ai::Stunned:
+            case Text3dAI::State::Idle:
+            case Text3dAI::State::Stunned:
                 break;
         }
 
@@ -937,8 +937,8 @@ GameState::substep(double dt)
                 Text3d* word = static_cast<Text3d*>(ev.other);
                 _step_ledger.ai_defeated.push_back(word->text);
 
-                word->ai_state       = Text3d::Ai::Stunned;
-                word->ai_state_timer = 0.0;
+                word->ai.state       = Text3dAI::State::Stunned;
+                word->ai.state_timer = 0.0;
                 word->body.vel       = Vec3{0, 0, 0};
                 word->set_Color(Text3d::color_stunned);
             }
diff --git a/src/systems/pick_system.cpp b/src/systems/pick_system.cpp
index ced445f..6f6b43a 100644
--- a/src/systems/pick_system.cpp
+++ b/src/systems/pick_system.cpp
@@ -80,7 +80,7 @@ pick(const Camera& camera,
     for (size_t i = 0; i < words.size(); ++i) {
         const Text3dPtr& w = words[i];
         if (!w || !w->interactable) continue;
-        if (w->ai_state != Text3d::Ai::Idle) continue;
+        if (w->ai.state != Text3dAI::State::Idle) continue;
 
         double a = ang_score(*w, eye, camera.fwd);
         if (a >= 0.0 && a < best_angle) {
diff --git a/src/systems/word_ai_system.cpp b/src/systems/word_ai_system.cpp
index c141d4b..b2e7790 100644
--- a/src/systems/word_ai_system.cpp
+++ b/src/systems/word_ai_system.cpp
@@ -39,7 +39,7 @@ WordAiSystem::update(DynArray<Text3dPtr>& words,
     int active_ai_count = 0;
     for (const Text3dPtr& w : words) {
         if (!w) continue;
-        if (w->ai_state != Text3d::Ai::Idle) ++active_ai_count;
+        if (w->ai.state != Text3dAI::State::Idle) ++active_ai_count;
     }
 
     // Index-based loop so reallocation doesn't invalidate.
@@ -49,31 +49,31 @@ WordAiSystem::update(DynArray<Text3dPtr>& words,
         Text3dPtr& w = words[i];
         if (!w) continue;
 
-        if (w->ai_state != Text3d::Ai::Idle &&
+        if (w->ai.state != Text3dAI::State::Idle &&
             w->body.mode != PhysicsMode::AiHover &&
             w->body.mode != PhysicsMode::Hover)
         {
-            for (Text3dPtr& s : w->ai_stolen) {
+            for (Text3dPtr& s : w->ai.stolen) {
                 if (!s) continue;
                 s->body.mode = PhysicsMode::Dynamic;
                 s->body.vel  = w->body.vel * 0.5;
                 s->set_Color(s->token_info ? TokenColor::get(s->token_info->type) : Text3d::color_floor);
                 released.push_back(std::move(s));   // deferred — not words.push_back
             }
-            w->ai_stolen.clear();
-            w->ai_state = Text3d::Ai::Idle;
+            w->ai.stolen.clear();
+            w->ai.state = Text3dAI::State::Idle;
             w->set_Color(w->token_info ? TokenColor::get(w->token_info->type) : Text3d::color_floor);
             continue;
         }
 
-        w->ai_state_timer += dt;
+        w->ai.state_timer += dt;
 
-        switch (w->ai_state) {
-            case Text3d::Ai::Idle:    update_Idle(*w, player, active_ai_count, dt); break;
-            case Text3d::Ai::Attack:  update_Attack(*w, player, carried, words, dropped, released, dt);
+        switch (w->ai.state) {
+            case Text3dAI::State::Idle:    update_Idle(*w, player, active_ai_count, dt); break;
+            case Text3dAI::State::Attack:  update_Attack(*w, player, carried, words, dropped, released, dt);
                                                                                     break;
-            case Text3d::Ai::Flee:    update_Flee(*w, player, released, dt);        break;
-            case Text3d::Ai::Stunned: update_Stunned(*w, released, dt);             break;
+            case Text3dAI::State::Flee:    update_Flee(*w, player, released, dt);        break;
+            case Text3dAI::State::Stunned: update_Stunned(*w, released, dt);             break;
         }
 
         update_StolenTrail(*w, dt);
@@ -100,7 +100,7 @@ WordAiSystem::update_Idle(Text3d& w, const Player& p, int active_ai_count, doubl
 
     if (dist_sq > TRIGGER_RANGE * TRIGGER_RANGE) return;
 
-    double roll_threshold = IDLE_AGGRO_ROLL * w.ai_aggressiveness * dt;
+    double roll_threshold = IDLE_AGGRO_ROLL * w.ai.aggressiveness * dt;
     if (random_unit() > roll_threshold) return;
 
     if (random_unit() < FLEE_BIAS) enter_Flee(w);
@@ -124,12 +124,12 @@ void WordAiSystem::update_Attack(Text3d& w, const Player& p, CarriedLine& carrie
         constexpr double ORBIT_SPEED  = 0.6;
         constexpr double ORBIT_HEIGHT = 0.5;
 
-        w.ai_orbit_angle += w.ai_orbit_dir * ORBIT_SPEED * dt;
+        w.ai.orbit_angle += w.ai.orbit_dir * ORBIT_SPEED * dt;
 
         target = {
-            p.pos.x + ORBIT_RADIUS * SDL_cos(w.ai_orbit_angle),
+            p.pos.x + ORBIT_RADIUS * SDL_cos(w.ai.orbit_angle),
             p.pos.y + ORBIT_HEIGHT,
-            p.pos.z + ORBIT_RADIUS * SDL_sin(w.ai_orbit_angle)
+            p.pos.z + ORBIT_RADIUS * SDL_sin(w.ai.orbit_angle)
         };
     }
 
@@ -137,23 +137,23 @@ void WordAiSystem::update_Attack(Text3d& w, const Player& p, CarriedLine& carrie
     double dist = length(to_target);
 
     // Pause/dash phase machine (unchanged)
-    w.ai_phase_timer -= dt;
-    if (w.ai_phase_timer <= 0.0) {
-        w.ai_in_pause = !w.ai_in_pause;
-        w.ai_phase_timer = w.ai_in_pause
+    w.ai.phase_timer -= dt;
+    if (w.ai.phase_timer <= 0.0) {
+        w.ai.in_pause = !w.ai.in_pause;
+        w.ai.phase_timer = w.ai.in_pause
                            ? (0.4 + random_unit() * 0.8)
                            : (1.0 + random_unit() * 1.5);
     }
 
     Vec3 target_vel{};
-    if (!w.ai_in_pause && dist > 0.01) {
+    if (!w.ai.in_pause && dist > 0.01) {
         double inv = 1.0 / dist;
         Vec3 dir = { to_target.x * inv, to_target.y * inv, to_target.z * inv };
-        target_vel = dir * w.ai_speed;
+        target_vel = dir * w.ai.speed;
 
-        w.ai_wiggle_phase += w.ai_wiggle_freq * dt;
+        w.ai.wiggle_phase += w.ai.wiggle_freq * dt;
         Vec3 perp = perpendicular_horizontal(dir);
-        double wig = SDL_sin(w.ai_wiggle_phase) * WIGGLE_AMPLITUDE;
+        double wig = SDL_sin(w.ai.wiggle_phase) * WIGGLE_AMPLITUDE;
         if (!has_carry) wig *= 0.4;   // gentler wobble while orbiting
         target_vel.x += perp.x * wig;
         target_vel.z += perp.z * wig;
@@ -163,7 +163,7 @@ void WordAiSystem::update_Attack(Text3d& w, const Player& p, CarriedLine& carrie
 
     // ── Contact: attacker touches carried word? ──────────────────────
     if (!carried.empty()) {
-        if (w.ai_has_stolen) {
+        if (w.ai.has_stolen) {
             // Already stole this session — touching carry does nothing.
         } else {
             DynArray<CarriedWordNode*> nodes = carried.reverse_nodes();
@@ -175,7 +175,7 @@ void WordAiSystem::update_Attack(Text3d& w, const Player& p, CarriedLine& carrie
                     Text3dPtr stolen = std::move(node->word);
                     stolen->body.mode = PhysicsMode::AiHover;
                     stolen->set_Color(Text3d::color_attack);
-                    w.ai_stolen.push_back(std::move(stolen));
+                    w.ai.stolen.push_back(std::move(stolen));
                     enter_Flee(w);   // run away with the loot
                     carried.compact();
                     break;
@@ -198,7 +198,7 @@ void WordAiSystem::update_Attack(Text3d& w, const Player& p, CarriedLine& carrie
     for (Text3dPtr& other : all_words) {
         if (!other) continue;
         if (other.get() == &w) continue;
-        if (other->ai_state != Text3d::Ai::Idle)    continue;
+        if (other->ai.state != Text3dAI::State::Idle)    continue;
         if (other->body.mode != PhysicsMode::Hover) continue;
         if (!Collision::aabb_Overlap(w, *other))    continue;
 
@@ -230,23 +230,23 @@ WordAiSystem::update_Flee(Text3d& w, const Player& p, DynArray<Text3dPtr>& relea
     double dist = length(away);
 
     // Pause/dash similar to attack
-    w.ai_phase_timer -= dt;
-    if (w.ai_phase_timer <= 0.0) {
-        w.ai_in_pause = !w.ai_in_pause;
-        w.ai_phase_timer = w.ai_in_pause
+    w.ai.phase_timer -= dt;
+    if (w.ai.phase_timer <= 0.0) {
+        w.ai.in_pause = !w.ai.in_pause;
+        w.ai.phase_timer = w.ai.in_pause
                            ? (0.3 + random_unit() * 0.5)
                            : (1.5 + random_unit() * 1.5);
     }
 
     Vec3 target_vel{};
-    if (!w.ai_in_pause && dist > 0.01) {
+    if (!w.ai.in_pause && dist > 0.01) {
         double inv = 1.0 / dist;
         Vec3 dir = { away.x * inv, 0.0, away.z * inv };   // horizontal flee
-        target_vel = dir * w.ai_speed * 1.2;
+        target_vel = dir * w.ai.speed * 1.2;
 
-        w.ai_wiggle_phase += w.ai_wiggle_freq * dt;
+        w.ai.wiggle_phase += w.ai.wiggle_freq * dt;
         Vec3 perp = perpendicular_horizontal(dir);
-        double wig = SDL_sin(w.ai_wiggle_phase) * WIGGLE_AMPLITUDE * 0.6;
+        double wig = SDL_sin(w.ai.wiggle_phase) * WIGGLE_AMPLITUDE * 0.6;
         target_vel.x += perp.x * wig;
         target_vel.z += perp.z * wig;
     }
@@ -255,9 +255,9 @@ WordAiSystem::update_Flee(Text3d& w, const Player& p, DynArray<Text3dPtr>& relea
 
     // Escape condition: far enough, for long enough.
     if (dist > FLEE_ESCAPE_DIST) {
-        if (w.ai_state_timer > FLEE_ESCAPE_TIME) {
+        if (w.ai.state_timer > FLEE_ESCAPE_TIME) {
             // Got away. If carrying loot, roll for drop-vs-keep.
-            if (!w.ai_stolen.empty()) {
+            if (!w.ai.stolen.empty()) {
                 if (random_unit() < AI_DROP_LOOT_PROBABILITY) {
                     release_StolenWords(w, released);
                 }
@@ -277,7 +277,7 @@ WordAiSystem::update_Stunned(Text3d& w, DynArray<Text3dPtr>& released, double dt
 {
     apply_Locomotion(w, Vec3{}, dt);
 
-    if (w.ai_state_timer > STUNNED_DURATION) {
+    if (w.ai.state_timer > STUNNED_DURATION) {
         release_StolenWords(w, released);
         enter_Idle(w);
     }
@@ -286,43 +286,43 @@ WordAiSystem::update_Stunned(Text3d& w, DynArray<Text3dPtr>& released, double dt
 void
 WordAiSystem::release_StolenWords(Text3d& w, DynArray<Text3dPtr>& out)
 {
-    for (Text3dPtr& s : w.ai_stolen) {
+    for (Text3dPtr& s : w.ai.stolen) {
         if (!s) continue;
         s->body.mode = PhysicsMode::Dynamic;
         s->body.vel  = { w.body.vel.x * 0.5, 2.0, w.body.vel.z * 0.5 };
         s->set_Color(s->token_info ? TokenColor::get(s->token_info->type) : Text3d::color_floor);
         out.push_back(std::move(s));
     }
-    w.ai_stolen.clear();
+    w.ai.stolen.clear();
 }
 
 
 void
 WordAiSystem::enter_Attack(Text3d& w)
 {
-    w.ai_state             = Text3d::Ai::Attack;
-    w.ai_state_timer       = 0.0;
-    w.ai_phase_timer       = 0.5 + random_unit() * 1.5;
-    w.ai_in_pause          = false;
-    w.ai_has_stolen        = false;
-    w.ai_hover_height_saved = w.body.hover_height;
+    w.ai.state             = Text3dAI::State::Attack;
+    w.ai.state_timer       = 0.0;
+    w.ai.phase_timer       = 0.5 + random_unit() * 1.5;
+    w.ai.in_pause          = false;
+    w.ai.has_stolen        = false;
+    w.ai.hover_height_saved = w.body.hover_height;
     w.body.mode            = PhysicsMode::AiHover;
     w.set_Color(Text3d::color_attack);
 
     // Pick an orbit direction and starting angle for the no-carry wander.
-    w.ai_orbit_angle = random_unit() * SDL_PI_D * 2.0;
-    w.ai_orbit_dir   = (random_unit() < 0.5) ? -1.0 : 1.0;
+    w.ai.orbit_angle = random_unit() * SDL_PI_D * 2.0;
+    w.ai.orbit_dir   = (random_unit() < 0.5) ? -1.0 : 1.0;
 }
 
 
 void
 WordAiSystem::enter_Flee(Text3d& w)
 {
-    w.ai_state             = Text3d::Ai::Flee;
-    w.ai_state_timer       = 0.0;
-    w.ai_phase_timer       = 0.3 + random_unit() * 0.7;
-    w.ai_in_pause          = false;
-    w.ai_hover_height_saved = w.body.hover_height;
+    w.ai.state             = Text3dAI::State::Flee;
+    w.ai.state_timer       = 0.0;
+    w.ai.phase_timer       = 0.3 + random_unit() * 0.7;
+    w.ai.in_pause          = false;
+    w.ai.hover_height_saved = w.body.hover_height;
     w.body.mode            = PhysicsMode::AiHover;
     w.set_Color(Text3d::color_flee);
 }
@@ -331,8 +331,8 @@ WordAiSystem::enter_Flee(Text3d& w)
 void
 WordAiSystem::enter_Stunned(Text3d& w)
 {
-    w.ai_state       = Text3d::Ai::Stunned;
-    w.ai_state_timer = 0.0;
+    w.ai.state       = Text3dAI::State::Stunned;
+    w.ai.state_timer = 0.0;
     w.body.vel       = Vec3{};
     w.set_Color(Text3d::color_stunned);
 }
@@ -341,10 +341,10 @@ WordAiSystem::enter_Stunned(Text3d& w)
 void
 WordAiSystem::enter_Idle(Text3d& w)
 {
-    w.ai_state       = Text3d::Ai::Idle;
-    w.ai_state_timer = 0.0;
+    w.ai.state       = Text3dAI::State::Idle;
+    w.ai.state_timer = 0.0;
     w.body.mode      = PhysicsMode::Hover;
-    w.body.hover_height = w.ai_hover_height_saved;
+    w.body.hover_height = w.ai.hover_height_saved;
     w.set_Color(w.token_info ? TokenColor::get(w.token_info->type) : Text3d::color_floor);
 }
 
@@ -379,7 +379,7 @@ WordAiSystem::apply_Locomotion(Text3d& w, const Vec3& target_vel, double dt)
 void
 WordAiSystem::update_StolenTrail(Text3d& w, double dt)
 {
-    if (w.ai_stolen.empty()) return;
+    if (w.ai.stolen.empty()) return;
 
     // ── Smoothed back direction ─────────────────────────────────────
     double speed = SDL_sqrt(w.body.vel.x * w.body.vel.x +
@@ -388,28 +388,28 @@ WordAiSystem::update_StolenTrail(Text3d& w, double dt)
     if (speed > 0.5) {
         Vec3 raw_back = { -w.body.vel.x / speed, 0.0, -w.body.vel.z / speed };
         double k = 1.0 - SDL_exp(-3.0 * dt);
-        w.ai_trail_dir.x += (raw_back.x - w.ai_trail_dir.x) * k;
-        w.ai_trail_dir.z += (raw_back.z - w.ai_trail_dir.z) * k;
+        w.ai.trail_dir.x += (raw_back.x - w.ai.trail_dir.x) * k;
+        w.ai.trail_dir.z += (raw_back.z - w.ai.trail_dir.z) * k;
 
-        double len = SDL_sqrt(w.ai_trail_dir.x * w.ai_trail_dir.x +
-                              w.ai_trail_dir.z * w.ai_trail_dir.z);
+        double len = SDL_sqrt(w.ai.trail_dir.x * w.ai.trail_dir.x +
+                              w.ai.trail_dir.z * w.ai.trail_dir.z);
         if (len > 0.01) {
-            w.ai_trail_dir.x /= len;
-            w.ai_trail_dir.z /= len;
+            w.ai.trail_dir.x /= len;
+            w.ai.trail_dir.z /= len;
         }
     }
 
-    if (SDL_fabs(w.ai_trail_dir.x) < 0.01 && SDL_fabs(w.ai_trail_dir.z) < 0.01)
-        w.ai_trail_dir = { 1.0, 0.0, 0.0 };
+    if (SDL_fabs(w.ai.trail_dir.x) < 0.01 && SDL_fabs(w.ai.trail_dir.z) < 0.01)
+        w.ai.trail_dir = { 1.0, 0.0, 0.0 };
 
     // ── Lerp stolen words toward their slot ─────────────────────────
     double offset = static_cast<double>(w.half_extent);
     double pos_lerp_k = 1.0 - SDL_exp(-8.0 * dt);
 
-    Vec3 perp = perpendicular_horizontal(w.ai_trail_dir);
-    double target_yaw = SDL_atan2(-w.ai_trail_dir.x, -w.ai_trail_dir.z);
+    Vec3 perp = perpendicular_horizontal(w.ai.trail_dir);
+    double target_yaw = SDL_atan2(-w.ai.trail_dir.x, -w.ai.trail_dir.z);
 
-    for (Text3dPtr& s : w.ai_stolen) {
+    for (Text3dPtr& s : w.ai.stolen) {
         if (!s) continue;
         offset += static_cast<double>(s->half_extent) + 0.18;
 
@@ -419,9 +419,9 @@ WordAiSystem::update_StolenTrail(Text3d& w, double dt)
         double sway   = SDL_sin(sway_t) * 0.12;
 
         Vec3 target {
-            w.pos.x + w.ai_trail_dir.x * offset + perp.x * sway,
+            w.pos.x + w.ai.trail_dir.x * offset + perp.x * sway,
             w.pos.y + SDL_sin(bob_t) * 0.08,
-            w.pos.z + w.ai_trail_dir.z * offset + perp.z * sway
+            w.pos.z + w.ai.trail_dir.z * offset + perp.z * sway
         };
 
         s->pos.x += (target.x - s->pos.x) * pos_lerp_k;
diff --git a/src/text_3d/text_3d.cpp b/src/text_3d/text_3d.cpp
index 86a8157..5b24441 100644
--- a/src/text_3d/text_3d.cpp
+++ b/src/text_3d/text_3d.cpp
@@ -29,10 +29,10 @@ Text3d::Text3d(const std::string& txt,
 
 
     // AI personality
-    ai_aggressiveness = (rand() % 1000) / 1000.0;
-    ai_wiggle_phase   = (rand() % 1000) / 1000.0 * SDL_PI_D * 2.0;
-    ai_wiggle_freq    = 3.0 + (rand() % 1000) / 1000.0 * 4.0;   // 3–7 rad/s
-    ai_speed          = 2.5 + (rand() % 1000) / 1000.0 * 2.5;
+    ai.aggressiveness = (rand() % 1000) / 1000.0;
+    ai.wiggle_phase   = (rand() % 1000) / 1000.0 * SDL_PI_D * 2.0;
+    ai.wiggle_freq    = 3.0 + (rand() % 1000) / 1000.0 * 4.0;   // 3–7 rad/s
+    ai.speed          = 2.5 + (rand() % 1000) / 1000.0 * 2.5;
 }
 
 
diff --git a/src/text_3d/text_3d.hpp b/src/text_3d/text_3d.hpp
index 671075c..a08a54a 100644
--- a/src/text_3d/text_3d.hpp
+++ b/src/text_3d/text_3d.hpp
@@ -14,32 +14,38 @@
 class Camera;
 class GlRenderer;
 
-struct Text3d final : public GameObject {
-    static inline const std::string font_path =
-        "/usr/share/fonts/dejavu/DejaVuSansMono.ttf";
-
-    enum class Ai : uint8_t {
+struct Text3dAI
+{
+    enum class State : uint8_t {
         Idle,
         Attack,
         Flee,
         Stunned,
     };
 
-    Ai     ai_state         = Ai::Idle;
-    double ai_state_timer   = 0.0;     // time in current state
-    double ai_aggressiveness = 0.0;    // 0..1, personal trait set at spawn
-    double ai_phase_timer   = 0.0;     // pause/dash phase timer
-    double ai_wiggle_phase  = 0.0;     // random init at spawn
-    double ai_wiggle_freq   = 4.0;     // rad/s
-    double ai_speed         = 2.0;     // base movement speed
-    bool   ai_in_pause      = false;
-    double ai_hover_height_saved = 0.0;  // restore on exit
-    double ai_orbit_angle    = 0.0;       // current angle around player (radians)
-    double ai_orbit_dir      = 1.0;       // +1 = clockwise, -1 = counter-clockwise
-    bool   ai_has_stolen     = false;
-    DynArray<Text3dPtr>
-           ai_stolen;
-    Vec3   ai_trail_dir { 1.0, 0.0, 0.0 };
+    State  state = State::Idle;
+    double state_timer        = 0.0;
+    double aggressiveness     = 0.0;
+    double phase_timer        = 0.0;
+    double wiggle_phase       = 0.0;
+    double wiggle_freq        = 4.0;
+    double speed              = 2.0;
+
+    bool   in_pause           = false;
+    double hover_height_saved = 0.0;
+    double orbit_angle        = 0.0;
+    double orbit_dir          = 1.0;
+    bool   has_stolen         = false;
+
+    DynArray<Text3dPtr> stolen;
+    Vec3 trail_dir {1.0, 0.0, 0.0};
+};
+
+struct Text3d final : public GameObject {
+    static inline const std::string font_path =
+        "/usr/share/fonts/dejavu/DejaVuSansMono.ttf";
+
+    Text3dAI ai;
 
     float  glow_strength { 0.2f };
     bool   glow_enabled  { false };