Design Patterns and Video Games

2D Strategy Game (2): Automatic window scale

In the Discover Python and Patterns series, we always create a window whose size depends on the game scene. If the screen resolution is much lower or higher than its size, it leads to a poor user experience. We can solve this issue using the scale function inside Pygame!

This post is part of the 2D Strategy Game series

As you can see in this video, whatever the size of the window, the resolution of the game scene is always the same. We also add black borders to keep the aspect ratio:

Implementation

We update the previous program in the following way:

import pygame
from pygame.constants import HWSURFACE, DOUBLEBUF, RESIZABLE
from pygame.surface import Surface

pygame.init()

# Load image and create window with default resolution
window = pygame.display.set_mode((1024, 768), HWSURFACE | DOUBLEBUF | RESIZABLE)
pygame.display.set_caption("2D Medieval Strategy Game with Python, http://www.patternsgameprog.com")

# The size of our game scene is the one of the image
image = pygame.image.load("toen/screen_cap_2.png")
renderWidth = image.get_width()
renderHeight = image.get_height()

running = True
while running:

    # Handle input
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
            break
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                running = False
                break

    # Render scene in a surface
    renderSurface = Surface((renderWidth, renderHeight))
    renderSurface.blit(image, (0, 0))

    # Scale rendering to window size
    windowWidth, windowHeight = window.get_size()
    renderRatio = renderWidth / renderHeight
    windowRatio = windowWidth / windowHeight
    if windowRatio <= renderRatio:
        rescaledSurfaceWidth = windowWidth
        rescaledSurfaceHeight = int(windowWidth / renderRatio)
        rescaledSurfaceX = 0
        rescaledSurfaceY = (windowHeight - rescaledSurfaceHeight) // 2
    else:
        rescaledSurfaceWidth = int(windowHeight * renderRatio)
        rescaledSurfaceHeight = windowHeight
        rescaledSurfaceX = (windowWidth - rescaledSurfaceWidth) // 2
        rescaledSurfaceY = 0

    # Scale the rendering to the window/screen size
    rescaledSurface = pygame.transform.scale(
        renderSurface, (rescaledSurfaceWidth, rescaledSurfaceHeight)
    )
    window.blit(rescaledSurface, (rescaledSurfaceX, rescaledSurfaceY))
    pygame.display.update()

pygame.quit()

Initialization

Line 8 creates the window:

window = pygame.display.set_mode((1024, 768), HWSURFACE | DOUBLEBUF | RESIZABLE)

Its size is 1024 per 768 pixels: we can set any value; the following code rescales the game scene to this size.

Note the second argument of the set_mode() function: HWSURFACE | DOUBLEBUF | RESIZABLE. It is the combination of three options for window creation. We use the | operator to combine them: we want to enable all of them. The HWSURFACE and DOUBLEBUF options allow a faster blitting on the screen with video cards that support it (almost all cards today). With the RESIZABLE option, the user can resize the window.

Note that the full name of Pygame options starts with pygame.constants.. So, for instance, the full name of the resizable option is pygame.constants.RESIZABLE. Using an import at the beginning of the program, we can simplify this name:

from pygame.constants import RESIZABLE

Using Pycharm, we don't need to know the full name of library variables or functions. If you type RESIZABLE without the import, you'll see a red line below this word. Leave the mouse cursor on it, a popup menu appears, and click "Import this name". Then, choose the import you prefer: Pycharm adds it automatically.

Line 12 reads an image: it is the only "sprite" we use in this example. The pygame.image.load() function loads an image file and returns a surface:

image = pygame.image.load("toen/screen_cap_2.png")

Lines 13-14 define the size of the game scene:

renderWidth = image.get_width()
renderHeight = image.get_height()

The get_width() and get_height() methods of the Pygame Surface class return the width and height of the surface. The size of our game scene is the one of the image; you can try other sizes to see what happens.

Game loop, inputs, scene rendering

Lines 16-53 contain the main game loop. It still runs as long as the running variable is True.

Lines 20-27 handles input as before and quit if the user clicks the close button or presses the Escape key.

Lines 30-31 render our scene:

renderSurface = Surface((renderWidth, renderHeight))
renderSurface.blit(image, (0, 0))

The first line creates a new Pygame surface. Its size is the one we chose previously. We can draw inside safely since it always has the same size. The second line is an example of scene rendering: we blit a single image. This rendering is in memory: we have to draw it in the window to see it on the screen.

Rescale and keep aspect ratio

Lines 34-46 are the most important. They compute the size of our scene in the window without changing its aspect ratio. Otherwise, we could stretch the surface in one direction.

Line 34 gets the width and height of the window:

windowWidth, windowHeight = window.get_size()

The get_size() method of the Surface class returns a tuple of integers with the width and height of the surface. Note that we could also write:

windowWidth = window.get_width()
windowHeight = window.get_height()

Lines 35-36 compute the aspect ratios of the scene and window surfaces:

renderRatio = renderWidth / renderHeight
windowRatio = windowWidth / windowHeight

Note that the / division is a float division: renderRatio and windowRatio are float values (not integers).

Then, depending on these ratios, the size of the rescaled scene is different.

Lines 37-41 handle the case where the window aspect ratio is smaller or equal to the scene aspect ratio. It means that we can use the full window width but not the full height. Line 38 sets the rescaled width to the window width:

rescaledSurfaceWidth = windowWidth

Line 39 computes a height that leads to a rescaled size with the aspect ratio of the rendering:

rescaledSurfaceHeight = int(windowWidth / renderRatio)

Note the int() function that converts to integers. The scale function in the following expects integer values (not floats), so we make sure that it is the case.

We can do some maths to check that our computation is right:

rescaledAspectRatio = rescaledSurfaceWidth / rescaledSurfaceHeight
 = windowWidth / (windowWidth / renderRatio)
 = windowWidth / windowWidth * renderRatio
 = renderRatio

Lines 40-41 compute the coordinates of the rescaled surface in the window in order to center it:

rescaledSurfaceX = 0
rescaledSurfaceY = (windowHeight - rescaledSurfaceHeight) // 2

This time, we use the // integer division: rescaledSurfaceY is an integer.

Lines 42-46 do the same, but for the case where we can use the full height of the window.

Rescale and blit

Lines 49-51 uses the pygame.transform.scale() function to rescale the game scene:

rescaledSurface = pygame.transform.scale(
    renderSurface, (rescaledSurfaceWidth, rescaledSurfaceHeight)
)

The first argument is the surface to rescale, and the second one is the target size.

Line 52 blits the rescaled surface on the window/screen. The rescaledSurfaceX and rescaledSurfaceY values ensure that we center this surface:

window.blit(rescaledSurface, (rescaledSurfaceX, rescaledSurfaceY))

Final program

Download code and assets

In the next post, we start the design of the game state.