From 48b6f6ce768d467a86e25745bf2ad825cd139e2c Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 2 May 2026 21:04:26 +0100 Subject: [PATCH] Add Quake 3 BSP entity interactions --- gameengine/CMakeLists.txt | 3 +- .../packages/quake3/workflows/q3_frame.json | 9 +- .../workflow_bsp_entity_update_step.cpp | 140 ++++++++++ .../workflow_bsp_parse_spawn_step.cpp | 243 ++++++++++++++++-- .../impl/workflow/workflow_registrar.cpp | 2 + .../workflow/rendering/bsp_types.hpp | 9 + .../workflow_bsp_entity_update_step.hpp | 20 ++ 7 files changed, 399 insertions(+), 27 deletions(-) create mode 100644 gameengine/src/services/impl/workflow/rendering/workflow_bsp_entity_update_step.cpp create mode 100644 gameengine/src/services/interfaces/workflow/rendering/workflow_bsp_entity_update_step.hpp diff --git a/gameengine/CMakeLists.txt b/gameengine/CMakeLists.txt index 86342106e..216723e60 100644 --- a/gameengine/CMakeLists.txt +++ b/gameengine/CMakeLists.txt @@ -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() \ No newline at end of file +endif() diff --git a/gameengine/packages/quake3/workflows/q3_frame.json b/gameengine/packages/quake3/workflows/q3_frame.json index 5c5bb45a7..c6e595d4d 100644 --- a/gameengine/packages/quake3/workflows/q3_frame.json +++ b/gameengine/packages/quake3/workflows/q3_frame.json @@ -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 }] } }, diff --git a/gameengine/src/services/impl/workflow/rendering/workflow_bsp_entity_update_step.cpp b/gameengine/src/services/impl/workflow/rendering/workflow_bsp_entity_update_step.cpp new file mode 100644 index 000000000..6de6c25c9 --- /dev/null +++ b/gameengine/src/services/impl/workflow/rendering/workflow_bsp_entity_update_step.cpp @@ -0,0 +1,140 @@ +#include "services/interfaces/workflow/rendering/workflow_bsp_entity_update_step.hpp" + +#include +#include + +#include +#include +#include + +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(), value[1].get(), value[2].get()); + 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 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("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("physics_body_" + playerName, nullptr); + if (!body) return; + + btTransform xform; + body->getMotionState()->getWorldTransform(xform); + const btVector3 playerPos = xform.getOrigin(); + const uint32_t frame = static_cast(context.GetDouble("loop.iteration", 0.0)); + + auto collected = context.Get("q3.collected", nlohmann::json::object()); + auto inventory = context.Get("q3.inventory", nlohmann::json::object()); + auto cooldowns = context.Get("q3.trigger_cooldowns", nlohmann::json::object()); + + auto setInventoryFlag = [&](const std::string& key) { + inventory[key] = true; + if (HasPrefix(key, "weapon_")) { + context.Set("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 diff --git a/gameengine/src/services/impl/workflow/rendering/workflow_bsp_parse_spawn_step.cpp b/gameengine/src/services/impl/workflow/rendering/workflow_bsp_parse_spawn_step.cpp index a76ff80ab..249d83b3b 100644 --- a/gameengine/src/services/impl/workflow/rendering/workflow_bsp_parse_spawn_step.cpp +++ b/gameengine/src/services/impl/workflow/rendering/workflow_bsp_parse_spawn_step.cpp @@ -3,14 +3,138 @@ #include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp" #include +#include +#include +#include #include +#include +#include +#include #include #include #include +#include +#include #include 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(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> ParseEntityLump(const std::string& entities) { + std::vector> 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 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 ConvertQ3Point(float qx, float qy, float qz, float scale) { + return {qx * scale, qz * scale, -qy * scale}; +} + +nlohmann::json PointJson(const std::array& p) { + return nlohmann::json::array({p[0], p[1], p[2]}); +} + +nlohmann::json ConvertModelBounds(const BspModel& model, float scale) { + std::array mn{ + std::numeric_limits::max(), + std::numeric_limits::max(), + std::numeric_limits::max() + }; + std::array mx{ + std::numeric_limits::lowest(), + std::numeric_limits::lowest(), + std::numeric_limits::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 logger) : logger_(std::move(logger)) {} @@ -29,39 +153,97 @@ void WorkflowBspParseSpawnStep::Execute(const WorkflowStepDefinition& step, Work auto* lumps = reinterpret_cast(bspData.data() + sizeof(BspHeader)); const auto& entLump = lumps[LUMP_ENTITIES]; std::string entities(reinterpret_cast(bspData.data() + entLump.offset), entLump.length); + const auto parsedEntities = ParseEntityLump(entities); + + std::vector models; + const auto& modelLump = lumps[LUMP_MODELS]; + if (modelLump.length >= 0 && modelLump.offset >= 0 && + static_cast(modelLump.offset + modelLump.length) <= bspData.size()) { + const auto* modelData = reinterpret_cast(bspData.data() + modelLump.offset); + const size_t modelCount = static_cast(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> targets; + std::map 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(modelIndex) < models.size()) { + ent["model_index"] = modelIndex; + ent["bounds"] = ConvertModelBounds(models[static_cast(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)"); } } diff --git a/gameengine/src/services/impl/workflow/workflow_registrar.cpp b/gameengine/src/services/impl/workflow/workflow_registrar.cpp index a653179e0..92205fb92 100644 --- a/gameengine/src/services/impl/workflow/workflow_registrar.cpp +++ b/gameengine/src/services/impl/workflow/workflow_registrar.cpp @@ -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 reg registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); + registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); registry->RegisterStep(std::make_shared(logger_)); diff --git a/gameengine/src/services/interfaces/workflow/rendering/bsp_types.hpp b/gameengine/src/services/interfaces/workflow/rendering/bsp_types.hpp index af2a83f73..7f4184ffb 100644 --- a/gameengine/src/services/interfaces/workflow/rendering/bsp_types.hpp +++ b/gameengine/src/services/interfaces/workflow/rendering/bsp_types.hpp @@ -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 diff --git a/gameengine/src/services/interfaces/workflow/rendering/workflow_bsp_entity_update_step.hpp b/gameengine/src/services/interfaces/workflow/rendering/workflow_bsp_entity_update_step.hpp new file mode 100644 index 000000000..d21ece428 --- /dev/null +++ b/gameengine/src/services/interfaces/workflow/rendering/workflow_bsp_entity_update_step.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "services/interfaces/i_workflow_step.hpp" +#include "services/interfaces/i_logger.hpp" + +#include + +namespace sdl3cpp::services::impl { + +class WorkflowBspEntityUpdateStep final : public IWorkflowStep { +public: + explicit WorkflowBspEntityUpdateStep(std::shared_ptr logger); + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + +private: + std::shared_ptr logger_; +}; + +} // namespace sdl3cpp::services::impl