diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7d79b84..97cd066 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -203,6 +203,9 @@ endif () # ----------------------------------------------------------------------------- set(OCTARINE_ENGINE_SOURCES Engine/EngineBootstrap.cpp + Engine/EngineRuntime.cpp + Engine/FrameLoop.cpp + Engine/SceneLoader.cpp Game/Game.cpp ) # DevListenServer (Stage 6): TCP listener for the dev iterate loop. Available in editor + player diff --git a/src/Engine/EngineRuntime.cpp b/src/Engine/EngineRuntime.cpp new file mode 100644 index 0000000..b1baf77 --- /dev/null +++ b/src/Engine/EngineRuntime.cpp @@ -0,0 +1,81 @@ +#include "Engine/EngineRuntime.h" + +#include +#include + +#include + +#include "General/Logger.h" + +#ifdef OCTARINE_WITH_IMGUI +#include "imgui.h" +#include "imgui_impl_sdl3.h" +#include "imgui_impl_sdlrenderer3.h" +#endif + +bool EngineRuntime::InitSubsystems() { + constexpr auto SDL_INI = SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_EVENTS | SDL_INIT_GAMEPAD; + + if (!SDL_Init(SDL_INI)) { + Logger::Error("SDL_Init Error: " + std::string(SDL_GetError())); + return false; + } + + if (!TTF_Init()) { + Logger::Error("TTF_Init Error: " + std::string(SDL_GetError())); + return false; + } + + return true; +} + +bool EngineRuntime::CreateWindow(const std::string& title, int width, int height) { + SDL_CreateWindowAndRenderer(title.c_str(), width, height, SDL_WINDOW_RESIZABLE, &window_, &sdl_renderer_); + + if (!window_) { + Logger::Error("SDL_CreateWindow Error: " + std::string(SDL_GetError())); + return false; + } + + if (!sdl_renderer_) { + Logger::Error("SDL_CreateRenderer Error: " + std::string(SDL_GetError())); + return false; + } + + return true; +} + +#ifdef OCTARINE_WITH_IMGUI +void EngineRuntime::InitImGui(const char* iniPath) { + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGuiIO& io = ImGui::GetIO(); + io.IniFilename = iniPath; + + io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; + io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; + io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; + + ImGui_ImplSDL3_InitForSDLRenderer(window_, sdl_renderer_); + ImGui_ImplSDLRenderer3_Init(sdl_renderer_); +} +#endif + +void EngineRuntime::Shutdown() { + if (sdl_renderer_) { +#ifdef OCTARINE_WITH_IMGUI + ImGui_ImplSDLRenderer3_Shutdown(); + ImGui_ImplSDL3_Shutdown(); + ImGui::DestroyContext(); +#endif + SDL_DestroyRenderer(sdl_renderer_); + sdl_renderer_ = nullptr; + } + + if (window_) { + SDL_DestroyWindow(window_); + window_ = nullptr; + } + + SDL_Quit(); +} diff --git a/src/Engine/EngineRuntime.h b/src/Engine/EngineRuntime.h new file mode 100644 index 0000000..564fb43 --- /dev/null +++ b/src/Engine/EngineRuntime.h @@ -0,0 +1,53 @@ +#pragma once + +#include + +#include + +class GameConfig; +class Renderer; + +// Owns the platform/runtime lifecycle that Game::Initialize and Game::Destroy used to inline: +// SDL + TTF subsystem init, the window/renderer pair, the ImGui backend, and orderly teardown. +// It deliberately knows nothing about GameConfig loading, project resolution, or editor +// persistence — Game still drives those and feeds this the window title/size it derived. The +// off-screen scene render target stays owned by Renderer (Stage 7); Game sequences DestroyScene +// around Shutdown so the SDL renderer is still live when the scene target is freed. +class EngineRuntime { + public: + EngineRuntime() = default; + + EngineRuntime(const EngineRuntime&) = delete; + EngineRuntime& operator=(const EngineRuntime&) = delete; + EngineRuntime(EngineRuntime&&) = delete; + EngineRuntime& operator=(EngineRuntime&&) = delete; + + ~EngineRuntime() = default; + + // SDL_Init(video/audio/events/gamepad) + TTF_Init. Returns false (logging the SDL error) on + // failure so Game::Initialize can abort. + [[nodiscard]] bool InitSubsystems(); + + // Create the window + accelerated renderer (resizable). Returns false on failure; on success + // Window()/SdlRenderer() are non-null. + [[nodiscard]] bool CreateWindow(const std::string& title, int width, int height); + +#ifdef OCTARINE_WITH_IMGUI + // Stand up the ImGui context + SDL3/SDLRenderer3 backends and the docking/nav config flags. + // `iniPath` becomes io.IniFilename (must outlive the context). Fonts/style are the caller's + // job afterwards (editor builds rebuild the editor font; player builds add the default). + void InitImGui(const char* iniPath); +#endif + + // Tear down the ImGui backend (if built) and destroy the renderer, window, and SDL subsystems. + // Safe to call when CreateWindow was never reached (no-ops on null handles). Call AFTER the + // owner has released anything that needs a live SDL renderer (scene target, GPU textures). + void Shutdown(); + + [[nodiscard]] SDL_Window* Window() const { return window_; } + [[nodiscard]] SDL_Renderer* SdlRenderer() const { return sdl_renderer_; } + + private: + SDL_Window* window_ = nullptr; + SDL_Renderer* sdl_renderer_ = nullptr; +}; diff --git a/src/Engine/FrameLoop.cpp b/src/Engine/FrameLoop.cpp new file mode 100644 index 0000000..863813d --- /dev/null +++ b/src/Engine/FrameLoop.cpp @@ -0,0 +1,267 @@ +#include "Engine/FrameLoop.h" + +#include +#include + +#include + +#include "Components/ViewportInfo.h" +#include "ECS/Registry.h" +#include "Engine/EngineContext.h" +#include "Engine/EngineRuntime.h" +#include "EventBus/EventBus.h" +#include "Events/KeyInputEvent.h" +#include "Events/MouseInputEvent.h" +#include "Events/MouseWheelEvent.h" +#include "Game/Game.h" +#include "Game/GameConfig.h" +#include "General/Constants.h" +#include "General/PerfUtils.h" +#include "Lua/HotReload/ScriptHotReload.h" +#include "Renderer/RenderQueue.h" +#include "Renderer/Renderer.h" +#include "Systems/DrawColliderSystem.h" +#include "Systems/InputSystem.h" + +#ifndef OCTARINE_SHIPPED +#include "Dev/DevListenServer.h" +#endif + +#ifdef OCTARINE_WITH_IMGUI +#include "Systems/RenderDebugGUISystem.h" +#include "imgui.h" +#include "imgui_impl_sdl3.h" +#endif + +FrameLoop::FrameLoop(Game* game, Registry* registry, EventBus* eventBus, Renderer* renderer, EngineRuntime* runtime, + sol::state& lua) + : game_(game), registry_(registry), event_bus_(eventBus), renderer_(renderer), runtime_(runtime), lua_(lua) { + // Pre-build the debug-collider query once so we don't allocate per render frame. + collider_query_ = registry_->CreateQuery(); +} + +void FrameLoop::Begin() { milliseconds_previous_frame_ = SDL_GetTicks(); } + +void FrameLoop::ProcessInput() { + PROFILE_NAMED_SCOPE("Game::ProcessInput"); + SDL_Event event; + + while (SDL_PollEvent(&event)) { +#ifdef OCTARINE_WITH_IMGUI + ImGui_ImplSDL3_ProcessEvent(&event); + auto& io = ImGui::GetIO(); + float mouseX, mouseY; + const unsigned int buttons = SDL_GetMouseState(&mouseX, &mouseY); + io.MousePos = ImVec2(mouseX, mouseY); + io.MouseDown[0] = buttons & SDL_BUTTON_MASK(SDL_BUTTON_LEFT); + io.MouseDown[1] = buttons & SDL_BUTTON_MASK(SDL_BUTTON_RIGHT); +#endif + + switch (event.type) { + case SDL_EVENT_QUIT: + Game::Quit(); + break; + + case SDL_EVENT_KEY_DOWN: + case SDL_EVENT_KEY_UP: { + KeyInputEvent keyInputEvent = GetKeyInputEvent(&event.key); + 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); + break; + } + case SDL_EVENT_MOUSE_WHEEL: { + event_bus_->EmitEvent(event.wheel.x, event.wheel.y); + break; + } + case SDL_EVENT_WINDOW_RESIZED: + case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: { + auto& viewportInfo = registry_->Get(); + int windowW, windowH; + SDL_GetWindowSize(runtime_->Window(), &windowW, &windowH); + viewportInfo.width = static_cast(windowW); + viewportInfo.height = static_cast(windowH); + break; + } + default: + break; + } + } +} + +void FrameLoop::Update(const float deltaTime) { +#ifdef OCTARINE_PROFILING + PerfUtils::ProfilingAccumulator::Clear(); + PerfUtils::PerfCounters::ResetValues(); +#endif + PROFILE_NAMED_SCOPE("Game::Update (total)"); + +#ifndef OCTARINE_SHIPPED + if (auto* devListen = registry_->TryGet()) { + devListen->Pump(); + } +#endif + + auto& options = registry_->Get().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 + // immediately even while execution is paused. + if (auto* mixer = registry_->Get().mixer) { + const float masterGain = options.audioEnabled ? std::max(0.0F, options.masterVolume) : 0.0F; + MIX_SetMixerGain(mixer, masterGain); + } + + auto* inputSystem = registry_->TryGet(); + if (inputSystem) { + inputSystem->BeginFrame(); + } + +#ifndef OCTARINE_SHIPPED + // Run hot reload even when paused — authors iterate code with the editor paused all the time, + // and the swap is cheap. Uses real deltaTime (not time-scaled) so the poll cadence is stable. + if (options.hotReloadEnabled) { + if (auto* hotReload = registry_->TryGet()) { + hotReload->Tick(*registry_, lua_, deltaTime, options.hotReloadPollSeconds); + } + } +#endif + + if (!options.isPaused || options.stepFrame) { + registry_->Update(deltaTime * options.timeScale); + options.stepFrame = false; + } else { + // If paused, we might still want to clear some per-frame signals so they don't get stuck. + } + + // Pressed/released keys and wheel deltas are per-frame edge signals — clear after every + // system in this frame has observed them. + if (inputSystem) { + inputSystem->ClearPerFrameInput(); + } +} + +void FrameLoop::Render([[maybe_unused]] const float deltaTime) { + PROFILE_NAMED_SCOPE("Game::Render (total)"); + auto& renderQueue = registry_->Get(); + auto& gameConfig = registry_->Get(); + + PROFILE_COUNTER_SET("RenderQueue: Size", static_cast(renderQueue.Size())); + PROFILE_COUNTER_SET("Entities: User", static_cast(registry_->GetUserEntityCount())); + + renderer_->BeginScene(runtime_->SdlRenderer()); + +#ifdef OCTARINE_PROFILING + { + PerfUtils::ScopedTimer sortTimer("Render: Sort"); + renderQueue.Sort(); + } + { + PerfUtils::ScopedTimer drawTimer("Render: Draw"); + renderer_->DrawQueue(renderQueue, runtime_->SdlRenderer()); + } +#else + renderQueue.Sort(); + renderer_->DrawQueue(renderQueue, runtime_->SdlRenderer()); +#endif + + if (gameConfig.GetEngineOptions().drawColliders && collider_query_) { + collider_query_->Update(); + DrawColliderSystem drawColliderSystem; + collider_query_->ForEach(drawColliderSystem); + } + + renderer_->EndScene(runtime_->SdlRenderer()); + + auto& options = gameConfig.GetEngineOptions(); + const bool editorSession = gameConfig.IsEditorMode() || !gameConfig.HasLoadedConfig(); + + // Update viewport info for non-editor sessions or when ImGui is disabled. + // In editor mode with ImGui, RenderDebugGUISystem::Render will override this with the Scene window bounds. + auto& viewportInfo = registry_->Get(); + viewportInfo.x = 0; + viewportInfo.y = 0; + int windowW, windowH; + SDL_GetWindowSize(runtime_->Window(), &windowW, &windowH); + viewportInfo.width = static_cast(windowW); + viewportInfo.height = static_cast(windowH); + +#ifdef OCTARINE_WITH_IMGUI + auto& io = ImGui::GetIO(); + viewportInfo.isHovered = !io.WantCaptureMouse; + viewportInfo.isFocused = !io.WantCaptureKeyboard; +#else + viewportInfo.isHovered = true; + viewportInfo.isFocused = true; +#endif + + if (!game_->IsBenchMode()) { + // Only draw the game texture to the full window if we are NOT in an editor session + // and NOT showing debug overlays. In editor mode, the Scene window handles drawing this texture. + if (!editorSession && !options.showDebugGUI) { + renderer_->CompositeSceneToWindow(runtime_->SdlRenderer()); + } +#ifdef OCTARINE_WITH_IMGUI + RenderDebugGUISystem::Render(game_, runtime_->SdlRenderer(), renderer_->GetSceneTexture(), deltaTime); +#endif + } + +#ifdef OCTARINE_PROFILING + { + PerfUtils::ScopedTimer presentTimer("Render: Present"); + renderer_->Present(runtime_->SdlRenderer()); + } +#else + renderer_->Present(runtime_->SdlRenderer()); +#endif + // Emit per-frame counters as COUNTER lines so headless bench runs can capture them + // alongside TIMER lines. All systems for this frame have already written their values. + PROFILE_COUNTERS_REPORT(); + renderQueue.Clear(); +} + +float FrameLoop::WaitTime() { + PROFILE_NAMED_SCOPE("Game::WaitTime"); + const Uint64 elapsedTime = SDL_GetTicks() - milliseconds_previous_frame_; + if (elapsedTime < Constants::kMillisecondsPerFrame) { + const Uint32 timeToWait = Constants::kMillisecondsPerFrame - static_cast(elapsedTime); + SDL_Delay(timeToWait); + } + + // Calculate delta time + const auto intermediate = static_cast(SDL_GetTicks() - milliseconds_previous_frame_) / + static_cast(Constants::kMillisecondsPerSecond); + const auto deltaTime = static_cast(intermediate); + + milliseconds_previous_frame_ = SDL_GetTicks(); + + return deltaTime; +} + +void FrameLoop::OnKeyInputEvent(const KeyInputEvent& event) { + if (!event.isPressed) { + return; + } + + auto& gameConfig = registry_->Get(); + + switch (event.inputKey) { + case SDLK_ESCAPE: + Game::Quit(); + break; + case SDLK_GRAVE: + gameConfig.GetEngineOptions().showDebugGUI = !gameConfig.GetEngineOptions().showDebugGUI; + break; + default: + break; + } +} + +KeyInputEvent FrameLoop::GetKeyInputEvent(SDL_KeyboardEvent* event) { + bool isPressed = event->down; + return {event->key, event->mod, isPressed}; +} diff --git a/src/Engine/FrameLoop.h b/src/Engine/FrameLoop.h new file mode 100644 index 0000000..7efa145 --- /dev/null +++ b/src/Engine/FrameLoop.h @@ -0,0 +1,65 @@ +#pragma once + +#include + +#include +#include + +#include "Components/BoxColliderComponent.h" +#include "Components/GlobalTransformComponent.h" +#include "ECS/Query.h" + +class Game; +class Registry; +class EventBus; +class Renderer; +class EngineRuntime; +// Defined in Events/KeyInputEvent.h. Forward-declared (not included) because that header is not +// self-contained — it derives from EventBus's Event without including it, so pulling it here +// would force a fragile include order on every consumer of FrameLoop.h. +struct KeyInputEvent; + +// Owns the per-frame loop body Game::Run drives: poll input, wait out the frame budget, update +// the ECS, and render. Game::Run keeps the while(running) loop and Setup/teardown; FrameLoop is +// the four phases plus the key-event handler that toggles the debug GUI / requests quit. +// +// Holds non-owning pointers to the engine pieces it touches (registry, event bus, renderer, the +// SDL runtime) plus the Game it renders the editor overlay for. Constructed by Game once those +// exist; the debug-collider query is built here so it isn't reallocated per frame. +class FrameLoop { + public: + FrameLoop(Game* game, Registry* registry, EventBus* eventBus, Renderer* renderer, EngineRuntime* runtime, + sol::state& lua); + + FrameLoop(const FrameLoop&) = delete; + FrameLoop& operator=(const FrameLoop&) = delete; + FrameLoop(FrameLoop&&) = delete; + FrameLoop& operator=(FrameLoop&&) = delete; + + ~FrameLoop() = default; + + // Seed the previous-frame tick so the first WaitTime() yields a sane delta. Call once right + // before entering the loop. + void Begin(); + + void ProcessInput(); + void Update(float deltaTime); + void Render(float deltaTime); + [[nodiscard]] float WaitTime(); + + // KeyInputEvent subscriber (Esc → quit, backtick → toggle debug GUI). Public so Game can wire + // it to the EventBus during Setup. + void OnKeyInputEvent(const KeyInputEvent& event); + + private: + static KeyInputEvent GetKeyInputEvent(SDL_KeyboardEvent* event); + + Game* game_; + Registry* registry_; + EventBus* event_bus_; + Renderer* renderer_; + EngineRuntime* runtime_; + sol::state& lua_; + Uint64 milliseconds_previous_frame_ = 0; + std::unique_ptr> collider_query_; +}; diff --git a/src/Engine/SceneLoader.cpp b/src/Engine/SceneLoader.cpp new file mode 100644 index 0000000..b6c9c11 --- /dev/null +++ b/src/Engine/SceneLoader.cpp @@ -0,0 +1,226 @@ +#include "Engine/SceneLoader.h" + +#include +#include +#include +#include +#include + +#include "AssetManager/AssetManager.h" +#include "AssetManager/SceneAssetScanner.h" +#include "ECS/Registry.h" +#include "Engine/EngineContext.h" +#include "Engine/SdlFileReader.h" +#include "Game/GameConfig.h" +#include "General/Logger.h" +#include "Lua/LuaEntityLoader.h" +#include "Renderer/SpriteRenderCache.h" +#include "Systems/InputSystem.h" + +#ifdef OCTARINE_WITH_EDITOR +#include "Editor/EditorPersistence.h" +#endif + +void SceneLoader::LoadScene(const std::string& scenePath) { + if (scenePath.empty()) { + Logger::Warn("LoadScene called with empty path."); + return; + } + + auto& assetManager = registry_->Get(); + SDL_Renderer* renderer = registry_->Get().sdlRenderer; + + // Use the full path relative to the asset directory if it's a relative path. + std::string fullPath = assetManager.GetFullPath(scenePath); + + Logger::Info("Loading scene: " + fullPath); + + // Acquire-before-release: stash the previous scene's tracked assets but defer releasing them + // until the new scene's assets are resident, so any id shared by both scenes never + // unloads/reloads across the swap. Entities are a different story — a scene script may spawn + // entities as a side effect while it runs (the no-table path), so the old entities are cleared + // up front, before the script executes, rather than after (which would wipe the new ones). + std::vector previousSceneAssets = std::move(current_scene_assets_); + current_scene_assets_.clear(); + clearSceneEntities(); + + auto releasePrevious = [&]() { assetManager.ReleaseAll(previousSceneAssets); }; + +#ifdef OCTARINE_WITH_EDITOR + if (auto* editorPersistence = registry_->TryGet()) { + editorPersistence->currentScenePath = scenePath; + editorPersistence->showSceneWindow = true; + } +#endif + + auto sceneBytes = ReadFileViaSDL(fullPath); + sol::protected_function_result result; + if (sceneBytes) { + result = lua_.safe_script(*sceneBytes, sol::script_pass_on_error, "@" + fullPath); + } + if (!sceneBytes || !result.valid()) { + if (!sceneBytes) { + Logger::Error("Failed to read scene '" + fullPath + "'"); + } else { + const sol::error err = result; + Logger::Error("Failed to load scene '" + fullPath + "': " + std::string(err.what())); + } + releasePrevious(); + } else if (result.return_count() > 0) { + if (result[0].is()) { + Logger::Info("Scene script returned a table. Attempting to load assets and entities..."); + sol::table sceneTable = result[0]; + + // 1. Load Assets + sol::optional assets = sceneTable["assets"]; + if (assets && assets->valid()) { + auto& am = registry_->Get(); + auto* mixer = registry_->Get().mixer; + int assetCount = 0; + for (auto& [key, value] : *assets) { + if (value.is()) { + sol::table asset = value.as(); + std::string type = asset["type"]; + std::string id = asset["id"]; + std::string file = asset["file"]; + if (type == "texture") { + am.AddTexture(renderer, id, file); + } else if (type == "font") { + float fontSize = asset["font_size"]; + am.AddFont(id, file, fontSize); + } else if (type == "audio_clip") { + if (mixer) am.AddAudioClip(mixer, id, file); + } + assetCount++; + } + } + Logger::Info("Loaded " + std::to_string(assetCount) + " assets from scene table."); + } + + // 1b. Derive the scene's required asset set from its data (sprite/font/audio ids + + // tilemap + preload), validate every reference against the catalog, then acquire up + // front before entities load. + { + auto& am = registry_->Get(); + MIX_Mixer* mixer = registry_->Get().mixer; + const std::vector refs = SceneAssetScanner::CollectRefs(sceneTable); + + if (const int failures = am.Validate(refs); failures > 0) { + Logger::Error("Scene '" + fullPath + "' has " + std::to_string(failures) + " unresolved asset reference(s)."); + if (registry_->Get().GetEngineOptions().assetValidationFatal) { + Logger::Error("assetValidationFatal is set — aborting scene load."); + releasePrevious(); + return; + } + } + + const int acquired = am.AcquireAll(refs, renderer, mixer); + Logger::Info("Scene scan acquired " + std::to_string(acquired) + " required asset(s)."); + + // New scene's assets are now resident. Track them, then release the previous + // scene's set — ids shared by both stay loaded thanks to refcounting. + std::vector newSceneAssets; + std::set seen; + for (const auto& ref : refs) { + if (seen.insert(ref.id).second) newSceneAssets.push_back(ref.id); + } + TrackSceneAssets(newSceneAssets); + releasePrevious(); + } + + // 2. Load Entities + sol::optional entities = sceneTable["entities"]; + if (entities && entities->valid()) { + int entityCount = 0; + for (auto& [key, value] : *entities) { + if (value.is()) { + LuaEntityLoader::LoadEntityFromLua(registry_, value.as()); + entityCount++; + } + } + Logger::Info("Loaded " + std::to_string(entityCount) + " entities from scene table."); + } + + // 3. Try to call a 'run' or 'load' or 'setup' function if present + sol::optional runFunc = sceneTable["run"]; + if (!runFunc) runFunc = sceneTable["load"].get>(); + if (!runFunc) runFunc = sceneTable["setup"].get>(); + + if (runFunc && runFunc->valid()) { + Logger::Info("Found 'run/load/setup' function in scene table. Calling it..."); + auto funcResult = (*runFunc)(sceneTable); + if (!funcResult.valid()) { + sol::error err = funcResult; + Logger::Error("Failed to run scene function: " + std::string(err.what())); + } + } + } else if (result[0].is()) { + // No scene table to scan up front. The returned function spawns the scene (and may + // call acquire_scene_assets, which tracks its ids via TrackSceneAssets). Run it, then + // release the previous scene's assets — shared ids it already re-acquired survive. + Logger::Info("Scene script returned a function. Calling it..."); + sol::function sceneFunc = result[0].as(); + auto funcResult = sceneFunc(); + if (!funcResult.valid()) { + sol::error err = funcResult; + Logger::Error("Failed to run scene function: " + std::string(err.what())); + } + releasePrevious(); + } else { + releasePrevious(); + } + } else { + // No return value: the script built the scene as a side effect while it ran (entities + + // any acquire_scene_assets call). Those are already in place; just release the old set. + releasePrevious(); + } + + scene_running_ = true; +} + +void SceneLoader::ReloadScene() { +#ifdef OCTARINE_WITH_EDITOR + if (auto* editorPersistence = registry_->TryGet(); + editorPersistence != nullptr && !editorPersistence->currentScenePath.empty()) { + LoadScene(editorPersistence->currentScenePath); + return; + } +#endif + Logger::Warn("ReloadScene called but no scene is currently loaded."); +} + +void SceneLoader::clearSceneEntities() { + registry_->ClearUserEntities(); + if (auto* inputSystem = registry_->TryGet()) { + inputSystem->ResetLuaState(); + } + // Drop cached SDL_Texture* lookups for entities that just got blammed; the next sprite-emit + // pass repopulates as those entities are recreated by the new scene's load. + if (auto* spriteCache = registry_->TryGet()) { + spriteCache->Clear(); + } +} + +void SceneLoader::TrackSceneAssets(const std::vector& assetIds) { + for (const auto& id : assetIds) { + if (std::ranges::find(current_scene_assets_, id) == current_scene_assets_.end()) { + current_scene_assets_.push_back(id); + } + } +} + +void SceneLoader::StopScene() { + Logger::Info("Stopping current scene (clearing entities)."); + clearSceneEntities(); + + // Release the assets this scene acquired. (LoadScene sequences acquire-before-release itself + // and does not route through here; this is the explicit-stop path.) + if (!current_scene_assets_.empty()) { + if (auto* assetManager = registry_->TryGet()) { + assetManager->ReleaseAll(current_scene_assets_); + } + current_scene_assets_.clear(); + } + + scene_running_ = false; +} diff --git a/src/Engine/SceneLoader.h b/src/Engine/SceneLoader.h new file mode 100644 index 0000000..645894a --- /dev/null +++ b/src/Engine/SceneLoader.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +class Registry; + +// Owns the scene lifecycle Game used to inline: parse a scene script (table / function / side- +// effect forms), acquire-before-release of the scene's assets, load its entities, and track the +// acquired ids so the next swap or StopScene releases them. Reads the live SDL renderer + mixer +// off the registry's EngineContext, so it needs only the Registry + the shared sol::state. +// +// Game delegates its LuaBindingContext scene-op overrides (LoadScene/ReloadScene/StopScene/ +// TrackSceneAssets) straight through to this; the `scene.*` Lua module binds against the same +// LuaBindingContext surface, so scripts reach these via Game. +class SceneLoader { + public: + SceneLoader(Registry* registry, sol::state& lua) : registry_(registry), lua_(lua) {} + + SceneLoader(const SceneLoader&) = delete; + SceneLoader& operator=(const SceneLoader&) = delete; + SceneLoader(SceneLoader&&) = delete; + SceneLoader& operator=(SceneLoader&&) = delete; + + ~SceneLoader() = default; + + void LoadScene(const std::string& scenePath); + void ReloadScene(); + void StopScene(); + + // Record asset ids acquired for the current scene so StopScene/the next LoadScene releases + // them. Deduped against ids already tracked. Called by the C++ scene loader and by the + // `acquire_scene_assets` Lua global (which serves scenes that load via a side-effect script + // rather than returning a table). Safe to call repeatedly with overlapping sets. + void TrackSceneAssets(const std::vector& assetIds); + + // True between a successful LoadScene/ReloadScene and the next StopScene. The editor toolbar + // uses this to decide whether Play should resume a paused scene or (re)start a stopped one. + [[nodiscard]] bool IsSceneRunning() const { return scene_running_; } + + private: + // Clear the current scene's user entities and reset per-scene Lua input state, without + // touching acquired assets. Asset release is sequenced separately so a scene swap can acquire + // the next scene's set before releasing the previous one (acquire-before-release). + void clearSceneEntities(); + + Registry* registry_; + sol::state& lua_; + bool scene_running_ = false; + // Asset ids acquired for the currently loaded scene. StopScene releases these; LoadScene + // acquires the next scene's set first (acquire-before-release) so shared assets never churn. + std::vector current_scene_assets_; +}; diff --git a/src/Engine/SdlFileReader.h b/src/Engine/SdlFileReader.h new file mode 100644 index 0000000..c8f72ad --- /dev/null +++ b/src/Engine/SdlFileReader.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include +#include + +#include "General/Logger.h" + +// Read a file's bytes through SDL_IO so the same path resolves on desktop, inside an APK asset +// root, or inside a .app bundle. Lua's stock fopen-based loader only sees a real filesystem and +// misses AAssetManager-backed entries on Android. Shared by Game::LoadGame and SceneLoader (the +// dofile override that exposes the same behavior to scripts lives in +// engine_bootstrap::InstallLuaLibraries). Header-only inline so both engine-layer TUs share one +// definition without a separate object. +inline std::optional ReadFileViaSDL(const std::string& path) { + SDL_IOStream* io = SDL_IOFromFile(path.c_str(), "rb"); + if (!io) { + Logger::Error("SDL_IOFromFile failed for '" + path + "': " + std::string(SDL_GetError())); + return std::nullopt; + } + std::size_t size = 0; + void* data = SDL_LoadFile_IO(io, &size, true); // closes io + if (!data) { + Logger::Error("SDL_LoadFile_IO failed for '" + path + "': " + std::string(SDL_GetError())); + return std::nullopt; + } + std::string out(static_cast(data), size); + SDL_free(data); + return out; +} diff --git a/src/Game/Game.cpp b/src/Game/Game.cpp index 43a3cbb..8e1ed7d 100644 --- a/src/Game/Game.cpp +++ b/src/Game/Game.cpp @@ -42,6 +42,7 @@ #include "ECS/Query.h" #include "ECS/Registry.h" #include "Engine/EngineBootstrap.h" +#include "Engine/SdlFileReader.h" #include "Events/MouseInputEvent.h" #include "Events/MouseWheelEvent.h" #include "GameConfig.h" @@ -96,29 +97,6 @@ #include "../Dev/DevListenServer.h" #endif -namespace { -// Read a file's bytes through SDL_IO so the same path resolves on desktop, inside an APK asset -// root, or inside a .app bundle. Lua's stock fopen-based loader only sees a real filesystem and -// misses AAssetManager-backed entries on Android. Used by LoadGame + LoadScene; the dofile -// override that exposes this to scripts lives in engine_bootstrap::InstallLuaLibraries. -std::optional ReadFileViaSDL(const std::string& path) { - SDL_IOStream* io = SDL_IOFromFile(path.c_str(), "rb"); - if (!io) { - Logger::Error("SDL_IOFromFile failed for '" + path + "': " + std::string(SDL_GetError())); - return std::nullopt; - } - std::size_t size = 0; - void* data = SDL_LoadFile_IO(io, &size, true); // closes io - if (!data) { - Logger::Error("SDL_LoadFile_IO failed for '" + path + "': " + std::string(SDL_GetError())); - return std::nullopt; - } - std::string out(static_cast(data), size); - SDL_free(data); - return out; -} -} // namespace - inline void LoadGame(sol::state& lua, const AssetManager& assetManager, const GameConfig& gameConfig) { const auto filePath = assetManager.GetFullPath(gameConfig.GetStartupScript()); @@ -146,25 +124,19 @@ inline void LoadGame(sol::state& lua, const AssetManager& assetManager, const Ga Logger::Info("Just opened entry script: " + filePath); } -Game::Game() : window_(nullptr), sdl_renderer_(nullptr) { +Game::Game() { registry_ = std::make_unique(); event_bus_ = std::make_unique(); renderer_ = std::make_unique(); + scene_loader_ = std::make_unique(registry_.get(), lua); + frame_loop_ = std::make_unique(this, registry_.get(), event_bus_.get(), renderer_.get(), &runtime_, lua); Logger::Info("Game Constructor called."); } Game::~Game() { Logger::Info("Game Destructor called."); } bool Game::Initialize(const std::string& assetPath) { - constexpr auto SDL_INI = SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_EVENTS | SDL_INIT_GAMEPAD; - - if (!SDL_Init(SDL_INI)) { - Logger::Error("SDL_Init Error: " + std::string(SDL_GetError())); - return false; - } - - if (!TTF_Init()) { - Logger::Error("TTF_Init Error: " + std::string(SDL_GetError())); + if (!runtime_.InitSubsystems()) { return false; } @@ -273,50 +245,27 @@ bool Game::Initialize(const std::string& assetPath) { gameConfig.windowHeight = projectLoaded ? gameConfig.GetDefaultHeight() : Constants::kDefaultWindowHeight; std::string title = projectLoaded ? gameConfig.GetGameTitle() : "Octarine Engine - Editor"; - SDL_CreateWindowAndRenderer(title.c_str(), gameConfig.windowWidth, gameConfig.windowHeight, SDL_WINDOW_RESIZABLE, - &window_, &sdl_renderer_); - - if (!window_) { - Logger::Error("SDL_CreateWindow Error: " + std::string(SDL_GetError())); - return false; - } - - if (!sdl_renderer_) { - Logger::Error("SDL_CreateRenderer Error: " + std::string(SDL_GetError())); + if (!runtime_.CreateWindow(title, gameConfig.windowWidth, gameConfig.windowHeight)) { return false; } - if (!renderer_->CreateScene(sdl_renderer_, gameConfig.windowWidth, gameConfig.windowHeight)) { + if (!renderer_->CreateScene(runtime_.SdlRenderer(), gameConfig.windowWidth, gameConfig.windowHeight)) { return false; } #ifdef OCTARINE_WITH_IMGUI - IMGUI_CHECKVERSION(); - ImGui::CreateContext(); - ImGuiIO& io = ImGui::GetIO(); - (void)io; - + // io.IniFilename holds the raw pointer for the context's lifetime, so the backing string must + // outlive InitImGui — keep it static. static std::string imguiIniPath; if (gameConfig.HasLoadedConfig()) { imguiIniPath = gameConfig.GetAssetPath() + "/imgui.ini"; - io.IniFilename = imguiIniPath.c_str(); + } else if (char* prefPath = SDL_GetPrefPath("Octarine", "Engine")) { + imguiIniPath = std::string(prefPath) + "imgui_editor.ini"; + SDL_free(prefPath); } else { - char* prefPath = SDL_GetPrefPath("Octarine", "Engine"); - if (prefPath) { - imguiIniPath = std::string(prefPath) + "imgui_editor.ini"; - io.IniFilename = imguiIniPath.c_str(); - SDL_free(prefPath); - } else { - io.IniFilename = "imgui_editor.ini"; - } + imguiIniPath = "imgui_editor.ini"; } - - io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; - io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; - io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; - - ImGui_ImplSDL3_InitForSDLRenderer(window_, sdl_renderer_); - ImGui_ImplSDLRenderer3_Init(sdl_renderer_); + runtime_.InitImGui(imguiIniPath.c_str()); #ifdef OCTARINE_WITH_EDITOR // --- Resolve editor font size (DPI-aware default on first launch) --- @@ -343,6 +292,7 @@ bool Game::Initialize(const std::string& assetPath) { octarine::editor::layouts::ApplyDefaultPreset(editorPersistence); } #else + ImGuiIO& io = ImGui::GetIO(); io.Fonts->Clear(); io.Fonts->AddFontDefault(); io.Fonts->Build(); @@ -353,8 +303,8 @@ bool Game::Initialize(const std::string& assetPath) { // filled in by Setup once those exist; consumers read the same instance via // Registry::Get(). EngineContext ctx; - ctx.sdlWindow = window_; - ctx.sdlRenderer = sdl_renderer_; + ctx.sdlWindow = runtime_.Window(); + ctx.sdlRenderer = runtime_.SdlRenderer(); ctx.eventBus = event_bus_.get(); ctx.config = &gameConfig; registry_->Set(ctx); @@ -393,21 +343,12 @@ void Game::Destroy() { registry_.reset(); event_bus_.reset(); - if (sdl_renderer_) { + // Free the off-screen scene target while the SDL renderer is still live (EngineRuntime owns + // the ImGui backend + window/renderer destroy + SDL_Quit, in that order). + if (runtime_.SdlRenderer()) { renderer_->DestroyScene(); -#ifdef OCTARINE_WITH_IMGUI - ImGui_ImplSDLRenderer3_Shutdown(); - ImGui_ImplSDL3_Shutdown(); - ImGui::DestroyContext(); -#endif - SDL_DestroyRenderer(sdl_renderer_); - } - - if (window_) { - SDL_DestroyWindow(window_); } - - SDL_Quit(); + runtime_.Shutdown(); } bool Game::Bake(const std::string& assetPath) { @@ -576,13 +517,13 @@ void Game::Run() { Setup(); // Seed previous-frame tick so the first WaitTime call produces a sane delta. - milliseconds_previous_frame_ = SDL_GetTicks(); + frame_loop_->Begin(); while (s_is_running_) { - ProcessInput(); - const float deltaTime = WaitTime(); - Update(deltaTime); - Render(deltaTime); + frame_loop_->ProcessInput(); + const float deltaTime = frame_loop_->WaitTime(); + frame_loop_->Update(deltaTime); + frame_loop_->Render(deltaTime); } } @@ -738,7 +679,7 @@ void Game::Setup() { registry_->RegisterParallelSystem(RenderPrimitiveSystem()); // Event subscriptions (one-time) - event_bus_->SubscribeEvent(this, &Game::OnKeyInputEvent); + event_bus_->SubscribeEvent(frame_loop_.get(), &FrameLoop::OnKeyInputEvent); // Event-driven systems with no per-frame Update — owned by the Registry instead of // living as parallel members on Game. Keeps the registry as the single source of truth. auto& uiButtonSystem = registry_->Set(UIButtonSystem()); @@ -748,437 +689,7 @@ void Game::Setup() { damageSystem.Init(registry_.get(), event_bus_); obstacleBounceSystem.Init(registry_.get(), event_bus_); - // Pre-build the debug-collider query once so we don't allocate per render frame. - collider_query_ = registry_->CreateQuery(); - // Hot reload owns its own ScriptWatcher + (path -> entities) discovery loop. Compiled out // under OCTARINE_SHIPPED; in dev/editor builds the runtime gate lives on EngineOptions. registry_->Set(ScriptHotReload()); } - -void Game::ProcessInput() const { - PROFILE_NAMED_SCOPE("Game::ProcessInput"); - SDL_Event event; - - while (SDL_PollEvent(&event)) { -#ifdef OCTARINE_WITH_IMGUI - ImGui_ImplSDL3_ProcessEvent(&event); - auto& io = ImGui::GetIO(); - float mouseX, mouseY; - const unsigned int buttons = SDL_GetMouseState(&mouseX, &mouseY); - io.MousePos = ImVec2(mouseX, mouseY); - io.MouseDown[0] = buttons & SDL_BUTTON_MASK(SDL_BUTTON_LEFT); - io.MouseDown[1] = buttons & SDL_BUTTON_MASK(SDL_BUTTON_RIGHT); -#endif - - switch (event.type) { - case SDL_EVENT_QUIT: - s_is_running_ = false; - break; - - case SDL_EVENT_KEY_DOWN: - case SDL_EVENT_KEY_UP: { - KeyInputEvent keyInputEvent = GetKeyInputEvent(&event.key); - 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); - break; - } - case SDL_EVENT_MOUSE_WHEEL: { - event_bus_->EmitEvent(event.wheel.x, event.wheel.y); - break; - } - case SDL_EVENT_WINDOW_RESIZED: - case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: { - auto& viewportInfo = registry_->Get(); - int windowW, windowH; - SDL_GetWindowSize(window_, &windowW, &windowH); - viewportInfo.width = static_cast(windowW); - viewportInfo.height = static_cast(windowH); - break; - } - default: - break; - } - } -} - -void Game::Update(const float deltaTime) { -#ifdef OCTARINE_PROFILING - PerfUtils::ProfilingAccumulator::Clear(); - PerfUtils::PerfCounters::ResetValues(); -#endif - PROFILE_NAMED_SCOPE("Game::Update (total)"); - -#ifndef OCTARINE_SHIPPED - if (auto* devListen = registry_->TryGet()) { - devListen->Pump(); - } -#endif - - auto& options = registry_->Get().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 - // immediately even while execution is paused. - if (auto* mixer = registry_->Get().mixer) { - const float masterGain = options.audioEnabled ? std::max(0.0F, options.masterVolume) : 0.0F; - MIX_SetMixerGain(mixer, masterGain); - } - - auto* inputSystem = registry_->TryGet(); - if (inputSystem) { - inputSystem->BeginFrame(); - } - -#ifndef OCTARINE_SHIPPED - // Run hot reload even when paused — authors iterate code with the editor paused all the time, - // and the swap is cheap. Uses real deltaTime (not time-scaled) so the poll cadence is stable. - if (options.hotReloadEnabled) { - if (auto* hotReload = registry_->TryGet()) { - hotReload->Tick(*registry_, lua, deltaTime, options.hotReloadPollSeconds); - } - } -#endif - - if (!options.isPaused || options.stepFrame) { - registry_->Update(deltaTime * options.timeScale); - options.stepFrame = false; - } else { - // If paused, we might still want to clear some per-frame signals so they don't get stuck. - } - - // Pressed/released keys and wheel deltas are per-frame edge signals — clear after every - // system in this frame has observed them. - if (inputSystem) { - inputSystem->ClearPerFrameInput(); - } -} - -void Game::Render([[maybe_unused]] const float deltaTime) { - PROFILE_NAMED_SCOPE("Game::Render (total)"); - auto& renderQueue = registry_->Get(); - auto& gameConfig = registry_->Get(); - - PROFILE_COUNTER_SET("RenderQueue: Size", static_cast(renderQueue.Size())); - PROFILE_COUNTER_SET("Entities: User", static_cast(registry_->GetUserEntityCount())); - - renderer_->BeginScene(sdl_renderer_); - -#ifdef OCTARINE_PROFILING - { - PerfUtils::ScopedTimer sortTimer("Render: Sort"); - renderQueue.Sort(); - } - { - PerfUtils::ScopedTimer drawTimer("Render: Draw"); - renderer_->DrawQueue(renderQueue, sdl_renderer_); - } -#else - renderQueue.Sort(); - renderer_->DrawQueue(renderQueue, sdl_renderer_); -#endif - - if (gameConfig.GetEngineOptions().drawColliders && collider_query_) { - collider_query_->Update(); - DrawColliderSystem drawColliderSystem; - collider_query_->ForEach(drawColliderSystem); - } - - renderer_->EndScene(sdl_renderer_); - - auto& options = gameConfig.GetEngineOptions(); - const bool editorSession = gameConfig.IsEditorMode() || !gameConfig.HasLoadedConfig(); - - // Update viewport info for non-editor sessions or when ImGui is disabled. - // In editor mode with ImGui, RenderDebugGUISystem::Render will override this with the Scene window bounds. - auto& viewportInfo = registry_->Get(); - viewportInfo.x = 0; - viewportInfo.y = 0; - int windowW, windowH; - SDL_GetWindowSize(window_, &windowW, &windowH); - viewportInfo.width = static_cast(windowW); - viewportInfo.height = static_cast(windowH); - -#ifdef OCTARINE_WITH_IMGUI - auto& io = ImGui::GetIO(); - viewportInfo.isHovered = !io.WantCaptureMouse; - viewportInfo.isFocused = !io.WantCaptureKeyboard; -#else - viewportInfo.isHovered = true; - viewportInfo.isFocused = true; -#endif - - if (!IsBenchMode()) { - // Only draw the game texture to the full window if we are NOT in an editor session - // and NOT showing debug overlays. In editor mode, the Scene window handles drawing this texture. - if (!editorSession && !options.showDebugGUI) { - renderer_->CompositeSceneToWindow(sdl_renderer_); - } -#ifdef OCTARINE_WITH_IMGUI - RenderDebugGUISystem::Render(this, sdl_renderer_, renderer_->GetSceneTexture(), deltaTime); -#endif - } - -#ifdef OCTARINE_PROFILING - { - PerfUtils::ScopedTimer presentTimer("Render: Present"); - renderer_->Present(sdl_renderer_); - } -#else - renderer_->Present(sdl_renderer_); -#endif - // Emit per-frame counters as COUNTER lines so headless bench runs can capture them - // alongside TIMER lines. All systems for this frame have already written their values. - PROFILE_COUNTERS_REPORT(); - renderQueue.Clear(); -} - -float Game::WaitTime() { - PROFILE_NAMED_SCOPE("Game::WaitTime"); - const Uint64 elapsedTime = SDL_GetTicks() - milliseconds_previous_frame_; - if (elapsedTime < Constants::kMillisecondsPerFrame) { - const Uint32 timeToWait = Constants::kMillisecondsPerFrame - static_cast(elapsedTime); - SDL_Delay(timeToWait); - } - - // Calculate delta time - const auto intermediate = static_cast(SDL_GetTicks() - milliseconds_previous_frame_) / - static_cast(Constants::kMillisecondsPerSecond); - const auto deltaTime = static_cast(intermediate); - - milliseconds_previous_frame_ = SDL_GetTicks(); - - return deltaTime; -} - -void Game::OnKeyInputEvent(const KeyInputEvent& event) { - if (!event.isPressed) { - return; - } - - auto& gameConfig = registry_->Get(); - - switch (event.inputKey) { - case SDLK_ESCAPE: - s_is_running_ = false; - break; - case SDLK_GRAVE: - gameConfig.GetEngineOptions().showDebugGUI = !gameConfig.GetEngineOptions().showDebugGUI; - break; - default: - break; - } -} - -void Game::LoadScene(const std::string& scenePath) { - if (scenePath.empty()) { - Logger::Warn("LoadScene called with empty path."); - return; - } - - auto& assetManager = registry_->Get(); - - // Use the full path relative to the asset directory if it's a relative path. - std::string fullPath = assetManager.GetFullPath(scenePath); - - Logger::Info("Loading scene: " + fullPath); - - // Acquire-before-release: stash the previous scene's tracked assets but defer releasing them - // until the new scene's assets are resident, so any id shared by both scenes never - // unloads/reloads across the swap. Entities are a different story — a scene script may spawn - // entities as a side effect while it runs (the no-table path), so the old entities are cleared - // up front, before the script executes, rather than after (which would wipe the new ones). - std::vector previousSceneAssets = std::move(current_scene_assets_); - current_scene_assets_.clear(); - clearSceneEntities(); - - auto releasePrevious = [&]() { assetManager.ReleaseAll(previousSceneAssets); }; - -#ifdef OCTARINE_WITH_EDITOR - if (auto* editorPersistence = registry_->TryGet()) { - editorPersistence->currentScenePath = scenePath; - editorPersistence->showSceneWindow = true; - } -#endif - - auto sceneBytes = ReadFileViaSDL(fullPath); - sol::protected_function_result result; - if (sceneBytes) { - result = lua.safe_script(*sceneBytes, sol::script_pass_on_error, "@" + fullPath); - } - if (!sceneBytes || !result.valid()) { - if (!sceneBytes) { - Logger::Error("Failed to read scene '" + fullPath + "'"); - } else { - const sol::error err = result; - Logger::Error("Failed to load scene '" + fullPath + "': " + std::string(err.what())); - } - releasePrevious(); - } else if (result.return_count() > 0) { - if (result[0].is()) { - Logger::Info("Scene script returned a table. Attempting to load assets and entities..."); - sol::table sceneTable = result[0]; - - // 1. Load Assets - sol::optional assets = sceneTable["assets"]; - if (assets && assets->valid()) { - auto& am = registry_->Get(); - auto* mixer = registry_->Get().mixer; - int assetCount = 0; - for (auto& [key, value] : *assets) { - if (value.is()) { - sol::table asset = value.as(); - std::string type = asset["type"]; - std::string id = asset["id"]; - std::string file = asset["file"]; - if (type == "texture") { - am.AddTexture(sdl_renderer_, id, file); - } else if (type == "font") { - float fontSize = asset["font_size"]; - am.AddFont(id, file, fontSize); - } else if (type == "audio_clip") { - if (mixer) am.AddAudioClip(mixer, id, file); - } - assetCount++; - } - } - Logger::Info("Loaded " + std::to_string(assetCount) + " assets from scene table."); - } - - // 1b. Derive the scene's required asset set from its data (sprite/font/audio ids + - // tilemap + preload), validate every reference against the catalog, then acquire up - // front before entities load. - { - auto& am = registry_->Get(); - MIX_Mixer* mixer = registry_->Get().mixer; - const std::vector refs = SceneAssetScanner::CollectRefs(sceneTable); - - if (const int failures = am.Validate(refs); failures > 0) { - Logger::Error("Scene '" + fullPath + "' has " + std::to_string(failures) + " unresolved asset reference(s)."); - if (registry_->Get().GetEngineOptions().assetValidationFatal) { - Logger::Error("assetValidationFatal is set — aborting scene load."); - releasePrevious(); - return; - } - } - - const int acquired = am.AcquireAll(refs, sdl_renderer_, mixer); - Logger::Info("Scene scan acquired " + std::to_string(acquired) + " required asset(s)."); - - // New scene's assets are now resident. Track them, then release the previous - // scene's set — ids shared by both stay loaded thanks to refcounting. - std::vector newSceneAssets; - std::set seen; - for (const auto& ref : refs) { - if (seen.insert(ref.id).second) newSceneAssets.push_back(ref.id); - } - TrackSceneAssets(newSceneAssets); - releasePrevious(); - } - - // 2. Load Entities - sol::optional entities = sceneTable["entities"]; - if (entities && entities->valid()) { - int entityCount = 0; - for (auto& [key, value] : *entities) { - if (value.is()) { - LuaEntityLoader::LoadEntityFromLua(registry_.get(), value.as()); - entityCount++; - } - } - Logger::Info("Loaded " + std::to_string(entityCount) + " entities from scene table."); - } - - // 3. Try to call a 'run' or 'load' or 'setup' function if present - sol::optional runFunc = sceneTable["run"]; - if (!runFunc) runFunc = sceneTable["load"].get>(); - if (!runFunc) runFunc = sceneTable["setup"].get>(); - - if (runFunc && runFunc->valid()) { - Logger::Info("Found 'run/load/setup' function in scene table. Calling it..."); - auto funcResult = (*runFunc)(sceneTable); - if (!funcResult.valid()) { - sol::error err = funcResult; - Logger::Error("Failed to run scene function: " + std::string(err.what())); - } - } - } else if (result[0].is()) { - // No scene table to scan up front. The returned function spawns the scene (and may - // call acquire_scene_assets, which tracks its ids via TrackSceneAssets). Run it, then - // release the previous scene's assets — shared ids it already re-acquired survive. - Logger::Info("Scene script returned a function. Calling it..."); - sol::function sceneFunc = result[0].as(); - auto funcResult = sceneFunc(); - if (!funcResult.valid()) { - sol::error err = funcResult; - Logger::Error("Failed to run scene function: " + std::string(err.what())); - } - releasePrevious(); - } else { - releasePrevious(); - } - } else { - // No return value: the script built the scene as a side effect while it ran (entities + - // any acquire_scene_assets call). Those are already in place; just release the old set. - releasePrevious(); - } - - scene_running_ = true; -} - -void Game::ReloadScene() { -#ifdef OCTARINE_WITH_EDITOR - if (auto* editorPersistence = registry_->TryGet(); - editorPersistence != nullptr && !editorPersistence->currentScenePath.empty()) { - LoadScene(editorPersistence->currentScenePath); - return; - } -#endif - Logger::Warn("ReloadScene called but no scene is currently loaded."); -} - -void Game::clearSceneEntities() { - registry_->ClearUserEntities(); - if (auto* inputSystem = registry_->TryGet()) { - inputSystem->ResetLuaState(); - } - // Drop cached SDL_Texture* lookups for entities that just got blammed; the next sprite-emit - // pass repopulates as those entities are recreated by the new scene's load. - if (auto* spriteCache = registry_->TryGet()) { - spriteCache->Clear(); - } -} - -void Game::TrackSceneAssets(const std::vector& assetIds) { - for (const auto& id : assetIds) { - if (std::ranges::find(current_scene_assets_, id) == current_scene_assets_.end()) { - current_scene_assets_.push_back(id); - } - } -} - -void Game::StopScene() { - Logger::Info("Stopping current scene (clearing entities)."); - clearSceneEntities(); - - // Release the assets this scene acquired. (LoadScene sequences acquire-before-release itself - // and does not route through here; this is the explicit-stop path.) - if (!current_scene_assets_.empty()) { - if (auto* assetManager = registry_->TryGet()) { - assetManager->ReleaseAll(current_scene_assets_); - } - current_scene_assets_.clear(); - } - - scene_running_ = false; -} - -KeyInputEvent Game::GetKeyInputEvent(SDL_KeyboardEvent* event) { - bool isPressed = event->down; - return {event->key, event->mod, isPressed}; -} diff --git a/src/Game/Game.h b/src/Game/Game.h index eb39939..cffd5c0 100644 --- a/src/Game/Game.h +++ b/src/Game/Game.h @@ -11,6 +11,9 @@ #include "../Components/GlobalTransformComponent.h" #include "../ECS/Query.h" #include "../Engine/EngineContext.h" +#include "../Engine/EngineRuntime.h" +#include "../Engine/FrameLoop.h" +#include "../Engine/SceneLoader.h" #include "../EventBus/EventBus.h" #include "../Events/KeyInputEvent.h" #include "../Lua/LuaBindingContext.h" @@ -76,8 +79,8 @@ class Game : public LuaBindingContext { } #endif - [[nodiscard]] SDL_Renderer* GetRenderer() const override { return sdl_renderer_; } - [[nodiscard]] SDL_Window* GetWindow() const { return window_; } + [[nodiscard]] SDL_Renderer* GetRenderer() const override { return runtime_.SdlRenderer(); } + [[nodiscard]] SDL_Window* GetWindow() const { return runtime_.Window(); } [[nodiscard]] Registry* GetRegistry() const override { return registry_.get(); } [[nodiscard]] sol::state& GetLua() { return lua; } @@ -89,42 +92,29 @@ class Game : public LuaBindingContext { [[nodiscard]] EngineContext& GetContext() override { return registry_->Get(); } [[nodiscard]] const EngineContext& GetContext() const { return registry_->Get(); } - void LoadScene(const std::string& scenePath) override; - void ReloadScene() override; - void StopScene() override; - - // Record asset ids acquired for the current scene so StopScene/the next LoadScene releases - // them. Deduped against ids already tracked. Called by the C++ scene loader and by the - // `acquire_scene_assets` Lua global (which serves scenes that load via a side-effect script - // rather than returning a table). Safe to call repeatedly with overlapping sets. - void TrackSceneAssets(const std::vector& assetIds) override; + // Scene lifecycle lives in SceneLoader; these LuaBindingContext overrides delegate so the + // `scene.*` Lua module (and the editor toolbar) drive it through Game unchanged. + void LoadScene(const std::string& scenePath) override { scene_loader_->LoadScene(scenePath); } + void ReloadScene() override { scene_loader_->ReloadScene(); } + void StopScene() override { scene_loader_->StopScene(); } + void TrackSceneAssets(const std::vector& assetIds) override { + scene_loader_->TrackSceneAssets(assetIds); + } // True between a successful LoadScene/ReloadScene and the next StopScene. The editor toolbar // uses this to decide whether Play should resume a paused scene or (re)start a stopped one. - [[nodiscard]] bool IsSceneRunning() const { return scene_running_; } + [[nodiscard]] bool IsSceneRunning() const { return scene_loader_->IsSceneRunning(); } private: - void ProcessInput() const; - void Update(float deltaTime); - void Render(float deltaTime); - [[nodiscard]] float WaitTime(); void Setup(); // Headless instance method behind the static Bake(): wires the minimal singleton + Lua surface // the startup script touches, force-scans the catalog, runs the startup script (which validates // its scene references via the bake-mode asset globals), then writes the manifest. Returns false // on a load/scan/write failure or any unresolved reference. [[nodiscard]] bool RunBakeValidation(const std::string& assetPath); - // Clear the current scene's user entities and reset per-scene Lua input state, without - // touching acquired assets. Asset release is sequenced separately so a scene swap can acquire - // the next scene's set before releasing the previous one (acquire-before-release). - void clearSceneEntities(); - void OnKeyInputEvent(const KeyInputEvent& event); - static KeyInputEvent GetKeyInputEvent(SDL_KeyboardEvent* event); - - SDL_Window* window_; - SDL_Renderer* sdl_renderer_; + + EngineRuntime runtime_; static inline bool s_is_running_{false}; - bool scene_running_ = false; bool bake_mode_ = false; bool use_manifest_ = false; #ifndef OCTARINE_SHIPPED @@ -132,15 +122,14 @@ class Game : public LuaBindingContext { bool dev_listen_all_ = false; #endif int bake_validation_failures_ = 0; - Uint64 milliseconds_previous_frame_ = 0; - // Asset ids acquired for the currently loaded scene. StopScene releases these; LoadScene - // acquires the next scene's set first (acquire-before-release) so shared assets never churn. - std::vector current_scene_assets_; sol::state lua; std::string startup_mode_; std::unique_ptr registry_; std::unique_ptr event_bus_; std::unique_ptr renderer_; - std::unique_ptr> collider_query_; + // Scene + frame helpers. Constructed in the Game ctor once registry_/renderer_ exist; they hold + // non-owning refs back into Game's members, so they are declared last and torn down first. + std::unique_ptr scene_loader_; + std::unique_ptr frame_loop_; };