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:
8
CrashReports/Modularity-preview.log
Normal file
8
CrashReports/Modularity-preview.log
Normal 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
|
||||
BIN
Resources/Sounds/Crash Error.mp3
Normal file
BIN
Resources/Sounds/Crash Error.mp3
Normal file
Binary file not shown.
10
imgui.ini
Normal file
10
imgui.ini
Normal 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
722
src/CrashReporter.cpp
Normal 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
13
src/CrashReporter.h
Normal 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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
814
src/EditorWindows/PixelSpriteEditorWindow.cpp
Normal file
814
src/EditorWindows/PixelSpriteEditorWindow.cpp
Normal 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();
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
71
src/Engine.h
71
src/Engine.h
@@ -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;
|
||||
|
||||
@@ -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); }},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
588
src/SpritesheetFormat.cpp
Normal 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
35
src/SpritesheetFormat.h
Normal 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();
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
37
src/main.cpp
37
src/main.cpp
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user