4.1. Rakstām spēli Haskell valodā

Spēles stāvoklis

Līdz šim brīdim mēs daudz laika veltījām teorijai un maz laika praktiskiem darbiem. Tagad mēs šo kļūdu labosim, veidojot strādājošu kodu Haskell valodā. Rasktīsim spēli, jo tas ļaus apgūt darbu ar ievadi un izvadi, pamācīties, kā tiek izmantoti dati, utt.

Veidosim ruletes spēli, kurā spēlētājs varēs ievadīt naudas summu, likmes, mainīt likmes summu. Izmantosim random ģeneratoru, lai saģenerētu skaitli, kas izkrīt. Mēģināsim izmantot visas tehnikas, ko esam iemācījušies iepriekš.

Pirmā problēma, kuru ir jāatrisina, ir spēles stāvoklis. Tiešām, Haskell valodā dati nevar mainīties, tāpēc ir jāizdomā veids, kā saglabāt, piemēram, cik spēlētājam palika naudas vai kāda ir likmes summa. Ir vairāki veidi, kā to panākt, viens no populārākiem ir State monādes izmantošana. Bet šo monādi mēs izskatīsim citreiz, pašlaik izmantosim citu metodi. Metode, ko izmantosim, der parastajām programmām un nav ļoti parocīga ar sarežģītākām programmām. Mēs to izskatīsim tikai izglītošanās nolūkos.

Acīmredzami, ja mums ir spēle, kurā kaut kāda darbība atkārtojas visu laiku (mūsu gadījumā - likmes ievadīšana), tad mums ir jāizveido cikls vai - Haskell gadījumā - rekursija. Shēma izskatīsies apmēram šādi:

  • 1) Tiek pieprasīts ievadīt likmi.
  • 2) Likme tiek apstrādāta.
  • 3) Tiek apstrādāts rezultāts, tiek samainīta naudas summa.
  • 4) Atkārtojam visu no pirma soļa.

Mēs veidosim šo ciklu ar rekursīvo funkciju. Bet mainīgos datus mēs izmantosim kā funkcijas argumentus. Tā arī būs tā metode, ar kuru mēs panāksim mainīgo stāvokli. Uzreiz skaidrs, ka, ja datu ir daudz, šī metode nav efektīva.


Spēles cikls

Tātad, mums ir ir jāizveido funkciju, kura īstenos šo spēles ciklu. Funkcija pārbaudīs, vai ir pietiekami naudas, ja nav, tad tiks izdots paziņojums par zaudējumu un spēle beigsies. Paziņojumu par spēles beigām izvadīs papildfunkcija end:

end s = putStrLn $ "Game ended: " ++ s

Pēc tam funkcijai vajag pārbaudīt, vai esošā likme nav lielāka par spēlētāja naudu. Spēlētājs mainīs likmi vienu reizi, un mūsu funkcijai šo likmi vajag atcerēties, jo visas nākamās spēles notiks ar šo likmi, kamēr viņš neizdomās šo likmi samainīt. Bet, ja likme ir lielāka par naudas summu, tad to ir jāsamaina uz spelētāja naudas summu.

Ja visas pārbaudes ir izietas, mēs attēlosim spēlētāja naudu un likmes izmēru, sagaidīsim ievadi no lietotāja un apstrādāsim šo ievadu.

Ar šo funkcijas aprakstu ir pietiekami, lai uzrakstītu funkcijas kodu:

game betSize money
    | money <= 0      = end "You lost!"
    | betSize > money = do
        putStrLn $ "Money too low, bet value changed to " ++ (show money) ++ "!"
        game money money
    | otherwise       = do
        putStrLn $ "Money: " ++ (show money) ++ ". Bet value: " ++ (show betSize)
        putStrLn "Enter your bet (to change amount of bet enter 'change <number>')"
        input <- getLine
        processInput betSize input money

Dati

Pirms mēs sāksim veidot funkciju processInput, mums vajag izdomāt, kā izskatās likmes, ko ievadīs spelētājs. Pirmkārt, spelētājs varēs uzlikt uz vienu konkrētu ciparu no 0 līdz 36. Otrkārt, viņš var uzlikt uz krāsu, pāra/nepāra, skaitļiem no 1 līdz 18 vai no 19 - 36 vai uz vienu no 3 segmentiem pa 12 skaitļiem katrā. Tāpat, pieņemsim, ka var likt uz 3 dažādiem skaitļiem vai uz sešiem dažādiem skaitļiem. Atkarībā no likmes mainās uzvaras koeficients - ja ir uzvarēts, liekot uz krāsu, tad koeficients ir 2, bet, ja ir uzvarēts, liekot uz 6 dažādiem skaitļiem, tad koeficients ir 36.

Izpētot dažādas likmes, mēs redzam, ka ir 2 dažādi veidi. Pirmais veids ir, kad ir zināmi konkrētie skaitļi, tam pieder - krāsas, paritāte, puses un segmenti. Savukārt otrais veids ir, kad mēs varam uzlikt uz 1, 3 vai 6 dažādiem skaitļiem. Pirmajam veidam ievads būs specifiskie atslēgvārdi, piemēram, "red". Otram veidam mēs definēsim, uz cik dažādiem skaitļiem var izdarīt likmi. Izveidosim šādus datus:

-- Bets
reds   = [1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36]
blacks = [1..36] \\ reds

bets = [
        ("odd",    [1,3..35],  2),
        ("even",   [2, 4..36], 2),
        ("red",    reds,       2),
        ("black",  blacks,     2),
        ("1..18",  [1..18],    2),
        ("19..36", [19..36],   2),
        ("1..12",  [1..12],    3),
        ("13..24", [13..24],   3),
        ("25..36", [25..36],   3)
    ]

numberBets = [1,3,6]

Pievērsiet uzmanību pirmai rindai - ar -- prefiksu Haskell valodā definējas koda komentāri vienā līnijā. Lai izveidotu komentāru vairākās līnijās, tiek izmantoti delimiteri {- komentārs -}.

Ar reds mēs definējam skaitļus, kuriem ir sarkana krāsa klasiskajā ruletē. Savukārt lai definētu melnos, mēs izmantojam funkciju (\\) no bibliotēkas Data.List - tā paņem divus sarakstus un atgriež pirmo sarakstu, no kura ir izņemti visi elementi, kas ir otrajā sarakstā, piemēram, [1,2,3,4,5] \\ [2,5] atgriež [1,3,4].

Saraksts bets sastāv no triples jeb pāra ar 3 elementiem. Pirmais elements ir rinda, ko ievadīs lietotājs. Otrais elements ir skaitļi, kas iekrīt šajā likmē. Savukārt trešais elements ir koeficients, ar kuru tiek reizināta likme uzvaras gadījumā.

Saraksta numberBets elementi ir nekas cits kā atļautie garumi gadījumā, ja cilvēks liek uz kaut kādiem atsevišķiem skaitļiem.


Palīgfunkcijas

Tagad mēs varam sarakstīt dažas palīgfunkcijas. Viena no tām ir funkcija, kas apstrādā likmi, kas ir 1, 3 vai 6 skaitļi. Vienosimies par formātu - tie būs skaitļi, kas ir atdalīti ar komatu, piemēram, 7,19,24. Tātad mums ir vajadzīgas 2 funkcijas, pirmā pārbauda, vai ievads ir pareizs, otrā sadala ievadu listē.

Bibliotēkā Data.Char ir funkcija, kas pārbauda, vai simbols ir cipars, isDigit :: Char -> Bool. Ir arī funkcija isNumber, kura ir diezgan neloģiska - it kā liekas, ka tai vajadzētu pārbaudīt, vai rinda ir korekts skaitlis, bet tā nav - patiesībā atšķirība starp isDigit un isNumber ir tajā, ka isNumber atbalsta dažus UTF-8 simbolus, kuri reprezentē romiešu ciparus un citus. Es netaisos diskutēt par to, vai šai funkcijai ir kaut kāds pielietojums, bet mums tā nav vajadzīga. Ko mēs gribētu panākt - mēs gribam importēt bibliotēku Data.Char, bet ignorēt isNumber funkciju, jo mēs šo nosaukumu gribām izmantot savai funkcijai. To izdarīt var diezgan vienkārši:

import Data.Char hiding (isNumber)

Ja gribat ignorēt vairākas funkcijas, tad rakstiet tās iekavās, atdalītas ar komatu. Līdzīgi var importēt tikai konkrētu funkciju vai dažas funkcijas:

import Data.Char (isDigit, toUpper)

Vēl dažas import iespējas: ja Jums ir divas bibliotēkas, kurās ir funkcijas ar vienādu nosaukumu, var izmantot qualified importu:

import qualified Data.Char

Turpmāk, lai izmantotu funkciju no šīs bibliotēkas, ir jāraksta pilns namespace, piemēram, Data.Char.toUpper. Šo var apiet, izmantojot moduļa sinonīmu:

import qualified Data.Char as Ch

Šajā gadījumā mēs nodefinējam Ch kā namespace sinonīmu, un to var izmantot šādi: Ch.toUpper. Sinonīmus var izmantot arī bez atslēgvārda qualified, tad funkcijas var izmantot gan ar, gan bez namespace sinonīma. Bet, ja neizmanto namespace sinonīmu, tad var saputroties, ja tiešām ir funkcijas ar vienādu nosaukumu - Haskell izmantos to funkciju, kura ir importēta pirmā, bet Jums nebūs skaidrs, kura tieši ir pirmā. Tāpēc, ja Jūs izmantojat namespace sinonīmu, tad labāk izmantot arī qualified importu un visur likt klāt to sinonīmu.


Atgriežoties pie mūsu spēles - mēs importējam Data.Char, ignorējot isNumber, un tagad mēs varam sarakstīt nepieciešamās funkcijas:

import Data.Char hiding (isNumber)

-- Check if all symbols in string are digits
isNumber n = n /= "" && all isDigit n

-- Check if string is numbers separated by comma
checkNumbers = all isNumber . splitBy ','

-- Split string by comma and convert to Int
toNumbers = nub . map (\x -> read x :: Int) . splitBy ','

-- Split string by delimiter
splitBy delimiter = foldr checkDelimiter [[]]
    where
        checkDelimiter c l@(x:xs)
            | c == delimiter = [] : l
            | otherwise      = (c:x) : xs

-- Check if number count is within allowed lengths and all numbers are in [0..36]
checkList l lengths = (length parsedList) `elem` lengths && all (`elem` [0..36]) parsedList
    where parsedList = toNumbers l

Ar funkciju isNumber viss ir skaidrs - tā pārbauda, vai rinda nav tukša un katrs simbols ir cipars. Funkcija checkNumbers ir kombinācija no 2 izteiksmēm. Pirmā, izmantojot splitBy funkciju, sadala sarakstu apakšrindās, izmantojot atdalītāju ','. Otrā izteiksme pārbauda, vai visas apakšrindas ir skaitļi. Savukārt funkcija toNumbers konvertē tās apakšrindas par skaitļiem.

Funkcijas splitBy kodā mēs izmantojam daudzas lietas, ko mēs esam apguvuši iepriekš - dažādas pattern matching iespējas, daļējo eta-reduction, guard simbolus. Mēs redzam kaut ko jaunu, un tas ir where atslēgvārds. Ar to Jūs varat definēt datus un funkcijas, kas eksistē tikai funkcijas ietvaros. Dotajā piemērā mēs definējam mazu funkciju checkDelimiter, kuru augstāk izmantojam kā foldr pirmo argumentu. Ārpus funkcijas splitBy tā funkcija neeksistēs. Funkcijas splitBy kodu paprovējiet izpētīt un saprast paši.

Visbeidzot, funkcija checkList - tā pārbauda, vai skaitļu skaits ir atļaujams un vai visi skaitļi ir no 0 līdz 36. Pievērsiet uzmanību, kā mēs konstrukcijā (`elem` [0..36]) apvienojam kopā eta-reduction un infix pierakstu funkcijai elem.


Mēs esam uzrakstījuši funkcijas, kas apstrādā likmes, kas ir atsevišķi skaitļi. Tagad mums arī vajag apstrādāt tādas likmes kā odd, black utt. Pateicoties tam, ka mums jau ir uzvaras koeficienti un ka mēs noteikti zinām, kādi skaitļi atbilst noteiktai likmei, funkcijas būs tikai divas. Pirmā funkcija meklēs, vai likme ir sarakstā:

import Data.Tuple.Utils

-- Check if argument is index in list of triples
inList :: Eq a => a -> [(a, b, c)] -> Bool
inList index = any (\ x -> index == fst3 x)

Funkcija fst3 nāk ar bibliotēku Data.Tuple.Utils un tā atgriež pirmo elementu no trīs elementu pāra. Mēs redzam, ka mēs esam daļēji noreducējuši funkciju inList, jo mums nekur nefigurē saraksta mainīgais. Padomājiet, kā var noreducēt lambda funkciju, kas tiek padota funkcijai any, atbilde būs zemāk.

Lai meklētu sarakstos, kuri sastāv no divu elementu pāriem, pastāv funkcija lookup :: Eq a => a -> [(a, b)] -> Maybe b. Tā atrod pāri, kuram pirmais elements atbilst meklētajam, un atgriež otro elementu. Šādi bieži tiek veidoti asociatīvie saraksti - kur indeksi ir rindas vai cits tips vai indeksi nav sakārtoti. Mūsu gadījumā šī funkcija neder, jo mums tiek izmantoti pāri ar 3 elementiem. Tāpat mēs gribam, lai funkcijas rezultāts nebūtu ar Maybe kontekstu - mēs labāk izmantosim kaut kādu noklusējuma vērtību. Un, visbeidzot, mums vajadzēs atgriezt vai nu otro, vai trešo pāra elementu, šo arī vajag noteikt ar argumentu.

Rezultātā šī funkcija izmantos 4 argumentus: pirmais ir indekss, kuru mēs meklēsim; otrais ir saraksts, kurā meklēsim; trešais ir funkcija, ko izpildīsim uz atrastā elementa - šajā gadījumā tā ir snd3 vai thd3; ceturtais ir vērtība, ko atgriezt, ja elements nav atrasts. Pirms izsaukt šo funkciju, mēs pārbaudīsim sarakstu ar funkciju inList, tāpēc ceturtais parametrs pēc idejas nav vajadzīgs, bet tomēr atstāsim to, ja gadījumā mēs šo funkciju kaut kad izmantosim citur. Funkcijas kods:

-- Find index in list of triples and execute function on that triple
tripleLookup _ [] _ def = def
tripleLookup index (l:ls) fn def
    | fst3 l == index = fn l
    | otherwise       = tripleLookup index ls fn def

Uz doto brīdi mums ir funkcijas, kas atrod likmes, funkcijas, kas izpilda spēles ciklu, vairākas papildfunkcijas, mums ir visi dati par likmēm. Mēs esam izdomājuši, kā glabāt spēles stāvokli un kā izpildīsies spēles cikls. Mums palika vēl nedaudz - uztaisīt funkcijas, kas apstrādā lietotāja ievadu un kas saģenerē skaitli un aprēķina uzvarēto vai zaudēto naudas summu. To visu darīsim turpinājumā, kā arī iemācīsimies, kā sadalīt mūsu programmu moduļos.



>> 4.2. Rakstām spēli (turpinājums)