diff --git a/destrum/include/destrum/App.h b/destrum/include/destrum/App.h index 188b128..d1158f8 100644 --- a/destrum/include/destrum/App.h +++ b/destrum/include/destrum/App.h @@ -2,8 +2,12 @@ #define APP_H #include #include +#include +#include #include +#include + #include "glm/vec2.hpp" #include @@ -17,6 +21,8 @@ #include +class Scene; + class App { public: struct AppParams { @@ -28,6 +34,7 @@ public: bool showDebugUi{true}; WindowMode windowMode{WindowMode::Windowed}; bool startMaximized{false}; + bool startInEditMode{false}; }; App(); @@ -45,7 +52,11 @@ public: virtual void onWindowResize(int, int) {}; - // Called inside the "Windows" menu of the main menu bar, add toggles for the game's own windows here + virtual void onPlayModeChanged(bool) {} + + [[nodiscard]] bool isPlaying() const { return playing; } + void requestPlayModeToggle() { playModeToggleRequested = true; } + virtual void customWindowsMenu() {} protected: @@ -92,6 +103,17 @@ protected: void drawWindowsMenu(); void drawPhysicsPanel(float dt, float fixedDt, float accumulator); +private: + void drawPlayControls(); + void applyPlayModeToggle(); + bool enterPlayMode(); + void exitPlayMode(); + + bool playing{true}; + bool playModeToggleRequested{false}; + std::optional editSnapshot; + std::weak_ptr editScene; + }; diff --git a/destrum/include/destrum/Scene/SceneManager.h b/destrum/include/destrum/Scene/SceneManager.h index a81e56d..44340f9 100644 --- a/destrum/include/destrum/Scene/SceneManager.h +++ b/destrum/include/destrum/Scene/SceneManager.h @@ -18,6 +18,9 @@ public: Scene& GetCurrentScene() const; const std::vector>& GetActiveScenes() const { return m_activeScenes; } + void SetPlaying(bool playing) { m_playing = playing; } + [[nodiscard]] bool IsPlaying() const { return m_playing; } + void Update(float dt); void FixedUpdate(float dt); void LateUpdate(float dt); @@ -52,6 +55,7 @@ private: std::vector> m_activeScenes; std::vector> m_scenes; + bool m_playing{true}; }; #endif //SCENEMANAGER_H diff --git a/destrum/include/destrum/Serialization/MaterialSerialization.h b/destrum/include/destrum/Serialization/MaterialSerialization.h index 72dedbc..60be578 100644 --- a/destrum/include/destrum/Serialization/MaterialSerialization.h +++ b/destrum/include/destrum/Serialization/MaterialSerialization.h @@ -18,10 +18,8 @@ namespace MaterialSerialization { [[nodiscard]] std::optional Validate(const nlohmann::json& materials); - // Materials that only exist at runtime (no source file) are stored in the scene or prefab - // file under their cache key, so the materialKeys in its objects resolve after a restart [[nodiscard]] nlohmann::json CollectEmbedded(const nlohmann::json& objects); - void RegisterEmbedded(const nlohmann::json& materials); + void RegisterEmbedded(const nlohmann::json& materials, bool overwriteExisting = false); } #endif //DESTRUM_MATERIALSERIALIZATION_H diff --git a/destrum/include/destrum/Serialization/SceneSerializer.h b/destrum/include/destrum/Serialization/SceneSerializer.h index dd583c3..8146aad 100644 --- a/destrum/include/destrum/Serialization/SceneSerializer.h +++ b/destrum/include/destrum/Serialization/SceneSerializer.h @@ -4,6 +4,7 @@ #include #include #include +#include #include @@ -17,8 +18,11 @@ public: nlohmann::json root; }; + [[nodiscard]] static std::optional Capture(Scene& scene); static bool Save(Scene& scene, const std::filesystem::path& path); + static bool LoadFromJson(Scene& scene, const nlohmann::json& root); + static bool Load(Scene& scene, const std::filesystem::path& path, std::function progressCallback = nullptr); @@ -27,8 +31,6 @@ public: static bool ConstructScene(Scene& scene, LoadResult& result, std::function progressCallback = nullptr); - // Parse the scene file on a background thread. Call LoadFromJson on the - // main thread once the future is ready. static std::future LoadSceneFileAsync(const std::filesystem::path& path); }; diff --git a/destrum/src/App.cpp b/destrum/src/App.cpp index b297321..abb5365 100644 --- a/destrum/src/App.cpp +++ b/destrum/src/App.cpp @@ -7,7 +7,10 @@ #include #include +#include +#include #include +#include #include #include "imgui.h" @@ -67,6 +70,8 @@ void App::init(const AppParams& params) customInitStarted = true; customInit(); + playing = !params.startInEditMode; + SceneManager::GetInstance().SetPlaying(playing); cleanedUp = false; } catch (...) { try { @@ -179,6 +184,11 @@ if (!isRunning) break; continue; } + if (InputManager::GetInstance().WasKeyPressed(SDL_SCANCODE_F5)) + { + playModeToggleRequested = true; + } + // Consume SDL events before updating the base camera so held and // newly pressed inputs are applied in the same frame. camera.Update(dt); @@ -239,6 +249,14 @@ if (!isRunning) break; imguiPass.endFrame(); } + if (playModeToggleRequested) + { + ZoneScopedN("Play Mode Toggle"); + playModeToggleRequested = false; + applyPlayModeToggle(); + accumulator = 0.0f; + } + { ZoneScopedN("FixedUpdate"); @@ -406,9 +424,111 @@ void App::drawWindowsMenu() ImGui::EndMenu(); } + drawPlayControls(); + ImGui::EndMainMenuBar(); } +void App::drawPlayControls() +{ + const char* state = playing ? "PLAYING" : "EDIT"; + const char* action = playing ? "Stop (F5)" : "Play (F5)"; + const ImGuiStyle& style = ImGui::GetStyle(); + const float width = ImGui::CalcTextSize(state).x + ImGui::CalcTextSize(action).x + + style.ItemSpacing.x * 2.0f + style.FramePadding.x * 2.0f; + + // Centered like Godot's run buttons, unless the menus already reach past the middle + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), (ImGui::GetWindowWidth() - width) * 0.5f)); + ImGui::TextColored(playing ? ImVec4{0.4f, 0.9f, 0.4f, 1.0f} : ImVec4{0.7f, 0.7f, 0.7f, 1.0f}, "%s", state); + if (ImGui::MenuItem(action)) + { + playModeToggleRequested = true; + } +} + +void App::applyPlayModeToggle() +{ + if (playing) + { + exitPlayMode(); + } + else + { + enterPlayMode(); + } +} + +bool App::enterPlayMode() +{ + auto& sceneManager = SceneManager::GetInstance(); + if (sceneManager.GetActiveScenes().empty()) + { + spdlog::error("Cannot enter play mode without an active scene"); + return false; + } + + const std::shared_ptr scene = sceneManager.GetActiveScenes().front(); + auto snapshot = SceneSerializer::Capture(*scene); + if (!snapshot) + { + spdlog::error("Cannot enter play mode, scene \"{}\" could not be saved", scene->GetName()); + return false; + } + if (const GameObject* selected = scene->GetSelectedObject()) + { + (*snapshot)["selected"] = selected->GetId(); + } + + // Play runs the scene as it would load from disk, so nothing the editor did to the live objects + // (started components, unsaved runtime state) leaks into the game + if (!SceneSerializer::LoadFromJson(*scene, *snapshot)) + { + spdlog::error("Cannot enter play mode, scene \"{}\" did not load back", scene->GetName()); + return false; + } + + editSnapshot = std::move(snapshot); + editScene = scene; + playing = true; + sceneManager.SetPlaying(true); + spdlog::info("Entered play mode"); + onPlayModeChanged(true); + return true; +} + +void App::exitPlayMode() +{ + auto& sceneManager = SceneManager::GetInstance(); + playing = false; + sceneManager.SetPlaying(false); + InputManager::GetInstance().SetMouseCaptured(false); + + const std::shared_ptr scene = editScene.lock(); + if (scene && editSnapshot) + { + const auto& scenes = sceneManager.GetScenes(); + const auto it = std::find(scenes.begin(), scenes.end(), scene); + if (it != scenes.end() && (sceneManager.GetActiveScenes().empty() || + sceneManager.GetActiveScenes().front() != scene)) + { + sceneManager.SwitchScene(static_cast(std::distance(scenes.begin(), it))); + } + if (!SceneSerializer::LoadFromJson(*scene, *editSnapshot)) + { + spdlog::error("Could not restore scene \"{}\" to its state before play mode", scene->GetName()); + } + } + else + { + spdlog::warn("The scene play mode started from no longer exists, nothing to restore"); + } + + editSnapshot.reset(); + editScene.reset(); + spdlog::info("Exited play mode"); + onPlayModeChanged(false); +} + void App::drawPhysicsPanel(float dt, float fixedDt, float accumulator) { if (!ImGui::Begin("Physics", &m_DebugWindows.physics)) diff --git a/destrum/src/Scene/SceneManager.cpp b/destrum/src/Scene/SceneManager.cpp index 8d7d48d..1940167 100644 --- a/destrum/src/Scene/SceneManager.cpp +++ b/destrum/src/Scene/SceneManager.cpp @@ -16,17 +16,27 @@ Scene& SceneManager::GetCurrentScene() const { void SceneManager::Update(float dt) { for (const auto& scene : m_activeScenes) { - scene->Update(dt); + if (m_playing) { + scene->Update(dt); + } else { + scene->CommitPendingAdditions(); + } } } void SceneManager::FixedUpdate(float dt) { + if (!m_playing) { + return; + } for (const auto& scene : m_activeScenes) { scene->FixedUpdate(dt); } } void SceneManager::LateUpdate(float dt) { + if (!m_playing) { + return; + } for (const auto& scene : m_activeScenes) { scene->LateUpdate(dt); } diff --git a/destrum/src/Serialization/MaterialSerialization.cpp b/destrum/src/Serialization/MaterialSerialization.cpp index 3079209..399e699 100644 --- a/destrum/src/Serialization/MaterialSerialization.cpp +++ b/destrum/src/Serialization/MaterialSerialization.cpp @@ -173,7 +173,7 @@ namespace MaterialSerialization { return materials; } - void RegisterEmbedded(const json& materials) { + void RegisterEmbedded(const json& materials, bool overwriteExisting) { RenderResources* resources = GetResources(); if (resources == nullptr || !materials.is_object()) { return; @@ -197,12 +197,14 @@ namespace MaterialSerialization { } }; - // A key that is already cached wins, so duplicating objects keeps sharing their material for (auto it = materials.begin(); it != materials.end(); ++it) { - if (resources->materials().findMaterialByKey(it.key())) { - continue; + const auto existing = resources->materials().findMaterialByKey(it.key()); + if (!existing) { + resources->materials().addMaterial(FromJson(it.value(), readTexture), it.key()); + } else if (overwriteExisting) { + resources->materials().getMaterialMutable(*existing) = FromJson(it.value(), readTexture); + GameState::GetInstance().Renderer().updateMaterialGPU(*existing); } - resources->materials().addMaterial(FromJson(it.value(), readTexture), it.key()); } } } diff --git a/destrum/src/Serialization/SceneSerializer.cpp b/destrum/src/Serialization/SceneSerializer.cpp index b0315f7..c1cdb10 100644 --- a/destrum/src/Serialization/SceneSerializer.cpp +++ b/destrum/src/Serialization/SceneSerializer.cpp @@ -50,6 +50,10 @@ namespace { return "\"currentCamera\" must be an object id"; } + if (root.contains("selected") && !root.at("selected").is_number_unsigned()) { + return "\"selected\" must be an object id"; + } + if (root.contains("materials")) { if (auto error = MaterialSerialization::Validate(root.at("materials"))) { return error; @@ -60,10 +64,10 @@ namespace { } } -bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) { +std::optional SceneSerializer::Capture(Scene& scene) { if (scene.IsIterating()) { spdlog::error("Cannot save a scene during an update or render phase: {}", scene.GetName()); - return false; + return std::nullopt; } RegisterEngineComponents(); @@ -71,7 +75,7 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) { scene.CommitPendingAdditions(); } catch (const std::exception& exception) { spdlog::error("Failed to commit scene additions before save: {}", exception.what()); - return false; + return std::nullopt; } json root; @@ -106,23 +110,31 @@ bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) { } } catch (const std::exception& exception) { spdlog::error("Cannot save scene \"{}\": {}", scene.GetName(), exception.what()); - return false; + return std::nullopt; } if (auto bad = ObjectSerialization::FindNonFinite(root, "scene")) { spdlog::error("Cannot save scene \"{}\": {} is NaN or Inf", scene.GetName(), *bad); - return false; + return std::nullopt; } // Refuse to write anything Load would reject if (auto error = ValidateSceneJson(root)) { spdlog::error("Cannot save scene \"{}\", it would not load back: {}", scene.GetName(), *error); + return std::nullopt; + } + return root; +} + +bool SceneSerializer::Save(Scene& scene, const std::filesystem::path& path) { + const auto root = Capture(scene); + if (!root) { return false; } std::string contents; try { - contents = root.dump(4); + contents = root->dump(4); } catch (const std::exception& exception) { spdlog::error("Failed to encode scene \"{}\": {}", scene.GetName(), exception.what()); return false; @@ -195,7 +207,7 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, try { if (root.contains("materials")) { context = "materials"; - MaterialSerialization::RegisterEmbedded(root.at("materials")); + MaterialSerialization::RegisterEmbedded(root.at("materials"), true); context = "scene"; } idMap = ObjectSerialization::BuildObjects(stagingScene, root.at("objects")).idMap; @@ -247,6 +259,11 @@ bool SceneSerializer::ConstructScene(Scene& scene, LoadResult& result, scene.m_name = root.at("name").get(); } scene.m_SelectedObjectId = InvalidObjectId; + if (const ObjectId selectedFileId = root.value("selected", InvalidObjectId); selectedFileId != InvalidObjectId) { + if (const auto it = idMap.find(selectedFileId); it != idMap.end()) { + scene.m_SelectedObjectId = it->second->GetId(); + } + } scene.m_CurrentCameraId = InvalidObjectId; if (const ObjectId cameraFileId = root.value("currentCamera", InvalidObjectId); cameraFileId != InvalidObjectId) { if (const auto it = idMap.find(cameraFileId); it != idMap.end()) { @@ -313,6 +330,18 @@ bool SceneSerializer::Load(Scene& scene, const std::filesystem::path& path, return ConstructScene(scene, result, std::move(progressCallback)); } +bool SceneSerializer::LoadFromJson(Scene& scene, const json& root) { + RegisterEngineComponents(); + LoadResult result; + if (auto error = ValidateSceneJson(root)) { + spdlog::error("Invalid scene data for \"{}\": {}", scene.GetName(), *error); + return false; + } + result.root = root; + result.success = true; + return ConstructScene(scene, result); +} + std::future SceneSerializer::LoadSceneFileAsync( const std::filesystem::path& path) { return std::async(std::launch::async, [path]() { diff --git a/lightkeeper/include/Lightkeeper.h b/lightkeeper/include/Lightkeeper.h index f48c2a1..b6167bb 100644 --- a/lightkeeper/include/Lightkeeper.h +++ b/lightkeeper/include/Lightkeeper.h @@ -1,6 +1,7 @@ #ifndef LIGHTKEEPER_H #define LIGHTKEEPER_H +#include #include #include @@ -28,6 +29,7 @@ public: void onWindowResize(int newWidth, int newHeight) override; void customWindowsMenu() override; + void onPlayModeChanged(bool nowPlaying) override; private: void setupSkybox(); @@ -53,6 +55,7 @@ private: void submitColliderDebugLines(); Camera camera{glm::vec3(0.f, 0.f, -5.f), glm::vec3(0, 1, 0)}; + std::optional editorCamera; std::unique_ptr skyboxCubemap; MainSceneObjects mainScene{}; diff --git a/lightkeeper/src/Lightkeeper.cpp b/lightkeeper/src/Lightkeeper.cpp index d8f5d60..e6c6e94 100644 --- a/lightkeeper/src/Lightkeeper.cpp +++ b/lightkeeper/src/Lightkeeper.cpp @@ -83,7 +83,7 @@ void LightKeeper::updateViewMode() auto& input = InputManager::GetInstance(); FirstPersonController* player = mainScene.playerController; - if (player != nullptr && input.WasKeyPressed(SDL_SCANCODE_F5)) + if (isPlaying() && player != nullptr && input.WasKeyPressed(SDL_SCANCODE_F6)) { player->SetPossessed(!player->IsPossessed()); } @@ -100,13 +100,13 @@ void LightKeeper::updateViewMode() bool LightKeeper::isPlayerViewActive() const { - return mainScene.playerController != nullptr && mainScene.playerController->IsPossessed(); + return isPlaying() && mainScene.playerController != nullptr && mainScene.playerController->IsPossessed(); } CameraComponent* LightKeeper::getSceneCamera() const { const auto& activeScenes = SceneManager::GetInstance().GetActiveScenes(); - if (useFreeCamera || activeScenes.empty()) { + if (!isPlaying() || useFreeCamera || activeScenes.empty()) { return nullptr; } return activeScenes.front()->GetCurrentCamera(); @@ -187,6 +187,23 @@ void LightKeeper::customFixedUpdate(float dt) SceneManager::GetInstance().FixedUpdate(dt); } +void LightKeeper::onPlayModeChanged(bool nowPlaying) +{ + findPlayer(); + if (nowPlaying) { + // The player view moves the free camera, put it back where the editor left it on Stop + editorCamera = camera; + if (mainScene.playerController != nullptr) { + mainScene.playerController->SetPossessed(true); + } + } else if (editorCamera) { + const float aspectRatio = camera.GetAspectRatio(); + camera = *editorCamera; + camera.setAspectRatio(aspectRatio); + editorCamera.reset(); + } +} + void LightKeeper::onWindowResize(int newWidth, int newHeight) { renderer.resize(gfxDevice, glm::ivec2{newWidth, newHeight}); diff --git a/lightkeeper/src/main.cpp b/lightkeeper/src/main.cpp index 5703bd7..23c55e7 100644 --- a/lightkeeper/src/main.cpp +++ b/lightkeeper/src/main.cpp @@ -28,6 +28,7 @@ int main(int argc, char* argv[]) { .appName = "Destrum Engine", .windowTitle = "Lightkeeper", .exeDir = exeDir, + .startInEditMode = true, }); app.run(); app.cleanup(); diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 4dc9da7..6d99721 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -37,6 +37,20 @@ namespace { std::string GetTypeName() const override { return "TestComponent"; } }; + class CountingComponent final : public Component { + public: + explicit CountingComponent(GameObject& owner) : Component(owner, "CountingComponent") {} + + void Update(float) override { ++updates; } + void LateUpdate(float) override { ++lateUpdates; } + void FixedUpdate(float) override { ++fixedUpdates; } + std::string GetTypeName() const override { return "CountingComponent"; } + + int updates{0}; + int lateUpdates{0}; + int fixedUpdates{0}; + }; + class RemovingListener final : public EventListener { public: explicit RemovingListener(std::unique_ptr* victim) @@ -268,6 +282,30 @@ namespace { SceneManager::GetInstance().Destroy(); } + + void testEditModeSkipsUpdates() + { + SceneManager& sceneManager = SceneManager::GetInstance(); + Scene& scene = sceneManager.CreateScene("edit mode"); + auto* counter = scene.CreateGameObject("counter")->AddComponent(); + + sceneManager.SetPlaying(false); + sceneManager.Update(0.1f); + sceneManager.FixedUpdate(0.1f); + sceneManager.LateUpdate(0.1f); + check(counter->updates == 0 && counter->fixedUpdates == 0 && counter->lateUpdates == 0, + "edit mode must not tick components"); + check(scene.GetObjects().size() == 1, "edit mode must still add objects created in the editor"); + + sceneManager.SetPlaying(true); + sceneManager.Update(0.1f); + sceneManager.FixedUpdate(0.1f); + sceneManager.LateUpdate(0.1f); + check(counter->updates == 1 && counter->fixedUpdates == 1 && counter->lateUpdates == 1, + "play mode must tick components"); + + sceneManager.Destroy(); + } } void RunCoreTests() @@ -280,4 +318,5 @@ void RunCoreTests() testEngineComponentRegistration(); testSceneCurrentCamera(); testHierarchyMoves(); + testEditModeSkipsUpdates(); } diff --git a/tests/serialization_tests.cpp b/tests/serialization_tests.cpp index c9417af..7dc07b7 100644 --- a/tests/serialization_tests.cpp +++ b/tests/serialization_tests.cpp @@ -334,6 +334,40 @@ namespace { SceneManager::GetInstance().Destroy(); } + + void testPlayModeSnapshotRestore() + { + Scene& scene = SceneManager::GetInstance().CreateScene("snapshot"); + GameObject* box = scene.CreateGameObject("box"); + box->GetTransform().SetLocalPosition({1.f, 2.f, 3.f}); + scene.CreateGameObject("other"); + scene.CommitPendingAdditions(); + scene.SetSelectedObject(box->GetId()); + + auto snapshot = SceneSerializer::Capture(scene); + check(snapshot.has_value(), "a scene must be capturable without a file"); + (*snapshot)["selected"] = box->GetId(); + + // What play mode does to the scene before Stop restores it + box->GetTransform().SetLocalPosition({9.f, 9.f, 9.f}); + scene.CreateGameObject("spawned during play"); + scene.CommitPendingAdditions(); + + check(SceneSerializer::LoadFromJson(scene, *snapshot), "restoring a snapshot must succeed"); + scene.CommitPendingAdditions(); + check(scene.GetObjects().size() == 2 && findByName(scene, "spawned during play") == nullptr, + "restoring must drop objects created after the snapshot"); + const GameObject* restored = findByName(scene, "box"); + check(restored != nullptr && close(restored->GetTransform().GetLocalPosition().x, 1.f), + "restoring must bring back the captured transforms"); + check(scene.GetSelectedObject() == restored, "the selection must follow the restored object"); + + check(!SceneSerializer::LoadFromJson(scene, nlohmann::json{{"objects", 5}}), + "an invalid snapshot must be rejected"); + check(scene.GetObjects().size() == 2, "a rejected snapshot must leave the scene alone"); + + SceneManager::GetInstance().Destroy(); + } } void RunSerializationTests() @@ -350,4 +384,5 @@ void RunSerializationTests() testSceneFileIdsAreLocal(); testMaterialJsonRoundTrip(); testSceneValidatesEmbeddedMaterials(); + testPlayModeSnapshotRestore(); }