mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-05-05 19:19:35 +00:00
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:
@@ -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 100–2100 ms pick a new target
|
||||
// yaw (180° ± 20°) and pitch (±5°), smoothstep-interpolate to it.
|
||||
// Base yaw 180° = head faces directly toward the camera.
|
||||
const uint64_t nowMs = SDL_GetTicks();
|
||||
if (nowMs >= swayEndMs_) {
|
||||
// Pick new random target angles
|
||||
swayStartYaw_ = swayEndYaw_;
|
||||
swayStartPitch_ = swayEndPitch_;
|
||||
swayStartMs_ = swayEndMs_;
|
||||
swayEndMs_ = nowMs + 100 + static_cast<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
|
||||
|
||||
+47
@@ -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
|
||||
+33
@@ -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
|
||||
+36
@@ -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
|
||||
+40
@@ -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
|
||||
+34
@@ -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
|
||||
+29
@@ -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
|
||||
Reference in New Issue
Block a user