closes #368 closes #367 #368 -- Frame(cache_subtree=True) silently froze its subtree's CONTENT. markContentDirty() gated its walk up the parent chain on (!was_dirty || !p->render_dirty). That is sound only if render_dirty is reliably cleared once a drawable has been drawn. It is not: clearDirty() had exactly one call site in the whole engine (UIGridView, added by #364 on this branch). UIFrame's non-render-texture path, UICaption, UISprite, UILine, UICircle and UIArc never cleared it, so render_dirty was a write-once latch -- true from an object's first mutation until it died. With the flag stuck true, was_dirty was always true AND the parent's render_dirty was always true, so the guard was always false and content invalidation propagated nowhere. A caching ancestor went on re-blitting a stale composite: text never updated, colours never changed, sprite indices never changed. Only movement survived, because x/y route through markCompositeDirty, whose walk was already unconditional. That asymmetry is why the bug read as intermittent rather than total, and it is why my original issue writeup got the shape wrong -- its repro's third step was a move, so it looked like the latch cleared. It does not. Measured on master: 4/4 recolours and 3/3 caption edits under a cache were ALL dropped, including the first. The fix makes markContentDirty's walk unconditional, matching markCompositeDirty. The alternative -- repair the guard by having every drawable clear the flag honestly -- buys a shorter walk at the price of an invariant that silently breaks the day someone adds a drawable and forgets. Not worth it here: parent chains are shallow, and this is the exact cost markCompositeDirty has always paid on every position change without anyone noticing. A/B on the changed path (200k content mutations, best of 3): depth 1 4 8 16 before 82ns 86ns 82ns 87ns <- flat because it did nothing after 76ns 114ns 175ns 295ns The old build's flatness IS the defect. Entity movement -- the actual hot path -- is unchanged (164ns/move either way), since it was already on the unconditional composite path. Crucible shows no regression (step_swarm 12.9 -> 10.7ms, entity_churn 36.3 -> 31.5ms, within noise). Regression test asserts every consecutive content mutation repaints through a cache at depths 1 and 5, AND that idle frames stay byte-identical -- without that last assertion, "mark everything dirty always" would pass and the cache would be worthless. #367 -- Losing window focus stranded hover exactly as a window-leave did (#363). An unfocused window is delivered no MouseMoved, so the hover walk never runs and whatever was hovered when focus left stays lit while the game sits in the background: on_exit/on_cell_exit never fire and hovered_cell keeps naming a cell the user is no longer pointing at. sf::Event::LostFocus was already produced by every backend -- SDL2Renderer even translates SDL_WINDOWEVENT_FOCUS_LOST into it -- and, like MouseLeft before #363, had nobody listening. Routed to the same PyScene::do_mouse_leave() that #363 added. Once unfocused the engine genuinely does not know where the pointer is, so "hovering nothing" is the only truthful state to hold; the next MouseMoved after focus returns restores it. automation.loseFocus() injects the event, via a new injectWindowEvent() rather than injectMouseEvent -- a focus event has no coordinates and no button, and should not borrow a signature that implies it does. api_surface golden re-baselined: +1 line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
970 lines
32 KiB
C++
970 lines
32 KiB
C++
#include "GameEngine.h"
|
|
#include "ActionCode.h"
|
|
#include <sys/stat.h>
|
|
#include "McRFPy_API.h"
|
|
#include "PyScene.h"
|
|
#include "UITestScene.h"
|
|
#include "Resources.h"
|
|
#include "Animation.h"
|
|
#include "Timer.h"
|
|
#include "BenchmarkLogger.h"
|
|
#include "platform/GLContext.h"
|
|
// ImGui is only available for SFML builds (not headless, not SDL2)
|
|
#if !defined(MCRF_HEADLESS) && !defined(MCRF_SDL2)
|
|
#include "imgui.h"
|
|
#include "imgui-SFML.h"
|
|
#endif
|
|
#include <cmath>
|
|
#include <Python.h>
|
|
#ifdef __EMSCRIPTEN__
|
|
#include <emscripten/html5.h>
|
|
#endif
|
|
|
|
// Static member definitions for shader intermediate texture (#106)
|
|
std::unique_ptr<sf::RenderTexture> GameEngine::shaderIntermediate;
|
|
bool GameEngine::shaderIntermediateInitialized = false;
|
|
|
|
// #219 - FrameLock implementation for thread-safe UI updates
|
|
|
|
void FrameLock::acquire() {
|
|
waiting++;
|
|
std::unique_lock<std::mutex> lock(mtx);
|
|
|
|
// Release GIL while waiting for safe window
|
|
Py_BEGIN_ALLOW_THREADS
|
|
cv.wait(lock, [this]{ return safe_window; });
|
|
Py_END_ALLOW_THREADS
|
|
|
|
waiting--;
|
|
active++;
|
|
}
|
|
|
|
void FrameLock::release() {
|
|
std::unique_lock<std::mutex> lock(mtx);
|
|
active--;
|
|
if (active == 0) {
|
|
cv.notify_all(); // Wake up closeWindow() if it's waiting
|
|
}
|
|
}
|
|
|
|
void FrameLock::openWindow() {
|
|
std::lock_guard<std::mutex> lock(mtx);
|
|
safe_window = true;
|
|
cv.notify_all(); // Wake up all waiting threads
|
|
}
|
|
|
|
void FrameLock::closeWindow() {
|
|
std::unique_lock<std::mutex> lock(mtx);
|
|
// First wait for all waiting threads to have entered the critical section
|
|
// (or confirm none were waiting). This prevents the race where we set
|
|
// safe_window=false before a waiting thread can check the condition.
|
|
cv.wait(lock, [this]{ return waiting == 0; });
|
|
// Then wait for all active threads to finish their critical sections
|
|
cv.wait(lock, [this]{ return active == 0; });
|
|
// Now safe to close the window
|
|
safe_window = false;
|
|
}
|
|
|
|
GameEngine::GameEngine() : GameEngine(McRogueFaceConfig{})
|
|
{
|
|
}
|
|
|
|
GameEngine::GameEngine(const McRogueFaceConfig& cfg)
|
|
: config(cfg), headless(cfg.headless)
|
|
{
|
|
// #219 - Store main thread ID for lock() thread detection
|
|
main_thread_id = std::this_thread::get_id();
|
|
|
|
Resources::font.loadFromFile("./assets/JetbrainsMono.ttf");
|
|
Resources::game = this;
|
|
window_title = "McRogueFace Engine";
|
|
|
|
// Ensure save/ directory exists for persistent game data
|
|
#ifndef __EMSCRIPTEN__
|
|
// Desktop: create save/ in working directory (WASM uses IDBFS mount from JS)
|
|
struct stat st;
|
|
if (stat("save", &st) != 0) {
|
|
#ifdef _WIN32
|
|
mkdir("save");
|
|
#else
|
|
mkdir("save", 0755);
|
|
#endif
|
|
}
|
|
#endif
|
|
|
|
// Initialize rendering based on headless mode
|
|
if (headless) {
|
|
headless_renderer = std::make_unique<HeadlessRenderer>();
|
|
if (!headless_renderer->init(1024, 768)) {
|
|
throw std::runtime_error("Failed to initialize headless renderer");
|
|
}
|
|
render_target = &headless_renderer->getRenderTarget();
|
|
} else {
|
|
window = std::make_unique<sf::RenderWindow>();
|
|
#ifdef __EMSCRIPTEN__
|
|
// Read actual canvas size from HTML template (may be fullscreen or layout-constrained)
|
|
// gameResolution stays at its default (1024x768) - the viewport system maps it to the canvas
|
|
int cw = 1024, ch = 768;
|
|
emscripten_get_canvas_element_size("#canvas", &cw, &ch);
|
|
window->create(sf::VideoMode(cw, ch), window_title, sf::Style::Titlebar | sf::Style::Close | sf::Style::Resize);
|
|
#else
|
|
window->create(sf::VideoMode(1024, 768), window_title, sf::Style::Titlebar | sf::Style::Close | sf::Style::Resize);
|
|
#endif
|
|
window->setFramerateLimit(60);
|
|
render_target = window.get();
|
|
|
|
// Initialize OpenGL function pointers via GLAD for 3D rendering
|
|
if (!mcrf::gl::initGL()) {
|
|
// Non-fatal: 3D features will be unavailable but 2D still works
|
|
fprintf(stderr, "Warning: OpenGL initialization failed. 3D features disabled.\n");
|
|
}
|
|
|
|
#if !defined(MCRF_HEADLESS) && !defined(MCRF_SDL2)
|
|
// Initialize ImGui for the window (SFML builds only)
|
|
if (ImGui::SFML::Init(*window)) {
|
|
imguiInitialized = true;
|
|
// Register settings handler before .ini is loaded (happens on first frame)
|
|
ImGuiConsole::registerSettingsHandler();
|
|
// Load JetBrains Mono for crisp console text (will be overridden by .ini if present)
|
|
ImGuiConsole::reloadFont(16.0f);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
visible = render_target->getDefaultView();
|
|
|
|
// Initialize the game view
|
|
gameView.setSize(static_cast<float>(gameResolution.x), static_cast<float>(gameResolution.y));
|
|
// Use integer center coordinates for pixel-perfect rendering
|
|
gameView.setCenter(std::floor(gameResolution.x / 2.0f), std::floor(gameResolution.y / 2.0f));
|
|
updateViewport();
|
|
scene = "uitest";
|
|
scenes["uitest"] = std::make_shared<UITestScene>(this);
|
|
|
|
McRFPy_API::game = this;
|
|
|
|
// Initialize profiler overlay
|
|
profilerOverlay = new ProfilerOverlay(Resources::font);
|
|
|
|
// Only load game.py if no custom script/command/module/exec is specified
|
|
bool should_load_game = config.script_path.empty() &&
|
|
config.python_command.empty() &&
|
|
config.python_module.empty() &&
|
|
config.exec_scripts.empty() &&
|
|
!config.interactive_mode &&
|
|
!config.python_mode;
|
|
|
|
if (should_load_game) {
|
|
if (!Py_IsInitialized()) {
|
|
McRFPy_API::api_init();
|
|
}
|
|
McRFPy_API::executePyString("import mcrfpy");
|
|
McRFPy_API::executeScript("scripts/game.py");
|
|
}
|
|
|
|
// Note: --exec scripts are NOT executed here.
|
|
// They are executed via executeStartupScripts() after the final engine is set up.
|
|
// This prevents double-execution when main.cpp creates multiple GameEngine instances.
|
|
|
|
clock.restart();
|
|
runtime.restart();
|
|
}
|
|
|
|
void GameEngine::executeStartupScripts()
|
|
{
|
|
// Execute any --exec scripts in order
|
|
// This is called ONCE from main.cpp after the final engine is set up
|
|
if (!config.exec_scripts.empty()) {
|
|
if (!Py_IsInitialized()) {
|
|
McRFPy_API::api_init();
|
|
}
|
|
McRFPy_API::executePyString("import mcrfpy");
|
|
|
|
for (const auto& exec_script : config.exec_scripts) {
|
|
std::cout << "Executing script: " << exec_script << std::endl;
|
|
McRFPy_API::executeScript(exec_script.string());
|
|
}
|
|
std::cout << "All --exec scripts completed" << std::endl;
|
|
}
|
|
}
|
|
|
|
GameEngine::~GameEngine()
|
|
{
|
|
cleanup();
|
|
// scenes map uses shared_ptr, will clean up automatically
|
|
scenes.clear();
|
|
delete profilerOverlay;
|
|
}
|
|
|
|
void GameEngine::cleanup()
|
|
{
|
|
if (cleaned_up) return;
|
|
cleaned_up = true;
|
|
|
|
// Clear all animations first (RAII handles invalidation)
|
|
AnimationManager::getInstance().clear();
|
|
|
|
// Clear Python references before destroying C++ objects
|
|
// Clear all timers (they hold Python callables)
|
|
timers.clear();
|
|
|
|
// Clear McRFPy_API's reference to this game engine
|
|
if (McRFPy_API::game == this) {
|
|
McRFPy_API::game = nullptr;
|
|
}
|
|
|
|
// Close window FIRST - ImGui-SFML requires window to be closed before Shutdown()
|
|
// because Shutdown() destroys sf::Cursor objects that the window may reference.
|
|
// See: modules/imgui-sfml/README.md - "Call ImGui::SFML::Shutdown() after window.close()"
|
|
if (window && window->isOpen()) {
|
|
window->close();
|
|
}
|
|
|
|
// Shutdown ImGui AFTER window is closed to avoid X11 BadCursor errors
|
|
#if !defined(MCRF_HEADLESS) && !defined(MCRF_SDL2)
|
|
if (imguiInitialized) {
|
|
ImGui::SFML::Shutdown();
|
|
imguiInitialized = false;
|
|
}
|
|
#endif
|
|
}
|
|
|
|
Scene* GameEngine::currentScene() { return scenes[scene].get(); }
|
|
Scene* GameEngine::getScene(const std::string& name) {
|
|
auto it = scenes.find(name);
|
|
return (it != scenes.end()) ? it->second.get() : nullptr;
|
|
}
|
|
|
|
std::vector<std::string> GameEngine::getSceneNames() const {
|
|
std::vector<std::string> names;
|
|
names.reserve(scenes.size());
|
|
for (const auto& [name, scene] : scenes) {
|
|
names.push_back(name);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
void GameEngine::changeScene(std::string s)
|
|
{
|
|
changeScene(s, TransitionType::None, 0.0f);
|
|
}
|
|
|
|
void GameEngine::changeScene(std::string sceneName, TransitionType transitionType, float duration)
|
|
{
|
|
if (scenes.find(sceneName) == scenes.end())
|
|
{
|
|
std::cout << "Attempted to change to a scene that doesn't exist (`" << sceneName << "`)" << std::endl;
|
|
return;
|
|
}
|
|
|
|
if (transitionType == TransitionType::None || duration <= 0.0f)
|
|
{
|
|
// Immediate scene change
|
|
std::string old_scene = scene;
|
|
scene = sceneName;
|
|
|
|
// Trigger Python scene lifecycle events
|
|
McRFPy_API::triggerSceneChange(old_scene, sceneName);
|
|
}
|
|
else
|
|
{
|
|
// Start transition with current game resolution
|
|
transition.width = gameResolution.x;
|
|
transition.height = gameResolution.y;
|
|
transition.start(transitionType, scene, sceneName, duration);
|
|
|
|
// Render current scene to texture
|
|
sf::RenderTarget* original_target = render_target;
|
|
render_target = transition.oldSceneTexture.get();
|
|
transition.oldSceneTexture->clear();
|
|
currentScene()->render();
|
|
transition.oldSceneTexture->display();
|
|
|
|
// Change to new scene
|
|
std::string old_scene = scene;
|
|
scene = sceneName;
|
|
|
|
// Render new scene to texture
|
|
render_target = transition.newSceneTexture.get();
|
|
transition.newSceneTexture->clear();
|
|
currentScene()->render();
|
|
transition.newSceneTexture->display();
|
|
|
|
// Restore original render target and scene
|
|
render_target = original_target;
|
|
scene = old_scene;
|
|
}
|
|
}
|
|
void GameEngine::quit() { running = false; }
|
|
void GameEngine::setPause(bool p) { paused = p; }
|
|
sf::Font & GameEngine::getFont() { /*return font; */ return Resources::font; }
|
|
sf::RenderWindow & GameEngine::getWindow() {
|
|
if (!window) {
|
|
throw std::runtime_error("Window not available in headless mode");
|
|
}
|
|
return *window;
|
|
}
|
|
|
|
sf::RenderTarget & GameEngine::getRenderTarget() {
|
|
return *render_target;
|
|
}
|
|
|
|
void GameEngine::createScene(std::string s) { scenes[s] = std::make_shared<PyScene>(this); }
|
|
|
|
void GameEngine::registerScene(const std::string& name, std::shared_ptr<Scene> scenePtr) {
|
|
scenes[name] = scenePtr;
|
|
}
|
|
|
|
void GameEngine::unregisterScene(const std::string& name) {
|
|
auto it = scenes.find(name);
|
|
if (it != scenes.end()) {
|
|
// If this was the active scene, we need to handle that
|
|
if (scene == name) {
|
|
// Find another scene to switch to, or leave empty
|
|
scene.clear();
|
|
for (const auto& [sceneName, scenePtr] : scenes) {
|
|
if (sceneName != name) {
|
|
scene = sceneName;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
scenes.erase(it);
|
|
}
|
|
}
|
|
|
|
void GameEngine::setWindowScale(float multiplier)
|
|
{
|
|
if (!headless && window) {
|
|
window->setSize(sf::Vector2u(gameResolution.x * multiplier, gameResolution.y * multiplier));
|
|
updateViewport();
|
|
}
|
|
}
|
|
|
|
// Emscripten callback support
|
|
#ifdef __EMSCRIPTEN__
|
|
#include <emscripten.h>
|
|
|
|
// Static callback for emscripten_set_main_loop_arg
|
|
static void emscriptenMainLoopCallback(void* arg) {
|
|
GameEngine* engine = static_cast<GameEngine*>(arg);
|
|
if (!engine->isRunning()) {
|
|
emscripten_cancel_main_loop();
|
|
engine->cleanup();
|
|
return;
|
|
}
|
|
engine->doFrame();
|
|
}
|
|
#endif
|
|
|
|
void GameEngine::run()
|
|
{
|
|
//std::cout << "GameEngine::run() starting main loop..." << std::endl;
|
|
frameTime = 0.016f; // Initialize to ~60 FPS
|
|
clock.restart();
|
|
|
|
#ifdef __EMSCRIPTEN__
|
|
// Browser: use callback-based loop (non-blocking)
|
|
// Start with 0 (requestAnimationFrame), then set timing based on framerate_limit
|
|
emscripten_set_main_loop_arg(emscriptenMainLoopCallback, this, 0, 1);
|
|
|
|
// Apply framerate_limit setting (0 = use RAF, >0 = use setTimeout)
|
|
if (framerate_limit == 0) {
|
|
emscripten_set_main_loop_timing(EM_TIMING_RAF, 1);
|
|
} else {
|
|
int interval_ms = 1000 / framerate_limit;
|
|
emscripten_set_main_loop_timing(EM_TIMING_SETTIMEOUT, interval_ms);
|
|
}
|
|
#else
|
|
// Desktop: traditional blocking loop
|
|
while (running)
|
|
{
|
|
doFrame();
|
|
}
|
|
|
|
// Clean up before exiting the run loop
|
|
cleanup();
|
|
|
|
// #144: Quick exit to avoid cleanup segfaults in Python/C++ destructor ordering
|
|
// This is a pragmatic workaround - proper cleanup would require careful
|
|
// attention to shared_ptr cycles and Python GC interaction
|
|
std::_Exit(0);
|
|
#endif
|
|
}
|
|
|
|
void GameEngine::doFrame()
|
|
{
|
|
// Reset per-frame metrics
|
|
metrics.resetPerFrame();
|
|
|
|
currentScene()->update();
|
|
testTimers();
|
|
|
|
// Update Python scenes
|
|
{
|
|
ScopedTimer pyTimer(metrics.pythonScriptTime);
|
|
McRFPy_API::updatePythonScenes(frameTime);
|
|
}
|
|
|
|
// Update animations (only if frameTime is valid)
|
|
if (frameTime > 0.0f && frameTime < 1.0f) {
|
|
ScopedTimer animTimer(metrics.animationTime);
|
|
AnimationManager::getInstance().update(frameTime);
|
|
}
|
|
|
|
if (!headless) {
|
|
sUserInput();
|
|
|
|
#if !defined(MCRF_HEADLESS) && !defined(MCRF_SDL2)
|
|
// Update ImGui (SFML builds only)
|
|
if (imguiInitialized) {
|
|
ImGui::SFML::Update(*window, clock.getElapsedTime());
|
|
}
|
|
#endif
|
|
}
|
|
if (!paused)
|
|
{
|
|
}
|
|
|
|
// Handle scene transitions
|
|
if (transition.type != TransitionType::None)
|
|
{
|
|
transition.update(frameTime);
|
|
|
|
if (transition.isComplete())
|
|
{
|
|
// Transition complete - finalize scene change
|
|
scene = transition.toScene;
|
|
transition.type = TransitionType::None;
|
|
|
|
// Trigger Python scene lifecycle events
|
|
McRFPy_API::triggerSceneChange(transition.fromScene, transition.toScene);
|
|
}
|
|
else
|
|
{
|
|
// Render transition
|
|
render_target->clear();
|
|
transition.render(*render_target);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Normal scene rendering
|
|
currentScene()->render();
|
|
}
|
|
|
|
// Update and render profiler overlay (if enabled)
|
|
if (profilerOverlay && !headless) {
|
|
profilerOverlay->update(metrics);
|
|
profilerOverlay->render(*render_target);
|
|
}
|
|
|
|
#if !defined(MCRF_HEADLESS) && !defined(MCRF_SDL2)
|
|
// Render ImGui overlays (console and scene explorer) - SFML builds only
|
|
if (imguiInitialized && !headless) {
|
|
console.render();
|
|
sceneExplorer.render(*this);
|
|
ImGui::SFML::Render(*window);
|
|
}
|
|
#endif
|
|
|
|
// Record work time before display (which may block for vsync/framerate limit)
|
|
metrics.workTime = clock.getElapsedTime().asSeconds() * 1000.0f;
|
|
|
|
// Display the frame
|
|
// #219 - Release GIL during display() to allow background threads to run
|
|
if (headless) {
|
|
Py_BEGIN_ALLOW_THREADS
|
|
headless_renderer->display();
|
|
Py_END_ALLOW_THREADS
|
|
// Take screenshot if requested
|
|
if (config.take_screenshot) {
|
|
headless_renderer->saveScreenshot(config.screenshot_path.empty() ? "screenshot.png" : config.screenshot_path);
|
|
config.take_screenshot = false; // Only take one screenshot
|
|
}
|
|
} else {
|
|
Py_BEGIN_ALLOW_THREADS
|
|
window->display();
|
|
Py_END_ALLOW_THREADS
|
|
}
|
|
|
|
// #219 - Safe window for background threads to modify UI
|
|
// This runs AFTER display() but BEFORE the next frame's processing
|
|
if (frameLock.hasWaiting()) {
|
|
frameLock.openWindow();
|
|
// Release GIL so waiting threads can proceed with their mcrfpy.lock() blocks
|
|
Py_BEGIN_ALLOW_THREADS
|
|
frameLock.closeWindow(); // Wait for all lock holders to complete
|
|
Py_END_ALLOW_THREADS
|
|
}
|
|
|
|
currentFrame++;
|
|
frameTime = clock.restart().asSeconds();
|
|
float fps = 1 / frameTime;
|
|
|
|
// Update profiling metrics
|
|
metrics.updateFrameTime(frameTime * 1000.0f); // Convert to milliseconds
|
|
|
|
// Record frame data for benchmark logging (if running)
|
|
g_benchmarkLogger.recordFrame(metrics);
|
|
|
|
int whole_fps = metrics.fps;
|
|
int tenth_fps = (metrics.fps * 10) % 10;
|
|
(void)whole_fps; (void)tenth_fps; (void)fps; // Suppress unused variable warnings
|
|
|
|
if (!headless && window) {
|
|
window->setTitle(window_title);
|
|
}
|
|
|
|
// In windowed mode, check if window was closed
|
|
if (!headless && window && !window->isOpen()) {
|
|
running = false;
|
|
}
|
|
|
|
// In headless exec mode, auto-exit when no timers remain
|
|
if (config.auto_exit_after_exec && timers.empty()) {
|
|
running = false;
|
|
}
|
|
|
|
// Check if a Python exception has signaled exit
|
|
if (McRFPy_API::shouldExit()) {
|
|
running = false;
|
|
}
|
|
}
|
|
|
|
std::shared_ptr<Timer> GameEngine::getTimer(const std::string& name)
|
|
{
|
|
auto it = timers.find(name);
|
|
if (it != timers.end()) {
|
|
return it->second;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
// Note: manageTimer() removed in #173 - use Timer objects directly
|
|
|
|
void GameEngine::testTimers()
|
|
{
|
|
int now = headless ? simulation_time : runtime.getElapsedTime().asMilliseconds();
|
|
auto it = timers.begin();
|
|
while (it != timers.end())
|
|
{
|
|
// Keep a local copy of the timer to prevent use-after-free.
|
|
// If the callback calls stop(), the timer may be marked for removal,
|
|
// but we need the Timer object to survive until test() returns.
|
|
auto timer = it->second;
|
|
|
|
// Skip stopped timers (they'll be removed below)
|
|
if (!timer->isStopped()) {
|
|
timer->test(now);
|
|
}
|
|
|
|
// Remove timers that have been stopped (including one-shot timers that fired).
|
|
// The stopped flag is the authoritative marker for "remove from map".
|
|
// Note: Check it->second (current map value) in case callback replaced it.
|
|
if (it->second->isStopped())
|
|
{
|
|
it = timers.erase(it);
|
|
}
|
|
else
|
|
{
|
|
it++;
|
|
}
|
|
}
|
|
}
|
|
|
|
void GameEngine::processEvent(const sf::Event& event)
|
|
{
|
|
std::string actionType;
|
|
int actionCode = 0;
|
|
|
|
if (event.type == sf::Event::Closed) { running = false; return; }
|
|
|
|
// Handle F3 for profiler overlay toggle
|
|
if (event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::F3) {
|
|
if (profilerOverlay) {
|
|
profilerOverlay->toggle();
|
|
}
|
|
return;
|
|
}
|
|
// Handle window resize events
|
|
else if (event.type == sf::Event::Resized) {
|
|
// Update the viewport to handle the new window size
|
|
updateViewport();
|
|
|
|
// Notify Python scenes about the resize
|
|
McRFPy_API::triggerResize(sf::Vector2u(event.size.width, event.size.height));
|
|
}
|
|
|
|
else if (event.type == sf::Event::KeyPressed || event.type == sf::Event::MouseButtonPressed || event.type == sf::Event::MouseWheelScrolled) actionType = "start";
|
|
else if (event.type == sf::Event::KeyReleased || event.type == sf::Event::MouseButtonReleased) actionType = "end";
|
|
|
|
if (event.type == sf::Event::MouseButtonPressed || event.type == sf::Event::MouseButtonReleased)
|
|
actionCode = ActionCode::keycode(event.mouseButton.button);
|
|
else if (event.type == sf::Event::KeyPressed || event.type == sf::Event::KeyReleased)
|
|
actionCode = ActionCode::keycode(event.key.code);
|
|
else if (event.type == sf::Event::MouseWheelScrolled)
|
|
{
|
|
if (event.mouseWheelScroll.wheel == sf::Mouse::VerticalWheel)
|
|
{
|
|
int delta = 1;
|
|
if (event.mouseWheelScroll.delta < 0) delta = -1;
|
|
actionCode = ActionCode::keycode(event.mouseWheelScroll.wheel, delta );
|
|
}
|
|
}
|
|
// #140 - Handle mouse movement for hover detection
|
|
else if (event.type == sf::Event::MouseMoved)
|
|
{
|
|
// Cast to PyScene to call do_mouse_hover
|
|
if (auto* pyscene = dynamic_cast<PyScene*>(currentScene())) {
|
|
pyscene->do_mouse_hover(event.mouseMove.x, event.mouseMove.y);
|
|
}
|
|
return;
|
|
}
|
|
// #363 - The cursor left the window. No further MouseMoved will arrive, so
|
|
// whatever is hovered must be exited now or it stays hovered forever.
|
|
//
|
|
// #367 - Losing focus strands hover identically. The cursor may still be physically
|
|
// over the window, but an unfocused window is delivered no MouseMoved, so the hover
|
|
// walk never runs and the last hovered drawable stays lit while the game sits in the
|
|
// background. Once unfocused we genuinely do not know where the pointer is, and
|
|
// "hovering nothing" is the only truthful state to hold; the next MouseMoved after
|
|
// focus returns restores hover.
|
|
else if (event.type == sf::Event::MouseLeft || event.type == sf::Event::LostFocus)
|
|
{
|
|
if (auto* pyscene = dynamic_cast<PyScene*>(currentScene())) {
|
|
pyscene->do_mouse_leave();
|
|
}
|
|
return;
|
|
}
|
|
else
|
|
return;
|
|
|
|
if (currentScene()->hasAction(actionCode))
|
|
{
|
|
std::string name = currentScene()->action(actionCode);
|
|
currentScene()->doAction(name, actionType);
|
|
}
|
|
else if (currentScene()->key_callable && !currentScene()->key_callable->isNone() &&
|
|
(event.type == sf::Event::KeyPressed || event.type == sf::Event::KeyReleased))
|
|
{
|
|
// Property-assigned handler (scene.on_key = callable)
|
|
currentScene()->key_callable->call(ActionCode::key_str(event.key.code), actionType);
|
|
}
|
|
else if (event.type == sf::Event::KeyPressed || event.type == sf::Event::KeyReleased)
|
|
{
|
|
// Try subclass on_key method if no property handler is set
|
|
McRFPy_API::triggerKeyEvent(ActionCode::key_str(event.key.code), actionType);
|
|
}
|
|
}
|
|
|
|
void GameEngine::sUserInput()
|
|
{
|
|
sf::Event event;
|
|
while (window && window->pollEvent(event))
|
|
{
|
|
#if !defined(MCRF_HEADLESS) && !defined(MCRF_SDL2)
|
|
// Process event through ImGui first (SFML builds only)
|
|
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
|
|
}
|
|
|
|
// Handle F4 for scene explorer toggle
|
|
if (event.type == sf::Event::KeyPressed && event.key.code == sf::Keyboard::F4) {
|
|
sceneExplorer.toggle();
|
|
continue; // Don't pass F4 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;
|
|
}
|
|
}
|
|
#endif
|
|
|
|
processEvent(event);
|
|
}
|
|
}
|
|
|
|
std::shared_ptr<std::vector<std::shared_ptr<UIDrawable>>> GameEngine::scene_ui(std::string target)
|
|
{
|
|
/*
|
|
// facts about maps
|
|
// You just can't do this during scenes["new_menu"] being assigned.
|
|
std::cout << "Current scene is: " << scene << ". Searching for: " << target << ".\n";
|
|
std::cout << "scenes.size(): " << scenes.size() << std::endl;
|
|
std::cout << "scenes.count(target): " << scenes.count(target) << std::endl;
|
|
std::cout << "scenes.find(target): " << std::distance(scenes.begin(), scenes.find(target)) << std::endl;
|
|
std::cout << "iterators: " << std::distance(scenes.begin(), scenes.begin()) << " " <<
|
|
std::distance(scenes.begin(), scenes.end()) << std::endl;
|
|
std::cout << "scenes.contains(target): " << scenes.contains(target) << std::endl;
|
|
std::cout << "scenes[target]: " << (long)(scenes[target].get()) << std::endl;
|
|
*/
|
|
if (scenes.count(target) == 0) return NULL;
|
|
return scenes[target]->ui_elements;
|
|
}
|
|
|
|
void GameEngine::setWindowTitle(const std::string& title)
|
|
{
|
|
window_title = title;
|
|
if (!headless && window) {
|
|
window->setTitle(title);
|
|
}
|
|
}
|
|
|
|
void GameEngine::setVSync(bool enabled)
|
|
{
|
|
vsync_enabled = enabled;
|
|
if (!headless && window) {
|
|
window->setVerticalSyncEnabled(enabled);
|
|
}
|
|
}
|
|
|
|
void GameEngine::setFramerateLimit(unsigned int limit)
|
|
{
|
|
framerate_limit = limit;
|
|
|
|
#ifdef __EMSCRIPTEN__
|
|
// For Emscripten: change loop timing dynamically
|
|
if (limit == 0) {
|
|
// Unlimited: use requestAnimationFrame (browser-controlled, typically 60fps)
|
|
emscripten_set_main_loop_timing(EM_TIMING_RAF, 1);
|
|
} else {
|
|
// Specific FPS: use setTimeout with calculated interval
|
|
int interval_ms = 1000 / limit;
|
|
emscripten_set_main_loop_timing(EM_TIMING_SETTIMEOUT, interval_ms);
|
|
}
|
|
#else
|
|
// For desktop: use SFML's setFramerateLimit
|
|
if (!headless && window) {
|
|
window->setFramerateLimit(limit);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void GameEngine::setGameResolution(unsigned int width, unsigned int height) {
|
|
gameResolution = sf::Vector2u(width, height);
|
|
gameView.setSize(static_cast<float>(width), static_cast<float>(height));
|
|
// Use integer center coordinates for pixel-perfect rendering
|
|
gameView.setCenter(std::floor(width / 2.0f), std::floor(height / 2.0f));
|
|
updateViewport();
|
|
}
|
|
|
|
void GameEngine::setViewportMode(ViewportMode mode) {
|
|
viewportMode = mode;
|
|
updateViewport();
|
|
}
|
|
|
|
std::string GameEngine::getViewportModeString() const {
|
|
switch (viewportMode) {
|
|
case ViewportMode::Center: return "center";
|
|
case ViewportMode::Stretch: return "stretch";
|
|
case ViewportMode::Fit: return "fit";
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
void GameEngine::updateViewport() {
|
|
if (!render_target) return;
|
|
|
|
auto windowSize = render_target->getSize();
|
|
|
|
switch (viewportMode) {
|
|
case ViewportMode::Center: {
|
|
// 1:1 pixels, centered in window
|
|
float viewportWidth = std::min(static_cast<float>(gameResolution.x), static_cast<float>(windowSize.x));
|
|
float viewportHeight = std::min(static_cast<float>(gameResolution.y), static_cast<float>(windowSize.y));
|
|
|
|
// Floor offsets to ensure integer pixel alignment
|
|
float offsetX = std::floor((windowSize.x - viewportWidth) / 2.0f);
|
|
float offsetY = std::floor((windowSize.y - viewportHeight) / 2.0f);
|
|
|
|
gameView.setViewport(sf::FloatRect(
|
|
offsetX / windowSize.x,
|
|
offsetY / windowSize.y,
|
|
viewportWidth / windowSize.x,
|
|
viewportHeight / windowSize.y
|
|
));
|
|
break;
|
|
}
|
|
|
|
case ViewportMode::Stretch: {
|
|
// Fill entire window, ignore aspect ratio
|
|
gameView.setViewport(sf::FloatRect(0, 0, 1, 1));
|
|
break;
|
|
}
|
|
|
|
case ViewportMode::Fit: {
|
|
// Maintain aspect ratio with black bars
|
|
float windowAspect = static_cast<float>(windowSize.x) / windowSize.y;
|
|
float gameAspect = static_cast<float>(gameResolution.x) / gameResolution.y;
|
|
|
|
float viewportWidth, viewportHeight;
|
|
float offsetX = 0, offsetY = 0;
|
|
|
|
if (windowAspect > gameAspect) {
|
|
// Window is wider - black bars on sides
|
|
// Calculate viewport size in pixels and floor for pixel-perfect scaling
|
|
float pixelHeight = static_cast<float>(windowSize.y);
|
|
float pixelWidth = std::floor(pixelHeight * gameAspect);
|
|
|
|
viewportHeight = 1.0f;
|
|
viewportWidth = pixelWidth / windowSize.x;
|
|
offsetX = (1.0f - viewportWidth) / 2.0f;
|
|
} else {
|
|
// Window is taller - black bars on top/bottom
|
|
// Calculate viewport size in pixels and floor for pixel-perfect scaling
|
|
float pixelWidth = static_cast<float>(windowSize.x);
|
|
float pixelHeight = std::floor(pixelWidth / gameAspect);
|
|
|
|
viewportWidth = 1.0f;
|
|
viewportHeight = pixelHeight / windowSize.y;
|
|
offsetY = (1.0f - viewportHeight) / 2.0f;
|
|
}
|
|
|
|
gameView.setViewport(sf::FloatRect(offsetX, offsetY, viewportWidth, viewportHeight));
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Apply the view
|
|
render_target->setView(gameView);
|
|
}
|
|
|
|
sf::Vector2f GameEngine::windowToGameCoords(const sf::Vector2f& windowPos) const {
|
|
if (!render_target) return windowPos;
|
|
|
|
// Convert window coordinates to game coordinates using the view
|
|
return render_target->mapPixelToCoords(sf::Vector2i(windowPos), gameView);
|
|
}
|
|
|
|
// #106 - Shader intermediate texture: shared texture for shader rendering
|
|
void GameEngine::initShaderIntermediate(unsigned int width, unsigned int height) {
|
|
if (!sf::Shader::isAvailable()) {
|
|
std::cerr << "GameEngine: Shaders not available, skipping intermediate texture init" << std::endl;
|
|
return;
|
|
}
|
|
|
|
if (!shaderIntermediate) {
|
|
shaderIntermediate = std::make_unique<sf::RenderTexture>();
|
|
}
|
|
|
|
if (!shaderIntermediate->create(width, height)) {
|
|
std::cerr << "GameEngine: Failed to create shader intermediate texture ("
|
|
<< width << "x" << height << ")" << std::endl;
|
|
shaderIntermediate.reset();
|
|
shaderIntermediateInitialized = false;
|
|
return;
|
|
}
|
|
|
|
shaderIntermediate->setSmooth(false); // Pixel-perfect rendering
|
|
shaderIntermediateInitialized = true;
|
|
}
|
|
|
|
sf::RenderTexture& GameEngine::getShaderIntermediate() {
|
|
if (!shaderIntermediateInitialized) {
|
|
// Initialize with game resolution from the engine instance
|
|
unsigned int w = 1024, h = 768;
|
|
if (Resources::game) {
|
|
auto res = Resources::game->getGameResolution();
|
|
w = res.x;
|
|
h = res.y;
|
|
}
|
|
initShaderIntermediate(w, h);
|
|
}
|
|
return *shaderIntermediate;
|
|
}
|
|
|
|
// #153 - Headless simulation control: step() advances simulation time
|
|
float GameEngine::step(float dt) {
|
|
// In windowed mode, step() is a no-op
|
|
if (!headless) {
|
|
return 0.0f;
|
|
}
|
|
|
|
float actual_dt;
|
|
|
|
if (dt < 0) {
|
|
// dt < 0 means "advance to next event"
|
|
// Find the minimum time until next timer fires
|
|
int min_remaining = INT_MAX;
|
|
|
|
for (auto& [name, timer] : timers) {
|
|
if (timer && timer->isActive()) {
|
|
int remaining = timer->getRemaining(simulation_time);
|
|
if (remaining > 0 && remaining < min_remaining) {
|
|
min_remaining = remaining;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also consider animations - find minimum time to completion
|
|
// AnimationManager doesn't expose this, so we'll just step by 1ms if no timers
|
|
if (min_remaining == INT_MAX) {
|
|
// No pending timers - check if there are active animations
|
|
// Step by a small amount to advance any running animations
|
|
min_remaining = 1; // 1ms minimum step
|
|
}
|
|
|
|
actual_dt = static_cast<float>(min_remaining) / 1000.0f; // Convert to seconds
|
|
simulation_time += min_remaining;
|
|
} else {
|
|
// Advance by specified amount
|
|
actual_dt = dt;
|
|
simulation_time += static_cast<int>(dt * 1000.0f); // Convert seconds to ms
|
|
}
|
|
|
|
// Update animations with the dt in seconds
|
|
if (actual_dt > 0.0f && actual_dt < 10.0f) { // Sanity check
|
|
AnimationManager::getInstance().update(actual_dt);
|
|
}
|
|
|
|
// Test timers with the new simulation time
|
|
auto it = timers.begin();
|
|
while (it != timers.end()) {
|
|
auto timer = it->second;
|
|
|
|
// Custom timer test using simulation time instead of runtime
|
|
if (timer && timer->isActive() && timer->hasElapsed(simulation_time)) {
|
|
timer->test(simulation_time);
|
|
}
|
|
|
|
// Remove cancelled timers
|
|
if (!it->second->getCallback() || it->second->getCallback() == Py_None) {
|
|
it = timers.erase(it);
|
|
} else {
|
|
it++;
|
|
}
|
|
}
|
|
|
|
return actual_dt;
|
|
}
|
|
|
|
// #153 - Force render the current scene (for synchronous screenshots)
|
|
void GameEngine::renderScene() {
|
|
if (!render_target) return;
|
|
|
|
// Handle scene transitions
|
|
if (transition.type != TransitionType::None) {
|
|
transition.update(0); // Don't advance transition time, just render current state
|
|
render_target->clear();
|
|
transition.render(*render_target);
|
|
} else {
|
|
// Normal scene rendering
|
|
currentScene()->render();
|
|
}
|
|
|
|
// For RenderTexture (headless), we need to call display()
|
|
if (headless && headless_renderer) {
|
|
headless_renderer->display();
|
|
}
|
|
}
|