feat(viewport): complete viewport-based rendering system (#8)
Implements a comprehensive viewport system that allows fixed game resolution with flexible window scaling, addressing the primary wishes for issues #34, #49, and #8. Key Features: - Fixed game resolution independent of window size (window.game_resolution property) - Three scaling modes accessible via window.scaling_mode: - "center": 1:1 pixels, viewport centered in window - "stretch": viewport fills window, ignores aspect ratio - "fit": maintains aspect ratio with black bars - Automatic window-to-game coordinate transformation for mouse input - Full Python API integration with PyWindow properties Technical Implementation: - GameEngine::ViewportMode enum with Center, Stretch, Fit modes - SFML View system for efficient GPU-based viewport scaling - updateViewport() recalculates on window resize or mode change - windowToGameCoords() transforms mouse coordinates correctly - PyScene mouse input automatically uses transformed coordinates Tests: - test_viewport_simple.py: Basic API functionality - test_viewport_visual.py: Visual verification with screenshots - test_viewport_scaling.py: Interactive mode switching and resizing This completes the viewport-based rendering task and provides the foundation for resolution-independent game development as requested for Crypt of Sokoban. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
93256b96c6
commit
5a49cb7b6d
9 changed files with 626 additions and 12 deletions
|
|
@ -32,6 +32,11 @@ GameEngine::GameEngine(const McRogueFaceConfig& cfg)
|
|||
}
|
||||
|
||||
visible = render_target->getDefaultView();
|
||||
|
||||
// Initialize the game view
|
||||
gameView.setSize(static_cast<float>(gameResolution.x), static_cast<float>(gameResolution.y));
|
||||
gameView.setCenter(gameResolution.x / 2.0f, gameResolution.y / 2.0f);
|
||||
updateViewport();
|
||||
scene = "uitest";
|
||||
scenes["uitest"] = new UITestScene(this);
|
||||
|
||||
|
|
@ -168,9 +173,9 @@ void GameEngine::createScene(std::string s) { scenes[s] = new PyScene(this); }
|
|||
void GameEngine::setWindowScale(float multiplier)
|
||||
{
|
||||
if (!headless && window) {
|
||||
window->setSize(sf::Vector2u(1024 * multiplier, 768 * multiplier)); // 7DRL 2024: window scaling
|
||||
window->setSize(sf::Vector2u(gameResolution.x * multiplier, gameResolution.y * multiplier));
|
||||
updateViewport();
|
||||
}
|
||||
//window.create(sf::VideoMode(1024 * multiplier, 768 * multiplier), window_title, sf::Style::Titlebar | sf::Style::Close);
|
||||
}
|
||||
|
||||
void GameEngine::run()
|
||||
|
|
@ -320,10 +325,8 @@ void GameEngine::processEvent(const sf::Event& event)
|
|||
if (event.type == sf::Event::Closed) { running = false; return; }
|
||||
// Handle window resize events
|
||||
else if (event.type == sf::Event::Resized) {
|
||||
// Update the view to match the new window size
|
||||
sf::FloatRect visibleArea(0, 0, event.size.width, event.size.height);
|
||||
visible = sf::View(visibleArea);
|
||||
render_target->setView(visible);
|
||||
// Update the viewport to handle the new window size
|
||||
updateViewport();
|
||||
|
||||
// Notify Python scenes about the resize
|
||||
McRFPy_API::triggerResize(event.size.width, event.size.height);
|
||||
|
|
@ -410,3 +413,89 @@ void GameEngine::setFramerateLimit(unsigned int limit)
|
|||
window->setFramerateLimit(limit);
|
||||
}
|
||||
}
|
||||
|
||||
void GameEngine::setGameResolution(unsigned int width, unsigned int height) {
|
||||
gameResolution = sf::Vector2u(width, height);
|
||||
gameView.setSize(static_cast<float>(width), static_cast<float>(height));
|
||||
gameView.setCenter(width / 2.0f, 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));
|
||||
|
||||
float offsetX = (windowSize.x - viewportWidth) / 2.0f;
|
||||
float offsetY = (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
|
||||
viewportHeight = 1.0f;
|
||||
viewportWidth = gameAspect / windowAspect;
|
||||
offsetX = (1.0f - viewportWidth) / 2.0f;
|
||||
} else {
|
||||
// Window is taller - black bars on top/bottom
|
||||
viewportWidth = 1.0f;
|
||||
viewportHeight = windowAspect / gameAspect;
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,15 @@
|
|||
|
||||
class GameEngine
|
||||
{
|
||||
public:
|
||||
// Viewport modes (moved here so private section can use it)
|
||||
enum class ViewportMode {
|
||||
Center, // 1:1 pixels, viewport centered in window
|
||||
Stretch, // viewport size = window size, doesn't respect aspect ratio
|
||||
Fit // maintains original aspect ratio, leaves black bars
|
||||
};
|
||||
|
||||
private:
|
||||
std::unique_ptr<sf::RenderWindow> window;
|
||||
std::unique_ptr<HeadlessRenderer> headless_renderer;
|
||||
sf::RenderTarget* render_target;
|
||||
|
|
@ -37,6 +46,13 @@ class GameEngine
|
|||
|
||||
// Scene transition state
|
||||
SceneTransition transition;
|
||||
|
||||
// Viewport system
|
||||
sf::Vector2u gameResolution{1024, 768}; // Fixed game resolution
|
||||
sf::View gameView; // View for the game content
|
||||
ViewportMode viewportMode = ViewportMode::Fit;
|
||||
|
||||
void updateViewport();
|
||||
|
||||
void testTimers();
|
||||
|
||||
|
|
@ -112,6 +128,14 @@ public:
|
|||
void setVSync(bool enabled);
|
||||
unsigned int getFramerateLimit() const { return framerate_limit; }
|
||||
void setFramerateLimit(unsigned int limit);
|
||||
|
||||
// Viewport system
|
||||
void setGameResolution(unsigned int width, unsigned int height);
|
||||
sf::Vector2u getGameResolution() const { return gameResolution; }
|
||||
void setViewportMode(ViewportMode mode);
|
||||
ViewportMode getViewportMode() const { return viewportMode; }
|
||||
std::string getViewportModeString() const;
|
||||
sf::Vector2f windowToGameCoords(const sf::Vector2f& windowPos) const;
|
||||
|
||||
// global textures for scripts to access
|
||||
std::vector<IndexTexture> textures;
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ void PyScene::do_mouse_input(std::string button, std::string type)
|
|||
}
|
||||
|
||||
auto unscaledmousepos = sf::Mouse::getPosition(game->getWindow());
|
||||
auto mousepos = game->getWindow().mapPixelToCoords(unscaledmousepos);
|
||||
// Convert window coordinates to game coordinates using the viewport
|
||||
auto mousepos = game->windowToGameCoords(sf::Vector2f(unscaledmousepos));
|
||||
|
||||
// Create a sorted copy by z-index (highest first)
|
||||
std::vector<std::shared_ptr<UIDrawable>> sorted_elements(*ui_elements);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
#include "GameEngine.h"
|
||||
#include "McRFPy_API.h"
|
||||
#include <SFML/Graphics.hpp>
|
||||
#include <cstring>
|
||||
|
||||
// Singleton instance - static variable, not a class member
|
||||
static PyWindowObject* window_instance = nullptr;
|
||||
|
|
@ -404,6 +405,82 @@ PyObject* PyWindow::screenshot(PyWindowObject* self, PyObject* args, PyObject* k
|
|||
return PyBytes_FromStringAndSize((const char*)pixels, size.x * size.y * 4);
|
||||
}
|
||||
|
||||
PyObject* PyWindow::get_game_resolution(PyWindowObject* self, void* closure)
|
||||
{
|
||||
GameEngine* game = McRFPy_API::game;
|
||||
if (!game) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "No game engine initialized");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
auto resolution = game->getGameResolution();
|
||||
return Py_BuildValue("(ii)", resolution.x, resolution.y);
|
||||
}
|
||||
|
||||
int PyWindow::set_game_resolution(PyWindowObject* self, PyObject* value, void* closure)
|
||||
{
|
||||
GameEngine* game = McRFPy_API::game;
|
||||
if (!game) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "No game engine initialized");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int width, height;
|
||||
if (!PyArg_ParseTuple(value, "ii", &width, &height)) {
|
||||
PyErr_SetString(PyExc_TypeError, "game_resolution must be a tuple of two integers (width, height)");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
PyErr_SetString(PyExc_ValueError, "Game resolution dimensions must be positive");
|
||||
return -1;
|
||||
}
|
||||
|
||||
game->setGameResolution(width, height);
|
||||
return 0;
|
||||
}
|
||||
|
||||
PyObject* PyWindow::get_scaling_mode(PyWindowObject* self, void* closure)
|
||||
{
|
||||
GameEngine* game = McRFPy_API::game;
|
||||
if (!game) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "No game engine initialized");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return PyUnicode_FromString(game->getViewportModeString().c_str());
|
||||
}
|
||||
|
||||
int PyWindow::set_scaling_mode(PyWindowObject* self, PyObject* value, void* closure)
|
||||
{
|
||||
GameEngine* game = McRFPy_API::game;
|
||||
if (!game) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "No game engine initialized");
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char* mode_str = PyUnicode_AsUTF8(value);
|
||||
if (!mode_str) {
|
||||
PyErr_SetString(PyExc_TypeError, "scaling_mode must be a string");
|
||||
return -1;
|
||||
}
|
||||
|
||||
GameEngine::ViewportMode mode;
|
||||
if (strcmp(mode_str, "center") == 0) {
|
||||
mode = GameEngine::ViewportMode::Center;
|
||||
} else if (strcmp(mode_str, "stretch") == 0) {
|
||||
mode = GameEngine::ViewportMode::Stretch;
|
||||
} else if (strcmp(mode_str, "fit") == 0) {
|
||||
mode = GameEngine::ViewportMode::Fit;
|
||||
} else {
|
||||
PyErr_SetString(PyExc_ValueError, "scaling_mode must be 'center', 'stretch', or 'fit'");
|
||||
return -1;
|
||||
}
|
||||
|
||||
game->setViewportMode(mode);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Property definitions
|
||||
PyGetSetDef PyWindow::getsetters[] = {
|
||||
{"resolution", (getter)get_resolution, (setter)set_resolution,
|
||||
|
|
@ -418,6 +495,10 @@ PyGetSetDef PyWindow::getsetters[] = {
|
|||
"Window visibility state", NULL},
|
||||
{"framerate_limit", (getter)get_framerate_limit, (setter)set_framerate_limit,
|
||||
"Frame rate limit (0 for unlimited)", NULL},
|
||||
{"game_resolution", (getter)get_game_resolution, (setter)set_game_resolution,
|
||||
"Fixed game resolution as (width, height) tuple", NULL},
|
||||
{"scaling_mode", (getter)get_scaling_mode, (setter)set_scaling_mode,
|
||||
"Viewport scaling mode: 'center', 'stretch', or 'fit'", NULL},
|
||||
{NULL}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ public:
|
|||
static int set_visible(PyWindowObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_framerate_limit(PyWindowObject* self, void* closure);
|
||||
static int set_framerate_limit(PyWindowObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_game_resolution(PyWindowObject* self, void* closure);
|
||||
static int set_game_resolution(PyWindowObject* self, PyObject* value, void* closure);
|
||||
static PyObject* get_scaling_mode(PyWindowObject* self, void* closure);
|
||||
static int set_scaling_mode(PyWindowObject* self, PyObject* value, void* closure);
|
||||
|
||||
// Methods
|
||||
static PyObject* center(PyWindowObject* self, PyObject* args);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue