Add Quake 3 BSP entity interactions

This commit is contained in:
2026-05-02 21:04:26 +01:00
parent 9ec17a5302
commit 48b6f6ce76
7 changed files with 399 additions and 27 deletions
+2 -1
View File
@@ -285,6 +285,7 @@ if(BUILD_SDL3_APP)
src/services/impl/workflow/geometry/workflow_geometry_cube_generate_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
src/services/impl/workflow/rendering/workflow_bsp_extract_textures_step.cpp
src/services/impl/workflow/rendering/workflow_bsp_lightmap_atlas_step.cpp
src/services/impl/workflow/rendering/workflow_bsp_load_step.cpp
@@ -394,4 +395,4 @@ if(BUILD_SDL3_APP)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/packages")
file(COPY "${CMAKE_CURRENT_SOURCE_DIR}/packages" DESTINATION "${CMAKE_CURRENT_BINARY_DIR}")
endif()
endif()
endif()
@@ -39,6 +39,12 @@
"typeVersion": 1,
"position": [500, 0]
},
{
"id": "bsp_entities_update",
"type": "bsp.entities.update",
"typeVersion": 1,
"position": [550, 0]
},
{
"id": "camera_update",
"type": "camera.fps.update",
@@ -129,7 +135,8 @@
"input_poll": { "main": { "0": [{ "node": "physics_move", "type": "main", "index": 0 }] } },
"physics_move": { "main": { "0": [{ "node": "physics_step", "type": "main", "index": 0 }] } },
"physics_step": { "main": { "0": [{ "node": "sync_transforms", "type": "main", "index": 0 }] } },
"sync_transforms": { "main": { "0": [{ "node": "camera_update", "type": "main", "index": 0 }] } },
"sync_transforms": { "main": { "0": [{ "node": "bsp_entities_update", "type": "main", "index": 0 }] } },
"bsp_entities_update": { "main": { "0": [{ "node": "camera_update", "type": "main", "index": 0 }] } },
"camera_update": { "main": { "0": [{ "node": "render_prepare", "type": "main", "index": 0 }] } },
"render_prepare": { "main": { "0": [{ "node": "frame_begin", "type": "main", "index": 0 }] } },
"frame_begin": { "main": { "0": [{ "node": "draw_map", "type": "main", "index": 0 }] } },
@@ -0,0 +1,140 @@
#include "services/interfaces/workflow/rendering/workflow_bsp_entity_update_step.hpp"
#include <btBulletDynamicsCommon.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cmath>
#include <string>
namespace sdl3cpp::services::impl {
namespace {
bool HasPrefix(const std::string& value, const std::string& prefix) {
return value.rfind(prefix, 0) == 0;
}
bool IsPickupClass(const std::string& classname) {
return HasPrefix(classname, "weapon_") ||
HasPrefix(classname, "ammo_") ||
HasPrefix(classname, "item_") ||
HasPrefix(classname, "holdable_");
}
bool ReadVec3(const nlohmann::json& value, btVector3& out) {
if (!value.is_array() || value.size() != 3) return false;
out = btVector3(value[0].get<float>(), value[1].get<float>(), value[2].get<float>());
return true;
}
bool InBounds(const btVector3& p, const nlohmann::json& bounds, float pad) {
if (!bounds.is_object() || !bounds.contains("min") || !bounds.contains("max")) return false;
btVector3 mn, mx;
if (!ReadVec3(bounds["min"], mn) || !ReadVec3(bounds["max"], mx)) return false;
return p.x() >= mn.x() - pad && p.x() <= mx.x() + pad &&
p.y() >= mn.y() - pad && p.y() <= mx.y() + pad &&
p.z() >= mn.z() - pad && p.z() <= mx.z() + pad;
}
void TeleportBody(btRigidBody* body, const btVector3& dest) {
btTransform xform;
xform.setIdentity();
xform.setOrigin(dest);
body->setWorldTransform(xform);
if (body->getMotionState()) body->getMotionState()->setWorldTransform(xform);
body->setLinearVelocity(btVector3(0, 0, 0));
body->setAngularVelocity(btVector3(0, 0, 0));
body->clearForces();
body->activate(true);
}
btVector3 JumpPadVelocity(const btVector3& from, const btVector3& target) {
constexpr float kGravity = 9.81f;
const btVector3 delta = target - from;
const float horiz = std::sqrt(delta.x() * delta.x() + delta.z() * delta.z());
const float t = std::clamp(horiz / 18.0f, 0.55f, 1.20f);
return btVector3(delta.x() / t,
(delta.y() + 0.5f * kGravity * t * t) / t,
delta.z() / t);
}
} // namespace
WorkflowBspEntityUpdateStep::WorkflowBspEntityUpdateStep(std::shared_ptr<ILogger> logger)
: logger_(std::move(logger)) {}
std::string WorkflowBspEntityUpdateStep::GetPluginId() const {
return "bsp.entities.update";
}
void WorkflowBspEntityUpdateStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) {
const auto* entities = context.TryGet<nlohmann::json>("bsp.entities");
if (!entities || !entities->is_array()) return;
const std::string playerName = context.GetString("physics_player_body", "");
if (playerName.empty()) return;
auto* body = context.Get<btRigidBody*>("physics_body_" + playerName, nullptr);
if (!body) return;
btTransform xform;
body->getMotionState()->getWorldTransform(xform);
const btVector3 playerPos = xform.getOrigin();
const uint32_t frame = static_cast<uint32_t>(context.GetDouble("loop.iteration", 0.0));
auto collected = context.Get<nlohmann::json>("q3.collected", nlohmann::json::object());
auto inventory = context.Get<nlohmann::json>("q3.inventory", nlohmann::json::object());
auto cooldowns = context.Get<nlohmann::json>("q3.trigger_cooldowns", nlohmann::json::object());
auto setInventoryFlag = [&](const std::string& key) {
inventory[key] = true;
if (HasPrefix(key, "weapon_")) {
context.Set<std::string>("q3.current_weapon", key);
}
};
for (const auto& ent : *entities) {
const std::string classname = ent.value("classname", std::string{});
const std::string id = ent.value("id", std::string{});
if (IsPickupClass(classname)) {
if (id.empty() || collected.value(id, false)) continue;
btVector3 itemPos;
if (!ent.contains("position") || !ReadVec3(ent["position"], itemPos)) continue;
if ((itemPos - playerPos).length2() > 1.2f * 1.2f) continue;
collected[id] = true;
setInventoryFlag(classname);
if (logger_) logger_->Info("bsp.entities.update: picked up " + classname);
continue;
}
if (classname != "trigger_push" && classname != "trigger_teleport") continue;
if (!ent.contains("bounds") || !InBounds(playerPos, ent["bounds"], 0.15f)) continue;
const uint32_t lastFrame = cooldowns.value(id, 0u);
const uint32_t cooldownFrames = classname == "trigger_teleport" ? 45u : 15u;
if (lastFrame != 0u && frame < lastFrame + cooldownFrames) continue;
cooldowns[id] = frame == 0u ? 1u : frame;
btVector3 target;
if (!ent.contains("target_position") || !ReadVec3(ent["target_position"], target)) continue;
if (classname == "trigger_teleport") {
target += btVector3(0, 1.0f, 0);
TeleportBody(body, target);
if (logger_) logger_->Info("bsp.entities.update: teleported player via " + id);
} else {
body->setLinearVelocity(JumpPadVelocity(playerPos, target));
body->activate(true);
if (logger_) logger_->Info("bsp.entities.update: jump pad launch via " + id);
}
}
context.Set("q3.collected", collected);
context.Set("q3.inventory", inventory);
context.Set("q3.trigger_cooldowns", cooldowns);
}
} // namespace sdl3cpp::services::impl
@@ -3,14 +3,138 @@
#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <array>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <limits>
#include <map>
#include <memory>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
namespace sdl3cpp::services::impl {
namespace {
bool HasPrefix(const std::string& value, const std::string& prefix) {
return value.rfind(prefix, 0) == 0;
}
bool IsPickupClass(const std::string& classname) {
return HasPrefix(classname, "weapon_") ||
HasPrefix(classname, "ammo_") ||
HasPrefix(classname, "item_") ||
HasPrefix(classname, "holdable_");
}
void SkipWhitespace(const std::string& text, size_t& pos) {
while (pos < text.size() && std::isspace(static_cast<unsigned char>(text[pos]))) ++pos;
}
bool ReadQuotedToken(const std::string& text, size_t& pos, std::string& out) {
SkipWhitespace(text, pos);
if (pos >= text.size() || text[pos] != '"') return false;
++pos;
out.clear();
while (pos < text.size()) {
const char c = text[pos++];
if (c == '"') return true;
if (c == '\\' && pos < text.size()) {
out.push_back(text[pos++]);
} else {
out.push_back(c);
}
}
return false;
}
std::vector<std::map<std::string, std::string>> ParseEntityLump(const std::string& entities) {
std::vector<std::map<std::string, std::string>> parsed;
size_t pos = 0;
while (pos < entities.size()) {
SkipWhitespace(entities, pos);
if (pos >= entities.size()) break;
if (entities[pos] != '{') {
++pos;
continue;
}
++pos;
std::map<std::string, std::string> values;
while (pos < entities.size()) {
SkipWhitespace(entities, pos);
if (pos >= entities.size()) break;
if (entities[pos] == '}') {
++pos;
break;
}
std::string key;
std::string value;
if (!ReadQuotedToken(entities, pos, key) || !ReadQuotedToken(entities, pos, value)) {
break;
}
values[key] = value;
}
if (!values.empty()) parsed.push_back(std::move(values));
}
return parsed;
}
bool ParseVec3(const std::string& text, float& x, float& y, float& z) {
return std::sscanf(text.c_str(), "%f %f %f", &x, &y, &z) == 3;
}
std::array<float, 3> ConvertQ3Point(float qx, float qy, float qz, float scale) {
return {qx * scale, qz * scale, -qy * scale};
}
nlohmann::json PointJson(const std::array<float, 3>& p) {
return nlohmann::json::array({p[0], p[1], p[2]});
}
nlohmann::json ConvertModelBounds(const BspModel& model, float scale) {
std::array<float, 3> mn{
std::numeric_limits<float>::max(),
std::numeric_limits<float>::max(),
std::numeric_limits<float>::max()
};
std::array<float, 3> mx{
std::numeric_limits<float>::lowest(),
std::numeric_limits<float>::lowest(),
std::numeric_limits<float>::lowest()
};
for (int x = 0; x < 2; ++x) {
for (int y = 0; y < 2; ++y) {
for (int z = 0; z < 2; ++z) {
const auto p = ConvertQ3Point(x ? model.maxs[0] : model.mins[0],
y ? model.maxs[1] : model.mins[1],
z ? model.maxs[2] : model.mins[2],
scale);
for (int axis = 0; axis < 3; ++axis) {
mn[axis] = std::min(mn[axis], p[axis]);
mx[axis] = std::max(mx[axis], p[axis]);
}
}
}
}
return nlohmann::json{{"min", PointJson(mn)}, {"max", PointJson(mx)}};
}
} // namespace
WorkflowBspParseSpawnStep::WorkflowBspParseSpawnStep(std::shared_ptr<ILogger> logger)
: logger_(std::move(logger)) {}
@@ -29,39 +153,97 @@ void WorkflowBspParseSpawnStep::Execute(const WorkflowStepDefinition& step, Work
auto* lumps = reinterpret_cast<const BspLump*>(bspData.data() + sizeof(BspHeader));
const auto& entLump = lumps[LUMP_ENTITIES];
std::string entities(reinterpret_cast<const char*>(bspData.data() + entLump.offset), entLump.length);
const auto parsedEntities = ParseEntityLump(entities);
std::vector<BspModel> models;
const auto& modelLump = lumps[LUMP_MODELS];
if (modelLump.length >= 0 && modelLump.offset >= 0 &&
static_cast<size_t>(modelLump.offset + modelLump.length) <= bspData.size()) {
const auto* modelData = reinterpret_cast<const BspModel*>(bspData.data() + modelLump.offset);
const size_t modelCount = static_cast<size_t>(modelLump.length) / sizeof(BspModel);
models.assign(modelData, modelData + modelCount);
}
float spawnX = 0, spawnY = 5, spawnZ = 0;
float spawnAngle = 0;
nlohmann::json entityJson = nlohmann::json::array();
std::unordered_map<std::string, std::array<float, 3>> targets;
std::map<std::string, int> classCounts;
int pickupCount = 0;
int jumpPadCount = 0;
int teleporterCount = 0;
size_t pos = entities.find("info_player_deathmatch");
if (pos != std::string::npos) {
size_t blockStart = entities.rfind('{', pos);
size_t blockEnd = entities.find('}', pos);
if (blockStart != std::string::npos && blockEnd != std::string::npos) {
std::string block = entities.substr(blockStart, blockEnd - blockStart);
for (size_t i = 0; i < parsedEntities.size(); ++i) {
const auto& values = parsedEntities[i];
nlohmann::json ent = nlohmann::json::object();
for (const auto& [key, value] : values) {
ent[key] = value;
}
size_t oPos = block.find("\"origin\"");
if (oPos != std::string::npos) {
size_t qStart = block.find('"', oPos + 8);
size_t qEnd = block.find('"', qStart + 1);
if (qStart != std::string::npos && qEnd != std::string::npos) {
std::string origin = block.substr(qStart + 1, qEnd - qStart - 1);
float ox = 0, oy = 0, oz = 0;
if (std::sscanf(origin.c_str(), "%f %f %f", &ox, &oy, &oz) == 3) {
spawnX = ox * scale;
spawnY = oz * scale + 1.0f;
spawnZ = -oy * scale;
}
const std::string classname = ent.value("classname", std::string{});
const int classIndex = classCounts[classname]++;
ent["id"] = classname.empty()
? "ent_" + std::to_string(i)
: classname + "_" + std::to_string(classIndex);
auto originIt = values.find("origin");
if (originIt != values.end()) {
float ox = 0, oy = 0, oz = 0;
if (ParseVec3(originIt->second, ox, oy, oz)) {
const auto p = ConvertQ3Point(ox, oy, oz, scale);
ent["position"] = PointJson(p);
auto targetNameIt = values.find("targetname");
if (targetNameIt != values.end()) {
targets[targetNameIt->second] = p;
}
if (classname == "info_player_deathmatch" && spawnY == 5.0f) {
spawnX = p[0];
spawnY = p[1] + 1.0f;
spawnZ = p[2];
}
}
}
size_t aPos = block.find("\"angle\"");
if (aPos != std::string::npos) {
size_t qStart = block.find('"', aPos + 7);
size_t qEnd = block.find('"', qStart + 1);
if (qStart != std::string::npos && qEnd != std::string::npos) {
spawnAngle = std::stof(block.substr(qStart + 1, qEnd - qStart - 1));
}
auto angleIt = values.find("angle");
if (classname == "info_player_deathmatch" && angleIt != values.end()) {
try {
spawnAngle = std::stof(angleIt->second);
} catch (...) {
spawnAngle = 0.0f;
}
}
auto modelIt = values.find("model");
if (modelIt != values.end() && modelIt->second.size() > 1 && modelIt->second[0] == '*') {
const int modelIndex = std::atoi(modelIt->second.c_str() + 1);
if (modelIndex >= 0 && static_cast<size_t>(modelIndex) < models.size()) {
ent["model_index"] = modelIndex;
ent["bounds"] = ConvertModelBounds(models[static_cast<size_t>(modelIndex)], scale);
}
}
if (IsPickupClass(classname)) {
ent["kind"] = "pickup";
++pickupCount;
} else if (classname == "trigger_push") {
ent["kind"] = "jump_pad";
++jumpPadCount;
} else if (classname == "trigger_teleport") {
ent["kind"] = "teleporter";
++teleporterCount;
}
entityJson.push_back(std::move(ent));
}
for (auto& ent : entityJson) {
const std::string target = ent.value("target", std::string{});
if (!target.empty()) {
auto targetIt = targets.find(target);
if (targetIt != targets.end()) {
ent["target_position"] = PointJson(targetIt->second);
}
}
}
@@ -69,11 +251,22 @@ void WorkflowBspParseSpawnStep::Execute(const WorkflowStepDefinition& step, Work
context.Set("bsp.spawn", nlohmann::json{
{"x", spawnX}, {"y", spawnY}, {"z", spawnZ}, {"angle", spawnAngle}
});
context.Set("bsp.entities", entityJson);
context.Set("bsp.entity_counts", nlohmann::json{
{"total", entityJson.size()},
{"pickups", pickupCount},
{"jump_pads", jumpPadCount},
{"teleporters", teleporterCount}
});
if (logger_) {
logger_->Info("bsp.parse_spawn: Spawn at (" + std::to_string(spawnX) + ", " +
std::to_string(spawnY) + ", " + std::to_string(spawnZ) +
") angle=" + std::to_string(spawnAngle));
logger_->Info("bsp.parse_spawn: Parsed " + std::to_string(entityJson.size()) +
" entities (" + std::to_string(pickupCount) + " pickups, " +
std::to_string(jumpPadCount) + " jump pads, " +
std::to_string(teleporterCount) + " teleporters)");
}
}
@@ -47,6 +47,7 @@
#include "services/interfaces/workflow/rendering/workflow_bsp_load_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_bsp_lightmap_atlas_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_bsp_parse_spawn_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_bsp_entity_update_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_bsp_build_geometry_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_bsp_extract_textures_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_bsp_upload_geometry_step.hpp"
@@ -295,6 +296,7 @@ void WorkflowRegistrar::RegisterSteps(std::shared_ptr<IWorkflowStepRegistry> reg
registry->RegisterStep(std::make_shared<WorkflowBspLoadStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowBspLightmapAtlasStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowBspParseSpawnStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowBspEntityUpdateStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowBspBuildGeometryStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowBspExtractTexturesStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowBspUploadGeometryStep>(logger_));
@@ -66,6 +66,15 @@ struct BspPlane {
float dist;
};
struct BspModel {
float mins[3];
float maxs[3];
int32_t firstFace;
int32_t numFaces;
int32_t firstBrush;
int32_t numBrushes;
};
#pragma pack(pop)
// Lump indices
@@ -0,0 +1,20 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include <memory>
namespace sdl3cpp::services::impl {
class WorkflowBspEntityUpdateStep final : public IWorkflowStep {
public:
explicit WorkflowBspEntityUpdateStep(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