diff --git a/CMakeLists.txt b/CMakeLists.txt index 70e1eb0..236a40d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -309,9 +309,14 @@ set(FRAME_WORKFLOW_SOURCES src/services/impl/workflow_frame_render_step.cpp src/services/impl/workflow_frame_audio_step.cpp src/services/impl/workflow_frame_gui_step.cpp + src/services/impl/workflow_soundboard_catalog_scan_step.cpp + src/services/impl/workflow_soundboard_gui_step.cpp + src/services/impl/workflow_soundboard_audio_step.cpp src/services/impl/workflow_validation_checkpoint_step.cpp src/services/impl/frame_workflow_step_registrar.cpp src/services/impl/frame_workflow_service.cpp + src/services/impl/soundboard_state_service.cpp + src/services/impl/soundboard_path_resolver.cpp ) set(MATERIALX_SCRIPT_SOURCES diff --git a/packages/soundboard/assets/soundboard_gui.json b/packages/soundboard/assets/soundboard_gui.json index 2997be3..90fbee8 100644 --- a/packages/soundboard/assets/soundboard_gui.json +++ b/packages/soundboard/assets/soundboard_gui.json @@ -8,20 +8,43 @@ "width": 664, "height": 520 }, + "padding": { + "x": 20, + "y": 20 + }, "colors": { "background": [0.06, 0.07, 0.09, 0.95], "border": [0.35, 0.38, 0.42, 1.0], - "text": [0.96, 0.96, 0.97, 1.0] + "title": [0.96, 0.96, 0.97, 1.0], + "description": [0.7, 0.75, 0.8, 1.0], + "category": [0.9, 0.9, 0.95, 1.0], + "status": [0.6, 0.8, 1.0, 1.0] } }, "buttons": { "width": 300, "height": 36, - "spacing": 12 + "spacing": 12, + "colors": { + "background": [0.2, 0.24, 0.28, 1.0], + "hover": [0.26, 0.3, 0.36, 1.0], + "active": [0.16, 0.22, 0.28, 1.0], + "border": [0.45, 0.52, 0.6, 1.0], + "text": [1.0, 1.0, 1.0, 1.0] + } }, "layout": { "columns": 2, "columnSpacing": 24, - "initialY": 120 + "initialY": 80, + "statusOffsetY": 34 + }, + "typography": { + "titleSize": 24, + "descriptionSize": 14, + "categorySize": 20, + "buttonSize": 16, + "statusSize": 14, + "headerSpacing": 26 } } diff --git a/packages/soundboard/workflows/soundboard_flow.json b/packages/soundboard/workflows/soundboard_flow.json index 38d6912..ae3b58c 100644 --- a/packages/soundboard/workflows/soundboard_flow.json +++ b/packages/soundboard/workflows/soundboard_flow.json @@ -23,11 +23,11 @@ "plugin": "soundboard.gui", "position": [520, -120], "inputs": { - "catalog": "soundboard.catalog", - "layout": "soundboard.layout" + "catalog": "soundboard.catalog" }, "outputs": { - "selection": "soundboard.selection" + "selection": "soundboard.selection", + "gui_commands": "soundboard.gui.commands" } }, { @@ -35,7 +35,6 @@ "plugin": "soundboard.audio", "position": [780, -120], "inputs": { - "catalog": "soundboard.catalog", "selection": "soundboard.selection" }, "outputs": { @@ -47,7 +46,8 @@ "plugin": "frame.render", "position": [520, 120], "inputs": { - "elapsed": "frame.elapsed" + "elapsed": "frame.elapsed", + "gui_commands": "soundboard.gui.commands" } }, { diff --git a/src/app/service_based_app.cpp b/src/app/service_based_app.cpp index 90d64bb..1bfc354 100644 --- a/src/app/service_based_app.cpp +++ b/src/app/service_based_app.cpp @@ -37,6 +37,7 @@ #include "services/impl/logger_service.hpp" #include "services/impl/pipeline_compiler_service.hpp" #include "services/impl/validation_tour_service.hpp" +#include "services/impl/soundboard_state_service.hpp" #include "services/impl/workflow_default_step_registrar.hpp" #include "services/impl/workflow_definition_parser.hpp" #include "services/impl/workflow_executor.hpp" @@ -305,6 +306,9 @@ void ServiceBasedApp::RegisterServices() { registry_.RegisterService( registry_.GetService()); + registry_.RegisterService( + registry_.GetService()); + registry_.RegisterService( registry_.GetService(), registry_.GetService(), @@ -313,7 +317,8 @@ void ServiceBasedApp::RegisterServices() { registry_.GetService(), registry_.GetService(), registry_.GetService(), - registry_.GetService()); + registry_.GetService(), + registry_.GetService()); // Script bridge services registry_.RegisterService( diff --git a/src/services/impl/frame_workflow_service.cpp b/src/services/impl/frame_workflow_service.cpp index 5e84113..6008b89 100644 --- a/src/services/impl/frame_workflow_service.cpp +++ b/src/services/impl/frame_workflow_service.cpp @@ -16,6 +16,7 @@ FrameWorkflowService::FrameWorkflowService(std::shared_ptr logger, std::shared_ptr sceneService, std::shared_ptr renderService, std::shared_ptr validationTourService, + std::shared_ptr soundboardStateService, const std::filesystem::path& templatePath) : registry_(std::make_shared()), executor_(registry_, logger), @@ -34,7 +35,8 @@ FrameWorkflowService::FrameWorkflowService(std::shared_ptr logger, std::move(physicsService), std::move(sceneService), std::move(renderService), - std::move(validationTourService)); + std::move(validationTourService), + std::move(soundboardStateService)); registrar.RegisterUsedSteps(workflow_, registry_); } diff --git a/src/services/impl/frame_workflow_service.hpp b/src/services/impl/frame_workflow_service.hpp index 3b39b36..6357740 100644 --- a/src/services/impl/frame_workflow_service.hpp +++ b/src/services/impl/frame_workflow_service.hpp @@ -8,6 +8,7 @@ #include "../interfaces/i_physics_service.hpp" #include "../interfaces/i_render_coordinator_service.hpp" #include "../interfaces/i_scene_service.hpp" +#include "../interfaces/i_soundboard_state_service.hpp" #include "../interfaces/i_validation_tour_service.hpp" #include "workflow_executor.hpp" @@ -29,6 +30,7 @@ public: std::shared_ptr sceneService, std::shared_ptr renderService, std::shared_ptr validationTourService, + std::shared_ptr soundboardStateService, const std::filesystem::path& templatePath = {}); void ExecuteFrame(float deltaTime, float elapsedTime) override; diff --git a/src/services/impl/frame_workflow_step_registrar.cpp b/src/services/impl/frame_workflow_step_registrar.cpp index 2ec7ab9..426f86a 100644 --- a/src/services/impl/frame_workflow_step_registrar.cpp +++ b/src/services/impl/frame_workflow_step_registrar.cpp @@ -8,6 +8,9 @@ #include "workflow_frame_physics_step.hpp" #include "workflow_frame_render_step.hpp" #include "workflow_frame_scene_step.hpp" +#include "workflow_soundboard_audio_step.hpp" +#include "workflow_soundboard_catalog_scan_step.hpp" +#include "workflow_soundboard_gui_step.hpp" #include "workflow_step_registry.hpp" #include "workflow_validation_checkpoint_step.hpp" @@ -23,7 +26,8 @@ FrameWorkflowStepRegistrar::FrameWorkflowStepRegistrar(std::shared_ptr std::shared_ptr physicsService, std::shared_ptr sceneService, std::shared_ptr renderService, - std::shared_ptr validationTourService) + std::shared_ptr validationTourService, + std::shared_ptr soundboardStateService) : logger_(std::move(logger)), configService_(std::move(configService)), audioService_(std::move(audioService)), @@ -31,7 +35,8 @@ FrameWorkflowStepRegistrar::FrameWorkflowStepRegistrar(std::shared_ptr physicsService_(std::move(physicsService)), sceneService_(std::move(sceneService)), renderService_(std::move(renderService)), - validationTourService_(std::move(validationTourService)) {} + validationTourService_(std::move(validationTourService)), + soundboardStateService_(std::move(soundboardStateService)) {} void FrameWorkflowStepRegistrar::RegisterUsedSteps( const WorkflowDefinition& workflow, @@ -72,6 +77,20 @@ void FrameWorkflowStepRegistrar::RegisterUsedSteps( registry->RegisterStep(std::make_shared( validationTourService_, logger_)); } + if (plugins.contains("soundboard.catalog.scan")) { + registry->RegisterStep(std::make_shared(configService_, logger_)); + } + if (plugins.contains("soundboard.gui")) { + registry->RegisterStep(std::make_shared(inputService_, + configService_, + soundboardStateService_, + logger_)); + } + if (plugins.contains("soundboard.audio")) { + registry->RegisterStep(std::make_shared(audioService_, + soundboardStateService_, + logger_)); + } } } // namespace sdl3cpp::services::impl diff --git a/src/services/impl/frame_workflow_step_registrar.hpp b/src/services/impl/frame_workflow_step_registrar.hpp index 97ac3b8..0bbec5f 100644 --- a/src/services/impl/frame_workflow_step_registrar.hpp +++ b/src/services/impl/frame_workflow_step_registrar.hpp @@ -13,6 +13,7 @@ class IInputService; class IPhysicsService; class IRenderCoordinatorService; class ISceneService; +class ISoundboardStateService; class IValidationTourService; } @@ -27,7 +28,8 @@ public: std::shared_ptr physicsService, std::shared_ptr sceneService, std::shared_ptr renderService, - std::shared_ptr validationTourService); + std::shared_ptr validationTourService, + std::shared_ptr soundboardStateService); void RegisterUsedSteps(const WorkflowDefinition& workflow, const std::shared_ptr& registry) const; @@ -41,6 +43,7 @@ private: std::shared_ptr sceneService_; std::shared_ptr renderService_; std::shared_ptr validationTourService_; + std::shared_ptr soundboardStateService_; }; } // namespace sdl3cpp::services::impl diff --git a/src/services/impl/render_coordinator_service.cpp b/src/services/impl/render_coordinator_service.cpp index 0ee330b..580099b 100644 --- a/src/services/impl/render_coordinator_service.cpp +++ b/src/services/impl/render_coordinator_service.cpp @@ -132,14 +132,22 @@ void RenderCoordinatorService::ConfigureRenderGraphPasses() { } void RenderCoordinatorService::RenderFrame(float time) { - RenderFrameInternal(time, nullptr); + RenderFrameInternal(time, nullptr, nullptr); } void RenderCoordinatorService::RenderFrameWithViewState(float time, const ViewState& viewState) { - RenderFrameInternal(time, &viewState); + RenderFrameInternal(time, &viewState, nullptr); } -void RenderCoordinatorService::RenderFrameInternal(float time, const ViewState* overrideView) { +void RenderCoordinatorService::RenderFrameWithOverrides(float time, + const ViewState* viewState, + const std::vector* guiCommands) { + RenderFrameInternal(time, viewState, guiCommands); +} + +void RenderCoordinatorService::RenderFrameInternal(float time, + const ViewState* overrideView, + const std::vector* guiCommands) { if (logger_) { logger_->Trace("RenderCoordinatorService", "RenderFrame", "time=" + std::to_string(time), "Entering"); } @@ -211,10 +219,14 @@ void RenderCoordinatorService::RenderFrameInternal(float time, const ViewState* ConfigureRenderGraphPasses(); } - if (guiService_ && guiScriptService_ && guiScriptService_->HasGuiCommands()) { - auto guiCommands = guiScriptService_->LoadGuiCommands(); + if (guiService_) { auto extent = graphicsService_->GetSwapchainExtent(); - guiService_->PrepareFrame(guiCommands, extent.first, extent.second); + if (guiCommands) { + guiService_->PrepareFrame(*guiCommands, extent.first, extent.second); + } else if (guiScriptService_ && guiScriptService_->HasGuiCommands()) { + auto scriptCommands = guiScriptService_->LoadGuiCommands(); + guiService_->PrepareFrame(scriptCommands, extent.first, extent.second); + } } if (useLuaScene && sceneScriptService_ && sceneService_) { diff --git a/src/services/impl/render_coordinator_service.hpp b/src/services/impl/render_coordinator_service.hpp index cbf4c3c..c2a3b24 100644 --- a/src/services/impl/render_coordinator_service.hpp +++ b/src/services/impl/render_coordinator_service.hpp @@ -31,6 +31,9 @@ public: void RenderFrame(float time) override; void RenderFrameWithViewState(float time, const ViewState& viewState) override; + void RenderFrameWithOverrides(float time, + const ViewState* viewState, + const std::vector* guiCommands) override; private: @@ -51,7 +54,9 @@ private: bool configFirstLogged_ = false; void ConfigureRenderGraphPasses(); - void RenderFrameInternal(float time, const ViewState* overrideView); + void RenderFrameInternal(float time, + const ViewState* overrideView, + const std::vector* guiCommands); }; } // namespace sdl3cpp::services::impl diff --git a/src/services/impl/soundboard_path_resolver.cpp b/src/services/impl/soundboard_path_resolver.cpp new file mode 100644 index 0000000..425da80 --- /dev/null +++ b/src/services/impl/soundboard_path_resolver.cpp @@ -0,0 +1,34 @@ +#include "soundboard_path_resolver.hpp" + +namespace sdl3cpp::services::impl { +namespace { + +std::filesystem::path FindPackageRoot(const std::filesystem::path& seed) { + std::filesystem::path current = seed; + for (int depth = 0; depth < 6; ++depth) { + const auto candidate = current / "packages" / "soundboard"; + if (std::filesystem::exists(candidate)) { + return current; + } + if (!current.has_parent_path()) { + break; + } + current = current.parent_path(); + } + return seed; +} + +} // namespace + +std::filesystem::path ResolveSoundboardPackageRoot(const std::shared_ptr& configService) { + std::filesystem::path seed = std::filesystem::current_path(); + if (configService) { + const auto scriptPath = configService->GetScriptPath(); + if (!scriptPath.empty()) { + seed = scriptPath.parent_path(); + } + } + return FindPackageRoot(seed) / "packages" / "soundboard"; +} + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/soundboard_path_resolver.hpp b/src/services/impl/soundboard_path_resolver.hpp new file mode 100644 index 0000000..73daa20 --- /dev/null +++ b/src/services/impl/soundboard_path_resolver.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include "../interfaces/i_config_service.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +std::filesystem::path ResolveSoundboardPackageRoot(const std::shared_ptr& configService); + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_frame_render_step.cpp b/src/services/impl/workflow_frame_render_step.cpp index 69ace8b..ecfd673 100644 --- a/src/services/impl/workflow_frame_render_step.cpp +++ b/src/services/impl/workflow_frame_render_step.cpp @@ -32,8 +32,16 @@ void WorkflowFrameRenderStep::Execute(const WorkflowStepDefinition& step, Workfl throw std::runtime_error("frame.render missing view_state input"); } } - if (viewState) { - renderService_->RenderFrameWithViewState(static_cast(*elapsed), *viewState); + const std::vector* guiCommands = nullptr; + auto guiIt = step.inputs.find("gui_commands"); + if (guiIt != step.inputs.end()) { + guiCommands = context.TryGet>(guiIt->second); + if (!guiCommands) { + throw std::runtime_error("frame.render missing gui_commands input"); + } + } + if (viewState || guiCommands) { + renderService_->RenderFrameWithOverrides(static_cast(*elapsed), viewState, guiCommands); } else { renderService_->RenderFrame(static_cast(*elapsed)); } diff --git a/src/services/impl/workflow_soundboard_audio_step.cpp b/src/services/impl/workflow_soundboard_audio_step.cpp new file mode 100644 index 0000000..00a675b --- /dev/null +++ b/src/services/impl/workflow_soundboard_audio_step.cpp @@ -0,0 +1,77 @@ +#include "workflow_soundboard_audio_step.hpp" + +#include "workflow_step_io_resolver.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +WorkflowSoundboardAudioStep::WorkflowSoundboardAudioStep(std::shared_ptr audioService, + std::shared_ptr stateService, + std::shared_ptr logger) + : audioService_(std::move(audioService)), + stateService_(std::move(stateService)), + logger_(std::move(logger)) {} + +std::string WorkflowSoundboardAudioStep::GetPluginId() const { + return "soundboard.audio"; +} + +void WorkflowSoundboardAudioStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { + if (!audioService_) { + throw std::runtime_error("soundboard.audio requires an IAudioService"); + } + + WorkflowStepIoResolver resolver; + const std::string selectionKey = resolver.GetRequiredInputKey(step, "selection"); + const std::string statusKey = resolver.GetRequiredOutputKey(step, "status"); + + const auto* selection = context.TryGet(selectionKey); + if (!selection) { + throw std::runtime_error("soundboard.audio missing selection input"); + } + + std::string status = stateService_ + ? stateService_->GetStatusMessage() + : std::string("Select a clip to play"); + + if (selection->hasSelection && selection->requestId != lastRequestId_) { + lastRequestId_ = selection->requestId; + const std::filesystem::path path = selection->path; + if (path.empty()) { + status = "Audio path missing for selection"; + if (logger_) { + logger_->Error("WorkflowSoundboardAudioStep::Execute: selection path missing"); + } + } else if (!std::filesystem::exists(path)) { + status = "Audio file not found: " + path.string(); + if (logger_) { + logger_->Error("WorkflowSoundboardAudioStep::Execute: audio file not found " + path.string()); + } + } else { + try { + audioService_->PlayEffect(path, false); + status = "Playing \"" + selection->label + "\""; + if (logger_) { + logger_->Trace("WorkflowSoundboardAudioStep", "Execute", + "clip=" + selection->label, + "Audio playback dispatched"); + } + } catch (const std::exception& ex) { + status = "Failed to play \"" + selection->label + "\": " + ex.what(); + if (logger_) { + logger_->Error("WorkflowSoundboardAudioStep::Execute: " + status); + } + } + } + + if (stateService_) { + stateService_->SetStatusMessage(status); + } + } + + context.Set(statusKey, status); +} + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_soundboard_audio_step.hpp b/src/services/impl/workflow_soundboard_audio_step.hpp new file mode 100644 index 0000000..3b67462 --- /dev/null +++ b/src/services/impl/workflow_soundboard_audio_step.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "../interfaces/i_audio_service.hpp" +#include "../interfaces/i_logger.hpp" +#include "../interfaces/i_soundboard_state_service.hpp" +#include "../interfaces/i_workflow_step.hpp" +#include "../interfaces/soundboard_types.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +class WorkflowSoundboardAudioStep final : public IWorkflowStep { +public: + WorkflowSoundboardAudioStep(std::shared_ptr audioService, + std::shared_ptr stateService, + std::shared_ptr logger); + + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + +private: + std::shared_ptr audioService_; + std::shared_ptr stateService_; + std::shared_ptr logger_; + std::uint64_t lastRequestId_ = 0; +}; + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_soundboard_catalog_scan_step.cpp b/src/services/impl/workflow_soundboard_catalog_scan_step.cpp new file mode 100644 index 0000000..3cf1e7f --- /dev/null +++ b/src/services/impl/workflow_soundboard_catalog_scan_step.cpp @@ -0,0 +1,149 @@ +#include "workflow_soundboard_catalog_scan_step.hpp" + +#include "json_config_document_parser.hpp" +#include "soundboard_path_resolver.hpp" +#include "workflow_step_io_resolver.hpp" + +#include +#include +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { +namespace { + +std::string ToLower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; +} + +std::string PrettyClipName(const std::string& fileName) { + std::string base = fileName; + const auto dot = base.find_last_of('.'); + if (dot != std::string::npos) { + base = base.substr(0, dot); + } + for (char& ch : base) { + if (ch == '_' || ch == '-') { + ch = ' '; + } + } + bool capitalize = true; + for (char& ch : base) { + if (std::isspace(static_cast(ch))) { + capitalize = true; + } else if (capitalize) { + ch = static_cast(std::toupper(static_cast(ch))); + capitalize = false; + } else { + ch = static_cast(std::tolower(static_cast(ch))); + } + } + return base; +} + +std::vector LoadClips(const std::filesystem::path& directory) { + std::vector clips; + if (!std::filesystem::exists(directory)) { + return clips; + } + for (const auto& entry : std::filesystem::directory_iterator(directory)) { + if (!entry.is_regular_file()) { + continue; + } + const auto extension = ToLower(entry.path().extension().string()); + if (extension != ".ogg" && extension != ".wav") { + continue; + } + const std::string fileName = entry.path().filename().string(); + SoundboardClip clip{}; + clip.id = fileName; + clip.label = PrettyClipName(fileName); + clip.path = entry.path(); + clips.push_back(std::move(clip)); + } + std::sort(clips.begin(), clips.end(), + [](const SoundboardClip& a, const SoundboardClip& b) { + return ToLower(a.id) < ToLower(b.id); + }); + return clips; +} + +SoundboardCatalog BuildCatalog(const rapidjson::Document& document, + const std::filesystem::path& packageRoot) { + if (!document.HasMember("categories") || !document["categories"].IsArray()) { + throw std::runtime_error("soundboard catalog requires a categories array"); + } + SoundboardCatalog catalog{}; + catalog.packageRoot = packageRoot; + const auto& categories = document["categories"]; + catalog.categories.reserve(categories.Size()); + for (const auto& categoryValue : categories.GetArray()) { + if (!categoryValue.IsObject()) { + throw std::runtime_error("soundboard catalog categories must be objects"); + } + if (!categoryValue.HasMember("id") || !categoryValue["id"].IsString()) { + throw std::runtime_error("soundboard catalog category requires string id"); + } + if (!categoryValue.HasMember("name") || !categoryValue["name"].IsString()) { + throw std::runtime_error("soundboard catalog category requires string name"); + } + if (!categoryValue.HasMember("path") || !categoryValue["path"].IsString()) { + throw std::runtime_error("soundboard catalog category requires string path"); + } + SoundboardCategory category{}; + category.id = categoryValue["id"].GetString(); + category.name = categoryValue["name"].GetString(); + category.basePath = packageRoot / categoryValue["path"].GetString(); + category.clips = LoadClips(category.basePath); + catalog.categories.push_back(std::move(category)); + } + return catalog; +} + +} // namespace + +WorkflowSoundboardCatalogScanStep::WorkflowSoundboardCatalogScanStep( + std::shared_ptr configService, + std::shared_ptr logger) + : configService_(std::move(configService)), + logger_(std::move(logger)) {} + +std::string WorkflowSoundboardCatalogScanStep::GetPluginId() const { + return "soundboard.catalog.scan"; +} + +void WorkflowSoundboardCatalogScanStep::Execute(const WorkflowStepDefinition& step, + WorkflowContext& context) { + WorkflowStepIoResolver resolver; + const std::string outputKey = resolver.GetRequiredOutputKey(step, "catalog"); + + if (!cachedCatalog_) { + cachedCatalog_ = LoadCatalog(); + if (logger_) { + std::size_t clipCount = 0; + for (const auto& category : cachedCatalog_->categories) { + clipCount += category.clips.size(); + } + logger_->Trace("WorkflowSoundboardCatalogScanStep", "Execute", + "categories=" + std::to_string(cachedCatalog_->categories.size()) + + ", clips=" + std::to_string(clipCount), + "Catalog scanned"); + } + } + + context.Set(outputKey, *cachedCatalog_); +} + +SoundboardCatalog WorkflowSoundboardCatalogScanStep::LoadCatalog() const { + const std::filesystem::path packageRoot = ResolveSoundboardPackageRoot(configService_); + const std::filesystem::path catalogPath = packageRoot / "assets" / "audio_catalog.json"; + json_config::JsonConfigDocumentParser parser; + auto document = parser.Parse(catalogPath, "soundboard audio catalog"); + return BuildCatalog(document, packageRoot); +} + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_soundboard_catalog_scan_step.hpp b/src/services/impl/workflow_soundboard_catalog_scan_step.hpp new file mode 100644 index 0000000..2c8d175 --- /dev/null +++ b/src/services/impl/workflow_soundboard_catalog_scan_step.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "../interfaces/i_config_service.hpp" +#include "../interfaces/i_logger.hpp" +#include "../interfaces/i_workflow_step.hpp" +#include "../interfaces/soundboard_types.hpp" + +#include +#include + +namespace sdl3cpp::services::impl { + +class WorkflowSoundboardCatalogScanStep final : public IWorkflowStep { +public: + WorkflowSoundboardCatalogScanStep(std::shared_ptr configService, + std::shared_ptr logger); + + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + +private: + SoundboardCatalog LoadCatalog() const; + + std::shared_ptr configService_; + std::shared_ptr logger_; + std::optional cachedCatalog_; +}; + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_soundboard_gui_step.cpp b/src/services/impl/workflow_soundboard_gui_step.cpp new file mode 100644 index 0000000..0f62a24 --- /dev/null +++ b/src/services/impl/workflow_soundboard_gui_step.cpp @@ -0,0 +1,334 @@ +#include "workflow_soundboard_gui_step.hpp" + +#include "json_config_document_parser.hpp" +#include "soundboard_path_resolver.hpp" +#include "workflow_step_io_resolver.hpp" + +#include + +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { +namespace { + +GuiColor ReadColor(const rapidjson::Value& value, const GuiColor& fallback) { + if (!value.IsArray() || (value.Size() != 3 && value.Size() != 4)) { + return fallback; + } + GuiColor color = fallback; + color.r = static_cast(value[0].GetDouble()); + color.g = static_cast(value[1].GetDouble()); + color.b = static_cast(value[2].GetDouble()); + color.a = value.Size() == 4 ? static_cast(value[3].GetDouble()) : fallback.a; + return color; +} + +void ReadColorField(const rapidjson::Value& object, const char* name, GuiColor& target) { + if (!object.HasMember(name)) { + return; + } + const auto& value = object[name]; + if (!value.IsArray()) { + throw std::runtime_error(std::string("soundboard.gui: '") + name + "' must be an array"); + } + target = ReadColor(value, target); +} + +bool ReadFloatField(const rapidjson::Value& object, const char* name, float& target) { + if (!object.HasMember(name)) { + return false; + } + const auto& value = object[name]; + if (!value.IsNumber()) { + throw std::runtime_error(std::string("soundboard.gui: '") + name + "' must be a number"); + } + target = static_cast(value.GetDouble()); + return true; +} + +bool ReadIntField(const rapidjson::Value& object, const char* name, int& target) { + if (!object.HasMember(name)) { + return false; + } + const auto& value = object[name]; + if (!value.IsInt()) { + throw std::runtime_error(std::string("soundboard.gui: '") + name + "' must be an integer"); + } + target = value.GetInt(); + return true; +} + +bool ReadStringField(const rapidjson::Value& object, const char* name, std::string& target) { + if (!object.HasMember(name)) { + return false; + } + const auto& value = object[name]; + if (!value.IsString()) { + throw std::runtime_error(std::string("soundboard.gui: '") + name + "' must be a string"); + } + target = value.GetString(); + return true; +} + +GuiCommand BuildRectCommand(const GuiCommand::RectData& rect, const GuiColor& color, + const GuiColor& border, float borderWidth) { + GuiCommand command{}; + command.type = GuiCommand::Type::Rect; + command.rect = rect; + command.color = color; + command.borderColor = border; + command.borderWidth = borderWidth; + return command; +} + +GuiCommand BuildTextCommand(const std::string& text, float x, float y, float fontSize, + const GuiColor& color, const std::string& alignX, + const std::string& alignY) { + GuiCommand command{}; + command.type = GuiCommand::Type::Text; + command.text = text; + command.rect.x = x; + command.rect.y = y; + command.fontSize = fontSize; + command.color = color; + command.alignX = alignX; + command.alignY = alignY; + return command; +} + +bool IsMouseOver(const GuiCommand::RectData& rect, float x, float y) { + return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; +} + +} // namespace + +WorkflowSoundboardGuiStep::WorkflowSoundboardGuiStep(std::shared_ptr inputService, + std::shared_ptr configService, + std::shared_ptr stateService, + std::shared_ptr logger) + : inputService_(std::move(inputService)), + configService_(std::move(configService)), + stateService_(std::move(stateService)), + logger_(std::move(logger)) {} + +std::string WorkflowSoundboardGuiStep::GetPluginId() const { + return "soundboard.gui"; +} + +void WorkflowSoundboardGuiStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) { + if (!inputService_) { + throw std::runtime_error("soundboard.gui requires an IInputService"); + } + EnsureConfigLoaded(); + + WorkflowStepIoResolver resolver; + const std::string catalogKey = resolver.GetRequiredInputKey(step, "catalog"); + const std::string selectionKey = resolver.GetRequiredOutputKey(step, "selection"); + const std::string commandsKey = resolver.GetRequiredOutputKey(step, "gui_commands"); + + const auto* catalog = context.TryGet(catalogKey); + if (!catalog) { + throw std::runtime_error("soundboard.gui missing catalog input"); + } + + const std::string status = stateService_ + ? stateService_->GetStatusMessage() + : std::string("Select a clip to play"); + + std::optional selection; + std::vector commands = BuildCommands(*catalog, *cachedConfig_, status, selection); + const std::size_t commandCount = commands.size(); + + SoundboardSelection output{}; + if (selection) { + output = *selection; + if (logger_) { + logger_->Trace("WorkflowSoundboardGuiStep", "Execute", + "selection=" + output.label + + ", requestId=" + std::to_string(output.requestId), + "Soundboard selection updated"); + } + } + context.Set(selectionKey, output); + context.Set(commandsKey, std::move(commands)); + if (logger_) { + logger_->Trace("WorkflowSoundboardGuiStep", "Execute", + "commands=" + std::to_string(commandCount), + "GUI commands prepared"); + } +} + +void WorkflowSoundboardGuiStep::EnsureConfigLoaded() { + if (!cachedConfig_) { + cachedConfig_ = LoadConfig(); + } +} + +WorkflowSoundboardGuiStep::SoundboardGuiConfig WorkflowSoundboardGuiStep::LoadConfig() const { + const std::filesystem::path packageRoot = ResolveSoundboardPackageRoot(configService_); + const std::filesystem::path guiPath = packageRoot / "assets" / "soundboard_gui.json"; + json_config::JsonConfigDocumentParser parser; + auto document = parser.Parse(guiPath, "soundboard gui config"); + + SoundboardGuiConfig config{}; + if (document.HasMember("panel") && document["panel"].IsObject()) { + const auto& panel = document["panel"]; + ReadStringField(panel, "title", config.title); + ReadStringField(panel, "description", config.description); + if (panel.HasMember("rect") && panel["rect"].IsObject()) { + const auto& rect = panel["rect"]; + ReadFloatField(rect, "x", config.panelRect.x); + ReadFloatField(rect, "y", config.panelRect.y); + ReadFloatField(rect, "width", config.panelRect.width); + ReadFloatField(rect, "height", config.panelRect.height); + } + if (panel.HasMember("padding") && panel["padding"].IsObject()) { + const auto& padding = panel["padding"]; + ReadFloatField(padding, "x", config.panelPaddingX); + ReadFloatField(padding, "y", config.panelPaddingY); + } + if (panel.HasMember("colors") && panel["colors"].IsObject()) { + const auto& colors = panel["colors"]; + ReadColorField(colors, "background", config.panelColor); + ReadColorField(colors, "border", config.panelBorder); + ReadColorField(colors, "title", config.titleColor); + ReadColorField(colors, "description", config.descriptionColor); + ReadColorField(colors, "category", config.categoryColor); + ReadColorField(colors, "status", config.statusColor); + } + } + if (document.HasMember("buttons") && document["buttons"].IsObject()) { + const auto& buttons = document["buttons"]; + ReadFloatField(buttons, "width", config.buttonWidth); + ReadFloatField(buttons, "height", config.buttonHeight); + ReadFloatField(buttons, "spacing", config.buttonSpacing); + if (buttons.HasMember("colors") && buttons["colors"].IsObject()) { + const auto& colors = buttons["colors"]; + ReadColorField(colors, "text", config.buttonTextColor); + ReadColorField(colors, "border", config.buttonBorder); + ReadColorField(colors, "background", config.buttonBackground); + ReadColorField(colors, "hover", config.buttonHover); + ReadColorField(colors, "active", config.buttonActive); + } + } + if (document.HasMember("layout") && document["layout"].IsObject()) { + const auto& layout = document["layout"]; + ReadIntField(layout, "columns", config.columns); + ReadFloatField(layout, "columnSpacing", config.columnSpacing); + ReadFloatField(layout, "initialY", config.columnStartY); + ReadFloatField(layout, "statusOffsetY", config.statusOffsetY); + } + if (document.HasMember("typography") && document["typography"].IsObject()) { + const auto& type = document["typography"]; + ReadFloatField(type, "titleSize", config.titleFontSize); + ReadFloatField(type, "descriptionSize", config.descriptionFontSize); + ReadFloatField(type, "categorySize", config.categoryFontSize); + ReadFloatField(type, "buttonSize", config.buttonFontSize); + ReadFloatField(type, "statusSize", config.statusFontSize); + ReadFloatField(type, "headerSpacing", config.headerSpacing); + } + return config; +} + +std::vector WorkflowSoundboardGuiStep::BuildCommands( + const SoundboardCatalog& catalog, + const SoundboardGuiConfig& config, + const std::string& statusMessage, + std::optional& selectionOut) { + std::vector commands; + + const auto& rect = config.panelRect; + commands.push_back(BuildRectCommand(rect, config.panelColor, config.panelBorder, 1.0f)); + + const float titleX = rect.x + config.panelPaddingX; + const float titleY = rect.y + config.panelPaddingY; + commands.push_back(BuildTextCommand(config.title, titleX, titleY, config.titleFontSize, + config.titleColor, "left", "top")); + commands.push_back(BuildTextCommand(config.description, titleX, + titleY + config.headerSpacing, + config.descriptionFontSize, config.descriptionColor, + "left", "top")); + + const auto inputState = inputService_->GetState(); + const float mouseX = inputState.mouseX; + const float mouseY = inputState.mouseY; + const bool mouseDown = inputService_->IsMouseButtonPressed(SDL_BUTTON_LEFT); + const bool justPressed = mouseDown && !lastMouseDown_; + const bool justReleased = !mouseDown && lastMouseDown_; + + const float columnStartY = rect.y + config.columnStartY; + int columnCount = std::max(1, config.columns); + for (size_t index = 0; index < catalog.categories.size(); ++index) { + const SoundboardCategory& category = catalog.categories[index]; + int columnIndex = static_cast(index % static_cast(columnCount)); + float columnX = rect.x + config.panelPaddingX + + columnIndex * (config.buttonWidth + config.columnSpacing); + commands.push_back(BuildTextCommand(category.name, columnX, columnStartY, + config.categoryFontSize, config.categoryColor, + "left", "top")); + + float buttonY = columnStartY + config.categoryFontSize + config.buttonSpacing; + if (category.clips.empty()) { + commands.push_back(BuildTextCommand("No clips available", columnX, buttonY, + config.buttonFontSize, config.descriptionColor, + "left", "top")); + continue; + } + for (const auto& clip : category.clips) { + GuiCommand::RectData buttonRect{columnX, buttonY, config.buttonWidth, config.buttonHeight}; + const std::string widgetId = category.id + "::" + clip.id; + const bool hovered = IsMouseOver(buttonRect, mouseX, mouseY); + if (justPressed && hovered) { + activeWidget_ = widgetId; + } + bool clicked = false; + if (justReleased && hovered && activeWidget_ == widgetId) { + clicked = true; + } + if (justReleased && activeWidget_ == widgetId) { + activeWidget_.clear(); + } + + GuiColor fill = config.buttonBackground; + if (activeWidget_ == widgetId && mouseDown) { + fill = config.buttonActive; + } else if (hovered) { + fill = config.buttonHover; + } + commands.push_back(BuildRectCommand(buttonRect, fill, config.buttonBorder, 1.0f)); + const float textX = buttonRect.x + (buttonRect.width * 0.5f); + const float textY = buttonRect.y + (buttonRect.height * 0.5f); + commands.push_back(BuildTextCommand(clip.label, textX, textY, config.buttonFontSize, + config.buttonTextColor, "center", "center")); + + if (clicked) { + SoundboardSelection selection{}; + selection.hasSelection = true; + selection.requestId = nextRequestId_++; + selection.categoryId = category.id; + selection.clipId = clip.id; + selection.label = clip.label; + selection.path = clip.path; + selectionOut = selection; + if (stateService_) { + stateService_->SetStatusMessage("Playing \"" + clip.label + "\""); + } + } + + buttonY += config.buttonHeight + config.buttonSpacing; + } + } + + const float statusY = rect.y + rect.height - config.statusOffsetY; + commands.push_back(BuildTextCommand(statusMessage, rect.x + config.panelPaddingX, + statusY, config.statusFontSize, + config.statusColor, "left", "center")); + + lastMouseDown_ = mouseDown; + return commands; +} + +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/workflow_soundboard_gui_step.hpp b/src/services/impl/workflow_soundboard_gui_step.hpp new file mode 100644 index 0000000..d151a47 --- /dev/null +++ b/src/services/impl/workflow_soundboard_gui_step.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include "../interfaces/gui_types.hpp" +#include "../interfaces/i_config_service.hpp" +#include "../interfaces/i_input_service.hpp" +#include "../interfaces/i_logger.hpp" +#include "../interfaces/i_soundboard_state_service.hpp" +#include "../interfaces/i_workflow_step.hpp" +#include "../interfaces/soundboard_types.hpp" + +#include +#include +#include +#include +#include + +namespace sdl3cpp::services::impl { + +class WorkflowSoundboardGuiStep final : public IWorkflowStep { +public: + WorkflowSoundboardGuiStep(std::shared_ptr inputService, + std::shared_ptr configService, + std::shared_ptr stateService, + std::shared_ptr logger); + + std::string GetPluginId() const override; + void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override; + +private: + struct SoundboardGuiConfig { + GuiCommand::RectData panelRect{16.0f, 16.0f, 664.0f, 520.0f}; + float panelPaddingX = 20.0f; + float panelPaddingY = 20.0f; + float headerSpacing = 26.0f; + float columnStartY = 120.0f; + float statusOffsetY = 34.0f; + int columns = 2; + float columnSpacing = 24.0f; + float buttonWidth = 300.0f; + float buttonHeight = 36.0f; + float buttonSpacing = 12.0f; + float titleFontSize = 24.0f; + float descriptionFontSize = 14.0f; + float categoryFontSize = 20.0f; + float buttonFontSize = 16.0f; + float statusFontSize = 14.0f; + GuiColor panelColor{0.06f, 0.07f, 0.09f, 0.95f}; + GuiColor panelBorder{0.35f, 0.38f, 0.42f, 1.0f}; + GuiColor titleColor{0.96f, 0.96f, 0.97f, 1.0f}; + GuiColor descriptionColor{0.7f, 0.75f, 0.8f, 1.0f}; + GuiColor categoryColor{0.9f, 0.9f, 0.95f, 1.0f}; + GuiColor statusColor{0.6f, 0.8f, 1.0f, 1.0f}; + GuiColor buttonTextColor{1.0f, 1.0f, 1.0f, 1.0f}; + GuiColor buttonBorder{0.45f, 0.52f, 0.6f, 1.0f}; + GuiColor buttonBackground{0.2f, 0.24f, 0.28f, 1.0f}; + GuiColor buttonHover{0.26f, 0.3f, 0.36f, 1.0f}; + GuiColor buttonActive{0.16f, 0.22f, 0.28f, 1.0f}; + std::string title = "Audio Soundboard"; + std::string description = "Trigger SFX or speech clips from bundled audio assets."; + }; + + SoundboardGuiConfig LoadConfig() const; + void EnsureConfigLoaded(); + std::vector BuildCommands(const SoundboardCatalog& catalog, + const SoundboardGuiConfig& config, + const std::string& statusMessage, + std::optional& selectionOut); + + std::shared_ptr inputService_; + std::shared_ptr configService_; + std::shared_ptr stateService_; + std::shared_ptr logger_; + std::optional cachedConfig_; + std::string activeWidget_; + bool lastMouseDown_ = false; + std::uint64_t nextRequestId_ = 1; +}; + +} // namespace sdl3cpp::services::impl diff --git a/src/services/interfaces/i_render_coordinator_service.hpp b/src/services/interfaces/i_render_coordinator_service.hpp index 0d8f2ff..69197e1 100644 --- a/src/services/interfaces/i_render_coordinator_service.hpp +++ b/src/services/interfaces/i_render_coordinator_service.hpp @@ -1,6 +1,9 @@ #pragma once #include "graphics_types.hpp" +#include "gui_types.hpp" + +#include namespace sdl3cpp::services { @@ -10,6 +13,9 @@ public: virtual void RenderFrame(float time) = 0; virtual void RenderFrameWithViewState(float time, const ViewState& viewState) = 0; + virtual void RenderFrameWithOverrides(float time, + const ViewState* viewState, + const std::vector* guiCommands) = 0; }; } // namespace sdl3cpp::services