diff --git a/CMakeLists.txt b/CMakeLists.txt index 4ddd923..ac073a8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,16 +17,36 @@ include_directories(${CMAKE_SOURCE_DIR}/deps/libtcod) include_directories(${CMAKE_SOURCE_DIR}/deps/cpython) include_directories(${CMAKE_SOURCE_DIR}/deps/Python) +# ImGui and ImGui-SFML include directories +include_directories(${CMAKE_SOURCE_DIR}/modules/imgui) +include_directories(${CMAKE_SOURCE_DIR}/modules/imgui-sfml) + +# ImGui source files +set(IMGUI_SOURCES + ${CMAKE_SOURCE_DIR}/modules/imgui/imgui.cpp + ${CMAKE_SOURCE_DIR}/modules/imgui/imgui_draw.cpp + ${CMAKE_SOURCE_DIR}/modules/imgui/imgui_tables.cpp + ${CMAKE_SOURCE_DIR}/modules/imgui/imgui_widgets.cpp + ${CMAKE_SOURCE_DIR}/modules/imgui-sfml/imgui-SFML.cpp +) + # Collect all the source files file(GLOB_RECURSE SOURCES "src/*.cpp") +# Add ImGui sources to the build +list(APPEND SOURCES ${IMGUI_SOURCES}) + +# Find OpenGL (required by ImGui-SFML) +find_package(OpenGL REQUIRED) + # Create a list of libraries to link against -set(LINK_LIBS - sfml-graphics - sfml-window - sfml-system - sfml-audio - tcod) +set(LINK_LIBS + sfml-graphics + sfml-window + sfml-system + sfml-audio + tcod + OpenGL::GL) # On Windows, add any additional libs and include directories if(WIN32) diff --git a/modules/imgui b/modules/imgui index 313676d..c6e0284 160000 --- a/modules/imgui +++ b/modules/imgui @@ -1 +1 @@ -Subproject commit 313676d200f093e2694b5cfca574f72a2b116c85 +Subproject commit c6e0284ac58b3f205c95365478888f7b53b077e2 diff --git a/modules/imgui-sfml b/modules/imgui-sfml index de565ac..bf9023d 160000 --- a/modules/imgui-sfml +++ b/modules/imgui-sfml @@ -1 +1 @@ -Subproject commit de565ac8f2b795dedc0307b60830cb006afd2ecd +Subproject commit bf9023d1bc6ec422769559a5eff60bd00597354f diff --git a/src/GameEngine.cpp b/src/GameEngine.cpp index f82cd99..b133457 100644 --- a/src/GameEngine.cpp +++ b/src/GameEngine.cpp @@ -6,6 +6,8 @@ #include "Resources.h" #include "Animation.h" #include "Timer.h" +#include "imgui.h" +#include "imgui-SFML.h" #include GameEngine::GameEngine() : GameEngine(McRogueFaceConfig{}) @@ -31,8 +33,13 @@ GameEngine::GameEngine(const McRogueFaceConfig& cfg) window->create(sf::VideoMode(1024, 768), window_title, sf::Style::Titlebar | sf::Style::Close | sf::Style::Resize); window->setFramerateLimit(60); render_target = window.get(); + + // Initialize ImGui for the window + if (ImGui::SFML::Init(*window)) { + imguiInitialized = true; + } } - + visible = render_target->getDefaultView(); // Initialize the game view @@ -116,6 +123,12 @@ void GameEngine::cleanup() McRFPy_API::game = nullptr; } + // Shutdown ImGui before closing window + if (imguiInitialized) { + ImGui::SFML::Shutdown(); + imguiInitialized = false; + } + // Force close the window if it's still open if (window && window->isOpen()) { window->close(); @@ -224,6 +237,11 @@ void GameEngine::run() if (!headless) { sUserInput(); + + // Update ImGui + if (imguiInitialized) { + ImGui::SFML::Update(*window, clock.getElapsedTime()); + } } if (!paused) { @@ -262,6 +280,12 @@ void GameEngine::run() profilerOverlay->render(*render_target); } + // Render ImGui console overlay + if (imguiInitialized && !headless) { + console.render(); + ImGui::SFML::Render(*window); + } + // Display the frame if (headless) { headless_renderer->display(); @@ -420,6 +444,26 @@ void GameEngine::sUserInput() sf::Event event; while (window && window->pollEvent(event)) { + // Process event through ImGui first + if (imguiInitialized) { + ImGui::SFML::ProcessEvent(*window, event); + } + + // Handle grave/tilde key for console toggle (before other processing) + if (event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::Grave) { + console.toggle(); + continue; // Don't pass grave key to game + } + + // If console wants keyboard, don't pass keyboard events to game + if (console.wantsKeyboardInput()) { + // Still process non-keyboard events (mouse, window close, etc.) + if (event.type == sf::Event::KeyPressed || event.type == sf::Event::KeyReleased || + event.type == sf::Event::TextEntered) { + continue; + } + } + processEvent(event); } } diff --git a/src/GameEngine.h b/src/GameEngine.h index e212743..ed70bd5 100644 --- a/src/GameEngine.h +++ b/src/GameEngine.h @@ -10,6 +10,7 @@ #include "HeadlessRenderer.h" #include "SceneTransition.h" #include "Profiler.h" +#include "ImGuiConsole.h" #include #include @@ -62,6 +63,10 @@ private: int overlayUpdateCounter = 0; // Only update overlay every N frames ProfilerOverlay* profilerOverlay = nullptr; // The actual overlay renderer + // ImGui console overlay + ImGuiConsole console; + bool imguiInitialized = false; + void updateViewport(); void testTimers(); diff --git a/src/ImGuiConsole.cpp b/src/ImGuiConsole.cpp new file mode 100644 index 0000000..24b793c --- /dev/null +++ b/src/ImGuiConsole.cpp @@ -0,0 +1,246 @@ +#include "ImGuiConsole.h" +#include "imgui.h" +#include "McRFPy_API.h" +#include +#include + +// Static member initialization +bool ImGuiConsole::enabled = true; + +ImGuiConsole::ImGuiConsole() { + addOutput("McRogueFace Python Console", false); + addOutput("Type Python commands and press Enter to execute.", false); + addOutput("", false); +} + +void ImGuiConsole::toggle() { + if (enabled) { + visible = !visible; + if (visible) { + // Focus input when opening + ImGui::SetWindowFocus("Console"); + } + } +} + +bool ImGuiConsole::wantsKeyboardInput() const { + return visible && enabled; +} + +void ImGuiConsole::addOutput(const std::string& text, bool isError) { + // Split text by newlines and add each line separately + std::istringstream stream(text); + std::string line; + while (std::getline(stream, line)) { + outputHistory.push_back({line, isError, false}); + } + + // Trim history if too long + while (outputHistory.size() > MAX_HISTORY) { + outputHistory.pop_front(); + } + + scrollToBottom = true; +} + +void ImGuiConsole::executeCommand(const std::string& command) { + if (command.empty()) return; + + // Add command to output with >>> prefix + outputHistory.push_back({">>> " + command, false, true}); + + // Add to command history + commandHistory.push_back(command); + historyIndex = -1; + + // Capture Python output + // Redirect stdout/stderr to capture output + std::string captureCode = R"( +import sys +import io +_console_stdout = io.StringIO() +_console_stderr = io.StringIO() +_old_stdout = sys.stdout +_old_stderr = sys.stderr +sys.stdout = _console_stdout +sys.stderr = _console_stderr +)"; + + std::string restoreCode = R"( +sys.stdout = _old_stdout +sys.stderr = _old_stderr +_stdout_val = _console_stdout.getvalue() +_stderr_val = _console_stderr.getvalue() +)"; + + // Set up capture + PyRun_SimpleString(captureCode.c_str()); + + // Try to evaluate as expression first (for things like "2+2") + PyObject* main_module = PyImport_AddModule("__main__"); + PyObject* main_dict = PyModule_GetDict(main_module); + + // First try eval (for expressions that return values) + PyObject* result = PyRun_String(command.c_str(), Py_eval_input, main_dict, main_dict); + bool showedResult = false; + + if (result == nullptr) { + // Clear the error from eval attempt + PyErr_Clear(); + + // Try exec (for statements) + result = PyRun_String(command.c_str(), Py_file_input, main_dict, main_dict); + + if (result == nullptr) { + // Real error - capture it + PyErr_Print(); // This prints to stderr which we're capturing + } + } else if (result != Py_None) { + // Expression returned a non-None value - show its repr + PyObject* repr = PyObject_Repr(result); + if (repr) { + const char* repr_str = PyUnicode_AsUTF8(repr); + if (repr_str) { + addOutput(repr_str, false); + showedResult = true; + } + Py_DECREF(repr); + } + } + Py_XDECREF(result); + + // Restore stdout/stderr + PyRun_SimpleString(restoreCode.c_str()); + + // Get captured stdout (only if we didn't already show a result) + PyObject* stdout_val = PyObject_GetAttrString(main_module, "_stdout_val"); + if (stdout_val && PyUnicode_Check(stdout_val)) { + const char* stdout_str = PyUnicode_AsUTF8(stdout_val); + if (stdout_str && strlen(stdout_str) > 0) { + addOutput(stdout_str, false); + } + } + Py_XDECREF(stdout_val); + + // Get captured stderr + PyObject* stderr_val = PyObject_GetAttrString(main_module, "_stderr_val"); + if (stderr_val && PyUnicode_Check(stderr_val)) { + const char* stderr_str = PyUnicode_AsUTF8(stderr_val); + if (stderr_str && strlen(stderr_str) > 0) { + addOutput(stderr_str, true); + } + } + Py_XDECREF(stderr_val); + + // Clean up temporary variables + PyRun_SimpleString("del _console_stdout, _console_stderr, _old_stdout, _old_stderr, _stdout_val, _stderr_val"); + + scrollToBottom = true; +} + +void ImGuiConsole::render() { + if (!visible || !enabled) return; + + // Set up console window + ImGuiIO& io = ImGui::GetIO(); + ImGui::SetNextWindowSize(ImVec2(io.DisplaySize.x, io.DisplaySize.y * 0.4f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(0, 0), ImGuiCond_FirstUseEver); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; + + if (!ImGui::Begin("Console", &visible, flags)) { + ImGui::End(); + return; + } + + // Output area (scrollable, no horizontal scrollbar - use word wrap) + float footerHeight = ImGui::GetStyle().ItemSpacing.y + ImGui::GetFrameHeightWithSpacing(); + ImGui::BeginChild("ScrollingRegion", ImVec2(0, -footerHeight), false, ImGuiWindowFlags_None); + + // Render output lines with word wrap + for (const auto& line : outputHistory) { + if (line.isInput) { + // User input - yellow/gold color + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.9f, 0.4f, 1.0f)); + } else if (line.isError) { + // Error - red color + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.4f, 0.4f, 1.0f)); + } else { + // Normal output - default color + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.8f, 0.8f, 0.8f, 1.0f)); + } + + ImGui::TextWrapped("%s", line.text.c_str()); + ImGui::PopStyleColor(); + } + + // Auto-scroll to bottom when new content is added + if (scrollToBottom || ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) { + ImGui::SetScrollHereY(1.0f); + } + scrollToBottom = false; + + ImGui::EndChild(); + + // Input line + ImGui::Separator(); + + // Input field + ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue | + ImGuiInputTextFlags_CallbackHistory | + ImGuiInputTextFlags_CallbackCompletion; + + bool reclaimFocus = false; + + // Custom callback for history navigation + auto callback = [](ImGuiInputTextCallbackData* data) -> int { + ImGuiConsole* console = static_cast(data->UserData); + + if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { + if (console->commandHistory.empty()) return 0; + + if (data->EventKey == ImGuiKey_UpArrow) { + if (console->historyIndex < 0) { + console->historyIndex = static_cast(console->commandHistory.size()) - 1; + } else if (console->historyIndex > 0) { + console->historyIndex--; + } + } else if (data->EventKey == ImGuiKey_DownArrow) { + if (console->historyIndex >= 0) { + console->historyIndex++; + if (console->historyIndex >= static_cast(console->commandHistory.size())) { + console->historyIndex = -1; + } + } + } + + // Update input buffer + if (console->historyIndex >= 0 && console->historyIndex < static_cast(console->commandHistory.size())) { + const std::string& historyEntry = console->commandHistory[console->historyIndex]; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, historyEntry.c_str()); + } else { + data->DeleteChars(0, data->BufTextLen); + } + } + + return 0; + }; + + ImGui::PushItemWidth(-1); // Full width + if (ImGui::InputText("##Input", inputBuffer, sizeof(inputBuffer), inputFlags, callback, this)) { + std::string command(inputBuffer); + inputBuffer[0] = '\0'; + executeCommand(command); + reclaimFocus = true; + } + ImGui::PopItemWidth(); + + // Keep focus on input + ImGui::SetItemDefaultFocus(); + if (reclaimFocus || (visible && !ImGui::IsAnyItemActive())) { + ImGui::SetKeyboardFocusHere(-1); + } + + ImGui::End(); +} diff --git a/src/ImGuiConsole.h b/src/ImGuiConsole.h new file mode 100644 index 0000000..4c0aa83 --- /dev/null +++ b/src/ImGuiConsole.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include + +/** + * @brief ImGui-based debug console for Python REPL + * + * Provides an overlay console that can execute Python code + * without blocking the main game loop. Activated by grave/tilde key. + */ +class ImGuiConsole { +public: + ImGuiConsole(); + + // Core functionality + void render(); // Render the console UI + void toggle(); // Toggle visibility + bool isVisible() const { return visible; } + void setVisible(bool v) { visible = v; } + + // Configuration (for Python API) + static bool isEnabled() { return enabled; } + static void setEnabled(bool e) { enabled = e; } + + // Input handling + bool wantsKeyboardInput() const; // Returns true if ImGui wants keyboard + +private: + void executeCommand(const std::string& command); + void addOutput(const std::string& text, bool isError = false); + + // State + bool visible = false; + static bool enabled; // Global enable/disable (for shipping games) + + // Input buffer + char inputBuffer[1024] = {0}; + + // Output history + struct OutputLine { + std::string text; + bool isError; + bool isInput; // True if this was user input (for styling) + }; + std::deque outputHistory; + static constexpr size_t MAX_HISTORY = 500; + + // Command history for up/down navigation + std::vector commandHistory; + int historyIndex = -1; + + // Scroll state + bool scrollToBottom = true; +}; diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 0fb8e43..d817028 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -9,6 +9,7 @@ #include "PyWindow.h" #include "PySceneObject.h" #include "GameEngine.h" +#include "ImGuiConsole.h" #include "UI.h" #include "UILine.h" #include "UICircle.h" @@ -202,6 +203,16 @@ static PyMethodDef mcrfpyMethods[] = { MCRF_RETURNS("dict: Performance data with keys: frame_time (last frame duration in seconds), avg_frame_time (average frame time), fps (frames per second), draw_calls (number of draw calls), ui_elements (total UI element count), visible_elements (visible element count), current_frame (frame counter), runtime (total runtime in seconds)") )}, + {"setDevConsole", McRFPy_API::_setDevConsole, METH_VARARGS, + MCRF_FUNCTION(setDevConsole, + MCRF_SIG("(enabled: bool)", "None"), + MCRF_DESC("Enable or disable the developer console overlay."), + MCRF_ARGS_START + MCRF_ARG("enabled", "True to enable the console (default), False to disable") + MCRF_RETURNS("None") + MCRF_NOTE("When disabled, the grave/tilde key will not open the console. Use this to ship games without debug features.") + )}, + {NULL, NULL, 0, NULL} }; @@ -1195,6 +1206,16 @@ PyObject* McRFPy_API::_getMetrics(PyObject* self, PyObject* args) { return dict; } +PyObject* McRFPy_API::_setDevConsole(PyObject* self, PyObject* args) { + int enabled; + if (!PyArg_ParseTuple(args, "p", &enabled)) { // "p" for boolean predicate + return NULL; + } + + ImGuiConsole::setEnabled(enabled); + Py_RETURN_NONE; +} + // Exception handling implementation void McRFPy_API::signalPythonException() { // Check if we should exit on exception (consult config via game) diff --git a/src/McRFPy_API.h b/src/McRFPy_API.h index 81ba540..aa7c189 100644 --- a/src/McRFPy_API.h +++ b/src/McRFPy_API.h @@ -81,7 +81,10 @@ public: // Profiling/metrics static PyObject* _getMetrics(PyObject*, PyObject*); - + + // Developer console + static PyObject* _setDevConsole(PyObject*, PyObject*); + // Scene lifecycle management for Python Scene objects static void triggerSceneChange(const std::string& from_scene, const std::string& to_scene); static void updatePythonScenes(float dt);