2D Spelontwikkeling in Python
ISW Workshop

By Jelle Pelgrims

Inleiding

Tijdens deze workshop gaan we een klein videospel maken. Dit doen we in Python, stap voor stap.

Voordat je begint is het belangrijk dat alle nodige software op je computer geïnstalleerd is:

Een deel van het videospel (framework-code en gebruikte media) geven we al mee. Deze bestanden kan je hier downloaden. We bouwen dan tijdens de workshop verder aan de gegeven code tot we een speelbaar gegeven hebben.

Het spel dat we maken zal relatief simpel zijn. Als eerste maken we een spelwereld aan. Die spelwereld vullen we dan met spelmonsters. Als laatste zorgen we ervoor dat de monsters en het spelerskarakter met elkaar kunnen vechten.

Inhoudstafel

1. Spelontwikkeling 101

Videospellen zijn vaak te ingewikkeld om helemaal zelf te maken. Om een competitief videospel te programmeren heeft men kennis nodig van:

Als men al deze delen zelf wel implementeren dan kan de ontwikkelingstijd van het spel enorm oplopen. Daarom ook dat veel spelontwikkelaars gebruik maken van videospel-specifieke libraries of zogenaamde game engines.

Wij maken gebruik van de library Pyglet. Je kan Pyglet installeren door een terminal te openen en het systeem-specifieke commando uit te voeren:

# Voor windows-systemen:
pip install pyglet

# Voor Linux-systemen:
sudo apt install python3-pip # Niet nodig indien pip all op je systeem staat
pip3 install pyglet

# Voor Mac-systemen:
sudo python -m ensurepip
pip install pyglet

2. De spelstructuur

De structuur van een videospel kunnen we opdelen in drie belangrijke delen:

Al deze onderdelen komen samen in de game loop. De game loop is een lus die zal blijven draaien zolang dat het spel bezig is. In de lus wordt opeenvolgend 1) input van de speler verwerkt, 2) de spellogica doorlopen en 3) het spelvenster vernieuwd.

3. De graphics

De graphics voor het spel dat we in deze workshop maken zijn heel eenvoudig. We bezien de spelwereld van boven af, zoals bijvoorbeeld in de eerste pokémon-spellen. We moeten dus geen ingewikkelde formules gebruiken om 3D-objecten op het scherm te laten verschijnen.

Om de spelwereld op het spelvenster te laten verschijnen werken we met een camera-systeem. De camera is gericht op een bepaalde locatie in de spelwereld, en laat enkel een gebied even groot als het spelvenster zien. Bij het omzetten van de spelwereldcoördinaten naar camera/scherm-coördinaten komt er wel nog wat wiskunde kijken, maar die slaan we voor deze workshop over.

De spelobjecten geven we vorm aan de hand van sprites. Sprites zijn kleine, pixelachtige plaatjes van spelobjecten die vooral vroeger veel gebruikt werden in videospellen. We laden deze sprites eenmalig op in het spel (de sprites zijn vaak gewoon .png-bestanden) en gebruiken ze dan op het scherm. Alle sprites van een spel één voor één laden zal echter wel voor veel overbodige code en tijdsverlies zorgen. Daarom ook dat we gebruik maken van spritesheets: Afbeeldingen die meerdere sprites bevatten en die we in één keer kunnen laden.

Spritesheet example

4. De spelwereld

Als eerste maken we dus een spelwereld aan. Deze wereld is opgedeeld in een raster met verschillende tegels. Op deze manier is het gemakkelijk om de wereld op te slaan in een 2-dimensionale array. De verschillende tegels kunnen we verschillende numerieke waardes geven, die dan hun "type" bepalen.

We gaan de wereld niet handmatig aanmaken. In plaats daarvan gebruiken we het random walk-algoritme. Dit algoritme zal vanaf een startlocatie een bepaald aantal keren in een willekeurige richting bewegen. Om met dit algoritme een interessante spelwereld aan te maken vullen we eerst een wereld met muur-tegels. Dan starten we het algoritme. De tegels waarop het algoritme beweegt vervangen we met grond-tegels. Na verloop van tijd zal het algoritme dan een soort van "pad" hebben uitgegraven in de wereld.

De onderstaande code steken we in een bestand "world.py".

import random
from enum import IntEnum

class tile(IntEnum):
    EMPTY, WALL, GROUND = range(3)

class Tile:

    def __init__(self, tile_id, x, y):
        self.tile_id = tile_id
        self.x, self.y = x, y
        self.blocked = False

        if self.tile_id is tile.WALL:
            self.blocked = True

class World:

    def __init__(self, player, map_width, map_height):
        self.player = player
        self.map_width = map_width
        self.map_height = map_height

        self.tilemap = generate_random_walk_cave((self.map_width, self.map_height), (self.player.x, self.player.y), 5000)
        self.entities = [player]

    def is_blocked(self, x, y):
        if self.tilemap[x][y].blocked:
            return True
        else:
            return any(entity.x == x and entity.y == y for entity in self.entities)

def generate_random_walk_cave(size, start, length):
    width, height = size
    x, y = start

    terrain = [[Tile(tile.WALL, x, y) for x in range(width)] for y in range(height)]
    terrain[x][y] = Tile(tile.GROUND, x, y)

    for i in range(length):
        t = random.choice([1, 2, 3, 4])

        if t == 1 and not (x+1) > width-2:
            x += 1
        elif t == 2 and not (x-1) < 1:
            x -= 1
        elif t == 3 and not (y+1) > height-2:
            y += 1
        elif t == 4 and not (y-1) < 1:
            y -= 1

        terrain[x][y] = Tile(tile.GROUND, x, y)

    return terrain

Als je nu "game1.py" (uit het zip-bestand dat je eerder in de workshop gedownload hebt) uitvoert dan zou je iets zoals op de onderstaande afbeelding moeten zien:

Gamedev workshop part 1

5. De spelkarakters

De spelkarakters houden we simpelweg bij in een aparte lijst. Bij het opstarten van het spel plaatsen we ze op willekeurige plaatsen in de spelwereld. De spelmonsters moeten achter elkaar jagen. Hiervoor moet elk monster een update() functie hebben, waarin we dan de logica voor het jagen steken. Elke herhaling van de spellus doorlopen we deze functie dan voor elk monster. De inhoud van de functie bestaat voornamelijk uit het oproepen van een pathfinding-algoritme. Hiermee wordt het pad naar de prooi berekend dat het monster dan kan volgen.

De onderstaande code steken we in een bestand "world.py".

import random
import utilities

class Entity:

    def __init__(self, entity_type, coords):
        self.name = entity_type
        self.direction = (0, 1)
        self.hitpoints = 100
        self.is_dead = False
        self.damaged_counter = 0
        self.x, self.y = coords
        self.path_queue = []

    def damage(self, damage):
        self.damaged_counter += 10
        if self.hitpoints - damage <= 0:
            self.hitpoints = 0
            self.is_dead = True
        else:
            self.hitpoints -= damage

    def move(self, world, dx, dy):
        self.direction = (dx, dy)
        if not world.is_blocked(self.x+dx, self.y+dy) and not self.is_dead:
            self.x += dx
            self.y += dy
        else:
            for entity in filter(lambda e: e.x == self.x+dx and e.y == self.y+dy, world.entities):
                if not entity.is_dead:
                    entity.damage(30)

    def moveto(self, world, x, y):
        if not world.tilemap[x][y].blocked:
            self.path_queue = utilities.pathfind(world, (self.x, self.y), (x, y))

    def update(self, world):
        if len(self.path_queue) != 0:
            x, y = self.path_queue.pop(0)
            dx, dy = x - self.x, y - self.y
            self.move(world, dx, dy)

class Player(Entity):

    def __init__(self, coords):
        Entity.__init__(self, "Player", coords)

class Monster(Entity):

    def __init__(self, coords):
        Entity.__init__(self, "Monster", coords)
        self.prey = None

    def update(self, world):
        self.logic(world)
        rnd = random.uniform(0.0, 10.0)
        if len(self.path_queue) != 0 and rnd > 9:
            x, y = self.path_queue.pop(0)
            dx, dy = x - self.x, y - self.y
            self.move(world, dx, dy)

    def logic(self, world):
        if not self.prey:
            entities = [entity for entity in world.entities if not entity is self and not entity.is_dead]
            self.prey = random.choice(entities)

        if self.prey.is_dead:
            self.prey = None
        else:
            self.moveto(world, self.prey.x, self.prey.y)

Als je nu "game3.py" (uit het zip-bestand dat je eerder in de workshop gedownload hebt) uitvoert dan zou je iets zoals op de onderstaande afbeelding moeten zien:

Gamedev workshop part 3