Mobile-"ish" emscripten support

Full screen "wasm-game" for viewport compatibility between desktop and
web interfaces. Viewport modes ("fit", "center", and "stretch") should
now work the same way under WASM/SDL and SFML. This should also enable
android or web-for-mobile aspect ratios to be supported more easily.
This commit is contained in:
John McCardle 2026-02-09 08:40:34 -05:00
commit 726a9cf09d
9 changed files with 282 additions and 46 deletions

View file

@ -15,6 +15,9 @@
#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;
@ -84,7 +87,15 @@ GameEngine::GameEngine(const McRogueFaceConfig& cfg)
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();
@ -243,7 +254,9 @@ void GameEngine::changeScene(std::string sceneName, TransitionType transitionTyp
}
else
{
// Start transition
// 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
@ -829,8 +842,14 @@ void GameEngine::initShaderIntermediate(unsigned int width, unsigned int height)
sf::RenderTexture& GameEngine::getShaderIntermediate() {
if (!shaderIntermediateInitialized) {
// Initialize with default resolution if not already done
initShaderIntermediate(1024, 768);
// 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;
}

View file

@ -6,15 +6,15 @@ void SceneTransition::start(TransitionType t, const std::string& from, const std
toScene = to;
duration = dur;
elapsed = 0.0f;
// Initialize render textures if needed
if (!oldSceneTexture) {
oldSceneTexture = std::make_unique<sf::RenderTexture>();
oldSceneTexture->create(1024, 768);
oldSceneTexture->create(width, height);
}
if (!newSceneTexture) {
newSceneTexture = std::make_unique<sf::RenderTexture>();
newSceneTexture->create(1024, 768);
newSceneTexture->create(width, height);
}
}
@ -25,14 +25,17 @@ void SceneTransition::update(float dt) {
void SceneTransition::render(sf::RenderTarget& target) {
if (type == TransitionType::None) return;
float progress = getProgress();
float easedProgress = easeInOut(progress);
float w = static_cast<float>(width);
float h = static_cast<float>(height);
// Update sprites with current textures
oldSprite.setTexture(oldSceneTexture->getTexture());
newSprite.setTexture(newSceneTexture->getTexture());
switch (type) {
case TransitionType::Fade:
// Fade out old scene, fade in new scene
@ -41,39 +44,39 @@ void SceneTransition::render(sf::RenderTarget& target) {
target.draw(oldSprite);
target.draw(newSprite);
break;
case TransitionType::SlideLeft:
// Old scene slides out to left, new scene slides in from right
oldSprite.setPosition(-1024 * easedProgress, 0);
newSprite.setPosition(1024 * (1.0f - easedProgress), 0);
oldSprite.setPosition(-w * easedProgress, 0);
newSprite.setPosition(w * (1.0f - easedProgress), 0);
target.draw(oldSprite);
target.draw(newSprite);
break;
case TransitionType::SlideRight:
// Old scene slides out to right, new scene slides in from left
oldSprite.setPosition(1024 * easedProgress, 0);
newSprite.setPosition(-1024 * (1.0f - easedProgress), 0);
oldSprite.setPosition(w * easedProgress, 0);
newSprite.setPosition(-w * (1.0f - easedProgress), 0);
target.draw(oldSprite);
target.draw(newSprite);
break;
case TransitionType::SlideUp:
// Old scene slides up, new scene slides in from bottom
oldSprite.setPosition(0, -768 * easedProgress);
newSprite.setPosition(0, 768 * (1.0f - easedProgress));
oldSprite.setPosition(0, -h * easedProgress);
newSprite.setPosition(0, h * (1.0f - easedProgress));
target.draw(oldSprite);
target.draw(newSprite);
break;
case TransitionType::SlideDown:
// Old scene slides down, new scene slides in from top
oldSprite.setPosition(0, 768 * easedProgress);
newSprite.setPosition(0, -768 * (1.0f - easedProgress));
oldSprite.setPosition(0, h * easedProgress);
newSprite.setPosition(0, -h * (1.0f - easedProgress));
target.draw(oldSprite);
target.draw(newSprite);
break;
default:
break;
}
@ -82,4 +85,4 @@ void SceneTransition::render(sf::RenderTarget& target) {
float SceneTransition::easeInOut(float t) {
// Smooth ease-in-out curve
return t < 0.5f ? 2 * t * t : -1 + (4 - 2 * t) * t;
}
}

View file

@ -17,6 +17,8 @@ public:
TransitionType type = TransitionType::None;
float duration = 0.0f;
float elapsed = 0.0f;
unsigned int width = 1024;
unsigned int height = 768;
std::string fromScene;
std::string toScene;

View file

@ -1144,7 +1144,8 @@ PyObject* UIGrid::get_grid_h(PyUIGridObject* self, void* closure) {
}
PyObject* UIGrid::get_position(PyUIGridObject* self, void* closure) {
return Py_BuildValue("(ff)", self->data->position.x, self->data->position.y);
// #179 - Return position as Vector (consistent with get_size, get_grid_size)
return PyVector(self->data->position).pyObject();
}
int UIGrid::set_position(PyUIGridObject* self, PyObject* value, void* closure) {

View file

@ -10,6 +10,8 @@
#include <emscripten.h>
#include <Python.h>
#include <string>
#include "Resources.h"
#include "GameEngine.h"
extern "C" {
@ -290,6 +292,16 @@ int get_python_globals_count() {
return (int)PyDict_Size(main_dict);
}
// Notify the engine that the browser canvas was resized (called from JS)
EMSCRIPTEN_KEEPALIVE
void notify_canvas_resize(int width, int height) {
// Forward to SDL so it generates a proper resize event
if (Resources::game) {
auto& win = Resources::game->getWindow();
win.setSize(sf::Vector2u(width, height));
}
}
} // extern "C"
#endif // __EMSCRIPTEN__

View file

@ -567,23 +567,10 @@ void RenderWindow::create(VideoMode mode, const std::string& title, uint32_t sty
open_ = true;
#ifdef __EMSCRIPTEN__
// Force canvas size AFTER SDL window creation (SDL may have reset it)
// Force canvas backing buffer size AFTER SDL window creation (SDL may have reset it)
// CSS display size is controlled by the HTML shell template (fullscreen or layout-constrained)
emscripten_set_canvas_element_size("#canvas", mode.width, mode.height);
// Also set the CSS size to match
EM_ASM({
var canvas = document.getElementById('canvas');
if (canvas) {
canvas.width = $0;
canvas.height = $1;
canvas.style.width = $0 + 'px';
canvas.style.height = $1 + 'px';
console.log('EM_ASM: Set canvas to ' + $0 + 'x' + $1);
} else {
console.error('EM_ASM: Canvas element not found!');
}
}, mode.width, mode.height);
// Re-make context current after canvas resize
SDL_GL_MakeCurrent(window, context);
#endif
@ -997,6 +984,80 @@ void RenderTarget::draw(const VertexArray& vertices, const RenderStates& states)
draw(&vertices[0], vertices.getVertexCount(), vertices.getPrimitiveType(), states);
}
void RenderTarget::setView(const View& view) {
view_ = view;
// Apply the view's viewport (normalized 0-1 coords) to OpenGL
auto vp = view.getViewport();
int px = static_cast<int>(vp.left * size_.x);
// OpenGL viewport origin is bottom-left, SFML is top-left
int py = static_cast<int>((1.0f - vp.top - vp.height) * size_.y);
int pw = static_cast<int>(vp.width * size_.x);
int ph = static_cast<int>(vp.height * size_.y);
glViewport(px, py, pw, ph);
// Set projection to map view center/size to the viewport
auto center = view.getCenter();
auto sz = view.getSize();
float left = center.x - sz.x / 2.0f;
float right = center.x + sz.x / 2.0f;
float top = center.y - sz.y / 2.0f;
float bottom = center.y + sz.y / 2.0f;
SDL2Renderer::getInstance().setProjection(left, right, bottom, top);
}
IntRect RenderTarget::getViewport(const View& view) const {
auto vp = view.getViewport();
return IntRect(
static_cast<int>(vp.left * size_.x),
static_cast<int>(vp.top * size_.y),
static_cast<int>(vp.width * size_.x),
static_cast<int>(vp.height * size_.y)
);
}
Vector2f RenderTarget::mapPixelToCoords(const Vector2i& point) const {
return mapPixelToCoords(point, view_);
}
Vector2f RenderTarget::mapPixelToCoords(const Vector2i& point, const View& view) const {
// Convert pixel position to world coordinates through the view
auto viewport = getViewport(view);
auto center = view.getCenter();
auto sz = view.getSize();
// Normalize point within viewport (0-1)
float nx = (static_cast<float>(point.x) - viewport.left) / viewport.width;
float ny = (static_cast<float>(point.y) - viewport.top) / viewport.height;
// Map to view coordinates
return Vector2f(
center.x + sz.x * (nx - 0.5f),
center.y + sz.y * (ny - 0.5f)
);
}
Vector2i RenderTarget::mapCoordsToPixel(const Vector2f& point) const {
return mapCoordsToPixel(point, view_);
}
Vector2i RenderTarget::mapCoordsToPixel(const Vector2f& point, const View& view) const {
// Convert world coordinates to pixel position through the view
auto viewport = getViewport(view);
auto center = view.getCenter();
auto sz = view.getSize();
// Normalize within view (0-1)
float nx = (point.x - center.x) / sz.x + 0.5f;
float ny = (point.y - center.y) / sz.y + 0.5f;
// Map to pixel coordinates within viewport
return Vector2i(
static_cast<int>(viewport.left + nx * viewport.width),
static_cast<int>(viewport.top + ny * viewport.height)
);
}
// =============================================================================
// RenderTexture Implementation
// =============================================================================

View file

@ -821,16 +821,16 @@ public:
void draw(const Vertex* vertices, size_t vertexCount, PrimitiveType type, const RenderStates& states = RenderStates::Default);
void draw(const VertexArray& vertices, const RenderStates& states = RenderStates::Default);
void setView(const View& view) { view_ = view; }
void setView(const View& view); // Implemented in SDL2Renderer.cpp - applies glViewport + projection
const View& getView() const { return view_; }
const View& getDefaultView() const { return defaultView_; }
IntRect getViewport(const View& view) const { return IntRect(0, 0, size_.x, size_.y); }
IntRect getViewport(const View& view) const; // Implemented in SDL2Renderer.cpp
Vector2f mapPixelToCoords(const Vector2i& point) const { return Vector2f(static_cast<float>(point.x), static_cast<float>(point.y)); }
Vector2f mapPixelToCoords(const Vector2i& point, const View& view) const { return Vector2f(static_cast<float>(point.x), static_cast<float>(point.y)); }
Vector2i mapCoordsToPixel(const Vector2f& point) const { return Vector2i(static_cast<int>(point.x), static_cast<int>(point.y)); }
Vector2i mapCoordsToPixel(const Vector2f& point, const View& view) const { return Vector2i(static_cast<int>(point.x), static_cast<int>(point.y)); }
Vector2f mapPixelToCoords(const Vector2i& point) const; // Implemented in SDL2Renderer.cpp
Vector2f mapPixelToCoords(const Vector2i& point, const View& view) const; // Implemented in SDL2Renderer.cpp
Vector2i mapCoordsToPixel(const Vector2f& point) const; // Implemented in SDL2Renderer.cpp
Vector2i mapCoordsToPixel(const Vector2f& point, const View& view) const; // Implemented in SDL2Renderer.cpp
};
// =============================================================================

138
src/shell_game.html Normal file
View file

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<title>McRogueFace</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: #000;
}
body {
padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left);
}
#canvas {
display: block; position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
image-rendering: pixelated;
image-rendering: crisp-edges;
-ms-interpolation-mode: nearest-neighbor;
outline: none;
}
.loading {
position: fixed; inset: 0; display: flex;
flex-direction: column; justify-content: center;
align-items: center; background: #000; color: #eee;
font-family: 'Segoe UI', system-ui, sans-serif;
z-index: 10;
}
.loading.hidden { display: none; }
.spinner {
width: 50px; height: 50px; border: 5px solid #333;
border-top-color: #e94560; border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#status {
margin-top: 15px;
font-size: 14px;
color: #888;
}
</style>
</head>
<body>
<canvas id="canvas" oncontextmenu="event.preventDefault()" tabindex="-1"></canvas>
<div class="loading" id="loading">
<div class="spinner"></div>
<p id="status">Loading...</p>
</div>
<script>
var canvasElement = document.getElementById('canvas');
var loadingElement = document.getElementById('loading');
var statusElement = document.getElementById('status');
var runtimeReady = false;
function updateCanvasSize() {
var w = Math.floor(window.innerWidth);
var h = Math.floor(window.innerHeight);
if (runtimeReady) {
// Notify C++ engine, which updates SDL + viewport system
Module.ccall('notify_canvas_resize', null, ['number', 'number'], [w, h]);
} else {
// Before runtime init, set canvas backing buffer directly
canvasElement.width = w;
canvasElement.height = h;
}
}
updateCanvasSize();
window.addEventListener('resize', updateCanvasSize);
// Focus canvas on click
canvasElement.addEventListener('click', function() {
canvasElement.focus();
});
canvasElement.addEventListener('mousedown', function() {
if (document.activeElement !== canvasElement) {
canvasElement.focus();
}
});
var Module = {
print: function(text) {
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
console.log(text);
},
printErr: function(text) {
if (arguments.length > 1) text = Array.prototype.slice.call(arguments).join(' ');
console.error(text);
},
canvas: canvasElement,
setStatus: function(text) {
if (!Module.setStatus.last) Module.setStatus.last = { time: Date.now(), text: '' };
if (text === Module.setStatus.last.text) return;
var m = text.match(/([^(]+)\((\d+(\.\d+)?)\/(\d+)\)/);
var now = Date.now();
if (m && now - Module.setStatus.last.time < 30) return;
Module.setStatus.last.time = now;
Module.setStatus.last.text = text;
if (m) {
text = m[1];
}
statusElement.textContent = text;
},
totalDependencies: 0,
monitorRunDependencies: function(left) {
this.totalDependencies = Math.max(this.totalDependencies, left);
Module.setStatus(left ? 'Preparing... (' + (this.totalDependencies - left) + '/' + this.totalDependencies + ')' : 'All downloads complete.');
},
onRuntimeInitialized: function() {
runtimeReady = true;
loadingElement.classList.add('hidden');
canvasElement.focus();
// Trigger resize so C++ picks up the actual canvas dimensions
window.dispatchEvent(new Event('resize'));
}
};
Module.setStatus('Downloading...');
window.onerror = function(event) {
Module.setStatus('Error! See console.');
};
if (typeof resolveGlobalSymbol === 'undefined') {
window.resolveGlobalSymbol = function(name, direct) {
return {
sym: Module['_' + name] || Module[name],
type: 'function'
};
};
}
</script>
{{{ SCRIPT }}}
</body>
</html>