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 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
-
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.
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 viaSystem.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
-
Du har fått ett IDEA-projekt som redan innehåller "resurskataloger" –
resources/audio
ochresources/images
. Filerna i de här katalogerna är inte källkod som ska kompileras, men i IDEA:s projektdefinition är de markerade somresurser
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 iimages
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.
-
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
ochClassLoader.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 filenfoo.png
direkt iresources/images
ska du ange namnetimages/foo.png
. 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...
Bestäm en lämplig plats / klass där nuvarande poäng kan lagras.
Se till att poängen uppdateras enligt ovan.
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
Singletonkunde 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, unikaHigscoreList
-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
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 dessdispose()
-metod). Se till att detta fungerar.Skapa en
Highscore
-klass som lagrar antal poäng plus namn på den som fick poängen.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.-
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 iBoard
, utan den kan t.ex. vara den som skaparBoard
som också skapar listan. Annars skulle ju highscores "nollställas" varje gång man skapar ett nyttBoard
som har en egen nyHighscoreList
! 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.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
Skapa en jämförarklass, en
ScoreComparator
, enligt ovan.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).
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.
Uppgift: Spara och läsa in highscores
Första steget blir att skriva highscores till fil efter ett avslutat spel.
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.
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?
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å.
Testa. Spela ett spel, skriv in en highscore, och titta manuellt på filen så att den faktiskt sparas och ser ut som den ska.
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 godtyckligReader
, t.ex. enFileReader
, som den själv kan läsa in strängen från (det behöver inte vara enJsonReader
).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 nyHighscoreList
, så det skulle leda till en oändlig loop.)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.
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.
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
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.
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.
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.
Testa att koden fortfarande fungerar när inga fel uppstår.
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
Se till att highscorelistor sparas på säkert sätt, enligt ovan.
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.
[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();
ellerboard.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ärBoard
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
-
Skapa ett gränssnitt som heter
FallHandler
. Detta ska ta över en del av funktionaliteten som just nu ligger iBoard
och bör därför ligga nära den klassen. -
Lägg till en signatur för en
hasCollision()
-metod motsvarande den du nu har iBoard
(men utan själva implementationen, eftersom detta är ett gränssnitt). -
Låt din nya
hasCollision()
-metod få en ny parameter, av typBoard
. Fallhanteraren måste ju veta vilket spelbräde den ska leta efter kollisioner i! -
Skapa en klass som heter
DefaultFallHandler
som implementerar FallHandler. Flytta implementationen avhasCollision()
frånBoard
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
, menthis
är ju numera en fall- och kollisionshanterare, inte ettBoard
. Därför behöver koden skrivas om en del så att den nyahasCollision()
får information från detBoard
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 somDefaultFallHandler
ska ha tillgång till den representationen. Då får man istället skriva om den nyahasCollision()
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åsomgetWidth()
,getHeight()
ochgetVisibleSquareAt()
! -
Ge
Board
ett privat fält av typFallHandler
. Sätt detta fält till ett objekt av typDefaultFallHandler
. Se till att Board anroparhasCollision()
i detta objekt, istället för att försöka anropahasCollision()
i "sig själv". -
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.
Skapa klassen
Fallthrough
som implementerarFallHandler
.Kopiera
hasCollision()
-koden frånDefaultFallHandler
.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.
-
Hitta på ett sätt att trigga denna Powerup. Till exempel kan man få en
Fallthrough
var tiondePoly
, 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 ennew Fallthrough()
. När nästaPoly
har fallit ner skaBoard
sätta tillbaka fallhanteraren till ennew 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()
iFallHandler
, så att varjeFallHandler
(inklusive default) kan ge en egen beskrivning av sig själv. -
Testa!
-
Fick
DefaultFallHandler
ochFallthrough
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.
-
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 positionfalling
har just nu. Som förberedelse behöver detta åtgärdas. Till exempel kan man ändrahasCollision()
så att den får blockets gamla position som parameter. Här kan man med fördel använda refactoring i IDEA. -
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.
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.-
Se till att
Heavy
kan triggas på något sätt. -
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