We start the design and implementation of the game logic using the Command pattern. Then, we use it to fill areas:
This post is part of the 2D Strategy Game series
We implement game logic with a more advanced Command pattern:
The Command
interface has three methods, some providing a new feature:
priority()
: returns the level of priority. Commands with a low priority value run first. We replace any existing command with the same level. Consequently, we can run only one command with a given priority: this feature can prevent the execution of unnecessary processing. For instance, if all command that changes a cell has the same priority, we only execute the last one.check()
: returns True
if the command is valid and does something. It is useful when we need to try actions. For example, it can be in the user interface, where we only enable a button if its corresponding command is possible. AI can use this feature to test what it can do.execute()
: the actual processing. We assume that check()
returned True
and do the changes with no checks.Implementation: the addCommand()
method of the Logic
class adds the command to the command list. The executeCommands()
method runs all scheduled commands:
def executeCommands(self):
commands = self.__commands.copy()
self.__commands.clear()
priorities = sorted(commands.keys())
for priority in priorities:
command = commands[priority]
if not command.check(self):
continue
command.execute(self)
Lines 2-3 copy the list in a variable and clear the attribute. We must proceed this way if the commands schedule commands. Thanks to this copy, we add commands to the attribute, not the list currently processed.
Line 4 sorts the priority levels. The key()
method returns a list of dictionary keys, and the sorted()
function returns a sorted list. As a result, priorities
contains the priority levels from the lowest to the highest.
Line 5 iterates through these levels, and line 6 gets the command corresponding to the current priority. Then, if the check()
method returns False
, we end the current iteration (lines 7-8). Finally, we run the command (line 9).
We create three command classes to update cells in each layer type (ground, impassable, and objects). These classes inherit a SetLayerValueCommand
class with shared features:
The priority()
method of the SetLayerValueCommand
class return a priority level that depends on the cell to update:
def priority(self) -> int:
return WORLD_PRIORITY + self._coords[0] + self._coords[1] * WORLD_MAX_WIDTH
The main idea is to compute a unique integer value for each cell and for the update case. The expression self._coords[0] + self._coords[1] * WORLD_MAX_WIDTH
is the usual conversion from 2D to 1D coordinates x + y * width
. We assume that WORLD_MAX_WIDTH
contains the highest with of the world; this is something we should check when creating or loading a level.
The WORLD_PRIORITY
value is set to ensure that the priority values don't intersect with others. Right now, these commands are the only ones, so we don't have to worry about that: it will be useful later.
Note that all layer command classes share this priority()
method: it means that we can only update a cell for a single layer. It is a design choice; if we want to update a cell on several layers simultaneously, we need to create a different set of priority values for each layer.
The check()
method of the SetGroundValueCommand()
returns True
if the command leads to an update:
def check(self, logic: Logic) -> bool:
value = self._value
if not checkCellValue("ground", value):
return False
coords = self._coords
world = logic.world
if not world.contains(coords):
return False
value = self._value
groundValue = world.ground.getValue(coords)
if value == groundValue: # Value already set
return False
if value == CellValue.GROUND_SEA: # Sea case
impassableValue = world.impassable.getValue(coords)
if impassableValue != CellValue.NONE:
return False
objectsValue = world.objects.getValue(coords)
if objectsValue != CellValue.NONE:
return False
return True
Lines 2-4 ensure that the value we want to set is correct. For instance, for the ground layer, the possible values are 101 and 102. As before, we don't want to add these values in the code: we collect them in a specific place (the CellValue
class). Moreover, we also put the logic that checks these values in the same python file with the checkCellValue()
function:
CellValueRanges = {
"ground": (101, 103),
"impassable": (201, 204),
"objects": (301, 311)
}
def checkCellValue(layer: str, value: CellValue):
if layer != "ground" and value == CellValue.NONE:
return True
valueRange = CellValueRanges[layer]
return valueRange[0] <= value < valueRange[1]
Note that we also try to write compact code even for implementing this function. For example, we use the CellValueRanges
dictionary in place of a large if
...elif
block to get the value ranges.
The remaining lines of the check()
method ensure that we don't try to remove ground under an existing element in another layer.
The implementation of the check()
method in the other command classes is similar.
The execute()
method of the SetGroundValueCommand()
sets the value and schedule new commands if we ask for a fill:
def execute(self, logic: Logic):
coords = self._coords
value = self._value
ground = logic.world.ground
ground.setValue(coords, value)
if self._fill:
x, y = coords[0], coords[1]
logic.addCommand(SetGroundValueCommand((x + 1, y), value, True))
logic.addCommand(SetGroundValueCommand((x - 1, y), value, True))
logic.addCommand(SetGroundValueCommand((x, y + 1), value, True))
logic.addCommand(SetGroundValueCommand((x, y - 1), value, True))
Lines 8-12 add a command in each direction around the current cell at (x,y). The game logic will execute these commands during the next game epoch. Consequently, if you run the program and click with the middle button, the area around the mouse cursor is slowly filled with a random object. It is because it runs at the pace of the game epoch update, which is 30 times per second (see the end of the main game loop in the run()
method of UserInterface
).
In the next post, I add a cache to reduce rendering time.