diff --git a/.gitmodules b/.gitmodules index e5725bb..a68397a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -15,3 +15,6 @@ [submodule "external/implot"] path = external/implot url = https://github.com/epezent/implot.git +[submodule "external/imnodes"] + path = external/imnodes + url = https://github.com/Nelarius/imnodes diff --git a/CMakeLists.txt b/CMakeLists.txt index 46c0297..77fdf57 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.15) include(FetchContent) -if (WIN32) +if(WIN32) set(VCPKG_TARGET_TRIPLET x64-windows-static-md) endif() @@ -27,7 +27,7 @@ add_subdirectory(external/embed) file(GLOB_RECURSE SOURCES "src/*.cpp" "src/*.h") -set(EXTERNAL_SOURCES +set(EXTERNAL_SOURCES external/tinyfiledialogs/tinyfiledialogs.c external/microtar/src/microtar.c external/nitroarc/nitroarc.c @@ -50,6 +50,9 @@ set(EXTERNAL_SOURCES # ImPlot external/implot/implot.cpp external/implot/implot_items.cpp + + # ImNodes + external/imnodes/imnodes.cpp ) add_executable(nitroefx ${SOURCES} ${EXTERNAL_SOURCES}) @@ -66,7 +69,7 @@ execute_process( ERROR_QUIET ) -if (NOT NITROEFX_VERSION) +if(NOT NITROEFX_VERSION) set(NITROEFX_VERSION "v0.0.0") endif() @@ -78,9 +81,11 @@ configure_file( # Read versions for packages whose CMake configs don't set _VERSION set(VCPKG_STATUS_FILE "${CMAKE_BINARY_DIR}/vcpkg_installed/vcpkg/status") -if (EXISTS ${VCPKG_STATUS_FILE}) + +if(EXISTS ${VCPKG_STATUS_FILE}) file(READ ${VCPKG_STATUS_FILE} _vcpkg_status) - if (_vcpkg_status MATCHES "Package: efsw\nVersion: ([^\n]+)") + + if(_vcpkg_status MATCHES "Package: efsw\nVersion: ([^\n]+)") set(efsw_VERSION "${CMAKE_MATCH_1}") endif() endif() @@ -91,11 +96,11 @@ configure_file( @ONLY ) -if (WIN32 AND NOT CMAKE_BUILD_TYPE MATCHES Debug) +if(WIN32 AND NOT CMAKE_BUILD_TYPE MATCHES Debug) set_target_properties(nitroefx PROPERTIES WIN32_EXECUTABLE true) endif() -if (WIN32) +if(WIN32) set(IMAGEQUANT_LIB "${LIBIMAGEQUANT_DIR}/target/release/imagequant_sys.lib") else() set(IMAGEQUANT_LIB "${LIBIMAGEQUANT_DIR}/target/release/libimagequant_sys.a") @@ -115,8 +120,8 @@ add_custom_target(libimagequant_sys_build ALL add_dependencies(nitroefx libimagequant_sys_build) -target_include_directories(nitroefx PRIVATE - src +target_include_directories(nitroefx PRIVATE + src external/tinyfiledialogs external/microtar/src external/stb @@ -127,13 +132,14 @@ target_include_directories(nitroefx PRIVATE external/imgui/backends external/imgui/misc/freetype external/imgui/misc/cpp - external/implot) -target_compile_definitions(nitroefx PRIVATE + external/implot + external/imnodes) +target_compile_definitions(nitroefx PRIVATE GLM_ENABLE_EXPERIMENTAL IMGUI_DEFINE_MATH_OPERATORS IMGUI_USER_CONFIG=) -target_link_libraries(nitroefx PRIVATE - SDL3::SDL3 +target_link_libraries(nitroefx PRIVATE + SDL3::SDL3 spdlog::spdlog fmt::fmt glm::glm @@ -147,14 +153,15 @@ target_link_libraries(nitroefx PRIVATE Freetype::Freetype ${IMAGEQUANT_LIB}) -if (WIN32) +if(WIN32) target_link_libraries(nitroefx PRIVATE Ws2_32.lib ntdll.lib userenv.lib Mpr.lib) target_compile_options(nitroefx PRIVATE /wd4312) target_sources(nitroefx PRIVATE data/icon.rc) target_compile_definitions(nitroefx PRIVATE NOMINMAX WIN32_LEAN_AND_MEAN) else() target_compile_options(nitroefx PRIVATE -Wno-int-to-pointer-cast -Wno-deprecated-enum-enum-conversion) - if (CMAKE_BUILD_TYPE MATCHES Debug) + + if(CMAKE_BUILD_TYPE MATCHES Debug) target_compile_definitions(nitroefx PRIVATE _DEBUG) # Match MSVC debug define endif() endif() diff --git a/external/imnodes b/external/imnodes new file mode 160000 index 0000000..eb36902 --- /dev/null +++ b/external/imnodes @@ -0,0 +1 @@ +Subproject commit eb36902c892548ef94f88f51ad7e7c9c7058a71c diff --git a/src/application.cpp b/src/application.cpp index f2b975e..a8a36f0 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -105,7 +105,9 @@ Application::Application() { }; } -int Application::run(int argc, char** argv) { +int Application::run(std::optional openPath) { + m_startupPath = std::move(openPath); + initLogging(); if (!SDL_Init(SDL_INIT_VIDEO)) { @@ -235,13 +237,15 @@ int Application::run(int argc, char** argv) { m_editor->update(delta); - if (!idle || activeEmitters) { - m_editor->updateParticles(delta); - } else if (idle && !activeEmitters) { - m_idleAccumulator += delta; - if (m_idleAccumulator > 0.5f) { - m_editor->updateParticles(m_idleAccumulator); - m_idleAccumulator = 0.0f; + if (const auto& active = g_projectManager->getActiveEditor()) { + if (!idle || activeEmitters) { + active->update(delta); + } else if (idle && !activeEmitters) { + m_idleAccumulator += delta; + if (m_idleAccumulator > 0.5f) { + active->update(m_idleAccumulator); + m_idleAccumulator = 0.0f; + } } } @@ -258,7 +262,9 @@ int Application::run(int argc, char** argv) { // Build default docking layout once if no ini file exists if (!m_layoutInitialized) { initDefaultDockingLayout(); - checkArgs(argc, argv); + if (m_startupPath) { + openStartupTarget(*m_startupPath); + } } ImGui::DockSpaceOverViewport(ImGui::GetID("DockSpace")); @@ -347,71 +353,6 @@ int Application::run(int argc, char** argv) { return 0; } -int Application::runCli(argparse::ArgumentParser& parser) { - if (parser.is_subcommand_used("export")) { - const auto& cmd = parser.at("export"); - const auto output = cmd.get("--output"); - const auto indices = cmd.get>("--index"); - - std::filesystem::path filePath(cmd.get("path")); - if (!SPLArchive::isValid(filePath)) { - spdlog::error("Invalid SPL file: {}", filePath.string()); - return 1; - } - - SPLArchive archive(filePath, /* createGpuTextures */ false); - - std::filesystem::path outputPath = output.empty() ? std::filesystem::current_path() : std::filesystem::path(output); - - if (indices.empty()) { - std::filesystem::create_directories(outputPath); - archive.exportTextures(outputPath); - spdlog::info("Exported {} textures to {}", archive.getTextureCount(), outputPath.string()); - return 0; - } - - if (indices.size() == 1) { - const int index = indices[0]; - if (index < 0 || static_cast(index) >= archive.getTextureCount()) { - spdlog::error("Invalid texture index: {}", index); - return 1; - } - - if (std::filesystem::is_directory(outputPath)) { - std::string filename = fmt::format("texture_{}.png", index); - outputPath /= filename; - } - - archive.exportTexture(index, outputPath); - spdlog::info("Exported texture {} to {}", index, outputPath.string()); - return 0; - } - - if (std::filesystem::is_regular_file(outputPath)) { - spdlog::error("Output path must be a directory when exporting multiple textures"); - return 1; - } - - std::filesystem::create_directories(outputPath); - archive.exportTextures(outputPath); - } else if (parser.is_subcommand_used("info")) { - const auto& cmd = parser.at("info"); - std::filesystem::path filePath(cmd.get("path")); - if (!SPLArchive::isValid(filePath)) { - spdlog::error("Invalid SPL file: {}", filePath.string()); - return 1; - } - - SPLArchive archive(filePath, /* createGpuTextures */ false); - archive.printInfo(filePath.filename().string()); - } else { - spdlog::error("No subcommand specified"); - return 1; - } - - return 0; -} - void Application::showPopup(const std::string& title, const std::string& message, PopupType type, std::optional callback) { const auto id = ImGui::GetID(title.c_str()); @@ -1161,7 +1102,9 @@ void Application::renderPerformanceWindow() { } ImGui::SeparatorText("Current Editor"); - m_editor->renderStats(); + if (const auto& editor = g_projectManager->getActiveEditor()) { + editor->renderStats(); + } } ImGui::End(); @@ -1835,12 +1778,12 @@ void Application::executeAction(u32 action) { break; case ApplicationAction::Undo: if (g_projectManager->hasActiveEditor()) { - g_projectManager->getActiveEditor()->undo(); + m_editor->undo(); } break; case ApplicationAction::Redo: if (g_projectManager->hasActiveEditor()) { - g_projectManager->getActiveEditor()->redo(); + m_editor->redo(); } break; case ApplicationAction::Exit: @@ -1870,18 +1813,13 @@ void Application::executeAction(u32 action) { } } -void Application::checkArgs(int argc, char** argv) { - if (argc > 1) { - const std::filesystem::path arg = argv[1]; - if (std::filesystem::is_directory(arg)) { - g_projectManager->openProject(arg); - } - else if (arg.extension() == ".spa") { - g_projectManager->openEditor(arg); - } - else { - spdlog::warn("Invalid argument: {}", arg.string()); - } +void Application::openStartupTarget(const std::filesystem::path& path) { + if (std::filesystem::is_directory(path)) { + addRecentProject(path.string()); + g_projectManager->openProject(path); + } else { + // Routes by file type (.spa -> editor, .narc/.arc -> narc project) and updates recents. + tryOpenEditor(path); } } @@ -2967,8 +2905,7 @@ bool Application::gunzipFile(const std::filesystem::path& srcPath, std::vectorhasActiveEditor()) return false; - const auto editor = g_projectManager->getActiveEditor(); + const auto editor = g_projectManager->getActiveEditorAs(); if (!editor) return false; // If any emitters exist we treat as active animation return !editor->getParticleSystem().getEmitters().empty(); diff --git a/src/application.h b/src/application.h index 6901e4d..7e28822 100644 --- a/src/application.h +++ b/src/application.h @@ -7,7 +7,6 @@ #include "version.h" #include "util/circular_buffer.h" -#include #include #include @@ -78,8 +77,7 @@ class Application { public: Application(); - int run(int argc, char** argv); - int runCli(argparse::ArgumentParser& parser); + int run(std::optional openPath = std::nullopt); void showPopup(const std::string& title, const std::string& message, PopupType type, std::optional callback); @@ -142,7 +140,7 @@ class Application { void loadIcon(); void clearTempDir(); void executeAction(u32 action); - void checkArgs(int argc, char** argv); + void openStartupTarget(const std::filesystem::path& path); void clearCache(); void checkForBackups(); @@ -250,6 +248,9 @@ class Application { // Tracks whether we've already attempted to initialize the layout bool m_layoutInitialized = false; + // Optional file/folder to open once the GUI has initialized (from the command line) + std::optional m_startupPath; + std::string m_downloadedArchivePath; std::string m_extractedBinaryPath; diff --git a/src/cli.cpp b/src/cli.cpp new file mode 100644 index 0000000..7c92632 --- /dev/null +++ b/src/cli.cpp @@ -0,0 +1,174 @@ +#include "cli.h" + +#include "version.h" +#include "spl/spl_archive.h" + +#include +#include +#include + +#include + +namespace cli { + +namespace { + +constexpr auto EXPORT_CMD = "export"; +constexpr auto INFO_CMD = "info"; + +} + +Command parse(int argc, char** argv) { + if (argc <= 1) { + return GuiCommand{}; + } + + const std::string_view first = argv[1]; + const bool isFlag = first.starts_with('-'); + const bool isSubcommand = first == EXPORT_CMD || first == INFO_CMD; + + if (!isFlag && !isSubcommand) { + std::filesystem::path path = argv[1]; + if (std::filesystem::exists(path)) { + return GuiCommand{ std::move(path) }; + } + } + + argparse::ArgumentParser program("nitroefx", NITROEFX_VERSION); + program.add_description( + "Launch the GUI, optionally opening a file or folder:\n" + " nitroefx [path]\n"); + + program.add_argument("--apply-update") + .nargs(3) + .help("Internal use only."); + + program.add_argument("--relaunch") + .flag() + .help("Internal use only."); + + argparse::ArgumentParser export_cmd(EXPORT_CMD); + export_cmd.add_argument("path") + .help("Path to a .spa file") + .required() + .nargs(1); + + export_cmd.add_argument("-i", "--index") + .help("Texture index to export") + .nargs(argparse::nargs_pattern::at_least_one) + .scan<'i', int>(); + + export_cmd.add_argument("-o", "--output") + .help("Output path. Can be a directory (always) or a file path (only when used with a single index -i)") + .nargs(1); + + export_cmd.add_description("\nExamples:\n" + " nitroefx export path/to/file.spa\n" + " nitroefx export -i 1 3 4 -o /output/directory path/to/file.spa \n" + " nitroefx export -i 0 -o /output/directory/texture.png path/to/file.spa \n"); + + argparse::ArgumentParser info_cmd(INFO_CMD); + info_cmd.add_argument("path").help("Path to a .spa file") + .required() + .nargs(1); + + info_cmd.add_description("\nExamples:\n" + " nitroefx info path/to/file.spa\n"); + + program.add_subparser(export_cmd); + program.add_subparser(info_cmd); + + try { + program.parse_args(argc, argv); + } catch (const std::exception& err) { + spdlog::error("Error parsing arguments: {}", err.what()); + std::exit(1); + } + + if (program.is_used("--apply-update")) { + const auto args = program.get>("--apply-update"); + return UpdateCommand{ + .src = args[0], + .dst = args[1], + .pid = std::stoul(args[2]), + .relaunch = program.get("--relaunch"), + }; + } + + if (program.is_subcommand_used(EXPORT_CMD)) { + const auto& cmd = program.at(EXPORT_CMD); + ExportCommand result; + result.path = cmd.get("path"); + if (auto indices = cmd.present>("--index")) { + result.indices = std::move(*indices); + } + if (auto output = cmd.present("--output")) { + result.output = std::filesystem::path(*output); + } + return result; + } + + if (program.is_subcommand_used(INFO_CMD)) { + const auto& cmd = program.at(INFO_CMD); + return InfoCommand{ .path = cmd.get("path") }; + } + + // Fall back to GUI + return GuiCommand{}; +} + +int runExport(const ExportCommand& cmd) { + if (!SPLArchive::isValid(cmd.path)) { + spdlog::error("Invalid SPL file: {}", cmd.path.string()); + return 1; + } + + SPLArchive archive(cmd.path, /* createGpuTextures */ false); + + std::filesystem::path outputPath = cmd.output.value_or(std::filesystem::current_path()); + + if (cmd.indices.empty()) { + std::filesystem::create_directories(outputPath); + archive.exportTextures(outputPath); + spdlog::info("Exported {} textures to {}", archive.getTextureCount(), outputPath.string()); + return 0; + } + + if (cmd.indices.size() == 1) { + const int index = cmd.indices[0]; + if (index < 0 || static_cast(index) >= archive.getTextureCount()) { + spdlog::error("Invalid texture index: {}", index); + return 1; + } + + if (std::filesystem::is_directory(outputPath)) { + outputPath /= fmt::format("texture_{}.png", index); + } + + archive.exportTexture(index, outputPath); + spdlog::info("Exported texture {} to {}", index, outputPath.string()); + return 0; + } + + if (std::filesystem::is_regular_file(outputPath)) { + spdlog::error("Output path must be a directory when exporting multiple textures"); + return 1; + } + + std::filesystem::create_directories(outputPath); + archive.exportTextures(outputPath); + return 0; +} + +int runInfo(const InfoCommand& cmd) { + if (!SPLArchive::isValid(cmd.path)) { + spdlog::error("Invalid SPL file: {}", cmd.path.string()); + return 1; + } + + SPLArchive archive(cmd.path, /* createGpuTextures */ false); + archive.printInfo(cmd.path.filename().string()); + return 0; +} + +} diff --git a/src/cli.h b/src/cli.h new file mode 100644 index 0000000..1b14697 --- /dev/null +++ b/src/cli.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include + +namespace cli { + +struct GuiCommand { + std::optional openPath; +}; + +struct ExportCommand { + std::filesystem::path path; + std::vector indices; // empty means all textures + std::optional output; +}; + +struct InfoCommand { + std::filesystem::path path; +}; + +struct UpdateCommand { + std::filesystem::path src; + std::filesystem::path dst; + unsigned long pid; + bool relaunch; +}; + +using Command = std::variant; + +Command parse(int argc, char** argv); + +// Headless operations +int runExport(const ExportCommand& cmd); +int runInfo(const InfoCommand& cmd); + +} diff --git a/src/editor/base_editor.h b/src/editor/base_editor.h new file mode 100644 index 0000000..1bb1629 --- /dev/null +++ b/src/editor/base_editor.h @@ -0,0 +1,53 @@ +#pragma once + +#include "types.h" + +#include + +#include +#include +#include +#include +#include +#include + + +enum class EditorType { + Particle, + MoveAnim, +}; + + +class BaseEditor { +public: + using Clock = std::chrono::steady_clock; + + virtual ~BaseEditor() = default; + + [[nodiscard]] virtual bool isModified() const = 0; + [[nodiscard]] virtual bool isTemp() const = 0; + [[nodiscard]] virtual bool isRecovered() const = 0; + [[nodiscard]] virtual EditorType getType() const = 0; + [[nodiscard]] virtual u64 getUniqueID() const = 0; + [[nodiscard]] virtual std::string getName() const = 0; + [[nodiscard]] virtual const std::filesystem::path& getPath() const = 0; + [[nodiscard]] virtual bool notifyClosing() = 0; + [[nodiscard]] virtual std::optional getNarcIndex() const { return std::nullopt; } + [[nodiscard]] virtual bool isAnimating() const { return false; } + + virtual void makePermanent() = 0; + virtual void save() = 0; + virtual void saveAs(const std::filesystem::path& path) = 0; + virtual void rename(std::string_view name) = 0; + + [[nodiscard]] virtual Clock::time_point getLastBackupTime() const = 0; + virtual void saveBackup() = 0; + + virtual std::pair render() = 0; + virtual void handleEvent(const SDL_Event& event) = 0; + + virtual void update(float dt) {} + + virtual void renderStats() {} + virtual void renderPanels() {} +}; diff --git a/src/editor/editor.cpp b/src/editor/editor.cpp index 581310e..af7c336 100644 --- a/src/editor/editor.cpp +++ b/src/editor/editor.cpp @@ -15,38 +15,13 @@ #include #include #include -#include #include #include -#include -#include -#include -#include #include -#define LOCKED_EDITOR() activeEditor_locked -#define LOCK_EDITOR() auto LOCKED_EDITOR() = m_activeEditor.lock() -#define NOTIFY(action) LOCKED_EDITOR()->valueChanged(action) -#define PUSH_HISTORY() LOCKED_EDITOR()->pushHistory() -#define HELP(name) helpPopup(help::name) - - -namespace { - -constexpr std::array s_emitterSpawnTypes = { - "Single Shot", - "Looped", - "Interval" -}; - -} - - -Editor::Editor() : m_xAnimBuffer(), m_yAnimBuffer1() { +Editor::Editor() { m_gridRenderer = std::make_shared(s_gridDimensions, s_gridSpacing); - m_debugRenderer = std::make_unique(1000); - m_collisionGridRenderer = std::make_shared(s_gridDimensions / 2, s_gridSpacing); m_settingsWindowId = ImHashStr("Settings##Editor"); m_tutorialWindowId = ImHashStr("Tutorial##Editor"); } @@ -73,19 +48,6 @@ void Editor::update(float deltaTime) { void Editor::render() { const auto& instances = g_projectManager->getOpenEditors(); - if (m_discardTempTexture) { - destroyTempTexture(); - } - - if (m_deleteSelectedTexture) { - const auto& editor = g_projectManager->getActiveEditor(); - if (editor) { - editor->getArchive().deleteTexture(m_selectedTexture); - m_selectedTexture = -1; - m_deleteSelectedTexture = false; - } - } - ImGuiWindowClass windowClass; windowClass.DockNodeFlagsOverrideSet = ImGuiDockNodeFlags_NoTabBar | ImGuiDockNodeFlags_NoDockingOverCentralNode @@ -94,7 +56,7 @@ void Editor::render() { ImGui::SetNextWindowClass(&windowClass); ImGui::Begin("Work Area##Editor", nullptr, ImGuiWindowFlags_NoDecoration); - std::vector> toClose; + std::vector> toClose; if (ImGui::BeginTabBar("Editor Instances", ImGuiTabBarFlags_Reorderable | ImGuiTabBarFlags_FittingPolicyShrink | ImGuiTabBarFlags_AutoSelectNewTabs)) { for (const auto& instance : instances) { @@ -118,16 +80,8 @@ void Editor::render() { ImGui::End(); - if (m_pickerOpen) { - renderResourcePicker(); - } - - if (m_textureManagerOpen) { - renderTextureManager(); - } - - if (m_editorOpen) { - renderResourceEditor(); + if (const auto& active = g_projectManager->getActiveEditor()) { + active->renderPanels(); } if (m_settingsOpen) { @@ -191,14 +145,14 @@ void Editor::render() { } void Editor::renderParticles() { - const auto& editor = g_projectManager->getActiveEditor(); + const auto editor = g_projectManager->getActiveEditorAs(); if (!editor) { return; } std::vector renderers = { m_gridRenderer.get() }; - renderDebugShapes(editor, renderers); + editor->renderDebugShapes(renderers); editor->renderParticles(renderers); } @@ -210,7 +164,12 @@ void Editor::renderMenu(std::string_view name) { if (ImGui::MenuItemIcon(ICON_FA_EYE, "Use Ortho Camera", nullptr, &m_settings.useOrthographicCamera)) { saveConfig = true; for (const auto& instance : g_projectManager->getOpenEditors()) { - instance->getCamera().setProjection( + const auto particleEditor = std::dynamic_pointer_cast(instance); + if (!particleEditor) { + continue; + } + + particleEditor->getCamera().setProjection( m_settings.useOrthographicCamera ? CameraProjection::Orthographic : CameraProjection::Perspective ); } @@ -263,59 +222,6 @@ void Editor::renderToolbar(float itemHeight) { ImGui::PopStyleVar(); } -void Editor::renderStats() { - const auto& editor = g_projectManager->getActiveEditor(); - if (!editor) { - return; - } - - const auto& system = editor->getParticleSystem(); - - const auto activeParticles = system.getParticleCount(); - const auto maxParticles = system.getMaxParticles(); - const auto fraction = static_cast(activeParticles) / maxParticles; - const auto particleText = fmt::format("Particles: {}/{}", activeParticles, maxParticles); - - constexpr u32 colorLow = IM_COL32(0, 200, 0, 255); - constexpr u32 colorHigh = IM_COL32(220, 0, 0, 255); - ImGui::AnimatedStatBar("##particleBar", fraction, particleText.c_str(), colorLow, colorHigh); - - const auto emitterCount = system.getEmitters().size(); - const float dt = ImGui::GetIO().DeltaTime; - const ImGuiID flashId = ImGui::GetID("##emitterFlash"); - - if (emitterCount != m_lastEmitterCount) { - m_emitterFlashColor = emitterCount > m_lastEmitterCount ? AppColors::LightGreen : AppColors::LightRed; - iam_tween_float(flashId, 0, 1.0f, 0.0f, iam_ease_preset(iam_ease_linear), iam_policy_cut, dt, 0.0f); - m_lastEmitterCount = emitterCount; - } - - const float flash = iam_tween_float( - flashId, 0, 0.0f, 0.45f, - iam_ease_preset(iam_ease_out_cubic), - iam_policy_crossfade, dt, 0.0f - ); - - const auto emitterText = fmt::format("Active Emitters: {}", emitterCount); - if (flash > 0.001f && m_emitterFlashColor != 0) { - const auto& style = ImGui::GetStyle(); - const ImVec2 p = ImGui::GetCursorScreenPos(); - const ImVec2 ts = ImGui::CalcTextSize(emitterText.c_str()); - const ImVec2 pad = { style.FramePadding.x, style.FramePadding.y * 0.5f }; - - ImVec4 hl = ImGui::ColorConvertU32ToFloat4(m_emitterFlashColor); - hl.w = flash * 0.35f; - ImGui::GetWindowDrawList()->AddRectFilled( - p - pad, - { p.x + ts.x + pad.x, p.y + ts.y + pad.y }, - ImGui::ColorConvertFloat4ToU32(hl), - style.FrameRounding - ); - } - - ImGui::TextUnformatted(emitterText.c_str()); -} - void Editor::openPicker() { m_pickerOpen = true; } @@ -328,25 +234,6 @@ void Editor::openTextureManager() { m_textureManagerOpen = true; } -void Editor::updateParticles(float deltaTime) { - const auto& editor = g_projectManager->getActiveEditor(); - if (!editor) { - return; - } - - for (auto& task : m_emitterTasks) { - const auto now = std::chrono::steady_clock::now(); - if (task.editorID == editor->getUniqueID() && m_timeScale * (now - task.time) >= task.interval) { - editor->getParticleSystem().addEmitter( - editor->getArchive().getResources()[task.resourceIndex], - false - ); - task.time = now; - } - } - - editor->updateParticles(deltaTime * m_timeScale); -} void Editor::openSettings() { if (m_settingsOpen) { @@ -377,8 +264,8 @@ void Editor::onEditorOpened(const std::shared_ptr &editor) { void Editor::onEditorRenamed(const std::filesystem::path& oldPath, const std::filesystem::path& newPath) { const auto editor = g_projectManager->getEditor(oldPath); - if (editor) { - editor->rename(newPath); + if (const auto particleEditor = std::dynamic_pointer_cast(editor)) { + particleEditor->rename(newPath); } } @@ -462,7 +349,7 @@ void Editor::saveConfig(nlohmann::json& config) const { } bool Editor::canUndo() const { - const auto& editor = g_projectManager->getActiveEditor(); + const auto editor = g_projectManager->getActiveEditorAs(); if (!editor) { return false; } @@ -471,7 +358,7 @@ bool Editor::canUndo() const { } bool Editor::canRedo() const { - const auto& editor = g_projectManager->getActiveEditor(); + const auto editor = g_projectManager->getActiveEditorAs(); if (!editor) { return false; } @@ -480,24 +367,24 @@ bool Editor::canRedo() const { } void Editor::undo() { - const auto& editor = g_projectManager->getActiveEditor(); + const auto editor = g_projectManager->getActiveEditorAs(); if (!editor) { return; } if (editor->undo() == EditorActionType::ResourceAdd) { - ensureValidSelection(editor); + editor->ensureValidSelection(); } } void Editor::redo() { - const auto& editor = g_projectManager->getActiveEditor(); + const auto editor = g_projectManager->getActiveEditorAs(); if (!editor) { return; } if (editor->redo() == EditorActionType::ResourceRemove) { - ensureValidSelection(editor); + editor->ensureValidSelection(); } } @@ -538,74 +425,27 @@ void Editor::pushClipboard(const std::string& source, const SPLResource& res, co } void Editor::playEmitter(EmitterSpawnType spawnType) { - const auto& editor = g_projectManager->getActiveEditor(); - if (!editor) { - return; - } - - const auto resourceIndex = m_selectedResources[editor->getUniqueID()]; - if (resourceIndex < 0 || resourceIndex >= editor->getArchive().getResources().size()) { - spdlog::warn("Invalid resource index: {}", resourceIndex); - return; - } - - editor->getParticleSystem().addEmitter( - editor->getArchive().getResource(resourceIndex), - spawnType == EmitterSpawnType::Looped - ); - - if (spawnType == EmitterSpawnType::Interval) { - m_emitterTasks.emplace_back( - resourceIndex, - std::chrono::steady_clock::now(), - std::chrono::duration(m_emitterInterval), - editor->getUniqueID() - ); + if (const auto editor = g_projectManager->getActiveEditorAs()) { + editor->playEmitter(spawnType); } } void Editor::playAllEmitters(EmitterSpawnType spawnType) { - const auto& editor = g_projectManager->getActiveEditor(); - if (!editor) { - return; - } - - for (size_t i = 0; i < editor->getArchive().getResources().size(); ++i) { - editor->getParticleSystem().addEmitter( - editor->getArchive().getResource(i), - spawnType == EmitterSpawnType::Looped - ); - - if (spawnType == EmitterSpawnType::Interval) { - m_emitterTasks.emplace_back( - i, - std::chrono::steady_clock::now(), - std::chrono::duration(m_emitterInterval), - editor->getUniqueID() - ); - } + if (const auto editor = g_projectManager->getActiveEditorAs()) { + editor->playAllEmitters(spawnType); } } void Editor::killEmitters() { - const auto& editor = g_projectManager->getActiveEditor(); - if (!editor) { - return; + if (const auto editor = g_projectManager->getActiveEditorAs()) { + editor->killEmitters(); } - - editor->getParticleSystem().killAllEmitters(); - std::erase_if(m_emitterTasks, [id = editor->getUniqueID()](const auto& task) { - return task.editorID == id; - }); } void Editor::resetCamera() { - const auto& editor = g_projectManager->getActiveEditor(); - if (!editor) { - return; + if (const auto editor = g_projectManager->getActiveEditorAs()) { + editor->resetCamera(); } - - editor->getCamera().reset(); } void Editor::handleEvent(const SDL_Event& event) { @@ -617,908 +457,162 @@ void Editor::handleEvent(const SDL_Event& event) { editor->handleEvent(event); } -void Editor::selectResource(u64 editorID, size_t resourceIndex) { - m_selectedResources[editorID] = resourceIndex; -} +void Editor::renderSettings() { + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 1.0f); + ImGui::PushOverrideID(m_settingsWindowId); + ImGui::BeginPopupFade("Settings##Editor"); -void Editor::renderResourcePicker() { - if (ImGui::Begin("Resource Picker##Editor", &m_pickerOpen)) { + bool closedThroughButton = false; + if (ImGui::BeginPopupModal("Settings##Editor", &m_settingsOpen)) { + // Reserve a footer area for action buttons and make the main content scrollable + const ImGuiStyle& style = ImGui::GetStyle(); + const float footerReserve = ImGui::GetFrameHeightWithSpacing() + style.ItemSpacing.y; // reserve approx one button row - const auto& style = ImGui::GetStyle(); - constexpr float thumbnailSize = 48.0f; - constexpr ImVec2 thumbnailSizeVec = { thumbnailSize, thumbnailSize }; - const float itemHeight = thumbnailSize + style.FramePadding.y * 2; + ImGui::BeginChild( + ImGui::GetID("##SettingsFrame"), + ImVec2(0, -footerReserve), + ImGuiChildFlags_Borders, + ImGuiWindowFlags_AlwaysVerticalScrollbar + ); - const auto& editor = g_projectManager->getActiveEditor(); - if (!editor) { - ImGui::Text("No editor open"); - ImGui::End(); - return; + if (ImGui::IsWindowAppearing()) { + iam_scroll_to_top(); } - const SPLResourceCopy* toPaste = nullptr; - size_t pasteIndex = -1; - - auto& archive = editor->getArchive(); - auto& resources = archive.getResources(); - auto& textures = archive.getTextures(); - - const auto id = editor->getUniqueID(); - if (!m_selectedResources.contains(id)) { - m_selectedResources[id] = -1; - editor->notifyResourceChanged(-1); + ImGui::SeparatorText("General"); + ImGui::InputScalar("Max Particles", ImGuiDataType_U32, &m_settings.maxParticles); + ImGui::SameLine(); + ImGui::TextDisabled("(?)"); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("The maximum number of particles that can be processed/rendered at once per editor.\n" + "Note that games using SPL usually have a limit of around 1000."); } - bool anyHovered = false; - - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.40f, 0.40f, 0.40f, 0.84f)); - - const auto contentRegion = ImGui::GetContentRegionAvail(); - if (ImGui::BeginListBox("##Resources", contentRegion)) { - const float itemWidth = contentRegion.x - (style.ItemSpacing.x + style.WindowPadding.x) * 1.3f; - const float dt = ImGui::GetIO().DeltaTime; - - // Detect selection changes - const size_t selected = m_selectedResources[id]; - const ImGuiID accentId = ImGui::GetID("##pickerAccent"); - bool selectionChanged = false; - if (m_lastPickerSelection[id] != selected) { - m_lastPickerSelection[id] = selected; - selectionChanged = true; - iam_tween_float(accentId, 0, 0.0f, 0.0f, iam_ease_preset(iam_ease_linear), iam_policy_cut, dt, 0.0f); - } - - const float accentGrow = iam_tween_float( - accentId, 0, - selected != (size_t)-1 ? 1.0f : 0.0f, 0.3f, - iam_ease_preset(iam_ease_out_cubic), - iam_policy_crossfade, dt, 0.0f - ); - - for (size_t i = 0; i < resources.size(); ++i) { - const auto& resource = resources[i]; - const auto& texture = textures[resource.header.misc.textureIndex]; - - ImGui::PushID(i); - const auto itemId = ImGui::GetID("##Resource"); - - const auto name = fmt::format("[{}] Tex {}x{}", i, texture.width, texture.height); - - auto bgColor = m_selectedResources[id] == i - ? style.Colors[ImGuiCol_ButtonActive] - : style.Colors[ImGuiCol_Button]; - - const auto cursor = ImGui::GetCursorScreenPos(); - if (ImGui::InvisibleButton("##Resource", { itemWidth, itemHeight })) { - m_selectedResources[id] = i; - editor->notifyResourceChanged(i); - } - - const bool hovered = ImGui::IsItemHovered(); - const bool pressed = ImGui::IsItemActive(); - - if (hovered) { - anyHovered = true; - - if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { - ImGui::OpenPopup("##ResourcePopup"); - } - } - - const auto targetColor = pressed - ? style.Colors[ImGuiCol_SliderGrabActive] - : hovered ? style.Colors[ImGuiCol_ButtonHovered] : bgColor; - - bgColor = iam_tween_color( - itemId, - 1, - targetColor, - 0.2f, - iam_ease_preset(iam_ease_linear), - iam_policy_crossfade, - iam_col_srgb, - dt, - bgColor - ); - - const auto bgColor2 = bgColor * ImVec4(0.8f, 0.8f, 0.8f, 1.0f); - - // Draw a filled rectangle behind the item - const auto drawList = ImGui::GetWindowDrawList(); - const int firstVtx = drawList->VtxBuffer.Size; - drawList->AddRectFilled( - cursor, - { cursor.x + itemWidth, cursor.y + itemHeight }, - ImGui::ColorConvertFloat4ToU32(bgColor), - style.FrameRounding - ); - const int lastVtx = drawList->VtxBuffer.Size; - - ImGui::ShadeVertsLinearColorGradientKeepAlpha(drawList, - firstVtx, lastVtx, - cursor, { cursor.x, cursor.y + itemHeight }, - ImGui::ColorConvertFloat4ToU32(bgColor), - ImGui::ColorConvertFloat4ToU32(bgColor2) - ); - - // Selection accent bar on the left edge, eased in on selection change - if (m_selectedResources[id] == i && accentGrow > 0.001f) { - const float barHeight = itemHeight * accentGrow; - const float barTop = cursor.y + (itemHeight - barHeight) * 0.5f; - ImVec4 accent = ImGui::ColorConvertU32ToFloat4(AppColors::LightBlue); - accent.w = accentGrow; - drawList->AddRectFilled( - { cursor.x, barTop }, - { cursor.x + 3.0f, barTop + barHeight }, - ImGui::ColorConvertFloat4ToU32(accent), - 1.5f - ); - - if (selectionChanged) { - const float itemContentY = (cursor.y - ImGui::GetWindowPos().y) + ImGui::GetScrollY(); - const float targetScroll = itemContentY - ImGui::GetWindowHeight() * 0.5f + itemHeight * 0.5f; - iam_scroll_to_y(targetScroll, 0.25f); - } - } - - const ImVec2 offset = { - iam_tween_float( - itemId, - 0, - hovered ? 8.0f : 0.0f, - 0.25f, - iam_ease_preset(iam_ease_in_out_quad), - iam_policy_crossfade, - dt - ), - 0.0f - }; - - const auto imagePos = cursor + style.FramePadding + offset; - const auto frameMinPos = imagePos - ImVec2(2, 2); - const auto frameMaxPos = imagePos + ImVec2(thumbnailSize + 2, thumbnailSize + 2); - drawList->AddRectFilled( - frameMinPos, - frameMaxPos, - IM_COL32(60, 60, 60, 255), - 2 - ); - - drawList->AddRect( - frameMinPos, - frameMaxPos, - IM_COL32(230, 230, 230, 30), - 2 - ); - - ImGui::SetCursorScreenPos(imagePos); - ImGui::Image(texture.glTexture->getHandle(), thumbnailSizeVec); - - ImGui::SameLine(); - - ImGui::PushFont(nullptr, Application::getFontSizeLarge()); - const auto textHeight = ImGui::GetFontSize(); - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (thumbnailSize - textHeight) / 2.0f); - ImGui::TextUnformatted(name.c_str()); - ImGui::PopFont(); - - if (ImGui::BeginPopup("##ResourcePopup")) { - if (ImGui::MenuItemIcon(ICON_FA_COPY, "Copy", "Ctrl+C", false, AppColors::LightGreen3)) { - pushClipboard(editor->getName(), resource); - ImGui::CloseCurrentPopup(); - } - - if (ImGui::MenuItemIcon(ICON_FA_COPY, "Copy with Texture", "Ctrl+Shift+C", false, AppColors::LightGreen3)) { - pushClipboard(editor->getName(), resource, texture); - ImGui::CloseCurrentPopup(); - } - - if (ImGui::MenuItemIcon(ICON_FA_PASTE, "Paste", "Ctrl+V", false, AppColors::LightOrange, !m_clipboardHistory.empty())) { - const auto& copy = m_clipboardHistory.back(); - if (!copy.resource) { - spdlog::error("No resource in clipboard to paste"); - } else { - toPaste = © - pasteIndex = i; - } - - ImGui::CloseCurrentPopup(); - } - - if (ImGui::MenuItemIcon(ICON_FA_CLONE, "Duplicate", nullptr, false, AppColors::LightBlue2)) { - editor->duplicateResource(i); - m_selectedResources[id] = resources.size() - 1; - editor->notifyResourceChanged(resources.size() - 1); - - ImGui::CloseCurrentPopup(); - } - - if (ImGui::MenuItemIcon(ICON_FA_TRASH, "Delete", nullptr, false, AppColors::Gray)) { - if (m_selectedResources[id] == i) { - m_selectedResources[id] = -1; - editor->notifyResourceChanged(-1); - } - - editor->deleteResource(i); - ImGui::CloseCurrentPopup(); - } - - ImGui::EndPopup(); - } - - ImGui::SetCursorScreenPos(cursor); - ImGui::Dummy({ itemWidth, itemHeight }); + ImGui::SeparatorText("Colors"); + ImGui::ColorEdit4("Active Emitter Color", glm::value_ptr(m_settings.activeEmitterColor)); + ImGui::ColorEdit4("Edited Emitter Color", glm::value_ptr(m_settings.editedEmitterColor)); + ImGui::ColorEdit4("Collision Plane Bounce Color", glm::value_ptr(m_settings.collisionPlaneBounceColor)); + ImGui::ColorEdit4("Collision Plane Kill Color", glm::value_ptr(m_settings.collisionPlaneKillColor)); + ImGui::ColorEdit4("Background Color", glm::value_ptr(m_settings.backgroundColor)); - ImGui::PopID(); - } + ImGui::SeparatorText("Rendering"); + bool changed = false; + bool swapRenderer = false; - ImGui::EndListBox(); + changed |= ImGui::Checkbox("Use DS Resolution", &m_settings.useFixedDsResolution); + ImGui::SameLine(); + ImGui::TextDisabled("(?)"); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("If enabled, particles will render at the Nintendo DS' native resolution of " + "256x192 * "); } - ImGui::PopStyleColor(); - - if (!anyHovered - && ImGui::IsWindowHovered(ImGuiHoveredFlags_RootAndChildWindows) - && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { - ImGui::OpenPopup("##AddResourcePopup"); + if (m_settings.useFixedDsResolution) { + changed |= ImGui::SliderInt("DS Resolution Scale", &m_settings.fixedDsResolutionScale, 1, 8); + m_settings.fixedDsResolutionScale = glm::clamp(m_settings.fixedDsResolutionScale, 1, 8); } - if (ImGui::BeginPopup("##AddResourcePopup")) { - if (ImGui::MenuItemIcon(ICON_FA_CIRCLE_PLUS, "Add Resource")) { - killEmitters(); // Stop all emitters before adding a new resource to avoid crashes - editor->addResource(); - m_selectedResources[id] = resources.size() - 1; - editor->notifyResourceChanged(resources.size() - 1); - ImGui::CloseCurrentPopup(); - } - - if (ImGui::MenuItemIcon(ICON_FA_PASTE, "Paste", "Ctrl+V", false, AppColors::LightOrange, !m_clipboardHistory.empty())) { - const auto& copy = m_clipboardHistory.back(); - if (!copy.resource) { - spdlog::error("No resource in clipboard to paste"); - } else { - toPaste = © - pasteIndex = -1; // Paste as new resource - } - ImGui::CloseCurrentPopup(); - } - - ImGui::EndPopup(); + if (ImGui::Checkbox("Use Accurate Renderer", &m_settings.useLegacyParticleRenderer)) { + changed = true; + swapRenderer = true; } - if (ImGui::IsWindowFocused() || ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows)) { - if (ImGui::IsKeyPressed(ImGuiKey_C) && ImGui::GetIO().KeyCtrl) { - if (editor && m_selectedResources[id] != -1) { - const auto& resource = resources[m_selectedResources[id]]; - - if (ImGui::GetIO().KeyShift) { - pushClipboard(editor->getName(), resource, textures[resource.header.misc.textureIndex]); - } else { - pushClipboard(editor->getName(), resource); - } - } - } - - if (ImGui::IsKeyPressed(ImGuiKey_V) && ImGui::GetIO().KeyCtrl) { - if (!m_clipboardHistory.empty()) { - const auto& copy = m_clipboardHistory.back(); - if (copy.resource) { - toPaste = © - pasteIndex = -1; // Paste at end - } else { - spdlog::error("No resource in clipboard to paste"); - } - } - } + if (changed) { + updateRenderSettings(swapRenderer); // Update all open editors } - if (toPaste) { - size_t texIndex = 0; - if (toPaste->texture) { - // Texture always pasted at the end - texIndex = textures.size(); - - auto& tex = *toPaste->texture; - - auto& texData = archive.getTextureData(); - auto& palData = archive.getPaletteData(); + ImGui::EndChild(); - texData.push_back(std::move(tex.data)); - palData.push_back(std::move(tex.pltt)); + if (ImGui::Button("Reset to Defaults")) { + m_settings = m_settingsDefault; + } - auto& existingTextures = archive.getTextures(); - existingTextures.emplace_back(std::move(tex.texture)); + ImGui::SameLine(); - editor->getParticleSystem().getRenderer().setTextures(existingTextures); + if (ImGui::Button("Save")) { + if (m_settings.maxParticles != m_settingsBackup.maxParticles) { + updateMaxParticles(); // Apply the setting to all open editors } - if (toPaste->resource) { - if (pasteIndex >= resources.size() || pasteIndex == -1) { - auto& res = resources.emplace_back(std::move(*toPaste->resource)); - res.header.misc.textureIndex = static_cast(texIndex); - } else { - const auto res = resources.emplace(resources.begin() + pasteIndex, std::move(*toPaste->resource)); - res->header.misc.textureIndex = static_cast(texIndex); - } - } + m_settingsBackup = m_settings; + m_settingsOpen = false; + closedThroughButton = true; + ImGui::CloseCurrentPopup(); - editor->valueChanged(true); + g_application->saveConfig(); } - } - - ImGui::End(); -} -void Editor::renderTextureManager() { - if (ImGui::Begin("Texture Manager##Editor", &m_textureManagerOpen)) { - const auto& editor = g_projectManager->getActiveEditor(); - if (!editor) { - ImGui::Text("No editor open"); - ImGui::End(); - return; - } + ImGui::SameLine(); - auto& archive = editor->getArchive(); - auto& textures = archive.getTextures(); - - const auto importPopupId = ImGui::GetID("##ImportTexturePopup"); - const auto deleteTexturePopupId = ImGui::GetID("##DeleteTexturePopup"); - - if (ImGui::IconButton(ICON_FA_FILE_IMPORT, "Import", AppColors::LightBlue)) { - const auto path = tinyfd_openFileDialog( - "Import Texture", - "", - 0, - nullptr, - "Image Files", - 0 - ); - if (path) { - openTempTexture(path); - ImGui::OpenPopup(importPopupId); - } + if (ImGui::Button("Cancel")) { + m_settings = m_settingsBackup; + m_settingsOpen = false; + closedThroughButton = true; + ImGui::CloseCurrentPopup(); } - ImGui::SameLine(); + const auto avail = ImGui::GetContentRegionAvail(); + const auto buttonWidth = ImGui::CalcTextSize("Open Config Directory").x + ImGui::GetStyle().FramePadding.x * 2; + ImGui::SameLine(0.0f, avail.x - buttonWidth * 2.63f); - if (ImGui::IconButton(ICON_FA_FILE_EXPORT, "Export All...", AppColors::Yellow)) { - const auto path = Application::openDirectory("Select Destination"); - if (!path.empty()) { - archive.exportTextures(path, Application::getTempPath()); + if (ImGui::Button("Open Config Directory")) { + const auto path = Application::getConfigPath(); + if (!std::filesystem::exists(path)) { + std::filesystem::create_directories(path); } + SDL_OpenURL(("file://" + path.string()).c_str()); } - ImGui::BeginChild("##TextureList", {}, ImGuiChildFlags_Borders); - bool anyHovered = false; - - const ImVec2 padding = { ImGui::GetStyle().FramePadding.x, 16.0f - ImGui::GetTextLineHeight() * 0.5f }; - - for (int i = 0; i < textures.size(); ++i) { - auto& texture = textures[i]; - const auto name = fmt::format("[{}] Tex {}x{}", i, texture.width, texture.height); - - ImGui::Image((ImTextureID)texture.glTexture->getHandle(), { 32, 32 }); - ImGui::SameLine(); - const bool open = ImGui::PaddedTreeNode(name.c_str(), padding, ImGuiTreeNodeFlags_SpanAvailWidth); - - if (ImGui::BeginPopupContextItem(fmt::format("##TexturePopup{}", i).c_str())) { - if (ImGui::MenuItemIcon(ICON_FA_FILE_IMPORT, "Update...", nullptr, false, AppColors::LightBlue2)) { - const auto path = tinyfd_openFileDialog( - "Update Texture", - "", - 0, - nullptr, - "Image Files", - 0 - ); - - if (path) { - openTempTexture(path, i); - ImGui::OpenPopup(importPopupId); - } - } + ImGui::EndPopup(); + } - if (ImGui::MenuItemIcon(ICON_FA_FILE_EXPORT, "Export...", nullptr, false, AppColors::Yellow)) { - const char* filterPatterns[] = { "*.png", "*.bmp", "*.tga" }; - const auto path = tinyfd_saveFileDialog( - "Export Texture", - fmt::format("texture_{}.png", i).c_str(), - std::size(filterPatterns), - filterPatterns, - nullptr - ); - - if (path) { - archive.exportTexture(i, path); - } - } + // If the window was closed through the 'x' button, we want to restore the settings + if (!m_settingsOpen && !closedThroughButton) { + m_settings = m_settingsBackup; + } - if (ImGui::MenuItemIcon(ICON_FA_COPY, "Copy", nullptr, false, AppColors::LightGreen3)) { - pushClipboard(editor->getName(), texture); - } + ImGui::EndPopupFade(); + ImGui::PopID(); + ImGui::PopStyleVar(); +} - if (ImGui::MenuItemIcon(ICON_FA_TRASH, "Delete", nullptr, false, AppColors::Gray)) { - m_selectedTexture = i; - ImGui::OpenPopup(deleteTexturePopupId); - } +void Editor::renderTutorial() { + ImGui::PushOverrideID(m_tutorialWindowId); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, { 16, 16 }); + ImGui::BeginPopupFade("##ViewportTutorial"); - ImGui::EndPopup(); - } + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, { 0.5f, 0.5f }); + ImGui::SetNextWindowSize({ 900, 0 }, ImGuiCond_Once); - if (open) { - ImGui::Text("Format: %s", getTextureFormat(texture.param.format)); + if (ImGui::BeginPopupModal("##ViewportTutorial", nullptr, ImGuiWindowFlags_NoDecoration)) { + const auto icon = g_application->getIcon(); + const auto windowSize = ImGui::GetWindowSize(); + if (icon) { + constexpr float iconSize = 64.0f; + ImGui::SetCursorPosX((windowSize.x - iconSize) * 0.5f); + ImGui::Image(icon->getHandle(), { iconSize, iconSize }); + } - if (ImGui::BeginCombo("Repeat", getTextureRepeat(texture.param.repeat))) { - for (const auto [val, name] : detail::g_textureRepeatNames) { - if (editor->valueChanged(ImGui::Selectable(name, texture.param.repeat == val))) { - texture.param.repeat = val; - texture.glTexture->setWrapping(texture.param.repeat, texture.param.flip); - } - } + ImGui::PushFont(nullptr, Application::getFontSizeLarge()); - ImGui::EndCombo(); - } + constexpr auto title = "NitroEFX Quick Tutorial"; + const auto titleSize = ImGui::CalcTextSize(title); - if (ImGui::BeginCombo("Flip", getTextureFlip(texture.param.flip))) { - for (const auto [val, name] : detail::g_textureFlipNames) { - if (editor->valueChanged(ImGui::Selectable(name, texture.param.flip == val))) { - texture.param.flip = val; - texture.glTexture->setWrapping(texture.param.repeat, texture.param.flip); - } - } + ImGui::SetCursorPosX((windowSize.x - titleSize.x) * 0.5f); + ImGui::TextUnformatted(title); - ImGui::EndCombo(); - } + ImGui::PopFont(); - if (editor->valueChanged(ImGui::Checkbox("Palette Color 0 Transparent", &texture.param.palColor0Transparent))) { - texture.glTexture->update(texture); - } + ImGui::Separator(); - editor->valueChanged(ImGui::Checkbox("Use Shared Texture", &texture.param.useSharedTexture)); + ImGui::TextDisabled("Tips to navigate the viewport and get started."); - if (texture.param.useSharedTexture) { - ImGui::InputScalar("Shared Texture ID", ImGuiDataType_U8, &texture.param.sharedTexID); - } + ImGui::Spacing(); - ImGui::TreePop(); - } - - anyHovered |= ImGui::IsItemHovered(); - } - - ImGui::EndChild(); - - const auto copyTexture = [&] { - const auto& copy = m_clipboardHistory.back(); - if (!copy.texture || copy.resource) { - spdlog::error("No texture in clipboard to paste"); - } else { - auto& tex = *m_clipboardHistory.back().texture; - - auto& texData = archive.getTextureData(); - auto& palData = archive.getPaletteData(); - - texData.push_back(std::move(tex.data)); - palData.push_back(std::move(tex.pltt)); - - auto& existingTextures = archive.getTextures(); - existingTextures.emplace_back(std::move(tex.texture)); - - editor->getParticleSystem().getRenderer().setTextures(existingTextures); - editor->valueChanged(true); - } - }; - - if (ImGui::IsWindowFocused() || ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows)) { - if (ImGui::IsKeyPressed(ImGuiKey_V) && ImGui::GetIO().KeyCtrl && !m_clipboardHistory.empty()) { - copyTexture(); - } - } - - if (!anyHovered && ImGui::BeginPopupContextItem()) { - if (ImGui::MenuItemIcon(ICON_FA_PASTE, "Paste", "Ctrl+V", false, AppColors::LightOrange, !m_clipboardHistory.empty())) { - copyTexture(); - } - - ImGui::EndPopup(); - } - - const auto popupPos = ImGui::GetMainViewport()->GetCenter(); - ImGui::SetNextWindowPos(popupPos, ImGuiCond_Appearing, { 0.5f, 0.5f }); - if (ImGui::BeginPopupModal("##ImportTexturePopup")) { - const auto textureSize = ImVec2(m_tempTexture->width, m_tempTexture->height) * m_tempTextureScale; - const ImVec2 tableSize = { std::max(textureSize.x, 300.0f), 0.0f}; - const auto& style = ImGui::GetStyle(); - - if (ImGui::BeginTable("##TempTextureTable", 2, - ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchSame, tableSize)) { - - ImGui::TableSetupColumn("##aaa", ImGuiTableColumnFlags_WidthStretch, 0.5f); - ImGui::TableSetupColumn("##bbb", ImGuiTableColumnFlags_WidthStretch, 0.5f); - - ImGui::TableNextColumn(); ImGui::Text("Size"); - ImGui::TableNextColumn(); ImGui::Text("%dx%d", m_tempTexture->width, m_tempTexture->height); - - ImGui::TableNextColumn(); ImGui::Text("Channels"); - ImGui::TableNextColumn(); ImGui::Text("%d", m_tempTexture->channels); - - ImGui::TableNextColumn(); ImGui::Text("Unique Colors"); - ImGui::TableNextColumn(); ImGui::Text("%" PRIu64, m_tempTexture->spec.uniqueColors.size()); - - ImGui::TableNextColumn(); ImGui::Text("Unique Alphas"); - ImGui::TableNextColumn(); ImGui::Text("%" PRIu64, m_tempTexture->spec.uniqueAlphas.size()); - - const auto estimatedSize = m_tempTexture->spec.getSizeEstimate(m_tempTexture->width, m_tempTexture->height); - ImGui::TableNextColumn(); ImGui::Text("Estimated Size"); - ImGui::TableNextColumn(); - if (estimatedSize >= 1024) { - ImGui::Text("%zu kB", estimatedSize / 1024); - } else { - ImGui::Text("%zu B", estimatedSize); - } - - ImGui::TableNextColumn(); ImGui::Text("Format"); - ImGui::TableNextColumn(); - ImGui::SetNextItemWidth(tableSize.x * 0.5f - style.CellPadding.x * 2); - if (ImGui::BeginCombo("##Format", getTextureFormat(m_tempTexture->spec.format))) { - for (auto i = (int)TextureFormat::A3I5; i < (int)TextureFormat::Count; i++) { - const auto flags = (TextureFormat)i == TextureFormat::Comp4x4 ? ImGuiSelectableFlags_Disabled : 0; - if (ImGui::Selectable(getTextureFormat((TextureFormat)i), (int)m_tempTexture->spec.format == i, flags)) { - m_tempTexture->spec.setFormat((TextureFormat)i); - - if (m_tempTexture->spec.format == m_tempTexture->suggestedFormat && m_tempTexture->suggestedFormatUncompressed) { - std::memcpy( - m_tempTexture->quantized, - m_tempTexture->data, - (size_t)m_tempTexture->width * m_tempTexture->height * 4 - ); - } else { - quantizeTexture( - m_tempTexture->data, - m_tempTexture->width, - m_tempTexture->height, - m_tempTexture->spec, - m_tempTexture->quantized - ); - } - - m_tempTexture->texture->update(m_tempTexture->quantized); - } - } - - ImGui::EndCombo(); - } - - ImGui::TableNextColumn(); ImGui::Text("Color Compression"); - ImGui::TableNextColumn(); ImGui::Text("%s", m_tempTexture->spec.requiresColorCompression ? "Yes" : "No"); - - ImGui::TableNextColumn(); ImGui::Text("Alpha Compression"); - ImGui::TableNextColumn(); ImGui::Text("%s", m_tempTexture->spec.requiresAlphaCompression ? "Yes" : "No"); - - ImGui::EndTable(); - } - - ImGui::SetNextItemWidth(150.0f - style.CellPadding.x * 2); - ImGui::SliderFloat("Display Scale", &m_tempTextureScale, 0.1f, 8.0f, "%.2fx"); - ImGui::SameLine(); - ImGui::TextDisabled("(?)"); - if (ImGui::BeginItemTooltip()) - { - ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); - ImGui::TextUnformatted("Only for checking how the texture looks. Does not affect the actual imported texture."); - ImGui::PopTextWrapPos(); - ImGui::EndTooltip(); - } - - ImGui::Image((ImTextureID)m_tempTexture->texture->getHandle(), textureSize); - - if (!m_tempTexture->isValidSize) { - ImGui::TextColored({ 0.93f, 0, 0, 1 }, "Invalid Texture Size (?)"); - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Both the width and the height of the texture must be a power of 2\n" - "and they must be in the range [8, 1024]"); - } - ImGui::BeginDisabled(); - } - - if (ImGui::Button("Confirm") && m_tempTexture->isValidSize) { - importTempTexture(); - ImGui::CloseCurrentPopup(); - } - - if (!m_tempTexture->isValidSize) { - ImGui::EndDisabled(); - } - - ImGui::SameLine(); - - if (ImGui::Button("Cancel")) { - discardTempTexture(); - ImGui::CloseCurrentPopup(); - } - - ImGui::EndPopup(); - } - - if (ImGui::BeginPopupModal("##DeleteTexturePopup")) { - ImGui::Text("Are you sure you want to delete this texture?"); - ImGui::TextDisabled("(This might break existing resources)"); - ImGui::Separator(); - - const auto texCount = archive.getTextureCount(); - if (texCount <= 1) { - ImGui::TextColored({ 0.93f, 0.2f, 0.2f, 1 }, "You cannot delete the last texture."); - ImGui::BeginDisabled(); - } - - if (ImGui::Button("Yes")) { - m_deleteSelectedTexture = true; - ImGui::CloseCurrentPopup(); - } - - if (texCount <= 1) { - ImGui::EndDisabled(); - } - - ImGui::SameLine(); - if (ImGui::Button("No")) { - ImGui::CloseCurrentPopup(); - } - - ImGui::EndPopup(); - } - } - - ImGui::End(); -} - -void Editor::renderResourceEditor() { - if (ImGui::Begin("Resource Editor##Editor", &m_editorOpen)) { - ImGui::SliderFloat("Global Time Scale", &m_timeScale, 0.0f, 2.0f, "%.2f"); - - const auto& editor = g_projectManager->getActiveEditor(); - if (!editor) { - ImGui::Text("No editor open"); - ImGui::End(); - return; - } - - m_activeEditor = editor; - - auto& archive = editor->getArchive(); - auto& resources = archive.getResources(); - auto& textures = archive.getTextures(); - - const auto id = editor->getUniqueID(); - if (!m_selectedResources.contains(id)) { - m_selectedResources[id] = -1; - } - - if (m_selectedResources[id] != -1) { - auto& resource = resources[m_selectedResources[id]]; - const auto& texture = textures[resource.header.misc.textureIndex]; - - if (ImGui::IconButton(ICON_FA_PLAY, "Play Emitter", AppColors::LightGreen)) { - playEmitter(m_emitterSpawnType); - } - - ImGui::SameLine(); - ImGui::SetNextItemWidth(150); - ImGui::Combo("##SpawnType", (int*)&m_emitterSpawnType, s_emitterSpawnTypes.data(), s_emitterSpawnTypes.size()); - - if (m_emitterSpawnType == EmitterSpawnType::Interval) { - ImGui::SameLine(); - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); - ImGui::InputFloat("##Interval", &m_emitterInterval, 0.1f, 1.0f, "%.2fs"); - } - - if (ImGui::IconButton(ICON_FA_PLAY, "Play All Emitters", AppColors::LightGreen)) { - playAllEmitters(m_emitterSpawnType); - } - - if (ImGui::IconButton(ICON_FA_STOP, "Kill Emitters", AppColors::LightRed)) { - killEmitters(); - } - - //if (ImGui::RedButton("Kill Emitters")) { - // killEmitters(); - //} - - if (ImGui::BeginTabBar("##editorTabs")) { - if (ImGui::BeginTabItem("General")) { - ImGui::BeginChild("##headerEditor", {}, ImGuiChildFlags_Borders); - renderHeaderEditor(resource.header); - ImGui::EndChild(); - ImGui::EndTabItem(); - } - - if (ImGui::BeginTabItem("Behaviors")) { - ImGui::BeginChild("##headerEditor", {}, ImGuiChildFlags_Borders); - renderBehaviorEditor(resource); - ImGui::EndChild(); - ImGui::EndTabItem(); - } - - if (ImGui::BeginTabItem("Animations")) { - ImGui::BeginChild("##animationEditor", {}, ImGuiChildFlags_Borders); - renderAnimationEditor(resource); - ImGui::EndChild(); - ImGui::EndTabItem(); - } - - if (ImGui::BeginTabItem("Children")) { - ImGui::BeginChild("##childEditor", {}, ImGuiChildFlags_Borders); - renderChildrenEditor(resource); - ImGui::EndChild(); - ImGui::EndTabItem(); - } - - ImGui::EndTabBar(); - } - } - } - - ImGui::End(); - - m_activeEditor.reset(); -} - -void Editor::renderSettings() { - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 1.0f); - ImGui::PushOverrideID(m_settingsWindowId); - ImGui::BeginPopupFade("Settings##Editor"); - - bool closedThroughButton = false; - if (ImGui::BeginPopupModal("Settings##Editor", &m_settingsOpen)) { - // Reserve a footer area for action buttons and make the main content scrollable - const ImGuiStyle& style = ImGui::GetStyle(); - const float footerReserve = ImGui::GetFrameHeightWithSpacing() + style.ItemSpacing.y; // reserve approx one button row - - ImGui::BeginChild( - ImGui::GetID("##SettingsFrame"), - ImVec2(0, -footerReserve), - ImGuiChildFlags_Borders, - ImGuiWindowFlags_AlwaysVerticalScrollbar - ); - - if (ImGui::IsWindowAppearing()) { - iam_scroll_to_top(); - } - - ImGui::SeparatorText("General"); - ImGui::InputScalar("Max Particles", ImGuiDataType_U32, &m_settings.maxParticles); - ImGui::SameLine(); - ImGui::TextDisabled("(?)"); - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("The maximum number of particles that can be processed/rendered at once per editor.\n" - "Note that games using SPL usually have a limit of around 1000."); - } - - ImGui::SeparatorText("Colors"); - ImGui::ColorEdit4("Active Emitter Color", glm::value_ptr(m_settings.activeEmitterColor)); - ImGui::ColorEdit4("Edited Emitter Color", glm::value_ptr(m_settings.editedEmitterColor)); - ImGui::ColorEdit4("Collision Plane Bounce Color", glm::value_ptr(m_settings.collisionPlaneBounceColor)); - ImGui::ColorEdit4("Collision Plane Kill Color", glm::value_ptr(m_settings.collisionPlaneKillColor)); - ImGui::ColorEdit4("Background Color", glm::value_ptr(m_settings.backgroundColor)); - - ImGui::SeparatorText("Rendering"); - bool changed = false; - bool swapRenderer = false; - - changed |= ImGui::Checkbox("Use DS Resolution", &m_settings.useFixedDsResolution); - ImGui::SameLine(); - ImGui::TextDisabled("(?)"); - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("If enabled, particles will render at the Nintendo DS' native resolution of " - "256x192 * "); - } - - if (m_settings.useFixedDsResolution) { - changed |= ImGui::SliderInt("DS Resolution Scale", &m_settings.fixedDsResolutionScale, 1, 8); - m_settings.fixedDsResolutionScale = glm::clamp(m_settings.fixedDsResolutionScale, 1, 8); - } - - if (ImGui::Checkbox("Use Accurate Renderer", &m_settings.useLegacyParticleRenderer)) { - changed = true; - swapRenderer = true; - } - - if (changed) { - updateRenderSettings(swapRenderer); // Update all open editors - } - - ImGui::EndChild(); - - if (ImGui::Button("Reset to Defaults")) { - m_settings = m_settingsDefault; - } - - ImGui::SameLine(); - - if (ImGui::Button("Save")) { - if (m_settings.maxParticles != m_settingsBackup.maxParticles) { - updateMaxParticles(); // Apply the setting to all open editors - } - - m_settingsBackup = m_settings; - m_settingsOpen = false; - closedThroughButton = true; - ImGui::CloseCurrentPopup(); - - g_application->saveConfig(); - } - - ImGui::SameLine(); - - if (ImGui::Button("Cancel")) { - m_settings = m_settingsBackup; - m_settingsOpen = false; - closedThroughButton = true; - ImGui::CloseCurrentPopup(); - } - - const auto avail = ImGui::GetContentRegionAvail(); - const auto buttonWidth = ImGui::CalcTextSize("Open Config Directory").x + ImGui::GetStyle().FramePadding.x * 2; - ImGui::SameLine(0.0f, avail.x - buttonWidth * 2.63f); - - if (ImGui::Button("Open Config Directory")) { - const auto path = Application::getConfigPath(); - if (!std::filesystem::exists(path)) { - std::filesystem::create_directories(path); - } - SDL_OpenURL(("file://" + path.string()).c_str()); - } - - ImGui::EndPopup(); - } - - // If the window was closed through the 'x' button, we want to restore the settings - if (!m_settingsOpen && !closedThroughButton) { - m_settings = m_settingsBackup; - } - - ImGui::EndPopupFade(); - ImGui::PopID(); - ImGui::PopStyleVar(); -} - -void Editor::renderTutorial() { - ImGui::PushOverrideID(m_tutorialWindowId); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, { 16, 16 }); - ImGui::BeginPopupFade("##ViewportTutorial"); - - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, { 0.5f, 0.5f }); - ImGui::SetNextWindowSize({ 900, 0 }, ImGuiCond_Once); - - if (ImGui::BeginPopupModal("##ViewportTutorial", nullptr, ImGuiWindowFlags_NoDecoration)) { - const auto icon = g_application->getIcon(); - const auto windowSize = ImGui::GetWindowSize(); - if (icon) { - constexpr float iconSize = 64.0f; - ImGui::SetCursorPosX((windowSize.x - iconSize) * 0.5f); - ImGui::Image(icon->getHandle(), { iconSize, iconSize }); - } - - ImGui::PushFont(nullptr, Application::getFontSizeLarge()); - - constexpr auto title = "NitroEFX Quick Tutorial"; - const auto titleSize = ImGui::CalcTextSize(title); - - ImGui::SetCursorPosX((windowSize.x - titleSize.x) * 0.5f); - ImGui::TextUnformatted(title); - - ImGui::PopFont(); - - ImGui::Separator(); - - ImGui::TextDisabled("Tips to navigate the viewport and get started."); - - ImGui::Spacing(); - - if (ImGui::BeginTable("##tutorial_layout", 2, ImGuiTableFlags_SizingStretchProp)) { - ImGui::TableSetupColumn("left", ImGuiTableColumnFlags_WidthStretch, 1.3f); - ImGui::TableSetupColumn("right", ImGuiTableColumnFlags_WidthStretch, 1.0f); - ImGui::TableNextRow(); + if (ImGui::BeginTable("##tutorial_layout", 2, ImGuiTableFlags_SizingStretchProp)) { + ImGui::TableSetupColumn("left", ImGuiTableColumnFlags_WidthStretch, 1.3f); + ImGui::TableSetupColumn("right", ImGuiTableColumnFlags_WidthStretch, 1.0f); + ImGui::TableNextRow(); // Left: Camera controls ImGui::TableSetColumnIndex(0); @@ -1622,1481 +716,28 @@ void Editor::updateRenderSettings(bool swapRenderer) { const auto editors = g_projectManager->getOpenEditors(); for (const auto& editor : editors) { - editor->updateViewportSize(); + const auto particleEditor = std::dynamic_pointer_cast(editor); + if (!particleEditor) { + continue; + } + + particleEditor->updateViewportSize(); if (swapRenderer) { if (legacyRenderer) { - editor->useLegacyRenderer(); + particleEditor->useLegacyRenderer(); } else { - editor->useModernRenderer(); + particleEditor->useModernRenderer(); } } } } -void Editor::renderHeaderEditor(SPLResourceHeader& header) const { - if (m_activeEditor.expired()) { - return; +void Editor::updateMaxParticles() { + const auto editors = g_projectManager->getOpenEditors(); + for (const auto& editor : editors) { + if (const auto particleEditor = std::dynamic_pointer_cast(editor)) { + particleEditor->setMaxParticles(m_settings.maxParticles); + } } - - LOCK_EDITOR(); - - auto& flags = header.flags; - auto& misc = header.misc; - constexpr f32 frameTime = 1.0f / (f32)SPLArchive::SPL_FRAMES_PER_SECOND; - - auto open = ImGui::TreeNodeEx("##emitterSettings", ImGuiTreeNodeFlags_SpanAvailWidth); - ImGui::SameLine(); - ImGui::SetCursorPosY(ImGui::GetCursorPosY() - 5); - ImGui::SeparatorText("Emitter Settings"); - if (open) { - if (ImGui::BeginCombo("Emission Type", getEmissionType(flags.emissionType))) { - for (const auto [val, name] : detail::g_emissionTypeNames) { - if (NOTIFY(ImGui::Selectable(name, flags.emissionType == val))) { - flags.emissionType = val; - } - } - - ImGui::EndCombo(); - } - HELP(emissionType); - - if (ImGui::BeginCombo("Emission Axis", getEmissionAxis(flags.emissionAxis))) { - for (const auto [val, name] : detail::g_emissionAxisNames) { - if (NOTIFY(ImGui::Selectable(name, flags.emissionAxis == val))) { - flags.emissionAxis = val; - } - } - - ImGui::EndCombo(); - } - HELP(emissionAxis); - - NOTIFY(ImGui::Checkbox("Self Maintaining", &flags.selfMaintaining)); - HELP(selfMaintaining); - - NOTIFY(ImGui::Checkbox("Draw Children First", &flags.drawChildrenFirst)); - HELP(drawChildrenFirst); - - NOTIFY(ImGui::Checkbox("Hide Parent", &flags.hideParent)); - HELP(hideParent); - - NOTIFY(ImGui::Checkbox("Use View Space", &flags.useViewSpace)); - HELP(useViewSpace); - - NOTIFY(ImGui::Checkbox("Has Fixed Polygon ID", &flags.hasFixedPolygonID)); - HELP(hasFixedPolygonID); - - NOTIFY(ImGui::Checkbox("Child Fixed Polygon ID", &flags.childHasFixedPolygonID)); - HELP(childHasFixedPolygonID); - - NOTIFY(ImGui::DragFloat3("Emitter Base Pos", glm::value_ptr(header.emitterBasePos), 0.01f)); - HELP(emitterBasePos); - - NOTIFY(ImGui::SliderFloat("Lifetime", &header.emitterLifeTime, frameTime, 60, "%.4fs", ImGuiSliderFlags_Logarithmic)); - HELP(emitterLifeTime); - - NOTIFY(ImGui::DragInt("Emission Amount", (int*)&header.emissionCount, 1, 0, 20)); - HELP(emissionCount); - - // The in-file value is 8 bits, 255 = 255 frames of delay, 255f / 30fps = 8.5s - NOTIFY(ImGui::SliderFloat("Emission Interval", &misc.emissionInterval, frameTime, 8.5f, "%.4fs")); - HELP(emissionInterval); - - u32 emissions = (u32)glm::ceil(header.emitterLifeTime / misc.emissionInterval); - const u32 maxEmissions = (u32)(header.emitterLifeTime / frameTime); - if (NOTIFY(ImGui::SliderInt("Emissions", (int*)&emissions, 1, maxEmissions))) { - misc.emissionInterval = header.emitterLifeTime / (f32)emissions; - } - HELP(emissions); - - NOTIFY(ImGui::SliderFloat("Start Delay", &header.startDelay, 0, header.emitterLifeTime, "%.2fs")); - HELP(startDelay); - - NOTIFY(ImGui::SliderFloat("Radius", &header.radius, 0.01f, 20.0f, "%.3f", ImGuiSliderFlags_Logarithmic)); - HELP(radius); - - NOTIFY(ImGui::SliderFloat("Length", &header.length, 0.01f, 20.0f, "%.3f", ImGuiSliderFlags_Logarithmic)); - HELP(length); - - NOTIFY(ImGui::DragFloat3("Axis", glm::value_ptr(header.axis), 0.02f)); - HELP(axis); - - ImGui::TreePop(); - } - - open = ImGui::TreeNodeEx("##particleSettings", ImGuiTreeNodeFlags_SpanAvailWidth); - ImGui::SameLine(); - ImGui::SetCursorPosY(ImGui::GetCursorPosY() - 5); - ImGui::SeparatorText("Particle Settings"); - if (open) { - if (ImGui::BeginCombo("Draw Type", getDrawType(flags.drawType))) { - for (const auto [val, name] : detail::g_drawTypeNames) { - if (NOTIFY(ImGui::Selectable(name, flags.drawType == val))) { - flags.drawType = val; - } - } - - ImGui::EndCombo(); - } - HELP(drawType); - - const auto& textures = LOCKED_EDITOR()->getArchive().getTextures(); - if (ImGui::ImageButton("##tex", (ImTextureID)textures[header.misc.textureIndex].glTexture->getHandle(), {32, 32})) { - ImGui::OpenPopup(ImGui::GetID("##texturePicker")); - } - ImGui::SameLine(); - ImGui::Text("Texture"); - HELP(texture); - - NOTIFY(ImGui::Checkbox("Rotate", &flags.hasRotation)); - HELP(hasRotation); - - NOTIFY(ImGui::Checkbox("Random Init Angle", &flags.randomInitAngle)); - HELP(randomInitAngle); - - NOTIFY(ImGui::Checkbox("Follow Emitter", &flags.followEmitter)); - HELP(followEmitter); - - if (ImGui::BeginCombo("Polygon Rotation Axis", getPolygonRotAxis(flags.polygonRotAxis))) { - for (const auto [val, name] : detail::g_polygonRotAxisNames) { - if (NOTIFY(ImGui::Selectable(name, flags.polygonRotAxis == val))) { - flags.polygonRotAxis = val; - } - } - - ImGui::EndCombo(); - } - HELP(polygonRotAxis); - - ImGui::TextUnformatted("Polygon Reference Plane"); - HELP(polygonReferencePlane); - ImGui::Indent(); - NOTIFY(ImGui::RadioButton("XY", &flags.polygonReferencePlane, 0)); - NOTIFY(ImGui::RadioButton("XZ", &flags.polygonReferencePlane, 1)); - ImGui::Unindent(); - - NOTIFY(ImGui::ColorEdit3("Color", glm::value_ptr(header.color))); - HELP(color); - - NOTIFY(ImGui::SliderFloat("Base Scale", &header.baseScale, 0.01f, 10.0f, "%.3f", ImGuiSliderFlags_Logarithmic)); - HELP(baseScale); - - NOTIFY(ImGui::SliderAngle("Init Angle", &header.initAngle, 0)); - HELP(initAngle); - - NOTIFY(ImGui::SliderFloat("Base Alpha", &misc.baseAlpha, 0, 1)); - HELP(baseAlpha); - - NOTIFY(ImGui::SliderFloat("Lifetime", &header.particleLifeTime, frameTime, 60, "%.4fs", ImGuiSliderFlags_Logarithmic)); - HELP(particleLifeTime); - - NOTIFY(ImGui::DragFloat("Aspect Ratio", &header.aspectRatio, 0.05f)); - HELP(aspectRatio); - - NOTIFY(ImGui::DragFloat("Init Velocity Pos Amplifier", &header.initVelPosAmplifier, 0.1f, -10, 10, "%.3f", ImGuiSliderFlags_Logarithmic)); - HELP(initVelPosAmplifier); - - NOTIFY(ImGui::DragFloat("Init Velocity Axis Amplifier", &header.initVelAxisAmplifier, 0.1f, -10, 10, "%.3f", ImGuiSliderFlags_Logarithmic)); - HELP(initVelAxisAmplifier); - - ImGui::TextUnformatted("Rotation Speed"); - HELP(rotationSpeed); - ImGui::Indent(); - NOTIFY(ImGui::SliderAngle("Min", &header.minRotation, 0, header.maxRotation * 180 / glm::pi())); - NOTIFY(ImGui::SliderAngle("Max", &header.maxRotation, header.minRotation * 180 / glm::pi(), 360)); - ImGui::Unindent(); - - ImGui::TextUnformatted("Variance"); - HELP(variance); - ImGui::Indent(); - NOTIFY(ImGui::SliderFloat("Base Scale##variance", &header.variance.baseScale, 0, 1)); - NOTIFY(ImGui::SliderFloat("Particle Lifetime##variance", &header.variance.lifeTime, 0, 1)); - NOTIFY(ImGui::SliderFloat("Init Velocity##variance", &header.variance.initVel, 0, 1)); - ImGui::Unindent(); - - NOTIFY(ImGui::SliderFloat("Air Resistance", &misc.airResistance, 0.75f, 1.25f)); - HELP(airResistance); - - NOTIFY(ImGui::SliderFloat("Loop Time", &misc.loopTime, frameTime, 8.5f, "%.4fs")); - HELP(loopTime); - - u32 loops = (u32)glm::ceil(header.particleLifeTime / misc.loopTime); - const u32 maxLoops = (u32)(header.particleLifeTime / frameTime); - if (NOTIFY(ImGui::SliderInt("Loops", (int*)&loops, 1, maxLoops))) { - misc.loopTime = header.particleLifeTime / (f32)loops; - } - HELP(loops); - - NOTIFY(ImGui::Checkbox("Randomize Looped Anim", &flags.randomizeLoopedAnim)); - HELP(randomizeLoopedAnim); - - NOTIFY(ImGui::SliderFloat("DBB Scale", &misc.dbbScale, -8.0f, 7.0f)); - HELP(dbbScale); - - if (ImGui::BeginCombo("Scale Anim Axis", getScaleAnimDir(misc.scaleAnimDir))) { - for (const auto [val, name] : detail::g_scaleAnimDirNames) { - if (NOTIFY(ImGui::Selectable(name, misc.scaleAnimDir == val))) { - misc.scaleAnimDir = val; - } - } - - ImGui::EndCombo(); - } - HELP(scaleAnimDir); - - ImGui::TextUnformatted("Texture Tiling"); - HELP(textureTiling); - ImGui::Indent(); - int tileCount = 1 << misc.textureTileCountS; - NOTIFY(ImGui::SliderInt("S", &tileCount, 1, 8)); - misc.textureTileCountS = glm::log2(tileCount); - - tileCount = 1 << misc.textureTileCountT; - NOTIFY(ImGui::SliderInt("T", &tileCount, 1, 8)); - misc.textureTileCountT = glm::log2(tileCount); - ImGui::Unindent(); - - NOTIFY(ImGui::Checkbox("DPol Face Emitter", &misc.dpolFaceEmitter)); - HELP(dpolFaceEmitter); - - NOTIFY(ImGui::Checkbox("Flip X", &misc.flipTextureS)); - HELP(flipTextureX); - - NOTIFY(ImGui::Checkbox("Flip Y", &misc.flipTextureT)); - HELP(flipTextureY); - - ImGui::TextUnformatted("Polygon Offset"); - HELP(polygonOffset); - ImGui::Indent(); - NOTIFY(ImGui::SliderFloat("X", &header.polygonX, -2, 2)); - NOTIFY(ImGui::SliderFloat("Y", &header.polygonY, -2, 2)); - ImGui::Unindent(); - - if (ImGui::BeginPopup("##texturePicker")) { - for (int i = 0; i < textures.size(); ++i) { - ImGui::PushID(i); - - const auto& texture = textures[i]; - if (NOTIFY(ImGui::ImageButton("##tex", (ImTextureID)texture.glTexture->getHandle(), {32, 32}))) { - header.misc.textureIndex = i; - ImGui::CloseCurrentPopup(); - } - - if (i % 4 != 3) { - ImGui::SameLine(); - } - - ImGui::PopID(); - } - - ImGui::EndPopup(); - } - - ImGui::TreePop(); - } -} - -void Editor::renderBehaviorEditor(SPLResource& res) { - LOCK_EDITOR(); - std::vector> toRemove; - - if (ImGui::IconButton(ICON_FA_CIRCLE_PLUS, "Add Behavior...", AppColors::Turquoise)) { - ImGui::OpenPopup("##addBehavior"); - } - - if (ImGui::BeginPopup("##addBehavior")) { - if (NOTIFY(ImGui::MenuItem("Gravity", nullptr, false, !res.header.flags.hasGravityBehavior))) { - res.behaviors.push_back(std::make_shared(glm::vec3(0, 0, 0))); - res.header.addBehavior(SPLBehaviorType::Gravity); - } - - if (NOTIFY(ImGui::MenuItem("Random", nullptr, false, !res.header.flags.hasRandomBehavior))) { - res.behaviors.push_back(std::make_shared(glm::vec3(0, 0, 0), 1.0f)); - res.header.addBehavior(SPLBehaviorType::Random); - } - - if (NOTIFY(ImGui::MenuItem("Magnet", nullptr, false, !res.header.flags.hasMagnetBehavior))) { - res.behaviors.push_back(std::make_shared(glm::vec3(0, 0, 0), 0.0f)); - res.header.addBehavior(SPLBehaviorType::Magnet); - } - - if (NOTIFY(ImGui::MenuItem("Spin", nullptr, false, !res.header.flags.hasSpinBehavior))) { - res.behaviors.push_back(std::make_shared(0.0f, SPLSpinAxis::Y)); - res.header.addBehavior(SPLBehaviorType::Spin); - } - - if (NOTIFY(ImGui::MenuItem("Collision Plane", nullptr, false, !res.header.flags.hasCollisionPlaneBehavior))) { - res.behaviors.push_back(std::make_shared(0.0f, 0.0f, SPLCollisionType::Bounce)); - res.header.addBehavior(SPLBehaviorType::CollisionPlane); - } - - if (NOTIFY(ImGui::MenuItem("Convergence", nullptr, false, !res.header.flags.hasConvergenceBehavior))) { - res.behaviors.push_back(std::make_shared(glm::vec3(0, 0, 0), 0.0f)); - res.header.addBehavior(SPLBehaviorType::Convergence); - } - - ImGui::EndPopup(); - } - - for (const auto& bhv : res.behaviors) { - ImGui::PushID(bhv.get()); - - bool context = false; - switch (bhv->type) { - case SPLBehaviorType::Gravity: - context = renderGravityBehaviorEditor(std::static_pointer_cast(bhv)); - break; - case SPLBehaviorType::Random: - context = renderRandomBehaviorEditor(std::static_pointer_cast(bhv)); - break; - case SPLBehaviorType::Magnet: - context = renderMagnetBehaviorEditor(std::static_pointer_cast(bhv)); - break; - case SPLBehaviorType::Spin: - context = renderSpinBehaviorEditor(std::static_pointer_cast(bhv)); - break; - case SPLBehaviorType::CollisionPlane: - context = renderCollisionPlaneBehaviorEditor(std::static_pointer_cast(bhv)); - break; - case SPLBehaviorType::Convergence: - context = renderConvergenceBehaviorEditor(std::static_pointer_cast(bhv)); - break; - } - - if (context) { - if (NOTIFY(ImGui::MenuItem("Delete"))) { - toRemove.push_back(bhv); - } - - ImGui::EndPopup(); - } - - ImGui::PopID(); - } - - for (const auto& r : toRemove) { - std::erase(res.behaviors, r); - res.header.removeBehavior(r->type); - } -} - -bool Editor::renderGravityBehaviorEditor(const std::shared_ptr& gravity) { - LOCK_EDITOR(); - static bool hovered = false; - ImGui::BeginHoverBorderChild("##gravityEditor", hovered, s_hoverAccentColor); - ImGui::TextUnformatted("Gravity"); - - NOTIFY(ImGui::DragFloat3("Magnitude", glm::value_ptr(gravity->magnitude), 0.001f)); - - ImGui::EndChild(); - - hovered = ImGui::IsItemHovered(); - return ImGui::BeginPopupContextItem("##behaviorContext"); -} - -bool Editor::renderRandomBehaviorEditor(const std::shared_ptr& random) { - LOCK_EDITOR(); - static bool hovered = false; - ImGui::BeginHoverBorderChild("##randomEditor", hovered, s_hoverAccentColor); - ImGui::TextUnformatted("Random"); - - NOTIFY(ImGui::DragFloat3("Magnitude", glm::value_ptr(random->magnitude), 0.001f)); - NOTIFY(ImGui::SliderFloat("Apply Interval", &random->applyInterval, 0, 5, "%.3fs", ImGuiSliderFlags_Logarithmic)); - - ImGui::EndChild(); - - hovered = ImGui::IsItemHovered(); - return ImGui::BeginPopupContextItem("##behaviorContext"); -} - -bool Editor::renderMagnetBehaviorEditor(const std::shared_ptr& magnet) { - LOCK_EDITOR(); - static bool hovered = false; - ImGui::BeginHoverBorderChild("##magnetEditor", hovered, s_hoverAccentColor); - ImGui::TextUnformatted("Magnet"); - - NOTIFY(ImGui::DragFloat3("Target", glm::value_ptr(magnet->target), 0.05f, -5.0f, 5.0f)); - NOTIFY(ImGui::SliderFloat("Force", &magnet->force, 0, 5, "%.3f", ImGuiSliderFlags_Logarithmic)); - - ImGui::EndChild(); - - hovered = ImGui::IsItemHovered(); - return ImGui::BeginPopupContextItem("##behaviorContext"); -} - -bool Editor::renderSpinBehaviorEditor(const std::shared_ptr& spin) { - LOCK_EDITOR(); - static bool hovered = false; - ImGui::BeginHoverBorderChild("##spinEditor", hovered, s_hoverAccentColor); - ImGui::TextUnformatted("Spin"); - - NOTIFY(ImGui::SliderAngle("Angle", &spin->angle, -180.0f, 180.0f)); - ImGui::TextUnformatted("Axis"); - ImGui::Indent(); - NOTIFY(ImGui::RadioButton("X", (int*)&spin->axis, 0)); - NOTIFY(ImGui::RadioButton("Y", (int*)&spin->axis, 1)); - NOTIFY(ImGui::RadioButton("Z", (int*)&spin->axis, 2)); - ImGui::Unindent(); - - ImGui::EndChild(); - - hovered = ImGui::IsItemHovered(); - return ImGui::BeginPopupContextItem("##behaviorContext"); -} - -bool Editor::renderCollisionPlaneBehaviorEditor(const std::shared_ptr& collisionPlane) { - LOCK_EDITOR(); - static bool hovered = false; - ImGui::BeginHoverBorderChild("##collisionPlaneEditor", hovered, s_hoverAccentColor); - ImGui::TextUnformatted("Collision Plane"); - - NOTIFY(ImGui::DragFloat("Height", &collisionPlane->y, 0.05f)); - NOTIFY(ImGui::SliderFloat("Elasticity", &collisionPlane->elasticity, 0, 2, "%.3f", ImGuiSliderFlags_Logarithmic)); - ImGui::TextUnformatted("Collision Type"); - ImGui::Indent(); - NOTIFY(ImGui::RadioButton("Kill", (int*)&collisionPlane->collisionType, 0)); - NOTIFY(ImGui::RadioButton("Bounce", (int*)&collisionPlane->collisionType, 1)); - ImGui::Unindent(); - - ImGui::EndChild(); - - hovered = ImGui::IsItemHovered(); - return ImGui::BeginPopupContextItem("##behaviorContext"); -} - -bool Editor::renderConvergenceBehaviorEditor(const std::shared_ptr& convergence) { - LOCK_EDITOR(); - static bool hovered = false; - ImGui::BeginHoverBorderChild("##convergenceEditor", hovered, s_hoverAccentColor); - ImGui::TextUnformatted("Convergence"); - - NOTIFY(ImGui::DragFloat3("Target", glm::value_ptr(convergence->target), 0.05f, -5.0f, 5.0f)); - NOTIFY(ImGui::SliderFloat("Force", &convergence->force, -5, 5, "%.3f", ImGuiSliderFlags_Logarithmic)); - - ImGui::EndChild(); - - hovered = ImGui::IsItemHovered(); - return ImGui::BeginPopupContextItem("##behaviorContext"); -} - -void Editor::renderAnimationEditor(SPLResource& res) { - if (m_activeEditor.expired()) { - return; - } - - LOCK_EDITOR(); - - if (ImGui::IconButton(ICON_FA_CIRCLE_PLUS , "Add Animation...", AppColors::Turquoise)) { - ImGui::OpenPopup("##addAnimation"); - } - - if (ImGui::BeginPopup("##addAnimation")) { - if (NOTIFY(ImGui::MenuItem("Scale", nullptr, false, !res.header.flags.hasScaleAnim))) { - res.addScaleAnim(SPLScaleAnim::createDefault()); - ImGui::CloseCurrentPopup(); - } - - if (NOTIFY(ImGui::MenuItem("Color", nullptr, false, !res.header.flags.hasColorAnim))) { - res.addColorAnim(SPLColorAnim::createDefault()); - ImGui::CloseCurrentPopup(); - } - - if (NOTIFY(ImGui::MenuItem("Alpha", nullptr, false, !res.header.flags.hasAlphaAnim))) { - res.addAlphaAnim(SPLAlphaAnim::createDefault()); - ImGui::CloseCurrentPopup(); - } - - if (NOTIFY(ImGui::MenuItem("Texture", nullptr, false, !res.header.flags.hasTexAnim))) { - res.addTexAnim(SPLTexAnim::createDefault()); - ImGui::CloseCurrentPopup(); - } - - ImGui::EndPopup(); - } - - if (res.scaleAnim) { - if (renderScaleAnimEditor(*res.scaleAnim)) { - res.removeScaleAnim(); - } - } - - if (res.colorAnim) { - if (renderColorAnimEditor(res, *res.colorAnim)) { - res.removeColorAnim(); - } - } - - if (res.alphaAnim) { - if (renderAlphaAnimEditor(*res.alphaAnim)) { - res.removeAlphaAnim(); - } - } - - if (res.texAnim) { - if (renderTexAnimEditor(*res.texAnim)) { - res.removeTexAnim(); - } - } -} - -bool Editor::renderScaleAnimEditor(SPLScaleAnim& res) { - LOCK_EDITOR(); - - static bool hovered = false; - - if (!ImGui::CollapsingHeader("Scale Animation")) { - return false; - } - - ImGui::BeginHoverBorderChild("##scaleAnimEditor", hovered, s_hoverAccentColor); - - NOTIFY(ImGui::SliderFloat("Start Scale", &res.start, 0.01f, 10.0f, "%.3f", ImGuiSliderFlags_Logarithmic)); - NOTIFY(ImGui::SliderFloat("Mid Scale", &res.mid, 0.01f, 10.0f, "%.3f", ImGuiSliderFlags_Logarithmic)); - NOTIFY(ImGui::SliderFloat("End Scale", &res.end, 0.01f, 10.0f, "%.3f", ImGuiSliderFlags_Logarithmic)); - constexpr u8 min = 0; - constexpr u8 max = 255; - NOTIFY(ImGui::SliderScalar("In", ImGuiDataType_U8, &res.curve.in, &min, &max, "%u")); - NOTIFY(ImGui::SliderScalar("Out", ImGuiDataType_U8, &res.curve.out, &min, &max, "%u")); - NOTIFY(ImGui::Checkbox("Loop", &res.flags.loop)); - - res.plot(m_xAnimBuffer, m_yAnimBuffer1); - if (ImPlot::BeginPlot("##scaleAnimPlot", {-1, 0}, ImPlotFlags_CanvasOnly)) { - ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_AutoFit, ImPlotAxisFlags_AutoFit); - ImPlot::PlotLine("Scale", m_xAnimBuffer.data(), m_yAnimBuffer1.data(), m_xAnimBuffer.size()); - ImPlot::EndPlot(); - } - - ImGui::EndChild(); - - hovered = ImGui::IsItemHovered(); - - bool result = false; - if (ImGui::BeginPopupContextItem("##scaleAnimContext")) { - if (ImGui::MenuItem("Delete")) { - ImGui::CloseCurrentPopup(); - result = true; - } - - ImGui::EndPopup(); - } - - return result; -} - -bool Editor::renderColorAnimEditor(const SPLResource& mainRes, SPLColorAnim& res) { - LOCK_EDITOR(); - - static bool hovered = false; - - if (!ImGui::CollapsingHeader("Color Animation")) { - return false; - } - - ImGui::BeginHoverBorderChild("##colorAnimEditor", hovered, s_hoverAccentColor); - - NOTIFY(ImGui::ColorEdit3("Start Color", glm::value_ptr(res.start))); - NOTIFY(ImGui::ColorEdit3("End Color", glm::value_ptr(res.end))); - constexpr u8 min = 0; - constexpr u8 max = 255; - NOTIFY(ImGui::SliderScalar("In", ImGuiDataType_U8, &res.curve.in, &min, &max, "%u")); - NOTIFY(ImGui::SliderScalar("Peak", ImGuiDataType_U8, &res.curve.peak, &min, &max, "%u")); - NOTIFY(ImGui::SliderScalar("Out", ImGuiDataType_U8, &res.curve.out, &min, &max, "%u")); - - NOTIFY(ImGui::Checkbox("Loop", &res.flags.loop)); - NOTIFY(ImGui::Checkbox("Interpolate", &res.flags.interpolate)); - NOTIFY(ImGui::Checkbox("Random Start Color", &res.flags.randomStartColor)); - - const auto drawList = ImGui::GetWindowDrawList(); - const auto startPos = ImGui::GetCursorScreenPos(); - const auto maxWidth = ImGui::GetContentRegionAvail().x; - auto pos = startPos; - - constexpr auto toImColor = [](const glm::vec3& color) { - return IM_COL32( - (u8)(color.r * 255), - (u8)(color.g * 255), - (u8)(color.b * 255), - 255 - ); - }; - - const auto in = res.curve.getIn(); - const auto peak = res.curve.getPeak(); - const auto out = res.curve.getOut(); - - const auto startCol = toImColor(res.start); - const auto peakCol = toImColor(mainRes.header.color); - const auto endCol = toImColor(res.end); - - if (in > 0.0f) { - const auto endPos = ImVec2(pos.x + in * maxWidth, pos.y + 20); - drawList->AddRectFilled(pos, endPos, startCol); - pos.x = endPos.x; - } - - if (res.flags.interpolate) { - const auto endPos = ImVec2(pos.x + (peak - in) * maxWidth, pos.y + 20); - drawList->AddRectFilledMultiColor( - pos, - endPos, - startCol, - peakCol, - peakCol, - startCol - ); - pos.x = endPos.x; - } else { - const auto endPos = ImVec2(pos.x + (peak - in) * maxWidth, pos.y + 20); - drawList->AddRectFilled(pos, endPos, toImColor(mainRes.header.color)); - pos.x = endPos.x; - } - - if (res.flags.interpolate) { - const auto endPos = ImVec2(pos.x + (out - peak) * maxWidth, pos.y + 20); - drawList->AddRectFilledMultiColor( - pos, - endPos, - peakCol, - endCol, - endCol, - peakCol - ); - pos.x = endPos.x; - } else { - const auto endPos = ImVec2(pos.x + (out - peak) * maxWidth, pos.y + 20); - drawList->AddRectFilled(pos, endPos, endCol); - pos.x = endPos.x; - } - - if (out < 1.0f) { - const auto endPos = ImVec2(pos.x + (1.0f - out) * maxWidth, pos.y + 20); - drawList->AddRectFilled(pos, endPos, endCol); - } - - ImGui::Dummy({ maxWidth, 20 }); - - ImGui::EndChild(); - - hovered = ImGui::IsItemHovered(); - - bool result = false; - if (ImGui::BeginPopupContextItem("##colorAnimContext")) { - if (ImGui::MenuItem("Delete")) { - ImGui::CloseCurrentPopup(); - result = true; - } - - ImGui::EndPopup(); - } - - return result; -} - -bool Editor::renderAlphaAnimEditor(SPLAlphaAnim& res) { - LOCK_EDITOR(); - - static bool hovered = false; - - if (!ImGui::CollapsingHeader("Alpha Animation")) { - return false; - } - - ImGui::BeginHoverBorderChild("##alphaAnimEditor", hovered, s_hoverAccentColor); - - NOTIFY(ImGui::SliderFloat("Start Alpha", &res.alpha.start, 0, 1)); - NOTIFY(ImGui::SliderFloat("Mid Alpha", &res.alpha.mid, 0, 1)); - NOTIFY(ImGui::SliderFloat("End Alpha", &res.alpha.end, 0, 1)); - - res.alpha.start = (f32)((u8)(res.alpha.start * 31.0f)) / 31.0f; - res.alpha.mid = (f32)((u8)(res.alpha.mid * 31.0f)) / 31.0f; - res.alpha.end = (f32)((u8)(res.alpha.end * 31.0f)) / 31.0f; - - const auto minAlpha = std::min({ res.alpha.start, res.alpha.mid, res.alpha.end }); - const auto maxAlpha = std::max({ res.alpha.start, res.alpha.mid, res.alpha.end }); - - constexpr u8 min = 0; - constexpr u8 max = 255; - NOTIFY(ImGui::SliderScalar("In", ImGuiDataType_U8, &res.curve.in, &min, &max, "%u")); - NOTIFY(ImGui::SliderScalar("Out", ImGuiDataType_U8, &res.curve.out, &min, &max, "%u")); - NOTIFY(ImGui::SliderFloat("Random Range", &res.flags.randomRange, 0, 1)); - NOTIFY(ImGui::Checkbox("Loop", &res.flags.loop)); - - res.plotWith(m_xAnimBuffer, m_yAnimBuffer1, 0.0f); - - if (ImPlot::BeginPlot("##alphaAnimPlot", {-1, 0}, ImPlotFlags_CanvasOnly | ImPlotFlags_NoInputs)) { - ImPlot::SetupAxisLimits(ImAxis_X1, 0, 1); - ImPlot::SetupAxisLimits(ImAxis_Y1, minAlpha, maxAlpha); - - if (res.flags.randomRange > 0) { - res.plotWith(m_xAnimBuffer, m_yAnimBuffer2, res.flags.randomRange, 255); - ImPlot::PlotLine("Alpha Base", m_xAnimBuffer.data(), m_yAnimBuffer1.data(), m_xAnimBuffer.size()); - ImPlot::PlotShaded( - "Alpha Variance", - m_xAnimBuffer.data(), - m_yAnimBuffer2.data(), - m_yAnimBuffer1.data(), - m_xAnimBuffer.size(), - ImPlotSpec{ ImPlotProp_FillAlpha, 0.25f } - ); - } else { - ImPlot::PlotLine("Alpha", m_xAnimBuffer.data(), m_yAnimBuffer1.data(), m_xAnimBuffer.size()); - } - - ImPlot::EndPlot(); - } - - ImGui::EndChild(); - - hovered = ImGui::IsItemHovered(); - - bool result = false; - if (ImGui::BeginPopupContextItem("##alphaAnimContext")) { - if (ImGui::MenuItem("Delete")) { - ImGui::CloseCurrentPopup(); - result = true; - } - - ImGui::EndPopup(); - } - - return result; -} - -bool Editor::renderTexAnimEditor(SPLTexAnim& res) { - LOCK_EDITOR(); - - static bool hovered = false; - - if (!ImGui::CollapsingHeader("Texture Animation")) { - return false; - } - - ImGui::BeginHoverBorderChild("##texAnimEditor", hovered, s_hoverAccentColor); - - NOTIFY(ImGui::SliderFloat("Step", &res.param.step, 0.01f, 1.0f)); - HELP(texAnimStep); - - NOTIFY(ImGui::Checkbox("Loop", &res.param.loop)); - HELP(texAnimLoop); - - NOTIFY(ImGui::Checkbox("Randomize Start", &res.param.randomizeInit)); - HELP(texAnimRandomizeInit); - - ImGui::SeparatorText("Textures"); - - const auto& textures = LOCKED_EDITOR()->getArchive().getTextures(); - const auto popupId = ImGui::GetID("##texAnimTexturePicker"); - bool buttonContextOpened = false; - static size_t selectedTexture = 0; - - for (int i = 0; i < res.param.textureCount; i++) { - ImGui::PushID(i); - const auto& texture = textures[res.textures[i]]; - if (ImGui::ImageButton("##tex", texture.glTexture->getHandle(), { 32, 32 })) { - ImGui::OpenPopup(popupId); - selectedTexture = i; - } - if (ImGui::BeginPopupContextItem("##texContext")) { - buttonContextOpened = true; - ImGui::BeginDisabled(res.param.textureCount <= 1); - - if (NOTIFY(ImGui::MenuItem("Delete"))) { - res.removeTexture(i); - ImGui::CloseCurrentPopup(); - } - - ImGui::EndDisabled(); - ImGui::EndPopup(); - } - ImGui::PopID(); - } - - if (res.param.textureCount < SPLTexAnim::MAX_TEXTURES) { - if (ImGui::Button(ICON_FA_PLUS, { 32, 32 })) { - res.addTexture(); - } - } - - if (ImGui::BeginPopup("##texAnimTexturePicker")) { - for (int i = 0; i < textures.size(); ++i) { - ImGui::PushID(i); - const auto& texture = textures[i]; - if (NOTIFY(ImGui::ImageButton("##tex", (ImTextureID)texture.glTexture->getHandle(), { 32, 32 }))) { - res.textures[selectedTexture] = i; - ImGui::CloseCurrentPopup(); - } - - if (i % 4 != 3) { - ImGui::SameLine(); - } - ImGui::PopID(); - } - ImGui::EndPopup(); - } - - ImGui::EndChild(); - - hovered = ImGui::IsItemHovered(); - bool result = false; - - if (!buttonContextOpened && ImGui::BeginPopupContextItem("##texAnimContext")) { - if (ImGui::MenuItem("Delete")) { - ImGui::CloseCurrentPopup(); - result = true; - } - - ImGui::EndPopup(); - } - - return result; -} - -void Editor::renderChildrenEditor(SPLResource& res) { - if (m_activeEditor.expired()) { - return; - } - - LOCK_EDITOR(); - - if (!res.childResource) { - ImGui::TextUnformatted("This resource does not have an associated child resource."); - if (ImGui::IconButton(ICON_FA_PLUS, "Add Child Resource", AppColors::Turquoise)) { - res.header.flags.hasChildResource = true; - res.childResource = SPLChildResource{ - .flags = { - .usesBehaviors = false, - .hasScaleAnim = false, - .hasAlphaAnim = false, - .rotationType = SPLChildRotationType::None, - .followEmitter = false, - .useChildColor = false, - .drawType = SPLDrawType::Billboard, - .polygonRotAxis = SPLPolygonRotAxis::Y, - .polygonReferencePlane = 0 - }, - .randomInitVelMag = 0.0f, - .endScale = 1.0f, - .lifeTime = 1.0f / SPLArchive::SPL_FRAMES_PER_SECOND, - .velocityRatio = 1.0f, - .scaleRatio = 1.0f, - .color = {}, - .misc = { - .emissionCount = 0, - .emissionDelay = 0, - .emissionInterval = 1.0f / SPLArchive::SPL_FRAMES_PER_SECOND, - .texture = 0, - .textureTileCountS = 1, - .textureTileCountT = 1, - .flipTextureS = false, - .flipTextureT = false, - .dpolFaceEmitter = false - } - }; - - PUSH_HISTORY(); - } - - return; - } - - auto& child = res.childResource.value(); - constexpr f32 frameTime = 1.0f / (f32)SPLArchive::SPL_FRAMES_PER_SECOND; - - if (NOTIFY(ImGui::IconButton(ICON_FA_XMARK, "Remove Child Resource", AppColors::LightRed))) { - res.header.flags.hasChildResource = false; - res.childResource.reset(); - PUSH_HISTORY(); - return; - } - - bool open = ImGui::TreeNodeEx("##parentSettings", ImGuiTreeNodeFlags_SpanAvailWidth); - ImGui::SameLine(); - ImGui::SetCursorPosY(ImGui::GetCursorPosY() - 5); - ImGui::SeparatorText("Parent Settings"); - if (open) { - NOTIFY(ImGui::DragInt("Emission Amount", (int*)&child.misc.emissionCount, 1, 0, 20)); - HELP(emissionCount); - - NOTIFY(ImGui::SliderFloat("Emission Delay", &child.misc.emissionDelay, 0, 1)); - HELP(childEmissionDelay); - - NOTIFY(ImGui::SliderFloat("Emission Interval", &child.misc.emissionInterval, frameTime, 8.5f, "%.4fs")); - HELP(childEmissionInterval); - - u32 emissions = (u32)glm::ceil(res.header.particleLifeTime / child.misc.emissionInterval); - const u32 maxEmissions = (u32)(res.header.particleLifeTime / frameTime); - if (NOTIFY(ImGui::SliderInt("Emissions", (int*)&emissions, 1, maxEmissions))) { - child.misc.emissionInterval = res.header.particleLifeTime / (f32)emissions; - } - HELP(childEmissions); - - ImGui::TreePop(); - } - - open = ImGui::TreeNodeEx("##childSettings", ImGuiTreeNodeFlags_SpanAvailWidth); - ImGui::SameLine(); - ImGui::SetCursorPosY(ImGui::GetCursorPosY() - 5); - ImGui::SeparatorText("Child Settings"); - if (open) { - auto& flags = child.flags; - auto& misc = child.misc; - - if (ImGui::BeginCombo("Draw Type", getDrawType(flags.drawType))) { - for (const auto [val, name] : detail::g_drawTypeNames) { - if (NOTIFY(ImGui::Selectable(name, flags.drawType == val))) { - flags.drawType = val; - } - } - - ImGui::EndCombo(); - } - HELP(drawType); - - const auto& textures = LOCKED_EDITOR()->getArchive().getTextures(); - if (ImGui::ImageButton("##tex", (ImTextureID)textures[misc.texture].glTexture->getHandle(), {32, 32})) { - ImGui::OpenPopup("##childTexturePicker"); - } - ImGui::SameLine(); - ImGui::Text("Texture"); - HELP(childTexture); - - if (ImGui::BeginCombo("Child Rotation", getChildRotType(flags.rotationType))) { - for (const auto [val, name] : detail::g_childRotTypeNames) { - if (NOTIFY(ImGui::Selectable(name, flags.rotationType == val))) { - flags.rotationType = val; - } - } - - ImGui::EndCombo(); - } - HELP(childRotation); - - ImGui::BeginDisabled((u8)flags.drawType < (u8)SPLDrawType::Polygon); - - if (ImGui::BeginCombo("Polygon Rotation Axis", getPolygonRotAxis(flags.polygonRotAxis))) { - for (const auto [val, name] : detail::g_polygonRotAxisNames) { - if (NOTIFY(ImGui::Selectable(name, flags.polygonRotAxis == val))) { - flags.polygonRotAxis = val; - } - } - - ImGui::EndCombo(); - } - HELP(polygonRotAxis); - - ImGui::TextUnformatted("Polygon Reference Plane"); - HELP(polygonReferencePlane); - ImGui::Indent(); - NOTIFY(ImGui::RadioButton("XY", &flags.polygonReferencePlane, 0)); - NOTIFY(ImGui::RadioButton("XZ", &flags.polygonReferencePlane, 1)); - ImGui::Unindent(); - - ImGui::EndDisabled(); - - NOTIFY(ImGui::Checkbox("Uses Behaviors", &flags.usesBehaviors)); - HELP(usesBehaviors); - - NOTIFY(ImGui::Checkbox("Follow Emitter", &flags.followEmitter)); - HELP(followEmitter); - - NOTIFY(ImGui::SliderFloat("Lifetime", &child.lifeTime, frameTime, 60, "%.4fs", ImGuiSliderFlags_Logarithmic)); - HELP(particleLifeTime); - - NOTIFY(ImGui::SliderFloat("Initial Velocity Random", &child.randomInitVelMag, -3, 3, "%.3f", ImGuiSliderFlags_Logarithmic)); - HELP(randomInitVelMag); - - NOTIFY(ImGui::SliderFloat("Velocity Ratio", &child.velocityRatio, 0, 1)); - HELP(velocityRatio); - - NOTIFY(ImGui::SliderFloat("Scale Ratio", &child.scaleRatio, 1 / 64.0f, 255 / 64.0f)); - HELP(scaleRatio); - - NOTIFY(ImGui::ColorEdit3("Color", glm::value_ptr(child.color))); - HELP(color); - - NOTIFY(ImGui::Checkbox("Use Color", &flags.useChildColor)); - HELP(useChildColor); - - ImGui::TextUnformatted("Texture Tiling"); - HELP(textureTiling); - ImGui::Indent(); - int tileCount = 1 << misc.textureTileCountS; - NOTIFY(ImGui::SliderInt("S", &tileCount, 1, 8)); - misc.textureTileCountS = glm::log2(tileCount); - - tileCount = 1 << misc.textureTileCountT; - NOTIFY(ImGui::SliderInt("T", &tileCount, 1, 8)); - misc.textureTileCountT = glm::log2(tileCount); - ImGui::Unindent(); - - NOTIFY(ImGui::Checkbox("DPol Face Emitter", &misc.dpolFaceEmitter)); - HELP(dpolFaceEmitter); - - NOTIFY(ImGui::Checkbox("Flip X", &misc.flipTextureS)); - HELP(flipTextureX); - - NOTIFY(ImGui::Checkbox("Flip Y", &misc.flipTextureT)); - HELP(flipTextureY); - - NOTIFY(ImGui::Checkbox("Scale Animation", &flags.hasScaleAnim)); - HELP(hasScaleAnim); - if (flags.hasScaleAnim) { - NOTIFY(ImGui::SliderFloat("End Scale", &child.endScale, 0, 5, "%.3f", ImGuiSliderFlags_Logarithmic)); - HELP(endScale); - } - - NOTIFY(ImGui::Checkbox("Fade Out", &flags.hasAlphaAnim)); - HELP(hasAlphaAnim); - - if (ImGui::BeginPopup("##childTexturePicker")) { - for (int i = 0; i < textures.size(); ++i) { - ImGui::PushID(i); - const auto& texture = textures[i]; - if (NOTIFY(ImGui::ImageButton("##tex", (ImTextureID)texture.glTexture->getHandle(), {32, 32}))) { - child.misc.texture = i; - ImGui::CloseCurrentPopup(); - } - - if ((i + 1) % 4 != 0) { - ImGui::SameLine(); - } - - ImGui::PopID(); - } - - ImGui::EndPopup(); - } - - ImGui::TreePop(); - } -} - -void Editor::helpPopup(std::string_view text) const { - ImGui::SameLine(); - ImGui::TextDisabled("(?)"); - - if (ImGui::BeginItemTooltip()) { - ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); - ImGui::TextUnformatted(text.data(), text.data() + text.size()); - ImGui::PopTextWrapPos(); - ImGui::EndTooltip(); - } -} - -void Editor::renderDebugShapes(const std::shared_ptr& editor, std::vector& renderers) { - const auto& archive = editor->getArchive(); - const auto& resources = archive.getResources(); - - renderers.push_back(m_debugRenderer.get()); - - // Render edited emitter and collision plane if any - if (m_selectedResources[editor->getUniqueID()] != -1) { - const auto& resource = resources[m_selectedResources[editor->getUniqueID()]]; - - if (m_settings.displayEditedEmitter) { - glm::vec3 axis; - switch (resource.header.flags.emissionAxis) { - case SPLEmissionAxis::X: - axis = { 1, 0, 0 }; - break; - case SPLEmissionAxis::Y: - axis = { 0, 1, 0 }; - break; - case SPLEmissionAxis::Z: - axis = { 0, 0, 1 }; - break; - case SPLEmissionAxis::Emitter: - axis = glm::normalize(resource.header.axis); - break; - } - - const glm::vec4& color = m_settings.editedEmitterColor; - switch (resource.header.flags.emissionType) { - case SPLEmissionType::Point: - m_debugRenderer->addBox(resource.header.emitterBasePos, { 0.2f, 0.2f, 0.2f }, color); - break; - case SPLEmissionType::SphereSurface: [[fallthrough]]; - case SPLEmissionType::Sphere: - m_debugRenderer->addSphere(resource.header.emitterBasePos, resource.header.radius, color); - break; - case SPLEmissionType::CircleBorder: [[fallthrough]]; - case SPLEmissionType::CircleBorderUniform: [[fallthrough]]; - case SPLEmissionType::Circle: - m_debugRenderer->addCircle(resource.header.emitterBasePos, axis, resource.header.radius, color); - break; - case SPLEmissionType::CylinderSurface: [[fallthrough]]; - case SPLEmissionType::Cylinder: - m_debugRenderer->addCylinder( - resource.header.emitterBasePos, - axis, - resource.header.length, - resource.header.radius, - color - ); - break; - case SPLEmissionType::HemisphereSurface: - case SPLEmissionType::Hemisphere: - m_debugRenderer->addHemisphere(resource.header.emitterBasePos, axis, resource.header.radius, color); - break; - } - } - - for (const auto& bhv : resource.behaviors) { - if (bhv->type == SPLBehaviorType::CollisionPlane) { - const auto colPlane = std::dynamic_pointer_cast(bhv); - const glm::vec4 color = colPlane->collisionType == SPLCollisionType::Kill - ? m_settings.collisionPlaneKillColor - : m_settings.collisionPlaneBounceColor; - m_collisionGridRenderer->setColor(color); - m_collisionGridRenderer->setHeight(colPlane->y); - renderers.push_back(m_collisionGridRenderer.get()); - } - } - } - - const auto emitters = editor->getParticleSystem().getEmitters(); - if (emitters.empty() || !m_settings.displayActiveEmitters) { - return; - } - - // Render emitters - for (const auto& emitter : editor->getParticleSystem().getEmitters()) { - const auto resource = emitter->getResource(); - glm::vec3 axis; - - switch (resource->header.flags.emissionAxis) { - case SPLEmissionAxis::X: - axis = { 1, 0, 0 }; - break; - case SPLEmissionAxis::Y: - axis = { 0, 1, 0 }; - break; - case SPLEmissionAxis::Z: - axis = { 0, 0, 1 }; - break; - case SPLEmissionAxis::Emitter: - axis = emitter->getAxis(); - break; - } - - const glm::vec4& color = m_settings.activeEmitterColor; - switch (resource->header.flags.emissionType) { - case SPLEmissionType::Point: - m_debugRenderer->addBox(emitter->getPosition(), { 0.2f, 0.2f, 0.2f }, color); - break; - case SPLEmissionType::SphereSurface: [[fallthrough]]; - case SPLEmissionType::Sphere: - m_debugRenderer->addSphere(emitter->getPosition(), resource->header.radius, color); - break; - case SPLEmissionType::CircleBorder: [[fallthrough]]; - case SPLEmissionType::CircleBorderUniform: [[fallthrough]]; - case SPLEmissionType::Circle: - m_debugRenderer->addCircle(emitter->getPosition(), axis, resource->header.radius, color); - break; - case SPLEmissionType::CylinderSurface: [[fallthrough]]; - case SPLEmissionType::Cylinder: - m_debugRenderer->addCylinder( - emitter->getPosition(), - axis, - resource->header.length, - resource->header.radius, - color - ); - break; - case SPLEmissionType::HemisphereSurface: [[fallthrough]]; - case SPLEmissionType::Hemisphere: - m_debugRenderer->addHemisphere(emitter->getPosition(), axis, resource->header.radius, color); - break; - } - } -} - -void Editor::updateMaxParticles() { - const auto editors = g_projectManager->getOpenEditors(); - for (const auto& editor : editors) { - editor->setMaxParticles(m_settings.maxParticles); - } -} - -void Editor::openTempTexture(const std::filesystem::path& path, size_t destIndex) { - constexpr auto isPowerOf2 = [](s32 value) { - return (value & (value - 1)) == 0; - }; - - if (destIndex != -1 && destIndex >= g_projectManager->getActiveEditor()->getArchive().getTextures().size()) { - spdlog::error("Invalid destination index for temp texture: {}", destIndex); - return; - } - - std::ifstream file(path, std::ios::binary | std::ios::in); - if (!file) { - spdlog::error("Failed to open file: {}", path.string()); - return; - } - - const std::vector fileData{std::istreambuf_iterator(file), std::istreambuf_iterator()}; - if (fileData.empty()) { - spdlog::error("File is empty: {}", path.string()); - return; - } - - spng_ctx* ctx = spng_ctx_new(0); - if (!ctx) { - spdlog::error("Failed to create SPNG context"); - return; - } - - spng_set_crc_action(ctx, SPNG_CRC_USE, SPNG_CRC_USE); - - constexpr auto limit = 1024 * 1024 * 64U; // 64 MB - spng_set_chunk_limits(ctx, limit, limit); - - int ret; - if ((ret = spng_set_png_buffer(ctx, fileData.data(), fileData.size())) != 0) { - spdlog::error("Failed to set PNG buffer: {}", spng_strerror(ret)); - spng_ctx_free(ctx); - return; - } - - spng_ihdr ihdr; - if ((ret = spng_get_ihdr(ctx, &ihdr)) != 0) { - spdlog::error("Failed to get PNG header: {}", spng_strerror(ret)); - spng_ctx_free(ctx); - return; - } - - const auto tempTex = new TempTexture; - m_tempTexture = tempTex; - - tempTex->path = path.string(); - tempTex->data = stbi_load_from_memory(fileData.data(), (int)fileData.size(), &tempTex->width, &tempTex->height, &tempTex->channels, 4); - if (!tempTex->data) { - delete m_tempTexture; - m_tempTexture = nullptr; - spng_ctx_free(ctx); - return; - } - - tempTex->texture = std::make_unique(tempTex->width, tempTex->height); - tempTex->quantized = new u8[(size_t)tempTex->width * tempTex->height * 4]; - - // If we load an indexed PNG, we can go a simple path and don't need to quantize it. - if (ihdr.color_type == SPNG_COLOR_TYPE_INDEXED && ihdr.bit_depth <= 8) { - spng_plte palette; - spng_get_plte(ctx, &palette); - - TextureFormat format; - if (palette.n_entries <= 4) { - format = TextureFormat::Palette4; - } else if (palette.n_entries <= 16) { - format = TextureFormat::Palette16; - } else if (palette.n_entries <= 256) { - format = TextureFormat::Palette256; - } else { - spdlog::error("Unsupported indexed PNG bit depth: {}", ihdr.bit_depth); - format = TextureFormat::Palette256; - } - - std::memcpy(tempTex->quantized, tempTex->data, (size_t)tempTex->width * tempTex->height * 4); - tempTex->preference = TextureConversionPreference::ColorDepth; - tempTex->spec = { - .color0Transparent = true, - .requiresColorCompression = false, - .requiresAlphaCompression = false, - .format = format, - .uniqueColors = {}, - .uniqueAlphas = {}, - .flags = TextureAttributes::None, - }; - tempTex->suggestedFormat = format; - tempTex->suggestedFormatUncompressed = true; - } else { - tempTex->preference = TextureConversionPreference::ColorDepth; - tempTex->spec = SPLTexture::suggestSpecification(tempTex->width, tempTex->height, tempTex->channels, tempTex->data, tempTex->preference); - tempTex->suggestedFormat = tempTex->spec.format; - - if (tempTex->spec.requiresColorCompression || tempTex->spec.requiresAlphaCompression) { - tempTex->suggestedFormatUncompressed = false; - quantizeTexture(tempTex->data, tempTex->width, tempTex->height, tempTex->spec, tempTex->quantized); - } else { - tempTex->suggestedFormatUncompressed = true; - std::memcpy(tempTex->quantized, tempTex->data, (size_t)tempTex->width * tempTex->height * 4); - } - } - - tempTex->texture->update(tempTex->quantized); - - tempTex->isValidSize = true; - - if (tempTex->width > 1024 || tempTex->height > 1024) { - tempTex->isValidSize = false; - } - - if (!isPowerOf2(tempTex->width) || !isPowerOf2(tempTex->height)) { - tempTex->isValidSize = false; - } - - tempTex->destIndex = destIndex; - - spng_ctx_free(ctx); -} - -void Editor::discardTempTexture() { - m_discardTempTexture = true; - spdlog::info("Discarding temp texture"); -} - -void Editor::destroyTempTexture() { - stbi_image_free(m_tempTexture->data); - delete[] m_tempTexture->quantized; - delete m_tempTexture; - m_tempTexture = nullptr; - m_discardTempTexture = false; -} - -void Editor::importTempTexture() { - if (!m_tempTexture) { - return; - } - - auto& activeEditor = g_projectManager->getActiveEditor(); - auto& archive = activeEditor->getArchive(); - auto& textures = archive.getTextures(); - auto& texture = m_tempTexture->destIndex != -1 ? textures[m_tempTexture->destIndex] : textures.emplace_back(); - texture.glTexture = std::move(m_tempTexture->texture); - texture.width = m_tempTexture->width; - texture.height = m_tempTexture->height; - texture.param = { - .format = m_tempTexture->spec.format, - .s = 1, - .t = 1, - .repeat = TextureRepeat::None, - .flip = TextureFlip::None, - .palColor0Transparent = false, - .useSharedTexture = false, - .sharedTexID = 0xFF - }; - - // -3 because s/t are enums starting at GX_TEXSIZE_8 = 0 - texture.param.s = (u8)glm::log2(texture.width) - 3; - texture.param.t = (u8)glm::log2(texture.height) - 3; - - auto& textureData = archive.getTextureData().emplace_back(); - auto& paletteData = archive.getPaletteData().emplace_back(); - - palettizeTexture( - m_tempTexture->quantized, - m_tempTexture->width, - m_tempTexture->height, - m_tempTexture->spec, - textureData, - paletteData - ); - - texture.textureData = textureData; - texture.paletteData = paletteData; - - switch (texture.param.format) { - case TextureFormat::Palette4: [[fallthrough]]; - case TextureFormat::Palette16: [[fallthrough]]; - case TextureFormat::Palette256: - // Check if the first color in the palette is transparent - texture.param.palColor0Transparent = ((GXRgba*)paletteData.data())->a == 0; - break; - default: - break; - } - - discardTempTexture(); - - activeEditor->getParticleSystem().getRenderer().setTextures(textures); -} - -void Editor::ensureValidSelection(const std::shared_ptr& editor) { - const auto id = editor->getUniqueID(); - const auto selectedResource = m_selectedResources[id]; - - if (selectedResource != -1 && selectedResource >= editor->getArchive().getResourceCount()) { - m_selectedResources[id] = -1; - editor->notifyResourceChanged(-1); - } -} - -bool Editor::palettizeTexture(const u8* data, s32 width, s32 height, const TextureImportSpecification& spec, std::vector& outData, std::vector& outPalette) { - return SPLTexture::convertFromRGBA8888(data, width, height, spec.format, outData, outPalette); -} - -void Editor::quantizeTexture(const u8* data, s32 width, s32 height, const TextureImportSpecification& spec, u8* out) { - liq_attr* attr = liq_attr_create(); - liq_set_max_colors(attr, spec.getMaxColors()); - - liq_image* image = liq_image_create_rgba(attr, data, width, height, 0); - - liq_error err; - liq_result* result = nullptr; - if ((err = liq_image_quantize(image, attr, &result)) != LIQ_OK) { - spdlog::error("Failed to quantize image: {}", (int)err); - return; - } - - std::vector quantized(width * height); - if ((err = liq_write_remapped_image(result, image, quantized.data(), quantized.size())) != LIQ_OK) { - spdlog::error("Failed to write quantized image: {}", (int)err); - return; - } - - const auto palette = liq_get_palette(result); - if (palette->count > spec.getMaxColors()) { - spdlog::error("Too many colors in resulting palette"); - return; - } - - auto palcopy = *palette; - std::span colorspan(palcopy.entries, palcopy.count); - - // Further quantize alpha values if necessary - if (spec.requiresAlphaCompression) { - const auto maxAlphas = spec.getMaxAlphas(); - const auto [min, max] = spec.getAlphaRange(); - - const auto transparent = std::ranges::find_if(colorspan, [](liq_color color) { return color.a == 0; }); - - switch (spec.format) { - case TextureFormat::None: break; - case TextureFormat::A3I5: - case TextureFormat::A5I3: - std::ranges::for_each(colorspan, [min, max](auto& color) { - const auto mapped = (u8)(((float)color.a / 255.0f) * (max - min)) + min; - color.a = (u8)(((float)(mapped - min) / (max - min)) * 255.0f); - }); - break; - - case TextureFormat::Palette4: - case TextureFormat::Palette16: - case TextureFormat::Palette256: - case TextureFormat::Direct: - if (spec.needsAlpha() || spec.format == TextureFormat::Direct) { - std::ranges::for_each(colorspan, [](auto& color) { - if (color.a < 128) { - color.a = 0; - } else { - color.a = 255; - } - }); - } - break; - default: break; - } - } - - const auto colors = (u32*)palcopy.entries; - std::span remapped((u32*)out, width * height); - for (auto [index, pixel] : std::views::zip(quantized, remapped)) { - pixel = colors[index]; - } - - liq_result_destroy(result); - liq_image_destroy(image); - liq_attr_destroy(attr); } diff --git a/src/editor/editor.h b/src/editor/editor.h index d51bba5..869e17a 100644 --- a/src/editor/editor.h +++ b/src/editor/editor.h @@ -2,7 +2,6 @@ #include "spl/spl_resource.h" #include "editor_instance.h" #include "editor_settings.h" -#include "debug_renderer.h" #include "grid_renderer.h" #include @@ -14,12 +13,6 @@ #include #include -enum class EmitterSpawnType { - SingleShot, - Looped, - Interval -}; - class Editor { public: Editor(); @@ -29,11 +22,9 @@ class Editor { void renderParticles(); void renderMenu(std::string_view name); void renderToolbar(float itemHeight); - void renderStats(); void openPicker(); void openEditor(); void openTextureManager(); - void updateParticles(float deltaTime); void openSettings(); void openTutorial(); @@ -47,8 +38,6 @@ class Editor { void handleEvent(const SDL_Event& event); - void selectResource(u64 editorID, size_t resourceIndex); - void save(); void saveAs(const std::filesystem::path& path); @@ -64,76 +53,35 @@ class Editor { void pushClipboard(const std::string& source, const SPLTexture& tex); void pushClipboard(const std::string& source, const SPLResource& res, const SPLTexture& tex); + std::queue& clipboardHistory() { + return m_clipboardHistory; + } + const EditorSettings& getSettings() const { return m_settings; } + float getTimeScale() const { + return m_timeScale; + } + + float& timeScale() { + return m_timeScale; + } + + bool& pickerOpen() { return m_pickerOpen; } + bool& textureManagerOpen() { return m_textureManagerOpen; } + bool& resourceEditorOpen() { return m_editorOpen; } + private: - void renderResourcePicker(); - void renderTextureManager(); - void renderResourceEditor(); void renderSettings(); void renderTutorial(); void updateRenderSettings(bool swapRenderer = false); - void renderHeaderEditor(SPLResourceHeader& header) const; - void renderBehaviorEditor(SPLResource& res); - - bool renderGravityBehaviorEditor(const std::shared_ptr& gravity); - bool renderRandomBehaviorEditor(const std::shared_ptr& random); - bool renderMagnetBehaviorEditor(const std::shared_ptr& magnet); - bool renderSpinBehaviorEditor(const std::shared_ptr& spin); - bool renderCollisionPlaneBehaviorEditor(const std::shared_ptr& collisionPlane); - bool renderConvergenceBehaviorEditor(const std::shared_ptr& convergence); - - void renderAnimationEditor(SPLResource& res); - bool renderScaleAnimEditor(SPLScaleAnim& res); - bool renderColorAnimEditor(const SPLResource& mainRes, SPLColorAnim& res); - bool renderAlphaAnimEditor(SPLAlphaAnim& res); - bool renderTexAnimEditor(SPLTexAnim& res); - void renderChildrenEditor(SPLResource& res); - - void helpPopup(std::string_view text) const; - - void renderDebugShapes(const std::shared_ptr& editor, std::vector& renderers); - void updateMaxParticles(); - void openTempTexture(const std::filesystem::path& path, size_t destIndex = -1); - void discardTempTexture(); - void destroyTempTexture(); - void importTempTexture(); - - void ensureValidSelection(const std::shared_ptr& editor); - - static bool palettizeTexture( - const u8* data, - s32 width, - s32 height, - const TextureImportSpecification& spec, - std::vector& outData, - std::vector& outPalette - ); - static void quantizeTexture(const u8* data, s32 width, s32 height, const TextureImportSpecification& spec, u8* out); - private: - struct TempTexture { - std::string path; - u8* data; - u8* quantized; - s32 width; - s32 height; - s32 channels; - TextureFormat suggestedFormat; - bool suggestedFormatUncompressed; - TextureImportSpecification spec; - TextureConversionPreference preference; - std::unique_ptr texture; - bool isValidSize; - size_t destIndex = -1; - }; - bool m_pickerOpen = true; bool m_textureManagerOpen = true; bool m_editorOpen = true; @@ -148,45 +96,10 @@ class Editor { EditorSettings m_settingsBackup; EditorSettings m_settingsDefault; - EmitterSpawnType m_emitterSpawnType = EmitterSpawnType::SingleShot; - float m_emitterInterval = 1.0f; // seconds - - // Stats panel feedback animation - u32 m_emitterFlashColor = 0; - size_t m_lastEmitterCount = 0; - - // Tracks last selection per editor to animate selection changes - std::unordered_map m_lastPickerSelection; - - static inline const u32 s_hoverAccentColor = ImGui::ColorConvertFloat4ToU32({ 0.7f, 0.3f, 0.7f, 1.0f }); static constexpr glm::ivec2 s_gridDimensions = { 20, 20 }; static constexpr glm::vec2 s_gridSpacing = { 1.0f, 1.0f }; - std::array m_xAnimBuffer; - std::array m_yAnimBuffer1; - std::array m_yAnimBuffer2; - - TempTexture* m_tempTexture = nullptr; - float m_tempTextureScale = 1.0f; - bool m_discardTempTexture = false; // Whether the temp texture should be discarded in the next frame - - size_t m_selectedTexture = -1; - bool m_deleteSelectedTexture = false; - - std::unordered_map m_selectedResources; - std::weak_ptr m_activeEditor; std::shared_ptr m_gridRenderer; - std::unique_ptr m_debugRenderer; - std::shared_ptr m_collisionGridRenderer; std::queue m_clipboardHistory; - - struct EmitterSpawnTask { - u64 resourceIndex; - std::chrono::time_point time; - std::chrono::duration interval; - u64 editorID; - }; - - std::vector m_emitterTasks; }; diff --git a/src/editor/editor_instance.cpp b/src/editor/editor_instance.cpp index d3ca813..2d4f07f 100644 --- a/src/editor/editor_instance.cpp +++ b/src/editor/editor_instance.cpp @@ -1,15 +1,48 @@ #include "editor_instance.h" #include "application.h" +#include "application_colors.h" #include "project_manager.h" #include "spl/spl_random.h" +#include "spl/enum_names.h" #include "gfx/gl_util.h" +#include "imgui/extensions.h" +#include "help_messages.h" + +#include +#include +#include #include #include -#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include + +#define NOTIFY(action) valueChanged(action) +#define HELP(name) helpPopup(help::name) + + +namespace { + +constexpr std::array s_emitterSpawnTypes = { + "Single Shot", + "Looped", + "Interval" +}; + +const u32 s_hoverAccentColor = ImGui::ColorConvertFloat4ToU32({ 0.7f, 0.3f, 0.7f, 1.0f }); +} EditorInstance::EditorInstance(const std::filesystem::path& path, bool isTemp, bool isRecovered) : m_path(path), m_archive(path) @@ -20,6 +53,7 @@ EditorInstance::EditorInstance(const std::filesystem::path& path, bool isTemp, b m_modified = true; } + createRenderers(); m_lastBackupTime = Clock::now(); m_uniqueID = SPLRandom::nextU64(); m_updateProj = true; @@ -43,6 +77,7 @@ EditorInstance::EditorInstance(std::string name, size_t narcIndex, std::spangetEditor()->getSettings().maxParticles, m_archive.getTextures()) , m_camera(glm::radians(45.0f), {800, 800}, 1.0f, 500.0f), m_isTemp(isTemp) { + createRenderers(); m_uniqueID = SPLRandom::nextU64(); m_updateProj = true; notifyResourceChanged(0); @@ -66,8 +101,8 @@ EditorInstance::EditorInstance(bool isTemp) m_uniqueID = SPLRandom::nextU64(); m_updateProj = true; - g_application->getEditor()->selectResource(m_uniqueID, -1); - notifyResourceChanged(-1); + createRenderers(); + notifyResourceChanged(INVALID_RESOURCE); // Choose particle renderer backend if (g_application->getEditor()->getSettings().useLegacyParticleRenderer) { @@ -182,9 +217,836 @@ void EditorInstance::renderParticles(const std::vector& renderers) { m_viewport.unbind(); } -void EditorInstance::updateParticles(float deltaTime) { +void EditorInstance::update(float deltaTime) { + const float timeScale = g_application->getEditor()->getTimeScale(); + + const auto now = Clock::now(); + for (auto& task : m_emitterTasks) { + if (timeScale * (now - task.time) >= task.interval) { + m_particleSystem.addEmitter(m_archive.getResources()[task.resourceIndex], false); + task.time = now; + } + } + m_camera.update(); - m_particleSystem.update(deltaTime); + m_particleSystem.update(deltaTime * timeScale); +} + +void EditorInstance::playEmitter(EmitterSpawnType spawnType) { + const auto resourceIndex = m_selectedResource; + if (resourceIndex >= m_archive.getResources().size()) { + spdlog::warn("Invalid resource index: {}", resourceIndex); + return; + } + + m_particleSystem.addEmitter(m_archive.getResource(resourceIndex), spawnType == EmitterSpawnType::Looped); + + if (spawnType == EmitterSpawnType::Interval) { + m_emitterTasks.emplace_back(resourceIndex, Clock::now(), std::chrono::duration(m_emitterInterval)); + } +} + +void EditorInstance::playAllEmitters(EmitterSpawnType spawnType) { + for (size_t i = 0; i < m_archive.getResources().size(); ++i) { + m_particleSystem.addEmitter(m_archive.getResource(i), spawnType == EmitterSpawnType::Looped); + + if (spawnType == EmitterSpawnType::Interval) { + m_emitterTasks.emplace_back(i, Clock::now(), std::chrono::duration(m_emitterInterval)); + } + } +} + +void EditorInstance::killEmitters() { + m_particleSystem.killAllEmitters(); + m_emitterTasks.clear(); +} + +void EditorInstance::resetCamera() { + m_camera.reset(); +} + +void EditorInstance::renderStats() { + const auto& system = m_particleSystem; + + const auto activeParticles = system.getParticleCount(); + const auto maxParticles = system.getMaxParticles(); + const auto fraction = static_cast(activeParticles) / maxParticles; + const auto particleText = fmt::format("Particles: {}/{}", activeParticles, maxParticles); + + constexpr u32 colorLow = IM_COL32(0, 200, 0, 255); + constexpr u32 colorHigh = IM_COL32(220, 0, 0, 255); + ImGui::AnimatedStatBar("##particleBar", fraction, particleText.c_str(), colorLow, colorHigh); + + const auto emitterCount = system.getEmitters().size(); + const float dt = ImGui::GetIO().DeltaTime; + const ImGuiID flashId = ImGui::GetID("##emitterFlash"); + + if (emitterCount != m_lastEmitterCount) { + m_emitterFlashColor = emitterCount > m_lastEmitterCount ? AppColors::LightGreen : AppColors::LightRed; + iam_tween_float(flashId, 0, 1.0f, 0.0f, iam_ease_preset(iam_ease_linear), iam_policy_cut, dt, 0.0f); + m_lastEmitterCount = emitterCount; + } + + const float flash = iam_tween_float( + flashId, 0, 0.0f, 0.45f, + iam_ease_preset(iam_ease_out_cubic), + iam_policy_crossfade, dt, 0.0f + ); + + const auto emitterText = fmt::format("Active Emitters: {}", emitterCount); + if (flash > 0.001f && m_emitterFlashColor != 0) { + const auto& style = ImGui::GetStyle(); + const ImVec2 p = ImGui::GetCursorScreenPos(); + const ImVec2 ts = ImGui::CalcTextSize(emitterText.c_str()); + const ImVec2 pad = { style.FramePadding.x, style.FramePadding.y * 0.5f }; + + ImVec4 hl = ImGui::ColorConvertU32ToFloat4(m_emitterFlashColor); + hl.w = flash * 0.35f; + ImGui::GetWindowDrawList()->AddRectFilled( + p - pad, + { p.x + ts.x + pad.x, p.y + ts.y + pad.y }, + ImGui::ColorConvertFloat4ToU32(hl), + style.FrameRounding + ); + } + + ImGui::TextUnformatted(emitterText.c_str()); +} + +void EditorInstance::renderPanels() { + auto* const shell = g_application->getEditor(); + if (shell->pickerOpen()) { + renderResourcePicker(&shell->pickerOpen()); + } + if (shell->textureManagerOpen()) { + renderTextureManager(&shell->textureManagerOpen()); + } + if (shell->resourceEditorOpen()) { + renderResourceEditor(&shell->resourceEditorOpen()); + } +} + +void EditorInstance::renderResourcePicker(bool* open) { + if (ImGui::Begin("Resource Picker##Editor", open)) { + + const auto& style = ImGui::GetStyle(); + constexpr float thumbnailSize = 48.0f; + constexpr ImVec2 thumbnailSizeVec = { thumbnailSize, thumbnailSize }; + const float itemHeight = thumbnailSize + style.FramePadding.y * 2; + + auto* const editor = this; + + const SPLResourceCopy* toPaste = nullptr; + size_t pasteIndex = -1; + + auto& archive = editor->getArchive(); + auto& resources = archive.getResources(); + auto& textures = archive.getTextures(); + + bool anyHovered = false; + + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.40f, 0.40f, 0.40f, 0.84f)); + + const auto contentRegion = ImGui::GetContentRegionAvail(); + if (ImGui::BeginListBox("##Resources", contentRegion)) { + const float itemWidth = contentRegion.x - (style.ItemSpacing.x + style.WindowPadding.x) * 1.3f; + const float dt = ImGui::GetIO().DeltaTime; + + // Detect selection changes + const size_t selected = m_selectedResource; + const ImGuiID accentId = ImGui::GetID("##pickerAccent"); + bool selectionChanged = false; + if (m_lastPickerSelection != selected) { + m_lastPickerSelection = selected; + selectionChanged = true; + iam_tween_float(accentId, 0, 0.0f, 0.0f, iam_ease_preset(iam_ease_linear), iam_policy_cut, dt, 0.0f); + } + + const float accentGrow = iam_tween_float( + accentId, 0, + selected != (size_t)-1 ? 1.0f : 0.0f, 0.3f, + iam_ease_preset(iam_ease_out_cubic), + iam_policy_crossfade, dt, 0.0f + ); + + for (size_t i = 0; i < resources.size(); ++i) { + const auto& resource = resources[i]; + const auto& texture = textures[resource.header.misc.textureIndex]; + + ImGui::PushID(i); + const auto itemId = ImGui::GetID("##Resource"); + + const auto name = fmt::format("[{}] Tex {}x{}", i, texture.width, texture.height); + + auto bgColor = m_selectedResource == i + ? style.Colors[ImGuiCol_ButtonActive] + : style.Colors[ImGuiCol_Button]; + + const auto cursor = ImGui::GetCursorScreenPos(); + if (ImGui::InvisibleButton("##Resource", { itemWidth, itemHeight })) { + m_selectedResource = i; + editor->notifyResourceChanged(i); + } + + const bool hovered = ImGui::IsItemHovered(); + const bool pressed = ImGui::IsItemActive(); + + if (hovered) { + anyHovered = true; + + if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + ImGui::OpenPopup("##ResourcePopup"); + } + } + + const auto targetColor = pressed + ? style.Colors[ImGuiCol_SliderGrabActive] + : hovered ? style.Colors[ImGuiCol_ButtonHovered] : bgColor; + + bgColor = iam_tween_color( + itemId, + 1, + targetColor, + 0.2f, + iam_ease_preset(iam_ease_linear), + iam_policy_crossfade, + iam_col_srgb, + dt, + bgColor + ); + + const auto bgColor2 = bgColor * ImVec4(0.8f, 0.8f, 0.8f, 1.0f); + + // Draw a filled rectangle behind the item + const auto drawList = ImGui::GetWindowDrawList(); + const int firstVtx = drawList->VtxBuffer.Size; + drawList->AddRectFilled( + cursor, + { cursor.x + itemWidth, cursor.y + itemHeight }, + ImGui::ColorConvertFloat4ToU32(bgColor), + style.FrameRounding + ); + const int lastVtx = drawList->VtxBuffer.Size; + + ImGui::ShadeVertsLinearColorGradientKeepAlpha(drawList, + firstVtx, lastVtx, + cursor, { cursor.x, cursor.y + itemHeight }, + ImGui::ColorConvertFloat4ToU32(bgColor), + ImGui::ColorConvertFloat4ToU32(bgColor2) + ); + + // Selection accent bar on the left edge, eased in on selection change + if (m_selectedResource == i && accentGrow > 0.001f) { + const float barHeight = itemHeight * accentGrow; + const float barTop = cursor.y + (itemHeight - barHeight) * 0.5f; + ImVec4 accent = ImGui::ColorConvertU32ToFloat4(AppColors::LightBlue); + accent.w = accentGrow; + drawList->AddRectFilled( + { cursor.x, barTop }, + { cursor.x + 3.0f, barTop + barHeight }, + ImGui::ColorConvertFloat4ToU32(accent), + 1.5f + ); + + if (selectionChanged) { + const float itemContentY = (cursor.y - ImGui::GetWindowPos().y) + ImGui::GetScrollY(); + const float targetScroll = itemContentY - ImGui::GetWindowHeight() * 0.5f + itemHeight * 0.5f; + iam_scroll_to_y(targetScroll, 0.25f); + } + } + + const ImVec2 offset = { + iam_tween_float( + itemId, + 0, + hovered ? 8.0f : 0.0f, + 0.25f, + iam_ease_preset(iam_ease_in_out_quad), + iam_policy_crossfade, + dt + ), + 0.0f + }; + + const auto imagePos = cursor + style.FramePadding + offset; + const auto frameMinPos = imagePos - ImVec2(2, 2); + const auto frameMaxPos = imagePos + ImVec2(thumbnailSize + 2, thumbnailSize + 2); + drawList->AddRectFilled( + frameMinPos, + frameMaxPos, + IM_COL32(60, 60, 60, 255), + 2 + ); + + drawList->AddRect( + frameMinPos, + frameMaxPos, + IM_COL32(230, 230, 230, 30), + 2 + ); + + ImGui::SetCursorScreenPos(imagePos); + ImGui::Image(texture.glTexture->getHandle(), thumbnailSizeVec); + + ImGui::SameLine(); + + ImGui::PushFont(nullptr, Application::getFontSizeLarge()); + const auto textHeight = ImGui::GetFontSize(); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (thumbnailSize - textHeight) / 2.0f); + ImGui::TextUnformatted(name.c_str()); + ImGui::PopFont(); + + if (ImGui::BeginPopup("##ResourcePopup")) { + if (ImGui::MenuItemIcon(ICON_FA_COPY, "Copy", "Ctrl+C", false, AppColors::LightGreen3)) { + g_application->getEditor()->pushClipboard(editor->getName(), resource); + ImGui::CloseCurrentPopup(); + } + + if (ImGui::MenuItemIcon(ICON_FA_COPY, "Copy with Texture", "Ctrl+Shift+C", false, AppColors::LightGreen3)) { + g_application->getEditor()->pushClipboard(editor->getName(), resource, texture); + ImGui::CloseCurrentPopup(); + } + + if (ImGui::MenuItemIcon(ICON_FA_PASTE, "Paste", "Ctrl+V", false, AppColors::LightOrange, !g_application->getEditor()->clipboardHistory().empty())) { + const auto& copy = g_application->getEditor()->clipboardHistory().back(); + if (!copy.resource) { + spdlog::error("No resource in clipboard to paste"); + } else { + toPaste = © + pasteIndex = i; + } + + ImGui::CloseCurrentPopup(); + } + + if (ImGui::MenuItemIcon(ICON_FA_CLONE, "Duplicate", nullptr, false, AppColors::LightBlue2)) { + editor->duplicateResource(i); + m_selectedResource = resources.size() - 1; + editor->notifyResourceChanged(resources.size() - 1); + + ImGui::CloseCurrentPopup(); + } + + if (ImGui::MenuItemIcon(ICON_FA_TRASH, "Delete", nullptr, false, AppColors::Gray)) { + if (m_selectedResource == i) { + m_selectedResource = -1; + editor->notifyResourceChanged(-1); + } + + editor->deleteResource(i); + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); + } + + ImGui::SetCursorScreenPos(cursor); + ImGui::Dummy({ itemWidth, itemHeight }); + + ImGui::PopID(); + } + + ImGui::EndListBox(); + } + + ImGui::PopStyleColor(); + + if (!anyHovered + && ImGui::IsWindowHovered(ImGuiHoveredFlags_RootAndChildWindows) + && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + ImGui::OpenPopup("##AddResourcePopup"); + } + + if (ImGui::BeginPopup("##AddResourcePopup")) { + if (ImGui::MenuItemIcon(ICON_FA_CIRCLE_PLUS, "Add Resource")) { + killEmitters(); // Stop all emitters before adding a new resource to avoid crashes + editor->addResource(); + m_selectedResource = resources.size() - 1; + editor->notifyResourceChanged(resources.size() - 1); + ImGui::CloseCurrentPopup(); + } + + if (ImGui::MenuItemIcon(ICON_FA_PASTE, "Paste", "Ctrl+V", false, AppColors::LightOrange, !g_application->getEditor()->clipboardHistory().empty())) { + const auto& copy = g_application->getEditor()->clipboardHistory().back(); + if (!copy.resource) { + spdlog::error("No resource in clipboard to paste"); + } else { + toPaste = © + pasteIndex = -1; // Paste as new resource + } + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); + } + + if (ImGui::IsWindowFocused() || ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows)) { + if (ImGui::IsKeyPressed(ImGuiKey_C) && ImGui::GetIO().KeyCtrl) { + if (editor && m_selectedResource != -1) { + const auto& resource = resources[m_selectedResource]; + + if (ImGui::GetIO().KeyShift) { + g_application->getEditor()->pushClipboard(editor->getName(), resource, textures[resource.header.misc.textureIndex]); + } else { + g_application->getEditor()->pushClipboard(editor->getName(), resource); + } + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_V) && ImGui::GetIO().KeyCtrl) { + if (!g_application->getEditor()->clipboardHistory().empty()) { + const auto& copy = g_application->getEditor()->clipboardHistory().back(); + if (copy.resource) { + toPaste = © + pasteIndex = -1; // Paste at end + } else { + spdlog::error("No resource in clipboard to paste"); + } + } + } + } + + if (toPaste) { + size_t texIndex = 0; + if (toPaste->texture) { + // Texture always pasted at the end + texIndex = textures.size(); + + auto& tex = *toPaste->texture; + + auto& texData = archive.getTextureData(); + auto& palData = archive.getPaletteData(); + + texData.push_back(std::move(tex.data)); + palData.push_back(std::move(tex.pltt)); + + auto& existingTextures = archive.getTextures(); + existingTextures.emplace_back(std::move(tex.texture)); + + editor->getParticleSystem().getRenderer().setTextures(existingTextures); + } + + if (toPaste->resource) { + if (pasteIndex >= resources.size() || pasteIndex == -1) { + auto& res = resources.emplace_back(std::move(*toPaste->resource)); + res.header.misc.textureIndex = static_cast(texIndex); + } else { + const auto res = resources.emplace(resources.begin() + pasteIndex, std::move(*toPaste->resource)); + res->header.misc.textureIndex = static_cast(texIndex); + } + } + + editor->valueChanged(true); + } + } + + ImGui::End(); +} + +void EditorInstance::renderTextureManager(bool* open) { + if (ImGui::Begin("Texture Manager##Editor", open)) { + auto* const editor = this; + + // Process deferred actions from a previous frame before touching the archive. + if (m_discardTempTexture) { + destroyTempTexture(); + } + if (m_deleteSelectedTexture) { + m_archive.deleteTexture(m_selectedTexture); + m_selectedTexture = -1; + m_deleteSelectedTexture = false; + } + + auto& archive = editor->getArchive(); + auto& textures = archive.getTextures(); + + const auto importPopupId = ImGui::GetID("##ImportTexturePopup"); + const auto deleteTexturePopupId = ImGui::GetID("##DeleteTexturePopup"); + + if (ImGui::IconButton(ICON_FA_FILE_IMPORT, "Import", AppColors::LightBlue)) { + const auto path = tinyfd_openFileDialog( + "Import Texture", + "", + 0, + nullptr, + "Image Files", + 0 + ); + if (path) { + openTempTexture(path); + ImGui::OpenPopup(importPopupId); + } + } + + ImGui::SameLine(); + + if (ImGui::IconButton(ICON_FA_FILE_EXPORT, "Export All...", AppColors::Yellow)) { + const auto path = Application::openDirectory("Select Destination"); + if (!path.empty()) { + archive.exportTextures(path, Application::getTempPath()); + } + } + + ImGui::BeginChild("##TextureList", {}, ImGuiChildFlags_Borders); + bool anyHovered = false; + + const ImVec2 padding = { ImGui::GetStyle().FramePadding.x, 16.0f - ImGui::GetTextLineHeight() * 0.5f }; + + for (int i = 0; i < textures.size(); ++i) { + auto& texture = textures[i]; + const auto name = fmt::format("[{}] Tex {}x{}", i, texture.width, texture.height); + + ImGui::Image((ImTextureID)texture.glTexture->getHandle(), { 32, 32 }); + ImGui::SameLine(); + const bool open = ImGui::PaddedTreeNode(name.c_str(), padding, ImGuiTreeNodeFlags_SpanAvailWidth); + + if (ImGui::BeginPopupContextItem(fmt::format("##TexturePopup{}", i).c_str())) { + if (ImGui::MenuItemIcon(ICON_FA_FILE_IMPORT, "Update...", nullptr, false, AppColors::LightBlue2)) { + const auto path = tinyfd_openFileDialog( + "Update Texture", + "", + 0, + nullptr, + "Image Files", + 0 + ); + + if (path) { + openTempTexture(path, i); + ImGui::OpenPopup(importPopupId); + } + } + + if (ImGui::MenuItemIcon(ICON_FA_FILE_EXPORT, "Export...", nullptr, false, AppColors::Yellow)) { + const char* filterPatterns[] = { "*.png", "*.bmp", "*.tga" }; + const auto path = tinyfd_saveFileDialog( + "Export Texture", + fmt::format("texture_{}.png", i).c_str(), + std::size(filterPatterns), + filterPatterns, + nullptr + ); + + if (path) { + archive.exportTexture(i, path); + } + } + + if (ImGui::MenuItemIcon(ICON_FA_COPY, "Copy", nullptr, false, AppColors::LightGreen3)) { + g_application->getEditor()->pushClipboard(editor->getName(), texture); + } + + if (ImGui::MenuItemIcon(ICON_FA_TRASH, "Delete", nullptr, false, AppColors::Gray)) { + m_selectedTexture = i; + ImGui::OpenPopup(deleteTexturePopupId); + } + + ImGui::EndPopup(); + } + + if (open) { + ImGui::Text("Format: %s", getTextureFormat(texture.param.format)); + + if (ImGui::BeginCombo("Repeat", getTextureRepeat(texture.param.repeat))) { + for (const auto [val, name] : detail::g_textureRepeatNames) { + if (editor->valueChanged(ImGui::Selectable(name, texture.param.repeat == val))) { + texture.param.repeat = val; + texture.glTexture->setWrapping(texture.param.repeat, texture.param.flip); + } + } + + ImGui::EndCombo(); + } + + if (ImGui::BeginCombo("Flip", getTextureFlip(texture.param.flip))) { + for (const auto [val, name] : detail::g_textureFlipNames) { + if (editor->valueChanged(ImGui::Selectable(name, texture.param.flip == val))) { + texture.param.flip = val; + texture.glTexture->setWrapping(texture.param.repeat, texture.param.flip); + } + } + + ImGui::EndCombo(); + } + + if (editor->valueChanged(ImGui::Checkbox("Palette Color 0 Transparent", &texture.param.palColor0Transparent))) { + texture.glTexture->update(texture); + } + + editor->valueChanged(ImGui::Checkbox("Use Shared Texture", &texture.param.useSharedTexture)); + + if (texture.param.useSharedTexture) { + ImGui::InputScalar("Shared Texture ID", ImGuiDataType_U8, &texture.param.sharedTexID); + } + + ImGui::TreePop(); + } + + anyHovered |= ImGui::IsItemHovered(); + } + + ImGui::EndChild(); + + const auto copyTexture = [&] { + auto& clipboard = g_application->getEditor()->clipboardHistory(); + const auto& copy = clipboard.back(); + if (!copy.texture || copy.resource) { + spdlog::error("No texture in clipboard to paste"); + } else { + auto& tex = *clipboard.back().texture; + + auto& texData = archive.getTextureData(); + auto& palData = archive.getPaletteData(); + + texData.push_back(std::move(tex.data)); + palData.push_back(std::move(tex.pltt)); + + auto& existingTextures = archive.getTextures(); + existingTextures.emplace_back(std::move(tex.texture)); + + editor->getParticleSystem().getRenderer().setTextures(existingTextures); + editor->valueChanged(true); + } + }; + + if (ImGui::IsWindowFocused() || ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows)) { + if (ImGui::IsKeyPressed(ImGuiKey_V) && ImGui::GetIO().KeyCtrl && !g_application->getEditor()->clipboardHistory().empty()) { + copyTexture(); + } + } + + if (!anyHovered && ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItemIcon(ICON_FA_PASTE, "Paste", "Ctrl+V", false, AppColors::LightOrange, !g_application->getEditor()->clipboardHistory().empty())) { + copyTexture(); + } + + ImGui::EndPopup(); + } + + const auto popupPos = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(popupPos, ImGuiCond_Appearing, { 0.5f, 0.5f }); + if (ImGui::BeginPopupModal("##ImportTexturePopup")) { + const auto textureSize = ImVec2(m_tempTexture->width, m_tempTexture->height) * m_tempTextureScale; + const ImVec2 tableSize = { std::max(textureSize.x, 300.0f), 0.0f}; + const auto& style = ImGui::GetStyle(); + + if (ImGui::BeginTable("##TempTextureTable", 2, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchSame, tableSize)) { + + ImGui::TableSetupColumn("##aaa", ImGuiTableColumnFlags_WidthStretch, 0.5f); + ImGui::TableSetupColumn("##bbb", ImGuiTableColumnFlags_WidthStretch, 0.5f); + + ImGui::TableNextColumn(); ImGui::Text("Size"); + ImGui::TableNextColumn(); ImGui::Text("%dx%d", m_tempTexture->width, m_tempTexture->height); + + ImGui::TableNextColumn(); ImGui::Text("Channels"); + ImGui::TableNextColumn(); ImGui::Text("%d", m_tempTexture->channels); + + ImGui::TableNextColumn(); ImGui::Text("Unique Colors"); + ImGui::TableNextColumn(); ImGui::Text("%" PRIu64, m_tempTexture->spec.uniqueColors.size()); + + ImGui::TableNextColumn(); ImGui::Text("Unique Alphas"); + ImGui::TableNextColumn(); ImGui::Text("%" PRIu64, m_tempTexture->spec.uniqueAlphas.size()); + + const auto estimatedSize = m_tempTexture->spec.getSizeEstimate(m_tempTexture->width, m_tempTexture->height); + ImGui::TableNextColumn(); ImGui::Text("Estimated Size"); + ImGui::TableNextColumn(); + if (estimatedSize >= 1024) { + ImGui::Text("%zu kB", estimatedSize / 1024); + } else { + ImGui::Text("%zu B", estimatedSize); + } + + ImGui::TableNextColumn(); ImGui::Text("Format"); + ImGui::TableNextColumn(); + ImGui::SetNextItemWidth(tableSize.x * 0.5f - style.CellPadding.x * 2); + if (ImGui::BeginCombo("##Format", getTextureFormat(m_tempTexture->spec.format))) { + for (auto i = (int)TextureFormat::A3I5; i < (int)TextureFormat::Count; i++) { + const auto flags = (TextureFormat)i == TextureFormat::Comp4x4 ? ImGuiSelectableFlags_Disabled : 0; + if (ImGui::Selectable(getTextureFormat((TextureFormat)i), (int)m_tempTexture->spec.format == i, flags)) { + m_tempTexture->spec.setFormat((TextureFormat)i); + + if (m_tempTexture->spec.format == m_tempTexture->suggestedFormat && m_tempTexture->suggestedFormatUncompressed) { + std::memcpy( + m_tempTexture->quantized, + m_tempTexture->data, + (size_t)m_tempTexture->width * m_tempTexture->height * 4 + ); + } else { + quantizeTexture( + m_tempTexture->data, + m_tempTexture->width, + m_tempTexture->height, + m_tempTexture->spec, + m_tempTexture->quantized + ); + } + + m_tempTexture->texture->update(m_tempTexture->quantized); + } + } + + ImGui::EndCombo(); + } + + ImGui::TableNextColumn(); ImGui::Text("Color Compression"); + ImGui::TableNextColumn(); ImGui::Text("%s", m_tempTexture->spec.requiresColorCompression ? "Yes" : "No"); + + ImGui::TableNextColumn(); ImGui::Text("Alpha Compression"); + ImGui::TableNextColumn(); ImGui::Text("%s", m_tempTexture->spec.requiresAlphaCompression ? "Yes" : "No"); + + ImGui::EndTable(); + } + + ImGui::SetNextItemWidth(150.0f - style.CellPadding.x * 2); + ImGui::SliderFloat("Display Scale", &m_tempTextureScale, 0.1f, 8.0f, "%.2fx"); + ImGui::SameLine(); + ImGui::TextDisabled("(?)"); + if (ImGui::BeginItemTooltip()) + { + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); + ImGui::TextUnformatted("Only for checking how the texture looks. Does not affect the actual imported texture."); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + + ImGui::Image((ImTextureID)m_tempTexture->texture->getHandle(), textureSize); + + if (!m_tempTexture->isValidSize) { + ImGui::TextColored({ 0.93f, 0, 0, 1 }, "Invalid Texture Size (?)"); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Both the width and the height of the texture must be a power of 2\n" + "and they must be in the range [8, 1024]"); + } + ImGui::BeginDisabled(); + } + + if (ImGui::Button("Confirm") && m_tempTexture->isValidSize) { + importTempTexture(); + ImGui::CloseCurrentPopup(); + } + + if (!m_tempTexture->isValidSize) { + ImGui::EndDisabled(); + } + + ImGui::SameLine(); + + if (ImGui::Button("Cancel")) { + discardTempTexture(); + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); + } + + if (ImGui::BeginPopupModal("##DeleteTexturePopup")) { + ImGui::Text("Are you sure you want to delete this texture?"); + ImGui::TextDisabled("(This might break existing resources)"); + ImGui::Separator(); + + const auto texCount = archive.getTextureCount(); + if (texCount <= 1) { + ImGui::TextColored({ 0.93f, 0.2f, 0.2f, 1 }, "You cannot delete the last texture."); + ImGui::BeginDisabled(); + } + + if (ImGui::Button("Yes")) { + m_deleteSelectedTexture = true; + ImGui::CloseCurrentPopup(); + } + + if (texCount <= 1) { + ImGui::EndDisabled(); + } + + ImGui::SameLine(); + if (ImGui::Button("No")) { + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); + } + } + + ImGui::End(); +} + +void EditorInstance::renderResourceEditor(bool* open) { + if (ImGui::Begin("Resource Editor##Editor", open)) { + ImGui::SliderFloat("Global Time Scale", &g_application->getEditor()->timeScale(), 0.0f, 2.0f, "%.2f"); + + auto* const editor = this; + + auto& archive = editor->getArchive(); + auto& resources = archive.getResources(); + auto& textures = archive.getTextures(); + + if (m_selectedResource != -1) { + auto& resource = resources[m_selectedResource]; + const auto& texture = textures[resource.header.misc.textureIndex]; + + if (ImGui::IconButton(ICON_FA_PLAY, "Play Emitter", AppColors::LightGreen)) { + playEmitter(m_emitterSpawnType); + } + + ImGui::SameLine(); + ImGui::SetNextItemWidth(150); + ImGui::Combo("##SpawnType", (int*)&m_emitterSpawnType, s_emitterSpawnTypes.data(), s_emitterSpawnTypes.size()); + + if (m_emitterSpawnType == EmitterSpawnType::Interval) { + ImGui::SameLine(); + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + ImGui::InputFloat("##Interval", &m_emitterInterval, 0.1f, 1.0f, "%.2fs"); + } + + if (ImGui::IconButton(ICON_FA_PLAY, "Play All Emitters", AppColors::LightGreen)) { + playAllEmitters(m_emitterSpawnType); + } + + if (ImGui::IconButton(ICON_FA_STOP, "Kill Emitters", AppColors::LightRed)) { + killEmitters(); + } + + //if (ImGui::RedButton("Kill Emitters")) { + // killEmitters(); + //} + + if (ImGui::BeginTabBar("##editorTabs")) { + if (ImGui::BeginTabItem("General")) { + ImGui::BeginChild("##headerEditor", {}, ImGuiChildFlags_Borders); + renderHeaderEditor(resource.header); + ImGui::EndChild(); + ImGui::EndTabItem(); + } + + if (ImGui::BeginTabItem("Behaviors")) { + ImGui::BeginChild("##headerEditor", {}, ImGuiChildFlags_Borders); + renderBehaviorEditor(resource); + ImGui::EndChild(); + ImGui::EndTabItem(); + } + + if (ImGui::BeginTabItem("Animations")) { + ImGui::BeginChild("##animationEditor", {}, ImGuiChildFlags_Borders); + renderAnimationEditor(resource); + ImGui::EndChild(); + ImGui::EndTabItem(); + } + + if (ImGui::BeginTabItem("Children")) { + ImGui::BeginChild("##childEditor", {}, ImGuiChildFlags_Borders); + renderChildrenEditor(resource); + ImGui::EndChild(); + ImGui::EndTabItem(); + } + + ImGui::EndTabBar(); + } + } + } + + ImGui::End(); } void EditorInstance::handleEvent(const SDL_Event& event) { @@ -212,8 +1074,8 @@ bool EditorInstance::notifyClosing() { } void EditorInstance::notifyResourceChanged(size_t index) { - if (index == -1) { - m_selectedResource = -1; + if (index == INVALID_RESOURCE) { + m_selectedResource = INVALID_RESOURCE; return; } @@ -410,3 +1272,1431 @@ std::string EditorInstance::getName() const { return m_path.filename().string(); } + +void EditorInstance::openTempTexture(const std::filesystem::path& path, size_t destIndex) { + constexpr auto isPowerOf2 = [](s32 value) { + return (value & (value - 1)) == 0; + }; + + if (destIndex != -1 && destIndex >= m_archive.getTextures().size()) { + spdlog::error("Invalid destination index for temp texture: {}", destIndex); + return; + } + + std::ifstream file(path, std::ios::binary | std::ios::in); + if (!file) { + spdlog::error("Failed to open file: {}", path.string()); + return; + } + + const std::vector fileData{std::istreambuf_iterator(file), std::istreambuf_iterator()}; + if (fileData.empty()) { + spdlog::error("File is empty: {}", path.string()); + return; + } + + spng_ctx* ctx = spng_ctx_new(0); + if (!ctx) { + spdlog::error("Failed to create SPNG context"); + return; + } + + spng_set_crc_action(ctx, SPNG_CRC_USE, SPNG_CRC_USE); + + constexpr auto limit = 1024 * 1024 * 64U; // 64 MB + spng_set_chunk_limits(ctx, limit, limit); + + int ret; + if ((ret = spng_set_png_buffer(ctx, fileData.data(), fileData.size())) != 0) { + spdlog::error("Failed to set PNG buffer: {}", spng_strerror(ret)); + spng_ctx_free(ctx); + return; + } + + spng_ihdr ihdr; + if ((ret = spng_get_ihdr(ctx, &ihdr)) != 0) { + spdlog::error("Failed to get PNG header: {}", spng_strerror(ret)); + spng_ctx_free(ctx); + return; + } + + const auto tempTex = new TempTexture; + m_tempTexture = tempTex; + + tempTex->path = path.string(); + tempTex->data = stbi_load_from_memory(fileData.data(), (int)fileData.size(), &tempTex->width, &tempTex->height, &tempTex->channels, 4); + if (!tempTex->data) { + delete m_tempTexture; + m_tempTexture = nullptr; + spng_ctx_free(ctx); + return; + } + + tempTex->texture = std::make_unique(tempTex->width, tempTex->height); + tempTex->quantized = new u8[(size_t)tempTex->width * tempTex->height * 4]; + + // If we load an indexed PNG, we can go a simple path and don't need to quantize it. + if (ihdr.color_type == SPNG_COLOR_TYPE_INDEXED && ihdr.bit_depth <= 8) { + spng_plte palette; + spng_get_plte(ctx, &palette); + + TextureFormat format; + if (palette.n_entries <= 4) { + format = TextureFormat::Palette4; + } else if (palette.n_entries <= 16) { + format = TextureFormat::Palette16; + } else if (palette.n_entries <= 256) { + format = TextureFormat::Palette256; + } else { + spdlog::error("Unsupported indexed PNG bit depth: {}", ihdr.bit_depth); + format = TextureFormat::Palette256; + } + + std::memcpy(tempTex->quantized, tempTex->data, (size_t)tempTex->width * tempTex->height * 4); + tempTex->preference = TextureConversionPreference::ColorDepth; + tempTex->spec = { + .color0Transparent = true, + .requiresColorCompression = false, + .requiresAlphaCompression = false, + .format = format, + .uniqueColors = {}, + .uniqueAlphas = {}, + .flags = TextureAttributes::None, + }; + tempTex->suggestedFormat = format; + tempTex->suggestedFormatUncompressed = true; + } else { + tempTex->preference = TextureConversionPreference::ColorDepth; + tempTex->spec = SPLTexture::suggestSpecification(tempTex->width, tempTex->height, tempTex->channels, tempTex->data, tempTex->preference); + tempTex->suggestedFormat = tempTex->spec.format; + + if (tempTex->spec.requiresColorCompression || tempTex->spec.requiresAlphaCompression) { + tempTex->suggestedFormatUncompressed = false; + quantizeTexture(tempTex->data, tempTex->width, tempTex->height, tempTex->spec, tempTex->quantized); + } else { + tempTex->suggestedFormatUncompressed = true; + std::memcpy(tempTex->quantized, tempTex->data, (size_t)tempTex->width * tempTex->height * 4); + } + } + + tempTex->texture->update(tempTex->quantized); + + tempTex->isValidSize = true; + + if (tempTex->width > 1024 || tempTex->height > 1024) { + tempTex->isValidSize = false; + } + + if (!isPowerOf2(tempTex->width) || !isPowerOf2(tempTex->height)) { + tempTex->isValidSize = false; + } + + tempTex->destIndex = destIndex; + + spng_ctx_free(ctx); +} + +void EditorInstance::discardTempTexture() { + m_discardTempTexture = true; + spdlog::info("Discarding temp texture"); +} + +void EditorInstance::destroyTempTexture() { + stbi_image_free(m_tempTexture->data); + delete[] m_tempTexture->quantized; + delete m_tempTexture; + m_tempTexture = nullptr; + m_discardTempTexture = false; +} + +void EditorInstance::importTempTexture() { + if (!m_tempTexture) { + return; + } + + auto& archive = m_archive; + auto& textures = archive.getTextures(); + auto& texture = m_tempTexture->destIndex != -1 ? textures[m_tempTexture->destIndex] : textures.emplace_back(); + texture.glTexture = std::move(m_tempTexture->texture); + texture.width = m_tempTexture->width; + texture.height = m_tempTexture->height; + texture.param = { + .format = m_tempTexture->spec.format, + .s = 1, + .t = 1, + .repeat = TextureRepeat::None, + .flip = TextureFlip::None, + .palColor0Transparent = false, + .useSharedTexture = false, + .sharedTexID = 0xFF + }; + + // -3 because s/t are enums starting at GX_TEXSIZE_8 = 0 + texture.param.s = (u8)glm::log2(texture.width) - 3; + texture.param.t = (u8)glm::log2(texture.height) - 3; + + auto& textureData = archive.getTextureData().emplace_back(); + auto& paletteData = archive.getPaletteData().emplace_back(); + + palettizeTexture( + m_tempTexture->quantized, + m_tempTexture->width, + m_tempTexture->height, + m_tempTexture->spec, + textureData, + paletteData + ); + + texture.textureData = textureData; + texture.paletteData = paletteData; + + switch (texture.param.format) { + case TextureFormat::Palette4: [[fallthrough]]; + case TextureFormat::Palette16: [[fallthrough]]; + case TextureFormat::Palette256: + // Check if the first color in the palette is transparent + texture.param.palColor0Transparent = ((GXRgba*)paletteData.data())->a == 0; + break; + default: + break; + } + + discardTempTexture(); + + m_particleSystem.getRenderer().setTextures(textures); +} + +void EditorInstance::ensureValidSelection() { + if (m_selectedResource != INVALID_RESOURCE && m_selectedResource >= m_archive.getResourceCount()) { + notifyResourceChanged(INVALID_RESOURCE); + } +} + +bool EditorInstance::palettizeTexture(const u8* data, s32 width, s32 height, const TextureImportSpecification& spec, std::vector& outData, std::vector& outPalette) { + return SPLTexture::convertFromRGBA8888(data, width, height, spec.format, outData, outPalette); +} + +void EditorInstance::quantizeTexture(const u8* data, s32 width, s32 height, const TextureImportSpecification& spec, u8* out) { + liq_attr* attr = liq_attr_create(); + liq_set_max_colors(attr, spec.getMaxColors()); + + liq_image* image = liq_image_create_rgba(attr, data, width, height, 0); + + liq_error err; + liq_result* result = nullptr; + if ((err = liq_image_quantize(image, attr, &result)) != LIQ_OK) { + spdlog::error("Failed to quantize image: {}", (int)err); + return; + } + + std::vector quantized(width * height); + if ((err = liq_write_remapped_image(result, image, quantized.data(), quantized.size())) != LIQ_OK) { + spdlog::error("Failed to write quantized image: {}", (int)err); + return; + } + + const auto palette = liq_get_palette(result); + if (palette->count > spec.getMaxColors()) { + spdlog::error("Too many colors in resulting palette"); + return; + } + + auto palcopy = *palette; + std::span colorspan(palcopy.entries, palcopy.count); + + // Further quantize alpha values if necessary + if (spec.requiresAlphaCompression) { + const auto maxAlphas = spec.getMaxAlphas(); + const auto [min, max] = spec.getAlphaRange(); + + const auto transparent = std::ranges::find_if(colorspan, [](liq_color color) { return color.a == 0; }); + + switch (spec.format) { + case TextureFormat::None: break; + case TextureFormat::A3I5: + case TextureFormat::A5I3: + std::ranges::for_each(colorspan, [min, max](auto& color) { + const auto mapped = (u8)(((float)color.a / 255.0f) * (max - min)) + min; + color.a = (u8)(((float)(mapped - min) / (max - min)) * 255.0f); + }); + break; + + case TextureFormat::Palette4: + case TextureFormat::Palette16: + case TextureFormat::Palette256: + case TextureFormat::Direct: + if (spec.needsAlpha() || spec.format == TextureFormat::Direct) { + std::ranges::for_each(colorspan, [](auto& color) { + if (color.a < 128) { + color.a = 0; + } else { + color.a = 255; + } + }); + } + break; + default: break; + } + } + + const auto colors = (u32*)palcopy.entries; + std::span remapped((u32*)out, width * height); + for (auto [index, pixel] : std::views::zip(quantized, remapped)) { + pixel = colors[index]; + } + + liq_result_destroy(result); + liq_image_destroy(image); + liq_attr_destroy(attr); +} + +void EditorInstance::createRenderers() { + m_debugRenderer = std::make_unique(1000); + m_collisionGridRenderer = std::make_shared(glm::ivec2(10, 10), glm::vec2(1.0f, 1.0f)); +} + +void EditorInstance::renderHeaderEditor(SPLResourceHeader &header) +{ + auto& flags = header.flags; + auto& misc = header.misc; + constexpr f32 frameTime = 1.0f / (f32)SPLArchive::SPL_FRAMES_PER_SECOND; + + auto open = ImGui::TreeNodeEx("##emitterSettings", ImGuiTreeNodeFlags_SpanAvailWidth); + ImGui::SameLine(); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() - 5); + ImGui::SeparatorText("Emitter Settings"); + if (open) { + if (ImGui::BeginCombo("Emission Type", getEmissionType(flags.emissionType))) { + for (const auto [val, name] : detail::g_emissionTypeNames) { + if (NOTIFY(ImGui::Selectable(name, flags.emissionType == val))) { + flags.emissionType = val; + } + } + + ImGui::EndCombo(); + } + HELP(emissionType); + + if (ImGui::BeginCombo("Emission Axis", getEmissionAxis(flags.emissionAxis))) { + for (const auto [val, name] : detail::g_emissionAxisNames) { + if (NOTIFY(ImGui::Selectable(name, flags.emissionAxis == val))) { + flags.emissionAxis = val; + } + } + + ImGui::EndCombo(); + } + HELP(emissionAxis); + + NOTIFY(ImGui::Checkbox("Self Maintaining", &flags.selfMaintaining)); + HELP(selfMaintaining); + + NOTIFY(ImGui::Checkbox("Draw Children First", &flags.drawChildrenFirst)); + HELP(drawChildrenFirst); + + NOTIFY(ImGui::Checkbox("Hide Parent", &flags.hideParent)); + HELP(hideParent); + + NOTIFY(ImGui::Checkbox("Use View Space", &flags.useViewSpace)); + HELP(useViewSpace); + + NOTIFY(ImGui::Checkbox("Has Fixed Polygon ID", &flags.hasFixedPolygonID)); + HELP(hasFixedPolygonID); + + NOTIFY(ImGui::Checkbox("Child Fixed Polygon ID", &flags.childHasFixedPolygonID)); + HELP(childHasFixedPolygonID); + + NOTIFY(ImGui::DragFloat3("Emitter Base Pos", glm::value_ptr(header.emitterBasePos), 0.01f)); + HELP(emitterBasePos); + + NOTIFY(ImGui::SliderFloat("Lifetime", &header.emitterLifeTime, frameTime, 60, "%.4fs", ImGuiSliderFlags_Logarithmic)); + HELP(emitterLifeTime); + + NOTIFY(ImGui::DragInt("Emission Amount", (int*)&header.emissionCount, 1, 0, 20)); + HELP(emissionCount); + + // The in-file value is 8 bits, 255 = 255 frames of delay, 255f / 30fps = 8.5s + NOTIFY(ImGui::SliderFloat("Emission Interval", &misc.emissionInterval, frameTime, 8.5f, "%.4fs")); + HELP(emissionInterval); + + u32 emissions = (u32)glm::ceil(header.emitterLifeTime / misc.emissionInterval); + const u32 maxEmissions = (u32)(header.emitterLifeTime / frameTime); + if (NOTIFY(ImGui::SliderInt("Emissions", (int*)&emissions, 1, maxEmissions))) { + misc.emissionInterval = header.emitterLifeTime / (f32)emissions; + } + HELP(emissions); + + NOTIFY(ImGui::SliderFloat("Start Delay", &header.startDelay, 0, header.emitterLifeTime, "%.2fs")); + HELP(startDelay); + + NOTIFY(ImGui::SliderFloat("Radius", &header.radius, 0.01f, 20.0f, "%.3f", ImGuiSliderFlags_Logarithmic)); + HELP(radius); + + NOTIFY(ImGui::SliderFloat("Length", &header.length, 0.01f, 20.0f, "%.3f", ImGuiSliderFlags_Logarithmic)); + HELP(length); + + NOTIFY(ImGui::DragFloat3("Axis", glm::value_ptr(header.axis), 0.02f)); + HELP(axis); + + ImGui::TreePop(); + } + + open = ImGui::TreeNodeEx("##particleSettings", ImGuiTreeNodeFlags_SpanAvailWidth); + ImGui::SameLine(); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() - 5); + ImGui::SeparatorText("Particle Settings"); + if (open) { + if (ImGui::BeginCombo("Draw Type", getDrawType(flags.drawType))) { + for (const auto [val, name] : detail::g_drawTypeNames) { + if (NOTIFY(ImGui::Selectable(name, flags.drawType == val))) { + flags.drawType = val; + } + } + + ImGui::EndCombo(); + } + HELP(drawType); + + const auto& textures = m_archive.getTextures(); + if (ImGui::ImageButton("##tex", (ImTextureID)textures[header.misc.textureIndex].glTexture->getHandle(), {32, 32})) { + ImGui::OpenPopup(ImGui::GetID("##texturePicker")); + } + ImGui::SameLine(); + ImGui::Text("Texture"); + HELP(texture); + + NOTIFY(ImGui::Checkbox("Rotate", &flags.hasRotation)); + HELP(hasRotation); + + NOTIFY(ImGui::Checkbox("Random Init Angle", &flags.randomInitAngle)); + HELP(randomInitAngle); + + NOTIFY(ImGui::Checkbox("Follow Emitter", &flags.followEmitter)); + HELP(followEmitter); + + if (ImGui::BeginCombo("Polygon Rotation Axis", getPolygonRotAxis(flags.polygonRotAxis))) { + for (const auto [val, name] : detail::g_polygonRotAxisNames) { + if (NOTIFY(ImGui::Selectable(name, flags.polygonRotAxis == val))) { + flags.polygonRotAxis = val; + } + } + + ImGui::EndCombo(); + } + HELP(polygonRotAxis); + + ImGui::TextUnformatted("Polygon Reference Plane"); + HELP(polygonReferencePlane); + ImGui::Indent(); + NOTIFY(ImGui::RadioButton("XY", &flags.polygonReferencePlane, 0)); + NOTIFY(ImGui::RadioButton("XZ", &flags.polygonReferencePlane, 1)); + ImGui::Unindent(); + + NOTIFY(ImGui::ColorEdit3("Color", glm::value_ptr(header.color))); + HELP(color); + + NOTIFY(ImGui::SliderFloat("Base Scale", &header.baseScale, 0.01f, 10.0f, "%.3f", ImGuiSliderFlags_Logarithmic)); + HELP(baseScale); + + NOTIFY(ImGui::SliderAngle("Init Angle", &header.initAngle, 0)); + HELP(initAngle); + + NOTIFY(ImGui::SliderFloat("Base Alpha", &misc.baseAlpha, 0, 1)); + HELP(baseAlpha); + + NOTIFY(ImGui::SliderFloat("Lifetime", &header.particleLifeTime, frameTime, 60, "%.4fs", ImGuiSliderFlags_Logarithmic)); + HELP(particleLifeTime); + + NOTIFY(ImGui::DragFloat("Aspect Ratio", &header.aspectRatio, 0.05f)); + HELP(aspectRatio); + + NOTIFY(ImGui::DragFloat("Init Velocity Pos Amplifier", &header.initVelPosAmplifier, 0.1f, -10, 10, "%.3f", ImGuiSliderFlags_Logarithmic)); + HELP(initVelPosAmplifier); + + NOTIFY(ImGui::DragFloat("Init Velocity Axis Amplifier", &header.initVelAxisAmplifier, 0.1f, -10, 10, "%.3f", ImGuiSliderFlags_Logarithmic)); + HELP(initVelAxisAmplifier); + + ImGui::TextUnformatted("Rotation Speed"); + HELP(rotationSpeed); + ImGui::Indent(); + NOTIFY(ImGui::SliderAngle("Min", &header.minRotation, 0, header.maxRotation * 180 / glm::pi())); + NOTIFY(ImGui::SliderAngle("Max", &header.maxRotation, header.minRotation * 180 / glm::pi(), 360)); + ImGui::Unindent(); + + ImGui::TextUnformatted("Variance"); + HELP(variance); + ImGui::Indent(); + NOTIFY(ImGui::SliderFloat("Base Scale##variance", &header.variance.baseScale, 0, 1)); + NOTIFY(ImGui::SliderFloat("Particle Lifetime##variance", &header.variance.lifeTime, 0, 1)); + NOTIFY(ImGui::SliderFloat("Init Velocity##variance", &header.variance.initVel, 0, 1)); + ImGui::Unindent(); + + NOTIFY(ImGui::SliderFloat("Air Resistance", &misc.airResistance, 0.75f, 1.25f)); + HELP(airResistance); + + NOTIFY(ImGui::SliderFloat("Loop Time", &misc.loopTime, frameTime, 8.5f, "%.4fs")); + HELP(loopTime); + + u32 loops = (u32)glm::ceil(header.particleLifeTime / misc.loopTime); + const u32 maxLoops = (u32)(header.particleLifeTime / frameTime); + if (NOTIFY(ImGui::SliderInt("Loops", (int*)&loops, 1, maxLoops))) { + misc.loopTime = header.particleLifeTime / (f32)loops; + } + HELP(loops); + + NOTIFY(ImGui::Checkbox("Randomize Looped Anim", &flags.randomizeLoopedAnim)); + HELP(randomizeLoopedAnim); + + NOTIFY(ImGui::SliderFloat("DBB Scale", &misc.dbbScale, -8.0f, 7.0f)); + HELP(dbbScale); + + if (ImGui::BeginCombo("Scale Anim Axis", getScaleAnimDir(misc.scaleAnimDir))) { + for (const auto [val, name] : detail::g_scaleAnimDirNames) { + if (NOTIFY(ImGui::Selectable(name, misc.scaleAnimDir == val))) { + misc.scaleAnimDir = val; + } + } + + ImGui::EndCombo(); + } + HELP(scaleAnimDir); + + ImGui::TextUnformatted("Texture Tiling"); + HELP(textureTiling); + ImGui::Indent(); + int tileCount = 1 << misc.textureTileCountS; + NOTIFY(ImGui::SliderInt("S", &tileCount, 1, 8)); + misc.textureTileCountS = glm::log2(tileCount); + + tileCount = 1 << misc.textureTileCountT; + NOTIFY(ImGui::SliderInt("T", &tileCount, 1, 8)); + misc.textureTileCountT = glm::log2(tileCount); + ImGui::Unindent(); + + NOTIFY(ImGui::Checkbox("DPol Face Emitter", &misc.dpolFaceEmitter)); + HELP(dpolFaceEmitter); + + NOTIFY(ImGui::Checkbox("Flip X", &misc.flipTextureS)); + HELP(flipTextureX); + + NOTIFY(ImGui::Checkbox("Flip Y", &misc.flipTextureT)); + HELP(flipTextureY); + + ImGui::TextUnformatted("Polygon Offset"); + HELP(polygonOffset); + ImGui::Indent(); + NOTIFY(ImGui::SliderFloat("X", &header.polygonX, -2, 2)); + NOTIFY(ImGui::SliderFloat("Y", &header.polygonY, -2, 2)); + ImGui::Unindent(); + + if (ImGui::BeginPopup("##texturePicker")) { + for (int i = 0; i < textures.size(); ++i) { + ImGui::PushID(i); + + const auto& texture = textures[i]; + if (NOTIFY(ImGui::ImageButton("##tex", (ImTextureID)texture.glTexture->getHandle(), {32, 32}))) { + header.misc.textureIndex = i; + ImGui::CloseCurrentPopup(); + } + + if (i % 4 != 3) { + ImGui::SameLine(); + } + + ImGui::PopID(); + } + + ImGui::EndPopup(); + } + + ImGui::TreePop(); + } +} + +void EditorInstance::renderBehaviorEditor(SPLResource& res) { + std::vector> toRemove; + + if (ImGui::IconButton(ICON_FA_CIRCLE_PLUS, "Add Behavior...", AppColors::Turquoise)) { + ImGui::OpenPopup("##addBehavior"); + } + + if (ImGui::BeginPopup("##addBehavior")) { + if (NOTIFY(ImGui::MenuItem("Gravity", nullptr, false, !res.header.flags.hasGravityBehavior))) { + res.behaviors.push_back(std::make_shared(glm::vec3(0, 0, 0))); + res.header.addBehavior(SPLBehaviorType::Gravity); + } + + if (NOTIFY(ImGui::MenuItem("Random", nullptr, false, !res.header.flags.hasRandomBehavior))) { + res.behaviors.push_back(std::make_shared(glm::vec3(0, 0, 0), 1.0f)); + res.header.addBehavior(SPLBehaviorType::Random); + } + + if (NOTIFY(ImGui::MenuItem("Magnet", nullptr, false, !res.header.flags.hasMagnetBehavior))) { + res.behaviors.push_back(std::make_shared(glm::vec3(0, 0, 0), 0.0f)); + res.header.addBehavior(SPLBehaviorType::Magnet); + } + + if (NOTIFY(ImGui::MenuItem("Spin", nullptr, false, !res.header.flags.hasSpinBehavior))) { + res.behaviors.push_back(std::make_shared(0.0f, SPLSpinAxis::Y)); + res.header.addBehavior(SPLBehaviorType::Spin); + } + + if (NOTIFY(ImGui::MenuItem("Collision Plane", nullptr, false, !res.header.flags.hasCollisionPlaneBehavior))) { + res.behaviors.push_back(std::make_shared(0.0f, 0.0f, SPLCollisionType::Bounce)); + res.header.addBehavior(SPLBehaviorType::CollisionPlane); + } + + if (NOTIFY(ImGui::MenuItem("Convergence", nullptr, false, !res.header.flags.hasConvergenceBehavior))) { + res.behaviors.push_back(std::make_shared(glm::vec3(0, 0, 0), 0.0f)); + res.header.addBehavior(SPLBehaviorType::Convergence); + } + + ImGui::EndPopup(); + } + + for (const auto& bhv : res.behaviors) { + ImGui::PushID(bhv.get()); + + bool context = false; + switch (bhv->type) { + case SPLBehaviorType::Gravity: + context = renderGravityBehaviorEditor(std::static_pointer_cast(bhv)); + break; + case SPLBehaviorType::Random: + context = renderRandomBehaviorEditor(std::static_pointer_cast(bhv)); + break; + case SPLBehaviorType::Magnet: + context = renderMagnetBehaviorEditor(std::static_pointer_cast(bhv)); + break; + case SPLBehaviorType::Spin: + context = renderSpinBehaviorEditor(std::static_pointer_cast(bhv)); + break; + case SPLBehaviorType::CollisionPlane: + context = renderCollisionPlaneBehaviorEditor(std::static_pointer_cast(bhv)); + break; + case SPLBehaviorType::Convergence: + context = renderConvergenceBehaviorEditor(std::static_pointer_cast(bhv)); + break; + } + + if (context) { + if (NOTIFY(ImGui::MenuItem("Delete"))) { + toRemove.push_back(bhv); + } + + ImGui::EndPopup(); + } + + ImGui::PopID(); + } + + for (const auto& r : toRemove) { + std::erase(res.behaviors, r); + res.header.removeBehavior(r->type); + } +} + +bool EditorInstance::renderGravityBehaviorEditor(const std::shared_ptr& gravity) { + static bool hovered = false; + ImGui::BeginHoverBorderChild("##gravityEditor", hovered, s_hoverAccentColor); + ImGui::TextUnformatted("Gravity"); + + NOTIFY(ImGui::DragFloat3("Magnitude", glm::value_ptr(gravity->magnitude), 0.001f)); + + ImGui::EndChild(); + + hovered = ImGui::IsItemHovered(); + return ImGui::BeginPopupContextItem("##behaviorContext"); +} + +bool EditorInstance::renderRandomBehaviorEditor(const std::shared_ptr& random) { + static bool hovered = false; + ImGui::BeginHoverBorderChild("##randomEditor", hovered, s_hoverAccentColor); + ImGui::TextUnformatted("Random"); + + NOTIFY(ImGui::DragFloat3("Magnitude", glm::value_ptr(random->magnitude), 0.001f)); + NOTIFY(ImGui::SliderFloat("Apply Interval", &random->applyInterval, 0, 5, "%.3fs", ImGuiSliderFlags_Logarithmic)); + + ImGui::EndChild(); + + hovered = ImGui::IsItemHovered(); + return ImGui::BeginPopupContextItem("##behaviorContext"); +} + +bool EditorInstance::renderMagnetBehaviorEditor(const std::shared_ptr& magnet) { + static bool hovered = false; + ImGui::BeginHoverBorderChild("##magnetEditor", hovered, s_hoverAccentColor); + ImGui::TextUnformatted("Magnet"); + + NOTIFY(ImGui::DragFloat3("Target", glm::value_ptr(magnet->target), 0.05f, -5.0f, 5.0f)); + NOTIFY(ImGui::SliderFloat("Force", &magnet->force, 0, 5, "%.3f", ImGuiSliderFlags_Logarithmic)); + + ImGui::EndChild(); + + hovered = ImGui::IsItemHovered(); + return ImGui::BeginPopupContextItem("##behaviorContext"); +} + +bool EditorInstance::renderSpinBehaviorEditor(const std::shared_ptr& spin) { + static bool hovered = false; + ImGui::BeginHoverBorderChild("##spinEditor", hovered, s_hoverAccentColor); + ImGui::TextUnformatted("Spin"); + + NOTIFY(ImGui::SliderAngle("Angle", &spin->angle, -180.0f, 180.0f)); + ImGui::TextUnformatted("Axis"); + ImGui::Indent(); + NOTIFY(ImGui::RadioButton("X", (int*)&spin->axis, 0)); + NOTIFY(ImGui::RadioButton("Y", (int*)&spin->axis, 1)); + NOTIFY(ImGui::RadioButton("Z", (int*)&spin->axis, 2)); + ImGui::Unindent(); + + ImGui::EndChild(); + + hovered = ImGui::IsItemHovered(); + return ImGui::BeginPopupContextItem("##behaviorContext"); +} + +bool EditorInstance::renderCollisionPlaneBehaviorEditor(const std::shared_ptr& collisionPlane) { + static bool hovered = false; + ImGui::BeginHoverBorderChild("##collisionPlaneEditor", hovered, s_hoverAccentColor); + ImGui::TextUnformatted("Collision Plane"); + + NOTIFY(ImGui::DragFloat("Height", &collisionPlane->y, 0.05f)); + NOTIFY(ImGui::SliderFloat("Elasticity", &collisionPlane->elasticity, 0, 2, "%.3f", ImGuiSliderFlags_Logarithmic)); + ImGui::TextUnformatted("Collision Type"); + ImGui::Indent(); + NOTIFY(ImGui::RadioButton("Kill", (int*)&collisionPlane->collisionType, 0)); + NOTIFY(ImGui::RadioButton("Bounce", (int*)&collisionPlane->collisionType, 1)); + ImGui::Unindent(); + + ImGui::EndChild(); + + hovered = ImGui::IsItemHovered(); + return ImGui::BeginPopupContextItem("##behaviorContext"); +} + +bool EditorInstance::renderConvergenceBehaviorEditor(const std::shared_ptr& convergence) { + static bool hovered = false; + ImGui::BeginHoverBorderChild("##convergenceEditor", hovered, s_hoverAccentColor); + ImGui::TextUnformatted("Convergence"); + + NOTIFY(ImGui::DragFloat3("Target", glm::value_ptr(convergence->target), 0.05f, -5.0f, 5.0f)); + NOTIFY(ImGui::SliderFloat("Force", &convergence->force, -5, 5, "%.3f", ImGuiSliderFlags_Logarithmic)); + + ImGui::EndChild(); + + hovered = ImGui::IsItemHovered(); + return ImGui::BeginPopupContextItem("##behaviorContext"); +} + +void EditorInstance::renderAnimationEditor(SPLResource& res) { + if (ImGui::IconButton(ICON_FA_CIRCLE_PLUS , "Add Animation...", AppColors::Turquoise)) { + ImGui::OpenPopup("##addAnimation"); + } + + if (ImGui::BeginPopup("##addAnimation")) { + if (NOTIFY(ImGui::MenuItem("Scale", nullptr, false, !res.header.flags.hasScaleAnim))) { + res.addScaleAnim(SPLScaleAnim::createDefault()); + ImGui::CloseCurrentPopup(); + } + + if (NOTIFY(ImGui::MenuItem("Color", nullptr, false, !res.header.flags.hasColorAnim))) { + res.addColorAnim(SPLColorAnim::createDefault()); + ImGui::CloseCurrentPopup(); + } + + if (NOTIFY(ImGui::MenuItem("Alpha", nullptr, false, !res.header.flags.hasAlphaAnim))) { + res.addAlphaAnim(SPLAlphaAnim::createDefault()); + ImGui::CloseCurrentPopup(); + } + + if (NOTIFY(ImGui::MenuItem("Texture", nullptr, false, !res.header.flags.hasTexAnim))) { + res.addTexAnim(SPLTexAnim::createDefault()); + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); + } + + if (res.scaleAnim) { + if (renderScaleAnimEditor(*res.scaleAnim)) { + res.removeScaleAnim(); + } + } + + if (res.colorAnim) { + if (renderColorAnimEditor(res, *res.colorAnim)) { + res.removeColorAnim(); + } + } + + if (res.alphaAnim) { + if (renderAlphaAnimEditor(*res.alphaAnim)) { + res.removeAlphaAnim(); + } + } + + if (res.texAnim) { + if (renderTexAnimEditor(*res.texAnim)) { + res.removeTexAnim(); + } + } +} + +bool EditorInstance::renderScaleAnimEditor(SPLScaleAnim& res) { + static bool hovered = false; + + if (!ImGui::CollapsingHeader("Scale Animation")) { + return false; + } + + ImGui::BeginHoverBorderChild("##scaleAnimEditor", hovered, s_hoverAccentColor); + + NOTIFY(ImGui::SliderFloat("Start Scale", &res.start, 0.01f, 10.0f, "%.3f", ImGuiSliderFlags_Logarithmic)); + NOTIFY(ImGui::SliderFloat("Mid Scale", &res.mid, 0.01f, 10.0f, "%.3f", ImGuiSliderFlags_Logarithmic)); + NOTIFY(ImGui::SliderFloat("End Scale", &res.end, 0.01f, 10.0f, "%.3f", ImGuiSliderFlags_Logarithmic)); + constexpr u8 min = 0; + constexpr u8 max = 255; + NOTIFY(ImGui::SliderScalar("In", ImGuiDataType_U8, &res.curve.in, &min, &max, "%u")); + NOTIFY(ImGui::SliderScalar("Out", ImGuiDataType_U8, &res.curve.out, &min, &max, "%u")); + NOTIFY(ImGui::Checkbox("Loop", &res.flags.loop)); + + res.plot(m_xAnimBuffer, m_yAnimBuffer1); + if (ImPlot::BeginPlot("##scaleAnimPlot", {-1, 0}, ImPlotFlags_CanvasOnly)) { + ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_AutoFit, ImPlotAxisFlags_AutoFit); + ImPlot::PlotLine("Scale", m_xAnimBuffer.data(), m_yAnimBuffer1.data(), m_xAnimBuffer.size()); + ImPlot::EndPlot(); + } + + ImGui::EndChild(); + + hovered = ImGui::IsItemHovered(); + + bool result = false; + if (ImGui::BeginPopupContextItem("##scaleAnimContext")) { + if (ImGui::MenuItem("Delete")) { + ImGui::CloseCurrentPopup(); + result = true; + } + + ImGui::EndPopup(); + } + + return result; +} + +bool EditorInstance::renderColorAnimEditor(const SPLResource& mainRes, SPLColorAnim& res) { + static bool hovered = false; + + if (!ImGui::CollapsingHeader("Color Animation")) { + return false; + } + + ImGui::BeginHoverBorderChild("##colorAnimEditor", hovered, s_hoverAccentColor); + + NOTIFY(ImGui::ColorEdit3("Start Color", glm::value_ptr(res.start))); + NOTIFY(ImGui::ColorEdit3("End Color", glm::value_ptr(res.end))); + constexpr u8 min = 0; + constexpr u8 max = 255; + NOTIFY(ImGui::SliderScalar("In", ImGuiDataType_U8, &res.curve.in, &min, &max, "%u")); + NOTIFY(ImGui::SliderScalar("Peak", ImGuiDataType_U8, &res.curve.peak, &min, &max, "%u")); + NOTIFY(ImGui::SliderScalar("Out", ImGuiDataType_U8, &res.curve.out, &min, &max, "%u")); + + NOTIFY(ImGui::Checkbox("Loop", &res.flags.loop)); + NOTIFY(ImGui::Checkbox("Interpolate", &res.flags.interpolate)); + NOTIFY(ImGui::Checkbox("Random Start Color", &res.flags.randomStartColor)); + + const auto drawList = ImGui::GetWindowDrawList(); + const auto startPos = ImGui::GetCursorScreenPos(); + const auto maxWidth = ImGui::GetContentRegionAvail().x; + auto pos = startPos; + + constexpr auto toImColor = [](const glm::vec3& color) { + return IM_COL32( + (u8)(color.r * 255), + (u8)(color.g * 255), + (u8)(color.b * 255), + 255 + ); + }; + + const auto in = res.curve.getIn(); + const auto peak = res.curve.getPeak(); + const auto out = res.curve.getOut(); + + const auto startCol = toImColor(res.start); + const auto peakCol = toImColor(mainRes.header.color); + const auto endCol = toImColor(res.end); + + if (in > 0.0f) { + const auto endPos = ImVec2(pos.x + in * maxWidth, pos.y + 20); + drawList->AddRectFilled(pos, endPos, startCol); + pos.x = endPos.x; + } + + if (res.flags.interpolate) { + const auto endPos = ImVec2(pos.x + (peak - in) * maxWidth, pos.y + 20); + drawList->AddRectFilledMultiColor( + pos, + endPos, + startCol, + peakCol, + peakCol, + startCol + ); + pos.x = endPos.x; + } else { + const auto endPos = ImVec2(pos.x + (peak - in) * maxWidth, pos.y + 20); + drawList->AddRectFilled(pos, endPos, toImColor(mainRes.header.color)); + pos.x = endPos.x; + } + + if (res.flags.interpolate) { + const auto endPos = ImVec2(pos.x + (out - peak) * maxWidth, pos.y + 20); + drawList->AddRectFilledMultiColor( + pos, + endPos, + peakCol, + endCol, + endCol, + peakCol + ); + pos.x = endPos.x; + } else { + const auto endPos = ImVec2(pos.x + (out - peak) * maxWidth, pos.y + 20); + drawList->AddRectFilled(pos, endPos, endCol); + pos.x = endPos.x; + } + + if (out < 1.0f) { + const auto endPos = ImVec2(pos.x + (1.0f - out) * maxWidth, pos.y + 20); + drawList->AddRectFilled(pos, endPos, endCol); + } + + ImGui::Dummy({ maxWidth, 20 }); + + ImGui::EndChild(); + + hovered = ImGui::IsItemHovered(); + + bool result = false; + if (ImGui::BeginPopupContextItem("##colorAnimContext")) { + if (ImGui::MenuItem("Delete")) { + ImGui::CloseCurrentPopup(); + result = true; + } + + ImGui::EndPopup(); + } + + return result; +} + +bool EditorInstance::renderAlphaAnimEditor(SPLAlphaAnim& res) { + static bool hovered = false; + + if (!ImGui::CollapsingHeader("Alpha Animation")) { + return false; + } + + ImGui::BeginHoverBorderChild("##alphaAnimEditor", hovered, s_hoverAccentColor); + + NOTIFY(ImGui::SliderFloat("Start Alpha", &res.alpha.start, 0, 1)); + NOTIFY(ImGui::SliderFloat("Mid Alpha", &res.alpha.mid, 0, 1)); + NOTIFY(ImGui::SliderFloat("End Alpha", &res.alpha.end, 0, 1)); + + res.alpha.start = (f32)((u8)(res.alpha.start * 31.0f)) / 31.0f; + res.alpha.mid = (f32)((u8)(res.alpha.mid * 31.0f)) / 31.0f; + res.alpha.end = (f32)((u8)(res.alpha.end * 31.0f)) / 31.0f; + + const auto minAlpha = std::min({ res.alpha.start, res.alpha.mid, res.alpha.end }); + const auto maxAlpha = std::max({ res.alpha.start, res.alpha.mid, res.alpha.end }); + + constexpr u8 min = 0; + constexpr u8 max = 255; + NOTIFY(ImGui::SliderScalar("In", ImGuiDataType_U8, &res.curve.in, &min, &max, "%u")); + NOTIFY(ImGui::SliderScalar("Out", ImGuiDataType_U8, &res.curve.out, &min, &max, "%u")); + NOTIFY(ImGui::SliderFloat("Random Range", &res.flags.randomRange, 0, 1)); + NOTIFY(ImGui::Checkbox("Loop", &res.flags.loop)); + + res.plotWith(m_xAnimBuffer, m_yAnimBuffer1, 0.0f); + + if (ImPlot::BeginPlot("##alphaAnimPlot", {-1, 0}, ImPlotFlags_CanvasOnly | ImPlotFlags_NoInputs)) { + ImPlot::SetupAxisLimits(ImAxis_X1, 0, 1); + ImPlot::SetupAxisLimits(ImAxis_Y1, minAlpha, maxAlpha); + + if (res.flags.randomRange > 0) { + res.plotWith(m_xAnimBuffer, m_yAnimBuffer2, res.flags.randomRange, 255); + ImPlot::PlotLine("Alpha Base", m_xAnimBuffer.data(), m_yAnimBuffer1.data(), m_xAnimBuffer.size()); + ImPlot::PlotShaded( + "Alpha Variance", + m_xAnimBuffer.data(), + m_yAnimBuffer2.data(), + m_yAnimBuffer1.data(), + m_xAnimBuffer.size(), + ImPlotSpec{ ImPlotProp_FillAlpha, 0.25f } + ); + } else { + ImPlot::PlotLine("Alpha", m_xAnimBuffer.data(), m_yAnimBuffer1.data(), m_xAnimBuffer.size()); + } + + ImPlot::EndPlot(); + } + + ImGui::EndChild(); + + hovered = ImGui::IsItemHovered(); + + bool result = false; + if (ImGui::BeginPopupContextItem("##alphaAnimContext")) { + if (ImGui::MenuItem("Delete")) { + ImGui::CloseCurrentPopup(); + result = true; + } + + ImGui::EndPopup(); + } + + return result; +} + +bool EditorInstance::renderTexAnimEditor(SPLTexAnim& res) { + static bool hovered = false; + + if (!ImGui::CollapsingHeader("Texture Animation")) { + return false; + } + + ImGui::BeginHoverBorderChild("##texAnimEditor", hovered, s_hoverAccentColor); + + NOTIFY(ImGui::SliderFloat("Step", &res.param.step, 0.01f, 1.0f)); + HELP(texAnimStep); + + NOTIFY(ImGui::Checkbox("Loop", &res.param.loop)); + HELP(texAnimLoop); + + NOTIFY(ImGui::Checkbox("Randomize Start", &res.param.randomizeInit)); + HELP(texAnimRandomizeInit); + + ImGui::SeparatorText("Textures"); + + const auto& textures = m_archive.getTextures(); + const auto popupId = ImGui::GetID("##texAnimTexturePicker"); + bool buttonContextOpened = false; + static size_t selectedTexture = 0; + + for (int i = 0; i < res.param.textureCount; i++) { + ImGui::PushID(i); + const auto& texture = textures[res.textures[i]]; + if (ImGui::ImageButton("##tex", texture.glTexture->getHandle(), { 32, 32 })) { + ImGui::OpenPopup(popupId); + selectedTexture = i; + } + if (ImGui::BeginPopupContextItem("##texContext")) { + buttonContextOpened = true; + ImGui::BeginDisabled(res.param.textureCount <= 1); + + if (NOTIFY(ImGui::MenuItem("Delete"))) { + res.removeTexture(i); + ImGui::CloseCurrentPopup(); + } + + ImGui::EndDisabled(); + ImGui::EndPopup(); + } + ImGui::PopID(); + } + + if (res.param.textureCount < SPLTexAnim::MAX_TEXTURES) { + if (ImGui::Button(ICON_FA_PLUS, { 32, 32 })) { + res.addTexture(); + } + } + + if (ImGui::BeginPopup("##texAnimTexturePicker")) { + for (int i = 0; i < textures.size(); ++i) { + ImGui::PushID(i); + const auto& texture = textures[i]; + if (NOTIFY(ImGui::ImageButton("##tex", (ImTextureID)texture.glTexture->getHandle(), { 32, 32 }))) { + res.textures[selectedTexture] = i; + ImGui::CloseCurrentPopup(); + } + + if (i % 4 != 3) { + ImGui::SameLine(); + } + ImGui::PopID(); + } + ImGui::EndPopup(); + } + + ImGui::EndChild(); + + hovered = ImGui::IsItemHovered(); + bool result = false; + + if (!buttonContextOpened && ImGui::BeginPopupContextItem("##texAnimContext")) { + if (ImGui::MenuItem("Delete")) { + ImGui::CloseCurrentPopup(); + result = true; + } + + ImGui::EndPopup(); + } + + return result; +} + +void EditorInstance::renderChildrenEditor(SPLResource& res) { + if (!res.childResource) { + ImGui::TextUnformatted("This resource does not have an associated child resource."); + if (ImGui::IconButton(ICON_FA_PLUS, "Add Child Resource", AppColors::Turquoise)) { + res.header.flags.hasChildResource = true; + res.childResource = SPLChildResource{ + .flags = { + .usesBehaviors = false, + .hasScaleAnim = false, + .hasAlphaAnim = false, + .rotationType = SPLChildRotationType::None, + .followEmitter = false, + .useChildColor = false, + .drawType = SPLDrawType::Billboard, + .polygonRotAxis = SPLPolygonRotAxis::Y, + .polygonReferencePlane = 0 + }, + .randomInitVelMag = 0.0f, + .endScale = 1.0f, + .lifeTime = 1.0f / SPLArchive::SPL_FRAMES_PER_SECOND, + .velocityRatio = 1.0f, + .scaleRatio = 1.0f, + .color = {}, + .misc = { + .emissionCount = 0, + .emissionDelay = 0, + .emissionInterval = 1.0f / SPLArchive::SPL_FRAMES_PER_SECOND, + .texture = 0, + .textureTileCountS = 1, + .textureTileCountT = 1, + .flipTextureS = false, + .flipTextureT = false, + .dpolFaceEmitter = false + } + }; + + pushHistory(); + } + + return; + } + + auto& child = res.childResource.value(); + constexpr f32 frameTime = 1.0f / (f32)SPLArchive::SPL_FRAMES_PER_SECOND; + + if (NOTIFY(ImGui::IconButton(ICON_FA_XMARK, "Remove Child Resource", AppColors::LightRed))) { + res.header.flags.hasChildResource = false; + res.childResource.reset(); + pushHistory(); + return; + } + + bool open = ImGui::TreeNodeEx("##parentSettings", ImGuiTreeNodeFlags_SpanAvailWidth); + ImGui::SameLine(); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() - 5); + ImGui::SeparatorText("Parent Settings"); + if (open) { + NOTIFY(ImGui::DragInt("Emission Amount", (int*)&child.misc.emissionCount, 1, 0, 20)); + HELP(emissionCount); + + NOTIFY(ImGui::SliderFloat("Emission Delay", &child.misc.emissionDelay, 0, 1)); + HELP(childEmissionDelay); + + NOTIFY(ImGui::SliderFloat("Emission Interval", &child.misc.emissionInterval, frameTime, 8.5f, "%.4fs")); + HELP(childEmissionInterval); + + u32 emissions = (u32)glm::ceil(res.header.particleLifeTime / child.misc.emissionInterval); + const u32 maxEmissions = (u32)(res.header.particleLifeTime / frameTime); + if (NOTIFY(ImGui::SliderInt("Emissions", (int*)&emissions, 1, maxEmissions))) { + child.misc.emissionInterval = res.header.particleLifeTime / (f32)emissions; + } + HELP(childEmissions); + + ImGui::TreePop(); + } + + open = ImGui::TreeNodeEx("##childSettings", ImGuiTreeNodeFlags_SpanAvailWidth); + ImGui::SameLine(); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() - 5); + ImGui::SeparatorText("Child Settings"); + if (open) { + auto& flags = child.flags; + auto& misc = child.misc; + + if (ImGui::BeginCombo("Draw Type", getDrawType(flags.drawType))) { + for (const auto [val, name] : detail::g_drawTypeNames) { + if (NOTIFY(ImGui::Selectable(name, flags.drawType == val))) { + flags.drawType = val; + } + } + + ImGui::EndCombo(); + } + HELP(drawType); + + const auto& textures = m_archive.getTextures(); + if (ImGui::ImageButton("##tex", (ImTextureID)textures[misc.texture].glTexture->getHandle(), {32, 32})) { + ImGui::OpenPopup("##childTexturePicker"); + } + ImGui::SameLine(); + ImGui::Text("Texture"); + HELP(childTexture); + + if (ImGui::BeginCombo("Child Rotation", getChildRotType(flags.rotationType))) { + for (const auto [val, name] : detail::g_childRotTypeNames) { + if (NOTIFY(ImGui::Selectable(name, flags.rotationType == val))) { + flags.rotationType = val; + } + } + + ImGui::EndCombo(); + } + HELP(childRotation); + + ImGui::BeginDisabled((u8)flags.drawType < (u8)SPLDrawType::Polygon); + + if (ImGui::BeginCombo("Polygon Rotation Axis", getPolygonRotAxis(flags.polygonRotAxis))) { + for (const auto [val, name] : detail::g_polygonRotAxisNames) { + if (NOTIFY(ImGui::Selectable(name, flags.polygonRotAxis == val))) { + flags.polygonRotAxis = val; + } + } + + ImGui::EndCombo(); + } + HELP(polygonRotAxis); + + ImGui::TextUnformatted("Polygon Reference Plane"); + HELP(polygonReferencePlane); + ImGui::Indent(); + NOTIFY(ImGui::RadioButton("XY", &flags.polygonReferencePlane, 0)); + NOTIFY(ImGui::RadioButton("XZ", &flags.polygonReferencePlane, 1)); + ImGui::Unindent(); + + ImGui::EndDisabled(); + + NOTIFY(ImGui::Checkbox("Uses Behaviors", &flags.usesBehaviors)); + HELP(usesBehaviors); + + NOTIFY(ImGui::Checkbox("Follow Emitter", &flags.followEmitter)); + HELP(followEmitter); + + NOTIFY(ImGui::SliderFloat("Lifetime", &child.lifeTime, frameTime, 60, "%.4fs", ImGuiSliderFlags_Logarithmic)); + HELP(particleLifeTime); + + NOTIFY(ImGui::SliderFloat("Initial Velocity Random", &child.randomInitVelMag, -3, 3, "%.3f", ImGuiSliderFlags_Logarithmic)); + HELP(randomInitVelMag); + + NOTIFY(ImGui::SliderFloat("Velocity Ratio", &child.velocityRatio, 0, 1)); + HELP(velocityRatio); + + NOTIFY(ImGui::SliderFloat("Scale Ratio", &child.scaleRatio, 1 / 64.0f, 255 / 64.0f)); + HELP(scaleRatio); + + NOTIFY(ImGui::ColorEdit3("Color", glm::value_ptr(child.color))); + HELP(color); + + NOTIFY(ImGui::Checkbox("Use Color", &flags.useChildColor)); + HELP(useChildColor); + + ImGui::TextUnformatted("Texture Tiling"); + HELP(textureTiling); + ImGui::Indent(); + int tileCount = 1 << misc.textureTileCountS; + NOTIFY(ImGui::SliderInt("S", &tileCount, 1, 8)); + misc.textureTileCountS = glm::log2(tileCount); + + tileCount = 1 << misc.textureTileCountT; + NOTIFY(ImGui::SliderInt("T", &tileCount, 1, 8)); + misc.textureTileCountT = glm::log2(tileCount); + ImGui::Unindent(); + + NOTIFY(ImGui::Checkbox("DPol Face Emitter", &misc.dpolFaceEmitter)); + HELP(dpolFaceEmitter); + + NOTIFY(ImGui::Checkbox("Flip X", &misc.flipTextureS)); + HELP(flipTextureX); + + NOTIFY(ImGui::Checkbox("Flip Y", &misc.flipTextureT)); + HELP(flipTextureY); + + NOTIFY(ImGui::Checkbox("Scale Animation", &flags.hasScaleAnim)); + HELP(hasScaleAnim); + if (flags.hasScaleAnim) { + NOTIFY(ImGui::SliderFloat("End Scale", &child.endScale, 0, 5, "%.3f", ImGuiSliderFlags_Logarithmic)); + HELP(endScale); + } + + NOTIFY(ImGui::Checkbox("Fade Out", &flags.hasAlphaAnim)); + HELP(hasAlphaAnim); + + if (ImGui::BeginPopup("##childTexturePicker")) { + for (int i = 0; i < textures.size(); ++i) { + ImGui::PushID(i); + const auto& texture = textures[i]; + if (NOTIFY(ImGui::ImageButton("##tex", (ImTextureID)texture.glTexture->getHandle(), {32, 32}))) { + child.misc.texture = i; + ImGui::CloseCurrentPopup(); + } + + if ((i + 1) % 4 != 0) { + ImGui::SameLine(); + } + + ImGui::PopID(); + } + + ImGui::EndPopup(); + } + + ImGui::TreePop(); + } +} + +void EditorInstance::renderDebugShapes(std::vector& renderers) { + const auto& resources = m_archive.getResources(); + const auto& settings = g_application->getEditor()->getSettings(); + + renderers.push_back(m_debugRenderer.get()); + + // Render edited emitter and collision plane if any + if (m_selectedResource != INVALID_RESOURCE) { + const auto& resource = resources[m_selectedResource]; + + if (settings.displayEditedEmitter) { + glm::vec3 axis; + switch (resource.header.flags.emissionAxis) { + case SPLEmissionAxis::X: + axis = { 1, 0, 0 }; + break; + case SPLEmissionAxis::Y: + axis = { 0, 1, 0 }; + break; + case SPLEmissionAxis::Z: + axis = { 0, 0, 1 }; + break; + case SPLEmissionAxis::Emitter: + axis = glm::normalize(resource.header.axis); + break; + } + + const glm::vec4& color = settings.editedEmitterColor; + switch (resource.header.flags.emissionType) { + case SPLEmissionType::Point: + m_debugRenderer->addBox(resource.header.emitterBasePos, { 0.2f, 0.2f, 0.2f }, color); + break; + case SPLEmissionType::SphereSurface: [[fallthrough]]; + case SPLEmissionType::Sphere: + m_debugRenderer->addSphere(resource.header.emitterBasePos, resource.header.radius, color); + break; + case SPLEmissionType::CircleBorder: [[fallthrough]]; + case SPLEmissionType::CircleBorderUniform: [[fallthrough]]; + case SPLEmissionType::Circle: + m_debugRenderer->addCircle(resource.header.emitterBasePos, axis, resource.header.radius, color); + break; + case SPLEmissionType::CylinderSurface: [[fallthrough]]; + case SPLEmissionType::Cylinder: + m_debugRenderer->addCylinder( + resource.header.emitterBasePos, + axis, + resource.header.length, + resource.header.radius, + color + ); + break; + case SPLEmissionType::HemisphereSurface: + case SPLEmissionType::Hemisphere: + m_debugRenderer->addHemisphere(resource.header.emitterBasePos, axis, resource.header.radius, color); + break; + } + } + + for (const auto& bhv : resource.behaviors) { + if (bhv->type == SPLBehaviorType::CollisionPlane) { + const auto colPlane = std::static_pointer_cast(bhv); + const glm::vec4 color = colPlane->collisionType == SPLCollisionType::Kill + ? settings.collisionPlaneKillColor + : settings.collisionPlaneBounceColor; + m_collisionGridRenderer->setColor(color); + m_collisionGridRenderer->setHeight(colPlane->y); + renderers.push_back(m_collisionGridRenderer.get()); + } + } + } + + const auto emitters = m_particleSystem.getEmitters(); + if (emitters.empty() || !settings.displayActiveEmitters) { + return; + } + + // Render emitters + for (const auto& emitter : m_particleSystem.getEmitters()) { + const auto resource = emitter->getResource(); + glm::vec3 axis; + + switch (resource->header.flags.emissionAxis) { + case SPLEmissionAxis::X: + axis = { 1, 0, 0 }; + break; + case SPLEmissionAxis::Y: + axis = { 0, 1, 0 }; + break; + case SPLEmissionAxis::Z: + axis = { 0, 0, 1 }; + break; + case SPLEmissionAxis::Emitter: + axis = emitter->getAxis(); + break; + } + + const glm::vec4& color = settings.activeEmitterColor; + switch (resource->header.flags.emissionType) { + case SPLEmissionType::Point: + m_debugRenderer->addBox(emitter->getPosition(), { 0.2f, 0.2f, 0.2f }, color); + break; + case SPLEmissionType::SphereSurface: [[fallthrough]]; + case SPLEmissionType::Sphere: + m_debugRenderer->addSphere(emitter->getPosition(), resource->header.radius, color); + break; + case SPLEmissionType::CircleBorder: [[fallthrough]]; + case SPLEmissionType::CircleBorderUniform: [[fallthrough]]; + case SPLEmissionType::Circle: + m_debugRenderer->addCircle(emitter->getPosition(), axis, resource->header.radius, color); + break; + case SPLEmissionType::CylinderSurface: [[fallthrough]]; + case SPLEmissionType::Cylinder: + m_debugRenderer->addCylinder( + emitter->getPosition(), + axis, + resource->header.length, + resource->header.radius, + color + ); + break; + case SPLEmissionType::HemisphereSurface: [[fallthrough]]; + case SPLEmissionType::Hemisphere: + m_debugRenderer->addHemisphere(emitter->getPosition(), axis, resource->header.radius, color); + break; + } + } +} + +void EditorInstance::helpPopup(std::string_view text) const { + ImGui::SameLine(); + ImGui::TextDisabled("(?)"); + + if (ImGui::BeginItemTooltip()) { + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); + ImGui::TextUnformatted(text.data(), text.data() + text.size()); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } +} diff --git a/src/editor/editor_instance.h b/src/editor/editor_instance.h index f2e494b..5f87c18 100644 --- a/src/editor/editor_instance.h +++ b/src/editor/editor_instance.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include // std::pair @@ -7,30 +8,58 @@ #include "camera.h" #include "gfx/gl_viewport.h" +#include "gfx/gl_texture.h" #include "particle_system.h" #include "renderer.h" +#include "debug_renderer.h" +#include "grid_renderer.h" #include "editor_history.h" #include "spl/spl_archive.h" +#include "spl/spl_resource.h" +#include "base_editor.h" -class EditorInstance { +enum class EmitterSpawnType { + SingleShot, + Looped, + Interval +}; + + +class EditorInstance : public BaseEditor { public: explicit EditorInstance(const std::filesystem::path& path, bool isTemp = false, bool isRecovered = false); explicit EditorInstance(std::string name, size_t narcIndex, std::span data, bool isTemp = false); - EditorInstance(bool isTemp = false); + explicit EditorInstance(bool isTemp = false); - std::pair render(); + std::pair render() override; void renderParticles(const std::vector& renderers); - void updateParticles(float deltaTime); - void handleEvent(const SDL_Event& event); + void renderDebugShapes(std::vector& renderers); + void update(float deltaTime) override; + [[nodiscard]] bool isAnimating() const override { + return !m_particleSystem.getEmitters().empty(); + } + void handleEvent(const SDL_Event& event) override; + + void renderStats() override; + void renderPanels() override; + void renderResourcePicker(bool* open); + void renderTextureManager(bool* open); + void renderResourceEditor(bool* open); + + void playEmitter(EmitterSpawnType spawnType); + void playAllEmitters(EmitterSpawnType spawnType); + void killEmitters(); + void resetCamera(); void useModernRenderer(); void useLegacyRenderer(); - bool notifyClosing(); + [[nodiscard]] bool notifyClosing() override; void notifyResourceChanged(size_t index); bool valueChanged(bool changed); - bool isModified() const { + + [[nodiscard]] bool isModified() const override { return m_modified; } @@ -38,19 +67,23 @@ class EditorInstance { m_particleSystem.setMaxParticles(maxParticles); } - void makePermanent() { + void makePermanent() override { m_isTemp = false; } - bool isTemp() const { + [[nodiscard]] bool isTemp() const override { return m_isTemp; } - bool isRecovered() const { + [[nodiscard]] bool isRecovered() const override { return m_isRecovered; } - bool isNarc() const { + [[nodiscard]] EditorType getType() const override { + return EditorType::Particle; + } + + [[nodiscard]] bool isNarc() const { return m_narcIndex != std::numeric_limits::max(); } @@ -58,10 +91,10 @@ class EditorInstance { void deleteResource(size_t index); void addResource(); - void save(); - void saveAs(const std::filesystem::path& path); + void save() override; + void saveAs(const std::filesystem::path& path) override; void saveTo(const std::filesystem::path& path); - void saveBackup(); + void saveBackup() override; void pushHistory(); @@ -79,31 +112,37 @@ class EditorInstance { std::filesystem::path getRelativePath() const; std::filesystem::path getBackupPath() const; - std::string getName() const; + [[nodiscard]] std::string getName() const override; void rename(const std::filesystem::path& to) { m_path = to; } - void rename(const std::string& to) { + void rename(std::string_view to) override { if (isNarc()) { m_narcMemberName = to; } } - const std::filesystem::path& getPath() const { + [[nodiscard]] const std::filesystem::path& getPath() const override { return m_path; } - size_t getNarcIndex() const { - return m_narcIndex; + [[nodiscard]] std::optional getNarcIndex() const override { + return isNarc() ? std::optional{ m_narcIndex } : std::nullopt; } SPLArchive& getArchive() { return m_archive; } - u64 getUniqueID() const { + [[nodiscard]] size_t getSelectedResource() const { + return m_selectedResource; + } + + void ensureValidSelection(); + + [[nodiscard]] u64 getUniqueID() const override { return m_uniqueID; } @@ -119,31 +158,113 @@ class EditorInstance { m_updateProj = true; } - using Clock = std::chrono::steady_clock; - - std::chrono::time_point getLastBackupTime() const { + [[nodiscard]] Clock::time_point getLastBackupTime() const override { return m_lastBackupTime; } private: + void createRenderers(); + + void renderHeaderEditor(SPLResourceHeader& header); + void renderBehaviorEditor(SPLResource& res); + bool renderGravityBehaviorEditor(const std::shared_ptr& gravity); + bool renderRandomBehaviorEditor(const std::shared_ptr& random); + bool renderMagnetBehaviorEditor(const std::shared_ptr& magnet); + bool renderSpinBehaviorEditor(const std::shared_ptr& spin); + bool renderCollisionPlaneBehaviorEditor(const std::shared_ptr& collisionPlane); + bool renderConvergenceBehaviorEditor(const std::shared_ptr& convergence); + + void renderAnimationEditor(SPLResource& res); + bool renderScaleAnimEditor(SPLScaleAnim& res); + bool renderColorAnimEditor(const SPLResource& mainRes, SPLColorAnim& res); + bool renderAlphaAnimEditor(SPLAlphaAnim& res); + bool renderTexAnimEditor(SPLTexAnim& res); + void renderChildrenEditor(SPLResource& res); + + void helpPopup(std::string_view text) const; + + void openTempTexture(const std::filesystem::path& path, size_t destIndex = -1); + void discardTempTexture(); + void destroyTempTexture(); + void importTempTexture(); + + static bool palettizeTexture( + const u8* data, + s32 width, + s32 height, + const TextureImportSpecification& spec, + std::vector& outData, + std::vector& outPalette + ); + static void quantizeTexture(const u8* data, s32 width, s32 height, const TextureImportSpecification& spec, u8* out); + + struct TempTexture { + std::string path; + u8* data; + u8* quantized; + s32 width; + s32 height; + s32 channels; + TextureFormat suggestedFormat; + bool suggestedFormatUncompressed; + TextureImportSpecification spec; + TextureConversionPreference preference; + std::unique_ptr texture; + bool isValidSize; + size_t destIndex = -1; + }; + + static constexpr auto INVALID_RESOURCE = std::numeric_limits::max(); + std::filesystem::path m_path; size_t m_narcIndex = -1; std::string m_narcMemberName; SPLArchive m_archive; - GLViewport m_viewport = GLViewport({ 800, 600 }); + GLViewport m_viewport = GLViewport({800, 600}); ParticleSystem m_particleSystem; + std::unique_ptr m_debugRenderer; + std::shared_ptr m_collisionGridRenderer; Camera m_camera; EditorHistory m_history; - std::chrono::time_point m_lastBackupTime; + Clock::time_point m_lastBackupTime; - size_t m_selectedResource = -1; + size_t m_selectedResource = INVALID_RESOURCE; SPLResource m_resourceBefore; - glm::vec2 m_size = { 800, 600 }; + // Texture manager / import state + TempTexture* m_tempTexture = nullptr; + float m_tempTextureScale = 1.0f; + bool m_discardTempTexture = false; + size_t m_selectedTexture = -1; + bool m_deleteSelectedTexture = false; + + // Stats panel feedback animation + u32 m_emitterFlashColor = 0; + size_t m_lastEmitterCount = 0; + + // Interval-spawn tasks owned by this editor + struct EmitterSpawnTask { + u64 resourceIndex; + Clock::time_point time; + std::chrono::duration interval; + }; + std::vector m_emitterTasks; + EmitterSpawnType m_emitterSpawnType = EmitterSpawnType::SingleShot; + float m_emitterInterval = 1.0f; // seconds + + // Resource picker: last selection (for selection-change animations) + size_t m_lastPickerSelection = INVALID_RESOURCE; + + // Scratch buffers for animation plots + std::array m_xAnimBuffer{}; + std::array m_yAnimBuffer1{}; + std::array m_yAnimBuffer2{}; + + glm::vec2 m_size = {800, 600}; + u64 m_uniqueID; bool m_updateProj; bool m_isTemp = false; bool m_modified = false; // Has the file been modified? bool m_isRecovered = false; // Was this instance recovered from a backup? - u64 m_uniqueID; }; diff --git a/src/editor/project_manager.cpp b/src/editor/project_manager.cpp index a0326c3..3e07505 100644 --- a/src/editor/project_manager.cpp +++ b/src/editor/project_manager.cpp @@ -293,7 +293,7 @@ void ProjectManager::openNarcProject(const fs::path& path) { m_isNarc = true; } -void ProjectManager::closeEditor(const std::shared_ptr& editor, bool force) { +void ProjectManager::closeEditor(const std::shared_ptr& editor, bool force) { if (!force && editor->isModified()) { m_unsavedEditors.push_back(editor); return; @@ -333,7 +333,7 @@ void ProjectManager::saveAllEditors() const { void ProjectManager::saveAllNarcEditors() const { for (const auto& editor : m_openEditors) { - if (editor->isNarc()) { + if (editor->getNarcIndex().has_value()) { editor->save(); } } @@ -750,7 +750,9 @@ void ProjectManager::renderNarcFile(NarcEntry& entry, size_t index) { ImGui::PushStyleColor(ImGuiCol_Text, AppColors::PastelYellow); } - const bool selected = m_activeEditor ? m_activeEditor->getNarcIndex() == index : false; + const bool selected = m_activeEditor + ? m_activeEditor->getNarcIndex() == index + : false; if (ImGui::Selectable(entry.name.c_str(), selected, ImGuiSelectableFlags_AllowDoubleClick) && isSplFile) { if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { openEditor(index); diff --git a/src/editor/project_manager.h b/src/editor/project_manager.h index 81974af..5375c50 100644 --- a/src/editor/project_manager.h +++ b/src/editor/project_manager.h @@ -1,6 +1,7 @@ #pragma once -#include "editor_instance.h" +#include "base_editor.h" +#include "types.h" #include #include @@ -21,6 +22,9 @@ #include #include +#include "editor_instance.h" + + class Editor; // Project is a bit of an overstatement here, because it's really just a directory @@ -33,7 +37,7 @@ class ProjectManager { void openEditor(); // Create an editor without an associated file void openTempEditor(const std::filesystem::path& path); void openNarcProject(const std::filesystem::path& path); - void closeEditor(const std::shared_ptr& editor, bool force = false); + void closeEditor(const std::shared_ptr& editor, bool force = false); void closeTempEditor(); void closeAllEditors(); void saveAllEditors() const; @@ -45,7 +49,7 @@ class ProjectManager { }); } - std::shared_ptr getEditor(const std::filesystem::path& path) const { + std::shared_ptr getEditor(const std::filesystem::path& path) const { const auto it = std::ranges::find_if(m_openEditors, [&path](const auto& editor) { return editor->getPath() == path; }); @@ -53,7 +57,7 @@ class ProjectManager { return it != m_openEditors.end() ? *it : nullptr; } - std::shared_ptr getEditor(u64 uniqueID) const { + std::shared_ptr getEditor(u64 uniqueID) const { const auto it = std::ranges::find_if(m_openEditors, [uniqueID](const auto& editor) { return editor->getUniqueID() == uniqueID; }); @@ -61,7 +65,7 @@ class ProjectManager { return it != m_openEditors.end() ? *it : nullptr; } - std::shared_ptr getNarcEditor(size_t index) { + std::shared_ptr getNarcEditor(size_t index) { const auto it = std::ranges::find_if(m_openEditors, [index](const auto& editor) { return editor->getNarcIndex() == index; }); @@ -72,15 +76,20 @@ class ProjectManager { void open(); void render(); - std::span> getOpenEditors() const { + std::span> getOpenEditors() const { return m_openEditors; } - const std::shared_ptr& getActiveEditor() const { + const std::shared_ptr& getActiveEditor() const { return m_activeEditor; } - void setActiveEditor(const std::shared_ptr& editor) { + template requires std::is_base_of_v + std::shared_ptr getActiveEditorAs() const { + return std::dynamic_pointer_cast(m_activeEditor); + } + + void setActiveEditor(const std::shared_ptr& editor) { m_activeEditor = editor; } @@ -120,7 +129,7 @@ class ProjectManager { }); } - std::span> getUnsavedEditors() { + std::span> getUnsavedEditors() { return m_unsavedEditors; } @@ -173,6 +182,7 @@ class ProjectManager { }; void buildDirectoryCache(const std::filesystem::path& directory); + void ensureDirectoryCached(const std::filesystem::path& directory) { if (!m_directoryCache.contains(directory)) { buildDirectoryCache(directory); @@ -202,7 +212,7 @@ class ProjectManager { explicit FileWatchListener(ProjectManager* projManager) : m_projManager(projManager) {} void handleFileAction(efsw::WatchID watchId, const std::string& dir, - const std::string& filename, efsw::Action action, std::string oldFilename) override { + const std::string& filename, efsw::Action action, std::string oldFilename) override { if (!m_projManager) { return; } @@ -284,12 +294,12 @@ class ProjectManager { std::unique_ptr m_listener; std::unordered_map, PathHash> m_directoryCache; - std::vector> m_openEditors; - std::shared_ptr m_activeEditor; + std::vector> m_openEditors; + std::shared_ptr m_activeEditor; bool m_forceActivate = false; // Unsaved changes data - std::vector> m_unsavedEditors; + std::vector> m_unsavedEditors; bool m_open = true; bool m_hideOtherFiles = false; @@ -312,7 +322,7 @@ class ProjectManager { std::atomic m_fuzzyIndexBuilding = false; bool m_fuzzyOpen = false; bool m_fuzzyQueryDirty = false; - char m_fuzzyQuery[256] = { 0 }; + char m_fuzzyQuery[256] = {0}; std::vector m_fuzzyResults; std::string m_prevFuzzyQuery; std::vector m_prevCandidates; @@ -332,6 +342,7 @@ class ProjectManager { uint64_t timestamp; uint64_t fileCount; }; + struct IndexEntry { uint16_t relLen; uint16_t filenameLen; diff --git a/src/main.cpp b/src/main.cpp index d7f413c..e423cab 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,67 +1,26 @@ #include "application.h" +#include "cli.h" -#include -#include +#include -int nitroefx_main(int argc, char** argv) { - constexpr auto EXPORT_CMD = "export"; - constexpr auto INFO_CMD = "info"; - const auto isSubCmd = [](std::string_view arg) { - return arg == EXPORT_CMD || arg == INFO_CMD; - }; - - if (argc <= 1 || std::filesystem::exists(argv[1])) { - // No subcommands used, launch GUI - Application app; - return app.run(argc, argv); - } - - argparse::ArgumentParser program("nitroefx", Application::VERSION); - - program.add_argument("--apply-update").nargs(3).help("Internal use only."); - program.add_argument("--relaunch").flag().help("Internal use only."); - - argparse::ArgumentParser export_cmd(EXPORT_CMD); - export_cmd.add_argument("path").help("Path to a .spa file").required().nargs(1); - export_cmd.add_argument("-i", "--index").help("Texture index to export").nargs(argparse::nargs_pattern::at_least_one).scan<'i', int>(); - export_cmd.add_argument("-o", "--output").help("Output path." - " Can be a directory (always) or a file path (only when used with a single index -i)").nargs(1); - - export_cmd.add_description("\nExamples:\n" - " nitroefx export path/to/file.spa\n" - " nitroefx export -i 1 3 4 -o /output/directory path/to/file.spa \n" - " nitroefx export -i 0 -o /output/directory/texture.png path/to/file.spa \n"); - - argparse::ArgumentParser info_cmd(INFO_CMD); - info_cmd.add_argument("path").help("Path to a .spa file").required().nargs(1); - info_cmd.add_description("\nExamples:\n" - " nitroefx info path/to/file.spa\n"); - - program.add_subparser(export_cmd); - program.add_subparser(info_cmd); - - try { - program.parse_args(argc, argv); - } catch (const std::runtime_error &err) { - spdlog::error("Error parsing arguments: {}", err.what()); - std::exit(1); - } - - if (program.is_used("--apply-update")) { - const auto args = program.get>("--apply-update"); - - const auto srcPath = std::filesystem::path(args[0]); - const auto dstPath = std::filesystem::path(args[1]); - const auto parentPid = std::stoul(args[2]); - const auto relaunch = program.get("--relaunch"); - - return Application::update(srcPath, dstPath, parentPid, relaunch); - } - - Application app; - if (!isSubCmd(argv[1])) { - return app.run(argc, argv); - } +namespace { +template struct overloaded : Ts... { using Ts::operator()...; }; +} - return app.runCli(program); +int nitroefx_main(int argc, char** argv) { + return std::visit(overloaded{ + [](const cli::GuiCommand& cmd) { + Application app; + return app.run(cmd.openPath); + }, + [](const cli::ExportCommand& cmd) { + return cli::runExport(cmd); + }, + [](const cli::InfoCommand& cmd) { + return cli::runInfo(cmd); + }, + [](const cli::UpdateCommand& cmd) { + return Application::update(cmd.src, cmd.dst, cmd.pid, cmd.relaunch); + }, + }, cli::parse(argc, argv)); }