diff --git a/Scripts/FPSDisplay.cpp b/Scripts/FPSDisplay.cpp new file mode 100644 index 0000000..2a77f06 --- /dev/null +++ b/Scripts/FPSDisplay.cpp @@ -0,0 +1,44 @@ +#include "ScriptRuntime.h" +#include "SceneObject.h" +#include "ThirdParty/imgui/imgui.h" +#include +#include + +namespace { +bool clampTo120 = false; +float smoothFps = 0.0f; +float smoothing = 0.15f; +} + +extern "C" void Script_OnInspector(ScriptContext& ctx) { + ctx.AutoSetting("ClampFPS120", clampTo120); + if (ctx.script) { + std::string saved = ctx.GetSetting("FpsSmoothing", ""); + if (!saved.empty()) { + try { smoothing = std::stof(saved); } catch (...) {} + } + } + + bool changed = false; + ImGui::TextUnformatted("FPS Display"); + ImGui::Separator(); + changed |= ImGui::Checkbox("Clamp FPS to 120", &clampTo120); + changed |= ImGui::DragFloat("Smoothing", &smoothing, 0.01f, 0.0f, 1.0f, "%.2f"); + + if (changed) { + ctx.SetFPSCap(clampTo120, 120.0f); + ctx.SetSetting("FpsSmoothing", std::to_string(smoothing)); + ctx.SaveAutoSettings(); + } + + ImGui::TextDisabled("Attach to a UI Text object."); +} + +void TickUpdate(ScriptContext& ctx, float deltaTime) { + if (!ctx.object || ctx.object->type != ObjectType::UIText) return; + float fps = (deltaTime > 1e-6f) ? (1.0f / deltaTime) : 0.0f; + float k = std::clamp(smoothing, 0.0f, 1.0f); + if (smoothFps <= 0.0f) smoothFps = fps; + smoothFps = smoothFps + (fps - smoothFps) * k; + ctx.object->ui.label = "FPS: " + std::to_string(static_cast(smoothFps + 0.5f)); +} diff --git a/Scripts/RigidbodyTest.cpp b/Scripts/RigidbodyTest.cpp index e626871..56f37c5 100644 --- a/Scripts/RigidbodyTest.cpp +++ b/Scripts/RigidbodyTest.cpp @@ -36,15 +36,10 @@ extern "C" void Script_OnInspector(ScriptContext& ctx) { ImGui::TextUnformatted("RigidbodyTest"); ImGui::Separator(); - bool changed = false; - changed |= ImGui::Checkbox("Launch on Begin", &autoLaunch); - changed |= ImGui::Checkbox("Show Velocity Readback", &showVelocity); - changed |= ImGui::DragFloat3("Launch Velocity", &launchVelocity.x, 0.25f, -50.0f, 50.0f); - changed |= ImGui::DragFloat3("Teleport Offset", &teleportOffset.x, 0.1f, -10.0f, 10.0f); - - if (changed) { - ctx.SaveAutoSettings(); - } + ImGui::Checkbox("Launch on Begin", &autoLaunch); + ImGui::Checkbox("Show Velocity Readback", &showVelocity); + ImGui::DragFloat3("Launch Velocity", &launchVelocity.x, 0.25f, -50.0f, 50.0f); + ImGui::DragFloat3("Teleport Offset", &teleportOffset.x, 0.1f, -10.0f, 10.0f); if (ImGui::Button("Launch Now")) { Launch(ctx); @@ -76,4 +71,4 @@ void Begin(ScriptContext& ctx, float /*deltaTime*/) { if (autoLaunch) { Launch(ctx); } -} \ No newline at end of file +} diff --git a/Scripts/SampleInspector Simplified.cpp b/Scripts/SampleInspector Simplified.cpp index 2c87f0b..dbe2bfc 100644 --- a/Scripts/SampleInspector Simplified.cpp +++ b/Scripts/SampleInspector Simplified.cpp @@ -3,13 +3,11 @@ #include "ThirdParty/imgui/imgui.h" namespace { - // Script state (persisted by AutoSetting binder) bool autoRotate = false; glm::vec3 spinSpeed = glm::vec3(0.0f, 45.0f, 0.0f); // deg/sec glm::vec3 offset = glm::vec3(0.0f, 1.0f, 0.0f); char targetName[128] = "MyTarget"; - // Runtime behavior static void ApplyAutoRotate(ScriptContext& ctx, float deltaTime) { if (!autoRotate || !ctx.object) return; ctx.SetRotation(ctx.object->rotation + spinSpeed * deltaTime); @@ -17,7 +15,6 @@ namespace { } extern "C" void Script_OnInspector(ScriptContext& ctx) { - // Auto settings (loaded once, saved only when changed) ctx.AutoSetting("autoRotate", autoRotate); ctx.AutoSetting("spinSpeed", spinSpeed); ctx.AutoSetting("offset", offset); @@ -26,15 +23,10 @@ extern "C" void Script_OnInspector(ScriptContext& ctx) { ImGui::TextUnformatted("SampleInspector"); ImGui::Separator(); - bool changed = false; - changed |= ImGui::Checkbox("Auto Rotate", &autoRotate); - changed |= ImGui::DragFloat3("Spin Speed (deg/s)", &spinSpeed.x, 1.0f, -360.0f, 360.0f); - changed |= ImGui::DragFloat3("Offset", &offset.x, 0.1f); - changed |= ImGui::InputText("Target Name", targetName, sizeof(targetName)); - - if (changed) { - ctx.SaveAutoSettings(); - } + ImGui::Checkbox("Auto Rotate", &autoRotate); + ImGui::DragFloat3("Spin Speed (deg/s)", &spinSpeed.x, 1.0f, -360.0f, 360.0f); + ImGui::DragFloat3("Offset", &offset.x, 0.1f); + ImGui::InputText("Target Name", targetName, sizeof(targetName)); if (ctx.object) { ImGui::TextDisabled("Attached to: %s (id=%d)", ctx.object->name.c_str(), ctx.object->id); @@ -44,15 +36,12 @@ extern "C" void Script_OnInspector(ScriptContext& ctx) { } } if (ImGui::Button("Nudge Target")) { - if (SceneObject* target = ctx.FindObjectByName(targetName)) { + if (SceneObject* target = ctx.ResolveObjectRef(targetName)) { target->position += offset; } } } -void Begin(ScriptContext& ctx, float /*deltaTime*/) { -} - void Spec(ScriptContext& ctx, float deltaTime) { ApplyAutoRotate(ctx, deltaTime); } diff --git a/Scripts/SampleInspector.cpp b/Scripts/SampleInspector.cpp index cd3259a..19ddde1 100644 --- a/Scripts/SampleInspector.cpp +++ b/Scripts/SampleInspector.cpp @@ -8,101 +8,41 @@ #include "ScriptRuntime.h" #include "SceneObject.h" #include "ThirdParty/imgui/imgui.h" -#include -#include -#include namespace { bool autoRotate = false; glm::vec3 spinSpeed = glm::vec3(0.0f, 45.0f, 0.0f); glm::vec3 offset = glm::vec3(0.0f, 1.0f, 0.0f); char targetName[128] = "MyTarget"; -int settingsLoadedForId = -1; -ScriptComponent* settingsLoadedForScript = nullptr; -void setSetting(ScriptContext& ctx, const std::string& key, const std::string& value) { - if (!ctx.script) return; - auto it = std::find_if(ctx.script->settings.begin(), ctx.script->settings.end(), - [&](const ScriptSetting& s) { return s.key == key; }); - if (it != ctx.script->settings.end()) { - it->value = value; - } else { - ctx.script->settings.push_back({key, value}); - } -} - -std::string getSetting(const ScriptContext& ctx, const std::string& key, const std::string& fallback = "") { - if (!ctx.script) return fallback; - auto it = std::find_if(ctx.script->settings.begin(), ctx.script->settings.end(), - [&](const ScriptSetting& s) { return s.key == key; }); - return (it != ctx.script->settings.end()) ? it->value : fallback; -} - -void loadSettings(ScriptContext& ctx) { - if (!ctx.script || !ctx.object) return; - if (settingsLoadedForId == ctx.object->id && settingsLoadedForScript == ctx.script) return;Segmentation fault (core dumped) - settingsLoadedForId = ctx.object->id; - settingsLoadedForScript = ctx.script; - - auto parseBool = [](const std::string& v, bool def) { - if (v == "1" || v == "true") return true; - if (v == "0" || v == "false") return false; - return def; - }; - - auto parseVec3 = [](const std::string& v, const glm::vec3& def) { - glm::vec3 out = def; - std::stringstream ss(v); - std::string part; - for (int i = 0; i < 3 && std::getline(ss, part, ','); ++i) { - try { out[i] = std::stof(part); } catch (...) {} - } - return out; - }; - - autoRotate = parseBool(getSetting(ctx, "autoRotate", autoRotate ? "1" : "0"), autoRotate); - spinSpeed = parseVec3(getSetting(ctx, "spinSpeed", ""), spinSpeed); - offset = parseVec3(getSetting(ctx, "offset", ""), offset); - std::string tgt = getSetting(ctx, "targetName", targetName); - if (!tgt.empty()) { - std::snprintf(targetName, sizeof(targetName), "%s", tgt.c_str()); - } -} - -void persistSettings(ScriptContext& ctx) { - setSetting(ctx, "autoRotate", autoRotate ? "1" : "0"); - setSetting(ctx, "spinSpeed", - std::to_string(spinSpeed.x) + "," + std::to_string(spinSpeed.y) + "," + std::to_string(spinSpeed.z)); - setSetting(ctx, "offset", - std::to_string(offset.x) + "," + std::to_string(offset.y) + "," + std::to_string(offset.z)); - setSetting(ctx, "targetName", targetName); - ctx.MarkDirty(); +void bindSettings(ScriptContext& ctx) { + ctx.AutoSetting("autoRotate", autoRotate); + ctx.AutoSetting("spinSpeed", spinSpeed); + ctx.AutoSetting("offset", offset); + ctx.AutoSetting("targetName", targetName, sizeof(targetName)); } void applyAutoRotate(ScriptContext& ctx, float deltaTime) { if (!autoRotate || !ctx.object) return; + if (ctx.HasRigidbody() && !ctx.object->rigidbody.isKinematic) { + if (ctx.SetRigidbodyAngularVelocity(glm::radians(spinSpeed))) { + return; + } + } ctx.SetRotation(ctx.object->rotation + spinSpeed * deltaTime); } } // namespace extern "C" void Script_OnInspector(ScriptContext& ctx) { - loadSettings(ctx); + bindSettings(ctx); ImGui::TextUnformatted("SampleInspector"); ImGui::Separator(); - if (ImGui::Checkbox("Auto Rotate", &autoRotate)) { - persistSettings(ctx); - } - if (ImGui::DragFloat3("Spin Speed (deg/s)", &spinSpeed.x, 1.0f, -360.0f, 360.0f)) { - persistSettings(ctx); - } - if (ImGui::DragFloat3("Offset", &offset.x, 0.1f)) { - persistSettings(ctx); - } - + ImGui::Checkbox("Auto Rotate", &autoRotate); + ImGui::DragFloat3("Spin Speed (deg/s)", &spinSpeed.x, 1.0f, -360.0f, 360.0f); + ImGui::DragFloat3("Offset", &offset.x, 0.1f); ImGui::InputText("Target Name", targetName, sizeof(targetName)); - persistSettings(ctx); if (ctx.object) { ImGui::TextDisabled("Attached to: %s (id=%d)", ctx.object->name.c_str(), ctx.object->id); @@ -113,7 +53,7 @@ extern "C" void Script_OnInspector(ScriptContext& ctx) { } if (ImGui::Button("Nudge Target")) { - if (SceneObject* target = ctx.FindObjectByName(targetName)) { + if (SceneObject* target = ctx.ResolveObjectRef(targetName)) { target->position += offset; } } @@ -122,7 +62,7 @@ extern "C" void Script_OnInspector(ScriptContext& ctx) { // New lifecycle hooks supported by the compiler wrapper. These are optional stubs demonstrating usage. void Begin(ScriptContext& ctx, float /*deltaTime*/) { // Initialize per-script state here. - loadSettings(ctx); + bindSettings(ctx); } void Spec(ScriptContext& ctx, float deltaTime) { diff --git a/Scripts/StandaloneMovementController.cpp b/Scripts/StandaloneMovementController.cpp index 7631e76..0a01844 100644 --- a/Scripts/StandaloneMovementController.cpp +++ b/Scripts/StandaloneMovementController.cpp @@ -8,6 +8,8 @@ struct ControllerState { float pitch = 0.0f; float yaw = 0.0f; float verticalVelocity = 0.0f; + glm::vec3 debugVelocity = glm::vec3(0.0f); + bool debugGrounded = false; bool initialized = false; }; @@ -38,23 +40,6 @@ void bindSettings(ScriptContext& ctx) { ctx.AutoSetting("enforceRigidbody", enforceRigidbody); ctx.AutoSetting("showDebug", showDebug); } - -void ensureComponents(ScriptContext& ctx, float height, float radius) { - if (!ctx.object) return; - if (enforceCollider) { - ctx.object->hasCollider = true; - ctx.object->collider.enabled = true; - ctx.object->collider.type = ColliderType::Capsule; - ctx.object->collider.convex = true; - ctx.object->collider.boxSize = glm::vec3(radius * 2.0f, height, radius * 2.0f); - } - if (enforceRigidbody) { - ctx.object->hasRigidbody = true; - ctx.object->rigidbody.enabled = true; - ctx.object->rigidbody.useGravity = true; - ctx.object->rigidbody.isKinematic = false; - } -} } // namespace extern "C" void Script_OnInspector(ScriptContext& ctx) { @@ -63,19 +48,24 @@ extern "C" void Script_OnInspector(ScriptContext& ctx) { ImGui::TextUnformatted("Standalone Movement Controller"); ImGui::Separator(); - bool changed = false; - changed |= ImGui::DragFloat3("Walk/Run/Jump", &moveTuning.x, 0.05f, 0.0f, 25.0f, "%.2f"); - changed |= ImGui::DragFloat2("Look Sens/Clamp", &lookTuning.x, 0.01f, 0.0f, 500.0f, "%.2f"); - changed |= ImGui::DragFloat3("Height/Radius/Snap", &capsuleTuning.x, 0.02f, 0.0f, 5.0f, "%.2f"); - changed |= ImGui::DragFloat3("Gravity/Probe/MaxFall", &gravityTuning.x, 0.05f, -50.0f, 50.0f, "%.2f"); - changed |= ImGui::Checkbox("Enable Mouse Look", &enableMouseLook); - changed |= ImGui::Checkbox("Hold RMB to Look", &requireMouseButton); - changed |= ImGui::Checkbox("Force Collider", &enforceCollider); - changed |= ImGui::Checkbox("Force Rigidbody", &enforceRigidbody); - changed |= ImGui::Checkbox("Show Debug", &showDebug); + ImGui::DragFloat3("Walk/Run/Jump", &moveTuning.x, 0.05f, 0.0f, 25.0f, "%.2f"); + ImGui::DragFloat2("Look Sens/Clamp", &lookTuning.x, 0.01f, 0.0f, 500.0f, "%.2f"); + ImGui::DragFloat3("Height/Radius/Snap", &capsuleTuning.x, 0.02f, 0.0f, 5.0f, "%.2f"); + ImGui::DragFloat3("Gravity/Probe/MaxFall", &gravityTuning.x, 0.05f, -50.0f, 50.0f, "%.2f"); + ImGui::Checkbox("Enable Mouse Look", &enableMouseLook); + ImGui::Checkbox("Hold RMB to Look", &requireMouseButton); + ImGui::Checkbox("Force Collider", &enforceCollider); + ImGui::Checkbox("Force Rigidbody", &enforceRigidbody); + ImGui::Checkbox("Show Debug", &showDebug); - if (changed) { - ctx.SaveAutoSettings(); + if (showDebug && ctx.object) { + auto it = g_states.find(ctx.object->id); + if (it != g_states.end()) { + const ControllerState& state = it->second; + ImGui::Separator(); + ImGui::Text("Move (%.2f, %.2f, %.2f)", state.debugVelocity.x, state.debugVelocity.y, state.debugVelocity.z); + ImGui::Text("Grounded: %s", state.debugGrounded ? "yes" : "no"); + } } } @@ -89,14 +79,16 @@ void Begin(ScriptContext& ctx, float /*deltaTime*/) { state.verticalVelocity = 0.0f; state.initialized = true; } - ensureComponents(ctx, capsuleTuning.x, capsuleTuning.y); + if (enforceCollider) ctx.EnsureCapsuleCollider(capsuleTuning.x, capsuleTuning.y); + if (enforceRigidbody) ctx.EnsureRigidbody(true, false); } void TickUpdate(ScriptContext& ctx, float deltaTime) { if (!ctx.object) return; ControllerState& state = getState(ctx.object->id); - ensureComponents(ctx, capsuleTuning.x, capsuleTuning.y); + if (enforceCollider) ctx.EnsureCapsuleCollider(capsuleTuning.x, capsuleTuning.y); + if (enforceRigidbody) ctx.EnsureRigidbody(true, false); const float walkSpeed = moveTuning.x; const float runSpeed = moveTuning.y; @@ -125,17 +117,9 @@ void TickUpdate(ScriptContext& ctx, float deltaTime) { } } - glm::quat q = glm::quat(glm::radians(glm::vec3(state.pitch, state.yaw, 0.0f))); - glm::vec3 forward = glm::normalize(q * glm::vec3(0.0f, 0.0f, -1.0f)); - glm::vec3 right = glm::normalize(q * glm::vec3(1.0f, 0.0f, 0.0f)); - glm::vec3 planarForward = glm::normalize(glm::vec3(forward.x, 0.0f, forward.z)); - glm::vec3 planarRight = glm::normalize(glm::vec3(right.x, 0.0f, right.z)); - if (!std::isfinite(planarForward.x) || glm::length(planarForward) < 1e-3f) { - planarForward = glm::vec3(0.0f, 0.0f, -1.0f); - } - if (!std::isfinite(planarRight.x) || glm::length(planarRight) < 1e-3f) { - planarRight = glm::vec3(1.0f, 0.0f, 0.0f); - } + glm::vec3 planarForward(0.0f); + glm::vec3 planarRight(0.0f); + ctx.GetPlanarYawPitchVectors(state.pitch, state.yaw, planarForward, planarRight); glm::vec3 move(0.0f); if (ImGui::IsKeyDown(ImGuiKey_W)) move += planarForward; @@ -195,8 +179,8 @@ void TickUpdate(ScriptContext& ctx, float deltaTime) { } if (showDebug) { - ImGui::Text("Move (%.2f, %.2f, %.2f)", velocity.x, velocity.y, velocity.z); - ImGui::Text("Grounded: %s", grounded ? "yes" : "no"); + state.debugVelocity = velocity; + state.debugGrounded = grounded; } } diff --git a/docs/Scripting.md b/docs/Scripting.md index 5a99dd8..f732ee6 100644 --- a/docs/Scripting.md +++ b/docs/Scripting.md @@ -1,117 +1,400 @@ -# Modularity C++ Scripting Quickstart +--- +title: C++ Scripting +description: Hot-compiled native C++ scripts, per-object state, ImGui inspectors, and runtime/editor hooks. +--- -## Project setup -- Scripts live under `Scripts/` (configurable via `Scripts.modu`). -- The engine generates a wrapper per script when compiling. It exports fixed entry points with `extern "C"` linkage: - - `Script_OnInspector(ScriptContext&)` - - `Script_Begin(ScriptContext&, float deltaTime)` - - `Script_Spec(ScriptContext&, float deltaTime)` - - `Script_TestEditor(ScriptContext&, float deltaTime)` - - `Script_Update(ScriptContext&, float deltaTime)` (fallback if TickUpdate is absent) - - `Script_TickUpdate(ScriptContext&, float deltaTime)` -- Build config file: `Scripts.modu` (auto-created per project). Keys: - - `scriptsDir`, `outDir`, `includeDir=...`, `define=...`, `linux.linkLib`, `win.linkLib`, `cppStandard`. +# C++ Scripting +Scripts in Modularity are native C++ code compiled into shared libraries and loaded at runtime. They run per scene object and can optionally draw ImGui UI in the inspector and in custom editor windows. + +> Notes up front: +> - Scripts are not sandboxed. They can crash the editor/game if they dereference bad pointers or do unsafe work. +> - Always null-check `ctx.object` (objects can be deleted, disabled, or scripts can be detached). + +## Table of contents +- [Quickstart](#quickstart) +- [Scripts.modu](#scriptsmodu) +- [How compilation works](#how-compilation-works) +- [Lifecycle hooks](#lifecycle-hooks) +- [ScriptContext](#scriptcontext) +- [ImGui in scripts](#imgui-in-scripts) +- [Per-script settings](#per-script-settings) +- [UI scripting](#ui-scripting) +- [IEnum tasks](#ienum-tasks) +- [Logging](#logging) +- [Scripted editor windows](#scripted-editor-windows) +- [Manual compile (CLI)](#manual-compile-cli) +- [Troubleshooting](#troubleshooting) +- [Templates](#templates) + +## Quickstart +1. Create a script file under `Scripts/` (e.g. `Scripts/MyScript.cpp`). +2. Select an object in the scene. +3. In the Inspector, add/enable a script component and set its path: + - In the **Scripts** section, set `Path` OR click **Use Selection** after selecting the file in the File Browser. +4. Compile the script: + - In the File Browser, right-click the script file and choose **Compile Script**, or + - In the Inspector’s script component menu, choose **Compile**. +5. Implement a tick hook (`TickUpdate`) and observe behavior in play mode. + +## Scripts.modu +Each project has a `Scripts.modu` file (auto-created if missing). It controls compilation. + +Common keys: +- `scriptsDir` - where script source files live (default: `Scripts`) +- `outDir` - where compiled binaries go (default: `Cache/ScriptBin`) +- `includeDir=...` - add include directories (repeatable) +- `define=...` - add preprocessor defines (repeatable) +- `linux.linkLib=...` - comma-separated link libs/flags for Linux (e.g. `dl,pthread`) +- `win.linkLib=...` - comma-separated link libs for Windows (e.g. `User32,Advapi32`) +- `cppStandard` - C++ standard (e.g. `c++20`) + +Example: +```ini +scriptsDir=Scripts +outDir=Cache/ScriptBin +includeDir=../src +includeDir=../include +cppStandard=c++20 +linux.linkLib=dl,pthread +win.linkLib=User32,Advapi32 +``` + +## How compilation works +Modularity compiles scripts into shared libraries and loads them by symbol name. + +- Source lives under `Scripts/`. +- Output binaries are written to `Cache/ScriptBin/`. +- Binaries are platform-specific: + - Windows: `.dll` + - Linux: `.so` + +### Wrapper generation (important) +To reduce boilerplate, Modularity auto-generates a wrapper for these hook names **if it detects them in your script**: +- `Begin` +- `TickUpdate` +- `Update` +- `Spec` +- `TestEditor` + +That wrapper exports `Script_Begin`, `Script_TickUpdate`, etc. This means you can usually write plain functions like: +```cpp +void TickUpdate(ScriptContext& ctx, float dt) { + (void)dt; + if (!ctx.object) return; +} +``` + +However: +- `Script_OnInspector` is **not** wrapper-generated. If you want inspector UI, you must export it explicitly with `extern "C"`. +- Scripted editor windows (`RenderEditorWindow`, `ExitRenderEditorWindow`) are also **not** wrapper-generated; export them explicitly with `extern "C"`. ## Lifecycle hooks -- **Inspector**: `Script_OnInspector(ScriptContext&)` is called when the script is inspected in the UI. -- **Begin**: `Script_Begin` runs once per object instance before ticking. -- **Spec/Test**: `Script_Spec` and `Script_TestEditor` run every frame when the global “Spec Mode” / “Test Mode” toggles are enabled (Scripts menu). -- **Tick**: `Script_TickUpdate` runs every frame for each script; `Script_Update` is a fallback if TickUpdate is missing. -- All tick-style hooks receive `deltaTime` (seconds) and the `ScriptContext`. +All hooks are optional. If a hook is missing, it is simply not called. + +Hook list: +- `Script_OnInspector(ScriptContext&)` (manual export required) +- `Script_Begin(ScriptContext&, float deltaTime)` (wrapper-generated from `Begin`) +- `Script_TickUpdate(ScriptContext&, float deltaTime)` (wrapper-generated from `TickUpdate`) +- `Script_Update(ScriptContext&, float deltaTime)` (wrapper-generated from `Update`, used only if TickUpdate missing) +- `Script_Spec(ScriptContext&, float deltaTime)` (wrapper-generated from `Spec`) +- `Script_TestEditor(ScriptContext&, float deltaTime)` (wrapper-generated from `TestEditor`) + +Runtime notes: +- `Begin` runs once per object instance (per script component instance). +- `TickUpdate` runs every frame (preferred). +- `Update` runs only if `TickUpdate` is not exported. +- `Spec/TestEditor` run every frame only while their global toggles are enabled (main menu -> Scripts). + +## ScriptContext +`ScriptContext` is passed into most hooks and provides access to the engine, the owning object, and helper APIs. -## ScriptContext helpers -Available methods: -- `FindObjectByName`, `FindObjectById` -- `SetPosition`, `SetRotation`, `SetScale` -- `HasRigidbody` -- `SetRigidbodyVelocity`, `GetRigidbodyVelocity` -- `SetRigidbodyAngularVelocity`, `GetRigidbodyAngularVelocity` -- `AddRigidbodyForce`, `AddRigidbodyImpulse` -- `AddRigidbodyTorque`, `AddRigidbodyAngularImpulse` -- `SetRigidbodyRotation`, `TeleportRigidbody` -- `MarkDirty` (flags the project as having unsaved changes) Fields: -- `engine`: pointer to the Engine -- `object`: pointer to the owning `SceneObject` -- `script`: pointer to the owning `ScriptComponent` (gives access to per-script `settings`) +- `engine` (`Engine*`) - engine pointer +- `object` (`SceneObject*`) - owning object pointer (may be null) +- `script` (`ScriptComponent*`) - owning script component (settings storage) -## Persisting per-script settings -- Each `ScriptComponent` has `settings` (key/value strings) serialized with the scene. -- You can read/write them via `ctx.script->settings` or helper functions in your script. -- After mutating settings or object transforms, call `ctx.MarkDirty()` so Ctrl+S captures changes. +### Object lookup +- `FindObjectByName(const std::string&)` +- `FindObjectById(int)` -## Example pattern (simplified) +### Transform helpers +- `SetPosition(const glm::vec3&)` +- `SetRotation(const glm::vec3&)` (degrees) +- `SetScale(const glm::vec3&)` +- `SetPosition2D(const glm::vec2&)` (UI position in pixels) + +### UI helpers (Buttons/Sliders) +- `IsUIButtonPressed()` +- `IsUIInteractable()`, `SetUIInteractable(bool)` +- `GetUISliderValue()`, `SetUISliderValue(float)` +- `SetUISliderRange(float min, float max)` +- `SetUILabel(const std::string&)`, `SetUIColor(const glm::vec4&)` +- `GetUITextScale()`, `SetUITextScale(float)` +- `SetUISliderStyle(UISliderStyle)` +- `SetUIButtonStyle(UIButtonStyle)` +- `SetUIStylePreset(const std::string&)` +- `RegisterUIStylePreset(const std::string& name, const ImGuiStyle& style, bool replace = false)` + +### Rigidbody helpers (3D) +- `HasRigidbody()` +- `SetRigidbodyVelocity(const glm::vec3&)`, `GetRigidbodyVelocity(glm::vec3& out)` +- `SetRigidbodyAngularVelocity(const glm::vec3&)`, `GetRigidbodyAngularVelocity(glm::vec3& out)` +- `AddRigidbodyForce(const glm::vec3&)`, `AddRigidbodyImpulse(const glm::vec3&)` +- `AddRigidbodyTorque(const glm::vec3&)`, `AddRigidbodyAngularImpulse(const glm::vec3&)` +- `SetRigidbodyRotation(const glm::vec3& rotDeg)` +- `TeleportRigidbody(const glm::vec3& pos, const glm::vec3& rotDeg)` + +### Rigidbody2D helpers (UI/canvas only) +- `HasRigidbody2D()` +- `SetRigidbody2DVelocity(const glm::vec2&)`, `GetRigidbody2DVelocity(glm::vec2& out)` + +### Audio helpers +- `HasAudioSource()` +- `PlayAudio()`, `StopAudio()` +- `SetAudioLoop(bool)` +- `SetAudioVolume(float)` +- `SetAudioClip(const std::string& path)` + +### Settings + utility +- `GetSetting(key, fallback)`, `SetSetting(key, value)` +- `GetSettingBool(key, fallback)`, `SetSettingBool(key, value)` +- `GetSettingVec3(key, fallback)`, `SetSettingVec3(key, value)` +- `AutoSetting(key, bool|glm::vec3|buffer)`, `SaveAutoSettings()` +- `AddConsoleMessage(text, type)` +- `MarkDirty()` + +## ImGui in scripts +Modularity uses Dear ImGui for editor UI. Scripts can draw ImGui in two places: + +### Inspector UI (per object) +Export `Script_OnInspector(ScriptContext&)`: ```cpp -static bool autoRotate = false; -static glm::vec3 speed = {0, 45, 0}; +#include "ScriptRuntime.h" +#include "ThirdParty/imgui/imgui.h" -void Script_OnInspector(ScriptContext& ctx) { +static bool autoRotate = false; + +extern "C" void Script_OnInspector(ScriptContext& ctx) { ImGui::Checkbox("Auto Rotate", &autoRotate); - ImGui::DragFloat3("Speed", &speed.x, 1.f, -360.f, 360.f); ctx.MarkDirty(); } +``` -void Script_Begin(ScriptContext& ctx, float) { - ctx.MarkDirty(); // ensure initial state is saved +> Tip: `Script_OnInspector` must be exported exactly with `extern "C"` (it is not wrapper-generated). +> Important: Do not call ImGui functions (e.g., `ImGui::Text`) from `TickUpdate` or other runtime hooks. Those run before the ImGui frame is active and outside any window, which can crash. + +### Scripted editor windows (custom tabs) +See [Scripted editor windows](#scripted-editor-windows). + +## Per-script settings +Each `ScriptComponent` owns serialized key/value strings (`ctx.script->settings`). Use them to persist state with the scene. + +### Direct settings +```cpp +void TickUpdate(ScriptContext& ctx, float) { + if (!ctx.script) return; + ctx.SetSetting("mode", "hard"); + ctx.MarkDirty(); } +``` -void Script_TickUpdate(ScriptContext& ctx, float dt) { - if (autoRotate && ctx.object) { - ctx.SetRotation(ctx.object->rotation + speed * dt); +### AutoSetting (recommended for inspector UI) +`AutoSetting` binds a variable to a key and loads/saves automatically when you call `SaveAutoSettings()`. +```cpp +extern "C" void Script_OnInspector(ScriptContext& ctx) { + static bool enabled = false; + ctx.AutoSetting("enabled", enabled); + ImGui::Checkbox("Enabled", &enabled); + ctx.SaveAutoSettings(); +} +``` + +## UI scripting +UI elements are scene objects (Create -> 2D/UI). They render in the **Game Viewport** overlay. + +### Button clicks +`IsUIButtonPressed()` is true only on the frame the click happens. +```cpp +void TickUpdate(ScriptContext& ctx, float) { + if (ctx.IsUIButtonPressed()) { + ctx.AddConsoleMessage("Button clicked!"); } } ``` -## Runtime behavior -- Scripts tick for all objects every frame, even if not selected. -- Spec/Test toggles are global (main menu → Scripts). -- Compile scripts via the UI “Compile Script” button or run the build command; wrapper generation is automatic. - -## Rigidbody helper usage -- `SetRigidbodyAngularVelocity(vec3)` sets angular velocity in radians/sec for dynamic, non-kinematic bodies. +### Sliders as meters (health/ammo) +Set `Interactable` to false to make a slider read-only. ```cpp -ctx.SetRigidbodyAngularVelocity({0.0f, 3.0f, 0.0f}); -``` -- `GetRigidbodyAngularVelocity(out vec3)` reads current angular velocity into `out`. Returns false if unavailable. -```cpp -glm::vec3 angVel; -if (ctx.GetRigidbodyAngularVelocity(angVel)) { - ctx.AddConsoleMessage("AngVel Y: " + std::to_string(angVel.y)); +void TickUpdate(ScriptContext& ctx, float) { + ctx.SetUIInteractable(false); + ctx.SetUISliderStyle(UISliderStyle::Fill); + ctx.SetUISliderRange(0.0f, 100.0f); + ctx.SetUISliderValue(health); } ``` -- `AddRigidbodyForce(vec3)` applies continuous force (mass-aware). + +### Style presets +You can register custom ImGui style presets in code and then select them per UI element in the Inspector. ```cpp -ctx.AddRigidbodyForce({0.0f, 0.0f, 25.0f}); +void Begin(ScriptContext& ctx, float) { + ImGuiStyle style = ImGui::GetStyle(); + style.Colors[ImGuiCol_Button] = ImVec4(0.20f, 0.50f, 0.90f, 1.00f); + style.Colors[ImGuiCol_ButtonHovered] = ImVec4(0.25f, 0.60f, 1.00f, 1.00f); + ctx.RegisterUIStylePreset("Ocean", style, true); +} ``` -- `AddRigidbodyImpulse(vec3)` applies an instant impulse (mass-aware). +Then select **UI -> Style Preset** on a button or slider. + +### Finding other UI objects ```cpp -ctx.AddRigidbodyImpulse({0.0f, 6.5f, 0.0f}); +void TickUpdate(ScriptContext& ctx, float) { + if (SceneObject* other = ctx.FindObjectByName("UI Button 3")) { + if (other->type == ObjectType::UIButton && other->ui.buttonPressed) { + ctx.AddConsoleMessage("Other button clicked!"); + } + } +} ``` -- `AddRigidbodyTorque(vec3)` applies continuous torque. + +## IEnum tasks +Modularity provides lightweight, opt-in “tasks” you can start/stop per script component instance. + +Important: In this version, an IEnum task is **just a function** with signature `void(ScriptContext&, float)` that is called every frame while it’s registered. + +Start/stop macros: +- `IEnum_Start(fn)` / `IEnum_Stop(fn)` / `IEnum_Ensure(fn)` + +Example (toggle rotation without cluttering TickUpdate): ```cpp -ctx.AddRigidbodyTorque({0.0f, 15.0f, 0.0f}); -``` -- `AddRigidbodyAngularImpulse(vec3)` applies an instant angular impulse. -```cpp -ctx.AddRigidbodyAngularImpulse({0.0f, 4.0f, 0.0f}); -``` -- `SetRigidbodyRotation(vec3 degrees)` teleports the rigidbody rotation. -```cpp -ctx.SetRigidbodyRotation({0.0f, 90.0f, 0.0f}); +static bool autoRotate = false; +static glm::vec3 speed = {0, 45, 0}; + +static void RotateTask(ScriptContext& ctx, float dt) { + if (!ctx.object) return; + ctx.SetRotation(ctx.object->rotation + speed * dt); +} + +extern "C" void Script_OnInspector(ScriptContext& ctx) { + ImGui::Checkbox("Auto Rotate", &autoRotate); + if (autoRotate) IEnum_Ensure(RotateTask); + else IEnum_Stop(RotateTask); + ctx.MarkDirty(); +} ``` + Notes: -- These return false if the object has no enabled rigidbody or is kinematic. -- Use force/torque for continuous input and impulses for bursty actions. -- `SetRigidbodyRotation` is authoritative; use it sparingly during gameplay. +- Tasks are stored per `ScriptComponent` instance. +- Don’t spam logs every frame inside a task; use “warn once” patterns. + +## Logging +Use `ctx.AddConsoleMessage(text, type)` to write to the editor console. + +Typical types: +- `ConsoleMessageType::Info` +- `ConsoleMessageType::Success` +- `ConsoleMessageType::Warning` +- `ConsoleMessageType::Error` + +Warn-once pattern: +```cpp +static bool warned = false; +if (!warned) { + ctx.AddConsoleMessage("[MyScript] Something looks off", ConsoleMessageType::Warning); + warned = true; +} +``` + +## Scripted editor windows +Scripts can expose ImGui-powered editor tabs by exporting: +- `RenderEditorWindow(ScriptContext& ctx)` (called every frame while tab is open) +- `ExitRenderEditorWindow(ScriptContext& ctx)` (called once when tab closes) + +Example: +```cpp +#include "ScriptRuntime.h" +#include "ThirdParty/imgui/imgui.h" + +extern "C" void RenderEditorWindow(ScriptContext& ctx) { + ImGui::TextUnformatted("Hello from script!"); + if (ImGui::Button("Log")) { + ctx.AddConsoleMessage("Editor window clicked"); + } +} + +extern "C" void ExitRenderEditorWindow(ScriptContext& ctx) { + (void)ctx; +} +``` + +How to open: +1. Compile the script so the binary is updated under `Cache/ScriptBin/`. +2. In the main menu, go to **View -> Scripted Windows** and toggle the entry. ## Manual compile (CLI) -Linux example: +Linux: ```bash g++ -std=c++20 -fPIC -O0 -g -I../src -I../include -c SampleInspector.cpp -o ../Cache/ScriptBin/SampleInspector.o g++ -shared ../Cache/ScriptBin/SampleInspector.o -o ../Cache/ScriptBin/SampleInspector.so -ldl -lpthread ``` -Windows example: + +Windows: ```bat cl /nologo /std:c++20 /EHsc /MD /Zi /Od /I ..\\src /I ..\\include /c SampleInspector.cpp /Fo ..\\Cache\\ScriptBin\\SampleInspector.obj link /nologo /DLL ..\\Cache\\ScriptBin\\SampleInspector.obj /OUT:..\\Cache\\ScriptBin\\SampleInspector.dll User32.lib Advapi32.lib ``` + +## Troubleshooting +- **Script not running** + - Ensure the object is enabled and the script component is enabled. + - Ensure the script path points to a real file and the compiled binary exists. +- **No inspector UI** + - `Script_OnInspector` must be exported with `extern "C"` (no wrapper is generated for it). +- **Changes not saved** + - Call `ctx.MarkDirty()` after mutating transforms/settings you want to persist. +- **Editor window not showing** + - Ensure `RenderEditorWindow` is exported with `extern "C"` and the binary is up to date. +- **Custom UI style preset not listed** + - Ensure `RegisterUIStylePreset(...)` ran (e.g. in `Begin`) before selecting it in the Inspector. +- **Hard crash** + - Add null checks, avoid static pointers to scene objects, and don’t hold references across frames unless you can validate them. + +## Templates +### Minimal runtime script (wrapper-based) +```cpp +#include "ScriptRuntime.h" + +void TickUpdate(ScriptContext& ctx, float /*dt*/) { + if (!ctx.object) return; +} +``` + +### Minimal script with inspector (manual export) +```cpp +#include "ScriptRuntime.h" +#include "ThirdParty/imgui/imgui.h" + +void TickUpdate(ScriptContext& ctx, float /*dt*/) { + if (!ctx.object) return; +} + +extern "C" void Script_OnInspector(ScriptContext& ctx) { + ImGui::TextDisabled("Hello from inspector!"); + (void)ctx; +} +``` +### Text +Use **UI Text** objects for on-screen text. Update their `label` and size from scripts: +```cpp +void TickUpdate(ScriptContext& ctx, float) { + if (SceneObject* text = ctx.FindObjectByName("UI Text 2")) { + if (text->type == ObjectType::UIText) { + text->ui.label = "Speed: 12.4"; + text->ui.textScale = 1.4f; + ctx.MarkDirty(); + } + } +} +``` + +### FPS display example +Attach `Scripts/FPSDisplay.cpp` to a **UI Text** object to show FPS. The inspector exposes a checkbox to clamp FPS to 120. diff --git a/src/EditorWindows/ProjectManagerWindow.cpp b/src/EditorWindows/ProjectManagerWindow.cpp index 7bc2b31..2e4a532 100644 --- a/src/EditorWindows/ProjectManagerWindow.cpp +++ b/src/EditorWindows/ProjectManagerWindow.cpp @@ -188,7 +188,7 @@ void Engine::renderLauncher() { ImGui::SetWindowFontScale(1.4f); ImGui::TextColored(ImVec4(0.95f, 0.96f, 0.98f, 1.0f), "Modularity"); ImGui::SetWindowFontScale(1.0f); - ImGui::TextColored(ImVec4(0.70f, 0.73f, 0.80f, 1.0f), "Modularity | Debug Build V0.7.0"); + ImGui::TextColored(ImVec4(0.70f, 0.73f, 0.80f, 1.0f), "Modularity | Beta V1.0"); ImGui::EndChild(); @@ -349,13 +349,10 @@ void Engine::renderLauncher() { ImGui::Spacing(); } } - ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); - - ImGui::TextDisabled("Modularity Engine - Version 0.6.8"); - + ImGui::TextDisabled("Modularity Engine - Beta V1.0"); ImGui::EndChild(); } diff --git a/src/EditorWindows/SceneWindows.cpp b/src/EditorWindows/SceneWindows.cpp index 77f3304..ad1b798 100644 --- a/src/EditorWindows/SceneWindows.cpp +++ b/src/EditorWindows/SceneWindows.cpp @@ -29,10 +29,17 @@ namespace { case ObjectType::AreaLight: return IM_COL32(255, 200, 90, 220); case ObjectType::PostFXNode: return IM_COL32(200, 140, 230, 220); case ObjectType::OBJMesh: - case ObjectType::Model: return IM_COL32(120, 200, 150, 220); + case ObjectType::Model: + case ObjectType::Sprite: return IM_COL32(120, 200, 150, 220); case ObjectType::Mirror: return IM_COL32(180, 200, 210, 220); case ObjectType::Plane: return IM_COL32(170, 180, 190, 220); case ObjectType::Torus: return IM_COL32(155, 215, 180, 220); + case ObjectType::Canvas: return IM_COL32(120, 180, 255, 220); + case ObjectType::UIImage: + case ObjectType::UISlider: + case ObjectType::UIButton: + case ObjectType::UIText: + case ObjectType::Sprite2D: return IM_COL32(160, 210, 255, 220); default: return IM_COL32(140, 190, 235, 220); } } @@ -194,6 +201,25 @@ void Engine::renderHierarchyPanel() { ImGuiPopupFlags_MouseButtonRight | ImGuiPopupFlags_NoOpenOverItems)) { + auto createUIWithCanvas = [&](ObjectType type, const std::string& baseName) { + int canvasId = -1; + for (const auto& obj : sceneObjects) { + if (obj.type == ObjectType::Canvas) { + canvasId = obj.id; + break; + } + } + if (canvasId < 0) { + addObject(ObjectType::Canvas, "Canvas"); + if (!sceneObjects.empty()) { + canvasId = sceneObjects.back().id; + } + } + addObject(type, baseName); + if (!sceneObjects.empty() && canvasId >= 0) { + setParent(sceneObjects.back().id, canvasId); + } + }; if (ImGui::BeginMenu("Create")) { // ── Primitives ───────────────────────────── @@ -204,10 +230,23 @@ void Engine::renderHierarchyPanel() { if (ImGui::MenuItem("Capsule")) addObject(ObjectType::Capsule, "Capsule"); if (ImGui::MenuItem("Plane")) addObject(ObjectType::Plane, "Plane"); if (ImGui::MenuItem("Torus")) addObject(ObjectType::Torus, "Torus"); + if (ImGui::MenuItem("Sprite (Quad)")) addObject(ObjectType::Sprite, "Sprite"); if (ImGui::MenuItem("Mirror")) addObject(ObjectType::Mirror, "Mirror"); ImGui::EndMenu(); } + if (ImGui::BeginMenu("RMesh")) + { + if (ImGui::BeginMenu("Primitives")) + { + if (ImGui::MenuItem("Cube")) createRMeshPrimitive("Cube"); + if (ImGui::MenuItem("Sphere")) createRMeshPrimitive("Sphere"); + if (ImGui::MenuItem("Plane")) createRMeshPrimitive("Plane"); + ImGui::EndMenu(); + } + ImGui::EndMenu(); + } + // ── Lights ──────────────────────────────── if (ImGui::BeginMenu("Lights")) { @@ -224,6 +263,16 @@ void Engine::renderHierarchyPanel() { if (ImGui::MenuItem("Post FX Node")) addObject(ObjectType::PostFXNode, "Post FX"); ImGui::EndMenu(); } + if (ImGui::BeginMenu("2D/UI")) + { + if (ImGui::MenuItem("Canvas")) addObject(ObjectType::Canvas, "Canvas"); + if (ImGui::MenuItem("UI Image")) createUIWithCanvas(ObjectType::UIImage, "UI Image"); + if (ImGui::MenuItem("UI Slider")) createUIWithCanvas(ObjectType::UISlider, "UI Slider"); + if (ImGui::MenuItem("UI Button")) createUIWithCanvas(ObjectType::UIButton, "UI Button"); + if (ImGui::MenuItem("UI Text")) createUIWithCanvas(ObjectType::UIText, "UI Text"); + if (ImGui::MenuItem("Sprite2D")) createUIWithCanvas(ObjectType::Sprite2D, "Sprite2D"); + ImGui::EndMenu(); + } if (ImGui::MenuItem("Camera")) addObject(ObjectType::Camera, "Camera"); ImGui::EndMenu(); @@ -341,6 +390,20 @@ void Engine::renderObjectNode(SceneObject& obj, const std::string& filter, deleteSelected(); } ImGui::Separator(); + if (obj.type == ObjectType::Canvas && ImGui::BeginMenu("Create UI Child")) { + auto createChild = [&](ObjectType type, const std::string& baseName) { + addObject(type, baseName); + if (!sceneObjects.empty()) { + setParent(sceneObjects.back().id, obj.id); + } + }; + if (ImGui::MenuItem("UI Image")) createChild(ObjectType::UIImage, "UI Image"); + if (ImGui::MenuItem("UI Slider")) createChild(ObjectType::UISlider, "UI Slider"); + if (ImGui::MenuItem("UI Button")) createChild(ObjectType::UIButton, "UI Button"); + if (ImGui::MenuItem("UI Text")) createChild(ObjectType::UIText, "UI Text"); + if (ImGui::MenuItem("Sprite2D")) createChild(ObjectType::Sprite2D, "Sprite2D"); + ImGui::EndMenu(); + } if (ImGui::MenuItem("Clear Parent") && obj.parentId != -1) { setParent(obj.id, -1); } @@ -901,6 +964,14 @@ void Engine::renderInspectorPanel() { SceneObject& obj = *it; ImGui::PushID(obj.id); // Scope per-object widgets to avoid ID collisions + auto isUIObjectType = [](ObjectType type) { + return type == ObjectType::Canvas || + type == ObjectType::UIImage || + type == ObjectType::UISlider || + type == ObjectType::UIButton || + type == ObjectType::UIText || + type == ObjectType::Sprite2D; + }; if (selectedObjectIds.size() > 1) { ImGui::Text("Multiple objects selected: %zu", selectedObjectIds.size()); @@ -931,6 +1002,13 @@ void Engine::renderInspectorPanel() { case ObjectType::Capsule: typeLabel = "Capsule"; break; case ObjectType::OBJMesh: typeLabel = "OBJ Mesh"; break; case ObjectType::Model: typeLabel = "Model"; break; + case ObjectType::Sprite: typeLabel = "Sprite"; break; + case ObjectType::Sprite2D: typeLabel = "Sprite2D"; break; + case ObjectType::Canvas: typeLabel = "Canvas"; break; + case ObjectType::UIImage: typeLabel = "UI Image"; break; + case ObjectType::UISlider: typeLabel = "UI Slider"; break; + case ObjectType::UIButton: typeLabel = "UI Button"; break; + case ObjectType::UIText: typeLabel = "UI Text"; break; case ObjectType::Camera: typeLabel = "Camera"; break; case ObjectType::DirectionalLight: typeLabel = "Directional Light"; break; case ObjectType::PointLight: typeLabel = "Point Light"; break; @@ -986,6 +1064,9 @@ void Engine::renderInspectorPanel() { if (obj.type == ObjectType::PostFXNode) { ImGui::TextDisabled("Transform is ignored for post-processing nodes."); } + if (isUIObjectType(obj.type)) { + ImGui::TextDisabled("UI objects use the UI section for positioning."); + } ImGui::Text("Position"); ImGui::PushItemWidth(-1); @@ -1032,6 +1113,138 @@ void Engine::renderInspectorPanel() { ImGui::PopStyleColor(); + if (isUIObjectType(obj.type)) { + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.25f, 0.45f, 0.65f, 1.0f)); + if (ImGui::CollapsingHeader("UI", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::PushID("UI"); + ImGui::Indent(10.0f); + + const char* anchors[] = { "Center", "Top Left", "Top Right", "Bottom Left", "Bottom Right" }; + int anchor = static_cast(obj.ui.anchor); + if (ImGui::Combo("Anchor", &anchor, anchors, IM_ARRAYSIZE(anchors))) { + obj.ui.anchor = static_cast(anchor); + projectManager.currentProject.hasUnsavedChanges = true; + } + + if (ImGui::DragFloat2("Position (px)", &obj.ui.position.x, 1.0f)) { + projectManager.currentProject.hasUnsavedChanges = true; + } + + glm::vec2 minSize(8.0f, 8.0f); + if (ImGui::DragFloat2("Size (px)", &obj.ui.size.x, 1.0f, minSize.x, 4096.0f)) { + obj.ui.size.x = std::max(minSize.x, obj.ui.size.x); + obj.ui.size.y = std::max(minSize.y, obj.ui.size.y); + projectManager.currentProject.hasUnsavedChanges = true; + } + + if (obj.type == ObjectType::UIButton || obj.type == ObjectType::UISlider) { + if (ImGui::Checkbox("Interactable", &obj.ui.interactable)) { + projectManager.currentProject.hasUnsavedChanges = true; + } + + const auto& presets = getUIStylePresets(); + if (!presets.empty()) { + int presetIndex = findUIStylePreset(obj.ui.stylePreset); + if (presetIndex < 0) presetIndex = 0; + const char* currentPreset = presets[presetIndex].name.c_str(); + if (ImGui::BeginCombo("Style Preset", currentPreset)) { + for (int i = 0; i < (int)presets.size(); ++i) { + bool selected = (i == presetIndex); + if (ImGui::Selectable(presets[i].name.c_str(), selected)) { + obj.ui.stylePreset = presets[i].name; + projectManager.currentProject.hasUnsavedChanges = true; + } + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } + } + + if (obj.type == ObjectType::UIButton || obj.type == ObjectType::UISlider || obj.type == ObjectType::UIImage || obj.type == ObjectType::UIText || obj.type == ObjectType::Sprite2D) { + char labelBuf[128] = {}; + std::snprintf(labelBuf, sizeof(labelBuf), "%s", obj.ui.label.c_str()); + if (ImGui::InputText(obj.type == ObjectType::UIText ? "Text" : "Label", labelBuf, sizeof(labelBuf))) { + obj.ui.label = labelBuf; + projectManager.currentProject.hasUnsavedChanges = true; + } + } + if (obj.type == ObjectType::UIText) { + if (ImGui::DragFloat("Text Size", &obj.ui.textScale, 0.05f, 0.1f, 10.0f, "%.2f")) { + obj.ui.textScale = std::max(0.1f, obj.ui.textScale); + projectManager.currentProject.hasUnsavedChanges = true; + } + } + + if (obj.type == ObjectType::UIImage || obj.type == ObjectType::Sprite2D) { + ImGui::TextUnformatted("Texture"); + ImGui::SetNextItemWidth(-160); + char texBuf[512] = {}; + std::snprintf(texBuf, sizeof(texBuf), "%s", obj.albedoTexturePath.c_str()); + if (ImGui::InputText("##UITexture", texBuf, sizeof(texBuf))) { + obj.albedoTexturePath = texBuf; + projectManager.currentProject.hasUnsavedChanges = true; + } + ImGui::SameLine(); + if (ImGui::SmallButton("Clear##UITexture")) { + obj.albedoTexturePath.clear(); + projectManager.currentProject.hasUnsavedChanges = true; + } + ImGui::SameLine(); + bool canUseTex = !fileBrowser.selectedFile.empty() && fs::exists(fileBrowser.selectedFile) && + fileBrowser.isTextureFile(fs::directory_entry(fileBrowser.selectedFile)); + ImGui::BeginDisabled(!canUseTex); + if (ImGui::SmallButton("Use Selection##UITexture")) { + obj.albedoTexturePath = fileBrowser.selectedFile.string(); + projectManager.currentProject.hasUnsavedChanges = true; + } + ImGui::EndDisabled(); + } + + if (obj.type == ObjectType::UISlider) { + const char* sliderStyles[] = { "ImGui", "Fill", "Circle" }; + int sliderStyle = static_cast(obj.ui.sliderStyle); + if (ImGui::Combo("Style", &sliderStyle, sliderStyles, IM_ARRAYSIZE(sliderStyles))) { + obj.ui.sliderStyle = static_cast(sliderStyle); + projectManager.currentProject.hasUnsavedChanges = true; + } + if (ImGui::DragFloat("Min", &obj.ui.sliderMin, 0.1f)) { + projectManager.currentProject.hasUnsavedChanges = true; + } + if (ImGui::DragFloat("Max", &obj.ui.sliderMax, 0.1f)) { + projectManager.currentProject.hasUnsavedChanges = true; + } + if (obj.ui.sliderMax < obj.ui.sliderMin) { + std::swap(obj.ui.sliderMin, obj.ui.sliderMax); + } + if (ImGui::SliderFloat("Value", &obj.ui.sliderValue, obj.ui.sliderMin, obj.ui.sliderMax)) { + projectManager.currentProject.hasUnsavedChanges = true; + } + } + + ImVec4 uiColor(obj.ui.color.r, obj.ui.color.g, obj.ui.color.b, obj.ui.color.a); + if (ImGui::ColorEdit4("Tint", &uiColor.x)) { + obj.ui.color = glm::vec4(uiColor.x, uiColor.y, uiColor.z, uiColor.w); + projectManager.currentProject.hasUnsavedChanges = true; + } + + if (obj.type == ObjectType::UIButton) { + const char* buttonStyles[] = { "ImGui", "Outline" }; + int buttonStyle = static_cast(obj.ui.buttonStyle); + if (ImGui::Combo("Style", &buttonStyle, buttonStyles, IM_ARRAYSIZE(buttonStyles))) { + obj.ui.buttonStyle = static_cast(buttonStyle); + projectManager.currentProject.hasUnsavedChanges = true; + } + ImGui::TextDisabled("Last Pressed: %s", obj.ui.buttonPressed ? "yes" : "no"); + } + + ImGui::Unindent(10.0f); + ImGui::PopID(); + } + ImGui::PopStyleColor(); + } + if (obj.hasCollider) { ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.35f, 0.5f, 0.35f, 1.0f)); @@ -1080,7 +1293,7 @@ void Engine::renderInspectorPanel() { } ImGui::TextDisabled("Capsule aligned to Y axis."); } else { - if (ImGui::Checkbox("Use Convex Hull (required for Rigidbody)", &obj.collider.convex)) { + if (ImGui::Checkbox("Use Convex Hull (required for Rigidbody3D)", &obj.collider.convex)) { changed = true; } ImGui::TextDisabled("Uses mesh from the object (OBJ/Model). Non-convex is static-only."); @@ -1164,7 +1377,7 @@ void Engine::renderInspectorPanel() { ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.45f, 0.45f, 0.25f, 1.0f)); bool removeRigidbody = false; bool changed = false; - auto header = drawComponentHeader("Rigidbody", "Rigidbody", &obj.rigidbody.enabled, true, [&]() { + auto header = drawComponentHeader("Rigidbody3D", "Rigidbody3D", &obj.rigidbody.enabled, true, [&]() { if (ImGui::MenuItem("Remove")) { removeRigidbody = true; } @@ -1173,9 +1386,12 @@ void Engine::renderInspectorPanel() { changed = true; } if (header.open) { - ImGui::PushID("Rigidbody"); + ImGui::PushID("Rigidbody3D"); ImGui::Indent(10.0f); ImGui::TextDisabled("Collider required for physics."); + if (isUIObjectType(obj.type)) { + ImGui::TextDisabled("Rigidbody3D is for 3D objects (use Rigidbody2D for UI/canvas)."); + } if (ImGui::DragFloat("Mass", &obj.rigidbody.mass, 0.05f, 0.01f, 1000.0f, "%.2f")) { obj.rigidbody.mass = std::max(0.01f, obj.rigidbody.mass); @@ -1218,6 +1434,52 @@ void Engine::renderInspectorPanel() { ImGui::PopStyleColor(); } + if (obj.hasRigidbody2D) { + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.35f, 0.55f, 0.45f, 1.0f)); + bool removeRigidbody2D = false; + bool changed = false; + auto header = drawComponentHeader("Rigidbody2D", "Rigidbody2D", &obj.rigidbody2D.enabled, true, [&]() { + if (ImGui::MenuItem("Remove")) { + removeRigidbody2D = true; + } + }); + if (header.enabledChanged) { + changed = true; + } + if (header.open) { + ImGui::PushID("Rigidbody2D"); + ImGui::Indent(10.0f); + if (!isUIObjectType(obj.type)) { + ImGui::TextDisabled("Rigidbody2D is for UI/canvas objects only."); + } + if (ImGui::Checkbox("Use Gravity", &obj.rigidbody2D.useGravity)) { + changed = true; + } + if (ImGui::DragFloat("Gravity Scale", &obj.rigidbody2D.gravityScale, 0.05f, 0.0f, 10.0f, "%.2f")) { + obj.rigidbody2D.gravityScale = std::max(0.0f, obj.rigidbody2D.gravityScale); + changed = true; + } + if (ImGui::DragFloat("Linear Damping", &obj.rigidbody2D.linearDamping, 0.01f, 0.0f, 10.0f)) { + obj.rigidbody2D.linearDamping = std::clamp(obj.rigidbody2D.linearDamping, 0.0f, 10.0f); + changed = true; + } + if (ImGui::DragFloat2("Velocity", &obj.rigidbody2D.velocity.x, 0.1f)) { + changed = true; + } + ImGui::Unindent(10.0f); + ImGui::PopID(); + } + if (removeRigidbody2D) { + obj.hasRigidbody2D = false; + changed = true; + } + if (changed) { + projectManager.currentProject.hasUnsavedChanges = true; + } + ImGui::PopStyleColor(); + } + if (obj.hasAudioSource) { ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.55f, 0.4f, 0.3f, 1.0f)); @@ -1481,7 +1743,7 @@ void Engine::renderInspectorPanel() { } // Material section (skip for pure light objects) - if (obj.type != ObjectType::DirectionalLight && obj.type != ObjectType::PointLight && obj.type != ObjectType::SpotLight && obj.type != ObjectType::AreaLight && obj.type != ObjectType::Camera && obj.type != ObjectType::PostFXNode) { + if (obj.type != ObjectType::DirectionalLight && obj.type != ObjectType::PointLight && obj.type != ObjectType::SpotLight && obj.type != ObjectType::AreaLight && obj.type != ObjectType::Camera && obj.type != ObjectType::PostFXNode && obj.type != ObjectType::Canvas && obj.type != ObjectType::UIImage && obj.type != ObjectType::UISlider && obj.type != ObjectType::UIButton && obj.type != ObjectType::UIText && obj.type != ObjectType::Sprite2D) { ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0.35f, 0.35f, 0.55f, 1.0f)); @@ -1951,6 +2213,7 @@ void Engine::renderInspectorPanel() { ImGui::PushID(inspectorId.c_str()); inspector(ctx); ImGui::PopID(); + ctx.SaveAutoSettings(); } else if (!scriptRuntime.getLastError().empty()) { ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.6f, 1.0f), "Inspector load failed"); ImGui::TextWrapped("%s", scriptRuntime.getLastError().c_str()); @@ -2052,11 +2315,21 @@ void Engine::renderInspectorPanel() { ImGui::OpenPopup("AddComponentPopup"); } if (ImGui::BeginPopup("AddComponentPopup")) { - if (!obj.hasRigidbody && ImGui::MenuItem("Rigidbody")) { + bool isUIType = isUIObjectType(obj.type); + ImGui::BeginDisabled(isUIType); + if (!obj.hasRigidbody && ImGui::MenuItem("Rigidbody3D")) { obj.hasRigidbody = true; obj.rigidbody = RigidbodyComponent{}; componentChanged = true; } + ImGui::EndDisabled(); + ImGui::BeginDisabled(!isUIType); + if (!obj.hasRigidbody2D && ImGui::MenuItem("Rigidbody2D")) { + obj.hasRigidbody2D = true; + obj.rigidbody2D = Rigidbody2DComponent{}; + componentChanged = true; + } + ImGui::EndDisabled(); if (!obj.hasPlayerController && ImGui::MenuItem("Player Controller")) { obj.hasPlayerController = true; obj.playerController = PlayerControllerComponent{}; diff --git a/src/EditorWindows/ViewportWindows.cpp b/src/EditorWindows/ViewportWindows.cpp index 39b210e..46ac38c 100644 --- a/src/EditorWindows/ViewportWindows.cpp +++ b/src/EditorWindows/ViewportWindows.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -314,6 +315,7 @@ void Engine::renderGameViewportWindow() { ); ImGui::Image((void*)(intptr_t)tex, ImVec2((float)width, (float)height), ImVec2(0, 1), ImVec2(1, 0)); + bool imageHovered = ImGui::IsItemHovered(); ImVec2 imageMin = ImGui::GetItemRectMin(); ImVec2 imageMax = ImGui::GetItemRectMax(); ImDrawList* drawList = ImGui::GetWindowDrawList(); @@ -333,8 +335,318 @@ void Engine::renderGameViewportWindow() { drawList->AddRectFilled(bgMin, bgMax, IM_COL32(20, 20, 24, 200), 4.0f); drawList->AddText(textPos, IM_COL32(235, 235, 245, 255), textLabel); } - bool hovered = ImGui::IsItemHovered(); - bool clicked = hovered && isPlaying && ImGui::IsMouseClicked(ImGuiMouseButton_Left); + bool uiInteracting = false; + auto isUIType = [](ObjectType type) { + return type == ObjectType::Canvas || + type == ObjectType::UIImage || + type == ObjectType::UISlider || + type == ObjectType::UIButton || + type == ObjectType::UIText || + type == ObjectType::Sprite2D; + }; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::SetCursorScreenPos(imageMin); + ImGui::BeginChild("GameUIOverlay", + ImVec2(imageMax.x - imageMin.x, imageMax.y - imageMin.y), + false, + ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse | + ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoBackground); + + auto anchorToPivot = [](UIAnchor anchor, const ImVec2& size) { + switch (anchor) { + case UIAnchor::Center: return ImVec2(size.x * 0.5f, size.y * 0.5f); + case UIAnchor::TopLeft: return ImVec2(0.0f, 0.0f); + case UIAnchor::TopRight: return ImVec2(size.x, 0.0f); + case UIAnchor::BottomLeft: return ImVec2(0.0f, size.y); + case UIAnchor::BottomRight: return ImVec2(size.x, size.y); + default: return ImVec2(size.x * 0.5f, size.y * 0.5f); + } + }; + auto anchorToPoint = [](UIAnchor anchor, const ImVec2& min, const ImVec2& max) { + switch (anchor) { + case UIAnchor::Center: return ImVec2((min.x + max.x) * 0.5f, (min.y + max.y) * 0.5f); + case UIAnchor::TopLeft: return min; + case UIAnchor::TopRight: return ImVec2(max.x, min.y); + case UIAnchor::BottomLeft: return ImVec2(min.x, max.y); + case UIAnchor::BottomRight: return max; + default: return ImVec2((min.x + max.x) * 0.5f, (min.y + max.y) * 0.5f); + } + }; + + auto resolveUIRect = [&](const SceneObject& obj, ImVec2& outMin, ImVec2& outMax, ImVec2* parentMin = nullptr, ImVec2* parentMax = nullptr) { + std::vector chain; + const SceneObject* current = &obj; + while (current) { + if (isUIType(current->type)) { + chain.push_back(current); + } + if (current->parentId < 0) break; + auto pit = std::find_if(sceneObjects.begin(), sceneObjects.end(), + [&](const SceneObject& o) { return o.id == current->parentId; }); + if (pit == sceneObjects.end()) break; + current = &(*pit); + } + std::reverse(chain.begin(), chain.end()); + + ImVec2 regionMin = ImGui::GetWindowPos(); + ImVec2 regionMax = ImVec2(regionMin.x + ImGui::GetWindowWidth(), regionMin.y + ImGui::GetWindowHeight()); + for (size_t idx = 0; idx < chain.size(); ++idx) { + const SceneObject* node = chain[idx]; + if (idx + 1 == chain.size() && parentMin && parentMax) { + *parentMin = regionMin; + *parentMax = regionMax; + } + ImVec2 size = ImVec2(std::max(1.0f, node->ui.size.x), std::max(1.0f, node->ui.size.y)); + ImVec2 anchorPoint = anchorToPoint(node->ui.anchor, regionMin, regionMax); + ImVec2 pivot(anchorPoint.x + node->ui.position.x, anchorPoint.y + node->ui.position.y); + ImVec2 pivotOffset = anchorToPivot(node->ui.anchor, size); + regionMin = ImVec2(pivot.x - pivotOffset.x, pivot.y - pivotOffset.y); + regionMax = ImVec2(regionMin.x + size.x, regionMin.y + size.y); + } + outMin = regionMin; + outMax = regionMax; + }; + + ImVec2 overlayPos = ImGui::GetWindowPos(); + ImVec2 overlaySize = ImGui::GetWindowSize(); + auto clampRectToOverlay = [&](const ImVec2& min, const ImVec2& max, ImVec2& outMin, ImVec2& outMax) { + outMin = ImVec2(std::max(min.x, overlayPos.x), std::max(min.y, overlayPos.y)); + outMax = ImVec2(std::min(max.x, overlayPos.x + overlaySize.x), std::min(max.y, overlayPos.y + overlaySize.y)); + return (outMax.x > outMin.x && outMax.y > outMin.y); + }; + + auto brighten = [](const ImVec4& c, float k) { + return ImVec4(std::clamp(c.x * k, 0.0f, 1.0f), + std::clamp(c.y * k, 0.0f, 1.0f), + std::clamp(c.z * k, 0.0f, 1.0f), + c.w); + }; + + for (auto& obj : sceneObjects) { + if (!obj.enabled || !isUIType(obj.type)) continue; + ImVec2 rectMin, rectMax; + resolveUIRect(obj, rectMin, rectMax); + ImVec2 rectSize(rectMax.x - rectMin.x, rectMax.y - rectMin.y); + if (rectSize.x <= 1.0f || rectSize.y <= 1.0f) continue; + + ImGuiStyle savedStyle = ImGui::GetStyle(); + bool styleApplied = false; + if (!obj.ui.stylePreset.empty()) { + if (const auto* preset = getUIStylePreset(obj.ui.stylePreset)) { + ImGui::GetStyle() = preset->style; + styleApplied = true; + } + } + + if (obj.type == ObjectType::Canvas) { + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddRect(rectMin, rectMax, IM_COL32(110, 170, 255, 140), 6.0f, 0, 1.5f); + if (styleApplied) ImGui::GetStyle() = savedStyle; + continue; + } + + ImVec2 clippedMin, clippedMax; + if (!clampRectToOverlay(rectMin, rectMax, clippedMin, clippedMax)) { + continue; + } + ImVec2 clippedSize(clippedMax.x - clippedMin.x, clippedMax.y - clippedMin.y); + ImVec2 localMin(clippedMin.x - overlayPos.x, clippedMin.y - overlayPos.y); + + ImGui::PushID(obj.id); + if (obj.type == ObjectType::UIImage || obj.type == ObjectType::Sprite2D) { + unsigned int texId = 0; + if (!obj.albedoTexturePath.empty()) { + if (auto* tex = renderer.getTexture(obj.albedoTexturePath)) { + texId = tex->GetID(); + } + } + ImGui::SetCursorPos(localMin); + ImVec4 tint(obj.ui.color.r, obj.ui.color.g, obj.ui.color.b, obj.ui.color.a); + if (texId != 0) { + ImGui::Image((ImTextureID)(intptr_t)texId, clippedSize, ImVec2(0, 1), ImVec2(1, 0), tint, ImVec4(0, 0, 0, 0)); + } else { + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImU32 fill = ImGui::GetColorU32(tint); + ImU32 border = ImGui::GetColorU32(brighten(tint, 0.85f)); + dl->AddRectFilled(clippedMin, clippedMax, fill, 6.0f); + dl->AddRect(clippedMin, clippedMax, border, 6.0f); + ImVec2 textSize = ImGui::CalcTextSize(obj.ui.label.c_str()); + ImVec2 textPos(clippedMin.x + (clippedSize.x - textSize.x) * 0.5f, clippedMin.y + (clippedSize.y - textSize.y) * 0.5f); + dl->AddText(textPos, IM_COL32(210, 210, 220, 220), obj.ui.label.c_str()); + ImGui::Dummy(clippedSize); + } + } else if (obj.type == ObjectType::UISlider) { + ImGui::SetCursorPos(localMin); + ImVec4 tint(obj.ui.color.r, obj.ui.color.g, obj.ui.color.b, obj.ui.color.a); + if (obj.ui.sliderStyle == UISliderStyle::ImGui) { + ImGui::PushItemWidth(clippedSize.x); + ImGui::BeginDisabled(!obj.ui.interactable); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(tint.x * 0.2f, tint.y * 0.2f, tint.z * 0.2f, tint.w * 0.6f)); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, brighten(tint, 0.5f)); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, brighten(tint, 0.7f)); + ImGui::PushStyleColor(ImGuiCol_SliderGrab, brighten(tint, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_SliderGrabActive, brighten(tint, 1.1f)); + if (ImGui::SliderFloat(obj.ui.label.c_str(), &obj.ui.sliderValue, obj.ui.sliderMin, obj.ui.sliderMax)) { + projectManager.currentProject.hasUnsavedChanges = true; + } + ImGui::PopStyleColor(5); + ImGui::EndDisabled(); + ImGui::PopItemWidth(); + } else { + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImU32 bg = ImGui::GetColorU32(ImVec4(tint.x * 0.2f, tint.y * 0.2f, tint.z * 0.2f, tint.w * 0.6f)); + ImU32 fill = ImGui::GetColorU32(tint); + ImU32 border = ImGui::GetColorU32(brighten(tint, 0.85f)); + float minValue = obj.ui.sliderMin; + float maxValue = obj.ui.sliderMax; + float range = (maxValue - minValue); + if (range <= 1e-6f) range = 1.0f; + float t = (obj.ui.sliderValue - minValue) / range; + t = std::clamp(t, 0.0f, 1.0f); + + ImGui::BeginDisabled(!obj.ui.interactable); + ImGui::InvisibleButton("##UISlider", clippedSize); + bool held = obj.ui.interactable && ImGui::IsItemActive(); + if (held && ImGui::IsMouseDown(ImGuiMouseButton_Left) && clippedSize.x > 1.0f) { + float mouseT = (ImGui::GetIO().MousePos.x - clippedMin.x) / clippedSize.x; + mouseT = std::clamp(mouseT, 0.0f, 1.0f); + float newValue = minValue + mouseT * range; + if (newValue != obj.ui.sliderValue) { + obj.ui.sliderValue = newValue; + projectManager.currentProject.hasUnsavedChanges = true; + } + } + ImGui::EndDisabled(); + + if (obj.ui.sliderStyle == UISliderStyle::Fill) { + float rounding = 6.0f; + ImVec2 fillMax(clippedMin.x + clippedSize.x * t, clippedMax.y); + dl->AddRectFilled(clippedMin, clippedMax, bg, rounding); + if (fillMax.x > clippedMin.x) { + dl->AddRectFilled(clippedMin, fillMax, fill, rounding); + } + dl->AddRect(clippedMin, clippedMax, border, rounding); + ImVec2 textSize = ImGui::CalcTextSize(obj.ui.label.c_str()); + ImVec2 textPos(clippedMin.x + (clippedSize.x - textSize.x) * 0.5f, + clippedMin.y + (clippedSize.y - textSize.y) * 0.5f); + dl->AddText(textPos, IM_COL32(240, 240, 245, 220), obj.ui.label.c_str()); + } else if (obj.ui.sliderStyle == UISliderStyle::Circle) { + ImVec2 center((clippedMin.x + clippedMax.x) * 0.5f, (clippedMin.y + clippedMax.y) * 0.5f); + float radius = std::max(2.0f, std::min(clippedSize.x, clippedSize.y) * 0.5f - 2.0f); + dl->AddCircleFilled(center, radius, bg, 32); + float start = -IM_PI * 0.5f; + float end = start + t * IM_PI * 2.0f; + dl->PathClear(); + dl->PathArcTo(center, radius, start, end, 32); + dl->PathLineTo(center); + dl->PathFillConvex(fill); + dl->AddCircle(center, radius, border, 32, 2.0f); + ImVec2 textSize = ImGui::CalcTextSize(obj.ui.label.c_str()); + ImVec2 textPos(center.x - textSize.x * 0.5f, center.y - textSize.y * 0.5f); + dl->AddText(textPos, IM_COL32(240, 240, 245, 220), obj.ui.label.c_str()); + } + } + } else if (obj.type == ObjectType::UIButton) { + ImGui::SetCursorPos(localMin); + ImVec4 tint(obj.ui.color.r, obj.ui.color.g, obj.ui.color.b, obj.ui.color.a); + obj.ui.buttonPressed = false; + if (obj.ui.buttonStyle == UIButtonStyle::ImGui) { + ImGui::PushStyleColor(ImGuiCol_Button, tint); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, brighten(tint, 1.1f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, brighten(tint, 1.2f)); + ImGui::BeginDisabled(!obj.ui.interactable); + obj.ui.buttonPressed = ImGui::Button(obj.ui.label.c_str(), clippedSize); + ImGui::EndDisabled(); + ImGui::PopStyleColor(3); + } else if (obj.ui.buttonStyle == UIButtonStyle::Outline) { + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImU32 border = ImGui::GetColorU32(tint); + ImU32 fill = ImGui::GetColorU32(brighten(tint, 0.45f)); + ImGui::BeginDisabled(!obj.ui.interactable); + if (ImGui::InvisibleButton("##UIButton", clippedSize)) { + obj.ui.buttonPressed = obj.ui.interactable; + } + bool hovered = ImGui::IsItemHovered(); + bool active = ImGui::IsItemActive(); + ImGui::EndDisabled(); + if (hovered) { + dl->AddRectFilled(clippedMin, clippedMax, fill, 6.0f); + } + if (active) { + dl->AddRectFilled(clippedMin, clippedMax, ImGui::GetColorU32(brighten(tint, 0.65f)), 6.0f); + } + dl->AddRect(clippedMin, clippedMax, border, 6.0f, 0, 2.0f); + ImVec2 textSize = ImGui::CalcTextSize(obj.ui.label.c_str()); + ImVec2 textPos(clippedMin.x + (clippedSize.x - textSize.x) * 0.5f, + clippedMin.y + (clippedSize.y - textSize.y) * 0.5f); + dl->AddText(textPos, IM_COL32(240, 240, 245, 220), obj.ui.label.c_str()); + } + } else if (obj.type == ObjectType::UIText) { + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImVec4 tint(obj.ui.color.r, obj.ui.color.g, obj.ui.color.b, obj.ui.color.a); + float scale = std::max(0.1f, obj.ui.textScale); + float fontSize = std::max(1.0f, ImGui::GetFontSize() * scale); + ImVec2 textPos = ImVec2(clippedMin.x + 4.0f, clippedMin.y + 2.0f); + ImGui::PushClipRect(clippedMin, clippedMax, true); + dl->AddText(ImGui::GetFont(), fontSize, textPos, ImGui::GetColorU32(tint), obj.ui.label.c_str()); + ImGui::PopClipRect(); + } + ImGui::PopID(); + if (styleApplied) ImGui::GetStyle() = savedStyle; + } + + bool gizmoUsed = false; + if (!isPlaying) { + SceneObject* selected = getSelectedObject(); + if (selected && isUIType(selected->type) && selected->type != ObjectType::Canvas) { + ImVec2 rectMin, rectMax; + ImVec2 parentMin, parentMax; + resolveUIRect(*selected, rectMin, rectMax, &parentMin, &parentMax); + ImVec2 rectSize(rectMax.x - rectMin.x, rectMax.y - rectMin.y); + + glm::mat4 view(1.0f); + glm::mat4 proj = glm::ortho(0.0f, (float)(imageMax.x - imageMin.x), + (float)(imageMax.y - imageMin.y), 0.0f, -1.0f, 1.0f); + glm::mat4 model(1.0f); + model = glm::translate(model, glm::vec3(rectMin.x - imageMin.x, rectMin.y - imageMin.y, 0.0f)); + model = glm::scale(model, glm::vec3(rectSize.x, rectSize.y, 1.0f)); + + ImGuizmo::BeginFrame(); + ImGuizmo::Enable(true); + ImGuizmo::SetOrthographic(true); + ImGuizmo::SetDrawlist(ImGui::GetWindowDrawList()); + ImGuizmo::SetRect(imageMin.x, imageMin.y, imageMax.x - imageMin.x, imageMax.y - imageMin.y); + + ImGuizmo::OPERATION op = (mCurrentGizmoOperation == ImGuizmo::SCALE) ? ImGuizmo::SCALE : ImGuizmo::TRANSLATE; + glm::mat4 delta(1.0f); + ImGuizmo::Manipulate(glm::value_ptr(view), glm::value_ptr(proj), op, ImGuizmo::LOCAL, glm::value_ptr(model), glm::value_ptr(delta)); + if (ImGuizmo::IsUsing()) { + glm::vec3 pos, rot, scl; + DecomposeMatrix(model, pos, rot, scl); + (void)rot; + ImVec2 newMin(imageMin.x + pos.x, imageMin.y + pos.y); + ImVec2 newSize(std::max(1.0f, scl.x), std::max(1.0f, scl.y)); + ImVec2 anchorPoint = anchorToPoint(selected->ui.anchor, parentMin, parentMax); + ImVec2 pivotOffset = anchorToPivot(selected->ui.anchor, newSize); + ImVec2 pivot(newMin.x + pivotOffset.x, newMin.y + pivotOffset.y); + selected->ui.position = glm::vec2(pivot.x - anchorPoint.x, pivot.y - anchorPoint.y); + selected->ui.size = glm::vec2(newSize.x, newSize.y); + projectManager.currentProject.hasUnsavedChanges = true; + gizmoUsed = true; + } + } + } + + uiInteracting = ImGui::IsAnyItemHovered() || ImGui::IsAnyItemActive() || gizmoUsed; + + ImGui::EndChild(); + ImGui::PopStyleVar(); + bool clicked = imageHovered && isPlaying && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !uiInteracting; if (clicked && !gameViewCursorLocked) { gameViewCursorLocked = true; @@ -548,7 +860,7 @@ void Engine::renderMainMenuBar() { if (ImGui::BeginMenu("Help")) { if (ImGui::MenuItem("About")) { - logToConsole("Modularity Engine v0.6.8"); + logToConsole("Modularity Engine - Beta V1.0\nThis build is in beta and might have issues,\n\nif you'd like to report any bugs or missing features, feel free to contact us!"); } ImGui::EndMenu(); } @@ -766,7 +1078,7 @@ void Engine::renderViewport() { }; SceneObject* selectedObj = getSelectedObject(); - if (selectedObj && selectedObj->type != ObjectType::PostFXNode) { + if (selectedObj && selectedObj->type != ObjectType::PostFXNode && selectedObj->type != ObjectType::Canvas && selectedObj->type != ObjectType::UIImage && selectedObj->type != ObjectType::UISlider && selectedObj->type != ObjectType::UIButton && selectedObj->type != ObjectType::UIText && selectedObj->type != ObjectType::Sprite2D) { ImGuizmo::BeginFrame(); ImGuizmo::Enable(true); ImGuizmo::SetOrthographic(false); @@ -808,6 +1120,7 @@ void Engine::renderViewport() { std::vector edges; edges.reserve(meshEditAsset.faces.size() * 3); std::unordered_set edgeSet; + std::unordered_map edgeIndex; auto edgeKey = [](uint32_t a, uint32_t b) { return (static_cast(std::min(a,b)) << 32) | static_cast(std::max(a,b)); }; @@ -819,7 +1132,9 @@ void Engine::renderViewport() { uint32_t b = tri[(e+1)%3]; uint64_t key = edgeKey(a,b); if (edgeSet.insert(key).second) { + int idx = (int)edges.size(); edges.push_back(glm::u32vec2(std::min(a,b), std::max(a,b))); + edgeIndex[key] = idx; } } } @@ -827,20 +1142,89 @@ void Engine::renderViewport() { ImDrawList* dl = ImGui::GetWindowDrawList(); ImU32 vertCol = ImGui::GetColorU32(ImVec4(0.35f, 0.75f, 1.0f, 0.9f)); ImU32 selCol = ImGui::GetColorU32(ImVec4(1.0f, 0.6f, 0.2f, 1.0f)); - ImU32 edgeCol = ImGui::GetColorU32(ImVec4(0.6f, 0.9f, 1.0f, 0.6f)); - ImU32 faceCol = ImGui::GetColorU32(ImVec4(1.0f, 0.8f, 0.4f, 0.7f)); + float edgeAlpha = (meshEditSelectionMode == MeshEditSelectionMode::Face) ? 0.35f : 0.6f; + ImU32 edgeCol = ImGui::GetColorU32(ImVec4(0.6f, 0.9f, 1.0f, edgeAlpha)); + ImU32 faceSelFillCol = ImGui::GetColorU32(ImVec4(1.0f, 0.6f, 0.2f, 0.38f)); float selectRadius = 10.0f; ImVec2 mouse = ImGui::GetIO().MousePos; - bool clicked = mouseOverViewportImage && ImGui::IsMouseClicked(0); + bool clicked = mouseOverViewportImage && ImGui::IsMouseClicked(0) && !ImGuizmo::IsUsing() && !ImGuizmo::IsOver(); bool additiveClick = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeyShift; - float bestDist = selectRadius; - int clickedIndex = -1; glm::mat4 invModel = glm::inverse(modelMatrix); + glm::mat4 invViewProj = glm::inverse(proj * view); + + auto distPointToSegment = [](const ImVec2& p, const ImVec2& a, const ImVec2& b) { + ImVec2 ab = ImVec2(b.x - a.x, b.y - a.y); + float len2 = ab.x * ab.x + ab.y * ab.y; + if (len2 < 1e-4f) { + float dx = p.x - a.x; + float dy = p.y - a.y; + return std::sqrt(dx * dx + dy * dy); + } + float t = ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / len2; + t = std::clamp(t, 0.0f, 1.0f); + ImVec2 proj = ImVec2(a.x + ab.x * t, a.y + ab.y * t); + float dx = p.x - proj.x; + float dy = p.y - proj.y; + return std::sqrt(dx * dx + dy * dy); + }; + + auto makeRay = [&](const ImVec2& pos) { + float x = (pos.x - imageMin.x) / (imageMax.x - imageMin.x); + float y = (pos.y - imageMin.y) / (imageMax.y - imageMin.y); + x = x * 2.0f - 1.0f; + y = 1.0f - y * 2.0f; + + glm::vec4 nearPt = invViewProj * glm::vec4(x, y, -1.0f, 1.0f); + glm::vec4 farPt = invViewProj * glm::vec4(x, y, 1.0f, 1.0f); + nearPt /= nearPt.w; + farPt /= farPt.w; + + glm::vec3 origin = glm::vec3(nearPt); + glm::vec3 dir = glm::normalize(glm::vec3(farPt - nearPt)); + return std::make_pair(origin, dir); + }; + + auto rayTriangle = [](const glm::vec3& orig, const glm::vec3& dir, const glm::vec3& v0, const glm::vec3& v1, const glm::vec3& v2, float& tHit) { + const float EPSILON = 1e-6f; + glm::vec3 e1 = v1 - v0; + glm::vec3 e2 = v2 - v0; + glm::vec3 pvec = glm::cross(dir, e2); + float det = glm::dot(e1, pvec); + if (fabs(det) < EPSILON) return false; + float invDet = 1.0f / det; + glm::vec3 tvec = orig - v0; + float u = glm::dot(tvec, pvec) * invDet; + if (u < 0.0f || u > 1.0f) return false; + glm::vec3 qvec = glm::cross(tvec, e1); + float v = glm::dot(dir, qvec) * invDet; + if (v < 0.0f || u + v > 1.0f) return false; + float t = glm::dot(e2, qvec) * invDet; + if (t < 0.0f) return false; + tHit = t; + return true; + }; + + float baseEdgeThickness = (meshEditSelectionMode == MeshEditSelectionMode::Edge) ? 2.2f : 1.4f; + for (size_t ei = 0; ei < edges.size(); ++ei) { + const auto& e = edges[ei]; + glm::vec3 a = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[e.x], 1.0f)); + glm::vec3 b = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[e.y], 1.0f)); + auto sa = projectToScreen(a); + auto sb = projectToScreen(b); + if (!sa || !sb) continue; + bool sel = meshEditSelectionMode == MeshEditSelectionMode::Edge && + std::find(meshEditSelectedEdges.begin(), meshEditSelectedEdges.end(), (int)ei) != meshEditSelectedEdges.end(); + float thickness = sel ? baseEdgeThickness + 1.1f : baseEdgeThickness; + ImU32 color = sel ? selCol : edgeCol; + dl->AddLine(*sa, *sb, color, thickness); + } if (meshEditSelectionMode == MeshEditSelectionMode::Vertex) { const size_t maxDraw = std::min(meshEditAsset.positions.size(), 2000); + float bestDist = selectRadius; + int clickedIndex = -1; for (size_t i = 0; i < maxDraw; ++i) { glm::vec3 world = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[i], 1.0f)); auto screen = projectToScreen(world); @@ -860,114 +1244,155 @@ void Engine::renderViewport() { } } - if (clickedIndex >= 0) { - if (additiveClick) { - auto itSel = std::find(meshEditSelectedVertices.begin(), meshEditSelectedVertices.end(), clickedIndex); - if (itSel == meshEditSelectedVertices.end()) { - meshEditSelectedVertices.push_back(clickedIndex); + if (clicked) { + if (clickedIndex >= 0) { + if (additiveClick) { + auto itSel = std::find(meshEditSelectedVertices.begin(), meshEditSelectedVertices.end(), clickedIndex); + if (itSel == meshEditSelectedVertices.end()) { + meshEditSelectedVertices.push_back(clickedIndex); + } else { + meshEditSelectedVertices.erase(itSel); + } } else { - meshEditSelectedVertices.erase(itSel); + meshEditSelectedVertices.clear(); + meshEditSelectedVertices.push_back(clickedIndex); } - } else { + } else if (!additiveClick) { meshEditSelectedVertices.clear(); - meshEditSelectedVertices.push_back(clickedIndex); } meshEditSelectedEdges.clear(); meshEditSelectedFaces.clear(); } - - if (meshEditSelectedVertices.empty()) { - meshEditSelectedVertices.push_back(0); - } } else if (meshEditSelectionMode == MeshEditSelectionMode::Edge) { - for (size_t ei = 0; ei < edges.size(); ++ei) { - const auto& e = edges[ei]; - glm::vec3 a = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[e.x], 1.0f)); - glm::vec3 b = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[e.y], 1.0f)); - auto sa = projectToScreen(a); - auto sb = projectToScreen(b); - if (!sa || !sb) continue; - ImVec2 mid = ImVec2((sa->x + sb->x) * 0.5f, (sa->y + sb->y) * 0.5f); - bool sel = std::find(meshEditSelectedEdges.begin(), meshEditSelectedEdges.end(), (int)ei) != meshEditSelectedEdges.end(); - dl->AddLine(*sa, *sb, edgeCol, sel ? 3.0f : 2.0f); - dl->AddCircleFilled(mid, sel ? 6.0f : 4.0f, sel ? selCol : edgeCol); + int clickedIndex = -1; + if (clicked) { + auto ray = makeRay(mouse); + glm::vec3 localOrigin = glm::vec3(invModel * glm::vec4(ray.first, 1.0f)); + glm::vec3 localDir = glm::normalize(glm::vec3(invModel * glm::vec4(ray.second, 0.0f))); + float bestT = FLT_MAX; + int hitFace = -1; + for (size_t fi = 0; fi < meshEditAsset.faces.size(); ++fi) { + const auto& f = meshEditAsset.faces[fi]; + if (f.x >= meshEditAsset.positions.size() || f.y >= meshEditAsset.positions.size() || f.z >= meshEditAsset.positions.size()) continue; + float tHit = 0.0f; + if (rayTriangle(localOrigin, localDir, + meshEditAsset.positions[f.x], + meshEditAsset.positions[f.y], + meshEditAsset.positions[f.z], + tHit)) + { + if (tHit < bestT) { + bestT = tHit; + hitFace = static_cast(fi); + } + } + } - if (clicked) { - float dx = mid.x - mouse.x; - float dy = mid.y - mouse.y; - float dist = std::sqrt(dx*dx + dy*dy); - if (dist < bestDist) { - bestDist = dist; - clickedIndex = static_cast(ei); + if (hitFace >= 0) { + const auto& f = meshEditAsset.faces[hitFace]; + uint32_t tri[3] = { f.x, f.y, f.z }; + float bestDist = selectRadius; + for (int e = 0; e < 3; ++e) { + uint32_t aIdx = tri[e]; + uint32_t bIdx = tri[(e + 1) % 3]; + glm::vec3 a = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[aIdx], 1.0f)); + glm::vec3 b = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[bIdx], 1.0f)); + auto sa = projectToScreen(a); + auto sb = projectToScreen(b); + if (!sa || !sb) continue; + float dist = distPointToSegment(mouse, *sa, *sb); + if (dist < bestDist) { + bestDist = dist; + auto it = edgeIndex.find(edgeKey(aIdx, bIdx)); + if (it != edgeIndex.end()) { + clickedIndex = it->second; + } + } } } } - if (clickedIndex >= 0) { - if (additiveClick) { - auto itSel = std::find(meshEditSelectedEdges.begin(), meshEditSelectedEdges.end(), clickedIndex); - if (itSel == meshEditSelectedEdges.end()) { - meshEditSelectedEdges.push_back(clickedIndex); + + if (clicked) { + if (clickedIndex >= 0) { + if (additiveClick) { + auto itSel = std::find(meshEditSelectedEdges.begin(), meshEditSelectedEdges.end(), clickedIndex); + if (itSel == meshEditSelectedEdges.end()) { + meshEditSelectedEdges.push_back(clickedIndex); + } else { + meshEditSelectedEdges.erase(itSel); + } } else { - meshEditSelectedEdges.erase(itSel); + meshEditSelectedEdges.clear(); + meshEditSelectedEdges.push_back(clickedIndex); } - } else { + } else if (!additiveClick) { meshEditSelectedEdges.clear(); - meshEditSelectedEdges.push_back(clickedIndex); } meshEditSelectedVertices.clear(); meshEditSelectedFaces.clear(); } - if (meshEditSelectedEdges.empty() && !edges.empty()) { - meshEditSelectedEdges.push_back(0); - } } else if (meshEditSelectionMode == MeshEditSelectionMode::Face) { - for (size_t fi = 0; fi < meshEditAsset.faces.size(); ++fi) { + for (int fi : meshEditSelectedFaces) { + if (fi < 0 || fi >= (int)meshEditAsset.faces.size()) continue; const auto& f = meshEditAsset.faces[fi]; glm::vec3 a = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[f.x], 1.0f)); glm::vec3 b = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[f.y], 1.0f)); glm::vec3 c = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[f.z], 1.0f)); - glm::vec3 centroid = (a + b + c) / 3.0f; - auto sc = projectToScreen(centroid); - if (!sc) continue; - bool sel = std::find(meshEditSelectedFaces.begin(), meshEditSelectedFaces.end(), (int)fi) != meshEditSelectedFaces.end(); - dl->AddCircleFilled(*sc, sel ? 7.0f : 5.0f, sel ? selCol : faceCol); + auto sa = projectToScreen(a); + auto sb = projectToScreen(b); + auto sc = projectToScreen(c); + if (!sa || !sb || !sc) continue; + dl->AddTriangleFilled(*sa, *sb, *sc, faceSelFillCol); + dl->AddTriangle(*sa, *sb, *sc, selCol, 2.0f); + } - if (clicked) { - float dx = sc->x - mouse.x; - float dy = sc->y - mouse.y; - float dist = std::sqrt(dx*dx + dy*dy); - if (dist < bestDist) { - bestDist = dist; - clickedIndex = static_cast(fi); + if (clicked) { + auto ray = makeRay(mouse); + glm::vec3 localOrigin = glm::vec3(invModel * glm::vec4(ray.first, 1.0f)); + glm::vec3 localDir = glm::normalize(glm::vec3(invModel * glm::vec4(ray.second, 0.0f))); + float bestT = FLT_MAX; + int clickedIndex = -1; + for (size_t fi = 0; fi < meshEditAsset.faces.size(); ++fi) { + const auto& f = meshEditAsset.faces[fi]; + if (f.x >= meshEditAsset.positions.size() || f.y >= meshEditAsset.positions.size() || f.z >= meshEditAsset.positions.size()) continue; + float tHit = 0.0f; + if (rayTriangle(localOrigin, localDir, + meshEditAsset.positions[f.x], + meshEditAsset.positions[f.y], + meshEditAsset.positions[f.z], + tHit)) { + if (tHit < bestT) { + bestT = tHit; + clickedIndex = static_cast(fi); + } } } - } - if (clickedIndex >= 0) { - if (additiveClick) { - auto itSel = std::find(meshEditSelectedFaces.begin(), meshEditSelectedFaces.end(), clickedIndex); - if (itSel == meshEditSelectedFaces.end()) { - meshEditSelectedFaces.push_back(clickedIndex); + if (clickedIndex >= 0) { + if (additiveClick) { + auto itSel = std::find(meshEditSelectedFaces.begin(), meshEditSelectedFaces.end(), clickedIndex); + if (itSel == meshEditSelectedFaces.end()) { + meshEditSelectedFaces.push_back(clickedIndex); + } else { + meshEditSelectedFaces.erase(itSel); + } } else { - meshEditSelectedFaces.erase(itSel); + meshEditSelectedFaces.clear(); + meshEditSelectedFaces.push_back(clickedIndex); } - } else { + } else if (!additiveClick) { meshEditSelectedFaces.clear(); - meshEditSelectedFaces.push_back(clickedIndex); } meshEditSelectedVertices.clear(); meshEditSelectedEdges.clear(); } - if (meshEditSelectedFaces.empty() && !meshEditAsset.faces.empty()) { - meshEditSelectedFaces.push_back(0); - } } // Compute affected vertices from selection - std::vector affectedVerts = meshEditSelectedVertices; + std::vector baseAffectedVerts = meshEditSelectedVertices; auto pushUnique = [&](int idx) { if (idx < 0) return; - if (std::find(affectedVerts.begin(), affectedVerts.end(), idx) == affectedVerts.end()) { - affectedVerts.push_back(idx); + if (std::find(baseAffectedVerts.begin(), baseAffectedVerts.end(), idx) == baseAffectedVerts.end()) { + baseAffectedVerts.push_back(idx); } }; if (meshEditSelectionMode == MeshEditSelectionMode::Edge) { @@ -985,42 +1410,8 @@ void Engine::renderViewport() { pushUnique(f.z); } } - if (affectedVerts.empty() && !meshEditAsset.positions.empty()) { - affectedVerts.push_back(0); - } - glm::vec3 pivotWorld(0.0f); - for (int idx : affectedVerts) { - glm::vec3 wp = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[idx], 1.0f)); - pivotWorld += wp; - } - pivotWorld /= (float)affectedVerts.size(); - - glm::mat4 gizmoMat = glm::translate(glm::mat4(1.0f), pivotWorld); - - ImGuizmo::Manipulate( - glm::value_ptr(view), - glm::value_ptr(proj), - ImGuizmo::TRANSLATE, - ImGuizmo::WORLD, - glm::value_ptr(gizmoMat) - ); - - static bool meshEditHistoryCaptured = false; - if (ImGuizmo::IsUsing()) { - if (!meshEditHistoryCaptured) { - recordState("meshEdit"); - meshEditHistoryCaptured = true; - } - glm::vec3 deltaWorld = glm::vec3(gizmoMat[3]) - pivotWorld; - for (int idx : affectedVerts) { - glm::vec3 wp = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[idx], 1.0f)); - wp += deltaWorld; - glm::vec3 newLocal = glm::vec3(invModel * glm::vec4(wp, 1.0f)); - meshEditAsset.positions[idx] = newLocal; - } - - // Recompute bounds + auto recalcMesh = [&]() { meshEditAsset.boundsMin = glm::vec3(FLT_MAX); meshEditAsset.boundsMax = glm::vec3(-FLT_MAX); for (const auto& p : meshEditAsset.positions) { @@ -1032,7 +1423,6 @@ void Engine::renderViewport() { meshEditAsset.boundsMax.z = std::max(meshEditAsset.boundsMax.z, p.z); } - // Recompute normals meshEditAsset.normals.assign(meshEditAsset.positions.size(), glm::vec3(0.0f)); for (const auto& f : meshEditAsset.faces) { if (f.x >= meshEditAsset.positions.size() || f.y >= meshEditAsset.positions.size() || f.z >= meshEditAsset.positions.size()) continue; @@ -1048,10 +1438,243 @@ void Engine::renderViewport() { if (glm::length(n) > 1e-6f) n = glm::normalize(n); } meshEditAsset.hasNormals = true; + }; - syncMeshEditToGPU(selectedObj); + static bool meshEditHistoryCaptured = false; + static bool meshEditWasUsing = false; + static bool meshEditExtruding = false; + static std::vector meshEditExtrudeVerts; + + if (!baseAffectedVerts.empty()) { + glm::vec3 pivotWorld(0.0f); + for (int idx : baseAffectedVerts) { + glm::vec3 wp = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[idx], 1.0f)); + pivotWorld += wp; + } + pivotWorld /= (float)baseAffectedVerts.size(); + + glm::mat4 gizmoMat = glm::translate(glm::mat4(1.0f), pivotWorld); + + ImGuizmo::Manipulate( + glm::value_ptr(view), + glm::value_ptr(proj), + ImGuizmo::TRANSLATE, + ImGuizmo::WORLD, + glm::value_ptr(gizmoMat) + ); + + bool usingNow = ImGuizmo::IsUsing(); + if (usingNow && !meshEditWasUsing) { + bool wantsExtrude = meshEditExtrudeMode || ImGui::GetIO().KeyShift; + bool seams = ImGui::GetIO().KeyShift && ImGui::GetIO().KeyCtrl; + meshEditExtruding = false; + meshEditExtrudeVerts.clear(); + + auto duplicateVertex = [&](uint32_t idx) -> uint32_t { + uint32_t newIdx = static_cast(meshEditAsset.positions.size()); + meshEditAsset.positions.push_back(meshEditAsset.positions[idx]); + if (idx < meshEditAsset.normals.size()) { + meshEditAsset.normals.push_back(meshEditAsset.normals[idx]); + } else { + meshEditAsset.normals.push_back(glm::vec3(0.0f)); + } + if (idx < meshEditAsset.uvs.size()) { + meshEditAsset.uvs.push_back(meshEditAsset.uvs[idx]); + } else { + meshEditAsset.uvs.push_back(glm::vec2(0.0f)); + } + return newIdx; + }; + auto pushExtrudeVert = [&](int idx) { + if (std::find(meshEditExtrudeVerts.begin(), meshEditExtrudeVerts.end(), idx) == meshEditExtrudeVerts.end()) { + meshEditExtrudeVerts.push_back(idx); + } + }; + + if (wantsExtrude && meshEditSelectionMode == MeshEditSelectionMode::Face && !meshEditSelectedFaces.empty()) { + const size_t faceCount = meshEditAsset.faces.size(); + std::vector originalFaces = meshEditAsset.faces; + std::vector faceSelected(faceCount, false); + for (int fi : meshEditSelectedFaces) { + if (fi >= 0 && fi < (int)faceCount) faceSelected[fi] = true; + } + + std::unordered_map vertexMap; + std::unordered_map newFaceVerts; + std::vector newFaceSelection; + vertexMap.reserve(meshEditSelectedFaces.size() * 3); + newFaceVerts.reserve(meshEditSelectedFaces.size()); + newFaceSelection.reserve(meshEditSelectedFaces.size()); + for (int fi : meshEditSelectedFaces) { + if (fi < 0 || fi >= (int)faceCount) continue; + const auto f = originalFaces[fi]; + uint32_t idx[3] = { f.x, f.y, f.z }; + uint32_t newIdx[3]; + for (int k = 0; k < 3; ++k) { + if (seams) { + newIdx[k] = duplicateVertex(idx[k]); + } else { + auto it = vertexMap.find(idx[k]); + if (it == vertexMap.end()) { + uint32_t created = duplicateVertex(idx[k]); + vertexMap[idx[k]] = created; + newIdx[k] = created; + } else { + newIdx[k] = it->second; + } + } + pushExtrudeVert((int)newIdx[k]); + } + meshEditAsset.faces.push_back(glm::u32vec3(newIdx[0], newIdx[1], newIdx[2])); + int newFaceIndex = (int)meshEditAsset.faces.size() - 1; + newFaceVerts[fi] = glm::u32vec3(newIdx[0], newIdx[1], newIdx[2]); + newFaceSelection.push_back(newFaceIndex); + } + + auto addSide = [&](uint32_t a, uint32_t b, uint32_t aNew, uint32_t bNew) { + meshEditAsset.faces.push_back(glm::u32vec3(a, b, bNew)); + meshEditAsset.faces.push_back(glm::u32vec3(a, bNew, aNew)); + }; + + if (seams) { + for (int fi : meshEditSelectedFaces) { + if (fi < 0 || fi >= (int)faceCount) continue; + auto itFace = newFaceVerts.find(fi); + if (itFace == newFaceVerts.end()) continue; + const auto f = itFace->second; + const auto oldF = originalFaces[fi]; + uint32_t oldIdx[3] = { oldF.x, oldF.y, oldF.z }; + uint32_t newIdx[3] = { f.x, f.y, f.z }; + addSide(oldIdx[0], oldIdx[1], newIdx[0], newIdx[1]); + addSide(oldIdx[1], oldIdx[2], newIdx[1], newIdx[2]); + addSide(oldIdx[2], oldIdx[0], newIdx[2], newIdx[0]); + } + } else { + struct EdgeInfo { int total = 0; int selected = 0; }; + std::unordered_map edgeInfo; + edgeInfo.reserve(faceCount * 3); + auto edgeKey = [](uint32_t a, uint32_t b) { + return (static_cast(std::min(a,b)) << 32) | static_cast(std::max(a,b)); + }; + for (size_t fi = 0; fi < faceCount; ++fi) { + const auto& f = originalFaces[fi]; + uint32_t tri[3] = { f.x, f.y, f.z }; + for (int e = 0; e < 3; ++e) { + uint32_t a = tri[e]; + uint32_t b = tri[(e + 1) % 3]; + auto& info = edgeInfo[edgeKey(a, b)]; + info.total += 1; + if (faceSelected[fi]) info.selected += 1; + } + } + for (int fi : meshEditSelectedFaces) { + if (fi < 0 || fi >= (int)faceCount) continue; + const auto& f = originalFaces[fi]; + uint32_t tri[3] = { f.x, f.y, f.z }; + for (int e = 0; e < 3; ++e) { + uint32_t a = tri[e]; + uint32_t b = tri[(e + 1) % 3]; + auto it = edgeInfo.find(edgeKey(a, b)); + if (it == edgeInfo.end()) continue; + if (it->second.selected == 1 && it->second.selected < it->second.total) { + uint32_t aNew = vertexMap[a]; + uint32_t bNew = vertexMap[b]; + addSide(a, b, aNew, bNew); + } else if (it->second.total == 1) { + uint32_t aNew = vertexMap[a]; + uint32_t bNew = vertexMap[b]; + addSide(a, b, aNew, bNew); + } + } + } + } + + if (!newFaceSelection.empty()) { + meshEditSelectedFaces = newFaceSelection; + meshEditSelectedVertices.clear(); + meshEditSelectedEdges.clear(); + } + + meshEditExtruding = !meshEditExtrudeVerts.empty(); + } else if (wantsExtrude && meshEditSelectionMode == MeshEditSelectionMode::Edge && !meshEditSelectedEdges.empty()) { + std::unordered_map vertexMap; + if (!seams) { + vertexMap.reserve(meshEditSelectedEdges.size() * 2); + } + + auto addSide = [&](uint32_t a, uint32_t b, uint32_t aNew, uint32_t bNew) { + meshEditAsset.faces.push_back(glm::u32vec3(a, b, bNew)); + meshEditAsset.faces.push_back(glm::u32vec3(a, bNew, aNew)); + }; + + for (int ei : meshEditSelectedEdges) { + if (ei < 0 || ei >= (int)edges.size()) continue; + uint32_t a = edges[ei].x; + uint32_t b = edges[ei].y; + uint32_t aNew = 0; + uint32_t bNew = 0; + if (seams) { + aNew = duplicateVertex(a); + bNew = duplicateVertex(b); + } else { + auto ita = vertexMap.find(a); + if (ita == vertexMap.end()) { + aNew = duplicateVertex(a); + vertexMap[a] = aNew; + } else { + aNew = ita->second; + } + auto itb = vertexMap.find(b); + if (itb == vertexMap.end()) { + bNew = duplicateVertex(b); + vertexMap[b] = bNew; + } else { + bNew = itb->second; + } + } + pushExtrudeVert((int)aNew); + pushExtrudeVert((int)bNew); + addSide(a, b, aNew, bNew); + } + + meshEditExtruding = !meshEditExtrudeVerts.empty(); + } + } + + std::vector affectedVerts = baseAffectedVerts; + if (meshEditExtruding && !meshEditExtrudeVerts.empty()) { + affectedVerts = meshEditExtrudeVerts; + } + + if (usingNow) { + if (!meshEditHistoryCaptured) { + recordState("meshEdit"); + meshEditHistoryCaptured = true; + } + glm::vec3 deltaWorld = glm::vec3(gizmoMat[3]) - pivotWorld; + for (int idx : affectedVerts) { + glm::vec3 wp = glm::vec3(modelMatrix * glm::vec4(meshEditAsset.positions[idx], 1.0f)); + wp += deltaWorld; + glm::vec3 newLocal = glm::vec3(invModel * glm::vec4(wp, 1.0f)); + meshEditAsset.positions[idx] = newLocal; + } + + recalcMesh(); + meshEditDirty = true; + + syncMeshEditToGPU(selectedObj); + } else { + meshEditHistoryCaptured = false; + meshEditExtruding = false; + meshEditExtrudeVerts.clear(); + } + + meshEditWasUsing = usingNow; } else { meshEditHistoryCaptured = false; + meshEditExtruding = false; + meshEditExtrudeVerts.clear(); + meshEditWasUsing = false; } } else { // Object transform mode @@ -1086,6 +1709,11 @@ void Engine::renderViewport() { gizmoBoundsMin = glm::vec3(-0.5f, -0.5f, -0.02f); gizmoBoundsMax = glm::vec3(0.5f, 0.5f, 0.02f); break; + case ObjectType::Mirror: + case ObjectType::Sprite: + gizmoBoundsMin = glm::vec3(-0.5f, -0.5f, -0.02f); + gizmoBoundsMax = glm::vec3(0.5f, 0.5f, 0.02f); + break; case ObjectType::Torus: gizmoBoundsMin = glm::vec3(-0.5f); gizmoBoundsMax = glm::vec3(0.5f); @@ -1121,6 +1749,15 @@ void Engine::renderViewport() { gizmoBoundsMin = glm::vec3(-0.25f); gizmoBoundsMax = glm::vec3(0.25f); break; + case ObjectType::Sprite2D: + case ObjectType::Canvas: + case ObjectType::UIImage: + case ObjectType::UISlider: + case ObjectType::UIButton: + case ObjectType::UIText: + gizmoBoundsMin = glm::vec3(-0.5f, -0.5f, -0.01f); + gizmoBoundsMax = glm::vec3(0.5f, 0.5f, 0.01f); + break; } float bounds[6] = { @@ -1455,6 +2092,8 @@ void Engine::renderViewport() { if (!meshEditMode) { meshEditLoaded = false; meshEditPath.clear(); + meshEditDirty = false; + meshEditExtrudeMode = false; meshEditSelectedVertices.clear(); meshEditSelectedEdges.clear(); meshEditSelectedFaces.clear(); @@ -1475,6 +2114,27 @@ void Engine::renderViewport() { if (GizmoToolbar::ModeButton("Faces", meshEditSelectionMode == MeshEditSelectionMode::Face, ImVec2(50,24), baseCol, accentCol, textCol)) { meshEditSelectionMode = MeshEditSelectionMode::Face; } + ImGui::SameLine(0.0f, toolbarSpacing * 0.6f); + if (GizmoToolbar::ModeButton("Extrude", meshEditExtrudeMode, ImVec2(68,24), baseCol, accentCol, textCol)) { + meshEditExtrudeMode = !meshEditExtrudeMode; + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Toggle extrude mode (Shift to extrude, Shift+Ctrl for seams)"); + } + ImGui::SameLine(0.0f, toolbarSpacing * 0.8f); + ImGui::BeginDisabled(!meshEditLoaded || meshEditPath.empty()); + if (GizmoToolbar::TextButton("Save", meshEditDirty, ImVec2(52,24), baseBtn, hoverBtn, activeBtn, accent, iconColor)) { + std::string err; + if (!saveMeshEditAsset(err)) { + addConsoleMessage("Mesh save failed: " + err, ConsoleMessageType::Error); + } else { + addConsoleMessage("Saved mesh: " + meshEditPath, ConsoleMessageType::Success); + } + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip(meshEditDirty ? "Save edited mesh to disk" : "Mesh is up to date"); + } + ImGui::EndDisabled(); } ImGui::SameLine(0.0f, toolbarSpacing); gizmoButton("Rect", ImGuizmo::BOUNDS, "Rect scale"); @@ -1648,9 +2308,20 @@ void Engine::renderViewport() { case ObjectType::Mirror: hit = rayAabb(localOrigin, localDir, glm::vec3(-0.5f, -0.5f, -0.02f), glm::vec3(0.5f, 0.5f, 0.02f), hitT); break; + case ObjectType::Sprite: + hit = rayAabb(localOrigin, localDir, glm::vec3(-0.5f, -0.5f, -0.02f), glm::vec3(0.5f, 0.5f, 0.02f), hitT); + break; case ObjectType::Torus: hit = raySphere(localOrigin, localDir, 0.5f, hitT); break; + case ObjectType::Sprite2D: + case ObjectType::Canvas: + case ObjectType::UIImage: + case ObjectType::UISlider: + case ObjectType::UIButton: + case ObjectType::UIText: + hit = false; + break; case ObjectType::OBJMesh: { const auto* info = g_objLoader.getMeshInfo(obj.meshId); if (info && info->boundsMin.x < info->boundsMax.x) { diff --git a/src/Engine.cpp b/src/Engine.cpp index b1faa73..269667a 100644 --- a/src/Engine.cpp +++ b/src/Engine.cpp @@ -5,6 +5,7 @@ #include #include #include +#include "ThirdParty/glm/gtc/constants.hpp" #pragma region Material File IO Helpers namespace { @@ -78,6 +79,122 @@ bool writeMaterialFile(const MaterialFileData& data, const std::string& path) { f << "fragmentShader=" << data.fragmentShader << "\n"; return true; } + +RawMeshAsset buildCubeRMesh() { + RawMeshAsset mesh; + mesh.positions.reserve(24); + mesh.normals.reserve(24); + mesh.uvs.reserve(24); + mesh.faces.reserve(12); + + struct Face { + glm::vec3 n; + glm::vec3 v[4]; + }; + + const float h = 0.5f; + Face faces[] = { + { glm::vec3(0, 0, 1), { {-h,-h, h}, { h,-h, h}, { h, h, h}, {-h, h, h} } }, // +Z + { glm::vec3(0, 0,-1), { { h,-h,-h}, {-h,-h,-h}, {-h, h,-h}, { h, h,-h} } }, // -Z + { glm::vec3(1, 0, 0), { { h,-h, h}, { h,-h,-h}, { h, h,-h}, { h, h, h} } }, // +X + { glm::vec3(-1,0, 0), { {-h,-h,-h}, {-h,-h, h}, {-h, h, h}, {-h, h,-h} } }, // -X + { glm::vec3(0, 1, 0), { {-h, h, h}, { h, h, h}, { h, h,-h}, {-h, h,-h} } }, // +Y + { glm::vec3(0,-1, 0), { {-h,-h,-h}, { h,-h,-h}, { h,-h, h}, {-h,-h, h} } }, // -Y + }; + + glm::vec2 uvs[4] = { + glm::vec2(0, 0), + glm::vec2(1, 0), + glm::vec2(1, 1), + glm::vec2(0, 1), + }; + + for (const auto& f : faces) { + uint32_t base = static_cast(mesh.positions.size()); + for (int i = 0; i < 4; ++i) { + mesh.positions.push_back(f.v[i]); + mesh.normals.push_back(f.n); + mesh.uvs.push_back(uvs[i]); + } + mesh.faces.push_back(glm::u32vec3(base, base + 1, base + 2)); + mesh.faces.push_back(glm::u32vec3(base, base + 2, base + 3)); + } + + mesh.boundsMin = glm::vec3(-h); + mesh.boundsMax = glm::vec3(h); + mesh.hasNormals = true; + mesh.hasUVs = true; + return mesh; +} + +RawMeshAsset buildPlaneRMesh() { + RawMeshAsset mesh; + mesh.positions = { + glm::vec3(-0.5f, 0.0f, 0.5f), + glm::vec3( 0.5f, 0.0f, 0.5f), + glm::vec3( 0.5f, 0.0f, -0.5f), + glm::vec3(-0.5f, 0.0f, -0.5f), + }; + mesh.normals = { + glm::vec3(0, 1, 0), + glm::vec3(0, 1, 0), + glm::vec3(0, 1, 0), + glm::vec3(0, 1, 0), + }; + mesh.uvs = { + glm::vec2(0, 0), + glm::vec2(1, 0), + glm::vec2(1, 1), + glm::vec2(0, 1), + }; + mesh.faces = { + glm::u32vec3(0, 1, 2), + glm::u32vec3(0, 2, 3), + }; + mesh.boundsMin = glm::vec3(-0.5f, 0.0f, -0.5f); + mesh.boundsMax = glm::vec3(0.5f, 0.0f, 0.5f); + mesh.hasNormals = true; + mesh.hasUVs = true; + return mesh; +} + +RawMeshAsset buildSphereRMesh(int slices = 24, int stacks = 16) { + RawMeshAsset mesh; + const float radius = 0.5f; + for (int i = 0; i <= stacks; ++i) { + float v = static_cast(i) / static_cast(stacks); + float phi = v * glm::pi(); + float y = std::cos(phi); + float r = std::sin(phi); + for (int j = 0; j <= slices; ++j) { + float u = static_cast(j) / static_cast(slices); + float theta = u * glm::two_pi(); + float x = r * std::cos(theta); + float z = r * std::sin(theta); + glm::vec3 pos = glm::vec3(x, y, z) * radius; + mesh.positions.push_back(pos); + mesh.normals.push_back(glm::normalize(glm::vec3(x, y, z))); + mesh.uvs.push_back(glm::vec2(u, 1.0f - v)); + } + } + + for (int i = 0; i < stacks; ++i) { + for (int j = 0; j < slices; ++j) { + uint32_t i0 = i * (slices + 1) + j; + uint32_t i1 = i0 + 1; + uint32_t i2 = i0 + (slices + 1); + uint32_t i3 = i2 + 1; + mesh.faces.push_back(glm::u32vec3(i0, i2, i1)); + mesh.faces.push_back(glm::u32vec3(i1, i2, i3)); + } + } + + mesh.boundsMin = glm::vec3(-radius); + mesh.boundsMax = glm::vec3(radius); + mesh.hasNormals = true; + mesh.hasUVs = true; + return mesh; +} } // namespace #pragma endregion @@ -310,6 +427,7 @@ void Engine::run() { std::cerr << "[DEBUG] Entering main loop, showLauncher=" << showLauncher << std::endl; while (!glfwWindowShouldClose(editorWindow)) { + double frameStart = glfwGetTime(); if (glfwGetWindowAttrib(editorWindow, GLFW_ICONIFIED)) { ImGui_ImplGlfw_Sleep(10); continue; @@ -362,6 +480,11 @@ void Engine::run() { updatePlayerController(deltaTime); } + bool simulate2D = (isPlaying && !isPaused) || (!isPlaying && specMode) || (!isPlaying && testMode); + if (simulate2D) { + updateRigidbody2D(deltaTime); + } + updateHierarchyWorldTransforms(); bool simulatePhysics = physics.isReady() && ((isPlaying && !isPaused) || (!isPlaying && specMode)); @@ -465,6 +588,16 @@ void Engine::run() { } glfwSwapBuffers(editorWindow); + + if (fpsCapEnabled && fpsCap > 1.0f) { + double target = 1.0 / fpsCap; + double frameEnd = glfwGetTime(); + double elapsed = frameEnd - frameStart; + if (elapsed < target) { + int sleepMs = static_cast((target - elapsed) * 1000.0); + if (sleepMs > 0) ImGui_ImplGlfw_Sleep(sleepMs); + } + } if (firstFrame) { std::cerr << "[DEBUG] First frame complete!" << std::endl; @@ -575,6 +708,45 @@ void Engine::convertModelToRawMesh(const std::string& filepath) { addConsoleMessage("Raw mesh export failed: " + error, ConsoleMessageType::Error); } } + +void Engine::createRMeshPrimitive(const std::string& primitiveName) { + if (!projectManager.currentProject.isLoaded) { + addConsoleMessage("Load a project before creating RMesh primitives", ConsoleMessageType::Warning); + return; + } + + fs::path root = projectManager.currentProject.assetsPath / "Models" / "RMeshes" / "Primitives"; + std::error_code ec; + fs::create_directories(root, ec); + if (ec) { + addConsoleMessage("Failed to create RMesh folder: " + root.string(), ConsoleMessageType::Error); + return; + } + + RawMeshAsset asset; + if (primitiveName == "Cube") { + asset = buildCubeRMesh(); + } else if (primitiveName == "Sphere") { + asset = buildSphereRMesh(); + } else if (primitiveName == "Plane") { + asset = buildPlaneRMesh(); + } else { + addConsoleMessage("Unknown RMesh primitive: " + primitiveName, ConsoleMessageType::Warning); + return; + } + + fs::path filePath = root / (primitiveName + ".rmesh"); + if (!fs::exists(filePath)) { + std::string error; + if (!getModelLoader().saveRawMesh(asset, filePath.string(), error)) { + addConsoleMessage("Failed to save RMesh primitive: " + error, ConsoleMessageType::Error); + return; + } + fileBrowser.needsRefresh = true; + } + + importModelToScene(filePath.string(), primitiveName); +} #pragma endregion #pragma region Mesh Editing @@ -600,6 +772,7 @@ bool Engine::ensureMeshEditTarget(SceneObject* obj) { } meshEditLoaded = true; meshEditPath = obj->meshPath; + meshEditDirty = false; meshEditSelectedVertices.clear(); meshEditSelectedEdges.clear(); meshEditSelectedFaces.clear(); @@ -617,6 +790,23 @@ bool Engine::syncMeshEditToGPU(SceneObject* obj) { projectManager.currentProject.hasUnsavedChanges = true; return true; } + +bool Engine::saveMeshEditAsset(std::string& error) { + if (!meshEditLoaded) { + error = "No mesh loaded for editing"; + return false; + } + if (meshEditPath.empty()) { + error = "Mesh edit path is empty"; + return false; + } + if (!getModelLoader().saveRawMesh(meshEditAsset, meshEditPath, error)) { + return false; + } + meshEditDirty = false; + fileBrowser.needsRefresh = true; + return true; +} #pragma endregion #pragma region Material IO @@ -942,6 +1132,33 @@ void Engine::updatePlayerController(float delta) { } syncLocalTransform(*player); } + +void Engine::updateRigidbody2D(float delta) { + if (delta <= 0.0f) return; + const float gravityPx = -980.0f; + auto isUIType = [](ObjectType type) { + return type == ObjectType::Canvas || + type == ObjectType::UIImage || + type == ObjectType::UISlider || + type == ObjectType::UIButton || + type == ObjectType::UIText || + type == ObjectType::Sprite2D; + }; + for (auto& obj : sceneObjects) { + if (!obj.enabled || !obj.hasRigidbody2D || !obj.rigidbody2D.enabled) continue; + if (!isUIType(obj.type)) continue; + glm::vec2 vel = obj.rigidbody2D.velocity; + if (obj.rigidbody2D.useGravity) { + vel.y += gravityPx * obj.rigidbody2D.gravityScale * delta; + } + float damping = std::max(0.0f, obj.rigidbody2D.linearDamping); + if (damping > 0.0f) { + vel -= vel * std::min(1.0f, damping * delta); + } + obj.ui.position += vel * delta; + obj.rigidbody2D.velocity = vel; + } +} #pragma endregion #pragma region Transform Hierarchy @@ -1071,6 +1288,13 @@ void Engine::updateHierarchyWorldTransforms() { worldPos = obj.position; worldRot = QuatFromEulerXYZ(obj.rotation); worldScale = obj.scale; + } else if (obj.parentId == -1) { + obj.position = obj.localPosition; + obj.rotation = NormalizeEulerDegrees(obj.localRotation); + obj.scale = obj.localScale; + worldPos = obj.position; + worldRot = QuatFromEulerXYZ(obj.rotation); + worldScale = obj.scale; } else { glm::quat localRot = QuatFromEulerXYZ(obj.localRotation); worldRot = parentRot * localRot; @@ -1331,6 +1555,27 @@ void Engine::addObject(ObjectType type, const std::string& baseName) { sceneObjects.back().scale = glm::vec3(2.0f, 2.0f, 0.05f); } else if (type == ObjectType::Plane) { sceneObjects.back().scale = glm::vec3(2.0f, 2.0f, 0.05f); + } else if (type == ObjectType::Sprite) { + sceneObjects.back().scale = glm::vec3(1.0f, 1.0f, 0.05f); + sceneObjects.back().material.ambientStrength = 1.0f; + } else if (type == ObjectType::Canvas) { + sceneObjects.back().ui.label = "Canvas"; + sceneObjects.back().ui.size = glm::vec2(600.0f, 400.0f); + } else if (type == ObjectType::UIImage) { + sceneObjects.back().ui.label = "Image"; + sceneObjects.back().ui.size = glm::vec2(200.0f, 200.0f); + } else if (type == ObjectType::UISlider) { + sceneObjects.back().ui.label = "Slider"; + sceneObjects.back().ui.size = glm::vec2(240.0f, 32.0f); + } else if (type == ObjectType::UIButton) { + sceneObjects.back().ui.label = "Button"; + sceneObjects.back().ui.size = glm::vec2(160.0f, 40.0f); + } else if (type == ObjectType::UIText) { + sceneObjects.back().ui.label = "Text"; + sceneObjects.back().ui.size = glm::vec2(240.0f, 32.0f); + } else if (type == ObjectType::Sprite2D) { + sceneObjects.back().ui.label = "Sprite2D"; + sceneObjects.back().ui.size = glm::vec2(128.0f, 128.0f); } sceneObjects.back().localPosition = sceneObjects.back().position; sceneObjects.back().localRotation = NormalizeEulerDegrees(sceneObjects.back().rotation); @@ -1369,6 +1614,8 @@ void Engine::duplicateSelected() { newObj.postFx = it->postFx; newObj.hasRigidbody = it->hasRigidbody; newObj.rigidbody = it->rigidbody; + newObj.hasRigidbody2D = it->hasRigidbody2D; + newObj.rigidbody2D = it->rigidbody2D; newObj.hasCollider = it->hasCollider; newObj.collider = it->collider; newObj.hasPlayerController = it->hasPlayerController; @@ -1379,6 +1626,7 @@ void Engine::duplicateSelected() { newObj.localInitialized = true; newObj.hasAudioSource = it->hasAudioSource; newObj.audioSource = it->audioSource; + newObj.ui = it->ui; sceneObjects.push_back(newObj); setPrimarySelection(id); @@ -1565,6 +1813,13 @@ bool Engine::raycastClosestFromScript(const glm::vec3& origin, const glm::vec3& } void Engine::syncLocalTransform(SceneObject& obj) { + if (obj.parentId == -1) { + obj.localPosition = obj.position; + obj.localRotation = NormalizeEulerDegrees(obj.rotation); + obj.localScale = obj.scale; + obj.localInitialized = true; + return; + } glm::vec3 parentPos(0.0f); glm::quat parentRot(1.0f, 0.0f, 0.0f, 0.0f); glm::vec3 parentScale(1.0f); @@ -1578,6 +1833,11 @@ void Engine::syncLocalTransform(SceneObject& obj) { updateLocalFromWorld(obj, parentPos, parentRot, parentScale); } +void Engine::setFrameRateCapFromScript(bool enabled, float cap) { + fpsCapEnabled = enabled; + fpsCap = std::max(1.0f, cap); +} + bool Engine::playAudioFromScript(int id) { SceneObject* obj = findObjectById(id); if (!obj || !obj->hasAudioSource) return false; @@ -1820,6 +2080,7 @@ void Engine::setupImGui() { style.WindowRounding = 0.0f; style.Colors[ImGuiCol_WindowBg].w = 1.0f; } + initUIStylePresets(); std::cerr << "[DEBUG] setupImGui: initializing ImGui GLFW backend..." << std::endl; ImGui_ImplGlfw_InitForOpenGL(editorWindow, true); @@ -1832,3 +2093,62 @@ void Engine::setupImGui() { std::cerr << "[DEBUG] setupImGui: complete!" << std::endl; } #pragma endregion + +void Engine::initUIStylePresets() { + uiStylePresets.clear(); + uiStylePresets.shrink_to_fit(); + + UIStylePreset current; + current.name = "Default"; + current.style = ImGui::GetStyle(); + current.builtin = true; + uiStylePresets.push_back(current); + + UIStylePreset editor; + editor.name = "Editor Style"; + editor.style = ImGui::GetStyle(); + editor.builtin = true; + uiStylePresets.push_back(editor); + + UIStylePreset imguiDefault; + imguiDefault.name = "ImGui Default"; + imguiDefault.style = ImGui::GetStyle(); + ImGui::StyleColorsDark(&imguiDefault.style); + imguiDefault.builtin = true; + uiStylePresets.push_back(imguiDefault); + + uiStylePresetIndex = 0; +} + +int Engine::findUIStylePreset(const std::string& name) const { + for (size_t i = 0; i < uiStylePresets.size(); ++i) { + if (uiStylePresets[i].name == name) return static_cast(i); + } + return -1; +} + +const Engine::UIStylePreset* Engine::getUIStylePreset(const std::string& name) const { + int idx = findUIStylePreset(name); + if (idx < 0) return nullptr; + return &uiStylePresets[idx]; +} + +void Engine::registerUIStylePreset(const std::string& name, const ImGuiStyle& style, bool replace) { + if (name.empty()) return; + int idx = findUIStylePreset(name); + if (idx >= 0) { + if (replace) { + uiStylePresets[idx].style = style; + } + return; + } + UIStylePreset preset; + preset.name = name; + preset.style = style; + preset.builtin = false; + uiStylePresets.push_back(preset); +} + +void Engine::registerUIStylePresetFromScript(const std::string& name, const ImGuiStyle& style, bool replace) { + registerUIStylePreset(name, style, replace); +} diff --git a/src/Engine.h b/src/Engine.h index b2f98f7..48a4711 100644 --- a/src/Engine.h +++ b/src/Engine.h @@ -121,6 +121,8 @@ private: char meshBuilderFaceInput[128] = ""; bool meshEditMode = false; bool meshEditLoaded = false; + bool meshEditDirty = false; + bool meshEditExtrudeMode = false; std::string meshEditPath; RawMeshAsset meshEditAsset; std::vector meshEditSelectedVertices; @@ -139,6 +141,15 @@ private: bool specMode = false; bool testMode = false; bool collisionWireframe = false; + bool fpsCapEnabled = false; + float fpsCap = 120.0f; + struct UIStylePreset { + std::string name; + ImGuiStyle style; + bool builtin = false; + }; + std::vector uiStylePresets; + int uiStylePresetIndex = 0; // Private methods SceneObject* getSelectedObject(); glm::vec3 getSelectionCenterWorld(bool worldSpace) const; @@ -154,8 +165,10 @@ private: void importOBJToScene(const std::string& filepath, const std::string& objectName); void importModelToScene(const std::string& filepath, const std::string& objectName); // Assimp import void convertModelToRawMesh(const std::string& filepath); + void createRMeshPrimitive(const std::string& primitiveName); bool ensureMeshEditTarget(SceneObject* obj); bool syncMeshEditToGPU(SceneObject* obj); + bool saveMeshEditAsset(std::string& error); void handleKeyboardShortcuts(); void OpenProjectPath(const std::string& path); @@ -184,6 +197,11 @@ private: void compileScriptFile(const fs::path& scriptPath); void updateScripts(float delta); void updatePlayerController(float delta); + void updateRigidbody2D(float delta); + void initUIStylePresets(); + int findUIStylePreset(const std::string& name) const; + const UIStylePreset* getUIStylePreset(const std::string& name) const; + void registerUIStylePreset(const std::string& name, const ImGuiStyle& style, bool replace); void renderFileBrowserToolbar(); void renderFileBrowserBreadcrumb(); @@ -265,4 +283,7 @@ public: bool setAudioVolumeFromScript(int id, float volume); bool setAudioClipFromScript(int id, const std::string& path); void syncLocalTransform(SceneObject& obj); + const std::vector& getUIStylePresets() const { return uiStylePresets; } + void registerUIStylePresetFromScript(const std::string& name, const ImGuiStyle& style, bool replace = false); + void setFrameRateCapFromScript(bool enabled, float cap); }; diff --git a/src/PhysicsSystem.cpp b/src/PhysicsSystem.cpp index ca156d9..4ab3fdf 100644 --- a/src/PhysicsSystem.cpp +++ b/src/PhysicsSystem.cpp @@ -16,8 +16,12 @@ PxVec3 ToPxVec3(const glm::vec3& v) { } PxQuat ToPxQuat(const glm::vec3& eulerDeg) { - glm::vec3 radians = glm::radians(eulerDeg); - glm::quat q = glm::quat(radians); + glm::vec3 r = glm::radians(eulerDeg); + glm::mat4 m(1.0f); + m = glm::rotate(m, r.x, glm::vec3(1.0f, 0.0f, 0.0f)); + m = glm::rotate(m, r.y, glm::vec3(0.0f, 1.0f, 0.0f)); + m = glm::rotate(m, r.z, glm::vec3(0.0f, 0.0f, 1.0f)); + glm::quat q = glm::quat_cast(glm::mat3(m)); return PxQuat(q.x, q.y, q.z, q.w); } @@ -25,9 +29,20 @@ glm::vec3 ToGlmVec3(const PxVec3& v) { return glm::vec3(v.x, v.y, v.z); } +glm::vec3 ExtractEulerXYZ(const glm::mat3& m) { + float T1 = std::atan2(m[2][1], m[2][2]); + float C2 = std::sqrt(m[0][0] * m[0][0] + m[1][0] * m[1][0]); + float T2 = std::atan2(-m[2][0], C2); + float S1 = std::sin(T1); + float C1 = std::cos(T1); + float T3 = std::atan2(S1 * m[0][2] - C1 * m[0][1], C1 * m[1][1] - S1 * m[1][2]); + return glm::vec3(-T1, -T2, -T3); +} + glm::vec3 ToGlmEulerDeg(const PxQuat& q) { glm::quat gq(q.w, q.x, q.y, q.z); - return glm::degrees(glm::eulerAngles(gq)); + glm::mat3 m = glm::mat3_cast(gq); + return glm::degrees(ExtractEulerXYZ(m)); } } // namespace @@ -234,6 +249,13 @@ bool PhysicsSystem::attachPrimitiveShape(PxRigidActor* actor, const SceneObject& tuneShape(shape, std::min({halfExtents.x, halfExtents.y, halfExtents.z}) * 2.0f, isDynamic); break; } + case ObjectType::Sprite: { + glm::vec3 halfExtents = glm::max(obj.scale * 0.5f, glm::vec3(0.01f)); + halfExtents.z = std::max(halfExtents.z, 0.01f); + shape = mPhysics->createShape(PxBoxGeometry(ToPxVec3(halfExtents)), *mDefaultMaterial, true); + tuneShape(shape, std::min({halfExtents.x, halfExtents.y, halfExtents.z}) * 2.0f, isDynamic); + break; + } case ObjectType::Torus: { float radius = std::max({obj.scale.x, obj.scale.y, obj.scale.z}) * 0.5f; radius = std::max(radius, 0.01f); diff --git a/src/ProjectManager.cpp b/src/ProjectManager.cpp index b4ec0c1..c64cf0b 100644 --- a/src/ProjectManager.cpp +++ b/src/ProjectManager.cpp @@ -299,7 +299,7 @@ bool SceneSerializer::saveScene(const fs::path& filePath, if (!file.is_open()) return false; file << "# Scene File\n"; - file << "version=10\n"; + file << "version=11\n"; file << "nextId=" << nextId << "\n"; file << "objectCount=" << objects.size() << "\n"; file << "\n"; @@ -328,6 +328,14 @@ bool SceneSerializer::saveScene(const fs::path& filePath, file << "rbLockRotY=" << (obj.rigidbody.lockRotationY ? 1 : 0) << "\n"; file << "rbLockRotZ=" << (obj.rigidbody.lockRotationZ ? 1 : 0) << "\n"; } + file << "hasRigidbody2D=" << (obj.hasRigidbody2D ? 1 : 0) << "\n"; + if (obj.hasRigidbody2D) { + file << "rb2dEnabled=" << (obj.rigidbody2D.enabled ? 1 : 0) << "\n"; + file << "rb2dUseGravity=" << (obj.rigidbody2D.useGravity ? 1 : 0) << "\n"; + file << "rb2dGravityScale=" << obj.rigidbody2D.gravityScale << "\n"; + file << "rb2dLinearDamping=" << obj.rigidbody2D.linearDamping << "\n"; + file << "rb2dVelocity=" << obj.rigidbody2D.velocity.x << "," << obj.rigidbody2D.velocity.y << "\n"; + } file << "hasCollider=" << (obj.hasCollider ? 1 : 0) << "\n"; if (obj.hasCollider) { file << "colliderEnabled=" << (obj.collider.enabled ? 1 : 0) << "\n"; @@ -394,6 +402,19 @@ bool SceneSerializer::saveScene(const fs::path& filePath, file << "cameraNear=" << obj.camera.nearClip << "\n"; file << "cameraFar=" << obj.camera.farClip << "\n"; file << "cameraPostFX=" << (obj.camera.applyPostFX ? 1 : 0) << "\n"; + file << "uiAnchor=" << static_cast(obj.ui.anchor) << "\n"; + file << "uiPosition=" << obj.ui.position.x << "," << obj.ui.position.y << "\n"; + file << "uiSize=" << obj.ui.size.x << "," << obj.ui.size.y << "\n"; + file << "uiSliderValue=" << obj.ui.sliderValue << "\n"; + file << "uiSliderMin=" << obj.ui.sliderMin << "\n"; + file << "uiSliderMax=" << obj.ui.sliderMax << "\n"; + file << "uiLabel=" << obj.ui.label << "\n"; + file << "uiColor=" << obj.ui.color.r << "," << obj.ui.color.g << "," << obj.ui.color.b << "," << obj.ui.color.a << "\n"; + file << "uiInteractable=" << (obj.ui.interactable ? 1 : 0) << "\n"; + file << "uiSliderStyle=" << static_cast(obj.ui.sliderStyle) << "\n"; + file << "uiButtonStyle=" << static_cast(obj.ui.buttonStyle) << "\n"; + file << "uiStylePreset=" << obj.ui.stylePreset << "\n"; + file << "uiTextScale=" << obj.ui.textScale << "\n"; if (obj.type == ObjectType::PostFXNode) { file << "postEnabled=" << (obj.postFx.enabled ? 1 : 0) << "\n"; file << "postBloomEnabled=" << (obj.postFx.bloomEnabled ? 1 : 0) << "\n"; @@ -556,6 +577,20 @@ bool SceneSerializer::loadScene(const fs::path& filePath, currentObj->rigidbody.lockRotationY = std::stoi(value) != 0; } else if (key == "rbLockRotZ") { currentObj->rigidbody.lockRotationZ = std::stoi(value) != 0; + } else if (key == "hasRigidbody2D") { + currentObj->hasRigidbody2D = std::stoi(value) != 0; + } else if (key == "rb2dEnabled") { + currentObj->rigidbody2D.enabled = std::stoi(value) != 0; + } else if (key == "rb2dUseGravity") { + currentObj->rigidbody2D.useGravity = std::stoi(value) != 0; + } else if (key == "rb2dGravityScale") { + currentObj->rigidbody2D.gravityScale = std::stof(value); + } else if (key == "rb2dLinearDamping") { + currentObj->rigidbody2D.linearDamping = std::stof(value); + } else if (key == "rb2dVelocity") { + sscanf(value.c_str(), "%f,%f", + ¤tObj->rigidbody2D.velocity.x, + ¤tObj->rigidbody2D.velocity.y); } else if (key == "hasCollider") { currentObj->hasCollider = std::stoi(value) != 0; } else if (key == "colliderEnabled") { @@ -701,6 +736,40 @@ bool SceneSerializer::loadScene(const fs::path& filePath, currentObj->camera.farClip = std::stof(value); } else if (key == "cameraPostFX") { currentObj->camera.applyPostFX = (std::stoi(value) != 0); + } else if (key == "uiAnchor") { + currentObj->ui.anchor = static_cast(std::stoi(value)); + } else if (key == "uiPosition") { + sscanf(value.c_str(), "%f,%f", + ¤tObj->ui.position.x, + ¤tObj->ui.position.y); + } else if (key == "uiSize") { + sscanf(value.c_str(), "%f,%f", + ¤tObj->ui.size.x, + ¤tObj->ui.size.y); + } else if (key == "uiSliderValue") { + currentObj->ui.sliderValue = std::stof(value); + } else if (key == "uiSliderMin") { + currentObj->ui.sliderMin = std::stof(value); + } else if (key == "uiSliderMax") { + currentObj->ui.sliderMax = std::stof(value); + } else if (key == "uiLabel") { + currentObj->ui.label = value; + } else if (key == "uiColor") { + sscanf(value.c_str(), "%f,%f,%f,%f", + ¤tObj->ui.color.r, + ¤tObj->ui.color.g, + ¤tObj->ui.color.b, + ¤tObj->ui.color.a); + } else if (key == "uiInteractable") { + currentObj->ui.interactable = (std::stoi(value) != 0); + } else if (key == "uiSliderStyle") { + currentObj->ui.sliderStyle = static_cast(std::stoi(value)); + } else if (key == "uiButtonStyle") { + currentObj->ui.buttonStyle = static_cast(std::stoi(value)); + } else if (key == "uiStylePreset") { + currentObj->ui.stylePreset = value; + } else if (key == "uiTextScale") { + currentObj->ui.textScale = std::stof(value); } else if (key == "postEnabled") { currentObj->postFx.enabled = (std::stoi(value) != 0); } else if (key == "postBloomEnabled") { diff --git a/src/Rendering.cpp b/src/Rendering.cpp index 7becedb..68b622c 100644 --- a/src/Rendering.cpp +++ b/src/Rendering.cpp @@ -993,7 +993,7 @@ void Renderer::renderObject(const SceneObject& obj) { shader->setFloat("specularStrength", obj.material.specularStrength); shader->setFloat("shininess", obj.material.shininess); shader->setFloat("mixAmount", obj.material.textureMix); - shader->setBool("unlit", obj.type == ObjectType::Mirror); + shader->setBool("unlit", obj.type == ObjectType::Mirror || obj.type == ObjectType::Sprite); Texture* baseTex = texture1; if (!obj.albedoTexturePath.empty()) { @@ -1046,6 +1046,9 @@ void Renderer::renderObject(const SceneObject& obj) { case ObjectType::Mirror: if (planeMesh) planeMesh->draw(); break; + case ObjectType::Sprite: + if (planeMesh) planeMesh->draw(); + break; case ObjectType::Torus: if (torusMesh) torusMesh->draw(); break; @@ -1078,6 +1081,14 @@ void Renderer::renderObject(const SceneObject& obj) { break; case ObjectType::PostFXNode: break; + case ObjectType::Sprite2D: + case ObjectType::Canvas: + case ObjectType::UIImage: + case ObjectType::UISlider: + case ObjectType::UIButton: + case ObjectType::UIText: + // UI types are rendered via ImGui, not here. + break; } } @@ -1196,7 +1207,7 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector& scene return target.texture; } -void Renderer::renderScene(const Camera& camera, const std::vector& sceneObjects, int /*selectedId*/, float fovDeg, float nearPlane, float farPlane, bool drawColliders) { +void Renderer::renderScene(const Camera& camera, const std::vector& sceneObjects, int selectedId, float fovDeg, float nearPlane, float farPlane, bool drawColliders) { updateMirrorTargets(camera, sceneObjects, currentWidth, currentHeight, fovDeg, nearPlane, farPlane); renderSceneInternal(camera, sceneObjects, currentWidth, currentHeight, true, fovDeg, nearPlane, farPlane, true); if (drawColliders) { @@ -1470,6 +1482,7 @@ void Renderer::renderScene(const Camera& camera, const std::vector& renderCollisionOverlay(camera, sceneObjects, currentWidth, currentHeight, fovDeg, nearPlane, farPlane); glBindFramebuffer(GL_FRAMEBUFFER, 0); } + renderSelectionOutline(camera, sceneObjects, selectedId, fovDeg, nearPlane, farPlane); unsigned int result = applyPostProcessing(sceneObjects, viewportTexture, currentWidth, currentHeight, true); displayTexture = result ? result : viewportTexture; } @@ -1498,7 +1511,11 @@ void Renderer::renderCollisionOverlay(const Camera& camera, const std::vector& sceneObjects, int selectedId, float fovDeg, float nearPlane, float farPlane) { + if (!defaultShader || selectedId < 0 || currentWidth <= 0 || currentHeight <= 0) return; + + const SceneObject* selectedObj = nullptr; + for (const auto& obj : sceneObjects) { + if (obj.id == selectedId) { + selectedObj = &obj; + break; + } + } + if (!selectedObj || !selectedObj->enabled) return; + + if (selectedObj->type == ObjectType::PointLight || + selectedObj->type == ObjectType::SpotLight || + selectedObj->type == ObjectType::AreaLight || + selectedObj->type == ObjectType::Camera || + selectedObj->type == ObjectType::PostFXNode || + selectedObj->type == ObjectType::Canvas || + selectedObj->type == ObjectType::UIImage || + selectedObj->type == ObjectType::UISlider || + selectedObj->type == ObjectType::UIButton || + selectedObj->type == ObjectType::UIText || + selectedObj->type == ObjectType::Sprite2D) { + return; + } + + Mesh* meshToDraw = nullptr; + if (selectedObj->type == ObjectType::Cube) meshToDraw = cubeMesh; + else if (selectedObj->type == ObjectType::Sphere) meshToDraw = sphereMesh; + else if (selectedObj->type == ObjectType::Capsule) meshToDraw = capsuleMesh; + else if (selectedObj->type == ObjectType::Plane) meshToDraw = planeMesh; + else if (selectedObj->type == ObjectType::Mirror) meshToDraw = planeMesh; + else if (selectedObj->type == ObjectType::Sprite) meshToDraw = planeMesh; + else if (selectedObj->type == ObjectType::Torus) meshToDraw = torusMesh; + else if (selectedObj->type == ObjectType::OBJMesh && selectedObj->meshId != -1) { + meshToDraw = g_objLoader.getMesh(selectedObj->meshId); + } else if (selectedObj->type == ObjectType::Model && selectedObj->meshId != -1) { + meshToDraw = getModelLoader().getMesh(selectedObj->meshId); + } + if (!meshToDraw) return; + + GLint prevPoly[2] = { GL_FILL, GL_FILL }; + glGetIntegerv(GL_POLYGON_MODE, prevPoly); + GLboolean depthTest = glIsEnabled(GL_DEPTH_TEST); + GLboolean depthMask = GL_TRUE; + glGetBooleanv(GL_DEPTH_WRITEMASK, &depthMask); + GLboolean cullFace = glIsEnabled(GL_CULL_FACE); + GLint prevCullMode = GL_BACK; + glGetIntegerv(GL_CULL_FACE_MODE, &prevCullMode); + GLboolean stencilTest = glIsEnabled(GL_STENCIL_TEST); + GLint prevStencilFunc = GL_ALWAYS; + GLint prevStencilRef = 0; + GLint prevStencilValueMask = 0xFF; + GLint prevStencilFail = GL_KEEP; + GLint prevStencilZFail = GL_KEEP; + GLint prevStencilZPass = GL_KEEP; + GLint prevStencilWriteMask = 0xFF; + glGetIntegerv(GL_STENCIL_FUNC, &prevStencilFunc); + glGetIntegerv(GL_STENCIL_REF, &prevStencilRef); + glGetIntegerv(GL_STENCIL_VALUE_MASK, &prevStencilValueMask); + glGetIntegerv(GL_STENCIL_FAIL, &prevStencilFail); + glGetIntegerv(GL_STENCIL_PASS_DEPTH_FAIL, &prevStencilZFail); + glGetIntegerv(GL_STENCIL_PASS_DEPTH_PASS, &prevStencilZPass); + glGetIntegerv(GL_STENCIL_WRITEMASK, &prevStencilWriteMask); + GLboolean prevColorMask[4] = { GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE }; + glGetBooleanv(GL_COLOR_WRITEMASK, prevColorMask); + + glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); + glViewport(0, 0, currentWidth, currentHeight); + glClearStencil(0); + glClear(GL_STENCIL_BUFFER_BIT); + glEnable(GL_DEPTH_TEST); + glDepthMask(GL_FALSE); + glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); + + Shader* active = defaultShader; + active->use(); + active->setMat4("view", camera.getViewMatrix()); + active->setMat4("projection", glm::perspective(glm::radians(fovDeg), (float)currentWidth / (float)currentHeight, nearPlane, farPlane)); + active->setVec3("viewPos", camera.position); + active->setBool("unlit", true); + active->setBool("hasOverlay", false); + active->setBool("hasNormalMap", false); + active->setInt("lightCount", 0); + active->setFloat("mixAmount", 0.0f); + active->setVec3("materialColor", glm::vec3(1.0f, 0.5f, 0.1f)); + active->setFloat("ambientStrength", 1.0f); + active->setFloat("specularStrength", 0.0f); + active->setFloat("shininess", 1.0f); + active->setInt("texture1", 0); + active->setInt("overlayTex", 1); + active->setInt("normalMap", 2); + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, debugWhiteTexture ? debugWhiteTexture : (texture1 ? texture1->GetID() : 0)); + + glm::mat4 baseModel = glm::mat4(1.0f); + baseModel = glm::translate(baseModel, selectedObj->position); + baseModel = glm::rotate(baseModel, glm::radians(selectedObj->rotation.x), glm::vec3(1.0f, 0.0f, 0.0f)); + baseModel = glm::rotate(baseModel, glm::radians(selectedObj->rotation.y), glm::vec3(0.0f, 1.0f, 0.0f)); + baseModel = glm::rotate(baseModel, glm::radians(selectedObj->rotation.z), glm::vec3(0.0f, 0.0f, 1.0f)); + baseModel = glm::scale(baseModel, selectedObj->scale); + + // Mark the object in the stencil buffer. + glEnable(GL_STENCIL_TEST); + glStencilMask(0xFF); + glStencilFunc(GL_ALWAYS, 1, 0xFF); + glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); + glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); + if (cullFace) { + glEnable(GL_CULL_FACE); + glCullFace(prevCullMode); + } else { + glDisable(GL_CULL_FACE); + } + active->setMat4("model", baseModel); + meshToDraw->draw(); + + // Draw the scaled outline where stencil is not marked. + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + glStencilFunc(GL_NOTEQUAL, 1, 0xFF); + glStencilMask(0x00); + glEnable(GL_CULL_FACE); + glCullFace(GL_FRONT); + + const float outlineScale = 1.03f; + glm::mat4 outlineModel = glm::scale(baseModel, glm::vec3(outlineScale)); + active->setMat4("model", outlineModel); + meshToDraw->draw(); + + if (!cullFace) { + glDisable(GL_CULL_FACE); + } else { + glCullFace(prevCullMode); + } + glDepthMask(depthMask); + if (depthTest) glEnable(GL_DEPTH_TEST); + else glDisable(GL_DEPTH_TEST); + glPolygonMode(GL_FRONT_AND_BACK, prevPoly[0]); + glColorMask(prevColorMask[0], prevColorMask[1], prevColorMask[2], prevColorMask[3]); + glStencilFunc(prevStencilFunc, prevStencilRef, prevStencilValueMask); + glStencilOp(prevStencilFail, prevStencilZFail, prevStencilZPass); + glStencilMask(prevStencilWriteMask); + if (!stencilTest) glDisable(GL_STENCIL_TEST); + glBindFramebuffer(GL_FRAMEBUFFER, 0); +} + void Renderer::endRender() { glBindFramebuffer(GL_FRAMEBUFFER, 0); } diff --git a/src/Rendering.h b/src/Rendering.h index cc67855..eeca16d 100644 --- a/src/Rendering.h +++ b/src/Rendering.h @@ -147,6 +147,7 @@ public: void renderSkybox(const glm::mat4& view, const glm::mat4& proj); void renderObject(const SceneObject& obj); void renderScene(const Camera& camera, const std::vector& sceneObjects, int selectedId = -1, float fovDeg = FOV, float nearPlane = NEAR_PLANE, float farPlane = FAR_PLANE, bool drawColliders = false); + void renderSelectionOutline(const Camera& camera, const std::vector& sceneObjects, int selectedId, float fovDeg, float nearPlane, float farPlane); unsigned int renderScenePreview(const Camera& camera, const std::vector& sceneObjects, int width, int height, float fovDeg, float nearPlane, float farPlane, bool applyPostFX = false); void renderCollisionOverlay(const Camera& camera, const std::vector& sceneObjects, int width, int height, float fovDeg, float nearPlane, float farPlane); void endRender(); diff --git a/src/SceneObject.h b/src/SceneObject.h index f5ce0c8..456757f 100644 --- a/src/SceneObject.h +++ b/src/SceneObject.h @@ -3,20 +3,27 @@ #include "Common.h" enum class ObjectType { - Cube, - Sphere, - Capsule, - OBJMesh, - Model, // New type for Assimp-loaded models (FBX, GLTF, etc.) - DirectionalLight, - PointLight, - SpotLight, - AreaLight, - Camera, - PostFXNode, - Mirror, - Plane, - Torus + Cube = 0, + Sphere = 1, + Capsule = 2, + OBJMesh = 3, + Model = 4, // New type for Assimp-loaded models (FBX, GLTF, etc.) + DirectionalLight = 5, + PointLight = 6, + SpotLight = 7, + AreaLight = 8, + Camera = 9, + PostFXNode = 10, + Mirror = 11, + Plane = 12, + Torus = 13, + Sprite = 14, // 3D quad sprite (lit/unlit with material) + Sprite2D = 15, // Screen-space sprite + Canvas = 16, // UI canvas root + UIImage = 17, + UISlider = 18, + UIButton = 19, + UIText = 20 }; struct MaterialProperties { @@ -34,6 +41,25 @@ enum class LightType { Area = 3 }; +enum class UIAnchor { + Center = 0, + TopLeft = 1, + TopRight = 2, + BottomLeft = 3, + BottomRight = 4 +}; + +enum class UISliderStyle { + ImGui = 0, + Fill = 1, + Circle = 2 +}; + +enum class UIButtonStyle { + ImGui = 0, + Outline = 1 +}; + struct LightComponent { LightType type = LightType::Point; glm::vec3 color = glm::vec3(1.0f); @@ -142,6 +168,31 @@ struct PlayerControllerComponent { float yaw = 0.0f; }; +struct UIElementComponent { + UIAnchor anchor = UIAnchor::Center; + glm::vec2 position = glm::vec2(0.0f); // offset in pixels from anchor + glm::vec2 size = glm::vec2(160.0f, 40.0f); + float sliderValue = 0.5f; + float sliderMin = 0.0f; + float sliderMax = 1.0f; + std::string label = "UI Element"; + bool buttonPressed = false; + glm::vec4 color = glm::vec4(1.0f); + bool interactable = true; + UISliderStyle sliderStyle = UISliderStyle::ImGui; + UIButtonStyle buttonStyle = UIButtonStyle::ImGui; + std::string stylePreset = "Default"; + float textScale = 1.0f; +}; + +struct Rigidbody2DComponent { + bool enabled = true; + bool useGravity = false; + float gravityScale = 1.0f; + float linearDamping = 0.0f; + glm::vec2 velocity = glm::vec2(0.0f); +}; + struct AudioSourceComponent { bool enabled = true; std::string clipPath; @@ -188,12 +239,15 @@ public: std::vector additionalMaterialPaths; bool hasRigidbody = false; RigidbodyComponent rigidbody; + bool hasRigidbody2D = false; + Rigidbody2DComponent rigidbody2D; bool hasCollider = false; ColliderComponent collider; bool hasPlayerController = false; PlayerControllerComponent playerController; bool hasAudioSource = false; AudioSourceComponent audioSource; + UIElementComponent ui; SceneObject(const std::string& name, ObjectType type, int id) : name(name), diff --git a/src/ScriptRuntime.cpp b/src/ScriptRuntime.cpp index 0914330..ad04f6b 100644 --- a/src/ScriptRuntime.cpp +++ b/src/ScriptRuntime.cpp @@ -2,6 +2,8 @@ #include "Engine.h" #include "SceneObject.h" #include +#include +#include #include #include @@ -27,6 +29,27 @@ std::string makeScriptInstanceKey(const ScriptContext& ctx) { } return key; } + +std::string trimString(const std::string& input) { + size_t start = 0; + while (start < input.size() && std::isspace(static_cast(input[start]))) { + ++start; + } + size_t end = input.size(); + while (end > start && std::isspace(static_cast(input[end - 1]))) { + --end; + } + return input.substr(start, end - start); +} + +bool isUIObjectType(ObjectType type) { + return type == ObjectType::Canvas || + type == ObjectType::UIImage || + type == ObjectType::UISlider || + type == ObjectType::UIButton || + type == ObjectType::UIText || + type == ObjectType::Sprite2D; +} } SceneObject* ScriptContext::FindObjectByName(const std::string& name) { @@ -39,6 +62,31 @@ SceneObject* ScriptContext::FindObjectById(int id) { return engine->findObjectById(id); } +SceneObject* ScriptContext::ResolveObjectRef(const std::string& ref) { + if (ref.empty()) return nullptr; + std::string trimmed = trimString(ref); + if (trimmed == "ObjectSelf") return object; + + const std::string namePrefix = "Object."; + const std::string idPrefix = "Object.ID-"; + if (trimmed.rfind(idPrefix, 0) == 0) { + std::string idStr = trimmed.substr(idPrefix.size()); + if (idStr.empty()) return nullptr; + try { + int id = std::stoi(idStr); + return FindObjectById(id); + } catch (...) { + return nullptr; + } + } + if (trimmed.rfind(namePrefix, 0) == 0) { + std::string name = trimmed.substr(namePrefix.size()); + if (name.empty()) return nullptr; + return FindObjectByName(name); + } + return nullptr; +} + bool ScriptContext::IsObjectEnabled() const { return object ? object->enabled : false; } @@ -97,6 +145,12 @@ void ScriptContext::SetPosition(const glm::vec3& pos) { } } +void ScriptContext::SetPosition2D(const glm::vec2& pos) { + if (!object) return; + object->ui.position = pos; + MarkDirty(); +} + void ScriptContext::SetRotation(const glm::vec3& rot) { if (object) { object->rotation = NormalizeEulerDegrees(rot); @@ -126,10 +180,204 @@ void ScriptContext::SetScale(const glm::vec3& scl) { } } +void ScriptContext::GetPlanarYawPitchVectors(float pitchDeg, float yawDeg, + glm::vec3& outForward, glm::vec3& outRight) const { + glm::quat q = glm::quat(glm::radians(glm::vec3(pitchDeg, yawDeg, 0.0f))); + glm::vec3 forward = glm::normalize(q * glm::vec3(0.0f, 0.0f, -1.0f)); + glm::vec3 right = glm::normalize(q * glm::vec3(1.0f, 0.0f, 0.0f)); + outForward = glm::normalize(glm::vec3(forward.x, 0.0f, forward.z)); + outRight = glm::normalize(glm::vec3(right.x, 0.0f, right.z)); + if (!std::isfinite(outForward.x) || glm::length(outForward) < 1e-3f) { + outForward = glm::vec3(0.0f, 0.0f, -1.0f); + } + if (!std::isfinite(outRight.x) || glm::length(outRight) < 1e-3f) { + outRight = glm::vec3(1.0f, 0.0f, 0.0f); + } +} + +bool ScriptContext::IsUIButtonPressed() const { + return object && object->type == ObjectType::UIButton && object->ui.buttonPressed; +} + +bool ScriptContext::IsUIInteractable() const { + return object ? object->ui.interactable : false; +} + +void ScriptContext::SetUIInteractable(bool interactable) { + if (!object) return; + if (object->ui.interactable != interactable) { + object->ui.interactable = interactable; + MarkDirty(); + } +} + +float ScriptContext::GetUISliderValue() const { + if (!object || object->type != ObjectType::UISlider) return 0.0f; + return object->ui.sliderValue; +} + +void ScriptContext::SetUISliderValue(float value) { + if (!object || object->type != ObjectType::UISlider) return; + float clamped = std::clamp(value, object->ui.sliderMin, object->ui.sliderMax); + if (object->ui.sliderValue != clamped) { + object->ui.sliderValue = clamped; + MarkDirty(); + } +} + +void ScriptContext::SetUISliderRange(float minValue, float maxValue) { + if (!object || object->type != ObjectType::UISlider) return; + if (maxValue < minValue) std::swap(minValue, maxValue); + object->ui.sliderMin = minValue; + object->ui.sliderMax = maxValue; + object->ui.sliderValue = std::clamp(object->ui.sliderValue, minValue, maxValue); + MarkDirty(); +} + +void ScriptContext::SetUILabel(const std::string& label) { + if (!object) return; + if (object->ui.label != label) { + object->ui.label = label; + MarkDirty(); + } +} + +void ScriptContext::SetUIColor(const glm::vec4& color) { + if (!object) return; + if (object->ui.color != color) { + object->ui.color = color; + MarkDirty(); + } +} + +float ScriptContext::GetUITextScale() const { + if (!object || object->type != ObjectType::UIText) return 1.0f; + return object->ui.textScale; +} + +void ScriptContext::SetUITextScale(float scale) { + if (!object || object->type != ObjectType::UIText) return; + float clamped = std::max(0.1f, scale); + if (object->ui.textScale != clamped) { + object->ui.textScale = clamped; + MarkDirty(); + } +} + +void ScriptContext::SetUISliderStyle(UISliderStyle style) { + if (!object || object->type != ObjectType::UISlider) return; + if (object->ui.sliderStyle != style) { + object->ui.sliderStyle = style; + MarkDirty(); + } +} + +void ScriptContext::SetUIButtonStyle(UIButtonStyle style) { + if (!object || object->type != ObjectType::UIButton) return; + if (object->ui.buttonStyle != style) { + object->ui.buttonStyle = style; + MarkDirty(); + } +} + +void ScriptContext::SetUIStylePreset(const std::string& name) { + if (!object || name.empty()) return; + if (object->ui.stylePreset != name) { + object->ui.stylePreset = name; + MarkDirty(); + } +} + +void ScriptContext::RegisterUIStylePreset(const std::string& name, const ImGuiStyle& style, bool replace) { + if (engine) { + engine->registerUIStylePresetFromScript(name, style, replace); + } +} + +void ScriptContext::SetFPSCap(bool enabled, float cap) { + if (engine) { + engine->setFrameRateCapFromScript(enabled, cap); + } +} + bool ScriptContext::HasRigidbody() const { return object && object->hasRigidbody && object->rigidbody.enabled; } +bool ScriptContext::HasRigidbody2D() const { + return object && isUIObjectType(object->type) && object->hasRigidbody2D && object->rigidbody2D.enabled; +} + +bool ScriptContext::EnsureCapsuleCollider(float height, float radius) { + if (!object) return false; + bool changed = false; + if (!object->hasCollider) { + object->hasCollider = true; + changed = true; + } + ColliderComponent& col = object->collider; + if (!col.enabled) { + col.enabled = true; + changed = true; + } + if (col.type != ColliderType::Capsule) { + col.type = ColliderType::Capsule; + changed = true; + } + if (!col.convex) { + col.convex = true; + changed = true; + } + glm::vec3 size(radius * 2.0f, height, radius * 2.0f); + if (col.boxSize != size) { + col.boxSize = size; + changed = true; + } + if (changed) { + MarkDirty(); + } + return true; +} + +bool ScriptContext::EnsureRigidbody(bool useGravity, bool kinematic) { + if (!object) return false; + bool changed = false; + if (!object->hasRigidbody) { + object->hasRigidbody = true; + changed = true; + } + RigidbodyComponent& rb = object->rigidbody; + if (!rb.enabled) { + rb.enabled = true; + changed = true; + } + if (rb.useGravity != useGravity) { + rb.useGravity = useGravity; + changed = true; + } + if (rb.isKinematic != kinematic) { + rb.isKinematic = kinematic; + changed = true; + } + if (changed) { + MarkDirty(); + } + return true; +} + +bool ScriptContext::SetRigidbody2DVelocity(const glm::vec2& velocity) { + if (!object || !HasRigidbody2D()) return false; + object->rigidbody2D.velocity = velocity; + MarkDirty(); + return true; +} + +bool ScriptContext::GetRigidbody2DVelocity(glm::vec2& outVelocity) const { + if (!object || !HasRigidbody2D()) return false; + outVelocity = object->rigidbody2D.velocity; + return true; +} + bool ScriptContext::SetRigidbodyVelocity(const glm::vec3& velocity) { if (!engine || !object || !HasRigidbody()) return false; return engine->setRigidbodyVelocityFromScript(object->id, velocity); @@ -140,6 +388,12 @@ bool ScriptContext::GetRigidbodyVelocity(glm::vec3& outVelocity) const { return engine->getRigidbodyVelocityFromScript(object->id, outVelocity); } +bool ScriptContext::AddRigidbodyVelocity(const glm::vec3& deltaVelocity) { + glm::vec3 current; + if (!GetRigidbodyVelocity(current)) return false; + return SetRigidbodyVelocity(current + deltaVelocity); +} + bool ScriptContext::SetRigidbodyAngularVelocity(const glm::vec3& velocity) { if (!engine || !object || !HasRigidbody()) return false; return engine->setRigidbodyAngularVelocityFromScript(object->id, velocity); @@ -265,6 +519,17 @@ void ScriptContext::SetSettingBool(const std::string& key, bool value) { SetSetting(key, value ? "1" : "0"); } +float ScriptContext::GetSettingFloat(const std::string& key, float fallback) const { + std::string v = GetSetting(key, ""); + if (v.empty()) return fallback; + try { return std::stof(v); } catch (...) {} + return fallback; +} + +void ScriptContext::SetSettingFloat(const std::string& key, float value) { + SetSetting(key, std::to_string(value)); +} + glm::vec3 ScriptContext::GetSettingVec3(const std::string& key, const glm::vec3& fallback) const { std::string v = GetSetting(key, ""); if (v.empty()) return fallback; @@ -315,6 +580,31 @@ void ScriptContext::AutoSetting(const std::string& key, bool& value) { autoSettings.push_back(entry); } +void ScriptContext::AutoSetting(const std::string& key, float& value) { + if (!script) return; + if (autoSettings.end() != std::find_if(autoSettings.begin(), autoSettings.end(), + [&](const AutoSettingEntry& e){ return e.key == key; })) return; + + static std::unordered_map defaults; + std::string scriptId = makeScriptInstanceKey(*this); + std::string id = scriptId + "|" + key; + float defaultVal = value; + auto itDef = defaults.find(id); + if (itDef != defaults.end()) { + defaultVal = itDef->second; + } else { + defaults[id] = defaultVal; + } + + value = GetSettingFloat(key, defaultVal); + AutoSettingEntry entry; + entry.type = AutoSettingType::Float; + entry.key = key; + entry.ptr = &value; + entry.initialFloat = value; + autoSettings.push_back(entry); +} + void ScriptContext::AutoSetting(const std::string& key, glm::vec3& value) { if (!script) return; if (autoSettings.end() != std::find_if(autoSettings.begin(), autoSettings.end(), @@ -378,6 +668,12 @@ void ScriptContext::SaveAutoSettings() { newVal = cur ? "1" : "0"; break; } + case AutoSettingType::Float: { + float cur = *static_cast(e.ptr); + if (std::abs(cur - e.initialFloat) < 1e-6f) continue; + newVal = std::to_string(cur); + break; + } case AutoSettingType::Vec3: { glm::vec3 cur = *static_cast(e.ptr); if (glm::all(glm::epsilonEqual(cur, e.initialVec3, 1e-6f))) continue; diff --git a/src/ScriptRuntime.h b/src/ScriptRuntime.h index f033d07..3c96dad 100644 --- a/src/ScriptRuntime.h +++ b/src/ScriptRuntime.h @@ -10,13 +10,14 @@ struct ScriptContext { Engine* engine = nullptr; SceneObject* object = nullptr; ScriptComponent* script = nullptr; - enum class AutoSettingType { Bool, Vec3, StringBuf }; + enum class AutoSettingType { Bool, Float, Vec3, StringBuf }; struct AutoSettingEntry { AutoSettingType type; std::string key; void* ptr = nullptr; size_t bufSize = 0; bool initialBool = false; + float initialFloat = 0.0f; glm::vec3 initialVec3 = glm::vec3(0.0f); std::string initialString; }; @@ -25,6 +26,7 @@ struct ScriptContext { // Convenience helpers for scripts SceneObject* FindObjectByName(const std::string& name); SceneObject* FindObjectById(int id); + SceneObject* ResolveObjectRef(const std::string& ref); bool IsObjectEnabled() const; void SetObjectEnabled(bool enabled); int GetLayer() const; @@ -34,11 +36,35 @@ struct ScriptContext { bool HasTag(const std::string& tag) const; bool IsInLayer(int layer) const; void SetPosition(const glm::vec3& pos); + void SetPosition2D(const glm::vec2& pos); void SetRotation(const glm::vec3& rot); void SetScale(const glm::vec3& scl); + void GetPlanarYawPitchVectors(float pitchDeg, float yawDeg, glm::vec3& outForward, glm::vec3& outRight) const; + // UI helpers + bool IsUIButtonPressed() const; + bool IsUIInteractable() const; + void SetUIInteractable(bool interactable); + float GetUISliderValue() const; + void SetUISliderValue(float value); + void SetUISliderRange(float minValue, float maxValue); + void SetUILabel(const std::string& label); + void SetUIColor(const glm::vec4& color); + float GetUITextScale() const; + void SetUITextScale(float scale); + void SetUISliderStyle(UISliderStyle style); + void SetUIButtonStyle(UIButtonStyle style); + void SetUIStylePreset(const std::string& name); + void RegisterUIStylePreset(const std::string& name, const ImGuiStyle& style, bool replace = false); + void SetFPSCap(bool enabled, float cap = 120.0f); bool HasRigidbody() const; + bool HasRigidbody2D() const; + bool EnsureCapsuleCollider(float height, float radius); + bool EnsureRigidbody(bool useGravity = true, bool kinematic = false); + bool SetRigidbody2DVelocity(const glm::vec2& velocity); + bool GetRigidbody2DVelocity(glm::vec2& outVelocity) const; bool SetRigidbodyVelocity(const glm::vec3& velocity); bool GetRigidbodyVelocity(glm::vec3& outVelocity) const; + bool AddRigidbodyVelocity(const glm::vec3& deltaVelocity); bool SetRigidbodyAngularVelocity(const glm::vec3& velocity); bool GetRigidbodyAngularVelocity(glm::vec3& outVelocity) const; bool AddRigidbodyForce(const glm::vec3& force); @@ -63,12 +89,15 @@ struct ScriptContext { void SetSetting(const std::string& key, const std::string& value); bool GetSettingBool(const std::string& key, bool fallback = false) const; void SetSettingBool(const std::string& key, bool value); + float GetSettingFloat(const std::string& key, float fallback = 0.0f) const; + void SetSettingFloat(const std::string& key, float value); glm::vec3 GetSettingVec3(const std::string& key, const glm::vec3& fallback = glm::vec3(0.0f)) const; void SetSettingVec3(const std::string& key, const glm::vec3& value); // Console helper void AddConsoleMessage(const std::string& message, ConsoleMessageType type = ConsoleMessageType::Info); - // Auto-binding helpers: bind once per call, optionally load stored value, then SaveAutoSettings() writes back on change. + // Auto-binding helpers: bind once per call, optionally load stored value. void AutoSetting(const std::string& key, bool& value); + void AutoSetting(const std::string& key, float& value); void AutoSetting(const std::string& key, glm::vec3& value); void AutoSetting(const std::string& key, char* buffer, size_t bufferSize); void SaveAutoSettings();