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:
John McCardle 2025-07-07 10:28:50 -04:00
parent 93256b96c6
commit 5a49cb7b6d
9 changed files with 626 additions and 12 deletions

View File

@ -65,6 +65,7 @@
- Grid background colors (#50) ✅
- RenderTexture base infrastructure ✅
- UIFrame clipping support ✅
- Viewport-based rendering (#8) ✅
### Active Development:
- **Branch**: alpha_streamline_2
@ -244,10 +245,13 @@ Rendering Layer:
⏳ Extend to other UI classes
⏳ Effects (blur, glow, etc.)
3. #8 - Viewport-based rendering [NEXT PRIORITY]
- RenderTexture matches viewport
- Proper scaling/letterboxing
- Coordinate system transformations
3. ✅ #8 - Viewport-based rendering [COMPLETED]
- Fixed game resolution (window.game_resolution)
- Three scaling modes: "center", "stretch", "fit"
- Window to game coordinate transformation
- Mouse input properly scaled with windowToGameCoords()
- Python API fully integrated
- Tests: test_viewport_simple.py, test_viewport_visual.py, test_viewport_scaling.py
4. #106 - Shader support [STRETCH GOAL]
sprite.shader = mcrfpy.Shader.load("glow.frag")
@ -267,7 +271,8 @@ Rendering Layer:
- Dirty flag system crucial for performance - only re-render when properties change
- Nested clipping works correctly with proper coordinate transformations
- Scene transitions already use RenderTextures - good integration test
- Next: Viewport rendering (#8) will build on RenderTexture foundation
- Viewport rendering (#8) ✅ complete with three scaling modes and coordinate transformation
- Next: Extend RenderTexture support to remaining UI classes (Caption, Sprite, Grid)
- Shader/Particle systems might be deferred to Phase 7 or Gamma
*Rationale*: This unlocks professional visual effects but is complex.

View File

@ -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);
}

View File

@ -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;

View File

@ -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);

View File

@ -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}
};

View File

@ -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);

View File

@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Test viewport scaling modes"""
import mcrfpy
from mcrfpy import Window, Frame, Caption, Color, Vector
import sys
def test_viewport_modes(runtime):
"""Test all three viewport scaling modes"""
mcrfpy.delTimer("test_viewport")
print("Testing viewport scaling modes...")
# Get window singleton
window = Window.get()
# Test initial state
print(f"Initial game resolution: {window.game_resolution}")
print(f"Initial scaling mode: {window.scaling_mode}")
print(f"Window resolution: {window.resolution}")
# Create test scene with visual elements
scene = mcrfpy.sceneUI("test")
# Create a frame that fills the game resolution to show boundaries
game_res = window.game_resolution
boundary = Frame(0, 0, game_res[0], game_res[1],
fill_color=Color(50, 50, 100),
outline_color=Color(255, 255, 255),
outline=2)
boundary.name = "boundary"
scene.append(boundary)
# Add corner markers
corner_size = 50
corners = [
(0, 0, "TL"), # Top-left
(game_res[0] - corner_size, 0, "TR"), # Top-right
(0, game_res[1] - corner_size, "BL"), # Bottom-left
(game_res[0] - corner_size, game_res[1] - corner_size, "BR") # Bottom-right
]
for x, y, label in corners:
corner = Frame(x, y, corner_size, corner_size,
fill_color=Color(255, 100, 100),
outline_color=Color(255, 255, 255),
outline=1)
scene.append(corner)
text = Caption(x + 5, y + 5, label)
text.font_size = 20
text.fill_color = Color(255, 255, 255)
scene.append(text)
# Add center crosshair
center_x = game_res[0] // 2
center_y = game_res[1] // 2
h_line = Frame(center_x - 50, center_y - 1, 100, 2,
fill_color=Color(255, 255, 0))
v_line = Frame(center_x - 1, center_y - 50, 2, 100,
fill_color=Color(255, 255, 0))
scene.append(h_line)
scene.append(v_line)
# Add mode indicator
mode_text = Caption(10, 10, f"Mode: {window.scaling_mode}")
mode_text.font_size = 24
mode_text.fill_color = Color(255, 255, 255)
mode_text.name = "mode_text"
scene.append(mode_text)
# Add instructions
instructions = Caption(10, 40,
"Press 1: Center mode (1:1 pixels)\n"
"Press 2: Stretch mode (fill window)\n"
"Press 3: Fit mode (maintain aspect ratio)\n"
"Press R: Change resolution\n"
"Press G: Change game resolution\n"
"Press Esc: Exit")
instructions.font_size = 14
instructions.fill_color = Color(200, 200, 200)
scene.append(instructions)
# Test changing modes
def test_mode_changes(runtime):
mcrfpy.delTimer("test_modes")
from mcrfpy import automation
print("\nTesting scaling modes:")
# Test center mode
window.scaling_mode = "center"
print(f"Set to center mode: {window.scaling_mode}")
mode_text.text = f"Mode: center (1:1 pixels)"
automation.screenshot("viewport_center_mode.png")
# Schedule next mode test
mcrfpy.setTimer("test_stretch", test_stretch_mode, 1000)
def test_stretch_mode(runtime):
mcrfpy.delTimer("test_stretch")
from mcrfpy import automation
window.scaling_mode = "stretch"
print(f"Set to stretch mode: {window.scaling_mode}")
mode_text.text = f"Mode: stretch (fill window)"
automation.screenshot("viewport_stretch_mode.png")
# Schedule next mode test
mcrfpy.setTimer("test_fit", test_fit_mode, 1000)
def test_fit_mode(runtime):
mcrfpy.delTimer("test_fit")
from mcrfpy import automation
window.scaling_mode = "fit"
print(f"Set to fit mode: {window.scaling_mode}")
mode_text.text = f"Mode: fit (aspect ratio maintained)"
automation.screenshot("viewport_fit_mode.png")
# Test different window sizes
mcrfpy.setTimer("test_resize", test_window_resize, 1000)
def test_window_resize(runtime):
mcrfpy.delTimer("test_resize")
from mcrfpy import automation
print("\nTesting window resize with fit mode:")
# Make window wider
window.resolution = (1280, 720)
print(f"Window resized to: {window.resolution}")
automation.screenshot("viewport_fit_wide.png")
# Make window taller
mcrfpy.setTimer("test_tall", test_tall_window, 1000)
def test_tall_window(runtime):
mcrfpy.delTimer("test_tall")
from mcrfpy import automation
window.resolution = (800, 1000)
print(f"Window resized to: {window.resolution}")
automation.screenshot("viewport_fit_tall.png")
# Test game resolution change
mcrfpy.setTimer("test_game_res", test_game_resolution, 1000)
def test_game_resolution(runtime):
mcrfpy.delTimer("test_game_res")
print("\nTesting game resolution change:")
window.game_resolution = (800, 600)
print(f"Game resolution changed to: {window.game_resolution}")
# Note: UI elements won't automatically reposition, but viewport will adjust
print("\nTest completed!")
print("Screenshots saved:")
print(" - viewport_center_mode.png")
print(" - viewport_stretch_mode.png")
print(" - viewport_fit_mode.png")
print(" - viewport_fit_wide.png")
print(" - viewport_fit_tall.png")
# Restore original settings
window.resolution = (1024, 768)
window.game_resolution = (1024, 768)
window.scaling_mode = "fit"
sys.exit(0)
# Start test sequence
mcrfpy.setTimer("test_modes", test_mode_changes, 500)
# Set up keyboard handler for manual testing
def handle_keypress(key, state):
if state != "start":
return
window = Window.get()
scene = mcrfpy.sceneUI("test")
mode_text = None
for elem in scene:
if hasattr(elem, 'name') and elem.name == "mode_text":
mode_text = elem
break
if key == "1":
window.scaling_mode = "center"
if mode_text:
mode_text.text = f"Mode: center (1:1 pixels)"
print(f"Switched to center mode")
elif key == "2":
window.scaling_mode = "stretch"
if mode_text:
mode_text.text = f"Mode: stretch (fill window)"
print(f"Switched to stretch mode")
elif key == "3":
window.scaling_mode = "fit"
if mode_text:
mode_text.text = f"Mode: fit (aspect ratio maintained)"
print(f"Switched to fit mode")
elif key == "r":
# Cycle through some resolutions
current = window.resolution
if current == (1024, 768):
window.resolution = (1280, 720)
elif current == (1280, 720):
window.resolution = (800, 600)
else:
window.resolution = (1024, 768)
print(f"Window resolution: {window.resolution}")
elif key == "g":
# Cycle game resolutions
current = window.game_resolution
if current == (1024, 768):
window.game_resolution = (800, 600)
elif current == (800, 600):
window.game_resolution = (640, 480)
else:
window.game_resolution = (1024, 768)
print(f"Game resolution: {window.game_resolution}")
elif key == "escape":
sys.exit(0)
# Main execution
print("Creating viewport test scene...")
mcrfpy.createScene("test")
mcrfpy.setScene("test")
mcrfpy.keypressScene(handle_keypress)
# Schedule the test
mcrfpy.setTimer("test_viewport", test_viewport_modes, 100)
print("Viewport test running...")
print("Use number keys to switch modes, R to resize window, G to change game resolution")

View File

@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Simple viewport test"""
import mcrfpy
from mcrfpy import Window
import sys
print("Testing viewport system...")
# Get window singleton
window = Window.get()
print(f"Game resolution: {window.game_resolution}")
print(f"Scaling mode: {window.scaling_mode}")
print(f"Window resolution: {window.resolution}")
# Test changing scaling mode
window.scaling_mode = "center"
print(f"Changed to center mode: {window.scaling_mode}")
window.scaling_mode = "stretch"
print(f"Changed to stretch mode: {window.scaling_mode}")
window.scaling_mode = "fit"
print(f"Changed to fit mode: {window.scaling_mode}")
# Test changing game resolution
window.game_resolution = (800, 600)
print(f"Changed game resolution to: {window.game_resolution}")
print("Test completed!")
sys.exit(0)

View File

@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Visual viewport test with screenshots"""
import mcrfpy
from mcrfpy import Window, Frame, Caption, Color
import sys
def test_viewport_visual(runtime):
"""Visual test of viewport modes"""
mcrfpy.delTimer("test")
print("Creating visual viewport test...")
# Get window singleton
window = Window.get()
# Create test scene
scene = mcrfpy.sceneUI("test")
# Create visual elements at game resolution boundaries
game_res = window.game_resolution
# Full boundary frame
boundary = Frame(0, 0, game_res[0], game_res[1],
fill_color=Color(40, 40, 80),
outline_color=Color(255, 255, 0),
outline=3)
scene.append(boundary)
# Corner markers
corner_size = 100
colors = [
Color(255, 100, 100), # Red TL
Color(100, 255, 100), # Green TR
Color(100, 100, 255), # Blue BL
Color(255, 255, 100), # Yellow BR
]
positions = [
(0, 0), # Top-left
(game_res[0] - corner_size, 0), # Top-right
(0, game_res[1] - corner_size), # Bottom-left
(game_res[0] - corner_size, game_res[1] - corner_size) # Bottom-right
]
labels = ["TL", "TR", "BL", "BR"]
for (x, y), color, label in zip(positions, colors, labels):
corner = Frame(x, y, corner_size, corner_size,
fill_color=color,
outline_color=Color(255, 255, 255),
outline=2)
scene.append(corner)
text = Caption(x + 10, y + 10, label)
text.font_size = 32
text.fill_color = Color(0, 0, 0)
scene.append(text)
# Center crosshair
center_x = game_res[0] // 2
center_y = game_res[1] // 2
h_line = Frame(0, center_y - 1, game_res[0], 2,
fill_color=Color(255, 255, 255, 128))
v_line = Frame(center_x - 1, 0, 2, game_res[1],
fill_color=Color(255, 255, 255, 128))
scene.append(h_line)
scene.append(v_line)
# Mode text
mode_text = Caption(center_x - 100, center_y - 50,
f"Mode: {window.scaling_mode}")
mode_text.font_size = 36
mode_text.fill_color = Color(255, 255, 255)
scene.append(mode_text)
# Resolution text
res_text = Caption(center_x - 150, center_y + 10,
f"Game: {game_res[0]}x{game_res[1]}")
res_text.font_size = 24
res_text.fill_color = Color(200, 200, 200)
scene.append(res_text)
from mcrfpy import automation
# Test different modes and window sizes
def test_sequence(runtime):
mcrfpy.delTimer("seq")
# Test 1: Fit mode with original size
print("Test 1: Fit mode, original window size")
automation.screenshot("viewport_01_fit_original.png")
# Test 2: Wider window
window.resolution = (1400, 768)
print(f"Test 2: Fit mode, wider window {window.resolution}")
automation.screenshot("viewport_02_fit_wide.png")
# Test 3: Taller window
window.resolution = (1024, 900)
print(f"Test 3: Fit mode, taller window {window.resolution}")
automation.screenshot("viewport_03_fit_tall.png")
# Test 4: Center mode
window.scaling_mode = "center"
mode_text.text = "Mode: center"
print(f"Test 4: Center mode {window.resolution}")
automation.screenshot("viewport_04_center.png")
# Test 5: Stretch mode
window.scaling_mode = "stretch"
mode_text.text = "Mode: stretch"
window.resolution = (1280, 720)
print(f"Test 5: Stretch mode {window.resolution}")
automation.screenshot("viewport_05_stretch.png")
# Test 6: Small window with fit
window.scaling_mode = "fit"
mode_text.text = "Mode: fit"
window.resolution = (640, 480)
print(f"Test 6: Fit mode, small window {window.resolution}")
automation.screenshot("viewport_06_fit_small.png")
print("\nViewport visual test completed!")
print("Screenshots saved:")
print(" - viewport_01_fit_original.png")
print(" - viewport_02_fit_wide.png")
print(" - viewport_03_fit_tall.png")
print(" - viewport_04_center.png")
print(" - viewport_05_stretch.png")
print(" - viewport_06_fit_small.png")
sys.exit(0)
# Start test sequence after a short delay
mcrfpy.setTimer("seq", test_sequence, 500)
# Main execution
print("Starting visual viewport test...")
mcrfpy.createScene("test")
mcrfpy.setScene("test")
mcrfpy.setTimer("test", test_viewport_visual, 100)
print("Test scheduled...")