#include #include "entities/world.hpp" #include "projectile_system.hpp" #include "systems/collision.hpp" #include "utils/config.hpp" namespace Nimbin { static inline double bounceVelocity(double v, double restitution) { return -v * restitution; } static double wallPlaneCoord(Wall::Position projectile, double ws) { switch (projectile) { case Wall::Position::PosX: return ws; case Wall::Position::NegX: return -ws; case Wall::Position::PosZ: return ws; case Wall::Position::NegZ: return -ws; } return ws; } static float wallTargetYaw(Wall::Position projectile) { switch (projectile) { case Wall::Position::PosX: return -SDL_PI_F * 0.5f; case Wall::Position::NegX: return SDL_PI_F * 0.5f; case Wall::Position::PosZ: return SDL_PI_F; case Wall::Position::NegZ: return 0.0f; } return 0.0f; } static double wallTangent(Wall::Position projectile, double wx, double wz) { switch (projectile) { case Wall::Position::PosX: return -wz; case Wall::Position::NegX: return wz; case Wall::Position::PosZ: return -wx; case Wall::Position::NegZ: return wx; } return 0.0; } static Vec3 wallReconstruct(Wall::Position projectile, double plane, double tangent, double y) { switch (projectile) { case Wall::Position::PosX: return { plane , y, -tangent }; case Wall::Position::NegX: return { plane , y, tangent }; case Wall::Position::PosZ: return { -tangent, y, plane }; case Wall::Position::NegZ: return { tangent, y, plane }; } return {}; } static void emitFloorLanding(Projectile& projectile, DynArray& results) { ProjectileResult res; res.landing = ProjectileLanding::Floor; res.vel = projectile.vel; res.words = std::move(projectile.words); results.push_back(std::move(res)); } static void tickSticking(Projectile& projectile, double dt, double ws, DynArray& results) { if (!projectile.sticking || projectile.words.empty()) return; projectile.stick_t += dt * 5.0; const double smoothing_factor = 1.0 - SDL_exp(-12.0 * dt); const float target_yaw = wallTargetYaw (projectile.stick_wall); const double plane = wallPlaneCoord(projectile.stick_wall, ws); for (Text::TokenPtr& word : projectile.words) { if (!word || word->type != ObjectType::Text) continue; Vec3 target = wallReconstruct(projectile.stick_wall, plane, word->phrase_offset.x, word->phrase_offset.y); word->pos = Math::lerp(word->pos, target, smoothing_factor); word->yaw = static_cast(Math::lerp(static_cast(word->yaw), static_cast(target_yaw), smoothing_factor)); } if (projectile.stick_t >= 1.0) { ProjectileResult res; res.landing = projectile.program_stick ? ProjectileLanding::ProgramInput : ProjectileLanding::Wall; res.wall_pos = projectile.stick_wall; res.words = std::move(projectile.words); results.push_back(std::move(res)); } } static bool checkFloorCeiling(Projectile& projectile, const Vec3& centroid, double ws, DynArray& results) { double word_he_y = 0.07; for (const Text::TokenPtr& w : projectile.words) { if (w) { word_he_y = w->shape.half_extents.y; break; } } if ((centroid.y - word_he_y) <= -ws + 0.05) { emitFloorLanding(projectile, results); return true; } if (centroid.y > ws) { projectile.vel.y = bounceVelocity(projectile.vel.y, 0.75); for (Text::TokenPtr& w : projectile.words) if (w) w->pos.y = ws - 0.01; } return false; } static bool detectWallHit(const Projectile& projectile, const Vec3& centroid, double dt, double ws, Wall::Position& out_pos) { const double next_x = centroid.x + projectile.vel.x * dt; const double next_z = centroid.z + projectile.vel.z * dt; if ((centroid.x > -ws + Config::Physics::stick_dist && next_x <= -ws + Config::Physics::stick_dist) || (centroid.x <= -ws + Config::Physics::stick_dist && projectile.vel.x < 0.0)) { out_pos = Wall::Position::NegX; return true; } if ((centroid.x < ws - Config::Physics::stick_dist && next_x >= ws - Config::Physics::stick_dist) || (centroid.x >= ws - Config::Physics::stick_dist && projectile.vel.x > 0.0)) { out_pos = Wall::Position::PosX; return true; } if ((centroid.z > -ws + Config::Physics::stick_dist && next_z <= -ws + Config::Physics::stick_dist) || (centroid.z <= -ws + Config::Physics::stick_dist && projectile.vel.z < 0.0)) { out_pos = Wall::Position::NegZ; return true; } if ((centroid.z < ws - Config::Physics::stick_dist && next_z >= ws - Config::Physics::stick_dist) || (centroid.z >= ws - Config::Physics::stick_dist && projectile.vel.z > 0.0)) { out_pos = Wall::Position::PosZ; return true; } return false; } static bool checkWallTextIntercept(const Projectile& projectile, const Vec3& centroid, double dt, Wall::Position hit_pos, const DynArray& wall_text_hits, double aim_y, double aim_z, bool editor_wall, size_t& out_idx) { const Vec3 next_centroid { centroid.x + projectile.vel.x * dt, centroid.y + projectile.vel.y * dt, centroid.z + projectile.vel.z * dt }; const Vec3 ray_dir = next_centroid - projectile.prev_centroid; Vec3 word_he { 0.3, 0.07, 0.07 }; for (const Text::TokenPtr& w : projectile.words) { if (w) { word_he = w->shape.half_extents; break; } } for (const WallTextHitTest& wt : wall_text_hits) { if (wt.wall_position != hit_pos) continue; if (editor_wall) { // Vertical row gate (as before): only the aimed row's words. const double center_y = (wt.aabb_Min.y + wt.aabb_Max.y) * 0.5; if (SDL_fabs(aim_y - center_y) > Config::Wall::line_height * 0.5) continue; // Horizontal: the aim must land within the token's column span at all. // The fine drop-vs-merge split (central 90% drop, outer 5% each edge // merge) is applied downstream by classify_Impact, so the gate here is // the full span — anything tighter would turn edge aims into "stick new // text" instead of the intended merge. const double center_z = (wt.aabb_Min.z + wt.aabb_Max.z) * 0.5; const double half_z = (wt.aabb_Max.z - wt.aabb_Min.z) * 0.5; if (SDL_fabs(aim_z - center_z) > half_z) continue; // In the central band → shoot it down (no projectile-width inflation // on z, so the gap between words stays a merge zone). out_idx = wt.index; return true; } Vec3 box_min { wt.aabb_Min.x - word_he.x, wt.aabb_Min.y - word_he.y, wt.aabb_Min.z - word_he.z }; Vec3 box_max { wt.aabb_Max.x + word_he.x, wt.aabb_Max.y + word_he.y, wt.aabb_Max.z + word_he.z }; Collision::SweptResult sw = Collision::swept_RayAabb(projectile.prev_centroid, ray_dir, box_min, box_max); if (sw.hit) { out_idx = wt.index; return true; } } return false; } static void beginEditorStick(Projectile& projectile, Wall::Position hit_pos) { projectile.sticking = true; projectile.stick_wall = hit_pos; projectile.vel = Vec3{0, 0, 0}; projectile.stick_t = 0.0; const double cr_x = SDL_cos(projectile.yaw); const double cr_z = -SDL_sin(projectile.yaw); double wt_x = 0.0, wt_z = 0.0; switch (hit_pos) { case Wall::Position::NegZ: wt_x = 1.0; break; case Wall::Position::PosZ: wt_x = -1.0; break; case Wall::Position::NegX: wt_z = 1.0; break; case Wall::Position::PosX: wt_z = -1.0; break; } const double dot = cr_x * wt_x + cr_z * wt_z; const double sign = (dot >= 0.0) ? 1.0 : -1.0; // Stick where the phrase actually is — its current centroid — NOT where the // original launch ray would cross the wall. The launch ray ignores the gravity // arc and is meaningless after a bounce (it would teleport the word, which is // why bounced shots used to respawn near the world centre). const Vec3 c_pos = Text::Token::Data::words_centroid(projectile.words); const double c_tan = wallTangent(hit_pos, c_pos.x, c_pos.z); const double c_y = c_pos.y; for (Text::TokenPtr& w : projectile.words) { if (!w || w->type != ObjectType::Text) continue; w->phrase_offset = { c_tan + w->phrase_offset.x * sign, c_y + w->phrase_offset.y, 0.0 }; w->pos.y = w->phrase_offset.y; } } static void beginProgramStick(Projectile& projectile, double ws) { projectile.sticking = true; projectile.program_stick = true; projectile.stick_wall = Wall::Position::PosX; projectile.vel = Vec3{0, 0, 0}; projectile.stick_t = 0.0; const double cr_x = SDL_cos(projectile.yaw); const double cr_z = -SDL_sin(projectile.yaw); const double wt_z = -1.0; const double dot = cr_x * 0.0 + cr_z * wt_z; const double sign = (dot >= 0.0) ? 1.0 : -1.0; const double c_tan = 0.0; const double c_y = -ws + 1.0; for (Text::TokenPtr& w : projectile.words) { if (!w || w->type != ObjectType::Text) continue; w->phrase_offset = { c_tan + w->phrase_offset.x * sign, c_y + w->phrase_offset.y, 0.0 }; } } static bool bounceOffWall(Projectile& projectile, Wall::Position hit_pos, double ws, DynArray& results) { const bool x_axis = (hit_pos == Wall::Position::PosX || hit_pos == Wall::Position::NegX); const uint8_t axis_bit = x_axis ? BOUNCED_X : BOUNCED_Z; if (projectile.bounce_state & axis_bit) { emitFloorLanding(projectile, results); return true; } projectile.bounce_state |= axis_bit; projectile.collided = true; if (x_axis) { projectile.vel.x = bounceVelocity(projectile.vel.x, 0.70); for (Text::TokenPtr& w : projectile.words) if (w) w->pos.x = (hit_pos == Wall::Position::PosX) ? ( ws - Config::Physics::stick_dist - 0.05) : (-ws + Config::Physics::stick_dist + 0.05); } else { projectile.vel.z = bounceVelocity(projectile.vel.z, 0.70); for (Text::TokenPtr& w : projectile.words) if (w) w->pos.z = (hit_pos == Wall::Position::PosZ) ? ( ws - Config::Physics::stick_dist - 0.05) : (-ws + Config::Physics::stick_dist + 0.05); } return false; } static bool handleWallHit(Projectile& projectile, const Vec3& centroid, Wall::Position hit_pos, double dt,double ws, const DynArray& wall_text_hits, DynArray& results, bool program_running) { if (Wall::role_Of(hit_pos) == Wall::Role::Program) { if (program_running) { beginProgramStick(projectile, ws); return false; } return bounceOffWall(projectile, hit_pos, ws, results); } const bool editor = (Wall::role_Of(hit_pos) == Wall::Role::Editor); // Drop/merge is decided from the exact crosshair COLUMN — the launch ray // intersected with the text plane — not the projectile's physical landing or // its mesh hit. The arc, the shooting angle, and any object between the camera // and the wall all corrupt the landing/mesh point; the ray∩plane is pure // geometry and maps cleanly to a column. After a bounce the launch ray is // stale, so fall back to the real landing centroid (keeps Bug 1 fixed). Vec3 decide = centroid; bool use_aim = false; if (editor && !projectile.collided && SDL_fabs(projectile.dir.x) > 1e-6) { // aim_point lies on the crosshair ray, so (aim_point, dir) IS that ray. // Intersect it with the text plane for the exact aimed column. const Vec3 n = Wall::inward_Normal(hit_pos); const double plane_x = wallPlaneCoord(hit_pos, ws) + n.x * static_cast(Config::Wall::text_offset); const double t = (plane_x - projectile.aim_point.x) / projectile.dir.x; if (t > 0.0) { decide = Vec3{ plane_x, projectile.aim_point.y + projectile.dir.y * t, projectile.aim_point.z + projectile.dir.z * t }; use_aim = true; } } const double aim_y = editor ? decide.y : 0.0; const double aim_z = editor ? decide.z : 0.0; size_t hit_idx = 0; if (projectile.words.size() == 1 && checkWallTextIntercept(projectile, centroid, dt, hit_pos, wall_text_hits, aim_y, aim_z, editor, hit_idx)) { ProjectileResult res; res.landing = ProjectileLanding::WallText; res.wall_pos = hit_pos; res.wall_text_index = hit_idx; res.vel = projectile.vel; res.aim_point = decide; res.use_aim = use_aim; res.words = std::move(projectile.words); results.push_back(std::move(res)); return true; } if (Wall::role_Of(hit_pos) == Wall::Role::Editor) { beginEditorStick(projectile, hit_pos); return false; } return bounceOffWall(projectile, hit_pos, ws, results); } void ProjectileSystem::launch(DynArray& out, DynArray words, const Vec3& fwd, const Aim::Result& aim) { if (words.empty()) return; double yaw = SDL_atan2(-fwd.x, -fwd.z); for (Text::TokenPtr& w : words) { if (!w) continue; if (w->type == ObjectType::Text) w->set_Look(Config::Color::keep, Text::Token::depth_floor); w->yaw = static_cast(yaw); } double right_x = SDL_cos(yaw); double right_z = -SDL_sin(yaw); Vec3 centroid = Text::Token::Data::words_centroid(words); for (Text::TokenPtr& w : words) { if (!w || w->type != ObjectType::Text) continue; Text::Token::Data& tx = static_cast(*w); double dx = w->pos.x - centroid.x; double dz = w->pos.z - centroid.z; tx.phrase_offset = { dx * right_x + dz * right_z, w->pos.y - centroid.y, 0.0 }; } const Vec3 target= aim.point; const double g = Config::Physics::proj_grav; const double dx = target.x - centroid.x; const double dy = target.y - centroid.y; const double dz = target.z - centroid.z; const double horiz = SDL_sqrt(dx*dx + dz*dz); double T = horiz / Config::Physics::proj_throw_speed; T = SDL_clamp(T, Config::Physics::proj_t_min, Config::Physics::proj_t_max); Projectile proj; proj.vel = Vec3{ dx / T, dy / T + 0.5 * g * T, dz / T }; proj.yaw = yaw; proj.bounce_state = 0; proj.words = std::move(words); proj.dir = fwd; proj.launch_origin = centroid; proj.aim_point = aim.point; // a point on the crosshair ray; rebuilds the ray with dir proj.prev_centroid = centroid; if (aim.track) { proj.track_obj = aim.track; proj.track_local = aim.local; proj.track_last = aim.point; } out.push_back(std::move(proj)); } void ProjectileSystem::update(DynArray& projectiles, DynArray& results, const DynArray& wall_text_hits, double dt, bool program_running) { const double ws = World::get_BoxScale(); for (Projectile& projectile : projectiles) tickSticking(projectile, dt, ws, results); for (auto it = projectiles.begin(); it != projectiles.end(); ) { Projectile& projectile = *it; if (projectile.sticking) { if (projectile.words.empty()) it = projectiles.erase(it); else ++it; continue; } if (projectile.words.empty()) { it = projectiles.erase(it); continue; } const Vec3 centroid = Text::Token::Data::words_centroid(projectile.words); bool remove = false; if (checkFloorCeiling(projectile, centroid, ws, results)) remove = true; if (!remove) { Wall::Position hit_pos{}; if (detectWallHit(projectile, centroid, dt, ws, hit_pos)) remove = handleWallHit(projectile, centroid, hit_pos, dt, ws, wall_text_hits, results, program_running); } if (!remove && !projectile.sticking && Math::length(projectile.vel) < 1.0) { emitFloorLanding(projectile, results); remove = true; } if (remove) { it = projectiles.erase(it); } else { projectile.prev_centroid = centroid; ++it; } } } } // namespace Nimbin