Pong-peli, vaihe 7

Tässä vaiheessa lisäämme peliin pistelaskun. Pong-pelissä pelaaja saa pisteen kun pallo ohittaa toisen pelaajan mailan.

Laskurin luominen

Laskureita tulee peliin kaksi, yksi kummallekin pelaajalle.

Koska molemmat laskurit luodaan arvatenkin lähes samalla tavalla, kannattaa laskurin luomisesta tehdä aliohjelma.

Voitaisiin tehdä (älä tee vielä) uusi aliohjelma LuoPisteLaskuri:

    public static IntMeter LuoPisteLaskuri()
    {
        IntMeter laskuri = new IntMeter(0);
        laskuri.MaxValue = 10;
        return laskuri;
    }

Aliohjelman paluuarvon tyyppi on IntMeter, mikä tarkoittaa laskuria joka laskee kokonaisluvuilla (kokonaisluvun tyyppi on int).

Laskurin konstruktorille (new IntMeter...) annetaan parametrina laskurin oletusarvo, mikä pistelaskun ollessa kyseessä on luonnollisesti nolla.

Laskurille voi asettaa maksimiarvon (MaxValue), jonka jälkeen laskuri lopettaa pisteiden laskemisen. Se voi olla vaikkapa kymmenen.

Pisteiden laskemiseen riittäisi toki pelkkä kokonaisluku, mutta IntMeter-tyyppiä käyttämällä päästään helpommalla kun pisteitä halutaan esittää ruudulla, kuten kohta nähdään.

Pisteiden esittäminen

Pelkkä IntMeter-olio ei vielä näytä pisteitä, vaan ainoastaan säilyttää lukuarvon muistissa. Pisteiden esittämiseksi ruudulla tarvitaan vielä tekstikenttiä eli Labeleita.

Tehdään pistelaskurin arvon näyttävä Label laskurin ohessa.

Koska pelaajien laskurit tulevat eri kohtaan ruutua, viedään jälleen koordinaatit parametrina aliohjelmalle.

Lisää LuoPisteLaskuri-funktio vaikkaLuoPallo alapuolelle:

    public static IntMeter LuoPisteLaskuri(PhysicsGame peli,  double x, double y)
    {
        IntMeter laskuri = new IntMeter(0);
        laskuri.MaxValue = 10;

        Label naytto = new Label();
        naytto.X = x;
        naytto.Y = y;
        naytto.TextColor = Color.White;
        naytto.BorderColor = peli.Level.BackgroundColor;
        naytto.Color = peli.Level.BackgroundColor;
        
        naytto.BindTo(laskuri);
        peli.Add(naytto);

        return laskuri;
    }

Tekstikenttä sidotaan näyttämään laskurin arvoa kutsulla naytto.BindTo(laskuri). Näin ruudulle päivittyy automaattisesti laskurin arvo, vaikka sitä jossain kohtaa muutetaan.

Näytön väri asetetaan valkoiseksi, että se erottuu taustasta

    naytto.TextColor = Color.White;

Aliohjelmasta on tehty julkinen funktio, koska samalla koodilla kuka tahansa voisi luoda vastaavia laskureita. Toki kaikissa peleissä maksimi ei välttämättä olisi 10, mutta lopulta sekin voitaisiin lisätä parametriksi.

Laskureiden lisääminen peliin

Tavallaan laskurit ovat osa kenttää, joten niiden luominen voitaisiin laittaa LuoKentta-metodiin:

    private void LuoKentta()
    {
        Level.CreateBorders(1.0, false);
        Level.Background.Color = Color.Black;
        IntMeter pelaajan1Pisteet = LuoPisteLaskuri(this, Screen.Left + 100.0, Screen.Top - 100.0);
        IntMeter pelaajan2Pisteet = LuoPisteLaskuri(this, Screen.Right - 100.0, Screen.Top - 100.0);

        Camera.ZoomToLevel();
    }


LuoPisteLaskuri-aliohjelmassa ensimmäinen parametri oli että mihin peliin laskurit listään ja seuraavaksi oli x-koordinaatti sekä y-koordinaatti. Näytölle lisättävien olioiden paikka ilmoitetaan ruudun, ei kentän, koordinaateissa.

Laskureiden x-koordinaatit on laskettu käyttäen hyväksi ruudun vasemman ja oikean reunan x-koordinaatteja (Screen.Left ja Screen.Right).

Y-koordinaatti lasketaan ruudun yläreunasta (Screen.Top) lukien.

Kun nyt ajat ohjelman, pitäisi ruudun yläreunassa näkyä kaksi laskuria, jotka näyttävät arvoa 0.

Törmäyksen käsittely

Jotta voisimme kasvattaa pisteitä, täytyisi tietää milloin pallo ohittaa jommankumman mailan. Tämä onnistuu siten, että tarkkaillaan sitä kun pallo osuu kentän vasempaan tai oikeaan reunaan.

Törmäyksiin reagoimista varten Jypeli-kirjastossa on aliohjelma nimeltä AddCollisionHandler. Aliohejlmasta on useita versiota, mutta käytetään versiota joka ottaa kolme parametria

  • fysiikkaolio, jonka törmäyksiä kuunnellaan
  • fysiikkaolio, jonka kanssa törmäyksiä kuunnellaan
  • aliohjelma, jota kutsutaan kun olio törmää johonkin

Voitaisiin lisätä seuraavanlainen kutsu LuoKentta-aliohjelmaan, sen jälkeen kun pallo on luotu ja lisätty tasoon:

        AddCollisionHandler(pallo, oikeaReuna, KasvataPelaajan1Pisteita);

Aliohjelman, jossa törmäys käsitellään, täytyy olla aina seuraavanlainen:

  • Paluuarvo on void (eli ei palauteta mitään).
  • Parametreina on kaksi PhysicsObject-luokan oliota.
    • Ensimmäinen parametri on se olio jonka törmäyksiä kuunnellaan, eli törmääjä (meillä se on siis pallo).
    • Toinen parametri on törmäyksen kohde, jota ei vielä tunneta.

Aliohjelman voi toki nimetä vapaasti, kunhan sama nimi annetaan parametrina AddCollisionHandler-kutsussa. Huomaa että tässä ei kutsuta aliohjelmaa KasvataPelaajan1Pisteita, vaan kerrotaan,että sitten kun törmäys tapahtuu, kutsutaan sitä.

Aliohjelman KasvataPelaajan1Pisteita olisi:

    public static void KasvataPelaajan1Pisteita(PhysicsObject pallo, PhysicsObject reuna)
    {
        pelaajan1Pisteet.Value += 1;
    }

Merkintä += tarkoittaa, että merkinnän vasemmalla puolella olevaan arvoon lisätään se mitä on merkinnän oikealla puolella.

Nyt tulisikin ongelmaksi että miten aliohjelmalla saataisiin pelaajan1Pisteet?

Tässä tulee avuksi C#-kielen Lambda-funktiot, joissa aliohjelman suoritus voidaan kirjoittaa jo kutsun yhteyteen. Eli nyt ei erikseen tarvitse tehdä aliohjelmaa KasvataPelaajan1Pisteita, vaan se toteutus on siinä, missä aliohjelma muuten esiintyisi.

Muutetaan metodia LuoKentta niin, että sille tuodaan parametriksi pallo, jotta siihen päästään käsiksi. Samalla itse asiassa kannatta nimetä metodi uudelleen, koska sille rupeaa tulemaan jo muitakin vastuita:

    private void LuoKentta(PhysicsObject pallo)
    private void LuoKenttaJaAsetaTormaykset(PhysicsObject pallo)
    {
        Level.CreateBorders(1.0, false);
        Level.Background.Color = Color.Black;
        
        IntMeter pelaajan1Pisteet = LuoPisteLaskuri(this, Screen.Left + 100.0, Screen.Top - 100.0);
        IntMeter pelaajan2Pisteet = LuoPisteLaskuri(this, Screen.Right - 100.0, Screen.Top - 100.0);
        
        AddCollisionHandler(pallo, oikeaReuna, (_, _) => pelaajan1Pisteet.Value += 1);
        AddCollisionHandler(pallo, vasenReuna, (_, _) => pelaajan2Pisteet.Value += 1);
        
        Camera.ZoomToLevel();
    }


Lambda-funktion selityksiä

  • (_, _) funktiolle olisi tulossa 2 parametria, mutta niitä ei käytetä itse toteutuksessa, joten nimetään ne alleviivalla kumpikin. Tuo voisi olla myös muodossa

      (p, reuna) => pelaajan1Pisteet.Value += 1

    mutta silloin ehkä valitettaisiin ettei muuttujia p ja reuna käytetä missään.

  • => parametrien jälkeen on itse funktion toteutus, eli nyt pisteiden kasvatus. Jossakin toisessa pelissä tässä voisi olla mitä tahansa muitakin lauseita, joita pitää tehdä törmäyksen sattuessa.

Lambda-funktioista voit lukea lisää Ohj1-kurssin luentomonisteesta.

Huomaa että tässä vaiheessa ei vielä kannata huolestua Lambda-funktioista enempää. Riittää että ymmärtää noiden koodien ansiosta pisteiden lisääntyvän.

Vaihdetaan myös Begin-metodissa kutsun nimi ja lisätään siihen parametriksi pallo:

        LuoKentta(pallo);
        LuoKenttaJaAsetaTormaykset(pallo);


Jotta kentän reunat saadaan erikseen käsiteltyä, muutetaan koodia niin, että luodaan kenttään reunat yksitellen eikä kaikkia kerralla.

Pyyhi LuoKentta-aliohjelmasta seuraava rivi:

        Level.CreateBorders(1.0, false);

Tilalle kirjoita sen tilalle ensin vasemman reunan luonti tähän tapaan:

        PhysicsObject vasenReuna = Level.CreateLeftBorder();
        vasenReuna.KineticFriction = 0.0;
        vasenReuna.Restitution = 1.0;
        vasenReuna.IsVisible = false;

Tee vasemman reunan luonnin perään samalla tavalla

  • oikeaReuna (CreateRightBorder),
  • alaReuna (CreateBottomBorder) ja
  • ylareuna (CreateTopBorder).

Nyt LuoKenttaJaAsetaTormaykset pitäisi olla seuraavan näköinen:

    private void LuoKentta(PhysicsObject pallo)
    {
        Level.Background.Color = Color.Black;

        PhysicsObject vasenReuna = Level.CreateLeftBorder();
        vasenReuna.Restitution = 1.0;
        vasenReuna.KineticFriction = 0.0;
        vasenReuna.IsVisible = false;

        PhysicsObject oikeaReuna = Level.CreateRightBorder();
        oikeaReuna.Restitution = 1.0;
        oikeaReuna.KineticFriction = 0.0;
        oikeaReuna.IsVisible = false;

        PhysicsObject ylaReuna = Level.CreateTopBorder();
        ylaReuna.Restitution = 1.0;
        ylaReuna.KineticFriction = 0.0;
        ylaReuna.IsVisible = false;

        PhysicsObject alaReuna = Level.CreateBottomBorder();
        alaReuna.Restitution = 1.0;
        alaReuna.KineticFriction = 0.0;
        alaReuna.IsVisible = false;
        
        IntMeter pelaajan1Pisteet = LuoPisteLaskuri(this, Screen.Left + 100.0, Screen.Top - 100.0);
        IntMeter pelaajan2Pisteet = LuoPisteLaskuri(this, Screen.Right - 100.0, Screen.Top - 100.0);
        
        AddCollisionHandler(pallo, oikeaReuna, (_, _) => pelaajan1Pisteet.Value += 1);
        AddCollisionHandler(pallo, vasenReuna, (_, _) => pelaajan2Pisteet.Value += 1);
        
        
        Camera.ZoomToLevel();
    }

Hienosäätöä

Pallo ei ehkä nyt käyttäydy aivan toivomallamme tavalla. Voit yrittää hienosäätää pelin pelattavuutta esimerkiksi pallon ominaisuuksia muuttamalla. Tutki mitä tekevät sen KineticFriction ja CanRotate ominaisuudet ja kokeile muuttaa niitä. Vaikuttaako pelikokemukseen? Jos pallo tuntuu joskus jäävän jumiin, kokeile esimerkiksi luoda resetointinäppäin jota painamalla pallon sijainti asetetaan pelialueen keskelle, jonka jälkeen pallolle annetaan jokin nopeus.

Lopputulos

Pong-pelin koodi kaikkine mailoineen ja pistelaskuineen näyttää nyt suunnilleen tältä:

using Jypeli;

namespace Pong;

/// @author vesal
/// @version 20.09.2024
/// <summary>
/// Peli jossa kaksi palaajaa yrittää saada pallon toisen päätyyn. 
/// </summary>
public class Pong : PhysicsGame
{
    private readonly Vector nopeusYlos = new Vector(0, 200);
    private readonly Vector nopeusAlas = new Vector(0, -200);

    public override void Begin()
    {
        PhysicsObject pallo = LuoPallo(this, -200, 0);
        PhysicsObject maila1 = LuoMaila(this,Level.Left + 20.0, 0.0);
        PhysicsObject maila2 = LuoMaila(this,Level.Right - 20.0, 0.0);
        
        LuoKentta(pallo);

        AsetaOhjaimet(maila1, maila2);
        AloitaPeli(pallo);
    }
    
    
    private void LuoKentta(PhysicsObject pallo)
    {
        Level.Background.Color = Color.Black;

        PhysicsObject vasenReuna = Level.CreateLeftBorder();
        vasenReuna.Restitution = 1.0;
        vasenReuna.KineticFriction = 0.0;
        vasenReuna.IsVisible = false;

        PhysicsObject oikeaReuna = Level.CreateRightBorder();
        oikeaReuna.Restitution = 1.0;
        oikeaReuna.KineticFriction = 0.0;
        oikeaReuna.IsVisible = false;

        PhysicsObject ylaReuna = Level.CreateTopBorder();
        ylaReuna.Restitution = 1.0;
        ylaReuna.KineticFriction = 0.0;
        ylaReuna.IsVisible = false;

        PhysicsObject alaReuna = Level.CreateBottomBorder();
        alaReuna.Restitution = 1.0;
        alaReuna.KineticFriction = 0.0;
        alaReuna.IsVisible = false;
        
        IntMeter pelaajan1Pisteet = LuoPisteLaskuri(this, Screen.Left + 100.0, Screen.Top - 100.0);
        IntMeter pelaajan2Pisteet = LuoPisteLaskuri(this, Screen.Right - 100.0, Screen.Top - 100.0);
        
        AddCollisionHandler(pallo, oikeaReuna, (_, _) => pelaajan1Pisteet.Value += 1);
        AddCollisionHandler(pallo, vasenReuna, (_, _) => pelaajan2Pisteet.Value += 1);
        
        Camera.ZoomToLevel();
    }
    
    
    public static PhysicsObject LuoPallo(PhysicsGame peli, double x, double y)
    {
        PhysicsObject pallo = new PhysicsObject(40.0, 40.0, Shape.Circle);
        pallo.X = x;
        pallo.Y = y;
        pallo.Restitution = 1.0;
        pallo.KineticFriction = 0.0;
        pallo.MomentOfInertia = double.PositiveInfinity;
        peli.Add(pallo);
        return pallo;
    }

    
    public static PhysicsObject LuoMaila(PhysicsGame peli, double x, double y)
    {
        PhysicsObject maila = PhysicsObject.CreateStaticObject(20.0, 100.0, Shape.Rectangle);
        maila.X = x;
        maila.Y = y;
        maila.Restitution = 1.0;
        peli.Add(maila);
        return maila;
    }    
    
    
    public static IntMeter LuoPisteLaskuri(PhysicsGame peli,  double x, double y)
    {
        IntMeter laskuri = new IntMeter(0);
        laskuri.MaxValue = 10;

        Label naytto = new Label();
        naytto.X = x;
        naytto.Y = y;
        naytto.TextColor = Color.White;
        naytto.BorderColor = peli.Level.BackgroundColor;
        naytto.Color = peli.Level.BackgroundColor;
        
        naytto.BindTo(laskuri);
        peli.Add(naytto);

        return laskuri;
    }

    private static void AloitaPeli(PhysicsObject pallo)
    {
        Vector impulssi = new Vector(500.0, 0.0);
        pallo.Hit(impulssi * pallo.Mass);
    }
    
    
    private void AsetaOhjaimet(PhysicsObject maila1, PhysicsObject maila2)
    {
        Keyboard.Listen(Key.A, ButtonState.Down, AsetaNopeus, "Pelaaja 1: Liikuta mailaa ylös", maila1, nopeusYlos );
        Keyboard.Listen(Key.A, ButtonState.Released, AsetaNopeus, null, maila1, Vector.Zero );
        Keyboard.Listen(Key.Z, ButtonState.Down, AsetaNopeus, "Pelaaja 1: Liikuta mailaa alas", maila1, nopeusAlas );
        Keyboard.Listen(Key.Z, ButtonState.Released, AsetaNopeus, null, maila1, Vector.Zero );

        Keyboard.Listen(Key.Up, ButtonState.Down, AsetaNopeus, "Pelaaja 2: Liikuta mailaa ylös", maila2, nopeusYlos );
        Keyboard.Listen(Key.Up, ButtonState.Released, AsetaNopeus, null, maila2, Vector.Zero );
        Keyboard.Listen(Key.Down, ButtonState.Down, AsetaNopeus, "Pelaaja 2: Liikuta mailaa alas", maila2, nopeusAlas );
        Keyboard.Listen(Key.Down, ButtonState.Released, AsetaNopeus, null, maila2, Vector.Zero );

        Keyboard.Listen(Key.F1, ButtonState.Pressed, ShowControlHelp, "Näytä ohjeet" );
        Keyboard.Listen(Key.Escape, ButtonState.Pressed, ConfirmExit, "Lopeta peli" );

        ControllerOne.Listen(Button.DPadUp, ButtonState.Down, AsetaNopeus, "Liikuta mailaa ylös", maila1, nopeusYlos );
        ControllerOne.Listen(Button.DPadUp, ButtonState.Released, AsetaNopeus, null, maila1, Vector.Zero );
        ControllerOne.Listen(Button.DPadDown, ButtonState.Down, AsetaNopeus, "Liikuta mailaa alas", maila1, nopeusAlas );
        ControllerOne.Listen(Button.DPadDown, ButtonState.Released, AsetaNopeus, null, maila1, Vector.Zero );

        ControllerTwo.Listen(Button.DPadUp, ButtonState.Down, AsetaNopeus, "Liikuta mailaa ylös", maila2, nopeusYlos );
        ControllerTwo.Listen(Button.DPadUp, ButtonState.Released, AsetaNopeus, null, maila2, Vector.Zero );
        ControllerTwo.Listen(Button.DPadDown, ButtonState.Down, AsetaNopeus, "Liikuta mailaa alas", maila2, nopeusAlas );
        ControllerTwo.Listen(Button.DPadDown, ButtonState.Released, AsetaNopeus, null, maila2, Vector.Zero );

        ControllerOne.Listen(Button.Back, ButtonState.Pressed, ConfirmExit, "Lopeta peli" );
        ControllerTwo.Listen(Button.Back, ButtonState.Pressed, ConfirmExit, "Lopeta peli" );
    }    
    
    
    private void AsetaNopeus(PhysicsObject maila, Vector nopeus)
    {
        if ((nopeus.Y < 0) && (maila.Bottom < Level.Bottom))
        {
            maila.Velocity = Vector.Zero;
            return;
        }
        if ((nopeus.Y > 0) && (maila.Top > Level.Top))
        {
            maila.Velocity = Vector.Zero;
            return;
        }

        maila.Velocity = nopeus;
    }
}

Lisää hienosäätöä

Voit toki lopettaa kehittämisen tähän. Mutta jos sinua on yhtään kiusannut jäljelle jäänyt turha toisto, niin voit vielä vähän jatkaa.

Itse asiassa reunojen luomisessa on nyt paljon toistoa. Voisimme taas yrittää taas ottaa aliohjelmia apuun. Ongelmaksi tulee, että miten tehdä aliohjelma, joka osaa luoda erityyppisiä reunoja. Avuksi tulee tässä C#-kielen mahdollisuus viedä toimintoja parametrina.

Muutetaan LuoKentta-metodia niin, että se kutsuu funktiota LuoReuna. LuoReuna-funktiolle viedään parametrina millä kutsulla (toiminnolla) itse reuna pitää luoda.

Muuten muutos on aika suoraviivainen, mutta pitää ensin esitellä uusi tyyppi tuollaiselle fysiikkaolion luovalle toiminnolle. Tämä tehdään delegate-lauseella

    public delegate PhysicsObject Luontifunktio();

Nyt kun uusi tyyppi on esitelty, voidaankin tehdä tuo funktio LuoReuna. Itse asiassa sillä voisi olla yleisempikin nimi, koska sillä voitaisiin luoda mitä tahansa fysiikkaolioita kunhan parametriksi tuotaisiin tieto siitä, miten luonti tehdään. Muuttujien alaReuna ja ylaReuna esittelyt on jätetty pois kun niitä ei tässä ohjelmasa tarvita. Toki reunat silti luodaan.

    private void LuoKentta(PhysicsObject pallo)
    {
        Level.Background.Color = Color.Black;

        PhysicsObject vasenReuna = LuoReuna(Level.CreateLeftBorder);
        PhysicsObject oikeaReuna = LuoReuna(Level.CreateRightBorder);
        LuoReuna(Level.CreateTopBorder);
        LuoReuna(Level.CreateBottomBorder);
        
        IntMeter pelaajan1Pisteet = LuoPisteLaskuri(this, Screen.Left + 100.0, Screen.Top - 100.0);
        IntMeter pelaajan2Pisteet = LuoPisteLaskuri(this, Screen.Right - 100.0, Screen.Top - 100.0);
        
        AddCollisionHandler(pallo, oikeaReuna, (_, _) => pelaajan1Pisteet.Value += 1);
        AddCollisionHandler(pallo, vasenReuna, (_, _) => pelaajan2Pisteet.Value += 1);
        
        Camera.ZoomToLevel();
    }


    public delegate PhysicsObject Luontifunktio();
    
    
    public static PhysicsObject LuoReuna(Luontifunktio luontifuktio)
    {
        PhysicsObject reuna = luontifuktio();
        reuna.Restitution = 1.0;
        reuna.KineticFriction = 0.0;
        reuna.IsVisible = false;
        return reuna;
    }

Mikäli nysväämistä vielä jatkettaisiin, voisi harmittaa toisto ohjainten asettamisessa. Tässä voitaisiin jälleen ottaa aliohjelmista apua:

    private void AsetaOhjaimet(PhysicsObject maila1, PhysicsObject maila2)
    {
        AsetaOhjain(Key.A, Key.Z, ControllerOne, 1, maila1);
        AsetaOhjain(Key.Up, Key.Down, ControllerTwo, 2, maila2);
        
        Keyboard.Listen(Key.F1, ButtonState.Pressed, ShowControlHelp, "Näytä ohjeet" );
        Keyboard.Listen(Key.Escape, ButtonState.Pressed, ConfirmExit, "Lopeta peli" );
    }


    private void AsetaOhjain(Key keyYlos, Key keyAlas, GamePad controller, int pelaaja, PhysicsObject maila)
    {
        Keyboard.Listen(keyYlos, ButtonState.Down, AsetaNopeus, $"Pelaaja {pelaaja}: Liikuta mailaa ylös", maila, nopeusYlos );
        Keyboard.Listen(keyYlos, ButtonState.Released, AsetaNopeus, null, maila, Vector.Zero );
        Keyboard.Listen(keyAlas, ButtonState.Down, AsetaNopeus, $"Pelaaja {pelaaja}: Liikuta mailaa alas", maila, nopeusAlas );
        Keyboard.Listen(keyAlas, ButtonState.Released, AsetaNopeus, null, maila, Vector.Zero );
        
        controller.Listen(Button.DPadUp, ButtonState.Down, AsetaNopeus, "Liikuta mailaa ylös", maila, nopeusYlos );
        controller.Listen(Button.DPadUp, ButtonState.Released, AsetaNopeus, null, maila, Vector.Zero );
        controller.Listen(Button.DPadDown, ButtonState.Down, AsetaNopeus, "Liikuta mailaa alas", maila, nopeusAlas );
        controller.Listen(Button.DPadDown, ButtonState.Released, AsetaNopeus, null, maila, Vector.Zero );

        controller.Listen(Button.Back, ButtonState.Pressed, ConfirmExit, "Lopeta peli" );
    }

Nyt voimme lopettaa tähän tämän pelin kanssa. Toki voisimme vielä lisätä pelin päättymisen kun 10 pistettä tulee jommalle kummalle täyteen jne.

These are the current permissions for this document; please modify if needed. You can always modify these permissions from the manage page.