Creature Battle Arena: soorten wezens¶
In week 5 bouwde je Creature en Party. Al je wezens vallen nog op dezelfde
manier aan: ze doen schade ter grootte van hun aanvalskracht. Maar een draak
spuwt vuur, een genezer valt helemaal niet aan, en een wolf wordt woedend en
bijt de tweede keer harder.
Je kunt voor elke soort een nieuwe klasse schrijven, met alles erin wat
Creature al heeft. Dan staat take_damage straks vijf keer in je bestand, en
een fout erin moet je vijf keer herstellen. In dit practicum maak je nieuwe
soorten wezens die alles van Creature overnemen, en alleen zelf schrijven wat
anders is.
Waar je begint¶
Je werkt verder in je creatures.py van week 5, met de klassen Creature en
Party en de assertions eronder. Laat die assertions staan: ze horen aan het
eind van dit practicum nog steeds te slagen.
Heb je week 5 niet af, download dan
de eindstand van week 5 en begin
daarmee. De assertions van week 5 staan daar al onderaan.
Wat je gaat maken¶
Stap |
Wat |
Doet |
|---|---|---|
1 |
|
een gewone aanval, voor elk wezen |
2 |
|
drie soorten wezens, elk met een eigen |
3 |
een lus over gemengde wezens |
één aanroep, ander gedrag per soort |
4 |
|
wat twee soorten delen, op één plek |
5 |
|
twee beesten die op een andere manier woedend worden |
6 |
|
een wachttoren die meevecht zonder een |
7 |
|
één aanval tussen twee strijders |
8 |
|
een gevecht tussen twee partijen, ronde na ronde |
Onder elke stap staan assertions. Zet ze onderaan in creatures.py, onder die van
week 5, en voer het bestand uit. Zie je geen foutmelding, dan klopt de stap.
Stap 1: een speciale zet voor elk wezen¶
Elk wezen krijgt een speciale zet: special_move(target). Voor een gewoon
Creature is dat een gewone aanval, met een zin die beschrijft wat er gebeurde.
Voeg deze methode toe aan Creature:
def special_move(self, target):
"""Doet een gewone aanval op target en beschrijft wat er gebeurde."""
damage = self.attack(target)
return f"{self.name} valt aan en doet {damage} schade."
De zin is alleen een verslag. De schade is echt gedaan, door attack.
vlam = Creature("Vlam", 120, 25, 10)
grom = Creature("Grom", 40, 8, 2)
assert vlam.special_move(grom) == "Vlam valt aan en doet 23 schade."
assert grom.hp == 17
Stap 2: drie soorten wezens¶
Een draak is een Creature: hij heeft hp, valt aan en neemt schade, precies
zoals elk wezen. Alleen zijn speciale zet is anders. Dat schrijf je zo:
class Dragon(Creature):
"""Een draak, die vuur spuwt."""
def __init__(self, name, hp=120, attack=25, defense=10):
"""Maak een draak; zonder verdere argumenten met de standaardwaarden."""
super().__init__(name, hp, attack, defense)
def special_move(self, target):
"""Spuwt vuur naar target."""
damage = self.attack(target)
return f"{self.name} spuwt vuur en doet {damage} schade!"
Er gebeuren vier dingen in deze paar regels:
Code |
Betekent |
|---|---|
|
|
|
een standaardwaarde voor een parameter: laat je het argument weg, dan krijgt de parameter deze waarde. |
|
roept de constructor van |
|
|
Parameters met een standaardwaarde staan achteraan. name heeft er geen, dus
een draak zonder naam kun je niet maken.
Schrijf Goblin en Healer op dezelfde manier. Ook zij zijn een subklasse van
Creature.
Klasse |
Standaardwaarden |
|
Geeft terug |
|---|---|---|---|
|
|
een gewone aanval op |
|
|
|
geneest zichzelf met |
|
Healer krijgt target dus mee zonder het te gebruiken. Dat is met opzet: zo
roep je special_move bij elk wezen op dezelfde manier aan, en dat heb je in de
volgende stap nodig.
vlam = Dragon("Vlam")
grom = Goblin("Grom")
mos = Healer("Mos")
assert repr(vlam) == "Creature(Vlam, level 1, hp 120/120, attack 25, defense 10)"
assert repr(grom) == "Creature(Grom, level 1, hp 40/40, attack 8, defense 2)"
assert repr(Healer("Varen", 80)) == (
"Creature(Varen, level 1, hp 80/80, attack 3, defense 5)"
)
assert vlam.special_move(grom) == "Vlam spuwt vuur en doet 23 schade!"
assert grom.special_move(mos) == "Grom gooit een puntige steen en doet 3 schade!"
assert grom.special_move(vlam) == "Grom gooit een puntige steen en doet 0 schade!"
mos.take_damage(30)
assert mos.hp == 32
assert mos.special_move(vlam) == (
"Mos spreekt een genezingsspreuk uit en herstelt 15 hp."
)
assert mos.hp == 47
assert vlam.is_stronger_than(grom)
De laatste regel roept is_stronger_than aan, en die staat niet in Dragon.
Een draak heeft haar geërfd van Creature.
Kijk ook naar de eerste assertion: een draak drukt zichzelf af als
Creature(...). Daar komt aan het eind een vraag over.
Stap 3: één aanroep, ander gedrag¶
Zet een draak, een goblin en een genezer in één lijst, en laat ze om de beurt hun speciale zet doen op een oefenpop. Neem deze code over en voer haar uit:
pop = Creature("Pop", 200, 1, 0)
for creature in [Dragon("Vlam"), Goblin("Grom"), Healer("Mos")]:
before = pop.hp
print(creature.special_move(pop))
print(f"hp van de pop: {before} -> {pop.hp}")
Je ziet dit:
Vlam spuwt vuur en doet 25 schade!
hp van de pop: 200 -> 175
Grom gooit een puntige steen en doet 8 schade!
hp van de pop: 175 -> 167
Mos spreekt een genezingsspreuk uit en herstelt 15 hp.
hp van de pop: 167 -> 167
In de lus staat maar één aanroep, creature.special_move(pop). Welke versie
daarvan draait, hangt af van het object: bij de draak die van Dragon, bij de
genezer die van Healer. Dat heet polymorfisme: dezelfde aanroep geeft ander
gedrag, afhankelijk van het object. De lus hoeft niet te weten welke soort wezen
ze voor zich heeft.
Ook Party hoeft dat niet te weten. Aan de klasse Party verandert niets, en
toch kan een party nu draken en genezers bevatten:
party = Party([Goblin("Grom"), Dragon("Vlam"), Healer("Mos")])
assert party.strongest_attacker().name == "Vlam"
assert len(party.alive_members()) == 3
assert pop.hp == 167
Stap 4: wat twee soorten delen¶
Een draak en een wolf zijn allebei beesten. Een beest kan woedend worden, en doet dan extra schade. Een goblin en een genezer kunnen dat niet.
Dat gedeelde gedrag hoort op één plek: in een klasse Beast, die een subklasse
is van Creature. Straks worden Dragon en Wolf subklassen van Beast.
Neem Beast over in je bestand, onder Creature:
class Beast(Creature):
"""Een wezen dat woedend kan worden, en dan harder aanvalt."""
def __init__(self, name, hp, attack, defense):
"""Maak een beest dat nog niet woedend is."""
super().__init__(name, hp, attack, defense)
self.enraged = False
def enrage(self):
"""Maakt het beest woedend."""
self.enraged = True
def rage_bonus(self, amount):
"""Geeft amount als het beest woedend is, en anders 0."""
if self.enraged:
return amount
return 0
De constructor van Beast doet eerst wat elke constructor van een Creature
doet, via super().__init__, en geeft daarna het nieuwe attribuut enraged een
beginwaarde.
Een woedend beest moet extra schade kunnen doen, zonder dat het de berekening
van attack overdoet. Daarom krijgt attack in Creature een parameter
bonus erbij, met standaardwaarde 0:
def attack(self, target, bonus=0):
"""Laat target schade nemen ter grootte van de aanvalskracht plus bonus."""
return target.take_damage(self._attack_power + bonus)
Hiermee verandert de methode attack uit week 5. Door de standaardwaarde werkt
elke aanroep attack(target) nog precies zoals eerst, en slagen de assertions
van week 5 nog.
brul = Beast("Brul", 50, 10, 0)
pop = Creature("Pop", 200, 1, 0)
assert brul.rage_bonus(5) == 0
assert brul.attack(pop, brul.rage_bonus(5)) == 10
brul.enrage()
assert brul.rage_bonus(5) == 5
assert brul.attack(pop, brul.rage_bonus(5)) == 15
assert brul.special_move(pop) == "Brul valt aan en doet 10 schade."
assert pop.hp == 165
Beast overschrijft special_move niet, dus een beest doet de gewone aanval van
Creature.
Stap 5: twee beesten¶
Maak Dragon nu een subklasse van Beast in plaats van Creature: verander
class Dragon(Creature): in class Dragon(Beast):. Zet Dragon daarvoor onder
Beast in je bestand. Een draak is dan nog steeds een Creature, want Beast
is dat ook.
Een draak en een wolf worden allebei woedend, maar op een andere manier:
Klasse |
Standaardwaarden |
|
|---|---|---|
|
|
een aanval met bonus |
|
|
een aanval met bonus |
|
Geeft terug |
|---|---|
|
|
|
|
Een wolf wordt dus woedend van zijn eigen zet, en zijn tweede beet is harder dan
zijn eerste. Een draak wordt alleen woedend als iemand anders enrage() aanroept.
Hint
De aanval van een draak is self.attack(target, self.rage_bonus(10)).
rage_bonus staat in Beast, en een draak heeft haar geërfd.
Goblin en Healer blijven een subklasse van Creature. Ze worden nooit woedend,
en hebben dus niets aan Beast.
pop = Creature("Pop", 200, 1, 0)
grijs = Wolf("Grijs")
assert grijs.special_move(pop) == (
"Grijs huilt en bespringt zijn prooi voor 15 schade, en wordt woedend!"
)
assert grijs.special_move(pop) == (
"Grijs huilt en bespringt zijn prooi voor 20 schade, en wordt woedend!"
)
vlam = Dragon("Vlam")
assert vlam.special_move(pop) == "Vlam spuwt vuur en doet 25 schade!"
assert vlam.special_move(pop) == "Vlam spuwt vuur en doet 25 schade!"
vlam.enrage()
assert vlam.special_move(pop) == "Vlam spuwt vuur en doet 35 schade!"
assert pop.hp == 80
assert repr(Wolf("Grijs")) == "Creature(Grijs, level 1, hp 50/50, attack 15, defense 5)"
Stap 6: een wachttoren die meevecht¶
Een wachttoren is geen wezen. Hij heeft geen hp en geen vaste aanvalskracht, en
geen enkele versie van een methode uit Creature past bij hem. Toch kan hij
meevechten, als hij de methoden en attributen heeft die een gevecht gebruikt.
Hij vult ze op zijn eigen manier in.
Een Turret werkt helemaal anders dan een Creature:
Hij vuurt elke keer een willekeurige schade af, van
5tot en met25.Hij valt om na precies drie klappen, hoe hard die klappen ook zijn. Eén enorme klap telt net zo zwaar als een schampschot.
De klasse erft van niets: er staat niets tussen haakjes achter class Turret.
Zet import random bovenaan je bestand, en neem dit begin over:
class Turret:
"""Een wachttoren: geen wezen, maar hij vecht wel mee."""
def __init__(self):
"""Maak een wachttoren die nog geen klap heeft gehad."""
self.name = "Wachttoren"
self._hits_taken = 0
self._max_hits = 3
self._min_damage = 5
self._max_damage = 25
def __repr__(self):
"""Geeft de wachttoren als string, met het aantal klappen."""
return f"Turret({self.name}, hits {self._hits_taken}/{self._max_hits})"
Schrijf de vier methoden die een gevecht nodig heeft:
Methode |
Doet |
|---|---|
|
telt één klap, hoe groot |
|
|
|
laat |
|
doet een |
Hint
random.randint(a, b) geeft een willekeurig geheel getal van a tot en met b.
Een Turret is geen subklasse van Creature, maar hij heeft wel de methoden en
attributen die een gevecht gebruikt, zoals special_move, is_alive en name.
Dat heet duck typing, naar het spreekwoord als het loopt als een eend en
kwaakt als een eend, dan is het een eend. Voor een aanroep telt niet van welke
klasse een object is, maar alleen of het de methoden en attributen heeft die
worden gebruikt.
De schade van een toren is elke keer anders. De assertions kijken daarom niet naar één getal, maar naar wat voor elke worp moet gelden:
stro = Creature("Stro", 1000, 1, 0)
toren = Turret()
for _ in range(20):
assert 5 <= toren.attack(stro) <= 25
toren = Turret()
toren.take_damage(1000)
assert toren.is_alive()
toren.take_damage(1)
assert toren.is_alive()
toren.take_damage(1)
assert not toren.is_alive()
assert repr(toren) == "Turret(Wachttoren, hits 3/3)"
toren = Turret()
vlam = Dragon("Vlam")
assert vlam.attack(toren) == 25
assert toren.special_move(vlam).startswith("Wachttoren vergrendelt en vuurt")
De stro-pop in de eerste lus heeft verdediging 0, zodat take_damage precies
de schade van de toren teruggeeft. In de laatste assertion geeft
s.startswith(t) True als de string s begint met de string t: het getal
aan het eind van de zin is elke keer anders, het begin niet.
Kijk ook naar de een na laatste assertion. attack van Creature roept
target.take_damage(...) aan, en een toren heeft take_damage. Een draak kan
een toren dus aanvallen, zonder dat Creature iets van torens weet.
Stap 7: één aanval tussen twee strijders¶
Neem deze functie over in je bestand. Het is een gewone functie, buiten de klassen:
def battle_round(attacker, defender):
"""Laat attacker zijn speciale zet doen op defender, en meldt de afloop."""
print(attacker.special_move(defender))
if defender.is_alive():
status = "levend"
else:
status = "verslagen"
print(f"{defender.name} is nu {status}.")
battle_round gebruikt van de aanvaller alleen special_move, en van de
verdediger alleen is_alive en name. Het maakt dus niet uit of de strijders een
Dragon, een Healer of een Turret zijn.
vlam = Dragon("Vlam")
grom = Goblin("Grom")
battle_round(vlam, grom)
battle_round(vlam, grom)
assert not grom.is_alive()
toren = Turret()
battle_round(vlam, toren)
assert toren.is_alive()
battle_round(toren, vlam)
assert 105 <= vlam.hp <= 120
De eerste twee aanroepen drukken dit af:
Vlam spuwt vuur en doet 23 schade!
Grom is nu levend.
Vlam spuwt vuur en doet 23 schade!
Grom is nu verslagen.
Stap 8: een gevecht tussen twee partijen¶
Schrijf een functie battle(side_a, side_b, max_rounds) die twee partijen tegen
elkaar laat vechten, ronde na ronde. Ook dit is een gewone functie, buiten de
klassen.
Onderdeel |
Regel |
|---|---|
een ronde |
eerst doet elk levend lid van |
het doelwit |
steeds het eerste levende lid van de andere partij |
het einde |
zodra een van beide partijen geen levende leden meer heeft, of na |
teruggeven |
het aantal gespeelde ronden |
max_rounds is nodig omdat niet elk gevecht vanzelf eindigt. Twee genezers doen
elkaar nooit schade, en een goblin doet een draak niets: 8 aanvalskracht tegen
10 verdediging is 0 schade.
Hint
Een partij valt aan in een lus over haar alive_members(). Vraag vóór elke
aanval opnieuw de levende leden van de andere partij op: het vorige doelwit kan
net verslagen zijn, en zijn er geen levende leden meer, dan valt er niemand aan
te vallen. Die aanval schrijf je twee keer, voor elke partij één keer; met een
hulpfunctie attack_all(attackers, defenders) hoeft dat maar één keer.
vlam = Dragon("Vlam")
grom = Goblin("Grom")
assert battle(Party([vlam]), Party([grom]), 10) == 2
assert not grom.is_alive()
assert vlam.hp == 120
assert battle(Party([Healer("Mos")]), Party([Healer("Varen")]), 5) == 5
team = Party([Wolf("Grijs"), Turret()])
enemies = Party([Goblin("Grom"), Goblin("Klauw")])
battle(team, enemies, 20)
assert len(enemies.alive_members()) == 0
assert len(team.alive_members()) == 2
In het derde gevecht vecht een wachttoren mee in een party. battle gebruikt van
een party alleen alive_members, en dat werkt ook met een toren erin.
Wat duck typing niet belooft¶
Een toren kan in een Party, want Party controleert niet wat je erin stopt.
Maar niet elke methode van Party werkt dan nog. Probeer het, en haal de twee
regels daarna weer weg:
team = Party([Wolf("Grijs"), Turret()])
team.heal_all(10)
AttributeError: 'Turret' object has no attribute 'heal'
heal_all roept bij elk levend lid heal aan, en een toren heeft geen heal.
Hetzelfde gebeurt met critical_members, want een toren heeft geen
is_critical, en met strongest_attacker, want een toren heeft geen
aanvalskracht om te vergelijken.
Dat is de keerzijde van duck typing. Niemand controleert vooraf of een object de
methoden en attributen heeft die later nodig zijn. De fout komt pas op het moment
dat de ontbrekende methode of het ontbrekende attribuut wordt gebruikt, en dat kan
lang nadat de toren in de party is gezet. Wat een object nodig heeft, hangt af van
wat je ermee doet: voor battle volstaat een toren, voor heal_all niet.
Vragen om over na te denken¶
Stel dat
battle_roundwél zou moeten weten van welke klasse zijn argumenten zijn. Wat zou het dan moeten controleren, en wat moet je veranderen als er een nieuwe soort wezen bijkomt? Waarom is dat slechter?battle_roundroeptattackentake_damageniet zelf aan, alleenspecial_moveenis_alive. Stel dat hetattacker.attack(defender)zou aanroepen. Is een kleiner aantal methoden dat een functie nodig heeft altijd beter, of ligt het werk dan alleen op een andere plek?Een subklasse die
special_movevergeet te overschrijven, doet stilletjes een gewone aanval. Is er verschil tussen een methode die je mag overschrijven en een methode die je moet overschrijven? Hoe zie je van buiten welke van de twee de superklasse bedoelde? In week 7 leer je hoe een superklasse kan afdwingen dat een subklasse een methode overschrijft.Omdat de schade van een toren willekeurig is, kan hetzelfde gevecht twee keer anders aflopen. Is dat een goede eigenschap van het spel, of een nadeel omdat je het gevecht slecht kunt testen? Hoe zou je één gevecht voorspelbaar maken zonder
randomuit de toren te halen?DragonenWolferven allebeienrageenrage_bonusvanBeast, maar gebruiken ze heel anders: de wolf wordt vanzelf woedend, de draak alleen door iets van buiten. Is een gedeelde superklasse dan nog de goede keuze, of hoort inBeastiets bij elkaar wat niet bij elkaar hoort?Wat zou er nu kapotgaan als
Goblineen subklasse vanBeastwerd in plaats vanCreature, terwijl hij nooitenrageaanroept? Gaat er niets kapot, is dat dan een reden om alle wezens viaBeastte laten lopen?Een
Partyheeft wezens, en eenDragonis een wezen. Waarom erftPartyniet vanCreature? Wat zou er misgaan als dat wel zo was?print(Dragon("Vlam"))druktCreature(Vlam, ...)af, en nietDragon(Vlam, ...). Waar komt die tekst vandaan? Wat zou je moeten doen om bij een draakDragon(...)te zien, en bij een wolfWolf(...)?
Tot slot¶
Je hebt vijf nieuwe soorten strijders, en het meeste van hun gedrag staat maar op
één plek. Wat alle wezens delen, staat in Creature; wat alleen beesten delen, in
Beast; en elke subklasse schrijft alleen wat anders is. battle_round en
battle werken met al die soorten, en met een wachttoren die helemaal geen wezen
is, omdat ze alleen de methoden en attributen gebruiken die elke strijder heeft.
Op een paar plekken valt je code nu nog stilletjes terug: bij foute invoer in de
constructor, en bij een subklasse die special_move vergeet. In week 7 kom je
daarop terug.