Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / commits / 73fc1d8

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

Fix Snap words between words With Claude

Christian Immanuel · 2026-06-03 06:19 · 73fc1d8457250ffe15146c362bd0c75498aaf5a8

 src/states/game_state.cpp         | 30 +++++++++++----
 src/systems/projectile_system.cpp | 56 ++++++++++++++-------------
 src/systems/projectile_system.hpp |  4 +-
 src/wall/editor_wall.cpp          | 80 +++++++++++++++++++++++++++++++++++++++
 src/wall/editor_wall.hpp          |  7 ++++
 5 files changed, 141 insertions(+), 36 deletions(-)

diff --git a/src/states/game_state.cpp b/src/states/game_state.cpp
index 37d6f92..d9868c7 100644
--- a/src/states/game_state.cpp
+++ b/src/states/game_state.cpp
@@ -295,7 +295,8 @@ GameState::on_ShootOne()
 
     DynArray<Text3dPtr> single;
     single.push_back(std::move(word));
-    _projectiles_sys->launch(_projectiles, std::move(single), _ctx.camera->fwd);
+    _projectiles_sys->launch(_projectiles, std::move(single),
+                             _ctx.camera->fwd, _ctx.camera->pos);
 
     if (!_carried.empty() && _carried.last && _carried.last->word)
         TextWall::show_TokenInfo(_ctx.wall_texts,
@@ -325,7 +326,8 @@ GameState::on_ShootAll()
 
     _carried = CarriedLine();
     if (!all_words.empty())
-        _projectiles_sys->launch(_projectiles, std::move(all_words), _ctx.camera->fwd);
+        _projectiles_sys->launch(_projectiles, std::move(all_words),
+                                 _ctx.camera->fwd, _ctx.camera->pos);
 
     infowall_StepdetailsShow();
 
@@ -485,6 +487,10 @@ GameState::projectile_LandOnFloor(ProjectileResult& result)
 void
 GameState::projectile_LandOnWall(ProjectileResult& result)
 {
+    DynArray<TextWallPtr> placed;
+    DynArray<Text3d*>     editor_group;
+    placed.reserve(result.words.size());
+
     for (Text3dPtr& w : result.words) {
         if (!w) continue;
         if (w->type == ObjectType::Text)
@@ -494,15 +500,23 @@ GameState::projectile_LandOnWall(ProjectileResult& result)
         w->body.vel  = Vec3{0, 0, 0};
 
         TextWallPtr wt = TextWall::from_Drop(std::move(w), _ctx.world->scale());
+        if (wt && wt->text3d && wt->role == Wall::Role::Editor)
+            editor_group.push_back(wt->text3d.get());     // collect, don't place yet
+        placed.push_back(std::move(wt));
+    }
 
-        if (wt && wt->text3d && wt->role == Wall::Role::Editor) {
-            const Vec3 impact = wt->text3d->pos;
-            const Vec3 snapped = _editor_wall.on_WordPlaced(wt->text3d.get(), impact);
-            wt->text3d->pos = snapped;
-        }
+    // Insert the whole shot line as ONE block at its centroid (the aim point),
+    // so it merges between two existing words instead of scattering.
+    if (!editor_group.empty()) {
+        Vec3 impact{0, 0, 0};
+        for (Text3d* t : editor_group) { impact.x += t->pos.x; impact.y += t->pos.y; impact.z += t->pos.z; }
+        const double inv = 1.0 / static_cast<double>(editor_group.size());
+        impact.x *= inv; impact.y *= inv; impact.z *= inv;
+        _editor_wall.on_LinePlaced(editor_group, impact);
+    }
 
+    for (TextWallPtr& wt : placed)
         _ctx.wall_texts.push_back(std::move(wt));
-    }
 }
 
 
diff --git a/src/systems/projectile_system.cpp b/src/systems/projectile_system.cpp
index 56533f5..334d2e3 100644
--- a/src/systems/projectile_system.cpp
+++ b/src/systems/projectile_system.cpp
@@ -233,35 +233,32 @@ begin_EditorStick(Projectile& p, Wall::Position hit_pos)
     const double dot  = cr_x * wt_x + cr_z * wt_z;
     const double sign = (dot >= 0.0) ? 1.0 : -1.0;
 
-    const Vec3   c_pos = Text3d::words_centroid(p.words);
-    const double c_tan = wall_tangent(hit_pos, c_pos.x, c_pos.z);
-
-    // Project along the flight direction to the wall plane, so the stick y is
-    // where the shot is HEADED on the wall, not the centroid's current height.
-    // Up close the centroid hasn't traveled, so its raw y is the spawn height;
-    // extrapolating to the plane gives the aimed-at y at any distance.
+    // Crosshair aim point on the wall: intersect the CAMERA ray (cam_origin
+    // along dir) with the wall plane. Using the camera ray rather than the
+    // line's centroid means the run merges where the crosshair points — the
+    // carried line is laid out from its anchor, so its centroid sits ~half a
+    // line-width off the aim, which is what dragged the merge sideways. This
+    // also gives the aimed-at height with no gravity soak at any distance.
+    const Vec3   c_pos  = Text3d::words_centroid(p.words);
     const double plane  = wall_plane_coord(hit_pos, WORLD_BOX_SCALE);
     const bool   x_wall = (hit_pos == Wall::Position::NegX || hit_pos == Wall::Position::PosX);
 
-    // y at the exact moment the flight path crosses the wall plane.
-    // prev_centroid is on the near side, c_pos has just crossed; lerp by how
-    // far along that segment the plane sits. This respects the gravity arc up
-    // to the wall (far shots land where they fall) without the overshoot drift.
-    const Vec3 prev = p.prev_centroid;
-    const Vec3 cur  = c_pos;
-    const double prev_coord = x_wall ? prev.x : prev.z;
-    const double cur_coord  = x_wall ? cur.x  : cur.z;
-
-    double c_y;
-    const double denom = cur_coord - prev_coord;
-    if (SDL_fabs(denom) > 1e-6) {
-        double s = (plane - prev_coord) / denom;   // fraction along the segment
-        s = SDL_clamp(s, 0.0, 1.0);                // stay within the crossing step
-        c_y = prev.y + (cur.y - prev.y) * s;       // interpolated y at the plane
+    const double cam_coord = x_wall ? p.cam_origin.x : p.cam_origin.z;
+    const double dir_coord = x_wall ? p.dir.x        : p.dir.z;
+
+    Vec3 aim;
+    if (SDL_fabs(dir_coord) > 1e-6) {
+        const double t = (plane - cam_coord) / dir_coord;   // steps along the aim ray
+        aim = Vec3{ p.cam_origin.x + p.dir.x * t,
+                    p.cam_origin.y + p.dir.y * t,
+                    p.cam_origin.z + p.dir.z * t };
     } else {
-        c_y = cur.y;   // moving parallel to wall; fall back
+        aim = c_pos;   // aiming parallel to the wall; fall back to the centroid
     }
 
+    const double c_tan = wall_tangent(hit_pos, aim.x, aim.z);
+    const double c_y   = aim.y;
+
     for (Text3dPtr& w : p.words) {
         if (!w || w->type != ObjectType::Text) continue;
         w->ribbon_local = {
@@ -316,10 +313,13 @@ handle_WallHit(Projectile& p, const Vec3& centroid, Wall::Position hit_pos,
                const DynArray<WallTextHitTest>& wall_text_hits,
                DynArray<ProjectileResult>& results)
 {
-    // Wall-text intercept takes priority: if the path crosses a wall-text,
-    // emit a WallText result and drop the projectile.
+    // Wall-text intercept takes priority — but ONLY for a single-word shot,
+    // which is what knocks an existing word down or hits a line number. A whole
+    // shot LINE must never knock a word off; it always sticks (and merges) or
+    // bounces, so skip the intercept when carrying more than one word.
     size_t hit_idx = 0;
-    if (check_WallTextIntercept(p, centroid, dt, hit_pos, wall_text_hits, hit_idx)) {
+    if (p.words.size() == 1 &&
+        check_WallTextIntercept(p, centroid, dt, hit_pos, wall_text_hits, hit_idx)) {
         ProjectileResult r;
         r.landing         = ProjectileLanding::WallText;
         r.wall_pos        = hit_pos;
@@ -342,7 +342,8 @@ handle_WallHit(Projectile& p, const Vec3& centroid, Wall::Position hit_pos,
 void
 ProjectileSystem::launch(DynArray<Projectile>&   out,
                          DynArray<Text3dPtr> words,
-                         const Vec3& fwd)
+                         const Vec3& fwd,
+                         const Vec3& eye)
 {
     if (words.empty()) return;
 
@@ -380,6 +381,7 @@ ProjectileSystem::launch(DynArray<Projectile>&   out,
     proj.words         = std::move(words);
     proj.dir           = fwd;
     proj.launch_origin = centroid;
+    proj.cam_origin    = eye;
     proj.prev_centroid = centroid;
     out.push_back(std::move(proj));
 }
diff --git a/src/systems/projectile_system.hpp b/src/systems/projectile_system.hpp
index ed4e33a..e8153ae 100644
--- a/src/systems/projectile_system.hpp
+++ b/src/systems/projectile_system.hpp
@@ -16,6 +16,7 @@ struct Projectile {
     Vec3     dir{};
     Vec3     prev_centroid{};
     Vec3     launch_origin{};
+    Vec3     cam_origin{};      // camera eye at launch — the crosshair ray origin
     double   yaw;
     uint8_t        bounce_state = 0;
     bool           sticking     = false;
@@ -57,7 +58,8 @@ class ProjectileSystem {
 public:
     void launch(DynArray<Projectile>& out,
                 DynArray<Text3dPtr> words,
-                const Vec3& fwd);
+                const Vec3& fwd,
+                const Vec3& eye);
 
     void update(DynArray<Projectile>&        projectiles,
                 DynArray<ProjectileResult>&  results,
diff --git a/src/wall/editor_wall.cpp b/src/wall/editor_wall.cpp
index 82492f9..dc5d080 100644
--- a/src/wall/editor_wall.cpp
+++ b/src/wall/editor_wall.cpp
@@ -105,6 +105,86 @@ EditorWall::on_WordPlaced(Text3d* word, const Vec3& impact_pos)
 }
 
 
+void
+EditorWall::on_LinePlaced(const DynArray<Text3d*>& words_in, const Vec3& impact)
+{
+    if (words_in.empty()) return;
+
+    // Visual left→right on the NegX wall is decreasing z (left = larger z).
+    DynArray<Text3d*> ws;
+    ws.reserve(words_in.size());
+    for (Text3d* w : words_in) if (w) ws.push_back(w);
+    if (ws.empty()) return;
+    std::sort(ws.begin(), ws.end(),
+        [](const Text3d* a, const Text3d* b){ return a->pos.z > b->pos.z; });
+
+    // Total width of the block: glyph widths + internal gaps.
+    double block_w = 0.0;
+    for (size_t i = 0; i < ws.size(); ++i) {
+        block_w += 2.0 * (double)ws[i]->half_extent;
+        if (i + 1 < ws.size()) block_w += FREE_SPACE_DIST;
+    }
+
+    Line* target = find_NearestLine(impact.y);
+    if (!target) {
+        Line fresh;
+        fresh.y = impact.y;
+        _lines.push_back(std::move(fresh));
+        target = &_lines.back();
+    }
+
+    auto left_edge  = [](const Text3d* w){ return w->pos.z + (double)w->half_extent; };
+    auto right_edge = [](const Text3d* w){ return w->pos.z - (double)w->half_extent; };
+
+    // Insert where the aim points: the slot comes after every word left of the
+    // aim (the wall's left is +z) and before every word to its right.
+    size_t insert_index = 0;
+    for (const Text3d* w : target->words)
+        if (w && w->pos.z > impact.z) ++insert_index;
+
+    const size_t n = target->words.size();
+
+    // Centre the block ON the aim, so it merges where the shot actually landed
+    // rather than flush against whichever word sits left of the gap.
+    double block_left_z = impact.z + block_w * 0.5;   // block's left edge (high-z side)
+
+    // Don't overlap the word to the LEFT of the slot: pull the block right (−z)
+    // if centring would push its left edge into that word.
+    if (insert_index > 0) {
+        const double max_left = right_edge(target->words[insert_index - 1]) - FREE_SPACE_DIST;
+        if (block_left_z > max_left) block_left_z = max_left;
+    }
+    // Make room on the RIGHT: if the block would overlap the word to its right,
+    // shove that word and everything past it further right (−z).
+    if (insert_index < n) {
+        const double block_right = block_left_z - block_w;
+        const double min_right   = left_edge(target->words[insert_index]) + FREE_SPACE_DIST;
+        if (block_right < min_right) {
+            const double shift = min_right - block_right;
+            for (size_t k = insert_index; k < n; ++k)
+                target->words[k]->pos.z -= shift;
+        }
+    }
+
+    // Lay the block out left→right from block_left_z (decreasing z) and snap
+    // each word to the row height; keep each word's existing x (wall plane).
+    double cursor = block_left_z;   // left edge for the next glyph
+    for (Text3d* w : ws) {
+        const double hw       = (double)w->half_extent;
+        const double center_z = cursor - hw;
+        w->pos.y = target->y;
+        w->pos.z = center_z;
+        cursor   = center_z - hw - FREE_SPACE_DIST;
+    }
+
+    // Insert the run into the line in visual order at the chosen slot.
+    for (size_t k = 0; k < ws.size(); ++k)
+        target->words.insert(
+            target->words.begin() + static_cast<std::ptrdiff_t>(insert_index + k),
+            ws[k]);
+}
+
+
 void
 EditorWall::on_WordRemoved(Text3d* word)
 {
diff --git a/src/wall/editor_wall.hpp b/src/wall/editor_wall.hpp
index ce62a2c..37339b8 100644
--- a/src/wall/editor_wall.hpp
+++ b/src/wall/editor_wall.hpp
@@ -98,6 +98,13 @@ public:
     // Returns the snapped pos so the caller can apply it.
     Vec3 on_WordPlaced(Text3d* word, const Vec3& impact_pos);
 
+    // Place a whole shot line (several words) as ONE contiguous block at the
+    // aim point, so a multi-word line merges between two existing words
+    // instead of each word scattering to its own slot. Sets each word's pos
+    // directly (caller doesn't re-apply). Pass the words in any order and the
+    // shot line's centroid as the impact.
+    void on_LinePlaced(const DynArray<Text3d*>& words, const Vec3& impact);
+
     // Deregister a word that's been picked up off the wall. Safe if the
     // word wasn't tracked (e.g. it was on a non-editor wall).
     void on_WordRemoved(Text3d* word);