もっと こだわり

A Change of Scene

Posted

One of the things I've seen crop up a few times in the r/pygame subreddit is people asking how to manage multiple scenes or screens in their games. This is a common challenge for beginners, and while there are many ways to approach it, I want to share a simple method that I think works well for small to medium-sized projects.

We'll start with a bare-bones Pygame program:

import pygame as pg


def run():
    pg.init()
    screen = pg.display.set_mode((800, 600))
    clock = pg.time.Clock()

    while True:
        delta_time = clock.tick(60) / 1000  # seconds since last frame
        events = pg.event.get()

        if any(event.type == pg.QUIT for event in events):
            break

        # Handle input, update game state and render
        screen.fill("black")

        pg.display.flip()

run()

Now let's say we want to add a separate title screen and game screen. To do this, we can factor out the input handling, updating, and rendering into a separate function for each scene. Each function will take the screen, input events, and delta time as parameters. The function will return the next scene function to be called, or return nothing to stay in the current scene.

We'll add these functions and modify the run function to allow switching between them:

import pygame as pg


def title_screen(screen, events, delta_time):
    if any(event.type == pg.KEYDOWN for event in events):
        return play_game

    screen.fill("blue")
    font = pg.Font(None, 30)
    title_text = font.render("Press any key to start", True, "white")
    screen.blit(title_text, title_text.get_rect(center=screen.get_rect().center))


def play_game(screen, events, delta_time):
    if any(event.type == pg.KEYDOWN for event in events):
        return title_screen

    screen.fill("green")


def run(initial_scene):
    pg.init()
    screen = pg.display.set_mode((800, 600))
    clock = pg.time.Clock()
    scene = initial_scene

    while True:
        delta_time = clock.tick(60) / 1000  # seconds since last frame
        events = pg.event.get()

        if any(event.type == pg.QUIT for event in events):
            break

        # Call the current scene function to handle input, update game state,
        # and render to the screen
        next_scene = scene(screen, events, delta_time)

        # Switch to next scene is one was returned
        if next_scene:
            scene = next_scene

        pg.display.flip()

run(title_screen)

Now we have a simple scene manager! The run function keeps track of the current scene function and calls it each frame. Each scene function can return the next scene function to switch to, or return nothing to stay in the current scene.

This works, but there's a problem. Let's take a closer look at the title_screen function. We're creating a new font object and rendering a new text surface every time this function is called - which is 60 times per second! This is really inefficient. We should create the font and text surface only once when the scene is initialized, and then reuse them every frame. But how?

Using Closures for Scene State

Instead of our scene functions doing work on every frame, we can split them into two parts: an outer "factory" function that runs expensive setup code just once, and an inner function that becomes the actual scene, running every frame.

The inner function, a "closure", can remember and use the variables from the outer function. You can imagine it as though the inner function packs the scene's assets and variables into a backpack that it carries around and can use those variables whenever it needs them.

Let's refactor the code to use this pattern. We'll also add some simple player movement to the play_game scene to make it more interesting:

import pygame as pg


def title_screen():
    font = pg.Font(None, 30)
    title_text = font.render("Press any key to start", True, "white")
    title_rect = title_text.get_rect()

    def _scene(screen, events, delta_time):
        if any(event.type == pg.KEYDOWN for event in events):
            return play_game()

        screen.fill("blue")
        title_rect.center = screen.get_rect().center
        screen.blit(title_text, title_rect)

    return _scene


def play_game():
    player = pg.Rect(400, 300, 50, 50)
    velocity = pg.Vector2(0, 0)

    def _scene(screen, events, delta_time):
        for event in events:
            if event.type == pg.KEYDOWN:
                if event.key == pg.K_ESCAPE:
                    return title_screen()
                if event.key == pg.K_LEFT:
                    velocity.x = -200
                if event.key == pg.K_RIGHT:
                    velocity.x = 200
            if event.type == pg.KEYUP and event.key in (pg.K_LEFT, pg.K_RIGHT):
                velocity.x = 0

        player.x += velocity.x * delta_time

        screen.fill("black")
        pg.draw.rect(screen, "white", player)

    return _scene


def run(initial_scene):
    pg.init()
    screen = pg.display.set_mode((800, 600))
    clock = pg.time.Clock()
    scene = initial_scene

    while True:
        delta_time = clock.tick(60) / 1000
        events = pg.event.get()

        if any(event.type == pg.QUIT for event in events):
            break

        # Delegate input handling, game state update and rendering to the
        # current scene function
        next_scene = scene(screen, events, delta_time)

        if next_scene:
            scene = next_scene

        pg.display.flip()


run(title_screen())

Our title_screen and play_game functions now act as factories. They create the necessary assets and state variables once only, and then return the inner _scene function. The main run loop then calls that returned function every frame.

We also change the way we start the game to call title_screen() to get the actual scene function, which has already packed its "backpack" with the font and text surface, and pass that into run. This is much more efficient.

Sharing State Between Scenes

The scene manager is working well, but what if we need to share information between scenes? A simple example is a high score that is set in the play scene and displayed on the title screen. Our current structure doesn't allow for this, as each scene is completely isolated.

We can solve this by creating a shared_state dictionary in our main run function. We can then pass this dictionary as an argument to every scene function, allowing them to read and write from a common pool of data.

Here is the complete code, updated to include a high score system. You can see how the run function now creates a shared_state dictionary and passes it to the scenes, and how the scenes themselves are updated to use it.

import pygame as pg


def title_screen():
    font = pg.Font(None, 30)
    title_text = font.render("Press any key to start", True, "white")
    title_rect = title_text.get_rect()

    def _scene(screen, events, delta_time, shared_state):
        if any(event.type == pg.KEYDOWN for event in events):
            return play_game()

        screen.fill("blue")
        title_rect.center = screen.get_rect().center
        screen.blit(title_text, title_rect)

        score_text = font.render(f"High Score: {shared_state['high_score']}", True, "white")
        score_rect = score_text.get_rect(centerx=title_rect.centerx, top=title_rect.bottom + 50)
        screen.blit(score_text, score_rect)

    return _scene


def play_game():
    player = pg.Rect(400, 300, 50, 50)
    velocity = pg.Vector2(0, 0)
    font = pg.Font(None, 30)
    scene_state = {"score": 0}

    def _scene(screen, events, delta_time, shared_state):
        for event in events:
            if event.type == pg.KEYDOWN:
                if event.key == pg.K_SPACE:
                    scene_state["score"] += 10
                if event.key == pg.K_ESCAPE:
                    shared_state["high_score"] = max(shared_state["high_score"], scene_state["score"])
                    return title_screen()
                if event.key == pg.K_LEFT:
                    velocity.x = -200
                if event.key == pg.K_RIGHT:
                    velocity.x = 200
            if event.type == pg.KEYUP and event.key in (pg.K_LEFT, pg.K_RIGHT):
                velocity.x = 0

        player.x += velocity.x * delta_time

        screen.fill("black")
        pg.draw.rect(screen, "white", player)

        score_text = font.render(f"Score: {scene_state['score']}", True, "white")
        screen.blit(score_text, (10, 10))

    return _scene


def run(initial_scene):
    pg.init()
    screen = pg.display.set_mode((800, 600))
    clock = pg.time.Clock()
    scene = initial_scene
    shared_state = {"high_score": 0}

    while True:
        delta_time = clock.tick(60) / 1000
        events = pg.event.get()

        if any(event.type == pg.QUIT for event in events):
            break

        next_scene = scene(screen, events, delta_time, shared_state)

        if next_scene:
            scene = next_scene

        pg.display.flip()


run(title_screen())

Now the player's score will be tracked during the game, and when they return to the title screen, the high score will be updated if they beat it. This pattern is very flexible: the shared_state dictionary can hold any data you want to persist across your entire game, while each scene can still have its own internal state (like the scene_state in play_game) for temporary data that should reset every time the scene is entered.

A Note on Design Patterns

If you're familiar with software design patterns, you may have noticed that this approach is a functional implementation of the State Pattern (or its close cousin, the Strategy Pattern). Our main run function acts as the "Context", and each scene function is a concrete "State". The context delegates all its work to the current state object, and the states themselves are responsible for transitioning to other states.

This is of course not limited to a functional style. For those who prefer object-oriented programming (OOP), the same pattern can be implemented with classes. Here is a complete, runnable example of how our program would look using this style:

import pygame as pg


class TitleScene:
    def __init__(self):
        self.font = pg.Font(None, 36)
        self.title_text = self.font.render("Press any key to start", True, "white")
        self.title_rect = self.title_text.get_rect()

    def run(self, screen, events, delta_time, shared_state):
        if any(event.type == pg.KEYDOWN for event in events):
            return PlayScene()

        screen.fill("blue")
        self.title_rect.center = screen.get_rect().center
        screen.blit(self.title_text, self.title_rect)

        score_text = self.font.render(f"High Score: {shared_state['high_score']}", True, "white")
        score_rect = score_text.get_rect(centerx=self.title_rect.centerx, top=self.title_rect.bottom + 50)
        screen.blit(score_text, score_rect)

        # return self to stay in this scene
        return self


class PlayScene:
    def __init__(self):
        self.player = pg.Rect(400, 300, 50, 50)
        self.velocity = pg.Vector2(0, 0)
        self.font = pg.Font(None, 30)
        self.score = 0

    def run(self, screen, events, delta_time, shared_state):
        for event in events:
            if event.type == pg.KEYDOWN:
                if event.key == pg.K_SPACE:
                    self.score += 10
                if event.key == pg.K_ESCAPE:
                    shared_state["high_score"] = max(shared_state["high_score"], self.score)
                    return TitleScene()
                if event.key == pg.K_LEFT:
                    self.velocity.x = -200
                if event.key == pg.K_RIGHT:
                    self.velocity.x = 200
            if event.type == pg.KEYUP and event.key in (pg.K_LEFT, pg.K_RIGHT):
                self.velocity.x = 0

        self.player.x += self.velocity.x * delta_time

        screen.fill("black")
        pg.draw.rect(screen, "white", self.player)

        score_text = self.font.render(f"Score: {self.score}", True, "white")
        screen.blit(score_text, (10, 10))

        # return self to stay in this scene
        return self


def run(initial_scene):
    pg.init()
    screen = pg.display.set_mode((800, 600))
    clock = pg.time.Clock()
    scene = initial_scene
    shared_state = {"high_score": 0}

    while scene is not None:
        delta_time = clock.tick(60) / 1000
        events = pg.event.get()

        if any(event.type == pg.QUIT for event in events):
            break

        scene = scene.run(screen, events, delta_time, shared_state)

        pg.display.flip()


run(TitleScene())

Which is Better?

For this particular case, I think that closures are the better fit. As Jack Diederich pointed out way back at PyCon 2012, if your class has two methods and one of them is __init__, you probably don't need a class. The classes here just add extra boilerplate without providing much benefit.

However, if you are more comfortable with Object Oriented Programming, as many people are, the class-based approach works just as well.

Conclusion

And that's my take on a simple scene manager. It's a pattern I've found useful for keeping game logic organized without a lot of ceremony. If you find your game getting more complex, you might want to look into things like a scene stack for pausing, but for many games, this is all you need. I hope you find it useful too!