Add ioq3-faithful gameplay systems: pmove, weapons, damage, nav, bots

34 new atomic workflow steps ported from ioq3 source:
- pmove: crouch/ground/friction/accelerate/jump/slide_move (bg_pmove.c)
- weapons: select, fire (instant-hit + projectile), missiles move/impact
- damage: armor absorption (G_Damage), bot damage, death check, respawn
- pickups: touch (all item types + respawn timers), ammo init
- movers: init/update (door/platform 4-state machine)
- triggers: check/apply (jump pads, teleporters, hurt volumes)
- nav: BSP grid ray-cast nav graph + A* bot pathfinding

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 20:45:47 +01:00
parent 730baa8733
commit 083d57177b
58 changed files with 3213 additions and 192 deletions
+26
View File
@@ -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
@@ -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" }
]
}
@@ -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 }] } }
}
@@ -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"
@@ -0,0 +1,35 @@
#include "services/interfaces/workflow/quake3/workflow_q3_ammo_init_step.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <nlohmann/json.hpp>
#include <string>
namespace sdl3cpp::services::impl {
WorkflowQ3AmmoInitStep::WorkflowQ3AmmoInitStep(std::shared_ptr<ILogger> 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
@@ -0,0 +1,48 @@
#include "services/interfaces/workflow/quake3/workflow_q3_bots_damage_step.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <nlohmann/json.hpp>
#include <string>
namespace sdl3cpp::services::impl {
WorkflowQ3BotsDamageStep::WorkflowQ3BotsDamageStep(std::shared_ptr<ILogger> 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<nlohmann::json>("q3.bot_damage");
if (!botDamagePtr || !botDamagePtr->is_object() || botDamagePtr->empty()) return;
const auto* botsPtr = context.TryGet<nlohmann::json>("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<int>();
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
@@ -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 <btBulletDynamicsCommon.h>
#include <glm/glm.hpp>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cmath>
#include <queue>
#include <string>
#include <vector>
#include <memory>
namespace sdl3cpp::services::impl {
// ----------------------------------------------------------------
// A* over NavGraph
// Returns node-index path from startNode to goalNode, or empty on failure.
// ----------------------------------------------------------------
namespace {
std::vector<int> 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<float> gCost(N, 1e9f);
std::vector<int> 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<float, int>;
std::priority_queue<Entry, std::vector<Entry>, std::greater<Entry>> 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<int> 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<ILogger> 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<nlohmann::json>("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<float>(), cp[1].get<float>(), cp[2].get<float>()};
}
if (const auto* pp = context.TryGet<glm::vec3>("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<sdl3cpp::q3::NavGraphPtr>("q3.nav_graph"))
if (*ngp && !(*ngp)->nodes.empty())
navGraph = ngp->get();
// Physics world for line-of-sight raycasts
auto* world = context.Get<btDiscreteDynamicsWorld*>("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<std::string>();
// 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<int> path;
if (const auto* cached = context.TryGet<std::shared_ptr<std::vector<int>>>(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<std::vector<int>>(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<std::vector<int>>(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
@@ -0,0 +1,46 @@
#include "services/interfaces/workflow/quake3/workflow_q3_damage_apply_step.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <algorithm>
#include <cmath>
#include <string>
namespace sdl3cpp::services::impl {
WorkflowQ3DamageApplyStep::WorkflowQ3DamageApplyStep(std::shared_ptr<ILogger> 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<int>(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
@@ -3,12 +3,14 @@
#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <SDL3/SDL.h>
#include <SDL3/SDL_gpu.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <string>
@@ -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<SDL_GPUTexture*>("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<SDL_GPUGraphicsPipeline*>("gpu_pipeline_textured", nullptr);
if (!pipeline) return;
if (!ready_ && !TryInitRT(device)) return;
auto* window = context.Get<SDL_Window*>("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 1002100 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<uint64_t>(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<float>(SDL_rand(1000)) / 1000.f - 0.5f) * 2.f;
const float rpitch = (static_cast<float>(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<float>(nowMs - swayStartMs_) /
static_cast<float>(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<SDL_GPUTexture*>("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<int>("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<float>("hud.face_rect_x", hx + hNumW + 4.f);
context.Set<float>("hud.face_rect_y", kH - kNH - 6.f);
context.Set<float>("hud.face_rect_w", kNH);
context.Set<float>("hud.face_rect_h", kNH);
}
} // namespace sdl3cpp::services::impl
@@ -2,6 +2,25 @@
#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp"
#include <SDL3/SDL_render.h>
#include <SDL3/SDL_gpu.h>
// ─────────────────────────────────────────────────────────────────────────────
// 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<SDL_Texture*>("overlay.tex.num." + std::to_string(i), nullptr);
auto* iArmor = context.Get<SDL_Texture*>("overlay.tex.icon_armor", nullptr);
auto* iFace = context.Get<SDL_Texture*>("overlay.tex.icon_face", nullptr);
auto* iWeapon = context.Get<SDL_Texture*>("overlay.tex.icon_weapon", nullptr);
const int health = context.Get<int>("q3.player_health", 100);
const int armor = context.Get<int>("q3.player_armor", 0);
const int ammo = context.Get<int>("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<float>(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<SDL_GPUTexture*>("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<SDL_GPUTexture*>("overlay.head_gpu_tex", nullptr)) {
auto* iFace = context.Get<SDL_Texture*>("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<float>("hud.face_rect_x", 185.f + kIconOff);
context.Set<float>("hud.face_rect_y", kHeadY);
context.Set<float>("hud.face_rect_w", kHeadSz);
context.Set<float>("hud.face_rect_h", kHeadSz);
}
} // namespace sdl3cpp::services::impl
@@ -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 <btBulletDynamicsCommon.h>
#include <glm/glm.hpp>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cmath>
#include <string>
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<int>(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<float>(), p[1].get<float>(), p[2].get<float>());
}
return glm::vec3(0.f);
}
} // namespace
WorkflowQ3MissilesImpactStep::WorkflowQ3MissilesImpactStep(std::shared_ptr<ILogger> 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<sdl3cpp::q3::MissileList>("q3.missiles", nullptr);
if (!missiles || missiles->empty()) {
// Clean up stale prev-position entries for finished missiles.
prevPositions_.clear();
return;
}
auto* world = context.Get<btDiscreteDynamicsWorld*>("physics_world", nullptr);
const glm::vec3 playerPos = context.Get<glm::vec3>("q3.player_pos", glm::vec3(0.f));
auto bots = context.Get<nlohmann::json>("q3.bots", nlohmann::json::array());
auto botDamage = context.Get<nlohmann::json>("q3.bot_damage", nlohmann::json::object());
int pendingDamage = context.Get<int>("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<int>("q3.pending_damage", pendingDamage);
context.Set("q3.bot_damage", botDamage);
}
} // namespace sdl3cpp::services::impl
@@ -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 <string>
namespace sdl3cpp::services::impl {
WorkflowQ3MissilesMoveStep::WorkflowQ3MissilesMoveStep(std::shared_ptr<ILogger> 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<sdl3cpp::q3::MissileList>("q3.missiles", nullptr);
if (!missiles || missiles->empty()) {
return;
}
const float dt = static_cast<float>(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
@@ -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 <nlohmann/json.hpp>
#include <glm/glm.hpp>
#include <cmath>
#include <string>
namespace sdl3cpp::services::impl {
WorkflowQ3MoversInitStep::WorkflowQ3MoversInitStep(std::shared_ptr<ILogger> 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<nlohmann::json>("bsp.entities");
auto movers = std::make_shared<std::vector<sdl3cpp::q3::Q3Mover>>();
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<std::string>();
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
@@ -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 <glm/glm.hpp>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cmath>
namespace sdl3cpp::services::impl {
WorkflowQ3MoversUpdateStep::WorkflowQ3MoversUpdateStep(std::shared_ptr<ILogger> 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<sdl3cpp::q3::MoverList>("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<glm::vec3>("q3.player_pos")) {
playerPos = *pp;
} else {
const auto camState = context.Get<nlohmann::json>("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<float>(), cp[1].get<float>(), cp[2].get<float>()};
}
}
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<glm::vec3>("q3.player_velocity_push", glm::vec3(0.f));
context.Set("q3.player_velocity_push", existing + velocityPush);
}
} // namespace sdl3cpp::services::impl
@@ -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 <btBulletDynamicsCommon.h>
#include <nlohmann/json.hpp>
#include <glm/glm.hpp>
#include <cmath>
#include <string>
#include <vector>
namespace sdl3cpp::services::impl {
WorkflowQ3NavBuildStep::WorkflowQ3NavBuildStep(std::shared_ptr<ILogger> 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<btDiscreteDynamicsWorld*>("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<nlohmann::json>("bsp.spawn_points");
if (!spawnPts) spawnPts = context.TryGet<nlohmann::json>("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<float>(), (*posJ)[1].get<float>(), (*posJ)[2].get<float>());
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<sdl3cpp::q3::NavGraph>();
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
@@ -0,0 +1,40 @@
#include "services/interfaces/workflow/quake3/workflow_q3_pickups_respawn_step.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
namespace sdl3cpp::services::impl {
WorkflowQ3PickupsRespawnStep::WorkflowQ3PickupsRespawnStep(std::shared_ptr<ILogger> 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<nlohmann::json>("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<nlohmann::json>("q3.collected", nlohmann::json::object());
std::vector<std::string> toRespawn;
for (auto it = respawnTimesPtr->begin(); it != respawnTimesPtr->end(); ++it) {
const double respawnAt = it.value().get<double>();
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
@@ -0,0 +1,176 @@
#include "services/interfaces/workflow/quake3/workflow_q3_pickups_touch_step.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <glm/glm.hpp>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <sstream>
#include <string>
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<ILogger> 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<nlohmann::json>("bsp.entities");
if (!entitiesPtr || !entitiesPtr->is_array()) return;
const glm::vec3 playerPos = context.Get<glm::vec3>("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<nlohmann::json>("q3.player_ammo", nlohmann::json::object());
auto inventory = context.Get<nlohmann::json>("q3.inventory", nlohmann::json::object());
auto collected = context.Get<nlohmann::json>("q3.collected", nlohmann::json::object());
auto respawnTimes = context.Get<nlohmann::json>("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<std::string>(), 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<float>(), p[1].get<float>(), p[2].get<float>());
}
} 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
@@ -0,0 +1,33 @@
#include "services/interfaces/workflow/quake3/workflow_q3_player_death_check_step.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <string>
namespace sdl3cpp::services::impl {
WorkflowQ3PlayerDeathCheckStep::WorkflowQ3PlayerDeathCheckStep(std::shared_ptr<ILogger> 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
@@ -0,0 +1,57 @@
#include "services/interfaces/workflow/quake3/workflow_q3_player_respawn_step.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <glm/glm.hpp>
#include <nlohmann/json.hpp>
#include <cstdlib>
#include <string>
namespace sdl3cpp::services::impl {
static constexpr double kRespawnDelay = 1.7;
WorkflowQ3PlayerRespawnStep::WorkflowQ3PlayerRespawnStep(std::shared_ptr<ILogger> 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<nlohmann::json>("bsp.spawn_points");
if (spawnPoints && spawnPoints->is_array() && !spawnPoints->empty()) {
const int idx = std::rand() % static_cast<int>(spawnPoints->size());
const auto& sp = (*spawnPoints)[idx];
if (sp.is_array() && sp.size() == 3) {
spawnPos = glm::vec3(sp[0].get<float>(), sp[1].get<float>(), sp[2].get<float>());
} 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
@@ -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 <glm/glm.hpp>
#include <algorithm>
#include <cmath>
#include <utility>
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<ILogger> 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<Q3PlayerState>("q3.ps");
if (!psPtr) return;
Q3PlayerState ps = *psPtr;
const float dt = static_cast<float>(context.GetDouble("frame.delta_time", 0.016));
const float moveForward = context.Get<float>("input.move_forward", 0.f);
const float moveRight = context.Get<float>("input.move_right", 0.f);
const float yaw = context.Get<float>("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
@@ -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 <btBulletDynamicsCommon.h>
#include <glm/glm.hpp>
#include <utility>
namespace sdl3cpp::services::impl {
static constexpr float kStandMaxsY = 1.4f;
static constexpr float kCrouchMaxsY = 0.85f;
WorkflowQ3PmCrouchStep::WorkflowQ3PmCrouchStep(std::shared_ptr<ILogger> 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<Q3PlayerState>("q3.ps");
if (!psPtr) return;
Q3PlayerState ps = *psPtr;
const bool crouchPressed = context.GetBool("input.crouch", false);
auto* world = context.Get<btDiscreteDynamicsWorld*>("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<float>("q3.player_yaw", 0.f));
context.Set("q3.player_pitch", context.Get<float>("q3.player_pitch", 0.f));
}
} // namespace sdl3cpp::services::impl
@@ -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 <glm/glm.hpp>
#include <algorithm>
#include <cmath>
#include <utility>
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<ILogger> 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<Q3PlayerState>("q3.ps");
if (!psPtr) return;
Q3PlayerState ps = *psPtr;
if (!ps.onGround) {
context.Set("q3.ps", ps);
return;
}
const float dt = static_cast<float>(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
@@ -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 <btBulletDynamicsCommon.h>
#include <glm/glm.hpp>
#include <utility>
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<ILogger> 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<Q3PlayerState>("q3.ps");
if (!psPtr) return;
Q3PlayerState ps = *psPtr;
const float dt = static_cast<float>(context.GetDouble("frame.delta_time", 0.016));
auto* world = context.Get<btDiscreteDynamicsWorld*>("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
@@ -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 <glm/glm.hpp>
#include <utility>
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<ILogger> 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<Q3PlayerState>("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
@@ -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 <btBulletDynamicsCommon.h>
#include <glm/glm.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <utility>
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<ILogger> 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<Q3PlayerState>("q3.ps");
if (!psPtr) return;
Q3PlayerState ps = *psPtr;
auto* world = context.Get<btDiscreteDynamicsWorld*>("physics_world", nullptr);
const float dt = static_cast<float>(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<glm::vec3, kMaxBumps> 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<float>("q3.player_yaw", 0.f));
}
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,69 @@
#include "services/interfaces/workflow/quake3/workflow_q3_triggers_apply_step.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <nlohmann/json.hpp>
#include <glm/glm.hpp>
namespace sdl3cpp::services::impl {
WorkflowQ3TriggersApplyStep::WorkflowQ3TriggersApplyStep(std::shared_ptr<ILogger> 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<glm::vec3>("q3.player_velocity_override")) {
const glm::vec3 vel = *velOverride;
// Update q3.ps if present
auto* psPtr = context.TryGet<nlohmann::json>("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<glm::vec3>("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<glm::vec3>("q3.player_teleport_dest")) {
const glm::vec3 destPos = *dest;
context.Set("q3.player_pos", destPos);
auto* psPtr = context.TryGet<nlohmann::json>("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<nlohmann::json>("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
@@ -0,0 +1,159 @@
#include "services/interfaces/workflow/quake3/workflow_q3_triggers_check_step.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <nlohmann/json.hpp>
#include <glm/glm.hpp>
#include <cmath>
#include <string>
#include <vector>
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<ILogger> 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<nlohmann::json>("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<std::string>());
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<std::string>());
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<nlohmann::json>("q3.trigger_list");
if (!triggerListPtr || !triggerListPtr->is_array()) return;
const auto* destIdxPtr = context.TryGet<nlohmann::json>("q3.trigger_dest_index");
glm::vec3 playerPos(0.f);
if (const auto* pp = context.TryGet<glm::vec3>("q3.player_pos")) {
playerPos = *pp;
} else {
const auto camState = context.Get<nlohmann::json>("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<float>(), cp[1].get<float>(), cp[2].get<float>()};
}
}
constexpr float kOverlapDist = 1.5f;
for (const auto& t : *triggerListPtr) {
const auto& oj = t["origin"];
const glm::vec3 tOrigin(oj[0].get<float>(), oj[1].get<float>(), oj[2].get<float>());
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<std::string>();
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<float>(), dj[1].get<float>(), dj[2].get<float>());
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<float>(), dj[1].get<float>(), dj[2].get<float>());
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<float>("q3.pending_damage", 0.f);
context.Set("q3.pending_damage", existing + dmg);
}
}
}
} // namespace sdl3cpp::services::impl
@@ -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 <btBulletDynamicsCommon.h>
#include <glm/glm.hpp>
#include <glm/gtc/constants.hpp>
#include <nlohmann/json.hpp>
#include <cstdlib>
#include <string>
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 <random> header needed).
float JitterComponent(int seed) {
// Returns a value in [-1, 1] cheaply.
return static_cast<float>((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<ILogger> 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<std::string>("q3.current_weapon", "weapon_machinegun");
const uint32_t frame = static_cast<uint32_t>(context.GetDouble("loop.iteration", 0.0));
uint32_t lastFire = context.Get<uint32_t>("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<nlohmann::json>("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<uint32_t>("q3.weapon_last_fire_frame", fireFrame);
context.Set<uint32_t>("q3.weapon_flash_until_frame", fireFrame + 4u);
context.Set<bool>("q3.last_shot_hit", false);
context.Set("q3.player_ammo", ammo);
// --- Gather camera info ---
auto cameraState = context.Get<nlohmann::json>("camera.state", nlohmann::json::object());
auto* world = context.Get<btDiscreteDynamicsWorld*>("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<float>(), posArr[1].get<float>(), posArr[2].get<float>());
const glm::vec3 forward(fwdArr[0].get<float>(), fwdArr[1].get<float>(), fwdArr[2].get<float>());
const glm::vec3 fwdN = glm::normalize(forward);
const btVector3 btFrom(origin.x, origin.y, origin.z);
int pendingDamage = context.Get<int>("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<sdl3cpp::q3::MissileList>("q3.missiles", nullptr);
if (!missiles) {
missiles = std::make_shared<std::vector<sdl3cpp::q3::Q3Missile>>();
}
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<bool>("q3.last_shot_hit", true);
context.Set<uint32_t>("q3.hit_marker_until_frame", fireFrame + 10u);
context.Set<int>("q3.pending_damage", pendingDamage);
}
if (logger_) logger_->Info("q3.weapon.fire: fired " + weapon);
}
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,59 @@
#include "services/interfaces/workflow/quake3/workflow_q3_weapon_select_step.hpp"
#include <nlohmann/json.hpp>
#include <array>
#include <string>
namespace sdl3cpp::services::impl {
namespace {
const std::array<const char*, 9> 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<ILogger> 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<nlohmann::json>("q3.inventory", nlohmann::json::object());
// Gauntlet and machinegun are always available.
inventory["weapon_gauntlet"] = true;
inventory["weapon_machinegun"] = true;
std::string current = context.Get<std::string>("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<std::string>("q3.current_weapon", current);
}
} // namespace sdl3cpp::services::impl
@@ -1,36 +1,13 @@
#include "services/interfaces/workflow/quake3/workflow_q3_weapon_update_step.hpp"
#include <btBulletDynamicsCommon.h>
#include <glm/glm.hpp>
#include <nlohmann/json.hpp>
#include <array>
#include <string>
// 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<const char*, 9> 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<ILogger> 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<nlohmann::json>("q3.inventory", nlohmann::json::object());
inventory["weapon_gauntlet"] = true;
inventory["weapon_machinegun"] = true;
std::string current = context.Get<std::string>("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<std::string>("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<uint32_t>(context.GetDouble("loop.iteration", 0.0));
uint32_t lastFire = context.Get<uint32_t>("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<uint32_t>("q3.weapon_last_fire_frame", lastFire);
context.Set<uint32_t>("q3.weapon_flash_until_frame", lastFire + 4u);
context.Set<int>("q3.shots_fired", context.Get<int>("q3.shots_fired", 0) + 1);
context.Set<bool>("q3.last_shot_hit", false);
auto* world = context.Get<btDiscreteDynamicsWorld*>("physics_world", nullptr);
auto cameraState = context.Get<nlohmann::json>("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<float>(), pos[1].get<float>(), pos[2].get<float>());
btVector3 dir(front[0].get<float>(), front[1].get<float>(), front[2].get<float>());
btVector3 to = from + dir.normalized() * 120.0f;
btCollisionWorld::ClosestRayResultCallback hit(from, to);
world->rayTest(from, to, hit);
context.Set<bool>("q3.last_shot_hit", hit.hasHit());
if (hit.hasHit()) {
context.Set<uint32_t>("q3.hit_marker_until_frame", lastFire + 10u);
context.Set<int>("q3.damage_done", context.Get<int>("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<std::string>("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
@@ -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<IWorkflowStepRegistry> reg
registry->RegisterStep(std::make_shared<WorkflowQ3BotsSpawnStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3BotsUpdateStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3BotsDrawStep>(logger_));
count += 29;
// Q3 pmove
registry->RegisterStep(std::make_shared<WorkflowQ3PmCrouchStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3PmGroundStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3PmFrictionStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3PmAccelerateStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3PmJumpStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3PmSlideMoveStep>(logger_));
// Q3 weapons + missiles
registry->RegisterStep(std::make_shared<WorkflowQ3WeaponSelectStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3WeaponFireStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3MissilesMoveStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3MissilesImpactStep>(logger_));
// Q3 damage + pickups + ammo
registry->RegisterStep(std::make_shared<WorkflowQ3AmmoInitStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3DamageApplyStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3BotsDamageStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3PlayerDeathCheckStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3PlayerRespawnStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3PickupsTouchStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3PickupsRespawnStep>(logger_));
// Q3 movers + triggers + nav
registry->RegisterStep(std::make_shared<WorkflowQ3MoversInitStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3MoversUpdateStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3TriggersCheckStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3TriggersApplyStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3NavBuildStep>(logger_));
count += 55;
// ── Texture ───────────────────────────────────────────────
registry->RegisterStep(std::make_shared<WorkflowTextureLoadStep>(logger_));
@@ -0,0 +1,28 @@
#pragma once
#include <glm/glm.hpp>
#include <string>
#include <vector>
#include <memory>
#include <cstdint>
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<std::vector<Q3Missile>>;
// context key: "q3.missiles" (MissileList)
} // namespace sdl3cpp::q3
@@ -0,0 +1,26 @@
#pragma once
#include <glm/glm.hpp>
#include <string>
#include <vector>
#include <memory>
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<std::vector<Q3Mover>>;
// context key: "q3.movers" (MoverList)
} // namespace sdl3cpp::q3
@@ -0,0 +1,13 @@
#pragma once
#include <glm/glm.hpp>
#include <vector>
#include <memory>
namespace sdl3cpp::q3 {
struct NavNode { glm::vec3 pos; std::vector<int> neighbors; };
struct NavGraph { std::vector<NavNode> nodes; };
using NavGraphPtr = std::shared_ptr<NavGraph>;
// context key: "q3.nav_graph" (NavGraphPtr)
} // namespace sdl3cpp::q3
@@ -0,0 +1,107 @@
#pragma once
#include <glm/glm.hpp>
#include <btBulletDynamicsCommon.h>
#include <BulletCollision/CollisionDispatch/btCollisionWorld.h>
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
@@ -0,0 +1,29 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,34 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,36 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -3,6 +3,7 @@
#include "services/interfaces/i_logger.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <SDL3/SDL_gpu.h>
#include <glm/glm.hpp>
#include <memory>
#include <string>
@@ -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<ILogger> 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
@@ -0,0 +1,47 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <glm/glm.hpp>
#include <memory>
#include <unordered_map>
#include <cstdint>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
// Tracks the previous-frame origin of each missile by ID.
std::unordered_map<uint32_t, glm::vec3> prevPositions_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,30 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,28 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,28 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,30 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,33 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,46 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,36 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,40 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,34 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
#include <string>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,35 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
#include <string>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,33 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
#include <string>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,31 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
#include <string>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,34 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
#include <string>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,40 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
#include <string>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,34 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,29 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,47 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
uint32_t nextMissileId_{1};
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,32 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
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<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl