Készítsünk HANGMAN játékot! - 1. rész

A legjobb tanulási módszernek azt tartom ha élő példán keresztül sajátítunk el valamit. Mivel a számítógépet használók az esetek nagy-nagy részében először a játékprogramokkal ismerkednek meg, talán a programozás elsajátításának is legjobb módja, ha egyszerű játékprogramocskák megírásával kezdünk neki.

Kezdésként a jól ismert Hangman játékot fogjuk elkészíteni, de objektumorientált szemlélettel. Az első működő változat linux terminálon fog futni, az utolsó viszont a népszerű Qt GUI-toolkit felülettel jelenik majd meg.

Ha valaki nem ismerné, a játékot két fél játsza. Az egyik gondol egy szóra - általában előre meghatározott témakörben -, és megmondja, hány betűből áll. A másik játékos megpróbálja kitalálni a szó betűit egyesével. Ha jó betűt mond, az első játékos beírja a kitalált betűt a helyére, ha rosszat, akkor egy akasztott ember testrészeit kezdi kirajzolni. A játék addig tart, míg a játékos ki nem találja a szót, vagy az aksztott emberke minden testrésze kirajzolásra nem kerül.
Akinek ez alapján sem világos a játék, annak minden megvilágosodik majd a program készítése során :)

Kezdjünk is hozzá!

Vegyük számba mit is kell csinálni a programunknak.
  1. "gondolnia" kell egy szót
  2. be kell kérnie a játékostól egy betűt
  3. meg kell vizsgálnia a kapott betűt, ha jó, akkor meg kell jelenítenie a szóban, ha nem jó, akkor rajzolni kell az akasztott embert
  4. ezután meg kell vizsgálnia, ki lett-e találva a szó, vagy ki lett-e rajzolva teljesen az akasztott ember
  5. ha nem, akkor a 2. ponttól folytatjuk tovább, ha igen, akkor befejeződik a játék
Az előzőekből látszik, hogy a játék egy időben ciklikusan ismétlődő dolgokat folytat. Mivel objektumorientált módon akarjuk elkészíteni, határozzuk meg, milyen objektumok fogják a dolgokat lekezelni és végrehajtani.

Először is kell egy olyan objektum, ami a szavakat fogja tárolni. Ez lesz felelős a szavak betöltéséért és véletlenszerű kiválasztásáért.

Legyen ez a 'Szavak' objektum.

Milyen műveleteket fog biztosítani nekünk ez az objektum, azaz milyen metódusai lesznek?

Legfontosabb feladata, hogy betöltsön egy listát a lemezről, majd kérésre adjon vissza nekünk egy véletlenszerű szót.

Első prototípusunk legyen ez:
class Szavak:
    def __init__(self):
        fajl=open(SZOFAJL,'r')
        self.lista=fajl.readlines()
        for i in range(len(self.lista)):
            self.lista[i]=self.lista[i][:-1]
    def valaszt(self):
        return self.lista[random.randint(0,len(self.lista)-1)]

Ahhoz hogy ez működjön pár beállítás kell a hangman.py fájlunkba a fenti classon kívül, így véglegesen legyen ez a tartalma:
#!/usr/bin/python
# -*- coding: utf-8 -*-

#########################
# HANGMAN program
# oktatási célra
#########################

import sys
import os
import os.path
import random

# konfigurációs adatok
SZOFAJL="hangman_szolista.txt"


class Szavak:
    def __init__(self):
        fajl=open(SZOFAJL,'r')
        self.lista=fajl.readlines()
        fajl.close()
        for i in range(len(self.lista)):
            self.lista[i]=self.lista[i][:-1]
    def valaszt(self):
        return self.lista[random.randint(0,len(self.lista)-1)]

s=Szavak()
print s.lista
print s.valaszt()

Az alsó három sor mindössze a 'Szavak' osztályunk tesztelését szolgálja. A teszteléshez csináljunk egy 'hangman_szolista.txt' fájlt pár szóval (minden sorban egy), az enyém így néz ki:
autó
káposzta
asztal
narancs
ember
szekrény

Teszteléskor ezt az eredményt kaptam:
ati@ati-laptop hangman $ python hangman.py 
['aut\xc3\xb3\n', 'k\xc3\xa1poszta\n', 'asztal\n', 'narancs\n',
 'ember\n', 'szekr\xc3\xa9ny \n']
narancs

Ugye emlékszünk még, hogy a python 2.x verzióiban a stringek ascii-ban kódolva tárolódnak?

Most menjünk végig a kódon, tekintsük át mi is történik a futtatásakor!
  • az első sor a kód futtatásához szükséges, ha 'exec' jogot adunk a fájlunknak (google->shebang), a második sorban pedig megadjuk a python interpreternek, hogy a forráskód utf-8 kódolásban van
  • a 9-12 sorokban beimportálunk pár standard modult, amire szükségünk lesz a későbbiekben. A sys az argumentumok kezeléséhez (is) fog kelleni, az os és os.path a fájlok eléréséhez, a random meg a véletlen számokhoz szükséges
  • a 14. sortól olyan változóknak adunk helyet, amik a programunk futását alapvetően tudják befolyásolni. Jelenleg csak a SZOFAJL található itt, ebben tudjuk megadni programunknak, hogy melyik fájlt használja a szavak beolvasásához
  • a 18-26 sorban helyezkedik el a Szavak osztályunk az __init__ és a valaszt metódusokkal
  • az __init__ metódus az osztály példányosításakor fut le, azaz kódunkban akkor, mikor az s változó értéket kap. Ekkor az __init__ megnyitja a SZOFAJL-t, és az s.lista objektumváltozóba teszi a szavakat listaként. A 23-24 sorban lévő ciklusban egy karakterrel megrövidítjük a szavakat, mivel a readlines az újsor karaktert is a szó végére teszi. Az nekünk nem kell.
  • a valaszt metódus meghívásakor választ egy véletlenszerű szót a listából a random.randint függvénnyel, amit return-nel ad vissza.
  • a 28-30 sorokban az s változóban létrehozunk egy új Szavak példányt, majd kiiratjuk a szó-listát, és választatunk egy véletlenszerű szót a valaszt() metódus segítségével
Az akasztott ember kirajzolását az Akasztofa objektum fogja végezni, ehhez létrehozzuk az Akasztofa osztályt. Mielőtt azonban ezt megtennénk, csináljuk meg az egyes hibaszintekhez tartozó akasztófa képeket. Mivel terminálra dolgozunk, könnyű dolgunk lesz.
Így néz ki a teljes akasztott emberünk:

.

  ########
   |   # #
  / \   ##
  \_/    #
  /|\    #
 / | \   #
  / \    #
 /   \   #
         #
 ###########

Egy akasztott ember kép 10 sorból áll, és maga a lógó emberke 7 sor magas, így 7 képet raktam a fájlba. A fájl első sorában ez szerepel: "10", ami az egy képben lévő sorok száma. Ezzel rugalmassá tettem az akasztófa emberkémet, hiszen ha később átrajzolom, akkor csak átírom a sorok számát és máris helyesen kerül beolvasásra. Ha eddig nem érthető valakinek, akkor meg fogja érteni magából a kódból:

class Akasztofa:
    def __init__(self):
        fajl=open(AKASZTOFAFAJL,'r')
        sorokSzama=int(fajl.readline())
        self.levelKepek=[]
        while True:
            kep=""
            for i in range(sorokSzama):
                sor=fajl.readline()
                kep+=sor
                if not sor:
                    break
            self.levelKepek.append(kep[:-1])
            if not sor:
                break
        fajl.close()

    def kiir(self,level):
        print self.levelKepek[level]

A teljes akasztófaember-fájl itt található: link

Nézzük az Akasztofa osztályt.
  • az __init__ megnyitja az AKASZTOFAFAJL-t, és rögtön beolvassa az első sort, amiben a képmagasság található, ezt átkovertálja egészre, és a sorokSzama változóba teszi
  • ezután a self.levelKepek listát egy ciklusban feltölti az akasztófa képekkel
  • az egyes akasztófaképeket annyi sor beolvasásával fűzi össze, amennyit a sorokSzama változóba beolvasott. Ezt a célt szolgálja a for ciklus.
  • a readline függvény által beolvasott karakterlánc tartalmazza az új sor karaktert is, ezért van, hogy a listába egy karakterrel rövidebb karakterlánc kerül: self.levelKepek.append(kep[:-1]). Az utolsó sor után ugyanis nincs szükségünk extra sortörésre
  • a kiir metódus egy paraméter alapján kiírja a kimenetre a megfelelő akasztottember képet
Menjünk tovább. Csinálni szeretnék egy osztályt a játékmenetre. Mit kell tudnia ennek az osztálynak? Azt szeretném, ha egy start() kiadása után bekérné a betűt a felhasználótól, megjelenítené amit meg kell, majd ezt ismételné, amíg valamelyik játék-vége feltétel be nem következik. Nézzük, az osztályunk fő törzse így néz ki:

class Jatek:
    def __init__(self):
        self.szavak=Szavak()
        self.akasztofa=Akasztofa()
        
    def start(self):
        self.vege=False
        self.nyert=False
        self.koszonto()
        self.szo=unicode(self.szavak.valaszt(),'utf-8')
        self.eredmeny=[0]*len(self.szo)
        self.hiba=0
        self.maxhiba=len(self.akasztofa.levelKepek)-2
        self.tippek=[]
        while not self.vege:
            self.megjelenit()
            tipp=self.beker()
            if tipp is not None:
                self.tippek.append(tipp)
            self.ertekel(tipp)
        self.jatekvege()

Tehát:
  • Jatek osztályunk inicializáláskor mindössze egy-egy példányt hoz létre az előzőleg definiált Szavak és Akasztofa osztályból
  • A start() metódus a játék két fontos állapotát hamisra állítja, azaz a játéknak még nincs vége, és nem nyerte meg a játékos. Ezután egy köszöntőt nyomtat a konzolra az osztály koszonto() metódusával. 
  • Gondol egy szóra, azaz meghívja a szavak objektum valaszt metódusát. A kapott szót unicode-ra konvertáljuk, mert a szóban lehet utf-8 kódolású karakter, ami ugye ascii-ban több karakterre konvertálódik. Ezt nem tudjnánk egy bekért másik betűvel összehasonlítani.
  • Beállítjuk az eredménylistánkat, amiben a betűk kitalált állapotát fogjuk tárolni, és nullázzuk a játékos hibáit, valamint meghatározzuk, hány hibát véthet. Ez abból adódik, hány hibaképet tároltunk az akasztofa objektumban. Csinálunk egy üres listát a játékos tippjeinek a tárolására
  • Ezek után belépünk a játék fő ciklusába. A ciklusban megjelenítjük a megjeleníteni valókat, bekérjük a tippet, és értékeljük. Ez megy addig, amíg az értékelés során a self.vege igaz értéket nem kap.
  • Ezután kiírjuk a játék végének üzeneteit
Lássuk az egyes metódusokat!
Köszöntő:
Egyszerű, szinte magyarázatot nem igényel.

def koszonto(self):
        WIDTH=20
        print "*"*WIDTH
        print "HANGMAN"
        print "*"*WIDTH
        print "\n"
        print "Üdvözöllek a játékban!"
        print "Gondoltam egy szóra, próbáld kitalálni!"
        print "\n"

A megjelenit metódus a következő:

def megjelenit(self):
        self.akasztofa.kiir(self.hiba)
        print "\n"
        szo=" "
        for c in range(len(self.szo)):
            if self.eredmeny[c]==0:
                szo+="_ "
            else:
                szo+=self.szo[c]+" "
        print szo
        print ""
        print "Eddigi tippjeid: "+", ".join(self.tippek)
        print ""

Kiírja a hibaszintnek megfelelő akasztófa ábrát, és a megfejtési állapotnak megfelelő szót. A nem megfejtett betűk helyett aláhúzás vonal jelenik meg. Ez alatt a játékos eddig megtett tippjei láthatók.

A beker metódus következik:
def beker(self):
        while True:
            tipp=unicode(raw_input("Addj meg egy betűt: "),'utf-8')
            if tipp=="":
                print "Nem lehet üres tipped! Lógni akarsz??"
            elif len(tipp)>1:
                print "Ez érvénytelen tipp, csak egy betűt adj meg!"
            else:
                break
        return tipp

Szintén nagyon egyszerű, beolvas egy adatot, amit unicode-ra konvertál a fentebb ismertetett okok miatt, majd megnézi, hogy érvényes-e a bírt tipp. Ha nem érvényes, azaz üres, vagy nem egy betű, akkor újat kér helyette.

A bekért tipp értékelését az ertekel metódus végzi:

def ertekel(self,tipp):
        if tipp in self.szo:
            for c in range(len(self.szo)):
                if tipp==self.szo[c]:
                    self.eredmeny[c]=1
            if self.eredmeny.count(1)==len(self.szo):
                self.vege=self.nyert=True
        else:
            print "Ez nem talált!"
            self.hiba+=1
            if self.hiba>=self.maxhiba:
                self.vege=True

Megnézi, szerepel-e a tipp a megfejtendő szóban, és ennek megfelelően módosítja a self.eredmeny és self.hiba tartalmát. Ha minden betű megfejtésre került, vagy elértük a hibák maximális számát, a játék végét igazra állítja (self.vege=True).

Már csak a jatekvege metódusunk van hátra:

def jatekvege(self):
        if self.nyert:
            print "Gratulálok! Kitaláltad!"
        else:
            self.megjelenit()
            print "Vesztettél! Érezd magad felakasztva :D"
            print "A megfejtés: "+self.szo

Ebben értékeljük az eredményt, és tájékoztatjuk róla a játékost.

A teljes kód itt található: link
Ne felejtsük el, hogy kell hozzá a hangman_rajz.txt és a hangman_szolista.txt

8 megjegyzés:

  1. kedvet kaptam, hogy kipróbáljam Tkinterrel, meg ASCII art helyett képpel. ha kész, dobom a linket:)

    VálaszTörlés
  2. szuper, köszönjük! mi is el fogunk ide jutni, de előtte parancssori paraméterek és gettext localization a terv. ha gondolod, és írsz egy cikket, betehetjük ide. lehetnél cikkíró is esetleg, ha van kedved/időd hozzá

    VálaszTörlés
  3. A 'jatekvege' funkcióban a

    print "A megfejtés: "+self.szo

    sor unicode errort dob 2.7.2-es pythonnal.
    A self.szo.encode("utf-8") megoldotta a dolgot.

    VálaszTörlés
    Válaszok
    1. print u"A megfejtés: " + unicode(self.szo,"utf-8")
      egy kicsit biztonságosabb, mert mindkét stringelem unicode objektum lesz. Igen egyébként a 2.7.2 "more strict" a stringek tekintetében, a fenti megoldás vígan futott 2.6-tal.

      Törlés
  4. Szia Attila !
    Ez érdekes. Nálam a "unicode(self.szo,"utf-8")"
    hibaüzenetet ad (TypeError: decoding Unicode is
    not supported), a "self.szo.encode("utf-8") "
    működik. Mi lehet ennek az oka ?
    Miben is különbözik a kettő?

    VálaszTörlés
  5. melyik python verzió?
    python 3 esetén nem kell az unicode konverzió. amennyiben a saját locale utf-8, akkor sem kell unicode konverzió 2.7 esetén (azt hiszem, de ebben nem vagyok 100%-ig biztos). 2.6-nál kellett azért, mert a saját locale utf-8 esetén a self.szo str objektum volt a locale szerint kódolva, és ebből unicode objektumot gyártottunk ezzel, a két unicode objektumot ezután össze tudtuk fűzni. Lassan teljesen beérik a python3 (értsd modulok tekintetében), és ezeket a problémákat teljesen elfelejthetjük.

    Egyébként a hibaüzenet azt jelenti, hogy utf-8 kódolásból való unicode konverzió nem támogatott.

    VálaszTörlés
    Válaszok
    1. esetleg, ha kiveszed az encoding paramétert, simán unicode(self.szo), akkor automatikusan kéne csinálnia a locale alapján, de ez nem minden esetben adhat jó végeredményt

      Törlés
  6. Python 2.6.6
    Csak az encode-al megy
    (self.szo.encode("utf-8")).
    Ha kiveszem az encoding paramétert,
    (simán unicode(self.szo)), akkor is
    hibaüzenet jön
    (UnicodeEncodeError: 'ascii' codec
    can't encode character...)
    ha az egyik szoban unicode ékezetes
    characterrel találkozik.
    Nem alakítja át a locale alapján
    automatikusan, pedig még a
    nyelvterületet is megadtam
    (# -*- coding: utf-8 -*-
    import locale
    locale.setlocale(locale.LC_ALL,
    'hu_HU.UTF-8')).

    Azt tudom, hogy a Python 3.x -ban már
    nem gond ez, mivel az eleve unicode
    kódolást használ. Legalább ez sikerült
    a Python 3-ban.
    Sajnos a Python 3 nagyon sokban eltér
    az elődjétől, néhol nem teljesen
    kompatibilis.
    Van akik szerint jók a változások,
    sokan viszont inkább ésszerűtlennek
    tarják a változások többségét.

    Neked milyen tapasztalataid vannak
    átállás kérdésében ?
    Kiadtak ugyan egy programot(2to3) ami
    átalakítja a régi forrást, de a
    próbáim szerint inkább csak
    "többé-kevésbé" oldja meg ezt a
    feladatot. Át kell írni a programot
    sajna. És elég sok csomag,
    python-rendszer még egy jó ideig nem
    fog áttérni.

    Rég óta szemeztem a Python-al de
    mostanáig igazából nem mélyedtem el
    benne. Kettő-három hónappal ezelőtt
    egy megfelelő CMS-t kerestem magamnak
    és így akadtam rá a Plone-ra, ami
    nagyon megradadott és így most egy
    nyomós okkal több a Python tanulására.
    Ezért is foglalkozom vele.
    Én még nem használom a Python 3-mat,
    mivel sajna a Plone még nem tért át
    arra. Tervezik az átállást (szavazási
    lista arról mit szeretne a python
    közösség átállítani :
    http://python.org/3kpoll ) nem csak a
    Plone, Zope, hanem sok egyéb rendszer/
    csomag esetén is, ami még szerintem
    várhatóan eltarthat 2-3 évig is.
    Azt azt azért jó látni,hogy már elég
    sok rendszer python 3 kompatibilis.

    VálaszTörlés