06 Feb 2023 - TUNING DIPSW (Andrew Hsu)
« Prev: #1: Introducing our game loop
« Prev: #1.1: Game loop tangents
Good day. In this series of articles, I’ll be attempting to program a simple 2D fighting game using the Python game development library Pygame.
The first thing we need to create is the input system of our game. Although it is an intriguing idea, we won’t be taking after NetHack or Dwarf Fortress and binding actions to single buttons (no ‘q’ to quaff). We must instead find a way to decide whether a player’s (eg.) “236P” input should trigger a special move or a command normal (6P).
Numpad notation will be used in these articles. See Dustloop for a refresher on how to read numpad notation.
It may help us to reference how other games show their inputs.
Guilty Gear XRD’s input display system. (source: own screenshot)
The slightly darker colour of some direction/button inputs indicate that the direction/button is still being held down from the previous input state.
The actions that these inputs evaluated to can also be displayed, although the buttons are ordered chronologically bottom to top, while the actions are top to bottom.
Under Night’s input display system. (video source)
Does not visually distinguish between invalid or buffered inputs and valid inputs. However, showing frame counts to indicate how long the player’s inputs remained in each state looks useful.
Guilty Gear XXAC+R’s key mapping menu. For some reason, +R has a keyboard menu that maps to controller keys, and then a controller menu that maps those controller keys to game buttons, which is a little confusing.
We will need to support a number of input devices and customizable controls for our fighting game, so we must avoid hardcoding specific keys into our business logic.
A GUI for setting these keys in-game is a must, but we’ll put it off for now.
Special inputs are a famous fixture of the fighting game genre, ranging from the simple quarter-circle forward (236) motion to the infamous pretzel motion (1632413).
The fighting game inputs are highly contextual even among video games; for many moves, the game has to consider our previous inputs as well as our current ones in order to determine whether (eg.) 6P should lead to the command normal 6P, or the special move 236P.
The exact logic of how to parse an input into one of multiple specific game actions, especially when the player has pressed more buttons than necessary, as in the case of many Option Selects, can become extremely complex.
Dustloop’s BlazBlue page contains a reference of input priority that shows just how complex this system might become. For special moves, we’ll lean on Soku’s simpler hierarchy for now, since BlazBlue’s hierarchy varies between characters.
For the time being, we’ll keep the inputs we accept fairly simple. We can reference the section on BlazBlue’s “Input Requirements” for some sensible specs on how to parse a special input.
An extension of the input system that allows more leniency during combos and defense. The leniency of the buffer system varies between games, ranging from generous to nearly non-existent, giving a slightly different feel to each game.
See “Buffer Window” on the Fighting Game Glossary.
Some inputs don’t cause an action (immediately), due to the character being committed to some other action, or possibly the requested action being invalid during the current state.
If done close enough to the earliest valid frame that the action could take place, a valid input may be “buffered” by the game to trigger the requested action on that earliest frame.
However, if the input is performed too many frames before that earliest valid frame, or “outside of the buffer window”, it is considered invalid and the game will not translate it into an action.
I’ve borrowed a version of the game loop with a FPS counter from this tutorial. I’ve taken the liberty of separating some global constants and the FPS counter class into separate files:
# constants.py WINDOW_WIDTH = 640 WINDOW_HEIGHT = 480 FRAME_RATE_CAP = 60 WHITE = (255, 255, 255) BLACK = (0, 0, 0)
# fps_counter.py import pygame as pg import constants class FpsCounter: def __init__(self): self.clock = pg.time.Clock() self.font = pg.font.SysFont("Verdana", 10) self.text = self.font.render(str(self.clock.get_fps()), True, constants.WHITE) def render(self, display): fps = int(self.clock.get_fps()) self.text = self.font.render(f"{fps}FPS", True, constants.WHITE) display.blit(self.text, (constants.WINDOW_WIDTH - 50, 5))
# main.py import pygame as pg import sys import fps_counter as fps import constants pg.init() window = pg.display.set_mode((constants.WINDOW_WIDTH, constants.WINDOW_HEIGHT)) fpsCounter = fps.FpsCounter() def processInput(): pass def update(): pass def render(): window.fill(constants.BLACK) fps_counter.render(window) pg.display.update() running = True while running: processInput() update() render() fpsCounter.clock.tick(constants.FRAME_RATE_CAP)
Running this game only displays a black screen with an FPS counter, but this is a nice skeleton of a program for us to springboard off of.
Our ultimate goal with the input system is to translate keys to buttons to inputs to actions:
Keys The raw inputs of an input device such as a keyboard, joystick, or controller. We’ll start with keyboard support only for simplicity.
Buttons The “buttons” that your game supports. In our case, this is the 4 cardinal direction keys (keyboard), a 2-dimensional direction from a joystick, the 5 attack buttons “Punch”, “Kick”, “Slash”, “Heavy Slash”, “Dust”, and macro buttons for combinations like P+K+S or dash macro.
We map our keys to our buttons.
Inputs The processed form of the buttons, after SOCD cleaning and macro button combinations have been applied.
For leverless devices like the keyboard, multiple buttons for contradictory directions can be pressed (Simultaneous Opposite Cardinal Directions), so we need to clean SOCDs and translate them into one of the 8 directions, or the neutral direction.
Devices with levers/joysticks can’t do this, so we just translate the 2-dimensional input into the closest of the 8 directions, or neutral if the stick is in some defined deadzone. It might be okay to handle that in “Buttons” instead of “Inputs”, but it probably doesn’t really matter.
Actions The actions performed by the character in game after parsing the inputs.
NB: We will consider inputs independent of state, and actions dependent on it. So inputs will use the absolute directions of left and right, while actions will have to consider which side of the opponent a character is on, and convert inputs’ “left or right” into forward or back.
Due to this dependence on game state, the parsing of inputs into actions will occur in update(), not processInput(). This way, processInput() does not need to know anything about game state.
update()
processInput()
While filling in processInput(), we hit a fork in the road.
# main.py def processInput(): # Method (1): Use key.get_pressed() parseKeysPressed() # Method (2): Event queue (not used) for event in pg.event.get(): if event.type == pg.QUIT: cleanupGame() break def cleanupGame(): ''' Run any cleanup before exiting the game. ''' pg.quit() sys.exit()
In Pygame, we have one of two choices in how we handle input: We can either ask Pygame for a list of keys that being pressed at this very moment, or we can check Pygame’s event queue for a list of all the key presses and key releases that have occurred since the last time we checked the queue (ie. called processInput()).
As a queue, the event queue method will catch all inputs, even if performance drops. If the performance of the game loop drops to 1 FPS, the queue will capture all frames’ worth of inputs during that second, while key.get_pressed() can only capture 1 frames’ worth of inputs.
key.get_pressed()
However, because it can capture multiple frames’ worth of inputs, the logic of processInput() becomes harder to get right as well. Even if it did only capture one frames’ worth of inputs, handling simultaneous button presses with the event queue requires juggling knowledge of key presses and key releases, which is also a little tricky.
As stated in article #1, we will not spend unnecessary time worrying about performance before we have to. We’ll go with the simpler key.get_pressed() method for now just to keep us moving forward; if our game starts to have trouble maintaining 60 FPS, I will revisit this to make the switch.
Let’s create a new file inputs.py to store our key, button, and input-related functions.
inputs.py
# inputs.py from __future__ import annotations # https://stackoverflow.com/questions/33533148 # For type hinting support. from enum import Enum import pygame.locals as locals import pygame as pg import constants class Button(Enum): LEFT = 0 DOWN = 1 RIGHT = 2 UP = 3 PUNCH = 10 KICK = 11 SLASH = 12 HEAVY = 13 DUST = 14 MACRO_PK = 20 MACRO_PD = 21 MACRO_PKS = 22 MACRO_PKSH = 23 # define keybindings manually here # TODO: eventually replace with a proper menu interface for rebinding keys keybinds = {} keybinds[locals.K_7] = Button.LEFT keybinds[locals.K_8] = Button.DOWN keybinds[locals.K_9] = Button.RIGHT keybinds[locals.K_SPACE] = Button.UP keybinds[locals.K_z] = Button.PUNCH keybinds[locals.K_x] = Button.KICK keybinds[locals.K_c] = Button.SLASH keybinds[locals.K_v] = Button.HEAVY keybinds[locals.K_d] = Button.DUST keybinds[locals.K_f] = Button.MACRO_PKS keybinds[locals.K_g] = Button.MACRO_PK keybinds[locals.K_h] = Button.MACRO_PD keybinds[locals.K_j] = Button.MACRO_PKSH # ...
A (literal) key-value pair dictionary stores our key mappings. I’ve used Python’s Enum feature to define our buttons.
You can use literal strings (“punch”, “kick”, etc) instead of Enums, as I did initially, but if you ever misspell one of these strings (“puncg”) in your logic later down the line, the logic may fail silently, which is troublesome to debug. Enums will cause an error at runtime if misspelled, so they are safer to use.
# inputs.py # ... macro_defs = {} macro_defs[Button.MACRO_PK] = [Button.PUNCH, Button.KICK] macro_defs[Button.MACRO_PD] = [Button.PUNCH, Button.DUST] macro_defs[Button.MACRO_PKS] = [Button.PUNCH, Button.KICK, Button.SLASH] macro_defs[Button.MACRO_PKSH] = [Button.PUNCH, Button.KICK, Button.SLASH, Button.HEAVY] def keysPressedToInput(current_frame: int) -> Input: ''' Takes an int current_frame, checks pygame.key.keys_pressed() for all keys currently pressed, and returns an Input created with a dict of all assigned Buttons pressed (after SOCD cleaning) and current_frame. ''' keys_pressed = pg.key.get_pressed() frame_buttons: dict[Button, bool] = {} for (key, button) in keybinds.items(): frame_buttons[button] = keys_pressed[key] for macro_button in macro_defs.keys(): if frame_buttons.get(macro_button) and frame_buttons[macro_button]: for button in macro_defs[macro_button]: frame_buttons[button] = True frame_buttons = cleanSocdButtons(frame_buttons) return Input(frame_buttons, current_frame, current_frame + 1) #...
We define some macro buttons as well, and keysPressedToInput(), which neatly translates the keys of pg.key.get_pressed() into our Buttons.
keysPressedToInput()
pg.key.get_pressed()
It eventually creates an Input,
# inputs.py # ... class Input(): def __init__(self, buttons: dict[Button, bool], start_frame: int, end_frame: int): self.buttons = buttons self.start_frame = start_frame self.end_frame = end_frame # https://stackoverflow.com/questions/390250 def __eq__(self, other): if isinstance(other, Input): return self.buttons == other.buttons and self.start_frame == other.start_frame and self.end_frame == other.end_frame return NotImplemented # ...
after running the buttons through cleanSocdButtons().
cleanSocdButtons()
# inputs.py # ... def cleanSocdButtons(frame_buttons: dict[Button, bool]) -> dict[Button, bool]: ''' Takes a dict with each assigned Button pressed this frame, and returns a copy of it with SOCD cases handled: - UP + DOWN = neutral, remove both - LEFT + RIGHT = neutral, remove both Note that these don't both have to be handled the same way. ''' cleaned_inputs = frame_buttons if cleaned_inputs[Button.LEFT] and cleaned_inputs[Button.RIGHT]: cleaned_inputs[Button.LEFT] = False cleaned_inputs[Button.RIGHT] = False if cleaned_inputs[Button.UP] and cleaned_inputs[Button.DOWN]: cleaned_inputs[Button.UP] = False cleaned_inputs[Button.DOWN] = False return cleaned_inputs # ...
These functions are all we need to convert from Keys to Input. We call these functions in main.py’s parseKeysPressed() to create an Input.
main.py
parseKeysPressed()
# main.py # ... import inputs # ... current_frame = 0 inputHistoryP1 = inputs.InputHistory("P1") inputHistoryP2 = inputs.InputHistory("P2") # ... def parseKeysPressed(): ''' Checks what keys are currently being pressed, and creates a corresponding Input in input_history. ''' input = inputs.keysPressedToInput(current_frame) inputHistoryP1.append(input)
We’ll store these Inputs in a slightly modified container class, InputHistory. This allows us to implement some custom logic when adding new Inputs in append(), and it also allows us to give it its own render() method that we can slot right into main.py.
append()
render()
# inputs.py # ... class InputHistory(): def __init__(self, player: str): self.player = player # "P1" or "P2" self.inputs: list[Input] = [] def append(self, input: Input) -> None: ''' Takes an Input and adds it to self.inputs, and removes old inputs from self.inputs. ''' if len(self.inputs) > 0 and input.buttons == self.inputs[-1].buttons: # If input buttons are the same as last input, # combine it with last input instead of making a duplicate. self.inputs[-1].end_frame = self.inputs[-1].end_frame + 1 else: self.inputs.append(input) # Don't need to keep inputs after a certain amount of time has passed. # Down/back charge history will be stored in game state, # so deleting old inputs has no effect on charge moves. if len(self.inputs) > 30: self.inputs.pop(0) def render(self, display: pg.surface.Surface) -> None: font = pg.font.Font("assets/seguisym.ttf", 20) for i in range(len(self.inputs)): # Print the newest inputs first, closer to the top. input = self.inputs[len(self.inputs) - 1 - i] arrow_direction = directionsToArrow(input.buttons) attack_buttons = attackButtonsToLetters(input.buttons) input_string = f"{arrow_direction} {attack_buttons} {input.end_frame - input.start_frame}" text = font.render(f"{input_string}", True, constants.WHITE) if self.player == "P1": x = 10 else: x = constants.WINDOW_WIDTH - 80 display.blit(text, (x, 15 * i))
(The logic behind deleting old inputs is probably not perfect, but it probably doesn’t have to be, either.)
We also implement some basic helper methods to convert Inputs into a text form, for InputHistory’s render().
# inputs.py def directionsToArrow(buttons: dict[Button, bool]) -> str: ''' Takes a dict of buttons pressed, and returns a character of the arrow corresponding to their direction (or ' ' for neutral). It is assumed that the buttons have already been SOCD cleaned. ''' if buttons[Button.LEFT]: if buttons[Button.UP]: return '↖' # elif buttons[Button.DOWN]: return '↙' else: return '←' elif buttons[Button.RIGHT]: if buttons[Button.UP]: return '↗' elif buttons[Button.DOWN]: return '↘' # else: return '→' elif buttons[Button.DOWN]: return '↓' elif buttons[Button.UP]: return '↑' else: return ' ' def attackButtonsToLetters(buttons: dict[Button, bool]) -> str: ''' Takes a dict of buttons pressed and returns a string of letters (or ' ' for nothing) representing all attack buttons pressed. ''' string = "" if buttons[Button.PUNCH]: string = string + "P" if buttons[Button.KICK]: string = string + "K" if buttons[Button.SLASH]: string = string + "S" if buttons[Button.HEAVY]: string = string + "H" if buttons[Button.DUST]: string = string + "D" return string
We can now add this into our main.py game loop:
# main.py # ... def render(): window.fill(constants.BLACK) fpsCounter.render(window) inputHistoryP1.render(window) inputHistoryP2.render(window) # no Inputs being added here right now pg.display.update() running = True while running: processInput() update() render() current_frame = current_frame + 1 fpsCounter.clock.tick(constants.FRAME_RATE_CAP)
And that’s all we need to get Inputs running and displayable.
Nothing too glamorous, but it gets the job done. Ah, pushing buttons to make all sorts of text spool out rapidly is quite fun.
Not a bad place to stop - our Inputs system seems to work fine, and is decoupled enough from other systems that we shouldn’t need to touch it much when we begin work on update().
We are missing a few things in the Inputs system, like a second set of keybindings for player 2, there are some magic numbers littered around that need to be cleaned up at some point, and we will need to bosh it around some more for GGPO, rollback, and netplay, but it’s enough for now.
python main.py when in the same directory as main.py to run it.
python main.py
I’ve uploaded the code for this article on GitHub here, where you can read it in uninterrupted form, or download it to try it yourself.
If we look back at our “Eventual goals”, we’ve currently managed “Display inputs on screen” and “Avoid hardcoding keybinds into our business logic”.
But before we can work on parsing our Inputs into actions, we’ll need to build up our game state. We’ll begin tackling this in article #3.
Coming to a blog, which is this one, at a time when it’s ready, which is soon (I hope). Look forward to it.
» Next: #3: Game state
I’ve also written a short mini-article on adding a few unit tests for inputs.py. This is not necessary in order to build our game, but it is an important part of programming at large, and I couldn’t find a direct mention of setting up tests within the Pygame tutorials, so I thought I would share something about how I set mine up, and some of the resources I drew upon to do so.
It’s more of a general Python or programming topic than a Pygame one, but I think it’ll be quite helpful to cover.
» Next: #2.1: Unit testing examples
Code fragments make up the body of this article, but I’m not sure how effective it was.
I think it’s better than writing pure abstract theory of what needs to be done, but most of my non-code comments turned out rather light. I am reasonably proud that my code is neat and well-commented enough to be self-documenting, at least in my opinion, but it did make my non-code comments feel a bit obvious when I wrote them.
But I think there is some merit to providing a logical order for looking through the code. It feels more approachable than throwing a whole GitHub repository at the reader. Learning to read through one of those is a useful skill, but it’s always a bit daunting.
If you have any complaints or feedback about the format, please let me know in the comments below.