Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / commits / 8a2752c

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

Jump on button AND freelook fix With Claude

Christian Immanuel · 2026-06-01 21:39 · 8a2752c1cc2ce3d0df9e9fd3c563407bd6c50e20

 src/entities/pult.cpp           |   9 +--
 src/entities/pult.hpp           |  13 ++--
 src/states/game_state.cpp       | 130 +++++++++++++++++++++++++---------------
 src/systems/freelook_system.cpp |  48 ++-------------
 src/systems/freelook_system.hpp |  11 ++--
 src/systems/pult_system.cpp     |  65 ++++++++++++++++++--
 src/systems/pult_system.hpp     |   4 ++
 7 files changed, 170 insertions(+), 110 deletions(-)

diff --git a/src/entities/pult.cpp b/src/entities/pult.cpp
index 7b219eb..2e8e499 100644
--- a/src/entities/pult.cpp
+++ b/src/entities/pult.cpp
@@ -56,14 +56,15 @@ PultButton::draw(GlRenderer& gl, const Camera& cam, const Light& light) const
 ///////////////////////////
 // Pult
 
-Pult::Pult(const std::string& n, Vec3 world_pos)
+Pult::Pult(const std::string& n, Vec3 world_pos,
+           float half_width, float half_depth)
      : SolidBox(
            world_pos,
-           Vec3{ W, H, D },
+           Vec3{ half_width, H, half_depth },
            SDL_FColor{ 0.45f, 0.45f, 0.65f, 1.0f },
            SDL_FColor{ 0.70f, 0.70f, 0.90f, 1.0f },
            false)
-     , name(n)
+     , name(n), half_w(half_width), half_d(half_depth)
 {
     label.set_Billboard(true);
     label.set_Style(Text3d::color_indigo, Text3d::depth_wall, 0.0025f);
@@ -91,7 +92,7 @@ Pult::draw(GlRenderer& gl, const Camera& cam, const Light& light) const
     gl.set_NormalMat(model);
     gl.set_Light(light);
     gl.set_Color(0.45f, 0.45f, 0.65f);
-    gl.draw_Box(W, H, D);
+    gl.draw_Box(half_w, H, half_d);
 
     for (const PultButton* b : buttons)
         b->draw(gl, cam, light);
diff --git a/src/entities/pult.hpp b/src/entities/pult.hpp
index 604e43b..011f9c1 100644
--- a/src/entities/pult.hpp
+++ b/src/entities/pult.hpp
@@ -26,6 +26,7 @@ struct PultButton final : public SolidBox
     PultAction  action;
 
     bool   pressed     { false };
+    bool   player_on   { false };   // player currently standing on it (edge-detect)
     double press_timer { 0.0   };
     static constexpr double
            PRESS_DUR   = 0.18;
@@ -45,13 +46,17 @@ struct Pult : public SolidBox
     DynArray<PultButton*> buttons;
     TextLabel                label;
 
-    static constexpr float W = 0.50f;
-    static constexpr float H = 0.28f;
-    static constexpr float D = 0.40f;
+    static constexpr float W = 0.50f;   // default half-width  (X)
+    static constexpr float H = 0.28f;   // half-height (Y)
+    static constexpr float D = 0.40f;   // default half-depth  (Z)
+
+    float half_w { W };
+    float half_d { D };
 
     static constexpr double LABEL_HEIGHT = 1.1;
 
-    Pult(const std::string& n, Vec3 world_pos);
+    Pult(const std::string& n, Vec3 world_pos,
+         float half_width = W, float half_depth = D);
 
     void tick(double dt);
     void draw(GlRenderer& gl, const Camera& cam, const Light& light) const override;
diff --git a/src/states/game_state.cpp b/src/states/game_state.cpp
index 237a6d3..affbd72 100644
--- a/src/states/game_state.cpp
+++ b/src/states/game_state.cpp
@@ -29,31 +29,31 @@ GameState::GameState(size_t starting_level)
 
 {
     _ctx.player = cpputils::make_unique<Player>();
-    _ctx.world  = cpputils::make_unique<World>();
+    _ctx.world  = cpputils::make_unique<World> ();
     _ctx.camera = cpputils::make_unique<Camera>();
-    _ctx.light  = cpputils::make_unique<Light>();
-
-    _input_sys       = cpputils::make_unique<InputSystem>();
-    _physics_sys     = cpputils::make_unique<PhysicsSystem>();
-    _projectiles_sys = cpputils::make_unique<ProjectileSystem>();
-    _compiler_sys    = cpputils::make_unique<CompilerSystem>();
-    _word_anim_sys   = cpputils::make_unique<WordAnimSystem>();
-    _freelook_sys    = cpputils::make_unique<FreelookSystem>();
-    _particle_sys    = cpputils::make_unique<ParticleSystem>();
-    _mod_loader_sys      = cpputils::make_unique<ModLoader>();
+    _ctx.light  = cpputils::make_unique<Light> ();
+
+    _input_sys       = cpputils::make_unique<InputSystem>      ();
+    _physics_sys     = cpputils::make_unique<PhysicsSystem>    ();
+    _projectiles_sys = cpputils::make_unique<ProjectileSystem> ();
+    _compiler_sys    = cpputils::make_unique<CompilerSystem>   ();
+    _word_anim_sys   = cpputils::make_unique<WordAnimSystem>   ();
+    _freelook_sys    = cpputils::make_unique<FreelookSystem>   ();
+    _particle_sys    = cpputils::make_unique<ParticleSystem>   ();
+    _mod_loader_sys  = cpputils::make_unique<ModLoader>        ();
     _celebration_sys = cpputils::make_unique<CelebrationSystem>();
 
-    _floor_sys       = cpputils::make_unique<FloorMandelbrot>();
+    _floor_sys       = cpputils::make_unique<FloorMandelbrot>  ();
     _floor_sys->generate(_ctx.world->scale(), -_ctx.world->scale());
 
     _word_ai_sys     = cpputils::make_unique<WordAiSystem>();
     _word_ai_sys->set_WorldScale(_ctx.world->scale());
 
-    _pult_sys        = cpputils::make_unique<PultSystem>();
+    _pult_sys        = cpputils::make_unique<PultSystem>  ();
     _pult_sys->init(_ctx.scenery, _ctx.buttons, _ctx.world->scale());
 
     if (starting_level >= level_Count()) starting_level = 0;
-    _level_runner_sys    = cpputils::make_unique<LevelRunner>(get_Level(starting_level));
+    _level_runner_sys = cpputils::make_unique<LevelRunner>(get_Level(starting_level));
 
     DynArray<std::string> tokens = _level_runner_sys->grants_for_step();
     step_Spawn(tokens);
@@ -107,14 +107,45 @@ GameState::debug_SpawnWords([[maybe_unused]]size_t n)
 std::tuple<Vec3, Vec3, double>
 GameState::anchorframe_Current() const
 {
-    if (_freelook_sys->state.active)
-        return { _freelook_sys->state.frozen_anchor,
-                 _freelook_sys->state.frozen_right,
-                 _freelook_sys->state.frozen_glyph_yaw };
+    const FreelookState& fs = _freelook_sys->state;
+
+    // Frozen in place while freelook is actively held.
+    if (fs.active)
+        return { fs.frozen_anchor, fs.frozen_right, fs.frozen_glyph_yaw };
+
+    // Live carry frame, in front of wherever the camera is looking now.
+    const Vec3   live_anchor = CarryMath::line_Anchor(*_ctx.camera, CarryMath::CARRY_DIST);
+    const Vec3   live_right  = _ctx.camera->right;
+    const double live_yaw    = SDL_atan2(-_ctx.camera->fwd.x, -_ctx.camera->fwd.z);
+
+    // Returning: ease the whole carry frame from the frozen spot back to the
+    // live one. The line flies to the player; the camera is never moved.
+    if (fs.lerping) {
+        double t = fs.lerp_t;
+        t = (t < 0.0) ? 0.0 : (t > 1.0 ? 1.0 : t);
+        const double s = t * t * (3.0 - 2.0 * t);   // smoothstep
+
+        auto lerp3 = [s](const Vec3& a, const Vec3& b) {
+            return Vec3{ a.x + (b.x - a.x) * s,
+                         a.y + (b.y - a.y) * s,
+                         a.z + (b.z - a.z) * s };
+        };
+
+        Vec3 anchor = lerp3(fs.frozen_anchor, live_anchor);
+        Vec3 right  = lerp3(fs.frozen_right,  live_right);
+        const double rl = SDL_sqrt(right.x*right.x + right.y*right.y + right.z*right.z);
+        if (rl > 1e-9) { right.x /= rl; right.y /= rl; right.z /= rl; }
+
+        // Shortest-path angular interpolation for the glyph yaw.
+        double dyaw = live_yaw - fs.frozen_glyph_yaw;
+        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 = fs.frozen_glyph_yaw + dyaw * s;
 
-    return { CarryMath::line_Anchor(*_ctx.camera, CarryMath::CARRY_DIST),
-             _ctx.camera->right,
-             SDL_atan2(-_ctx.camera->fwd.x, -_ctx.camera->fwd.z) };
+        return { anchor, right, yaw };
+    }
+
+    return { live_anchor, live_right, live_yaw };
 }
 
 
@@ -125,8 +156,10 @@ GameState::carriedline_Update()
 
     auto [anchor, right, glyph_yaw] = anchorframe_Current();
 
-    DynArray<Vec3>             positions = CarryMath::slot_positions(_carried, anchor, right, CarryMath::CARRY_GAP);
-    DynArray<CarriedWordNode*> nodes     = _carried.reverse_nodes();
+    DynArray<Vec3>
+        positions = CarryMath::slot_positions(_carried, anchor, right, CarryMath::CARRY_GAP);
+    DynArray<CarriedWordNode*>
+        nodes     = _carried.reverse_nodes();
 
     for (size_t i = 0; i < nodes.size() && i < positions.size(); ++i) {
         if (nodes[i]->word) {
@@ -317,11 +350,9 @@ void
 GameState::on_DebugPrint()
 {
     std::print("TEXT::: {0}\n", TextWall::extract_Text(_ctx.wall_texts, Wall::Role::Program));
-    for (TextWallPtr& wt : _ctx.wall_texts) {
-        if (wt->text3d->type == ObjectType::Text) {
+    for (TextWallPtr& wt : _ctx.wall_texts)
+        if (wt->text3d->type == ObjectType::Text)
             SDL_Log("wall text '%s' y=%.3f", wt->text3d->text.c_str(), wt->text3d->pos.y);
-        }
-    }
 }
 
 
@@ -348,9 +379,7 @@ void
 GameState::on_FreelookEnd()
 {
     if (_freelook_sys->state.active)
-        _freelook_sys->end(_ctx.player->yaw, _ctx.player->pitch,
-                          _ctx.camera->pos,
-                          _ctx.player->yaw, _ctx.player->pitch);
+        _freelook_sys->end();
 }
 
 void
@@ -380,24 +409,24 @@ GameState::on_TextStart(FrameInput& input)
 {
     input.text_mode = true;
     input.text_buffer.clear();
-    _typing_words.clear();
-    _typing_last.clear();
+    _typing_words    .clear();
+    _typing_last     .clear();
 }
 void
 GameState::on_TextCommit(FrameInput& input)
 {
-    _typing_words.clear();
-    _typing_last.clear();
     input.text_mode = false;
     input.text_buffer.clear();
+    _typing_words    .clear();
+    _typing_last     .clear();
 }
 void
 GameState::on_TextCancel(FrameInput& input)
 {
     typing_ClearPreview();
-    _typing_last.clear();
     input.text_mode = false;
     input.text_buffer.clear();
+    _typing_last     .clear();
 }
 
 
@@ -455,7 +484,6 @@ GameState::projectile_LandOnWall(ProjectileResult& result)
 
         TextWallPtr wt = TextWall::from_Drop(std::move(w), _ctx.world->scale());
 
-        // Editor-wall snap: rewrite position to align with row.
         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);
@@ -550,12 +578,13 @@ GameState::update(double dt)
     }
 
 
-    const bool freelook_active = _freelook_sys->state.active ||
-                                 _freelook_sys->state.lerping;
-    if (!freelook_active)
+    // Freelook freezes the view only while held. Once released the player
+    // keeps full camera control immediately and the carried line flies back
+    // on its own (see anchorframe_Current / FreelookSystem::update).
+    if (!_freelook_sys->state.active)
         _ctx.player->add_Look(input.look_x, input.look_y);
 
-    _freelook_sys->update(dt, _ctx.player->yaw, _ctx.player->pitch);
+    _freelook_sys->update(dt);
 
     _accumulator += dt;
     _ctx.player->compute_Movement(input);
@@ -692,7 +721,7 @@ GameState::render(SDL_Window& window, GlRenderer& renderer)
     { 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._floor_sys");   _floor_sys   ->draw(renderer, *_ctx.camera, *_ctx.light); }
+    { FP_ZONE("render._floor_sys");  _floor_sys   ->draw(renderer, *_ctx.camera, *_ctx.light); }
 
     { FP_ZONE("render.celebration");
         _celebration_sys->draw(renderer, *_ctx.camera, *_ctx.light);
@@ -752,7 +781,7 @@ GameState::infowall_StepdetailsShow()
         if (line.empty()) continue;
 
         Text3dPtr t = make_Text3d(line, Text3d::depth_wall, 0.0025f,
-                                  pos, Text3d::color_white);
+                                  pos,  Text3d::color_white);
         t->yaw = wall_transform.yaw;
 
         _ctx.wall_texts.push_back(cpputils::make_unique<TextWall>(
@@ -826,13 +855,14 @@ GameState::scene_Teardown()
                 [&scenery_set](const GameObjectPtr& sp) {
                     return scenery_set.count(sp.get()) > 0;
                 }),
-            _ctx.scenery.end());
+            _ctx.scenery.end()
+        );
     }
 
     mod_Clear();
 
     _step_scenery.clear();
-    _step_plates.clear();
+    _step_plates .clear();
 }
 
 
@@ -843,8 +873,8 @@ GameState::substep(double dt)
 
     { FP_ZONE("subst.phys_player");
         _physics_sys->update_Player(*_ctx.player,
-                                    _ctx.player->get_AccX(),
-                                    _ctx.player->get_AccZ(),
+                                     _ctx.player->get_AccX(),
+                                     _ctx.player->get_AccZ(),
                                     dt);
     }
 
@@ -904,7 +934,7 @@ GameState::substep(double dt)
     DynArray<ProjectileResult> proj_results;
     { FP_ZONE("subst.projectile_sys");
         _projectiles_sys->update(_projectiles, proj_results,
-                                wall_text_hits, dt, _ctx.world->scale());
+                                 wall_text_hits, dt, _ctx.world->scale());
     }
 
     DynArray<size_t> wall_text_to_erase;
@@ -950,6 +980,11 @@ GameState::substep(double dt)
         _ctx.player->apply_Collision(col);
 
         _physics_sys->resolve_PlayerVsObjects(*_ctx.player, _carried, colliders);
+
+        // Jump-on-button
+        if (std::optional<PultAction> act =
+                _pult_sys->handle_PlayerContact(*_ctx.player, _ctx.buttons); act)
+            pult_DispatchAction(*act);
     }
 }
 
@@ -1201,4 +1236,3 @@ GameState::carry_BeginAnim(Text3dPtr word, bool with_pitch)
 
     _anims_add.push_back(std::move(anim));
 }
-
diff --git a/src/systems/freelook_system.cpp b/src/systems/freelook_system.cpp
index 3d218f2..d28add3 100644
--- a/src/systems/freelook_system.cpp
+++ b/src/systems/freelook_system.cpp
@@ -6,21 +6,6 @@ static constexpr double
 FREELOOK_LERP_DUR = 0.20;
 
 
-static double
-smoothstep(double t)
-{
-    t = std::clamp(t, 0., 1.);
-    return t * t * (3. - 2. * t);
-}
-
-
-static inline int32_t
-yaw_diff(uint32_t from, uint32_t to)
-{
-    return static_cast<int32_t>(to - from);
-}
-
-
 void
 FreelookSystem::begin(const Vec3& anchor, const Vec3& right, double glyph_yaw)
 {
@@ -33,48 +18,25 @@ FreelookSystem::begin(const Vec3& anchor, const Vec3& right, double glyph_yaw)
 
 
 void
-FreelookSystem::end(uint32_t player_yaw, int32_t player_pitch,
-                          const Vec3& camera_pos,
-                          uint32_t& out_yaw, int32_t& out_pitch)
+FreelookSystem::end()
 {
+    // Hand control of the carry anchor back to the live camera frame and let
+    // the line ease there. The camera angle is NOT changed.
     state.active  = false;
     state.lerping = true;
     state.lerp_t  = 0.0;
-
-    state.lerp_yaw_start   = player_yaw;
-    state.lerp_pitch_start = player_pitch;
-
-    look_At(camera_pos,
-            state.frozen_anchor,
-            state.lerp_yaw_target,
-            state.lerp_pitch_target);
-
-    out_yaw   = state.lerp_yaw_target;
-    out_pitch = state.lerp_pitch_target;
 }
 
 
 void
-FreelookSystem::update(double dt, uint32_t& player_yaw, int32_t& player_pitch)
+FreelookSystem::update(double dt)
 {
     if (!state.lerping) return;
 
     state.lerp_t += dt / FREELOOK_LERP_DUR;
-    double s = smoothstep(std::min(state.lerp_t, 1.0));
-
-    int32_t dy = yaw_diff(state.lerp_yaw_start, state.lerp_yaw_target);
-    player_yaw = static_cast<uint32_t>(
-        static_cast<int32_t>(state.lerp_yaw_start) +
-        static_cast<int32_t>(dy * s)
-    );
-
-    int32_t dp    = state.lerp_pitch_target - state.lerp_pitch_start;
-    player_pitch  = state.lerp_pitch_start + static_cast<int32_t>(dp * s);
-
     if (state.lerp_t >= 1.0) {
+        state.lerp_t  = 1.0;
         state.lerping = false;
-        player_yaw    = state.lerp_yaw_target;
-        player_pitch  = state.lerp_pitch_target;
     }
 }
 
diff --git a/src/systems/freelook_system.hpp b/src/systems/freelook_system.hpp
index 4cde0e8..1d138e4 100644
--- a/src/systems/freelook_system.hpp
+++ b/src/systems/freelook_system.hpp
@@ -26,15 +26,14 @@ public:
     void
     begin(const Vec3& anchor, const Vec3& right, double glyph_yaw);
 
-    // Call when shift released — starts lerp back to face anchor
+    // Call when shift released — starts the carried-line fly-back. The camera
+    // is left exactly where the player is looking.
     void
-    end(uint32_t player_yaw, int32_t player_pitch,
-        const Vec3& camera_pos,
-        uint32_t& out_yaw, int32_t& out_pitch);
+    end();
 
-    // Call every frame
+    // Call every frame — advances the fly-back timer only.
     void
-    update(double dt, uint32_t& player_yaw, int32_t& player_pitch);
+    update(double dt);
 
     static void
     look_At(const Vec3& eye, const Vec3& target,
diff --git a/src/systems/pult_system.cpp b/src/systems/pult_system.cpp
index 6863408..c42ae46 100644
--- a/src/systems/pult_system.cpp
+++ b/src/systems/pult_system.cpp
@@ -1,4 +1,7 @@
+#include <cmath>
+
 #include "entities/camera.hpp"
+#include "entities/player.hpp"
 #include "pult_system.hpp"
 #include "systems/physics_system.hpp"
 #include "rendering/gl_renderer.hpp"
@@ -33,9 +36,12 @@ PultSystem::init(DynArray<GameObjectPtr>& scenery,
     };
 
     auto spawn_pult = [&](const std::string& name,
-                          Wall::Role role, float along) -> Pult*
+                          Wall::Role role, float along,
+                          float half_w = Pult::W,
+                          float half_d = Pult::D) -> Pult*
     {
-        auto owned = cpputils::make_unique<Pult>(name, make_pult_pos(role, along));
+        auto owned = cpputils::make_unique<Pult>(name, make_pult_pos(role, along),
+                                                 half_w, half_d);
         Pult* raw  = owned.get();
         _pults.push_back(raw);
         scenery.push_back(std::move(owned));
@@ -58,9 +64,12 @@ PultSystem::init(DynArray<GameObjectPtr>& scenery,
         scenery.push_back(std::move(owned));
     };
 
-    Pult* scroll = spawn_pult("Scroll", Wall::Role::Editor, lane_1);
-    add_btn(scroll, "UP",   PultAction::ScrollEditorUp,   0.0f, -0.42f);
-    add_btn(scroll, "DOWN", PultAction::ScrollEditorDown, 0.0f,  0.42f);
+    // The two buttons sit side-by-side along Z, so this pult is twice as deep
+    // (its visible width facing the editor wall) and the buttons get a wider gap.
+    Pult* scroll = spawn_pult("Scroll", Wall::Role::Editor, lane_1,
+                              Pult::W, Pult::D * 2.0f);
+    add_btn(scroll, "UP",   PultAction::ScrollEditorUp,   0.0f, -0.46f);
+    add_btn(scroll, "DOWN", PultAction::ScrollEditorDown, 0.0f,  0.46f);
 
     Pult* clr = spawn_pult("Clear", Wall::Role::Editor, lane_2);
     add_btn(clr, "CLEAR", PultAction::ClearEditor, 0.0f, 0.0f);
@@ -100,6 +109,52 @@ PultSystem::handle_ProjectileContact(const ProjectileContactEvent&    ev,
 }
 
 
+// Is the player standing on this button? The button sits on top of the solid
+// pult, so on the ground the player's CENTER is always at least `radius`
+// outside the pult footprint — it can only get over a button by standing on
+// top. So we test the center against the button footprint (small tolerance,
+// NOT expanded by the radius, or brushing the pult's side would trigger it).
+static bool
+player_OnButton(const Player& p, const PultButton& b)
+{
+    constexpr double TOL = 0.10;
+
+    const double hx = b.shape.half_extents.x;
+    const double hy = b.shape.half_extents.y;
+    const double hz = b.shape.half_extents.z;
+
+    if (std::fabs(p.pos.x - b.pos.x) > hx + TOL) return false;
+    if (std::fabs(p.pos.z - b.pos.z) > hz + TOL) return false;
+
+    // On/above the top face, not sailing far overhead. No tight lower bound is
+    // needed: being over the footprint already implies on-top (can't be beside
+    // it — that space is solid pult), so this only rejects a high fly-over.
+    const double dy = p.pos.y - (b.pos.y + hy);
+    return dy >= -0.10 && dy <= 1.8;
+}
+
+
+std::optional<PultAction>
+PultSystem::handle_PlayerContact(const Player&                    player,
+                                 const DynArray<PultButton*>&  buttons)
+{
+    std::optional<PultAction> fired;
+
+    for (PultButton* btn : buttons) {
+        const bool over = player_OnButton(player, *btn);
+
+        // Rising edge only: fire once when the player lands, not every frame
+        // they keep standing on it.
+        if (over && !btn->player_on) {
+            btn->press();
+            if (!fired) fired = btn->action;
+        }
+        btn->player_on = over;
+    }
+    return fired;
+}
+
+
 void
 PultSystem::update(double dt, const Camera& camera)
 {
diff --git a/src/systems/pult_system.hpp b/src/systems/pult_system.hpp
index a1e89d0..ba1d11b 100644
--- a/src/systems/pult_system.hpp
+++ b/src/systems/pult_system.hpp
@@ -11,6 +11,7 @@ class GlRenderer;
 struct Light;
 class  TextLabel;
 struct ProjectileContactEvent;
+struct Player;
 
 class PultSystem
 {
@@ -25,6 +26,9 @@ public:
     std::optional<PultAction>
          handle_ProjectileContact(const ProjectileContactEvent&    ev,
                                   const DynArray<PultButton*>&  buttons);
+    std::optional<PultAction>
+         handle_PlayerContact(const Player&                    player,
+                              const DynArray<PultButton*>&  buttons);
     void update(double dt, const Camera& camera);
     void draw_Labels(GlRenderer& gl, const Camera& camera, const Light& light) const;