Made FIle Explorer look better + Added a WIP Sprite Manager, layer system and a proper 2D Sprite Atlas system and rect system for sprite clips. Yey!

This commit is contained in:
2026-03-02 06:53:53 -05:00
parent aef2bf42fc
commit 5b5fe9c13b
27 changed files with 3128 additions and 130 deletions

View File

@@ -0,0 +1,8 @@
[CrashReporter] Preview session started at 2026-03-01 04:30:59
[DEBUG] Renderer backend: OpenGL
[INFO] Opening sample crash reporter preview window.
[WARN] This is mock data for UI iteration only.
[ERROR] Example exception: Failed to resolve preview resource handle.
[ERROR] Stack frame 0: Modularity::Preview::OpenCrashReporter()
[ERROR] Stack frame 1: Modularity::Engine::Tick()
[ERROR] Stack frame 2: main

Binary file not shown.

10
imgui.ini Normal file
View File

@@ -0,0 +1,10 @@
[Window][Debug##Default]
Pos=60,60
Size=400,400
Collapsed=0
[Window][Crash Reporter Root]
Pos=0,0
Size=920,720
Collapsed=0

View File

@@ -56,6 +56,12 @@ void Modu_SetSettingBool(ModuScriptContext* ctx, const char* key, int value);
void Modu_SetSettingString(ModuScriptContext* ctx, const char* key, const char* value);
int Modu_GetSettingString(ModuScriptContext* ctx, const char* key, const char* fallback,
char* outBuffer, int outBufferSize);
int Modu_GetSpriteClipCount(ModuScriptContext* ctx);
int Modu_GetSpriteClipIndex(ModuScriptContext* ctx);
int Modu_SetSpriteClipIndex(ModuScriptContext* ctx, int index);
int Modu_SetSpriteClipName(ModuScriptContext* ctx, const char* name);
int Modu_GetSpriteClipName(ModuScriptContext* ctx, char* outBuffer, int outBufferSize);
int Modu_GetSpriteClipNameAt(ModuScriptContext* ctx, int index, char* outBuffer, int outBufferSize);
void Modu_InspectorText(ModuScriptContext* ctx, const char* text);
void Modu_InspectorSeparator(ModuScriptContext* ctx);

View File

@@ -442,6 +442,9 @@ bool AudioSystem::playPreview(const std::string& path, float volume, bool loop)
if (path.empty()) return false;
if (!initialized && !init()) return false;
// Prime cached waveform metadata up front so streamed formats still expose duration/seek info.
(void)getPreview(path);
stopPreview();
if (!initSoundFromPath(path, MA_SOUND_FLAG_STREAM, nullptr, previewSound, previewDecodedData)) {
std::cerr << "AudioSystem: preview load failed for " << path << "\n";
@@ -477,7 +480,13 @@ bool AudioSystem::getPreviewTime(const std::string& path, double& cursorSeconds,
if (!previewActive || previewPath != path) return false;
ma_uint32 sampleRate = 0;
if (ma_sound_get_data_format(&previewSound, nullptr, nullptr, &sampleRate, nullptr, 0) != MA_SUCCESS || sampleRate == 0) {
durationSeconds = 0.0;
auto it = previewCache.find(path);
if (it != previewCache.end() && it->second.loaded && it->second.sampleRate > 0) {
sampleRate = it->second.sampleRate;
} else if (previewDecodedData && previewDecodedData->sampleRate > 0) {
sampleRate = previewDecodedData->sampleRate;
} else if (ma_sound_get_data_format(&previewSound, nullptr, nullptr, &sampleRate, nullptr, 0) != MA_SUCCESS || sampleRate == 0) {
return false;
}
@@ -485,8 +494,6 @@ bool AudioSystem::getPreviewTime(const std::string& path, double& cursorSeconds,
if (ma_sound_get_cursor_in_pcm_frames(&previewSound, &cursorFrames) != MA_SUCCESS) return false;
cursorSeconds = static_cast<double>(cursorFrames) / static_cast<double>(sampleRate);
durationSeconds = 0.0;
auto it = previewCache.find(path);
if (it != previewCache.end() && it->second.loaded && std::isfinite(it->second.durationSeconds) && it->second.durationSeconds > 0.0) {
durationSeconds = it->second.durationSeconds;
} else if (previewDecodedData && previewDecodedData->sampleRate > 0 && previewDecodedData->frameCount > 0) {

722
src/CrashReporter.cpp Normal file
View File

@@ -0,0 +1,722 @@
#include <glad/glad.h>
#include "CrashReporter.h"
#include "AudioSystem.h"
#include "ThirdParty/glfw/include/GLFW/glfw3.h"
#include "ThirdParty/imgui/backends/imgui_impl_glfw.h"
#include "ThirdParty/imgui/backends/imgui_impl_opengl3.h"
#include "ThirdParty/imgui/imgui.h"
#include "../include/ThirdParty/stb_image.h"
#include <atomic>
#include <chrono>
#include <csignal>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <exception>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#if defined(__linux__) || defined(__APPLE__)
#include <execinfo.h>
#include <unistd.h>
#endif
#if defined(_WIN32)
#include <shellapi.h>
#include <windows.h>
#endif
namespace Modularity::CrashReporter {
namespace {
namespace fs = std::filesystem;
struct CrashContext {
std::string productName = "Modularity";
fs::path executablePath;
fs::path sessionLogPath;
std::mutex logMutex;
std::ofstream logFile;
std::streambuf* oldCout = nullptr;
std::streambuf* oldCerr = nullptr;
std::atomic<bool> crashHandled{false};
};
CrashContext& context() {
static CrashContext ctx;
return ctx;
}
fs::path executableDirectory() {
auto& ctx = context();
if (!ctx.executablePath.empty()) {
std::error_code ec;
const fs::path absolute = fs::absolute(ctx.executablePath, ec);
if (!ec && !absolute.empty()) {
return absolute.parent_path();
}
const fs::path rawPath(ctx.executablePath);
if (rawPath.has_parent_path()) {
return rawPath.parent_path();
}
}
return {};
}
std::string nowForFileName() {
const auto now = std::chrono::system_clock::now();
const auto time = std::chrono::system_clock::to_time_t(now);
std::tm tmNow{};
#if defined(_WIN32)
localtime_s(&tmNow, &time);
#else
localtime_r(&time, &tmNow);
#endif
char buffer[32] = {};
std::strftime(buffer, sizeof(buffer), "%Y%m%d-%H%M%S", &tmNow);
return buffer;
}
std::string nowForDisplay() {
const auto now = std::chrono::system_clock::now();
const auto time = std::chrono::system_clock::to_time_t(now);
std::tm tmNow{};
#if defined(_WIN32)
localtime_s(&tmNow, &time);
#else
localtime_r(&time, &tmNow);
#endif
char buffer[64] = {};
std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &tmNow);
return buffer;
}
class TeeStreamBuf final : public std::streambuf {
public:
TeeStreamBuf(std::streambuf* primary, std::streambuf* secondary)
: primary_(primary), secondary_(secondary) {}
protected:
int overflow(int ch) override {
if (ch == EOF) {
return !EOF;
}
const int first = primary_ ? primary_->sputc(static_cast<char>(ch)) : ch;
const int second = secondary_ ? secondary_->sputc(static_cast<char>(ch)) : ch;
return (first == EOF || second == EOF) ? EOF : ch;
}
int sync() override {
const int first = primary_ ? primary_->pubsync() : 0;
const int second = secondary_ ? secondary_->pubsync() : 0;
return (first == 0 && second == 0) ? 0 : -1;
}
private:
std::streambuf* primary_ = nullptr;
std::streambuf* secondary_ = nullptr;
};
TeeStreamBuf*& coutTee() {
static TeeStreamBuf* tee = nullptr;
return tee;
}
TeeStreamBuf*& cerrTee() {
static TeeStreamBuf* tee = nullptr;
return tee;
}
std::string quoteArg(const std::string& value) {
std::string out = "\"";
for (char c : value) {
if (c == '"') {
out += "\\\"";
} else {
out += c;
}
}
out += "\"";
return out;
}
void launchUrl(const std::string& url) {
#if defined(_WIN32)
ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
#elif defined(__APPLE__)
std::string command = "open " + quoteArg(url);
std::system(command.c_str());
#else
std::string command = "xdg-open " + quoteArg(url) + " >/dev/null 2>&1 &";
std::system(command.c_str());
#endif
}
void openPath(const fs::path& path) {
#if defined(_WIN32)
ShellExecuteA(nullptr, "open", path.string().c_str(), nullptr, nullptr, SW_SHOWNORMAL);
#elif defined(__APPLE__)
std::string command = "open " + quoteArg(path.string());
std::system(command.c_str());
#else
std::string command = "xdg-open " + quoteArg(path.string()) + " >/dev/null 2>&1 &";
std::system(command.c_str());
#endif
}
fs::path crashDirectory() {
fs::path baseDir = executableDirectory();
if (baseDir.empty()) {
baseDir = fs::current_path();
}
const fs::path dir = baseDir / "CrashReports";
std::error_code ec;
fs::create_directories(dir, ec);
return dir;
}
void writeCrashSummary(const std::string& reason, const std::string& details) {
auto& ctx = context();
const fs::path summaryPath = crashDirectory() / "last_crash.txt";
std::ofstream summary(summaryPath, std::ios::trunc);
if (!summary.is_open()) {
return;
}
summary << "product=" << ctx.productName << "\n";
summary << "timestamp=" << nowForDisplay() << "\n";
summary << "reason=" << reason << "\n";
summary << "details=" << details << "\n";
summary << "log=" << ctx.sessionLogPath.string() << "\n";
}
void launchReporterProcess(const std::string& reason, const std::string& details) {
auto& ctx = context();
writeCrashSummary(reason, details);
if (ctx.executablePath.empty()) {
return;
}
#if defined(_WIN32)
std::string commandLine = quoteArg(ctx.executablePath.string()) +
" --crash-reporter" +
" --product-name " + quoteArg(ctx.productName) +
" --crash-reason " + quoteArg(reason) +
" --crash-details " + quoteArg(details) +
" --crash-log " + quoteArg(ctx.sessionLogPath.string());
STARTUPINFOA startupInfo{};
startupInfo.cb = sizeof(startupInfo);
PROCESS_INFORMATION processInfo{};
std::vector<char> mutableCommand(commandLine.begin(), commandLine.end());
mutableCommand.push_back('\0');
if (CreateProcessA(nullptr,
mutableCommand.data(),
nullptr,
nullptr,
FALSE,
DETACHED_PROCESS,
nullptr,
nullptr,
&startupInfo,
&processInfo)) {
CloseHandle(processInfo.hThread);
CloseHandle(processInfo.hProcess);
}
#else
const std::string command = quoteArg(ctx.executablePath.string()) +
" --crash-reporter" +
" --product-name " + quoteArg(ctx.productName) +
" --crash-reason " + quoteArg(reason) +
" --crash-details " + quoteArg(details) +
" --crash-log " + quoteArg(ctx.sessionLogPath.string()) +
" >/dev/null 2>&1 &";
std::system(command.c_str());
#endif
}
[[noreturn]] void handleCrash(const std::string& reason, const std::string& details, int exitCode) {
auto& ctx = context();
if (!ctx.crashHandled.exchange(true)) {
AppendLogLine("[CrashReporter] Fatal crash detected.");
AppendLogLine("[CrashReporter] Reason: " + reason);
if (!details.empty()) {
AppendLogLine("[CrashReporter] Details: " + details);
}
launchReporterProcess(reason, details);
}
std::_Exit(exitCode);
}
void signalHandler(int signalValue) {
#if defined(_WIN32)
std::ostringstream details;
details << signalValue;
handleCrash("Fatal signal", details.str(), 128 + signalValue);
#else
static constexpr char kMessage[] =
"[CrashReporter] Fatal signal received. Reporter fallback is disabled for POSIX signal crashes.\n";
(void)!::write(STDERR_FILENO, kMessage, sizeof(kMessage) - 1);
std::signal(signalValue, SIG_DFL);
std::raise(signalValue);
std::_Exit(128 + signalValue);
#endif
}
void terminateHandler() {
std::string details = "No active exception.";
if (const std::exception_ptr ex = std::current_exception()) {
try {
std::rethrow_exception(ex);
} catch (const std::exception& e) {
details = e.what();
} catch (...) {
details = "Non-standard exception";
}
}
handleCrash("Unhandled termination", details, EXIT_FAILURE);
}
#if defined(_WIN32)
LONG WINAPI unhandledExceptionFilter(EXCEPTION_POINTERS* exceptionInfo) {
DWORD code = exceptionInfo && exceptionInfo->ExceptionRecord
? exceptionInfo->ExceptionRecord->ExceptionCode
: 0;
std::ostringstream details;
details << "Windows structured exception 0x" << std::hex << code;
handleCrash("Unhandled SEH exception", details.str(), EXIT_FAILURE);
}
#endif
std::string readFilePreview(const fs::path& path) {
std::ifstream in(path);
if (!in.is_open()) {
return "Unable to read crash log.";
}
std::string line;
std::deque<std::string> tail;
while (std::getline(in, line)) {
tail.push_back(line);
if (tail.size() > 30) {
tail.pop_front();
}
}
std::ostringstream out;
for (const auto& entry : tail) {
out << entry << '\n';
}
return out.str();
}
void applyCrashReporterTheme() {
ImGuiStyle& style = ImGui::GetStyle();
ImVec4* colors = style.Colors;
const ImVec4 slate = ImVec4(0.11f, 0.12f, 0.19f, 1.00f);
const ImVec4 panel = ImVec4(0.16f, 0.16f, 0.24f, 1.00f);
const ImVec4 overlay = ImVec4(0.10f, 0.11f, 0.17f, 0.98f);
const ImVec4 accent = ImVec4(0.48f, 0.56f, 0.86f, 1.00f);
const ImVec4 accentMuted = ImVec4(0.38f, 0.46f, 0.74f, 1.00f);
const ImVec4 highlight = ImVec4(0.22f, 0.23f, 0.34f, 1.00f);
style.WindowPadding = ImVec2(14.0f, 14.0f);
style.FramePadding = ImVec2(10.0f, 8.0f);
style.ItemSpacing = ImVec2(10.0f, 8.0f);
style.ItemInnerSpacing = ImVec2(6.0f, 6.0f);
style.ScrollbarSize = 14.0f;
style.WindowRounding = 10.0f;
style.ChildRounding = 10.0f;
style.FrameRounding = 8.0f;
style.PopupRounding = 8.0f;
style.GrabRounding = 8.0f;
style.TabRounding = 8.0f;
style.WindowBorderSize = 1.0f;
style.ChildBorderSize = 1.0f;
style.FrameBorderSize = 0.0f;
colors[ImGuiCol_Text] = ImVec4(0.92f, 0.93f, 0.97f, 1.00f);
colors[ImGuiCol_TextDisabled] = ImVec4(0.60f, 0.62f, 0.70f, 1.00f);
colors[ImGuiCol_WindowBg] = slate;
colors[ImGuiCol_ChildBg] = panel;
colors[ImGuiCol_PopupBg] = overlay;
colors[ImGuiCol_Border] = ImVec4(0.22f, 0.23f, 0.34f, 0.70f);
colors[ImGuiCol_BorderShadow] = ImVec4(0, 0, 0, 0);
colors[ImGuiCol_MenuBarBg] = ImVec4(0.09f, 0.10f, 0.16f, 1.00f);
colors[ImGuiCol_Header] = highlight;
colors[ImGuiCol_HeaderHovered] = ImVec4(0.26f, 0.28f, 0.38f, 1.00f);
colors[ImGuiCol_HeaderActive] = ImVec4(0.28f, 0.30f, 0.42f, 1.00f);
colors[ImGuiCol_Button] = ImVec4(0.22f, 0.23f, 0.32f, 1.00f);
colors[ImGuiCol_ButtonHovered] = ImVec4(0.28f, 0.30f, 0.42f, 1.00f);
colors[ImGuiCol_ButtonActive] = ImVec4(0.33f, 0.36f, 0.48f, 1.00f);
colors[ImGuiCol_FrameBg] = ImVec4(0.20f, 0.21f, 0.30f, 1.00f);
colors[ImGuiCol_FrameBgHovered] = ImVec4(0.26f, 0.28f, 0.40f, 1.00f);
colors[ImGuiCol_FrameBgActive] = ImVec4(0.30f, 0.34f, 0.46f, 1.00f);
colors[ImGuiCol_TitleBg] = ImVec4(0.11f, 0.12f, 0.18f, 1.00f);
colors[ImGuiCol_TitleBgActive] = ImVec4(0.16f, 0.17f, 0.24f, 1.00f);
colors[ImGuiCol_TitleBgCollapsed] = ImVec4(0.09f, 0.10f, 0.15f, 1.00f);
colors[ImGuiCol_Separator] = ImVec4(0.22f, 0.23f, 0.34f, 1.00f);
colors[ImGuiCol_SeparatorHovered] = ImVec4(0.34f, 0.36f, 0.52f, 1.00f);
colors[ImGuiCol_SeparatorActive] = ImVec4(0.44f, 0.50f, 0.70f, 1.00f);
colors[ImGuiCol_ScrollbarBg] = ImVec4(0.11f, 0.12f, 0.18f, 1.00f);
colors[ImGuiCol_ScrollbarGrab] = ImVec4(0.24f, 0.26f, 0.36f, 1.00f);
colors[ImGuiCol_ScrollbarGrabHovered] = ImVec4(0.32f, 0.35f, 0.48f, 1.00f);
colors[ImGuiCol_ScrollbarGrabActive] = ImVec4(0.36f, 0.42f, 0.58f, 1.00f);
colors[ImGuiCol_CheckMark] = accent;
colors[ImGuiCol_SliderGrab] = accent;
colors[ImGuiCol_SliderGrabActive] = accentMuted;
colors[ImGuiCol_ResizeGrip] = ImVec4(0.28f, 0.30f, 0.42f, 1.00f);
colors[ImGuiCol_ResizeGripHovered] = ImVec4(0.38f, 0.44f, 0.60f, 0.80f);
colors[ImGuiCol_ResizeGripActive] = accent;
colors[ImGuiCol_TextSelectedBg] = ImVec4(accent.x, accent.y, accent.z, 0.24f);
colors[ImGuiCol_NavHighlight] = accent;
colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.05f, 0.06f, 0.09f, 0.70f);
}
GLuint createTextureFromImage(const fs::path& imagePath, int& width, int& height) {
int channels = 0;
unsigned char* pixels = stbi_load(imagePath.string().c_str(), &width, &height, &channels, STBI_rgb_alpha);
if (!pixels) {
return 0;
}
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
stbi_image_free(pixels);
return texture;
}
std::string valueForArg(int argc, char** argv, const std::string& name) {
for (int i = 1; i + 1 < argc; ++i) {
if (std::strcmp(argv[i], name.c_str()) == 0) {
return argv[i + 1];
}
}
return {};
}
bool hasArg(int argc, char** argv, const std::string& name) {
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], name.c_str()) == 0) {
return true;
}
}
return false;
}
fs::path createPreviewLog(const std::string& productName) {
const fs::path path = crashDirectory() / (productName + "-preview.log");
std::ofstream out(path, std::ios::trunc);
if (!out.is_open()) {
return {};
}
out << "[CrashReporter] Preview session started at " << nowForDisplay() << "\n";
out << "[DEBUG] Renderer backend: OpenGL\n";
out << "[INFO] Opening sample crash reporter preview window.\n";
out << "[WARN] This is mock data for UI iteration only.\n";
out << "[ERROR] Example exception: Failed to resolve preview resource handle.\n";
out << "[ERROR] Stack frame 0: Modularity::Preview::OpenCrashReporter()\n";
out << "[ERROR] Stack frame 1: Modularity::Engine::Tick()\n";
out << "[ERROR] Stack frame 2: main\n";
return path;
}
float saturate(float value) {
return std::clamp(value, 0.0f, 1.0f);
}
float easeOutCubic(float value) {
const float t = 1.0f - saturate(value);
return 1.0f - (t * t * t);
}
int runCrashReporterWindow(const std::string& productName,
const std::string& reason,
const std::string& details,
const fs::path& logPath) {
if (!glfwInit()) {
return EXIT_FAILURE;
}
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
GLFWwindow* window = glfwCreateWindow(920, 720, (productName + " Crash Reporter").c_str(), nullptr, nullptr);
if (!window) {
glfwTerminate();
return EXIT_FAILURE;
}
glfwMakeContextCurrent(window);
glfwSwapInterval(1);
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
glfwDestroyWindow(window);
glfwTerminate();
return EXIT_FAILURE;
}
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
ImGui::StyleColorsDark();
applyCrashReporterTheme();
ImGui_ImplGlfw_InitForOpenGL(window, true);
ImGui_ImplOpenGL3_Init("#version 330");
AudioSystem audio;
const bool audioReady = audio.init();
if (audioReady) {
audio.playPreview("Resources/Sounds/Crash Error.mp3", 0.95f, false);
}
int logoWidth = 0;
int logoHeight = 0;
GLuint logoTexture = createTextureFromImage(fs::current_path() / "Resources/Engine-Root/Modu-Logo.png", logoWidth, logoHeight);
bool splash = true;
const auto splashStart = std::chrono::steady_clock::now();
const std::string logPreview = readFilePreview(logPath);
const float splashDurationSeconds = 1.3f;
const float revealDurationSeconds = 0.55f;
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
const float elapsedSeconds = std::chrono::duration<float>(
std::chrono::steady_clock::now() - splashStart).count();
splash = elapsedSeconds < splashDurationSeconds;
const float revealT = easeOutCubic((elapsedSeconds - splashDurationSeconds) / revealDurationSeconds);
const float heroAlpha = revealT;
const float heroOffset = (1.0f - revealT) * 24.0f;
const float contentT = easeOutCubic((elapsedSeconds - splashDurationSeconds - 0.08f) / revealDurationSeconds);
const float contentAlpha = contentT;
const float contentOffset = (1.0f - contentT) * 38.0f;
ImGui::SetNextWindowPos(ImVec2(0, 0));
ImGui::SetNextWindowSize(io.DisplaySize);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoResize;
ImGui::Begin("Crash Reporter Root", nullptr, flags);
if (splash) {
ImGui::Dummy(ImVec2(0.0f, 84.0f));
const float imageSize = 160.0f;
if (logoTexture != 0) {
ImGui::SetCursorPosX((io.DisplaySize.x - imageSize) * 0.5f);
ImGui::Image(reinterpret_cast<void*>(static_cast<intptr_t>(logoTexture)), ImVec2(imageSize, imageSize));
}
ImGui::Dummy(ImVec2(0.0f, 20.0f));
ImGui::SetCursorPosX((io.DisplaySize.x - 220.0f) * 0.5f);
ImGui::TextColored(ImVec4(0.92f, 0.93f, 0.97f, 1.0f), "Preparing crash report...");
ImGui::Dummy(ImVec2(0.0f, 10.0f));
ImGui::SetCursorPosX((io.DisplaySize.x - 320.0f) * 0.5f);
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.48f, 0.56f, 0.86f, 1.0f));
static float progress = 0.0f;
progress = std::min(1.0f, progress + 0.02f);
ImGui::ProgressBar(progress, ImVec2(320.0f, 10.0f), "");
ImGui::PopStyleColor();
} else {
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + heroOffset);
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, heroAlpha);
ImGui::BeginChild("hero", ImVec2(0, 150), true);
if (logoTexture != 0) {
ImGui::SetCursorPos(ImVec2(18.0f, 22.0f));
ImGui::Image(reinterpret_cast<void*>(static_cast<intptr_t>(logoTexture)), ImVec2(96.0f, 96.0f));
}
ImGui::SetCursorPos(ImVec2(136.0f, 28.0f));
ImGui::TextColored(ImVec4(0.92f, 0.93f, 0.97f, 1.0f), "%s has crashed", productName.c_str());
ImGui::SetCursorPos(ImVec2(136.0f, 60.0f));
ImGui::TextWrapped("A crash log was captured for this session. Review the details below, then open the log or file an issue.");
ImGui::SetCursorPos(ImVec2(136.0f, 104.0f));
ImGui::TextColored(ImVec4(0.48f, 0.56f, 0.86f, 1.0f), "Crash log: %s", logPath.filename().string().c_str());
ImGui::EndChild();
ImGui::PopStyleVar();
ImGui::Dummy(ImVec2(0.0f, 10.0f + contentOffset));
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, contentAlpha);
ImGui::BeginChild("reporter_content", ImVec2(0, -70), true);
ImGui::TextColored(ImVec4(0.48f, 0.56f, 0.86f, 1.0f), "Reason");
ImGui::Separator();
ImGui::TextWrapped("%s", reason.c_str());
if (!details.empty()) {
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.48f, 0.56f, 0.86f, 1.0f), "Details");
ImGui::Separator();
ImGui::BeginChild("details_panel", ImVec2(0, 110), true, ImGuiWindowFlags_HorizontalScrollbar);
ImGui::TextUnformatted(details.c_str());
ImGui::EndChild();
}
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.48f, 0.56f, 0.86f, 1.0f), "Recent log output");
ImGui::Separator();
ImGui::BeginChild("log_preview", ImVec2(0, 260), true, ImGuiWindowFlags_HorizontalScrollbar);
ImGui::TextUnformatted(logPreview.c_str());
ImGui::EndChild();
ImGui::EndChild();
ImGui::PopStyleVar();
if (ImGui::Button("Open Log", ImVec2(140, 0))) {
openPath(logPath);
}
ImGui::SameLine();
if (ImGui::Button("Report Issue", ImVec2(140, 0))) {
launchUrl("https://git.shockinteractive.xyz/Tareno-Labs-LLC/Modularity/issues");
}
ImGui::SameLine();
if (ImGui::Button("Open Crash Folder", ImVec2(160, 0))) {
openPath(logPath.parent_path());
}
ImGui::SameLine();
if (ImGui::Button("Close", ImVec2(110, 0))) {
glfwSetWindowShouldClose(window, GLFW_TRUE);
}
}
ImGui::End();
ImGui::Render();
int displayW = 0;
int displayH = 0;
glfwGetFramebufferSize(window, &displayW, &displayH);
glViewport(0, 0, displayW, displayH);
glClearColor(0.11f, 0.12f, 0.19f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
glfwSwapBuffers(window);
}
if (logoTexture != 0) {
glDeleteTextures(1, &logoTexture);
}
if (audioReady) {
audio.shutdown();
}
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();
glfwDestroyWindow(window);
glfwTerminate();
return EXIT_SUCCESS;
}
} // namespace
bool HandleCrashReporterMode(int argc, char** argv) {
const bool previewMode = hasArg(argc, argv, "--crash-reporter-preview");
if (!previewMode && !hasArg(argc, argv, "--crash-reporter")) {
return false;
}
if (argc > 0 && argv && argv[0]) {
context().executablePath = argv[0];
}
const std::string productName = valueForArg(argc, argv, "--product-name");
std::string reason = valueForArg(argc, argv, "--crash-reason");
std::string details = valueForArg(argc, argv, "--crash-details");
fs::path logPath = valueForArg(argc, argv, "--crash-log");
const std::string resolvedProductName = productName.empty() ? "Modularity" : productName;
if (previewMode) {
if (reason.empty()) {
reason = "Preview crash dialog";
}
if (details.empty()) {
details =
"This is a preview-only crash reporter window.\n"
"This is used to preview the layout, text and it helps to debug the output of the text output on screen.";
}
if (logPath.empty()) {
logPath = createPreviewLog(resolvedProductName);
}
}
runCrashReporterWindow(productName.empty() ? "Modularity" : productName,
reason.empty() ? "Unknown crash" : reason,
details,
logPath);
return true;
}
void Initialize(const std::string& productName, const std::string& executablePath) {
auto& ctx = context();
ctx.productName = productName;
ctx.executablePath = executablePath;
ctx.sessionLogPath = crashDirectory() / (productName + "-session-" + nowForFileName() + ".log");
ctx.logFile.open(ctx.sessionLogPath, std::ios::out | std::ios::trunc);
if (ctx.logFile.is_open()) {
ctx.oldCout = std::cout.rdbuf();
ctx.oldCerr = std::cerr.rdbuf();
coutTee() = new TeeStreamBuf(ctx.oldCout, ctx.logFile.rdbuf());
cerrTee() = new TeeStreamBuf(ctx.oldCerr, ctx.logFile.rdbuf());
std::cout.rdbuf(coutTee());
std::cerr.rdbuf(cerrTee());
AppendLogLine("[CrashReporter] Session started at " + nowForDisplay());
}
std::set_terminate(terminateHandler);
#if defined(_WIN32)
std::signal(SIGABRT, signalHandler);
std::signal(SIGILL, signalHandler);
std::signal(SIGFPE, signalHandler);
std::signal(SIGSEGV, signalHandler);
SetUnhandledExceptionFilter(unhandledExceptionFilter);
#else
struct sigaction action {};
action.sa_handler = signalHandler;
sigemptyset(&action.sa_mask);
action.sa_flags = 0;
sigaction(SIGABRT, &action, nullptr);
sigaction(SIGILL, &action, nullptr);
sigaction(SIGFPE, &action, nullptr);
sigaction(SIGSEGV, &action, nullptr);
#if defined(SIGBUS)
sigaction(SIGBUS, &action, nullptr);
#endif
#endif
}
int RunProtected(const std::function<int()>& entryPoint) {
try {
return entryPoint();
} catch (const std::exception& e) {
handleCrash("Unhandled exception", e.what(), EXIT_FAILURE);
} catch (...) {
handleCrash("Unhandled exception", "Non-standard exception", EXIT_FAILURE);
}
}
void AppendLogLine(const std::string& line) {
auto& ctx = context();
std::lock_guard<std::mutex> lock(ctx.logMutex);
if (!ctx.logFile.is_open()) {
return;
}
ctx.logFile << line << '\n';
ctx.logFile.flush();
}
} // namespace Modularity::CrashReporter

13
src/CrashReporter.h Normal file
View File

@@ -0,0 +1,13 @@
#pragma once
#include <functional>
#include <string>
namespace Modularity::CrashReporter {
bool HandleCrashReporterMode(int argc, char** argv);
void Initialize(const std::string& productName, const std::string& executablePath);
int RunProtected(const std::function<int()>& entryPoint);
void AppendLogLine(const std::string& line);
} // namespace Modularity::CrashReporter

View File

@@ -351,6 +351,11 @@ void Engine::renderAnimationWindow() {
if (path == "AIAgent.Speed" && obj.hasAIAgent) { outValue = obj.aiAgent.speed; return true; }
if (path == "AIAgent.StoppingDistance" && obj.hasAIAgent) { outValue = obj.aiAgent.stoppingDistance; return true; }
if (path == "Sprite.Frame" && obj.hasUI &&
(obj.ui.type == UIElementType::Sprite2D || obj.ui.type == UIElementType::Image)) {
outValue = static_cast<float>(obj.ui.spriteSheetFrame);
return true;
}
int scriptIndex = -1;
int settingIndex = -1;
@@ -407,6 +412,14 @@ void Engine::renderAnimationWindow() {
if (path == "AIAgent.Speed" && obj.hasAIAgent) { obj.aiAgent.speed = std::max(0.05f, value); return true; }
if (path == "AIAgent.StoppingDistance" && obj.hasAIAgent) { obj.aiAgent.stoppingDistance = std::max(0.0f, value); return true; }
if (path == "Sprite.Frame" && obj.hasUI &&
(obj.ui.type == UIElementType::Sprite2D || obj.ui.type == UIElementType::Image)) {
const int frameCount = obj.ui.spriteCustomFramesEnabled && !obj.ui.spriteCustomFrames.empty()
? static_cast<int>(obj.ui.spriteCustomFrames.size())
: std::max(1, obj.ui.spriteSheetColumns * obj.ui.spriteSheetRows);
obj.ui.spriteSheetFrame = std::clamp(static_cast<int>(std::round(value)), 0, std::max(0, frameCount - 1));
return true;
}
int scriptIndex = -1;
int settingIndex = -1;
@@ -532,6 +545,9 @@ void Engine::renderAnimationWindow() {
push("AIAgent.Speed", "AI Agent/Speed");
push("AIAgent.StoppingDistance", "AI Agent/Stopping Distance");
}
if (obj.hasUI && (obj.ui.type == UIElementType::Sprite2D || obj.ui.type == UIElementType::Image)) {
push("Sprite.Frame", "Sprite/Frame");
}
for (size_t si = 0; si < obj.scripts.size(); ++si) {
const auto& script = obj.scripts[si];
for (size_t st = 0; st < script.settings.size(); ++st) {

View File

@@ -265,18 +265,7 @@ namespace FileIcons {
// Draw a scene/document icon
void DrawSceneIcon(ImDrawList* drawList, ImVec2 pos, float size, ImU32 color) {
PaperFrame frame = DrawSheetFileBase(drawList, pos, size, color);
ImU32 ink = IM_COL32(90, 90, 90, 230);
float w = frame.max.x - frame.min.x;
float h = frame.max.y - frame.min.y;
ImVec2 center(frame.min.x + w * 0.55f, frame.min.y + h * 0.55f);
float tri = std::min(w, h) * 0.35f;
drawList->AddTriangleFilled(
ImVec2(center.x - tri * 0.4f, center.y - tri * 0.55f),
ImVec2(center.x - tri * 0.4f, center.y + tri * 0.55f),
ImVec2(center.x + tri * 0.6f, center.y),
ink
);
DrawSheetFileBase(drawList, pos, size, color);
}
// Draw a 3D model icon (cube wireframe)
@@ -480,6 +469,188 @@ namespace FileIcons {
#pragma region File Actions
namespace {
struct CachedModelPreview {
bool loaded = false;
bool attempted = false;
bool isObj = false;
int meshId = -1;
glm::vec3 boundsMin = glm::vec3(FLT_MAX);
glm::vec3 boundsMax = glm::vec3(-FLT_MAX);
};
Texture* GetTexturePreview(Renderer& renderer, const fs::path& path) {
std::error_code ec;
if (!fs::exists(path, ec) || ec) {
return nullptr;
}
return renderer.getTexture(path.string());
}
Texture* GetModularityLogoTexture(Renderer& renderer) {
static const fs::path kLogoPath("/home/anemunt/Git-base/Modularity/Resources/Engine-Root/Modu-Logo.png");
return GetTexturePreview(renderer, kLogoPath);
}
CachedModelPreview& GetModelPreviewData(const fs::path& path) {
static std::unordered_map<std::string, CachedModelPreview> cache;
CachedModelPreview& preview = cache[path.string()];
if (preview.attempted) {
return preview;
}
preview.attempted = true;
std::string ext = path.extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
preview.isObj = (ext == ".obj");
if (preview.isObj) {
std::string err;
preview.meshId = g_objLoader.loadOBJ(path.string(), err);
const auto* info = g_objLoader.getMeshInfo(preview.meshId);
if (preview.meshId >= 0 && info) {
preview.loaded = true;
preview.boundsMin = info->boundsMin;
preview.boundsMax = info->boundsMax;
}
return preview;
}
ModelLoadResult result = getModelLoader().loadModel(path.string());
const auto* info = getModelLoader().getMeshInfo(result.meshIndex);
if (result.success && result.meshIndex >= 0 && info) {
preview.loaded = true;
preview.meshId = result.meshIndex;
preview.boundsMin = info->boundsMin;
preview.boundsMax = info->boundsMax;
}
return preview;
}
void DrawTexturePreview(Renderer& renderer, ImDrawList* drawList, const fs::path& path,
ImVec2 min, ImVec2 max, float rounding) {
Texture* tex = GetTexturePreview(renderer, path);
if (!tex || tex->GetID() == 0 || tex->GetWidth() <= 0 || tex->GetHeight() <= 0) {
return;
}
const float availW = max.x - min.x;
const float availH = max.y - min.y;
const float scale = std::min(availW / static_cast<float>(tex->GetWidth()),
availH / static_cast<float>(tex->GetHeight()));
const float drawW = static_cast<float>(tex->GetWidth()) * scale;
const float drawH = static_cast<float>(tex->GetHeight()) * scale;
const ImVec2 imgMin(min.x + (availW - drawW) * 0.5f, min.y + (availH - drawH) * 0.5f);
const ImVec2 imgMax(imgMin.x + drawW, imgMin.y + drawH);
drawList->AddRectFilled(min, max, IM_COL32(24, 27, 34, 255), rounding);
drawList->PushClipRect(min, max, true);
drawList->AddImage((ImTextureID)(intptr_t)tex->GetID(), imgMin, imgMax, ImVec2(0, 1), ImVec2(1, 0));
drawList->PopClipRect();
drawList->AddRect(min, max, IM_COL32(96, 108, 126, 210), rounding, 0, 1.0f);
}
bool DrawSceneLogoPreview(Renderer& renderer, ImDrawList* drawList, ImVec2 min, ImVec2 max, float rounding) {
Texture* tex = GetModularityLogoTexture(renderer);
if (!tex || tex->GetID() == 0 || tex->GetWidth() <= 0 || tex->GetHeight() <= 0) {
return false;
}
const float availW = max.x - min.x;
const float availH = max.y - min.y;
const float innerPad = std::min(availW, availH) * 0.08f;
const ImVec2 paddedMin(min.x + innerPad, min.y + innerPad);
const ImVec2 paddedMax(max.x - innerPad, max.y - innerPad);
const float innerW = paddedMax.x - paddedMin.x;
const float innerH = paddedMax.y - paddedMin.y;
const float scale = std::min(innerW / static_cast<float>(tex->GetWidth()),
innerH / static_cast<float>(tex->GetHeight()));
const float drawW = static_cast<float>(tex->GetWidth()) * scale;
const float drawH = static_cast<float>(tex->GetHeight()) * scale;
const ImVec2 imgMin(paddedMin.x + (innerW - drawW) * 0.5f, paddedMin.y + (innerH - drawH) * 0.5f);
const ImVec2 imgMax(imgMin.x + drawW, imgMin.y + drawH);
drawList->AddRectFilled(min, max, IM_COL32(24, 27, 34, 255), rounding);
drawList->PushClipRect(min, max, true);
drawList->AddImage((ImTextureID)(intptr_t)tex->GetID(), imgMin, imgMax, ImVec2(0, 1), ImVec2(1, 0));
drawList->PopClipRect();
drawList->AddRect(min, max, IM_COL32(96, 108, 126, 210), rounding, 0, 1.0f);
return true;
}
bool DrawModelPreview(Renderer& renderer, ImDrawList* drawList, const fs::path& path,
ImVec2 min, ImVec2 max, int previewSlot, float rounding) {
CachedModelPreview& preview = GetModelPreviewData(path);
if (!preview.loaded || preview.meshId < 0) {
return false;
}
glm::vec3 boundsMin = preview.boundsMin;
glm::vec3 boundsMax = preview.boundsMax;
if (boundsMin.x >= boundsMax.x || boundsMin.y >= boundsMax.y || boundsMin.z >= boundsMax.z) {
boundsMin = glm::vec3(-0.5f);
boundsMax = glm::vec3(0.5f);
}
const glm::vec3 center = (boundsMin + boundsMax) * 0.5f;
const glm::vec3 size = glm::max(boundsMax - boundsMin, glm::vec3(0.001f));
const float radius = std::max({ size.x, size.y, size.z }) * 0.5f;
const float uniformScale = (radius > 0.0f) ? (1.5f / radius) : 1.0f;
const float scaledHalfHeight = size.y * 0.5f * uniformScale;
SceneObject obj("FilePreviewModel", ObjectType::Model, -1);
obj.hasRenderer = true;
obj.renderType = preview.isObj ? RenderType::OBJMesh : RenderType::Model;
obj.meshId = preview.meshId;
obj.position = -center;
obj.rotation = glm::vec3(-18.0f, 32.0f, 0.0f);
obj.scale = glm::vec3(uniformScale);
obj.material.color = glm::vec3(0.90f, 0.92f, 0.96f);
obj.material.ambientStrength = 0.34f;
obj.material.specularStrength = 0.22f;
obj.material.shininess = 24.0f;
obj.albedoTexturePath.clear();
obj.overlayTexturePath.clear();
obj.normalMapPath.clear();
SceneObject ground("FilePreviewGround", ObjectType::Plane, -2);
ground.hasRenderer = true;
ground.renderType = RenderType::Plane;
ground.position = glm::vec3(0.0f, -scaledHalfHeight - 0.28f, 0.0f);
ground.rotation = glm::vec3(-90.0f, 0.0f, 0.0f);
ground.scale = glm::vec3(3.0f, 3.0f, 1.0f);
ground.material.color = glm::vec3(0.22f, 0.24f, 0.28f);
ground.material.ambientStrength = 0.22f;
ground.material.specularStrength = 0.02f;
ground.material.shininess = 4.0f;
Camera cam;
const glm::vec3 target(0.0f, 0.0f, 0.0f);
cam.position = glm::vec3(radius * 1.9f, radius * 1.05f, radius * 2.3f + 0.4f);
cam.front = glm::normalize(target - cam.position);
glm::vec3 worldUp(0.0f, 1.0f, 0.0f);
glm::vec3 right = glm::cross(cam.front, worldUp);
if (glm::dot(right, right) < 1e-6f) {
worldUp = glm::vec3(0.0f, 0.0f, 1.0f);
right = glm::cross(cam.front, worldUp);
}
right = glm::normalize(right);
cam.up = glm::normalize(glm::cross(right, cam.front));
const int width = std::max(48, static_cast<int>(max.x - min.x));
const int height = std::max(48, static_cast<int>(max.y - min.y));
std::vector<SceneObject> scene = { ground, obj };
unsigned int texId = renderer.renderScenePreview(cam, scene, width, height, 32.0f, 0.1f, 100.0f, false, previewSlot);
if (texId == 0) {
return false;
}
drawList->AddRectFilled(min, max, IM_COL32(20, 23, 29, 255), rounding);
drawList->PushClipRect(min, max, true);
drawList->AddImage((ImTextureID)(intptr_t)texId, min, max, ImVec2(0, 1), ImVec2(1, 0));
drawList->PopClipRect();
drawList->AddRect(min, max, IM_COL32(96, 108, 126, 210), rounding, 0, 1.0f);
return true;
}
enum class CreateKind {
Folder,
CppScript,
@@ -757,6 +928,10 @@ void Engine::renderFileBrowserPanel() {
fileBrowser.navigateTo(entry.path());
return;
}
if (fileBrowser.isTextureFile(entry)) {
loadPixelSpriteDocument(entry.path());
return;
}
if (fileBrowser.isModelFile(entry)) {
bool isObj = fileBrowser.isOBJFile(entry);
std::string defaultName = entry.path().stem().string();
@@ -1298,12 +1473,24 @@ void Engine::renderFileBrowserPanel() {
drawList->AddRect(cellStart, cellEnd, IM_COL32(84, 98, 116, 210), 7.0f, 0, 1.0f);
}
// Draw icon centered in cell
ImVec2 iconPos(
cellStart.x + (cellWidth - iconSize) * 0.5f,
cellStart.y + padding
);
FileIcons::DrawIcon(drawList, category, iconPos, iconSize, getCategoryColor(category), folderHasItems);
const ImVec2 previewMin = iconPos;
const ImVec2 previewMax(iconPos.x + iconSize, iconPos.y + iconSize);
bool drewPreview = false;
if (category == FileCategory::Scene) {
drewPreview = DrawSceneLogoPreview(renderer, drawList, previewMin, previewMax, 9.0f);
} else if (category == FileCategory::Texture) {
DrawTexturePreview(renderer, drawList, entry.path(), previewMin, previewMax, 9.0f);
drewPreview = true;
} else if (category == FileCategory::Model) {
drewPreview = DrawModelPreview(renderer, drawList, entry.path(), previewMin, previewMax, 1000 + i, 9.0f);
}
if (!drewPreview) {
FileIcons::DrawIcon(drawList, category, iconPos, iconSize, getCategoryColor(category), folderHasItems);
}
// Draw filename below icon (centered, with wrapping)
std::string displayName = filename;
@@ -1434,12 +1621,16 @@ void Engine::renderFileBrowserPanel() {
}
}
if (fileBrowser.getFileCategory(entry) == FileCategory::Texture) {
if (ImGui::MenuItem("Open in Pixel Sprite Editor")) {
loadPixelSpriteDocument(entry.path());
}
if (ImGui::MenuItem("Create Sprite2D")) {
addObject(ObjectType::Sprite2D, entry.path().stem().string());
if (!sceneObjects.empty()) {
SceneObject& created = sceneObjects.back();
created.albedoTexturePath = entry.path().string();
if (Texture* tex = renderer.getTexture(created.albedoTexturePath)) {
created.material.textureFilter = MaterialProperties::TextureFilter::Point;
if (Texture* tex = renderer.getTexture(created.albedoTexturePath, MaterialProperties::TextureFilter::Point)) {
if (tex->GetWidth() > 0 && tex->GetHeight() > 0) {
created.ui.size = glm::vec2(static_cast<float>(tex->GetWidth()),
static_cast<float>(tex->GetHeight()));
@@ -1509,7 +1700,11 @@ void Engine::renderFileBrowserPanel() {
ImGui::PushID(i);
// Selectable row
if (ImGui::Selectable("##row", isSelected, ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, 21))) {
const bool showRichPreview = (category == FileCategory::Scene ||
category == FileCategory::Texture ||
category == FileCategory::Model);
const float rowHeight = showRichPreview ? 34.0f : 21.0f;
if (ImGui::Selectable("##row", isSelected, ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, rowHeight))) {
fileBrowser.selectedFile = entry.path();
if (ImGui::IsMouseDoubleClicked(0)) {
@@ -1531,8 +1726,25 @@ void Engine::renderFileBrowserPanel() {
}
const float listIconSize = 15.0f;
ImVec2 iconPos(rowMin.x + 7.0f, rowMin.y + (rowMax.y - rowMin.y - listIconSize) * 0.5f);
FileIcons::DrawIcon(drawList, category, iconPos, listIconSize, getCategoryColor(category), folderHasItems);
const float listPreviewSize = 30.0f;
ImVec2 iconPos(rowMin.x + 7.0f,
rowMin.y + (rowMax.y - rowMin.y - (showRichPreview ? listPreviewSize : listIconSize)) * 0.5f);
bool drewPreview = false;
if (showRichPreview) {
ImVec2 previewMin = iconPos;
ImVec2 previewMax(iconPos.x + listPreviewSize, iconPos.y + listPreviewSize);
if (category == FileCategory::Scene) {
drewPreview = DrawSceneLogoPreview(renderer, drawList, previewMin, previewMax, 4.0f);
} else if (category == FileCategory::Texture) {
DrawTexturePreview(renderer, drawList, entry.path(), previewMin, previewMax, 4.0f);
drewPreview = true;
} else {
drewPreview = DrawModelPreview(renderer, drawList, entry.path(), previewMin, previewMax, 3000 + i, 4.0f);
}
}
if (!drewPreview) {
FileIcons::DrawIcon(drawList, category, iconPos, listIconSize, getCategoryColor(category), folderHasItems);
}
ImU32 nameColor = IM_COL32(220, 224, 230, 255);
switch (category) {
@@ -1555,7 +1767,8 @@ void Engine::renderFileBrowserPanel() {
}
float textY = rowMin.y + 3.0f;
float nameX = iconPos.x + listIconSize + 8.0f;
float visualWidth = drewPreview ? listPreviewSize : listIconSize;
float nameX = iconPos.x + visualWidth + 8.0f;
float rightPad = 10.0f;
float metaWidth = ImGui::CalcTextSize(metadata.c_str()).x;
float metaX = rowMax.x - rightPad - metaWidth;
@@ -1679,12 +1892,16 @@ void Engine::renderFileBrowserPanel() {
}
}
if (fileBrowser.getFileCategory(entry) == FileCategory::Texture) {
if (ImGui::MenuItem("Open in Pixel Sprite Editor")) {
loadPixelSpriteDocument(entry.path());
}
if (ImGui::MenuItem("Create Sprite2D")) {
addObject(ObjectType::Sprite2D, entry.path().stem().string());
if (!sceneObjects.empty()) {
SceneObject& created = sceneObjects.back();
created.albedoTexturePath = entry.path().string();
if (Texture* tex = renderer.getTexture(created.albedoTexturePath)) {
created.material.textureFilter = MaterialProperties::TextureFilter::Point;
if (Texture* tex = renderer.getTexture(created.albedoTexturePath, MaterialProperties::TextureFilter::Point)) {
if (tex->GetWidth() > 0 && tex->GetHeight() > 0) {
created.ui.size = glm::vec2(static_cast<float>(tex->GetWidth()),
static_cast<float>(tex->GetHeight()));

View File

@@ -0,0 +1,814 @@
#include "Engine.h"
#include "ThirdParty/imgui/imgui.h"
#include "ThirdParty/glfw/deps/stb_image_write.h"
#include "../../include/ThirdParty/stb_image.h"
#include "../SpritesheetFormat.h"
#include <algorithm>
#include <cstdint>
#include <cmath>
#include <fstream>
#include <queue>
#include <sstream>
namespace {
struct PixelRgba {
unsigned char r = 0;
unsigned char g = 0;
unsigned char b = 0;
unsigned char a = 255;
};
PixelRgba ToRgba8(const glm::vec4& color) {
return PixelRgba{
static_cast<unsigned char>(std::clamp(color.r, 0.0f, 1.0f) * 255.0f + 0.5f),
static_cast<unsigned char>(std::clamp(color.g, 0.0f, 1.0f) * 255.0f + 0.5f),
static_cast<unsigned char>(std::clamp(color.b, 0.0f, 1.0f) * 255.0f + 0.5f),
static_cast<unsigned char>(std::clamp(color.a, 0.0f, 1.0f) * 255.0f + 0.5f)
};
}
bool Matches(const std::vector<unsigned char>& pixels, int width, int height, int x, int y, const PixelRgba& color) {
if (x < 0 || y < 0 || x >= width || y >= height) return false;
const size_t idx = static_cast<size_t>((y * width + x) * 4);
return pixels[idx + 0] == color.r &&
pixels[idx + 1] == color.g &&
pixels[idx + 2] == color.b &&
pixels[idx + 3] == color.a;
}
void SetPixel(std::vector<unsigned char>& pixels, int width, int height, int x, int y, const PixelRgba& color) {
if (x < 0 || y < 0 || x >= width || y >= height) return;
const size_t idx = static_cast<size_t>((y * width + x) * 4);
pixels[idx + 0] = color.r;
pixels[idx + 1] = color.g;
pixels[idx + 2] = color.b;
pixels[idx + 3] = color.a;
}
glm::ivec4 NormalizeRect(glm::ivec2 a, glm::ivec2 b) {
const int minX = std::min(a.x, b.x);
const int minY = std::min(a.y, b.y);
const int maxX = std::max(a.x, b.x);
const int maxY = std::max(a.y, b.y);
return glm::ivec4(minX, minY, maxX - minX + 1, maxY - minY + 1);
}
void FloodFill(std::vector<unsigned char>& pixels, int width, int height, int startX, int startY, const PixelRgba& target, const PixelRgba& replacement) {
if (target.r == replacement.r && target.g == replacement.g &&
target.b == replacement.b && target.a == replacement.a) {
return;
}
if (!Matches(pixels, width, height, startX, startY, target)) {
return;
}
std::queue<glm::ivec2> pending;
pending.push(glm::ivec2(startX, startY));
while (!pending.empty()) {
glm::ivec2 p = pending.front();
pending.pop();
if (!Matches(pixels, width, height, p.x, p.y, target)) {
continue;
}
SetPixel(pixels, width, height, p.x, p.y, replacement);
pending.push(glm::ivec2(p.x + 1, p.y));
pending.push(glm::ivec2(p.x - 1, p.y));
pending.push(glm::ivec2(p.x, p.y + 1));
pending.push(glm::ivec2(p.x, p.y - 1));
}
}
void EnsureSpriteClipNames(std::vector<std::string>& names, size_t count) {
if (names.size() < count) {
for (size_t i = names.size(); i < count; ++i) {
names.push_back("Rect_" + std::to_string(i));
}
} else if (names.size() > count) {
names.resize(count);
}
}
void EnsureSpriteLayers(std::vector<SpritesheetLayer>& layers) {
if (layers.empty()) {
layers.push_back({"Layer_0"});
}
for (size_t i = 0; i < layers.size(); ++i) {
if (layers[i].name.empty()) {
layers[i].name = "Layer_" + std::to_string(i);
}
}
}
ImU32 CheckerColor(bool darkTheme, bool oddCell) {
if (darkTheme) {
return oddCell ? IM_COL32(88, 88, 88, 255) : IM_COL32(58, 58, 58, 255);
}
return oddCell ? IM_COL32(214, 214, 214, 255) : IM_COL32(244, 244, 244, 255);
}
}
bool Engine::loadPixelSpriteDocument(const fs::path& imagePath) {
int width = 0;
int height = 0;
int channels = 0;
stbi_set_flip_vertically_on_load(0);
unsigned char* data = stbi_load(imagePath.string().c_str(), &width, &height, &channels, STBI_rgb_alpha);
if (!data || width <= 0 || height <= 0) {
if (data) stbi_image_free(data);
addConsoleMessage("Failed to open sprite image: " + imagePath.string(), ConsoleMessageType::Error);
return false;
}
pixelSpriteDocument = PixelSpriteDocument{};
pixelSpriteDocument.imagePath = imagePath;
pixelSpriteDocument.sidecarPath = imagePath;
pixelSpriteDocument.sidecarPath += ".spritesheet";
pixelSpriteDocument.name = imagePath.filename().string();
pixelSpriteDocument.width = width;
pixelSpriteDocument.height = height;
pixelSpriteDocument.pixels.assign(data, data + static_cast<size_t>(width * height * 4));
pixelSpriteDocument.loaded = true;
stbi_image_free(data);
if (fs::exists(pixelSpriteDocument.sidecarPath)) {
std::ifstream sidecar(pixelSpriteDocument.sidecarPath);
std::ostringstream buffer;
buffer << sidecar.rdbuf();
const SpritesheetParseResult parsed = ParseSpritesheet(buffer.str());
pixelSpriteDocument.expectedMinimumModuEngineVersionOrHigher =
parsed.document.expectedMinimumModuEngineVersionOrHigher;
pixelSpriteDocument.strictValidation = parsed.document.strictValidation;
pixelSpriteDocument.spriteFrames = parsed.document.rects;
pixelSpriteDocument.spriteFrameNames = parsed.document.names;
pixelSpriteDocument.layers = parsed.document.layers;
for (const SpritesheetParseMessage& message : parsed.messages) {
addConsoleMessage(message.text, ConsoleMessageType::Warning);
}
}
EnsureSpriteClipNames(pixelSpriteDocument.spriteFrameNames, pixelSpriteDocument.spriteFrames.size());
EnsureSpriteLayers(pixelSpriteDocument.layers);
pixelSpriteUndoStack.clear();
pixelSpriteRedoStack.clear();
PixelSpriteHistoryState initialState;
initialState.width = pixelSpriteDocument.width;
initialState.height = pixelSpriteDocument.height;
initialState.pixels = pixelSpriteDocument.pixels;
initialState.selectionActive = pixelSpriteDocument.selectionActive;
initialState.selectionStart = pixelSpriteDocument.selectionStart;
initialState.selectionEnd = pixelSpriteDocument.selectionEnd;
initialState.expectedMinimumModuEngineVersionOrHigher = pixelSpriteDocument.expectedMinimumModuEngineVersionOrHigher;
initialState.strictValidation = pixelSpriteDocument.strictValidation;
initialState.spriteFrames = pixelSpriteDocument.spriteFrames;
initialState.spriteFrameNames = pixelSpriteDocument.spriteFrameNames;
initialState.layers = pixelSpriteDocument.layers;
initialState.activeLayer = pixelSpriteDocument.activeLayer;
initialState.activeFrame = pixelSpriteDocument.activeFrame;
pixelSpriteUndoStack.push_back(std::move(initialState));
showPixelSpriteEditorWindow = true;
pixelSpriteCanvasPan = ImVec2(0.0f, 0.0f);
pixelSpriteCanvasTargetPan = ImVec2(0.0f, 0.0f);
pixelSpriteCanvasStateInitialized = false;
pixelSpriteCanvasCenterPending = true;
pixelSpriteZoom = 12.0f;
pixelSpriteTargetZoom = 12.0f;
saveEditorUserSettings();
return true;
}
bool Engine::savePixelSpriteDocument() {
if (!pixelSpriteDocument.loaded || pixelSpriteDocument.imagePath.empty()) {
return false;
}
fs::create_directories(pixelSpriteDocument.imagePath.parent_path());
if (!stbi_write_png(pixelSpriteDocument.imagePath.string().c_str(),
pixelSpriteDocument.width,
pixelSpriteDocument.height,
4,
pixelSpriteDocument.pixels.data(),
pixelSpriteDocument.width * 4)) {
addConsoleMessage("Failed to save sprite image: " + pixelSpriteDocument.imagePath.string(), ConsoleMessageType::Error);
return false;
}
std::ofstream sidecar(pixelSpriteDocument.sidecarPath);
if (sidecar.is_open()) {
EnsureSpriteClipNames(pixelSpriteDocument.spriteFrameNames, pixelSpriteDocument.spriteFrames.size());
EnsureSpriteLayers(pixelSpriteDocument.layers);
SpritesheetDocument sidecarDocument;
sidecarDocument.linkedSpriteName = pixelSpriteDocument.imagePath.lexically_relative(projectManager.currentProject.projectPath).generic_string();
if (sidecarDocument.linkedSpriteName.empty() || sidecarDocument.linkedSpriteName == ".") {
sidecarDocument.linkedSpriteName = pixelSpriteDocument.imagePath.generic_string();
}
sidecarDocument.spriteVersion = 1;
sidecarDocument.expectedMinimumModuEngineVersionOrHigher =
pixelSpriteDocument.expectedMinimumModuEngineVersionOrHigher.empty() ? "ModuEngine V6.5" : pixelSpriteDocument.expectedMinimumModuEngineVersionOrHigher;
sidecarDocument.expectLayers = std::max(1, static_cast<int>(pixelSpriteDocument.layers.size()));
sidecarDocument.expectRects = static_cast<int>(pixelSpriteDocument.spriteFrames.size());
sidecarDocument.strictValidation = pixelSpriteDocument.strictValidation;
sidecarDocument.rects = pixelSpriteDocument.spriteFrames;
sidecarDocument.names = pixelSpriteDocument.spriteFrameNames;
sidecarDocument.layers = pixelSpriteDocument.layers;
sidecar << WriteSpritesheet(sidecarDocument);
}
renderer.invalidateTexture(pixelSpriteDocument.imagePath.string());
if (vulkanRenderer) {
vulkanRenderer->invalidateImagePath(pixelSpriteDocument.imagePath.string());
}
pixelSpriteDocument.dirty = false;
projectManager.currentProject.hasUnsavedChanges = true;
addConsoleMessage("Saved sprite image: " + pixelSpriteDocument.imagePath.string(), ConsoleMessageType::Success);
return true;
}
void Engine::renderPixelSpriteEditorWindow() {
if (!showPixelSpriteEditorWindow) return;
if (!pixelSpriteDocument.loaded) {
pixelSpriteDocument = PixelSpriteDocument{};
pixelSpriteDocument.pixels.assign(static_cast<size_t>(16 * 16 * 4), 0);
pixelSpriteDocument.loaded = true;
EnsureSpriteLayers(pixelSpriteDocument.layers);
pixelSpriteUndoStack.clear();
pixelSpriteRedoStack.clear();
PixelSpriteHistoryState initialState;
initialState.width = pixelSpriteDocument.width;
initialState.height = pixelSpriteDocument.height;
initialState.pixels = pixelSpriteDocument.pixels;
initialState.selectionActive = pixelSpriteDocument.selectionActive;
initialState.selectionStart = pixelSpriteDocument.selectionStart;
initialState.selectionEnd = pixelSpriteDocument.selectionEnd;
initialState.expectedMinimumModuEngineVersionOrHigher = pixelSpriteDocument.expectedMinimumModuEngineVersionOrHigher;
initialState.strictValidation = pixelSpriteDocument.strictValidation;
initialState.spriteFrames = pixelSpriteDocument.spriteFrames;
initialState.spriteFrameNames = pixelSpriteDocument.spriteFrameNames;
initialState.layers = pixelSpriteDocument.layers;
initialState.activeLayer = pixelSpriteDocument.activeLayer;
initialState.activeFrame = pixelSpriteDocument.activeFrame;
pixelSpriteUndoStack.push_back(std::move(initialState));
}
EnsureSpriteLayers(pixelSpriteDocument.layers);
pixelSpriteDocument.activeLayer = std::clamp(pixelSpriteDocument.activeLayer, 0, std::max(0, static_cast<int>(pixelSpriteDocument.layers.size()) - 1));
if (!ImGui::Begin("Pixel Sprite Editor", &showPixelSpriteEditorWindow, ImGuiWindowFlags_NoCollapse)) {
ImGui::End();
return;
}
auto ensureProjectAssetPath = [&]() {
if (!pixelSpriteDocument.imagePath.empty() || !projectManager.currentProject.isLoaded) {
return;
}
fs::path base = projectManager.currentProject.projectPath / "Assets" / "Sprites";
fs::create_directories(base);
pixelSpriteDocument.imagePath = base / "sprite.png";
pixelSpriteDocument.sidecarPath = pixelSpriteDocument.imagePath;
pixelSpriteDocument.sidecarPath += ".spritesheet";
};
auto pushHistory = [&]() {
PixelSpriteHistoryState state;
state.width = pixelSpriteDocument.width;
state.height = pixelSpriteDocument.height;
state.pixels = pixelSpriteDocument.pixels;
state.selectionActive = pixelSpriteDocument.selectionActive;
state.selectionStart = pixelSpriteDocument.selectionStart;
state.selectionEnd = pixelSpriteDocument.selectionEnd;
state.expectedMinimumModuEngineVersionOrHigher = pixelSpriteDocument.expectedMinimumModuEngineVersionOrHigher;
state.strictValidation = pixelSpriteDocument.strictValidation;
state.spriteFrames = pixelSpriteDocument.spriteFrames;
state.spriteFrameNames = pixelSpriteDocument.spriteFrameNames;
state.layers = pixelSpriteDocument.layers;
state.activeLayer = pixelSpriteDocument.activeLayer;
state.activeFrame = pixelSpriteDocument.activeFrame;
pixelSpriteUndoStack.push_back(std::move(state));
if (pixelSpriteUndoStack.size() > 128) {
pixelSpriteUndoStack.erase(pixelSpriteUndoStack.begin());
}
pixelSpriteRedoStack.clear();
};
auto commitHistoryTop = [&]() {
if (!pixelSpriteUndoStack.empty()) {
pixelSpriteUndoStack.back().width = pixelSpriteDocument.width;
pixelSpriteUndoStack.back().height = pixelSpriteDocument.height;
pixelSpriteUndoStack.back().pixels = pixelSpriteDocument.pixels;
pixelSpriteUndoStack.back().selectionActive = pixelSpriteDocument.selectionActive;
pixelSpriteUndoStack.back().selectionStart = pixelSpriteDocument.selectionStart;
pixelSpriteUndoStack.back().selectionEnd = pixelSpriteDocument.selectionEnd;
pixelSpriteUndoStack.back().expectedMinimumModuEngineVersionOrHigher = pixelSpriteDocument.expectedMinimumModuEngineVersionOrHigher;
pixelSpriteUndoStack.back().strictValidation = pixelSpriteDocument.strictValidation;
pixelSpriteUndoStack.back().spriteFrames = pixelSpriteDocument.spriteFrames;
pixelSpriteUndoStack.back().spriteFrameNames = pixelSpriteDocument.spriteFrameNames;
pixelSpriteUndoStack.back().layers = pixelSpriteDocument.layers;
pixelSpriteUndoStack.back().activeLayer = pixelSpriteDocument.activeLayer;
pixelSpriteUndoStack.back().activeFrame = pixelSpriteDocument.activeFrame;
}
};
auto undoHistory = [&]() {
if (pixelSpriteUndoStack.size() <= 1) return;
pixelSpriteRedoStack.push_back(pixelSpriteUndoStack.back());
pixelSpriteUndoStack.pop_back();
const PixelSpriteHistoryState& state = pixelSpriteUndoStack.back();
pixelSpriteDocument.width = state.width;
pixelSpriteDocument.height = state.height;
pixelSpriteDocument.pixels = state.pixels;
pixelSpriteDocument.selectionActive = state.selectionActive;
pixelSpriteDocument.selectionStart = state.selectionStart;
pixelSpriteDocument.selectionEnd = state.selectionEnd;
pixelSpriteDocument.expectedMinimumModuEngineVersionOrHigher = state.expectedMinimumModuEngineVersionOrHigher;
pixelSpriteDocument.strictValidation = state.strictValidation;
pixelSpriteDocument.spriteFrames = state.spriteFrames;
pixelSpriteDocument.spriteFrameNames = state.spriteFrameNames;
pixelSpriteDocument.layers = state.layers;
pixelSpriteDocument.activeLayer = std::clamp(state.activeLayer, 0, std::max(0, static_cast<int>(state.layers.size()) - 1));
pixelSpriteDocument.activeFrame = std::clamp(state.activeFrame, 0, std::max(0, static_cast<int>(state.spriteFrames.size()) - 1));
pixelSpriteDocument.dirty = true;
};
auto redoHistory = [&]() {
if (pixelSpriteRedoStack.empty()) return;
pixelSpriteUndoStack.push_back(pixelSpriteRedoStack.back());
pixelSpriteRedoStack.pop_back();
const PixelSpriteHistoryState& state = pixelSpriteUndoStack.back();
pixelSpriteDocument.width = state.width;
pixelSpriteDocument.height = state.height;
pixelSpriteDocument.pixels = state.pixels;
pixelSpriteDocument.selectionActive = state.selectionActive;
pixelSpriteDocument.selectionStart = state.selectionStart;
pixelSpriteDocument.selectionEnd = state.selectionEnd;
pixelSpriteDocument.expectedMinimumModuEngineVersionOrHigher = state.expectedMinimumModuEngineVersionOrHigher;
pixelSpriteDocument.strictValidation = state.strictValidation;
pixelSpriteDocument.spriteFrames = state.spriteFrames;
pixelSpriteDocument.spriteFrameNames = state.spriteFrameNames;
pixelSpriteDocument.layers = state.layers;
pixelSpriteDocument.activeLayer = std::clamp(state.activeLayer, 0, std::max(0, static_cast<int>(state.layers.size()) - 1));
pixelSpriteDocument.activeFrame = std::clamp(state.activeFrame, 0, std::max(0, static_cast<int>(state.spriteFrames.size()) - 1));
pixelSpriteDocument.dirty = true;
};
auto applyDocToSelectedSprite = [&]() {
SceneObject* selected = getSelectedObject();
if (!selected || !selected->hasUI ||
(selected->ui.type != UIElementType::Sprite2D && selected->ui.type != UIElementType::Image)) {
addConsoleMessage("Select a Sprite2D, Sprite 2.5D, or UI Image object to apply sprite clips.", ConsoleMessageType::Warning);
return;
}
ensureProjectAssetPath();
if (pixelSpriteDocument.imagePath.empty()) {
return;
}
if (pixelSpriteDocument.dirty) {
savePixelSpriteDocument();
}
selected->albedoTexturePath = pixelSpriteDocument.imagePath.string();
selected->ui.spriteSheetEnabled = true;
selected->ui.spriteCustomFramesEnabled = !pixelSpriteDocument.spriteFrames.empty();
selected->ui.spriteSourceWidth = pixelSpriteDocument.width;
selected->ui.spriteSourceHeight = pixelSpriteDocument.height;
selected->ui.spriteCustomFrames = pixelSpriteDocument.spriteFrames;
selected->ui.spriteCustomFrameNames = pixelSpriteDocument.spriteFrameNames;
if (!pixelSpriteDocument.spriteFrames.empty()) {
selected->ui.size.x = static_cast<float>(pixelSpriteDocument.spriteFrames[0].z);
selected->ui.size.y = static_cast<float>(pixelSpriteDocument.spriteFrames[0].w);
} else {
selected->ui.size.x = static_cast<float>(pixelSpriteDocument.width);
selected->ui.size.y = static_cast<float>(pixelSpriteDocument.height);
}
projectManager.currentProject.hasUnsavedChanges = true;
addConsoleMessage("Applied sprite clips to: " + selected->name, ConsoleMessageType::Success);
};
const bool editorFocused = ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows);
if (editorFocused && ImGui::GetIO().KeyCtrl && !ImGui::GetIO().KeyShift && ImGui::IsKeyPressed(ImGuiKey_Z, false)) {
undoHistory();
}
if (editorFocused && ((ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_Y, false)) ||
(ImGui::GetIO().KeyCtrl && ImGui::GetIO().KeyShift && ImGui::IsKeyPressed(ImGuiKey_Z, false)))) {
redoHistory();
}
char pathBuf[512];
std::snprintf(pathBuf, sizeof(pathBuf), "%s", pixelSpriteDocument.imagePath.string().c_str());
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 10.0f);
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 14.0f);
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.08f, 0.10f, 0.17f, 0.96f));
if (ImGui::BeginChild("PixelSpriteToolbar", ImVec2(0.0f, 156.0f), true)) {
ImGui::Columns(2, "PixelSpriteToolbarColumns", false);
ImGui::SetColumnWidth(0, std::max(420.0f, ImGui::GetWindowWidth() * 0.58f));
ImGui::TextDisabled("Workspace");
const char* modeLabels[] = { "Edit Mode", "Spritesheet Mode" };
int modeIndex = static_cast<int>(pixelSpriteEditorMode);
ImGui::SetNextItemWidth(180.0f);
if (ImGui::Combo("##PixelMode", &modeIndex, modeLabels, IM_ARRAYSIZE(modeLabels))) {
pixelSpriteEditorMode = static_cast<PixelSpriteEditorMode>(modeIndex);
}
ImGui::SameLine();
if (ImGui::Button("Save")) {
ensureProjectAssetPath();
savePixelSpriteDocument();
}
ImGui::SameLine();
if (ImGui::Button("Apply To Selected")) {
applyDocToSelectedSprite();
}
ImGui::SameLine();
ImGui::BeginDisabled(pixelSpriteUndoStack.size() <= 1);
if (ImGui::Button("Undo")) {
undoHistory();
}
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::BeginDisabled(pixelSpriteRedoStack.empty());
if (ImGui::Button("Redo")) {
redoHistory();
}
ImGui::EndDisabled();
ImGui::TextDisabled("Document");
if (ImGui::InputText("Image Path", pathBuf, sizeof(pathBuf))) {
pixelSpriteDocument.imagePath = fs::path(pathBuf);
pixelSpriteDocument.sidecarPath = pixelSpriteDocument.imagePath;
pixelSpriteDocument.sidecarPath += ".spritesheet";
pixelSpriteDocument.name = pixelSpriteDocument.imagePath.filename().string();
}
ImGui::SameLine();
if (ImGui::Button("Load") && fs::exists(pixelSpriteDocument.imagePath)) {
loadPixelSpriteDocument(pixelSpriteDocument.imagePath);
}
int dims[2] = { pixelSpriteDocument.width, pixelSpriteDocument.height };
if (ImGui::InputInt2("Canvas Size", dims)) {
dims[0] = std::clamp(dims[0], 1, 1024);
dims[1] = std::clamp(dims[1], 1, 1024);
if (dims[0] != pixelSpriteDocument.width || dims[1] != pixelSpriteDocument.height) {
pushHistory();
std::vector<unsigned char> resized(static_cast<size_t>(dims[0] * dims[1] * 4), 0);
const int copyW = std::min(dims[0], pixelSpriteDocument.width);
const int copyH = std::min(dims[1], pixelSpriteDocument.height);
for (int y = 0; y < copyH; ++y) {
for (int x = 0; x < copyW; ++x) {
for (int c = 0; c < 4; ++c) {
resized[static_cast<size_t>((y * dims[0] + x) * 4 + c)] =
pixelSpriteDocument.pixels[static_cast<size_t>((y * pixelSpriteDocument.width + x) * 4 + c)];
}
}
}
pixelSpriteDocument.width = dims[0];
pixelSpriteDocument.height = dims[1];
pixelSpriteDocument.pixels.swap(resized);
pixelSpriteDocument.dirty = true;
commitHistoryTop();
}
}
ImGui::NextColumn();
ImGui::TextDisabled("View");
if (ImGui::SliderFloat("Zoom", &pixelSpriteTargetZoom, 1.0f, 128.0f, "%.1f")) {
if (pixelSpritePixelPerfect) {
pixelSpriteTargetZoom = std::round(pixelSpriteTargetZoom);
}
}
ImGui::Checkbox("Pixel Perfect", &pixelSpritePixelPerfect);
ImGui::SameLine();
ImGui::Checkbox("Show Grid", &pixelSpriteShowGrid);
const char* checkerLabels[] = { "Light Checker", "Dark Checker" };
int checkerIndex = static_cast<int>(pixelSpriteCheckerTheme);
ImGui::SetNextItemWidth(170.0f);
if (ImGui::Combo("Background", &checkerIndex, checkerLabels, IM_ARRAYSIZE(checkerLabels))) {
pixelSpriteCheckerTheme = static_cast<PixelSpriteCheckerTheme>(checkerIndex);
}
ImGui::ColorEdit4("Primary", &pixelSpritePrimaryColor.x, ImGuiColorEditFlags_NoInputs);
ImGui::ColorEdit4("Secondary", &pixelSpriteSecondaryColor.x, ImGuiColorEditFlags_NoInputs);
ImGui::TextDisabled("Tools");
if (pixelSpriteEditorMode == PixelSpriteEditorMode::Edit) {
const char* toolLabels[] = { "Pencil", "Eraser", "Fill", "Select" };
int toolIndex = static_cast<int>(pixelSpriteTool);
ImGui::SetNextItemWidth(170.0f);
if (ImGui::Combo("Tool", &toolIndex, toolLabels, IM_ARRAYSIZE(toolLabels))) {
pixelSpriteTool = static_cast<PixelSpriteTool>(toolIndex);
}
} else {
if (ImGui::Button("Add Selection As Clip") && pixelSpriteDocument.selectionActive) {
pushHistory();
pixelSpriteDocument.spriteFrames.push_back(NormalizeRect(pixelSpriteDocument.selectionStart, pixelSpriteDocument.selectionEnd));
pixelSpriteDocument.spriteFrameNames.push_back("Rect_" + std::to_string(pixelSpriteDocument.spriteFrames.size() - 1));
pixelSpriteDocument.activeFrame = static_cast<int>(pixelSpriteDocument.spriteFrames.size()) - 1;
pixelSpriteDocument.dirty = true;
commitHistoryTop();
}
ImGui::SameLine();
if (ImGui::Button("Clear Clips")) {
pushHistory();
pixelSpriteDocument.spriteFrames.clear();
pixelSpriteDocument.spriteFrameNames.clear();
pixelSpriteDocument.activeFrame = 0;
pixelSpriteDocument.dirty = true;
commitHistoryTop();
}
if (!pixelSpriteDocument.spriteFrames.empty()) {
EnsureSpriteClipNames(pixelSpriteDocument.spriteFrameNames, pixelSpriteDocument.spriteFrames.size());
ImGui::TextDisabled("%d clipped sprites", static_cast<int>(pixelSpriteDocument.spriteFrames.size()));
ImGui::SliderInt("Selected Clip", &pixelSpriteDocument.activeFrame, 0, static_cast<int>(pixelSpriteDocument.spriteFrames.size()) - 1);
char clipNameBuf[128];
std::snprintf(clipNameBuf, sizeof(clipNameBuf), "%s",
pixelSpriteDocument.spriteFrameNames[pixelSpriteDocument.activeFrame].c_str());
if (ImGui::InputText("Clip Name", clipNameBuf, sizeof(clipNameBuf))) {
pixelSpriteDocument.spriteFrameNames[pixelSpriteDocument.activeFrame] = clipNameBuf;
pixelSpriteDocument.dirty = true;
commitHistoryTop();
}
}
ImGui::SeparatorText("Spritesheet");
char versionBuf[128];
std::snprintf(versionBuf, sizeof(versionBuf), "%s",
pixelSpriteDocument.expectedMinimumModuEngineVersionOrHigher.empty()
? "ModuEngine V6.5"
: pixelSpriteDocument.expectedMinimumModuEngineVersionOrHigher.c_str());
if (ImGui::InputText("Engine Version", versionBuf, sizeof(versionBuf))) {
pixelSpriteDocument.expectedMinimumModuEngineVersionOrHigher = versionBuf;
pixelSpriteDocument.dirty = true;
}
if (ImGui::Checkbox("Strict Validation", &pixelSpriteDocument.strictValidation)) {
pixelSpriteDocument.dirty = true;
}
ImGui::SeparatorText("Layers");
EnsureSpriteLayers(pixelSpriteDocument.layers);
if (ImGui::Button("Add Layer")) {
pushHistory();
pixelSpriteDocument.layers.push_back({"Layer_" + std::to_string(pixelSpriteDocument.layers.size())});
pixelSpriteDocument.activeLayer = static_cast<int>(pixelSpriteDocument.layers.size()) - 1;
pixelSpriteDocument.dirty = true;
commitHistoryTop();
}
ImGui::SameLine();
ImGui::BeginDisabled(pixelSpriteDocument.layers.size() <= 1);
if (ImGui::Button("Remove Layer")) {
pushHistory();
pixelSpriteDocument.layers.erase(pixelSpriteDocument.layers.begin() + pixelSpriteDocument.activeLayer);
EnsureSpriteLayers(pixelSpriteDocument.layers);
pixelSpriteDocument.activeLayer = std::clamp(pixelSpriteDocument.activeLayer, 0, std::max(0, static_cast<int>(pixelSpriteDocument.layers.size()) - 1));
pixelSpriteDocument.dirty = true;
commitHistoryTop();
}
ImGui::EndDisabled();
ImGui::SliderInt("Active Layer", &pixelSpriteDocument.activeLayer, 0, static_cast<int>(pixelSpriteDocument.layers.size()) - 1);
char layerNameBuf[128];
std::snprintf(layerNameBuf, sizeof(layerNameBuf), "%s",
pixelSpriteDocument.layers[pixelSpriteDocument.activeLayer].name.c_str());
if (ImGui::InputText("Layer Name", layerNameBuf, sizeof(layerNameBuf))) {
pixelSpriteDocument.layers[pixelSpriteDocument.activeLayer].name = layerNameBuf;
pixelSpriteDocument.dirty = true;
commitHistoryTop();
}
}
ImGui::Columns(1);
}
ImGui::EndChild();
ImGui::PopStyleColor();
ImGui::PopStyleVar(2);
ImGui::Separator();
ImGui::Text("%s%s", pixelSpriteDocument.name.c_str(), pixelSpriteDocument.dirty ? " *" : "");
ImGui::SameLine();
ImGui::TextDisabled("Ctrl+Wheel zoom | MMB pan | Ctrl+Z/Y history");
const float zoomBlend = std::clamp(deltaTime * 14.0f, 0.0f, 1.0f);
if (pixelSpritePixelPerfect) {
pixelSpriteTargetZoom = std::round(std::clamp(pixelSpriteTargetZoom, 1.0f, 128.0f));
} else {
pixelSpriteTargetZoom = std::clamp(pixelSpriteTargetZoom, 1.0f, 128.0f);
}
pixelSpriteZoom += (pixelSpriteTargetZoom - pixelSpriteZoom) * zoomBlend;
if (pixelSpritePixelPerfect) {
pixelSpriteZoom = std::round(pixelSpriteZoom);
}
const ImVec2 imageSize(
pixelSpriteDocument.width * pixelSpriteZoom,
pixelSpriteDocument.height * pixelSpriteZoom);
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.05f, 0.06f, 0.11f, 1.0f));
ImGui::BeginChild("PixelSpriteCanvas",
ImVec2(0.0f, 0.0f),
true,
ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoScrollbar);
ImGui::PopStyleColor();
const ImVec2 childWindowPos = ImGui::GetWindowPos();
const ImVec2 childContentMin = ImGui::GetWindowContentRegionMin();
const ImVec2 childContentMax = ImGui::GetWindowContentRegionMax();
const ImVec2 childPos(childWindowPos.x + childContentMin.x, childWindowPos.y + childContentMin.y);
const ImVec2 childMax(childWindowPos.x + childContentMax.x, childWindowPos.y + childContentMax.y);
const ImVec2 avail(childMax.x - childPos.x, childMax.y - childPos.y);
if (pixelSpriteCanvasCenterPending) {
pixelSpriteCanvasPan = ImVec2(0.0f, 0.0f);
pixelSpriteCanvasTargetPan = pixelSpriteCanvasPan;
pixelSpriteCanvasCenterPending = false;
}
ImGui::SetCursorScreenPos(childPos);
ImGui::InvisibleButton("PixelCanvasButton", avail,
ImGuiButtonFlags_MouseButtonLeft |
ImGuiButtonFlags_MouseButtonRight |
ImGuiButtonFlags_MouseButtonMiddle);
const bool canvasHovered = ImGui::IsItemHovered();
const bool canvasActive = ImGui::IsItemActive();
const ImVec2 mousePos = ImGui::GetIO().MousePos;
const ImVec2 mouseViewport(mousePos.x - childPos.x, mousePos.y - childPos.y);
const bool ctrlWheelZoom = canvasHovered && ImGui::GetIO().KeyCtrl && std::abs(ImGui::GetIO().MouseWheel) > 0.0f;
const bool middleMousePan = canvasActive &&
ImGui::IsMouseDown(ImGuiMouseButton_Middle) &&
ImGui::IsMouseDragging(ImGuiMouseButton_Middle, 0.0f);
if (ctrlWheelZoom) {
const float oldZoom = std::max(0.001f, pixelSpriteZoom);
float nextZoom = oldZoom;
if (pixelSpritePixelPerfect) {
const float direction = (ImGui::GetIO().MouseWheel > 0.0f) ? 1.0f : -1.0f;
nextZoom = std::clamp(std::round(oldZoom) + direction, 1.0f, 128.0f);
} else {
nextZoom = std::clamp(oldZoom * (ImGui::GetIO().MouseWheel > 0.0f ? 1.12f : (1.0f / 1.12f)), 1.0f, 128.0f);
}
const ImVec2 oldImageMin(
childPos.x + (avail.x - imageSize.x) * 0.5f + pixelSpriteCanvasPan.x,
childPos.y + (avail.y - imageSize.y) * 0.5f + pixelSpriteCanvasPan.y);
const float imageSpaceX = (mousePos.x - oldImageMin.x) / oldZoom;
const float imageSpaceY = (mousePos.y - oldImageMin.y) / oldZoom;
pixelSpriteTargetZoom = nextZoom;
pixelSpriteZoom = nextZoom;
const ImVec2 nextImageSize(
pixelSpriteDocument.width * nextZoom,
pixelSpriteDocument.height * nextZoom);
const ImVec2 nextCenteredMin(
childPos.x + (avail.x - nextImageSize.x) * 0.5f,
childPos.y + (avail.y - nextImageSize.y) * 0.5f);
pixelSpriteCanvasPan.x = mousePos.x - (nextCenteredMin.x + imageSpaceX * nextZoom);
pixelSpriteCanvasPan.y = mousePos.y - (nextCenteredMin.y + imageSpaceY * nextZoom);
pixelSpriteCanvasTargetPan = pixelSpriteCanvasPan;
} else if (middleMousePan) {
const ImVec2 delta = ImGui::GetIO().MouseDelta;
pixelSpriteCanvasPan.x += delta.x;
pixelSpriteCanvasPan.y += delta.y;
pixelSpriteCanvasTargetPan = pixelSpriteCanvasPan;
}
ImDrawList* draw = ImGui::GetWindowDrawList();
draw->PushClipRect(childPos, childMax, true);
const ImVec2 canvasOrigin(
childPos.x + (avail.x - imageSize.x) * 0.5f + pixelSpriteCanvasPan.x,
childPos.y + (avail.y - imageSize.y) * 0.5f + pixelSpriteCanvasPan.y);
const ImVec2 imageMin = canvasOrigin;
const ImVec2 imageMax(canvasOrigin.x + imageSize.x, canvasOrigin.y + imageSize.y);
const int sampleStep = std::max(1, static_cast<int>(std::ceil(3.0f / std::max(0.001f, pixelSpriteZoom))));
const bool darkChecker = pixelSpriteCheckerTheme == PixelSpriteCheckerTheme::Dark;
for (int y = 0; y < pixelSpriteDocument.height; y += sampleStep) {
for (int x = 0; x < pixelSpriteDocument.width; x += sampleStep) {
const int blockW = std::min(sampleStep, pixelSpriteDocument.width - x);
const int blockH = std::min(sampleStep, pixelSpriteDocument.height - y);
const ImVec2 p0(canvasOrigin.x + x * pixelSpriteZoom, canvasOrigin.y + y * pixelSpriteZoom);
const ImVec2 p1(p0.x + blockW * pixelSpriteZoom, p0.y + blockH * pixelSpriteZoom);
draw->AddRectFilled(p0, p1, CheckerColor(darkChecker, ((x + y) & 1) != 0));
}
}
for (int y = 0; y < pixelSpriteDocument.height; y += sampleStep) {
for (int x = 0; x < pixelSpriteDocument.width; x += sampleStep) {
const size_t idx = static_cast<size_t>((y * pixelSpriteDocument.width + x) * 4);
const ImU32 color = IM_COL32(
pixelSpriteDocument.pixels[idx + 0],
pixelSpriteDocument.pixels[idx + 1],
pixelSpriteDocument.pixels[idx + 2],
pixelSpriteDocument.pixels[idx + 3]);
if ((color >> IM_COL32_A_SHIFT) == 0) {
continue;
}
const int blockW = std::min(sampleStep, pixelSpriteDocument.width - x);
const int blockH = std::min(sampleStep, pixelSpriteDocument.height - y);
const ImVec2 p0(canvasOrigin.x + x * pixelSpriteZoom, canvasOrigin.y + y * pixelSpriteZoom);
const ImVec2 p1(p0.x + blockW * pixelSpriteZoom, p0.y + blockH * pixelSpriteZoom);
draw->AddRectFilled(p0, p1, color);
if (sampleStep == 1 && pixelSpriteShowGrid && pixelSpriteZoom >= 8.0f) {
draw->AddRect(p0, p1, IM_COL32(0, 0, 0, 72));
}
}
}
for (size_t i = 0; i < pixelSpriteDocument.spriteFrames.size(); ++i) {
const glm::ivec4 frame = pixelSpriteDocument.spriteFrames[i];
EnsureSpriteClipNames(pixelSpriteDocument.spriteFrameNames, pixelSpriteDocument.spriteFrames.size());
const ImU32 frameColor = static_cast<int>(i) == pixelSpriteDocument.activeFrame
? IM_COL32(250, 210, 80, 255)
: IM_COL32(80, 220, 170, 220);
draw->AddRect(
ImVec2(canvasOrigin.x + frame.x * pixelSpriteZoom, canvasOrigin.y + frame.y * pixelSpriteZoom),
ImVec2(canvasOrigin.x + (frame.x + frame.z) * pixelSpriteZoom, canvasOrigin.y + (frame.y + frame.w) * pixelSpriteZoom),
frameColor,
0.0f,
0,
2.0f);
draw->AddText(
ImVec2(canvasOrigin.x + frame.x * pixelSpriteZoom + 4.0f,
canvasOrigin.y + frame.y * pixelSpriteZoom + 2.0f),
frameColor,
pixelSpriteDocument.spriteFrameNames[i].c_str());
}
if (pixelSpriteDocument.selectionActive) {
const glm::ivec4 rect = NormalizeRect(pixelSpriteDocument.selectionStart, pixelSpriteDocument.selectionEnd);
draw->AddRect(
ImVec2(canvasOrigin.x + rect.x * pixelSpriteZoom, canvasOrigin.y + rect.y * pixelSpriteZoom),
ImVec2(canvasOrigin.x + (rect.x + rect.z) * pixelSpriteZoom, canvasOrigin.y + (rect.y + rect.w) * pixelSpriteZoom),
IM_COL32(255, 255, 255, 220),
0.0f,
0,
2.0f);
}
draw->AddRect(imageMin, imageMax, IM_COL32(255, 255, 255, 42));
draw->PopClipRect();
const bool hovered = canvasHovered;
const bool held = canvasActive;
const int px = static_cast<int>((mousePos.x - canvasOrigin.x) / pixelSpriteZoom);
const int py = static_cast<int>((mousePos.y - canvasOrigin.y) / pixelSpriteZoom);
const bool validPixel = hovered && px >= 0 && py >= 0 && px < pixelSpriteDocument.width && py < pixelSpriteDocument.height;
if (validPixel && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
if (pixelSpriteTool == PixelSpriteTool::Select || pixelSpriteEditorMode == PixelSpriteEditorMode::SpriteSheet) {
bool clickedExistingClip = false;
if (pixelSpriteEditorMode == PixelSpriteEditorMode::SpriteSheet) {
for (size_t i = 0; i < pixelSpriteDocument.spriteFrames.size(); ++i) {
const glm::ivec4& rect = pixelSpriteDocument.spriteFrames[i];
if (px >= rect.x && py >= rect.y && px < rect.x + rect.z && py < rect.y + rect.w) {
pixelSpriteDocument.activeFrame = static_cast<int>(i);
pixelSpriteDocument.selectionActive = true;
pixelSpriteDocument.selectionStart = glm::ivec2(rect.x, rect.y);
pixelSpriteDocument.selectionEnd = glm::ivec2(rect.x + rect.z - 1, rect.y + rect.w - 1);
clickedExistingClip = true;
break;
}
}
}
if (clickedExistingClip) {
// Selection synced to an existing clip; don't start a new drag box.
} else {
pushHistory();
pixelSpriteDocument.selectionActive = true;
pixelSpriteDocument.selectionStart = glm::ivec2(px, py);
pixelSpriteDocument.selectionEnd = glm::ivec2(px, py);
commitHistoryTop();
}
} else if (pixelSpriteTool == PixelSpriteTool::Fill) {
const size_t idx = static_cast<size_t>((py * pixelSpriteDocument.width + px) * 4);
PixelRgba target{
pixelSpriteDocument.pixels[idx + 0],
pixelSpriteDocument.pixels[idx + 1],
pixelSpriteDocument.pixels[idx + 2],
pixelSpriteDocument.pixels[idx + 3]
};
pushHistory();
FloodFill(pixelSpriteDocument.pixels, pixelSpriteDocument.width, pixelSpriteDocument.height, px, py, target, ToRgba8(pixelSpritePrimaryColor));
pixelSpriteDocument.dirty = true;
commitHistoryTop();
} else {
pushHistory();
const PixelRgba color = (pixelSpriteTool == PixelSpriteTool::Eraser)
? ToRgba8(pixelSpriteSecondaryColor)
: ToRgba8(pixelSpritePrimaryColor);
SetPixel(pixelSpriteDocument.pixels, pixelSpriteDocument.width, pixelSpriteDocument.height, px, py, color);
pixelSpriteDocument.dirty = true;
commitHistoryTop();
}
}
if (validPixel && held && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
if (pixelSpriteTool == PixelSpriteTool::Select || pixelSpriteEditorMode == PixelSpriteEditorMode::SpriteSheet) {
pixelSpriteDocument.selectionEnd = glm::ivec2(px, py);
commitHistoryTop();
} else if (pixelSpriteTool != PixelSpriteTool::Fill) {
const PixelRgba color = (pixelSpriteTool == PixelSpriteTool::Eraser)
? ToRgba8(pixelSpriteSecondaryColor)
: ToRgba8(pixelSpritePrimaryColor);
SetPixel(pixelSpriteDocument.pixels, pixelSpriteDocument.width, pixelSpriteDocument.height, px, py, color);
pixelSpriteDocument.dirty = true;
commitHistoryTop();
}
}
ImGui::EndChild();
ImGui::End();
}

View File

@@ -1,5 +1,6 @@
#include "Engine.h"
#include "ModelLoader.h"
#include "../SpritesheetFormat.h"
#include <algorithm>
#include <array>
#include <cstring>
@@ -24,6 +25,29 @@
#pragma region Hierarchy Helpers
namespace {
bool IsSpriteSheetSidecarPath(const fs::path& path) {
return path.extension() == ".spritesheet";
}
fs::path ResolveSpriteSheetImagePath(const fs::path& path) {
if (!IsSpriteSheetSidecarPath(path)) {
return path;
}
fs::path imagePath = path;
imagePath.replace_extension();
return imagePath;
}
std::vector<glm::ivec4> LoadSpriteSheetRects(const fs::path& sidecarPath) {
std::ifstream sidecar(sidecarPath);
if (!sidecar.is_open()) {
return {};
}
std::ostringstream buffer;
buffer << sidecar.rdbuf();
return ParseSpritesheet(buffer.str()).document.rects;
}
std::optional<std::string> InferManagedTypeFromSource(const std::string& source,
const std::string& fallbackClass) {
std::string nameSpace;
@@ -590,6 +614,26 @@ namespace {
}
}
}
std::vector<std::string> LoadSpriteSheetNames(const fs::path& sidecarPath) {
std::ifstream sidecar(sidecarPath);
if (!sidecar.is_open()) {
return {};
}
std::ostringstream buffer;
buffer << sidecar.rdbuf();
return ParseSpritesheet(buffer.str()).document.names;
}
void EnsureSpriteClipNames(std::vector<std::string>& names, size_t count) {
if (names.size() < count) {
for (size_t i = names.size(); i < count; ++i) {
names.push_back("Rect_" + std::to_string(i));
}
} else if (names.size() > count) {
names.resize(count);
}
}
#pragma endregion
#pragma region Hierarchy Panel
@@ -1400,6 +1444,55 @@ void Engine::renderInspectorPanel() {
return changed;
};
auto isTextureOrSpriteSheetSelection = [&](const fs::path& path) {
if (path.empty() || !fs::exists(path)) return false;
std::error_code ec;
fs::directory_entry entry(path, ec);
if (ec) return false;
return fileBrowser.isTextureFile(entry) || IsSpriteSheetSidecarPath(path);
};
auto assignSpriteTextureOrClips = [&](SceneObject& target, const fs::path& sourcePath) -> bool {
if (sourcePath.empty() || !fs::exists(sourcePath)) {
return false;
}
const fs::path imagePath = ResolveSpriteSheetImagePath(sourcePath);
if (!fs::exists(imagePath)) {
return false;
}
target.albedoTexturePath = imagePath.string();
const fs::path sidecarPath = IsSpriteSheetSidecarPath(sourcePath) ? sourcePath : fs::path(imagePath.string() + ".spritesheet");
std::vector<glm::ivec4> clips;
if (fs::exists(sidecarPath)) {
clips = LoadSpriteSheetRects(sidecarPath);
}
std::vector<std::string> clipNames;
if (fs::exists(sidecarPath)) {
clipNames = LoadSpriteSheetNames(sidecarPath);
}
target.ui.spriteCustomFrames = std::move(clips);
target.ui.spriteCustomFrameNames = std::move(clipNames);
EnsureSpriteClipNames(target.ui.spriteCustomFrameNames, target.ui.spriteCustomFrames.size());
target.ui.spriteCustomFramesEnabled = !target.ui.spriteCustomFrames.empty();
target.ui.spriteSheetEnabled = target.ui.spriteCustomFramesEnabled || target.ui.spriteSheetEnabled;
target.ui.spriteSheetFrame = 0;
target.ui.spriteSourceWidth = 0;
target.ui.spriteSourceHeight = 0;
if (Texture* tex = renderer.getTexture(target.albedoTexturePath, MaterialProperties::TextureFilter::Point)) {
target.ui.spriteSourceWidth = tex->GetWidth();
target.ui.spriteSourceHeight = tex->GetHeight();
}
if (target.ui.spriteCustomFramesEnabled) {
target.ui.size.x = static_cast<float>(std::max(1, target.ui.spriteCustomFrames[0].z));
target.ui.size.y = static_cast<float>(std::max(1, target.ui.spriteCustomFrames[0].w));
}
return true;
};
auto renderMaterialAssetPanel = [&](const char* headerTitle, bool allowApply) {
if (!browserHasMaterial) return;
@@ -1425,8 +1518,9 @@ void Engine::renderInspectorPanel() {
const char* dropped = static_cast<const char*>(payload->Data);
std::error_code ec;
fs::directory_entry droppedEntry(fs::path(dropped), ec);
if (!ec && fileBrowser.isTextureFile(droppedEntry)) {
path = dropped;
const fs::path droppedPath(dropped);
if ((!ec && fileBrowser.isTextureFile(droppedEntry)) || IsSpriteSheetSidecarPath(droppedPath)) {
path = ResolveSpriteSheetImagePath(droppedPath).string();
changed = true;
}
}
@@ -1438,12 +1532,11 @@ void Engine::renderInspectorPanel() {
changed = true;
}
ImGui::SameLine();
bool canUseTex = !fileBrowser.selectedFile.empty() && fs::exists(fileBrowser.selectedFile) &&
fileBrowser.isTextureFile(fs::directory_entry(fileBrowser.selectedFile));
bool canUseTex = isTextureOrSpriteSheetSelection(fileBrowser.selectedFile);
ImGui::BeginDisabled(!canUseTex);
std::string btnLabel = std::string("Use Selection##") + idSuffix;
if (ImGui::SmallButton(btnLabel.c_str())) {
path = fileBrowser.selectedFile.string();
path = ResolveSpriteSheetImagePath(fileBrowser.selectedFile).string();
changed = true;
}
ImGui::EndDisabled();
@@ -1720,12 +1813,14 @@ void Engine::renderInspectorPanel() {
ImGui::TextDisabled("%s", selectedTexturePath.filename().string().c_str());
ImGui::TextColored(ImVec4(0.8f, 0.65f, 0.95f, 1.0f), "%s", selectedTexturePath.string().c_str());
Texture* previewTex = renderer.getTexture(selectedTexturePath.string());
static float textureAssetPreviewZoom = 1.0f;
Texture* previewTex = renderer.getTexture(selectedTexturePath.string(), MaterialProperties::TextureFilter::Point);
ImGui::Spacing();
if (previewTex && previewTex->GetID()) {
ImGui::SliderFloat("Preview Zoom", &textureAssetPreviewZoom, 0.25f, 16.0f, "%.2fx", ImGuiSliderFlags_Logarithmic);
float maxWidth = ImGui::GetContentRegionAvail().x;
float size = std::min(maxWidth, 160.0f);
float size = std::min(maxWidth, 160.0f * textureAssetPreviewZoom);
float aspect = previewTex->GetHeight() > 0 ? (previewTex->GetWidth() / static_cast<float>(previewTex->GetHeight())) : 1.0f;
ImVec2 imageSize(size, size);
if (aspect > 1.0f) {
@@ -2019,22 +2114,50 @@ void Engine::renderInspectorPanel() {
std::snprintf(texBuf, sizeof(texBuf), "%s", obj.albedoTexturePath.c_str());
if (ImGui::InputText("##UITexture", texBuf, sizeof(texBuf))) {
obj.albedoTexturePath = texBuf;
obj.ui.spriteCustomFrames.clear();
obj.ui.spriteCustomFrameNames.clear();
obj.ui.spriteCustomFramesEnabled = false;
obj.ui.spriteSourceWidth = 0;
obj.ui.spriteSourceHeight = 0;
changed = true;
}
ImGui::SameLine();
if (ImGui::SmallButton("Clear##UITexture")) {
obj.albedoTexturePath.clear();
obj.ui.spriteCustomFrames.clear();
obj.ui.spriteCustomFrameNames.clear();
obj.ui.spriteCustomFramesEnabled = false;
obj.ui.spriteSourceWidth = 0;
obj.ui.spriteSourceHeight = 0;
obj.ui.spriteSheetFrame = 0;
changed = true;
}
ImGui::SameLine();
bool canUseTex = !fileBrowser.selectedFile.empty() && fs::exists(fileBrowser.selectedFile) &&
fileBrowser.isTextureFile(fs::directory_entry(fileBrowser.selectedFile));
bool canUseTex = isTextureOrSpriteSheetSelection(fileBrowser.selectedFile);
ImGui::BeginDisabled(!canUseTex);
if (ImGui::SmallButton("Use Selection##UITexture")) {
obj.albedoTexturePath = fileBrowser.selectedFile.string();
changed = true;
if (assignSpriteTextureOrClips(obj, fileBrowser.selectedFile)) {
changed = true;
}
}
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::BeginDisabled(obj.albedoTexturePath.empty());
if (ImGui::SmallButton("Reload Clips##UITexture")) {
if (assignSpriteTextureOrClips(obj, fs::path(obj.albedoTexturePath))) {
changed = true;
}
}
ImGui::EndDisabled();
if (ImGui::BeginDragDropTarget()) {
if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("FILE_PATH")) {
const char* dropped = static_cast<const char*>(payload->Data);
if (assignSpriteTextureOrClips(obj, fs::path(dropped))) {
changed = true;
}
}
ImGui::EndDragDropTarget();
}
if (!obj.albedoTexturePath.empty()) {
ImGui::SameLine();
@@ -2046,17 +2169,27 @@ void Engine::renderInspectorPanel() {
}
}
if (Texture* previewTex = (!obj.albedoTexturePath.empty() ? renderer.getTexture(obj.albedoTexturePath) : nullptr)) {
if (Texture* previewTex = (!obj.albedoTexturePath.empty()
? renderer.getTexture(obj.albedoTexturePath, MaterialProperties::TextureFilter::Point)
: nullptr)) {
if (previewTex->GetID()) {
ImGui::Spacing();
ImGui::TextDisabled("Sprite Preview");
std::array<ImVec2, 4> uvQuad = buildSpriteSheetUvs(obj);
ImVec2 uvMin(uvQuad[0].x, uvQuad[0].y);
ImVec2 uvMax(uvQuad[2].x, uvQuad[2].y);
float frameWidth = static_cast<float>(previewTex->GetWidth());
float frameHeight = static_cast<float>(previewTex->GetHeight());
if (obj.ui.spriteCustomFramesEnabled && !obj.ui.spriteCustomFrames.empty()) {
const glm::ivec4 frame = obj.ui.spriteCustomFrames[std::clamp(obj.ui.spriteSheetFrame, 0, static_cast<int>(obj.ui.spriteCustomFrames.size()) - 1)];
frameWidth = static_cast<float>(frame.z);
frameHeight = static_cast<float>(frame.w);
} else if (obj.ui.spriteSheetEnabled) {
frameWidth = std::max(1.0f, frameWidth / static_cast<float>(std::max(1, obj.ui.spriteSheetColumns)));
frameHeight = std::max(1.0f, frameHeight / static_cast<float>(std::max(1, obj.ui.spriteSheetRows)));
}
float previewWidth = std::min(ImGui::GetContentRegionAvail().x, 196.0f);
float aspect = (previewTex->GetWidth() > 0)
? (static_cast<float>(previewTex->GetHeight()) / static_cast<float>(previewTex->GetWidth()))
: 1.0f;
float aspect = frameWidth > 0.0f ? (frameHeight / frameWidth) : 1.0f;
ImVec2 previewSize(previewWidth, std::max(64.0f, previewWidth * aspect));
ImGui::Image((ImTextureID)(intptr_t)previewTex->GetID(), previewSize, uvMin, uvMax);
}
@@ -2067,25 +2200,50 @@ void Engine::renderInspectorPanel() {
changed = true;
}
ImGui::BeginDisabled(!obj.ui.spriteSheetEnabled);
if (ImGui::DragInt("Columns", &obj.ui.spriteSheetColumns, 1.0f, 1, 1024)) {
obj.ui.spriteSheetColumns = std::max(1, obj.ui.spriteSheetColumns);
changed = true;
}
if (ImGui::DragInt("Rows", &obj.ui.spriteSheetRows, 1.0f, 1, 1024)) {
obj.ui.spriteSheetRows = std::max(1, obj.ui.spriteSheetRows);
changed = true;
}
int frameCount = std::max(1, obj.ui.spriteSheetColumns * obj.ui.spriteSheetRows);
if (ImGui::SliderInt("Frame", &obj.ui.spriteSheetFrame, 0, frameCount - 1)) {
obj.ui.spriteSheetFrame = std::clamp(obj.ui.spriteSheetFrame, 0, frameCount - 1);
changed = true;
}
if (ImGui::DragFloat("FPS", &obj.ui.spriteSheetFps, 0.1f, 1.0f, 120.0f, "%.1f")) {
obj.ui.spriteSheetFps = std::clamp(obj.ui.spriteSheetFps, 1.0f, 120.0f);
changed = true;
}
if (ImGui::Checkbox("Loop", &obj.ui.spriteSheetLoop)) {
changed = true;
const bool usingCustomClips = obj.ui.spriteCustomFramesEnabled && !obj.ui.spriteCustomFrames.empty();
if (usingCustomClips) {
const int clipCount = static_cast<int>(obj.ui.spriteCustomFrames.size());
EnsureSpriteClipNames(obj.ui.spriteCustomFrameNames, obj.ui.spriteCustomFrames.size());
ImGui::TextDisabled("Using %d cropped sprite clips.", clipCount);
obj.ui.spriteSheetFrame = std::clamp(obj.ui.spriteSheetFrame, 0, clipCount - 1);
const char* previewName = obj.ui.spriteCustomFrameNames[obj.ui.spriteSheetFrame].c_str();
if (ImGui::BeginCombo("Clip", previewName)) {
for (int clipIndex = 0; clipIndex < clipCount; ++clipIndex) {
bool selected = (clipIndex == obj.ui.spriteSheetFrame);
if (ImGui::Selectable(obj.ui.spriteCustomFrameNames[clipIndex].c_str(), selected)) {
obj.ui.spriteSheetFrame = clipIndex;
changed = true;
}
if (selected) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
int clipIndex = obj.ui.spriteSheetFrame;
if (ImGui::SliderInt("Clip Index", &clipIndex, 0, clipCount - 1)) {
obj.ui.spriteSheetFrame = std::clamp(clipIndex, 0, clipCount - 1);
changed = true;
}
} else {
if (ImGui::DragInt("Columns", &obj.ui.spriteSheetColumns, 1.0f, 1, 1024)) {
obj.ui.spriteSheetColumns = std::max(1, obj.ui.spriteSheetColumns);
changed = true;
}
if (ImGui::DragInt("Rows", &obj.ui.spriteSheetRows, 1.0f, 1, 1024)) {
obj.ui.spriteSheetRows = std::max(1, obj.ui.spriteSheetRows);
changed = true;
}
int frameCount = std::max(1, obj.ui.spriteSheetColumns * obj.ui.spriteSheetRows);
if (ImGui::SliderInt("Frame", &obj.ui.spriteSheetFrame, 0, frameCount - 1)) {
obj.ui.spriteSheetFrame = std::clamp(obj.ui.spriteSheetFrame, 0, frameCount - 1);
changed = true;
}
if (ImGui::DragFloat("FPS", &obj.ui.spriteSheetFps, 0.1f, 1.0f, 120.0f, "%.1f")) {
obj.ui.spriteSheetFps = std::clamp(obj.ui.spriteSheetFps, 1.0f, 120.0f);
changed = true;
}
if (ImGui::Checkbox("Loop", &obj.ui.spriteSheetLoop)) {
changed = true;
}
}
ImGui::EndDisabled();
}
@@ -3577,7 +3735,25 @@ void Engine::renderInspectorPanel() {
materialChanged |= textureField("Normal Map", "ObjNormal", obj.normalMapPath);
if (obj.renderType == RenderType::Sprite) {
bool canUseSpriteAsset = isTextureOrSpriteSheetSelection(fileBrowser.selectedFile);
ImGui::BeginDisabled(!canUseSpriteAsset);
if (ImGui::SmallButton("Use Selection As Sprite Asset##WorldSpriteAsset")) {
if (assignSpriteTextureOrClips(obj, fileBrowser.selectedFile)) {
materialChanged = true;
}
}
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::BeginDisabled(obj.albedoTexturePath.empty());
if (ImGui::SmallButton("Reload Clips##WorldSpriteAsset")) {
if (assignSpriteTextureOrClips(obj, fs::path(obj.albedoTexturePath))) {
materialChanged = true;
}
}
ImGui::EndDisabled();
if (!obj.albedoTexturePath.empty()) {
ImGui::SameLine();
if (ImGui::SmallButton("Import Sheet##WorldSpriteSheet")) {
pendingSpriteSheetPath = obj.albedoTexturePath;
std::snprintf(importSpriteSheetName, sizeof(importSpriteSheetName), "%s", obj.name.c_str());
@@ -3591,25 +3767,50 @@ void Engine::renderInspectorPanel() {
materialChanged = true;
}
ImGui::BeginDisabled(!obj.ui.spriteSheetEnabled);
if (ImGui::DragInt("Columns", &obj.ui.spriteSheetColumns, 1.0f, 1, 1024)) {
obj.ui.spriteSheetColumns = std::max(1, obj.ui.spriteSheetColumns);
materialChanged = true;
}
if (ImGui::DragInt("Rows", &obj.ui.spriteSheetRows, 1.0f, 1, 1024)) {
obj.ui.spriteSheetRows = std::max(1, obj.ui.spriteSheetRows);
materialChanged = true;
}
int frameCount = std::max(1, obj.ui.spriteSheetColumns * obj.ui.spriteSheetRows);
if (ImGui::SliderInt("Frame", &obj.ui.spriteSheetFrame, 0, frameCount - 1)) {
obj.ui.spriteSheetFrame = std::clamp(obj.ui.spriteSheetFrame, 0, frameCount - 1);
materialChanged = true;
}
if (ImGui::DragFloat("FPS", &obj.ui.spriteSheetFps, 0.1f, 1.0f, 120.0f, "%.1f")) {
obj.ui.spriteSheetFps = std::clamp(obj.ui.spriteSheetFps, 1.0f, 120.0f);
materialChanged = true;
}
if (ImGui::Checkbox("Loop", &obj.ui.spriteSheetLoop)) {
materialChanged = true;
const bool usingCustomClips = obj.ui.spriteCustomFramesEnabled && !obj.ui.spriteCustomFrames.empty();
if (usingCustomClips) {
const int clipCount = static_cast<int>(obj.ui.spriteCustomFrames.size());
EnsureSpriteClipNames(obj.ui.spriteCustomFrameNames, obj.ui.spriteCustomFrames.size());
ImGui::TextDisabled("Using %d cropped sprite clips.", clipCount);
obj.ui.spriteSheetFrame = std::clamp(obj.ui.spriteSheetFrame, 0, clipCount - 1);
const char* previewName = obj.ui.spriteCustomFrameNames[obj.ui.spriteSheetFrame].c_str();
if (ImGui::BeginCombo("Clip", previewName)) {
for (int clipIndex = 0; clipIndex < clipCount; ++clipIndex) {
bool selected = (clipIndex == obj.ui.spriteSheetFrame);
if (ImGui::Selectable(obj.ui.spriteCustomFrameNames[clipIndex].c_str(), selected)) {
obj.ui.spriteSheetFrame = clipIndex;
materialChanged = true;
}
if (selected) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
int clipIndex = obj.ui.spriteSheetFrame;
if (ImGui::SliderInt("Clip Index", &clipIndex, 0, clipCount - 1)) {
obj.ui.spriteSheetFrame = std::clamp(clipIndex, 0, clipCount - 1);
materialChanged = true;
}
} else {
if (ImGui::DragInt("Columns", &obj.ui.spriteSheetColumns, 1.0f, 1, 1024)) {
obj.ui.spriteSheetColumns = std::max(1, obj.ui.spriteSheetColumns);
materialChanged = true;
}
if (ImGui::DragInt("Rows", &obj.ui.spriteSheetRows, 1.0f, 1, 1024)) {
obj.ui.spriteSheetRows = std::max(1, obj.ui.spriteSheetRows);
materialChanged = true;
}
int frameCount = std::max(1, obj.ui.spriteSheetColumns * obj.ui.spriteSheetRows);
if (ImGui::SliderInt("Frame", &obj.ui.spriteSheetFrame, 0, frameCount - 1)) {
obj.ui.spriteSheetFrame = std::clamp(obj.ui.spriteSheetFrame, 0, frameCount - 1);
materialChanged = true;
}
if (ImGui::DragFloat("FPS", &obj.ui.spriteSheetFps, 0.1f, 1.0f, 120.0f, "%.1f")) {
obj.ui.spriteSheetFps = std::clamp(obj.ui.spriteSheetFps, 1.0f, 120.0f);
materialChanged = true;
}
if (ImGui::Checkbox("Loop", &obj.ui.spriteSheetLoop)) {
materialChanged = true;
}
}
ImGui::EndDisabled();
}
@@ -5607,12 +5808,17 @@ void Engine::renderDialogs() {
if (!sceneObjects.empty()) {
SceneObject& created = sceneObjects.back();
created.albedoTexturePath = pendingSpriteSheetPath;
created.material.textureFilter = MaterialProperties::TextureFilter::Point;
created.ui.spriteSheetEnabled = true;
created.ui.spriteSheetColumns = std::max(1, importSpriteSheetColumns);
created.ui.spriteSheetRows = std::max(1, importSpriteSheetRows);
created.ui.spriteSheetFrame = 0;
created.ui.spriteSheetFps = importSpriteSheetFps;
created.ui.spriteSheetLoop = true;
created.ui.spriteCustomFramesEnabled = false;
created.ui.spriteSourceWidth = texW;
created.ui.spriteSourceHeight = texH;
created.ui.spriteCustomFrames.clear();
if (texW > 0 && texH > 0) {
created.ui.size.x = std::max(1.0f, static_cast<float>(texW) / static_cast<float>(created.ui.spriteSheetColumns));
created.ui.size.y = std::max(1.0f, static_cast<float>(texH) / static_cast<float>(created.ui.spriteSheetRows));

View File

@@ -54,6 +54,12 @@ bool ResolveProjectedSprite25DRect(const SceneObject& obj,
glm::vec3 cameraRight = glm::normalize(glm::vec3(invView[0]));
glm::vec3 cameraUp = glm::normalize(glm::vec3(invView[1]));
glm::vec2 baseSize = glm::max(obj.ui.size, glm::vec2(1.0f));
if (obj.ui.spriteCustomFramesEnabled && !obj.ui.spriteCustomFrames.empty()) {
const int frame = std::clamp(obj.ui.spriteSheetFrame, 0, static_cast<int>(obj.ui.spriteCustomFrames.size()) - 1);
const glm::ivec4 rect = obj.ui.spriteCustomFrames[frame];
baseSize.x = std::max(baseSize.x, static_cast<float>(std::max(1, rect.z)));
baseSize.y = std::max(baseSize.y, static_cast<float>(std::max(1, rect.w)));
}
glm::vec3 objectScale = glm::max(glm::abs(obj.scale), glm::vec3(0.01f));
glm::vec2 worldHalfExtents = glm::vec2(baseSize.x * objectScale.x, baseSize.y * objectScale.y) * 0.005f;
@@ -1243,7 +1249,8 @@ void Engine::renderGameViewportWindow() {
*parentMin = regionMin;
*parentMax = regionMax;
}
ImVec2 size = ImVec2(std::max(1.0f, node->ui.size.x * uiScaleX), std::max(1.0f, node->ui.size.y * uiScaleY));
glm::vec2 nodeSizeWorld = getSpriteDisplaySize(*node);
ImVec2 size = ImVec2(std::max(1.0f, nodeSizeWorld.x * uiScaleX), std::max(1.0f, nodeSizeWorld.y * uiScaleY));
ImVec2 anchorPoint = anchorToPoint(node->ui.anchor, regionMin, regionMax);
ImVec2 pivot(anchorPoint.x + node->ui.position.x * uiScaleX, anchorPoint.y + node->ui.position.y * uiScaleY);
ImVec2 pivotOffset = anchorToPivot(node->ui.anchor, size);
@@ -1317,7 +1324,7 @@ void Engine::renderGameViewportWindow() {
}
glm::vec2 parentOffset = getWorldParentOffset(obj);
glm::vec2 worldPos = parentOffset + glm::vec2(obj.ui.position.x, obj.ui.position.y) + parallaxOffset(obj);
glm::vec2 sizeWorld(obj.ui.size.x, obj.ui.size.y);
glm::vec2 sizeWorld = getSpriteDisplaySize(obj);
ImVec2 pivotOffset = anchorToPivot(obj.ui.anchor, ImVec2(sizeWorld.x, sizeWorld.y));
glm::vec2 worldMin = worldPos - glm::vec2(pivotOffset.x, pivotOffset.y);
glm::vec2 worldMax = worldMin + sizeWorld;
@@ -1516,7 +1523,7 @@ void Engine::renderGameViewportWindow() {
if (obj.ui.type == UIElementType::Image || obj.ui.type == UIElementType::Sprite2D) {
unsigned int texId = 0;
if (rendererInitialized && !obj.albedoTexturePath.empty()) {
if (auto* tex = renderer.getTexture(obj.albedoTexturePath)) {
if (auto* tex = renderer.getTexture(obj.albedoTexturePath, MaterialProperties::TextureFilter::Point)) {
texId = tex->GetID();
}
}
@@ -1525,11 +1532,12 @@ void Engine::renderGameViewportWindow() {
bool repeatX = useWorldUi && obj.hasParallaxLayer2D && obj.parallaxLayer2D.enabled && obj.parallaxLayer2D.repeatX;
bool repeatY = useWorldUi && obj.hasParallaxLayer2D && obj.parallaxLayer2D.enabled && obj.parallaxLayer2D.repeatY;
glm::vec2 spacing = obj.hasParallaxLayer2D ? obj.parallaxLayer2D.repeatSpacing : glm::vec2(0.0f);
float stepX = obj.ui.size.x + spacing.x;
float stepY = obj.ui.size.y + spacing.y;
glm::vec2 spriteSizeWorld = getSpriteDisplaySize(obj);
float stepX = spriteSizeWorld.x + spacing.x;
float stepY = spriteSizeWorld.y + spacing.y;
glm::vec2 baseWorldMin = worldViewMin;
if (repeatX || repeatY) {
glm::vec2 sizeWorld(obj.ui.size.x, obj.ui.size.y);
glm::vec2 sizeWorld = spriteSizeWorld;
ImVec2 pivotOffset = anchorToPivot(obj.ui.anchor, ImVec2(sizeWorld.x, sizeWorld.y));
glm::vec2 parentOffset = getWorldParentOffset(obj);
glm::vec2 worldPos = parentOffset + glm::vec2(obj.ui.position.x, obj.ui.position.y) + parallaxOffset(obj);
@@ -1595,7 +1603,7 @@ void Engine::renderGameViewportWindow() {
float dy = repeatY ? (float)iy * stepY : 0.0f;
glm::vec2 tileMin = baseWorldMin + glm::vec2(dx, dy);
ImVec2 s0 = worldToScreen(tileMin);
ImVec2 s1 = worldToScreen(tileMin + glm::vec2(obj.ui.size.x, obj.ui.size.y));
ImVec2 s1 = worldToScreen(tileMin + glm::vec2(spriteSizeWorld.x, spriteSizeWorld.y));
ImVec2 tMin(std::min(s0.x, s1.x), std::min(s0.y, s1.y));
ImVec2 tMax(std::max(s0.x, s1.x), std::max(s0.y, s1.y));
drawImageRect(tMin, tMax);
@@ -2676,6 +2684,11 @@ void Engine::renderMainMenuBar() {
if (prevAIPathWindow != showAIPathfindingWindow) {
saveEditorUserSettings();
}
bool prevPixelSpriteEditor = showPixelSpriteEditorWindow;
ImGui::MenuItem("Pixel Sprite Editor", nullptr, &showPixelSpriteEditorWindow);
if (prevPixelSpriteEditor != showPixelSpriteEditorWindow) {
saveEditorUserSettings();
}
bool prevSpritePreview = showSpritePreviewPanel;
ImGui::MenuItem("Sprite Preview", nullptr, &showSpritePreviewPanel);
if (prevSpritePreview != showSpritePreviewPanel) {
@@ -3511,7 +3524,7 @@ void Engine::renderViewport() {
unsigned int texId = 0;
if (rendererInitialized && !obj.albedoTexturePath.empty()) {
if (auto* tex = renderer.getTexture(obj.albedoTexturePath)) {
if (auto* tex = renderer.getTexture(obj.albedoTexturePath, MaterialProperties::TextureFilter::Point)) {
texId = tex->GetID();
}
}
@@ -3642,7 +3655,7 @@ void Engine::renderViewport() {
}
glm::vec2 parentOffset = getWorldParentOffset(obj);
glm::vec2 worldPos = parentOffset + glm::vec2(obj.ui.position.x, obj.ui.position.y) + parallaxOffset(obj);
glm::vec2 sizeWorld(obj.ui.size.x, obj.ui.size.y);
glm::vec2 sizeWorld = getSpriteDisplaySize(obj);
ImVec2 pivotOffset = ImVec2(sizeWorld.x * 0.5f, sizeWorld.y * 0.5f);
switch (obj.ui.anchor) {
case UIAnchor::TopLeft: pivotOffset = ImVec2(0.0f, 0.0f); break;
@@ -3842,7 +3855,7 @@ void Engine::renderViewport() {
if (obj.ui.type == UIElementType::Image || obj.ui.type == UIElementType::Sprite2D) {
unsigned int texId = 0;
if (rendererInitialized && !obj.albedoTexturePath.empty()) {
if (auto* tex = renderer.getTexture(obj.albedoTexturePath)) {
if (auto* tex = renderer.getTexture(obj.albedoTexturePath, MaterialProperties::TextureFilter::Point)) {
texId = tex->GetID();
}
}
@@ -3850,12 +3863,13 @@ void Engine::renderViewport() {
ImVec4 tint(obj.ui.color.r, obj.ui.color.g, obj.ui.color.b, obj.ui.color.a);
bool repeatX = obj.hasParallaxLayer2D && obj.parallaxLayer2D.enabled && obj.parallaxLayer2D.repeatX;
bool repeatY = obj.hasParallaxLayer2D && obj.parallaxLayer2D.enabled && obj.parallaxLayer2D.repeatY;
glm::vec2 spriteSizeWorld = getSpriteDisplaySize(obj);
glm::vec2 spacing = obj.hasParallaxLayer2D ? obj.parallaxLayer2D.repeatSpacing : glm::vec2(0.0f);
float stepX = drawSize.x + spacing.x;
float stepY = drawSize.y + spacing.y;
glm::vec2 baseWorldMin = worldViewMin;
if (repeatX || repeatY) {
glm::vec2 sizeWorld(obj.ui.size.x, obj.ui.size.y);
glm::vec2 sizeWorld = spriteSizeWorld;
ImVec2 pivotOffset = ImVec2(sizeWorld.x * 0.5f, sizeWorld.y * 0.5f);
switch (obj.ui.anchor) {
case UIAnchor::TopLeft: pivotOffset = ImVec2(0.0f, 0.0f); break;
@@ -3930,7 +3944,7 @@ void Engine::renderViewport() {
float dy = repeatY ? (float)iy * stepY : 0.0f;
glm::vec2 tileMin = baseWorldMin + glm::vec2(dx, dy);
ImVec2 s0 = worldToScreen(tileMin);
ImVec2 s1 = worldToScreen(tileMin + glm::vec2(obj.ui.size.x, obj.ui.size.y));
ImVec2 s1 = worldToScreen(tileMin + glm::vec2(spriteSizeWorld.x, spriteSizeWorld.y));
ImVec2 tMin(std::min(s0.x, s1.x), std::min(s0.y, s1.y));
ImVec2 tMax(std::max(s0.x, s1.x), std::max(s0.y, s1.y));
drawImageRect(tMin, tMax);
@@ -6634,37 +6648,52 @@ void Engine::renderViewport() {
if (!validSprite) {
ImGui::TextDisabled("Select an Image or Sprite2D to preview.");
} else {
static float spritePreviewZoom = 1.0f;
Texture* previewTex = nullptr;
if (!selected->albedoTexturePath.empty()) {
previewTex = renderer.getTexture(selected->albedoTexturePath);
previewTex = renderer.getTexture(selected->albedoTexturePath, MaterialProperties::TextureFilter::Point);
}
if (!previewTex || !previewTex->GetID()) {
ImGui::TextDisabled("Assign a texture to preview this sprite.");
} else {
std::array<ImVec2, 4> uvQuad = buildSpriteSheetUvs(*selected);
float availW = std::max(80.0f, ImGui::GetContentRegionAvail().x);
float maxPreviewW = std::min(availW, 340.0f);
ImGui::SliderFloat("Zoom", &spritePreviewZoom, 0.25f, 16.0f, "%.2fx", ImGuiSliderFlags_Logarithmic);
float maxPreviewW = std::min(availW, 340.0f * spritePreviewZoom);
float texW = static_cast<float>(std::max(1, previewTex->GetWidth()));
float texH = static_cast<float>(std::max(1, previewTex->GetHeight()));
float frameW = texW / static_cast<float>(std::max(1, selected->ui.spriteSheetColumns));
float frameH = texH / static_cast<float>(std::max(1, selected->ui.spriteSheetRows));
if (!selected->ui.spriteSheetEnabled) {
frameW = texW;
frameH = texH;
float frameW = texW;
float frameH = texH;
if (selected->ui.spriteCustomFramesEnabled && !selected->ui.spriteCustomFrames.empty()) {
const glm::ivec4 frame = selected->ui.spriteCustomFrames[
std::clamp(selected->ui.spriteSheetFrame, 0, static_cast<int>(selected->ui.spriteCustomFrames.size()) - 1)];
frameW = static_cast<float>(std::max(1, frame.z));
frameH = static_cast<float>(std::max(1, frame.w));
} else if (selected->ui.spriteSheetEnabled) {
frameW = texW / static_cast<float>(std::max(1, selected->ui.spriteSheetColumns));
frameH = texH / static_cast<float>(std::max(1, selected->ui.spriteSheetRows));
}
float aspect = frameH > 0.0f ? (frameW / frameH) : 1.0f;
ImVec2 previewSize(maxPreviewW, std::max(40.0f, maxPreviewW / std::max(0.1f, aspect)));
ImGui::Image((ImTextureID)(intptr_t)previewTex->GetID(), previewSize, uvQuad[0], uvQuad[2]);
int frameCount = std::max(1, std::max(1, selected->ui.spriteSheetColumns) * std::max(1, selected->ui.spriteSheetRows));
const bool usingCustomClips = selected->ui.spriteCustomFramesEnabled && !selected->ui.spriteCustomFrames.empty();
int frameCount = usingCustomClips
? static_cast<int>(selected->ui.spriteCustomFrames.size())
: std::max(1, std::max(1, selected->ui.spriteSheetColumns) * std::max(1, selected->ui.spriteSheetRows));
ImGui::Separator();
ImGui::Text("Object: %s", selected->name.c_str());
ImGui::Text("Texture: %d x %d", previewTex->GetWidth(), previewTex->GetHeight());
if (selected->ui.spriteSheetEnabled) {
ImGui::Text("Frame: %d / %d", std::clamp(selected->ui.spriteSheetFrame, 0, frameCount - 1), frameCount - 1);
ImGui::Text("Grid: %d x %d | %.1f FPS",
std::max(1, selected->ui.spriteSheetColumns),
std::max(1, selected->ui.spriteSheetRows),
std::max(1.0f, selected->ui.spriteSheetFps));
if (usingCustomClips) {
ImGui::Text("Clip: %d / %d", std::clamp(selected->ui.spriteSheetFrame, 0, frameCount - 1), frameCount - 1);
ImGui::Text("Clips: %d cropped sprites", frameCount);
} else {
ImGui::Text("Frame: %d / %d", std::clamp(selected->ui.spriteSheetFrame, 0, frameCount - 1), frameCount - 1);
ImGui::Text("Grid: %d x %d | %.1f FPS",
std::max(1, selected->ui.spriteSheetColumns),
std::max(1, selected->ui.spriteSheetRows),
std::max(1.0f, selected->ui.spriteSheetFps));
}
} else {
ImGui::TextDisabled("Sprite sheet disabled (showing full texture).");
}
@@ -6681,11 +6710,12 @@ void Engine::renderViewport() {
SceneObject* selected = getSelectedObject();
bool validSprite = selected && selected->hasUI &&
(selected->ui.type == UIElementType::Image || selected->ui.type == UIElementType::Sprite2D) &&
selected->ui.spriteSheetEnabled;
selected->ui.spriteSheetEnabled &&
!(selected->ui.spriteCustomFramesEnabled && !selected->ui.spriteCustomFrames.empty());
if (!validSprite) {
spriteTimelinePreviewPlaying = false;
spriteTimelineTargetId = -1;
ImGui::TextDisabled("Select a sprite sheet to animate.");
ImGui::TextDisabled("Select a grid-based sprite sheet to animate.");
} else {
int columns = std::max(1, selected->ui.spriteSheetColumns);
int rows = std::max(1, selected->ui.spriteSheetRows);
@@ -6933,8 +6963,9 @@ void Engine::renderUiCanvas3DTargets() {
ImVec2 regionMin = ImGui::GetWindowPos();
ImVec2 regionMax = ImVec2(regionMin.x + layoutWidth, regionMin.y + layoutHeight);
for (const SceneObject* node : chain) {
ImVec2 size = ImVec2(std::max(1.0f, node->ui.size.x),
std::max(1.0f, node->ui.size.y));
glm::vec2 nodeSize = getSpriteDisplaySize(*node);
ImVec2 size = ImVec2(std::max(1.0f, nodeSize.x),
std::max(1.0f, nodeSize.y));
ImVec2 anchorPoint = anchorToPoint(node->ui.anchor, regionMin, regionMax);
ImVec2 pivot(anchorPoint.x + node->ui.position.x,
anchorPoint.y + node->ui.position.y);
@@ -7003,7 +7034,7 @@ void Engine::renderUiCanvas3DTargets() {
if (obj.ui.type == UIElementType::Image || obj.ui.type == UIElementType::Sprite2D) {
unsigned int texId = 0;
if (rendererInitialized && !obj.albedoTexturePath.empty()) {
if (auto* tex = renderer.getTexture(obj.albedoTexturePath)) {
if (auto* tex = renderer.getTexture(obj.albedoTexturePath, MaterialProperties::TextureFilter::Point)) {
texId = tex->GetID();
}
}
@@ -7428,8 +7459,9 @@ void Engine::renderPlayerViewport() {
ImVec2 regionMin = ImGui::GetWindowPos();
ImVec2 regionMax = ImVec2(regionMin.x + ImGui::GetWindowWidth(), regionMin.y + ImGui::GetWindowHeight());
for (const SceneObject* node : chain) {
ImVec2 size = ImVec2(std::max(1.0f, node->ui.size.x * uiScaleX),
std::max(1.0f, node->ui.size.y * uiScaleY));
glm::vec2 nodeSizeWorld = getSpriteDisplaySize(*node);
ImVec2 size = ImVec2(std::max(1.0f, nodeSizeWorld.x * uiScaleX),
std::max(1.0f, nodeSizeWorld.y * uiScaleY));
ImVec2 anchorPoint = anchorToPoint(node->ui.anchor, regionMin, regionMax);
ImVec2 pivot(anchorPoint.x + node->ui.position.x * uiScaleX,
anchorPoint.y + node->ui.position.y * uiScaleY);
@@ -7484,7 +7516,7 @@ void Engine::renderPlayerViewport() {
}
glm::vec2 parentOffset = getWorldParentOffset(obj);
glm::vec2 worldPos = parentOffset + glm::vec2(obj.ui.position.x, obj.ui.position.y);
glm::vec2 sizeWorld(obj.ui.size.x, obj.ui.size.y);
glm::vec2 sizeWorld = getSpriteDisplaySize(obj);
ImVec2 pivotOffset = anchorToPivot(obj.ui.anchor, ImVec2(sizeWorld.x, sizeWorld.y));
glm::vec2 worldMin = worldPos - glm::vec2(pivotOffset.x, pivotOffset.y);
glm::vec2 worldMax = worldMin + sizeWorld;
@@ -7658,7 +7690,7 @@ void Engine::renderPlayerViewport() {
if (obj.ui.type == UIElementType::Image || obj.ui.type == UIElementType::Sprite2D) {
unsigned int texId = 0;
if (rendererInitialized && !obj.albedoTexturePath.empty()) {
if (auto* tex = renderer.getTexture(obj.albedoTexturePath)) {
if (auto* tex = renderer.getTexture(obj.albedoTexturePath, MaterialProperties::TextureFilter::Point)) {
texId = tex->GetID();
}
}

View File

@@ -1,4 +1,5 @@
#include "Engine.h"
#include "CrashReporter.h"
#include "ModelLoader.h"
#include <iostream>
#include <fstream>
@@ -374,6 +375,11 @@ void Engine::applyProjectPipelineDefaults(bool force) {
}
int Engine::resolveSpriteSheetFrame(const SceneObject& obj) const {
if (obj.ui.spriteCustomFramesEnabled && !obj.ui.spriteCustomFrames.empty()) {
int total = static_cast<int>(obj.ui.spriteCustomFrames.size());
int frame = std::max(0, obj.ui.spriteSheetFrame);
return frame % total;
}
if (!obj.ui.spriteSheetEnabled) {
return 0;
}
@@ -384,6 +390,16 @@ int Engine::resolveSpriteSheetFrame(const SceneObject& obj) const {
return frame % total;
}
glm::vec2 Engine::getSpriteDisplaySize(const SceneObject& obj) const {
glm::vec2 size(std::max(1.0f, obj.ui.size.x), std::max(1.0f, obj.ui.size.y));
if (obj.ui.spriteCustomFramesEnabled && !obj.ui.spriteCustomFrames.empty()) {
const glm::ivec4 rect = obj.ui.spriteCustomFrames[resolveSpriteSheetFrame(obj)];
size.x = std::max(size.x, static_cast<float>(std::max(1, rect.z)));
size.y = std::max(size.y, static_cast<float>(std::max(1, rect.w)));
}
return size;
}
std::array<ImVec2, 4> Engine::buildSpriteSheetUvs(const SceneObject& obj) const {
std::array<ImVec2, 4> uvs = {
ImVec2(0.0f, 1.0f),
@@ -395,6 +411,24 @@ std::array<ImVec2, 4> Engine::buildSpriteSheetUvs(const SceneObject& obj) const
return uvs;
}
if (obj.ui.spriteCustomFramesEnabled &&
!obj.ui.spriteCustomFrames.empty() &&
obj.ui.spriteSourceWidth > 0 &&
obj.ui.spriteSourceHeight > 0) {
const glm::ivec4 rect = obj.ui.spriteCustomFrames[resolveSpriteSheetFrame(obj)];
const float invW = 1.0f / static_cast<float>(obj.ui.spriteSourceWidth);
const float invH = 1.0f / static_cast<float>(obj.ui.spriteSourceHeight);
const float u0 = rect.x * invW;
const float u1 = (rect.x + rect.z) * invW;
const float vTop = 1.0f - rect.y * invH;
const float vBottom = 1.0f - (rect.y + rect.w) * invH;
uvs[0] = ImVec2(u0, vTop);
uvs[1] = ImVec2(u1, vTop);
uvs[2] = ImVec2(u1, vBottom);
uvs[3] = ImVec2(u0, vBottom);
return uvs;
}
int columns = std::max(1, obj.ui.spriteSheetColumns);
int rows = std::max(1, obj.ui.spriteSheetRows);
int frame = resolveSpriteSheetFrame(obj);
@@ -1807,6 +1841,7 @@ void Engine::run() {
if (showCameraWindow) renderCameraWindow();
if (showAnimationWindow) renderAnimationWindow();
if (showAIPathfindingWindow) renderAIPathfindingWindow();
if (showPixelSpriteEditorWindow) renderPixelSpriteEditorWindow();
if (showProjectBrowser) renderProjectBrowserPanel();
}
@@ -4180,6 +4215,7 @@ void Engine::applyAutoStartMode() {
showCameraWindow = false;
showAnimationWindow = false;
showAIPathfindingWindow = false;
showPixelSpriteEditorWindow = false;
showViewOutput = false;
showSceneGizmos = false;
showGameViewport = false;
@@ -5557,6 +5593,7 @@ void Engine::addConsoleMessage(const std::string& message, ConsoleMessageType ty
entry.message = message;
entry.type = type;
consoleLog.push_back(std::move(entry));
Modularity::CrashReporter::AppendLogLine(std::string("[") + timeStr + "] " + message);
if (type == ConsoleMessageType::Error) {
latestErrorMessage = message;
@@ -6560,6 +6597,7 @@ void Engine::autosaveWorkspaceLayout() {
countDockState("Camera", showCameraWindow);
countDockState("Animation", showAnimationWindow);
countDockState("AI Pathfinding", showAIPathfindingWindow);
countDockState("Pixel Sprite Editor", showPixelSpriteEditorWindow);
countDockState("Scripting", showScriptingWindow);
countDockState("Project Settings", showProjectBrowser);
@@ -6719,6 +6757,8 @@ void Engine::loadEditorUserSettings() {
showAnimationWindow = (value == "1" || value == "true" || value == "yes");
} else if (key == "showAIPathfindingWindow") {
showAIPathfindingWindow = (value == "1" || value == "true" || value == "yes");
} else if (key == "showPixelSpriteEditorWindow") {
showPixelSpriteEditorWindow = (value == "1" || value == "true" || value == "yes");
} else if (key == "showSceneGizmos") {
showSceneGizmos = (value == "1" || value == "true" || value == "yes");
} else if (key == "gizmoShowCameraOverlays") {
@@ -6885,6 +6925,7 @@ void Engine::saveEditorUserSettings() const {
file << "consoleWrapText=" << (consoleWrapText ? "1" : "0") << "\n";
file << "showAnimationWindow=" << (showAnimationWindow ? "1" : "0") << "\n";
file << "showAIPathfindingWindow=" << (showAIPathfindingWindow ? "1" : "0") << "\n";
file << "showPixelSpriteEditorWindow=" << (showPixelSpriteEditorWindow ? "1" : "0") << "\n";
file << "showSceneGizmos=" << (showSceneGizmos ? "1" : "0") << "\n";
file << "gizmoShowCameraOverlays=" << (gizmoShowCameraOverlays ? "1" : "0") << "\n";
file << "gizmoShowCameraFrustumLabels=" << (gizmoShowCameraFrustumLabels ? "1" : "0") << "\n";

View File

@@ -13,6 +13,7 @@
#include "AudioSystem.h"
#include "PackageManager.h"
#include "ManagedScriptRuntime.h"
#include "SpritesheetFormat.h"
#include "ThirdParty/ImGuiColorTextEdit/TextEditor.h"
#include "Vulkan/VulkanRenderer.h"
#include "../include/Window/Window.h"
@@ -172,6 +173,7 @@ private:
bool showCameraWindow = true;
bool showAnimationWindow = false;
bool showAIPathfindingWindow = false;
bool showPixelSpriteEditorWindow = false;
int animationTargetId = -1;
std::vector<int> animationEditTargetIds;
bool animationApplyToSelection = true;
@@ -242,6 +244,71 @@ private:
bool spriteTimelinePreviewPlaying = false;
double spriteTimelineLastTick = 0.0;
int spriteTimelineTargetId = -1;
enum class PixelSpriteEditorMode {
Edit = 0,
SpriteSheet = 1
};
enum class PixelSpriteTool {
Pencil = 0,
Eraser = 1,
Fill = 2,
Select = 3
};
enum class PixelSpriteCheckerTheme {
Light = 0,
Dark = 1
};
struct PixelSpriteDocument {
fs::path imagePath;
fs::path sidecarPath;
std::string name = "Untitled";
int width = 16;
int height = 16;
std::vector<unsigned char> pixels;
bool dirty = false;
bool loaded = false;
bool selectionActive = false;
glm::ivec2 selectionStart = glm::ivec2(0);
glm::ivec2 selectionEnd = glm::ivec2(0);
std::string expectedMinimumModuEngineVersionOrHigher;
bool strictValidation = false;
std::vector<glm::ivec4> spriteFrames;
std::vector<std::string> spriteFrameNames;
std::vector<SpritesheetLayer> layers;
int activeLayer = 0;
int activeFrame = 0;
};
struct PixelSpriteHistoryState {
int width = 16;
int height = 16;
std::vector<unsigned char> pixels;
bool selectionActive = false;
glm::ivec2 selectionStart = glm::ivec2(0);
glm::ivec2 selectionEnd = glm::ivec2(0);
std::string expectedMinimumModuEngineVersionOrHigher;
bool strictValidation = false;
std::vector<glm::ivec4> spriteFrames;
std::vector<std::string> spriteFrameNames;
std::vector<SpritesheetLayer> layers;
int activeLayer = 0;
int activeFrame = 0;
};
PixelSpriteDocument pixelSpriteDocument;
std::vector<PixelSpriteHistoryState> pixelSpriteUndoStack;
std::vector<PixelSpriteHistoryState> pixelSpriteRedoStack;
PixelSpriteEditorMode pixelSpriteEditorMode = PixelSpriteEditorMode::Edit;
PixelSpriteTool pixelSpriteTool = PixelSpriteTool::Pencil;
float pixelSpriteZoom = 18.0f;
float pixelSpriteTargetZoom = 18.0f;
ImVec2 pixelSpriteCanvasPan = ImVec2(0.0f, 0.0f);
ImVec2 pixelSpriteCanvasTargetPan = ImVec2(0.0f, 0.0f);
bool pixelSpriteCanvasStateInitialized = false;
bool pixelSpriteCanvasCenterPending = false;
glm::vec4 pixelSpritePrimaryColor = glm::vec4(0.12f, 0.12f, 0.12f, 1.0f);
glm::vec4 pixelSpriteSecondaryColor = glm::vec4(0.0f, 0.0f, 0.0f, 0.0f);
bool pixelSpriteShowGrid = true;
bool pixelSpritePixelPerfect = true;
PixelSpriteCheckerTheme pixelSpriteCheckerTheme = PixelSpriteCheckerTheme::Light;
int gameViewportResolutionIndex = 0;
int gameViewportCustomWidth = 1920;
int gameViewportCustomHeight = 1080;
@@ -497,6 +564,7 @@ private:
void renderCameraWindow();
void renderAnimationWindow();
void renderAIPathfindingWindow();
void renderPixelSpriteEditorWindow();
void renderHierarchyPanel();
void renderObjectNode(SceneObject& obj, const std::string& filter,
std::vector<bool>& ancestorHasNext, bool isLast, int depth, float animStep);
@@ -558,6 +626,7 @@ private:
bool is2DWorldEditingEnabled() const;
void applyProjectPipelineDefaults(bool force = false);
int resolveSpriteSheetFrame(const SceneObject& obj) const;
glm::vec2 getSpriteDisplaySize(const SceneObject& obj) const;
std::array<ImVec2, 4> buildSpriteSheetUvs(const SceneObject& obj) const;
void resetBuildSettings();
void loadBuildSettings();
@@ -634,6 +703,8 @@ public:
void shutdown();
SceneObject* findObjectByName(const std::string& name);
SceneObject* findObjectById(int id);
bool loadPixelSpriteDocument(const fs::path& imagePath);
bool savePixelSpriteDocument();
fs::path resolveScriptBinary(const fs::path& sourcePath);
fs::path resolveManagedAssembly(const fs::path& sourcePath);
fs::path getManagedProjectPath() const;

View File

@@ -684,6 +684,25 @@ bool SceneSerializer::saveScene(const fs::path& filePath,
file << "uiSpriteSheetFrame=" << obj.ui.spriteSheetFrame << "\n";
file << "uiSpriteSheetFps=" << obj.ui.spriteSheetFps << "\n";
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";
if (!obj.ui.spriteCustomFrames.empty()) {
file << "uiSpriteCustomFrames=";
for (size_t i = 0; i < obj.ui.spriteCustomFrames.size(); ++i) {
const glm::ivec4& frame = obj.ui.spriteCustomFrames[i];
if (i > 0) file << ";";
file << frame.x << "," << frame.y << "," << frame.z << "," << frame.w;
}
file << "\n";
}
if (!obj.ui.spriteCustomFrameNames.empty()) {
file << "uiSpriteCustomFrameNames=";
for (size_t i = 0; i < obj.ui.spriteCustomFrameNames.size(); ++i) {
if (i > 0) file << ";";
file << obj.ui.spriteCustomFrameNames[i];
}
file << "\n";
}
if (obj.hasPostFX) {
file << "postEnabled=" << (obj.postFx.enabled ? 1 : 0) << "\n";
file << "postBloomEnabled=" << (obj.postFx.bloomEnabled ? 1 : 0) << "\n";
@@ -1172,6 +1191,35 @@ const std::unordered_map<std::string, KeyHandler>& GetSceneObjectKeyHandlers() {
{"uiSpriteSheetFrame", +[](SceneObject& obj, const std::string& value) { obj.ui.spriteSheetFrame = std::max(0, std::stoi(value)); }},
{"uiSpriteSheetFps", +[](SceneObject& obj, const std::string& value) { obj.ui.spriteSheetFps = std::max(1.0f, std::stof(value)); }},
{"uiSpriteSheetLoop", +[](SceneObject& obj, const std::string& value) { obj.ui.spriteSheetLoop = (std::stoi(value) != 0); }},
{"uiSpriteCustomFramesEnabled", +[](SceneObject& obj, const std::string& value) { obj.ui.spriteCustomFramesEnabled = (std::stoi(value) != 0); }},
{"uiSpriteSourceSize", +[](SceneObject& obj, const std::string& value) {
glm::ivec2 size(0, 0);
ParseIVec2(value, size);
obj.ui.spriteSourceWidth = std::max(0, size.x);
obj.ui.spriteSourceHeight = std::max(0, size.y);
}},
{"uiSpriteCustomFrames", +[](SceneObject& obj, const std::string& value) {
obj.ui.spriteCustomFrames.clear();
std::stringstream ss(value);
std::string item;
while (std::getline(ss, item, ';')) {
if (item.empty()) continue;
glm::ivec4 rect(0);
if (std::sscanf(item.c_str(), "%d,%d,%d,%d", &rect.x, &rect.y, &rect.z, &rect.w) == 4) {
rect.z = std::max(1, rect.z);
rect.w = std::max(1, rect.w);
obj.ui.spriteCustomFrames.push_back(rect);
}
}
}},
{"uiSpriteCustomFrameNames", +[](SceneObject& obj, const std::string& value) {
obj.ui.spriteCustomFrameNames.clear();
std::stringstream ss(value);
std::string item;
while (std::getline(ss, item, ';')) {
obj.ui.spriteCustomFrameNames.push_back(item);
}
}},
{"postEnabled", +[](SceneObject& obj, const std::string& value) { obj.postFx.enabled = (std::stoi(value) != 0); }},
{"postBloomEnabled", +[](SceneObject& obj, const std::string& value) { obj.postFx.bloomEnabled = (std::stoi(value) != 0); }},
{"postBloomThreshold", +[](SceneObject& obj, const std::string& value) { obj.postFx.bloomThreshold = std::stof(value); }},

View File

@@ -10,6 +10,17 @@
namespace {
glm::vec4 BuildSpriteUvRect(const SceneObject& obj) {
if (obj.ui.spriteCustomFramesEnabled &&
!obj.ui.spriteCustomFrames.empty() &&
obj.ui.spriteSourceWidth > 0 &&
obj.ui.spriteSourceHeight > 0) {
const int frame = std::clamp(obj.ui.spriteSheetFrame, 0, static_cast<int>(obj.ui.spriteCustomFrames.size()) - 1);
const glm::ivec4 rect = obj.ui.spriteCustomFrames[frame];
const float invW = 1.0f / static_cast<float>(obj.ui.spriteSourceWidth);
const float invH = 1.0f / static_cast<float>(obj.ui.spriteSourceHeight);
return glm::vec4(rect.x * invW, rect.y * invH, rect.z * invW, rect.w * invH);
}
if (!obj.ui.spriteSheetEnabled) {
return glm::vec4(0.0f, 0.0f, 1.0f, 1.0f);
}
@@ -738,6 +749,12 @@ Texture* Renderer::getTexture(const std::string& path, MaterialProperties::Textu
return raw;
}
void Renderer::invalidateTexture(const std::string& path) {
if (path.empty()) return;
textureCacheBilinear.erase(path);
textureCachePoint.erase(path);
}
void Renderer::initialize() {
shader = new Shader(defaultVertPath.c_str(), defaultFragPath.c_str());
defaultShader = shader;

View File

@@ -189,6 +189,7 @@ public:
void initialize();
Texture* getTexture(const std::string& path, MaterialProperties::TextureFilter filter = MaterialProperties::TextureFilter::Bilinear);
void invalidateTexture(const std::string& path);
Shader* getShader(const std::string& vert, const std::string& frag);
bool forceReloadShader(const std::string& vert, const std::string& frag);
void setAmbientColor(const glm::vec3& color) { ambientColor = color; }

View File

@@ -345,6 +345,11 @@ struct UIElementComponent {
int spriteSheetFrame = 0;
float spriteSheetFps = 12.0f;
bool spriteSheetLoop = true;
bool spriteCustomFramesEnabled = false;
int spriteSourceWidth = 0;
int spriteSourceHeight = 0;
std::vector<glm::ivec4> spriteCustomFrames;
std::vector<std::string> spriteCustomFrameNames;
};
struct Rigidbody2DComponent {

View File

@@ -734,6 +734,40 @@ bool ScriptCompiler::makeCommands(const ScriptBuildConfig& config, const fs::pat
wrapper << " std::snprintf(outBuffer, static_cast<size_t>(outBufferSize), \"%s\", value.c_str());\n";
wrapper << " return 1;\n";
wrapper << "}\n\n";
wrapper << "int Modu_GetSpriteClipCount(ModuScriptContext* ctx) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return cpp ? cpp->GetSpriteClipCount() : 0;\n";
wrapper << "}\n\n";
wrapper << "int Modu_GetSpriteClipIndex(ModuScriptContext* ctx) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return cpp ? cpp->GetSpriteClipIndex() : -1;\n";
wrapper << "}\n\n";
wrapper << "int Modu_SetSpriteClipIndex(ModuScriptContext* ctx, int index) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return (cpp && cpp->SetSpriteClipIndex(index)) ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "int Modu_SetSpriteClipName(ModuScriptContext* ctx, const char* name) {\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " return (cpp && cpp->SetSpriteClipName(name ? name : \"\")) ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "int Modu_GetSpriteClipName(ModuScriptContext* ctx, char* outBuffer, int outBufferSize) {\n";
wrapper << " if (!outBuffer || outBufferSize <= 0) return 0;\n";
wrapper << " outBuffer[0] = '\\0';\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " if (!cpp) return 0;\n";
wrapper << " std::string value = cpp->GetSpriteClipName();\n";
wrapper << " std::snprintf(outBuffer, static_cast<size_t>(outBufferSize), \"%s\", value.c_str());\n";
wrapper << " return !value.empty() ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "int Modu_GetSpriteClipNameAt(ModuScriptContext* ctx, int index, char* outBuffer, int outBufferSize) {\n";
wrapper << " if (!outBuffer || outBufferSize <= 0) return 0;\n";
wrapper << " outBuffer[0] = '\\0';\n";
wrapper << " ScriptContext* cpp = ModuAsCpp(ctx);\n";
wrapper << " if (!cpp) return 0;\n";
wrapper << " std::string value = cpp->GetSpriteClipNameAt(index);\n";
wrapper << " std::snprintf(outBuffer, static_cast<size_t>(outBufferSize), \"%s\", value.c_str());\n";
wrapper << " return !value.empty() ? 1 : 0;\n";
wrapper << "}\n\n";
wrapper << "void Modu_InspectorText(ModuScriptContext* ctx, const char* text) {\n";
wrapper << " (void)ctx;\n";
wrapper << " ImGui::TextUnformatted(text ? text : \"\");\n";

View File

@@ -607,6 +607,68 @@ void ScriptContext::SetUIColor(const glm::vec4& color) {
}
}
int ScriptContext::GetSpriteClipCount() const {
if (!object || !object->hasUI) return 0;
if (object->ui.spriteCustomFramesEnabled && !object->ui.spriteCustomFrames.empty()) {
return static_cast<int>(object->ui.spriteCustomFrames.size());
}
if (!object->ui.spriteSheetEnabled) return 0;
return std::max(1, object->ui.spriteSheetColumns * object->ui.spriteSheetRows);
}
int ScriptContext::GetSpriteClipIndex() const {
int clipCount = GetSpriteClipCount();
if (clipCount <= 0 || !object) return -1;
return std::clamp(object->ui.spriteSheetFrame, 0, clipCount - 1);
}
std::string ScriptContext::GetSpriteClipName() const {
return GetSpriteClipNameAt(GetSpriteClipIndex());
}
std::string ScriptContext::GetSpriteClipNameAt(int index) const {
if (!object || !object->hasUI || index < 0) return {};
if (object->ui.spriteCustomFramesEnabled && !object->ui.spriteCustomFrames.empty()) {
if (index >= static_cast<int>(object->ui.spriteCustomFrames.size())) return {};
if (index < static_cast<int>(object->ui.spriteCustomFrameNames.size()) &&
!object->ui.spriteCustomFrameNames[static_cast<size_t>(index)].empty()) {
return object->ui.spriteCustomFrameNames[static_cast<size_t>(index)];
}
return "Clip " + std::to_string(index);
}
int clipCount = GetSpriteClipCount();
if (index >= clipCount) return {};
return "Frame " + std::to_string(index);
}
bool ScriptContext::SetSpriteClipIndex(int index) {
if (!object || !object->hasUI) return false;
int clipCount = GetSpriteClipCount();
if (clipCount <= 0 || index < 0 || index >= clipCount) return false;
if (object->ui.spriteSheetFrame != index) {
object->ui.spriteSheetFrame = index;
MarkDirty();
}
return true;
}
bool ScriptContext::SetSpriteClipName(const std::string& name) {
if (!object || !object->hasUI) return false;
if (!(object->ui.spriteCustomFramesEnabled && !object->ui.spriteCustomFrames.empty())) return false;
std::string target = trimString(name);
if (target.empty()) return false;
for (size_t i = 0; i < object->ui.spriteCustomFrames.size(); ++i) {
std::string clipName = (i < object->ui.spriteCustomFrameNames.size() &&
!object->ui.spriteCustomFrameNames[i].empty())
? object->ui.spriteCustomFrameNames[i]
: ("Clip " + std::to_string(i));
if (clipName == target) {
return SetSpriteClipIndex(static_cast<int>(i));
}
}
return false;
}
float ScriptContext::GetUITextScale() const {
if (!object || !object->hasUI || object->ui.type != UIElementType::Text) return 1.0f;
return object->ui.textScale;

View File

@@ -95,6 +95,12 @@ struct ScriptContext {
void SetUISliderRange(float minValue, float maxValue);
void SetUILabel(const std::string& label);
void SetUIColor(const glm::vec4& color);
int GetSpriteClipCount() const;
int GetSpriteClipIndex() const;
std::string GetSpriteClipName() const;
std::string GetSpriteClipNameAt(int index) const;
bool SetSpriteClipIndex(int index);
bool SetSpriteClipName(const std::string& name);
float GetUITextScale() const;
void SetUITextScale(float scale);
void SetUISliderStyle(UISliderStyle style);

588
src/SpritesheetFormat.cpp Normal file
View File

@@ -0,0 +1,588 @@
#include "SpritesheetFormat.h"
#include <cassert>
#include <cstdio>
#include <cctype>
namespace {
enum class TokenType {
Identifier,
Number,
String,
LBrace,
RBrace,
Equals,
Semicolon,
Comma,
End
};
struct Token {
TokenType type = TokenType::End;
std::string text;
int line = 1;
};
struct Tokenizer {
const std::string& input;
size_t pos = 0;
int line = 1;
std::vector<SpritesheetParseMessage>* messages = nullptr;
void warn(int warnLine, const std::string& text) {
if (messages) messages->push_back({warnLine, true, text});
}
Token next() {
while (pos < input.size()) {
const char c = input[pos];
if (c == ' ' || c == '\t' || c == '\r') {
++pos;
continue;
}
if (c == '\n') {
++line;
++pos;
continue;
}
if (c == '/' && pos + 1 < input.size() && input[pos + 1] == '/') {
pos += 2;
while (pos < input.size() && input[pos] != '\n') {
++pos;
}
continue;
}
Token token;
token.line = line;
switch (c) {
case '{': ++pos; token.type = TokenType::LBrace; token.text = "{"; return token;
case '}': ++pos; token.type = TokenType::RBrace; token.text = "}"; return token;
case '=': ++pos; token.type = TokenType::Equals; token.text = "="; return token;
case ';': ++pos; token.type = TokenType::Semicolon; token.text = ";"; return token;
case ',': ++pos; token.type = TokenType::Comma; token.text = ","; return token;
case '"': {
++pos;
token.type = TokenType::String;
while (pos < input.size()) {
char ch = input[pos++];
if (ch == '\n') ++line;
if (ch == '"') return token;
if (ch == '\\' && pos < input.size()) {
char escaped = input[pos++];
if (escaped == 'n') token.text.push_back('\n');
else token.text.push_back(escaped);
continue;
}
token.text.push_back(ch);
}
warn(token.line, "Parse error at line " + std::to_string(token.line) + ": unterminated string literal");
return token;
}
default:
break;
}
if (std::isdigit(static_cast<unsigned char>(c)) || (c == '-' && pos + 1 < input.size() && std::isdigit(static_cast<unsigned char>(input[pos + 1])))) {
token.type = TokenType::Number;
token.text.push_back(input[pos++]);
while (pos < input.size() && std::isdigit(static_cast<unsigned char>(input[pos]))) {
token.text.push_back(input[pos++]);
}
return token;
}
if (std::isalpha(static_cast<unsigned char>(c)) || c == '_') {
token.type = TokenType::Identifier;
token.text.push_back(input[pos++]);
while (pos < input.size()) {
char ch = input[pos];
if (std::isalnum(static_cast<unsigned char>(ch)) || ch == '_' || ch == '.') {
token.text.push_back(ch);
++pos;
continue;
}
break;
}
return token;
}
warn(line, "Parse error at line " + std::to_string(line) + ": unexpected character '" + std::string(1, c) + "'");
++pos;
}
return Token{TokenType::End, "", line};
}
};
struct Parser {
std::vector<Token> tokens;
size_t index = 0;
SpritesheetParseResult result;
const Token& peek(size_t offset = 0) const {
const size_t i = std::min(index + offset, tokens.size() - 1);
return tokens[i];
}
const Token& advance() {
const size_t i = std::min(index, tokens.size() - 1);
if (index < tokens.size() - 1) ++index;
return tokens[i];
}
bool match(TokenType type) {
if (peek().type != type) return false;
advance();
return true;
}
void error(int line, const std::string& text) {
result.messages.push_back({line, true, "Parse error at line " + std::to_string(line) + ": " + text});
}
void syncToTopLevel() {
int depth = 0;
while (peek().type != TokenType::End) {
if (peek().type == TokenType::LBrace) ++depth;
else if (peek().type == TokenType::RBrace) {
if (depth == 0) return;
--depth;
} else if (depth == 0 && (peek().type == TokenType::Identifier || peek().type == TokenType::RBrace)) {
return;
}
advance();
}
}
bool parseInt(int& out) {
if (peek().type != TokenType::Number) return false;
out = std::stoi(advance().text);
return true;
}
bool parseAssignmentValue(const Token& key) {
if (!match(TokenType::Equals)) {
error(key.line, "expected '=' after identifier '" + key.text + "'");
if (peek().type == TokenType::Equals) advance();
syncToTopLevel();
return false;
}
if (key.text == "LinkedSpriteName" ||
key.text == "LastSavedUtc" ||
key.text == "ExpectedMinimumModuEngineVersionOrHigher") {
if (peek().type != TokenType::String) {
error(peek().line, "expected string value for '" + key.text + "'");
syncToTopLevel();
return false;
}
const std::string value = advance().text;
match(TokenType::Semicolon);
if (key.text == "LinkedSpriteName") result.document.linkedSpriteName = value;
else if (key.text == "LastSavedUtc") result.document.lastSavedUtc = value;
else result.document.expectedMinimumModuEngineVersionOrHigher = value;
return true;
}
if (key.text == "SpriteVersion" || key.text == "Expect_Layers" || key.text == "Expect_rects") {
int value = 0;
if (!parseInt(value)) {
error(peek().line, "expected integer value for '" + key.text + "'");
syncToTopLevel();
return false;
}
match(TokenType::Semicolon);
if (key.text == "SpriteVersion") result.document.spriteVersion = value;
else if (key.text == "Expect_Layers") result.document.expectLayers = value;
else result.document.expectRects = value;
return true;
}
if (key.text == "Confirmation.StrictValidation") {
if (peek().type != TokenType::Identifier || (peek().text != "true" && peek().text != "false")) {
error(peek().line, "expected boolean value for '" + key.text + "'");
syncToTopLevel();
return false;
}
result.document.strictValidation = (advance().text == "true");
match(TokenType::Semicolon);
return true;
}
error(key.line, "unknown assignment '" + key.text + "'");
syncToTopLevel();
return false;
}
void skipUnknownBlock() {
if (!match(TokenType::LBrace)) return;
int depth = 1;
while (peek().type != TokenType::End && depth > 0) {
if (match(TokenType::LBrace)) ++depth;
else if (match(TokenType::RBrace)) --depth;
else advance();
}
}
void parseRectsBlock() {
std::vector<glm::ivec4> parsedRects;
if (!match(TokenType::LBrace)) {
error(peek().line, "expected '{' after rects");
return;
}
while (peek().type != TokenType::End && peek().type != TokenType::RBrace) {
glm::ivec4 rect(0);
const int entryLine = peek().line;
bool ok = true;
for (int i = 0; i < 4; ++i) {
if (!parseInt(rect[i])) {
error(peek().line, "unexpected token '" + peek().text + "' inside rects block");
ok = false;
break;
}
if (i < 3 && !match(TokenType::Comma)) {
error(peek().line, "expected ',' inside rects block");
ok = false;
break;
}
}
if (ok) {
if (!match(TokenType::Semicolon)) {
error(peek().line, "expected ';' after rect entry");
}
rect.z = std::max(1, rect.z);
rect.w = std::max(1, rect.w);
parsedRects.push_back(rect);
} else {
while (peek().type != TokenType::End && peek().type != TokenType::Semicolon && peek().type != TokenType::RBrace) {
advance();
}
match(TokenType::Semicolon);
parsedRects.clear();
while (peek().type != TokenType::End && peek().line == entryLine && peek().type != TokenType::RBrace) {
advance();
}
}
}
match(TokenType::RBrace);
result.document.rects = parsedRects;
}
void parseNamesBlock() {
std::vector<std::string> parsedNames;
if (!match(TokenType::LBrace)) {
error(peek().line, "expected '{' after names");
return;
}
while (peek().type != TokenType::End && peek().type != TokenType::RBrace) {
if (match(TokenType::Semicolon)) {
parsedNames.emplace_back();
continue;
}
if (peek().type == TokenType::Identifier) {
parsedNames.push_back(advance().text);
if (!match(TokenType::Semicolon)) {
error(peek().line, "expected ';' after name entry");
}
continue;
}
error(peek().line, "unexpected token '" + peek().text + "' inside names block");
while (peek().type != TokenType::End && peek().type != TokenType::Semicolon && peek().type != TokenType::RBrace) {
advance();
}
match(TokenType::Semicolon);
parsedNames.clear();
}
match(TokenType::RBrace);
result.document.names = parsedNames;
}
void parseLayersBlock() {
if (!match(TokenType::LBrace)) {
error(peek().line, "expected '{' after info Layers");
return;
}
std::vector<SpritesheetLayer> layers;
while (peek().type != TokenType::End && peek().type != TokenType::RBrace) {
if (peek().type == TokenType::Identifier && peek().text == "names") {
advance();
if (!match(TokenType::LBrace)) {
error(peek().line, "expected '{' after layer names");
continue;
}
while (peek().type != TokenType::End && peek().type != TokenType::RBrace) {
if (match(TokenType::Semicolon)) {
layers.push_back({"Layer_" + std::to_string(layers.size())});
continue;
}
if (peek().type == TokenType::Identifier) {
layers.push_back({advance().text});
match(TokenType::Semicolon);
continue;
}
advance();
}
match(TokenType::RBrace);
continue;
}
if (peek().type == TokenType::Identifier) {
advance();
if (peek().type == TokenType::LBrace) {
skipUnknownBlock();
} else {
while (peek().type != TokenType::End && peek().type != TokenType::RBrace && peek().type != TokenType::Semicolon) {
advance();
}
match(TokenType::Semicolon);
}
continue;
}
advance();
}
match(TokenType::RBrace);
result.document.layers = layers;
}
void parseAtlasInfoBlock() {
std::vector<glm::ivec4> defaultRects = result.document.rects;
std::vector<std::string> defaultNames = result.document.names;
if (!match(TokenType::LBrace)) {
error(peek().line, "expected '{' after info AtlasInfo");
return;
}
while (peek().type != TokenType::End && peek().type != TokenType::RBrace) {
if (peek().type != TokenType::Identifier) {
error(peek().line, "unexpected token '" + peek().text + "' inside info AtlasInfo");
advance();
continue;
}
const std::string blockName = advance().text;
if (blockName == "rects") parseRectsBlock();
else if (blockName == "names") parseNamesBlock();
else {
error(peek().line, "unexpected block '" + blockName + "' inside info AtlasInfo");
if (peek().type == TokenType::LBrace) skipUnknownBlock();
}
}
match(TokenType::RBrace);
if (result.document.rects.empty() && !defaultRects.empty()) result.document.rects = defaultRects;
if (result.document.names.empty() && !defaultNames.empty()) result.document.names = defaultNames;
}
void parseInfoBlock() {
const Token infoToken = advance();
if (peek().type != TokenType::Identifier) {
error(peek().line, "expected identifier after info");
return;
}
const Token blockName = advance();
if (blockName.text == "AtlasInfo") parseAtlasInfoBlock();
else if (blockName.text == "Layers") parseLayersBlock();
else {
error(blockName.line, "unknown info block '" + blockName.text + "'");
if (peek().type == TokenType::LBrace) skipUnknownBlock();
}
(void)infoToken;
}
void finalize() {
if (result.document.expectRects <= 0) {
result.document.expectRects = static_cast<int>(result.document.rects.size());
}
if (result.document.expectLayers <= 0) {
result.document.expectLayers = std::max(1, static_cast<int>(result.document.layers.size()));
}
if (result.document.names.size() < result.document.rects.size()) {
result.document.names.resize(result.document.rects.size());
} else if (result.document.names.size() > result.document.rects.size()) {
result.document.names.resize(result.document.rects.size());
}
for (size_t i = 0; i < result.document.names.size(); ++i) {
if (result.document.names[i].empty()) {
result.document.names[i] = "Rect_" + std::to_string(i);
}
}
for (size_t i = 0; i < result.document.layers.size(); ++i) {
if (result.document.layers[i].name.empty()) {
result.document.layers[i].name = "Layer_" + std::to_string(i);
}
}
}
SpritesheetParseResult parse() {
while (peek().type != TokenType::End) {
if (peek().type != TokenType::Identifier) {
error(peek().line, "unexpected token '" + peek().text + "' at top level");
advance();
continue;
}
if (peek().text == "info") {
parseInfoBlock();
continue;
}
const Token key = advance();
parseAssignmentValue(key);
}
finalize();
return result;
}
};
std::string EscapeString(const std::string& value) {
std::string out;
out.reserve(value.size());
for (char c : value) {
if (c == '\\' || c == '"') out.push_back('\\');
out.push_back(c);
}
return out;
}
std::string CurrentUtcIso8601() {
const auto now = std::chrono::system_clock::now();
const std::time_t timeValue = std::chrono::system_clock::to_time_t(now);
std::tm tmUtc{};
#ifdef _WIN32
gmtime_s(&tmUtc, &timeValue);
#else
gmtime_r(&timeValue, &tmUtc);
#endif
char buffer[32];
std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &tmUtc);
return buffer;
}
#ifndef NDEBUG
bool ContainsMessage(const std::vector<SpritesheetParseMessage>& messages, const std::string& needle) {
for (const auto& message : messages) {
if (message.text.find(needle) != std::string::npos) return true;
}
return false;
}
#endif
} // namespace
SpritesheetParseResult ParseSpritesheet(const std::string& text) {
static bool selfTestsRan = false;
if (!selfTestsRan) {
selfTestsRan = true;
RunSpritesheetParserSelfTests();
}
SpritesheetParseResult result;
Tokenizer tokenizer{text, 0, 1, &result.messages};
std::vector<Token> tokens;
while (true) {
Token token = tokenizer.next();
tokens.push_back(token);
if (token.type == TokenType::End) break;
}
Parser parser;
parser.tokens = std::move(tokens);
parser.result = std::move(result);
return parser.parse();
}
std::string WriteSpritesheet(const SpritesheetDocument& inputDocument) {
SpritesheetDocument document = inputDocument;
if (document.spriteVersion <= 0) document.spriteVersion = 1;
if (document.expectLayers <= 0) document.expectLayers = std::max(1, static_cast<int>(document.layers.size()));
if (document.expectRects <= 0) document.expectRects = static_cast<int>(document.rects.size());
if (document.lastSavedUtc.empty()) document.lastSavedUtc = CurrentUtcIso8601();
if (document.names.size() < document.rects.size()) document.names.resize(document.rects.size());
for (size_t i = 0; i < document.names.size(); ++i) {
if (document.names[i].empty()) document.names[i] = "Rect_" + std::to_string(i);
}
for (size_t i = 0; i < document.layers.size(); ++i) {
if (document.layers[i].name.empty()) document.layers[i].name = "Layer_" + std::to_string(i);
}
std::ostringstream out;
out << "LinkedSpriteName = \"" << EscapeString(document.linkedSpriteName) << "\";\n";
out << "SpriteVersion = " << document.spriteVersion << ";\n";
out << "LastSavedUtc = \"" << EscapeString(document.lastSavedUtc) << "\"; // Don't edit this, it updates automatically when you save this script, don't worry!\n\n";
out << "ExpectedMinimumModuEngineVersionOrHigher = \"" << EscapeString(document.expectedMinimumModuEngineVersionOrHigher) << "\";\n";
out << "Expect_Layers = " << document.expectLayers << ";\n";
out << "Expect_rects = " << document.expectRects << ";\n\n";
out << "Confirmation.StrictValidation = " << (document.strictValidation ? "true" : "false") << ";\n";
out << "// This above is a toggle switch for stricter values, enable this if you really want info if something is wrong.\n\n";
out << "// this stores info of the atlas sprites and the rects below, you can edit them to crop positions, (or just do it in the Spritesheet editor lol.)\n";
out << "info AtlasInfo\n";
out << "{\n";
out << " rects\n";
out << " {\n";
out << " // To edit this: remember the position values (x, y, w, h).\n";
for (const glm::ivec4& rect : document.rects) {
out << " " << rect.x << "," << rect.y << "," << rect.z << "," << rect.w << ";\n";
}
out << " }\n\n";
out << " names\n";
out << " {\n";
out << " // To edit this: Each name corresponds to the names of the atlas position values above, Leave it empty to name it by ints or name it by name below.\n";
for (const std::string& name : document.names) {
out << " " << name << ";\n";
}
out << " }\n";
out << "}\n\n";
out << "info Layers\n";
out << "{\n";
out << " // If you haven't used layers, ignore placing them here, it's best to let the Edit Mode handle Layering as it's pretty strict, unless you want hell of course.\n";
if (!document.layers.empty()) {
out << "\n";
out << " names\n";
out << " {\n";
for (const SpritesheetLayer& layer : document.layers) {
out << " " << layer.name << ";\n";
}
out << " }\n";
}
out << "}\n";
return out.str();
}
void RunSpritesheetParserSelfTests() {
#ifndef NDEBUG
const std::string valid =
"LinkedSpriteName = \"Assets/Sprites/sprite.png\";\n"
"SpriteVersion = 1;\n"
"LastSavedUtc = \"2026-03-02T06:09:00Z\"; // note\n"
"ExpectedMinimumModuEngineVersionOrHigher = \"ModuEngine V6.5\";\n"
"Expect_Layers = 1;\n"
"Expect_rects = 2;\n"
"Confirmation.StrictValidation = false;\n"
"info AtlasInfo { rects { 1,2,3,4; 5,6,7,8; } names { A; ; } }\n"
"info Layers { }\n";
const auto validResult = ParseSpritesheet(valid);
assert(validResult.document.rects.size() == 2);
assert(validResult.document.names.size() == 2);
assert(validResult.document.names[1] == "Rect_1");
const std::string whitespace =
"LinkedSpriteName= \"A\" ;\n"
"SpriteVersion =1\n"
"ExpectedMinimumModuEngineVersionOrHigher = \"B\";\n"
"Expect_Layers= 1 ;\n"
"Expect_rects =2;\n"
"Confirmation.StrictValidation = false\n"
"info AtlasInfo{rects{1,1,1,1;2,2,2,2;}names{X;Y;}}\n"
"info Layers{}\n";
const auto whitespaceResult = ParseSpritesheet(whitespace);
assert(whitespaceResult.document.linkedSpriteName == "A");
assert(whitespaceResult.document.rects.size() == 2);
const std::string invalid =
"LinkedSpriteName \"bad\";\n"
"SpriteVersion == 2;\n"
"info AtlasInfo { rects { 1,2,3; ; } names { A; } }\n";
const auto invalidResult = ParseSpritesheet(invalid);
assert(invalidResult.document.linkedSpriteName.empty());
assert(invalidResult.document.spriteVersion == 1);
assert(ContainsMessage(invalidResult.messages, "expected '=' after identifier 'LinkedSpriteName'"));
assert(ContainsMessage(invalidResult.messages, "expected integer value for 'SpriteVersion'") ||
ContainsMessage(invalidResult.messages, "unexpected token '='"));
#endif
}

35
src/SpritesheetFormat.h Normal file
View File

@@ -0,0 +1,35 @@
#pragma once
#include "Common.h"
struct SpritesheetLayer {
std::string name;
};
struct SpritesheetDocument {
std::string linkedSpriteName;
int spriteVersion = 1;
std::string lastSavedUtc;
std::string expectedMinimumModuEngineVersionOrHigher;
int expectLayers = 1;
int expectRects = 0;
bool strictValidation = false;
std::vector<glm::ivec4> rects;
std::vector<std::string> names;
std::vector<SpritesheetLayer> layers;
};
struct SpritesheetParseMessage {
int line = 1;
bool error = true;
std::string text;
};
struct SpritesheetParseResult {
SpritesheetDocument document;
std::vector<SpritesheetParseMessage> messages;
};
SpritesheetParseResult ParseSpritesheet(const std::string& text);
std::string WriteSpritesheet(const SpritesheetDocument& document);
void RunSpritesheetParserSelfTests();

View File

@@ -452,6 +452,26 @@ ImTextureID VulkanRenderer::getOrCreateUIImage(const std::string& path, int* out
#endif
}
void VulkanRenderer::invalidateImagePath(const std::string& path) {
#if !MODULARITY_HAS_VULKAN
(void)path;
#else
if (path.empty()) {
return;
}
auto uiIt = uiImageCache.find(path);
if (uiIt != uiImageCache.end()) {
destroyUiImageTexture(uiIt->second);
uiImageCache.erase(uiIt);
}
auto sceneIt = sceneTextureCache.find(path);
if (sceneIt != sceneTextureCache.end()) {
destroySceneTexture(sceneIt->second);
sceneTextureCache.erase(sceneIt);
}
#endif
}
ImTextureID VulkanRenderer::getGameSceneTextureID() const {
#if MODULARITY_HAS_VULKAN
return (gameSceneTarget.descriptorSet == VK_NULL_HANDLE)

View File

@@ -39,6 +39,7 @@ public:
ImTextureID getViewportSceneTextureID() const;
ImTextureID getGameSceneTextureID() const;
ImTextureID getOrCreateUIImage(const std::string& path, int* outWidth = nullptr, int* outHeight = nullptr);
void invalidateImagePath(const std::string& path);
bool isReady() const { return initialized; }
bool isImGuiReady() const { return imguiInitialized; }

View File

@@ -1,4 +1,5 @@
#include "Engine.h"
#include "CrashReporter.h"
#include <filesystem>
#include <iostream>
#include <string>
@@ -41,7 +42,11 @@ static std::filesystem::path getExecutableDir() {
#endif
}
int main() {
int main(int argc, char** argv) {
if (Modularity::CrashReporter::HandleCrashReporterMode(argc, argv)) {
return 0;
}
if (auto exeDir = getExecutableDir(); !exeDir.empty()) {
std::error_code ec;
std::filesystem::current_path(exeDir, ec);
@@ -50,21 +55,25 @@ int main() {
<< ec.message() << std::endl;
}
}
std::cerr << "[DEBUG] Starting engine initialization..." << std::endl;
Engine engine;
const std::string executablePath = (argc > 0 && argv && argv[0]) ? argv[0] : "";
Modularity::CrashReporter::Initialize("Modularity", executablePath);
std::cerr << "[DEBUG] Calling engine.init()..." << std::endl;
if (!engine.init()) {
std::cerr << "[DEBUG] Engine init failed!" << std::endl;
return -1;
}
return Modularity::CrashReporter::RunProtected([]() -> int {
std::cerr << "[DEBUG] Starting engine initialization..." << std::endl;
Engine engine;
std::cerr << "[DEBUG] Engine init succeeded, starting run loop..."
<< std::endl;
engine.run();
std::cerr << "[DEBUG] Calling engine.init()..." << std::endl;
if (!engine.init()) {
std::cerr << "[DEBUG] Engine init failed!" << std::endl;
return -1;
}
std::cerr << "[DEBUG] Run loop ended, shutting down..." << std::endl;
engine.shutdown();
std::cerr << "[DEBUG] Engine init succeeded, starting run loop..."
<< std::endl;
engine.run();
return 0;
std::cerr << "[DEBUG] Run loop ended, shutting down..." << std::endl;
engine.shutdown();
return 0;
});
}

View File

@@ -1,4 +1,5 @@
#include "Engine.h"
#include "CrashReporter.h"
#include <filesystem>
#include <iostream>
#include <string>
@@ -33,7 +34,11 @@ static std::filesystem::path getExecutableDir() {
#endif
}
int main() {
int main(int argc, char** argv) {
if (Modularity::CrashReporter::HandleCrashReporterMode(argc, argv)) {
return 0;
}
if (auto exeDir = getExecutableDir(); !exeDir.empty()) {
std::error_code ec;
std::filesystem::current_path(exeDir, ec);
@@ -42,11 +47,15 @@ int main() {
<< ec.message() << std::endl;
}
}
const std::string executablePath = (argc > 0 && argv && argv[0]) ? argv[0] : "";
Modularity::CrashReporter::Initialize("Modularity Player", executablePath);
Engine engine;
if (!engine.init()) {return -1;}
return Modularity::CrashReporter::RunProtected([]() -> int {
Engine engine;
if (!engine.init()) {return -1;}
engine.run();
engine.shutdown();
return 0;
engine.run();
engine.shutdown();
return 0;
});
}