diff --git a/src/Editor/Panels/EditorToolbarPanel.cpp b/src/Editor/Panels/EditorToolbarPanel.cpp index 8aa3797..3a43db8 100644 --- a/src/Editor/Panels/EditorToolbarPanel.cpp +++ b/src/Editor/Panels/EditorToolbarPanel.cpp @@ -10,6 +10,7 @@ #include "Editor/EditorPersistence.h" #include "Editor/ExportBuilder.h" #include "Editor/PlayerLauncher.h" +#include "Engine/EditorExceptionGuard.h" #include "Game/Game.h" #include "Game/GameConfig.h" #include "imgui.h" @@ -106,10 +107,14 @@ void DrawToolbar(Game* game, bool& showProjectSelector, bool& openSaveLayoutModa // Disabled while already running so the button reads as the current state. ImGui::BeginDisabled(!engineOptions.isPaused); if (ImGui::Button("Play")) { + // ReloadScene runs the scene's Lua, which can throw. Guard it so a broken scene logs + + // stays paused instead of crashing the editor (toolbar handlers run during ImGui render, + // outside FrameLoop's per-frame Update guard). See EditorExceptionGuard.h. + bool started = true; if (!game->IsSceneRunning() && hasScene) { - game->ReloadScene(); + started = RunEditorGuarded(gameConfig, "ReloadScene", [&] { game->ReloadScene(); }); } - engineOptions.isPaused = false; + engineOptions.isPaused = !started; } ImGui::EndDisabled(); ImGui::SameLine(); @@ -126,7 +131,7 @@ void DrawToolbar(Game* game, bool& showProjectSelector, bool& openSaveLayoutModa } ImGui::SameLine(); if (ImGui::Button("Stop")) { - game->StopScene(); + RunEditorGuarded(gameConfig, "StopScene", [&] { game->StopScene(); }); engineOptions.isPaused = true; // back to the default ready-but-paused state } diff --git a/src/Engine/EditorExceptionGuard.h b/src/Engine/EditorExceptionGuard.h new file mode 100644 index 0000000..da4579d --- /dev/null +++ b/src/Engine/EditorExceptionGuard.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#include "Game/GameConfig.h" +#include "General/Logger.h" + +namespace octarine::editor { + +// Run a piece of game-driving work (`fn`) that may execute project/Lua code and could throw. +// +// In an editor session a project bug must not take down the whole editor process. When `fn` throws +// there, the exception is caught, logged (so it surfaces in the editor console), and the running +// game is paused — leaving the editor alive so the author can read the error, fix the script, and +// hit Play again. Pausing on catch also stops a throwing per-frame tick from re-raising the same +// exception every frame. +// +// Outside an editor session (the standalone player, headless bench/bake tooling) the exception is +// left to propagate exactly as before: the player runs in its own process, so a crash there never +// reaches the editor, and we don't want to silently swallow failures in shipped/CI paths. +// +// `context` is a short human label for the failing phase (e.g. "Update", "Input", "ReloadScene") +// included in the log line. Returns true when `fn` completed without throwing, false when it was +// caught. +template +bool RunEditorGuarded(GameConfig& config, const char* context, Fn&& fn) { + if (!config.IsEditorMode()) { + fn(); + return true; + } + + try { + fn(); + return true; + } catch (const std::exception& ex) { + Logger::Error(std::string("[editor] Game exception in ") + context + ": " + ex.what() + " — execution paused."); + } catch (...) { + Logger::Error(std::string("[editor] Unknown game exception in ") + context + " — execution paused."); + } + config.GetEngineOptions().isPaused = true; + return false; +} + +} // namespace octarine::editor diff --git a/src/Engine/FrameLoop.cpp b/src/Engine/FrameLoop.cpp index 76db860..84ca171 100644 --- a/src/Engine/FrameLoop.cpp +++ b/src/Engine/FrameLoop.cpp @@ -10,6 +10,7 @@ #include "AssetManager/AssetManager.h" #include "Components/ViewportInfo.h" #include "ECS/Registry.h" +#include "Engine/EditorExceptionGuard.h" #include "Engine/EngineContext.h" #include "Engine/EngineRuntime.h" #include "EventBus/EventBus.h" @@ -83,6 +84,11 @@ void FrameLoop::ProcessInput() { PROFILE_NAMED_SCOPE("Game::ProcessInput"); SDL_Event event; + // Mouse/key event emission fans out to game code (Lua input handlers, UIButtonSystem callbacks), + // so it is guarded the same way as the per-frame Update — a throwing handler pauses rather than + // crashes the editor. See FrameLoop::Update / EditorExceptionGuard.h. + auto& gameConfig = registry_->Get(); + while (SDL_PollEvent(&event)) { #ifdef OCTARINE_WITH_IMGUI ImGui_ImplSDL3_ProcessEvent(&event); @@ -102,17 +108,20 @@ void FrameLoop::ProcessInput() { case SDL_EVENT_KEY_DOWN: case SDL_EVENT_KEY_UP: { KeyInputEvent keyInputEvent = GetKeyInputEvent(&event.key); - event_bus_->EmitEvent(keyInputEvent); + octarine::editor::RunEditorGuarded(gameConfig, "Input", + [&] { event_bus_->EmitEvent(keyInputEvent); }); break; } case SDL_EVENT_MOUSE_BUTTON_DOWN: case SDL_EVENT_MOUSE_BUTTON_UP: { SDL_MouseButtonEvent mouseButtonEvent = event.button; - event_bus_->EmitEvent(mouseButtonEvent); + octarine::editor::RunEditorGuarded(gameConfig, "Input", + [&] { event_bus_->EmitEvent(mouseButtonEvent); }); break; } case SDL_EVENT_MOUSE_WHEEL: { - event_bus_->EmitEvent(event.wheel.x, event.wheel.y); + octarine::editor::RunEditorGuarded( + gameConfig, "Input", [&] { event_bus_->EmitEvent(event.wheel.x, event.wheel.y); }); break; } case SDL_EVENT_WINDOW_RESIZED: @@ -143,7 +152,8 @@ void FrameLoop::Update(const float deltaTime) { } #endif - auto& options = registry_->Get().GetEngineOptions(); + auto& gameConfig = registry_->Get(); + auto& options = gameConfig.GetEngineOptions(); // Master volume + mute live at the mixer level so they apply to every track (including loops // already playing). Synced every frame — and outside the pause gate — so toggling mute reacts @@ -174,7 +184,11 @@ void FrameLoop::Update(const float deltaTime) { #endif if (!options.isPaused || options.stepFrame) { - registry_->Update(deltaTime * options.timeScale); + // The per-frame tick runs every game system, including ScriptSystem's Lua calls. In an editor + // session an uncaught exception here is caught + paused instead of crashing the editor; in the + // player/headless paths it propagates unchanged. See EditorExceptionGuard.h. + const float scaledDelta = deltaTime * options.timeScale; + octarine::editor::RunEditorGuarded(gameConfig, "Update", [&] { registry_->Update(scaledDelta); }); options.stepFrame = false; } else { // If paused, we might still want to clear some per-frame signals so they don't get stuck.