Python Forum
[PyGame] Creating a state machine
Thread Rating:
  • 1 Vote(s) - 5 Average
  • 1
  • 2
  • 3
  • 4
  • 5
[PyGame] Creating a state machine
#1
A state (or screen, scene, etc.) machine, is a way to handle different scenes of the game, and flip between them. A good example of multiple states would be...splash screen, title screen, game screen, credit screen, menu screen, level screen, end of game screen, loading screen, etc. You can use them also as a pause, inventory screen, or character stats screen, or anything like the such. A state machine handles switching between any two states, even when there are more states in total. One big reason one wants a state machine is to make a menu system. This posts gives that example, but you must understand the states concept first. Each sequential post here on this thread adds a functionality to the state machine for more clarity.

There are two ways of handling states. The first way is the bad way, and the second way is a good way.
  1. [Bad] One way is creating a separate while loop for each state. Or each state having its own independent event loop. This means you have while/for loops sporadically throughout your code wherever you have a new state. Each handling logic on its own, looping events on its own, drawing on its own, etc. With this method you are splitting up your main game loop into many. And the main problem with that is you have data flowing in and out of that that needs to get to other while loops. Hence you end up with passing variables back and forth or using the global keywords (which is NOT best method). In addition to that it splits yours code for maintenance. Since you have numerous main game loops, you have numerous main game logic and drawing all over your code. Its simply hard to find, and is prone to causing bugs. This is NOT how you should be doing it. This will cause spaghetti code and make you pull your hair out later as your game gets larger as you add more and more. This is not a major issue if you have a few while loops, but imagine as a game gets larger that each component having its own while loops will just be unmaintainable at some point. There are tutorials out there that instruct people to do it this way and it leads them down a rabbit hole of chaos later for them. A very simple way to determine if someone is using this method is if they have a second line and more likely more of pygame.display.update() anywhere in their program.
  2. [Good] The other way is by using inheritance of classes. Which is exactly what will be used in this tutorial. This method will only ever have one pygame.display.update() and one while loop as a main game loop. The beneefit to this is because there is only ever one while loop as the main game loop, you can easily filter each occurrence (events, logic, drawing) to the next state. So if one state needs to pass along information to the next, you just pass it along to the next state. You also keep organization by having one loop to follow in the event of bugs, instead of many. And less code is always more maintainable. There are different variations of a state machine, but they all run under the same principle. That there is never more than one main game loop and handles switching between states. To keep it simple this tutorial will show just how to switch between 2 states, a menu state and the actual game state (where the game occurs). Both of which are blank states just showing color. More complications later in the thread. If you want to see a full fledged game that uses this method and shows more complication, then check out our public community game pyroller's states. This is where each developer makes their own state (which is a mini game) and plugs it into the game.

import pygame as pg
import sys
 
class States(object):
    def __init__(self):
        self.done = False
        self.next = None
        self.quit = False
        self.previous = None
 
class Menu(States):
    def __init__(self):
        States.__init__(self)
        self.next = 'game'
    def cleanup(self):
        print('cleaning up Menu state stuff')
    def startup(self):
        print('starting Menu state stuff')
    def get_event(self, event):
        if event.type == pg.KEYDOWN:
            print('Menu State keydown')
        elif event.type == pg.MOUSEBUTTONDOWN:
            self.done = True
    def update(self, screen, dt):
        self.draw(screen)
    def draw(self, screen):
        screen.fill((255,0,0))
 
class Game(States):
    def __init__(self):
        States.__init__(self)
        self.next = 'menu'
    def cleanup(self):
        print('cleaning up Game state stuff')
    def startup(self):
        print('starting Game state stuff')
    def get_event(self, event):
        if event.type == pg.KEYDOWN:
            print('Game State keydown')
        elif event.type == pg.MOUSEBUTTONDOWN:
            self.done = True
    def update(self, screen, dt):
        self.draw(screen)
    def draw(self, screen):
        screen.fill((0,0,255))
 
class Control:
    def __init__(self, **settings):
        self.__dict__.update(settings)
        self.done = False
        self.screen = pg.display.set_mode(self.size)
        self.clock = pg.time.Clock()
    def setup_states(self, state_dict, start_state):
        self.state_dict = state_dict
        self.state_name = start_state
        self.state = self.state_dict[self.state_name]
    def flip_state(self):
        self.state.done = False
        previous,self.state_name = self.state_name, self.state.next
        self.state.cleanup()
        self.state = self.state_dict[self.state_name]
        self.state.startup()
        self.state.previous = previous
    def update(self, dt):
        if self.state.quit:
            self.done = True
        elif self.state.done:
            self.flip_state()
        self.state.update(self.screen, dt)
    def event_loop(self):
        for event in pg.event.get():
            if event.type == pg.QUIT:
                self.done = True
            self.state.get_event(event)
    def main_game_loop(self):
        while not self.done:
            delta_time = self.clock.tick(self.fps)/1000.0
            self.event_loop()
            self.update(delta_time)
            pg.display.update()
 
 
settings = {
    'size':(600,400),
    'fps' :60
}
 
app = Control(**settings)
state_dict = {
    'menu': Menu(),
    'game': Game()
}
app.setup_states(state_dict, 'menu')
app.main_game_loop()
pg.quit()
sys.exit()
So this is the entire code snippet. It might look daunting but if you break it down class by class, it is more easier to understand. So we have 4 classes in total. Control, Game, Menu, and States. 

States class is the super class of all states. Any data that you wish to persist between all states would go in here. And logic that persists between all states would go in here, as opposed to each sub class. This could be information such as font objects that span across the entire game, retained player health or modifications between levels, common background, etc. If you are repeating a method/function across a lot of states (even if not all), the super class is there to put it in so you dont have to do that. It is to stop redundancy. Why have a function in every level state to create the same font object and such?

Menu and Game states are sub classes of States. These are the actual states. You can have as many of these as you wish. These of themselves can even be super classes of a sub-sub class state, and so on. The possibilities are endless. The "current" state that is active has control. So what is rendered on the active state gets drawn. The same for update, and their event methods. You dont have to worry about two states drawing at the same time because only one is ever active.

Control does not have to be a class. It could in fact be in the global scope with the rest. But to keep things organized it is in a class. There will never be more than one Control object though. Control is named control because it controls the entire program. The main game loop is in it, the main update is in it, and the main event loop is in it. Control switches between the states. It simply checks if the state is completed by the current state's done attribute and switches to the next state by the next attribute (being a key in its state_dict dictionary) and switches to that state and repeats. Once you establish the control class like it is now, you should hardly ever have to modify it. You only add new states instead. Control more handles the boilerplate code for the window and states. If i was to copy this example for a game i was using, i would not even touch Control class and only modify Menu and Game classes as that is the meat of the program.


settings = {
    'size':(600,400),
    'fps' :60
}
  
app = Control(**settings)
state_dict = {
    'menu': Menu(),
    'game': Game()
}
app.setup_states(state_dict, 'menu')
app.main_game_loop()
The program starts off by creating a dictionary of settings. This gets passed in Control class. Control creates the app object. Then each state (Menu and Game) object get assigned to a dictionary. This allows Control to be able to switch to and from any state as needed. After that setup_states is called, and sets the initial state of the program. Control.state becomes the active state's object here, as Menu's object or Games object. But initially set as menu. And finally the main game loop gets called. From this point on the whole game is constantly just looping Control.main_game_loop(). This IS the main game loop, thus its name.

def main_game_loop(self):
    while not self.done:
        delta_time = self.clock.tick(self.fps)/1000.0
        self.event_loop()
        self.update(delta_time)
        pg.display.update()
In the main game loop, the if clause checks to close the program or not. Then we get the delta time. This is not used in this example, but i put it in here to show how to pass it from the main game loop to each state, where you need to use it later. Next it runs the main event loop. This loops through the event queue, checks for game quit, and runs the active state method get_event() while passing the event to it. This happens for EACH event. At this point Menu.get_event is what is being ran. This is because menu is the intial set state. And that is Control.state is Menu() object. Menu.get_event get passed each event and handles it as needed. It checks for a keydown and a mouse button down event. If the event is a mouse button down, it sets Menu.done to True. This in turn allows Control.update to pass the if clause for state.done, executing Control.flip_state on the next frame. Next the main update method gets called. This method checks for quit, and checks for if the state is done. It then execute the states update method. This would be Menu.update. After that, it runs pygame.dispaly.update() to update the screen every frame. And it just keeps looping over and over again.

def flip_state(self):
    self.state.done = False
    previous,self.state_name = self.state_name, self.state.next
    self.state.cleanup()
    self.state = self.state_dict[self.state_name]
    self.state.startup()
    self.state.previous = previous
Control.flip_state actually flips the state to the next state. Each state has a next attribute which defines the next state to switch to. flip_state runs the states cleanup method, sets this next state to the current active state, runs the new active states startup method, and sets the previous method.
Only the active state (Control.state) will run its methods. So you can keep your menu draw, menu updates, and menu event checks inside the Menu class. The same for Game...or any other state. This clumps the related code together in each states class. So if you need to modify something for your menu, it will be in Menu class. If you need to modify something for your game, it will be in your Game class. With just these two classes, it looks more work than it is worth. But when you have tons and tons of states, this makes it easy to find your code you are looking for. Its organized and structured. It also ensure you have only one event loop, and it is in your Control class. The state classes just have check events that have if clauses inside of it. Usually each state will be in a different module. And states would be grouped together in the same directory.


Because some people have trouble implementing this into their game, usage, etc. i have created more posts to illustrate how it can be used and things added to be more usable. Some of which these have been asked on the forum enough, that i have just implemented them here on appending posts.
This tutorial shows a little bit about how to modify a game to implement this structure, organizing states, and adding more states, menu states, etc.
This post shows how to add a game menu system to this state machine
This post shows game logic occuring in one state and pausing when the state is inactive.
This post implements buttons to switch the states
This post illustrates using class variables to share data between two states (classes) instead of passing information back and forth
Recommended Tutorials:
Reply
#2
Here is an example merging the basic state example with another example. The entire point of this is to illustrate game logic occurring only when the state is active.

Here in game state it counts to indicate game logic occurring. When you press the mouse button, it switches to the other state. Which that could be anything. Logic stops in the game state while not active as shown by the number only counting when you are in game state. To simplify the example i left it as two states, and they alternate between the two by mouse press. You can change that by adding an event and changing the self.next value. And you can add more states to the chain.

As shown in the example, you do not need to check for which state it is in to draw something like the number or backgrounds, as the control class handles that for you. You only need to worry about what is drawn/updated/events in that state only.

import pygame as pg
import sys
 
class Number:
    def __init__(self, screenrect):
        self.timer = 0.0
        self.delay = 1000
        self.screenrect = screenrect
        self.num = 0
        self.new_num()

    def inc_num(self):
        self.num += 1
        return str(self.num)
 
    def new_num(self):
        self.image, self.rect = self.make_text(self.inc_num(), (255,0,0), self.screenrect.center, 75, 'Ariel')
 
    def make_text(self,message,color,center,size, fonttype):
        font = pg.font.SysFont(fonttype, size)
        text = font.render(message,True,color)
        rect = text.get_rect(center=center)
        return text,rect
 
    def update(self):
        if pg.time.get_ticks()-self.timer > self.delay:
            self.timer = pg.time.get_ticks()
            self.new_num()
 
    def draw(self, surf):
        surf.blit(self.image, self.rect)
  
class States(object):
    def __init__(self):
        self.done = False
        self.next = None
        self.quit = False
        self.previous = None
  
class Menu(States):
    def __init__(self, screenrect):
        States.__init__(self)
        self.next = 'game'
    def cleanup(self):
        print('cleaning up Menu state stuff')
    def startup(self):
        print('starting Menu state stuff')
    def get_event(self, event):
        if event.type == pg.KEYDOWN:
            print('Menu State keydown')
        elif event.type == pg.MOUSEBUTTONDOWN:
            self.done = True
    def update(self, screen, dt):
        self.draw(screen)
    def draw(self, screen):
        screen.fill((255,0,0))
  
class Game(States):
    def __init__(self, screenrect):
        States.__init__(self)
        self.next = 'menu'
        self.num = Number(screenrect)
    def cleanup(self):
        print('cleaning up Game state stuff')
    def startup(self):
        print('starting Game state stuff')
    def get_event(self, event):
        if event.type == pg.KEYDOWN:
            print('Game State keydown')
        elif event.type == pg.MOUSEBUTTONDOWN:
            self.done = True
    def update(self, screen, dt):
        self.draw(screen)
        self.num.update()
    def draw(self, screen):
        screen.fill((0,0,255))
        self.num.draw(screen)
  
class Control:
    def __init__(self, **settings):
        self.__dict__.update(settings)
        self.done = False
        self.screen = pg.display.set_mode(self.size)
        self.screen_rect = self.screen.get_rect()
        self.clock = pg.time.Clock()
        self.state_dict = {
            'menu': Menu(self.screen_rect),
            'game': Game(self.screen_rect)
        }
    def setup_states(self, start_state):
        self.state_name = start_state
        self.state = self.state_dict[self.state_name]
    def flip_state(self):
        self.state.done = False
        previous,self.state_name = self.state_name, self.state.next
        self.state.cleanup()
        self.state = self.state_dict[self.state_name]
        self.state.startup()
        self.state.previous = previous
    def update(self, dt):
        if self.state.quit:
            self.done = True
        elif self.state.done:
            self.flip_state()
        self.state.update(self.screen, dt)
    def event_loop(self):
        for event in pg.event.get():
            if event.type == pg.QUIT:
                self.done = True
            self.state.get_event(event)
    def main_game_loop(self):
        while not self.done:
            delta_time = self.clock.tick(self.fps)/1000.0
            self.event_loop()
            self.update(delta_time)
            pg.display.update()
  
  
settings = {
    'size':(600,400),
    'fps' :60
}

pg.init()
  
app = Control(**settings)

app.setup_states('menu')
app.main_game_loop()
pg.quit()
sys.exit()
Recommended Tutorials:
Reply
#3
This is a variation of the previous example with the number example using button events to switch the states instead of a mouse button press anywhere on the screen. The point of this is to show that the state machine stays the same, while the methods to change the states can be dynamic. Its also is a basic example of button usage in states. Only the buttons in the current state will work as only their event checks are running.

import pygame as pg
import sys
 
class Button(object):
    def __init__(self,rect,command,**kwargs):
        self.rect = pg.Rect(rect)
        self.command = command
        self.clicked = False
        self.hovered = False
        self.hover_text = None
        self.clicked_text = None
        self.process_kwargs(kwargs)
        self.render_text()
 
    def process_kwargs(self,kwargs):
        settings = {
            "color"             : pg.Color('green'),
            "text"              : None,
            "font"              : None, #pg.font.Font(None,16),
            "call_on_release"   : True,
            "hover_color"       : None,
            "clicked_color"     : None,
            "font_color"        : pg.Color("black"),
            "hover_font_color"  : None,
            "clicked_font_color": None,
            "click_sound"       : None,
            "hover_sound"       : None,
            'border_color'      : pg.Color('black'),
            'border_hover_color': pg.Color('yellow'),
            'disabled'          : False,
            'disabled_color'     : pg.Color('grey'),
            'radius'            : 3,
        }
        for kwarg in kwargs:
            if kwarg in settings:
                settings[kwarg] = kwargs[kwarg]
            else:
                raise AttributeError("{} has no keyword: {}".format(self.__class__.__name__, kwarg))
        self.__dict__.update(settings)
 
    def render_text(self):
        if self.text:
            if self.hover_font_color:
                color = self.hover_font_color
                self.hover_text = self.font.render(self.text,True,color)
            if self.clicked_font_color:
                color = self.clicked_font_color
                self.clicked_text = self.font.render(self.text,True,color)
            self.text = self.font.render(self.text,True,self.font_color)
 
    def get_event(self,event):
        if event.type == pg.MOUSEBUTTONDOWN and event.button == 1:
            self.on_click(event)
        elif event.type == pg.MOUSEBUTTONUP and event.button == 1:
            self.on_release(event)
 
    def on_click(self,event):
        if self.rect.collidepoint(event.pos):
            self.clicked = True
            if not self.call_on_release:
                self.function()
 
    def on_release(self,event):
        if self.clicked and self.call_on_release:
            #if user is still within button rect upon mouse release
            if self.rect.collidepoint(pg.mouse.get_pos()):
                self.command()
        self.clicked = False
 
    def check_hover(self):
        if self.rect.collidepoint(pg.mouse.get_pos()):
            if not self.hovered:
                self.hovered = True
                if self.hover_sound:
                    self.hover_sound.play()
        else:
            self.hovered = False
 
    def draw(self,surface):
        color = self.color
        text = self.text
        border = self.border_color
        self.check_hover()
        if not self.disabled:
            if self.clicked and self.clicked_color:
                color = self.clicked_color
                if self.clicked_font_color:
                    text = self.clicked_text
            elif self.hovered and self.hover_color:
                color = self.hover_color
                if self.hover_font_color:
                    text = self.hover_text
            if self.hovered and not self.clicked:
                border = self.border_hover_color
        else:
            color = self.disabled_color
        if self.radius:
            rad = self.radius
        else:
            rad = 0
        self.round_rect(surface, self.rect , border, rad, 1, color)
        if self.text:
            text_rect = text.get_rect(center=self.rect.center)
            surface.blit(text,text_rect)
             
             
    def round_rect(self, surface, rect, color, rad=20, border=0, inside=(0,0,0,0)):
        rect = pg.Rect(rect)
        zeroed_rect = rect.copy()
        zeroed_rect.topleft = 0,0
        image = pg.Surface(rect.size).convert_alpha()
        image.fill((0,0,0,0))
        self._render_region(image, zeroed_rect, color, rad)
        if border:
            zeroed_rect.inflate_ip(-2*border, -2*border)
            self._render_region(image, zeroed_rect, inside, rad)
        surface.blit(image, rect)
 
 
    def _render_region(self, image, rect, color, rad):
        corners = rect.inflate(-2*rad, -2*rad)
        for attribute in ("topleft", "topright", "bottomleft", "bottomright"):
            pg.draw.circle(image, color, getattr(corners,attribute), rad)
        image.fill(color, rect.inflate(-2*rad,0))
        image.fill(color, rect.inflate(0,-2*rad))
         
    def update(self):
        #for completeness
        pass
  
class Number:
    def __init__(self, screenrect):
        self.timer = 0.0
        self.delay = 1000
        self.screenrect = screenrect
        self.num = 0
        self.new_num()
 
    def inc_num(self):
        self.num += 1
        return str(self.num)
  
    def new_num(self):
        self.image, self.rect = self.make_text(self.inc_num(), (255,0,0), self.screenrect.center, 75, 'Ariel')
  
    def make_text(self,message,color,center,size, fonttype):
        font = pg.font.SysFont(fonttype, size)
        text = font.render(message,True,color)
        rect = text.get_rect(center=center)
        return text,rect
  
    def update(self):
        if pg.time.get_ticks()-self.timer > self.delay:
            self.timer = pg.time.get_ticks()
            self.new_num()
  
    def draw(self, surf):
        surf.blit(self.image, self.rect)
   
class States(object):
    def __init__(self):
        self.done = False
        self.next = None
        self.quit = False
        self.previous = None
        self.btn_settings = {
        "clicked_font_color" : (0,0,0),
        "hover_font_color"   : (205,195, 100),
        'font'               : pg.font.Font(None,16),
        'font_color'         : (0,0,0),
        'border_color'       : (0,0,0),
        }
    def switch_state(self):
        self.done = True
   
class Menu(States):
    def __init__(self, screenrect):
        States.__init__(self)
        self.next = 'game'
        self.btn = Button(rect=(10,10,105,25), command=self.switch_state, text='Unpause', **self.btn_settings)
    def cleanup(self):
        print('cleaning up Menu state stuff')
    def startup(self):
        print('starting Menu state stuff')
    def get_event(self, event):
        self.btn.get_event(event)
    def update(self, screen, dt):
        self.draw(screen)
    def draw(self, screen):
        screen.fill((255,0,0))
        self.btn.draw(screen)
   
class Game(States):
    def __init__(self, screenrect):
        States.__init__(self)
        self.next = 'menu'
        self.num = Number(screenrect)
        self.btn = Button(rect=(10,10,105,25), command=self.switch_state, text='Pause', **self.btn_settings)
    def cleanup(self):
        print('cleaning up Game state stuff')
    def startup(self):
        print('starting Game state stuff')
    def get_event(self, event):
        self.btn.get_event(event)
    def update(self, screen, dt):
        self.draw(screen)
        self.num.update()
    def draw(self, screen):
        screen.fill((0,0,255))
        self.num.draw(screen)
        self.btn.draw(screen)
   
class Control:
    def __init__(self, **settings):
        self.__dict__.update(settings)
        self.done = False
        self.screen = pg.display.set_mode(self.size)
        self.screen_rect = self.screen.get_rect()
        self.clock = pg.time.Clock()
        self.state_dict = {
            'menu': Menu(self.screen_rect),
            'game': Game(self.screen_rect)
        }
    def setup_states(self, start_state):
        self.state_name = start_state
        self.state = self.state_dict[self.state_name]
    def flip_state(self):
        self.state.done = False
        previous,self.state_name = self.state_name, self.state.next
        self.state.cleanup()
        self.state = self.state_dict[self.state_name]
        self.state.startup()
        self.state.previous = previous
    def update(self, dt):
        if self.state.quit:
            self.done = True
        elif self.state.done:
            self.flip_state()
        self.state.update(self.screen, dt)
    def event_loop(self):
        for event in pg.event.get():
            if event.type == pg.QUIT:
                self.done = True
            self.state.get_event(event)
    def main_game_loop(self):
        while not self.done:
            delta_time = self.clock.tick(self.fps)/1000.0
            self.event_loop()
            self.update(delta_time)
            pg.display.update()
   
   
settings = {
    'size':(600,400),
    'fps' :60
}
 
pg.init()
   
app = Control(**settings)
 
app.setup_states('menu')
app.main_game_loop()
pg.quit()
sys.exit()
Recommended Tutorials:
Reply
#4
This is a simple modification of adding a class variable to the
States class to show how a variable can be shared across sub classes and modified by any subclass.

import pygame as pg
import sys
   
class States(object):
    share = 'share'
    def __init__(self):
        self.done = False
        self.next = None
        self.quit = False
        self.previous = None
   
class Menu(States):
    def __init__(self):
        States.__init__(self)
        self.next = 'game'
    def cleanup(self):
        print('cleaning up Menu state stuff')
    def startup(self):
        print('starting Menu state stuff')
    def get_event(self, event):
        if event.type == pg.KEYDOWN:
            print('Menu State keydown')
        elif event.type == pg.MOUSEBUTTONDOWN:
            self.done = True
            print(States.share)
            States.share = 'menu share'
            print(States.share)
    def update(self, screen, dt):
        self.draw(screen)
    def draw(self, screen):
        screen.fill((255,0,0))
   
class Game(States):
    def __init__(self):
        States.__init__(self)
        self.next = 'menu'
    def cleanup(self):
        print('cleaning up Game state stuff')
    def startup(self):
        print('starting Game state stuff')
    def get_event(self, event):
        if event.type == pg.KEYDOWN:
            print('Game State keydown')
        elif event.type == pg.MOUSEBUTTONDOWN:
            self.done = True
            print(States.share)
            States.share = 'game share'
            print(States.share)
    def update(self, screen, dt):
        self.draw(screen)
    def draw(self, screen):
        screen.fill((0,0,255))
   
class Control:
    def __init__(self, **settings):
        self.__dict__.update(settings)
        self.done = False
        self.screen = pg.display.set_mode(self.size)
        self.clock = pg.time.Clock()
    def setup_states(self, state_dict, start_state):
        self.state_dict = state_dict
        self.state_name = start_state
        self.state = self.state_dict[self.state_name]
    def flip_state(self):
        self.state.done = False
        previous,self.state_name = self.state_name, self.state.next
        self.state.cleanup()
        self.state = self.state_dict[self.state_name]
        self.state.startup()
        self.state.previous = previous
    def update(self, dt):
        if self.state.quit:
            self.done = True
        elif self.state.done:
            self.flip_state()
        self.state.update(self.screen, dt)
    def event_loop(self):
        for event in pg.event.get():
            if event.type == pg.QUIT:
                self.done = True
            self.state.get_event(event)
    def main_game_loop(self):
        while not self.done:
            delta_time = self.clock.tick(self.fps)/1000.0
            self.event_loop()
            self.update(delta_time)
            pg.display.update()
   
   
settings = {
    'size':(600,400),
    'fps' :60
}
   
app = Control(**settings)
state_dict = {
    'menu': Menu(),
    'game': Game()
}
app.setup_states(state_dict, 'menu')
app.main_game_loop()
pg.quit()
sys.exit()
Recommended Tutorials:
Reply
#5
In this post i will make an example of how to make an actual menu system that you can select states (like a main menu, options menu, quit, etc. This is because this is a common reason for people to start using states in the first place.

Here is the entire code:
import pygame as pg
import sys

pg.init()
  
class Control:
    def __init__(self):
        self.done = False
        self.fps = 60
        self.screen = pg.display.set_mode((600,600))
        self.screen_rect = self.screen.get_rect()
        self.clock = pg.time.Clock()
    def setup_states(self, state_dict, start_state):
        self.state_dict = state_dict
        self.state_name = start_state
        self.state = self.state_dict[self.state_name]
    def flip_state(self):
        self.state.done = False
        previous,self.state_name = self.state_name, self.state.next
        self.state.cleanup()
        self.state = self.state_dict[self.state_name]
        self.state.startup()
        self.state.previous = previous
    def update(self, dt):
        if self.state.quit:
            self.done = True
        elif self.state.done:
            self.flip_state()
        self.state.update(self.screen, dt)
    def event_loop(self):
        for event in pg.event.get():
            if event.type == pg.QUIT:
                self.done = True
            self.state.get_event(event)
    def main_game_loop(self):
        while not self.done:
            delta_time = self.clock.tick(self.fps)/1000.0
            self.event_loop()
            self.update(delta_time)
            pg.display.update()
            
class MenuManager:
    def __init__(self):
        self.selected_index = 0
        self.last_option = None
        self.selected_color = (255,255,0)
        self.deselected_color = (255,255,255)
        
    def draw_menu(self, screen):
        '''handle drawing of the menu options'''
        for i,opt in enumerate(self.rendered["des"]):
            opt[1].center = (self.screen_rect.centerx, self.from_bottom+i*self.spacer)
            if i == self.selected_index:
                rend_img,rend_rect = self.rendered["sel"][i]
                rend_rect.center = opt[1].center
                screen.blit(rend_img,rend_rect)
            else:
                screen.blit(opt[0],opt[1])
        
    def update_menu(self):
        self.mouse_hover_sound()
        self.change_selected_option()
        
    def get_event_menu(self, event):
        if event.type == pg.KEYDOWN:
            '''select new index'''
            if event.key in [pg.K_UP, pg.K_w]:
                self.change_selected_option(-1)
            elif event.key in [pg.K_DOWN, pg.K_s]:
                self.change_selected_option(1)
                
            elif event.key == pg.K_RETURN:
                self.select_option(self.selected_index)
        self.mouse_menu_click(event)
        
    def mouse_hover_sound(self):
        '''play sound when selected option changes'''
        for i,opt in enumerate(self.rendered["des"]):
            if opt[1].collidepoint(pg.mouse.get_pos()):
                if self.last_option != opt:
                    self.last_option = opt
    def mouse_menu_click(self, event):
        '''select menu option '''
        if event.type == pg.MOUSEBUTTONDOWN and event.button == 1:
            for i,opt in enumerate(self.rendered["des"]):
                if opt[1].collidepoint(pg.mouse.get_pos()):
                    self.selected_index = i
                    self.select_option(i)
                    break
    def pre_render_options(self):
        '''setup render menu options based on selected or deselected'''
        font_deselect = pg.font.SysFont("arial", 50)
        font_selected = pg.font.SysFont("arial", 70)

        rendered_msg = {"des":[],"sel":[]}
        for option in self.options:
            d_rend = font_deselect.render(option, 1, self.deselected_color)
            d_rect = d_rend.get_rect()
            s_rend = font_selected.render(option, 1, self.selected_color)
            s_rect = s_rend.get_rect()
            rendered_msg["des"].append((d_rend,d_rect))
            rendered_msg["sel"].append((s_rend,s_rect))
        self.rendered = rendered_msg

    def select_option(self, i):
        '''select menu option via keys or mouse'''
        if i == len(self.next_list):
            self.quit = True
        else:
            self.next = self.next_list[i]
            self.done = True
            self.selected_index = 0

    def change_selected_option(self, op=0):
        '''change highlighted menu option'''
        for i,opt in enumerate(self.rendered["des"]):
            if opt[1].collidepoint(pg.mouse.get_pos()):
                self.selected_index = i
        if op:
            self.selected_index += op
            max_ind = len(self.rendered['des'])-1
            if self.selected_index < 0:
                self.selected_index = max_ind
            elif self.selected_index > max_ind:
                self.selected_index = 0
                
class States(Control):
    def __init__(self):
        Control.__init__(self)
        self.done = False
        self.next = None
        self.quit = False
        self.previous = None
  
                
class Menu(States, MenuManager):
    def __init__(self):
        States.__init__(self)
        MenuManager.__init__(self)
        self.next = 'game'
        self.options = ['Play', 'Options', 'Quit']
        self.next_list = ['game', 'options']
        self.pre_render_options()
        self.from_bottom = 200
        self.spacer = 75
    def cleanup(self):
        print('cleaning up Main Menu state stuff')
    def startup(self):
        print('starting Main Menu state stuff')
    def get_event(self, event):
        if event.type == pg.QUIT:
            self.quit = True
        self.get_event_menu(event)
    def update(self, screen, dt):
        self.update_menu()
        self.draw(screen)
    def draw(self, screen):
        screen.fill((255,0,0))
        self.draw_menu(screen)
        
class Options(States, MenuManager):
    def __init__(self):
        States.__init__(self)
        MenuManager.__init__(self)
        self.next = 'menu'
        self.options = ['Music', 'Sound', 'Graphics', 'Controls', 'Main Menu']
        self.next_list = ['options', 'options', 'options', 'options', 'menu']
        self.pre_render_options()
        self.from_bottom = 200
        self.spacer = 75
    def cleanup(self):
        print('cleaning up Options state stuff')
    def startup(self):
        print('starting Options state stuff')
    def get_event(self, event):
        if event.type == pg.QUIT:
            self.quit = True
        self.get_event_menu(event)
    def update(self, screen, dt):
        self.update_menu()
        self.draw(screen)
    def draw(self, screen):
        screen.fill((255,0,0))
        self.draw_menu(screen)
  
class Game(States):
    def __init__(self):
        States.__init__(self)
        self.next = 'menu'
    def cleanup(self):
        print('cleaning up Game state stuff')
    def startup(self):
        print('starting Game state stuff')
    def get_event(self, event):
        if event.type == pg.MOUSEBUTTONDOWN or event.type == pg.KEYDOWN:
            self.done = True
    def update(self, screen, dt):
        self.draw(screen)
    def draw(self, screen):
        screen.fill((0,0,255))
  
app = Control()
state_dict = {
    'menu': Menu(),
    'game': Game(),
    'options':Options()
}
app.setup_states(state_dict, 'menu')
app.main_game_loop()
pg.quit()
sys.exit()
In this example, the already existing classes are hardly touched at all. I did move settings to be attributes of Control for simplicity. I also made the States class sub classed from the Control class explicitly to pass the screen rect over to the menus for drawing. This was to bypass passing screen_rect all over the place. screen_Rect was needed to center the text on the screen. I also added a new Options state for better illustration of the menu system.

The main meat of the addition is the MenuManager class. This handles everything related to the menu system. draw_menu() handles the drawing of the menu options, update_menu() handles the update of each state, get_event_menu() handles the index selecting of the options, mouse_hover_sound() would be if you wanted to play a sound when you hover over an option, mouse_menu_click() handles the mouse collision with the option, pre_render_options() handles the setup of the fonts for selected and deselected texts, selected_option() handles the actual selection of an option, and change_selected_option() handles the index position of the options. Only the first three are called upon by the actual menu states.

Each menu related state (Menu, Option) but not game state is sub classed not only to States, but to MenuManager as well. This is so it can draw the menu and update it. the menu draw method calls the MenuManager.draw_menu() to handle the drawing of the menu, update() calls update_menu() to handle the selection change, the get_event() method calls get_event_menu() method which handles the increment or decrement of the options based on events. Each states also contains what is listed on that state's menu via its options attribute. This is what is displayed to the state. The secondary list next_list attribute is the dictionary state's key to the associated index of options attribute. So "Play" text will go to the "play" state key. Quit does not need a state key because there is no state for quitting. Hence it is coded to quit if there is no next_list index.

This is a basic illustration of a menu system using state to handle this. Each class in theory would be in its own module. It is in one solely for a tutorial to copy and paste easily. It is quite a lot of code for little results. But it is very adaptable in terms that you can copy a menu state and simply change merely the options text list and their associated key states, and have added an entirely new menu state. Each menu state can be expanded upon further cleanly as each one is barely over 20 lines long. You would only need to change MenuManager if you plan on changing the layout of the actual menu.

Here is an example of the adaptability of this process:
We simply add this before the pre_render_options() call in any menu state
        self.deselected_color = (150,150,150)
        self.selected_color = (0,0,0)
and change the font menu color of that state only.
Full example we put this same code in the Options dunder init method and we now get a different font color from that menu state as opposed to the main menu state.
import pygame as pg
import sys

pg.init()
  
class Control:
    def __init__(self):
        self.done = False
        self.fps = 60
        self.screen = pg.display.set_mode((600,600))
        self.screen_rect = self.screen.get_rect()
        self.clock = pg.time.Clock()
    def setup_states(self, state_dict, start_state):
        self.state_dict = state_dict
        self.state_name = start_state
        self.state = self.state_dict[self.state_name]
    def flip_state(self):
        self.state.done = False
        previous,self.state_name = self.state_name, self.state.next
        self.state.cleanup()
        self.state = self.state_dict[self.state_name]
        self.state.startup()
        self.state.previous = previous
    def update(self, dt):
        if self.state.quit:
            self.done = True
        elif self.state.done:
            self.flip_state()
        self.state.update(self.screen, dt)
    def event_loop(self):
        for event in pg.event.get():
            if event.type == pg.QUIT:
                self.done = True
            self.state.get_event(event)
    def main_game_loop(self):
        while not self.done:
            delta_time = self.clock.tick(self.fps)/1000.0
            self.event_loop()
            self.update(delta_time)
            pg.display.update()
            
class MenuManager:
    def __init__(self):
        self.selected_index = 0
        self.last_option = None
        self.selected_color = (255,255,0)
        self.deselected_color = (255,255,255)
        
    def draw_menu(self, screen):
        '''handle drawing of the menu options'''
        for i,opt in enumerate(self.rendered["des"]):
            opt[1].center = (self.screen_rect.centerx, self.from_bottom+i*self.spacer)
            if i == self.selected_index:
                rend_img,rend_rect = self.rendered["sel"][i]
                rend_rect.center = opt[1].center
                screen.blit(rend_img,rend_rect)
            else:
                screen.blit(opt[0],opt[1])
        
    def update_menu(self):
        self.mouse_hover_sound()
        self.change_selected_option()
        
    def get_event_menu(self, event):
        if event.type == pg.KEYDOWN:
            '''select new index'''
            if event.key in [pg.K_UP, pg.K_w]:
                self.change_selected_option(-1)
            elif event.key in [pg.K_DOWN, pg.K_s]:
                self.change_selected_option(1)
                
            elif event.key == pg.K_RETURN:
                self.select_option(self.selected_index)
        self.mouse_menu_click(event)
        
    def mouse_hover_sound(self):
        '''play sound when selected option changes'''
        for i,opt in enumerate(self.rendered["des"]):
            if opt[1].collidepoint(pg.mouse.get_pos()):
                if self.last_option != opt:
                    self.last_option = opt
    def mouse_menu_click(self, event):
        '''select menu option '''
        if event.type == pg.MOUSEBUTTONDOWN and event.button == 1:
            for i,opt in enumerate(self.rendered["des"]):
                if opt[1].collidepoint(pg.mouse.get_pos()):
                    self.selected_index = i
                    self.select_option(i)
                    break
    def pre_render_options(self):
        '''setup render menu options based on selected or deselected'''
        font_deselect = pg.font.SysFont("arial", 50)
        font_selected = pg.font.SysFont("arial", 70)

        rendered_msg = {"des":[],"sel":[]}
        for option in self.options:
            d_rend = font_deselect.render(option, 1, self.deselected_color)
            d_rect = d_rend.get_rect()
            s_rend = font_selected.render(option, 1, self.selected_color)
            s_rect = s_rend.get_rect()
            rendered_msg["des"].append((d_rend,d_rect))
            rendered_msg["sel"].append((s_rend,s_rect))
        self.rendered = rendered_msg

    def select_option(self, i):
        '''select menu option via keys or mouse'''
        if i == len(self.next_list):
            self.quit = True
        else:
            self.next = self.next_list[i]
            self.done = True
            self.selected_index = 0

    def change_selected_option(self, op=0):
        '''change highlighted menu option'''
        for i,opt in enumerate(self.rendered["des"]):
            if opt[1].collidepoint(pg.mouse.get_pos()):
                self.selected_index = i
        if op:
            self.selected_index += op
            max_ind = len(self.rendered['des'])-1
            if self.selected_index < 0:
                self.selected_index = max_ind
            elif self.selected_index > max_ind:
                self.selected_index = 0
                
class States(Control):
    def __init__(self):
        Control.__init__(self)
        self.done = False
        self.next = None
        self.quit = False
        self.previous = None
  
                
class Menu(States, MenuManager):
    def __init__(self):
        States.__init__(self)
        MenuManager.__init__(self)
        self.next = 'game'
        self.options = ['Play', 'Options', 'Quit']
        self.next_list = ['game', 'options']
        self.pre_render_options()
        self.from_bottom = 200
        self.spacer = 75
    def cleanup(self):
        print('cleaning up Main Menu state stuff')
    def startup(self):
        print('starting Main Menu state stuff')
    def get_event(self, event):
        if event.type == pg.QUIT:
            self.quit = True
        self.get_event_menu(event)
    def update(self, screen, dt):
        self.update_menu()
        self.draw(screen)
    def draw(self, screen):
        screen.fill((255,0,0))
        self.draw_menu(screen)
        
class Options(States, MenuManager):
    def __init__(self):
        States.__init__(self)
        MenuManager.__init__(self)
        self.next = 'menu'
        self.options = ['Music', 'Sound', 'Graphics', 'Controls', 'Main Menu']
        self.next_list = ['options', 'options', 'options', 'options', 'menu']
        self.from_bottom = 200
        self.spacer = 75
        self.deselected_color = (150,150,150)
        self.selected_color = (0,0,0)
        self.pre_render_options()
    def cleanup(self):
        print('cleaning up Options state stuff')
    def startup(self):
        print('starting Options state stuff')
    def get_event(self, event):
        if event.type == pg.QUIT:
            self.quit = True
        self.get_event_menu(event)
    def update(self, screen, dt):
        self.update_menu()
        self.draw(screen)
    def draw(self, screen):
        screen.fill((255,0,0))
        self.draw_menu(screen)
  
class Game(States):
    def __init__(self):
        States.__init__(self)
        self.next = 'menu'
    def cleanup(self):
        print('cleaning up Game state stuff')
    def startup(self):
        print('starting Game state stuff')
    def get_event(self, event):
        if event.type == pg.MOUSEBUTTONDOWN or event.type == pg.KEYDOWN:
            self.done = True
    def update(self, screen, dt):
        self.draw(screen)
    def draw(self, screen):
        screen.fill((0,0,255))
  
app = Control()
state_dict = {
    'menu': Menu(),
    'game': Game(),
    'options':Options()
}
app.setup_states(state_dict, 'menu')
app.main_game_loop()
pg.quit()
sys.exit()
Also as you may have noticed....this allows you easily add more states and change it on the fly. Instead of a mouse press or enter key, you can use this same structure to interrupt the normal next state and change it based on events of the game. If player enters east side of the map, queue cut-scene for East Map , etc. You can modify the next state at any point.
Recommended Tutorials:
Reply


Forum Jump:

User Panel Messages

Announcements
Announcement #1 8/1/2020
Announcement #2 8/2/2020
Announcement #3 8/6/2020