Labb 5: För högre projektbetyg
För att få betyg 4, 5 eller VG 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, 5 respektive VG. De uppgifterna är upplagda så att man kan demonstrera kunskaperna på ett 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/5/VG 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.
Att göra 5.1.1: 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.
Att göra 5.2: Resurshantering
Skapa mappen pics på samma nivå som src och libs i din projektkatalog.
Högerklicka pics i IDEA och välj Mark Directory as | Resources Root. Detta gör att filerna i pics inte bara kommer att finnas i din källkatalog, utan även kommer att kopieras över till det kompilerade resultatet.
Lägg in en bildfil i pics, från kursen eller var som helst, och addera den till Git. Ta gärna hänsyn till vad andra kan tycka är trevligt att se...
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.Om du har lagt bilden foo.png direkt i pics ska du ange namnet /foo.png. Det är bara om du har lagt den i en underkatalog till resurskatalogen pics som du ska ange en katalog som del av resursnamnet.
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
Att förstå 5.3.1: 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.
Att göra 5.3.1: Poänghantering
Inför poänghantering i spelet enligt poängsättningen ovan.
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.
Att förstå 5.3.2: Highscorelista
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.
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.
Att göra 5.3.2: 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.
Att förstå 5.3.3: 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());
Att göra 5.3.3: 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.
Att förstå 5.4.1: 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 används numera inom många språk. 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, precis som
Pythons dict
, och värden inom []
är
listor, precis som Pythons listor. 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" } ]
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.
Att göra 5.4.1: Spara och läsa in highscores
Första steget blir att skriva highscores till fil efter ett avslutat spel.
Spara Gson-biblioteket någonstans i ditt projekt. Du kan t.ex. skapa en ny libs-katalog bredvid din src-katalog och spara biblioteket där.
Addera filen till Git så det checkas in tillsammans med övriga filer.
Högerklicka den i IDEA, välj "Add project library" och tryck OK:
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.
[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.
Att förstå 5.5: 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).
Att göra 5.5: 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/VG på PRA och kursen
För betyg VG krävs bara vissa av dessa (se nedan).
[Betyg 5/VG, efter GUI] Tetris 5.6: Paus i spelet
Att göra 5.6: 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/VG, efter GUI] Tetris 5.7: Gradvis uppsnabbning
Att göra 5.7: 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/VG, efter exceptions] Tetris 5.8: Säker skrivning
Att förstå 5.8: 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.
Att göra 5.8: 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, ej för VG] Tetris 5.9: Powerups med State-mönstret
Att förstå: 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!
Att förstå 5.9.1: 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.
Att göra 5.9.1: 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()
ochgetSquareAt()
! -
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.
5.9.2: 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.
5.9.3: 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–2020.
Sidansvarig: Jonas Kvarnström
Senast uppdaterad: 2020-02-05