Design Patterns and Video Games

2D Strategy Game (21): Buildings

We add buildings to increase city production and allow the recruitment of units!

This post is part of the 2D Strategy Game series

Buildings

Buildings are created on the world map, near a city:

Buildings on the world map

It differs from games like Civilization, where buildings are "inside" the city, usually presented in a list.

We assign each building to one (and only one) city. Then, the city gains bonuses that depend on the buildings. In the following screenshot, we see the city screen, where each assigned building (and house) is highlighted:

Buildings in the city screen

The player can assign (or unassign) buildings with a mouse click.

We have two types of buildings: production and training camps.

The production buildings boost the production of one resource:

We have two tiers of buildings. The base ones (mills, sawmills, and markets) increase base production, but in a limited way, so we need to build several ones to get maximum effect. The advanced ones (bakery, factory, and bank) increase the production of base ones, so we need them to get an impact. They have no limit, so only one is worthwhile.
The training camps allow the recruitment of units in the city. Each training camp can recruit one unit of one type during the turn. For instance, in the screenshot above, we can recruit one worker (the city acts as a worker training camp), two bowmen, one pikeman, one swordsman, one knight, and two catapults.

Game state

We update the game state to include the buildings and the city assignments:

Updated game state for buildings and assignments

We previously used the buildings layer to store the city and assignments. It causes some issues; for instance, we can't share item assignments across layers. Consequently, we remove this layer and create a new Assignments class dedicated to assignments. The layers are then:

Item properties. We add two new item properties: RECRUIT and RECRUIT_MAX. They define what units (and how many) an item can recruit each turn. Here are the defaults for the training camps:

CampProperties: Dict[CellValue, Dict[ItemProperty, ItemPropertyValue]] = {
    CellValue.OBJECTS_CAMP: {
        ItemProperty.RECRUIT: {UnitClass.PIKEMAN: 0},
        ItemProperty.RECRUIT_MAX: {UnitClass.PIKEMAN: 1},
    },
    CellValue.OBJECTS_BOWCAMP: {
        ItemProperty.RECRUIT: {UnitClass.BOWMAN: 0},
        ItemProperty.RECRUIT_MAX: {UnitClass.BOWMAN: 1},
    },
    CellValue.OBJECTS_SWORDCAMP: {
        ItemProperty.RECRUIT: {UnitClass.SWORDSMAN: 0},
        ItemProperty.RECRUIT_MAX: {UnitClass.SWORDSMAN: 1},
    },
    CellValue.OBJECTS_KNIGHTCAMP: {
        ItemProperty.RECRUIT: {UnitClass.KNIGHT: 0},
        ItemProperty.RECRUIT_MAX: {UnitClass.KNIGHT: 1},
    },
    CellValue.OBJECTS_SIEGECAMP: {
        ItemProperty.RECRUIT: {UnitClass.CATAPULT: 0},
        ItemProperty.RECRUIT_MAX: {UnitClass.CATAPULT: 1},
    },
}

Default city properties also gain similar values with UnitClass.WORKER.

Game logic

Assignments. We convert the AssignCitizen and UnassignCityzen commands into more generic AssignCell and UnassignCell commands. They (un)assign a cell to a city.

The cell assignment works in the following way, assuming that the tile at location cell is not already assigned:

usage = rules.getCityCellUsage(city, cell)
if usage == CellUsage.HOUSE:
    citizenCount = city.getProperty(ItemProperty.CITIZEN_COUNT, 0)
    city.setIntProperty(ItemProperty.CITIZEN_COUNT, citizenCount + 1)
elif usage in [CellUsage.PRODUCTION_BUILDING, CellUsage.TRAINING_CAMP]:
    pass
elif usage == CellUsage.WORKER:
    workerCount = city.getProperty(ItemProperty.WORKER_COUNT, 0)
    city.setIntProperty(ItemProperty.WORKER_COUNT, workerCount + 1)
else:
    raise ValueError(f"Invalid usage {usage}")
world.assignments.setAssignment(cell, city)
city.assign(cell)
state.notifyCityCellAssigned(self.__cityCell, cell, usage)
state.notifyResourcesChanged()

We first use a new getCityCellUsage() method that returns what a city can do with a tile (line 1). For instance, if the tile contains houses, it returns CellUsage.HOUSE, meaning the city can use that tile for housing.

For tiles with houses, we increase the number of citizens (lines 2-4). For tiles with buildings, we have no property to update (lines 5-6): we recalculate their effect whenever necessary. We increase the number of workers for tiles the city can work on (lines 7-9).

Finally, we assign the tile to the city (lines 12-13) and notify this assignment (line 14) and that resources have changed (line 15).

Compute city production. We compute the effects of buildings in the computeCityProduction() method of the Rules class. It is a long method that returns named dictionaries of resources, e.g. Dict[str, Dict[Resource, int]]. Each dictionary corresponds to a specific total; for instance, "workers" contains the resources workers produce.

We first create the base dictionary that corresponds to the city base production:

base: Dict[Resource, int] = defaultdict(int)
addResources(base, city.getResourcesListProperty(ItemProperty.BASE_PRODUCTION, []))

The defaultdict class from the standard package collections works like a usual Python dictionary, except there is no error if we ask for an undefined value. Instead, it returns the default value of the constructor argument: in this example, it is int, so the default value is 0. This class is convenient in our current case: using this class, we don't have to define a value for each possible resource. Furthermore, it works fine since the default production is no production (value 0).

The addResources() function is a commodity function that adds resources to a dictionary and supports several argument types (list, dictionary, defaultdict, etc.).

Then, we init the total dictionary that corresponds to the sum of all productions:

total = base.copy()

Note that we copy base; otherwise, if we update total, it will also update base! Throughout the following, we update total with all production sources.

In the next stage, we compute the resources workers produce:

workers: Dict[Resource, int] = defaultdict(int)
for cell in city.cells:
    mode = self.getCityCellUsage(city, cell)
    if mode == CellUsage.WORKER:
        resources = self.computeCellResources(city, cell)
        addResources(workers, resources)
        addResources(total, resources)

We analyze each tile assigned to the city; if the city works on it, we can add resources. The computeCellResources() method returns the list of resources a tile produces (without any bonus).

The merchants' production is the number of merchants in the city, times the production of one merchant:

merchants: Dict[Resource, int] = defaultdict(int)
merchantProduction = city.getResourcesListProperty(ItemProperty.MERCHANT_PRODUCTION, [])
addResources(merchants, merchantProduction, city.getMerchantCount())
addResources(total, merchantProduction, city.getMerchantCount())

At this stage, total contains the city production without bonuses.

We initialize the upkeep dictionary with the "cost" of citizens (e.g., what they eat each turn):

upkeep: Dict[Resource, int] = defaultdict(int)
citizenUpkeep = city.getResourcesListProperty(ItemProperty.CITIZEN_UPKEEP, [])
addResources(upkeep, citizenUpkeep, city.getCitizenCount())

Before computing building effects, we count them:

objects = self.__state.world.objects
buildings: Dict[int, int] = defaultdict(int)
for cell in city.cells:
    mode = self.getCityCellUsage(city, cell)
    if mode == CellUsage.PRODUCTION_BUILDING:
        objectValue = objects.getValue(cell)
        buildings[objectValue] += 1

Note the last line: thanks to the defaultdict class, we don't have to check if there is already an existing value. If there is none, the dictionary acts as if there is one with a zero value and then adds one.

Next, we add the cost of buildings to the upkeep dictionary:

buildingsUpkeep = city.getProperty(ItemProperty.BUILDINGS_UPKEEP, {})
for building, count in buildings.items():
    if building in buildingsUpkeep:
        buildingUpkeep = buildingsUpkeep[building]
        addResources(upkeep, buildingUpkeep, count)

We compute the mills and bakery production:

mills = {
    Resource.FOOD: min(total[Resource.FOOD], 8 * buildings[CellValue.OBJECTS_MILL])
}
if buildings[CellValue.OBJECTS_BAKERY] > 0:
    bakery = {
        Resource.FOOD: mills[Resource.FOOD]
    }
else:
    bakery = {}
addResources(total, mills)
addResources(total, bakery)

Mills double the food production: its production is the same as the base production total[Resource.FOOD]. However, it cannot be more than eight times the number of mills 8 * buildings[CellValue.OBJECTS_MILL]. Consequently, we take the minimum value between the two (line 2).

The bakery doubles the mills' production: if there is at least one, its production is mills[Resource.FOOD].

The computation of the effects of other buildings works the same way.

Finally, we compute the final production balance is the difference between the total production and the upkeep:

balance = total.copy()
for resource, count in upkeep.items():
    balance[resource] -= count

Next turn. Buildings bring a tricky part in the game state update. If there is upkeep the player can't afford, we do something to prevent a negative amount of resources. We could delete buildings as we do for units, but instead, choose to disable them, in which case they lose their effect. Here comes the tricky part: turning off a building can reduce production and lead to more negative totals.

To solve this issue, we create a new updatePlayer() method in the NextTurn command that updates the data of a player:

def updatePlayer(self, logic: Logic, player: Player) -> bool:
    ...

This method returns a boolean value: if it is True, everything is fine, and we can proceed. Otherwise, a building was disabled, so we must restart the player update.

In the updateAll() method of the NextTurn class, we use it in the following way:

for player in state.players:
    while not self.updatePlayer(logic, player):
        pass

For each player, we repeat the update until it's ok.

Nothing is special in the updatePlayer() method except that if a resource total turns negative, we disable a building that uses the same resource and return False. It is certainly not the most optimal algorithm since some buildings could be more interesting to keep than others. It is only to ensure that we never have a negative amount of resources! It is up to the player to make the best choices.

User Interface

In the city screen, we add a new frame for recruiting new units:

Unit Recruitment frame

Each button corresponds to a unit type and appears if there is a corresponding training camp (except for workers we recruit with the city). This frame needs to be dynamic: if the player (un)assigns a training camp, the number of buttons can change.

We create a new RecruitFrame class, a child of FrameComponent, to manage the buttons for unit recruitment. In this class, we define updateButtons(), a method we call every time a training camp is (un)assigned. The central part of this method is the following:

self.removeAllComponents()
previousButton: Optional[Button] = None
recruits, recruitsMax = rules.getCityRecruitState(city)
for unitClass in sorted(recruits.keys()):
    button = RecruitButton(theme, logic, cityCell, unitClass)
    if previousButton is None:
        button.moveRelativeTo("topLeft", self, "topLeft")
    else:
        button.moveRelativeTo("left", previousButton, "right")
    self.addComponent(button)
    previousButton = button
self.pack()

We first call the removeAllComponents() method (line 1) to remove all the current child components. Then, we define previousButton to store the previous button: at the beginning, there is no such button, so it is None (line 2).

We call a new getCityRecruitState() method from the Rules class (line 3). It uses a city's RECRUIT and RECRUIT_MAX properties to build two dictionaries with the number and maximum of recruited units per class. For instance, recruits[UnitClass.WORKER] contains the number of workers recruited this turn.

Then, we iterate through unit classes in these dictionaries (line 4) and create a new button for each case (line 5). We describe RecruitButton in the following. If there is no previous button (line 6), we move the button to the top left corner of the frame (line 7). Otherwise, we move the button's left side to the previous one's right side (line 9). Finally, we add the button to the frame (line 10) and update the previous button (line 11).

In the end, we call the pack() method that computes the frame size that contains all buttons.

The RecruitButton class inherits the Button class:

class RecruitButton(Button):
    def __init__(self, theme: Theme, logic: Logic, 
                 cityCell: Tuple[int, int], unitClass: UnitClass):
        self.unitClass = unitClass
        command = RecruitUnit(cityCell, unitClass)

        def action(buttons: Tuple[bool, bool, bool]):
            if buttons[0]:
                logic.addCommand(command)

        tileset = theme.getTileset("units")
        surface = tileset.getTile(unitClass, logic.state.playerId)
        super().__init__(theme, surface, action)
        ok = command.check(logic)
        tooltip = f"Recruit a {unitClass.toName()} ({command.available} available)<br>"
        cost = formatResourceCost(command.cost)
        if cost:
            tooltip += f"{cost}<br>"
        if not ok:
            self.disable()
            tooltip += command.message
        else:
            tooltip += f"<s color='blue'><leftclick>Recruit</s>"
        self.setTootip(tooltip, 200)

The main purpose of this class is to trigger the RecruitUnit command. This command creates a new unit given a city and a unit class (line 5). As for any command, it has a check() method that returns True if we can run the command. We run this method (line 14) and update the tooltip depending on the result (lines 15-24). We also disable the button if we cannot run the command (line 20).

Final program

Download code and assets

In the next post, we add armies.