Spelontwikkeling in Python - deel 2
Terrain generation & isometric graphics

By Jelle Pelgrims

Heb je ooit al eens willen weten hoe de spelwerelden in Minecraft of Terraria gemaakt worden? In deze workshop gaan we dit gedetailleerd bekijken. We behandelen beide 2D- en 3D- terreingeneratie en werken een aantal voorbeelden uit. Ook bezien we het concept van isometrische graphics: een eenvoudige manier om 3D na te bootsen. Deze methode gebruiken we uiteindelijk om een eigen spelwereld weer te geven.

Inleiding

Tijdens deze workshop gaan we een aantal aspecten van spelontwikkeling meer gedetailleerd bekijken. We gaan onder meer terreingeneratie en isometrische graphics leren toepassen. Dit doen we door in python een aantal voorbeelden uit te werken die gebruik maken van al deze aspecten.

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

Het bestand "workshop_tools.py" geven we al mee. Dit python-bestand bevat een aantal functies die we tijdens de workshop gaan gebruiken. Je kan het bestand hier downloaden.

Inhoudstafel

1. Opzetten van de ontwikkelingsomgeving

Wij maken gebruik van de library Pygame. Je kan PyGame installeren door een terminal te openen en het onderstaande commando uit te voeren. Voor meer gedetailleerde instructies kan je op de website van PyGame eens kijken.

pip install pygame

# Indien dit niet werkt:

python3 -m pip install -U pygame --user

Ook zullen we gebruikmaken van de library opensimplex, voor het genereren van simplex noise. Deze installeer je als volgt:

pip install opensimplex

2. Terreingeneratie

In deze workshop gaan we spelwerelden maken zoals in Minecraft of Terraria. Deze werelden zijn beide opgebouwd uit blokken, en wij zullen daar dus ook gebruik van maken. Blokwerelden zijn trouwens ook heel gemakkelijk om mee te werken in eender welke programmeertaal, aangezien je niet meer nodig hebt dan een lijst of een array om ze in te bewaren. Het bewerken of aanpassen van zo'n wereld is dan niet ingewikkelder dan gewoon over de (al dan niet 2- of 3-dimensionale) lijst lopen en de individuele blokken aan te passen.

Om spelwerelden te genereren gaan we gebruik maken van een noise-functie, meer bepaald Simplex Noise. Zo een functie genereert samenhangende waarden op basis van gegeven inputs. De waarden die we terugkrijgen als uitkomst van zo'n functie liggen normaal gezien tussen 0 en 1. Dit is alles dat we nodig hebben om mee te beginnen.

2.1 Terraria

We beginnen met het aanmaken van een simpele 2-dimensionale spelwereld zoals in het spel Terraria. Dit doen we door 1-dimensionale noise te genereren:

import workshop_tools as tools
import opensimplex

SEED = 232
STEP = 10
DELTA = 50
WIDTH = 140

generator = opensimplex.OpenSimplex()

height_map = []

for x in range(0, WIDTH*STEP, STEP):
    nx = SEED + (x/WIDTH - 0.5)
    noise = generator.noise2d(nx, 0)/2 + 0.5
    elevation = round(noise*DELTA)
    height_map.append(elevation)

tools.render_1d_terrain(height_map)

Met dit kort stukje code hebben we in feite al een spelwereld aangemaakt. In de variabele height_map steken nu 140 waardes tussen 0 en 10. Deze waardes kunnen we interpreteren als een soort van "vloer". Alles onder deze vloer is grond, en alles erboven lucht.

1-dimensional terrain

Deze waardes genereren we dus door gebruik te maken van de x-waarde van een bepaalde locatie. Het x-coordinaat steken we in de noise-functie, en als uitkomst krijgen we een waarde die wij kunnen interpreteren als een grondniveau.

Het is dus logischerwijze mogelijk om voor eender welke locatie de vloer te berekenen, zonder dat men dat ook moet doen voor de voorgaande locaties! Dit geeft ons de volgende twee mogelijkheden:

  1. Oneindige mappen
    Aangezien we voor elke mogelijke x-coordinaat een niveau kunnen berekenen is de grootte van onze spelwereld eindeloos (indien nodig).

  2. On-the-fly terreingeneratie
    Dit gegeven is ook heel handig voor multiplayer-spellen. Vaak worden er voor zo'n spellen zeer grote spelwerelden gebruikt die niet helemaal in het werkgeheugen van een server passen. Als men dan gebruik maakt van noise-gebaseerde terreingeneratie kan men gewoon enkel waar er zich spelers bevinden de spelwereld genereren. Zo kan men het geheugengebruikl drastisch verminderen, en wortdt enkel het nodige gebruikt. Het opslaan van deze wereld op de harde schijf is uiteindelijk ook niet meer nodig, aangezien men enkel een seed nodig heeft om de wereld opnieuw te kunnen maken.

Bovenstaande eigenschappen gelden voor vrijwel alle mappen aangemaakt met noise-functies, zowel 2D als 3D.

Natuurlijk hebben we momenteel nog geen grotten zoals in Terraria. Om een spelwereld te maken met grotten zijn we genoodzaakt om gebruik te maken van 2-dimensionale noise. Dit betekent dus dat behalve het x-coordinaat, nu ook het y-coordinaat meegeven moet worden aan de noise-functie.

import workshop_tools as tools
import opensimplex

SEED = 139483098
STEP = 20
DELTA = 30
HEIGHT = 60
WIDTH = 140

generator = opensimplex.OpenSimplex()

height_map = [[0 for x in range(WIDTH)] for y in range(HEIGHT)]
for y in range(0, HEIGHT*STEP, STEP):
    for x in range(0, WIDTH*STEP, STEP):
        nx = SEED + (x/WIDTH - 0.5)
        ny = SEED + (y/HEIGHT - 0.5)
        noise = generator.noise2d(nx, ny) / 2.0 + 0.5
        elevation = round(noise*DELTA)
        height_map[y//STEP][x//STEP] = elevation

tools.render_heightmap(height_map)

Met dit stukje code hebben we noise gemaakt die er ongeveer zo zal uitzien:

Simplex noise

Met een beetje verbeelding is het mogelijk om in dit resultaat al grotten te zien. Echter zijn de waardes momenteel nog continu en is het dus moeilijk om deze waardes direct om te zetten naar een type van blok. Om dit op te lossen moeten we deze waardes dus omzetten van continu naar discreet. Dit kunnen we doen door simpelweg alle waardes kleiner dan een bepaalde limiet gelijk te stellen aan nul, en alle waardes gelijk te stellen aan 1. Als de waarde op een bepaalde locatie gelijk is aan 0, dan kunnen we deze interpreteren als lucht. Als de waarde gelijk is aan 1, dan kunnen we deze locatie zien als grond.

terrain = [[1 if height_map[y][x] > (DELTA//2) else 0 for x in range(WIDTH)] for y in range(HEIGHT)]
tools.render_heightmap(terrain)

Terrain with caves

Het probleem is nu echter dat we enkel grotten hebben; er is geen vloer zoals in ons vorig voorbeeld. We kunnen dit probleem oplossen door de waarden die we voordien al gebruikten (bij het maken van de Terraria-wereld) te combineren met de huidige waardes. We zeggen gewoon dat de vorige waardes, die de "vloer" van de spelwereld aangaven, hier ook van toepassing zijn. Dus, alle locaties die liggen boven de vloer van de vorige wereld kunnen we zien als lucht.

floor_map = tools.generate_1d_noise(WIDTH, DELTA, SEED)

for x in range(WIDTH):
    for y in range(0, floor_map[x]):
        terrain[y][x] = 2

tools.render_heightmap(terrain)

Terrain with caves and surface

Als we dit resultaat wat opkleuren dan zien we een spelwereld die toch wel iets weg heeft van Terraria.

tools.render_2d_terrain(terrain)

Terrain with caves and surface in color

2.2 Minecraft

Om de 3D wereld te genereren gaan we opnieuw gebruik maken van 2D noise. Elk punt in een 3D-wereld heeft een x-, een y- en een z-waarde. De x- en y-waardes die hebben we al. De z-waarde van een punt is gelijk aan de output van de simplex-noise functie, gegeven de x- en y-waarde van hetzelfde punt. De output van de simplex-functie geeft dus de elevatie van een bepaald punt (x, y) in onze wereld. Daarom worden afbeeldingen zoals onderstaande ook wel heightmaps genoemd.

Terrain with caves and surface in color

De code die nodig is om een 3D wereld te genereren is simpel. We maken een drie-dimensionale lijst aan met een bepaalde breedte, hoogte en diepte. Deze datastructuur zal onze wereld voorstellen. Voor elke x- en y-waarde in onze wereld genereren we dan een elevatie aan de hand van de output van de simplex-functie. Als laatste vullen we dan onze wereld op vanaf de berekende waarde tot de bodem.

import workshop_tools as tools
import opensimplex

SEED = 139483098 # Willekeurig nummer dat de gegenereerde waardes zal veranderen
STEP = 4 # In- of uit-zoomen op de gegenereerde waardes
DELTA = 25 # Alle gegenereerde waardes liggen tussen 0 en delta
HEIGHT = 90
WIDTH = 180

generator = opensimplex.OpenSimplex()

terrain = [[[0 for x in range(WIDTH)] for y in range(HEIGHT)] for z in range(DELTA)]
for y in range(0, HEIGHT*STEP, STEP):
    for x in range(0, WIDTH*STEP, STEP):
        nx = SEED + (x/WIDTH - 0.5)
        ny = SEED + (y/HEIGHT - 0.5)
        noise = generator.noise2d(nx, ny) / 2.0 + 0.5
        elevation = round(noise*DELTA)
        #elevation = int(round(elevation/12, 2)) * 12 # make terraces

        for z in range(0, elevation):
            terrain[z][y//STEP][x//STEP] = 1

Indien we zoals in Minecraft ook oceanen willen hebben kunnen we dit doen door y-waarde als zee-niveau te kiezen en alle lucht-blokken die onder dat niveau zitten om te zetten naar water-blokken.

3. Isometric graphics

We missen momenteel nog een manier om de 3D-wereld die we juist hebben aangemaakt op een overzichtelijke manier te visualiseren. Een gemakkelijke methode hiervoor is de isometrische weergave.

tools.render_3d_terrain(terrain)

Terrain with caves and surface in color

Een blok isometrisch weergeven doen we met deze functie (genomen uit de camera-klasse in workshop_tools.py):

def draw(self, tile, x, y):
    if tile == 0: # no tile so don't draw anything
        return
    # Fill tile walls
    pygame.draw.polygon(self.screen,
                    pygame.Color(150, 75, 0),
                    [(x,y), 
                    (x+self.block_size//2, y+self.block_size//4), 
                    (x+self.block_size//2, y+(self.block_size//4)+self.block_size//2), 
                    (x, y+self.block_size//2)])
    pygame.draw.polygon(self.screen,
                    pygame.Color(74, 37, 0),
                    [(x+self.block_size//2, y+self.block_size//4), 
                    (x+self.block_size, y), 
                    (x+self.block_size, y+self.block_size//2), 
                    (x+self.block_size//2, y+(self.block_size//4)+self.block_size//2)])
    # Fill tile "floors"
    pygame.draw.polygon(self.screen, 
                        pygame.Color("green"),
                        [(x,y), (x+self.block_size//2, y-self.block_size//4), (x+self.block_size, y), (x+self.block_size//2, y+self.block_size//4)])