3. Alchemy tekstversie#

Drie lijsten#

In de tekstversie van de Alchemy game gebruiken we drie lijsten:

  • elements: een lijst met de namen van alle elementen die in het spel voorkomen.

  • discoveries: een lijst met de elementen die de speler heeft ontdekt. De items in deze lijst zijn integers die overeenkomen met indices van de elementen in de elements lijst.

  • recipes: een twee-dimensionale lijst waarin we de recepten opslaan. Je kunt deze lijst zien als een tabel waarin het rijnummer de index van het eerste element is en het kolomnummer de index van het tweede element. De waarde in de tabel is de index van het nieuwe element dat ontstaat door de combinatie van de twee elementen.

Hieronder wordt getoond hoe de elements en recipes lijsten werken:

../_images/alchemy_lists.svg

Zoals je ziet, gebruiken we Engelse namen voor de elementen, maar je mag natuurlijk zelf ook Nederlandse namen kiezen. In de recipes tabel kun je aflezen dat bijvoorbeeld de elementen met index 0 en 1 samen een nieuw element opleveren met index 4. Dit betekent dat als spelers de elementen 'water' en 'fire' combineren, ze het element 'steam' ontdekken. De index van 'steam' is namelijk 4.

In de recipes tabel staan ook veel streepjes. Dit betekent dat de combinatie van die twee elementen geen nieuw element oplevert. Bijvoorbeeld de combinatie van 'wave' (index 5) en 'water' (index 0) levert geen nieuw element op, want op het kruispunt van rij 5 en kolom 0 staat een streepje. Uiteraard kun je later zelf recepten toevoegen waardoor wél nieuwe elementen ontstaan.

Het is je vast al opgevallen dat de recipes tabel symmetrisch is in de hoofddiagonaal: je kunt hem spiegelen in de diagonaal van linksboven naar rechtsonder. Dit betekent dat de volgorde waarin je de elementen combineert niet uitmaakt. De combinatie van 'water' en 'fire' levert hetzelfde resultaat op als de combinatie van 'fire' en 'water'.

Nu gaan we echt beginnen met het programmeren van het spel. Verwijder alle code uit je alchemytxt.py bestand en voeg de volgende code toe:

alchemytxt.py#
 1################
 2# ALCHEMY GAME #
 3# Text version #
 4################
 5
 6# LISTS
 7
 8elements = [
 9   'water',
10   'fire',
11   'wind',
12   'earth',
13   'steam',
14   'wave',
15   'plant',
16   'smoke',
17   'lava',
18   'dust'
19]
20
21recipes = [[None for column in range(len(elements))] for row in range(len(elements))]
22
23discoveries = []

De moeilijkste regel is regel 21. Hier maken we de twee-dimensionale lijst recipes aan met dezelfde afmetingen als de elements lijst. Met een dubbele list comprehension zorgen we ervoor dat alle cellen in de tabel de waarde None krijgen. In Python betekent None dat er geen waarde is. None komt dus overeen met een streepje in de tabel.

Om de recipes lijst zichtbaar te maken, kun je op regel 22 tijdelijk de volgende code toevoegen:

22for row in recipes: print(row)

Als je nu de code runt, krijg je de volgende uitvoer:

[None, None, None, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]

Verwijder de code in regel 22 weer, want dit was slechts om te laten zien dat de lijst goed is aangemaakt.

Helper functies#

Nu gaan we vier helper functies maken. De eerste is add_recipe(). Met deze functie kunnen we recepten toevoegen aan de recipes lijst.

Voeg de volgende code toe:

25# HELPER FUNCTIONS
26
27def add_recipe(ingredient1, ingredient2, result):
28   i1 = elements.index(ingredient1)
29   i2 = elements.index(ingredient2)
30   r = elements.index(result)
31   recipes[i1][i2] = r
32   recipes[i2][i1] = r

Op regel 27 zie je dat we add_recipe() drie parameters meegeven: de twee ingrediënten en het resulterende element. Op regels 28-30 gebruiken we de list-functie index() om de indices van de ingrediënten en van het resultaat op te zoeken in de elements lijst. Op regels 31-32 vullen we de cellen in de recipes tabel met de index van het resultaat. Je ziet hierin weer de eerder genoemde symmetrie terugkomen.

Laten we meteen een paar recepten toevoegen. Voeg de volgende regels toe onder de add_recipe() functie:

34# MAIN PROGRAM
35
36add_recipe('water', 'fire', 'steam')
37add_recipe('water', 'wind', 'wave')
38add_recipe('water', 'earth', 'plant')
39add_recipe('fire', 'wind', 'smoke')
40add_recipe('fire', 'earth', 'lava')
41add_recipe('wind', 'earth', 'dust')
42
43for row in recipes: print(row)   # Just for testing

Op regel 43 printen we de recipes lijst uit, zodat je kunt zien dat de recepten zijn toegevoegd, wederom slechts om te testen of alles goed werkt. Als je het programma nu runt, krijg je de volgende uitvoer:

[None, 4, 5, 6, None, None, None, None, None, None]
[4, None, 7, 8, None, None, None, None, None, None]
[5, 7, None, 9, None, None, None, None, None, None]
[6, 8, 9, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]
[None, None, None, None, None, None, None, None, None, None]

En voilà, we hebben de tabel met recepten gemaakt!

De volgende helper functie is get_recipe(). Deze functie gaan we later gebruiken om te checken of de combinatie van twee ingrediënten een nieuw element oplevert. Eigenlijk is het een soort lookup functie, die het juiste item uit de recipes lijst haalt. Voeg onderstaande code toe onder de add_recipe() functie (en boven # MAIN PROGRAM).

34def get_recipe(ingredient1, ingredient2):
35   i1 = elements.index(ingredient1)
36   i2 = elements.index(ingredient2)
37   r = recipes[i1][i2]
38   return r

Om te testen of de functie goed werkt, kun je de volgende regel gebruiken in plaats van het eerder print statement waarmee we de receptentabel testten:

49print(get_recipe('water', 'fire'))  # Just for testing

De uitvoer zou moeten zijn:

4

Want de combinatie van 'water' en 'fire' levert het element met index 4 op, namelijk 'steam'.

De derde lijst die we in het spel gebruiken is de discoveries lijst. Deze lijst houdt bij welke elementen de speler al heeft ontdekt. In regel 23 hebben we daarvoor een lege lijst aangemaakt: discoveries = []. Om nieuw ontdekte elementen toe te voegen aan discoveries, maken we de helper functie add_discovery(). Voeg onderstaande code toe onder de get_recipe() functie:

40def add_discovery(element):
41   i = elements.index(element)
42   if i not in discoveries:
43      discoveries.append(i)

Met de index() functie zoeken we weer de index van het element op in de elements lijst. Vervolgens controleren we met een if statement of deze index al in de discoveries lijst staat. Als dat niet zo is, voegen we de index toe aan de discoveries lijst.

Laten we ook deze functie testen. Voeg de volgende regels toe onderaan je code, na de add_recipe() aanroepen:

54add_discovery('water')
55add_discovery('fire')
56add_discovery('wind')
57add_discovery('earth')
58
59print(discoveries)  # Just for testing

De uitvoer is nu:

[0, 1, 2, 3]

De laatste helper functie die we nodig hebben, is is_discovered(). Deze functie controleert of een bepaald element al is ontdekt door de speler. Voeg onderstaande code toe onder de add_discovery() functie:

45def is_discovered(element):
46   if element not in elements:
47      return False
48   i = elements.index(element)
49   return i in discoveries

De functie is_discovered() controleert eerst of de naam van het element dat we willen controleren überhaupt in de elements lijst staat. Als dat niet zo is, geeft de functie False terug (en wordt de rest van de functie niet uitgevoerd). Als het element wel in de lijst staat, zoeken we de index op. Met return i in discoveries retourneren we True als die index in discoveries voorkomt. Zo niet, dan wordt False teruggegeven.

Gebruik om de functie te testen de volgende code. Let daarbij op het verschil tussen de dubbele en de enkele aanhalingstekens (en bedenk waarom dat nodig is).

65print(f"{is_discovered('earth') = }")  # Just for testing
66print(f"{is_discovered('smoke') = }")  # Just for testing
67print(f"{is_discovered('stone') = }")  # Just for testing

De uitvoer laat zien dat 'earth' is ontdekt, maar de andere twee elementen niet:

is_discovered('earth') = True
is_discovered('smoke') = False
is_discovered('stone') = False
f-strings met een = teken

Met f-strings ben je inmiddels wel enigszins bekend, maar het gebruik van een = teken in een f-string, zoals in regels 65-67, is nieuw. De techniek bestaat sinds Python 3.8 en is een manier om een expressie en het resultaat ervan in één keer weer te geven. In Python wordt dit een Self Documenting Expression genoemd. Handig voor het debuggen van je code!

Game loop#

De fundamenten van het spel zijn klaar. We hebben drie lijsten en vier helper functies gemaakt. Nu gaan we de game loop maken. Dit is de hoofdloop van het spel, die steeds opnieuw wordt uitgevoerd totdat de speler het spel beëindigt. In de loop vragen we de speler telkens om twee elementen te combineren en kijken we of ze een nieuw element opleveren. Als dat zo is, voegen we het nieuwe element toe aan de discoveries lijst.

Naast het combineren van elementen, moet de speler ook de mogelijkheid hebben om een lijst op te vragen van de elementen die hij al heeft ontdekt, en het spel moet kunnen worden beëindigd. Laten we met die twee zaken beginnen. Voeg de volgende code toe onderaan in # MAIN PROGRAM:

65print('Mix two ingredients with a + sign, e.g. water+fire')
66print('d to view discoveries, x to exit')
67while True:
68   command = input('> ')
69   if command == 'x':
70      print('PROGRAM TERMINATED')
71      break
72   elif command == 'd':
73      print('-' * 20)
74      print('DISCOVERIES:')
75      for d in discoveries: print(elements[d])
76      print('-' * 20)

Regels 65-66 geven de speler instructies over hoe hij het spel kan spelen. Regel 67 start de game loop. De loop blijft draaien totdat de speler het spel beëindigt door x in te voeren. Als de speler d invoert, wordt de lijst met ontdekte elementen weergegeven. De lijst wordt netjes opgemaakt met een lijn erboven en eronder.

break

Wellicht wekt regel 67 enige verbazing. Met while True starten we een loop die oneindig doorgaat. Hoe kan het spel dan ooit stoppen? Daarvoor gebruiken we in regel 71 het break keyword. Met deze instructie kun je een loop vroegtijdig beëindigen. Vergelijk de onderstaande codefragmenten:

reply = input('Typ x om te stoppen: ')
while reply != 'x':
   reply = input('Typ x om te stoppen: ')
while True:
   reply = input('Typ x om te stoppen: ')
   if reply == 'x':
      break

Beide fragmenten doen hetzelfde. Welke techniek je kiest, hangt af van de situatie en je persoonlijke voorkeur. Het eerste codefragment telt een regel minder dan het tweede, maar de regel reply = input('Typ x om te stoppen: ') komt twee keer voor, hetgeen minder elegant is.

Nu moeten we nog de mogelijkheid inbouwen om twee elementen met elkaar te combineren. In regel 65 zie je dat we de speler vragen om elementen te combineren met een + teken. Onze code moet de invoer van de gebruiker dus opslitsen in twee stukken: het deel vóór het + teken en het deel erna. Python heeft daar een handige functie voor: split(). Deze functie splitst een string in delen, op basis van een opgegeven scheidingsteken. In ons geval is dat het + teken. De functie geeft een lijst terug met de delen.
Voeg onderstaande code toe aan het if statement dat we net hebben gemaakt:

77   else:
78      ingredients = command.split('+')
79      if len(ingredients) != 2 or not (is_discovered(ingredients[0]) and is_discovered(ingredients[1])):
80            print('Unknown command')
81            continue
82      else:
83            print(f'Combining {ingredients[0]} and {ingredients[1]}.')

In regel 78 splitsen we de invoer van de speler op het + teken. De resulterende lijst met ingrediënten wordt opgeslagen in de variabele ingredients. Vervolgens controleren we of de lengte van de lijst gelijk is aan 2 (de speler moet immers twee ingrediënten opgeven) en of beide ingrediënten al zijn ontdekt. Als dat niet zo is, geven we een foutmelding weer en gaan we terug naar het begin van de loop met continue. Als alles goed is, geven we een melding weer dat we de ingrediënten gaan combineren.

continue

Net als het break keyword, is ook continue een instructie die je kunt gebruiken om een loop te beïnvloeden. Met continue ga je terug naar het begin van de loop, zonder de rest van de code in de loop uit te voeren. Het volgende voorbeeld laat zien hoe dat werkt:

for i in range(5):
   if i == 2:
      continue
   print(i)

De output van deze code is:

0
1
3
4

Wij gebruiken continue in regel 81 om de rest van de loop over te slaan als de speler een onbekende opdracht invoert. De loop gaat dan meteen weer terug naar het begin, waar we opnieuw om een opdracht vragen.

Wanneer de speler een goede combinatie van elementen invoert, moeten we checken of er een recept bestaat voor deze combinatie. Vervolgens kunnen er drie dingen gebeuren:

  1. Er bestaat geen recept voor de combinatie. In dat geval vertellen we dat aan de speler.

  2. Er bestaat een recept voor de combinatie, maar het resultaat is al ontdekt. Ook dat vertellen we aan de speler.

  3. Er bestaat een recept voor de combinatie en het resultaat is nog niet ontdekt. We voegen het resultaat toe aan de discoveries lijst en vertellen dat aan de speler.

In code ziet dat er zo uit (verwijder het huidige else statement in regels 82-83):

82         r = get_recipe(ingredients[0], ingredients[1])
83         if r == None:
84            print(f'Alas, no recipe found for {ingredients[0]} and {ingredients[1]}.')
85         elif r in discoveries:
86            print(f'You already discovered {elements[r]}.')
87         else:
88            print(f'You discovered {elements[r]}!')
89            add_discovery(elements[r])

En hiermee is de tekstversie van het spel klaar! Probeer het spel uit en kijk of alles werkt. Je kunt nu elementen combineren, de ontdekkingen bekijken en het spel beëindigen.

../_images/alchemytxt_finished.png

Als je het spel nog verder wilt uitbreiden, kun je uiteraard zelf recepten toevoegen of de lijst met elementen uitbreiden. Maar het is natuurlijk nog leuker om het spel een grafische interface te geven met Pygame Zero! Echter voordat we daarmee beginnen, gaan we de tekstversie nog wat verbeteren. In plaats van lijsten met indices, gaan we dictionaries gebruiken en we gaan de recepten in een tekstbestand opslaan. Dat maakt de code een stuk overzichtelijker en makkelijker aan te passen.