-- Improvements --

- Adding better animation support + modupak support being added though WIP,
- Optimized the engine a bit,
- Added a few references to the UI for the Scripting API,
- Improved the starting intro and made it quicker,
- Added more shortcut keys for deleting objects, copying and pasting,
- Improved hierarchy to have a better system to allow for moving objects between other objects out of the ID range without being affected,
- Added WIP gizmos for 2D world, just gotta hook it up to the correct layer lol.
- Added a few test scripts in the modularity repo,
- Added more image functions to the engine to allow for 9 split scaling for image buttons,

-- Known Bugs --
- Some stuff related to animations sometimes bug out when scrolling in the editor,
- Sometimes the starting intro goes behind the wrong layers, but typically fixes itself,
- Scripts that use the wrong include directive sometimes crash the engine instead of pushing an error and ignoring the script,
- there's a bug that occasionally happens with switching workspaces that causes it to flicker in the sprite previewer instead of automatically spawning below the hierarchy,
- auto scrollling in the console tends to shake a lot.
This commit is contained in:
2026-03-07 19:35:16 -05:00
parent 96976d4ffb
commit 9fe31b4ca1
41 changed files with 15343 additions and 3168 deletions

View File

@@ -0,0 +1,145 @@
#include "DialoguePortShared.h"
#include <algorithm>
#include <unordered_map>
#include <vector>
namespace {
using namespace DialoguePort;
struct ToggleConfig {
std::vector<std::string> objectsToEnable;
std::vector<std::string> objectsToDisable;
float intervalSeconds = 1.0f;
};
struct ToggleRuntimeState {
float timer = 0.0f;
bool initialized = false;
bool configInitialized = false;
std::string cachedEnableRefsRaw;
std::string cachedDisableRefsRaw;
std::string cachedIntervalRaw;
ToggleConfig cachedConfig;
};
std::unordered_map<int, ToggleRuntimeState> g_runtimeStates;
constexpr const char* kSettingEnableRefs = "toggle.objectsToEnable";
constexpr const char* kSettingDisableRefs = "toggle.objectsToDisable";
constexpr const char* kSettingInterval = "toggle.intervalSeconds";
bool setSettingIfChanged(ScriptContext& ctx, const std::string& key, const std::string& value) {
if (ctx.GetSetting(key, "") == value) return false;
ctx.SetSetting(key, value);
return true;
}
ToggleConfig loadConfig(ScriptContext& ctx) {
ToggleConfig config;
config.objectsToEnable = DeserializeObjectRefs(ctx.GetSetting(kSettingEnableRefs, ""));
config.objectsToDisable = DeserializeObjectRefs(ctx.GetSetting(kSettingDisableRefs, ""));
config.intervalSeconds = std::max(0.0f, ParseFloat(ctx.GetSetting(kSettingInterval, "1"), 1.0f));
return config;
}
const ToggleConfig& getCachedConfig(ScriptContext& ctx, ToggleRuntimeState& state) {
const std::string enableRefsRaw = ctx.GetSetting(kSettingEnableRefs, "");
const std::string disableRefsRaw = ctx.GetSetting(kSettingDisableRefs, "");
const std::string intervalRaw = ctx.GetSetting(kSettingInterval, "1");
const bool configChanged =
!state.configInitialized ||
state.cachedEnableRefsRaw != enableRefsRaw ||
state.cachedDisableRefsRaw != disableRefsRaw ||
state.cachedIntervalRaw != intervalRaw;
if (configChanged) {
state.cachedEnableRefsRaw = enableRefsRaw;
state.cachedDisableRefsRaw = disableRefsRaw;
state.cachedIntervalRaw = intervalRaw;
state.cachedConfig.objectsToEnable = DeserializeObjectRefs(enableRefsRaw);
state.cachedConfig.objectsToDisable = DeserializeObjectRefs(disableRefsRaw);
state.cachedConfig.intervalSeconds = std::max(0.0f, ParseFloat(intervalRaw, 1.0f));
state.configInitialized = true;
}
return state.cachedConfig;
}
void saveConfig(ScriptContext& ctx, const ToggleConfig& config) {
setSettingIfChanged(ctx, kSettingEnableRefs, SerializeObjectRefs(config.objectsToEnable));
setSettingIfChanged(ctx, kSettingDisableRefs, SerializeObjectRefs(config.objectsToDisable));
setSettingIfChanged(ctx, kSettingInterval, std::to_string(config.intervalSeconds));
}
bool enableObjects(ScriptContext& ctx, const std::vector<std::string>& refs) {
bool changed = false;
for (const std::string& ref : refs) {
SceneObject* obj = ResolveSceneObjectRef(ctx, ref);
if (!obj) continue;
if (!obj->enabled) {
obj->enabled = true;
changed = true;
}
if (obj->hasCollider && !obj->collider.enabled) {
obj->collider.enabled = true;
changed = true;
}
if (obj->hasCollider2D && !obj->collider2D.enabled) {
obj->collider2D.enabled = true;
changed = true;
}
}
if (changed) {
ctx.MarkDirty();
}
return changed;
}
bool disableObjects(ScriptContext& ctx, const std::vector<std::string>& refs) {
return SetObjectsEnabledState(ctx, refs, false);
}
} // namespace
extern "C" void Script_OnInspector(ScriptContext& ctx) {
ToggleConfig config = loadConfig(ctx);
bool changed = false;
changed |= ImGui::DragFloat("Interval (s)", &config.intervalSeconds, 0.01f, 0.0f, 120.0f, "%.2f");
config.intervalSeconds = std::max(0.0f, config.intervalSeconds);
changed |= DrawObjectRefListEditor(ctx, "Objects To Enable", config.objectsToEnable);
changed |= DrawObjectRefListEditor(ctx, "Objects To Disable", config.objectsToDisable);
if (changed) {
saveConfig(ctx, config);
}
}
void Begin(ScriptContext& ctx, float /*deltaTime*/) {
if (!ctx.object) return;
ToggleRuntimeState& state = g_runtimeStates[ctx.object->id];
state.timer = 0.0f;
state.initialized = true;
}
void TickUpdate(ScriptContext& ctx, float deltaTime) {
if (!ctx.object || deltaTime <= 0.0f) return;
ToggleRuntimeState& state = g_runtimeStates[ctx.object->id];
if (!state.initialized) {
state.timer = 0.0f;
state.initialized = true;
}
const ToggleConfig& config = getCachedConfig(ctx, state);
state.timer += deltaTime;
if (state.timer < config.intervalSeconds) return;
enableObjects(ctx, config.objectsToEnable);
disableObjects(ctx, config.objectsToDisable);
state.timer = 0.0f;
}

View File

@@ -0,0 +1,558 @@
#pragma once
#include "ScriptRuntime.h"
#include "SceneObject.h"
#include "ThirdParty/imgui/imgui.h"
#include <algorithm>
#include <array>
#include <cctype>
#include <cfloat>
#include <cstdio>
#include <cstdlib>
#include <limits>
#include <string>
#include <vector>
#include <GLFW/glfw3.h>
namespace DialoguePort {
enum class Language {
English = 0,
German = 1,
JapaneseKana = 2
};
enum class TextEffectType : int {
None = 0,
Wave = 1 << 0,
Shake = 1 << 1,
Bounce = 1 << 2,
Rotate = 1 << 3,
Fade = 1 << 4
};
struct DialogueLine {
std::string characterName;
std::string sentence;
std::string sentenceGerman;
std::string sentenceJapaneseKana;
std::string characterSoundClip;
float typingSpeed = 0.03f;
TextEffectType textEffect = TextEffectType::None;
float animationSpeed = 1.0f;
float effectIntensity = 1.0f;
std::string notTalkingObjectRef;
std::string openMouthObjectRef;
std::string closedMouthObjectRef;
std::vector<std::string> itemsToEnable;
std::vector<std::string> itemsToDisable;
};
inline std::string Trim(const std::string& value) {
size_t start = 0;
while (start < value.size() && std::isspace(static_cast<unsigned char>(value[start])) != 0) {
++start;
}
size_t end = value.size();
while (end > start && std::isspace(static_cast<unsigned char>(value[end - 1])) != 0) {
--end;
}
return value.substr(start, end - start);
}
inline int ParseInt(const std::string& value, int fallback = 0) {
if (value.empty()) return fallback;
char* endPtr = nullptr;
long parsed = std::strtol(value.c_str(), &endPtr, 10);
if (endPtr == value.c_str()) return fallback;
if (parsed < std::numeric_limits<int>::min()) return std::numeric_limits<int>::min();
if (parsed > std::numeric_limits<int>::max()) return std::numeric_limits<int>::max();
return static_cast<int>(parsed);
}
inline float ParseFloat(const std::string& value, float fallback = 0.0f) {
if (value.empty()) return fallback;
char* endPtr = nullptr;
float parsed = std::strtof(value.c_str(), &endPtr);
if (endPtr == value.c_str()) return fallback;
return parsed;
}
inline bool ParseBool(const std::string& value, bool fallback = false) {
std::string lowered = Trim(value);
std::transform(lowered.begin(), lowered.end(), lowered.begin(), [](char c) {
return static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
});
if (lowered == "1" || lowered == "true" || lowered == "yes" || lowered == "on") return true;
if (lowered == "0" || lowered == "false" || lowered == "no" || lowered == "off") return false;
return fallback;
}
inline std::string GetScriptSetting(const ScriptComponent* script, const std::string& key,
const std::string& fallback = "") {
if (!script) return fallback;
for (const ScriptSetting& setting : script->settings) {
if (setting.key == key) {
return setting.value;
}
}
return fallback;
}
inline bool SetScriptSetting(ScriptComponent* script, const std::string& key, const std::string& value) {
if (!script) return false;
for (ScriptSetting& setting : script->settings) {
if (setting.key == key) {
if (setting.value == value) return false;
setting.value = value;
return true;
}
}
script->settings.push_back(ScriptSetting{key, value});
return true;
}
inline std::string EscapeField(const std::string& value, char delimiter) {
std::string out;
out.reserve(value.size() + 8);
for (char c : value) {
if (c == '\\' || c == delimiter || c == '\n' || c == '\r') {
out.push_back('\\');
if (c == '\n') out.push_back('n');
else if (c == '\r') out.push_back('r');
else out.push_back(c);
} else {
out.push_back(c);
}
}
return out;
}
inline std::string UnescapeField(const std::string& value) {
std::string out;
out.reserve(value.size());
bool escaped = false;
for (char c : value) {
if (escaped) {
if (c == 'n') out.push_back('\n');
else if (c == 'r') out.push_back('\r');
else out.push_back(c);
escaped = false;
continue;
}
if (c == '\\') {
escaped = true;
continue;
}
out.push_back(c);
}
if (escaped) out.push_back('\\');
return out;
}
inline std::vector<std::string> SplitEscaped(const std::string& value, char delimiter) {
std::vector<std::string> fields;
std::string current;
bool escaped = false;
for (char c : value) {
if (escaped) {
if (c == 'n') current.push_back('\n');
else if (c == 'r') current.push_back('\r');
else current.push_back(c);
escaped = false;
continue;
}
if (c == '\\') {
escaped = true;
continue;
}
if (c == delimiter) {
fields.push_back(current);
current.clear();
continue;
}
current.push_back(c);
}
if (escaped) current.push_back('\\');
fields.push_back(current);
return fields;
}
inline std::string JoinEscaped(const std::vector<std::string>& values, char delimiter) {
std::string joined;
for (size_t i = 0; i < values.size(); ++i) {
joined += EscapeField(values[i], delimiter);
if (i + 1 < values.size()) joined.push_back(delimiter);
}
return joined;
}
inline std::vector<std::string> DeserializeObjectRefs(const std::string& encoded) {
std::vector<std::string> refs;
if (encoded.empty()) return refs;
std::vector<std::string> parsed = SplitEscaped(encoded, ';');
refs.reserve(parsed.size());
for (const std::string& item : parsed) {
std::string trimmed = Trim(item);
if (!trimmed.empty()) refs.push_back(trimmed);
}
return refs;
}
inline std::string SerializeObjectRefs(const std::vector<std::string>& refs) {
std::vector<std::string> cleaned;
cleaned.reserve(refs.size());
for (const std::string& ref : refs) {
std::string trimmed = Trim(ref);
if (!trimmed.empty()) cleaned.push_back(trimmed);
}
return JoinEscaped(cleaned, ';');
}
inline std::string SerializeDialogueLines(const std::vector<DialogueLine>& lines) {
std::vector<std::string> encodedLines;
encodedLines.reserve(lines.size());
for (const DialogueLine& line : lines) {
std::vector<std::string> fields;
fields.reserve(14);
fields.push_back(line.characterName);
fields.push_back(line.sentence);
fields.push_back(line.sentenceGerman);
fields.push_back(line.sentenceJapaneseKana);
fields.push_back(line.characterSoundClip);
fields.push_back(std::to_string(line.typingSpeed));
fields.push_back(std::to_string(static_cast<int>(line.textEffect)));
fields.push_back(std::to_string(line.animationSpeed));
fields.push_back(std::to_string(line.effectIntensity));
fields.push_back(line.notTalkingObjectRef);
fields.push_back(line.openMouthObjectRef);
fields.push_back(line.closedMouthObjectRef);
fields.push_back(SerializeObjectRefs(line.itemsToEnable));
fields.push_back(SerializeObjectRefs(line.itemsToDisable));
encodedLines.push_back(JoinEscaped(fields, '|'));
}
// Use tab as the outer delimiter so values stay single-line in scene files.
return JoinEscaped(encodedLines, '\t');
}
inline std::vector<DialogueLine> DeserializeDialogueLines(const std::string& encoded) {
std::vector<DialogueLine> lines;
if (encoded.empty()) return lines;
std::vector<std::string> rawLines = SplitEscaped(encoded, '\t');
// Backward compatibility for older saved data that used newline delimiters.
if (rawLines.size() <= 1 && encoded.find('\t') == std::string::npos &&
encoded.find('\n') != std::string::npos) {
rawLines = SplitEscaped(encoded, '\n');
}
lines.reserve(rawLines.size());
for (const std::string& rawLine : rawLines) {
if (Trim(rawLine).empty()) continue;
std::vector<std::string> fields = SplitEscaped(rawLine, '|');
if (fields.empty()) continue;
DialogueLine line;
if (fields.size() > 0) line.characterName = fields[0];
if (fields.size() > 1) line.sentence = fields[1];
if (fields.size() > 2) line.sentenceGerman = fields[2];
if (fields.size() > 3) line.sentenceJapaneseKana = fields[3];
if (fields.size() > 4) line.characterSoundClip = fields[4];
if (fields.size() > 5) line.typingSpeed = ParseFloat(fields[5], line.typingSpeed);
if (fields.size() > 6) line.textEffect = static_cast<TextEffectType>(ParseInt(fields[6], 0));
if (fields.size() > 7) line.animationSpeed = ParseFloat(fields[7], line.animationSpeed);
if (fields.size() > 8) line.effectIntensity = ParseFloat(fields[8], line.effectIntensity);
if (fields.size() > 9) line.notTalkingObjectRef = fields[9];
if (fields.size() > 10) line.openMouthObjectRef = fields[10];
if (fields.size() > 11) line.closedMouthObjectRef = fields[11];
if (fields.size() > 12) line.itemsToEnable = DeserializeObjectRefs(fields[12]);
if (fields.size() > 13) line.itemsToDisable = DeserializeObjectRefs(fields[13]);
lines.push_back(std::move(line));
}
return lines;
}
inline std::string MakeObjectRef(int objectId) {
return std::string("Object.ID-") + std::to_string(objectId);
}
inline bool IsAllDigits(const std::string& value) {
if (value.empty()) return false;
size_t start = (value[0] == '-' || value[0] == '+') ? 1 : 0;
if (start >= value.size()) return false;
for (size_t i = start; i < value.size(); ++i) {
if (!std::isdigit(static_cast<unsigned char>(value[i]))) return false;
}
return true;
}
inline SceneObject* ResolveSceneObjectRef(ScriptContext& ctx, const std::string& objectRef) {
std::string trimmed = Trim(objectRef);
if (trimmed.empty()) return nullptr;
if (SceneObject* resolved = ctx.ResolveObjectRef(trimmed)) {
return resolved;
}
if (IsAllDigits(trimmed)) {
return ctx.FindObjectById(ParseInt(trimmed, -1));
}
return ctx.FindObjectByName(trimmed);
}
inline bool SetObjectEnabledState(ScriptContext& ctx, SceneObject* object, bool enabled) {
if (!object) return false;
if (object->enabled == enabled) return false;
object->enabled = enabled;
ctx.MarkDirty();
return true;
}
inline bool SetObjectsEnabledState(ScriptContext& ctx, const std::vector<std::string>& refs, bool enabled) {
bool changed = false;
for (const std::string& ref : refs) {
SceneObject* object = ResolveSceneObjectRef(ctx, ref);
if (SetObjectEnabledState(ctx, object, enabled)) {
changed = true;
}
}
return changed;
}
inline bool SetUITextLabel(ScriptContext& ctx, const std::string& objectRef, const std::string& label) {
SceneObject* object = ResolveSceneObjectRef(ctx, objectRef);
if (!object || !HasUIComponent(*object) || object->ui.type != UIElementType::Text) return false;
if (object->ui.label == label) return false;
object->ui.label = label;
ctx.MarkDirty();
return true;
}
inline bool SetUITextEffects(ScriptContext& ctx,
const std::string& objectRef,
TextEffectType effect,
float animationSpeed,
float effectIntensity) {
SceneObject* object = ResolveSceneObjectRef(ctx, objectRef);
if (!object || !HasUIComponent(*object) || object->ui.type != UIElementType::Text) return false;
const int flags = static_cast<int>(effect);
const float speed = std::max(0.01f, animationSpeed);
const float intensity = std::max(0.0f, effectIntensity);
bool changed = false;
if (object->ui.textEffectFlags != flags) {
object->ui.textEffectFlags = flags;
changed = true;
}
if (std::abs(object->ui.textEffectSpeed - speed) > 1e-5f) {
object->ui.textEffectSpeed = speed;
changed = true;
}
if (std::abs(object->ui.textEffectIntensity - intensity) > 1e-5f) {
object->ui.textEffectIntensity = intensity;
changed = true;
}
if (changed) {
ctx.MarkDirty();
}
return changed;
}
inline bool SetRigidbody2DSimulated(ScriptContext& ctx, const std::string& objectRef, bool simulated) {
SceneObject* object = ResolveSceneObjectRef(ctx, objectRef);
if (!object || !object->hasRigidbody2D) return false;
if (object->rigidbody2D.enabled == simulated) return false;
object->rigidbody2D.enabled = simulated;
ctx.MarkDirty();
return true;
}
inline bool DrawStdStringInput(const char* label, std::string& value,
size_t capacity = 512,
ImGuiInputTextFlags flags = 0,
bool multiline = false,
float multilineHeight = 90.0f) {
std::vector<char> buffer(capacity + 1, '\0');
std::snprintf(buffer.data(), buffer.size(), "%s", value.c_str());
bool edited = false;
if (multiline) {
edited = ImGui::InputTextMultiline(label, buffer.data(), buffer.size(),
ImVec2(-FLT_MIN, multilineHeight), flags);
} else {
edited = ImGui::InputText(label, buffer.data(), buffer.size(), flags);
}
if (edited) {
value = buffer.data();
}
return edited;
}
inline bool DrawObjectRefInput(ScriptContext& ctx, const char* label, std::string& objectRef) {
bool changed = DrawStdStringInput(label, objectRef, 256);
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("SCENE_OBJECT")) {
if (payload->Data && payload->DataSize == static_cast<int>(sizeof(int))) {
const int droppedId = *static_cast<const int*>(payload->Data);
const std::string newRef = MakeObjectRef(droppedId);
if (objectRef != newRef) {
objectRef = newRef;
changed = true;
}
}
}
ImGui::EndDragDropTarget();
}
SceneObject* resolved = ResolveSceneObjectRef(ctx, objectRef);
if (resolved) {
ImGui::TextDisabled("-> %s (id=%d)", resolved->name.c_str(), resolved->id);
} else if (!Trim(objectRef).empty()) {
ImGui::TextDisabled("-> unresolved");
}
return changed;
}
inline bool IsAudioClipPath(const std::string& path) {
const size_t dot = path.find_last_of('.');
if (dot == std::string::npos || dot + 1 >= path.size()) return false;
std::string ext = path.substr(dot + 1);
std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return ext == "wav" || ext == "mp3" || ext == "ogg" || ext == "flac" ||
ext == "aac" || ext == "m4a";
}
inline bool DrawAudioClipInput(const char* label, std::string& clipPath, size_t capacity = 512) {
bool changed = DrawStdStringInput(label, clipPath, capacity);
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_PATH")) {
if (payload->Data && payload->DataSize > 0) {
const char* droppedPath = static_cast<const char*>(payload->Data);
if (droppedPath) {
std::string candidate = droppedPath;
if (IsAudioClipPath(candidate) && clipPath != candidate) {
clipPath = std::move(candidate);
changed = true;
}
}
}
}
ImGui::EndDragDropTarget();
}
return changed;
}
inline bool DrawObjectRefListEditor(ScriptContext& ctx, const char* label, std::vector<std::string>& refs) {
bool changed = false;
if (!ImGui::TreeNode(label)) {
return false;
}
for (size_t i = 0; i < refs.size(); ++i) {
ImGui::PushID(static_cast<int>(i));
ImGui::SetNextItemWidth(-80.0f);
changed |= DrawStdStringInput("##ref", refs[i], 256);
ImGui::SameLine();
if (ImGui::SmallButton("X")) {
refs.erase(refs.begin() + static_cast<std::ptrdiff_t>(i));
changed = true;
ImGui::PopID();
--i;
continue;
}
SceneObject* resolved = ResolveSceneObjectRef(ctx, refs[i]);
if (resolved) {
ImGui::TextDisabled("%s (id=%d)", resolved->name.c_str(), resolved->id);
}
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("SCENE_OBJECT")) {
if (payload->Data && payload->DataSize == static_cast<int>(sizeof(int))) {
const int droppedId = *static_cast<const int*>(payload->Data);
refs[i] = MakeObjectRef(droppedId);
changed = true;
}
}
ImGui::EndDragDropTarget();
}
ImGui::PopID();
}
if (ImGui::Button("Add Reference")) {
refs.emplace_back();
changed = true;
}
ImGui::SameLine();
if (ImGui::Button("Add Selected")) {
const int selectedId = ctx.GetSelectedObjectId();
if (selectedId >= 0) {
refs.push_back(MakeObjectRef(selectedId));
changed = true;
}
}
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("SCENE_OBJECT")) {
if (payload->Data && payload->DataSize == static_cast<int>(sizeof(int))) {
const int droppedId = *static_cast<const int*>(payload->Data);
refs.push_back(MakeObjectRef(droppedId));
changed = true;
}
}
ImGui::EndDragDropTarget();
}
ImGui::TreePop();
return changed;
}
inline const char* LanguageLabel(Language language) {
switch (language) {
case Language::English: return "English";
case Language::German: return "German";
case Language::JapaneseKana: return "Japanese Kana";
default: return "English";
}
}
inline bool DrawLanguageCombo(const char* label, Language& language) {
bool changed = false;
if (ImGui::BeginCombo(label, LanguageLabel(language))) {
std::array<Language, 3> values = {
Language::English,
Language::German,
Language::JapaneseKana
};
for (Language value : values) {
const bool selected = (language == value);
if (ImGui::Selectable(LanguageLabel(value), selected)) {
language = value;
changed = true;
}
if (selected) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
return changed;
}
inline bool IsRuntimeKeyDown(int glfwKey, ImGuiKey imguiKey) {
if (ImGui::IsKeyDown(imguiKey)) return true;
GLFWwindow* window = glfwGetCurrentContext();
if (!window) return false;
return glfwGetKey(window, glfwKey) == GLFW_PRESS;
}
} // namespace DialoguePort

769
Scripts/DialogueSystem.cpp Normal file
View File

@@ -0,0 +1,769 @@
#include "DialoguePortShared.h"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <regex>
#include <string>
#include <unordered_map>
#include <vector>
namespace {
using namespace DialoguePort;
struct DialogueConfig {
std::string characterNameTextRef;
std::string dialogueTextRef;
std::string playerRef;
Language currentLanguage = Language::English;
float pitchVariation = 0.1f;
float openAnimationDelay = 0.3f;
float closeAnimationDelay = 0.3f;
float spacingFactor = 0.1f;
float sizeMultiplier = 2.0f;
std::string characterSoundClip;
std::string triggerSoundClip;
std::string enterSoundClip;
std::string exitSoundClip;
std::string skipSoundClip;
bool rigidSimulated = true;
bool disableSelfOnEnd = true;
bool autoOpenOnBegin = false;
std::vector<std::string> itemsToEnable;
std::vector<std::string> itemsToDisable;
std::vector<DialogueLine> dialogueLines;
};
struct DialogueRuntimeState {
bool running = false;
bool isTyping = false;
bool isTextFullyDisplayed = false;
bool autoOpened = false;
int index = 0;
size_t revealedCharacters = 0;
float typeAccumulator = 0.0f;
float holdDelay = 0.0f;
float effectTimer = 0.0f;
float soundCooldown = 0.0f;
float openDelayRemaining = 0.0f;
float closeDelayRemaining = 0.0f;
bool pendingClose = false;
int lastInteractionRequestSerial = 0;
bool prevSubmitDown = false;
std::string currentCleanSentence;
std::string currentPlayerRef;
std::vector<DialogueLine> activeLines;
std::vector<std::string> endItemsToEnable;
std::vector<std::string> endItemsToDisable;
};
std::unordered_map<int, DialogueRuntimeState> g_runtimeStates;
std::unordered_map<int, int> g_selectedLineByObject;
constexpr const char* kSettingCharacterNameRef = "dialogue.characterNameTextRef";
constexpr const char* kSettingDialogueTextRef = "dialogue.dialogueTextRef";
constexpr const char* kSettingPlayerRef = "dialogue.playerRef";
constexpr const char* kSettingLanguage = "dialogue.language";
constexpr const char* kSettingPitchVariation = "dialogue.pitchVariation";
constexpr const char* kSettingOpenDelay = "dialogue.openAnimationDelay";
constexpr const char* kSettingCloseDelay = "dialogue.closeAnimationDelay";
constexpr const char* kSettingSpacingFactor = "dialogue.spacingFactor";
constexpr const char* kSettingSizeMultiplier = "dialogue.sizeMultiplier";
constexpr const char* kSettingCharacterSound = "dialogue.characterSoundClip";
constexpr const char* kSettingTriggerSound = "dialogue.triggerSoundClip";
constexpr const char* kSettingEnterSound = "dialogue.enterSoundClip";
constexpr const char* kSettingExitSound = "dialogue.exitSoundClip";
constexpr const char* kSettingSkipSound = "dialogue.skipSoundClip";
constexpr const char* kSettingRigidSimulated = "dialogue.rigidSimulated";
constexpr const char* kSettingDisableOnEnd = "dialogue.disableSelfOnEnd";
constexpr const char* kSettingAutoOpenOnBegin = "dialogue.autoOpenOnBegin";
constexpr const char* kSettingItemsEnable = "dialogue.itemsToEnable";
constexpr const char* kSettingItemsDisable = "dialogue.itemsToDisable";
constexpr const char* kSettingLines = "dialogue.lines";
constexpr const char* kInteractionRequestSerial = "interaction.requestSerial";
constexpr const char* kInteractionRequestPending = "interaction.requestPending";
constexpr const char* kInteractionOverrideLines = "interaction.overrideDialogueLines";
constexpr const char* kInteractionEndEnable = "interaction.itemsToEnableOnEnd";
constexpr const char* kInteractionEndDisable = "interaction.itemsToDisableOnEnd";
constexpr const char* kInteractionPlayerRef = "interaction.playerRef";
bool setSettingIfChanged(ScriptContext& ctx, const std::string& key, const std::string& value) {
if (ctx.GetSetting(key, "") == value) return false;
ctx.SetSetting(key, value);
return true;
}
DialogueConfig loadConfig(ScriptContext& ctx) {
DialogueConfig config;
config.characterNameTextRef = ctx.GetSetting(kSettingCharacterNameRef, "");
config.dialogueTextRef = ctx.GetSetting(kSettingDialogueTextRef, "");
config.playerRef = ctx.GetSetting(kSettingPlayerRef, "");
config.currentLanguage = static_cast<Language>(
std::clamp(ParseInt(ctx.GetSetting(kSettingLanguage, "0"), 0), 0, 2));
config.pitchVariation = ParseFloat(ctx.GetSetting(kSettingPitchVariation, "0.1"), 0.1f);
config.openAnimationDelay = std::max(0.0f, ParseFloat(ctx.GetSetting(kSettingOpenDelay, "0.3"), 0.3f));
config.closeAnimationDelay = std::max(0.0f, ParseFloat(ctx.GetSetting(kSettingCloseDelay, "0.3"), 0.3f));
config.spacingFactor = ParseFloat(ctx.GetSetting(kSettingSpacingFactor, "0.1"), 0.1f);
config.sizeMultiplier = std::max(0.1f, ParseFloat(ctx.GetSetting(kSettingSizeMultiplier, "2.0"), 2.0f));
config.characterSoundClip = ctx.GetSetting(kSettingCharacterSound, "");
config.triggerSoundClip = ctx.GetSetting(kSettingTriggerSound, "");
config.enterSoundClip = ctx.GetSetting(kSettingEnterSound, "");
config.exitSoundClip = ctx.GetSetting(kSettingExitSound, "");
config.skipSoundClip = ctx.GetSetting(kSettingSkipSound, "");
config.rigidSimulated = ParseBool(ctx.GetSetting(kSettingRigidSimulated, "1"), true);
config.disableSelfOnEnd = ParseBool(ctx.GetSetting(kSettingDisableOnEnd, "1"), true);
config.autoOpenOnBegin = ParseBool(ctx.GetSetting(kSettingAutoOpenOnBegin, "0"), false);
config.itemsToEnable = DeserializeObjectRefs(ctx.GetSetting(kSettingItemsEnable, ""));
config.itemsToDisable = DeserializeObjectRefs(ctx.GetSetting(kSettingItemsDisable, ""));
config.dialogueLines = DeserializeDialogueLines(ctx.GetSetting(kSettingLines, ""));
return config;
}
void saveConfig(ScriptContext& ctx, const DialogueConfig& config) {
setSettingIfChanged(ctx, kSettingCharacterNameRef, config.characterNameTextRef);
setSettingIfChanged(ctx, kSettingDialogueTextRef, config.dialogueTextRef);
setSettingIfChanged(ctx, kSettingPlayerRef, config.playerRef);
setSettingIfChanged(ctx, kSettingLanguage, std::to_string(static_cast<int>(config.currentLanguage)));
setSettingIfChanged(ctx, kSettingPitchVariation, std::to_string(config.pitchVariation));
setSettingIfChanged(ctx, kSettingOpenDelay, std::to_string(config.openAnimationDelay));
setSettingIfChanged(ctx, kSettingCloseDelay, std::to_string(config.closeAnimationDelay));
setSettingIfChanged(ctx, kSettingSpacingFactor, std::to_string(config.spacingFactor));
setSettingIfChanged(ctx, kSettingSizeMultiplier, std::to_string(config.sizeMultiplier));
setSettingIfChanged(ctx, kSettingCharacterSound, config.characterSoundClip);
setSettingIfChanged(ctx, kSettingTriggerSound, config.triggerSoundClip);
setSettingIfChanged(ctx, kSettingEnterSound, config.enterSoundClip);
setSettingIfChanged(ctx, kSettingExitSound, config.exitSoundClip);
setSettingIfChanged(ctx, kSettingSkipSound, config.skipSoundClip);
setSettingIfChanged(ctx, kSettingRigidSimulated, config.rigidSimulated ? "1" : "0");
setSettingIfChanged(ctx, kSettingDisableOnEnd, config.disableSelfOnEnd ? "1" : "0");
setSettingIfChanged(ctx, kSettingAutoOpenOnBegin, config.autoOpenOnBegin ? "1" : "0");
setSettingIfChanged(ctx, kSettingItemsEnable, SerializeObjectRefs(config.itemsToEnable));
setSettingIfChanged(ctx, kSettingItemsDisable, SerializeObjectRefs(config.itemsToDisable));
setSettingIfChanged(ctx, kSettingLines, SerializeDialogueLines(config.dialogueLines));
}
bool hasDialogueLines(const std::vector<DialogueLine>& lines) {
return !lines.empty();
}
std::string getSentenceForLanguage(const DialogueLine& line, Language language) {
switch (language) {
case Language::English:
return line.sentence;
case Language::German:
return line.sentenceGerman.empty() ? line.sentence : line.sentenceGerman;
case Language::JapaneseKana:
return line.sentenceJapaneseKana.empty() ? line.sentence : line.sentenceJapaneseKana;
default:
return line.sentence;
}
}
std::string parseSentenceForDisplay(const std::string& sentence) {
static const std::regex taggedWordPattern(R"(\(\[(\w+),\s*([\d.]+),\s*([\d.]+),\](.*?)\))");
std::string clean;
clean.reserve(sentence.size());
size_t lastIndex = 0;
for (std::sregex_iterator it(sentence.begin(), sentence.end(), taggedWordPattern), end; it != end; ++it) {
const std::smatch& match = *it;
const size_t matchPos = static_cast<size_t>(match.position());
const size_t matchLen = static_cast<size_t>(match.length());
if (matchPos > lastIndex) {
clean += sentence.substr(lastIndex, matchPos - lastIndex);
}
if (match.size() >= 5) {
clean += match[4].str();
}
lastIndex = matchPos + matchLen;
}
if (lastIndex < sentence.size()) {
clean += sentence.substr(lastIndex);
}
return clean;
}
std::string toLowerAscii(std::string value) {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return value;
}
int findAnimationClipIndexByName(SceneObject& object, const std::string& desiredName) {
if (!object.hasAnimation || !object.animation.enabled) return -1;
NormalizeAnimationClipSlots(object.animation);
if (object.animation.clips.empty()) return -1;
const std::string needle = toLowerAscii(Trim(desiredName));
for (int i = 0; i < static_cast<int>(object.animation.clips.size()); ++i) {
const AnimationClipSlot& clip = object.animation.clips[static_cast<size_t>(i)];
std::string clipName = clip.name.empty() ? AnimationClipNameFromPath(clip.assetPath) : clip.name;
if (toLowerAscii(Trim(clipName)) == needle) {
return i;
}
}
return -1;
}
bool playDialogueStateAnimation(ScriptContext& ctx, const std::string& clipName) {
if (!ctx.object || !ctx.object->hasAnimation || !ctx.object->animation.enabled) return false;
SceneObject& object = *ctx.object;
int clipIndex = findAnimationClipIndexByName(object, clipName);
if (clipIndex < 0) return false;
object.animation.activeClipIndex = clipIndex;
object.animation.clipAssetPath = object.animation.clips[static_cast<size_t>(clipIndex)].assetPath;
object.animation.runtimeClipPath.clear();
object.animation.runtimeInitialized = false;
object.animation.runtimePaused = false;
object.animation.runtimeDirection = 1.0f;
return ctx.PlayAnimation(true);
}
enum class MouthState {
TalkingOpen,
TalkingClosed,
NotTalking
};
void applyMouthState(ScriptContext& ctx, const DialogueLine& line, MouthState mouthState) {
const bool isOpen = (mouthState == MouthState::TalkingOpen);
const bool isClosed = (mouthState == MouthState::TalkingClosed);
const bool isIdle = (mouthState == MouthState::NotTalking);
SceneObject* openObj = ResolveSceneObjectRef(ctx, line.openMouthObjectRef);
SceneObject* closedObj = ResolveSceneObjectRef(ctx, line.closedMouthObjectRef);
SceneObject* idleObj = ResolveSceneObjectRef(ctx, line.notTalkingObjectRef);
SetObjectEnabledState(ctx, openObj, isOpen);
SetObjectEnabledState(ctx, closedObj, isClosed);
SetObjectEnabledState(ctx, idleObj, isIdle);
}
void resetTextWidgets(ScriptContext& ctx, const DialogueConfig& config) {
SetUITextLabel(ctx, config.characterNameTextRef, "");
SetUITextLabel(ctx, config.dialogueTextRef, "");
SetUITextEffects(ctx, config.dialogueTextRef, TextEffectType::None, 1.0f, 0.0f);
}
void revealDialogueText(ScriptContext& ctx, const DialogueConfig& config, DialogueRuntimeState& state) {
const size_t shown = std::min(state.revealedCharacters, state.currentCleanSentence.size());
SetUITextLabel(ctx, config.dialogueTextRef, state.currentCleanSentence.substr(0, shown));
}
void startLine(ScriptContext& ctx, DialogueRuntimeState& state, const DialogueConfig& config) {
if (state.index < 0 || state.index >= static_cast<int>(state.activeLines.size())) {
state.running = false;
state.isTyping = false;
state.isTextFullyDisplayed = false;
return;
}
const DialogueLine& line = state.activeLines[static_cast<size_t>(state.index)];
SetObjectsEnabledState(ctx, line.itemsToDisable, false);
SetObjectsEnabledState(ctx, line.itemsToEnable, true);
SetUITextLabel(ctx, config.characterNameTextRef, line.characterName);
state.currentCleanSentence = parseSentenceForDisplay(getSentenceForLanguage(line, config.currentLanguage));
state.revealedCharacters = 0;
state.typeAccumulator = 0.0f;
state.holdDelay = 0.0f;
state.soundCooldown = 0.0f;
state.isTyping = true;
state.isTextFullyDisplayed = false;
revealDialogueText(ctx, config, state);
SetUITextEffects(ctx, config.dialogueTextRef, line.textEffect, line.animationSpeed, line.effectIntensity);
applyMouthState(ctx, line, MouthState::TalkingClosed);
}
void completeTyping(ScriptContext& ctx, DialogueRuntimeState& state, const DialogueConfig& config) {
if (state.index < 0 || state.index >= static_cast<int>(state.activeLines.size())) return;
const DialogueLine& line = state.activeLines[static_cast<size_t>(state.index)];
state.revealedCharacters = state.currentCleanSentence.size();
state.isTyping = false;
state.isTextFullyDisplayed = true;
state.holdDelay = 0.0f;
state.typeAccumulator = 0.0f;
revealDialogueText(ctx, config, state);
applyMouthState(ctx, line, MouthState::NotTalking);
}
void finalizeDialogueEnd(ScriptContext& ctx, DialogueRuntimeState& state, const DialogueConfig& config) {
SetObjectsEnabledState(ctx, state.endItemsToDisable, false);
SetObjectsEnabledState(ctx, state.endItemsToEnable, true);
SetRigidbody2DSimulated(ctx, state.currentPlayerRef, config.rigidSimulated);
resetTextWidgets(ctx, config);
state.running = false;
state.pendingClose = false;
state.closeDelayRemaining = 0.0f;
state.isTyping = false;
state.isTextFullyDisplayed = false;
state.revealedCharacters = 0;
state.currentCleanSentence.clear();
if (config.disableSelfOnEnd) {
state.autoOpened = false;
}
if (config.disableSelfOnEnd) {
ctx.SetObjectEnabled(false);
}
}
void beginDialogueClose(ScriptContext& ctx, DialogueRuntimeState& state, const DialogueConfig& config) {
if (state.pendingClose) return;
state.pendingClose = true;
state.closeDelayRemaining = std::max(0.0f, config.closeAnimationDelay);
playDialogueStateAnimation(ctx, "DialogueStateClose");
if (state.closeDelayRemaining <= 0.0f) {
finalizeDialogueEnd(ctx, state, config);
}
}
void openDialogue(ScriptContext& ctx,
DialogueRuntimeState& state,
const DialogueConfig& config,
const std::vector<DialogueLine>& lines,
const std::vector<std::string>& itemsToEnableOnEnd,
const std::vector<std::string>& itemsToDisableOnEnd,
const std::string& playerRef) {
if (!hasDialogueLines(lines)) {
ctx.AddConsoleMessage("DialogueSystem: no dialogue lines configured.", ConsoleMessageType::Warning);
return;
}
state.activeLines = lines;
state.endItemsToEnable = itemsToEnableOnEnd;
state.endItemsToDisable = itemsToDisableOnEnd;
state.currentPlayerRef = playerRef.empty() ? config.playerRef : playerRef;
state.running = true;
state.index = 0;
state.revealedCharacters = 0;
state.typeAccumulator = 0.0f;
state.holdDelay = 0.0f;
state.soundCooldown = 0.0f;
state.effectTimer = 0.0f;
state.isTyping = false;
state.isTextFullyDisplayed = false;
state.pendingClose = false;
state.closeDelayRemaining = 0.0f;
state.currentCleanSentence.clear();
state.openDelayRemaining = config.openAnimationDelay;
resetTextWidgets(ctx, config);
SetRigidbody2DSimulated(ctx, state.currentPlayerRef, false);
playDialogueStateAnimation(ctx, "DialogueStateOpen");
if (!config.triggerSoundClip.empty()) {
ctx.PlayAudioOneShot(config.triggerSoundClip);
}
if (state.openDelayRemaining <= 0.0f) {
startLine(ctx, state, config);
}
}
bool isSubmitDown() {
return IsRuntimeKeyDown(GLFW_KEY_ENTER, ImGuiKey_Enter) ||
IsRuntimeKeyDown(GLFW_KEY_KP_ENTER, ImGuiKey_KeypadEnter);
}
void tickTyping(ScriptContext& ctx, DialogueRuntimeState& state, const DialogueConfig& config, float deltaTime) {
if (!state.isTyping) return;
if (state.index < 0 || state.index >= static_cast<int>(state.activeLines.size())) return;
const DialogueLine& line = state.activeLines[static_cast<size_t>(state.index)];
if (state.currentCleanSentence.empty()) {
completeTyping(ctx, state, config);
return;
}
const float typingSpeed = std::max(0.001f, line.typingSpeed);
state.effectTimer += deltaTime;
state.soundCooldown = std::max(0.0f, state.soundCooldown - deltaTime);
float remaining = deltaTime;
while (remaining > 0.0f && state.isTyping) {
if (state.holdDelay > 0.0f) {
const float consume = std::min(remaining, state.holdDelay);
state.holdDelay -= consume;
remaining -= consume;
continue;
}
state.typeAccumulator += remaining;
remaining = 0.0f;
bool revealedAny = false;
while (state.typeAccumulator >= typingSpeed && state.revealedCharacters < state.currentCleanSentence.size()) {
state.typeAccumulator -= typingSpeed;
++state.revealedCharacters;
revealedAny = true;
const size_t charIndex = state.revealedCharacters - 1;
const char character = state.currentCleanSentence[charIndex];
if (std::isalnum(static_cast<unsigned char>(character)) != 0) {
const std::string& clip = line.characterSoundClip.empty() ? config.characterSoundClip : line.characterSoundClip;
if (!clip.empty() && state.soundCooldown <= 0.0f) {
ctx.PlayAudioOneShot(clip);
state.soundCooldown = std::max(0.01f, typingSpeed * 0.6f);
}
}
if ((charIndex % 2U) == 0U) {
applyMouthState(ctx, line, MouthState::TalkingOpen);
} else {
applyMouthState(ctx, line, MouthState::TalkingClosed);
}
if (character == ',' || character == ';') {
state.holdDelay += typingSpeed * 3.0f;
} else if (character == '.' || character == '!' || character == '?') {
if (character == '.' && charIndex + 2 < state.currentCleanSentence.size() &&
state.currentCleanSentence[charIndex + 1] == '.' &&
state.currentCleanSentence[charIndex + 2] == '.') {
state.holdDelay += typingSpeed * 5.0f;
} else {
state.holdDelay += typingSpeed * 5.0f;
}
}
}
if (revealedAny) {
revealDialogueText(ctx, config, state);
}
if (state.revealedCharacters >= state.currentCleanSentence.size()) {
completeTyping(ctx, state, config);
break;
}
if (state.typeAccumulator < typingSpeed) {
break;
}
}
}
void drawTextEffectEditor(TextEffectType& effect) {
int flags = static_cast<int>(effect);
bool wave = (flags & static_cast<int>(TextEffectType::Wave)) != 0;
bool shake = (flags & static_cast<int>(TextEffectType::Shake)) != 0;
bool bounce = (flags & static_cast<int>(TextEffectType::Bounce)) != 0;
bool rotate = (flags & static_cast<int>(TextEffectType::Rotate)) != 0;
bool fade = (flags & static_cast<int>(TextEffectType::Fade)) != 0;
if (ImGui::Checkbox("Wave", &wave)) {
if (wave) flags |= static_cast<int>(TextEffectType::Wave);
else flags &= ~static_cast<int>(TextEffectType::Wave);
}
ImGui::SameLine();
if (ImGui::Checkbox("Shake", &shake)) {
if (shake) flags |= static_cast<int>(TextEffectType::Shake);
else flags &= ~static_cast<int>(TextEffectType::Shake);
}
ImGui::SameLine();
if (ImGui::Checkbox("Bounce", &bounce)) {
if (bounce) flags |= static_cast<int>(TextEffectType::Bounce);
else flags &= ~static_cast<int>(TextEffectType::Bounce);
}
if (ImGui::Checkbox("Rotate", &rotate)) {
if (rotate) flags |= static_cast<int>(TextEffectType::Rotate);
else flags &= ~static_cast<int>(TextEffectType::Rotate);
}
ImGui::SameLine();
if (ImGui::Checkbox("Fade", &fade)) {
if (fade) flags |= static_cast<int>(TextEffectType::Fade);
else flags &= ~static_cast<int>(TextEffectType::Fade);
}
effect = static_cast<TextEffectType>(flags);
}
bool drawDialogueLineEditor(ScriptContext& ctx, std::vector<DialogueLine>& lines, int& selectedIndex) {
bool changed = false;
if (ImGui::Button("Add Line")) {
if (!lines.empty()) {
lines.push_back(lines.back());
} else {
lines.emplace_back();
}
selectedIndex = static_cast<int>(lines.size()) - 1;
changed = true;
}
ImGui::SameLine();
if (ImGui::Button("Remove Line") && selectedIndex >= 0 && selectedIndex < static_cast<int>(lines.size())) {
lines.erase(lines.begin() + static_cast<std::ptrdiff_t>(selectedIndex));
if (lines.empty()) selectedIndex = -1;
else selectedIndex = std::clamp(selectedIndex, 0, static_cast<int>(lines.size()) - 1);
changed = true;
}
if (lines.empty()) {
ImGui::TextDisabled("No dialogue lines configured.");
return changed;
}
selectedIndex = std::clamp(selectedIndex, 0, static_cast<int>(lines.size()) - 1);
ImGui::BeginChild("DialogueLinesList", ImVec2(230.0f, 220.0f), true);
for (size_t i = 0; i < lines.size(); ++i) {
std::string label = std::to_string(i + 1) + ". " +
(lines[i].characterName.empty() ? std::string("<Unnamed>") : lines[i].characterName);
if (ImGui::Selectable(label.c_str(), selectedIndex == static_cast<int>(i))) {
selectedIndex = static_cast<int>(i);
}
}
ImGui::EndChild();
ImGui::SameLine();
ImGui::BeginGroup();
DialogueLine& line = lines[static_cast<size_t>(selectedIndex)];
changed |= DrawStdStringInput("Character Name", line.characterName, 256);
changed |= DrawStdStringInput("Sentence (EN)", line.sentence, 2048, 0, true, 60.0f);
changed |= DrawStdStringInput("Sentence (DE)", line.sentenceGerman, 2048, 0, true, 60.0f);
changed |= DrawStdStringInput("Sentence (JP Kana)", line.sentenceJapaneseKana, 2048, 0, true, 60.0f);
changed |= DrawAudioClipInput("Character Voice Clip", line.characterSoundClip, 512);
changed |= ImGui::DragFloat("Typing Speed", &line.typingSpeed, 0.001f, 0.001f, 1.0f, "%.3f");
changed |= ImGui::DragFloat("Animation Speed", &line.animationSpeed, 0.01f, 0.01f, 10.0f, "%.2f");
changed |= ImGui::DragFloat("Effect Intensity", &line.effectIntensity, 0.01f, 0.0f, 10.0f, "%.2f");
ImGui::TextUnformatted("Text Effects");
TextEffectType before = line.textEffect;
drawTextEffectEditor(line.textEffect);
changed |= (before != line.textEffect);
changed |= DrawObjectRefInput(ctx, "Not Talking Object", line.notTalkingObjectRef);
changed |= DrawObjectRefInput(ctx, "Open Mouth Object", line.openMouthObjectRef);
changed |= DrawObjectRefInput(ctx, "Closed Mouth Object", line.closedMouthObjectRef);
changed |= DrawObjectRefListEditor(ctx, "Line Items To Enable", line.itemsToEnable);
changed |= DrawObjectRefListEditor(ctx, "Line Items To Disable", line.itemsToDisable);
ImGui::EndGroup();
return changed;
}
void drawRuntimeStatus(const DialogueRuntimeState* state) {
if (!state) {
ImGui::TextDisabled("Runtime: idle");
return;
}
ImGui::Separator();
ImGui::Text("Runtime");
ImGui::TextDisabled("Running: %s", state->running ? "Yes" : "No");
ImGui::TextDisabled("Typing: %s", state->isTyping ? "Yes" : "No");
ImGui::TextDisabled("Line: %d", state->index + 1);
ImGui::TextDisabled("Chars: %zu / %zu", state->revealedCharacters, state->currentCleanSentence.size());
}
} // namespace
extern "C" void Script_OnInspector(ScriptContext& ctx) {
DialogueConfig config = loadConfig(ctx);
bool changed = false;
ImGui::TextUnformatted("DialogueSystem (Unity Port)");
ImGui::Separator();
changed |= DrawObjectRefInput(ctx, "Character Name Text Ref", config.characterNameTextRef);
changed |= DrawObjectRefInput(ctx, "Dialogue Text Ref", config.dialogueTextRef);
changed |= DrawObjectRefInput(ctx, "Player Ref", config.playerRef);
changed |= DrawLanguageCombo("Language", config.currentLanguage);
changed |= ImGui::DragFloat("Pitch Variation", &config.pitchVariation, 0.01f, 0.0f, 2.0f, "%.2f");
changed |= ImGui::DragFloat("Open Animation Delay", &config.openAnimationDelay, 0.01f, 0.0f, 10.0f, "%.2f");
changed |= ImGui::DragFloat("Close Animation Delay", &config.closeAnimationDelay, 0.01f, 0.0f, 10.0f, "%.2f");
changed |= ImGui::DragFloat("Spacing Factor", &config.spacingFactor, 0.01f, 0.0f, 2.0f, "%.2f");
changed |= ImGui::DragFloat("Size Multiplier", &config.sizeMultiplier, 0.01f, 0.1f, 10.0f, "%.2f");
changed |= DrawAudioClipInput("Character Sound Clip", config.characterSoundClip, 512);
changed |= DrawAudioClipInput("Trigger Sound Clip", config.triggerSoundClip, 512);
changed |= DrawAudioClipInput("Enter Sound Clip", config.enterSoundClip, 512);
changed |= DrawAudioClipInput("Exit Sound Clip", config.exitSoundClip, 512);
changed |= DrawAudioClipInput("Skip Sound Clip", config.skipSoundClip, 512);
changed |= ImGui::Checkbox("Player Simulated On End", &config.rigidSimulated);
changed |= ImGui::Checkbox("Disable Self On End", &config.disableSelfOnEnd);
changed |= ImGui::Checkbox("Auto Open On Begin", &config.autoOpenOnBegin);
changed |= DrawObjectRefListEditor(ctx, "Items To Enable On End", config.itemsToEnable);
changed |= DrawObjectRefListEditor(ctx, "Items To Disable On End", config.itemsToDisable);
ImGui::Separator();
ImGui::TextUnformatted("Dialogue Lines");
const int objectId = ctx.object ? ctx.object->id : -1;
int& selectedLine = g_selectedLineByObject[objectId];
changed |= drawDialogueLineEditor(ctx, config.dialogueLines, selectedLine);
if (changed) {
saveConfig(ctx, config);
}
auto stateIt = g_runtimeStates.find(objectId);
drawRuntimeStatus(stateIt != g_runtimeStates.end() ? &stateIt->second : nullptr);
}
void Begin(ScriptContext& ctx, float /*deltaTime*/) {
if (!ctx.object) return;
DialogueConfig config = loadConfig(ctx);
DialogueRuntimeState& state = g_runtimeStates[ctx.object->id];
state.running = false;
state.isTyping = false;
state.isTextFullyDisplayed = false;
state.autoOpened = false;
state.index = 0;
state.revealedCharacters = 0;
state.typeAccumulator = 0.0f;
state.holdDelay = 0.0f;
state.effectTimer = 0.0f;
state.soundCooldown = 0.0f;
state.openDelayRemaining = 0.0f;
state.closeDelayRemaining = 0.0f;
state.pendingClose = false;
state.prevSubmitDown = false;
const int interactionSerial = ParseInt(ctx.GetSetting(kInteractionRequestSerial, "0"), 0);
const bool interactionPending = ParseBool(ctx.GetSetting(kInteractionRequestPending, "0"), false);
state.lastInteractionRequestSerial = interactionPending ? (interactionSerial - 1) : interactionSerial;
state.currentCleanSentence.clear();
state.currentPlayerRef = config.playerRef;
state.activeLines.clear();
state.endItemsToEnable.clear();
state.endItemsToDisable.clear();
resetTextWidgets(ctx, config);
if (config.autoOpenOnBegin && hasDialogueLines(config.dialogueLines)) {
openDialogue(ctx, state, config, config.dialogueLines, config.itemsToEnable, config.itemsToDisable, config.playerRef);
state.autoOpened = true;
}
}
void TickUpdate(ScriptContext& ctx, float deltaTime) {
if (!ctx.object) return;
DialogueConfig config = loadConfig(ctx);
DialogueRuntimeState& state = g_runtimeStates[ctx.object->id];
const int interactionSerial = ParseInt(ctx.GetSetting(kInteractionRequestSerial, "0"), 0);
const bool interactionPending = ParseBool(ctx.GetSetting(kInteractionRequestPending, "0"), false);
if (interactionSerial != state.lastInteractionRequestSerial || interactionPending) {
state.lastInteractionRequestSerial = interactionSerial;
if (interactionPending) {
ctx.SetSetting(kInteractionRequestPending, "0");
}
std::vector<DialogueLine> overrideLines = DeserializeDialogueLines(ctx.GetSetting(kInteractionOverrideLines, ""));
std::vector<std::string> overrideEnable = DeserializeObjectRefs(ctx.GetSetting(kInteractionEndEnable, ""));
std::vector<std::string> overrideDisable = DeserializeObjectRefs(ctx.GetSetting(kInteractionEndDisable, ""));
std::string overridePlayerRef = ctx.GetSetting(kInteractionPlayerRef, "");
if (overrideLines.empty()) overrideLines = config.dialogueLines;
if (overrideEnable.empty()) overrideEnable = config.itemsToEnable;
if (overrideDisable.empty()) overrideDisable = config.itemsToDisable;
if (overridePlayerRef.empty()) overridePlayerRef = config.playerRef;
openDialogue(ctx, state, config, overrideLines, overrideEnable, overrideDisable, overridePlayerRef);
}
if (!state.running && config.autoOpenOnBegin && !state.autoOpened && hasDialogueLines(config.dialogueLines)) {
openDialogue(ctx, state, config, config.dialogueLines, config.itemsToEnable, config.itemsToDisable, config.playerRef);
state.autoOpened = true;
}
if (!state.running) {
return;
}
if (state.pendingClose) {
state.closeDelayRemaining -= std::max(0.0f, deltaTime);
if (state.closeDelayRemaining <= 0.0f) {
finalizeDialogueEnd(ctx, state, config);
}
return;
}
const bool submitDown = isSubmitDown();
const bool submitPressed = submitDown && !state.prevSubmitDown;
state.prevSubmitDown = submitDown;
if (state.openDelayRemaining > 0.0f) {
state.openDelayRemaining -= std::max(0.0f, deltaTime);
if (state.openDelayRemaining <= 0.0f) {
if (!config.enterSoundClip.empty()) {
ctx.PlayAudioOneShot(config.enterSoundClip);
}
startLine(ctx, state, config);
}
return;
}
tickTyping(ctx, state, config, std::max(0.0f, deltaTime));
if (submitPressed) {
if (state.isTyping) {
completeTyping(ctx, state, config);
if (!config.skipSoundClip.empty()) {
ctx.PlayAudioOneShot(config.skipSoundClip);
}
} else if (state.isTextFullyDisplayed) {
if (state.index < static_cast<int>(state.activeLines.size()) - 1) {
++state.index;
if (!config.enterSoundClip.empty()) {
ctx.PlayAudioOneShot(config.enterSoundClip);
}
startLine(ctx, state, config);
} else {
if (!config.exitSoundClip.empty()) {
ctx.PlayAudioOneShot(config.exitSoundClip);
}
beginDialogueClose(ctx, state, config);
}
}
}
}

View File

@@ -0,0 +1,687 @@
#include "DialoguePortShared.h"
#include <algorithm>
#include <string>
#include <unordered_map>
#include <vector>
namespace {
using namespace DialoguePort;
enum class InteractionType {
Dialogue = 0,
ToggleObjects = 1
};
struct InteractionOption {
std::string optionName = "New Option";
InteractionType interactionType = InteractionType::Dialogue;
std::string dialogueSystemRef;
std::vector<DialogueLine> dialogueLines;
std::vector<std::string> dialogueItemsToEnableOnEnd;
std::vector<std::string> dialogueItemsToDisableOnEnd;
std::vector<std::string> itemsToEnable;
std::vector<std::string> itemsToDisable;
};
struct InteractableConfig {
bool canInteract = true;
bool oneTimeUse = false;
int selectedOptionIndex = 0;
std::vector<InteractionOption> options;
std::string selectionNameOverride;
bool isSelected = false;
std::vector<std::string> selectedStateEnable;
std::vector<std::string> selectedStateDisable;
bool interactOnKeyPress = true;
bool requirePlayerInRange = false;
std::string playerRef;
float interactDistance = 2.5f;
bool debugRange = false;
};
struct InteractableRuntimeState {
bool prevInteractDown = false;
bool hasSelectionState = false;
bool lastSelectionState = false;
float lastRangeDistance = -1.0f;
bool lastRangeInRange = false;
bool lastRangeHasPlayer = false;
glm::vec3 lastPlayerWorldPos = glm::vec3(0.0f);
glm::vec3 lastSelfWorldPos = glm::vec3(0.0f);
};
std::unordered_map<int, InteractableRuntimeState> g_runtimeStates;
std::unordered_map<int, int> g_selectedOptionEditorIndex;
std::unordered_map<int, int> g_selectedDialogueLineEditorIndex;
constexpr const char* kSettingCanInteract = "interactable.canInteract";
constexpr const char* kSettingOneTimeUse = "interactable.oneTimeUse";
constexpr const char* kSettingSelectedOption = "interactable.selectedOptionIndex";
constexpr const char* kSettingOptions = "interactable.options";
constexpr const char* kSettingSelectionName = "interactable.selectionNameOverride";
constexpr const char* kSettingIsSelected = "interactable.isSelected";
constexpr const char* kSettingSelectedEnable = "interactable.selectedStateEnable";
constexpr const char* kSettingSelectedDisable = "interactable.selectedStateDisable";
constexpr const char* kSettingInteractOnKey = "interactable.interactOnKeyPress";
constexpr const char* kSettingRequireRange = "interactable.requirePlayerInRange";
constexpr const char* kSettingPlayerRef = "interactable.playerRef";
constexpr const char* kSettingDistance = "interactable.interactDistance";
constexpr const char* kSettingDebugRange = "interactable.debugRange";
constexpr const char* kInteractionRequestSerial = "interaction.requestSerial";
constexpr const char* kInteractionRequestPending = "interaction.requestPending";
constexpr const char* kInteractionOverrideLines = "interaction.overrideDialogueLines";
constexpr const char* kInteractionEndEnable = "interaction.itemsToEnableOnEnd";
constexpr const char* kInteractionEndDisable = "interaction.itemsToDisableOnEnd";
constexpr const char* kInteractionPlayerRef = "interaction.playerRef";
bool setSettingIfChanged(ScriptContext& ctx, const std::string& key, const std::string& value) {
if (ctx.GetSetting(key, "") == value) return false;
ctx.SetSetting(key, value);
return true;
}
std::string serializeOptions(const std::vector<InteractionOption>& options) {
std::vector<std::string> encodedOptions;
encodedOptions.reserve(options.size());
for (const InteractionOption& option : options) {
std::vector<std::string> fields;
fields.reserve(8);
fields.push_back(option.optionName);
fields.push_back(std::to_string(static_cast<int>(option.interactionType)));
fields.push_back(option.dialogueSystemRef);
fields.push_back(SerializeDialogueLines(option.dialogueLines));
fields.push_back(SerializeObjectRefs(option.dialogueItemsToEnableOnEnd));
fields.push_back(SerializeObjectRefs(option.dialogueItemsToDisableOnEnd));
fields.push_back(SerializeObjectRefs(option.itemsToEnable));
fields.push_back(SerializeObjectRefs(option.itemsToDisable));
encodedOptions.push_back(JoinEscaped(fields, '|'));
}
// Keep serialized options single-line for scene save parsing.
return JoinEscaped(encodedOptions, '\t');
}
std::vector<InteractionOption> deserializeOptions(const std::string& encoded) {
std::vector<InteractionOption> options;
if (encoded.empty()) return options;
std::vector<std::string> rawOptions = SplitEscaped(encoded, '\t');
if (rawOptions.size() <= 1 && encoded.find('\t') == std::string::npos &&
encoded.find('\n') != std::string::npos) {
rawOptions = SplitEscaped(encoded, '\n');
}
options.reserve(rawOptions.size());
for (const std::string& raw : rawOptions) {
if (Trim(raw).empty()) continue;
std::vector<std::string> fields = SplitEscaped(raw, '|');
if (fields.empty()) continue;
InteractionOption option;
if (fields.size() > 0) option.optionName = fields[0];
if (fields.size() > 1) {
option.interactionType = static_cast<InteractionType>(
std::clamp(ParseInt(fields[1], 0), 0, 1));
}
if (fields.size() > 2) option.dialogueSystemRef = fields[2];
if (fields.size() > 3) option.dialogueLines = DeserializeDialogueLines(fields[3]);
if (fields.size() > 4) option.dialogueItemsToEnableOnEnd = DeserializeObjectRefs(fields[4]);
if (fields.size() > 5) option.dialogueItemsToDisableOnEnd = DeserializeObjectRefs(fields[5]);
if (fields.size() > 6) option.itemsToEnable = DeserializeObjectRefs(fields[6]);
if (fields.size() > 7) option.itemsToDisable = DeserializeObjectRefs(fields[7]);
options.push_back(std::move(option));
}
return options;
}
InteractableConfig loadConfig(ScriptContext& ctx) {
InteractableConfig config;
config.canInteract = ParseBool(ctx.GetSetting(kSettingCanInteract, "1"), true);
config.oneTimeUse = ParseBool(ctx.GetSetting(kSettingOneTimeUse, "0"), false);
config.selectedOptionIndex = std::max(0, ParseInt(ctx.GetSetting(kSettingSelectedOption, "0"), 0));
config.options = deserializeOptions(ctx.GetSetting(kSettingOptions, ""));
config.selectionNameOverride = ctx.GetSetting(kSettingSelectionName, "");
config.isSelected = ParseBool(ctx.GetSetting(kSettingIsSelected, "0"), false);
config.selectedStateEnable = DeserializeObjectRefs(ctx.GetSetting(kSettingSelectedEnable, ""));
config.selectedStateDisable = DeserializeObjectRefs(ctx.GetSetting(kSettingSelectedDisable, ""));
config.interactOnKeyPress = ParseBool(ctx.GetSetting(kSettingInteractOnKey, "1"), true);
config.requirePlayerInRange = ParseBool(ctx.GetSetting(kSettingRequireRange, "0"), false);
config.playerRef = ctx.GetSetting(kSettingPlayerRef, "");
config.interactDistance = std::max(0.0f, ParseFloat(ctx.GetSetting(kSettingDistance, "2.5"), 2.5f));
config.debugRange = ParseBool(ctx.GetSetting(kSettingDebugRange, "0"), false);
return config;
}
void saveConfig(ScriptContext& ctx, const InteractableConfig& config) {
setSettingIfChanged(ctx, kSettingCanInteract, config.canInteract ? "1" : "0");
setSettingIfChanged(ctx, kSettingOneTimeUse, config.oneTimeUse ? "1" : "0");
setSettingIfChanged(ctx, kSettingSelectedOption, std::to_string(std::max(0, config.selectedOptionIndex)));
setSettingIfChanged(ctx, kSettingOptions, serializeOptions(config.options));
setSettingIfChanged(ctx, kSettingSelectionName, config.selectionNameOverride);
setSettingIfChanged(ctx, kSettingIsSelected, config.isSelected ? "1" : "0");
setSettingIfChanged(ctx, kSettingSelectedEnable, SerializeObjectRefs(config.selectedStateEnable));
setSettingIfChanged(ctx, kSettingSelectedDisable, SerializeObjectRefs(config.selectedStateDisable));
setSettingIfChanged(ctx, kSettingInteractOnKey, config.interactOnKeyPress ? "1" : "0");
setSettingIfChanged(ctx, kSettingRequireRange, config.requirePlayerInRange ? "1" : "0");
setSettingIfChanged(ctx, kSettingPlayerRef, config.playerRef);
setSettingIfChanged(ctx, kSettingDistance, std::to_string(config.interactDistance));
setSettingIfChanged(ctx, kSettingDebugRange, config.debugRange ? "1" : "0");
}
bool canInteract(const InteractableConfig& config) {
return config.canInteract && !config.options.empty();
}
const char* interactionTypeLabel(InteractionType type) {
switch (type) {
case InteractionType::Dialogue: return "Dialogue";
case InteractionType::ToggleObjects: return "Toggle Objects";
default: return "Dialogue";
}
}
bool drawInteractionTypeCombo(InteractionType& type) {
bool changed = false;
if (ImGui::BeginCombo("Interaction Type", interactionTypeLabel(type))) {
const bool dialogueSelected = (type == InteractionType::Dialogue);
if (ImGui::Selectable("Dialogue", dialogueSelected)) {
type = InteractionType::Dialogue;
changed = true;
}
if (dialogueSelected) ImGui::SetItemDefaultFocus();
const bool toggleSelected = (type == InteractionType::ToggleObjects);
if (ImGui::Selectable("Toggle Objects", toggleSelected)) {
type = InteractionType::ToggleObjects;
changed = true;
}
if (toggleSelected) ImGui::SetItemDefaultFocus();
ImGui::EndCombo();
}
return changed;
}
void drawTextEffectEditor(TextEffectType& effect) {
int flags = static_cast<int>(effect);
bool wave = (flags & static_cast<int>(TextEffectType::Wave)) != 0;
bool shake = (flags & static_cast<int>(TextEffectType::Shake)) != 0;
bool bounce = (flags & static_cast<int>(TextEffectType::Bounce)) != 0;
bool rotate = (flags & static_cast<int>(TextEffectType::Rotate)) != 0;
bool fade = (flags & static_cast<int>(TextEffectType::Fade)) != 0;
if (ImGui::Checkbox("Wave", &wave)) {
if (wave) flags |= static_cast<int>(TextEffectType::Wave);
else flags &= ~static_cast<int>(TextEffectType::Wave);
}
ImGui::SameLine();
if (ImGui::Checkbox("Shake", &shake)) {
if (shake) flags |= static_cast<int>(TextEffectType::Shake);
else flags &= ~static_cast<int>(TextEffectType::Shake);
}
ImGui::SameLine();
if (ImGui::Checkbox("Bounce", &bounce)) {
if (bounce) flags |= static_cast<int>(TextEffectType::Bounce);
else flags &= ~static_cast<int>(TextEffectType::Bounce);
}
if (ImGui::Checkbox("Rotate", &rotate)) {
if (rotate) flags |= static_cast<int>(TextEffectType::Rotate);
else flags &= ~static_cast<int>(TextEffectType::Rotate);
}
ImGui::SameLine();
if (ImGui::Checkbox("Fade", &fade)) {
if (fade) flags |= static_cast<int>(TextEffectType::Fade);
else flags &= ~static_cast<int>(TextEffectType::Fade);
}
effect = static_cast<TextEffectType>(flags);
}
bool drawDialogueLineEditor(ScriptContext& ctx, std::vector<DialogueLine>& lines, int& selectedIndex) {
bool changed = false;
if (ImGui::Button("Add Dialogue Line")) {
if (!lines.empty()) {
lines.push_back(lines.back());
} else {
lines.emplace_back();
}
selectedIndex = static_cast<int>(lines.size()) - 1;
changed = true;
}
ImGui::SameLine();
if (ImGui::Button("Remove Dialogue Line") && selectedIndex >= 0 && selectedIndex < static_cast<int>(lines.size())) {
lines.erase(lines.begin() + static_cast<std::ptrdiff_t>(selectedIndex));
if (lines.empty()) selectedIndex = -1;
else selectedIndex = std::clamp(selectedIndex, 0, static_cast<int>(lines.size()) - 1);
changed = true;
}
if (lines.empty()) {
ImGui::TextDisabled("No override dialogue lines configured.");
return changed;
}
selectedIndex = std::clamp(selectedIndex, 0, static_cast<int>(lines.size()) - 1);
ImGui::BeginChild("DialogueLinesList", ImVec2(230.0f, 220.0f), true);
for (size_t i = 0; i < lines.size(); ++i) {
std::string label = std::to_string(i + 1) + ". " +
(lines[i].characterName.empty() ? std::string("<Unnamed>") : lines[i].characterName);
if (ImGui::Selectable(label.c_str(), selectedIndex == static_cast<int>(i))) {
selectedIndex = static_cast<int>(i);
}
}
ImGui::EndChild();
ImGui::SameLine();
ImGui::BeginGroup();
DialogueLine& line = lines[static_cast<size_t>(selectedIndex)];
changed |= DrawStdStringInput("Character Name", line.characterName, 256);
changed |= DrawStdStringInput("Sentence (EN)", line.sentence, 2048, 0, true, 60.0f);
changed |= DrawStdStringInput("Sentence (DE)", line.sentenceGerman, 2048, 0, true, 60.0f);
changed |= DrawStdStringInput("Sentence (JP Kana)", line.sentenceJapaneseKana, 2048, 0, true, 60.0f);
changed |= DrawAudioClipInput("Character Voice Clip", line.characterSoundClip, 512);
changed |= ImGui::DragFloat("Typing Speed", &line.typingSpeed, 0.001f, 0.001f, 1.0f, "%.3f");
changed |= ImGui::DragFloat("Animation Speed", &line.animationSpeed, 0.01f, 0.01f, 10.0f, "%.2f");
changed |= ImGui::DragFloat("Effect Intensity", &line.effectIntensity, 0.01f, 0.0f, 10.0f, "%.2f");
ImGui::TextUnformatted("Text Effects");
TextEffectType before = line.textEffect;
drawTextEffectEditor(line.textEffect);
changed |= (before != line.textEffect);
changed |= DrawObjectRefInput(ctx, "Not Talking Object", line.notTalkingObjectRef);
changed |= DrawObjectRefInput(ctx, "Open Mouth Object", line.openMouthObjectRef);
changed |= DrawObjectRefInput(ctx, "Closed Mouth Object", line.closedMouthObjectRef);
changed |= DrawObjectRefListEditor(ctx, "Line Items To Enable", line.itemsToEnable);
changed |= DrawObjectRefListEditor(ctx, "Line Items To Disable", line.itemsToDisable);
ImGui::EndGroup();
return changed;
}
void applySelectedState(ScriptContext& ctx, const InteractableConfig& config) {
SetObjectsEnabledState(ctx, config.selectedStateEnable, config.isSelected);
SetObjectsEnabledState(ctx, config.selectedStateDisable, !config.isSelected);
}
bool isInteractDown() {
return IsRuntimeKeyDown(GLFW_KEY_E, ImGuiKey_E);
}
glm::vec3 getObjectReferencePosition(ScriptContext& ctx, const SceneObject& object) {
if (HasUIComponent(object) && object.ui.type != UIElementType::None) {
glm::vec2 worldUiPos(object.ui.position.x, object.ui.position.y);
int parentId = object.parentId;
int guard = 0;
while (parentId >= 0 && guard < 256) {
++guard;
SceneObject* parent = ctx.FindObjectById(parentId);
if (!parent) break;
if (HasUIComponent(*parent) && parent->ui.type != UIElementType::None) {
worldUiPos += parent->ui.position;
parentId = parent->parentId;
continue;
}
worldUiPos += glm::vec2(parent->position.x, parent->position.y);
break;
}
return glm::vec3(worldUiPos.x, worldUiPos.y, object.position.z);
}
return object.position;
}
bool isPlayerInRange(ScriptContext& ctx, const InteractableConfig& config,
InteractableRuntimeState* runtimeState = nullptr) {
if (!ctx.object) return false;
SceneObject* player = ResolveSceneObjectRef(ctx, config.playerRef);
if (!player) {
if (runtimeState) {
runtimeState->lastRangeHasPlayer = false;
runtimeState->lastRangeInRange = false;
runtimeState->lastRangeDistance = -1.0f;
runtimeState->lastSelfWorldPos = getObjectReferencePosition(ctx, *ctx.object);
runtimeState->lastPlayerWorldPos = glm::vec3(0.0f);
}
return !config.requirePlayerInRange;
}
const glm::vec3 selfPos = getObjectReferencePosition(ctx, *ctx.object);
const glm::vec3 playerPos = getObjectReferencePosition(ctx, *player);
const glm::vec3 delta = playerPos - selfPos;
const float range = std::max(0.0f, config.interactDistance);
const float distanceSq = glm::dot(delta, delta);
const bool inRange = distanceSq <= (range * range);
if (runtimeState) {
runtimeState->lastRangeHasPlayer = true;
runtimeState->lastRangeInRange = inRange;
runtimeState->lastRangeDistance = std::sqrt(std::max(0.0f, distanceSq));
runtimeState->lastSelfWorldPos = selfPos;
runtimeState->lastPlayerWorldPos = playerPos;
}
return !config.requirePlayerInRange || inRange;
}
ScriptComponent* findDialogueScript(SceneObject* object) {
if (!object) return nullptr;
for (ScriptComponent& script : object->scripts) {
if (script.path.find("DialogueSystem") != std::string::npos) {
return &script;
}
}
for (ScriptComponent& script : object->scripts) {
if (script.language == ScriptLanguage::Cpp) {
return &script;
}
}
if (!object->scripts.empty()) {
return &object->scripts.front();
}
return nullptr;
}
bool executeDialogueOption(ScriptContext& ctx,
const InteractionOption& option,
const InteractableConfig& config) {
SceneObject* dialogueObject = ResolveSceneObjectRef(ctx, option.dialogueSystemRef);
if (!dialogueObject) {
ctx.AddConsoleMessage("InteractableObject: dialogue option is missing a valid DialogueSystem object reference.",
ConsoleMessageType::Warning);
return false;
}
ScriptComponent* dialogueScript = findDialogueScript(dialogueObject);
if (!dialogueScript) {
ctx.AddConsoleMessage("InteractableObject: target DialogueSystem object has no script component.",
ConsoleMessageType::Warning);
return false;
}
bool changed = false;
changed |= SetScriptSetting(dialogueScript, kInteractionRequestPending, "1");
changed |= SetScriptSetting(dialogueScript, kInteractionOverrideLines, SerializeDialogueLines(option.dialogueLines));
changed |= SetScriptSetting(dialogueScript, kInteractionEndEnable, SerializeObjectRefs(option.dialogueItemsToEnableOnEnd));
changed |= SetScriptSetting(dialogueScript, kInteractionEndDisable, SerializeObjectRefs(option.dialogueItemsToDisableOnEnd));
changed |= SetScriptSetting(dialogueScript, kInteractionPlayerRef, config.playerRef);
const int currentSerial = ParseInt(GetScriptSetting(dialogueScript, kInteractionRequestSerial, "0"), 0);
changed |= SetScriptSetting(dialogueScript, kInteractionRequestSerial, std::to_string(currentSerial + 1));
if (!dialogueObject->enabled) {
dialogueObject->enabled = true;
changed = true;
}
if (changed) {
ctx.MarkDirty();
}
return true;
}
bool executeOption(ScriptContext& ctx,
const InteractionOption& option,
const InteractableConfig& config) {
if (option.interactionType == InteractionType::Dialogue) {
return executeDialogueOption(ctx, option, config);
}
SetObjectsEnabledState(ctx, option.itemsToEnable, true);
SetObjectsEnabledState(ctx, option.itemsToDisable, false);
return true;
}
bool interact(ScriptContext& ctx, InteractableConfig& config, InteractableRuntimeState* runtimeState = nullptr) {
if (!canInteract(config)) {
return false;
}
if (!isPlayerInRange(ctx, config, runtimeState)) {
if (config.requirePlayerInRange && config.debugRange) {
if (runtimeState && runtimeState->lastRangeHasPlayer) {
char msg[256];
std::snprintf(msg, sizeof(msg),
"InteractableObject: out of range (distance %.2f > %.2f).",
runtimeState->lastRangeDistance,
std::max(0.0f, config.interactDistance));
ctx.AddConsoleMessage(msg, ConsoleMessageType::Info);
} else {
ctx.AddConsoleMessage("InteractableObject: player reference is missing/unresolved for range check.",
ConsoleMessageType::Warning);
}
}
return false;
}
const int optionIndex = std::clamp(config.selectedOptionIndex, 0,
static_cast<int>(config.options.size()) - 1);
config.selectedOptionIndex = optionIndex;
const InteractionOption& option = config.options[static_cast<size_t>(optionIndex)];
const bool executed = executeOption(ctx, option, config);
if (executed && config.oneTimeUse) {
config.canInteract = false;
}
return executed;
}
const char* selectionName(const InteractableConfig& config, const SceneObject* object) {
if (!Trim(config.selectionNameOverride).empty()) {
return config.selectionNameOverride.c_str();
}
if (object) return object->name.c_str();
return "Interactable";
}
void drawRuntimeStatus(const InteractableConfig& config, const InteractableRuntimeState* runtimeState) {
ImGui::Separator();
ImGui::TextUnformatted("Runtime");
ImGui::TextDisabled("Can Interact: %s", config.canInteract ? "Yes" : "No");
ImGui::TextDisabled("Options: %zu", config.options.size());
ImGui::TextDisabled("Selected Option: %d", config.selectedOptionIndex);
if (!runtimeState) return;
if (config.requirePlayerInRange || config.debugRange) {
if (!runtimeState->lastRangeHasPlayer) {
ImGui::TextDisabled("Range: player not found");
} else {
ImGui::TextDisabled("Range Distance: %.2f / %.2f",
runtimeState->lastRangeDistance,
std::max(0.0f, config.interactDistance));
ImGui::TextDisabled("In Range: %s", runtimeState->lastRangeInRange ? "Yes" : "No");
if (config.debugRange) {
ImGui::TextDisabled("Self Pos: (%.2f, %.2f, %.2f)",
runtimeState->lastSelfWorldPos.x,
runtimeState->lastSelfWorldPos.y,
runtimeState->lastSelfWorldPos.z);
ImGui::TextDisabled("Player Pos: (%.2f, %.2f, %.2f)",
runtimeState->lastPlayerWorldPos.x,
runtimeState->lastPlayerWorldPos.y,
runtimeState->lastPlayerWorldPos.z);
}
}
}
}
} // namespace
extern "C" void Script_OnInspector(ScriptContext& ctx) {
InteractableConfig config = loadConfig(ctx);
bool changed = false;
const int objectId = ctx.object ? ctx.object->id : -1;
InteractableRuntimeState& runtimeState = g_runtimeStates[objectId];
isPlayerInRange(ctx, config, &runtimeState);
ImGui::TextUnformatted("InteractableObject (Unity Port)");
ImGui::Separator();
ImGui::TextDisabled("Selection Name: %s", selectionName(config, ctx.object));
changed |= ImGui::Checkbox("Can Interact", &config.canInteract);
changed |= ImGui::Checkbox("One Time Use", &config.oneTimeUse);
changed |= ImGui::Checkbox("Interact On E", &config.interactOnKeyPress);
changed |= ImGui::Checkbox("Require Player In Range", &config.requirePlayerInRange);
changed |= ImGui::DragFloat("Interaction Distance", &config.interactDistance, 0.05f, 0.0f, 100.0f, "%.2f");
changed |= ImGui::Checkbox("Debug Range", &config.debugRange);
changed |= DrawObjectRefInput(ctx, "Player Ref", config.playerRef);
changed |= DrawStdStringInput("Selection Name Override", config.selectionNameOverride, 256);
if (ImGui::Checkbox("Is Selected", &config.isSelected)) {
applySelectedState(ctx, config);
changed = true;
}
changed |= DrawObjectRefListEditor(ctx, "Selected State Enable", config.selectedStateEnable);
changed |= DrawObjectRefListEditor(ctx, "Selected State Disable", config.selectedStateDisable);
if (ImGui::Button("Interact Now")) {
if (interact(ctx, config, &runtimeState)) {
changed = true;
}
}
ImGui::Separator();
ImGui::TextUnformatted("Interaction Options");
if (ImGui::Button("Add Option")) {
config.options.emplace_back();
const int objectId = ctx.object ? ctx.object->id : -1;
g_selectedOptionEditorIndex[objectId] = static_cast<int>(config.options.size()) - 1;
changed = true;
}
ImGui::SameLine();
{
const int objectId = ctx.object ? ctx.object->id : -1;
int& selected = g_selectedOptionEditorIndex[objectId];
if (ImGui::Button("Remove Option") && selected >= 0 && selected < static_cast<int>(config.options.size())) {
config.options.erase(config.options.begin() + static_cast<std::ptrdiff_t>(selected));
if (config.options.empty()) selected = -1;
else selected = std::clamp(selected, 0, static_cast<int>(config.options.size()) - 1);
changed = true;
}
}
if (!config.options.empty()) {
config.selectedOptionIndex = std::clamp(config.selectedOptionIndex, 0, static_cast<int>(config.options.size()) - 1);
changed |= ImGui::SliderInt("Selected Option Index", &config.selectedOptionIndex, 0,
static_cast<int>(config.options.size()) - 1);
const int objectId = ctx.object ? ctx.object->id : -1;
int& selected = g_selectedOptionEditorIndex[objectId];
selected = std::clamp(selected, 0, static_cast<int>(config.options.size()) - 1);
ImGui::BeginChild("OptionList", ImVec2(230.0f, 180.0f), true);
for (size_t i = 0; i < config.options.size(); ++i) {
const std::string label = std::to_string(i + 1) + ". " +
(config.options[i].optionName.empty() ? std::string("<Unnamed>") : config.options[i].optionName);
if (ImGui::Selectable(label.c_str(), selected == static_cast<int>(i))) {
selected = static_cast<int>(i);
}
}
ImGui::EndChild();
ImGui::SameLine();
ImGui::BeginGroup();
InteractionOption& option = config.options[static_cast<size_t>(selected)];
changed |= DrawStdStringInput("Option Name", option.optionName, 256);
changed |= drawInteractionTypeCombo(option.interactionType);
if (option.interactionType == InteractionType::Dialogue) {
ImGui::Separator();
ImGui::TextUnformatted("Dialogue Settings");
changed |= DrawObjectRefInput(ctx, "Dialogue System Ref", option.dialogueSystemRef);
int& selectedLine = g_selectedDialogueLineEditorIndex[objectId];
changed |= drawDialogueLineEditor(ctx, option.dialogueLines, selectedLine);
changed |= DrawObjectRefListEditor(ctx, "Dialogue End Enable", option.dialogueItemsToEnableOnEnd);
changed |= DrawObjectRefListEditor(ctx, "Dialogue End Disable", option.dialogueItemsToDisableOnEnd);
} else {
ImGui::Separator();
ImGui::TextUnformatted("Object Toggle Settings");
changed |= DrawObjectRefListEditor(ctx, "Items To Enable", option.itemsToEnable);
changed |= DrawObjectRefListEditor(ctx, "Items To Disable", option.itemsToDisable);
}
ImGui::EndGroup();
} else {
ImGui::TextDisabled("No options configured.");
}
if (changed) {
saveConfig(ctx, config);
}
drawRuntimeStatus(config, &runtimeState);
}
void Begin(ScriptContext& ctx, float /*deltaTime*/) {
if (!ctx.object) return;
const InteractableConfig config = loadConfig(ctx);
InteractableRuntimeState& state = g_runtimeStates[ctx.object->id];
state.prevInteractDown = false;
state.hasSelectionState = true;
state.lastSelectionState = config.isSelected;
state.lastRangeDistance = -1.0f;
state.lastRangeInRange = false;
state.lastRangeHasPlayer = false;
state.lastPlayerWorldPos = glm::vec3(0.0f);
state.lastSelfWorldPos = ctx.object ? getObjectReferencePosition(ctx, *ctx.object) : glm::vec3(0.0f);
applySelectedState(ctx, config);
}
void TickUpdate(ScriptContext& ctx, float /*deltaTime*/) {
if (!ctx.object) return;
InteractableConfig config = loadConfig(ctx);
InteractableRuntimeState& state = g_runtimeStates[ctx.object->id];
if (!state.hasSelectionState || state.lastSelectionState != config.isSelected) {
applySelectedState(ctx, config);
state.hasSelectionState = true;
state.lastSelectionState = config.isSelected;
}
isPlayerInRange(ctx, config, &state);
const bool interactDown = isInteractDown();
const bool interactPressed = interactDown && !state.prevInteractDown;
state.prevInteractDown = interactDown;
if (!config.interactOnKeyPress || !interactPressed) {
return;
}
if (interact(ctx, config, &state)) {
saveConfig(ctx, config);
}
}

View File

@@ -0,0 +1,341 @@
#include "DialoguePortShared.h"
#include <algorithm>
#include <string>
#include <unordered_map>
#include <vector>
namespace {
using namespace DialoguePort;
enum class MenuOrientation {
Vertical = 0,
Horizontal = 1
};
struct MenuAction {
std::vector<std::string> enableRefs;
std::vector<std::string> disableRefs;
};
struct MenuConfig {
MenuOrientation orientation = MenuOrientation::Vertical;
std::string heartRef;
std::vector<std::string> menuItemRefs;
std::string moveSoundRef;
std::string selectSoundRef;
float moveSpeed = 0.3f;
float offset = 20.0f;
float inputDelay = 0.2f;
float initialDelay = 0.2f;
std::vector<MenuAction> actions;
};
struct MenuRuntimeState {
int currentIndex = 0;
float nextInputTime = 0.0f;
float startupDelayRemaining = 0.0f;
float elapsed = 0.0f;
glm::vec2 heartVisualPos = glm::vec2(0.0f);
bool initialized = false;
};
std::unordered_map<int, MenuRuntimeState> g_runtimeStates;
std::unordered_map<int, int> g_selectedActionByObject;
constexpr const char* kSettingOrientation = "menu.orientation";
constexpr const char* kSettingHeartRef = "menu.heartRef";
constexpr const char* kSettingMenuItems = "menu.itemRefs";
constexpr const char* kSettingMoveSoundRef = "menu.moveSoundRef";
constexpr const char* kSettingSelectSoundRef = "menu.selectSoundRef";
constexpr const char* kSettingMoveSpeed = "menu.moveSpeed";
constexpr const char* kSettingOffset = "menu.offset";
constexpr const char* kSettingInputDelay = "menu.inputDelay";
constexpr const char* kSettingInitialDelay = "menu.initialDelay";
constexpr const char* kSettingActions = "menu.actions";
bool setSettingIfChanged(ScriptContext& ctx, const std::string& key, const std::string& value) {
if (ctx.GetSetting(key, "") == value) return false;
ctx.SetSetting(key, value);
return true;
}
void normalizeActionCount(MenuConfig& config) {
if (config.actions.size() < config.menuItemRefs.size()) {
config.actions.resize(config.menuItemRefs.size());
} else if (config.actions.size() > config.menuItemRefs.size()) {
config.actions.resize(config.menuItemRefs.size());
}
}
std::string serializeActions(const std::vector<MenuAction>& actions) {
std::vector<std::string> encoded;
encoded.reserve(actions.size());
for (const MenuAction& action : actions) {
std::vector<std::string> fields;
fields.reserve(2);
fields.push_back(SerializeObjectRefs(action.enableRefs));
fields.push_back(SerializeObjectRefs(action.disableRefs));
encoded.push_back(JoinEscaped(fields, '|'));
}
return JoinEscaped(encoded, '\t');
}
std::vector<MenuAction> deserializeActions(const std::string& encoded) {
std::vector<MenuAction> actions;
if (encoded.empty()) return actions;
char outerDelimiter = '\t';
if (encoded.find('\t') == std::string::npos && encoded.find('\n') != std::string::npos) {
outerDelimiter = '\n';
}
std::vector<std::string> entries = SplitEscaped(encoded, outerDelimiter);
actions.reserve(entries.size());
for (const std::string& entry : entries) {
if (Trim(entry).empty()) continue;
std::vector<std::string> fields = SplitEscaped(entry, '|');
MenuAction action;
if (fields.size() > 0) action.enableRefs = DeserializeObjectRefs(fields[0]);
if (fields.size() > 1) action.disableRefs = DeserializeObjectRefs(fields[1]);
actions.push_back(std::move(action));
}
return actions;
}
MenuConfig loadConfig(ScriptContext& ctx) {
MenuConfig config;
config.orientation = static_cast<MenuOrientation>(
std::clamp(ParseInt(ctx.GetSetting(kSettingOrientation, "0"), 0), 0, 1));
config.heartRef = ctx.GetSetting(kSettingHeartRef, "");
config.menuItemRefs = DeserializeObjectRefs(ctx.GetSetting(kSettingMenuItems, ""));
config.moveSoundRef = ctx.GetSetting(kSettingMoveSoundRef, "");
config.selectSoundRef = ctx.GetSetting(kSettingSelectSoundRef, "");
config.moveSpeed = std::max(0.0f, ParseFloat(ctx.GetSetting(kSettingMoveSpeed, "0.3"), 0.3f));
config.offset = ParseFloat(ctx.GetSetting(kSettingOffset, "20"), 20.0f);
config.inputDelay = std::max(0.0f, ParseFloat(ctx.GetSetting(kSettingInputDelay, "0.2"), 0.2f));
config.initialDelay = std::max(0.0f, ParseFloat(ctx.GetSetting(kSettingInitialDelay, "0.2"), 0.2f));
config.actions = deserializeActions(ctx.GetSetting(kSettingActions, ""));
normalizeActionCount(config);
return config;
}
void saveConfig(ScriptContext& ctx, MenuConfig& config) {
normalizeActionCount(config);
setSettingIfChanged(ctx, kSettingOrientation, std::to_string(static_cast<int>(config.orientation)));
setSettingIfChanged(ctx, kSettingHeartRef, config.heartRef);
setSettingIfChanged(ctx, kSettingMenuItems, SerializeObjectRefs(config.menuItemRefs));
setSettingIfChanged(ctx, kSettingMoveSoundRef, config.moveSoundRef);
setSettingIfChanged(ctx, kSettingSelectSoundRef, config.selectSoundRef);
setSettingIfChanged(ctx, kSettingMoveSpeed, std::to_string(config.moveSpeed));
setSettingIfChanged(ctx, kSettingOffset, std::to_string(config.offset));
setSettingIfChanged(ctx, kSettingInputDelay, std::to_string(config.inputDelay));
setSettingIfChanged(ctx, kSettingInitialDelay, std::to_string(config.initialDelay));
setSettingIfChanged(ctx, kSettingActions, serializeActions(config.actions));
}
void playSoundFromRef(ScriptContext& ctx, const std::string& objectRef) {
SceneObject* source = ResolveSceneObjectRef(ctx, objectRef);
if (!source || !source->hasAudioSource || source->audioSource.clipPath.empty()) return;
ctx.PlayAudioOneShot(source->audioSource.clipPath);
}
int getDirectionInput(MenuOrientation orientation) {
if (orientation == MenuOrientation::Vertical) {
if (IsRuntimeKeyDown(GLFW_KEY_DOWN, ImGuiKey_DownArrow) ||
IsRuntimeKeyDown(GLFW_KEY_S, ImGuiKey_S)) {
return 1;
}
if (IsRuntimeKeyDown(GLFW_KEY_UP, ImGuiKey_UpArrow) ||
IsRuntimeKeyDown(GLFW_KEY_W, ImGuiKey_W)) {
return -1;
}
return 0;
}
if (IsRuntimeKeyDown(GLFW_KEY_RIGHT, ImGuiKey_RightArrow) ||
IsRuntimeKeyDown(GLFW_KEY_D, ImGuiKey_D)) {
return 1;
}
if (IsRuntimeKeyDown(GLFW_KEY_LEFT, ImGuiKey_LeftArrow) ||
IsRuntimeKeyDown(GLFW_KEY_A, ImGuiKey_A)) {
return -1;
}
return 0;
}
bool isSubmitPressed() {
return IsRuntimeKeyDown(GLFW_KEY_ENTER, ImGuiKey_Enter) ||
IsRuntimeKeyDown(GLFW_KEY_KP_ENTER, ImGuiKey_KeypadEnter);
}
SceneObject* resolveMenuItem(ScriptContext& ctx, const MenuConfig& config, int index) {
if (index < 0 || index >= static_cast<int>(config.menuItemRefs.size())) return nullptr;
return ResolveSceneObjectRef(ctx, config.menuItemRefs[static_cast<size_t>(index)]);
}
void updateHeartPosition(ScriptContext& ctx,
const MenuConfig& config,
MenuRuntimeState& state,
float deltaTime) {
SceneObject* heart = ResolveSceneObjectRef(ctx, config.heartRef);
SceneObject* target = resolveMenuItem(ctx, config, state.currentIndex);
if (!heart || !target || !HasUIComponent(*heart) || !HasUIComponent(*target)) return;
glm::vec2 targetPosition = target->ui.position;
const float itemHalfW = std::max(0.0f, target->ui.size.x * 0.5f);
const float itemHalfH = std::max(0.0f, target->ui.size.y * 0.5f);
const float heartHalfW = std::max(0.0f, heart->ui.size.x * 0.5f);
const float heartHalfH = std::max(0.0f, heart->ui.size.y * 0.5f);
if (config.orientation == MenuOrientation::Vertical) {
targetPosition.x -= itemHalfW + heartHalfW + config.offset;
} else {
targetPosition.y -= itemHalfH + heartHalfH + config.offset;
}
if (!state.initialized) {
state.heartVisualPos = heart->ui.position;
}
if (config.moveSpeed <= 0.0001f) {
state.heartVisualPos = targetPosition;
} else {
float alpha = std::clamp(deltaTime / config.moveSpeed, 0.0f, 1.0f);
state.heartVisualPos = glm::mix(state.heartVisualPos, targetPosition, alpha);
}
if (glm::distance(state.heartVisualPos, heart->ui.position) > 0.001f) {
heart->ui.position = state.heartVisualPos;
ctx.MarkDirty();
}
}
void executeAction(ScriptContext& ctx, const MenuConfig& config, int index) {
if (index < 0 || index >= static_cast<int>(config.actions.size())) {
ctx.AddConsoleMessage("MainMenuController: no action assigned for current menu item.",
ConsoleMessageType::Warning);
return;
}
const MenuAction& action = config.actions[static_cast<size_t>(index)];
SetObjectsEnabledState(ctx, action.disableRefs, false);
SetObjectsEnabledState(ctx, action.enableRefs, true);
}
} // namespace
extern "C" void Script_OnInspector(ScriptContext& ctx) {
MenuConfig config = loadConfig(ctx);
bool changed = false;
const char* orientationLabels[] = { "Vertical", "Horizontal" };
int orientation = static_cast<int>(config.orientation);
if (ImGui::Combo("Menu Orientation", &orientation, orientationLabels, IM_ARRAYSIZE(orientationLabels))) {
config.orientation = static_cast<MenuOrientation>(std::clamp(orientation, 0, 1));
changed = true;
}
changed |= DrawObjectRefInput(ctx, "Heart Object", config.heartRef);
changed |= DrawObjectRefInput(ctx, "Move Sound Source", config.moveSoundRef);
changed |= DrawObjectRefInput(ctx, "Select Sound Source", config.selectSoundRef);
changed |= DrawObjectRefListEditor(ctx, "Menu Item Refs", config.menuItemRefs);
changed |= ImGui::DragFloat("Move Speed (s)", &config.moveSpeed, 0.01f, 0.0f, 5.0f, "%.2f");
config.moveSpeed = std::max(0.0f, config.moveSpeed);
changed |= ImGui::DragFloat("Offset", &config.offset, 0.5f, -500.0f, 500.0f, "%.1f");
changed |= ImGui::DragFloat("Input Delay", &config.inputDelay, 0.01f, 0.0f, 2.0f, "%.2f");
config.inputDelay = std::max(0.0f, config.inputDelay);
changed |= ImGui::DragFloat("Initial Delay", &config.initialDelay, 0.01f, 0.0f, 5.0f, "%.2f");
config.initialDelay = std::max(0.0f, config.initialDelay);
normalizeActionCount(config);
if (!config.menuItemRefs.empty()) {
int& selected = g_selectedActionByObject[ctx.object ? ctx.object->id : -1];
selected = std::clamp(selected, 0, static_cast<int>(config.menuItemRefs.size()) - 1);
if (ImGui::BeginCombo("Edit Action", std::to_string(selected + 1).c_str())) {
for (int i = 0; i < static_cast<int>(config.menuItemRefs.size()); ++i) {
std::string label = std::to_string(i + 1);
if (SceneObject* item = resolveMenuItem(ctx, config, i)) {
label += ". " + item->name;
}
bool isSelected = (selected == i);
if (ImGui::Selectable(label.c_str(), isSelected)) {
selected = i;
}
if (isSelected) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
if (selected >= 0 && selected < static_cast<int>(config.actions.size())) {
MenuAction& action = config.actions[static_cast<size_t>(selected)];
changed |= DrawObjectRefListEditor(ctx, "Enable Objects", action.enableRefs);
changed |= DrawObjectRefListEditor(ctx, "Disable Objects", action.disableRefs);
}
} else {
ImGui::TextDisabled("Add menu item references to configure actions.");
}
if (changed) {
saveConfig(ctx, config);
}
}
void Begin(ScriptContext& ctx, float /*deltaTime*/) {
if (!ctx.object) return;
MenuConfig config = loadConfig(ctx);
MenuRuntimeState& state = g_runtimeStates[ctx.object->id];
state.currentIndex = 0;
state.nextInputTime = 0.0f;
state.startupDelayRemaining = config.initialDelay;
state.elapsed = 0.0f;
state.initialized = false;
updateHeartPosition(ctx, config, state, 1.0f);
state.initialized = true;
}
void TickUpdate(ScriptContext& ctx, float deltaTime) {
if (!ctx.object || deltaTime <= 0.0f) return;
MenuConfig config = loadConfig(ctx);
normalizeActionCount(config);
MenuRuntimeState& state = g_runtimeStates[ctx.object->id];
if (!state.initialized) {
state.currentIndex = 0;
state.nextInputTime = 0.0f;
state.startupDelayRemaining = config.initialDelay;
state.elapsed = 0.0f;
state.initialized = true;
}
const int itemCount = static_cast<int>(config.menuItemRefs.size());
if (itemCount <= 0) {
updateHeartPosition(ctx, config, state, deltaTime);
return;
}
state.currentIndex = std::clamp(state.currentIndex, 0, itemCount - 1);
state.elapsed += deltaTime;
state.startupDelayRemaining = std::max(0.0f, state.startupDelayRemaining - deltaTime);
const bool canMove = state.startupDelayRemaining <= 0.0f;
if (canMove && state.elapsed >= state.nextInputTime) {
const int direction = getDirectionInput(config.orientation);
if (direction != 0) {
state.currentIndex += direction;
if (state.currentIndex < 0) state.currentIndex = itemCount - 1;
if (state.currentIndex >= itemCount) state.currentIndex = 0;
state.nextInputTime = state.elapsed + std::max(0.0f, config.inputDelay);
playSoundFromRef(ctx, config.moveSoundRef);
}
if (isSubmitPressed()) {
executeAction(ctx, config, state.currentIndex);
playSoundFromRef(ctx, config.selectSoundRef);
state.nextInputTime = state.elapsed + std::max(0.0f, config.inputDelay);
}
}
updateHeartPosition(ctx, config, state, deltaTime);
}

View File

@@ -76,6 +76,18 @@ namespace ModuCPP {
public IntPtr ImGuiBeginCombo;
public IntPtr ImGuiEndCombo;
public IntPtr ImGuiSelectable;
// Version 5+ additions.
public IntPtr HasAnimation;
public IntPtr PlayAnimation;
public IntPtr StopAnimation;
public IntPtr PauseAnimation;
public IntPtr ReverseAnimation;
public IntPtr SetAnimationTime;
public IntPtr GetAnimationTime;
public IntPtr IsAnimationPlaying;
public IntPtr SetAnimationLoop;
public IntPtr SetAnimationPlaySpeed;
public IntPtr SetAnimationPlayOnAwake;
}
internal unsafe static class Native {
@@ -116,6 +128,17 @@ namespace ModuCPP {
public static ImGuiEndComboFn ImGuiEndCombo;
public static ImGuiSelectableFn ImGuiSelectable;
public static ImGuiAcceptSceneObjectDropFn ImGuiAcceptSceneObjectDrop;
public static HasAnimationFn HasAnimation;
public static PlayAnimationFn PlayAnimation;
public static StopAnimationFn StopAnimation;
public static PauseAnimationFn PauseAnimation;
public static ReverseAnimationFn ReverseAnimation;
public static SetAnimationTimeFn SetAnimationTime;
public static GetAnimationTimeFn GetAnimationTime;
public static IsAnimationPlayingFn IsAnimationPlaying;
public static SetAnimationLoopFn SetAnimationLoop;
public static SetAnimationPlaySpeedFn SetAnimationPlaySpeed;
public static SetAnimationPlayOnAwakeFn SetAnimationPlayOnAwake;
public static void BindDelegates() {
GetObjectId = Marshal.GetDelegateForFunctionPointer<GetObjectIdFn>(Api.GetObjectId);
@@ -176,6 +199,61 @@ namespace ModuCPP {
} else {
ImGuiSelectable = (_, _) => 0;
}
if (Api.Version >= 5 && Api.HasAnimation != IntPtr.Zero) {
HasAnimation = Marshal.GetDelegateForFunctionPointer<HasAnimationFn>(Api.HasAnimation);
} else {
HasAnimation = _ => 0;
}
if (Api.Version >= 5 && Api.PlayAnimation != IntPtr.Zero) {
PlayAnimation = Marshal.GetDelegateForFunctionPointer<PlayAnimationFn>(Api.PlayAnimation);
} else {
PlayAnimation = (_, _) => 0;
}
if (Api.Version >= 5 && Api.StopAnimation != IntPtr.Zero) {
StopAnimation = Marshal.GetDelegateForFunctionPointer<StopAnimationFn>(Api.StopAnimation);
} else {
StopAnimation = (_, _) => 0;
}
if (Api.Version >= 5 && Api.PauseAnimation != IntPtr.Zero) {
PauseAnimation = Marshal.GetDelegateForFunctionPointer<PauseAnimationFn>(Api.PauseAnimation);
} else {
PauseAnimation = (_, _) => 0;
}
if (Api.Version >= 5 && Api.ReverseAnimation != IntPtr.Zero) {
ReverseAnimation = Marshal.GetDelegateForFunctionPointer<ReverseAnimationFn>(Api.ReverseAnimation);
} else {
ReverseAnimation = (_, _) => 0;
}
if (Api.Version >= 5 && Api.SetAnimationTime != IntPtr.Zero) {
SetAnimationTime = Marshal.GetDelegateForFunctionPointer<SetAnimationTimeFn>(Api.SetAnimationTime);
} else {
SetAnimationTime = (_, _) => 0;
}
if (Api.Version >= 5 && Api.GetAnimationTime != IntPtr.Zero) {
GetAnimationTime = Marshal.GetDelegateForFunctionPointer<GetAnimationTimeFn>(Api.GetAnimationTime);
} else {
GetAnimationTime = _ => 0f;
}
if (Api.Version >= 5 && Api.IsAnimationPlaying != IntPtr.Zero) {
IsAnimationPlaying = Marshal.GetDelegateForFunctionPointer<IsAnimationPlayingFn>(Api.IsAnimationPlaying);
} else {
IsAnimationPlaying = _ => 0;
}
if (Api.Version >= 5 && Api.SetAnimationLoop != IntPtr.Zero) {
SetAnimationLoop = Marshal.GetDelegateForFunctionPointer<SetAnimationLoopFn>(Api.SetAnimationLoop);
} else {
SetAnimationLoop = (_, _) => 0;
}
if (Api.Version >= 5 && Api.SetAnimationPlaySpeed != IntPtr.Zero) {
SetAnimationPlaySpeed = Marshal.GetDelegateForFunctionPointer<SetAnimationPlaySpeedFn>(Api.SetAnimationPlaySpeed);
} else {
SetAnimationPlaySpeed = (_, _) => 0;
}
if (Api.Version >= 5 && Api.SetAnimationPlayOnAwake != IntPtr.Zero) {
SetAnimationPlayOnAwake = Marshal.GetDelegateForFunctionPointer<SetAnimationPlayOnAwakeFn>(Api.SetAnimationPlayOnAwake);
} else {
SetAnimationPlayOnAwake = (_, _) => 0;
}
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
@@ -250,6 +328,28 @@ namespace ModuCPP {
public unsafe delegate int ImGuiSelectableFn(byte* label, int selected);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public unsafe delegate int ImGuiAcceptSceneObjectDropFn(int* outId);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public unsafe delegate int HasAnimationFn(IntPtr ctx);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public unsafe delegate int PlayAnimationFn(IntPtr ctx, int restart);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public unsafe delegate int StopAnimationFn(IntPtr ctx, int resetTime);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public unsafe delegate int PauseAnimationFn(IntPtr ctx, int pause);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public unsafe delegate int ReverseAnimationFn(IntPtr ctx, int restartIfStopped);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public unsafe delegate int SetAnimationTimeFn(IntPtr ctx, float timeSeconds);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public unsafe delegate float GetAnimationTimeFn(IntPtr ctx);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public unsafe delegate int IsAnimationPlayingFn(IntPtr ctx);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public unsafe delegate int SetAnimationLoopFn(IntPtr ctx, int loop);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public unsafe delegate int SetAnimationPlaySpeedFn(IntPtr ctx, float speed);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public unsafe delegate int SetAnimationPlayOnAwakeFn(IntPtr ctx, int playOnAwake);
}
public struct ModuObject {
@@ -375,6 +475,48 @@ namespace ModuCPP {
Native.AddRigidbodyImpulse(handle, impulse.X, impulse.Y, impulse.Z);
}
public bool HasAnimation => Native.HasAnimation(handle) != 0;
public bool PlayAnimation(bool restart = true) {
return Native.PlayAnimation(handle, restart ? 1 : 0) != 0;
}
public bool StopAnimation(bool resetTime = true) {
return Native.StopAnimation(handle, resetTime ? 1 : 0) != 0;
}
public bool PauseAnimation(bool pause = true) {
return Native.PauseAnimation(handle, pause ? 1 : 0) != 0;
}
public bool ReverseAnimation(bool restartIfStopped = true) {
return Native.ReverseAnimation(handle, restartIfStopped ? 1 : 0) != 0;
}
public bool SetAnimationTime(float timeSeconds) {
return Native.SetAnimationTime(handle, timeSeconds) != 0;
}
public float GetAnimationTime() {
return Native.GetAnimationTime(handle);
}
public bool IsAnimationPlaying() {
return Native.IsAnimationPlaying(handle) != 0;
}
public bool SetAnimationLoop(bool loop) {
return Native.SetAnimationLoop(handle, loop ? 1 : 0) != 0;
}
public bool SetAnimationPlaySpeed(float speed) {
return Native.SetAnimationPlaySpeed(handle, speed) != 0;
}
public bool SetAnimationPlayOnAwake(bool playOnAwake) {
return Native.SetAnimationPlayOnAwake(handle, playOnAwake ? 1 : 0) != 0;
}
public float GetSettingFloat(string key, float fallback = 0f) {
byte[] keyBytes = Encoding.UTF8.GetBytes((key ?? string.Empty) + "\0");
fixed (byte* keyPtr = keyBytes) {

View File

@@ -0,0 +1,160 @@
using UnityEngine;
using System.Collections;
using TMPro;
using UnityEngine.UI;
public class MainMenuController : MonoBehaviour
{
public enum MenuOrientation { Vertical, Horizontal }
public MenuOrientation menuOrientation = MenuOrientation.Vertical;
public RectTransform heartImage;
public TextMeshProUGUI[] menuItems;
public AudioSource MenuMove;
public AudioSource MenuSelect;
public float moveSpeed = 0.3f;
public float offset = 20f;
public MenuAction[] menuActions;
private int currentIndex = 0;
private float inputDelay = 0.2f;
private float nextInputTime = 0f;
private float initialDelay = 0.2f;
private bool canMove = false;
[System.Serializable]
public class MenuAction
{
public GameObject[] enableObjects;
public GameObject[] disableObjects;
}
void Start()
{
StartCoroutine(InitialDelayCoroutine());
}
IEnumerator InitialDelayCoroutine()
{
yield return new WaitForSeconds(initialDelay);
canMove = true;
}
void Update()
{
if (canMove && Time.time >= nextInputTime)
{
if (menuOrientation == MenuOrientation.Vertical)
{
if (Input.GetKeyDown(KeyCode.DownArrow) || Input.GetKeyDown(KeyCode.S) || (Input.GetAxisRaw("Vertical") < -0.5f && Mathf.Approximately(Input.GetAxisRaw("Vertical"), -1)))
{
Navigate(1);
MenuMove.Play();
nextInputTime = Time.time + inputDelay;
}
else if (Input.GetKeyDown(KeyCode.UpArrow) || Input.GetKeyDown(KeyCode.W) || (Input.GetAxisRaw("Vertical") > 0.5f && Mathf.Approximately(Input.GetAxisRaw("Vertical"), 1)))
{
Navigate(-1);
MenuMove.Play();
nextInputTime = Time.time + inputDelay;
}
}
else if (menuOrientation == MenuOrientation.Horizontal)
{
if (Input.GetKeyDown(KeyCode.RightArrow) || Input.GetKeyDown(KeyCode.D) || (Input.GetAxisRaw("Horizontal") > 0.5f && Mathf.Approximately(Input.GetAxisRaw("Horizontal"), 1)))
{
Navigate(1);
MenuMove.Play();
nextInputTime = Time.time + inputDelay;
}
else if (Input.GetKeyDown(KeyCode.LeftArrow) || Input.GetKeyDown(KeyCode.A) || (Input.GetAxisRaw("Horizontal") < -0.5f && Mathf.Approximately(Input.GetAxisRaw("Horizontal"), -1)))
{
Navigate(-1);
MenuMove.Play();
nextInputTime = Time.time + inputDelay;
}
}
// Check for keyboard and controller input for selection
if (Input.GetKeyDown(KeyCode.Return) || Input.GetKeyDown(KeyCode.KeypadEnter) || Input.GetButtonDown("Submit"))
{
ExecuteAction();
MenuSelect.Play();
}
}
}
void Navigate(int direction)
{
currentIndex += direction;
if (currentIndex < 0)
{
currentIndex = menuItems.Length - 1;
}
else if (currentIndex >= menuItems.Length)
{
currentIndex = 0;
}
MoveHeartToCurrentItem();
}
void MoveHeartToCurrentItem()
{
if (menuItems.Length > 0)
{
RectTransform targetRect = menuItems[currentIndex].GetComponent<RectTransform>();
Vector2 targetPosition = targetRect.anchoredPosition;
if (menuOrientation == MenuOrientation.Vertical)
{
targetPosition.x -= targetRect.rect.width / 2 + heartImage.rect.width / 2 + offset;
}
else
{
targetPosition.y -= targetRect.rect.height / 2 + heartImage.rect.height / 2 + offset;
}
StartCoroutine(MoveHeart(targetPosition));
}
}
System.Collections.IEnumerator MoveHeart(Vector2 targetPosition)
{
Vector2 startPos = heartImage.anchoredPosition;
float elapsedTime = 0f;
while (elapsedTime < 1f)
{
heartImage.anchoredPosition = Vector2.Lerp(startPos, targetPosition, elapsedTime);
elapsedTime += Time.deltaTime / moveSpeed;
yield return null;
}
heartImage.anchoredPosition = targetPosition;
}
void ExecuteAction()
{
if (currentIndex < menuActions.Length)
{
var action = menuActions[currentIndex];
foreach (GameObject obj in action.disableObjects)
{
obj.SetActive(false);
}
foreach (GameObject obj in action.enableObjects)
{
obj.SetActive(true);
}
}
else
{
Debug.LogWarning("No action assigned for this menu item.");
}
}
}

View File

@@ -0,0 +1,63 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class autoEnableAndDisableListsOfObjectsAfterAmountOfTime : MonoBehaviour
{
public List<GameObject> objectsToEnable;
public List<GameObject> objectsToDisable;
public float timeToEnableObjects;
private float timer;
private void Start()
{
timer = 0;
}
private void Update()
{
timer += Time.deltaTime;
if (timer >= timeToEnableObjects)
{
EnableObjects();
DisableObjects();
timer = 0;
}
}
private void EnableObjects()
{
foreach (var obj in objectsToEnable)
{
if (obj != null)
{
obj.SetActive(true);
// Check for Collider (3D)
Collider collider = obj.GetComponent<Collider>();
if (collider != null && !collider.enabled)
{
collider.enabled = true; // Enable the Collider if it's disabled
}
// Check for Collider2D (2D)
Collider2D collider2D = obj.GetComponent<Collider2D>();
if (collider2D != null && !collider2D.enabled)
{
collider2D.enabled = true; // Enable the Collider2D if it's disabled
}
}
}
}
private void DisableObjects()
{
foreach (var obj in objectsToDisable)
{
if (obj != null)
{
obj.SetActive(false);
}
}
}
}

View File

@@ -2,6 +2,9 @@
#include "SceneObject.h"
#include "ThirdParty/imgui/imgui.h"
#include <array>
#include <cctype>
#include <cstdio>
#include <random>
#include <string>
#include <unordered_map>
@@ -26,6 +29,7 @@ enum class FacingDirection : int {
struct ControllerState {
FacingDirection facing = FacingDirection::Down;
float animationTime = 0.0f;
int lastWalkFrame = -1;
bool warnedMissingRb = false;
bool warnedMissingSprite = false;
};
@@ -39,6 +43,28 @@ std::array<std::array<int, 4>, 4> walkClips = {{
{{ 0, 0, 0, 0 }}
}};
constexpr const char* kDirectionLabels[4] = { "Down", "Up", "Right", "Left" };
constexpr std::array<const char*, 6> kDefaultStepSounds = {
"Resources/Sounds/Footstep_01.wav",
"Resources/Sounds/Footstep_02.wav",
"Resources/Sounds/Footstep_03.wav",
"Resources/Sounds/Footstep_04.wav",
"Resources/Sounds/Footstep_05.wav",
"Resources/Sounds/Footstep_06.wav"
};
std::array<std::string, 6> stepSounds = {
kDefaultStepSounds[0],
kDefaultStepSounds[1],
kDefaultStepSounds[2],
kDefaultStepSounds[3],
kDefaultStepSounds[4],
kDefaultStepSounds[5]
};
std::mt19937 rng(std::random_device{}());
std::string loadStringSetting(ScriptContext& ctx, const std::string& key, const std::string& fallback) {
const std::string raw = ctx.GetSetting(key, "");
return raw.empty() ? fallback : raw;
}
int loadIntSetting(ScriptContext& ctx, const std::string& key, int fallback) {
const std::string raw = ctx.GetSetting(key, "");
@@ -57,6 +83,61 @@ void saveIntSettingIfChanged(ScriptContext& ctx, const std::string& key, int val
}
}
void saveStringSettingIfChanged(ScriptContext& ctx, const std::string& key, const std::string& value) {
if (ctx.GetSetting(key, "") != value) {
ctx.SetSetting(key, value);
}
}
bool isAudioPath(const std::string& path) {
const size_t dot = path.find_last_of('.');
if (dot == std::string::npos || dot + 1 >= path.size()) return false;
std::string ext = path.substr(dot + 1);
for (char& c : ext) {
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
return ext == "wav" || ext == "mp3" || ext == "ogg" || ext == "flac" || ext == "aac" || ext == "m4a";
}
bool drawStepSoundSlot(ScriptContext& ctx, int slotIndex, std::string& path) {
bool changed = false;
ImGui::PushID(slotIndex);
char buffer[512] = {};
std::snprintf(buffer, sizeof(buffer), "%s", path.c_str());
ImGui::SetNextItemWidth(-84.0f);
if (ImGui::InputText("##StepSoundPath", buffer, sizeof(buffer))) {
path = buffer;
changed = true;
}
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_PATH")) {
if (payload->Data && payload->DataSize > 0) {
const char* droppedPath = static_cast<const char*>(payload->Data);
if (droppedPath && isAudioPath(droppedPath)) {
path = droppedPath;
changed = true;
} else {
ctx.AddConsoleMessage("TopDownMovement2D: dropped file is not a supported audio format.",
ConsoleMessageType::Warning);
}
}
}
ImGui::EndDragDropTarget();
}
ImGui::SameLine();
if (ImGui::SmallButton("Clear")) {
path.clear();
changed = true;
}
ImGui::PopID();
return changed;
}
void bindSettings(ScriptContext& ctx) {
ctx.AutoSetting("walkSpeed", walkSpeed);
ctx.AutoSetting("runSpeed", runSpeed);
@@ -76,6 +157,10 @@ void bindSettings(ScriptContext& ctx) {
walkClips[dir][frame]);
}
}
for (int i = 0; i < 6; ++i) {
stepSounds[i] = loadStringSetting(ctx, "stepSound" + std::to_string(i), kDefaultStepSounds[i]);
}
}
bool isValidClip(const ScriptContext& ctx, int clip) {
@@ -107,6 +192,23 @@ int selectWalkClip(const ScriptContext& ctx, FacingDirection dir, int frameIndex
return isValidClip(ctx, candidate) ? candidate : -1;
}
void playRandomFootstep(ScriptContext& ctx) {
if (!ctx.HasAudioSource()) return;
std::array<int, 6> validIndices{};
int count = 0;
for (int i = 0; i < 6; ++i) {
if (!stepSounds[i].empty()) {
validIndices[count++] = i;
}
}
if (count <= 0) return;
std::uniform_int_distribution<int> dist(0, count - 1);
const std::string& clipPath = stepSounds[validIndices[dist(rng)]];
ctx.PlayAudioOneShot(clipPath);
}
void applySpriteAnimation(ScriptContext& ctx, ControllerState& state, const glm::vec2& motion, float dt, bool isRunning) {
if (!useSpriteAnimation || !ctx.object) return;
@@ -131,10 +233,28 @@ void applySpriteAnimation(ScriptContext& ctx, ControllerState& state, const glm:
if (clip >= 0) {
ctx.SetSpriteClipIndex(clip);
}
// Human-readable walk frames 1 and 3 map to zero-based indices 0 and 2.
// Treat idle->move as coming from frame 4 (index 3) so the first step (frame 1) is not skipped.
int probe = (state.lastWalkFrame >= 0) ? state.lastWalkFrame : 3;
int stepHits = 0;
while (probe != walkFrame) {
probe = (probe + 1) % 4;
if (probe == 0 || probe == 2) {
++stepHits;
}
}
// At low FPS, we may cross multiple step frames in a single tick.
for (int i = 0; i < stepHits; ++i) {
playRandomFootstep(ctx);
}
state.lastWalkFrame = walkFrame;
return;
}
state.animationTime = 0.0f;
state.lastWalkFrame = -1;
const int idleClip = idleClips[facingIndex(state.facing)];
if (isValidClip(ctx, idleClip)) {
ctx.SetSpriteClipIndex(idleClip);
@@ -188,6 +308,7 @@ extern "C" void Script_OnInspector(ScriptContext& ctx) {
ImGui::Checkbox("Use Sprite Animation", &useSpriteAnimation);
bool clipSettingsChanged = false;
bool stepSoundsChanged = false;
if (useSpriteAnimation) {
const int clipCount = ctx.GetSpriteClipCount();
ImGui::Separator();
@@ -212,6 +333,17 @@ extern "C" void Script_OnInspector(ScriptContext& ctx) {
}
}
ImGui::Separator();
ImGui::TextUnformatted("Footstep Sounds");
ImGui::TextDisabled("Drop audio files from File Browser or type paths. Randomized on step frames 1 and 3.");
if (!ctx.object || !ctx.object->hasAudioSource) {
ImGui::TextDisabled("Add an Audio Source component to this object for footsteps.");
}
for (int i = 0; i < 6; ++i) {
ImGui::Text("Sound %d", i + 1);
stepSoundsChanged |= drawStepSoundSlot(ctx, i, stepSounds[i]);
}
ctx.SaveAutoSettings();
if (clipSettingsChanged) {
for (int dir = 0; dir < 4; ++dir) {
@@ -223,6 +355,11 @@ extern "C" void Script_OnInspector(ScriptContext& ctx) {
}
}
}
if (stepSoundsChanged) {
for (int i = 0; i < 6; ++i) {
saveStringSettingIfChanged(ctx, "stepSound" + std::to_string(i), stepSounds[i]);
}
}
}
void TickUpdate(ScriptContext& ctx, float dt) {
@@ -278,5 +415,6 @@ void TickUpdate(ScriptContext& ctx, float dt) {
ctx.SetPosition2D(pos);
}
applySpriteAnimation(ctx, state, actualVelocity, dt, isRunning);
const glm::vec2 animationMotion = (glm::length(input) > 1e-3f) ? targetVel : actualVelocity;
applySpriteAnimation(ctx, state, animationMotion, dt, isRunning);
}

View File

@@ -36,6 +36,17 @@ int Modu_GetRigidbodyVelocity(ModuScriptContext* ctx, ModuVec3* outVelocity);
int Modu_SetRigidbodyRotation(ModuScriptContext* ctx, ModuVec3 rotation);
int Modu_EnsureCapsuleCollider(ModuScriptContext* ctx, float height, float radius);
int Modu_EnsureRigidbody(ModuScriptContext* ctx, int useGravity, int kinematic);
int Modu_HasAnimation(ModuScriptContext* ctx);
int Modu_PlayAnimation(ModuScriptContext* ctx, int restart);
int Modu_StopAnimation(ModuScriptContext* ctx, int resetTime);
int Modu_PauseAnimation(ModuScriptContext* ctx, int pause);
int Modu_ReverseAnimation(ModuScriptContext* ctx, int restartIfStopped);
int Modu_SetAnimationTime(ModuScriptContext* ctx, float timeSeconds);
float Modu_GetAnimationTime(ModuScriptContext* ctx);
int Modu_IsAnimationPlaying(ModuScriptContext* ctx);
int Modu_SetAnimationLoop(ModuScriptContext* ctx, int loop);
int Modu_SetAnimationPlaySpeed(ModuScriptContext* ctx, float speed);
int Modu_SetAnimationPlayOnAwake(ModuScriptContext* ctx, int playOnAwake);
int Modu_IsSprintDown(ModuScriptContext* ctx);
int Modu_IsJumpDown(ModuScriptContext* ctx);

View File

@@ -280,6 +280,7 @@ bool AudioSystem::init() {
void AudioSystem::shutdown() {
stopPreview();
destroyActiveSounds();
destroyOneShotSounds();
shutdownReverbGraph();
if (initialized) {
ma_engine_uninit(&engine);
@@ -297,11 +298,37 @@ void AudioSystem::destroyActiveSounds() {
activeSounds.clear();
}
void AudioSystem::destroyOneShotSounds() {
for (auto& snd : oneShotSounds) {
if (snd) {
ma_sound_uninit(&snd->sound);
releaseDecodedAudio(snd->decodedData);
}
}
oneShotSounds.clear();
}
void AudioSystem::cleanupFinishedOneShots() {
for (auto it = oneShotSounds.begin(); it != oneShotSounds.end(); ) {
bool erase = !(*it) || !ma_sound_is_playing(&(*it)->sound);
if (erase) {
if (*it) {
ma_sound_uninit(&(*it)->sound);
releaseDecodedAudio((*it)->decodedData);
}
it = oneShotSounds.erase(it);
} else {
++it;
}
}
}
void AudioSystem::onPlayStart(const std::vector<SceneObject>& objects) {
if (!initialized && !init()) return;
destroyActiveSounds();
destroyOneShotSounds();
for (const auto& obj : objects) {
if (!obj.enabled || !obj.hasAudioSource || obj.audioSource.clipPath.empty()) continue;
if (!IsObjectEnabledInHierarchy(obj) || !obj.hasAudioSource || obj.audioSource.clipPath.empty()) continue;
if (!obj.audioSource.enabled) continue;
if (ensureSoundFor(obj) && obj.audioSource.playOnStart) {
ma_sound_start(&activeSounds[obj.id]->sound);
@@ -311,6 +338,7 @@ void AudioSystem::onPlayStart(const std::vector<SceneObject>& objects) {
void AudioSystem::onPlayStop() {
destroyActiveSounds();
destroyOneShotSounds();
}
bool AudioSystem::ensureSoundFor(const SceneObject& obj) {
@@ -396,6 +424,7 @@ void AudioSystem::update(const std::vector<SceneObject>& objects, const Camera&
if (!playing) {
destroyActiveSounds();
destroyOneShotSounds();
return;
}
@@ -405,7 +434,7 @@ void AudioSystem::update(const std::vector<SceneObject>& objects, const Camera&
stillPresent.insert(obj.id);
auto eraseIt = activeSounds.find(obj.id);
if (!obj.enabled || !obj.audioSource.enabled || obj.audioSource.clipPath.empty()) {
if (!IsObjectEnabledInHierarchy(obj) || !obj.audioSource.enabled || obj.audioSource.clipPath.empty()) {
if (eraseIt != activeSounds.end()) {
if (eraseIt->second) {
ma_sound_uninit(&eraseIt->second->sound);
@@ -436,6 +465,8 @@ void AudioSystem::update(const std::vector<SceneObject>& objects, const Camera&
++it;
}
}
cleanupFinishedOneShots();
}
bool AudioSystem::playPreview(const std::string& path, float volume, bool loop) {
@@ -554,13 +585,75 @@ bool AudioSystem::setPreviewLoop(bool loop) {
}
bool AudioSystem::playObjectSound(const SceneObject& obj) {
if (!obj.hasAudioSource || obj.audioSource.clipPath.empty() || !obj.audioSource.enabled) return false;
if (!IsObjectEnabledInHierarchy(obj) || !obj.hasAudioSource || obj.audioSource.clipPath.empty() || !obj.audioSource.enabled) return false;
if (!ensureSoundFor(obj)) return false;
ActiveSound& snd = *activeSounds[obj.id];
snd.started = true;
return ma_sound_start(&snd.sound) == MA_SUCCESS;
}
bool AudioSystem::playObjectOneShot(const SceneObject& obj, const std::string& clipPathOverride, float volumeScale) {
if (!IsObjectEnabledInHierarchy(obj) || !obj.hasAudioSource || !obj.audioSource.enabled) return false;
const std::string& clipPath = clipPathOverride.empty() ? obj.audioSource.clipPath : clipPathOverride;
if (clipPath.empty()) return false;
if (!initialized && !init()) return false;
if (!fs::exists(clipPath)) {
if (missingClips.insert(clipPath).second) {
std::cerr << "AudioSystem: clip not found " << clipPath << "\n";
}
return false;
}
missingClips.erase(clipPath);
auto oneShot = std::make_unique<OneShotSound>();
if (!initSoundFromPath(clipPath, 0, reverbReady ? &reverbGroup : nullptr,
oneShot->sound, oneShot->decodedData))
{
return false;
}
const float minDist = std::max(0.1f, obj.audioSource.minDistance);
const float maxDist = std::max(obj.audioSource.maxDistance, minDist + 0.5f);
ma_sound_set_looping(&oneShot->sound, MA_FALSE);
ma_sound_set_volume(&oneShot->sound, std::max(0.0f, obj.audioSource.volume * volumeScale));
ma_sound_set_spatialization_enabled(&oneShot->sound, obj.audioSource.spatial ? MA_TRUE : MA_FALSE);
ma_sound_set_min_distance(&oneShot->sound, minDist);
ma_sound_set_max_distance(&oneShot->sound, maxDist);
ma_sound_set_position(&oneShot->sound, obj.position.x, obj.position.y, obj.position.z);
if (obj.audioSource.spatial) {
switch (obj.audioSource.rolloffMode) {
case AudioRolloffMode::Linear:
ma_sound_set_attenuation_model(&oneShot->sound, ma_attenuation_model_linear);
break;
case AudioRolloffMode::Exponential:
ma_sound_set_attenuation_model(&oneShot->sound, ma_attenuation_model_exponential);
break;
case AudioRolloffMode::Custom:
ma_sound_set_attenuation_model(&oneShot->sound, ma_attenuation_model_none);
break;
case AudioRolloffMode::Logarithmic:
default:
ma_sound_set_attenuation_model(&oneShot->sound, ma_attenuation_model_inverse);
break;
}
ma_sound_set_rolloff(&oneShot->sound, std::max(0.01f, obj.audioSource.rolloff));
} else {
ma_sound_set_attenuation_model(&oneShot->sound, ma_attenuation_model_none);
}
if (ma_sound_start(&oneShot->sound) != MA_SUCCESS) {
ma_sound_uninit(&oneShot->sound);
releaseDecodedAudio(oneShot->decodedData);
return false;
}
oneShotSounds.emplace_back(std::move(oneShot));
cleanupFinishedOneShots();
return true;
}
bool AudioSystem::stopObjectSound(int objectId) {
auto it = activeSounds.find(objectId);
if (it == activeSounds.end()) return false;
@@ -616,7 +709,7 @@ AudioSystem::ReverbSettings AudioSystem::getReverbTarget(const std::vector<Scene
float bestBlend = 0.0f;
for (const auto& obj : objects) {
if (!obj.enabled || !obj.hasReverbZone || !obj.reverbZone.enabled) continue;
if (!IsObjectEnabledInHierarchy(obj) || !obj.hasReverbZone || !obj.reverbZone.enabled) continue;
const auto& zone = obj.reverbZone;
float blend = 0.0f;

View File

@@ -48,6 +48,7 @@ public:
// Scene audio control (runtime)
bool playObjectSound(const SceneObject& obj);
bool playObjectOneShot(const SceneObject& obj, const std::string& clipPathOverride = "", float volumeScale = 1.0f);
bool stopObjectSound(int objectId);
bool setObjectLoop(const SceneObject& obj, bool loop);
bool setObjectVolume(const SceneObject& obj, float volume);
@@ -105,9 +106,15 @@ private:
std::shared_ptr<DecodedAudioData> decodedData;
};
struct OneShotSound {
ma_sound sound{};
std::shared_ptr<DecodedAudioData> decodedData;
};
ma_engine engine{};
bool initialized = false;
std::unordered_map<int, std::unique_ptr<ActiveSound>> activeSounds;
std::vector<std::unique_ptr<OneShotSound>> oneShotSounds;
std::unordered_map<std::string, AudioClipPreview> previewCache;
std::unordered_set<std::string> missingClips;
@@ -123,6 +130,8 @@ private:
std::shared_ptr<DecodedAudioData> previewDecodedData;
void destroyActiveSounds();
void destroyOneShotSounds();
void cleanupFinishedOneShots();
bool ensureSoundFor(const SceneObject& obj);
void refreshSoundParams(const SceneObject& obj, ActiveSound& snd);
float computeCustomAttenuation(const SceneObject& obj, const glm::vec3& listenerPos) const;

View File

@@ -13,6 +13,7 @@
#include <csignal>
#include <cstdlib>
#include <cstring>
#include <cstdint>
#include <deque>
#include <exception>
#include <filesystem>
@@ -26,6 +27,7 @@
#if defined(__linux__) || defined(__APPLE__)
#include <execinfo.h>
#include <fcntl.h>
#include <unistd.h>
#endif
@@ -55,6 +57,14 @@ CrashContext& context() {
return ctx;
}
#if defined(__linux__) || defined(__APPLE__)
constexpr size_t kSignalPathBufferSize = 1024;
char gSessionLogPathForSignal[kSignalPathBufferSize] = {};
char gSignalCrashLogPath[kSignalPathBufferSize] = {};
char gCrashSummaryPathForSignal[kSignalPathBufferSize] = {};
volatile sig_atomic_t gSignalLoggingReady = 0;
#endif
fs::path executableDirectory() {
auto& ctx = context();
if (!ctx.executablePath.empty()) {
@@ -100,6 +110,137 @@ std::string nowForDisplay() {
return buffer;
}
#if defined(__linux__) || defined(__APPLE__)
void storePathForSignal(const fs::path& path, char* outBuffer, size_t outBufferSize) {
if (!outBuffer || outBufferSize == 0) return;
std::memset(outBuffer, 0, outBufferSize);
const std::string value = path.string();
const size_t copyLen = std::min(value.size(), outBufferSize - 1);
std::memcpy(outBuffer, value.data(), copyLen);
outBuffer[copyLen] = '\0';
}
size_t signalSafeStrLen(const char* text) {
if (!text) return 0;
size_t n = 0;
while (text[n] != '\0') ++n;
return n;
}
void signalSafeWrite(int fd, const char* text) {
if (fd < 0 || !text) return;
const size_t len = signalSafeStrLen(text);
if (len == 0) return;
(void)!::write(fd, text, len);
}
char* appendUnsignedDec(char* out, unsigned long long value) {
char tmp[32];
int idx = 0;
do {
tmp[idx++] = static_cast<char>('0' + (value % 10ULL));
value /= 10ULL;
} while (value && idx < static_cast<int>(sizeof(tmp)));
while (idx > 0) {
*out++ = tmp[--idx];
}
return out;
}
char* appendSignedDec(char* out, long long value) {
if (value < 0) {
*out++ = '-';
const unsigned long long magnitude = static_cast<unsigned long long>(-(value + 1)) + 1ULL;
return appendUnsignedDec(out, magnitude);
}
return appendUnsignedDec(out, static_cast<unsigned long long>(value));
}
char* appendHex(char* out, uintptr_t value) {
static constexpr char kHex[] = "0123456789abcdef";
*out++ = '0';
*out++ = 'x';
bool started = false;
for (int shift = static_cast<int>(sizeof(uintptr_t) * 8) - 4; shift >= 0; shift -= 4) {
const unsigned nibble = static_cast<unsigned>((value >> shift) & 0xF);
if (!started && nibble == 0 && shift > 0) {
continue;
}
started = true;
*out++ = kHex[nibble];
}
if (!started) {
*out++ = '0';
}
return out;
}
void signalWriteKeyInt(int fd, const char* key, long long value) {
char line[128];
char* ptr = line;
for (const char* p = key; *p; ++p) *ptr++ = *p;
*ptr++ = '=';
ptr = appendSignedDec(ptr, value);
*ptr++ = '\n';
(void)!::write(fd, line, static_cast<size_t>(ptr - line));
}
void signalWriteKeyHex(int fd, const char* key, uintptr_t value) {
char line[128];
char* ptr = line;
for (const char* p = key; *p; ++p) *ptr++ = *p;
*ptr++ = '=';
ptr = appendHex(ptr, value);
*ptr++ = '\n';
(void)!::write(fd, line, static_cast<size_t>(ptr - line));
}
void writeSignalSummaryFile(int signalValue) {
if (!gCrashSummaryPathForSignal[0]) return;
const int fd = ::open(gCrashSummaryPathForSignal, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) return;
signalSafeWrite(fd, "reason=Fatal signal\n");
signalWriteKeyInt(fd, "signal", signalValue);
if (gSignalCrashLogPath[0]) {
signalSafeWrite(fd, "log=");
signalSafeWrite(fd, gSignalCrashLogPath);
signalSafeWrite(fd, "\n");
} else if (gSessionLogPathForSignal[0]) {
signalSafeWrite(fd, "log=");
signalSafeWrite(fd, gSessionLogPathForSignal);
signalSafeWrite(fd, "\n");
}
(void)!::close(fd);
}
void writeSignalCrashLog(int signalValue, siginfo_t* info) {
int fd = -1;
if (gSignalCrashLogPath[0]) {
fd = ::open(gSignalCrashLogPath, O_WRONLY | O_CREAT | O_APPEND, 0644);
}
if (fd < 0 && gSessionLogPathForSignal[0]) {
fd = ::open(gSessionLogPathForSignal, O_WRONLY | O_CREAT | O_APPEND, 0644);
}
if (fd < 0) return;
signalSafeWrite(fd, "\n[CrashReporter] Fatal signal captured on POSIX.\n");
signalWriteKeyInt(fd, "signal", signalValue);
signalWriteKeyInt(fd, "pid", static_cast<long long>(::getpid()));
if (info) {
signalWriteKeyInt(fd, "si_code", static_cast<long long>(info->si_code));
signalWriteKeyHex(fd, "si_addr", reinterpret_cast<uintptr_t>(info->si_addr));
}
signalSafeWrite(fd, "stacktrace_begin\n");
void* frames[64];
const int count = ::backtrace(frames, 64);
if (count > 0) {
::backtrace_symbols_fd(frames, count, fd);
}
signalSafeWrite(fd, "stacktrace_end\n");
(void)!::close(fd);
}
#endif
class TeeStreamBuf final : public std::streambuf {
public:
TeeStreamBuf(std::streambuf* primary, std::streambuf* secondary)
@@ -257,20 +398,28 @@ void launchReporterProcess(const std::string& reason, const std::string& details
std::_Exit(exitCode);
}
void signalHandler(int signalValue) {
#if defined(_WIN32)
void signalHandler(int signalValue) {
std::ostringstream details;
details << signalValue;
handleCrash("Fatal signal", details.str(), 128 + signalValue);
#else
}
#endif
#if defined(__linux__) || defined(__APPLE__)
void signalHandlerWithInfo(int signalValue, siginfo_t* info, void* /*ucontext*/) {
static constexpr char kMessage[] =
"[CrashReporter] Fatal signal received. Reporter fallback is disabled for POSIX signal crashes.\n";
"[CrashReporter] Fatal signal captured. Wrote POSIX crash log.\n";
if (gSignalLoggingReady) {
writeSignalCrashLog(signalValue, info);
writeSignalSummaryFile(signalValue);
}
(void)!::write(STDERR_FILENO, kMessage, sizeof(kMessage) - 1);
std::signal(signalValue, SIG_DFL);
std::raise(signalValue);
std::_Exit(128 + signalValue);
#endif
}
#endif
void terminateHandler() {
std::string details = "No active exception.";
@@ -666,6 +815,16 @@ void Initialize(const std::string& productName, const std::string& executablePat
ctx.productName = productName;
ctx.executablePath = executablePath;
ctx.sessionLogPath = crashDirectory() / (productName + "-session-" + nowForFileName() + ".log");
#if defined(__linux__) || defined(__APPLE__)
storePathForSignal(ctx.sessionLogPath, gSessionLogPathForSignal, sizeof(gSessionLogPathForSignal));
storePathForSignal(crashDirectory() / (productName + "-signal-last.log"),
gSignalCrashLogPath,
sizeof(gSignalCrashLogPath));
storePathForSignal(crashDirectory() / "last_crash.txt",
gCrashSummaryPathForSignal,
sizeof(gCrashSummaryPathForSignal));
gSignalLoggingReady = 1;
#endif
ctx.logFile.open(ctx.sessionLogPath, std::ios::out | std::ios::trunc);
if (ctx.logFile.is_open()) {
ctx.oldCout = std::cout.rdbuf();
@@ -686,9 +845,9 @@ void Initialize(const std::string& productName, const std::string& executablePat
SetUnhandledExceptionFilter(unhandledExceptionFilter);
#else
struct sigaction action {};
action.sa_handler = signalHandler;
action.sa_sigaction = signalHandlerWithInfo;
sigemptyset(&action.sa_mask);
action.sa_flags = 0;
action.sa_flags = SA_SIGINFO | SA_RESETHAND;
sigaction(SIGABRT, &action, nullptr);
sigaction(SIGILL, &action, nullptr);
sigaction(SIGFPE, &action, nullptr);

View File

@@ -3,6 +3,8 @@
#include <unordered_map>
namespace {
constexpr float kEditorBottomStatusReserveHeight = 24.0f;
struct TouchSwipeWindowState {
ImVec2 virtualScroll = ImVec2(0.0f, 0.0f);
ImVec2 velocity = ImVec2(0.0f, 0.0f);
@@ -212,6 +214,7 @@ FileCategory FileBrowser::getFileCategory(const fs::directory_entry& entry) cons
// Scene files
if (ext == ".modu" || ext == ".scene") return FileCategory::Scene;
if (ext == ".modupak") return FileCategory::Text;
// Model files
if (ext == ".fbx" || ext == ".obj" || ext == ".gltf" || ext == ".glb" ||
@@ -566,11 +569,18 @@ ImGuiID setupDockspace(const std::function<void()>& menuBarContent) {
}
ImGuiID dockspaceId = ImGui::GetID("MainDockspace");
ImGui::DockSpace(dockspaceId, ImVec2(0.0f, 0.0f), dockspaceFlags);
const float reserveHeight = getEditorBottomStatusReserveHeight();
ImVec2 dockspaceSize = ImGui::GetContentRegionAvail();
dockspaceSize.y = ImMax(0.0f, dockspaceSize.y - reserveHeight);
ImGui::DockSpace(dockspaceId, dockspaceSize, dockspaceFlags);
ImGui::End();
return dockspaceId;
}
float getEditorBottomStatusReserveHeight() {
return kEditorBottomStatusReserveHeight;
}
#pragma endregion
#pragma region Touch Swipe Scroll
@@ -585,6 +595,26 @@ void updateTouchSwipeScrolling() {
static TouchSwipeRuntimeState runtime;
const bool touchScreenMode = (io.ConfigFlags & ImGuiConfigFlags_IsTouchScreen) != 0;
const bool hasWheelInput = std::abs(io.MouseWheelH) > 0.0001f || std::abs(io.MouseWheel) > 0.0001f;
// On non-touch platforms, skip the full window traversal when there is no
// scroll input and no active inertial movement to process.
if (!touchScreenMode && !hasWheelInput && runtime.activeWindowId == 0) {
bool hasResidualMotion = false;
for (const auto& [id, state] : runtime.windowStates) {
(void)id;
if (state.isDragging ||
std::abs(state.velocity.x) > 0.35f ||
std::abs(state.velocity.y) > 0.35f) {
hasResidualMotion = true;
break;
}
}
if (!hasResidualMotion) {
runtime.windowStates.clear();
return;
}
}
const float dt = std::max(io.DeltaTime, 1.0f / 240.0f);
const float dragThresholdSqr = 16.0f;

View File

@@ -86,6 +86,7 @@ void applySuperRoundStyle(ImGuiStyle& style);
// Setup ImGui dockspace for the editor and return its stable dockspace ID.
ImGuiID setupDockspace(const std::function<void()>& menuBarContent = nullptr);
float getEditorBottomStatusReserveHeight();
// Apply touch-style swipe scrolling with inertial motion and elastic edge return.
void updateTouchSwipeScrolling();

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,11 @@
#include <array>
#include <cstring>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cfloat>
#include <cmath>
#include <cctype>
#include <iomanip>
#include <functional>
#include <sstream>
@@ -17,6 +19,7 @@
#include <future>
#ifdef _WIN32
#include <commdlg.h>
#include <shlobj.h>
#include <shellapi.h>
#endif
@@ -965,6 +968,154 @@ namespace {
openPathInShell(path);
#endif
}
std::string trimWhitespace(std::string value) {
auto isSpace = [](unsigned char c) { return std::isspace(c) != 0; };
while (!value.empty() && isSpace(static_cast<unsigned char>(value.front()))) {
value.erase(value.begin());
}
while (!value.empty() && isSpace(static_cast<unsigned char>(value.back()))) {
value.pop_back();
}
return value;
}
#if defined(__linux__)
std::string shellQuote(const std::string& value) {
std::string out;
out.reserve(value.size() + 2);
out.push_back('\'');
for (char c : value) {
if (c == '\'') {
out += "'\\''";
} else {
out.push_back(c);
}
}
out.push_back('\'');
return out;
}
bool commandExists(const char* command) {
if (!command || !*command) {
return false;
}
std::string check = "command -v ";
check += command;
check += " >/dev/null 2>&1";
return std::system(check.c_str()) == 0;
}
std::optional<std::string> runSelectionDialogCommand(const std::string& command) {
FILE* pipe = popen(command.c_str(), "r");
if (!pipe) {
return std::nullopt;
}
std::string output;
char buffer[512];
while (std::fgets(buffer, static_cast<int>(sizeof(buffer)), pipe)) {
output += buffer;
}
pclose(pipe);
output = trimWhitespace(output);
if (output.empty()) {
return std::nullopt;
}
return output;
}
#endif
std::optional<fs::path> chooseImportFilePath(const fs::path& initialDir) {
#ifdef _WIN32
std::wstring initialDirWide = initialDir.wstring();
std::array<wchar_t, MAX_PATH> fileBuffer{};
OPENFILENAMEW ofn{};
ofn.lStructSize = sizeof(ofn);
ofn.hwndOwner = nullptr;
ofn.lpstrFilter = L"All Files\0*.*\0";
ofn.lpstrFile = fileBuffer.data();
ofn.nMaxFile = static_cast<DWORD>(fileBuffer.size());
ofn.lpstrInitialDir = initialDirWide.empty() ? nullptr : initialDirWide.c_str();
ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY;
if (GetOpenFileNameW(&ofn) == TRUE) {
return fs::path(fileBuffer.data());
}
return std::nullopt;
#elif __linux__
const fs::path initial = initialDir.empty() ? fs::current_path() : initialDir;
const std::string initialString = initial.string();
const std::string initialForDialog =
(!initialString.empty() && initialString.back() == '/') ? initialString : (initialString + "/");
if (commandExists("zenity")) {
std::string cmd = "zenity --file-selection --filename=" + shellQuote(initialForDialog) + " 2>/dev/null";
if (auto selected = runSelectionDialogCommand(cmd)) {
return fs::path(*selected);
}
}
if (commandExists("kdialog")) {
std::string cmd = "kdialog --getopenfilename " + shellQuote(initialString) + " 2>/dev/null";
if (auto selected = runSelectionDialogCommand(cmd)) {
return fs::path(*selected);
}
}
return std::nullopt;
#else
(void)initialDir;
return std::nullopt;
#endif
}
std::optional<fs::path> chooseImportFolderPath(const fs::path& initialDir) {
#ifdef _WIN32
BROWSEINFOW info{};
info.ulFlags = BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE;
info.lpszTitle = L"Select Folder";
LPITEMIDLIST selected = SHBrowseForFolderW(&info);
if (!selected) {
return std::nullopt;
}
std::array<wchar_t, MAX_PATH> folderPath{};
std::optional<fs::path> result;
if (SHGetPathFromIDListW(selected, folderPath.data())) {
result = fs::path(folderPath.data());
}
CoTaskMemFree(selected);
return result;
#elif __linux__
const fs::path initial = initialDir.empty() ? fs::current_path() : initialDir;
const std::string initialString = initial.string();
const std::string initialForDialog =
(!initialString.empty() && initialString.back() == '/') ? initialString : (initialString + "/");
if (commandExists("zenity")) {
std::string cmd = "zenity --file-selection --directory --filename=" + shellQuote(initialForDialog) + " 2>/dev/null";
if (auto selected = runSelectionDialogCommand(cmd)) {
return fs::path(*selected);
}
}
if (commandExists("kdialog")) {
std::string cmd = "kdialog --getexistingdirectory " + shellQuote(initialString) + " 2>/dev/null";
if (auto selected = runSelectionDialogCommand(cmd)) {
return fs::path(*selected);
}
}
return std::nullopt;
#else
(void)initialDir;
return std::nullopt;
#endif
}
bool supportsNativeImportPathPicker() {
#ifdef _WIN32
return true;
#elif __linux__
return commandExists("zenity") || commandExists("kdialog");
#else
return false;
#endif
}
}
#pragma endregion
@@ -989,6 +1140,10 @@ void Engine::renderFileBrowserPanel() {
static fs::path pendingDeletePath;
static fs::path pendingRenamePath;
static char renameName[256] = "";
static bool showImportAssetsPopup = false;
static bool triggerImportAssetsPopup = false;
static fs::path pendingImportTargetPath;
static char importAssetPaths[4096] = "";
bool settingsDirty = false;
auto openEntry = [&](const fs::directory_entry& entry) {
@@ -1147,6 +1302,234 @@ void Engine::renderFileBrowserPanel() {
}
return path.lexically_normal();
};
auto isPathWithin = [&](const fs::path& root, const fs::path& path) {
if (root.empty()) return true;
const fs::path normalizedRoot = normalizePath(root);
const fs::path normalizedPath = normalizePath(path);
if (normalizedPath == normalizedRoot) return true;
fs::path current = normalizedPath;
while (current.has_parent_path()) {
fs::path parent = current.parent_path();
if (parent.empty() || parent == current) {
break;
}
current = parent;
if (current == normalizedRoot) {
return true;
}
}
return false;
};
auto remapSelectedPathAfterMove = [&](const fs::path& sourcePath, const fs::path& movedPath) {
fs::path selected = normalizePath(fileBrowser.selectedFile);
fs::path source = normalizePath(sourcePath);
fs::path moved = normalizePath(movedPath);
if (selected.empty()) return;
if (selected == source) {
fileBrowser.selectedFile = moved;
return;
}
if (!isPathWithin(source, selected)) {
return;
}
std::error_code relEc;
fs::path rel = fs::relative(selected, source, relEc);
if (relEc || rel.empty()) {
return;
}
fileBrowser.selectedFile = moved / rel;
};
auto importPathIntoDirectory = [&](const fs::path& sourcePath, const fs::path& destinationDir) {
std::error_code ec;
if (sourcePath.empty() || destinationDir.empty()) {
return false;
}
if (!fs::exists(sourcePath, ec) || ec) {
addConsoleMessage("Import failed: source missing " + sourcePath.string(), ConsoleMessageType::Error);
return false;
}
if (!fs::exists(destinationDir, ec) || ec || !fs::is_directory(destinationDir, ec)) {
addConsoleMessage("Import failed: destination folder missing " + destinationDir.string(), ConsoleMessageType::Error);
return false;
}
const fs::path normalizedSource = normalizePath(sourcePath);
const fs::path normalizedDestination = normalizePath(destinationDir);
if (fs::is_directory(normalizedSource, ec) && !ec && isPathWithin(normalizedSource, normalizedDestination)) {
addConsoleMessage("Import failed: cannot import a folder into itself " + normalizedSource.string(),
ConsoleMessageType::Error);
return false;
}
fs::path targetPath = makeUniquePath(normalizedDestination / normalizedSource.filename());
ec.clear();
if (fs::is_directory(normalizedSource, ec) && !ec) {
fs::copy(normalizedSource, targetPath,
fs::copy_options::recursive |
fs::copy_options::copy_symlinks |
fs::copy_options::skip_existing,
ec);
} else {
fs::copy_file(normalizedSource, targetPath, fs::copy_options::overwrite_existing, ec);
}
if (ec) {
addConsoleMessage("Import failed: " + normalizedSource.string() + " (" + ec.message() + ")",
ConsoleMessageType::Error);
return false;
}
addConsoleMessage("Imported: " + targetPath.string(), ConsoleMessageType::Success);
fileBrowser.selectedFile = targetPath;
fileBrowser.needsRefresh = true;
projectManager.currentProject.hasUnsavedChanges = true;
return true;
};
auto importDirectoryContentsIntoDirectory = [&](const fs::path& sourceDir, const fs::path& destinationDir) {
std::error_code ec;
if (sourceDir.empty() || destinationDir.empty()) {
return false;
}
if (!fs::exists(sourceDir, ec) || ec || !fs::is_directory(sourceDir, ec)) {
addConsoleMessage("Import failed: source folder missing " + sourceDir.string(), ConsoleMessageType::Error);
return false;
}
if (!fs::exists(destinationDir, ec) || ec || !fs::is_directory(destinationDir, ec)) {
addConsoleMessage("Import failed: destination folder missing " + destinationDir.string(), ConsoleMessageType::Error);
return false;
}
const fs::path normalizedSource = normalizePath(sourceDir);
const fs::path normalizedDestination = normalizePath(destinationDir);
if (normalizedSource == normalizedDestination) {
addConsoleMessage("Import failed: source and destination folder are the same.", ConsoleMessageType::Error);
return false;
}
int importedCount = 0;
int failedCount = 0;
for (const auto& child : fs::directory_iterator(normalizedSource, ec)) {
if (ec) {
addConsoleMessage("Import failed while reading folder: " + normalizedSource.string() +
" (" + ec.message() + ")", ConsoleMessageType::Error);
return false;
}
if (importPathIntoDirectory(child.path(), normalizedDestination)) {
++importedCount;
} else {
++failedCount;
}
}
if (importedCount == 0 && failedCount == 0) {
addConsoleMessage("Import skipped: folder is empty " + normalizedSource.string(), ConsoleMessageType::Info);
return false;
}
if (importedCount > 0) {
addConsoleMessage("Imported " + std::to_string(importedCount) + " item(s) from folder contents.",
ConsoleMessageType::Success);
}
if (failedCount > 0) {
addConsoleMessage("Failed to import " + std::to_string(failedCount) + " item(s) from folder contents.",
ConsoleMessageType::Warning);
}
return importedCount > 0;
};
auto movePathIntoDirectory = [&](const fs::path& sourcePath, const fs::path& destinationDir) {
std::error_code ec;
if (sourcePath.empty() || destinationDir.empty()) {
return false;
}
if (!fs::exists(sourcePath, ec) || ec) {
return false;
}
if (!fs::exists(destinationDir, ec) || ec || !fs::is_directory(destinationDir, ec)) {
return false;
}
if (!fileBrowser.projectRoot.empty() && !isPathWithin(fileBrowser.projectRoot, sourcePath)) {
addConsoleMessage("Move blocked: source is outside the project root.", ConsoleMessageType::Warning);
return false;
}
if (!fileBrowser.projectRoot.empty() && !isPathWithin(fileBrowser.projectRoot, destinationDir)) {
addConsoleMessage("Move blocked: destination is outside the project root.", ConsoleMessageType::Warning);
return false;
}
fs::path normalizedSource = normalizePath(sourcePath);
fs::path normalizedDestination = normalizePath(destinationDir);
fs::path sourceParent = normalizePath(normalizedSource.parent_path());
if (normalizedDestination == sourceParent) {
return false;
}
if (fs::is_directory(normalizedSource, ec) && !ec && isPathWithin(normalizedSource, normalizedDestination)) {
addConsoleMessage("Move failed: cannot move a folder into itself.", ConsoleMessageType::Error);
return false;
}
fs::path targetPath = normalizedDestination / normalizedSource.filename();
if (targetPath == normalizedSource) {
return false;
}
if (fs::exists(targetPath, ec) && !ec) {
targetPath = makeUniquePath(targetPath);
}
ec.clear();
fs::rename(normalizedSource, targetPath, ec);
if (ec) {
ec.clear();
if (fs::is_directory(normalizedSource, ec) && !ec) {
fs::copy(normalizedSource, targetPath,
fs::copy_options::recursive |
fs::copy_options::copy_symlinks |
fs::copy_options::skip_existing,
ec);
if (!ec) {
fs::remove_all(normalizedSource, ec);
}
} else {
fs::copy_file(normalizedSource, targetPath, fs::copy_options::overwrite_existing, ec);
if (!ec) {
fs::remove(normalizedSource, ec);
}
}
}
if (ec) {
addConsoleMessage("Move failed: " + normalizedSource.string() + " (" + ec.message() + ")",
ConsoleMessageType::Error);
return false;
}
remapSelectedPathAfterMove(normalizedSource, targetPath);
fileBrowser.needsRefresh = true;
projectManager.currentProject.hasUnsavedChanges = true;
addConsoleMessage("Moved: " + normalizedSource.filename().string() + " -> " + targetPath.string(),
ConsoleMessageType::Success);
return true;
};
auto handleMovePayloadToDirectory = [&](const ImGuiPayload* payload, const fs::path& destinationDir) {
if (!payload || payload->DataSize <= 0 || !payload->Data) {
return false;
}
const char* sourcePathChars = static_cast<const char*>(payload->Data);
if (!sourcePathChars || !*sourcePathChars) {
return false;
}
return movePathIntoDirectory(fs::path(sourcePathChars), destinationDir);
};
auto queueImportAssetsDialog = [&](const fs::path& destinationDir) {
pendingImportTargetPath = destinationDir.empty() ? fileBrowser.currentPath : destinationDir;
importAssetPaths[0] = '\0';
showImportAssetsPopup = true;
triggerImportAssetsPopup = true;
};
// Get colors for categories
auto getCategoryColor = [](FileCategory cat) -> ImU32 {
@@ -1194,6 +1577,16 @@ void Engine::renderFileBrowserPanel() {
ImGui::Button("^##Up", ImVec2(24, 0));
if (ImGui::IsItemActivated()) fileBrowser.navigateUp();
ImGui::EndDisabled();
if (canGoUp && ImGui::BeginDragDropTarget()) {
fs::path parentTarget = fileBrowser.currentPath.parent_path();
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_BROWSER_ENTRY")) {
handleMovePayloadToDirectory(payload, parentTarget);
}
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_PATH")) {
handleMovePayloadToDirectory(payload, parentTarget);
}
ImGui::EndDragDropTarget();
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("Up one folder");
ImGui::PopStyleVar();
@@ -1245,6 +1638,15 @@ void Engine::renderFileBrowserPanel() {
if (ImGui::SmallButton(crumbs[i].label.c_str())) {
fileBrowser.navigateTo(crumbs[i].target);
}
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_BROWSER_ENTRY")) {
handleMovePayloadToDirectory(payload, crumbs[i].target);
}
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_PATH")) {
handleMovePayloadToDirectory(payload, crumbs[i].target);
}
ImGui::EndDragDropTarget();
}
ImGui::PopID();
if (i < crumbs.size() - 1) {
ImGui::SameLine(0, 2);
@@ -1343,6 +1745,22 @@ void Engine::renderFileBrowserPanel() {
contentBg.z = std::min(contentBg.z + 0.01f, 1.0f);
ImGui::PushStyleColor(ImGuiCol_ChildBg, contentBg);
ImGui::BeginChild("FileContent", ImVec2(0, 0), true);
if (!pendingExternalFileDrops.empty()) {
const ImVec2 contentMin = ImGui::GetWindowPos();
const ImVec2 contentMax(contentMin.x + ImGui::GetWindowWidth(),
contentMin.y + ImGui::GetWindowHeight());
for (const ExternalFileDropEvent& drop : pendingExternalFileDrops) {
const bool droppedOverPanel =
(drop.mouseX >= contentMin.x && drop.mouseX <= contentMax.x &&
drop.mouseY >= contentMin.y && drop.mouseY <= contentMax.y);
if (droppedOverPanel) {
importPathIntoDirectory(drop.path, fileBrowser.currentPath);
}
}
pendingExternalFileDrops.clear();
}
if (showFileBrowserSidebar) {
float minSidebarWidth = 150.0f;
float maxSidebarWidth = std::max(minSidebarWidth, ImGui::GetContentRegionAvail().x * 0.5f);
@@ -1444,6 +1862,15 @@ void Engine::renderFileBrowserPanel() {
if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) {
fileBrowser.navigateTo(path);
}
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_BROWSER_ENTRY")) {
handleMovePayloadToDirectory(payload, path);
}
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_PATH")) {
handleMovePayloadToDirectory(payload, path);
}
ImGui::EndDragDropTarget();
}
if (open) {
std::vector<fs::path> dirs;
std::error_code ec;
@@ -1656,6 +2083,9 @@ void Engine::renderFileBrowserPanel() {
}
ImGui::EndMenu();
}
if (entry.is_directory() && ImGui::MenuItem("Import Assets Here...")) {
queueImportAssetsDialog(entry.path());
}
if (fileBrowser.isModelFile(entry)) {
bool isObj = fileBrowser.isOBJFile(entry);
if (ImGui::MenuItem("Import to Scene")) {
@@ -1744,6 +2174,14 @@ void Engine::renderFileBrowserPanel() {
if (ImGui::MenuItem("Open in File Explorer")) {
openPathInFileManager(entry.path());
}
if (ImGui::MenuItem("Move to Parent Folder")) {
movePathIntoDirectory(entry.path(), entry.path().parent_path());
}
if (!fileBrowser.projectRoot.empty() &&
normalizePath(entry.path().parent_path()) != normalizePath(fileBrowser.projectRoot) &&
ImGui::MenuItem("Move to Project Root")) {
movePathIntoDirectory(entry.path(), fileBrowser.projectRoot);
}
if (ImGui::MenuItem("Rename")) {
pendingRenamePath = entry.path();
std::string baseName = pendingRenamePath.filename().string();
@@ -1762,9 +2200,20 @@ void Engine::renderFileBrowserPanel() {
ImGui::EndPopup();
}
if (!entry.is_directory() && ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) {
if (entry.is_directory() && ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_BROWSER_ENTRY")) {
handleMovePayloadToDirectory(payload, entry.path());
}
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_PATH")) {
handleMovePayloadToDirectory(payload, entry.path());
}
ImGui::EndDragDropTarget();
}
if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) {
std::string payloadPath = entry.path().string();
ImGui::SetDragDropPayload("FILE_PATH", payloadPath.c_str(), payloadPath.size() + 1);
const char* payloadType = entry.is_directory() ? "FILE_BROWSER_ENTRY" : "FILE_PATH";
ImGui::SetDragDropPayload(payloadType, payloadPath.c_str(), payloadPath.size() + 1);
ImGui::Text("%s", filename.c_str());
ImGui::EndDragDropSource();
}
@@ -1940,6 +2389,9 @@ void Engine::renderFileBrowserPanel() {
}
ImGui::EndMenu();
}
if (entry.is_directory() && ImGui::MenuItem("Import Assets Here...")) {
queueImportAssetsDialog(entry.path());
}
if (fileBrowser.isModelFile(entry)) {
bool isObj = fileBrowser.isOBJFile(entry);
std::string ext = entry.path().extension().string();
@@ -2031,6 +2483,14 @@ void Engine::renderFileBrowserPanel() {
if (ImGui::MenuItem("Open in File Explorer")) {
openPathInFileManager(entry.path());
}
if (ImGui::MenuItem("Move to Parent Folder")) {
movePathIntoDirectory(entry.path(), entry.path().parent_path());
}
if (!fileBrowser.projectRoot.empty() &&
normalizePath(entry.path().parent_path()) != normalizePath(fileBrowser.projectRoot) &&
ImGui::MenuItem("Move to Project Root")) {
movePathIntoDirectory(entry.path(), fileBrowser.projectRoot);
}
if (ImGui::MenuItem("Rename")) {
pendingRenamePath = entry.path();
std::string baseName = pendingRenamePath.filename().string();
@@ -2049,9 +2509,20 @@ void Engine::renderFileBrowserPanel() {
ImGui::EndPopup();
}
if (!entry.is_directory() && ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) {
if (entry.is_directory() && ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_BROWSER_ENTRY")) {
handleMovePayloadToDirectory(payload, entry.path());
}
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_PATH")) {
handleMovePayloadToDirectory(payload, entry.path());
}
ImGui::EndDragDropTarget();
}
if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) {
std::string payloadPath = entry.path().string();
ImGui::SetDragDropPayload("FILE_PATH", payloadPath.c_str(), payloadPath.size() + 1);
const char* payloadType = entry.is_directory() ? "FILE_BROWSER_ENTRY" : "FILE_PATH";
ImGui::SetDragDropPayload(payloadType, payloadPath.c_str(), payloadPath.size() + 1);
ImGui::Text("%s", filename.c_str());
ImGui::EndDragDropSource();
}
@@ -2062,10 +2533,31 @@ void Engine::renderFileBrowserPanel() {
ImGui::PopStyleVar();
}
const bool fileMainBackgroundLeftClicked =
ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) &&
ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
!ImGui::IsAnyItemHovered();
if (fileMainBackgroundLeftClicked) {
fileBrowser.selectedFile.clear();
}
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_BROWSER_ENTRY")) {
handleMovePayloadToDirectory(payload, fileBrowser.currentPath);
}
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_PATH")) {
handleMovePayloadToDirectory(payload, fileBrowser.currentPath);
}
ImGui::EndDragDropTarget();
}
if (ImGui::BeginPopupContextWindow("FileBrowserEmptyContext", ImGuiPopupFlags_NoOpenOverItems | ImGuiPopupFlags_MouseButtonRight)) {
if (ImGui::MenuItem("Open in File Explorer")) {
openPathInFileManager(fileBrowser.currentPath);
}
if (ImGui::MenuItem("Import Assets...")) {
queueImportAssetsDialog(fileBrowser.currentPath);
}
if (ImGui::MenuItem("Refresh")) {
fileBrowser.needsRefresh = true;
}
@@ -2205,6 +2697,104 @@ void Engine::renderFileBrowserPanel() {
ImGui::EndPopup();
}
if (triggerImportAssetsPopup) {
ImGui::OpenPopup("Import Assets");
triggerImportAssetsPopup = false;
}
if (ImGui::BeginPopupModal("Import Assets", &showImportAssetsPopup, ImGuiWindowFlags_AlwaysAutoResize)) {
fs::path targetDir = pendingImportTargetPath.empty() ? fileBrowser.currentPath : pendingImportTargetPath;
ImGui::Text("Import assets into:");
ImGui::TextDisabled("%s", targetDir.string().c_str());
ImGui::Spacing();
ImGui::TextWrapped("Use your file explorer to import a single file or an entire folder's contents.");
const bool nativePickerAvailable = supportsNativeImportPathPicker();
ImGui::BeginDisabled(!nativePickerAvailable);
if (ImGui::Button("Select File...", ImVec2(180, 0))) {
if (auto selectedPath = chooseImportFilePath(targetDir)) {
if (importPathIntoDirectory(*selectedPath, targetDir)) {
showImportAssetsPopup = false;
ImGui::CloseCurrentPopup();
}
}
}
ImGui::SameLine();
if (ImGui::Button("Select Folder Contents...", ImVec2(220, 0))) {
if (auto selectedFolder = chooseImportFolderPath(targetDir)) {
if (importDirectoryContentsIntoDirectory(*selectedFolder, targetDir)) {
showImportAssetsPopup = false;
ImGui::CloseCurrentPopup();
}
}
}
ImGui::EndDisabled();
if (!nativePickerAvailable) {
ImGui::TextDisabled("Native picker unavailable. Install zenity/kdialog or paste paths below.");
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
ImGui::TextWrapped("Paste one or more source paths. Separate paths with new lines, semicolons, or commas.");
ImGui::InputTextMultiline("##ImportAssetPaths", importAssetPaths, sizeof(importAssetPaths),
ImVec2(520.0f, 120.0f));
bool canImport = importAssetPaths[0] != '\0';
ImGui::BeginDisabled(!canImport);
if (ImGui::Button("Import", ImVec2(120, 0))) {
auto trim = [](std::string value) {
auto isSpace = [](unsigned char c) { return std::isspace(c) != 0; };
while (!value.empty() && isSpace(static_cast<unsigned char>(value.front()))) {
value.erase(value.begin());
}
while (!value.empty() && isSpace(static_cast<unsigned char>(value.back()))) {
value.pop_back();
}
return value;
};
int importedCount = 0;
int failedCount = 0;
std::string raw(importAssetPaths);
size_t start = 0;
for (size_t i = 0; i <= raw.size(); ++i) {
const bool split = (i == raw.size()) || raw[i] == '\n' || raw[i] == '\r' || raw[i] == ';' || raw[i] == ',';
if (!split) continue;
std::string token = trim(raw.substr(start, i - start));
start = i + 1;
if (token.empty()) continue;
std::error_code absEc;
fs::path source = fs::absolute(fs::path(token), absEc);
if (absEc) {
source = fs::path(token);
}
if (importPathIntoDirectory(source, targetDir)) {
++importedCount;
} else {
++failedCount;
}
}
if (importedCount > 0) {
addConsoleMessage("Imported " + std::to_string(importedCount) + " asset(s).",
ConsoleMessageType::Success);
}
if (failedCount > 0) {
addConsoleMessage("Failed to import " + std::to_string(failedCount) + " path(s).",
ConsoleMessageType::Warning);
}
if (importedCount > 0 || failedCount > 0) {
showImportAssetsPopup = false;
ImGui::CloseCurrentPopup();
}
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(120, 0))) {
showImportAssetsPopup = false;
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
ImGui::End();
}
#pragma endregion

View File

@@ -282,6 +282,9 @@ void Engine::renderPixelSpriteEditorWindow() {
EnsureSpriteClipScales(pixelSpriteDocument.spriteFrameScales, pixelSpriteDocument.spriteFrames.size());
pixelSpriteDocument.activeLayer = std::clamp(pixelSpriteDocument.activeLayer, 0, std::max(0, static_cast<int>(pixelSpriteDocument.layers.size()) - 1));
if (mainDockspaceId != 0) {
ImGui::SetNextWindowDockID(mainDockspaceId, ImGuiCond_FirstUseEver);
}
if (!ImGui::Begin("Pixel Sprite Editor", &showPixelSpriteEditorWindow, ImGuiWindowFlags_NoCollapse)) {
ImGui::End();
return;

View File

@@ -278,6 +278,80 @@ static float EaseOutCubic(float t) {
return 1.0f - inv * inv * inv;
}
static float EaseInOutCubic(float t) {
t = ImClamp(t, 0.0f, 1.0f);
if (t < 0.5f) {
return 4.0f * t * t * t;
}
const float f = -2.0f * t + 2.0f;
return 1.0f - (f * f * f) * 0.5f;
}
static float EaseOutBack(float t) {
t = ImClamp(t, 0.0f, 1.0f);
constexpr float c1 = 1.70158f;
constexpr float c3 = c1 + 1.0f;
const float p = t - 1.0f;
return 1.0f + c3 * p * p * p + c1 * p * p;
}
struct LauncherIntroTimings {
float fadeIn = 0.16f;
float popIn = 0.44f;
float hold = 0.10f;
float drift = 0.64f;
};
struct LauncherIntroState {
float elapsed = 0.0f;
float introTotal = 0.0f;
float textAlpha = 0.0f;
float popT = 1.0f;
float driftT = 1.0f;
float driftEase = 1.0f;
float contentRevealT = 1.0f;
bool finished = true;
};
static LauncherIntroState EvaluateLauncherIntro(double now,
double introStartTime,
bool forceFinished,
const LauncherIntroTimings& timings) {
LauncherIntroState state;
const float popStart = timings.fadeIn;
const float driftStart = popStart + timings.popIn + timings.hold;
state.introTotal = driftStart + timings.drift;
state.elapsed = forceFinished
? state.introTotal
: static_cast<float>(now - introStartTime);
if (timings.fadeIn <= 0.0001f) {
state.textAlpha = 1.0f;
} else {
state.textAlpha = ImClamp(state.elapsed / timings.fadeIn, 0.0f, 1.0f);
}
state.popT = timings.popIn <= 0.0001f
? 1.0f
: ImClamp((state.elapsed - popStart) / timings.popIn, 0.0f, 1.0f);
state.driftT = timings.drift <= 0.0001f
? 1.0f
: ImClamp((state.elapsed - driftStart) / timings.drift, 0.0f, 1.0f);
state.driftEase = EaseInOutCubic(state.driftT);
const float contentWindow = std::max(0.001f, timings.drift + timings.hold);
state.contentRevealT = ImClamp((state.elapsed - (driftStart - timings.hold * 0.55f)) / contentWindow, 0.0f, 1.0f);
state.finished = forceFinished || state.driftT >= 1.0f;
return state;
}
static const PackageInfo* FindRegistryPackageById(const std::vector<PackageInfo>& registry,
const std::string& id) {
auto it = std::find_if(registry.begin(), registry.end(), [&](const PackageInfo& pkg) {
return pkg.id == id;
});
return it != registry.end() ? &(*it) : nullptr;
}
struct LauncherPackageSnapshot {
std::string fingerprint;
std::string sourceProjectName;
@@ -344,26 +418,39 @@ static bool ParsePackageManifestLabels(const fs::path& manifestPath,
continue;
}
std::string sourceTag;
std::string sourceSuffix;
size_t sourcePrefixLen = 0;
if (cleaned.rfind("git=", 0) == 0) {
const auto parts = SplitString(cleaned.substr(4), '|');
if (parts.empty()) continue;
sourceTag = "git";
sourceSuffix = " (Git)";
sourcePrefixLen = 4;
} else if (cleaned.rfind("modupak=", 0) == 0) {
sourceTag = "modupak";
sourceSuffix = " (.modupak)";
sourcePrefixLen = 8;
} else {
continue;
}
const std::string id = TrimCopy(parts[0]);
std::string label = id;
if (parts.size() > 1) {
const std::string parsedName = TrimCopy(parts[1]);
if (!parsedName.empty()) {
label = parsedName;
}
}
if (!label.empty()) {
const std::string key = "git:" + id;
if (seen.insert(key).second) {
outLabels.push_back(label + " (Git)");
outExternalCount++;
}
const auto parts = SplitString(cleaned.substr(sourcePrefixLen), '|');
if (parts.empty()) continue;
const std::string id = TrimCopy(parts[0]);
std::string label = id;
if (parts.size() > 1) {
const std::string parsedName = TrimCopy(parts[1]);
if (!parsedName.empty()) {
label = parsedName;
}
}
if (label.empty()) continue;
const std::string key = sourceTag + ":" + id;
if (seen.insert(key).second) {
outLabels.push_back(label + sourceSuffix);
outExternalCount++;
}
}
return true;
@@ -769,35 +856,13 @@ void Engine::renderLauncher() {
launcherIntroSoundPlayed = true;
}
const float introFadeIn = 0.6f;
const float introHold = 0.5f;
const float introFadeOut = 0.7f;
const float introSlide = 0.8f;
const float introSlideStart = introFadeIn + introHold + introFadeOut;
const float introTotal = introSlideStart + introSlide;
const double introElapsedRaw = launcherIntroFinished ? introTotal : (now - launcherIntroStartTime);
const float introElapsed = static_cast<float>(introElapsedRaw);
float textAlpha = 0.0f;
if (!launcherIntroFinished) {
if (introElapsed < introFadeIn) {
textAlpha = introElapsed / introFadeIn;
} else if (introElapsed < introFadeIn + introHold) {
textAlpha = 1.0f;
} else if (introElapsed < introSlideStart) {
textAlpha = 1.0f - (introElapsed - (introFadeIn + introHold)) / introFadeOut;
}
const LauncherIntroTimings introTimings{};
LauncherIntroState introState = EvaluateLauncherIntro(now, launcherIntroStartTime, launcherIntroFinished, introTimings);
if (!launcherIntroFinished && introState.finished) {
launcherIntroFinished = true;
introState = EvaluateLauncherIntro(now, launcherIntroStartTime, true, introTimings);
}
float slideT = 1.0f;
if (!launcherIntroFinished) {
slideT = ImClamp((introElapsed - introSlideStart) / introSlide, 0.0f, 1.0f);
if (slideT >= 1.0f) {
launcherIntroFinished = true;
}
}
const float slideEase = 1.0f - std::pow(1.0f - slideT, 3.0f);
const float transitionDuration = 0.45f;
float transitionT = 0.0f;
if (launcherTransitionActive) {
@@ -805,9 +870,15 @@ void Engine::renderLauncher() {
}
const float transitionEase = 1.0f - std::pow(1.0f - transitionT, 3.0f);
const float transitionAlpha = 1.0f - transitionEase;
const float uiScale = 1.0f + 0.06f * transitionEase;
const float introOffsetT = launcherIntroFinished ? 0.0f : (1.0f - slideEase);
const float contentAlpha = launcherIntroFinished ? 1.0f : slideEase;
const float menuBuildT = launcherIntroFinished ? 1.0f : EaseOutCubic(introState.contentRevealT);
const float sidebarBuildSeed = launcherIntroFinished ? 1.0f : ImClamp((menuBuildT - 0.02f) / 0.92f, 0.0f, 1.0f);
const float contentBuildSeed = launcherIntroFinished ? 1.0f : ImClamp((menuBuildT - 0.16f) / 0.94f, 0.0f, 1.0f);
const float sidebarBuildT = launcherIntroFinished ? 1.0f : std::pow(sidebarBuildSeed, 1.32f);
const float contentBuildT = launcherIntroFinished ? 1.0f : std::pow(contentBuildSeed, 1.40f);
const float introMenuScale = launcherIntroFinished ? 1.0f : ImLerp(1.07f, 1.0f, menuBuildT);
const float uiScale = (1.0f + 0.06f * transitionEase) * introMenuScale;
const float introOffsetT = launcherIntroFinished ? 0.0f : (1.0f - introState.driftEase);
const float contentAlpha = launcherIntroFinished ? 1.0f : ImClamp(0.12f + introState.contentRevealT * 0.88f, 0.0f, 1.0f);
const float introHeroOffsetY = -90.0f * uiScale * introOffsetT;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f);
@@ -982,7 +1053,8 @@ void Engine::renderLauncher() {
const float paneGap = 12.0f * uiScale;
const float shellHeight = ImMax(0.0f, ImGui::GetContentRegionAvail().y - shellInsetTop - shellInsetBottom);
ImGui::SetCursorPos(ImVec2(contentStart.x + shellInsetX, contentStart.y + introHeroOffsetY + shellInsetTop));
ImGui::SetCursorPos(ImVec2(contentStart.x + shellInsetX,
contentStart.y + introHeroOffsetY + shellInsetTop));
ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 0.0f);
ImGui::PushStyleColor(ImGuiCol_ChildBg, shellBg);
ImGui::BeginChild("LauncherShell", ImVec2(-shellInsetX, shellHeight), false);
@@ -991,21 +1063,55 @@ void Engine::renderLauncher() {
const ImVec2 sidebarPadding(14.0f * uiScale, 14.0f * uiScale);
const ImVec2 contentPadding(14.0f * uiScale, 12.0f * uiScale);
const ImVec2 sectionInset(0.0f, 0.0f);
const ImVec2 shellLayoutOrigin = ImGui::GetCursorPos();
const ImVec2 shellLayoutAvail = ImGui::GetContentRegionAvail();
const float paneHeight = ImMax(0.0f, shellLayoutAvail.y);
const float contentPaneWidth = ImMax(0.0f, shellLayoutAvail.x - sidebarWidth - paneGap);
const float sidebarPaneT = launcherIntroFinished ? 1.0f : EaseOutCubic(sidebarBuildT);
const float sidebarPaneOffsetX = -(1.0f - sidebarPaneT) * 88.0f * uiScale;
const float sidebarPaneOffsetY = (1.0f - sidebarPaneT) * 14.0f * uiScale;
const float sidebarPaneAlpha = ImLerp(0.0f, 1.0f, sidebarPaneT);
const float contentPaneT = launcherIntroFinished ? 1.0f : EaseOutCubic(contentBuildT);
const float contentPaneOffsetX = (1.0f - contentPaneT) * 96.0f * uiScale;
const float contentPaneOffsetY = (1.0f - contentPaneT) * 12.0f * uiScale;
const float contentPaneAlpha = ImLerp(0.0f, 1.0f, contentPaneT);
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, sidebarPaneAlpha);
ImGui::SetCursorPos(ImVec2(shellLayoutOrigin.x + sidebarPaneOffsetX,
shellLayoutOrigin.y + sidebarPaneOffsetY));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, sidebarPadding);
ImGui::PushStyleColor(ImGuiCol_ChildBg, sidebarBg);
ImGui::BeginChild("LauncherSidebar", ImVec2(sidebarWidth, 0), false);
ImGui::BeginChild("LauncherSidebar", ImVec2(sidebarWidth, paneHeight), false);
ImGui::PopStyleColor();
ImGui::PopStyleVar();
ImGui::TextColored(ImVec4(0.86f, 0.89f, 0.96f, 1.0f), "Modularity Project Manager");
ImGui::Spacing();
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
int sidebarAnimIndex = 0;
auto beginSidebarBuildStep = [&](float delayStep = 0.132f) {
const float delay = std::min(0.86f, delayStep * static_cast<float>(sidebarAnimIndex));
const float localT = ImClamp(
(sidebarBuildT - delay) / std::max(0.0001f, 1.0f - delay),
0.0f,
1.0f);
const float eased = EaseOutCubic(localT);
const float alpha = ImLerp(0.0f, 1.0f, eased);
const float yOffset = (1.0f - eased) * (24.0f + static_cast<float>(sidebarAnimIndex) * 2.0f) * uiScale;
const float xOffset = -(1.0f - eased) * 34.0f * uiScale;
ImGui::SetCursorPos(ImVec2(ImGui::GetCursorPosX() + xOffset, ImGui::GetCursorPosY() + yOffset));
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, alpha);
++sidebarAnimIndex;
};
auto endSidebarBuildStep = [&]() {
ImGui::PopStyleVar();
};
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(14.0f * uiScale, 10.0f * uiScale));
beginSidebarBuildStep();
ImGui::PushStyleColor(ImGuiCol_Button, launcherSection == 0 ? ImVec4(0.21f, 0.32f, 0.47f, 1.0f) : ImVec4(0.16f, 0.19f, 0.28f, 0.95f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.24f, 0.36f, 0.54f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.21f, 0.32f, 0.47f, 1.0f));
@@ -1013,6 +1119,9 @@ void Engine::renderLauncher() {
setLauncherSection(0);
}
ImGui::PopStyleColor(3);
endSidebarBuildStep();
beginSidebarBuildStep();
ImGui::PushStyleColor(ImGuiCol_Button, launcherSection == 1 ? ImVec4(0.21f, 0.32f, 0.47f, 1.0f) : ImVec4(0.16f, 0.19f, 0.28f, 0.95f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.24f, 0.36f, 0.54f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.21f, 0.32f, 0.47f, 1.0f));
@@ -1020,6 +1129,9 @@ void Engine::renderLauncher() {
setLauncherSection(1);
}
ImGui::PopStyleColor(3);
endSidebarBuildStep();
beginSidebarBuildStep();
ImGui::PushStyleColor(ImGuiCol_Button, launcherSection == 2 ? ImVec4(0.21f, 0.32f, 0.47f, 1.0f) : ImVec4(0.16f, 0.19f, 0.28f, 0.95f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.24f, 0.36f, 0.54f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.21f, 0.32f, 0.47f, 1.0f));
@@ -1027,12 +1139,14 @@ void Engine::renderLauncher() {
setLauncherSection(2);
}
ImGui::PopStyleColor(3);
endSidebarBuildStep();
ImGui::PopStyleVar();
const float footerY = ImGui::GetWindowHeight() - 60.0f * uiScale;
if (ImGui::GetCursorPosY() < footerY) {
ImGui::SetCursorPosY(footerY);
}
ImGui::Separator();
beginSidebarBuildStep();
if (ImGui::Button("Modularity Website", ImVec2(-1, 28.0f * uiScale))) {
#ifdef _WIN32
system("start https://moduengine.xyz");
@@ -1040,6 +1154,8 @@ void Engine::renderLauncher() {
system("xdg-open https://moduengine.xyz &");
#endif
}
endSidebarBuildStep();
beginSidebarBuildStep();
if (ImGui::Button("Documentation", ImVec2(-1, 28.0f * uiScale))) {
#ifdef _WIN32
system("start https://moduengine.xyz/docs");
@@ -1047,16 +1163,21 @@ void Engine::renderLauncher() {
system("xdg-open https://moduengine.xyz/docs &");
#endif
}
endSidebarBuildStep();
beginSidebarBuildStep();
if (ImGui::Button("Exit", ImVec2(-1, 28.0f * uiScale))) {
glfwSetWindowShouldClose(editorWindow, GLFW_TRUE);
}
endSidebarBuildStep();
ImGui::EndChild();
ImGui::PopStyleVar();
ImGui::SameLine(0.0f, paneGap);
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, contentPaneAlpha);
ImGui::SetCursorPos(ImVec2(shellLayoutOrigin.x + sidebarWidth + paneGap + contentPaneOffsetX,
shellLayoutOrigin.y + contentPaneOffsetY));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, contentPadding);
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f));
ImGui::BeginChild("LauncherContent", ImVec2(0, 0), false);
ImGui::BeginChild("LauncherContent", ImVec2(contentPaneWidth, paneHeight), false);
ImGui::PopStyleColor();
ImGui::PopStyleVar();
@@ -1070,8 +1191,14 @@ void Engine::renderLauncher() {
const float projectsInsetX = 10.0f * uiScale;
const float projectsInsetY = 6.0f * uiScale;
const float projectsHeaderGap = 12.0f * uiScale;
const float panelBuildT = launcherIntroFinished ? 1.0f : EaseOutCubic(contentBuildT);
const float headerAlpha = ImLerp(0.0f, 1.0f, panelBuildT);
const float headerOffsetY = (1.0f - panelBuildT) * 24.0f * uiScale;
const float headerOffsetX = (1.0f - panelBuildT) * 30.0f * uiScale;
ImGui::SetCursorPos(ImVec2(ImGui::GetCursorPosX() + projectsInsetX,
ImGui::GetCursorPosY() + projectsInsetY));
ImGui::GetCursorPosY() + projectsInsetY + headerOffsetY));
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, headerAlpha);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + headerOffsetX);
ImGui::TextColored(ImVec4(0.92f, 0.94f, 0.98f, 1.0f), "Projects");
const float buttonWidth = 112.0f * uiScale;
const float spacing = ImGui::GetStyle().ItemSpacing.x;
@@ -1113,12 +1240,15 @@ void Engine::renderLauncher() {
ImGui::Dummy(ImVec2(0.0f, projectsHeaderGap));
ImGui::Separator();
ImGui::Dummy(ImVec2(0.0f, projectsHeaderGap));
ImGui::PopStyleVar();
const std::string filter = toLower(TrimCopy(launcherSearch));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f));
ImGui::BeginChild("RecentProjectsList", ImVec2(0, 0), false);
bool hadVisible = false;
bool removedProject = false;
int visibleRowIndex = 0;
const float cardsBuildT = launcherIntroFinished ? 1.0f : EaseOutCubic(contentBuildT);
for (size_t i = 0; i < projectManager.recentProjects.size(); ++i) {
const auto& rp = projectManager.recentProjects[i];
@@ -1128,6 +1258,16 @@ void Engine::renderLauncher() {
}
hadVisible = true;
const int rowIndex = visibleRowIndex++;
const float rowDelay = std::min(0.74f, static_cast<float>(rowIndex) * 0.072f);
const float rowInput = ImClamp(
(cardsBuildT - rowDelay) / std::max(0.0001f, 1.0f - rowDelay),
0.0f,
1.0f);
const float rowReveal = EaseOutCubic(rowInput);
const float rowSlideX = (1.0f - rowReveal) * 56.0f * uiScale;
const float rowSlideY = (1.0f - rowReveal) * (20.0f + static_cast<float>(rowIndex) * 2.8f) * uiScale;
const float rowAlpha = ImLerp(0.0f, 1.0f, rowReveal);
ImGui::PushID(static_cast<int>(i));
ImTextureID previewTexId = static_cast<ImTextureID>(0);
@@ -1150,6 +1290,8 @@ void Engine::renderLauncher() {
const float cardHeight = 92.0f * uiScale;
const ImVec2 cardSize(ImGui::GetContentRegionAvail().x, cardHeight);
const ImVec2 rowBasePos = ImGui::GetCursorScreenPos();
ImGui::SetCursorScreenPos(ImVec2(rowBasePos.x + rowSlideX, rowBasePos.y + rowSlideY));
const ImVec2 cardPos = ImGui::GetCursorScreenPos();
ImGui::InvisibleButton("RecentProjectCard", cardSize);
const bool hovered = ImGui::IsItemHovered();
@@ -1172,20 +1314,23 @@ void Engine::renderLauncher() {
}
ImDrawList* list = ImGui::GetWindowDrawList();
auto withRowAlpha = [rowAlpha](const ImVec4& col) {
return ImVec4(col.x, col.y, col.z, col.w * rowAlpha);
};
const float hoverT = EaseOutCubic(hovered ? 1.0f : 0.0f);
const ImU32 cardBg = ImGui::GetColorU32(hovered
? ImVec4(0.18f, 0.22f, 0.31f, 1.0f)
: ImVec4(0.14f, 0.16f, 0.22f, 0.98f));
? withRowAlpha(ImVec4(0.18f, 0.22f, 0.31f, 1.0f))
: withRowAlpha(ImVec4(0.14f, 0.16f, 0.22f, 0.98f)));
const ImU32 cardBorder = ImGui::GetColorU32(hovered
? ImVec4(0.30f, 0.52f, 0.80f, 1.0f)
: ImVec4(0.22f, 0.27f, 0.36f, 0.95f));
? withRowAlpha(ImVec4(0.30f, 0.52f, 0.80f, 1.0f))
: withRowAlpha(ImVec4(0.22f, 0.27f, 0.36f, 0.95f)));
const float cardRounding = 12.0f * uiScale;
list->AddRectFilled(cardPos, ImVec2(cardPos.x + cardSize.x, cardPos.y + cardSize.y), cardBg, cardRounding);
list->AddRect(cardPos, ImVec2(cardPos.x + cardSize.x, cardPos.y + cardSize.y), cardBorder, cardRounding, 0, hovered ? 2.0f : 1.0f);
if (hovered) {
list->AddRectFilled(cardPos,
ImVec2(cardPos.x + cardSize.x, cardPos.y + cardSize.y),
ImGui::GetColorU32(ImVec4(0.22f, 0.36f, 0.58f, 0.06f + 0.08f * hoverT)),
ImGui::GetColorU32(withRowAlpha(ImVec4(0.22f, 0.36f, 0.58f, 0.06f + 0.08f * hoverT))),
cardRounding);
}
@@ -1195,21 +1340,22 @@ void Engine::renderLauncher() {
const ImVec2 thumbMax(thumbMin.x + thumbSize.x, thumbMin.y + thumbSize.y);
if (previewTexId != static_cast<ImTextureID>(0)) {
DrawImageCover(list, previewTexId, thumbMin, thumbMax, previewTexWidth, previewTexHeight,
ImGui::GetColorU32(ImVec4(1, 1, 1, 1)), 8.0f * uiScale);
ImGui::GetColorU32(withRowAlpha(ImVec4(1, 1, 1, 1))), 8.0f * uiScale);
} else {
list->AddRectFilled(thumbMin, thumbMax, ImGui::GetColorU32(ImVec4(0.11f, 0.13f, 0.18f, 1.0f)), 8.0f * uiScale);
list->AddRectFilled(thumbMin, thumbMax, ImGui::GetColorU32(withRowAlpha(ImVec4(0.11f, 0.13f, 0.18f, 1.0f))), 8.0f * uiScale);
const char* noPreview = "No Preview";
ImVec2 txt = ImGui::CalcTextSize(noPreview);
list->AddText(ImVec2(thumbMin.x + (thumbSize.x - txt.x) * 0.5f,
thumbMin.y + (thumbSize.y - txt.y) * 0.5f),
ImGui::GetColorU32(ImVec4(0.66f, 0.70f, 0.78f, 1.0f)),
ImGui::GetColorU32(withRowAlpha(ImVec4(0.66f, 0.70f, 0.78f, 1.0f))),
noPreview);
}
list->AddRect(thumbMin, thumbMax, ImGui::GetColorU32(ImVec4(0.24f, 0.28f, 0.38f, 0.95f)), 8.0f * uiScale);
list->AddRect(thumbMin, thumbMax, ImGui::GetColorU32(withRowAlpha(ImVec4(0.24f, 0.28f, 0.38f, 0.95f))), 8.0f * uiScale);
const float actionWidth = 112.0f * uiScale;
const float textStartX = thumbMax.x + 16.0f * uiScale;
const float textWidth = std::max(140.0f * uiScale, cardMax.x - textStartX - actionWidth - 28.0f * uiScale);
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, rowAlpha);
ImGui::SetCursorScreenPos(ImVec2(textStartX, cardPos.y + 15.0f * uiScale));
ImGui::PushTextWrapPos(textStartX + textWidth);
ImGui::TextColored(ImVec4(0.92f, 0.94f, 0.98f, 1.0f), "%s",
@@ -1235,6 +1381,7 @@ void Engine::renderLauncher() {
}
ImGui::PopStyleColor(3);
ImGui::EndDisabled();
ImGui::PopStyleVar();
if (shouldRemove) {
projectManager.recentProjects.erase(
@@ -1250,7 +1397,7 @@ void Engine::renderLauncher() {
launchRecentProject(rp, rowCenter);
}
ImGui::SetCursorScreenPos(ImVec2(cardPos.x, cardPos.y + cardHeight));
ImGui::SetCursorScreenPos(ImVec2(rowBasePos.x, rowBasePos.y + cardHeight));
ImGui::Dummy(ImVec2(cardSize.x, 5.0f * uiScale));
ImGui::PopID();
}
@@ -1661,6 +1808,7 @@ void Engine::renderLauncher() {
ImGui::PopStyleVar(2);
ImGui::EndChild();
ImGui::PopStyleVar();
ImGui::EndChild();
ImGui::PopStyleVar();
@@ -1670,54 +1818,87 @@ void Engine::renderLauncher() {
ImGui::SetWindowFontScale(1.0f);
ImGui::PopStyleVar();
if (textAlpha > 0.001f) {
ImDrawList* overlay = ImGui::GetWindowDrawList();
if (introState.textAlpha > 0.001f) {
ImDrawList* overlay = ImGui::GetForegroundDrawList(ImGui::GetMainViewport());
const char* title = "Modularity";
ImFont* font = ImGui::GetFont();
const float baseFontSize = ImGui::GetFontSize();
const float fadeOutT = ImClamp((introElapsed - (introFadeIn + introHold)) / introFadeOut, 0.0f, 1.0f);
const float globalScale = 2.0f - 0.18f * fadeOutT;
const float fontSizeNormal = baseFontSize * 2.6f * globalScale;
const float centerFontSize = baseFontSize * 2.62f;
const float headerFontSize = baseFontSize * 1.24f;
const ImVec4 baseCol = ImVec4(0.95f, 0.96f, 0.98f, 1.0f);
const float letterDelay = 0.07f;
const float letterIn = 0.18f;
auto ease = [](float t) {
t = ImClamp(t, 0.0f, 1.0f);
return t * t * (3.0f - 2.0f * t);
};
ImVec2 center = ImVec2(displaySize.x * 0.5f, displaySize.y * 0.38f);
float totalWidth = 0.0f;
ImVec2 center = ImVec2(displaySize.x * 0.5f, displaySize.y * 0.36f);
float centerTotalWidth = 0.0f;
float headerTotalWidth = 0.0f;
for (const char* c = title; *c; ++c) {
char letter[2] = { *c, 0 };
totalWidth += font->CalcTextSizeA(fontSizeNormal, FLT_MAX, 0.0f, letter).x;
centerTotalWidth += font->CalcTextSizeA(centerFontSize, FLT_MAX, 0.0f, letter).x;
headerTotalWidth += font->CalcTextSizeA(headerFontSize, FLT_MAX, 0.0f, letter).x;
}
ImVec2 textPos = ImVec2(center.x - totalWidth * 0.5f,
center.y - fontSizeNormal * 0.5f);
const ImVec2 centerTextPos(center.x - centerTotalWidth * 0.5f,
center.y - centerFontSize * 0.5f);
const float headerMarginLeft = 26.0f * uiScale;
const float headerTop = windowPos.y + 14.0f * uiScale;
const ImVec2 headerTextPos(
windowPos.x + headerMarginLeft,
headerTop);
float advanceX = 0.0f;
float centerAdvanceX = 0.0f;
float headerAdvanceX = 0.0f;
int index = 0;
const int totalLetters = static_cast<int>(std::strlen(title));
const float popDelayStep = 0.055f;
const float driftDelayStep = 0.024f;
for (const char* c = title; *c; ++c, ++index) {
char letter[2] = { *c, 0 };
float letterT = (introElapsed - (letterDelay * index)) / letterIn;
float letterEase = ease(letterT);
float letterAlpha = textAlpha * letterEase;
if (letterAlpha <= 0.001f) {
float letterWidth = font->CalcTextSizeA(fontSizeNormal, FLT_MAX, 0.0f, letter).x;
advanceX += letterWidth;
continue;
}
const float popDelay = (totalLetters > 1) ? (popDelayStep * static_cast<float>(index)) : 0.0f;
const float popInput = ImClamp(
(introState.elapsed - introTimings.fadeIn - popDelay) / std::max(0.0001f, introTimings.popIn),
0.0f,
1.0f);
const float popEase = EaseOutBack(popInput);
const float popAlpha = EaseOutCubic(popInput);
float letterScale = (1.15f - 0.15f * letterEase) * globalScale;
float letterFontSize = baseFontSize * 2.6f * letterScale;
float letterWidth = font->CalcTextSizeA(fontSizeNormal, FLT_MAX, 0.0f, letter).x;
float offsetX = (letterWidth * (letterScale / globalScale - 1.0f)) * 0.5f;
ImVec2 letterPos = ImVec2(textPos.x + advanceX - offsetX, textPos.y);
ImU32 textCol = ImGui::GetColorU32(ImVec4(baseCol.x, baseCol.y, baseCol.z, letterAlpha));
overlay->AddText(font, letterFontSize, letterPos, textCol, letter);
advanceX += letterWidth;
const float driftDelay = (totalLetters > 1) ? (driftDelayStep * static_cast<float>(index)) : 0.0f;
const float driftInput = ImClamp(
(introState.driftT - driftDelay) / std::max(0.0001f, 1.0f - driftDelay),
0.0f,
1.0f);
const float driftEase = EaseInOutCubic(driftInput);
const float centerWidth = font->CalcTextSizeA(centerFontSize, FLT_MAX, 0.0f, letter).x;
const float headerWidth = font->CalcTextSizeA(headerFontSize, FLT_MAX, 0.0f, letter).x;
const ImVec2 centerLetterPos(centerTextPos.x + centerAdvanceX, centerTextPos.y);
const ImVec2 headerLetterPos(headerTextPos.x + headerAdvanceX, headerTextPos.y);
const float popScale = std::max(0.05f, 0.34f + 0.66f * popEase);
const float popSize = centerFontSize * popScale;
const ImVec2 spawnPos(centerLetterPos.x, centerLetterPos.y + (1.0f - popAlpha) * 22.0f * uiScale);
const ImVec2 popPos(
ImLerp(spawnPos.x, centerLetterPos.x, popAlpha),
ImLerp(spawnPos.y, centerLetterPos.y, popAlpha));
const float letterSize = ImLerp(popSize, headerFontSize, driftEase);
const ImVec2 letterPos(
ImLerp(popPos.x, headerLetterPos.x, driftEase),
ImLerp(popPos.y, headerLetterPos.y, driftEase));
const float letterAlpha = introState.textAlpha * popAlpha * ImLerp(0.92f, 1.0f, driftEase);
const ImU32 textCol = ImGui::GetColorU32(ImVec4(baseCol.x, baseCol.y, baseCol.z, letterAlpha));
overlay->AddText(font, letterSize, letterPos, textCol, letter);
centerAdvanceX += centerWidth;
headerAdvanceX += headerWidth;
}
const float packageTagAlpha = introState.textAlpha * ImClamp((introState.driftT - 0.23f) / 0.62f, 0.0f, 1.0f);
if (packageTagAlpha > 0.001f) {
const char* packageLabel = "Project manager";
const float packageFontSize = baseFontSize * 0.95f;
const ImU32 tagCol = ImGui::GetColorU32(ImVec4(0.79f, 0.84f, 0.92f, packageTagAlpha));
const ImVec2 tagPos(headerTextPos.x + headerTotalWidth + 14.0f * uiScale,
headerTextPos.y + headerFontSize * 0.18f);
overlay->AddText(font, packageFontSize, tagPos, tagCol, packageLabel);
}
}
}
@@ -2075,6 +2256,7 @@ void Engine::renderProjectBrowserPanel() {
static char gitUrlBuf[256] = "";
static char gitNameBuf[128] = "";
static char gitIncludeBuf[128] = "include";
static char moduPakPathBuf[512] = "";
auto pollPackageTask = [&]() {
if (!packageTask.active) return;
@@ -2139,6 +2321,25 @@ void Engine::renderProjectBrowserPanel() {
});
}
ImGui::Spacing();
ImGui::TextDisabled("Install from .modupak");
ImGui::InputTextWithHint("Bundle path", "/path/to/package.modupak", moduPakPathBuf, sizeof(moduPakPathBuf));
if (ImGui::Button("Install .modupak")) {
const std::string bundlePath = TrimCopy(moduPakPathBuf);
startPackageTask("Importing .modupak...", [this, bundlePath]() {
PackageTaskResult result;
std::string newId;
if (packageManager.installModuPak(fs::path(bundlePath), newId)) {
result.success = true;
result.message = "Installed .modupak package: " + newId;
} else {
result.message = packageManager.getLastError();
}
return result;
});
}
ImGui::TextDisabled("Expected bundle layout: manifest.modu + payload/ (tar archive or .modupak folder).");
ImGui::EndDisabled();
ImGui::Separator();
ImGui::TextDisabled("Installed packages");
@@ -2151,57 +2352,74 @@ void Engine::renderProjectBrowserPanel() {
ImGui::TextDisabled("None installed");
} else {
for (const auto& id : installedIds) {
const PackageInfo* pkg = nullptr;
for (const auto& p : registry) {
if (p.id == id) { pkg = &p; break; }
}
const PackageInfo* pkg = FindRegistryPackageById(registry, id);
if (!pkg) continue;
ImGui::PushID(pkg->id.c_str());
ImGui::Separator();
ImGui::Text("%s", pkg->name.c_str());
ImGui::TextDisabled("%s", pkg->description.c_str());
if (!pkg->external) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.4f, 0.7f, 0.95f, 1.0f), "[bundled]");
const char* sourceBadge = "[bundled]";
ImVec4 badgeColor = ImVec4(0.4f, 0.7f, 0.95f, 1.0f);
if (pkg->modupak) {
sourceBadge = "[.modupak]";
badgeColor = ImVec4(0.62f, 0.86f, 0.68f, 1.0f);
} else if (pkg->external) {
sourceBadge = "[git]";
badgeColor = ImVec4(0.96f, 0.79f, 0.49f, 1.0f);
}
ImGui::SameLine();
ImGui::TextColored(badgeColor, "%s", sourceBadge);
if (pkg->external) {
ImGui::TextDisabled("Path: %s", pkg->localPath.string().c_str());
}
if (pkg->modupak && !pkg->modupakSourcePath.empty()) {
ImGui::TextDisabled("Bundle: %s", pkg->modupakSourcePath.string().c_str());
} else if (pkg->external && !pkg->gitUrl.empty()) {
ImGui::TextDisabled("Git: %s", pkg->gitUrl.c_str());
}
if (pkg->builtIn) {
ImGui::TextDisabled("Required package.");
ImGui::PopID();
continue;
}
ImGui::TextDisabled("Path: %s", pkg->localPath.string().c_str());
ImGui::TextDisabled("Git: %s", pkg->gitUrl.c_str());
if (pkg->external && !pkg->gitUrl.empty()) {
if (ImGui::Button("Check updates")) {
std::string id = pkg->id;
startPackageTask("Checking package status...", [this, id]() {
PackageTaskResult result;
std::string status;
if (packageManager.checkGitStatus(id, status)) {
result.success = true;
result.message = status;
} else {
result.message = packageManager.getLastError();
}
return result;
});
}
ImGui::SameLine();
if (ImGui::Button("Update")) {
std::string id = pkg->id;
std::string name = pkg->name;
startPackageTask("Updating package...", [this, id, name]() {
PackageTaskResult result;
std::string log;
if (packageManager.updateGitPackage(id, log)) {
result.success = true;
result.message = "Updated " + name + "\n" + log;
} else {
result.message = packageManager.getLastError();
}
return result;
});
}
ImGui::SameLine();
}
if (ImGui::Button("Check updates")) {
std::string id = pkg->id;
startPackageTask("Checking package status...", [this, id]() {
PackageTaskResult result;
std::string status;
if (packageManager.checkGitStatus(id, status)) {
result.success = true;
result.message = status;
} else {
result.message = packageManager.getLastError();
}
return result;
});
}
ImGui::SameLine();
if (ImGui::Button("Update")) {
std::string id = pkg->id;
std::string name = pkg->name;
startPackageTask("Updating package...", [this, id, name]() {
PackageTaskResult result;
std::string log;
if (packageManager.updateGitPackage(id, log)) {
result.success = true;
result.message = "Updated " + name + "\n" + log;
} else {
result.message = packageManager.getLastError();
}
return result;
});
}
ImGui::SameLine();
if (ImGui::Button("Uninstall")) {
std::string id = pkg->id;
std::string name = pkg->name;

File diff suppressed because it is too large Load Diff

View File

@@ -457,9 +457,11 @@ void Engine::openScriptInEditor(const fs::path& path) {
void Engine::renderScriptingWindow() {
if (!showScriptingWindow) return;
bool listRefreshedThisFrame = false;
if (scriptingFilesDirty) {
refreshScriptingFileList();
scriptingFilesDirty = false;
listRefreshedThisFrame = true;
}
ImGui::Begin("Scripting", &showScriptingWindow);
@@ -480,7 +482,17 @@ void Engine::renderScriptingWindow() {
static std::vector<std::string> completionPool;
static std::vector<std::string> activeSuggestions;
static std::string activePrefix;
static bool completionPoolDirty = true;
static bool completionPanelOpen = true;
static fs::path reloadCheckPath;
static bool cachedCanReload = false;
static double lastReloadCheckTime = 0.0;
static std::string cachedFilterLower;
static std::vector<int> filteredScriptIndices;
if (listRefreshedThisFrame) {
completionPoolDirty = true;
}
ImGui::TextDisabled("Script & Shader Editor");
ImGui::SameLine();
@@ -493,21 +505,35 @@ void Engine::renderScriptingWindow() {
float leftWidth = 240.0f;
ImGui::BeginChild("ScriptingFiles", ImVec2(leftWidth, 0.0f), true);
ImGui::TextDisabled("Scripts / Shaders");
ImGui::InputTextWithHint("##ScriptFilter", "Filter", scriptingFilter, sizeof(scriptingFilter));
bool filterChanged = ImGui::InputTextWithHint("##ScriptFilter", "Filter", scriptingFilter, sizeof(scriptingFilter));
ImGui::Separator();
for (const auto& scriptPath : scriptingFileList) {
std::string label = scriptPath.filename().string();
std::string filter = scriptingFilter;
std::transform(filter.begin(), filter.end(), filter.begin(), ::tolower);
std::string lowerLabel = label;
std::transform(lowerLabel.begin(), lowerLabel.end(), lowerLabel.begin(), ::tolower);
if (!filter.empty() && lowerLabel.find(filter) == std::string::npos) {
continue;
std::string filterLower = toLowerCopy(scriptingFilter);
if (listRefreshedThisFrame || filterChanged || filterLower != cachedFilterLower) {
cachedFilterLower = filterLower;
filteredScriptIndices.clear();
filteredScriptIndices.reserve(scriptingFileList.size());
for (size_t i = 0; i < scriptingFileList.size(); ++i) {
if (!cachedFilterLower.empty()) {
std::string labelLower = toLowerCopy(scriptingFileList[i].filename().string());
if (labelLower.find(cachedFilterLower) == std::string::npos) {
continue;
}
}
filteredScriptIndices.push_back(static_cast<int>(i));
}
bool selected = (scriptEditorState.filePath == scriptPath);
if (ImGui::Selectable(label.c_str(), selected)) {
openScriptInEditor(scriptPath);
}
ImGuiListClipper clipper;
clipper.Begin(static_cast<int>(filteredScriptIndices.size()));
while (clipper.Step()) {
for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) {
const fs::path& scriptPath = scriptingFileList[filteredScriptIndices[row]];
std::string label = scriptPath.filename().string();
bool selected = (scriptEditorState.filePath == scriptPath);
if (ImGui::Selectable(label.c_str(), selected)) {
openScriptInEditor(scriptPath);
}
}
}
ImGui::EndChild();
@@ -537,6 +563,8 @@ void Engine::renderScriptingWindow() {
std::error_code ec;
scriptEditorState.lastWriteTime = fs::last_write_time(scriptEditorState.filePath, ec);
scriptEditorState.hasWriteTime = !ec;
cachedCanReload = false;
lastReloadCheckTime = glfwGetTime();
if (scriptEditorState.autoCompileOnSave && canCompileFile) {
compileScriptFile(scriptEditorState.filePath);
}
@@ -565,14 +593,25 @@ void Engine::renderScriptingWindow() {
}
bool canReload = false;
if (reloadCheckPath != scriptEditorState.filePath) {
reloadCheckPath = scriptEditorState.filePath;
cachedCanReload = false;
lastReloadCheckTime = 0.0;
}
if (hasFile && scriptEditorState.hasWriteTime) {
std::error_code ec;
if (fs::exists(scriptEditorState.filePath, ec)) {
auto diskTime = fs::last_write_time(scriptEditorState.filePath, ec);
if (!ec && diskTime > scriptEditorState.lastWriteTime) {
canReload = true;
const double now = glfwGetTime();
if (now - lastReloadCheckTime >= 0.25) {
lastReloadCheckTime = now;
cachedCanReload = false;
std::error_code ec;
if (fs::exists(scriptEditorState.filePath, ec)) {
auto diskTime = fs::last_write_time(scriptEditorState.filePath, ec);
if (!ec && diskTime > scriptEditorState.lastWriteTime) {
cachedCanReload = true;
}
}
}
canReload = cachedCanReload;
}
if (canReload) {
ImGui::SameLine();
@@ -580,6 +619,8 @@ void Engine::renderScriptingWindow() {
ImGui::SameLine();
if (ImGui::Button("Reload")) {
openScriptInEditor(scriptEditorState.filePath);
cachedCanReload = false;
lastReloadCheckTime = glfwGetTime();
}
}
@@ -593,62 +634,81 @@ void Engine::renderScriptingWindow() {
scriptTextEditorReady = true;
}
ScriptEditorLanguage language = detectScriptEditorLanguage(scriptEditorState.filePath);
uint64_t bufferHash = hashBuffer(scriptEditorState.buffer);
bool fileChanged = (identifiersFilePath != scriptEditorState.filePath);
bool languageChanged = (activeLanguage != language);
if (bufferHash != identifiersHash || fileChanged || languageChanged) {
identifiersHash = bufferHash;
identifiersFilePath = scriptEditorState.filePath;
activeLanguage = language;
ScriptEditorLanguage language = detectScriptEditorLanguage(scriptEditorState.filePath);
uint64_t bufferHash = hashBuffer(scriptEditorState.buffer);
bool fileChanged = (identifiersFilePath != scriptEditorState.filePath);
bool languageChanged = (activeLanguage != language);
if (bufferHash != identifiersHash || fileChanged || languageChanged) {
identifiersHash = bufferHash;
identifiersFilePath = scriptEditorState.filePath;
activeLanguage = language;
const auto& keywords = keywordsForLanguage(language);
bufferIdentifiers = extractIdentifiers(scriptEditorState.buffer, keywords);
bufferFunctions = extractFunctionIdentifiers(scriptEditorState.buffer, keywords);
bufferDefines = extractDefineIdentifiers(scriptEditorState.buffer);
scriptTextEditor.SetLanguageDefinition(buildLanguageDefinition(language, bufferFunctions, bufferDefines));
}
completionPool.clear();
std::unordered_set<std::string> poolSet;
const auto& langDef = scriptTextEditor.GetLanguageDefinition();
for (const auto& kw : langDef.mKeywords) {
poolSet.insert(kw);
}
for (const auto& identifier : langDef.mIdentifiers) {
poolSet.insert(identifier.first);
}
for (const auto& identifier : langDef.mPreprocIdentifiers) {
poolSet.insert(identifier.first);
}
for (const auto& entry : scriptingCompletions) {
poolSet.insert(entry);
}
for (const auto& entry : symbols) {
poolSet.insert(entry);
}
for (const auto& entry : bufferIdentifiers) {
poolSet.insert(entry);
}
for (const auto& entry : bufferFunctions) {
poolSet.insert(entry);
}
for (const auto& entry : bufferDefines) {
poolSet.insert(entry);
}
completionPool.assign(poolSet.begin(), poolSet.end());
std::sort(completionPool.begin(), completionPool.end());
TextEditor::Coordinates cursorBefore = scriptTextEditor.GetCursorPosition();
activePrefix = scriptTextEditor.GetWordAtPublic(cursorBefore);
if (activePrefix.empty() && cursorBefore.mColumn > 0) {
TextEditor::Coordinates prev(cursorBefore.mLine, cursorBefore.mColumn - 1);
activePrefix = scriptTextEditor.GetWordAtPublic(prev);
const auto& keywords = keywordsForLanguage(language);
bufferIdentifiers = extractIdentifiers(scriptEditorState.buffer, keywords);
bufferFunctions = extractFunctionIdentifiers(scriptEditorState.buffer, keywords);
bufferDefines = extractDefineIdentifiers(scriptEditorState.buffer);
scriptTextEditor.SetLanguageDefinition(buildLanguageDefinition(language, bufferFunctions, bufferDefines));
completionPoolDirty = true;
}
if (!activePrefix.empty() && activePrefix.size() >= 2) {
activeSuggestions = buildCompletionList(completionPool, activePrefix);
} else {
activeSuggestions.clear();
auto rebuildCompletionPool = [&]() -> bool {
if (!completionPoolDirty) return false;
completionPool.clear();
std::unordered_set<std::string> poolSet;
const auto& langDef = scriptTextEditor.GetLanguageDefinition();
for (const auto& kw : langDef.mKeywords) {
poolSet.insert(kw);
}
for (const auto& identifier : langDef.mIdentifiers) {
poolSet.insert(identifier.first);
}
for (const auto& identifier : langDef.mPreprocIdentifiers) {
poolSet.insert(identifier.first);
}
for (const auto& entry : scriptingCompletions) {
poolSet.insert(entry);
}
for (const auto& entry : symbols) {
poolSet.insert(entry);
}
for (const auto& entry : bufferIdentifiers) {
poolSet.insert(entry);
}
for (const auto& entry : bufferFunctions) {
poolSet.insert(entry);
}
for (const auto& entry : bufferDefines) {
poolSet.insert(entry);
}
completionPool.assign(poolSet.begin(), poolSet.end());
std::sort(completionPool.begin(), completionPool.end());
completionPoolDirty = false;
return true;
};
auto extractPrefixAtCursor = [&]() {
TextEditor::Coordinates cursor = scriptTextEditor.GetCursorPosition();
std::string prefix = scriptTextEditor.GetWordAtPublic(cursor);
if (prefix.empty() && cursor.mColumn > 0) {
TextEditor::Coordinates prev(cursor.mLine, cursor.mColumn - 1);
prefix = scriptTextEditor.GetWordAtPublic(prev);
}
return prefix;
};
auto updateSuggestions = [&](const std::string& prefix) {
activePrefix = prefix;
if (!activePrefix.empty() && activePrefix.size() >= 2) {
activeSuggestions = buildCompletionList(completionPool, activePrefix);
} else {
activeSuggestions.clear();
}
};
bool poolRebuiltBeforeRender = rebuildCompletionPool();
std::string prefixBeforeRender = extractPrefixAtCursor();
if (poolRebuiltBeforeRender || prefixBeforeRender != activePrefix) {
updateSuggestions(prefixBeforeRender);
}
bool tabPressed = ImGui::IsKeyPressed(ImGuiKey_Tab);
@@ -668,18 +728,13 @@ void Engine::renderScriptingWindow() {
if (newHash != symbolsHash) {
symbolsHash = newHash;
symbols = buildSymbolList(scriptEditorState.buffer);
completionPoolDirty = true;
}
TextEditor::Coordinates cursorAfter = scriptTextEditor.GetCursorPosition();
activePrefix = scriptTextEditor.GetWordAtPublic(cursorAfter);
if (activePrefix.empty() && cursorAfter.mColumn > 0) {
TextEditor::Coordinates prev(cursorAfter.mLine, cursorAfter.mColumn - 1);
activePrefix = scriptTextEditor.GetWordAtPublic(prev);
}
if (!activePrefix.empty() && activePrefix.size() >= 2) {
activeSuggestions = buildCompletionList(completionPool, activePrefix);
} else {
activeSuggestions.clear();
bool poolRebuiltAfterRender = rebuildCompletionPool();
std::string prefixAfterRender = extractPrefixAtCursor();
if (poolRebuiltAfterRender || prefixAfterRender != activePrefix) {
updateSuggestions(prefixAfterRender);
}
bool canCompleteNow = !activeSuggestions.empty() && !ImGui::GetIO().KeyShift;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,8 @@ private:
int viewportWidth = 800;
int viewportHeight = 600;
bool gizmoHistoryCaptured = false;
bool worldUiGizmoHistoryCaptured = false;
bool gameUiGizmoHistoryCaptured = false;
// Standalone material inspection cache
std::string inspectedMaterialPath;
MaterialProperties inspectedMaterial;
@@ -82,6 +84,9 @@ private:
uint64_t runtimeScriptBindingsCachedVersion = 0;
int selectedObjectId = -1; // primary selection (last)
std::vector<int> selectedObjectIds; // multi-select
std::vector<int> hierarchyVisibleOrder;
int hierarchyRangeAnchorId = -1;
std::vector<SceneObject> objectClipboard;
int nextObjectId = 0;
// Gizmo state
@@ -165,6 +170,12 @@ private:
float fileBrowserSidebarWidth = 220.0f;
bool showFileBrowserSidebar = true;
std::vector<fs::path> fileBrowserFavorites;
struct ExternalFileDropEvent {
fs::path path;
double mouseX = 0.0;
double mouseY = 0.0;
};
std::vector<ExternalFileDropEvent> pendingExternalFileDrops;
std::string uiStylePresetName = "Current";
enum class UIAnimationMode {
Off = 0,
@@ -341,11 +352,21 @@ private:
bool meshEditLoaded = false;
bool meshEditDirty = false;
bool meshEditExtrudeMode = false;
bool meshEditAutoUV = true;
bool meshEditTriangleSelection = false;
std::string meshEditPath;
RawMeshAsset meshEditAsset;
std::vector<int> meshEditSelectedVertices;
std::vector<int> meshEditSelectedEdges; // indices into generated edge list
std::vector<int> meshEditSelectedFaces; // indices into mesh faces
int meshEditActiveMaterialSlot = 0;
float meshEditInsetAmount = 0.2f;
float meshEditExtrudeAmount = 0.3f;
float meshEditBevelAmount = 0.1f;
float meshEditGridSnap = 0.1f;
float meshEditUvMoveStep = 0.1f;
float meshEditUvScaleStep = 1.1f;
float meshEditUvRotateStep = 15.0f;
struct UIAnimationState {
float hover = 0.0f;
float active = 0.0f;
@@ -390,8 +411,8 @@ private:
std::unordered_map<int, UiCanvas3DContext> uiCanvas3DContexts;
std::unordered_map<int, UiCanvas3DInput> uiCanvas3DInputs;
bool consoleWrapText = true;
enum class MeshEditSelectionMode { Vertex = 0, Edge = 1, Face = 2 };
MeshEditSelectionMode meshEditSelectionMode = MeshEditSelectionMode::Vertex;
enum class MeshEditSelectionMode { Object = 0, Vertex = 1, Edge = 2, Face = 3, UV = 4 };
MeshEditSelectionMode meshEditSelectionMode = MeshEditSelectionMode::Object;
ScriptCompiler scriptCompiler;
ScriptRuntime scriptRuntime;
ManagedScriptRuntime managedRuntime;
@@ -551,6 +572,34 @@ private:
bool scriptTextEditorReady = false;
char scriptingFilter[128] = "";
bool scriptingFilesDirty = true;
struct RuntimeAnimKey {
float time = 0.0f;
float value = 0.0f;
float inTangent = 0.0f;
float outTangent = 0.0f;
int interpolation = 1; // 0=constant, 1=linear, 2=cubic
};
struct RuntimeAnimTrack {
std::string propertyId;
std::vector<RuntimeAnimKey> keys;
};
struct RuntimeAnimBinding {
std::string path;
std::vector<RuntimeAnimTrack> tracks;
};
struct RuntimeAnimationClip {
std::string name;
float duration = 2.0f;
float sampleRate = 30.0f;
std::vector<RuntimeAnimBinding> bindings;
};
struct RuntimeClipCacheEntry {
RuntimeAnimationClip clip;
fs::file_time_type lastWriteTime{};
bool hasWriteTime = false;
bool valid = false;
};
std::unordered_map<std::string, RuntimeClipCacheEntry> runtimeAnimationClipCache;
// Private methods
SceneObject* getSelectedObject();
glm::vec3 getSelectionCenterWorld(bool worldSpace) const;
@@ -625,9 +674,16 @@ private:
void updatePlayerController(float delta);
void updateRigidbody2D(float delta);
void updateCameraFollow2D(float delta);
void updateRuntimeAnimations(float delta);
void updateAIAgents(float delta);
void updateSkeletalAnimations(float delta);
void updateSkinningMatrices();
fs::path resolveAnimationClipPath(const std::string& storedPath) const;
bool loadRuntimeAnimationClipFile(const fs::path& path, RuntimeAnimationClip& outClip) const;
const RuntimeAnimationClip* getRuntimeAnimationClip(const std::string& storedPath);
float getAnimationDurationForObject(const SceneObject& obj) const;
bool applyRuntimeAnimatedProperty(SceneObject& obj, const std::string& propertyId, float value);
void evaluateRuntimeAnimationClip(const RuntimeAnimationClip& clip, float time, int rootObjectId);
void rebuildSkeletalBindings();
void initUIStylePresets();
int findUIStylePreset(const std::string& name) const;
@@ -687,8 +743,11 @@ private:
// Scene object management
void addObject(ObjectType type, const std::string& baseName);
void duplicateSelected();
void copySelected();
void pasteClipboard();
void selectAllObjects();
void deleteSelected();
void setParent(int childId, int parentId);
void setParent(int childId, int parentId, int beforeSiblingId = -1);
void loadMaterialFromFile(SceneObject& obj);
void saveMaterialToFile(const SceneObject& obj);
void recordState(const char* reason = "");
@@ -729,6 +788,9 @@ public:
void shutdown();
SceneObject* findObjectByName(const std::string& name);
SceneObject* findObjectById(int id);
bool propagateObjectRenameReferences(const std::string& oldName,
const std::string& newName,
int renamedObjectId = -1);
bool loadPixelSpriteDocument(const fs::path& imagePath);
bool savePixelSpriteDocument();
fs::path resolveScriptBinary(const fs::path& sourcePath);
@@ -767,6 +829,18 @@ public:
bool setAudioLoopFromScript(int id, bool loop);
bool setAudioVolumeFromScript(int id, float volume);
bool setAudioClipFromScript(int id, const std::string& path);
bool playAudioOneShotFromScript(int id, const std::string& clipPath, float volumeScale = 1.0f);
bool hasAnimationFromScript(int id) const;
bool playAnimationFromScript(int id, bool restart = true);
bool stopAnimationFromScript(int id, bool resetTime = true);
bool pauseAnimationFromScript(int id, bool pause);
bool reverseAnimationFromScript(int id, bool restartIfStopped = true);
bool setAnimationTimeFromScript(int id, float timeSeconds);
float getAnimationTimeFromScript(int id) const;
bool isAnimationPlayingFromScript(int id) const;
bool setAnimationLoopFromScript(int id, bool loop);
bool setAnimationPlaySpeedFromScript(int id, float speed);
bool setAnimationPlayOnAwakeFromScript(int id, bool playOnAwake);
void syncLocalTransform(SceneObject& obj);
const std::vector<SceneObject>& getSceneObjects() const { return sceneObjects; }
const std::vector<UIStylePreset>& getUIStylePresets() const { return uiStylePresets; }

View File

@@ -78,6 +78,59 @@ int modu_ctx_add_rigidbody_impulse(ScriptContext* ctx, float x, float y, float z
return ctx->AddRigidbodyImpulse(glm::vec3(x, y, z)) ? 1 : 0;
}
int modu_ctx_has_animation(ScriptContext* ctx) {
return (ctx && ctx->HasAnimation()) ? 1 : 0;
}
int modu_ctx_play_animation(ScriptContext* ctx, int restart) {
if (!ctx) return 0;
return ctx->PlayAnimation(restart != 0) ? 1 : 0;
}
int modu_ctx_stop_animation(ScriptContext* ctx, int resetTime) {
if (!ctx) return 0;
return ctx->StopAnimation(resetTime != 0) ? 1 : 0;
}
int modu_ctx_pause_animation(ScriptContext* ctx, int pause) {
if (!ctx) return 0;
return ctx->PauseAnimation(pause != 0) ? 1 : 0;
}
int modu_ctx_reverse_animation(ScriptContext* ctx, int restartIfStopped) {
if (!ctx) return 0;
return ctx->ReverseAnimation(restartIfStopped != 0) ? 1 : 0;
}
int modu_ctx_set_animation_time(ScriptContext* ctx, float timeSeconds) {
if (!ctx) return 0;
return ctx->SetAnimationTime(timeSeconds) ? 1 : 0;
}
float modu_ctx_get_animation_time(ScriptContext* ctx) {
if (!ctx) return 0.0f;
return ctx->GetAnimationTime();
}
int modu_ctx_is_animation_playing(ScriptContext* ctx) {
return (ctx && ctx->IsAnimationPlaying()) ? 1 : 0;
}
int modu_ctx_set_animation_loop(ScriptContext* ctx, int loop) {
if (!ctx) return 0;
return ctx->SetAnimationLoop(loop != 0) ? 1 : 0;
}
int modu_ctx_set_animation_play_speed(ScriptContext* ctx, float speed) {
if (!ctx) return 0;
return ctx->SetAnimationPlaySpeed(speed) ? 1 : 0;
}
int modu_ctx_set_animation_play_on_awake(ScriptContext* ctx, int playOnAwake) {
if (!ctx) return 0;
return ctx->SetAnimationPlayOnAwake(playOnAwake != 0) ? 1 : 0;
}
float modu_ctx_get_setting_float(ScriptContext* ctx, const char* key, float fallback) {
if (!ctx || !key) return fallback;
return ctx->GetSettingFloat(key, fallback);
@@ -221,7 +274,7 @@ int modu_imgui_accept_scene_object_drop(int* outId) {
ManagedNativeApi BuildManagedNativeApi() {
ManagedNativeApi api;
api.version = 4;
api.version = 5;
api.getObjectId = modu_ctx_get_object_id;
api.getPosition = modu_ctx_get_position;
api.setPosition = modu_ctx_set_position;
@@ -258,5 +311,16 @@ ManagedNativeApi BuildManagedNativeApi() {
api.imguiBeginCombo = modu_imgui_begin_combo;
api.imguiEndCombo = modu_imgui_end_combo;
api.imguiSelectable = modu_imgui_selectable;
api.hasAnimation = modu_ctx_has_animation;
api.playAnimation = modu_ctx_play_animation;
api.stopAnimation = modu_ctx_stop_animation;
api.pauseAnimation = modu_ctx_pause_animation;
api.reverseAnimation = modu_ctx_reverse_animation;
api.setAnimationTime = modu_ctx_set_animation_time;
api.getAnimationTime = modu_ctx_get_animation_time;
api.isAnimationPlaying = modu_ctx_is_animation_playing;
api.setAnimationLoop = modu_ctx_set_animation_loop;
api.setAnimationPlaySpeed = modu_ctx_set_animation_play_speed;
api.setAnimationPlayOnAwake = modu_ctx_set_animation_play_on_awake;
return api;
}

View File

@@ -17,6 +17,17 @@ int modu_ctx_set_rigidbody_velocity(ScriptContext* ctx, float x, float y, float
int modu_ctx_get_rigidbody_velocity(ScriptContext* ctx, float* x, float* y, float* z);
int modu_ctx_add_rigidbody_force(ScriptContext* ctx, float x, float y, float z);
int modu_ctx_add_rigidbody_impulse(ScriptContext* ctx, float x, float y, float z);
int modu_ctx_has_animation(ScriptContext* ctx);
int modu_ctx_play_animation(ScriptContext* ctx, int restart);
int modu_ctx_stop_animation(ScriptContext* ctx, int resetTime);
int modu_ctx_pause_animation(ScriptContext* ctx, int pause);
int modu_ctx_reverse_animation(ScriptContext* ctx, int restartIfStopped);
int modu_ctx_set_animation_time(ScriptContext* ctx, float timeSeconds);
float modu_ctx_get_animation_time(ScriptContext* ctx);
int modu_ctx_is_animation_playing(ScriptContext* ctx);
int modu_ctx_set_animation_loop(ScriptContext* ctx, int loop);
int modu_ctx_set_animation_play_speed(ScriptContext* ctx, float speed);
int modu_ctx_set_animation_play_on_awake(ScriptContext* ctx, int playOnAwake);
float modu_ctx_get_setting_float(ScriptContext* ctx, const char* key, float fallback);
int modu_ctx_get_setting_bool(ScriptContext* ctx, const char* key, int fallback);
void modu_ctx_get_setting_string(ScriptContext* ctx, const char* key, const char* fallback,
@@ -83,6 +94,18 @@ struct ManagedNativeApi {
int (*imguiBeginCombo)(const char* label, const char* previewValue) = nullptr;
void (*imguiEndCombo)() = nullptr;
int (*imguiSelectable)(const char* label, int selected) = nullptr;
// Version 5+ additions.
int (*hasAnimation)(ScriptContext* ctx) = nullptr;
int (*playAnimation)(ScriptContext* ctx, int restart) = nullptr;
int (*stopAnimation)(ScriptContext* ctx, int resetTime) = nullptr;
int (*pauseAnimation)(ScriptContext* ctx, int pause) = nullptr;
int (*reverseAnimation)(ScriptContext* ctx, int restartIfStopped) = nullptr;
int (*setAnimationTime)(ScriptContext* ctx, float timeSeconds) = nullptr;
float (*getAnimationTime)(ScriptContext* ctx) = nullptr;
int (*isAnimationPlaying)(ScriptContext* ctx) = nullptr;
int (*setAnimationLoop)(ScriptContext* ctx, int loop) = nullptr;
int (*setAnimationPlaySpeed)(ScriptContext* ctx, float speed) = nullptr;
int (*setAnimationPlayOnAwake)(ScriptContext* ctx, int playOnAwake) = nullptr;
};
ManagedNativeApi BuildManagedNativeApi();

File diff suppressed because it is too large Load Diff

View File

@@ -36,7 +36,11 @@ struct RawMeshAsset {
std::vector<glm::vec3> positions;
std::vector<glm::vec3> normals;
std::vector<glm::vec2> uvs;
std::vector<glm::u32vec2> edges;
std::vector<glm::u32vec3> faces;
std::vector<uint32_t> faceMaterialIndices;
std::vector<uint32_t> faceIslandIds;
std::vector<std::string> materialSlots;
glm::vec3 boundsMin = glm::vec3(FLT_MAX);
glm::vec3 boundsMax = glm::vec3(-FLT_MAX);
bool hasNormals = false;

View File

@@ -3,6 +3,7 @@
#include <unordered_set>
#include <array>
#include <cstdio>
#include <chrono>
#include <sstream>
#pragma region Local Path Helpers
@@ -16,6 +17,40 @@ fs::path normalizePath(const fs::path& p) {
return canonical.lexically_normal();
}
std::string trimCopy(const std::string& value) {
size_t start = 0;
while (start < value.size() && std::isspace(static_cast<unsigned char>(value[start]))) start++;
size_t end = value.size();
while (end > start && std::isspace(static_cast<unsigned char>(value[end - 1]))) end--;
return value.substr(start, end - start);
}
std::vector<std::string> splitTokens(const std::string& input, char delim) {
std::vector<std::string> out;
std::stringstream ss(input);
std::string part;
while (std::getline(ss, part, delim)) {
out.push_back(part);
}
return out;
}
fs::path resolveManifestPath(const fs::path& projectRoot, const std::string& token) {
if (token.empty()) return {};
const fs::path parsed(token);
if (parsed.is_absolute()) {
return normalizePath(parsed);
}
return normalizePath(projectRoot / parsed);
}
std::string toManifestPathToken(const fs::path& pathValue, const fs::path& projectRoot) {
if (pathValue.empty()) return "";
std::error_code ec;
fs::path rel = fs::relative(pathValue, projectRoot, ec);
return (!ec ? rel : pathValue).generic_string();
}
bool containsPath(const std::vector<fs::path>& haystack, const fs::path& needle) {
std::string target = normalizePath(needle).string();
for (const auto& entry : haystack) {
@@ -59,6 +94,117 @@ fs::path guessIncludeDir(const fs::path& repoRoot, const std::string& includeRel
return normalizePath(repoRoot);
}
bool copyDirectoryRecursive(const fs::path& sourceRoot,
const fs::path& destinationRoot,
std::string& outError) {
if (!fs::exists(sourceRoot)) {
outError = "Source folder does not exist: " + sourceRoot.string();
return false;
}
std::error_code ec;
fs::create_directories(destinationRoot, ec);
if (ec) {
outError = "Failed to create destination folder: " + destinationRoot.string();
return false;
}
for (const auto& entry : fs::recursive_directory_iterator(sourceRoot, ec)) {
if (ec) {
outError = "Failed to read source folder: " + sourceRoot.string();
return false;
}
const fs::path rel = fs::relative(entry.path(), sourceRoot, ec);
if (ec) {
outError = "Failed to compute relative path for " + entry.path().string();
return false;
}
const fs::path dst = destinationRoot / rel;
if (entry.is_directory()) {
fs::create_directories(dst, ec);
if (ec) {
outError = "Failed to create directory: " + dst.string();
return false;
}
} else if (entry.is_regular_file()) {
fs::create_directories(dst.parent_path(), ec);
if (ec) {
outError = "Failed to create directory: " + dst.parent_path().string();
return false;
}
fs::copy_file(entry.path(), dst, fs::copy_options::overwrite_existing, ec);
if (ec) {
outError = "Failed to copy file: " + entry.path().string();
return false;
}
}
}
return true;
}
bool parseExternalManifestPackage(const fs::path& projectRoot,
const std::string& payload,
bool modupak,
PackageInfo& outPackage) {
const auto parts = splitTokens(payload, '|');
if (parts.size() < 4) return false;
outPackage = PackageInfo{};
outPackage.id = trimCopy(parts[0]);
outPackage.name = trimCopy(parts[1]);
outPackage.external = true;
outPackage.modupak = modupak;
outPackage.localPath = resolveManifestPath(projectRoot, trimCopy(parts[3]));
outPackage.description = modupak ? "External package from .modupak" : "External package from git";
const std::string sourceToken = trimCopy(parts[2]);
if (modupak) {
outPackage.modupakSourcePath = resolveManifestPath(projectRoot, sourceToken);
} else {
outPackage.gitUrl = sourceToken;
}
if (parts.size() > 4) {
for (const auto& inc : splitTokens(parts[4], ';')) {
const std::string cleaned = trimCopy(inc);
if (cleaned.empty()) continue;
outPackage.includeDirs.push_back(resolveManifestPath(projectRoot, cleaned));
}
}
auto readCleanList = [](const std::string& raw) {
std::vector<std::string> vals;
for (const std::string& token : splitTokens(raw, ';')) {
const std::string cleaned = trimCopy(token);
if (!cleaned.empty()) vals.push_back(cleaned);
}
return vals;
};
if (parts.size() > 5) {
outPackage.defines = readCleanList(parts[5]);
}
if (parts.size() > 6) {
outPackage.linuxLibs = readCleanList(parts[6]);
}
if (parts.size() > 7) {
outPackage.windowsLibs = readCleanList(parts[7]);
}
if (parts.size() > 8) {
const std::string desc = trimCopy(parts[8]);
if (!desc.empty()) outPackage.description = desc;
}
if (outPackage.id.empty()) return false;
if (outPackage.name.empty()) outPackage.name = outPackage.id;
if (outPackage.includeDirs.empty()) {
outPackage.includeDirs.push_back(guessIncludeDir(outPackage.localPath, "include"));
}
return true;
}
} // namespace
#pragma endregion
@@ -108,7 +254,8 @@ bool PackageManager::remove(const std::string& id) {
}
if (pkg->external) {
std::string log;
if (isGitRepo(projectRoot)) {
const bool isGitExternal = !pkg->gitUrl.empty();
if (isGitExternal && isGitRepo(projectRoot)) {
std::error_code ec;
fs::path relPath = fs::relative(pkg->localPath, projectRoot, ec);
std::string rel = (!ec ? relPath : pkg->localPath).generic_string();
@@ -267,44 +414,40 @@ void PackageManager::loadManifest() {
std::string cleaned = trim(line);
if (cleaned.empty() || cleaned[0] == '#') continue;
std::string id;
if (cleaned.rfind("package=", 0) == 0) {
id = cleaned.substr(8);
const std::string id = trim(cleaned.substr(8));
if (!id.empty() && !isInstalled(id) && findPackage(id)) {
installedIds.push_back(id);
}
continue;
}
bool isGitLine = false;
bool isModuPakLine = false;
std::string payload;
if (cleaned.rfind("git=", 0) == 0) {
auto payload = cleaned.substr(4);
auto parts = split(payload, '|');
if (parts.size() < 4) continue;
PackageInfo pkg;
pkg.id = parts[0];
pkg.name = parts[1];
pkg.description = "External package from git";
pkg.external = true;
pkg.gitUrl = parts[2];
fs::path relPath = parts[3];
pkg.localPath = normalizePath(projectRoot / relPath);
payload = cleaned.substr(4);
isGitLine = true;
} else if (cleaned.rfind("modupak=", 0) == 0) {
payload = cleaned.substr(8);
isModuPakLine = true;
}
std::vector<std::string> includeTokens;
if (parts.size() > 4) includeTokens = split(parts[4], ';');
for (const auto& inc : includeTokens) {
if (inc.empty()) continue;
pkg.includeDirs.push_back(normalizePath(projectRoot / inc));
}
std::vector<std::string> defTokens;
if (parts.size() > 5) defTokens = split(parts[5], ';');
pkg.defines = defTokens;
if (parts.size() > 6) pkg.linuxLibs = split(parts[6], ';');
if (parts.size() > 7) pkg.windowsLibs = split(parts[7], ';');
if (!isGitLine && !isModuPakLine) {
continue;
}
registry.push_back(pkg);
if (!isInstalled(pkg.id)) {
installedIds.push_back(pkg.id);
}
PackageInfo pkg;
if (!parseExternalManifestPackage(projectRoot, payload, isModuPakLine, pkg)) {
continue;
}
registry.erase(std::remove_if(registry.begin(), registry.end(),
[&](const PackageInfo& entry) { return entry.id == pkg.id && entry.external; }),
registry.end());
registry.push_back(pkg);
if (!isInstalled(pkg.id)) {
installedIds.push_back(pkg.id);
}
}
}
@@ -316,6 +459,9 @@ void PackageManager::saveManifest() const {
file << "# Modularity package manifest\n";
file << "# Add optional script-time dependencies here\n";
file << "# package=<id>\n";
file << "# git=<id>|<name>|<url>|<path>|<includeDirs>|<defines>|<linuxLibs>|<windowsLibs>|<description>\n";
file << "# modupak=<id>|<name>|<bundlePath>|<path>|<includeDirs>|<defines>|<linuxLibs>|<windowsLibs>|<description>\n";
for (const auto& id : installedIds) {
const PackageInfo* pkg = findPackage(id);
if (!pkg) continue;
@@ -334,17 +480,21 @@ void PackageManager::saveManifest() const {
relIncludes.push_back((!ec ? rel : inc).generic_string());
}
file << "git=" << pkg->id << "|"
<< pkg->name << "|"
<< pkg->gitUrl << "|";
const char* sourceTag = pkg->modupak ? "modupak=" : "git=";
file << sourceTag << pkg->id << "|"
<< pkg->name << "|";
if (pkg->modupak) {
file << toManifestPathToken(pkg->modupakSourcePath, projectRoot) << "|";
} else {
file << pkg->gitUrl << "|";
}
std::error_code ec;
fs::path relPath = fs::relative(pkg->localPath, projectRoot, ec);
file << ((!ec ? relPath : pkg->localPath).generic_string()) << "|";
file << toManifestPathToken(pkg->localPath, projectRoot) << "|";
file << join(relIncludes, ';') << "|";
file << join(pkg->defines, ';') << "|";
file << join(pkg->linuxLibs, ';') << "|";
file << join(pkg->windowsLibs, ';') << "\n";
file << join(pkg->windowsLibs, ';') << "|";
file << pkg->description << "\n";
}
}
#pragma endregion
@@ -508,12 +658,177 @@ bool PackageManager::installGitPackage(const std::string& url,
return true;
}
bool PackageManager::installModuPak(const fs::path& modupakPath, std::string& outId) {
lastError.clear();
outId.clear();
if (!ensureProjectRoot()) {
lastError = "Project root not set.";
return false;
}
if (modupakPath.empty()) {
lastError = ".modupak path is required.";
return false;
}
const fs::path source = normalizePath(modupakPath);
if (!fs::exists(source)) {
lastError = ".modupak was not found: " + source.string();
return false;
}
if (source.extension() != ".modupak") {
lastError = "Expected a .modupak bundle.";
return false;
}
const fs::path tempRoot = projectRoot / "Library" / "Temp" / "ModuPakInstall";
std::error_code ec;
fs::create_directories(tempRoot, ec);
if (ec) {
lastError = "Failed to create temp folder: " + ec.message();
return false;
}
const auto now = std::chrono::steady_clock::now().time_since_epoch().count();
const fs::path unpackRoot = tempRoot / ("extract_" + std::to_string(now));
fs::create_directories(unpackRoot, ec);
if (ec) {
lastError = "Failed to create extraction folder: " + ec.message();
return false;
}
auto cleanup = [&]() {
std::error_code removeEc;
fs::remove_all(unpackRoot, removeEc);
};
if (fs::is_directory(source)) {
std::string copyError;
if (!copyDirectoryRecursive(source, unpackRoot, copyError)) {
cleanup();
lastError = copyError;
return false;
}
} else {
std::string tarLog;
const std::string extractCmd =
"tar -xf \"" + source.string() + "\" -C \"" + unpackRoot.string() + "\" 2>&1";
if (!runCommand(extractCmd, tarLog)) {
cleanup();
lastError = "Failed to extract .modupak with tar.\n" + tarLog;
return false;
}
}
const fs::path manifestFile = unpackRoot / "manifest.modu";
if (!fs::exists(manifestFile)) {
cleanup();
lastError = ".modupak is missing manifest.modu.";
return false;
}
PackageInfo pkg;
pkg.external = true;
pkg.modupak = true;
pkg.modupakSourcePath = source;
pkg.description = "External package from .modupak";
std::vector<std::string> includeHints;
std::ifstream manifest(manifestFile);
if (!manifest.is_open()) {
cleanup();
lastError = "Failed to open .modupak manifest.";
return false;
}
std::string line;
while (std::getline(manifest, line)) {
const std::string cleaned = trim(line);
if (cleaned.empty() || cleaned[0] == '#') continue;
auto readValue = [&](size_t prefixSize) {
return trim(cleaned.substr(prefixSize));
};
if (cleaned.rfind("id=", 0) == 0) {
pkg.id = slugify(readValue(3));
} else if (cleaned.rfind("name=", 0) == 0) {
pkg.name = readValue(5);
} else if (cleaned.rfind("description=", 0) == 0) {
pkg.description = readValue(12);
} else if (cleaned.rfind("includeDir=", 0) == 0) {
const std::string value = readValue(11);
if (!value.empty()) includeHints.push_back(value);
} else if (cleaned.rfind("define=", 0) == 0) {
const std::string value = readValue(7);
if (!value.empty()) pkg.defines.push_back(value);
} else if (cleaned.rfind("linux.linkLib=", 0) == 0) {
const std::string value = readValue(14);
if (!value.empty()) pkg.linuxLibs.push_back(value);
} else if (cleaned.rfind("win.linkLib=", 0) == 0) {
const std::string value = readValue(12);
if (!value.empty()) pkg.windowsLibs.push_back(value);
} else if (cleaned.rfind("windows.linkLib=", 0) == 0) {
const std::string value = readValue(16);
if (!value.empty()) pkg.windowsLibs.push_back(value);
}
}
if (pkg.name.empty()) {
pkg.name = source.stem().string();
}
if (pkg.id.empty()) {
pkg.id = slugify(pkg.name);
}
if (pkg.id.empty()) {
cleanup();
lastError = "Unable to resolve package id from .modupak.";
return false;
}
if (isInstalled(pkg.id)) {
cleanup();
lastError = "Package already installed: " + pkg.id;
return false;
}
pkg.localPath = normalizePath(packagesFolder() / pkg.id);
if (fs::exists(pkg.localPath)) {
cleanup();
lastError = "Package target path already exists: " + pkg.localPath.string();
return false;
}
const fs::path payloadRoot = unpackRoot / "payload";
const fs::path sourceContent =
(fs::exists(payloadRoot) && fs::is_directory(payloadRoot)) ? payloadRoot : unpackRoot;
std::string installCopyError;
if (!copyDirectoryRecursive(sourceContent, pkg.localPath, installCopyError)) {
cleanup();
lastError = installCopyError;
return false;
}
for (const std::string& includeRel : includeHints) {
pkg.includeDirs.push_back(normalizePath(pkg.localPath / includeRel));
}
if (pkg.includeDirs.empty()) {
pkg.includeDirs.push_back(guessIncludeDir(pkg.localPath, "include"));
}
registry.push_back(pkg);
installedIds.push_back(pkg.id);
saveManifest();
outId = pkg.id;
cleanup();
return true;
}
bool PackageManager::checkGitStatus(const std::string& id, std::string& outStatus) {
lastError.clear();
outStatus.clear();
const PackageInfo* pkg = findPackage(id);
if (!pkg || !pkg->external) {
lastError = "Package is not external or not found.";
if (!pkg || !pkg->external || pkg->gitUrl.empty()) {
lastError = "Package is not a Git package or was not found.";
return false;
}
@@ -532,8 +847,8 @@ bool PackageManager::updateGitPackage(const std::string& id, std::string& outLog
lastError.clear();
outLog.clear();
const PackageInfo* pkg = findPackage(id);
if (!pkg || !pkg->external) {
lastError = "Package is not external or not found.";
if (!pkg || !pkg->external || pkg->gitUrl.empty()) {
lastError = "Package is not a Git package or was not found.";
return false;
}
std::string cmd = "git -C \"" + pkg->localPath.string() + "\" pull --ff-only";

View File

@@ -10,7 +10,9 @@ struct PackageInfo {
std::string description;
bool builtIn = false;
bool external = false;
bool modupak = false;
std::string gitUrl;
fs::path modupakSourcePath;
fs::path localPath; // Absolute path for external packages
fs::path includeHint; // Absolute include root for external packages
std::vector<fs::path> includeDirs;
@@ -36,6 +38,7 @@ public:
const std::string& nameHint,
const std::string& includeRel,
std::string& outId);
bool installModuPak(const fs::path& modupakPath, std::string& outId);
bool checkGitStatus(const std::string& id, std::string& outStatus);
bool updateGitPackage(const std::string& id, std::string& outLog);
bool remove(const std::string& id);

View File

@@ -538,7 +538,7 @@ void PhysicsSystem::onPlayStart(const std::vector<SceneObject>& objects) {
cookInfos.reserve(objects.size());
for (const auto& obj : objects) {
if (!obj.enabled || !obj.hasCollider || !obj.collider.enabled) continue;
if (!IsObjectEnabledInHierarchy(obj) || !obj.hasCollider || !obj.collider.enabled) continue;
if (obj.collider.type == ColliderType::Box || obj.collider.type == ColliderType::Capsule) continue;
const OBJLoader::LoadedMesh* meshInfo = nullptr;
if (obj.hasRenderer && obj.renderType == RenderType::OBJMesh && obj.meshId >= 0) {
@@ -574,7 +574,7 @@ void PhysicsSystem::onPlayStart(const std::vector<SceneObject>& objects) {
}
for (const auto& obj : objects) {
if (!obj.enabled) continue;
if (!IsObjectEnabledInHierarchy(obj)) continue;
ActorRecord rec = createActorFor(obj);
if (!rec.actor) continue;
mScene->addActor(*rec.actor);
@@ -802,7 +802,7 @@ void PhysicsSystem::simulate(float deltaTime, std::vector<SceneObject>& objects)
if (!rec.actor) continue;
auto it = std::find_if(objects.begin(), objects.end(), [id](const SceneObject& o) { return o.id == id; });
if (it == objects.end()) continue;
if (!it->enabled) {
if (!IsObjectEnabledInHierarchy(*it)) {
rec.actor->setActorFlag(PxActorFlag::eDISABLE_SIMULATION, true);
continue;
} else {
@@ -825,7 +825,7 @@ void PhysicsSystem::simulate(float deltaTime, std::vector<SceneObject>& objects)
if (!rec.actor || !rec.isDynamic || rec.isKinematic) continue;
PxTransform pose = rec.actor->getGlobalPose();
auto it = std::find_if(objects.begin(), objects.end(), [id](const SceneObject& o) { return o.id == id; });
if (it == objects.end() || !it->enabled) continue;
if (it == objects.end() || !IsObjectEnabledInHierarchy(*it)) continue;
it->position = ToGlmVec3(pose.p);
if (it->hasPlayerController && it->playerController.enabled) {

View File

@@ -86,6 +86,9 @@ bool Project::create() {
std::ofstream packageManifest(projectPath / "packages.modu");
packageManifest << "# Modularity package manifest\n";
packageManifest << "# package=<id>\n";
packageManifest << "# git=<id>|<name>|<url>|<path>|<includeDirs>|<defines>|<linuxLibs>|<windowsLibs>|<description>\n";
packageManifest << "# modupak=<id>|<name>|<bundlePath>|<path>|<includeDirs>|<defines>|<linuxLibs>|<windowsLibs>|<description>\n";
packageManifest.close();
currentSceneName = "Main";
@@ -566,14 +569,25 @@ bool SceneSerializer::saveScene(const fs::path& filePath,
}
file << "hasAnimation=" << (obj.hasAnimation ? 1 : 0) << "\n";
if (obj.hasAnimation) {
file << "animEnabled=" << (obj.animation.enabled ? 1 : 0) << "\n";
file << "animClipLength=" << obj.animation.clipLength << "\n";
file << "animPlaySpeed=" << obj.animation.playSpeed << "\n";
file << "animLoop=" << (obj.animation.loop ? 1 : 0) << "\n";
file << "animApplyOnScrub=" << (obj.animation.applyOnScrub ? 1 : 0) << "\n";
file << "animKeyCount=" << obj.animation.keyframes.size() << "\n";
for (size_t ki = 0; ki < obj.animation.keyframes.size(); ++ki) {
const auto& key = obj.animation.keyframes[ki];
AnimationComponent animation = obj.animation;
NormalizeAnimationClipSlots(animation);
file << "animEnabled=" << (animation.enabled ? 1 : 0) << "\n";
file << "animClipAsset=" << animation.clipAssetPath << "\n";
file << "animClipCount=" << animation.clips.size() << "\n";
file << "animActiveClipIndex=" << animation.activeClipIndex << "\n";
for (size_t ci = 0; ci < animation.clips.size(); ++ci) {
const auto& clip = animation.clips[ci];
file << "animClip" << ci << "_name=" << clip.name << "\n";
file << "animClip" << ci << "_asset=" << clip.assetPath << "\n";
}
file << "animClipLength=" << animation.clipLength << "\n";
file << "animPlaySpeed=" << animation.playSpeed << "\n";
file << "animLoop=" << (animation.loop ? 1 : 0) << "\n";
file << "animPlayOnAwake=" << (animation.playOnAwake ? 1 : 0) << "\n";
file << "animApplyOnScrub=" << (animation.applyOnScrub ? 1 : 0) << "\n";
file << "animKeyCount=" << animation.keyframes.size() << "\n";
for (size_t ki = 0; ki < animation.keyframes.size(); ++ki) {
const auto& key = animation.keyframes[ki];
file << "animKey" << ki << "_time=" << key.time << "\n";
file << "animKey" << ki << "_pos=" << key.position.x << "," << key.position.y << "," << key.position.z << "\n";
file << "animKey" << ki << "_rot=" << key.rotation.x << "," << key.rotation.y << "," << key.rotation.z << "\n";
@@ -583,16 +597,16 @@ bool SceneSerializer::saveScene(const fs::path& filePath,
file << "animKey" << ki << "_in=" << key.bezierIn.x << "," << key.bezierIn.y << "\n";
file << "animKey" << ki << "_out=" << key.bezierOut.x << "," << key.bezierOut.y << "\n";
}
file << "animEventCount=" << obj.animation.events.size() << "\n";
for (size_t ei = 0; ei < obj.animation.events.size(); ++ei) {
const auto& evt = obj.animation.events[ei];
file << "animEventCount=" << animation.events.size() << "\n";
for (size_t ei = 0; ei < animation.events.size(); ++ei) {
const auto& evt = animation.events[ei];
file << "animEvent" << ei << "_time=" << evt.time << "\n";
file << "animEvent" << ei << "_id=" << evt.eventId << "\n";
file << "animEvent" << ei << "_payload=" << evt.payload << "\n";
}
file << "animTrackCount=" << obj.animation.tracks.size() << "\n";
for (size_t ti = 0; ti < obj.animation.tracks.size(); ++ti) {
const auto& track = obj.animation.tracks[ti];
file << "animTrackCount=" << animation.tracks.size() << "\n";
for (size_t ti = 0; ti < animation.tracks.size(); ++ti) {
const auto& track = animation.tracks[ti];
file << "animTrack" << ti << "_enabled=" << (track.enabled ? 1 : 0) << "\n";
file << "animTrack" << ti << "_path=" << track.path << "\n";
file << "animTrack" << ti << "_label=" << track.label << "\n";
@@ -676,6 +690,7 @@ bool SceneSerializer::saveScene(const fs::path& filePath,
file << "uiPosition=" << obj.ui.position.x << "," << obj.ui.position.y << "\n";
file << "uiRotation=" << obj.ui.rotation << "\n";
file << "uiSize=" << obj.ui.size.x << "," << obj.ui.size.y << "\n";
file << "uiMaskChildren=" << (obj.ui.maskChildren ? 1 : 0) << "\n";
file << "uiSliderValue=" << obj.ui.sliderValue << "\n";
file << "uiSliderMin=" << obj.ui.sliderMin << "\n";
file << "uiSliderMax=" << obj.ui.sliderMax << "\n";
@@ -686,6 +701,12 @@ bool SceneSerializer::saveScene(const fs::path& filePath,
file << "uiButtonStyle=" << static_cast<int>(obj.ui.buttonStyle) << "\n";
file << "uiStylePreset=" << obj.ui.stylePreset << "\n";
file << "uiTextScale=" << obj.ui.textScale << "\n";
file << "uiTextWrap=" << (obj.ui.textAutoWrap ? 1 : 0) << "\n";
file << "uiTextHAlign=" << static_cast<int>(obj.ui.textHAlign) << "\n";
file << "uiTextVAlign=" << static_cast<int>(obj.ui.textVAlign) << "\n";
file << "uiTextEffectFlags=" << obj.ui.textEffectFlags << "\n";
file << "uiTextEffectSpeed=" << obj.ui.textEffectSpeed << "\n";
file << "uiTextEffectIntensity=" << obj.ui.textEffectIntensity << "\n";
file << "uiRenderIn3D=" << (obj.ui.renderIn3D ? 1 : 0) << "\n";
file << "uiRenderTargetSize=" << obj.ui.renderTargetSize.x << "," << obj.ui.renderTargetSize.y << "\n";
file << "uiSpriteSheetEnabled=" << (obj.ui.spriteSheetEnabled ? 1 : 0) << "\n";
@@ -695,6 +716,14 @@ bool SceneSerializer::saveScene(const fs::path& filePath,
file << "uiSpriteSheetLoop=" << (obj.ui.spriteSheetLoop ? 1 : 0) << "\n";
file << "uiSpriteCustomFramesEnabled=" << (obj.ui.spriteCustomFramesEnabled ? 1 : 0) << "\n";
file << "uiSpriteSourceSize=" << obj.ui.spriteSourceWidth << "," << obj.ui.spriteSourceHeight << "\n";
file << "uiNineSliceEnabled=" << (obj.ui.nineSliceEnabled ? 1 : 0) << "\n";
file << "uiNineSliceBorder="
<< obj.ui.nineSliceBorder.x << ","
<< obj.ui.nineSliceBorder.y << ","
<< obj.ui.nineSliceBorder.z << ","
<< obj.ui.nineSliceBorder.w << "\n";
file << "uiNineSliceTileEdges=" << (obj.ui.nineSliceTileEdges ? 1 : 0) << "\n";
file << "uiNineSliceTileCenter=" << (obj.ui.nineSliceTileCenter ? 1 : 0) << "\n";
if (!obj.ui.spriteCustomFrames.empty()) {
file << "uiSpriteCustomFrames=";
for (size_t i = 0; i < obj.ui.spriteCustomFrames.size(); ++i) {
@@ -1121,9 +1150,18 @@ const std::unordered_map<std::string, KeyHandler>& GetSceneObjectKeyHandlers() {
{"aiAgentDebugDrawPath", +[](SceneObject& obj, const std::string& value) { obj.aiAgent.debugDrawPath = std::stoi(value) != 0; }},
{"hasAnimation", +[](SceneObject& obj, const std::string& value) { obj.hasAnimation = std::stoi(value) != 0; }},
{"animEnabled", +[](SceneObject& obj, const std::string& value) { obj.animation.enabled = std::stoi(value) != 0; }},
{"animClipAsset", +[](SceneObject& obj, const std::string& value) { obj.animation.clipAssetPath = value; }},
{"animClipCount", +[](SceneObject& obj, const std::string& value) {
int count = std::stoi(value);
obj.animation.clips.resize(std::max(0, count));
}},
{"animActiveClipIndex", +[](SceneObject& obj, const std::string& value) {
obj.animation.activeClipIndex = std::stoi(value);
}},
{"animClipLength", +[](SceneObject& obj, const std::string& value) { obj.animation.clipLength = std::stof(value); }},
{"animPlaySpeed", +[](SceneObject& obj, const std::string& value) { obj.animation.playSpeed = std::stof(value); }},
{"animLoop", +[](SceneObject& obj, const std::string& value) { obj.animation.loop = std::stoi(value) != 0; }},
{"animPlayOnAwake", +[](SceneObject& obj, const std::string& value) { obj.animation.playOnAwake = std::stoi(value) != 0; }},
{"animApplyOnScrub", +[](SceneObject& obj, const std::string& value) { obj.animation.applyOnScrub = std::stoi(value) != 0; }},
{"animKeyCount", +[](SceneObject& obj, const std::string& value) {
int count = std::stoi(value);
@@ -1203,6 +1241,7 @@ const std::unordered_map<std::string, KeyHandler>& GetSceneObjectKeyHandlers() {
{"uiPosition", +[](SceneObject& obj, const std::string& value) { ParseVec2(value, obj.ui.position); }},
{"uiRotation", +[](SceneObject& obj, const std::string& value) { obj.ui.rotation = std::stof(value); }},
{"uiSize", +[](SceneObject& obj, const std::string& value) { ParseVec2(value, obj.ui.size); }},
{"uiMaskChildren", +[](SceneObject& obj, const std::string& value) { obj.ui.maskChildren = (std::stoi(value) != 0); }},
{"uiSliderValue", +[](SceneObject& obj, const std::string& value) { obj.ui.sliderValue = std::stof(value); }},
{"uiSliderMin", +[](SceneObject& obj, const std::string& value) { obj.ui.sliderMin = std::stof(value); }},
{"uiSliderMax", +[](SceneObject& obj, const std::string& value) { obj.ui.sliderMax = std::stof(value); }},
@@ -1213,6 +1252,16 @@ const std::unordered_map<std::string, KeyHandler>& GetSceneObjectKeyHandlers() {
{"uiButtonStyle", +[](SceneObject& obj, const std::string& value) { obj.ui.buttonStyle = static_cast<UIButtonStyle>(std::stoi(value)); }},
{"uiStylePreset", +[](SceneObject& obj, const std::string& value) { obj.ui.stylePreset = value; }},
{"uiTextScale", +[](SceneObject& obj, const std::string& value) { obj.ui.textScale = std::stof(value); }},
{"uiTextWrap", +[](SceneObject& obj, const std::string& value) { obj.ui.textAutoWrap = (std::stoi(value) != 0); }},
{"uiTextHAlign", +[](SceneObject& obj, const std::string& value) {
obj.ui.textHAlign = static_cast<UITextHAlign>(std::clamp(std::stoi(value), 0, 2));
}},
{"uiTextVAlign", +[](SceneObject& obj, const std::string& value) {
obj.ui.textVAlign = static_cast<UITextVAlign>(std::clamp(std::stoi(value), 0, 2));
}},
{"uiTextEffectFlags", +[](SceneObject& obj, const std::string& value) { obj.ui.textEffectFlags = std::stoi(value); }},
{"uiTextEffectSpeed", +[](SceneObject& obj, const std::string& value) { obj.ui.textEffectSpeed = std::max(0.01f, std::stof(value)); }},
{"uiTextEffectIntensity", +[](SceneObject& obj, const std::string& value) { obj.ui.textEffectIntensity = std::max(0.0f, std::stof(value)); }},
{"uiRenderIn3D", +[](SceneObject& obj, const std::string& value) { obj.ui.renderIn3D = (std::stoi(value) != 0); }},
{"uiRenderTargetSize", +[](SceneObject& obj, const std::string& value) { ParseIVec2(value, obj.ui.renderTargetSize); }},
{"uiSpriteSheetEnabled", +[](SceneObject& obj, const std::string& value) { obj.ui.spriteSheetEnabled = (std::stoi(value) != 0); }},
@@ -1232,6 +1281,22 @@ const std::unordered_map<std::string, KeyHandler>& GetSceneObjectKeyHandlers() {
obj.ui.spriteSourceWidth = std::max(0, size.x);
obj.ui.spriteSourceHeight = std::max(0, size.y);
}},
{"uiNineSliceEnabled", +[](SceneObject& obj, const std::string& value) {
obj.ui.nineSliceEnabled = (std::stoi(value) != 0);
}},
{"uiNineSliceBorder", +[](SceneObject& obj, const std::string& value) {
ParseVec4(value, obj.ui.nineSliceBorder);
obj.ui.nineSliceBorder.x = std::max(0.0f, obj.ui.nineSliceBorder.x);
obj.ui.nineSliceBorder.y = std::max(0.0f, obj.ui.nineSliceBorder.y);
obj.ui.nineSliceBorder.z = std::max(0.0f, obj.ui.nineSliceBorder.z);
obj.ui.nineSliceBorder.w = std::max(0.0f, obj.ui.nineSliceBorder.w);
}},
{"uiNineSliceTileEdges", +[](SceneObject& obj, const std::string& value) {
obj.ui.nineSliceTileEdges = (std::stoi(value) != 0);
}},
{"uiNineSliceTileCenter", +[](SceneObject& obj, const std::string& value) {
obj.ui.nineSliceTileCenter = (std::stoi(value) != 0);
}},
{"uiSpriteCustomFrames", +[](SceneObject& obj, const std::string& value) {
obj.ui.spriteCustomFrames.clear();
std::stringstream ss(value);
@@ -1464,6 +1529,20 @@ bool SceneSerializer::loadScene(const fs::path& filePath,
auto handlerIt = handlers.find(key);
if (handlerIt != handlers.end()) {
handlerIt->second(*currentObj, value);
} else if (key.rfind("animClip", 0) == 0) {
size_t underscore = key.find('_');
if (underscore != std::string::npos && underscore > 8) {
int clipIdx = std::stoi(key.substr(8, underscore - 8));
if (clipIdx >= 0 && clipIdx < static_cast<int>(currentObj->animation.clips.size())) {
std::string sub = key.substr(underscore + 1);
auto& clip = currentObj->animation.clips[clipIdx];
if (sub == "name") {
clip.name = value;
} else if (sub == "asset") {
clip.assetPath = value;
}
}
}
} else if (key.rfind("animKey", 0) == 0) {
size_t underscore = key.find('_');
if (underscore != std::string::npos && underscore > 7) {
@@ -1619,6 +1698,9 @@ bool SceneSerializer::loadScene(const fs::path& filePath,
file.close();
for (auto& obj : objects) {
if (obj.hasAnimation) {
NormalizeAnimationClipSlots(obj.animation);
}
obj.type = GetLegacyTypeFromComponents(obj);
}
outVersion = sceneVersion;

View File

@@ -1,6 +1,7 @@
#include "Rendering.h"
#include "Camera.h"
#include "ModelLoader.h"
#include <algorithm>
#include <array>
#include <cstring>
#include <future>
@@ -16,6 +17,21 @@ extern float vertices[288];
extern float mirrorPlaneVertices[48];
namespace {
struct Runtime2DRenderCounters {
uint64_t textureBindCount = 0;
uint64_t stateBindCount = 0;
};
Runtime2DRenderCounters gRuntime2DRenderCounters;
inline void Runtime2DCountTextureBind() {
++gRuntime2DRenderCounters.textureBindCount;
}
inline void Runtime2DCountStateBind() {
++gRuntime2DRenderCounters.stateBindCount;
}
glm::vec4 BuildSpriteUvRect(const SceneObject& obj) {
if (obj.ui.spriteCustomFramesEnabled &&
!obj.ui.spriteCustomFrames.empty() &&
@@ -211,6 +227,15 @@ bool IsFiniteVec3(const glm::vec3& value) {
return std::isfinite(value.x) && std::isfinite(value.y) && std::isfinite(value.z);
}
uint64_t HashStringFNV1a(const std::string& value) {
uint64_t hash = 1469598103934665603ull;
for (unsigned char c : value) {
hash ^= static_cast<uint64_t>(c);
hash *= 1099511628211ull;
}
return hash;
}
bool TryGetObjectLocalBounds(const SceneObject& obj, glm::vec3& outCenter, glm::vec3& outExtents) {
outCenter = glm::vec3(0.0f);
switch (obj.renderType) {
@@ -364,7 +389,7 @@ const std::vector<float>& GetPrimitiveTriangleVertices(RenderType type) {
}
bool IsStaticMergeCandidate(const SceneObject& obj) {
if (!obj.enabled || !HasRendererComponent(obj)) return false;
if (!IsObjectEnabledInHierarchy(obj) || !HasRendererComponent(obj)) return false;
if (obj.hasUI || obj.hasLight || obj.hasCamera || obj.hasPostFX) return false;
if (obj.hasRigidbody || obj.hasRigidbody2D || obj.hasPlayerController) return false;
if (obj.hasAnimation || obj.hasSkeletalAnimation) return false;
@@ -463,6 +488,20 @@ void AppendTransformedTriangleVertices(const std::vector<float>& src, const glm:
}
} // namespace
void ModuRuntime2DRenderCounters_Reset() {
gRuntime2DRenderCounters = Runtime2DRenderCounters{};
}
void ModuRuntime2DRenderCounters_Read(uint64_t* outTextureBindCount,
uint64_t* outStateBindCount) {
if (outTextureBindCount) {
*outTextureBindCount = gRuntime2DRenderCounters.textureBindCount;
}
if (outStateBindCount) {
*outStateBindCount = gRuntime2DRenderCounters.stateBindCount;
}
}
// Global OBJ loader instance
OBJLoader g_objLoader;
@@ -1615,7 +1654,26 @@ void Renderer::releaseRenderTarget(RenderTarget& target) {
void Renderer::updateMirrorTargets(const Camera& camera, const std::vector<SceneObject>& sceneObjects, int width, int height, float fovDeg, float nearPlane, float farPlane) {
if (camera.orthographic || sceneObjects.empty() || width <= 0 || height <= 0) return;
bool hasEnabledMirror = false;
for (const auto& obj : sceneObjects) {
if (IsObjectEnabledInHierarchy(obj) && obj.hasRenderer && obj.renderType == RenderType::Mirror) {
hasEnabledMirror = true;
break;
}
}
if (!hasEnabledMirror) {
if (!mirrorTargets.empty()) {
for (auto& entry : mirrorTargets) {
releaseRenderTarget(entry.second);
}
mirrorTargets.clear();
mirrorUpdateStates.clear();
}
return;
}
std::unordered_set<int> active;
active.reserve(mirrorTargets.size() + 4);
const double nowSec = glfwGetTime();
GLint prevFBO = 0;
glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFBO);
@@ -1634,7 +1692,7 @@ void Renderer::updateMirrorTargets(const Camera& camera, const std::vector<Scene
};
for (const auto& obj : sceneObjects) {
if (!obj.enabled || !obj.hasRenderer || obj.renderType != RenderType::Mirror) continue;
if (!IsObjectEnabledInHierarchy(obj) || !obj.hasRenderer || obj.renderType != RenderType::Mirror) continue;
active.insert(obj.id);
glm::vec3 n = planeNormal(obj);
@@ -1877,18 +1935,22 @@ void Renderer::resize(int w, int h) {
void Renderer::beginRender(const glm::mat4& view, const glm::mat4& proj, const glm::vec3& cameraPos) {
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
Runtime2DCountStateBind();
glViewport(0, 0, currentWidth, currentHeight);
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
displayTexture = viewportTexture;
shader->use();
Runtime2DCountStateBind();
shader->setMat4("view", view);
shader->setMat4("projection", proj);
shader->setVec3("viewPos", cameraPos);
shader->setFloat("uTime", static_cast<float>(glfwGetTime()));
texture1->Bind(GL_TEXTURE0);
texture2->Bind(GL_TEXTURE1);
Runtime2DCountTextureBind();
Runtime2DCountTextureBind();
shader->setInt("texture1", 0);
shader->setInt("overlayTex", 1);
shader->setInt("normalMap", 2);
@@ -2084,7 +2146,7 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
candidates.reserve(sceneObjects.size());
for (const auto& obj : sceneObjects) {
if (!obj.enabled || !obj.hasLight || !obj.light.enabled) continue;
if (!IsObjectEnabledInHierarchy(obj) || !obj.hasLight || !obj.light.enabled) continue;
if (obj.light.type == LightType::Directional) {
LightUniform l;
l.type = 0;
@@ -2162,11 +2224,19 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
}
if (lights.size() < kMaxLights && !candidates.empty()) {
std::sort(candidates.begin(), candidates.end(),
[](const LightCandidate& a, const LightCandidate& b) {
if (a.distSq != b.distSq) return a.distSq < b.distSq;
return a.id < b.id;
});
const auto candidateLess = [](const LightCandidate& a, const LightCandidate& b) {
if (a.distSq != b.distSq) return a.distSq < b.distSq;
return a.id < b.id;
};
const size_t remainingSlots = kMaxLights - lights.size();
if (candidates.size() > remainingSlots) {
std::nth_element(candidates.begin(),
candidates.begin() + remainingSlots,
candidates.end(),
candidateLess);
candidates.resize(remainingSlots);
}
std::sort(candidates.begin(), candidates.end(), candidateLess);
for (const auto& c : candidates) {
if (lights.size() >= kMaxLights) break;
lights.push_back(c.light);
@@ -2187,7 +2257,10 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
map.depthCube = 0;
map.resolution = 0;
};
if (shadowDepthShader && shadowDepthShader->ID != 0) {
const bool hasShadowCasters = std::any_of(lights.begin(), lights.end(), [](const LightUniform& light) {
return light.castShadows && light.type != 0 && light.sourceId >= 0;
});
if (shadowDepthShader && shadowDepthShader->ID != 0 && hasShadowCasters) {
GLint prevViewport[4] = {0, 0, width, height};
GLint prevFbo = 0;
GLboolean blendWasEnabled = glIsEnabled(GL_BLEND);
@@ -2281,7 +2354,7 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
shadowDepthShader->setMat4("lightSpaceMatrix", shadowProj * shadowViews[face]);
for (const auto& obj : sceneObjects) {
if (!obj.enabled || !HasRendererComponent(obj)) continue;
if (!IsObjectEnabledInHierarchy(obj) || !HasRendererComponent(obj)) continue;
if (obj.renderType == RenderType::Mirror) continue;
if (obj.renderType == RenderType::Sprite) continue;
if (obj.id == light.sourceId) continue;
@@ -2349,19 +2422,40 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
renderSkybox(view, proj);
rebuildStaticMergeBatches(sceneObjects);
const std::string emptyPath;
auto combineHash = [](uint64_t seed, uint64_t value) {
seed ^= value + 0x9e3779b97f4a7c15ull + (seed << 6) + (seed >> 2);
return seed;
};
auto buildOpaqueSortKey = [&](const std::string& vert,
const std::string& frag,
const std::string& material,
const std::string& albedo,
const std::string& overlay,
const std::string& normal) {
uint64_t key = 1469598103934665603ull;
key = combineHash(key, HashStringFNV1a(vert));
key = combineHash(key, HashStringFNV1a(frag));
key = combineHash(key, HashStringFNV1a(material));
key = combineHash(key, HashStringFNV1a(albedo));
key = combineHash(key, HashStringFNV1a(overlay));
key = combineHash(key, HashStringFNV1a(normal));
return key;
};
struct RenderItem {
const SceneObject* obj = nullptr;
const StaticMergeBatch* staticBatch = nullptr;
Mesh* mesh = nullptr;
glm::mat4 model = glm::mat4(1.0f);
glm::vec3 sortCenter = glm::vec3(0.0f);
std::string vertPath;
std::string fragPath;
const std::string* vertPath = nullptr;
const std::string* fragPath = nullptr;
MaterialProperties material;
std::string materialPath;
std::string albedoTexturePath;
std::string overlayTexturePath;
std::string normalMapPath;
const std::string* materialPath = nullptr;
const std::string* albedoTexturePath = nullptr;
const std::string* overlayTexturePath = nullptr;
const std::string* normalMapPath = nullptr;
bool useOverlay = false;
int boneLimit = 0;
int availableBones = 0;
@@ -2370,10 +2464,11 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
bool sortOpaque = false;
bool unlit = false;
float cameraDistanceSq = 0.0f;
uint64_t opaqueSortKey = 0;
};
std::vector<RenderItem> drawItems;
drawItems.reserve(sceneObjects.size());
drawItems.reserve(sceneObjects.size() + staticMergeBatches.size());
auto gatherRenderItemsRange = [&](size_t beginIndex, size_t endIndex) {
std::vector<RenderItem> localItems;
@@ -2381,7 +2476,7 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
for (size_t objIndex = beginIndex; objIndex < endIndex; ++objIndex) {
const auto& obj = sceneObjects[objIndex];
if (!obj.enabled) continue;
if (!IsObjectEnabledInHierarchy(obj)) continue;
if (!drawMirrorObjects && obj.hasRenderer && obj.renderType == RenderType::Mirror) continue;
if (!HasRendererComponent(obj)) continue;
if (staticMergeSourceIds.find(obj.id) != staticMergeSourceIds.end()) continue;
@@ -2402,13 +2497,13 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
item.mesh = mesh;
item.model = model;
item.sortCenter = obj.position;
item.vertPath = obj.vertexShaderPath;
item.fragPath = obj.fragmentShaderPath;
item.vertPath = &obj.vertexShaderPath;
item.fragPath = &obj.fragmentShaderPath;
item.material = obj.material;
item.materialPath = obj.materialPath;
item.albedoTexturePath = obj.albedoTexturePath;
item.overlayTexturePath = obj.overlayTexturePath;
item.normalMapPath = obj.normalMapPath;
item.materialPath = &obj.materialPath;
item.albedoTexturePath = &obj.albedoTexturePath;
item.overlayTexturePath = &obj.overlayTexturePath;
item.normalMapPath = &obj.normalMapPath;
item.useOverlay = obj.useOverlay;
item.boneLimit = obj.skeletal.maxBones;
item.availableBones = static_cast<int>(obj.skeletal.finalMatrices.size());
@@ -2417,8 +2512,8 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
item.boneLimit > 0 && item.availableBones > item.boneLimit;
item.wantsGpuSkinning = obj.hasSkeletalAnimation && obj.skeletal.enabled &&
obj.skeletal.useGpuSkinning && !needsFallback;
if (item.vertPath.empty() && item.wantsGpuSkinning) {
item.vertPath = skinnedVertPath;
if (item.wantsGpuSkinning && item.vertPath->empty()) {
item.vertPath = &skinnedVertPath;
}
item.isUiCanvas3D = obj.hasUI && obj.ui.type == UIElementType::Canvas && obj.ui.renderIn3D;
item.sortOpaque = item.material.alpha >= 0.999f &&
@@ -2426,6 +2521,15 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
obj.renderType != RenderType::Sprite &&
obj.renderType != RenderType::Mirror;
item.unlit = obj.renderType == RenderType::Mirror || obj.renderType == RenderType::Sprite || item.isUiCanvas3D;
if (item.sortOpaque) {
item.opaqueSortKey = buildOpaqueSortKey(
*item.vertPath,
*item.fragPath,
*item.materialPath,
*item.albedoTexturePath,
*item.overlayTexturePath,
*item.normalMapPath);
}
glm::vec3 toCamera = item.sortCenter - camera.position;
item.cameraDistanceSq = glm::dot(toCamera, toCamera);
localItems.push_back(std::move(item));
@@ -2435,9 +2539,9 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
};
const unsigned int hardwareThreads = std::thread::hardware_concurrency();
const size_t minItemsPerTask = 128;
const size_t minItemsPerTask = 2048;
const size_t maxTaskCountByWork = std::max<size_t>(1, sceneObjects.size() / minItemsPerTask);
const size_t taskCount = (hardwareThreads > 1 && sceneObjects.size() >= minItemsPerTask * 2)
const size_t taskCount = (hardwareThreads > 4 && sceneObjects.size() >= minItemsPerTask * 2)
? std::min<size_t>(hardwareThreads, maxTaskCountByWork)
: 1;
@@ -2480,15 +2584,24 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
item.mesh = batch.mesh.get();
item.model = glm::mat4(1.0f);
item.sortCenter = batch.boundsCenter;
item.vertPath = batch.vertPath;
item.fragPath = batch.fragPath;
item.vertPath = &batch.vertPath;
item.fragPath = &batch.fragPath;
item.material = batch.material;
item.materialPath = batch.materialPath;
item.albedoTexturePath = batch.albedoTexturePath;
item.overlayTexturePath = batch.overlayTexturePath;
item.normalMapPath = batch.normalMapPath;
item.materialPath = &batch.materialPath;
item.albedoTexturePath = &batch.albedoTexturePath;
item.overlayTexturePath = &batch.overlayTexturePath;
item.normalMapPath = &batch.normalMapPath;
item.useOverlay = batch.useOverlay;
item.sortOpaque = item.material.alpha >= 0.999f;
if (item.sortOpaque) {
item.opaqueSortKey = buildOpaqueSortKey(
*item.vertPath,
*item.fragPath,
*item.materialPath,
*item.albedoTexturePath,
*item.overlayTexturePath,
*item.normalMapPath);
}
glm::vec3 toCamera = item.sortCenter - camera.position;
item.cameraDistanceSq = glm::dot(toCamera, toCamera);
drawItems.push_back(std::move(item));
@@ -2505,12 +2618,7 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
int bId = b.obj ? b.obj->id : -1;
return aId < bId;
}
if (a.vertPath != b.vertPath) return a.vertPath < b.vertPath;
if (a.fragPath != b.fragPath) return a.fragPath < b.fragPath;
if (a.materialPath != b.materialPath) return a.materialPath < b.materialPath;
if (a.albedoTexturePath != b.albedoTexturePath) return a.albedoTexturePath < b.albedoTexturePath;
if (a.overlayTexturePath != b.overlayTexturePath) return a.overlayTexturePath < b.overlayTexturePath;
if (a.normalMapPath != b.normalMapPath) return a.normalMapPath < b.normalMapPath;
if (a.opaqueSortKey != b.opaqueSortKey) return a.opaqueSortKey < b.opaqueSortKey;
int aId = a.obj ? a.obj->id : -1;
int bId = b.obj ? b.obj->id : -1;
return aId < bId;
@@ -2527,6 +2635,7 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
if (currentActiveTexture != static_cast<GLint>(unit)) {
glActiveTexture(unit);
currentActiveTexture = static_cast<GLint>(unit);
Runtime2DCountStateBind();
}
int slot = static_cast<int>(unit - GL_TEXTURE0);
if (slot >= 0 && slot < static_cast<int>(boundTexture2D.size()) &&
@@ -2534,20 +2643,69 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
return;
}
glBindTexture(GL_TEXTURE_2D, textureId);
Runtime2DCountTextureBind();
if (slot >= 0 && slot < static_cast<int>(boundTexture2D.size())) {
boundTexture2D[slot] = textureId;
}
};
struct LightUniformNameCache {
std::array<std::string, kMaxLights> type;
std::array<std::string, kMaxLights> dir;
std::array<std::string, kMaxLights> pos;
std::array<std::string, kMaxLights> color;
std::array<std::string, kMaxLights> intensity;
std::array<std::string, kMaxLights> range;
std::array<std::string, kMaxLights> innerCos;
std::array<std::string, kMaxLights> outerCos;
std::array<std::string, kMaxLights> areaSize;
std::array<std::string, kMaxLights> areaFade;
std::array<std::string, kMaxLights> shadowMap;
std::array<std::string, kMaxLights> shadowMode;
std::array<std::string, kMaxLights> shadowBias;
std::array<std::string, kMaxLights> shadowSoftness;
std::array<std::string, kMaxLights> shadowFar;
};
static const LightUniformNameCache kLightNames = []() {
LightUniformNameCache names;
for (size_t i = 0; i < kMaxLights; ++i) {
const std::string idx = "[" + std::to_string(i) + "]";
names.type[i] = "lightTypeArr" + idx;
names.dir[i] = "lightDirArr" + idx;
names.pos[i] = "lightPosArr" + idx;
names.color[i] = "lightColorArr" + idx;
names.intensity[i] = "lightIntensityArr" + idx;
names.range[i] = "lightRangeArr" + idx;
names.innerCos[i] = "lightInnerCosArr" + idx;
names.outerCos[i] = "lightOuterCosArr" + idx;
names.areaSize[i] = "lightAreaSizeArr" + idx;
names.areaFade[i] = "lightAreaFadeArr" + idx;
names.shadowMap[i] = "lightShadowMapArr" + idx;
names.shadowMode[i] = "lightShadowModeArr" + idx;
names.shadowBias[i] = "lightShadowBiasArr" + idx;
names.shadowSoftness[i] = "lightShadowSoftnessArr" + idx;
names.shadowFar[i] = "lightShadowFarArr" + idx;
}
return names;
}();
Shader* currentShader = nullptr;
for (const RenderItem& item : drawItems) {
const SceneObject* objPtr = item.obj;
Shader* active = getShader(item.vertPath, item.fragPath);
const std::string& vertPath = item.vertPath ? *item.vertPath : emptyPath;
const std::string& fragPath = item.fragPath ? *item.fragPath : emptyPath;
const std::string& materialPath = item.materialPath ? *item.materialPath : emptyPath;
const std::string& albedoTexturePath = item.albedoTexturePath ? *item.albedoTexturePath : emptyPath;
const std::string& overlayTexturePath = item.overlayTexturePath ? *item.overlayTexturePath : emptyPath;
const std::string& normalMapPath = item.normalMapPath ? *item.normalMapPath : emptyPath;
Shader* active = getShader(vertPath, fragPath);
if (!active) continue;
shader = active;
if (currentShader != shader) {
currentShader = shader;
shader->use();
Runtime2DCountStateBind();
shader->setMat4("view", view);
shader->setMat4("projection", proj);
shader->setVec3("viewPos", camera.position);
@@ -2563,28 +2721,27 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
shader->setInt("lightCount", static_cast<int>(lights.size()));
for (size_t i = 0; i < lights.size() && i < kMaxLights; ++i) {
const auto& l = lights[i];
std::string idx = "[" + std::to_string(i) + "]";
shader->setInt("lightTypeArr" + idx, l.type);
shader->setVec3("lightDirArr" + idx, l.dir);
shader->setVec3("lightPosArr" + idx, l.pos);
shader->setVec3("lightColorArr" + idx, l.color);
shader->setFloat("lightIntensityArr" + idx, l.intensity);
shader->setFloat("lightRangeArr" + idx, l.range);
shader->setFloat("lightInnerCosArr" + idx, l.inner);
shader->setFloat("lightOuterCosArr" + idx, l.outer);
shader->setVec2("lightAreaSizeArr" + idx, l.areaSize);
shader->setFloat("lightAreaFadeArr" + idx, l.areaFade);
shader->setInt("lightShadowMapArr" + idx, l.shadowMapIndex);
shader->setInt("lightShadowModeArr" + idx, (l.shadowMapIndex >= 0) ? l.shadowMode : 0);
shader->setFloat("lightShadowBiasArr" + idx, l.shadowBias);
shader->setFloat("lightShadowSoftnessArr" + idx, l.shadowSoftness);
shader->setFloat("lightShadowFarArr" + idx, l.shadowFar);
shader->setInt(kLightNames.type[i], l.type);
shader->setVec3(kLightNames.dir[i], l.dir);
shader->setVec3(kLightNames.pos[i], l.pos);
shader->setVec3(kLightNames.color[i], l.color);
shader->setFloat(kLightNames.intensity[i], l.intensity);
shader->setFloat(kLightNames.range[i], l.range);
shader->setFloat(kLightNames.innerCos[i], l.inner);
shader->setFloat(kLightNames.outerCos[i], l.outer);
shader->setVec2(kLightNames.areaSize[i], l.areaSize);
shader->setFloat(kLightNames.areaFade[i], l.areaFade);
shader->setInt(kLightNames.shadowMap[i], l.shadowMapIndex);
shader->setInt(kLightNames.shadowMode[i], (l.shadowMapIndex >= 0) ? l.shadowMode : 0);
shader->setFloat(kLightNames.shadowBias[i], l.shadowBias);
shader->setFloat(kLightNames.shadowSoftness[i], l.shadowSoftness);
shader->setFloat(kLightNames.shadowFar[i], l.shadowFar);
}
}
bool hasMaterialAsset = !item.materialPath.empty();
bool hasCustomShader = !item.vertPath.empty() || !item.fragPath.empty();
bool hasAnySurfaceInput = !item.albedoTexturePath.empty() || !item.overlayTexturePath.empty() || !item.normalMapPath.empty();
bool hasMaterialAsset = !materialPath.empty();
bool hasCustomShader = !vertPath.empty() || !fragPath.empty();
bool hasAnySurfaceInput = !albedoTexturePath.empty() || !overlayTexturePath.empty() || !normalMapPath.empty();
bool missingMaterialAndShader = !hasMaterialAsset && !hasCustomShader && !hasAnySurfaceInput;
shader->setBool("unlit", item.unlit || missingMaterialAndShader);
@@ -2625,8 +2782,8 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
if (missingMaterialAndShader && missingMaterialFallbackTexture != 0) {
bindTexture2D(GL_TEXTURE0, missingMaterialFallbackTexture);
} else {
if (!item.albedoTexturePath.empty()) {
if (auto* t = getTexture(item.albedoTexturePath, item.material.textureFilter)) baseTex = t;
if (!albedoTexturePath.empty()) {
if (auto* t = getTexture(albedoTexturePath, item.material.textureFilter)) baseTex = t;
}
if (baseTex) bindTexture2D(GL_TEXTURE0, baseTex->GetID());
}
@@ -2640,8 +2797,8 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
overlayUsed = true;
}
}
if (!overlayUsed && item.useOverlay && !item.overlayTexturePath.empty()) {
if (auto* t = getTexture(item.overlayTexturePath, item.material.textureFilter)) {
if (!overlayUsed && item.useOverlay && !overlayTexturePath.empty()) {
if (auto* t = getTexture(overlayTexturePath, item.material.textureFilter)) {
bindTexture2D(GL_TEXTURE1, t->GetID());
overlayUsed = true;
}
@@ -2652,8 +2809,8 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
shader->setBool("hasOverlay", overlayUsed);
bool normalUsed = false;
if (!item.normalMapPath.empty()) {
if (auto* t = getTexture(item.normalMapPath, item.material.textureFilter)) {
if (!normalMapPath.empty()) {
if (auto* t = getTexture(normalMapPath, item.material.textureFilter)) {
bindTexture2D(GL_TEXTURE2, t->GetID());
normalUsed = true;
}
@@ -2675,9 +2832,11 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
bool doubleSided = objPtr && (objPtr->renderType == RenderType::Sprite || objPtr->renderType == RenderType::Mirror);
if (doubleSided) {
glDisable(GL_CULL_FACE);
Runtime2DCountStateBind();
} else {
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);
Runtime2DCountStateBind();
}
bool wantsTransparency = !item.sortOpaque;
@@ -2686,18 +2845,23 @@ void Renderer::renderSceneInternal(const Camera& camera, const std::vector<Scene
if (!currentBlendEnabled) {
glEnable(GL_BLEND);
currentBlendEnabled = true;
Runtime2DCountStateBind();
}
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
Runtime2DCountStateBind();
} else if (currentBlendEnabled) {
glDisable(GL_BLEND);
currentBlendEnabled = false;
Runtime2DCountStateBind();
}
if ((wantsTransparency || item.isUiCanvas3D) && currentDepthMask) {
glDepthMask(GL_FALSE);
currentDepthMask = false;
Runtime2DCountStateBind();
} else if (!(wantsTransparency || item.isUiCanvas3D) && !currentDepthMask) {
glDepthMask(GL_TRUE);
currentDepthMask = true;
Runtime2DCountStateBind();
}
recordMeshDraw();
item.mesh->draw();
@@ -2830,22 +2994,23 @@ unsigned int Renderer::applyPostProcessing(const Camera& camera, const std::vect
postStats.activeEffectCount = CountEnabledPostEffects(settings);
postStats.hdrEnabled = settings.hdrEnabled;
GLint polygonMode[2] = { GL_FILL, GL_FILL };
#ifdef GL_POLYGON_MODE
glGetIntegerv(GL_POLYGON_MODE, polygonMode);
#endif
bool wireframe = (polygonMode[0] == GL_LINE || polygonMode[1] == GL_LINE);
bool wantsEffects = settings.enabled &&
(settings.hdrEnabled || settings.bloomEnabled || settings.colorAdjustEnabled ||
settings.motionBlurEnabled || settings.vignetteEnabled ||
settings.chromaticAberrationEnabled || settings.sharpenEnabled ||
settings.ambientOcclusionEnabled);
if (wireframe) {
wantsEffects = false;
postStats.activeEffectCount = 0;
postStats.hdrEnabled = false;
if (wantsEffects) {
GLint polygonMode[2] = { GL_FILL, GL_FILL };
#ifdef GL_POLYGON_MODE
glGetIntegerv(GL_POLYGON_MODE, polygonMode);
#endif
bool wireframe = (polygonMode[0] == GL_LINE || polygonMode[1] == GL_LINE);
if (wireframe) {
wantsEffects = false;
postStats.activeEffectCount = 0;
postStats.hdrEnabled = false;
}
}
if (!wantsEffects || !postShader || width <= 0 || height <= 0 || sourceTexture == 0) {
@@ -3109,7 +3274,7 @@ void Renderer::renderCollisionOverlay(const Camera& camera, const std::vector<Sc
for (const auto& obj : sceneObjects) {
if (!previewSet.empty() && previewSet.find(obj.id) == previewSet.end()) continue;
if (!obj.enabled) continue;
if (!IsObjectEnabledInHierarchy(obj)) continue;
if (!(obj.hasCollider && obj.collider.enabled) && !(obj.hasRigidbody && obj.rigidbody.enabled)) continue;
Mesh* meshToDraw = nullptr;
@@ -3287,7 +3452,7 @@ void Renderer::renderSelectionOutline(const Camera& camera, const std::vector<Sc
drawItems.reserve(selectedSet.size());
for (const auto& obj : sceneObjects) {
if (!obj.enabled) continue;
if (!IsObjectEnabledInHierarchy(obj)) continue;
if (selectedSet.find(obj.id) == selectedSet.end()) continue;
if (!HasRendererComponent(obj)) continue;
@@ -3520,4 +3685,5 @@ void Renderer::renderSelectionOutline(const Camera& camera, const std::vector<Sc
void Renderer::endRender() {
glBindFramebuffer(GL_FRAMEBUFFER, 0);
Runtime2DCountStateBind();
}

View File

@@ -42,6 +42,13 @@ public:
class OBJLoader {
public:
struct LoadedMesh {
struct SubMesh {
std::unique_ptr<Mesh> mesh;
int materialIndex = 0;
int vertexCount = 0;
int faceCount = 0;
};
std::string path;
std::unique_ptr<Mesh> mesh;
std::string name;
@@ -60,6 +67,8 @@ public:
std::vector<glm::ivec4> boneIds;
std::vector<glm::vec4> boneWeights;
std::vector<float> baseVertices;
std::vector<SubMesh> subMeshes;
std::vector<std::string> materialSlots;
};
private:

View File

@@ -81,6 +81,18 @@ enum class UIAnchor {
BottomRight = 4
};
enum class UITextHAlign {
Left = 0,
Center = 1,
Right = 2
};
enum class UITextVAlign {
Top = 0,
Middle = 1,
Bottom = 2
};
enum class UISliderStyle {
ImGui = 0,
Fill = 1,
@@ -161,17 +173,127 @@ struct AnimationPropertyTrack {
std::vector<AnimationPropertyKeyframe> keyframes;
};
struct AnimationClipSlot {
std::string name;
std::string assetPath;
};
struct AnimationComponent {
bool enabled = true;
std::string clipAssetPath;
std::vector<AnimationClipSlot> clips;
int activeClipIndex = -1;
float clipLength = 2.0f;
float playSpeed = 1.0f;
bool loop = true;
bool playOnAwake = true;
bool applyOnScrub = true;
bool runtimePlaying = false;
bool runtimePaused = false;
float runtimeTime = 0.0f;
float runtimeDirection = 1.0f;
bool runtimeInitialized = false;
std::string runtimeClipPath;
std::vector<AnimationKeyframe> keyframes;
std::vector<AnimationEvent> events;
std::vector<AnimationPropertyTrack> tracks;
};
inline std::string AnimationClipNameFromPath(const std::string& assetPath) {
if (assetPath.empty()) return "Animation";
fs::path path(assetPath);
std::string stem = path.stem().string();
if (!stem.empty()) return stem;
std::string fileName = path.filename().string();
if (!fileName.empty()) return fileName;
return "Animation";
}
inline int AnimationGetActiveClipIndex(const AnimationComponent& animation) {
if (animation.clips.empty()) return -1;
if (animation.activeClipIndex >= 0 &&
animation.activeClipIndex < static_cast<int>(animation.clips.size())) {
return animation.activeClipIndex;
}
if (!animation.clipAssetPath.empty()) {
for (int i = 0; i < static_cast<int>(animation.clips.size()); ++i) {
if (animation.clips[i].assetPath == animation.clipAssetPath) {
return i;
}
}
}
return 0;
}
inline const AnimationClipSlot* AnimationGetActiveClip(const AnimationComponent& animation) {
const int index = AnimationGetActiveClipIndex(animation);
if (index < 0 || index >= static_cast<int>(animation.clips.size())) return nullptr;
return &animation.clips[index];
}
inline AnimationClipSlot* AnimationGetActiveClip(AnimationComponent& animation) {
const int index = AnimationGetActiveClipIndex(animation);
if (index < 0 || index >= static_cast<int>(animation.clips.size())) return nullptr;
return &animation.clips[index];
}
inline std::string AnimationGetActiveClipAssetPath(const AnimationComponent& animation) {
const AnimationClipSlot* clip = AnimationGetActiveClip(animation);
if (clip) return clip->assetPath;
return animation.clipAssetPath;
}
inline std::string AnimationGetActiveClipName(const AnimationComponent& animation) {
const AnimationClipSlot* clip = AnimationGetActiveClip(animation);
if (clip == nullptr) {
if (!animation.clipAssetPath.empty()) {
return AnimationClipNameFromPath(animation.clipAssetPath);
}
return {};
}
if (!clip->name.empty()) return clip->name;
return AnimationClipNameFromPath(clip->assetPath);
}
inline void NormalizeAnimationClipSlots(AnimationComponent& animation) {
if (!animation.clipAssetPath.empty()) {
bool foundLegacyPath = false;
for (AnimationClipSlot& clip : animation.clips) {
if (clip.assetPath == animation.clipAssetPath) {
foundLegacyPath = true;
}
if (clip.name.empty()) {
clip.name = AnimationClipNameFromPath(clip.assetPath);
}
}
if (!foundLegacyPath) {
AnimationClipSlot clip;
clip.assetPath = animation.clipAssetPath;
clip.name = AnimationClipNameFromPath(clip.assetPath);
animation.clips.push_back(std::move(clip));
}
} else {
for (AnimationClipSlot& clip : animation.clips) {
if (clip.name.empty()) {
clip.name = AnimationClipNameFromPath(clip.assetPath);
}
}
}
if (animation.clips.empty()) {
animation.activeClipIndex = -1;
animation.clipAssetPath.clear();
return;
}
int resolvedIndex = AnimationGetActiveClipIndex(animation);
if (resolvedIndex < 0 || resolvedIndex >= static_cast<int>(animation.clips.size())) {
resolvedIndex = 0;
}
animation.activeClipIndex = resolvedIndex;
animation.clipAssetPath = animation.clips[resolvedIndex].assetPath;
}
struct SkeletalAnimationComponent {
bool enabled = true;
bool useGpuSkinning = true;
@@ -347,6 +469,7 @@ 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);
bool maskChildren = true; // Canvas-only: clip descendants to this canvas rect.
float rotation = 0.0f;
float sliderValue = 0.5f;
float sliderMin = 0.0f;
@@ -359,6 +482,12 @@ struct UIElementComponent {
UIButtonStyle buttonStyle = UIButtonStyle::ImGui;
std::string stylePreset = "Default";
float textScale = 1.0f;
bool textAutoWrap = true;
UITextHAlign textHAlign = UITextHAlign::Left;
UITextVAlign textVAlign = UITextVAlign::Top;
int textEffectFlags = 0;
float textEffectSpeed = 1.0f;
float textEffectIntensity = 1.0f;
bool renderIn3D = false;
glm::ivec2 renderTargetSize = glm::ivec2(512, 512);
bool spriteSheetEnabled = false;
@@ -373,6 +502,10 @@ struct UIElementComponent {
std::vector<glm::ivec4> spriteCustomFrames;
std::vector<std::string> spriteCustomFrameNames;
std::vector<glm::vec2> spriteCustomFrameScales;
bool nineSliceEnabled = false;
glm::vec4 nineSliceBorder = glm::vec4(12.0f, 12.0f, 12.0f, 12.0f); // left, right, top, bottom in source pixels
bool nineSliceTileEdges = true;
bool nineSliceTileCenter = false;
};
struct Rigidbody2DComponent {
@@ -486,6 +619,8 @@ public:
std::string name;
ObjectType type;
bool enabled = true;
// Derived each hierarchy update: true when all ancestors are locally enabled.
bool hierarchyEnabled = true;
int layer = 0;
std::string tag = "Untagged";
bool hasRenderer = false;
@@ -569,6 +704,10 @@ inline bool HasRendererComponent(const SceneObject& obj) {
return obj.hasRenderer && obj.renderType != RenderType::None;
}
inline bool IsObjectEnabledInHierarchy(const SceneObject& obj) {
return obj.enabled && obj.hierarchyEnabled;
}
inline bool HasUIComponent(const SceneObject& obj) {
return obj.hasUI && obj.ui.type != UIElementType::None;
}

View File

@@ -9,6 +9,7 @@
#include <optional>
#include <sstream>
#include <regex>
#include <unordered_set>
#if defined(_WIN32)
#include <windows.h>
#endif
@@ -75,6 +76,128 @@ namespace {
return t;
}
struct DependencyInfo {
bool hasDepFile = false;
bool missingDependency = false;
std::optional<fs::file_time_type> newestInput;
};
DependencyInfo readDependencyInfo(const fs::path& depFilePath) {
DependencyInfo info;
if (depFilePath.empty()) {
return info;
}
info.hasDepFile = true;
std::error_code ec;
if (!fs::exists(depFilePath, ec) || ec) {
info.missingDependency = true;
return info;
}
std::ifstream depFile(depFilePath, std::ios::binary);
if (!depFile.is_open()) {
info.missingDependency = true;
return info;
}
std::ostringstream depStream;
depStream << depFile.rdbuf();
std::string content = depStream.str();
if (content.empty()) {
info.missingDependency = true;
return info;
}
// Flatten line continuations used by Make-style dep files.
std::string flattened;
flattened.reserve(content.size());
for (size_t i = 0; i < content.size(); ++i) {
if (content[i] == '\\') {
if (i + 1 < content.size() && content[i + 1] == '\n') {
++i;
continue;
}
if (i + 2 < content.size() && content[i + 1] == '\r' && content[i + 2] == '\n') {
i += 2;
continue;
}
}
flattened.push_back(content[i]);
}
const size_t colonPos = flattened.find(':');
if (colonPos == std::string::npos || colonPos + 1 >= flattened.size()) {
info.missingDependency = true;
return info;
}
std::string depList = flattened.substr(colonPos + 1);
std::vector<std::string> tokens;
std::string current;
bool escaped = false;
for (char c : depList) {
if (escaped) {
current.push_back(c);
escaped = false;
continue;
}
if (c == '\\') {
escaped = true;
continue;
}
if (std::isspace(static_cast<unsigned char>(c))) {
if (!current.empty()) {
tokens.push_back(current);
current.clear();
}
continue;
}
current.push_back(c);
}
if (escaped) {
current.push_back('\\');
}
if (!current.empty()) {
tokens.push_back(current);
}
if (tokens.empty()) {
info.missingDependency = true;
return info;
}
std::unordered_set<std::string> seen;
for (const std::string& rawToken : tokens) {
if (rawToken.empty() || !seen.insert(rawToken).second) continue;
fs::path depPath = fs::path(rawToken);
if (depPath.is_relative()) {
depPath = fs::absolute(depPath, ec);
ec.clear();
}
if (!fs::exists(depPath, ec) || ec) {
info.missingDependency = true;
ec.clear();
continue;
}
auto depTime = fs::last_write_time(depPath, ec);
if (ec) {
info.missingDependency = true;
ec.clear();
continue;
}
info.newestInput = info.newestInput ? std::max(*info.newestInput, depTime) : depTime;
}
if (!info.newestInput) {
info.missingDependency = true;
}
return info;
}
#if !defined(_WIN32)
std::string posixCompileDriver(bool cxx) {
static int ccacheAvailable = -1;
@@ -372,6 +495,8 @@ bool ScriptCompiler::makeCommands(const ScriptBuildConfig& config, const fs::pat
std::string baseName = scriptAbs.stem().string();
fs::path objectPath = config.outDir / relativeParent / (baseName + ".o");
fs::path secondaryObjectPath;
fs::path dependencyPath;
fs::path secondaryDependencyPath;
fs::path binaryPath = config.outDir / relativeParent;
#ifdef _WIN32
@@ -379,6 +504,7 @@ bool ScriptCompiler::makeCommands(const ScriptBuildConfig& config, const fs::pat
binaryPath /= baseName + ".dll";
#else
binaryPath /= baseName + ".so";
dependencyPath = config.outDir / relativeParent / (baseName + ".d");
#endif
std::string extLower = scriptAbs.extension().string();
@@ -511,6 +637,7 @@ bool ScriptCompiler::makeCommands(const ScriptBuildConfig& config, const fs::pat
secondaryObjectPath = config.outDir / relativeParent / (baseName + ".wrap.obj");
#else
secondaryObjectPath = config.outDir / relativeParent / (baseName + ".wrap.o");
secondaryDependencyPath = config.outDir / relativeParent / (baseName + ".wrap.d");
#endif
std::ostringstream wrapper;
@@ -626,6 +753,50 @@ bool ScriptCompiler::makeCommands(const ScriptBuildConfig& config, const fs::pat
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return (cpp && cpp->EnsureRigidbody(useGravity != 0, kinematic != 0)) ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "int Modu_HasAnimation(ModuScriptContext* ctx) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return (cpp && cpp->HasAnimation()) ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "int Modu_PlayAnimation(ModuScriptContext* ctx, int restart) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return (cpp && cpp->PlayAnimation(restart != 0)) ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "int Modu_StopAnimation(ModuScriptContext* ctx, int resetTime) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return (cpp && cpp->StopAnimation(resetTime != 0)) ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "int Modu_PauseAnimation(ModuScriptContext* ctx, int pause) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return (cpp && cpp->PauseAnimation(pause != 0)) ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "int Modu_ReverseAnimation(ModuScriptContext* ctx, int restartIfStopped) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return (cpp && cpp->ReverseAnimation(restartIfStopped != 0)) ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "int Modu_SetAnimationTime(ModuScriptContext* ctx, float timeSeconds) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return (cpp && cpp->SetAnimationTime(timeSeconds)) ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "float Modu_GetAnimationTime(ModuScriptContext* ctx) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return cpp ? cpp->GetAnimationTime() : 0.0f;\n";
wrapper << "}\n\n";
wrapper << "int Modu_IsAnimationPlaying(ModuScriptContext* ctx) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return (cpp && cpp->IsAnimationPlaying()) ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "int Modu_SetAnimationLoop(ModuScriptContext* ctx, int loop) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return (cpp && cpp->SetAnimationLoop(loop != 0)) ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "int Modu_SetAnimationPlaySpeed(ModuScriptContext* ctx, float speed) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return (cpp && cpp->SetAnimationPlaySpeed(speed)) ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "int Modu_SetAnimationPlayOnAwake(ModuScriptContext* ctx, int playOnAwake) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return (cpp && cpp->SetAnimationPlayOnAwake(playOnAwake != 0)) ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "int Modu_IsSprintDown(ModuScriptContext* ctx) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return (cpp && cpp->IsSprintDown()) ? 1 : 0;\n";
@@ -948,10 +1119,12 @@ bool ScriptCompiler::makeCommands(const ScriptBuildConfig& config, const fs::pat
#else
compileCmd << posixCompileDriver(false) << " -std=c11 -fPIC -O0 -g";
appendPosixIncludesAndDefines(compileCmd);
compileCmd << " -MMD -MF \"" << dependencyPath.string() << "\"";
compileCmd << " -c \"" << scriptAbs.string() << "\" -o \"" << objectPath.string() << "\"";
compileCmd << " && ";
compileCmd << posixCompileDriver(true) << " -std=" << config.cppStandard << " -fPIC -O0 -g";
appendPosixIncludesAndDefines(compileCmd);
compileCmd << " -MMD -MF \"" << secondaryDependencyPath.string() << "\"";
compileCmd << " -c \"" << wrapperPath.string() << "\" -o \"" << secondaryObjectPath.string() << "\"";
linkCmd << "g++ -shared \"" << objectPath.string() << "\" \"" << secondaryObjectPath.string()
<< "\" -o \"" << binaryPath.string() << "\"";
@@ -1076,6 +1249,7 @@ bool ScriptCompiler::makeCommands(const ScriptBuildConfig& config, const fs::pat
#else
compileCmd << posixCompileDriver(true) << " -std=" << config.cppStandard << " -fPIC -O0 -g";
appendPosixIncludesAndDefines(compileCmd);
compileCmd << " -MMD -MF \"" << dependencyPath.string() << "\"";
compileCmd << " -c \"" << sourceToCompile.string() << "\" -o \"" << objectPath.string() << "\"";
linkCmd << "g++ -shared \"" << objectPath.string() << "\" -o \"" << binaryPath.string() << "\"";
for (const auto& lib : config.linuxLinkLibs) {
@@ -1108,6 +1282,8 @@ bool ScriptCompiler::makeCommands(const ScriptBuildConfig& config, const fs::pat
outCommands.link = linkStr;
outCommands.objectPath = objectPath;
outCommands.secondaryObjectPath = secondaryObjectPath;
outCommands.dependencyPath = dependencyPath;
outCommands.secondaryDependencyPath = secondaryDependencyPath;
outCommands.binaryPath = binaryPath;
outCommands.wrapperPath = wrapperPath;
outCommands.sourcePath = scriptAbs;
@@ -1169,22 +1345,34 @@ bool ScriptCompiler::compile(const ScriptBuildCommands& commands, ScriptCompileO
: std::optional<fs::file_time_type>{};
const auto binaryTime = getFileWriteTime(commands.binaryPath);
std::optional<fs::file_time_type> newestInput = sourceTime;
std::optional<fs::file_time_type> fallbackNewestInput = sourceTime;
if (wrapperTime) {
newestInput = newestInput ? std::max(*newestInput, *wrapperTime) : wrapperTime;
fallbackNewestInput = fallbackNewestInput
? std::max(*fallbackNewestInput, *wrapperTime)
: wrapperTime;
}
if (objectTime && newestInput) {
needsCompile = (*objectTime < *newestInput);
if (hasSecondaryObject) {
if (!secondaryObjectTime) {
needsCompile = true;
} else {
needsCompile = needsCompile || (*secondaryObjectTime < *newestInput);
auto objectNeedsCompile = [&](const std::optional<fs::file_time_type>& builtTime,
const fs::path& depPath) {
if (!builtTime) return true;
std::optional<fs::file_time_type> newestInput = fallbackNewestInput;
if (!depPath.empty()) {
DependencyInfo depInfo = readDependencyInfo(depPath);
if (!depInfo.hasDepFile || depInfo.missingDependency || !depInfo.newestInput) {
return true;
}
newestInput = depInfo.newestInput;
}
} else {
needsCompile = true;
if (!newestInput) return true;
return *builtTime < *newestInput;
};
needsCompile = objectNeedsCompile(objectTime, commands.dependencyPath);
if (hasSecondaryObject) {
needsCompile = needsCompile ||
objectNeedsCompile(secondaryObjectTime, commands.secondaryDependencyPath);
}
if (!needsCompile) {

View File

@@ -17,6 +17,8 @@ struct ScriptBuildCommands {
std::string link;
fs::path objectPath;
fs::path secondaryObjectPath;
fs::path dependencyPath;
fs::path secondaryDependencyPath;
fs::path binaryPath;
fs::path wrapperPath;
fs::path sourcePath;

View File

@@ -168,7 +168,7 @@ SceneObject* ScriptContext::ResolveObjectRef(const std::string& ref) {
}
bool ScriptContext::IsObjectEnabled() const {
return object ? object->enabled : false;
return object ? IsObjectEnabledInHierarchy(*object) : false;
}
void ScriptContext::SetObjectEnabled(bool enabled) {
@@ -917,6 +917,66 @@ bool ScriptContext::SetAudioClip(const std::string& path) {
return engine->setAudioClipFromScript(object->id, path);
}
bool ScriptContext::PlayAudioOneShot(const std::string& clipPath, float volumeScale) {
if (!engine || !object || !object->hasAudioSource) return false;
return engine->playAudioOneShotFromScript(object->id, clipPath, std::max(0.0f, volumeScale));
}
bool ScriptContext::HasAnimation() const {
if (!engine || !object) return false;
return engine->hasAnimationFromScript(object->id);
}
bool ScriptContext::PlayAnimation(bool restart) {
if (!engine || !object) return false;
return engine->playAnimationFromScript(object->id, restart);
}
bool ScriptContext::StopAnimation(bool resetTime) {
if (!engine || !object) return false;
return engine->stopAnimationFromScript(object->id, resetTime);
}
bool ScriptContext::PauseAnimation(bool pause) {
if (!engine || !object) return false;
return engine->pauseAnimationFromScript(object->id, pause);
}
bool ScriptContext::ReverseAnimation(bool restartIfStopped) {
if (!engine || !object) return false;
return engine->reverseAnimationFromScript(object->id, restartIfStopped);
}
bool ScriptContext::SetAnimationTime(float timeSeconds) {
if (!engine || !object) return false;
return engine->setAnimationTimeFromScript(object->id, timeSeconds);
}
float ScriptContext::GetAnimationTime() const {
if (!engine || !object) return 0.0f;
return engine->getAnimationTimeFromScript(object->id);
}
bool ScriptContext::IsAnimationPlaying() const {
if (!engine || !object) return false;
return engine->isAnimationPlayingFromScript(object->id);
}
bool ScriptContext::SetAnimationLoop(bool loop) {
if (!engine || !object) return false;
return engine->setAnimationLoopFromScript(object->id, loop);
}
bool ScriptContext::SetAnimationPlaySpeed(float speed) {
if (!engine || !object) return false;
return engine->setAnimationPlaySpeedFromScript(object->id, speed);
}
bool ScriptContext::SetAnimationPlayOnAwake(bool playOnAwake) {
if (!engine || !object) return false;
return engine->setAnimationPlayOnAwakeFromScript(object->id, playOnAwake);
}
std::string ScriptContext::GetSetting(const std::string& key, const std::string& fallback) const {
if (!script) return fallback;
auto it = std::find_if(script->settings.begin(), script->settings.end(),

View File

@@ -142,6 +142,19 @@ struct ScriptContext {
bool SetAudioLoop(bool loop);
bool SetAudioVolume(float volume);
bool SetAudioClip(const std::string& path);
bool PlayAudioOneShot(const std::string& clipPath = "", float volumeScale = 1.0f);
// Animation helpers
bool HasAnimation() const;
bool PlayAnimation(bool restart = true);
bool StopAnimation(bool resetTime = true);
bool PauseAnimation(bool pause = true);
bool ReverseAnimation(bool restartIfStopped = true);
bool SetAnimationTime(float timeSeconds);
float GetAnimationTime() const;
bool IsAnimationPlaying() const;
bool SetAnimationLoop(bool loop);
bool SetAnimationPlaySpeed(float speed);
bool SetAnimationPlayOnAwake(bool playOnAwake);
// Settings helpers (auto-mark dirty)
std::string GetSetting(const std::string& key, const std::string& fallback = "") const;
void SetSetting(const std::string& key, const std::string& value);

View File

@@ -1805,7 +1805,7 @@ void VulkanRenderer::setSceneDataForTarget(SceneCameraData& targetCamera,
std::vector<OrderedInstance> ordered;
ordered.reserve(sceneObjects.size());
for (const SceneObject& obj : sceneObjects) {
if (!obj.enabled || !obj.hasRenderer || obj.renderType == RenderType::None) {
if (!IsObjectEnabledInHierarchy(obj) || !obj.hasRenderer || obj.renderType == RenderType::None) {
continue;
}
if (obj.renderType == RenderType::Sprite) {
@@ -1873,7 +1873,7 @@ void VulkanRenderer::setSceneDataForTarget(SceneCameraData& targetCamera,
targetLights.clear();
targetLights.reserve(std::min<size_t>(sceneObjects.size(), kMaxSceneLights));
for (const SceneObject& obj : sceneObjects) {
if (!obj.enabled || !obj.hasLight || !obj.light.enabled) {
if (!IsObjectEnabledInHierarchy(obj) || !obj.hasLight || !obj.light.enabled) {
continue;
}
if (obj.light.intensity <= 0.0f) {

View File

@@ -9,6 +9,8 @@ int width, height, channels;
namespace
{
constexpr int kAnyProfile = 0;
constexpr int kDefaultWindowWidth = 1000;
constexpr int kDefaultWindowHeight = 800;
void glfwErrorCallback(int code, const char* description)
{
@@ -17,26 +19,66 @@ namespace
<< "\n";
}
void centerWindowOnPrimaryMonitor(GLFWwindow* window)
{
if (!window) return;
#if defined(__linux__)
// Wayland does not allow clients to set absolute window positions.
if (glfwGetPlatform() == GLFW_PLATFORM_WAYLAND) {
return;
}
#endif
GLFWmonitor* monitor = glfwGetPrimaryMonitor();
if (!monitor) return;
int workX = 0;
int workY = 0;
int workW = 0;
int workH = 0;
glfwGetMonitorWorkarea(monitor, &workX, &workY, &workW, &workH);
if (workW <= 0 || workH <= 0) {
const GLFWvidmode* mode = glfwGetVideoMode(monitor);
if (!mode) return;
workW = mode->width;
workH = mode->height;
workX = 0;
workY = 0;
}
int windowW = 0;
int windowH = 0;
glfwGetWindowSize(window, &windowW, &windowH);
if (windowW <= 0) windowW = kDefaultWindowWidth;
if (windowH <= 0) windowH = kDefaultWindowHeight;
const int centeredX = workX + (workW - windowW) / 2;
const int centeredY = workY + (workH - windowH) / 2;
glfwSetWindowPos(window, centeredX, centeredY);
}
GLFWwindow* tryCreateWindow(int major, int minor, int profile)
{
glfwDefaultWindowHints();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, major);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, minor);
glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE);
if (profile != kAnyProfile)
{
glfwWindowHint(GLFW_OPENGL_PROFILE, profile);
}
return glfwCreateWindow(1000, 800, "Modularity", nullptr, nullptr);
return glfwCreateWindow(kDefaultWindowWidth, kDefaultWindowHeight, "Modularity", nullptr, nullptr);
}
GLFWwindow* tryCreateVulkanWindow()
{
glfwDefaultWindowHints();
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE);
return glfwCreateWindow(1000, 800, "Modularity", nullptr, nullptr);
return glfwCreateWindow(kDefaultWindowWidth, kDefaultWindowHeight, "Modularity", nullptr, nullptr);
}
} // namespace
@@ -146,5 +188,8 @@ GLFWwindow* Window::makeWindow(Modularity::GraphicsBackend backend)
stbi_image_free(pixels);
}
centerWindowOnPrimaryMonitor(window);
glfwShowWindow(window);
return window;
}
}