Bottom Page

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, etc. You can use them also as a pause, inventory screen, or character stats screen, or anything like the such. There are two ways of doing this. One is creating a separate while loop for each state. 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. The other way is by using inheritance of classes. Which is exactly what will be used in this tutorial.

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 

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 possibilites 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. <

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.


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.
Quote
#2
here is an example merging the basic state example with another example. 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()
nilamo likes this post
Quote
#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.

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()
Quote

Top Page

Forum Jump:


Users browsing this thread: 1 Guest(s)