diff --git a/gameengine/CMakeLists.txt b/gameengine/CMakeLists.txt index a4a5e37d8..4043cd598 100644 --- a/gameengine/CMakeLists.txt +++ b/gameengine/CMakeLists.txt @@ -301,6 +301,32 @@ if(BUILD_SDL3_APP) src/services/impl/workflow/quake3/workflow_q3_bots_spawn_step.cpp src/services/impl/workflow/quake3/workflow_q3_bots_update_step.cpp src/services/impl/workflow/quake3/workflow_q3_bots_draw_step.cpp + # Q3 pmove + src/services/impl/workflow/quake3/workflow_q3_pm_crouch_step.cpp + src/services/impl/workflow/quake3/workflow_q3_pm_ground_step.cpp + src/services/impl/workflow/quake3/workflow_q3_pm_friction_step.cpp + src/services/impl/workflow/quake3/workflow_q3_pm_accelerate_step.cpp + src/services/impl/workflow/quake3/workflow_q3_pm_jump_step.cpp + src/services/impl/workflow/quake3/workflow_q3_pm_slide_move_step.cpp + # Q3 weapons + missiles + src/services/impl/workflow/quake3/workflow_q3_weapon_select_step.cpp + src/services/impl/workflow/quake3/workflow_q3_weapon_fire_step.cpp + src/services/impl/workflow/quake3/workflow_q3_missiles_move_step.cpp + src/services/impl/workflow/quake3/workflow_q3_missiles_impact_step.cpp + # Q3 damage + pickups + ammo + src/services/impl/workflow/quake3/workflow_q3_ammo_init_step.cpp + src/services/impl/workflow/quake3/workflow_q3_damage_apply_step.cpp + src/services/impl/workflow/quake3/workflow_q3_bots_damage_step.cpp + src/services/impl/workflow/quake3/workflow_q3_player_death_check_step.cpp + src/services/impl/workflow/quake3/workflow_q3_player_respawn_step.cpp + src/services/impl/workflow/quake3/workflow_q3_pickups_touch_step.cpp + src/services/impl/workflow/quake3/workflow_q3_pickups_respawn_step.cpp + # Q3 movers + triggers + nav + src/services/impl/workflow/quake3/workflow_q3_movers_init_step.cpp + src/services/impl/workflow/quake3/workflow_q3_movers_update_step.cpp + src/services/impl/workflow/quake3/workflow_q3_triggers_check_step.cpp + src/services/impl/workflow/quake3/workflow_q3_triggers_apply_step.cpp + src/services/impl/workflow/quake3/workflow_q3_nav_build_step.cpp src/services/impl/workflow/rendering/workflow_bsp_build_collision_step.cpp src/services/impl/workflow/rendering/workflow_bsp_build_geometry_step.cpp src/services/impl/workflow/rendering/workflow_bsp_entity_update_step.cpp diff --git a/gameengine/packages/quake3/workflows/q3_combat.json b/gameengine/packages/quake3/workflows/q3_combat.json index 043e76aa0..ca00399ec 100644 --- a/gameengine/packages/quake3/workflows/q3_combat.json +++ b/gameengine/packages/quake3/workflows/q3_combat.json @@ -1,10 +1,22 @@ { "name": "Q3 Combat", - "description": "Weapon fire + bot AI update.", + "description": "Weapon selection, firing, projectile movement, and impact detection.", "steps": [ { - "id": "q3_weapon", - "plugin": "q3.weapon.update" + "id": "weapon_select", + "plugin": "q3.weapon.select" + }, + { + "id": "weapon_fire", + "plugin": "q3.weapon.fire" + }, + { + "id": "missiles_move", + "plugin": "q3.missiles.move" + }, + { + "id": "missiles_impact", + "plugin": "q3.missiles.impact" }, { "id": "q3_bots_update", @@ -15,6 +27,12 @@ "move_speed": 3.5, "shoot_interval": 30 } - } + }, + { "id": "damage_apply", "plugin": "q3.damage.apply" }, + { "id": "bots_damage", "plugin": "q3.damage.bots" }, + { "id": "death_check", "plugin": "q3.player.death_check" }, + { "id": "player_respawn", "plugin": "q3.player.respawn" }, + { "id": "pickups_touch", "plugin": "q3.pickups.touch" }, + { "id": "pickups_respawn", "plugin": "q3.pickups.respawn" } ] } diff --git a/gameengine/packages/quake3/workflows/q3_map_session.json b/gameengine/packages/quake3/workflows/q3_map_session.json index b3a3466a7..79af75c5a 100644 --- a/gameengine/packages/quake3/workflows/q3_map_session.json +++ b/gameengine/packages/quake3/workflows/q3_map_session.json @@ -101,6 +101,18 @@ "position": [760, 0], "parameters": { "count": 4 } }, + { + "id": "ammo_init", + "type": "q3.ammo.init", + "typeVersion": 1, + "position": [770, 0] + }, + { + "id": "nav_build", + "type": "q3.nav.build", + "typeVersion": 1, + "position": [780, 0] + }, { "id": "set_running", "type": "value.literal", @@ -137,7 +149,9 @@ "load_upper": { "main": { "0": [{ "node": "load_head", "type": "main", "index": 0 }] } }, "load_head": { "main": { "0": [{ "node": "load_weapon_mg","type": "main", "index": 0 }] } }, "load_weapon_mg":{ "main": { "0": [{ "node": "bots_spawn", "type": "main", "index": 0 }] } }, - "bots_spawn": { "main": { "0": [{ "node": "set_running", "type": "main", "index": 0 }] } }, + "bots_spawn": { "main": { "0": [{ "node": "ammo_init", "type": "main", "index": 0 }] } }, + "ammo_init": { "main": { "0": [{ "node": "nav_build", "type": "main", "index": 0 }] } }, + "nav_build": { "main": { "0": [{ "node": "set_running", "type": "main", "index": 0 }] } }, "set_running": { "main": { "0": [{ "node": "game_loop", "type": "main", "index": 0 }] } }, "game_loop": { "main": { "0": [{ "node": "check_quit", "type": "main", "index": 0 }] } } } diff --git a/gameengine/packages/quake3/workflows/q3_physics.json b/gameengine/packages/quake3/workflows/q3_physics.json index ea4038b5a..1814f0ed4 100644 --- a/gameengine/packages/quake3/workflows/q3_physics.json +++ b/gameengine/packages/quake3/workflows/q3_physics.json @@ -3,22 +3,27 @@ "description": "FPS movement, physics step, transform sync, BSP entity update, camera.", "steps": [ { - "id": "physics_move", - "plugin": "physics.fps.move", - "parameters": { - "move_speed": 6.5, - "sprint_multiplier": 1.5, - "crouch_multiplier": 0.45, - "jump_velocity": 5.5, - "air_control": 0.25, - "gravity_scale": 0.5, - "ground_accel": 30.0, - "ground_friction": 24.0, - "step_height": 0.6, - "crouch_height": 0.5, - "stand_height": 1.4 - } + "id": "movers_init", + "plugin": "q3.movers.init" }, + { + "id": "movers_update", + "plugin": "q3.movers.update" + }, + { + "id": "triggers_check", + "plugin": "q3.triggers.check" + }, + { + "id": "triggers_apply", + "plugin": "q3.triggers.apply" + }, + { "id": "pm_crouch", "plugin": "q3.pm.crouch" }, + { "id": "pm_ground", "plugin": "q3.pm.ground" }, + { "id": "pm_friction", "plugin": "q3.pm.friction" }, + { "id": "pm_accelerate", "plugin": "q3.pm.accelerate" }, + { "id": "pm_jump", "plugin": "q3.pm.jump" }, + { "id": "pm_slide", "plugin": "q3.pm.slide_move" }, { "id": "physics_step", "plugin": "physics.step" diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_ammo_init_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_ammo_init_step.cpp new file mode 100644 index 000000000..d6dc22469 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_ammo_init_step.cpp @@ -0,0 +1,35 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_ammo_init_step.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowQ3AmmoInitStep::WorkflowQ3AmmoInitStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3AmmoInitStep::GetPluginId() const { return "q3.ammo.init"; } + +void WorkflowQ3AmmoInitStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + // One-shot guard + if (context.GetBool("q3.ammo_initialized", false)) return; + + nlohmann::json ammo = { + {"weapon_machinegun", 100}, + {"weapon_shotgun", 10}, + {"weapon_grenadelauncher", 5}, + {"weapon_rocketlauncher", 5}, + {"weapon_lightning", 60}, + {"weapon_railgun", 10}, + {"weapon_plasmagun", 50}, + {"weapon_bfg", 15} + }; + + context.Set("q3.player_ammo", ammo); + context.Set("q3.ammo_initialized", true); + + if (logger_) logger_->Info("q3.ammo.init: ammo initialized"); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_damage_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_damage_step.cpp new file mode 100644 index 000000000..8249aecb5 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_damage_step.cpp @@ -0,0 +1,48 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_bots_damage_step.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowQ3BotsDamageStep::WorkflowQ3BotsDamageStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3BotsDamageStep::GetPluginId() const { return "q3.damage.bots"; } + +void WorkflowQ3BotsDamageStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + const auto* botDamagePtr = context.TryGet("q3.bot_damage"); + if (!botDamagePtr || !botDamagePtr->is_object() || botDamagePtr->empty()) return; + + const auto* botsPtr = context.TryGet("q3.bots"); + if (!botsPtr || !botsPtr->is_array()) return; + + nlohmann::json botDamage = *botDamagePtr; + nlohmann::json bots = *botsPtr; + + for (auto it = botDamage.begin(); it != botDamage.end(); ++it) { + const std::string& key = it.key(); + const int amount = it.value().get(); + if (amount <= 0) continue; + + // Find the bot whose id matches the key suffix (e.g. "bot_0" → id==0) + for (auto& bot : bots) { + const std::string botKey = "bot_" + std::to_string(bot.value("id", -1)); + if (botKey != key) continue; + + int hp = bot.value("health", 0) - amount; + bot["health"] = hp; + if (hp <= 0) { + bot["state"] = "dead"; + if (logger_) logger_->Info("q3.damage.bots: " + key + " killed"); + } + break; + } + } + + context.Set("q3.bots", bots); + context.Set("q3.bot_damage", nlohmann::json::object()); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_update_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_update_step.cpp index 67c18501b..039a04766 100644 --- a/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_update_step.cpp +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_bots_update_step.cpp @@ -1,14 +1,93 @@ #include "services/interfaces/workflow/quake3/workflow_q3_bots_update_step.hpp" +#include "services/interfaces/workflow/quake3/q3_nav_types.hpp" #include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" #include "services/interfaces/workflow_context.hpp" +#include #include #include + +#include #include +#include #include +#include +#include namespace sdl3cpp::services::impl { +// ---------------------------------------------------------------- +// A* over NavGraph +// Returns node-index path from startNode to goalNode, or empty on failure. +// ---------------------------------------------------------------- +namespace { + +std::vector AStar(const sdl3cpp::q3::NavGraph& graph, int startNode, int goalNode) { + if (startNode < 0 || goalNode < 0 || + startNode >= (int)graph.nodes.size() || + goalNode >= (int)graph.nodes.size()) { + return {}; + } + if (startNode == goalNode) return {startNode}; + + const int N = (int)graph.nodes.size(); + std::vector gCost(N, 1e9f); + std::vector parent(N, -1); + + auto heuristic = [&](int a, int b) { + const glm::vec3 d = graph.nodes[a].pos - graph.nodes[b].pos; + return std::sqrt(d.x*d.x + d.y*d.y + d.z*d.z); + }; + + // Min-heap: (fCost, nodeIdx) + using Entry = std::pair; + std::priority_queue, std::greater> open; + + gCost[startNode] = 0.f; + open.push({heuristic(startNode, goalNode), startNode}); + + while (!open.empty()) { + const auto [f, cur] = open.top(); open.pop(); + if (cur == goalNode) break; + + for (int nb : graph.nodes[cur].neighbors) { + const glm::vec3 d = graph.nodes[nb].pos - graph.nodes[cur].pos; + const float edgeCost = std::sqrt(d.x*d.x + d.y*d.y + d.z*d.z); + const float newG = gCost[cur] + edgeCost; + if (newG < gCost[nb]) { + gCost[nb] = newG; + parent[nb] = cur; + open.push({newG + heuristic(nb, goalNode), nb}); + } + } + } + + if (parent[goalNode] == -1 && goalNode != startNode) return {}; + + // Reconstruct path + std::vector path; + for (int cur = goalNode; cur != -1; cur = parent[cur]) + path.push_back(cur); + std::reverse(path.begin(), path.end()); + return path; +} + +// Find the nearest nav node to a world position +int NearestNode(const sdl3cpp::q3::NavGraph& graph, const glm::vec3& pos) { + int best = -1; + float bestD2 = 1e9f; + for (int i = 0; i < (int)graph.nodes.size(); ++i) { + const glm::vec3 d = graph.nodes[i].pos - pos; + const float d2 = d.x*d.x + d.y*d.y + d.z*d.z; + if (d2 < bestD2) { bestD2 = d2; best = i; } + } + return best; +} + +} // namespace + +// ---------------------------------------------------------------- + WorkflowQ3BotsUpdateStep::WorkflowQ3BotsUpdateStep(std::shared_ptr logger) : logger_(std::move(logger)) {} @@ -24,18 +103,19 @@ void WorkflowQ3BotsUpdateStep::Execute(const WorkflowStepDefinition& step, Workf return (p && p->type == WorkflowParameterValue::Type::Number) ? (float)p->numberValue : def; }; - const float chaseRange = getNum("chase_range", 20.0f); - const float shootRange = getNum("shoot_range", 6.0f); - const float moveSpeed = getNum("move_speed", 3.0f); - const int legIdle = (int)getNum("leg_idle", 162); // Q3 keel defaults - const int legRun = (int)getNum("leg_run", 167); - const int legRunCnt = (int)getNum("leg_run_cnt", 8); + const float chaseRange = getNum("chase_range", 20.0f); + const float shootRange = getNum("shoot_range", 6.0f); + const float moveSpeed = getNum("move_speed", 3.0f); + const int legIdle = (int)getNum("leg_idle", 162); // Q3 keel defaults + const int legRun = (int)getNum("leg_run", 167); + const int legRunCnt = (int)getNum("leg_run_cnt", 8); const int torsoStand = (int)getNum("torso_stand", 101); - const int torsoAtk = (int)getNum("torso_attack", 107); + const int torsoAtk = (int)getNum("torso_attack",107); const int torsoAtkCnt = (int)getNum("torso_atk_cnt", 6); const int shootInterv = (int)getNum("shoot_interval", 30); // frames + const int replanRate = (int)getNum("replan_frames", 60); // A* replan interval - // Player position from camera state + // Player position: prefer explicit q3.player_pos, fall back to camera.state const auto camState = context.Get("camera.state", nlohmann::json::object()); glm::vec3 playerPos(0.0f); if (camState.contains("position") && camState["position"].is_array()) { @@ -43,15 +123,27 @@ void WorkflowQ3BotsUpdateStep::Execute(const WorkflowStepDefinition& step, Workf if (cp.size() >= 3) playerPos = {cp[0].get(), cp[1].get(), cp[2].get()}; } + if (const auto* pp = context.TryGet("q3.player_pos")) + playerPos = *pp; - const double dt = context.GetDouble("frame.delta_time", 0.016); - const double elapsed = context.GetDouble("frame.elapsed", 0.0); + const double dt = context.GetDouble("frame.delta_time", 0.016); + const double elapsed = context.GetDouble("frame.elapsed", 0.0); const int globalFrame = (int)(elapsed * 60.0); // ~60fps frame counter - nlohmann::json bots = *botsPtr; + // Nav graph — may be null when q3.nav.build hasn't run yet + sdl3cpp::q3::NavGraph* navGraph = nullptr; + if (const auto* ngp = context.TryGet("q3.nav_graph")) + if (*ngp && !(*ngp)->nodes.empty()) + navGraph = ngp->get(); + + // Physics world for line-of-sight raycasts + auto* world = context.Get("physics_world", nullptr); + + nlohmann::json bots = *botsPtr; nlohmann::json shots = nlohmann::json::array(); - for (auto& bot : bots) { + for (int botIdx = 0; botIdx < (int)bots.size(); ++botIdx) { + auto& bot = bots[botIdx]; if (bot.value("state", std::string{}) == "dead") continue; const auto& posJ = bot["pos"]; @@ -62,12 +154,24 @@ void WorkflowQ3BotsUpdateStep::Execute(const WorkflowStepDefinition& step, Workf toPlayer.y * toPlayer.y + toPlayer.z * toPlayer.z); - // Face player: yaw = atan2 of horizontal direction (XZ plane in Y-up) + // Face player: yaw = atan2 in XZ plane (Y-up world) const float targetYaw = std::atan2(toPlayer.x, toPlayer.z); bot["yaw"] = targetYaw; + // Line-of-sight check via physics raycast + bool canSeePlayer = false; + if (world && dist > 0.f) { + btVector3 from(bpos.x, bpos.y, bpos.z); + btVector3 to(playerPos.x, playerPos.y, playerPos.z); + btCollisionWorld::ClosestRayResultCallback cb(from, to); + world->rayTest(from, to, cb); + canSeePlayer = !cb.hasHit(); + } else { + canSeePlayer = (dist < chaseRange); + } + // State transitions - if (dist < shootRange) { + if (dist < shootRange && canSeePlayer) { bot["state"] = "shoot"; } else if (dist < chaseRange) { bot["state"] = "chase"; @@ -77,12 +181,53 @@ void WorkflowQ3BotsUpdateStep::Execute(const WorkflowStepDefinition& step, Workf const std::string state = bot["state"].get(); - // Movement + // ---------------------------------------------------------------- + // Movement with A* path following (falls back to direct if no graph) + // ---------------------------------------------------------------- if (state == "chase" && dist > 0.5f) { - const glm::vec3 dir = toPlayer / dist; - bpos.x += dir.x * moveSpeed * (float)dt; - bpos.z += dir.z * moveSpeed * (float)dt; - // Keep Y from spawn (basic ground-snapping; proper would do physics) + const std::string pathKey = "q3.bot_path_" + std::to_string(botIdx); + + // Load cached path + std::vector path; + if (const auto* cached = context.TryGet>>(pathKey)) + if (*cached) path = **cached; + + // Re-plan when path empty or stale + const int lastPlan = bot.value("last_plan_frame", -replanRate - 1); + const bool needReplan = path.empty() || (globalFrame - lastPlan >= replanRate); + + if (navGraph && needReplan) { + const int botNode = NearestNode(*navGraph, bpos); + const int playerNode = NearestNode(*navGraph, playerPos); + path = AStar(*navGraph, botNode, playerNode); + bot["last_plan_frame"] = globalFrame; + context.Set(pathKey, std::make_shared>(path)); + } + + // Steer toward path[1] (next waypoint) or directly at player + glm::vec3 moveTarget = playerPos; + if (navGraph && path.size() >= 2) { + const int nextNode = path[1]; + if (nextNode >= 0 && nextNode < (int)navGraph->nodes.size()) + moveTarget = navGraph->nodes[nextNode].pos; + } + + const glm::vec3 toTarget = moveTarget - bpos; + const float td = std::sqrt(toTarget.x*toTarget.x + + toTarget.y*toTarget.y + + toTarget.z*toTarget.z); + if (td > 0.1f) { + const glm::vec3 dir = toTarget / td; + bpos.x += dir.x * moveSpeed * (float)dt; + bpos.z += dir.z * moveSpeed * (float)dt; + + // Pop waypoint when close enough to advance path + if (navGraph && path.size() >= 2 && td < 0.8f) { + path.erase(path.begin()); + context.Set(pathKey, std::make_shared>(path)); + } + } + bot["pos"] = nlohmann::json::array({bpos.x, bpos.y, bpos.z}); } @@ -90,10 +235,10 @@ void WorkflowQ3BotsUpdateStep::Execute(const WorkflowStepDefinition& step, Workf const double fps = 15.0; const int baseFrame = (int)(elapsed * fps); if (state == "chase") { - bot["leg_frame"] = legRun + (legRunCnt > 0 ? (baseFrame % legRunCnt) : 0); + bot["leg_frame"] = legRun + (legRunCnt > 0 ? (baseFrame % legRunCnt) : 0); bot["torso_frame"] = torsoStand; } else if (state == "shoot") { - bot["leg_frame"] = legIdle; + bot["leg_frame"] = legIdle; bot["torso_frame"] = torsoAtk + (torsoAtkCnt > 0 ? (baseFrame % torsoAtkCnt) : 0); // Fire shot diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_damage_apply_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_damage_apply_step.cpp new file mode 100644 index 000000000..220e84b0b --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_damage_apply_step.cpp @@ -0,0 +1,46 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_damage_apply_step.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowQ3DamageApplyStep::WorkflowQ3DamageApplyStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3DamageApplyStep::GetPluginId() const { return "q3.damage.apply"; } + +void WorkflowQ3DamageApplyStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + const int damage = context.GetInt("q3.pending_damage", 0); + if (damage == 0) return; + + int health = context.GetInt("q3.player_health", 100); + int armor = context.GetInt("q3.player_armor", 0); + const std::string armorType = context.GetString("q3.armor_type", "none"); + + // ioq3 G_Damage armor absorption + // yellow armor: protection = 1.0, green armor: protection = 0.667 + if (armor > 0 && armorType != "none") { + const double protection = (armorType == "yellow") ? 1.0 : 0.667; + int save = static_cast(std::ceil(damage * protection)); + save = std::min(save, armor); + armor -= save; + health -= (damage - save); + } else { + health -= damage; + } + + context.Set("q3.player_health", health); + context.Set("q3.player_armor", armor); + context.Set("q3.pending_damage", 0); + + if (logger_) { + logger_->Info("q3.damage.apply: damage=" + std::to_string(damage) + + " health=" + std::to_string(health) + + " armor=" + std::to_string(armor)); + } +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_head_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_head_step.cpp index 6f4e838a4..674f0307c 100644 --- a/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_head_step.cpp +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_head_step.cpp @@ -3,12 +3,14 @@ #include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp" #include "services/interfaces/workflow_context.hpp" +#include #include #include #include #include #include +#include #include #include #include @@ -29,7 +31,7 @@ WorkflowQ3HudHeadStep::~WorkflowQ3HudHeadStep() { std::string WorkflowQ3HudHeadStep::GetPluginId() const { return "q3.hud_head_render"; } -bool WorkflowQ3HudHeadStep::TryInitRT(SDL_GPUDevice* device) { +bool WorkflowQ3HudHeadStep::TryInitRT(SDL_GPUDevice* device, SDL_Window* window) { device_ = device; SDL_GPUTextureCreateInfo ci{}; @@ -39,7 +41,13 @@ bool WorkflowQ3HudHeadStep::TryInitRT(SDL_GPUDevice* device) { ci.layer_count_or_depth = 1; ci.num_levels = 1; - ci.format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; + // Must match the swapchain format so gpu_pipeline_textured (compiled for the + // swapchain) can render into this target without a format-mismatch error. + const SDL_GPUTextureFormat scFmt = window + ? SDL_GetGPUSwapchainTextureFormat(device, window) + : SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM; + + ci.format = scFmt; ci.usage = SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | SDL_GPU_TEXTUREUSAGE_SAMPLER; color_rt_ = SDL_CreateGPUTexture(device, &ci); @@ -100,6 +108,10 @@ static void DrawHeadMd3(const std::string& prefix, // --------------------------------------------------------------------------- void WorkflowQ3HudHeadStep::Execute( const WorkflowStepDefinition&, WorkflowContext& context) { + // Always clear the tex from last frame so overlay.sw.end never blits a + // stale portrait (e.g. when the menu is open or the head model isn't loaded). + context.Set("overlay.head_gpu_tex", nullptr); + if (context.GetBool("frame_skip", false)) return; if (context.GetBool("q3.menu_open", false)) return; @@ -113,25 +125,52 @@ void WorkflowQ3HudHeadStep::Execute( auto* pipeline = context.Get("gpu_pipeline_textured", nullptr); if (!pipeline) return; - if (!ready_ && !TryInitRT(device)) return; + auto* window = context.Get("sdl_window", nullptr); + if (!ready_ && !TryInitRT(device, window)) return; - // ── Build portrait camera ──────────────────────────────────────────────── - // Slow Y-axis rotation so the head gently spins — a quarter turn per ~300 frames. - yaw_ += 0.021f; + // ── Q3A-style idle sway ─────────────────────────────────────────────────── + // Mirrors ioq3 CG_DrawStatusBarHead: every 100–2100 ms pick a new target + // yaw (180° ± 20°) and pitch (±5°), smoothstep-interpolate to it. + // Base yaw 180° = head faces directly toward the camera. + const uint64_t nowMs = SDL_GetTicks(); + if (nowMs >= swayEndMs_) { + // Pick new random target angles + swayStartYaw_ = swayEndYaw_; + swayStartPitch_ = swayEndPitch_; + swayStartMs_ = swayEndMs_; + swayEndMs_ = nowMs + 100 + static_cast(SDL_rand(2000)); - // The head model is centred near the origin. Place a close-up camera - // slightly above and in front; the head radius is ~0.15 world units. + const float ryaw = (static_cast(SDL_rand(1000)) / 1000.f - 0.5f) * 2.f; + const float rpitch = (static_cast(SDL_rand(1000)) / 1000.f - 0.5f) * 2.f; + swayEndYaw_ = glm::radians(180.f + 20.f * ryaw); + swayEndPitch_ = glm::radians(5.f * rpitch); + } + if (swayStartMs_ == 0) swayStartMs_ = nowMs; + + float frac = 0.f; + if (swayEndMs_ > swayStartMs_) { + frac = static_cast(nowMs - swayStartMs_) / + static_cast(swayEndMs_ - swayStartMs_); + frac = std::min(1.f, std::max(0.f, frac)); + frac = frac * frac * (3.f - 2.f * frac); // smoothstep + } + const float curYaw = swayStartYaw_ + (swayEndYaw_ - swayStartYaw_) * frac; + const float curPitch = swayStartPitch_ + (swayEndPitch_ - swayStartPitch_) * frac; + + // Camera orbit around the head using current yaw/pitch angles. + // Q3A places origin at (len/tan(15°), 0, 0) in model space; we approximate. const float camDist = 0.45f; const glm::vec3 camPos( - std::sin(yaw_) * camDist, - 0.06f, - std::cos(yaw_) * camDist); + std::sin(curYaw) * std::cos(curPitch) * camDist, + -std::sin(curPitch) * camDist, + std::cos(curYaw) * std::cos(curPitch) * camDist); const glm::mat4 view = glm::lookAt(camPos, glm::vec3(0.0f, 0.04f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f)); + // Q3A uses FOV=30° for head (tan(15°)=0.268); keep 30° here too. const glm::mat4 proj = glm::perspective( - glm::radians(55.0f), 1.0f /*square aspect*/, 0.01f, 10.0f); + glm::radians(30.0f), 1.0f /*square aspect*/, 0.01f, 10.0f); const glm::mat4 model(1.0f); // head at world origin const glm::mat4 mvp = proj * view * model; @@ -171,21 +210,8 @@ void WorkflowQ3HudHeadStep::Execute( SDL_EndGPURenderPass(pass); // ── Expose the render target so overlay.sw.end can blit it ────────────── + // Face rect coords are set by q3.hud (which runs after this step). context.Set("overlay.head_gpu_tex", color_rt_); - - // ── Face rect in overlay-space (matches q3.hud layout) ────────────────── - constexpr float kNS = 1.0f; - constexpr float kNH = 32.f * kNS; - - const int health = context.Get("q3.player_health", 100); - const float hNumW = (health >= 100 ? 3 : health >= 10 ? 2 : 1) * 32.f * kNS; - const float cluster = hNumW + 4.f + kNH; - const float hx = kW * 0.5f - cluster * 0.5f; - - context.Set("hud.face_rect_x", hx + hNumW + 4.f); - context.Set("hud.face_rect_y", kH - kNH - 6.f); - context.Set("hud.face_rect_w", kNH); - context.Set("hud.face_rect_h", kNH); } } // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_step.cpp index daaea4138..0453ef9bd 100644 --- a/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_step.cpp +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_hud_step.cpp @@ -2,6 +2,25 @@ #include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp" #include #include + +// ───────────────────────────────────────────────────────────────────────────── +// Real Q3A status-bar layout (from ioq3 cg_draw.c), adapted to 640×360: +// +// Source constants (640×480): +// CHAR_WIDTH=32 CHAR_HEIGHT=48 ICON_SIZE=48 TEXT_ICON_SPACE=4 +// ammo x=0, y=432 (field width 3) +// ammo icon x=CHAR_WIDTH*3+TEXT_ICON_SPACE = 100 +// health x=185, y=432 (field width 3) +// head x=185+100=285, y=480-ICON_SIZE*1.25=420, size=60 +// armor x=370, y=432 (field width 3) +// armor icon x=370+100 = 470 +// +// Scale to 640×360: Y *= 360/480 = 0.75; X unchanged. +// kCharW=32 kIconSz=36 kHeadSz=45 +// HUD bar bottom = 360; digits 32px tall → kHudY = 360-32-2 = 326 +// head y = 360 - 45 - 2 = 313 +// ───────────────────────────────────────────────────────────────────────────── + namespace sdl3cpp::services::impl { using namespace q3overlay; @@ -21,16 +40,19 @@ void WorkflowQ3HudStep::Execute( digits[i] = context.Get("overlay.tex.num." + std::to_string(i), nullptr); auto* iArmor = context.Get("overlay.tex.icon_armor", nullptr); - auto* iFace = context.Get("overlay.tex.icon_face", nullptr); auto* iWeapon = context.Get("overlay.tex.icon_weapon", nullptr); const int health = context.Get("q3.player_health", 100); const int armor = context.Get("q3.player_armor", 0); const int ammo = context.Get("q3.player_ammo", 50); - constexpr float kNS = 1.0f; // digit sprite scale → 32×32 px (Q3A native) - constexpr float kNH = 32.f * kNS; // digit height = 32 px - constexpr float kHudY = kH - kNH - 6.f; + // ── Layout constants (match Q3A source, scaled to 640×360) ────────────── + constexpr float kScale = static_cast(kH) / 480.f; // 0.75 + constexpr float kCharW = 32.f; // digit sprite width (native 32b) + constexpr float kCharH = 32.f; // drawn at native height + constexpr float kIconSz = 48.f * kScale; // 36px + constexpr float kIconOff = kCharW * 3.f + 4.f; // 100px (TEXT_ICON_SPACE=4) + constexpr float kHudY = kH - kCharH - 2.f; // 326px — digit baseline auto drawIcon = [&](SDL_Texture* t, float x, float y, float w, float h) { if (!t) return; @@ -39,38 +61,36 @@ void WorkflowQ3HudStep::Execute( SDL_FRect dst{x, y, w, h}; SDL_RenderTexture(r, t, nullptr, &dst); }; - auto digitWidth = [&](int val) -> float { - return (val >= 100 ? 3 : val >= 10 ? 2 : 1) * 32.f * kNS; - }; + const float iconY = kH - kIconSz - 2.f; // icon baseline (taller than digits) - // ── Left cluster: [armor#] [armor icon] ───────────────────────────────── - // Real Q3A: armor sits at far left, icon to its right. - float cx = 10.f; - cx = DrawHudNumber(r, digits, cx, kHudY, armor, kNS); - drawIcon(iArmor, cx + 4.f, kHudY, kNH, kNH); + // ── Left cluster: ammo# + ammo/weapon icon ── x=0 ─────────────────────── + DrawHudNumber(r, digits, 0.f, kHudY, ammo, 1.0f); + drawIcon(iWeapon, kIconOff, iconY, kIconSz, kIconSz); - // ── Center cluster: [health#] [face placeholder] ──────────────────────── - // Real Q3A: health number + mugshot centered on screen. - // The actual face is rendered as a live 3D head by q3.hud_head_render and - // blitted on the GPU by overlay.sw.end — we only draw the number here. - { - const float hNumW = digitWidth(health); - const float cluster = hNumW + 4.f + kNH; - const float hx = kW * 0.5f - cluster * 0.5f; - DrawHudNumber(r, digits, hx, kHudY, health, kNS); - // Face icon drawn as fallback if head render is unavailable - if (!context.Get("overlay.head_gpu_tex", nullptr)) - drawIcon(iFace, hx + hNumW + 4.f, kHudY, kNH, kNH); + // ── Center cluster: health# + head portrait ── x=185 ──────────────────── + DrawHudNumber(r, digits, 185.f, kHudY, health, 1.0f); + // Head portrait drawn by q3.hud_head_render on GPU and blitted by overlay.sw.end. + // Fall back to flat face icon if GPU head is unavailable. + if (!context.Get("overlay.head_gpu_tex", nullptr)) { + auto* iFace = context.Get("overlay.tex.icon_face", nullptr); + constexpr float kHeadSz = 48.f * kScale * 1.25f; // 45px — matches Q3A ICON_SIZE*1.25 + constexpr float kHeadY = kH - kHeadSz - 2.f; + drawIcon(iFace, 185.f + kIconOff, kHeadY, kHeadSz, kHeadSz); } - // ── Right cluster: [ammo#] [weapon icon] ───────────────────────────────── - // Real Q3A: ammo number then weapon icon at far-right edge. - { - const float wIconW = iWeapon ? kNH : 0.f; - const float aNumW = digitWidth(ammo); - const float rx = kW - 10.f - wIconW - (wIconW > 0 ? 6.f : 0.f) - aNumW; - DrawHudNumber(r, digits, rx, kHudY, ammo, kNS); - drawIcon(iWeapon, rx + aNumW + 6.f, kHudY, wIconW, wIconW); + // ── Right cluster: armor# + armor icon ── x=370 ───────────────────────── + if (armor > 0) { + DrawHudNumber(r, digits, 370.f, kHudY, armor, 1.0f); + drawIcon(iArmor, 370.f + kIconOff, iconY, kIconSz, kIconSz); } + + // ── Store head portrait position for the GPU blit step ─────────────────── + // q3.hud_head_render reads these to position its quad on the swapchain. + constexpr float kHeadSz = 48.f * kScale * 1.25f; + constexpr float kHeadY = kH - kHeadSz - 2.f; + context.Set("hud.face_rect_x", 185.f + kIconOff); + context.Set("hud.face_rect_y", kHeadY); + context.Set("hud.face_rect_w", kHeadSz); + context.Set("hud.face_rect_h", kHeadSz); } } // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_missiles_impact_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_missiles_impact_step.cpp new file mode 100644 index 000000000..2ad3356aa --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_missiles_impact_step.cpp @@ -0,0 +1,122 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_missiles_impact_step.hpp" +#include "services/interfaces/workflow/quake3/q3_missile_types.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace sdl3cpp::services::impl { + +namespace { + +// Splash damage: linear falloff to zero at splashRadius. +int SplashDamage(const sdl3cpp::q3::Q3Missile& m, const glm::vec3& targetPos) { + const float dist = glm::length(targetPos - m.origin); + if (dist >= m.splashRadius) return 0; + return static_cast(m.splashDamage * (1.f - dist / m.splashRadius)); +} + +glm::vec3 BotPosition(const nlohmann::json& bot) { + if (bot.contains("pos")) { + const auto& p = bot["pos"]; + return glm::vec3(p[0].get(), p[1].get(), p[2].get()); + } + return glm::vec3(0.f); +} + +} // namespace + +WorkflowQ3MissilesImpactStep::WorkflowQ3MissilesImpactStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3MissilesImpactStep::GetPluginId() const { + return "q3.missiles.impact"; +} + +void WorkflowQ3MissilesImpactStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + auto missiles = context.Get("q3.missiles", nullptr); + if (!missiles || missiles->empty()) { + // Clean up stale prev-position entries for finished missiles. + prevPositions_.clear(); + return; + } + + auto* world = context.Get("physics_world", nullptr); + const glm::vec3 playerPos = context.Get("q3.player_pos", glm::vec3(0.f)); + auto bots = context.Get("q3.bots", nlohmann::json::array()); + auto botDamage = context.Get("q3.bot_damage", nlohmann::json::object()); + int pendingDamage = context.Get("q3.pending_damage", 0); + + // ===================================================================== + // Phase 1: detect impacts via raycast from prev→current position. + // ===================================================================== + if (world) { + for (auto& m : *missiles) { + if (m.exploded) continue; + + auto prevIt = prevPositions_.find(m.id); + const glm::vec3 prev = (prevIt != prevPositions_.end()) ? prevIt->second : m.origin; + + const btVector3 btFrom(prev.x, prev.y, prev.z); + const btVector3 btTo(m.origin.x, m.origin.y, m.origin.z); + + // Only cast if the missile actually moved. + if (btFrom.distance(btTo) > 0.0001f) { + btCollisionWorld::ClosestRayResultCallback cb(btFrom, btTo); + world->rayTest(btFrom, btTo, cb); + if (cb.hasHit()) { + // Snap origin to hit point for splash calculation. + m.origin = glm::vec3(cb.m_hitPointWorld.x(), + cb.m_hitPointWorld.y(), + cb.m_hitPointWorld.z()); + m.exploded = true; + } + } + + // Update stored previous position for next frame. + prevPositions_[m.id] = m.origin; + } + } + + // ===================================================================== + // Phase 2: splash damage for every exploded missile. + // ===================================================================== + for (const auto& m : *missiles) { + if (!m.exploded) continue; + + // Player splash. + const int playerSplash = SplashDamage(m, playerPos); + pendingDamage += playerSplash; + + // Bot splash. + for (auto& [key, bot] : bots.items()) { + const glm::vec3 botPos = BotPosition(bot); + const int botSplash = SplashDamage(m, botPos); + if (botSplash > 0) { + const std::string botKey = key; + botDamage[botKey] = botDamage.value(botKey, 0) + botSplash; + } + } + + // Remove this missile's prev-position entry — it's done. + prevPositions_.erase(m.id); + } + + // ===================================================================== + // Phase 3: remove all exploded missiles. + // ===================================================================== + missiles->erase( + std::remove_if(missiles->begin(), missiles->end(), + [](const sdl3cpp::q3::Q3Missile& m) { return m.exploded; }), + missiles->end()); + + context.Set("q3.missiles", missiles); + context.Set("q3.pending_damage", pendingDamage); + context.Set("q3.bot_damage", botDamage); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_missiles_move_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_missiles_move_step.cpp new file mode 100644 index 000000000..d77110068 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_missiles_move_step.cpp @@ -0,0 +1,44 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_missiles_move_step.hpp" +#include "services/interfaces/workflow/quake3/q3_missile_types.hpp" + +#include + +namespace sdl3cpp::services::impl { + +WorkflowQ3MissilesMoveStep::WorkflowQ3MissilesMoveStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3MissilesMoveStep::GetPluginId() const { + return "q3.missiles.move"; +} + +void WorkflowQ3MissilesMoveStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + auto missiles = context.Get("q3.missiles", nullptr); + if (!missiles || missiles->empty()) { + return; + } + + const float dt = static_cast(context.GetDouble("frame.delta_time", 0.016)); + + for (auto& m : *missiles) { + if (m.exploded) continue; + + // Apply gravity to grenades. + if (m.type == sdl3cpp::q3::MissileType::Grenade) { + m.velocity.y -= 20.f * dt; + } + + m.origin += m.velocity * dt; + m.lifetimeLeft -= dt; + + if (m.lifetimeLeft <= 0.f) { + m.exploded = true; + } + } + + // No need to re-set — missiles is a shared_ptr and was modified in-place. + // Set it back so any step that reads the pointer sees the updated list. + context.Set("q3.missiles", missiles); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_movers_init_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_movers_init_step.cpp new file mode 100644 index 000000000..20b66db30 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_movers_init_step.cpp @@ -0,0 +1,75 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_movers_init_step.hpp" +#include "services/interfaces/workflow/quake3/q3_mover_types.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowQ3MoversInitStep::WorkflowQ3MoversInitStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3MoversInitStep::GetPluginId() const { return "q3.movers.init"; } + +void WorkflowQ3MoversInitStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + if (context.GetBool("q3.movers_initialized", false)) return; + + const auto* entities = context.TryGet("bsp.entities"); + auto movers = std::make_shared>(); + + if (entities && entities->is_array()) { + int idx = 0; + for (const auto& ent : *entities) { + const std::string cls = ent.value("classname", std::string{}); + if (cls != "func_door" && cls != "func_plat") continue; + + // Parse origin + glm::vec3 origin(0.f); + if (ent.contains("origin") && ent["origin"].is_string()) { + const std::string os = ent["origin"].get(); + float x = 0.f, y = 0.f, z = 0.f; + if (std::sscanf(os.c_str(), "%f %f %f", &x, &y, &z) == 3) { + // Apply Q3 unit-to-world scale (same 0.03125 factor used by bsp.load) + constexpr float kScale = 0.03125f; + origin = glm::vec3(x * kScale, y * kScale, z * kScale); + } + } + + // Parse angle (degrees, Q3 convention: 0=+X, 90=-Z in XZ plane) + const float angleDeg = ent.value("angle", 0.f); + const float angleRad = angleDeg * (3.14159265f / 180.f); + // angle 0 → +X, 90 → -Z (right-hand Y-up) + const glm::vec3 moveDir(std::cos(angleRad), 0.f, -std::sin(angleRad)); + + // Parse speed and distance + const float speed = ent.value("speed", 100.f); + const float distRaw = ent.value("distance", 128.f); + constexpr float kScale = 0.03125f; + const float dist = distRaw * kScale; + + const float wait = ent.value("wait", 2.f); + const float travelTime = (speed > 0.f) ? dist / (speed * kScale) : 1.f; + + sdl3cpp::q3::Q3Mover m; + m.id = cls + "_" + std::to_string(idx++); + m.classname = cls; + m.pos1 = origin; + m.pos2 = origin + moveDir * dist; + m.travelTime = travelTime; + m.waitTime = wait; + m.currentPos = origin; + movers->push_back(m); + } + } + + context.Set("q3.movers", movers); + context.Set("q3.movers_initialized", true); + + if (logger_) + logger_->Info("q3.movers.init: created " + std::to_string(movers->size()) + " movers"); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_movers_update_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_movers_update_step.cpp new file mode 100644 index 000000000..8339b4d79 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_movers_update_step.cpp @@ -0,0 +1,109 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_movers_update_step.hpp" +#include "services/interfaces/workflow/quake3/q3_mover_types.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowQ3MoversUpdateStep::WorkflowQ3MoversUpdateStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3MoversUpdateStep::GetPluginId() const { return "q3.movers.update"; } + +void WorkflowQ3MoversUpdateStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + const auto* moversPtr = context.TryGet("q3.movers"); + if (!moversPtr || !(*moversPtr)) return; + + auto& movers = **moversPtr; + if (movers.empty()) return; + + const float dt = (float)context.GetDouble("frame.delta_time", 0.016); + if (dt <= 0.f) return; + + // Player position: try q3.player_pos first, fall back to camera.state + glm::vec3 playerPos(0.f); + if (const auto* pp = context.TryGet("q3.player_pos")) { + playerPos = *pp; + } else { + const auto camState = context.Get("camera.state", nlohmann::json::object()); + if (camState.contains("position") && camState["position"].is_array()) { + const auto& cp = camState["position"]; + if (cp.size() >= 3) + playerPos = {cp[0].get(), cp[1].get(), cp[2].get()}; + } + } + + glm::vec3 velocityPush(0.f); + + for (auto& m : movers) { + using State = sdl3cpp::q3::Q3Mover::State; + + const glm::vec3 prevPos = m.currentPos; + + switch (m.state) { + case State::AtPos1: { + // Trigger: player within 2.5 units + const glm::vec3 diff = playerPos - m.pos1; + const float d2 = diff.x*diff.x + diff.y*diff.y + diff.z*diff.z; + if (d2 < 2.5f * 2.5f) { + m.state = State::MovingTo2; + m.stateProgress = 0.f; + } + m.currentPos = m.pos1; + break; + } + case State::MovingTo2: { + m.stateProgress += dt / std::max(m.travelTime, 0.001f); + m.stateProgress = std::min(m.stateProgress, 1.f); + m.currentPos = glm::mix(m.pos1, m.pos2, m.stateProgress); + if (m.stateProgress >= 1.f) { + m.state = State::AtPos2; + m.stateTimer = m.waitTime; + } + break; + } + case State::AtPos2: { + m.stateTimer -= dt; + m.currentPos = m.pos2; + if (m.stateTimer <= 0.f) { + m.state = State::MovingTo1; + m.stateProgress = 1.f; + } + break; + } + case State::MovingTo1: { + m.stateProgress -= dt / std::max(m.travelTime, 0.001f); + m.stateProgress = std::max(m.stateProgress, 0.f); + m.currentPos = glm::mix(m.pos1, m.pos2, m.stateProgress); + if (m.stateProgress <= 0.f) { + m.state = State::AtPos1; + m.currentPos = m.pos1; + } + break; + } + } + + // Compute velocity for this frame + m.velocity = (m.currentPos - prevPos) / dt; + + // Push player if they are very close (within 1.5 units) and mover is moving + const bool isMoving = (m.state == State::MovingTo2 || m.state == State::MovingTo1); + if (isMoving) { + const glm::vec3 diff = playerPos - m.currentPos; + const float d2 = diff.x*diff.x + diff.y*diff.y + diff.z*diff.z; + if (d2 < 1.5f * 1.5f) { + velocityPush += m.velocity; + } + } + } + + // Write push back (additive with any existing push) + const glm::vec3 existing = context.Get("q3.player_velocity_push", glm::vec3(0.f)); + context.Set("q3.player_velocity_push", existing + velocityPush); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_nav_build_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_nav_build_step.cpp new file mode 100644 index 000000000..9e1a17a5c --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_nav_build_step.cpp @@ -0,0 +1,130 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_nav_build_step.hpp" +#include "services/interfaces/workflow/quake3/q3_nav_types.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowQ3NavBuildStep::WorkflowQ3NavBuildStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3NavBuildStep::GetPluginId() const { return "q3.nav.build"; } + +void WorkflowQ3NavBuildStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + if (context.GetBool("q3.nav_built", false)) return; + + auto* world = context.Get("physics_world", nullptr); + if (!world) { + if (logger_) logger_->Warn("q3.nav.build: no physics_world — skipping nav graph"); + context.Set("q3.nav_built", true); + return; + } + + // ---------------------------------------------------------------- + // Determine AABB from spawn points or use a default box + // ---------------------------------------------------------------- + glm::vec3 aabbMin(-50.f, -10.f, -50.f); + glm::vec3 aabbMax( 50.f, 50.f, 50.f); + + const auto* spawnPts = context.TryGet("bsp.spawn_points"); + if (!spawnPts) spawnPts = context.TryGet("bsp.entities"); + + if (spawnPts && spawnPts->is_array() && !spawnPts->empty()) { + glm::vec3 mn( 1e9f), mx(-1e9f); + bool any = false; + for (const auto& sp : *spawnPts) { + // Accept either {pos:[x,y,z]} or direct [x,y,z] arrays + const nlohmann::json* posJ = nullptr; + if (sp.is_array() && sp.size() >= 3) posJ = &sp; + else if (sp.contains("position") && sp["position"].is_array()) posJ = &sp["position"]; + else if (sp.contains("pos") && sp["pos"].is_array()) posJ = &sp["pos"]; + if (!posJ) continue; + + glm::vec3 p((*posJ)[0].get(), (*posJ)[1].get(), (*posJ)[2].get()); + mn = glm::min(mn, p); + mx = glm::max(mx, p); + any = true; + } + if (any) { + // Expand by 20 units to cover the full playable area around spawns + constexpr float kPad = 20.f; + aabbMin = mn - glm::vec3(kPad, 5.f, kPad); + aabbMax = mx + glm::vec3(kPad, 5.f, kPad); + } + } + + // ---------------------------------------------------------------- + // Grid sample + // ---------------------------------------------------------------- + constexpr float kStep = 2.0f; // grid spacing in XZ + constexpr float kRayUp = 50.f; // start ray this far above grid point + constexpr float kRayDown = 50.f; // ray extends this far below grid point + constexpr float kMinNormalY = 0.7f; // walkable surface normal threshold + constexpr float kAgentHeight = 0.5f; // lift node above hit point + + auto graph = std::make_shared(); + + for (float x = aabbMin.x; x <= aabbMax.x; x += kStep) { + for (float z = aabbMin.z; z <= aabbMax.z; z += kStep) { + const float midY = (aabbMin.y + aabbMax.y) * 0.5f; + const btVector3 from(x, midY + kRayUp, z); + const btVector3 to (x, midY - kRayDown, z); + + btCollisionWorld::ClosestRayResultCallback cb(from, to); + world->rayTest(from, to, cb); + + if (!cb.hasHit()) continue; + if (cb.m_hitNormalWorld.y() < kMinNormalY) continue; + + sdl3cpp::q3::NavNode node; + node.pos = glm::vec3( + cb.m_hitPointWorld.x(), + cb.m_hitPointWorld.y() + kAgentHeight, + cb.m_hitPointWorld.z() + ); + graph->nodes.push_back(node); + } + } + + // ---------------------------------------------------------------- + // Connect neighbors (within 3.0 units, clear line of sight) + // ---------------------------------------------------------------- + constexpr float kNeighborDist = 3.0f; + constexpr float kNeighborDist2 = kNeighborDist * kNeighborDist; + + const int n = (int)graph->nodes.size(); + for (int i = 0; i < n; ++i) { + const glm::vec3& pi = graph->nodes[i].pos; + for (int j = i + 1; j < n; ++j) { + const glm::vec3& pj = graph->nodes[j].pos; + const glm::vec3 d = pj - pi; + const float dist2 = d.x*d.x + d.y*d.y + d.z*d.z; + if (dist2 > kNeighborDist2) continue; + + // LOS check + btVector3 from(pi.x, pi.y, pi.z); + btVector3 to (pj.x, pj.y, pj.z); + btCollisionWorld::ClosestRayResultCallback cb(from, to); + world->rayTest(from, to, cb); + + if (!cb.hasHit()) { + graph->nodes[i].neighbors.push_back(j); + graph->nodes[j].neighbors.push_back(i); + } + } + } + + context.Set("q3.nav_graph", graph); + context.Set("q3.nav_built", true); + + if (logger_) + logger_->Info("q3.nav.build: built " + std::to_string(n) + " nav nodes"); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_pickups_respawn_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pickups_respawn_step.cpp new file mode 100644 index 000000000..3d923a2ed --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pickups_respawn_step.cpp @@ -0,0 +1,40 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_pickups_respawn_step.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowQ3PickupsRespawnStep::WorkflowQ3PickupsRespawnStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3PickupsRespawnStep::GetPluginId() const { return "q3.pickups.respawn"; } + +void WorkflowQ3PickupsRespawnStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + const auto* respawnTimesPtr = context.TryGet("q3.pickup_respawn_times"); + if (!respawnTimesPtr || !respawnTimesPtr->is_object() || respawnTimesPtr->empty()) return; + + const double elapsed = context.GetDouble("frame.elapsed", 0.0); + auto collected = context.Get("q3.collected", nlohmann::json::object()); + + std::vector toRespawn; + for (auto it = respawnTimesPtr->begin(); it != respawnTimesPtr->end(); ++it) { + const double respawnAt = it.value().get(); + if (elapsed > respawnAt) { + toRespawn.push_back(it.key()); + } + } + + if (toRespawn.empty()) return; + + for (const auto& id : toRespawn) { + collected[id] = false; + if (logger_) logger_->Info("q3.pickups.respawn: respawned pickup id=" + id); + } + + context.Set("q3.collected", collected); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_pickups_touch_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pickups_touch_step.cpp new file mode 100644 index 000000000..07c9baaaa --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pickups_touch_step.cpp @@ -0,0 +1,176 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_pickups_touch_step.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +namespace { + +constexpr float kTouchRadius = 1.0f; + +// Respawn times (seconds) matching ioq3 defaults +constexpr double kRespawnArmor = 25.0; +constexpr double kRespawnHealth = 35.0; +constexpr double kRespawnAmmo = 40.0; +constexpr double kRespawnWeapon = 30.0; + +bool HasPrefix(const std::string& s, const std::string& prefix) { + return s.rfind(prefix, 0) == 0; +} + +// Parse "x y z" origin string into glm::vec3; returns false on failure. +bool ParseOrigin(const std::string& origin, glm::vec3& out) { + std::istringstream ss(origin); + float x = 0.0f, y = 0.0f, z = 0.0f; + if (!(ss >> x >> y >> z)) return false; + out = glm::vec3(x, y, z); + return true; +} + +// ioq3 default ammo per ammo_* classname +int DefaultAmmoAmount(const std::string& cls) { + if (cls == "ammo_bullets") return 50; + if (cls == "ammo_shells") return 10; + if (cls == "ammo_grenades") return 5; + if (cls == "ammo_rockets") return 5; + if (cls == "ammo_cells") return 30; + if (cls == "ammo_lightning") return 60; + if (cls == "ammo_slugs") return 10; + if (cls == "ammo_bfg") return 15; + return 10; +} + +// Map ammo_* classname to the weapon key used in q3.player_ammo +std::string AmmoWeaponKey(const std::string& cls) { + if (cls == "ammo_bullets") return "weapon_machinegun"; + if (cls == "ammo_shells") return "weapon_shotgun"; + if (cls == "ammo_grenades") return "weapon_grenadelauncher"; + if (cls == "ammo_rockets") return "weapon_rocketlauncher"; + if (cls == "ammo_cells") return "weapon_plasmagun"; + if (cls == "ammo_lightning") return "weapon_lightning"; + if (cls == "ammo_slugs") return "weapon_railgun"; + if (cls == "ammo_bfg") return "weapon_bfg"; + return cls; // fallback: use classname as key +} + +// Default ammo granted when picking up a weapon_* entity +int DefaultWeaponAmmo(const std::string& cls) { + if (cls == "weapon_machinegun") return 100; + if (cls == "weapon_shotgun") return 10; + if (cls == "weapon_grenadelauncher")return 5; + if (cls == "weapon_rocketlauncher") return 5; + if (cls == "weapon_lightning") return 60; + if (cls == "weapon_railgun") return 10; + if (cls == "weapon_plasmagun") return 50; + if (cls == "weapon_bfg") return 15; + return 10; +} + +} // namespace + +WorkflowQ3PickupsTouchStep::WorkflowQ3PickupsTouchStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3PickupsTouchStep::GetPluginId() const { return "q3.pickups.touch"; } + +void WorkflowQ3PickupsTouchStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + if (context.GetBool("q3.player_dead", false)) return; + + const auto* entitiesPtr = context.TryGet("bsp.entities"); + if (!entitiesPtr || !entitiesPtr->is_array()) return; + + const glm::vec3 playerPos = context.Get("q3.player_pos", glm::vec3(0.0f)); + int health = context.GetInt("q3.player_health", 100); + int armor = context.GetInt("q3.player_armor", 0); + std::string armorType = context.GetString("q3.armor_type", "none"); + const double elapsed = context.GetDouble("frame.elapsed", 0.0); + + auto ammo = context.Get("q3.player_ammo", nlohmann::json::object()); + auto inventory = context.Get("q3.inventory", nlohmann::json::object()); + auto collected = context.Get("q3.collected", nlohmann::json::object()); + auto respawnTimes = context.Get("q3.pickup_respawn_times", nlohmann::json::object()); + + for (const auto& ent : *entitiesPtr) { + const std::string cls = ent.value("classname", std::string{}); + if (cls.empty()) continue; + + // Only consider pickups + const bool isHealth = cls.find("item_health") != std::string::npos; + const bool isArmor = cls.find("item_armor") != std::string::npos; + const bool isAmmo = HasPrefix(cls, "ammo_"); + const bool isWeapon = HasPrefix(cls, "weapon_"); + if (!isHealth && !isArmor && !isAmmo && !isWeapon) continue; + + // Entity ID for collection tracking + const std::string id = ent.value("id", std::string{}); + if (id.empty()) continue; + if (collected.value(id, false)) continue; + + // Parse entity position from "origin" field ("x y z") + glm::vec3 entPos(0.0f); + if (ent.contains("origin") && ent["origin"].is_string()) { + if (!ParseOrigin(ent["origin"].get(), entPos)) continue; + } else if (ent.contains("position") && ent["position"].is_array()) { + const auto& p = ent["position"]; + if (p.size() == 3) { + entPos = glm::vec3(p[0].get(), p[1].get(), p[2].get()); + } + } else { + continue; + } + + if (glm::distance(playerPos, entPos) >= kTouchRadius) continue; + + // Apply pickup effect + double respawnDelay = kRespawnHealth; + + if (isHealth) { + respawnDelay = kRespawnHealth; + if (cls == "item_health") health = std::min(health + 25, 100); + else if (cls == "item_health_large") health = std::min(health + 50, 100); + else if (cls == "item_health_mega") health = std::min(health + 100, 200); + else health = std::min(health + 25, 100); + } else if (isArmor) { + respawnDelay = kRespawnArmor; + if (cls == "item_armor_shard") { + armor = std::min(armor + 5, 200); + } else if (cls == "item_armor_combat") { + armor = std::min(armor + 50, 200); + armorType = "green"; + } else if (cls == "item_armor_body") { + armor = std::min(armor + 100, 200); + armorType = "yellow"; + } + } else if (isAmmo) { + respawnDelay = kRespawnAmmo; + const std::string weaponKey = AmmoWeaponKey(cls); + const int current = ammo.value(weaponKey, 0); + ammo[weaponKey] = current + DefaultAmmoAmount(cls); + } else if (isWeapon) { + respawnDelay = kRespawnWeapon; + inventory[cls] = true; + const int current = ammo.value(cls, 0); + ammo[cls] = current + DefaultWeaponAmmo(cls); + } + + collected[id] = true; + respawnTimes[id] = elapsed + respawnDelay; + + if (logger_) logger_->Info("q3.pickups.touch: collected " + cls + " (id=" + id + ")"); + } + + context.Set("q3.player_health", health); + context.Set("q3.player_armor", armor); + context.Set("q3.armor_type", armorType); + context.Set("q3.player_ammo", ammo); + context.Set("q3.inventory", inventory); + context.Set("q3.collected", collected); + context.Set("q3.pickup_respawn_times", respawnTimes); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_player_death_check_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_player_death_check_step.cpp new file mode 100644 index 000000000..6dedd4fd7 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_player_death_check_step.cpp @@ -0,0 +1,33 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_player_death_check_step.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include + +namespace sdl3cpp::services::impl { + +WorkflowQ3PlayerDeathCheckStep::WorkflowQ3PlayerDeathCheckStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3PlayerDeathCheckStep::GetPluginId() const { return "q3.player.death_check"; } + +void WorkflowQ3PlayerDeathCheckStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + const int health = context.GetInt("q3.player_health", 100); + const bool isDead = context.GetBool("q3.player_dead", false); + + // Only trigger on the first frame health drops to/below zero + if (health > 0 || isDead) return; + + const double elapsed = context.GetDouble("frame.elapsed", 0.0); + const int deathCount = context.GetInt("q3.death_count", 0); + + context.Set("q3.player_dead", true); + context.Set("q3.death_time", elapsed); + context.Set("q3.death_count", deathCount + 1); + + if (logger_) { + logger_->Info("q3.player.death_check: player died (count=" + + std::to_string(deathCount + 1) + ")"); + } +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_player_respawn_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_player_respawn_step.cpp new file mode 100644 index 000000000..26845b73d --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_player_respawn_step.cpp @@ -0,0 +1,57 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_player_respawn_step.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +static constexpr double kRespawnDelay = 1.7; + +WorkflowQ3PlayerRespawnStep::WorkflowQ3PlayerRespawnStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3PlayerRespawnStep::GetPluginId() const { return "q3.player.respawn"; } + +void WorkflowQ3PlayerRespawnStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + if (!context.GetBool("q3.player_dead", false)) return; + + const double elapsed = context.GetDouble("frame.elapsed", 0.0); + const double deathTime = context.GetDouble("q3.death_time", 0.0); + if ((elapsed - deathTime) <= kRespawnDelay) return; + + // Pick a random spawn point + glm::vec3 spawnPos(0.0f, 2.0f, 0.0f); + const auto* spawnPoints = context.TryGet("bsp.spawn_points"); + if (spawnPoints && spawnPoints->is_array() && !spawnPoints->empty()) { + const int idx = std::rand() % static_cast(spawnPoints->size()); + const auto& sp = (*spawnPoints)[idx]; + if (sp.is_array() && sp.size() == 3) { + spawnPos = glm::vec3(sp[0].get(), sp[1].get(), sp[2].get()); + } else if (sp.is_object()) { + spawnPos = glm::vec3( + sp.value("x", 0.0f), + sp.value("y", 2.0f), + sp.value("z", 0.0f)); + } + } + + context.Set("q3.player_pos", spawnPos); + context.Set("q3.player_health", 100); + context.Set("q3.player_armor", 0); + context.Set("q3.armor_type", std::string("none")); + context.Set("q3.player_dead", false); + // Trigger ammo re-init on next frame + context.Set("q3.ammo_initialized", false); + + if (logger_) { + logger_->Info("q3.player.respawn: respawned at (" + + std::to_string(spawnPos.x) + ", " + + std::to_string(spawnPos.y) + ", " + + std::to_string(spawnPos.z) + ")"); + } +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_accelerate_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_accelerate_step.cpp new file mode 100644 index 000000000..9717cec7d --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_accelerate_step.cpp @@ -0,0 +1,74 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_pm_accelerate_step.hpp" +#include "services/interfaces/workflow/quake3/q3_pm_types.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +static constexpr float kMaxSpeedGround = 7.5f; // scaled Q3 ground max speed +static constexpr float kMaxSpeedAir = 6.0f; // scaled Q3 air max speed +static constexpr float kAccelGround = 50.0f; // ground acceleration +static constexpr float kAccelAir = 2.0f; // air acceleration + +WorkflowQ3PmAccelerateStep::WorkflowQ3PmAccelerateStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3PmAccelerateStep::GetPluginId() const { return "q3.pm.accelerate"; } + +void WorkflowQ3PmAccelerateStep::Execute( + const WorkflowStepDefinition& /*step*/, WorkflowContext& context) +{ + auto* psPtr = context.TryGet("q3.ps"); + if (!psPtr) return; + + Q3PlayerState ps = *psPtr; + + const float dt = static_cast(context.GetDouble("frame.delta_time", 0.016)); + const float moveForward = context.Get("input.move_forward", 0.f); + const float moveRight = context.Get("input.move_right", 0.f); + const float yaw = context.Get("q3.player_yaw", 0.f); + + // Build wish direction in XZ plane, rotated by player yaw + // yaw is the angle from +Z toward +X (standard Q3 / OpenGL convention) + const float sinY = std::sin(yaw); + const float cosY = std::cos(yaw); + + // Forward vector: -sinY, 0, -cosY (same convention as fps_move_step) + // Right vector: cosY, 0, -sinY + glm::vec3 wishDir{ + -sinY * moveForward + cosY * moveRight, + 0.f, + -cosY * moveForward - sinY * moveRight + }; + + const float wishLen = std::sqrt(wishDir.x * wishDir.x + wishDir.z * wishDir.z); + if (wishLen < 0.001f) { + context.Set("q3.ps", ps); + return; + } + + const float maxSpeed = ps.onGround ? kMaxSpeedGround : kMaxSpeedAir; + const float wishSpeed = wishLen * maxSpeed; + wishDir /= wishLen; // normalize + + const float accel = ps.onGround ? kAccelGround : kAccelAir; + const float currentSpeed = ps.velocity.x * wishDir.x + ps.velocity.z * wishDir.z; + const float addSpeed = wishSpeed - currentSpeed; + + if (addSpeed <= 0.f) { + context.Set("q3.ps", ps); + return; + } + + const float accelSpeed = std::min(addSpeed, accel * wishSpeed * dt); + ps.velocity.x += accelSpeed * wishDir.x; + ps.velocity.z += accelSpeed * wishDir.z; + + context.Set("q3.ps", ps); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_crouch_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_crouch_step.cpp new file mode 100644 index 000000000..8d8f83aad --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_crouch_step.cpp @@ -0,0 +1,58 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_pm_crouch_step.hpp" +#include "services/interfaces/workflow/quake3/q3_pm_types.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include + +namespace sdl3cpp::services::impl { + +static constexpr float kStandMaxsY = 1.4f; +static constexpr float kCrouchMaxsY = 0.85f; + +WorkflowQ3PmCrouchStep::WorkflowQ3PmCrouchStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3PmCrouchStep::GetPluginId() const { return "q3.pm.crouch"; } + +void WorkflowQ3PmCrouchStep::Execute( + const WorkflowStepDefinition& /*step*/, WorkflowContext& context) +{ + auto* psPtr = context.TryGet("q3.ps"); + if (!psPtr) return; + + Q3PlayerState ps = *psPtr; + + const bool crouchPressed = context.GetBool("input.crouch", false); + auto* world = context.Get("physics_world", nullptr); + + if (crouchPressed && !ps.crouching) { + // Transition: standing → crouching + ps.crouching = true; + ps.maxs.y = kCrouchMaxsY; + } else if (!crouchPressed && ps.crouching) { + // Transition: crouching → standing — only if there is room above + bool canStand = true; + if (world) { + // Trace upward by the height difference to check for headroom + const float rise = kStandMaxsY - kCrouchMaxsY; + const glm::vec3 traceEnd = ps.origin + glm::vec3(0.f, rise, 0.f); + Q3Trace tr = TraceBox(world, ps.origin, traceEnd, ps.mins, ps.maxs); + canStand = (tr.fraction >= 1.f); + } + if (canStand) { + ps.crouching = false; + ps.maxs.y = kStandMaxsY; + } + } + + context.Set("q3.ps", ps); + + // Backward-compat writes for HUD / bots + context.Set("q3.player_pos", ps.origin); + context.Set("q3.player_yaw", context.Get("q3.player_yaw", 0.f)); + context.Set("q3.player_pitch", context.Get("q3.player_pitch", 0.f)); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_friction_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_friction_step.cpp new file mode 100644 index 000000000..26a7b5c36 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_friction_step.cpp @@ -0,0 +1,55 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_pm_friction_step.hpp" +#include "services/interfaces/workflow/quake3/q3_pm_types.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +static constexpr float kStopSpeed = 1.5f; // pm_stopspeed (scaled) +static constexpr float kFriction = 8.0f; // pm_friction (scaled) + +WorkflowQ3PmFrictionStep::WorkflowQ3PmFrictionStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3PmFrictionStep::GetPluginId() const { return "q3.pm.friction"; } + +void WorkflowQ3PmFrictionStep::Execute( + const WorkflowStepDefinition& /*step*/, WorkflowContext& context) +{ + auto* psPtr = context.TryGet("q3.ps"); + if (!psPtr) return; + + Q3PlayerState ps = *psPtr; + + if (!ps.onGround) { + context.Set("q3.ps", ps); + return; + } + + const float dt = static_cast(context.GetDouble("frame.delta_time", 0.016)); + + const float speed = std::sqrt(ps.velocity.x * ps.velocity.x + + ps.velocity.z * ps.velocity.z); + if (speed < 0.1f) { + ps.velocity.x = 0.f; + ps.velocity.z = 0.f; + context.Set("q3.ps", ps); + return; + } + + const float control = std::max(speed, kStopSpeed); + const float drop = control * kFriction * dt; + const float newSpeed = std::max(0.f, speed - drop); + + const float scale = newSpeed / speed; + ps.velocity.x *= scale; + ps.velocity.z *= scale; + + context.Set("q3.ps", ps); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_ground_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_ground_step.cpp new file mode 100644 index 000000000..2acc29c71 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_ground_step.cpp @@ -0,0 +1,59 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_pm_ground_step.hpp" +#include "services/interfaces/workflow/quake3/q3_pm_types.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include + +namespace sdl3cpp::services::impl { + +// Gravity in world units/s² at our Q3 scale (800 q3units * 0.03125 ≈ 25, using 20 as spec'd) +static constexpr float kGravity = 20.f; +// Walkable slope: normal.y must exceed this (cos 45°≈0.7) +static constexpr float kMinGroundNormalY = 0.7f; +// Distance to probe below origin for ground detection +static constexpr float kGroundProbe = 0.05f; + +WorkflowQ3PmGroundStep::WorkflowQ3PmGroundStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3PmGroundStep::GetPluginId() const { return "q3.pm.ground"; } + +void WorkflowQ3PmGroundStep::Execute( + const WorkflowStepDefinition& /*step*/, WorkflowContext& context) +{ + auto* psPtr = context.TryGet("q3.ps"); + if (!psPtr) return; + + Q3PlayerState ps = *psPtr; + const float dt = static_cast(context.GetDouble("frame.delta_time", 0.016)); + auto* world = context.Get("physics_world", nullptr); + + // ── Ground detection: trace down kGroundProbe units ────────────────────── + bool grounded = false; + if (world) { + const glm::vec3 traceEnd = ps.origin - glm::vec3(0.f, kGroundProbe, 0.f); + Q3Trace tr = TraceBox(world, ps.origin, traceEnd, ps.mins, ps.maxs); + if (tr.hit && tr.normal.y >= kMinGroundNormalY) { + grounded = true; + ps.onGround = true; + // Snap origin to ground surface + ps.origin = tr.endPos; + // Kill any downward velocity when landing + if (ps.velocity.y < 0.f) ps.velocity.y = 0.f; + } + } + + if (!grounded) { + ps.onGround = false; + // Apply gravity + ps.velocity.y -= kGravity * dt; + } + + ps.groundFraction = grounded ? 1.f : 0.f; + + context.Set("q3.ps", ps); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_jump_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_jump_step.cpp new file mode 100644 index 000000000..563ad4cc9 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_jump_step.cpp @@ -0,0 +1,39 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_pm_jump_step.hpp" +#include "services/interfaces/workflow/quake3/q3_pm_types.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +// 270 q3units/s * 0.03125 world/q3unit = 8.4375 ≈ 8.5 +static constexpr float kJumpVelocity = 8.5f; + +WorkflowQ3PmJumpStep::WorkflowQ3PmJumpStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3PmJumpStep::GetPluginId() const { return "q3.pm.jump"; } + +void WorkflowQ3PmJumpStep::Execute( + const WorkflowStepDefinition& /*step*/, WorkflowContext& context) +{ + auto* psPtr = context.TryGet("q3.ps"); + if (!psPtr) return; + + Q3PlayerState ps = *psPtr; + + const bool jumpPressed = context.GetBool("input.jump", false); + const bool jumpHeld = context.GetBool("q3.pm_jump_held", false); + + if (jumpPressed && !jumpHeld && ps.onGround) { + ps.velocity.y = kJumpVelocity; + ps.onGround = false; + } + + // Track held state to prevent auto-bunnyhop + context.Set("q3.pm_jump_held", jumpPressed); + context.Set("q3.ps", ps); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_slide_move_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_slide_move_step.cpp new file mode 100644 index 000000000..758690922 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_pm_slide_move_step.cpp @@ -0,0 +1,114 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_pm_slide_move_step.hpp" +#include "services/interfaces/workflow/quake3/q3_pm_types.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +// ───────────────────────────────────────────────────────────────────────────── +// ClipVelocity +// v = v - dot(v, n) * overbounce * n +// overbounce > 1 prevents the player from sticking to surfaces. +// ───────────────────────────────────────────────────────────────────────────── +static glm::vec3 ClipVelocity(const glm::vec3& v, const glm::vec3& normal, float overbounce) +{ + const float backoff = glm::dot(v, normal) * overbounce; + return v - normal * backoff; +} + +WorkflowQ3PmSlideMoveStep::WorkflowQ3PmSlideMoveStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3PmSlideMoveStep::GetPluginId() const { return "q3.pm.slide_move"; } + +void WorkflowQ3PmSlideMoveStep::Execute( + const WorkflowStepDefinition& /*step*/, WorkflowContext& context) +{ + auto* psPtr = context.TryGet("q3.ps"); + if (!psPtr) return; + + Q3PlayerState ps = *psPtr; + auto* world = context.Get("physics_world", nullptr); + const float dt = static_cast(context.GetDouble("frame.delta_time", 0.016)); + + if (!world) { + // No physics world: integrate velocity directly + ps.origin += ps.velocity * dt; + context.Set("q3.ps", ps); + context.Set("q3.player_pos", ps.origin); + return; + } + + // ── Slide-move loop ─────────────────────────────────────────────────────── + static constexpr int kMaxBumps = 4; + static constexpr float kOverbounce = 1.001f; + static constexpr float kMinFraction = 0.001f; + + float timeLeft = dt; + int bumpCount = 0; + + // Accumulate hit normals to handle crease collisions + std::array planes{}; + int numPlanes = 0; + + for (bumpCount = 0; bumpCount < kMaxBumps; ++bumpCount) { + if (timeLeft <= 0.f) break; + + const glm::vec3 target = ps.origin + ps.velocity * timeLeft; + Q3Trace tr = TraceBox(world, ps.origin, target, ps.mins, ps.maxs); + + // Advance as far as the trace allows + if (tr.fraction > kMinFraction) { + ps.origin = tr.endPos; + timeLeft *= (1.f - tr.fraction); + } + + // No collision this bump: we moved the full remaining distance + if (tr.fraction >= 1.f || !tr.hit) break; + + // Record this normal + if (numPlanes < kMaxBumps) { + planes[numPlanes++] = tr.normal; + } + + // Clip velocity against the surface we hit + ps.velocity = ClipVelocity(ps.velocity, tr.normal, kOverbounce); + + // If the new velocity re-enters any previously stored plane, + // project along the crease formed by the two planes. + for (int p = 0; p < numPlanes - 1; ++p) { + if (glm::dot(ps.velocity, planes[p]) < 0.f) { + const glm::vec3 crease = glm::normalize(glm::cross(tr.normal, planes[p])); + const float proj = glm::dot(ps.velocity, crease); + ps.velocity = crease * proj; + break; + } + } + + // If we're now moving into a plane we hit earlier too, stop entirely + // to avoid tunnelling between two walls. + bool stoppedByCrease = false; + for (int p = 0; p < numPlanes; ++p) { + if (glm::dot(ps.velocity, planes[p]) < 0.f) { + ps.velocity = glm::vec3(0.f); + stoppedByCrease = true; + break; + } + } + if (stoppedByCrease) break; + } + + context.Set("q3.ps", ps); + context.Set("q3.player_pos", ps.origin); + // Yaw is managed by the camera step; just re-publish what is already set. + // (No-op write to keep the key alive for any downstream reader.) + context.Set("q3.player_yaw", context.Get("q3.player_yaw", 0.f)); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_triggers_apply_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_triggers_apply_step.cpp new file mode 100644 index 000000000..b82ea8233 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_triggers_apply_step.cpp @@ -0,0 +1,69 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_triggers_apply_step.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowQ3TriggersApplyStep::WorkflowQ3TriggersApplyStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3TriggersApplyStep::GetPluginId() const { return "q3.triggers.apply"; } + +void WorkflowQ3TriggersApplyStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + bool didSomething = false; + + // ---------------------------------------------------------------- + // Apply velocity override (jump pad launch) + // ---------------------------------------------------------------- + if (const auto* velOverride = context.TryGet("q3.player_velocity_override")) { + const glm::vec3 vel = *velOverride; + + // Update q3.ps if present + auto* psPtr = context.TryGet("q3.ps"); + if (psPtr) { + nlohmann::json ps = *psPtr; + ps["velocity"] = nlohmann::json::array({vel.x, vel.y, vel.z}); + context.Set("q3.ps", ps); + } + + // Update player_pos is NOT changed by velocity override — the physics + // step will integrate it next frame. Just propagate the velocity push. + context.Set("q3.player_velocity_push", + context.Get("q3.player_velocity_push", glm::vec3(0.f)) + vel); + + context.Remove("q3.player_velocity_override"); + context.Remove("q3.player_on_jump_pad"); + didSomething = true; + } + + // ---------------------------------------------------------------- + // Apply teleport + // ---------------------------------------------------------------- + if (const auto* dest = context.TryGet("q3.player_teleport_dest")) { + const glm::vec3 destPos = *dest; + + context.Set("q3.player_pos", destPos); + + auto* psPtr = context.TryGet("q3.ps"); + if (psPtr) { + nlohmann::json ps = *psPtr; + ps["origin"] = nlohmann::json::array({destPos.x, destPos.y, destPos.z}); + ps["velocity"] = nlohmann::json::array({0.f, 0.f, 0.f}); + context.Set("q3.ps", ps); + } + + // Also update camera.state origin so the renderer follows immediately + auto camState = context.Get("camera.state", nlohmann::json::object()); + camState["position"] = nlohmann::json::array({destPos.x, destPos.y, destPos.z}); + context.Set("camera.state", camState); + + context.Remove("q3.player_teleport_dest"); + didSomething = true; + } + + (void)didSomething; +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_triggers_check_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_triggers_check_step.cpp new file mode 100644 index 000000000..9523a3264 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_triggers_check_step.cpp @@ -0,0 +1,159 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_triggers_check_step.hpp" +#include "services/interfaces/workflow_context.hpp" + +#include +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +namespace { + +// Parsed representation of a trigger entity +struct TriggerEnt { + std::string classname; + glm::vec3 origin{}; + float radius{1.5f}; // overlap distance + std::string target; // target entity name (for jump pad / teleport) + float dmg{5.f}; // for trigger_hurt +}; + +// Parse "x y z" string into glm::vec3 with optional Q3 unit scale +glm::vec3 ParseOrigin(const std::string& s, float scale = 0.03125f) { + float x = 0.f, y = 0.f, z = 0.f; + std::sscanf(s.c_str(), "%f %f %f", &x, &y, &z); + return glm::vec3(x * scale, y * scale, z * scale); +} + +} // namespace + +WorkflowQ3TriggersCheckStep::WorkflowQ3TriggersCheckStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3TriggersCheckStep::GetPluginId() const { return "q3.triggers.check"; } + +void WorkflowQ3TriggersCheckStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + if (context.GetBool("q3.player_dead", false)) return; + + // ---------------------------------------------------------------- + // One-time parse of trigger entities + // ---------------------------------------------------------------- + if (!context.GetBool("q3.triggers_loaded", false)) { + const auto* entities = context.TryGet("bsp.entities"); + if (!entities || !entities->is_array()) { + context.Set("q3.triggers_loaded", true); + return; + } + + // Build index: targetname → origin (for destination lookups) + // Store as shared nlohmann::json in context + nlohmann::json triggerList = nlohmann::json::array(); + nlohmann::json destIndex = nlohmann::json::object(); // targetname → [x,y,z] + + for (const auto& ent : *entities) { + const std::string cls = ent.value("classname", std::string{}); + + // Record destination entities + if (cls == "target_position" || cls == "misc_teleporter_dest") { + const std::string tname = ent.value("targetname", std::string{}); + if (!tname.empty() && ent.contains("origin") && ent["origin"].is_string()) { + glm::vec3 o = ParseOrigin(ent["origin"].get()); + destIndex[tname] = nlohmann::json::array({o.x, o.y, o.z}); + } + } + + if (cls != "trigger_push" && cls != "trigger_teleport" && cls != "trigger_hurt") continue; + + glm::vec3 origin(0.f); + if (ent.contains("origin") && ent["origin"].is_string()) + origin = ParseOrigin(ent["origin"].get()); + + nlohmann::json t; + t["classname"] = cls; + t["origin"] = nlohmann::json::array({origin.x, origin.y, origin.z}); + t["target"] = ent.value("target", std::string{}); + t["dmg"] = ent.value("dmg", 5.f); + + triggerList.push_back(t); + } + + context.Set("q3.trigger_list", triggerList); + context.Set("q3.trigger_dest_index", destIndex); + context.Set("q3.triggers_loaded", true); + + if (logger_) + logger_->Info("q3.triggers.check: parsed " + std::to_string(triggerList.size()) + " triggers"); + } + + // ---------------------------------------------------------------- + // Per-frame overlap checks + // ---------------------------------------------------------------- + const auto* triggerListPtr = context.TryGet("q3.trigger_list"); + if (!triggerListPtr || !triggerListPtr->is_array()) return; + + const auto* destIdxPtr = context.TryGet("q3.trigger_dest_index"); + + glm::vec3 playerPos(0.f); + if (const auto* pp = context.TryGet("q3.player_pos")) { + playerPos = *pp; + } else { + const auto camState = context.Get("camera.state", nlohmann::json::object()); + if (camState.contains("position") && camState["position"].is_array()) { + const auto& cp = camState["position"]; + if (cp.size() >= 3) + playerPos = {cp[0].get(), cp[1].get(), cp[2].get()}; + } + } + + constexpr float kOverlapDist = 1.5f; + + for (const auto& t : *triggerListPtr) { + const auto& oj = t["origin"]; + const glm::vec3 tOrigin(oj[0].get(), oj[1].get(), oj[2].get()); + + const glm::vec3 diff = playerPos - tOrigin; + const float d2 = diff.x*diff.x + diff.y*diff.y + diff.z*diff.z; + if (d2 > kOverlapDist * kOverlapDist) continue; + + const std::string cls = t["classname"].get(); + + if (cls == "trigger_push") { + // Find target_position from dest index + const std::string tgtName = t.value("target", std::string{}); + if (!tgtName.empty() && destIdxPtr && destIdxPtr->contains(tgtName)) { + const auto& dj = (*destIdxPtr)[tgtName]; + const glm::vec3 targetPos(dj[0].get(), dj[1].get(), dj[2].get()); + + const glm::vec3 toTarget = targetPos - playerPos; + const float height = std::max(toTarget.y, 1.0f); + constexpr float kGravity = 20.f; + const float vy = std::sqrt(2.f * kGravity * height); + const float time = vy / kGravity; + + const glm::vec3 launchVel( + toTarget.x / time, + vy, + toTarget.z / time + ); + + context.Set("q3.player_velocity_override", launchVel); + context.Set("q3.player_on_jump_pad", true); + } + } else if (cls == "trigger_teleport") { + const std::string tgtName = t.value("target", std::string{}); + if (!tgtName.empty() && destIdxPtr && destIdxPtr->contains(tgtName)) { + const auto& dj = (*destIdxPtr)[tgtName]; + const glm::vec3 destPos(dj[0].get(), dj[1].get(), dj[2].get()); + context.Set("q3.player_teleport_dest", destPos); + } + } else if (cls == "trigger_hurt") { + const float dmg = t.value("dmg", 5.f); + const float existing = context.Get("q3.pending_damage", 0.f); + context.Set("q3.pending_damage", existing + dmg); + } + } +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_weapon_fire_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_weapon_fire_step.cpp new file mode 100644 index 000000000..2759bd058 --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_weapon_fire_step.cpp @@ -0,0 +1,248 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_weapon_fire_step.hpp" +#include "services/interfaces/workflow/quake3/q3_missile_types.hpp" + +#include +#include +#include +#include + +#include +#include + +namespace sdl3cpp::services::impl { + +namespace { + +// Fire intervals in frames at 60 fps (ioq3 values). +uint32_t FireInterval(const std::string& weapon) { + if (weapon == "weapon_machinegun") return 6u; + if (weapon == "weapon_shotgun") return 30u; + if (weapon == "weapon_grenadelauncher") return 30u; + if (weapon == "weapon_rocketlauncher") return 20u; + if (weapon == "weapon_lightning") return 1u; + if (weapon == "weapon_railgun") return 50u; + if (weapon == "weapon_plasmagun") return 6u; + if (weapon == "weapon_bfg") return 200u; + return 18u; // gauntlet fallback +} + +// Whether the weapon is held-fire (auto) or single-shot. +bool IsAutoFire(const std::string& weapon) { + return weapon == "weapon_machinegun" || + weapon == "weapon_lightning" || + weapon == "weapon_plasmagun"; +} + +// Instant-hit damage per hit (or per pellet for shotgun). +int InstantHitDamage(const std::string& weapon) { + if (weapon == "weapon_machinegun") return 7; + if (weapon == "weapon_shotgun") return 10; // ×11 pellets + if (weapon == "weapon_lightning") return 8; + if (weapon == "weapon_railgun") return 100; + return 0; +} + +// Build a perpendicular tangent from a direction vector. +glm::vec3 Tangent(const glm::vec3& dir) { + glm::vec3 up = (std::abs(dir.y) < 0.9f) ? glm::vec3(0, 1, 0) : glm::vec3(1, 0, 0); + return glm::normalize(glm::cross(dir, up)); +} + +// Deterministic-ish jitter for shotgun spread (no header needed). +float JitterComponent(int seed) { + // Returns a value in [-1, 1] cheaply. + return static_cast((seed % 200) - 100) / 100.f; +} + +// Perform a single Bullet raycast; returns true on hit and fills hitPoint. +bool Raycast(btDiscreteDynamicsWorld* world, + const btVector3& from, const btVector3& to, + btVector3& hitPoint) { + btCollisionWorld::ClosestRayResultCallback cb(from, to); + world->rayTest(from, to, cb); + if (cb.hasHit()) { + hitPoint = cb.m_hitPointWorld; + return true; + } + return false; +} + +} // namespace + +WorkflowQ3WeaponFireStep::WorkflowQ3WeaponFireStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3WeaponFireStep::GetPluginId() const { + return "q3.weapon.fire"; +} + +void WorkflowQ3WeaponFireStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + const bool fireHeld = context.GetBool("input_mouse_left", false); + const bool firePressed = context.GetBool("input_mouse_left_pressed", false); + const std::string weapon = context.Get("q3.current_weapon", "weapon_machinegun"); + const uint32_t frame = static_cast(context.GetDouble("loop.iteration", 0.0)); + uint32_t lastFire = context.Get("q3.weapon_last_fire_frame", 0u); + const uint32_t interval = FireInterval(weapon); + + // Determine if we want to fire this frame. + const bool wantsFire = firePressed || (fireHeld && IsAutoFire(weapon)); + const bool canFire = lastFire == 0u || frame >= lastFire + interval; + + if (!wantsFire || !canFire) { + return; + } + + // --- Ammo check --- + auto ammo = context.Get("q3.player_ammo", nlohmann::json::object()); + if (weapon != "weapon_gauntlet" && weapon != "weapon_lightning") { + int currentAmmo = ammo.value(weapon, 0); + if (currentAmmo <= 0) { + return; // dry click + } + ammo[weapon] = currentAmmo - 1; + } + + // --- Update fire-timing context --- + const uint32_t fireFrame = (frame == 0u) ? 1u : frame; + context.Set("q3.weapon_last_fire_frame", fireFrame); + context.Set("q3.weapon_flash_until_frame", fireFrame + 4u); + context.Set("q3.last_shot_hit", false); + context.Set("q3.player_ammo", ammo); + + // --- Gather camera info --- + auto cameraState = context.Get("camera.state", nlohmann::json::object()); + auto* world = context.Get("physics_world", nullptr); + + if (!world || !cameraState.contains("pos") || !cameraState.contains("forward")) { + // No physics world or camera info — still record the fire event. + if (logger_) logger_->Info("q3.weapon.fire: fired " + weapon + " (no physics)"); + return; + } + + const auto& posArr = cameraState["pos"]; + const auto& fwdArr = cameraState["forward"]; + const glm::vec3 origin(posArr[0].get(), posArr[1].get(), posArr[2].get()); + const glm::vec3 forward(fwdArr[0].get(), fwdArr[1].get(), fwdArr[2].get()); + const glm::vec3 fwdN = glm::normalize(forward); + + const btVector3 btFrom(origin.x, origin.y, origin.z); + + int pendingDamage = context.Get("q3.pending_damage", 0); + bool hitThisFrame = false; + + // ===================================================================== + // Instant-hit weapons + // ===================================================================== + + if (weapon == "weapon_machinegun") { + const btVector3 btTo = btFrom + btVector3(fwdN.x, fwdN.y, fwdN.z) * 120.f; + btVector3 hp; + if (Raycast(world, btFrom, btTo, hp)) { + pendingDamage += InstantHitDamage(weapon); + hitThisFrame = true; + } + } + else if (weapon == "weapon_shotgun") { + // 11 pellets with random spread up to ±0.04 world units in tangent plane. + const glm::vec3 tan1 = Tangent(fwdN); + const glm::vec3 tan2 = glm::normalize(glm::cross(fwdN, tan1)); + constexpr float kSpread = 0.04f; + constexpr int kPellets = 11; + for (int p = 0; p < kPellets; ++p) { + const float jx = JitterComponent(p * 7 + 3) * kSpread; + const float jy = JitterComponent(p * 13 + 7) * kSpread; + const glm::vec3 pelletDir = glm::normalize(fwdN + tan1 * jx + tan2 * jy); + const btVector3 btTo = btFrom + btVector3(pelletDir.x, pelletDir.y, pelletDir.z) * 120.f; + btVector3 hp; + if (Raycast(world, btFrom, btTo, hp)) { + pendingDamage += InstantHitDamage(weapon); + hitThisFrame = true; + } + } + } + else if (weapon == "weapon_lightning") { + // Short range: 8 world units. + const btVector3 btTo = btFrom + btVector3(fwdN.x, fwdN.y, fwdN.z) * 8.f; + btVector3 hp; + if (Raycast(world, btFrom, btTo, hp)) { + pendingDamage += InstantHitDamage(weapon); + hitThisFrame = true; + } + } + else if (weapon == "weapon_railgun") { + // Piercing: step past each hit up to 4 times. + glm::vec3 rayOrigin = origin; + constexpr int kMaxPierces = 4; + for (int pierce = 0; pierce < kMaxPierces; ++pierce) { + const btVector3 btRayFrom(rayOrigin.x, rayOrigin.y, rayOrigin.z); + const btVector3 btRayTo = btRayFrom + btVector3(fwdN.x, fwdN.y, fwdN.z) * 120.f; + btVector3 hp; + if (!Raycast(world, btRayFrom, btRayTo, hp)) break; + pendingDamage += InstantHitDamage(weapon); + hitThisFrame = true; + // Step slightly past the hit point so next iteration continues. + rayOrigin = glm::vec3(hp.x(), hp.y(), hp.z()) + fwdN * 0.05f; + } + } + + // ===================================================================== + // Projectile weapons — append to q3.missiles + // ===================================================================== + else { + auto missiles = context.Get("q3.missiles", nullptr); + if (!missiles) { + missiles = std::make_shared>(); + } + + sdl3cpp::q3::Q3Missile m; + m.id = nextMissileId_++; + m.origin = origin; + m.fromPlayer = true; + + if (weapon == "weapon_rocketlauncher") { + m.type = sdl3cpp::q3::MissileType::Rocket; + m.velocity = fwdN * 25.f; + m.damage = 100.f; + m.splashDamage = 100.f; + m.splashRadius = 4.f; + } + else if (weapon == "weapon_grenadelauncher") { + m.type = sdl3cpp::q3::MissileType::Grenade; + m.velocity = fwdN * 18.f; + m.velocity.y += 3.f; // lob arc + m.damage = 100.f; + m.splashDamage = 100.f; + m.splashRadius = 4.f; + } + else if (weapon == "weapon_plasmagun") { + m.type = sdl3cpp::q3::MissileType::Plasma; + m.velocity = fwdN * 30.f; + m.damage = 20.f; + m.splashDamage = 15.f; + m.splashRadius = 1.5f; + } + else if (weapon == "weapon_bfg") { + m.type = sdl3cpp::q3::MissileType::BFG; + m.velocity = fwdN * 20.f; + m.damage = 100.f; + m.splashDamage = 200.f; + m.splashRadius = 6.f; + } + + missiles->push_back(m); + context.Set("q3.missiles", missiles); + } + + // ===================================================================== + // Accumulate hit state + // ===================================================================== + if (hitThisFrame) { + context.Set("q3.last_shot_hit", true); + context.Set("q3.hit_marker_until_frame", fireFrame + 10u); + context.Set("q3.pending_damage", pendingDamage); + } + + if (logger_) logger_->Info("q3.weapon.fire: fired " + weapon); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_weapon_select_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_weapon_select_step.cpp new file mode 100644 index 000000000..f0b1d16de --- /dev/null +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_weapon_select_step.cpp @@ -0,0 +1,59 @@ +#include "services/interfaces/workflow/quake3/workflow_q3_weapon_select_step.hpp" + +#include + +#include +#include + +namespace sdl3cpp::services::impl { + +namespace { + +const std::array kWeapons = { + "weapon_gauntlet", + "weapon_machinegun", + "weapon_shotgun", + "weapon_grenadelauncher", + "weapon_rocketlauncher", + "weapon_lightning", + "weapon_railgun", + "weapon_plasmagun", + "weapon_bfg" +}; + +bool HasWeapon(const nlohmann::json& inventory, const std::string& weapon) { + return weapon == "weapon_gauntlet" || + weapon == "weapon_machinegun" || + inventory.value(weapon, false); +} + +} // namespace + +WorkflowQ3WeaponSelectStep::WorkflowQ3WeaponSelectStep(std::shared_ptr logger) + : logger_(std::move(logger)) {} + +std::string WorkflowQ3WeaponSelectStep::GetPluginId() const { + return "q3.weapon.select"; +} + +void WorkflowQ3WeaponSelectStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& context) { + auto inventory = context.Get("q3.inventory", nlohmann::json::object()); + // Gauntlet and machinegun are always available. + inventory["weapon_gauntlet"] = true; + inventory["weapon_machinegun"] = true; + + std::string current = context.Get("q3.current_weapon", "weapon_machinegun"); + + for (size_t i = 0; i < kWeapons.size(); ++i) { + if (!context.GetBool("input_key_" + std::to_string(i + 1), false)) continue; + const std::string requested = kWeapons[i]; + if (HasWeapon(inventory, requested)) { + current = requested; + } + } + + context.Set("q3.inventory", inventory); + context.Set("q3.current_weapon", current); +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/quake3/workflow_q3_weapon_update_step.cpp b/gameengine/src/services/impl/workflow/quake3/workflow_q3_weapon_update_step.cpp index 63ee5f343..f5ab5f1ed 100644 --- a/gameengine/src/services/impl/workflow/quake3/workflow_q3_weapon_update_step.cpp +++ b/gameengine/src/services/impl/workflow/quake3/workflow_q3_weapon_update_step.cpp @@ -1,36 +1,13 @@ #include "services/interfaces/workflow/quake3/workflow_q3_weapon_update_step.hpp" -#include -#include -#include - -#include -#include +// NOTE: This step has been superseded by the following dedicated steps: +// q3.weapon.select (workflow_q3_weapon_select_step) +// q3.weapon.fire (workflow_q3_weapon_fire_step) +// It is kept registered for backwards compatibility but performs no work. +// Remove it from workflow JSON files and use the new steps instead. namespace sdl3cpp::services::impl { -namespace { - -const std::array kWeapons = { - "weapon_gauntlet", - "weapon_machinegun", - "weapon_shotgun", - "weapon_grenadelauncher", - "weapon_rocketlauncher", - "weapon_lightning", - "weapon_railgun", - "weapon_plasmagun", - "weapon_bfg" -}; - -bool HasWeapon(const nlohmann::json& inventory, const std::string& weapon) { - return weapon == "weapon_gauntlet" || - weapon == "weapon_machinegun" || - inventory.value(weapon, false); -} - -} // namespace - WorkflowQ3WeaponUpdateStep::WorkflowQ3WeaponUpdateStep(std::shared_ptr logger) : logger_(std::move(logger)) {} @@ -38,68 +15,9 @@ std::string WorkflowQ3WeaponUpdateStep::GetPluginId() const { return "q3.weapon.update"; } -void WorkflowQ3WeaponUpdateStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { - auto inventory = context.Get("q3.inventory", nlohmann::json::object()); - inventory["weapon_gauntlet"] = true; - inventory["weapon_machinegun"] = true; - - std::string current = context.Get("q3.current_weapon", "weapon_machinegun"); - for (size_t i = 0; i < kWeapons.size(); ++i) { - if (!context.GetBool("input_key_" + std::to_string(i + 1), false)) continue; - const std::string requested = kWeapons[i]; - if (HasWeapon(inventory, requested)) { - current = requested; - context.Set("q3.current_weapon", current); - } - } - - const bool fireHeld = context.GetBool("input_mouse_left", false); - const bool firePressed = context.GetBool("input_mouse_left_pressed", false); - const uint32_t frame = static_cast(context.GetDouble("loop.iteration", 0.0)); - uint32_t lastFire = context.Get("q3.weapon_last_fire_frame", 0u); - const uint32_t interval = current == "weapon_machinegun" ? 8u : - current == "weapon_lightning" ? 3u : - current == "weapon_gauntlet" ? 18u : - 28u; - - bool wantsFire = firePressed || (fireHeld && current == "weapon_machinegun") || (fireHeld && current == "weapon_lightning"); - if (context.GetBool("movement_active", true) && wantsFire && (lastFire == 0u || frame >= lastFire + interval)) { - lastFire = frame == 0u ? 1u : frame; - context.Set("q3.weapon_last_fire_frame", lastFire); - context.Set("q3.weapon_flash_until_frame", lastFire + 4u); - context.Set("q3.shots_fired", context.Get("q3.shots_fired", 0) + 1); - context.Set("q3.last_shot_hit", false); - - auto* world = context.Get("physics_world", nullptr); - auto cameraState = context.Get("camera.state", nlohmann::json::object()); - if (world && cameraState.contains("position") && cameraState.contains("front")) { - const auto pos = cameraState["position"]; - const auto front = cameraState["front"]; - btVector3 from(pos[0].get(), pos[1].get(), pos[2].get()); - btVector3 dir(front[0].get(), front[1].get(), front[2].get()); - btVector3 to = from + dir.normalized() * 120.0f; - btCollisionWorld::ClosestRayResultCallback hit(from, to); - world->rayTest(from, to, hit); - context.Set("q3.last_shot_hit", hit.hasHit()); - if (hit.hasHit()) { - context.Set("q3.hit_marker_until_frame", lastFire + 10u); - context.Set("q3.damage_done", context.Get("q3.damage_done", 0) + ( - current == "weapon_railgun" ? 100 : - current == "weapon_rocketlauncher" ? 100 : - current == "weapon_shotgun" ? 80 : - current == "weapon_plasmagun" ? 20 : - current == "weapon_machinegun" ? 7 : 15)); - context.Set("q3.last_shot_position", nlohmann::json::array({ - hit.m_hitPointWorld.x(), hit.m_hitPointWorld.y(), hit.m_hitPointWorld.z() - })); - } - } - - if (logger_) logger_->Info("q3.weapon.update: fired " + current); - } - - context.Set("q3.inventory", inventory); - context.Set("q3.current_weapon", current); +void WorkflowQ3WeaponUpdateStep::Execute(const WorkflowStepDefinition& /*step*/, WorkflowContext& /*context*/) { + // No-op: weapon selection and firing are now handled by + // q3.weapon.select and q3.weapon.fire respectively. } } // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/impl/workflow/workflow_registrar.cpp b/gameengine/src/services/impl/workflow/workflow_registrar.cpp index e90894023..2aec9ad9e 100644 --- a/gameengine/src/services/impl/workflow/workflow_registrar.cpp +++ b/gameengine/src/services/impl/workflow/workflow_registrar.cpp @@ -130,6 +130,32 @@ #include "services/interfaces/workflow/quake3/workflow_q3_bots_spawn_step.hpp" #include "services/interfaces/workflow/quake3/workflow_q3_bots_update_step.hpp" #include "services/interfaces/workflow/quake3/workflow_q3_bots_draw_step.hpp" +// Q3 pmove +#include "services/interfaces/workflow/quake3/workflow_q3_pm_crouch_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_pm_ground_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_pm_friction_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_pm_accelerate_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_pm_jump_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_pm_slide_move_step.hpp" +// Q3 weapons + missiles +#include "services/interfaces/workflow/quake3/workflow_q3_weapon_select_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_weapon_fire_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_missiles_move_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_missiles_impact_step.hpp" +// Q3 damage + pickups + ammo +#include "services/interfaces/workflow/quake3/workflow_q3_ammo_init_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_damage_apply_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_bots_damage_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_player_death_check_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_player_respawn_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_pickups_touch_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_pickups_respawn_step.hpp" +// Q3 movers + triggers + nav +#include "services/interfaces/workflow/quake3/workflow_q3_movers_init_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_movers_update_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_triggers_check_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_triggers_apply_step.hpp" +#include "services/interfaces/workflow/quake3/workflow_q3_nav_build_step.hpp" // Audio (service-dependent, registered with nullptr) #include "services/interfaces/workflow/workflow_generic_steps/workflow_audio_pause_step.hpp" @@ -351,7 +377,33 @@ void WorkflowRegistrar::RegisterSteps(std::shared_ptr reg registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); - count += 29; + // Q3 pmove + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + // Q3 weapons + missiles + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + // Q3 damage + pickups + ammo + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + // Q3 movers + triggers + nav + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); + count += 55; // ── Texture ─────────────────────────────────────────────── registry->RegisterStep(std::make_shared(logger_)); diff --git a/gameengine/src/services/interfaces/workflow/quake3/q3_missile_types.hpp b/gameengine/src/services/interfaces/workflow/quake3/q3_missile_types.hpp new file mode 100644 index 000000000..a7efd553f --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/q3_missile_types.hpp @@ -0,0 +1,28 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace sdl3cpp::q3 { + +enum class MissileType { Rocket, Grenade, Plasma, BFG }; + +struct Q3Missile { + uint32_t id{0}; + MissileType type{MissileType::Rocket}; + glm::vec3 origin{}; + glm::vec3 velocity{}; // world-units/s + float damage{0}; + float splashDamage{0}; + float splashRadius{0}; + float lifetimeLeft{10.f}; // seconds + bool fromPlayer{true}; + bool exploded{false}; +}; + +using MissileList = std::shared_ptr>; +// context key: "q3.missiles" (MissileList) + +} // namespace sdl3cpp::q3 diff --git a/gameengine/src/services/interfaces/workflow/quake3/q3_mover_types.hpp b/gameengine/src/services/interfaces/workflow/quake3/q3_mover_types.hpp new file mode 100644 index 000000000..8b40181d9 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/q3_mover_types.hpp @@ -0,0 +1,26 @@ +#pragma once +#include +#include +#include +#include + +namespace sdl3cpp::q3 { + +struct Q3Mover { + std::string id; + std::string classname; + glm::vec3 pos1{}; // closed/bottom position + glm::vec3 pos2{}; // open/top position + float travelTime{1.f}; + float waitTime{2.f}; + float stateProgress{0.f}; // 0=pos1, 1=pos2 + enum class State { AtPos1, MovingTo2, AtPos2, MovingTo1 } state{State::AtPos1}; + float stateTimer{0.f}; + glm::vec3 currentPos{}; + glm::vec3 velocity{}; // current frame velocity (for player push) +}; + +using MoverList = std::shared_ptr>; +// context key: "q3.movers" (MoverList) + +} // namespace sdl3cpp::q3 diff --git a/gameengine/src/services/interfaces/workflow/quake3/q3_nav_types.hpp b/gameengine/src/services/interfaces/workflow/quake3/q3_nav_types.hpp new file mode 100644 index 000000000..494ae117a --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/q3_nav_types.hpp @@ -0,0 +1,13 @@ +#pragma once +#include +#include +#include + +namespace sdl3cpp::q3 { + +struct NavNode { glm::vec3 pos; std::vector neighbors; }; +struct NavGraph { std::vector nodes; }; +using NavGraphPtr = std::shared_ptr; +// context key: "q3.nav_graph" (NavGraphPtr) + +} // namespace sdl3cpp::q3 diff --git a/gameengine/src/services/interfaces/workflow/quake3/q3_pm_types.hpp b/gameengine/src/services/interfaces/workflow/quake3/q3_pm_types.hpp new file mode 100644 index 000000000..51410f734 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/q3_pm_types.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include + +namespace sdl3cpp::services::impl { + +// ───────────────────────────────────────────────────────────────────────────── +// Q3PlayerState +// Authoritative per-frame player movement state. +// Context key: "q3.ps" (stored by value as Q3PlayerState) +// ───────────────────────────────────────────────────────────────────────────── +struct Q3PlayerState { + glm::vec3 origin{0.f, 1.f, 0.f}; + glm::vec3 velocity{0.f, 0.f, 0.f}; + glm::vec3 mins{-0.28f, 0.f, -0.28f}; + glm::vec3 maxs{ 0.28f, 1.4f, 0.28f}; + bool onGround{false}; + bool crouching{false}; + float groundFraction{0.f}; // 0 = air, 1 = fully grounded +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Q3Trace +// Result from a swept-box trace against the Bullet world. +// ───────────────────────────────────────────────────────────────────────────── +struct Q3Trace { + bool hit{false}; + float fraction{1.f}; // 0 = started solid, 1 = no hit + glm::vec3 endPos{0.f}; + glm::vec3 normal{0.f, 1.f, 0.f}; + bool startSolid{false}; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Callback that ignores the kinematic player ghost itself (if any). +// We use the simplest form: closest-hit convex result. +// ───────────────────────────────────────────────────────────────────────────── +struct Q3NotMeCallback final : public btCollisionWorld::ClosestConvexResultCallback { + const btCollisionObject* me{nullptr}; + + Q3NotMeCallback() + : btCollisionWorld::ClosestConvexResultCallback( + btVector3(0, 0, 0), btVector3(0, 0, 0)) {} + + btScalar addSingleResult( + btCollisionWorld::LocalConvexResult& result, + bool normalInWorldSpace) override + { + if (result.m_hitCollisionObject == me) return 1.f; + return ClosestConvexResultCallback::addSingleResult(result, normalInWorldSpace); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// TraceBox +// Sweeps an AABB (defined by mins/maxs) from `from` to `to` in the given +// Bullet world and returns collision info. +// ───────────────────────────────────────────────────────────────────────────── +inline Q3Trace TraceBox( + btDiscreteDynamicsWorld* world, + glm::vec3 from, + glm::vec3 to, + glm::vec3 mins, + glm::vec3 maxs) +{ + Q3Trace result; + result.endPos = to; + + if (!world) return result; + + // Half-extents of the AABB + const glm::vec3 half = (maxs - mins) * 0.5f; + btBoxShape boxShape(btVector3(half.x, half.y, half.z)); + boxShape.setMargin(0.001f); + + // Centre offset of the AABB (mins are not necessarily symmetric) + const glm::vec3 centre = (mins + maxs) * 0.5f; + + btTransform fromTr, toTr; + fromTr.setIdentity(); + toTr.setIdentity(); + fromTr.setOrigin(btVector3(from.x + centre.x, from.y + centre.y, from.z + centre.z)); + toTr.setOrigin(btVector3(to.x + centre.x, to.y + centre.y, to.z + centre.z)); + + Q3NotMeCallback cb; + cb.m_collisionFilterGroup = btBroadphaseProxy::DefaultFilter; + cb.m_collisionFilterMask = btBroadphaseProxy::StaticFilter | btBroadphaseProxy::DefaultFilter; + + world->convexSweepTest(&boxShape, fromTr, toTr, cb, 0.001f); + + if (cb.hasHit()) { + result.hit = true; + result.fraction = cb.m_closestHitFraction; + const btVector3& n = cb.m_hitNormalWorld; + result.normal = glm::vec3(n.x(), n.y(), n.z()); + + // Interpolate end position along the sweep + glm::vec3 delta = to - from; + result.endPos = from + delta * result.fraction; + } + + return result; +} + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_ammo_init_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_ammo_init_step.hpp new file mode 100644 index 000000000..ba80445c4 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_ammo_init_step.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.ammo.init + * + * One-shot step: writes q3.player_ammo with ioq3 default ammo counts. + * Guards with q3.ammo_initialized — returns immediately if already done. + * + * Context writes: + * q3.player_ammo nlohmann::json object of weapon->count + * q3.ammo_initialized bool (true) + */ +class WorkflowQ3AmmoInitStep final : public IWorkflowStep { +public: + explicit WorkflowQ3AmmoInitStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_bots_damage_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_bots_damage_step.hpp new file mode 100644 index 000000000..52583a86c --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_bots_damage_step.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.damage.bots + * + * Applies pending bot damage from q3.bot_damage to each bot in q3.bots. + * Sets bot["state"]="dead" when health drops to zero or below. + * Clears q3.bot_damage after applying. + * + * Context reads: + * q3.bot_damage nlohmann::json object {"bot_0": int, ...} + * q3.bots nlohmann::json array + * + * Context writes: + * q3.bots nlohmann::json array (updated health / state) + * q3.bot_damage nlohmann::json object (cleared to {}) + */ +class WorkflowQ3BotsDamageStep final : public IWorkflowStep { +public: + explicit WorkflowQ3BotsDamageStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_damage_apply_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_damage_apply_step.hpp new file mode 100644 index 000000000..035d2da14 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_damage_apply_step.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.damage.apply + * + * Applies pending player damage with armor absorption (ioq3 G_Damage logic). + * Returns early if q3.pending_damage == 0. + * + * Context reads: + * q3.pending_damage int + * q3.player_health int + * q3.player_armor int + * q3.armor_type std::string ("none" | "green" | "yellow") + * + * Context writes: + * q3.player_health int + * q3.player_armor int + * q3.pending_damage int (cleared to 0) + */ +class WorkflowQ3DamageApplyStep final : public IWorkflowStep { +public: + explicit WorkflowQ3DamageApplyStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_hud_head_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_hud_head_step.hpp index 6392559b2..d329d5d6d 100644 --- a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_hud_head_step.hpp +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_hud_head_step.hpp @@ -3,6 +3,7 @@ #include "services/interfaces/i_logger.hpp" #include "services/interfaces/workflow_context.hpp" #include +#include #include #include @@ -21,14 +22,21 @@ public: static constexpr int kHeadSz = 64; // square render target size private: - bool TryInitRT(SDL_GPUDevice* device); + bool TryInitRT(SDL_GPUDevice* device, SDL_Window* window); std::shared_ptr logger_; SDL_GPUDevice* device_ = nullptr; SDL_GPUTexture* color_rt_ = nullptr; SDL_GPUTexture* depth_rt_ = nullptr; bool ready_ = false; - float yaw_ = 0.0f; // slow portrait rotation (radians) + + // Q3A-style idle sway state (mirrors cg.headStart/EndYaw/Pitch/Time) + float swayStartYaw_ = glm::radians(180.f); + float swayStartPitch_ = 0.f; + float swayEndYaw_ = glm::radians(180.f); + float swayEndPitch_ = 0.f; + uint64_t swayStartMs_ = 0; + uint64_t swayEndMs_ = 0; }; } // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_missiles_impact_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_missiles_impact_step.hpp new file mode 100644 index 000000000..7ea24c048 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_missiles_impact_step.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +#include +#include +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.missiles.impact + * + * Detects missile-world collisions using Bullet raycasts from each missile's + * previous position to its current position. On impact: + * - Marks the missile as exploded. + * - Calculates splash damage to the player and each bot within splashRadius. + * - Accumulates damage into q3.pending_damage (player) and q3.bot_damage (bots). + * - Removes all exploded missiles from the vector. + * + * Splash damage formula (ioq3): dmg = splashDamage * (1 - dist / splashRadius) + * + * Reads: + * q3.missiles (MissileList) + * physics_world (btDiscreteDynamicsWorld*) + * q3.player_pos (glm::vec3) + * q3.bots (nlohmann::json) + * q3.pending_damage (int) + * Writes: + * q3.missiles, q3.pending_damage, q3.bot_damage (nlohmann::json) + */ +class WorkflowQ3MissilesImpactStep final : public IWorkflowStep { +public: + explicit WorkflowQ3MissilesImpactStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + +private: + std::shared_ptr logger_; + // Tracks the previous-frame origin of each missile by ID. + std::unordered_map prevPositions_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_missiles_move_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_missiles_move_step.hpp new file mode 100644 index 000000000..d6b9f0bb2 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_missiles_move_step.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.missiles.move + * + * Advances all live missiles by delta time. Applies gravity to grenades. + * Marks missiles as exploded when their lifetime expires. + * Does NOT perform collision detection — that is handled by q3.missiles.impact. + * + * Reads: q3.missiles (MissileList), frame.delta_time (double) + * Writes: q3.missiles (modified in-place, same shared_ptr) + */ +class WorkflowQ3MissilesMoveStep final : public IWorkflowStep { +public: + explicit WorkflowQ3MissilesMoveStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_movers_init_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_movers_init_step.hpp new file mode 100644 index 000000000..8bc9df4e3 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_movers_init_step.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.movers.init + * + * One-shot step (guard on q3.movers_initialized). + * Reads bsp.entities and creates Q3Mover objects for each func_door / func_plat entity. + * + * Reads: bsp.entities (nlohmann::json array) + * Writes: q3.movers (MoverList), q3.movers_initialized (bool) + */ +class WorkflowQ3MoversInitStep final : public IWorkflowStep { +public: + explicit WorkflowQ3MoversInitStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_movers_update_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_movers_update_step.hpp new file mode 100644 index 000000000..9cc33e771 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_movers_update_step.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.movers.update + * + * Per-frame mover simulation: doors/platforms move between pos1 and pos2. + * Proximity to player triggers door open. Pushes player if mover overlaps. + * + * Reads: q3.movers (MoverList), q3.player_pos (glm::vec3), frame.delta_time + * Writes: q3.movers (in place), q3.player_velocity_push (glm::vec3, additive) + */ +class WorkflowQ3MoversUpdateStep final : public IWorkflowStep { +public: + explicit WorkflowQ3MoversUpdateStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_nav_build_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_nav_build_step.hpp new file mode 100644 index 000000000..2f5e39e0f --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_nav_build_step.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.nav.build + * + * One-shot step (guard on q3.nav_built). + * Grid-samples the map using downward ray casts against the Bullet physics world + * to build a nav graph of walkable nodes. Neighbors are connected when the + * line-of-sight ray cast is clear. + * + * Reads: physics_world (btDiscreteDynamicsWorld*), bsp.spawn_points (optional) + * Writes: q3.nav_graph (NavGraphPtr), q3.nav_built (bool) + */ +class WorkflowQ3NavBuildStep final : public IWorkflowStep { +public: + explicit WorkflowQ3NavBuildStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pickups_respawn_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pickups_respawn_step.hpp new file mode 100644 index 000000000..9952e19b5 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pickups_respawn_step.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.pickups.respawn + * + * Re-enables collected pickups once their respawn timer has elapsed. + * Sets collected[entityId]=false when elapsed > pickup_respawn_times[entityId]. + * + * Context reads: + * q3.collected nlohmann::json object {entityId: bool} + * q3.pickup_respawn_times nlohmann::json object {entityId: double} + * frame.elapsed double + * + * Context writes: + * q3.collected nlohmann::json object (respawned items cleared) + */ +class WorkflowQ3PickupsRespawnStep final : public IWorkflowStep { +public: + explicit WorkflowQ3PickupsRespawnStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pickups_touch_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pickups_touch_step.hpp new file mode 100644 index 000000000..1777bcdb8 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pickups_touch_step.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.pickups.touch + * + * AABB proximity test between the player and each uncollected BSP pickup entity. + * Applies health, armor, ammo, and weapon pickups on contact. + * Records collection time for respawn timer. + * + * Context reads: + * q3.player_pos glm::vec3 + * q3.player_health int + * q3.player_armor int + * q3.armor_type std::string + * q3.player_ammo nlohmann::json object + * q3.player_dead bool + * q3.collected nlohmann::json object {entityId: bool} + * bsp.entities nlohmann::json array + * frame.elapsed double + * + * Context writes: + * q3.player_health int + * q3.player_armor int + * q3.armor_type std::string + * q3.player_ammo nlohmann::json object + * q3.inventory nlohmann::json object {classname: bool} + * q3.collected nlohmann::json object + * q3.pickup_respawn_times nlohmann::json object {entityId: double} + */ +class WorkflowQ3PickupsTouchStep final : public IWorkflowStep { +public: + explicit WorkflowQ3PickupsTouchStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_player_death_check_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_player_death_check_step.hpp new file mode 100644 index 000000000..bed6207fb --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_player_death_check_step.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.player.death_check + * + * Detects when the player health first drops to zero or below. + * Records the time of death and increments the death counter. + * No-ops if already dead or health > 0. + * + * Context reads: + * q3.player_health int + * q3.player_dead bool + * frame.elapsed double + * + * Context writes: + * q3.player_dead bool (set true on new death) + * q3.death_time double + * q3.death_count int (incremented) + */ +class WorkflowQ3PlayerDeathCheckStep final : public IWorkflowStep { +public: + explicit WorkflowQ3PlayerDeathCheckStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_player_respawn_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_player_respawn_step.hpp new file mode 100644 index 000000000..a4e06c81b --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_player_respawn_step.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.player.respawn + * + * Respawns the player 1.7 seconds after death. + * Picks a random BSP spawn point (bsp.spawn_points), resets health/armor/ammo. + * Clears q3.ammo_initialized so q3.ammo.init re-runs next frame. + * + * Context reads: + * q3.player_dead bool + * q3.death_time double + * frame.elapsed double + * bsp.spawn_points nlohmann::json array of {x,y,z} + * + * Context writes: + * q3.player_pos glm::vec3 + * q3.player_health int + * q3.player_armor int + * q3.armor_type std::string + * q3.player_dead bool (false) + * q3.ammo_initialized bool (false — triggers re-init) + */ +class WorkflowQ3PlayerRespawnStep final : public IWorkflowStep { +public: + explicit WorkflowQ3PlayerRespawnStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_accelerate_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_accelerate_step.hpp new file mode 100644 index 000000000..562095235 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_accelerate_step.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.pm.accelerate + * + * Applies wish-direction acceleration, matching bg_pmove.c PM_Accelerate. + * Builds a wish direction from input axes rotated by player yaw, then + * adds velocity up to a maximum (wishspeed) using the Q3 accel formula. + * + * Reads: q3.ps (Q3PlayerState), + * input.move_forward (float -1..1), + * input.move_right (float -1..1), + * q3.player_yaw (float, radians), + * frame.delta_time (double) + * Writes: q3.ps + */ +class WorkflowQ3PmAccelerateStep final : public IWorkflowStep { +public: + explicit WorkflowQ3PmAccelerateStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_crouch_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_crouch_step.hpp new file mode 100644 index 000000000..3b6e52618 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_crouch_step.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.pm.crouch + * + * Handles crouch/uncrouch transitions for the Q3 player movement system. + * When crouching: shrinks bounding box (maxs.y → 0.85). + * When uncrouching: traces upward first; only stands if there is room. + * Also writes q3.player_pos, q3.player_yaw, q3.player_pitch for backward + * compatibility with HUD and bot steps. + * + * Reads: input.crouch (bool), q3.ps (Q3PlayerState), + * q3.player_yaw (float), q3.player_pitch (float), + * physics_world (btDiscreteDynamicsWorld*) + * Writes: q3.ps, q3.player_pos (glm::vec3), q3.player_yaw (float), + * q3.player_pitch (float) + */ +class WorkflowQ3PmCrouchStep final : public IWorkflowStep { +public: + explicit WorkflowQ3PmCrouchStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_friction_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_friction_step.hpp new file mode 100644 index 000000000..fe32d0c03 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_friction_step.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.pm.friction + * + * Applies ground friction to the player velocity, matching bg_pmove.c + * PM_Friction. Only acts when onGround is true. + * + * Constants (scaled from Q3 units): + * pm_stopspeed = 1.5 (world units/s) + * pm_friction = 8.0 (world units/s²) + * + * Reads: q3.ps (Q3PlayerState), frame.delta_time (double) + * Writes: q3.ps + */ +class WorkflowQ3PmFrictionStep final : public IWorkflowStep { +public: + explicit WorkflowQ3PmFrictionStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_ground_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_ground_step.hpp new file mode 100644 index 000000000..cb3307e59 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_ground_step.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.pm.ground + * + * Detects whether the player is on the ground via a short downward box-trace. + * Snaps origin to ground when grounded. Applies gravity acceleration when + * airborne. + * + * Reads: q3.ps (Q3PlayerState), physics_world (btDiscreteDynamicsWorld*), + * frame.delta_time (double) + * Writes: q3.ps + */ +class WorkflowQ3PmGroundStep final : public IWorkflowStep { +public: + explicit WorkflowQ3PmGroundStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_jump_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_jump_step.hpp new file mode 100644 index 000000000..4058e2a73 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_jump_step.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.pm.jump + * + * Handles the Q3 jump impulse. Jump fires once per key-press (not + * auto-bunnyhop) by tracking whether the jump key was held last frame + * via q3.pm_jump_held. + * + * Jump velocity: 8.5 world units/s (270 q3units/s * 0.03125 ≈ 8.4375) + * + * Reads: q3.ps (Q3PlayerState), + * input.jump (bool), + * q3.pm_jump_held (bool) + * Writes: q3.ps, q3.pm_jump_held (bool) + */ +class WorkflowQ3PmJumpStep final : public IWorkflowStep { +public: + explicit WorkflowQ3PmJumpStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_slide_move_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_slide_move_step.hpp new file mode 100644 index 000000000..c72903416 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_pm_slide_move_step.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.pm.slide_move + * + * Integrates the player velocity against the Bullet world using Q3's + * iterative slide-move algorithm (bg_slidemove.c PM_SlideMove). + * + * Up to 4 collision bumps per frame: + * 1. Sweep box from origin in the direction of velocity * timeLeft. + * 2. Advance origin to hit point. + * 3. Clip velocity against the hit normal (overbounce 1.001). + * 4. If the clipped velocity would re-enter a previously hit plane, + * project along the crease (cross product of both normals). + * + * After integration, syncs q3.player_pos ← ps.origin. + * + * Reads: q3.ps (Q3PlayerState), + * physics_world (btDiscreteDynamicsWorld*), + * frame.delta_time (double) + * Writes: q3.ps, q3.player_pos (glm::vec3) + */ +class WorkflowQ3PmSlideMoveStep final : public IWorkflowStep { +public: + explicit WorkflowQ3PmSlideMoveStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_triggers_apply_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_triggers_apply_step.hpp new file mode 100644 index 000000000..8c37c11f1 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_triggers_apply_step.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.triggers.apply + * + * Applies trigger effects computed by q3.triggers.check to game state. + * Must run after q3.triggers.check in the same frame. + * + * Reads: q3.player_velocity_override (glm::vec3, optional), + * q3.player_teleport_dest (glm::vec3, optional), + * q3.player_on_jump_pad (bool), + * q3.ps (nlohmann::json, optional Q3 player state), + * q3.player_pos (glm::vec3) + * Writes: q3.ps (velocity/origin if present), q3.player_pos, + * clears q3.player_velocity_override, q3.player_teleport_dest, + * q3.player_on_jump_pad + */ +class WorkflowQ3TriggersApplyStep final : public IWorkflowStep { +public: + explicit WorkflowQ3TriggersApplyStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_triggers_check_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_triggers_check_step.hpp new file mode 100644 index 000000000..530a693d1 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_triggers_check_step.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.triggers.check + * + * One-time parse of trigger entities on first call, then per-frame overlap tests. + * Handles: trigger_push (jump pad), trigger_teleport, trigger_hurt. + * + * Reads: bsp.entities (nlohmann::json array), q3.player_pos (glm::vec3), q3.player_dead (bool) + * Writes: q3.player_velocity_override (glm::vec3), q3.player_on_jump_pad (bool), + * q3.player_teleport_dest (glm::vec3), q3.pending_damage (float, additive) + */ +class WorkflowQ3TriggersCheckStep final : public IWorkflowStep { +public: + explicit WorkflowQ3TriggersCheckStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_weapon_fire_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_weapon_fire_step.hpp new file mode 100644 index 000000000..9a282b347 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_weapon_fire_step.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.weapon.fire + * + * Per-frame weapon firing logic. Enforces per-weapon fire rate, performs + * instant-hit raycasts (machinegun, shotgun, railgun, lightning) and spawns + * projectile missiles (rocket, grenade, plasma, BFG). + * + * Fire rates (frames at 60 fps, from ioq3): + * machinegun 6 | shotgun 30 | grenade 30 | rocket 20 + * lightning 1 | railgun 50 | plasma 6 | bfg 200 + * + * Reads: + * input_mouse_left (bool), input_mouse_left_pressed (bool) + * q3.current_weapon (string) + * q3.weapon_last_fire_frame (uint32_t) + * q3.player_ammo (nlohmann::json — e.g. {"weapon_machinegun": 100}) + * camera.state (nlohmann::json — "pos":[x,y,z], "forward":[x,y,z]) + * physics_world (btDiscreteDynamicsWorld*) + * q3.missiles (MissileList — initialised here if null) + * loop.iteration (double) + * + * Writes: + * q3.missiles, q3.pending_damage, q3.last_shot_hit, + * q3.hit_marker_until_frame, q3.weapon_last_fire_frame, + * q3.weapon_flash_until_frame, q3.player_ammo + */ +class WorkflowQ3WeaponFireStep final : public IWorkflowStep { +public: + explicit WorkflowQ3WeaponFireStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + +private: + std::shared_ptr logger_; + uint32_t nextMissileId_{1}; +}; + +} // namespace sdl3cpp::services::impl diff --git a/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_weapon_select_step.hpp b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_weapon_select_step.hpp new file mode 100644 index 000000000..1c1cdc70d --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/quake3/workflow_q3_weapon_select_step.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +/** + * Plugin ID: q3.weapon.select + * + * Scans number-key inputs 1-9. If the key is pressed and the requested weapon + * is present in q3.inventory, updates q3.current_weapon. + * + * Reads: input_key_1 … input_key_9 (bool) + * q3.inventory (nlohmann::json) + * q3.current_weapon (string) + * Writes: q3.current_weapon (string) + * q3.inventory (string — ensures gauntlet + machinegun always present) + */ +class WorkflowQ3WeaponSelectStep final : public IWorkflowStep { +public: + explicit WorkflowQ3WeaponSelectStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl