ROADMAP.md

This commit is contained in:
2026-01-10 02:27:42 +00:00
parent 97a2d9ab30
commit d7e679e923
20 changed files with 856 additions and 22 deletions

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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"
}
},
{

View File

@@ -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<services::IAudioService, services::impl::SdlAudioService>(
registry_.GetService<services::ILogger>());
registry_.RegisterService<services::ISoundboardStateService, services::impl::SoundboardStateService>(
registry_.GetService<services::ILogger>());
registry_.RegisterService<services::IFrameWorkflowService, services::impl::FrameWorkflowService>(
registry_.GetService<services::ILogger>(),
registry_.GetService<services::IConfigService>(),
@@ -313,7 +317,8 @@ void ServiceBasedApp::RegisterServices() {
registry_.GetService<services::IPhysicsService>(),
registry_.GetService<services::ISceneService>(),
registry_.GetService<services::IRenderCoordinatorService>(),
registry_.GetService<services::IValidationTourService>());
registry_.GetService<services::IValidationTourService>(),
registry_.GetService<services::ISoundboardStateService>());
// Script bridge services
registry_.RegisterService<services::IMeshService, services::impl::MeshService>(

View File

@@ -16,6 +16,7 @@ FrameWorkflowService::FrameWorkflowService(std::shared_ptr<ILogger> logger,
std::shared_ptr<ISceneService> sceneService,
std::shared_ptr<IRenderCoordinatorService> renderService,
std::shared_ptr<IValidationTourService> validationTourService,
std::shared_ptr<ISoundboardStateService> soundboardStateService,
const std::filesystem::path& templatePath)
: registry_(std::make_shared<WorkflowStepRegistry>()),
executor_(registry_, logger),
@@ -34,7 +35,8 @@ FrameWorkflowService::FrameWorkflowService(std::shared_ptr<ILogger> logger,
std::move(physicsService),
std::move(sceneService),
std::move(renderService),
std::move(validationTourService));
std::move(validationTourService),
std::move(soundboardStateService));
registrar.RegisterUsedSteps(workflow_, registry_);
}

View File

@@ -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<ISceneService> sceneService,
std::shared_ptr<IRenderCoordinatorService> renderService,
std::shared_ptr<IValidationTourService> validationTourService,
std::shared_ptr<ISoundboardStateService> soundboardStateService,
const std::filesystem::path& templatePath = {});
void ExecuteFrame(float deltaTime, float elapsedTime) override;

View File

@@ -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<ILogger>
std::shared_ptr<IPhysicsService> physicsService,
std::shared_ptr<ISceneService> sceneService,
std::shared_ptr<IRenderCoordinatorService> renderService,
std::shared_ptr<IValidationTourService> validationTourService)
std::shared_ptr<IValidationTourService> validationTourService,
std::shared_ptr<ISoundboardStateService> soundboardStateService)
: logger_(std::move(logger)),
configService_(std::move(configService)),
audioService_(std::move(audioService)),
@@ -31,7 +35,8 @@ FrameWorkflowStepRegistrar::FrameWorkflowStepRegistrar(std::shared_ptr<ILogger>
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<WorkflowValidationCheckpointStep>(
validationTourService_, logger_));
}
if (plugins.contains("soundboard.catalog.scan")) {
registry->RegisterStep(std::make_shared<WorkflowSoundboardCatalogScanStep>(configService_, logger_));
}
if (plugins.contains("soundboard.gui")) {
registry->RegisterStep(std::make_shared<WorkflowSoundboardGuiStep>(inputService_,
configService_,
soundboardStateService_,
logger_));
}
if (plugins.contains("soundboard.audio")) {
registry->RegisterStep(std::make_shared<WorkflowSoundboardAudioStep>(audioService_,
soundboardStateService_,
logger_));
}
}
} // namespace sdl3cpp::services::impl

View File

@@ -13,6 +13,7 @@ class IInputService;
class IPhysicsService;
class IRenderCoordinatorService;
class ISceneService;
class ISoundboardStateService;
class IValidationTourService;
}
@@ -27,7 +28,8 @@ public:
std::shared_ptr<IPhysicsService> physicsService,
std::shared_ptr<ISceneService> sceneService,
std::shared_ptr<IRenderCoordinatorService> renderService,
std::shared_ptr<IValidationTourService> validationTourService);
std::shared_ptr<IValidationTourService> validationTourService,
std::shared_ptr<ISoundboardStateService> soundboardStateService);
void RegisterUsedSteps(const WorkflowDefinition& workflow,
const std::shared_ptr<IWorkflowStepRegistry>& registry) const;
@@ -41,6 +43,7 @@ private:
std::shared_ptr<ISceneService> sceneService_;
std::shared_ptr<IRenderCoordinatorService> renderService_;
std::shared_ptr<IValidationTourService> validationTourService_;
std::shared_ptr<ISoundboardStateService> soundboardStateService_;
};
} // namespace sdl3cpp::services::impl

View File

@@ -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<GuiCommand>* guiCommands) {
RenderFrameInternal(time, viewState, guiCommands);
}
void RenderCoordinatorService::RenderFrameInternal(float time,
const ViewState* overrideView,
const std::vector<GuiCommand>* 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_) {

View File

@@ -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<GuiCommand>* 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<GuiCommand>* guiCommands);
};
} // namespace sdl3cpp::services::impl

View File

@@ -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<IConfigService>& 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

View File

@@ -0,0 +1,12 @@
#pragma once
#include "../interfaces/i_config_service.hpp"
#include <filesystem>
#include <memory>
namespace sdl3cpp::services::impl {
std::filesystem::path ResolveSoundboardPackageRoot(const std::shared_ptr<IConfigService>& configService);
} // namespace sdl3cpp::services::impl

View File

@@ -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<float>(*elapsed), *viewState);
const std::vector<GuiCommand>* guiCommands = nullptr;
auto guiIt = step.inputs.find("gui_commands");
if (guiIt != step.inputs.end()) {
guiCommands = context.TryGet<std::vector<GuiCommand>>(guiIt->second);
if (!guiCommands) {
throw std::runtime_error("frame.render missing gui_commands input");
}
}
if (viewState || guiCommands) {
renderService_->RenderFrameWithOverrides(static_cast<float>(*elapsed), viewState, guiCommands);
} else {
renderService_->RenderFrame(static_cast<float>(*elapsed));
}

View File

@@ -0,0 +1,77 @@
#include "workflow_soundboard_audio_step.hpp"
#include "workflow_step_io_resolver.hpp"
#include <filesystem>
#include <stdexcept>
namespace sdl3cpp::services::impl {
WorkflowSoundboardAudioStep::WorkflowSoundboardAudioStep(std::shared_ptr<IAudioService> audioService,
std::shared_ptr<ISoundboardStateService> stateService,
std::shared_ptr<ILogger> 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<SoundboardSelection>(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

View File

@@ -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 <cstdint>
#include <memory>
namespace sdl3cpp::services::impl {
class WorkflowSoundboardAudioStep final : public IWorkflowStep {
public:
WorkflowSoundboardAudioStep(std::shared_ptr<IAudioService> audioService,
std::shared_ptr<ISoundboardStateService> stateService,
std::shared_ptr<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<IAudioService> audioService_;
std::shared_ptr<ISoundboardStateService> stateService_;
std::shared_ptr<ILogger> logger_;
std::uint64_t lastRequestId_ = 0;
};
} // namespace sdl3cpp::services::impl

View File

@@ -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 <algorithm>
#include <cctype>
#include <filesystem>
#include <stdexcept>
#include <string>
#include <vector>
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<char>(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<unsigned char>(ch))) {
capitalize = true;
} else if (capitalize) {
ch = static_cast<char>(std::toupper(static_cast<unsigned char>(ch)));
capitalize = false;
} else {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
}
return base;
}
std::vector<SoundboardClip> LoadClips(const std::filesystem::path& directory) {
std::vector<SoundboardClip> 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<IConfigService> configService,
std::shared_ptr<ILogger> 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

View File

@@ -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 <memory>
#include <optional>
namespace sdl3cpp::services::impl {
class WorkflowSoundboardCatalogScanStep final : public IWorkflowStep {
public:
WorkflowSoundboardCatalogScanStep(std::shared_ptr<IConfigService> configService,
std::shared_ptr<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
SoundboardCatalog LoadCatalog() const;
std::shared_ptr<IConfigService> configService_;
std::shared_ptr<ILogger> logger_;
std::optional<SoundboardCatalog> cachedCatalog_;
};
} // namespace sdl3cpp::services::impl

View File

@@ -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 <rapidjson/document.h>
#include <algorithm>
#include <cmath>
#include <filesystem>
#include <stdexcept>
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<float>(value[0].GetDouble());
color.g = static_cast<float>(value[1].GetDouble());
color.b = static_cast<float>(value[2].GetDouble());
color.a = value.Size() == 4 ? static_cast<float>(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<float>(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<IInputService> inputService,
std::shared_ptr<IConfigService> configService,
std::shared_ptr<ISoundboardStateService> stateService,
std::shared_ptr<ILogger> 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<SoundboardCatalog>(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<SoundboardSelection> selection;
std::vector<GuiCommand> 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<GuiCommand> WorkflowSoundboardGuiStep::BuildCommands(
const SoundboardCatalog& catalog,
const SoundboardGuiConfig& config,
const std::string& statusMessage,
std::optional<SoundboardSelection>& selectionOut) {
std::vector<GuiCommand> 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<int>(index % static_cast<size_t>(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

View File

@@ -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 <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <vector>
namespace sdl3cpp::services::impl {
class WorkflowSoundboardGuiStep final : public IWorkflowStep {
public:
WorkflowSoundboardGuiStep(std::shared_ptr<IInputService> inputService,
std::shared_ptr<IConfigService> configService,
std::shared_ptr<ISoundboardStateService> stateService,
std::shared_ptr<ILogger> 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<GuiCommand> BuildCommands(const SoundboardCatalog& catalog,
const SoundboardGuiConfig& config,
const std::string& statusMessage,
std::optional<SoundboardSelection>& selectionOut);
std::shared_ptr<IInputService> inputService_;
std::shared_ptr<IConfigService> configService_;
std::shared_ptr<ISoundboardStateService> stateService_;
std::shared_ptr<ILogger> logger_;
std::optional<SoundboardGuiConfig> cachedConfig_;
std::string activeWidget_;
bool lastMouseDown_ = false;
std::uint64_t nextRequestId_ = 1;
};
} // namespace sdl3cpp::services::impl

View File

@@ -1,6 +1,9 @@
#pragma once
#include "graphics_types.hpp"
#include "gui_types.hpp"
#include <vector>
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<GuiCommand>* guiCommands) = 0;
};
} // namespace sdl3cpp::services