Göm menyn

Labb 5: För högre projektbetyg

För att få betyg 4 eller 5 på kursen behöver du visa upp både bredare och djupare kunskaper inom Java och objektorienterad programmering.

Detta kan man till stor del visa upp i projektet, oavsett vilket projekt man väljer. Men det finns också vissa specifika kunskaper som alla behöver demonstrera för högre betyg, men som inte nödvändigtvis har en naturlig plats i alla projekttyper – särskilt när man får välja helt fritt. Det kan ju hända att projektet inte har något större behov av t.ex. felhantering, filhantering och ett par väl valda designmönster.

För att detta inte ska leda till problem har vi istället infört ett antal uppgifter i Tetris som är specifika för kursbetyg 4 respektive 5. De uppgifterna är upplagda så att man kan demonstrera kunskaperna på ett mer naturligt sätt utan att behöva "tvinga" in dem i ett projekt där de egentligen inte passar. Att dessa uppgifter är individuella hjälper oss också att göra en individuell bedömning av kursbetyget, vilket vi måste göra enligt lagar och regler.

Om du vill satsa på ett högre betyg, eller helt enkelt vill få en grundligare genomgång av Java innan projektet, genomför du dessa uppgifter. Annars kan du gå direkt vidare till inlämningen.

Uppgifterna på denna sida räknas till momentet PRA1 eller PRA3 i LADOK (beroende på kurskod). Detta är det enda moment som har ett 3-4-5-betyg. Ofta är vi lite slarviga och kallar helt enkelt momentet för "projektet", men i själva verket består alltså PRA1/PRA3 av både projektet och labb 5, medan momentet LAB1/LAB3 består av labb 1-4.

För dessa högre betyg kan man ibland behöva leta mer information på egen hand!

För dig som vill ha betyg 4 eller 5 på PRA och kursen

[Betyg 4, efter GUI] Tetris 5.1: Mer GUI: Menyer!

Det är dags att vidareutveckla resten av det grafiska gränssnittet för spelet en liten aning.

Uppgift: Menyer

  1. Lägg till menyer i spelet enligt vanliga lösningar. Se minst till att det finns en meny med valet "Avsluta". Lägg till fler val efter behov och önskemål.

  2. Se till att "Avsluta" gör att en dialogruta visas med hjälp av JOptionPane, där man får bekräfta att man vill sluta. Se vanliga lösningar. Vid bekräftelse ska spelet avslutas via System.exit(0).

[Betyg 4, efter GUI] Tetris 5.2: Resurshantering

Korrekt hantering av resurser (bilder och andra datafiler som följer med i ett program) är viktigt för att även andra än författaren ska kunna köra ett program. Det är till exempel inte ovanligt att projekt i denna kurs innehåller hårdkodade sökvägar till bilder som ligger på författarens hårddisk, så att assistenter inte kan testa den inlämnade koden. Därför gör vi nu ett snabbt test av användning av Javas inbyggda stöd för resurshantering. Detta låter programmet hitta sina egna filer, utan att veta var det är installerat eller om det till och med ligger inuti en JAR-fil.

Uppgift: Resurshantering

  1. Du har fått ett IDEA-projekt som redan innehåller "resurskataloger" – resources/audio och resources/images. Filerna i de här katalogerna är inte källkod som ska kompileras, men i IDEA:s projektdefinition är de markerade som resurser som ska inkluderas i det kompilerade resultatet så att de kan användas i det slutliga programmet.

    Lägg in en bildfil i resources/images, från kursen eller var som helst, och addera den till Git. Du kan lägga den direkt i images eller i en underkatalog. Ta gärna hänsyn till vad andra kan tycka är trevligt att se...

    VSCode: Oklart hur man åstadkommer detta. Här kanske du får använda IDEA.

  2. Skapa en ny GUI-klass som kan visa en "startbild" för ditt Tetris-program. Klassen ska läsa in startbilden med hjälp av ImageIcon och ClassLoader.getSystemResource() så som diskuteras under andra delen av GUI-föreläsningen. Klassen ska sedan visa bilden på något sätt, lämpligen i ett par sekunder, och sedan stängas. Att det blir snyggt är inte viktigt, eftersom vi fokuserar på just resurshanteringen.

    Ett delexempel på detta finns i klassen ResourceTester som följer med i ditt projekt. Där ser du också att om du har lagt filen foo.png direkt i resources/images ska du ange namnet images/foo.png.

  3. Använd den nya GUI-klassen i ditt Tetris-spel så att bilden visas en kort tid innan spelet startar.

[Betyg 4, efter GUI] Tetris 5.3: Poänghantering

Om poänghantering

Nu är det dags att lägga till poänghantering i spelet. Vi kan använda en enkel poängsättning där man får:

  • 100 poäng om 1 rad försvinner
  • 300 poäng om 2 rader försvinner på samma gång
  • 500 poäng om 3 rader försvinner på samma gång
  • 800 poäng om 4 rader försvinner på samma gång

Detta är en förenkling av ett vanligt poängsystem som även tar hänsyn till olika spelnivåer (hastigheter) och andra finesser som vi inte har implementerat.

Uppgift: Poänghantering

Inför poänghantering i spelet enligt poängsättningen ovan.

Tips: Det kan vara frestande att lägga in informationen om poäng som en stor switch-sats... men då blir den hårdkodad. Det är alltid bättre att låta information vara information och hålla den borta från själva koden. Ett sätt att göra det är att skapa en Map<Integer,Integer> som håller reda på antalet poäng man får för ett visst antal rader. Detta kan göras med ... pointMap = Map.of(1, 100, 2, 300, ...) som kan lagras i objektet eller statiskt. När man sedan vill veta poängen gör man pointMap.get(numberOfLines).

Varför är det bättre? Ja, till exempel är det mycket lättare att läsa in den här informationen från fil, eller att modifiera den dynamiskt. Det ändrar bara på innehållet i en Map. Om allt vore hårdkodat i en switch skulle man behöva skriva om koden och kompilera den för att få nya värden...

  1. Bestäm en lämplig plats / klass där nuvarande poäng kan lagras.

  2. Se till att poängen uppdateras enligt ovan.

  3. Se till att nuvarande poäng hela tiden visas (grafiskt) någonstans i spelet.

Om highscorelistor

Nu fungerar poängen, men man kan bara se dem medan man spelar. Det vore bra om man kunde lagra poängen så att man efter varje spelomgång kan se en highscorelista.

Hur ska rätt del av koden komma åt listan, om vi gång på gång skapar ett nytt Board och detta leder till en ny spelomgång? För att implementera det på rätt sätt får vi tänka på vilka egenskaper vi vill ha, eller i alla fall vilka vi vill förbereda för.

  • Vi vill använda oss av bra objektorientering. En highscorelista ska därmed vara ett objekt, en instans av en klass, och informationen ska lagras i objektets vanliga, icke-statiska fält.

  • Vi vill förbereda för att man vill kunna spela många parallella spel (kanske över nätet), och att man i flera av dessa samtidiga spelomgångar vill kunna använda sig av samma gemensamma highscorelista.

  • Samtidigt kanske vi i framtiden vill separera det hela så att vi har olika highscorelistor i olika grupper eller spelligor.

Då blir nog det bästa att vi har en HighscoreList-klass med den funktionaliteten vi behöver, att vi centralt (i uppstarten) ser till att bara skapa ett enda objekt av den klassen, och att vi på sedvanligt sätt skickar vidare detta unika objekt till alla som behöver tillgång till det.

Att komma åt en highscorelista – sätt att undvika

Det finns flera alternativ som vi av olika anledningar förkastar:

  • Om man inte ville ha stöd för att ha flera olika highscorelistor kunde man rent tekniskt ha lagrat all information om highscores i statiska variabler i någon klass. "Det finns ju bara en lista!"

    Men att lagra sådan information i klassen istället för i enskilda objekt följer inte objektorienteringens principer utan blir snarare procedurell programmering. Det följer inte heller allmänna programmeringsprinciper om att undvika globala variabler och globala tillstånd.

  • Med hjälp av ett designmönster som heter Singleton kunde man ha lagrat informationen i ett objekt, men sluppit att skicka vidare objektet till alla som behövde det. Singleton gör det möjligt för vem som helst att hitta det globala, unika HigscoreList-objektet.

    Detta bryter fortfarande om principerna om att undvika globala variabler och tillstånd. Det leder också till svårigheter att följa hur informationen egentligen flödar genom ett program, eftersom globalt tillgängliga objekt "kortsluter" informationsflödet, vilket i sin tur kan göra det svårare att skapa en bra uppdelning av programmet i tydliga ansvarsområden.

    Singleton kallas därför ibland ett antimönster som man inte bör följa. Det främsta undantaget är när man verkligen inte kan veta vem som kan behöva få tillgång till ett visst unikt objekt och skicka med det dit. Att veta detta är inget problem i Tetris, och inte heller i 99% av alla kursprojekt.

Uppgift: Highscorelista

  1. Vi ska inte lagra highscorelistan i en fil förrän i nästa uppgift. För att highscorelistan ändå ska fungera och vara meningsfull måste man kunna fortsätta spela en ny omgång när ett spel är över. Man kan t.ex. skapa ett nytt Board och en ny GUI-komponent som visar denna. Eftersom GUI-programmering inte är ett fokus kan man till och med (för enkelhetens skull) skapa ett helt nytt fönster som visar upp det nya spelet (och helst ta bort det gamla genom att anropa dess dispose()-metod). Se till att detta fungerar.

  2. Skapa en Highscore-klass som lagrar antal poäng plus namn på den som fick poängen.

  3. Skapa en HighscoreList-klass. Den ska innehålla funktionalitet för att lagra highscores, lägga till highscores samt få fram samtliga highscores som finns i listan.

  4. När man kör igång spelet ska det skapa en enda HighscoreList en gång för alla. Detta bör inte ske i Board, utan den kan t.ex. vara den som skapar Board som också skapar listan. Annars skulle ju highscores "nollställas" varje gång man skapar ett nytt Board som har en egen ny HighscoreList!

  5. Så snart en spelomgång avslutas ska programmet fråga användaren efter ett namn. Ett Highscore-objekt med rätt namn och poäng ska skapas och läggas till i highscorelistan. För tillfället behöver listan inte sorteras i rätt ordning.

  6. Därefter ska programmet visa åtminstone de 10 första personerna i highscorelistan, och vänta på en knapptryckning eller liknande innan nästa spel börjar. Listan kan t.ex. visas med drawString(), som en sträng i en textkomponent, eller som en sträng i en dialogruta. Vi fokuserar inte på hur snygg visningen är.

Om sorterade highscores

Nu kan vi se highscorelistor, men de hamnar i godtycklig ordning. Vi vill se den högsta poängen först!

För att åstadkomma detta behöver vi kunna sortera listorna. Den större delen av en sorteringsalgoritm brukar vara generell och fungera för godtyckliga sorters element, och den delen finns så klart "inbyggd" i Java. Men sortering bygger oftast på att man kan jämföra två godtyckliga element i en lista och tala om vilket av dem som borde vara först, och just denna del är helt och hållet specifik för varje elementtyp. I vårt fall handlar det alltså om att tala om för sorteringsalgoritmen hur man tar reda på vilket av två Highscore-objekt som ska vara först i den sorterade listan.

Det finns flera olika sätt att göra detta på. Ett sätt är genom designmönstret Strategy. Detta låter oss "plugga in" jämförelser genom att först skapa en jämförare, ett objekt som vet hur man jämför Highscore-objekt, och därefter skicka med denna jämförare som parameter till sorteringsmetoden. Jämförarobjektet är alltså ett sätt att implementera en sorteringsstrategi som sorteraren kan använda sig av.

Vilken typ ska "jämförar-parametern" till sorteringsmetoden ha? I Javas standardsortering används gränssnittet Comparator. Detta är ett "generiskt" gränssnitt, som också talar om vilken typ <T> man kan jämföra ett objekt med. Vi diskuterar detta i detalj under föreläsningen om datatyper. Har du inte läst om detta än kan du ändå följa med i instruktionerna.

public interface Comparator<T> {

    /**
      Compares its two arguments for order. 
      Returns a negative integer, zero, or a positive integer 
      as the first argument is less than, equal to, or greater than
      the second.
    */
    public int compareTo(T o1, T o2);

Detta gränssnitt finns redan i Java. Vi behöver nu implementera det i en poängjämförare. Typvariabeln T får alltså värdet Highscore denna gång:

public class ScoreComparator implements Comparator<Highscore> {

    public int compare(Highscore o1, Highscore o2) {
        ...
    }
}

Vi kan sedan sortera listan:

List<Highscore> scores = ...;
...

scores.sort(new ScoreComparator());

Uppgift: Sorterade highscores

  1. Skapa en jämförarklass, en ScoreComparator, enligt ovan.

  2. Använd denna för att se till att highscores sorteras (antingen vid visning eller varje gång en highscore läggs till i listan).

  3. Testa!

[Betyg 4, efter GUI/exceptions] Tetris 5.4: Spara på fil

OBS: I slutet av detta steg använder vi oss till viss del av exceptionhantering, som kommer i en föreläsning efter GUI. Det går ändå bra att börja på en gång.

Nu ska vi lagra highscores i en fil, och även läsa tillbaka dem – men först måste vi diskutera vilket format man kan använda för att lagra den informationen.

Om format för datalagring

Att läsa och spara information på fil kan vara viktigt i många program. I denna uppgift kommer vi att testa en enkel variant av detta där vi lagrar highscorelistan i en fil.

Varje highscore har i sig ett ganska enkelt format: Ett namn (sträng) och ett poängvärde (heltal). För sådana objekt kan det vara frestande att själv hitta på ett enkelt format, t.ex. en textfil där varje rad har poäng mellanslag namn som fyller resten av raden:

      10000 MittNamn
      4000 Jag är bäst
      3000 Hej på dej 

Men det händer ofta att man kommer på mer att lägga till senare, och då kan det bli krångligt att hålla reda på olika versioner av filformat. Borde vi ha lagt namnet inom citattecken? Om vi har mer info än bara highscores, hur separerar vi listan från resten av informationen? Och så vidare. Det kan vara bättre att direkt gå till en mer strukturerad informationshantering där man använder sig av ett färdigt markup-format.

Det finns många sådana och just nu kommer vi att prova JSON, JavaScript Object Notation. Trots namnet är JavaScript egentligen inte direkt relaterat till Java, men JSON är numera vanligt oavsett vilket språk man använder. Här syns en typisk JSON-fil:

{
    "firstName": "John",
    "lastName": "Smith",
    "age": 25,
    "address": {
        "streetAddress": "21 2nd Street",
        "city": "New York",
        "state": "NY",
        "postalCode": 10021
    },
    "phoneNumbers": [
        {
            "type": "home",
            "number": "212 555-1234"
        },
        {
            "type": "fax",
            "number": "646 555-4567" 
        }
    ] 
}

Värden inom {} är mappningar, som Pythons dict och Javas Map, och värden inom [] är listor, som Pythons list och Javas Map. Vi kommer alltså att kunna skapa en highscorelista som ser ut ungefär så här:

[
  {
    "points": 10000,
    "name": "Mittnamn"
  },
  {
    "points": 4000,
    "name": "Jag är bäst"
  }
  {
    "points": 3000,
    "name": "Hej på dej"
  }
]

Om JSON-format för HighscoreList

Konverteringen från HighscoreList-objekt till JSON-format kan så klart göras genom att "manuellt" skriva ut strängar på det här formatet, men i denna uppgift ska vi istället ta hjälp av ett klassbibliotek för JSON. Det bibliotek vi rekommenderar heter Gson och är skrivet av Google. Det har vissa begränsningar när det gäller mer komplexa datastrukturer, men fungerar utmärkt för bland annat:

  • Primitiva datatyper och strängar

  • Enkla datastrukturklasser som Highscore, med ett antal fält som är primitiva datatyper eller strängar

  • Arrayer och arraylistor av specifika konkreta typer (till exempel ArrayList<HighScore>), med mera

Då kan vi "konvertera" enkla eller sammansatta objekt till JSON på följande sätt:

Gson gson = new Gson();
String listAsJson = gson.toJson(myHighscoreList);

Vill man ha ett snyggare JSON-format som vi människor lättare kan läsa, med radbrytningar och indentering, gör man så här:

Gson gson = new GsonBuilder().setPrettyPrinting().create();
String listAsJson = gson.toJson(myHighscoreList);

Sedan kan vi t.ex. skriva listAsJson till en fil.

För att sedan läsa in dessa objekt igen behöver "dataklasserna" som används ha en konstruktor utan argument. Du kommer alltså att behöva se till att konstruktorerna HighScore() och HighscoreList() existerar. Då kan Gson vid inläsningen börja med att skapa t.ex. ett "tomt" HighScore-objekt och sedan fylla i information för dess fält.

Anta nu att vi på något sätt har fått tag på strängen listAsJson som innehåller en JSON-representation enligt ovan, t.ex. genom att läsa in den från en fil. Då kan vi få fram motsvarande Javaobjekt på följande sätt:

Gson gson = new Gson();
HighscoreList list = gson.fromJson(listAsJson, HighscoreList.class)

Den sista raden kan se lite underlig ut. Varför anger man HighscoreList.class?

Jo, när Gson skapar sin textrepresentation av ett objekt tar den inte med någon information om vilken klass objektet hade. I exemplet på JSON-fil ovan stod det t.ex. aldrig att objektet var av typen HighscoreList; det syntes bara att det var "någon sorts lista". Genom att vi anger HighscoreList.class vet Gson vilken typ av data den ska förvänta sig som startpunkt. Sedan analyserar den själv klassen HighscoreList för att se vilka fält den har och vilka typer de har, och så vidare.

Överkurs
Hur kan den analysera det? Java stödjer så kallad reflection, som låter ett program inspektera sin egen struktur -- vilka klasser som finns, vilka metoder och fält de har, och så vidare.

Uppgift: Spara och läsa in highscores

Första steget blir att skriva highscores till fil efter ett avslutat spel.

  1. Klassbiblioteket Gson ska redan finns i ditt IDEA-projekt.

    VSCode: Under Manage dependencies for unmanaged folder kanske du får hjälp att lägga till den existerande filen libs/gson/gson-2.8.9.jar till din CLASSPATH.

  2. Testa att använda Gson genom att skriva ut (på skärmen) en sträng för en highscorelista efter varje avslutat spel. Fungerar det? Ser det ut som du förväntade dig? Varför / varför inte?

  3. Skriv en metod, kanske i HighscoreList, som sparar nuvarande highscorelista på fil i Gson-format. Spara t.ex. i nuvarande katalogen, eller i hemkatalogen: System.getProperty("user.home").

    Se till att denna metod anropas varje gång highscorelistan ändras. Om du t.ex. gör detta i metoden som lägger till nya highscores (som du kanske har kallat addScore) måste du tänka på att detta ändrar metodens kontrakt: Den kommer nu att betyda att man ska lägga till poäng i minnet och att de ska sparas på fil.

    Filhantering finns inte med på föreläsningarna, men i kursböcker (t.ex. Java Tutorial) och i gamla föreläsningsbilder. Att på egen hand kunna utforska de enklaste grunderna inom detta område är en del av det högre kravet för betyg 4.

    Tips: Använd PrintWriter för att skriva text till en fil, med korrekt konvertering av teckenkodning. Använd try-with-resources för att garantera att denna fil stängs om något går fel (en exception kastas).

    Hur ska man hantera de exceptions som kan uppstå? Just nu är det OK att göra det på godtyckligt sätt, och till och med att ignorera dem, så länge som programmet går att kompilera och fungerar i de fall man inte får exceptions. Felhanteringen ska vi snart titta närmare på.

  4. Testa. Spela ett spel, skriv in en highscore, och titta manuellt på filen så att den faktiskt sparas och ser ut som den ska.

  5. Se till att highscores kan läsas in igen vid uppstart av programmet. Detta kan göras genom att läsa in en sträng som man konverterar enligt exemplet ovan. Det kan också göras genom att ge fromJson() en godtycklig Reader, t.ex. en FileReader, som den själv kan läsa in strängen från (det behöver inte vara en JsonReader).

    Glöm inte att det ska fungera även om highscorelistan inte finns när du startar programmet första gången! Här kan man t.ex. prova att öppna filen, och om FileNotFoundException kastas vet man att filen inte finns och att man alltså behöver skapa en ny lista istället.

    (Det går inte att lägga inläsningskoden i konstruktorn för HighscoreList -- inläsningen skapar själv en ny HighscoreList, så det skulle leda till en oändlig loop.)

  6. Testa. Radera den gamla highscorefilen och testa att programmet fungerar även då. Spela ett spel så att highscorelistan sparas, stäng av, starta upp programmet igen, och testa att gamla highscores läses in och finns kvar.

Överkurs
I uppgiften ovan bestämde vi inte exakt hur man skulle se till att highscores sparas varje gång listan ändras. Ett snyggt sätt att lösa det på kunde vara att införa en HighscoreListener med en metod som anropas varje gång listan ändras, och implementera denna i en klass som får ansvaret för att spara på disk. Detta är dock överkurs med tanke på att vi just nu fokuserar på själva filhanteringen.
Överkurs

Varför använde vi inte Javas inbyggda serialization?

En anledning är att den är ganska "ömtålig": När man ändrar på klasser gäller det att man redan från början har gjort helt rätt för att man fortfarande ska kunna läsa in objekt skrivna med den gamla versionen av en klass.

En annan är att vi gärna vill att människor (eller program skrivna i andra språk) ska kunna läsa filerna, och Javas serialisering använder sig av ett specifikt och relativt komplext binärformat.

En tredje är att serialiseringen i Java har många välkända problem. Se t.ex. What we hate about Java serialization (and what we might do about it) presenterat av bl.a. Brian Goetz, Java Language Architect på Oracle.

[Betyg 4, efter exceptions] Tetris 5.5: Felhantering

I förra uppgiften arbetade vi med läsning och skrivning av filer, ett av de områden där det ofta uppstår fel som signaleras som exceptions. Nu ska vi titta vidare på hur man ska hantera dessa typer av fel.

Om felhantering

De undantag (exceptions) som kan uppstå i Tetris har just nu att göra med filhantering. Om det då till exempel blir problem när man vill spara sina highscores i en fil är det kanske inte så mycket som programmet kan göra åt problemet. Men man måste ändå meddela användaren om det, så användaren inte tror att allt fungerade! Det ska vi göra nu.

Var ska vi då lägga koden som meddelar användaren? En tanke kunde vara att lägga den direkt där man försökte spara filen... men vi har också diskuterat under föreläsningen om felhantering att den kod där felen uppstår inte nödvändigtvis är den som är ansvarig för användargränssnittet. Koden som sparar highscores i en fil borde gå att använda även om användaren spelar Tetris över nätverket och inte finns vid samma dator, och i det fallet vill man ju absolut inte skicka upp en dialogruta på Tetrisservern.

Funktioner på lägre nivå ska inte själva meddela användaren när något gick fel: Då går det inte att återanvända funktionerna i andra situationer, när man vill hantera felen på annat sätt.

Alltså ska spara-funktionen skicka vidare felsignalen (undantaget) uppåt. Den ska inte stoppa förrän:

  • Felsignalen når en del av koden som faktiskt tillhör användargränssnittet. I vårt fall är användargränssnittet ett GUI, och når undantaget en del av GUI-koden kan det vara rimligt att fånga undantaget där och visa ett felmeddelande i en dialogruta.

  • Felsignalen når "toppen" av koden, utan att komma ända till användargränssnittet. Till exempel kanske anropet går i följande steg:

    	  Timer anropar timerhandling, en Runnable
    	  Timerhandling anropar spelbrädets tick()
    	  Spelbrädets tick() upptäcker Game Over,
    	      anropar highscorelistans addScore()
    	  I addScore() anropas saveToDisk()
    	  I saveToDisk() kastas ett undantag
    	

    Inget av dessa steg är strikt sett en del av användargränssnittet -- men ju högre upp man skickar felet innan man tar hand om det, desto större delen av koden är fri från antaganden om vem som ska meddelas om ett fel (en användare som sitter vid datorn).

Uppgift: Felhantering

  1. Se till att de undantag som kan uppstå när man sparar highscores i en fil, eller läser tillbaka dem, inte fångas upp i samma metod. Skicka istället vidare undantagen till anroparen.

    Detta borde göra att programmet inte längre går att kompilera, eftersom anroparen inte tar hand om undantagen. Kontrollera om den ska göra det eller om den också borde skicka vidare undantagen. Fortsätt till du hittar en lämplig plats att fånga undantagen, och se till att de faktiskt fångas där.

  2. När undantagen fångas behöver du hantera dem på något sätt. I detta fall är det rimligast att informera användaren om felet

    Hur ska man informera? Om detta vore ett kommandoradsprogram kunde man informera via en vanlig utskrift, men nu är det ju ett GUI-program och där tittar användaren kanske inte ens i ett terminalfönster. Alltså ska man använda sig av en dialogruta!

    Dialogrutan ska visa ett lämpligt felmeddelande och fråga om man ska försöka igen.

  3. Se till att faktiskt försöka igen, om användaren ber om det (om man t.ex. har ordnat mer utrymme för filen, fixat felaktiga skrivrättigheter i katalogen, eller liknande). Det ska gå att försöka igen godtyckligt antal gånger.

  4. Testa att koden fortfarande fungerar när inga fel uppstår.

  5. Testa att provocera fram fel! Du kan till exempel ändra åtkomsträttigheterna så att katalogen där highscores ska sparas inte blir skrivbar (chmod 000 katalogen). Se till att felhanteringskoden faktiskt fungerar!

För dig som vill ha betyg 5 på PRA och kursen

[Betyg 5, efter GUI] Tetris 5.6: Paus i spelet

Uppgift: Paus i spelet

Se till att det går att göra en paus i spelet, och att fortsätta efter pausen. Det kan till exempel finnas en grafisk knapp att trycka på, eller en tangent man kan trycka.

Avgör själv hur detta ska lösas: Genom att manipulera timern eller genom att styra vad som händer när timern "tickar". Se till att lösningen blir välstrukturerad, så att inte fel delar av programmet behöver känna till varandra.

[Betyg 5, efter GUI] Tetris 5.7: Gradvis uppsnabbning

Uppgift: Gradvis uppsnabbning

Se till att spelet långsamt går snabbare och snabbare. Tick-takten kan t.ex. öka något för varje minut som går, eventuellt med en gräns där den inte ökar mer. Testa att ökningen känns lagom.

[Betyg 5, efter exceptions] Tetris 5.8: Säker skrivning

Om säker skrivning

När man skriver en highscorelista till en fil kan det hända att något går fel. Till exempel kan lagringsutrymmet vara fullt (kanske på grund av begränsad quota), eller så kan programmet krascha mitt i skrivningen. Om man då sparar filer genom att helt enkelt skriva över de gamla, förlorar man inte bara den nya informationen – man kan dessutom ha förstört den gamla filen utan att kunna få tillbaka den. Det är tråkigt om detta händer med highscores.

En enkel lösning på detta kan vara följande procedur:

  • Skriv det som ska sparas till en temporärfil med ett annat namn
  • Om detta lyckades: Ta bort den gamla filen. Döp sedan om temporärfilen till det önskade namnet. I och med att man lyckades spara hela temporärfilen är det betydligt mer osannolikt att något ska gå fel just när man döper om den.
  • Om det inte lyckades: Signalera fel.

Här kan man så klart också tänka sig mer avancerade metoder för att återhämta sig om man lyckades spara till temporärfilen men något faktiskt gick fel i de senare stegen, men det tittar vi inte på i den här uppgiften.

Uppgift: Säker skrivning

  1. Se till att highscorelistor sparas på säkert sätt, enligt ovan.

  2. Testa att detta verkligen fungerar, t.ex. genom att ändra filrättigheterna för katalogen där filerna sparas så att du (och därmed programmet) inte får skapa nya filer.

Under utveckling
Denna uppgift är under utveckling. Här kommer vi att testa säker skrivning till fil: Om vi bara skriver rakt över den gamla highscorefilen, kan vi ju tappa bort data om vi skulle misslyckas mitt i skrivningen.

[Betyg 5] Tetris 5.9: Powerups med State-mönstret

Om State-mönstret

Det är nu dags att titta på en teknik för att "plugga in" olika beteenden i en klass.

Grundtanken är att vi har en klass som har ett stabilt grundbeteende, men där vissa delar av beteendet kan ändras då och då, kanske ganska radikalt. Till exempel kanske vi skriver ett spel där spelaren kan vara i olika lägen beroende på om man har hittat vissa "powerups" som ger extra krafter. Det kan man ju ordna via vanliga villkorssatser:

public enum PlayerMode {
    NORMAL,	// Standard mode
    STRONGER,	// Stronger and less vulnerable
    FASTER,	// Runs faster
    GHOST, ...	// Can walk, run or jump through solid matter
}
public class Player {
    private PlayerMode mode;
    public void jump() {
        if (mode == PlayerMode.NORMAL) {
            // Ordinary jump
        } else if (mode == PlayerMode.STRONGER) { 
            // Jump a bit higher
        } else if (mode == PlayerMode.GHOST) {
            // Can jump through platforms from
            // below, land on platforms from above
        } ...
    }
    public void run() {
        ...
    }
}

Men att använda villkorssatser har vissa nackdelar:

  • Informationen om vad det innebär att vara STRONGER sprids ut i många metoder (jump, run, ...).

  • Varje gång man lägger till en ny typ av powerup måste man ändra Player-klassen!

  • Alltså går det inte heller att lägga till nya typer av powerups om man inte har källkoden till Player...

En idé kanske kunde vara att göra Player abstrakt och skapa nya konkreta subklasser: GhostPlayer, StrongPlayer, FastPlayer, och så vidare. Grundbeteendet skulle då ligga kvar i Player, medan varje subklass skulle ha sin egen implementation av jump() och run(). Men då måste vi byta ut spelarobjektet varje gång spelaren får en ny förmåga! Det går ju inte att byta klass på ett existerande objekt. Det vi vill ha är en spelare, vars beteende ändras.

Därför kan man ibland använda den alternativa lösningen att plugga in ett beteende.

public interface PowerupState {
    public void jump(Player p);
    public void run(Player p);
}
public class NormalState implements PowerupState  {
    public void jump(Player p) {
        // Make p jump a short distance
    }
    ...
}
public class GhostState implements PowerupState  {
    public void jump(Player p) {
        // Significant difference in code:
        // Jump through other objects
    }
    ...
}
public class Player {
    private PowerupState pstate;
    public Player() {
        this.pstate = new NormalState();
    }
    public void jump() {
       ...
       // Ask the state object to do the actual jump – delegate!
       pstate.jump(this);
       ...
    }
    ...
}

Det vill säga:

  • Vi vill kunna byta ut delar av beteendet hos ett objekt. Man kan säga att objektet är i flera olika tillstånd (state), och att varje tillstånd ger ett eget beteende.

  • Skillnaderna i beteende är inte sådana som enkelt representeras med ett enda värde – om bara hopplängden skulle vara olika, skulle vi helt enkelt ha ett fält "int hopplängd;".

    Det är inte heller så att vi bara vill ändra en eller ett par rader kod för att implementera skillnaden. Vi behöver en hel del separat kod för varje tillstånd.

  • Därför skapar vi ett gränssnitt som beskriver just det beteende vi vill byta ut: Hoppandet och springandet. Sedan skapar vi en klass för varje konkret variation av beteendet: Hoppa och spring på normalt sätt, hoppa och spring som ett spöke, och så vidare.

  • Player har ett objekt av den givna typen (PowerupState). Detta sätts från början till ett objekt som representerar någon form av standardbeteende (new NormalState()).

  • När man dynamiskt vill byta beteende: this.pstate = new FasterState(); eller board.setPowerupState(new FasterState());

    Fördelen är inte bara att koden delas upp i moduler, utan att man kan anropa en metod som setPowerupState med powerups som inte ens fanns när Board skrevs!

På det sättet använder man objektorientering med sen bindning (dynamisk dispatch) för att variera beteendet, istället för en villkorssats (if / switch): När man anropar pstate.jump() är det den för tillfället aktuella tillståndsklassens kod som körs.

Viktigt: Detta används alltså om man dynamiskt vill byta ut lite komplexare beteenden. Om man inte behöver göra detta, eller koden verkar bli alltför komplicerad av att använda detta mönster, är det antagligen bättre att låta bli!

Om gränssnitt och defaultimplementation

I Tetrisprojektet vill vi att man i vissa lägen ska kunna få "extra krafter" som hjälper till i spelet. Detta kallas ofta powerups.

Varje powerup påverkar spelmekaniken på ett specifikt sätt. Till exempel kan det finnas olika powerups som påverkar hanteringen av fall och kollisioner. I standardläget stannar ju en fallande Poly så snart den når ner till en kvadrat som redan finns på spelplanen. Vi kan också tänka oss att en Poly skulle falla rakt genom existerande kvadrater, så att den rensar en väg ner till ett hål som var svårt att fylla, eller att den skulle falla tungt så att de underliggande kvadraterna lossnar och ramlar så långt ner de kan.

Här vill vi alltså kunna plugga in olika beteenden när en Poly når ner till en existerande kvadrat. Vi skulle kunna göra detta med en enum-variabel som räknar upp alla tänkbara beteenden (NORMAL, FALLTHROUGH, HEAVY) tillsammans med en switch-sats som gör att koden beter sig olika beroende på nuvarande värdet på denna variabel. I så fall skulle vi centralisera kunskapen om alla existerande beteenden. Nu väljer vi istället att modularisera detta via State-mönstret som diskuterades ovan, så att varje beteende blir en egen klass.

Vi kommer därför att behöva ett gränssnitt för fallhanterare, och behöver flytta ut den nuvarande koden för kollisionshantering till en egen klass som implementerar detta gränssnitt.

Uppgift: Flytta kod för kollisionshantering

  1. Skapa ett gränssnitt som heter FallHandler. Detta ska ta över en del av funktionaliteten som just nu ligger i Board och bör därför ligga nära den klassen.

  2. Lägg till en signatur för en hasCollision()-metod motsvarande den du nu har i Board (men utan själva implementationen, eftersom detta är ett gränssnitt).

  3. Låt din nya hasCollision()-metod få en ny parameter, av typ Board. Fallhanteraren måste ju veta vilket spelbräde den ska leta efter kollisioner i!

  4. Skapa en klass som heter DefaultFallHandler som implementerar FallHandler. Flytta implementationen av hasCollision() från Board till den nya klassen. Lägg även här till den nya parametern.

    Den kod du just flyttade försöker titta på spelbrädesinformation i this, men this är ju numera en fall- och kollisionshanterare, inte ett Board. Därför behöver koden skrivas om en del så att den nya hasCollision() får information från det Board som den fick som parameter.

    Om du tidigare har implementerat hasCollision() så att den direkt använder sig av den interna representationen (SquareType[][]-arrayen) kan detta bli lite knepigt, eftersom vi inte vill att externa klasser som DefaultFallHandler ska ha tillgång till den representationen. Då får man istället skriva om den nya hasCollision() så att den använder sig av existerande, och kanske nya, getter-metoder för att hämta ut den information som krävs.

    Förtydligande: Skapa inte en metod som plockar ut den interna SquareType[][]-arrayen, utan använd mer finkorniga metoder såsom getWidth(), getHeight() och getVisibleSquareAt()!

  5. Ge Board ett privat fält av typ FallHandler. Sätt detta fält till ett objekt av typ DefaultFallHandler. Se till att Board anropar hasCollision() i detta objekt, istället för att försöka anropa hasCollision() i "sig själv".

  6. Testa! Om allt är korrekt ska spelet fortfarande fungera exakt som tidigare.

Uppgift: En första powerup

Nu är det dags att skapa en första powerup – ett första alternativ till DefaultFallHandler.

Din första powerup ska låta en Poly falla rakt ner, genom existerande kvadrater, ända till den når botten. Där kan den "täppa till" hål som har uppstått nära botten.

  1. Skapa klassen Fallthrough som implementerar FallHandler.

  2. Kopiera hasCollision()-koden från DefaultFallHandler.

  3. Modifiera koden så att:

    • Om den fallande brickan överlappar OUTSIDE, detekteras en kollision.

    • Om den fallande brickan överlappar någon annan kvadrat, ignoreras detta.

    När brickan har nått botten ska dess kvadrater ersätta det som fanns på den tidigare positionen. Detta är ungefär vad som har hänt tidigare när en bricka faller. Skillnaden är att det som ersätts inte bara är EMPTY utan även kan vara andra kvadrater.

  4. Hitta på ett sätt att trigga denna Powerup. Till exempel kan man få en Fallthrough var tionde Poly, eller varannan gång man har tagit bort en rad, eller något annat villkor. (Ofta får man powerups när man "plockar upp" dem någonstans på skärmen, men detta kan vara onödigt komplicerat att implementera.)

    När Board triggar en Powerup ska den alltså helt enkelt sätta om fallhanteraren till en new Fallthrough(). När nästa Poly har fallit ner ska Board sätta tillbaka fallhanteraren till en new DefaultFallHandler().

    Se till att skärmen hela tiden visar om man har en powerup, och i så fall vilken. Man kan t.ex. lägga till en metod getDescription() i FallHandler, så att varje FallHandler (inklusive default) kan ge en egen beskrivning av sig själv.

  5. Testa!

  6. Fick DefaultFallHandler och Fallthrough onödigt mycket gemensam, repeterad kod? Öva gärna på att skapa en gemensam (abstrakt) superklass där det gemensamma kan representeras. Duplicerad kod kan ge komplettering.

Uppgift: En andra powerup

För att verkligen utforska det här sättet att programmera borde vi skapa en powerup till.

Skapa en powerup där den Poly som faller är extremt tung, så att den knackar loss underliggande kvadrater och trycker dem nedåt. Detta kan vara en väldigt kraftfull powerup, eftersom ett tryck på rätt plats kan få många rader att bli fulla på samma gång.

  1. Vi vill att en Poly ska kunna trycka ner kvadrater när den flyttas nedåt, men inte när den flyttas i sidled.

    Just nu vet inte kollisionshanteraren hur falling har flyttats, bara vilken position falling har just nu. Som förberedelse behöver detta åtgärdas. Till exempel kan man ändra hasCollision() så att den får blockets gamla position som parameter. Här kan man med fördel använda refactoring i IDEA.

  2. Skapa en ny sorts fallhanterare: Heavy. Se till att den "trycker ner" kvadrater enligt följande:

    • Om den fallande brickan inte har fallit rakt nedåt, används de vanliga kollisionsreglerna. Se till att den koden inte behöver upprepas utan att alla som vill ha "vanlig kollisionshantering" kan anropa samma kod!

    • Annars har vi alltså ett fall rakt nedåt jämfört med förra positionen. Om den fallande brickan då överlappar OUTSIDE, detekteras en kollision.

    • Om den fallande brickan överlappar någon annan sorts kvadrat, vill vi se om detta kan "fixas" genom att samtliga överlappande kvadrater "trycks ner".

      Man behöver alltså först testa om det för samtliga överlappande kvadrater finns tomma hål längre ner i samma kolumn.

      Om detta är falskt, detekterar vi en kollision så att den fallande brickan fastnar (översta bilden nedan).

      Om det istället är sant, ska samtliga överlappande gamla kvadrater tryckas ner ett steg (mittersta bilden nedan) och ingen kollision rapporteras. Detta kan i sin tur trycka ner andra kvadrater ett steg, men bara fram till nästa hål i denna kolumn (mittersta bilden visar hur två kvadrater trycks ner ett steg på samma gång).

      Sedan går spelet automatiskt vidare och försöker flytta brickan ytterligare ett steg nedåt, vilket kan trycka ner kvadrater ytterligare steg om det finns flera hål (tredje bilden nedan).

      OBS: Tänk på vad vi har sagt om vad som behöver göras i vilken ordning. Man behöver t.ex. verkligen vara säker på att samtliga överlappande kvadrater kan tryckas ner innan man börjar med att flytta vissa kvadrater. Annars kan man trycka ner en del kvadrater och sedan upptäcka att en annan del av den fallande brickan faktiskt har stöd.

  3. Denna hanterare kommer också att behöva kollapsa rader, om de kvadrater som "lossnar" ramlar ner och fyller rader. I exemplet nedan bildar de gröna brickorna en sådan rad.

    Detta kan göras genom att man inför en särskild metod i Board för att kollapsa en given rad, och anropar detta från fallhanteraren.

  4. Se till att Heavy kan triggas på något sätt.

  5. Testa och iterera till du är nöjd!

Avslutning

När du är klar med alla uppgifter du tänker genomföra demonstrerar du hela slutresultatet för din handledare.

Därefter kan du följa inlämningsproceduren.

Labb av Jonas Kvarnström, Mikael Nilsson 2014–2024.


Sidansvarig: Jonas Kvarnström
Senast uppdaterad: 2024-02-04