TIES343 - Asteroids peli

Johdanto

Tässä toteutetaan yksinkertainen Asteroids tyylinen peli. Pelissä alus (punainen ympyrä) yrittää tuhota asteroideja (ruskeat ympyrät) ampumalla niitä pienemmiksi paloiksi. Asteroidiin törmääminen tuhoaa aluksen ja peli loppuu. Peliä ohjataan hiiren vasemmalla napilla. Vasen nappi lähettää ammuksen ja jokainen ammuttu ammus lisää aluksen nopeutta.

Tämän tiedoston viimeinen version löytyy osoitteesta https://yousource.it.jyu.fi/ties343/asteroids, josta voit kopioida sen muokattavaksesi.

Käytetyt kirjastot

Pelin toteuttamiseksi Main-modulissa otetaan käyttöön gloss-kirjasto, joka tarjoaa sekä grafiikkaa (Graphics.Gloss), yksinkertaisia vektorioperaatioita (Graphics.Gloss.Data.Vector) ja valmiin rajapinnan näppäinten ja hiirten lukua varten (Graphics.Gloss.Interface.Game). Tämän kirjaston ei ole tarkoitus olla erityisen kattava grafiikkakirjasto tai pelimoottori, vaan pelkästään yksinkertainen tapa saada asioita ruudulle. Jos olet asentanut Haskell ympäristön omalle koneellesi, joudut asentamaan myös Gloss kirjaston, mikä toivottavasti onnistuu komennolla cabal update && cabal install gloss. Kirjastot otetaan käyttöön näin:

> module Main where
> import Graphics.Gloss
> import Graphics.Gloss.Data.Vector
> import Graphics.Gloss.Interface.Game

Pelimaailma

Ensimmäiseksi määrittelemme tietotyypin pelimaailman tilaa varten. Pelimaailma koostuu kivistä, pelaajan aluksesta ja ammutuista ammuksista. Lisäksi peli voi olla tilassa GameOver, johon päädytään silloin kun pelaaja lentää aluksensa asteroidiin.

> data AsteroidWorld = GameOver 
> | World { rocks :: [Rock]
> , shipLocation :: PointInSpace
> , shipVelocity :: Velocity
> , bullets :: [Bullet]}

Yllä kirjoitimme tietotyypin kenttien tyypeiksi semanttisesti mielekkäitä arvoja, kuten Bullet tai Velocity. Seuraavaksi kirjoitamme tyyppien konkreettiset esitykset:

> type PointInSpace = (Float, Float)
> type Velocity = (Float, Float)
> type Size = Float
> type Age = Float
> data Bullet = Bullet PointInSpace Velocity Age 
> data Rock = Rock PointInSpace Size Velocity

Osa tyypeistä on pelkkiä tyyppisynonyymejä, jotka auttavat ohjelmoijaa lukemaan koodia ja kaksi viimeistä ovat varsinaisia tietotyyppejä, jotka koostuvat useammasta kentästä.

Näiden määritelmien perusteella voimmekin koostaa ensimmäisen pelimaailmamme:

> initialWorld = World
> [Rock (150,150) 45 (2,6)
> ,Rock (-45,201) 45 (13,-8)
> ,Rock (45,22) 25 (-2,8)
> ,Rock (-210,-15) 30 (-2,-8)
> ,Rock (-45,-201) 25 (8,2)
> ]
> (0,0) (0,5)
> []

Tietotyyppi AsteroidWorld esittää siis pelimaailman tilaa tietylla ajan hetkellä. Seuraavaksi lienee olennaista osata esittää pelimaailma graafisesti. Teemme siis funktion, joka kuvaa pelimaailmamme Gloss kirjaston käyttämäksi kuvatyypiksi:

> drawWorld :: AsteroidWorld -> Picture
> drawWorld GameOver =  scale 0.3 0.3 . translate (-400) 0 . color red . text $ "Game Over!"
> drawWorld (World rocks (x,y) (vx,vy) bullets) = pictures [ship, asteroids,shots] 
> where
> ship = color red . pictures $ [translate x y . circle $ 10]
> asteroids = pictures [translate x y . color orange . circle $ s | Rock  (x,y) s _ <- rocks]
> shots = pictures [translate x y . color red . circle $ 2 | Bullet (x,y) _ _ <- bullets]

Tätä funktiota voitaisiin testata esimerkiksi seuraavalla pääohjelmalla:

main = displayInWindow "Testimaailma" (550,550) (20,20) black $ drawWorld initialWorld

Simulaatio

Seuraavaksi teemme funktion, jolla päivitämme maailman tilaa annetun aika-askeleen verran. Tämä funktio toteuttaa käytännössä koko pelin sisäisen logiikan. Toistamalla tätä funktiota maailma etenee omien lakiensa mukaan, eli käytännössä siirrämme kaikkia kohteita nopeutensa verran ja tarkastamme törmäykset asteroidien ja ammusten sekä aluksen välillä. Lopuksi luomme maailman uudestaan ilman tuhoutuneita kohteita. (Käyttäjän antama syöte huomioidaan myöhemmin.)

Käytämme määritelmissä myös apufunktioita .+ ja .*, jotka helpottavat vektoreilla laskemista. Ne määritellään tämän dokumentin lopussa.

> simulateWorld :: Float -> AsteroidWorld -> AsteroidWorld
> simulateWorld _        GameOver          = GameOver  
> simulateWorld timeStep (World rocks ship v bullets) 
> | any (collidesWith ship) rocks = GameOver
> | otherwise = World (concatMap updateRock rocks)
> newShipPos v
> (concatMap updateBullet bullets)
> where
> collidesWith p (Rock rp s _) = magV (rp .- p) < s
>      collidesWithBullet r = any (\(Bullet bp _ _) -> collidesWith bp r) bullets 
>      updateRock r@(Rock p s v) 
> | collidesWithBullet r && s < 7 = []
> | collidesWithBullet r && s > 7 = splitRock r
> | otherwise = [Rock (restoreToScreen $ p .+ timeStep .* v) s v]
>      updateBullet (Bullet p v a) 
> | a > 5 = []
> | any (collidesWith p) rocks = []
> | otherwise = [Bullet (restoreToScreen $ p .+ timeStep .* v)
> v (a + timeStep)]
>      newShipPos = restoreToScreen $ ship .+ timeStep .* v

Simuloinnin toteuttamiseksi tarvitsemme pari apufunktiota: yhden kivien pilkkomiseen ja toisen ruudulta karanneiden kohteiden palauttamiseen takaisin ruudulle.

> splitRock :: Rock -> [Rock]
> splitRock (Rock p s v) = [Rock p (s/2) (3 .* rotateV (pi/3) v)
> ,Rock p (s/2) (3 .* rotateV (-pi/3) v) ]
> restoreToScreen :: PointInSpace -> PointInSpace
> restoreToScreen (x,y) = (cycleCoordinates x, cycleCoordinates y)
> cycleCoordinates :: (Ord a, Num a) => a -> a
> cycleCoordinates x
> | x < (-400) = 800+x
> | x > 400 = x-800
> | otherwise = x

Käyttäjän syötteiden hallinta

Käyttämämme Gloss kirjasto käsittelee syötteet yksi kerrallaan siten, että sille annetaan maailman tilaa eventin perusteella päivittävä funktio, jota kutsuaan kun havaitaan syöte.

> handleEvents :: Event -> AsteroidWorld -> AsteroidWorld
> handleEvents _ GameOver = GameOver
> handleEvents (EventKey (MouseButton LeftButton) Down _ clickPos)
> (World rocks shipPos shipVel bullets)
> = World rocks shipPos newVel (newBullet : bullets)
> where
> newBullet = Bullet shipPos (-150 .* norm (shipPos .- clickPos)) 0
> newVel = shipVel .+ (50 .* norm (shipPos .- clickPos))
> handleEvents _ w = w

Eli muokkaamme pelimaailmaan sopivalla tavalla, aina kun hiiren nappia painetaan.

Pääohjelma

Pääohjelma on tässä tapauksessa hyvin yksinkertainen. Kutsumme Gloss kirjaston gameInWindow funktiota ja livautamme sen parametreiksi juuri määrittelemämme piirto-, simulaatio-, ja tapahtumankäsittelijäfunktiot.

> main = gameInWindow
> "Haskell Asteroids"
> (550,550)
> (20,20)
> black
> 24
> initialWorld
> drawWorld
> handleEvents
> simulateWorld

Lyhyitä apufunktioita

Lopuksi esitämme vielä vektorilaskuun käyttämämme apufunktiot. Tietystikin määrittelimme näitä ohjelmaa kirjoittaessamme sitä mukaa kuin tarvitsimme niitä, emmekä vasta lopuksi.

> (x,y) .- (u,v) = (x-u,y-v)
> (x,y) .+ (u,v) = (x+u,y+v)
> infixl 6 .- , .+
> infixl 7 .*
> (.*) :: Float -> PointInSpace -> PointInSpace
> s .* (u,v) = (s*u,s*v)
> norm :: Floating t => (t, t) -> (t, t)
> norm (x,y) = let m = sqrt (x**2 + y**2) in (x/m,y/m)
blog comments powered by Disqus